arona-agent 1.1.2 → 1.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arona-agent",
3
- "version": "1.1.2",
3
+ "version": "1.1.3",
4
4
  "description": "Terminal AI Agent with desktop pet Arona — eye-tracking pupils, voice cloning, Computer Use, TTS/STT, MCP.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/pet/main.cjs CHANGED
@@ -28,9 +28,9 @@ const AGENT_IDS = [MAIN_AGENT_ID, ...SUB_AGENT_IDS];
28
28
 
29
29
  const PREFIX = "###PET###";
30
30
  const POS_FILE = path.join(os.homedir(), ".arona", "pet.json");
31
- const WIN_W = 580; // 窗口总宽:左侧 320px Spine 角色渲染区,右侧 260px 为文字气泡专用区(避免气泡遮脸)
31
+ const WIN_W = 320; // 窗口 = Spine 角色渲染区本体(收窄以避免透明区域拦截点击造成误触;角色尺寸不变)
32
32
  const WIN_H = 674;
33
- const SUB_OFFSET_X = 620; // 子窗口默认横向错开(> WIN_W,避免角色+气泡区重叠)
33
+ const SUB_OFFSET_X = 340; // 子窗口默认横向错开(略大于 WIN_W
34
34
  const SUB_OFFSET_Y = 40;
35
35
 
36
36
  // --verbose(src/pet.ts 注入 ARONA_PET_VERBOSE=1 + --enable-logging):
@@ -282,6 +282,62 @@ ipcMain.on("pet:fx-up", () => {
282
282
  if (fxWin && !fxWin.isDestroyed()) fxWin.webContents.send("fx:up");
283
283
  });
284
284
 
285
+ // ---- 文字气泡:画在全屏特效窗上(桌宠窗口已收窄为角色本体,不再有气泡区) ----
286
+ // 特效窗鼠标穿透(setIgnoreMouseEvents),气泡纯展示不会拦点击;层级高于桌宠。
287
+ // 锚点沿用旧窗口内定位的观感:气泡出现在头部右上方、小尾巴指向角色;右侧越出
288
+ // 所在显示器时翻到左侧(flip,fx 层镜像小尾巴)。
289
+ const BUBBLE_ANCHOR_X = 264; // 默认:气泡左缘相对桌宠窗口左上角
290
+ const BUBBLE_ANCHOR_Y = 90;
291
+ const BUBBLE_MAX_W = 220; // 与 fx 层样式 max-width 一致
292
+ const BUBBLE_FLIP_X = -196; // 翻转:气泡左缘相对桌宠窗口左上角(右缘留 ~24px 缝隙给小尾巴)
293
+ const BUBBLE_FLIP_PAD = 12; // 翻转阈值缓冲:贴屏幕右缘多少 px 内就算溢出
294
+
295
+ /** 桌宠窗口当前姿态 → 特效窗本地气泡锚点 { x, y, flip };特效窗/角色窗不可用时返回 null */
296
+ function bubbleAnchorLocal(agentId) {
297
+ const bw = windowByAgent(agentId);
298
+ if (!bw || bw.isDestroyed() || !fxWin || fxWin.isDestroyed()) return null;
299
+ const [wx, wy] = bw.getPosition();
300
+ const [fxX, fxY] = fxWin.getPosition();
301
+ let flip = false;
302
+ let gx = wx + BUBBLE_ANCHOR_X;
303
+ try {
304
+ const d = screen.getDisplayNearestPoint({ x: wx, y: wy });
305
+ const rightEdge = d.bounds.x + d.bounds.width;
306
+ if (gx + BUBBLE_MAX_W + BUBBLE_FLIP_PAD > rightEdge) {
307
+ // 右侧放不下 → 翻到角色左侧
308
+ flip = true;
309
+ gx = wx + BUBBLE_FLIP_X;
310
+ }
311
+ gx = Math.max(d.bounds.x + BUBBLE_FLIP_PAD, Math.min(gx, rightEdge - BUBBLE_FLIP_PAD));
312
+ // 垂直方向钳回所在显示器(粗略按气泡最大高度 ~140px 预留)
313
+ const gy = Math.max(d.bounds.y, Math.min(wy + BUBBLE_ANCHOR_Y, d.bounds.y + d.bounds.height - 140));
314
+ return { x: Math.round(gx - fxX), y: Math.round(gy - fy), flip };
315
+ } catch {
316
+ return { x: Math.round(gx - fxX), y: Math.round(wy + BUBBLE_ANCHOR_Y - fxY), flip };
317
+ }
318
+ }
319
+
320
+ /** 把某角色的文字消息转成特效窗上的气泡 show/hide */
321
+ function forwardBubble(agentId, kind, data) {
322
+ if (!fxWin || fxWin.isDestroyed()) return;
323
+ if (kind === "tts_end") {
324
+ fxWin.webContents.send("pet:bubble", { agent: agentId, kind: "hide" });
325
+ return;
326
+ }
327
+ if (typeof data !== "string" || !data) return;
328
+ const pos = bubbleAnchorLocal(agentId);
329
+ if (!pos) return;
330
+ fxWin.webContents.send("pet:bubble", { agent: agentId, kind: "show", data, ...pos });
331
+ }
332
+
333
+ /** 拖动中/落位后同步气泡锚点(气泡已显示时 fx 层原地位移,未显示则忽略) */
334
+ function syncBubblePosition(agentId) {
335
+ if (!fxWin || fxWin.isDestroyed()) return;
336
+ const pos = bubbleAnchorLocal(agentId);
337
+ if (!pos) return;
338
+ fxWin.webContents.send("pet:bubble", { agent: agentId, kind: "move", ...pos });
339
+ }
340
+
285
341
  // ---- 全局光标轮询(~60Hz,DIP 坐标系内运算,供渲染层瞳孔跟随 + 按住期间晃动检测补采样) ----
286
342
  // 对每个桌宠窗口分别发送窗口本地坐标 + 全局坐标(gx/gy:renderer 按住期间用于晃动检测——
287
343
  // 光标快速甩动划出窗口时 mousemove 断流,本轮询不断流,轻微出窗仍能采到晃动)
@@ -324,6 +380,7 @@ ipcMain.on("pet:drag", (e, dx, dy) => {
324
380
  if (p && windowByAgent(agentId)) {
325
381
  windowByAgent(agentId).setPosition(Math.round(p.x), Math.round(p.y));
326
382
  dragPendingMap.delete(agentId);
383
+ syncBubblePosition(agentId); // 气泡跟随拖动
327
384
  }
328
385
  }, 16));
329
386
  }
@@ -343,6 +400,7 @@ ipcMain.on("pet:dragend", (e) => {
343
400
  bw.setPosition(Math.round(pending.x), Math.round(pending.y));
344
401
  dragPendingMap.delete(agentId);
345
402
  }
403
+ syncBubblePosition(agentId); // 落位后校正气泡锚点(flip 状态也可能变化)
346
404
  const [x, y] = bw.getPosition();
347
405
  savePosition(agentId, x, y);
348
406
  send({ type: "moved", agent: agentId, x, y });
@@ -385,7 +443,8 @@ function handleMessage(msg) {
385
443
  break;
386
444
  case "text": {
387
445
  const id = msg.agent && AGENTS[msg.agent] ? msg.agent : MAIN_AGENT_ID;
388
- sendToAgent(id, "pet:text", { kind: msg.kind, data: msg.data });
446
+ // 气泡已迁出桌宠窗口(窗口收窄为角色本体),统一渲染在全屏特效窗上
447
+ forwardBubble(id, msg.kind, msg.data);
389
448
  break;
390
449
  }
391
450
  case "tts_level": {
package/pet/preload.cjs CHANGED
@@ -3,7 +3,6 @@ const { contextBridge, ipcRenderer } = require("electron");
3
3
  contextBridge.exposeInMainWorld("petAPI", {
4
4
  onEmotion: (cb) => ipcRenderer.on("pet:emotion", (_e, name) => cb(name)),
5
5
  onReset: (cb) => ipcRenderer.on("pet:reset", () => cb()),
6
- onText: (cb) => ipcRenderer.on("pet:text", (_e, p) => cb(p)),
7
6
  // TTS 播放中实时音量(RMS 0~1)→ 嘴型 lip-sync
8
7
  onTtsLevel: (cb) => ipcRenderer.on("pet:tts-level", (_e, rms) => cb(rms)),
9
8
  getAgentConfig: () => ipcRenderer.invoke("pet:get-agent-config"),
@@ -20,4 +19,6 @@ contextBridge.exposeInMainWorld("petAPI", {
20
19
  onFxDown: (cb) => ipcRenderer.on("fx:down", (_e, x, y) => cb(x, y)),
21
20
  onFxMove: (cb) => ipcRenderer.on("fx:move", (_e, x, y) => cb(x, y)),
22
21
  onFxUp: (cb) => ipcRenderer.on("fx:up", () => cb()),
22
+ // 文字气泡:main.cjs 把桌宠窗口姿态换算成本地坐标后发到这里(fx 层专用)
23
+ onBubble: (cb) => ipcRenderer.on("pet:bubble", (_e, p) => cb(p)),
23
24
  });
@@ -20,10 +20,55 @@
20
20
  height: 100%;
21
21
  pointer-events: none;
22
22
  }
23
+ /* 文字气泡容器:铺满窗口,纯展示(特效窗整体 setIgnoreMouseEvents) */
24
+ #fx-bubbles {
25
+ position: absolute;
26
+ inset: 0;
27
+ pointer-events: none;
28
+ z-index: 5;
29
+ }
30
+ /* 角色语音气泡(从桌宠窗口迁入):位置由 main.cjs 按桌宠窗口姿态换算后下发,
31
+ 显示在角色头部侧上方;.flip 时小尾巴镜像到右侧指向角色。淡出用 opacity 过渡 */
32
+ .bubble {
33
+ position: absolute;
34
+ max-width: 220px;
35
+ padding: 8px 12px;
36
+ box-sizing: border-box;
37
+ background: rgba(20, 22, 34, 0.82);
38
+ border-radius: 14px;
39
+ color: #fff;
40
+ font-size: 13px;
41
+ line-height: 1.45;
42
+ text-align: left;
43
+ white-space: pre-line;
44
+ word-break: break-all;
45
+ opacity: 1;
46
+ transition: opacity 0.25s ease;
47
+ }
48
+ .bubble::after {
49
+ content: "";
50
+ position: absolute;
51
+ left: -12px;
52
+ top: 50%;
53
+ transform: translateY(-50%);
54
+ border-top: 8px solid transparent;
55
+ border-bottom: 8px solid transparent;
56
+ border-right: 12px solid rgba(20, 22, 34, 0.82);
57
+ }
58
+ .bubble.flip::after {
59
+ left: auto;
60
+ right: -12px;
61
+ border-right: none;
62
+ border-left: 12px solid rgba(20, 22, 34, 0.82);
63
+ }
64
+ .bubble.hidden {
65
+ opacity: 0;
66
+ }
23
67
  </style>
24
68
  </head>
25
69
  <body>
26
70
  <div id="fx"></div>
71
+ <div id="fx-bubbles"></div>
27
72
  <script src="../../node_modules/ba-click-fx/dist/ba-click-fx.iife.js"></script>
28
73
  <script src="fx.js"></script>
29
74
  </body>
@@ -38,4 +38,38 @@ window.petAPI.onFxUp(() => {
38
38
  if (fx) fx.pointerUp(1);
39
39
  });
40
40
 
41
+ // ---- 文字气泡:按角色各维护一个节点(气泡已从桌宠窗口迁到这里渲染) ----
42
+ // main.cjs 下发的消息:
43
+ // { agent, kind: "show", data, x, y, flip } — 上屏文本并定位(flip=角色在右侧,尾巴镜像)
44
+ // { agent, kind: "move", x, y, flip } — 仅更新位置(拖动跟随;未显示时无副作用)
45
+ // { agent, kind: "hide" } — 淡出(TTS 播完 / 兜底定时器 / 打断)
46
+ const bubbles = new Map();
47
+
48
+ function ensureBubble(agentId) {
49
+ let el = bubbles.get(agentId);
50
+ if (!el) {
51
+ el = document.createElement("div");
52
+ el.className = "bubble hidden";
53
+ document.getElementById("fx-bubbles").appendChild(el);
54
+ bubbles.set(agentId, el);
55
+ }
56
+ return el;
57
+ }
58
+
59
+ window.petAPI.onBubble((msg) => {
60
+ const agentId = String(msg?.agent ?? "main");
61
+ if (msg.kind === "hide") {
62
+ bubbles.get(agentId)?.classList.add("hidden");
63
+ return;
64
+ }
65
+ const el = ensureBubble(agentId);
66
+ el.style.left = `${Math.round(msg.x)}px`;
67
+ el.style.top = `${Math.round(msg.y)}px`;
68
+ el.classList.toggle("flip", !!msg.flip);
69
+ if (msg.kind === "show" && typeof msg.data === "string" && msg.data) {
70
+ el.textContent = msg.data;
71
+ el.classList.remove("hidden");
72
+ }
73
+ });
74
+
41
75
  initFx();
@@ -8,7 +8,6 @@
8
8
  <body>
9
9
  <div id="stage">
10
10
  <canvas id="spine"></canvas>
11
- <div id="pet-bubble" class="hidden"></div>
12
11
  </div>
13
12
  <script src="../vendor/spine/spine-webgl.js"></script>
14
13
  <script src="../vendor/spine/spine-canvas.js"></script>
@@ -104,9 +104,9 @@ const PAT_TURN_PX = 20; // 单次"有效换向"所需的最小半波位
104
104
  const DIZZY_TURNS = 2; // 换向次数阈值(一来一回)
105
105
  const DIZZY_AMPLITUDE = 150; // 窗口内单轴摆幅极差阈值(px)——"大幅"档位,待实测调整
106
106
  const DIZZY_RECOVER_MS = 2000; // 松手后保持晕脸的时长,之后自动回待机
107
- // 头部区域:x 比例基于左侧 320px 渲染区(spineCanvas.clientWidth),y 基于窗口高。
108
- // ⚠️ x 不能按整窗 innerWidth(580 = 320 渲染 + 260 气泡)算:0.26×580≈151px 比头部实际
109
- // 左缘(渲染区内 ~110px)偏右,会把头部左边缘切掉——左侧长按拖动永不触发摸头(实测)。
107
+ // 头部区域:x 比例基于 320px 渲染区宽(spineCanvas.clientWidth——旧版窗口曾含 260px 气泡区,
108
+ // 若按整窗 innerWidth x 会把头部左缘切掉;现窗口已收窄为渲染区本体,两者恰好相等,
109
+ // 但仍按 clientWidth 计算以防未来再加非渲染区域),y 基于窗口高。
110
110
  // Spine 姿势实测头部在 CSS y 119~180。
111
111
  const HEAD_BOX = { xMin: 0.26, xMax: 0.78, yMin: 0.06, yMax: 0.29 };
112
112
  // 摸头期间"离开头部"判定缓冲(px):用户反馈 16px 太严苛,先放宽 50px、再要求更宽 → 90px;
@@ -300,20 +300,7 @@ function onMouseUp() {
300
300
  }
301
301
  }
302
302
 
303
- // ---- 文字气泡 ----
304
- const petBubble = document.getElementById("pet-bubble");
305
- let bubbleTimer = null;
306
-
307
- function showBubble(text) {
308
- if (!petBubble) return;
309
- petBubble.textContent = text;
310
- petBubble.classList.remove("hidden");
311
- }
312
-
313
- function hideBubble() {
314
- if (!petBubble) return;
315
- petBubble.classList.add("hidden");
316
- }
303
+ // ---- 文字气泡:已迁至全屏特效窗(fx.html/fx.js),本窗口不再渲染气泡 ----
317
304
 
318
305
  async function init() {
319
306
  // 情绪预设映射:agents.cjs(与 main 进程白名单同源)值 = 预设动画名;
@@ -360,23 +347,6 @@ async function init() {
360
347
  window.SpineLayer.setMouthLevel(rms);
361
348
  });
362
349
 
363
- // 文字气泡:只显示 mid/final 短消息;等到 TTS 播放结束(tts_end)再消失
364
- window.petAPI.onText(({ kind, data }) => {
365
- if (!petBubble) return;
366
- if (kind === "tts_end") {
367
- if (bubbleTimer) clearTimeout(bubbleTimer);
368
- bubbleTimer = null;
369
- hideBubble();
370
- return;
371
- }
372
- // mid / final
373
- if (typeof data === "string" && data.length > 0) {
374
- if (bubbleTimer) clearTimeout(bubbleTimer);
375
- bubbleTimer = null;
376
- showBubble(data);
377
- }
378
- });
379
-
380
350
  document.addEventListener("mousedown", onMouseDown);
381
351
  document.addEventListener("mousemove", onMouseMove);
382
352
  document.addEventListener("mouseup", onMouseUp);
@@ -31,47 +31,4 @@ body:active {
31
31
  pointer-events: none;
32
32
  z-index: 1;
33
33
  }
34
-
35
-
36
- /* 桌宠文字气泡:定位到角色头部右侧,贴近角色(不遮脸、不高高飘在右上);
37
- top 取头部高度(头 CSS y 40~195),left 270px 贴近头部右缘(≈250)留 ~20px 缝隙,
38
- 由 ::after 小尾巴向左伸进缝隙指向角色(视觉上"连到"角色,不显远);
39
- 高于 Spine、鼠标穿透;只显示 mid/final 短消息 */
40
- #pet-bubble {
41
- position: absolute;
42
- left: 270px;
43
- top: 90px;
44
- transform: none;
45
- max-width: 220px;
46
- padding: 8px 12px;
47
- box-sizing: border-box;
48
- background: rgba(20, 22, 34, 0.82);
49
- border-radius: 14px;
50
- color: #fff;
51
- font-size: 13px;
52
- line-height: 1.45;
53
- text-align: left;
54
- white-space: pre-line;
55
- word-break: break-all;
56
- pointer-events: none;
57
- z-index: 10;
58
- opacity: 1;
59
- transition: opacity 0.25s ease;
60
- }
61
-
62
- /* 左指小三角尾巴:从气泡左缘向左伸进气泡与角色头部的缝隙里,指向角色。
63
- 随父级 hidden / opacity 过渡一起隐藏。top 取气泡中部即可,位置可按需微调 */
64
- #pet-bubble::after {
65
- content: "";
66
- position: absolute;
67
- left: -12px;
68
- top: 50%;
69
- transform: translateY(-50%);
70
- border-top: 8px solid transparent;
71
- border-bottom: 8px solid transparent;
72
- border-right: 12px solid rgba(20, 22, 34, 0.82);
73
- }
74
-
75
- #pet-bubble.hidden {
76
- opacity: 0;
77
- }
34
+ /* 文字气泡已迁至全屏特效窗(fx.html 的 .bubble),桌宠窗口只保留角色渲染区 */
package/src/agent.ts CHANGED
@@ -20,7 +20,7 @@ import { createSkillTools } from "./tools/skill_tools.ts";
20
20
  import { readDocsTool } from "./tools/read_docs_tool.ts";
21
21
  import { connectMcpServers } from "./mcp.ts";
22
22
  import { InMemoryCredentialStore } from "./in_memory_credentials.ts";
23
- import { getMainAgent, type SubAgentId, type AgentId } from "./agent_registry.ts";
23
+ import { getMainAgent, getAgentLabel, type SubAgentId, type AgentId } from "./agent_registry.ts";
24
24
  import { speakerContextExtension } from "./speaker_context.ts";
25
25
  import { gestureContextExtension } from "./gesture_context.ts";
26
26
  import { t, getLang } from "./locale.ts";
@@ -593,7 +593,7 @@ export async function initAgent(): Promise<{
593
593
 
594
594
  // ============================================================
595
595
  // 子 Agent(白子 / 星野)—— 纯聊天角色,仅 change_emotion + keep_silent
596
- // 人设全文硬编码(已内联,不再读外部文件)
596
+ // 人设全文硬编码
597
597
  // ============================================================
598
598
 
599
599
  const SUB_PERSONA_ZH: Record<SubAgentId, string> = {
@@ -668,6 +668,7 @@ function buildSubSystemPrompt(id: SubAgentId, memoryContent: string): string {
668
668
  # Group Chat Rules
669
669
 
670
670
  - You are one of several desktop-pet characters chatting with Sensei (the user).
671
+ - You are ${getAgentLabel(id)}. Always speak as yourself — do not play, mimic, or mix in the identity or tone of any other character (Arona, Plana, Shiroko, Hoshino, Hanako, Koharu).
671
672
  - After the main agent finishes replying, each enabled sub-agent takes a turn. Keep your reply SHORT (one or two sentences), natural, in-character, and add nothing but your own spoken line.
672
673
  - Do not repeat or summarize the main agent's reply.
673
674
  - In the conversation history, assistant messages carry a \`Name:\` prefix showing who said them (e.g. "Arona:", "Shiroko:", etc.); user inputs are Sensei speaking. When you reply, do NOT add any name prefix.
@@ -681,6 +682,7 @@ Available tools: change_emotion (set the emotion before speaking), keep_silent (
681
682
  # 群聊规则
682
683
 
683
684
  - 你是多个桌宠角色之一,正在陪老师聊天。
685
+ - 你是${getAgentLabel(id)}。始终以自己的身份发言,禁止扮演或模仿或混用其他角色(阿洛娜、普拉娜、白子、星野、花子、小春)的身份与语气。
684
686
  - 主 Agent 回复完毕后,每个启用的子 Agent 依次发言。回复保持简短(一两句),贴角色,只说自己的台词。
685
687
  - 不要复读或总结主 Agent 的话。
686
688
  - 对话历史中,assistant 消息带「角色名:」前缀标明发言者(如「阿洛娜:」「砂狼白子:」等);用户输入是 Sensei 说的。你发言时不要加名字前缀。
package/src/logo.ts CHANGED
@@ -7,6 +7,10 @@
7
7
 
8
8
  import figlet from "figlet";
9
9
 
10
+ // Fonts 是 @types/figlet 里 export = 命名空间的成员类型,默认导入/具名导入都拿不到;
11
+ // 用 loadFont 的首参反推出等价的字体名联合类型。
12
+ type FigletFont = Parameters<typeof figlet.loadFont>[0];
13
+
10
14
  // 配色(与原 launcher.sh 一致)
11
15
  const C = "\x1b[38;2;0;210;255m"; // CYAN #00D2FF 边框 + 底部字母
12
16
  const B = "\x1b[38;2;66;135;245m"; // BLUE #4287F5 顶部字母
@@ -21,7 +25,7 @@ const R = "\x1b[0m"; // RESET
21
25
  // 4) 平面字体(最后兜底):按宽度降序,每个字体也是先 AGENT 再 ARONA
22
26
  //
23
27
  // 列数估算已留出边框 2 格 + 缩进 2 格 + 内边距 6 格(即 width + 10)
24
- const VARIANTS: { text: string; font: figlet.Fonts }[] = [
28
+ const VARIANTS: { text: string; font: FigletFont }[] = [
25
29
  // —— ANSI Shadow 绝对优先 ——
26
30
  { text: "ARONA AGENT", font: "ANSI Shadow" }, // ~101 列
27
31
  { text: "ARONA", font: "ANSI Shadow" }, // ~53 列
package/src/repl.ts CHANGED
@@ -77,6 +77,10 @@ export class Repl {
77
77
  // 斜杠命令菜单(渲染与状态机封装在 SlashMenu 中)
78
78
  private menu = new SlashMenu();
79
79
  private menuKeyListener: ((s: any, k: any) => void) | null = null;
80
+ // 本次按键中被菜单消费、需要拦截 readline 内置处理的键名(up/down 的历史回溯)。
81
+ // 注意 readline 有自己的 stdin "keypress" 监听器,prependListener 只保证我们先执行,
82
+ // 无法阻止它处理同一按键——因此 start() 里包装 rl._ttyWrite,读到该字段时短路内置逻辑。
83
+ private swallowedKeyName: string | null = null;
80
84
  // 本地快照式 undo/redo 管理器(不依赖 git)
81
85
  private undoManager: UndoManager;
82
86
 
@@ -170,7 +174,7 @@ export class Repl {
170
174
  (this.rl as any).line = "";
171
175
  (this.rl as any).cursor = 0;
172
176
  process.stdout.write(chalk.yellow("\n[aborted]\n"));
173
- // 标记刚中断过,下一次 Ctrl+C 直接退出(不再提示"再按一次")
177
+ // 标记刚中断过,下一次 Ctrl+C 直接退出
174
178
  this.sigintCount = 1;
175
179
  if (this.sigintTimer) clearTimeout(this.sigintTimer);
176
180
  this.sigintTimer = setTimeout(() => {
@@ -528,7 +532,23 @@ export class Repl {
528
532
  (process.stdin as any).setRawMode?.(true);
529
533
  }
530
534
 
531
- // 解析热键已移除:STT 热键改为全局监听(pynput),见 startHotkeyHook()
535
+ // 包装 readline 内部的 _ttyWrite:被菜单消费的导航键(见 swallowedKeyName,由
536
+ // menuKeyListener 置位)在此短路,readline 不再做历史回溯/移动光标。此前菜单打开时
537
+ // 按 ↑ 会同时触发 menu.move 和内置历史回溯——输入行直接被"上次执行的命令"覆盖。
538
+ // 每次调用后立即清标志:一个 keypress 事件只吞一次。
539
+ // _ttyWrite 是 readline 私有 API,带 typeof 守卫:不可用时优雅降级为旧行为。
540
+ const rlAny = this.rl as any;
541
+ if (typeof rlAny._ttyWrite === "function") {
542
+ const origTtyWrite = rlAny._ttyWrite;
543
+ rlAny._ttyWrite = (s: string, key: any) => {
544
+ const swallowed = !!key?.name && this.swallowedKeyName === key.name;
545
+ this.swallowedKeyName = null;
546
+ if (swallowed) return;
547
+ origTtyWrite.call(this.rl, s, key);
548
+ };
549
+ }
550
+
551
+ // STT 热键为全局监听(pynput),见 startHotkeyHook()
532
552
 
533
553
  this.rl.prompt();
534
554
 
@@ -540,8 +560,15 @@ export class Repl {
540
560
  if (this.menu.isOpen()) {
541
561
  if (key.name === "escape") { this.menu.close(this.rl); return; }
542
562
  if (key.name === "up" || key.name === "down") {
543
- this.menu.move(key.name === "up" ? -1 : 1, this.rl);
544
- return; // 不转发给 readline(避免移动输入光标)
563
+ if (this.menu.move(key.name === "up" ? -1 : 1, this.rl)) {
564
+ // 菜单消费了这次导航:吞掉,readline 不再做历史回溯/移动输入光标
565
+ this.swallowedKeyName = key.name;
566
+ } else {
567
+ // 菜单顶部按 ↑(仅此边界):放行给 readline 做历史回溯——读取上次执行的命令。
568
+ // 缓冲区被改写后在微任务里刷新菜单(不再是斜杠前缀/完整命令名时自动关闭)。
569
+ queueMicrotask(() => this.menu.refresh(this.rl));
570
+ }
571
+ return;
545
572
  }
546
573
  // 行末再按 →:把选中指令填入输入框但不执行(用户可随后补参数/编辑,
547
574
  // 再自己 Enter)。光标不在行末时落到 readline 正常移动光标。
@@ -632,7 +659,7 @@ export class Repl {
632
659
  }
633
660
 
634
661
  private async processInput(input: string) {
635
- // 桌宠手势(摸头/dizzy)不再拼进用户消息:落到主 Agent 发送边界注入(gesture_context.ts),
662
+ // 桌宠手势(摸头/dizzy)落到主 Agent 发送边界注入(gesture_context.ts),
636
663
  // 不进 state.messages → 子 Agent 复制主 session 历史时看不到、会话命名/存储零污染。
637
664
  // takeGesture 消费即清空由发送边界扩展完成,只注入最近一次。
638
665
  // 展开 @文件 / !命令 后走完整回合生命周期
@@ -771,7 +798,10 @@ export class Repl {
771
798
 
772
799
  // 子 Agent 的触发消息:固定短句(上下文在复制来的全量日志里,这里只负责"叫醒"它发言)
773
800
  const promptText = isSub
774
- ? t("(现在轮到你,请简短发言)", "(It's your turn now, speak briefly)")
801
+ ? t(
802
+ `(你是${getAgentLabel(agentId)}。现在轮到你发言——保持你自己的身份和语气,不要扮演或模仿其他角色。请简短发言。)`,
803
+ `(You are ${getAgentLabel(agentId)}. It's your turn — stay in your own character and voice; do not play or mimic another character. Speak briefly.)`,
804
+ )
775
805
  : input;
776
806
 
777
807
  try {
@@ -855,13 +885,16 @@ export class Repl {
855
885
  console.warn(chalk.yellow(t(`撤销快照记录失败:${err instanceof Error ? err.message : err}`, `Failed to record undo snapshot: ${err instanceof Error ? err.message : err}`)));
856
886
  }
857
887
  this.isProcessing = false;
858
- // 回合结束:恢复桌宠到 idle
859
- // - TTS 启用 + 无残余播放 → 立即 reset;有残余由 onIdle(play_end) 兜底
860
- // - TTS 禁用(--no-voice / 缺音色 / 切到无音色角色)→ 5s 后再撤表情和气泡
861
888
  this.turnEnded = true;
862
- if (voice.isTtsEnabledFor(this.activeAgentId)) {
863
- if (!this.ttsStream.isPending) pet.reset();
864
- } else {
889
+ // 回合结束:恢复桌宠到 idle + 气泡兜底隐藏。
890
+ // 以"本回合实际是否还有待播内容"(isPending)为准,而非 TTS 配置开关——
891
+ // 否则 TTS 开启但整段 ≥50 字被 tts_stream.endTurn 整段跳过时,既没有 play_end
892
+ // 触发 onIdle 隐藏,又不走 5s 兜底定时器,气泡会永久停留在屏幕上。
893
+ // - 无待播(含 --no-voice / 缺音色 / ≥50 字整段静音):立即 reset + 5s 后隐藏气泡
894
+ // - 有残余播放:由队列排空后的 onIdle(play_end) 隐藏气泡并 reset
895
+ // (onIdle 已先触发的场景下,这里的定时器重复隐藏一次已隐藏的气泡,无害)
896
+ if (!this.ttsStream.isPending) {
897
+ pet.reset();
865
898
  this.scheduleBubbleHide();
866
899
  }
867
900
  // 若被 Esc/Ctrl+C 中断,中断处理已重绘提示符,跳过重复 prompt
package/src/setup.ts CHANGED
@@ -11,10 +11,10 @@ import {
11
11
  type TtsProvider,
12
12
  } from "./config.ts";
13
13
  import { AGENT_IDS, getAgentLabel, type AgentId } from "./agent_registry.ts";
14
- import { VOICE_AUDIO, cloneVoice, setVoiceId, getMissingAgents, hasVoice, getGptSovitsVoice, setGptSovitsVoice, deleteGptSovitsVoice } from "./voices.ts";
14
+ import { VOICE_AUDIO, cloneVoice, setVoiceId, getMissingAgents, hasVoice, DEMO_PRECLONED_AGENTS, getGptSovitsVoice, setGptSovitsVoice } from "./voices.ts";
15
15
  import { normalizeGptSovitsConfig } from "./tts_provider.ts";
16
16
  import { installGptSovitsDeps } from "./gpt_sovits_local.ts";
17
- import { multiSelect } from "./tui_select.ts";
17
+ import { multiSelect, lockExisting } from "./tui_select.ts";
18
18
  import { t, getLang, setLang } from "./locale.ts";
19
19
 
20
20
  interface Settings {
@@ -46,6 +46,8 @@ interface Settings {
46
46
  cuaApiKey?: string;
47
47
  pythonPath?: string;
48
48
  mcpServers?: Record<string, unknown>;
49
+ /** 启动时是否从 ~/.agents/skills 补全缺失 Skill(默认 true);保留手写参数,勿覆盖 */
50
+ autoLoadSkills?: boolean;
49
51
  /** 用户手动维护;setup 向导只读不写。true 时启用演示模式。 */
50
52
  demoMode?: boolean;
51
53
  }
@@ -122,11 +124,11 @@ async function main() {
122
124
  const tried = `"${existing.pythonPath || "python3"}" 与 "python"`;
123
125
  if (pyCheck.version === "not found") {
124
126
  console.log(chalk.red(t(`\n✗ 未找到可用的 Python(已尝试 ${tried})。`, `\n✗ No usable Python found (tried ${tried}).`)));
125
- console.log(chalk.cyan(t(" ARONA 需要 Python 3.12 或 3.13(不支持 3.14)。", " ARONA requires Python 3.12 or 3.13 (3.14 is not supported).")));
127
+ console.log(chalk.cyan(t(" ARONA 需要 Python 3.12 或 3.13", " ARONA requires Python 3.12 or 3.13.")));
126
128
  console.log(chalk.gray(t(" 请安装 Python 后重新运行 arona setup。", " Install Python and run arona setup again.")));
127
129
  } else {
128
130
  console.log(chalk.red(t(`\n✗ Python 版本不兼容:${pyCheck.version}`, `\n✗ Incompatible Python version: ${pyCheck.version}`)));
129
- console.log(chalk.cyan(t(" ARONA 需要 Python 3.12 或 3.13(不支持 3.14,因 pydantic-core 限制)。", " ARONA requires Python 3.12 or 3.13 (3.14 not supported due to pydantic-core).")));
131
+ console.log(chalk.cyan(t(" ARONA 需要 Python 3.12 或 3.13", " ARONA requires Python 3.12 or 3.13.")));
130
132
  console.log(chalk.gray(t(" 请安装正确版本后重新运行 arona setup。", " Install the correct version and run arona setup again.")));
131
133
  }
132
134
  process.exit(1);
@@ -160,8 +162,8 @@ async function main() {
160
162
  const detected = getLang();
161
163
  console.log(chalk.cyan(t(` 检测到系统语言:${detected === "en" ? "英文 (en)" : "中文 (zh)"}`, ` Detected system language: ${detected === "en" ? "English (en)" : "Chinese (zh)"}`)));
162
164
  const langInput = (await ask(t(
163
- " 语言选择 [auto/en/zh](默认 auto=按系统):",
164
- " Language [auto/en/zh] (default auto = follow system): ",
165
+ " 语言选择 [auto/en/zh]: ",
166
+ " Language [auto/en/zh]: ",
165
167
  ))).toLowerCase();
166
168
  let langSetting: "auto" | "zh" | "en" = "auto";
167
169
  if (langInput === "en") { langSetting = "en"; setLang("en"); }
@@ -206,7 +208,7 @@ async function main() {
206
208
  // Step 2: 语音配置
207
209
  // ============================================================
208
210
  console.log(chalk.bold.cyan(t("\nStep 2: 语音配置\n", "\nStep 2: Voice Configuration\n")));
209
- console.log(chalk.cyan(t(" 百炼 API Key(可选,用于TTS/STT)", " Dashscope API Key (optional, for TTS/STT)")));
211
+ console.log(chalk.cyan(t(" 百炼 API Key(可选)", " Dashscope API Key (optional)")));
210
212
  console.log(chalk.cyan(t(" 获取 API Key: https://help.aliyun.com/zh/model-studio/get-api-key\n", " Get API Key: https://help.aliyun.com/zh/model-studio/get-api-key\n")));
211
213
 
212
214
  const existingTtsKey = existing.ttsApiKey || "";
@@ -218,7 +220,7 @@ async function main() {
218
220
 
219
221
  console.log(chalk.bold.cyan(t("\n TTS Provider 菜单\n", "\n TTS Provider Menu\n")));
220
222
  const providerOptions = [
221
- { id: "aliyun", label: t("阿里云百炼(默认)", "Dashscope") },
223
+ { id: "aliyun", label: t("阿里云百炼", "Dashscope") },
222
224
  { id: "gpt-sovits", label: t("GPT-SoVITS", "GPT-SoVITS") },
223
225
  ];
224
226
  // 二选一使用 TUI 单选菜单(不要求键盘输入 1/2)
@@ -247,7 +249,7 @@ async function main() {
247
249
  console.log(chalk.bold.cyan(t("\n GPT-SoVITS 配置\n", "\n GPT-SoVITS Configuration\n")));
248
250
  // readline 已在上方 TUI 前关闭;为后续 TUI 选择保持 stdin 空闲
249
251
  const deployOptions = [
250
- { id: "cloud", label: t("云端 API(远程服务)", "Cloud API (remote)") },
252
+ { id: "cloud", label: t("云端 API", "Cloud API") },
251
253
  { id: "local", label: t("本地模型", "Local model") },
252
254
  ];
253
255
  const deploySelected = await multiSelect(
@@ -418,7 +420,7 @@ async function main() {
418
420
  ` text_lang [${gptSovitsConfig.textLang}] (auto/zh/en/ja/yue/ko): `,
419
421
  ));
420
422
  const textLang = textLangInput.trim() || gptSovitsConfig.textLang;
421
- // prompt_lang 不再全局询问:按每角色参考音频文字内容自动判断(走默认素材固定 zh,见 tts_provider.detectPromptLang)
423
+ // prompt_lang 按每角色参考音频文字内容自动判断(默认素材固定 zh,见 tts_provider.detectPromptLang)
422
424
  gptSovitsConfig = {
423
425
  ...gptSovitsConfig,
424
426
  mode,
@@ -433,9 +435,16 @@ async function main() {
433
435
  };
434
436
  rl2.close();
435
437
 
436
- // 多选要配置音色的角色:全部可编辑(已配置者默认 [*],取消勾选 = 删除该角色配置)
437
- const configured = AGENT_IDS.filter((id) => getGptSovitsVoice(id));
438
- const options = AGENT_IDS.map((id) => ({ id, label: getAgentLabel(id), locked: false }));
438
+ // 多选要配置音色的角色:已配置者经 lockExisting 锁定为已克隆。
439
+ // 演示模式:硬编码全未克隆,路径信息直接丢弃、不写 voices.json。
440
+ const configured: AgentId[] = demoMode ? [] : AGENT_IDS.filter((id) => getGptSovitsVoice(id));
441
+ const options = lockExisting(
442
+ AGENT_IDS.map((id) => ({
443
+ id,
444
+ label: configured.includes(id) ? `${getAgentLabel(id)}${t("(已克隆)", " (cloned)")}` : getAgentLabel(id),
445
+ })),
446
+ configured,
447
+ );
439
448
  const selected = await multiSelect(
440
449
  t("选择要配置 GPT-SoVITS 音色的角色", "Select characters to configure GPT-SoVITS voices"),
441
450
  options,
@@ -468,24 +477,37 @@ async function main() {
468
477
  if (!selected.has(id)) continue;
469
478
  const prev = getGptSovitsVoice(id) || {};
470
479
  console.log(chalk.bold.cyan(t(`\n ${getAgentLabel(id)} 音色配置\n`, `\n ${getAgentLabel(id)} voice config\n`)));
480
+ // GPT/SoVITS 权重必填:无既有值默认 [ N/A ],空输入黄色提示后重新询问。
471
481
  const prevCkpt = prev.gptWeightsPath || "";
472
- const ckpt = (await ask3(t(
473
- prevCkpt
474
- ? ` GPT 权重 .ckpt 路径 [${prevCkpt}]: `
475
- : " GPT 权重 .ckpt 路径 [不切换]: ",
476
- prevCkpt
477
- ? ` GPT weights .ckpt path [${prevCkpt}]: `
478
- : " GPT weights .ckpt path [no switch]: ",
479
- ))).trim() || prevCkpt;
482
+ let ckpt = prevCkpt;
483
+ for (;;) {
484
+ const ckptInput = (await ask3(t(
485
+ ckpt
486
+ ? ` GPT 权重 .ckpt 路径 [${ckpt}]: `
487
+ : " GPT 权重 .ckpt 路径 [ N/A ]: ",
488
+ ckpt
489
+ ? ` GPT weights .ckpt path [${ckpt}]: `
490
+ : " GPT weights .ckpt path [ N/A ]: ",
491
+ ))).trim();
492
+ if (ckptInput) { ckpt = ckptInput; break; }
493
+ if (prevCkpt) break;
494
+ console.log(chalk.yellow(t(" 请输入路径!", " Please enter a path!")));
495
+ }
480
496
  const prevPth = prev.sovitsWeightsPath || "";
481
- const pth = (await ask3(t(
482
- prevPth
483
- ? ` SoVITS 权重 .pth 路径 [${prevPth}]: `
484
- : " SoVITS 权重 .pth 路径 [不切换]: ",
485
- prevPth
486
- ? ` SoVITS weights .pth path [${prevPth}]: `
487
- : " SoVITS weights .pth path [no switch]: ",
488
- ))).trim() || prevPth;
497
+ let pth = prevPth;
498
+ for (;;) {
499
+ const pthInput = (await ask3(t(
500
+ pth
501
+ ? ` SoVITS 权重 .pth 路径 [${pth}]: `
502
+ : " SoVITS 权重 .pth 路径 [ N/A ]: ",
503
+ pth
504
+ ? ` SoVITS weights .pth path [${pth}]: `
505
+ : " SoVITS weights .pth path [ N/A ]: ",
506
+ ))).trim();
507
+ if (pthInput) { pth = pthInput; break; }
508
+ if (prevPth) break;
509
+ console.log(chalk.yellow(t(" 请输入路径!", " Please enter a path!")));
510
+ }
489
511
  const prevRef = prev.refAudioPath || "";
490
512
  // cloud 模式 ref 必填(无 ref 则该角色 isTtsEnabledFor=false 静音且无解释);循环重询直至非空
491
513
  let ref = prevRef;
@@ -506,8 +528,8 @@ async function main() {
506
528
  if (prevRef) break;
507
529
  if (mode !== "cloud") break;
508
530
  console.log(chalk.yellow(t(
509
- ` ref_audio_path 不能为空(云端模式必填)。`,
510
- ` ref_audio_path cannot be empty (required in cloud mode).`,
531
+ ` ref_audio_path 不能为空。`,
532
+ ` ref_audio_path cannot be empty.`,
511
533
  )));
512
534
  }
513
535
  const prevText = prev.promptText || "";
@@ -515,20 +537,19 @@ async function main() {
515
537
  prevText
516
538
  ? ` 示例音频文字内容 prompt_text [${prevText}]: `
517
539
  : mode === "cloud"
518
- ? " 示例音频文字内容 prompt_text [必填,文字/本地txt路径/URL](语言自动判断): "
519
- : " 示例音频文字内容 prompt_text [文字/本地txt路径/URL,缺省用 voice_text.txt](语言自动判断): ",
540
+ ? " 示例音频文字内容 prompt_text [文字/本地txt路径/URL]: "
541
+ : ` 示例音频文字内容 prompt_text [assets/blue-archive/${id}/voice_text.txt]: `,
520
542
  prevText
521
543
  ? ` Reference audio text prompt_text [${prevText}]: `
522
544
  : mode === "cloud"
523
- ? " Reference audio text prompt_text [required, text/local txt path/URL] (lang auto-detected): "
524
- : " Reference audio text prompt_text [text/local txt path/URL, default voice_text.txt] (lang auto-detected): ",
545
+ ? " Reference audio text prompt_text [text/local txt path/URL]: "
546
+ : ` Reference audio text prompt_text [assets/blue-archive/${id}/voice_text.txt]: `,
525
547
  ))).trim() || prevText;
526
548
  // 每角色音色写 voices.json#gpt-sovits(与百炼 voice_id 共存,不写 settings.json)
527
- setGptSovitsVoice(id, { gptWeightsPath: ckpt, sovitsWeightsPath: pth, refAudioPath: ref, promptText: refText });
528
- }
529
- // 未勾选的原已配置角色 删除其配置(幂等)
530
- for (const id of AGENT_IDS) {
531
- if (configured.includes(id) && !selected.has(id)) deleteGptSovitsVoice(id);
549
+ // 演示模式:路径信息直接丢弃,不写 voices.json。
550
+ if (!demoMode) {
551
+ setGptSovitsVoice(id, { gptWeightsPath: ckpt, sovitsWeightsPath: pth, refAudioPath: ref, promptText: refText });
552
+ }
532
553
  }
533
554
  rl3.close();
534
555
  }
@@ -588,22 +609,26 @@ async function main() {
588
609
  // 多选 TUI 独占 stdin raw mode(此后不再用 rl.question;rl 已在 TTS Provider TUI 前关闭)。
589
610
 
590
611
  // 已有音色的角色锁定 [*](迁移已在模块加载时完成,此处读到的 voices.json 已是新格式)。
591
- const missing = getMissingAgents();
612
+ // 演示模式:必然显示 TUI,预标记角色锁定为已克隆,其余未克隆。
613
+ const missing = demoMode ? AGENT_IDS : getMissingAgents();
592
614
  if (missing.length === 0) {
593
615
  console.log(chalk.green(t(
594
616
  " 所有角色已有音色,无需克隆。如需重新克隆请运行 arona voice add <角色名>。",
595
617
  " All characters already have voices. Use `arona voice add <name>` to re-clone.",
596
618
  )));
597
619
  } else {
598
- const options = AGENT_IDS.map((id) => ({
599
- id,
600
- label: hasVoice(id) ? `${getAgentLabel(id)}${t("(已克隆)", " (cloned)")}` : getAgentLabel(id),
601
- locked: hasVoice(id),
602
- }));
620
+ const clonedIds = demoMode ? DEMO_PRECLONED_AGENTS : AGENT_IDS.filter((id) => hasVoice(id));
621
+ const options = lockExisting(
622
+ AGENT_IDS.map((id) => ({
623
+ id,
624
+ label: clonedIds.includes(id) ? `${getAgentLabel(id)}${t("(已克隆)", " (cloned)")}` : getAgentLabel(id),
625
+ })),
626
+ clonedIds,
627
+ );
603
628
  const selected = await multiSelect(
604
629
  t("选择要克隆音色的角色", "Select characters to clone voices."),
605
630
  options,
606
- new Set<string>(), // 已有音色者 locked 强制 [*],未克隆者默认 [ ]
631
+ new Set<string>(), // 已有音色者经 lockExisting 锁定,未克隆者默认 [ ]
607
632
  t(
608
633
  " ↑/↓ 切换 · 空格选中 [*] · 回车克隆 · Esc 取消",
609
634
  " ↑/↓ move · Space select [*] · Enter clone · Esc cancel",
@@ -618,7 +643,7 @@ async function main() {
618
643
  )));
619
644
  return;
620
645
  } else if (selected.size === 0) {
621
- console.log(chalk.cyan(t(" 未选择任何角色,跳过音色克隆(TTS 将保持静音)。", " No character selected, skipping voice cloning (TTS stays muted).")));
646
+ console.log(chalk.cyan(t(" 未选择任何角色,跳过音色克隆。", " No character selected, skipping voice cloning.")));
622
647
  } else {
623
648
  const model = existing.ttsModel || "qwen-audio-3.0-tts-plus";
624
649
  for (const id of AGENT_IDS) {
@@ -629,13 +654,13 @@ async function main() {
629
654
  continue;
630
655
  }
631
656
  if (demoMode) {
632
- // 演示模式:不调用 voice_clone.py、不写 voices.json,静默 5s 后显示成功。
633
- console.log(chalk.cyan(t(` 正在克隆 ${getAgentLabel(id)} 的音色...`, `Cloning voice for ${getAgentLabel(id)}...`)));
657
+ // 演示模式:不调用 voice_clone.py、不写 voices.json5s 模拟后显示成功。
658
+ console.log(chalk.cyan(t(` 正在克隆 ${getAgentLabel(id)} 的音色...`, ` Cloning ${getAgentLabel(id)}'s voice...`)));
634
659
  await new Promise((r) => setTimeout(r, 5000));
635
660
  console.log(chalk.green(t(` ✓ ${getAgentLabel(id)} 音色克隆成功`, ` ✓ ${getAgentLabel(id)} voice cloned.`)));
636
661
  continue;
637
662
  }
638
- console.log(chalk.cyan(t(` 正在克隆 ${getAgentLabel(id)} 的音色(可能需要 1-2 分钟)...`, ` Cloning ${getAgentLabel(id)}'s voice (may take 1-2 minutes)...`)));
663
+ console.log(chalk.cyan(t(` 正在克隆 ${getAgentLabel(id)} 的音色...`, ` Cloning ${getAgentLabel(id)}'s voice...`)));
639
664
  try {
640
665
  const voiceId = await cloneVoice(id, ttsApiKey, model);
641
666
  setVoiceId(id, voiceId);
package/src/slash_menu.ts CHANGED
@@ -171,15 +171,21 @@ export class SlashMenu {
171
171
  this.redraw(rl);
172
172
  }
173
173
 
174
- /** ↑/↓ 导航(循环)。 */
175
- move(direction: -1 | 1, rl: Interface): void {
176
- if (!this.visible || this.matches.length === 0) return;
174
+ /**
175
+ * ↑/↓ 导航。返回是否移动了选中项:
176
+ * - 到底环绕回顶部(保持循环导航);
177
+ * - ↑ 在顶部(selected === 0)不环绕,返回 false 作为"到达边界"信号,
178
+ * 由 repl 放行给 readline 做历史回溯(读取上次执行的命令)。
179
+ */
180
+ move(direction: -1 | 1, rl: Interface): boolean {
181
+ if (!this.visible || this.matches.length === 0) return false;
182
+ if (direction === -1 && this.selected === 0) return false; // 顶部按 ↑:交给历史回溯
177
183
  let next = this.selected + direction;
178
- if (next < 0) next = this.matches.length - 1;
179
184
  if (next >= this.matches.length) next = 0;
180
185
  this.selected = next;
181
186
  this.clampScroll();
182
187
  this.redraw(rl);
188
+ return true;
183
189
  }
184
190
 
185
191
  /**
@@ -26,7 +26,7 @@ export interface SlashCommandSpec {
26
26
  export const SLASH_COMMANDS: SlashCommandSpec[] = [
27
27
  { name: "help", aliases: ["?"], description: t("显示命令列表", "Show command list") },
28
28
  { name: "exit", aliases: ["quit", "q"], description: t("退出", "Exit") },
29
- { name: "new", aliases: ["clear"], description: t("开始新会话(清空上下文)", "Start a new session (clear context)") },
29
+ { name: "new", aliases: ["clear"], description: t("开始新会话", "Start a new session") },
30
30
  { name: "resume", aliases: ["r"], description: t("上下键选择并恢复一个已保存的会话", "Pick and resume a saved session with arrow keys"), interactive: true },
31
31
  { name: "export", description: t("导出当前会话为 Markdown", "Export current session as Markdown") },
32
32
  { name: "thinking", description: t("开关推理块显示", "Toggle reasoning block display") },
@@ -34,7 +34,7 @@ export const SLASH_COMMANDS: SlashCommandSpec[] = [
34
34
  { name: "compact", description: t("压缩上下文", "Compact context") },
35
35
  { name: "tts", description: t("开关文字转语音", "Toggle text-to-speech") },
36
36
  { name: "stt", description: t(`开关语音转文字`, `Toggle speech-to-text`) },
37
- { name: "skill", description: t("调用技能(/skill <名称>)", "Invoke a skill (/skill <name>)") },
37
+ { name: "skill", description: t("调用技能", "Invoke a skill") },
38
38
  { name: "mcp", description: t("管理 MCP 服务器和工具", "Manage MCP servers and tools") },
39
39
  { name: "change-agent", description: t("切换主 Agent + 多选子 Agent", "Switch main agent + multi-select sub agents"), interactive: true },
40
40
  { name: "undo", description: t("撤销上一个回合的全部文件改动", "Undo the previous turn's file changes") },
@@ -20,8 +20,8 @@ function loadSkillsTool(loader: DefaultResourceLoader): ToolDefinition {
20
20
  name: "load_skills",
21
21
  label: t("加载技能", "Load Skills"),
22
22
  description: t(
23
- "列出或加载技能。**不传 names 时**:返回所有可用技能的 name + description 列表,便于先发现再加载。**传 names 时**:返回对应技能的 SKILL.md 全文(支持单个字符串或字符串数组),加载后遵循其中指令执行;未找到的技能会在结果中列出。建议单次加载不超过 5 个,避免上下文膨胀。",
24
- "List or load skills. **Without names**: returns the name + description list of all available skills, so you can discover before loading. **With names**: returns the full SKILL.md content of the requested skills (accepts a single string or an array of strings); follow the instructions inside after loading; missing skills are reported in the result. Load at most 5 per call to avoid bloating the context.",
23
+ "列出或加载技能。**不传 names 时**:返回所有可用技能的 name + description 列表。**传 names 时**:返回对应技能的 SKILL.md 全文(单个字符串或字符串数组),加载后遵循其中指令执行;未找到的技能在结果中列出。单次加载不超过 5 个。",
24
+ "List or load skills. **Without names**: returns the name + description list of all available skills. **With names**: returns the full SKILL.md content of the requested skills (a single string or an array of strings); follow the instructions inside after loading; missing skills are reported in the result. Load at most 5 per call.",
25
25
  ),
26
26
  parameters: Type.Object({
27
27
  names: Type.Optional(
package/src/tui_select.ts CHANGED
@@ -8,10 +8,22 @@ import { t } from "./locale.ts";
8
8
  export interface SelectOption {
9
9
  id: string;
10
10
  label: string;
11
- /** 锁定:强制显示 [*],空格不可切换(已有音色的角色)。 */
11
+ /**
12
+ * 锁定(已存在/已配置的项)。
13
+ * 请统一用 lockExisting() 按 existing 集合生成。
14
+ */
12
15
  locked?: boolean;
13
16
  }
14
17
 
18
+ /**
19
+ * 把"已存在/已配置"的项标记为锁定。调用方给出 existing 集合即可。
20
+ * 已显式 locked:true 的项保持锁定;不在 existing 里的项原样返回。
21
+ */
22
+ export function lockExisting<T extends SelectOption>(options: readonly T[], existing: Iterable<string>): T[] {
23
+ const set = new Set(existing);
24
+ return options.map((o) => (o.locked || set.has(o.id) ? { ...o, locked: true } : o));
25
+ }
26
+
15
27
  /** 计算一个字符串去除 ANSI 转义后的可见宽度(CJK/全角算 2,控制符算 0)。 */
16
28
  function visibleWidth(s: string): number {
17
29
  const stripped = s.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|[0-9])/g, "");
package/src/undo.ts CHANGED
@@ -135,8 +135,8 @@ export class UndoManager {
135
135
 
136
136
  /**
137
137
  * 启动时调用:加载持久化状态(如有),并在后台异步刷新基线为当前工作目录真实状态。
138
- * 基线扫描改为后台异步 + 有界(MAX_SCAN_FILES/深度/系统目录):在 ~/、/ 等巨型目录下
139
- * 同步全盘递归会把启动卡死(此前每次启动都在这卡住,子目录正常)。
138
+ * 基线扫描为后台异步 + 有界(MAX_SCAN_FILES/深度/系统目录):在 ~/、/ 等巨型目录下
139
+ * 同步全盘递归会卡死启动。
140
140
  */
141
141
  load(): void {
142
142
  try {
@@ -377,7 +377,7 @@ export class UndoManager {
377
377
 
378
378
  private hashLargeFile(abs: string): string {
379
379
  // 只读 4KB 头 + 4KB 尾 + size 当 hash 近似(够用于检测删除)
380
- // 用 fd 流式读取,避免把整个大文件读入内存(原实现误用 readFileSync 全量读取)
380
+ // 用 fd 流式读取,避免把整个大文件读入内存
381
381
  let fd: number | null = null;
382
382
  try {
383
383
  const stat = statSync(abs);
@@ -436,8 +436,7 @@ export class UndoManager {
436
436
 
437
437
  /**
438
438
  * 把 before / after 两个 snapshot 对比,产出 changed files 的 diff。
439
- * content 直接取自 snapshot(takeSnapshot 已读出),不再回读文件——
440
- * 因为 afterTurn 调用时,文件已是 after 状态,回读会污染 before 内容。
439
+ * content 直接取自 snapshottakeSnapshot 已读出)。
441
440
  * 无变化返回 null。
442
441
  */
443
442
  private computeDiff(
package/src/voice_cli.ts CHANGED
@@ -10,8 +10,8 @@ import { existsSync, readFileSync } from "fs";
10
10
  import chalk from "chalk";
11
11
  import { SETTINGS_FILE, verbose } from "./config.ts";
12
12
  import { AGENT_IDS, getAgentLabel, type AgentId } from "./agent_registry.ts";
13
- import { cloneVoice, hasVoice, setVoiceId, getGptSovitsVoice, setGptSovitsVoice, deleteGptSovitsVoice } from "./voices.ts";
14
- import { multiSelect } from "./tui_select.ts";
13
+ import { cloneVoice, hasVoice, setVoiceId, DEMO_PRECLONED_AGENTS, getGptSovitsVoice, setGptSovitsVoice } from "./voices.ts";
14
+ import { multiSelect, lockExisting } from "./tui_select.ts";
15
15
  import { t } from "./locale.ts";
16
16
 
17
17
  function readSettings(): { ttsApiKey: string; ttsModel: string; ttsProvider: string } {
@@ -52,8 +52,9 @@ function isValidAgentId(id: string): id is AgentId {
52
52
  * 配置 GPT-SoVITS 每角色音色(gptWeightsPath/sovitsWeightsPath/refAudioPath/promptText)。
53
53
  * 写 voices.json#gpt-sovits(read-modify-write,与 voices.json#aliyun 的百炼音色共存)。
54
54
  * 带角色名 = 只配置该角色;无参 = TUI 多选(原已配置角色默认 [*],取消勾选 = 删除其配置)。
55
+ * 演示模式:TUI 默认显示所有角色都为未克隆,记录的路径信息直接丢弃、不写 voices.json。
55
56
  */
56
- async function configureGptSovitsVoice(name?: string): Promise<void> {
57
+ async function configureGptSovitsVoice(name: string | undefined, demoMode: boolean): Promise<void> {
57
58
  const targets: AgentId[] = [];
58
59
  if (name) {
59
60
  if (!isValidAgentId(name)) {
@@ -65,8 +66,16 @@ async function configureGptSovitsVoice(name?: string): Promise<void> {
65
66
  }
66
67
  targets.push(name);
67
68
  } else {
68
- const configured = AGENT_IDS.filter((id) => getGptSovitsVoice(id));
69
- const options = AGENT_IDS.map((id) => ({ id, label: getAgentLabel(id), locked: false }));
69
+ // 已配置者经 lockExisting 锁定为已克隆。
70
+ // 演示模式:硬编码全未克隆。
71
+ const configured: AgentId[] = demoMode ? [] : AGENT_IDS.filter((id) => getGptSovitsVoice(id));
72
+ const options = lockExisting(
73
+ AGENT_IDS.map((id) => ({
74
+ id,
75
+ label: configured.includes(id) ? `${getAgentLabel(id)}${t("(已克隆)", " (cloned)")}` : getAgentLabel(id),
76
+ })),
77
+ configured,
78
+ );
70
79
  const selected = await multiSelect(
71
80
  t("选择要配置 GPT-SoVITS 音色的角色", "Select characters to configure GPT-SoVITS voices"),
72
81
  options,
@@ -81,10 +90,6 @@ async function configureGptSovitsVoice(name?: string): Promise<void> {
81
90
  return;
82
91
  }
83
92
  targets.push(...AGENT_IDS.filter((id) => selected.has(id)));
84
- // 未勾选的原已配置角色 → 删除其配置(与 setup 语义一致)
85
- for (const id of AGENT_IDS) {
86
- if (configured.includes(id) && !selected.has(id)) deleteGptSovitsVoice(id);
87
- }
88
93
  }
89
94
 
90
95
  if (targets.length === 0) {
@@ -109,16 +114,29 @@ async function configureGptSovitsVoice(name?: string): Promise<void> {
109
114
  `\n ${getAgentLabel(id)} GPT-SoVITS 音色配置\n`,
110
115
  `\n ${getAgentLabel(id)} GPT-SoVITS voice config\n`,
111
116
  )));
117
+ // GPT/SoVITS 权重必填:无既有值默认 [ N/A ],空输入黄色提示后重新询问。
112
118
  const prevCkpt = prev.gptWeightsPath || "";
113
- const ckpt = (await ask(t(
114
- prevCkpt ? ` GPT 权重 .ckpt 路径 [${prevCkpt}]: ` : " GPT 权重 .ckpt 路径 [不切换]: ",
115
- prevCkpt ? ` GPT weights .ckpt path [${prevCkpt}]: ` : " GPT weights .ckpt path [no switch]: ",
116
- ))) || prevCkpt;
119
+ let ckpt = prevCkpt;
120
+ for (;;) {
121
+ const ckptInput = (await ask(t(
122
+ ckpt ? ` GPT 权重 .ckpt 路径 [${ckpt}]: ` : " GPT 权重 .ckpt 路径 [ N/A ]: ",
123
+ ckpt ? ` GPT weights .ckpt path [${ckpt}]: ` : " GPT weights .ckpt path [ N/A ]: ",
124
+ ))).trim();
125
+ if (ckptInput) { ckpt = ckptInput; break; }
126
+ if (prevCkpt) break;
127
+ console.log(chalk.yellow(t(" 请输入路径!", " Please enter a path!")));
128
+ }
117
129
  const prevPth = prev.sovitsWeightsPath || "";
118
- const pth = (await ask(t(
119
- prevPth ? ` SoVITS 权重 .pth 路径 [${prevPth}]: ` : " SoVITS 权重 .pth 路径 [不切换]: ",
120
- prevPth ? ` SoVITS weights .pth path [${prevPth}]: ` : " SoVITS weights .pth path [no switch]: ",
121
- ))) || prevPth;
130
+ let pth = prevPth;
131
+ for (;;) {
132
+ const pthInput = (await ask(t(
133
+ pth ? ` SoVITS 权重 .pth 路径 [${pth}]: ` : " SoVITS 权重 .pth 路径 [ N/A ]: ",
134
+ pth ? ` SoVITS weights .pth path [${pth}]: ` : " SoVITS weights .pth path [ N/A ]: ",
135
+ ))).trim();
136
+ if (pthInput) { pth = pthInput; break; }
137
+ if (prevPth) break;
138
+ console.log(chalk.yellow(t(" 请输入路径!", " Please enter a path!")));
139
+ }
122
140
  const prevRef = prev.refAudioPath || "";
123
141
  const ref = (await ask(t(
124
142
  prevRef ? ` 示例音频 ref_audio_path [${prevRef}]: ` : " 示例音频 ref_audio_path [本地路径或URL]: ",
@@ -126,11 +144,18 @@ async function configureGptSovitsVoice(name?: string): Promise<void> {
126
144
  ))) || prevRef;
127
145
  const prevText = prev.promptText || "";
128
146
  const text = (await ask(t(
129
- prevText ? ` 示例音频文字 prompt_text [${prevText}]: ` : " 示例音频文字 prompt_text [文字/本地txt路径/URL](语言自动判断): ",
130
- prevText ? ` Reference audio text prompt_text [${prevText}]: ` : " Reference audio text prompt_text [text/local txt path/URL] (lang auto-detected): ",
147
+ prevText
148
+ ? ` 示例音频文字内容 prompt_text [${prevText}]: `
149
+ : ` 示例音频文字内容 prompt_text [assets/blue-archive/${id}/voice_text.txt]: `,
150
+ prevText
151
+ ? ` Reference audio text prompt_text [${prevText}]: `
152
+ : ` Reference audio text prompt_text [assets/blue-archive/${id}/voice_text.txt]: `,
131
153
  ))) || prevText;
132
154
  // 每角色音色写 voices.json#gpt-sovits(与百炼 voice_id 共存,不写 settings.json)
133
- setGptSovitsVoice(id, { gptWeightsPath: ckpt, sovitsWeightsPath: pth, refAudioPath: ref, promptText: text });
155
+ // 演示模式:路径信息直接丢弃,不写 voices.json。
156
+ if (!demoMode) {
157
+ setGptSovitsVoice(id, { gptWeightsPath: ckpt, sovitsWeightsPath: pth, refAudioPath: ref, promptText: text });
158
+ }
134
159
  }
135
160
  rl.close();
136
161
 
@@ -160,8 +185,8 @@ async function cloneOne(agent: AgentId, apiKey: string, model: string, simulate:
160
185
  if (simulate) {
161
186
  // 演示模式:打印"正在克隆"与"克隆成功",但不调用 voice_clone.py、不写 voices.json、无演示模式提示。
162
187
  console.log(chalk.cyan(t(
163
- `正在克隆 ${getAgentLabel(agent)} 的音色(可能需要 1-2 分钟)...`,
164
- `Cloning ${getAgentLabel(agent)}'s voice (may take 1-2 minutes)...`,
188
+ `正在克隆 ${getAgentLabel(agent)} 的音色...`,
189
+ `Cloning ${getAgentLabel(agent)}'s voice...`,
165
190
  )));
166
191
  await new Promise((r) => setTimeout(r, 5000));
167
192
  console.log(chalk.green(t(
@@ -171,8 +196,8 @@ async function cloneOne(agent: AgentId, apiKey: string, model: string, simulate:
171
196
  return true;
172
197
  }
173
198
  console.log(chalk.cyan(t(
174
- `正在克隆 ${getAgentLabel(agent)} 的音色(可能需要 1-2 分钟)...`,
175
- `Cloning ${getAgentLabel(agent)}'s voice (may take 1-2 minutes)...`,
199
+ `正在克隆 ${getAgentLabel(agent)} 的音色...`,
200
+ `Cloning ${getAgentLabel(agent)}'s voice...`,
176
201
  )));
177
202
  try {
178
203
  const voiceId = await cloneVoice(agent, apiKey, model);
@@ -214,13 +239,13 @@ async function run(argv: string[]): Promise<void> {
214
239
 
215
240
  // GPT-SoVITS:配置每角色专属权重/参考音频(写 voices.json#gpt-sovits,与百炼音色共存)。
216
241
  if (ttsProvider === "gpt-sovits") {
217
- await configureGptSovitsVoice(name);
242
+ await configureGptSovitsVoice(name, demoMode);
218
243
  return;
219
244
  }
220
245
  if (ttsProvider !== "aliyun") {
221
246
  console.log(chalk.yellow(t(
222
- `当前 TTS Provider 为 ${ttsProvider}:arona voice add 仅支持 aliyun(百炼音色克隆)与 gpt-sovits(每角色权重/参考音频配置)。`,
223
- `Current TTS Provider is ${ttsProvider}: arona voice add only supports aliyun (Bailian cloning) and gpt-sovits (per-character weights/ref audio).`,
247
+ `当前 TTS Provider 为 ${ttsProvider}:arona voice add 仅支持 aliyun gpt-sovits。`,
248
+ `Current TTS Provider is ${ttsProvider}: arona voice add only supports aliyun and gpt-sovits.`,
224
249
  )));
225
250
  process.exit(1);
226
251
  }
@@ -257,20 +282,15 @@ async function run(argv: string[]): Promise<void> {
257
282
  }
258
283
 
259
284
  // 无参:TUI 展示全部角色(主 Agent + 子 Agent)。
260
- // 演示模式:阿洛娜强制显示为已克隆(锁定、不重克隆);普拉娜/砂狼白子/小鸟游星野强制显示为未克隆(可选)。
261
- const options = AGENT_IDS.map((id) => {
262
- if (demoMode && id === "arona") {
263
- return { id, label: `${getAgentLabel(id)}${t("(已克隆)", " (cloned)")}`, locked: true };
264
- }
265
- if (demoMode) {
266
- return { id, label: getAgentLabel(id), locked: false };
267
- }
268
- return {
285
+ // 演示模式:Arona/Plana/Shiroko/Hoshino 显示为已克隆,其余显示为未克隆。
286
+ const clonedIds = demoMode ? DEMO_PRECLONED_AGENTS : AGENT_IDS.filter((id) => hasVoice(id));
287
+ const options = lockExisting(
288
+ AGENT_IDS.map((id) => ({
269
289
  id,
270
- label: hasVoice(id) ? `${getAgentLabel(id)}${t("(已克隆)", " (cloned)")}` : getAgentLabel(id),
271
- locked: hasVoice(id),
272
- };
273
- });
290
+ label: clonedIds.includes(id) ? `${getAgentLabel(id)}${t("(已克隆)", " (cloned)")}` : getAgentLabel(id),
291
+ })),
292
+ clonedIds,
293
+ );
274
294
  const initial = new Set<string>();
275
295
  const selected = await multiSelect(
276
296
  t("选择要补全音色的角色", "Select characters to add voices"),
package/src/voices.ts CHANGED
@@ -178,6 +178,12 @@ export function getMissingAgents(): AgentId[] {
178
178
  return AGENT_IDS.filter((id) => !hasVoice(id));
179
179
  }
180
180
 
181
+ /**
182
+ * 演示模式(settings.json#demoMode === true)下,aliyun 分支 TUI 预标记为"已克隆"并锁定的角色。
183
+ * setup.ts 与 voice_cli.ts 共用此表(配合 tui_select.lockExisting),保证两个命令的演示行为一致(其余角色视为未克隆)。
184
+ */
185
+ export const DEMO_PRECLONED_AGENTS: readonly AgentId[] = ["arona", "plana", "shiroko", "hoshino"];
186
+
181
187
  /**
182
188
  * 克隆指定角色的百炼音色,返回 voice_id。
183
189
  * 复用 python/voice_clone.py:ARONA_VOICE_AUDIO + ARONA_VOICE_PREFIX 指定音频与角色前缀。