arona-agent 1.2.1 → 1.2.3
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/gui/main.cjs +68 -11
- package/gui/renderer/app.js +289 -39
- package/gui/renderer/index.html +6 -0
- package/gui/renderer/style.css +144 -2
- package/package.json +1 -1
- package/pet/agents.cjs +9 -4
- package/pet/main.cjs +41 -2
- package/pet/renderer/spinetest.js +3 -3
- package/pet/tools/gallery_capture.cjs +1 -1
- package/pet/tools/mouth_capture.cjs +1 -1
- package/pet/tools/visual_test.cjs +1 -1
- package/python/__pycache__/stt.cpython-314.pyc +0 -0
- package/python/__pycache__/tts_say.cpython-314.pyc +0 -0
- package/python/stt.py +24 -3
- package/src/agent.ts +11 -10
- package/src/agent_registry.ts +30 -0
- package/src/coding_agent.ts +6 -5
- package/src/commands.ts +56 -17
- package/src/config.ts +22 -0
- package/src/gui/controller.ts +385 -159
- package/src/gui/index.ts +57 -11
- package/src/gui/protocol.ts +11 -2
- package/src/gui/setup_backend.ts +15 -7
- package/src/index.ts +25 -5
- package/src/memory.ts +105 -12
- package/src/pet.ts +60 -1
- package/src/renderer.ts +27 -3
- package/src/repl.ts +24 -6
- package/src/setup.ts +14 -6
- package/src/speaker_context.ts +67 -1
- package/src/utils/python.ts +11 -6
- package/src/voice.ts +3 -2
- package/src/voices.ts +5 -1
- package/src/workspace.ts +163 -0
package/src/gui/index.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// 启动条件:裸 `arona`(默认入口,src/index.ts 分流到本文件;--cli / settings.json CLIEnabled: true 时走命令行)。
|
|
3
3
|
import { spawn, type ChildProcessWithoutNullStreams } from "child_process";
|
|
4
4
|
import { join } from "path";
|
|
5
|
+
import { existsSync } from "fs";
|
|
5
6
|
import chalk from "chalk";
|
|
6
7
|
import { PROJECT_ROOT, settingsExist, reloadConfig, config, verbose } from "../config.ts";
|
|
7
8
|
import { t, refreshLanguage } from "../locale.ts";
|
|
@@ -21,11 +22,15 @@ class GuiBridge {
|
|
|
21
22
|
private controller: GuiController | null = null;
|
|
22
23
|
private startingMain = false;
|
|
23
24
|
private exited = false;
|
|
24
|
-
// Windows 下 spawn 后立刻写 stdin
|
|
25
|
-
|
|
26
|
-
//
|
|
25
|
+
// Windows 下 spawn 后立刻写 stdin 会丢数据,且实测 GUI 子系统 Electron 的 stdin **完全不可达**
|
|
26
|
+
//(hello 握手证明进程与 stdout 均正常,数据仍不到达)——GUI 进程起 127.0.0.1 随机端口 + 随机 token
|
|
27
|
+
// 的 HTTP 服务,端口/token 随 hello 行告知;hello 前入队,收到后按序经 HTTP 下发。
|
|
28
|
+
// httpPort=0(HTTP 起失败)时退回 stdin 写入(非 Windows 平台 stdin 本来就通)。
|
|
27
29
|
private helloReceived = false;
|
|
28
30
|
private queuedEvents: GuiEvent[] = [];
|
|
31
|
+
private guiHttpPort = 0;
|
|
32
|
+
private guiHttpToken = "";
|
|
33
|
+
private httpChain: Promise<void> = Promise.resolve(); // 串行链保序(HTTP 异步,并发会乱序)
|
|
29
34
|
|
|
30
35
|
emit(ev: GuiEvent): void {
|
|
31
36
|
if (verbose) console.error(chalk.gray("[gui:verbose]"), "emit", ev.type, this.helloReceived ? "" : "(queued)");
|
|
@@ -34,6 +39,10 @@ class GuiBridge {
|
|
|
34
39
|
return;
|
|
35
40
|
}
|
|
36
41
|
if (!this.proc || this.proc.killed) return;
|
|
42
|
+
if (this.guiHttpPort) {
|
|
43
|
+
this.httpChain = this.httpChain.then(() => this.postEvent(ev));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
37
46
|
try {
|
|
38
47
|
this.proc.stdin.write(formatGuiLine(ev));
|
|
39
48
|
} catch {
|
|
@@ -41,6 +50,21 @@ class GuiBridge {
|
|
|
41
50
|
}
|
|
42
51
|
}
|
|
43
52
|
|
|
53
|
+
/** 经本地 HTTP 通道下发事件(gui/main.cjs 的 127.0.0.1 随机端口服务,token 鉴权) */
|
|
54
|
+
private async postEvent(ev: GuiEvent): Promise<void> {
|
|
55
|
+
try {
|
|
56
|
+
await fetch(`http://127.0.0.1:${this.guiHttpPort}/`, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers: { "content-type": "application/json", "x-arona-token": this.guiHttpToken },
|
|
59
|
+
body: JSON.stringify(ev),
|
|
60
|
+
});
|
|
61
|
+
} catch (err) {
|
|
62
|
+
if (verbose) {
|
|
63
|
+
console.error(chalk.gray("[gui:verbose]"), "http emit failed:", err instanceof Error ? err.message : err);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
44
68
|
async start(): Promise<void> {
|
|
45
69
|
const electronPath = await getElectronPath();
|
|
46
70
|
if (!electronPath) {
|
|
@@ -136,6 +160,20 @@ class GuiBridge {
|
|
|
136
160
|
syncSkillsFromAgentsDir();
|
|
137
161
|
}
|
|
138
162
|
|
|
163
|
+
// 旧会话工作区一次性回填(按内容推断;已在启动初期完成,会话列表/侧栏分组才准确)
|
|
164
|
+
const { backfillLegacyWorkspaces } = await import("../memory.ts");
|
|
165
|
+
const migrated = backfillLegacyWorkspaces();
|
|
166
|
+
if (migrated > 0) {
|
|
167
|
+
console.error(chalk.gray(`[ws] 已将 ${migrated} 个历史会话按内容归入工作区`));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 恢复/设定活动工作区(须在 initAgent 前,SDK cwd 跟随)。GUI 的进程启动目录
|
|
171
|
+
// 对用户无意义,不作为工作区:有上次选择用之,否则默认家目录。
|
|
172
|
+
const { getLastWorkspace } = await import("../config.ts");
|
|
173
|
+
const { setActiveWorkspace, guiDefaultWorkspace } = await import("../workspace.ts");
|
|
174
|
+
const last = getLastWorkspace();
|
|
175
|
+
setActiveWorkspace(last && existsSync(last) ? last : guiDefaultWorkspace());
|
|
176
|
+
|
|
139
177
|
let current = await initAgent();
|
|
140
178
|
await startPet();
|
|
141
179
|
preloadGptSovitsLocal();
|
|
@@ -149,12 +187,11 @@ class GuiBridge {
|
|
|
149
187
|
this.exited = true;
|
|
150
188
|
process.exit(0);
|
|
151
189
|
},
|
|
190
|
+
// 只负责创建新会话(cwd 跟随当前活动工作区);旧会话的生命周期由 controller
|
|
191
|
+
// 槽位管理:生成中挂后台继续、空闲存盘释放,这里不得 dispose。
|
|
152
192
|
async () => {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
resetConversationFlag();
|
|
156
|
-
current = await initAgent();
|
|
157
|
-
return current;
|
|
193
|
+
const { initAgent } = await import("../agent.ts");
|
|
194
|
+
return await initAgent();
|
|
158
195
|
},
|
|
159
196
|
);
|
|
160
197
|
|
|
@@ -187,13 +224,15 @@ class GuiBridge {
|
|
|
187
224
|
private async handleRequest(req: GuiRequest): Promise<void> {
|
|
188
225
|
switch (req.type) {
|
|
189
226
|
case "hello": {
|
|
190
|
-
// GUI
|
|
191
|
-
//
|
|
227
|
+
// GUI 进程握手:解锁排队中的事件(emit mode/setup_info 在 spawn 后立即调用,Windows 下彼时
|
|
228
|
+
// stdin 写入会丢失)。端口/token 就位后,后续事件经本地 HTTP 通道下发。
|
|
192
229
|
this.helloReceived = true;
|
|
230
|
+
this.guiHttpPort = Number(req.httpPort) || 0;
|
|
231
|
+
this.guiHttpToken = String(req.token || "");
|
|
193
232
|
const queued = this.queuedEvents;
|
|
194
233
|
this.queuedEvents = [];
|
|
195
234
|
if (verbose && queued.length) {
|
|
196
|
-
console.error(chalk.gray("[gui:verbose]"), "hello received, flushing", queued.length, "queued events");
|
|
235
|
+
console.error(chalk.gray("[gui:verbose]"), "hello received (httpPort=" + this.guiHttpPort + "), flushing", queued.length, "queued events");
|
|
197
236
|
}
|
|
198
237
|
for (const ev of queued) this.emit(ev);
|
|
199
238
|
break;
|
|
@@ -244,6 +283,13 @@ class GuiBridge {
|
|
|
244
283
|
case "rename_session":
|
|
245
284
|
this.controller?.renameSessionByPath(req.path, req.title);
|
|
246
285
|
break;
|
|
286
|
+
case "set_workspace":
|
|
287
|
+
await this.controller?.setWorkspace(req.path);
|
|
288
|
+
break;
|
|
289
|
+
case "move_session":
|
|
290
|
+
this.controller?.moveSessionByPath(req.path, req.workspace);
|
|
291
|
+
break;
|
|
292
|
+
// pick_workspace_folder 在 gui/main.cjs 进程内拦截弹原生目录框,不经此处
|
|
247
293
|
case "invoke_skill":
|
|
248
294
|
await this.controller?.handleCommand(`/skill ${req.name}`);
|
|
249
295
|
break;
|
package/src/gui/protocol.ts
CHANGED
|
@@ -15,8 +15,10 @@ export type GuiEvent =
|
|
|
15
15
|
| { type: "agent_event"; agentId: string; event: Record<string, unknown> }
|
|
16
16
|
// 命令/操作反馈文本
|
|
17
17
|
| { type: "notice"; level: "info" | "warn" | "error" | "success"; text: string }
|
|
18
|
-
// 会话列表(侧栏数据源;currentPath
|
|
19
|
-
|
|
18
|
+
// 会话列表(侧栏数据源;currentPath=当前恢复会话,用于高亮;currentWorkspace=当前活动工作区;
|
|
19
|
+
// homeDir=用户家目录(前端把家目录工作区显示为「用户目录」);knownWorkspaces=已知工作区
|
|
20
|
+
// (settings 选择历史 ∪ 会话推导,去重);workspace=会话所属工作区,旧会话可能缺失,展示层归入「未分组」)
|
|
21
|
+
| { type: "sessions"; currentPath: string | null; currentWorkspace: string; homeDir: string; knownWorkspaces: string[]; sessions: Array<{ path: string; preview: string; timestamp: string; model: string; workspace?: string }> }
|
|
20
22
|
// 技能列表(/skill 弹窗数据)
|
|
21
23
|
| { type: "skills"; skills: Array<{ name: string; description: string }> }
|
|
22
24
|
// STT 结果(空串=未识别到语音)与录音状态
|
|
@@ -64,6 +66,8 @@ export interface GuiState {
|
|
|
64
66
|
// gui → backend
|
|
65
67
|
// ============================================================
|
|
66
68
|
export type GuiRequest =
|
|
69
|
+
// GUI 进程握手(gui/main.cjs 起好 HTTP 通道后回报;httpPort=0 表示 HTTP 不可用,backend 退回 stdin)
|
|
70
|
+
| { type: "hello"; httpPort: number; token: string }
|
|
67
71
|
// 用户消息(@file/!shell 由 backend 展开)
|
|
68
72
|
| { type: "input"; text: string }
|
|
69
73
|
// 斜杠命令(name 不含 "/")
|
|
@@ -83,6 +87,11 @@ export type GuiRequest =
|
|
|
83
87
|
// 侧栏会话管理(右键菜单)
|
|
84
88
|
| { type: "delete_session"; path: string }
|
|
85
89
|
| { type: "rename_session"; path: string; title: string }
|
|
90
|
+
// 工作区:切换活动工作区(重建 Agent 会话)/ 把会话移动到某工作区
|
|
91
|
+
// (pick_workspace_folder 由 gui/main.cjs 拦截弹原生目录对话框,经渲染层回传 set_workspace)
|
|
92
|
+
| { type: "set_workspace"; path: string }
|
|
93
|
+
| { type: "move_session"; path: string; workspace: string }
|
|
94
|
+
| { type: "pick_workspace_folder" }
|
|
86
95
|
// 调用技能
|
|
87
96
|
| { type: "invoke_skill"; name: string }
|
|
88
97
|
// 切换角色
|
package/src/gui/setup_backend.ts
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
// 图形化 setup 后端:表单数据 → Python 检查 / pip 依赖 / 音色克隆 / 写盘。
|
|
2
2
|
// 步骤与字段对齐 CLI src/setup.ts(该文件模块加载即跑 CLI 向导,不能 import,独立实现)。
|
|
3
|
-
import {
|
|
3
|
+
import { execFileSync } from "child_process";
|
|
4
4
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
5
5
|
import { join } from "path";
|
|
6
6
|
import { ARONA_DIR, SETTINGS_FILE, PROJECT_ROOT, resolveModelPrefix } from "../config.ts";
|
|
7
7
|
import { VOICE_AGENT_IDS } from "../agent_registry.ts";
|
|
8
8
|
import { VOICE_AUDIO, cloneVoice, setVoiceId, setGptSovitsVoice } from "../voices.ts";
|
|
9
|
-
import { normalizeGptSovitsConfig } from "../tts_provider.ts";
|
|
9
|
+
import { normalizeGptSovitsConfig, type GptSovitsModelVersion } from "../tts_provider.ts";
|
|
10
10
|
import { installGptSovitsDeps } from "../gpt_sovits_local.ts";
|
|
11
11
|
import { t } from "../locale.ts";
|
|
12
|
+
import { spawnCompat } from "../utils/spawn.ts";
|
|
12
13
|
import type { GuiEvent } from "./protocol.ts";
|
|
13
14
|
|
|
15
|
+
/** 前端表单的 modelVersion 字符串 → 合法枚举(未知值回退 v2,与 normalizeGptSovitsConfig 一致)。 */
|
|
16
|
+
function normalizeModelVersion(raw: string | undefined): GptSovitsModelVersion {
|
|
17
|
+
return raw === "v2Pro" || raw === "v3" || raw === "v4" || raw === "v2" ? raw : "v2";
|
|
18
|
+
}
|
|
19
|
+
|
|
14
20
|
export interface GuiSetupForm {
|
|
15
21
|
language: "auto" | "zh" | "en";
|
|
16
22
|
apiBaseUrl: string;
|
|
@@ -74,7 +80,8 @@ function loadExistingSettings(): ExistingSettings {
|
|
|
74
80
|
/** Python 须为 3.12/3.13(与 CLI setup.checkPythonVersion 一致)。 */
|
|
75
81
|
function checkPythonVersion(pythonPath: string): { ok: boolean; version: string } {
|
|
76
82
|
try {
|
|
77
|
-
|
|
83
|
+
// execFileSync(不经 shell):兼容含空格的 Python 路径(Windows C:\Program Files\...)
|
|
84
|
+
const output = execFileSync(pythonPath, ["--version"], { stdio: "pipe", encoding: "utf-8" }).trim();
|
|
78
85
|
const match = output.match(/Python\s+(\d+)\.(\d+)\.(\d+)/);
|
|
79
86
|
if (!match) return { ok: false, version: output || "unknown" };
|
|
80
87
|
const major = parseInt(match[1]);
|
|
@@ -90,7 +97,8 @@ function checkPythonVersion(pythonPath: string): { ok: boolean; version: string
|
|
|
90
97
|
/** 流式执行命令:逐行转发输出,resolve 退出码。 */
|
|
91
98
|
function streamCommand(cmd: string, args: string[], emit: Emit, step: string): Promise<number> {
|
|
92
99
|
return new Promise((resolve) => {
|
|
93
|
-
|
|
100
|
+
// spawnCompat:Windows 上命令解析为 .bat/.cmd 时自动补 shell(否则 spawn 直接 EINVAL)
|
|
101
|
+
const child = spawnCompat(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
94
102
|
const pump = (data: Buffer) => {
|
|
95
103
|
for (const line of data.toString().split(/[\r\n]+/)) {
|
|
96
104
|
if (line.trim()) emit({ type: "setup_log", step, line });
|
|
@@ -144,7 +152,7 @@ export async function runGuiSetup(form: GuiSetupForm, emit: Emit): Promise<boole
|
|
|
144
152
|
// ---- 音色配置 / 克隆 ----
|
|
145
153
|
const ttsProvider = form.ttsProvider;
|
|
146
154
|
let gptSovitsConfig = normalizeGptSovitsConfig((existing.ttsConfig as Record<string, unknown>)?.["gpt-sovits"]);
|
|
147
|
-
delete
|
|
155
|
+
delete gptSovitsConfig.voices;
|
|
148
156
|
|
|
149
157
|
if (ttsProvider === "gpt-sovits") {
|
|
150
158
|
const gs = form.gptSovits;
|
|
@@ -159,7 +167,7 @@ export async function runGuiSetup(form: GuiSetupForm, emit: Emit): Promise<boole
|
|
|
159
167
|
cnhubertPath: gs.mode === "local" ? (gs.cnhubertPath || "") : "",
|
|
160
168
|
baseUrl: gs.baseUrl || gptSovitsConfig.baseUrl,
|
|
161
169
|
textLang: gs.textLang || gptSovitsConfig.textLang,
|
|
162
|
-
modelVersion: gs.mode === "cloud" ? "v2" : (gs.modelVersion
|
|
170
|
+
modelVersion: gs.mode === "cloud" ? "v2" : normalizeModelVersion(gs.modelVersion),
|
|
163
171
|
};
|
|
164
172
|
|
|
165
173
|
// 本地部署:可选依赖安装(api_v2.py 同目录 requirements 优先)
|
|
@@ -192,7 +200,7 @@ export async function runGuiSetup(form: GuiSetupForm, emit: Emit): Promise<boole
|
|
|
192
200
|
} else if (depsOk) {
|
|
193
201
|
let dashscopeOk = false;
|
|
194
202
|
try {
|
|
195
|
-
|
|
203
|
+
execFileSync(pythonPath, ["-c", "import dashscope"], { stdio: "pipe" });
|
|
196
204
|
dashscopeOk = true;
|
|
197
205
|
} catch {
|
|
198
206
|
emit({ type: "setup_log", step: "clone", line: t("dashscope 包不可用,跳过音色克隆。", "dashscope package unavailable, skipping voice cloning.") });
|
package/src/index.ts
CHANGED
|
@@ -16,17 +16,20 @@ import chalk from "chalk";
|
|
|
16
16
|
* 独立进程写 settings.json,主进程随后 reloadConfig 即可(ESM 缓存无法重建单例)。
|
|
17
17
|
*/
|
|
18
18
|
function runSetupWizard(): Promise<number> {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
// 与 bin/arona.mjs 同款:用当前 node 直跑内置 tsx CLI。不依赖 node_modules/.bin shim
|
|
20
|
+
//(Windows 全局包装不出 .cmd,且 shell:true 的字符串拼接在含空格路径下易错)。
|
|
21
|
+
const tsxCli = join(PROJECT_ROOT, "node_modules", "tsx", "dist", "cli.mjs");
|
|
22
22
|
const wizardArgs = process.argv.slice(2).filter((a) => a !== "--cli");
|
|
23
23
|
return new Promise((resolve) => {
|
|
24
|
-
const child = spawn(
|
|
24
|
+
const child = spawn(process.execPath, [tsxCli, join(PROJECT_ROOT, "src", "setup.ts"), ...wizardArgs], {
|
|
25
25
|
cwd: process.cwd(),
|
|
26
26
|
stdio: "inherit",
|
|
27
|
-
shell: process.platform === "win32",
|
|
28
27
|
env: { ...process.env, ARONA_AUTO_SETUP: "1" },
|
|
29
28
|
});
|
|
29
|
+
child.on("error", (err) => {
|
|
30
|
+
console.error(chalk.red(t(`无法启动初始化向导:${err.message}`, `Failed to launch setup wizard: ${err.message}`)));
|
|
31
|
+
resolve(1);
|
|
32
|
+
});
|
|
30
33
|
child.on("exit", (code) => resolve(code ?? 0));
|
|
31
34
|
});
|
|
32
35
|
}
|
|
@@ -66,6 +69,13 @@ async function runCli() {
|
|
|
66
69
|
}
|
|
67
70
|
}
|
|
68
71
|
|
|
72
|
+
// 旧会话工作区一次性回填(按内容推断;/resume 分组展示前完成)
|
|
73
|
+
const { backfillLegacyWorkspaces } = await import("./memory.ts");
|
|
74
|
+
const migrated = backfillLegacyWorkspaces();
|
|
75
|
+
if (migrated > 0) {
|
|
76
|
+
console.log(chalk.cyan(t(`已将 ${migrated} 个历史会话按内容归入工作区`, `Assigned ${migrated} legacy session(s) to their workspaces`)));
|
|
77
|
+
}
|
|
78
|
+
|
|
69
79
|
let { session, modelRuntime, loader } = await initAgent();
|
|
70
80
|
|
|
71
81
|
// 首次启动需下载 Electron,须等下载完成再进入 REPL
|
|
@@ -114,6 +124,16 @@ async function runCli() {
|
|
|
114
124
|
}
|
|
115
125
|
|
|
116
126
|
async function main() {
|
|
127
|
+
// Headless Linux(无显示服务器):GUI 窗口/桌宠都无法启动,直接回退命令行(与 pet.ts 同款守卫)。
|
|
128
|
+
const headless = process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY;
|
|
129
|
+
if (headless) {
|
|
130
|
+
console.warn(chalk.yellow(t(
|
|
131
|
+
"未检测到显示服务器,进入命令行模式。",
|
|
132
|
+
"No display server detected; falling back to CLI mode.",
|
|
133
|
+
)));
|
|
134
|
+
await runCli();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
117
137
|
// 默认启动 GUI;--cli / settings.json CLIEnabled: true 时进入命令行。
|
|
118
138
|
// --resume= 恢复会话历史仅在终端 REPL 里展示,也一并路由到 CLI 以保持原行为。
|
|
119
139
|
if (process.argv.includes("--cli") || config.cliEnabled || process.argv.some((a) => a.startsWith("--resume="))) {
|
package/src/memory.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync, rmSync, appendFileSync } from "fs";
|
|
2
|
-
import { join } from "path";
|
|
2
|
+
import { basename, join, resolve } from "path";
|
|
3
3
|
import { MEMORY_FILE, SESSIONS_DIR } from "./config.ts";
|
|
4
|
+
import { inferWorkspaceFromContent } from "./workspace.ts";
|
|
4
5
|
import { t, getLang } from "./locale.ts";
|
|
5
6
|
|
|
6
7
|
// ============================================================
|
|
@@ -161,6 +162,8 @@ export interface SessionInfo {
|
|
|
161
162
|
timestamp: string;
|
|
162
163
|
preview: string;
|
|
163
164
|
model: string;
|
|
165
|
+
/** 所属工作区(创建会话时的启动目录绝对路径);旧会话可能缺失(undefined → 展示层归入「未分组」)。 */
|
|
166
|
+
workspace?: string;
|
|
164
167
|
}
|
|
165
168
|
|
|
166
169
|
interface SessionHeader {
|
|
@@ -169,6 +172,8 @@ interface SessionHeader {
|
|
|
169
172
|
timestamp: string;
|
|
170
173
|
model: string;
|
|
171
174
|
preview: string;
|
|
175
|
+
/** 所属工作区(启动目录绝对路径)。version 2 起写入;读取不校验版本,缺失视为「未分组」。 */
|
|
176
|
+
workspace?: string;
|
|
172
177
|
}
|
|
173
178
|
|
|
174
179
|
/** 从首条 user 消息提取会话预览。 */
|
|
@@ -189,7 +194,7 @@ function firstUserPreview(messages: any[]): string {
|
|
|
189
194
|
return preview;
|
|
190
195
|
}
|
|
191
196
|
|
|
192
|
-
export function saveSession(messages: any[], model: string, silent = false): string | null {
|
|
197
|
+
export function saveSession(messages: any[], model: string, silent = false, workspace?: string): string | null {
|
|
193
198
|
if (!hasConversation) {
|
|
194
199
|
if (!silent) console.log(t("无对话可保存。", "No conversation to save."));
|
|
195
200
|
return null;
|
|
@@ -203,16 +208,21 @@ export function saveSession(messages: any[], model: string, silent = false): str
|
|
|
203
208
|
const filename = `${timestamp}__${safePreview}.jsonl`;
|
|
204
209
|
const filepath = join(SESSIONS_DIR, filename);
|
|
205
210
|
|
|
211
|
+
// 落盘前修剪残缺尾部(生成中存盘时可能含未配对 toolCall,防 resume 后服务端 400;
|
|
212
|
+
// 对完整历史幂等)。只影响文件内容,不改内存——回合可继续,完成后以完整版覆盖。
|
|
213
|
+
const persisted = trimPartialTail(messages);
|
|
214
|
+
|
|
206
215
|
const header: SessionHeader = {
|
|
207
216
|
type: "arona-session",
|
|
208
|
-
version:
|
|
217
|
+
version: 2,
|
|
209
218
|
timestamp: new Date().toISOString(),
|
|
210
219
|
model,
|
|
211
220
|
preview,
|
|
221
|
+
workspace,
|
|
212
222
|
};
|
|
213
223
|
|
|
214
224
|
const lines: string[] = [JSON.stringify(header)];
|
|
215
|
-
for (const msg of
|
|
225
|
+
for (const msg of persisted) {
|
|
216
226
|
lines.push(JSON.stringify(msg));
|
|
217
227
|
}
|
|
218
228
|
writeFileSync(filepath, lines.join("\n"));
|
|
@@ -223,28 +233,32 @@ export function saveSession(messages: any[], model: string, silent = false): str
|
|
|
223
233
|
/**
|
|
224
234
|
* 将会话保存到指定路径(覆盖原文件)。
|
|
225
235
|
* 用于 /resume 恢复的会话退出时保存回原文件,而非另存为新文件。
|
|
226
|
-
* 保留原文件的 header(timestamp/preview),仅更新 model
|
|
236
|
+
* 保留原文件的 header(timestamp/preview/workspace),仅更新 model 字段;
|
|
237
|
+
* 原 header 缺 workspace(旧会话)且调用方传入时补写——resume 保存即归入当前工作区。
|
|
227
238
|
*/
|
|
228
|
-
export function saveSessionToPath(filepath: string, messages: any[], model: string, silent = false): void {
|
|
239
|
+
export function saveSessionToPath(filepath: string, messages: any[], model: string, silent = false, workspace?: string): void {
|
|
229
240
|
let header: SessionHeader;
|
|
230
241
|
try {
|
|
231
242
|
const content = readFileSync(filepath, "utf-8");
|
|
232
243
|
header = JSON.parse(content.split("\n")[0]) as SessionHeader;
|
|
233
244
|
header.model = model; // 模型可能已切换,更新之
|
|
245
|
+
if (!header.workspace && workspace) header.workspace = workspace;
|
|
234
246
|
} catch {
|
|
235
247
|
// 原文件不存在或损坏,生成新 header(剥离桌宠手势注入块,见 firstUserPreview)
|
|
236
248
|
const preview = firstUserPreview(messages);
|
|
237
249
|
header = {
|
|
238
250
|
type: "arona-session",
|
|
239
|
-
version:
|
|
251
|
+
version: 2,
|
|
240
252
|
timestamp: new Date().toISOString(),
|
|
241
253
|
model,
|
|
242
254
|
preview,
|
|
255
|
+
workspace,
|
|
243
256
|
};
|
|
244
257
|
}
|
|
245
258
|
|
|
246
259
|
const lines: string[] = [JSON.stringify(header)];
|
|
247
|
-
|
|
260
|
+
// 同 saveSession:落盘前修剪残缺尾部(幂等;只影响文件,不改内存)
|
|
261
|
+
for (const msg of trimPartialTail(messages)) {
|
|
248
262
|
lines.push(JSON.stringify(msg));
|
|
249
263
|
}
|
|
250
264
|
writeFileSync(filepath, lines.join("\n"));
|
|
@@ -269,6 +283,7 @@ export function listSessions(): SessionInfo[] {
|
|
|
269
283
|
timestamp: header.timestamp,
|
|
270
284
|
preview: header.preview,
|
|
271
285
|
model: header.model,
|
|
286
|
+
workspace: header.workspace,
|
|
272
287
|
});
|
|
273
288
|
}
|
|
274
289
|
} catch {
|
|
@@ -306,8 +321,8 @@ export function renameSession(filepath: string, title: string): string | null {
|
|
|
306
321
|
header.preview = preview;
|
|
307
322
|
lines[0] = JSON.stringify(header);
|
|
308
323
|
|
|
309
|
-
// 沿用原文件名的时间戳前缀,替换预览 slug
|
|
310
|
-
const stamp = filepath
|
|
324
|
+
// 沿用原文件名的时间戳前缀,替换预览 slug(basename:Windows 路径分隔符是 \,split("/") 会把整个路径当文件名)
|
|
325
|
+
const stamp = basename(filepath).split("__")[0] || new Date().toISOString().replace(/[:.]/g, "-");
|
|
311
326
|
const safePreview = preview.replace(/[^a-zA-Z0-9\u4e00-\u9fff]/g, "_").slice(0, 30);
|
|
312
327
|
const newPath = join(SESSIONS_DIR, `${stamp}__${safePreview}.jsonl`);
|
|
313
328
|
|
|
@@ -332,8 +347,45 @@ export function renameSession(filepath: string, title: string): string | null {
|
|
|
332
347
|
}
|
|
333
348
|
}
|
|
334
349
|
|
|
335
|
-
|
|
336
|
-
|
|
350
|
+
/**
|
|
351
|
+
* 修剪尾部残缺回合("未完成也存盘"场景专用):LLM 生成中落盘时,尾部可能存在
|
|
352
|
+
* 未应答的 toolCall(没有配对的 toolResult,甚至参数只生成了半截)——直接写盘,
|
|
353
|
+
* resume 后提交服务端会 400。规则:
|
|
354
|
+
* - toolCall/toolResult 数量不配对 → 修剪回最后一条 user 消息(含);
|
|
355
|
+
* - 数量配对但尾部停在 toolResult(模型总结还没生成)→ 同样修剪:resume 后的
|
|
356
|
+
* 新输入会与 toolResult 所在的 user 消息相邻,仍有 400 风险;
|
|
357
|
+
* - 以 assistant 文本收尾(哪怕是生成到一半的文本)→ 保留:API 合法状态,
|
|
358
|
+
* 重启恢复后能看到已生成的半成品,可让模型接着写。
|
|
359
|
+
* 只影响写盘内容,不改内存中的会话(后台回合继续跑,完成后以完整版覆盖存盘)。
|
|
360
|
+
*/
|
|
361
|
+
export function trimPartialTail(messages: any[]): any[] {
|
|
362
|
+
let lastUser = -1;
|
|
363
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
364
|
+
if (messages[i]?.role === "user") {
|
|
365
|
+
lastUser = i;
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (lastUser < 0) return []; // 没有用户消息:历史不完整(不该发生),退到空
|
|
370
|
+
const tail = messages.slice(lastUser + 1);
|
|
371
|
+
let calls = 0;
|
|
372
|
+
let results = 0;
|
|
373
|
+
for (const m of tail) {
|
|
374
|
+
if (m.role === "assistant") {
|
|
375
|
+
for (const b of m.content ?? []) {
|
|
376
|
+
if (b.type === "toolCall") calls++;
|
|
377
|
+
}
|
|
378
|
+
} else if (m.role === "toolResult") {
|
|
379
|
+
results++;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const last = tail[tail.length - 1];
|
|
383
|
+
const endsOnToolResult = last?.role === "toolResult";
|
|
384
|
+
if (calls === results && !endsOnToolResult) return messages; // 合法收尾(含半截文本),原样保存
|
|
385
|
+
return messages.slice(0, lastUser + 1);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function loadSession(filepath: string): any[] { const content = readFileSync(filepath, "utf-8");
|
|
337
389
|
const lines = content.split("\n").filter((l) => l.trim());
|
|
338
390
|
const messages: any[] = [];
|
|
339
391
|
|
|
@@ -346,6 +398,47 @@ export function loadSession(filepath: string): any[] {
|
|
|
346
398
|
return messages;
|
|
347
399
|
}
|
|
348
400
|
|
|
401
|
+
/**
|
|
402
|
+
* 写入会话的所属工作区(就地改 header 首行,文件名不变)。
|
|
403
|
+
* force=false 时仅补写缺失的 workspace(旧会话回填);force=true 用于「移动到工作区」。
|
|
404
|
+
*/
|
|
405
|
+
export function setSessionWorkspace(filepath: string, workspace: string, force = false): boolean {
|
|
406
|
+
try {
|
|
407
|
+
const content = readFileSync(filepath, "utf-8");
|
|
408
|
+
const nl = content.indexOf("\n");
|
|
409
|
+
const firstLine = nl >= 0 ? content.slice(0, nl) : content;
|
|
410
|
+
const header = JSON.parse(firstLine) as SessionHeader;
|
|
411
|
+
if (header.type !== "arona-session") return false;
|
|
412
|
+
if (header.workspace && !force) return false;
|
|
413
|
+
header.workspace = resolve(workspace);
|
|
414
|
+
const rest = nl >= 0 ? content.slice(nl) : "";
|
|
415
|
+
writeFileSync(filepath, JSON.stringify(header) + rest);
|
|
416
|
+
return true;
|
|
417
|
+
} catch (err) {
|
|
418
|
+
console.warn(`setSessionWorkspace: ${err instanceof Error ? err.message : err}`);
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* 一次性回填:为未记录工作区的旧会话按内容推断工作区(会话中出现频率最高的项目目录)。
|
|
425
|
+
* 推断不出(纯聊天、无绝对路径)保持未分组。返回回填数量。CLI/GUI 启动时各调用一次。
|
|
426
|
+
*/
|
|
427
|
+
export function backfillLegacyWorkspaces(): number {
|
|
428
|
+
let migrated = 0;
|
|
429
|
+
for (const s of listSessions()) {
|
|
430
|
+
if (s.workspace) continue;
|
|
431
|
+
try {
|
|
432
|
+
const content = readFileSync(s.path, "utf-8");
|
|
433
|
+
const inferred = inferWorkspaceFromContent(content);
|
|
434
|
+
if (inferred && setSessionWorkspace(s.path, inferred)) migrated++;
|
|
435
|
+
} catch {
|
|
436
|
+
// 单个文件损坏不影响其余回填
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return migrated;
|
|
440
|
+
}
|
|
441
|
+
|
|
349
442
|
// ============================================================
|
|
350
443
|
// 编码子代理过程留痕(sidecar:`<会话名>.coding.jsonl`)
|
|
351
444
|
// 单独存放:不出现在会话列表(header 类型不同被过滤),loadSession 不读它(不进主 Agent 上下文)。
|
package/src/pet.ts
CHANGED
|
@@ -42,6 +42,15 @@ class PetBridge {
|
|
|
42
42
|
private pendingSelection: { main: AgentId; subs: AgentId[] } | null = null;
|
|
43
43
|
// 最近一次桌宠手势(摸头/dizzy):只保留最新一次,供下一条用户消息注入一次后即消费清空
|
|
44
44
|
private latestGesture: PetGestureType | null = null;
|
|
45
|
+
// 桌宠进程握手(hello 行)与 HTTP 下行通道:Windows 下 GUI 子系统 Electron 的 stdin 数据
|
|
46
|
+
// 不可达(GUI 白屏坑③同族问题),pet/main.cjs 起 127.0.0.1 随机端口 + 随机 token 的 HTTP
|
|
47
|
+
// 服务并经 hello 行告知;hello 前入队,收到后按序经 HTTP 串行下发。httpPort=0(HTTP 起
|
|
48
|
+
// 失败)退回 stdin 写入(非 Windows 平台 stdin 本来就通)。
|
|
49
|
+
private helloReceived = false;
|
|
50
|
+
private queuedMessages: Record<string, unknown>[] = [];
|
|
51
|
+
private petHttpPort = 0;
|
|
52
|
+
private petHttpToken = "";
|
|
53
|
+
private httpChain: Promise<void> = Promise.resolve(); // 串行链保序(HTTP 异步,并发会乱序)
|
|
45
54
|
|
|
46
55
|
/**
|
|
47
56
|
* Electron 42+ 把二进制下载从 postinstall 挪到了首次 require。这里在 spawn 之前
|
|
@@ -78,6 +87,12 @@ class PetBridge {
|
|
|
78
87
|
if (!electronPath) return;
|
|
79
88
|
|
|
80
89
|
this.intentionalStop = false;
|
|
90
|
+
// 重置握手状态:新进程会发新 hello(新端口/token),旧通道必须作废
|
|
91
|
+
this.helloReceived = false;
|
|
92
|
+
this.petHttpPort = 0;
|
|
93
|
+
this.petHttpToken = "";
|
|
94
|
+
this.queuedMessages = [];
|
|
95
|
+
this.httpChain = Promise.resolve();
|
|
81
96
|
|
|
82
97
|
try {
|
|
83
98
|
// 剔除 ELECTRON_RUN_AS_NODE,否则 Electron 会退化为纯 Node 运行;
|
|
@@ -132,11 +147,18 @@ class PetBridge {
|
|
|
132
147
|
vlog("spawn error:", err.message);
|
|
133
148
|
console.warn(chalk.yellow(t(`桌宠:进程错误(${err.message}),已降级。`, `Pet: process error (${err.message}), degraded.`)));
|
|
134
149
|
this.proc = null;
|
|
150
|
+
this.helloReceived = false;
|
|
151
|
+
this.petHttpPort = 0;
|
|
152
|
+
this.queuedMessages = [];
|
|
135
153
|
});
|
|
136
154
|
|
|
137
155
|
this.proc.on("close", (code, signal) => {
|
|
138
156
|
vlog("process closed", { code, signal, intentional: this.intentionalStop, pendingSelection: this.pendingSelection });
|
|
139
157
|
this.proc = null;
|
|
158
|
+
this.helloReceived = false;
|
|
159
|
+
this.petHttpPort = 0;
|
|
160
|
+
this.petHttpToken = "";
|
|
161
|
+
this.queuedMessages = [];
|
|
140
162
|
if (this.pendingSelection) {
|
|
141
163
|
// 切换角色:旧进程已退出,以新角色组合重新拉起(stop 时 intentionalStop=true 不会走退避重启)
|
|
142
164
|
const sel = this.pendingSelection;
|
|
@@ -183,7 +205,19 @@ class PetBridge {
|
|
|
183
205
|
return;
|
|
184
206
|
}
|
|
185
207
|
vlog("recv", JSON.stringify(msg));
|
|
186
|
-
if (msg.type === "
|
|
208
|
+
if (msg.type === "hello") {
|
|
209
|
+
// 桌宠进程握手:解锁排队消息;httpPort>0 时后续消息走本机 HTTP 通道
|
|
210
|
+
//(Windows 下 GUI 子系统 Electron 的 stdin 数据不可达,根治手段与 GUI 桥同款)
|
|
211
|
+
this.helloReceived = true;
|
|
212
|
+
this.petHttpPort = Number(msg.httpPort) || 0;
|
|
213
|
+
this.petHttpToken = String(msg.token || "");
|
|
214
|
+
const queued = this.queuedMessages;
|
|
215
|
+
this.queuedMessages = [];
|
|
216
|
+
if (verbose && queued.length) {
|
|
217
|
+
console.error(chalk.gray("[pet:verbose]"), "hello received (httpPort=" + this.petHttpPort + "), flushing", queued.length, "queued messages");
|
|
218
|
+
}
|
|
219
|
+
for (const m of queued) this.send(m);
|
|
220
|
+
} else if (msg.type === "ready") {
|
|
187
221
|
this.restartCount = 0; // 成功启动后重置退避计数
|
|
188
222
|
} else if (msg.type === "error") {
|
|
189
223
|
console.error(chalk.gray("[pet]"), msg.message);
|
|
@@ -213,7 +247,17 @@ class PetBridge {
|
|
|
213
247
|
|
|
214
248
|
private send(msg: Record<string, unknown>): void {
|
|
215
249
|
if (!this.proc || this.proc.killed) return;
|
|
250
|
+
if (!this.helloReceived) {
|
|
251
|
+
// 握手前入队(进程已起但通道未就绪)。设上限防 hello 永不到来时无限增长。
|
|
252
|
+
if (this.queuedMessages.length >= 200) this.queuedMessages.shift();
|
|
253
|
+
this.queuedMessages.push(msg);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
216
256
|
vlog("send", JSON.stringify(msg));
|
|
257
|
+
if (this.petHttpPort) {
|
|
258
|
+
this.httpChain = this.httpChain.then(() => this.postEvent(msg));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
217
261
|
try {
|
|
218
262
|
this.proc.stdin.write(JSON.stringify(msg) + "\n");
|
|
219
263
|
} catch {
|
|
@@ -221,6 +265,21 @@ class PetBridge {
|
|
|
221
265
|
}
|
|
222
266
|
}
|
|
223
267
|
|
|
268
|
+
/** 经本机 HTTP 通道下发消息(pet/main.cjs 的 127.0.0.1 随机端口服务,token 鉴权) */
|
|
269
|
+
private async postEvent(msg: Record<string, unknown>): Promise<void> {
|
|
270
|
+
try {
|
|
271
|
+
await fetch(`http://127.0.0.1:${this.petHttpPort}/`, {
|
|
272
|
+
method: "POST",
|
|
273
|
+
headers: { "content-type": "application/json", "x-arona-token": this.petHttpToken },
|
|
274
|
+
body: JSON.stringify(msg),
|
|
275
|
+
});
|
|
276
|
+
} catch (err) {
|
|
277
|
+
if (verbose) {
|
|
278
|
+
console.error(chalk.gray("[pet:verbose]"), "http send failed:", err instanceof Error ? err.message : err);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
224
283
|
setEmotion(agentId: AgentId, name: string): void {
|
|
225
284
|
this.send({ type: "set_emotion", agent: agentId, name });
|
|
226
285
|
}
|