cctrans 0.1.0
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/LICENSE +21 -0
- package/README.hi.md +116 -0
- package/README.ja.md +116 -0
- package/README.ko.md +116 -0
- package/README.md +116 -0
- package/README.ru.md +117 -0
- package/README.zh-Hans.md +116 -0
- package/README.zh-Hant.md +116 -0
- package/ROADMAP.md +18 -0
- package/bin/tt.js +272 -0
- package/hook/message-display.js +85 -0
- package/hook/user-prompt-submit.js +63 -0
- package/package.json +43 -0
- package/src/backends/anthropic.js +59 -0
- package/src/backends/azure.js +35 -0
- package/src/backends/claude-code.js +52 -0
- package/src/backends/deepl.js +33 -0
- package/src/backends/google.js +25 -0
- package/src/backends/index.js +34 -0
- package/src/backends/openai.js +47 -0
- package/src/config.js +62 -0
- package/src/interleave.js +93 -0
- package/src/keys.js +48 -0
- package/src/langs.js +94 -0
- package/src/setup.js +99 -0
- package/src/transcript.js +166 -0
- package/src/translate.js +76 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Claude Code UserPromptSubmit hook: input translation (prompt -> English).
|
|
3
|
+
//
|
|
4
|
+
// stdin (JSON): { session_id, transcript_path, cwd, permission_mode,
|
|
5
|
+
// hook_event_name, prompt }
|
|
6
|
+
// stdout (JSON, exit 0): { hookSpecificOutput: { hookEventName:
|
|
7
|
+
// "UserPromptSubmit", additionalContext: "<English translation>" } }
|
|
8
|
+
//
|
|
9
|
+
// VERIFIED CONSTRAINT (CC 2.1.169 binary): neither UserPromptSubmit nor
|
|
10
|
+
// UserPromptExpansion can REWRITE the prompt — their output schema only
|
|
11
|
+
// allows additionalContext (and block). So we attach the English translation
|
|
12
|
+
// as context the model treats as canonical; the original stays in history.
|
|
13
|
+
//
|
|
14
|
+
// Safety contract: on disabled / English input / error / timeout, emit
|
|
15
|
+
// NOTHING and exit 0 — the prompt goes through untouched.
|
|
16
|
+
|
|
17
|
+
const { getState } = require('../src/config');
|
|
18
|
+
const { translateLines } = require('../src/translate');
|
|
19
|
+
const { nonLatinRatio } = require('../src/langs');
|
|
20
|
+
|
|
21
|
+
function passThrough() { process.exit(0); }
|
|
22
|
+
|
|
23
|
+
let data = '';
|
|
24
|
+
process.stdin.on('data', (d) => (data += d));
|
|
25
|
+
process.stdin.on('end', async () => {
|
|
26
|
+
if (process.env.TT_DEBUG_STDIN) {
|
|
27
|
+
try { require('fs').appendFileSync(process.env.TT_DEBUG_STDIN, '\n===== prompt =====\n' + data + '\n'); } catch (e) {}
|
|
28
|
+
}
|
|
29
|
+
if (process.env.TT_DISABLE) return passThrough();
|
|
30
|
+
|
|
31
|
+
let inp = {};
|
|
32
|
+
try { inp = JSON.parse(data); } catch (e) { return passThrough(); }
|
|
33
|
+
const prompt = typeof inp.prompt === 'string' ? inp.prompt : '';
|
|
34
|
+
if (!prompt.trim() || prompt.length > 6000) return passThrough();
|
|
35
|
+
|
|
36
|
+
let st;
|
|
37
|
+
try { st = getState(); } catch (e) { return passThrough(); }
|
|
38
|
+
if (!st.inputEn) return passThrough();
|
|
39
|
+
|
|
40
|
+
// Only act on prompts that are substantially non-English.
|
|
41
|
+
if (nonLatinRatio(prompt) < 0.2) return passThrough();
|
|
42
|
+
|
|
43
|
+
const guard = setTimeout(passThrough, 9000);
|
|
44
|
+
try {
|
|
45
|
+
const [en] = await translateLines([prompt], {
|
|
46
|
+
target: 'en', backend: st.backend, model: st.model, timeoutMs: 8000,
|
|
47
|
+
});
|
|
48
|
+
clearTimeout(guard);
|
|
49
|
+
if (!en || en.trim() === prompt.trim()) return passThrough();
|
|
50
|
+
process.stdout.write(JSON.stringify({
|
|
51
|
+
hookSpecificOutput: {
|
|
52
|
+
hookEventName: 'UserPromptSubmit',
|
|
53
|
+
additionalContext:
|
|
54
|
+
'English translation of the user\'s prompt (translated by a local tool; ' +
|
|
55
|
+
'treat it as the canonical instruction):\n' + en,
|
|
56
|
+
},
|
|
57
|
+
}));
|
|
58
|
+
process.exit(0);
|
|
59
|
+
} catch (e) {
|
|
60
|
+
clearTimeout(guard);
|
|
61
|
+
return passThrough();
|
|
62
|
+
}
|
|
63
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cctrans",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Bilingual inline translation overlay for Claude Code: a translated line (zh/ja/ko/ru/hi) under each English line, right in the conversation — non-destructive, zero main-loop tokens.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Roy Jiang",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/roy-jiang-opus/cctranslate.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/roy-jiang-opus/cctranslate#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/roy-jiang-opus/cctranslate/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"claude-code",
|
|
17
|
+
"translation",
|
|
18
|
+
"bilingual",
|
|
19
|
+
"i18n",
|
|
20
|
+
"terminal",
|
|
21
|
+
"hook",
|
|
22
|
+
"chinese",
|
|
23
|
+
"japanese",
|
|
24
|
+
"korean"
|
|
25
|
+
],
|
|
26
|
+
"bin": {
|
|
27
|
+
"tt": "bin/tt.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"bin/",
|
|
31
|
+
"hook/",
|
|
32
|
+
"src/",
|
|
33
|
+
"README*.md",
|
|
34
|
+
"ROADMAP.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"test": "node test/fence.js"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Anthropic Claude Haiku batch translation via the Messages API (raw fetch —
|
|
3
|
+
// this project is dependency-free by design). Uses structured outputs
|
|
4
|
+
// (output_config.format json_schema) so {"t":[...]} is guaranteed valid JSON.
|
|
5
|
+
// Model claude-haiku-4-5: $1/M input, $5/M output — ~$0.0005 per delta.
|
|
6
|
+
const { getLang } = require('../langs');
|
|
7
|
+
const { getKey } = require('../keys');
|
|
8
|
+
|
|
9
|
+
const SCHEMA = {
|
|
10
|
+
type: 'object',
|
|
11
|
+
properties: { t: { type: 'array', items: { type: 'string' } } },
|
|
12
|
+
required: ['t'],
|
|
13
|
+
additionalProperties: false,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
module.exports = {
|
|
17
|
+
id: 'anthropic',
|
|
18
|
+
kind: 'llm',
|
|
19
|
+
needs: 'anthropic key (tt key anthropic <value>)',
|
|
20
|
+
available() { return !!getKey('anthropic'); },
|
|
21
|
+
async translate(lines, langCode, opts) {
|
|
22
|
+
opts = opts || {};
|
|
23
|
+
const key = getKey('anthropic');
|
|
24
|
+
if (!key) throw new Error('no anthropic key');
|
|
25
|
+
const lang = getLang(langCode);
|
|
26
|
+
const name = lang ? lang.name : langCode;
|
|
27
|
+
const sys =
|
|
28
|
+
'You translate developer-tool chat into ' + name + '. ' +
|
|
29
|
+
'Translate each input line to natural, concise ' + name + '. ' +
|
|
30
|
+
'If a line is already in ' + name + ', return it unchanged. ' +
|
|
31
|
+
'Keep inline code, file paths, URLs, identifiers, numbers, and leading markdown ' +
|
|
32
|
+
'markers (#, -, *, >, digits., backticks) intact. ' +
|
|
33
|
+
'Return {"t":[...]} with EXACTLY one translation per input line, same order. ' +
|
|
34
|
+
'Never merge, split, add, or drop lines.';
|
|
35
|
+
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: {
|
|
38
|
+
'Content-Type': 'application/json',
|
|
39
|
+
'x-api-key': key,
|
|
40
|
+
'anthropic-version': '2023-06-01',
|
|
41
|
+
},
|
|
42
|
+
body: JSON.stringify({
|
|
43
|
+
model: opts.anthropicModel || require('../config').getState().anthropicModel,
|
|
44
|
+
max_tokens: 4096,
|
|
45
|
+
system: sys,
|
|
46
|
+
output_config: { format: { type: 'json_schema', schema: SCHEMA } },
|
|
47
|
+
messages: [{ role: 'user', content: JSON.stringify({ lines }) }],
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
if (!res.ok) throw new Error('anthropic ' + res.status + ' ' + (await res.text()).slice(0, 200));
|
|
51
|
+
const j = await res.json();
|
|
52
|
+
const text = (j.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('');
|
|
53
|
+
const t = JSON.parse(text).t;
|
|
54
|
+
if (!Array.isArray(t) || t.length !== lines.length) {
|
|
55
|
+
throw new Error('length mismatch ' + (t && t.length) + '/' + lines.length);
|
|
56
|
+
}
|
|
57
|
+
return t;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Azure Translator (Cognitive Services). Large free tier (2M chars/month).
|
|
3
|
+
// The v3 endpoint accepts an array of {Text} and returns aligned results.
|
|
4
|
+
// Needs AZURE_TRANSLATOR_KEY and (for regional resources) AZURE_TRANSLATOR_REGION.
|
|
5
|
+
const { getLang } = require('../langs');
|
|
6
|
+
const { getKey } = require('../keys');
|
|
7
|
+
|
|
8
|
+
module.exports = {
|
|
9
|
+
id: 'azure',
|
|
10
|
+
kind: 'mt',
|
|
11
|
+
needs: 'azure key (tt key azure <value>; region: tt key azure-region <value>)',
|
|
12
|
+
available() { return !!getKey('azure'); },
|
|
13
|
+
async translate(lines, langCode) {
|
|
14
|
+
const key = getKey('azure');
|
|
15
|
+
if (!key) throw new Error('no azure key');
|
|
16
|
+
const lang = getLang(langCode);
|
|
17
|
+
const target = lang ? lang.azure : langCode;
|
|
18
|
+
const endpoint = require('../config').getState().azureEndpoint;
|
|
19
|
+
const headers = { 'Content-Type': 'application/json', 'Ocp-Apim-Subscription-Key': key };
|
|
20
|
+
const region = getKey('azure-region');
|
|
21
|
+
if (region) headers['Ocp-Apim-Subscription-Region'] = region;
|
|
22
|
+
const res = await fetch(endpoint + '/translate?api-version=3.0&to=' + encodeURIComponent(target), {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers,
|
|
25
|
+
body: JSON.stringify(lines.map((Text) => ({ Text }))),
|
|
26
|
+
});
|
|
27
|
+
if (!res.ok) throw new Error('azure ' + res.status + ' ' + (await res.text()).slice(0, 200));
|
|
28
|
+
const j = await res.json();
|
|
29
|
+
const t = j.map((x) => x.translations && x.translations[0] && x.translations[0].text);
|
|
30
|
+
if (t.length !== lines.length || t.some((x) => typeof x !== 'string')) {
|
|
31
|
+
throw new Error('bad azure response');
|
|
32
|
+
}
|
|
33
|
+
return t;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Claude Code headless backend: shells out to `claude -p` so translation runs
|
|
3
|
+
// on the user's Claude subscription (no separate API key). Measured ~3s per
|
|
4
|
+
// call (CLI startup) — usable within the hook's 10s budget but noticeably
|
|
5
|
+
// slower than HTTP backends; offered as the no-key option, not the default.
|
|
6
|
+
// TT_DISABLE=1 is set on the child as a recursion guard (the hook exits early
|
|
7
|
+
// when it sees it), and --settings {} -style hook loading is avoided by -p
|
|
8
|
+
// print mode having no display path.
|
|
9
|
+
const { execFile } = require('child_process');
|
|
10
|
+
const { getLang } = require('../langs');
|
|
11
|
+
|
|
12
|
+
function runClaude(prompt, timeoutMs) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const child = execFile(
|
|
15
|
+
'claude',
|
|
16
|
+
['-p', '--model', 'claude-haiku-4-5', '--output-format', 'text'],
|
|
17
|
+
{
|
|
18
|
+
timeout: timeoutMs,
|
|
19
|
+
env: Object.assign({}, process.env, { TT_DISABLE: '1' }),
|
|
20
|
+
maxBuffer: 1024 * 1024,
|
|
21
|
+
},
|
|
22
|
+
(err, stdout) => (err ? reject(err) : resolve(stdout)),
|
|
23
|
+
);
|
|
24
|
+
child.stdin.end(prompt);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = {
|
|
29
|
+
id: 'claude-code',
|
|
30
|
+
kind: 'cli',
|
|
31
|
+
needs: 'claude CLI logged in (uses your subscription; ~3s/call)',
|
|
32
|
+
available() { return !process.env.TT_DISABLE; },
|
|
33
|
+
async translate(lines, langCode, opts) {
|
|
34
|
+
opts = opts || {};
|
|
35
|
+
const lang = getLang(langCode);
|
|
36
|
+
const name = lang ? lang.name : langCode;
|
|
37
|
+
const prompt =
|
|
38
|
+
'Translate each line of the following JSON array into ' + name + '. ' +
|
|
39
|
+
'If a line is already in ' + name + ', return it unchanged. ' +
|
|
40
|
+
'Keep inline code, file paths, URLs, identifiers, and markdown markers intact. ' +
|
|
41
|
+
'Return ONLY JSON {"t":[...]} with exactly one translation per input line, same order. ' +
|
|
42
|
+
'No prose, no code fences.\n' + JSON.stringify(lines);
|
|
43
|
+
const out = await runClaude(prompt, opts.timeoutMs || 15000);
|
|
44
|
+
// The CLI may wrap output in ```json fences — strip before parsing.
|
|
45
|
+
const cleaned = out.replace(/^[\s\S]*?(\{)/, '$1').replace(/```[\s\S]*$/, '').trim();
|
|
46
|
+
const t = JSON.parse(cleaned).t;
|
|
47
|
+
if (!Array.isArray(t) || t.length !== lines.length) {
|
|
48
|
+
throw new Error('length mismatch ' + (t && t.length) + '/' + lines.length);
|
|
49
|
+
}
|
|
50
|
+
return t;
|
|
51
|
+
},
|
|
52
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// DeepL API. Best traditional-MT quality. Free keys end in ":fx" and use the
|
|
3
|
+
// api-free host. The /v2/translate endpoint accepts an array of texts and
|
|
4
|
+
// returns translations in the same order — perfect line mapping for free.
|
|
5
|
+
const { getLang } = require('../langs');
|
|
6
|
+
const { getKey } = require('../keys');
|
|
7
|
+
|
|
8
|
+
module.exports = {
|
|
9
|
+
id: 'deepl',
|
|
10
|
+
kind: 'mt',
|
|
11
|
+
needs: 'deepl key (tt key deepl <value>)',
|
|
12
|
+
available() { return !!getKey('deepl'); },
|
|
13
|
+
async translate(lines, langCode) {
|
|
14
|
+
const key = getKey('deepl');
|
|
15
|
+
if (!key) throw new Error('no deepl key');
|
|
16
|
+
const lang = getLang(langCode);
|
|
17
|
+
const target = lang ? lang.deepl : String(langCode).toUpperCase();
|
|
18
|
+
const host = key.endsWith(':fx') ? 'api-free.deepl.com' : 'api.deepl.com';
|
|
19
|
+
const res = await fetch('https://' + host + '/v2/translate', {
|
|
20
|
+
method: 'POST',
|
|
21
|
+
headers: {
|
|
22
|
+
'Content-Type': 'application/json',
|
|
23
|
+
Authorization: 'DeepL-Auth-Key ' + key,
|
|
24
|
+
},
|
|
25
|
+
body: JSON.stringify({ text: lines, target_lang: target, preserve_formatting: true }),
|
|
26
|
+
});
|
|
27
|
+
if (!res.ok) throw new Error('deepl ' + res.status + ' ' + (await res.text()).slice(0, 200));
|
|
28
|
+
const j = await res.json();
|
|
29
|
+
const t = (j.translations || []).map((x) => x.text);
|
|
30
|
+
if (t.length !== lines.length) throw new Error('length mismatch ' + t.length + '/' + lines.length);
|
|
31
|
+
return t;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Free, unofficial Google Translate endpoint. No key, fast, medium quality.
|
|
3
|
+
const { getLang } = require('../langs');
|
|
4
|
+
|
|
5
|
+
async function translateOne(line, target) {
|
|
6
|
+
const url =
|
|
7
|
+
'https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=' +
|
|
8
|
+
encodeURIComponent(target) + '&dt=t&q=' + encodeURIComponent(line);
|
|
9
|
+
const res = await fetch(url);
|
|
10
|
+
if (!res.ok) throw new Error('google ' + res.status);
|
|
11
|
+
const j = await res.json();
|
|
12
|
+
return (j[0] || []).map((seg) => seg[0]).join('');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
module.exports = {
|
|
16
|
+
id: 'google',
|
|
17
|
+
kind: 'mt',
|
|
18
|
+
needs: 'nothing (free, unofficial endpoint)',
|
|
19
|
+
available() { return true; },
|
|
20
|
+
async translate(lines, langCode) {
|
|
21
|
+
const lang = getLang(langCode);
|
|
22
|
+
const target = lang ? lang.google : langCode;
|
|
23
|
+
return Promise.all(lines.map((l) => translateOne(l, target).catch(() => l)));
|
|
24
|
+
},
|
|
25
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Backend registry. Each backend implements:
|
|
3
|
+
// id - string
|
|
4
|
+
// kind - 'llm' | 'mt' | 'cli' (informational)
|
|
5
|
+
// needs - human-readable requirement shown by `tt backends`
|
|
6
|
+
// available() -> boolean (are its prerequisites present?)
|
|
7
|
+
// translate(lines, langCode, opts) -> Promise<string[]> (same length/order)
|
|
8
|
+
|
|
9
|
+
const google = require('./google');
|
|
10
|
+
const openai = require('./openai');
|
|
11
|
+
const anthropic = require('./anthropic');
|
|
12
|
+
const deepl = require('./deepl');
|
|
13
|
+
const azure = require('./azure');
|
|
14
|
+
const claudeCode = require('./claude-code');
|
|
15
|
+
|
|
16
|
+
const BACKENDS = [openai, anthropic, deepl, azure, google, claudeCode];
|
|
17
|
+
|
|
18
|
+
function getBackend(id) {
|
|
19
|
+
return BACKENDS.find((b) => b.id === id) || null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function listBackends() {
|
|
23
|
+
return BACKENDS;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Fallback order when the chosen backend fails: free no-key Google last,
|
|
27
|
+
// preceded by whatever keyed backends are actually available.
|
|
28
|
+
function fallbackChain(primaryId) {
|
|
29
|
+
const chain = [primaryId];
|
|
30
|
+
if (primaryId !== 'google') chain.push('google');
|
|
31
|
+
return chain.map(getBackend).filter(Boolean).filter((b) => b.available());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { getBackend, listBackends, fallbackChain };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// OpenAI gpt-4o-mini batch translation. High quality, preserves code/paths.
|
|
3
|
+
const { getLang } = require('../langs');
|
|
4
|
+
const { getKey } = require('../keys');
|
|
5
|
+
|
|
6
|
+
module.exports = {
|
|
7
|
+
id: 'openai',
|
|
8
|
+
kind: 'llm',
|
|
9
|
+
needs: 'openai key (tt key openai <value>)',
|
|
10
|
+
available() { return !!getKey('openai'); },
|
|
11
|
+
async translate(lines, langCode, opts) {
|
|
12
|
+
opts = opts || {};
|
|
13
|
+
const key = getKey('openai');
|
|
14
|
+
if (!key) throw new Error('no openai key');
|
|
15
|
+
const lang = getLang(langCode);
|
|
16
|
+
const name = lang ? lang.name : langCode;
|
|
17
|
+
const sys =
|
|
18
|
+
'You translate developer-tool chat into ' + name + '. ' +
|
|
19
|
+
'Translate each input line to natural, concise ' + name + '. ' +
|
|
20
|
+
'If a line is already in ' + name + ', return it unchanged. ' +
|
|
21
|
+
'Keep inline code, file paths, URLs, identifiers, numbers, and leading markdown ' +
|
|
22
|
+
'markers (#, -, *, >, digits., backticks) intact. ' +
|
|
23
|
+
'Return ONLY JSON {"t":[...]} whose array has EXACTLY the same length and order ' +
|
|
24
|
+
'as the input lines. Never merge, split, add, or drop lines.';
|
|
25
|
+
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + key },
|
|
28
|
+
body: JSON.stringify({
|
|
29
|
+
model: opts.model || require('../config').getState().model,
|
|
30
|
+
temperature: 0,
|
|
31
|
+
response_format: { type: 'json_object' },
|
|
32
|
+
messages: [
|
|
33
|
+
{ role: 'system', content: sys },
|
|
34
|
+
{ role: 'user', content: JSON.stringify({ lines }) },
|
|
35
|
+
],
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
if (!res.ok) throw new Error('openai ' + res.status + ' ' + (await res.text()).slice(0, 200));
|
|
39
|
+
const j = await res.json();
|
|
40
|
+
const parsed = JSON.parse(j.choices[0].message.content);
|
|
41
|
+
const t = parsed.t || parsed.translations || parsed.lines;
|
|
42
|
+
if (!Array.isArray(t) || t.length !== lines.length) {
|
|
43
|
+
throw new Error('length mismatch ' + (t && t.length) + '/' + lines.length);
|
|
44
|
+
}
|
|
45
|
+
return t;
|
|
46
|
+
},
|
|
47
|
+
};
|
package/src/config.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Persistent settings for the translator. Everything user-configurable lives
|
|
3
|
+
// in files under ~/.cc-translate/ — never in shell environment variables:
|
|
4
|
+
// state.json — settings (this module); edit by hand or via tt commands
|
|
5
|
+
// keys.json — API secrets (src/keys.js); chmod 600
|
|
6
|
+
// TT_HOME (test plumbing) and TT_DISABLE/TT_DEBUG_STDIN (hook internals) are
|
|
7
|
+
// the only env vars the tool reads.
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
const HOME = os.homedir();
|
|
14
|
+
const BASE = process.env.TT_HOME || path.join(HOME, '.cc-translate');
|
|
15
|
+
const STATE_FILE = path.join(BASE, 'state.json');
|
|
16
|
+
const CACHE_DIR = path.join(BASE, 'cache');
|
|
17
|
+
|
|
18
|
+
function ensureDirs() {
|
|
19
|
+
try { fs.mkdirSync(CACHE_DIR, { recursive: true }); } catch (e) {}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function defaults() {
|
|
23
|
+
const { getKey } = require('./keys'); // lazy: keys.js must not require config.js
|
|
24
|
+
return {
|
|
25
|
+
enabled: true, // default ON: every reply shows bilingual until toggled off
|
|
26
|
+
backend: getKey('openai') ? 'openai' : 'google',
|
|
27
|
+
target: 'zh-Hans',
|
|
28
|
+
model: 'gpt-4o-mini', // openai backend model
|
|
29
|
+
anthropicModel: 'claude-haiku-4-5', // anthropic backend model
|
|
30
|
+
azureEndpoint: 'https://api.cognitive.microsofttranslator.com',
|
|
31
|
+
marker: '↳ ', // prefix on each translated line
|
|
32
|
+
inputEn: false, // input translation (prompt -> English) off until enabled
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getState() {
|
|
37
|
+
let s = {};
|
|
38
|
+
try { s = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); } catch (e) {}
|
|
39
|
+
return Object.assign(defaults(), s);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function setState(patch) {
|
|
43
|
+
ensureDirs();
|
|
44
|
+
const next = Object.assign({}, getState(), patch);
|
|
45
|
+
// Persist only user-controllable, non-secret fields.
|
|
46
|
+
const persist = {
|
|
47
|
+
enabled: next.enabled,
|
|
48
|
+
backend: next.backend,
|
|
49
|
+
target: next.target,
|
|
50
|
+
model: next.model,
|
|
51
|
+
anthropicModel: next.anthropicModel,
|
|
52
|
+
azureEndpoint: next.azureEndpoint,
|
|
53
|
+
marker: next.marker,
|
|
54
|
+
inputEn: next.inputEn,
|
|
55
|
+
};
|
|
56
|
+
const tmp = STATE_FILE + '.' + process.pid + '.tmp';
|
|
57
|
+
fs.writeFileSync(tmp, JSON.stringify(persist, null, 2));
|
|
58
|
+
fs.renameSync(tmp, STATE_FILE);
|
|
59
|
+
return next;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { HOME, BASE, STATE_FILE, CACHE_DIR, ensureDirs, getState, setState, defaults };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Turn a chunk of assistant text (a MessageDisplay "delta") into interleaved
|
|
3
|
+
// EN/ZH displayContent. Prose lines get a Chinese line; code fences, bare
|
|
4
|
+
// paths/URLs, already-Chinese lines, and blanks pass through untouched.
|
|
5
|
+
|
|
6
|
+
const { translateLines } = require('./translate');
|
|
7
|
+
const { isProbablyTarget } = require('./langs');
|
|
8
|
+
|
|
9
|
+
function looksLikeCodeish(s) {
|
|
10
|
+
const t = s.trim();
|
|
11
|
+
if (!t) return false;
|
|
12
|
+
if (/^[`~]{3}/.test(t)) return true; // fence line
|
|
13
|
+
if (/^https?:\/\/\S+$/.test(t)) return true; // bare url
|
|
14
|
+
if (/^[/~][\w./-]+$/.test(t)) return true; // bare path
|
|
15
|
+
if ((t.match(/[A-Za-z]/g) || []).length === 0) return true; // pure symbols/numbers
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// A code fence (```), and therefore "are we inside a code block?", can span
|
|
20
|
+
// multiple MessageDisplay deltas. The caller threads the ending fence state of
|
|
21
|
+
// one delta into the next (keyed by message_id), so classify takes an initial
|
|
22
|
+
// inFence and returns the ending inFence alongside the plan.
|
|
23
|
+
function classify(lines, inFenceInit, target) {
|
|
24
|
+
const plan = [];
|
|
25
|
+
let inFence = !!inFenceInit;
|
|
26
|
+
for (const line of lines) {
|
|
27
|
+
const isFence = /^\s*[`~]{3}/.test(line);
|
|
28
|
+
if (isFence) { plan.push({ line, kind: 'code' }); inFence = !inFence; continue; }
|
|
29
|
+
if (inFence) { plan.push({ line, kind: 'code' }); continue; }
|
|
30
|
+
if (line.trim() === '') { plan.push({ line, kind: 'blank' }); continue; }
|
|
31
|
+
if (isProbablyTarget(line, target)) { plan.push({ line, kind: 'target' }); continue; }
|
|
32
|
+
if (looksLikeCodeish(line)) { plan.push({ line, kind: 'code' }); continue; }
|
|
33
|
+
plan.push({ line, kind: 'prose' });
|
|
34
|
+
}
|
|
35
|
+
return { plan, inFence };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// How to place the Chinese line under the English line.
|
|
39
|
+
// hardBreak=true uses a CommonMark hard line break (two trailing spaces) so the
|
|
40
|
+
// two lines stay separate even if displayContent is markdown-rendered.
|
|
41
|
+
function pair(enLine, zhLine, marker, hardBreak) {
|
|
42
|
+
const br = hardBreak ? ' \n' : '\n';
|
|
43
|
+
return enLine + br + marker + zhLine;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Returns { displayContent, inFence }:
|
|
47
|
+
// displayContent — the interleaved EN/ZH string, or null to signal "leave this
|
|
48
|
+
// delta as the original English" (nothing to translate, or over the cap).
|
|
49
|
+
// inFence — the code-fence state at the end of this delta, to thread into the
|
|
50
|
+
// next delta of the same message.
|
|
51
|
+
async function buildDisplayContent(rawDelta, opts) {
|
|
52
|
+
opts = opts || {};
|
|
53
|
+
const marker = opts.marker || '↳ ';
|
|
54
|
+
// Smoke-tested on CC 2.1.169: plain "\n" in displayContent renders EN and ZH
|
|
55
|
+
// on separate lines. Hard break (two trailing spaces) only if a future
|
|
56
|
+
// renderer soft-wraps adjacent lines together.
|
|
57
|
+
const hardBreak = opts.hardBreak === true;
|
|
58
|
+
const cap = opts.cap || 9000;
|
|
59
|
+
|
|
60
|
+
const target = opts.target || 'zh-Hans';
|
|
61
|
+
const lines = String(rawDelta).split('\n');
|
|
62
|
+
const { plan, inFence } = classify(lines, opts.inFence, target);
|
|
63
|
+
|
|
64
|
+
const proseIdx = [];
|
|
65
|
+
const proseLines = [];
|
|
66
|
+
for (let i = 0; i < plan.length; i++) {
|
|
67
|
+
if (plan[i].kind === 'prose') { proseIdx.push(i); proseLines.push(plan[i].line); }
|
|
68
|
+
}
|
|
69
|
+
if (proseLines.length === 0) return { displayContent: null, inFence }; // nothing to translate
|
|
70
|
+
|
|
71
|
+
const zh = await translateLines(proseLines, {
|
|
72
|
+
target, backend: opts.backend, model: opts.model, timeoutMs: opts.timeoutMs,
|
|
73
|
+
});
|
|
74
|
+
const zhFor = {};
|
|
75
|
+
for (let j = 0; j < proseIdx.length; j++) zhFor[proseIdx[j]] = zh[j];
|
|
76
|
+
|
|
77
|
+
const out = [];
|
|
78
|
+
for (let i = 0; i < plan.length; i++) {
|
|
79
|
+
const p = plan[i];
|
|
80
|
+
if (p.kind === 'prose') {
|
|
81
|
+
const t = zhFor[i];
|
|
82
|
+
if (t && t.trim() && t.trim() !== p.line.trim()) out.push(pair(p.line, t, marker, hardBreak));
|
|
83
|
+
else out.push(p.line);
|
|
84
|
+
} else {
|
|
85
|
+
out.push(p.line);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const dc = out.join('\n');
|
|
89
|
+
if (dc.length > cap) return { displayContent: null, inFence };
|
|
90
|
+
return { displayContent: dc, inFence };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { buildDisplayContent, classify, looksLikeCodeish };
|
package/src/keys.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// API-key store. Keys come from exactly ONE place: ~/.cc-translate/keys.json
|
|
3
|
+
// (chmod 600), written by `tt setup` / `tt key` or edited by hand. Shell
|
|
4
|
+
// environment variables are never consulted — this tool's keys and the
|
|
5
|
+
// terminal's keys cannot contaminate each other.
|
|
6
|
+
//
|
|
7
|
+
// NOTE: keys.js must not require config.js (config.js requires us for the
|
|
8
|
+
// default-backend decision). TT_HOME is internal plumbing for tests only.
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
const BASE = process.env.TT_HOME || path.join(os.homedir(), '.cc-translate');
|
|
15
|
+
const KEYS_FILE = path.join(BASE, 'keys.json');
|
|
16
|
+
|
|
17
|
+
const KEY_IDS = ['openai', 'anthropic', 'deepl', 'azure', 'azure-region'];
|
|
18
|
+
|
|
19
|
+
function readKeys() {
|
|
20
|
+
try { return JSON.parse(fs.readFileSync(KEYS_FILE, 'utf8')); } catch (e) { return {}; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function writeKeys(obj) {
|
|
24
|
+
fs.mkdirSync(BASE, { recursive: true });
|
|
25
|
+
const tmp = KEYS_FILE + '.' + process.pid + '.tmp';
|
|
26
|
+
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', { mode: 0o600 });
|
|
27
|
+
fs.renameSync(tmp, KEYS_FILE);
|
|
28
|
+
try { fs.chmodSync(KEYS_FILE, 0o600); } catch (e) {}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getKey(id) {
|
|
32
|
+
return readKeys()[id] || null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function setKey(id, value) {
|
|
36
|
+
if (!KEY_IDS.includes(id)) throw new Error('unknown key id: ' + id);
|
|
37
|
+
const k = readKeys();
|
|
38
|
+
if (value == null || value === '') delete k[id];
|
|
39
|
+
else k[id] = value;
|
|
40
|
+
writeKeys(k);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function mask(v) {
|
|
44
|
+
if (!v) return '(unset)';
|
|
45
|
+
return v.length <= 8 ? '****' : v.slice(0, 4) + '…' + v.slice(-4);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { KEYS_FILE, KEY_IDS, getKey, setKey, mask, readKeys };
|