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/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 会丢数据(GUI 白屏根因:mode 事件从未到达 Electron 进程)——
25
- // 等 GUI 进程回报 hello(stdin 解析器就绪的握手,gui/main.cjs 模块加载即发送)才真正下发,此前入队。
26
- // macOS/Linux 不受影响,但握手在所有平台统一走一遍(幂等,且顺带验证 stdout 协议链路通)。
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
- current.session.dispose();
154
- const { resetConversationFlag } = await import("../memory.ts");
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 进程 stdin 解析器就绪:解锁排队中的事件(emit mode/setup_info 在 spawn 后立即调用,
191
- // Windows 下彼时写入会丢失——见 emit() 注释)
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;
@@ -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 结果(空串=未识别到语音)与录音状态
@@ -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
  // 切换角色
@@ -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
  }