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/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
- current.session.dispose();
177
- const { resetConversationFlag } = await import("../memory.ts");
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;
@@ -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
- | { type: "sessions"; currentPath: string | null; sessions: Array<{ path: string; preview: string; timestamp: string; model: string }> }
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
  // 切换角色
@@ -1,16 +1,22 @@
1
1
  // 图形化 setup 后端:表单数据 → Python 检查 / pip 依赖 / 音色克隆 / 写盘。
2
2
  // 步骤与字段对齐 CLI src/setup.ts(该文件模块加载即跑 CLI 向导,不能 import,独立实现)。
3
- import { spawn, execSync } from "child_process";
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
- const output = execSync(`${pythonPath} --version`, { stdio: "pipe" }).toString().trim();
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
- const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
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 (gptSovitsConfig as Record<string, unknown>).voices;
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 || "v2"),
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
- execSync(`${pythonPath} -c "import dashscope"`, { stdio: "pipe" });
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
- const tsxBin = process.platform === "win32"
20
- ? join(PROJECT_ROOT, "node_modules", ".bin", "tsx.cmd")
21
- : join(PROJECT_ROOT, "node_modules", ".bin", "tsx");
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(tsxBin, [join(PROJECT_ROOT, "src", "setup.ts"), ...wizardArgs], {
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: 1,
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 messages) {
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: 1,
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
- for (const msg of messages) {
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.split("/").pop()?.split("__")[0] ?? new Date().toISOString().replace(/[:.]/g, "-");
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
- export function loadSession(filepath: string): any[] {
336
- const content = readFileSync(filepath, "utf-8");
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 === "ready") {
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
- process.stdout.write(ae.delta);
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 += ae.delta;
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
  }