dsh-plugin-deck-studio 0.2.8 → 0.2.9

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/index.js CHANGED
@@ -11,7 +11,8 @@
11
11
 
12
12
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
13
13
  import { createHash } from "node:crypto";
14
- import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
14
+ import { execFile } from "node:child_process";
15
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
15
16
  import { PLUGIN_DIR, mergeConfig, repoRootFrom } from "./lib/config.js";
16
17
  import { createRuntimeStore } from "./lib/store.js";
17
18
  import { createDeckWorkspace, scriptSectionRange } from "./lib/deck-workspace.js";
@@ -33,7 +34,26 @@ import { appendSampleRecord, readSampleRecords, readStyleDefinition, writeStyleD
33
34
  import { resolveMediaFile, scanDesignStyles, scanResources } from "./lib/home-catalog.js";
34
35
  import { defaultSelection, renderModelPayload, resolveSelection } from "./lib/render-nodes.js";
35
36
  import { applyRenderSettings, readRenderSettings, renderSettingsPayload, saveRenderSettings } from "./lib/render-settings.js";
36
- import { editEndpointFor, selectRenderer } from "./lib/render.js";
37
+ import { buildPrompt, editEndpointFor, selectRenderer } from "./lib/render.js";
38
+ import { readDeckBrief } from "./lib/deck-brief.js";
39
+ import { detectProfileDir, installModeOf, isNewer, latestFromRegistry, repoLinksOf } from "./lib/update-check.js";
40
+
41
+ /** 跑一个外部命令(异步、带超时、合并 stdout/stderr;失败时把输出带进错误信息)。 */
42
+ function runCommand(command, args, { cwd = "", timeoutMs = 120000 } = {}) {
43
+ return new Promise((resolve, reject) => {
44
+ execFile(command, args, { cwd: cwd || undefined, timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024, env: process.env }, (error, stdout, stderr) => {
45
+ const output = `${stdout ?? ""}${stderr ?? ""}`.trim();
46
+ if (error) {
47
+ const failure = new Error(`${command} ${args.join(" ")} 失败:${error.message}${output ? `
48
+ ${output.slice(-2000)}` : ""}`);
49
+ failure.output = output;
50
+ reject(failure);
51
+ return;
52
+ }
53
+ resolve(output);
54
+ });
55
+ });
56
+ }
37
57
 
38
58
  /** 稳定 Cordis 插件名。 */
39
59
  export const name = "deck-studio";
@@ -51,6 +71,7 @@ export const ROUTES = {
51
71
  docfile: "/api/deck-studio.docfile",
52
72
  export: "/api/deck-studio.export",
53
73
  pageEdit: "/api/deck-studio.page-edit",
74
+ pagePrompt: "/api/deck-studio.page-prompt",
54
75
  pageSave: "/api/deck-studio.page-save",
55
76
  media: "/api/deck-studio.media",
56
77
  renderModel: "/api/deck-studio.render-model",
@@ -78,6 +99,9 @@ export const ROUTES = {
78
99
  manual: "/api/deck-studio.manual",
79
100
  manualDoc: "/api/deck-studio.manual-doc",
80
101
  docsAsset: "/api/deck-studio.docs-asset",
102
+ // F-188:顶栏「更新」按钮——查 npm 最新版 / 一键更新到最新版(只在 npm 安装模式下执行)
103
+ updateCheck: "/api/deck-studio.update-check",
104
+ update: "/api/deck-studio.update",
81
105
  };
82
106
 
83
107
  /** 出图资产 MIME(image deck 只有图片;svg 仅 fake 测试用)。 */
@@ -198,6 +222,117 @@ function pluginVersion() {
198
222
  return versionCache;
199
223
  }
200
224
 
225
+ /** 插件自己的 package.json(版本 / 仓库链接来源;只读一次)。 */
226
+ let pluginMetaCache = null;
227
+ function pluginMeta() {
228
+ if (pluginMetaCache) return pluginMetaCache;
229
+ let pkg = {};
230
+ try {
231
+ pkg = JSON.parse(readFileSync(join(PLUGIN_DIR, "package.json"), "utf8"));
232
+ } catch {
233
+ pkg = {};
234
+ }
235
+ pluginMetaCache = {
236
+ name: String(pkg.name ?? "dsh-plugin-deck-studio"),
237
+ version: String(pkg.version ?? pluginVersion()),
238
+ links: repoLinksOf(pkg),
239
+ };
240
+ return pluginMetaCache;
241
+ }
242
+
243
+ /** 本机 profile 目录:先按插件真实路径反推(npm 布局),再退回 DSH 注入的 env。 */
244
+ function profileDirHint() {
245
+ const env = typeof process !== "undefined" ? process.env : {};
246
+ const meta = pluginMeta();
247
+ const found = detectProfileDir({ pluginDir: PLUGIN_DIR, packageName: meta.name, env });
248
+ if (found) return found;
249
+ const home = env.DSH_HOME ? String(env.DSH_HOME) : "";
250
+ const profile = env.DSH_PROFILE ? String(env.DSH_PROFILE) : "";
251
+ return home && profile ? join(home, "profiles", profile) : "";
252
+ }
253
+
254
+ /** 更新相关的静态信息(不发网络请求;workbench 首屏用)。 */
255
+ function updateBrief() {
256
+ const meta = pluginMeta();
257
+ const install = installModeOf({ profileDir: profileDirHint(), packageName: meta.name });
258
+ const env = typeof process !== "undefined" ? process.env : {};
259
+ return {
260
+ currentVersion: meta.version,
261
+ installMode: install.mode,
262
+ installSpec: install.spec,
263
+ installReason: install.reason,
264
+ profile: (() => {
265
+ const dir = profileDirHint();
266
+ return dir ? basename(dir) : String(env.DSH_PROFILE ?? "");
267
+ })(),
268
+ repo: meta.links.repo,
269
+ issues: meta.links.issues,
270
+ suggest: meta.links.suggest,
271
+ registry: `https://registry.npmjs.org/${encodeURIComponent(meta.name)}`,
272
+ };
273
+ }
274
+
275
+ /** 查 npm 上的最新版本(6s 超时;离线 / 超时不抛,只在 payload 里报错)。 */
276
+ async function updateStatus() {
277
+ const brief = updateBrief();
278
+ const payload = { ...brief, latestVersion: "", hasUpdate: false, checkedAt: new Date().toISOString(), error: "" };
279
+ try {
280
+ const res = await fetch(`${brief.registry}/latest`, {
281
+ headers: { accept: "application/json" },
282
+ signal: AbortSignal.timeout(12000),
283
+ });
284
+ if (!res.ok) throw new Error(`registry HTTP ${res.status}`);
285
+ const latest = latestFromRegistry(await res.json());
286
+ if (!latest) throw new Error("registry 返回里没有 latest 版本");
287
+ payload.latestVersion = latest;
288
+ payload.hasUpdate = isNewer(latest, brief.currentVersion);
289
+ payload.command = brief.installMode === "npm"
290
+ ? `dsh plugin --profile ${brief.profile || "<profile>"} add ${pluginMeta().name}@${latest}`
291
+ : "";
292
+ if (brief.installMode === "linked") payload.note = "本机是开发链接安装,更新走仓库(git pull);界面不执行 pnpm。";
293
+ } catch (error) {
294
+ const message = String(error?.message ?? error);
295
+ const timedOut = error?.name === "TimeoutError" || /abort|timeout/i.test(message);
296
+ payload.error = timedOut
297
+ ? "检查更新超时:连不上 npm registry(网络 / 代理问题),可以直接用命令手动更新。"
298
+ : `检查更新失败:${message}`;
299
+ }
300
+ return payload;
301
+ }
302
+
303
+ /** 一键更新:npm 安装模式下在 profile 目录跑 pnpm add <name>@<latest>(超时 240s)。 */
304
+ async function runSelfUpdate() {
305
+ const brief = updateBrief();
306
+ if (brief.installMode !== "npm") {
307
+ throw new Error(
308
+ brief.installMode === "linked"
309
+ ? "本机是开发链接安装(link:/file:),界面不执行更新;请在仓库里 git pull 后重启 DSH。"
310
+ : "没识别出 profile 的安装方式,无法自动更新;请手动执行 dsh plugin --profile <profile> add dsh-plugin-deck-studio@latest。",
311
+ );
312
+ }
313
+ const status = await updateStatus();
314
+ if (status.error) throw new Error(status.error);
315
+ if (!status.latestVersion) throw new Error("没查到 npm 上的最新版本");
316
+ const target = `${pluginMeta().name}@${status.latestVersion}`;
317
+ const output = await runCommand("pnpm", ["add", target], { cwd: brief.profile, timeoutMs: 240000 });
318
+ store.recordEvent({
319
+ type: "plugin.update",
320
+ deckId: "",
321
+ actor: "user",
322
+ status: "succeeded",
323
+ payload: { from: brief.currentVersion, to: status.latestVersion, target },
324
+ });
325
+ return {
326
+ ok: true,
327
+ from: brief.currentVersion,
328
+ to: status.latestVersion,
329
+ target,
330
+ restarted: false,
331
+ restartHint: "更新已写入 profile,重启 DSH 后生效(当前进程仍是旧版本)。",
332
+ output,
333
+ };
334
+ }
335
+
201
336
  function json(payload, status = 200) {
202
337
  return Response.json(payload, { status, headers: { "cache-control": "no-store" } });
203
338
  }
@@ -1464,6 +1599,39 @@ export function createService(config, logger, env = process.env) {
1464
1599
  return pageEditView(adapter, pageId);
1465
1600
  }
1466
1601
 
1602
+ /**
1603
+ * F-189:某页「拼装后的完整出图 prompt」(只读,脚本页折叠展示用)。
1604
+ * 与出图工具同一套拼装:deck 关联 design 的 styleBrief + 图上文字清单 + 讲述用正文 + 画面要求。
1605
+ */
1606
+ function pagePrompt(deckId, pageId) {
1607
+ const adapter = adapterOf(deckId);
1608
+ if (!adapter?.available()) return null;
1609
+ const deck = workspace.manifest(deckId);
1610
+ const normalized = String(pageId ?? "").trim();
1611
+ const slide = (deck?.slides ?? []).find((item) => item.id === normalized || `p${String(item.order).padStart(2, "0")}` === normalized);
1612
+ if (!slide) return null;
1613
+ let styleBrief = config.styleBrief;
1614
+ const association = designRegistry.deckDesignFor(deckId);
1615
+ const design = association?.designId ? designRegistry.findDesign(association.designId) : null;
1616
+ if (design) {
1617
+ let definition = null;
1618
+ try {
1619
+ definition = readStyleDefinition(adapter.sourceRoot, design.abbr ?? "", design);
1620
+ } catch {
1621
+ definition = null;
1622
+ }
1623
+ const brief = String(design.styleBrief || definition?.styleBrief || definition?.description || design.description || "").trim();
1624
+ const constraints = String(design.constraints || definition?.constraints || "").trim();
1625
+ if (brief) styleBrief = [brief, constraints ? `不要什么:${constraints}` : ""].filter(Boolean).join("\n");
1626
+ }
1627
+ return {
1628
+ deckId,
1629
+ pageId: slide.id,
1630
+ design: design?.abbr ?? null,
1631
+ prompt: buildPrompt(slide, styleBrief, { brief: readDeckBrief(adapter.sourceRoot) }),
1632
+ };
1633
+ }
1634
+
1467
1635
  /**
1468
1636
  * 页面微调保存:写回源 script.md / prompts/Pnn.txt,重建 manifest,并自动 git 提交。
1469
1637
  * 这是唯一允许直接写源 deck 的动作(ADR-0006 的例外,见 ADR-0008)。
@@ -2105,6 +2273,8 @@ export function createService(config, logger, env = process.env) {
2105
2273
  designProjects: designProjects(),
2106
2274
  bindings: listBindings(),
2107
2275
  proposals: base.pendingProposals,
2276
+ // F-188:顶栏「更新 / GitHub」按钮首屏数据(不打网络,检查更新另调 update-check)
2277
+ update: updateBrief(),
2108
2278
  };
2109
2279
  }
2110
2280
 
@@ -2114,7 +2284,7 @@ export function createService(config, logger, env = process.env) {
2114
2284
  bootstrapDesignStyles();
2115
2285
  }
2116
2286
 
2117
- return { config, store, workspace, adapters, status, state, workbench, readManual, readUserName: () => config.userName, saveUserName, readDoc, docsAsset, setDeckDensity, renderSettings, saveRenderConfig, isLocalDeck: (deckId) => localDeckIds().has(String(deckId ?? "")), assetFile, slideRenderInfo, deckCoverUrl, deckRevisions, deckDocs, deckDoc, deckDocsScaffold, deckDocFile, pageForEdit, savePage, exportDeckFile, designSamples, sourceFile, homeCatalog, mediaFile, renderState, setRenderSelection, renderRuntime, createDeck, createDesign, createStyleFromDeck, designProjects, designStyle, updateDesignStyle, backfillStyleBriefs, listBindings, bindSession, unbindSession, deleteDesign, deleteDeck, setDeckDesign, addDesignSamples, seedStyleDesigns, renderStatus, exportHandoff }
2287
+ return { config, store, workspace, adapters, status, state, workbench, readManual, readUserName: () => config.userName, saveUserName, readDoc, docsAsset, setDeckDensity, renderSettings, saveRenderConfig, isLocalDeck: (deckId) => localDeckIds().has(String(deckId ?? "")), assetFile, slideRenderInfo, deckCoverUrl, deckRevisions, deckDocs, deckDoc, deckDocsScaffold, deckDocFile, pageForEdit, pagePrompt, savePage, exportDeckFile, designSamples, sourceFile, homeCatalog, mediaFile, renderState, setRenderSelection, renderRuntime, createDeck, createDesign, createStyleFromDeck, designProjects, designStyle, updateDesignStyle, backfillStyleBriefs, listBindings, bindSession, unbindSession, deleteDesign, deleteDeck, setDeckDesign, addDesignSamples, seedStyleDesigns, renderStatus, exportHandoff, updateBrief, updateStatus, runSelfUpdate }
2118
2288
  }
2119
2289
 
2120
2290
  /**
@@ -2191,6 +2361,12 @@ export function apply(ctx, rawConfig) {
2191
2361
  return view ? json({ ok: true, ...view }) : json({ ok: false, error: { message: "page not found" } }, 404);
2192
2362
  });
2193
2363
 
2364
+ registerRoute(ROUTES.pagePrompt, (request) => {
2365
+ const url = new URL(request?.url ?? "/", "http://localhost");
2366
+ const view = service.pagePrompt(url.searchParams.get("deckId") ?? "", url.searchParams.get("pageId") ?? "");
2367
+ return view ? json({ ok: true, ...view }) : json({ ok: false, error: { message: "page not found" } }, 404);
2368
+ });
2369
+
2194
2370
  registerRoute(ROUTES.pageSave, async (request) => {
2195
2371
  const body = await request.json().catch(() => ({}));
2196
2372
  const result = service.savePage(body.deckId ?? "", body.pageId ?? "", body.fields ?? body);
@@ -2415,6 +2591,19 @@ export function apply(ctx, rawConfig) {
2415
2591
  });
2416
2592
  });
2417
2593
 
2594
+ // F-188:顶栏「更新」按钮——GET 查 npm 最新版(6s 超时,离线只报错不抛)。
2595
+ registerRoute(ROUTES.updateCheck, async () => json({ ok: true, ...(await service.updateStatus()) }), ["GET"]);
2596
+
2597
+ // F-188:一键更新到最新版(只在 npm 安装模式执行;开发链接安装返回 400 说明原因)。
2598
+ registerRoute(ROUTES.update, async (request) => {
2599
+ if ((request?.method ?? "POST").toUpperCase() !== "POST") return json({ ok: false, error: { message: "POST only" } }, 405);
2600
+ try {
2601
+ return json(await service.runSelfUpdate());
2602
+ } catch (error) {
2603
+ return json({ ok: false, error: { message: String(error?.message ?? error) } }, 400);
2604
+ }
2605
+ }, ["POST"]);
2606
+
2418
2607
  registerRoute(ROUTES.faq, (request) => {
2419
2608
  const url = new URL(request?.url ?? "/", "http://localhost");
2420
2609
  const query = String(url.searchParams.get("q") ?? "").trim();
package/lib/render.js CHANGED
@@ -19,37 +19,119 @@ const SECRET_PATTERNS = [
19
19
  [/password\s*[=::]\s*\S+/i, "password 赋值"],
20
20
  ];
21
21
 
22
+ /**
23
+ * 从正文里抽「要点」短句(F-189):图上只放短句,不排长段。
24
+ * 切块优先级:空行分段 → ①②③ 编号 → 句号;每块取冒号前的标签 + 后半句短描述,压到 ~28 字。
25
+ * @param {string} body
26
+ * @param {number} [limit]
27
+ * @returns {string[]}
28
+ */
29
+ /** 把一句压到 limit 字内:优先在标点处断(不出现省略号——画面上的省略号会被当成内容印出来)。 */
30
+ function shortenClause(text, limit = 28) {
31
+ const value = String(text ?? "").trim();
32
+ if (value.length <= limit) return value;
33
+ const cut = value.slice(0, limit);
34
+ let best = -1;
35
+ for (const punct of [",", "、", ";", ":", "。"]) {
36
+ best = Math.max(best, cut.lastIndexOf(punct));
37
+ }
38
+ const base = best >= 10 ? cut.slice(0, best) : cut;
39
+ return base.replace(/[,、;:。]$/u, "").trim();
40
+ }
41
+
42
+ export function bodyKeyPoints(body, limit = 4) {
43
+ const text = String(body ?? "").trim();
44
+ if (!text) return [];
45
+ const blocks = text
46
+ .split(/\n{2,}|[①②③④⑤⑥⑦⑧⑨⑩]/u)
47
+ .map((block) => block.trim())
48
+ .filter(Boolean);
49
+ const points = [];
50
+ for (const block of blocks) {
51
+ const first = block.split(/(?<=[。!?])/u)[0].trim().replace(/[。!?]$/u, "");
52
+ if (!first) continue;
53
+ // 「标签(补充):说明」→ 保留标签 + 说明的首个短句,图上才读得完
54
+ const match = first.match(/^([^:]{1,14}:)\s*(.+)$/u);
55
+ const point = match ? `${match[1]}${shortenClause(match[2], 20)}` : shortenClause(first);
56
+ if (point) points.push(point);
57
+ if (points.length >= limit) break;
58
+ }
59
+ return points;
60
+ }
61
+
62
+ /**
63
+ * 「这一页必须在画面上印出的文字」清单(F-189)。
64
+ * 出图模型只认「要印什么」,把字段名换成口语会漏印——所以这里给出逐字清单,供 prompt 与 QA 共用。
65
+ * @param {object} slide
66
+ * @returns {{title:string, subtitle:string, claim:string, bullets:string[]}}
67
+ */
68
+ export function onImageText(slide) {
69
+ const text = (value) => String(value ?? "").trim();
70
+ const title = text(slide?.title);
71
+ const subtitle = text(slide?.question);
72
+ const claim = text(slide?.claim);
73
+ return {
74
+ title,
75
+ subtitle,
76
+ // 主张超过 40 字就别整句塞画面(读不完),让副标题 + 要点承担信息。
77
+ claim: claim.length > 40 ? `${claim.slice(0, 39)}…` : claim,
78
+ bullets: bodyKeyPoints(slide?.body),
79
+ };
80
+ }
81
+
82
+ const ON_IMAGE_HEAD = "【这一页必须在画面上印出的文字:逐字照排,不要改写、翻译、缩写或省略】";
83
+
84
+ /** 把清单渲染成 prompt 行(QA 只检查 head 存在与关键行逐字命中)。 */
85
+ function onImageLines(list) {
86
+ const lines = [ON_IMAGE_HEAD];
87
+ if (list.title) lines.push(`主标题:${list.title}`);
88
+ if (list.subtitle) lines.push(`副标题(标题下方一行,字号小于主标题):${list.subtitle}`);
89
+ if (list.claim) lines.push(`一句话主张:${list.claim}`);
90
+ if (list.bullets.length) {
91
+ lines.push("要点(逐条列出,每条一行短句):");
92
+ list.bullets.forEach((point, index) => lines.push(`${index + 1}. ${point}`));
93
+ }
94
+ return lines;
95
+ }
96
+
22
97
  /**
23
98
  * 由 slide 字段 + 全局风格 preset 组 prompt。
24
99
  * F-158:封面页单列一套版式纪律(只放标题/副标题/作者/日期,不堆正文);
25
100
  * 普通页带上 intent 的受众 / 目的,避免「图很漂亮但讲错人」。
101
+ * F-189:文字不再是「语境」,而是显式「图上文字清单」——副标题 / 主张 / 要点逐字列出,
102
+ * 并声明与 visualBrief 的排他指令冲突时文字优先(否则「画面上只出现 XX 标签」会把副标题吞掉)。
26
103
  * @param {object} slide manifest slide
27
104
  * @param {string} styleBrief
28
105
  * @param {{brief?: object}} [context] readDeckBrief() 的结果
29
106
  */
30
107
  export function buildPrompt(slide, styleBrief, context = {}) {
31
108
  const brief = context.brief ?? {};
109
+ const list = onImageText(slide);
32
110
  const isCover = slide?.type === "cover" || Number(slide?.order) === 1;
33
111
  if (isCover) {
34
- const parts = [styleBrief, "封面页(16:9):只放标题信息", `主标题:${slide?.title ?? ""}`];
35
- if (slide?.question) parts.push(`副标题:${slide.question}`);
112
+ const parts = [styleBrief, "封面页(16:9):只放标题信息", ...onImageLines({ ...list, bullets: [] })];
36
113
  parts.push(...coverPromptLines(brief));
37
114
  parts.push("版式纪律:只放主标题 / 副标题 / 作者 / 日期;正文不超过一行;不要目录、要点清单、数据图表、大段文字;留白为主。");
38
115
  if (slide?.visualBrief) parts.push(`画面要求:${slide.visualBrief}`);
116
+ parts.push("文字优先:上面【图上文字】的每一行都必须出现在画面里;画面要求与它冲突时,以上面为准。");
39
117
  return parts.filter(Boolean).join("\n");
40
118
  }
41
- const parts = [styleBrief, `页面标题:${slide.title ?? ""}`];
42
- if (slide.question) parts.push(`这页回答:${slide.question}`);
43
- if (slide.claim) parts.push(`主张:${slide.claim}`);
44
- const body = String(slide.body ?? "").trim();
45
- if (body) parts.push(`正文要点:${body.slice(0, 300)}`);
46
- if (slide.visualBrief) parts.push(`画面要求:${slide.visualBrief}`);
119
+ const parts = [styleBrief, ...onImageLines(list)];
120
+ const body = String(slide?.body ?? "").trim();
121
+ if (body) parts.push(`讲述用正文(不要整段排进画面,只提炼成上面的要点;用来把握语气与例子):${body.slice(0, 300)}`);
122
+ if (slide?.visualBrief) parts.push(`画面要求:${slide.visualBrief}`);
123
+ parts.push("文字优先:上面【图上文字】的每一行都必须印出来(可换行、可分层,但不得省略);画面要求若写「只出现 XX」,XX 之外仍要保留主标题与副标题。");
47
124
  parts.push(...briefPromptLines(brief));
48
125
  return parts.filter(Boolean).join("\n");
49
126
  }
50
127
 
51
- /** QA 门禁。返回 {ok, checks:[{name, ok, detail}]}。 */
52
- export function qaCheck(prompt, size) {
128
+ /**
129
+ * QA 门禁。返回 {ok, checks:[{name, ok, detail}]}。
130
+ * @param {string} prompt 最终 prompt
131
+ * @param {string} size 尺寸
132
+ * @param {object} [slide] 传了就额外检查「图上文字」是否都在 prompt 里(F-189)
133
+ */
134
+ export function qaCheck(prompt, size, slide) {
53
135
  const checks = [];
54
136
  const promptOk = typeof prompt === "string" && prompt.trim().length >= 8;
55
137
  checks.push({ name: "prompt-exists", ok: promptOk, detail: promptOk ? `${prompt.trim().length} chars` : "prompt 为空或过短" });
@@ -57,6 +139,15 @@ export function qaCheck(prompt, size) {
57
139
  checks.push({ name: "size-valid", ok: sizeOk, detail: String(size ?? "") });
58
140
  const secretHit = SECRET_PATTERNS.find(([pattern]) => pattern.test(prompt ?? ""));
59
141
  checks.push({ name: "no-secrets", ok: !secretHit, detail: secretHit ? `疑似敏感串:${secretHit[1]}` : "clean" });
142
+ if (slide) {
143
+ const list = onImageText(slide);
144
+ const missing = [];
145
+ if (!String(prompt ?? "").includes(ON_IMAGE_HEAD)) missing.push("图上文字清单");
146
+ if (list.title && !String(prompt ?? "").includes(list.title)) missing.push("主标题");
147
+ if (list.subtitle && !String(prompt ?? "").includes(list.subtitle)) missing.push("副标题");
148
+ if (list.claim && !String(prompt ?? "").includes(list.claim)) missing.push("主张");
149
+ checks.push({ name: "on-image-text", ok: missing.length === 0, detail: missing.length ? `prompt 缺:${missing.join(" / ")}` : `${list.title ? "标题" : ""}${list.subtitle ? "+副标题" : ""}${list.claim ? "+主张" : ""}${list.bullets.length ? `+${list.bullets.length} 要点` : ""}` || "clean" });
150
+ }
60
151
  return { ok: checks.every((check) => check.ok), checks };
61
152
  }
62
153
 
package/lib/tools.js CHANGED
@@ -1214,7 +1214,7 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
1214
1214
  ? [linked.brief, linked.constraints ? `不要什么:${linked.constraints}` : ""].filter(Boolean).join("\n")
1215
1215
  : config.styleBrief;
1216
1216
  const prompt = buildPrompt(slide, styleBrief, { brief: readDeckBrief(renderAdapter.sourceRoot) });
1217
- const qa = qaCheck(prompt, size);
1217
+ const qa = qaCheck(prompt, size, slide);
1218
1218
  if (!qa.ok) {
1219
1219
  store.recordEvent({ type: "deck.qa", sessionId, taskId: task.taskId, deckId: deck.id, slideIds: [pageId], actor: "system", status: "failed", payload: { gate: "qa", qa } });
1220
1220
  return tryFinish({ status: "failed", error: "qa-gate", value: { taskId: task.taskId, deckId: deck.id, slideIds: [pageId], qa, text: `QA 门禁未通过:${qa.checks.filter((check) => !check.ok).map((check) => `${check.name}(${check.detail})`).join(";")}` } });
@@ -1380,6 +1380,7 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
1380
1380
  };
1381
1381
  try {
1382
1382
  const prompt = `# 修图(局部修改,其余保持不变)\n${instruction}\n\n要求:只改点名的地方;不换整页构图;图里的文字保持可读,不要新增没提到的文字或 logo。`;
1383
+ // 修图是「以现有图为底图」的局部指令,不走「图上文字清单」检查(那是整页出图的门禁)。
1383
1384
  const qa = qaCheck(prompt, size);
1384
1385
  if (!qa.ok) {
1385
1386
  store.recordEvent({ type: "deck.qa", sessionId, taskId: task.taskId, deckId: deck.id, slideIds: pageId ? [pageId] : [], actor: "system", status: "failed", payload: { gate: "qa", qa } });
@@ -0,0 +1,166 @@
1
+ /**
2
+ * 插件自更新:版本比较 + 安装方式判断 + 更新命令(F-188)。
3
+ *
4
+ * 背景:插件装好之后没有「检查更新 / 一键更新」入口,用户只能自己去终端 `dsh plugin add`。
5
+ * 工作台顶栏给一个刷新按钮(有新版时带小圆点),点了能看版本差异并直接更新;旁边是仓库入口(提 issue)。
6
+ *
7
+ * 纪律:
8
+ * - **只在 npm 安装模式下执行更新**;本机 `link:` / `file:` 开发安装一律拒绝执行,只解释原因
9
+ * (开发树里跑 pnpm add 会把本地 link 换成 registry 版本,把人正在改的代码顶掉)。
10
+ * - 更新只改 profile 里的依赖,**不改运行中的进程**:跑完提示重启 DSH 才生效。
11
+ * - 版本比较按 semver 的「主.次.补 + 预发」做,不引第三方依赖。
12
+ *
13
+ * @module dsh-plugin-deck-studio/update-check
14
+ */
15
+
16
+
17
+ const clamp = (value) => String(value ?? "").trim();
18
+
19
+ /** 解析 "1.2.3-rc.4" → { main:[1,2,3], pre:["rc","4"] };非法返回 null。 */
20
+ export function parseVersion(raw) {
21
+ const text = clamp(raw).replace(/^v/i, "");
22
+ const match = text.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
23
+ if (!match) return null;
24
+ return {
25
+ main: [Number(match[1]), Number(match[2]), Number(match[3])],
26
+ pre: match[4] ? match[4].split(".") : [],
27
+ };
28
+ }
29
+
30
+ /** semver 优先级比较:a>b 返回 1,a<b 返回 -1,相等 0(含预发版规则)。非法版本抛错。 */
31
+ export function compareVersions(a, b) {
32
+ const left = parseVersion(a);
33
+ const right = parseVersion(b);
34
+ if (!left) throw new Error(`版本号不合法:${a}`);
35
+ if (!right) throw new Error(`版本号不合法:${b}`);
36
+ for (let i = 0; i < 3; i += 1) {
37
+ if (left.main[i] !== right.main[i]) return left.main[i] > right.main[i] ? 1 : -1;
38
+ }
39
+ // 有预发标记的版本低于同号正式版(0.3.0-rc.1 < 0.3.0)
40
+ if (!left.pre.length && !right.pre.length) return 0;
41
+ if (!left.pre.length) return 1;
42
+ if (!right.pre.length) return -1;
43
+ const size = Math.max(left.pre.length, right.pre.length);
44
+ for (let i = 0; i < size; i += 1) {
45
+ const x = left.pre[i];
46
+ const y = right.pre[i];
47
+ if (x === undefined) return -1;
48
+ if (y === undefined) return 1;
49
+ if (x === y) continue;
50
+ const xn = /^\d+$/.test(x);
51
+ const yn = /^\d+$/.test(y);
52
+ if (xn && yn) return Number(x) > Number(y) ? 1 : -1;
53
+ if (xn) return -1; // 数字标识符优先级低于字母
54
+ if (yn) return 1;
55
+ return x > y ? 1 : -1;
56
+ }
57
+ return 0;
58
+ }
59
+
60
+ /** latest 比 current 新才返回 true;任一版本号非法时返回 false(宁可不动)。 */
61
+ export function isNewer(latest, current) {
62
+ try {
63
+ return compareVersions(latest, current) > 0;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ /** 从 registry 的 packument / version 文档里取 latest 版本(兼容 dist-tags 与单版本文档)。 */
70
+ export function latestFromRegistry(payload) {
71
+ if (!payload || typeof payload !== "object") return "";
72
+ const tagged = clamp(payload["dist-tags"]?.latest);
73
+ if (tagged && parseVersion(tagged)) return tagged; // 坏 tag("abc")不许当版本
74
+ const single = clamp(payload.version);
75
+ return parseVersion(single) ? single : "";
76
+ }
77
+
78
+ /**
79
+ * 判断插件是怎么装进 profile 的:
80
+ * - `linked`:`link:` / `file:` / `portal:` 等本地路径 —— 开发模式,更新走 git,不执行 pnpm
81
+ * - `npm`:semver 版本(或 `^` / `~` / `latest` 等 tag)—— 可以 `pnpm add <pkg>@latest`
82
+ * - `unknown`:找不到 profile 或依赖缺失
83
+ */
84
+ export function installModeOf({ profileDir = "", packageName = "" } = {}) {
85
+ if (!profileDir || !packageName) return { mode: "unknown", spec: "", reason: "拿不到 profile 目录或包名" };
86
+ let pkg;
87
+ try {
88
+ pkg = JSON.parse(readFileSync(join(profileDir, "package.json"), "utf8"));
89
+ } catch {
90
+ return { mode: "unknown", spec: "", reason: `读不到 ${join(profileDir, "package.json")}` };
91
+ }
92
+ const spec = clamp(pkg?.dependencies?.[packageName]);
93
+ if (!spec) return { mode: "unknown", spec: "", reason: `profile 的 dependencies 里没有 ${packageName}` };
94
+ if (/^(link|file|portal|workspace):/i.test(spec) || spec.startsWith(".") || spec.startsWith("/")) {
95
+ return { mode: "linked", spec, reason: "本机是开发链接安装(link:/file:),更新走仓库,不在界面里执行" };
96
+ }
97
+ return { mode: "npm", spec, reason: "" };
98
+ }
99
+
100
+ /**
101
+ * 找出插件所在的 profile 目录(更新命令要在那里跑 pnpm):
102
+ * 1. 从插件真实路径往上走,哪一级 package.json 的 dependencies 声明了这个包,哪一级就是 profile
103
+ * —— npm 布局下插件在 `<profile>/node_modules/<pkg>`,这一条就够,不依赖 env;
104
+ * 2. 退而用 env.DSH_PROFILE_DIR(DSH 启动时注入),要求它确实声明了这个依赖;
105
+ * 3. 都没有返回 ""(界面只解释、不执行)。
106
+ */
107
+ export function detectProfileDir({ pluginDir = "", packageName = "", env = {} } = {}) {
108
+ if (!pluginDir || !packageName) return "";
109
+ const declares = (dir) => {
110
+ try {
111
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
112
+ return Boolean(pkg?.dependencies?.[packageName]);
113
+ } catch {
114
+ return false;
115
+ }
116
+ };
117
+ let dir = "";
118
+ try {
119
+ dir = realpathSync(pluginDir);
120
+ } catch {
121
+ dir = pluginDir;
122
+ }
123
+ // 逐级向上(最多 6 层,避免走到磁盘根)
124
+ for (let depth = 0; depth < 6; depth += 1) {
125
+ const parent = dirname(dir);
126
+ if (parent === dir) break;
127
+ if (declares(parent)) return parent;
128
+ dir = parent;
129
+ }
130
+ const hinted = clamp(env?.DSH_PROFILE_DIR);
131
+ if (hinted && declares(hinted)) return hinted;
132
+ // 开发链接安装:插件不在 profile 里,扫 DSH_HOME/profiles/*,挑一个确实声明了依赖的
133
+ const root = clamp(env?.DSH_HOME) ? join(clamp(env?.DSH_HOME), "profiles") : "";
134
+ if (root) {
135
+ try {
136
+ const candidates = readdirSync(root)
137
+ .map((entry) => join(root, entry))
138
+ .filter((dir) => declares(dir));
139
+ if (candidates.length) {
140
+ const named = env?.DSH_PROFILE ? candidates.find((dir) => dir.endsWith(`/${env.DSH_PROFILE}`)) : null;
141
+ return named ?? candidates[0];
142
+ }
143
+ } catch {
144
+ /* profiles 目录不存在就算了 */
145
+ }
146
+ }
147
+ return "";
148
+ }
149
+
150
+ /** 插件包里仓库相关链接(用于顶栏「GitHub」按钮);没有就留空。 */
151
+ export function repoLinksOf(packageJson = {}) {
152
+ const repo = packageJson.repository;
153
+ const raw = typeof repo === "string" ? repo : clamp(repo?.url);
154
+ const url = raw
155
+ .replace(/^git\+/, "")
156
+ .replace(/^git@github\.com:/, "https://github.com/")
157
+ .replace(/\.git$/, "");
158
+ const homepage = clamp(packageJson.homepage) || url;
159
+ const bugs = typeof packageJson.bugs === "string" ? packageJson.bugs : clamp(packageJson.bugs?.url);
160
+ const issues = bugs ? bugs.replace(/\/+$/, "") : url ? `${url}/issues` : "";
161
+ const suggest = issues.endsWith("/issues") ? `${issues}/new/choose` : url ? `${url}/issues/new/choose` : "";
162
+ return { repo: url, homepage, issues, suggest };
163
+ }
164
+
165
+ import { readFileSync, readdirSync, realpathSync } from "node:fs";
166
+ import { dirname, join } from "node:path";
package/package.json CHANGED
@@ -1,8 +1,17 @@
1
1
  {
2
2
  "name": "dsh-plugin-deck-studio",
3
- "version": "0.2.8",
4
- "private": false,
3
+ "version": "0.2.9",
5
4
  "description": "Deckline:DSH 的 image deck / PPT 工作台插件——原生 Chat + 整页图片 deck 工作区 + 设计风格库;用对话做 PPT、按页出图、版本可追踪、导出 PDF/PPTX。",
5
+ "homepage": "https://github.com/yuna78/dsh-deck-studio#readme",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/yuna78/dsh-deck-studio.git",
9
+ "directory": "plugin/deck-studio"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/yuna78/dsh-deck-studio/issues"
13
+ },
14
+ "private": false,
6
15
  "license": "MIT",
7
16
  "type": "module",
8
17
  "main": "./index.js",
@@ -52,15 +61,6 @@
52
61
  ]
53
62
  }
54
63
  },
55
- "repository": {
56
- "type": "git",
57
- "url": "git+https://github.com/yuna78/dsh-deck-studio.git",
58
- "directory": "plugin/deck-studio"
59
- },
60
- "homepage": "https://github.com/yuna78/dsh-deck-studio#readme",
61
- "bugs": {
62
- "url": "https://github.com/yuna78/dsh-deck-studio/issues"
63
- },
64
64
  "peerDependencies": {
65
65
  "@deepseek-ai/dsh": ">=0.2.1-rc.2 <0.3.0"
66
66
  },
@@ -292,3 +292,20 @@ fabrication, explicit confirmation); suspicious instructions are flagged, never
292
292
  | `exports/` | exported PDF / PPTX / handoff bundles. |
293
293
 
294
294
  `.deck/` and `exports/` are gitignored; the repository only ships code, docs, samples and preset styles.
295
+ ## 13. Updating the plugin & giving feedback
296
+
297
+ Three always-on buttons live in the top-right of the workbench header:
298
+
299
+ | Button | What it does |
300
+ |---|---|
301
+ | **⚙** | Jumps to the settings panel (render node / model / API key source / size); from the home page it returns to the current deck first |
302
+ | **↻ vX.Y.Z** | Checks npm for the latest release: up to date → tells you the version; newer → asks "current vX → vY", then runs `dsh plugin add` for you (restart DSH to apply). A `link:` / `file:` dev install is never auto-updated — it explains to pull the repo instead, so your local link is not replaced by a registry copy |
303
+ | **GitHub** | Opens the repo's new-issue page (feature requests / bug reports) |
304
+
305
+ Command-line equivalent:
306
+
307
+ ```sh
308
+ dsh plugin --profile deckline add dsh-plugin-deck-studio@latest
309
+ ```
310
+
311
+ If npm is unreachable the button says so ("check timed out") instead of hanging.
@@ -256,3 +256,20 @@ key 值只存在本机数据文件里,任何接口都不会回显。
256
256
  | `exports/` | 导出产物(PDF / PPTX / 交接包)。 |
257
257
 
258
258
  `.deck/` 与 `exports/` 都在 `.gitignore` 里;仓库只带代码、文档、示例与预设风格。
259
+ ## 13. 更新插件与反馈
260
+
261
+ 工作台顶栏右上角三个常驻按钮,装好之后不用记命令:
262
+
263
+ | 按钮 | 行为 |
264
+ |---|---|
265
+ | **⚙** | 直接切到右栏「设置」页(出图节点 / 模型 / key 来源 / 尺寸);在首页时先回到当前 deck 再切 |
266
+ | **↻ vX.Y.Z** | 点一下查 npm 最新版:已是最新 → 提示版本号;有新版 → 弹「当前 vX → vY」确认,点「更新」直接跑 `dsh plugin add`,完成后**重启 DSH 生效**;本机 `link:` / `file:` 开发安装只解释「更新走仓库」,不会把本地链接换成 registry 包 |
267
+ | **GitHub** | 打开仓库的「新建 issue」页(提建议 / 报问题) |
268
+
269
+ 命令行的等价写法:
270
+
271
+ ```sh
272
+ dsh plugin --profile deckline add dsh-plugin-deck-studio@latest
273
+ ```
274
+
275
+ 不上网时点 ↻ 会明确提示「检查更新超时:连不上 npm registry」,不会卡住界面。
package/ui/workbench.css CHANGED
@@ -83,11 +83,16 @@
83
83
  .tool-line.err{border-left-color:#c0533f;background:#faf0ed;color:#8c3a2c;white-space:normal}
84
84
 
85
85
  /* 8 份必备文档(中间区标签) */
86
- .doc-head{display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap;margin-bottom:10px}
87
- .doc-head h2{margin:6px 0 0;font-family:"Noto Serif SC",Georgia,serif;font-size:22px;color:var(--ink)}
88
- .doc-head .doc-file{margin-left:auto;color:#8a9599;font-size:10.5px;align-self:center}
89
- /* F-187:审稿页的相关文件切换(听众读后感 / 意图差距 / 工程口径 …) */
90
- .doc-head .doc-files{margin-left:auto;display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end;max-width:70%}
86
+ .doc-head{display:flex;flex-direction:column;gap:6px;margin-bottom:10px}
87
+ .doc-head-top{display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap}
88
+ .doc-head-top h2{margin:6px 0 0;font-family:"Noto Serif SC",Georgia,serif;font-size:22px;color:var(--ink)}
89
+ .doc-head-side{margin-left:auto;display:flex;flex-direction:column;align-items:flex-end;gap:5px;text-align:right}
90
+ .doc-head .doc-file{color:#8a9599;font-size:10.5px}
91
+ /* F-187:审稿页的相关文件切换(听众读后感 / 意图差距 / 工程口径 …)
92
+ F-188 后续:一轮评审有 5 个 chip,跟标题挤一行会右对齐折成两截,改成独立一行、左对齐。 */
93
+ .doc-head-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:2px}
94
+ .doc-head .doc-files{margin:0;display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-start;max-width:100%}
95
+ .doc-head-extra{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end}
91
96
  .doc-file-chip{border:1px solid #d7dee0;background:#fff;color:#5a6a70;border-radius:999px;padding:3px 10px;font-size:10.5px;cursor:pointer}
92
97
  .doc-file-chip:hover{border-color:#0f6f7a;color:#0f6f7a}
93
98
  .doc-file-chip.active{background:#0f6f7a;border-color:#0f6f7a;color:#fff}
@@ -690,3 +695,15 @@ body.presenting{overflow:hidden}
690
695
  .source-title .doc-status{margin-left:8px;font-size:9px;font-weight:400;vertical-align:2px}
691
696
  .doc-status.optional{color:#7a7f7c;background:#efece3;border:1px solid #e0dbcd;border-radius:8px;padding:0 6px;line-height:16px}
692
697
  .source-hint{font-size:10px;color:#8b8f8b;margin-top:3px;line-height:1.5}
698
+
699
+ /* F-188:顶栏「更新 / GitHub」小按钮 + 临时提示(顶栏是深色,沿用 .top button 的浅色底) */
700
+ .top .dl-icon-btn{position:relative;display:inline-flex;align-items:center;gap:5px;padding:6px 9px;cursor:pointer;font:inherit;font-size:12px;line-height:1}
701
+ .top .dl-icon-btn:hover{background:#ffffff2e}
702
+ .top .dl-icon-btn:active{transform:translateY(1px)}
703
+ .top .dl-icon-btn.busy svg{animation:dl-spin 1s linear infinite}
704
+ .top .dl-icon-btn svg{flex:0 0 auto;opacity:.85}
705
+ .top .dl-icon-ver{opacity:.72;font-size:11px;font-variant-numeric:tabular-nums}
706
+ .top .dl-icon-dot{position:absolute;top:3px;right:4px;width:7px;height:7px;border-radius:50%;background:#e8b45c;box-shadow:0 0 0 2px var(--ink)}
707
+ .dl-toast{position:fixed;left:50%;top:66px;transform:translateX(-50%) translateY(-6px);z-index:80;max-width:min(560px,90vw);padding:9px 14px;border:1px solid var(--line);border-radius:10px;background:#fffdf7;color:#2c2a26;font-size:13px;line-height:1.5;box-shadow:0 10px 28px rgba(0,0,0,.18);opacity:0;pointer-events:none;transition:opacity .16s ease,transform .16s ease;white-space:pre-wrap}
708
+ .dl-toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
709
+ @keyframes dl-spin{to{transform:rotate(360deg)}}
package/ui/workbench.html CHANGED
@@ -92,6 +92,19 @@
92
92
  <span class="project">AI Image Deck Studio</span>
93
93
  <span class="spacer"></span>
94
94
  <span id="save">DSH 会话 · 连接中…</span>
95
+ <!-- F-188:更新 / 仓库 / 设置入口(点刷新查 npm 最新版,有新版带小圆点) -->
96
+ <button id="settingsBtn" class="dl-icon-btn" title="设置(出图节点 / 模型 / 尺寸)" aria-label="设置">
97
+ <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M19.4 13a7.7 7.7 0 0 0 0-2l2-1.6-2-3.4-2.4 1a7.7 7.7 0 0 0-1.7-1L15 3.5h-4l-.3 2.5a7.7 7.7 0 0 0-1.7 1l-2.4-1-2 3.4 2 1.6a7.7 7.7 0 0 0 0 2l-2 1.6 2 3.4 2.4-1a7.7 7.7 0 0 0 1.7 1l.3 2.5h4l.3-2.5a7.7 7.7 0 0 0 1.7-1l2.4 1 2-3.4-2-1.6zM12 15.2A3.2 3.2 0 1 1 12 8.8a3.2 3.2 0 0 1 0 6.4z"/></svg>
98
+ </button>
99
+ <button id="updateBtn" class="dl-icon-btn" title="检查更新" aria-label="检查更新">
100
+ <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M12 4V1.5L7.5 6l4.5 4.5V8a5 5 0 1 1-5 5H5a7 7 0 1 0 7-9z"/></svg>
101
+ <span class="dl-icon-ver" id="updateVer">—</span>
102
+ <i class="dl-icon-dot" id="updateDot" hidden></i>
103
+ </button>
104
+ <button id="repoBtn" class="dl-icon-btn" title="GitHub 仓库 / 提建议" aria-label="GitHub 仓库">
105
+ <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
106
+ </button>
107
+ <div id="dlToast" class="dl-toast" hidden></div>
95
108
  <button id="shellSidebarBtn" title="显示 / 收起 DSH 侧栏(会话 · 插件 · 设置)" hidden>☰ DSH</button>
96
109
  </header>
97
110
 
@@ -1044,6 +1057,137 @@ async function postJson(routeName, body) {
1044
1057
  return data;
1045
1058
  }
1046
1059
 
1060
+ /** GET 一个插件路由(失败抛后端 message)。 */
1061
+ async function getJson(routeName) {
1062
+ const res = await fetch("/api/deck-studio." + routeName, { headers: { accept: "application/json" } });
1063
+ const data = await res.json().catch(() => ({}));
1064
+ if (!res.ok || data?.ok === false) throw new Error(data?.error?.message || "HTTP " + res.status);
1065
+ return data;
1066
+ }
1067
+
1068
+ /** 顶栏临时提示(不打断操作)。 */
1069
+ let DL_TOAST_TIMER = null;
1070
+ function dlToast(text, ms = 4200) {
1071
+ const box = getEl("dlToast");
1072
+ if (!box) return;
1073
+ box.textContent = text;
1074
+ box.hidden = false;
1075
+ box.classList.add("show");
1076
+ clearTimeout(DL_TOAST_TIMER);
1077
+ DL_TOAST_TIMER = setTimeout(() => {
1078
+ box.classList.remove("show");
1079
+ box.hidden = true;
1080
+ }, ms);
1081
+ }
1082
+
1083
+ /** F-188:更新状态(版本 / 安装方式 / 仓库链接)由 workbench payload 带下来。 */
1084
+ const UPDATE = { current: "", latest: "", hasUpdate: false, installMode: "", repo: "", suggest: "", note: "" };
1085
+
1086
+ function paintUpdate() {
1087
+ const ver = getEl("updateVer");
1088
+ if (ver) ver.textContent = UPDATE.current ? `v${UPDATE.current}` : "—";
1089
+ const dot = getEl("updateDot");
1090
+ if (dot) dot.hidden = !UPDATE.hasUpdate;
1091
+ const btn = getEl("updateBtn");
1092
+ if (btn) {
1093
+ btn.title = UPDATE.hasUpdate
1094
+ ? `有新版 v${UPDATE.latest}(当前 v${UPDATE.current})`
1095
+ : UPDATE.installMode === "linked"
1096
+ ? "本机是开发链接安装:更新走 git pull"
1097
+ : "检查更新";
1098
+ }
1099
+ }
1100
+
1101
+ function absorbUpdate(payload) {
1102
+ const brief = payload?.update ?? {};
1103
+ if (brief.currentVersion) UPDATE.current = brief.currentVersion;
1104
+ if (brief.installMode) UPDATE.installMode = brief.installMode;
1105
+ if (brief.repo) UPDATE.repo = brief.repo;
1106
+ if (brief.suggest) UPDATE.suggest = brief.suggest;
1107
+ if (brief.issues) UPDATE.issues = brief.issues;
1108
+ paintUpdate();
1109
+ }
1110
+
1111
+ async function runPluginUpdate() {
1112
+ try {
1113
+ const data = await postJson("update", {});
1114
+ UPDATE.current = data.to || UPDATE.current;
1115
+ UPDATE.hasUpdate = false;
1116
+ paintUpdate();
1117
+ openConfirm({
1118
+ title: `已更新到 v${data.to}`,
1119
+ desc: `${data.restartHint || "重启 DSH 后生效。"}\n\n${data.output ? String(data.output).slice(-600) : ""}`,
1120
+ okLabel: "知道了",
1121
+ onOk: null,
1122
+ });
1123
+ } catch (error) {
1124
+ dlToast(String(error?.message || error), 8000);
1125
+ }
1126
+ }
1127
+
1128
+ /** 点刷新:先查 npm 最新版,再决定是提示「已是最新」、解释开发链接、还是弹更新确认。 */
1129
+ async function checkPluginUpdate() {
1130
+ const btn = getEl("updateBtn");
1131
+ if (btn) btn.classList.add("busy");
1132
+ try {
1133
+ const data = await getJson("update-check");
1134
+ UPDATE.latest = data.latestVersion || "";
1135
+ UPDATE.hasUpdate = Boolean(data.hasUpdate);
1136
+ UPDATE.installMode = data.installMode || UPDATE.installMode;
1137
+ if (data.note) UPDATE.note = data.note;
1138
+ paintUpdate();
1139
+ if (data.error) {
1140
+ dlToast(data.error, 6000);
1141
+ return;
1142
+ }
1143
+ if (!UPDATE.hasUpdate) {
1144
+ dlToast(`已是最新版本 v${data.currentVersion}`);
1145
+ return;
1146
+ }
1147
+ if (data.installMode === "linked") {
1148
+ dlToast(data.note || "本机是开发链接安装,更新走仓库(git pull)。", 7000);
1149
+ return;
1150
+ }
1151
+ if (data.installMode !== "npm") {
1152
+ dlToast("没识别出 profile 的安装方式,无法一键更新;请手动 dsh plugin add。", 7000);
1153
+ return;
1154
+ }
1155
+ openConfirm({
1156
+ title: `更新到 v${data.latestVersion}`,
1157
+ desc: `当前 v${data.currentVersion} → v${data.latestVersion}。会在 profile 里执行 pnpm add,下载完成后重启 DSH 生效。`,
1158
+ okLabel: "更新",
1159
+ onOk: runPluginUpdate,
1160
+ });
1161
+ } catch (error) {
1162
+ dlToast(`检查更新失败:${String(error?.message || error)}`, 6000);
1163
+ } finally {
1164
+ if (btn) btn.classList.remove("busy");
1165
+ }
1166
+ }
1167
+
1168
+ /** 顶栏齿轮:直接切到右栏「设置」(首页时先回到当前 deck 的工作台)。 */
1169
+ async function openSettingsPanel() {
1170
+ const deckId = STATE.data?.active?.deckId;
1171
+ if (!deckId) {
1172
+ dlToast("先在 Decks 里打开一本 deck,右栏就有设置(出图节点 / 模型 / 尺寸)", 6000);
1173
+ return;
1174
+ }
1175
+ if (STATE.view === "home") await showDeck(deckId);
1176
+ activateRightTab("settings");
1177
+ const panel = getEl("settings");
1178
+ if (panel) panel.scrollIntoView({ block: "start" });
1179
+ }
1180
+
1181
+ /** 打开仓库 / 提建议页(新标签)。 */
1182
+ function openRepo() {
1183
+ const url = UPDATE.suggest || UPDATE.issues || UPDATE.repo;
1184
+ if (!url) {
1185
+ dlToast("插件包里没配 repository 链接", 5000);
1186
+ return;
1187
+ }
1188
+ window.open(url, "_blank", "noopener");
1189
+ }
1190
+
1047
1191
  let CONFIRM_OK = null;
1048
1192
 
1049
1193
  /** 通用确认弹层(删除等破坏性操作用)。 */
@@ -1407,12 +1551,24 @@ function emptyDoc(doc) {
1407
1551
  }
1408
1552
 
1409
1553
  function docHeader(doc, extra = "") {
1554
+ // 只有「多个可切换的文档」才出 chip:目录(prompts/ 之类)、不存在的相关文件都不算。
1555
+ // 以前脚本页右侧也挂着 script.md / prompts/ 两个 chip,看着像能切,其实没内容可切。
1556
+ const switchable = (doc.files || []).filter((file) => !String(file.name || "").endsWith("/") && file.exists !== false);
1557
+ const chips = switchable.length > 1
1558
+ ? `<div class="doc-files">${switchable.map((file) => `<button type="button" class="doc-file-chip${file.name === doc.primary ? " active" : ""}" data-doc-file="${esc(file.name)}" title="${esc(file.name)}">${esc(file.name.replace(/^docs\//, ""))}</button>`).join("")}</div>`
1559
+ : "";
1560
+ // 两行:第一行标题 + 徽标,右上角是主文件路径与草案说明 / 动作;第二行只有文件 chips(左对齐,可换行)。
1561
+ // 一轮审稿会有 review.md / readers / gap / experts / images / scores 六个 chip,跟标题挤一行会被挤到右侧折成两截,很难读。
1410
1562
  return `<div class="doc-head">
1411
- <div><div class="eyebrow">Deck Document</div><h2>${esc(doc.label)}</h2></div>
1412
- ${docBadge(doc.status)}
1413
- <span class="small doc-file">${esc(doc.primary || "无文件")}</span>
1414
- ${(doc.files || []).length > 1 ? `<div class="doc-files">${doc.files.map((file) => `<button type="button" class="doc-file-chip${file.name === doc.primary ? " active" : ""}" data-doc-file="${esc(file.name)}" title="${esc(file.name)}">${esc(file.name.replace(/^docs\//, ""))}</button>`).join("")}</div>` : ""}
1415
- ${extra}
1563
+ <div class="doc-head-top">
1564
+ <div><div class="eyebrow">Deck Document</div><h2>${esc(doc.label)}</h2></div>
1565
+ ${docBadge(doc.status)}
1566
+ <span class="doc-head-side">
1567
+ <span class="small doc-file">${esc(doc.primary || "无文件")}</span>
1568
+ ${extra ? `<span class="doc-head-extra">${extra}</span>` : ""}
1569
+ </span>
1570
+ </div>
1571
+ ${chips ? `<div class="doc-head-row">${chips}</div>` : ""}
1416
1572
  </div>`;
1417
1573
  }
1418
1574
 
@@ -1650,6 +1806,7 @@ function renderDoc(doc, index) {
1650
1806
  })()}
1651
1807
  ${bodyText ? `<div class="page-content md${placeholderContent ? " is-placeholder" : ""}">${placeholderContent ? esc(bodyText) : mdToHtml(bodyText)}</div><p class="density-line">${densityBadge("body", bodyText)}</p>` : `<p class="engine-note">(这页没有正文)</p>`}
1652
1808
  <details class="prompt-details"><summary>提示词(${placeholderPrompt ? "待补" : `${String(promptText).length} 字符`}${densityTarget && !placeholderPrompt ? ` · 目标 ${densityTarget.briefMin}–${densityTarget.briefMax} 字` : ""})</summary><div class="prompt-quote">${esc(promptText || "(缺提示词)")}${placeholderPrompt ? pendingHint(["visualBrief"]) : ""}</div></details>
1809
+ <details class="prompt-details" data-assembled-prompt="${esc(slide.id)}"><summary>出图时拼装的完整 prompt(含图上文字清单,展开时读取)</summary><div class="prompt-quote" data-assembled-body="${esc(slide.id)}">展开读取…</div></details>
1653
1810
  ${proposalBlock}
1654
1811
  </div></div>`;
1655
1812
  }).join("")}</div>` + docFooter(doc, content);
@@ -1929,6 +2086,29 @@ async function rollbackFirstDraft(revision) {
1929
2086
  }
1930
2087
 
1931
2088
  function bindDocActions(root) {
2089
+ root.querySelectorAll("details[data-assembled-prompt]").forEach((details) => {
2090
+ details.addEventListener("toggle", async () => {
2091
+ if (!details.open) return;
2092
+ const pageId = details.getAttribute("data-assembled-prompt");
2093
+ const body = details.querySelector("[data-assembled-body]");
2094
+ if (!pageId || !body || body.dataset.loaded === "1") return;
2095
+ const deckId = STATE.data?.active?.deckId;
2096
+ if (!deckId) return;
2097
+ body.textContent = "读取中…";
2098
+ try {
2099
+ const res = await fetch(`/api/deck-studio.page-prompt?` + new URLSearchParams({ deckId, pageId }), { headers: { accept: "application/json" } });
2100
+ const data = await res.json().catch(() => ({}));
2101
+ if (res.ok && data?.ok && data.prompt) {
2102
+ body.textContent = data.prompt;
2103
+ body.dataset.loaded = "1";
2104
+ } else {
2105
+ body.textContent = `读取失败:${data?.error?.message || "未知错误"}`;
2106
+ }
2107
+ } catch (error) {
2108
+ body.textContent = `读取失败:${String(error?.message ?? error)}`;
2109
+ }
2110
+ });
2111
+ });
1932
2112
  root.querySelectorAll("[data-doc-file]").forEach((btn) => btn.addEventListener("click", () => {
1933
2113
  const file = btn.getAttribute("data-doc-file") || "";
1934
2114
  if (file) openDocFile(STATE.tab, file);
@@ -4678,6 +4858,10 @@ function backToManual(box) {
4678
4858
  }
4679
4859
  getEl("embedBack").addEventListener("click", () => window.parent?.postMessage({ type: "dsh-deck-studio:close" }, "*"));
4680
4860
  await loadWorkbench();
4861
+ absorbUpdate(STATE.data);
4862
+ getEl("settingsBtn").addEventListener("click", openSettingsPanel);
4863
+ getEl("updateBtn").addEventListener("click", checkPluginUpdate);
4864
+ getEl("repoBtn").addEventListener("click", openRepo);
4681
4865
  await refreshSessions();
4682
4866
  getEl("save").textContent = EMBED ? "对话在 DSH 原生 Chat" : "对话在左侧 · DSH 原生";
4683
4867
  // F-69:只有 profile 开了 hideShellSidebar,宿主左侧栏才会默认收起;此时顶栏给一个切换入口。