dsh-plugin-deck-studio 0.2.24 → 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",
@@ -753,6 +756,193 @@ export function createService(config, logger, env = process.env) {
753
756
  };
754
757
  }
755
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
+
756
946
  /**
757
947
  * F-124:设置 deck 的内容密度(auto / concise / medium / dense)。
758
948
  * 只改 manifest 设置,不改已有内容;Agent 下次读 script / 提案时按新目标字数写。
@@ -2399,7 +2589,7 @@ export function createService(config, logger, env = process.env) {
2399
2589
  bootstrapDesignStyles();
2400
2590
  }
2401
2591
 
2402
- 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 }
2403
2593
  }
2404
2594
 
2405
2595
  /* ─────────────────────────────────────────────────────────────────────────
@@ -2846,7 +3036,7 @@ export function apply(ctx, rawConfig) {
2846
3036
  * @param {string} pathValue 绝对路径;空 = 当前生效的 dataRoot
2847
3037
  * @returns {{path: string, parent: string, isRoot: boolean, truncated: boolean, dirs: {name: string, path: string, hidden: boolean, hasScript: boolean, deckCount: number, isDataRoot: boolean}[]}}
2848
3038
  */
2849
- function browseDir(pathValue) {
3039
+ function browseDir(pathValue, { includeFiles = false } = {}) {
2850
3040
  const raw = String(pathValue ?? "").trim();
2851
3041
  if (raw && !isAbsolute(raw)) {
2852
3042
  const error = new Error(`必须是绝对路径:${raw}`);
@@ -2877,6 +3067,7 @@ export function apply(ctx, rawConfig) {
2877
3067
  throw failed;
2878
3068
  }
2879
3069
  const dirs = [];
3070
+ const files = [];
2880
3071
  let truncated = false;
2881
3072
  for (const entry of entries) {
2882
3073
  if (dirs.length >= BROWSE_MAX_ENTRIES) {
@@ -2893,7 +3084,22 @@ export function apply(ctx, rawConfig) {
2893
3084
  childIsDir = false;
2894
3085
  }
2895
3086
  }
2896
- 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
+ }
2897
3103
  dirs.push({
2898
3104
  name: entry.name,
2899
3105
  path: childPath,
@@ -2904,6 +3110,7 @@ export function apply(ctx, rawConfig) {
2904
3110
  });
2905
3111
  }
2906
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));
2907
3114
  const parentPath = dirname(target);
2908
3115
  return {
2909
3116
  path: target,
@@ -2911,7 +3118,48 @@ export function apply(ctx, rawConfig) {
2911
3118
  isRoot: parentPath === target,
2912
3119
  truncated,
2913
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
+ }
2914
3159
  };
3160
+ walk(rootDir, 0);
3161
+ found.sort((a, b) => a.path.localeCompare(b.path));
3162
+ return { files: found, truncated };
2915
3163
  }
2916
3164
 
2917
3165
  // 目录浏览(目录选择器):GET ?path=<绝对路径>;缺省 = 当前 dataRoot。
@@ -2920,7 +3168,31 @@ export function apply(ctx, rawConfig) {
2920
3168
  if (switching) return switchingResponse();
2921
3169
  const url = new URL(request?.url ?? "/", "http://localhost");
2922
3170
  try {
2923
- 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 }) });
2924
3196
  } catch (error) {
2925
3197
  const code = error?.code ?? "browse-failed";
2926
3198
  const status = code === "switching" ? 409 : code === "browse-failed" ? 500 : 400;
@@ -2994,6 +3266,28 @@ export function apply(ctx, rawConfig) {
2994
3266
  return json({ ok: true, deck: service.createDeck(body) });
2995
3267
  }, ["POST"]);
2996
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
+
2997
3291
  // 生图设置(F-126):body { mode, endpoint, model, apiKeyEnv, apiKeyFile, size },存本机数据目录并即刻生效。
2998
3292
  registerRoute(ROUTES.renderSettings, async (request) => {
2999
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({
@@ -0,0 +1,270 @@
1
+ /**
2
+ * PDF 导入(需求 req_28a1eec6eab8,2026-11-06):把目录里的一份 PDF 变成 Deckline 的
3
+ * 「每页一张整页图」deck,顺带抽取同目录里的 md。
4
+ *
5
+ * 设计边界(先看清再改):
6
+ * - **不做 OCR、不做视觉推理**:意图字段只从 md 里「已经写好的」字段抽;抽不到就留占位,
7
+ * 并在 intent.md 末尾写「导入说明(推断 · 待确认)」。看图推意图是下一阶段的事。
8
+ * - **导入的整页图是参考 baseline**,不是最终交付图:PDF 渲染出来的字/版式与源 pptx 不一致时
9
+ * 不能当过审的稿子用(真正的原因见 docs/architecture.md「导入」一节)。
10
+ * - 纯函数(分类 / 抽字段 / 标题启发 / 说明生成)都不碰磁盘,smoke 直接测;
11
+ * IO 只有两处:`listImportDir()` 与 `rasterizePdfToPng()`。
12
+ * - `rasterizePdfToPng()` 依赖 poppler 的 `pdftoppm`(本机 brew install poppler)。
13
+ * 插件本身不引入 npm 依赖,缺二进制时**明确报错**,不静默降级成空 deck。
14
+ */
15
+
16
+ import { execFileSync } from "node:child_process";
17
+ import { existsSync, mkdirSync, readdirSync, renameSync, statSync } from "node:fs";
18
+ import { basename, extname, join } from "node:path";
19
+ import { parseSectionedPages, parseStoryPages } from "./source-adapter.js";
20
+
21
+ export const PDF_IMPORT_DEFAULT_DPI = 120;
22
+ export const PDF_IMPORT_MIN_DPI = 72;
23
+ export const PDF_IMPORT_MAX_DPI = 300;
24
+ /** 一次导入的页数上限:够覆盖常见分享会 deck,又不至于让一次请求跑十几分钟。 */
25
+ export const PDF_IMPORT_MAX_PAGES = 40;
26
+
27
+ /** dpi 规范化:非数字回默认,超范围夹到边界(越界不报错,导入不该因为参数小错失败)。 */
28
+ export function normalizeDpi(value) {
29
+ const parsed = Number.parseInt(value, 10);
30
+ if (!Number.isFinite(parsed)) return PDF_IMPORT_DEFAULT_DPI;
31
+ return Math.min(PDF_IMPORT_MAX_DPI, Math.max(PDF_IMPORT_MIN_DPI, parsed));
32
+ }
33
+
34
+ /** 目录里哪些文件是导入源:只要 PDF 与 md(其它文件不猜、不读)。 */
35
+ export function classifyImportNames(names = []) {
36
+ const pdfs = [];
37
+ const markdown = [];
38
+ for (const raw of Array.isArray(names) ? names : []) {
39
+ const name = String(raw ?? "").trim();
40
+ if (!name || name.startsWith(".")) continue;
41
+ if (/\.pdf$/i.test(name)) pdfs.push(name);
42
+ else if (/\.md$/i.test(name)) markdown.push(name);
43
+ }
44
+ return { pdfs: pdfs.sort(), markdown: markdown.sort() };
45
+ }
46
+
47
+ /** 从 PDF 文件名推 deck 标题(末级兜底,用户填了就用用户的)。 */
48
+ export function titleFromPdfName(fileName = "") {
49
+ const base = basename(String(fileName ?? ""), extname(String(fileName ?? "")));
50
+ return base.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim() || "导入的 deck";
51
+ }
52
+
53
+ /** 意图六字段的别名表(中文 / 英文都认,大小写不敏感)。 */
54
+ const INTENT_FIELDS = [
55
+ { key: "audience", labels: ["听众", "受众", "用户", "audience"] },
56
+ { key: "topic", labels: ["主题", "话题", "topic"] },
57
+ { key: "intent", labels: ["意图", "带走", "intent"] },
58
+ { key: "occasion", labels: ["场合", "时长", "occasion"] },
59
+ { key: "success", labels: ["成功标准", "success"] },
60
+ { key: "scopeOut", labels: ["不讲的", "不讲", "边界", "scopeout", "scope-out"] },
61
+ ];
62
+
63
+ /** 占位值不算抽取成功(「(待补:…)」正是模板里的空位)。 */
64
+ function isEmptyPlaceholder(value) {
65
+ const text = String(value ?? "").trim();
66
+ if (!text) return true;
67
+ return /^[((]\s*待补/.test(text) || /^[((]\s*未填/.test(text);
68
+ }
69
+
70
+ /**
71
+ * frontmatter 的键值对。这里**不复用** source-adapter 的 `parseFrontmatter`:
72
+ * 它只认 ASCII 键名(`^[A-Za-z0-9_-]+`),
73
+ * 而导入目录里的 md 常写 `听众:` / `主题:` 这种中文键——那些正是我们要抽的字段。
74
+ */
75
+ function readFrontmatterPairs(text = "") {
76
+ const body = String(text ?? "");
77
+ if (!body.startsWith("---")) return [];
78
+ const end = body.indexOf("\n---", 3);
79
+ if (end < 0) return [];
80
+ const pairs = [];
81
+ for (const line of body.slice(4, end).split("\n")) {
82
+ if (!line.trim() || /^\s*[-#]/.test(line)) continue;
83
+ const match = /^\s*([^:\s][^:]{0,31}?)\s*[::]\s*(.*)$/.exec(line);
84
+ if (!match) continue;
85
+ pairs.push([match[1].trim(), match[2].trim()]);
86
+ }
87
+ return pairs;
88
+ }
89
+
90
+ function cleanFieldValue(value) {
91
+ return String(value ?? "").replace(/\s+/g, " ").replace(/\*\*/g, "").trim();
92
+ }
93
+
94
+ /**
95
+ * 从一段 md 里抽意图字段:
96
+ * 1. frontmatter(`audience:` / `听众:` 等);
97
+ * 2. `**听众**:…` 这种结构化字段行(Deckline 自己的 intent.md 就是这个写法)。
98
+ * 只抽「已经写好的」,不推断;返回 `sources` 方便在说明里交代每个字段从哪来。
99
+ */
100
+ export function extractIntentFields(text = "") {
101
+ const fields = {};
102
+ const sources = {};
103
+ const body = String(text ?? "");
104
+ if (!body.trim()) return { fields, sources };
105
+
106
+ const metaEntries = readFrontmatterPairs(body);
107
+ for (const { key, labels } of INTENT_FIELDS) {
108
+ for (const label of labels) {
109
+ const hit = metaEntries.find(([name]) => String(name).trim().toLowerCase() === label.toLowerCase());
110
+ if (!hit) continue;
111
+ const value = cleanFieldValue(hit[1]);
112
+ if (isEmptyPlaceholder(value)) continue;
113
+ fields[key] = value;
114
+ sources[key] = `frontmatter · ${hit[0]}`;
115
+ break;
116
+ }
117
+ }
118
+
119
+ for (const line of body.split("\n")) {
120
+ const match = /^\s*[-*]?\s*\*\*([^*]{1,16})\*\*\s*[::]\s*(.+)$/.exec(line);
121
+ if (!match) continue;
122
+ const label = match[1].trim();
123
+ const value = cleanFieldValue(match[2]);
124
+ if (isEmptyPlaceholder(value)) continue;
125
+ for (const { key, labels } of INTENT_FIELDS) {
126
+ if (fields[key]) continue;
127
+ if (labels.some((item) => label.toLowerCase() === item.toLowerCase())) {
128
+ fields[key] = value;
129
+ sources[key] = `「${label}」字段`;
130
+ }
131
+ }
132
+ }
133
+ return { fields, sources };
134
+ }
135
+
136
+ /**
137
+ * 标题启发:md 里 `#` / `##` 标题的条数**正好等于页数**(或多一条文档大标题)时,
138
+ * 才把它们当逐页标题——数量对不上就宁可不用,也不硬塞。
139
+ */
140
+ export function headingsAsTitles(text = "", pageCount = 0) {
141
+ const count = Number(pageCount) || 0;
142
+ if (!count) return [];
143
+ const heads = String(text ?? "")
144
+ .split("\n")
145
+ .map((line) => /^(#{1,2})\s+(.+?)\s*$/.exec(line.trim()))
146
+ .filter(Boolean)
147
+ .map((match) => match[2].replace(/\*\*/g, "").replace(/^\d+[.、)]\s*/, "").trim())
148
+ .filter(Boolean);
149
+ if (heads.length === count) return heads;
150
+ if (heads.length === count + 1) return heads.slice(1);
151
+ return [];
152
+ }
153
+
154
+ /** 一份 md 能不能当这台 deck 的 script:逐页解析出来的页数必须和 PDF 页数一致。 */
155
+ export function scriptPagesFromMarkdown(text = "") {
156
+ const body = String(text ?? "");
157
+ if (!body.trim()) return [];
158
+ const table = parseStoryPages(body);
159
+ const rows = table.length ? table : parseSectionedPages(body);
160
+ return rows.filter((row) => row && Number(row.page) > 0);
161
+ }
162
+
163
+ /**
164
+ * 把 `createDeck()` 写出来的占位 `**标题**:待定` 换成导入时推出来的页标题。
165
+ * 不改分节头(`### P01 · …` 在建档时已经是目标标题),只补「标题」字段——
166
+ * 否则 deck 列表 / 工作台每一页都显示「待定」,等于没导入。
167
+ */
168
+ export function applyImportedTitles(scriptText = "", titles = []) {
169
+ let index = 0;
170
+ return String(scriptText ?? "").replace(/^\*\*标题\*\*\s*[::].*$/gm, (line) => {
171
+ const title = String(titles[index] ?? "").trim();
172
+ index += 1;
173
+ return title ? `**标题**:${title}` : line;
174
+ });
175
+ }
176
+
177
+ /**
178
+ * 导入说明(写在 intent.md 末尾):把「哪些是抽到的、哪些是占位、哪些没做」一次交代清楚,
179
+ * 避免用户或 Agent 把导入的占位当成已确认的意图。
180
+ */
181
+ export function buildImportNote({
182
+ pdfName = "",
183
+ pageCount = 0,
184
+ dpi = PDF_IMPORT_DEFAULT_DPI,
185
+ mdFiles = [],
186
+ fieldSources = {},
187
+ warnings = [],
188
+ } = {}) {
189
+ const lines = [
190
+ "## 导入说明(推断 · 待确认)",
191
+ "",
192
+ `- 来源:\`${pdfName || "(未知 PDF)"}\` —— ${Number(pageCount) || 0} 页 · ${Number(dpi) || PDF_IMPORT_DEFAULT_DPI} dpi,每页已落成整页图 \`pages/p01.png…\`。`,
193
+ `- 抽到的 md:${mdFiles.length ? mdFiles.map((name) => `\`${name}\``).join("、") : "(目录里没有 md)"}。`,
194
+ ];
195
+ const sourced = Object.entries(fieldSources ?? {}).filter(([, from]) => from);
196
+ lines.push(
197
+ `- 上面字段的来源:${sourced.length ? sourced.map(([key, from]) => `${key} ← ${from}`).join(";") : "没有抽到现成字段,全部是占位"}。`,
198
+ );
199
+ lines.push("- 没做的:**逐页看图推断意图**(本轮不做视觉推理)。需要时让 Agent 读整页图后补这里。");
200
+ lines.push("- 提醒:导入的整页图是**参考 baseline**(`rev=source`),不是最终交付图;要重出图请走正常出图流程。");
201
+ for (const warning of Array.isArray(warnings) ? warnings : []) lines.push(`- ⚠️ ${warning}`);
202
+ lines.push("");
203
+ return lines.join("\n");
204
+ }
205
+
206
+ /** 列导入目录(只读一层,不递归):返回 PDF / md 的文件对象。 */
207
+ export function listImportDir(dir) {
208
+ const names = readdirSync(dir).filter((name) => !name.startsWith("."));
209
+ const { pdfs, markdown } = classifyImportNames(names);
210
+ const stat = (name) => {
211
+ const path = join(dir, name);
212
+ const info = statSync(path);
213
+ return { name, path, sizeBytes: info.size };
214
+ };
215
+ return {
216
+ dir,
217
+ pdfs: pdfs.map(stat),
218
+ markdown: markdown.map(stat),
219
+ };
220
+ }
221
+
222
+ /** poppler 的 pdftoppm 在不在(只用于提前给人话提示)。 */
223
+ export function hasPdftoppm() {
224
+ try {
225
+ execFileSync("pdftoppm", ["-v"], { stdio: "ignore", timeout: 10_000 });
226
+ return true;
227
+ } catch (error) {
228
+ return error?.code !== "ENOENT";
229
+ }
230
+ }
231
+
232
+ /**
233
+ * PDF → 每页 PNG。
234
+ * 输出名统一成 `p01.png … pNN.png`(两位补零,超过 99 页按实际位数),
235
+ * 与 `source-adapter.sourceImages()` 认的 `P01.png` 约定一致(大小写都认)。
236
+ */
237
+ export function rasterizePdfToPng({ pdfPath, outDir, dpi = PDF_IMPORT_DEFAULT_DPI } = {}) {
238
+ if (!pdfPath || !existsSync(pdfPath)) throw new Error(`PDF 不存在:${pdfPath ?? "(空)"}`);
239
+ const value = normalizeDpi(dpi);
240
+ mkdirSync(outDir, { recursive: true });
241
+ const rawPrefix = join(outDir, "raw");
242
+ try {
243
+ execFileSync("pdftoppm", ["-png", "-r", String(value), pdfPath, rawPrefix], {
244
+ stdio: ["ignore", "pipe", "pipe"],
245
+ timeout: 20 * 60 * 1000,
246
+ });
247
+ } catch (error) {
248
+ if (error?.code === "ENOENT") {
249
+ const missing = new Error("PDF 导入需要 poppler 的 `pdftoppm`:macOS `brew install poppler`,Debian/Ubuntu `apt install poppler-utils`");
250
+ missing.code = "pdftoppm-missing";
251
+ throw missing;
252
+ }
253
+ const stderr = String(error?.stderr ?? "").trim();
254
+ throw new Error(`PDF 转图片失败:${stderr || error?.message || "pdftoppm 退出异常"}`);
255
+ }
256
+
257
+ const produced = readdirSync(outDir)
258
+ .filter((name) => /^raw-\d+\.png$/i.test(name))
259
+ .map((name) => ({ name, page: Number(/^raw-(\d+)\.png$/i.exec(name)[1]) }))
260
+ .sort((a, b) => a.page - b.page);
261
+ if (!produced.length) throw new Error("pdftoppm 没有产出任何页图(PDF 可能损坏、加密或没有页面)");
262
+
263
+ const files = produced.map((item, index) => {
264
+ const pageId = `p${String(index + 1).padStart(2, "0")}`;
265
+ const file = join(outDir, `${pageId}.png`);
266
+ renameSync(join(outDir, item.name), file);
267
+ return { pageId, file, bytes: statSync(file).size };
268
+ });
269
+ return { files, pageCount: files.length, dpi: value };
270
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Declare native tool presentation, when the host still has that API.
3
+ *
4
+ * DSH `0.2.1-rc.2` exposes `ctx.tools.presentAs()` and a preset must declare its
5
+ * presentation there; DSH `0.2.2-rc.1` removed the method and mounts
6
+ * `@deepseek-ai/dsh-tool-executor-native` at the base layer instead. Calling it
7
+ * unconditionally on `0.2.2` throws `ctx.tools.presentAs is not a function`
8
+ * while the preset mounts, which surfaces as `agent-preset/invalid` and blocks
9
+ * every new deck session. Feature-detect instead of version-sniffing: native
10
+ * execution is the only shape this plugin supports, so declaring it is a no-op
11
+ * on hosts that already default to it.
12
+ */
13
+
14
+ /** @param {{ presentAs?: (mode: "native") => void } | undefined} tools */
15
+ export function presentNative(tools) {
16
+ if (typeof tools?.presentAs === "function") tools.presentAs("native");
17
+ }
@@ -1,6 +1,7 @@
1
1
  /** Dedicated, preset-free, one-shot audience reviewer. No Cordis import is required. */
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { isAbsolute } from "node:path";
4
+ import { presentNative } from "./present-native.js";
4
5
 
5
6
  export const REVIEW_AGENT_PROVIDER = "deck-studio-review";
6
7
 
@@ -122,7 +123,7 @@ export function registerReviewAgentProvider(ctx, dependencies = {}) {
122
123
  signal: controller.signal,
123
124
  setup(agentCtx, child) {
124
125
  agentCtx.systemPrompt.suppressRuntimeContext();
125
- agentCtx.tools.presentAs("native");
126
+ presentNative(agentCtx.tools);
126
127
  agentCtx.tools.restrict({ allow: [] });
127
128
  agentCtx.tools.guard(() => "Internal reviewers cannot execute tools");
128
129
  agentCtx.systemPrompt.section({ name: "deck-studio:internal-review", order: 0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-deck-studio",
3
- "version": "0.2.24",
3
+ "version": "0.2.25",
4
4
  "description": "Deckline:DSH 的 image deck / PPT 工作台插件——原生 Chat + 整页图片 deck 工作区 + 设计风格库;用对话做 PPT、按页出图、版本可追踪、导出 PDF/PPTX。",
5
5
  "icon": "assets/icon.svg",
6
6
  "homepage": "https://github.com/yuna78/dsh-deck-studio#readme",
package/ui/workbench.html CHANGED
@@ -128,6 +128,7 @@
128
128
  <section id="homeView" class="home">
129
129
  <aside class="home-nav">
130
130
  <button class="btn rail-new home-new-deck" id="newDeckBtn">+ 新建 Deck</button>
131
+ <button class="btn alt rail-new home-new-deck" id="importPdfBtn" title="把一份 PDF 的每一页转成整页图,建一本 deck">⇪ 导入 PDF</button>
131
132
  <nav class="home-nav-list" id="homeNav">
132
133
  <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>
133
134
  <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>
@@ -215,6 +216,42 @@
215
216
  </div>
216
217
  </div>
217
218
 
219
+ <div id="importPdfModal" class="modal" hidden>
220
+ <div class="modal-card newdeck-card">
221
+ <div class="modal-head">
222
+ <div><div class="eyebrow">Import</div><h2>导入 PDF</h2></div>
223
+ <button class="modal-close" id="impClose" title="关闭">✕</button>
224
+ </div>
225
+ <div class="modal-body newdeck-body">
226
+ <p class="nd-tip">点一份 PDF:每一页会转成一张整页图,建一本新 deck。
227
+ <br>同一个文件夹里有 md 就顺手抽(页数对得上当脚本,其余进支撑材料),没有就只导入图。
228
+ <br>只支持 PDF(本机 poppler 的 <code>pdftoppm</code>);单次上限 40 页。</p>
229
+ <div class="imp-picker">
230
+ <div class="imp-picker-bar">
231
+ <button type="button" class="btn ghost" id="impUp">↑ 上一级</button>
232
+ <code id="impPath">—</code>
233
+ </div>
234
+ <div class="imp-picker-list" id="impList"><div class="dir-picker-empty">正在读目录…</div></div>
235
+ </div>
236
+ <p class="engine-note" id="impSelected">还没有选 PDF:点上面的 📄 文件即可。</p>
237
+ <div class="nd-foot">
238
+ <label class="imp-dpi">清晰度
239
+ <select id="impDpi">
240
+ <option value="96">96 · 快</option>
241
+ <option value="120" selected>120 · 默认</option>
242
+ <option value="200">200 · 更清</option>
243
+ <option value="300">300 · 最清最慢</option>
244
+ </select>
245
+ </label>
246
+ <span class="spacer"></span>
247
+ <button type="button" class="btn alt" id="impCancel">取消</button>
248
+ <button type="button" class="btn" id="impSubmit" disabled>开始导入</button>
249
+ </div>
250
+ <p class="engine-note" id="impState"></p>
251
+ </div>
252
+ </div>
253
+ </div>
254
+
218
255
  <div id="confirmModal" class="modal" hidden>
219
256
  <div class="modal-card confirm-card">
220
257
  <div class="modal-head">
@@ -518,6 +555,17 @@
518
555
  .dir-picker-tag{font-size:10.5px;color:#0f6975}
519
556
  .dir-picker-count{font-size:10.5px;color:#8a7a58}
520
557
  .dir-picker-list{border:1px solid #e3e1d8;border-radius:10px;max-height:42vh;overflow:auto;background:#fff}
558
+ /* F-203:导入 PDF —— 直接在弹层里点文件,不让人手打目录 / 文件名 */
559
+ .imp-picker{border:1px solid #e4ded0;border-radius:10px;overflow:hidden;margin:8px 0 10px;background:#fff}
560
+ .imp-picker-bar{display:flex;align-items:center;gap:10px;padding:8px 10px;border-bottom:1px solid #f2efe6;background:#faf8f3}
561
+ .imp-picker-bar code{flex:1;min-width:0;font-size:11.5px;color:#6d7f83;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
562
+ .imp-picker-list{max-height:280px;overflow:auto;padding:2px}
563
+ .imp-picker-list .dir-picker-row.selected{background:#eaf4f1;box-shadow:inset 0 0 0 1px #0d6b62}
564
+ .imp-picker-list .dir-picker-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
565
+ .imp-picker-list .imp-find{background:#fbf7ec;border-top:1px solid #f2efe6;color:#8a5b12}
566
+ .imp-picker-list .imp-find:hover{background:#f7f1df}
567
+ .imp-dpi{display:flex;align-items:center;gap:6px;font-size:12px;color:#6d7f83}
568
+ .imp-dpi select{font-size:12px;padding:4px 6px;border-radius:8px;border:1px solid #dcd5c6;background:#fff}
521
569
  .dir-picker-row{display:flex;gap:10px;align-items:center;width:100%;padding:8px 12px;border:0;border-bottom:1px solid #f2efe6;background:transparent;text-align:left;font-size:12.5px;color:#18343b;cursor:pointer}
522
570
  .dir-picker-row:last-child{border-bottom:0}
523
571
  .dir-picker-row:hover{background:#f6f9f8}
@@ -1196,15 +1244,18 @@ function openDirPicker(options = {}) {
1196
1244
  const modal = getEl("dirPickerModal");
1197
1245
  if (!modal) return;
1198
1246
  const dd = readDataDir(DIAG_DATA || STATE.data);
1199
- if (dd.envOverride.active) {
1247
+ const targetId = options.targetId || "frDataRoot";
1248
+ // 只读闸只针对「选数据目录」;导入 PDF 选的是别人的目录,不该被 env 覆盖挡住。
1249
+ const dataDirTarget = targetId === "frDataRoot" || targetId === "frProjectsRoot";
1250
+ if (dataDirTarget && dd.envOverride.active) {
1200
1251
  // env 覆盖时目录是只读的,选也没用——直接说清楚,不弹一个点不动的界面。
1201
1252
  flash(ENV_READONLY_TEXT + ",改环境变量后重启 DSH", "error");
1202
1253
  return;
1203
1254
  }
1204
1255
  DIR_PICKER = {
1205
1256
  mode: options.mode === "switch" ? "switch" : "fill",
1206
- targetId: options.targetId || "frDataRoot",
1207
- path: options.path || dd.dataRoot.value || dd.dataRoot.effective || "",
1257
+ targetId,
1258
+ path: options.path || (dataDirTarget ? dd.dataRoot.value || dd.dataRoot.effective || "" : ""),
1208
1259
  data: null,
1209
1260
  loading: true,
1210
1261
  error: "",
@@ -1297,6 +1348,16 @@ async function confirmDirPicker() {
1297
1348
  const path = st.data.path;
1298
1349
  if (st.mode === "fill") {
1299
1350
  const targetId = st.targetId;
1351
+ // 非数据目录的填写目标(导入 PDF 的目录)只往输入框里填,不借首次引导那张表单。
1352
+ if (targetId !== "frDataRoot" && targetId !== "frProjectsRoot") {
1353
+ closeDirPicker();
1354
+ const input = getEl(targetId);
1355
+ if (input) {
1356
+ input.value = path;
1357
+ input.dispatchEvent(new Event("input", { bubbles: true }));
1358
+ }
1359
+ return;
1360
+ }
1300
1361
  closeDirPicker();
1301
1362
  const wizard = getEl("firstRunModal");
1302
1363
  const wizardOpen = wizard && wizard.hidden === false;
@@ -4858,6 +4919,239 @@ getEl("newDeckForm").addEventListener("submit", async (event) => {
4858
4919
  }
4859
4920
  });
4860
4921
 
4922
+ /* ---------------- 导入 PDF:点一份 PDF → 每页整页图建 deck;同目录 md 顺手抽(F-203) ----------------
4923
+ * 交互目标(负责人反馈):不要填目录 / 文件名 / 标题——直接浏览、点那份 PDF,同文件夹的 md 有就抽、没有拉倒。
4924
+ * 目录列表:GET /api/deck-studio.dir-browse?files=1(只回 pdf / md)
4925
+ * 子目录搜索:GET /api/deck-studio.dir-browse?find=pdf&path=…(深度 ≤3、最多 60 份,解决「这层只有文件夹」)
4926
+ * 导入:POST /api/deck-studio.deck-import { pdf: <绝对路径>, dpi } */
4927
+ const IMPORT_ERR_TEXT = {
4928
+ "pdftoppm-missing": "本机缺 poppler 的 pdftoppm:macOS 执行 `brew install poppler`,Linux 装 poppler-utils,装完重启 DSH。",
4929
+ "no-pdf": "这个文件夹里没有 PDF(或者这份 PDF 读不到)。",
4930
+ "not-found": "这个路径不存在,检查一下。",
4931
+ "bad-dir": "这个路径不对:要绝对路径,单次最多 40 页。",
4932
+ };
4933
+
4934
+ function importErrText(code, message) {
4935
+ return IMPORT_ERR_TEXT[code] || message || "导入失败";
4936
+ }
4937
+
4938
+ const IMP = {
4939
+ path: "",
4940
+ mode: "browse", // browse=看当前目录;found=子目录搜索结果的平铺列表
4941
+ dirs: [],
4942
+ pdfs: [],
4943
+ mdCount: 0,
4944
+ foundFiles: [],
4945
+ foundTruncated: false,
4946
+ stale: false, // 后端还是旧版(dir-browse 没返回 files)——必须重启 DSH
4947
+ loading: false,
4948
+ error: "",
4949
+ selected: null,
4950
+ busy: false,
4951
+ };
4952
+
4953
+ function impSize(bytes) {
4954
+ const n = Number(bytes) || 0;
4955
+ if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
4956
+ return `${Math.max(1, Math.round(n / 1024))} KB`;
4957
+ }
4958
+
4959
+ function impSelectedText() {
4960
+ if (!IMP.selected) return "还没有选 PDF:点上面的 📄 文件即可。";
4961
+ const md = IMP.mdCount ? `同目录 ${IMP.mdCount} 份 md 会一起抽` : "同目录没有 md —— 只导入整页图";
4962
+ return `已选:${IMP.selected.name} · ${md}`;
4963
+ }
4964
+
4965
+ function impFileRow(file, label) {
4966
+ const selected = IMP.selected?.path === file.path;
4967
+ return `<button type="button" class="dir-picker-row imp-file${selected ? " selected" : ""}" data-imp-pdf="${esc(file.path)}" title="${esc(file.path)}"><span class="dir-picker-name">📄 ${esc(label)}</span><span class="dir-picker-meta">${impSize(file.sizeBytes)}</span></button>`;
4968
+ }
4969
+
4970
+ function renderImportPicker() {
4971
+ const list = getEl("impList");
4972
+ if (!list) return;
4973
+ getEl("impPath").textContent = IMP.mode === "found" ? `${IMP.path}(含子目录)` : IMP.path || "—";
4974
+ getEl("impPath").title = IMP.path || "";
4975
+ getEl("impUp").disabled = IMP.loading || !IMP.path || IMP.mode === "found";
4976
+ getEl("impSubmit").disabled = !IMP.selected || IMP.busy;
4977
+ getEl("impSelected").textContent = impSelectedText();
4978
+
4979
+ if (IMP.loading) {
4980
+ list.innerHTML = `<div class="dir-picker-empty">正在读目录…</div>`;
4981
+ return;
4982
+ }
4983
+ if (IMP.error) {
4984
+ list.innerHTML = `<div class="dir-picker-empty">${esc(IMP.error)}</div>`;
4985
+ return;
4986
+ }
4987
+ if (IMP.stale) {
4988
+ list.innerHTML = `<div class="dir-picker-empty">这个弹层是新版,但**正在运行的 Deckline 后端还是旧版**:<br>接口没返回文件列表,所以只看得到文件夹。<br><br>重启 DSH(<code>dsh --profile deck-studio</code> 或 DSH 桌面端)后再打开这里。</div>`;
4989
+ return;
4990
+ }
4991
+
4992
+ if (IMP.mode === "found") {
4993
+ const rows = IMP.foundFiles.length
4994
+ ? IMP.foundFiles.map((file) => impFileRow(file, file.path.replace(`${IMP.path.replace(/[\\/]+$/, "")}/`, ""))).join("")
4995
+ : `<div class="dir-picker-empty">子目录里也没找到 PDF(只看 3 层)。</div>`;
4996
+ list.innerHTML = `<button type="button" class="dir-picker-row" data-imp-back="1"><span class="dir-picker-name">↩ 回到目录列表</span></button>${rows}${IMP.foundTruncated ? `<div class="dir-picker-empty">只列了前 60 份,缩小范围再找。</div>` : ""}`;
4997
+ return;
4998
+ }
4999
+
5000
+ const dirs = (IMP.dirs || []).filter((item) => !item.hidden);
5001
+ const pdfs = IMP.pdfs || [];
5002
+ const findRow = dirs.length
5003
+ ? `<button type="button" class="dir-picker-row imp-find" data-imp-find="1"><span class="dir-picker-name">🔍 ${pdfs.length ? "这层也有 PDF;在子目录里再找一遍" : "这层没有 PDF —— 在子目录里找(≤3 层)"}</span></button>`
5004
+ : "";
5005
+ const body = [
5006
+ ...pdfs.map((file) => impFileRow(file, file.name)),
5007
+ ...dirs.map((dir) => `<button type="button" class="dir-picker-row" data-imp-dir="${esc(dir.path)}" title="${esc(dir.path)}"><span class="dir-picker-name">📁 ${esc(dir.name)}</span></button>`),
5008
+ ].join("");
5009
+ if (!body) {
5010
+ list.innerHTML = `<div class="dir-picker-empty">这个文件夹里没有子目录,也没有 PDF。点「↑ 上一级」换个位置。</div>`;
5011
+ return;
5012
+ }
5013
+ list.innerHTML = body + findRow;
5014
+ }
5015
+
5016
+ async function impFetchJson(url) {
5017
+ const res = await fetch(url, { headers: { accept: "application/json" } });
5018
+ let payload = {};
5019
+ try { payload = await res.json(); } catch { /* 非 JSON 兜底 */ }
5020
+ if (!res.ok || !payload?.ok) {
5021
+ const failure = new Error(payload?.error?.message || `HTTP ${res.status}`);
5022
+ failure.code = payload?.error?.code || "";
5023
+ throw failure;
5024
+ }
5025
+ return payload;
5026
+ }
5027
+
5028
+ async function impLoad(pathValue) {
5029
+ IMP.loading = true;
5030
+ IMP.error = "";
5031
+ IMP.selected = null;
5032
+ IMP.mode = "browse";
5033
+ IMP.stale = false;
5034
+ renderImportPicker();
5035
+ try {
5036
+ const query = pathValue ? `?files=1&path=${encodeURIComponent(pathValue)}` : "?files=1";
5037
+ const payload = await impFetchJson("/api/deck-studio.dir-browse" + query);
5038
+ const dir = payload.dir || {};
5039
+ IMP.path = dir.path || pathValue || "";
5040
+ IMP.dirs = dir.dirs || [];
5041
+ // 旧版后端不会返回 files:明确说「要重启」,别让人以为目录里真没 PDF。
5042
+ IMP.stale = !Array.isArray(dir.files);
5043
+ const files = Array.isArray(dir.files) ? dir.files : [];
5044
+ IMP.pdfs = files.filter((file) => file.kind === "pdf");
5045
+ IMP.mdCount = files.filter((file) => file.kind === "md").length;
5046
+ } catch (error) {
5047
+ IMP.error = importErrText(error?.code, error?.message);
5048
+ IMP.dirs = [];
5049
+ IMP.pdfs = [];
5050
+ IMP.mdCount = 0;
5051
+ } finally {
5052
+ IMP.loading = false;
5053
+ renderImportPicker();
5054
+ }
5055
+ }
5056
+
5057
+ async function impFindPdfs() {
5058
+ IMP.loading = true;
5059
+ IMP.error = "";
5060
+ renderImportPicker();
5061
+ try {
5062
+ const payload = await impFetchJson(`/api/deck-studio.dir-browse?find=pdf&path=${encodeURIComponent(IMP.path)}`);
5063
+ IMP.foundFiles = payload.found?.files || [];
5064
+ IMP.foundTruncated = Boolean(payload.found?.truncated);
5065
+ IMP.mode = "found";
5066
+ } catch (error) {
5067
+ IMP.error = importErrText(error?.code, error?.message);
5068
+ } finally {
5069
+ IMP.loading = false;
5070
+ renderImportPicker();
5071
+ }
5072
+ }
5073
+
5074
+ const IMP_LAST_DIR_KEY = "deckImportLastDir";
5075
+
5076
+ function openImportPdfModal() {
5077
+ IMP.busy = false;
5078
+ IMP.selected = null;
5079
+ IMP.stale = false;
5080
+ getEl("impState").textContent = "";
5081
+ getEl("impDpi").value = "120";
5082
+ getEl("importPdfModal").hidden = false;
5083
+ let start = "";
5084
+ try { start = localStorage.getItem(IMP_LAST_DIR_KEY) || ""; } catch { start = ""; }
5085
+ if (!start) start = String(STATE.data?.config?.dataRoot || "").trim();
5086
+ impLoad(start);
5087
+ }
5088
+
5089
+ function closeImportPdfModal() {
5090
+ getEl("importPdfModal").hidden = true;
5091
+ }
5092
+
5093
+ getEl("importPdfBtn").addEventListener("click", openImportPdfModal);
5094
+ getEl("impClose").addEventListener("click", closeImportPdfModal);
5095
+ getEl("impCancel").addEventListener("click", closeImportPdfModal);
5096
+ getEl("importPdfModal").addEventListener("click", (event) => { if (event.target === getEl("importPdfModal")) closeImportPdfModal(); });
5097
+ getEl("impUp").addEventListener("click", () => {
5098
+ if (!IMP.path || IMP.mode === "found") return;
5099
+ const parent = IMP.path.replace(/[\\/]+$/, "").replace(/[\\/][^\\/]+$/, "");
5100
+ impLoad(parent || "/");
5101
+ });
5102
+ getEl("impList").addEventListener("click", (event) => {
5103
+ if (event.target.closest("[data-imp-back]")) {
5104
+ impLoad(IMP.path);
5105
+ return;
5106
+ }
5107
+ if (event.target.closest("[data-imp-find]")) {
5108
+ impFindPdfs();
5109
+ return;
5110
+ }
5111
+ const dirHit = event.target.closest("[data-imp-dir]");
5112
+ if (dirHit) {
5113
+ impLoad(dirHit.dataset.impDir);
5114
+ return;
5115
+ }
5116
+ const pdfHit = event.target.closest("[data-imp-pdf]");
5117
+ if (pdfHit) {
5118
+ IMP.selected = [...IMP.pdfs, ...IMP.foundFiles].find((file) => file.path === pdfHit.dataset.impPdf) || null;
5119
+ renderImportPicker();
5120
+ }
5121
+ });
5122
+ getEl("impSubmit").addEventListener("click", async () => {
5123
+ if (!IMP.selected || IMP.busy) return;
5124
+ IMP.busy = true;
5125
+ getEl("impSubmit").disabled = true;
5126
+ getEl("impState").textContent = `正在把 ${IMP.selected.name} 每页转成整页图(页数多会慢一点,别关页面)…`;
5127
+ try {
5128
+ const res = await fetch("/api/deck-studio.deck-import", {
5129
+ method: "POST",
5130
+ headers: { "content-type": "application/json" },
5131
+ body: JSON.stringify({ pdf: IMP.selected.path, dpi: Number(getEl("impDpi").value) || 120 }),
5132
+ });
5133
+ const result = await res.json().catch(() => ({}));
5134
+ if (!res.ok || !result.ok) {
5135
+ const failure = new Error(result?.error?.message || `HTTP ${res.status}`);
5136
+ failure.code = result?.error?.code || "";
5137
+ throw failure;
5138
+ }
5139
+ try { localStorage.setItem(IMP_LAST_DIR_KEY, IMP.path); } catch { /* 隐私模式忽略 */ }
5140
+ const parts = [`已导入 ${result.pages} 页(${result.dpi} dpi)`];
5141
+ if (result.markdown?.length) parts.push(`抽到 md:${result.markdown.join("、")}`);
5142
+ if (result.warnings?.length) parts.push(`注意:${result.warnings.join(";")}`);
5143
+ getEl("impState").textContent = parts.join(" · ");
5144
+ closeImportPdfModal();
5145
+ flash(`已导入「${result.deck?.label || ""}」:${result.pages} 页整页图`, "ok");
5146
+ if (result.deck?.deckId) await showDeck(result.deck.deckId);
5147
+ } catch (error) {
5148
+ getEl("impState").textContent = "导入失败:" + importErrText(error?.code, error?.message);
5149
+ } finally {
5150
+ IMP.busy = false;
5151
+ getEl("impSubmit").disabled = !IMP.selected;
5152
+ }
5153
+ });
5154
+
4861
5155
  // F-63:标签是动态渲染的,用事件委托
4862
5156
  getEl("observeTabs").addEventListener("click", (event) => {
4863
5157
  const btn = event.target.closest(".observe-tab");