dsh-plugin-deck-studio 0.0.0-stage → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -2
  3. package/client.js +500 -0
  4. package/cordis.example.yml +163 -0
  5. package/cordis.patch.yml +147 -0
  6. package/index.js +2340 -0
  7. package/lib/config.js +204 -0
  8. package/lib/content-density.js +98 -0
  9. package/lib/deck-brief.js +87 -0
  10. package/lib/deck-docs.js +1095 -0
  11. package/lib/deck-rules.js +124 -0
  12. package/lib/deck-templates.js +149 -0
  13. package/lib/deck-workspace.js +480 -0
  14. package/lib/design-style.js +157 -0
  15. package/lib/designs.js +175 -0
  16. package/lib/diff.js +88 -0
  17. package/lib/exporter.js +336 -0
  18. package/lib/faq.js +300 -0
  19. package/lib/git-commit.js +87 -0
  20. package/lib/home-catalog.js +249 -0
  21. package/lib/png.js +275 -0
  22. package/lib/proposal-decision.js +204 -0
  23. package/lib/render-nodes.js +171 -0
  24. package/lib/render-settings.js +213 -0
  25. package/lib/render.js +287 -0
  26. package/lib/review-personas.js +258 -0
  27. package/lib/script-style.js +55 -0
  28. package/lib/script-writer.js +165 -0
  29. package/lib/source-adapter.js +327 -0
  30. package/lib/store.js +212 -0
  31. package/lib/tools.js +2594 -0
  32. package/lib/watermark.js +137 -0
  33. package/package.json +66 -4
  34. package/seeds/resources/deckline-logo.png +0 -0
  35. package/seeds/resources/deckline-logo.svg +12 -0
  36. package/seeds/resources/deckline-mark.png +0 -0
  37. package/seeds/resources/index.json +19 -0
  38. package/seeds/samples/BIZ-01.jpg +0 -0
  39. package/seeds/samples/BIZ-02.jpg +0 -0
  40. package/seeds/samples/BIZ-03.jpg +0 -0
  41. package/seeds/samples/EDT-01.jpg +0 -0
  42. package/seeds/samples/EDT-02.jpg +0 -0
  43. package/seeds/samples/EDT-03.jpg +0 -0
  44. package/seeds/samples/FYI-01.jpg +0 -0
  45. package/seeds/samples/FYI-02.jpg +0 -0
  46. package/seeds/samples/FYI-03.jpg +0 -0
  47. package/seeds/samples/GRD-01.jpg +0 -0
  48. package/seeds/samples/GRD-02.jpg +0 -0
  49. package/seeds/samples/GRD-03.jpg +0 -0
  50. package/seeds/samples/JPN-01.jpg +0 -0
  51. package/seeds/samples/JPN-02.jpg +0 -0
  52. package/seeds/samples/JPN-03.jpg +0 -0
  53. package/seeds/samples/SKT-01.jpg +0 -0
  54. package/seeds/samples/SKT-02.jpg +0 -0
  55. package/seeds/samples/SKT-03.jpg +0 -0
  56. package/seeds/samples/SWS-01.jpg +0 -0
  57. package/seeds/samples/SWS-02.jpg +0 -0
  58. package/seeds/samples/SWS-03.jpg +0 -0
  59. package/seeds/samples/TEC-01.jpg +0 -0
  60. package/seeds/samples/TEC-02.jpg +0 -0
  61. package/seeds/samples/TEC-03.jpg +0 -0
  62. package/seeds/styles/BIZ.json +22 -0
  63. package/seeds/styles/EDT.json +22 -0
  64. package/seeds/styles/FYI.json +22 -0
  65. package/seeds/styles/GRD.json +22 -0
  66. package/seeds/styles/JPN.json +22 -0
  67. package/seeds/styles/SKT.json +22 -0
  68. package/seeds/styles/SWS.json +22 -0
  69. package/seeds/styles/TEC.json +22 -0
  70. package/ui/logo.svg +20 -0
  71. package/ui/workbench.css +687 -0
  72. package/ui/workbench.html +4554 -0
package/lib/png.js ADDED
@@ -0,0 +1,275 @@
1
+ /**
2
+ * 最小 PNG 编解码 + 缩放 + 叠加(F-125 水印用)。
3
+ *
4
+ * 为什么自己写:插件对外承诺「零第三方依赖」,而出图产物是 PNG(real renderer 写
5
+ * image.png)。水印只需要:读 PNG → 解码成 RGBA → 把 logo 缩放到目标尺寸 → alpha 叠加
6
+ * → 再编码回 PNG。用 node 内置 zlib 足够,不引 sharp / canvas。
7
+ *
8
+ * 支持范围(够用且明确报错,不静默出错):
9
+ * - 解码:8 bit、非隔行;colorType 0/2/3/4/6(灰度 / RGB / 调色板 / 灰度+alpha / RGBA),
10
+ * 调色板带 tRNS 透明度;不支持 16 bit / Adam7 / APNG。
11
+ * - 编码:RGBA(colorType 6)+ filter 0。
12
+ *
13
+ * @module dsh-plugin-deck-studio/png
14
+ */
15
+
16
+ import { deflateSync, inflateSync } from "node:zlib";
17
+
18
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
19
+
20
+ const CRC_TABLE = (() => {
21
+ const table = new Uint32Array(256);
22
+ for (let n = 0; n < 256; n += 1) {
23
+ let c = n;
24
+ for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
25
+ table[n] = c >>> 0;
26
+ }
27
+ return table;
28
+ })();
29
+
30
+ function crc32(buffer) {
31
+ let c = 0xffffffff;
32
+ for (let i = 0; i < buffer.length; i += 1) c = CRC_TABLE[(c ^ buffer[i]) & 0xff] ^ (c >>> 8);
33
+ return (c ^ 0xffffffff) >>> 0;
34
+ }
35
+
36
+ function chunk(type, data) {
37
+ const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
38
+ const out = Buffer.alloc(body.length + 8);
39
+ out.writeUInt32BE(data.length, 0);
40
+ body.copy(out, 4);
41
+ out.writeUInt32BE(crc32(body), body.length + 4);
42
+ return out;
43
+ }
44
+
45
+ function paeth(a, b, c) {
46
+ const p = a + b - c;
47
+ const pa = Math.abs(p - a);
48
+ const pb = Math.abs(p - b);
49
+ const pc = Math.abs(p - c);
50
+ if (pa <= pb && pa <= pc) return a;
51
+ return pb <= pc ? b : c;
52
+ }
53
+
54
+ /**
55
+ * 解码 PNG → { width, height, data: Uint8Array RGBA }。
56
+ * 不认识的就抛错(调用方决定是否跳过该页),不返回半成品。
57
+ */
58
+ export function decodePng(buffer) {
59
+ if (!Buffer.isBuffer(buffer) || buffer.length < 8 || !buffer.subarray(0, 8).equals(PNG_SIGNATURE)) {
60
+ throw new Error("不是 PNG 文件");
61
+ }
62
+ let offset = 8;
63
+ let header = null;
64
+ let palette = null;
65
+ let transparency = null;
66
+ const idat = [];
67
+ while (offset + 8 <= buffer.length) {
68
+ const length = buffer.readUInt32BE(offset);
69
+ const type = buffer.toString("ascii", offset + 4, offset + 8);
70
+ const data = buffer.subarray(offset + 8, offset + 8 + length);
71
+ offset += 12 + length;
72
+ if (type === "IHDR") {
73
+ header = {
74
+ width: data.readUInt32BE(0),
75
+ height: data.readUInt32BE(4),
76
+ bitDepth: data[8],
77
+ colorType: data[9],
78
+ compression: data[10],
79
+ filter: data[11],
80
+ interlace: data[12],
81
+ };
82
+ } else if (type === "PLTE") {
83
+ palette = data;
84
+ } else if (type === "tRNS") {
85
+ transparency = data;
86
+ } else if (type === "IDAT") {
87
+ idat.push(data);
88
+ } else if (type === "IEND") {
89
+ break;
90
+ }
91
+ }
92
+ if (!header) throw new Error("PNG 缺 IHDR");
93
+ if (header.bitDepth !== 8) throw new Error(`暂不支持 ${header.bitDepth} bit PNG(水印需要 8 bit)`);
94
+ if (header.interlace !== 0) throw new Error("暂不支持隔行(Adam7)PNG");
95
+ if (header.compression !== 0 || header.filter !== 0) throw new Error("PNG 压缩 / 过滤方式不支持");
96
+ const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[header.colorType];
97
+ if (!channels) throw new Error(`暂不支持 PNG colorType=${header.colorType}`);
98
+ const raw = inflateSync(Buffer.concat(idat));
99
+ const { width, height } = header;
100
+ const bpp = channels;
101
+ const rowBytes = width * bpp;
102
+ const stride = rowBytes + 1;
103
+ if (raw.length < stride * height) throw new Error("PNG 像素数据不完整");
104
+ const pixels = new Uint8Array(width * height * 4);
105
+ const prev = new Uint8Array(rowBytes);
106
+ const line = new Uint8Array(rowBytes);
107
+ const paletteEntries = palette
108
+ ? Array.from({ length: Math.floor(palette.length / 3) }, (_, index) => [palette[index * 3], palette[index * 3 + 1], palette[index * 3 + 2], transparency?.[index] ?? 255])
109
+ : null;
110
+
111
+ for (let y = 0; y < height; y += 1) {
112
+ const filter = raw[y * stride];
113
+ const srcRow = raw.subarray(y * stride + 1, y * stride + 1 + rowBytes);
114
+ for (let x = 0; x < rowBytes; x += 1) {
115
+ const left = x >= bpp ? line[x - bpp] : 0;
116
+ const up = prev[x];
117
+ const upLeft = x >= bpp ? prev[x - bpp] : 0;
118
+ let value = srcRow[x];
119
+ if (filter === 1) value += left;
120
+ else if (filter === 2) value += up;
121
+ else if (filter === 3) value += Math.floor((left + up) / 2);
122
+ else if (filter === 4) value += paeth(left, up, upLeft);
123
+ else if (filter !== 0) throw new Error(`PNG 行过滤类型不支持:${filter}`);
124
+ line[x] = value & 0xff;
125
+ }
126
+ for (let x = 0; x < width; x += 1) {
127
+ const target = (y * width + x) * 4;
128
+ const index = x * bpp;
129
+ if (header.colorType === 6) {
130
+ pixels[target] = line[index];
131
+ pixels[target + 1] = line[index + 1];
132
+ pixels[target + 2] = line[index + 2];
133
+ pixels[target + 3] = line[index + 3];
134
+ } else if (header.colorType === 2) {
135
+ pixels[target] = line[index];
136
+ pixels[target + 1] = line[index + 1];
137
+ pixels[target + 2] = line[index + 2];
138
+ pixels[target + 3] = 255;
139
+ } else if (header.colorType === 0) {
140
+ pixels[target] = pixels[target + 1] = pixels[target + 2] = line[index];
141
+ pixels[target + 3] = 255;
142
+ } else if (header.colorType === 4) {
143
+ pixels[target] = pixels[target + 1] = pixels[target + 2] = line[index];
144
+ pixels[target + 3] = line[index + 1];
145
+ } else if (header.colorType === 3) {
146
+ if (!paletteEntries) throw new Error("调色板 PNG 缺 PLTE");
147
+ const entry = paletteEntries[line[index]] ?? [0, 0, 0, 255];
148
+ pixels[target] = entry[0];
149
+ pixels[target + 1] = entry[1];
150
+ pixels[target + 2] = entry[2];
151
+ pixels[target + 3] = entry[3];
152
+ }
153
+ }
154
+ prev.set(line);
155
+ }
156
+ return { width, height, data: pixels };
157
+ }
158
+
159
+ /** 编码 RGBA(Uint8Array/Uint8ClampedArray)→ PNG Buffer。 */
160
+ export function encodePng({ width, height, data }) {
161
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) throw new Error("PNG 尺寸非法");
162
+ if (data.length !== width * height * 4) throw new Error("RGBA 数据长度与尺寸不匹配");
163
+ const stride = width * 4;
164
+ const raw = Buffer.alloc((stride + 1) * height);
165
+ for (let y = 0; y < height; y += 1) {
166
+ raw[y * (stride + 1)] = 0; // filter 0
167
+ Buffer.from(data.buffer, data.byteOffset + y * stride, stride).copy(raw, y * (stride + 1) + 1);
168
+ }
169
+ const header = Buffer.alloc(13);
170
+ header.writeUInt32BE(width, 0);
171
+ header.writeUInt32BE(height, 4);
172
+ header[8] = 8; // bitDepth
173
+ header[9] = 6; // RGBA
174
+ header[10] = 0;
175
+ header[11] = 0;
176
+ header[12] = 0;
177
+ return Buffer.concat([
178
+ PNG_SIGNATURE,
179
+ chunk("IHDR", header),
180
+ chunk("IDAT", deflateSync(raw, { level: 9 })),
181
+ chunk("IEND", Buffer.alloc(0)),
182
+ ]);
183
+ }
184
+
185
+ /**
186
+ * 面积平均缩放(预乘 alpha,避免透明边缘发黑)。
187
+ * 放大用双线性近似(直接按最近邻会更脆;面积法放大同样可用)。
188
+ */
189
+ export function resizeRgba(image, targetWidth, targetHeight) {
190
+ const { width, height, data } = image;
191
+ if (targetWidth === width && targetHeight === height) return { width, height, data: Uint8Array.from(data) };
192
+ const out = new Uint8Array(targetWidth * targetHeight * 4);
193
+ const scaleX = width / targetWidth;
194
+ const scaleY = height / targetHeight;
195
+ for (let y = 0; y < targetHeight; y += 1) {
196
+ const y0 = Math.floor(y * scaleY);
197
+ const y1 = Math.max(y0 + 1, Math.min(height, Math.ceil((y + 1) * scaleY)));
198
+ for (let x = 0; x < targetWidth; x += 1) {
199
+ const x0 = Math.floor(x * scaleX);
200
+ const x1 = Math.max(x0 + 1, Math.min(width, Math.ceil((x + 1) * scaleX)));
201
+ let r = 0;
202
+ let g = 0;
203
+ let b = 0;
204
+ let a = 0;
205
+ let count = 0;
206
+ for (let sy = y0; sy < y1; sy += 1) {
207
+ for (let sx = x0; sx < x1; sx += 1) {
208
+ const index = (sy * width + sx) * 4;
209
+ const alpha = data[index + 3] / 255;
210
+ r += data[index] * alpha;
211
+ g += data[index + 1] * alpha;
212
+ b += data[index + 2] * alpha;
213
+ a += alpha;
214
+ count += 1;
215
+ }
216
+ }
217
+ const target = (y * targetWidth + x) * 4;
218
+ if (a > 0) {
219
+ out[target] = Math.round(r / a);
220
+ out[target + 1] = Math.round(g / a);
221
+ out[target + 2] = Math.round(b / a);
222
+ }
223
+ out[target + 3] = Math.round((a / count) * 255);
224
+ }
225
+ }
226
+ return { width: targetWidth, height: targetHeight, data: out };
227
+ }
228
+
229
+ /**
230
+ * 把 logo 叠加到页面图上(原地生成新 buffer)。
231
+ * position:top-right / top-left / bottom-right / bottom-left / center;margin/scale 都是页面宽度的比例。
232
+ */
233
+ export function compositeLogo({ page, logo, position = "top-right", opacity = 0.85, scale = 0.14, margin = 0.04 }) {
234
+ const pageWidth = page.width;
235
+ const pageHeight = page.height;
236
+ const logoWidth = Math.max(8, Math.round(pageWidth * Math.min(Math.max(scale, 0.02), 0.5)));
237
+ const logoHeight = Math.max(4, Math.round((logo.height / logo.width) * logoWidth));
238
+ const scaled = resizeRgba(logo, logoWidth, logoHeight);
239
+ const gap = Math.round(pageWidth * Math.min(Math.max(margin, 0), 0.2));
240
+ const positions = {
241
+ "top-right": [pageWidth - logoWidth - gap, gap],
242
+ "top-left": [gap, gap],
243
+ "bottom-right": [pageWidth - logoWidth - gap, pageHeight - logoHeight - gap],
244
+ "bottom-left": [gap, pageHeight - logoHeight - gap],
245
+ center: [Math.round((pageWidth - logoWidth) / 2), Math.round((pageHeight - logoHeight) / 2)],
246
+ };
247
+ const [originX, originY] = positions[position] ?? positions["top-right"];
248
+ const alphaScale = Math.min(Math.max(Number(opacity) || 0, 0), 1);
249
+ const out = Uint8Array.from(page.data);
250
+ for (let y = 0; y < logoHeight; y += 1) {
251
+ const pageY = originY + y;
252
+ if (pageY < 0 || pageY >= pageHeight) continue;
253
+ for (let x = 0; x < logoWidth; x += 1) {
254
+ const pageX = originX + x;
255
+ if (pageX < 0 || pageX >= pageWidth) continue;
256
+ const src = (y * logoWidth + x) * 4;
257
+ const srcAlpha = (scaled.data[src + 3] / 255) * alphaScale;
258
+ if (srcAlpha <= 0) continue;
259
+ const dst = (pageY * pageWidth + pageX) * 4;
260
+ out[dst] = Math.round(scaled.data[src] * srcAlpha + out[dst] * (1 - srcAlpha));
261
+ out[dst + 1] = Math.round(scaled.data[src + 1] * srcAlpha + out[dst + 1] * (1 - srcAlpha));
262
+ out[dst + 2] = Math.round(scaled.data[src + 2] * srcAlpha + out[dst + 2] * (1 - srcAlpha));
263
+ out[dst + 3] = 255;
264
+ }
265
+ }
266
+ return {
267
+ image: { width: pageWidth, height: pageHeight, data: out },
268
+ placement: { position, originX, originY, logoWidth, logoHeight, opacity: alphaScale, scale },
269
+ };
270
+ }
271
+
272
+ /** 读一张 PNG 文件 → 解码后的图(调用方自己兜异常)。 */
273
+ export function readPng(buffer) {
274
+ return decodePng(buffer);
275
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * 提案裁决(F-108):把「用户确认/驳回提案」的逻辑从工具里抽出来,
3
+ * 让 Agent 的 deck_revision_confirm 与工作台按钮(POST /api/deck-studio.proposal-decide)
4
+ * 走同一条写入路径——不会出现「UI 确认了但 revision/源文件没写」的两套账。
5
+ *
6
+ * 写入纪律不变:只追加 revision(copy-on-write),本机 deck 回写源 script.md,
7
+ * 受控客户源只读;每一步都留 `deck.edit` 事件与 task 状态。
8
+ *
9
+ * @module dsh-plugin-deck-studio/proposal-decision
10
+ */
11
+
12
+ import { existsSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+
15
+ import { SCRIPT_FIELD_CELL, proposalNow, scriptSectionRange } from "./deck-workspace.js";
16
+ import { commitFiles } from "./git-commit.js";
17
+
18
+ /**
19
+ * 本机新建 deck:确认后的脚本内容回写源 script.md + git 留痕(F-100 / ADR-0022)。
20
+ * 受控客户源(isLocalDeck=false)仍然只读:只写 .deck 工作副本,不回写源。
21
+ */
22
+ export function createScriptToSourceSync({ adapters = [], isLocalDeck = null } = {}) {
23
+ return function syncScriptToSource(deckId, scriptText, proposal = null) {
24
+ if (typeof isLocalDeck !== "function" || !isLocalDeck(deckId)) return null;
25
+ const adapter = adapters.find((candidate) => candidate.id === deckId) ?? null;
26
+ const sourceRoot = String(adapter?.sourceRoot ?? "");
27
+ if (!sourceRoot || !existsSync(sourceRoot)) return null;
28
+ const file = join(sourceRoot, "script.md");
29
+ writeFileSync(file, scriptText.endsWith("\n") ? scriptText : `${scriptText}\n`, "utf8");
30
+ const label = proposal?.kind === "script-add-page" ? "追加页" : "改稿";
31
+ const git = commitFiles({ dir: sourceRoot, files: [file], message: `deck(${deckId}):${label}回写 script` });
32
+ return { path: file, ok: git?.ok === true, commit: git?.commit ?? null, error: git?.ok ? null : git?.error ?? "git 未提交" };
33
+ };
34
+ }
35
+
36
+ /** 把一条 waiting-user 提案落成 revision(或记为用户驳回)。 */
37
+ export function decideProposal({ store, workspace, proposal, confirmed, quote = "", sessionId = null, syncScriptToSource = null }) {
38
+ if (!proposal) throw new Error("提案不存在");
39
+ if (proposal.status !== "waiting-user") throw new Error(`提案 ${proposal.proposalId} 状态为 ${proposal.status},只有 waiting-user 可裁决`);
40
+
41
+ const task = store.taskList(200).find((candidate) => candidate.kind === "edit" && (candidate.inputRefs ?? []).includes(`proposal:${proposal.proposalId}`));
42
+
43
+ if (confirmed !== true) {
44
+ workspace.updateProposal(proposal, { status: "rejected", decidedAt: proposalNow(), decidedBy: "user", note: String(quote ?? "") });
45
+ if (task) store.taskUpdate(task.taskId, { status: "cancelled", actor: "user", error: "user-rejected" });
46
+ store.recordEvent({
47
+ type: "deck.edit",
48
+ sessionId,
49
+ taskId: task?.taskId ?? null,
50
+ deckId: proposal.deckId,
51
+ slideIds: [proposal.pageId],
52
+ actor: "user",
53
+ status: "failed",
54
+ payload: { proposalId: proposal.proposalId, outcome: "rejected", note: String(quote ?? "") },
55
+ });
56
+ return { proposalId: proposal.proposalId, status: "rejected", deckId: proposal.deckId, text: `提案 ${proposal.proposalId} 已按用户意思驳回并留痕。` };
57
+ }
58
+
59
+ // F-137:确认不再强制「必须引用原话」。用户在 Chat 说「就这样 / 可以」、或在工作台点「确认落版」都算确认;
60
+ // 拿不到原话时仍落版,但事件里记 quoteMissing=true 留痕(审计可查),并把警告回给 Agent。
61
+ const confirmationQuote = String(quote ?? "").trim();
62
+ const quoteMissing = !confirmationQuote;
63
+ const affectedSlideIds = Array.isArray(proposal.pageIds) && proposal.pageIds.length ? [...proposal.pageIds] : [proposal.pageId];
64
+
65
+ let revisionInfo = null;
66
+ let appliedScriptText = null;
67
+ if (proposal.kind === "script") {
68
+ const current = workspace.scriptCurrent(proposal.deckId);
69
+ if (current == null) throw new Error(`script 工作副本缺失:${proposal.deckId}`);
70
+ // 用行级替换:before 行 → after 行(提案生成时已定稿,避免重算)。
71
+ const lines = current.split("\n");
72
+ // before 行必须落在「这一页」的分节里;否则同页对面(或别的页)的相同占位行会被误改。
73
+ const range = scriptSectionRange(current, proposal.page);
74
+ const start = range ? range.start + 1 : 0;
75
+ const end = range ? range.end : lines.length;
76
+ let index = -1;
77
+ for (let i = start; i < end; i += 1) {
78
+ if (lines[i] === proposal.before) {
79
+ index = i;
80
+ break;
81
+ }
82
+ }
83
+ if (index < 0) throw new Error(`提案已过期:工作副本里找不到提案 ${proposal.proposalId} 的 before 行(已被其他 revision 改动)。请驳回这条提案,让 Agent 基于当前脚本重新提案。`);
84
+ lines[index] = proposal.after;
85
+ // F-120:改标题时把分节标题行 `### PNN · 旧标题` 一起改掉。历史 bug:只改 `**标题**` 行,
86
+ // 标题行一直留着旧标题,错位后没人看得出来,工作副本与 manifest 越走越远。
87
+ if (proposal.field === "title") {
88
+ const newTitle = String(proposal.value ?? proposal.after ?? "").replace(/^\s*\*\*[^*]+\*\*\s*[::]\s*/, "").trim();
89
+ if (newTitle && range) {
90
+ for (let i = range.start; i < Math.min(range.start + 3, lines.length); i += 1) {
91
+ if (/^#{2,3}\s*P0*\d+\s*[·.\-–—:]/.test(lines[i].trim())) {
92
+ lines[i] = lines[i].replace(/(^#{2,3}\s*P0*\d+\s*[·.\-–—:]\s*).*$/, `$1${newTitle}`);
93
+ break;
94
+ }
95
+ }
96
+ }
97
+ }
98
+ appliedScriptText = lines.join("\n");
99
+ revisionInfo = workspace.saveScriptRevision(proposal.deckId, appliedScriptText, `apply ${proposal.proposalId} (${proposal.pageId}.${proposal.field}) by user confirmation`);
100
+ // 同步 manifest 的对应字段,保持 deck.json 与 script 一致。
101
+ const deck = workspace.manifest(proposal.deckId);
102
+ const slide = deck?.slides?.find((candidate) => candidate.id === proposal.pageId);
103
+ if (deck && slide) {
104
+ if (String(proposal.after ?? "").startsWith("|") && SCRIPT_FIELD_CELL[proposal.field] != null) {
105
+ // 表格惯例:after 是完整表格行,第 0 个 split 段是行首空串。
106
+ const cell = proposal.after.split("|")[SCRIPT_FIELD_CELL[proposal.field] + 1];
107
+ if (typeof cell === "string" && cell.trim()) slide[proposal.field] = cell.trim();
108
+ workspace.saveManifest(deck);
109
+ } else if (proposal.field === "title" || proposal.field === "question" || proposal.field === "claim" || proposal.field === "body") {
110
+ // 分节惯例(F-75 / F-109):直接取提案里的纯值;老提案没有 value 就从 `**字段**:值` 里剥。
111
+ const value = proposal.value != null ? String(proposal.value).trim() : String(proposal.after ?? "").replace(/^\s*\*\*[^*]+\*\*\s*[::]\s*/, "").trim();
112
+ if (value) slide[proposal.field] = value;
113
+ workspace.saveManifest(deck);
114
+ }
115
+ }
116
+ } else if (proposal.kind === "script-add-page") {
117
+ // F-99:追加页的写回分支。不复用上面的行级替换(before="" 会命中第一处空行),
118
+ // 而是基于**当前**工作副本重算追加结果,避免提案后又被别的 revision 改过而覆盖。
119
+ const current = workspace.scriptCurrent(proposal.deckId);
120
+ if (current == null) throw new Error(`script 工作副本缺失:${proposal.deckId}`);
121
+ const applied = workspace.appendScriptPages(current, proposal.pages ?? []);
122
+ if (!applied.pages.length) throw new Error(`提案 ${proposal.proposalId} 没有可追加的页`);
123
+ revisionInfo = workspace.saveScriptRevision(
124
+ proposal.deckId,
125
+ applied.text,
126
+ `append ${applied.pages.length} page(s) ${applied.pages.map((spec) => `P${String(spec.page).padStart(2, "0")}`).join(" ")} by user confirmation (${proposal.proposalId})`,
127
+ );
128
+ const deck = workspace.manifest(proposal.deckId);
129
+ if (!deck) throw new Error(`manifest 缺失:${proposal.deckId}`);
130
+ deck.slides = Array.isArray(deck.slides) ? deck.slides : [];
131
+ const existingIds = new Set(deck.slides.map((slide) => slide.id));
132
+ for (const spec of applied.pages) {
133
+ const pageId = `p${String(spec.page).padStart(2, "0")}`;
134
+ if (existingIds.has(pageId)) {
135
+ throw new Error(`manifest 已有 ${pageId},拒绝重复追加(可能已确认过;请重新提案)`);
136
+ }
137
+ deck.slides.push({
138
+ id: pageId,
139
+ order: spec.page,
140
+ type: "story",
141
+ title: spec.title,
142
+ question: spec.question,
143
+ claim: spec.claim,
144
+ source: "script.md",
145
+ asset: null,
146
+ // 追加页默认没有画面要求;出图前先走 deck_script_propose_edit 补 visualBrief。
147
+ visualBrief: spec.visualBrief,
148
+ });
149
+ }
150
+ deck.slides.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
151
+ workspace.saveManifest(deck);
152
+ proposal.pageCountAfter = applied.pageCount;
153
+ appliedScriptText = applied.text;
154
+ } else {
155
+ const deck = workspace.manifest(proposal.deckId);
156
+ const slide = deck?.slides?.find((candidate) => candidate.id === proposal.pageId);
157
+ if (!deck || !slide) throw new Error(`manifest 页不存在:${proposal.pageId}`);
158
+ slide.visualBrief = String(proposal.after ?? "").trim();
159
+ // F-114:记录这页画面要求是在哪个关联风格下写的(换风格时据此判断要不要刷新)。
160
+ slide.visualBriefDesign = proposal.writtenUnderDesign ?? null;
161
+ workspace.saveManifest(deck);
162
+ }
163
+
164
+ // F-100 / ADR-0022:本机新建 deck 的脚本改动回写源 script.md;受控客户源保持只读。
165
+ const sourceSync = appliedScriptText != null && typeof syncScriptToSource === "function"
166
+ ? syncScriptToSource(proposal.deckId, appliedScriptText, proposal)
167
+ : null;
168
+
169
+ if (quoteMissing) {
170
+ store.recordEvent({ type: "deck.edit", sessionId, taskId: task?.taskId ?? null, deckId: proposal.deckId, slideIds: affectedSlideIds, actor: "system", status: "succeeded", payload: { proposalId: proposal.proposalId, outcome: "applied", quoteMissing: true } });
171
+ }
172
+ workspace.updateProposal(proposal, { status: "applied", decidedAt: proposalNow(), decidedBy: "user", confirmationQuote, quoteMissing, revision: revisionInfo?.revision ?? null });
173
+ if (task) {
174
+ store.taskUpdate(task.taskId, {
175
+ status: "succeeded",
176
+ actor: "user",
177
+ outputRefs: [...(revisionInfo ? [`revision:${proposal.deckId}/script/${revisionInfo.revision}`] : []), `proposal:${proposal.proposalId}`],
178
+ });
179
+ }
180
+ store.recordEvent({
181
+ type: "deck.edit",
182
+ sessionId,
183
+ taskId: task?.taskId ?? null,
184
+ deckId: proposal.deckId,
185
+ slideIds: affectedSlideIds,
186
+ actor: "user",
187
+ status: "succeeded",
188
+ payload: { proposalId: proposal.proposalId, outcome: "applied", confirmationQuote, quoteMissing, revision: revisionInfo?.revision ?? null, revisionPath: revisionInfo?.path ?? null, ...(sourceSync ? { sourceSync } : {}), ...(proposal.kind === "script-add-page" ? { field: "add-page", pages: proposal.pageIds, pageCountAfter: proposal.pageCountAfter ?? null } : {}) },
189
+ });
190
+
191
+ return {
192
+ proposalId: proposal.proposalId,
193
+ status: "applied",
194
+ revision: revisionInfo?.revision ?? null,
195
+ revisionPath: revisionInfo?.path ?? null,
196
+ deckId: proposal.deckId,
197
+ ...(sourceSync ? { sourceSync } : {}),
198
+ ...(quoteMissing ? { quoteMissing: true } : {}),
199
+ text:
200
+ `提案 ${proposal.proposalId} 已应用${revisionInfo ? `,script revision ${revisionInfo.revision} 已写入(旧版保留)` : ""}` +
201
+ `。${sourceSync ? (sourceSync.ok ? `源 script.md 已回写并提交(${sourceSync.commit})。` : `源 script.md 已回写,git 未提交:${sourceSync.error}。`) : ""}` +
202
+ `${quoteMissing ? "\n(这次没带用户原话,已按确认动作落版并标 quoteMissing 留痕;下次能引用原话更好。)" : ""}`,
203
+ };
204
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * 出图节点与模型:多节点(url / key / model 列表)+ 当前 deck 的生效选择。
3
+ *
4
+ * 配置(profile 的 plugin config):
5
+ * render:
6
+ * mode: real
7
+ * default: main/gpt-image-2.5-sunburst # 缺省选择:节点 id / 模型 id
8
+ * nodes:
9
+ * - id: main
10
+ * name: 主节点(示例)
11
+ * endpoint: https://your-image-relay.example.com/v1/images/generations
12
+ * apiKeyEnv: DSH_DECK_IMAGE_API_KEY # 只存 env 名 / key 文件路径,不存 key 本身
13
+ * apiKeyFile: ~/.config/relays.secret.env
14
+ * models: [gpt-image-2.5-sunburst, gpt-image-2.5-flare]
15
+ *
16
+ * 节点地址与 key 都只来自配置 / env / 本机 key 文件;仓库不携带任何真实节点地址(默认空)。
17
+ * 兼容旧配置:没有 nodes 时,用 render.endpoint / model / apiKeyEnv / apiKeyFile 生成一个默认节点。
18
+ * 所有对外 payload 只给 `keyConfigured` 布尔值,绝不返回 key。
19
+ * @module dsh-plugin-deck-studio/render-nodes
20
+ */
21
+
22
+ import { existsSync } from "node:fs";
23
+
24
+ const asTrimmed = (value) => (typeof value === "string" ? value.trim() : "");
25
+
26
+ /**
27
+ * 归一化出图节点列表(过滤空节点/无模型节点;同时兼容旧的单节点配置)。
28
+ * @param {Record<string, unknown>} render - config.render
29
+ * @returns {Array<{id:string,name:string,endpoint:string,apiKeyEnv:string,apiKeyFile:string,models:string[]}>}
30
+ */
31
+ export function normalizeRenderNodes(render = {}) {
32
+ const rawNodes = Array.isArray(render?.nodes) ? render.nodes : [];
33
+ const nodes = rawNodes
34
+ .filter((node) => node && typeof node === "object")
35
+ .map((node, index) => {
36
+ const id = asTrimmed(node.id) || `node-${index + 1}`;
37
+ const models = (Array.isArray(node.models) ? node.models : [])
38
+ .map((model) => (typeof model === "string" ? model.trim() : asTrimmed(model?.id)))
39
+ .filter(Boolean);
40
+ return {
41
+ id,
42
+ name: asTrimmed(node.name) || id,
43
+ // 节点字段缺省时回退到旧的顶层配置,保证老 profile 还能用
44
+ endpoint: asTrimmed(node.endpoint) || asTrimmed(render?.endpoint),
45
+ // 直接存在本机设置文件里的 key(F-146):给「填名字 + 地址 + key + 模型」的小白流程用。
46
+ apiKey: asTrimmed(node.apiKey),
47
+ // 修图端点:不配就由 endpoint 派生(/v1/images/generations → /v1/images/edits)。
48
+ editEndpoint: asTrimmed(node.editEndpoint) || asTrimmed(render?.editEndpoint),
49
+ editTransport: asTrimmed(node.editTransport) || asTrimmed(render?.editTransport),
50
+ apiKeyEnv: asTrimmed(node.apiKeyEnv) || asTrimmed(render?.apiKeyEnv),
51
+ apiKeyFile: asTrimmed(node.apiKeyFile) || asTrimmed(render?.apiKeyFile),
52
+ models,
53
+ };
54
+ })
55
+ .filter((node) => node.models.length > 0);
56
+
57
+ if (nodes.length) return nodes;
58
+
59
+ // 旧配置:单个 endpoint + 单个 model
60
+ const legacyModel = asTrimmed(render?.model);
61
+ if (!render?.endpoint && !legacyModel) return [];
62
+ return [
63
+ {
64
+ id: "default",
65
+ name: "默认节点",
66
+ endpoint: asTrimmed(render?.endpoint),
67
+ apiKey: asTrimmed(render?.apiKey),
68
+ editEndpoint: asTrimmed(render?.editEndpoint),
69
+ editTransport: asTrimmed(render?.editTransport),
70
+ apiKeyEnv: asTrimmed(render?.apiKeyEnv),
71
+ apiKeyFile: asTrimmed(render?.apiKeyFile),
72
+ models: legacyModel ? [legacyModel] : [],
73
+ },
74
+ ].filter((node) => node.models.length > 0);
75
+ }
76
+
77
+ /**
78
+ * 配置里的缺省选择:`render.default`(nodeId/modelId);兼容旧 `render.model`。
79
+ * @returns {{nodeId:string, model:string}|null}
80
+ */
81
+ export function defaultSelection(render = {}) {
82
+ const nodes = normalizeRenderNodes(render);
83
+ if (!nodes.length) return null;
84
+ const raw = asTrimmed(render?.default);
85
+ if (raw) {
86
+ const slash = raw.indexOf("/");
87
+ const nodeId = slash > 0 ? raw.slice(0, slash) : "";
88
+ const model = slash > 0 ? raw.slice(slash + 1) : raw;
89
+ const node = nodes.find((item) => item.id === nodeId);
90
+ if (node && node.models.includes(model)) return { nodeId: node.id, model };
91
+ const fallbackNode = nodes.find((item) => item.models.includes(model));
92
+ if (fallbackNode) return { nodeId: fallbackNode.id, model };
93
+ }
94
+ const legacyModel = asTrimmed(render?.model);
95
+ if (legacyModel) {
96
+ const node = nodes.find((item) => item.models.includes(legacyModel));
97
+ if (node) return { nodeId: node.id, model: legacyModel };
98
+ }
99
+ return { nodeId: nodes[0].id, model: nodes[0].models[0] };
100
+ }
101
+
102
+ /**
103
+ * 校验一个选择是否合法;非法返回 null(调用方报错而不是悄悄用别的)。
104
+ * @returns {{nodeId:string, nodeName:string, node:object, model:string, endpoint:string, apiKeyEnv:string, apiKeyFile:string, label:string}|null}
105
+ */
106
+ export function resolveSelection(render = {}, selection = null) {
107
+ const nodes = normalizeRenderNodes(render);
108
+ const wanted = selection && (selection.nodeId || selection.model)
109
+ ? {
110
+ nodeId: asTrimmed(selection.nodeId),
111
+ model: asTrimmed(selection.model),
112
+ }
113
+ : defaultSelection(render);
114
+ if (!wanted) return null;
115
+ const node = nodes.find((item) => item.id === wanted.nodeId) ?? nodes.find((item) => item.models.includes(wanted.model));
116
+ if (!node) return null;
117
+ if (wanted.model && !node.models.includes(wanted.model)) return null; // 显式指定了不存在的模型:拒绝,不悄悄替换
118
+ const model = wanted.model || node.models[0];
119
+ return {
120
+ nodeId: node.id,
121
+ nodeName: node.name,
122
+ node,
123
+ model,
124
+ endpoint: node.endpoint,
125
+ apiKey: node.apiKey,
126
+ editEndpoint: node.editEndpoint,
127
+ editTransport: node.editTransport,
128
+ apiKeyEnv: node.apiKeyEnv,
129
+ apiKeyFile: node.apiKeyFile,
130
+ label: `${node.name} / ${model}`,
131
+ };
132
+ }
133
+
134
+ /** key 是否已配置(env 变量存在或有 key 文件;不读、不返回 key 值本身)。 */
135
+ export function keyConfigured(node, env = process.env) {
136
+ if (asTrimmed(node?.apiKey)) return true;
137
+ const envName = asTrimmed(node?.apiKeyEnv);
138
+ const file = asTrimmed(node?.apiKeyFile);
139
+ if (envName && env[envName]) return true;
140
+ return Boolean(file && existsSync(file));
141
+ }
142
+
143
+ /**
144
+ * 对外 payload(设置面板 / 工作台状态):节点列表(含 key 配置状态)+ 当前生效选择。
145
+ * @param {Record<string, unknown>} render
146
+ * @param {object} [options]
147
+ * @param {NodeJS.ProcessEnv} [options.env]
148
+ * @param {{nodeId:string, model:string}|null} [options.selection]
149
+ */
150
+ export function renderModelPayload(render = {}, { env = process.env, selection = null } = {}) {
151
+ const nodes = normalizeRenderNodes(render);
152
+ const active = resolveSelection(render, selection) ?? resolveSelection(render, null);
153
+ return {
154
+ mode: asTrimmed(render?.mode) || "fake",
155
+ size: asTrimmed(render?.size),
156
+ active: active
157
+ ? { nodeId: active.nodeId, nodeName: active.nodeName, model: active.model, label: active.label }
158
+ : null,
159
+ nodes: nodes.map((node) => ({
160
+ id: node.id,
161
+ name: node.name,
162
+ endpoint: node.endpoint,
163
+ models: node.models,
164
+ apiKeyEnv: node.apiKeyEnv,
165
+ apiKeyFile: node.apiKeyFile,
166
+ keyConfigured: keyConfigured(node, env),
167
+ // saved = key 存在本机设置文件;env / file = 用环境变量或 key 文件;none = 没配
168
+ keySource: asTrimmed(node.apiKey) ? "saved" : node.apiKeyEnv && env[node.apiKeyEnv] ? "env" : node.apiKeyFile && existsSync(node.apiKeyFile) ? "file" : "none",
169
+ })),
170
+ };
171
+ }