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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) 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 +216 -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 +67 -4
  34. package/seeds/docs/assets/screenshots/00-hero.png +0 -0
  35. package/seeds/docs/assets/screenshots/01-home.png +0 -0
  36. package/seeds/docs/assets/screenshots/02-deck-pages.png +0 -0
  37. package/seeds/docs/assets/screenshots/03-script.png +0 -0
  38. package/seeds/docs/assets/screenshots/04-design.png +0 -0
  39. package/seeds/docs/assets/screenshots/05-governance.png +0 -0
  40. package/seeds/docs/assets/screenshots/06-settings.png +0 -0
  41. package/seeds/docs/assets/screenshots/07-new-deck.png +0 -0
  42. package/seeds/docs/assets/screenshots/manual/01-home.png +0 -0
  43. package/seeds/docs/assets/screenshots/manual/02-deck-pages.png +0 -0
  44. package/seeds/docs/assets/screenshots/manual/03-script.png +0 -0
  45. package/seeds/docs/assets/screenshots/manual/04-design.png +0 -0
  46. package/seeds/docs/assets/screenshots/manual/05-governance.png +0 -0
  47. package/seeds/docs/assets/screenshots/manual/06-settings.png +0 -0
  48. package/seeds/docs/assets/screenshots/manual/07-new-deck.png +0 -0
  49. package/seeds/docs/assets/screenshots/manual/legend.json +12 -0
  50. package/seeds/docs/compat.md +134 -0
  51. package/seeds/docs/install.en.md +252 -0
  52. package/seeds/docs/install.md +304 -0
  53. package/seeds/docs/manual.en.md +294 -0
  54. package/seeds/docs/manual.md +258 -0
  55. package/seeds/docs/productization.en.md +212 -0
  56. package/seeds/docs/productization.md +193 -0
  57. package/seeds/docs/sample-deck.en.md +114 -0
  58. package/seeds/docs/sample-deck.md +104 -0
  59. package/seeds/docs/styles.en.md +112 -0
  60. package/seeds/docs/styles.md +100 -0
  61. package/seeds/resources/deckline-logo.png +0 -0
  62. package/seeds/resources/deckline-logo.svg +12 -0
  63. package/seeds/resources/deckline-mark.png +0 -0
  64. package/seeds/resources/index.json +19 -0
  65. package/seeds/samples/BIZ-01.jpg +0 -0
  66. package/seeds/samples/BIZ-02.jpg +0 -0
  67. package/seeds/samples/BIZ-03.jpg +0 -0
  68. package/seeds/samples/EDT-01.jpg +0 -0
  69. package/seeds/samples/EDT-02.jpg +0 -0
  70. package/seeds/samples/EDT-03.jpg +0 -0
  71. package/seeds/samples/FYI-01.jpg +0 -0
  72. package/seeds/samples/FYI-02.jpg +0 -0
  73. package/seeds/samples/FYI-03.jpg +0 -0
  74. package/seeds/samples/GRD-01.jpg +0 -0
  75. package/seeds/samples/GRD-02.jpg +0 -0
  76. package/seeds/samples/GRD-03.jpg +0 -0
  77. package/seeds/samples/JPN-01.jpg +0 -0
  78. package/seeds/samples/JPN-02.jpg +0 -0
  79. package/seeds/samples/JPN-03.jpg +0 -0
  80. package/seeds/samples/SKT-01.jpg +0 -0
  81. package/seeds/samples/SKT-02.jpg +0 -0
  82. package/seeds/samples/SKT-03.jpg +0 -0
  83. package/seeds/samples/SWS-01.jpg +0 -0
  84. package/seeds/samples/SWS-02.jpg +0 -0
  85. package/seeds/samples/SWS-03.jpg +0 -0
  86. package/seeds/samples/TEC-01.jpg +0 -0
  87. package/seeds/samples/TEC-02.jpg +0 -0
  88. package/seeds/samples/TEC-03.jpg +0 -0
  89. package/seeds/samples/deckline-intro/README.md +41 -0
  90. package/seeds/samples/deckline-intro/assets/deckline-logo.png +0 -0
  91. package/seeds/samples/deckline-intro/assets/deckline-logo.svg +12 -0
  92. package/seeds/samples/deckline-intro/assets/deckline-mark.png +0 -0
  93. package/seeds/samples/deckline-intro/deckline-intro.pptx +0 -0
  94. package/seeds/samples/deckline-intro/intent.md +48 -0
  95. package/seeds/samples/deckline-intro/materials/project-summary.md +90 -0
  96. package/seeds/samples/deckline-intro/materials/screenshots/01-workbench-deck.png +0 -0
  97. package/seeds/samples/deckline-intro/materials/screenshots/02-home-decks.png +0 -0
  98. package/seeds/samples/deckline-intro/materials/screenshots/03-home-designs.png +0 -0
  99. package/seeds/samples/deckline-intro/materials/screenshots/04-new-deck-popup.png +0 -0
  100. package/seeds/samples/deckline-intro/materials.md +110 -0
  101. package/seeds/samples/deckline-intro/pages/p01.png +0 -0
  102. package/seeds/samples/deckline-intro/pages/p02.png +0 -0
  103. package/seeds/samples/deckline-intro/pages/p03.png +0 -0
  104. package/seeds/samples/deckline-intro/pages/p04.png +0 -0
  105. package/seeds/samples/deckline-intro/pages/p05.png +0 -0
  106. package/seeds/samples/deckline-intro/pages/p06.png +0 -0
  107. package/seeds/samples/deckline-intro/pages/p07.png +0 -0
  108. package/seeds/samples/deckline-intro/pages/p08.png +0 -0
  109. package/seeds/samples/deckline-intro/pages/p09.png +0 -0
  110. package/seeds/samples/deckline-intro/pages/p10.png +0 -0
  111. package/seeds/samples/deckline-intro/popup.md +87 -0
  112. package/seeds/samples/deckline-intro/prompts/p01.txt +4 -0
  113. package/seeds/samples/deckline-intro/prompts/p02.txt +6 -0
  114. package/seeds/samples/deckline-intro/prompts/p03.txt +6 -0
  115. package/seeds/samples/deckline-intro/prompts/p04.txt +6 -0
  116. package/seeds/samples/deckline-intro/prompts/p05.txt +6 -0
  117. package/seeds/samples/deckline-intro/prompts/p06.txt +6 -0
  118. package/seeds/samples/deckline-intro/prompts/p07.txt +6 -0
  119. package/seeds/samples/deckline-intro/prompts/p08.txt +6 -0
  120. package/seeds/samples/deckline-intro/prompts/p09.txt +6 -0
  121. package/seeds/samples/deckline-intro/prompts/p10.txt +6 -0
  122. package/seeds/samples/deckline-intro/script.md +88 -0
  123. package/seeds/styles/BIZ.json +22 -0
  124. package/seeds/styles/EDT.json +22 -0
  125. package/seeds/styles/FYI.json +22 -0
  126. package/seeds/styles/GRD.json +22 -0
  127. package/seeds/styles/JPN.json +22 -0
  128. package/seeds/styles/SKT.json +22 -0
  129. package/seeds/styles/SWS.json +22 -0
  130. package/seeds/styles/TEC.json +22 -0
  131. package/ui/logo.svg +20 -0
  132. package/ui/workbench.css +687 -0
  133. package/ui/workbench.html +4554 -0
@@ -0,0 +1,4554 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width">
6
+ <title>Deckline · AI Image Deck Studio</title>
7
+ <style>/*__CSS__*/.proposal-banner{display:flex;justify-content:space-between;align-items:center;gap:12px;margin:10px 0 12px;padding:12px 14px;border:1px solid #e3c98e;background:#fdf6e5;border-radius:10px}
8
+ .proposal-banner b{color:#8a5a00}
9
+ .proposal-banner span{display:block;color:#6b6353;font-size:12px;margin-top:2px}
10
+ .proposal-banner-actions{display:flex;gap:8px;flex:0 0 auto}
11
+ .proposal-strip{margin-top:10px;padding:8px 10px;border:1px dashed #d9c9a6;border-radius:8px;background:#fbf7ec}
12
+ .proposal-strip-foot{margin-top:6px;text-align:right}
13
+ .proposal-row{display:flex;gap:8px;align-items:center;padding:4px 0;border-bottom:1px solid #f0e8d8}
14
+ .proposal-row:last-child{border-bottom:0}
15
+ .proposal-field{flex:0 0 56px;font-size:11px;color:#8a5a00;font-weight:600}
16
+ .proposal-value{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;color:#3b4a4d}
17
+ .proposal-before{flex:0 1 auto;max-width:38%;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;color:#9a958a;text-decoration:line-through}
18
+ .proposal-arrow{flex:0 0 auto;color:#b08a3e;font-size:12px}
19
+ .proposal-strip-note{margin:0 0 4px;font-size:11px;color:#8a7a58}
20
+ .proposal-strip-note b{color:#8a5a00}
21
+ .page-pending-badge{font-size:11px;color:#8a5a00;background:#fdf6e5;border:1px solid #e3c98e;border-radius:999px;padding:1px 8px;margin-left:6px}
22
+ .page-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#c25a3a;margin-left:6px;vertical-align:middle;box-shadow:0 0 0 3px #f7e7e0}
23
+ .tab-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:#c25a3a;margin-left:6px;vertical-align:middle}
24
+ .page-content.is-placeholder{color:#9a958a;font-style:italic}
25
+ .default-style-pill{background:#fdf1dc;border-color:#e9cf9d;color:#8a6414;font-size:10px}
26
+ .brief-style-badge{display:inline-block;margin-left:6px;font-size:10px;padding:1px 6px;border-radius:999px;border:1px solid #e0b96a;background:#fdf3dd;color:#8a5a00}
27
+ .brief-style-badge.is-pending{border-color:#9bc3c0;background:#eef7f6;color:#2f6663}
28
+ .stale-chip{display:inline-block;margin-left:8px;font-size:11px;padding:1px 7px;border-radius:999px;border:1px solid #e0b96a;background:#fdf3dd;color:#8a5a00;font-weight:500}
29
+ .live-style{border-top:1px dashed #d8d2c4;padding-top:8px;margin-top:6px}
30
+ .brief-style-badge.is-unknown{border-color:#cdd6d4;background:#f4f7f6;color:#6b7b7a}
31
+ .brief-cover{margin-left:6px;color:#4a5c5a}
32
+ .brief-banner{border-color:#e0b96a;background:#fdf7e8}
33
+ .assoc-switch-panel{margin-top:10px;padding:12px 14px;border:1px dashed #d8b46a;border-radius:10px;background:#fffdf6}
34
+ .assoc-switch-panel label{display:block;margin:6px 0;font-size:13px;color:#3c4a4c}
35
+ .assoc-switch-panel .panel-actions{display:flex;gap:8px;margin-top:10px}
36
+ .linked-style{margin:10px 0 0;font-size:12px;color:#5f6b6d;background:#f7f4ec;border:1px solid #e9e2d2;border-radius:10px;padding:8px 12px}
37
+ .proposal-row.is-stale{opacity:.8}
38
+ .proposal-row.is-stale .proposal-before,.proposal-row.is-stale .proposal-value{color:#a09a8e}
39
+ .proposal-expired{flex:0 0 auto;font-size:11px;color:#b3462f;background:#fbeee9;border:1px solid #ecc8bb;border-radius:999px;padding:1px 8px}
40
+ .proposal-actions{flex:0 0 auto;display:flex;gap:6px}
41
+ .deck-flash{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);padding:10px 16px;border-radius:9px;font-size:13px;box-shadow:0 6px 24px rgba(12,70,81,.18);z-index:99;background:#0f6975;color:#fff}
42
+ .deck-flash.error{background:#a63a2b}
43
+ /* F-140:帮助 / FAQ */
44
+ .help-results { max-height: 52vh; overflow: auto; margin-top: 8px; }
45
+ .help-item { padding: 10px 12px; border: 1px solid var(--line, #e3e1d8); border-radius: 8px; margin-bottom: 8px; background: #fff; }
46
+ .help-item-head { display: flex; align-items: center; gap: 8px; }
47
+ .help-answer { margin-top: 6px; color: var(--ink-2, #4a4a45); font-size: 13px; line-height: 1.7; }
48
+ /* F-141:首页「使用手册」 */
49
+ .manual-toc { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0 14px; }
50
+ .manual-toc a { font-size: 12px; padding: 4px 9px; border: 1px solid var(--line, #e3e1d8); border-radius: 999px; color: var(--ink-2, #4a4a45); text-decoration: none; background: #fff; }
51
+ .manual-toc a:hover { border-color: var(--teal, #0f5c5c); color: var(--teal, #0f5c5c); }
52
+ .manual-page { max-width: 940px; padding: 4px 2px 40px; }
53
+ .manual-page h2 { margin-top: 30px; padding-top: 14px; border-top: 1px solid var(--line, #e3e1d8); }
54
+ .manual-page h3 { margin-top: 20px; }
55
+ .manual-page figure.doc-figure { margin: 16px 0 20px; }
56
+ .manual-page figure.doc-figure img { max-width: 100%; min-height: 160px; background: #f4f2ea; border: 1px solid var(--line, #e3e1d8); border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,.05); }
57
+ .manual-page figure.doc-figure figcaption { font-size: 12px; color: var(--ink-3, #8a8a80); margin-top: 6px; }
58
+ </style>
59
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%230f6975'/%3E%3Crect x='15' y='26' width='34' height='21' rx='3.5' fill='%23faf8f2' opacity='.55' transform='rotate(-5 32 36.5)'/%3E%3Crect x='17' y='17' width='34' height='22' rx='3.5' fill='%23faf8f2'/%3E%3Crect x='22' y='23' width='19' height='2.6' rx='1.3' fill='%230f6975'/%3E%3Crect x='22' y='29' width='12' height='2.2' rx='1.1' fill='%231496a8'/%3E%3Cpath d='M48.5 9.5l2.2 5.4 5.4 2.2-5.4 2.2-2.2 5.4-2.2-5.4-5.4-2.2 5.4-2.2z' fill='%23c5882f'/%3E%3C/svg%3E">
60
+ </head>
61
+ <body>
62
+ <header class="top">
63
+ <button class="brand-home" id="brandHome" title="回到 Deckline 首页" aria-label="回到首页">
64
+ <svg class="brand-mark" viewBox="0 0 64 64" width="26" height="26" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
65
+ <defs>
66
+ <linearGradient id="dlbg" x1="4" y1="2" x2="60" y2="62" gradientUnits="userSpaceOnUse"><stop stop-color="#0e5563"/><stop offset="1" stop-color="#08343d"/></linearGradient>
67
+ <linearGradient id="dlgold" x1="40" y1="8" x2="58" y2="26" gradientUnits="userSpaceOnUse"><stop stop-color="#e8b45c"/><stop offset="1" stop-color="#c5882f"/></linearGradient>
68
+ <linearGradient id="dlcard" x1="18" y1="16" x2="50" y2="44" gradientUnits="userSpaceOnUse"><stop stop-color="#fffdf8"/><stop offset="1" stop-color="#efe8d9"/></linearGradient>
69
+ </defs>
70
+ <rect width="64" height="64" rx="16" fill="url(#dlbg)"/>
71
+ <rect x="13" y="34" width="34" height="21" rx="3.5" fill="#faf8f2" opacity=".28" transform="rotate(-10 30 44.5)"/>
72
+ <rect x="15" y="26" width="34" height="21" rx="3.5" fill="#faf8f2" opacity=".55" transform="rotate(-5 32 36.5)"/>
73
+ <rect x="17" y="17" width="34" height="22" rx="3.5" fill="url(#dlcard)"/>
74
+ <rect x="22" y="23" width="19" height="2.6" rx="1.3" fill="#0f6975"/>
75
+ <rect x="22" y="29" width="12" height="2.2" rx="1.1" fill="#1496a8" opacity=".75"/>
76
+ <path d="M48.5 9.5l2.2 5.4 5.4 2.2-5.4 2.2-2.2 5.4-2.2-5.4-5.4-2.2 5.4-2.2z" fill="url(#dlgold)"/>
77
+ </svg>
78
+ <strong class="brand-name" id="brandName">Deckline</strong>
79
+ </button>
80
+ <span class="project">AI Image Deck Studio</span>
81
+ <span class="spacer"></span>
82
+ <span id="save">DSH 会话 · 连接中…</span>
83
+ <button id="shellSidebarBtn" title="显示 / 收起 DSH 侧栏(会话 · 插件 · 设置)" hidden>☰ DSH</button>
84
+ </header>
85
+
86
+ <section id="homeView" class="home">
87
+ <aside class="home-nav">
88
+ <button class="btn rail-new home-new-deck" id="newDeckBtn">+ 新建 Deck</button>
89
+ <nav class="home-nav-list" id="homeNav">
90
+ <button class="home-nav-item" data-section="decks"><span class="hn-icon">▦</span><span class="hn-label">Decks</span><em id="navCountDecks">0</em></button>
91
+ <button class="home-nav-item" data-section="designs"><span class="hn-icon">◈</span><span class="hn-label">Designs</span><em id="navCountDesigns">0</em></button>
92
+ <button class="home-nav-item" data-section="resources"><span class="hn-icon">◇</span><span class="hn-label">资源库</span><em id="navCountResources">0</em></button>
93
+ <button class="home-nav-item" data-section="conversations"><span class="hn-icon">☰</span><span class="hn-label">Conversations</span><em id="navCountConvs">0</em></button>
94
+ <button class="home-nav-item" data-section="manual"><span class="hn-icon">?</span><span class="hn-label">使用手册</span></button>
95
+ </nav>
96
+ </aside>
97
+ <main class="home-main">
98
+ <header class="home-greet">
99
+ <div class="eyebrow" id="homeDate">—</div>
100
+ <h1><span id="homeUserName"></span><span id="homeGreetSuffix">欢迎你</span></h1>
101
+ <p class="home-sub" id="homeGreetSub">今天想推进哪一部分?从左侧选一个区域。</p>
102
+ </header>
103
+ <div class="home-section" id="homeSection"></div>
104
+ </main>
105
+ </section>
106
+
107
+ <div id="previewModal" class="modal" hidden>
108
+ <div class="modal-card preview-card">
109
+ <div class="modal-head">
110
+ <div><div class="eyebrow" id="pvKind">Preview</div><h2 id="pvTitle">预览</h2></div>
111
+ <button class="modal-close" id="pvClose" title="关闭">✕</button>
112
+ </div>
113
+ <div class="modal-body preview-body">
114
+ <p class="preview-desc" id="pvDesc"></p>
115
+ <div class="preview-stage"><img id="pvImage" alt="" hidden><div class="preview-empty" id="pvEmpty" hidden>暂无样张</div></div>
116
+ <div class="preview-thumbs" id="pvThumbs"></div>
117
+ <div class="preview-foot" id="pvFoot" hidden>
118
+ <button class="btn alt" id="pvAddSample">+ 添加样张(粘贴 / 选文件)</button>
119
+ <input type="file" id="pvFile" accept="image/png,image/jpeg,image/webp" multiple hidden>
120
+ <span class="small" id="pvSampleState"></span>
121
+ </div>
122
+ </div>
123
+ </div>
124
+ </div>
125
+
126
+ <div id="newDeckModal" class="modal" hidden>
127
+ <div class="modal-card newdeck-card">
128
+ <div class="modal-head">
129
+ <div><div class="eyebrow">New Deck</div><h2>新建 Deck 项目</h2></div>
130
+ <button class="modal-close" id="ndClose" title="关闭">✕</button>
131
+ </div>
132
+ <form class="modal-body newdeck-body" id="newDeckForm">
133
+ <p class="nd-tip">先定「讲给谁、讲什么、想带走什么」+ 视觉风格。创建后会开出源目录与 9 份文档骨架(新项目落在 <code>00_company/sharing/&lt;目录名&gt;/</code>),然后在对话里继续补资料、改 script,直到可以出图。</p>
134
+ <label class="field"><span>主题 / 标题</span><input id="ndTitle" required placeholder="例:XX 业务场景的 AI 落地分享"></label>
135
+ <label class="field"><span>项目目录名(可选,英文小写中划线;留空自动生成)</span><input id="ndDirName" placeholder="例:talk-ai-transformation-6-stages"></label>
136
+ <label class="field"><span>页数(占位页 1–40;创建后还能在对话里追加)</span><input id="ndPages" type="number" min="1" max="40" inputmode="numeric" value="6"></label>
137
+ <label class="field"><span>内容密度(一页正文 / 画面要求的目标字数;之后可在 deck 工具栏改)</span>
138
+ <select id="ndDensity">
139
+ <option value="auto" selected>自动 —— 由 Agent 按内容判断每页长短(不设固定字数)</option>
140
+ <option value="concise">简洁 —— 正文 50–100 字/页 · 画面要求 200–300 字</option>
141
+ <option value="medium">适中 —— 正文 100–200 字/页 · 画面要求 400–600 字</option>
142
+ <option value="dense">信息密集 —— 正文 200–500 字/页 · 画面要求 800–900 字</option>
143
+ </select>
144
+ </label>
145
+ <label class="field"><span>受众 / 用户</span><input id="ndAudience" placeholder="例:IT 与业务部门的负责人"></label>
146
+ <label class="field"><span>想讲什么话题</span><textarea id="ndTopic" rows="2" placeholder="例:从现场调研到 AI 可交付的工作方式"></textarea></label>
147
+ <label class="field"><span>意图 / 希望大家带走什么</span><textarea id="ndIntent" rows="2" placeholder="例:先看见一段工作,再决定 AI 应该进入哪一步"></textarea></label>
148
+ <div class="nd-row">
149
+ <label class="field"><span>场合 / 时长(可选)</span><input id="ndOccasion" placeholder="例:内部培训 · 30 分钟"></label>
150
+ <label class="field"><span>成功标准(可选)</span><input id="ndSuccess" placeholder="例:会后 3 个业务组愿意试点"></label>
151
+ </div>
152
+ <label class="field"><span>不讲的 / 边界(可选)</span><textarea id="ndScopeOut" rows="2" placeholder="例:不讲模型原理、不承诺落地时间表"></textarea></label>
153
+ <label class="field"><span>参考资料(可选)</span><textarea id="ndRefs" rows="2" placeholder="链接 / 文件路径 / 已有材料说明"></textarea></label>
154
+ <div class="field"><span>风格(选一个历史 preset,或自己描述)</span>
155
+ <div class="nd-styles" id="ndStyles"></div>
156
+ <p class="engine-note nd-style-note" id="ndStylePresetNote" hidden></p>
157
+ <div class="nd-style-custom" id="ndStyleCustom">
158
+ <textarea id="ndStyle" rows="2" placeholder="自己描述风格:例「彩铅素描信息页:米白纸纹、石墨线、青绿+暖金」"></textarea>
159
+ </div>
160
+ </div>
161
+ <div class="nd-foot"><span class="small" id="ndState"></span><span class="spacer"></span><button type="button" class="btn alt" id="ndCancel">取消</button><button type="submit" class="btn" id="ndCreate">创建并开始对话</button></div>
162
+ </form>
163
+ </div>
164
+ </div>
165
+
166
+ <div id="confirmModal" class="modal" hidden>
167
+ <div class="modal-card confirm-card">
168
+ <div class="modal-head">
169
+ <div><div class="eyebrow">Confirm</div><h2 id="cfTitle">确认操作</h2></div>
170
+ <button class="modal-close" id="cfClose" title="关闭">✕</button>
171
+ </div>
172
+ <div class="modal-body confirm-body">
173
+ <p id="cfDesc"></p>
174
+ <div class="confirm-actions"><button class="btn alt" id="cfCancel">取消</button><button class="btn danger" id="cfOk">确认</button></div>
175
+ </div>
176
+ </div>
177
+ </div>
178
+
179
+ <div id="nameModal" class="modal" hidden>
180
+ <div class="modal-card confirm-card">
181
+ <div class="modal-head">
182
+ <div><div class="eyebrow">Welcome</div><h2>先记个名字</h2></div>
183
+ <button class="modal-close" id="nmClose" title="关闭">✕</button>
184
+ </div>
185
+ <form class="modal-body confirm-body" id="nameForm">
186
+ <p>以后新 deck 的 owner / 封面作者默认用这个名字,首页也用它打招呼;随时可以在设置里改。</p>
187
+ <div class="field"><label for="nameInput">你的名字</label><input id="nameInput" maxlength="40" placeholder="例如:Yuna" autocomplete="nickname" /></div>
188
+ <div class="confirm-actions"><button type="button" class="btn alt" id="nmLater">稍后</button><button type="submit" class="btn solid">保存</button></div>
189
+ </form>
190
+ </div>
191
+ </div>
192
+
193
+ <button id="embedBack" class="embed-back" hidden>← 回到对话</button>
194
+
195
+ <div id="workspaceView" class="layout">
196
+ <aside class="nav chat-col" id="chatCol" hidden>
197
+ <div class="native-chat-head">
198
+ <div class="chat-bind" id="chatBind">
199
+ <span class="bind-chip deck" id="chatBindChip">Deck</span>
200
+ <b class="chat-bind-title" id="chatBindTitle">—</b>
201
+ </div>
202
+ <div class="chat-head-actions">
203
+ <button type="button" class="chat-session-btn" id="chatSessionBtn" title="切换本 deck / design 的会话">
204
+ <span class="chat-session-label" id="chatSessionLabel">会话</span>
205
+ <span class="chat-session-count" id="chatSessionCount"></span>
206
+ <span class="chat-session-caret">▾</span>
207
+ </button>
208
+ <button class="icon-btn" id="newChatBtn" title="新建本 deck 的会话">+</button>
209
+ <button class="icon-btn" id="expandChatBtn" title="在 DSH 里打开完整对话(含会话列表)">↗</button>
210
+ </div>
211
+ <div class="chat-session-menu" id="chatSessionMenu" hidden></div>
212
+ </div>
213
+ <iframe id="nativeChatFrame" title="DSH 原生对话"></iframe>
214
+ </aside>
215
+ <div class="splitter" data-split="nav" title="拖动调整对话栏宽度" hidden></div>
216
+
217
+ <main class="main">
218
+ <div class="observe-tabs" id="observeTabs"></div>
219
+
220
+ <div id="deckView" class="main-view deck-view">
221
+ <div class="deck-head">
222
+ <div class="deck-head-title">
223
+ <div class="eyebrow" id="deckEyebrow">Image Storyboard</div>
224
+ <div class="deck-head-line">
225
+ <h2 id="pageTitle">—</h2>
226
+ </div>
227
+ </div>
228
+ </div>
229
+ <div class="deck-toolbar">
230
+ <label class="model-pick" title="当前 deck 生效的出图节点 / 模型(切换后立即用于下一次出图)">
231
+ <span>出图</span>
232
+ <select id="renderModelSelect"></select>
233
+ </label>
234
+ <label class="model-pick" title="内容密度:一页写多少字 / 画面要求写多细(写 script 前 Agent 会读到这个目标)">
235
+ <span>密度</span>
236
+ <select id="densitySelect"></select>
237
+ </label>
238
+ <span class="spacer"></span>
239
+ <button class="btn ghost" id="addPageBtn">+ 添加页面</button>
240
+ <button class="btn ghost" id="presentBtn" title="全屏演示:↑ ↓ ← → 翻页 · 空格下一页 · Esc 退出(笔记本没有 PageUp/PageDown 也能翻)">▶ 全屏演示</button>
241
+ <div class="menu-wrap">
242
+ <button class="btn solid" id="exportBtn" aria-haspopup="menu" aria-expanded="false">导出 <span class="caret">▾</span></button>
243
+ <div class="menu" id="exportMenu" role="menu" hidden>
244
+ <button type="button" role="menuitem" data-export="pdf">导出 PDF<small>每页一张整页图,直接分享/打印</small></button>
245
+ <button type="button" role="menuitem" data-export="pptx">导出 PPTX<small>每页一张整页图,可进 PPT 继续展示</small></button>
246
+ <button type="button" role="menuitem" data-export="handoff">导出交接包<small>zip:script + prompts + 每页图 + 9 份文档 + 事件</small></button>
247
+ </div>
248
+ </div>
249
+ </div>
250
+ <div class="slide-stack" id="slideStack"></div>
251
+ <div class="status" id="renderStatus">渲染状态:读取中…</div>
252
+ </div>
253
+
254
+ <div id="observeView" class="main-view observe-view" hidden></div>
255
+ </main>
256
+
257
+ <div class="splitter" data-split="right" title="拖动调整右栏(Inspector)宽度"></div>
258
+
259
+ <aside class="right">
260
+ <div class="tabbar">
261
+ <button class="tab active" data-tab="library">▤ Library <sup id="libCount">—</sup></button>
262
+ <button class="tab" data-tab="history">◷ Context</button>
263
+ <button class="tab" id="tabInspector" data-tab="inspector">☷ Inspector</button>
264
+ <button class="tab tab-settings" data-tab="settings" title="设置(出图节点 / 模型 / 尺寸)"><svg viewBox="0 0 24 24" width="15" height="15" 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>设置</button>
265
+ </div>
266
+ <section class="panel active" id="library">
267
+ <div class="eyebrow" id="libEyebrow">Deck Library</div>
268
+ <h2 id="libTitle">当前 Deck 资料库</h2>
269
+ <div id="libraryList"></div>
270
+ </section>
271
+ <section class="panel" id="history">
272
+ <div class="eyebrow" id="histEyebrow">Deck Timeline</div>
273
+ <h2 id="histTitle">历史与观测</h2>
274
+ <div id="timelineList"></div>
275
+ </section>
276
+ <section class="panel" id="settings">
277
+ <div class="eyebrow">Settings</div>
278
+ <h2>配置</h2>
279
+ <div id="settingsList"></div>
280
+ </section>
281
+ <section class="panel" id="inspector">
282
+ <div class="eyebrow">Slide Inspector</div>
283
+ <h2>逐页编辑</h2>
284
+ <div class="insp-page" id="insPage">未选中页</div>
285
+ <div class="field"><label>正标题</label><input id="insTitle" value=""></div>
286
+ <div class="field"><label>副标题 / 页面标签</label><input id="insSubtitle" value=""></div>
287
+ <div class="field"><label>正文(保存写回 script.md)</label><textarea id="insBody" rows="12"></textarea></div>
288
+ <div class="field"><label>提示词(prompts/Pnn.txt)</label><textarea id="insPrompt" rows="6"></textarea></div>
289
+ <div class="actions">
290
+ <button class="btn" id="insSave">保存并 git 提交</button>
291
+ <button class="btn alt" id="renderBtn">重新出图</button>
292
+ </div>
293
+ <div class="status" id="inspectorStatus" style="margin-top:16px">—</div>
294
+ <p class="engine-note" style="margin-top:10px">说明:<b>副标题</b> = 页面标签/一句话副题;<b>主张</b> = 这页要证明的判断(写在正文的「讲述意图」里),已归入正文,不再是单独字段。</p>
295
+ </section>
296
+ </aside>
297
+ </div>
298
+
299
+ <div id="addPageModal" class="modal" hidden>
300
+ <div class="modal-card" style="width:min(720px,94vw)">
301
+ <div class="modal-head">
302
+ <div><div class="eyebrow">Add Page</div><h2>添加一页</h2></div>
303
+ <button class="modal-close" id="apClose" title="关闭">✕</button>
304
+ </div>
305
+ <div class="modal-body">
306
+ <p class="engine-note">填完后点「发送到 Chat」:这段会作为一条完整指令发给 Agent,由它调 <code>deck_script_add_page</code> 出「追加页提案」;我确认后才落 revision(V1 只支持追加到最后一页之后,避免打乱既有页的版本目录)。</p>
307
+ <div class="field"><label>位置(V1 只支持追加到最后一页之后)</label><select id="apAfter"></select></div>
308
+ <div class="field"><label>页面标题(必填)</label><input id="apTitle" placeholder="例:一张工作观察卡,把五个问题变成检查栏"></div>
309
+ <div class="field"><label>这一页要回答什么(一句话主张)</label><input id="apClaim" placeholder="例:先看见一段工作,再决定 AI 应该进入哪一步"></div>
310
+ <div class="field"><label>页面内容 / 结构(一条一行,会原样变成该页正文)</label><textarea id="apBody" rows="6" placeholder="例:
311
+ - 左边:工作观察卡(谁在用 / 交付什么 / 卡在哪)
312
+ - 右边:五个问题纵向检查栏
313
+ - 底部:一句 takeaway"></textarea></div>
314
+ <div class="field"><label>画面 / 视觉要求(可选)</label><textarea id="apVisual" rows="3" placeholder="例:彩铅素描信息页,高密度;一页一个 case;右侧留 logo 空间"></textarea></div>
315
+ <div class="field"><label>出图提示词(可选,留空由 Agent 按 deck 风格补)</label><textarea id="apPrompt" rows="3" placeholder="留空即可;填了会作为这一页 prompts/Pnn.txt 的草稿"></textarea></div>
316
+ <p class="small" id="apState"></p>
317
+ </div>
318
+ <div class="modal-foot">
319
+ <button class="btn solid" id="apSend">发送到 Chat</button>
320
+ <button class="btn ghost" id="apCopy">只复制</button>
321
+ <span class="small" id="apAck"></span>
322
+ <span class="spacer"></span>
323
+ <span class="small">发送后会出现在 DSH 原生对话里,可继续对话修改</span>
324
+ </div>
325
+ </div>
326
+ </div>
327
+
328
+ <div id="addStyleModal" class="modal" hidden>
329
+ <div class="modal-card" style="width:min(720px,94vw)">
330
+ <div class="modal-head">
331
+ <div><div class="eyebrow">New Design</div><h2>添加风格</h2></div>
332
+ <button class="modal-close" id="asClose" title="关闭">✕</button>
333
+ </div>
334
+ <div class="modal-body">
335
+ <p class="engine-note">描述你想要的画面语言。确认后会开一个 0 页空白 deck 当画布,并在左侧 Chat 新建一个<b>绑定这个 design 的会话</b>开始工作(这个会话不绑定 deck)。</p>
336
+ <div class="field"><label>风格名称(必填)</label><input id="asName" placeholder="例:彩铅素描信息页"></div>
337
+ <div class="field"><label>缩写(必填,2–6 位字母,用作目录名 designs/&lt;ABBR&gt;/)</label><input id="asAbbr" placeholder="例:PC" maxlength="6"></div>
338
+ <div class="field"><label>画面描述(必填:纸感 / 笔触 / 颜色 / 版面 / 信息密度)</label><textarea id="asDesc" rows="6" placeholder="例:知识型彩色铅笔素描;米白纸张、轻微排线;真实工作物件(便利贴 / 表格 / 白板);高密度信息页,不是卡通海报、不是 3D"></textarea></div>
339
+ <div class="field"><label>不要什么(可选,负面约束)</label><textarea id="asConstraints" rows="3" placeholder="例:不要儿童插画、不要售前微 3D、不要大面积渐变和光效"></textarea></div>
340
+ <div class="field"><label>样张图片(可选:直接粘贴 ⌘V、拖进来,或点击选择;png/jpg/webp,可多张)</label>
341
+ <div class="sample-drop" id="asDrop" tabindex="0">
342
+ <div class="sample-drop-hint" id="asDropHint">把图片拖到这里 · 或按 ⌘V 粘贴 · 或点击选择文件</div>
343
+ <div class="sample-thumbs" id="asThumbs"></div>
344
+ <input type="file" id="asFile" accept="image/png,image/jpeg,image/webp" multiple hidden>
345
+ </div>
346
+ </div>
347
+ <div class="field"><label>参考(可选:样张路径 / 品牌元素 / 参考描述)</label><textarea id="asRef" rows="2" placeholder="例:参考源 deck 的 designs/PC/ 三张样张"></textarea></div>
348
+ <p class="small" id="asState"></p>
349
+ </div>
350
+ <div class="modal-foot">
351
+ <button class="btn solid" id="asSubmit">创建并开始</button>
352
+ <span class="small" id="asAck"></span>
353
+ <span class="spacer"></span>
354
+ <span class="small">会新建:风格目录 + 0 页空白画布 deck + 一个 design 会话</span>
355
+ </div>
356
+ </div>
357
+ </div>
358
+
359
+ <div id="agentPromptModal" class="modal" hidden>
360
+ <div class="modal-card" style="width:min(680px,92vw)">
361
+ <div class="modal-head">
362
+ <div><div class="eyebrow">Agent Prompt</div><h2 id="agentPromptTitle">发给 Agent</h2></div>
363
+ <button class="modal-close" id="agentPromptClose" title="关闭">✕</button>
364
+ </div>
365
+ <div class="modal-body">
366
+ <p class="engine-note">点「发送到左侧对话」会直接把这段指令发进 <b>DSH 原生 Chat</b> 的当前会话(模型选择、思考过程、流式回复都在那边,本页只负责 deck 编辑);想自己改再发就点复制。</p>
367
+ <div class="field"><label>指令(只读)</label><textarea id="agentPromptText" readonly rows="10"></textarea></div>
368
+ </div>
369
+ <div class="modal-foot">
370
+ <button class="btn" id="agentPromptCopy">复制指令</button>
371
+ <span class="small" id="agentPromptState"></span>
372
+ <span class="spacer"></span>
373
+ <button class="btn alt" id="agentPromptSend">发送到左侧对话</button>
374
+ </div>
375
+ </div>
376
+ </div>
377
+
378
+ <div id="pageEditor" class="modal" hidden>
379
+ <div class="modal-card">
380
+ <div class="modal-head">
381
+ <div><div class="eyebrow">Page Script</div><h2 id="peTitle">微调此页</h2></div>
382
+ <button class="modal-close" id="peClose" title="关闭">✕</button>
383
+ </div>
384
+ <div class="modal-body">
385
+ <p class="engine-note" id="peHint">保存会写回源 <code>script.md</code>(和提示词文件)并自动 git 提交,然后可以重出图片。</p>
386
+ <div class="field"><label>正标题</label><input id="peTitleInput"></div>
387
+ <div class="field"><label>副标题 / 页面标签</label><input id="peSubtitleInput"></div>
388
+ <div class="field"><label>正文(标题行以下的页面内容)</label><textarea id="peBodyInput" rows="14"></textarea></div>
389
+ <div class="field"><label>提示词(prompts/Pnn.txt,可留空)</label><textarea id="pePromptInput" rows="5"></textarea></div>
390
+ </div>
391
+ <div class="modal-foot">
392
+ <span class="small" id="peStatus"></span>
393
+ <span class="spacer"></span>
394
+ <button class="btn alt" id="peCancel">取消</button>
395
+ <button class="btn" id="peSave">保存并 git 提交</button>
396
+ </div>
397
+ </div>
398
+ </div>
399
+
400
+
401
+ <!-- F-102:全屏演示层(整页图一页一屏,方向键 / 空格 / PageUp-Down / 滚轮翻页) -->
402
+ <div id="presentOverlay" class="present-overlay" role="dialog" aria-modal="true" aria-label="全屏演示" tabindex="-1" hidden>
403
+ <div class="present-stage" id="presentStage" title="点一下 = 下一页"></div>
404
+ <div class="present-bar" id="presentBar">
405
+ <button type="button" class="present-nav" id="presentPrev" aria-label="上一页(↑ / ←)">‹</button>
406
+ <span class="present-counter" id="presentCounter">— / —</span>
407
+ <button type="button" class="present-nav" id="presentNext" aria-label="下一页(↓ / →)">›</button>
408
+ <span class="present-title" id="presentTitle"></span>
409
+ <span class="spacer"></span>
410
+ <span class="present-hint" id="presentHint">↑ ↓ ← → 翻页 · 空格下一页 · Esc 退出</span>
411
+ <button type="button" class="present-exit" id="presentExit" title="退出演示(Esc)">✕ 退出</button>
412
+ </div>
413
+ <div class="present-note" id="presentNote" hidden></div>
414
+ </div>
415
+
416
+ <script>
417
+ /* ------------------------------------------------------------------ *
418
+ * dsh-deck-studio 工作台(真前端)
419
+ * - 聊天列:直连 DSH 会话 RPC(session/create · prompt · projections 游标 · page 轮询)
420
+ * - 中间/右侧:读插件路由 /api/deck-studio.*(真实 .deck 数据与出图资产)
421
+ * - Deck 标签 = ppt 本体(image render);其余标签 = 结构化 markdown 观测视图
422
+ * - 编辑动作:转成给 Deck Agent 的指令,由 agent 走工具闭环(不绕过门禁)
423
+ * ------------------------------------------------------------------ */
424
+ let SETTINGS_NODES = [];
425
+ let SETTINGS_EDITING = null;
426
+ // 正在设置页填表 / 光标在设置表单里时,后台刷新不要重建面板,否则会把输入的文字覆盖回旧值。
427
+ let SETTINGS_FORM_DIRTY = false;
428
+
429
+ const STATE = { sid: null, sessionTitle: "", data: null, selected: null, running: false, demo: false, tab: "deck", entityKind: "", sig: "", docCache: {}, docLoading: null, view: "home", deckId: null, rightTab: "", sessions: [], editorPage: null, ndStyleAbbr: "", designCards: [] };
430
+ const EMBED = new URLSearchParams(location.search).get("embed") === "1";
431
+
432
+ const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
433
+ const getEl = (id) => document.getElementById(id);
434
+ const fmtBytes = (n) => (n ? (n > 1048576 ? (n / 1048576).toFixed(1) + " MB" : Math.round(n / 1024) + " KB") : "");
435
+ const fmtTime = (iso) => String(iso ?? "").replace("T", " ").replace(/\.\d+Z$/, "Z").replace("Z", "").slice(5);
436
+
437
+ async function rpc(method, args) {
438
+ const body = { type: "client-request", rpcId: crypto.randomUUID(), method, payload: { args: args ?? {} } };
439
+ const res = await fetch("/api/" + method, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
440
+ if (!res.ok) throw new Error("HTTP " + res.status);
441
+ const json = await res.json();
442
+ if (!json.result || !json.result.ok) throw new Error(json.result?.error?.message || "rpc failed");
443
+ return json.result.value;
444
+ }
445
+ async function route(name, params) {
446
+ const qs = params ? "?" + new URLSearchParams(params) : "";
447
+ const res = await fetch("/api/deck-studio." + name + qs, { headers: { accept: "application/json" } });
448
+ if (!res.ok) throw new Error("HTTP " + res.status);
449
+ return res.json();
450
+ }
451
+
452
+ /* ---------------- 结构化 markdown 预览(无依赖) ---------------- */
453
+ function mdInline(source) {
454
+ let out = esc(source);
455
+ out = out.replace(/`([^`]+)`/g, "<code>$1</code>");
456
+ out = out.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>");
457
+ out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (whole, text, href) => {
458
+ const external = /^(https?:|mailto:)/.test(href);
459
+ const extra = external ? ' target="_blank" rel="noreferrer"' : "";
460
+ return `<a href="${href}"${extra}>${text}</a>`;
461
+ });
462
+ return out;
463
+ }
464
+
465
+ /** 标题 → 锚点 id(手册目录用;与 mdToHtml 的 anchors 保持一致)。 */
466
+ function docSlug(text) {
467
+ return String(text ?? "").replace(/[*`\[]/g, "").replace(/[^\w\u4e00-\u9fa5]+/g, "-").replace(/^-+|-+$/g, "");
468
+ }
469
+
470
+ function mdTable(head, rows) {
471
+ const isPageTable = /^(页|page|#|序号)/i.test(head[0] ?? "");
472
+ if (isPageTable && head.length >= 2) {
473
+ return '<div class="page-cards">' + rows
474
+ .map((row) => {
475
+ const page = row[0] ?? "";
476
+ const titleCell = row[1] ?? "";
477
+ const rest = head.slice(2).map((label, index) => ({ label, value: row[index + 2] ?? "" })).filter((cell) => cell.value && cell.value !== "—");
478
+ return `<div class="page-card"><div class="page-card-head"><span class="pill">${esc(page)}</span><b>${mdInline(titleCell)}</b></div><div class="page-card-body">${rest
479
+ .map((cell) => `<p><span class="page-card-label">${esc(cell.label)}</span>${mdInline(cell.value)}</p>`)
480
+ .join("")}</div></div>`;
481
+ })
482
+ .join("") + "</div>";
483
+ }
484
+ return '<table><thead><tr>' + head.map((cell) => `<th>${mdInline(cell)}</th>`).join("") + "</tr></thead><tbody>" + rows
485
+ .map((row) => "<tr>" + head.map((_, index) => `<td>${mdInline(row[index] ?? "")}</td>`).join("") + "</tr>")
486
+ .join("") + "</tbody></table>";
487
+ }
488
+
489
+ function mdToHtml(markdown, options = {}) {
490
+ const lines = String(markdown ?? "").replace(/\r\n/g, "\n").split("\n");
491
+ const out = [];
492
+ let index = 0;
493
+ let inList = false;
494
+ const flushList = () => { if (inList) { out.push("</ul>"); inList = false; } };
495
+ const parseRow = (line) => line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim());
496
+ while (index < lines.length) {
497
+ const line = lines[index];
498
+ if (/^```/.test(line)) {
499
+ const buf = [];
500
+ index += 1;
501
+ while (index < lines.length && !/^```/.test(lines[index])) buf.push(lines[index++]);
502
+ index += 1;
503
+ flushList();
504
+ out.push(`<pre>${esc(buf.join("\n"))}</pre>`);
505
+ continue;
506
+ }
507
+ if (/^\s*$/.test(line)) { flushList(); index += 1; continue; }
508
+ // 独立一行图片:`![alt](src)`(手册 / 文档里的截图)
509
+ const imageLine = line.match(/^!\[([^\]]*)\]\(([^)\s]+)\)\s*$/);
510
+ if (imageLine) {
511
+ flushList();
512
+ const src = options.imageSrc ? options.imageSrc(imageLine[2]) : imageLine[2];
513
+ out.push(`<figure class="doc-figure"><img src="${esc(src)}" alt="${esc(imageLine[1])}" loading="lazy"><figcaption>${mdInline(imageLine[1])}</figcaption></figure>`);
514
+ index += 1;
515
+ continue;
516
+ }
517
+ const heading = line.match(/^(#{1,6})\s+(.*)$/);
518
+ if (heading) {
519
+ flushList();
520
+ const level = Math.min(heading[1].length, 4);
521
+ const slug = options.anchors ? docSlug(heading[2]) : "";
522
+ out.push(`<h${level}${slug ? ` id="doc-${esc(slug)}"` : ""}>${mdInline(heading[2])}</h${level}>`);
523
+ index += 1;
524
+ continue;
525
+ }
526
+ if (/^\s*>\s?/.test(line)) {
527
+ const buf = [];
528
+ while (index < lines.length && /^\s*>\s?/.test(lines[index])) buf.push(lines[index++].replace(/^\s*>\s?/, ""));
529
+ flushList();
530
+ out.push(`<blockquote>${buf.map(mdInline).join("<br>")}</blockquote>`);
531
+ continue;
532
+ }
533
+ if (/^\s*(---|\*\*\*|___)\s*$/.test(line)) { flushList(); out.push("<hr>"); index += 1; continue; }
534
+ if (/^\s*[-*+]\s+/.test(line)) {
535
+ flushList();
536
+ if (!inList) { out.push("<ul>"); inList = true; }
537
+ out.push(`<li>${mdInline(line.replace(/^\s*[-*+]\s+/, ""))}</li>`);
538
+ index += 1;
539
+ continue;
540
+ }
541
+ if (line.includes("|") && index + 1 < lines.length && /^\s*\|?\s*:?-{2,}/.test(lines[index + 1])) {
542
+ flushList();
543
+ const head = parseRow(line);
544
+ index += 2;
545
+ const rows = [];
546
+ while (index < lines.length && lines[index].includes("|") && !/^\s*$/.test(lines[index])) rows.push(parseRow(lines[index++]));
547
+ out.push(mdTable(head, rows));
548
+ continue;
549
+ }
550
+ flushList();
551
+ out.push(`<p>${mdInline(line)}</p>`);
552
+ index += 1;
553
+ }
554
+ flushList();
555
+ return out.join("\n");
556
+ }
557
+
558
+ /* ---------------- 数据加载 ---------------- */
559
+ async function loadWorkbench(deckId = null) {
560
+ try {
561
+ const next = await route("workbench", deckId ? { deckId } : undefined);
562
+ const active = next.active || {};
563
+ const signature = JSON.stringify([
564
+ active.deckId,
565
+ active.status,
566
+ (active.slides || []).map((slide) => [slide.id, slide.title, slide.question, slide.claim, slide.visualBrief, slide.render?.revision ?? null]),
567
+ (next.decks || []).map((deck) => [deck.deckId, deck.pageCount, deck.warnings?.length ?? 0]),
568
+ (next.tasks || []).length,
569
+ (next.events || []).length,
570
+ (next.proposals || []).length,
571
+ active.governance?.score ?? -1,
572
+ (active.documents || []).map((doc) => [doc.id, doc.status, doc.issues.length, doc.primary, doc.version ?? ""]),
573
+ (next.designProjects || []).map((project) => [project.designId, project.abbr, (project.samples || []).length]),
574
+ // F-67:design 可绑多个会话,绑定变化也要触发重渲染(否则定义页的会话列表会停在旧数据)
575
+ (next.bindings || []).map((binding) => [binding.kind, binding.id, binding.sessionId]),
576
+ ]);
577
+ const first = !STATE.data;
578
+ STATE.data = next;
579
+ STATE.deckId = next.active?.deckId ?? null;
580
+ renderChatHeader();
581
+ STATE.demo = false;
582
+ renderChrome();
583
+ if (signature !== STATE.sig) STATE.docCache = {}; // 源/草案变了,文档内容缓存作废
584
+ if (first || signature !== STATE.sig) {
585
+ STATE.sig = signature;
586
+ renderMain();
587
+ if (STATE.view === "home") renderHome();
588
+ if (!next.user?.name && !localStorage.getItem("deckline.nameLater")) {
589
+ getEl("nameModal").hidden = false;
590
+ setTimeout(() => getEl("nameInput")?.focus(), 60);
591
+ }
592
+ }
593
+ } catch (error) {
594
+ STATE.demo = true;
595
+ STATE.data = demoData();
596
+ renderChrome();
597
+ renderMain();
598
+ }
599
+ }
600
+
601
+ function demoData() {
602
+ const slides = [
603
+ { id: "p01", order: 1, type: "cover", title: "AI 进入业务现场", question: "从重复执行到可复用工作链路", claim: "—", visualBrief: "", source: "demo", render: null },
604
+ { id: "p02", order: 2, type: "story", title: "一条工作链路如何被复用", question: "从输入、动作到输出和人审,形成可观察的工作流。", claim: "—", visualBrief: "", source: "demo", render: null },
605
+ { id: "p03", order: 3, type: "story", title: "个人技能 × 业务赋能", question: "把经验沉淀为可复用的结构和视觉资产。", claim: "—", visualBrief: "", source: "demo", render: null },
606
+ ];
607
+ return {
608
+ cwd: "(demo 预览:真实数据在 DSH 工作台 13090)",
609
+ config: { renderMode: "demo", renderModel: "gpt-image-2.5-sunburst", renderEndpoint: "", renderSize: "2560x1440", dataRoot: ".deck", preset: "deck-studio" },
610
+ decks: [{ deckId: "demo-image-deck", label: "AI 进入业务现场(虚构演示)", title: "AI 进入业务现场(虚构演示)", status: "Draft", pageCount: 3, appendixCount: 0, sourceRoot: "(fixture)", warnings: [] }],
611
+ active: { deckId: "demo-image-deck", title: "AI 进入业务现场(虚构演示)", status: "Draft", slides, appendix: { count: 0, rows: [] }, sources: [], script: "# demo\n\n这是设计稿预览模式。真实 script 与出图在 DSH 工作台里。" },
612
+ tasks: [], events: [], proposals: [],
613
+ };
614
+ }
615
+
616
+ /* ---------------- 渲染 ---------------- */
617
+ /** 切右栏标签(Library / Context / Inspector / 设置)。F-131:进 deck、点页面默认给 Inspector。 */
618
+ function activateRightTab(name) {
619
+ if (!name) return;
620
+ // design 画布没有「选中页」,Inspector 无意义(tab 也是隐藏的)——别把它切出来变成死面板
621
+ if (name === "inspector" && designCanvasProject(STATE.data?.active)) name = "library";
622
+ document.querySelectorAll(".tab").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === name));
623
+ document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === name));
624
+ }
625
+
626
+ function renderModelSelect(renderNodes) {
627
+ const select = getEl("renderModelSelect");
628
+ if (!select) return;
629
+ const nodes = renderNodes?.nodes || [];
630
+ const activeKey = renderNodes?.active ? `${renderNodes.active.nodeId}/${renderNodes.active.model}` : "";
631
+ const options = [];
632
+ for (const node of nodes) {
633
+ for (const model of node.models) {
634
+ const key = `${node.id}/${model}`;
635
+ // 选项只放「节点 / 模型」;key 状态进 title,避免长括号把工具栏下拉撑成两行
636
+ options.push(`<option value="${esc(key)}"${key === activeKey ? " selected" : ""}>${esc(node.name)} / ${esc(model)}</option>`);
637
+ }
638
+ }
639
+ select.innerHTML = options.join("") || `<option value="">未配置出图节点</option>`;
640
+ select.disabled = !options.length;
641
+ select.title = options.length
642
+ ? `节点 ${nodes.length} 个 · 模型 ${nodes.reduce((sum, node) => sum + node.models.length, 0)} 个${activeKey && (renderNodes.active?.keyConfigured === false) ? " · 当前节点 key 未配置" : ""}`
643
+ : "在 profile 的 render.nodes 里配置节点";
644
+ }
645
+
646
+ /* F-124:内容密度下拉(值来自 workbench payload,标签带目标字数,用户一眼看懂差别) */
647
+ function renderDensitySelect(active) {
648
+ const select = getEl("densitySelect");
649
+ if (!select) return;
650
+ const options = active?.contentDensityOptions || [];
651
+ const current = active?.contentDensity || "auto";
652
+ const optionText = (option) => {
653
+ if (option.key === "auto") return "自动 · 按页判断";
654
+ return `${option.label} · ${String(option.body || "").replace(" 字/页", " 字")}`;
655
+ };
656
+ select.innerHTML = options.map((option) => `<option value="${esc(option.key)}"${option.key === current ? " selected" : ""}>${esc(optionText(option))}</option>`).join("");
657
+ const spec = options.find((option) => option.key === current);
658
+ select.title = spec ? `正文 ${spec.body} · 画面要求 ${spec.brief};${spec.hint}` : "内容密度";
659
+ select.disabled = !options.length || active?.kind === "design-canvas";
660
+ }
661
+
662
+ function renderChrome() {
663
+ const data = STATE.data;
664
+ if (!data) return;
665
+ // F-63:design 画布没有 deck 页面 / 选中页,Inspector 对它无意义,直接收起。
666
+ const isDesignCanvas = Boolean(designCanvasProject(data.active));
667
+ const inspectorTab = getEl("tabInspector");
668
+ if (inspectorTab) inspectorTab.hidden = isDesignCanvas;
669
+ if (isDesignCanvas && inspectorTab?.classList.contains("active")) {
670
+ document.querySelectorAll(".tab").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === "library"));
671
+ document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === "library"));
672
+ }
673
+ if (getEl("cwd")) getEl("cwd").textContent = data.cwd || "local workspace";
674
+ if (getEl("brandName") && data.displayName) getEl("brandName").textContent = data.displayName;
675
+ const decks = data.decks || [];
676
+ const active = data.active || {};
677
+ const stats = getEl("homeStats");
678
+ if (stats) {
679
+ stats.textContent = `${decks.length} 个 deck · 治理 ${active.governance?.score ?? "—"}/100 · ${(data.tasks || []).length} task / ${(data.events || []).length} event`;
680
+ }
681
+ const canvasProject = (data.designProjects || []).find((project) => project.canvasDeckId === active.deckId);
682
+ if (active.kind === "design-canvas" && canvasProject) {
683
+ getEl("pageTitle").textContent = `${canvasProject.abbr} · ${canvasProject.name}`;
684
+ } else {
685
+ getEl("pageTitle").textContent = active.title || "—";
686
+ }
687
+ renderModelSelect(active.renderNodes);
688
+ renderDensitySelect(active);
689
+ renderLibrary(data);
690
+ renderTimeline(data);
691
+ renderSettings(data);
692
+ // 首页右栏给 Library(deck 列表 / 文档);只有 deck 工作区才默认 Inspector,且不覆盖用户手动选择
693
+ if (STATE.view === "home" && !STATE.rightTab) activateRightTab("library");
694
+ if (STATE.view === "deck" && !STATE.selected && (active.slides || []).length) selectSlide(active.slides[0].id);
695
+ }
696
+
697
+ /* ---------------- 首页(左侧导航 + 右侧分区 blocks) ---------------- */
698
+
699
+ const HOME_SECTIONS = ["decks", "designs", "resources", "conversations", "manual"];
700
+ const HOME_TITLES = { decks: "Decks", designs: "Designs", resources: "资源库", conversations: "Conversations" };
701
+
702
+ function homeDateText() {
703
+ const now = new Date();
704
+ const week = ["日", "一", "二", "三", "四", "五", "六"][now.getDay()];
705
+ return `${now.getFullYear()} 年 ${now.getMonth() + 1} 月 ${now.getDate()} 日 · 星期${week}`;
706
+ }
707
+
708
+ function homeThumb(deck) {
709
+ const active = STATE.data?.active;
710
+ const activeSlide = active?.deckId === deck.deckId ? (active.slides || []).find((item) => item.render) : null;
711
+ // F-48:每个 deck 都用自己的封面(后端 deck.cover = 源 baseline 第一页 / 已渲染第一页 / 风格样张)
712
+ const url = deck.cover || activeSlide?.render?.url || "";
713
+ if (url) return `<img src="${esc(url)}" alt="${esc(deck.label)}" loading="lazy">`;
714
+ return `<div class="home-thumb-empty">${deck.kind === "design-canvas" ? "还没有样张" : `${esc(String(deck.pageCount || 0))} 页`}</div>`;
715
+ }
716
+
717
+ /** 风格画布 deck 的标题:去掉「· 风格画布」后缀(pill 已经标了),避免重复。 */
718
+ function homeDeckTitle(deck) {
719
+ const label = String(deck.label || deck.deckId || "");
720
+ return deck.kind === "design-canvas" ? label.replace(/\s*·\s*风格画布$/, "") : label;
721
+ }
722
+
723
+ /* ---------------- F-141:首页「使用手册」(docs/manual.md + 截图) ---------------- */
724
+ function manualImageSrc(src) {
725
+ const raw = String(src ?? "").trim();
726
+ if (!raw || /^(https?:|data:)/.test(raw)) return raw;
727
+ const match = raw.replace(/^\.?\//, "").match(/(?:docs\/)?assets\/screenshots\/(.+)$/);
728
+ return match ? `/api/deck-studio.docs-asset?path=${encodeURIComponent(match[1])}` : raw;
729
+ }
730
+
731
+ async function renderManualHome(box) {
732
+ box.innerHTML = `<div class="section-head"><h2>使用手册</h2><span class="small">截图 + 步骤 · 也可以在 Chat 里直接问「怎么…」</span></div><p class="engine-note">加载中…</p>`;
733
+ try {
734
+ if (!STATE.manualMarkdown) {
735
+ const res = await fetch("/api/deck-studio.manual");
736
+ const data = await res.json();
737
+ if (!data?.ok) throw new Error(data?.error?.message || "手册加载失败");
738
+ STATE.manualMarkdown = data.markdown || "";
739
+ }
740
+ const markdown = STATE.manualMarkdown;
741
+ const headings = markdown.split("\n").filter((line) => /^##\s+/.test(line)).map((line) => line.replace(/^##\s+/, "").trim());
742
+ const toc = headings.length
743
+ ? `<div class="manual-toc">${headings.map((title) => `<a href="#doc-${esc(docSlug(title))}">${esc(title)}</a>`).join("")}</div>`
744
+ : "";
745
+ box.innerHTML = `<div class="section-head"><h2>使用手册</h2><span class="small">截图 + 步骤 · 也可以直接问 Chat</span></div>${toc}<article class="md manual-page">${mdToHtml(markdown, { anchors: true, imageSrc: manualImageSrc })}</article>`;
746
+ bindManualDocLinks(box);
747
+ } catch (error) {
748
+ box.innerHTML = `<p class="engine-note">手册加载失败:${esc(error.message)}</p>`;
749
+ }
750
+ }
751
+
752
+ function renderHome() {
753
+ const data = STATE.data;
754
+ if (!data) return;
755
+ const active = data.active || {};
756
+ // F-51:Decks 页只列正式 deck;design 的 0 页空白画布只在 Designs 页出现,
757
+ // 不再混进 Decks 列表(之前会被误解成「错误创建的彩铅 deck」)。
758
+ const decks = (data.decks || []).filter((deck) => deck.kind !== "design-canvas");
759
+ const styles = active.designStyles || [];
760
+ const designProjects = data.designProjects || [];
761
+ const bindings = data.bindings || [];
762
+ const resources = active.resources || [];
763
+ const sessions = STATE.sessions || [];
764
+ const gov = active.governance || {};
765
+ const section = HOME_SECTIONS.includes(STATE.homeSection) ? STATE.homeSection : "decks";
766
+ STATE.homeSection = section;
767
+ const designCards = mergeDesignCards(designProjects, styles);
768
+ STATE.designCards = designCards;
769
+
770
+ // 问候 + 日期
771
+ const userName = data.user?.name || "";
772
+ getEl("homeUserName").textContent = userName;
773
+ getEl("homeGreetSuffix").textContent = userName ? ",欢迎你" : "欢迎你";
774
+ getEl("homeDate").textContent = homeDateText();
775
+ const greet = {
776
+ decks: `${decks.length} 个 deck 在管理;点一个进入工作区。`,
777
+ designs: `${designCards.length} 个风格;点卡片进会话,右上角眼睛只看样张。`,
778
+ resources: `${resources.length} 个资源(logo / 素材);点开预览。`,
779
+ conversations: `${sessions.length} 个历史会话;点一个继续聊。`,
780
+ manual: "从新建到导出:截图 + 步骤;找不到就直接在 Chat 里问。",
781
+ }[section];
782
+ getEl("homeGreetSub").textContent = greet;
783
+
784
+ // 左导航计数与选中态
785
+ getEl("navCountDecks").textContent = String(decks.length);
786
+ getEl("navCountDesigns").textContent = String(designCards.length);
787
+ getEl("navCountResources").textContent = String(resources.length);
788
+ getEl("navCountConvs").textContent = String(sessions.length);
789
+ getEl("homeNav").querySelectorAll("[data-section]").forEach((node) => node.classList.toggle("active", node.dataset.section === section));
790
+
791
+ const box = getEl("homeSection");
792
+ if (section === "decks") box.innerHTML = renderHomeDecks(decks, active, gov);
793
+ else if (section === "designs") box.innerHTML = renderHomeDesigns(designCards);
794
+ else if (section === "resources") box.innerHTML = renderHomeResources(resources);
795
+ else if (section === "manual") {
796
+ // F-160b:后台 refresh 会重渲染 home;正在应用内读 install.md 时不能被弹回手册首页。
797
+ if (STATE.manualDoc) openManualDoc(box, STATE.manualDoc);
798
+ else renderManualHome(box);
799
+ }
800
+ else box.innerHTML = renderHomeConversations(sessions, bindings);
801
+
802
+ box.querySelectorAll("[data-home-deck]").forEach((node) => node.addEventListener("click", async () => {
803
+ const deckId = node.dataset.homeDeck;
804
+ // 先等 deck 切完(iframe 里的 Chat 也要加载完),再选默认会话——否则 postMessage 发早了会落到旧会话。
805
+ await showDeck(deckId);
806
+ const deck = decks.find((item) => item.deckId === deckId);
807
+ if (deck?.kind !== "design-canvas") await ensureDeckConversation(deckId);
808
+ }));
809
+ box.querySelectorAll("[data-design-card]").forEach((node) => node.addEventListener("click", () => {
810
+ const card = (STATE.designCards || [])[Number(node.dataset.designCard)];
811
+ if (!card) return;
812
+ if (card.designId) openDesignProject(card.designId);
813
+ else openStylePreview(card);
814
+ }));
815
+ box.querySelectorAll("[data-design-eye]").forEach((node) => node.addEventListener("click", (event) => {
816
+ event.stopPropagation();
817
+ const card = (STATE.designCards || [])[Number(node.dataset.designEye)];
818
+ if (card) openStylePreview(card);
819
+ }));
820
+ box.querySelectorAll("[data-design-delete]").forEach((node) => node.addEventListener("click", (event) => {
821
+ event.stopPropagation();
822
+ const card = (STATE.designCards || [])[Number(node.dataset.designDelete)];
823
+ if (card?.designId) requestDesignDelete(card);
824
+ }));
825
+ box.querySelectorAll("[data-deck-delete]").forEach((node) => node.addEventListener("click", (event) => {
826
+ event.stopPropagation();
827
+ requestDeckDelete(node.dataset.deckDelete);
828
+ }));
829
+ box.querySelectorAll("[data-home-resource]").forEach((node) => node.addEventListener("click", () => {
830
+ const resource = resources[Number(node.dataset.homeResource)];
831
+ if (resource) openResourcePreview(resource);
832
+ }));
833
+ box.querySelectorAll("[data-home-conv]").forEach((node) => node.addEventListener("click", () => openBoundConversation(node.dataset.homeConv)));
834
+ box.querySelectorAll("[data-home-newstyle]").forEach((node) => node.addEventListener("click", () => openAddStyleModal()));
835
+ box.querySelectorAll("[data-seed-styles]").forEach((node) => node.addEventListener("click", seedPresetStyles));
836
+ }
837
+
838
+ function renderHomeDecks(decks, active, gov) {
839
+ if (!decks.length) return `<div class="home-empty">还没有 deck。点左下「+ 新建 Deck」开始。</div>`;
840
+ return `<div class="section-head"><h2>Decks</h2><span class="small">${decks.length} 个</span></div>
841
+ <div class="home-blocks">${decks.map((deck) => {
842
+ const isActive = deck.deckId === active.deckId;
843
+ // F-52:每张卡用自己的文档状态(后端 decks[].documents / missing / score),
844
+ // 圆点 = 9 份治理文档状态;不再只有「当前打开的 deck」有点点。
845
+ const documents = deck.documents || [];
846
+ const missing = deck.missing ?? documents.filter((doc) => doc.status === "missing").length;
847
+ const score = deck.score ?? (isActive ? gov.score : null);
848
+ const styleChip = deck.design ? `<span class="bind-chip design" title="这个 deck 关联的登记风格">${esc(deck.design.abbr)} · ${esc(deck.design.name)}</span>` : "";
849
+ const rulesChip = deck.rules ? `<span class="bind-chip rules" title="已配置项目规则 deck.rules.md(只约束这个 deck)">规则</span>` : "";
850
+ const ready = documents.filter((doc) => doc.status === "ok").length;
851
+ const docSummary = missing
852
+ ? `<span class="home-block-warn">缺 ${missing} 份文档</span>`
853
+ : ready === documents.length
854
+ ? `<span class="home-block-ok">${documents.length} 份文档齐备</span>`
855
+ : `<span class="home-block-ok" title="ok / 全部文档">${ready}/${documents.length} 齐备 · ${documents.length - ready} 份待完善</span>`;
856
+ const docTags = documents.length
857
+ ? `${documents.map((doc) => `<span class="doc-dot ${doc.status}" title="${esc(doc.label)} · ${esc(doc.status)}"></span>`).join("")}${docSummary}`
858
+ : "";
859
+ return `<div class="home-block deck-block" data-home-deck="${esc(deck.deckId)}" role="button" tabindex="0">
860
+ ${deck.deletable ? `<button type="button" class="design-tool danger deck-tool" data-deck-delete="${esc(deck.deckId)}" title="删除这个 deck(本机新建的才可以)">${TRASH_ICON}</button>` : ""}
861
+ <div class="home-block-media">${homeThumb(deck)}</div>
862
+ <div class="home-block-body">
863
+ <div class="home-block-title">${esc(homeDeckTitle(deck))}${deck.kind === "design-canvas" ? `<span class="pill canvas-pill">风格画布</span>` : ""}</div>
864
+ <div class="home-block-meta">${deck.kind === "design-canvas" ? "风格画布" : `${esc(String(deck.pageCount ?? 0))} 页`}${score != null ? ` · 治理 ${score}/100` : ""}</div>
865
+ ${styleChip || docTags || rulesChip ? `<div class="home-block-tags">${styleChip}${rulesChip}${docTags}</div>` : ""}
866
+ </div>
867
+ </div>`;
868
+ }).join("")}</div>`;
869
+ }
870
+
871
+ /** 同一个风格可能同时有 design 实体(可开会话)和源 deck 样张目录,按 abbr / 名称合并成一张卡(F-49)。 */
872
+ function mergeDesignCards(designProjects = [], styles = []) {
873
+ const sameStyle = (a, b) => {
874
+ const abbrA = String(a?.abbr || "").toUpperCase();
875
+ const abbrB = String(b?.abbr || "").toUpperCase();
876
+ if (abbrA && abbrB && abbrA === abbrB) return true;
877
+ const nameA = String(a?.name || "").trim().toLowerCase();
878
+ const nameB = String(b?.name || "").trim().toLowerCase();
879
+ return Boolean(nameA && nameB && nameA === nameB);
880
+ };
881
+ const used = new Set();
882
+ const cards = designProjects.map((project) => {
883
+ const index = styles.findIndex((style, i) => !used.has(i) && sameStyle(project, style));
884
+ const matched = index >= 0 ? styles[index] : null;
885
+ if (index >= 0) used.add(index);
886
+ const samples = (project.samples || []).length ? project.samples : (matched?.samples || []);
887
+ return {
888
+ designId: project.designId,
889
+ abbr: project.abbr,
890
+ name: project.name,
891
+ description: project.description || matched?.description || "",
892
+ samples,
893
+ boundSessionId: project.boundSessionId || null,
894
+ canvasTitle: project.canvasTitle || "",
895
+ deckIds: project.decks || [],
896
+ };
897
+ });
898
+ const extras = styles
899
+ .filter((style, i) => !used.has(i))
900
+ .map((style) => ({
901
+ designId: null,
902
+ abbr: style.abbr,
903
+ name: style.name,
904
+ description: style.description || "",
905
+ samples: style.samples || [],
906
+ boundSessionId: null,
907
+ canvasTitle: "",
908
+ deckIds: [],
909
+ }));
910
+ return cards.concat(extras);
911
+ }
912
+
913
+ const EYE_ICON = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12Z"/><circle cx="12" cy="12" r="2.7"/></svg>';
914
+ const TRASH_ICON = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13M10 11v6M14 11v6"/></svg>';
915
+
916
+ function renderHomeDesigns(cards = []) {
917
+ const head = `<div class="section-head"><h2>Designs</h2><span class="small">${cards.length} 个风格 · 点卡片进会话 · 右上角眼睛只看样张</span><button class="btn solid" data-home-newstyle="1">+ 添加风格</button><button class="btn" data-seed-styles="1" title="导入插件自带的预设风格(含样张)">+ 导入预设风格</button></div>`;
918
+ const cardsHtml = cards.length
919
+ ? `<div class="home-blocks style-blocks">${cards.map((card, index) => `
920
+ <div class="home-block style-block design-card" data-design-card="${index}" role="button" tabindex="0">
921
+ <div class="style-cover">${card.samples.slice(0, 3).map((sample) => `<img src="${esc(sample.url)}" alt="${esc(sample.name || "")}" loading="lazy">`).join("") || `<div class="home-thumb-empty">${card.designId ? "还没有样张 · 去会话里出" : "暂无样张"}</div>`}</div>
922
+ <div class="design-tools">
923
+ <button type="button" class="design-tool" data-design-eye="${index}" title="只看样张预览" aria-label="只看样张预览">${EYE_ICON}</button>
924
+ ${card.designId ? `<button type="button" class="design-tool danger" data-design-delete="${index}" title="删除这个风格(误建时用)" aria-label="删除这个风格">${TRASH_ICON}</button>` : ""}
925
+ </div>
926
+ <div class="home-block-body">
927
+ <div class="style-head"><span class="style-abbr">${esc(card.abbr)}</span><b>${esc(card.name)}</b>${String(card.abbr || "").toUpperCase() === String(STATE.data?.config?.defaultStyleAbbr || "") ? '<span class="pill default-style-pill">默认</span>' : ""}<span class="style-count">${card.samples.length} 张</span></div>
928
+ <div class="style-prompt">${esc((card.description || (card.designId ? "(未写风格描述)" : "(未写风格提示词)")).slice(0, 140))}</div>
929
+ <div class="home-block-tags">${card.designId
930
+ ? `<span class="bind-chip design">Design 会话${card.boundSessionId ? "已绑定" : "待开始"}</span>${card.deckIds?.length ? `<span class="small" title="正在使用这个风格的 deck">用于 ${card.deckIds.map((deck) => esc(deck.label)).join("、")}</span>` : `<span class="small">点卡片进会话</span>`}`
931
+ : `<span class="bind-chip unbound">样张目录</span><span class="small">源 deck 只读 · 点开预览</span>`}</div>
932
+ </div>
933
+ </div>`).join("")}</div>`
934
+ : "";
935
+ const empty = !cards.length
936
+ ? `<div class="home-empty design-empty">
937
+ <p><b>还没有风格。</b></p>
938
+ <p>点「+ 添加风格」描述画面:确认后会建一个 0 页空白 deck 当画布,并在 Chat 里开一个绑定该 design 的会话,Agent 一张一张出样张。</p>
939
+ <p class="small">旧的「让 Agent 在源 deck 建档」入口已改成真实弹窗流程(F-43)。</p>
940
+ </div>`
941
+ : "";
942
+ return head + cardsHtml + empty;
943
+ }
944
+
945
+ function renderHomeResources(resources) {
946
+ if (!resources.length) return `<div class="section-head"><h2>资源库</h2></div><div class="home-empty">还没有资源。把 logo / 素材放进 deck 的 <code>resources/</code> 目录即可自动登记。</div>`;
947
+ return `<div class="section-head"><h2>资源库</h2><span class="small">${resources.length} 个资源</span></div>
948
+ <div class="home-blocks resource-blocks">${resources.map((resource, index) => `
949
+ <button class="home-block resource-card" data-home-resource="${index}">
950
+ <div class="home-block-media resource-media${/白/.test(resource.name) ? " is-light" : ""}"><img src="${esc(resource.url)}" alt="${esc(resource.name)}" loading="lazy"></div>
951
+ <div class="home-block-body">
952
+ <div class="home-block-title">${esc(resource.name)}</div>
953
+ <div class="home-block-meta">${esc(resource.kind)} · ${fmtBytes(resource.sizeBytes)} · ${esc(resource.scope === "work" ? "工作副本" : resource.scope === "shared" ? "共享资源库" : "源目录")}</div>
954
+ <div class="home-block-meta">${esc(resource.description || "")}</div>
955
+ </div>
956
+ </button>`).join("")}</div>`;
957
+ }
958
+
959
+ function renderHomeConversations(sessions, bindings = []) {
960
+ const bound = new Map((bindings || []).map((binding) => [binding.sessionId, binding]));
961
+ const targetLabel = (binding) => {
962
+ if (!binding) return "未绑定";
963
+ if (binding.kind === "deck") {
964
+ const deck = (STATE.data?.decks || []).find((item) => item.deckId === binding.id);
965
+ return deck?.label || binding.title || binding.id;
966
+ }
967
+ const design = (STATE.data?.designProjects || []).find((item) => item.designId === binding.id);
968
+ return design ? `${design.abbr} · ${design.name}` : binding.title || binding.id;
969
+ };
970
+ if (!sessions.length) return `<div class="section-head"><h2>Conversations</h2></div><div class="home-empty">还没有会话。进入一个 deck / 添加一个风格就会绑定出会话。</div>`;
971
+ return `<div class="section-head"><h2>Conversations</h2><span class="small">${sessions.length} 个 · 每个会话绑定一个 deck 或 design</span></div>
972
+ <div class="conv-list">${sessions.map((session) => {
973
+ const binding = bound.get(session.sessionId);
974
+ return `<button class="conv-card" data-home-conv="${esc(session.sessionId)}">
975
+ <b>${esc(session.title)}</b>
976
+ <span class="conv-meta">${session.running ? "进行中" : esc(fmtDayTime(session.updatedAt ?? Date.now()))}
977
+ ${binding ? `<span class="bind-chip ${binding.kind === "design" ? "design" : "deck"}">${binding.kind === "design" ? "Design" : "Deck"} · ${esc(targetLabel(binding))}</span>` : `<span class="bind-chip unbound">未绑定</span>`}
978
+ </span>
979
+ </button>`;
980
+ }).join("")}</div>`;
981
+ }
982
+
983
+ /* ---------------- 预览弹层(风格多张样张 / 资源) ---------------- */
984
+
985
+ let PREVIEW = { items: [], index: 0, title: "", desc: "" };
986
+
987
+ function openPreview({ title, desc, keys, items, design }) {
988
+ PREVIEW = { title, desc, items, keys, design: design || null };
989
+ const foot = getEl("pvFoot");
990
+ if (foot) foot.hidden = !design;
991
+ const sampleState = getEl("pvSampleState");
992
+ if (sampleState) sampleState.textContent = "";
993
+ getEl("pvTitle").textContent = title;
994
+ getEl("pvDesc").textContent = desc || "";
995
+ showPreviewAt(0);
996
+ getEl("previewModal").hidden = false;
997
+ }
998
+
999
+ function showPreviewAt(index) {
1000
+ const { items } = PREVIEW;
1001
+ if (!items.length) {
1002
+ getEl("pvImage").hidden = true;
1003
+ getEl("pvEmpty").hidden = false;
1004
+ getEl("pvThumbs").innerHTML = "";
1005
+ PREVIEW.index = 0;
1006
+ return;
1007
+ }
1008
+ const item = items[Math.max(0, Math.min(index, items.length - 1))];
1009
+ PREVIEW.index = index;
1010
+ getEl("pvImage").hidden = false;
1011
+ getEl("pvEmpty").hidden = true;
1012
+ getEl("pvImage").src = item.url;
1013
+ getEl("pvThumbs").innerHTML = items.map((sample, i) => `<button class="pv-thumb${i === index ? " active" : ""}" data-pv-index="${i}"><img src="${esc(sample.url)}" alt="${esc(sample.name || "")}" loading="lazy"><span>${esc(sample.name || "")}</span></button>`).join("");
1014
+ getEl("pvThumbs").querySelectorAll("[data-pv-index]").forEach((node) => node.addEventListener("click", () => showPreviewAt(Number(node.dataset.pvIndex))));
1015
+ }
1016
+
1017
+ function closePreview() {
1018
+ getEl("previewModal").hidden = true;
1019
+ if (getEl("pvFoot")) getEl("pvFoot").hidden = true;
1020
+ PREVIEW = { items: [], index: 0 };
1021
+ }
1022
+
1023
+ /** POST 一个插件路由(失败抛后端 message)。 */
1024
+ async function postJson(routeName, body) {
1025
+ const res = await fetch("/api/deck-studio." + routeName, {
1026
+ method: "POST",
1027
+ headers: { "content-type": "application/json" },
1028
+ body: JSON.stringify(body ?? {}),
1029
+ });
1030
+ const data = await res.json().catch(() => ({}));
1031
+ if (!res.ok || data?.ok === false) throw new Error(data?.error?.message || "HTTP " + res.status);
1032
+ return data;
1033
+ }
1034
+
1035
+ let CONFIRM_OK = null;
1036
+
1037
+ /** 通用确认弹层(删除等破坏性操作用)。 */
1038
+ function openConfirm({ title, desc, okLabel = "确认", danger = false, onOk = null }) {
1039
+ CONFIRM_OK = onOk;
1040
+ getEl("cfTitle").textContent = title;
1041
+ getEl("cfDesc").textContent = desc;
1042
+ const ok = getEl("cfOk");
1043
+ ok.textContent = okLabel;
1044
+ ok.classList.toggle("danger", Boolean(danger));
1045
+ getEl("confirmModal").hidden = false;
1046
+ }
1047
+
1048
+ function closeConfirm() {
1049
+ getEl("confirmModal").hidden = true;
1050
+ CONFIRM_OK = null;
1051
+ }
1052
+
1053
+ /** 删除风格:登记 + 0 页画布 + 绑定关系(误建时用,F-51)。 */
1054
+ function requestDesignDelete(card) {
1055
+ openConfirm({
1056
+ title: `删除风格「${card.abbr} · ${card.name}」?`,
1057
+ desc: "会删掉风格登记、它的 0 页空白画布、画布里的样张和会话绑定;源 deck 里的风格目录与样张不受影响。这个操作不可恢复。",
1058
+ okLabel: "确认删除",
1059
+ danger: true,
1060
+ onOk: async () => {
1061
+ try {
1062
+ await postJson("design-delete", { designId: card.designId });
1063
+ await loadWorkbench(STATE.deckId);
1064
+ } catch (error) {
1065
+ openConfirm({ title: "删除失败", desc: error.message, okLabel: "知道了", onOk: () => {} });
1066
+ }
1067
+ },
1068
+ });
1069
+ }
1070
+
1071
+ /** 删除本机新建的 deck(受控源 deck 不会有这个按钮,后端也会拒)。 */
1072
+ function requestDeckDelete(deckId) {
1073
+ const deck = (STATE.data?.decks || []).find((item) => item.deckId === deckId);
1074
+ if (!deck) return;
1075
+ openConfirm({
1076
+ title: `删除 deck「${deck.label}」?`,
1077
+ desc: `会删掉它的工作副本和本机源目录(${deck.sourceRoot || "未知路径"}),以及会话绑定和风格关联。这个操作不可恢复。`,
1078
+ okLabel: "确认删除",
1079
+ danger: true,
1080
+ onOk: async () => {
1081
+ try {
1082
+ await postJson("deck-delete", { deckId });
1083
+ if (STATE.data?.active?.deckId === deckId) STATE.deckId = null;
1084
+ await loadWorkbench(STATE.deckId);
1085
+ } catch (error) {
1086
+ openConfirm({ title: "删除失败", desc: error.message, okLabel: "知道了", onOk: () => {} });
1087
+ }
1088
+ },
1089
+ });
1090
+ }
1091
+
1092
+ /** 导入插件自带的 8 个预设风格(F-56)。 */
1093
+ async function seedPresetStyles() {
1094
+ try {
1095
+ const data = await postJson("styles-seed", {});
1096
+ await loadWorkbench(STATE.deckId);
1097
+ renderHome();
1098
+ const added = (data.seeded || []).length;
1099
+ const skipped = (data.skipped || []).length;
1100
+ openConfirm({
1101
+ title: "预设风格已导入",
1102
+ desc: `新增 ${added} 个风格(含样张)${skipped ? `,跳过 ${skipped} 个已存在的缩写` : ""}。现在可以在 Designs 页看到它们,点卡片即可用这个风格开工。`,
1103
+ okLabel: "去看看",
1104
+ onOk: () => {},
1105
+ });
1106
+ } catch (error) {
1107
+ openConfirm({ title: "导入失败", desc: error?.message ?? String(error), okLabel: "知道了", onOk: () => {} });
1108
+ }
1109
+ }
1110
+
1111
+ /** F-114:哪些页的画面要求不是当前风格下写的(换风格 / 重写入口用)。 */
1112
+ function staleBriefPagesFor(abbr) {
1113
+ // 只算「有记录、且不是当前风格」的页;未记录写作风格的(旧提案)单独算,不混在一起吓人。
1114
+ return (STATE.data?.active?.slides || []).filter((slide) => {
1115
+ const brief = String(slide.visualBrief || "").trim();
1116
+ const written = slide.visualBriefDesign ?? null;
1117
+ return Boolean(brief) && written !== null && written !== abbr;
1118
+ });
1119
+ }
1120
+ function unknownBriefPagesFor() {
1121
+ return (STATE.data?.active?.slides || []).filter((slide) => {
1122
+ const brief = String(slide.visualBrief || "").trim();
1123
+ return Boolean(brief) && (slide.visualBriefDesign ?? null) === null;
1124
+ });
1125
+ }
1126
+
1127
+ function openAssocSwitchPanel() {
1128
+ const designId = getEl("assocSwitch")?.value || "";
1129
+ const design = (STATE.data?.designProjects || []).find((item) => item.designId === designId);
1130
+ if (!design) return;
1131
+ const stale = staleBriefPagesFor(design.abbr);
1132
+ const unknown = unknownBriefPagesFor();
1133
+ const parts = [];
1134
+ if (stale.length) parts.push(`${stale.length} 页的画面要求是别的风格(${stale.map((slide) => `${slide.id}${slide.visualBriefDesign ? `·${slide.visualBriefDesign}` : ""}`).join("、")})`);
1135
+ if (unknown.length) parts.push(`${unknown.length} 页未记录写作风格(旧提案)`);
1136
+ getEl("assocSwitchTitle").textContent = `换到 ${design.abbr} ${design.name}`;
1137
+ getEl("assocSwitchStaleNote").textContent = parts.length
1138
+ ? `注意:${parts.join(";")}。换风格后这些页出图会用「旧画面要求 + 新风格」,可能出现风格混搭。`
1139
+ : `所有页的画面要求都兼容 ${design.abbr},不需要重写。`;
1140
+ getEl("assocSwitchPanel").hidden = false;
1141
+ }
1142
+
1143
+ async function confirmAssocSwitch() {
1144
+ const designId = getEl("assocSwitch")?.value || "";
1145
+ const design = (STATE.data?.designProjects || []).find((item) => item.designId === designId);
1146
+ const mode = document.querySelector('input[name="assocRefresh"]:checked')?.value || "keep";
1147
+ getEl("assocSwitchPanel").hidden = true;
1148
+ if (!designId || !design) return;
1149
+ const stalePages = staleBriefPagesFor(design.abbr);
1150
+ await bindDeckDesign(designId);
1151
+ if (mode === "rewrite") {
1152
+ openAgentPrompt(`已把这份 deck 的出图风格换成 ${design.abbr} ${design.name}。\n请按新风格重写这些页的 visualBrief:${stalePages.map((slide) => slide.id).join("、") || "(全部有画面要求的页)"}。\n要求:\n① 必须调用 deck_script_propose_edit 逐页提案(一次一页也行,或用批量提案一次提多页),不要输出表格或清单让我复制粘贴——那样工作台看不到,也无法确认落版;\n② visualBrief 只写画面内容 / 版式 / 信息结构,不要写颜色、材质、笔触、背景等风格词(那些交给风格提示词);\n③ 全部提完回我 proposalId 列表 + 每页一句重点。我确认后再出图。`, "按新风格重写画面要求");
1153
+ flash(`已换到 ${design.abbr};点「发送到左侧对话」即可让 Agent 重写这 ${stalePages.length} 页`, "ok");
1154
+ } else {
1155
+ flash(`已换到 ${design.abbr}:保留现有画面要求,下次出图 = 现有画面要求 + 新风格提示词`, "ok");
1156
+ }
1157
+ }
1158
+
1159
+ /** 登记 / 解除 deck → design 关联(Design 文档页,F-51)。 */
1160
+ async function bindDeckDesign(designId) {
1161
+ const deckId = STATE.data?.active?.deckId ?? "";
1162
+ try {
1163
+ await postJson("deck-design", { deckId, designId: designId || null });
1164
+ await loadWorkbench(STATE.deckId);
1165
+ if (STATE.tab === "design") renderDocTab("design");
1166
+ } catch (error) {
1167
+ openConfirm({ title: "关联失败", desc: error.message, okLabel: "知道了", onOk: () => {} });
1168
+ }
1169
+ }
1170
+
1171
+ function openStylePreview(style) {
1172
+ if (!style) return;
1173
+ openPreview({
1174
+ title: `${style.abbr} · ${style.name}`,
1175
+ desc: style.description || "(未写风格提示词)",
1176
+ items: style.samples || [],
1177
+ design: style.designId ? { designId: style.designId, name: `${style.abbr} · ${style.name}` } : null,
1178
+ });
1179
+ }
1180
+
1181
+ function openResourcePreview(resource) {
1182
+ if (!resource) return;
1183
+ openPreview({
1184
+ title: resource.name,
1185
+ desc: `${resource.description || ""}${resource.description ? " · " : ""}${resource.kind} · ${fmtBytes(resource.sizeBytes)}`,
1186
+ items: [{ url: resource.url, name: resource.name }],
1187
+ });
1188
+ }
1189
+
1190
+ function showHome() {
1191
+ STATE.view = "home";
1192
+ getEl("homeView").style.display = "grid";
1193
+ getEl("workspaceView").style.display = "none";
1194
+ activateRightTab(STATE.rightTab || "library");
1195
+ if (!location.hash.startsWith("#/home")) history.replaceState(null, "", `#/home/${STATE.homeSection || "decks"}`);
1196
+ renderHome();
1197
+ }
1198
+
1199
+ async function switchDeck(deckId) {
1200
+ if (!deckId || deckId === STATE.data?.active?.deckId) return;
1201
+ STATE.docCache = {};
1202
+ // F-132:换 deck 必须清掉上一本的选中页。以前 selected 还是 p01,新 deck 也有 p01,
1203
+ // 就被当成「已选中」跳过重选,右栏 Inspector 会一直显示**上一本 deck** 的页面内容。
1204
+ STATE.selected = null;
1205
+ getEl("insPage").textContent = "切换中…";
1206
+ getEl("insTitle").value = "";
1207
+ getEl("insSubtitle").value = "";
1208
+ getEl("insBody").value = "";
1209
+ getEl("insPrompt").value = "";
1210
+ await loadWorkbench(deckId);
1211
+ }
1212
+
1213
+ async function showDeck(deckId, tab = null) {
1214
+ if (deckId && deckId !== STATE.data?.active?.deckId) await switchDeck(deckId);
1215
+ STATE.view = "deck";
1216
+ getEl("homeView").style.display = "none";
1217
+ getEl("workspaceView").style.display = "grid";
1218
+ // F-131:进 deck 默认右栏就是 Inspector(逐页编辑),Library / Context 需要时再点;
1219
+ // 同时把第一页选上,Inspector 不能停在「未选中页」。
1220
+ activateRightTab(STATE.rightTab || "inspector");
1221
+ const slides = STATE.data?.active?.slides || [];
1222
+ if (!STATE.selected || !slides.some((slide) => slide.id === STATE.selected)) selectSlide(slides[0]?.id);
1223
+ renderChatHeader();
1224
+ if (location.hash !== "#/deck") history.replaceState(null, "", "#/deck");
1225
+ if (tab) {
1226
+ STATE.tab = tab;
1227
+ renderMain();
1228
+ }
1229
+ // F-68:进 deck 时把左栏原生 Chat 切到它绑定的会话,别停在 DSH 欢迎页。
1230
+ switchChatToBoundSession().catch(() => {});
1231
+ }
1232
+
1233
+ /** 切左栏原生 Chat 到当前 deck / design 绑定的那个会话(没有绑定就不动)。 */
1234
+ async function switchChatToBoundSession(attempt = 0) {
1235
+ const target = activeChatTarget();
1236
+ if (!target) return false;
1237
+ const binding = bindingOf(target.kind, target.id);
1238
+ if (!binding?.sessionId) return false;
1239
+ if (currentChatSessionId() === binding.sessionId) return true;
1240
+ const result = await requestConversation({ action: "open", sessionId: binding.sessionId });
1241
+ if (!result.ok) {
1242
+ if (attempt < 2) {
1243
+ setTimeout(() => switchChatToBoundSession(attempt + 1).catch(() => {}), 900);
1244
+ }
1245
+ return false;
1246
+ }
1247
+ STATE.chatSessionId = binding.sessionId;
1248
+ await refreshSessions();
1249
+ renderChatHeader();
1250
+ return true;
1251
+ }
1252
+
1253
+ /* F-63:两种实体打开后看到的东西不一样 —— deck 是文档 + 治理,design 只有样张 + 风格定义 + 修改日志。 */
1254
+ const DECK_TABS = [
1255
+ ["deck", "Deck"],
1256
+ ["intent", "意图"],
1257
+ ["script", "脚本"],
1258
+ ["design", "Design"],
1259
+ ["changelog", "修改日志"],
1260
+ ["feedback", "反馈"],
1261
+ ["research", "调研"],
1262
+ ["materials", "支撑材料"],
1263
+ ["review", "审稿"],
1264
+ ["factcheck", "事实核对"],
1265
+ ["governance", "治理"],
1266
+ ];
1267
+ const DESIGN_TABS = [
1268
+ ["deck", "样张"],
1269
+ ["design", "风格定义"],
1270
+ ["changelog", "修改日志"],
1271
+ ];
1272
+
1273
+ /** 当前打开的是 deck 还是 design 画布。 */
1274
+ function activeKind() {
1275
+ return designCanvasProject(STATE.data?.active) ? "design" : "deck";
1276
+ }
1277
+
1278
+ /** 按实体渲染中间区标签;deck 与 design 的标签集合不同,切实体时回到第一个标签。 */
1279
+ function renderObserveTabs() {
1280
+ const kind = activeKind();
1281
+ if (STATE.entityKind !== kind) {
1282
+ STATE.entityKind = kind;
1283
+ STATE.tab = "deck";
1284
+ STATE.docCache = {};
1285
+ }
1286
+ const tabs = kind === "design" ? DESIGN_TABS : DECK_TABS;
1287
+ if (!tabs.some(([id]) => id === STATE.tab)) STATE.tab = tabs[0][0];
1288
+ // F-158:脚本 tab 上有未确认提案时打小圆点(用户可以不在脚本页,也能看到有东西要确认)。
1289
+ const pendingScript = (STATE.data?.proposals || []).filter(
1290
+ (proposal) => proposal.status === "waiting-user" && (!STATE.data?.active?.deckId || proposal.deckId === STATE.data.active.deckId),
1291
+ ).length;
1292
+ getEl("observeTabs").innerHTML = tabs
1293
+ .map(([id, label]) => `<button class="observe-tab${STATE.tab === id ? " active" : ""}" data-observe="${id}">${label}${id === "script" && pendingScript ? `<span class="tab-dot" title="${pendingScript} 条待确认修改"></span>` : ""}</button>`)
1294
+ .join("");
1295
+ return kind;
1296
+ }
1297
+
1298
+ function renderMain() {
1299
+ const kind = renderObserveTabs();
1300
+ const isDesign = kind === "design";
1301
+ const tab = STATE.tab;
1302
+ getEl("deckView").hidden = tab !== "deck";
1303
+ getEl("observeView").hidden = tab === "deck";
1304
+ if (tab === "deck") {
1305
+ renderDeck();
1306
+ return;
1307
+ }
1308
+ if (isDesign) {
1309
+ if (tab === "design") {
1310
+ renderDesignDefinition();
1311
+ return;
1312
+ }
1313
+ renderDesignLog();
1314
+ return;
1315
+ }
1316
+ if (tab === "governance") {
1317
+ renderGovernance();
1318
+ return;
1319
+ }
1320
+ renderDocTab(tab);
1321
+ }
1322
+
1323
+ /* ---------------- 9 份必备文档(中间区标签) ---------------- */
1324
+
1325
+ const DOC_STATUS = {
1326
+ ok: ["齐备", "ok"],
1327
+ partial: ["待补字段", "partial"],
1328
+ derived: ["脚本派生", "derived"],
1329
+ draft: ["工作副本草案", "draft"],
1330
+ missing: ["缺失", "missing"],
1331
+ };
1332
+
1333
+ /** F-158:脚本页「未查看」小圆点——按页内容 hash 存 localStorage,点过就清。 */
1334
+ function scriptSeenKey(deckId) {
1335
+ return `deckline.scriptSeen.${deckId || "demo"}`;
1336
+ }
1337
+ function scriptSeenRead(deckId) {
1338
+ try {
1339
+ return JSON.parse(localStorage.getItem(scriptSeenKey(deckId)) || "{}") || {};
1340
+ } catch {
1341
+ return {};
1342
+ }
1343
+ }
1344
+ function scriptSeenWrite(deckId, map) {
1345
+ try {
1346
+ localStorage.setItem(scriptSeenKey(deckId), JSON.stringify(map));
1347
+ } catch {
1348
+ /* 隐私模式禁用 storage 时忽略 */
1349
+ }
1350
+ }
1351
+ function hashString(text) {
1352
+ let hash = 0;
1353
+ for (const ch of String(text ?? "")) hash = (hash * 31 + ch.codePointAt(0)) >>> 0;
1354
+ return hash.toString(36);
1355
+ }
1356
+ function scriptPageHash(page, slide) {
1357
+ return hashString([slide?.title, slide?.question, slide?.claim, slide?.body, page?.title, page?.subtitle, page?.content, page?.prompt].filter(Boolean).join("|"));
1358
+ }
1359
+ function scriptSeenUnseen(deckId, pageId, hash) {
1360
+ const map = scriptSeenRead(deckId);
1361
+ if (!(pageId in map)) {
1362
+ map[pageId] = hash;
1363
+ scriptSeenWrite(deckId, map);
1364
+ return false;
1365
+ }
1366
+ return map[pageId] !== hash;
1367
+ }
1368
+ function markScriptSeen(deckId, pageId, hash) {
1369
+ const map = scriptSeenRead(deckId);
1370
+ map[pageId] = hash;
1371
+ scriptSeenWrite(deckId, map);
1372
+ }
1373
+
1374
+ function docBadge(status) {
1375
+ const [label, cls] = DOC_STATUS[status] ?? ["未知", "missing"];
1376
+ return `<span class="doc-status ${cls}">${label}</span>`;
1377
+ }
1378
+
1379
+ function docIssues(doc) {
1380
+ if (!doc.issues?.length) return "";
1381
+ return `<div class="doc-issues">${doc.issues.map((issue) => `<p>· ${esc(issue)}</p>`).join("")}</div>`;
1382
+ }
1383
+
1384
+ function emptyDoc(doc) {
1385
+ return `<div class="doc-empty">
1386
+ <b>${esc(doc.label)}还没有</b>
1387
+ <p>${esc(doc.purpose)}</p>
1388
+ ${docIssues(doc)}
1389
+ <div class="doc-actions">
1390
+ <button class="btn" data-doc-fill="${esc(doc.id)}">让 Agent 起草这份文档</button>
1391
+ <button class="btn alt" data-doc-scaffold="${esc(doc.id)}">先在工作副本打骨架</button>
1392
+ </div>
1393
+ <p class="engine-note">草案写到 <code>.deck/decks/&lt;deckId&gt;/docs/</code>,确认后再回写源 deck;插件不会直接改客户目录。</p>
1394
+ </div>`;
1395
+ }
1396
+
1397
+ function docHeader(doc, extra = "") {
1398
+ return `<div class="doc-head">
1399
+ <div><div class="eyebrow">Deck Document</div><h2>${esc(doc.label)}</h2></div>
1400
+ ${docBadge(doc.status)}
1401
+ <span class="small doc-file">${esc(doc.primary || "无文件")}</span>
1402
+ ${extra}
1403
+ </div>`;
1404
+ }
1405
+
1406
+ function docFooter(doc, content) {
1407
+ return `<details class="doc-raw"><summary>查看原始 markdown</summary><div class="md"><pre>${esc(content || "(空)")}</pre></div></details>`;
1408
+ }
1409
+
1410
+ async function fetchDoc(docId) {
1411
+ if (STATE.docCache[docId]) return STATE.docCache[docId];
1412
+ if (STATE.docLoading === docId) return null;
1413
+ STATE.docLoading = docId;
1414
+ 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;
1420
+ return doc;
1421
+ } finally {
1422
+ STATE.docLoading = null;
1423
+ }
1424
+ }
1425
+
1426
+ async function renderDocTab(docId) {
1427
+ const index = (STATE.data.active.documents || []).find((doc) => doc.id === docId);
1428
+ const view = getEl("observeView");
1429
+ const cached = STATE.docCache[docId];
1430
+ if (!cached) view.innerHTML = `<div class="observe-body"><div class="doc-loading">读取 ${esc(index?.label ?? docId)} …</div></div>`;
1431
+ const doc = cached ?? (await fetchDoc(docId));
1432
+ if (!doc || STATE.tab !== docId) return; // 加载期间用户已切走
1433
+ view.innerHTML = `<div class="observe-body">${renderDoc(doc, index)}</div>`;
1434
+ bindDocActions(view);
1435
+ }
1436
+
1437
+ function renderDoc(doc, index) {
1438
+ const status = doc.status;
1439
+ if (status === "missing") return emptyDoc({ ...index, ...doc });
1440
+ const draftNote = doc.kind === "draft" ? `<span class="small">(工作副本草案,确认后回写源)</span>` : "";
1441
+ // F-159:脚本页入口——按 intent 听众动态起审稿人,只评不改。
1442
+ const reviewBtn = doc.id === "script" ? `<button class="btn alt" data-script-review="1" title="按 intent 里的听众生成审稿人,逐页给问题和改法;只评不改稿">听众评审</button>` : "";
1443
+ const head = docHeader({ ...index, ...doc }, `${draftNote}${reviewBtn}`);
1444
+ const content = doc.content || "";
1445
+ if (doc.id === "intent") {
1446
+ const fields = doc.parsed.fields ?? {};
1447
+ const derived = doc.parsed.derived ?? {};
1448
+ const rows = [
1449
+ ["听众", fields["听众"]],
1450
+ ["主题", fields["主题"]],
1451
+ ["希望带走", fields["带走"]],
1452
+ ["场合 / 时长", fields["场合 / 时长"] ?? fields["场合"]],
1453
+ ["成功标准", fields["成功标准"]],
1454
+ ["不讲的", fields["不讲的"]],
1455
+ ];
1456
+ return head + docIssues(doc) + `<div class="doc-fields">${rows
1457
+ .map(([label, value]) => `<div class="doc-field"><span>${esc(label)}</span><b>${esc(value || "—")}</b></div>`)
1458
+ .join("")}</div>
1459
+ <div class="meta-chips">${Object.entries(derived).filter(([, value]) => value).map(([key, value]) => `<span class="meta-chip"><b>${esc(key)}</b> ${esc(value)}</span>`).join("")}</div>
1460
+ ${content ? `<div class="md">${mdToHtml(stripFrontmatter(content))}</div>` : ""}`;
1461
+ }
1462
+ if (doc.id === "script") {
1463
+ const pages = doc.parsed.pages ?? [];
1464
+ const segments = doc.parsed.segments ?? [];
1465
+ const storyOverview = segments.length
1466
+ ? `<details class="story-overview"><summary>叙事结构概览(${segments.length} 段 · ${pages.length} 页,从 script 自动派生)</summary>
1467
+ <table><thead><tr><th>段落</th><th>页码</th><th>任务</th></tr></thead><tbody>${segments.map((row) => `<tr><td>${esc(row.segment)}</td><td>${esc(row.pages)}</td><td>${esc(row.task)}</td></tr>`).join("")}</tbody></table>
1468
+ <p class="engine-note">这里是脚本的派生视图;叙事主线/分段主张请写在「意图」文档里(ADR-0016)。</p>
1469
+ </details>`
1470
+ : "";
1471
+ const slideById = new Map((STATE.data?.active?.slides || []).map((slide) => [slide.id, slide]));
1472
+ const pending = (STATE.data?.proposals || []).filter((proposal) => !STATE.data?.active?.deckId || proposal.deckId === STATE.data.active.deckId);
1473
+ // 「两套标题」的第二层原因:同一页同一字段被提了两轮。只把最新一条当成待确认,旧的标成可清理。
1474
+ const latestPending = latestByField(pending);
1475
+ const stalePending = staleProposals(pending);
1476
+ const expiredPending = latestPending.filter((proposal) => proposal.stale);
1477
+ const pendingByPage = new Map();
1478
+ for (const proposal of latestPending) {
1479
+ const pageNo = Number(proposal.page);
1480
+ if (!Number.isFinite(pageNo) || pageNo <= 0) continue;
1481
+ if (!pendingByPage.has(pageNo)) pendingByPage.set(pageNo, []);
1482
+ pendingByPage.get(pageNo).push(proposal);
1483
+ }
1484
+ const actionablePending = latestPending.filter((proposal) => !proposal.stale);
1485
+ const bannerNotes = [
1486
+ stalePending.length ? `另有 ${stalePending.length} 条更早的同类提案已被新提案取代。` : "",
1487
+ expiredPending.length ? `${expiredPending.length} 条已过期(工作副本变了,确认会失败),请清理后让 Agent 重新提案。` : "",
1488
+ ].filter(Boolean).join(" ");
1489
+ const banner = latestPending.length
1490
+ ? `<div class="proposal-banner"><div><b>Agent 提了 ${latestPending.length} 条修改,还没落版</b><span>确认后才写入 revision(旧版保留);也可以逐条确认或驳回。${bannerNotes ? ` ${bannerNotes}` : ""}</span></div><div class="proposal-banner-actions">${actionablePending.length ? `<button class="btn solid" data-confirm-all="1">全部确认落版</button>` : ""}${stalePending.length ? `<button class="btn alt" data-reject-stale-all="1">清理旧提案</button>` : ""}${expiredPending.length ? `<button class="btn alt" data-reject-expired-all="1">清理过期提案</button>` : ""}<button class="btn alt" data-reject-all="1">全部驳回</button></div></div>`
1491
+ : "";
1492
+ // F-111:把「这个 deck 用哪个登记的 design」放到脚本页顶部——出图会自动带它的 styleBrief,
1493
+ // 写 visualBrief 时也该服从它,避免「Design 页写着 SKT、页面 brief 却是另一套视觉语言」。
1494
+ const linkedDesign = STATE.data?.active?.design;
1495
+ // F-121:provenance 汇总放在这一行,用户不用逐页找徽标就知道「画面要求到底算不算当前风格的」。
1496
+ const briefSlides = (STATE.data?.active?.slides || []).filter((slide) => String(slide.visualBrief || "").trim());
1497
+ const briefSame = linkedDesign ? briefSlides.filter((slide) => slide.visualBriefDesign === linkedDesign.abbr).length : 0;
1498
+ const briefUnknown = briefSlides.filter((slide) => !slide.visualBriefDesign).length;
1499
+ const styleNote = linkedDesign
1500
+ ? `<p class="linked-style">出图风格:<b>${esc(linkedDesign.abbr)} ${esc(linkedDesign.name)}</b>(Design 关联,出图自动带上它的风格提示词)${briefSlides.length ? `<span class="brief-cover">· 画面要求:<b>${briefSame}/${briefSlides.length}</b> 页写于 ${esc(linkedDesign.abbr)}${briefUnknown ? `,${briefUnknown} 页未记录写作风格` : ""}</span>` : ""}</p>`
1501
+ : `<p class="linked-style">出图风格:未关联 design(只带全局风格;可在 Design 页关联一个登记风格)</p>`;
1502
+ // F-114:换风格后,不是在新风格下写的画面要求标出来——用户可以选择「只换风格」或「重写画面要求」。
1503
+ const briefPendingPageIds = new Set(
1504
+ latestPending.filter((proposal) => proposal.field === "visualBrief").map((proposal) => proposal.pageId),
1505
+ );
1506
+ const staleBriefPages = linkedDesign
1507
+ ? staleBriefPagesFor(linkedDesign.abbr).filter((slide) => !briefPendingPageIds.has(slide.id))
1508
+ : [];
1509
+ const densityOption = (STATE.data?.active?.contentDensityOptions || []).find((option) => option.key === (STATE.data?.active?.contentDensity || "auto"));
1510
+ const densityDetail = !densityOption
1511
+ ? ""
1512
+ : densityOption.key === "auto"
1513
+ ? esc(densityOption.hint)
1514
+ : `正文 ${esc(densityOption.body)} · 画面要求 ${esc(densityOption.brief)};${esc(densityOption.hint)}`;
1515
+ const densityNote = densityOption
1516
+ ? `<p class="linked-style">内容密度:<b>${esc(densityOption.label)}</b>(${densityDetail})${densityOption.bodyMin != null ? ` <button class="mini-btn" data-density-rewrite="1">按当前密度检查 / 重写</button>` : ""}——在 deck 工具栏「密度」里改;正文长 ≠ 画面字多,别把正文塞进图。</p>`
1517
+ : "";
1518
+ const briefBanner = staleBriefPages.length
1519
+ ? `<div class="proposal-banner brief-banner"><div><b>${staleBriefPages.length} 页的画面要求是旧风格下写的</b><span>当前关联 ${esc(linkedDesign.abbr)},这些页出图会用「旧画面要求 + 新风格」,可能出现风格混搭。可以只换风格,也可以让 Agent 按新风格重写画面要求(效果更好)。</span></div><div class="proposal-banner-actions"><button class="btn solid" data-brief-refresh="1">让 Agent 按 ${esc(linkedDesign.abbr)} 重写这 ${staleBriefPages.length} 页</button></div></div>`
1520
+ : "";
1521
+ const densityTarget = densityOption && densityOption.bodyMin != null ? densityOption : null;
1522
+ const countChars = (text) => String(text || "").replace(/\s+/g, "").length;
1523
+ const densityBadge = (kind, text) => {
1524
+ if (!densityTarget) return "";
1525
+ const chars = countChars(text);
1526
+ const min = kind === "body" ? densityTarget.bodyMin : densityTarget.briefMin;
1527
+ const max = kind === "body" ? densityTarget.bodyMax : densityTarget.briefMax;
1528
+ const off = chars < min || chars > max;
1529
+ const name = kind === "body" ? "正文" : "画面要求";
1530
+ return `<span class="density-count${off ? " is-off" : ""}" title="当前密度「${esc(densityTarget.label)}」:${name}目标 ${min}–${max} 字">${name} ${chars} 字${off ? ` · 目标 ${min}–${max}` : " · 达标"}</span>`;
1531
+ };
1532
+ return head + styleNote + densityNote + briefBanner + docIssues(doc) + storyOverview + banner + `<div class="page-cards">${pages.map((page) => {
1533
+ const pageProposals = pendingByPage.get(Number(page.page)) || [];
1534
+ const slide = slideById.get(page.id) || {};
1535
+ // 已落版值(manifest)优先:确认过的 title/question/body 必须立刻显示,不能被源文档快照盖住。
1536
+ const pageTitle = slide.title || page.title;
1537
+ const pageSubtitle = slide.question || page.subtitle;
1538
+ const bodyText = String(slide.body || page.content || "").trim();
1539
+ // visualBrief(manifest)才是出图真正用的提示词;prompts/Pxx.txt 是源侧旧文件。
1540
+ const promptText = slide.visualBrief || page.prompt || "";
1541
+ const fieldProposals = pageProposals.filter((proposal) => proposal.field !== "script-add-page");
1542
+ const staleForPage = stalePending.filter((proposal) => Number(proposal.page) === Number(page.page));
1543
+ const expiredForPage = pageProposals.filter((proposal) => proposal.stale);
1544
+ // 「两套」问题的修法:卡片上半部分明确是当前已落版内容,提案行显示「当前值 → 建议值」。
1545
+ const proposalBlock = pageProposals.length
1546
+ ? `<div class="proposal-strip"><p class="proposal-strip-note">待确认修改 <b>${pageProposals.length}</b> 条 · 上半部分是<b>当前已落版</b>内容,点「确认」后才替换</p>${fieldProposals.map((proposal) => proposal.stale
1547
+ ? `<div class="proposal-row is-stale"><span class="proposal-field">${esc(proposalFieldLabel(proposal.field))}</span><span class="proposal-before">${esc(proposalCurrent(proposal) || "(空)")}</span><span class="proposal-arrow">→</span><span class="proposal-value">${esc(proposalPreview(proposal) || "(空值)")}</span><span class="proposal-expired">已过期 · 需重新提案</span><span class="proposal-actions"><button class="mini-btn" data-reject-proposal="${esc(proposal.proposalId)}">驳回</button></span></div>`
1548
+ : `<div class="proposal-row"><span class="proposal-field">${esc(proposalFieldLabel(proposal.field))}</span><span class="proposal-before" title="${esc(proposalCurrent(proposal) || "(空)")}">${esc(proposalCurrent(proposal) || "(空)")}</span><span class="proposal-arrow">→</span><span class="proposal-value" title="${esc(proposalPreview(proposal))}">${esc(proposalPreview(proposal) || "(空值)")}</span><span class="proposal-actions"><button class="mini-btn" data-confirm-proposal="${esc(proposal.proposalId)}">确认</button><button class="mini-btn" data-reject-proposal="${esc(proposal.proposalId)}">驳回</button></span></div>`).join("")}<div class="proposal-strip-foot">${staleForPage.length ? `<button class="mini-btn" data-reject-stale="${esc(staleForPage.map((proposal) => proposal.proposalId).join(","))}">清理 ${staleForPage.length} 条旧提案</button> ` : ""}<button class="mini-btn" data-confirm-page="${esc(page.page)}">这一页全部确认</button></div></div>`
1549
+ : "";
1550
+ const placeholderContent = /^(待补|^(待定/.test(bodyText);
1551
+ const placeholderPrompt = /^(待补|^(缺|^(待定/.test(String(promptText || "").trim());
1552
+ const pendingHint = (fields) => {
1553
+ const count = pageProposals.filter((proposal) => fields.includes(proposal.field)).length;
1554
+ return count ? `<span class="small"> · 有 ${count} 条待确认提案(见下)</span>` : "";
1555
+ };
1556
+ const briefWriteStyle = String(slide.visualBrief || "").trim() ? (slide.visualBriefDesign ?? null) : null;
1557
+ const hasPendingBrief = pageProposals.some((proposal) => proposal.field === "visualBrief");
1558
+ const briefStaleBadge = hasPendingBrief
1559
+ ? `<span class="brief-style-badge is-pending" title="新画面要求已提案,确认后才写入">新画面要求待确认</span>`
1560
+ : !linkedDesign || briefWriteStyle === null
1561
+ ? (linkedDesign && String(slide.visualBrief || "").trim() ? `<span class="brief-style-badge is-unknown" title="这条画面要求写入时没有记录风格(功能上线前的旧数据);出图仍按当前关联风格,换风格时建议一并刷新">写作风格未记录</span>` : "")
1562
+ : (briefWriteStyle !== linkedDesign.abbr
1563
+ ? `<span class="brief-style-badge" title="这条画面要求写于旧风格下,出图可能混搭">旧风格 brief · ${esc(briefWriteStyle)}</span>`
1564
+ : "");
1565
+ const pageKey = page.id ?? `p${String(page.page).padStart(2, "0")}`;
1566
+ const pageHash = scriptPageHash(page, slide);
1567
+ const unseen = scriptSeenUnseen(STATE.data?.active?.deckId, pageKey, pageHash);
1568
+ return `<div class="page-card script-page" data-script-page="${esc(pageKey)}" data-script-hash="${esc(pageHash)}">
1569
+ <div class="page-card-head"><span class="pill">P${String(page.page).padStart(2, "0")}</span><b>${esc(pageTitle || "(未命名)")}</b>${unseen ? `<span class="page-dot" title="这页脚本更新过,点开确认后消失"></span>` : ""}${briefStaleBadge}${pageProposals.length ? `<span class="page-pending-badge">${pageProposals.length} 条待确认${staleForPage.length ? ` + ${staleForPage.length} 旧` : ""}${expiredForPage.length ? ` + ${expiredForPage.length} 过期` : ""}</span>` : ""}<button class="mini-btn" data-edit-page="${esc(page.id)}">微调</button></div>
1570
+ <div class="page-card-body">
1571
+ <p><span class="page-card-label">副标题</span>${esc(pageSubtitle || "—")}${pendingHint(["question"])}</p>
1572
+ ${(() => {
1573
+ const claimText = String(slide.claim ?? "").trim();
1574
+ // 主张与正文完全重复时不重复显示(老数据里 claim 曾被当成正文写)。
1575
+ if (!claimText || claimText === String(bodyText ?? "").trim()) return "";
1576
+ const claimChars = [...claimText].length;
1577
+ const claimWarn = claimChars > 120
1578
+ ? ` <span class="density-count is-off" title="主张是一句话判断(建议 ≤ 60 字);超过 120 字通常是把正文写进了主张——长内容应放在正文">主张过长 ${claimChars} 字 · 长内容应写正文</span>`
1579
+ : ` <span class="density-count">${claimChars} 字</span>`;
1580
+ return `<p class="claim-line"><span class="page-card-label">主张</span>${esc(claimText.slice(0, 160))}${claimText.length > 160 ? "…" : ""}${claimWarn}${pendingHint(["claim"])}</p>`;
1581
+ })()}
1582
+ ${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>`}
1583
+ <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>
1584
+ ${proposalBlock}
1585
+ </div></div>`;
1586
+ }).join("")}</div>` + docFooter(doc, content);
1587
+ }
1588
+ if (doc.id === "design") {
1589
+ const rules = doc.parsed.rules ?? [];
1590
+ const render = doc.parsed.render ?? {};
1591
+ const styles = STATE.data?.active?.designStyles || [];
1592
+ // F-51:本 deck ↔ 登记风格(design)的关联区,可关联 / 解除。
1593
+ const association = STATE.data?.active?.design || null;
1594
+ const designChoices = STATE.data?.designProjects || [];
1595
+ const assocCurrent = association
1596
+ ? `<div class="assoc-current">
1597
+ <div class="style-doc-head"><span class="style-abbr">${esc(association.abbr)}</span><b>${esc(association.name)}</b><span class="small">${(association.samples || []).length} 张样张</span></div>
1598
+ <p class="engine-note">${esc(association.description || "(未写风格描述)")}</p>
1599
+ <div class="design-grid">${(association.samples || []).slice(0, 6).map((sample) => `<a class="design-item" href="${esc(sample.url)}" target="_blank" rel="noreferrer"><img src="${esc(sample.url)}" alt="${esc(sample.name)}" loading="lazy"><span>${esc(sample.name)}</span></a>`).join("") || `<p class="engine-note">这个风格还没有样张。</p>`}</div>
1600
+ <div class="assoc-actions">${(() => {
1601
+ // F-113:已关联时也能直接换绑(之前只给了「解除关联」,换风格要先解绑,用户找不到入口)。
1602
+ const others = designChoices.filter((design) => design.designId !== association.designId);
1603
+ if (!others.length) return "";
1604
+ return `<select id="assocSwitch">${others.map((design) => `<option value="${esc(design.designId)}">${esc(design.abbr)} · ${esc(design.name)}(${(design.samples || []).length} 张样张)</option>`).join("")}</select><button class="btn" data-assoc-switch="1">更换到这个风格</button>`;
1605
+ })()}<button class="btn alt" data-assoc-unbind="1">解除关联</button></div>
1606
+ <div class="assoc-switch-panel" id="assocSwitchPanel" hidden>
1607
+ <b id="assocSwitchTitle">换风格</b>
1608
+ <p class="small" id="assocSwitchStaleNote"></p>
1609
+ <label><input type="radio" name="assocRefresh" value="keep" checked> 只换风格(保留现有画面要求;下次出图 = 现有画面要求 + 新风格)</label>
1610
+ <label><input type="radio" name="assocRefresh" value="rewrite"> 按新风格重写画面要求(推荐;逐页提案,确认后出图效果更好)</label>
1611
+ <div class="panel-actions"><button class="btn solid" data-assoc-switch-confirm="1">确认换绑</button><button class="btn alt" data-assoc-switch-cancel="1">取消</button></div>
1612
+ </div>
1613
+ <p class="engine-note">换风格只改出图时用的 styleBrief,不改已有页面。旧页面的 visualBrief 若写的是上一个风格的画面语言,换完先重写再出图。</p>
1614
+ </div>`
1615
+ : `<div class="assoc-picker">${designChoices.length
1616
+ ? `<select id="assocSelect">${designChoices.map((design) => `<option value="${esc(design.designId)}">${esc(design.abbr)} · ${esc(design.name)}(${(design.samples || []).length} 张样张)</option>`).join("")}</select><button class="btn solid" data-assoc-bind="1">关联到本 deck</button>`
1617
+ : `<span class="small">还没有登记风格,先到 Designs 页「+ 添加风格」。</span>`}</div>`;
1618
+ const assocBlock = `<div class="assoc-block">
1619
+ <div class="assoc-head"><div><div class="eyebrow">关联风格 · DESIGN</div><h3>这个 deck 用哪个登记风格</h3></div>${association ? `<span class="bind-chip design">已关联 ${esc(association.abbr)}</span>` : `<span class="bind-chip unbound">未关联</span>`}</div>
1620
+ ${assocCurrent}
1621
+ <p class="engine-note">关联只登记「这个 deck 用这个风格」,不改源 deck 的任何文件;Designs 页会显示这个风格被哪些 deck 使用。</p>
1622
+ </div>`;
1623
+ // F-116:源 deck 的 style-reference.md 可能还是旧风格(只读改不了);把「当前风格」实时展示出来,
1624
+ // 并给文档打过期标记,避免「关联 FYI、文档写 SKT」看起来自相矛盾。
1625
+ const liveStyle = STATE.data?.active?.design || null;
1626
+ const docStyleAbbr = (String(content || "").match(/^\s*style-abbr:\s*([A-Za-z0-9_-]+)/m) || [])[1] || "";
1627
+ const styleMismatch = Boolean(liveStyle && docStyleAbbr && String(docStyleAbbr).toUpperCase() !== String(liveStyle.abbr).toUpperCase());
1628
+ const liveStyleBlock = liveStyle
1629
+ ? `<div class="md live-style"><h3>当前风格(实时 · 来自关联的 ${esc(liveStyle.abbr)})</h3>
1630
+ <p><b>画面语言</b>:${esc(liveStyle.styleBrief || liveStyle.description || "(未写)")}</p>
1631
+ <p><b>不要什么</b>:${esc(liveStyle.constraints || "(未写)")}</p>
1632
+ <p><b>参考</b>:${esc(liveStyle.reference || "(未写)")}</p>
1633
+ <p class="engine-note">这段直接来自 Designs 登记表,换风格后立刻生效;出图 prompt 用它。下面「源 deck 的设计文档」是只读老文档,可能过期。</p></div>`
1634
+ : "";
1635
+ return head + docIssues(doc) + assocBlock + liveStyleBlock + `<div class="meta-chips">
1636
+ <span class="meta-chip"><b>模式</b> ${esc(render.mode || "—")}</span>
1637
+ <span class="meta-chip"><b>模型</b> ${esc(render.model || "—")}</span>
1638
+ <span class="meta-chip"><b>尺寸</b> ${esc(render.size || "—")}</span>
1639
+ <span class="meta-chip"><b>节点</b> ${esc(render.endpoint || "—")}</span>
1640
+ </div><div class="md"><h3>源 deck 的设计文档(只读)${styleMismatch ? `<span class="stale-chip">style-abbr: ${esc(docStyleAbbr)} ≠ 当前 ${esc(liveStyle.abbr)}</span>` : ""}</h3>
1641
+ <h4>画面约束(${rules.length} 条)</h4><ul>${rules.map((rule) => `<li>${esc(rule)}</li>`).join("")}</ul>
1642
+ ${styleMismatch ? `<p class="engine-note">这份文档在源 deck 里(只读),无法随关联风格自动改写;以上面「当前风格(实时)」为准,需要改文档请到源里改。</p>` : ""}</div>
1643
+ <h3>源 deck 里的风格目录(${styles.length} 个)</h3>
1644
+ ${styles.length ? styles.map((style) => `<div class="style-doc">
1645
+ <div class="style-doc-head"><span class="style-abbr">${esc(style.abbr)}</span><b>${esc(style.name)}</b><span class="small">${(style.samples || []).length} 张样张</span></div>
1646
+ <p class="engine-note">${esc(style.description || "(未写风格提示词)")}</p>
1647
+ <div class="design-grid">${(style.samples || []).map((sample) => `<a class="design-item" href="${esc(sample.url)}" target="_blank" rel="noreferrer"><img src="${esc(sample.url)}" alt="${esc(sample.name)}" loading="lazy"><span>${esc(sample.name)} · ${fmtBytes(sample.sizeBytes)}</span></a>`).join("") || `<p class="engine-note">这个风格还没有样张。</p>`}</div>
1648
+ </div>`).join("") : `<p class="engine-note">源 deck 里没有 <code>designs/&lt;缩写&gt;/</code> 目录;本 deck 用的风格以上面的「关联风格」为准(来自 Designs 登记表)。</p>`}` + docFooter(doc, content);
1649
+ }
1650
+ if (doc.id === "changelog") {
1651
+ const feed = doc.parsed.feed ?? [];
1652
+ const fmtWhen = (value) => String(value || "").replace("T", " ").slice(0, 16).replace(/-/g, "/");
1653
+ const list = feed.length
1654
+ ? `<div class="change-feed">${feed.map((row) => `
1655
+ <div class="change-item">
1656
+ <div class="change-meta">
1657
+ <span class="change-when">${esc(fmtWhen(row.date) || "—")}</span>
1658
+ <span class="change-kind">${esc(row.kind || "变更")}</span>
1659
+ ${row.page ? `<span class="change-page">${esc(row.page)}</span>` : ""}
1660
+ ${row.revision ? `<span class="change-rev">${esc(row.revision)}</span>` : ""}
1661
+ <span class="change-actor">${esc(row.actor || "—")}</span>
1662
+ </div>
1663
+ <div class="change-note">${row.note ? mdInline(row.note) : `<span class="small">系统记录,未写说明</span>`}</div>
1664
+ </div>`).join("")}</div>`
1665
+ : `<p class="engine-note">还没有变更记录:改一次 script / 出一次图就会自动出现在这里。</p>`;
1666
+ return head + docIssues(doc) + list + `<p class="engine-note">这张日志由系统从版本与事件自动生成;Agent 每次改完 deck 只用补一句「为什么」(工具 <code>deck_note_change</code>),不需要手抄历史。</p>` + docFooter(doc, content);
1667
+ }
1668
+ if (doc.id === "feedback") {
1669
+ const rows = doc.parsed.rows ?? [];
1670
+ const list = rows.length
1671
+ ? `<div class="fb-list">${rows.map((row) => `
1672
+ <div class="fb-item">
1673
+ <div class="fb-meta">
1674
+ <span class="fb-date">${esc(row.date || "—")}</span>
1675
+ <span class="fb-target">${esc(row.target || "")}</span>
1676
+ <span class="fb-layer">${esc(row.layer || "")}</span>
1677
+ ${row.tags ? `<span class="fb-tags">${esc(row.tags)}</span>` : ""}
1678
+ </div>
1679
+ <div class="fb-quote">${mdInline(row.quote || "")}</div>
1680
+ <div class="fb-action"><span>处理</span>${mdInline(row.action || "—")}</div>
1681
+ </div>`).join("")}</div>`
1682
+ : `<p class="engine-note">还没有反馈记录。</p>`;
1683
+ return head + docIssues(doc) + list + docFooter(doc, content);
1684
+ }
1685
+ if (doc.id === "research") {
1686
+ const entries = doc.parsed.entries ?? [];
1687
+ return head + docIssues(doc) + `<div class="src-ledger">${entries.map((entry, index) => {
1688
+ const localHref = entry.localUrl ? docFileUrl("research", entry.localUrl) : "";
1689
+ return `<div class="src-entry">
1690
+ <div class="src-entry-head"><span class="pill">${String(index + 1).padStart(2, "0")}</span><b>${esc(entry.title || "(未命名)")}</b></div>
1691
+ <p><span class="page-card-label">出处</span>${esc(entry.source || "—")} <span class="page-card-label">发表时间</span>${esc(entry.date || "缺")}</p>
1692
+ <p><span class="page-card-label">原文</span>${entry.url ? `<a href="${esc(entry.url)}" target="_blank" rel="noreferrer">${esc(entry.url)}</a>` : "缺链接"}</p>
1693
+ <p><span class="page-card-label">本地存档</span>${localHref ? `<a href="${esc(localHref)}" target="_blank" rel="noreferrer">${esc(entry.localFile)}</a>` : "未下载"}</p>
1694
+ ${entry.summary ? `<p><span class="page-card-label">总结</span>${esc(entry.summary)}</p>` : ""}
1695
+ </div>`;
1696
+ }).join("")}</div>` + docFooter(doc, content);
1697
+ }
1698
+ if (doc.id === "materials") {
1699
+ const items = doc.parsed.items ?? [];
1700
+ const sourceRoot = String(STATE.data?.active?.sourceRoot || "").replace(/\/+$/, "");
1701
+ const kindOf = (name) => {
1702
+ const value = String(name || "");
1703
+ if (value.endsWith("/") || !/\.[a-z0-9]+$/i.test(value)) return "目录";
1704
+ const ext = (value.match(/\.([a-z0-9]+)$/i) ?? [])[1];
1705
+ if (!ext) return "文件";
1706
+ if (/^(png|jpe?g|webp|gif)$/i.test(ext)) return "图片";
1707
+ if (/^(pdf|pptx?|docx?)$/i.test(ext)) return "交付物";
1708
+ if (/^(json|ya?ml|csv)$/i.test(ext)) return "数据";
1709
+ if (/^(md|txt)$/i.test(ext)) return "文档";
1710
+ return ext.toUpperCase();
1711
+ };
1712
+ // 长绝对路径压成相对 deck 源目录的短路径(完整路径进 title 悬停),避免把列撑爆(F-50)
1713
+ const shortPath = (link) => {
1714
+ const value = String(link || "").trim();
1715
+ if (!value) return "";
1716
+ if (/^https?:\/\//i.test(value)) return value;
1717
+ if (sourceRoot && value === sourceRoot) return ".";
1718
+ if (sourceRoot && value.startsWith(`${sourceRoot}/`)) return value.slice(sourceRoot.length + 1);
1719
+ const parts = value.split("/").filter(Boolean);
1720
+ return parts.length > 3 ? `…/${parts.slice(-2).join("/")}` : value;
1721
+ };
1722
+ return head + docIssues(doc) + (items.length
1723
+ ? `<div class="mat-list">${items.map((row) => {
1724
+ const path = shortPath(row.link);
1725
+ const isUrl = /^https?:\/\//i.test(String(row.link || ""));
1726
+ const linkHint = row.link && row.link !== path ? ` title="${esc(row.link)}"` : "";
1727
+ return `<div class="mat-item">
1728
+ <div class="mat-head"><b class="mat-name"${linkHint}>${esc(row.name || "(未命名)")}</b><span class="mat-kind">${esc(kindOf(row.name))}</span></div>
1729
+ <p class="mat-summary">${esc(row.summary || "未登记总结")}</p>
1730
+ ${path && path !== row.name ? `<p class="mat-link"${linkHint}>${isUrl ? `<a href="${esc(row.link)}" target="_blank" rel="noreferrer">${esc(path)}</a>` : esc(path)}</p>` : ""}
1731
+ </div>`;
1732
+ }).join("")}</div>
1733
+ <p class="engine-note">路径相对 deck 源目录显示,完整路径在悬停提示里。这张清单只登记引用,不复制素材。</p>`
1734
+ : `<p class="engine-note">还没有登记支撑材料。</p>`) + docFooter(doc, content);
1735
+ }
1736
+ return head + `<div class="md">${mdToHtml(stripFrontmatter(content))}</div>` + docFooter(doc, content);
1737
+ }
1738
+
1739
+ const stripFrontmatter = (text) => String(text ?? "").replace(/^---\n[\s\S]*?\n---\n?/, "");
1740
+ const docFileUrl = (docId, name) => `/api/deck-studio.docfile?` + new URLSearchParams({ deckId: STATE.data.active.deckId, docId, name });
1741
+
1742
+ /** F-108:提案字段的展示名 + 预览值(分节提案的 after 是 `**字段**:值` 整行)。 */
1743
+ const PROPOSAL_FIELD_LABEL = { title: "标题", question: "副标题", claim: "主张", body: "正文", visualBrief: "画面要求", "script-add-page": "追加页" };
1744
+ function proposalFieldLabel(field) {
1745
+ return PROPOSAL_FIELD_LABEL[field] || field || "修改";
1746
+ }
1747
+ function proposalPreview(proposal) {
1748
+ const raw = String(proposal?.after ?? "").replace(/^\s*\*\*[^*]+\*\*\s*[::]\s*/, "").trim();
1749
+ return raw.length > 220 ? `${raw.slice(0, 220)}…` : raw;
1750
+ }
1751
+ function proposalCurrent(proposal) {
1752
+ const raw = String(proposal?.before ?? "").replace(/^\s*\*\*[^*]+\*\*\s*[::]\s*/, "").trim();
1753
+ return raw.length > 160 ? `${raw.slice(0, 160)}…` : raw;
1754
+ }
1755
+ // 同一页同一字段可能有多轮提案:只有最新一条算「待确认」,旧的视为可清理(F-108)。
1756
+ function latestByField(proposals) {
1757
+ const grouped = new Map();
1758
+ for (const proposal of proposals || []) {
1759
+ const key = `${proposal.pageId || ""}::${proposal.field || ""}`;
1760
+ const prev = grouped.get(key);
1761
+ if (!prev || String(proposal.createdAt || "") >= String(prev.createdAt || "")) grouped.set(key, proposal);
1762
+ }
1763
+ return [...grouped.values()];
1764
+ }
1765
+ function staleProposals(proposals) {
1766
+ const latestIds = new Set(latestByField(proposals).map((proposal) => proposal.proposalId));
1767
+ return (proposals || []).filter((proposal) => !latestIds.has(proposal.proposalId));
1768
+ }
1769
+ function deckPendingProposals(target = null) {
1770
+ const deckId = target ?? STATE.data?.active?.deckId ?? "";
1771
+ return (STATE.data?.proposals || []).filter((proposal) => !deckId || proposal.deckId === deckId);
1772
+ }
1773
+ function pendingProposalIds(target = null) {
1774
+ return latestByField(deckPendingProposals(target)).map((proposal) => proposal.proposalId);
1775
+ }
1776
+ function freshProposalIds(target = null) {
1777
+ return latestByField(deckPendingProposals(target)).filter((proposal) => !proposal.stale).map((proposal) => proposal.proposalId);
1778
+ }
1779
+ function allPendingProposalIds(target = null) {
1780
+ return deckPendingProposals(target).map((proposal) => proposal.proposalId);
1781
+ }
1782
+
1783
+ function flash(text, kind = "ok") {
1784
+ let el = document.getElementById("deckFlash");
1785
+ if (!el) {
1786
+ el = document.createElement("div");
1787
+ el.id = "deckFlash";
1788
+ document.body.appendChild(el);
1789
+ }
1790
+ el.className = `deck-flash ${kind}`;
1791
+ el.textContent = text;
1792
+ clearTimeout(flash.timer);
1793
+ flash.timer = setTimeout(() => el.remove(), 4200);
1794
+ }
1795
+
1796
+ /** F-108:在工作台直接落版 / 驳回提案(与 Agent 的 deck_revision_confirm 同一条写入路径)。 */
1797
+ async function decideProposals(proposalIds, confirmed) {
1798
+ const ids = (proposalIds || []).filter(Boolean);
1799
+ if (!ids.length) return;
1800
+ const res = await fetch("/api/deck-studio.proposal-decide", {
1801
+ method: "POST",
1802
+ headers: { "content-type": "application/json" },
1803
+ body: JSON.stringify({
1804
+ proposalIds: ids,
1805
+ confirmed,
1806
+ quote: confirmed ? "(工作台:用户点了「确认落版」)" : "(工作台:用户点了「驳回」)",
1807
+ }),
1808
+ });
1809
+ let payload = {};
1810
+ try { payload = await res.json(); } catch { /* 非 JSON 响应走下面兜底 */ }
1811
+ if (!res.ok || !payload.ok) {
1812
+ flash(`操作失败:${payload?.error?.message || `HTTP ${res.status}`}`, "error");
1813
+ return;
1814
+ }
1815
+ const failed = (payload.results || []).filter((item) => !item.ok);
1816
+ const expired = failed.filter((item) => /已过期|工作副本已漂移/.test(String(item.error || "")));
1817
+ let message;
1818
+ if (!failed.length) message = confirmed ? `已落版 ${payload.decided} 条(旧版保留)` : `已驳回 ${payload.decided} 条`;
1819
+ else if (expired.length === failed.length) message = `${failed.length} 条提案已过期(工作副本已被其他 revision 改动),没有落版。点「清理过期提案」后让 Agent 重新提案。`;
1820
+ else message = `部分失败:已落版 ${payload.decided} 条 · ${failed.length} 条失败(${expired.length} 条过期)· ${failed[0].error}`;
1821
+ flash(message, failed.length ? "error" : "ok");
1822
+ // 文档是按标签缓存的;落版后必须丢掉缓存,否则脚本 tab 还显示旧正文(F-108 验收发现)。
1823
+ STATE.docCache = {};
1824
+ await loadWorkbench(STATE.deckId);
1825
+ if (["script", "design", "intent", "changelog"].includes(STATE.tab)) await renderDocTab(STATE.tab);
1826
+ }
1827
+
1828
+ function bindDocActions(root) {
1829
+ root.querySelectorAll("[data-assoc-bind]").forEach((btn) => btn.addEventListener("click", () => bindDeckDesign(getEl("assocSelect")?.value || "")));
1830
+ root.querySelectorAll("[data-assoc-switch]").forEach((btn) => btn.addEventListener("click", openAssocSwitchPanel));
1831
+ root.querySelectorAll("[data-assoc-switch-confirm]").forEach((btn) => btn.addEventListener("click", confirmAssocSwitch));
1832
+ root.querySelectorAll("[data-assoc-switch-cancel]").forEach((btn) => btn.addEventListener("click", () => { getEl("assocSwitchPanel").hidden = true; }));
1833
+ root.querySelectorAll("[data-brief-refresh]").forEach((btn) => btn.addEventListener("click", () => {
1834
+ const linkedDesign = STATE.data?.active?.design;
1835
+ if (!linkedDesign) return;
1836
+ const stale = staleBriefPagesFor(linkedDesign.abbr);
1837
+ openAgentPrompt(`当前 deck 关联的出图风格是 ${linkedDesign.abbr} ${linkedDesign.name},但以下页的 visualBrief 是旧风格下写的:${stale.map((slide) => slide.id).join("、")}。\n请按 ${linkedDesign.abbr} 重写这些页的 visualBrief。\n要求:\n① 必须调用 deck_script_propose_edit 逐页提案(一次一页也行,或用批量提案一次提多页),不要输出表格或清单让我复制粘贴;\n② 只写这页画面内容 / 版式 / 信息结构,不要写颜色、材质、笔触、背景等风格词;\n③ 全部提完回我 proposalId 列表 + 每页一句重点。我确认后再出图。`, "按新风格重写画面要求");
1838
+ }));
1839
+ root.querySelectorAll("[data-density-rewrite]").forEach((btn) => btn.addEventListener("click", () => {
1840
+ const active = STATE.data?.active || {};
1841
+ const option = (active.contentDensityOptions || []).find((item) => item.key === (active.contentDensity || "auto"));
1842
+ if (!active.deckId || !option || option.bodyMin == null) return;
1843
+ const pages = (active.slides || []).map((slide) => ({
1844
+ id: slide.id,
1845
+ body: String(slide.body || "").replace(/\s+/g, "").length,
1846
+ brief: String(slide.visualBrief || "").length,
1847
+ }));
1848
+ const bad = pages.filter((page) => page.body < option.bodyMin || page.body > option.bodyMax || page.brief < option.briefMin || page.brief > option.briefMax);
1849
+ const list = bad.map((page) => `${page.id}(正文 ${page.body} 字 / 画面要求 ${page.brief} 字)`).join("、") || "(无)";
1850
+ openAgentPrompt(`请按当前内容密度「${option.label}」(正文 ${option.bodyMin}–${option.bodyMax} 字/页、画面要求 ${option.briefMin}–${option.briefMax} 字)检查 deck ${active.deckId},把不达标的页补齐:${list}。\n要求:\n① 用 deck_script_propose_edits 批量提案(title / question / body / visualBrief 一起提),不要只改一部分;\n② 正文按目标字数重写,画面要求只写这页画面内容 / 版式 / 信息结构,不写风格词;\n③ 提完回我 proposalId 列表 + 每页正文/画面要求字数,我确认后再出图。`, "按密度重写未达标页");
1851
+ }));
1852
+ root.querySelectorAll("[data-assoc-unbind]").forEach((btn) => btn.addEventListener("click", () => bindDeckDesign("")));
1853
+ root.querySelectorAll("[data-doc-fill]").forEach((btn) => btn.addEventListener("click", () => askAgentForDoc(btn.dataset.docFill)));
1854
+ // F-159:听众评审入口(脚本页)。评审只出意见,不改稿;改哪几条由用户说。
1855
+ root.querySelectorAll("[data-script-review]").forEach((btn) =>
1856
+ btn.addEventListener("click", () =>
1857
+ sendPrompt(
1858
+ `请调用 deck_script_review 评审 deck ${STATE.data.active.deckId} 的整份 script:按 intent 里的听众生成审稿人,只报告 P0/P1 与具体改法,不要直接改稿、不要出图。\n评审完先把 P0 逐条列给我(带页号),等我指定改哪几条,再用 deck_script_propose_edits 提案。`,
1859
+ ),
1860
+ ),
1861
+ );
1862
+ root.querySelectorAll("[data-doc-scaffold]").forEach((btn) => btn.addEventListener("click", () => askAgentForDoc(btn.dataset.docScaffold, true)));
1863
+ root.querySelectorAll("[data-copy-rules]").forEach((btn) => btn.addEventListener("click", async () => {
1864
+ const kind = btn.dataset.copyRules;
1865
+ const sourceRoot = STATE.data?.active?.sourceRoot || "";
1866
+ const text = kind === "template" ? RULES_TEMPLATE : `${sourceRoot ? `${sourceRoot}/` : ""}deck.rules.md`;
1867
+ const original = btn.textContent;
1868
+ try {
1869
+ await navigator.clipboard.writeText(text);
1870
+ btn.textContent = kind === "template" ? "已复制模板" : "已复制路径";
1871
+ } catch {
1872
+ btn.textContent = "复制失败,请手动选择文本";
1873
+ }
1874
+ setTimeout(() => { btn.textContent = original; }, 1600);
1875
+ }));
1876
+ root.querySelectorAll("[data-edit-page]").forEach((btn) => btn.addEventListener("click", () => openPageEditor(btn.dataset.editPage)));
1877
+ root.querySelectorAll("[data-confirm-proposal]").forEach((btn) => btn.addEventListener("click", () => decideProposals([btn.dataset.confirmProposal], true)));
1878
+ root.querySelectorAll("[data-reject-proposal]").forEach((btn) => btn.addEventListener("click", () => decideProposals([btn.dataset.rejectProposal], false)));
1879
+ root.querySelectorAll("[data-confirm-page]").forEach((btn) => btn.addEventListener("click", () => {
1880
+ const page = Number(btn.dataset.confirmPage);
1881
+ const ids = latestByField(deckPendingProposals().filter((proposal) => Number(proposal.page) === page && !proposal.stale)).map((proposal) => proposal.proposalId);
1882
+ decideProposals(ids, true);
1883
+ }));
1884
+ root.querySelectorAll("[data-confirm-all]").forEach((btn) => btn.addEventListener("click", () => decideProposals(freshProposalIds(), true)));
1885
+ root.querySelectorAll("[data-reject-all]").forEach((btn) => btn.addEventListener("click", () => decideProposals(allPendingProposalIds(), false)));
1886
+ root.querySelectorAll("[data-reject-stale]").forEach((btn) => btn.addEventListener("click", () => decideProposals(String(btn.dataset.rejectStale || "").split(",").filter(Boolean), false)));
1887
+ root.querySelectorAll("[data-reject-expired-all]").forEach((btn) => btn.addEventListener("click", () => decideProposals(deckPendingProposals().filter((proposal) => proposal.stale).map((proposal) => proposal.proposalId), false)));
1888
+ root.querySelectorAll("[data-reject-stale-all]").forEach((btn) => btn.addEventListener("click", () => {
1889
+ const latestIds = new Set(pendingProposalIds());
1890
+ decideProposals(allPendingProposalIds().filter((id) => !latestIds.has(id)), false);
1891
+ }));
1892
+ }
1893
+
1894
+ async function askAgentForDoc(docId, scaffoldOnly = false) {
1895
+ const doc = (STATE.data.active.documents || []).find((item) => item.id === docId);
1896
+ const label = doc?.label ?? docId;
1897
+ const prompt = scaffoldOnly
1898
+ ? `请调用 deck_docs_scaffold 为 deck ${STATE.data.active.deckId} 的「${label}」文档打骨架,然后告诉我创建了哪些文件。`
1899
+ : `请为 deck ${STATE.data.active.deckId} 补「${label}」文档:先读源目录里已有的 script / CURRENT / feedback / 调研等材料,按 .deck 工作副本模板起草到 docs/,把缺失字段(${(doc?.issues ?? []).join(";") || "按契约要求"})填上,并在 Chat 里给我确认要点。不要直接改客户源目录。`;
1900
+ await sendPrompt(prompt);
1901
+ }
1902
+
1903
+ /* ---------------- 治理标签 ---------------- */
1904
+
1905
+ /* F-88:deck 项目级规则卡片(只读展示;受控源只读,不提供页面内写入)。 */
1906
+ const RULES_TEMPLATE = [
1907
+ "# 项目规则(deck.rules.md)",
1908
+ "",
1909
+ "> 只约束这一份 deck;不能覆盖产品硬纪律(客户源只读 / QA 门禁 / 不编造 / 需用户确认)。",
1910
+ "",
1911
+ "- 受众:",
1912
+ "- 禁忌:",
1913
+ "- 视觉:",
1914
+ "- 事实来源:",
1915
+ ].join("\n");
1916
+
1917
+ function projectRulesCard(active) {
1918
+ const rules = active.projectRules || { found: false, flags: [] };
1919
+ if (!rules.found) {
1920
+ return `<div class="rules-card" id="projectRulesCard">
1921
+ <div class="rules-head"><b>项目规则</b><span class="doc-status missing">未配置</span><span class="small">可选</span></div>
1922
+ <p class="engine-note">新建 deck 时会自动生成一份 <code>deck.rules.md</code> 模板(受众 / 禁忌 / 视觉 / 事实来源已按创建表单填好),
1923
+ 打开项目根改这个文件即可生效(≤8KB);它只约束这一份 deck,不写进产品 persona,也不能覆盖硬纪律。</p>
1924
+ <button type="button" class="mini-btn" data-copy-rules="template">复制模板</button>
1925
+ <pre class="rules-body" id="rulesTemplateBody" hidden>${esc(RULES_TEMPLATE)}</pre>
1926
+ </div>`;
1927
+ }
1928
+ const flags = rules.flags || [];
1929
+ return `<div class="rules-card" id="projectRulesCard">
1930
+ <div class="rules-head"><b>项目规则</b><span class="doc-status ok">已配置</span>
1931
+ <span class="small">${esc(rules.path)} · ${rules.bytes} 字节${rules.truncated ? " · 超过 8KB 已截断" : ""}</span></div>
1932
+ <p class="engine-note">来自 deck 项目根,只约束这一份 deck;不能覆盖硬纪律(客户源只读 / QA 门禁 / 不编造 / 需用户确认)。
1933
+ ${flags.length ? `⚠ 检测到疑似越权表述:${flags.map((flag) => esc(flag)).join("、")}(不会被执行)` : ""}</p>
1934
+ <details class="rules-details"><summary>查看规则正文</summary><pre class="rules-body">${esc(rules.content || "")}</pre></details>
1935
+ <button type="button" class="mini-btn" data-copy-rules="path">复制路径</button>
1936
+ </div>`;
1937
+ }
1938
+
1939
+ function renderGovernance() {
1940
+ const data = STATE.data || {};
1941
+ const active = data.active || {};
1942
+ const governance = active.governance ?? { score: 0, counts: {}, missing: [], partial: [] };
1943
+ const documents = active.documents ?? [];
1944
+ const proposals = (data.proposals ?? []).filter((proposal) => !active.deckId || proposal.deckId === active.deckId);
1945
+ const config = data.config ?? {};
1946
+ const statusText = { ok: "齐备", partial: "待补", derived: "派生", draft: "草案", missing: "缺失" };
1947
+ getEl("observeView").innerHTML = `<div class="observe-body">
1948
+ <div class="gov-head">
1949
+ <div><div class="eyebrow">Deck Governance</div><h2>文档治理 · ${esc(active.title || "")}</h2></div>
1950
+ <div class="gov-score"><b>${governance.score}</b><span>/100</span></div>
1951
+ </div>
1952
+ <div class="gov-bar"><i style="width:${governance.score}%"></i></div>
1953
+ <p class="engine-note">这是「交接完备度」:3 份与生成相关(意图 / 脚本 / Design),另外 6 份交接可选。
1954
+ 缺文档不影响出图 / 改稿 / 导出,按需补即可;故事线已并入脚本概览 + 意图,不单独成文。</p>
1955
+ ${projectRulesCard(active)}
1956
+ <div class="gov-list">${documents.map((doc) => `<div class="gov-row">
1957
+ <span class="doc-status ${doc.status}">${statusText[doc.status] ?? doc.status}</span>
1958
+ <b>${esc(doc.label)}</b>
1959
+ <span class="small">${esc(doc.primary || "无文件")}</span>
1960
+ <span class="spacer"></span>
1961
+ ${doc.status === "ok" ? "" : `<button class="mini-btn" data-doc-fill="${esc(doc.id)}">让 Agent 补</button>`}
1962
+ </div>${doc.issues?.length ? `<div class="gov-issue">${doc.issues.map((issue) => `· ${esc(issue)}`).join("<br>")}</div>` : ""}`).join("")}</div>
1963
+ <h3>待确认提案(${latestByField(proposals).length})</h3>
1964
+ ${proposals.length ? latestByField(proposals).map((proposal) => `<div class="page-card"><div class="page-card-head"><span class="pill">${esc(proposal.proposalId)}</span><b>${esc(proposal.deckId)} / ${esc(proposal.pageId)} · ${esc(proposal.field)}</b></div><div class="page-card-body"><pre>${esc(proposal.diff || proposal.after || "")}</pre><p class="engine-note">在「脚本」页点确认落版(也可在 Chat 里回一句确认,由 Agent 走同一条裁决路径)。</p></div></div>`).join("") : `<p class="engine-note">暂无待确认提案。</p>`}
1965
+ <h3>环境</h3>
1966
+ <div class="src-row"><span class="src-name">cwd</span><span class="src-role">${esc(data.cwd)}</span></div>
1967
+ <div class="src-row"><span class="src-name">.deck</span><span class="src-role">${esc(config.dataRoot)}(工作副本 / 调研存档 / revision / 事件)</span></div>
1968
+ <div class="src-row"><span class="src-name">render</span><span class="src-role">${esc(config.renderMode)} · ${esc(config.renderModel)} · ${esc(config.renderSize)}</span></div>
1969
+ <div class="src-row"><span class="src-name">Agent</span><span class="src-role">deck_docs_check / deck_docs_scaffold 两个工具可直接跑治理检查</span></div>
1970
+ </div>`;
1971
+ bindDocActions(getEl("observeView"));
1972
+ }
1973
+
1974
+ /** 当前 design 画布对应的 Design 项目(含样张 URL,F-58)。 */
1975
+ function designCanvasProject(active) {
1976
+ if (active?.kind !== "design-canvas") return null;
1977
+ return (STATE.data?.designProjects || []).find((project) => project.canvasDeckId === active.deckId) || null;
1978
+ }
1979
+
1980
+ /** deck 工作区 chrome 在 design 画布下切成 design 语义(F-58)。 */
1981
+ function setDeckChromeForDesign(isDesign) {
1982
+ const addPage = getEl("addPageBtn");
1983
+ if (addPage) addPage.hidden = Boolean(isDesign);
1984
+ const presentBtn = getEl("presentBtn");
1985
+ if (presentBtn) presentBtn.hidden = Boolean(isDesign);
1986
+ const exportBtn = getEl("exportBtn");
1987
+ if (exportBtn) {
1988
+ const wrap = exportBtn.closest(".menu-wrap");
1989
+ if (wrap) wrap.hidden = Boolean(isDesign);
1990
+ }
1991
+ const eyebrow = getEl("deckEyebrow");
1992
+ if (eyebrow) eyebrow.textContent = isDesign ? "Design Samples" : "Image Storyboard";
1993
+ }
1994
+
1995
+ /** design 画布中间区:样张墙预览(F-58,观感对齐 deck 的整页图)。 */
1996
+ function renderDesignCanvasView(active) {
1997
+ const project = designCanvasProject(active);
1998
+ const samples = project?.samples || [];
1999
+ setDeckChromeForDesign(true);
2000
+ const abbr = project?.abbr || "ABBR";
2001
+ getEl("pageTitle").textContent = project ? `${project.abbr} · ${project.name}` : (active.title || "风格画布");
2002
+ const grid = samples.length
2003
+ ? `<div class="sample-wall-grid">${samples.map((sample, index) => `
2004
+ <button type="button" class="sample-wall-item" data-sample-index="${index}" title="${esc(sample.name)}">
2005
+ <img src="${esc(sample.url)}" alt="${esc(sample.name)}" loading="lazy">
2006
+ <span><b>${esc(sample.name)}</b><small>${fmtBytes(sample.sizeBytes)}</small></span>
2007
+ </button>`).join("")}</div>`
2008
+ : `<div class="sample-wall-empty">
2009
+ <b>还没有样张</b>
2010
+ <p>这个 design 的画布还没有样张。可以让左侧 Agent 先给 2–3 个版面 brief 再一张一张出,也可以直接添加你自己的图片。</p>
2011
+ <div class="sample-wall-actions">
2012
+ <button class="btn solid" id="sampleWallChat">让 Agent 出样张</button>
2013
+ <button class="btn alt" id="sampleWallEmptyAdd">+ 添加样张</button>
2014
+ </div>
2015
+ </div>`;
2016
+ const stack = getEl("slideStack");
2017
+ stack.innerHTML = `<div class="sample-wall" id="sampleWall" tabindex="0">
2018
+ <div class="sample-wall-head">
2019
+ <div><b>样张墙</b><span class="small">${samples.length ? `${samples.length} 张 · 点样张放大,预览里还能继续加` : "还没有样张"}</span></div>
2020
+ <span class="spacer"></span>
2021
+ <button class="btn alt" id="sampleWallAdd">+ 添加样张</button>
2022
+ <input type="file" id="sampleWallFile" accept="image/png,image/jpeg,image/webp" multiple hidden>
2023
+ </div>
2024
+ ${grid}
2025
+ <p class="engine-note">样张落在画布 <code>designs/${esc(abbr)}/</code>;可以直接 ⌘V 粘贴到这块区域、拖文件进来,或到 Designs 卡的眼睛预览里继续添加。换样张不影响源 deck。</p>
2026
+ </div>`;
2027
+
2028
+ const config = STATE.data?.config || {};
2029
+ const activeModel = active.renderNodes?.active?.label || config.renderModel;
2030
+ const statusEl = getEl("renderStatus");
2031
+ if (statusEl) statusEl.textContent = `渲染:${config.renderMode || "fake"} · ${activeModel || "未配置节点"} · ${config.renderSize || ""} | design 画布:${samples.length} 张样张(没有 deck 页面)`;
2032
+ const wall = getEl("sampleWall");
2033
+ const target = () => designCanvasProject(STATE.data?.active);
2034
+ wall.querySelectorAll("[data-sample-index]").forEach((node) => node.addEventListener("click", () => {
2035
+ const current = target();
2036
+ if (!current) return;
2037
+ openPreview({
2038
+ title: `${current.abbr} · ${current.name} · 样张预览`,
2039
+ desc: current.description || "",
2040
+ items: current.samples || [],
2041
+ design: { designId: current.designId, name: `${current.abbr} · ${current.name}` },
2042
+ });
2043
+ showPreviewAt(Number(node.dataset.sampleIndex));
2044
+ }));
2045
+ const pickFile = () => getEl("sampleWallFile").click();
2046
+ getEl("sampleWallAdd").addEventListener("click", pickFile);
2047
+ const emptyAdd = getEl("sampleWallEmptyAdd");
2048
+ if (emptyAdd) emptyAdd.addEventListener("click", pickFile);
2049
+ getEl("sampleWallFile").addEventListener("change", async (event) => {
2050
+ const current = target();
2051
+ if (current) await uploadSamplesToDesign({ designId: current.designId }, event.target.files);
2052
+ event.target.value = "";
2053
+ });
2054
+ wall.addEventListener("paste", async (event) => {
2055
+ const files = clipboardImageFiles(event);
2056
+ const current = target();
2057
+ if (!files.length || !current) return;
2058
+ event.preventDefault();
2059
+ await uploadSamplesToDesign({ designId: current.designId }, files);
2060
+ });
2061
+ const chatBtn = getEl("sampleWallChat");
2062
+ if (chatBtn) chatBtn.addEventListener("click", async () => {
2063
+ const current = target();
2064
+ await ensurePromptConversation();
2065
+ await sendPrompt(`请为 design ${current ? current.abbr : ""} 出样张:先给我 2–3 个版面 brief 选一个,我确认后你按这个 brief 连续出(每张一版;别一次同时换 3 个版面)。`);
2066
+ });
2067
+ wall.focus({ preventScroll: true });
2068
+ }
2069
+
2070
+ /* ---------------- design 工作区:风格定义 / 修改日志(F-63) ---------------- */
2071
+
2072
+ const DESIGN_EVENT_META = {
2073
+ "design.created": ["创建风格", "建了 0 页空白画布 + 风格目录"],
2074
+ "design.seeded": ["导入预设风格", "从插件预设风格包导入(含样张)"],
2075
+ "design.sample": ["生成样张", "Agent 出一张样张"],
2076
+ "design.sample.add": ["添加样张", "手动贴图 / 上传 / 导入"],
2077
+ "design.deleted": ["删除风格", "登记与画布一起清掉"],
2078
+ "deck.created": ["创建画布", "0 页空白 deck,只用来存风格样张"],
2079
+ "session.bound": ["绑定会话", "这个 design 有了自己的对话"],
2080
+ };
2081
+
2082
+ function designEventRows(project) {
2083
+ const events = STATE.data?.events || [];
2084
+ return events
2085
+ .filter((event) => {
2086
+ const payload = event.payload || {};
2087
+ if (payload.designId === project.designId) return true;
2088
+ if (event.type === "session.bound" && payload.kind === "design" && payload.id === project.designId) return true;
2089
+ return event.deckId === project.canvasDeckId && String(event.type || "").startsWith("design.");
2090
+ })
2091
+ .map((event) => {
2092
+ const meta = DESIGN_EVENT_META[event.type] || [event.type, ""];
2093
+ const payload = event.payload || {};
2094
+ const detail = [
2095
+ payload.abbr ? `${payload.abbr}` : "",
2096
+ payload.revision ? `样张 ${payload.revision}` : "",
2097
+ Array.isArray(payload.samples) ? `+${payload.samples.length} 张` : "",
2098
+ payload.name && event.type === "design.created" ? payload.name : "",
2099
+ payload.sessionId && event.type === "session.bound" ? `会话 ${String(payload.sessionId).slice(0, 12)}…` : "",
2100
+ ].filter(Boolean).join(" · ");
2101
+ return { label: meta[0], detail: detail || meta[1], time: fmtTime(event.timestamp), status: event.status || "" };
2102
+ });
2103
+ }
2104
+
2105
+ /** design 的风格定义页(F-67):这份风格是什么 + 它被谁用;样张大图不在定义页重复。 */
2106
+ function designSessionsFor(project) {
2107
+ return chatSessionsForTarget({ kind: "design", id: project.designId });
2108
+ }
2109
+
2110
+ /** 在 design 定义页 / Design Library 里切到某个已绑定会话。 */
2111
+ async function switchDesignSession(sessionId) {
2112
+ const result = await requestConversation({ action: "open", sessionId });
2113
+ if (!result.ok) return false;
2114
+ STATE.chatSessionId = sessionId;
2115
+ await refreshSessions();
2116
+ renderChatHeader();
2117
+ return true;
2118
+ }
2119
+
2120
+ function renderDesignSessionRows(sessions, { limit = 0, attr = "def-session" } = {}) {
2121
+ const list = limit > 0 ? sessions.slice(0, limit) : sessions;
2122
+ if (!list.length) return `<p class="engine-note">还没有会话;点「+ 新会话」或回 Designs 页点这张卡开始。</p>`;
2123
+ return `<div class="design-session-list">${list.map((session) => `
2124
+ <button type="button" class="design-session-row${session.current ? " current" : ""}" data-${attr}="${esc(session.sessionId)}">
2125
+ <b>${esc(session.title)}</b>
2126
+ <small>${session.running ? "进行中" : esc(fmtDayTime(session.updatedAt || Date.now()))}${session.current ? " · 当前" : ""}</small>
2127
+ </button>`).join("")}</div>`;
2128
+ }
2129
+
2130
+ function renderDesignDefinition() {
2131
+ const active = STATE.data?.active || {};
2132
+ const project = designCanvasProject(active);
2133
+ const view = getEl("observeView");
2134
+ if (!project) {
2135
+ view.innerHTML = `<div class="observe-body"><div class="doc-empty"><b>没有找到这个画布对应的 design</b><p>可能是画布被删了;回 Designs 页重新添加风格。</p></div></div>`;
2136
+ return;
2137
+ }
2138
+ const style = active.designStyle || {};
2139
+ const samples = project.samples || [];
2140
+ const sessions = designSessionsFor(project);
2141
+ const decks = project.decks || [];
2142
+ const meta = [
2143
+ ["缩写", project.abbr],
2144
+ ["样张", `${samples.length} 张`],
2145
+ ["关联会话", `${sessions.length} 个`],
2146
+ ["使用它的 deck", decks.length ? `${decks.length} 个` : "还没有"],
2147
+ ];
2148
+ const deckRows = decks.length
2149
+ ? `<div class="design-deck-chips">${decks.map((deck) => `<button type="button" class="bind-chip design-deck-chip" data-def-deck="${esc(deck.deckId)}" title="打开这个 deck">${esc(deck.label || deck.deckId)}</button>`).join("")}</div>`
2150
+ : `<p class="engine-note">还没有 deck 用这个风格;在 deck 的 Design 文档页可以关联。</p>`;
2151
+ view.innerHTML = `<div class="observe-body design-doc">
2152
+ <div class="doc-head">
2153
+ <div><div class="eyebrow">Design Definition</div><h2>${esc(project.abbr)} · ${esc(project.name)}</h2></div>
2154
+ <span class="pill">${samples.length} 张样张</span>
2155
+ <span class="small doc-file">${esc(style.dirLabel || `designs/${project.abbr}`)}</span>
2156
+ </div>
2157
+ <div class="design-meta">${meta.map(([label, value]) => `<div class="design-meta-row"><span>${esc(label)}</span><b>${esc(value)}</b></div>`).join("")}</div>
2158
+ <section class="design-section"><h3>画面语言</h3><p>${esc(project.description || "(未写风格描述)")}</p></section>
2159
+ <section class="design-section">
2160
+ <div class="design-section-head"><h3>风格提示词(出样张自动带上)</h3><span class="spacer"></span>
2161
+ <button class="btn alt" id="defCopyStyleBrief" title="复制这段提示词">复制</button>
2162
+ </div>
2163
+ <pre class="style-brief-pre" id="defStyleBrief">${esc(project.styleBrief || "(还没有 styleBrief;出样张会用上面的画面语言兜底)")}</pre>
2164
+ <p class="engine-note">补样张时只补「这张要试的画面」,风格提示词复用这份;要改风格就跟左侧 Chat 说要改,Agent 用 deck_style_update 保存。</p>
2165
+ </section>
2166
+ ${project.constraints ? `<section class="design-section"><h3>不要什么</h3><p>${esc(project.constraints)}</p></section>` : ""}
2167
+ ${project.reference ? `<section class="design-section"><h3>参考</h3><p>${esc(project.reference)}</p></section>` : ""}
2168
+ <section class="design-section">
2169
+ <div class="design-section-head"><h3>关联会话(${sessions.length})</h3><span class="spacer"></span>
2170
+ <button class="btn alt" id="defNewSession">+ 新会话</button>
2171
+ </div>
2172
+ ${renderDesignSessionRows(sessions)}
2173
+ <p class="engine-note">一个 design 可以绑多个会话;这里列出全部,点一条切过去。</p>
2174
+ </section>
2175
+ <section class="design-section">
2176
+ <h3>使用这个风格的 deck(${decks.length})</h3>
2177
+ ${deckRows}
2178
+ </section>
2179
+ <section class="design-section">
2180
+ <div class="design-section-head"><h3>样张(${samples.length})</h3><span class="spacer"></span>
2181
+ <button class="btn alt" id="defAddSample">+ 添加样张</button>
2182
+ <input type="file" id="defSampleFile" accept="image/png,image/jpeg,image/webp" multiple hidden>
2183
+ </div>
2184
+ <p class="engine-note">样张大图统一在「样张」标签 / 右栏 Design Library 看,这里不重复铺。</p>
2185
+ <div class="design-doc-actions"><button class="btn" id="defOpenWall">打开样张墙(${samples.length} 张)</button></div>
2186
+ </section>
2187
+ ${style.markdown ? `<details class="doc-raw"><summary>查看 style.md 原文</summary><div class="md"><pre>${esc(style.markdown)}</pre></div></details>` : ""}
2188
+ <p class="engine-note design-path-note">画布:${esc(project.canvasDeckId)} · 资料:<code>${esc(style.dirLabel || `designs/${project.abbr}`)}/</code> · 意图 / 脚本 / 反馈 / 调研 / 支撑材料 / 治理是 deck 的文档,不适用于风格画布。</p>
2189
+ </div>`;
2190
+
2191
+ const addBtn = getEl("defAddSample");
2192
+ if (addBtn) addBtn.addEventListener("click", () => getEl("defSampleFile").click());
2193
+ const fileInput = getEl("defSampleFile");
2194
+ if (fileInput) fileInput.addEventListener("change", async (event) => {
2195
+ await uploadSamplesToDesign(project, event.target.files);
2196
+ event.target.value = "";
2197
+ renderDesignDefinition();
2198
+ });
2199
+ const wallBtn = getEl("defOpenWall");
2200
+ if (wallBtn) wallBtn.addEventListener("click", () => {
2201
+ STATE.tab = "deck";
2202
+ renderMain();
2203
+ });
2204
+ const copyBriefBtn = getEl("defCopyStyleBrief");
2205
+ if (copyBriefBtn) copyBriefBtn.addEventListener("click", async () => {
2206
+ const text = getEl("defStyleBrief")?.textContent ?? "";
2207
+ try {
2208
+ await navigator.clipboard.writeText(text);
2209
+ copyBriefBtn.textContent = "已复制 ✓";
2210
+ setTimeout(() => { copyBriefBtn.textContent = "复制"; }, 1500);
2211
+ } catch {
2212
+ copyBriefBtn.textContent = "复制失败";
2213
+ }
2214
+ });
2215
+ const newSessionBtn = getEl("defNewSession");
2216
+ if (newSessionBtn) newSessionBtn.addEventListener("click", async () => {
2217
+ await newChatForTarget();
2218
+ renderDesignDefinition();
2219
+ });
2220
+ view.querySelectorAll("[data-def-session]").forEach((node) => node.addEventListener("click", async () => {
2221
+ if (await switchDesignSession(node.dataset.defSession)) renderDesignDefinition();
2222
+ }));
2223
+ view.querySelectorAll("[data-def-deck]").forEach((node) => node.addEventListener("click", () => {
2224
+ showDeck(node.dataset.defDeck);
2225
+ ensureDeckConversation(node.dataset.defDeck);
2226
+ }));
2227
+ }
2228
+
2229
+ /** design 的修改日志:只列 design.* / 绑定事件,不混 deck 文档变更。 */
2230
+ function renderDesignLog() {
2231
+ const project = designCanvasProject(STATE.data?.active);
2232
+ const view = getEl("observeView");
2233
+ const rows = project ? designEventRows(project) : [];
2234
+ const html = rows.length
2235
+ ? rows.map((row) => `<div class="history-row design-log-row">
2236
+ <b>${esc(row.label)}</b>
2237
+ <span class="design-log-detail">${esc(row.detail)}</span>
2238
+ <small>${esc(row.time)}${row.status && row.status !== "succeeded" ? " · " + esc(row.status) : ""}</small>
2239
+ </div>`).join("")
2240
+ : `<div class="history-row"><b>还没有记录</b><small>出样张 / 加样张 / 绑定会话后会出现在这里</small></div>`;
2241
+ view.innerHTML = `<div class="observe-body design-doc">
2242
+ <div class="doc-head">
2243
+ <div><div class="eyebrow">Design Timeline</div><h2>修改日志</h2></div>
2244
+ <span class="small doc-file">只记 design 事件</span>
2245
+ </div>
2246
+ <div class="design-log">${html}</div>
2247
+ <p class="engine-note">这里只记这个 design 的创建、样张、绑定;不显示 deck 的 revision / 文档变更。</p>
2248
+ </div>`;
2249
+ }
2250
+
2251
+
2252
+ function renderDeck() {
2253
+ const data = STATE.data || {};
2254
+ const active = data.active || {};
2255
+ if (active.kind === "design-canvas") {
2256
+ renderDesignCanvasView(active);
2257
+ return;
2258
+ }
2259
+ setDeckChromeForDesign(false);
2260
+ const slides = active.slides || [];
2261
+ const scroller = getEl("deckView");
2262
+ const keepScroll = scroller.scrollTop;
2263
+ const withImage = slides.filter((slide) => slide.render);
2264
+ const fromSource = withImage.filter((slide) => slide.render.fromSource).length;
2265
+ getEl("slideStack").innerHTML = slides
2266
+ .map((slide) => {
2267
+ const media = slide.render
2268
+ ? `<img src="${esc(slide.render.url)}" alt="${esc(slide.title)}" loading="lazy">`
2269
+ : `<div class="slide-empty"><b>尚未出图</b><span>image deck 的页面本体是渲染出的整页图片。visualBrief 为空时,Agent 会先提案补齐再出图。</span><button class="btn" data-render="${esc(slide.id)}">让 Agent 出图</button></div>`;
2270
+ return `<div class="canvas deck-slide" data-slide="${esc(slide.id)}">
2271
+ <div class="slide-media">${media}</div>
2272
+ <div class="slide-meta">
2273
+ <span class="pill">${esc(slide.type)} · P${String(slide.order).padStart(2, "0")}</span>
2274
+ <h3 title="${esc(slide.title || "(未命名)")}">${esc(slide.title || "(未命名)")}</h3>
2275
+ <span class="small" title="${esc(slide.question || "")}">${esc(slide.question || "")}</span>
2276
+ ${slide.render ? `<span class="slide-rev">${slide.render.fromSource ? "源出图" : esc(slide.render.revision)} · ${fmtBytes(slide.render.sizeBytes)}</span>` : `<span class="slide-rev" style="border-color:var(--line);color:#8a9599">未出图</span>`}
2277
+ <button class="mini-btn" data-edit="${esc(slide.id)}">微调 script</button>
2278
+ ${slide.render ? `<button class="mini-btn" data-render="${esc(slide.id)}">${slide.render.fromSource ? "重出此页" : "重新出图"}</button>` : ""}
2279
+ </div>
2280
+ </div>`;
2281
+ })
2282
+ .join("") + `<div class="slide-stack-end">End of deck</div>`;
2283
+ getEl("slideStack").querySelectorAll("[data-slide]").forEach((node) => node.addEventListener("click", () => selectSlide(node.dataset.slide)));
2284
+ getEl("slideStack").querySelectorAll("[data-render]").forEach((node) => node.addEventListener("click", (event) => {
2285
+ event.stopPropagation();
2286
+ renderSlidePrompt(node.dataset.render);
2287
+ }));
2288
+ getEl("slideStack").querySelectorAll("[data-edit]").forEach((node) => node.addEventListener("click", (event) => {
2289
+ event.stopPropagation();
2290
+ openPageEditor(node.dataset.edit);
2291
+ }));
2292
+ scroller.scrollTop = keepScroll;
2293
+ const config = data.config || {};
2294
+ const activeModel = active.renderNodes?.active?.label || config.renderModel;
2295
+ getEl("renderStatus").textContent = `渲染:${config.renderMode} · ${activeModel} · ${config.renderSize}|有图 ${withImage.length}/${slides.length} 页(源出图 ${fromSource},其余待出 ${slides.length - withImage.length})· 数据根 ${config.dataRoot}`;
2296
+ }
2297
+
2298
+ function selectSlide(id) {
2299
+ const slides = STATE.data?.active?.slides || [];
2300
+ const slide = slides.find((item) => item.id === id);
2301
+ if (!slide) return;
2302
+ STATE.selected = id;
2303
+ // F-131:点页面 = 要看这一页的分页内容,右栏直接给 Inspector(不再停在 Library)
2304
+ if (!designCanvasProject(STATE.data?.active) && (!STATE.rightTab || STATE.rightTab === "inspector")) activateRightTab("inspector");
2305
+ getEl("insTitle").value = slide.title || "";
2306
+ getEl("insSubtitle").value = slide.question || "";
2307
+ getEl("insBody").value = "(读取 script 正文…)";
2308
+ getEl("insPrompt").value = slide.visualBrief || "";
2309
+ getEl("insPage").textContent = `${slide.id} · 第 ${slide.order} 页 · ${slide.render ? `已出图 ${slide.render.revision}(${slide.render.fromSource ? "源出图" : "工作副本"})` : "未出图"}`;
2310
+ getEl("inspectorStatus").innerHTML = `模型:${esc(STATE.data.config.renderModel)}<br>保存 = 写回源 script.md / prompts + 自动 git,随后可「重新出图」`;
2311
+ document.querySelectorAll("[data-slide]").forEach((node) => node.classList.toggle("selected-canvas", node.dataset.slide === id));
2312
+ fillInspectorBody(slide.id);
2313
+ }
2314
+
2315
+ /** Inspector 正文来自源 script 的这页分节(不是 manifest 里的一句摘录)。 */
2316
+ async function fillInspectorBody(pageId) {
2317
+ const deckId = STATE.data?.active?.deckId;
2318
+ if (!deckId) return;
2319
+ try {
2320
+ const res = await fetch(`/api/deck-studio.page-edit?` + new URLSearchParams({ deckId, pageId }));
2321
+ const view = await res.json();
2322
+ // 期间用户已切页 / 已切 deck:两个都要校验,否则上一本 deck 的慢响应会覆盖当前 Inspector
2323
+ if (STATE.selected !== pageId || STATE.data?.active?.deckId !== deckId) return;
2324
+ if (!view.ok) throw new Error(view.error?.message || "读取失败");
2325
+ if (view.unsupported) {
2326
+ getEl("insBody").value = "";
2327
+ getEl("inspectorStatus").textContent = view.reason;
2328
+ return;
2329
+ }
2330
+ // 源 script / prompts 没写时回落到 manifest(visualBrief 是 manifest-only 字段):
2331
+ // 否则 Inspector 提示词是空的,但出图其实用的是 visualBrief。
2332
+ const slide = (STATE.data?.active?.slides || []).find((item) => item.id === pageId) || {};
2333
+ getEl("insTitle").value = view.title || slide.title || "";
2334
+ getEl("insSubtitle").value = view.subtitle || slide.question || "";
2335
+ getEl("insBody").value = view.body || slide.body || "";
2336
+ getEl("insPrompt").value = view.prompt || slide.visualBrief || "";
2337
+ } catch (error) {
2338
+ if (STATE.selected === pageId) getEl("inspectorStatus").textContent = "读取正文失败:" + error.message;
2339
+ }
2340
+ }
2341
+
2342
+ /**
2343
+ * design 的右栏资料库(F-63):只列风格定义 + 样张 + 画布 + 会话,
2344
+ * 不再列 deck 的 source script / prompts / outputs / 7 份文档。
2345
+ */
2346
+ function renderDesignLibrary(project, active) {
2347
+ const style = active.designStyle || {};
2348
+ const samples = project.samples || [];
2349
+ const sessions = designSessionsFor(project);
2350
+ getEl("libEyebrow").textContent = "Design Library";
2351
+ getEl("libTitle").textContent = "当前 Design 资料库";
2352
+ getEl("libCount").textContent = String(samples.length);
2353
+ const files = [
2354
+ { name: "style.md", exists: Boolean(style.markdown), role: "风格定义(Agent 出样张的上下文)" },
2355
+ { name: "style.json", exists: Boolean(style.style), role: "结构化风格字段" },
2356
+ ];
2357
+ getEl("libraryList").innerHTML = `
2358
+ <div class="lib-group">风格文件</div>
2359
+ ${files.map((file) => `<div class="source-row"><div class="source-title">${esc(file.name)} <span class="doc-status ${file.exists ? "ok" : "missing"}">${file.exists ? "已就绪" : "缺失"}</span></div><div class="source-meta">${esc(file.role)}</div></div>`).join("")}
2360
+ <div class="lib-group">样张(${samples.length})</div>
2361
+ ${samples.length
2362
+ ? samples.map((sample, index) => `<button type="button" class="source-row lib-row" data-lib-sample="${index}"><div class="source-title">${esc(sample.name)}</div><div class="source-meta">${fmtBytes(sample.sizeBytes)} · 点击预览</div></button>`).join("")
2363
+ : `<div class="source-row"><div class="source-title">还没有样张</div><div class="source-meta">在左侧会话说一句「出样张」,或点「+ 添加样张」</div></div>`}
2364
+ <div class="lib-group">画布</div>
2365
+ <div class="source-row"><div class="source-title">${esc(project.canvasDeckId)}</div><div class="source-meta">0 页画布 · 只存 <code>${esc(style.dirLabel || `designs/${project.abbr}`)}/</code></div></div>
2366
+ <div class="lib-group">会话(${sessions.length})</div>
2367
+ ${renderDesignSessionRows(sessions, { limit: 5, attr: "lib-session" })}
2368
+ ${sessions.length > 5 ? `<p class="engine-note">还有 ${sessions.length - 5} 个会话,去「风格定义」页看全部。</p>` : ""}
2369
+ <p class="engine-note">Design Library = 风格定义 + 样张 + 会话;deck 的 9 份文档 / 源目录 / prompts / 导出只在 deck 打开时出现。</p>`;
2370
+ getEl("libraryList").querySelectorAll("[data-lib-sample]").forEach((node) => node.addEventListener("click", () => {
2371
+ openPreview({
2372
+ title: `${project.abbr} · ${project.name} · 样张预览`,
2373
+ desc: project.description || "",
2374
+ items: samples,
2375
+ design: { designId: project.designId, name: `${project.abbr} · ${project.name}` },
2376
+ });
2377
+ showPreviewAt(Number(node.dataset.libSample));
2378
+ }));
2379
+ getEl("libraryList").querySelectorAll("[data-lib-session]").forEach((node) => node.addEventListener("click", async () => {
2380
+ if (await switchDesignSession(node.dataset.libSession)) renderChrome();
2381
+ }));
2382
+ }
2383
+
2384
+ function renderLibrary(data) {
2385
+ const active = data.active || {};
2386
+ const project = designCanvasProject(active);
2387
+ if (project) {
2388
+ renderDesignLibrary(project, active);
2389
+ return;
2390
+ }
2391
+ getEl("libEyebrow").textContent = "Deck Library";
2392
+ getEl("libTitle").textContent = "当前 Deck 资料库";
2393
+ getEl("tabInspector").hidden = false;
2394
+ const sources = (active.sources || []).length ? active.sources : [{ name: "(未配置受控源)", role: "在 profile 里设置 decks[].sourceRoot", exists: false }];
2395
+ getEl("libCount").textContent = String(sources.length);
2396
+ // F-136:只有 script.md 是必需;其余标「可选」,缺失用中性色,避免新 deck 看起来像坏了
2397
+ const intro = `<p class="engine-note">这是源目录的文件清单,不是必填项——<b>只有 script.md 必需</b>,
2398
+ 其余用到再补;「缺失」不代表出图/编辑会坏。</p>`;
2399
+ const rows = sources.map((item) => {
2400
+ const required = item.required === true;
2401
+ const state = item.exists
2402
+ ? (required ? "已就绪" : "可选 · 已有")
2403
+ : (required ? "缺失(必需)" : "可选 · 缺失");
2404
+ const chipClass = item.exists ? (required ? "ok" : "optional") : (required ? "missing" : "optional");
2405
+ return `<div class="source-row">
2406
+ <div class="source-title">${esc(item.name)}<span class="doc-status ${chipClass}">${esc(state)}</span></div>
2407
+ <div class="source-meta">${esc(item.role)}</div>
2408
+ ${item.hint ? `<div class="source-hint">${esc(item.hint)}</div>` : ""}
2409
+ </div>`;
2410
+ }).join("");
2411
+ getEl("libraryList").innerHTML = intro + rows;
2412
+ }
2413
+
2414
+ function renderTimeline(data) {
2415
+ const active = data.active || {};
2416
+ const project = designCanvasProject(active);
2417
+ const rows = [];
2418
+ if (project) {
2419
+ getEl("histEyebrow").textContent = "Design Timeline";
2420
+ getEl("histTitle").textContent = "样张与事件";
2421
+ designEventRows(project).slice(0, 10).forEach((row) => rows.push(`<div class="history-row"><b>${esc(row.label)}</b><small>${esc(row.time)}${row.detail ? " · " + esc(row.detail) : ""}</small></div>`));
2422
+ getEl("timelineList").innerHTML = rows.join("") || `<div class="history-row"><b>暂无记录</b><small>design 事件会实时出现在这里</small></div>`;
2423
+ return;
2424
+ }
2425
+ getEl("histEyebrow").textContent = "Deck Timeline";
2426
+ getEl("histTitle").textContent = "历史与观测";
2427
+ (data.proposals || []).forEach((proposal) => rows.push(`<div class="history-row"><b>待你确认 · ${esc(proposal.pageId)} ${esc(proposal.field)}</b><small>${esc(proposal.proposalId)} · ${esc(proposal.reason || "提案")}</small></div>`));
2428
+ (data.tasks || []).slice(0, 6).forEach((task) => rows.push(`<div class="history-row"><b>${esc(task.status)} · ${esc(task.kind)}</b><small>${esc(task.taskId)} · ${esc((task.deckId || "") + " " + ((task.slideIds || []).join(",")))}</small></div>`));
2429
+ (data.events || []).slice(0, 8).forEach((event) => rows.push(`<div class="history-row"><b>${esc(event.type)} · ${esc(event.status || "")}</b><small>${esc(fmtTime(event.timestamp))} · ${esc(event.eventId || "")}</small></div>`));
2430
+ getEl("timelineList").innerHTML = rows.join("") || `<div class="history-row"><b>暂无记录</b><small>task / event 会实时出现在这里</small></div>`;
2431
+ }
2432
+
2433
+ function renderSettings(data) {
2434
+ // 用户正在填节点表单:整块 innerHTML 重建会丢焦点、把刚输入的字覆盖成旧值,直接跳过。
2435
+ // 只看是否还停在表单输入区(.node-editor);按钮焦点不阻止刷新,否则点完「保存节点」列表会一直停在旧内容。
2436
+ const focused = document.activeElement;
2437
+ if (SETTINGS_FORM_DIRTY || (focused && focused.closest && focused.closest(".node-editor"))) return;
2438
+ const config = data.config || {};
2439
+ const renderNodes = data.active?.renderNodes || { nodes: [], active: null };
2440
+ const settings = data.renderSettings || {};
2441
+ // 设置页里正在编辑的节点列表(以服务端 payload 为种子;保存时整表提交)
2442
+ if (!Array.isArray(SETTINGS_NODES) || !SETTINGS_NODES.length) {
2443
+ SETTINGS_NODES = (renderNodes.nodes || []).map((node) => ({
2444
+ id: node.id, name: node.name, endpoint: node.endpoint, models: [...(node.models || [])],
2445
+ apiKeyEnv: node.apiKeyEnv || "", apiKeyFile: node.apiKeyFile || "",
2446
+ }));
2447
+ }
2448
+ const activeNodeId = renderNodes.active?.nodeId ?? "";
2449
+ const activeModel = renderNodes.active?.model ?? "";
2450
+ const nodeCards = (renderNodes.nodes || []).map((node) => {
2451
+ const isActive = node.id === activeNodeId;
2452
+ const modelPills = node.models.map((model) => {
2453
+ const current = isActive && model === activeModel;
2454
+ return `<button type="button" class="pill node-model${current ? " active" : ""}" data-node-id="${esc(node.id)}" data-model="${esc(model)}" title="${current ? "当前正在用" : `切换到 ${esc(node.name)} / ${esc(model)}(下一次出图生效)`}">${esc(model)}${current ? " · 当前" : ""}</button>`;
2455
+ }).join("");
2456
+ const keyLabel = node.keyConfigured
2457
+ ? node.keySource === "saved" ? "key 已保存" : node.keySource === "env" ? "key 来自环境变量" : "key 来自文件"
2458
+ : "key 未配置";
2459
+ return `
2460
+ <div class="node-card${isActive ? " active" : ""}" data-node-card="${esc(node.id)}">
2461
+ <div class="node-head"><b>${esc(node.name)}</b><code>${esc(node.id)}</code>
2462
+ ${isActive ? `<span class="node-key ok">正在使用</span>` : ""}
2463
+ <span class="node-key ${node.keyConfigured ? "ok" : "warn"}">${keyLabel}</span></div>
2464
+ <div class="node-meta">${esc(node.endpoint || "(未配置接口地址)")}</div>
2465
+ <div class="node-models">${modelPills}</div>
2466
+ <div class="node-actions">
2467
+ ${isActive ? "" : `<button type="button" class="btn ghost node-switch" data-node-id="${esc(node.id)}" data-model="${esc(node.models[0] || "")}" title="切换到这个节点出图(下一次出图生效)">用这个节点</button>`}
2468
+ <button type="button" class="btn ghost node-edit" data-edit-node="${esc(node.id)}" title="编辑这个节点">编辑</button>
2469
+ <button type="button" class="btn ghost node-delete" data-delete-node="${esc(node.id)}" title="删除这个节点" ${(renderNodes.nodes || []).length <= 1 ? "disabled" : ""}>删除</button>
2470
+ </div>
2471
+ </div>`;
2472
+ }).join("") || `<p class="engine-note">还没有节点——在上面填 名称 / 接口地址 / API Key / 模型名,点「保存节点」。</p>`;
2473
+ getEl("settingsList").innerHTML = `
2474
+ <div class="field"><label>Agent preset</label><input value="${esc(config.preset || "deck-studio")}" readonly></div>
2475
+ <div class="field"><label>当前生效(节点 / 模型)</label><input id="rsActive" value="${esc(renderNodes.active?.label || settings.active?.label || "—")}" readonly></div>
2476
+ <div class="status">渲染模式:${esc(config.renderMode || settings.mode || "fake")} · key 只显示「已保存 / 未配置」,值不回显</div>
2477
+ ${data.render ? `<div class="node-card">
2478
+ <div class="node-head"><b>生图配置</b><span class="node-key ${data.render.ready ? "ok" : "warn"}">${data.render.ready ? "可出真图" : "未就绪"}</span></div>
2479
+ <div class="node-meta">${esc(data.render.hint || `当前 deck 生效:${renderNodes.active?.label || "—"} · ${data.render.size || ""}`)}</div>
2480
+ </div>` : ""}
2481
+ <h3 class="settings-title">生图设置(写本机数据目录,不进 git)</h3>
2482
+ <div class="settings-form">
2483
+ <div class="nd-row">
2484
+ <label class="field"><span>模式</span>
2485
+ <select id="rsMode">
2486
+ <option value="fake"${(settings.mode || config.renderMode) === "fake" ? " selected" : ""}>fake —— 占位图,不联网、不花钱(先跑通流程)</option>
2487
+ <option value="real"${(settings.mode || config.renderMode) === "real" ? " selected" : ""}>real —— 真出图(需要节点 + key)</option>
2488
+ </select></label>
2489
+ <label class="field"><span>出图尺寸(宽x高)</span>
2490
+ <input id="rsSize" placeholder="2560x1440" value="${esc(settings.size || config.renderSize || "")}"></label>
2491
+ </div>
2492
+ </div>
2493
+ <div class="settings-title-row">
2494
+ <h3 class="settings-title">出图节点(${(renderNodes.nodes || []).length})</h3>
2495
+ <button class="btn ghost" id="renderSettingsNew" type="button">+ 新增节点</button>
2496
+ </div>
2497
+ <p class="engine-note">点节点下的模型、或「用这个节点」,立即切换当前 deck 的出图线路(下一次出图生效,不重启)。<br>
2498
+ 平时只显示列表;点「编辑」或「+ 新增节点」才会展开下面的表单。</p>
2499
+ <div class="node-list">${nodeCards}</div>
2500
+ <div class="node-editor" id="nodeEditor" hidden>
2501
+ <div class="editor-title"><b id="rsEditorTitle">编辑节点</b><span class="small">照着中转给的 4 个信息填就行</span></div>
2502
+ <label class="field"><span>节点名称</span>
2503
+ <input id="rsName" placeholder="例:主线路 / 备用线路" value=""></label>
2504
+ <label class="field"><span>接口地址(OpenAI 兼容,一般以 /v1 结尾)</span>
2505
+ <input id="rsEndpoint" placeholder="https://your-relay.example.com/v1" value=""></label>
2506
+ <label class="field"><span>API Key(粘贴到本机,不进 git)</span>
2507
+ <input id="rsKey" type="password" autocomplete="off" placeholder="粘贴 key;已保存过的留空不改"></label>
2508
+ <label class="field"><span>模型名(多个用逗号分隔)</span>
2509
+ <input id="rsModel" placeholder="gpt-image-2.5-sunburst, gpt-image-2.5-flare" value=""></label>
2510
+ <details class="node-advanced">
2511
+ <summary>高级:用环境变量 / key 文件(可选,小白不用管)</summary>
2512
+ <div class="nd-row">
2513
+ <label class="field"><span>key 环境变量名</span>
2514
+ <input id="rsKeyEnv" placeholder="例:DSH_DECK_IMAGE_API_KEY" value=""></label>
2515
+ <label class="field"><span>或 key 文件路径(KEY=VALUE 每行一条)</span>
2516
+ <input id="rsKeyFile" placeholder="例:~/.config/relays.secret.env" value=""></label>
2517
+ </div>
2518
+ </details>
2519
+ <div class="settings-actions">
2520
+ <button class="btn solid" id="renderSettingsSave" type="button">保存节点</button>
2521
+ <button class="btn ghost" id="renderSettingsReset" type="button">取消编辑</button>
2522
+ <span class="small" id="rsState">${settings.found ? `已保存过:${esc(settings.updatedAt || "")}` : "还没有保存过设置(当前用的是 profile / 环境变量)"}</span>
2523
+ </div>
2524
+ <p class="engine-note">key 只写进本机数据目录 <code>.deck/render-settings.json</code>(0600,已 gitignore),界面只显示「已保存」;
2525
+ 仓库和 profile 里都不会出现你的 key 或节点地址。</p>
2526
+ </div>
2527
+ <h3 class="settings-title">其它</h3>
2528
+ <div class="field"><label>Data root(.deck)</label><input value="${esc(config.dataRoot)}" readonly></div>`;
2529
+ getEl("rsActive")?.setAttribute("title", "当前 deck 生效的节点 / 模型(切换后下一次出图生效)");
2530
+ fillSettingsNodeForm();
2531
+ bindRenderSettingsForm();
2532
+ bindSettingsNodeSwitch();
2533
+ bindSettingsNodeEditor();
2534
+ }
2535
+
2536
+ /* ---------------- 设置页:节点新增 / 编辑 / 删除(F-146) ---------------- */
2537
+ function settingsNodeById(nodeId) {
2538
+ return (SETTINGS_NODES || []).find((node) => node.id === nodeId) ?? null;
2539
+ }
2540
+
2541
+ function fillSettingsNodeForm() {
2542
+ const renderNodes = STATE.data?.active?.renderNodes || { nodes: [], active: null };
2543
+ const editingId = SETTINGS_EDITING && SETTINGS_EDITING !== "new" ? SETTINGS_EDITING : "";
2544
+ const node = editingId ? settingsNodeById(editingId) : settingsNodeById(renderNodes.active?.nodeId) ?? SETTINGS_NODES[0] ?? null;
2545
+ const isNew = SETTINGS_EDITING === "new";
2546
+ const nameInput = getEl("rsName");
2547
+ if (!nameInput) return;
2548
+ const editor = getEl("nodeEditor");
2549
+ const editing = Boolean(SETTINGS_EDITING);
2550
+ if (editor) editor.hidden = !editing;
2551
+ if (!editing) return;
2552
+ editor?.scrollIntoView({ block: "nearest", behavior: "smooth" });
2553
+ nameInput.value = isNew ? "" : node?.name || "";
2554
+ getEl("rsEndpoint").value = isNew ? "" : node?.endpoint || "";
2555
+ getEl("rsModel").value = isNew ? "" : (node?.models || []).join(", ");
2556
+ getEl("rsKey").value = "";
2557
+ getEl("rsKey").placeholder = node?.keyConfigured && !isNew ? "已保存(留空不改)" : "粘贴 key";
2558
+ getEl("rsKeyEnv").value = isNew ? "" : node?.apiKeyEnv || "";
2559
+ getEl("rsKeyFile").value = isNew ? "" : node?.apiKeyFile || "";
2560
+ const title = getEl("rsEditorTitle");
2561
+ if (title) title.textContent = isNew ? "新增节点" : node ? `编辑节点:${node.name}` : "新增节点";
2562
+ }
2563
+
2564
+ function nodeIdFromName(name) {
2565
+ const base = String(name || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32);
2566
+ const taken = new Set((SETTINGS_NODES || []).map((node) => node.id));
2567
+ let id = base || `node-${Date.now().toString(36).slice(-5)}`;
2568
+ let suffix = 2;
2569
+ while (taken.has(id)) id = `${base || "node"}-${suffix++}`;
2570
+ return id;
2571
+ }
2572
+
2573
+ async function saveSettingsNodes(nodes, stateText, options = {}) {
2574
+ const mode = getEl("rsMode").value;
2575
+ const size = getEl("rsSize").value.trim();
2576
+ const state = getEl("rsState");
2577
+ if (state) state.textContent = stateText || "正在保存 …";
2578
+ const res = await fetch("/api/deck-studio.render-settings", {
2579
+ method: "POST",
2580
+ headers: { "content-type": "application/json" },
2581
+ body: JSON.stringify({ mode, size, nodes }),
2582
+ });
2583
+ const result = await res.json();
2584
+ if (!res.ok || !result.ok) throw new Error(result.error?.message || "保存失败");
2585
+ SETTINGS_NODES = (result.nodes || []).map((node) => ({
2586
+ id: node.id, name: node.name, endpoint: node.endpoint, models: [...(node.models || [])],
2587
+ apiKeyEnv: node.apiKeyEnv || "", apiKeyFile: node.apiKeyFile || "",
2588
+ }));
2589
+ SETTINGS_EDITING = null;
2590
+ SETTINGS_FORM_DIRTY = false;
2591
+ // 保存按钮本身在 .node-editor 里,会带着焦点;先移开,保证紧接着的刷新真的重建节点列表
2592
+ if (document.activeElement?.closest?.(".node-editor")) document.activeElement.blur();
2593
+ // 新增节点后直接切到它:用户刚配这条线路,意图就是要用它;不切的话「当前生效」还是旧节点,看起来像没保存。
2594
+ const deckId = STATE.data?.active?.deckId;
2595
+ let switched = false;
2596
+ if (options.activateNodeId && deckId) {
2597
+ try {
2598
+ const pick = await fetch("/api/deck-studio.render-model", {
2599
+ method: "POST",
2600
+ headers: { "content-type": "application/json" },
2601
+ body: JSON.stringify({ deckId, nodeId: options.activateNodeId, model: options.activateModel || "" }),
2602
+ });
2603
+ const picked = await pick.json();
2604
+ if (pick.ok && picked.ok) switched = true;
2605
+ } catch {
2606
+ /* 切换失败不掩盖「保存成功」,下面照样刷新,flash 里补一句 */
2607
+ }
2608
+ }
2609
+ flash(switched
2610
+ ? `已保存并切换到新节点:${options.activateLabel || options.activateNodeId}(下一次出图生效)`
2611
+ : `出图设置已保存(${result.nodes?.length ?? 0} 个节点)`);
2612
+ await loadWorkbench(deckId);
2613
+ }
2614
+
2615
+ function bindRenderSettingsForm() {
2616
+ const button = getEl("renderSettingsSave");
2617
+ if (!button || button.dataset.bound === "1") return;
2618
+ button.dataset.bound = "1";
2619
+ button.addEventListener("click", async () => {
2620
+ const name = getEl("rsName").value.trim();
2621
+ const endpoint = getEl("rsEndpoint").value.trim();
2622
+ const models = getEl("rsModel").value.split(/[,,\n]/).map((model) => model.trim()).filter(Boolean);
2623
+ const apiKey = getEl("rsKey").value.trim();
2624
+ const keyEnv = getEl("rsKeyEnv").value.trim();
2625
+ const keyFile = getEl("rsKeyFile").value.trim();
2626
+ if (!name) { flash("先给节点起个名字", "error"); return; }
2627
+ if (!endpoint) { flash("接口地址不能为空(一般以 /v1 结尾)", "error"); return; }
2628
+ if (!models.length) { flash("至少填一个模型名", "error"); return; }
2629
+ const editingId = SETTINGS_EDITING && SETTINGS_EDITING !== "new" ? SETTINGS_EDITING : "";
2630
+ const id = editingId || nodeIdFromName(name);
2631
+ const next = [...SETTINGS_NODES];
2632
+ const payload = { id, name, endpoint, models, apiKeyEnv: keyEnv, apiKeyFile: keyFile };
2633
+ if (apiKey) payload.apiKey = apiKey;
2634
+ const index = next.findIndex((node) => node.id === id);
2635
+ if (index >= 0) next[index] = { ...next[index], ...payload };
2636
+ else next.push(payload);
2637
+ button.disabled = true;
2638
+ try {
2639
+ await saveSettingsNodes(next, "正在保存节点 …", editingId ? {} : {
2640
+ activateNodeId: id,
2641
+ activateModel: models[0],
2642
+ activateLabel: `${name} / ${models[0]}`,
2643
+ });
2644
+ } catch (error) {
2645
+ getEl("rsState").textContent = "保存失败:" + error.message;
2646
+ flash("保存出图设置失败:" + error.message, "error");
2647
+ } finally {
2648
+ button.disabled = false;
2649
+ }
2650
+ });
2651
+
2652
+ const newButton = getEl("renderSettingsNew");
2653
+ if (newButton && newButton.dataset.bound !== "1") {
2654
+ newButton.dataset.bound = "1";
2655
+ newButton.addEventListener("click", () => {
2656
+ SETTINGS_EDITING = "new";
2657
+ SETTINGS_FORM_DIRTY = false;
2658
+ fillSettingsNodeForm();
2659
+ getEl("rsName")?.focus();
2660
+ });
2661
+ }
2662
+ const resetButton = getEl("renderSettingsReset");
2663
+ if (resetButton && resetButton.dataset.bound !== "1") {
2664
+ resetButton.dataset.bound = "1";
2665
+ resetButton.addEventListener("click", () => {
2666
+ SETTINGS_EDITING = null;
2667
+ SETTINGS_FORM_DIRTY = false;
2668
+ fillSettingsNodeForm();
2669
+ getEl("rsState").textContent = "已取消编辑";
2670
+ });
2671
+ }
2672
+ }
2673
+
2674
+ function bindSettingsNodeEditor() {
2675
+ const box = getEl("settingsList");
2676
+ if (!box || box.dataset.nodeEditorBound === "1") return;
2677
+ box.dataset.nodeEditorBound = "1";
2678
+ box.addEventListener("input", (event) => {
2679
+ if (event.target.closest(".node-editor")) SETTINGS_FORM_DIRTY = true;
2680
+ });
2681
+ box.addEventListener("change", (event) => {
2682
+ if (event.target.closest(".node-editor")) SETTINGS_FORM_DIRTY = true;
2683
+ });
2684
+ box.addEventListener("focusin", (event) => {
2685
+ if (event.target.closest(".node-editor")) SETTINGS_FORM_DIRTY = true;
2686
+ });
2687
+ box.addEventListener("click", async (event) => {
2688
+ const edit = event.target.closest("[data-edit-node]");
2689
+ if (edit) {
2690
+ SETTINGS_EDITING = edit.dataset.editNode;
2691
+ SETTINGS_FORM_DIRTY = false;
2692
+ fillSettingsNodeForm();
2693
+ getEl("rsName")?.focus();
2694
+ return;
2695
+ }
2696
+ const del = event.target.closest("[data-delete-node]");
2697
+ if (!del || del.disabled) return;
2698
+ const nodeId = del.dataset.deleteNode;
2699
+ const node = settingsNodeById(nodeId);
2700
+ if (!node) return;
2701
+ const next = SETTINGS_NODES.filter((item) => item.id !== nodeId);
2702
+ if (!next.length) { flash("至少要保留一个节点", "error"); return; }
2703
+ if (SETTINGS_EDITING === nodeId) SETTINGS_EDITING = null;
2704
+ del.disabled = true;
2705
+ try {
2706
+ await saveSettingsNodes(next, `正在删除节点 ${node.name} …`);
2707
+ flash(`已删除节点:${node.name}`);
2708
+ } catch (error) {
2709
+ del.disabled = false;
2710
+ flash("删除节点失败:" + error.message, "error");
2711
+ }
2712
+ });
2713
+ }
2714
+
2715
+ /* 设置页切换出图节点 / 模型:复用工作台顶栏那条 render-model 接口(写 deck 的 render-selection.json)。 */
2716
+ function bindSettingsNodeSwitch() {
2717
+ const box = getEl("settingsList");
2718
+ if (!box || box.dataset.nodeSwitchBound === "1") return;
2719
+ box.dataset.nodeSwitchBound = "1";
2720
+ box.addEventListener("click", async (event) => {
2721
+ const target = event.target.closest("[data-node-id][data-model]");
2722
+ if (!target) return;
2723
+ const deckId = STATE.data?.active?.deckId;
2724
+ if (!deckId) { flash("先打开一本 deck 再切换出图节点", "error"); return; }
2725
+ const nodeId = target.dataset.nodeId || "";
2726
+ const model = target.dataset.model || "";
2727
+ if (!nodeId || !model) return;
2728
+ const buttons = box.querySelectorAll("[data-node-id][data-model]");
2729
+ buttons.forEach((button) => { button.disabled = true; });
2730
+ try {
2731
+ const res = await fetch("/api/deck-studio.render-model", {
2732
+ method: "POST",
2733
+ headers: { "content-type": "application/json" },
2734
+ body: JSON.stringify({ deckId, nodeId, model }),
2735
+ });
2736
+ const result = await res.json();
2737
+ if (!res.ok || !result.ok) throw new Error(result.error?.message || "切换失败");
2738
+ flash(`出图线路已切换:${result.active?.label || `${nodeId} / ${model}`}`);
2739
+ await loadWorkbench(deckId);
2740
+ } catch (error) {
2741
+ flash(`切换出图节点失败:${error.message}`, "error");
2742
+ } finally {
2743
+ buttons.forEach((button) => { button.disabled = false; });
2744
+ }
2745
+ });
2746
+ }
2747
+
2748
+ /* ---------------- 对话入口:一律走 DSH 原生 Chat ---------------- */
2749
+
2750
+ let AGENT_PROMPT = "";
2751
+ function openAgentPrompt(content, title = "发给 Agent") {
2752
+ const text = String(content || "").trim();
2753
+ if (!text) return;
2754
+ AGENT_PROMPT = text;
2755
+ getEl("agentPromptTitle").textContent = title;
2756
+ getEl("agentPromptText").value = text;
2757
+ getEl("agentPromptState").textContent = "";
2758
+ getEl("agentPromptModal").hidden = false;
2759
+ getEl("agentPromptText").focus();
2760
+ getEl("agentPromptText").setSelectionRange(0, 0);
2761
+ }
2762
+ function closeAgentPrompt() {
2763
+ getEl("agentPromptModal").hidden = true;
2764
+ }
2765
+ async function copyAgentPrompt() {
2766
+ const text = AGENT_PROMPT;
2767
+ try {
2768
+ await navigator.clipboard.writeText(text);
2769
+ getEl("agentPromptState").textContent = "已复制 · 可粘贴到左侧对话自己改";
2770
+ } catch {
2771
+ getEl("agentPromptText").select();
2772
+ document.execCommand("copy");
2773
+ getEl("agentPromptState").textContent = "已复制 · 可粘贴到左侧对话自己改";
2774
+ }
2775
+ }
2776
+
2777
+ /** 发送前确认左侧当前会话就是本 deck / design 的绑定会话;不是就先切/建再发。 */
2778
+ async function ensurePromptConversation() {
2779
+ const target = activeChatTarget();
2780
+ if (!target) return;
2781
+ const bound = chatSessionsForTarget(target);
2782
+ if (bound.length) {
2783
+ const current = currentChatSessionId();
2784
+ if (bound.some((session) => session.sessionId === current)) return;
2785
+ await openBoundConversation(bound[0].sessionId);
2786
+ await new Promise((resolve) => setTimeout(resolve, 250));
2787
+ return;
2788
+ }
2789
+ const active = STATE.data?.active;
2790
+ if (active?.kind === "design-canvas") await openDesignProject(active.designId);
2791
+ else if (active?.deckId) await ensureDeckConversation(active.deckId);
2792
+ await new Promise((resolve) => setTimeout(resolve, 250));
2793
+ }
2794
+
2795
+ /** 点「发送到左侧对话」:直接通过 prompt 桥把指令发进当前 DSH 原生 Chat。 */
2796
+ async function sendAgentPrompt() {
2797
+ const text = AGENT_PROMPT;
2798
+ if (!text) return;
2799
+ const button = getEl("agentPromptSend");
2800
+ const state = getEl("agentPromptState");
2801
+ button.disabled = true;
2802
+ state.textContent = "正在发送到左侧对话…";
2803
+ try {
2804
+ await ensurePromptConversation();
2805
+ const result = await requestNativeChat(text);
2806
+ if (!result.ok) {
2807
+ state.textContent = `未能直接发送${result.reason ? `(${result.reason})` : ""} · 可复制后手动粘贴`;
2808
+ return;
2809
+ }
2810
+ state.textContent = "已发送到左侧对话 ✓";
2811
+ setTimeout(closeAgentPrompt, 900);
2812
+ } finally {
2813
+ button.disabled = false;
2814
+ }
2815
+ }
2816
+
2817
+ /** 所有「让 Agent 做点什么」都生成一条指令 → 弹层里可直接发进左侧对话(也可复制自己改)。 */
2818
+ async function sendPrompt(content) {
2819
+ openAgentPrompt(content);
2820
+ }
2821
+
2822
+ /** 会话列表:只用于首页 Conversations 展示(对话本身在 DSH 原生 Chat)。 */
2823
+ const fmtDayTime = (ms) => {
2824
+ const d = new Date(ms);
2825
+ const p2 = (n) => String(n).padStart(2, "0");
2826
+ return `${p2(d.getMonth() + 1)}-${p2(d.getDate())} ${p2(d.getHours())}:${p2(d.getMinutes())}`;
2827
+ };
2828
+
2829
+ async function refreshSessions() {
2830
+ try {
2831
+ const value = await rpc("session/list", { _request: {} });
2832
+ const cwd = STATE.data?.cwd ?? "";
2833
+ const preset = STATE.data?.config?.preset ?? "deck-studio";
2834
+ STATE.sessions = (value?.items || [])
2835
+ .filter((session) => !cwd || session.cwd === cwd)
2836
+ .filter((session) => (session.projections?.values?.agentPreset ?? preset) === preset)
2837
+ .filter((session) => !session.blank || session.running)
2838
+ .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
2839
+ .slice(0, 20)
2840
+ .map((session) => ({
2841
+ sessionId: session.sessionId,
2842
+ title: session.projections?.values?.title
2843
+ || (session.running ? "进行中的会话" : `未命名会话 · ${fmtDayTime(session.updatedAt ?? Date.now())}`),
2844
+ running: Boolean(session.running),
2845
+ updatedAt: session.updatedAt ?? null,
2846
+ }));
2847
+ } catch {
2848
+ /* best-effort:会话列举失败不影响 deck */
2849
+ }
2850
+ if (!STATE.chatTitlesNormalized) {
2851
+ STATE.chatTitlesNormalized = true;
2852
+ if (await normalizeBoundSessionTitles()) return refreshSessions();
2853
+ }
2854
+ if (STATE.view === "home") renderHome();
2855
+ if (STATE.data?.active) renderChatHeader();
2856
+ }
2857
+
2858
+ /** 让 Agent 给某一页出图(走 render 工具 + QA 门禁,不直接写文件)。 */
2859
+ async function renderSlidePrompt(pageId) {
2860
+ const slide = (STATE.data?.active?.slides || []).find((item) => item.id === pageId);
2861
+ if (!slide) return;
2862
+ getEl("inspectorStatus").textContent = "已生成 Agent 指令(复制到 DSH 原生 Chat 发送):单页出图…";
2863
+ await sendPrompt(`请给 deck ${STATE.data.active.deckId} 的第 ${slide.order} 页(${slide.id})直接出图:现在调用 deck_render_page 生成新版本(旧图保留),不用先提案、不用等我确认。只有 visualBrief 为空或仍是占位时才先提案补齐。`);
2864
+ }
2865
+
2866
+ /* ---------------- 页面微调(写回源 script + 自动 git) ---------------- */
2867
+
2868
+ async function openPageEditor(pageId) {
2869
+ const deckId = STATE.data?.active?.deckId;
2870
+ if (!deckId) return;
2871
+ try {
2872
+ const res = await fetch(`/api/deck-studio.page-edit?` + new URLSearchParams({ deckId, pageId }));
2873
+ const view = await res.json();
2874
+ if (!view.ok) throw new Error(view.error?.message || "无法读取这页");
2875
+ if (view.unsupported) {
2876
+ getEl("renderStatus").textContent = view.reason;
2877
+ return;
2878
+ }
2879
+ STATE.editorPage = { deckId, pageId };
2880
+ getEl("pageEditor").hidden = false;
2881
+ getEl("peTitle").textContent = `微调 P${String(view.page).padStart(2, "0")} · ${view.title || ""}`;
2882
+ getEl("peTitleInput").value = view.title || "";
2883
+ getEl("peSubtitleInput").value = view.subtitle || "";
2884
+ getEl("peBodyInput").value = view.body || "";
2885
+ getEl("pePromptInput").value = view.prompt || "";
2886
+ getEl("peStatus").textContent = `源:${view.heading || ""} · 保存后自动 git 提交`;
2887
+ getEl("peBodyInput").focus();
2888
+ } catch (error) {
2889
+ getEl("renderStatus").textContent = "读取页面失败:" + error.message;
2890
+ }
2891
+ }
2892
+
2893
+ function closePageEditor() {
2894
+ getEl("pageEditor").hidden = true;
2895
+ STATE.editorPage = null;
2896
+ }
2897
+
2898
+ async function savePageEditor() {
2899
+ const editor = STATE.editorPage;
2900
+ if (!editor) return;
2901
+ getEl("peSave").disabled = true;
2902
+ getEl("peStatus").textContent = "保存并提交 git …";
2903
+ try {
2904
+ const res = await fetch(`/api/deck-studio.page-save`, {
2905
+ method: "POST",
2906
+ headers: { "content-type": "application/json" },
2907
+ body: JSON.stringify({
2908
+ deckId: editor.deckId,
2909
+ pageId: editor.pageId,
2910
+ fields: {
2911
+ title: getEl("peTitleInput").value,
2912
+ subtitle: getEl("peSubtitleInput").value,
2913
+ body: getEl("peBodyInput").value,
2914
+ prompt: getEl("pePromptInput").value,
2915
+ },
2916
+ }),
2917
+ });
2918
+ const result = await res.json();
2919
+ if (!res.ok || !result.ok) throw new Error(result.error?.message || "保存失败");
2920
+ const gitText = result.commit ? `已提交 git ${result.commit}` : `已保存(git 未提交:${result.gitError || "无变化"})`;
2921
+ getEl("peStatus").textContent = `已保存 ${result.files.join("、")} · ${gitText}`;
2922
+ STATE.docCache = {};
2923
+ await loadWorkbench(editor.deckId);
2924
+ } catch (error) {
2925
+ getEl("peStatus").textContent = "保存失败:" + error.message;
2926
+ } finally {
2927
+ getEl("peSave").disabled = false;
2928
+ }
2929
+ }
2930
+
2931
+ /* ---------------- 导出 PDF / PPTX ---------------- */
2932
+
2933
+ const EXPORT_LABELS = { pdf: "PDF", pptx: "PPTX", handoff: "交接包(handoff zip)" };
2934
+ function exportDeck(format) {
2935
+ const deckId = STATE.data?.active?.deckId;
2936
+ if (!deckId) return;
2937
+ const label = EXPORT_LABELS[format] ?? format;
2938
+ const url = format === "handoff"
2939
+ ? `/api/deck-studio.handoff?` + new URLSearchParams({ deckId })
2940
+ : `/api/deck-studio.export?` + new URLSearchParams({ deckId, format });
2941
+ getEl("renderStatus").textContent = `正在准备 ${label}…`;
2942
+ const anchor = document.createElement("a");
2943
+ anchor.href = url;
2944
+ anchor.download = "";
2945
+ document.body.appendChild(anchor);
2946
+ anchor.click();
2947
+ anchor.remove();
2948
+ setTimeout(() => {
2949
+ const active = STATE.data?.active || {};
2950
+ const slides = active.slides || [];
2951
+ const missing = slides.filter((slide) => !slide.render).map((slide) => slide.id);
2952
+ const ready = slides.length - missing.length;
2953
+ getEl("renderStatus").textContent = format === "handoff"
2954
+ ? "已触发 交接包(zip) 下载 · 文件在浏览器下载目录"
2955
+ : `已触发 ${label} 下载(${ready}/${slides.length} 页)${missing.length ? ` · 缺 ${missing.join("、")} 未出图,先出图再导一次更完整` : ""} · 文件在浏览器下载目录`;
2956
+ }, 2500);
2957
+ }
2958
+
2959
+ /* ---------------- F-102:全屏演示(整页图翻页) ----------------
2960
+ * 需求原话:「在导出 deck 的按钮旁边放一个全屏演示,我可以上下切换页面,用上下/左右按钮,
2961
+ * 我是笔记本,没有 PageUp/PageDown」。
2962
+ * 实现要点:
2963
+ * - 纯前端:读当前 workbench().active.slides 的整页图(免费、不改契约、不重出图);
2964
+ * - 键盘:↑/↓/←/→ 都翻页(笔记本方向键即可),另兼容 PageUp/PageDown、空格、Home/End;
2965
+ * - 真全屏:先试 overlay 元素(工作台在 iframe 里时需要 iframe allow="fullscreen"),
2966
+ * 再试同源父窗口根元素;都不行就退回页面内全屏(仍盖满工作台可视区),绝不白屏;
2967
+ * - 退出:Esc / ✕ 退出 / 浏览器原生全屏被系统退出时自动收起。
2968
+ */
2969
+ const PRESENT = { index: 0, slides: [], noteTimer: null, wheelAt: 0, fullscreen: false };
2970
+
2971
+ function presentSlides() {
2972
+ return STATE.data?.active?.slides || [];
2973
+ }
2974
+
2975
+ function presentRender(index) {
2976
+ const slides = PRESENT.slides;
2977
+ if (!slides.length) return;
2978
+ const next = Math.min(Math.max(index, 0), slides.length - 1);
2979
+ PRESENT.index = next;
2980
+ const slide = slides[next];
2981
+ const pageNo = String(slide.order ?? next + 1).padStart(2, "0");
2982
+ getEl("presentStage").innerHTML = slide.render?.url
2983
+ ? `<figure class="present-slide"><img src="${esc(slide.render.url)}" alt="${esc(slide.title || "")}" draggable="false"></figure>`
2984
+ : `<div class="present-empty"><b>P${pageNo} · ${esc(slide.title || "未命名")}</b><span>这页还没有出图;可以先退出演示,让 Agent 补图或多刷几次「重新出图」。</span></div>`;
2985
+ getEl("presentCounter").textContent = `${next + 1} / ${slides.length}`;
2986
+ getEl("presentTitle").textContent = `P${pageNo} · ${slide.title || ""}`;
2987
+ getEl("presentPrev").disabled = next <= 0;
2988
+ getEl("presentNext").disabled = next >= slides.length - 1;
2989
+ // 预载相邻页:翻页时不出现空帧(image deck 单页图通常 1–3 MB)
2990
+ [next - 1, next + 1].forEach((near) => {
2991
+ const url = slides[near]?.render?.url;
2992
+ if (url) { const image = new Image(); image.src = url; }
2993
+ });
2994
+ }
2995
+
2996
+ function presentGo(delta) {
2997
+ if (!PRESENT.slides.length) return;
2998
+ presentRender(PRESENT.index + delta);
2999
+ }
3000
+
3001
+ function presentNote(text) {
3002
+ const note = getEl("presentNote");
3003
+ note.textContent = text;
3004
+ note.hidden = false;
3005
+ clearTimeout(PRESENT.noteTimer);
3006
+ PRESENT.noteTimer = setTimeout(() => { note.hidden = true; }, 5000);
3007
+ }
3008
+
3009
+ /** 同源父窗口(DSH 外壳)文档;跨域 / 不存在时返回 null。 */
3010
+ function presentParentDoc() {
3011
+ try {
3012
+ return window.parent && window.parent !== window ? window.parent.document : null;
3013
+ } catch {
3014
+ return null;
3015
+ }
3016
+ }
3017
+
3018
+ async function presentRequestFullscreen() {
3019
+ const targets = [getEl("presentOverlay"), document.documentElement];
3020
+ for (const target of targets) {
3021
+ try {
3022
+ if (typeof target.requestFullscreen !== "function") continue;
3023
+ await target.requestFullscreen({ navigationUI: "hide" });
3024
+ return true;
3025
+ } catch {
3026
+ // iframe 未授权 fullscreen / 浏览器拒绝:继续下一个候选
3027
+ }
3028
+ }
3029
+ // 工作台嵌在 DSH iframe 里且 iframe 没给 allow="fullscreen" 时:退到同源父窗口整窗全屏,
3030
+ // 演示层本身仍然盖满工作台区域(视觉上就是全屏放映)。
3031
+ const parentDoc = presentParentDoc();
3032
+ try {
3033
+ if (parentDoc?.documentElement?.requestFullscreen) {
3034
+ await parentDoc.documentElement.requestFullscreen({ navigationUI: "hide" });
3035
+ return true;
3036
+ }
3037
+ } catch {
3038
+ // 父窗口也拒绝:用页面内全屏兜底
3039
+ }
3040
+ return false;
3041
+ }
3042
+
3043
+ async function presentExitFullscreen() {
3044
+ const parentDoc = presentParentDoc();
3045
+ // F-142 顺手修:退出全屏是异步的,隐藏演示层之前必须确认真的退出——
3046
+ // 否则 fullscreenElement 还挂在 presentOverlay 上,整个页面处于 top-layer 失活状态,
3047
+ // 后面的按钮全部点不动(e2e 里表现为 nav 点不到)。
3048
+ for (let attempt = 0; attempt < 3; attempt += 1) {
3049
+ let pending = false;
3050
+ try {
3051
+ if (parentDoc?.fullscreenElement && parentDoc.exitFullscreen) {
3052
+ await parentDoc.exitFullscreen();
3053
+ pending = true;
3054
+ }
3055
+ } catch {}
3056
+ try {
3057
+ if (document.fullscreenElement && document.exitFullscreen) {
3058
+ await document.exitFullscreen();
3059
+ pending = true;
3060
+ }
3061
+ } catch {}
3062
+ if (!pending && !document.fullscreenElement && !parentDoc?.fullscreenElement) return;
3063
+ await new Promise((resolve) => setTimeout(resolve, 40));
3064
+ }
3065
+ }
3066
+
3067
+ async function openPresentation(startId) {
3068
+ const slides = presentSlides();
3069
+ if (!slides.length) {
3070
+ getEl("renderStatus").textContent = "全屏演示:这个 deck 还没有页面(先让 Agent 出图)";
3071
+ return;
3072
+ }
3073
+ PRESENT.slides = slides;
3074
+ PRESENT.index = Math.max(0, slides.findIndex((slide) => slide.id === (startId || STATE.selected)));
3075
+ getEl("presentOverlay").hidden = false;
3076
+ document.body.classList.add("presenting");
3077
+ presentRender(PRESENT.index);
3078
+ getEl("presentOverlay").focus({ preventScroll: true });
3079
+ PRESENT.fullscreen = await presentRequestFullscreen();
3080
+ if (!PRESENT.fullscreen) {
3081
+ presentNote("浏览器没允许真全屏(工作台在 iframe 内),已用页面内全屏;按 F11 可整窗演示。");
3082
+ }
3083
+ }
3084
+
3085
+ async function closePresentation() {
3086
+ const overlay = getEl("presentOverlay");
3087
+ if (!overlay || overlay.hidden) return;
3088
+ document.body.classList.remove("presenting");
3089
+ getEl("presentStage").innerHTML = "";
3090
+ getEl("presentNote").hidden = true;
3091
+ clearTimeout(PRESENT.noteTimer);
3092
+ PRESENT.slides = [];
3093
+ PRESENT.index = 0;
3094
+ PRESENT.fullscreen = false;
3095
+ // 先退全屏再隐藏:隐藏一个还在 top-layer 的元素会让浏览器保持「全屏中」,
3096
+ // 页面剩余部分不可点(Chrome 的 exitFullscreen 又是异步的)。
3097
+ await presentExitFullscreen();
3098
+ overlay.hidden = true;
3099
+ try { getEl("presentBtn").focus({ preventScroll: true }); } catch {}
3100
+ }
3101
+
3102
+ /** 原生全屏被 Esc / 系统退出时,把演示层一起收掉(含父窗口全屏)。 */
3103
+ function presentWatchFullscreen() {
3104
+ const onChange = () => {
3105
+ const parentDoc = presentParentDoc();
3106
+ if (getEl("presentOverlay").hidden) {
3107
+ // 演示层已经收了但 fullscreenElement 还在(Esc 竞态):补一刀退出全屏,避免整页不可点。
3108
+ if (document.fullscreenElement || parentDoc?.fullscreenElement) presentExitFullscreen();
3109
+ return;
3110
+ }
3111
+ if (!document.fullscreenElement && !parentDoc?.fullscreenElement) closePresentation();
3112
+ };
3113
+ document.addEventListener("fullscreenchange", onChange);
3114
+ const parentDoc = presentParentDoc();
3115
+ if (parentDoc) parentDoc.addEventListener("fullscreenchange", onChange);
3116
+ }
3117
+
3118
+ function bindPresentation() {
3119
+ const presentBtn = getEl("presentBtn");
3120
+ if (presentBtn) presentBtn.addEventListener("click", () => openPresentation(STATE.selected));
3121
+ getEl("presentExit").addEventListener("click", closePresentation);
3122
+ getEl("presentPrev").addEventListener("click", () => presentGo(-1));
3123
+ getEl("presentNext").addEventListener("click", () => presentGo(1));
3124
+ // 放映习惯:点画面 = 下一页(和 PPT / Keynote 一致)
3125
+ getEl("presentStage").addEventListener("click", () => presentGo(1));
3126
+ getEl("presentStage").addEventListener("wheel", (event) => {
3127
+ event.preventDefault();
3128
+ if (Date.now() - PRESENT.wheelAt < 320) return; // 触控板惯性一下翻好几页
3129
+ PRESENT.wheelAt = Date.now();
3130
+ presentGo(event.deltaY > 0 ? 1 : -1);
3131
+ }, { passive: false });
3132
+ document.addEventListener("keydown", (event) => {
3133
+ if (getEl("presentOverlay").hidden) return;
3134
+ const tag = event.target?.tagName;
3135
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
3136
+ const key = event.key;
3137
+ if (key === "Escape") { event.preventDefault(); closePresentation(); return; }
3138
+ if (key === "ArrowDown" || key === "ArrowRight" || key === "PageDown" || key === " " || key === "Enter") {
3139
+ event.preventDefault(); presentGo(1); return;
3140
+ }
3141
+ if (key === "ArrowUp" || key === "ArrowLeft" || key === "PageUp" || key === "Backspace") {
3142
+ event.preventDefault(); presentGo(-1); return;
3143
+ }
3144
+ if (key === "Home") { event.preventDefault(); presentRender(0); return; }
3145
+ if (key === "End") { event.preventDefault(); presentRender(PRESENT.slides.length - 1); }
3146
+ });
3147
+ presentWatchFullscreen();
3148
+ }
3149
+
3150
+ bindPresentation();
3151
+
3152
+ /* ---------------- UI 版本自动刷新(改 HTML/CSS 后不用手动硬刷新) ---------------- */
3153
+
3154
+ async function watchUiVersion() {
3155
+ const read = async () => {
3156
+ try {
3157
+ const res = await fetch("/api/deck-studio.status", { headers: { accept: "application/json" } });
3158
+ const data = await res.json();
3159
+ return data.uiVersion ?? null;
3160
+ } catch {
3161
+ return null;
3162
+ }
3163
+ };
3164
+ const initial = await read();
3165
+ setInterval(async () => {
3166
+ const current = await read();
3167
+ if (initial && current && current !== initial) location.reload();
3168
+ }, 6000);
3169
+ return initial;
3170
+ }
3171
+
3172
+ /* ---------------- 三列可拖拽(宽度存 localStorage) ---------------- */
3173
+
3174
+ const LAYOUT_KEY = "dsh-deck-layout";
3175
+
3176
+ function applySavedLayout() {
3177
+ try {
3178
+ const saved = JSON.parse(localStorage.getItem(LAYOUT_KEY) || "{}");
3179
+ if (Number.isFinite(saved.nav)) document.documentElement.style.setProperty("--nav-w", `${saved.nav}px`);
3180
+ if (Number.isFinite(saved.right)) document.documentElement.style.setProperty("--right-w", `${saved.right}px`);
3181
+ } catch {
3182
+ /* 坏数据忽略 */
3183
+ }
3184
+ }
3185
+
3186
+ function bindSplitters() {
3187
+ const root = document.documentElement;
3188
+ const navEl = document.querySelector(".nav");
3189
+ const rightEl = document.querySelector(".right");
3190
+ let drag = null;
3191
+ const widths = () => ({
3192
+ nav: navEl ? Math.round(navEl.getBoundingClientRect().width) : 0,
3193
+ right: rightEl ? Math.round(rightEl.getBoundingClientRect().width) : 0,
3194
+ });
3195
+ document.querySelectorAll(".splitter").forEach((splitter) => {
3196
+ splitter.addEventListener("mousedown", (event) => {
3197
+ event.preventDefault();
3198
+ const current = widths();
3199
+ drag = { side: splitter.dataset.split, startX: event.clientX, nav: current.nav, right: current.right };
3200
+ splitter.classList.add("dragging");
3201
+ document.body.classList.add("resizing");
3202
+ });
3203
+ splitter.addEventListener("dblclick", () => {
3204
+ root.style.removeProperty("--nav-w");
3205
+ root.style.removeProperty("--right-w");
3206
+ localStorage.removeItem(LAYOUT_KEY);
3207
+ });
3208
+ });
3209
+ window.addEventListener("mousemove", (event) => {
3210
+ if (!drag) return;
3211
+ const dx = event.clientX - drag.startX;
3212
+ if (drag.side === "nav") {
3213
+ const width = Math.min(620, Math.max(240, drag.nav + dx));
3214
+ root.style.setProperty("--nav-w", `${width}px`);
3215
+ } else {
3216
+ const width = Math.min(820, Math.max(320, drag.right - dx));
3217
+ root.style.setProperty("--right-w", `${width}px`);
3218
+ }
3219
+ });
3220
+ window.addEventListener("mouseup", () => {
3221
+ if (!drag) return;
3222
+ const current = widths();
3223
+ localStorage.setItem(LAYOUT_KEY, JSON.stringify(current));
3224
+ document.querySelectorAll(".splitter").forEach((node) => node.classList.remove("dragging"));
3225
+ document.body.classList.remove("resizing");
3226
+ drag = null;
3227
+ });
3228
+ }
3229
+
3230
+ /* ---------------- 交互 ---------------- */
3231
+ getEl("agentPromptClose").addEventListener("click", closeAgentPrompt);
3232
+ getEl("agentPromptCopy").addEventListener("click", copyAgentPrompt);
3233
+ getEl("agentPromptSend").addEventListener("click", () => { sendAgentPrompt().catch(() => {}); });
3234
+ getEl("agentPromptModal").addEventListener("click", (event) => { if (event.target === getEl("agentPromptModal")) closeAgentPrompt(); });
3235
+ document.addEventListener("keydown", (event) => {
3236
+ if (event.key === "Escape" && !getEl("agentPromptModal").hidden) closeAgentPrompt();
3237
+ });
3238
+ getEl("brandHome").addEventListener("click", () => showHome());
3239
+ getEl("renderModelSelect").addEventListener("change", async (event) => {
3240
+ const deckId = STATE.data?.active?.deckId;
3241
+ const value = event.target.value || "";
3242
+ const slash = value.indexOf("/");
3243
+ if (!deckId || slash <= 0) return;
3244
+ const nodeId = value.slice(0, slash);
3245
+ const model = value.slice(slash + 1);
3246
+ event.target.disabled = true;
3247
+ getEl("renderStatus").textContent = `正在切换出图模型:${value.replace("/", " / ")} …`;
3248
+ try {
3249
+ const res = await fetch("/api/deck-studio.render-model", {
3250
+ method: "POST",
3251
+ headers: { "content-type": "application/json" },
3252
+ body: JSON.stringify({ deckId, nodeId, model }),
3253
+ });
3254
+ const result = await res.json();
3255
+ if (!res.ok || !result.ok) throw new Error(result.error?.message || "切换失败");
3256
+ getEl("renderStatus").textContent = `已切换出图模型:${result.active?.label || model}(下一次出图生效)`;
3257
+ await loadWorkbench(deckId);
3258
+ } catch (error) {
3259
+ getEl("renderStatus").textContent = "切换出图模型失败:" + error.message;
3260
+ await loadWorkbench(deckId);
3261
+ } finally {
3262
+ event.target.disabled = false;
3263
+ }
3264
+ });
3265
+ getEl("densitySelect").addEventListener("change", async (event) => {
3266
+ const deckId = STATE.data?.active?.deckId;
3267
+ const density = event.target.value || "";
3268
+ if (!deckId) return;
3269
+ event.target.disabled = true;
3270
+ try {
3271
+ const res = await fetch("/api/deck-studio.deck-density", {
3272
+ method: "POST",
3273
+ headers: { "content-type": "application/json" },
3274
+ body: JSON.stringify({ deckId, density }),
3275
+ });
3276
+ const result = await res.json();
3277
+ if (!res.ok || !result.ok) throw new Error(result.error?.message || "设置失败");
3278
+ const spec = result.spec || {};
3279
+ flash(spec.bodyMin == null
3280
+ ? `内容密度已设为「${result.label}」:每页长短由 Agent 按内容判断(不设固定字数)`
3281
+ : `内容密度已设为「${result.label}」:正文 ${spec.bodyMin}–${spec.bodyMax} 字/页 · 画面要求 ${spec.briefMin}–${spec.briefMax} 字`);
3282
+ await loadWorkbench(deckId);
3283
+ // F-130:改密度只改「以后怎么写」,不会自动重写已有页。这里直接算一遍差多少页,
3284
+ // 免得用户以为改完就生效了(真实反馈:对话说改了,页面上还是旧内容)。
3285
+ if (spec.bodyMin != null) {
3286
+ const slides = STATE.data?.active?.slides || [];
3287
+ const bad = slides.filter((slide) => {
3288
+ const body = String(slide.body || "").replace(/\s+/g, "").length;
3289
+ const brief = String(slide.visualBrief || "").length;
3290
+ return body < spec.bodyMin || body > spec.bodyMax || brief < spec.briefMin || brief > spec.briefMax;
3291
+ }).length;
3292
+ if (bad) flash(`密度已设为「${result.label}」,但 ${bad} 页的正文 / 画面要求还没按新口径重写——到「脚本」页点「按当前密度检查 / 重写」让 Agent 批量提案。`, "error");
3293
+ }
3294
+ } catch (error) {
3295
+ flash("设置内容密度失败:" + error.message, "error");
3296
+ await loadWorkbench(deckId);
3297
+ } finally {
3298
+ event.target.disabled = false;
3299
+ }
3300
+ });
3301
+ /* 导出合并成一个按钮 + 菜单:PDF / PPTX / 交接包(handoff zip) */
3302
+ const exportMenu = getEl("exportMenu");
3303
+ const exportBtn = getEl("exportBtn");
3304
+ const closeExportMenu = () => { exportMenu.hidden = true; exportBtn.setAttribute("aria-expanded", "false"); };
3305
+ exportBtn.addEventListener("click", (event) => {
3306
+ event.stopPropagation();
3307
+ exportMenu.hidden = !exportMenu.hidden;
3308
+ exportBtn.setAttribute("aria-expanded", String(!exportMenu.hidden));
3309
+ });
3310
+ exportMenu.addEventListener("click", (event) => {
3311
+ const item = event.target.closest("[data-export]");
3312
+ if (!item) return;
3313
+ closeExportMenu();
3314
+ exportDeck(item.dataset.export);
3315
+ });
3316
+ document.addEventListener("click", () => { if (!exportMenu.hidden) closeExportMenu(); });
3317
+ document.addEventListener("keydown", (event) => { if (event.key === "Escape") closeExportMenu(); });
3318
+ getEl("peClose").addEventListener("click", closePageEditor);
3319
+ getEl("pvClose").addEventListener("click", closePreview);
3320
+ getEl("cfClose").addEventListener("click", closeConfirm);
3321
+ getEl("cfCancel").addEventListener("click", closeConfirm);
3322
+ getEl("cfOk").addEventListener("click", () => { const fn = CONFIRM_OK; closeConfirm(); if (fn) fn(); });
3323
+ getEl("confirmModal").addEventListener("click", (event) => { if (event.target === getEl("confirmModal")) closeConfirm(); });
3324
+
3325
+ // F-158:点脚本卡片 = 看过这页,清掉未查看小圆点。
3326
+ document.addEventListener("click", (event) => {
3327
+ const card = event.target.closest?.(".script-page[data-script-page]");
3328
+ if (!card) return;
3329
+ markScriptSeen(STATE.data?.active?.deckId, card.dataset.scriptPage, card.dataset.scriptHash);
3330
+ card.querySelectorAll(".page-dot").forEach((dot) => dot.remove());
3331
+ });
3332
+
3333
+ // F-158:首次启动登记名字(存 <dataRoot>/user.json;之后 greeting / deck owner 默认用它)。
3334
+ async function submitUserName() {
3335
+ const input = getEl("nameInput");
3336
+ const name = String(input?.value || "").trim();
3337
+ if (!name) {
3338
+ input?.focus();
3339
+ return;
3340
+ }
3341
+ try {
3342
+ const data = await postJson("user-settings", { name });
3343
+ STATE.data = { ...(STATE.data || {}), user: { name: data?.user?.name || name } };
3344
+ getEl("nameModal").hidden = true;
3345
+ getEl("homeUserName").textContent = STATE.data.user.name;
3346
+ getEl("homeGreetSuffix").textContent = ",欢迎你";
3347
+ flash("名字已记住:新 deck 的 owner 和封面作者会用这个名字");
3348
+ } catch (error) {
3349
+ flash(`保存失败:${error.message}`, "error");
3350
+ }
3351
+ }
3352
+ getEl("nmClose").addEventListener("click", () => { getEl("nameModal").hidden = true; });
3353
+ getEl("nmLater").addEventListener("click", () => {
3354
+ try { localStorage.setItem("deckline.nameLater", "1"); } catch {}
3355
+ getEl("nameModal").hidden = true;
3356
+ });
3357
+ getEl("nameForm").addEventListener("submit", (event) => {
3358
+ event.preventDefault();
3359
+ submitUserName();
3360
+ });
3361
+ document.addEventListener("keydown", (event) => { if (event.key === "Escape" && !getEl("confirmModal").hidden) closeConfirm(); });
3362
+ getEl("previewModal").addEventListener("click", (event) => { if (event.target === getEl("previewModal")) closePreview(); });
3363
+ document.addEventListener("keydown", (event) => {
3364
+ if (getEl("previewModal").hidden) return;
3365
+ if (event.key === "Escape") closePreview();
3366
+ if (event.key === "ArrowRight") showPreviewAt(PREVIEW.index + 1);
3367
+ if (event.key === "ArrowLeft") showPreviewAt(PREVIEW.index - 1);
3368
+ });
3369
+ getEl("homeNav").querySelectorAll("[data-section]").forEach((node) => node.addEventListener("click", () => {
3370
+ STATE.homeSection = node.dataset.section;
3371
+ STATE.manualDoc = null; // 切章节 = 退出应用内阅读,回章节首页
3372
+ history.replaceState(null, "", `#/home/${STATE.homeSection}`);
3373
+ renderHome();
3374
+ }));
3375
+ getEl("peCancel").addEventListener("click", closePageEditor);
3376
+ getEl("peSave").addEventListener("click", savePageEditor);
3377
+ getEl("pageEditor").addEventListener("click", (event) => { if (event.target === getEl("pageEditor")) closePageEditor(); });
3378
+ document.addEventListener("keydown", (event) => { if (event.key === "Escape" && !getEl("pageEditor").hidden) closePageEditor(); });
3379
+ /* ---------------- 新建 Deck:意图 + 风格 → 建源目录/文档骨架 → 对话继续 ---------------- */
3380
+ /* 历史风格来源 = 当前 deck 的 designs + Designs 页风格项目,按缩写去重(F-45)。 */
3381
+ function deckStyleChoices() {
3382
+ const choices = [];
3383
+ const seen = new Set();
3384
+ const seenNames = new Set();
3385
+ const push = (style, kind) => {
3386
+ const key = String(style?.abbr || "").toUpperCase();
3387
+ const nameKey = String(style?.name || "").trim().toLowerCase();
3388
+ if (!key || seen.has(key) || (nameKey && seenNames.has(nameKey))) return;
3389
+ seen.add(key);
3390
+ if (nameKey) seenNames.add(nameKey);
3391
+ choices.push({
3392
+ abbr: style.abbr,
3393
+ name: style.name || style.abbr,
3394
+ description: style.description || "",
3395
+ samples: style.samples || [],
3396
+ kind,
3397
+ });
3398
+ };
3399
+ for (const style of STATE.data?.active?.designStyles ?? []) push(style, "deck");
3400
+ for (const project of STATE.data?.designProjects ?? []) push(project, "design");
3401
+ return choices;
3402
+ }
3403
+
3404
+ function pickedDeckStyle() {
3405
+ const picked = STATE.ndStyleAbbr || "";
3406
+ return deckStyleChoices().find((style) => style.abbr === picked) || null;
3407
+ }
3408
+
3409
+ function renderDeckStyleChoices() {
3410
+ const choices = deckStyleChoices();
3411
+ const picked = pickedDeckStyle();
3412
+ getEl("ndStyles").innerHTML = choices.length
3413
+ ? choices.map((style) => {
3414
+ const cover = (style.samples || [])[0]?.url || "";
3415
+ const count = (style.samples || []).length;
3416
+ const selected = style.abbr === picked?.abbr;
3417
+ return `<button type="button" class="nd-style${selected ? " selected" : ""}" data-abbr="${esc(style.abbr)}" title="${esc(style.name)}">
3418
+ <span class="nd-style-thumb">${cover ? `<img src="${esc(cover)}" alt="" loading="lazy">` : `<em>${esc(style.abbr)}</em>`}${selected ? `<span class="nd-style-check">✓</span>` : ""}</span>
3419
+ <span class="nd-style-meta"><b>${esc(style.abbr)}</b><span>${esc(style.name)}</span><i>${count ? `${count} 张样张` : "还没有样张"}</i></span>
3420
+ </button>`;
3421
+ }).join("")
3422
+ : `<p class="engine-note">还没有登记风格。可以先自己描述,或去 Designs 里建一个风格目录。</p>`;
3423
+ // preset 的描述不展示给用户:选中后折叠自定义输入,再点一次取消选择就恢复。
3424
+ const custom = getEl("ndStyleCustom");
3425
+ custom.hidden = Boolean(picked);
3426
+ const note = getEl("ndStylePresetNote");
3427
+ note.hidden = !picked;
3428
+ note.textContent = picked ? `已选 preset「${picked.name}」;想自己描述就再点一次取消选择。` : "";
3429
+ for (const button of getEl("ndStyles").querySelectorAll(".nd-style")) {
3430
+ button.addEventListener("click", () => {
3431
+ STATE.ndStyleAbbr = STATE.ndStyleAbbr === button.dataset.abbr ? "" : button.dataset.abbr;
3432
+ renderDeckStyleChoices();
3433
+ });
3434
+ }
3435
+ }
3436
+
3437
+ function openNewDeckModal() {
3438
+ // F-112:profile 里配了 defaultStyleAbbr 就默认预选这个风格(用户仍可改/取消)。
3439
+ STATE.ndStyleAbbr = String(STATE.data?.config?.defaultStyleAbbr || "");
3440
+ getEl("newDeckForm").reset();
3441
+ getEl("ndState").textContent = "";
3442
+ getEl("newDeckModal").hidden = false;
3443
+ renderDeckStyleChoices();
3444
+ setTimeout(() => getEl("ndTitle").focus(), 30);
3445
+ }
3446
+
3447
+ function closeNewDeckModal() {
3448
+ getEl("newDeckModal").hidden = true;
3449
+ }
3450
+
3451
+ getEl("newDeckBtn").addEventListener("click", openNewDeckModal);
3452
+ getEl("ndClose").addEventListener("click", closeNewDeckModal);
3453
+ getEl("ndCancel").addEventListener("click", closeNewDeckModal);
3454
+ getEl("newDeckModal").addEventListener("click", (event) => { if (event.target === getEl("newDeckModal")) closeNewDeckModal(); });
3455
+ getEl("newDeckForm").addEventListener("submit", async (event) => {
3456
+ event.preventDefault();
3457
+ const payload = {
3458
+ title: getEl("ndTitle").value.trim(),
3459
+ dirName: getEl("ndDirName").value.trim(),
3460
+ pages: Math.min(Math.max(Number.parseInt(getEl("ndPages").value, 10) || 6, 1), 40),
3461
+ contentDensity: getEl("ndDensity").value || "auto",
3462
+ audience: getEl("ndAudience").value.trim(),
3463
+ topic: getEl("ndTopic").value.trim(),
3464
+ intent: getEl("ndIntent").value.trim(),
3465
+ occasion: getEl("ndOccasion").value.trim(),
3466
+ success: getEl("ndSuccess").value.trim(),
3467
+ scopeOut: getEl("ndScopeOut").value.trim(),
3468
+ references: getEl("ndRefs").value.trim(),
3469
+ style: {
3470
+ abbr: STATE.ndStyleAbbr || "",
3471
+ // preset 选中的话,description 仍带完整风格定义(只是不给用户看)
3472
+ description: pickedDeckStyle()?.description || getEl("ndStyle").value.trim(),
3473
+ },
3474
+ };
3475
+ if (!payload.title) return;
3476
+ getEl("ndCreate").disabled = true;
3477
+ getEl("ndState").textContent = "正在创建 deck 项目(源目录 + 9 份文档骨架)…";
3478
+ try {
3479
+ const res = await fetch("/api/deck-studio.deck-create", {
3480
+ method: "POST",
3481
+ headers: { "content-type": "application/json" },
3482
+ body: JSON.stringify(payload),
3483
+ });
3484
+ const result = await res.json();
3485
+ if (!res.ok || !result.ok) throw new Error(result.error?.message || "创建失败");
3486
+ const { deckId, label } = result.deck;
3487
+ getEl("ndState").textContent = `已创建 ${label}(${deckId})`;
3488
+ closeNewDeckModal();
3489
+ // F-71:创建完必须真正进这个 deck(主页 → 三栏工作区),不能只 loadWorkbench
3490
+ // 停在 home(loadWorkbench 在 home 视图下只会重渲染首页,用户会以为「没自动打开」)。
3491
+ await showDeck(deckId);
3492
+ getEl("renderStatus").textContent = `已创建 ${label};对话在 DSH 原生 Chat 里继续。`;
3493
+ sendPrompt(
3494
+ `我刚新建了 deck「${label}」(${deckId})。\n` +
3495
+ `页数:已按 ${payload.pages} 页占位(P01–P${String(payload.pages).padStart(2, "0")});页数要改就在对话里说,用 deck_script_add_page 追加。\n` +
3496
+ `受众:${payload.audience || "待确认"};话题:${payload.topic || "待确认"};\n` +
3497
+ `意图/希望带走:${payload.intent || "待确认"};\n` +
3498
+ `场合/时长:${payload.occasion || "(未填,请根据意图判断或问我)"};` +
3499
+ `成功标准:${payload.success || "(未填,请根据意图判断或问我)"};` +
3500
+ `不讲的:${payload.scopeOut || "(未填,请根据意图判断或问我)"};\n` +
3501
+ `参考资料:${payload.references || "暂无"};\n` +
3502
+ (payload.style.abbr
3503
+ ? `风格:历史 preset ${payload.style.abbr}(按已登记风格执行,不要自创)。\n`
3504
+ : `风格:${payload.style.description || "待确认"}。\n`) +
3505
+ `请按这个顺序做,不要留 TODO / 占位:\n` +
3506
+ `1) 用 deck_doc_read 读 intent / design;参考资料里的路径用 deck_file_read 逐条读(白名单:README / 对外 docs / samples / plugin 代码;网页链接说明抓不到,让我补材料);\n` +
3507
+ `2) 分析每份资料:出处 / 类型 / 能引用的事实 / 能支撑哪条主张 / 还缺什么 → 用 deck_doc_update(docId=materials)写进「支撑材料」;\n` +
3508
+ `3) 把 intent 里空着的「场合 / 时长」「成功标准」「不讲的」按意图 + 资料做判断补全 → 用 deck_doc_update(docId=intent)写回;没依据的字段不要编,列成问题问我,并把该字段写成「待确认:缺依据,已列问题」,不要留模板占位;\n` +
3509
+ `4) 需要时再用 deck_doc_update(docId=research)补调研条目。\n` +
3510
+ `5) 页数不够/多了:用 deck_script_add_page 追加页(先提案、我确认后再 deck_revision_confirm);不要直接改 script.md,也不要回答「工具不支持加页」。\n` +
3511
+ `材料里没有的数字 / 客户 / 案例 / URL 不要编。做完复述你补了什么、还缺什么,再等我确认后起 script 草案,不要直接出图。`,
3512
+ "新建 deck · 下一步发给 Agent",
3513
+ );
3514
+ } catch (error) {
3515
+ getEl("ndState").textContent = "创建失败:" + error.message;
3516
+ } finally {
3517
+ getEl("ndCreate").disabled = false;
3518
+ }
3519
+ });
3520
+
3521
+ // F-63:标签是动态渲染的,用事件委托
3522
+ getEl("observeTabs").addEventListener("click", (event) => {
3523
+ const btn = event.target.closest(".observe-tab");
3524
+ if (!btn) return;
3525
+ STATE.tab = btn.dataset.observe;
3526
+ renderMain();
3527
+ });
3528
+ document.querySelectorAll(".tab").forEach((btn) => btn.addEventListener("click", () => {
3529
+ STATE.rightTab = btn.dataset.tab || "";
3530
+ activateRightTab(STATE.rightTab);
3531
+ }));
3532
+
3533
+ getEl("insSave").addEventListener("click", async () => {
3534
+ const deckId = STATE.data?.active?.deckId;
3535
+ const pageId = STATE.selected;
3536
+ if (!deckId || !pageId) return;
3537
+ const slide = (STATE.data?.active?.slides || []).find((item) => item.id === pageId);
3538
+ const body = getEl("insBody").value;
3539
+ if (!body.trim()) {
3540
+ getEl("inspectorStatus").textContent = "正文为空,未保存(避免把整页正文清掉)。";
3541
+ return;
3542
+ }
3543
+ getEl("insSave").disabled = true;
3544
+ getEl("inspectorStatus").textContent = "保存中:写回源 script.md / prompts + git 提交 …";
3545
+ try {
3546
+ const res = await fetch(`/api/deck-studio.page-save`, {
3547
+ method: "POST",
3548
+ headers: { "content-type": "application/json" },
3549
+ body: JSON.stringify({
3550
+ deckId,
3551
+ pageId,
3552
+ fields: {
3553
+ title: getEl("insTitle").value,
3554
+ subtitle: getEl("insSubtitle").value,
3555
+ body,
3556
+ prompt: getEl("insPrompt").value,
3557
+ },
3558
+ }),
3559
+ });
3560
+ const result = await res.json();
3561
+ if (!res.ok || !result.ok) throw new Error(result.error?.message || "保存失败");
3562
+ const gitText = result.commit ? `已提交 git ${result.commit}` : `已保存(git 未提交:${result.gitError || "无变化"})`;
3563
+ getEl("inspectorStatus").innerHTML = `已保存 ${esc(result.files.join("、"))}<br>${esc(gitText)} · 可点「重新出图」用新提示词重出本页`;
3564
+ STATE.docCache = {};
3565
+ await loadWorkbench(deckId);
3566
+ } catch (error) {
3567
+ getEl("inspectorStatus").textContent = "保存失败:" + error.message;
3568
+ } finally {
3569
+ getEl("insSave").disabled = false;
3570
+ }
3571
+ });
3572
+ getEl("renderBtn").addEventListener("click", () => renderSlidePrompt(STATE.selected));
3573
+ /* ---------------- 添加页面:结构化弹层 → 发到 DSH 原生 Chat ---------------- */
3574
+
3575
+ const ADD_PAGE = { open: false };
3576
+ function openAddPageModal() {
3577
+ const active = STATE.data?.active;
3578
+ if (!active) return;
3579
+ const slides = active.slides || [];
3580
+ const select = getEl("apAfter");
3581
+ select.innerHTML = `<option value="end" selected>最后一页之后(P${String(slides.length).padStart(2, "0")} 之后)</option>`;
3582
+ select.value = "end";
3583
+ select.disabled = true;
3584
+ getEl("apTitle").value = "";
3585
+ getEl("apClaim").value = "";
3586
+ getEl("apBody").value = "";
3587
+ getEl("apVisual").value = "";
3588
+ getEl("apPrompt").value = "";
3589
+ getEl("apState").textContent = "";
3590
+ getEl("apAck").textContent = "";
3591
+ getEl("addPageModal").hidden = false;
3592
+ ADD_PAGE.open = true;
3593
+ getEl("apTitle").focus();
3594
+ }
3595
+ function closeAddPageModal() {
3596
+ getEl("addPageModal").hidden = true;
3597
+ ADD_PAGE.open = false;
3598
+ }
3599
+ function buildAddPagePrompt() {
3600
+ const active = STATE.data?.active || {};
3601
+ const slides = active.slides || [];
3602
+ const afterText = `最后一页(P${String(slides.length).padStart(2, "0")})之后(追加)`;
3603
+ const title = getEl("apTitle").value.trim();
3604
+ const claim = getEl("apClaim").value.trim();
3605
+ const body = getEl("apBody").value.trim();
3606
+ const visual = getEl("apVisual").value.trim();
3607
+ const prompt = getEl("apPrompt").value.trim();
3608
+ if (!title) {
3609
+ getEl("apState").textContent = "先把页面标题填上。";
3610
+ getEl("apTitle").focus();
3611
+ return "";
3612
+ }
3613
+ const lines = [
3614
+ `请在 deck「${active.title || active.deckId}」(${active.deckId})里新增一页,位置:${afterText}。`,
3615
+ ``,
3616
+ `页面标题:${title}`,
3617
+ ];
3618
+ if (claim) lines.push(`这一页要回答:${claim}`);
3619
+ if (body) {
3620
+ lines.push(``, `页面内容 / 结构:`);
3621
+ lines.push(body);
3622
+ }
3623
+ if (visual) lines.push(``, `画面要求:${visual}`);
3624
+ if (prompt) lines.push(``, `出图提示词草稿(可润色):`, `"""`, prompt, `"""`);
3625
+ lines.push(
3626
+ ``,
3627
+ `要求:`,
3628
+ `1. 调 deck_script_add_page(deckId=${active.deckId})追加一页:title / question / claim / body / visualBrief 一次给全;不要自己改 script.md(你也没有写文件的工具);`,
3629
+ `2. 把工具返回的 diff 贴给我,等我确认后再调 deck_revision_confirm(confirmationQuote 引用我原话);`,
3630
+ `3. 新页默认没有图:先补 visualBrief 再出这一页;这一轮只处理新增页,别的页别动。`,
3631
+ );
3632
+ return lines.join("\n");
3633
+ }
3634
+ async function sendAddPageToChat() {
3635
+ const text = buildAddPagePrompt();
3636
+ if (!text) return;
3637
+ getEl("apSend").disabled = true;
3638
+ getEl("apAck").textContent = "正在发送到 DSH 原生对话…";
3639
+ // F-106:先确保发到这个 deck 绑定的会话;否则新 deck 还没有绑定时,指令会落进「当前打开的任意 chat」
3640
+ await ensurePromptConversation();
3641
+ const result = await requestNativeChat(text);
3642
+ getEl("apSend").disabled = false;
3643
+ if (result.ok) {
3644
+ getEl("apAck").textContent = "已发送到原生对话 ✓";
3645
+ getEl("apState").textContent = `看看${EMBED ? "右侧" : "左侧"} Chat 里的 Agent 回复;确认后再让它出图。`;
3646
+ setTimeout(closeAddPageModal, 900);
3647
+ } else {
3648
+ copyText(text);
3649
+ getEl("apAck").textContent = "未能直接发送(已复制指令)";
3650
+ getEl("apState").textContent = `指令已复制,去 DSH 原生 Chat 粘贴发送。${result.reason ? `(${result.reason})` : ""}`;
3651
+ }
3652
+ }
3653
+ /** 左栏内嵌 DSH 原生对话:隐藏壳的会话列表/右栏,只留 Chat,并套上 Deckline 配色。 */
3654
+ function decorateNativeChat(doc) {
3655
+ if (!doc || doc.getElementById("deckline-native-chat-theme")) return;
3656
+ const style = doc.createElement("style");
3657
+ style.id = "deckline-native-chat-theme";
3658
+ style.textContent = `
3659
+ .pI_x6G_sidebarCol, .pI_x6G_rightbarCol, .pI_x6G_overlayLayer { display: none !important; }
3660
+ .pI_x6G_frame { display: block !important; grid-template-columns: none !important; }
3661
+ .pI_x6G_centerCol { width: 100% !important; height: 100vh !important; }
3662
+ [data-sidebar-right-expand] { display: none !important; }
3663
+ .wSkVaW_header { padding: 0 10px !important; }
3664
+ .wSkVaW_root { --dsh-conversation-column-width: 100% !important; }
3665
+ body {
3666
+ background: #faf8f2 !important;
3667
+ --dsw-alias-bg-base: #faf8f2;
3668
+ --dsw-alias-bg-layer-1: #fffdf8;
3669
+ --dsw-alias-bg-layer-2: #fffdf8;
3670
+ --dsw-alias-label-primary: #0c4651;
3671
+ --dsw-alias-label-secondary: #5c6f72;
3672
+ --dsw-alias-label-tertiary: #899497;
3673
+ --dsw-alias-brand-primary: #0f6975;
3674
+ --dsw-alias-brand-text: #0f6975;
3675
+ --dsw-alias-border-l1: #e4ddd0;
3676
+ --dsw-alias-border-l2: #d6d0c4;
3677
+ --dsw-alias-button-primary-fill: #0f6975;
3678
+ --dsw-alias-button-primary-hover: #0c4651;
3679
+ }
3680
+ [class*="_bubble"] { background-color: #e4f1ef !important; }
3681
+ button[class*="_primary"] { background-color: #0f6975 !important; }
3682
+ `;
3683
+ doc.head.appendChild(style);
3684
+ }
3685
+
3686
+ function setupNativeChat() {
3687
+ if (EMBED) return; // embed 模式下宿主 shell 本身就有原生 Chat
3688
+ const column = getEl("chatCol");
3689
+ const frame = getEl("nativeChatFrame");
3690
+ const splitter = document.querySelector('.splitter[data-split="nav"]');
3691
+ if (!column || !frame) return;
3692
+ column.hidden = false;
3693
+ if (splitter) splitter.hidden = false;
3694
+ document.body.classList.add("has-chat");
3695
+ frame.addEventListener("load", () => {
3696
+ try {
3697
+ decorateNativeChat(frame.contentDocument);
3698
+ } catch {
3699
+ /* 跨源失败时保留原生样式 */
3700
+ }
3701
+ setupChatBindingSync(frame);
3702
+ });
3703
+ frame.src = "/?decklineChat=1";
3704
+ getEl("chatBindChip").addEventListener("click", () => {
3705
+ if (!getEl("chatBindChip").classList.contains("unbound")) return;
3706
+ bindCurrentChatToTarget().catch(() => {});
3707
+ });
3708
+ getEl("newChatBtn").addEventListener("click", () => {
3709
+ newChatForTarget().catch(() => {});
3710
+ });
3711
+ getEl("expandChatBtn").addEventListener("click", () => window.open("/", "_blank"));
3712
+ getEl("chatSessionBtn").addEventListener("click", (event) => {
3713
+ event.stopPropagation();
3714
+ toggleChatSessionMenu();
3715
+ });
3716
+ document.addEventListener("click", (event) => {
3717
+ const menu = getEl("chatSessionMenu");
3718
+ if (menu.hidden) return;
3719
+ if (menu.contains(event.target) || event.target.closest?.("#chatSessionBtn")) return;
3720
+ menu.hidden = true;
3721
+ });
3722
+ document.addEventListener("keydown", (event) => {
3723
+ if (event.key === "Escape") getEl("chatSessionMenu").hidden = true;
3724
+ });
3725
+ }
3726
+
3727
+ function copyText(text) {
3728
+ try {
3729
+ navigator.clipboard.writeText(text);
3730
+ return true;
3731
+ } catch {
3732
+ return false;
3733
+ }
3734
+ }
3735
+
3736
+ /** 工作台 → 原生 Chat 的窗口:非 embed 用左栏自己的 iframe,embed 用宿主 DSH 壳。 */
3737
+ function nativeChatWindow() {
3738
+ if (!EMBED) {
3739
+ const frame = getEl("nativeChatFrame");
3740
+ return frame && frame.contentWindow ? frame.contentWindow : null;
3741
+ }
3742
+ return window.parent !== window ? window.parent : null; // embed:宿主 DSH 壳
3743
+ }
3744
+
3745
+ function requestNativeChat(text) {
3746
+ const target = nativeChatWindow();
3747
+ if (!target) return Promise.resolve({ ok: false, reason: "没有可用的 DSH 对话窗口" });
3748
+ return new Promise((resolve) => {
3749
+ let settled = false;
3750
+ const onAck = (event) => {
3751
+ if (event.data?.type !== "dsh-deck-studio:prompt-ack") return;
3752
+ settled = true;
3753
+ window.removeEventListener("message", onAck);
3754
+ resolve({ ok: Boolean(event.data.ok), reason: event.data.reason || "" });
3755
+ };
3756
+ window.addEventListener("message", onAck);
3757
+ target.postMessage({ type: "dsh-deck-studio:prompt", text }, "*");
3758
+ setTimeout(() => {
3759
+ if (settled) return;
3760
+ window.removeEventListener("message", onAck);
3761
+ resolve({ ok: false, reason: "宿主 2.5s 内没有回应" });
3762
+ }, 2500);
3763
+ });
3764
+ }
3765
+
3766
+ /* ---------------- 会话桥:建/切会话 + 绑定 deck|design(F-43) ---------------- */
3767
+
3768
+ const bindingOf = (kind, id) => (STATE.data?.bindings || []).find((binding) => binding.kind === kind && binding.id === id) || null;
3769
+
3770
+ function addLocalBinding(binding) {
3771
+ if (!STATE.data || !binding?.sessionId) return;
3772
+ STATE.data.bindings = [...(STATE.data.bindings || []).filter((item) => item.sessionId !== binding.sessionId), binding];
3773
+ if (STATE.view === "home") renderHome();
3774
+ }
3775
+
3776
+ /** 工作台 → DSH 壳:open(切会话)/ ensure(没有就新建再切),可选把首条 prompt 发进去。 */
3777
+ function requestConversation(payload) {
3778
+ const target = nativeChatWindow();
3779
+ if (!target) return Promise.resolve({ ok: false, reason: "没有可用的 DSH 对话窗口" });
3780
+ const requestId = `conv-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
3781
+ return new Promise((resolve) => {
3782
+ let settled = false;
3783
+ const cleanup = () => window.removeEventListener("message", onAck);
3784
+ const onAck = (event) => {
3785
+ if (event.data?.type !== "dsh-deck-studio:conversation-ack" || event.data.requestId !== requestId) return;
3786
+ settled = true;
3787
+ cleanup();
3788
+ resolve({ ok: Boolean(event.data.ok), sessionId: event.data.sessionId || "", reason: event.data.reason || "" });
3789
+ };
3790
+ window.addEventListener("message", onAck);
3791
+ target.postMessage({ type: "dsh-deck-studio:conversation", requestId, cwd: STATE.data?.cwd || "", ...payload }, "*");
3792
+ setTimeout(() => {
3793
+ if (settled) return;
3794
+ cleanup();
3795
+ resolve({ ok: false, reason: "宿主 4s 内没有回应" });
3796
+ }, 4000);
3797
+ });
3798
+ }
3799
+
3800
+ async function bindConversation(payload) {
3801
+ try {
3802
+ const res = await fetch("/api/deck-studio.session-bind", {
3803
+ method: "POST",
3804
+ headers: { "content-type": "application/json" },
3805
+ body: JSON.stringify(payload),
3806
+ });
3807
+ const data = await res.json();
3808
+ if (data?.ok && data.binding) addLocalBinding(data.binding);
3809
+ return Boolean(data?.ok);
3810
+ } catch {
3811
+ return false;
3812
+ }
3813
+ }
3814
+
3815
+ function buildDesignPrompt(design, canvasDeckId) {
3816
+ return [
3817
+ "本对话是「风格设计(design)」会话,不绑定 deck。",
3818
+ `绑定 design:${design.abbr} · ${design.name}(designId=${design.designId})`,
3819
+ `空白画布 deck:${canvasDeckId}(0 页,只作样张画布,不要把它当正式 deck 改内容/补文档)`,
3820
+ "",
3821
+ "风格描述:",
3822
+ design.description || "(未填)",
3823
+ design.constraints ? `\n不要什么:\n${design.constraints}` : "",
3824
+ design.reference ? `\n参考:\n${design.reference}` : "",
3825
+ "",
3826
+ "工作方式(F-72:风格提示词只有一份,样张复用它,不要重造):",
3827
+ "1. 先调 deck_style_read 读这个 design 的已有风格定义(style.json 里的 styleBrief)和已有样张的 brief/prompt 记录;",
3828
+ " 然后复述你理解的画面语言(纸感/笔触/颜色/版面/信息密度)。",
3829
+ "2. 新样张 = 已有风格提示词 + 只补「这张要试的画面」;不要重写 / 替换风格提示词,除非我明确要改风格。",
3830
+ "3. 要改风格时:先给我 styleBrief 草案,我确认后再 deck_style_update 保存,然后再出样张。",
3831
+ "4. 给 2–3 个不同版面的样张 brief,我确认一个再连续出(别一次同时出 3 个不同版面);",
3832
+ " 确认后用 deck_design_sample(designId, brief) 逐张出图,样张会存到 designs/" + design.abbr + "/ 并出现在 Designs 页;",
3833
+ " designId 必须逐字复制上面的完整 id(不要截断);不确定时直接传缩写「" + design.abbr + "」,工具会自动匹配。",
3834
+ "5. 每张样张的 brief + 实际 prompt 会自动记到 designs/" + design.abbr + "/samples.jsonl;追加样张时沿用同一份风格提示词。",
3835
+ "6. 出图 prompt 里不要写机构 / 客户品牌名(参考里的 McKinsey / BCG 等只给人看),统一用「顶级战略咨询机构提案质感」这类描述;品牌名会触发 image adapter 内容过滤、表现成 180s 超时。",
3836
+ "7. 不要改 deck 页面,不要编造文件路径。",
3837
+ "8. 若工具报「未知 design」:说明 id 写错/被截断,改用缩写「" + design.abbr + "」重试即可;",
3838
+ " design 已经存在,不要让我回 Designs 页重新初始化,也不要尝试 design/create。",
3839
+ ].join("\n");
3840
+ }
3841
+
3842
+ /** 点 deck 时复用/新建绑定该 deck 的会话(每个会话必须绑定 deck 或 design)。 */
3843
+ async function ensureDeckConversation(deckId) {
3844
+ const deck = (STATE.data?.decks || []).find((item) => item.deckId === deckId);
3845
+ const label = deck?.label || deckId;
3846
+ const target = chatTargetForBinding({ kind: "deck", id: deckId });
3847
+ // F-139:进入 deck 的默认会话 = 绑定会话里「最近真正用过的那个」,不是绑定的第一条
3848
+ // (最早绑的那个可能几天没说话)。iframe 里残留的旧会话不再优先——用户要的是最近在用的那个。
3849
+ const bound = chatSessionsForTarget(target);
3850
+ const chosen = bound[0] || null;
3851
+ const binding = chosen ? { sessionId: chosen.sessionId } : null;
3852
+ const result = await requestConversation({
3853
+ action: chosen ? "open" : "ensure",
3854
+ sessionId: chosen?.sessionId || "",
3855
+ kind: "deck",
3856
+ id: deckId,
3857
+ title: label,
3858
+ prompt: chosen ? "" : `本对话绑定 deck ${deckId}(${label})。后续所有修改都在这个 deck 上进行;先读 intent 与 script 概要,等我的指令,不要直接出图。`,
3859
+ });
3860
+ if (!result.ok) return result;
3861
+ if (!binding || binding.sessionId !== result.sessionId) {
3862
+ await bindConversation({ sessionId: result.sessionId, kind: "deck", id: deckId, title: label });
3863
+ }
3864
+ STATE.chatSessionId = result.sessionId;
3865
+ await refreshSessions();
3866
+ renderChatHeader();
3867
+ return result;
3868
+ }
3869
+
3870
+ /** Designs 页点一个风格项目:切到它的空白画布 + 打开/新建绑定该 design 的会话。 */
3871
+ async function openDesignProject(designId) {
3872
+ const project = (STATE.data?.designProjects || []).find((item) => item.designId === designId);
3873
+ if (!project) return;
3874
+ showDeck(project.canvasDeckId);
3875
+ const binding = bindingOf("design", designId);
3876
+ const title = `风格 · ${project.name}`;
3877
+ const result = await requestConversation({
3878
+ action: binding ? "open" : "ensure",
3879
+ sessionId: binding?.sessionId || "",
3880
+ kind: "design",
3881
+ id: designId,
3882
+ title,
3883
+ prompt: binding ? "" : buildDesignPrompt(project, project.canvasDeckId),
3884
+ });
3885
+ if (result.ok && (!binding || binding.sessionId !== result.sessionId)) {
3886
+ await bindConversation({ sessionId: result.sessionId, kind: "design", id: designId, title });
3887
+ } else if (!result.ok) {
3888
+ STATE.lastConvError = result.reason || "未知原因";
3889
+ }
3890
+ if (result.ok) {
3891
+ STATE.chatSessionId = result.sessionId;
3892
+ renderChatHeader();
3893
+ }
3894
+ }
3895
+
3896
+ /** Conversations 页点会话:切过去,并把工作台切到它绑定的 deck / design 画布。 */
3897
+ async function openBoundConversation(sessionId) {
3898
+ const binding = (STATE.data?.bindings || []).find((item) => item.sessionId === sessionId);
3899
+ const result = await requestConversation({ action: "open", sessionId });
3900
+ if (!result.ok) {
3901
+ getEl("homeGreetSub").textContent = `没能切到会话:${result.reason || "未知原因"}`;
3902
+ return;
3903
+ }
3904
+ if (binding?.kind === "deck") {
3905
+ showDeck(binding.id);
3906
+ return;
3907
+ }
3908
+ if (binding?.kind === "design") {
3909
+ const project = (STATE.data?.designProjects || []).find((item) => item.designId === binding.id);
3910
+ if (project) showDeck(project.canvasDeckId);
3911
+ }
3912
+ }
3913
+
3914
+ /* ---------------- 对话列头部:绑定信息 + 本对象会话切换(F-46) ---------------- */
3915
+
3916
+ /** 任意绑定对象 → 头部展示信息(deck 用 label,design 用 缩写 · 名称)。 */
3917
+ function chatTargetForBinding(binding) {
3918
+ if (!binding?.kind || !binding?.id) return null;
3919
+ if (binding.kind === "design") {
3920
+ const design = (STATE.data?.designProjects || []).find((item) => item.designId === binding.id);
3921
+ return {
3922
+ kind: "design",
3923
+ id: binding.id,
3924
+ chip: "Design",
3925
+ label: design ? `${design.abbr} · ${design.name}` : (binding.title || "未命名风格"),
3926
+ };
3927
+ }
3928
+ const deck = (STATE.data?.decks || []).find((item) => item.deckId === binding.id);
3929
+ return {
3930
+ kind: "deck",
3931
+ id: binding.id,
3932
+ chip: "Deck",
3933
+ label: deck?.label || binding.title || binding.id,
3934
+ };
3935
+ }
3936
+
3937
+ /** 当前工作台对应的绑定对象:普通 deck,或 design 的空白画布。 */
3938
+ function activeChatTarget() {
3939
+ const active = STATE.data?.active;
3940
+ if (!active?.deckId) return null;
3941
+ if (active.kind === "design-canvas") {
3942
+ return chatTargetForBinding({ kind: "design", id: active.designId || active.deckId });
3943
+ }
3944
+ return chatTargetForBinding({ kind: "deck", id: active.deckId });
3945
+ }
3946
+
3947
+ /** 当前 Chat 所在会话 id:优先读 iframe 里 shell 的 dsh.sessions.current(同源),退回本地记录。 */
3948
+ function currentChatSessionId() {
3949
+ try {
3950
+ const win = getEl("nativeChatFrame")?.contentWindow;
3951
+ const raw = win?.localStorage?.getItem("dsh.sessions.current");
3952
+ const sessionId = raw ? JSON.parse(raw).sessionId || "" : "";
3953
+ if (sessionId) return sessionId;
3954
+ } catch {
3955
+ /* 跨源 / 未加载时退回本地 */
3956
+ }
3957
+ return STATE.chatSessionId || "";
3958
+ }
3959
+
3960
+ /** 本 deck / design 的会话列表:只取绑定表里同一绑定对象的会话(F-46 ②)。 */
3961
+ function chatSessionsForTarget(target) {
3962
+ if (!target) return [];
3963
+ const live = new Map((STATE.sessions || []).map((session) => [session.sessionId, session]));
3964
+ const current = currentChatSessionId();
3965
+ return (STATE.data?.bindings || [])
3966
+ .filter((binding) => binding.kind === target.kind && binding.id === target.id)
3967
+ .map((binding) => {
3968
+ const session = live.get(binding.sessionId);
3969
+ const createdAt = binding.createdAt ? Date.parse(binding.createdAt) : 0;
3970
+ const updatedAt = session?.updatedAt ?? createdAt;
3971
+ const fallback = "新会话";
3972
+ return {
3973
+ sessionId: binding.sessionId,
3974
+ title: session?.title || fallback,
3975
+ running: Boolean(session?.running),
3976
+ updatedAt,
3977
+ current: binding.sessionId === current,
3978
+ };
3979
+ })
3980
+ .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
3981
+ }
3982
+
3983
+ /** 会话标题:第一个会话就叫 deck 名,之后「deck 名 · 会话 N」。 */
3984
+ function sessionTitleForTarget(target, index) {
3985
+ const label = target.label.length > 22 ? `${target.label.slice(0, 22)}…` : target.label;
3986
+ return index > 1 ? `${label} · 会话 ${index}` : label;
3987
+ }
3988
+
3989
+ async function renameSessionTitle(sessionId, title) {
3990
+ try {
3991
+ const value = await rpc("session/rename", { request: { sessionId, title } });
3992
+ return Boolean(value?.title);
3993
+ } catch {
3994
+ return false;
3995
+ }
3996
+ }
3997
+
3998
+ /** 早期会话标题来自首条 intro prompt(「本对话绑定 deck …」),一次性改成干净名字。 */
3999
+ const CHAT_TITLE_PREFIXES = ["本对话绑定 deck ", "本对话是「风格设计"];
4000
+
4001
+ async function normalizeBoundSessionTitles() {
4002
+ const live = new Map((STATE.sessions || []).map((session) => [session.sessionId, session]));
4003
+ const groups = new Map();
4004
+ for (const binding of STATE.data?.bindings || []) {
4005
+ const key = `${binding.kind}:${binding.id}`;
4006
+ if (!groups.has(key)) groups.set(key, []);
4007
+ groups.get(key).push(binding);
4008
+ }
4009
+ let changed = false;
4010
+ for (const list of groups.values()) {
4011
+ const target = chatTargetForBinding(list[0]);
4012
+ if (!target) continue;
4013
+ for (let index = 0; index < list.length; index += 1) {
4014
+ const binding = list[index];
4015
+ const title = live.get(binding.sessionId)?.title || "";
4016
+ if (!CHAT_TITLE_PREFIXES.some((prefix) => title.startsWith(prefix))) continue;
4017
+ if (await renameSessionTitle(binding.sessionId, sessionTitleForTarget(target, index + 1))) changed = true;
4018
+ }
4019
+ }
4020
+ return changed;
4021
+ }
4022
+
4023
+ function renderChatSessionMenu(sessions, target) {
4024
+ const menu = getEl("chatSessionMenu");
4025
+ const head = `仅显示绑定 ${target.chip} · ${esc(target.label)} 的会话`;
4026
+ // F-118:会话可能因为换绑/测试/误点新建而掉成「未绑定」,之前菜单里根本找不到它,
4027
+ // 用户就只能看到一个空会话。这里把最近的未绑定会话也列出来,点开后可绑到当前对象。
4028
+ const boundIds = new Set((STATE.data?.bindings || []).map((binding) => binding.sessionId));
4029
+ const current = currentChatSessionId();
4030
+ const unbound = (STATE.sessions || [])
4031
+ .filter((session) => !boundIds.has(session.sessionId) && !session.blank)
4032
+ .slice(0, 5)
4033
+ .map((session) => ({
4034
+ sessionId: session.sessionId,
4035
+ title: session.title || "未命名会话",
4036
+ running: Boolean(session.running),
4037
+ updatedAt: session.updatedAt,
4038
+ current: session.sessionId === current,
4039
+ }));
4040
+ const itemHtml = (session) => `<button type="button" class="chat-session-item${session.current ? " active" : ""}" data-chat-session="${esc(session.sessionId)}">
4041
+ <b>${esc(session.title)}</b>
4042
+ <span>${session.running ? "进行中" : esc(fmtDayTime(session.updatedAt || Date.now()))}${session.current ? " · 当前" : ""}</span>
4043
+ </button>`;
4044
+ menu.innerHTML = (sessions.length
4045
+ ? `<div class="chat-session-menu-head">${head}</div>` + sessions.map(itemHtml).join("")
4046
+ : `<div class="chat-session-menu-head">${head}</div><p class="chat-session-empty">还没有会话;点 + 新建一个(会自动绑定)。</p>`)
4047
+ + (unbound.length
4048
+ ? `<div class="chat-session-menu-head">未绑定的会话(点开后可绑到本 ${target.chip})</div>` + unbound.map(itemHtml).join("")
4049
+ : "");
4050
+ menu.querySelectorAll("[data-chat-session]").forEach((node) => node.addEventListener("click", async () => {
4051
+ menu.hidden = true;
4052
+ const sessionId = node.dataset.chatSession;
4053
+ const result = await requestConversation({ action: "open", sessionId });
4054
+ if (!result.ok) {
4055
+ getEl("chatSessionBtn").title = `切换失败:${result.reason || "未知原因"}`;
4056
+ return;
4057
+ }
4058
+ STATE.chatSessionId = sessionId;
4059
+ await refreshSessions();
4060
+ renderChatHeader();
4061
+ }));
4062
+ }
4063
+
4064
+ function toggleChatSessionMenu() {
4065
+ const menu = getEl("chatSessionMenu");
4066
+ if (!menu.hidden) {
4067
+ menu.hidden = true;
4068
+ return;
4069
+ }
4070
+ const target = activeChatTarget();
4071
+ if (!target) return;
4072
+ renderChatSessionMenu(chatSessionsForTarget(target), target);
4073
+ menu.hidden = false;
4074
+ }
4075
+
4076
+ /** 查一个会话是不是空白(新建)——只有空白会话才自动绑定,有历史的会话要求手动确认。 */
4077
+ async function findChatSessionInfo(sessionId) {
4078
+ const cache = STATE.chatSessionInfoCache;
4079
+ if (cache && cache.sessionId === sessionId && Date.now() - cache.at < 15000) return cache.info;
4080
+ let info = null;
4081
+ try {
4082
+ const value = await rpc("session/list", { _request: {} });
4083
+ const item = (value?.items || []).find((session) => session.sessionId === sessionId);
4084
+ if (item) {
4085
+ info = {
4086
+ sessionId,
4087
+ title: item.projections?.values?.title || "",
4088
+ blank: Boolean(item.blank),
4089
+ updatedAt: item.updatedAt ?? null,
4090
+ };
4091
+ }
4092
+ } catch {
4093
+ /* 列不出来就按「未知」处理,宁可手动绑定 */
4094
+ }
4095
+ STATE.chatSessionInfoCache = { sessionId, info, at: Date.now() };
4096
+ return info;
4097
+ }
4098
+
4099
+ /** F-107:内嵌 DSH 原生 Chat 自己「新建会话」不会经过工作台,这里补同步与自动绑定。 */
4100
+ async function syncChatBinding(force = false) {
4101
+ if (STATE.chatBindingSyncing) return;
4102
+ const target = activeChatTarget();
4103
+ const current = currentChatSessionId();
4104
+ if (!target || !current) return;
4105
+ const bindings = STATE.data?.bindings || [];
4106
+ const mine = bindings.find((binding) => binding.sessionId === current);
4107
+ if (mine && mine.kind === target.kind && mine.id === target.id) {
4108
+ if (STATE.chatBindingState) { STATE.chatBindingState = ""; renderChatHeader(); }
4109
+ return;
4110
+ }
4111
+ if (mine) { // 这个会话绑在别的 deck / design 上:不抢,只如实提示
4112
+ STATE.chatBindingState = "other";
4113
+ renderChatHeader();
4114
+ return;
4115
+ }
4116
+ const known = (STATE.sessions || []).find((session) => session.sessionId === current) || (await findChatSessionInfo(current));
4117
+ const fresh = !known || known.blank;
4118
+ if (!fresh && !force) {
4119
+ STATE.chatBindingState = "manual";
4120
+ renderChatHeader();
4121
+ return;
4122
+ }
4123
+ // F-118:当前是个空白未绑定会话,但这份 deck 已经有绑好的会话——别把这个空会话绑上去
4124
+ // (工作台左边的 Chat 会看起来像「空的/坏了」),直接切回最近的那个有内容的会话。
4125
+ if (fresh && !force) {
4126
+ const existing = chatSessionsForTarget(target);
4127
+ if (existing.length) {
4128
+ STATE.chatBindingSyncing = true;
4129
+ try {
4130
+ const opened = await requestConversation({ action: "open", sessionId: existing[0].sessionId });
4131
+ if (opened.ok) {
4132
+ STATE.chatSessionId = opened.sessionId;
4133
+ await refreshSessions();
4134
+ renderChatHeader();
4135
+ }
4136
+ } catch {
4137
+ /* 切不回去就退回自动绑定 */
4138
+ } finally {
4139
+ STATE.chatBindingSyncing = false;
4140
+ }
4141
+ if (currentChatSessionId() === existing[0].sessionId) return;
4142
+ STATE.chatBindingSyncing = true;
4143
+ }
4144
+ }
4145
+ STATE.chatBindingSyncing = true;
4146
+ try {
4147
+ const ok = await bindConversation({ sessionId: current, kind: target.kind, id: target.id, title: target.label });
4148
+ if (ok) {
4149
+ STATE.chatBindingState = "";
4150
+ // 绑定落表后立刻刷徽标:e2e / 用户可能马上看头部,别等 refreshSessions 回来还显示「未绑定 / 其他绑定」
4151
+ renderChatHeader();
4152
+ await refreshSessions();
4153
+ renderChatHeader();
4154
+ }
4155
+ } finally {
4156
+ STATE.chatBindingSyncing = false;
4157
+ }
4158
+ }
4159
+
4160
+ /** 手动把当前会话绑到本 deck / design(头部「未绑定」点击时用)。 */
4161
+ async function bindCurrentChatToTarget() {
4162
+ const target = activeChatTarget();
4163
+ const current = currentChatSessionId();
4164
+ if (!target || !current) return;
4165
+ const ok = await bindConversation({ sessionId: current, kind: target.kind, id: target.id, title: target.label });
4166
+ if (ok) {
4167
+ STATE.chatBindingState = "";
4168
+ renderChatHeader();
4169
+ await refreshSessions();
4170
+ renderChatHeader();
4171
+ }
4172
+ }
4173
+
4174
+ /** 监听内嵌 Chat 的「新建会话 / 切换会话」:storage 事件 + 点击 + 兜底轮询。 */
4175
+ function setupChatBindingSync(frame) {
4176
+ if (STATE.chatBindingHooked) return;
4177
+ STATE.chatBindingHooked = true;
4178
+ try {
4179
+ const doc = frame?.contentDocument;
4180
+ if (doc) {
4181
+ doc.addEventListener("click", (event) => {
4182
+ const button = event.target?.closest?.('[aria-label="New session"], .hHd-Xa_newSession');
4183
+ if (!button) return;
4184
+ setTimeout(() => syncChatBinding(), 250);
4185
+ setTimeout(() => syncChatBinding(), 1200);
4186
+ }, true);
4187
+ }
4188
+ } catch {
4189
+ /* 跨源 / 未加载时靠轮询 */
4190
+ }
4191
+ try {
4192
+ frame?.contentWindow?.addEventListener("storage", (event) => {
4193
+ if (!event.key || event.key === "dsh.sessions.current") syncChatBinding();
4194
+ });
4195
+ } catch {
4196
+ /* ignore */
4197
+ }
4198
+ setInterval(() => syncChatBinding(), 2500);
4199
+ syncChatBinding();
4200
+ }
4201
+
4202
+ /** 头部固定绑定信息 + 当前会话标题(不随会话切换消失)。 */
4203
+ function renderChatHeader() {
4204
+ const chip = getEl("chatBindChip");
4205
+ const title = getEl("chatBindTitle");
4206
+ const label = getEl("chatSessionLabel");
4207
+ const count = getEl("chatSessionCount");
4208
+ const target = activeChatTarget();
4209
+ if (!target) {
4210
+ chip.textContent = "—";
4211
+ chip.className = "bind-chip unbound";
4212
+ title.textContent = "没有绑定对象";
4213
+ label.textContent = "会话";
4214
+ count.textContent = "";
4215
+ return;
4216
+ }
4217
+ const sessionId = currentChatSessionId();
4218
+ const mine = (STATE.data?.bindings || []).find((binding) => binding.sessionId === sessionId);
4219
+ const boundHere = mine && mine.kind === target.kind && mine.id === target.id;
4220
+ if (!boundHere) {
4221
+ // F-107:当前会话不是这个 deck / design 的——别假装已绑定,给一键绑定入口
4222
+ chip.textContent = STATE.chatBindingState === "other" ? "其他绑定" : "未绑定";
4223
+ chip.className = "bind-chip unbound";
4224
+ chip.title = STATE.chatBindingState === "other"
4225
+ ? `当前会话绑定在别的对象上(${mine?.kind} ${mine?.id}),点这里改绑到本 ${target.chip}`
4226
+ : `当前会话还没绑定;点这里绑到本 ${target.chip}`;
4227
+ title.textContent = STATE.chatBindingState === "other" ? "会话绑在别的对象上(点这里改绑)" : "会话未绑定(点这里绑定)";
4228
+ title.title = chip.title;
4229
+ const known = (STATE.sessions || []).find((session) => session.sessionId === sessionId);
4230
+ label.textContent = known ? known.title : "新会话";
4231
+ label.title = label.textContent;
4232
+ count.textContent = "";
4233
+ getEl("chatSessionBtn").title = `当前会话未绑定本 ${target.chip};点左侧徽标可绑定`;
4234
+ return;
4235
+ }
4236
+ chip.textContent = target.chip;
4237
+ chip.className = `bind-chip ${target.kind}`;
4238
+ chip.title = `${target.chip} · ${target.label}`;
4239
+ title.textContent = target.label;
4240
+ title.title = `${target.chip} · ${target.label}`;
4241
+ const sessions = chatSessionsForTarget(target);
4242
+ const current = sessions.find((session) => session.sessionId === sessionId) || sessions[0] || null;
4243
+ label.textContent = current ? current.title : "还没有会话";
4244
+ label.title = label.textContent;
4245
+ count.textContent = sessions.length > 1 ? String(sessions.length) : "";
4246
+ getEl("chatSessionBtn").title = `切换本 ${target.chip} 的会话(${sessions.length} 个)`;
4247
+ if (!getEl("chatSessionMenu").hidden) renderChatSessionMenu(sessions, target);
4248
+ }
4249
+
4250
+ /** + 新建会话:建完立即绑定当前 deck / design(不再产生未绑定会话)。 */
4251
+ async function newChatForTarget() {
4252
+ const target = activeChatTarget();
4253
+ if (!target) return;
4254
+ const button = getEl("newChatBtn");
4255
+ button.disabled = true;
4256
+ try {
4257
+ const result = await requestConversation({ action: "ensure", kind: target.kind, id: target.id, title: target.label });
4258
+ if (!result.ok || !result.sessionId) {
4259
+ getEl("chatSessionBtn").title = `新建会话失败:${result.reason || "未知原因"}`;
4260
+ return;
4261
+ }
4262
+ STATE.chatSessionId = result.sessionId;
4263
+ await bindConversation({ sessionId: result.sessionId, kind: target.kind, id: target.id, title: target.label });
4264
+ await refreshSessions();
4265
+ renderChatHeader();
4266
+ } finally {
4267
+ button.disabled = false;
4268
+ }
4269
+ }
4270
+
4271
+ /* ---------------- 样张:粘贴 / 拖拽 / 上传(F-55) ---------------- */
4272
+
4273
+ let ADD_STYLE_SAMPLES = [];
4274
+
4275
+ function fileToDataUrl(file) {
4276
+ return new Promise((resolve, reject) => {
4277
+ const reader = new FileReader();
4278
+ reader.onload = () => resolve(String(reader.result || ""));
4279
+ reader.onerror = () => reject(reader.error || new Error("读取文件失败"));
4280
+ reader.readAsDataURL(file);
4281
+ });
4282
+ }
4283
+
4284
+ function renderAddStyleThumbs() {
4285
+ const box = getEl("asThumbs");
4286
+ box.innerHTML = ADD_STYLE_SAMPLES.map((sample, index) => `
4287
+ <div class="sample-thumb" title="${esc(sample.name)}">
4288
+ <img src="${esc(sample.data)}" alt="${esc(sample.name)}">
4289
+ <button type="button" class="sample-remove" data-sample-remove="${index}" title="移除">✕</button>
4290
+ </div>`).join("");
4291
+ box.querySelectorAll("[data-sample-remove]").forEach((node) => node.addEventListener("click", () => {
4292
+ ADD_STYLE_SAMPLES.splice(Number(node.dataset.sampleRemove), 1);
4293
+ renderAddStyleThumbs();
4294
+ }));
4295
+ if (getEl("asDropHint")) getEl("asDropHint").hidden = ADD_STYLE_SAMPLES.length > 0;
4296
+ }
4297
+
4298
+ async function addStyleFiles(files) {
4299
+ const images = [...(files || [])].filter((file) => /^image\/(png|jpeg|webp)$/.test(file.type));
4300
+ if (!images.length) return;
4301
+ for (const file of images) {
4302
+ if (file.size > 12 * 1024 * 1024) {
4303
+ getEl("asState").textContent = `${file.name} 超过 12MB,跳过`;
4304
+ continue;
4305
+ }
4306
+ ADD_STYLE_SAMPLES.push({ name: file.name || "sample", mime: file.type, data: await fileToDataUrl(file) });
4307
+ }
4308
+ renderAddStyleThumbs();
4309
+ }
4310
+
4311
+ function clipboardImageFiles(event) {
4312
+ const items = event.clipboardData?.items || [];
4313
+ const files = [];
4314
+ for (const item of items) {
4315
+ if (item.kind !== "file" || !String(item.type || "").startsWith("image/")) continue;
4316
+ const file = item.getAsFile();
4317
+ if (file) files.push(file);
4318
+ }
4319
+ return files;
4320
+ }
4321
+
4322
+ async function uploadSamplesToDesign(design, files) {
4323
+ const images = [...(files || [])].filter((file) => /^image\/(png|jpeg|webp)$/.test(file.type));
4324
+ if (!images.length) return;
4325
+ const state = getEl("pvSampleState");
4326
+ if (state) state.textContent = "正在上传样张…";
4327
+ try {
4328
+ const filesPayload = [];
4329
+ for (const file of images) filesPayload.push({ name: file.name || "sample", mime: file.type, data: await fileToDataUrl(file) });
4330
+ await postJson("design-sample", { designId: design.designId, files: filesPayload });
4331
+ await loadWorkbench(STATE.deckId);
4332
+ const fresh = (STATE.data?.designProjects || []).find((item) => item.designId === design.designId);
4333
+ if (fresh) {
4334
+ PREVIEW.items = fresh.samples || [];
4335
+ showPreviewAt(Math.max(0, (fresh.samples || []).length - 1));
4336
+ }
4337
+ if (state) state.textContent = `已加 ${filesPayload.length} 张 ✓`;
4338
+ } catch (error) {
4339
+ if (state) state.textContent = `上传失败:${error?.message ?? error}`;
4340
+ }
4341
+ }
4342
+
4343
+ document.addEventListener("paste", async (event) => {
4344
+ const files = clipboardImageFiles(event);
4345
+ if (!files.length) return;
4346
+ if (!getEl("addStyleModal").hidden) {
4347
+ event.preventDefault();
4348
+ await addStyleFiles(files);
4349
+ return;
4350
+ }
4351
+ if (!getEl("previewModal").hidden && PREVIEW.design) {
4352
+ event.preventDefault();
4353
+ await uploadSamplesToDesign(PREVIEW.design, files);
4354
+ return;
4355
+ }
4356
+ const canvasProject = designCanvasProject(STATE.data?.active);
4357
+ if (canvasProject && !getEl("workspaceView")?.hidden) {
4358
+ event.preventDefault();
4359
+ await uploadSamplesToDesign({ designId: canvasProject.designId }, files);
4360
+ }
4361
+ });
4362
+
4363
+ /* ---------------- 添加风格(popup → 空白画布 + design 会话) ---------------- */
4364
+
4365
+ function openAddStyleModal() {
4366
+ if (EMBED) {
4367
+ // embed 模式同样支持:会话桥会打到宿主壳。
4368
+ }
4369
+ getEl("asName").value = "";
4370
+ getEl("asAbbr").value = "";
4371
+ getEl("asDesc").value = "";
4372
+ getEl("asConstraints").value = "";
4373
+ getEl("asRef").value = "";
4374
+ ADD_STYLE_SAMPLES = [];
4375
+ renderAddStyleThumbs();
4376
+ getEl("asState").textContent = "";
4377
+ getEl("asAck").textContent = "";
4378
+ getEl("asSubmit").disabled = false;
4379
+ getEl("addStyleModal").hidden = false;
4380
+ getEl("asName").focus();
4381
+ }
4382
+
4383
+ function closeAddStyleModal() {
4384
+ getEl("addStyleModal").hidden = true;
4385
+ }
4386
+
4387
+ async function submitAddStyle() {
4388
+ const name = getEl("asName").value.trim();
4389
+ const abbr = getEl("asAbbr").value.trim();
4390
+ const description = getEl("asDesc").value.trim();
4391
+ if (!name || !abbr || !description) {
4392
+ getEl("asState").textContent = "名称、缩写、画面描述都要填。";
4393
+ return;
4394
+ }
4395
+ getEl("asSubmit").disabled = true;
4396
+ getEl("asState").textContent = "正在建风格与空白画布…";
4397
+ getEl("asAck").textContent = "";
4398
+ try {
4399
+ const res = await fetch("/api/deck-studio.design-create", {
4400
+ method: "POST",
4401
+ headers: { "content-type": "application/json" },
4402
+ body: JSON.stringify({
4403
+ name,
4404
+ abbr,
4405
+ description,
4406
+ constraints: getEl("asConstraints").value.trim(),
4407
+ reference: getEl("asRef").value.trim(),
4408
+ }),
4409
+ });
4410
+ const data = await res.json().catch(() => ({}));
4411
+ if (!res.ok || !data?.ok) throw new Error(data?.error?.message || `HTTP ${res.status}`);
4412
+ const design = data.design;
4413
+ const canvasDeckId = data.deckId;
4414
+ if (ADD_STYLE_SAMPLES.length) {
4415
+ getEl("asState").textContent = `画布建好了,正在上传 ${ADD_STYLE_SAMPLES.length} 张样张…`;
4416
+ await postJson("design-sample", { designId: design.designId, files: ADD_STYLE_SAMPLES });
4417
+ ADD_STYLE_SAMPLES = [];
4418
+ renderAddStyleThumbs();
4419
+ }
4420
+ getEl("asState").textContent = "正在新建 design 会话并让它开始工作…";
4421
+ const conv = await requestConversation({
4422
+ action: "ensure",
4423
+ kind: "design",
4424
+ id: design.designId,
4425
+ title: `风格 · ${design.name}`,
4426
+ prompt: buildDesignPrompt(design, canvasDeckId),
4427
+ });
4428
+ if (conv.ok && conv.sessionId) {
4429
+ await bindConversation({ sessionId: conv.sessionId, kind: "design", id: design.designId, title: `风格 · ${design.name}` });
4430
+ getEl("asAck").textContent = "已创建 ✓ 左侧 Chat 已开始工作";
4431
+ } else {
4432
+ getEl("asAck").textContent = `已创建;会话没能自动打开(${conv.reason || "未知原因"}),去 DSH 对话里继续`;
4433
+ }
4434
+ await loadWorkbench(canvasDeckId);
4435
+ showDeck(canvasDeckId);
4436
+ setTimeout(closeAddStyleModal, 900);
4437
+ } catch (error) {
4438
+ getEl("asState").textContent = `创建失败:${error?.message ?? error}`;
4439
+ } finally {
4440
+ getEl("asSubmit").disabled = false;
4441
+ }
4442
+ }
4443
+
4444
+ getEl("asDrop").addEventListener("click", () => getEl("asFile").click());
4445
+ getEl("asDrop").addEventListener("dragover", (event) => { event.preventDefault(); getEl("asDrop").classList.add("dragging"); });
4446
+ getEl("asDrop").addEventListener("dragleave", () => getEl("asDrop").classList.remove("dragging"));
4447
+ getEl("asDrop").addEventListener("drop", async (event) => {
4448
+ event.preventDefault();
4449
+ getEl("asDrop").classList.remove("dragging");
4450
+ await addStyleFiles(event.dataTransfer?.files);
4451
+ });
4452
+ getEl("asFile").addEventListener("change", async (event) => {
4453
+ await addStyleFiles(event.target.files);
4454
+ event.target.value = "";
4455
+ });
4456
+ getEl("pvAddSample").addEventListener("click", () => getEl("pvFile").click());
4457
+ getEl("pvFile").addEventListener("change", async (event) => {
4458
+ if (PREVIEW.design) await uploadSamplesToDesign(PREVIEW.design, event.target.files);
4459
+ event.target.value = "";
4460
+ });
4461
+ getEl("asClose").addEventListener("click", closeAddStyleModal);
4462
+ getEl("asSubmit").addEventListener("click", submitAddStyle);
4463
+ getEl("addStyleModal").addEventListener("click", (event) => { if (event.target === getEl("addStyleModal")) closeAddStyleModal(); });
4464
+
4465
+ getEl("addPageBtn").addEventListener("click", openAddPageModal);
4466
+ getEl("apClose").addEventListener("click", closeAddPageModal);
4467
+ getEl("apCopy").addEventListener("click", () => {
4468
+ const text = buildAddPagePrompt();
4469
+ if (!text) return;
4470
+ copyText(text);
4471
+ getEl("apAck").textContent = "已复制 · 去 DSH 原生 Chat 粘贴发送";
4472
+ });
4473
+ getEl("apSend").addEventListener("click", sendAddPageToChat);
4474
+ getEl("addPageModal").addEventListener("click", (event) => { if (event.target === getEl("addPageModal")) closeAddPageModal(); });
4475
+ document.addEventListener("keydown", (event) => {
4476
+ if (event.key === "Escape" && !getEl("addPageModal").hidden) closeAddPageModal();
4477
+ });
4478
+ /* handoff 现在是真实导出(交接包 zip),不再是占位对话 */
4479
+
4480
+ /* F-150:手册里的 install.md / styles.md 等链接在应用内打开(此前会跳到不存在的 URL,显示空白)。 */
4481
+ function bindManualDocLinks(box) {
4482
+ box.querySelectorAll(".manual-page a[href]").forEach((a) => {
4483
+ const href = a.getAttribute("href") || "";
4484
+ if (/^(https?:|mailto:|#)/.test(href) || !/\.md(#.*)?$/.test(href)) return;
4485
+ a.addEventListener("click", (event) => {
4486
+ event.preventDefault();
4487
+ openManualDoc(box, href.split("#")[0]);
4488
+ });
4489
+ });
4490
+ }
4491
+
4492
+ async function openManualDoc(box, docPath) {
4493
+ const name = String(docPath || "").replace(/^docs\//, "");
4494
+ STATE.manualDoc = name;
4495
+ box.innerHTML = `<div class="section-head"><h2>${esc(name)}</h2><span class="small">应用内阅读 · 返回手册</span></div><p class="engine-note">加载中…</p>`;
4496
+ try {
4497
+ const res = await fetch(`/api/deck-studio.manual-doc?path=${encodeURIComponent(name)}`);
4498
+ const data = await res.json();
4499
+ if (!data?.ok) throw new Error(data?.error?.message || "文档加载失败");
4500
+ box.innerHTML = `<div class="section-head"><h2>${esc(name)}</h2><button class="btn ghost" id="manualBack" type="button">← 返回使用手册</button></div>
4501
+ <article class="md manual-page">${mdToHtml(data.markdown || "", { anchors: true, imageSrc: manualImageSrc })}</article>`;
4502
+ getEl("manualBack")?.addEventListener("click", () => backToManual(box));
4503
+ bindManualDocLinks(box);
4504
+ } catch (error) {
4505
+ box.innerHTML = `<div class="section-head"><h2>${esc(name)}</h2><button class="btn ghost" id="manualBack" type="button">← 返回使用手册</button></div>
4506
+ <p class="engine-note">打不开这份文档:${esc(error.message)}(公开包里只带 install / styles / sample-deck / compat / productization 与手册本身)</p>`;
4507
+ getEl("manualBack")?.addEventListener("click", () => backToManual(box));
4508
+ }
4509
+ }
4510
+
4511
+ function backToManual(box) {
4512
+ STATE.manualDoc = null;
4513
+ renderManualHome(box);
4514
+ }
4515
+
4516
+ /* ---------------- F-140:帮助 / FAQ(与 deck_help 同一份内容) ---------------- */
4517
+ /* ---------------- 启动 ---------------- */
4518
+ (async function boot() {
4519
+ applySavedLayout();
4520
+ bindSplitters();
4521
+ setupNativeChat();
4522
+ watchUiVersion();
4523
+ if (EMBED) {
4524
+ document.body.classList.add("embed");
4525
+ getEl("embedBack").hidden = false;
4526
+ if (!location.hash.startsWith("#/deck")) history.replaceState(null, "", "#/deck");
4527
+ }
4528
+ getEl("embedBack").addEventListener("click", () => window.parent?.postMessage({ type: "dsh-deck-studio:close" }, "*"));
4529
+ await loadWorkbench();
4530
+ await refreshSessions();
4531
+ getEl("save").textContent = EMBED ? "对话在 DSH 原生 Chat" : "对话在左侧 · DSH 原生";
4532
+ // F-69:只有 profile 开了 hideShellSidebar,宿主左侧栏才会默认收起;此时顶栏给一个切换入口。
4533
+ if (!EMBED && window.parent !== window && STATE.data?.hideShellSidebar === true) {
4534
+ const shellBtn = getEl("shellSidebarBtn");
4535
+ shellBtn.hidden = false;
4536
+ shellBtn.addEventListener("click", () => {
4537
+ window.parent.postMessage({ type: "dsh-deck-studio:toggle-shell-sidebar" }, "*");
4538
+ });
4539
+ }
4540
+ if (location.hash.startsWith("#/deck")) {
4541
+ STATE.view = "deck";
4542
+ getEl("homeView").style.display = "none";
4543
+ getEl("workspaceView").style.display = "grid";
4544
+ switchChatToBoundSession().catch(() => {});
4545
+ } else {
4546
+ const match = location.hash.match(/^#\/home\/(decks|designs|resources|conversations|manual)/);
4547
+ STATE.homeSection = match ? match[1] : "decks";
4548
+ showHome();
4549
+ }
4550
+ setInterval(() => loadWorkbench(STATE.deckId), 5000);
4551
+ })();
4552
+ </script>
4553
+ </body>
4554
+ </html>