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.
@@ -109,7 +109,8 @@ __define('./reader', (module, exports, require) => {
109
109
  for (const item of items) {
110
110
  const expand = item.action === 'expand' || /^扩写/.test(item.instruction || '');
111
111
  const key = expand ? JSON.stringify([item.docHash, item.original, item.anchor?.kind,
112
- item.anchor?.blockId, item.anchor?.srcStart, item.anchor?.srcEnd, item.anchor?.sheet, item.anchor?.cell]) : item.id;
112
+ item.anchor?.blockId, item.anchor?.srcStart, item.anchor?.srcEnd, item.anchor?.sheet, item.anchor?.cell,
113
+ item.anchor?.segments?.map(segment => [segment.blockId, segment.srcStart, segment.srcEnd])]) : item.id;
113
114
  let group = byKey.get(key);
114
115
  if (!group) { group = { key, items: [] }; byKey.set(key, group); groups.push(group); }
115
116
  group.items.push(item);
@@ -120,14 +121,19 @@ __define('./reader', (module, exports, require) => {
120
121
 
121
122
  function comparisonChoices(group, currentHash) {
122
123
  if (group.items.some(item => item.status === 'accepted')) return [];
123
- return group.items.filter(item => item.status === 'pending' && !item.deletedAt
124
- && (!currentHash || !item.docHash || item.docHash === currentHash));
124
+ return group.items.filter(item => item.status === 'pending' && !item.deletedAt);
125
125
  }
126
126
 
127
127
  function captureRequest(value) {
128
128
  const request = JSON.parse(JSON.stringify(value));
129
- if (request.anchor) Object.freeze(request.anchor);
130
- return Object.freeze(request);
129
+ const freeze = item => {
130
+ if (item && typeof item === 'object') {
131
+ Object.values(item).forEach(freeze);
132
+ Object.freeze(item);
133
+ }
134
+ return item;
135
+ };
136
+ return freeze(request);
131
137
  }
132
138
 
133
139
  // =========================================================================
@@ -707,7 +713,7 @@ __define('./reader', (module, exports, require) => {
707
713
  }
708
714
 
709
715
  const LAYOUT_FORMATS = { docx: true, pptx: true, xlsx: true, pdf: true };
710
- const LAYOUT_ONLY_FORMATS = { docx: true, pptx: true, pdf: true };
716
+ const LAYOUT_ONLY_FORMATS = { doc: true, docx: true, pptx: true, pdf: true };
711
717
 
712
718
  function mediaUrl(route, projectId, path, extra) {
713
719
  const q = new URLSearchParams();
@@ -826,13 +832,39 @@ __define('./reader', (module, exports, require) => {
826
832
  return { picked, matched, srcStart: matched[0], srcEnd: matched[matched.length - 1] + 1 };
827
833
  }
828
834
 
835
+ function wordLineGeometry(line) {
836
+ const box = line && line.layoutBox;
837
+ return box && box.w > 0 ? { x: (line.x - box.x) / box.w, w: line.w / box.w }
838
+ : { x: Number(line && line.x), w: Number(line && line.w) };
839
+ }
840
+
841
+ /** Column membership supplies layout context only; source matching stays exact. */
842
+ function sameWordColumn(a, b) {
843
+ const first = wordLineGeometry(a), next = wordLineGeometry(b);
844
+ if (![first.x, first.w, next.x, next.w].every(Number.isFinite) || first.w <= 0 || next.w <= 0) return false;
845
+ const overlap = Math.min(first.x + first.w, next.x + next.w) - Math.max(first.x, next.x);
846
+ return Math.abs(first.x - next.x) <= .065 && overlap > Math.min(first.w, next.w) * .45;
847
+ }
848
+
849
+ function exactWordGlyphs(line) {
850
+ if (!line || line.ocr || line.precise !== true || !Array.isArray(line.glyphs) || !line.glyphs.length) return false;
851
+ let cursor = 0;
852
+ for (const glyph of line.glyphs) {
853
+ if (glyph.precise === false || !Number.isInteger(glyph.from) || !Number.isInteger(glyph.to)
854
+ || glyph.from !== cursor || glyph.to <= cursor || !Number.isFinite(glyph.x) || !Number.isFinite(glyph.w) || glyph.w < 0) return false;
855
+ cursor = glyph.to;
856
+ }
857
+ return cursor === String(line.text || '').length;
858
+ }
859
+
829
860
  function bindWordLayoutLines(blocks, lines) {
830
861
  const parts = sourceParts({ blocks });
831
862
  const source = parts.map((part) => part.text).join('\n');
832
863
  const index = wordTextIndex(source);
833
864
  const beginnings = parts.map(part => wordTextIndex(part.text).compact);
834
865
  const list = Array.isArray(lines) ? lines : [];
835
- const entries = list.map((line, order) => {
866
+ const entries = list.map((rawLine, order) => {
867
+ const { srcStart, srcEnd, srcMap, blockId, wordCell, wordParagraphs, wordPrefix, wordLayout, ...line } = rawLine || {};
836
868
  const text = String(line && line.text || '');
837
869
  let prefix = 0;
838
870
  let picked = wordTextIndex(text);
@@ -877,16 +909,56 @@ __define('./reader', (module, exports, require) => {
877
909
  window.forEach((entry, i) => { entry.at = placements[i]; });
878
910
  }
879
911
  }
912
+ // Multi-column reports interleave author details with body lines in PDF
913
+ // reading order. Match a unique, exact continuation in its own column,
914
+ // and require any already mapped neighbour to agree with the placement.
915
+ for (const entry of ordered) {
916
+ if (entry.at >= 0 || !exactWordGlyphs(entry.line)) continue;
917
+ const column = ordered.filter(other => exactWordGlyphs(other.line)
918
+ && Number(other.line.page || 1) === Number(entry.line.page || 1) && sameWordColumn(entry.line, other.line));
919
+ const center = column.indexOf(entry), placements = new Set();
920
+ for (let size = 2; size <= Math.min(8, column.length); size += 1) {
921
+ for (let from = Math.max(0, center - size + 1); from <= center && from + size <= column.length; from += 1) {
922
+ const window = column.slice(from, from + size);
923
+ if (!window.some(other => other !== entry && other.at >= 0)) continue;
924
+ const needle = window.map(other => other.picked.compact).join('');
925
+ if (needle.length > 2048) continue;
926
+ let offset = 0;
927
+ const anchored = new Set();
928
+ for (const other of window) {
929
+ if (other.at >= 0) anchored.add(other.at - offset);
930
+ offset += other.picked.compact.length;
931
+ }
932
+ if (anchored.size !== 1) continue;
933
+ const at = anchored.values().next().value;
934
+ if (at < 0 || index.compact.slice(at, at + needle.length) !== needle) continue;
935
+ let cursor = at, wanted = -1, consistent = true;
936
+ for (const other of window) {
937
+ if (other.at >= 0 && other.at !== cursor) consistent = false;
938
+ if (other === entry) wanted = cursor;
939
+ cursor += other.picked.compact.length;
940
+ }
941
+ if (consistent && !ordered.some(other => other !== entry && other.at >= 0
942
+ && other.at < wanted + entry.picked.compact.length && other.at + other.picked.compact.length > wanted)) placements.add(wanted);
943
+ }
944
+ }
945
+ if (placements.size === 1) entry.at = placements.values().next().value;
946
+ }
880
947
  }
881
948
  return entries.map(entry => {
882
949
  const { line, text, picked, prefix, at } = entry;
883
- if (at < 0) return { ...line, text };
950
+ if (at < 0) return { ...line, text, wordLayout: true };
884
951
  const matched = index.positions.slice(at, at + picked.compact.length);
885
952
  const srcStart = matched[0];
886
953
  const srcEnd = matched[matched.length - 1] + 1;
887
954
  const part = parts.find(item => srcStart >= item.start && srcStart < item.start + item.text.length);
888
- if (!part || srcEnd > part.start + part.text.length) return { ...line, text };
889
- const bound = { ...line, text, blockId: part.block.id, srcStart, srcEnd };
955
+ // PDF-to-Word documents can put a bullet and its text in two separate
956
+ // paragraphs while painting them as one PDF line. Native glyphs prove
957
+ // that exact combined span; OCR and table-cell crossings stay refused.
958
+ const crossed = parts.filter(item => item.start < srcEnd && item.start + item.text.length > srcStart);
959
+ if (!part || (srcEnd > part.start + part.text.length
960
+ && (!exactWordGlyphs(line) || crossed.some(item => item.block.type === 'table')))) return { ...line, text, wordLayout: true };
961
+ const bound = { ...line, text, wordLayout: true, blockId: part.block.id, srcStart, srcEnd };
890
962
  if (part.block.type === 'table') {
891
963
  for (let row = 0; row < (part.block.rows || []).length; row++) {
892
964
  const cells = part.block.rows[row].cells || [];
@@ -985,6 +1057,45 @@ __define('./reader', (module, exports, require) => {
985
1057
  return ranges;
986
1058
  }
987
1059
 
1060
+ function wordListMarker(text) {
1061
+ return /^[\s⚫•●▪◦‣⁃∙○◉◎■□◆◇◼◾⬤*\-]+$/u.test(String(text || ''))
1062
+ && /[⚫•●▪◦‣⁃∙○◉◎■□◆◇◼◾⬤*\-]/u.test(String(text || ''));
1063
+ }
1064
+
1065
+ /** Separate Word paragraphs remain exact source segments of one selection. */
1066
+ function wordCompoundAnchor(doc, parts) {
1067
+ const source = sourceParts(doc).map(part => part.text).join('\n');
1068
+ if (!Array.isArray(parts) || !parts.length || parts.some(part => !part?.anchor
1069
+ || !Number.isInteger(part.anchor.srcStart) || !Number.isInteger(part.anchor.srcEnd)
1070
+ || part.anchor.srcStart < 0 || part.anchor.srcEnd <= part.anchor.srcStart || part.anchor.srcEnd > source.length
1071
+ || source.slice(part.anchor.srcStart, part.anchor.srcEnd) !== part.text)) return null;
1072
+ if (parts.length === 1) return { ...parts[0].anchor };
1073
+ const segments = parts.map(part => ({ blockId: part.anchor.blockId, srcStart: part.anchor.srcStart,
1074
+ srcEnd: part.anchor.srcEnd, selectionProof: part.anchor.selectionProof || 'pdf-glyphs-v1' }));
1075
+ if (segments.some((segment, i) => i > 0 && segment.srcStart < segments[i - 1].srcEnd)) return null;
1076
+ const anchor = { kind: 'range', blockId: segments[0].blockId, segments };
1077
+ const from = segments[0].srcStart, to = segments.at(-1).srcEnd;
1078
+ if (segments.every((segment, i) => !i || /^\s*$/.test(source.slice(segments[i - 1].srcEnd, segment.srcStart)))) {
1079
+ anchor.srcStart = from; anchor.srcEnd = to;
1080
+ }
1081
+ return anchor;
1082
+ }
1083
+
1084
+ function wordAnchorText(doc, anchor) {
1085
+ const source = sourceParts(doc).map(part => part.text).join('\n');
1086
+ const segments = Array.isArray(anchor?.segments) ? anchor.segments : [anchor];
1087
+ if (!segments.length || segments.some(segment => !Number.isInteger(segment?.srcStart) || !Number.isInteger(segment?.srcEnd)
1088
+ || segment.srcStart < 0 || segment.srcEnd <= segment.srcStart || segment.srcEnd > source.length)) return undefined;
1089
+ return segments.map(segment => source.slice(segment.srcStart, segment.srcEnd)).join('\n');
1090
+ }
1091
+
1092
+ function continuousWordAnchor(doc, anchor) {
1093
+ const source = sourceParts(doc).map(part => part.text).join('\n');
1094
+ const segments = Array.isArray(anchor?.segments) ? anchor.segments : [anchor];
1095
+ return wordAnchorText(doc, anchor) !== undefined && segments.every((segment, i) => !i
1096
+ || (segment.srcStart >= segments[i - 1].srcEnd && /^\s*$/.test(source.slice(segments[i - 1].srcEnd, segment.srcStart))));
1097
+ }
1098
+
988
1099
  /** The physical highlight, quote and rewrite all use this same exact source. */
989
1100
  function wordNativeSelection(doc, lines) {
990
1101
  const selected = sortReadingOrder(lines);
@@ -998,8 +1109,9 @@ __define('./reader', (module, exports, require) => {
998
1109
  if (!original.trim() || wordTextIndex(original).compact !== wordTextIndex(picked).compact) return fallback;
999
1110
  const parts = wordRanges(doc, from, to);
1000
1111
  if (!parts.length) return fallback;
1001
- return { anchor: parts.length === 1 ? parts[0].anchor : { kind: 'range', blockId: parts[0].blockId },
1002
- text: original, blockId: parts[0].blockId, parts, verified: true };
1112
+ const anchor = wordCompoundAnchor(doc, parts);
1113
+ if (!anchor) return fallback;
1114
+ return { anchor, text: wordAnchorText(doc, anchor), blockId: parts[0].blockId, parts, verified: true };
1003
1115
  }
1004
1116
 
1005
1117
  /** A rectangle can select separate source slices; verify each before merging only whitespace gaps. */
@@ -1020,8 +1132,9 @@ __define('./reader', (module, exports, require) => {
1020
1132
  }
1021
1133
  if (!groups.length) return null;
1022
1134
  const parts = groups.flatMap(group => wordRanges(doc, group.from, group.to));
1023
- return { anchor: parts.length === 1 ? parts[0].anchor : { kind: 'range', blockId: parts[0].blockId },
1024
- text: groups.map(group => source.slice(group.from, group.to)).join('\n'), parts, blockId: parts[0].blockId, verified: true };
1135
+ const anchor = wordCompoundAnchor(doc, parts);
1136
+ if (!anchor) return { anchor: { kind: 'range' }, text: joinOcrText(lines), wordUnmapped: true };
1137
+ return { anchor, text: wordAnchorText(doc, anchor), parts, blockId: parts[0].blockId, verified: true };
1025
1138
  }
1026
1139
 
1027
1140
  function uniqueAt(haystack, needle) {
@@ -1491,12 +1604,26 @@ __define('./reader', (module, exports, require) => {
1491
1604
  * @param {{ x: number, y: number }} b
1492
1605
  */
1493
1606
  function selectionSpans(lines, a, b) {
1494
- const list = sortReadingOrder((Array.isArray(lines) ? lines : []).filter((line) => (
1607
+ let list = sortReadingOrder((Array.isArray(lines) ? lines : []).filter((line) => (
1495
1608
  line && (line.ocr === true || line.nativeSelection) && String(line.text || '').trim()
1496
1609
  )));
1497
1610
  if (!a || !b || list.length === 0) return [];
1498
- const ia = indexAtPoint(list, Number(a.x) || 0, Number(a.y) || 0);
1499
- const ib = indexAtPoint(list, Number(b.x) || 0, Number(b.y) || 0);
1611
+ let ia = indexAtPoint(list, Number(a.x) || 0, Number(a.y) || 0);
1612
+ let ib = indexAtPoint(list, Number(b.x) || 0, Number(b.y) || 0);
1613
+ const first = list[ia >= 0 ? ia : ib], last = list[ib >= 0 ? ib : ia];
1614
+ if (first && first.wordLayout && !first.wordCell && (!last || sameWordColumn(first, last))) {
1615
+ const edge = wordLineGeometry(first);
1616
+ // Restrict an ordinary drag inside a clearly separate Word column. A
1617
+ // rectangle remains available for an intentional multi-column selection.
1618
+ const hasOtherColumn = list.some(line => line.wordLayout && Number(line.page || 1) === Number(first.page || 1)
1619
+ && Math.abs(wordLineGeometry(line).x - edge.x) > .18
1620
+ && (wordLineGeometry(line).x >= edge.x + edge.w || wordLineGeometry(line).x + wordLineGeometry(line).w <= edge.x));
1621
+ if (hasOtherColumn) {
1622
+ list = list.filter(line => sameWordColumn(first, line));
1623
+ ia = indexAtPoint(list, Number(a.x) || 0, Number(a.y) || 0);
1624
+ ib = indexAtPoint(list, Number(b.x) || 0, Number(b.y) || 0);
1625
+ }
1626
+ }
1500
1627
  if (ia < 0 && ib < 0) {
1501
1628
  // 指针停在行外时,仍按拖过的矩形把跨到的行切开,避免一松手选区被清掉。
1502
1629
  const hits = hitsInRect(list, { x0: a.x, y0: a.y, x1: b.x, y1: b.y });
@@ -1995,7 +2122,7 @@ __define('./reader', (module, exports, require) => {
1995
2122
  function revisionAllowed(doc) {
1996
2123
  if (!doc || typeof doc !== 'object') return true;
1997
2124
  const format = typeof doc.format === 'string' ? doc.format : '';
1998
- if (format === 'pdf' || format === 'image') return false;
2125
+ if (format === 'pdf' || format === 'image' || format === 'doc') return false;
1999
2126
  return !(doc.meta && doc.meta.writable === false);
2000
2127
  }
2001
2128
 
@@ -2057,6 +2184,18 @@ __define('./reader', (module, exports, require) => {
2057
2184
  return `dshDoc:scroll:${projectId}:${path}`;
2058
2185
  }
2059
2186
 
2187
+ function slideStorageKey(projectId, path) {
2188
+ return `dshDoc:slide:${projectId}:${path}`;
2189
+ }
2190
+
2191
+ function storedSlide(projectId, path) {
2192
+ try {
2193
+ const value = Number(currentWindow()?.localStorage?.getItem(slideStorageKey(projectId, path)));
2194
+ if (Number.isSafeInteger(value) && value >= 1) return value;
2195
+ } catch { /* A private browser still opens the first page. */ }
2196
+ return 1;
2197
+ }
2198
+
2060
2199
  function isTypingTarget(target) {
2061
2200
  if (!target || typeof target.tagName !== 'string') return false;
2062
2201
  const tag = target.tagName;
@@ -2073,7 +2212,9 @@ __define('./reader', (module, exports, require) => {
2073
2212
  const [findOpen, setFindOpen] = React.useState(false);
2074
2213
  const [findQ, setFindQ] = React.useState('');
2075
2214
  const [findAt, setFindAt] = React.useState(0);
2076
- const [slide, setSlide] = React.useState(1);
2215
+ const [slide, setSlide] = React.useState(() => storedSlide(p.projectId, p.path));
2216
+ const [slideInput, setSlideInput] = React.useState(() => String(storedSlide(p.projectId, p.path)));
2217
+ const [slideJumpError, setSlideJumpError] = React.useState('');
2077
2218
  const [slideCount, setSlideCount] = React.useState(0);
2078
2219
  const [slideReady, setSlideReady] = React.useState(false);
2079
2220
  const [slideError, setSlideError] = React.useState('');
@@ -2082,6 +2223,7 @@ __define('./reader', (module, exports, require) => {
2082
2223
  const [pageFailed, setPageFailed] = React.useState(false);
2083
2224
  const [readyPages, setReadyPages] = React.useState({});
2084
2225
  const [ocrByPage, setOcrByPage] = React.useState({});
2226
+ const [pageTextNotice, setPageTextNotice] = React.useState(null);
2085
2227
  const [rasterPages, setRasterPages] = React.useState(0);
2086
2228
  const [rasterAspect, setRasterAspect] = React.useState(0);
2087
2229
  const [ocrOn, setOcrOn] = React.useState(null);
@@ -2093,6 +2235,7 @@ __define('./reader', (module, exports, require) => {
2093
2235
  const bodyRef = React.useRef(null);
2094
2236
  const wrapRef = React.useRef(null);
2095
2237
  const slideCountRef = React.useRef(0);
2238
+ const slideStorageRef = React.useRef({ target: '', ready: false });
2096
2239
  const missTick = React.useRef(0);
2097
2240
  const gestureRef = React.useRef(null);
2098
2241
  const selectionScrollRef = React.useRef(null);
@@ -2111,7 +2254,9 @@ __define('./reader', (module, exports, require) => {
2111
2254
  const actions = React.useMemo(() => (
2112
2255
  canRevise ? baseActions : baseActions.filter((action) => !action || action.kind !== 'revise')
2113
2256
  ), [baseActions, canRevise]);
2114
- const format = doc && typeof doc.format === 'string' ? doc.format : '';
2257
+ const sourceFormat = doc && typeof doc.format === 'string' ? doc.format : '';
2258
+ const format = sourceFormat === 'doc' ? 'docx' : sourceFormat;
2259
+ const visionModelKey = JSON.stringify(p.visionRoute || {});
2115
2260
  const canLayout = Boolean(p.projectId && p.path && LAYOUT_FORMATS[format]);
2116
2261
  // Word / PPT / PDF 只显示版式,旧的文本偏好也不能把它们带回抽取正文。
2117
2262
  const view = (LAYOUT_ONLY_FORMATS[format] && canLayout) || (p.view === 'layout' && canLayout) ? 'layout' : 'text';
@@ -2124,9 +2269,8 @@ __define('./reader', (module, exports, require) => {
2124
2269
  kind: 'pick',
2125
2270
  title: '列出这一页的文本框,选一段润色、扩写,或自己改字',
2126
2271
  }])
2127
- : actions).map(action => action.kind === 'revise' && (p.busyAction === 'polish' || p.busyAction === 'expand')
2128
- ? { ...action, disabled: true } : action)
2129
- ), [actions, format, canRevise, showLayout, p.busyAction]);
2272
+ : actions)
2273
+ ), [actions, format, canRevise, showLayout]);
2130
2274
  const showImage = format === 'image' && !showLayout;
2131
2275
  const reason = doc && doc.meta && typeof doc.meta.reason === 'string' ? doc.meta.reason : '';
2132
2276
  const previewRev = Number(p.previewEpoch) || 0;
@@ -2149,6 +2293,9 @@ __define('./reader', (module, exports, require) => {
2149
2293
  }
2150
2294
  function docMedia(route, extra) {
2151
2295
  const query = extra ? { ...extra } : {};
2296
+ if (route === 'page-text' && query.page && p.visionRoute?.provider && p.visionRoute?.model) {
2297
+ query.provider = p.visionRoute.provider; query.model = p.visionRoute.model;
2298
+ }
2152
2299
  // 旧地址没有 v,浏览器会把改写前的页图缓存五分钟。每次接受都换一个 v。
2153
2300
  query.v = previewRev;
2154
2301
  return mediaUrl(route, p.projectId, p.path, query);
@@ -2161,10 +2308,15 @@ __define('./reader', (module, exports, require) => {
2161
2308
  setFindOpen(false);
2162
2309
  setFindQ('');
2163
2310
  setFindAt(0);
2164
- setSlide(1);
2311
+ const restoredSlide = storedSlide(p.projectId, p.path);
2312
+ slideStorageRef.current = { target: `${p.projectId}\u0000${p.path}`, ready: false };
2313
+ setSlide(restoredSlide);
2314
+ setSlideInput(String(restoredSlide));
2315
+ setSlideJumpError('');
2165
2316
  setSlideCount(0);
2166
2317
  setSlideReady(false);
2167
2318
  setSlideError('');
2319
+ setPageTextNotice(null);
2168
2320
  setSlideRatio(16 / 9);
2169
2321
  setPageModel(null);
2170
2322
  setPageFailed(false);
@@ -2216,23 +2368,30 @@ __define('./reader', (module, exports, require) => {
2216
2368
  fetch(docMedia('pages'), { signal: ac.signal })
2217
2369
  .then((res) => res.json())
2218
2370
  .then((body) => {
2371
+ if (ac.signal.aborted) return;
2219
2372
  if (!body || body.ok === false) {
2220
2373
  setSlideError((body && body.error) || '无法读取幻灯片页数');
2221
2374
  return;
2222
2375
  }
2223
2376
  const pages = Number(body.pages);
2377
+ const count = Number.isInteger(pages) && pages > 0 ? pages : 1;
2224
2378
  setSlideError('');
2225
- setSlideCount(Number.isInteger(pages) && pages > 0 ? pages : 1);
2379
+ slideStorageRef.current = { target: `${p.projectId}\u0000${p.path}`, ready: true };
2380
+ setSlideCount(count);
2381
+ setSlide(value => Math.min(count, Math.max(1, value)));
2226
2382
  })
2227
2383
  .catch((error) => {
2228
- if (error && error.name === 'AbortError') return;
2384
+ if (ac.signal.aborted || error && error.name === 'AbortError') return;
2229
2385
  setSlideError('无法读取幻灯片页数');
2230
2386
  });
2231
2387
  return () => ac.abort();
2232
- }, [format, showLayout, p.projectId, p.path]);
2388
+ }, [format, showLayout, p.projectId, p.path, previewRev]);
2233
2389
 
2234
2390
  React.useEffect(() => {
2391
+ setSlideInput(String(slide));
2392
+ setSlideJumpError('');
2235
2393
  setSlideReady(false);
2394
+ setSlideError('');
2236
2395
  setSelection(null);
2237
2396
  setOcrOn(null);
2238
2397
  setBoxPick(null);
@@ -2241,8 +2400,16 @@ __define('./reader', (module, exports, require) => {
2241
2400
  }, [slide, p.projectId, p.path]);
2242
2401
 
2243
2402
  React.useEffect(() => {
2244
- // 版式预览(含幻灯片)一直靠这段文字。PDF 切到文本页时也要,正文块是空的。
2245
- const want = Boolean(p.projectId && p.path && (showLayout || format === 'pdf'));
2403
+ if (format !== 'pptx' || !slideCount || slide < 1 || slide > slideCount
2404
+ || !slideStorageRef.current.ready || slideStorageRef.current.target !== `${p.projectId}\u0000${p.path}`) return;
2405
+ try { currentWindow()?.localStorage?.setItem(slideStorageKey(p.projectId, p.path), String(slide)); }
2406
+ catch { /* Navigation does not depend on local persistence. */ }
2407
+ if (typeof p.onLayoutPage === 'function') p.onLayoutPage(slide);
2408
+ }, [format, p.projectId, p.path, slide, slideCount]);
2409
+
2410
+ React.useEffect(() => {
2411
+ // 幻灯片逐页读取;打开图片型 PPTX 不预先转换整册。
2412
+ const want = Boolean(format !== 'pptx' && p.projectId && p.path && (showLayout || format === 'pdf'));
2246
2413
  if (!want) return undefined;
2247
2414
  const ac = new AbortController();
2248
2415
  fetch(docMedia('page-text'), { signal: ac.signal })
@@ -2291,7 +2458,7 @@ __define('./reader', (module, exports, require) => {
2291
2458
  if (!nativePages.has(i)) queue.push(i);
2292
2459
  }
2293
2460
  }
2294
- const tokens = queue.map((page) => `${p.projectId}:${p.path}:${page}:${previewRev}`);
2461
+ const tokens = queue.map((page) => `${p.projectId}:${p.path}:${page}:${previewRev}:${visionModelKey}`);
2295
2462
  (async () => {
2296
2463
  for (let i = 0; i < queue.length; i += 1) {
2297
2464
  if (cancelled) return;
@@ -2299,23 +2466,32 @@ __define('./reader', (module, exports, require) => {
2299
2466
  const token = tokens[i];
2300
2467
  if (ocrTried.current[token]) continue;
2301
2468
  ocrTried.current[token] = 'pending';
2469
+ if (format === 'pptx') setPageTextNotice({ page, pending: true, text: '正在识别本页文字,完成后即可选中…' });
2302
2470
  try {
2303
2471
  const res = await fetch(docMedia('page-text', { page }), { signal: ac.signal });
2304
2472
  const body = await res.json();
2305
2473
  if (cancelled) return;
2306
2474
  ocrTried.current[token] = 'done';
2307
- if (!body || body.ok === false) continue;
2475
+ if (!body || body.ok === false) {
2476
+ if (format === 'pptx') setPageTextNotice({ page, text: body?.error || '本页文字读取失败,请刷新后重试。' });
2477
+ continue;
2478
+ }
2479
+ if (format === 'pptx') setPageTextNotice(body.lines?.length
2480
+ ? (body.ocr?.warning ? { page, text: body.ocr.warning + (body.ocr.visionError ? ` ${body.ocr.visionError}` : '') } : null)
2481
+ : { page, text: body.ocr?.visionError || body.ocr?.error || '本页未识别到文字;可选用支持图片的模型进行文档提问。' });
2308
2482
  const pages = Number(body.pages);
2309
2483
  const aspect = Number(body.aspect);
2310
2484
  // 第一页可能全是图片;页数仍用于继续读取后面的文字页。
2311
2485
  if (pages > 0) setRasterPages((prev) => (pages > prev ? pages : prev));
2312
2486
  if (aspect > 0) setRasterAspect((prev) => (prev > 0 ? prev : aspect));
2313
2487
  if (!Array.isArray(body.lines) || body.lines.length === 0) continue;
2314
- if (format === 'docx' && body.source === 'pdf') {
2488
+ if (body.source === 'pdf' || body.source === 'mixed') {
2315
2489
  setPageModel((prev) => ({
2316
2490
  pages: Math.max(pages || 1, prev && prev.pages || 1),
2317
2491
  aspect: aspect > 0 ? aspect : (prev && prev.aspect || 0.772),
2318
- lines: (prev ? prev.lines.filter((line) => Number(line.page) !== page) : []).concat(body.lines),
2492
+ lines: (prev ? prev.lines.filter((line) => Number(line.page) !== page) : []).concat(body.lines.map(line => ({ ...line, page,
2493
+ ...(line.ocr !== true && line.precise && Array.isArray(line.glyphs) ? { nativeSelection: true } : {}),
2494
+ }))),
2319
2495
  }));
2320
2496
  continue;
2321
2497
  }
@@ -2326,7 +2502,10 @@ __define('./reader', (module, exports, require) => {
2326
2502
  }));
2327
2503
  } catch (error) {
2328
2504
  if (error && error.name === 'AbortError') return;
2329
- if (!cancelled) ocrTried.current[token] = 'done';
2505
+ if (!cancelled) {
2506
+ ocrTried.current[token] = 'done';
2507
+ if (format === 'pptx') setPageTextNotice({ page, text: normalizeError(error) });
2508
+ }
2330
2509
  }
2331
2510
  }
2332
2511
  })();
@@ -2337,7 +2516,7 @@ __define('./reader', (module, exports, require) => {
2337
2516
  if (ocrTried.current[tokens[i]] === 'pending') delete ocrTried.current[tokens[i]];
2338
2517
  }
2339
2518
  };
2340
- }, [format, showLayout, slide, pageModel, pageFailed, rasterPages, p.projectId, p.path, previewRev]);
2519
+ }, [format, showLayout, slide, pageModel, pageFailed, rasterPages, p.projectId, p.path, previewRev, visionModelKey]);
2341
2520
 
2342
2521
  const boundLines = React.useMemo(
2343
2522
  () => (format === 'docx' ? bindWordLayoutLines : bindLayoutLines)(blocks, pageModel && pageModel.lines),
@@ -2781,14 +2960,15 @@ __define('./reader', (module, exports, require) => {
2781
2960
  if (target && typeof target.closest === 'function' && target.closest('button')) return;
2782
2961
  if (startPageGesture(event, event.currentTarget.closest?.('[data-reader-pages]'))) return;
2783
2962
  const native = format === 'docx' && (lines || []).some(line => line.precise && Array.isArray(line.glyphs));
2784
- if (!native && target && typeof target.closest === 'function') {
2963
+ const mixedSlide = format === 'pptx' && (lines || []).some(line => line.nativeSelection);
2964
+ if (!native && !mixedSlide && target && typeof target.closest === 'function') {
2785
2965
  const layer = target.closest('[data-text-layer]');
2786
2966
  if (layer && layer.getAttribute('data-ocr') !== '1') {
2787
2967
  ocrHold.current = false;
2788
2968
  return;
2789
2969
  }
2790
2970
  }
2791
- let ocrLines = (lines || []).filter(line => line && (native ? line.precise && Array.isArray(line.glyphs) : line.ocr === true))
2971
+ let ocrLines = (lines || []).filter(line => line && (native ? line.precise && Array.isArray(line.glyphs) : (line.ocr === true || line.nativeSelection)))
2792
2972
  .map(line => native ? { ...line, nativeSelection: true } : line);
2793
2973
  if (ocrLines.length === 0) return;
2794
2974
  const plane = event.currentTarget;
@@ -3033,6 +3213,18 @@ __define('./reader', (module, exports, require) => {
3033
3213
  });
3034
3214
  };
3035
3215
 
3216
+ const jumpSlide = () => {
3217
+ const value = slideInput.trim(), number = Number(value), max = slideCountRef.current;
3218
+ if (!max) { setSlideJumpError('页数尚未读取完成,请稍后重试。'); return; }
3219
+ if (!/^\d+$/.test(value) || !Number.isSafeInteger(number) || number < 1 || number > max) {
3220
+ setSlideJumpError(`请输入 1–${max} 之间的整数页码。`);
3221
+ return;
3222
+ }
3223
+ setSlideJumpError('');
3224
+ setSlideInput(String(number));
3225
+ setSlide(number);
3226
+ };
3227
+
3036
3228
  const slideMode = showLayout && format === 'pptx';
3037
3229
  const pdfLineCount = pageModel && Array.isArray(pageModel.lines) ? pageModel.lines.length : 0;
3038
3230
  const ocrLineCount = Object.keys(ocrBound).reduce((sum, key) => sum + (Array.isArray(ocrBound[key]) ? ocrBound[key].length : 0), 0);
@@ -3071,7 +3263,7 @@ __define('./reader', (module, exports, require) => {
3071
3263
  return;
3072
3264
  }
3073
3265
  const target = event.target;
3074
- if (target && typeof target.closest === 'function' && (target.closest('button') || target.closest('[data-text-layer]'))) return;
3266
+ if (target && typeof target.closest === 'function' && (target.closest('button,input,select,textarea') || target.closest('[data-text-layer]'))) return;
3075
3267
  const selected = currentWindow();
3076
3268
  const live = selected && typeof selected.getSelection === 'function' ? selected.getSelection() : null;
3077
3269
  if (live && !live.isCollapsed) return;
@@ -3156,7 +3348,25 @@ __define('./reader', (module, exports, require) => {
3156
3348
  disabled: slideCount === 0 || slide >= slideCount,
3157
3349
  onClick: () => turnSlide(1),
3158
3350
  }, '›'),
3159
- h('div', { key: 'count', className: P + 'SlideCount' }, slideCount ? `${slide} / ${slideCount}` : '…'),
3351
+ h('div', { key: 'count', className: P + 'SlideCount', 'data-slide-navigation': '1' }, [
3352
+ h('div', { key: 'controls', className: P + 'SlideCountControls' }, [
3353
+ h('input', {
3354
+ key: 'page', type: 'number', inputMode: 'numeric', min: 1, max: slideCount || undefined, step: 1,
3355
+ value: slideInput, disabled: !slideCount, className: P + 'SlidePageInput',
3356
+ 'aria-label': '跳转页码', 'aria-invalid': slideJumpError ? 'true' : undefined, 'data-slide-page-input': '1',
3357
+ onChange: event => { setSlideInput(event.target.value); setSlideJumpError(''); },
3358
+ onKeyDown: event => {
3359
+ if (event.key === 'Enter') { event.preventDefault(); event.stopPropagation(); jumpSlide(); }
3360
+ else if (event.key === 'Escape') { setSlideInput(String(slide)); setSlideJumpError(''); }
3361
+ },
3362
+ }),
3363
+ h('span', { key: 'total', 'data-slide-total': '1' }, slideCount ? `/ ${slideCount}` : '/ …'),
3364
+ h('button', { key: 'jump', type: 'button', className: P + 'SlideJumpButton', disabled: !slideCount,
3365
+ 'aria-label': '跳转到指定页', 'data-slide-jump': '1', onClick: jumpSlide }, '跳转'),
3366
+ ]),
3367
+ slideJumpError ? h('div', { key: 'error', className: P + 'SlideJumpError', role: 'alert',
3368
+ 'data-slide-jump-error': '1' }, slideJumpError) : null,
3369
+ ]),
3160
3370
  ]) : null;
3161
3371
 
3162
3372
  const shownPages = usePages ? Math.min(pageCount, 80) : 0;
@@ -3214,6 +3424,12 @@ __define('./reader', (module, exports, require) => {
3214
3424
 
3215
3425
  return h('div', { className: P + 'Wrap', ref: wrapRef, 'data-reader-wrap': '1', 'data-reader-view': view }, [
3216
3426
  viewBar,
3427
+ sourceFormat === 'doc' ? h('div', { key: 'legacy-word', className: P + 'SelectionNotice', role: 'status', 'data-legacy-word-notice': '1' }, [
3428
+ p.convertingWord ? h(Processing, { key: 'progress', text: '正在转换为可修订的 Word 文档…' })
3429
+ : h('span', { key: 'text' }, '旧版 DOC 可以阅读和提问;转换为 DOCX 副本后即可润色、扩写和接受修订。'),
3430
+ h('button', { key: 'convert', type: 'button', className: P + 'Btn', 'data-convert-word': '1',
3431
+ disabled: p.convertingWord || p.conversionDisabled, onClick: p.onConvertWord }, '转换并编辑'),
3432
+ ]) : null,
3217
3433
  p.officeNotice ? h('div', { key: 'office', className: P + 'SelectionNotice', role: 'alert', 'data-office-notice': '1' }, [
3218
3434
  h('span', { key: 'message' }, p.officeNotice.message),
3219
3435
  p.officeNotice.helpUrl ? h('a', { key: 'help', href: '/doc-assistant/office/help', target: '_blank', rel: 'noopener noreferrer' }, ' 查看安装指引') : null,
@@ -3224,6 +3440,9 @@ __define('./reader', (module, exports, require) => {
3224
3440
  showLayout && p.selectionNotice
3225
3441
  ? h('div', { key: 'selection-notice', className: P + 'SelectionNotice', role: 'status', 'data-layout-selection-notice': '1' }, p.selectionNotice)
3226
3442
  : null,
3443
+ showLayout && format === 'pptx' && pageTextNotice?.page === slide
3444
+ ? h('div', { key: 'page-text-notice', className: P + 'SelectionNotice', role: 'status', 'data-page-text-status': '1' },
3445
+ pageTextNotice.pending ? h(Processing, { text: pageTextNotice.text }) : pageTextNotice.text) : null,
3227
3446
  findBar,
3228
3447
  h('main', {
3229
3448
  key: 'main',
@@ -3500,6 +3719,7 @@ __define('./reader', (module, exports, require) => {
3500
3719
  const [rightOpen, setRightOpen] = React.useState(true);
3501
3720
  const [status, setStatus] = React.useState('');
3502
3721
  const [busyAction, setBusyAction] = React.useState('');
3722
+ const [acceptingRevisionId, setAcceptingRevisionId] = React.useState('');
3503
3723
  const [revisionTick, setRevisionTick] = React.useState(0);
3504
3724
  // 接受写回后幻灯片图的地址要变,否则浏览器会继续显示缓存的旧页。
3505
3725
  const [previewEpoch, setPreviewEpoch] = React.useState(0);
@@ -3512,6 +3732,8 @@ __define('./reader', (module, exports, require) => {
3512
3732
  const readerStateRef = React.useRef(null);
3513
3733
  const reloadDocumentRef = React.useRef(null);
3514
3734
  const [officeBusy, setOfficeBusy] = React.useState(false);
3735
+ const [legacyConverting, setLegacyConverting] = React.useState(false);
3736
+ const legacyConvertRef = React.useRef(null);
3515
3737
  const officeLaunchRef = React.useRef('');
3516
3738
  const [officeNotice, setOfficeNotice] = React.useState(null);
3517
3739
  const [booted, setBooted] = React.useState(false);
@@ -3533,16 +3755,26 @@ __define('./reader', (module, exports, require) => {
3533
3755
  const [readerView, setReaderView] = React.useState('text');
3534
3756
  const [selectionNotice, setSelectionNotice] = React.useState('');
3535
3757
  const [noChangeNotice, setNoChangeNotice] = React.useState('');
3536
- const [generationError, setGenerationError] = React.useState(null);
3537
- const generationRef = React.useRef(null);
3758
+ // Every request owns its immutable selection/model snapshot. Updating a job
3759
+ // replaces the Map so independent completions cannot overwrite each other.
3760
+ const [generationJobs, setGenerationJobs] = React.useState(() => new Map());
3761
+ const generationJobsRef = React.useRef(new Map());
3762
+ const generationSequenceRef = React.useRef(0);
3763
+ const generationAliveRef = React.useRef(true);
3764
+ const writeBusyRef = React.useRef(false);
3765
+ const [revisionErrors, setRevisionErrors] = React.useState(() => new Map());
3538
3766
  const generationTargetRef = React.useRef('');
3539
3767
  generationTargetRef.current = `${projectId}\u0000${docPath}`;
3540
- React.useEffect(() => () => { generationRef.current = null; }, []);
3768
+ const updateGenerationJobs = React.useCallback(update => {
3769
+ const next = update(generationJobsRef.current);
3770
+ generationJobsRef.current = next;
3771
+ if (generationAliveRef.current) setGenerationJobs(next);
3772
+ }, []);
3541
3773
  React.useEffect(() => {
3542
- setGenerationError(null);
3543
- setNoChangeNotice('');
3544
- if (generationRef.current && generationRef.current.target !== generationTargetRef.current) setBusyAction('');
3545
- }, [projectId, docPath]);
3774
+ generationAliveRef.current = true;
3775
+ return () => { generationAliveRef.current = false; };
3776
+ }, []);
3777
+ React.useEffect(() => { setNoChangeNotice(''); }, [projectId, docPath]);
3546
3778
  const [layoutPage, setLayoutPage] = React.useState(0);
3547
3779
  const [layoutFocus, setLayoutFocus] = React.useState(null);
3548
3780
  const [readPct, setReadPct] = React.useState(0);
@@ -3563,6 +3795,8 @@ __define('./reader', (module, exports, require) => {
3563
3795
  const [messages, setMessages] = React.useState([]);
3564
3796
  const [chatBusy, setChatBusy] = React.useState(false);
3565
3797
  const [chatKind, setChatKind] = React.useState('chat');
3798
+ const [chatProgress, setChatProgress] = React.useState('');
3799
+ const chatStartedRef = React.useRef(0);
3566
3800
  const [chatError, setChatError] = React.useState('');
3567
3801
  const [chatModels, setChatModels] = React.useState([]);
3568
3802
  const [chatDefaultModel, setChatDefaultModel] = React.useState(null);
@@ -3605,7 +3839,7 @@ __define('./reader', (module, exports, require) => {
3605
3839
  // Word / PPT / PDF 打开即版式;Excel 打开即表格文本,版式按需再转。
3606
3840
  React.useEffect(() => {
3607
3841
  const fmt = doc && doc.format;
3608
- if (fmt === 'docx' || fmt === 'pptx' || fmt === 'pdf') setReaderView('layout');
3842
+ if (fmt === 'doc' || fmt === 'docx' || fmt === 'pptx' || fmt === 'pdf') setReaderView('layout');
3609
3843
  else setReaderView('text');
3610
3844
  setLayoutPage(0);
3611
3845
  setLayoutFocus(null);
@@ -3738,7 +3972,7 @@ __define('./reader', (module, exports, require) => {
3738
3972
  uploadBusyRef.current = true;
3739
3973
  setUploadOutcome(null);
3740
3974
  setStatus(`正在导入 ${list.length} 个文件…`);
3741
- let done = 0;
3975
+ let done = 0, converted = 0;
3742
3976
  let currentFile = '';
3743
3977
  try {
3744
3978
  for (const file of list) {
@@ -3747,11 +3981,12 @@ __define('./reader', (module, exports, require) => {
3747
3981
  setUploadProgress({ ...progress, stage: '正在读取文件…' });
3748
3982
  const dataBase64 = await shelfModule.readFileBase64(file);
3749
3983
  // The response also waits for best-effort cover preparation.
3750
- setUploadProgress({ ...progress, stage: '正在保存文档并准备封面,请稍候…' });
3751
- await transport.uploadDocs(targetProject, '', [{ name: file.name, dataBase64 }]);
3984
+ setUploadProgress({ ...progress, stage: /\.doc$/i.test(file.name) ? '正在转换为可修订的 Word 文档并准备封面…' : '正在保存文档并准备封面,请稍候…' });
3985
+ const imported = await transport.uploadDocs(targetProject, '', [{ name: file.name, dataBase64 }]);
3986
+ converted += (Array.isArray(imported) ? imported : imported?.entries || []).filter(entry => entry.convertedFrom === 'doc').length;
3752
3987
  done += 1;
3753
3988
  }
3754
- const message = `已导入 ${done} 个文件`;
3989
+ const message = `已导入 ${done} 个文件${converted ? `,其中 ${converted} 个 DOC 已转换为可修订的 DOCX` : ''}`;
3755
3990
  setStatus(message);
3756
3991
  setUploadOutcome({ message, error: false });
3757
3992
  } catch (error) {
@@ -3847,6 +4082,21 @@ __define('./reader', (module, exports, require) => {
3847
4082
  if (draftReference && rightOpen && rightTab === 'chat' && !chatBusy && chatInputRef.current) chatInputRef.current.focus();
3848
4083
  }, [draftReference, rightOpen, rightTab, chatBusy]);
3849
4084
 
4085
+ React.useEffect(() => {
4086
+ if (!chatBusy || !projectId || !docPath || typeof transport.contextStatus !== 'function' || !chatStartedRef.current) return undefined;
4087
+ let alive = true, timer;
4088
+ const started = chatStartedRef.current;
4089
+ const poll = async () => {
4090
+ try {
4091
+ const state = await transport.contextStatus(projectId, docPath);
4092
+ if (alive && Number(state?.at) >= started && ['reading', 'vision', 'ocr', 'ready'].includes(state.stage)) setChatProgress(state.message || '');
4093
+ } catch { /* The original chat request reports any real failure. */ }
4094
+ if (alive) timer = setTimeout(poll, 1000);
4095
+ };
4096
+ poll();
4097
+ return () => { alive = false; clearTimeout(timer); };
4098
+ }, [chatBusy, projectId, docPath, transport]);
4099
+
3850
4100
  // ---- 新消息滚到底
3851
4101
  React.useEffect(() => {
3852
4102
  const el = chatListRef.current;
@@ -3880,6 +4130,8 @@ __define('./reader', (module, exports, require) => {
3880
4130
  setDraft('');
3881
4131
  setDraftReference(null);
3882
4132
  }
4133
+ chatStartedRef.current = Date.now();
4134
+ setChatProgress('');
3883
4135
  setChatBusy(true);
3884
4136
  setChatKind(kind);
3885
4137
  setChatError('');
@@ -3918,6 +4170,7 @@ __define('./reader', (module, exports, require) => {
3918
4170
  if (chatInFlightRef.current && chatInFlightRef.current.target === target) return;
3919
4171
  const request = ++chatRequestRef.current;
3920
4172
  chatInFlightRef.current = { target, request };
4173
+ chatStartedRef.current = 0; setChatProgress('');
3921
4174
  setChatBusy(true);
3922
4175
  setChatError('');
3923
4176
  try {
@@ -3982,57 +4235,66 @@ __define('./reader', (module, exports, require) => {
3982
4235
  const payload = extra?.request ? { ...extra.request } : { projectId, path: docPath, anchor, instruction, action: actionId };
3983
4236
  if (!extra?.request && typeof extra?.revised !== 'string') Object.assign(payload, chosenModel(chatModel));
3984
4237
  if (!extra?.request && doc && doc.format === 'docx') {
3985
- const source = sourceParts(doc).map(part => part.text).join('\n');
3986
- payload.expectedOriginal = source.slice(anchor.srcStart, anchor.srcEnd);
4238
+ payload.expectedOriginal = wordAnchorText(doc, anchor);
3987
4239
  if (doc.meta?.documentHash) payload.expectedDocumentHash = doc.meta.documentHash;
3988
4240
  }
3989
4241
  if (extra && typeof extra.revised === 'string') payload.revised = extra.revised;
3990
4242
  const snapshot = captureRequest(payload);
3991
4243
  const target = `${snapshot.projectId}\u0000${snapshot.path}`;
3992
- if (generationRef.current?.target === target) return;
3993
- const operation = { target, snapshot };
3994
- generationRef.current = operation;
3995
- const isCurrent = () => generationRef.current === operation && generationTargetRef.current === target;
4244
+ const range = snapshot.anchor || {};
4245
+ const ranges = Array.isArray(range.segments) ? range.segments : [range];
4246
+ const key = JSON.stringify([target, snapshot.action || actionId,
4247
+ snapshot.expectedDocumentHash || doc?.meta?.documentHash, snapshot.expectedOriginal ?? wordAnchorText(doc, snapshot.anchor), ranges.map(part => [part.kind,
4248
+ part.srcStart, part.srcEnd, part.blockId, part.sheet, part.cell, part.row, part.col])]);
4249
+ if (Array.from(generationJobsRef.current.values()).some(job => job.key === key && job.state === 'running')) return;
4250
+ const id = extra?.jobId || `generation-${++generationSequenceRef.current}`;
4251
+ const operation = Object.freeze({ id, key, target, snapshot, state: 'running', replaceId: extra?.replaceId,
4252
+ original: snapshot.expectedOriginal ?? wordAnchorText(doc, snapshot.anchor),
4253
+ documentHash: snapshot.expectedDocumentHash || doc?.meta?.documentHash,
4254
+ label: actionId === 'expand' ? '扩写' : actionId === 'manual' ? '修改' : '润色' });
4255
+ updateGenerationJobs(previous => { const next = new Map(previous); next.set(id, operation); return next; });
4256
+ const isCurrent = () => generationAliveRef.current && generationTargetRef.current === target
4257
+ && store.projectId === snapshot.projectId && store.docPath === snapshot.path;
4258
+ const finishJob = changes => updateGenerationJobs(previous => {
4259
+ if (previous.get(id) !== operation) return previous;
4260
+ const next = new Map(previous);
4261
+ if (changes) next.set(id, Object.freeze({ ...operation, ...changes }));
4262
+ else next.delete(id);
4263
+ return next;
4264
+ });
3996
4265
  let replaced = false;
3997
- setBusyAction(actionId || 'generate');
3998
- setStatus(extra?.replaceId ? '正在重新生成…' : '正在生成修订…');
3999
- setSelectionNotice('');
4000
- setNoChangeNotice('');
4001
- setGenerationError(null);
4002
- // 生成后让用户直接看到结果:展开右栏并切到「修订」标签。
4003
- setRightOpen(true);
4004
- setRightTab('revise');
4266
+ if (isCurrent()) {
4267
+ setStatus(extra?.replaceId ? '正在重新生成…' : '正在生成修订…');
4268
+ setSelectionNotice(''); setNoChangeNotice('');
4269
+ setRightOpen(true); setRightTab('revise');
4270
+ }
4005
4271
  try {
4006
4272
  if (extra?.replaceId) {
4007
4273
  await store.delete(extra.replaceId, { optimistic: true });
4008
4274
  replaced = true;
4009
4275
  }
4010
4276
  const revision = await store.generate(snapshot);
4011
- if (!isCurrent()) return revision;
4012
4277
  if (revision?.unchanged) {
4013
- const message = `${actionId === 'expand' ? '扩写' : '润色'}结果与原文一致,内容无变化。`;
4014
- if (!extra?.batch) {
4015
- setNoChangeNotice(message);
4016
- setStatus(message);
4017
- }
4278
+ const message = `${operation.label}结果与原文一致,内容无变化。`;
4279
+ finishJob({ state: 'unchanged', message, replaceId: undefined });
4280
+ if (isCurrent() && !extra?.batch) setStatus(message);
4018
4281
  return revision;
4019
4282
  }
4020
- const note = extra && extra.note ? `${extra.note} ` : '';
4021
- setStatus(`${note}${revision && revision.id ? `已生成修订 ${revision.id}` : '已生成修订'}`);
4283
+ finishJob(null);
4284
+ if (isCurrent()) {
4285
+ const note = extra && extra.note ? `${extra.note} ` : '';
4286
+ setStatus(`${note}${revision && revision.id ? `已生成修订 ${revision.id}` : '已生成修订'}`);
4287
+ }
4022
4288
  return revision;
4023
4289
  } catch (error) {
4024
- if (!isCurrent()) return;
4025
- const message = `${extra?.replaceId ? '重新生成' : actionId === 'expand' ? '扩写' : '润色'}失败:${normalizeError(error)}`;
4026
- setStatus(message);
4027
- setGenerationError({ message, request: snapshot, replaceId: !replaced ? extra?.replaceId : undefined });
4028
- } finally {
4029
- if (isCurrent()) setBusyAction('');
4030
- if (generationRef.current === operation) generationRef.current = null;
4290
+ const message = `${extra?.replaceId ? '重新生成' : operation.label}失败:${normalizeError(error)}`;
4291
+ finishJob({ state: 'failed', message, replaceId: !replaced ? extra?.replaceId : undefined });
4292
+ if (isCurrent()) setStatus(message);
4031
4293
  }
4032
- }, [store, projectId, docPath, doc, chatModel]);
4294
+ }, [store, projectId, docPath, doc, chatModel, updateGenerationJobs]);
4033
4295
 
4034
4296
  const refreshDoc = React.useCallback(() => {
4035
- if (docPath) openDoc(docPath, { keepStatus: true });
4297
+ if (docPath) return openDoc(docPath, { keepStatus: true });
4036
4298
  }, [docPath, openDoc]);
4037
4299
 
4038
4300
  const reloadDocument = async (automatic = false) => {
@@ -4081,83 +4343,86 @@ __define('./reader', (module, exports, require) => {
4081
4343
  };
4082
4344
 
4083
4345
  const acceptRevision = React.useCallback(async (revision) => {
4084
- if (!revision) return;
4085
- setBusyAction(`rev:${revision.id}`);
4346
+ if (!revision || writeBusyRef.current) return;
4347
+ const target = `${store.projectId}\u0000${store.docPath}`;
4348
+ const isCurrent = () => generationTargetRef.current === target && `${store.projectId}\u0000${store.docPath}` === target;
4349
+ writeBusyRef.current = true; setBusyAction(`rev:${revision.id}`); setAcceptingRevisionId(revision.id);
4350
+ setStatus(/\.docx$/i.test(revision.docPath || '') ? '正在核对版式并写回…' : '正在写回修订…');
4351
+ setRevisionErrors(previous => { const next = new Map(previous); next.delete(revision.id); return next; });
4086
4352
  try {
4087
- const out = await store.accept(revision.id);
4353
+ const out = await store.accept(revision.id, revision.layoutWarning?.token
4354
+ ? { layoutRiskToken: revision.layoutWarning.token } : undefined);
4355
+ if (!isCurrent()) return;
4088
4356
  const written = out && out.written;
4089
- setStatus(written && written.backup
4090
- ? `已接受并写回(${written.bytes || 0} 字节,备份:${written.backup})`
4091
- : '已接受修订');
4092
- setPreviewEpoch((value) => value + 1);
4093
- refreshDoc();
4357
+ setStatus(written?.layoutRiskAccepted
4358
+ ? '已接受并写入修订,请点击文档标题右侧的 Office 图标,手动打开文档调整排版。'
4359
+ : written && written.backup ? `已接受并写回(${written.bytes || 0} 字节,备份:${written.backup})` : '已接受修订');
4360
+ setPreviewEpoch(value => value + 1);
4361
+ await Promise.all([refreshDoc(), store.load()]);
4094
4362
  } catch (error) {
4095
- setStatus(`接受失败:${normalizeError(error)}`);
4363
+ if (!isCurrent()) return;
4364
+ const message = normalizeError(error);
4365
+ setStatus(`${error?.body?.revision?.layoutWarning ? '排版提示' : '接受失败'}:${message}`);
4366
+ setRevisionErrors(previous => { const next = new Map(previous); next.set(revision.id, message); return next; });
4096
4367
  } finally {
4097
- setBusyAction('');
4368
+ writeBusyRef.current = false; setBusyAction(''); setAcceptingRevisionId('');
4098
4369
  }
4099
4370
  }, [store, refreshDoc]);
4100
4371
 
4101
4372
  const rejectRevision = React.useCallback(async (revision) => {
4102
4373
  if (!revision) return;
4103
- setBusyAction(`rev:${revision.id}`);
4374
+ const target = `${store.projectId}\u0000${store.docPath}`;
4104
4375
  try {
4105
4376
  await store.reject(revision.id);
4106
- setStatus('已撤回修订');
4377
+ if (generationTargetRef.current === target) setStatus('已撤回修订');
4107
4378
  } catch (error) {
4108
- setStatus(`撤回失败:${normalizeError(error)}`);
4109
- } finally {
4110
- setBusyAction('');
4379
+ if (generationTargetRef.current === target) setStatus(`撤回失败:${normalizeError(error)}`);
4111
4380
  }
4112
4381
  }, [store]);
4113
4382
 
4114
4383
  const deleteRevision = React.useCallback(async (revision) => {
4115
4384
  if (!revision) return;
4116
- setBusyAction(`rev:${revision.id}`);
4385
+ const target = `${store.projectId}\u0000${store.docPath}`;
4117
4386
  try {
4118
- await store.delete(revision.id);
4119
- setStatus(revision.status === 'accepted' ? '已删除修订卡片,文档中已写入的修改保留。' : '已删除修订卡片');
4387
+ await store.delete(revision.id, { optimistic: true });
4388
+ if (generationTargetRef.current === target) setStatus(revision.status === 'accepted'
4389
+ ? '已删除修订卡片,文档中已写入的修改保留。' : '已删除修订卡片');
4120
4390
  } catch (error) {
4121
- setStatus(`删除修订失败:${normalizeError(error)}`);
4122
- } finally { setBusyAction(''); }
4391
+ if (generationTargetRef.current === target) setStatus(`删除修订失败:${normalizeError(error)}`);
4392
+ }
4123
4393
  }, [store]);
4124
4394
 
4125
4395
  const restoreRevision = React.useCallback(async (revision) => {
4126
- if (!revision) return;
4127
- setBusyAction(`rev:${revision.id}`);
4396
+ if (!revision || writeBusyRef.current) return;
4397
+ const target = `${store.projectId}\u0000${store.docPath}`;
4398
+ writeBusyRef.current = true; setBusyAction(`rev:${revision.id}`);
4128
4399
  try {
4129
4400
  await store.restore(revision.id);
4401
+ if (generationTargetRef.current !== target) return;
4130
4402
  setStatus(revision.status === 'accepted' ? '已撤销写入并还原文件' : '已恢复为待处理');
4131
- setPreviewEpoch((value) => value + 1);
4132
- refreshDoc();
4133
- await store.load();
4403
+ setPreviewEpoch(value => value + 1);
4404
+ await Promise.all([refreshDoc(), store.load()]);
4134
4405
  } catch (error) {
4135
- setStatus(`恢复失败:${normalizeError(error)}`);
4136
- } finally {
4137
- setBusyAction('');
4138
- }
4406
+ if (generationTargetRef.current === target) setStatus(`恢复失败:${normalizeError(error)}`);
4407
+ } finally { writeBusyRef.current = false; setBusyAction(''); }
4139
4408
  }, [store, refreshDoc]);
4140
4409
 
4141
4410
  const restoreWriteGroup = React.useCallback(async (group) => {
4142
- const list = Array.isArray(group) ? group.filter((item) => item && item.id) : [];
4143
- if (list.length === 0) return;
4144
- setBusyAction(`rev:${list[0].id}`);
4411
+ const list = Array.isArray(group) ? group.filter(item => item && item.id) : [];
4412
+ if (!list.length || writeBusyRef.current) return;
4413
+ const target = `${store.projectId}\u0000${store.docPath}`;
4414
+ writeBusyRef.current = true; setBusyAction(`rev:${list[0].id}`);
4145
4415
  try {
4146
- const results = await store.bulk(list.map((item) => item.id), 'restore');
4147
- const failed = (results || []).filter((item) => item && item.ok === false);
4148
- if (failed.length > 0) {
4149
- setStatus(`整组撤回失败:${failed[0].error || ''}`);
4150
- return;
4151
- }
4416
+ const results = await store.bulk(list.map(item => item.id), 'restore');
4417
+ if (generationTargetRef.current !== target) return;
4418
+ const failed = (results || []).filter(item => item && item.ok === false);
4419
+ if (failed.length) { setStatus(`整组撤回失败:${failed[0].error || ''}`); return; }
4152
4420
  setStatus(`已撤回本次批量写入(${list.length} 条)`);
4153
- setPreviewEpoch((value) => value + 1);
4154
- refreshDoc();
4155
- await store.load();
4421
+ setPreviewEpoch(value => value + 1);
4422
+ await Promise.all([refreshDoc(), store.load()]);
4156
4423
  } catch (error) {
4157
- setStatus(`整组撤回失败:${normalizeError(error)}`);
4158
- } finally {
4159
- setBusyAction('');
4160
- }
4424
+ if (generationTargetRef.current === target) setStatus(`整组撤回失败:${normalizeError(error)}`);
4425
+ } finally { writeBusyRef.current = false; setBusyAction(''); }
4161
4426
  }, [store, refreshDoc]);
4162
4427
 
4163
4428
  const regenerate = React.useCallback(async (revision) => {
@@ -4176,15 +4441,21 @@ __define('./reader', (module, exports, require) => {
4176
4441
  }, [generate, projectId, docPath, chatModel]);
4177
4442
 
4178
4443
  const acceptAll = React.useCallback(async () => {
4444
+ if (writeBusyRef.current) return;
4179
4445
  if (groupRevisionVariants(store.all()).some(group => group.items.filter(item => item.status === 'pending').length > 1)) {
4180
4446
  setStatus('同一选区有多份扩写,请选择其中一份接受。');
4181
4447
  return;
4182
4448
  }
4449
+ const target = `${store.projectId}\u0000${store.docPath}`;
4450
+ writeBusyRef.current = true;
4183
4451
  setBusyAction('accept-all');
4184
4452
  setStatus('正在批量接受…');
4185
4453
  try {
4186
4454
  const results = await store.acceptAll();
4455
+ if (generationTargetRef.current !== target) return;
4187
4456
  const failed = (results || []).filter((item) => item && item.ok === false);
4457
+ if (failed.length) setRevisionErrors(previous => { const next = new Map(previous);
4458
+ failed.forEach(item => next.set(item.revisionId, item.error || '接受失败')); return next; });
4188
4459
  const wrote = (results || []).some((item) => item && item.ok !== false);
4189
4460
  setStatus(failed.length > 0
4190
4461
  ? `接受完成,${failed.length} 条失败:${failed[0].error || ''}`
@@ -4192,9 +4463,9 @@ __define('./reader', (module, exports, require) => {
4192
4463
  if (wrote) setPreviewEpoch((value) => value + 1);
4193
4464
  refreshDoc();
4194
4465
  } catch (error) {
4195
- setStatus(`批量接受失败:${normalizeError(error)}`);
4466
+ if (generationTargetRef.current === target) setStatus(`批量接受失败:${normalizeError(error)}`);
4196
4467
  } finally {
4197
- setBusyAction('');
4468
+ writeBusyRef.current = false; setBusyAction('');
4198
4469
  }
4199
4470
  }, [store, refreshDoc]);
4200
4471
 
@@ -4262,18 +4533,48 @@ __define('./reader', (module, exports, require) => {
4262
4533
  if (!instruction) return;
4263
4534
  // 只读格式即使工具条漏了按钮,也不再打出「改写失败」。
4264
4535
  if (!revisionAllowed(doc)) return;
4536
+ const isWordEdit = doc?.format === 'docx' && (actionId === 'polish' || actionId === 'expand');
4537
+ if (isWordEdit && wordListMarker(hit.text)) {
4538
+ setNoChangeNotice('选区只有列表标记,没有需要修改的正文。');
4539
+ setStatus('已保留原列表标记,内容无变化。');
4540
+ return;
4541
+ }
4265
4542
  const anchor = hit.anchor || {};
4266
4543
  const hasRange = Number.isInteger(anchor.srcStart) && Number.isInteger(anchor.srcEnd);
4267
4544
  const parts = Array.isArray(hit.parts) ? hit.parts : [];
4268
4545
  const shapeNote = hit.matched === 'shape' ? '识别和原文不一致,已按整个文本框改写。' : '';
4546
+ if (isWordEdit && (parts.length > 1 || Array.isArray(anchor.segments))) {
4547
+ const allParts = parts.length ? parts : (anchor.segments || []).map(segment => ({ anchor: segment,
4548
+ text: wordAnchorText(doc, segment) }));
4549
+ if (!wordCompoundAnchor(doc, allParts)) {
4550
+ cannotLocate(); return;
4551
+ }
4552
+ let firstBody = 0;
4553
+ while (firstBody < allParts.length && wordListMarker(allParts[firstBody].text)) firstBody += 1;
4554
+ const body = allParts.slice(firstBody), combined = wordCompoundAnchor(doc, body);
4555
+ if (!combined || body.some(part => wordListMarker(part.text)) || !continuousWordAnchor(doc, combined)) {
4556
+ const message = '选区中间有未选中的文字或列表标记,请连续选择一段正文后再修改。';
4557
+ setSelectionNotice(message); setStatus(message); return;
4558
+ }
4559
+ // One model request sees the complete selected paragraph. Per-line
4560
+ // rewriting could skip an unchanged first line and fragment the result.
4561
+ generate(combined, instruction, actionId);
4562
+ return;
4563
+ }
4269
4564
  if (!hasRange && anchor.kind !== 'cell' && parts.length > 1) {
4270
- const ready = parts.filter((part) => (
4565
+ const located = parts.filter((part) => (
4271
4566
  part && part.anchor && Number.isInteger(part.anchor.srcStart) && Number.isInteger(part.anchor.srcEnd)
4272
4567
  ));
4273
- if (ready.length === 0 || (doc && doc.format === 'docx' && ready.length < parts.length)) {
4568
+ if (located.length === 0 || (doc && doc.format === 'docx' && located.length < parts.length)) {
4274
4569
  cannotLocate();
4275
4570
  return;
4276
4571
  }
4572
+ const ready = located;
4573
+ if (!ready.length) {
4574
+ setNoChangeNotice('选区只有列表标记,没有需要修改的正文。');
4575
+ setStatus('已保留原列表标记,内容无变化。');
4576
+ return;
4577
+ }
4277
4578
  (async () => {
4278
4579
  let unchanged = 0, changed = 0, failed = 0;
4279
4580
  for (let i = 0; i < ready.length; i += 1) {
@@ -4287,7 +4588,7 @@ __define('./reader', (module, exports, require) => {
4287
4588
  if (unchanged) setNoChangeNotice(unchanged === ready.length
4288
4589
  ? '所选内容与原文一致,内容无变化。' : `${unchanged} 段内容无变化,已跳过。`);
4289
4590
  setStatus([changed ? `已生成 ${changed} 段修订` : '', unchanged ? `${unchanged} 段内容无变化` : '',
4290
- failed ? `${failed} 段处理失败` : '', ready.length < parts.length ? `${parts.length - ready.length} 段对不上原文` : ''].filter(Boolean).join(';'));
4591
+ failed ? `${failed} 段处理失败` : '', located.length < parts.length ? `${parts.length - located.length} 段对不上原文` : ''].filter(Boolean).join(';'));
4291
4592
  })();
4292
4593
  return;
4293
4594
  }
@@ -4423,6 +4724,28 @@ __define('./reader', (module, exports, require) => {
4423
4724
  : treeNodes('', 0, treeCtx)),
4424
4725
  ]);
4425
4726
 
4727
+ const convertLegacyWord = async () => {
4728
+ if (legacyConvertRef.current || docBusy || busyAction || !projectId || !docPath) return;
4729
+ const target = `${projectId}\u0000${docPath}`, operation = { target };
4730
+ legacyConvertRef.current = operation; setLegacyConverting(true); setOfficeNotice(null);
4731
+ setStatus('正在转换为可修订的 Word 文档…');
4732
+ try {
4733
+ const entry = await transport.convertWord(projectId, docPath);
4734
+ if (!entry?.relPath) throw new Error('转换未返回可编辑的 Word 文件');
4735
+ setShelfReloadKey(value => value + 1);
4736
+ if (chatTargetRef.current === target) {
4737
+ await openDoc(entry.relPath, { projectId });
4738
+ setStatus(`已打开可修订副本 ${entry.name || entry.relPath}`);
4739
+ }
4740
+ } catch (error) {
4741
+ if (chatTargetRef.current === target) {
4742
+ setOfficeNotice({ message: normalizeError(error) }); setStatus(`转换失败:${normalizeError(error)}`);
4743
+ }
4744
+ } finally {
4745
+ if (legacyConvertRef.current === operation) { legacyConvertRef.current = null; setLegacyConverting(false); }
4746
+ }
4747
+ };
4748
+
4426
4749
  // ---- 渲染:中栏正文
4427
4750
  const center = h(DocReader, {
4428
4751
  key: 'center',
@@ -4430,6 +4753,7 @@ __define('./reader', (module, exports, require) => {
4430
4753
  docBusy,
4431
4754
  docError,
4432
4755
  pendingByBlock,
4756
+ visionRoute: chosenModel(chatModel),
4433
4757
  busyAction: busyAction || (chatBusy && chatKind === 'translate' ? 'translate' : ''),
4434
4758
  actions: SELECTION_ACTIONS,
4435
4759
  projectId,
@@ -4449,6 +4773,9 @@ __define('./reader', (module, exports, require) => {
4449
4773
  onAction: runAction,
4450
4774
  previewEpoch,
4451
4775
  officeNotice,
4776
+ onConvertWord: convertLegacyWord,
4777
+ convertingWord: legacyConverting,
4778
+ conversionDisabled: docBusy || Boolean(busyAction),
4452
4779
  onRefresh: reloadDocument,
4453
4780
  refreshDisabled: docBusy || Boolean(busyAction),
4454
4781
  onAccept: acceptRevision,
@@ -4528,7 +4855,7 @@ __define('./reader', (module, exports, require) => {
4528
4855
  messageFooter(message),
4529
4856
  ])),
4530
4857
  chatBusy ? h('div', { key: 'thinking', className: P + 'Thinking', 'data-chat-thinking': '1' },
4531
- h(Processing, { kind: chatKind, text: chatKind === 'translate' ? '正在翻译…' : '思考中…' })) : null,
4858
+ h(Processing, { kind: chatKind, text: chatProgress || (chatKind === 'translate' ? '正在翻译…' : '思考中…') })) : null,
4532
4859
  ),
4533
4860
  chatError ? h('div', { key: 'chatErr', className: P + 'Err', 'data-chat-error': '1' }, chatError) : null,
4534
4861
  h('div', { key: 'composer', className: P + 'Composer' }, [
@@ -4590,7 +4917,7 @@ __define('./reader', (module, exports, require) => {
4590
4917
  h('option', { key: 'default', value: '' }, chatModelsBusy ? '加载模型…' : `跟随 DSH${chatDefaultModel ? ` · ${chatDefaultModel.model}` : ''}`),
4591
4918
  chatModel && !chatModels.some(item => JSON.stringify([item.provider, item.model]) === chatModel)
4592
4919
  ? h('option', { key: 'unavailable', value: chatModel }, '所选模型暂不可用') : null,
4593
- ...chatModels.map(item => h('option', { key: JSON.stringify([item.provider, item.model]), value: JSON.stringify([item.provider, item.model]) }, `${item.label} · ${item.providerLabel}`)),
4920
+ ...chatModels.map(item => h('option', { key: JSON.stringify([item.provider, item.model]), value: JSON.stringify([item.provider, item.model]) }, `${item.label} · ${item.providerLabel}${item.inputModalities?.includes('image') ? ' · 图片' : ''}`)),
4594
4921
  ]),
4595
4922
  ]),
4596
4923
  h('div', { key: 'hint', className: P + 'Hint' },
@@ -4606,7 +4933,11 @@ __define('./reader', (module, exports, require) => {
4606
4933
  collapsed: collapsedRevisions.includes(item.id),
4607
4934
  onToggleCollapsed: id => setCollapsedRevisions(previous => previous.includes(id)
4608
4935
  ? previous.filter(value => value !== id) : previous.concat(id).slice(-500)),
4609
- busy: busyAction === 'polish' || busyAction === 'expand' || busyAction === `rev:${item.id}`,
4936
+ busy: store.isBusy(`rev:${item.id}`),
4937
+ writeBusy: Boolean(busyAction),
4938
+ acceptError: revisionErrors.get(item.id),
4939
+ progress: acceptingRevisionId === item.id ? h(Processing, { kind: 'accept',
4940
+ text: /\.docx$/i.test(item.docPath || '') ? '正在核对版式并写回…' : '正在写回修订…' }) : null,
4610
4941
  writeGroup: (revision) => store.writeGroup(revision),
4611
4942
  onAccept: acceptRevision,
4612
4943
  onReject: rejectRevision,
@@ -4624,16 +4955,26 @@ __define('./reader', (module, exports, require) => {
4624
4955
  store.loading ? h('div', { className: P + 'Hint' }, '加载修订中…') : null,
4625
4956
  store.error ? h('div', { className: P + 'Err' }, store.error) : null,
4626
4957
  noChangeNotice ? h('div', { className: P + 'NoChange', role: 'status', 'data-revision-unchanged': '1' }, noChangeNotice) : null,
4627
- generationError ? h('div', { className: P + 'GenerationError', role: 'alert', 'data-generation-error': '1' }, [
4628
- h('div', { key: 'message', className: P + 'Err' }, generationError.message),
4629
- h('button', { key: 'retry', type: 'button', className: P + 'Btn',
4630
- onClick: () => generate(generationError.request.anchor, generationError.request.instruction, generationError.request.action, {
4631
- request: generationError.request, replaceId: generationError.replaceId,
4632
- }) }, '重试'),
4633
- ]) : null,
4634
- busyAction === 'polish' || busyAction === 'expand'
4635
- ? h(Processing, { kind: busyAction, text: busyAction === 'expand' ? '正在扩写…' : '正在润色…' }) : null,
4636
- items.length === 0 && !store.loading && !noChangeNotice && !generationError && busyAction !== 'polish' && busyAction !== 'expand'
4958
+ Array.from(generationJobs.values()).filter(job => job.target === generationTargetRef.current).reverse().map(job =>
4959
+ h('div', { key: job.id, className: P + 'RevCard', 'data-generation-id': job.id,
4960
+ 'data-generation-state': job.state, 'data-generation-action': job.snapshot.action,
4961
+ 'data-generation-error': job.state === 'failed' ? '1' : undefined,
4962
+ role: job.state === 'failed' ? 'alert' : 'status' }, [
4963
+ h('div', { key: 'label', className: P + 'RevCollapse' }, `${job.label} · ${anchorLabel(job.snapshot.anchor)}`),
4964
+ typeof job.original === 'string' ? h('div', { key: 'original', className: P + 'GenerationOriginal',
4965
+ title: job.original }, job.original) : null,
4966
+ job.state === 'running' ? h(Processing, { key: 'progress', kind: job.snapshot.action,
4967
+ text: `正在${job.label}…` }) : null,
4968
+ job.message ? h('div', { key: 'message', className: job.state === 'failed' ? P + 'Err' : P + 'NoChange',
4969
+ 'data-revision-unchanged': job.state === 'unchanged' ? '1' : undefined }, job.message) : null,
4970
+ job.state === 'failed' ? h('button', { key: 'retry', type: 'button', className: P + 'Btn',
4971
+ onClick: () => generate(job.snapshot.anchor, job.snapshot.instruction, job.snapshot.action,
4972
+ { request: job.snapshot, replaceId: job.replaceId, jobId: job.id }) }, '重试') : null,
4973
+ job.state !== 'running' ? h('button', { key: 'dismiss', type: 'button', className: P + 'Btn',
4974
+ onClick: () => updateGenerationJobs(previous => { const next = new Map(previous); next.delete(job.id); return next; }) }, '删除') : null,
4975
+ ])),
4976
+ items.length === 0 && !store.loading && !noChangeNotice
4977
+ && !Array.from(generationJobs.values()).some(job => job.target === generationTargetRef.current)
4637
4978
  ? h('div', { className: P + 'Hint' }, revisionAllowed(doc)
4638
4979
  ? '选中正文后点「润色 / 扩写」生成修订;接受才会写回文件。'
4639
4980
  : '这份文档只能阅读,不能生成写回修订。')
@@ -4965,6 +5306,8 @@ __define('./reader', (module, exports, require) => {
4965
5306
  .${P}NoChange{padding:12px;border:1px solid var(--dsw-alias-border-l1);border-radius:var(--dsw-radius-md,8px);
4966
5307
  background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px;margin-bottom:10px}
4967
5308
  .${P}GenerationError{padding:8px 10px;margin-bottom:10px}
5309
+ .${P}GenerationOriginal{max-height:90px;overflow:auto;white-space:pre-wrap;word-break:break-word;font-size:12px;line-height:1.6;
5310
+ color:var(--dsw-alias-label-secondary);padding:6px 10px}
4968
5311
  .${P}Processing{display:flex;align-items:center;gap:8px;padding:8px 10px;color:var(--dsw-alias-label-secondary);font-size:12px;flex:none}
4969
5312
  .${P}WhaleTail{width:20px;height:20px;contain:strict;flex:none;display:inline-flex;position:relative;overflow:hidden}
4970
5313
  .${P}WhaleAnimated{display:none;position:absolute;inset:0}
@@ -5174,8 +5517,18 @@ __define('./reader', (module, exports, require) => {
5174
5517
  .${P}SlidePrev{left:12px}
5175
5518
  .${P}SlideNext{right:12px}
5176
5519
  .${P}SlideCount{position:absolute;left:50%;bottom:14px;transform:translateX(-50%);z-index:2;
5177
- color:#fff;background:rgba(0,0,0,.5);border-radius:999px;padding:2px 12px;font-size:12px;
5520
+ color:#fff;background:rgba(0,0,0,.65);border-radius:12px;padding:5px 8px;font-size:12px;max-width:calc(100% - 24px);
5178
5521
  font-variant-numeric:tabular-nums}
5522
+ .${P}SlideCountControls{display:flex;align-items:center;justify-content:center;gap:6px;white-space:nowrap}
5523
+ .${P}SlidePageInput{box-sizing:border-box;width:54px;min-width:0;padding:3px 4px;border:1px solid rgba(255,255,255,.4);
5524
+ border-radius:5px;background:rgba(255,255,255,.12);color:#fff;font:inherit;line-height:18px;text-align:center}
5525
+ .${P}SlidePageInput:focus-visible{outline:2px solid #fff;outline-offset:1px}
5526
+ .${P}SlidePageInput[aria-invalid="true"]{border-color:#ffaeae}
5527
+ .${P}SlideJumpButton{padding:3px 7px;border:0;border-radius:5px;background:rgba(255,255,255,.18);color:#fff;
5528
+ font:inherit;line-height:20px;cursor:pointer}
5529
+ .${P}SlideJumpButton:hover:not(:disabled){background:rgba(255,255,255,.3)}
5530
+ .${P}SlidePageInput:disabled,.${P}SlideJumpButton:disabled{opacity:.5;cursor:default}
5531
+ .${P}SlideJumpError{margin-top:4px;max-width:240px;white-space:normal;line-height:18px;color:#ffcdcd;text-align:center}
5179
5532
  .${P}ViewBar .${P}Hint{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:0}
5180
5533
  .${P}SelectionNotice{flex:none;padding:10px 12px;font-size:13px;line-height:1.6;
5181
5534
  color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);border-bottom:1px solid var(--dsw-alias-border-l1)}
@@ -5245,6 +5598,12 @@ __define('./reader', (module, exports, require) => {
5245
5598
  exports.bindWordLayoutLines = bindWordLayoutLines;
5246
5599
  exports.wordNativeSelection = wordNativeSelection;
5247
5600
  exports.wordBoxSelection = wordBoxSelection;
5601
+ exports.wordCompoundAnchor = wordCompoundAnchor;
5602
+ exports.wordAnchorText = wordAnchorText;
5603
+ exports.continuousWordAnchor = continuousWordAnchor;
5604
+ exports.wordListMarker = wordListMarker;
5605
+ exports.captureRequest = captureRequest;
5606
+ exports.groupRevisionVariants = groupRevisionVariants;
5248
5607
  exports.rectangleSpans = rectangleSpans;
5249
5608
  exports.placeByShapes = placeByShapes;
5250
5609
  exports.mapOffsetToSrc = mapOffsetToSrc;