@goodandready/dsh-russian-lang 0.3.0 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -5
- package/README.ru.md +20 -5
- package/bin/dsh-i18n.mjs +247 -0
- package/lib/client.js +2932 -2360
- package/lib/index.js +89 -15
- package/lib/locales/core.json +1 -1
- package/lib/locales/plugins/03-dshmarket.json +1 -1
- package/lib/locales/plugins/17-dsh-context.json +1 -1
- package/lib/locales/plugins/26-config-manager.json +1 -1
- package/lib/locales/plugins/29-config-manager-recovery.json +1 -1
- package/lib/locales/plugins/32-vision-bridge.json +1 -1
- package/lib/locales/plugins/33-messenger-gateway.json +1 -1
- package/lib/locales/plugins/34-key-rotation.json +1 -1
- package/lib/locales/plugins/35-gitea.json +1 -1
- package/lib/locales/plugins/36-cost-meter.json +1 -1
- package/lib/locales/plugins/37-time-machine.json +1 -1
- package/lib/locales/plugins/40-clinebot.json +1 -1
- package/lib/locales/plugins/41-lanmode.json +1 -1
- package/lib/locales/plugins/42-plugin-console.json +1 -1
- package/lib/locales/plugins/48-task-tracker.json +1 -1
- package/lib/locales/plugins/49-dsml-guard.json +1 -1
- package/lib/locales/plugins/54-workshop-market.json +1 -1
- package/lib/locales/plugins/56-cron.json +1 -1
- package/lib/locales/plugins/58-grok-xsearch.json +1 -1
- package/lib/locales/plugins/59-goal.json +1 -1
- package/lib/locales/plugins/60-model-sync.json +1 -1
- package/lib/locales/plugins/61-issue-reporter.json +1 -1
- package/lib/locales/plugins/62-model-search.json +1 -1
- package/lib/locales/plugins/63-agent-orchestrator.json +1 -1
- package/lib/locales/plugins/65-subscriptions.json +1 -1
- package/lib/locales/plugins/67-voice.json +1 -1
- package/lib/locales/plugins/68-image-gen.json +1 -1
- package/lib/locales/plugins/70-hypotheses.json +1 -1
- package/lib/locales/plugins/72-server-monitor.json +1 -1
- package/lib/locales/plugins/73-agentrouter.json +1 -1
- package/lib/locales/plugins/77-github-ops.json +1 -0
- package/lib/locales/plugins/78-web-gateway.json +1 -0
- package/lib/locales/plugins/79-approval-gate.json +1 -0
- package/lib/locales/plugins/80-ip-pool.json +1 -0
- package/lib/locales/plugins/81-ego-browser.json +1 -0
- package/lib/locales/plugins/82-effort-slider.json +1 -0
- package/lib/locales/zh-ru.json +1 -1
- package/lib/locales.js +50 -12
- package/lib/pure.js +306 -14
- package/lib/translator.js +55 -0
- package/package.json +7 -2
package/lib/locales.js
CHANGED
|
@@ -7,6 +7,29 @@ const CORE_FILE = join(__dirname, "locales", "core.json")
|
|
|
7
7
|
const PLUGINS_DIR = join(__dirname, "locales", "plugins")
|
|
8
8
|
const ZH_RU_FILE = join(__dirname, "locales", "zh-ru.json")
|
|
9
9
|
|
|
10
|
+
let _pluginCache = null
|
|
11
|
+
let _pluginIndex = null
|
|
12
|
+
|
|
13
|
+
function ensurePluginCache() {
|
|
14
|
+
if (_pluginCache && _pluginIndex) return
|
|
15
|
+
_pluginCache = {}
|
|
16
|
+
_pluginIndex = new Map()
|
|
17
|
+
if (existsSync(PLUGINS_DIR)) {
|
|
18
|
+
for (const file of readdirSync(PLUGINS_DIR).sort()) {
|
|
19
|
+
if (!file.endsWith(".json")) continue
|
|
20
|
+
try {
|
|
21
|
+
const data = JSON.parse(readFileSync(join(PLUGINS_DIR, file), "utf8"))
|
|
22
|
+
for (const [ns, entries] of Object.entries(data)) {
|
|
23
|
+
_pluginCache[ns] = Object.assign(_pluginCache[ns] || {}, entries)
|
|
24
|
+
_pluginIndex.set(ns.toLowerCase(), ns)
|
|
25
|
+
const baseName = ns.replace(/^@[^/]+\//, "")
|
|
26
|
+
_pluginIndex.set(baseName.toLowerCase(), ns)
|
|
27
|
+
}
|
|
28
|
+
} catch (_) { /* bestEffort */ void _; }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
10
33
|
export function getCoreDictionaries() {
|
|
11
34
|
if (existsSync(CORE_FILE)) {
|
|
12
35
|
try {
|
|
@@ -17,19 +40,34 @@ export function getCoreDictionaries() {
|
|
|
17
40
|
}
|
|
18
41
|
|
|
19
42
|
export function getPluginDictionaries() {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
43
|
+
ensurePluginCache()
|
|
44
|
+
return Object.assign(
|
|
45
|
+
{},
|
|
46
|
+
_pluginCache
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getPluginDictionariesByNames(names) {
|
|
51
|
+
ensurePluginCache()
|
|
52
|
+
if (!names || (Array.isArray(names) && names.length === 0)) {
|
|
53
|
+
return Object.assign(
|
|
54
|
+
{},
|
|
55
|
+
_pluginCache
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
const nameList = Array.isArray(names) ? names : String(names).split(",")
|
|
59
|
+
const result = {}
|
|
60
|
+
for (const rawName of nameList) {
|
|
61
|
+
const trimmed = String(rawName).trim()
|
|
62
|
+
if (!trimmed) continue
|
|
63
|
+
const targetNs = _pluginIndex.get(trimmed.toLowerCase())
|
|
64
|
+
if (targetNs && _pluginCache[targetNs]) {
|
|
65
|
+
result[targetNs] = _pluginCache[targetNs]
|
|
66
|
+
} else if (_pluginCache[trimmed]) {
|
|
67
|
+
result[trimmed] = _pluginCache[trimmed]
|
|
30
68
|
}
|
|
31
69
|
}
|
|
32
|
-
return
|
|
70
|
+
return result
|
|
33
71
|
}
|
|
34
72
|
|
|
35
73
|
export function getAllDictionaries() {
|
|
@@ -41,7 +79,7 @@ export function getAllDictionaries() {
|
|
|
41
79
|
export function getZhRuMap() {
|
|
42
80
|
if (existsSync(ZH_RU_FILE)) {
|
|
43
81
|
try {
|
|
44
|
-
return JSON.parse(readFileSync(
|
|
82
|
+
return JSON.parse(readFileSync(ZG_RU_FILE, "utf8"))
|
|
45
83
|
} catch (_) { /* bestEffort */ void _; }
|
|
46
84
|
}
|
|
47
85
|
return {}
|
package/lib/pure.js
CHANGED
|
@@ -17,7 +17,50 @@
|
|
|
17
17
|
// ---------------------------------------------------------------- форматы
|
|
18
18
|
|
|
19
19
|
const numberFormat = new Intl.NumberFormat('ru-RU')
|
|
20
|
+
const compactNumberFormat = new Intl.NumberFormat('ru-RU', { notation: 'compact', compactDisplay: 'short' })
|
|
20
21
|
const relativeTimeFormat = new Intl.RelativeTimeFormat('ru-RU', { numeric: 'auto' })
|
|
22
|
+
|
|
23
|
+
const datePresetFormats = {
|
|
24
|
+
short: new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' }),
|
|
25
|
+
shortDateTime: new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }),
|
|
26
|
+
medium: new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }),
|
|
27
|
+
long: new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' }),
|
|
28
|
+
time: new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const formatDate = (val, style = 'short') => {
|
|
32
|
+
if (val === null || val === undefined || val === '') return ''
|
|
33
|
+
const d = val instanceof Date ? val : new Date(typeof val === 'number' && val < 1e12 ? val * 1000 : val)
|
|
34
|
+
if (isNaN(d.getTime())) return String(val)
|
|
35
|
+
if (typeof style === 'string' && datePresetFormats[style]) {
|
|
36
|
+
return datePresetFormats[style].format(d)
|
|
37
|
+
}
|
|
38
|
+
if (typeof style === 'object' && style !== null) {
|
|
39
|
+
try {
|
|
40
|
+
return new Intl.DateTimeFormat('ru-RU', style).format(d)
|
|
41
|
+
} catch (_) {
|
|
42
|
+
return datePresetFormats.short.format(d)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return datePresetFormats.short.format(d)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const formatCompactNumber = (val) => {
|
|
49
|
+
if (val === null || val === undefined || val === '') return ''
|
|
50
|
+
const n = typeof val === 'number' ? val : Number(val)
|
|
51
|
+
return isNaN(n) ? String(val) : compactNumberFormat.format(n)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const formatTokens = (count, compact = false) => {
|
|
55
|
+
if (count === null || count === undefined || count === '') return ''
|
|
56
|
+
const n = typeof count === 'number' ? count : Number(count)
|
|
57
|
+
if (isNaN(n)) return String(count)
|
|
58
|
+
const tokenWord = plural(n, ['токен', 'токена', 'токенов'])
|
|
59
|
+
if (compact && n >= 1000) {
|
|
60
|
+
return `${compactNumberFormat.format(n)} токенов`
|
|
61
|
+
}
|
|
62
|
+
return `${numberFormat.format(n)} ${tokenWord}`
|
|
63
|
+
}
|
|
21
64
|
const currencyFormats = new Map()
|
|
22
65
|
|
|
23
66
|
const getCurrencyFormat = (cur) => {
|
|
@@ -119,24 +162,64 @@ export const inflect = (phrase, cName) => {
|
|
|
119
162
|
|
|
120
163
|
// ------------------------------------------------- подстановка и плюрализация
|
|
121
164
|
|
|
165
|
+
const pluralRules = new Intl.PluralRules('ru-RU')
|
|
166
|
+
|
|
167
|
+
/** Русская форма числительного: one | few | many | other. */
|
|
168
|
+
export const pluralForm = (n) => pluralRules.select(n)
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Универсальная русская плюрализация 1, 2-4, 5+
|
|
172
|
+
* @param {number|string} count - Число элементов
|
|
173
|
+
* @param {string[]|string} forms - Массив [one, few, many] или строка первого варианта
|
|
174
|
+
* @param {boolean} [withCount=false] - Включать ли число в результат ("5 задач" vs "задач")
|
|
175
|
+
*/
|
|
176
|
+
export function plural(count, forms, withCount = false) {
|
|
177
|
+
const n = typeof count === 'number' ? count : (Number(count) || 0)
|
|
178
|
+
let one = '', few = '', many = ''
|
|
179
|
+
if (Array.isArray(forms)) {
|
|
180
|
+
one = forms[0] || ''
|
|
181
|
+
few = forms[1] || one
|
|
182
|
+
many = forms[2] || few || one
|
|
183
|
+
} else if (typeof forms === 'string') {
|
|
184
|
+
one = forms
|
|
185
|
+
few = arguments[2] || one
|
|
186
|
+
many = arguments[3] || few || one
|
|
187
|
+
withCount = arguments[4] || false
|
|
188
|
+
} else {
|
|
189
|
+
return ''
|
|
190
|
+
}
|
|
191
|
+
const form = pluralRules.select(n)
|
|
192
|
+
let word = many
|
|
193
|
+
if (form === 'one') word = one
|
|
194
|
+
else if (form === 'few') word = few
|
|
195
|
+
else if (form === 'many') word = many
|
|
196
|
+
|
|
197
|
+
return withCount ? `${formatNumber(n)} ${word}` : word
|
|
198
|
+
}
|
|
199
|
+
|
|
122
200
|
export const fill = (template, params) => {
|
|
123
201
|
if (!params || typeof params !== 'object') return String(template)
|
|
124
|
-
return String(template).replace(/\{(\w+)(?::(
|
|
202
|
+
return String(template).replace(/\{(\w+)(?::([^}]+))?\}/g, (match, name, spec) => {
|
|
125
203
|
if (!(name in params)) return match
|
|
126
204
|
const val = params[name]
|
|
205
|
+
if (!spec) return String(val)
|
|
127
206
|
if (spec === 'number') return formatNumber(val)
|
|
207
|
+
if (spec === 'compact') return formatCompactNumber(val)
|
|
208
|
+
if (spec === 'tokens') return formatTokens(val)
|
|
209
|
+
if (spec === 'tokens:compact') return formatTokens(val, true)
|
|
128
210
|
if (spec === 'reltime') return formatRelativeTime(val)
|
|
129
211
|
if (spec === 'currency') return formatCurrency(val, params.currency || 'RUB')
|
|
212
|
+
if (spec === 'date') return formatDate(val)
|
|
213
|
+
if (spec.startsWith('date:')) return formatDate(val, spec.slice(5))
|
|
214
|
+
if (spec.startsWith('plural:')) {
|
|
215
|
+
const parts = spec.slice(7).split(',')
|
|
216
|
+
return plural(val, parts)
|
|
217
|
+
}
|
|
130
218
|
if (INFLECT_CASES.has(spec)) return inflect(String(val), spec)
|
|
131
219
|
return String(val)
|
|
132
220
|
})
|
|
133
221
|
}
|
|
134
222
|
|
|
135
|
-
const pluralRules = new Intl.PluralRules('ru-RU')
|
|
136
|
-
|
|
137
|
-
/** Русская форма числительного: one | few | many | other. */
|
|
138
|
-
export const pluralForm = (n) => pluralRules.select(n)
|
|
139
|
-
|
|
140
223
|
// ------------------------------------------------------ поисковая морфология
|
|
141
224
|
|
|
142
225
|
export const stemRussian = (word) => {
|
|
@@ -197,7 +280,13 @@ export const ERROR_MAP = {
|
|
|
197
280
|
500: { title: 'Внутренняя ошибка сервера', message: 'На стороне сервера произошел сбой', hint: 'Попробуйте повторить запрос позже или проверьте серверные логи.' },
|
|
198
281
|
502: { title: 'Ошибочный шлюз (Bad Gateway)', message: 'Промежуточный прокси не получил корректный ответ', hint: 'Проверьте работу нижележащей службы или upstream-сервера.' },
|
|
199
282
|
503: { title: 'Служба временно недоступна', message: 'Сервер перегружен или находится на обслуживании', hint: 'Попробуйте повторить операцию через некоторое время.' },
|
|
200
|
-
504: { title: 'Шлюз не отвечает (Gateway Timeout)', message: 'Превышено время ожидания ответа от upstream-сервера', hint: 'Попробуйте повторить запрос позже.' }
|
|
283
|
+
504: { title: 'Шлюз не отвечает (Gateway Timeout)', message: 'Превышено время ожидания ответа от upstream-сервера', hint: 'Попробуйте повторить запрос позже.' },
|
|
284
|
+
FETCH_FAILED: { title: 'Сетевой запрос не удался', message: 'Не удалось связаться с сервером (Failed to fetch)', hint: 'Проверьте сетевое подключение и доступность адреса.' },
|
|
285
|
+
CORS_ERROR: { title: 'Ошибка CORS', message: 'Запрос заблокирован политикой безопасности браузера', hint: 'Проверьте заголовки разрешённых источников на сервере.' },
|
|
286
|
+
WS_CLOSED: { title: 'Разрыв WebSocket', message: 'Связь с сервером по WebSocket прервана', hint: 'Проверьте работу сервера и сетевой статус.' },
|
|
287
|
+
QUOTA_EXCEEDED: { title: 'Исчерпана квота API', message: 'Превышен лимит запросов или баланс провайдера', hint: 'Проверьте баланс API-ключа в кабинете провайдера.' },
|
|
288
|
+
CONTEXT_OVERFLOW: { title: 'Превышен контекст', message: 'Объём диалога превышает размер контекстного окна', hint: 'Очистите или сожмите историю сообщений сессии.' },
|
|
289
|
+
MODEL_NOT_FOUND: { title: 'Модель не найдена', message: 'Указанная модель отсутствует или недоступна', hint: 'Проверьте идентификатор модели в настройках.' }
|
|
201
290
|
}
|
|
202
291
|
|
|
203
292
|
const fromMap = (key, rawMsg) => ({
|
|
@@ -213,9 +302,25 @@ export const humanizeError = (err) => {
|
|
|
213
302
|
if (lookupKey && ERROR_MAP[lookupKey]) return fromMap(lookupKey, rawMsg)
|
|
214
303
|
if (/rate limit|too many requests/i.test(rawMsg)) return fromMap(429, rawMsg)
|
|
215
304
|
if (/unauthorized|invalid token|invalid api key/i.test(rawMsg)) return fromMap(401, rawMsg)
|
|
305
|
+
if (/failed to fetch|network\s*error|load\s*failed|net::err_/i.test(rawMsg)) return fromMap('FETCH_FAILED', rawMsg)
|
|
306
|
+
if (/cors|cross-origin/i.test(rawMsg)) return fromMap('CORS_ERROR', rawMsg)
|
|
307
|
+
if (/websocket.*(?:closed|failed)/i.test(rawMsg)) return fromMap('WS_CLOSED', rawMsg)
|
|
308
|
+
if (/insufficient_quota|quota\s*exceeded/i.test(rawMsg)) return fromMap('QUOTA_EXCEEDED', rawMsg)
|
|
309
|
+
if (/context.*(?:exceeded|overflow|length)/i.test(rawMsg)) return fromMap('CONTEXT_OVERFLOW', rawMsg)
|
|
310
|
+
if (/model.*not.*found|model.*does not exist/i.test(rawMsg)) return fromMap('MODEL_NOT_FOUND', rawMsg)
|
|
216
311
|
return { code: 'UNKNOWN', title: 'Ошибка операции', message: rawMsg, hint: 'Проверьте параметры операции и логи.', raw: rawMsg }
|
|
217
312
|
}
|
|
218
313
|
|
|
314
|
+
export const formatErrorToast = (err) => {
|
|
315
|
+
const h = humanizeError(err)
|
|
316
|
+
if (!h) return null
|
|
317
|
+
return {
|
|
318
|
+
type: 'error',
|
|
319
|
+
title: h.title,
|
|
320
|
+
description: h.message + (h.hint ? ' ' + h.hint : '')
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
219
324
|
// -------------------------------------------------------- отчёт об ошибке
|
|
220
325
|
|
|
221
326
|
/** Версию передаёт вызывающий: в бандл её подставляет build.py из package.json,
|
|
@@ -293,7 +398,10 @@ export const yoCase = (src, repl) => {
|
|
|
293
398
|
/** pairs — [[«еще», «ещё»], ...] из build.py (ручные + корпусные, за вычетом
|
|
294
399
|
* омографов). Регистр разбирает yoCase, поэтому пара нужна одна на слово. */
|
|
295
400
|
export const makeTypoYo = (pairs) => {
|
|
296
|
-
const
|
|
401
|
+
const list = typeof pairs === 'string'
|
|
402
|
+
? pairs.split('|').filter(Boolean).map((s) => s.split(':'))
|
|
403
|
+
: (pairs || [])
|
|
404
|
+
const compiled = list.map((p) => [new RegExp(YO_EDGE_L + p[0] + YO_EDGE_R, 'gi'), p[1]])
|
|
297
405
|
return (text) => {
|
|
298
406
|
for (const [re, repl] of compiled) text = text.replace(re, (match) => yoCase(match, repl))
|
|
299
407
|
return text
|
|
@@ -486,20 +594,38 @@ export const SYSTEM_PROMPT_PRESETS = {
|
|
|
486
594
|
technical_expert: {
|
|
487
595
|
id: 'technical_expert',
|
|
488
596
|
label: 'Технический эксперт',
|
|
489
|
-
desc: '
|
|
490
|
-
text: 'Отвечай пользователю на русском языке. Используй точную
|
|
597
|
+
desc: 'Точная терминология, чистый код и русские комментарии',
|
|
598
|
+
text: 'Отвечай пользователю на русском языке. Используй точную инженерную терминологию, пиши чистый код и русские комментарии.'
|
|
491
599
|
},
|
|
492
600
|
tech_writer: {
|
|
493
601
|
id: 'tech_writer',
|
|
494
602
|
label: 'Технический писатель',
|
|
495
|
-
desc: 'Структурированные тексты, Markdown
|
|
496
|
-
text: 'Отвечай пользователю на русском языке. Оформляй документацию
|
|
603
|
+
desc: 'Структурированные тексты, Markdown и выверенная типографика',
|
|
604
|
+
text: 'Отвечай пользователю на русском языке. Оформляй документацию в Markdown, строго соблюдай правила русской типографики («ёлочки», тире, «ё»).'
|
|
497
605
|
},
|
|
498
606
|
concise: {
|
|
499
607
|
id: 'concise',
|
|
500
608
|
label: 'Лаконичный режим',
|
|
501
|
-
desc: 'Краткие ёмкие ответы без
|
|
502
|
-
text: 'Отвечай пользователю на русском языке максимально кратко и ёмко. Без
|
|
609
|
+
desc: 'Краткие ёмкие ответы без вводных слов и воды',
|
|
610
|
+
text: 'Отвечай пользователю на русском языке максимально кратко и ёмко. Без вводных слов и воды, сразу код или решение.'
|
|
611
|
+
},
|
|
612
|
+
code_reviewer: {
|
|
613
|
+
id: 'code_reviewer',
|
|
614
|
+
label: 'Код-ревьюер',
|
|
615
|
+
desc: 'Поиск багов, безопасность и архитектурный аудит',
|
|
616
|
+
text: 'Анализируй код и отвечай на русском языке. Ищи уязвимости, граничные случаи и нарушения контрактов, предлагая исправления.'
|
|
617
|
+
},
|
|
618
|
+
architect: {
|
|
619
|
+
id: 'architect',
|
|
620
|
+
label: 'Системный архитектор',
|
|
621
|
+
desc: 'Контракты API/CLI, модульность и надёжность',
|
|
622
|
+
text: 'Отвечай на русском языке как архитектор. Проектируй модульные системы, чёткие контракты API/CLI и надёжные решения без лишней сложности.'
|
|
623
|
+
},
|
|
624
|
+
tutor: {
|
|
625
|
+
id: 'tutor',
|
|
626
|
+
label: 'Наставник (ментор)',
|
|
627
|
+
desc: 'Понятные объяснения сложных концепций и пошаговый разбор',
|
|
628
|
+
text: 'Отвечай на русском языке как наставник. Объясняй сложные темы простыми словами, наглядными аналогиями и пошаговыми примерами.'
|
|
503
629
|
}
|
|
504
630
|
}
|
|
505
631
|
|
|
@@ -548,3 +674,169 @@ export const exportSessionToMarkdown = (session, options = {}) => {
|
|
|
548
674
|
md += `*Сгенерировано с помощью [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) и @goodandready/dsh-russian-lang*\n`
|
|
549
675
|
return md
|
|
550
676
|
}
|
|
677
|
+
|
|
678
|
+
// -------------------------------------------------- инспектор переводов и оверрайды (#298, #330)
|
|
679
|
+
|
|
680
|
+
export const findTranslationKey = (text, dicts = {}, overrides = {}, zhRu = {}, domEn = {}, options = {}) => {
|
|
681
|
+
if (!text || typeof text !== 'string') return null
|
|
682
|
+
const trimmed = text.trim()
|
|
683
|
+
if (!trimmed) return null
|
|
684
|
+
|
|
685
|
+
// 1. Оверрайды пользователя (наивысший приоритет)
|
|
686
|
+
if (overrides && typeof overrides === 'object') {
|
|
687
|
+
if (overrides[trimmed] !== undefined) {
|
|
688
|
+
return { key: trimmed, value: overrides[trimmed], source: 'override' }
|
|
689
|
+
}
|
|
690
|
+
for (const [k, v] of Object.entries(overrides)) {
|
|
691
|
+
if (v === text || (typeof v === 'string' && v.trim() === trimmed)) {
|
|
692
|
+
return { key: k, value: v, source: 'override' }
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// 2. Словари неймспейсов
|
|
698
|
+
if (dicts && typeof dicts === 'object') {
|
|
699
|
+
for (const [ns, map] of Object.entries(dicts)) {
|
|
700
|
+
if (!map || typeof map !== 'object') continue
|
|
701
|
+
if (map[trimmed] !== undefined) {
|
|
702
|
+
return { ns, key: trimmed, value: map[trimmed], source: 'dictionary' }
|
|
703
|
+
}
|
|
704
|
+
for (const [k, v] of Object.entries(map)) {
|
|
705
|
+
if (v === text || (typeof v === 'string' && v.trim() === trimmed)) {
|
|
706
|
+
return { ns, key: k, value: v, source: 'dictionary' }
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// 3. Таблица китайских соответствий ZH_RU
|
|
713
|
+
if (zhRu && typeof zhRu === 'object') {
|
|
714
|
+
for (const [zh, ru] of Object.entries(zhRu)) {
|
|
715
|
+
if (ru === text || (typeof ru === 'string' && ru.trim() === trimmed)) {
|
|
716
|
+
return { zh, key: zh, value: ru, source: 'dom_zh' }
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
if (zhRu[trimmed] !== undefined) {
|
|
720
|
+
return { zh: trimmed, key: trimmed, value: zhRu[trimmed], source: 'dom_zh_original' }
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// 4. Таблица английских соответствий DOM_EN_TEXT
|
|
725
|
+
if (domEn && typeof domEn === 'object') {
|
|
726
|
+
for (const [en, ru] of Object.entries(domEn)) {
|
|
727
|
+
if (ru === text || (typeof ru === 'string' && ru.trim() === trimmed)) {
|
|
728
|
+
return { en, key: en, value: ru, source: 'dom_en' }
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
if (domEn[trimmed] !== undefined) {
|
|
732
|
+
return { en: trimmed, key: trimmed, value: domEn[trimmed], source: 'dom_en_original' }
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// 5. Детекция непереведённых фрагментов (при явном запросе)
|
|
737
|
+
if (options && options.detectUntranslated) {
|
|
738
|
+
const hasRu = /[а-яё]/i.test(trimmed)
|
|
739
|
+
const hasZh = /[㐀-鿿豈-]/.test(trimmed)
|
|
740
|
+
const hasEn = /[a-z]/i.test(trimmed)
|
|
741
|
+
if (!hasRu && hasZh) {
|
|
742
|
+
return { key: trimmed, value: trimmed, source: 'untranslated_zh' }
|
|
743
|
+
}
|
|
744
|
+
if (!hasRu && hasEn) {
|
|
745
|
+
return { key: trimmed, value: trimmed, source: 'untranslated_en' }
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
return null
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
export const generateBugReportSnippet = (info = {}) => {
|
|
753
|
+
const { key, ns, original, current, override, pkgVersion = '0.3.1' } = info
|
|
754
|
+
const lines = [
|
|
755
|
+
'### 🌐 Неточность перевода / Пользовательское предложение',
|
|
756
|
+
'- **Пакет:** `@goodandready/dsh-russian-lang@v' + pkgVersion + '`',
|
|
757
|
+
ns ? '- **Пространство имён:** `' + ns + '`' : null,
|
|
758
|
+
key ? '- **Ключ:** `' + key + '`' : null,
|
|
759
|
+
original ? '- **Оригинальный текст:** `' + original + '`' : null,
|
|
760
|
+
current ? '- **Текущий перевод:** `' + current + '`' : null,
|
|
761
|
+
override ? '- **Предлагаемый перевод:** `' + override + '`' : null,
|
|
762
|
+
].filter(Boolean)
|
|
763
|
+
return lines.join('\n')
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// ------------------------------------------ двуязычный поиск команд (#300)
|
|
767
|
+
|
|
768
|
+
export const bilingualCommandMatch = (query, command, options = {}) => {
|
|
769
|
+
if (!query || !command) return { matched: false, score: 0 }
|
|
770
|
+
const q = String(query).trim().toLowerCase()
|
|
771
|
+
if (!q) return { matched: false, score: 0 }
|
|
772
|
+
|
|
773
|
+
const cmdObj = typeof command === 'string' ? { title: command } : command
|
|
774
|
+
const candidates = [
|
|
775
|
+
cmdObj.title,
|
|
776
|
+
cmdObj.label,
|
|
777
|
+
cmdObj.name,
|
|
778
|
+
cmdObj.originalTitle,
|
|
779
|
+
cmdObj.enTitle,
|
|
780
|
+
cmdObj.id,
|
|
781
|
+
cmdObj.description,
|
|
782
|
+
cmdObj.category,
|
|
783
|
+
...(Array.isArray(cmdObj.keywords) ? cmdObj.keywords : [])
|
|
784
|
+
].filter((c) => typeof c === 'string' && c.trim())
|
|
785
|
+
|
|
786
|
+
if (!candidates.length) return { matched: false, score: 0 }
|
|
787
|
+
|
|
788
|
+
const queryVariants = [{ text: q, kind: 'direct' }]
|
|
789
|
+
|
|
790
|
+
const lat2cyr = translit(q, LAYOUT_LAT_TO_CYR)
|
|
791
|
+
if (lat2cyr !== q) queryVariants.push({ text: lat2cyr, kind: 'layout' })
|
|
792
|
+
|
|
793
|
+
const cyr2lat = translit(q, LAYOUT_CYR_TO_LAT)
|
|
794
|
+
if (cyr2lat !== q) queryVariants.push({ text: cyr2lat, kind: 'layout' })
|
|
795
|
+
|
|
796
|
+
const phonLat2Cyr = phoneticTranslit(q, 'lat2cyr')
|
|
797
|
+
if (phonLat2Cyr && phonLat2Cyr !== q && phonLat2Cyr !== lat2cyr) {
|
|
798
|
+
queryVariants.push({ text: phonLat2Cyr, kind: 'phonetic' })
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const phonCyr2Lat = phoneticTranslit(q, 'cyr2lat')
|
|
802
|
+
if (phonCyr2Lat && phonCyr2Lat !== q && phonCyr2Lat !== cyr2lat) {
|
|
803
|
+
queryVariants.push({ text: phonCyr2Lat, kind: 'phonetic' })
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
let bestScore = 0
|
|
807
|
+
let bestVariant = null
|
|
808
|
+
let bestCandidate = null
|
|
809
|
+
|
|
810
|
+
for (const variant of queryVariants) {
|
|
811
|
+
for (const cand of candidates) {
|
|
812
|
+
const score = fuzzyMatchRu(variant.text, cand)
|
|
813
|
+
if (score > bestScore) {
|
|
814
|
+
bestScore = score
|
|
815
|
+
bestVariant = variant
|
|
816
|
+
bestCandidate = cand
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const threshold = options.threshold ?? 50
|
|
822
|
+
return {
|
|
823
|
+
matched: bestScore >= threshold,
|
|
824
|
+
score: bestScore,
|
|
825
|
+
matchedText: bestCandidate,
|
|
826
|
+
variant: bestVariant ? bestVariant.text : q,
|
|
827
|
+
kind: bestVariant ? bestVariant.kind : 'none'
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
export const filterCommands = (query, commands = [], options = {}) => {
|
|
832
|
+
if (!Array.isArray(commands)) return []
|
|
833
|
+
if (!query || !String(query).trim()) return commands
|
|
834
|
+
const results = []
|
|
835
|
+
for (const cmd of commands) {
|
|
836
|
+
const res = bilingualCommandMatch(query, cmd, options)
|
|
837
|
+
if (res.matched) {
|
|
838
|
+
results.push({ item: cmd, ...res })
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
return results.sort((a, b) => b.score - a.score).map((r) => r.item)
|
|
842
|
+
}
|
package/lib/translator.js
CHANGED
|
@@ -34,6 +34,45 @@ function readJsonBody(request, limit = 500_000) {
|
|
|
34
34
|
})
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
function header(request, name) {
|
|
38
|
+
const value = request?.headers?.[name]
|
|
39
|
+
return Array.isArray(value) ? value[0] : value
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isLoopback(value) {
|
|
43
|
+
const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
|
|
44
|
+
return (
|
|
45
|
+
address === 'localhost' ||
|
|
46
|
+
address === 'localhost.' ||
|
|
47
|
+
address === '::1' ||
|
|
48
|
+
address?.startsWith('127.') === true ||
|
|
49
|
+
address?.startsWith('::ffff:127.') === true
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isTrustedTranslatorRequest(request) {
|
|
54
|
+
if (request?.socket?.remoteAddress && !isLoopback(request.socket.remoteAddress)) {
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
const site = header(request, 'sec-fetch-site')
|
|
58
|
+
if (site !== undefined && site !== 'same-origin' && site !== 'none') {
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
const origin = header(request, 'origin')
|
|
62
|
+
const host = header(request, 'host')
|
|
63
|
+
if (origin !== undefined) {
|
|
64
|
+
try {
|
|
65
|
+
const url = new URL(origin)
|
|
66
|
+
if (!isLoopback(url.hostname)) return false
|
|
67
|
+
if (host && url.host !== host) return false
|
|
68
|
+
} catch {
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return true
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
37
76
|
export async function getTranslatorStatus(settingsCtx) {
|
|
38
77
|
const currentSettings = settingsCtx?.settings?.get('russian-lang') || {}
|
|
39
78
|
const engine = currentSettings.translateEngine || 'off'
|
|
@@ -214,6 +253,14 @@ export function registerTranslator(wctx, settingsCtx) {
|
|
|
214
253
|
response.end(JSON.stringify({ error: 'Method not allowed' }))
|
|
215
254
|
return
|
|
216
255
|
}
|
|
256
|
+
if (!isTrustedTranslatorRequest(request)) {
|
|
257
|
+
response.writeHead(403, {
|
|
258
|
+
'content-type': 'application/json; charset=utf-8',
|
|
259
|
+
'cache-control': 'no-store'
|
|
260
|
+
})
|
|
261
|
+
response.end(JSON.stringify({ error: 'Rejected non-local or cross-origin translator setup request.' }))
|
|
262
|
+
return
|
|
263
|
+
}
|
|
217
264
|
const result = await setupLibreTranslateContainer()
|
|
218
265
|
response.writeHead(200, {
|
|
219
266
|
'content-type': 'application/json; charset=utf-8',
|
|
@@ -240,6 +287,14 @@ export function registerTranslator(wctx, settingsCtx) {
|
|
|
240
287
|
response.end(JSON.stringify({ error: 'Method not allowed' }))
|
|
241
288
|
return
|
|
242
289
|
}
|
|
290
|
+
if (!isTrustedTranslatorRequest(request)) {
|
|
291
|
+
response.writeHead(403, {
|
|
292
|
+
'content-type': 'application/json; charset=utf-8',
|
|
293
|
+
'cache-control': 'no-store'
|
|
294
|
+
})
|
|
295
|
+
response.end(JSON.stringify({ error: 'Rejected non-local or cross-origin translation request.' }))
|
|
296
|
+
return
|
|
297
|
+
}
|
|
243
298
|
|
|
244
299
|
const body = await readJsonBody(request)
|
|
245
300
|
const text = body.text
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-russian-lang",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Russian localization for DeepSeek Harness (DSH v0.1.6-alpha.1+) web UI: adds Русский as native language option. 100.0% coverage across 11,295 keys (127 namespaces). Features full DSH v0.1.6-alpha.1 core support (terminal, archive, command palette, Auto review, trajectory, PTC/Creator presets) and complete GoodAndReady plugin catalog (subscriptions, tts, image-gen, voice, key-limits, notify, commandcode, plugin-console).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"lib",
|
|
15
|
+
"bin",
|
|
15
16
|
"cordis.patch.yml",
|
|
16
17
|
"README.md",
|
|
17
18
|
"README.ru.md",
|
|
@@ -25,7 +26,8 @@
|
|
|
25
26
|
"platform": "web",
|
|
26
27
|
"inject": [
|
|
27
28
|
"@deepseek-ai/dsh-client-locale",
|
|
28
|
-
"@deepseek-ai/dsh-client-ui-settings"
|
|
29
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
30
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
29
31
|
]
|
|
30
32
|
}
|
|
31
33
|
},
|
|
@@ -65,5 +67,8 @@
|
|
|
65
67
|
"homepage": "https://github.com/GooDAnDReaDY/dsh-russian-lang#readme",
|
|
66
68
|
"bugs": {
|
|
67
69
|
"url": "https://github.com/GooDAnDReaDY/dsh-russian-lang/issues"
|
|
70
|
+
},
|
|
71
|
+
"bin": {
|
|
72
|
+
"dsh-i18n": "./bin/dsh-i18n.mjs"
|
|
68
73
|
}
|
|
69
74
|
}
|