dsh-plugin-deck-studio 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/tools.js CHANGED
@@ -19,6 +19,7 @@ import { readDeckRules, renderDeckRules } from "./deck-rules.js";
19
19
  import { readDeckBrief } from "./deck-brief.js";
20
20
  import { scriptStyleHints } from "./script-style.js";
21
21
  import { DEFAULT_MAX_REVIEWERS, HARD_MAX_REVIEWERS, deriveReviewers, mergeIssues, parseReviewerOutput, renderReviewMarkdown, reviewerPrompt } from "./review-personas.js";
22
+ import { REVIEW_AGENT_PROVIDER } from "./review-agent.js";
22
23
  import { densityLine } from "./content-density.js";
23
24
  import { faqIndex, searchFaq } from "./faq.js";
24
25
  import { WATERMARK_POSITIONS, WATERMARK_POSITION_LABELS, findAsset, listAssets, positionLine, readPngFile, watermarkPng } from "./watermark.js";
@@ -138,7 +139,7 @@ const strEnum = (values) => ({ type: "string", enum: values });
138
139
  * @param {import('./config.js').mergeConfig} services.config
139
140
  * @param {NodeJS.ProcessEnv} [services.env]
140
141
  */
141
- export function defineDeckTools({ store, workspace, adapters, config, env = process.env, resolveRender = null, isLocalDeck = null, repoRoot = null, subagents = null, reviewPreset = null }) {
142
+ export function defineDeckTools({ store, workspace, adapters, config, env = process.env, resolveRender = null, isLocalDeck = null, repoRoot = null, subagents = null }) {
142
143
  const resolveSubagents = () => (typeof subagents === "function" ? subagents() : subagents);
143
144
  const designs = createDesignRegistry({ dataRoot: config.dataRoot });
144
145
  const decksRegistryFile = join(config.dataRoot, "decks-registry.json");
@@ -2085,8 +2086,8 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2085
2086
  reviewers: derived.reviewers,
2086
2087
  issues: [],
2087
2088
  text: textOnly(
2088
- "当前 profile 没有挂 subagent 服务(@deepseek-ai/dsh-subagent + dsh-subagent-spawn-in-process),无法起独立审稿人。\n" +
2089
- "在 profile 里挂上这三行后重试:dsh-subagent / dsh-subagent-spawn-in-process / dsh-tool-subagent。",
2089
+ "当前 profile 没有挂 subagent 服务(@deepseek-ai/dsh-subagent),无法启动插件内置审稿人。\n" +
2090
+ "请启用 DSH 子 Agent 服务后重试,不需要选择或安装审稿模式。",
2090
2091
  ),
2091
2092
  };
2092
2093
  }
@@ -2110,6 +2111,7 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2110
2111
  else exec.signal?.addEventListener?.("abort", onAbort, { once: true });
2111
2112
  let timer;
2112
2113
  let activation;
2114
+ const outcome = { reviewer, label: reviewer.label };
2113
2115
  const timeout = new Promise((_resolve, reject) => {
2114
2116
  timer = setTimeout(() => {
2115
2117
  controller.abort();
@@ -2118,7 +2120,7 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2118
2120
  });
2119
2121
  try {
2120
2122
  activation = await runtime.startActivation({
2121
- provider: "spawn",
2123
+ provider: REVIEW_AGENT_PROVIDER,
2122
2124
  label: `deck-review:${deck.id}:${reviewer.id}`,
2123
2125
  request: {
2124
2126
  prompt: [
@@ -2134,26 +2136,32 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2134
2136
  },
2135
2137
  ],
2136
2138
  parent: exec.agent,
2137
- ...(reviewPreset || config.review?.presetId ? { agentPreset: reviewPreset || config.review?.presetId } : {}),
2138
2139
  },
2139
2140
  signal: controller.signal,
2140
2141
  delivery: "caller",
2141
2142
  });
2142
2143
  const result = await Promise.race([activation.result, timeout]);
2144
+ if (controller.signal.aborted) throw new Error("听众评审已取消或超时");
2145
+ if (result?.stopReason !== "completed") throw new Error(`审稿人未正常完成(${result?.stopReason ?? "unknown"})`);
2143
2146
  const text = activationResultText(result);
2144
2147
  const parsed = parseReviewerOutput(text);
2145
- return { reviewer, label: reviewer.label, parsed, text, stopReason: result?.stopReason ?? "" };
2148
+ if (parsed.unparsed) throw new Error("审稿人未返回符合契约的 JSON 意见");
2149
+ if (parsed.issues.some((issue) => !script.includes(issue.evidence))) {
2150
+ throw new Error("审稿意见的证据不是稿件原文,已拒绝采纳");
2151
+ }
2152
+ Object.assign(outcome, { parsed, text, stopReason: result.stopReason });
2146
2153
  } catch (error) {
2147
- return { reviewer, label: reviewer.label, error: error instanceof Error ? error.message : String(error) };
2154
+ outcome.error = error instanceof Error ? error.message : String(error);
2148
2155
  } finally {
2149
2156
  clearTimeout(timer);
2150
2157
  exec.signal?.removeEventListener?.("abort", onAbort);
2151
2158
  try {
2152
2159
  await activation?.dispose?.();
2153
2160
  } catch {
2154
- /* 清理失败不影响评审结果 */
2161
+ outcome.error = "审稿执行清理失败,请检查 DSH 子任务状态";
2155
2162
  }
2156
2163
  }
2164
+ return outcome;
2157
2165
  };
2158
2166
 
2159
2167
  // 审稿人并发跑:每个都是一次独立子 agent,互不影响;失败一个不拖垮整份评审。
@@ -2171,12 +2179,8 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2171
2179
  const generatedAt = new Date().toISOString();
2172
2180
  const target = join(deckDir, "docs", "review.experts.md");
2173
2181
  let written = false;
2174
- if (args.write !== false) {
2182
+ if (args.write !== false && !exec.signal?.aborted) {
2175
2183
  mkdirSync(dirname(target), { recursive: true });
2176
- const unparsed = ok
2177
- .filter((outcome) => outcome.parsed.unparsed)
2178
- .map((outcome) => `### ${outcome.label}\n\n${outcome.text.slice(0, 4000)}`)
2179
- .join("\n\n");
2180
2184
  const body = [
2181
2185
  renderReviewMarkdown({
2182
2186
  deckTitle: deck.title,
@@ -2186,7 +2190,6 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2186
2190
  generatedAt,
2187
2191
  failed,
2188
2192
  }),
2189
- ...(unparsed ? ["", "## 未按 JSON 输出、原文保留", "", unparsed] : []),
2190
2193
  ].join("\n");
2191
2194
  writeFileSync(target, body.endsWith("\n") ? body : `${body}\n`, "utf8");
2192
2195
  written = true;
@@ -2213,7 +2216,7 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2213
2216
  });
2214
2217
  const lines = issues.length
2215
2218
  ? issues.map((issue) => `${issue.severity} · ${issue.page || "—"} · ${issue.problem} → ${issue.suggestion}${issue.reviewer ? `(${issue.reviewer})` : ""}`)
2216
- : ["没有可操作的问题(可能听众视角下确实没问题,也可能审稿人没读懂——先看 docs/review.experts.md 的原始评审)"];
2219
+ : [ok.length ? "已完成评审,未发现有原文证据支持的可操作问题。" : "本次没有有效评审结果;不能据此判断稿件没有问题。"];
2217
2220
  const failureLine = failed.length
2218
2221
  ? `\n${failed.length} 位审稿人失败:${failed.map((item) => `${item.label}(${item.error})`).join(" / ")}`
2219
2222
  : "";
@@ -2222,6 +2225,7 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2222
2225
  reviewers: derived.reviewers.map((reviewer) => ({ id: reviewer.id, label: reviewer.label, audience: reviewer.audience })),
2223
2226
  issues,
2224
2227
  bySeverity,
2228
+ failed,
2225
2229
  written,
2226
2230
  text: `听众评审(${derived.reviewers.map((reviewer) => reviewer.label).join(" / ")}):P0 ${bySeverity.P0} / P1 ${bySeverity.P1} / P2 ${bySeverity.P2}${failureLine}\n${lines.join("\n")}${written ? `\n已写入:${target}` : "\n(dry run,未写文件)"}\n这些是评审意见,不是已改稿:按 P0/P1 用 deck_script_propose_edits 提案,等用户确认后再落版。`,
2227
2231
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-deck-studio",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "private": false,
5
5
  "description": "Deckline:DSH 的 image deck / PPT 工作台插件——原生 Chat + 整页图片 deck 工作区 + 设计风格库;用对话做 PPT、按页出图、版本可追踪、导出 PDF/PPTX。",
6
6
  "license": "MIT",
@@ -30,7 +30,7 @@
30
30
  ],
31
31
  "engines": {
32
32
  "node": "^22.19.0 || >=24.0.0",
33
- "dsh": ">=0.1.7-0 <0.3.0"
33
+ "dsh": ">=0.2.1-rc.2 <0.3.0"
34
34
  },
35
35
  "scripts": {
36
36
  "verify": "node verify/smoke.mjs",
@@ -61,7 +61,7 @@
61
61
  "url": "https://github.com/yuna78/dsh-deck-studio/issues"
62
62
  },
63
63
  "peerDependencies": {
64
- "@deepseek-ai/dsh": ">=0.1.7-0 <0.2.0 || >=0.2.0-0 <0.3.0 || >=0.2.1-0 <0.3.0"
64
+ "@deepseek-ai/dsh": ">=0.2.1-rc.2 <0.3.0"
65
65
  },
66
66
  "author": "Yuna",
67
67
  "files": [
@@ -1,16 +1,16 @@
1
1
  # DSH 兼容矩阵(定期回归记录)
2
2
 
3
3
  > 口径:DSH 处于 prerelease 阶段,没有 stable 线;本项目**定期批次**回归,不逐 rc 跟。
4
- > 每个批次记录:证实的版本、验证范围、断点、复现方式。已知限制与路线见 [productization.md](productization.md) 与 [install.md](install.md)。
4
+ > 每个批次记录:证实的版本、验证范围、断点、复现方式。已知限制见 [install.md](install.md) 的故障排查部分。
5
5
 
6
6
  ## 当前支持声明
7
7
 
8
8
  | 项 | 值 | 说明 |
9
9
  |---|---|---|
10
- | `peerDependencies["@deepseek-ai/dsh"]` | `>=0.1.7-0 <0.2.0 \|\| >=0.2.0-0 <0.3.0 \|\| >=0.2.1-0 <0.3.0` | 装到 0.3+ 会被 DSH 的 peer 检查拦下 |
11
- | `engines.dsh` | `>=0.1.7-0 <0.3.0` | 元数据口径,同 peer |
10
+ | `peerDependencies["@deepseek-ai/dsh"]` | `>=0.2.1-rc.2 <0.3.0` | 装到 0.3+ 会被 DSH 的 peer 检查拦下 |
11
+ | `engines.dsh` | `>=0.2.1-rc.2 <0.3.0` | 插件0.2.6起:内置审稿使用0.2.1 API,旧DSH请升级或保留插件0.2.5 |
12
12
  | `engines.node` | `^22.19.0 \|\| >=24.0.0` | 与 DSH 运行时要求一致;**Node 23 不在范围内** |
13
- | 已验证版本 | `0.1.7-rc.2`(旧基线)· `0.2.1-rc.2`(2026-10-21:隔离环境 + 本地全局升级后真机回归) | 见下表 |
13
+ | 已验证版本 | `0.2.1-rc.2`(0.2.6:内置审稿真实服务19项、产品模型评审;此前版本还有0.1.7旧基线) | 历史批次不代表本版继续支持0.1.7 |
14
14
 
15
15
  ## 回归记录
16
16
 
@@ -10,11 +10,13 @@ assets / conversations on the right. It is not a standalone app — it runs on a
10
10
 
11
11
  ## 0. Prerequisites
12
12
 
13
+ Plugin 0.2.6+ requires DSH 0.2.1-rc.2 or a newer 0.2.x release. Restart DSH after upgrading. Remove any manually declared `preset-deck-critic` entry from your profile; legacy `review.presetId` is ignored. Reviews now run internally without selecting another mode.
14
+
13
15
  | Dependency | Version | Notes |
14
16
  |---|---|---|
15
17
  | Node.js | `^22.19` or `≥ 24` | Same as the DSH runtime; Node 23 is not supported |
16
18
  | pnpm | 10+ | DSH uses pnpm to manage profile dependencies |
17
- | DSH | `≥ 0.1.7 < 0.3` | `npm i -g @deepseek-ai/dsh`; verified on 0.1.7-rc.2 / 0.2.1-rc.2, see [compat.md](compat.md) |
19
+ | DSH | `≥ 0.2.1-rc.2 < 0.3` (plugin 0.2.6+) | `npm i -g @deepseek-ai/dsh`; verified on 0.2.1-rc.2, see [compat.md](compat.md) |
18
20
  | An LLM provider key | — | e.g. `DEEPSEEK_API_KEY` / `OPENAI_API_KEY` (configured in the profile, see DSH docs) |
19
21
  | An image node key | — | For real renders; the fake mode lets you try the flow first (section 4) |
20
22
 
@@ -77,16 +79,18 @@ The minimum (append to the profile's `cordis.patch.yml`):
77
79
  config:
78
80
  displayName: 'Deckline'
79
81
  userName: 'Yuna'
80
- # where new decks are created; empty = the plugin's internal .deck/projects/<id>/source
81
- projectsRoot: '/absolute/path/to/your/sharing'
82
+ # root for new deck source files; empty = <dataRoot>/projects/<deck-id>/source
83
+ projectsRoot: '/absolute/path/to/your/decks'
84
+ # workspacePath: '/absolute/path/to/your/workspace' # DSH chat workspace, not storage
82
85
  decks:
83
86
  - id: my-deck
84
87
  label: 'My deck'
85
88
  sourceRoot: '/absolute/path/to/my-deck'
86
89
  ```
87
90
 
88
- > `decks[].sourceRoot` is read-only: Deckline never writes to the source directory; all edits and
89
- > revisions live in `.deck/`. You can also start with **+ New Deck** if you have no deck yet.
91
+ > `decks[].sourceRoot` is a managed source and always read-only: Deckline never writes to it; working copies,
92
+ > revisions and renders live under `dataRoot`. A locally created deck is different: after user confirmation,
93
+ > script / intent source files may be written back to that local project directory. You can also start with **+ New Deck** if you have no deck yet.
90
94
 
91
95
  ### Per-deck project rules (optional)
92
96
 
@@ -105,8 +109,7 @@ Drop a `deck.rules.md` at the root of a deck project to describe the creative co
105
109
  - It **cannot override** the product's hard rules (read-only source decks / QA gates / no fabrication /
106
110
  user confirmation). In a conflict the hard rules win and the agent says so.
107
111
  - Max 8 KB; it is never written back and never triggers any automatic action.
108
- - Do not name it `AGENTS.md`: that belongs to DSH workspace instructions and Deckline does not depend on it
109
- (see `docs/adr/0020` in the development repo).
112
+ - Do not name it `AGENTS.md`: that belongs to DSH workspace instructions and Deckline does not depend on it.
110
113
 
111
114
  ## 2.5 Where the data lives: source deck / documents / images / exports
112
115
 
@@ -126,14 +129,15 @@ creating a deck, check this table first.
126
129
  - **`dataRoot` defaults to `$DSH_HOME/deckline`** (usually `~/.dsh/deckline`) — never inside the plugin
127
130
  package, so plugin upgrades do not touch it. Override with `dataRoot: '/your/path'` in the profile patch,
128
131
  or the `DSH_DECK_DATA_ROOT` env var.
129
- - **`projectsRoot`** decides where new decks are created; if unset they go to `<dataRoot>/projects/<deckId>/source`.
130
- - **Managed source decks are read-only**: Deckline never writes to a deck registered via `decks[].sourceRoot`.
132
+ - **`projectsRoot`** decides where locally created deck source files live; if unset they go to `<dataRoot>/projects/<deckId>/source`. It does not select the chat workspace.
133
+ - **`workspacePath`** selects the DSH workspace used for conversation binding; it is independent of `projectsRoot` / `dataRoot`. If unset, Deckline resolves it from the startup and source directories.
134
+ - **Managed source decks are read-only**: Deckline never writes to a deck registered via `decks[].sourceRoot`; a confirmed local deck may write back to its own source files.
131
135
  - **Backup**: archive `dataRoot` and `projectsRoot` together (documents + images + events):
132
136
  ```sh
133
137
  tar czf deckline-backup-$(date +%Y%m%d).tgz "$DSH_HOME/deckline" "<your projectsRoot>"
134
138
  ```
135
- - The workbench **Settings** panel shows the current data directory; in the development repo the data root
136
- is the repo's `.deck/` (configured explicitly in the profile).
139
+ - The workbench **Settings** panel shows the current data directory; keep `dataRoot` outside the source tree
140
+ in normal installs so runtime data is portable and plugin upgrades cannot overwrite it.
137
141
 
138
142
  ## 3. Start
139
143
 
@@ -217,7 +221,7 @@ bottom of the preview (paste / pick files work the same way).
217
221
 
218
222
  Samples live under `designs/<ABBR>/` (ABBR is your style abbreviation):
219
223
 
220
- - canvas directory (recommended): `.deck/projects/<canvasDeckId>/source/designs/<ABBR>/`;
224
+ - canvas directory (recommended): `<dataRoot>/projects/<canvasDeckId>/source/designs/<ABBR>/`;
221
225
  - or `designs/<ABBR>/` inside any managed deck source (Deckline scans and merges them).
222
226
 
223
227
  Refresh the page afterwards. png / jpg / webp are supported; names are free-form
@@ -236,7 +240,7 @@ Swiss minimal / aurora gradient), each with samples. See [styles.md](styles.md).
236
240
  | Port already in use | `dsh --profile deckline --port 13100`; or kill the old process with `lsof -tiTCP:13090 -sTCP:LISTEN` |
237
241
  | Page 404 / blank | Make sure the plugin bundle is enabled: `dsh --profile deckline --dump-config \| grep deck-studio` |
238
242
  | No "render" button / render fails | Check the key state in Settings; confirm `render.mode: real`; `curl` the node endpoint |
239
- | New decks land in the wrong place | Set `projectsRoot`; otherwise they go to the plugin data directory `.deck/projects/` |
243
+ | New decks land in the wrong place | Set `projectsRoot`; otherwise they go to `<dataRoot>/projects/` (by default `<DSH_HOME>/deckline/projects/`) |
240
244
  | Where are data / samples | See section 2.5: source decks in `projectsRoot`, working copies / renders in `dataRoot` (default `~/.dsh/deckline`) |
241
245
  | Upgrading the plugin | `dsh plugin --profile deckline install <spec>`, or `add` the new version again |
242
246
 
@@ -11,11 +11,13 @@ Deckline(`dsh-plugin-deck-studio`)是 DeepSeek Harness(DSH)的 **image d
11
11
 
12
12
  ## 0. 前置条件
13
13
 
14
+ 插件0.2.6起需要 DSH 0.2.1-rc.2 或更新的0.2.x;升级后重启 DSH。若手写 profile 仍声明 `preset-deck-critic`,请移除;旧 `review.presetId` 已忽略,评审改由插件内部调度,不需选审稿模式。
15
+
14
16
  | 依赖 | 版本 | 说明 |
15
17
  |---|---|---|
16
18
  | Node.js | `^22.19` 或 `≥ 24` | 与 DSH 运行时一致;Node 23 不在支持范围 |
17
19
  | pnpm | 10+ | DSH 用 pnpm 管理 profile 依赖 |
18
- | DSH | `≥ 0.1.7 < 0.3`(0.1.5 不支持) | **用 npm 全局安装的发行版**:`npm i -g @deepseek-ai/dsh@0.2.1-rc.2`;已验证 0.2.1-rc.2,见 [compat.md](compat.md)。从源码自编译的 dsh 若缺内置包(如 `@deepseek-ai/dsh-agent-preset`)会启动失败,见 4.3 |
20
+ | DSH | `≥ 0.2.1-rc.2 < 0.3`(插件0.2.6起) | **用 npm 全局安装的发行版**:`npm i -g @deepseek-ai/dsh@0.2.1-rc.2`;已验证 0.2.1-rc.2,见 [compat.md](compat.md)。从源码自编译的 dsh 若缺内置包(如 `@deepseek-ai/dsh-agent-preset`)会启动失败,见 4.3 |
19
21
  | 一个 LLM provider key | — | 例:`DEEPSEEK_API_KEY` / `OPENAI_API_KEY`(profile 里配置,见 DSH 文档) |
20
22
  | 一个 image 节点 key | — | 生成整页图用;可先用 fake 模式体验(见第 4 节) |
21
23
 
@@ -111,15 +113,17 @@ Deckline 的配置都在 **profile patch 层**:`~/.dsh/profiles/deckline/cordi
111
113
  config:
112
114
  displayName: 'Deckline'
113
115
  userName: '' # 留空 = 首页只显示「欢迎你」;填了就显示「XX,欢迎你」
114
- # 新建 deck 落盘根:留空 = 插件内部 .deck/projects/<id>/source
115
- projectsRoot: '/absolute/path/to/your/sharing'
116
+ # 新建 deck 源文件根:留空 = <dataRoot>/projects/<deck-id>/source
117
+ projectsRoot: '/absolute/path/to/your/decks'
118
+ # workspacePath: '/absolute/path/to/your/workspace' # DSH 会话工作区,不是保存位置
116
119
  decks:
117
120
  - id: my-deck
118
121
  label: '我的 deck'
119
122
  sourceRoot: '/absolute/path/to/my-deck'
120
123
  ```
121
124
 
122
- > `decks[].sourceRoot` 只读:Deckline 永远不写源目录,编辑产物和版本都在 `.deck/` 里。
125
+ > `decks[].sourceRoot` 是受控源,始终只读:Deckline 不会写入它;编辑工作副本、版本和出图落在 `dataRoot`。
126
+ > 通过「+ 新建 Deck」创建的本机 deck 是另一种情况:用户确认后,script / intent 等源文件可以回写到该本机项目目录。
123
127
  > 没有现成 deck 也可以先用「+ 新建 Deck」建一个。
124
128
 
125
129
  ### 每个 deck 的项目规则(可选)
@@ -137,7 +141,7 @@ Deck Agent 每次 `deck_source_overview` 时会读到并遵守:
137
141
  - 只约束**这一份 deck**,不影响其他 deck;不写就等于没有规则(可选能力)。
138
142
  - 它**不能覆盖**产品硬纪律(客户源只读 / QA 门禁 / 不编造 / 需用户确认)——冲突时以硬纪律为准,Agent 会说明。
139
143
  - 上限 8 KB;读取时不会回写,也不会因为文件内容自动执行任何操作。
140
- - 不叫 `AGENTS.md`:`AGENTS.md` 属于 DSH 的工作区指令,Deckline 不依赖它(见开发仓库的 `docs/adr/0020`)。
144
+ - 不叫 `AGENTS.md`:`AGENTS.md` 属于 DSH 的工作区指令,Deckline 不依赖它。
141
145
 
142
146
  ## 2.5 数据在哪里:源 deck / 文档 / 图片 / 导出
143
147
 
@@ -155,13 +159,14 @@ Deckline 的数据分两处:**你的 deck 源**(`projectsRoot`,纯 md +
155
159
 
156
160
  - **`dataRoot` 默认 `$DSH_HOME/deckline`**(通常是 `~/.dsh/deckline`)——不在插件包里,升级插件不会覆盖;
157
161
  可在 profile patch 配 `dataRoot: '/your/path'`,或环境变量 `DSH_DECK_DATA_ROOT` 覆盖。
158
- - **`projectsRoot`** 决定新建 deck 落在哪;不配置时落在 `<dataRoot>/projects/<deckId>/source`。
159
- - **受控源 deck 只读**:`decks[].sourceRoot` 指向的已有 deck 目录,插件永远不写。
162
+ - **`projectsRoot`** 决定本机新建 deck 的源文件落在哪;不配置时落在 `<dataRoot>/projects/<deckId>/source`。它不决定聊天会话绑定的工作区。
163
+ - **`workspacePath`** 只决定会话绑定哪个 DSH workspace,与 `projectsRoot` / `dataRoot` 相互独立;未配置时插件按启动目录和源目录自动选择工作区。
164
+ - **受控源 deck 只读**:`decks[].sourceRoot` 指向的已有 deck 目录,插件永远不写;本机新建 deck 在用户确认后可回写自己的源文件。
160
165
  - **备份**:把 `dataRoot` 和 `projectsRoot` 一起打包即可(文档 + 图 + 事件都在里面):
161
166
  ```sh
162
167
  tar czf deckline-backup-$(date +%Y%m%d).tgz "$DSH_HOME/deckline" "<你的 projectsRoot>"
163
168
  ```
164
- - 工作台「设置」面板会显示当前数据目录;本仓库开发时数据根在仓库 `.deck/`(profile 里显式配置)。
169
+ - 工作台「设置」面板会显示当前数据目录;开发环境也建议使用独立的 `dataRoot`,不要把运行数据写进仓库。
165
170
  - **preset 工具实例要和根挂载用同一个数据根**(F-98):DSH 面板路由与 Agent 工具是两个实例,根挂载里覆盖了
166
171
  `dataRoot` / `projectsRoot` 时,preset 的 `deck-studio-tools` 也要写同一组值(推荐 YAML 锚点),
167
172
  否则新建 deck 只进一边的注册表,Agent 会报「未知 deck:…」。
@@ -194,7 +199,7 @@ Deckline 支持:
194
199
  3. 填:**模式**(fake / real)、**生图节点 endpoint**、**模型**、**key 环境变量名**(或 **key 文件路径**)、**出图尺寸**;
195
200
  4. 点 **保存并生效**。
196
201
 
197
- 保存后写入本机数据目录 `~/.dsh/deckline/render-settings.json`(开发仓库里是 `.deck/render-settings.json`,
202
+ 保存后写入本机数据目录 `<dataRoot>/render-settings.json`(默认 `~/.dsh/deckline/render-settings.json`;
198
203
  已 gitignore):节点可直存 API key(0600,界面只显示「已保存」,payload 不回显),也可改用变量名 / key 文件;
199
204
  下次启动自动生效,并覆盖 profile 里的 render 配置。
200
205
 
@@ -252,7 +257,7 @@ export DSH_DECK_IMAGE_API_KEY="sk-…" # 只放在你的 shell / 密钥
252
257
  - 没配 key / 仍是 fake 模式时,设置面板会直接给出提示。
253
258
  - 模型名填你的中转实际开放的模型;常见 OpenAI 兼容名 `gpt-image-2` 系列的模型 id 以节点 `/v1/models` 为准。
254
259
  - 工作台「设置 → 出图节点」可以直接切换线路,也能**新增 / 编辑 / 删除节点**:填 名称 + 接口地址 + API Key +
255
- 模型名即可(key 存本机 `.deck/render-settings.json`,0600,不进 git;也可改用 env / key 文件);
260
+ 模型名即可(key 存本机 `<dataRoot>/render-settings.json`,0600,不进 git;也可改用 env / key 文件);
256
261
  切换写当前 deck 的 `render-selection.json`,下一次出图生效,不用重启。
257
262
  - **修图**(`deck_image_edit`)需要节点支持 `POST /v1/images/edits`(multipart:`image` + `prompt` + `size` + `model`)。
258
263
  回车节点通常和出图同源;不填 `editEndpoint` 就按上面的规则派生,派生不出来(自定义路径)时工具会明确报错、不猜地址。
@@ -273,7 +278,7 @@ export DSH_DECK_IMAGE_API_KEY="sk-…" # 只放在你的 shell / 密钥
273
278
 
274
279
  样张存在 `designs/<ABBR>/` 下(ABBR 是你的风格缩写):
275
280
 
276
- - 画布目录(推荐):`.deck/projects/<canvasDeckId>/source/designs/<ABBR>/`;
281
+ - 画布目录(推荐):`<dataRoot>/projects/<canvasDeckId>/source/designs/<ABBR>/`;
277
282
  - 或任何受控 deck 源里的 `designs/<ABBR>/`(Deckline 会自动扫描并合并展示)。
278
283
 
279
284
  放完刷新页面即可。支持 png / jpg / webp;命名随意(`sample-01.jpg` 之类便于排序)。
@@ -290,7 +295,7 @@ Designs 页 **+ 导入预设风格** 会一次导入 8 个自带风格(商
290
295
  | 端口被占 | `dsh --profile deckline --port 13100`;或 `lsof -tiTCP:13090 -sTCP:LISTEN` 杀掉旧进程 |
291
296
  | 页面 404 / 白屏 | 确认插件组合包已启用:`dsh --profile deckline --dump-config \| grep deck-studio` |
292
297
  | 没有「生图」按钮 / 出图失败 | 设置面板看 key 是否配置;确认 `render.mode: real`;节点 endpoint 可 `curl` 通 |
293
- | 新 deck 目录落错位置 | 配 `projectsRoot`;否则落在插件数据目录 `.deck/projects/` |
298
+ | 新 deck 目录落错位置 | 配 `projectsRoot`;否则落在 `<dataRoot>/projects/`(默认 `<DSH_HOME>/deckline/projects/`) |
294
299
  | 数据 / 样张在哪 | 见 §2.5:源 deck 在 `projectsRoot`,工作副本 / 出图在 `dataRoot`(默认 `~/.dsh/deckline`) |
295
300
  | 升级插件 | `dsh plugin --profile deckline install <spec>`,或重新 `add` 新版本 |
296
301
 
@@ -42,8 +42,8 @@ bottom of the preview (paste / pick files work the same way).
42
42
  ### Way 2: drop files directly
43
43
 
44
44
  Put images under `designs/<ABBR>/` (canvas directory
45
- `.deck/projects/<canvasDeckId>/source/designs/<ABBR>/`, or `designs/<ABBR>/` inside any managed deck
46
- source). png / jpg / webp are supported; refresh the page afterwards.
45
+ `<dataRoot>/projects/<canvasDeckId>/source/designs/<ABBR>/`). Existing `designs/<ABBR>/` directories in managed sources are also scanned read-only.
46
+ Prefer uploading new samples through the canvas rather than modifying managed sources. png / jpg / webp are supported; refresh the page afterwards.
47
47
 
48
48
  Fields of `style.json` (the plugin also writes one automatically):
49
49
 
@@ -104,7 +104,7 @@ node scripts/f74-regenerate-presets.mjs --only=BIZ --limit=1
104
104
  ```
105
105
 
106
106
  What the script does: sync `styleBrief` from `seeds/styles/<ABBR>.json` into the online design's
107
- `style.json` → back up old samples to `.deck/tmp/f74-old-samples/<ABBR>/` → render one image per
107
+ `style.json` → back up old samples to `<dataRoot>/tmp/f74-old-samples/<ABBR>/` → render one image per
108
108
  `baselineBriefs` entry (prompt recorded in `samples.jsonl`) → downscale and write back into `seeds/samples/`.
109
109
  Refresh the Designs page afterwards; no service restart needed.
110
110
 
@@ -37,8 +37,8 @@ Designs 页 → **+ 导入预设风格**:一次导入下面 8 个风格(
37
37
 
38
38
  ### 方式 2:直接放文件
39
39
 
40
- 把图片放进 `designs/<ABBR>/` 目录(画布目录 `.deck/projects/<canvasDeckId>/source/designs/<ABBR>/`,
41
- 或任何受控 deck 源里的 `designs/<ABBR>/`)。支撑 png / jpg / webp,刷新页面即可。
40
+ 把图片放进 `designs/<ABBR>/` 目录(画布目录 `<dataRoot>/projects/<canvasDeckId>/source/designs/<ABBR>/`,
41
+ 插件也会只读扫描已有源目录中的 `designs/<ABBR>/`)。新增样张优先通过画布上传,避免修改受控源。支持 png / jpg / webp,刷新页面即可。
42
42
 
43
43
  `style.json` 的字段(插件也会自动写一份):
44
44
 
@@ -94,7 +94,7 @@ node scripts/f74-regenerate-presets.mjs --only=BIZ --limit=1
94
94
  ```
95
95
 
96
96
  脚本做的事:把 `seeds/styles/<ABBR>.json` 的 `styleBrief` 同步进线上 design 的 `style.json` →
97
- 旧样张备份到 `.deck/tmp/f74-old-samples/<ABBR>/` → 按 `baselineBriefs` 逐张出图(prompt 落 `samples.jsonl`)→
97
+ 旧样张备份到 `<dataRoot>/tmp/f74-old-samples/<ABBR>/` → 按 `baselineBriefs` 逐张出图(prompt 落 `samples.jsonl`)→
98
98
  预览回写 `seeds/samples/`。重制后刷新 Designs 页即可看到新样张;不用重启服务。
99
99
 
100
100
  > 样张里的文字是示意(图像模型会糊字),不承诺逐字复刻;也不要把真实客户资料、品牌 logo 放进样例图。
package/ui/workbench.html CHANGED
@@ -130,7 +130,7 @@
130
130
  <button class="modal-close" id="ndClose" title="关闭">✕</button>
131
131
  </div>
132
132
  <form class="modal-body newdeck-body" id="newDeckForm">
133
- <p class="nd-tip">先定「讲给谁、讲什么、想带走什么」+ 视觉风格。创建后会开出源目录与 9 份文档骨架(新项目落在 <code>00_company/sharing/&lt;目录名&gt;/</code>),然后在对话里继续补资料、改 script,直到可以出图。</p>
133
+ <p class="nd-tip">先定「讲给谁、讲什么、想带走什么」+ 视觉风格。创建后会开出源目录与 9 份文档骨架,然后在对话里继续补资料、改 script,直到可以出图。<br>新项目保存位置:<code id="ndStoragePath">正在读取配置…</code>。<span id="ndStorageNote"></span></p>
134
134
  <label class="field"><span>主题 / 标题</span><input id="ndTitle" required placeholder="例:XX 业务场景的 AI 落地分享"></label>
135
135
  <label class="field"><span>项目目录名(可选,英文小写中划线;留空自动生成)</span><input id="ndDirName" placeholder="例:talk-ai-transformation-6-stages"></label>
136
136
  <label class="field"><span>页数(占位页 1–40;创建后还能在对话里追加)</span><input id="ndPages" type="number" min="1" max="40" inputmode="numeric" value="6"></label>
@@ -1411,14 +1411,30 @@ async function fetchDoc(docId) {
1411
1411
  if (STATE.docCache[docId]) return STATE.docCache[docId];
1412
1412
  if (STATE.docLoading === docId) return null;
1413
1413
  STATE.docLoading = docId;
1414
+ const deckId = STATE.data?.active?.deckId ?? "";
1415
+ // F-163:加载失败必须可见。以前只 throw,中间的 spinner 永远不停,用户不知道是 404、500 还是超时。
1416
+ const controller = typeof AbortController === "function" ? new AbortController() : null;
1417
+ const timer = controller ? setTimeout(() => controller.abort(), 15000) : null;
1414
1418
  try {
1415
- const deckId = STATE.data.active.deckId;
1416
- const res = await fetch(`/api/deck-studio.doc?` + new URLSearchParams({ deckId, docId }));
1417
- if (!res.ok) throw new Error("HTTP " + res.status);
1418
- const doc = await res.json();
1419
- if (doc.ok) STATE.docCache[docId] = doc;
1419
+ const res = await fetch(
1420
+ `/api/deck-studio.doc?` + new URLSearchParams({ deckId, docId }),
1421
+ controller ? { signal: controller.signal } : undefined,
1422
+ );
1423
+ const text = await res.text();
1424
+ let doc = null;
1425
+ try { doc = JSON.parse(text); } catch { doc = null; }
1426
+ if (!res.ok || !doc || doc.ok === false) {
1427
+ const detail = doc?.error?.message || text.replace(/\s+/g, " ").trim().slice(0, 200) || res.statusText;
1428
+ throw new Error(`${res.status} · ${detail}`);
1429
+ }
1430
+ STATE.docCache[docId] = doc;
1420
1431
  return doc;
1432
+ } catch (error) {
1433
+ const message = error?.name === "AbortError" ? "请求超时(15 秒没有响应)" : String(error?.message ?? error);
1434
+ console.warn(`[deck-studio] 读取文档失败 docId=${docId} deckId=${deckId}:${message}`);
1435
+ return { ok: false, docId, error: { message } };
1421
1436
  } finally {
1437
+ if (timer) clearTimeout(timer);
1422
1438
  STATE.docLoading = null;
1423
1439
  }
1424
1440
  }
@@ -1430,6 +1446,18 @@ async function renderDocTab(docId) {
1430
1446
  if (!cached) view.innerHTML = `<div class="observe-body"><div class="doc-loading">读取 ${esc(index?.label ?? docId)} …</div></div>`;
1431
1447
  const doc = cached ?? (await fetchDoc(docId));
1432
1448
  if (!doc || STATE.tab !== docId) return; // 加载期间用户已切走
1449
+ if (doc.ok === false) {
1450
+ view.innerHTML = `<div class="observe-body">
1451
+ <div class="doc-loading">读取「${esc(index?.label ?? docId)}」失败:${esc(doc.error?.message || "未知错误")}</div>
1452
+ <p style="text-align:center;margin-top:10px"><button type="button" class="btn" data-doc-retry="${esc(docId)}">重试</button></p>
1453
+ <p class="small" style="text-align:center">先在终端看服务端日志;若一直失败,把上面这行错误发我。</p>
1454
+ </div>`;
1455
+ view.querySelectorAll("[data-doc-retry]").forEach((btn) => btn.addEventListener("click", () => {
1456
+ STATE.docCache[docId] = null;
1457
+ renderDocTab(docId);
1458
+ }));
1459
+ return;
1460
+ }
1433
1461
  view.innerHTML = `<div class="observe-body">${renderDoc(doc, index)}</div>`;
1434
1462
  bindDocActions(view);
1435
1463
  }
@@ -2780,15 +2808,19 @@ async function ensurePromptConversation() {
2780
2808
  if (!target) return;
2781
2809
  const bound = chatSessionsForTarget(target);
2782
2810
  if (bound.length) {
2783
- const current = currentChatSessionId();
2784
- if (bound.some((session) => session.sessionId === current)) return;
2785
- await openBoundConversation(bound[0].sessionId);
2811
+ // 发送操作也要核对 preset,不能只因绑定存在就向标准模式发制作指令。
2812
+ const result = target.kind === "deck"
2813
+ ? await ensureDeckConversation(target.id)
2814
+ : await openDesignProject(target.id);
2815
+ if (!result?.ok) throw new Error(result?.reason || "无法打开 Deckline 制作会话");
2786
2816
  await new Promise((resolve) => setTimeout(resolve, 250));
2787
2817
  return;
2788
2818
  }
2789
2819
  const active = STATE.data?.active;
2790
- if (active?.kind === "design-canvas") await openDesignProject(active.designId);
2791
- else if (active?.deckId) await ensureDeckConversation(active.deckId);
2820
+ const result = active?.kind === "design-canvas"
2821
+ ? await openDesignProject(active.designId)
2822
+ : active?.deckId ? await ensureDeckConversation(active.deckId) : null;
2823
+ if (!result?.ok) throw new Error(result?.reason || "无法打开 Deckline 制作会话");
2792
2824
  await new Promise((resolve) => setTimeout(resolve, 250));
2793
2825
  }
2794
2826
 
@@ -2809,6 +2841,8 @@ async function sendAgentPrompt() {
2809
2841
  }
2810
2842
  state.textContent = "已发送到左侧对话 ✓";
2811
2843
  setTimeout(closeAgentPrompt, 900);
2844
+ } catch (error) {
2845
+ state.textContent = `未能发送:${error?.message || error}`;
2812
2846
  } finally {
2813
2847
  button.disabled = false;
2814
2848
  }
@@ -3434,11 +3468,23 @@ function renderDeckStyleChoices() {
3434
3468
  }
3435
3469
  }
3436
3470
 
3471
+ function newDeckStorageInfo(config = {}) {
3472
+ const root = String(config?.projectsRoot || "").trim();
3473
+ const dataRoot = String(config?.dataRoot || "").trim();
3474
+ const append = (base, suffix) => `${base.replace(/[\\/]+$/, "")}/${suffix}`;
3475
+ if (root) return { path: append(root, "<目录名或自动生成的 deck-id>/"), note: "由 profile 的 projectsRoot 配置;与会话工作区独立。" };
3476
+ if (dataRoot) return { path: append(dataRoot, "projects/<deck-id>/source/"), note: "未配置 projectsRoot,使用插件数据目录;不会自动保存在当前工作区。" };
3477
+ return { path: "暂时无法确定", note: "请等待工作台配置加载后重试;未配置 projectsRoot 时使用 <dataRoot>/projects/<deck-id>/source/。" };
3478
+ }
3479
+
3437
3480
  function openNewDeckModal() {
3438
3481
  // F-112:profile 里配了 defaultStyleAbbr 就默认预选这个风格(用户仍可改/取消)。
3439
3482
  STATE.ndStyleAbbr = String(STATE.data?.config?.defaultStyleAbbr || "");
3440
3483
  getEl("newDeckForm").reset();
3441
3484
  getEl("ndState").textContent = "";
3485
+ const storage = newDeckStorageInfo(STATE.data?.config);
3486
+ getEl("ndStoragePath").textContent = storage.path;
3487
+ getEl("ndStorageNote").textContent = storage.note;
3442
3488
  getEl("newDeckModal").hidden = false;
3443
3489
  renderDeckStyleChoices();
3444
3490
  setTimeout(() => getEl("ndTitle").focus(), 30);
@@ -3788,7 +3834,14 @@ function requestConversation(payload) {
3788
3834
  resolve({ ok: Boolean(event.data.ok), sessionId: event.data.sessionId || "", reason: event.data.reason || "" });
3789
3835
  };
3790
3836
  window.addEventListener("message", onAck);
3791
- target.postMessage({ type: "dsh-deck-studio:conversation", requestId, cwd: STATE.data?.cwd || "", ...payload }, "*");
3837
+ target.postMessage({
3838
+ type: "dsh-deck-studio:conversation",
3839
+ requestId,
3840
+ cwd: STATE.data?.cwd || "",
3841
+ workspacePath: STATE.data?.workspacePath || "",
3842
+ sourceRoot: STATE.data?.active?.sourceRoot || "",
3843
+ ...payload,
3844
+ }, "*");
3792
3845
  setTimeout(() => {
3793
3846
  if (settled) return;
3794
3847
  cleanup();
@@ -3850,14 +3903,17 @@ async function ensureDeckConversation(deckId) {
3850
3903
  const chosen = bound[0] || null;
3851
3904
  const binding = chosen ? { sessionId: chosen.sessionId } : null;
3852
3905
  const result = await requestConversation({
3853
- action: chosen ? "open" : "ensure",
3906
+ action: "ensure",
3854
3907
  sessionId: chosen?.sessionId || "",
3855
3908
  kind: "deck",
3856
3909
  id: deckId,
3857
3910
  title: label,
3858
- prompt: chosen ? "" : `本对话绑定 deck ${deckId}(${label})。后续所有修改都在这个 deck 上进行;先读 intent 与 script 概要,等我的指令,不要直接出图。`,
3911
+ prompt: `本对话绑定 deck ${deckId}(${label})。后续所有修改都在这个 deck 上进行;先读 intent 与 script 概要,等我的指令,不要直接出图。`,
3859
3912
  });
3860
- if (!result.ok) return result;
3913
+ if (!result.ok) {
3914
+ getEl("chatBindChip").textContent = `会话打开失败:${result.reason || "未知原因"}`;
3915
+ return result;
3916
+ }
3861
3917
  if (!binding || binding.sessionId !== result.sessionId) {
3862
3918
  await bindConversation({ sessionId: result.sessionId, kind: "deck", id: deckId, title: label });
3863
3919
  }
@@ -3872,25 +3928,28 @@ async function openDesignProject(designId) {
3872
3928
  const project = (STATE.data?.designProjects || []).find((item) => item.designId === designId);
3873
3929
  if (!project) return;
3874
3930
  showDeck(project.canvasDeckId);
3875
- const binding = bindingOf("design", designId);
3931
+ const target = chatTargetForBinding({ kind: "design", id: designId });
3932
+ const binding = chatSessionsForTarget(target)[0] || null;
3876
3933
  const title = `风格 · ${project.name}`;
3877
3934
  const result = await requestConversation({
3878
- action: binding ? "open" : "ensure",
3935
+ action: "ensure",
3879
3936
  sessionId: binding?.sessionId || "",
3880
3937
  kind: "design",
3881
3938
  id: designId,
3882
3939
  title,
3883
- prompt: binding ? "" : buildDesignPrompt(project, project.canvasDeckId),
3940
+ prompt: buildDesignPrompt(project, project.canvasDeckId),
3884
3941
  });
3885
3942
  if (result.ok && (!binding || binding.sessionId !== result.sessionId)) {
3886
3943
  await bindConversation({ sessionId: result.sessionId, kind: "design", id: designId, title });
3887
3944
  } else if (!result.ok) {
3888
3945
  STATE.lastConvError = result.reason || "未知原因";
3946
+ getEl("chatBindChip").textContent = `会话打开失败:${STATE.lastConvError}`;
3889
3947
  }
3890
3948
  if (result.ok) {
3891
3949
  STATE.chatSessionId = result.sessionId;
3892
3950
  renderChatHeader();
3893
3951
  }
3952
+ return result;
3894
3953
  }
3895
3954
 
3896
3955
  /** Conversations 页点会话:切过去,并把工作台切到它绑定的 deck / design 画布。 */