dsh-cait 1.0.3 → 1.0.4

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/lib/store.js CHANGED
@@ -1,734 +1,734 @@
1
- import { CatError } from './errors.js'
2
- import { createRequire } from 'node:module'
3
- import { join, resolve } from 'node:path'
4
- import { splitIntoSegments } from './segmenter.js'
5
- import { canonicalLanguage, cleanFormatMeta, cleanLang, cleanName, cleanSegmentMeta, cleanText, clamp, clampInt, countWords, documentFingerprint, DEFAULT_SETTINGS, DEFAULT_GLOSSARY_ID, DEFAULT_GLOSSARY_NAME, DEFAULT_TM_ID, DEFAULT_TM_NAME, escapeRegExp, MAX_IMPORT_CHARS, MAX_SEGMENTS, MAX_SEGMENT_CHARS, now, makeId, parseGlossaryCsv, restoredDate, restoredLang, restoredText, restoreSettings, safeBaseName, STORE_FILENAME, UI_LANG_CODES } from './store-utils.js'
6
- import { libraryMethods } from './libraries.js'
7
- import { tmMethods } from './tm.js'
8
- import { glossaryMethods } from './glossary.js'
9
- import { persistenceMethods } from './persistence.js'
10
- import { importExportMethods } from './import-export.js'
11
- import { classifyMatchType, normalizeSource, normalizeText, similarity } from './similarity.js'
12
- import { MOCK_MODEL, MOCK_PROVIDER, NO_MODEL_MESSAGE, NO_ROUTE_MESSAGE, collectStreamText } from './mt.js'
13
- import { buildTmx } from './tmx.js'
14
- import { missingInlineTokens } from './formats.js'
15
-
16
- export { CatError } from './errors.js'
17
- export { canonicalLanguage } from './store-utils.js'
18
-
19
- export class CatStore {
20
-
21
- constructor({ dataDir = '', logger = console, mtRuntime = null } = {}) {
22
- this.docs = new Map()
23
- this.activeDocId = null
24
- this.openDocIds = []
25
- this.tm = []
26
- this.glossary = []
27
- this.settings = structuredClone(DEFAULT_SETTINGS)
28
- this.revision = 0
29
- this.logger = logger
30
- this.mtRuntime = mtRuntime
31
- this.resetLibraryState()
32
- this.dataDir = String(dataDir ?? '').trim() ? resolve(String(dataDir)) : ''
33
- this.storageFile = this.dataDir ? join(this.dataDir, STORE_FILENAME) : ''
34
- this.persistence = {
35
- enabled: Boolean(this.storageFile),
36
- file: this.storageFile || null,
37
- loaded: false,
38
- lastSavedAt: null,
39
- error: null,
40
- corruptBackup: null,
41
- }
42
- if (this.storageFile) this.load()
43
- }
44
-
45
- touch() {
46
- this.revision += 1
47
- }
48
-
49
- activeDocument() {
50
- return this.activeDocId ? this.docs.get(this.activeDocId) ?? null : null
51
- }
52
-
53
- requireDocument(documentId) {
54
- const doc = documentId ? this.docs.get(documentId) : this.activeDocument()
55
- if (!doc) throw new CatError('NO_DOCUMENT', '没有活动文档,请先在 CAIT 工作台创建或导入文档。', 409)
56
- return doc
57
- }
58
-
59
- findSegment(doc, index) {
60
- if (!Number.isInteger(index) || index < 0 || index >= doc.segments.length) {
61
- throw new CatError('SEGMENT_OUT_OF_RANGE', `句段索引超出范围: ${index}`)
62
- }
63
- return doc.segments[index]
64
- }
65
-
66
- publicSettings() {
67
- return {
68
- ...this.settings,
69
- mt: {
70
- provider: this.settings.mt.provider,
71
- model: this.settings.mt.model,
72
- temperature: this.settings.mt.temperature,
73
- },
74
- }
75
- }
76
-
77
- async listMtRoutes(options = {}) {
78
- if (this.mtRuntime && typeof this.mtRuntime.listRoutes === 'function') {
79
- return this.mtRuntime.listRoutes(options)
80
- }
81
- return { providers: [], fallback: true, message: NO_ROUTE_MESSAGE }
82
- }
83
-
84
- progress(doc) {
85
- const total = doc.segments.length
86
- const confirmed = doc.segments.filter((segment) => segment.status === 'confirmed').length
87
- return {
88
- total,
89
- confirmed,
90
- pending: total - confirmed,
91
- percent: total ? Math.round((confirmed / total) * 100) : 0,
92
- }
93
- }
94
-
95
- documentSummary(doc) {
96
- return {
97
- id: doc.id,
98
- name: doc.name,
99
- sourceLang: doc.sourceLang,
100
- targetLang: doc.targetLang,
101
- cursor: doc.cursor,
102
- createdAt: doc.createdAt,
103
- updatedAt: doc.updatedAt,
104
- progress: this.progress(doc),
105
- }
106
- }
107
-
108
- segmentView(segment, index) {
109
- return {
110
- id: segment.id,
111
- index,
112
- source: segment.source,
113
- target: segment.target,
114
- status: segment.status,
115
- paragraph: segment.paragraph,
116
- confirmedAt: segment.confirmedAt,
117
- }
118
- }
119
-
120
- segmentDetail(doc, index) {
121
- const segment = this.findSegment(doc, index)
122
- return {
123
- ...this.segmentView(segment, index),
124
- chars: Array.from(segment.source).length,
125
- words: countWords(segment.source),
126
- tmMatches: this.lookupTM({
127
- query: segment.source,
128
- sourceLang: doc.sourceLang,
129
- targetLang: doc.targetLang,
130
- }).matches,
131
- glossaryHits: this.findGlossaryHits(segment.source, doc.sourceLang),
132
- }
133
- }
134
-
135
- documentView(doc) {
136
- return {
137
- ...this.documentSummary(doc),
138
- segments: doc.segments.map((segment, index) => this.segmentView(segment, index)),
139
- current: this.segmentDetail(doc, doc.cursor),
140
- }
141
- }
142
-
143
- bootstrap() {
144
- const doc = this.activeDocument()
145
- const mountedTm = new Set(this.mounts.tmIds)
146
- const mountedGlossary = new Set(this.mounts.glossaryIds)
147
- const mountedTmEntries = this.tm
148
- .filter((entry) => mountedTm.has(entry.tmId || DEFAULT_TM_ID))
149
- .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
150
- const tmEntries = mountedTmEntries.slice(0, 40).map((entry) => ({
151
- id: entry.id,
152
- tmId: entry.tmId || DEFAULT_TM_ID,
153
- source: entry.source,
154
- target: entry.target,
155
- sourceLang: entry.sourceLang,
156
- targetLang: entry.targetLang,
157
- updatedAt: entry.updatedAt,
158
- }))
159
- const glossary = this.glossary.filter((entry) =>
160
- mountedGlossary.has(entry.glossaryId || DEFAULT_GLOSSARY_ID),
161
- )
162
-
163
- return {
164
- revision: this.revision,
165
- settings: this.publicSettings(),
166
- storage: {
167
- enabled: this.persistence.enabled,
168
- file: this.persistence.file,
169
- loaded: this.persistence.loaded,
170
- lastSavedAt: this.persistence.lastSavedAt,
171
- error: this.persistence.error,
172
- },
173
- stats: {
174
- documents: this.docs.size,
175
- segments: [...this.docs.values()].reduce((sum, item) => sum + item.segments.length, 0),
176
- confirmed: [...this.docs.values()].reduce(
177
- (sum, item) => sum + item.segments.filter((segment) => segment.status === 'confirmed').length,
178
- 0,
179
- ),
180
- tm: this.tm.length,
181
- glossary: this.glossary.length,
182
- },
183
- documents: [...this.docs.values()].map((item) => this.documentSummary(item)),
184
- openDocuments: this.openDocIds
185
- .map((id) => this.docs.get(id))
186
- .filter(Boolean)
187
- .map((item) => this.documentSummary(item)),
188
- activeDocument: doc ? this.documentView(doc) : null,
189
- libraries: this.librariesView(),
190
- mounts: this.mountsView(),
191
- tm: {
192
- count: mountedTmEntries.length,
193
- total: this.tm.length,
194
- entries: tmEntries,
195
- },
196
- glossary: glossary.map((entry) => ({ ...entry })),
197
- }
198
- }
199
-
200
- createDocument(input = {}) {
201
- const sourceLang = cleanLang(input.sourceLang || this.settings.sourceLang, this.settings.sourceLang)
202
- const targetLang = cleanLang(input.targetLang || this.settings.targetLang, this.settings.targetLang)
203
- let rawSegments = []
204
-
205
- if (Array.isArray(input.segments) && input.segments.length) {
206
- rawSegments = input.segments.map((segment) =>
207
- typeof segment === 'string' ? { source: segment } : segment,
208
- )
209
- } else {
210
- const text = String(input.text ?? '')
211
- if (!text.trim()) throw new CatError('EMPTY_SOURCE', '源文本为空,无法创建文档。')
212
- if (text.length > MAX_IMPORT_CHARS) {
213
- throw new CatError('SOURCE_TOO_LARGE', `源文本超过 ${MAX_IMPORT_CHARS} 字符上限`)
214
- }
215
- rawSegments = splitIntoSegments(text)
216
- }
217
-
218
- if (!rawSegments.length) throw new CatError('EMPTY_SOURCE', '没有可翻译的句段。')
219
- if (rawSegments.length > MAX_SEGMENTS) {
220
- throw new CatError('TOO_MANY_SEGMENTS', `句段数量超过 ${MAX_SEGMENTS} 上限`)
221
- }
222
-
223
- const createdAt = now()
224
- const paragraphMap = Array.isArray(input.paragraphMap) ? input.paragraphMap : null
225
- const segments = rawSegments.map((raw, index) => {
226
- const source = cleanText(raw.source, '原文')
227
- const target = cleanText(raw.target, '译文')
228
- if (!source) throw new CatError('EMPTY_SEGMENT', `第 ${index + 1} 句原文为空`)
229
- const paragraph = paragraphMap && Number.isInteger(paragraphMap[raw.paragraph])
230
- ? paragraphMap[raw.paragraph]
231
- : Number.isInteger(raw.paragraph)
232
- ? raw.paragraph
233
- : index
234
- const meta = cleanSegmentMeta(raw.meta)
235
- return {
236
- id: `s${index + 1}`,
237
- source,
238
- target,
239
- status: target ? 'confirmed' : 'pending',
240
- paragraph,
241
- ...(meta ? { meta } : {}),
242
- confirmedAt: target ? createdAt : null,
243
- updatedAt: createdAt,
244
- }
245
- })
246
-
247
- const format = typeof input.format === 'string' ? input.format.slice(0, 24) : ''
248
- const formatMeta = cleanFormatMeta(input.formatMeta)
249
- const fingerprint = documentFingerprint(sourceLang, targetLang, segments, format)
250
- const existing = [...this.docs.values()].find((item) => item.fingerprint === fingerprint)
251
- if (existing) {
252
- if (input.docxSource) this.attachDocxSource(existing, input.docxSource, input.docxName)
253
- if (input.pptxSource) this.attachPptxSource(existing, input.pptxSource, input.pptxName)
254
- if (input.format) existing.format = String(input.format).slice(0, 24)
255
- if (formatMeta) existing.formatMeta = cleanFormatMeta({ ...(existing.formatMeta || {}), ...formatMeta })
256
- this.activeDocId = existing.id
257
- if (!this.openDocIds.includes(existing.id)) this.openDocIds.push(existing.id)
258
- this.settings.sourceLang = sourceLang
259
- this.settings.targetLang = targetLang
260
- this.touch()
261
- this.persist()
262
- return { document: this.documentSummary(existing), reused: true }
263
- }
264
-
265
- const doc = {
266
- id: makeId('doc'),
267
- name: cleanName(input.name, String(input.name || '').trim() || '未命名文档'),
268
- sourceLang,
269
- targetLang,
270
- segments,
271
- cursor: segments.findIndex((segment) => segment.status === 'pending'),
272
- createdAt,
273
- updatedAt: createdAt,
274
- fingerprint,
275
- format,
276
- ...(formatMeta ? { formatMeta } : {}),
277
- }
278
- if (doc.cursor < 0) doc.cursor = 0
279
- if (input.docxSource) this.attachDocxSource(doc, input.docxSource, input.docxName)
280
- if (input.pptxSource) this.attachPptxSource(doc, input.pptxSource, input.pptxName)
281
-
282
- this.docs.set(doc.id, doc)
283
- this.activeDocId = doc.id
284
- if (!this.openDocIds.includes(doc.id)) this.openDocIds.push(doc.id)
285
- this.settings.sourceLang = sourceLang
286
- this.settings.targetLang = targetLang
287
-
288
- for (const segment of segments) {
289
- if (segment.target) this.upsertTM(segment.source, segment.target, sourceLang, targetLang)
290
- }
291
- this.touch()
292
- this.persist()
293
-
294
- return { document: this.documentSummary(doc) }
295
- }
296
-
297
- deleteDocument({ documentId } = {}) {
298
- const doc = this.requireDocument(documentId)
299
- const tabIndex = this.openDocIds.indexOf(doc.id)
300
- this.removeDocxSource(doc)
301
- this.removePptxSource(doc)
302
- this.docs.delete(doc.id)
303
- this.openDocIds = this.openDocIds.filter((id) => id !== doc.id)
304
- if (this.activeDocId === doc.id) {
305
- const nextId = this.openDocIds[Math.min(tabIndex, this.openDocIds.length - 1)]
306
- this.activeDocId = nextId && this.docs.has(nextId) ? nextId : null
307
- }
308
- this.touch()
309
- this.persist()
310
- return { deleted: doc.id }
311
- }
312
-
313
- openDocument({ documentId } = {}) {
314
- const doc = this.requireDocument(documentId)
315
- this.activeDocId = doc.id
316
- if (!this.openDocIds.includes(doc.id)) this.openDocIds.push(doc.id)
317
- this.settings.sourceLang = doc.sourceLang
318
- this.settings.targetLang = doc.targetLang
319
- this.touch()
320
- this.persist()
321
- return { document: this.documentSummary(doc) }
322
- }
323
-
324
- closeDocument({ documentId } = {}) {
325
- const doc = this.requireDocument(documentId)
326
- const tabIndex = this.openDocIds.indexOf(doc.id)
327
- this.openDocIds = this.openDocIds.filter((id) => id !== doc.id)
328
- if (this.activeDocId === doc.id) {
329
- const nextId = this.openDocIds[Math.min(tabIndex, this.openDocIds.length - 1)]
330
- this.activeDocId = nextId && this.docs.has(nextId) ? nextId : null
331
- }
332
- this.touch()
333
- this.persist()
334
- return { closed: doc.id }
335
- }
336
-
337
- selectSegment({ documentId, index } = {}) {
338
- const doc = this.requireDocument(documentId)
339
- const target = Number(index ?? doc.cursor)
340
- this.findSegment(doc, target)
341
- doc.cursor = target
342
- doc.updatedAt = now()
343
- this.touch()
344
- return { cursor: doc.cursor, current: this.segmentDetail(doc, doc.cursor) }
345
- }
346
-
347
- saveDraft({ documentId, index, target } = {}) {
348
- const doc = this.requireDocument(documentId)
349
- const segment = this.findSegment(doc, Number(index ?? doc.cursor))
350
- const value = cleanText(target, '译文')
351
- segment.target = value
352
- segment.updatedAt = now()
353
- doc.updatedAt = segment.updatedAt
354
- this.touch()
355
- return {
356
- segment: this.segmentView(segment, doc.segments.indexOf(segment)),
357
- cursor: doc.cursor,
358
- }
359
- }
360
-
361
- confirmSegment({ documentId, index, target, addToTm = true, targetTmId } = {}) {
362
- const doc = this.requireDocument(documentId)
363
- const segmentIndex = Number(index ?? doc.cursor)
364
- const segment = this.findSegment(doc, segmentIndex)
365
- const writeTmId = addToTm ? this.requireMountedTmLibrary(targetTmId) : null
366
- const before = {
367
- revision: this.revision,
368
- cursor: doc.cursor,
369
- updatedAt: doc.updatedAt,
370
- segment: {
371
- target: segment.target,
372
- status: segment.status,
373
- confirmedAt: segment.confirmedAt,
374
- updatedAt: segment.updatedAt,
375
- },
376
- tm: addToTm ? this.tm.map((entry) => ({ ...entry })) : null,
377
- }
378
-
379
- if (target !== undefined) {
380
- segment.target = cleanText(target, '译文')
381
- }
382
- if (!segment.target) {
383
- throw new CatError('EMPTY_TARGET', '译文为空,无法确认句段。')
384
- }
385
- const missingInline = missingInlineTokens(segment.meta?.inline, segment.target)
386
- if (missingInline.length) {
387
- throw new CatError(
388
- 'PLACEHOLDER_MISMATCH',
389
- `译文缺少占位符 ${missingInline.join('、')},请保留源文中的占位符后再确认。`,
390
- 400,
391
- )
392
- }
393
-
394
- segment.status = 'confirmed'
395
- segment.confirmedAt = now()
396
- segment.updatedAt = segment.confirmedAt
397
- doc.updatedAt = segment.confirmedAt
398
-
399
- const tmResult = addToTm
400
- ? this.upsertTM(segment.source, segment.target, doc.sourceLang, doc.targetLang, writeTmId)
401
- : null
402
-
403
- const nextIndex = this.findNextUnconfirmed(doc, segmentIndex)
404
- doc.cursor = nextIndex
405
- this.touch()
406
- try {
407
- this.persist({ strict: true })
408
- } catch (error) {
409
- if (before.tm) this.tm = before.tm
410
- this.revision = before.revision
411
- doc.cursor = before.cursor
412
- doc.updatedAt = before.updatedAt
413
- Object.assign(segment, before.segment)
414
- throw error
415
- }
416
-
417
- const progress = this.progress(doc)
418
- return {
419
- confirmed: this.segmentView(segment, segmentIndex),
420
- next: progress.pending ? this.segmentDetail(doc, doc.cursor) : null,
421
- cursor: doc.cursor,
422
- progress,
423
- done: progress.pending === 0,
424
- tm: tmResult
425
- ? {
426
- added: tmResult.created ? 1 : 0,
427
- updated: tmResult.created ? 0 : 1,
428
- count: this.mountedTmCount(),
429
- total: this.tm.length,
430
- tmId: tmResult.entry.tmId,
431
- }
432
- : null,
433
- }
434
- }
435
-
436
- findNextUnconfirmed(doc, fromIndex) {
437
- const total = doc.segments.length
438
- for (let step = 1; step <= total; step += 1) {
439
- const index = (fromIndex + step) % total
440
- if (doc.segments[index].status !== 'confirmed') return index
441
- }
442
- return fromIndex
443
- }
444
-
445
- moveSegment({ documentId, delta = 1 } = {}) {
446
- const doc = this.requireDocument(documentId)
447
- const total = doc.segments.length
448
- const amount = Number.isFinite(Number(delta)) ? Math.trunc(Number(delta)) : 1
449
- doc.cursor = ((doc.cursor + amount) % total + total) % total
450
- doc.updatedAt = now()
451
- this.touch()
452
- return { cursor: doc.cursor, current: this.segmentDetail(doc, doc.cursor) }
453
- }
454
-
455
- saveSettings(patch = {}) {
456
- if (patch.sourceLang !== undefined) {
457
- this.settings.sourceLang = cleanLang(patch.sourceLang, this.settings.sourceLang)
458
- }
459
- if (patch.targetLang !== undefined) {
460
- this.settings.targetLang = cleanLang(patch.targetLang, this.settings.targetLang)
461
- }
462
- if (patch.fuzzyThreshold !== undefined) {
463
- this.settings.fuzzyThreshold = clamp(Number(patch.fuzzyThreshold), 0, 1)
464
- }
465
- if (patch.tmLimit !== undefined) {
466
- this.settings.tmLimit = clampInt(patch.tmLimit, 1, 20, this.settings.tmLimit)
467
- }
468
- if (patch.uiLang !== undefined) {
469
- const uiLang = String(patch.uiLang ?? '').trim().toLowerCase()
470
- if (!UI_LANG_CODES.includes(uiLang)) {
471
- throw new CatError('INVALID_UI_LANG', '界面语言无效,可选 auto / zh / en / ja。')
472
- }
473
- this.settings.uiLang = uiLang
474
- }
475
-
476
- if (patch.mt && typeof patch.mt === 'object') {
477
- const mt = patch.mt
478
- if (mt.provider !== undefined) {
479
- const provider = String(mt.provider ?? '').trim()
480
- if (!provider) throw new CatError('INVALID_PROVIDER', '请选择 MT provider 路由。')
481
- this.settings.mt.provider = provider.slice(0, 120)
482
- }
483
- if (mt.model !== undefined) {
484
- this.settings.mt.model = String(mt.model ?? '').trim().slice(0, 200)
485
- }
486
- if (mt.temperature !== undefined) {
487
- this.settings.mt.temperature = clamp(Number(mt.temperature), 0, 2)
488
- }
489
- }
490
-
491
- this.touch()
492
- const storage = this.persist()
493
- return { settings: this.publicSettings(), storage }
494
- }
495
-
496
- mockSuggestion(sourceText, hits, started, note) {
497
- let output = sourceText
498
- for (const hit of hits) {
499
- output = output.replace(new RegExp(escapeRegExp(hit.term), 'gi'), hit.target)
500
- }
501
- if (output === sourceText) output = `【机器翻译示例 · 术语替换】${sourceText}`
502
- return {
503
- text: output,
504
- provider: MOCK_PROVIDER,
505
- model: MOCK_MODEL,
506
- latencyMs: Date.now() - started,
507
- note,
508
- }
509
- }
510
-
511
- async mtSuggest({ text, documentId, index, sourceLang, targetLang } = {}) {
512
- let doc = null
513
- if (documentId !== undefined && documentId !== null) {
514
- if (typeof documentId !== 'string' || !documentId.trim()) {
515
- throw new CatError('DOCUMENT_NOT_FOUND', '文档不存在。', 404)
516
- }
517
- doc = this.docs.get(documentId)
518
- if (!doc) throw new CatError('DOCUMENT_NOT_FOUND', `文档不存在: ${documentId}`, 404)
519
- } else {
520
- doc = this.activeDocument()
521
- }
522
-
523
- let segment = null
524
- if (index !== undefined && index !== null) {
525
- if (!doc) throw new CatError('NO_DOCUMENT', '没有活动文档,无法定位句段。', 409)
526
- if (!Number.isInteger(index)) {
527
- throw new CatError('SEGMENT_OUT_OF_RANGE', `句段索引不是整数: ${index}`)
528
- }
529
- segment = this.findSegment(doc, index)
530
- }
531
-
532
- const fallbackSource = segment?.source ?? (doc ? doc.segments[doc.cursor]?.source : undefined)
533
- if (text === undefined && fallbackSource === undefined) {
534
- throw new CatError('NO_DOCUMENT', '没有活动文档,无法生成译文建议。', 409)
535
- }
536
- const sourceText = cleanText(text === undefined ? fallbackSource : text, '原文')
537
- if (!sourceText) throw new CatError('EMPTY_SOURCE', '没有可翻译的原文。')
538
-
539
- const src = canonicalLanguage(sourceLang || doc?.sourceLang || this.settings.sourceLang)
540
- const tgt = canonicalLanguage(targetLang || doc?.targetLang || this.settings.targetLang)
541
- const started = Date.now()
542
- const provider = this.settings.mt.provider
543
- const hits = this.findGlossaryHits(sourceText, src)
544
-
545
- let routes
546
- try {
547
- routes = await this.listMtRoutes()
548
- } catch {
549
- routes = { providers: [], fallback: true, message: NO_ROUTE_MESSAGE }
550
- }
551
- const providers = Array.isArray(routes?.providers) ? routes.providers : []
552
- const selectedRoute = providers.find((route) => route.id === provider)
553
-
554
- if (provider === MOCK_PROVIDER) {
555
- return this.mockSuggestion(
556
- sourceText,
557
- hits,
558
- started,
559
- providers.length ? '术语替换(离线):当前未调用 DSH 模型。' : NO_ROUTE_MESSAGE,
560
- )
561
- }
562
- if (!selectedRoute) {
563
- return this.mockSuggestion(sourceText, hits, started, NO_ROUTE_MESSAGE)
564
- }
565
-
566
- const models = Array.isArray(selectedRoute.models) ? selectedRoute.models : []
567
- const model = models.some((item) => item.id === this.settings.mt.model)
568
- ? this.settings.mt.model
569
- : models[0]?.id
570
- if (!model) {
571
- return this.mockSuggestion(sourceText, hits, started, NO_MODEL_MESSAGE)
572
- }
573
-
574
- const glossaryRule = hits.length
575
- ? `\nUse this terminology consistently: ${hits.map((hit) => `${hit.term} => ${hit.target}`).join('; ')}.`
576
- : ''
577
- const systemText =
578
- 'You are a professional translator. Preserve meaning, numbers, markup and tone. Return only the translation, without quotes or explanations.'
579
- const messages = [
580
- {
581
- role: 'user',
582
- content: [
583
- {
584
- type: 'text',
585
- text: `Translate from ${src} to ${tgt}.${glossaryRule}\n\n${sourceText}`,
586
- },
587
- ],
588
- },
589
- ]
590
-
591
- let output
592
- try {
593
- const stream = await this.mtRuntime.stream({
594
- provider,
595
- model,
596
- messages,
597
- system: systemText,
598
- temperature: this.settings.mt.temperature,
599
- purpose: 'cait-mt',
600
- signal: AbortSignal.timeout(45000),
601
- })
602
- output = await collectStreamText(stream)
603
- if (!output) {
604
- const empty = new Error('DSH 模型路由没有返回译文')
605
- empty.code = 'MT_EMPTY'
606
- throw empty
607
- }
608
- } catch (error) {
609
- this.logger?.warn?.(
610
- `CAIT: DSH MT 路由调用失败 provider=${provider} model=${model} code=${
611
- error?.code || 'UNKNOWN'
612
- } status=${error?.status ?? '-'}: ${error?.message || error}`,
613
- )
614
- throw new CatError(
615
- 'MT_PROVIDER_FAILED',
616
- '机器翻译调用失败,请检查 DSH 模型路由配置。',
617
- 502,
618
- )
619
- }
620
-
621
- return {
622
- text: output,
623
- provider,
624
- model,
625
- latencyMs: Date.now() - started,
626
- }
627
- }
628
-
629
- getSegmentForTool({ documentId } = {}) {
630
- const doc = this.requireDocument(documentId)
631
- const detail = this.segmentDetail(doc, doc.cursor)
632
- const progress = this.progress(doc)
633
- return {
634
- document: {
635
- id: doc.id,
636
- name: doc.name,
637
- sourceLang: doc.sourceLang,
638
- targetLang: doc.targetLang,
639
- total: progress.total,
640
- cursor: doc.cursor,
641
- },
642
- segment: {
643
- id: detail.id,
644
- index: detail.index,
645
- source: detail.source,
646
- target: detail.target,
647
- status: detail.status,
648
- },
649
- progress,
650
- tmMatches: detail.tmMatches,
651
- glossaryHits: detail.glossaryHits,
652
- done: progress.pending === 0,
653
- }
654
- }
655
-
656
- submitTranslationForTool({ documentId, index, target, addToTm = true, targetTmId } = {}) {
657
- const result = this.confirmSegment({ documentId, index, target, addToTm, targetTmId })
658
- const doc = this.requireDocument(documentId)
659
- return {
660
- document: {
661
- id: doc.id,
662
- name: doc.name,
663
- sourceLang: doc.sourceLang,
664
- targetLang: doc.targetLang,
665
- total: doc.segments.length,
666
- cursor: doc.cursor,
667
- },
668
- confirmed: {
669
- index: result.confirmed.index,
670
- source: result.confirmed.source,
671
- target: result.confirmed.target,
672
- status: result.confirmed.status,
673
- },
674
- next: result.next
675
- ? {
676
- index: result.next.index,
677
- source: result.next.source,
678
- target: result.next.target,
679
- status: result.next.status,
680
- }
681
- : null,
682
- progress: result.progress,
683
- done: result.done,
684
- tm: {
685
- ...result.tm,
686
- count: result.tm?.count ?? this.mountedTmCount(),
687
- total: this.tm.length,
688
- },
689
- }
690
- }
691
-
692
- loadDemo() {
693
- const text = [
694
- 'Computer-aided translation tools help translators work faster and more consistently. A translation memory stores previously translated sentences, so repetitive passages can be reused with a single click.',
695
- 'Terminology databases keep product names and technical terms consistent across documents. Every confirmed segment can be written back to the translation memory, building a reusable knowledge base for the next project.',
696
- 'A good CAT workbench brings the source text, the target editor, translation memory matches, glossary hits, and machine translation suggestions into one focused workspace.',
697
- ].join('\n\n')
698
-
699
- const created = this.createDocument({
700
- name: 'CAIT 演示 · 产品简介',
701
- text,
702
- sourceLang: 'en',
703
- targetLang: 'zh-CN',
704
- })
705
-
706
- const terms = [
707
- ['computer-aided translation', '计算机辅助翻译', 'CAT 的全称'],
708
- ['translation memory', '翻译记忆库', '存储已翻译句段'],
709
- ['terminology database', '术语库', '维护术语一致性'],
710
- ['segment', '句段', 'CAT 的基本翻译单位'],
711
- ['workspace', '工作台', '译者的操作界面'],
712
- ]
713
- for (const [source, target, note] of terms) {
714
- if (!this.glossary.some((entry) => normalizeText(entry.source) === normalizeText(source))) {
715
- this.addGlossary({ source, target, note, sourceLang: 'en' })
716
- }
717
- }
718
-
719
- const memories = [
720
- ['A translation memory stores previously translated sentences.', '翻译记忆库存储以前翻译过的句子。'],
721
- ['Computer-aided translation tools help translators work faster and more consistently.', '计算机辅助翻译工具帮助译者更快、更一致地工作。'],
722
- ['Terminology databases keep product names and technical terms consistent.', '术语库让产品名称和技术术语保持一致。'],
723
- ]
724
- for (const [source, target] of memories) {
725
- this.upsertTM(source, target, 'en', 'zh-CN')
726
- }
727
- this.touch()
728
- this.persist()
729
-
730
- return created
731
- }
732
- }
733
-
734
- Object.assign(CatStore.prototype, libraryMethods, tmMethods, glossaryMethods, persistenceMethods, importExportMethods)
1
+ import { CatError } from './errors.js'
2
+ import { createRequire } from 'node:module'
3
+ import { join, resolve } from 'node:path'
4
+ import { splitIntoSegments } from './segmenter.js'
5
+ import { canonicalLanguage, cleanFormatMeta, cleanLang, cleanName, cleanSegmentMeta, cleanText, clamp, clampInt, countWords, documentFingerprint, DEFAULT_SETTINGS, DEFAULT_GLOSSARY_ID, DEFAULT_GLOSSARY_NAME, DEFAULT_TM_ID, DEFAULT_TM_NAME, escapeRegExp, MAX_IMPORT_CHARS, MAX_SEGMENTS, MAX_SEGMENT_CHARS, now, makeId, parseGlossaryCsv, restoredDate, restoredLang, restoredText, restoreSettings, safeBaseName, STORE_FILENAME, UI_LANG_CODES } from './store-utils.js'
6
+ import { libraryMethods } from './libraries.js'
7
+ import { tmMethods } from './tm.js'
8
+ import { glossaryMethods } from './glossary.js'
9
+ import { persistenceMethods } from './persistence.js'
10
+ import { importExportMethods } from './import-export.js'
11
+ import { classifyMatchType, normalizeSource, normalizeText, similarity } from './similarity.js'
12
+ import { MOCK_MODEL, MOCK_PROVIDER, NO_MODEL_MESSAGE, NO_ROUTE_MESSAGE, collectStreamText } from './mt.js'
13
+ import { buildTmx } from './tmx.js'
14
+ import { missingInlineTokens } from './formats.js'
15
+
16
+ export { CatError } from './errors.js'
17
+ export { canonicalLanguage } from './store-utils.js'
18
+
19
+ export class CatStore {
20
+
21
+ constructor({ dataDir = '', logger = console, mtRuntime = null } = {}) {
22
+ this.docs = new Map()
23
+ this.activeDocId = null
24
+ this.openDocIds = []
25
+ this.tm = []
26
+ this.glossary = []
27
+ this.settings = structuredClone(DEFAULT_SETTINGS)
28
+ this.revision = 0
29
+ this.logger = logger
30
+ this.mtRuntime = mtRuntime
31
+ this.resetLibraryState()
32
+ this.dataDir = String(dataDir ?? '').trim() ? resolve(String(dataDir)) : ''
33
+ this.storageFile = this.dataDir ? join(this.dataDir, STORE_FILENAME) : ''
34
+ this.persistence = {
35
+ enabled: Boolean(this.storageFile),
36
+ file: this.storageFile || null,
37
+ loaded: false,
38
+ lastSavedAt: null,
39
+ error: null,
40
+ corruptBackup: null,
41
+ }
42
+ if (this.storageFile) this.load()
43
+ }
44
+
45
+ touch() {
46
+ this.revision += 1
47
+ }
48
+
49
+ activeDocument() {
50
+ return this.activeDocId ? this.docs.get(this.activeDocId) ?? null : null
51
+ }
52
+
53
+ requireDocument(documentId) {
54
+ const doc = documentId ? this.docs.get(documentId) : this.activeDocument()
55
+ if (!doc) throw new CatError('NO_DOCUMENT', '没有活动文档,请先在 CAIT 工作台创建或导入文档。', 409)
56
+ return doc
57
+ }
58
+
59
+ findSegment(doc, index) {
60
+ if (!Number.isInteger(index) || index < 0 || index >= doc.segments.length) {
61
+ throw new CatError('SEGMENT_OUT_OF_RANGE', `句段索引超出范围: ${index}`)
62
+ }
63
+ return doc.segments[index]
64
+ }
65
+
66
+ publicSettings() {
67
+ return {
68
+ ...this.settings,
69
+ mt: {
70
+ provider: this.settings.mt.provider,
71
+ model: this.settings.mt.model,
72
+ temperature: this.settings.mt.temperature,
73
+ },
74
+ }
75
+ }
76
+
77
+ async listMtRoutes(options = {}) {
78
+ if (this.mtRuntime && typeof this.mtRuntime.listRoutes === 'function') {
79
+ return this.mtRuntime.listRoutes(options)
80
+ }
81
+ return { providers: [], fallback: true, message: NO_ROUTE_MESSAGE }
82
+ }
83
+
84
+ progress(doc) {
85
+ const total = doc.segments.length
86
+ const confirmed = doc.segments.filter((segment) => segment.status === 'confirmed').length
87
+ return {
88
+ total,
89
+ confirmed,
90
+ pending: total - confirmed,
91
+ percent: total ? Math.round((confirmed / total) * 100) : 0,
92
+ }
93
+ }
94
+
95
+ documentSummary(doc) {
96
+ return {
97
+ id: doc.id,
98
+ name: doc.name,
99
+ sourceLang: doc.sourceLang,
100
+ targetLang: doc.targetLang,
101
+ cursor: doc.cursor,
102
+ createdAt: doc.createdAt,
103
+ updatedAt: doc.updatedAt,
104
+ progress: this.progress(doc),
105
+ }
106
+ }
107
+
108
+ segmentView(segment, index) {
109
+ return {
110
+ id: segment.id,
111
+ index,
112
+ source: segment.source,
113
+ target: segment.target,
114
+ status: segment.status,
115
+ paragraph: segment.paragraph,
116
+ confirmedAt: segment.confirmedAt,
117
+ }
118
+ }
119
+
120
+ segmentDetail(doc, index) {
121
+ const segment = this.findSegment(doc, index)
122
+ return {
123
+ ...this.segmentView(segment, index),
124
+ chars: Array.from(segment.source).length,
125
+ words: countWords(segment.source),
126
+ tmMatches: this.lookupTM({
127
+ query: segment.source,
128
+ sourceLang: doc.sourceLang,
129
+ targetLang: doc.targetLang,
130
+ }).matches,
131
+ glossaryHits: this.findGlossaryHits(segment.source, doc.sourceLang),
132
+ }
133
+ }
134
+
135
+ documentView(doc) {
136
+ return {
137
+ ...this.documentSummary(doc),
138
+ segments: doc.segments.map((segment, index) => this.segmentView(segment, index)),
139
+ current: this.segmentDetail(doc, doc.cursor),
140
+ }
141
+ }
142
+
143
+ bootstrap() {
144
+ const doc = this.activeDocument()
145
+ const mountedTm = new Set(this.mounts.tmIds)
146
+ const mountedGlossary = new Set(this.mounts.glossaryIds)
147
+ const mountedTmEntries = this.tm
148
+ .filter((entry) => mountedTm.has(entry.tmId || DEFAULT_TM_ID))
149
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
150
+ const tmEntries = mountedTmEntries.slice(0, 40).map((entry) => ({
151
+ id: entry.id,
152
+ tmId: entry.tmId || DEFAULT_TM_ID,
153
+ source: entry.source,
154
+ target: entry.target,
155
+ sourceLang: entry.sourceLang,
156
+ targetLang: entry.targetLang,
157
+ updatedAt: entry.updatedAt,
158
+ }))
159
+ const glossary = this.glossary.filter((entry) =>
160
+ mountedGlossary.has(entry.glossaryId || DEFAULT_GLOSSARY_ID),
161
+ )
162
+
163
+ return {
164
+ revision: this.revision,
165
+ settings: this.publicSettings(),
166
+ storage: {
167
+ enabled: this.persistence.enabled,
168
+ file: this.persistence.file,
169
+ loaded: this.persistence.loaded,
170
+ lastSavedAt: this.persistence.lastSavedAt,
171
+ error: this.persistence.error,
172
+ },
173
+ stats: {
174
+ documents: this.docs.size,
175
+ segments: [...this.docs.values()].reduce((sum, item) => sum + item.segments.length, 0),
176
+ confirmed: [...this.docs.values()].reduce(
177
+ (sum, item) => sum + item.segments.filter((segment) => segment.status === 'confirmed').length,
178
+ 0,
179
+ ),
180
+ tm: this.tm.length,
181
+ glossary: this.glossary.length,
182
+ },
183
+ documents: [...this.docs.values()].map((item) => this.documentSummary(item)),
184
+ openDocuments: this.openDocIds
185
+ .map((id) => this.docs.get(id))
186
+ .filter(Boolean)
187
+ .map((item) => this.documentSummary(item)),
188
+ activeDocument: doc ? this.documentView(doc) : null,
189
+ libraries: this.librariesView(),
190
+ mounts: this.mountsView(),
191
+ tm: {
192
+ count: mountedTmEntries.length,
193
+ total: this.tm.length,
194
+ entries: tmEntries,
195
+ },
196
+ glossary: glossary.map((entry) => ({ ...entry })),
197
+ }
198
+ }
199
+
200
+ createDocument(input = {}) {
201
+ const sourceLang = cleanLang(input.sourceLang || this.settings.sourceLang, this.settings.sourceLang)
202
+ const targetLang = cleanLang(input.targetLang || this.settings.targetLang, this.settings.targetLang)
203
+ let rawSegments = []
204
+
205
+ if (Array.isArray(input.segments) && input.segments.length) {
206
+ rawSegments = input.segments.map((segment) =>
207
+ typeof segment === 'string' ? { source: segment } : segment,
208
+ )
209
+ } else {
210
+ const text = String(input.text ?? '')
211
+ if (!text.trim()) throw new CatError('EMPTY_SOURCE', '源文本为空,无法创建文档。')
212
+ if (text.length > MAX_IMPORT_CHARS) {
213
+ throw new CatError('SOURCE_TOO_LARGE', `源文本超过 ${MAX_IMPORT_CHARS} 字符上限`)
214
+ }
215
+ rawSegments = splitIntoSegments(text)
216
+ }
217
+
218
+ if (!rawSegments.length) throw new CatError('EMPTY_SOURCE', '没有可翻译的句段。')
219
+ if (rawSegments.length > MAX_SEGMENTS) {
220
+ throw new CatError('TOO_MANY_SEGMENTS', `句段数量超过 ${MAX_SEGMENTS} 上限`)
221
+ }
222
+
223
+ const createdAt = now()
224
+ const paragraphMap = Array.isArray(input.paragraphMap) ? input.paragraphMap : null
225
+ const segments = rawSegments.map((raw, index) => {
226
+ const source = cleanText(raw.source, '原文')
227
+ const target = cleanText(raw.target, '译文')
228
+ if (!source) throw new CatError('EMPTY_SEGMENT', `第 ${index + 1} 句原文为空`)
229
+ const paragraph = paragraphMap && Number.isInteger(paragraphMap[raw.paragraph])
230
+ ? paragraphMap[raw.paragraph]
231
+ : Number.isInteger(raw.paragraph)
232
+ ? raw.paragraph
233
+ : index
234
+ const meta = cleanSegmentMeta(raw.meta)
235
+ return {
236
+ id: `s${index + 1}`,
237
+ source,
238
+ target,
239
+ status: target ? 'confirmed' : 'pending',
240
+ paragraph,
241
+ ...(meta ? { meta } : {}),
242
+ confirmedAt: target ? createdAt : null,
243
+ updatedAt: createdAt,
244
+ }
245
+ })
246
+
247
+ const format = typeof input.format === 'string' ? input.format.slice(0, 24) : ''
248
+ const formatMeta = cleanFormatMeta(input.formatMeta)
249
+ const fingerprint = documentFingerprint(sourceLang, targetLang, segments, format)
250
+ const existing = [...this.docs.values()].find((item) => item.fingerprint === fingerprint)
251
+ if (existing) {
252
+ if (input.docxSource) this.attachDocxSource(existing, input.docxSource, input.docxName)
253
+ if (input.pptxSource) this.attachPptxSource(existing, input.pptxSource, input.pptxName)
254
+ if (input.format) existing.format = String(input.format).slice(0, 24)
255
+ if (formatMeta) existing.formatMeta = cleanFormatMeta({ ...(existing.formatMeta || {}), ...formatMeta })
256
+ this.activeDocId = existing.id
257
+ if (!this.openDocIds.includes(existing.id)) this.openDocIds.push(existing.id)
258
+ this.settings.sourceLang = sourceLang
259
+ this.settings.targetLang = targetLang
260
+ this.touch()
261
+ this.persist()
262
+ return { document: this.documentSummary(existing), reused: true }
263
+ }
264
+
265
+ const doc = {
266
+ id: makeId('doc'),
267
+ name: cleanName(input.name, String(input.name || '').trim() || '未命名文档'),
268
+ sourceLang,
269
+ targetLang,
270
+ segments,
271
+ cursor: segments.findIndex((segment) => segment.status === 'pending'),
272
+ createdAt,
273
+ updatedAt: createdAt,
274
+ fingerprint,
275
+ format,
276
+ ...(formatMeta ? { formatMeta } : {}),
277
+ }
278
+ if (doc.cursor < 0) doc.cursor = 0
279
+ if (input.docxSource) this.attachDocxSource(doc, input.docxSource, input.docxName)
280
+ if (input.pptxSource) this.attachPptxSource(doc, input.pptxSource, input.pptxName)
281
+
282
+ this.docs.set(doc.id, doc)
283
+ this.activeDocId = doc.id
284
+ if (!this.openDocIds.includes(doc.id)) this.openDocIds.push(doc.id)
285
+ this.settings.sourceLang = sourceLang
286
+ this.settings.targetLang = targetLang
287
+
288
+ for (const segment of segments) {
289
+ if (segment.target) this.upsertTM(segment.source, segment.target, sourceLang, targetLang)
290
+ }
291
+ this.touch()
292
+ this.persist()
293
+
294
+ return { document: this.documentSummary(doc) }
295
+ }
296
+
297
+ deleteDocument({ documentId } = {}) {
298
+ const doc = this.requireDocument(documentId)
299
+ const tabIndex = this.openDocIds.indexOf(doc.id)
300
+ this.removeDocxSource(doc)
301
+ this.removePptxSource(doc)
302
+ this.docs.delete(doc.id)
303
+ this.openDocIds = this.openDocIds.filter((id) => id !== doc.id)
304
+ if (this.activeDocId === doc.id) {
305
+ const nextId = this.openDocIds[Math.min(tabIndex, this.openDocIds.length - 1)]
306
+ this.activeDocId = nextId && this.docs.has(nextId) ? nextId : null
307
+ }
308
+ this.touch()
309
+ this.persist()
310
+ return { deleted: doc.id }
311
+ }
312
+
313
+ openDocument({ documentId } = {}) {
314
+ const doc = this.requireDocument(documentId)
315
+ this.activeDocId = doc.id
316
+ if (!this.openDocIds.includes(doc.id)) this.openDocIds.push(doc.id)
317
+ this.settings.sourceLang = doc.sourceLang
318
+ this.settings.targetLang = doc.targetLang
319
+ this.touch()
320
+ this.persist()
321
+ return { document: this.documentSummary(doc) }
322
+ }
323
+
324
+ closeDocument({ documentId } = {}) {
325
+ const doc = this.requireDocument(documentId)
326
+ const tabIndex = this.openDocIds.indexOf(doc.id)
327
+ this.openDocIds = this.openDocIds.filter((id) => id !== doc.id)
328
+ if (this.activeDocId === doc.id) {
329
+ const nextId = this.openDocIds[Math.min(tabIndex, this.openDocIds.length - 1)]
330
+ this.activeDocId = nextId && this.docs.has(nextId) ? nextId : null
331
+ }
332
+ this.touch()
333
+ this.persist()
334
+ return { closed: doc.id }
335
+ }
336
+
337
+ selectSegment({ documentId, index } = {}) {
338
+ const doc = this.requireDocument(documentId)
339
+ const target = Number(index ?? doc.cursor)
340
+ this.findSegment(doc, target)
341
+ doc.cursor = target
342
+ doc.updatedAt = now()
343
+ this.touch()
344
+ return { cursor: doc.cursor, current: this.segmentDetail(doc, doc.cursor) }
345
+ }
346
+
347
+ saveDraft({ documentId, index, target } = {}) {
348
+ const doc = this.requireDocument(documentId)
349
+ const segment = this.findSegment(doc, Number(index ?? doc.cursor))
350
+ const value = cleanText(target, '译文')
351
+ segment.target = value
352
+ segment.updatedAt = now()
353
+ doc.updatedAt = segment.updatedAt
354
+ this.touch()
355
+ return {
356
+ segment: this.segmentView(segment, doc.segments.indexOf(segment)),
357
+ cursor: doc.cursor,
358
+ }
359
+ }
360
+
361
+ confirmSegment({ documentId, index, target, addToTm = true, targetTmId } = {}) {
362
+ const doc = this.requireDocument(documentId)
363
+ const segmentIndex = Number(index ?? doc.cursor)
364
+ const segment = this.findSegment(doc, segmentIndex)
365
+ const writeTmId = addToTm ? this.requireMountedTmLibrary(targetTmId) : null
366
+ const before = {
367
+ revision: this.revision,
368
+ cursor: doc.cursor,
369
+ updatedAt: doc.updatedAt,
370
+ segment: {
371
+ target: segment.target,
372
+ status: segment.status,
373
+ confirmedAt: segment.confirmedAt,
374
+ updatedAt: segment.updatedAt,
375
+ },
376
+ tm: addToTm ? this.tm.map((entry) => ({ ...entry })) : null,
377
+ }
378
+
379
+ if (target !== undefined) {
380
+ segment.target = cleanText(target, '译文')
381
+ }
382
+ if (!segment.target) {
383
+ throw new CatError('EMPTY_TARGET', '译文为空,无法确认句段。')
384
+ }
385
+ const missingInline = missingInlineTokens(segment.meta?.inline, segment.target)
386
+ if (missingInline.length) {
387
+ throw new CatError(
388
+ 'PLACEHOLDER_MISMATCH',
389
+ `译文缺少占位符 ${missingInline.join('、')},请保留源文中的占位符后再确认。`,
390
+ 400,
391
+ )
392
+ }
393
+
394
+ segment.status = 'confirmed'
395
+ segment.confirmedAt = now()
396
+ segment.updatedAt = segment.confirmedAt
397
+ doc.updatedAt = segment.confirmedAt
398
+
399
+ const tmResult = addToTm
400
+ ? this.upsertTM(segment.source, segment.target, doc.sourceLang, doc.targetLang, writeTmId)
401
+ : null
402
+
403
+ const nextIndex = this.findNextUnconfirmed(doc, segmentIndex)
404
+ doc.cursor = nextIndex
405
+ this.touch()
406
+ try {
407
+ this.persist({ strict: true })
408
+ } catch (error) {
409
+ if (before.tm) this.tm = before.tm
410
+ this.revision = before.revision
411
+ doc.cursor = before.cursor
412
+ doc.updatedAt = before.updatedAt
413
+ Object.assign(segment, before.segment)
414
+ throw error
415
+ }
416
+
417
+ const progress = this.progress(doc)
418
+ return {
419
+ confirmed: this.segmentView(segment, segmentIndex),
420
+ next: progress.pending ? this.segmentDetail(doc, doc.cursor) : null,
421
+ cursor: doc.cursor,
422
+ progress,
423
+ done: progress.pending === 0,
424
+ tm: tmResult
425
+ ? {
426
+ added: tmResult.created ? 1 : 0,
427
+ updated: tmResult.created ? 0 : 1,
428
+ count: this.mountedTmCount(),
429
+ total: this.tm.length,
430
+ tmId: tmResult.entry.tmId,
431
+ }
432
+ : null,
433
+ }
434
+ }
435
+
436
+ findNextUnconfirmed(doc, fromIndex) {
437
+ const total = doc.segments.length
438
+ for (let step = 1; step <= total; step += 1) {
439
+ const index = (fromIndex + step) % total
440
+ if (doc.segments[index].status !== 'confirmed') return index
441
+ }
442
+ return fromIndex
443
+ }
444
+
445
+ moveSegment({ documentId, delta = 1 } = {}) {
446
+ const doc = this.requireDocument(documentId)
447
+ const total = doc.segments.length
448
+ const amount = Number.isFinite(Number(delta)) ? Math.trunc(Number(delta)) : 1
449
+ doc.cursor = ((doc.cursor + amount) % total + total) % total
450
+ doc.updatedAt = now()
451
+ this.touch()
452
+ return { cursor: doc.cursor, current: this.segmentDetail(doc, doc.cursor) }
453
+ }
454
+
455
+ saveSettings(patch = {}) {
456
+ if (patch.sourceLang !== undefined) {
457
+ this.settings.sourceLang = cleanLang(patch.sourceLang, this.settings.sourceLang)
458
+ }
459
+ if (patch.targetLang !== undefined) {
460
+ this.settings.targetLang = cleanLang(patch.targetLang, this.settings.targetLang)
461
+ }
462
+ if (patch.fuzzyThreshold !== undefined) {
463
+ this.settings.fuzzyThreshold = clamp(Number(patch.fuzzyThreshold), 0, 1)
464
+ }
465
+ if (patch.tmLimit !== undefined) {
466
+ this.settings.tmLimit = clampInt(patch.tmLimit, 1, 20, this.settings.tmLimit)
467
+ }
468
+ if (patch.uiLang !== undefined) {
469
+ const uiLang = String(patch.uiLang ?? '').trim().toLowerCase()
470
+ if (!UI_LANG_CODES.includes(uiLang)) {
471
+ throw new CatError('INVALID_UI_LANG', '界面语言无效,可选 auto / zh / en / ja。')
472
+ }
473
+ this.settings.uiLang = uiLang
474
+ }
475
+
476
+ if (patch.mt && typeof patch.mt === 'object') {
477
+ const mt = patch.mt
478
+ if (mt.provider !== undefined) {
479
+ const provider = String(mt.provider ?? '').trim()
480
+ if (!provider) throw new CatError('INVALID_PROVIDER', '请选择 MT provider 路由。')
481
+ this.settings.mt.provider = provider.slice(0, 120)
482
+ }
483
+ if (mt.model !== undefined) {
484
+ this.settings.mt.model = String(mt.model ?? '').trim().slice(0, 200)
485
+ }
486
+ if (mt.temperature !== undefined) {
487
+ this.settings.mt.temperature = clamp(Number(mt.temperature), 0, 2)
488
+ }
489
+ }
490
+
491
+ this.touch()
492
+ const storage = this.persist()
493
+ return { settings: this.publicSettings(), storage }
494
+ }
495
+
496
+ mockSuggestion(sourceText, hits, started, note) {
497
+ let output = sourceText
498
+ for (const hit of hits) {
499
+ output = output.replace(new RegExp(escapeRegExp(hit.term), 'gi'), hit.target)
500
+ }
501
+ if (output === sourceText) output = `【机器翻译示例 · 术语替换】${sourceText}`
502
+ return {
503
+ text: output,
504
+ provider: MOCK_PROVIDER,
505
+ model: MOCK_MODEL,
506
+ latencyMs: Date.now() - started,
507
+ note,
508
+ }
509
+ }
510
+
511
+ async mtSuggest({ text, documentId, index, sourceLang, targetLang } = {}) {
512
+ let doc = null
513
+ if (documentId !== undefined && documentId !== null) {
514
+ if (typeof documentId !== 'string' || !documentId.trim()) {
515
+ throw new CatError('DOCUMENT_NOT_FOUND', '文档不存在。', 404)
516
+ }
517
+ doc = this.docs.get(documentId)
518
+ if (!doc) throw new CatError('DOCUMENT_NOT_FOUND', `文档不存在: ${documentId}`, 404)
519
+ } else {
520
+ doc = this.activeDocument()
521
+ }
522
+
523
+ let segment = null
524
+ if (index !== undefined && index !== null) {
525
+ if (!doc) throw new CatError('NO_DOCUMENT', '没有活动文档,无法定位句段。', 409)
526
+ if (!Number.isInteger(index)) {
527
+ throw new CatError('SEGMENT_OUT_OF_RANGE', `句段索引不是整数: ${index}`)
528
+ }
529
+ segment = this.findSegment(doc, index)
530
+ }
531
+
532
+ const fallbackSource = segment?.source ?? (doc ? doc.segments[doc.cursor]?.source : undefined)
533
+ if (text === undefined && fallbackSource === undefined) {
534
+ throw new CatError('NO_DOCUMENT', '没有活动文档,无法生成译文建议。', 409)
535
+ }
536
+ const sourceText = cleanText(text === undefined ? fallbackSource : text, '原文')
537
+ if (!sourceText) throw new CatError('EMPTY_SOURCE', '没有可翻译的原文。')
538
+
539
+ const src = canonicalLanguage(sourceLang || doc?.sourceLang || this.settings.sourceLang)
540
+ const tgt = canonicalLanguage(targetLang || doc?.targetLang || this.settings.targetLang)
541
+ const started = Date.now()
542
+ const provider = this.settings.mt.provider
543
+ const hits = this.findGlossaryHits(sourceText, src)
544
+
545
+ let routes
546
+ try {
547
+ routes = await this.listMtRoutes()
548
+ } catch {
549
+ routes = { providers: [], fallback: true, message: NO_ROUTE_MESSAGE }
550
+ }
551
+ const providers = Array.isArray(routes?.providers) ? routes.providers : []
552
+ const selectedRoute = providers.find((route) => route.id === provider)
553
+
554
+ if (provider === MOCK_PROVIDER) {
555
+ return this.mockSuggestion(
556
+ sourceText,
557
+ hits,
558
+ started,
559
+ providers.length ? '术语替换(离线):当前未调用 DSH 模型。' : NO_ROUTE_MESSAGE,
560
+ )
561
+ }
562
+ if (!selectedRoute) {
563
+ return this.mockSuggestion(sourceText, hits, started, NO_ROUTE_MESSAGE)
564
+ }
565
+
566
+ const models = Array.isArray(selectedRoute.models) ? selectedRoute.models : []
567
+ const model = models.some((item) => item.id === this.settings.mt.model)
568
+ ? this.settings.mt.model
569
+ : models[0]?.id
570
+ if (!model) {
571
+ return this.mockSuggestion(sourceText, hits, started, NO_MODEL_MESSAGE)
572
+ }
573
+
574
+ const glossaryRule = hits.length
575
+ ? `\nUse this terminology consistently: ${hits.map((hit) => `${hit.term} => ${hit.target}`).join('; ')}.`
576
+ : ''
577
+ const systemText =
578
+ 'You are a professional translator. Preserve meaning, numbers, markup and tone. Return only the translation, without quotes or explanations.'
579
+ const messages = [
580
+ {
581
+ role: 'user',
582
+ content: [
583
+ {
584
+ type: 'text',
585
+ text: `Translate from ${src} to ${tgt}.${glossaryRule}\n\n${sourceText}`,
586
+ },
587
+ ],
588
+ },
589
+ ]
590
+
591
+ let output
592
+ try {
593
+ const stream = await this.mtRuntime.stream({
594
+ provider,
595
+ model,
596
+ messages,
597
+ system: systemText,
598
+ temperature: this.settings.mt.temperature,
599
+ purpose: 'cait-mt',
600
+ signal: AbortSignal.timeout(45000),
601
+ })
602
+ output = await collectStreamText(stream)
603
+ if (!output) {
604
+ const empty = new Error('DSH 模型路由没有返回译文')
605
+ empty.code = 'MT_EMPTY'
606
+ throw empty
607
+ }
608
+ } catch (error) {
609
+ this.logger?.warn?.(
610
+ `CAIT: DSH MT 路由调用失败 provider=${provider} model=${model} code=${
611
+ error?.code || 'UNKNOWN'
612
+ } status=${error?.status ?? '-'}: ${error?.message || error}`,
613
+ )
614
+ throw new CatError(
615
+ 'MT_PROVIDER_FAILED',
616
+ '机器翻译调用失败,请检查 DSH 模型路由配置。',
617
+ 502,
618
+ )
619
+ }
620
+
621
+ return {
622
+ text: output,
623
+ provider,
624
+ model,
625
+ latencyMs: Date.now() - started,
626
+ }
627
+ }
628
+
629
+ getSegmentForTool({ documentId } = {}) {
630
+ const doc = this.requireDocument(documentId)
631
+ const detail = this.segmentDetail(doc, doc.cursor)
632
+ const progress = this.progress(doc)
633
+ return {
634
+ document: {
635
+ id: doc.id,
636
+ name: doc.name,
637
+ sourceLang: doc.sourceLang,
638
+ targetLang: doc.targetLang,
639
+ total: progress.total,
640
+ cursor: doc.cursor,
641
+ },
642
+ segment: {
643
+ id: detail.id,
644
+ index: detail.index,
645
+ source: detail.source,
646
+ target: detail.target,
647
+ status: detail.status,
648
+ },
649
+ progress,
650
+ tmMatches: detail.tmMatches,
651
+ glossaryHits: detail.glossaryHits,
652
+ done: progress.pending === 0,
653
+ }
654
+ }
655
+
656
+ submitTranslationForTool({ documentId, index, target, addToTm = true, targetTmId } = {}) {
657
+ const result = this.confirmSegment({ documentId, index, target, addToTm, targetTmId })
658
+ const doc = this.requireDocument(documentId)
659
+ return {
660
+ document: {
661
+ id: doc.id,
662
+ name: doc.name,
663
+ sourceLang: doc.sourceLang,
664
+ targetLang: doc.targetLang,
665
+ total: doc.segments.length,
666
+ cursor: doc.cursor,
667
+ },
668
+ confirmed: {
669
+ index: result.confirmed.index,
670
+ source: result.confirmed.source,
671
+ target: result.confirmed.target,
672
+ status: result.confirmed.status,
673
+ },
674
+ next: result.next
675
+ ? {
676
+ index: result.next.index,
677
+ source: result.next.source,
678
+ target: result.next.target,
679
+ status: result.next.status,
680
+ }
681
+ : null,
682
+ progress: result.progress,
683
+ done: result.done,
684
+ tm: {
685
+ ...result.tm,
686
+ count: result.tm?.count ?? this.mountedTmCount(),
687
+ total: this.tm.length,
688
+ },
689
+ }
690
+ }
691
+
692
+ loadDemo() {
693
+ const text = [
694
+ 'Computer-aided translation tools help translators work faster and more consistently. A translation memory stores previously translated sentences, so repetitive passages can be reused with a single click.',
695
+ 'Terminology databases keep product names and technical terms consistent across documents. Every confirmed segment can be written back to the translation memory, building a reusable knowledge base for the next project.',
696
+ 'A good CAT workbench brings the source text, the target editor, translation memory matches, glossary hits, and machine translation suggestions into one focused workspace.',
697
+ ].join('\n\n')
698
+
699
+ const created = this.createDocument({
700
+ name: 'CAIT 演示 · 产品简介',
701
+ text,
702
+ sourceLang: 'en',
703
+ targetLang: 'zh-CN',
704
+ })
705
+
706
+ const terms = [
707
+ ['computer-aided translation', '计算机辅助翻译', 'CAT 的全称'],
708
+ ['translation memory', '翻译记忆库', '存储已翻译句段'],
709
+ ['terminology database', '术语库', '维护术语一致性'],
710
+ ['segment', '句段', 'CAT 的基本翻译单位'],
711
+ ['workspace', '工作台', '译者的操作界面'],
712
+ ]
713
+ for (const [source, target, note] of terms) {
714
+ if (!this.glossary.some((entry) => normalizeText(entry.source) === normalizeText(source))) {
715
+ this.addGlossary({ source, target, note, sourceLang: 'en' })
716
+ }
717
+ }
718
+
719
+ const memories = [
720
+ ['A translation memory stores previously translated sentences.', '翻译记忆库存储以前翻译过的句子。'],
721
+ ['Computer-aided translation tools help translators work faster and more consistently.', '计算机辅助翻译工具帮助译者更快、更一致地工作。'],
722
+ ['Terminology databases keep product names and technical terms consistent.', '术语库让产品名称和技术术语保持一致。'],
723
+ ]
724
+ for (const [source, target] of memories) {
725
+ this.upsertTM(source, target, 'en', 'zh-CN')
726
+ }
727
+ this.touch()
728
+ this.persist()
729
+
730
+ return created
731
+ }
732
+ }
733
+
734
+ Object.assign(CatStore.prototype, libraryMethods, tmMethods, glossaryMethods, persistenceMethods, importExportMethods)