arona-agent 1.2.2 → 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 +29 -6
- 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 +26 -5
- package/src/gui/protocol.ts +9 -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";
|
|
@@ -159,6 +160,20 @@ class GuiBridge {
|
|
|
159
160
|
syncSkillsFromAgentsDir();
|
|
160
161
|
}
|
|
161
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
|
+
|
|
162
177
|
let current = await initAgent();
|
|
163
178
|
await startPet();
|
|
164
179
|
preloadGptSovitsLocal();
|
|
@@ -172,12 +187,11 @@ class GuiBridge {
|
|
|
172
187
|
this.exited = true;
|
|
173
188
|
process.exit(0);
|
|
174
189
|
},
|
|
190
|
+
// 只负责创建新会话(cwd 跟随当前活动工作区);旧会话的生命周期由 controller
|
|
191
|
+
// 槽位管理:生成中挂后台继续、空闲存盘释放,这里不得 dispose。
|
|
175
192
|
async () => {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
resetConversationFlag();
|
|
179
|
-
current = await initAgent();
|
|
180
|
-
return current;
|
|
193
|
+
const { initAgent } = await import("../agent.ts");
|
|
194
|
+
return await initAgent();
|
|
181
195
|
},
|
|
182
196
|
);
|
|
183
197
|
|
|
@@ -269,6 +283,13 @@ class GuiBridge {
|
|
|
269
283
|
case "rename_session":
|
|
270
284
|
this.controller?.renameSessionByPath(req.path, req.title);
|
|
271
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 进程内拦截弹原生目录框,不经此处
|
|
272
293
|
case "invoke_skill":
|
|
273
294
|
await this.controller?.handleCommand(`/skill ${req.name}`);
|
|
274
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 结果(空串=未识别到语音)与录音状态
|
|
@@ -85,6 +87,11 @@ export type GuiRequest =
|
|
|
85
87
|
// 侧栏会话管理(右键菜单)
|
|
86
88
|
| { type: "delete_session"; path: string }
|
|
87
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" }
|
|
88
95
|
// 调用技能
|
|
89
96
|
| { type: "invoke_skill"; name: string }
|
|
90
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
|
}
|
package/src/renderer.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { t } from "./locale.ts";
|
|
3
|
-
import { getAgentLabel, getMainAgent } from "./agent_registry.ts";
|
|
3
|
+
import { getAgentLabel, getMainAgent, type AgentId } from "./agent_registry.ts";
|
|
4
|
+
import { SpeakerPrefixStripper } from "./speaker_context.ts";
|
|
4
5
|
import { countTextUnits } from "./text_split.ts";
|
|
5
6
|
|
|
6
7
|
// 思考块与工具详情始终显示(开关已移除)
|
|
@@ -69,6 +70,10 @@ export function createRenderer(
|
|
|
69
70
|
let inThinking = false;
|
|
70
71
|
let inText = false;
|
|
71
72
|
let textPrefixWritten = false;
|
|
73
|
+
// 流式剥离「名字:」前缀:模型偶发把「星野:」这类前缀写进台词(模仿历史消息),
|
|
74
|
+
// 而说话人前缀已由本 renderer 标注,模型再写就重复。按当前发言角色剥离(null=不剥)。
|
|
75
|
+
let prefixAgentId: AgentId | null = null;
|
|
76
|
+
let prefixStripper: SpeakerPrefixStripper | null = null;
|
|
72
77
|
|
|
73
78
|
// ── 流式思考折叠重绘 ──────────────────────────────────────────
|
|
74
79
|
let thinkingBuffer = ""; // 完整思考内容缓冲区
|
|
@@ -161,6 +166,11 @@ export function createRenderer(
|
|
|
161
166
|
setSpeakerLabel(label: string | undefined) {
|
|
162
167
|
speakerLabel = label;
|
|
163
168
|
},
|
|
169
|
+
/** 设置当前发言角色(用于流式剥离模型偶发写出的「名字:」前缀) */
|
|
170
|
+
setPrefixAgent(id: AgentId | null) {
|
|
171
|
+
prefixAgentId = id;
|
|
172
|
+
prefixStripper = null;
|
|
173
|
+
},
|
|
164
174
|
// 切 session(setActiveAgent)时显式复位回合状态:消除跨会话 curMsgText/lastText 残留,
|
|
165
175
|
// 防止被新 session 的 agent_end 误读上一角色文本。
|
|
166
176
|
resetTurn() {
|
|
@@ -171,6 +181,7 @@ export function createRenderer(
|
|
|
171
181
|
inThinking = false;
|
|
172
182
|
inText = false;
|
|
173
183
|
textPrefixWritten = false;
|
|
184
|
+
prefixStripper = null;
|
|
174
185
|
},
|
|
175
186
|
subscribe: (session: any) => {
|
|
176
187
|
return session.subscribe((event: any) => {
|
|
@@ -182,6 +193,7 @@ export function createRenderer(
|
|
|
182
193
|
curMsgText = "";
|
|
183
194
|
thinkingBuffer = "";
|
|
184
195
|
drawnThinkingLines = 0;
|
|
196
|
+
prefixStripper = prefixAgentId ? new SpeakerPrefixStripper(prefixAgentId) : null;
|
|
185
197
|
break;
|
|
186
198
|
|
|
187
199
|
case "message_update": {
|
|
@@ -198,9 +210,11 @@ export function createRenderer(
|
|
|
198
210
|
textPrefixWritten = true;
|
|
199
211
|
}
|
|
200
212
|
}
|
|
201
|
-
|
|
213
|
+
const delta = prefixStripper ? prefixStripper.push(ae.delta) : ae.delta;
|
|
214
|
+
if (!delta) break;
|
|
215
|
+
process.stdout.write(delta);
|
|
202
216
|
// 文本只累积到当前 message,TTS 与气泡都在 agent_end 一次性收尾(只读最后一段)
|
|
203
|
-
curMsgText +=
|
|
217
|
+
curMsgText += delta;
|
|
204
218
|
} else if (ae.type === "thinking_delta") {
|
|
205
219
|
if (showThinking) {
|
|
206
220
|
if (!inThinking) {
|
|
@@ -221,6 +235,16 @@ export function createRenderer(
|
|
|
221
235
|
if (inThinking && showThinking) {
|
|
222
236
|
finalizeThinking();
|
|
223
237
|
}
|
|
238
|
+
// 放行剥离器仍扣留的内容(无前缀的短回复可能整段被扣留到 message 结束)
|
|
239
|
+
const held = prefixStripper?.flush() ?? "";
|
|
240
|
+
if (held) {
|
|
241
|
+
if (!inText && speakerLabel && !textPrefixWritten) {
|
|
242
|
+
process.stdout.write(style.speaker(speakerLabel + ":"));
|
|
243
|
+
textPrefixWritten = true;
|
|
244
|
+
}
|
|
245
|
+
process.stdout.write(held);
|
|
246
|
+
curMsgText += held;
|
|
247
|
+
}
|
|
224
248
|
if (inText) {
|
|
225
249
|
process.stdout.write("\n");
|
|
226
250
|
}
|