arona-agent 1.2.2 → 1.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/main.cjs +29 -6
- package/gui/renderer/app.js +289 -39
- package/gui/renderer/index.html +6 -0
- package/gui/renderer/style.css +144 -2
- package/package.json +1 -1
- package/pet/agents.cjs +9 -4
- package/pet/main.cjs +41 -2
- package/pet/renderer/spinetest.js +3 -3
- package/pet/tools/gallery_capture.cjs +1 -1
- package/pet/tools/mouth_capture.cjs +1 -1
- package/pet/tools/visual_test.cjs +1 -1
- package/python/__pycache__/stt.cpython-314.pyc +0 -0
- package/python/__pycache__/tts_say.cpython-314.pyc +0 -0
- package/python/stt.py +24 -3
- package/src/agent.ts +11 -10
- package/src/agent_registry.ts +30 -0
- package/src/coding_agent.ts +6 -5
- package/src/commands.ts +56 -17
- package/src/config.ts +22 -0
- package/src/gui/controller.ts +385 -159
- package/src/gui/index.ts +26 -5
- package/src/gui/protocol.ts +9 -2
- package/src/gui/setup_backend.ts +15 -7
- package/src/index.ts +25 -5
- package/src/memory.ts +105 -12
- package/src/pet.ts +60 -1
- package/src/renderer.ts +27 -3
- package/src/repl.ts +24 -6
- package/src/setup.ts +14 -6
- package/src/speaker_context.ts +67 -1
- package/src/utils/python.ts +11 -6
- package/src/voice.ts +3 -2
- package/src/voices.ts +5 -1
- package/src/workspace.ts +163 -0
package/src/repl.ts
CHANGED
|
@@ -23,6 +23,8 @@ import { t } from "./locale.ts";
|
|
|
23
23
|
import { spawnCompat } from "./utils/spawn.ts";
|
|
24
24
|
import { initSubAgent } from "./agent.ts";
|
|
25
25
|
import { getMainAgent, getSubAgents, getAgentLabel, type AgentId, type SubAgentId } from "./agent_registry.ts";
|
|
26
|
+
import { stripSpeakerPrefix } from "./speaker_context.ts";
|
|
27
|
+
import { currentWorkspace } from "./workspace.ts";
|
|
26
28
|
|
|
27
29
|
// STT 长按阈值:按下录音热键持续 ≥ 该毫秒数并在释放时才触发录音;提前松开视为误触
|
|
28
30
|
const STT_HOLD_MS = 2000;
|
|
@@ -107,7 +109,7 @@ export class Repl {
|
|
|
107
109
|
this.activeSession = session;
|
|
108
110
|
this.activeAgentId = getMainAgent();
|
|
109
111
|
|
|
110
|
-
this.undoManager = new UndoManager(
|
|
112
|
+
this.undoManager = new UndoManager(currentWorkspace());
|
|
111
113
|
this.undoManager.load();
|
|
112
114
|
|
|
113
115
|
this.rl = readline.createInterface({
|
|
@@ -131,6 +133,7 @@ export class Repl {
|
|
|
131
133
|
},
|
|
132
134
|
getAgentLabel(getMainAgent()),
|
|
133
135
|
);
|
|
136
|
+
this.renderer.setPrefixAgent(getMainAgent());
|
|
134
137
|
this.rendererUnsub = this.renderer.subscribe(this.session);
|
|
135
138
|
|
|
136
139
|
this.setupSignals();
|
|
@@ -226,6 +229,7 @@ export class Repl {
|
|
|
226
229
|
this.resetSubSessions();
|
|
227
230
|
this.rendererUnsub?.();
|
|
228
231
|
this.renderer.setSpeakerLabel(getAgentLabel(getMainAgent()));
|
|
232
|
+
this.renderer.setPrefixAgent(getMainAgent());
|
|
229
233
|
this.rendererUnsub = this.renderer.subscribe(this.session);
|
|
230
234
|
},
|
|
231
235
|
runAgentTurn: (text: string) => this.runRawTurn(text),
|
|
@@ -437,11 +441,12 @@ export class Repl {
|
|
|
437
441
|
const messages = this.session.messages;
|
|
438
442
|
const model = this.session.model?.id || "unknown";
|
|
439
443
|
if (this.currentSessionPath) {
|
|
440
|
-
// resume
|
|
441
|
-
|
|
444
|
+
// resume 的会话:必保存(覆盖原文件),即使没有新增对话也保留原内容;
|
|
445
|
+
// 旧会话缺 workspace 时经此补写归入当前工作区
|
|
446
|
+
memory.saveSessionToPath(this.currentSessionPath, messages, model, silent, currentWorkspace());
|
|
442
447
|
} else if (memory.getHasConversation()) {
|
|
443
448
|
// 新会话:仅有有效对话时才保存;记录路径,后续回合覆盖同一文件
|
|
444
|
-
this.currentSessionPath = memory.saveSession(messages, model, silent);
|
|
449
|
+
this.currentSessionPath = memory.saveSession(messages, model, silent, currentWorkspace());
|
|
445
450
|
}
|
|
446
451
|
}
|
|
447
452
|
|
|
@@ -674,6 +679,7 @@ export class Repl {
|
|
|
674
679
|
this.activeAgentId = agentId;
|
|
675
680
|
this.activeSession = session;
|
|
676
681
|
this.renderer.setSpeakerLabel(getAgentLabel(agentId));
|
|
682
|
+
this.renderer.setPrefixAgent(agentId);
|
|
677
683
|
// 显式复位回合状态,杜绝跨 session 残留 curMsgText/lastText 被误读
|
|
678
684
|
this.renderer.resetTurn();
|
|
679
685
|
this.rendererUnsub?.();
|
|
@@ -802,8 +808,8 @@ export class Repl {
|
|
|
802
808
|
// 子 Agent 的触发消息:固定短句(上下文在复制来的全量日志里,这里只负责"叫醒"它发言)
|
|
803
809
|
const promptText = isSub
|
|
804
810
|
? t(
|
|
805
|
-
`(你是${getAgentLabel(agentId)}
|
|
806
|
-
`(You are ${getAgentLabel(agentId)}. It's your turn — stay in your own character and voice; do not play or mimic another character. Speak briefly.)`,
|
|
811
|
+
`(你是${getAgentLabel(agentId)}。现在轮到你发言——保持你自己的身份和语气,不要扮演或模仿其他角色。直接说台词,不要以「${getAgentLabel(agentId)}:」这类名字前缀开头。请简短发言。)`,
|
|
812
|
+
`(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.)`,
|
|
807
813
|
)
|
|
808
814
|
: input;
|
|
809
815
|
|
|
@@ -814,6 +820,18 @@ export class Repl {
|
|
|
814
820
|
return "";
|
|
815
821
|
}
|
|
816
822
|
|
|
823
|
+
// 输出侧兜底:模型偶发模仿历史消息把「星野:」这类前缀写进台词,统一剥掉。
|
|
824
|
+
// 就地改写文本块 → 提取文本 / TTS / 回填主 session / 会话存档全部干净,
|
|
825
|
+
// 且下轮 speaker 扩展不会再叠出「小鸟游星野:星野:…」双重前缀。
|
|
826
|
+
for (const m of stateMessages.slice(startLen)) {
|
|
827
|
+
if (m.role !== "assistant" || !Array.isArray(m.content)) continue;
|
|
828
|
+
for (const b of m.content) {
|
|
829
|
+
if (b.type === "text" && typeof b.text === "string" && b.text) {
|
|
830
|
+
b.text = stripSpeakerPrefix(b.text, agentId);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
817
835
|
const text = this.extractNewAssistantText(stateMessages, startLen);
|
|
818
836
|
if (!text) return "";
|
|
819
837
|
|
package/src/setup.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as readline from "readline";
|
|
2
|
-
import {
|
|
2
|
+
import { execFileSync } from "child_process";
|
|
3
3
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
import chalk from "chalk";
|
|
@@ -67,7 +67,9 @@ function loadExistingSettings(): Settings {
|
|
|
67
67
|
*/
|
|
68
68
|
function checkPythonVersion(pythonPath: string): { ok: boolean; version: string } {
|
|
69
69
|
try {
|
|
70
|
-
|
|
70
|
+
// execFileSync(不经 shell):Windows 上 Python 常装在含空格路径(C:\Program Files\...),
|
|
71
|
+
// execSync 字符串拼接会把路径拆成多个 token。
|
|
72
|
+
const output = execFileSync(pythonPath, ["--version"], { stdio: "pipe", encoding: "utf-8" }).trim();
|
|
71
73
|
const match = output.match(/Python\s+(\d+)\.(\d+)\.(\d+)/);
|
|
72
74
|
if (!match) return { ok: false, version: output || "unknown" };
|
|
73
75
|
const major = parseInt(match[1]);
|
|
@@ -560,12 +562,18 @@ async function main() {
|
|
|
560
562
|
console.log(chalk.bold.cyan("\nStep 3: Install Python Dependencies\n"));
|
|
561
563
|
|
|
562
564
|
const requirementsFile = join(PROJECT_ROOT, "requirements.txt");
|
|
565
|
+
const pipMirror = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple";
|
|
563
566
|
const pipCmd = demoMode
|
|
564
|
-
? `pip3.13 install -r "${requirementsFile}" -i
|
|
565
|
-
: `${pythonPath} -m pip install -r "${requirementsFile}" -i
|
|
567
|
+
? `pip3.13 install -r "${requirementsFile}" -i ${pipMirror}`
|
|
568
|
+
: `${pythonPath} -m pip install -r "${requirementsFile}" -i ${pipMirror}`;
|
|
566
569
|
let depsOk = false;
|
|
567
570
|
try {
|
|
568
|
-
|
|
571
|
+
// execFileSync 参数数组执行:同 checkPythonVersion,避免 shell 拼接在含空格路径下出错
|
|
572
|
+
if (demoMode) {
|
|
573
|
+
execFileSync("pip3.13", ["install", "-r", requirementsFile, "-i", pipMirror], { stdio: "inherit" });
|
|
574
|
+
} else {
|
|
575
|
+
execFileSync(pythonPath, ["-m", "pip", "install", "-r", requirementsFile, "-i", pipMirror], { stdio: "inherit" });
|
|
576
|
+
}
|
|
569
577
|
console.log(chalk.green(t("\n ✓ Python 依赖安装完成", "\n ✓ Python dependencies installed")));
|
|
570
578
|
depsOk = true;
|
|
571
579
|
} catch {
|
|
@@ -597,7 +605,7 @@ async function main() {
|
|
|
597
605
|
if (!demoMode) {
|
|
598
606
|
// Check dashscope package (safety fallback even after pip install)
|
|
599
607
|
try {
|
|
600
|
-
|
|
608
|
+
execFileSync(pythonPath, ["-c", "import dashscope"], { stdio: "pipe" });
|
|
601
609
|
dashscopeOk = true;
|
|
602
610
|
} catch {
|
|
603
611
|
console.log(chalk.yellow(t(" dashscope 包仍不可用。跳过音色克隆。", " The dashscope package is still unavailable. Skipping voice cloning.")));
|
package/src/speaker_context.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// 当前正在生成的 assistant 回复没有 speaker,不会被加前缀(模型知道自己说什么)。
|
|
10
10
|
|
|
11
11
|
import type { ContextEvent, ExtensionAPI, InlineExtension } from "@earendil-works/pi-coding-agent";
|
|
12
|
-
import { getAgentLabel, type AgentId } from "./agent_registry.ts";
|
|
12
|
+
import { getAgentLabel, getAgentNameVariants, type AgentId } from "./agent_registry.ts";
|
|
13
13
|
|
|
14
14
|
/** speaker 是项目自定义字段(repl.ts 回填时标记),不在 SDK 的 AgentMessage 类型上。 */
|
|
15
15
|
interface SpeakerMessage {
|
|
@@ -18,6 +18,72 @@ interface SpeakerMessage {
|
|
|
18
18
|
content?: Array<{ type: string; text?: string; [k: string]: unknown }>;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// ---- 输出侧「名字:」前缀剥离 -------------------------------------------
|
|
22
|
+
// 群聊历史在发送边界带「角色名:」前缀(下方扩展),模型偶发模仿该格式把
|
|
23
|
+
// 「星野:」这类前缀写进自己的台词。前缀由 UI/TTS 自行标注(speakerLabel),
|
|
24
|
+
// 模型再写一遍就会显示/朗读两遍。输出侧统一剥掉,三层生效:
|
|
25
|
+
// 1) 流式:SpeakerPrefixStripper 处理 text_delta(终端 renderer / GUI 转发);
|
|
26
|
+
// 2) 状态:runOneAgent 结束后改写 assistant 文本块(回填/存档/后续上下文干净,
|
|
27
|
+
// 否则 speaker 扩展下轮会再叠一层,出现「小鸟游星野:星野:…」双重前缀);
|
|
28
|
+
// 3) 提示词:buildSubSystemPrompt 明确禁止(见 agent.ts)。
|
|
29
|
+
|
|
30
|
+
function escapeRegExp(s: string): string {
|
|
31
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 剥掉文本开头「角色名+冒号」前缀(长名优先,短名「星野:」不残留);无前缀原样返回。 */
|
|
35
|
+
export function stripSpeakerPrefix(text: string, agentId: AgentId): string {
|
|
36
|
+
if (!text) return text;
|
|
37
|
+
const names = [...getAgentNameVariants(agentId)].sort((a, b) => b.length - a.length);
|
|
38
|
+
for (const name of names) {
|
|
39
|
+
const m = text.match(new RegExp(`^${escapeRegExp(name)}\\s*[::]`));
|
|
40
|
+
if (m) return text.slice(m[0].length).replace(/^[ \t\u3000]+/, "");
|
|
41
|
+
}
|
|
42
|
+
return text;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 流式前缀剥离器:逐段喂 text_delta,返回可安全输出的增量。
|
|
47
|
+
* 前缀可能横跨多个 delta("名字"与"冒号"分两段到达),开头先扣留缓冲:
|
|
48
|
+
* 仍是某候选前缀的前缀则继续等;一旦完整匹配则消费前缀、放行余文(余文为空时
|
|
49
|
+
* 后续增量直通);确认不匹配或 message 结束时 flush 放行全部扣留内容。
|
|
50
|
+
*/
|
|
51
|
+
export class SpeakerPrefixStripper {
|
|
52
|
+
private buf = "";
|
|
53
|
+
private done = false;
|
|
54
|
+
|
|
55
|
+
constructor(private agentId: AgentId) {}
|
|
56
|
+
|
|
57
|
+
push(delta: string): string {
|
|
58
|
+
if (this.done) return delta;
|
|
59
|
+
this.buf += delta;
|
|
60
|
+
// 长名优先;覆盖全/半角冒号及「名字 冒号」间的一个空格
|
|
61
|
+
const names = [...getAgentNameVariants(this.agentId)].sort((a, b) => b.length - a.length);
|
|
62
|
+
const candidates = names.flatMap((n) => [`${n}:`, `${n}:`, `${n} :`, `${n} :`]);
|
|
63
|
+
for (const c of candidates) {
|
|
64
|
+
if (this.buf.startsWith(c)) {
|
|
65
|
+
this.done = true;
|
|
66
|
+
const rest = this.buf.slice(c.length).replace(/^[ \t\u3000]+/, "");
|
|
67
|
+
this.buf = "";
|
|
68
|
+
return rest;
|
|
69
|
+
}
|
|
70
|
+
if (c.startsWith(this.buf)) return ""; // 还差几个字,继续扣留
|
|
71
|
+
}
|
|
72
|
+
this.done = true;
|
|
73
|
+
const out = this.buf;
|
|
74
|
+
this.buf = "";
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** message 结束时调用:放行仍被扣留的内容(无前缀的短回复可能整段被扣留)。 */
|
|
79
|
+
flush(): string {
|
|
80
|
+
const out = this.done ? "" : this.buf;
|
|
81
|
+
this.buf = "";
|
|
82
|
+
this.done = true;
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
21
87
|
export const speakerContextExtension: InlineExtension = {
|
|
22
88
|
name: "arona-speaker-context",
|
|
23
89
|
hidden: true, // 不显示在启动扩展列表
|
package/src/utils/python.ts
CHANGED
|
@@ -41,11 +41,16 @@ export async function runPython(
|
|
|
41
41
|
};
|
|
42
42
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
43
43
|
|
|
44
|
-
// 优雅停止(如 GUI
|
|
45
|
-
//
|
|
46
|
-
|
|
44
|
+
// 优雅停止(如 GUI 麦克风再点一次=提前结束录音并识别已说内容):
|
|
45
|
+
// - POSIX:SIGUSR1 信号(脚本自行收尾输出,Promise 等待正常 resolve)
|
|
46
|
+
// - Windows:进程信号不可捕获(proc.kill 全部等效硬杀),改写 stdin "stop" 行,
|
|
47
|
+
// 由脚本(stt.py)的 stdin 监视线程置位停止标志。因此 stdinData 为空时保持
|
|
48
|
+
// stdin 打开不 end——脚本自行退出,不依赖 EOF。
|
|
47
49
|
const onGraceful = () => {
|
|
48
|
-
try {
|
|
50
|
+
try {
|
|
51
|
+
if (process.platform === "win32") proc.stdin.write("stop\n");
|
|
52
|
+
else proc.kill("SIGUSR1");
|
|
53
|
+
} catch {}
|
|
49
54
|
};
|
|
50
55
|
gracefulSignal?.addEventListener("abort", onGraceful, { once: true });
|
|
51
56
|
|
|
@@ -96,9 +101,9 @@ export async function runPython(
|
|
|
96
101
|
if (stdinData !== undefined) {
|
|
97
102
|
proc.stdin.write(stdinData);
|
|
98
103
|
proc.stdin.end();
|
|
99
|
-
} else {
|
|
100
|
-
proc.stdin.end();
|
|
101
104
|
}
|
|
105
|
+
// 无 stdinData:stdin 保持打开。脚本不读 stdin、自行退出;过早 end 会堵死
|
|
106
|
+
// Windows 优雅停止的 "stop" 行通道(且已 end 的管道写入必抛错)。
|
|
102
107
|
});
|
|
103
108
|
}
|
|
104
109
|
|
package/src/voice.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { runPython } from "./utils/python.ts";
|
|
|
2
2
|
import { config, updateSettings, verbose } from "./config.ts";
|
|
3
3
|
import { getMainAgent, type AgentId } from "./agent_registry.ts";
|
|
4
4
|
import { getTtsProvider } from "./tts_provider.ts";
|
|
5
|
+
import { t } from "./locale.ts";
|
|
5
6
|
|
|
6
7
|
let ttsEnabled = config.noVoice ? false : config.ttsEnabled;
|
|
7
8
|
let sttEnabled = config.noVoice ? false : config.sttEnabled;
|
|
@@ -75,7 +76,7 @@ export function stripMarkdown(text: string): string {
|
|
|
75
76
|
*/
|
|
76
77
|
export async function listen(signal?: AbortSignal, gracefulSignal?: AbortSignal): Promise<string> {
|
|
77
78
|
if (!config.sttApiKey) {
|
|
78
|
-
console.warn("STT: QWEN_STT_API_KEY not configured");
|
|
79
|
+
console.warn(t("STT: 未配置 QWEN_STT_API_KEY", "STT: QWEN_STT_API_KEY not configured"));
|
|
79
80
|
return "";
|
|
80
81
|
}
|
|
81
82
|
|
|
@@ -91,7 +92,7 @@ export async function listen(signal?: AbortSignal, gracefulSignal?: AbortSignal)
|
|
|
91
92
|
} catch (err) {
|
|
92
93
|
// 用户主动取消(GUI 麦克风再点一次停止录音):静默返回空串
|
|
93
94
|
if (signal?.aborted) return "";
|
|
94
|
-
console.warn(`STT error: ${err instanceof Error ? err.message : err}`);
|
|
95
|
+
console.warn(t(`STT 错误:${err instanceof Error ? err.message : err}`, `STT error: ${err instanceof Error ? err.message : err}`));
|
|
95
96
|
return "";
|
|
96
97
|
}
|
|
97
98
|
}
|
package/src/voices.ts
CHANGED
|
@@ -31,7 +31,7 @@ export interface GptSovitsVoiceConfig {
|
|
|
31
31
|
promptText?: string;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
/**
|
|
34
|
+
/** 每个角色的音色源文件路径(声音复刻上传的音频)。编码子Agent(millennium/justice)无语音素材,置空串。 */
|
|
35
35
|
export const VOICE_AUDIO: Record<AgentId, string> = {
|
|
36
36
|
arona: join(PROJECT_ROOT, "assets", "blue-archive", "arona", "voice.mp3"),
|
|
37
37
|
plana: join(PROJECT_ROOT, "assets", "blue-archive", "plana", "voice.mp3"),
|
|
@@ -41,6 +41,8 @@ export const VOICE_AUDIO: Record<AgentId, string> = {
|
|
|
41
41
|
koharu: join(PROJECT_ROOT, "assets", "blue-archive", "koharu", "voice.mp3"),
|
|
42
42
|
kei: join(PROJECT_ROOT, "assets", "blue-archive", "kei", "voice.mp3"),
|
|
43
43
|
aris: join(PROJECT_ROOT, "assets", "blue-archive", "aris", "voice.mp3"),
|
|
44
|
+
millennium: "",
|
|
45
|
+
justice: "",
|
|
44
46
|
};
|
|
45
47
|
|
|
46
48
|
/**
|
|
@@ -57,6 +59,8 @@ export const VOICE_SOVITS_AUDIO: Record<AgentId, string> = {
|
|
|
57
59
|
koharu: join(PROJECT_ROOT, "assets", "blue-archive", "koharu", "voice_sovits.mp3"),
|
|
58
60
|
kei: join(PROJECT_ROOT, "assets", "blue-archive", "kei", "voice_sovits.mp3"),
|
|
59
61
|
aris: join(PROJECT_ROOT, "assets", "blue-archive", "aris", "voice_sovits.mp3"),
|
|
62
|
+
millennium: "",
|
|
63
|
+
justice: "",
|
|
60
64
|
};
|
|
61
65
|
|
|
62
66
|
function loadVoices(): Record<string, unknown> {
|
package/src/workspace.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// 工作区(workspace):会话所属的目录。CLI 以启动目录为工作区;GUI 支持在下拉里切换,
|
|
2
|
+
// 切换后重建 Agent 会话(SDK cwd 跟随),新会话归属新工作区。
|
|
3
|
+
// 会话 header 记录工作区绝对路径,展示层(CLI /resume 选择器、GUI 侧栏)按工作区分组。
|
|
4
|
+
import { basename, join, resolve } from "path";
|
|
5
|
+
import { homedir } from "os";
|
|
6
|
+
import { existsSync } from "fs";
|
|
7
|
+
import { t } from "./locale.ts";
|
|
8
|
+
|
|
9
|
+
// 活动工作区(GUI 切换时由 setActiveWorkspace 更新;null = 跟随进程启动目录)。
|
|
10
|
+
// agent.ts / coding_agent.ts 的 SDK cwd 均取 currentWorkspace(),切换后新会话即在新目录生效。
|
|
11
|
+
let activeWorkspace: string | null = null;
|
|
12
|
+
// 进程启动目录在模块加载时定格(CLI 语义:启动目录即工作区)
|
|
13
|
+
const startupDir = resolve(process.cwd());
|
|
14
|
+
|
|
15
|
+
/** 当前活动工作区(绝对路径)。未显式设置时 = 进程启动目录(CLI)。 */
|
|
16
|
+
export function currentWorkspace(): string {
|
|
17
|
+
return activeWorkspace ?? startupDir;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* GUI 默认工作区:家目录。GUI 的进程启动目录对用户无意义(多为安装/项目目录),
|
|
22
|
+
* 不作为工作区——GUI 启动时无条件设定活动工作区(上次选择,缺省回落家目录),
|
|
23
|
+
* 因此 GUI 下本回退值不会生效;工作区列表也只含显式选择与会话推导,无启动目录。
|
|
24
|
+
*/
|
|
25
|
+
export function guiDefaultWorkspace(): string {
|
|
26
|
+
return homedir();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 设置活动工作区(GUI 切换工作区时调用;CLI 不调用,始终为启动目录)。 */
|
|
30
|
+
export function setActiveWorkspace(path: string): void {
|
|
31
|
+
activeWorkspace = resolve(path);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 工作区显示名:取目录名;家目录显示「用户目录」;根目录显示 /;空值 = 旧会话未记录工作区,显示「未分组」。 */
|
|
35
|
+
export function workspaceLabel(workspace: string | null | undefined): string {
|
|
36
|
+
if (!workspace) return t("未分组", "Ungrouped");
|
|
37
|
+
if (workspace === "/") return "/";
|
|
38
|
+
if (workspace === homedir()) return t("用户目录", "Home");
|
|
39
|
+
return basename(workspace) || workspace;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface WorkspaceGroup<T extends { workspace?: string; timestamp: string }> {
|
|
43
|
+
/** 工作区绝对路径;null = 旧会话未记录(「未分组」组,恒排最后)。 */
|
|
44
|
+
workspace: string | null;
|
|
45
|
+
label: string;
|
|
46
|
+
sessions: T[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 按工作区分组:组内按会话时间倒序;组间按组内最新会话时间倒序,「未分组」恒排最后。
|
|
51
|
+
* 不区分 CLI/GUI 来源——同属一个工作区的会话归为一组。
|
|
52
|
+
*/
|
|
53
|
+
export function groupByWorkspace<T extends { workspace?: string; timestamp: string }>(
|
|
54
|
+
sessions: T[],
|
|
55
|
+
): WorkspaceGroup<T>[] {
|
|
56
|
+
const byKey = new Map<string | null, T[]>();
|
|
57
|
+
for (const s of sessions) {
|
|
58
|
+
const key = s.workspace || null;
|
|
59
|
+
if (!byKey.has(key)) byKey.set(key, []);
|
|
60
|
+
byKey.get(key)!.push(s);
|
|
61
|
+
}
|
|
62
|
+
const groups: WorkspaceGroup<T>[] = [];
|
|
63
|
+
for (const [workspace, list] of byKey) {
|
|
64
|
+
list.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
65
|
+
groups.push({ workspace, label: workspaceLabel(workspace), sessions: list });
|
|
66
|
+
}
|
|
67
|
+
groups.sort((a, b) => {
|
|
68
|
+
if (!a.workspace) return 1; // 未分组恒最后
|
|
69
|
+
if (!b.workspace) return -1;
|
|
70
|
+
return (b.sessions[0]?.timestamp ?? "").localeCompare(a.sessions[0]?.timestamp ?? "");
|
|
71
|
+
});
|
|
72
|
+
return groups;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ============================================================
|
|
76
|
+
// 旧会话工作区推断(一次性回填)
|
|
77
|
+
// ============================================================
|
|
78
|
+
|
|
79
|
+
// POSIX 绝对路径(/Users/...、/home/...,含更深层级)。JSON 文本里路径两侧是引号/空白。
|
|
80
|
+
const POSIX_PATH_RE = /\/(?:Users|home)\/[^\s"'`,:;\\<>|*?[\]()]+/g;
|
|
81
|
+
// Windows 路径(JSON 内反斜杠成对出现:C:\\Users\\...)
|
|
82
|
+
const WIN_PATH_RE = /[A-Za-z]:(?:\\\\|\\)[^\s"',:;*?<>|]+/g;
|
|
83
|
+
|
|
84
|
+
/** 项目根标志文件:候选目录里存在任一即视为项目根(推断并列时的最强信号)。 */
|
|
85
|
+
const PROJECT_MARKERS = ["package.json", ".git", "pyproject.toml", "Cargo.toml", "go.mod", "requirements.txt", "pom.xml"];
|
|
86
|
+
|
|
87
|
+
/** 常见代码子目录名:路径并列时降低其优先级(工作区应是项目根而非其中的子目录)。 */
|
|
88
|
+
const CODE_DIR_NAMES = new Set([
|
|
89
|
+
"src", "lib", "app", "bin", "dist", "build", "out", "test", "tests", "docs",
|
|
90
|
+
"node_modules", "components", "utils", "hooks", "services", "pages", "api",
|
|
91
|
+
"public", "assets", "scripts", "config", "types", "python", "gui", "pet",
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
/** 推断时排除的目录(无项目意义):系统根、用户家目录本身、常见系统目录。 */
|
|
95
|
+
function excludedDirs(): Set<string> {
|
|
96
|
+
const home = homedir();
|
|
97
|
+
return new Set([
|
|
98
|
+
"/", "/Users", "/home", home,
|
|
99
|
+
"/tmp", "/usr", "/var", "/etc", "/opt", "/bin", "/sbin", "/private",
|
|
100
|
+
"/Applications", "/System", "/Library", "/Volumes",
|
|
101
|
+
"/dev", "/run", "/proc", "/sys",
|
|
102
|
+
]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function stripTrailingPunct(p: string): string {
|
|
106
|
+
return p.replace(/[.,;:!?)、」』"'`]+$/g, "");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function looksLikeFile(lastSegment: string): boolean {
|
|
110
|
+
return /\.[A-Za-z0-9]{1,8}$/.test(lastSegment);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isProjectRoot(dir: string): boolean {
|
|
114
|
+
try {
|
|
115
|
+
return PROJECT_MARKERS.some((m) => existsSync(join(dir, m)));
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 从会话内容推断所属工作区:统计文本中出现的绝对路径的各级祖先目录频次,
|
|
123
|
+
* 取出现 ≥2 次中频次最高的目录;并列时依次按「磁盘上是项目根(有标志文件)」
|
|
124
|
+
* →「非常见代码目录名」→「更深」择优。纯聊天会话无路径,返回 null 保持「未分组」。
|
|
125
|
+
*/
|
|
126
|
+
export function inferWorkspaceFromContent(content: string): string | null {
|
|
127
|
+
if (!content) return null;
|
|
128
|
+
const excluded = excludedDirs();
|
|
129
|
+
const counts = new Map<string, number>();
|
|
130
|
+
|
|
131
|
+
const record = (rawPath: string, sep: string) => {
|
|
132
|
+
let p = stripTrailingPunct(rawPath);
|
|
133
|
+
if (sep === "\\") p = p.replace(/\\+/g, "\\");
|
|
134
|
+
const segments = p.split(/[\\/]/).filter(Boolean);
|
|
135
|
+
// 文件路径(末段带扩展名)只统计其目录祖先,不把文件本身当目录
|
|
136
|
+
const dirLen = looksLikeFile(segments[segments.length - 1] ?? "") ? segments.length - 1 : segments.length;
|
|
137
|
+
for (let i = 2; i <= dirLen; i++) {
|
|
138
|
+
const dir = segments.slice(0, i).join(sep === "\\" ? "\\" : "/");
|
|
139
|
+
const full = sep === "\\" ? dir : "/" + dir;
|
|
140
|
+
if (excluded.has(full)) continue;
|
|
141
|
+
counts.set(full, (counts.get(full) ?? 0) + 1);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
for (const m of content.match(POSIX_PATH_RE) ?? []) record(m, "/");
|
|
146
|
+
for (const m of content.match(WIN_PATH_RE) ?? []) record(m, "\\");
|
|
147
|
+
|
|
148
|
+
const maxCount = Math.max(0, ...counts.values());
|
|
149
|
+
if (maxCount < 2) return null;
|
|
150
|
+
let candidates = [...counts.entries()].filter(([, c]) => c === maxCount).map(([d]) => d);
|
|
151
|
+
|
|
152
|
+
const deepest = (dirs: string[]): string | null =>
|
|
153
|
+
dirs.length ? dirs.reduce((a, b) => (b.split(/[\\/]/).length > a.split(/[\\/]/).length ? b : a)) : null;
|
|
154
|
+
|
|
155
|
+
// 1) 磁盘上的项目根(标志文件)
|
|
156
|
+
const withMarkers = candidates.filter(isProjectRoot);
|
|
157
|
+
if (withMarkers.length) return deepest(withMarkers);
|
|
158
|
+
// 2) 排除常见代码子目录名
|
|
159
|
+
const notCodeDir = candidates.filter((d) => !CODE_DIR_NAMES.has(d.split(/[\\/]/).pop()!.toLowerCase()));
|
|
160
|
+
if (notCodeDir.length) candidates = notCodeDir;
|
|
161
|
+
// 3) 并列取更深
|
|
162
|
+
return deepest(candidates);
|
|
163
|
+
}
|