dsh-plugin-deck-studio 0.2.14 → 0.2.25

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/index.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * @module dsh-plugin-deck-studio
10
10
  */
11
11
 
12
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
12
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
13
13
  import { createHash } from "node:crypto";
14
14
  import { execFile } from "node:child_process";
15
15
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -37,6 +37,7 @@ import { resolveMediaFile, scanDesignStyles, scanResources } from "./lib/home-ca
37
37
  import { defaultSelection, renderModelPayload, resolveSelection } from "./lib/render-nodes.js";
38
38
  import { applyRenderSettings, readRenderSettings, renderSettingsPayload, saveRenderSettings } from "./lib/render-settings.js";
39
39
  import { buildPrompt, editEndpointFor, selectRenderer } from "./lib/render.js";
40
+ import { PDF_IMPORT_MAX_PAGES, applyImportedTitles, buildImportNote, extractIntentFields, headingsAsTitles, listImportDir, normalizeDpi, rasterizePdfToPng, scriptPagesFromMarkdown, titleFromPdfName } from "./lib/pdf-import.js";
40
41
  import { readDeckBrief } from "./lib/deck-brief.js";
41
42
  import { detectProfileDir, installModeOf, isNewer, latestFromRegistry, repoLinksOf } from "./lib/update-check.js";
42
43
 
@@ -78,6 +79,8 @@ export const ROUTES = {
78
79
  media: "/api/deck-studio.media",
79
80
  renderModel: "/api/deck-studio.render-model",
80
81
  deckCreate: "/api/deck-studio.deck-create",
82
+ // PDF 导入:目录里的一份 PDF → 每页整页图的 deck(附带 md 抽取)
83
+ deckImport: "/api/deck-studio.deck-import",
81
84
  deckDensity: "/api/deck-studio.deck-density",
82
85
  renderSettings: "/api/deck-studio.render-settings",
83
86
  userSettings: "/api/deck-studio.user-settings",
@@ -395,9 +398,38 @@ export function createService(config, logger, env = process.env) {
395
398
  // F-71:标出「来自注册表」——preset 工具实例靠它区分静态源,运行时删除的 deck 才能被移除。
396
399
  config.decks.push({ id: entry.id, label: entry.label || entry.id, sourceRoot: entry.sourceRoot, kind: entry.kind ?? "deck", designId: entry.designId ?? null, fromRegistry: true });
397
400
  }
401
+ /**
402
+ * 0.2.15:补挂「工作副本里有 manifest、但注册表里没有条目」的 deck(ghost deck)。
403
+ *
404
+ * 以前这类 deck 只在 status() 的兜底分支里露一个标题:没有 adapter → 源里已出好的整页图
405
+ * baseline、资源库、封面、9 份文档全部拿不到,工作台打开就是一屏「未出图」的空白页,
406
+ * 看起来像「资源都在就是没显示」。这里在 adapters 之前补挂:manifest 里的源还可读
407
+ * (script.md 在)就造一个只读 adapter;读不到的留给 status() 标「未挂载」。
408
+ */
409
+ function adoptManifestDecks() {
410
+ const adopted = [];
411
+ for (const deckId of workspace.listDeckIds()) {
412
+ if (config.decks.some((deck) => deck.id === deckId)) continue;
413
+ const manifest = workspace.manifest(deckId);
414
+ const root = String(manifest?.source?.root ?? "").trim();
415
+ if (!root || !existsSync(join(root, "script.md"))) continue;
416
+ const kind = manifest?.kind === "design-canvas" ? "design-canvas" : "deck";
417
+ config.decks.push({
418
+ id: deckId,
419
+ label: String(manifest?.title ?? "").trim() || deckId,
420
+ sourceRoot: root,
421
+ kind,
422
+ designId: manifest?.designId ?? null,
423
+ fromManifest: true,
424
+ });
425
+ adopted.push(deckId);
426
+ }
427
+ return adopted;
428
+ }
398
429
  // F-157 首次启动自举:新装 profile 打开 bootstrap 时,自动登记示例 deck / 拷资源库 / 导入预设风格,
399
430
  // 避免「装完四个页面全是空的」。可用 profile patch 里 bootstrap: false 关掉。
400
431
  // 示例 deck 必须在 adapters 之前登记;资源和 fashioned 风格放后面(依赖 adapters/workspace)。
432
+ const adoptedDeckIds = adoptManifestDecks();
401
433
  if (config.bootstrap === true) bootstrapSampleDeck();
402
434
  const adapters = config.decks.map((deck) => createLocalSourceAdapter(deck));
403
435
 
@@ -517,9 +549,12 @@ export function createService(config, logger, env = process.env) {
517
549
  const success = String(payload.success ?? "").trim();
518
550
  const scopeOut = String(payload.scopeOut ?? "").trim();
519
551
  if (!title) throw new Error("deck 标题不能为空");
552
+ // 0.2.18:开工方式。表单式(默认)要求先把受众 / 意图 / 页数定清楚;
553
+ // 探索式只要一个标题——主意还模糊时先建本 deck,简报随对话补充(对齐 html deck-studio 的 explore)。
554
+ const creationMode = payload.mode === "explore" ? "explore" : "form";
520
555
  // 空白画布(design 会话用):0 页,等 Agent 把风格样张沉淀到 designs/<ABBR>/。
521
556
  const blank = Boolean(payload.blank);
522
- const pageTitles = newDeckPageTitles(payload, blank);
557
+ const pageTitles = creationMode === "explore" ? ["待定 · 边聊边定"] : newDeckPageTitles(payload, blank);
523
558
  const pad = (n) => String(n).padStart(2, "0");
524
559
  const now = new Date();
525
560
  const id = `deck-${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}-${Math.random().toString(36).slice(2, 6)}`;
@@ -553,13 +588,16 @@ export function createService(config, logger, env = process.env) {
553
588
  "confidential: client",
554
589
  "render: image",
555
590
  "deck-type: talk",
591
+ ...(creationMode === "explore" ? ["creation-mode: explore"] : []),
556
592
  `pages: ${pageTitles.length}`,
557
593
  "status: draft",
558
594
  "---",
559
595
  "",
560
596
  `# ${title}`,
561
597
  "",
562
- "> 由「新建 Deck」创建:先补意图与资料,再改 script;未确认前不出图。",
598
+ creationMode === "explore"
599
+ ? "> 由「新建 Deck · 探索模式」创建:还没定简报,先和 Agent 把听众 / 想让人带走什么 / 场合聊清楚,再补 script;未确认前不出图。"
600
+ : "> 由「新建 Deck」创建:先补意图与资料,再改 script;未确认前不出图。",
563
601
  "",
564
602
  "## 故事线",
565
603
  "",
@@ -594,6 +632,7 @@ export function createService(config, logger, env = process.env) {
594
632
  scopeOut,
595
633
  created: now.toISOString(),
596
634
  owner: config.userName,
635
+ creationMode,
597
636
  }),
598
637
  "utf8",
599
638
  );
@@ -672,6 +711,8 @@ export function createService(config, logger, env = process.env) {
672
711
  style: { abbr: styleAbbr, description: styleDescription },
673
712
  kind: payload.kind ?? "deck",
674
713
  designId: payload.designId ?? null,
714
+ // 0.2.18:探索式建档在注册表里也留痕(重启后 UI 能显示「探索式」)。
715
+ creationMode,
675
716
  });
676
717
  writeFileSync(decksRegistryFile, `${JSON.stringify({ decks: entries }, null, 2)}\n`, "utf8");
677
718
 
@@ -700,7 +741,8 @@ export function createService(config, logger, env = process.env) {
700
741
  designId: payload.designId ?? null,
701
742
  style: { abbr: styleAbbr, description: styleDescription },
702
743
  pages: pageTitles.length,
703
- note: `初始化:script ${pageTitles.length} 页 + 意图 / 风格 / 参考资料清单 / 文档骨架`,
744
+ creationMode,
745
+ note: `初始化:script ${pageTitles.length} 页 + 意图 / 风格 / 参考资料清单 / 文档骨架${creationMode === "explore" ? "(探索式:简报随对话补充)" : ""}`,
704
746
  },
705
747
  });
706
748
  return {
@@ -714,6 +756,193 @@ export function createService(config, logger, env = process.env) {
714
756
  };
715
757
  }
716
758
 
759
+ /**
760
+ * PDF 导入(需求 req_28a1eec6eab8):导入目录里的 PDF → 每页一张整页图的 deck。
761
+ *
762
+ * 顺序:列目录(PDF / md)→ 栅格化(pdftoppm)→ createDeck(页数 = PDF 页数)→ 落 pages/p01.png…
763
+ * → md 抽取(script / intent / 其它 md 进 materials)→ 写导入说明 → 事件。
764
+ * - **只读**导入目录,产物全部落 createDeck 新建的目录;PDF 本身不复制。
765
+ * - 意图只抽 md 里已写好的字段,不推断;看图推意图是下一阶段。
766
+ * - `rasterize` 参数是测试缝(smoke 注入假栅格器,CI 不需要 poppler),产品路径用默认实现。
767
+ */
768
+ function importPdfDeck(payload = {}, { rasterize = rasterizePdfToPng } = {}) {
769
+ // 两种入口:直接给 PDF 绝对路径(工作台「点一份 PDF」走这条),或给目录 + 可选文件名(脚本 / API 用)。
770
+ const pdfPath = String(payload.pdf ?? "").trim();
771
+ let dir = String(payload.dir ?? "").trim();
772
+ let wanted = String(payload.file ?? "").trim();
773
+ if (pdfPath) {
774
+ if (!isAbsolute(pdfPath)) throw new Error(`PDF 要填绝对路径:${pdfPath}`);
775
+ if (!existsSync(pdfPath) || !statSync(pdfPath).isFile()) throw new Error(`PDF 不存在:${pdfPath}`);
776
+ if (!/\.pdf$/i.test(pdfPath)) throw new Error(`不是 PDF:${pdfPath}`);
777
+ dir = dirname(pdfPath);
778
+ wanted = basename(pdfPath);
779
+ }
780
+ if (!dir) throw new Error("请先选一份 PDF");
781
+ if (!isAbsolute(dir)) throw new Error(`导入目录要填绝对路径:${dir}`);
782
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) throw new Error(`导入目录不存在:${dir}`);
783
+
784
+ const listing = listImportDir(dir);
785
+ if (!listing.pdfs.length) throw new Error("这个目录里没有 PDF(本轮只支持 PDF;PPTX 下一阶段再做)");
786
+ let pdf = listing.pdfs[0];
787
+ if (wanted) {
788
+ pdf = listing.pdfs.find((item) => item.name === wanted || item.path === wanted);
789
+ if (!pdf) throw new Error(`目录里没有这份 PDF:${wanted}`);
790
+ } else if (listing.pdfs.length > 1) {
791
+ throw new Error(`目录里有 ${listing.pdfs.length} 份 PDF,先指定要导入哪一份:${listing.pdfs.map((item) => item.name).join(" / ")}`);
792
+ }
793
+
794
+ const dpi = normalizeDpi(payload.dpi);
795
+ // 暂存区放在数据目录里(不是导入目录):导入不该往别人的目录里写东西。
796
+ const staging = join(config.dataRoot, "imports", `staging-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`);
797
+ const raster = rasterize({ pdfPath: pdf.path, outDir: staging, dpi });
798
+ if (raster.pageCount > PDF_IMPORT_MAX_PAGES) {
799
+ rmSync(staging, { recursive: true, force: true });
800
+ throw new Error(`这份 PDF 有 ${raster.pageCount} 页,超过单次导入上限 ${PDF_IMPORT_MAX_PAGES} 页;先拆分再导入`);
801
+ }
802
+
803
+ try {
804
+ const mdTexts = listing.markdown.map((entry) => ({ ...entry, text: readFileSync(entry.path, "utf8") }));
805
+ const scriptEntry = mdTexts.find((entry) => /^script\.md$/i.test(entry.name));
806
+ const intentEntry = mdTexts.find((entry) => /^intent\.md$/i.test(entry.name));
807
+ const otherEntries = mdTexts.filter((entry) => entry !== scriptEntry && entry !== intentEntry);
808
+
809
+ // 意图字段:intent.md > script.md > 其它 md;只取第一个非空值,并记下来源。
810
+ const fields = {};
811
+ const fieldSources = {};
812
+ for (const entry of [intentEntry, scriptEntry, ...otherEntries]) {
813
+ if (!entry) continue;
814
+ const extracted = extractIntentFields(entry.text);
815
+ for (const [key, value] of Object.entries(extracted.fields)) {
816
+ if (fields[key]) continue;
817
+ fields[key] = value;
818
+ fieldSources[key] = `${entry.name} 的 ${extracted.sources[key] ?? "字段"}`;
819
+ }
820
+ }
821
+
822
+ const warnings = [];
823
+ const scriptRows = scriptEntry ? scriptPagesFromMarkdown(scriptEntry.text) : [];
824
+ const scriptMatches = scriptRows.length === raster.pageCount;
825
+ if (scriptEntry && !scriptMatches) {
826
+ warnings.push(`script.md 解析出 ${scriptRows.length} 页,和 PDF 的 ${raster.pageCount} 页对不上:已按 PDF 页数重建逐页骨架,请人工核对。`);
827
+ }
828
+ let titles = scriptMatches ? scriptRows.map((row) => String(row.title ?? "").trim()) : [];
829
+ if (!titles.length || titles.some((title) => !title)) {
830
+ const headingFallback = [intentEntry, scriptEntry, ...otherEntries]
831
+ .filter(Boolean)
832
+ .map((entry) => headingsAsTitles(entry.text, raster.pageCount))
833
+ .find((list) => list.length) ?? [];
834
+ if (headingFallback.length) titles = headingFallback;
835
+ }
836
+ titles = Array.from({ length: raster.pageCount }, (_, index) => titles[index] || `导入页 ${index + 1}`);
837
+
838
+ const title = String(payload.title ?? "").trim() || titleFromPdfName(pdf.name);
839
+ const deck = createDeck({
840
+ title,
841
+ dirName: payload.dirName,
842
+ pageTitles: titles,
843
+ audience: fields.audience,
844
+ topic: fields.topic,
845
+ intent: fields.intent,
846
+ occasion: fields.occasion,
847
+ success: fields.success,
848
+ scopeOut: fields.scopeOut,
849
+ contentDensity: payload.contentDensity,
850
+ kind: "deck",
851
+ });
852
+
853
+ // 1) 每页整页图:直接落 pages/p01.png…,sourceImages() 认的就是这套命名。
854
+ const pagesDir = join(deck.sourceRoot, "pages");
855
+ mkdirSync(pagesDir, { recursive: true });
856
+ for (const file of raster.files) copyFileSync(file.file, join(pagesDir, `${file.pageId}.png`));
857
+
858
+ // 2) md 抽取:script 页数对得上才当脚本源;intent.md 有就直接沿用原文。
859
+ const copiedScript = Boolean(scriptEntry && scriptMatches);
860
+ if (copiedScript) writeFileSync(join(deck.sourceRoot, "script.md"), scriptEntry.text, "utf8");
861
+ if (!copiedScript) {
862
+ // 没沿用 md 脚本时,把占位「**标题**:待定」换成导入推出来的标题,否则每页都显示「待定」。
863
+ const scriptFile = join(deck.sourceRoot, "script.md");
864
+ writeFileSync(scriptFile, applyImportedTitles(readFileSync(scriptFile, "utf8"), titles), "utf8");
865
+ }
866
+ if (intentEntry) writeFileSync(join(deck.sourceRoot, "intent.md"), intentEntry.text, "utf8");
867
+
868
+ // 3) 其余 md → materials/,并在支撑材料清单里登记(表头必须是 文件名/链接/总结,见 deck-templates)。
869
+ const materialsDir = join(deck.sourceRoot, "materials");
870
+ const materialRows = [];
871
+ for (const entry of otherEntries) {
872
+ mkdirSync(materialsDir, { recursive: true });
873
+ copyFileSync(entry.path, join(materialsDir, entry.name));
874
+ const sizeKb = Math.max(1, Math.round(entry.sizeBytes / 1024));
875
+ materialRows.push({ ref: `materials/${entry.name}`, type: "文件", status: "已找到", note: `${sizeKb} KB · 随 PDF 一起导入,待 Agent 抽事实` });
876
+ }
877
+ const materialsFile = join(workspace.deckDir(deck.deckId), "docs", "materials.md");
878
+ mkdirSync(dirname(materialsFile), { recursive: true });
879
+ writeFileSync(
880
+ materialsFile,
881
+ buildMaterialsMarkdown({
882
+ title,
883
+ raw: `从导入目录 ${dir} 导入:PDF \`${pdf.name}\`(${raster.pageCount} 页 · ${raster.dpi} dpi,整页图见 pages/,原 PDF 不复制)${materialRows.length ? `;另带 ${materialRows.length} 份 md` : ""}。`,
884
+ rows: materialRows,
885
+ }),
886
+ "utf8",
887
+ );
888
+
889
+ // 4) 导入说明写进 intent.md 末尾:哪来的、抽到哪几个字段、哪些没做。
890
+ const intentFile = join(deck.sourceRoot, "intent.md");
891
+ if (existsSync(intentFile)) {
892
+ const note = buildImportNote({
893
+ pdfName: pdf.name,
894
+ pageCount: raster.pageCount,
895
+ dpi: raster.dpi,
896
+ mdFiles: mdTexts.map((entry) => entry.name),
897
+ fieldSources,
898
+ warnings,
899
+ });
900
+ writeFileSync(intentFile, `${readFileSync(intentFile, "utf8").trimEnd()}\n\n${note}`, "utf8");
901
+ }
902
+
903
+ // 5) 页数 / 标题变了 → 重建 manifest(createDeck 里建的是占位页)。
904
+ const adapter = adapters.find((item) => item.id === deck.deckId);
905
+ if (adapter) {
906
+ try {
907
+ workspace.ensureDeck(adapter, { forceRebuild: true });
908
+ } catch (error) {
909
+ logger?.warn?.(`deck-studio: 导入 deck ${deck.deckId} 重建 manifest 失败:${error?.message ?? error}`);
910
+ }
911
+ }
912
+
913
+ store.recordEvent({
914
+ type: "deck.imported",
915
+ deckId: deck.deckId,
916
+ actor: "user",
917
+ status: "succeeded",
918
+ payload: {
919
+ label: deck.label,
920
+ sourceRoot: deck.sourceRoot,
921
+ importedFrom: dir,
922
+ pdf: pdf.name,
923
+ pages: raster.pageCount,
924
+ dpi: raster.dpi,
925
+ markdown: mdTexts.map((entry) => entry.name),
926
+ copiedScript,
927
+ note: `PDF 导入:${raster.pageCount} 页整页图${copiedScript ? " + script.md" : ""}${materialRows.length ? ` + ${materialRows.length} 份材料` : ""}`,
928
+ },
929
+ });
930
+
931
+ return {
932
+ deck: { ...deck, pages: raster.pageCount },
933
+ importedFrom: dir,
934
+ pdf: { name: pdf.name, sizeBytes: pdf.sizeBytes },
935
+ pages: raster.pageCount,
936
+ dpi: raster.dpi,
937
+ markdown: mdTexts.map((entry) => entry.name),
938
+ copiedScript,
939
+ warnings,
940
+ };
941
+ } finally {
942
+ rmSync(staging, { recursive: true, force: true });
943
+ }
944
+ }
945
+
717
946
  /**
718
947
  * F-124:设置 deck 的内容密度(auto / concise / medium / dense)。
719
948
  * 只改 manifest 设置,不改已有内容;Agent 下次读 script / 提案时按新目标字数写。
@@ -837,9 +1066,16 @@ export function createService(config, logger, env = process.env) {
837
1066
  return { designId: design.designId, added, design: designProjectView(design) };
838
1067
  }
839
1068
 
840
- /** F-157:把仓库自带的示例 deck 登记成只读源(没有配置任何 decks 时)。 */
1069
+ /**
1070
+ * F-157:把仓库自带的示例 deck 登记成只读源。
1071
+ *
1072
+ * 0.2.15:判定从「`config.decks` 非空就跳过」改成「这份示例没被挂上才登记」。
1073
+ * 旧口径会被「导入预设风格时自动建的 8 本风格画布」顶掉——装过风格的机器上示例 deck
1074
+ * 永远不会再登记,而它的工作副本还在 `decks/<id>/deck.json`,于是变成 ghost deck:
1075
+ * 列表里有 10 页标题,打开却一页图都没有。
1076
+ */
841
1077
  function bootstrapSampleDeck() {
842
- if (config.decks.length > 0) return;
1078
+ if (config.decks.some((deck) => deck.id === "sample-deckline-intro")) return;
843
1079
  const sampleRoot = join(REPO_ROOT, "samples", "deckline-intro");
844
1080
  if (!existsSync(join(sampleRoot, "script.md"))) return;
845
1081
  config.decks.push({ id: "sample-deckline-intro", label: "Deckline 示例手册", sourceRoot: sampleRoot, kind: "deck", designId: null, fromBootstrap: true });
@@ -1874,6 +2110,29 @@ export function createService(config, logger, env = process.env) {
1874
2110
  };
1875
2111
  }
1876
2112
 
2113
+ /**
2114
+ * 0.2.16:这本 deck 该用哪个目录当「会话工作区」。
2115
+ *
2116
+ * 背景:桌面宿主的 shell cwd 是 profile 目录(被工作区守卫挡掉),宿主客户端的
2117
+ * workspaces 服务又没有 initializeDefault,源目录也常常还没登记成工作区——三者叠加,
2118
+ * 「+ 新建会话」永远解析不到工作区,于是**从没有会话的 deck 永远建不出会话**(点了没反应)。
2119
+ * 这里给宿主桥一个明确的落点:deck 自己的项目目录优先;源落在插件安装位(node_modules)
2120
+ * 或 App 包里时不拿来当工作区,退回这本 deck 的工作副本目录 <dataRoot>/decks/<id>。
2121
+ */
2122
+ function sessionWorkspacePathOf(deckId) {
2123
+ const sourceRoot = String(adapterOf(deckId)?.sourceRoot ?? "").trim();
2124
+ const installed = /[/\\]node_modules[/\\]/.test(sourceRoot) || sourceRoot.startsWith("/Applications/");
2125
+ if (sourceRoot && !installed) return sourceRoot;
2126
+ try {
2127
+ const dir = workspace.deckDir(deckId);
2128
+ // 工作区必须指向真实存在的目录;工作副本还没建出来时先建空的(没有 deck.json,不会被当成 deck 列出)。
2129
+ mkdirSync(dir, { recursive: true });
2130
+ return dir;
2131
+ } catch {
2132
+ return "";
2133
+ }
2134
+ }
2135
+
1877
2136
  /** media 路由解析:只放行白名单目录的图片。 */
1878
2137
  function mediaFile(deckId, scope, path) {
1879
2138
  const adapter = adapterOf(deckId);
@@ -1951,8 +2210,11 @@ export function createService(config, logger, env = process.env) {
1951
2210
  }
1952
2211
  for (const deckId of workspace.listDeckIds()) {
1953
2212
  if (decks.some((deck) => deck.deckId === deckId)) continue;
2213
+ // 0.2.15:走到这里说明这本 deck 的源没挂上(目录不在了 / script.md 丢了)。
2214
+ // 明确标 unmounted + sourceAvailable:false,前端据此给「重新登记 / 换源」的入口,
2215
+ // 而不是列一屏看起来正常、点开全是空白页的 deck。
1954
2216
  const manifest = workspace.manifest(deckId);
1955
- decks.push({ deckId, label: manifest?.title ?? deckId, sourceRoot: manifest?.source?.root ?? "", sourceAvailable: Boolean(manifest?.source?.root), title: manifest?.title ?? deckId, pageCount: manifest?.slides?.length ?? 0, status: manifest?.status ?? "", warnings: [] });
2217
+ decks.push({ deckId, label: manifest?.title ?? deckId, sourceRoot: manifest?.source?.root ?? "", sourceAvailable: false, unmounted: true, title: manifest?.title ?? deckId, pageCount: manifest?.slides?.length ?? 0, status: manifest?.status ?? "", warnings: [] });
1956
2218
  }
1957
2219
  const tasks = store.taskList(200);
1958
2220
  const counts = {};
@@ -2276,10 +2538,12 @@ export function createService(config, logger, env = process.env) {
2276
2538
  // 当前 deck 生效的出图节点/模型(下拉可换)
2277
2539
  renderNodes: renderState(activeId),
2278
2540
  // 首页/标签用:deck 列表、风格(designs/<ABBR>/ 样张)、资源库
2279
- decks: decks.map((item) => ({ deckId: item.deckId, label: item.label, pageCount: item.pageCount, status: item.status ?? "", sourceRoot: item.sourceRoot ?? "", kind: item.kind ?? "deck" })),
2541
+ decks: decks.map((item) => ({ deckId: item.deckId, label: item.label, pageCount: item.pageCount, status: item.status ?? "", sourceRoot: item.sourceRoot ?? "", kind: item.kind ?? "deck", sourceAvailable: item.sourceAvailable !== false, unmounted: item.unmounted === true })),
2280
2542
  designs: designSamples(activeId),
2281
2543
  ...homeCatalog(activeId),
2282
2544
  sourceRoot: adapter?.sourceRoot ?? deck?.source?.root ?? "",
2545
+ // 0.2.16:给宿主桥注册 DSH 工作区用的项目目录(见 sessionWorkspacePathOf)。
2546
+ sessionWorkspacePath: sessionWorkspacePathOf(activeId),
2283
2547
  // P0:黄条与 Inspector 用的数据目录状态(active 级别)
2284
2548
  dataDir: activeDataDir,
2285
2549
  dataDirMismatch: needsBanner,
@@ -2325,7 +2589,7 @@ export function createService(config, logger, env = process.env) {
2325
2589
  bootstrapDesignStyles();
2326
2590
  }
2327
2591
 
2328
- return { config, store, workspace, adapters, status, state, workbench, readManual, readUserName: () => config.userName, saveUserName, readDoc, docsAsset, setDeckDensity, renderSettings, saveRenderConfig, isLocalDeck: (deckId) => localDeckIds().has(String(deckId ?? "")), assetFile, slideRenderInfo, deckCoverUrl, deckRevisions, deckDocs, deckDoc, deckDocsScaffold, deckDocFile, pageForEdit, pagePrompt, savePage, exportDeckFile, designSamples, sourceFile, homeCatalog, mediaFile, renderState, setRenderSelection, renderRuntime, createDeck, createDesign, createStyleFromDeck, designProjects, designStyle, updateDesignStyle, backfillStyleBriefs, listBindings, bindSession, unbindSession, deleteDesign, deleteDeck, setDeckDesign, addDesignSamples, seedStyleDesigns, renderStatus, exportHandoff, updateBrief, updateStatus, runSelfUpdate }
2592
+ return { config, store, workspace, adapters, status, state, workbench, readManual, readUserName: () => config.userName, saveUserName, readDoc, docsAsset, setDeckDensity, renderSettings, saveRenderConfig, isLocalDeck: (deckId) => localDeckIds().has(String(deckId ?? "")), assetFile, slideRenderInfo, deckCoverUrl, deckRevisions, deckDocs, deckDoc, deckDocsScaffold, deckDocFile, pageForEdit, pagePrompt, savePage, exportDeckFile, designSamples, sourceFile, homeCatalog, mediaFile, renderState, setRenderSelection, renderRuntime, createDeck, importPdfDeck, createDesign, createStyleFromDeck, designProjects, designStyle, updateDesignStyle, backfillStyleBriefs, listBindings, bindSession, unbindSession, deleteDesign, deleteDeck, setDeckDesign, addDesignSamples, seedStyleDesigns, renderStatus, exportHandoff, updateBrief, updateStatus, runSelfUpdate }
2329
2593
  }
2330
2594
 
2331
2595
  /* ─────────────────────────────────────────────────────────────────────────
@@ -2772,7 +3036,7 @@ export function apply(ctx, rawConfig) {
2772
3036
  * @param {string} pathValue 绝对路径;空 = 当前生效的 dataRoot
2773
3037
  * @returns {{path: string, parent: string, isRoot: boolean, truncated: boolean, dirs: {name: string, path: string, hidden: boolean, hasScript: boolean, deckCount: number, isDataRoot: boolean}[]}}
2774
3038
  */
2775
- function browseDir(pathValue) {
3039
+ function browseDir(pathValue, { includeFiles = false } = {}) {
2776
3040
  const raw = String(pathValue ?? "").trim();
2777
3041
  if (raw && !isAbsolute(raw)) {
2778
3042
  const error = new Error(`必须是绝对路径:${raw}`);
@@ -2803,6 +3067,7 @@ export function apply(ctx, rawConfig) {
2803
3067
  throw failed;
2804
3068
  }
2805
3069
  const dirs = [];
3070
+ const files = [];
2806
3071
  let truncated = false;
2807
3072
  for (const entry of entries) {
2808
3073
  if (dirs.length >= BROWSE_MAX_ENTRIES) {
@@ -2819,7 +3084,22 @@ export function apply(ctx, rawConfig) {
2819
3084
  childIsDir = false;
2820
3085
  }
2821
3086
  }
2822
- if (!childIsDir) continue;
3087
+ if (!childIsDir) {
3088
+ // 导入 PDF 要从这里直接点文件:只认 pdf / md,其它一律不列(不暴露目录里有什么)。
3089
+ if (includeFiles && entry.isFile()) {
3090
+ const kind = /\.pdf$/i.test(entry.name) ? "pdf" : /\.md$/i.test(entry.name) ? "md" : "";
3091
+ if (kind) {
3092
+ let sizeBytes = 0;
3093
+ try {
3094
+ sizeBytes = statSync(childPath).size;
3095
+ } catch {
3096
+ sizeBytes = 0;
3097
+ }
3098
+ files.push({ name: entry.name, path: childPath, kind, sizeBytes });
3099
+ }
3100
+ }
3101
+ continue;
3102
+ }
2823
3103
  dirs.push({
2824
3104
  name: entry.name,
2825
3105
  path: childPath,
@@ -2830,6 +3110,7 @@ export function apply(ctx, rawConfig) {
2830
3110
  });
2831
3111
  }
2832
3112
  dirs.sort((a, b) => a.name.localeCompare(b.name));
3113
+ files.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === "pdf" ? -1 : 1));
2833
3114
  const parentPath = dirname(target);
2834
3115
  return {
2835
3116
  path: target,
@@ -2837,7 +3118,48 @@ export function apply(ctx, rawConfig) {
2837
3118
  isRoot: parentPath === target,
2838
3119
  truncated,
2839
3120
  dirs,
3121
+ ...(includeFiles ? { files } : {}),
3122
+ };
3123
+ }
3124
+
3125
+ /** 有界地找子目录里的 PDF(导入 PDF 用:先看当前层,找不到再一键往下找)。 */
3126
+ function findPdfFiles(rootDir, { maxDepth = 3, maxFiles = 60 } = {}) {
3127
+ const found = [];
3128
+ let truncated = false;
3129
+ const walk = (dir, depth) => {
3130
+ if (found.length >= maxFiles) {
3131
+ truncated = true;
3132
+ return;
3133
+ }
3134
+ let entries = [];
3135
+ try {
3136
+ entries = readdirSync(dir, { withFileTypes: true });
3137
+ } catch {
3138
+ return;
3139
+ }
3140
+ for (const entry of entries) {
3141
+ if (found.length >= maxFiles) {
3142
+ truncated = true;
3143
+ return;
3144
+ }
3145
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
3146
+ const childPath = join(dir, entry.name);
3147
+ if (entry.isFile() && /\.pdf$/i.test(entry.name)) {
3148
+ let sizeBytes = 0;
3149
+ try {
3150
+ sizeBytes = statSync(childPath).size;
3151
+ } catch {
3152
+ sizeBytes = 0;
3153
+ }
3154
+ found.push({ name: entry.name, path: childPath, kind: "pdf", sizeBytes });
3155
+ continue;
3156
+ }
3157
+ if (depth < maxDepth && entry.isDirectory()) walk(childPath, depth + 1);
3158
+ }
2840
3159
  };
3160
+ walk(rootDir, 0);
3161
+ found.sort((a, b) => a.path.localeCompare(b.path));
3162
+ return { files: found, truncated };
2841
3163
  }
2842
3164
 
2843
3165
  // 目录浏览(目录选择器):GET ?path=<绝对路径>;缺省 = 当前 dataRoot。
@@ -2846,7 +3168,31 @@ export function apply(ctx, rawConfig) {
2846
3168
  if (switching) return switchingResponse();
2847
3169
  const url = new URL(request?.url ?? "/", "http://localhost");
2848
3170
  try {
2849
- return json({ ok: true, dir: browseDir(url.searchParams.get("path") ?? "") });
3171
+ const includeFiles = url.searchParams.get("files") === "1";
3172
+ if (url.searchParams.get("find") === "pdf") {
3173
+ const raw = String(url.searchParams.get("path") ?? "").trim();
3174
+ if (raw && !isAbsolute(raw)) {
3175
+ const error = new Error(`必须是绝对路径:${raw}`);
3176
+ error.code = "bad-path";
3177
+ throw error;
3178
+ }
3179
+ const target = resolve(raw || config.dataRoot || process.cwd());
3180
+ let stats;
3181
+ try {
3182
+ stats = statSync(target);
3183
+ } catch {
3184
+ const error = new Error(`目录不存在:${target}`);
3185
+ error.code = "not-found";
3186
+ throw error;
3187
+ }
3188
+ if (!stats.isDirectory()) {
3189
+ const error = new Error(`不是目录:${target}`);
3190
+ error.code = "not-a-dir";
3191
+ throw error;
3192
+ }
3193
+ return json({ ok: true, found: { path: target, ...findPdfFiles(target) } });
3194
+ }
3195
+ return json({ ok: true, dir: browseDir(url.searchParams.get("path") ?? "", { includeFiles }) });
2850
3196
  } catch (error) {
2851
3197
  const code = error?.code ?? "browse-failed";
2852
3198
  const status = code === "switching" ? 409 : code === "browse-failed" ? 500 : 400;
@@ -2920,6 +3266,28 @@ export function apply(ctx, rawConfig) {
2920
3266
  return json({ ok: true, deck: service.createDeck(body) });
2921
3267
  }, ["POST"]);
2922
3268
 
3269
+ // PDF 导入:body { dir, file?, title?, dirName?, dpi? }。错误按 code 分支,message 只做兜底(与 dir-browse 一致)。
3270
+ registerRoute(ROUTES.deckImport, async (request) => {
3271
+ if ((request?.method ?? "POST").toUpperCase() !== "POST") return json({ ok: false, error: { code: "method", message: "POST only" } }, 405);
3272
+ const body = await request.json().catch(() => ({}));
3273
+ try {
3274
+ return json({ ok: true, ...service.importPdfDeck(body) });
3275
+ } catch (error) {
3276
+ const message = error instanceof Error ? error.message : String(error);
3277
+ const code = error?.code === "pdftoppm-missing"
3278
+ ? "pdftoppm-missing"
3279
+ : /不在|没有 PDF|没有这份 PDF|份 PDF|不是 PDF|PDF 不存在/.test(message)
3280
+ ? "no-pdf"
3281
+ : /不存在/.test(message)
3282
+ ? "not-found"
3283
+ : /超过单次导入上限|绝对路径|请先选/.test(message)
3284
+ ? "bad-dir"
3285
+ : "import-failed";
3286
+ const status = code === "import-failed" ? 500 : code === "pdftoppm-missing" ? 501 : 400;
3287
+ return json({ ok: false, error: { code, message } }, status);
3288
+ }
3289
+ }, ["POST"]);
3290
+
2923
3291
  // 生图设置(F-126):body { mode, endpoint, model, apiKeyEnv, apiKeyFile, size },存本机数据目录并即刻生效。
2924
3292
  registerRoute(ROUTES.renderSettings, async (request) => {
2925
3293
  if ((request?.method ?? "GET").toUpperCase() === "GET") return json({ ok: true, ...service.renderSettings() });
@@ -6,6 +6,7 @@
6
6
  import { lstatSync, realpathSync, readdirSync } from "node:fs";
7
7
  import { isAbsolute, resolve, relative, sep, parse, join } from "node:path";
8
8
  import { randomUUID } from "node:crypto";
9
+ import { presentNative } from "./present-native.js";
9
10
 
10
11
  export const name = "deck-studio-capabilities";
11
12
  export const inject = ["tools", "workingDirectory", "agents", "systemPrompt"];
@@ -183,7 +184,7 @@ export function createResearchProvider(ctx, config = {}, dependencies = {}) {
183
184
  signal: controller.signal,
184
185
  setup(agentCtx, child) {
185
186
  agentCtx.systemPrompt.suppressRuntimeContext();
186
- agentCtx.tools.presentAs("native");
187
+ presentNative(agentCtx.tools);
187
188
  agentCtx.tools.restrict({ allow: [] });
188
189
  for (const definition of researchDefinitions) agentCtx.tools.register(definition);
189
190
  agentCtx.tools.guard(createCapabilityGuard(ctx, config, { readonly: true, cwd }));
@@ -217,7 +218,7 @@ export function createResearchProvider(ctx, config = {}, dependencies = {}) {
217
218
 
218
219
  /** Mount ONLY under the main-assistant preset, never the root router mount. */
219
220
  export function apply(ctx, config = {}) {
220
- ctx.tools.presentAs("native");
221
+ presentNative(ctx.tools);
221
222
  ctx.tools.guard(createCapabilityGuard(ctx, config));
222
223
  const provider = createResearchProvider(ctx, config);
223
224
  ctx.tools.register({
@@ -20,7 +20,7 @@ const orPlaceholder = (value, hint) => (pad(value) ? pad(value) : `(待补:$
20
20
  * intent.md 初始内容。
21
21
  * @param {{title:string, audience?:string, topic?:string, intent?:string, references?:string, created?:string, owner?:string}} input
22
22
  */
23
- export function buildIntentMarkdown({ title, audience = "", topic = "", intent = "", references = "", occasion = "", success = "", scopeOut = "", argument = "", skeletonType = "", pageOrder = "", keepThree = "", created = "", owner = "" } = {}) {
23
+ export function buildIntentMarkdown({ title, audience = "", topic = "", intent = "", references = "", occasion = "", success = "", scopeOut = "", argument = "", skeletonType = "", pageOrder = "", keepThree = "", created = "", owner = "", creationMode = "" } = {}) {
24
24
  const heading = pad(title) || "(deck 标题)";
25
25
  return [
26
26
  "---",
@@ -28,10 +28,15 @@ export function buildIntentMarkdown({ title, audience = "", topic = "", intent =
28
28
  `owner: ${owner}`,
29
29
  ...(created ? [`created: ${created}`] : []),
30
30
  "status: draft",
31
+ // 0.2.18:探索式建档留痕——重启 / 重读 intent 时仍然知道这本是「边聊边定」的。
32
+ ...(creationMode === "explore" ? ["creation-mode: explore"] : []),
31
33
  "---",
32
34
  "",
33
35
  "# 意图",
34
36
  "",
37
+ ...(creationMode === "explore"
38
+ ? ["- **创建模式**:探索式 —— 简报随对话补充;下面「待补」的字段由 Agent 逐条问清楚,不是漏填表单。"]
39
+ : []),
35
40
  `- **听众**:${orPlaceholder(audience, "讲给谁:角色 / 部门 / 层级 / 对 AI 的熟悉度")}`,
36
41
  `- **主题**:${orPlaceholder(topic || heading, "一句话说清这场讲什么")}`,
37
42
  `- **带走**:${orPlaceholder(intent, "听完他们应该能带走什么(1–3 条,可验证)")}`,