dsh-doc-assistant 0.1.14 → 0.1.18
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/README.md +27 -2
- package/lib/client.js +590 -174
- package/package.json +3 -3
- package/src/client/20-api.js +2 -0
- package/src/client/40-revisions.js +67 -24
- package/src/client/50-library.js +1 -1
- package/src/client/60-reader.js +505 -146
- package/src/client/70-shelf.js +14 -2
- package/src/host/chat.js +43 -13
- package/src/host/covers.js +56 -15
- package/src/host/docir.js +26 -3
- package/src/host/legacyWord.js +91 -0
- package/src/host/modelVision.js +44 -0
- package/src/host/ocr.js +120 -210
- package/src/host/ocr_vision.js +39 -0
- package/src/host/ocr_windows.ps1 +89 -0
- package/src/host/office.js +3 -1
- package/src/host/officeImages.js +269 -0
- package/src/host/office_tool.py +451 -29
- package/src/host/preview.js +132 -28
- package/src/host/revise.js +146 -21
- package/src/host/revisionBoundary.js +45 -0
- package/src/host/revisionRebase.js +123 -0
- package/src/host/revisions.js +20 -6
- package/src/host/routes.js +18 -5
- package/src/host/tree.js +1 -0
- package/src/host/upload.js +113 -11
- package/src/host/visionOcr.js +101 -0
- package/src/host/visualContext.js +248 -0
- package/src/host/wordLayout.js +131 -0
- package/src/host/wordSelection.js +114 -0
- package/src/host/writeback.js +108 -19
- package/src/host/writers/index.js +1 -0
package/src/client/70-shelf.js
CHANGED
|
@@ -378,7 +378,13 @@ __define('./shelf', (module, exports, require) => {
|
|
|
378
378
|
const p = props || {};
|
|
379
379
|
const cover = normalizeCover(p.cover, p.relPath, p.format);
|
|
380
380
|
const [failed, setFailed] = React.useState(false);
|
|
381
|
+
const [loaded, setLoaded] = React.useState(false);
|
|
381
382
|
const [naturalShape, setNaturalShape] = React.useState(null);
|
|
383
|
+
React.useEffect(() => {
|
|
384
|
+
setFailed(false);
|
|
385
|
+
setLoaded(false);
|
|
386
|
+
setNaturalShape(null);
|
|
387
|
+
}, [cover.imageUrl, cover.relPath]);
|
|
382
388
|
// 形状优先级:父级/显式给的 > 图片实际尺寸测出来的 > 按 cover.width/height 推断 > 竖版。
|
|
383
389
|
// 即使图片失败回落生成封面也保持同一卡片形状,避免书架在 <img> 出错时跳版(§17.3.1)。
|
|
384
390
|
const controlled = p.shape && p.shape.key ? p.shape : null;
|
|
@@ -396,6 +402,7 @@ __define('./shelf', (module, exports, require) => {
|
|
|
396
402
|
setFailed(true);
|
|
397
403
|
return;
|
|
398
404
|
}
|
|
405
|
+
setLoaded(true);
|
|
399
406
|
const next = shapeFor({ width: w, height: hgt });
|
|
400
407
|
if (next.key === shape.key) return;
|
|
401
408
|
if (controlled) {
|
|
@@ -413,10 +420,13 @@ __define('./shelf', (module, exports, require) => {
|
|
|
413
420
|
'data-cover-kind': 'page',
|
|
414
421
|
'data-cover-shape': shape.key,
|
|
415
422
|
'data-cover-src': cover.imageUrl,
|
|
423
|
+
'aria-busy': !loaded,
|
|
416
424
|
}, [
|
|
425
|
+
!loaded ? h(CoverArt, { key: 'placeholder', cover, shape, size: width,
|
|
426
|
+
className: P + 'CoverPlaceholder', relPath: cover.relPath, format: cover.format }) : null,
|
|
417
427
|
h('img', {
|
|
418
428
|
key: 'img',
|
|
419
|
-
className: P + 'CoverImg',
|
|
429
|
+
className: cx(P + 'CoverImg', !loaded && P + 'CoverImgLoading'),
|
|
420
430
|
src: cover.imageUrl,
|
|
421
431
|
alt: cover.title,
|
|
422
432
|
loading: 'lazy',
|
|
@@ -1079,8 +1089,10 @@ __define('./shelf', (module, exports, require) => {
|
|
|
1079
1089
|
.${P}Book_landscape .${P}Cover{box-shadow:0 6px 18px rgba(0,0,0,.28)}
|
|
1080
1090
|
.${P}Book_square .${P}Cover{border-radius:8px}
|
|
1081
1091
|
.${P}CoverPage{background:var(--dsw-alias-bg-layer-2)}
|
|
1082
|
-
.${P}
|
|
1092
|
+
.${P}CoverPlaceholder{position:absolute;inset:0;box-shadow:none}
|
|
1093
|
+
.${P}CoverImg{position:absolute;inset:0;display:block;width:100%;height:100%;object-fit:cover;object-position:top center;
|
|
1083
1094
|
background:var(--dsw-alias-bg-layer-2)}
|
|
1095
|
+
.${P}CoverImgLoading{opacity:0}
|
|
1084
1096
|
.${P}PageFade{position:absolute;left:0;right:0;bottom:0;height:26%;pointer-events:none;
|
|
1085
1097
|
background:linear-gradient(to top,rgba(0,0,0,.28),rgba(0,0,0,0))}
|
|
1086
1098
|
.${P}PageBadge{position:absolute;left:8px;bottom:8px;background:rgba(0,0,0,.42);color:#fff;
|
package/src/host/chat.js
CHANGED
|
@@ -42,7 +42,12 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
|
42
42
|
import { dirname, join } from 'node:path';
|
|
43
43
|
|
|
44
44
|
import { resolveRoute, safeArray, fromChatReply } from './revise.js';
|
|
45
|
+
import { rebaseRevisionSelection } from './revisionRebase.js';
|
|
45
46
|
import { expansionPlan, expansionPrompt } from './expansion.js';
|
|
47
|
+
import { loadVisualContext, visualEvidence, visualImageParts } from './visualContext.js';
|
|
48
|
+
import { revisionBoundaryPrompt } from './revisionBoundary.js';
|
|
49
|
+
import { resolveWordSelection } from './wordSelection.js';
|
|
50
|
+
import { service as modelService } from './modelVision.js';
|
|
46
51
|
|
|
47
52
|
/** Request-body cap, matching host-core's dispatcher. */
|
|
48
53
|
const BODY_LIMIT = 512 * 1024;
|
|
@@ -75,6 +80,7 @@ const EDIT_PREAMBLE = [
|
|
|
75
80
|
'理解、分析、评价、建议、翻译、比较,以及仅要求总结、概括、提炼要点、尚未要求替换原文的请求,intent 必须为 answer,只回答,不生成修订。仅询问修改是否有必要或是否可行也属于咨询。',
|
|
76
81
|
'用户明确要求执行修改、重写、增删、调整语气或结构、按个人要求修改,或对上一份待接受修订提出进一步修改时,intent 为 revise。礼貌表达“能不能帮我改得简短一点”也是执行修改的请求,不要误判为咨询。',
|
|
77
82
|
'修改范围只能是当前引用的原文。revised 必须是该选区修改后的完整文本,不能省掉未改的部分或包含选区外的上下文。',
|
|
83
|
+
'revised 必须遵守选区末尾边界:原文没有选入句末标点,结果不得在结尾补上句末标点。此限制只针对写回的 revised,不限制 reply 中正常回答的标点。',
|
|
78
84
|
'用户明确提供的新事实、名称、数字可以按要求修改;除此之外不要编造事实。',
|
|
79
85
|
'这是生成待接受的修订,不是写回文件,不能声称已经修改了文档。',
|
|
80
86
|
'存在上轮待接受修订时,进一步修改以它为草稿,但仍返回当前引用位置的完整最终文本。',
|
|
@@ -501,12 +507,12 @@ async function loadDocText(deps, projectId, relPath) {
|
|
|
501
507
|
return { text: raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw, format, reason: '', hash: info.hash };
|
|
502
508
|
}
|
|
503
509
|
|
|
504
|
-
const doc = await docir.parse(abs, info);
|
|
510
|
+
const doc = await docir.parse(abs, info, { home: deps.config?.home });
|
|
505
511
|
const blocks = Array.isArray(doc?.blocks) ? doc.blocks : [];
|
|
506
|
-
const text = ['docx', 'xlsx', 'pptx'].includes(format)
|
|
512
|
+
const text = ['doc', 'docx', 'xlsx', 'pptx'].includes(format)
|
|
507
513
|
? (await import('./office.js')).plainSource(doc)
|
|
508
514
|
: blocks.map(b => String(b?.text ?? '')).filter(Boolean).join('\n\n');
|
|
509
|
-
return { text, format, parsed: doc, hash: info.hash, reason: String(doc?.meta?.reason ?? info?.reason ?? '') };
|
|
515
|
+
return { text, format, abs, parsed: doc, hash: info.hash, reason: String(doc?.meta?.reason ?? info?.reason ?? '') };
|
|
510
516
|
}
|
|
511
517
|
|
|
512
518
|
/**
|
|
@@ -534,7 +540,7 @@ export function buildContext(deps, input) {
|
|
|
534
540
|
);
|
|
535
541
|
} else if (docText.length <= sizes.docChars) {
|
|
536
542
|
mode = 'full';
|
|
537
|
-
lines.push('', '文档完整内容如下:', '<<<DOCUMENT', docText, 'DOCUMENT>>>');
|
|
543
|
+
lines.push('', input.visual ? '文档逐页内容索引与相关页面证据如下:' : '文档完整内容如下:', '<<<DOCUMENT', docText, 'DOCUMENT>>>');
|
|
538
544
|
} else {
|
|
539
545
|
mode = 'excerpt';
|
|
540
546
|
const span = excerptSpan(docText, anchor, sizes.contextChars);
|
|
@@ -750,6 +756,7 @@ export async function listChatModels(deps) {
|
|
|
750
756
|
for (const model of choices.slice(0, 300)) if (typeof model?.id === 'string') models.push({
|
|
751
757
|
provider: provider.id, model: model.id, label: typeof model.name === 'string' ? model.name : typeof model.label === 'string' ? model.label : model.id,
|
|
752
758
|
providerLabel: typeof provider.name === 'string' ? provider.name : typeof provider.label === 'string' ? provider.label : provider.id,
|
|
759
|
+
...(Array.isArray(model.inputModalities) ? { inputModalities: model.inputModalities.filter(item => item === 'text' || item === 'image') } : {}),
|
|
753
760
|
});
|
|
754
761
|
}
|
|
755
762
|
const current = await resolveRoute(deps.ctx, deps, {});
|
|
@@ -858,24 +865,45 @@ async function handleSend(deps, body) {
|
|
|
858
865
|
let revision;
|
|
859
866
|
try {
|
|
860
867
|
const doc = await loadDocText(deps, target.projectId, target.relPath);
|
|
868
|
+
const route = chosenRoute || await resolveRoute(deps.ctx, deps, {});
|
|
869
|
+
const visual = doc.abs ? await loadVisualContext(deps, { abs: doc.abs, doc, route }) : null;
|
|
861
870
|
const editFromQuote = body?.editFromQuote === true && Boolean(quote);
|
|
862
871
|
let blocked = '';
|
|
863
872
|
if (editFromQuote) {
|
|
864
873
|
if (target.readOnly) blocked = '文档所在根目录是只读的';
|
|
865
|
-
else if (!deps.writers?.canWrite?.(doc.format)) blocked = '当前文档格式无法写回修订';
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
874
|
+
else if (!deps.writers?.canWrite?.(doc.format)) blocked = doc.format === 'doc' ? '旧版 DOC 需要先点击“转换并编辑”,在 DOCX 副本上生成修订' : '当前文档格式无法写回修订';
|
|
875
|
+
if (!blocked && expectedHash && expectedHash !== doc.hash) {
|
|
876
|
+
try {
|
|
877
|
+
const current = await rebaseRevisionSelection(deps, {
|
|
878
|
+
project: await deps.projects.get(target.projectId), path: target.relPath, abs: target.abs,
|
|
879
|
+
fromHash: expectedHash, toHash: doc.hash, anchor, original: quote, format: doc.format,
|
|
880
|
+
});
|
|
881
|
+
anchor = current.anchor;
|
|
882
|
+
expectedHash = current.docHash;
|
|
883
|
+
userMessage.anchor = anchor;
|
|
884
|
+
userMessage.documentHash = expectedHash;
|
|
885
|
+
} catch (error) { blocked = error.message || '文档在引用后已变化,请重新引用原文'; }
|
|
886
|
+
}
|
|
887
|
+
if (!blocked && !anchor) blocked = '引用缺少原文位置,请重新引用';
|
|
888
|
+
else if (!blocked && anchor.kind === 'cell') {
|
|
869
889
|
const { cellText } = await import('./office.js');
|
|
870
890
|
if (doc.format !== 'xlsx' || cellText(doc.parsed, anchor.sheet, anchor.cell) !== quote) blocked = '引用对不上单元格中的完整原文,请重新引用';
|
|
871
|
-
} else if (!
|
|
891
|
+
} else if (!blocked && doc.format === 'docx' && Array.isArray(anchor.segments)) {
|
|
892
|
+
try {
|
|
893
|
+
const selection = resolveWordSelection(doc.parsed, anchor);
|
|
894
|
+
if (selection.original !== quote) blocked = '引用对不上文档里的完整原文,请重新引用';
|
|
895
|
+
else { anchor = selection.anchor; userMessage.anchor = anchor; }
|
|
896
|
+
} catch (error) { blocked = error.message || '引用选区无法定位,请重新引用完整段落'; }
|
|
897
|
+
} else if (!blocked && (!Number.isInteger(anchor.srcStart) || !Number.isInteger(anchor.srcEnd)
|
|
872
898
|
|| anchor.srcStart < 0 || anchor.srcEnd <= anchor.srcStart || anchor.srcEnd > doc.text.length
|
|
873
|
-
|| doc.text.slice(anchor.srcStart, anchor.srcEnd) !== quote) blocked = '引用对不上文档里的完整原文,请重新引用';
|
|
899
|
+
|| doc.text.slice(anchor.srcStart, anchor.srcEnd) !== quote)) blocked = '引用对不上文档里的完整原文,请重新引用';
|
|
874
900
|
if (!expectedHash) userMessage.documentHash = doc.hash;
|
|
875
901
|
}
|
|
902
|
+
const visualText = visual ? visualEvidence(visual, message, sizes.docChars) : '';
|
|
876
903
|
const context = buildContext(deps, {
|
|
877
904
|
relPath: target.relPath,
|
|
878
|
-
docText: doc.text,
|
|
905
|
+
docText: visual ? visualText : doc.text,
|
|
906
|
+
visual: Boolean(visual),
|
|
879
907
|
format: doc.format,
|
|
880
908
|
reason: doc.reason,
|
|
881
909
|
anchor,
|
|
@@ -890,10 +918,11 @@ async function handleSend(deps, body) {
|
|
|
890
918
|
// `toLlmMessages`, which skips failed/empty assistant records.
|
|
891
919
|
const llmMessages = [
|
|
892
920
|
...toLlmMessages(history, sizes.turns),
|
|
893
|
-
{ role: 'user', content: [{ type: 'text', text: context.userContent }] },
|
|
921
|
+
{ role: 'user', content: [{ type: 'text', text: context.userContent }, ...(visual ? visualImageParts(visual, message, modelService(deps, 'attachments')?.imageLimits) : [])] },
|
|
894
922
|
];
|
|
895
923
|
let styleGuidance = '';
|
|
896
924
|
if (editFromQuote && !blocked) {
|
|
925
|
+
styleGuidance += `\n${revisionBoundaryPrompt(quote)}`;
|
|
897
926
|
const settings = deps.config?.settings || {};
|
|
898
927
|
const preset = settings.stylePresets?.find(item => item.id === settings.defaultStyleId);
|
|
899
928
|
if (preset) styleGuidance += `\n仅当用户要求润色或扩写时,默认文风为:${preset.prompt}。用户明确的文风要求优先。`;
|
|
@@ -904,7 +933,7 @@ async function handleSend(deps, body) {
|
|
|
904
933
|
system: editFromQuote ? `${context.system}\n\n${EDIT_PREAMBLE}\n${blocked ? `当前不能生成修订:${blocked}。无论用户如何要求,intent 只能为 answer;如要求修改,请说明原因。` : '当前引用已核对,可根据用户明确的修改要求生成修订。'}${styleGuidance}` : context.system,
|
|
905
934
|
messages: llmMessages,
|
|
906
935
|
editFromQuote,
|
|
907
|
-
route
|
|
936
|
+
route,
|
|
908
937
|
// `/chat/send` is non-streaming in M1.1, so there is no client-side
|
|
909
938
|
// cancellation channel yet; the host still passes a signal because the
|
|
910
939
|
// llm runtime requires one.
|
|
@@ -933,6 +962,7 @@ async function handleSend(deps, body) {
|
|
|
933
962
|
provider: reply.provider,
|
|
934
963
|
model: reply.model,
|
|
935
964
|
...(reply.completion ? { completion: reply.completion } : {}),
|
|
965
|
+
...(visual ? { documentContext: { source: visual.source, pagesRead: visual.pages.length, totalPages: visual.total, complete: visual.complete } } : {}),
|
|
936
966
|
...(revision?.id ? { revisionId: revision.id, revisionText: revision.revised } : {}),
|
|
937
967
|
};
|
|
938
968
|
} catch (error) {
|
package/src/host/covers.js
CHANGED
|
@@ -41,6 +41,7 @@ import { basename, dirname, extname, join } from 'node:path';
|
|
|
41
41
|
import { deflateSync } from 'node:zlib';
|
|
42
42
|
|
|
43
43
|
import { firstPagePng, WORD_PREVIEW_VERSION } from './convert.js';
|
|
44
|
+
import { embeddedPptxCover, PPTX_COVER_VERSION } from './officeImages.js';
|
|
44
45
|
import { resolveRoute } from './revise.js';
|
|
45
46
|
// Host-core's own skip rule, imported rather than re-implemented: `/covers` and
|
|
46
47
|
// `/tree` must agree on what a "document" is, and two copies of that rule would
|
|
@@ -324,7 +325,7 @@ export function paletteIsReadable(palette) {
|
|
|
324
325
|
// ---------------------------------------------------------------------------
|
|
325
326
|
|
|
326
327
|
/** Formats whose first page *is* the cover. */
|
|
327
|
-
const PAGE_FORMATS = new Set(['image', 'pdf', 'docx', 'pptx', 'xlsx']);
|
|
328
|
+
const PAGE_FORMATS = new Set(['image', 'pdf', 'doc', 'docx', 'pptx', 'xlsx']);
|
|
328
329
|
|
|
329
330
|
/** Raster types a browser renders directly, so we can serve the file itself. */
|
|
330
331
|
const DIRECT_IMAGE_MIME = new Map([
|
|
@@ -350,8 +351,9 @@ export function artFor(format) {
|
|
|
350
351
|
}
|
|
351
352
|
|
|
352
353
|
/** URL a client can put straight into `<img src>`. */
|
|
353
|
-
export function coverImageUrl(projectId, relPath) {
|
|
354
|
+
export function coverImageUrl(projectId, relPath, version) {
|
|
354
355
|
const q = new URLSearchParams({ projectId: String(projectId), path: normalizeRelPath(relPath) });
|
|
356
|
+
if (typeof version === 'string' && version) q.set('v', version);
|
|
355
357
|
return `/doc-assistant/cover/image?${q.toString()}`;
|
|
356
358
|
}
|
|
357
359
|
|
|
@@ -389,6 +391,7 @@ const CANONICAL_PAGE = {
|
|
|
389
391
|
pptx: { width: 1600, height: 900 },
|
|
390
392
|
// A4 at ~110 dpi (matches a measured 910x1287 page)
|
|
391
393
|
docx: { width: 1240, height: 1754 },
|
|
394
|
+
doc: { width: 1240, height: 1754 },
|
|
392
395
|
// Spreadsheets are **portrait A4** here, not the landscape 4:3 one might
|
|
393
396
|
// assume from "tables are usually wide". Measured on all four of the user's
|
|
394
397
|
// real `.xlsx` files: 935x1210, 910x1287, 935x1210, 910x1287 — 4/4 portrait,
|
|
@@ -572,6 +575,10 @@ export async function estimatePageSize(abs, format, relPath) {
|
|
|
572
575
|
|
|
573
576
|
const renderChain = { tail: Promise.resolve() };
|
|
574
577
|
const renderInflight = new Map();
|
|
578
|
+
// Reading an already embedded slide image does not spawn Office and must not
|
|
579
|
+
// wait behind another document's conversion. Coalesce its cache writes alone.
|
|
580
|
+
const embeddedCoverInflight = new Map();
|
|
581
|
+
const embeddedCoverStats = { started: 0, coalesced: 0 };
|
|
575
582
|
// `running` is incremented when a task actually *starts*, not when it is
|
|
576
583
|
// queued. Counting at enqueue time makes `peak` report queue depth, which would
|
|
577
584
|
// read as "5 concurrent renders" for five queued tasks — a statistic that lies
|
|
@@ -616,7 +623,8 @@ function renderOnce(key, task) {
|
|
|
616
623
|
|
|
617
624
|
/** Observable queue state, for tests and diagnostics. */
|
|
618
625
|
export function renderQueueStats() {
|
|
619
|
-
return { ...renderStats, inflight: renderInflight.size, chainDepth: renderStats.queued - renderStats.completed
|
|
626
|
+
return { ...renderStats, inflight: renderInflight.size, chainDepth: renderStats.queued - renderStats.completed,
|
|
627
|
+
embedded: { ...embeddedCoverStats, inflight: embeddedCoverInflight.size } };
|
|
620
628
|
}
|
|
621
629
|
|
|
622
630
|
/** Reset the counters (tests only; does not cancel queued work). */
|
|
@@ -626,6 +634,8 @@ export function resetRenderStats() {
|
|
|
626
634
|
renderStats.queued = 0;
|
|
627
635
|
renderStats.completed = 0;
|
|
628
636
|
renderStats.coalesced = 0;
|
|
637
|
+
embeddedCoverStats.started = 0;
|
|
638
|
+
embeddedCoverStats.coalesced = 0;
|
|
629
639
|
}
|
|
630
640
|
|
|
631
641
|
// ---------------------------------------------------------------------------
|
|
@@ -1090,11 +1100,12 @@ function coverFromCache(cached, base) {
|
|
|
1090
1100
|
}
|
|
1091
1101
|
|
|
1092
1102
|
/**
|
|
1093
|
-
* Absolute path of one document's cached **page**
|
|
1103
|
+
* Absolute path of one document's cached **page** image
|
|
1094
1104
|
* (`covers/<projectId>/<sha256(relPath)[:16]>.png`).
|
|
1095
1105
|
*
|
|
1096
|
-
* The `.
|
|
1097
|
-
*
|
|
1106
|
+
* The historical `.png` filename is retained for cache compatibility; the
|
|
1107
|
+
* metadata records MIME because an embedded PPTX cover may be a JPEG. The
|
|
1108
|
+
* `.json` sibling holds the `docHash` that decides freshness, and re-deriving it from the file
|
|
1098
1109
|
* every time would defeat the cache.
|
|
1099
1110
|
*
|
|
1100
1111
|
* @param {object} deps
|
|
@@ -1128,7 +1139,8 @@ async function readCachedPagePng(deps, projectId, relPath, docHash) {
|
|
|
1128
1139
|
if (typeof meta.docHash !== 'string' || meta.docHash !== docHash) return null;
|
|
1129
1140
|
try {
|
|
1130
1141
|
const bytes = await readFile(coverImageFileFor(deps, projectId, relPath));
|
|
1131
|
-
|
|
1142
|
+
const mime = meta.mime === 'image/jpeg' ? 'image/jpeg' : 'image/png';
|
|
1143
|
+
return bytes.length > 0 ? { bytes, meta, mime } : null;
|
|
1132
1144
|
} catch {
|
|
1133
1145
|
return null;
|
|
1134
1146
|
}
|
|
@@ -1138,12 +1150,12 @@ async function readCachedPagePng(deps, projectId, relPath, docHash) {
|
|
|
1138
1150
|
async function writeCachedPagePng(deps, projectId, relPath, docHash, bytes, info) {
|
|
1139
1151
|
const pngFile = coverImageFileFor(deps, projectId, relPath);
|
|
1140
1152
|
await mkdir(dirname(pngFile), { recursive: true, mode: 0o700 });
|
|
1141
|
-
const tmpPng = `${pngFile}.tmp`;
|
|
1153
|
+
const tmpPng = `${pngFile}.${randomUUID()}.tmp`;
|
|
1142
1154
|
await writeFile(tmpPng, bytes, { mode: 0o600 });
|
|
1143
1155
|
await rename(tmpPng, pngFile);
|
|
1144
1156
|
|
|
1145
1157
|
const metaFile = coverImageMetaFor(deps, projectId, relPath);
|
|
1146
|
-
const tmpMeta = `${metaFile}.tmp`;
|
|
1158
|
+
const tmpMeta = `${metaFile}.${randomUUID()}.tmp`;
|
|
1147
1159
|
await writeFile(
|
|
1148
1160
|
tmpMeta,
|
|
1149
1161
|
JSON.stringify(
|
|
@@ -1513,7 +1525,11 @@ async function handleCoversBatch(deps, url) {
|
|
|
1513
1525
|
// `/cover/image`, and only for the cards it actually shows. Resolving
|
|
1514
1526
|
// the absolute path is cheap (no file read) and lets `pageCover` probe
|
|
1515
1527
|
// the real page size from the file header.
|
|
1516
|
-
covers
|
|
1528
|
+
// Let the browser keep covers cached, but use a different URL after a
|
|
1529
|
+
// saved file changes so its five-minute HTTP cache cannot hide updates.
|
|
1530
|
+
const imageVersion = `${entry.mtime ?? ''}:${entry.size ?? ''}:${format === 'pptx'
|
|
1531
|
+
? PPTX_COVER_VERSION : format === 'docx' ? WORD_PREVIEW_VERSION : 'page-cover-v1'}`;
|
|
1532
|
+
covers.push(await pageCover(deps, projectId, entry.relPath, format, docChars, abs, imageVersion));
|
|
1517
1533
|
continue;
|
|
1518
1534
|
}
|
|
1519
1535
|
covers.push(await coverWithoutModel(deps, projectId, entry.relPath, { format, docChars }));
|
|
@@ -1585,7 +1601,7 @@ async function handleGenerate(deps, body) {
|
|
|
1585
1601
|
*
|
|
1586
1602
|
* @returns {object} Cover
|
|
1587
1603
|
*/
|
|
1588
|
-
async function pageCover(deps, projectId, relPath, format, docChars, abs = null) {
|
|
1604
|
+
async function pageCover(deps, projectId, relPath, format, docChars, abs = null, imageVersion) {
|
|
1589
1605
|
const base = heuristicCover({ relPath, format });
|
|
1590
1606
|
void docChars;
|
|
1591
1607
|
|
|
@@ -1625,7 +1641,7 @@ async function pageCover(deps, projectId, relPath, format, docChars, abs = null)
|
|
|
1625
1641
|
source: 'rendered',
|
|
1626
1642
|
art: 'page',
|
|
1627
1643
|
// §17.3: only present when the document has a page to show.
|
|
1628
|
-
imageUrl: coverImageUrl(projectId, relPath),
|
|
1644
|
+
imageUrl: coverImageUrl(projectId, relPath, imageVersion),
|
|
1629
1645
|
};
|
|
1630
1646
|
}
|
|
1631
1647
|
|
|
@@ -1668,20 +1684,42 @@ async function pagePng(deps, projectId, relPath, abs, format, docHash) {
|
|
|
1668
1684
|
// behind a render would mean a cached card waits ~8 s for an unrelated deck to
|
|
1669
1685
|
// finish converting — the shelf would appear to load in dribs and drabs even
|
|
1670
1686
|
// though every image was already on disk.
|
|
1671
|
-
const renderHash = format === 'docx' ? `${docHash}:${WORD_PREVIEW_VERSION}`
|
|
1687
|
+
const renderHash = format === 'docx' ? `${docHash}:${WORD_PREVIEW_VERSION}`
|
|
1688
|
+
: format === 'pptx' ? `${docHash}:${PPTX_COVER_VERSION}` : docHash;
|
|
1672
1689
|
const hit = await readCachedPagePng(deps, projectId, relPath, renderHash);
|
|
1673
1690
|
if (hit !== null) {
|
|
1674
|
-
return { bytes: hit.bytes, mime:
|
|
1691
|
+
return { bytes: hit.bytes, mime: hit.mime, cached: true, ms: Date.now() - started, source: hit.meta.source };
|
|
1675
1692
|
}
|
|
1676
1693
|
|
|
1677
1694
|
const key = `${projectId}\u0000${normalizeRelPath(relPath)}`;
|
|
1695
|
+
if (format === 'pptx') {
|
|
1696
|
+
const embeddedKey = `${key}\u0000${renderHash}`;
|
|
1697
|
+
let pending = embeddedCoverInflight.get(embeddedKey);
|
|
1698
|
+
if (!pending) {
|
|
1699
|
+
embeddedCoverStats.started += 1;
|
|
1700
|
+
pending = (async () => {
|
|
1701
|
+
const image = await embeddedPptxCover(abs);
|
|
1702
|
+
if (!image) return null;
|
|
1703
|
+
await writeCachedPagePng(deps, projectId, relPath, renderHash, image.bytes, {
|
|
1704
|
+
width: image.width, height: image.height, mime: image.mime,
|
|
1705
|
+
converted: false, source: image.source, renderMs: Date.now() - started,
|
|
1706
|
+
});
|
|
1707
|
+
return { bytes: image.bytes, mime: image.mime, source: image.source, cached: false, ms: Date.now() - started };
|
|
1708
|
+
})().finally(() => embeddedCoverInflight.delete(embeddedKey));
|
|
1709
|
+
embeddedCoverInflight.set(embeddedKey, pending);
|
|
1710
|
+
} else {
|
|
1711
|
+
embeddedCoverStats.coalesced += 1;
|
|
1712
|
+
}
|
|
1713
|
+
const image = await pending;
|
|
1714
|
+
if (image) return image;
|
|
1715
|
+
}
|
|
1678
1716
|
return renderOnce(key, async () => {
|
|
1679
1717
|
// Re-check inside the queue: a request that queued a moment ago may have
|
|
1680
1718
|
// just rendered exactly this document, and `renderOnce` coalesces only
|
|
1681
1719
|
// *concurrent* callers, not ones that arrive one tick later.
|
|
1682
1720
|
const late = await readCachedPagePng(deps, projectId, relPath, renderHash);
|
|
1683
1721
|
if (late !== null) {
|
|
1684
|
-
return { bytes: late.bytes, mime:
|
|
1722
|
+
return { bytes: late.bytes, mime: late.mime, cached: true, ms: Date.now() - started, source: late.meta.source };
|
|
1685
1723
|
}
|
|
1686
1724
|
const rendered = await firstPagePng(abs);
|
|
1687
1725
|
await writeCachedPagePng(deps, projectId, relPath, renderHash, rendered.bytes, {
|
|
@@ -1689,6 +1727,8 @@ async function pagePng(deps, projectId, relPath, abs, format, docHash) {
|
|
|
1689
1727
|
height: rendered.height,
|
|
1690
1728
|
converted: rendered.converted,
|
|
1691
1729
|
renderMs: rendered.ms,
|
|
1730
|
+
mime: 'image/png',
|
|
1731
|
+
source: 'rendered-page',
|
|
1692
1732
|
});
|
|
1693
1733
|
return { bytes: rendered.bytes, mime: 'image/png', cached: false, ms: Date.now() - started };
|
|
1694
1734
|
});
|
|
@@ -1730,6 +1770,7 @@ async function handleCoverImage(deps, url, req, res) {
|
|
|
1730
1770
|
'x-cover-cached': out.cached ? '1' : '0',
|
|
1731
1771
|
'x-cover-art': artFor(format),
|
|
1732
1772
|
'x-cover-ms': String(out.ms),
|
|
1773
|
+
'x-cover-source': out.source ?? 'rendered-page',
|
|
1733
1774
|
});
|
|
1734
1775
|
} catch {
|
|
1735
1776
|
// Never a 5xx and never JSON: one unrenderable document must not turn into
|
package/src/host/docir.js
CHANGED
|
@@ -61,6 +61,7 @@ const BY_EXT = new Map(
|
|
|
61
61
|
'.js', '.ts', '.tsx', '.jsx', '.json', '.yaml', '.yml', '.py', '.go', '.rs',
|
|
62
62
|
'.java', '.c', '.h', '.cpp', '.css', '.scss', '.sh', '.sql', '.toml', '.ini',
|
|
63
63
|
],
|
|
64
|
+
doc: ['.doc'],
|
|
64
65
|
docx: ['.docx'],
|
|
65
66
|
xlsx: ['.xlsx'],
|
|
66
67
|
csv: ['.csv'],
|
|
@@ -74,13 +75,13 @@ const BY_EXT = new Map(
|
|
|
74
75
|
format,
|
|
75
76
|
renderable: true,
|
|
76
77
|
// CONTRACT §4: pdf and image are render-only; everything else writes.
|
|
77
|
-
writable: format !== 'pdf' && format !== 'image',
|
|
78
|
+
writable: format !== 'pdf' && format !== 'image' && format !== 'doc',
|
|
78
79
|
granularity:
|
|
79
80
|
format === 'xlsx' || format === 'csv'
|
|
80
81
|
? 'cell'
|
|
81
82
|
: format === 'pptx'
|
|
82
83
|
? 'shape'
|
|
83
|
-
: format === 'pdf' || format === 'image'
|
|
84
|
+
: format === 'pdf' || format === 'image' || format === 'doc'
|
|
84
85
|
? null
|
|
85
86
|
: 'range',
|
|
86
87
|
},
|
|
@@ -116,7 +117,7 @@ const OFFICE_MAX_BYTES = 40_000_000;
|
|
|
116
117
|
* @returns {number}
|
|
117
118
|
*/
|
|
118
119
|
export function sizeLimit(format, maxFileBytes = DEFAULT_MAX_BYTES) {
|
|
119
|
-
if (format === 'docx' || format === 'xlsx' || format === 'pptx' || format === 'pdf' || format === 'image') {
|
|
120
|
+
if (format === 'doc' || format === 'docx' || format === 'xlsx' || format === 'pptx' || format === 'pdf' || format === 'image') {
|
|
120
121
|
return Math.max(maxFileBytes, OFFICE_MAX_BYTES);
|
|
121
122
|
}
|
|
122
123
|
return maxFileBytes;
|
|
@@ -241,6 +242,8 @@ export async function detect(absPath, opts = {}) {
|
|
|
241
242
|
? 'PDF 是只读格式:可以预览,但不支持把改写内容写回'
|
|
242
243
|
: spec.format === 'image'
|
|
243
244
|
? '图片没有可编辑的文本层:可以预览,但无法在此改写'
|
|
245
|
+
: spec.format === 'doc'
|
|
246
|
+
? '旧版 Word 仅支持只读预览,请转换为 DOCX 副本后编辑'
|
|
244
247
|
: `格式「${spec.format}」不支持写回`;
|
|
245
248
|
}
|
|
246
249
|
|
|
@@ -963,6 +966,26 @@ export async function parse(absPath, info, opts = {}) {
|
|
|
963
966
|
const det = info ?? (await detect(absPath, { maxFileBytes: maxBytes }));
|
|
964
967
|
const format = det.format;
|
|
965
968
|
|
|
969
|
+
if (format === 'doc') {
|
|
970
|
+
const reason = '旧版 Word 文档使用只读预览;转换为 DOCX 副本后即可润色、扩写并接受修订。';
|
|
971
|
+
if (det.tooLarge) {
|
|
972
|
+
const doc = stubDoc(relPath, format, '旧版 Word 文件超过转换上限(40 MB)');
|
|
973
|
+
return { ...doc, meta: { ...doc.meta, writable: false, legacyWord: true, sourceName: basename(absPath) } };
|
|
974
|
+
}
|
|
975
|
+
try {
|
|
976
|
+
const { legacyWordDocx } = await import('./legacyWord.js');
|
|
977
|
+
const converted = await legacyWordDocx(absPath, { home: opts.home });
|
|
978
|
+
if (det.hash && converted.sourceHash !== det.hash) throw new Error('旧版 Word 文档在读取时发生变化,请重新打开');
|
|
979
|
+
const doc = await extractOffice(converted.path, 'docx', relPath);
|
|
980
|
+
return { ...doc, format, meta: { ...doc.meta, writable: false, legacyWord: true, sourceName: basename(absPath),
|
|
981
|
+
convertedFormat: 'docx', documentHash: det.hash, reason } };
|
|
982
|
+
} catch (error) {
|
|
983
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
984
|
+
const doc = stubDoc(relPath, format, /[\u4e00-\u9fff]/.test(message) ? message : `旧版 Word 转换失败:${message}`);
|
|
985
|
+
return { ...doc, meta: { ...doc.meta, writable: false, legacyWord: true, sourceName: basename(absPath) } };
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
966
989
|
if (format === 'csv') {
|
|
967
990
|
const { text, tooLarge } = await readText(absPath, maxBytes);
|
|
968
991
|
if (tooLarge) {
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/** Convert legacy Word bytes into validated DOCX copies; never write a DOC. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { basename, join } from 'node:path';
|
|
5
|
+
import { homedir, tmpdir } from 'node:os';
|
|
6
|
+
import { convert } from './convert.js';
|
|
7
|
+
import { extractOffice, isZip } from './office.js';
|
|
8
|
+
|
|
9
|
+
export const LEGACY_WORD_VERSION = 'legacy-word-docx-v1';
|
|
10
|
+
export const LEGACY_WORD_LIMIT = 40_000_000;
|
|
11
|
+
const pending = new Map();
|
|
12
|
+
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
13
|
+
const defaultHome = () => join(tmpdir(), `dsh-doc-assistant-legacy-${digest(Buffer.from(homedir())).slice(0, 16)}`);
|
|
14
|
+
function sameFile(a, b) {
|
|
15
|
+
return a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs && a.ino === b.ino && a.dev === b.dev;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function validateDocx(path) {
|
|
19
|
+
const info = await stat(path);
|
|
20
|
+
if (!info.size || info.size > LEGACY_WORD_LIMIT || !(await isZip(path))) throw new Error('旧版 Word 转换没有生成有效的 DOCX 文档');
|
|
21
|
+
// Extracting the actual OOXML body validates ZIP entries/XML through the
|
|
22
|
+
// same parser used by document reading, including legitimate blank docs.
|
|
23
|
+
const doc = await extractOffice(path, 'docx', basename(path));
|
|
24
|
+
if (doc.format !== 'docx' || !Array.isArray(doc.blocks)) throw new Error('转换后的 DOCX 正文无法读取');
|
|
25
|
+
return digest(await readFile(path));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function cachedResult(path, metaPath, sourceHash) {
|
|
29
|
+
try {
|
|
30
|
+
const meta = JSON.parse(await readFile(metaPath, 'utf8'));
|
|
31
|
+
if (meta.version !== LEGACY_WORD_VERSION || meta.sourceHash !== sourceHash || !meta.outputHash) return null;
|
|
32
|
+
if (await validateDocx(path) !== meta.outputHash) return null;
|
|
33
|
+
return { path, sourceHash: sourceHash.slice(0, 16), contentHash: sourceHash, cached: true };
|
|
34
|
+
} catch { return null; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Memory input is an immutable snapshot, so imports cannot corrupt originals. */
|
|
38
|
+
export async function convertLegacyWordBuffer(buffer, options = {}) {
|
|
39
|
+
if (!Buffer.isBuffer(buffer) || !buffer.length) throw new Error('旧版 Word 文件内容为空,无法转换');
|
|
40
|
+
if (buffer.length > LEGACY_WORD_LIMIT) throw new Error('旧版 Word 文件超过转换上限(40 MB)');
|
|
41
|
+
const snapshot = Buffer.from(buffer);
|
|
42
|
+
const sourceHash = digest(snapshot);
|
|
43
|
+
const dir = join(options.home || defaultHome(), 'legacy-word');
|
|
44
|
+
const key = digest(Buffer.from(`${sourceHash}\u0000${LEGACY_WORD_VERSION}`));
|
|
45
|
+
const path = join(dir, `${key}.docx`); const metaPath = `${path}.json`;
|
|
46
|
+
const hit = await cachedResult(path, metaPath, sourceHash);
|
|
47
|
+
if (hit) return hit;
|
|
48
|
+
const id = `${dir}\u0000${key}`;
|
|
49
|
+
let job = pending.get(id);
|
|
50
|
+
if (!job) {
|
|
51
|
+
job = (async () => {
|
|
52
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
53
|
+
const late = await cachedResult(path, metaPath, sourceHash);
|
|
54
|
+
if (late) return late;
|
|
55
|
+
const work = await mkdtemp(join(dir, '.convert-'));
|
|
56
|
+
try {
|
|
57
|
+
const input = join(work, 'source.doc'), output = join(work, 'converted.docx');
|
|
58
|
+
await writeFile(input, snapshot, { mode: 0o600 });
|
|
59
|
+
const { enqueueRender } = await import('./covers.js');
|
|
60
|
+
try { await enqueueRender(() => (options.converter || convert)(input, output, { timeoutMs: 120_000 })); }
|
|
61
|
+
catch (error) { throw new Error(`旧版 Word 转换失败:${error.message || error}`); }
|
|
62
|
+
const outputHash = await validateDocx(output);
|
|
63
|
+
const metadata = join(work, 'meta.json');
|
|
64
|
+
await writeFile(metadata, JSON.stringify({ version: LEGACY_WORD_VERSION, sourceHash, outputHash, at: Date.now() }), { mode: 0o600 });
|
|
65
|
+
// Cache publication is private. Content and its fingerprint are checked
|
|
66
|
+
// on every hit, so a partial/crashed publication is simply a cache miss.
|
|
67
|
+
await rename(output, path); await rename(metadata, metaPath);
|
|
68
|
+
return { path, sourceHash: sourceHash.slice(0, 16), contentHash: sourceHash, cached: false };
|
|
69
|
+
} finally { await rm(work, { recursive: true, force: true }).catch(() => {}); }
|
|
70
|
+
})().finally(() => pending.delete(id));
|
|
71
|
+
pending.set(id, job);
|
|
72
|
+
}
|
|
73
|
+
return job;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Cache a read-only converted view, rejecting edits during conversion. */
|
|
77
|
+
export async function legacyWordDocx(abs, options = {}) {
|
|
78
|
+
const before = await stat(abs);
|
|
79
|
+
if (!before.isFile() || !before.size || before.size > LEGACY_WORD_LIMIT) throw new Error('旧版 Word 文件为空或超过转换上限(40 MB)');
|
|
80
|
+
const bytes = await readFile(abs);
|
|
81
|
+
if (!sameFile(before, await stat(abs))) throw new Error('旧版 Word 文档在读取时发生变化,请重新打开');
|
|
82
|
+
const out = await convertLegacyWordBuffer(bytes, options);
|
|
83
|
+
if (!sameFile(before, await stat(abs)) || digest(await readFile(abs)) !== out.contentHash) throw new Error('旧版 Word 文档在转换时发生变化,请重新打开');
|
|
84
|
+
return { ...out, sourceName: basename(abs) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Explicit user action creates a new editable sibling, never overwrites DOC. */
|
|
88
|
+
export async function convertExistingLegacyDoc(deps, projectId, path) {
|
|
89
|
+
const { createLegacyWordCopy } = await import('./upload.js');
|
|
90
|
+
return createLegacyWordCopy(deps, projectId, path);
|
|
91
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Use the selected DSH route and its mounted attachment store for real image input. */
|
|
2
|
+
|
|
3
|
+
export function service(deps, name) {
|
|
4
|
+
try { return deps?.ctx?.get?.(name); } catch { return undefined; }
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export async function visionModelInfo(deps, route, signal) {
|
|
8
|
+
if (!route?.provider || !route?.model) return null;
|
|
9
|
+
const llm = service(deps, 'llm');
|
|
10
|
+
if (!llm) return null;
|
|
11
|
+
if (typeof llm.resolveModelInfo === 'function') {
|
|
12
|
+
const info = await llm.resolveModelInfo(route.provider, route.model, signal);
|
|
13
|
+
if (Array.isArray(info?.inputModalities)) return info;
|
|
14
|
+
}
|
|
15
|
+
if (typeof llm.listModels === 'function') {
|
|
16
|
+
const models = await llm.listModels(route.provider);
|
|
17
|
+
if (Array.isArray(models)) return models.find(item => item?.id === route.model) || null;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function supportsVision(deps, route, signal) {
|
|
23
|
+
const info = await visionModelInfo(deps, route, signal);
|
|
24
|
+
return info?.inputModalities?.includes('image') === true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The LLM vocabulary takes a durable attachment reference, never an ad hoc base64 block. */
|
|
28
|
+
export async function imageInput(deps, route, data, mediaType, name, { signal } = {}) {
|
|
29
|
+
signal?.throwIfAborted();
|
|
30
|
+
if (!route?.provider || !route?.model) throw new Error('无法确定识图模型,请重新选择文档 AI 模型');
|
|
31
|
+
const attachments = service(deps, 'attachments');
|
|
32
|
+
if (!attachments) throw new Error('DSH 图片附件服务不可用,暂时无法向模型发送文档图片');
|
|
33
|
+
if (!(data instanceof Uint8Array) || !data.byteLength) throw new Error('文档页面图片为空,无法发送模型');
|
|
34
|
+
const input = { data: Uint8Array.from(data), mediaType, name };
|
|
35
|
+
let attachment;
|
|
36
|
+
if (typeof attachments.saveImages === 'function') [attachment] = await attachments.saveImages([input]);
|
|
37
|
+
else if (typeof attachments.saveImage === 'function') {
|
|
38
|
+
attachments.validateImageBatch?.([input]);
|
|
39
|
+
attachment = await attachments.saveImage(input);
|
|
40
|
+
} else throw new Error('DSH 图片附件服务不支持保存图片,请更新 DSH');
|
|
41
|
+
signal?.throwIfAborted();
|
|
42
|
+
if (!attachment?.attachmentId) throw new Error('DSH 没有返回可用的页面图片附件');
|
|
43
|
+
return { type: 'image', attachment };
|
|
44
|
+
}
|