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/host/preview.js
CHANGED
|
@@ -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;
|
|
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`) && (
|
|
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`,
|
|
395
|
-
*
|
|
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 >
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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);
|
package/src/host/revise.js
CHANGED
|
@@ -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 {
|
|
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
|
|
821
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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)
|
|
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
|
|
924
|
-
|
|
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)
|
|
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
|
-
|
|
1105
|
+
let iterateInfo = null, iterateDoc = null;
|
|
1106
|
+
if (!abs && deps.projects?.resolve && revision.docPath) {
|
|
1035
1107
|
const pid = params.projectId ?? revision.projectId;
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
+
}
|