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.
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * PowerPoint is one PNG per slide. Word, Excel and PDF use the same page
5
5
  * PNGs. `/doc/page-text` without `page` is the PDF text layer. Word keeps
6
- * this native layer when available; other page requests use macOS Vision
6
+ * this native layer when available; pages without text use the system OCR
7
7
  * to read the page's PNG and return boxes only — the reader
8
8
  * does not paint the recognized glyphs. The PDF iframe remains the fallback
9
9
  * when both layers are empty.
@@ -27,15 +27,16 @@ import { extname, join } from 'node:path';
27
27
  import { enqueueRender } from './covers.js';
28
28
  import { available, convert, renderImages, ConvertError, WORD_PREVIEW_VERSION } from './convert.js';
29
29
  import { PREVIEW_UNAVAILABLE_MESSAGE, RUNTIME_HELP_URL, needsRuntimeHelp } from './setup.js';
30
- import { recognizePage } from './ocr.js';
30
+ import { recognizePage, cachedPageRecognition } from './ocr.js';
31
31
  import { pdfPageText } from './pdfText.js';
32
+ import { getPptxSlideImages } from './officeImages.js';
32
33
 
33
34
  /** Office/PDF files above this are refused before LibreOffice starts. */
34
35
  export const PREVIEW_LIMIT = 40_000_000;
35
36
 
36
37
  const MAX_CACHED = 40;
37
38
  const SLIDE_DPI = 144;
38
- const LAYOUT_FORMATS = new Set(['pdf', 'docx', 'xlsx', 'pptx']);
39
+ const LAYOUT_FORMATS = new Set(['pdf', 'doc', 'docx', 'xlsx', 'pptx']);
39
40
 
40
41
  /** @type {Map<string, Promise<string>>} */
41
42
  const inflight = new Map();
@@ -86,7 +87,7 @@ async function sweep(dir) {
86
87
  await rm(join(dir, `${key}.json`), { force: true }).catch(() => undefined);
87
88
  const siblings = await readdir(dir).catch(() => []);
88
89
  await Promise.all(siblings
89
- .filter((name) => name.startsWith(`${key}-p`) && (name.endsWith('.png') || name.endsWith('.png.ocr.json')))
90
+ .filter((name) => name.startsWith(`${key}-p`) && /\.(?:png|jpe?g)(?:\.ocr(?:-vision-[a-f\d]+)?\.json)?$/.test(name))
90
91
  .map((name) => rm(join(dir, name), { force: true }).catch(() => undefined)));
91
92
  }
92
93
  }
@@ -119,6 +120,29 @@ async function readPageCount(metaPath) {
119
120
  */
120
121
  export async function ensureSlide(abs, format, home, page, signal) {
121
122
  if (!Number.isInteger(page) || page < 1 || page > 5000) throw new Error('页码不正确');
123
+ if (signal?.aborted) throw new Error('版式预览已取消');
124
+ if (format === 'pptx') {
125
+ const before = await stat(abs);
126
+ if (before.size > PREVIEW_LIMIT) throw new Error('文件超过版式预览上限(40 MB)');
127
+ const original = await getPptxSlideImages(abs, { pages: [page] });
128
+ if (original.totalPages && page > original.totalPages) throw new Error('没有这一页');
129
+ const image = original.images[0];
130
+ if (image) {
131
+ const info = await stat(abs);
132
+ if (info.size !== before.size || info.mtimeMs !== before.mtimeMs || info.ino !== before.ino) {
133
+ throw new Error('文档已更新,请重新加载预览');
134
+ }
135
+ if (info.size > PREVIEW_LIMIT) throw new Error('文件超过版式预览上限(40 MB)');
136
+ const dir = join(home, 'previews');
137
+ const key = cacheKey(abs, info);
138
+ const imagePath = join(dir, `${key}-p${String(page).padStart(4, '0')}-source.${image.mime === 'image/jpeg' ? 'jpg' : 'png'}`);
139
+ await mkdir(dir, { recursive: true });
140
+ // Preserve the original page pixels and its OCR cache timestamps.
141
+ try { if ((await stat(imagePath)).size !== image.bytes.length) await writeFile(imagePath, image.bytes); }
142
+ catch { await writeFile(imagePath, image.bytes); }
143
+ return { pngPath: imagePath, pages: original.totalPages, mime: image.mime, aspect: image.width / image.height };
144
+ }
145
+ }
122
146
  const pdfPath = await ensurePdf(abs, format, home, signal);
123
147
  const fileStat = await stat(abs);
124
148
  const key = cacheKey(abs, fileStat);
@@ -370,11 +394,11 @@ export async function handleSlide(deps, _req, res, url) {
370
394
  try {
371
395
  const target = await resolveLayout(deps, url);
372
396
  const page = Number(url.searchParams.get('page') ?? '1');
373
- const { pngPath, pages } = await ensureSlide(target.abs, target.format, deps.config.home, page);
397
+ const { pngPath, pages, mime } = await ensureSlide(target.abs, target.format, deps.config.home, page);
374
398
  if (res.writableEnded || res.headersSent) return;
375
399
  const body = await readFile(pngPath);
376
400
  res.writeHead(200, {
377
- 'content-type': 'image/png',
401
+ 'content-type': mime || 'image/png',
378
402
  'content-length': String(body.length),
379
403
  'cache-control': 'private, max-age=300',
380
404
  'x-content-type-options': 'nosniff',
@@ -388,11 +412,95 @@ export async function handleSlide(deps, _req, res, url) {
388
412
  }
389
413
  }
390
414
 
415
+ /** Shared reader/chat page recognition. Native PDF text takes precedence for
416
+ * every format; image pages use the same local OCR cache as the reader.
417
+ * A caller may supply the already parsed PDF layer to avoid reading it again.
418
+ * The optional selected-model fallback is restricted to this requested page.
419
+ */
420
+ export function sparseNativeSlide(lines) {
421
+ const chars = lines.reduce((total, line) => total + String(line.text || '').replace(/\s+/g, '').length, 0);
422
+ const area = lines.reduce((total, line) => total + Math.max(0, Number(line.w) || 0) * Math.max(0, Number(line.h) || 0), 0);
423
+ // A heading, copyright or a few labels do not prove that the image body has
424
+ // a text layer. Dense text slides can keep their native, precise fast path.
425
+ return chars < 120 || (chars < 300 && area < .1);
426
+ }
427
+
428
+ export function mergePageText(native, recognized) {
429
+ const kept = native.map(line => ({ ...line, ocr: false }));
430
+ const comparable = text => String(text || '').replace(/\s+/g, '').toLowerCase();
431
+ for (const line of recognized) {
432
+ const duplicate = native.some(original => {
433
+ const ix = Math.min(original.x + original.w, line.x + line.w) - Math.max(original.x, line.x);
434
+ const iy = Math.min(original.y + original.h, line.y + line.h) - Math.max(original.y, line.y);
435
+ if (!(ix > 0 && iy > 0)) return false;
436
+ const overlap = ix * iy;
437
+ const originalArea = original.w * original.h, recognizedArea = line.w * line.h;
438
+ // Native spelling and exact glyphs win even when OCR makes a typo.
439
+ return overlap / recognizedArea > .7 || (comparable(original.text) === comparable(line.text)
440
+ && overlap / Math.min(originalArea, recognizedArea) > .5);
441
+ });
442
+ if (!duplicate) kept.push({ ...line, ocr: true });
443
+ }
444
+ return kept.sort((a, b) => Number(a.page) - Number(b.page) || a.y - b.y || a.x - b.x);
445
+ }
446
+
447
+ export async function recognizeLayoutPage(abs, format, home, page, options = {}) {
448
+ if (!Number.isInteger(page) || page < 1 || page > 5000) throw new Error('页码超出预览范围');
449
+ if (options.signal?.aborted) throw new Error('页面文字识别已取消');
450
+ // A strict full-slide source image needs neither Office conversion nor PDF
451
+ // parsing. Complex or editable slides still use the regular render path.
452
+ const embedded = format === 'pptx' ? (await getPptxSlideImages(abs, { pages: [page] })).images[0] : null;
453
+ const text = options.nativeText || (embedded ? { pages: 0, aspect: embedded.width / embedded.height, lines: [] }
454
+ : await pdfPageText(await ensurePdf(abs, format, home, options.signal)));
455
+ const native = (Array.isArray(text.lines) ? text.lines : []).filter(line => Number(line.page) === page);
456
+ if (native.length > 0 && ((format !== 'pptx' && options.inspectImages !== true) || !sparseNativeSlide(native))) return { source: 'pdf', pages: Number(text.pages) || page,
457
+ aspect: Number(text.aspect) || .772, lines: native };
458
+ const { pngPath, pages } = await ensureSlide(abs, format, home, page, options.signal);
459
+ const local = await recognizePage(pngPath, home, options.localOcr || {});
460
+ if (options.signal?.aborted) throw new Error('页面文字识别已取消');
461
+ let recognized = local;
462
+ if (options.allowVision !== false && options.deps && options.route &&
463
+ (local.lines.length === 0 || local.ocr?.code === 'missing-chinese')) {
464
+ const { recognizeVisionPage } = await import('./visionOcr.js');
465
+ const visual = await recognizeVisionPage(options.deps, options.route, pngPath, { signal: options.signal });
466
+ if (visual?.ocr?.status === 'ready' || visual?.ocr?.status === 'empty') recognized = visual;
467
+ else if (visual?.ocr?.error) recognized = { ...local, ocr: { ...local.ocr, visionError: visual.ocr.error } };
468
+ }
469
+ const ocrLines = recognized.lines.map(line => ({ ...line, page }));
470
+ const lines = native.length ? mergePageText(native, ocrLines) : ocrLines;
471
+ return { source: native.length ? (lines.some(line => line.ocr) ? 'mixed' : 'pdf') : 'ocr', pages,
472
+ aspect: recognized.aspect > 0 ? recognized.aspect : Number(text.aspect) || .772,
473
+ lines, ocr: recognized.ocr };
474
+ }
475
+
476
+ /** Chat can reuse reader OCR without converting/rendering/recognizing a page.
477
+ * Returns null when it has not yet been recognized. This is intentionally
478
+ * cache-only: whole-document chat chooses its own bounded image strategy.
479
+ */
480
+ export async function cachedLayoutPageText(abs, format, home, page, options = {}) {
481
+ if (!Number.isInteger(page) || page < 1 || page > 5000) return null;
482
+ let info;
483
+ try { info = await stat(abs); } catch { return null; }
484
+ const key = cacheKey(abs, info);
485
+ const base = join(home, 'previews', `${key}-p${String(page).padStart(4, '0')}`);
486
+ for (const imagePath of [`${base}-source.jpg`, `${base}-source.png`, `${base}.png`]) {
487
+ const local = await cachedPageRecognition(imagePath);
488
+ if (local?.lines.length && local.ocr?.code !== 'missing-chinese') return { source: 'ocr', ...local, lines: local.lines.map(line => ({ ...line, page })) };
489
+ if (options.route) {
490
+ const { cachedVisionRecognition } = await import('./visionOcr.js');
491
+ const visual = await cachedVisionRecognition(imagePath, options.route);
492
+ if (visual?.lines.length) return { source: 'ocr', ...visual, lines: visual.lines.map(line => ({ ...line, page })) };
493
+ }
494
+ if (local?.lines.length) return { source: 'ocr', ...local, lines: local.lines.map(line => ({ ...line, page })) };
495
+ }
496
+ return null;
497
+ }
498
+
391
499
  /**
392
500
  * `GET /doc/page-text` — selectable lines for the layout preview.
393
501
  * Without `page`, this is the PDF text layer for the whole document.
394
- * With `page`, Word first uses the native PDF text (`source: "pdf"`);
395
- * only pages without native text fall back to Vision (`source: "ocr"`).
502
+ * With `page`, native PDF text is preferred (`source: "pdf"`);
503
+ * image pages use local system OCR or the explicitly selected vision model.
396
504
  * An empty `lines` list means the reader should keep whatever it already has.
397
505
  * Business failures stay `{ ok: false }` at HTTP 200.
398
506
  *
@@ -407,28 +515,24 @@ export async function handlePageText(deps, _req, res, url) {
407
515
  const pageRaw = url.searchParams.get('page');
408
516
  if (pageRaw != null && pageRaw !== '') {
409
517
  const page = Number(pageRaw);
410
- if (!Number.isInteger(page) || page < 1 || page > 80) throw new Error('页码超出预览范围');
411
- if (target.format === 'docx') {
412
- const pdfPath = await ensurePdf(target.abs, target.format, deps.config.home);
413
- const text = await pdfPageText(pdfPath);
414
- const lines = text.lines.filter((line) => Number(line.page) === page);
415
- if (lines.length > 0) {
416
- if (res.writableEnded || res.headersSent) return;
417
- sendJson(res, { ok: true, source: 'pdf', pages: text.pages, aspect: text.aspect, lines });
418
- return;
419
- }
518
+ if (!Number.isInteger(page) || page < 1 || page > 5000) throw new Error('页码超出预览范围');
519
+ let route;
520
+ if (url.searchParams.has('provider') || url.searchParams.has('model')) {
521
+ const { selectedModelRoute } = await import('./chat.js');
522
+ route = await selectedModelRoute(deps, { provider: url.searchParams.get('provider'), model: url.searchParams.get('model') });
523
+ } else {
524
+ const { resolveRoute } = await import('./revise.js');
525
+ route = await resolveRoute(deps.ctx, deps, {});
420
526
  }
421
- const { pngPath, pages } = await ensureSlide(target.abs, target.format, deps.config.home, page);
422
- const ocr = await recognizePage(pngPath, deps.config.home);
423
- if (res.writableEnded || res.headersSent) return;
424
- const lines = (Array.isArray(ocr.lines) ? ocr.lines : []).map((line) => ({ ...line, page }));
425
- sendJson(res, {
426
- ok: true,
427
- source: 'ocr',
428
- pages,
429
- aspect: ocr.aspect > 0 ? ocr.aspect : 0.772,
430
- lines,
527
+ const recognized = await recognizeLayoutPage(target.abs, target.format, deps.config.home, page, {
528
+ deps, route,
529
+ // PPTX requests only its active slide. PDF/Word readers prefetch their
530
+ // complete local text layer, so a missing system OCR must not silently
531
+ // turn that prefetch into dozens of paid vision requests.
532
+ allowVision: target.format === 'pptx' || url.searchParams.get('vision') === '1',
431
533
  });
534
+ if (res.writableEnded || res.headersSent) return;
535
+ sendJson(res, { ok: true, ...recognized });
432
536
  return;
433
537
  }
434
538
  const pdfPath = await ensurePdf(target.abs, target.format, deps.config.home);
@@ -64,7 +64,12 @@
64
64
  */
65
65
 
66
66
  import { randomUUID } from 'node:crypto';
67
+ import { realpath } from 'node:fs/promises';
67
68
  import { expansionPlan, expansionPrompt } from './expansion.js';
69
+ import { needsRevisionBoundary, normalizeRevisionBoundary, revisionBoundaryPrompt } from './revisionBoundary.js';
70
+ import { resolveWordSelection, protectWordSelectionPrefix } from './wordSelection.js';
71
+ import { rebaseRevisionSelection } from './revisionRebase.js';
72
+ import { createWriteback } from './writeback.js';
68
73
 
69
74
  // ---------------------------------------------------------------------------
70
75
  // deps / ctx normalization
@@ -158,6 +163,7 @@ export const SYSTEM_PROMPT = [
158
163
  '3. 不得增删事实;不得编造原文没有的数字、日期、名称。',
159
164
  '4. 如果原文没有需要改动的地方,原样返回。',
160
165
  '5. 同时给出一句不超过 50 字的修改理由。',
166
+ '6. 润色和扩写只替换选区本身:原文没有选入末尾句末标点时,结尾不得新增句末标点,选区外的标点会保留。',
161
167
  '',
162
168
  '【输出格式】',
163
169
  '只返回一个 JSON 对象,前后不要有任何其他文字(不要 Markdown 代码块):',
@@ -181,6 +187,8 @@ function buildUserPrompt(p) {
181
187
  if (p.before) parts.push(`【上文】\n${p.before}`);
182
188
  if (p.after) parts.push(`【下文】\n${p.after}`);
183
189
  parts.push(`【待改写的原文】\n<<<ORIGINAL\n${p.original}\nORIGINAL>>>`);
190
+ if (p.protectBoundary) parts.push(revisionBoundaryPrompt(p.boundaryOriginal ?? p.original));
191
+ if (p.wholeSelection) parts.push('【完整选区】以上原文由多个相邻段落组成,是同一次选择。必须一次处理全部选中内容,revised 返回整份完整结果;不得只处理第一段或最后一段,不得省略中间任何一句。可按逻辑使用换行,不要把结果按原段字数拆分;原文开头独立的列表标记保持原样。');
184
192
  parts.push('请只返回改写后的这段文字(不要包含 <<<ORIGINAL 标记),并附一句理由。');
185
193
  return parts.join('\n\n');
186
194
  }
@@ -593,6 +601,7 @@ async function viaLlm(ctx, deps, params, req) {
593
601
  throw new Error(`模型调用失败:${chunk.reason?.failure?.message ?? '未知错误'}`);
594
602
  }
595
603
  if (kind === 'aborted') throw new Error('改写请求已取消');
604
+ if (kind === 'max-tokens') throw new Error('修改结果超过模型输出长度,未生成修订;请缩小选区后重试');
596
605
  }
597
606
  }
598
607
  return { raw: text, model: `${route.provider}/${route.model}` };
@@ -771,6 +780,11 @@ function pushVersion(revision, at = Date.now()) {
771
780
  return revision;
772
781
  }
773
782
 
783
+ async function relocateSelection(deps, params, phase) {
784
+ try { return await rebaseRevisionSelection(deps, params); }
785
+ catch (error) { error.message = `${phase}:${error.message}`; throw error; }
786
+ }
787
+
774
788
  // ---------------------------------------------------------------------------
775
789
  // Public API
776
790
  // ---------------------------------------------------------------------------
@@ -794,7 +808,8 @@ function pushVersion(revision, at = Date.now()) {
794
808
  export async function generate(first, params, completedReply) {
795
809
  const { ctx, deps } = normalizeDeps(first);
796
810
  if (!params || typeof params !== 'object') throw new Error('revise.generate 缺少参数');
797
- const { anchor, instruction, styleId } = params;
811
+ const { instruction, styleId } = params;
812
+ let anchor = params.anchor ? structuredClone(params.anchor) : params.anchor;
798
813
  if (!anchor || typeof anchor !== 'object') throw new Error('缺少修订锚点 anchor');
799
814
 
800
815
  const docir = deps.docir ?? (await import('./docir.js'));
@@ -817,22 +832,46 @@ export async function generate(first, params, completedReply) {
817
832
  }
818
833
  }
819
834
 
820
- const initialInfo = abs ? await docir.detect(abs) : null;
821
- const earlyFormat = params.format ?? (initialInfo ? initialInfo.format : 'markdown');
835
+ const sourceIdentity = abs ? await realpath(abs) : null;
836
+ const locked = fn => abs && projects?.resolve ? createWriteback(deps).withDocLock(project, params.path, async () => {
837
+ const live = projects.get?.(project.id);
838
+ if (typeof projects.get === 'function' && !live) throw new Error('项目已被移除,已取消生成修订');
839
+ const current = projects.resolve(project.id, params.path);
840
+ if (current.readOnly) throw new Error('文档所在根目录已改为只读,已取消生成修订');
841
+ if (await realpath(current.abs) !== sourceIdentity) throw new Error('文档所在根目录已改变,已取消生成修订');
842
+ return fn();
843
+ }) : fn();
844
+ let initialInfo, earlyFormat, parsed = null, wordSelection, resolvedAnchor, original;
845
+ await locked(async () => {
846
+ initialInfo = abs ? await docir.detect(abs) : null;
847
+ earlyFormat = params.format ?? (initialInfo ? initialInfo.format : 'markdown');
822
848
  if (!writers.canWrite?.(earlyFormat) && writers.reason?.(earlyFormat)) {
823
849
  throw new Error(`无法为 ${earlyFormat} 生成可写回的修订:${writers.reason(earlyFormat)}`);
824
850
  }
825
851
 
826
- let parsed = null;
852
+ if (params.expectedDocumentHash && initialInfo && params.expectedDocumentHash !== initialInfo.hash) {
853
+ const relocated = await relocateSelection(deps, { project, path: params.path, abs,
854
+ fromHash: params.expectedDocumentHash, toHash: initialInfo.hash, anchor,
855
+ original: params.expectedOriginal ?? params.original, format: earlyFormat }, '文档在选中后已经变化');
856
+ anchor = relocated.anchor; source = undefined;
857
+ }
827
858
  if (source === undefined) {
828
859
  const loaded = await loadPlain(docir, abs, earlyFormat);
829
860
  source = loaded.source;
830
861
  parsed = loaded.doc;
831
862
  }
832
- let resolvedAnchor = enrichAnchor(anchor, parsed);
863
+ if (earlyFormat === 'docx' && !parsed && abs) parsed = await docir.parse(abs);
864
+ wordSelection = earlyFormat === 'docx' && parsed ? resolveWordSelection(parsed, anchor) : null;
865
+ if (earlyFormat === 'docx' && Array.isArray(anchor.segments) && !wordSelection) throw new Error('复合 Word 选区必须核对文档真实段落');
866
+ resolvedAnchor = wordSelection?.anchor || enrichAnchor(anchor, parsed);
867
+ if (wordSelection) source = (await import('./office.js')).plainSource(parsed);
833
868
 
834
869
  // ---- extract the original text for the anchor --------------------------
835
- let original = params.original;
870
+ original = params.original;
871
+ if (wordSelection) {
872
+ if (original !== undefined && original !== wordSelection.original) throw new Error('选区文字与文档中的完整原文不一致,请重新选择后生成修订');
873
+ original = wordSelection.original;
874
+ }
836
875
  if (original === undefined) {
837
876
  if (Number.isInteger(resolvedAnchor.srcStart) && Number.isInteger(resolvedAnchor.srcEnd)) {
838
877
  original = source.slice(resolvedAnchor.srcStart, resolvedAnchor.srcEnd);
@@ -853,16 +892,22 @@ export async function generate(first, params, completedReply) {
853
892
  if (typeof params.expectedOriginal === 'string' && params.expectedOriginal !== original) {
854
893
  throw new Error('选区文字与文档中的完整原文不一致,请重新选择后生成修订');
855
894
  }
856
- if (params.expectedDocumentHash && abs && (await docir.detect(abs)).hash !== params.expectedDocumentHash) {
895
+ if (initialInfo && abs && (await docir.detect(abs)).hash !== initialInfo.hash) {
857
896
  throw new Error('文档在选中后已经变化,请重新打开并选择文字');
858
897
  }
898
+ });
859
899
 
860
900
  // 用户在「改文本框」里自己改的字,不再送给模型。写回仍用同一条修订。
861
901
  if (typeof params.revised === 'string') {
862
- const revised = params.revised;
902
+ return locked(async () => {
903
+ const revised = completedReply ? protectWordSelectionPrefix(resolvedAnchor, normalizeRevisionBoundary(original, params.revised)) : params.revised;
863
904
  if (revised.length > 8000) throw new Error('修改内容过长,无法生成修订');
864
905
  const manualInfo = abs ? await docir.detect(abs) : { hash: '' };
865
- if (initialInfo && manualInfo.hash !== initialInfo.hash) throw new Error('文档在生成修订期间发生变化,请重新选择后再试');
906
+ if (initialInfo && manualInfo.hash !== initialInfo.hash) {
907
+ const moved = await relocateSelection(deps, { project, path: params.path, abs,
908
+ fromHash: initialInfo.hash, toHash: manualInfo.hash, anchor: resolvedAnchor, original, format: earlyFormat }, '文档在生成修订期间发生变化');
909
+ resolvedAnchor = moved.anchor;
910
+ }
866
911
  if (completedReply && revised.replace(/\r\n?/g, '\n').trim() === original.replace(/\r\n?/g, '\n').trim()) {
867
912
  return { unchanged: true, projectId: project.id ?? params.projectId, docPath: params.path,
868
913
  original, revised: original, action: 'chat', model: completedReply.model, status: 'unchanged' };
@@ -889,6 +934,7 @@ export async function generate(first, params, completedReply) {
889
934
  pushVersion(manual);
890
935
  if (deps.revisions?.save) await deps.revisions.save(project, manual);
891
936
  return manual;
937
+ });
892
938
  }
893
939
 
894
940
  // ---- style preset ------------------------------------------------------
@@ -909,6 +955,8 @@ export async function generate(first, params, completedReply) {
909
955
  before,
910
956
  after,
911
957
  original,
958
+ protectBoundary: needsRevisionBoundary(params.action, instruction),
959
+ wholeSelection: Array.isArray(resolvedAnchor.segments),
912
960
  });
913
961
 
914
962
  const signal = params.signal ?? new AbortController().signal;
@@ -920,8 +968,10 @@ export async function generate(first, params, completedReply) {
920
968
  expansionTargetChars: setting(deps, 'expansionTargetChars', 300),
921
969
  }) : null;
922
970
 
923
- const out = await rewriteOnce(ctx, deps, plan && params.maxTokens === undefined
924
- ? { ...params, maxTokens: Math.min(32768, Math.max(4096, plan.targetChars * 4 + 1024)) } : params, {
971
+ const wholeSelection = Array.isArray(resolvedAnchor.segments);
972
+ const budget = plan ? plan.targetChars * 4 + 1024 : original.length * 4 + 1024;
973
+ const out = await rewriteOnce(ctx, deps, (plan || wholeSelection) && params.maxTokens === undefined
974
+ ? { ...params, maxTokens: Math.min(32768, Math.max(4096, budget)) } : params, {
925
975
  system: SYSTEM_PROMPT,
926
976
  user: expansion ? `${user}\n\n${expansionPrompt(plan)}\n第二份完整文本放在 alternatives 数组中(最多一项),不要为了凑两份只改措辞。` : user,
927
977
  original,
@@ -933,12 +983,21 @@ export async function generate(first, params, completedReply) {
933
983
  });
934
984
 
935
985
  // ---- assemble the Revision (§3) ---------------------------------------
986
+ return locked(async () => {
936
987
  const info = abs ? await docir.detect(abs) : { hash: '' };
937
- if (initialInfo && info.hash !== initialInfo.hash) throw new Error('文档在生成修订期间发生变化,请重新选择后再试');
988
+ if (initialInfo && info.hash !== initialInfo.hash) {
989
+ const moved = await relocateSelection(deps, { project, path: params.path, abs,
990
+ fromHash: initialInfo.hash, toHash: info.hash, anchor: resolvedAnchor, original, format: earlyFormat }, '文档在生成修订期间发生变化');
991
+ resolvedAnchor = moved.anchor;
992
+ }
938
993
  const sameText = text => text.replace(/\r\n?/g, '\n').trim() === original.replace(/\r\n?/g, '\n').trim();
939
994
  const extraChoices = expansion && Array.isArray(out.alternatives) ? out.alternatives.filter(item => item && typeof item.revised === 'string'
940
995
  && item.revised.trim() && item.revised.length <= 8000).slice(0, 1) : [];
996
+ const protectBoundary = needsRevisionBoundary(params.action, instruction);
941
997
  const choices = [{ revised: out.revised, rationale: out.rationale }, ...extraChoices]
998
+ .map(item => ({ ...item, revised: protectWordSelectionPrefix(resolvedAnchor,
999
+ protectBoundary ? normalizeRevisionBoundary(original, item.revised) : item.revised) }))
1000
+ .filter(item => !protectBoundary || item.revised.trim() !== '')
942
1001
  .filter((item, index, all) => !sameText(item.revised) && all.findIndex(other => other.revised.trim() === item.revised.trim()) === index);
943
1002
  if (!choices.length) {
944
1003
  return { unchanged: true, projectId: project.id ?? params.projectId, docPath: params.path,
@@ -964,6 +1023,7 @@ export async function generate(first, params, completedReply) {
964
1023
  instruction: instruction ?? '',
965
1024
  rationale: primary.rationale,
966
1025
  status: 'pending',
1026
+ layoutWarning: null,
967
1027
  versions: [],
968
1028
  createdAt: Date.now(),
969
1029
  model: out.model,
@@ -982,6 +1042,7 @@ export async function generate(first, params, completedReply) {
982
1042
  if (deps.revisions?.save) await deps.revisions.save(project, revision);
983
1043
  for (const alternative of alternatives) if (deps.revisions?.save) await deps.revisions.save(project, alternative);
984
1044
  return revision;
1045
+ });
985
1046
  }
986
1047
 
987
1048
  /** A chat completion already supplied the wording; reuse all source/hash checks without a second model call. */
@@ -1011,6 +1072,7 @@ export async function iterate(first, params) {
1011
1072
  if (!params.instruction) throw new Error('revise.iterate 缺少 instruction');
1012
1073
 
1013
1074
  const docir = deps.docir ?? (await import('./docir.js'));
1075
+ const writers = deps.writers ?? (await import('./writers/index.js'));
1014
1076
 
1015
1077
  // Locate the stored revision: explicit object, or via the revisions service.
1016
1078
  let revision = params.revision;
@@ -1026,21 +1088,55 @@ export async function iterate(first, params) {
1026
1088
  if (!revision) throw new Error(`找不到修订 ${params.revisionId}`);
1027
1089
  }
1028
1090
  if (revision.status === 'rejected') throw new Error('该修订已被拒绝,请重新生成');
1091
+ let sourceIdentity;
1092
+ const locked = fn => deps.projects?.resolve && project
1093
+ ? createWriteback(deps).withDocLock(project, revision.docPath, async () => {
1094
+ if (typeof deps.projects.get === 'function' && !deps.projects.get(project.id)) throw new Error('项目已被移除,已取消生成修订');
1095
+ const current = deps.projects.resolve(project.id, revision.docPath);
1096
+ if (current.readOnly) throw new Error('文档所在根目录已改为只读,已取消生成修订');
1097
+ if (sourceIdentity && await realpath(current.abs) !== sourceIdentity) throw new Error('文档所在根目录已改变,已取消生成修订');
1098
+ return fn();
1099
+ }) : fn();
1029
1100
 
1030
1101
  // Read the *current* file so context reflects what is actually on disk.
1031
1102
  // Office context is the extracted plain text, not the zip.
1032
1103
  let source = params.source;
1033
1104
  let abs = params.abs;
1034
- if (source === undefined && deps.projects?.resolve && revision.docPath) {
1105
+ let iterateInfo = null, iterateDoc = null;
1106
+ if (!abs && deps.projects?.resolve && revision.docPath) {
1035
1107
  const pid = params.projectId ?? revision.projectId;
1036
- const resolved = deps.projects.resolve(pid, revision.docPath);
1037
- abs = resolved.abs;
1038
- const fmt = (await docir.detect(abs)).format;
1039
- const loaded = await loadPlain(docir, abs, fmt);
1040
- source = loaded.source;
1108
+ abs = deps.projects.resolve(pid, revision.docPath).abs;
1109
+ }
1110
+ sourceIdentity = abs ? await realpath(abs) : null;
1111
+ let anchor = revision.anchor ?? {};
1112
+ await locked(async () => {
1113
+ if (abs) {
1114
+ iterateInfo = await docir.detect(abs);
1115
+ const fmt = iterateInfo.format;
1116
+ if (!writers.canWrite?.(fmt) && writers.reason?.(fmt)) {
1117
+ throw new Error(`无法继续修改 ${fmt} 修订:${writers.reason(fmt)}`);
1118
+ }
1119
+ if (source === undefined) {
1120
+ const loaded = await loadPlain(docir, abs, fmt);
1121
+ source = loaded.source;
1122
+ iterateDoc = loaded.doc;
1123
+ }
1041
1124
  }
1042
1125
 
1043
- const anchor = revision.anchor ?? {};
1126
+ if (abs && iterateInfo && revision.docHash !== iterateInfo.hash) {
1127
+ const moved = await rebaseRevisionSelection(deps, { project, path: revision.docPath, abs,
1128
+ fromHash: revision.docHash, toHash: iterateInfo.hash, anchor, original: revision.original, format: iterateInfo.format });
1129
+ anchor = moved.anchor;
1130
+ }
1131
+ if (iterateInfo?.format === 'docx') {
1132
+ iterateDoc = await docir.parse(abs);
1133
+ const selected = resolveWordSelection(iterateDoc, anchor);
1134
+ if (selected.original !== revision.original) throw new Error('修订的完整原文已经变化,请重新选择');
1135
+ anchor = selected.anchor;
1136
+ source = (await import('./office.js')).plainSource(iterateDoc);
1137
+ }
1138
+ if (abs && iterateInfo && (await docir.detect(abs)).hash !== iterateInfo.hash) throw new Error('文档在读取修订期间发生变化');
1139
+ });
1044
1140
  const { before, after } = Number.isInteger(anchor.srcStart)
1045
1141
  ? surrounding(source ?? '', anchor.srcStart, anchor.srcEnd, setting(deps, 'contextChars', 200))
1046
1142
  : { before: '', after: '' };
@@ -1057,9 +1153,13 @@ export async function iterate(first, params) {
1057
1153
  original: revision.revised,
1058
1154
  before,
1059
1155
  after,
1156
+ protectBoundary: needsRevisionBoundary(revision.action, combinedInstruction),
1157
+ boundaryOriginal: revision.original,
1158
+ wholeSelection: Array.isArray(anchor.segments),
1060
1159
  });
1061
1160
 
1062
- const out = await rewriteOnce(ctx, deps, params, {
1161
+ const out = await rewriteOnce(ctx, deps, Array.isArray(anchor.segments) && params.maxTokens === undefined
1162
+ ? { ...params, maxTokens: Math.min(32768, Math.max(4096, String(revision.revised || '').length * 4 + 1024)) } : params, {
1063
1163
  system: SYSTEM_PROMPT,
1064
1164
  user,
1065
1165
  original: revision.revised,
@@ -1070,13 +1170,29 @@ export async function iterate(first, params) {
1070
1170
  prefer: resolveChannel(deps, params),
1071
1171
  });
1072
1172
 
1173
+ if (needsRevisionBoundary(revision.action, combinedInstruction)) {
1174
+ out.revised = normalizeRevisionBoundary(String(revision.original ?? ''), out.revised);
1175
+ }
1176
+ out.revised = protectWordSelectionPrefix(anchor, out.revised);
1177
+ return locked(async () => {
1178
+ const endInfo = abs ? await docir.detect(abs) : iterateInfo;
1179
+ if (abs && iterateInfo && endInfo.hash !== iterateInfo.hash) {
1180
+ const moved = await rebaseRevisionSelection(deps, { project, path: revision.docPath, abs,
1181
+ fromHash: iterateInfo.hash, toHash: endInfo.hash, anchor, original: revision.original, format: endInfo.format });
1182
+ anchor = moved.anchor;
1183
+ }
1184
+ if (out.revised.replace(/\r\n?/g, '\n').trim() === String(revision.revised ?? '').replace(/\r\n?/g, '\n').trim()) return revision;
1185
+
1073
1186
  const next = {
1074
1187
  ...revision,
1188
+ anchor,
1075
1189
  revised: out.revised,
1076
1190
  rationale: out.rationale,
1077
1191
  instruction: combinedInstruction,
1078
1192
  status: 'pending',
1079
1193
  model: out.model,
1194
+ error: '',
1195
+ layoutWarning: null,
1080
1196
  // Recompute against the *original* text, not the previous revision, so the
1081
1197
  // diff the user sees is always "current file -> proposed text".
1082
1198
  inlineDiff:
@@ -1095,10 +1211,19 @@ export async function iterate(first, params) {
1095
1211
 
1096
1212
  if (deps.revisions?.update) {
1097
1213
  const proj = project ?? (deps.projects?.get ? deps.projects.get(next.projectId) : null);
1098
- if (proj) return deps.revisions.update(proj, next.id, next);
1214
+ if (proj) {
1215
+ const current = typeof deps.revisions.get === 'function' ? await deps.revisions.get(proj, next.id) : revision;
1216
+ if (!current || current.deletedAt || current.status !== 'pending') throw new Error('这张修订在生成期间已经改变,请重新生成');
1217
+ if (endInfo?.hash && current.docHash !== endInfo.hash) {
1218
+ if (!deps.revisions.rebase) throw new Error('修订存储尚不支持安全重定位');
1219
+ await deps.revisions.rebase(proj, next.id, { fromHash: current.docHash, docHash: endInfo.hash, anchor });
1220
+ }
1221
+ return deps.revisions.update(proj, next.id, next);
1222
+ }
1099
1223
  }
1100
1224
  void docir;
1101
1225
  return next;
1226
+ });
1102
1227
  }
1103
1228
 
1104
1229
  export default { generate, fromChatReply, iterate, parseRewriteReply, resolveParentAgent, resolveChannel, resolveRoute, diffWords, RESPONSE_SCHEMA, SYSTEM_PROMPT };
@@ -0,0 +1,45 @@
1
+ /** Keep model rewrites within the punctuation boundary of the selected text.
2
+ * Only generated revision text uses this helper; manual replacements and
3
+ * ordinary chat/translation replies remain literal user/model content.
4
+ */
5
+ const STOPS = /[。!?;.!?;]/;
6
+ const CLOSE = new Set(Array.from('”’"\'」』》〉】〕〗]))]}'));
7
+ const TAIL_SPACE = /[\s\u200b-\u200d\u2060\ufeff]/;
8
+ const TAIL_SPACES = /[\s\u200b-\u200d\u2060\ufeff]*$/;
9
+
10
+ /** Inspect a final stop before optional closing quotes/brackets and whitespace. */
11
+ export function hasTrailingSentencePunctuation(text) {
12
+ let end = String(text || '').replace(TAIL_SPACES, '').length;
13
+ const value = String(text || '');
14
+ while (end > 0 && (CLOSE.has(value[end - 1]) || TAIL_SPACE.test(value[end - 1]))) end -= 1;
15
+ return end > 0 && STOPS.test(value[end - 1]);
16
+ }
17
+
18
+ /** Remove introduced terminal stops only; internal punctuation, decimal text,
19
+ * trailing whitespace and closing punctuation retain their original spelling.
20
+ */
21
+ export function normalizeRevisionBoundary(original, revised) {
22
+ if (typeof revised !== 'string' || hasTrailingSentencePunctuation(original)) return revised;
23
+ let value = revised;
24
+ while (value) {
25
+ const whitespace = TAIL_SPACES.exec(value)[0];
26
+ const body = value.slice(0, value.length - whitespace.length);
27
+ let end = body.length;
28
+ while (end > 0 && (CLOSE.has(body[end - 1]) || TAIL_SPACE.test(body[end - 1]))) end -= 1;
29
+ let start = end;
30
+ while (start > 0 && STOPS.test(body[start - 1])) start -= 1;
31
+ if (start === end) break;
32
+ value = body.slice(0, start) + body.slice(end) + whitespace;
33
+ }
34
+ return value;
35
+ }
36
+
37
+ export function revisionBoundaryPrompt(original) {
38
+ return hasTrailingSentencePunctuation(original)
39
+ ? '【选区末尾边界】只替换选区本身,保留选区外的文字和标点。原文已选入末尾标点,可按修改要求调整句式与标点。'
40
+ : '【选区末尾边界】原文没有选入末尾句末标点,revised 及每份 alternatives 的结尾不得新增。!?;或英文 .!?;。不要为了组成完整句子补标点,也不要在末尾引号或括号内补句末标点;内部标点可正常使用,保留成对引号括号。选区外原有的标点会保留。';
41
+ }
42
+
43
+ export function needsRevisionBoundary(action, instruction) {
44
+ return action === 'polish' || action === 'expand' || action === 'chat' || /(?:润色|扩写)/.test(instruction || '');
45
+ }