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.
@@ -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}CoverImg{display:block;width:100%;height:100%;object-fit:cover;object-position:top center;
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
- else if (expectedHash && expectedHash !== doc.hash) blocked = '文档在引用后已变化,请重新引用原文';
867
- else if (!anchor) blocked = '引用缺少原文位置,请重新引用';
868
- else if (anchor.kind === 'cell') {
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 (!Number.isInteger(anchor.srcStart) || !Number.isInteger(anchor.srcEnd)
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: chosenRoute,
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) {
@@ -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** PNG
1103
+ * Absolute path of one document's cached **page** image
1094
1104
  * (`covers/<projectId>/<sha256(relPath)[:16]>.png`).
1095
1105
  *
1096
- * The `.json` sibling next to it holds the `docHash` that decides freshness —
1097
- * the PNG cannot store its own provenance, and re-deriving it from the file
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
- return bytes.length > 0 ? { bytes, meta } : null;
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.push(await pageCover(deps, projectId, entry.relPath, format, docChars, abs));
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}` : docHash;
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: 'image/png', cached: true, ms: Date.now() - started };
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: 'image/png', cached: true, ms: Date.now() - started };
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
+ }