dsh-plugin-deck-studio 0.2.8 → 0.2.10

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/README.md CHANGED
@@ -1,53 +1,91 @@
1
- # Deckline · `dsh-plugin-deck-studio`
1
+ ![Deckline](../../assets/logo/deckline-logo.png)
2
2
 
3
- DSH(DeepSeek Harness)的 **image deck 工作台插件**:左侧原生 Chat、中间整页图片 deck、右侧资料 / Designs / 资源 / 会话。
4
- 用对话改稿、按页出图(可点名批量连出)、版本留存、导出 PDF / PPTX / 交接包——不是又一个 PPT 编辑器。
3
+ # Deckline · 用对话制作整页图片幻灯片
4
+
5
+ **从「讲给谁、讲什么」到脚本、逐页出图、版本与交付,在 DSH 里完成。**
6
+
7
+ Deckline 是 DeepSeek Harness(DSH)的 **image deck 工作台插件**,不是独立应用,也不是可编辑图层的 PPT 编辑器。对话留在 DSH 原生 Chat;工作台负责脚本、图片、设计风格、版本和资料。
8
+
9
+ [安装与配置](../../docs/install.md) · [使用手册](../../docs/manual.md) · [示例 deck](../../samples/deckline-intro) · [English overview](../../README.en.md)
10
+
11
+ ## 先看看它怎么工作
12
+
13
+ ![Deckline 工作台:逐页图片、脚本编辑与版本操作](../../docs/assets/screenshots/02-deck-pages.png)
14
+
15
+ *上图为内置公开示例:中间浏览整页图片,右侧查看与修改这一页的正文、画面要求和提示词。截图展示对应拍摄版本,具体控件以当前版本为准。*
16
+
17
+ ### 从想法到可交付的 deck
18
+
19
+ | 你要做什么 | Deckline 提供什么 |
20
+ |---|---|
21
+ | 把想法讲清楚 | 从受众、意图与参考资料开始,组织故事线和逐页脚本 |
22
+ | 做出整页画面 | 按页生成图片;点名多页时可连续出图,每页保留 prompt 与版本 |
23
+ | 调整内容和画面 | 在 Chat 里改稿,查看提案;已有图片可做局部修图,生成新版本而不覆盖旧图 |
24
+ | 统一视觉风格 | 内置风格与样张预览,也可创建自己的 Design、添加参考图并跨 deck 复用 |
25
+ | 检查再交付 | 听众评审、资料与审稿文档;直接播放,或导出 PDF / PPTX / 交接包 |
26
+
27
+ > **导出的 PPTX 每页是一张整页图片,不是可逐项编辑的文本和图表。** 真实出图需要自行配置兼容的 image API;默认 fake 模式用于体验流程。
28
+
29
+ ## 写完就能讲:直接全屏播放
30
+
31
+ 在工作台点击「全屏演示」,用方向键翻页、Esc 退出;不必为了预览先导出文件。
32
+
33
+ ![Deckline 全屏演示:直接播放图片幻灯片](../../docs/assets/screenshots/08-presentation.png)
34
+
35
+ ## Designs:不只选风格,还能维护自己的风格库
36
+
37
+ 预览内置样张、为 deck 选择风格;也可以创建自己的风格,管理画面描述、参考图和样张。这里的 Design 是可复用的视觉定义,**不是固定布局的 PPT 母版**。
38
+
39
+ ![Deckline 风格库:预设样张与风格选择](../../docs/assets/screenshots/09-style-library.png)
40
+
41
+ ![Deckline 风格管理:定义、提示词、样张与关联会话](../../docs/assets/screenshots/10-style-management.png)
42
+
43
+ 更多画面与流程见 [仓库首页](../../README.md)、[风格指南](../../docs/styles.md) 和 [完整示例](../../docs/sample-deck.md)。以上图片均来自公开示例或演示数据。
5
44
 
6
45
  ## 安装
7
46
 
47
+ 前置:Node.js `^22.19 或 ≥24`、pnpm 10+、DSH `≥0.2.1-rc.2 <0.3`。详细兼容范围见 [兼容说明](../../docs/compat.md)。
48
+
8
49
  ```sh
9
50
  # 1) 新建一个 profile(基于 DSH 自带 web 模板)
10
51
  dsh --profile deckline --from-default-profile web
11
52
 
12
- # 2) 安装本插件(安装后会自动注册 bundle,无需手动改配置文件)
13
- dsh plugin --profile deckline add dsh-plugin-deck-studio@0.2.4
53
+ # 2) 安装 npm 最新版;安装后自动注册 bundle
54
+ dsh plugin --profile deckline add dsh-plugin-deck-studio@latest
14
55
 
15
- # 国内网络建议走镜像(否则从 npmjs.org 拉 20 MB 包可能很慢)
16
- npm_config_registry=https://registry.npmmirror.com dsh plugin --profile deckline add dsh-plugin-deck-studio@0.2.4
56
+ # 国内网络可按需使用镜像,注意镜像版本同步可能有延迟
57
+ # npm_config_registry=https://registry.npmmirror.com dsh plugin --profile deckline add dsh-plugin-deck-studio@latest
17
58
 
18
- # 3) 启动,浏览器打开终端打印的带 token 链接
59
+ # 3) 启动,打开终端打印的带 token 链接
19
60
  dsh --profile deckline --port 13090
20
61
  ```
21
62
 
22
- 确认装上了(输出 `1` 才对):
63
+ 也可以在 DSH「插件 → 发现」中添加索引源:
23
64
 
24
- ```sh
25
- dsh --profile deckline --dump-config | grep -c "preset-deck-critic"
65
+ ```text
66
+ https://cdn.jsdelivr.net/gh/yuna78/dsh-deck-studio@main/market-index.json
26
67
  ```
27
68
 
28
- 在 npmjs.com 搜 `deckline` 或 `ppt dsh` 也能找到。也可以在 DSH「插件 → 发现」里加索引源 `https://cdn.jsdelivr.net/gh/yuna78/dsh-deck-studio@main/market-index.json` 后点安装。
29
-
30
- 从源码 / Git 安装、Windows、以及完整配置(数据目录 / deck 源 / 出图节点 / key)见仓库文档:
69
+ 从源码 / Git 安装、Windows、模型与出图配置及排障见 [中文安装指南](../../docs/install.md) / [English installation](../../docs/install.en.md)。升级后重启 DSH;听众审稿由插件内部调用,不需要单独选择审稿 Mode。
31
70
 
32
- - [docs/install.md](https://github.com/yuna78/dsh-deck-studio/blob/main/docs/install.md)(中文)
33
- - [docs/install.en.md](https://github.com/yuna78/dsh-deck-studio/blob/main/docs/install.en.md)(English)
34
- - [示例 deck 与效果](https://github.com/yuna78/dsh-deck-studio/tree/main/samples/deckline-intro)
71
+ ## 快速开始
35
72
 
36
- ## 快速开始(fake 模式,无需 key)
73
+ 1. 首页点「+ 新建 Deck」,填写标题、受众和一句话意图,选择视觉风格。
74
+ 2. 在 Chat 中补充资料、起草故事线与逐页脚本,再按需修改和出图。
75
+ 3. 默认 fake 模式生成占位图;真实图片请在「设置」配置出图节点 endpoint、模型与 key。
76
+ 4. 在工作台查看页面和版本,直接全屏演示,或导出文件交付。
37
77
 
38
- 1. 首页点「+ 新建 Deck」,填标题 / 受众 / 一句话意图;
39
- 2. 在 Chat 里让 Agent 起草 `intent.md` / `script.md`,确认后逐页出图;
40
- 3. 默认 fake 模式出占位图,能走完整个流程;要看真图再进「设置」填出图节点 endpoint / 模型 / key。
78
+ 出图节点不内置。真实渲染需要配置 OpenAI 兼容的 `/v1/images/generations` 地址。节点 key 保存在本机数据目录的 `render-settings.json`(0600,不回显);不要将 key 提交到 Git。
41
79
 
42
- 出图节点不内置:real 模式必须自己配 OpenAI 兼容的 `/v1/images/generations` 地址(key 存本机,
43
- `~/.dsh/deckline/render-settings.json`,0600,不进 git、不回显)。
80
+ ## 数据与边界
44
81
 
45
- ## 数据在哪
82
+ - 工作副本、图片、版本与事件默认写 `$DSH_HOME/deckline`,可通过 `dataRoot` 覆盖。
83
+ - 受控源 deck 的 `sourceRoot` **只读**;本机新建 deck 位于 `projectsRoot` 或 `<dataRoot>/projects/<deckId>/source`,有独立写入流程。
84
+ - 每次出图保留版本,旧图片不会因新图生成而被覆盖。
85
+ - 这是 DSH 插件,运行时依赖宿主与用户配置的模型服务;不承诺离线真实出图。
46
86
 
47
- - 运行数据(工作副本 / 出图 / 事件):`$DSH_HOME/deckline`(可用 `dataRoot` 覆盖);
48
- - 受控源 deck:`decks[].sourceRoot` 指向的目录,**只读**,Deckline 永不回写;
49
- - 新建 deck:`projectsRoot` 或 `<dataRoot>/projects/<deckId>/source`。
87
+ ## 许可与反馈
50
88
 
51
- ## 许可
89
+ MIT © 2026 Yuna (Deckline) · [LICENSE](../../LICENSE)
52
90
 
53
- MIT © 2026 Yuna (Deckline)。见仓库 [LICENSE](https://github.com/yuna78/dsh-deck-studio/blob/main/LICENSE)。
91
+ [报告问题 / 提建议](https://github.com/yuna78/dsh-deck-studio/issues) · [发布记录](https://github.com/yuna78/dsh-deck-studio/releases)
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,125 @@ 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
+ const CANVAS_DISCIPLINE =
85
+ "画面纪律:只画上面【图上文字】与「画面要求」点名的元素;不要新增图例 / 清单面板 / 装饰性英文标签 / 示意数据 / 与内容无关的图标;同一元素不要既画出来又列进清单(重复两遍);同一信息只写一次,不要用双层括号,也不要既加括注又写注释行。";
86
+
87
+ /** 把清单渲染成 prompt 行(QA 只检查 head 存在与关键行逐字命中)。 */
88
+ function onImageLines(list) {
89
+ const lines = [ON_IMAGE_HEAD];
90
+ if (list.title) lines.push(`主标题:${list.title}`);
91
+ if (list.subtitle) lines.push(`副标题(标题下方一行,字号小于主标题):${list.subtitle}`);
92
+ if (list.claim) lines.push(`一句话主张:${list.claim}`);
93
+ if (list.bullets.length) {
94
+ lines.push("要点(原句只作理解;画面上每条压成 ≤12 字短语贴到对应模块):");
95
+ list.bullets.forEach((point, index) => lines.push(`${index + 1}. ${point}`));
96
+ }
97
+ lines.push("版式与字号预算:主标题最大;副标题紧接其下、约标题的 45% 字号;一句话主张做成通栏一行(字号小于副标题、大于要点)——这三项都必须完整印出,不得省略或用别的话替代。");
98
+ return lines;
99
+ }
100
+
22
101
  /**
23
102
  * 由 slide 字段 + 全局风格 preset 组 prompt。
24
103
  * F-158:封面页单列一套版式纪律(只放标题/副标题/作者/日期,不堆正文);
25
104
  * 普通页带上 intent 的受众 / 目的,避免「图很漂亮但讲错人」。
105
+ * F-189:文字不再是「语境」,而是显式「图上文字清单」——副标题 / 主张 / 要点逐字列出,
106
+ * 并声明与 visualBrief 的排他指令冲突时文字优先(否则「画面上只出现 XX 标签」会把副标题吞掉)。
26
107
  * @param {object} slide manifest slide
27
108
  * @param {string} styleBrief
28
109
  * @param {{brief?: object}} [context] readDeckBrief() 的结果
29
110
  */
30
111
  export function buildPrompt(slide, styleBrief, context = {}) {
31
112
  const brief = context.brief ?? {};
113
+ const list = onImageText(slide);
32
114
  const isCover = slide?.type === "cover" || Number(slide?.order) === 1;
33
115
  if (isCover) {
34
- const parts = [styleBrief, "封面页(16:9):只放标题信息", `主标题:${slide?.title ?? ""}`];
35
- if (slide?.question) parts.push(`副标题:${slide.question}`);
116
+ const parts = [styleBrief, "封面页(16:9):只放标题信息", ...onImageLines({ ...list, bullets: [] })];
36
117
  parts.push(...coverPromptLines(brief));
37
118
  parts.push("版式纪律:只放主标题 / 副标题 / 作者 / 日期;正文不超过一行;不要目录、要点清单、数据图表、大段文字;留白为主。");
38
119
  if (slide?.visualBrief) parts.push(`画面要求:${slide.visualBrief}`);
120
+ parts.push(CANVAS_DISCIPLINE);
121
+ parts.push("文字优先:上面【图上文字】的每一行都必须出现在画面里;画面要求与它冲突时,以上面为准。");
39
122
  return parts.filter(Boolean).join("\n");
40
123
  }
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}`);
124
+ const parts = [styleBrief, ...onImageLines(list)];
125
+ const body = String(slide?.body ?? "").trim();
126
+ if (body) parts.push(`讲述用正文(不要整段排进画面,只提炼成上面的要点;用来把握语气与例子):${body.slice(0, 300)}`);
127
+ if (slide?.visualBrief) parts.push(`画面要求:${slide.visualBrief}`);
128
+ parts.push(CANVAS_DISCIPLINE);
129
+ parts.push("文字优先:上面【图上文字】的主标题 / 副标题 / 主张必须完整印出(可换行、可分层,但不得省略、不得改写成别的说法);画面要求若写「只出现 XX」,XX 之外仍要保留这三项与要点短语。");
47
130
  parts.push(...briefPromptLines(brief));
48
131
  return parts.filter(Boolean).join("\n");
49
132
  }
50
133
 
51
- /** QA 门禁。返回 {ok, checks:[{name, ok, detail}]}。 */
52
- export function qaCheck(prompt, size) {
134
+ /**
135
+ * QA 门禁。返回 {ok, checks:[{name, ok, detail}]}。
136
+ * @param {string} prompt 最终 prompt
137
+ * @param {string} size 尺寸
138
+ * @param {object} [slide] 传了就额外检查「图上文字」是否都在 prompt 里(F-189)
139
+ */
140
+ export function qaCheck(prompt, size, slide) {
53
141
  const checks = [];
54
142
  const promptOk = typeof prompt === "string" && prompt.trim().length >= 8;
55
143
  checks.push({ name: "prompt-exists", ok: promptOk, detail: promptOk ? `${prompt.trim().length} chars` : "prompt 为空或过短" });
@@ -57,6 +145,15 @@ export function qaCheck(prompt, size) {
57
145
  checks.push({ name: "size-valid", ok: sizeOk, detail: String(size ?? "") });
58
146
  const secretHit = SECRET_PATTERNS.find(([pattern]) => pattern.test(prompt ?? ""));
59
147
  checks.push({ name: "no-secrets", ok: !secretHit, detail: secretHit ? `疑似敏感串:${secretHit[1]}` : "clean" });
148
+ if (slide) {
149
+ const list = onImageText(slide);
150
+ const missing = [];
151
+ if (!String(prompt ?? "").includes(ON_IMAGE_HEAD)) missing.push("图上文字清单");
152
+ if (list.title && !String(prompt ?? "").includes(list.title)) missing.push("主标题");
153
+ if (list.subtitle && !String(prompt ?? "").includes(list.subtitle)) missing.push("副标题");
154
+ if (list.claim && !String(prompt ?? "").includes(list.claim)) missing.push("主张");
155
+ 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" });
156
+ }
60
157
  return { ok: checks.every((check) => check.ok), checks };
61
158
  }
62
159
 
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.10",
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」,不会卡住界面。
@@ -4,7 +4,7 @@
4
4
  "description": "科技蓝图风:深海军蓝网格底、青白技术线条、等距系统/架构图、等宽字体标签、发光节点;像工程蓝图和架构白皮书。",
5
5
  "constraints": "不要照片、不要卡通、不要渐变光效泛滥、不要彩色斑块、不要写实人物、不要真实品牌 logo。",
6
6
  "reference": "架构白皮书 / 工程蓝图",
7
- "styleBrief": "科技蓝图 / 等距架构图:深海军蓝底 #0A1628,叠加极淡青色网格(#1E3A5F);主体是等距(isometric)线框模块:节点、服务器、数据库、云、客户端,用青白色 #7DD3FC 细线与发光边,节点间正交折线连接 + 小箭头;标签用等宽字体(monospace)全大写小字,外加带边框的图例 / 清单面板(不发光)。点缀极少量暖色 #F59E0B 强调关键节点。整体像工程蓝图 / 架构白皮书,信息密度高但网格秩序强;无照片、无卡通、无廉价霓虹。",
7
+ "styleBrief": "科技蓝图 / 等距架构图:深海军蓝底 #0A1628,叠加极淡青色网格(#1E3A5F);主体是等距(isometric)线框模块:节点、服务器、数据库、云、客户端,用青白色 #7DD3FC 细线与发光边,节点间正交折线连接 + 小箭头;标签用等宽字体(monospace)全大写小字。图例 / 清单面板只在画面里确实有多种连线或符号需要解释时才加,且只列本页出现过的元素,不要为填充版面编造图例项。点缀极少量暖色 #F59E0B 强调关键节点。整体像工程蓝图 / 架构白皮书,信息密度高但网格秩序强;无照片、无卡通、无廉价霓虹。",
8
8
  "baselineBriefs": [
9
9
  {
10
10
  "title": "架构总览",
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,宿主左侧栏才会默认收起;此时顶栏给一个切换入口。