@lancetw/aiyu 0.3.2
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/aiyu-host.js +533 -0
- package/install.js +229 -0
- package/package.json +36 -0
package/aiyu-host.js
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
#!/Users/lancetw/.nvm/versions/node/v24.11.0/bin/node
|
|
2
|
+
// aiyu native messaging host
|
|
3
|
+
// 從 stdin 讀 4-byte LE length + JSON,呼叫 claude -p 或 codex exec,回 JSON 結果。
|
|
4
|
+
// 所有除錯訊息走 stderr,stdout 嚴格只能寫 framing message。
|
|
5
|
+
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
const { spawn } = require("child_process");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const os = require("os");
|
|
12
|
+
|
|
13
|
+
const HOST_VERSION = "0.3.2";
|
|
14
|
+
// 單次 CLI 呼叫上限:全後端統一 300s。健康呼叫遠低於此(claude ~24s、codex ~15s),
|
|
15
|
+
// 300s 幾乎只有真正卡死/中斷/伺服器負載或額度退避拉長時才觸發 → 不再誤砍正在正常工作的
|
|
16
|
+
// 呼叫。偶發失敗(含 codex 罕見的「整個卡住」)由前端 scheduler 優先重試、bounded 後大聲
|
|
17
|
+
// 標示兜底,不會無聲漏譯。
|
|
18
|
+
// timeout 階梯(外層必須比內層寬,否則外層先砍、host 放寬白做):
|
|
19
|
+
// host(本檔 300s) < SW callHost(320s) < 前端 sendTranslate(340s)。
|
|
20
|
+
const SPAWN_TIMEOUT_MS = 300_000;
|
|
21
|
+
// log 寫在腳本同目錄 — Chrome 啟動的是安裝副本(~/Library/Application Support/aiyu/),
|
|
22
|
+
// 該目錄 TCC 可寫;smoke test 從 repo 跑則寫在 host/。
|
|
23
|
+
const LOG_FILE = process.env.AIYU_LOG || path.join(__dirname, "aiyu-host.log");
|
|
24
|
+
|
|
25
|
+
function log(...args) {
|
|
26
|
+
const line = `[${new Date().toISOString()}] ${args
|
|
27
|
+
.map((a) => (typeof a === "string" ? a : JSON.stringify(a)))
|
|
28
|
+
.join(" ")}\n`;
|
|
29
|
+
try {
|
|
30
|
+
fs.appendFileSync(LOG_FILE, line);
|
|
31
|
+
} catch {
|
|
32
|
+
/* ignore */
|
|
33
|
+
}
|
|
34
|
+
process.stderr.write(line);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ----- Native Messaging framing -----
|
|
38
|
+
|
|
39
|
+
let stdinBuf = Buffer.alloc(0);
|
|
40
|
+
|
|
41
|
+
function writeMessage(obj) {
|
|
42
|
+
const json = Buffer.from(JSON.stringify(obj), "utf8");
|
|
43
|
+
const len = Buffer.alloc(4);
|
|
44
|
+
len.writeUInt32LE(json.length, 0);
|
|
45
|
+
process.stdout.write(len);
|
|
46
|
+
process.stdout.write(json);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
process.stdin.on("data", (chunk) => {
|
|
50
|
+
stdinBuf = Buffer.concat([stdinBuf, chunk]);
|
|
51
|
+
while (true) {
|
|
52
|
+
if (stdinBuf.length < 4) return;
|
|
53
|
+
const len = stdinBuf.readUInt32LE(0);
|
|
54
|
+
if (stdinBuf.length < 4 + len) return;
|
|
55
|
+
const body = stdinBuf.slice(4, 4 + len).toString("utf8");
|
|
56
|
+
stdinBuf = stdinBuf.slice(4 + len);
|
|
57
|
+
let msg;
|
|
58
|
+
try {
|
|
59
|
+
msg = JSON.parse(body);
|
|
60
|
+
} catch (e) {
|
|
61
|
+
log("bad json", body.slice(0, 200));
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
handleMessage(msg).catch((e) => {
|
|
65
|
+
log("handler crashed", e.stack || e.message);
|
|
66
|
+
writeMessage({ id: msg.id, error: e.message });
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
process.stdin.on("end", () => process.exit(0));
|
|
72
|
+
|
|
73
|
+
// ----- CLI invocation -----
|
|
74
|
+
|
|
75
|
+
function buildPrompt({ target, style, glossary, customPrompt, segments, context }) {
|
|
76
|
+
const targetLabel =
|
|
77
|
+
target === "zh-TW"
|
|
78
|
+
? "台灣繁體中文(正體中文,台灣地區用語)"
|
|
79
|
+
: target === "zh-CN"
|
|
80
|
+
? "簡體中文"
|
|
81
|
+
: target === "ja"
|
|
82
|
+
? "日本語"
|
|
83
|
+
: target;
|
|
84
|
+
|
|
85
|
+
const styleNote =
|
|
86
|
+
style === "literal"
|
|
87
|
+
? "翻譯風格:直譯,盡量保留原句結構與術語精確度。"
|
|
88
|
+
: style === "academic"
|
|
89
|
+
? "翻譯風格:正式、學術、書面語氣。"
|
|
90
|
+
: "翻譯風格:自然流暢,符合目標語言母語者閱讀習慣。";
|
|
91
|
+
|
|
92
|
+
const glossaryBlock =
|
|
93
|
+
Array.isArray(glossary) && glossary.length
|
|
94
|
+
? "請特別遵守以下用詞對照(左為其他用法,右為應採用之譯詞):\n" +
|
|
95
|
+
glossary.map(([a, b]) => `- ${a} → ${b}`).join("\n")
|
|
96
|
+
: "";
|
|
97
|
+
|
|
98
|
+
const customBlock = customPrompt ? `額外指示:\n${customPrompt}` : "";
|
|
99
|
+
|
|
100
|
+
// 台灣在地化用詞:一句指示+高頻錨點,靠模型自身知識類推其餘 —— 用來取代沉重的詞庫
|
|
101
|
+
//(啟用詞庫每次呼叫多扛 ~2300 字、Opus 超線性變慢)。使用者可關閉詞庫、改靠這段。
|
|
102
|
+
// 僅 zh-TW 適用;其他目標語言不加(filter(Boolean) 會自然濾掉空字串)。
|
|
103
|
+
const taiwanTermNote =
|
|
104
|
+
target === "zh-TW"
|
|
105
|
+
? "用詞在地化:一律採台灣慣用的資訊科技與日常譯詞,避免對岸用語。例如:" +
|
|
106
|
+
"優化→最佳化、軟件→軟體、硬件→硬體、數據→資料、文件→檔案、程序→程式、" +
|
|
107
|
+
"函數→函式、默認→預設、緩存→快取、視頻→影片、網絡→網路、信息→資訊、" +
|
|
108
|
+
"服務器→伺服器、用戶→使用者、質量→品質、性能→效能。未列出的詞亦比照(採台灣慣用語)。"
|
|
109
|
+
: "";
|
|
110
|
+
|
|
111
|
+
// 依情境切換譯者人格:YouTube 字幕=「聽人說話」→ 即時口譯;
|
|
112
|
+
// 其餘(選取文字/網頁書面文字)=「讀書面內容」→ 翻譯記者(編譯)。
|
|
113
|
+
const isSubtitle = context === "youtube";
|
|
114
|
+
|
|
115
|
+
const persona = isSubtitle
|
|
116
|
+
? "你是一位專業資深的即時口譯員,具備母語級的雙語駕馭力與跨領域背景知識," +
|
|
117
|
+
`能即時、精準地把內容轉譯成道地的「${targetLabel}」。`
|
|
118
|
+
: "你是一位專業資深的翻譯記者(編譯),長年處理跨國新聞與專業內容," +
|
|
119
|
+
`譯筆精準、可讀性高,能把內容如實轉譯成道地的「${targetLabel}」。`;
|
|
120
|
+
|
|
121
|
+
const principles = (isSubtitle
|
|
122
|
+
? [
|
|
123
|
+
"口譯準則:",
|
|
124
|
+
"1. 意義優先:傳達說話者的真實意圖、語氣與言下之意,不逐字硬翻;成語、俚語、文化用語改用目標語言的對等說法。字幕是被切碎的短句,務必把整批段落當成連續上下文來理解,不可孤立逐句翻。",
|
|
125
|
+
"1b. 詞義與修辭判讀:一詞多義時依整批上下文選最貼切的義;遇到刻意的比喻、雙關或專門用法,要保留其修辭意圖,不可機械取字典首義,也不可把比喻硬拆成字面。",
|
|
126
|
+
"2. 自然順口:譯文要像專業口譯員當場脫口而出的母語,通順好懂、毫無翻譯腔。",
|
|
127
|
+
"3. 流暢化:原文的口語贅詞、語助詞、結巴與無意義重複(um、you know、「那個…」)在不損失資訊下自然濾除。",
|
|
128
|
+
"4. 語氣對應:講者正式就正式、輕鬆就輕鬆、帶情緒就保留情緒。",
|
|
129
|
+
"5. 全篇連貫:把所有段落視為同一場談話的連續脈絡,專有名詞、術語、人物代稱前後保持一致,同一詞不要忽而兩種譯法。",
|
|
130
|
+
"6. 忠實不臆造:不增添原文沒有的資訊、不自行評論、不漏譯關鍵內容;無法判斷時以最忠實的方式處理,絕不編造。"
|
|
131
|
+
]
|
|
132
|
+
: [
|
|
133
|
+
"編譯準則:",
|
|
134
|
+
"1. 意義與事實並重:忠實傳達原文的事實、論點與語氣,不逐字硬翻;成語、慣用語改用目標語言的對等說法。",
|
|
135
|
+
"1b. 詞義與修辭判讀:一詞多義時依上下文選最貼切的義;遇到刻意的比喻、雙關或專門用法,要保留其修辭意圖,不可機械取字典首義,也不可把比喻硬拆成字面。",
|
|
136
|
+
"2. 書面流暢:譯文要像母語記者撰寫的通順書面文字,遣詞精準、結構清晰、毫無翻譯腔。",
|
|
137
|
+
"3. 譯名規範:人名、地名、機構、職稱採約定俗成的通用譯名,全篇統一。",
|
|
138
|
+
"4. 語域對應:原文正式就正式、論述就保留論述語氣,維持應有的客觀與精確。",
|
|
139
|
+
"5. 精確還原:數據、引述、專有名詞如實譯出,不誇大、不弱化。",
|
|
140
|
+
"6. 忠實不臆造:不增添原文沒有的資訊、不夾帶個人評論、不漏譯關鍵內容;無法判斷時以最忠實的方式處理,絕不編造。"
|
|
141
|
+
])
|
|
142
|
+
.concat("(若上方已指定翻譯風格,語域與直譯/意譯的程度以該風格為準。)")
|
|
143
|
+
.join("\n");
|
|
144
|
+
|
|
145
|
+
const rules = [
|
|
146
|
+
"輸出規則:",
|
|
147
|
+
"1. 每個輸入 id 都必須有且僅有一筆對應譯文,逐段一一對應——不可遺漏、合併或新增 id(「精煉」只在同一段內進行,不得刪減段落)。",
|
|
148
|
+
"2. 只有這些保留原文、不翻譯:程式碼、指令/變數/函式名、URL、檔名、真正的專有名詞(特定人名、產品或品牌名)。其餘一律譯出——月份、星期、單位、職稱、一般名詞等常見字詞都要翻成目標語言(如 December→十二月),不可因為是英文或首字母大寫就保留原文。數字本身的格式維持不變。",
|
|
149
|
+
"3. 保留 Markdown 標記(粗體、斜體、連結語法)。",
|
|
150
|
+
"4. 不要解釋、不要加註,僅輸出譯文。",
|
|
151
|
+
"5. 嚴格只輸出 JSON 陣列,格式:[{\"id\":\"...\",\"zh\":\"...\"}],無任何前後文字、無 markdown 程式碼框。"
|
|
152
|
+
].join("\n");
|
|
153
|
+
|
|
154
|
+
const system = [persona, styleNote, principles, rules, taiwanTermNote, glossaryBlock, customBlock]
|
|
155
|
+
.filter(Boolean)
|
|
156
|
+
.join("\n\n");
|
|
157
|
+
|
|
158
|
+
const user = `輸入段落(JSON):\n${JSON.stringify(segments)}`;
|
|
159
|
+
|
|
160
|
+
// 拆開回傳:claude 走 --system-prompt 把 system 當系統提示、user 當對話內容;
|
|
161
|
+
// codex 沒有對應旗標,會在 runCli 裡再合併成單一字串。
|
|
162
|
+
return { system, user };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const IS_WIN = process.platform === "win32";
|
|
166
|
+
// Windows 可執行副檔名:npm 安裝的 CLI 通常是 .cmd(claude.cmd / codex.cmd),
|
|
167
|
+
// 不是裸檔名 → 找的時候要逐一補上這些副檔名。
|
|
168
|
+
const WIN_EXTS = (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD")
|
|
169
|
+
.split(";")
|
|
170
|
+
.map((e) => e.toLowerCase())
|
|
171
|
+
.filter(Boolean);
|
|
172
|
+
|
|
173
|
+
function isExecutable(p) {
|
|
174
|
+
try {
|
|
175
|
+
// Windows 沒有 unix 執行位元,accessSync(X_OK) 不可靠 → 存在且是檔案即可。
|
|
176
|
+
if (IS_WIN) return fs.statSync(p).isFile();
|
|
177
|
+
fs.accessSync(p, fs.constants.X_OK);
|
|
178
|
+
return true;
|
|
179
|
+
} catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 在某目錄裡找可執行檔:unix 直接試裸名;Windows 已含副檔名就試該名,否則逐一補 PATHEXT。
|
|
185
|
+
function resolveIn(dir, name) {
|
|
186
|
+
if (!IS_WIN) {
|
|
187
|
+
const full = path.join(dir, name);
|
|
188
|
+
return isExecutable(full) ? full : null;
|
|
189
|
+
}
|
|
190
|
+
const candidates = path.extname(name) ? [name] : WIN_EXTS.map((e) => name + e);
|
|
191
|
+
for (const c of candidates) {
|
|
192
|
+
const full = path.join(dir, c);
|
|
193
|
+
if (isExecutable(full)) return full;
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 盡量找到 CLI 執行檔:環境變數覆寫 → 穩定安裝位置 → PATH → 保底位置。
|
|
199
|
+
// 偵測必須可靠 — 否則「沒有 codex 時自動改用 claude」會因找不到 claude 而失敗。
|
|
200
|
+
function findExecutable(name) {
|
|
201
|
+
const envPath = process.env[`AIYU_${name.toUpperCase()}_PATH`];
|
|
202
|
+
if (envPath && isExecutable(envPath)) return envPath;
|
|
203
|
+
|
|
204
|
+
const home = os.homedir();
|
|
205
|
+
const dirs = [];
|
|
206
|
+
|
|
207
|
+
if (IS_WIN) {
|
|
208
|
+
// Windows 常見安裝位置(npm -g 的 .cmd 落在 %APPDATA%\npm)。
|
|
209
|
+
const appdata = process.env.APPDATA || path.join(home, "AppData/Roaming");
|
|
210
|
+
const localappdata = process.env.LOCALAPPDATA || path.join(home, "AppData/Local");
|
|
211
|
+
dirs.push(
|
|
212
|
+
path.join(appdata, "npm"),
|
|
213
|
+
path.join(home, "scoop", "shims"),
|
|
214
|
+
path.join(localappdata, "Microsoft", "WinGet", "Links"),
|
|
215
|
+
"C:\\ProgramData\\chocolatey\\bin",
|
|
216
|
+
path.join(home, ".bun", "bin")
|
|
217
|
+
);
|
|
218
|
+
} else {
|
|
219
|
+
// 穩定的套件管理器安裝位置「優先於 PATH」。
|
|
220
|
+
// 原因:native host 由 Chrome 啟動時 PATH 極簡 / 或 launcher 會前置各 nvm
|
|
221
|
+
// node 版本的 bin(為了找到 node)。若某個舊 node 版本下殘留過時的
|
|
222
|
+
// codex/claude,照 PATH 順序掃會先撞到它(實測:舊 codex 不認得
|
|
223
|
+
// --ephemeral,翻譯整個失敗)。Homebrew / ~/.local 等位置由套件管理器
|
|
224
|
+
// 維護、隨升級更新,比 nvm 版本 bin 可靠,故優先採用。
|
|
225
|
+
dirs.push(
|
|
226
|
+
"/opt/homebrew/bin",
|
|
227
|
+
"/usr/local/bin",
|
|
228
|
+
path.join(home, ".local/bin"),
|
|
229
|
+
"/usr/bin",
|
|
230
|
+
"/bin"
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 其次掃 PATH(用 path.delimiter 跨平台;去重避免重複檢查)
|
|
235
|
+
for (const d of (process.env.PATH || "").split(path.delimiter).filter(Boolean)) {
|
|
236
|
+
if (!dirs.includes(d)) dirs.push(d);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (!IS_WIN) {
|
|
240
|
+
// 補其他套件管理器位置
|
|
241
|
+
dirs.push(
|
|
242
|
+
path.join(home, ".bun/bin"),
|
|
243
|
+
path.join(home, ".volta/bin"),
|
|
244
|
+
path.join(home, ".deno/bin"),
|
|
245
|
+
path.join(home, ".npm-global/bin"),
|
|
246
|
+
path.join(home, ".npm-packages/bin"),
|
|
247
|
+
path.join(home, ".local/share/pnpm"),
|
|
248
|
+
path.join(home, "Library/pnpm"),
|
|
249
|
+
path.join(home, ".yarn/bin")
|
|
250
|
+
);
|
|
251
|
+
// 最後才掃 nvm 各 node 版本的 bin —— 最容易殘留舊版,放最後當保底
|
|
252
|
+
try {
|
|
253
|
+
const nvmRoot = path.join(home, ".nvm/versions/node");
|
|
254
|
+
for (const v of fs.readdirSync(nvmRoot)) {
|
|
255
|
+
dirs.push(path.join(nvmRoot, v, "bin"));
|
|
256
|
+
}
|
|
257
|
+
} catch {
|
|
258
|
+
/* 沒裝 nvm */
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
for (const dir of dirs) {
|
|
263
|
+
const found = resolveIn(dir, name);
|
|
264
|
+
if (found) return found;
|
|
265
|
+
}
|
|
266
|
+
return null; // 真的找不到才回 null,不要回字面名稱
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function detectAvailability() {
|
|
270
|
+
return {
|
|
271
|
+
claude: !!findExecutable("claude"),
|
|
272
|
+
codex: !!findExecutable("codex"),
|
|
273
|
+
agy: !!findExecutable("agy")
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function pickCli(requested) {
|
|
278
|
+
const av = detectAvailability();
|
|
279
|
+
if (av[requested]) return { cli: requested, fellBack: false };
|
|
280
|
+
// 找其他可用
|
|
281
|
+
for (const alt of ["claude", "codex", "agy"]) {
|
|
282
|
+
if (alt !== requested && av[alt]) return { cli: alt, fellBack: true, from: requested };
|
|
283
|
+
}
|
|
284
|
+
return { cli: null, fellBack: false, error: "no CLI installed" };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// 從 CLI stderr 判斷是否「用量/額度上限」。
|
|
288
|
+
// codex 實測訊息:"You've hit your usage limit. ... try again at 3:43 PM."
|
|
289
|
+
// claude 的確切字樣未完整確認 → 用較寬的樣式一併涵蓋(usage/rate limit、quota、429…)。
|
|
290
|
+
function isQuotaError(stderr) {
|
|
291
|
+
return /usage limit|rate limit|quota|too many requests|\b429\b|out of credit|credit balance|insufficient_quota/i.test(
|
|
292
|
+
stderr || ""
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function runCli(cli, prompt, model, context) {
|
|
297
|
+
return new Promise((resolve, reject) => {
|
|
298
|
+
let bin, args;
|
|
299
|
+
let outFile = null; // codex 走 --output-last-message 寫檔,避免 stdout trace 污染
|
|
300
|
+
let stdinPayload = null; // Windows shell 模式改走 stdin 餵 prompt,避免 cmd.exe 拆引號
|
|
301
|
+
|
|
302
|
+
if (cli === "claude") {
|
|
303
|
+
bin = findExecutable("claude");
|
|
304
|
+
args = [
|
|
305
|
+
"-p",
|
|
306
|
+
"--tools", "",
|
|
307
|
+
"--system-prompt", prompt.system,
|
|
308
|
+
"--strict-mcp-config",
|
|
309
|
+
"--no-session-persistence",
|
|
310
|
+
"--disable-slash-commands"
|
|
311
|
+
];
|
|
312
|
+
if (model) args.push("--model", model);
|
|
313
|
+
args.push(prompt.user);
|
|
314
|
+
} else if (cli === "codex") {
|
|
315
|
+
bin = findExecutable("codex");
|
|
316
|
+
outFile = path.join(
|
|
317
|
+
os.tmpdir(),
|
|
318
|
+
`aiyu-codex-${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2)}.txt`
|
|
319
|
+
);
|
|
320
|
+
args = [
|
|
321
|
+
"exec",
|
|
322
|
+
"--skip-git-repo-check",
|
|
323
|
+
"--ephemeral",
|
|
324
|
+
"--color", "never",
|
|
325
|
+
"-c", `model_reasoning_effort=${context === "youtube" ? "low" : "medium"}`,
|
|
326
|
+
"--output-last-message", outFile
|
|
327
|
+
];
|
|
328
|
+
if (model) args.push("-m", model);
|
|
329
|
+
args.push(`${prompt.system}\n\n${prompt.user}`);
|
|
330
|
+
} else if (cli === "agy") {
|
|
331
|
+
bin = findExecutable("agy");
|
|
332
|
+
args = ["-p", `${prompt.system}\n\n${prompt.user}`];
|
|
333
|
+
} else {
|
|
334
|
+
reject(new Error(`unknown cli: ${cli}`));
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const spawnT0 = Date.now();
|
|
339
|
+
const promptLen = prompt.system.length + prompt.user.length;
|
|
340
|
+
log("spawn", bin, "subcmd=", args[0], "model=", model || "(default)", "prompt-len=", promptLen);
|
|
341
|
+
|
|
342
|
+
const extraPath = IS_WIN
|
|
343
|
+
? [path.join(process.env.APPDATA || path.join(os.homedir(), "AppData/Roaming"), "npm")]
|
|
344
|
+
: ["/opt/homebrew/bin", "/usr/local/bin", path.join(os.homedir(), ".local/bin")];
|
|
345
|
+
const env = {
|
|
346
|
+
...process.env,
|
|
347
|
+
PATH: [...extraPath, process.env.PATH || ""].join(path.delimiter)
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
// Windows:.cmd/.bat 須經 shell 才能 spawn(否則 Node 丟 EINVAL)。但 cmd.exe 會
|
|
351
|
+
// 對 argv 做 tokenization,含空白/引號/換行的長 prompt 會被拆散 → CLI 報錯。
|
|
352
|
+
// 解法:把 prompt 從 argv 拔掉,改寫入 stdin(codex/claude/agy 都支援從 stdin 讀 prompt)。
|
|
353
|
+
const useShell = IS_WIN && /\.(cmd|bat)$/i.test(bin || "");
|
|
354
|
+
if (useShell) {
|
|
355
|
+
if (cli === "codex") {
|
|
356
|
+
// codex: 最後一個 positional arg 就是 prompt → 拔掉,改餵 stdin
|
|
357
|
+
stdinPayload = args.pop();
|
|
358
|
+
} else if (cli === "claude") {
|
|
359
|
+
// claude -p: 最後一個 positional arg 是 user prompt;--system-prompt 的值也
|
|
360
|
+
// 可能含特殊字元 → 一併改寫 tmpfile 再用 --system-prompt-file(未來可考慮)。
|
|
361
|
+
// 但 claude -p 支援從 stdin 讀 prompt → 先拔 user prompt 就好。
|
|
362
|
+
stdinPayload = args.pop();
|
|
363
|
+
} else if (cli === "agy") {
|
|
364
|
+
// agy -p <prompt> → 拔 prompt,改走 stdin
|
|
365
|
+
stdinPayload = args.pop();
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const child = spawn(bin, args, {
|
|
370
|
+
env,
|
|
371
|
+
cwd: os.tmpdir(),
|
|
372
|
+
stdio: [stdinPayload != null ? "pipe" : "ignore", "pipe", "pipe"],
|
|
373
|
+
shell: useShell
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
if (stdinPayload != null) {
|
|
377
|
+
child.stdin.write(stdinPayload);
|
|
378
|
+
child.stdin.end();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
let stdout = "";
|
|
382
|
+
let stderr = "";
|
|
383
|
+
let killed = false;
|
|
384
|
+
|
|
385
|
+
const cleanup = () => {
|
|
386
|
+
if (outFile) {
|
|
387
|
+
try { fs.unlinkSync(outFile); } catch { /* ignore */ }
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const timer = setTimeout(() => {
|
|
392
|
+
killed = true;
|
|
393
|
+
child.kill("SIGKILL");
|
|
394
|
+
cleanup();
|
|
395
|
+
// 有些 CLI(如 claude)達額度上限時會退避重試 → 拖到逾時。若期間 stderr
|
|
396
|
+
// 已透露額度字樣,當作額度用盡回報,而非單純逾時。
|
|
397
|
+
if (isQuotaError(stderr)) {
|
|
398
|
+
reject(new Error(`翻譯額度用盡,請稍後再試(${cli})`));
|
|
399
|
+
} else {
|
|
400
|
+
reject(new Error(`CLI timeout after ${SPAWN_TIMEOUT_MS}ms`));
|
|
401
|
+
}
|
|
402
|
+
}, SPAWN_TIMEOUT_MS);
|
|
403
|
+
|
|
404
|
+
// 設定 encoding:讓 stream 內部以 StringDecoder 跨 chunk 邊界解碼 UTF-8。
|
|
405
|
+
// 否則「+= d.toString('utf8')」會在多位元組字元(CJK = 3 bytes)被 data 事件
|
|
406
|
+
// 切半時,把不完整片段各自解碼成 U+FFFD(�)→ 譯文出現「��」亂碼。codex 走
|
|
407
|
+
// 檔案讀取(一次解碼)故無此問題;agy/claude 走串流 stdout 才會中招。
|
|
408
|
+
child.stdout.setEncoding("utf8");
|
|
409
|
+
child.stderr.setEncoding("utf8");
|
|
410
|
+
child.stdout.on("data", (d) => (stdout += d));
|
|
411
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
412
|
+
child.on("error", (e) => {
|
|
413
|
+
clearTimeout(timer);
|
|
414
|
+
cleanup();
|
|
415
|
+
reject(new Error(`spawn failed: ${e.message}`));
|
|
416
|
+
});
|
|
417
|
+
child.on("close", (code) => {
|
|
418
|
+
clearTimeout(timer);
|
|
419
|
+
if (killed) return;
|
|
420
|
+
const elapsed = ((Date.now() - spawnT0) / 1000).toFixed(1);
|
|
421
|
+
if (code !== 0) {
|
|
422
|
+
log("cli non-zero exit", code, "after", elapsed + "s", "stderr:", stderr.slice(0, 500));
|
|
423
|
+
cleanup();
|
|
424
|
+
if (isQuotaError(stderr)) {
|
|
425
|
+
// 額度用盡:給乾淨、可辨識的訊息(關鍵字「額度用盡」),讓前端在影片
|
|
426
|
+
// 右上角顯示專屬狀態。否則 stderr.slice(0,200) 會把 usage-limit 字樣切掉。
|
|
427
|
+
reject(new Error(`翻譯額度用盡,請稍後再試(${cli})`));
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
reject(new Error(`${cli} exited with code ${code}: ${stderr.slice(0, 200)}`));
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
log("cli done", cli, "in", elapsed + "s");
|
|
434
|
+
let payload = stdout;
|
|
435
|
+
if (outFile) {
|
|
436
|
+
try {
|
|
437
|
+
payload = fs.readFileSync(outFile, "utf8");
|
|
438
|
+
} catch (e) {
|
|
439
|
+
cleanup();
|
|
440
|
+
reject(new Error(`讀取 codex 輸出失敗: ${e.message}`));
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
cleanup();
|
|
444
|
+
}
|
|
445
|
+
resolve(payload);
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function extractJsonArray(s) {
|
|
451
|
+
// 從輸出中抽出第一個 [...] 區塊,支援前後有雜訊或 ```json 包裹
|
|
452
|
+
const fenced = s.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
453
|
+
if (fenced) {
|
|
454
|
+
try {
|
|
455
|
+
const v = JSON.parse(fenced[1]);
|
|
456
|
+
if (Array.isArray(v)) return v;
|
|
457
|
+
} catch {
|
|
458
|
+
/* fallthrough */
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const start = s.indexOf("[");
|
|
462
|
+
const end = s.lastIndexOf("]");
|
|
463
|
+
if (start === -1 || end === -1 || end <= start) {
|
|
464
|
+
throw new Error("找不到 JSON 陣列輸出");
|
|
465
|
+
}
|
|
466
|
+
return JSON.parse(s.slice(start, end + 1));
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ----- Message handlers -----
|
|
470
|
+
|
|
471
|
+
async function handleMessage(msg) {
|
|
472
|
+
if (msg.action === "ping") {
|
|
473
|
+
writeMessage({
|
|
474
|
+
id: msg.id,
|
|
475
|
+
result: {
|
|
476
|
+
host: "aiyu-host",
|
|
477
|
+
version: HOST_VERSION,
|
|
478
|
+
node: process.version,
|
|
479
|
+
claude: findExecutable("claude"),
|
|
480
|
+
codex: findExecutable("codex"),
|
|
481
|
+
agy: findExecutable("agy"),
|
|
482
|
+
available: detectAvailability()
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (msg.action === "translate") {
|
|
489
|
+
const { cli: requested = "codex", model = null, segments = [] } = msg;
|
|
490
|
+
if (!Array.isArray(segments) || segments.length === 0) {
|
|
491
|
+
writeMessage({ id: msg.id, error: "no segments" });
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
const picked = pickCli(requested);
|
|
495
|
+
if (!picked.cli) {
|
|
496
|
+
writeMessage({ id: msg.id, error: picked.error || "no CLI installed" });
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
if (picked.fellBack) {
|
|
500
|
+
log("fallback", picked.from, "→", picked.cli);
|
|
501
|
+
}
|
|
502
|
+
// fallback 後 model 可能不適用對方 CLI(gpt-* 給 claude 會炸)→ 只在沒 fallback 時帶 model
|
|
503
|
+
const effectiveModel = picked.fellBack ? null : model;
|
|
504
|
+
const prompt = buildPrompt({
|
|
505
|
+
target: msg.target,
|
|
506
|
+
style: msg.style,
|
|
507
|
+
glossary: msg.glossary,
|
|
508
|
+
customPrompt: msg.customPrompt,
|
|
509
|
+
segments,
|
|
510
|
+
context: msg.context
|
|
511
|
+
});
|
|
512
|
+
try {
|
|
513
|
+
const stdout = await runCli(picked.cli, prompt, effectiveModel, msg.context);
|
|
514
|
+
const parsed = extractJsonArray(stdout);
|
|
515
|
+
const result = parsed
|
|
516
|
+
.filter((x) => x && typeof x === "object" && "id" in x && "zh" in x)
|
|
517
|
+
.map((x) => ({ id: String(x.id), zh: String(x.zh) }));
|
|
518
|
+
writeMessage({
|
|
519
|
+
id: msg.id,
|
|
520
|
+
result,
|
|
521
|
+
meta: { usedCli: picked.cli, fellBack: picked.fellBack || false }
|
|
522
|
+
});
|
|
523
|
+
} catch (e) {
|
|
524
|
+
log("translate error", e.message);
|
|
525
|
+
writeMessage({ id: msg.id, error: e.message });
|
|
526
|
+
}
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
writeMessage({ id: msg.id, error: `unknown action: ${msg.action}` });
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
log("aiyu-host started", HOST_VERSION, "node", process.version);
|
package/install.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
// aiyu 跨平台 native host 安裝器 —— 取代 install.sh / deploy.sh,並新增 Windows 支援。
|
|
4
|
+
//
|
|
5
|
+
// 做的事:
|
|
6
|
+
// 1. 把 host(aiyu-host.js)複製到穩定目錄(mac/win 的 app data;linux 的 ~/.local/share)。
|
|
7
|
+
// —— host 必須住在 repo 外:macOS TCC 會擋 Chrome 存取 ~/Documents 下的 script。
|
|
8
|
+
// 2. 產生 launcher(unix .sh / win .cmd),用「當下這顆 node 的絕對路徑」啟動 host。
|
|
9
|
+
// —— 不依賴 shebang,順手解決 aiyu-host.js 寫死 nvm 路徑、以及 Windows 無 shebang 的問題。
|
|
10
|
+
// 3. 註冊 native messaging manifest:
|
|
11
|
+
// mac/linux → 寫進每個偵測到的瀏覽器 NativeMessagingHosts 目錄
|
|
12
|
+
// windows → 寫一份 manifest 到安裝目錄,再用 reg.exe 把各瀏覽器登錄檔機碼指過去
|
|
13
|
+
// 4. 印出「載入未封裝」的擴充資料夾路徑與步驟(off-store 安裝)。
|
|
14
|
+
//
|
|
15
|
+
// 用法:
|
|
16
|
+
// node install.js 安裝 host 並印出擴充安裝步驟
|
|
17
|
+
// node install.js --dry-run 只印出會做什麼,不實際寫入
|
|
18
|
+
// node install.js --uninstall 移除 host 註冊(manifest / 登錄檔)
|
|
19
|
+
//
|
|
20
|
+
// 擴充 ID 是從 extension/manifest.json 的 key 推導出來的固定值(off-store 自有金鑰,永遠一致)。
|
|
21
|
+
|
|
22
|
+
const fs = require("fs");
|
|
23
|
+
const os = require("os");
|
|
24
|
+
const path = require("path");
|
|
25
|
+
const { execFileSync } = require("child_process");
|
|
26
|
+
|
|
27
|
+
const HOST_NAME = "com.lancetw.aiyu";
|
|
28
|
+
// 擴充 ID。host 的 allowed_origins 同時信任兩者(dual-ID):
|
|
29
|
+
// DEV = off-store 載入未封裝(ID 由 manifest 的 key 推導,固定)
|
|
30
|
+
// STORE = Chrome Web Store 上架後指派的 ID
|
|
31
|
+
// 兩個 build 用不同 ID,但同一台 host 都收 → dev 與 store 安裝都能連線。
|
|
32
|
+
// DEV 重新計算: node -e 'const c=require("crypto"),f=require("fs");const k=JSON.parse(f.readFileSync("extension/manifest.json")).key;console.log([...c.createHash("sha256").update(Buffer.from(k,"base64")).digest("hex").slice(0,32)].map(x=>String.fromCharCode(97+parseInt(x,16))).join(""))'
|
|
33
|
+
const DEV_EXT_ID = "loelfpeedlfjbjekifhjbbgejajnnpan";
|
|
34
|
+
const STORE_EXT_ID = "mkdjepnmcmmjbnhkligompoblagocjmd";
|
|
35
|
+
const EXT_IDS = [DEV_EXT_ID, STORE_EXT_ID];
|
|
36
|
+
|
|
37
|
+
const PLATFORM = process.platform; // 'darwin' | 'linux' | 'win32'
|
|
38
|
+
const HOME = os.homedir();
|
|
39
|
+
const SRC_DIR = __dirname; // host/
|
|
40
|
+
const REPO_ROOT = path.join(SRC_DIR, "..");
|
|
41
|
+
const EXT_DIR = path.join(REPO_ROOT, "extension");
|
|
42
|
+
const HOST_JS_SRC = path.join(SRC_DIR, "aiyu-host.js");
|
|
43
|
+
|
|
44
|
+
const argv = new Set(process.argv.slice(2));
|
|
45
|
+
const DRY = argv.has("--dry-run");
|
|
46
|
+
const UNINSTALL = argv.has("--uninstall");
|
|
47
|
+
const HELP = argv.has("--help") || argv.has("-h");
|
|
48
|
+
|
|
49
|
+
function log(...a) { console.log(...a); }
|
|
50
|
+
function step(s) { console.log((DRY ? "[dry-run] " : "") + s); }
|
|
51
|
+
|
|
52
|
+
function installDir() {
|
|
53
|
+
if (PLATFORM === "darwin") return path.join(HOME, "Library/Application Support/aiyu");
|
|
54
|
+
if (PLATFORM === "win32")
|
|
55
|
+
return path.join(process.env.LOCALAPPDATA || path.join(HOME, "AppData/Local"), "aiyu");
|
|
56
|
+
return path.join(process.env.XDG_DATA_HOME || path.join(HOME, ".local/share"), "aiyu"); // linux
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// unix:各瀏覽器 NativeMessagingHosts 目錄(parent 存在才算裝了該瀏覽器)
|
|
60
|
+
function unixBrowserDirs() {
|
|
61
|
+
const ASUP = path.join(HOME, "Library/Application Support");
|
|
62
|
+
const CFG = process.env.XDG_CONFIG_HOME || path.join(HOME, ".config");
|
|
63
|
+
if (PLATFORM === "darwin")
|
|
64
|
+
return [
|
|
65
|
+
"Google/Chrome", "Google/Chrome Canary", "Google/Chrome Beta", "Google/Chrome Dev",
|
|
66
|
+
"Google/Chrome for Testing", "Chromium", "Microsoft Edge",
|
|
67
|
+
"BraveSoftware/Brave-Browser", "Arc/User Data"
|
|
68
|
+
].map((b) => path.join(ASUP, b, "NativeMessagingHosts"));
|
|
69
|
+
return [
|
|
70
|
+
"google-chrome", "google-chrome-beta", "google-chrome-unstable", "chromium",
|
|
71
|
+
"microsoft-edge", "BraveSoftware/Brave-Browser"
|
|
72
|
+
].map((b) => path.join(CFG, b, "NativeMessagingHosts"));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// windows:各瀏覽器登錄檔機碼(HKCU,免管理員)
|
|
76
|
+
function winRegKeys() {
|
|
77
|
+
return [
|
|
78
|
+
"Software\\Google\\Chrome",
|
|
79
|
+
"Software\\Chromium",
|
|
80
|
+
"Software\\Microsoft\\Edge",
|
|
81
|
+
"Software\\BraveSoftware\\Brave-Browser"
|
|
82
|
+
].map((b) => `HKCU\\${b}\\NativeMessagingHosts\\${HOST_NAME}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function regExe() {
|
|
86
|
+
return path.join(process.env.SystemRoot || "C:\\Windows", "System32", "reg.exe");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function manifestObject(launcherPath) {
|
|
90
|
+
return {
|
|
91
|
+
name: HOST_NAME,
|
|
92
|
+
description: "aiyu — AI 譯語 native host",
|
|
93
|
+
path: launcherPath,
|
|
94
|
+
type: "stdio",
|
|
95
|
+
allowed_origins: EXT_IDS.map((id) => `chrome-extension://${id}/`)
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function writeFile(p, content, mode) {
|
|
100
|
+
step(`寫入 ${p}`);
|
|
101
|
+
if (DRY) return;
|
|
102
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
103
|
+
fs.writeFileSync(p, content);
|
|
104
|
+
if (mode) fs.chmodSync(p, mode);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function doInstall() {
|
|
108
|
+
if (!fs.existsSync(HOST_JS_SRC)) {
|
|
109
|
+
console.error(`✗ 找不到 host:${HOST_JS_SRC}(請從 repo 根目錄附近執行)`);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const dir = installDir();
|
|
114
|
+
const hostJsDst = path.join(dir, "aiyu-host.js");
|
|
115
|
+
|
|
116
|
+
// 1. 複製 host
|
|
117
|
+
step(`複製 host → ${hostJsDst}`);
|
|
118
|
+
if (!DRY) {
|
|
119
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
120
|
+
fs.copyFileSync(HOST_JS_SRC, hostJsDst);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 2. 產生 launcher(用 process.execPath 這顆 node 的絕對路徑啟動,不靠 shebang)
|
|
124
|
+
const node = process.execPath;
|
|
125
|
+
let launcherPath;
|
|
126
|
+
if (PLATFORM === "win32") {
|
|
127
|
+
launcherPath = path.join(dir, "aiyu-host.cmd");
|
|
128
|
+
writeFile(launcherPath, `@echo off\r\n"${node}" "${hostJsDst}" %*\r\n`);
|
|
129
|
+
} else {
|
|
130
|
+
launcherPath = path.join(dir, "aiyu-host-launcher.sh");
|
|
131
|
+
const sh =
|
|
132
|
+
`#!/bin/sh
|
|
133
|
+
# Chrome 啟動 native host 時 PATH 極簡 —— 補上常見 CLI 安裝位置,再用固定的 node 啟動 host。
|
|
134
|
+
export PATH="/opt/homebrew/bin:/usr/local/bin:$HOME/.local/bin:$PATH"
|
|
135
|
+
exec "${node}" "${hostJsDst}" "$@"
|
|
136
|
+
`;
|
|
137
|
+
writeFile(launcherPath, sh, 0o755);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 3. 註冊 manifest
|
|
141
|
+
const manifest = JSON.stringify(manifestObject(launcherPath), null, 2);
|
|
142
|
+
let registered = 0;
|
|
143
|
+
if (PLATFORM === "win32") {
|
|
144
|
+
const manifestPath = path.join(dir, `${HOST_NAME}.json`);
|
|
145
|
+
writeFile(manifestPath, manifest);
|
|
146
|
+
for (const key of winRegKeys()) {
|
|
147
|
+
step(`reg add ${key} → ${manifestPath}`);
|
|
148
|
+
if (DRY) { registered++; continue; }
|
|
149
|
+
try {
|
|
150
|
+
execFileSync(regExe(), ["add", key, "/ve", "/t", "REG_SZ", "/d", manifestPath, "/f"], {
|
|
151
|
+
stdio: "ignore"
|
|
152
|
+
});
|
|
153
|
+
registered++;
|
|
154
|
+
} catch (e) {
|
|
155
|
+
console.error(` ⚠ 寫入登錄檔失敗 ${key}: ${e.message}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
for (const d of unixBrowserDirs()) {
|
|
160
|
+
if (!fs.existsSync(path.dirname(d))) continue; // 沒裝這個瀏覽器
|
|
161
|
+
writeFile(path.join(d, `${HOST_NAME}.json`), manifest);
|
|
162
|
+
registered++;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (registered === 0)
|
|
167
|
+
console.error("\n⚠ 沒偵測到任何已安裝的 Chromium 系瀏覽器。host 已就位,但未寫入任何瀏覽器。");
|
|
168
|
+
|
|
169
|
+
printExtensionSteps();
|
|
170
|
+
log(`\n✓ host 安裝完成(${registered} 個瀏覽器位置)。`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function printExtensionSteps() {
|
|
174
|
+
if (fs.existsSync(EXT_DIR)) {
|
|
175
|
+
// 從 repo 跑(有同層 extension/):載入未封裝 dev build
|
|
176
|
+
log("\n──────── 安裝擴充(off-store / 載入未封裝)────────");
|
|
177
|
+
log("1. 開啟 chrome://extensions");
|
|
178
|
+
log("2. 右上角開啟「開發人員模式」(請保持開啟,否則擴充會被停用)");
|
|
179
|
+
log("3. 點「載入未封裝項目」,選這個資料夾:");
|
|
180
|
+
log(` ${EXT_DIR}`);
|
|
181
|
+
log(`4. 載入後顯示的 Extension ID 應為: ${DEV_EXT_ID}`);
|
|
182
|
+
log(" (若不同,表示 manifest 的 key 被改過 → 重跑安裝器更新 host 的 allowed_origins)");
|
|
183
|
+
log("5. 重啟瀏覽器,點擴充圖示 →「測試 host 連線」。");
|
|
184
|
+
} else {
|
|
185
|
+
// 從 npx 跑(沒有同層 extension/):擴充來自 Chrome Web Store
|
|
186
|
+
log("\n──────── 安裝擴充(Chrome Web Store)────────");
|
|
187
|
+
log("1. 到 Chrome Web Store 安裝 aiyu:");
|
|
188
|
+
log(` https://chromewebstore.google.com/detail/${STORE_EXT_ID}`);
|
|
189
|
+
log("2. 重啟瀏覽器,點擴充圖示 →「測試 host 連線」。");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function doUninstall() {
|
|
194
|
+
const dir = installDir();
|
|
195
|
+
if (PLATFORM === "win32") {
|
|
196
|
+
for (const key of winRegKeys()) {
|
|
197
|
+
step(`reg delete ${key}`);
|
|
198
|
+
if (!DRY) {
|
|
199
|
+
try { execFileSync(regExe(), ["delete", key, "/f"], { stdio: "ignore" }); } catch { /* 沒這個機碼 */ }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} else {
|
|
203
|
+
for (const d of unixBrowserDirs()) {
|
|
204
|
+
const out = path.join(d, `${HOST_NAME}.json`);
|
|
205
|
+
if (fs.existsSync(out)) { step(`刪除 ${out}`); if (!DRY) fs.rmSync(out); }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
step(`刪除安裝目錄 ${dir}`);
|
|
209
|
+
if (!DRY && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
|
|
210
|
+
log("\n✓ 已移除 host 註冊。擴充請到 chrome://extensions 自行移除。");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function printHelp() {
|
|
214
|
+
log(`aiyu 跨平台 native host 安裝器
|
|
215
|
+
|
|
216
|
+
用法:
|
|
217
|
+
node install.js 安裝 host 並印出擴充安裝步驟
|
|
218
|
+
node install.js --dry-run 只印出會做什麼,不實際寫入
|
|
219
|
+
node install.js --uninstall 移除 host 註冊(manifest / 登錄檔)
|
|
220
|
+
|
|
221
|
+
平台:${PLATFORM} 擴充 ID(dev/store):${DEV_EXT_ID} / ${STORE_EXT_ID}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
(function main() {
|
|
225
|
+
if (HELP) return printHelp();
|
|
226
|
+
log(`aiyu installer 平台=${PLATFORM} node=${process.version}${DRY ? " (dry-run)" : ""}`);
|
|
227
|
+
if (UNINSTALL) return doUninstall();
|
|
228
|
+
doInstall();
|
|
229
|
+
})();
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lancetw/aiyu",
|
|
3
|
+
"version": "0.3.2",
|
|
4
|
+
"description": "Installs the native-messaging host for the aiyu Chrome extension, letting it call your local claude/codex/agy CLI to translate YouTube subtitles and selected text. Run: npx @lancetw/aiyu",
|
|
5
|
+
"bin": {
|
|
6
|
+
"aiyu": "install.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"aiyu-host.js",
|
|
10
|
+
"install.js"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "lancetw",
|
|
17
|
+
"homepage": "https://github.com/lancetw/aiyu#readme",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/lancetw/aiyu/issues"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/lancetw/aiyu.git",
|
|
24
|
+
"directory": "host"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"aiyu",
|
|
28
|
+
"translation",
|
|
29
|
+
"youtube",
|
|
30
|
+
"subtitles",
|
|
31
|
+
"native-messaging",
|
|
32
|
+
"chrome-extension",
|
|
33
|
+
"claude",
|
|
34
|
+
"codex"
|
|
35
|
+
]
|
|
36
|
+
}
|