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/CHANGELOG.md +367 -352
- package/LICENSE +201 -201
- package/README.ja.md +4 -3
- package/README.md +424 -423
- package/README.zh-CN.md +391 -390
- package/client/components.js +2377 -2377
- package/client/helpers.js +103 -103
- package/client/locales.js +730 -730
- package/client/rpc.js +93 -93
- package/client/styles.js +258 -258
- package/client.js +3556 -3556
- package/icon.svg +8 -8
- package/index.js +45 -45
- package/lib/api.js +223 -223
- package/lib/docx.js +389 -389
- package/lib/errors.js +8 -8
- package/lib/formats.js +917 -917
- package/lib/glossary.js +178 -178
- package/lib/import-export.js +605 -605
- package/lib/libraries.js +183 -183
- package/lib/markdown.js +28 -28
- package/lib/mt.js +152 -152
- package/lib/persistence.js +321 -321
- package/lib/pptx.js +89 -89
- package/lib/segmenter.js +84 -84
- package/lib/similarity.js +113 -113
- package/lib/store-utils.js +339 -339
- package/lib/store.js +734 -734
- package/lib/tm.js +160 -160
- package/lib/tmx.js +85 -85
- package/lib/tools.js +472 -472
- package/lib/xlsx.js +218 -218
- package/lib/xml.js +31 -31
- package/locale/en.json +11 -11
- package/locale/zh.json +11 -11
- package/package.json +83 -83
- package/scripts/build-client.mjs +13 -13
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)
|