arona-agent 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -9
- package/assets/blue-archive/arona/voice_sovits.mp3 +0 -0
- package/assets/blue-archive/arona/voice_text.txt +1 -0
- package/assets/blue-archive/hoshino/voice_sovits.mp3 +0 -0
- package/assets/blue-archive/hoshino/voice_text.txt +1 -0
- package/assets/blue-archive/plana/voice_sovits.mp3 +0 -0
- package/assets/blue-archive/plana/voice_text.txt +1 -0
- package/assets/blue-archive/shiroko/voice_sovits.mp3 +0 -0
- package/assets/blue-archive/shiroko/voice_text.txt +1 -0
- package/bin/arona.mjs +3 -1
- package/bin/postinstall-fix.mjs +46 -0
- package/package.json +3 -2
- package/pet/renderer/gallery.js +2 -2
- package/pet/renderer/renderer.js +1 -1
- package/pet/renderer/style.css +16 -2
- package/pet/tools/gen_sway.cjs +1 -1
- package/pet/tools/visual_test.cjs +2 -2
- package/python/__pycache__/_i18n.cpython-314.pyc +0 -0
- package/python/__pycache__/computer_use.cpython-314.pyc +0 -0
- package/python/__pycache__/hotkey.cpython-314.pyc +0 -0
- package/python/__pycache__/oss_upload.cpython-314.pyc +0 -0
- package/python/__pycache__/stt.cpython-314.pyc +0 -0
- package/python/__pycache__/tts_say.cpython-314.pyc +0 -0
- package/python/hotkey.py +7 -1
- package/python/oss_upload.py +70 -0
- package/python/tts_say.py +182 -19
- package/src/agent.ts +155 -147
- package/src/commands.ts +17 -23
- package/src/config.ts +45 -2
- package/src/gesture_context.ts +48 -0
- package/src/gpt_sovits_local.ts +706 -0
- package/src/index.ts +13 -10
- package/src/memory.ts +3 -10
- package/src/oss_upload.ts +116 -0
- package/src/renderer.ts +17 -6
- package/src/repl.ts +70 -59
- package/src/setup.ts +349 -15
- package/src/skills.ts +32 -2
- package/src/slash_menu.ts +1 -1
- package/src/slash_registry.ts +6 -5
- package/src/tools/read_docs_tool.ts +84 -0
- package/src/tools/tavily_tools.ts +24 -10
- package/src/tools/voice_tools.ts +1 -1
- package/src/tts_provider.ts +501 -0
- package/src/tts_stream.ts +321 -31
- package/src/tui_select.ts +31 -2
- package/src/undo.ts +82 -18
- package/src/utils/python.ts +22 -0
- package/src/voice.ts +17 -7
- package/src/voice_cli.ts +117 -9
- package/src/voices.ts +197 -28
package/src/index.ts
CHANGED
|
@@ -3,36 +3,39 @@ import { Repl } from "./repl.ts";
|
|
|
3
3
|
import { resetConversationFlag, loadSession } from "./memory.ts";
|
|
4
4
|
import { startPet } from "./pet.ts";
|
|
5
5
|
import { config, settingsExist } from "./config.ts";
|
|
6
|
+
import { preloadGptSovitsLocal } from "./tts_provider.ts";
|
|
7
|
+
import { syncSkillsFromAgentsDir } from "./skills.ts";
|
|
6
8
|
import { t } from "./locale.ts";
|
|
7
9
|
import chalk from "chalk";
|
|
8
10
|
|
|
9
11
|
async function main() {
|
|
10
|
-
// Require settings.json — if missing, tell user to run arona setup
|
|
11
12
|
if (!settingsExist()) {
|
|
12
|
-
console.log(chalk.yellow(t("
|
|
13
|
-
console.log(chalk.cyan(t("请先运行 arona setup 进行初始化配置。", "Please run arona setup to initialize first.")));
|
|
13
|
+
console.log(chalk.yellow(t("未找到配置文件,请运行 arona setup 初始化。", "Config file not found. Run `arona setup` to initialize.")));
|
|
14
14
|
process.exit(1);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
// Check for required environment
|
|
18
17
|
if (!config.apiKey) {
|
|
19
|
-
console.log(chalk.yellow(t("警告:未找到 LLM API Key
|
|
20
|
-
console.log(chalk.cyan(t("请运行 arona setup 进行配置。", "Please run arona setup to configure.")));
|
|
21
|
-
console.log();
|
|
18
|
+
console.log(chalk.yellow(t("警告:未找到 LLM API Key,请运行 arona setup 配置。", "Warning: LLM API Key not found. Run `arona setup` to configure.")));
|
|
22
19
|
}
|
|
23
20
|
|
|
24
21
|
if (config.noVoice) {
|
|
25
22
|
console.log(chalk.cyan(t("语音功能已禁用(--no-voice)。", "Voice features disabled (--no-voice).")));
|
|
26
23
|
}
|
|
27
24
|
|
|
28
|
-
//
|
|
29
|
-
|
|
25
|
+
// 补全缺失 Skill(仅补缺,不覆盖用户定制)
|
|
26
|
+
const synced = syncSkillsFromAgentsDir();
|
|
27
|
+
if (synced > 0) {
|
|
28
|
+
console.log(chalk.cyan(t(`已同步 ${synced} 个 Skill`, `Synced ${synced} skill(s)`)));
|
|
29
|
+
}
|
|
30
30
|
|
|
31
31
|
let { session, modelRuntime, loader } = await initAgent();
|
|
32
32
|
|
|
33
|
-
//
|
|
33
|
+
// 首次启动需下载 Electron,须等下载完成再进入 REPL
|
|
34
34
|
await startPet();
|
|
35
35
|
|
|
36
|
+
// 后台预热本地 GPT-SoVITS,避免首句合成冷启动等待(fire-and-forget)
|
|
37
|
+
preloadGptSovitsLocal();
|
|
38
|
+
|
|
36
39
|
// Handle session resume from command line arg
|
|
37
40
|
// 只加载消息到 session,渲染交给 Repl.start() 处理(确保 logo 在历史记录之前)
|
|
38
41
|
// initialSessionPath 传给 Repl,退出时覆盖保存回原文件而非另存为新文件
|
package/src/memory.ts
CHANGED
|
@@ -171,12 +171,7 @@ interface SessionHeader {
|
|
|
171
171
|
preview: string;
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
/**
|
|
175
|
-
* 从首条 user 消息提取会话预览。
|
|
176
|
-
* 剥离 repl.ts 注入的桌宠手势场景块(全角括号 `(…)` 包裹、后随空行),
|
|
177
|
-
* 避免"摸头/dizzy 提示词"污染会话命名(用户首条消息常是手势触发)。
|
|
178
|
-
* 仅影响预览命名,不改动存储内容。
|
|
179
|
-
*/
|
|
174
|
+
/** 从首条 user 消息提取会话预览。 */
|
|
180
175
|
function firstUserPreview(messages: any[]): string {
|
|
181
176
|
const firstUserMsg = messages.find((m) => m.role === "user");
|
|
182
177
|
if (!firstUserMsg) return "(empty)";
|
|
@@ -189,10 +184,8 @@ function firstUserPreview(messages: any[]): string {
|
|
|
189
184
|
.map((c: any) => c.text)
|
|
190
185
|
.join(" ")
|
|
191
186
|
: "";
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
let preview = cleaned.slice(0, 50).replace(/\n/g, " ");
|
|
195
|
-
if (cleaned.length > 50) preview += "...";
|
|
187
|
+
let preview = content.slice(0, 50).replace(/\n/g, " ");
|
|
188
|
+
if (content.length > 50) preview += "...";
|
|
196
189
|
return preview;
|
|
197
190
|
}
|
|
198
191
|
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// 本地文件 → 阿里云 DashScope 托管 OSS 的工具:上传换取可长期引用的 URL,供云端 GPT-SoVITS
|
|
2
|
+
// 作为 ref_audio_path 引用(复用 python/oss_upload.py,与 voice_clone.py 同一 DashScope 通道)。
|
|
3
|
+
//
|
|
4
|
+
// - 同一本地路径会话内只上传一次(URL 长期有效),缓存于 urlCache;并发上传共享同一 in-flight Promise。
|
|
5
|
+
// - ensureOssUrlAlive 用 Range GET 探测 URL 有效性,失效则 invalidate 触发下次重传
|
|
6
|
+
// ("发现无效→重新走上传流程")。
|
|
7
|
+
// - promptText 若为 URL 用 fetchTextFromUrl 抓内容;joinPromptLines 把多行文字合并为一行,
|
|
8
|
+
// 行尾缺句末标点时按中英文智能补「。」或「.」。
|
|
9
|
+
import { verbose } from "./config.ts";
|
|
10
|
+
import { t } from "./locale.ts";
|
|
11
|
+
import { runPython } from "./utils/python.ts";
|
|
12
|
+
|
|
13
|
+
const urlCache = new Map<string, string>(); // 本地源路径 → OSS URL(会话内复用)
|
|
14
|
+
const inflight = new Map<string, Promise<string>>(); // 在途上传(并发去重)
|
|
15
|
+
const verifiedSet = new Set<string>(); // 已校验且仍有效的源路径(避免每 drain 重复探测)
|
|
16
|
+
|
|
17
|
+
/** 是否 http(s) URL(用于区分"本地路径"与"远端 URL"输入)。 */
|
|
18
|
+
export function isHttpUrl(s: string): boolean {
|
|
19
|
+
return /^https?:\/\/\S+$/i.test(s.trim());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 上传本地文件到 DashScope 托管 OSS,返回长期有效的 URL(同源路径会话内缓存,只上传一次)。 */
|
|
23
|
+
export function uploadToOss(path: string, apiKey: string): Promise<string> {
|
|
24
|
+
const cached = urlCache.get(path);
|
|
25
|
+
if (cached) return Promise.resolve(cached);
|
|
26
|
+
const pending = inflight.get(path);
|
|
27
|
+
if (pending) return pending;
|
|
28
|
+
const task = runPython("oss_upload.py", [], JSON.stringify({ path, apiKey }), {}, 120_000)
|
|
29
|
+
.then((out) => {
|
|
30
|
+
let parsed: { url?: string; error?: string };
|
|
31
|
+
try {
|
|
32
|
+
parsed = JSON.parse(out);
|
|
33
|
+
} catch {
|
|
34
|
+
throw new Error(t(`OSS 上传输出解析失败: ${out}`, `OSS upload parse failed: ${out}`));
|
|
35
|
+
}
|
|
36
|
+
if (!parsed.url) {
|
|
37
|
+
throw new Error(parsed.error || t("OSS 上传未返回 URL", "OSS upload returned no URL"));
|
|
38
|
+
}
|
|
39
|
+
urlCache.set(path, parsed.url);
|
|
40
|
+
if (verbose) console.error(`[oss] uploaded ${path} -> ${parsed.url}`);
|
|
41
|
+
return parsed.url;
|
|
42
|
+
})
|
|
43
|
+
.finally(() => {
|
|
44
|
+
inflight.delete(path);
|
|
45
|
+
});
|
|
46
|
+
inflight.set(path, task);
|
|
47
|
+
return task;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 作废某源路径的缓存 URL(发现无效时调用,下次上传会重新走上传流程)。 */
|
|
51
|
+
export function invalidateOssUrl(path: string): void {
|
|
52
|
+
urlCache.delete(path);
|
|
53
|
+
verifiedSet.delete(path);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Range GET 探测 URL 是否仍可访问(OSS 支持 range,200/206/304 均视为有效)。 */
|
|
57
|
+
async function checkUrlAlive(url: string): Promise<boolean> {
|
|
58
|
+
try {
|
|
59
|
+
const res = await fetch(url, {
|
|
60
|
+
method: "GET",
|
|
61
|
+
headers: { Range: "bytes=0-0" },
|
|
62
|
+
redirect: "follow",
|
|
63
|
+
signal: AbortSignal.timeout(6000),
|
|
64
|
+
});
|
|
65
|
+
return res.status === 200 || res.status === 206 || res.status === 304;
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 校验 OSS URL 有效性;失效则强制重传并返回新 URL("发现无效→重新走上传流程")。
|
|
73
|
+
* 会话内每个源路径只校验一次(URL 长期有效,无需每 drain 都探测)。
|
|
74
|
+
*/
|
|
75
|
+
export async function ensureOssUrlAlive(sourcePath: string, url: string, apiKey: string): Promise<string> {
|
|
76
|
+
if (verifiedSet.has(sourcePath)) return url;
|
|
77
|
+
if (await checkUrlAlive(url)) {
|
|
78
|
+
verifiedSet.add(sourcePath);
|
|
79
|
+
return url;
|
|
80
|
+
}
|
|
81
|
+
if (verbose) console.error(`[oss] URL 失效,重新上传: ${url}`);
|
|
82
|
+
invalidateOssUrl(sourcePath);
|
|
83
|
+
const fresh = await uploadToOss(sourcePath, apiKey);
|
|
84
|
+
verifiedSet.add(sourcePath);
|
|
85
|
+
return fresh;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 抓取 URL 文本内容(promptText 为 URL 时使用)。 */
|
|
89
|
+
export async function fetchTextFromUrl(url: string): Promise<string> {
|
|
90
|
+
const res = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(10_000) });
|
|
91
|
+
if (!res.ok) {
|
|
92
|
+
throw new Error(t(`拉取文字失败 (HTTP ${res.status})`, `Fetch text failed (HTTP ${res.status})`));
|
|
93
|
+
}
|
|
94
|
+
return await res.text();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 把多行文字合并为一行:丢弃空行;行尾缺少句末标点(。..!?!?…)时,按该行是否含
|
|
99
|
+
* 中日韩字符智能补「。」或「.」;最后一行不补(官方会自动补全)。单行输入原样返回。
|
|
100
|
+
*/
|
|
101
|
+
export function joinPromptLines(text: string): string {
|
|
102
|
+
const lines = text
|
|
103
|
+
.split(/\r?\n/)
|
|
104
|
+
.map((l) => l.trim())
|
|
105
|
+
.filter(Boolean);
|
|
106
|
+
if (lines.length <= 1) return lines[0] || "";
|
|
107
|
+
const CJK = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/;
|
|
108
|
+
const END = /[。..!?!?…]$/;
|
|
109
|
+
return lines
|
|
110
|
+
.map((line, i) => {
|
|
111
|
+
if (i === lines.length - 1) return line; // 最后一行不补标点
|
|
112
|
+
if (END.test(line)) return line;
|
|
113
|
+
return line + (CJK.test(line) ? "。" : ".");
|
|
114
|
+
})
|
|
115
|
+
.join(" ");
|
|
116
|
+
}
|
package/src/renderer.ts
CHANGED
|
@@ -161,6 +161,17 @@ export function createRenderer(
|
|
|
161
161
|
setSpeakerLabel(label: string | undefined) {
|
|
162
162
|
speakerLabel = label;
|
|
163
163
|
},
|
|
164
|
+
// 切 session(setActiveAgent)时显式复位回合状态:消除跨会话 curMsgText/lastText 残留,
|
|
165
|
+
// 防止被新 session 的 agent_end 误读上一角色文本。
|
|
166
|
+
resetTurn() {
|
|
167
|
+
curMsgText = "";
|
|
168
|
+
lastText = "";
|
|
169
|
+
thinkingBuffer = "";
|
|
170
|
+
drawnThinkingLines = 0;
|
|
171
|
+
inThinking = false;
|
|
172
|
+
inText = false;
|
|
173
|
+
textPrefixWritten = false;
|
|
174
|
+
},
|
|
164
175
|
subscribe: (session: any) => {
|
|
165
176
|
return session.subscribe((event: any) => {
|
|
166
177
|
switch (event.type) {
|
|
@@ -248,19 +259,19 @@ export function createRenderer(
|
|
|
248
259
|
if (showToolDetails) {
|
|
249
260
|
const name = event.toolName;
|
|
250
261
|
const input = event.input ? JSON.stringify(event.input).slice(0, 100) : "";
|
|
251
|
-
process.stdout.write(chalk.cyan(
|
|
262
|
+
process.stdout.write(chalk.cyan(`\n → ${name} ${input}`.trimEnd() + "\n"));
|
|
252
263
|
}
|
|
253
264
|
break;
|
|
254
265
|
|
|
255
266
|
case "tool_execution_end":
|
|
256
267
|
if (showToolDetails) {
|
|
257
|
-
const status = event.isError ? chalk.red(t("
|
|
258
|
-
process.stdout.write(
|
|
268
|
+
const status = event.isError ? chalk.red(t("✗", "✗")) : chalk.green(t("✓", "✓"));
|
|
269
|
+
process.stdout.write(status + "\n");
|
|
259
270
|
}
|
|
260
271
|
break;
|
|
261
272
|
|
|
262
273
|
case "compaction_start":
|
|
263
|
-
process.stdout.write(chalk.yellow(t("\n[
|
|
274
|
+
process.stdout.write(chalk.yellow(t("\n[压缩上下文…]\n", "\n[Compacting…]\n")));
|
|
264
275
|
break;
|
|
265
276
|
|
|
266
277
|
case "compaction_end":
|
|
@@ -271,12 +282,12 @@ export function createRenderer(
|
|
|
271
282
|
const msg = String(event.errorMessage).replace(/^Compaction failed:\s*/i, "");
|
|
272
283
|
process.stdout.write(chalk.red(t(`[压缩失败] ${msg}\n\n`, `[Compaction failed] ${msg}\n\n`)));
|
|
273
284
|
} else {
|
|
274
|
-
process.stdout.write(chalk.yellow(t("[压缩完成]\n\n", "[Compaction
|
|
285
|
+
process.stdout.write(chalk.yellow(t("[压缩完成]\n\n", "[Compaction done]\n\n")));
|
|
275
286
|
}
|
|
276
287
|
break;
|
|
277
288
|
|
|
278
289
|
case "auto_retry_start":
|
|
279
|
-
process.stdout.write(chalk.yellow(t("\n[
|
|
290
|
+
process.stdout.write(chalk.yellow(t("\n[重试中…]\n", "\n[Retrying…]\n")));
|
|
280
291
|
break;
|
|
281
292
|
}
|
|
282
293
|
});
|
package/src/repl.ts
CHANGED
|
@@ -4,15 +4,17 @@ import { execSync, type ChildProcessWithoutNullStreams } from "child_process";
|
|
|
4
4
|
import { readFileSync, existsSync } from "fs";
|
|
5
5
|
import { join, resolve } from "path";
|
|
6
6
|
import type { AgentSession, ModelRuntime, DefaultResourceLoader } from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import { config } from "./config.ts";
|
|
7
|
+
import { config, sttHotkeyKey, sttHotkeyLabel } from "./config.ts";
|
|
8
8
|
import { handleCommand, type CommandContext } from "./commands.ts";
|
|
9
9
|
import { createRenderer, renderSavedMessages } from "./renderer.ts";
|
|
10
10
|
import * as memory from "./memory.ts";
|
|
11
11
|
import * as voice from "./voice.ts";
|
|
12
|
+
import { getTtsProvider } from "./tts_provider.ts";
|
|
12
13
|
import { TtsStream } from "./tts_stream.ts";
|
|
14
|
+
import { stopGptSovitsLocalServer } from "./gpt_sovits_local.ts";
|
|
13
15
|
import { stopComputerUse } from "./tools/computer_use.ts";
|
|
14
16
|
import { disconnectAllMcp } from "./mcp.ts";
|
|
15
|
-
import { pet, stopPet
|
|
17
|
+
import { pet, stopPet } from "./pet.ts";
|
|
16
18
|
import { SlashMenu } from "./slash_menu.ts";
|
|
17
19
|
import { printLogo } from "./logo.ts";
|
|
18
20
|
import { PYTHON_DIR } from "./config.ts";
|
|
@@ -25,20 +27,6 @@ import { getMainAgent, getSubAgents, getAgentLabel, type AgentId, type SubAgentI
|
|
|
25
27
|
// STT 长按阈值:按下录音热键持续 ≥ 该毫秒数并在释放时才触发录音;提前松开视为误触
|
|
26
28
|
const STT_HOLD_MS = 2000;
|
|
27
29
|
|
|
28
|
-
/** 桌宠手势 → 注入用户消息头部的上下文场景行(双语)。引导 Agent 在回复中自然流露出被摸头/被摇晃的感受。 */
|
|
29
|
-
function petGestureScene(type: PetGestureType): string {
|
|
30
|
-
if (type === "dizzy") {
|
|
31
|
-
return t(
|
|
32
|
-
"(Sensei刚才拖着你到处晃来晃去,你感觉有些头晕。请在回复里自然地体现出这份晕眩感。)",
|
|
33
|
-
"(Sensei just swung you around, and you feel a bit dizzy. Let that dizziness show naturally in your reply.)",
|
|
34
|
-
);
|
|
35
|
-
}
|
|
36
|
-
return t(
|
|
37
|
-
"(Sensei刚才摸了摸你的头。请在回复里自然地体现出被摸头的感受。)",
|
|
38
|
-
"(Sensei just petted your head. Let that show naturally in your reply.)",
|
|
39
|
-
);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
30
|
export class Repl {
|
|
43
31
|
private rl: readline.Interface;
|
|
44
32
|
private session: AgentSession;
|
|
@@ -223,6 +211,9 @@ export class Repl {
|
|
|
223
211
|
this.activeSession = this.session;
|
|
224
212
|
this.activeAgentId = getMainAgent();
|
|
225
213
|
this.currentSessionPath = null; // 新会话清除源文件路径,退出时另存为新文件
|
|
214
|
+
// 复制上下文方案下子会话消息每轮被覆盖,无需清历史;此处回收子 session 对象,
|
|
215
|
+
// 让下轮按新主会话重新初始化(刷新 initSubAgent 期捕获的 memory 等快照)
|
|
216
|
+
this.resetSubSessions();
|
|
226
217
|
this.rendererUnsub?.();
|
|
227
218
|
this.renderer.setSpeakerLabel(getAgentLabel(getMainAgent()));
|
|
228
219
|
this.rendererUnsub = this.renderer.subscribe(this.session);
|
|
@@ -252,6 +243,9 @@ export class Repl {
|
|
|
252
243
|
},
|
|
253
244
|
undoManager: this.undoManager,
|
|
254
245
|
restartTts: () => this.ttsStream.restartVoice(),
|
|
246
|
+
// /change-agent 只改子 Agent 组合时回收旧的子 session 对象(dispose + 清空),
|
|
247
|
+
// 下轮 ensureSubSessions 按新组合重新初始化(刷新 initSubAgent 期捕获的 memory/工具状态)。
|
|
248
|
+
resetSubAgents: () => this.resetSubSessions(),
|
|
255
249
|
};
|
|
256
250
|
}
|
|
257
251
|
|
|
@@ -266,7 +260,7 @@ export class Repl {
|
|
|
266
260
|
|
|
267
261
|
/**
|
|
268
262
|
* 启动全局热键监听(Python pynput 独立进程)。
|
|
269
|
-
*
|
|
263
|
+
* 检测到全局录音热键(macOS 右 Cmd / 其他右 Ctrl)长按 ≥2 秒时,hotkey.py 输出 {"event":"trigger"},触发 STT 录音。
|
|
270
264
|
*/
|
|
271
265
|
private async startHotkeyHook() {
|
|
272
266
|
if (this.hotkeyProc) return; // 已启动
|
|
@@ -278,7 +272,7 @@ export class Repl {
|
|
|
278
272
|
env: {
|
|
279
273
|
...process.env,
|
|
280
274
|
PYTHONUTF8: "1",
|
|
281
|
-
ARONA_HOTKEY_KEY:
|
|
275
|
+
ARONA_HOTKEY_KEY: sttHotkeyKey(),
|
|
282
276
|
ARONA_HOTKEY_HOLD_MS: String(STT_HOLD_MS),
|
|
283
277
|
},
|
|
284
278
|
stdio: ["pipe", "pipe", "pipe"],
|
|
@@ -323,6 +317,16 @@ export class Repl {
|
|
|
323
317
|
}
|
|
324
318
|
});
|
|
325
319
|
|
|
320
|
+
// spawn 失败(pythonPath 不存在 / 脚本缺失)不会触发 close,必须监听 error,
|
|
321
|
+
// 否则 Node 会因未处理的 'error' 事件直接崩溃。
|
|
322
|
+
proc.on("error", (err) => {
|
|
323
|
+
this.hotkeyProc = null;
|
|
324
|
+
this.hotkeyReady = false;
|
|
325
|
+
if (this.hotkeyReadyReject) {
|
|
326
|
+
this.hotkeyReadyReject(new Error(t(`hotkey.py 启动失败:${err.message}`, `hotkey.py failed to start: ${err.message}`)));
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
326
330
|
// 等待 ready 信号(最多 5 秒;进程提前退出会立即 reject)
|
|
327
331
|
await new Promise<void>((resolve, reject) => {
|
|
328
332
|
const cleanup = () => {
|
|
@@ -348,14 +352,15 @@ export class Repl {
|
|
|
348
352
|
this.hotkeyReadyReject = null;
|
|
349
353
|
} catch (err) {
|
|
350
354
|
this.hotkeyProc = null;
|
|
355
|
+
const isMac = process.platform === "darwin";
|
|
351
356
|
console.warn(chalk.yellow(
|
|
352
357
|
t(
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
358
|
+
isMac
|
|
359
|
+
? "STT 热键不可用:请安装 pynput(pip install pynput),并在 系统设置 → 隐私与安全性 → 辅助功能 中为Python授权。"
|
|
360
|
+
: "STT 热键不可用:请安装 pynput(pip install pynput),并检查 Python 是否有权限监听全局键盘。",
|
|
361
|
+
isMac
|
|
362
|
+
? "STT hotkey unavailable: install pynput (pip install pynput) and grant Accessibility permission to Python in System Settings."
|
|
363
|
+
: "STT hotkey unavailable: install pynput (pip install pynput) and check that Python has permission to listen for global keys.",
|
|
359
364
|
)
|
|
360
365
|
));
|
|
361
366
|
}
|
|
@@ -400,7 +405,7 @@ export class Repl {
|
|
|
400
405
|
}
|
|
401
406
|
// 让 readline 先把当前提示符行清掉,再启动录音 UI
|
|
402
407
|
await new Promise((r) => setImmediate(r));
|
|
403
|
-
console.log(chalk.cyan(t("
|
|
408
|
+
console.log(chalk.cyan(t("聆听…请说话", "Listening…")));
|
|
404
409
|
this.rl.prompt();
|
|
405
410
|
const text = await voice.listen();
|
|
406
411
|
if (text) {
|
|
@@ -444,6 +449,9 @@ export class Repl {
|
|
|
444
449
|
try {
|
|
445
450
|
const messages = memory.loadSession(path);
|
|
446
451
|
this.session.agent.state.messages = messages;
|
|
452
|
+
// 复制上下文方案下子会话消息每轮被覆盖,无需清历史;此处回收子 session 对象,
|
|
453
|
+
// 让下轮按恢复的会话重新初始化(刷新 initSubAgent 期捕获的 memory 等快照)
|
|
454
|
+
this.resetSubSessions();
|
|
447
455
|
memory.resetConversationFlag();
|
|
448
456
|
this.currentSessionPath = path; // 退出时覆盖保存回此文件
|
|
449
457
|
console.clear();
|
|
@@ -489,28 +497,18 @@ export class Repl {
|
|
|
489
497
|
console.clear();
|
|
490
498
|
}
|
|
491
499
|
printLogo();
|
|
492
|
-
|
|
500
|
+
const statusLine: string[] = [];
|
|
501
|
+
statusLine.push(t(`模型:${config.model}`, `Model: ${config.model}`));
|
|
493
502
|
if (config.noVoice) {
|
|
494
|
-
|
|
503
|
+
statusLine.push(t("语音:关", "Voice: off"));
|
|
495
504
|
} else {
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
} else {
|
|
501
|
-
console.log(chalk.cyan(t(` TTS:${voice.isTtsEnabled() ? "开" : "关"}`, ` TTS: ${voice.isTtsEnabled() ? "on" : "off"}`)));
|
|
502
|
-
}
|
|
503
|
-
console.log(chalk.cyan(t(` STT:${voice.isSttEnabled() ? "开" : "关"}`, ` STT: ${voice.isSttEnabled() ? "on" : "off"}`)));
|
|
504
|
-
// STT 开启时打印热键提示:长按右 Cmd ≥2秒录音;任务中按会先 abort 再录音
|
|
505
|
-
if (voice.isSttEnabled()) {
|
|
506
|
-
console.log(chalk.cyan(t(` 录音:长按右 Cmd ≥${STT_HOLD_MS / 1000}秒(提前松开取消)`, ` Record: hold right Cmd ≥${STT_HOLD_MS / 1000}s (release early to cancel)`)));
|
|
507
|
-
// 非 macOS 无 系统设置 → 隐私与安全性 → 辅助功能 这一路径,提示仅对 macOS 显示
|
|
508
|
-
if (process.platform === "darwin") {
|
|
509
|
-
console.log(chalk.cyan(t(" 提示:若热键无响应,请在 系统设置 → 隐私与安全性 → 辅助功能 中为Python授权", " Tip: if the hotkey does not respond, grant Accessibility permission to Python in System Settings → Privacy & Security")));
|
|
510
|
-
}
|
|
511
|
-
}
|
|
505
|
+
statusLine.push(voice.hasCurrentVoice()
|
|
506
|
+
? t(`TTS:${voice.isTtsEnabled() ? `开(${getTtsProvider().label()})` : "关"}`, `TTS: ${voice.isTtsEnabled() ? `on (${getTtsProvider().label()})` : "off"}`)
|
|
507
|
+
: t("TTS:关(未配置声音)", "TTS: off (no voice configured)"));
|
|
508
|
+
statusLine.push(t(`STT:${voice.isSttEnabled() ? "开" : "关"}`, `STT: ${voice.isSttEnabled() ? "on" : "off"}`));
|
|
512
509
|
}
|
|
513
|
-
console.log(chalk.cyan(
|
|
510
|
+
console.log(chalk.cyan(" " + statusLine.join(" · ")));
|
|
511
|
+
// 仅在有编程语义的操作提示时打印帮助行
|
|
514
512
|
|
|
515
513
|
// --resume= 启动时渲染恢复的历史记录(在 logo 之后,提示符之前)
|
|
516
514
|
if (this.resumedMessages) {
|
|
@@ -623,25 +621,18 @@ export class Repl {
|
|
|
623
621
|
this.doExit();
|
|
624
622
|
});
|
|
625
623
|
|
|
626
|
-
// STT 开启时启动全局热键监听(pynput
|
|
624
|
+
// STT 开启时启动全局热键监听(pynput,长按全局录音热键 ≥2秒触发录音)
|
|
627
625
|
if (voice.isSttEnabled()) {
|
|
628
626
|
this.startHotkeyHook();
|
|
629
627
|
}
|
|
630
628
|
}
|
|
631
629
|
|
|
632
630
|
private async processInput(input: string) {
|
|
633
|
-
// 桌宠手势(摸头/dizzy
|
|
634
|
-
//
|
|
635
|
-
// takeGesture
|
|
636
|
-
// processInput 不会被调用(见 line handler 的 / 分支),手势保留到下一条真实消息。
|
|
637
|
-
const gesture = pet.takeGesture();
|
|
638
|
-
let effective = input;
|
|
639
|
-
if (gesture) {
|
|
640
|
-
const scene = petGestureScene(gesture);
|
|
641
|
-
if (scene) effective = `${scene}\n\n${input}`;
|
|
642
|
-
}
|
|
631
|
+
// 桌宠手势(摸头/dizzy)不再拼进用户消息:落到主 Agent 发送边界注入(gesture_context.ts),
|
|
632
|
+
// 不进 state.messages → 子 Agent 复制主 session 历史时看不到、会话命名/存储零污染。
|
|
633
|
+
// takeGesture 消费即清空由发送边界扩展完成,只注入最近一次。
|
|
643
634
|
// 展开 @文件 / !命令 后走完整回合生命周期
|
|
644
|
-
await this.runRawTurn(this.parseInput(
|
|
635
|
+
await this.runRawTurn(this.parseInput(input));
|
|
645
636
|
}
|
|
646
637
|
|
|
647
638
|
/** 把 renderer 订阅切到指定角色 session,并记录当前发言者。 */
|
|
@@ -649,10 +640,29 @@ export class Repl {
|
|
|
649
640
|
this.activeAgentId = agentId;
|
|
650
641
|
this.activeSession = session;
|
|
651
642
|
this.renderer.setSpeakerLabel(getAgentLabel(agentId));
|
|
643
|
+
// 显式复位回合状态,杜绝跨 session 残留 curMsgText/lastText 被误读
|
|
644
|
+
this.renderer.resetTurn();
|
|
652
645
|
this.rendererUnsub?.();
|
|
653
646
|
this.rendererUnsub = this.renderer.subscribe(session);
|
|
654
647
|
}
|
|
655
648
|
|
|
649
|
+
/**
|
|
650
|
+
* 回收全部子 session 对象(dispose + 清空)。
|
|
651
|
+
* 复制上下文方案下子会话每轮被主 session 消息覆盖,无需借此清历史;此处目的是释放
|
|
652
|
+
* session 对象,并让下轮 ensureSubSessions 按新主会话/新子组合重新初始化,刷新
|
|
653
|
+
* initSubAgent 期捕获的 memoryContent 等快照与工具状态(/new、/resume、/change-agent 时调用)。
|
|
654
|
+
*/
|
|
655
|
+
private resetSubSessions(): void {
|
|
656
|
+
for (const subSession of this.subSessions.values()) {
|
|
657
|
+
try {
|
|
658
|
+
subSession.dispose();
|
|
659
|
+
} catch {
|
|
660
|
+
// 回收失败不影响主流程
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
this.subSessions.clear();
|
|
664
|
+
}
|
|
665
|
+
|
|
656
666
|
/** 按 settings.json 当前启用的子 Agent 初始化/补齐子 session。 */
|
|
657
667
|
private async ensureSubSessions(): Promise<void> {
|
|
658
668
|
const enabled = getSubAgents();
|
|
@@ -800,7 +810,7 @@ export class Repl {
|
|
|
800
810
|
this.hidePetBubble();
|
|
801
811
|
// 回合开始前打 checkpoint:扫一次当前工作目录,作为 before 快照
|
|
802
812
|
// (回合结束后 afterTurn 会与它做 diff 入 undo 栈)
|
|
803
|
-
this.undoManager.beforeTurn();
|
|
813
|
+
await this.undoManager.beforeTurn();
|
|
804
814
|
// 回合开始:不主动切换情绪,保持默认待机动画;
|
|
805
815
|
// 由 agent 调用 change_emotion 一步到位地确定本回合情绪,避免先 saying 再切换的跳变
|
|
806
816
|
|
|
@@ -835,7 +845,7 @@ export class Repl {
|
|
|
835
845
|
} finally {
|
|
836
846
|
// 回合结束后打 after checkpoint,产出 diff 入 undo 栈
|
|
837
847
|
try {
|
|
838
|
-
this.undoManager.afterTurn();
|
|
848
|
+
await this.undoManager.afterTurn();
|
|
839
849
|
} catch (err) {
|
|
840
850
|
// undo checkpoint 失败不影响主流程
|
|
841
851
|
console.warn(chalk.yellow(t(`撤销快照记录失败:${err instanceof Error ? err.message : err}`, `Failed to record undo snapshot: ${err instanceof Error ? err.message : err}`)));
|
|
@@ -876,8 +886,9 @@ export class Repl {
|
|
|
876
886
|
// 停止全局热键监听(pynput)
|
|
877
887
|
this.stopHotkeyHook();
|
|
878
888
|
|
|
879
|
-
// 停止 TTS
|
|
889
|
+
// 停止 TTS:终止当前合成进程并清空队列;同时回收本进程自动启动的 GPT-SoVITS api_v2 子进程
|
|
880
890
|
this.ttsStream.shutdown();
|
|
891
|
+
stopGptSovitsLocalServer();
|
|
881
892
|
|
|
882
893
|
// Cleanup
|
|
883
894
|
stopComputerUse();
|