arona-agent 1.0.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.md +149 -0
- package/assets/blue-archive/arona/spine/arona_spr.atlas.txt +1168 -0
- package/assets/blue-archive/arona/spine/arona_spr.png +0 -0
- package/assets/blue-archive/arona/spine/arona_spr.skel +0 -0
- package/assets/blue-archive/arona/voice.mp3 +0 -0
- package/assets/blue-archive/hoshino/hoshino_spr.atlas.txt +174 -0
- package/assets/blue-archive/hoshino/hoshino_spr.json +15385 -0
- package/assets/blue-archive/hoshino/hoshino_spr.png +0 -0
- package/assets/blue-archive/hoshino/hoshino_spr.skel +0 -0
- package/assets/blue-archive/hoshino/robber/hoshino_robber_spr.atlas.txt +111 -0
- package/assets/blue-archive/hoshino/robber/hoshino_robber_spr.png +0 -0
- package/assets/blue-archive/hoshino/robber/hoshino_robber_spr.skel +0 -0
- package/assets/blue-archive/hoshino/swimsuit/hoshino_swimsuit_spr.atlas.txt +251 -0
- package/assets/blue-archive/hoshino/swimsuit/hoshino_swimsuit_spr.png +0 -0
- package/assets/blue-archive/hoshino/swimsuit/hoshino_swimsuit_spr.skel +0 -0
- package/assets/blue-archive/hoshino/voice.mp3 +0 -0
- package/assets/blue-archive/plana/spine/plana_spr.atlas.txt +1266 -0
- package/assets/blue-archive/plana/spine/plana_spr.png +0 -0
- package/assets/blue-archive/plana/spine/plana_spr.skel +0 -0
- package/assets/blue-archive/plana/voice.mp3 +0 -0
- package/assets/blue-archive/shiroko/ridingsuit/shiroko_ridingsuit_spr.atlas.txt +97 -0
- package/assets/blue-archive/shiroko/ridingsuit/shiroko_ridingsuit_spr.png +0 -0
- package/assets/blue-archive/shiroko/ridingsuit/shiroko_ridingsuit_spr.skel +0 -0
- package/assets/blue-archive/shiroko/robber/shiroko_robber_spr.atlas.txt +174 -0
- package/assets/blue-archive/shiroko/robber/shiroko_robber_spr.png +0 -0
- package/assets/blue-archive/shiroko/robber/shiroko_robber_spr.skel +0 -0
- package/assets/blue-archive/shiroko/shiroko_spr.atlas.txt +167 -0
- package/assets/blue-archive/shiroko/shiroko_spr.json +10702 -0
- package/assets/blue-archive/shiroko/shiroko_spr.png +0 -0
- package/assets/blue-archive/shiroko/shiroko_spr.skel +0 -0
- package/assets/blue-archive/shiroko/voice.mp3 +0 -0
- package/bin/arona.mjs +43 -0
- package/package.json +37 -0
- package/pet/agents.cjs +197 -0
- package/pet/main.cjs +462 -0
- package/pet/preload.cjs +21 -0
- package/pet/renderer/fx.html +30 -0
- package/pet/renderer/fx.js +41 -0
- package/pet/renderer/gallery.html +18 -0
- package/pet/renderer/gallery.js +31 -0
- package/pet/renderer/index.html +18 -0
- package/pet/renderer/renderer.js +382 -0
- package/pet/renderer/spine_layer.js +609 -0
- package/pet/renderer/spinetest.html +24 -0
- package/pet/renderer/spinetest.js +295 -0
- package/pet/renderer/style.css +63 -0
- package/pet/tools/gallery_capture.cjs +267 -0
- package/pet/tools/gen_sway.cjs +170 -0
- package/pet/tools/meshify_region.cjs +105 -0
- package/pet/tools/skel_inspect.cjs +112 -0
- package/pet/tools/skel_to_json.cjs +410 -0
- package/pet/tools/spine_node.cjs +69 -0
- package/pet/tools/visual_test.cjs +262 -0
- package/pet/vendor/spine/spine-canvas.js +8472 -0
- package/pet/vendor/spine/spine-webgl.js +10839 -0
- package/python/__pycache__/_i18n.cpython-313.pyc +0 -0
- package/python/__pycache__/_i18n.cpython-314.pyc +0 -0
- package/python/__pycache__/computer_use.cpython-314.pyc +0 -0
- package/python/__pycache__/hotkey.cpython-313.pyc +0 -0
- package/python/__pycache__/hotkey.cpython-314.pyc +0 -0
- package/python/__pycache__/stt.cpython-313.pyc +0 -0
- package/python/__pycache__/stt.cpython-314.pyc +0 -0
- package/python/__pycache__/tts.cpython-313.pyc +0 -0
- package/python/__pycache__/tts.cpython-314.pyc +0 -0
- package/python/__pycache__/tts_stream.cpython-313.pyc +0 -0
- package/python/__pycache__/tts_stream.cpython-314.pyc +0 -0
- package/python/__pycache__/voice_clone.cpython-314.pyc +0 -0
- package/python/_i18n.py +18 -0
- package/python/computer_use.py +190 -0
- package/python/hotkey.py +196 -0
- package/python/stt.py +240 -0
- package/python/tts_say.py +177 -0
- package/python/voice_clone.py +98 -0
- package/requirements.txt +6 -0
- package/src/agent.ts +791 -0
- package/src/agent_registry.ts +109 -0
- package/src/commands.ts +641 -0
- package/src/config.ts +245 -0
- package/src/in_memory_credentials.ts +61 -0
- package/src/index.ts +78 -0
- package/src/locale.ts +131 -0
- package/src/logo.ts +120 -0
- package/src/mcp.ts +192 -0
- package/src/memory.ts +303 -0
- package/src/pet.ts +346 -0
- package/src/renderer.ts +350 -0
- package/src/repl.ts +902 -0
- package/src/setup.ts +371 -0
- package/src/skills.ts +96 -0
- package/src/slash_menu.ts +371 -0
- package/src/slash_registry.ts +53 -0
- package/src/speaker_context.ts +41 -0
- package/src/text_split.ts +79 -0
- package/src/tools/computer_use.ts +142 -0
- package/src/tools/emotion_tool.ts +55 -0
- package/src/tools/keep_silent_tool.ts +26 -0
- package/src/tools/memory_tool.ts +24 -0
- package/src/tools/skill_tools.ts +100 -0
- package/src/tools/tavily_tools.ts +431 -0
- package/src/tools/voice_tools.ts +28 -0
- package/src/tts_stream.ts +260 -0
- package/src/tui_select.ts +146 -0
- package/src/undo.ts +451 -0
- package/src/utils/python.ts +211 -0
- package/src/utils/spawn.ts +56 -0
- package/src/voice.ts +83 -0
- package/src/voice_cli.ts +198 -0
- package/src/voices.ts +135 -0
package/src/repl.ts
ADDED
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
import * as readline from "readline";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { execSync, type ChildProcessWithoutNullStreams } from "child_process";
|
|
4
|
+
import { readFileSync, existsSync } from "fs";
|
|
5
|
+
import { join, resolve } from "path";
|
|
6
|
+
import type { AgentSession, ModelRuntime, DefaultResourceLoader } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { config } from "./config.ts";
|
|
8
|
+
import { handleCommand, type CommandContext } from "./commands.ts";
|
|
9
|
+
import { createRenderer, renderSavedMessages } from "./renderer.ts";
|
|
10
|
+
import * as memory from "./memory.ts";
|
|
11
|
+
import * as voice from "./voice.ts";
|
|
12
|
+
import { TtsStream } from "./tts_stream.ts";
|
|
13
|
+
import { stopComputerUse } from "./tools/computer_use.ts";
|
|
14
|
+
import { disconnectAllMcp } from "./mcp.ts";
|
|
15
|
+
import { pet, stopPet, type PetGestureType } from "./pet.ts";
|
|
16
|
+
import { SlashMenu } from "./slash_menu.ts";
|
|
17
|
+
import { printLogo } from "./logo.ts";
|
|
18
|
+
import { PYTHON_DIR } from "./config.ts";
|
|
19
|
+
import { UndoManager } from "./undo.ts";
|
|
20
|
+
import { t } from "./locale.ts";
|
|
21
|
+
import { spawnCompat } from "./utils/spawn.ts";
|
|
22
|
+
import { initSubAgent } from "./agent.ts";
|
|
23
|
+
import { getMainAgent, getSubAgents, getAgentLabel, type AgentId, type SubAgentId } from "./agent_registry.ts";
|
|
24
|
+
|
|
25
|
+
// STT 长按阈值:按下录音热键持续 ≥ 该毫秒数并在释放时才触发录音;提前松开视为误触
|
|
26
|
+
const STT_HOLD_MS = 2000;
|
|
27
|
+
|
|
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
|
+
export class Repl {
|
|
43
|
+
private rl: readline.Interface;
|
|
44
|
+
private session: AgentSession;
|
|
45
|
+
private modelRuntime: ModelRuntime;
|
|
46
|
+
private loader: DefaultResourceLoader;
|
|
47
|
+
private isProcessing = false;
|
|
48
|
+
private aborted = false; // 中断标志:避免 processInput finally 重复 prompt
|
|
49
|
+
private sigintCount = 0;
|
|
50
|
+
private sigintTimer: NodeJS.Timeout | null = null;
|
|
51
|
+
// 非流式 TTS 管道:agent_end 时只把最后一个回复 message 交给 endTurn() 整段合成播放
|
|
52
|
+
// 音色按当前发言角色动态切换(setVoice),isEnabled 按该角色是否有克隆音色判断
|
|
53
|
+
private ttsStream = new TtsStream(
|
|
54
|
+
(agentId) => voice.isTtsEnabledFor(agentId),
|
|
55
|
+
() => {
|
|
56
|
+
// TTS 播放结束:先隐藏桌宠气泡,再恢复默认待机
|
|
57
|
+
this.hidePetBubble();
|
|
58
|
+
if (this.turnEnded && !this.ttsStream.isPending) pet.reset();
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
// 回合结束标志:中间段的 play_end 不触发 pet.reset,只有回合结束后才恢复桌宠
|
|
62
|
+
private turnEnded = false;
|
|
63
|
+
// 子 Agent session 集合(shiroko/hoshino;按 settings 启用)
|
|
64
|
+
private subSessions = new Map<SubAgentId, AgentSession>();
|
|
65
|
+
// 当前正在发言的 session / agent(中断时 abort 的是它,而不是固定主 session)
|
|
66
|
+
private activeSession: AgentSession;
|
|
67
|
+
private activeAgentId: AgentId;
|
|
68
|
+
// TTS 禁用时 5s 后隐藏气泡的定时器
|
|
69
|
+
private bubbleHideTimer: NodeJS.Timeout | null = null;
|
|
70
|
+
private onExit: () => void;
|
|
71
|
+
private onNewSession: () => Promise<{
|
|
72
|
+
session: AgentSession;
|
|
73
|
+
modelRuntime: ModelRuntime;
|
|
74
|
+
loader: DefaultResourceLoader;
|
|
75
|
+
}>;
|
|
76
|
+
// renderer 提升为字段:/new 切换会话后需要重新 subscribe 到新 session
|
|
77
|
+
private renderer!: ReturnType<typeof createRenderer>;
|
|
78
|
+
private rendererUnsub: (() => void) | null = null;
|
|
79
|
+
// --resume= 启动时恢复的消息(在 start() 中渲染,确保 logo 在历史记录之前)
|
|
80
|
+
private resumedMessages: any[] | null;
|
|
81
|
+
// 当前会话的源文件路径。
|
|
82
|
+
// - null:新会话(退出时另存为新文件)
|
|
83
|
+
// - 非 null:从该文件 resume 的会话(退出时覆盖保存回原文件)
|
|
84
|
+
private currentSessionPath: string | null;
|
|
85
|
+
// 斜杠命令菜单(渲染与状态机封装在 SlashMenu 中)
|
|
86
|
+
private menu = new SlashMenu();
|
|
87
|
+
private menuKeyListener: ((s: any, k: any) => void) | null = null;
|
|
88
|
+
// 本地快照式 undo/redo 管理器(不依赖 git)
|
|
89
|
+
private undoManager: UndoManager;
|
|
90
|
+
|
|
91
|
+
constructor(
|
|
92
|
+
session: AgentSession,
|
|
93
|
+
modelRuntime: ModelRuntime,
|
|
94
|
+
loader: DefaultResourceLoader,
|
|
95
|
+
onExit: () => void,
|
|
96
|
+
onNewSession: () => Promise<{
|
|
97
|
+
session: AgentSession;
|
|
98
|
+
modelRuntime: ModelRuntime;
|
|
99
|
+
loader: DefaultResourceLoader;
|
|
100
|
+
}>,
|
|
101
|
+
resumedMessages: any[] | null = null,
|
|
102
|
+
initialSessionPath: string | null = null,
|
|
103
|
+
) {
|
|
104
|
+
this.session = session;
|
|
105
|
+
this.modelRuntime = modelRuntime;
|
|
106
|
+
this.loader = loader;
|
|
107
|
+
this.onExit = onExit;
|
|
108
|
+
this.onNewSession = onNewSession;
|
|
109
|
+
this.resumedMessages = resumedMessages;
|
|
110
|
+
this.currentSessionPath = initialSessionPath;
|
|
111
|
+
this.activeSession = session;
|
|
112
|
+
this.activeAgentId = getMainAgent();
|
|
113
|
+
|
|
114
|
+
this.undoManager = new UndoManager(process.cwd());
|
|
115
|
+
this.undoManager.load();
|
|
116
|
+
|
|
117
|
+
this.rl = readline.createInterface({
|
|
118
|
+
input: process.stdin,
|
|
119
|
+
output: process.stdout,
|
|
120
|
+
prompt: chalk.cyan("❯ "),
|
|
121
|
+
terminal: true,
|
|
122
|
+
// We provide our own Tab behavior; disable readline's default completion
|
|
123
|
+
completer: () => [[], ""],
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Setup renderer with TTS callbacks
|
|
127
|
+
// onTurnEnd: 回合结束(agent_end)时把最后一个 message 的完整文本交给 TTS 一次性处理
|
|
128
|
+
// (只朗读最终回复;中间穿插工具调用的过程性发言不朗读)+ 气泡同步上屏最后一段
|
|
129
|
+
// onPetText: 桌宠文字气泡(仅桌宠运行时发送);情绪仍由 LLM change_emotion 工具驱动
|
|
130
|
+
// --no-voice 模式下没有 play_end 兜底隐藏气泡:final 上屏时挂 5s 定时器
|
|
131
|
+
this.renderer = createRenderer(
|
|
132
|
+
(text) => this.ttsStream.endTurn(text),
|
|
133
|
+
(kind, data) => {
|
|
134
|
+
if (pet.isRunning) pet.sendText(this.activeAgentId, kind, data);
|
|
135
|
+
},
|
|
136
|
+
getAgentLabel(getMainAgent()),
|
|
137
|
+
);
|
|
138
|
+
this.rendererUnsub = this.renderer.subscribe(this.session);
|
|
139
|
+
|
|
140
|
+
this.setupSignals();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 通知桌宠隐藏气泡(TTS 播放结束 / 打断时调用) */
|
|
144
|
+
private hidePetBubble(): void {
|
|
145
|
+
if (this.bubbleHideTimer) {
|
|
146
|
+
clearTimeout(this.bubbleHideTimer);
|
|
147
|
+
this.bubbleHideTimer = null;
|
|
148
|
+
}
|
|
149
|
+
if (pet.isRunning) pet.sendText(this.activeAgentId, "tts_end", "");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* TTS 禁用时的兜底:5s 后同时隐藏气泡 + 恢复 idle 表情。
|
|
154
|
+
* 涵盖 --no-voice / 缺音色 / 切到无音色角色三种静音原因(voice.isTtsEnabled() 统一判断)。
|
|
155
|
+
*/
|
|
156
|
+
private scheduleBubbleHide(): void {
|
|
157
|
+
if (this.bubbleHideTimer) clearTimeout(this.bubbleHideTimer);
|
|
158
|
+
this.bubbleHideTimer = setTimeout(() => {
|
|
159
|
+
this.bubbleHideTimer = null;
|
|
160
|
+
this.hidePetBubble();
|
|
161
|
+
if (pet.isRunning) pet.reset();
|
|
162
|
+
}, 5000);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private setupSignals() {
|
|
166
|
+
// 注意:emitKeypressEvents 后,Ctrl+C 由 readline 拦截并 emit rl 的
|
|
167
|
+
// 'SIGINT' 事件,不会冒泡到 process 的 SIGINT。所以必须监听 rl 上的
|
|
168
|
+
// SIGINT,否则空闲态按 Ctrl+C 会直接走 readline 默认行为(关闭接口)。
|
|
169
|
+
this.rl.on("SIGINT", () => {
|
|
170
|
+
// 正在执行任务:第一次中断任务,第二次(紧随其后)直接退出
|
|
171
|
+
if (this.isProcessing) {
|
|
172
|
+
this.activeSession.abort().catch(() => {});
|
|
173
|
+
this.isProcessing = false;
|
|
174
|
+
this.aborted = true; // 阻止 processInput finally 再次 prompt
|
|
175
|
+
this.ttsStream.cancel(); // 打断 TTS,中断后不再播放后续内容
|
|
176
|
+
this.hidePetBubble(); // 打断时立即隐藏气泡
|
|
177
|
+
// 清空 readline 缓冲区,避免残留内容在重绘时混入提示符行
|
|
178
|
+
(this.rl as any).line = "";
|
|
179
|
+
(this.rl as any).cursor = 0;
|
|
180
|
+
process.stdout.write(chalk.yellow("\n[aborted]\n"));
|
|
181
|
+
// 标记刚中断过,下一次 Ctrl+C 直接退出(不再提示"再按一次")
|
|
182
|
+
this.sigintCount = 1;
|
|
183
|
+
if (this.sigintTimer) clearTimeout(this.sigintTimer);
|
|
184
|
+
this.sigintTimer = setTimeout(() => {
|
|
185
|
+
this.sigintCount = 0;
|
|
186
|
+
this.sigintTimer = null;
|
|
187
|
+
}, 1500);
|
|
188
|
+
this.rl.prompt(true);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 空闲态:第一次提示,第二次退出
|
|
193
|
+
this.sigintCount++;
|
|
194
|
+
if (this.sigintCount >= 2) {
|
|
195
|
+
if (this.sigintTimer) {
|
|
196
|
+
clearTimeout(this.sigintTimer);
|
|
197
|
+
this.sigintTimer = null;
|
|
198
|
+
}
|
|
199
|
+
console.log(chalk.yellow("\nExiting..."));
|
|
200
|
+
this.doExit();
|
|
201
|
+
} else {
|
|
202
|
+
process.stdout.write(chalk.cyan(t("\n再按一次 Ctrl+C 退出。\n", "\nPress Ctrl+C again to exit.\n")));
|
|
203
|
+
this.sigintTimer = setTimeout(() => {
|
|
204
|
+
this.sigintCount = 0;
|
|
205
|
+
this.sigintTimer = null;
|
|
206
|
+
}, 1500);
|
|
207
|
+
this.rl.prompt(true);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private getCommandContext(): CommandContext {
|
|
213
|
+
return {
|
|
214
|
+
session: this.session,
|
|
215
|
+
loader: this.loader,
|
|
216
|
+
exit: () => this.doExit(),
|
|
217
|
+
newSession: async () => {
|
|
218
|
+
// 重建会话:旧 session 已 dispose,需把 Repl 的引用和 renderer 订阅绑到新 session
|
|
219
|
+
const result = await this.onNewSession();
|
|
220
|
+
this.session = result.session;
|
|
221
|
+
this.modelRuntime = result.modelRuntime;
|
|
222
|
+
this.loader = result.loader;
|
|
223
|
+
this.activeSession = this.session;
|
|
224
|
+
this.activeAgentId = getMainAgent();
|
|
225
|
+
this.currentSessionPath = null; // 新会话清除源文件路径,退出时另存为新文件
|
|
226
|
+
this.rendererUnsub?.();
|
|
227
|
+
this.renderer.setSpeakerLabel(getAgentLabel(getMainAgent()));
|
|
228
|
+
this.rendererUnsub = this.renderer.subscribe(this.session);
|
|
229
|
+
},
|
|
230
|
+
runAgentTurn: (text: string) => this.runRawTurn(text),
|
|
231
|
+
resumeSession: (path: string) => {
|
|
232
|
+
this.resumeSession(path);
|
|
233
|
+
},
|
|
234
|
+
saveCurrentSession: () => {
|
|
235
|
+
// /change-agent 重建会话前落盘当前对话(resume 覆盖原文件 / 新会话仅有效对话)
|
|
236
|
+
this.saveCurrentSessionIfNeeded();
|
|
237
|
+
},
|
|
238
|
+
// 交互式命令(如 /resume)接管输入期间,暂停斜杠菜单监听器
|
|
239
|
+
pauseMenuListener: () => {
|
|
240
|
+
if (this.menuKeyListener) {
|
|
241
|
+
process.stdin.removeListener("keypress", this.menuKeyListener);
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
resumeMenuListener: () => {
|
|
245
|
+
if (this.menuKeyListener) {
|
|
246
|
+
process.stdin.prependListener("keypress", this.menuKeyListener);
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
setSttHookEnabled: (enabled: boolean) => {
|
|
250
|
+
if (enabled) this.startHotkeyHook();
|
|
251
|
+
else this.stopHotkeyHook();
|
|
252
|
+
},
|
|
253
|
+
undoManager: this.undoManager,
|
|
254
|
+
restartTts: () => this.ttsStream.restartVoice(),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 全局热键监听:独立 Python 进程(python/hotkey.py,pynput 实现)
|
|
259
|
+
// 通过 stdout JSON 事件流通信:{"event":"ready"} / {"event":"trigger"} / {"event":"exit"}
|
|
260
|
+
// 独立进程不受 Node 事件循环和 spawn stt.py 影响,比 uiohook-napi native 模块更可靠
|
|
261
|
+
private hotkeyProc: ChildProcessWithoutNullStreams | null = null;
|
|
262
|
+
private hotkeyBuffer = "";
|
|
263
|
+
private hotkeyReady = false;
|
|
264
|
+
// 就绪等待的 reject 句柄:进程提前退出时立即 reject,不必等满 5 秒
|
|
265
|
+
private hotkeyReadyReject: ((e: Error) => void) | null = null;
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* 启动全局热键监听(Python pynput 独立进程)。
|
|
269
|
+
* 检测到右 Cmd 长按 ≥2 秒时,hotkey.py 输出 {"event":"trigger"},触发 STT 录音。
|
|
270
|
+
*/
|
|
271
|
+
private async startHotkeyHook() {
|
|
272
|
+
if (this.hotkeyProc) return; // 已启动
|
|
273
|
+
const scriptPath = join(PYTHON_DIR, "hotkey.py");
|
|
274
|
+
this.hotkeyReady = false;
|
|
275
|
+
this.hotkeyBuffer = "";
|
|
276
|
+
try {
|
|
277
|
+
const proc = spawnCompat(config.pythonPath, ["-u", scriptPath], {
|
|
278
|
+
env: {
|
|
279
|
+
...process.env,
|
|
280
|
+
PYTHONUTF8: "1",
|
|
281
|
+
ARONA_HOTKEY_KEY: "cmd_r",
|
|
282
|
+
ARONA_HOTKEY_HOLD_MS: String(STT_HOLD_MS),
|
|
283
|
+
},
|
|
284
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
285
|
+
});
|
|
286
|
+
this.hotkeyProc = proc;
|
|
287
|
+
|
|
288
|
+
proc.stdout.on("data", (data) => {
|
|
289
|
+
this.hotkeyBuffer += data.toString();
|
|
290
|
+
const lines = this.hotkeyBuffer.split("\n");
|
|
291
|
+
this.hotkeyBuffer = lines.pop() || "";
|
|
292
|
+
for (const line of lines) {
|
|
293
|
+
const trimmed = line.trim();
|
|
294
|
+
if (!trimmed) continue;
|
|
295
|
+
try {
|
|
296
|
+
const msg = JSON.parse(trimmed);
|
|
297
|
+
if (msg.event === "ready") {
|
|
298
|
+
this.hotkeyReady = true;
|
|
299
|
+
} else if (msg.event === "trigger") {
|
|
300
|
+
// 收到触发事件,调用 triggerStt
|
|
301
|
+
this.triggerStt().catch(() => {});
|
|
302
|
+
}
|
|
303
|
+
} catch {
|
|
304
|
+
// 非 JSON,忽略
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
proc.stderr.on("data", (data) => {
|
|
310
|
+
const msg = data.toString().trim();
|
|
311
|
+
if (msg) console.error(`[python:hotkey]`, msg);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
proc.on("close", (code) => {
|
|
315
|
+
this.hotkeyProc = null;
|
|
316
|
+
this.hotkeyReady = false;
|
|
317
|
+
// 进程提前退出(如 pynput 缺失/未授权):立即 reject 就绪等待,避免等满 5 秒
|
|
318
|
+
if (this.hotkeyReadyReject) {
|
|
319
|
+
this.hotkeyReadyReject(new Error(t(`hotkey.py 进程退出(code ${code})`, `hotkey.py process exited (code ${code})`)));
|
|
320
|
+
}
|
|
321
|
+
if (code !== 0 && code !== null) {
|
|
322
|
+
console.warn(chalk.yellow(t(`STT 热键进程退出(code ${code})。`, `STT hotkey process exited (code ${code}).`)));
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// 等待 ready 信号(最多 5 秒;进程提前退出会立即 reject)
|
|
327
|
+
await new Promise<void>((resolve, reject) => {
|
|
328
|
+
const cleanup = () => {
|
|
329
|
+
clearTimeout(timeout);
|
|
330
|
+
clearInterval(interval);
|
|
331
|
+
};
|
|
332
|
+
const timeout = setTimeout(() => {
|
|
333
|
+
cleanup();
|
|
334
|
+
reject(new Error(t("hotkey.py 未在 5 秒内就绪", "hotkey.py did not become ready within 5s")));
|
|
335
|
+
}, 5000);
|
|
336
|
+
// 轮询检查(ready 在 stdout data 事件里设置)
|
|
337
|
+
const interval = setInterval(() => {
|
|
338
|
+
if (this.hotkeyReady) {
|
|
339
|
+
cleanup();
|
|
340
|
+
resolve();
|
|
341
|
+
}
|
|
342
|
+
}, 50);
|
|
343
|
+
this.hotkeyReadyReject = (e: Error) => {
|
|
344
|
+
cleanup();
|
|
345
|
+
reject(e);
|
|
346
|
+
};
|
|
347
|
+
});
|
|
348
|
+
this.hotkeyReadyReject = null;
|
|
349
|
+
} catch (err) {
|
|
350
|
+
this.hotkeyProc = null;
|
|
351
|
+
console.warn(chalk.yellow(
|
|
352
|
+
t(
|
|
353
|
+
"STT 热键不可用:全局键盘监听启动失败。\n" +
|
|
354
|
+
" 请确认已安装 pynput(pip install pynput),\n" +
|
|
355
|
+
" 并在 系统设置 → 隐私与安全性 → 辅助功能 中为Python 授权。",
|
|
356
|
+
"STT hotkey unavailable: global keyboard listener failed to start.\n" +
|
|
357
|
+
" Make sure pynput is installed (pip install pynput),\n" +
|
|
358
|
+
" and grant Accessibility permission to Python in System Settings.",
|
|
359
|
+
)
|
|
360
|
+
));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* 停止全局热键监听,终止 hotkey.py 进程。
|
|
366
|
+
*/
|
|
367
|
+
private stopHotkeyHook() {
|
|
368
|
+
if (!this.hotkeyProc) return;
|
|
369
|
+
try {
|
|
370
|
+
this.hotkeyProc.stdin.end();
|
|
371
|
+
this.hotkeyProc.kill("SIGTERM");
|
|
372
|
+
} catch {
|
|
373
|
+
// 忽略
|
|
374
|
+
}
|
|
375
|
+
this.hotkeyProc = null;
|
|
376
|
+
this.hotkeyReady = false;
|
|
377
|
+
this.hotkeyBuffer = "";
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* 触发一次 STT 录音。任务进行中先 abort 当前任务再开始录音。
|
|
382
|
+
* STT 关闭或无 API Key 时直接 return。
|
|
383
|
+
*/
|
|
384
|
+
private async triggerStt() {
|
|
385
|
+
if (!voice.isSttEnabled()) {
|
|
386
|
+
console.log(chalk.cyan(t("STT 已关闭(用 /stt 打开)。", "STT is off (use /stt to enable).")));
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (this.isProcessing) {
|
|
390
|
+
// 任务中按热键:先中断当前 Agent,再开始录音
|
|
391
|
+
this.activeSession.abort().catch(() => {});
|
|
392
|
+
this.isProcessing = false;
|
|
393
|
+
this.aborted = true; // 阻止 processInput finally 再次 prompt
|
|
394
|
+
this.ttsStream.cancel(); // 打断 TTS(STT 接管输入前清掉残余播放)
|
|
395
|
+
this.hidePetBubble(); // 打断时立即隐藏气泡
|
|
396
|
+
(this.rl as any).line = "";
|
|
397
|
+
(this.rl as any).cursor = 0;
|
|
398
|
+
process.stdout.write(chalk.yellow("\n[aborted by stt]\n"));
|
|
399
|
+
this.rl.prompt(true);
|
|
400
|
+
}
|
|
401
|
+
// 让 readline 先把当前提示符行清掉,再启动录音 UI
|
|
402
|
+
await new Promise((r) => setImmediate(r));
|
|
403
|
+
console.log(chalk.cyan(t("正在聆听... 请说话", "Listening... speak now")));
|
|
404
|
+
this.rl.prompt();
|
|
405
|
+
const text = await voice.listen();
|
|
406
|
+
if (text) {
|
|
407
|
+
console.log(chalk.green(t(`听到:${text}`, `Heard: ${text}`)));
|
|
408
|
+
await this.processInput(text);
|
|
409
|
+
} else {
|
|
410
|
+
console.log(chalk.cyan(t("未检测到语音。", "No speech detected.")));
|
|
411
|
+
this.rl.prompt();
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* 保存当前会话。
|
|
417
|
+
* - currentSessionPath 非 null(resume 的会话):必保存,覆盖原文件(不走 hasConversation 判断)
|
|
418
|
+
* - currentSessionPath 为 null(新会话):仅在 hasConversation 为 true 时另存为新文件
|
|
419
|
+
*/
|
|
420
|
+
private saveCurrentSessionIfNeeded() {
|
|
421
|
+
const messages = this.session.messages;
|
|
422
|
+
const model = this.session.model?.id || "unknown";
|
|
423
|
+
if (this.currentSessionPath) {
|
|
424
|
+
// resume 的会话:必保存(覆盖原文件),即使没有新增对话也保留原内容
|
|
425
|
+
memory.saveSessionToPath(this.currentSessionPath, messages, model);
|
|
426
|
+
} else if (memory.getHasConversation()) {
|
|
427
|
+
// 新会话:仅有有效对话时才保存
|
|
428
|
+
memory.saveSession(messages, model);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* 恢复指定会话。
|
|
434
|
+
* 1. 先保存当前会话(resume 的会话必保存覆盖原文件,新会话仅有有效对话时另存新文件)
|
|
435
|
+
* 2. 加载目标会话的消息到 session
|
|
436
|
+
* 3. 记录 currentSessionPath(退出时覆盖保存回原文件)
|
|
437
|
+
* 4. 清屏并渲染历史记录
|
|
438
|
+
*/
|
|
439
|
+
private resumeSession(path: string) {
|
|
440
|
+
// 1. 先保存当前会话(resume 的会话覆盖原文件,新会话仅有有效对话时另存新文件)
|
|
441
|
+
this.saveCurrentSessionIfNeeded();
|
|
442
|
+
|
|
443
|
+
// 2. 加载目标会话
|
|
444
|
+
try {
|
|
445
|
+
const messages = memory.loadSession(path);
|
|
446
|
+
this.session.agent.state.messages = messages;
|
|
447
|
+
memory.resetConversationFlag();
|
|
448
|
+
this.currentSessionPath = path; // 退出时覆盖保存回此文件
|
|
449
|
+
console.clear();
|
|
450
|
+
renderSavedMessages(messages);
|
|
451
|
+
} catch (err) {
|
|
452
|
+
console.error(chalk.red(`Failed to load session: ${err instanceof Error ? err.message : err}`));
|
|
453
|
+
// 加载失败,currentSessionPath 保持不变
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private parseInput(input: string): string {
|
|
458
|
+
let processed = input;
|
|
459
|
+
|
|
460
|
+
// Handle @file references
|
|
461
|
+
const fileRefs = processed.match(/@(\S+)/g);
|
|
462
|
+
if (fileRefs) {
|
|
463
|
+
for (const ref of fileRefs) {
|
|
464
|
+
const filePath = resolve(ref.slice(1));
|
|
465
|
+
if (existsSync(filePath)) {
|
|
466
|
+
const content = readFileSync(filePath, "utf-8");
|
|
467
|
+
processed = processed.split(ref).join(`\n--- ${ref.slice(1)} ---\n${content}\n--- end ---\n`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Handle !shell commands
|
|
473
|
+
if (processed.startsWith("!")) {
|
|
474
|
+
const cmd = processed.slice(1);
|
|
475
|
+
try {
|
|
476
|
+
const output = execSync(cmd, { encoding: "utf-8", maxBuffer: 1024 * 1024 * 10 });
|
|
477
|
+
processed = `Shell command: \`${cmd}\`\nOutput:\n\`\`\`\n${output}\n\`\`\``;
|
|
478
|
+
} catch (err) {
|
|
479
|
+
processed = `Shell command: \`${cmd}\`\nError: ${err instanceof Error ? err.message : err}`;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return processed;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async start() {
|
|
487
|
+
// --resume= 启动时先清屏,确保 logo 和历史记录在干净的画面上
|
|
488
|
+
if (this.resumedMessages) {
|
|
489
|
+
console.clear();
|
|
490
|
+
}
|
|
491
|
+
printLogo();
|
|
492
|
+
console.log(chalk.cyan(t(` 模型:${config.model}`, ` Model: ${config.model}`)));
|
|
493
|
+
if (config.noVoice) {
|
|
494
|
+
console.log(chalk.cyan(t(" 语音:已禁用(--no-voice)", " Voice: disabled (--no-voice)")));
|
|
495
|
+
} else {
|
|
496
|
+
// 当前主 Agent 无音色时强制显示"未配置声音"(权重大于 settings.ttsEnabled),
|
|
497
|
+
// TTS 在 isTtsEnabled() 里已被挡掉,不会启动
|
|
498
|
+
if (!voice.hasCurrentVoice()) {
|
|
499
|
+
console.log(chalk.cyan(t(" TTS:关(未配置声音)", " TTS: off (no voice configured)")));
|
|
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
|
+
}
|
|
512
|
+
}
|
|
513
|
+
console.log(chalk.cyan(t(" 输入 / 弹出命令菜单,/help 查看完整命令,/exit 退出。\n", " Type / to open the command menu, /help for all commands, /exit to quit.\n")));
|
|
514
|
+
|
|
515
|
+
// --resume= 启动时渲染恢复的历史记录(在 logo 之后,提示符之前)
|
|
516
|
+
if (this.resumedMessages) {
|
|
517
|
+
renderSavedMessages(this.resumedMessages);
|
|
518
|
+
this.resumedMessages = null; // 渲染完毕,释放引用
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Enable keypress events so we can intercept arrow/Tab/Esc while the user
|
|
522
|
+
// is typing at the prompt. The keypress listener is added/removed based
|
|
523
|
+
// on whether the slash menu is open.
|
|
524
|
+
readline.emitKeypressEvents(process.stdin, this.rl);
|
|
525
|
+
if (process.stdin.isTTY) {
|
|
526
|
+
(process.stdin as any).setRawMode?.(true);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// 解析热键已移除:STT 热键改为全局监听(pynput),见 startHotkeyHook()
|
|
530
|
+
|
|
531
|
+
this.rl.prompt();
|
|
532
|
+
|
|
533
|
+
// 按键监听:菜单打开时拦截导航键并转发给 SlashMenu;其余按键交给
|
|
534
|
+
// readline 先更新缓冲区,再在微任务里刷新菜单。
|
|
535
|
+
// STT 热键检测已移至全局 hook(pynput),此处仅处理斜杠命令菜单。
|
|
536
|
+
this.menuKeyListener = (_str: string, key: any) => {
|
|
537
|
+
if (!key) return;
|
|
538
|
+
if (this.menu.isOpen()) {
|
|
539
|
+
if (key.name === "escape") { this.menu.close(this.rl); return; }
|
|
540
|
+
if (key.name === "up" || key.name === "down") {
|
|
541
|
+
this.menu.move(key.name === "up" ? -1 : 1, this.rl);
|
|
542
|
+
return; // 不转发给 readline(避免移动输入光标)
|
|
543
|
+
}
|
|
544
|
+
// 行末再按 →:把选中指令填入输入框但不执行(用户可随后补参数/编辑,
|
|
545
|
+
// 再自己 Enter)。光标不在行末时落到 readline 正常移动光标。
|
|
546
|
+
if (key.name === "right") {
|
|
547
|
+
const line: string = (this.rl as any).line ?? "";
|
|
548
|
+
const cursor: number = (this.rl as any).cursor ?? 0;
|
|
549
|
+
if (cursor >= line.length) {
|
|
550
|
+
this.menu.complete(this.rl);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (key.name === "return") { this.menu.confirm(this.rl); return; } // 补全但不提交
|
|
555
|
+
if (key.name === "tab") { return; } // 菜单内 Tab 无操作
|
|
556
|
+
} else if (key.name === "escape" && this.isProcessing) {
|
|
557
|
+
// 菜单关闭且正在执行任务:Esc 中断当前任务(不退出 CLI)
|
|
558
|
+
this.activeSession.abort().catch(() => {});
|
|
559
|
+
this.isProcessing = false;
|
|
560
|
+
this.aborted = true; // 阻止 processInput finally 再次 prompt
|
|
561
|
+
this.ttsStream.cancel(); // 打断 TTS
|
|
562
|
+
this.hidePetBubble(); // 打断时立即隐藏气泡
|
|
563
|
+
// 清空 readline 缓冲区,避免残留内容在重绘时混入提示符行
|
|
564
|
+
(this.rl as any).line = "";
|
|
565
|
+
(this.rl as any).cursor = 0;
|
|
566
|
+
process.stdout.write(chalk.yellow("\n[aborted]\n"));
|
|
567
|
+
this.rl.prompt(true);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
// 其余按键:先让 readline 更新缓冲区,再在微任务里刷新菜单
|
|
571
|
+
queueMicrotask(() => {
|
|
572
|
+
// 若上述处理已把菜单关闭(Esc/Enter),跳过刷新
|
|
573
|
+
if (this.menu.isOpen() && (key.name === "return" || key.name === "tab" || key.name === "escape")) return;
|
|
574
|
+
this.menu.refresh(this.rl);
|
|
575
|
+
});
|
|
576
|
+
};
|
|
577
|
+
// 用 prependListener 让本监听器先于 readline 的 keypress 监听器执行。
|
|
578
|
+
// 这样 Enter 时我们先 confirm(设置 rl.line),readline 随后处理 return
|
|
579
|
+
// 并 emit "line" 事件,line handler 收到的就是补全后的命令名。
|
|
580
|
+
process.stdin.prependListener("keypress", this.menuKeyListener);
|
|
581
|
+
|
|
582
|
+
// The 'line' event also needs to close the menu (in case the user
|
|
583
|
+
// submitted text that no longer matches anything).
|
|
584
|
+
this.rl.on("line", async (input: string) => {
|
|
585
|
+
// 提交时关闭菜单(若仍打开)
|
|
586
|
+
if (this.menu.isOpen()) this.menu.close(this.rl);
|
|
587
|
+
|
|
588
|
+
// needsParams 指令:菜单 Enter 只填入不执行,
|
|
589
|
+
// 消费信号后仅重绘提示行,让用户补参数再回车。信号由同一按键同步置位、
|
|
590
|
+
// 紧随其后的 "line" 事件消费,无泄漏。
|
|
591
|
+
if (this.menu.consumeNoExecSignal()) {
|
|
592
|
+
// readline 处理 return 时已:①在 emit "line" 前清空 rl.line;②输出了
|
|
593
|
+
// \r\n 换行。故必须用 input(confirm 补全后的指令)恢复 line,且 prompt
|
|
594
|
+
// 带 preserveCursor=true(无参会把 cursor 归零、参数被插到行首),并在
|
|
595
|
+
// 重绘前 \x1b[1A 上移一行抵消那次换行——否则补全内容会错位到下一行。
|
|
596
|
+
(this.rl as any).line = input;
|
|
597
|
+
(this.rl as any).cursor = input.length;
|
|
598
|
+
process.stdout.write("\x1b[1A");
|
|
599
|
+
this.rl.prompt(true);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const trimmed = input.trim();
|
|
604
|
+
|
|
605
|
+
// Empty input — STT 改由热键触发,空 Enter 仅为刷新提示符
|
|
606
|
+
if (!trimmed) {
|
|
607
|
+
this.rl.prompt();
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Slash commands
|
|
612
|
+
if (trimmed.startsWith("/")) {
|
|
613
|
+
await handleCommand(trimmed, this.getCommandContext());
|
|
614
|
+
this.rl.prompt();
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
await this.processInput(trimmed);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
// After every input change, decide whether to show/hide the menu.
|
|
622
|
+
this.rl.on("close", () => {
|
|
623
|
+
this.doExit();
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
// STT 开启时启动全局热键监听(pynput,长按右 Cmd ≥2秒触发录音)
|
|
627
|
+
if (voice.isSttEnabled()) {
|
|
628
|
+
this.startHotkeyHook();
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
private async processInput(input: string) {
|
|
633
|
+
// 桌宠手势(摸头/dizzy)注入:若用户摸过头/拖住晃过,把"刚才发生了什么"作为
|
|
634
|
+
// 上下文行写在用户消息头部,引导 Agent 在回复中自然流露出确实感受到了。
|
|
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
|
+
}
|
|
643
|
+
// 展开 @文件 / !命令 后走完整回合生命周期
|
|
644
|
+
await this.runRawTurn(this.parseInput(effective));
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** 把 renderer 订阅切到指定角色 session,并记录当前发言者。 */
|
|
648
|
+
private setActiveAgent(agentId: AgentId, session: AgentSession): void {
|
|
649
|
+
this.activeAgentId = agentId;
|
|
650
|
+
this.activeSession = session;
|
|
651
|
+
this.renderer.setSpeakerLabel(getAgentLabel(agentId));
|
|
652
|
+
this.rendererUnsub?.();
|
|
653
|
+
this.rendererUnsub = this.renderer.subscribe(session);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** 按 settings.json 当前启用的子 Agent 初始化/补齐子 session。 */
|
|
657
|
+
private async ensureSubSessions(): Promise<void> {
|
|
658
|
+
const enabled = getSubAgents();
|
|
659
|
+
for (const id of enabled) {
|
|
660
|
+
if (this.subSessions.has(id)) continue;
|
|
661
|
+
try {
|
|
662
|
+
const { session } = await initSubAgent(id, this.modelRuntime);
|
|
663
|
+
this.subSessions.set(id, session);
|
|
664
|
+
} catch (err) {
|
|
665
|
+
console.error(chalk.red(t(
|
|
666
|
+
`初始化子 Agent ${getAgentLabel(id)} 失败:${err instanceof Error ? err.message : err},已跳过该角色。`,
|
|
667
|
+
`Failed to init sub-agent ${getAgentLabel(id)}: ${err instanceof Error ? err.message : err}; skipping it.`,
|
|
668
|
+
)));
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* 等待当前角色的 TTS 播放完毕(或禁用 TTS 时固定 5 秒)。
|
|
675
|
+
* 这是"主 Agent 回复完 → 子 Agent 轮询"的节奏控制点。
|
|
676
|
+
*/
|
|
677
|
+
private async waitTurnSettled(agentId: AgentId): Promise<void> {
|
|
678
|
+
if (!voice.isTtsEnabledFor(agentId)) {
|
|
679
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
// 先等最多 2s 让 play_start 到达(若文本全被长句过滤,不会播放)
|
|
683
|
+
const t0 = Date.now();
|
|
684
|
+
await new Promise<void>((resolve) => {
|
|
685
|
+
const iv = setInterval(() => {
|
|
686
|
+
if (this.ttsStream.isPending) {
|
|
687
|
+
clearInterval(iv);
|
|
688
|
+
resolve();
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
if (Date.now() - t0 > 2000) {
|
|
692
|
+
clearInterval(iv);
|
|
693
|
+
resolve();
|
|
694
|
+
}
|
|
695
|
+
}, 50);
|
|
696
|
+
});
|
|
697
|
+
if (this.ttsStream.isPending) {
|
|
698
|
+
await new Promise<void>((resolve) => {
|
|
699
|
+
const iv = setInterval(() => {
|
|
700
|
+
if (!this.ttsStream.isPending) {
|
|
701
|
+
clearInterval(iv);
|
|
702
|
+
clearTimeout(tout);
|
|
703
|
+
resolve();
|
|
704
|
+
}
|
|
705
|
+
}, 100);
|
|
706
|
+
// 30s 兜底:异常卡死时不阻塞子 Agent 轮询/回合收尾
|
|
707
|
+
const tout = setTimeout(() => {
|
|
708
|
+
clearInterval(iv);
|
|
709
|
+
resolve();
|
|
710
|
+
}, 30000);
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** 从 session 新增消息中提取本轮 assistant 纯文本(用于 TTS / 回填主 session)。 */
|
|
716
|
+
private extractNewAssistantText(stateMessages: any[], startLen: number): string {
|
|
717
|
+
const texts: string[] = [];
|
|
718
|
+
for (const m of stateMessages.slice(startLen)) {
|
|
719
|
+
if (m.role !== "assistant" || !Array.isArray(m.content)) continue;
|
|
720
|
+
const text = m.content
|
|
721
|
+
.filter((b: any) => b.type === "text")
|
|
722
|
+
.map((b: any) => b.text || "")
|
|
723
|
+
.join("");
|
|
724
|
+
if (text.trim()) texts.push(text);
|
|
725
|
+
}
|
|
726
|
+
return texts.join("\n").trim();
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* 运行单个 Agent 的一轮(主或子)。
|
|
731
|
+
* - 切 renderer/TTS 到该角色;
|
|
732
|
+
* - 主 Agent:直接 prompt 用户输入(全量群聊历史已在 session 里);
|
|
733
|
+
* - 子 Agent:每轮把主 session 的全量群聊日志复制为自己的上下文(与主 Agent 看到的完全一致,
|
|
734
|
+
* 用户输入/主回复/其他子回复都在内,无需拼接注入),只追加一条固定"轮到你了"触发消息;
|
|
735
|
+
* - 提取回复:主 Agent 标记 speaker(历史回放 + 发送边界标注发言者);
|
|
736
|
+
* 子 Agent 回填主 session(speaker 标记),形成共享群聊历史。
|
|
737
|
+
* 返回该轮文本(可能为空,例如 keep_silent)。
|
|
738
|
+
*/
|
|
739
|
+
private async runOneAgent(
|
|
740
|
+
session: AgentSession,
|
|
741
|
+
agentId: AgentId,
|
|
742
|
+
input: string,
|
|
743
|
+
isSub: boolean,
|
|
744
|
+
): Promise<string> {
|
|
745
|
+
this.setActiveAgent(agentId, session);
|
|
746
|
+
// 段边界切音色:即使目标角色无音色也要更新 currentAgent,保证 isTtsEnabledFor 判断正确
|
|
747
|
+
this.ttsStream.setVoice(agentId);
|
|
748
|
+
|
|
749
|
+
if (isSub) {
|
|
750
|
+
// 子 Agent:复制主 session 全量群聊日志作为上下文(浅拷贝,元素引用共享)。
|
|
751
|
+
// 主/子聊天记录完全一致 → 各 Agent 前缀稳定,各自命中各自缓存(system prompt 人设不同 → 不同缓存条目)。
|
|
752
|
+
session.agent.state.messages = [...(this.session.agent.state.messages as any[])];
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const stateMessages = session.agent.state.messages as any[];
|
|
756
|
+
const startLen = stateMessages.length;
|
|
757
|
+
|
|
758
|
+
// 子 Agent 的触发消息:固定短句(上下文在复制来的全量日志里,这里只负责"叫醒"它发言)
|
|
759
|
+
const promptText = isSub
|
|
760
|
+
? t("(现在轮到你,请简短发言)", "(It's your turn now, speak briefly)")
|
|
761
|
+
: input;
|
|
762
|
+
|
|
763
|
+
try {
|
|
764
|
+
await session.prompt(promptText);
|
|
765
|
+
} catch (err) {
|
|
766
|
+
console.error(chalk.red(t("\n错误:", "\nError: ") + (err instanceof Error ? err.message : err)));
|
|
767
|
+
return "";
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const text = this.extractNewAssistantText(stateMessages, startLen);
|
|
771
|
+
if (!text) return "";
|
|
772
|
+
|
|
773
|
+
if (!isSub) {
|
|
774
|
+
// 主 Agent 的 assistant 消息补 speaker 标记(历史回放显示角色名 + speaker_context 扩展标注发言者)
|
|
775
|
+
for (const m of stateMessages.slice(startLen)) {
|
|
776
|
+
if (m.role === "assistant") m.speaker = agentId;
|
|
777
|
+
}
|
|
778
|
+
} else {
|
|
779
|
+
// 子 Agent 回复回填主 session,形成共享群聊历史(主 Agent 及其他子 Agent 下轮可见)
|
|
780
|
+
(this.session.agent.state.messages as any[]).push({
|
|
781
|
+
role: "assistant",
|
|
782
|
+
speaker: agentId,
|
|
783
|
+
content: [{ type: "text", text }],
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
return text;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* 走完整多角色回合生命周期(markConversation / undo checkpoint / isProcessing /
|
|
791
|
+
* 主 Agent → settle → 子 Agent 逐个 settle / pet.reset / abort)。
|
|
792
|
+
* 不展开 @文件 / !命令;供普通输入与 /skill 共用。
|
|
793
|
+
*/
|
|
794
|
+
private async runRawTurn(input: string) {
|
|
795
|
+
memory.markConversation();
|
|
796
|
+
this.isProcessing = true;
|
|
797
|
+
this.turnEnded = false;
|
|
798
|
+
// 回合开始前打断上一回合残余 TTS 播放(新输入立即接管)
|
|
799
|
+
this.ttsStream.cancel();
|
|
800
|
+
this.hidePetBubble();
|
|
801
|
+
// 回合开始前打 checkpoint:扫一次当前工作目录,作为 before 快照
|
|
802
|
+
// (回合结束后 afterTurn 会与它做 diff 入 undo 栈)
|
|
803
|
+
this.undoManager.beforeTurn();
|
|
804
|
+
// 回合开始:不主动切换情绪,保持默认待机动画;
|
|
805
|
+
// 由 agent 调用 change_emotion 一步到位地确定本回合情绪,避免先 saying 再切换的跳变
|
|
806
|
+
|
|
807
|
+
try {
|
|
808
|
+
await this.ensureSubSessions();
|
|
809
|
+
|
|
810
|
+
// 0. 记忆增量检测:MEMORY.md 运行时变更 → 追加到下一轮主 Agent 的 user 消息末尾
|
|
811
|
+
// (子 Agent 复制主 session 全量后自动继承,无需单独注入)
|
|
812
|
+
const memoryDelta = memory.getMemoryDelta();
|
|
813
|
+
const mainInput = memoryDelta ? `${input}\n\n${memoryDelta}` : input;
|
|
814
|
+
|
|
815
|
+
// 1. 主 Agent 回复
|
|
816
|
+
await this.runOneAgent(this.session, getMainAgent(), mainInput, false);
|
|
817
|
+
if (this.aborted) return;
|
|
818
|
+
await this.waitTurnSettled(getMainAgent());
|
|
819
|
+
if (this.aborted) return;
|
|
820
|
+
|
|
821
|
+
// 2. 子 Agent 逐个轮询回复(每个子 Agent 回复后等待其 TTS 播完/静音 5s)
|
|
822
|
+
for (const subId of getSubAgents()) {
|
|
823
|
+
const subSession = this.subSessions.get(subId);
|
|
824
|
+
if (!subSession) continue;
|
|
825
|
+
await this.runOneAgent(subSession, subId, input, true);
|
|
826
|
+
if (this.aborted) return;
|
|
827
|
+
await this.waitTurnSettled(subId);
|
|
828
|
+
if (this.aborted) return;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// 3. 回合结束:renderer 订阅切回主 session,方便下一轮 prompt 展示
|
|
832
|
+
this.setActiveAgent(getMainAgent(), this.session);
|
|
833
|
+
} catch (err) {
|
|
834
|
+
console.error(chalk.red(t("\n错误:", "\nError: ") + (err instanceof Error ? err.message : err)));
|
|
835
|
+
} finally {
|
|
836
|
+
// 回合结束后打 after checkpoint,产出 diff 入 undo 栈
|
|
837
|
+
try {
|
|
838
|
+
this.undoManager.afterTurn();
|
|
839
|
+
} catch (err) {
|
|
840
|
+
// undo checkpoint 失败不影响主流程
|
|
841
|
+
console.warn(chalk.yellow(t(`撤销快照记录失败:${err instanceof Error ? err.message : err}`, `Failed to record undo snapshot: ${err instanceof Error ? err.message : err}`)));
|
|
842
|
+
}
|
|
843
|
+
this.isProcessing = false;
|
|
844
|
+
// 回合结束:恢复桌宠到 idle
|
|
845
|
+
// - TTS 启用 + 无残余播放 → 立即 reset;有残余由 onIdle(play_end) 兜底
|
|
846
|
+
// - TTS 禁用(--no-voice / 缺音色 / 切到无音色角色)→ 5s 后再撤表情和气泡
|
|
847
|
+
this.turnEnded = true;
|
|
848
|
+
if (voice.isTtsEnabledFor(this.activeAgentId)) {
|
|
849
|
+
if (!this.ttsStream.isPending) pet.reset();
|
|
850
|
+
} else {
|
|
851
|
+
this.scheduleBubbleHide();
|
|
852
|
+
}
|
|
853
|
+
// 若被 Esc/Ctrl+C 中断,中断处理已重绘提示符,跳过重复 prompt
|
|
854
|
+
if (this.aborted) {
|
|
855
|
+
this.aborted = false;
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
console.log(); // Extra newline after response
|
|
859
|
+
this.rl.prompt();
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
private async doExit() {
|
|
864
|
+
// 保存当前会话(resume 的会话覆盖原文件,新会话另存为新文件)
|
|
865
|
+
this.saveCurrentSessionIfNeeded();
|
|
866
|
+
|
|
867
|
+
// Cleanup keypress listener so the process can actually exit
|
|
868
|
+
if (this.menuKeyListener) {
|
|
869
|
+
process.stdin.removeListener("keypress", this.menuKeyListener);
|
|
870
|
+
this.menuKeyListener = null;
|
|
871
|
+
}
|
|
872
|
+
if (process.stdin.isTTY) {
|
|
873
|
+
(process.stdin as any).setRawMode?.(false);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// 停止全局热键监听(pynput)
|
|
877
|
+
this.stopHotkeyHook();
|
|
878
|
+
|
|
879
|
+
// 停止 TTS:终止当前合成进程并清空队列
|
|
880
|
+
this.ttsStream.shutdown();
|
|
881
|
+
|
|
882
|
+
// Cleanup
|
|
883
|
+
stopComputerUse();
|
|
884
|
+
try {
|
|
885
|
+
await disconnectAllMcp();
|
|
886
|
+
} catch {
|
|
887
|
+
// 忽略清理失败,继续退出
|
|
888
|
+
}
|
|
889
|
+
stopPet();
|
|
890
|
+
this.session.dispose();
|
|
891
|
+
for (const subSession of this.subSessions.values()) {
|
|
892
|
+
try {
|
|
893
|
+
subSession.dispose();
|
|
894
|
+
} catch {
|
|
895
|
+
// 子 session 清理失败不影响退出
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
this.onExit();
|
|
900
|
+
process.exit(0);
|
|
901
|
+
}
|
|
902
|
+
}
|