dsh-plugin-deck-studio 0.2.25 → 0.2.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client.js +128 -46
- package/cordis.example.yml +6 -1
- package/cordis.patch.yml +6 -1
- package/index.js +124 -16
- package/lib/data-dir.js +9 -0
- package/lib/deck-docs.js +54 -24
- package/lib/deck-workspace.js +290 -55
- package/lib/faq.js +6 -3
- package/lib/proposal-decision.js +138 -7
- package/lib/render.js +84 -2
- package/lib/tools.js +168 -3
- package/package.json +1 -1
- package/seeds/docs/install.md +7 -0
- package/ui/workbench.html +175 -39
package/ui/workbench.html
CHANGED
|
@@ -186,7 +186,7 @@
|
|
|
186
186
|
<p class="nd-tip">先定「讲给谁、讲什么、想带走什么」+ 视觉风格。创建后会开出源目录与 9 份文档骨架,然后在对话里继续补资料、改 script,直到可以出图。<br>新项目保存位置:<code id="ndStoragePath">正在读取配置…</code>。<span id="ndStorageNote"></span></p>
|
|
187
187
|
<label class="field"><span>主题 / 标题</span><input id="ndTitle" required placeholder="例:XX 业务场景的 AI 落地分享"></label>
|
|
188
188
|
<label class="field"><span>项目目录名(可选,英文小写中划线;留空自动生成)</span><input id="ndDirName" placeholder="例:talk-ai-transformation-6-stages"></label>
|
|
189
|
-
<label class="field"><span>页数(占位页 1–40
|
|
189
|
+
<label class="field"><span>页数(占位页 1–40;创建后还能在对话里追加或插页)</span><input id="ndPages" type="number" min="1" max="40" inputmode="numeric" value="6"></label>
|
|
190
190
|
<label class="field"><span>内容密度(一页正文 / 画面要求的目标字数;之后可在 deck 工具栏改)</span>
|
|
191
191
|
<select id="ndDensity">
|
|
192
192
|
<option value="auto" selected>自动 —— 由 Agent 按内容判断每页长短(不设固定字数)</option>
|
|
@@ -426,8 +426,8 @@
|
|
|
426
426
|
<button class="modal-close" id="apClose" title="关闭">✕</button>
|
|
427
427
|
</div>
|
|
428
428
|
<div class="modal-body">
|
|
429
|
-
<p class="engine-note">填完后点「发送到 Chat」:这段会作为一条完整指令发给 Agent
|
|
430
|
-
<div class="field"><label
|
|
429
|
+
<p class="engine-note">填完后点「发送到 Chat」:这段会作为一条完整指令发给 Agent——追加到末尾调 <code>deck_script_add_page</code>,插到中间或开场位调 <code>deck_script_insert_page</code>;Agent 先出提案,我确认后才落 revision。插页会重排页号,既有页连同已出的图一起后移(图跟着内容走),<b>不需要重出整份</b>。</p>
|
|
430
|
+
<div class="field"><label>插入位置(默认追加到最后一页之后;也可选任意一页之后,或 P01 之前当新的开场位)</label><select id="apAfter"></select></div>
|
|
431
431
|
<div class="field"><label>页面标题(必填)</label><input id="apTitle" placeholder="例:一张工作观察卡,把五个问题变成检查栏"></div>
|
|
432
432
|
<div class="field"><label>这一页要回答什么(一句话主张)</label><input id="apClaim" placeholder="例:先看见一段工作,再决定 AI 应该进入哪一步"></div>
|
|
433
433
|
<div class="field"><label>页面内容 / 结构(一条一行,会原样变成该页正文)</label><textarea id="apBody" rows="6" placeholder="例:
|
|
@@ -830,9 +830,15 @@ function mdToHtml(markdown, options = {}) {
|
|
|
830
830
|
}
|
|
831
831
|
|
|
832
832
|
/* ---------------- 数据加载 ---------------- */
|
|
833
|
+
/* F-205:并发保护。导入 / 切 deck 会连着打好几次 workbench 接口,先发的旧请求如果后回来,
|
|
834
|
+
* 会把刚切到的新 deck 覆盖成旧数据(表现:导入成功却停在上一个 / 第一个 deck)。
|
|
835
|
+
* 只让**最后发起**的那次结果落地。 */
|
|
836
|
+
let LOAD_SEQ = 0;
|
|
833
837
|
async function loadWorkbench(deckId = null) {
|
|
838
|
+
const seq = ++LOAD_SEQ;
|
|
834
839
|
try {
|
|
835
840
|
const next = await route("workbench", deckId ? { deckId } : undefined);
|
|
841
|
+
if (seq !== LOAD_SEQ) return;
|
|
836
842
|
const active = next.active || {};
|
|
837
843
|
const signature = JSON.stringify([
|
|
838
844
|
active.deckId,
|
|
@@ -865,6 +871,7 @@ async function loadWorkbench(deckId = null) {
|
|
|
865
871
|
}
|
|
866
872
|
}
|
|
867
873
|
} catch (error) {
|
|
874
|
+
if (seq !== LOAD_SEQ) return;
|
|
868
875
|
STATE.demo = true;
|
|
869
876
|
STATE.data = demoData();
|
|
870
877
|
renderChrome();
|
|
@@ -1521,11 +1528,16 @@ function renderDataDirBanner(data) {
|
|
|
1521
1528
|
: "⚠ 这本 deck 的正文和图片不在当前数据目录";
|
|
1522
1529
|
const currentRoot = dd?.dataRoot?.value || banner.dataRoot || "—";
|
|
1523
1530
|
const sourceNote = dd?.sourceLabel ? `(${esc(dd.sourceLabel)})` : "";
|
|
1531
|
+
// 指针赢过 profile 时把「为什么」写出来:否则只看到「当前 = 指针」,不知道 profile 里还写着另一个目录。
|
|
1532
|
+
const conflict = dd?.pointerConflict && dd.pointerConflict.profileDataRoot !== dd?.dataRoot?.value ? dd.pointerConflict : null;
|
|
1533
|
+
const conflictLine = conflict
|
|
1534
|
+
? `<span class="data-dir-line data-dir-conflict">你的 profile 里配的是 <code>${esc(conflict.profileDataRoot)}</code>,但指针文件把它改成了上面这个目录${conflict.setBy ? `(${esc(conflict.setBy)})` : ""}——<b>指针优先</b>。点「切到这个目录」只改指针,不会删改任何 deck 文件。</span>`
|
|
1535
|
+
: "";
|
|
1524
1536
|
const candidates = banner.candidates || [];
|
|
1525
1537
|
const rows = candidates.length
|
|
1526
1538
|
? candidates.map((candidate) => `<div class="data-dir-candidate"><span class="data-dir-path">已发现:${esc(candidate.dataRoot)}</span><span class="data-dir-count">(${Number(candidate.deckCount) || 0} 本 deck · ${Number(candidate.imageCount) || 0} 张图)</span><button class="btn ghost" data-data-root="${esc(candidate.dataRoot)}"${envOverride ? " disabled" : ""}>切到这个目录</button></div>`).join("")
|
|
1527
1539
|
: `<div class="data-dir-candidate data-dir-empty"><span>没找到这本 deck 的历史数据。如果你在另一个 DSH 实例里出过图,请在那个实例的『设置』里看它显示的数据目录,然后把它填到下面。</span><input id="dataDirManualInput" placeholder="/绝对路径"${envOverride ? " disabled" : ""}><button class="btn ghost" id="dataDirManualSwitch"${envOverride ? " disabled" : ""}>用这个目录</button></div>`;
|
|
1528
|
-
box.innerHTML = `<div class="data-dir-banner-body"><div class="data-dir-banner-main"><b>${title}</b><span class="data-dir-line" title="换端口不改数据,换 dataRoot 才改数据。">当前:<code>${esc(currentRoot)}</code>${sourceNote}</span>${rows}</div><div class="data-dir-banner-actions"><button class="btn ghost" id="dataDirOpenSettings">打开设置</button><button class="btn ghost" id="dataDirCopyDiag">复制诊断信息</button>${envOverride ? `<span class="data-dir-readonly">由环境变量 ${esc(dd?.envOverride?.name || "DSH_DECK_DATA_ROOT")} 指定(只读)</span>` : ""}</div></div>`;
|
|
1540
|
+
box.innerHTML = `<div class="data-dir-banner-body"><div class="data-dir-banner-main"><b>${title}</b><span class="data-dir-line" title="换端口不改数据,换 dataRoot 才改数据。">当前:<code>${esc(currentRoot)}</code>${sourceNote}</span>${conflictLine}${rows}</div><div class="data-dir-banner-actions"><button class="btn ghost" id="dataDirOpenSettings">打开设置</button><button class="btn ghost" id="dataDirCopyDiag">复制诊断信息</button>${envOverride ? `<span class="data-dir-readonly">由环境变量 ${esc(dd?.envOverride?.name || "DSH_DECK_DATA_ROOT")} 指定(只读)</span>` : ""}</div></div>`;
|
|
1529
1541
|
box.hidden = false;
|
|
1530
1542
|
box.querySelectorAll("[data-data-root]").forEach((node) => node.addEventListener("click", () => switchDataDirTo(node.dataset.dataRoot)));
|
|
1531
1543
|
box.querySelector("#dataDirOpenSettings")?.addEventListener("click", () => openSettingsPanel());
|
|
@@ -2758,7 +2770,9 @@ function renderDoc(doc, index) {
|
|
|
2758
2770
|
? `<div class="review-panel"><p class="proposal-strip-note">评审意见 <b>${reviewBatch.length}</b> 条 · 勾选后点「接受并更新」合成<b>一条</b> revision(旧版保留,可回滚);不点就什么都不改</p>${reviewBatch.map(reviewRow).join("")}<div class="review-actions"><button class="btn solid" data-accept-review="1">接受并更新所选</button><span class="review-hint">A=原文可定位;B=转述或支撑偏弱;D=原文在但推不出结论(默认不勾,只作参考);C=无法核实,不进清单</span></div></div>`
|
|
2759
2771
|
: "";
|
|
2760
2772
|
return head + styleNote + densityNote + briefBanner + docIssues(doc) + storyOverview + banner + reviewPanel + `<div class="page-cards">${pages.map((page) => {
|
|
2761
|
-
|
|
2773
|
+
// 追加页 / 插页是整份结构提案(kind=script-add-page / script-insert-page),没有「当前值 → 建议值」单值,
|
|
2774
|
+
// 不进页内提案行;它们的 diff 在顶部 banner + 观察页里确认。
|
|
2775
|
+
const pageProposals = (pendingByPage.get(Number(page.page)) || []).filter((proposal) => proposal.kind !== "script-add-page" && proposal.kind !== "script-insert-page");
|
|
2762
2776
|
const slide = slideById.get(page.id) || {};
|
|
2763
2777
|
// 已落版值(manifest)优先:确认过的 title/question/body 必须立刻显示,不能被源文档快照盖住。
|
|
2764
2778
|
const pageTitle = slide.title || page.title;
|
|
@@ -2766,8 +2780,8 @@ function renderDoc(doc, index) {
|
|
|
2766
2780
|
const bodyText = String(slide.body || page.content || "").trim();
|
|
2767
2781
|
// visualBrief(manifest)才是出图真正用的提示词;prompts/Pxx.txt 是源侧旧文件。
|
|
2768
2782
|
const promptText = slide.visualBrief || page.prompt || "";
|
|
2769
|
-
const fieldProposals = pageProposals.filter((proposal) => proposal.
|
|
2770
|
-
const staleForPage = stalePending.filter((proposal) => Number(proposal.page) === Number(page.page));
|
|
2783
|
+
const fieldProposals = pageProposals.filter((proposal) => proposal.kind !== "script-add-page" && proposal.kind !== "script-insert-page");
|
|
2784
|
+
const staleForPage = stalePending.filter((proposal) => Number(proposal.page) === Number(page.page) && proposal.kind !== "script-add-page" && proposal.kind !== "script-insert-page");
|
|
2771
2785
|
const expiredForPage = pageProposals.filter((proposal) => proposal.stale);
|
|
2772
2786
|
// 「两套」问题的修法:卡片上半部分明确是当前已落版内容,提案行显示「当前值 → 建议值」。
|
|
2773
2787
|
const proposalBlock = pageProposals.length
|
|
@@ -2975,7 +2989,7 @@ const stripFrontmatter = (text) => String(text ?? "").replace(/^---\n[\s\S]*?\n-
|
|
|
2975
2989
|
const docFileUrl = (docId, name) => `/api/deck-studio.docfile?` + new URLSearchParams({ deckId: STATE.data.active.deckId, docId, name });
|
|
2976
2990
|
|
|
2977
2991
|
/** F-108:提案字段的展示名 + 预览值(分节提案的 after 是 `**字段**:值` 整行)。 */
|
|
2978
|
-
const PROPOSAL_FIELD_LABEL = { title: "标题", question: "副标题", claim: "主张", body: "正文", visualBrief: "画面要求", "script-add-page": "追加页" };
|
|
2992
|
+
const PROPOSAL_FIELD_LABEL = { title: "标题", question: "副标题", claim: "主张", body: "正文", visualBrief: "画面要求", "add-page": "追加页", "insert-page": "插入页", "script-add-page": "追加页", "script-insert-page": "插入页" };
|
|
2979
2993
|
function proposalFieldLabel(field) {
|
|
2980
2994
|
return PROPOSAL_FIELD_LABEL[field] || field || "修改";
|
|
2981
2995
|
}
|
|
@@ -3170,7 +3184,8 @@ function bindDocActions(root) {
|
|
|
3170
3184
|
root.querySelectorAll("[data-reject-proposal]").forEach((btn) => btn.addEventListener("click", () => decideProposals([btn.dataset.rejectProposal], false)));
|
|
3171
3185
|
root.querySelectorAll("[data-confirm-page]").forEach((btn) => btn.addEventListener("click", () => {
|
|
3172
3186
|
const page = Number(btn.dataset.confirmPage);
|
|
3173
|
-
|
|
3187
|
+
// 结构提案(追加页 / 插页)不挂在页卡的「当前值 → 建议值」行上,不能被「这一页全部确认」顺手带走。
|
|
3188
|
+
const ids = latestByField(deckPendingProposals().filter((proposal) => Number(proposal.page) === page && !proposal.stale && proposal.kind !== "script-add-page" && proposal.kind !== "script-insert-page")).map((proposal) => proposal.proposalId);
|
|
3174
3189
|
decideProposals(ids, true);
|
|
3175
3190
|
}));
|
|
3176
3191
|
root.querySelectorAll("[data-confirm-all]").forEach((btn) => btn.addEventListener("click", () => decideProposals(freshProposalIds(), true)));
|
|
@@ -4133,11 +4148,20 @@ async function sendAgentPrompt() {
|
|
|
4133
4148
|
state.textContent = "正在发送到左侧对话…";
|
|
4134
4149
|
try {
|
|
4135
4150
|
await ensurePromptConversation();
|
|
4151
|
+
// F-211:先把「该显示哪个会话」推给宿主 Dock,再发——否则消息到了、左侧还停在旧会话。
|
|
4152
|
+
publishChatSessionToHost(true);
|
|
4136
4153
|
const result = await requestNativeChat(text);
|
|
4137
4154
|
if (!result.ok) {
|
|
4138
4155
|
state.textContent = `未能直接发送${result.reason ? `(${result.reason})` : ""} · 可复制后手动粘贴`;
|
|
4139
4156
|
return;
|
|
4140
4157
|
}
|
|
4158
|
+
// F-211:核对落点。宿主要么发进我们给的会话,要么如实回它发去了哪;不一致就别谎报「已发送」。
|
|
4159
|
+
const want = currentChatSessionId();
|
|
4160
|
+
const landed = result.sessionId || "";
|
|
4161
|
+
if (landed && want && landed !== want) {
|
|
4162
|
+
state.textContent = `已发送到另一个会话(${landed.replace(/^session-/, "").slice(0, 8)}…),不是左侧这个;用左上「会话 ▾」切过去,或复制后手动粘贴`;
|
|
4163
|
+
return;
|
|
4164
|
+
}
|
|
4141
4165
|
state.textContent = "已发送到左侧对话 ✓";
|
|
4142
4166
|
setTimeout(closeAgentPrompt, 900);
|
|
4143
4167
|
} catch (error) {
|
|
@@ -4843,7 +4867,7 @@ getEl("newDeckForm").addEventListener("submit", async (event) => {
|
|
|
4843
4867
|
description: pickedDeckStyle()?.description || getEl("ndStyle").value.trim(),
|
|
4844
4868
|
};
|
|
4845
4869
|
// 0.2.18:探索式只提交「标题 + 风格」——被隐藏字段里可能还留着旧值,不能偷偷带上;
|
|
4846
|
-
// 页数先占 1
|
|
4870
|
+
// 页数先占 1 页占位,等对话里把故事线聊清楚再追加或插页。
|
|
4847
4871
|
const payload = explore
|
|
4848
4872
|
? { title: getEl("ndTitle").value.trim(), mode: "explore", pages: 1, style }
|
|
4849
4873
|
: {
|
|
@@ -4885,14 +4909,14 @@ getEl("newDeckForm").addEventListener("submit", async (event) => {
|
|
|
4885
4909
|
`1) 先一次问完 3–5 个关键问题(讲给谁 / 听完要带走什么 / 什么场合和时长 / 手上有没有现成材料 / 有没有必须避开的边界),别一条一条挤牙膏;\n` +
|
|
4886
4910
|
`2) 我回答后,用 deck_doc_update(docId=intent)把听众 / 带走 / 场合 / 成功标准 / 不讲的写回去;没依据的字段写成「待确认:缺依据,已列问题」,不要编,也不要留模板占位;\n` +
|
|
4887
4911
|
`3) 再给一版故事线草案:分几段、每段回答什么、大概几页(不确定就给区间),等我确认;\n` +
|
|
4888
|
-
`4) 我确认后再用 deck_script_add_page
|
|
4912
|
+
`4) 我确认后再用 deck_script_add_page(末尾加页)/ deck_script_insert_page(插到某页之前,既有页图跟着内容后移、不用重出整份)/ 提案改 script;要出图我会明确说。\n` +
|
|
4889
4913
|
`材料里没有的数字 / 客户 / 案例 / URL 不要编。`,
|
|
4890
4914
|
"探索式新建 · 首条消息发给 Agent",
|
|
4891
4915
|
);
|
|
4892
4916
|
} else {
|
|
4893
4917
|
sendPrompt(
|
|
4894
4918
|
`我刚新建了 deck「${label}」(${deckId})。\n` +
|
|
4895
|
-
`页数:已按 ${payload.pages} 页占位(P01–P${String(payload.pages).padStart(2, "0")}
|
|
4919
|
+
`页数:已按 ${payload.pages} 页占位(P01–P${String(payload.pages).padStart(2, "0")});页数要改就在对话里说:末尾加页用 deck_script_add_page,插到中间/开场位用 deck_script_insert_page(at=插到第几页之前,既有页图跟着内容后移、不用重出整份)。\n` +
|
|
4896
4920
|
`受众:${payload.audience || "待确认"};话题:${payload.topic || "待确认"};\n` +
|
|
4897
4921
|
`意图/希望带走:${payload.intent || "待确认"};\n` +
|
|
4898
4922
|
`场合/时长:${payload.occasion || "(未填,请根据意图判断或问我)"};` +
|
|
@@ -4907,7 +4931,7 @@ getEl("newDeckForm").addEventListener("submit", async (event) => {
|
|
|
4907
4931
|
`2) 分析每份资料:出处 / 类型 / 能引用的事实 / 能支撑哪条主张 / 还缺什么 → 用 deck_doc_update(docId=materials)写进「支撑材料」;\n` +
|
|
4908
4932
|
`3) 把 intent 里空着的「场合 / 时长」「成功标准」「不讲的」按意图 + 资料做判断补全 → 用 deck_doc_update(docId=intent)写回;没依据的字段不要编,列成问题问我,并把该字段写成「待确认:缺依据,已列问题」,不要留模板占位;\n` +
|
|
4909
4933
|
`4) 需要时再用 deck_doc_update(docId=research)补调研条目。\n` +
|
|
4910
|
-
`5)
|
|
4934
|
+
`5) 页数不够/多了:末尾加页用 deck_script_add_page,中间/开场插页用 deck_script_insert_page(at=插到第 N 页之前;既有页号与已出图整体后移、图跟着内容走,不用重出整份)(先提案、我确认后再 deck_revision_confirm);不要直接改 script.md,也不要回答「工具不支持加页/不支持插页」或建议我重出整份。\n` +
|
|
4911
4935
|
`材料里没有的数字 / 客户 / 案例 / URL 不要编。做完复述你补了什么、还缺什么,再等我确认后起 script 草案,不要直接出图。`,
|
|
4912
4936
|
"新建 deck · 下一步发给 Agent",
|
|
4913
4937
|
);
|
|
@@ -5119,6 +5143,32 @@ getEl("impList").addEventListener("click", (event) => {
|
|
|
5119
5143
|
renderImportPicker();
|
|
5120
5144
|
}
|
|
5121
5145
|
});
|
|
5146
|
+
/**
|
|
5147
|
+
* F-205:导入成功后必须停在**刚导入的这本** deck。
|
|
5148
|
+
*
|
|
5149
|
+
* 导入要跑 pdftoppm(几秒到几十秒),期间 5s 轮询、宿主指令、刚创建 deck 的列表延迟都可能
|
|
5150
|
+
* 把视图切到别的 deck(典型:工作台列表还没收录新 deck 时接口退回第一本)。
|
|
5151
|
+
* 这里在成功后的 ~3 秒里以新 deck 为准核对几次;用户自己点了/按了键就立刻停手,不抢手动导航。
|
|
5152
|
+
*/
|
|
5153
|
+
async function openImportedDeck(deckId) {
|
|
5154
|
+
let userTookOver = false;
|
|
5155
|
+
const flag = () => { userTookOver = true; };
|
|
5156
|
+
window.addEventListener("pointerdown", flag, true);
|
|
5157
|
+
window.addEventListener("keydown", flag, true);
|
|
5158
|
+
try {
|
|
5159
|
+
await showDeck(deckId);
|
|
5160
|
+
for (const gap of [500, 900, 1500]) {
|
|
5161
|
+
await new Promise((resolve) => setTimeout(resolve, gap));
|
|
5162
|
+
if (userTookOver) return;
|
|
5163
|
+
if (STATE.view === "deck" && STATE.deckId === deckId) continue;
|
|
5164
|
+
await showDeck(deckId);
|
|
5165
|
+
}
|
|
5166
|
+
} finally {
|
|
5167
|
+
window.removeEventListener("pointerdown", flag, true);
|
|
5168
|
+
window.removeEventListener("keydown", flag, true);
|
|
5169
|
+
}
|
|
5170
|
+
}
|
|
5171
|
+
|
|
5122
5172
|
getEl("impSubmit").addEventListener("click", async () => {
|
|
5123
5173
|
if (!IMP.selected || IMP.busy) return;
|
|
5124
5174
|
IMP.busy = true;
|
|
@@ -5143,7 +5193,8 @@ getEl("impSubmit").addEventListener("click", async () => {
|
|
|
5143
5193
|
getEl("impState").textContent = parts.join(" · ");
|
|
5144
5194
|
closeImportPdfModal();
|
|
5145
5195
|
flash(`已导入「${result.deck?.label || ""}」:${result.pages} 页整页图`, "ok");
|
|
5146
|
-
|
|
5196
|
+
// F-205:导入成功后以新 deck 为准(详见 openImportedDeck)。
|
|
5197
|
+
if (result.deck?.deckId) await openImportedDeck(result.deck.deckId);
|
|
5147
5198
|
} catch (error) {
|
|
5148
5199
|
getEl("impState").textContent = "导入失败:" + importErrText(error?.code, error?.message);
|
|
5149
5200
|
} finally {
|
|
@@ -5207,14 +5258,34 @@ getEl("renderBtn").addEventListener("click", () => renderSlidePrompt(STATE.selec
|
|
|
5207
5258
|
/* ---------------- 添加页面:结构化弹层 → 发到 DSH 原生 Chat ---------------- */
|
|
5208
5259
|
|
|
5209
5260
|
const ADD_PAGE = { open: false };
|
|
5261
|
+
/** 页号:manifest 的 slide.order 是 1-based 页号(插页确认后会整体重排)。 */
|
|
5262
|
+
function slidePageNumber(slide, index) {
|
|
5263
|
+
const order = Number(slide?.order);
|
|
5264
|
+
return Number.isFinite(order) && order >= 1 ? order : index + 1;
|
|
5265
|
+
}
|
|
5266
|
+
/** 「添加一页」的位置选项:默认追加末尾 + 每个已有页之后 + P01 之前(开弹窗时按当前页数重算)。 */
|
|
5267
|
+
function addPagePositionOptions(slides) {
|
|
5268
|
+
const list = Array.isArray(slides) ? slides : [];
|
|
5269
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
5270
|
+
const total = list.length;
|
|
5271
|
+
const options = [
|
|
5272
|
+
`<option value="end" selected>${total ? `追加到最后一页之后(P${pad(total)} 之后)` : "追加到最后一页之后(当前还没有页)"}</option>`,
|
|
5273
|
+
];
|
|
5274
|
+
list.forEach((slide, index) => {
|
|
5275
|
+
const pageNo = slidePageNumber(slide, index);
|
|
5276
|
+
options.push(`<option value="after:${pageNo}">P${pad(pageNo)} 之后</option>`);
|
|
5277
|
+
});
|
|
5278
|
+
options.push(`<option value="before:1">P01 之前(新的开场位)</option>`);
|
|
5279
|
+
return options.join("");
|
|
5280
|
+
}
|
|
5210
5281
|
function openAddPageModal() {
|
|
5211
5282
|
const active = STATE.data?.active;
|
|
5212
5283
|
if (!active) return;
|
|
5213
5284
|
const slides = active.slides || [];
|
|
5214
5285
|
const select = getEl("apAfter");
|
|
5215
|
-
select.innerHTML =
|
|
5286
|
+
select.innerHTML = addPagePositionOptions(slides);
|
|
5216
5287
|
select.value = "end";
|
|
5217
|
-
select.disabled =
|
|
5288
|
+
select.disabled = false;
|
|
5218
5289
|
getEl("apTitle").value = "";
|
|
5219
5290
|
getEl("apClaim").value = "";
|
|
5220
5291
|
getEl("apBody").value = "";
|
|
@@ -5230,10 +5301,39 @@ function closeAddPageModal() {
|
|
|
5230
5301
|
getEl("addPageModal").hidden = true;
|
|
5231
5302
|
ADD_PAGE.open = false;
|
|
5232
5303
|
}
|
|
5304
|
+
/**
|
|
5305
|
+
* 解析「添加一页」的位置选择:
|
|
5306
|
+
* - end → 追加到末尾(deck_script_add_page)
|
|
5307
|
+
* - after:N → 插到 P0N 之后 = at=N+1(deck_script_insert_page)
|
|
5308
|
+
* - before:1 → 插到 P01 之前 = at=1(deck_script_insert_page,新的开场位)
|
|
5309
|
+
*/
|
|
5310
|
+
function readAddPagePosition(slides) {
|
|
5311
|
+
const list = Array.isArray(slides) ? slides : [];
|
|
5312
|
+
const total = list.length;
|
|
5313
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
5314
|
+
const raw = String(getEl("apAfter").value || "end");
|
|
5315
|
+
if (raw.startsWith("after:")) {
|
|
5316
|
+
const pageNo = Number.parseInt(raw.slice("after:".length), 10);
|
|
5317
|
+
if (Number.isFinite(pageNo) && pageNo >= 1) {
|
|
5318
|
+
return {
|
|
5319
|
+
mode: "insert",
|
|
5320
|
+
at: pageNo + 1,
|
|
5321
|
+
label: `P${pad(pageNo)} 之后(at=${pageNo + 1},即插到第 ${pageNo + 1} 页之前)`,
|
|
5322
|
+
atLast: pageNo >= total,
|
|
5323
|
+
};
|
|
5324
|
+
}
|
|
5325
|
+
}
|
|
5326
|
+
if (raw.startsWith("before:")) {
|
|
5327
|
+
const pageNo = Number.parseInt(raw.slice("before:".length), 10);
|
|
5328
|
+
const at = Number.isFinite(pageNo) && pageNo > 1 ? pageNo : 1;
|
|
5329
|
+
return { mode: "insert", at, label: `P${pad(at)} 之前(at=${at},新的开场位)`, atLast: false };
|
|
5330
|
+
}
|
|
5331
|
+
return { mode: "append", at: total + 1, label: `最后一页(P${pad(total)})之后(追加到末尾)`, atLast: false };
|
|
5332
|
+
}
|
|
5233
5333
|
function buildAddPagePrompt() {
|
|
5234
5334
|
const active = STATE.data?.active || {};
|
|
5235
5335
|
const slides = active.slides || [];
|
|
5236
|
-
const
|
|
5336
|
+
const position = readAddPagePosition(slides);
|
|
5237
5337
|
const title = getEl("apTitle").value.trim();
|
|
5238
5338
|
const claim = getEl("apClaim").value.trim();
|
|
5239
5339
|
const body = getEl("apBody").value.trim();
|
|
@@ -5245,7 +5345,7 @@ function buildAddPagePrompt() {
|
|
|
5245
5345
|
return "";
|
|
5246
5346
|
}
|
|
5247
5347
|
const lines = [
|
|
5248
|
-
`请在 deck「${active.title || active.deckId}」(${active.deckId}
|
|
5348
|
+
`请在 deck「${active.title || active.deckId}」(${active.deckId})里${position.mode === "insert" ? "插入一页" : "新增一页"},位置:${position.label}。`,
|
|
5249
5349
|
``,
|
|
5250
5350
|
`页面标题:${title}`,
|
|
5251
5351
|
];
|
|
@@ -5256,13 +5356,21 @@ function buildAddPagePrompt() {
|
|
|
5256
5356
|
}
|
|
5257
5357
|
if (visual) lines.push(``, `画面要求:${visual}`);
|
|
5258
5358
|
if (prompt) lines.push(``, `出图提示词草稿(可润色):`, `"""`, prompt, `"""`);
|
|
5259
|
-
lines.push(
|
|
5260
|
-
|
|
5261
|
-
|
|
5262
|
-
|
|
5263
|
-
|
|
5264
|
-
|
|
5265
|
-
|
|
5359
|
+
lines.push(``, `要求:`);
|
|
5360
|
+
if (position.mode === "insert") {
|
|
5361
|
+
lines.push(
|
|
5362
|
+
`1. 调 deck_script_insert_page(deckId=${active.deckId}, at=${position.at}):pages 数组里给一项 { title, question, claim, body, visualBrief },一次给全;reason 写一句为什么插在这里。不要自己改 script.md(你也没有写文件的工具);`,
|
|
5363
|
+
`2. 插入后既有页的页号会整体后移(页号 >= ${position.at} 的页 +1)${position.atLast ? ";这里 at 已等于末页之后,等价于追加到末尾" : ""};已出的图跟着各自内容走(slides 目录随页迁移),**不需要重出整份,也不要建议我重出整份**;`,
|
|
5364
|
+
`3. 把工具返回的 diff 贴给我,等我确认后再调 deck_revision_confirm(confirmationQuote 引用我原话);`,
|
|
5365
|
+
`4. 新页默认没有图:先补 visualBrief 再出这一页;这一轮只处理新增页,别的页的内容和画面要求别动。`,
|
|
5366
|
+
);
|
|
5367
|
+
} else {
|
|
5368
|
+
lines.push(
|
|
5369
|
+
`1. 调 deck_script_add_page(deckId=${active.deckId}):pages 数组里给一项 { title, question, claim, body, visualBrief },一次给全;reason 写一句为什么加这一页。不要自己改 script.md(你也没有写文件的工具);`,
|
|
5370
|
+
`2. 把工具返回的 diff 贴给我,等我确认后再调 deck_revision_confirm(confirmationQuote 引用我原话);`,
|
|
5371
|
+
`3. 新页默认没有图:先补 visualBrief 再出这一页;这一轮只处理新增页,别的页别动。`,
|
|
5372
|
+
);
|
|
5373
|
+
}
|
|
5266
5374
|
return lines.join("\n");
|
|
5267
5375
|
}
|
|
5268
5376
|
async function sendAddPageToChat() {
|
|
@@ -5363,6 +5471,20 @@ function setupNativeChat() {
|
|
|
5363
5471
|
* 桌面(dsh-app://app)的对话列:不嵌 DSH 页面,把头部保留成「绑定 + 会话切换」,
|
|
5364
5472
|
* 动作经宿主桥(client.js)在宿主主区打开原生会话;打开成功后关闭工作台面板。
|
|
5365
5473
|
*/
|
|
5474
|
+
/**
|
|
5475
|
+
* 桌面 Dock:把「本 deck / design 的会话」报给宿主,Dock 据此渲染原生会话。
|
|
5476
|
+
* F-211:从 setupHostChatColumn 里提出来,发送指令前后可以立刻推一次,
|
|
5477
|
+
* 不用等 1.2s 心跳(否则会出现「消息已经到了、左侧还是旧会话」)。
|
|
5478
|
+
*/
|
|
5479
|
+
let chatSessionPublished = "§init§";
|
|
5480
|
+
function publishChatSessionToHost(force = false) {
|
|
5481
|
+
if (!DESKTOP_HOST) return;
|
|
5482
|
+
const sessionId = currentChatSessionId();
|
|
5483
|
+
if (!force && sessionId === chatSessionPublished) return;
|
|
5484
|
+
chatSessionPublished = sessionId;
|
|
5485
|
+
try { window.parent?.postMessage({ type: "dsh-deck-studio:chat-session", sessionId }, "*"); } catch { /* 宿主桥不可用 */ }
|
|
5486
|
+
}
|
|
5487
|
+
|
|
5366
5488
|
function setupHostChatColumn() {
|
|
5367
5489
|
const column = getEl("chatCol");
|
|
5368
5490
|
const frame = getEl("nativeChatFrame");
|
|
@@ -5379,15 +5501,8 @@ function setupHostChatColumn() {
|
|
|
5379
5501
|
if (!STATE.dockActive) getEl("chatHostCard").hidden = false;
|
|
5380
5502
|
}, 6000);
|
|
5381
5503
|
// 心跳把当前绑定会话报给宿主:Dock 据此用原生会话盖住 #chatDockArea。
|
|
5382
|
-
|
|
5383
|
-
|
|
5384
|
-
const sessionId = currentChatSessionId();
|
|
5385
|
-
if (sessionId === lastPublished) return;
|
|
5386
|
-
lastPublished = sessionId;
|
|
5387
|
-
try { window.parent?.postMessage({ type: "dsh-deck-studio:chat-session", sessionId }, "*"); } catch { /* 宿主桥不可用 */ }
|
|
5388
|
-
};
|
|
5389
|
-
setInterval(publishChatSession, 1200);
|
|
5390
|
-
try { window.parent?.postMessage({ type: "dsh-deck-studio:chat-session", sessionId: lastPublished = currentChatSessionId() }, "*"); } catch { /* 同上 */ }
|
|
5504
|
+
setInterval(() => publishChatSessionToHost(), 1200);
|
|
5505
|
+
publishChatSessionToHost(true);
|
|
5391
5506
|
window.addEventListener("message", (event) => {
|
|
5392
5507
|
if (event.source !== window.parent) return;
|
|
5393
5508
|
if (event.data?.type !== "dsh-deck-studio:chat-dock") return;
|
|
@@ -5466,21 +5581,34 @@ function nativeChatWindow() {
|
|
|
5466
5581
|
function requestNativeChat(text) {
|
|
5467
5582
|
const target = nativeChatWindow();
|
|
5468
5583
|
if (!target) return Promise.resolve({ ok: false, reason: "没有可用的 DSH 对话窗口" });
|
|
5584
|
+
/**
|
|
5585
|
+
* F-211:必须把「本 deck / design 的会话」一起送过去,不能让宿主自己挑。
|
|
5586
|
+
* 桌面 Dock 模式下宿主记的 `dsh.sessions.current` 是宿主主区那个会话(见 currentChatSessionId 的注释),
|
|
5587
|
+
* 常常不是左侧 Dock 正在显示的这个——只发 text 会让指令落进隔壁会话:
|
|
5588
|
+
* 弹层提示「已发送到左侧对话 ✓」,左边却什么也没有(2026-11-07 真机复现:
|
|
5589
|
+
* 指令进了未绑定的 session-a274e455,绑定的 session-e2eda8c9 一直空着)。
|
|
5590
|
+
*/
|
|
5591
|
+
const sessionId = currentChatSessionId();
|
|
5469
5592
|
return new Promise((resolve) => {
|
|
5470
5593
|
let settled = false;
|
|
5471
5594
|
const onAck = (event) => {
|
|
5472
5595
|
if (event.data?.type !== "dsh-deck-studio:prompt-ack") return;
|
|
5473
5596
|
settled = true;
|
|
5474
5597
|
window.removeEventListener("message", onAck);
|
|
5475
|
-
resolve({
|
|
5598
|
+
resolve({
|
|
5599
|
+
ok: Boolean(event.data.ok),
|
|
5600
|
+
sessionId: event.data.sessionId || sessionId,
|
|
5601
|
+
reason: event.data.reason || "",
|
|
5602
|
+
});
|
|
5476
5603
|
};
|
|
5477
5604
|
window.addEventListener("message", onAck);
|
|
5478
|
-
target.postMessage({ type: "dsh-deck-studio:prompt", text }, "*");
|
|
5605
|
+
target.postMessage({ type: "dsh-deck-studio:prompt", text, sessionId }, "*");
|
|
5479
5606
|
setTimeout(() => {
|
|
5480
5607
|
if (settled) return;
|
|
5481
5608
|
window.removeEventListener("message", onAck);
|
|
5482
|
-
|
|
5483
|
-
|
|
5609
|
+
// 宿主 inject 会话上下文(最多 1.5s)再 send,2.5s 太紧,容易「其实发出去了但界面报没回应」。
|
|
5610
|
+
resolve({ ok: false, reason: "宿主 8s 内没有回应" });
|
|
5611
|
+
}, 8000);
|
|
5484
5612
|
});
|
|
5485
5613
|
}
|
|
5486
5614
|
|
|
@@ -5517,6 +5645,8 @@ function requestConversation(payload) {
|
|
|
5517
5645
|
sourceRoot: STATE.data?.active?.sourceRoot || "",
|
|
5518
5646
|
// 0.2.16:宿主要按「deck 自己的项目目录」建会话工作区(桌面里 cwd 是 profile 目录,用不了)。
|
|
5519
5647
|
sessionWorkspacePath: STATE.data?.active?.sessionWorkspacePath || "",
|
|
5648
|
+
// F-210:宿主拿它给工作区起名(`Deckline · <deck 标题>`),侧栏才像 HTML Deck 一样按 deck 分组。
|
|
5649
|
+
title: STATE.data?.active?.title || "",
|
|
5520
5650
|
...payload,
|
|
5521
5651
|
// F-202B:桌面 Dock 模式默认不抢主区(open:false);显式 takeMain → open:true 切宿主主区。
|
|
5522
5652
|
// web 不传 open(undefined → 宿主按原语义 openSession)。
|
|
@@ -5960,6 +6090,8 @@ function renderChatHeader() {
|
|
|
5960
6090
|
const label = getEl("chatSessionLabel");
|
|
5961
6091
|
const count = getEl("chatSessionCount");
|
|
5962
6092
|
const target = activeChatTarget();
|
|
6093
|
+
// F-211:头部重画时顺手把「该显示哪个会话」推给宿主 Dock(内部去重,未变化不重复 postMessage)。
|
|
6094
|
+
publishChatSessionToHost();
|
|
5963
6095
|
if (!target) {
|
|
5964
6096
|
chip.textContent = "—";
|
|
5965
6097
|
chip.className = "bind-chip unbound";
|
|
@@ -6322,7 +6454,11 @@ function backToManual(box) {
|
|
|
6322
6454
|
STATE.homeSection = match ? match[1] : "decks";
|
|
6323
6455
|
showHome();
|
|
6324
6456
|
}
|
|
6325
|
-
|
|
6457
|
+
// F-205:导入(pdftoppm 可能要几十秒)期间别用旧 deckId 轮询,否则回来晚的那次会把新 deck 顶掉。
|
|
6458
|
+
setInterval(() => {
|
|
6459
|
+
if (IMP.busy) return;
|
|
6460
|
+
loadWorkbench(STATE.deckId);
|
|
6461
|
+
}, 5000);
|
|
6326
6462
|
|
|
6327
6463
|
/**
|
|
6328
6464
|
* 0.2.21:boot 完成 → 把启动期间暂存的「打开某本 deck」执行掉,再向宿主报 ready。
|