arona-agent 1.2.2 → 1.2.4

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.
@@ -1,12 +1,14 @@
1
1
  // GuiController:Repl(src/repl.ts)的无 readline 版回合编排,GUI 模式专用。
2
2
  // 与 Repl 的对应关系(双改须同步):parseInput / runRawTurn / runOneAgent / ensureSubSessions /
3
- // waitTurnSettled / extractNewAssistantText / setActiveAgent / resetSubSessions /
4
- // saveCurrentSessionIfNeeded / resumeSession(渲染差异:输出走 agent_event 协议而非 stdout)。
3
+ // waitTurnSettled / extractNewAssistantText / setActiveAgent / resumeSession(渲染差异:输出走 agent_event 协议而非 stdout)。
4
+ // 会话管理为"槽位"模型:LLM 生成中切换会话/工作区不中断回合——旧会话挂后台继续,
5
+ // 结束按槽位存盘(含其定格的工作区),可随时从侧栏接回实时查看。
5
6
  import { execSync } from "child_process";
7
+ import { homedir } from "os";
6
8
  import { readFileSync, existsSync, writeFileSync } from "fs";
7
9
  import { resolve, join } from "path";
8
10
  import type { AgentSession, ModelRuntime, DefaultResourceLoader } from "@earendil-works/pi-coding-agent";
9
- import { config } from "../config.ts";
11
+ import { config, getStoredWorkspaces, rememberWorkspace } from "../config.ts";
10
12
  import * as memory from "../memory.ts";
11
13
  import * as voice from "../voice.ts";
12
14
  import { TtsStream } from "../tts_stream.ts";
@@ -26,24 +28,47 @@ import {
26
28
  import * as skills from "../skills.ts";
27
29
  import { SLASH_COMMANDS, resolveSlashCommand } from "../slash_registry.ts";
28
30
  import { setCodingRunSink, setCodingEventSink } from "../coding_process.ts";
31
+ import { stripSpeakerPrefix, SpeakerPrefixStripper } from "../speaker_context.ts";
32
+ import { currentWorkspace, setActiveWorkspace, guiDefaultWorkspace, workspaceLabel } from "../workspace.ts";
29
33
  import type { CodingRun } from "../memory.ts";
30
34
  import type { GuiEvent, GuiState } from "./protocol.ts";
31
35
 
32
- const PET_MAX_BUBBLE_LEN = 50; // 气泡字数上限(countTextUnits 口径):≥50 的回复不上气泡
36
+ const PET_MAX_BUBBLE_LEN = 50; // 气泡字数上限(countTextUnits 口径):≥50 字的回复不上气泡
37
+ const MAX_BACKGROUND_SLOTS = 6; // 后台会话上限(超出时释放最早的未在生成的会话)
38
+
39
+ /**
40
+ * 会话槽位:每个 AgentSession 一份元数据。当前会话与"挂在后台继续生成"的会话统一管理——
41
+ * 切换会话不再复用/销毁同一个 session 对象,后台回合结束时按槽位存盘。
42
+ */
43
+ interface SessionSlot {
44
+ session: AgentSession;
45
+ path: string | null; // 已落盘文件路径(null = 尚未存盘)
46
+ workspace: string; // 会话归属工作区(创建时定格;后台回合存盘用它,避免被切换后的 currentWorkspace 误标)
47
+ deleted: boolean; // 用户已删除该会话文件:不再回写
48
+ hasConversation: boolean; // 该会话是否发生过有效对话(原全局 hasConversation 的按会话版)
49
+ processing: boolean; // 回合进行中(可能在后台)
50
+ abortRequested: boolean; // 用户点了停止
51
+ pendingRuns: CodingRun[]; // 首次落盘前缓冲的子代理执行记录
52
+ undo: UndoManager; // 撤销快照按会话隔离(不同工作区/并发回合互不串扰)
53
+ subs: Map<SubAgentId, AgentSession>; // 群聊子 Agent 会话按主会话隔离(并发回合各自的子会话消息不互踩)
54
+ }
33
55
 
34
56
  export class GuiController {
35
57
  private session: AgentSession;
36
58
  private modelRuntime: ModelRuntime;
37
59
  private loader: DefaultResourceLoader;
38
- private isProcessing = false;
39
- private aborted = false;
40
60
  private turnEnded = false;
41
61
  private recording = false;
42
62
  private sttAbort: AbortController | null = null;
43
63
  private sttGraceful: AbortController | null = null;
44
- // 当前会话文件被用户删除:置位后本轮对话不再回写文件(避免删除后被自动保存复活)
45
- private currentSessionDeleted = false;
46
64
 
65
+ private slots = new Map<AgentSession, SessionSlot>();
66
+ // 进行中的回合栈(栈顶 = 最近开始的回合;编码子代理过程按它路由落盘)
67
+ private turnStack: SessionSlot[] = [];
68
+ private activeSession: AgentSession;
69
+ private activeAgentId: AgentId;
70
+ private bubbleHideTimer: NodeJS.Timeout | null = null;
71
+ private rendererUnsub: (() => void) | null = null;
47
72
  private ttsStream = new TtsStream(
48
73
  (agentId) => voice.isTtsEnabledFor(agentId),
49
74
  () => {
@@ -55,19 +80,37 @@ export class GuiController {
55
80
  },
56
81
  );
57
82
 
58
- private subSessions = new Map<SubAgentId, AgentSession>();
59
- private activeSession: AgentSession;
60
- private activeAgentId: AgentId;
61
- private bubbleHideTimer: NodeJS.Timeout | null = null;
62
- private rendererUnsub: (() => void) | null = null;
63
- private currentSessionPath: string | null = null;
64
- private undoManager: UndoManager;
65
- // 尚未落盘的子代理执行记录(会话文件建立后一次性写入 sidecar)
66
- private pendingCodingRuns: CodingRun[];
67
-
68
83
  // 回合文本累积(与 renderer.ts 同构:只保留最后一个 assistant message 的文本)
69
84
  private curMsgText = "";
70
85
  private lastText = "";
86
+ // 流式剥离「名字:」前缀(模型偶发模仿历史消息写出;GUI 侧已单独标注说话人,会显示两遍)
87
+ private prefixStripper: SpeakerPrefixStripper | null = null;
88
+
89
+ /** 取会话槽位(无则按当前工作区创建)。 */
90
+ private slotOf(session: AgentSession): SessionSlot {
91
+ let slot = this.slots.get(session);
92
+ if (!slot) {
93
+ slot = {
94
+ session,
95
+ path: null,
96
+ workspace: currentWorkspace(),
97
+ deleted: false,
98
+ hasConversation: false,
99
+ processing: false,
100
+ abortRequested: false,
101
+ pendingRuns: [],
102
+ undo: new UndoManager(currentWorkspace()),
103
+ subs: new Map<SubAgentId, AgentSession>(),
104
+ };
105
+ slot.undo.load();
106
+ this.slots.set(session, slot);
107
+ }
108
+ return slot;
109
+ }
110
+
111
+ private get activeSlot(): SessionSlot {
112
+ return this.slotOf(this.activeSession);
113
+ }
71
114
 
72
115
  constructor(
73
116
  session: AgentSession,
@@ -75,7 +118,7 @@ export class GuiController {
75
118
  loader: DefaultResourceLoader,
76
119
  private emit: (ev: GuiEvent) => void,
77
120
  private onExit: () => void,
78
- private onNewSession: () => Promise<{
121
+ private onCreateSession: () => Promise<{
79
122
  session: AgentSession;
80
123
  modelRuntime: ModelRuntime;
81
124
  loader: DefaultResourceLoader;
@@ -87,22 +130,22 @@ export class GuiController {
87
130
  this.activeSession = session;
88
131
  this.activeAgentId = getMainAgent();
89
132
 
90
- this.undoManager = new UndoManager(process.cwd());
91
- this.undoManager.load();
92
-
93
- // 编码子代理过程留痕:会话文件已存在 → 直接写 sidecar;
94
- // 尚未落盘(新会话首回合)→ 缓冲,saveCurrentSessionIfNeeded 建文件后回填。
95
- this.pendingCodingRuns = [];
133
+ // 编码子代理过程留痕:按栈顶回合的会话路由(该会话文件已存在 直接写 sidecar;
134
+ // 尚未落盘(新会话首回合)→ 缓冲,回合结束存盘后回填)。
96
135
  setCodingRunSink((run) => {
97
- if (this.currentSessionDeleted) return;
98
- if (this.currentSessionPath) {
99
- memory.appendCodingRun(this.currentSessionPath, run);
136
+ const slot = this.turnStack[this.turnStack.length - 1] ?? this.activeSlot;
137
+ if (slot.deleted) return;
138
+ if (slot.path) {
139
+ memory.appendCodingRun(slot.path, run);
100
140
  } else {
101
- this.pendingCodingRuns.push(run);
141
+ slot.pendingRuns.push(run);
102
142
  }
103
143
  });
104
- // 编码子代理事件实时转发前端(agentId 区分角色,前端以对应角色名义渲染)
144
+ // 编码子代理事件实时转发前端(agentId 区分角色,前端以对应角色名义渲染);
145
+ // 栈顶回合不是当前会话(后台回合)时不投影,避免后台过程串进前台画面。
105
146
  setCodingEventSink((agentId, event) => {
147
+ const top = this.turnStack[this.turnStack.length - 1];
148
+ if (top && top.session !== this.activeSession) return;
106
149
  this.emit({ type: "agent_event", agentId, event: event as Record<string, unknown> });
107
150
  });
108
151
 
@@ -122,23 +165,67 @@ export class GuiController {
122
165
  ttsEnabled: voice.isTtsEnabled(),
123
166
  sttEnabled: voice.isSttEnabled(),
124
167
  noVoice: config.noVoice,
125
- processing: this.isProcessing,
168
+ processing: this.activeSlot.processing,
126
169
  recording: this.recording,
127
- currentSessionPath: this.currentSessionPath,
170
+ currentSessionPath: this.activeSlot.path,
128
171
  };
129
172
  }
130
173
 
131
- /** 推送侧栏会话列表(附当前会话路径供高亮)。 */
174
+ /** 推送侧栏会话列表(附当前会话路径供高亮 + 当前工作区与已知工作区)。 */
132
175
  pushSessions(): void {
176
+ const listed = memory.listSessions();
177
+ // 已知工作区 = settings 选择历史 ∪ 会话 header 推导 ∪ 当前活动工作区(去重保序)
178
+ const known: string[] = [];
179
+ const push = (ws: string | null | undefined) => {
180
+ if (ws && !known.includes(ws)) known.push(ws);
181
+ };
182
+ for (const ws of getStoredWorkspaces()) push(ws);
183
+ for (const s of listed) push(s.workspace);
184
+ push(currentWorkspace()); // 启动时已无条件设定(上次选择或家目录),不在列表中丢失
133
185
  this.emit({
134
186
  type: "sessions",
135
- currentPath: this.currentSessionPath,
136
- sessions: memory.listSessions().map((s) => ({
137
- path: s.path, preview: s.preview, timestamp: s.timestamp, model: s.model,
187
+ currentPath: this.activeSlot.path,
188
+ currentWorkspace: currentWorkspace(),
189
+ homeDir: homedir(),
190
+ knownWorkspaces: known,
191
+ sessions: listed.map((s) => ({
192
+ path: s.path, preview: s.preview, timestamp: s.timestamp, model: s.model, workspace: s.workspace,
138
193
  })),
139
194
  });
140
195
  }
141
196
 
197
+ /**
198
+ * 切换活动工作区(GUI 欢迎页选择器):当前会话存盘/转后台 → 切换 → 在新工作区建会话(SDK cwd 跟随)。
199
+ * 生成中也可切换:进行中的回合按旧会话槽位继续,结束按其定格的工作区存盘。
200
+ */
201
+ async setWorkspace(path: string): Promise<void> {
202
+ const target = resolve(path);
203
+ if (target === currentWorkspace()) return;
204
+ if (!existsSync(target)) {
205
+ this.notice("error", t(`文件夹不存在:${target}`, `Folder not found: ${target}`));
206
+ return;
207
+ }
208
+ await this.detachActive(); // 存盘/转后台须在切换前:存盘补写的 workspace 用旧值才正确
209
+ setActiveWorkspace(target);
210
+ rememberWorkspace(target);
211
+ await this.createAndAttach();
212
+ this.notice("success", t(
213
+ `工作区已切换:${workspaceLabel(target)}`,
214
+ `Workspace switched: ${workspaceLabel(target)}`,
215
+ ));
216
+ this.emit({ type: "ready", state: this.buildState() });
217
+ }
218
+
219
+ /** 把会话移动到指定工作区(右键菜单),返回是否成功。 */
220
+ moveSessionByPath(path: string, workspace: string): void {
221
+ const ok = memory.setSessionWorkspace(path, workspace, true);
222
+ if (!ok) {
223
+ this.notice("error", t("移动失败。", "Move failed."));
224
+ return;
225
+ }
226
+ this.pushSessions();
227
+ }
228
+
142
229
  private notice(level: "info" | "warn" | "error" | "success", text: string): void {
143
230
  this.emit({ type: "notice", level, text });
144
231
  }
@@ -166,46 +253,83 @@ export class GuiController {
166
253
 
167
254
  private subscribeTo(session: AgentSession): () => void {
168
255
  return session.subscribe((event: any) => {
256
+ // 前缀剥离把整个 delta 扣留时置位:本条事件不向下转发(无可上屏内容)
257
+ let dropEvent = false;
169
258
  // GUI 渲染流:thinking/tool 按显示开关过滤;折叠规则由前端实现(尾部 3 行)
170
259
  switch (event.type) {
171
260
  case "message_start":
172
261
  this.curMsgText = "";
262
+ this.prefixStripper = new SpeakerPrefixStripper(this.activeAgentId);
173
263
  break;
174
264
  case "message_update": {
175
265
  const ae = event.assistantMessageEvent;
176
266
  if (ae?.type === "text_delta") {
177
- this.curMsgText += ae.delta;
267
+ // 转发前剥离前缀:就地改写 delta,走末尾统一转发(前端上屏与 TTS/气泡累积都干净)
268
+ const stripped = this.prefixStripper?.push(ae.delta) ?? ae.delta;
269
+ if (!stripped) {
270
+ dropEvent = true; // 整段被扣留(前缀未判完)
271
+ } else {
272
+ this.curMsgText += stripped;
273
+ ae.delta = stripped;
274
+ }
178
275
  }
179
276
  break;
180
277
  }
181
- case "message_end":
278
+ case "message_end": {
279
+ // 放行剥离器仍扣留的内容(无前缀的短回复可能整段被扣到 message 结束)
280
+ const held = this.prefixStripper?.flush() ?? "";
281
+ if (held) {
282
+ this.curMsgText += held;
283
+ this.emit({
284
+ type: "agent_event",
285
+ agentId: this.activeAgentId,
286
+ event: {
287
+ type: "message_update",
288
+ assistantMessageEvent: { type: "text_delta", delta: held },
289
+ } as Record<string, unknown>,
290
+ });
291
+ }
182
292
  this.lastText = this.curMsgText.trim();
183
293
  this.curMsgText = "";
184
294
  break;
295
+ }
185
296
  case "agent_end": {
186
- // TTS 收尾 + 桌宠气泡:以角色最后一轮输出为准,仅 <50 字上气泡;≥50 字不显示气泡
187
- if (this.lastText) {
188
- this.ttsStream.endTurn(this.lastText);
189
- if (pet.isRunning) {
190
- const units = countTextUnits(this.lastText);
191
- if (units > 0 && units < PET_MAX_BUBBLE_LEN) {
192
- pet.sendText(this.activeAgentId, "final", this.lastText);
297
+ // TTS 收尾 + 桌宠气泡:仅前台会话的回合(后台会话用户看不到,静默继续避免抢音/抢气泡)
298
+ if (session === this.activeSession) {
299
+ if (this.lastText) {
300
+ this.ttsStream.endTurn(this.lastText);
301
+ if (pet.isRunning) {
302
+ const units = countTextUnits(this.lastText);
303
+ if (units > 0 && units < PET_MAX_BUBBLE_LEN) {
304
+ pet.sendText(this.activeAgentId, "final", this.lastText);
305
+ }
193
306
  }
194
307
  }
308
+ this.lastText = "";
195
309
  }
196
- this.lastText = "";
197
310
  break;
198
311
  }
199
312
  }
313
+ if (dropEvent) return;
200
314
  // 协议转发(思考块与工具详情始终显示,前端自行分流渲染)
201
315
  this.emit({ type: "agent_event", agentId: this.activeAgentId, event: event as Record<string, unknown> });
202
316
  });
203
317
  }
204
318
 
205
- /** renderer 订阅到指定角色 session(与 Repl.setActiveAgent 一致)。 */
319
+ /** 该会话是否是前台主会话的群聊子会话。 */
320
+ private isFrontSub(session: AgentSession): boolean {
321
+ for (const sub of this.activeSlot.subs.values()) {
322
+ if (sub === session) return true;
323
+ }
324
+ return false;
325
+ }
326
+
327
+ /** 切 renderer 订阅到指定角色 session(与 Repl.setActiveAgent 一致)。
328
+ * 仅前台会话(及其群聊子会话)的回合接管渲染;后台会话的回合静默继续,
329
+ * 结束后由 finally 存盘,不抢订阅。 */
206
330
  private setActiveAgent(agentId: AgentId, session: AgentSession): void {
331
+ if (session !== this.activeSession && !this.isFrontSub(session)) return;
207
332
  this.activeAgentId = agentId;
208
- this.activeSession = session;
209
333
  this.curMsgText = "";
210
334
  this.lastText = "";
211
335
  this.rendererUnsub?.();
@@ -334,13 +458,13 @@ export class GuiController {
334
458
  return;
335
459
 
336
460
  case "undo": {
337
- const r = await this.undoManager.undo();
461
+ const r = await this.activeSlot.undo.undo();
338
462
  this.notice(r.ok ? "success" : "warn", r.message);
339
463
  return;
340
464
  }
341
465
 
342
466
  case "redo": {
343
- const r = await this.undoManager.redo();
467
+ const r = await this.activeSlot.undo.redo();
344
468
  this.notice(r.ok ? "success" : "warn", r.message);
345
469
  return;
346
470
  }
@@ -470,10 +594,9 @@ export class GuiController {
470
594
  setMainAgent(main as MainAgentId);
471
595
  setSubAgents(validSubs);
472
596
  pet.restartWithSelection(main as AgentId, validSubs);
473
- if (subsChanged) this.resetSubSessions();
474
597
  if (mainChanged) {
475
598
  this.ttsStream.restartVoice();
476
- this.saveCurrentSessionIfNeeded();
599
+ // 保存/转后台由 newSession 的 detachActive 统一处理
477
600
  await this.newSession();
478
601
  this.notice("success", t(
479
602
  `主 Agent 已切换为 ${getAgentLabel(main as MainAgentId)}。`,
@@ -486,112 +609,179 @@ export class GuiController {
486
609
  }
487
610
 
488
611
  // ============================================================
489
- // 会话
612
+ // 会话(槽位模型:当前会话与后台会话统一管理)
490
613
  // ============================================================
491
614
 
615
+ /** 新会话:当前会话若在生成则转后台继续,否则存盘释放;然后建新会话。 */
492
616
  private async newSession(): Promise<void> {
493
- this.saveCurrentSessionIfNeeded(); // 切换前静默落盘当前会话
494
- const result = await this.onNewSession();
617
+ await this.detachActive();
618
+ await this.createAndAttach();
619
+ }
620
+
621
+ /** 创建新 AgentSession(cwd 跟随当前活动工作区)并接管前台。 */
622
+ private async createAndAttach(): Promise<void> {
623
+ const result = await this.onCreateSession();
495
624
  this.session = result.session;
496
625
  this.modelRuntime = result.modelRuntime;
497
626
  this.loader = result.loader;
498
- this.activeSession = this.session;
627
+ this.attach(result.session);
628
+ }
629
+
630
+ /** 接管某会话为前台:绑定渲染订阅并下发回放数据(重新接上后台会话时含其当前消息)。 */
631
+ private attach(session: AgentSession, replay?: { runs: CodingRun[]; messages: unknown[] }): void {
632
+ this.activeSession = session;
499
633
  this.activeAgentId = getMainAgent();
500
- this.currentSessionPath = null;
501
- this.currentSessionDeleted = false;
502
- this.pendingCodingRuns = []; // 新会话:丢弃旧会话未落盘的子代理记录
503
- this.resetSubSessions();
634
+ this.curMsgText = "";
635
+ this.lastText = "";
636
+ this.prefixStripper = null;
504
637
  this.rendererUnsub?.();
505
- this.rendererUnsub = this.subscribeTo(this.session);
506
- this.emit({ type: "history", messages: [] });
638
+ this.rendererUnsub = this.subscribeTo(session);
639
+ if (replay) {
640
+ this.emit({ type: "coding_runs", runs: replay.runs });
641
+ this.emit({ type: "history", messages: replay.messages });
642
+ } else {
643
+ const slot = this.slotOf(session);
644
+ // 重新接上(可能是后台生成中的会话):下发已有消息,之后实时事件继续流向前台
645
+ this.emit({ type: "coding_runs", runs: slot.path ? memory.loadCodingRuns(slot.path) : [] });
646
+ this.emit({ type: "history", messages: (session.agent.state.messages ?? []) as unknown[] });
647
+ }
648
+ this.emit({ type: "ready", state: this.buildState() });
507
649
  this.pushSessions();
508
650
  }
509
651
 
510
- /** 删除会话文件;若删的是当前会话,丢弃其后续自动保存。 */
511
- deleteSessionByPath(path: string): void {
512
- memory.deleteSession(path);
513
- if (this.currentSessionPath === path) {
514
- this.currentSessionPath = null;
515
- this.currentSessionDeleted = true;
652
+ /**
653
+ * 脱离当前前台会话:
654
+ * - 回合进行中 → 先按当前内容落盘(修剪残缺尾部,侧栏立即可见、随时可点回),
655
+ * 再挂后台:断开渲染订阅,回合继续,结束时以完整上下文覆盖存盘;
656
+ * - 空闲 → 立即存盘并释放(含其子会话)。
657
+ */
658
+ private detachActive(): void {
659
+ const old = this.activeSession;
660
+ const slot = this.slotOf(old);
661
+ if (slot.processing) {
662
+ this.saveSlot(slot, old); // 立即落盘(修剪版):侧栏马上出现该会话,点击可接回实时画面
663
+ this.rendererUnsub?.();
664
+ this.rendererUnsub = null;
665
+ this.pruneBackgroundSlots(old);
666
+ } else {
667
+ this.saveSlot(slot, old);
668
+ this.disposeSession(old);
516
669
  }
517
- this.pushSessions();
518
670
  }
519
671
 
520
- /** 重命名会话(更新 preview 与文件名),返回是否成功。 */
521
- renameSessionByPath(path: string, title: string): boolean {
522
- const newPath = memory.renameSession(path, title);
523
- if (!newPath) {
524
- this.notice("error", t("重命名失败。", "Rename failed."));
525
- return false;
672
+ /** 后台会话超上限时释放最早的未在生成的会话(渲染订阅已断开,直接释放即可)。 */
673
+ private pruneBackgroundSlots(keep: AgentSession): void {
674
+ const bg = [...this.slots.values()].filter((s) => s.session !== keep);
675
+ if (bg.length <= MAX_BACKGROUND_SLOTS) return;
676
+ for (const slot of bg) {
677
+ if (this.slots.size <= MAX_BACKGROUND_SLOTS + 1) break; // +1 为前台会话
678
+ if (!slot.processing) this.disposeSession(slot.session);
526
679
  }
527
- if (this.currentSessionPath === path) this.currentSessionPath = newPath;
528
- this.pushSessions();
529
- return true;
530
680
  }
531
681
 
532
- resumeSession(path: string): void {
533
- this.saveCurrentSessionIfNeeded();
682
+ private disposeSession(session: AgentSession): void {
683
+ const slot = this.slots.get(session);
684
+ if (slot) {
685
+ for (const sub of slot.subs.values()) {
686
+ try {
687
+ sub.dispose();
688
+ } catch {
689
+ // 回收失败不影响主流程
690
+ }
691
+ }
692
+ }
534
693
  try {
535
- const messages = memory.loadSession(path);
536
- this.session.agent.state.messages = messages;
537
- this.resetSubSessions();
538
- memory.resetConversationFlag();
539
- this.currentSessionPath = path;
540
- this.currentSessionDeleted = false;
541
- // 先下发子代理过程(前端按 toolCallId 建关联),再下发历史触发回放渲染
542
- this.emit({ type: "coding_runs", runs: memory.loadCodingRuns(path) });
543
- this.emit({ type: "history", messages });
544
- this.pushSessions();
545
- } catch (err) {
546
- this.notice("error", `Failed to load session: ${err instanceof Error ? err.message : err}`);
694
+ session.dispose();
695
+ } catch {
696
+ // 回收失败不影响主流程
547
697
  }
698
+ this.slots.delete(session);
548
699
  }
549
700
 
550
701
  /**
551
- * 保存当前会话。silent=true(默认)用于自动保存,不产生任何提示;
552
- * 退出时 silent=false,GUI 弹「已保存」提示。
553
- * - currentSessionPath null:覆盖原文件
554
- * - null:仅有有效对话时另存为新文件并记录路径
702
+ * 按槽位存盘。silent=true(默认)用于自动保存;前台会话首次落盘时弹「已保存」提示。
703
+ * - slot.path 非 null:覆盖原文件(workspace 保留 header 原值,缺失时补写槽位定格的工作区)
704
+ * - null:仅有有效对话时另存为新文件(归属槽位定格的工作区,后台回合不被切换误标)
705
+ * 生成中的会话由 memory 写盘函数统一修剪残缺尾部(防 resume 400),不影响内存与后台回合。
555
706
  */
556
- private saveCurrentSessionIfNeeded(silent = true): void {
557
- if (this.currentSessionDeleted) return; // 用户已删除该会话,不再回写
558
- const messages = this.session.messages;
559
- const model = this.session.model?.id || "unknown";
560
- if (this.currentSessionPath) {
561
- memory.saveSessionToPath(this.currentSessionPath, messages, model, silent);
562
- } else if (memory.getHasConversation()) {
563
- this.currentSessionPath = memory.saveSession(messages, model, silent);
564
- if (this.currentSessionPath && !silent) {
707
+ private saveSlot(slot: SessionSlot, session: AgentSession, silent = true): void {
708
+ if (slot.deleted) return; // 用户已删除该会话,不再回写
709
+ const messages = session.agent.state.messages as any[];
710
+ const model = session.model?.id || "unknown";
711
+ if (slot.path) {
712
+ memory.saveSessionToPath(slot.path, messages, model, silent, slot.workspace);
713
+ } else if (slot.hasConversation) {
714
+ slot.path = memory.saveSession(messages, model, silent, slot.workspace);
715
+ if (slot.path && !silent && session === this.activeSession) {
565
716
  this.notice("success", t(`会话已保存`, "Session saved"));
566
717
  }
567
718
  }
568
719
  // 首次落盘后,回填期间缓冲的子代理执行记录
569
- if (this.currentSessionPath && this.pendingCodingRuns.length) {
570
- for (const run of this.pendingCodingRuns) {
571
- memory.appendCodingRun(this.currentSessionPath, run);
720
+ if (slot.path && slot.pendingRuns.length) {
721
+ for (const run of slot.pendingRuns) {
722
+ memory.appendCodingRun(slot.path, run);
572
723
  }
573
- this.pendingCodingRuns = [];
724
+ slot.pendingRuns = [];
574
725
  }
575
726
  }
576
727
 
577
- private resetSubSessions(): void {
578
- for (const subSession of this.subSessions.values()) {
579
- try {
580
- subSession.dispose();
581
- } catch {
582
- // 回收失败不影响主流程
728
+ /** 恢复会话:后台已有该会话(可能仍在生成)直接接回;否则新建会话加载历史。 */
729
+ async resumeSession(path: string): Promise<void> {
730
+ if (this.activeSlot.path === path) return;
731
+ for (const slot of this.slots.values()) {
732
+ if (slot.session !== this.activeSession && slot.path === path) {
733
+ this.detachActive();
734
+ this.attach(slot.session); // 接回:生成中则继续实时渲染
735
+ return;
736
+ }
737
+ }
738
+ this.detachActive();
739
+ try {
740
+ const messages = memory.loadSession(path);
741
+ const { session } = await this.onCreateSession();
742
+ session.agent.state.messages = messages;
743
+ this.session = session;
744
+ const slot = this.slotOf(session);
745
+ slot.path = path;
746
+ this.attach(session, { runs: memory.loadCodingRuns(path), messages });
747
+ } catch (err) {
748
+ this.notice("error", `Failed to load session: ${err instanceof Error ? err.message : err}`);
749
+ }
750
+ }
751
+
752
+ /** 删除会话文件;命中所有槽位(含后台生成中的)标记删除,后续不再回写。 */
753
+ deleteSessionByPath(path: string): void {
754
+ memory.deleteSession(path);
755
+ for (const slot of this.slots.values()) {
756
+ if (slot.path === path) {
757
+ slot.deleted = true;
758
+ slot.path = null;
583
759
  }
584
760
  }
585
- this.subSessions.clear();
761
+ this.pushSessions();
762
+ }
763
+
764
+ /** 重命名会话(更新 preview 与文件名),返回是否成功;槽位里的路径同步跟随。 */
765
+ renameSessionByPath(path: string, title: string): boolean {
766
+ const newPath = memory.renameSession(path, title);
767
+ if (!newPath) {
768
+ this.notice("error", t("重命名失败。", "Rename failed."));
769
+ return false;
770
+ }
771
+ for (const slot of this.slots.values()) {
772
+ if (slot.path === path) slot.path = newPath;
773
+ }
774
+ this.pushSessions();
775
+ return true;
586
776
  }
587
777
 
588
- private async ensureSubSessions(): Promise<void> {
589
- const enabled = getSubAgents();
590
- for (const id of enabled) {
591
- if (this.subSessions.has(id)) continue;
778
+ private async ensureSubSessions(main: AgentSession): Promise<void> {
779
+ const slot = this.slotOf(main);
780
+ for (const id of getSubAgents()) {
781
+ if (slot.subs.has(id)) continue;
592
782
  try {
593
783
  const { session } = await initSubAgent(id, this.modelRuntime);
594
- this.subSessions.set(id, session);
784
+ slot.subs.set(id, session);
595
785
  } catch (err) {
596
786
  this.notice("error", t(
597
787
  `初始化子 Agent ${getAgentLabel(id)} 失败:${err instanceof Error ? err.message : err},已跳过该角色。`,
@@ -655,17 +845,20 @@ export class GuiController {
655
845
  }
656
846
 
657
847
  private async runOneAgent(
848
+ mainSession: AgentSession,
658
849
  session: AgentSession,
659
850
  agentId: AgentId,
660
851
  input: string,
661
852
  isSub: boolean,
662
853
  ): Promise<string> {
663
854
  this.setActiveAgent(agentId, session);
664
- this.ttsStream.setVoice(agentId);
855
+ if (session === this.activeSession || this.isFrontSub(session)) {
856
+ this.ttsStream.setVoice(agentId);
857
+ }
665
858
 
666
859
  if (isSub) {
667
- // 子 Agent:复制主 session 全量群聊日志作为上下文(浅拷贝,元素引用共享)
668
- session.agent.state.messages = [...(this.session.agent.state.messages as any[])];
860
+ // 子 Agent:复制所属主 session 全量群聊日志作为上下文(浅拷贝,元素引用共享)
861
+ session.agent.state.messages = [...(mainSession.agent.state.messages as any[])];
669
862
  }
670
863
 
671
864
  const stateMessages = session.agent.state.messages as any[];
@@ -673,8 +866,8 @@ export class GuiController {
673
866
 
674
867
  const promptText = isSub
675
868
  ? t(
676
- `(你是${getAgentLabel(agentId)}。现在轮到你发言——保持你自己的身份和语气,不要扮演或模仿其他角色。请简短发言。)`,
677
- `(You are ${getAgentLabel(agentId)}. It's your turn — stay in your own character and voice; do not play or mimic another character. Speak briefly.)`,
869
+ `(你是${getAgentLabel(agentId)}。现在轮到你发言——保持你自己的身份和语气,不要扮演或模仿其他角色。直接说台词,不要以「${getAgentLabel(agentId)}:」这类名字前缀开头。请简短发言。)`,
870
+ `(You are ${getAgentLabel(agentId)}. It's your turn — stay in your own character and voice; do not play or mimic another character. Speak your line directly, without starting with a name prefix like "${getAgentLabel(agentId)}:". Speak briefly.)`,
678
871
  )
679
872
  : input;
680
873
 
@@ -685,6 +878,17 @@ export class GuiController {
685
878
  return "";
686
879
  }
687
880
 
881
+ // 输出侧兜底:模型偶发模仿历史消息把「星野:」这类前缀写进台词,统一剥掉
882
+ // (与 Repl.runOneAgent 同步;否则下轮 speaker 扩展会叠出双重前缀)
883
+ for (const m of stateMessages.slice(startLen)) {
884
+ if (m.role !== "assistant" || !Array.isArray(m.content)) continue;
885
+ for (const b of m.content) {
886
+ if (b.type === "text" && typeof b.text === "string" && b.text) {
887
+ b.text = stripSpeakerPrefix(b.text, agentId);
888
+ }
889
+ }
890
+ }
891
+
688
892
  const text = this.extractNewAssistantText(stateMessages, startLen);
689
893
  if (!text) return "";
690
894
 
@@ -693,7 +897,7 @@ export class GuiController {
693
897
  if (m.role === "assistant") m.speaker = agentId;
694
898
  }
695
899
  } else {
696
- (this.session.agent.state.messages as any[]).push({
900
+ (mainSession.agent.state.messages as any[]).push({
697
901
  role: "assistant",
698
902
  speaker: agentId,
699
903
  content: [{ type: "text", text }],
@@ -703,55 +907,62 @@ export class GuiController {
703
907
  }
704
908
 
705
909
  async runRawTurn(input: string): Promise<void> {
910
+ // 回合上下文整体局部化到槽位:回合进行中用户可切换会话/工作区,
911
+ // 本回合在后台继续,结束时按槽位存盘(不再读写"当前会话"的易变状态)
912
+ const turnSession = this.activeSession;
913
+ const turnSlot = this.slotOf(turnSession);
914
+ turnSlot.hasConversation = true;
915
+ turnSlot.abortRequested = false;
916
+ turnSlot.processing = true;
917
+ this.turnStack.push(turnSlot);
706
918
  memory.markConversation();
707
- this.isProcessing = true;
708
- this.turnEnded = false;
709
919
  this.ttsStream.cancel();
710
920
  this.hidePetBubble();
711
- await this.undoManager.beforeTurn();
921
+ this.emit({ type: "ready", state: this.buildState() });
922
+ await turnSlot.undo.beforeTurn();
712
923
 
713
924
  try {
714
- await this.ensureSubSessions();
925
+ await this.ensureSubSessions(turnSession);
715
926
 
716
927
  // 记忆增量检测:MEMORY.md 运行时变更 → 追加到下一轮主 Agent 的 user 消息末尾
717
928
  const memoryDelta = memory.getMemoryDelta();
718
929
  const mainInput = memoryDelta ? `${input}\n\n${memoryDelta}` : input;
719
930
 
720
- await this.runOneAgent(this.session, getMainAgent(), mainInput, false);
721
- if (this.aborted) return;
931
+ await this.runOneAgent(turnSession, turnSession, getMainAgent(), mainInput, false);
932
+ if (turnSlot.abortRequested) return;
722
933
  await this.waitTurnSettled(getMainAgent());
723
- if (this.aborted) return;
934
+ if (turnSlot.abortRequested) return;
724
935
 
936
+ const subs = turnSlot.subs;
725
937
  for (const subId of getSubAgents()) {
726
- const subSession = this.subSessions.get(subId);
938
+ const subSession = subs.get(subId);
727
939
  if (!subSession) continue;
728
- await this.runOneAgent(subSession, subId, input, true);
729
- if (this.aborted) return;
940
+ await this.runOneAgent(turnSession, subSession, subId, input, true);
941
+ if (turnSlot.abortRequested) return;
730
942
  await this.waitTurnSettled(subId);
731
- if (this.aborted) return;
943
+ if (turnSlot.abortRequested) return;
732
944
  }
733
945
 
734
- this.setActiveAgent(getMainAgent(), this.session);
946
+ this.setActiveAgent(getMainAgent(), turnSession);
735
947
  } catch (err) {
736
948
  this.notice("error", t("错误:", "Error: ") + (err instanceof Error ? err.message : err));
737
949
  } finally {
738
950
  try {
739
- await this.undoManager.afterTurn();
951
+ await turnSlot.undo.afterTurn();
740
952
  } catch (err) {
741
953
  this.notice("warn", t(`撤销快照记录失败:${err instanceof Error ? err.message : err}`, `Failed to record undo snapshot: ${err instanceof Error ? err.message : err}`));
742
954
  }
743
- this.isProcessing = false;
955
+ turnSlot.processing = false;
744
956
  this.turnEnded = true;
745
- // 每回合结束自动保存(静默:有效对话才落盘;resume 会话覆盖原文件),并刷新侧栏让新会话可见
746
- this.saveCurrentSessionIfNeeded();
957
+ this.turnStack = this.turnStack.filter((s) => s !== turnSlot);
958
+ // 每回合结束自动存盘(静默;resume 会话覆盖原文件)——前台后台一视同仁,并刷新侧栏
959
+ this.saveSlot(turnSlot, turnSession);
747
960
  this.pushSessions();
748
- if (!this.ttsStream.isPending) {
749
- pet.reset();
750
- this.scheduleBubbleHide();
751
- }
752
- if (this.aborted) {
753
- this.aborted = false;
754
- return;
961
+ if (turnSession === this.activeSession) {
962
+ if (!this.ttsStream.isPending) {
963
+ pet.reset();
964
+ this.scheduleBubbleHide();
965
+ }
755
966
  }
756
967
  this.emit({ type: "ready", state: this.buildState() });
757
968
  }
@@ -762,10 +973,11 @@ export class GuiController {
762
973
  // ============================================================
763
974
 
764
975
  abort(): void {
765
- if (!this.isProcessing) return;
976
+ const slot = this.activeSlot;
977
+ if (!slot.processing) return;
766
978
  this.activeSession.abort().catch(() => {});
767
- this.isProcessing = false;
768
- this.aborted = true;
979
+ slot.abortRequested = true;
980
+ slot.processing = false; // UI 立即恢复;runRawTurn 的 finally 兜底收尾
769
981
  this.ttsStream.cancel();
770
982
  this.hidePetBubble();
771
983
  this.notice("info", "[aborted]");
@@ -779,7 +991,7 @@ export class GuiController {
779
991
  this.notice("info", t("STT 已关闭(用 /stt 打开)。", "STT is off (use /stt to enable)."));
780
992
  return;
781
993
  }
782
- if (this.isProcessing) {
994
+ if (this.activeSlot.processing) {
783
995
  // 任务中录音:先中断当前任务(对齐 CLI triggerStt 行为)
784
996
  this.abort();
785
997
  }
@@ -805,7 +1017,15 @@ export class GuiController {
805
1017
  }
806
1018
 
807
1019
  async doExit(): Promise<void> {
808
- this.saveCurrentSessionIfNeeded(false); // 退出保留保存提示
1020
+ // 所有会话槽位落盘(前台保留「已保存」提示;后台/生成中的会话按各自工作区静默保存)
1021
+ const active = this.activeSlot;
1022
+ const activeHadPath = active.path !== null;
1023
+ for (const slot of this.slots.values()) {
1024
+ this.saveSlot(slot, slot.session, slot !== active);
1025
+ }
1026
+ if (!activeHadPath && active.path && active.hasConversation) {
1027
+ this.notice("success", t(`会话已保存`, "Session saved"));
1028
+ }
809
1029
  this.ttsStream.shutdown();
810
1030
  stopGptSovitsLocalServer();
811
1031
  stopComputerUse();
@@ -815,12 +1035,18 @@ export class GuiController {
815
1035
  // 清理失败不影响退出
816
1036
  }
817
1037
  stopPet();
818
- this.session.dispose();
819
- for (const subSession of this.subSessions.values()) {
1038
+ for (const slot of this.slots.values()) {
820
1039
  try {
821
- subSession.dispose();
1040
+ slot.session.dispose();
822
1041
  } catch {
823
- // 子 session 清理失败不影响退出
1042
+ // 清理失败不影响退出
1043
+ }
1044
+ for (const subSession of slot.subs.values()) {
1045
+ try {
1046
+ subSession.dispose();
1047
+ } catch {
1048
+ // 子 session 清理失败不影响退出
1049
+ }
824
1050
  }
825
1051
  }
826
1052
  this.emit({ type: "exiting" });