dsh-plugin-deck-studio 0.2.24 → 0.2.27
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 +329 -6
- package/lib/capabilities.js +3 -2
- package/lib/data-dir.js +9 -0
- package/lib/pdf-import.js +270 -0
- package/lib/present-native.js +17 -0
- package/lib/review-agent.js +2 -1
- package/package.json +1 -1
- package/seeds/docs/install.md +7 -0
- package/ui/workbench.html +342 -5
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 / 提案时按新目标字数写。
|
|
@@ -2268,7 +2458,12 @@ export function createService(config, logger, env = process.env) {
|
|
|
2268
2458
|
appendixCount: adapterOf(deck.deckId)?.available() ? adapterOf(deck.deckId).overview().appendixCount ?? 0 : workspace.manifest(deck.deckId)?.appendix?.count ?? 0,
|
|
2269
2459
|
appendixRows: workspace.manifest(deck.deckId)?.appendix?.rows ?? [],
|
|
2270
2460
|
}));
|
|
2271
|
-
|
|
2461
|
+
// F-205:显式点名要打开某本 deck、但这次快照里还没有它(刚导入 / 刚创建,列表还没跟上)时,
|
|
2462
|
+
// 不要静默退回第一本——前端拿到 requestedMissing 会立刻重试,避免「导入成功却打开别的 deck」。
|
|
2463
|
+
const requestedMissing = Boolean(requestedDeckId) && !decks.some((deck) => deck.deckId === requestedDeckId);
|
|
2464
|
+
const activeId = requestedMissing
|
|
2465
|
+
? decks[0]?.deckId ?? workspace.listDeckIds()[0] ?? null
|
|
2466
|
+
: requestedDeckId ?? decks[0]?.deckId ?? workspace.listDeckIds()[0] ?? null;
|
|
2272
2467
|
// P0:进 deck 时先体检(规格 §2「进入某本 deck 时,每次,读 manifest 之前」)。
|
|
2273
2468
|
// mismatch / partial 时不 ensureDeck:那会凭空建出空的 decks/<id>/,旧图也找不回(禁止项)。
|
|
2274
2469
|
const dataDirInfo = dataDirStateOf({ config, store, workspace, adapters, env }, activeId);
|
|
@@ -2385,6 +2580,9 @@ export function createService(config, logger, env = process.env) {
|
|
|
2385
2580
|
renderSettings: renderSettings(),
|
|
2386
2581
|
decks,
|
|
2387
2582
|
active,
|
|
2583
|
+
// F-205:这次请求点名的 deck 是否存在(true = 列表还没跟上,前端重试即可)。
|
|
2584
|
+
requestedDeckId: requestedDeckId ?? null,
|
|
2585
|
+
requestedMissing,
|
|
2388
2586
|
designProjects: designProjects(),
|
|
2389
2587
|
bindings: listBindings(),
|
|
2390
2588
|
proposals: base.pendingProposals,
|
|
@@ -2399,7 +2597,7 @@ export function createService(config, logger, env = process.env) {
|
|
|
2399
2597
|
bootstrapDesignStyles();
|
|
2400
2598
|
}
|
|
2401
2599
|
|
|
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 }
|
|
2600
|
+
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
2601
|
}
|
|
2404
2602
|
|
|
2405
2603
|
/* ─────────────────────────────────────────────────────────────────────────
|
|
@@ -2487,6 +2685,25 @@ function noteDataDirTransitions(serviceLike, report, trigger = "poll") {
|
|
|
2487
2685
|
/**
|
|
2488
2686
|
* 数据目录载荷:黄条 / 设置页 / 自检共用(不重算解析,只合并最新体检)。
|
|
2489
2687
|
*/
|
|
2688
|
+
/**
|
|
2689
|
+
* 「指针覆盖了 profile 配置」的事实(没有冲突返回 null)。
|
|
2690
|
+
* 这是本次事故的根因:web profile 没配 dataRoot、desktop 配了仓库 .deck,而指针全机共享——
|
|
2691
|
+
* 谁写的指针谁说了算,界面上却只显示「当前 = 指针」,看不出 profile 里还写着另一个目录。
|
|
2692
|
+
*/
|
|
2693
|
+
function pointerConflictOf(resolved = {}) {
|
|
2694
|
+
const profileDataRoot = String(resolved.profileDataRoot ?? "").trim();
|
|
2695
|
+
const fact = resolved.dataRoot ?? {};
|
|
2696
|
+
if (!profileDataRoot || fact.source !== "pointer") return null;
|
|
2697
|
+
if (String(fact.value ?? "") === profileDataRoot) return null;
|
|
2698
|
+
return {
|
|
2699
|
+
profileDataRoot,
|
|
2700
|
+
pointerDataRoot: String(fact.value ?? ""),
|
|
2701
|
+
pointerFile: String(resolved.pointerFile?.path ?? ""),
|
|
2702
|
+
setBy: String(resolved.pointerFile?.setBy ?? ""),
|
|
2703
|
+
updatedAt: String(resolved.pointerFile?.updatedAt ?? ""),
|
|
2704
|
+
};
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2490
2707
|
function dataDirStateOf(serviceLike, deckId = null, { refresh = false, countImages = false } = {}) {
|
|
2491
2708
|
const { config } = serviceLike;
|
|
2492
2709
|
const resolved = config.dataDir ?? {};
|
|
@@ -2503,6 +2720,8 @@ function dataDirStateOf(serviceLike, deckId = null, { refresh = false, countImag
|
|
|
2503
2720
|
firstRun: resolved.firstRun === true,
|
|
2504
2721
|
processCwd: resolved.processCwd ?? process.cwd(),
|
|
2505
2722
|
sourceLabel: resolved.sourceLabel ?? "",
|
|
2723
|
+
// 指针赢过 profile 时的「为什么」:profile 配的是哪个目录、指针文件在哪。前端只在有冲突时多画一行。
|
|
2724
|
+
pointerConflict: pointerConflictOf(resolved),
|
|
2506
2725
|
banner: dataDirBannerFacts({ report, deckId, envOverride: resolved.envOverride?.active === true, dataRoot: config.dataRoot }),
|
|
2507
2726
|
decks: report.decks,
|
|
2508
2727
|
candidates: report.candidates,
|
|
@@ -2846,7 +3065,7 @@ export function apply(ctx, rawConfig) {
|
|
|
2846
3065
|
* @param {string} pathValue 绝对路径;空 = 当前生效的 dataRoot
|
|
2847
3066
|
* @returns {{path: string, parent: string, isRoot: boolean, truncated: boolean, dirs: {name: string, path: string, hidden: boolean, hasScript: boolean, deckCount: number, isDataRoot: boolean}[]}}
|
|
2848
3067
|
*/
|
|
2849
|
-
function browseDir(pathValue) {
|
|
3068
|
+
function browseDir(pathValue, { includeFiles = false } = {}) {
|
|
2850
3069
|
const raw = String(pathValue ?? "").trim();
|
|
2851
3070
|
if (raw && !isAbsolute(raw)) {
|
|
2852
3071
|
const error = new Error(`必须是绝对路径:${raw}`);
|
|
@@ -2877,6 +3096,7 @@ export function apply(ctx, rawConfig) {
|
|
|
2877
3096
|
throw failed;
|
|
2878
3097
|
}
|
|
2879
3098
|
const dirs = [];
|
|
3099
|
+
const files = [];
|
|
2880
3100
|
let truncated = false;
|
|
2881
3101
|
for (const entry of entries) {
|
|
2882
3102
|
if (dirs.length >= BROWSE_MAX_ENTRIES) {
|
|
@@ -2893,7 +3113,22 @@ export function apply(ctx, rawConfig) {
|
|
|
2893
3113
|
childIsDir = false;
|
|
2894
3114
|
}
|
|
2895
3115
|
}
|
|
2896
|
-
if (!childIsDir)
|
|
3116
|
+
if (!childIsDir) {
|
|
3117
|
+
// 导入 PDF 要从这里直接点文件:只认 pdf / md,其它一律不列(不暴露目录里有什么)。
|
|
3118
|
+
if (includeFiles && entry.isFile()) {
|
|
3119
|
+
const kind = /\.pdf$/i.test(entry.name) ? "pdf" : /\.md$/i.test(entry.name) ? "md" : "";
|
|
3120
|
+
if (kind) {
|
|
3121
|
+
let sizeBytes = 0;
|
|
3122
|
+
try {
|
|
3123
|
+
sizeBytes = statSync(childPath).size;
|
|
3124
|
+
} catch {
|
|
3125
|
+
sizeBytes = 0;
|
|
3126
|
+
}
|
|
3127
|
+
files.push({ name: entry.name, path: childPath, kind, sizeBytes });
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
continue;
|
|
3131
|
+
}
|
|
2897
3132
|
dirs.push({
|
|
2898
3133
|
name: entry.name,
|
|
2899
3134
|
path: childPath,
|
|
@@ -2904,6 +3139,7 @@ export function apply(ctx, rawConfig) {
|
|
|
2904
3139
|
});
|
|
2905
3140
|
}
|
|
2906
3141
|
dirs.sort((a, b) => a.name.localeCompare(b.name));
|
|
3142
|
+
files.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === "pdf" ? -1 : 1));
|
|
2907
3143
|
const parentPath = dirname(target);
|
|
2908
3144
|
return {
|
|
2909
3145
|
path: target,
|
|
@@ -2911,16 +3147,81 @@ export function apply(ctx, rawConfig) {
|
|
|
2911
3147
|
isRoot: parentPath === target,
|
|
2912
3148
|
truncated,
|
|
2913
3149
|
dirs,
|
|
3150
|
+
...(includeFiles ? { files } : {}),
|
|
2914
3151
|
};
|
|
2915
3152
|
}
|
|
2916
3153
|
|
|
3154
|
+
/** 有界地找子目录里的 PDF(导入 PDF 用:先看当前层,找不到再一键往下找)。 */
|
|
3155
|
+
function findPdfFiles(rootDir, { maxDepth = 3, maxFiles = 60 } = {}) {
|
|
3156
|
+
const found = [];
|
|
3157
|
+
let truncated = false;
|
|
3158
|
+
const walk = (dir, depth) => {
|
|
3159
|
+
if (found.length >= maxFiles) {
|
|
3160
|
+
truncated = true;
|
|
3161
|
+
return;
|
|
3162
|
+
}
|
|
3163
|
+
let entries = [];
|
|
3164
|
+
try {
|
|
3165
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
3166
|
+
} catch {
|
|
3167
|
+
return;
|
|
3168
|
+
}
|
|
3169
|
+
for (const entry of entries) {
|
|
3170
|
+
if (found.length >= maxFiles) {
|
|
3171
|
+
truncated = true;
|
|
3172
|
+
return;
|
|
3173
|
+
}
|
|
3174
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
3175
|
+
const childPath = join(dir, entry.name);
|
|
3176
|
+
if (entry.isFile() && /\.pdf$/i.test(entry.name)) {
|
|
3177
|
+
let sizeBytes = 0;
|
|
3178
|
+
try {
|
|
3179
|
+
sizeBytes = statSync(childPath).size;
|
|
3180
|
+
} catch {
|
|
3181
|
+
sizeBytes = 0;
|
|
3182
|
+
}
|
|
3183
|
+
found.push({ name: entry.name, path: childPath, kind: "pdf", sizeBytes });
|
|
3184
|
+
continue;
|
|
3185
|
+
}
|
|
3186
|
+
if (depth < maxDepth && entry.isDirectory()) walk(childPath, depth + 1);
|
|
3187
|
+
}
|
|
3188
|
+
};
|
|
3189
|
+
walk(rootDir, 0);
|
|
3190
|
+
found.sort((a, b) => a.path.localeCompare(b.path));
|
|
3191
|
+
return { files: found, truncated };
|
|
3192
|
+
}
|
|
3193
|
+
|
|
2917
3194
|
// 目录浏览(目录选择器):GET ?path=<绝对路径>;缺省 = 当前 dataRoot。
|
|
2918
3195
|
// 只列子目录名 + deckCount,不读内容、不返回大小 / 权限 / 环境变量;切换窗口内复用切换闸 409。
|
|
2919
3196
|
registerRoute(ROUTES.dirBrowse, (request) => {
|
|
2920
3197
|
if (switching) return switchingResponse();
|
|
2921
3198
|
const url = new URL(request?.url ?? "/", "http://localhost");
|
|
2922
3199
|
try {
|
|
2923
|
-
|
|
3200
|
+
const includeFiles = url.searchParams.get("files") === "1";
|
|
3201
|
+
if (url.searchParams.get("find") === "pdf") {
|
|
3202
|
+
const raw = String(url.searchParams.get("path") ?? "").trim();
|
|
3203
|
+
if (raw && !isAbsolute(raw)) {
|
|
3204
|
+
const error = new Error(`必须是绝对路径:${raw}`);
|
|
3205
|
+
error.code = "bad-path";
|
|
3206
|
+
throw error;
|
|
3207
|
+
}
|
|
3208
|
+
const target = resolve(raw || config.dataRoot || process.cwd());
|
|
3209
|
+
let stats;
|
|
3210
|
+
try {
|
|
3211
|
+
stats = statSync(target);
|
|
3212
|
+
} catch {
|
|
3213
|
+
const error = new Error(`目录不存在:${target}`);
|
|
3214
|
+
error.code = "not-found";
|
|
3215
|
+
throw error;
|
|
3216
|
+
}
|
|
3217
|
+
if (!stats.isDirectory()) {
|
|
3218
|
+
const error = new Error(`不是目录:${target}`);
|
|
3219
|
+
error.code = "not-a-dir";
|
|
3220
|
+
throw error;
|
|
3221
|
+
}
|
|
3222
|
+
return json({ ok: true, found: { path: target, ...findPdfFiles(target) } });
|
|
3223
|
+
}
|
|
3224
|
+
return json({ ok: true, dir: browseDir(url.searchParams.get("path") ?? "", { includeFiles }) });
|
|
2924
3225
|
} catch (error) {
|
|
2925
3226
|
const code = error?.code ?? "browse-failed";
|
|
2926
3227
|
const status = code === "switching" ? 409 : code === "browse-failed" ? 500 : 400;
|
|
@@ -2994,6 +3295,28 @@ export function apply(ctx, rawConfig) {
|
|
|
2994
3295
|
return json({ ok: true, deck: service.createDeck(body) });
|
|
2995
3296
|
}, ["POST"]);
|
|
2996
3297
|
|
|
3298
|
+
// PDF 导入:body { dir, file?, title?, dirName?, dpi? }。错误按 code 分支,message 只做兜底(与 dir-browse 一致)。
|
|
3299
|
+
registerRoute(ROUTES.deckImport, async (request) => {
|
|
3300
|
+
if ((request?.method ?? "POST").toUpperCase() !== "POST") return json({ ok: false, error: { code: "method", message: "POST only" } }, 405);
|
|
3301
|
+
const body = await request.json().catch(() => ({}));
|
|
3302
|
+
try {
|
|
3303
|
+
return json({ ok: true, ...service.importPdfDeck(body) });
|
|
3304
|
+
} catch (error) {
|
|
3305
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3306
|
+
const code = error?.code === "pdftoppm-missing"
|
|
3307
|
+
? "pdftoppm-missing"
|
|
3308
|
+
: /不在|没有 PDF|没有这份 PDF|份 PDF|不是 PDF|PDF 不存在/.test(message)
|
|
3309
|
+
? "no-pdf"
|
|
3310
|
+
: /不存在/.test(message)
|
|
3311
|
+
? "not-found"
|
|
3312
|
+
: /超过单次导入上限|绝对路径|请先选/.test(message)
|
|
3313
|
+
? "bad-dir"
|
|
3314
|
+
: "import-failed";
|
|
3315
|
+
const status = code === "import-failed" ? 500 : code === "pdftoppm-missing" ? 501 : 400;
|
|
3316
|
+
return json({ ok: false, error: { code, message } }, status);
|
|
3317
|
+
}
|
|
3318
|
+
}, ["POST"]);
|
|
3319
|
+
|
|
2997
3320
|
// 生图设置(F-126):body { mode, endpoint, model, apiKeyEnv, apiKeyFile, size },存本机数据目录并即刻生效。
|
|
2998
3321
|
registerRoute(ROUTES.renderSettings, async (request) => {
|
|
2999
3322
|
if ((request?.method ?? "GET").toUpperCase() === "GET") return json({ ok: true, ...service.renderSettings() });
|
package/lib/capabilities.js
CHANGED
|
@@ -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
|
|
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
|
|
221
|
+
presentNative(ctx.tools);
|
|
221
222
|
ctx.tools.guard(createCapabilityGuard(ctx, config));
|
|
222
223
|
const provider = createResearchProvider(ctx, config);
|
|
223
224
|
ctx.tools.register({
|
package/lib/data-dir.js
CHANGED
|
@@ -232,6 +232,8 @@ function decksDirEmpty(dataRoot) {
|
|
|
232
232
|
/** 冲突 warn 只打一次:5s 轮询与多次重建服务都不重复刷屏。 */
|
|
233
233
|
let warnedDataRootConflict = false;
|
|
234
234
|
let warnedProjectsRootConflict = false;
|
|
235
|
+
/** 指针 vs profile 的冲突按「指针→profile」这一对去重:同一处冲突不刷屏,换了目录再提醒一次。 */
|
|
236
|
+
const warnedPointerProfilePairs = new Set();
|
|
235
237
|
|
|
236
238
|
export function resolveDataDir({ env = process.env, profileDataRoot = "", profileProjectsRoot = "", logger = null } = {}) {
|
|
237
239
|
const envDataRoot = normalizeDirPath(env[DATA_DIR_ENV] ?? "", env);
|
|
@@ -253,6 +255,13 @@ export function resolveDataDir({ env = process.env, profileDataRoot = "", profil
|
|
|
253
255
|
const pointerDataRoot = pointer.error || !isAbsolute(pointer.dataRoot) ? "" : pointer.dataRoot;
|
|
254
256
|
const pointerProjectsRoot = pointer.error || !isAbsolute(pointer.projectsRoot) ? "" : pointer.projectsRoot;
|
|
255
257
|
|
|
258
|
+
// 指针(用户点「切到这个目录」写下的)优先级高于 profile;两者不一致时以前是**完全静默**的,
|
|
259
|
+
// 表现成「我这本 deck 怎么没了」。留一条 warn,日志里能找到是谁把目录换掉的。
|
|
260
|
+
const pointerProfileKey = pointerDataRoot && profileRoot && pointerDataRoot !== profileRoot ? `${pointerDataRoot}→${profileRoot}` : "";
|
|
261
|
+
if (warn && pointerProfileKey && !warnedPointerProfilePairs.has(pointerProfileKey)) {
|
|
262
|
+
warnedPointerProfilePairs.add(pointerProfileKey);
|
|
263
|
+
warn(`dataRoot: 指针文件(${pointerDataRoot})与 profile 配置(${profileRoot})不一致,已按指针生效;要让 profile 配置生效就删掉指针文件 ${pointer.path}`);
|
|
264
|
+
}
|
|
256
265
|
const dataRoot = envDataRoot || pointerDataRoot || profileRoot || normalizeDirPath(defaultDataRoot(env), env);
|
|
257
266
|
const source = envDataRoot ? "env" : pointerDataRoot ? "pointer" : profileRoot ? "profile" : "default";
|
|
258
267
|
const projectsSource = envProjectsRoot ? "env" : pointerProjectsRoot ? "pointer" : profileProjects ? "profile" : "default";
|
|
@@ -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
|
+
}
|
package/lib/review-agent.js
CHANGED
|
@@ -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
|
|
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.
|
|
3
|
+
"version": "0.2.27",
|
|
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/seeds/docs/install.md
CHANGED
|
@@ -188,6 +188,13 @@ Deckline 的数据分两处:**你的 deck 源**(`projectsRoot`,纯 md +
|
|
|
188
188
|
`dataRoot` / `DSH_DECK_DATA_ROOT`,两边就各看各的。所以「换到另一个端口,内容变空 / 标题在但页面空白」先别怀疑端口:
|
|
189
189
|
去那个实例的 ⚙ 看「当前数据目录」,再和这边显示的路径比一比。
|
|
190
190
|
**换端口不改数据,换 dataRoot 才改数据。**
|
|
191
|
+
- **指针的优先级高于 profile**(「打开一片空白 + 黄条」最常见的真凶):`$DSH_HOME/deckline/location.json`
|
|
192
|
+
是全机共享的「用户选择」,解析顺序是 `env > 指针 > profile patch > 默认`。在一个实例里点过
|
|
193
|
+
「切到这个目录」,同一个 `$DSH_HOME` 下的其它 profile 也会跟着换目录——**哪怕它在 patch 里写了 `dataRoot`**。
|
|
194
|
+
想让各端稳定一致,二选一:① 每个 profile 的 patch 写**同一个** `dataRoot`,并删掉
|
|
195
|
+
`~/.dsh/deckline/location.json`;② 只用默认目录,不要在界面里切。真有冲突时黄条会多写一行
|
|
196
|
+
「你的 profile 里配的是 A,但指针文件把它改成了 B——指针优先;切换只改指针,不动文件」,
|
|
197
|
+
日志里也有一条 `dataRoot: 指针文件(B)与 profile 配置(A)不一致` 的 warn(每次冲突只打一次)。
|
|
191
198
|
- 报问题不用自己拼路径:⚙ →「当前数据目录与自检」底部 **「复制诊断信息」** 一键复制(内容由插件生成、已脱敏,
|
|
192
199
|
不含 key),贴出来就包含 cwd / dataRoot(含来源)/ projectsRoot / 每本 deck 有没有 `script/current.md`。
|
|
193
200
|
- 开发环境用独立 `dataRoot`,不要把运行数据写进仓库(见 §7「开发数据目录」)。
|
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}
|
|
@@ -782,9 +830,15 @@ function mdToHtml(markdown, options = {}) {
|
|
|
782
830
|
}
|
|
783
831
|
|
|
784
832
|
/* ---------------- 数据加载 ---------------- */
|
|
833
|
+
/* F-205:并发保护。导入 / 切 deck 会连着打好几次 workbench 接口,先发的旧请求如果后回来,
|
|
834
|
+
* 会把刚切到的新 deck 覆盖成旧数据(表现:导入成功却停在上一个 / 第一个 deck)。
|
|
835
|
+
* 只让**最后发起**的那次结果落地。 */
|
|
836
|
+
let LOAD_SEQ = 0;
|
|
785
837
|
async function loadWorkbench(deckId = null) {
|
|
838
|
+
const seq = ++LOAD_SEQ;
|
|
786
839
|
try {
|
|
787
840
|
const next = await route("workbench", deckId ? { deckId } : undefined);
|
|
841
|
+
if (seq !== LOAD_SEQ) return;
|
|
788
842
|
const active = next.active || {};
|
|
789
843
|
const signature = JSON.stringify([
|
|
790
844
|
active.deckId,
|
|
@@ -817,6 +871,7 @@ async function loadWorkbench(deckId = null) {
|
|
|
817
871
|
}
|
|
818
872
|
}
|
|
819
873
|
} catch (error) {
|
|
874
|
+
if (seq !== LOAD_SEQ) return;
|
|
820
875
|
STATE.demo = true;
|
|
821
876
|
STATE.data = demoData();
|
|
822
877
|
renderChrome();
|
|
@@ -1196,15 +1251,18 @@ function openDirPicker(options = {}) {
|
|
|
1196
1251
|
const modal = getEl("dirPickerModal");
|
|
1197
1252
|
if (!modal) return;
|
|
1198
1253
|
const dd = readDataDir(DIAG_DATA || STATE.data);
|
|
1199
|
-
|
|
1254
|
+
const targetId = options.targetId || "frDataRoot";
|
|
1255
|
+
// 只读闸只针对「选数据目录」;导入 PDF 选的是别人的目录,不该被 env 覆盖挡住。
|
|
1256
|
+
const dataDirTarget = targetId === "frDataRoot" || targetId === "frProjectsRoot";
|
|
1257
|
+
if (dataDirTarget && dd.envOverride.active) {
|
|
1200
1258
|
// env 覆盖时目录是只读的,选也没用——直接说清楚,不弹一个点不动的界面。
|
|
1201
1259
|
flash(ENV_READONLY_TEXT + ",改环境变量后重启 DSH", "error");
|
|
1202
1260
|
return;
|
|
1203
1261
|
}
|
|
1204
1262
|
DIR_PICKER = {
|
|
1205
1263
|
mode: options.mode === "switch" ? "switch" : "fill",
|
|
1206
|
-
targetId
|
|
1207
|
-
path: options.path || dd.dataRoot.value || dd.dataRoot.effective || "",
|
|
1264
|
+
targetId,
|
|
1265
|
+
path: options.path || (dataDirTarget ? dd.dataRoot.value || dd.dataRoot.effective || "" : ""),
|
|
1208
1266
|
data: null,
|
|
1209
1267
|
loading: true,
|
|
1210
1268
|
error: "",
|
|
@@ -1297,6 +1355,16 @@ async function confirmDirPicker() {
|
|
|
1297
1355
|
const path = st.data.path;
|
|
1298
1356
|
if (st.mode === "fill") {
|
|
1299
1357
|
const targetId = st.targetId;
|
|
1358
|
+
// 非数据目录的填写目标(导入 PDF 的目录)只往输入框里填,不借首次引导那张表单。
|
|
1359
|
+
if (targetId !== "frDataRoot" && targetId !== "frProjectsRoot") {
|
|
1360
|
+
closeDirPicker();
|
|
1361
|
+
const input = getEl(targetId);
|
|
1362
|
+
if (input) {
|
|
1363
|
+
input.value = path;
|
|
1364
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
1365
|
+
}
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1300
1368
|
closeDirPicker();
|
|
1301
1369
|
const wizard = getEl("firstRunModal");
|
|
1302
1370
|
const wizardOpen = wizard && wizard.hidden === false;
|
|
@@ -1460,11 +1528,16 @@ function renderDataDirBanner(data) {
|
|
|
1460
1528
|
: "⚠ 这本 deck 的正文和图片不在当前数据目录";
|
|
1461
1529
|
const currentRoot = dd?.dataRoot?.value || banner.dataRoot || "—";
|
|
1462
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
|
+
: "";
|
|
1463
1536
|
const candidates = banner.candidates || [];
|
|
1464
1537
|
const rows = candidates.length
|
|
1465
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("")
|
|
1466
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>`;
|
|
1467
|
-
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>`;
|
|
1468
1541
|
box.hidden = false;
|
|
1469
1542
|
box.querySelectorAll("[data-data-root]").forEach((node) => node.addEventListener("click", () => switchDataDirTo(node.dataset.dataRoot)));
|
|
1470
1543
|
box.querySelector("#dataDirOpenSettings")?.addEventListener("click", () => openSettingsPanel());
|
|
@@ -4858,6 +4931,266 @@ getEl("newDeckForm").addEventListener("submit", async (event) => {
|
|
|
4858
4931
|
}
|
|
4859
4932
|
});
|
|
4860
4933
|
|
|
4934
|
+
/* ---------------- 导入 PDF:点一份 PDF → 每页整页图建 deck;同目录 md 顺手抽(F-203) ----------------
|
|
4935
|
+
* 交互目标(负责人反馈):不要填目录 / 文件名 / 标题——直接浏览、点那份 PDF,同文件夹的 md 有就抽、没有拉倒。
|
|
4936
|
+
* 目录列表:GET /api/deck-studio.dir-browse?files=1(只回 pdf / md)
|
|
4937
|
+
* 子目录搜索:GET /api/deck-studio.dir-browse?find=pdf&path=…(深度 ≤3、最多 60 份,解决「这层只有文件夹」)
|
|
4938
|
+
* 导入:POST /api/deck-studio.deck-import { pdf: <绝对路径>, dpi } */
|
|
4939
|
+
const IMPORT_ERR_TEXT = {
|
|
4940
|
+
"pdftoppm-missing": "本机缺 poppler 的 pdftoppm:macOS 执行 `brew install poppler`,Linux 装 poppler-utils,装完重启 DSH。",
|
|
4941
|
+
"no-pdf": "这个文件夹里没有 PDF(或者这份 PDF 读不到)。",
|
|
4942
|
+
"not-found": "这个路径不存在,检查一下。",
|
|
4943
|
+
"bad-dir": "这个路径不对:要绝对路径,单次最多 40 页。",
|
|
4944
|
+
};
|
|
4945
|
+
|
|
4946
|
+
function importErrText(code, message) {
|
|
4947
|
+
return IMPORT_ERR_TEXT[code] || message || "导入失败";
|
|
4948
|
+
}
|
|
4949
|
+
|
|
4950
|
+
const IMP = {
|
|
4951
|
+
path: "",
|
|
4952
|
+
mode: "browse", // browse=看当前目录;found=子目录搜索结果的平铺列表
|
|
4953
|
+
dirs: [],
|
|
4954
|
+
pdfs: [],
|
|
4955
|
+
mdCount: 0,
|
|
4956
|
+
foundFiles: [],
|
|
4957
|
+
foundTruncated: false,
|
|
4958
|
+
stale: false, // 后端还是旧版(dir-browse 没返回 files)——必须重启 DSH
|
|
4959
|
+
loading: false,
|
|
4960
|
+
error: "",
|
|
4961
|
+
selected: null,
|
|
4962
|
+
busy: false,
|
|
4963
|
+
};
|
|
4964
|
+
|
|
4965
|
+
function impSize(bytes) {
|
|
4966
|
+
const n = Number(bytes) || 0;
|
|
4967
|
+
if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
4968
|
+
return `${Math.max(1, Math.round(n / 1024))} KB`;
|
|
4969
|
+
}
|
|
4970
|
+
|
|
4971
|
+
function impSelectedText() {
|
|
4972
|
+
if (!IMP.selected) return "还没有选 PDF:点上面的 📄 文件即可。";
|
|
4973
|
+
const md = IMP.mdCount ? `同目录 ${IMP.mdCount} 份 md 会一起抽` : "同目录没有 md —— 只导入整页图";
|
|
4974
|
+
return `已选:${IMP.selected.name} · ${md}`;
|
|
4975
|
+
}
|
|
4976
|
+
|
|
4977
|
+
function impFileRow(file, label) {
|
|
4978
|
+
const selected = IMP.selected?.path === file.path;
|
|
4979
|
+
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>`;
|
|
4980
|
+
}
|
|
4981
|
+
|
|
4982
|
+
function renderImportPicker() {
|
|
4983
|
+
const list = getEl("impList");
|
|
4984
|
+
if (!list) return;
|
|
4985
|
+
getEl("impPath").textContent = IMP.mode === "found" ? `${IMP.path}(含子目录)` : IMP.path || "—";
|
|
4986
|
+
getEl("impPath").title = IMP.path || "";
|
|
4987
|
+
getEl("impUp").disabled = IMP.loading || !IMP.path || IMP.mode === "found";
|
|
4988
|
+
getEl("impSubmit").disabled = !IMP.selected || IMP.busy;
|
|
4989
|
+
getEl("impSelected").textContent = impSelectedText();
|
|
4990
|
+
|
|
4991
|
+
if (IMP.loading) {
|
|
4992
|
+
list.innerHTML = `<div class="dir-picker-empty">正在读目录…</div>`;
|
|
4993
|
+
return;
|
|
4994
|
+
}
|
|
4995
|
+
if (IMP.error) {
|
|
4996
|
+
list.innerHTML = `<div class="dir-picker-empty">${esc(IMP.error)}</div>`;
|
|
4997
|
+
return;
|
|
4998
|
+
}
|
|
4999
|
+
if (IMP.stale) {
|
|
5000
|
+
list.innerHTML = `<div class="dir-picker-empty">这个弹层是新版,但**正在运行的 Deckline 后端还是旧版**:<br>接口没返回文件列表,所以只看得到文件夹。<br><br>重启 DSH(<code>dsh --profile deck-studio</code> 或 DSH 桌面端)后再打开这里。</div>`;
|
|
5001
|
+
return;
|
|
5002
|
+
}
|
|
5003
|
+
|
|
5004
|
+
if (IMP.mode === "found") {
|
|
5005
|
+
const rows = IMP.foundFiles.length
|
|
5006
|
+
? IMP.foundFiles.map((file) => impFileRow(file, file.path.replace(`${IMP.path.replace(/[\\/]+$/, "")}/`, ""))).join("")
|
|
5007
|
+
: `<div class="dir-picker-empty">子目录里也没找到 PDF(只看 3 层)。</div>`;
|
|
5008
|
+
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>` : ""}`;
|
|
5009
|
+
return;
|
|
5010
|
+
}
|
|
5011
|
+
|
|
5012
|
+
const dirs = (IMP.dirs || []).filter((item) => !item.hidden);
|
|
5013
|
+
const pdfs = IMP.pdfs || [];
|
|
5014
|
+
const findRow = dirs.length
|
|
5015
|
+
? `<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>`
|
|
5016
|
+
: "";
|
|
5017
|
+
const body = [
|
|
5018
|
+
...pdfs.map((file) => impFileRow(file, file.name)),
|
|
5019
|
+
...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>`),
|
|
5020
|
+
].join("");
|
|
5021
|
+
if (!body) {
|
|
5022
|
+
list.innerHTML = `<div class="dir-picker-empty">这个文件夹里没有子目录,也没有 PDF。点「↑ 上一级」换个位置。</div>`;
|
|
5023
|
+
return;
|
|
5024
|
+
}
|
|
5025
|
+
list.innerHTML = body + findRow;
|
|
5026
|
+
}
|
|
5027
|
+
|
|
5028
|
+
async function impFetchJson(url) {
|
|
5029
|
+
const res = await fetch(url, { headers: { accept: "application/json" } });
|
|
5030
|
+
let payload = {};
|
|
5031
|
+
try { payload = await res.json(); } catch { /* 非 JSON 兜底 */ }
|
|
5032
|
+
if (!res.ok || !payload?.ok) {
|
|
5033
|
+
const failure = new Error(payload?.error?.message || `HTTP ${res.status}`);
|
|
5034
|
+
failure.code = payload?.error?.code || "";
|
|
5035
|
+
throw failure;
|
|
5036
|
+
}
|
|
5037
|
+
return payload;
|
|
5038
|
+
}
|
|
5039
|
+
|
|
5040
|
+
async function impLoad(pathValue) {
|
|
5041
|
+
IMP.loading = true;
|
|
5042
|
+
IMP.error = "";
|
|
5043
|
+
IMP.selected = null;
|
|
5044
|
+
IMP.mode = "browse";
|
|
5045
|
+
IMP.stale = false;
|
|
5046
|
+
renderImportPicker();
|
|
5047
|
+
try {
|
|
5048
|
+
const query = pathValue ? `?files=1&path=${encodeURIComponent(pathValue)}` : "?files=1";
|
|
5049
|
+
const payload = await impFetchJson("/api/deck-studio.dir-browse" + query);
|
|
5050
|
+
const dir = payload.dir || {};
|
|
5051
|
+
IMP.path = dir.path || pathValue || "";
|
|
5052
|
+
IMP.dirs = dir.dirs || [];
|
|
5053
|
+
// 旧版后端不会返回 files:明确说「要重启」,别让人以为目录里真没 PDF。
|
|
5054
|
+
IMP.stale = !Array.isArray(dir.files);
|
|
5055
|
+
const files = Array.isArray(dir.files) ? dir.files : [];
|
|
5056
|
+
IMP.pdfs = files.filter((file) => file.kind === "pdf");
|
|
5057
|
+
IMP.mdCount = files.filter((file) => file.kind === "md").length;
|
|
5058
|
+
} catch (error) {
|
|
5059
|
+
IMP.error = importErrText(error?.code, error?.message);
|
|
5060
|
+
IMP.dirs = [];
|
|
5061
|
+
IMP.pdfs = [];
|
|
5062
|
+
IMP.mdCount = 0;
|
|
5063
|
+
} finally {
|
|
5064
|
+
IMP.loading = false;
|
|
5065
|
+
renderImportPicker();
|
|
5066
|
+
}
|
|
5067
|
+
}
|
|
5068
|
+
|
|
5069
|
+
async function impFindPdfs() {
|
|
5070
|
+
IMP.loading = true;
|
|
5071
|
+
IMP.error = "";
|
|
5072
|
+
renderImportPicker();
|
|
5073
|
+
try {
|
|
5074
|
+
const payload = await impFetchJson(`/api/deck-studio.dir-browse?find=pdf&path=${encodeURIComponent(IMP.path)}`);
|
|
5075
|
+
IMP.foundFiles = payload.found?.files || [];
|
|
5076
|
+
IMP.foundTruncated = Boolean(payload.found?.truncated);
|
|
5077
|
+
IMP.mode = "found";
|
|
5078
|
+
} catch (error) {
|
|
5079
|
+
IMP.error = importErrText(error?.code, error?.message);
|
|
5080
|
+
} finally {
|
|
5081
|
+
IMP.loading = false;
|
|
5082
|
+
renderImportPicker();
|
|
5083
|
+
}
|
|
5084
|
+
}
|
|
5085
|
+
|
|
5086
|
+
const IMP_LAST_DIR_KEY = "deckImportLastDir";
|
|
5087
|
+
|
|
5088
|
+
function openImportPdfModal() {
|
|
5089
|
+
IMP.busy = false;
|
|
5090
|
+
IMP.selected = null;
|
|
5091
|
+
IMP.stale = false;
|
|
5092
|
+
getEl("impState").textContent = "";
|
|
5093
|
+
getEl("impDpi").value = "120";
|
|
5094
|
+
getEl("importPdfModal").hidden = false;
|
|
5095
|
+
let start = "";
|
|
5096
|
+
try { start = localStorage.getItem(IMP_LAST_DIR_KEY) || ""; } catch { start = ""; }
|
|
5097
|
+
if (!start) start = String(STATE.data?.config?.dataRoot || "").trim();
|
|
5098
|
+
impLoad(start);
|
|
5099
|
+
}
|
|
5100
|
+
|
|
5101
|
+
function closeImportPdfModal() {
|
|
5102
|
+
getEl("importPdfModal").hidden = true;
|
|
5103
|
+
}
|
|
5104
|
+
|
|
5105
|
+
getEl("importPdfBtn").addEventListener("click", openImportPdfModal);
|
|
5106
|
+
getEl("impClose").addEventListener("click", closeImportPdfModal);
|
|
5107
|
+
getEl("impCancel").addEventListener("click", closeImportPdfModal);
|
|
5108
|
+
getEl("importPdfModal").addEventListener("click", (event) => { if (event.target === getEl("importPdfModal")) closeImportPdfModal(); });
|
|
5109
|
+
getEl("impUp").addEventListener("click", () => {
|
|
5110
|
+
if (!IMP.path || IMP.mode === "found") return;
|
|
5111
|
+
const parent = IMP.path.replace(/[\\/]+$/, "").replace(/[\\/][^\\/]+$/, "");
|
|
5112
|
+
impLoad(parent || "/");
|
|
5113
|
+
});
|
|
5114
|
+
getEl("impList").addEventListener("click", (event) => {
|
|
5115
|
+
if (event.target.closest("[data-imp-back]")) {
|
|
5116
|
+
impLoad(IMP.path);
|
|
5117
|
+
return;
|
|
5118
|
+
}
|
|
5119
|
+
if (event.target.closest("[data-imp-find]")) {
|
|
5120
|
+
impFindPdfs();
|
|
5121
|
+
return;
|
|
5122
|
+
}
|
|
5123
|
+
const dirHit = event.target.closest("[data-imp-dir]");
|
|
5124
|
+
if (dirHit) {
|
|
5125
|
+
impLoad(dirHit.dataset.impDir);
|
|
5126
|
+
return;
|
|
5127
|
+
}
|
|
5128
|
+
const pdfHit = event.target.closest("[data-imp-pdf]");
|
|
5129
|
+
if (pdfHit) {
|
|
5130
|
+
IMP.selected = [...IMP.pdfs, ...IMP.foundFiles].find((file) => file.path === pdfHit.dataset.impPdf) || null;
|
|
5131
|
+
renderImportPicker();
|
|
5132
|
+
}
|
|
5133
|
+
});
|
|
5134
|
+
/**
|
|
5135
|
+
* F-205:导入成功后必须停在**刚导入的这本** deck。
|
|
5136
|
+
*
|
|
5137
|
+
* 导入要跑 pdftoppm(几秒到几十秒),期间 5s 轮询、宿主指令、刚创建 deck 的列表延迟都可能
|
|
5138
|
+
* 把视图切到别的 deck(典型:工作台列表还没收录新 deck 时接口退回第一本)。
|
|
5139
|
+
* 这里在成功后的 ~3 秒里以新 deck 为准核对几次;用户自己点了/按了键就立刻停手,不抢手动导航。
|
|
5140
|
+
*/
|
|
5141
|
+
async function openImportedDeck(deckId) {
|
|
5142
|
+
let userTookOver = false;
|
|
5143
|
+
const flag = () => { userTookOver = true; };
|
|
5144
|
+
window.addEventListener("pointerdown", flag, true);
|
|
5145
|
+
window.addEventListener("keydown", flag, true);
|
|
5146
|
+
try {
|
|
5147
|
+
await showDeck(deckId);
|
|
5148
|
+
for (const gap of [500, 900, 1500]) {
|
|
5149
|
+
await new Promise((resolve) => setTimeout(resolve, gap));
|
|
5150
|
+
if (userTookOver) return;
|
|
5151
|
+
if (STATE.view === "deck" && STATE.deckId === deckId) continue;
|
|
5152
|
+
await showDeck(deckId);
|
|
5153
|
+
}
|
|
5154
|
+
} finally {
|
|
5155
|
+
window.removeEventListener("pointerdown", flag, true);
|
|
5156
|
+
window.removeEventListener("keydown", flag, true);
|
|
5157
|
+
}
|
|
5158
|
+
}
|
|
5159
|
+
|
|
5160
|
+
getEl("impSubmit").addEventListener("click", async () => {
|
|
5161
|
+
if (!IMP.selected || IMP.busy) return;
|
|
5162
|
+
IMP.busy = true;
|
|
5163
|
+
getEl("impSubmit").disabled = true;
|
|
5164
|
+
getEl("impState").textContent = `正在把 ${IMP.selected.name} 每页转成整页图(页数多会慢一点,别关页面)…`;
|
|
5165
|
+
try {
|
|
5166
|
+
const res = await fetch("/api/deck-studio.deck-import", {
|
|
5167
|
+
method: "POST",
|
|
5168
|
+
headers: { "content-type": "application/json" },
|
|
5169
|
+
body: JSON.stringify({ pdf: IMP.selected.path, dpi: Number(getEl("impDpi").value) || 120 }),
|
|
5170
|
+
});
|
|
5171
|
+
const result = await res.json().catch(() => ({}));
|
|
5172
|
+
if (!res.ok || !result.ok) {
|
|
5173
|
+
const failure = new Error(result?.error?.message || `HTTP ${res.status}`);
|
|
5174
|
+
failure.code = result?.error?.code || "";
|
|
5175
|
+
throw failure;
|
|
5176
|
+
}
|
|
5177
|
+
try { localStorage.setItem(IMP_LAST_DIR_KEY, IMP.path); } catch { /* 隐私模式忽略 */ }
|
|
5178
|
+
const parts = [`已导入 ${result.pages} 页(${result.dpi} dpi)`];
|
|
5179
|
+
if (result.markdown?.length) parts.push(`抽到 md:${result.markdown.join("、")}`);
|
|
5180
|
+
if (result.warnings?.length) parts.push(`注意:${result.warnings.join(";")}`);
|
|
5181
|
+
getEl("impState").textContent = parts.join(" · ");
|
|
5182
|
+
closeImportPdfModal();
|
|
5183
|
+
flash(`已导入「${result.deck?.label || ""}」:${result.pages} 页整页图`, "ok");
|
|
5184
|
+
// F-205:导入成功后以新 deck 为准(详见 openImportedDeck)。
|
|
5185
|
+
if (result.deck?.deckId) await openImportedDeck(result.deck.deckId);
|
|
5186
|
+
} catch (error) {
|
|
5187
|
+
getEl("impState").textContent = "导入失败:" + importErrText(error?.code, error?.message);
|
|
5188
|
+
} finally {
|
|
5189
|
+
IMP.busy = false;
|
|
5190
|
+
getEl("impSubmit").disabled = !IMP.selected;
|
|
5191
|
+
}
|
|
5192
|
+
});
|
|
5193
|
+
|
|
4861
5194
|
// F-63:标签是动态渲染的,用事件委托
|
|
4862
5195
|
getEl("observeTabs").addEventListener("click", (event) => {
|
|
4863
5196
|
const btn = event.target.closest(".observe-tab");
|
|
@@ -6028,7 +6361,11 @@ function backToManual(box) {
|
|
|
6028
6361
|
STATE.homeSection = match ? match[1] : "decks";
|
|
6029
6362
|
showHome();
|
|
6030
6363
|
}
|
|
6031
|
-
|
|
6364
|
+
// F-205:导入(pdftoppm 可能要几十秒)期间别用旧 deckId 轮询,否则回来晚的那次会把新 deck 顶掉。
|
|
6365
|
+
setInterval(() => {
|
|
6366
|
+
if (IMP.busy) return;
|
|
6367
|
+
loadWorkbench(STATE.deckId);
|
|
6368
|
+
}, 5000);
|
|
6032
6369
|
|
|
6033
6370
|
/**
|
|
6034
6371
|
* 0.2.21:boot 完成 → 把启动期间暂存的「打开某本 deck」执行掉,再向宿主报 ready。
|