arona-agent 1.0.8 → 1.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/README.md CHANGED
@@ -19,6 +19,9 @@ npm i -g arona-agent
19
19
 
20
20
  # 首次运行自动进入初始化向导
21
21
  arona
22
+
23
+ # 或着如果想先上手玩玩
24
+ npx arona-agent
22
25
  ```
23
26
 
24
27
  ---
@@ -37,6 +40,9 @@ arona --no-voice
37
40
 
38
41
  # 补全/重新克隆某角色音色
39
42
  arona voice add [<角色名>] # 不带角色名则进入 TUI 选择未补全的角色
43
+
44
+ # 环境自检
45
+ arona doctor
40
46
  ```
41
47
 
42
48
  ---
package/README_en.md CHANGED
@@ -17,6 +17,9 @@ npm i -g arona-agent
17
17
 
18
18
  # First run auto-starts the setup wizard, then launches ARONA
19
19
  arona
20
+
21
+ # Quick Start
22
+ npx arona-agent
20
23
  ```
21
24
 
22
25
  ---
@@ -35,6 +38,9 @@ arona --no-voice
35
38
 
36
39
  # Clone/re-clone a character's voice
37
40
  arona voice add [<character-name>] # omit the name to enter the TUI for missing voices
41
+
42
+ # Environment health check
43
+ arona doctor
38
44
  ```
39
45
 
40
46
  ---
package/bin/arona.mjs CHANGED
@@ -14,8 +14,9 @@ const args = process.argv.slice(2);
14
14
 
15
15
  const isSetup = args[0] === 'setup';
16
16
  const isVoice = args[0] === 'voice';
17
- const target = isSetup ? 'src/setup.ts' : isVoice ? 'src/voice_cli.ts' : 'src/index.ts';
18
- const passArgs = (isSetup || isVoice) ? args.slice(1) : args;
17
+ const isDoctor = args[0] === 'doctor';
18
+ const target = isSetup ? 'src/setup.ts' : isVoice ? 'src/voice_cli.ts' : isDoctor ? 'src/doctor.ts' : 'src/index.ts';
19
+ const passArgs = (isSetup || isVoice || isDoctor) ? args.slice(1) : args;
19
20
 
20
21
  // tsx ships a CLI binary alongside the package. We prefer the local install.
21
22
  const tsxBin = process.platform === 'win32'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arona-agent",
3
- "version": "1.0.8",
3
+ "version": "1.1.0",
4
4
  "description": "Terminal AI Agent with desktop pet Arona — eye-tracking pupils, voice cloning, Computer Use, TTS/STT, MCP.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -17,8 +17,22 @@ stdout:
17
17
 
18
18
  import json
19
19
  import os
20
+ import socket
20
21
  import sys
21
22
 
23
+ # ---- IPv4 优先解析(同 tts_say.py / voice_clone.py)----
24
+ # macOS 家宽坑:有全局 IPv6 但出口路由不通,python 无 Happy Eyeballs →
25
+ # getaddrinfo v6 在前,每次请求先卡满 v6 超时再回落 v4。重排 AF_INET 在前根治。
26
+ _orig_getaddrinfo = socket.getaddrinfo
27
+
28
+
29
+ def _ipv4_first_getaddrinfo(*args, **kwargs):
30
+ res = _orig_getaddrinfo(*args, **kwargs)
31
+ return sorted(res, key=lambda r: 0 if r[0] == socket.AF_INET else 1)
32
+
33
+
34
+ socket.getaddrinfo = _ipv4_first_getaddrinfo
35
+
22
36
 
23
37
  def fail(msg):
24
38
  print(json.dumps({"error": msg}))
@@ -16,9 +16,32 @@ All logging goes to stderr so stdout stays clean JSON.
16
16
  import json
17
17
  import os
18
18
  import re
19
+ import socket
19
20
  import sys
20
21
  import time
21
22
 
23
+ from _i18n import t
24
+
25
+ # ---- IPv4 优先解析(同 tts_say.py)----
26
+ # macOS 家宽坑:系统有全局 IPv6 地址但出口路由不通,python 无 Happy Eyeballs,
27
+ # getaddrinfo 返回 v6 在前 → 每次请求先卡满 v6 超时才回落 v4(上传/轮询多请求叠加=分钟级卡死,
28
+ # 表现为"正在克隆"卡住;开代理可用是因为代理直连 127.0.0.1 恰好绕开 v6)。
29
+ # 重排 AF_INET 在前根治;IPv4 不可用时仍按序回落 IPv6。
30
+ _orig_getaddrinfo = socket.getaddrinfo
31
+
32
+
33
+ def _ipv4_first_getaddrinfo(*args, **kwargs):
34
+ res = _orig_getaddrinfo(*args, **kwargs)
35
+ return sorted(res, key=lambda r: 0 if r[0] == socket.AF_INET else 1)
36
+
37
+
38
+ socket.getaddrinfo = _ipv4_first_getaddrinfo
39
+
40
+
41
+ def log(msg_zh, msg_en):
42
+ # 阶段诊断只走 stderr:Node 侧 --verbose 会逐行转发实时可见,stdout 保持纯 JSON。
43
+ print(f"[voice_clone] {t(msg_zh, msg_en)}", file=sys.stderr, flush=True)
44
+
22
45
 
23
46
  def fail(msg):
24
47
  print(json.dumps({"error": msg}))
@@ -52,29 +75,39 @@ def main():
52
75
 
53
76
  # 1. Upload audio to DashScope hosted OSS
54
77
  try:
78
+ log(f"上传音频 {os.path.basename(audio_file)} 到 DashScope 托管 OSS...",
79
+ f"Uploading {os.path.basename(audio_file)} to DashScope OSS...")
55
80
  resp = Files.upload(file_path=audio_file, purpose="voice_clone")
56
81
  file_id = resp.output["uploaded_files"][0]["file_id"]
82
+ log(f"上传完成 file_id={file_id}", f"Upload complete file_id={file_id}")
57
83
  except Exception as e:
58
84
  fail(f"Upload failed: {e}")
59
85
 
60
86
  # 2. Get the Alibaba Cloud internal URL (must use internal address)
61
87
  try:
88
+ log("获取 OSS 内部地址...", "Getting OSS internal URL...")
62
89
  oss_url = Files.get(file_id).output["url"]
90
+ log("OSS 内部地址获取成功", "OSS internal URL acquired")
63
91
  except Exception as e:
64
92
  fail(f"Failed to get OSS URL: {e}")
65
93
 
66
94
  # 3. Submit voice cloning
67
95
  try:
96
+ log(f"提交音色克隆(model={target_model},prefix={prefix})...",
97
+ f"Submitting voice creation (model={target_model}, prefix={prefix})...")
68
98
  svc = VoiceEnrollmentService()
69
99
  voice_id = svc.create_voice(target_model=target_model, prefix=prefix, url=oss_url)
100
+ log(f"已提交,voice_id={voice_id}", f"Submitted, voice_id={voice_id}")
70
101
  except Exception as e:
71
102
  fail(f"create_voice failed: {e}")
72
103
 
73
104
  # 4. Wait for voice to be ready (max 5 minutes)
74
105
  try:
75
- for _ in range(30):
106
+ for i in range(30):
76
107
  info = svc.query_voice(voice_id=voice_id)
77
108
  status = info.get("status")
109
+ log(f"查询克隆状态:{status}(第 {i + 1}/30 次,每 10s)",
110
+ f"Voice status: {status} ({i + 1}/30, every 10s)")
78
111
  if status == "OK":
79
112
  break
80
113
  if status == "UNDEPLOYED":
@@ -87,10 +120,12 @@ def main():
87
120
 
88
121
  # 5. Delete the uploaded file (best-effort cleanup)
89
122
  try:
123
+ log("删除已上传文件(尽力清理)...", "Deleting uploaded file (best-effort)...")
90
124
  Files.delete(file_id)
91
125
  except Exception:
92
126
  pass # Cleanup is best-effort
93
127
 
128
+ log(f"音色克隆完成 voice_id={voice_id}", f"Voice clone complete voice_id={voice_id}")
94
129
  print(json.dumps({"voice_id": voice_id}))
95
130
 
96
131
 
package/src/doctor.ts ADDED
@@ -0,0 +1,385 @@
1
+ // arona doctor:独立环境自检命令(bin/arona.mjs 路由到本文件,不走 REPL/斜杠命令)。
2
+ // 颜色语义:绿 = 正常;黄 = 可略过(未配置/某功能不可用);红 = 可能影响使用。
3
+ // 注意:勿 import tts_provider / gpt_sovits_local——后者模块级 setInterval 会让本进程无法退出,
4
+ // GPT-SoVITS 配置在此处直接解析 ttsConfig["gpt-sovits"] 原始字段。
5
+
6
+ import chalk from "chalk";
7
+ import net from "net";
8
+ import { existsSync } from "fs";
9
+ import { join } from "path";
10
+ import { config, settingsExist, PROJECT_ROOT } from "./config.ts";
11
+ import { t } from "./locale.ts";
12
+ import { AGENT_IDS, getMainAgent } from "./agent_registry.ts";
13
+ import { getVoiceId, getGptSovitsVoice } from "./voices.ts";
14
+ import { spawnCompat } from "./utils/spawn.ts";
15
+
16
+ type Level = "ok" | "warn" | "fail";
17
+
18
+ let failCount = 0;
19
+ let warnCount = 0;
20
+
21
+ function item(level: Level, text: string): void {
22
+ const mark = level === "ok" ? chalk.green("✓") : level === "warn" ? chalk.yellow("!") : chalk.red("✗");
23
+ if (level === "warn") warnCount++;
24
+ if (level === "fail") failCount++;
25
+ console.log(` ${mark} ${text}`);
26
+ }
27
+
28
+ function section(title: string): void {
29
+ console.log(`\n${chalk.bold(title)}`);
30
+ }
31
+
32
+ interface ProbeResult {
33
+ ok: boolean;
34
+ stdout: string;
35
+ stderr: string;
36
+ }
37
+
38
+ /** 运行一个命令并收集输出(超时/启动失败均视为不可用,不抛异常)。 */
39
+ function probe(bin: string, args: string[], timeoutMs: number): Promise<ProbeResult> {
40
+ return new Promise((resolve) => {
41
+ let proc;
42
+ try {
43
+ proc = spawnCompat(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
44
+ } catch {
45
+ resolve({ ok: false, stdout: "", stderr: "spawn failed" });
46
+ return;
47
+ }
48
+ let stdout = "";
49
+ let stderr = "";
50
+ let settled = false;
51
+ const timer = setTimeout(() => {
52
+ if (!settled) {
53
+ settled = true;
54
+ try { proc.kill("SIGKILL"); } catch {}
55
+ resolve({ ok: false, stdout, stderr: "timeout" });
56
+ }
57
+ }, timeoutMs);
58
+ proc.stdout.on("data", (d) => { stdout += d.toString(); });
59
+ proc.stderr.on("data", (d) => { stderr += d.toString(); });
60
+ proc.on("error", (err) => {
61
+ if (!settled) {
62
+ settled = true;
63
+ clearTimeout(timer);
64
+ resolve({ ok: false, stdout, stderr: err.message });
65
+ }
66
+ });
67
+ proc.on("close", (code) => {
68
+ if (!settled) {
69
+ settled = true;
70
+ clearTimeout(timer);
71
+ resolve({ ok: code === 0, stdout, stderr });
72
+ }
73
+ });
74
+ });
75
+ }
76
+
77
+ function tcpReachable(host: string, port: number, timeoutMs = 2000): Promise<boolean> {
78
+ return new Promise((resolve) => {
79
+ const socket = new net.Socket();
80
+ let done = false;
81
+ const finish = (v: boolean) => {
82
+ if (!done) {
83
+ done = true;
84
+ socket.destroy();
85
+ resolve(v);
86
+ }
87
+ };
88
+ socket.setTimeout(timeoutMs);
89
+ socket.once("connect", () => finish(true));
90
+ socket.once("timeout", () => finish(false));
91
+ socket.once("error", () => finish(false));
92
+ socket.connect(port, host);
93
+ });
94
+ }
95
+
96
+ /** 主程序 Python 的版本要求:3.12 / 3.13(3.14 起 pydantic-core 不支持)。 */
97
+ const PY_REQUIRE = "3.12 / 3.13";
98
+
99
+ function pyVersionFrom(out: string): string | null {
100
+ const m = out.match(/Python\s+(\d+\.\d+(?:\.\d+)?)/);
101
+ return m ? m[1] : null;
102
+ }
103
+
104
+ /** 主程序 Python 依赖(requirements.txt):一次子进程探测全部 import。 */
105
+ const DEPS = ["cua", "websockets", "pyaudio", "numpy", "pynput", "dashscope"] as const;
106
+
107
+ const DEPS_SCRIPT = [
108
+ "import json",
109
+ `mods = ${JSON.stringify([...DEPS])}`,
110
+ "r = {}",
111
+ "for m in mods:",
112
+ " try:",
113
+ " __import__(m)",
114
+ " r[m] = 1",
115
+ " except Exception:",
116
+ " r[m] = 0",
117
+ "print(json.dumps(r))",
118
+ ].join("\n");
119
+
120
+ interface GsvInfo {
121
+ mode: "local" | "cloud";
122
+ baseUrl: string;
123
+ /** 显式配置的 GPT-SoVITS 专用 Python;空 = 回退主程序 Python。 */
124
+ pythonPath: string;
125
+ apiScriptPath: string;
126
+ gptModelPath: string;
127
+ sovitsModelPath: string;
128
+ bertPath: string;
129
+ cnhubertPath: string;
130
+ }
131
+
132
+ /** 解析 ttsConfig["gpt-sovits"] 原始字段;无任何有效键 = 暂未配置。 */
133
+ function readGsvConfig(): GsvInfo | null {
134
+ const raw = config.ttsConfig?.["gpt-sovits"];
135
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
136
+ const obj = raw as Record<string, unknown>;
137
+ const s = (k: string) => (typeof obj[k] === "string" ? (obj[k] as string).trim() : "");
138
+ const configured = ["mode", "pythonPath", "apiScriptPath", "gptModelPath", "sovitsModelPath", "bertPath", "cnhubertPath", "baseUrl", "apiKey"].some((k) => s(k));
139
+ if (!configured) return null;
140
+ return {
141
+ mode: obj.mode === "cloud" ? "cloud" : "local",
142
+ baseUrl: s("baseUrl") || "http://127.0.0.1:9880",
143
+ pythonPath: s("pythonPath"),
144
+ apiScriptPath: s("apiScriptPath"),
145
+ gptModelPath: s("gptModelPath"),
146
+ sovitsModelPath: s("sovitsModelPath"),
147
+ bertPath: s("bertPath"),
148
+ cnhubertPath: s("cnhubertPath"),
149
+ };
150
+ }
151
+
152
+ function parseBaseUrl(baseUrl: string): { host: string; port: number } {
153
+ try {
154
+ const u = new URL(baseUrl);
155
+ return { host: u.hostname || "127.0.0.1", port: u.port ? Number(u.port) : 9880 };
156
+ } catch {
157
+ return { host: "127.0.0.1", port: 9880 };
158
+ }
159
+ }
160
+
161
+ async function main(): Promise<void> {
162
+ console.log(chalk.bold("ARONA doctor"));
163
+
164
+ const mainPy = config.pythonPath;
165
+ const gsv = readGsvConfig();
166
+ const gsvPy = gsv?.pythonPath || mainPy;
167
+
168
+ // 并行探测:主程序 Python 版本 + 依赖;GPT-SoVITS Python 版本 + torch
169
+ const pyVerP = probe(mainPy, ["--version"], 10000);
170
+ const pyDepsP = probe(mainPy, ["-c", DEPS_SCRIPT], 60000);
171
+ let gsvVerP: Promise<ProbeResult> | null = null;
172
+ let gsvTorchP: Promise<ProbeResult> | null = null;
173
+ if (gsv) {
174
+ gsvVerP = probe(gsvPy, ["--version"], 10000);
175
+ gsvTorchP = probe(gsvPy, ["-c", "import torch;print(torch.__version__)"], 90000);
176
+ }
177
+ const [pyVer, pyDeps, gsvVer, gsvTorch] = await Promise.all([pyVerP, pyDepsP, gsvVerP, pyTorch(gsvTorchP)]);
178
+
179
+ // ---------------- 核心环境 ----------------
180
+ section(t("核心环境", "Core"));
181
+
182
+ const nodeVer = process.versions.node;
183
+ const [nodeMajor, nodeMinor] = nodeVer.split(".").map(Number);
184
+ if (nodeMajor > 22 || (nodeMajor === 22 && nodeMinor >= 19)) {
185
+ item("ok", `Node.js v${nodeVer}`);
186
+ } else {
187
+ item("fail", t(`Node.js v${nodeVer},需 ≥ 22.19.0`, `Node.js v${nodeVer}, need >= 22.19.0`));
188
+ }
189
+
190
+ if (settingsExist()) {
191
+ item("ok", t("配置文件 ~/.arona/settings.json", "Config file ~/.arona/settings.json"));
192
+ } else {
193
+ item("fail", t("配置文件不存在,请先运行 arona setup", "Config file missing. Run `arona setup` first"));
194
+ }
195
+
196
+ item(
197
+ config.apiKey ? "ok" : "fail",
198
+ config.apiKey
199
+ ? t("LLM API Key 已配置", "LLM API Key configured")
200
+ : t("LLM API Key 未配置", "LLM API Key not configured"),
201
+ );
202
+
203
+ if (!pyVer.ok) {
204
+ item("fail", t(`主程序 Python 未找到:${mainPy}`, `Main Python not found: ${mainPy}`));
205
+ } else {
206
+ const ver = pyVersionFrom(pyVer.stdout);
207
+ const mm = ver?.split(".").slice(0, 2).join(".");
208
+ if (!ver) {
209
+ item("fail", t("无法获取主程序 Python 版本", "Failed to detect main Python version"));
210
+ } else if (mm !== "3.12" && mm !== "3.13") {
211
+ item("fail", t(`主程序 Python ${ver} 版本不受支持,需 ${PY_REQUIRE}`, `Main Python ${ver} unsupported, need ${PY_REQUIRE}`));
212
+ } else {
213
+ item("ok", t(`主程序 Python ${ver}`, `Main Python ${ver}`));
214
+ }
215
+ }
216
+
217
+ if (pyVer.ok) {
218
+ let missing: string[] | null = null;
219
+ try {
220
+ const r = JSON.parse(pyDeps.stdout) as Record<string, unknown>;
221
+ missing = DEPS.filter((d) => !r[d]);
222
+ } catch {
223
+ missing = null;
224
+ }
225
+ if (missing === null) {
226
+ item("warn", t("Python 依赖检查失败", "Python dependency check failed"));
227
+ } else if (missing.length > 0) {
228
+ item("warn", t(
229
+ `缺少 Python 依赖:${missing.join("、")},运行 pip install -r requirements.txt 补装`,
230
+ `Missing Python deps: ${missing.join(", ")}. Run pip install -r requirements.txt`,
231
+ ));
232
+ } else {
233
+ item("ok", t("Python 依赖完整", "Python dependencies installed"));
234
+ }
235
+ }
236
+
237
+ // ---------------- 语音 ----------------
238
+ if (config.noVoice) {
239
+ console.log(t("\n语音功能已禁用(--no-voice),跳过语音检查。", "\nVoice disabled (--no-voice), voice checks skipped."));
240
+ } else {
241
+ section(t("语音", "Voice"));
242
+ console.log(` TTS Provider:${config.ttsProvider === "gpt-sovits" ? "GPT-SoVITS" : t("阿里云百炼", "Aliyun Bailian")}`);
243
+
244
+ if (config.ttsProvider === "aliyun") {
245
+ item(
246
+ config.ttsApiKey ? "ok" : "warn",
247
+ config.ttsApiKey
248
+ ? t("TTS API Key 已配置", "TTS API Key configured")
249
+ : t("TTS API Key 未配置,语音合成不可用", "TTS API Key not configured, speech synthesis unavailable"),
250
+ );
251
+ }
252
+
253
+ const mainAgent = getMainAgent();
254
+ if (config.ttsProvider === "aliyun") {
255
+ item(
256
+ getVoiceId(mainAgent) ? "ok" : "warn",
257
+ getVoiceId(mainAgent)
258
+ ? t(`当前角色 ${mainAgent} 已配置音色`, `Current agent ${mainAgent} has a cloned voice`)
259
+ : t(`当前角色 ${mainAgent} 未克隆音色,TTS 静音;arona voice add 可补全`, `Current agent ${mainAgent} has no cloned voice, TTS muted. Run \`arona voice add\``),
260
+ );
261
+ }
262
+
263
+ // GPT-SoVITS(无论当前 provider 是否为它,配置了就体检)
264
+ if (!gsv) {
265
+ item("warn", t("GPT-SoVITS:暂未配置", "GPT-SoVITS: not configured"));
266
+ } else {
267
+ if (gsv.pythonPath) {
268
+ if (!gsvVer?.ok) {
269
+ item("fail", t(`GPT-SoVITS Python 未找到:${gsv.pythonPath}`, `GPT-SoVITS Python not found: ${gsv.pythonPath}`));
270
+ } else {
271
+ const ver = pyVersionFrom(gsvVer.stdout) ?? "";
272
+ item("ok", t(`GPT-SoVITS Python ${ver}`, `GPT-SoVITS Python ${ver}`));
273
+ }
274
+ } else {
275
+ // 未单独配置专用 Python:按设计回退主程序 Python(是否可用由 torch 检查揭示)
276
+ if (!pyVer.ok) {
277
+ item("fail", t("GPT-SoVITS Python 未配置,且主程序 Python 不可用", "GPT-SoVITS Python not configured and main Python unavailable"));
278
+ } else {
279
+ item("warn", t(
280
+ `GPT-SoVITS Python 未单独配置,将使用主程序 Python ${pyVersionFrom(pyVer.stdout) ?? ""}`,
281
+ `GPT-SoVITS Python not set; falls back to main Python ${pyVersionFrom(pyVer.stdout) ?? ""}`,
282
+ ));
283
+ }
284
+ }
285
+
286
+ if (gsvTorch?.ok && gsvTorch.stdout.trim()) {
287
+ item("ok", t(`PyTorch ${gsvTorch.stdout.trim()}`, `PyTorch ${gsvTorch.stdout.trim()}`));
288
+ } else {
289
+ item("warn", t("PyTorch 未安装,GPT-SoVITS 合成不可用", "PyTorch not installed, GPT-SoVITS synthesis unavailable"));
290
+ }
291
+
292
+ if (gsv.mode === "local") {
293
+ if (!gsv.apiScriptPath) {
294
+ item("warn", t("api_v2 脚本未配置,无法自动启动本地服务", "api_v2 script not configured, cannot auto-start local server"));
295
+ } else if (!existsSync(gsv.apiScriptPath)) {
296
+ item("warn", t(`api_v2 脚本不存在:${gsv.apiScriptPath}`, `api_v2 script not found: ${gsv.apiScriptPath}`));
297
+ } else {
298
+ item("ok", t("api_v2 脚本就绪", "api_v2 script ready"));
299
+ }
300
+
301
+ // 配置了但路径失效的权重
302
+ const badPaths: string[] = [];
303
+ if (gsv.gptModelPath && !existsSync(gsv.gptModelPath)) badPaths.push("gptModelPath");
304
+ if (gsv.sovitsModelPath && !existsSync(gsv.sovitsModelPath)) badPaths.push("sovitsModelPath");
305
+ if (badPaths.length > 0) {
306
+ item("warn", t(`模型权重路径不存在:${badPaths.join("、")}`, `Model weight path(s) not found: ${badPaths.join(", ")}`));
307
+ } else {
308
+ const hasGpt = !!gsv.gptModelPath || AGENT_IDS.some((id) => !!getGptSovitsVoice(id)?.gptWeightsPath?.trim());
309
+ const hasSovits = !!gsv.sovitsModelPath || AGENT_IDS.some((id) => !!getGptSovitsVoice(id)?.sovitsWeightsPath?.trim());
310
+ if (hasGpt && hasSovits) {
311
+ item("ok", t("模型权重已配置", "Model weights configured"));
312
+ } else {
313
+ item("warn", t("未配置模型权重,本地合成不可用", "No model weights configured, local synthesis unavailable"));
314
+ }
315
+ }
316
+
317
+ const bertOk = !!gsv.bertPath && existsSync(gsv.bertPath);
318
+ const hubOk = !!gsv.cnhubertPath && existsSync(gsv.cnhubertPath);
319
+ if (bertOk && hubOk) {
320
+ item("ok", t("BERT / CNHubert 就绪", "BERT / CNHubert ready"));
321
+ } else {
322
+ item("warn", t("BERT 或 CNHubert 未配置或不存在,自动启动本地服务需要它们", "BERT or CNHubert missing; both are required to auto-start the local server"));
323
+ }
324
+
325
+ const { host, port } = parseBaseUrl(gsv.baseUrl);
326
+ const alive = await tcpReachable(host, port);
327
+ item(
328
+ alive ? "ok" : "warn",
329
+ alive
330
+ ? t(`本地服务运行中 ${host}:${port}`, `Local server running at ${host}:${port}`)
331
+ : t("本地服务未运行,首次合成时自动启动", "Local server not running; it will auto-start on first synthesis"),
332
+ );
333
+ } else {
334
+ const { host, port } = parseBaseUrl(gsv.baseUrl);
335
+ const alive = await tcpReachable(host, port, 3000);
336
+ item(
337
+ alive ? "ok" : "warn",
338
+ alive
339
+ ? t(`云端服务可达 ${gsv.baseUrl}`, `Cloud service reachable at ${gsv.baseUrl}`)
340
+ : t(`云端服务不可达:${gsv.baseUrl}`, `Cloud service unreachable: ${gsv.baseUrl}`),
341
+ );
342
+ }
343
+ }
344
+
345
+ if (config.sttEnabled) {
346
+ item(
347
+ config.sttApiKey ? "ok" : "warn",
348
+ config.sttApiKey
349
+ ? t("STT API Key 已配置", "STT API Key configured")
350
+ : t("STT API Key 未配置,语音识别不可用", "STT API Key not configured, speech recognition unavailable"),
351
+ );
352
+ }
353
+ }
354
+
355
+ // ---------------- 桌宠 ----------------
356
+ section(t("桌宠", "Pet"));
357
+ const electronOk = existsSync(join(PROJECT_ROOT, "node_modules", "electron"));
358
+ item(
359
+ electronOk ? "ok" : "fail",
360
+ electronOk
361
+ ? t("Electron 已安装", "Electron installed")
362
+ : t(`Electron 未安装,桌宠不可用;在 ${PROJECT_ROOT} 运行 npm install`, `Electron not installed, pet unavailable. Run npm install in ${PROJECT_ROOT}`),
363
+ );
364
+
365
+ // ---------------- 汇总 ----------------
366
+ console.log();
367
+ if (failCount > 0) {
368
+ console.log(chalk.red(t(`发现 ${failCount} 项可能影响使用的问题`, `${failCount} issue(s) that may affect usage`)));
369
+ } else if (warnCount > 0) {
370
+ console.log(chalk.yellow(t(`有 ${warnCount} 项可略过(未配置或功能受限)`, `${warnCount} item(s) skippable (not configured or limited)`)));
371
+ } else {
372
+ console.log(chalk.green(t("一切正常", "All checks passed")));
373
+ }
374
+ process.exitCode = failCount > 0 ? 1 : 0;
375
+ }
376
+
377
+ /** 占位透传:让 Promise.all 的元素类型统一(null 探测保持 null)。 */
378
+ function pyTorch(p: Promise<ProbeResult> | null): Promise<ProbeResult | null> {
379
+ return p ?? Promise.resolve(null);
380
+ }
381
+
382
+ main().catch((err) => {
383
+ console.error(chalk.red(t(`doctor 执行失败:`, `doctor failed: `) + (err instanceof Error ? err.message : String(err))));
384
+ process.exitCode = 1;
385
+ });
@@ -1,6 +1,6 @@
1
1
  import type { ChildProcessWithoutNullStreams } from "child_process";
2
2
  import { join } from "path";
3
- import { PYTHON_DIR, config } from "../config.ts";
3
+ import { PYTHON_DIR, config, verbose } from "../config.ts";
4
4
  import { t, getLang } from "../locale.ts";
5
5
  import { spawnCompat, stripProxyEnv } from "./spawn.ts";
6
6
 
@@ -42,6 +42,13 @@ export async function runPython(
42
42
  });
43
43
  proc.stderr.on("data", (data) => {
44
44
  stderr += data.toString();
45
+ // --verbose:逐行实时转发 python stderr,可见 voice_clone 上传/轮询等阶段进度
46
+ if (verbose) {
47
+ for (const line of data.toString().split(/[\r\n]+/)) {
48
+ const msg = line.trim();
49
+ if (msg) console.error(`[python:${scriptName}]`, msg);
50
+ }
51
+ }
45
52
  });
46
53
  proc.on("close", (code) => {
47
54
  clearTimeout(timer);