agentus 0.0.0-stage → 0.2.0
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/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +476 -2
- package/README.zh-CN.md +396 -0
- package/bin/agentus.mjs +147 -0
- package/docs/android-notify-contract.md +658 -0
- package/docs/dev/01-dev-loop.md +186 -0
- package/docs/dev/02-verify.md +237 -0
- package/docs/dev/03-release.md +218 -0
- package/docs/dev/04-recover.md +199 -0
- package/docs/dev/05-agent-driven.md +114 -0
- package/docs/dev/README.md +65 -0
- package/docs/installation.md +366 -0
- package/docs/installation.zh-CN.md +350 -0
- package/docs/refs/aionui-acp-rules.md +46 -0
- package/docs/refs/hermes-studio-ui-ideas.md +191 -0
- package/docs/transcript-folding.md +139 -0
- package/package.json +76 -5
- package/packages/server/mcp/plan-server.mjs +217 -0
- package/packages/server/mock/agent.mjs +693 -0
- package/packages/server/package.json +22 -0
- package/packages/server/src/acp/backends.ts +196 -0
- package/packages/server/src/acp/errors.ts +46 -0
- package/packages/server/src/acp/registry.ts +579 -0
- package/packages/server/src/acp/session-manager.ts +1868 -0
- package/packages/server/src/auth.ts +688 -0
- package/packages/server/src/dashscope.ts +235 -0
- package/packages/server/src/fs.ts +379 -0
- package/packages/server/src/hotwords.ts +132 -0
- package/packages/server/src/index.ts +1486 -0
- package/packages/server/src/notify/center.ts +994 -0
- package/packages/server/src/notify/types.ts +222 -0
- package/packages/server/src/plan/plan.ts +126 -0
- package/packages/server/src/pty-helper.py +135 -0
- package/packages/server/src/settings.ts +469 -0
- package/packages/server/src/store/export.ts +209 -0
- package/packages/server/src/store/store.ts +1238 -0
- package/packages/server/src/term.ts +201 -0
- package/packages/server/src/title.ts +109 -0
- package/packages/server/src/voice.ts +251 -0
- package/packages/shared/package.json +14 -0
- package/packages/shared/src/index.ts +271 -0
- package/packages/web/dist/assets/index-D667PX2c.css +32 -0
- package/packages/web/dist/assets/index-d9NzwPFu.js +283 -0
- package/packages/web/dist/coding-agents/hermes.png +0 -0
- package/packages/web/dist/coding-agents/qoder.svg +5 -0
- package/packages/web/dist/icons/agentus-icon-inverse.svg +301 -0
- package/packages/web/dist/icons/agentus-icon.svg +301 -0
- package/packages/web/dist/icons/agentus-mark-inverse.svg +301 -0
- package/packages/web/dist/icons/agentus-mark.svg +301 -0
- package/packages/web/dist/icons/apple-touch-icon-180.png +0 -0
- package/packages/web/dist/icons/favicon.ico +0 -0
- package/packages/web/dist/icons/icon-192.png +0 -0
- package/packages/web/dist/icons/icon-512.png +0 -0
- package/packages/web/dist/icons/icon-maskable-512.png +0 -0
- package/packages/web/dist/index.html +30 -0
- package/packages/web/dist/manifest.webmanifest +17 -0
- package/packages/web/dist/sw.js +61 -0
- package/scripts/make-cert.sh +101 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// A real (PTY-backed) shell for the workspace panel's Terminal tab.
|
|
2
|
+
//
|
|
3
|
+
// Design notes, since this is the one place the cockpit forks a process that is
|
|
4
|
+
// not an agent:
|
|
5
|
+
//
|
|
6
|
+
// * **No native dependency, and honest about what that costs.** node-pty is a
|
|
7
|
+
// build-time native module; this project is deliberately dependency-light, so
|
|
8
|
+
// the default shell is `$SHELL -i` over ordinary pipes. That runs commands,
|
|
9
|
+
// streams output, keeps the prompt and `cd` — everything a cockpit panel is
|
|
10
|
+
// used for — but it is *not* a tty: no job control, no colour, and full-screen
|
|
11
|
+
// programs (vim, top) will refuse to start. Two ways out, both opt-in:
|
|
12
|
+
// AGENTUS_TERM_PTY=1 wraps the shell in Python's stdlib `pty.spawn` for a
|
|
13
|
+
// real terminal, and AGENTUS_TERM_CMD overrides the whole command line.
|
|
14
|
+
// * **One shell per panel, killed with the socket.** The child lives in the
|
|
15
|
+
// server's own group and is killed on disconnect, on `term-close`, and in the
|
|
16
|
+
// process-exit safety net (see installSafetyNet) — an abandoned shell is
|
|
17
|
+
// exactly the orphan class AC5 exists to prevent.
|
|
18
|
+
// * **The workspace is the fence.** The shell starts in the slot's workspace,
|
|
19
|
+
// because that is the directory the operator pointed this slot at. It is not
|
|
20
|
+
// a sandbox and does not pretend to be: the person running the cockpit already
|
|
21
|
+
// owns the machine (same trust model as the agent processes).
|
|
22
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
23
|
+
import { randomUUID } from "node:crypto";
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
27
|
+
|
|
28
|
+
export interface TermSession {
|
|
29
|
+
id: string;
|
|
30
|
+
cwd: string;
|
|
31
|
+
pid: number | null;
|
|
32
|
+
/** bytes of output kept for a late joiner / a reconnect */
|
|
33
|
+
buffer: string;
|
|
34
|
+
child: ChildProcess;
|
|
35
|
+
subscribers: Set<(chunk: string) => void>;
|
|
36
|
+
exited: { code: number | null; signal: string | null } | null;
|
|
37
|
+
/** true when the child has a real tty (see shellCommand). The panel says which it got, because
|
|
38
|
+
* a pipe shell and a tty look nothing alike and the operator should not have to guess. */
|
|
39
|
+
tty: boolean;
|
|
40
|
+
/** Resize the tty (no-op on a pipe shell). The kernel then SIGWINCHes the child's foreground
|
|
41
|
+
* process group, which is what makes a full-screen program redraw at the new width. */
|
|
42
|
+
resize: (cols: number, rows: number) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const MAX_BUFFER = 256 * 1024;
|
|
46
|
+
|
|
47
|
+
/** The PTY helper that ships with the server. */
|
|
48
|
+
function ptyHelperPath(): string | null {
|
|
49
|
+
for (const rel of ["./pty-helper.py", "../src/pty-helper.py"]) {
|
|
50
|
+
try {
|
|
51
|
+
const p = fileURLToPath(new URL(rel, import.meta.url));
|
|
52
|
+
if (fs.existsSync(p)) return p;
|
|
53
|
+
} catch {
|
|
54
|
+
/* not this layout */
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** How to start the shell.
|
|
61
|
+
*
|
|
62
|
+
* Default is a REAL pty via `pty-helper.py` (python3 stdlib `forkpty`): the shell gets a controlling
|
|
63
|
+
* terminal, so prompts, echo, colours, job control and full-screen programs behave. That is the
|
|
64
|
+
* whole point of the panel — a terminal emulator in front of a pipe shell is a lie (no prompt lands
|
|
65
|
+
* on screen, keystrokes are never echoed, resize means nothing).
|
|
66
|
+
*
|
|
67
|
+
* Escapes: `AGENTUS_TERM_CMD` runs an arbitrary command line instead (pipes), `AGENTUS_TERM_PTY=0`
|
|
68
|
+
* forces the old pipe shell, and a machine without python3 degrades to it automatically. Whoever
|
|
69
|
+
* gets a pipe shell is told so in the panel rather than being shown a fake terminal. */
|
|
70
|
+
function shellCommand(shell: string): { cmd: string; args: string[]; tty: boolean } {
|
|
71
|
+
const override = (process.env.AGENTUS_TERM_CMD || "").trim();
|
|
72
|
+
if (override) {
|
|
73
|
+
const parts = override.split(/\s+/);
|
|
74
|
+
return { cmd: parts[0], args: parts.slice(1), tty: false };
|
|
75
|
+
}
|
|
76
|
+
if (process.env.AGENTUS_TERM_PTY !== "0") {
|
|
77
|
+
const helper = ptyHelperPath();
|
|
78
|
+
if (helper) return { cmd: "python3", args: [helper, shell, "-i"], tty: true };
|
|
79
|
+
}
|
|
80
|
+
return { cmd: shell, args: ["-i"], tty: false };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function pickShell(): string {
|
|
84
|
+
const candidate = process.env.SHELL && fs.existsSync(process.env.SHELL) ? process.env.SHELL : null;
|
|
85
|
+
if (candidate) return candidate;
|
|
86
|
+
for (const s of ["/bin/zsh", "/bin/bash", "/bin/sh"]) {
|
|
87
|
+
if (fs.existsSync(s)) return s;
|
|
88
|
+
}
|
|
89
|
+
return "/bin/sh";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export class TermService {
|
|
93
|
+
#sessions = new Map<string, TermSession>();
|
|
94
|
+
|
|
95
|
+
start(cwd: string, size?: { cols: number; rows: number }): TermSession {
|
|
96
|
+
const shell = pickShell();
|
|
97
|
+
const { cmd, args, tty } = shellCommand(shell);
|
|
98
|
+
const cols = Math.max(20, Math.min(size?.cols ?? 80, 1000));
|
|
99
|
+
const rows = Math.max(5, Math.min(size?.rows ?? 24, 1000));
|
|
100
|
+
const child = spawn(cmd, args, {
|
|
101
|
+
cwd: fs.existsSync(cwd) ? cwd : homedir(),
|
|
102
|
+
env: {
|
|
103
|
+
...process.env,
|
|
104
|
+
TERM: "xterm-256color",
|
|
105
|
+
PYTHONUNBUFFERED: "1",
|
|
106
|
+
// Let the shell know a machine is driving, so prompts stay plain.
|
|
107
|
+
AGENTUS_TERMINAL: "1",
|
|
108
|
+
// The pty is sized at start because the shell reads its size when it draws the first
|
|
109
|
+
// prompt — resizing afterwards still works, but a 80x24 flash on every open is not free.
|
|
110
|
+
AGENTUS_PTY_COLS: String(cols),
|
|
111
|
+
AGENTUS_PTY_ROWS: String(rows),
|
|
112
|
+
},
|
|
113
|
+
// fd 3 is the PTY helper's control channel (resize). A pipe shell has no use for it.
|
|
114
|
+
stdio: tty ? ["pipe", "pipe", "pipe", "pipe"] : ["pipe", "pipe", "pipe"],
|
|
115
|
+
// own process group: the shell (or the pty helper wrapping it) is killed as a group, never
|
|
116
|
+
// one process at a time (see kill()).
|
|
117
|
+
detached: true,
|
|
118
|
+
}) as ChildProcess;
|
|
119
|
+
|
|
120
|
+
const ctl = tty ? ((child.stdio[3] ?? null) as NodeJS.WritableStream | null) : null;
|
|
121
|
+
const session: TermSession = {
|
|
122
|
+
id: randomUUID(),
|
|
123
|
+
cwd,
|
|
124
|
+
pid: child.pid ?? null,
|
|
125
|
+
buffer: "",
|
|
126
|
+
child,
|
|
127
|
+
subscribers: new Set(),
|
|
128
|
+
exited: null,
|
|
129
|
+
tty,
|
|
130
|
+
resize: (c: number, r: number): void => {
|
|
131
|
+
if (!ctl) return;
|
|
132
|
+
try {
|
|
133
|
+
ctl.write(`resize ${Math.max(1, Math.min(c, 1000))} ${Math.max(1, Math.min(r, 1000))}\n`);
|
|
134
|
+
} catch {
|
|
135
|
+
/* the helper is gone; the exit event says so */
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
const push = (chunk: string): void => {
|
|
140
|
+
session.buffer = (session.buffer + chunk).slice(-MAX_BUFFER);
|
|
141
|
+
for (const fn of session.subscribers) {
|
|
142
|
+
try {
|
|
143
|
+
fn(chunk);
|
|
144
|
+
} catch {
|
|
145
|
+
/* a dead socket must not break the other listeners */
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
child.stdout?.setEncoding("utf8");
|
|
150
|
+
child.stderr?.setEncoding("utf8");
|
|
151
|
+
// stderr is part of the terminal picture (a missing command prints there). With a pty both
|
|
152
|
+
// streams are already merged into the pty master, so only stdout carries them — harmless.
|
|
153
|
+
child.stdout?.on("data", (d: string) => push(d));
|
|
154
|
+
child.stderr?.on("data", (d: string) => push(d));
|
|
155
|
+
child.on("exit", (code: number | null, signal: string | null) => {
|
|
156
|
+
session.exited = { code, signal };
|
|
157
|
+
push(`\r\n[process exited${code != null ? ` with code ${code}` : ""}${signal ? ` (${signal})` : ""}]\r\n`);
|
|
158
|
+
this.#sessions.delete(session.id);
|
|
159
|
+
});
|
|
160
|
+
child.on("error", (err: Error) => push(`\r\n[cannot start shell: ${err.message}]\r\n`));
|
|
161
|
+
this.#sessions.set(session.id, session);
|
|
162
|
+
return session;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
get(id: string): TermSession | undefined {
|
|
166
|
+
return this.#sessions.get(id);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
list(): { id: string; cwd: string; pid: number | null; alive: boolean }[] {
|
|
170
|
+
return [...this.#sessions.values()].map((s) => ({
|
|
171
|
+
id: s.id, cwd: s.cwd, pid: s.pid, alive: s.exited === null,
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
kill(id: string): boolean {
|
|
176
|
+
const s = this.#sessions.get(id);
|
|
177
|
+
if (!s) return false;
|
|
178
|
+
// The wrapper is `script`; killing the group takes the shell with it.
|
|
179
|
+
try {
|
|
180
|
+
if (s.child.pid) process.kill(-s.child.pid, "SIGKILL");
|
|
181
|
+
} catch {
|
|
182
|
+
/* no own process group: fall through to the direct kill */
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
s.child.kill("SIGKILL");
|
|
186
|
+
} catch {
|
|
187
|
+
/* already gone */
|
|
188
|
+
}
|
|
189
|
+
this.#sessions.delete(id);
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Used by the process-exit safety net and on shutdown: no shells outlive us. */
|
|
194
|
+
killAll(): number {
|
|
195
|
+
const ids = [...this.#sessions.keys()];
|
|
196
|
+
for (const id of ids) this.kill(id);
|
|
197
|
+
return ids.length;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export const terms = new TermService();
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Session titles.
|
|
2
|
+
//
|
|
3
|
+
// Two sources, in this order of authority:
|
|
4
|
+
// 1. the AGENT — ACP's own `session_info_update` carries `title` ("Agents send this
|
|
5
|
+
// notification to update session information like title… This allows clients to
|
|
6
|
+
// display dynamic session names"). Zero cost, no model call in our layer, and it is
|
|
7
|
+
// the only source that can be right about a conversation we never parse. If an agent
|
|
8
|
+
// ever uses it, it wins.
|
|
9
|
+
// 2. US — a deterministic derivation, so a new session stops being "Hermes @ tmp" the
|
|
10
|
+
// moment the operator asks something. hermes-studio/AionUi lineage (AionUi's
|
|
11
|
+
// `buildAutoTitleFromContent`): first non-empty line of the first user prompt, markdown
|
|
12
|
+
// furniture stripped, ≤50 chars. No model, instant, works offline.
|
|
13
|
+
//
|
|
14
|
+
// The operator's own rename always outranks both (the store keeps `title` = what is
|
|
15
|
+
// displayed and `auto_title` = the generated name, so a rename is reversible and a
|
|
16
|
+
// generated name never stomps a hand-written one).
|
|
17
|
+
import type { StoredMessage } from "../../shared/src/index.js";
|
|
18
|
+
|
|
19
|
+
/** Hard cap for a single-line row, in characters (AionUi uses 50). */
|
|
20
|
+
export const TITLE_MAX = 50;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Deterministic title from one blob of text (AionUi's rules, reimplemented):
|
|
24
|
+
* drop thinking blocks, take the first line that carries words, strip the markdown
|
|
25
|
+
* furniture that would otherwise lead (`#`, `>`, `-`, `1.`), collapse runs of spaces,
|
|
26
|
+
* and cap the length. Returns null when nothing usable is left.
|
|
27
|
+
*/
|
|
28
|
+
export function deriveTitle(raw: string | null | undefined): string | null {
|
|
29
|
+
if (!raw) return null;
|
|
30
|
+
const withoutThink = raw
|
|
31
|
+
.replace(/<think(?:ing)?>[\s\S]*?<\/think(?:ing)?>/gi, " ")
|
|
32
|
+
.replace(/<think(?:ing)?>[\s\S]*$/i, " ");
|
|
33
|
+
const lines = withoutThink
|
|
34
|
+
.replace(/\r/g, "")
|
|
35
|
+
.split("\n")
|
|
36
|
+
.map((l) => l.trim())
|
|
37
|
+
.filter((l) => l && l !== "```" && !/^```/.test(l));
|
|
38
|
+
const first = lines[0] ?? "";
|
|
39
|
+
const normalized = first
|
|
40
|
+
.replace(/^[#>*\-\d.\s]+/u, "")
|
|
41
|
+
.replace(/\s+/g, " ")
|
|
42
|
+
.trim()
|
|
43
|
+
.slice(0, TITLE_MAX)
|
|
44
|
+
.trim();
|
|
45
|
+
return normalized || null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Title from the FIRST user prompt we stored for a session (the AionUi trigger point). */
|
|
49
|
+
export function titleFromFirstPrompt(msgs: StoredMessage[]): string | null {
|
|
50
|
+
for (const m of msgs) {
|
|
51
|
+
if (m.kind !== "user") continue;
|
|
52
|
+
const t = deriveTitle(userText(m));
|
|
53
|
+
if (t) return t;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Title from the LATEST user prompt — the cheap reading of "regenerate from the context":
|
|
59
|
+
* what the operator is working on NOW, rather than what they said first. */
|
|
60
|
+
export function titleFromLatestPrompt(msgs: StoredMessage[]): string | null {
|
|
61
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
62
|
+
const m = msgs[i];
|
|
63
|
+
if (m.kind !== "user") continue;
|
|
64
|
+
const t = deriveTitle(userText(m));
|
|
65
|
+
if (t) return t;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** user rows store `{ text }`; accept a `{content:{text}}` shape too so this keeps working
|
|
71
|
+
* whichever way the ingest writes them (export.ts learned that lesson already). */
|
|
72
|
+
function userText(m: StoredMessage): string {
|
|
73
|
+
const p = (m.payload ?? {}) as {
|
|
74
|
+
text?: unknown;
|
|
75
|
+
content?: { text?: unknown } | null;
|
|
76
|
+
};
|
|
77
|
+
if (typeof p.text === "string") return p.text;
|
|
78
|
+
if (p.content && typeof p.content.text === "string") return p.content.text;
|
|
79
|
+
return "";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The prompt for the agent-driven path. Short on purpose: the answer is one line we paste
|
|
84
|
+
* into a row, and every extra word is an extra second of latency.
|
|
85
|
+
*/
|
|
86
|
+
export const TITLE_INSTRUCTION =
|
|
87
|
+
"请为这段对话生成一个会话标题:不超过 12 个字或 6 个英文单词,直接输出标题本身," +
|
|
88
|
+
"不要引号、不要句号、不要任何解释或前缀。";
|
|
89
|
+
|
|
90
|
+
/** Clean whatever the agent answered into a single-line title. Models like to answer with
|
|
91
|
+
* quotes, a bullet, "标题:" or a whole sentence; none of that belongs in a rail row. */
|
|
92
|
+
export function cleanAgentTitle(raw: string | null | undefined): string | null {
|
|
93
|
+
if (!raw) return null;
|
|
94
|
+
let t = raw.replace(/<think(?:ing)?>[\s\S]*?<\/think(?:ing)?>/gi, " ");
|
|
95
|
+
const line = t
|
|
96
|
+
.replace(/\r/g, "")
|
|
97
|
+
.split("\n")
|
|
98
|
+
.map((l) => l.trim())
|
|
99
|
+
.filter(Boolean)
|
|
100
|
+
.find((l) => !/^```/.test(l)) ?? "";
|
|
101
|
+
t = line
|
|
102
|
+
.replace(/^[#>*\-\d.、\s]+/u, "")
|
|
103
|
+
.replace(/^(会话标题|标题|title)\s*[::]\s*/i, "")
|
|
104
|
+
.replace(/^["'“”‘’《【\[]+|["'“”‘’》】\]]+$/g, "")
|
|
105
|
+
.replace(/\s+/g, " ")
|
|
106
|
+
.trim();
|
|
107
|
+
if (!t) return null;
|
|
108
|
+
return t.slice(0, TITLE_MAX).trim() || null;
|
|
109
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// Voice: speech-to-text and text-to-speech.
|
|
2
|
+
//
|
|
3
|
+
// Two layers (unchanged from the first voice pass): the browser can do both directions
|
|
4
|
+
// with zero configuration, and the server adds a provider when the operator configures
|
|
5
|
+
// one. What changed is where the configuration lives: the settings page (settings.ts)
|
|
6
|
+
// owns provider / base URL / key / models / voice / hotwords; env remains a bootstrap.
|
|
7
|
+
//
|
|
8
|
+
// This module is a ROUTER over providers:
|
|
9
|
+
// "dashscope" 阿里百炼 (dashscope.ts): streaming ASR over its inference WebSocket,
|
|
10
|
+
// batch ASR over the OpenAI-compatible chat route, TTS via
|
|
11
|
+
// SpeechSynthesizer; fixed + dynamic hotwords ride along.
|
|
12
|
+
// "openai" any OpenAI-compatible /audio/speech + /audio/transcriptions endpoint
|
|
13
|
+
// (the AGENTUS_TTS_* / AGENTUS_STT_* bootstrap path).
|
|
14
|
+
// "browser" no server path (the UI uses speechSynthesis / SpeechRecognition).
|
|
15
|
+
//
|
|
16
|
+
// The server still synthesises or recognises nothing itself: every call forwards to an
|
|
17
|
+
// endpoint the operator owns. That keeps the project's "the UI holds no intelligence"
|
|
18
|
+
// line intact (a voice endpoint is I/O, like the file panel, not an agent).
|
|
19
|
+
import { getSettings, dashscopeUrls } from "./settings.js";
|
|
20
|
+
import { dashscopeAsr, dashscopeTts, DashscopeError } from "./dashscope.js";
|
|
21
|
+
|
|
22
|
+
export interface VoiceConfig {
|
|
23
|
+
tts: { baseUrl: string; key: string; model: string; voice: string } | null;
|
|
24
|
+
stt: { baseUrl: string; key: string; model: string; language: string } | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const trimmed = (v: string | undefined): string => String(v ?? "").trim().replace(/\/+$/, "");
|
|
28
|
+
|
|
29
|
+
/** Env bootstrap for the OpenAI-compatible provider (unchanged semantics: what the
|
|
30
|
+
* fake-endpoint tests and openai users hit; the settings page overrides it). */
|
|
31
|
+
export function voiceConfig(env: NodeJS.ProcessEnv = process.env): VoiceConfig {
|
|
32
|
+
const ttsBase = trimmed(env.AGENTUS_TTS_BASE_URL);
|
|
33
|
+
const sttBase = trimmed(env.AGENTUS_STT_BASE_URL);
|
|
34
|
+
return {
|
|
35
|
+
tts: ttsBase
|
|
36
|
+
? {
|
|
37
|
+
baseUrl: ttsBase,
|
|
38
|
+
key: String(env.AGENTUS_TTS_API_KEY ?? env.OPENAI_API_KEY ?? ""),
|
|
39
|
+
model: String(env.AGENTUS_TTS_MODEL || "tts-1"),
|
|
40
|
+
voice: String(env.AGENTUS_TTS_VOICE || "alloy"),
|
|
41
|
+
}
|
|
42
|
+
: null,
|
|
43
|
+
stt: sttBase
|
|
44
|
+
? {
|
|
45
|
+
baseUrl: sttBase,
|
|
46
|
+
key: String(env.AGENTUS_STT_API_KEY ?? env.OPENAI_API_KEY ?? ""),
|
|
47
|
+
model: String(env.AGENTUS_STT_MODEL || "whisper-1"),
|
|
48
|
+
language: String(env.AGENTUS_STT_LANGUAGE || ""),
|
|
49
|
+
}
|
|
50
|
+
: null,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type VoiceProvider = "dashscope" | "openai" | "browser";
|
|
55
|
+
|
|
56
|
+
/** Which provider would answer right now. The settings page wins over env; "dashscope"
|
|
57
|
+
* selected but incomplete resolves to "browser" (the UI must not be promised a server
|
|
58
|
+
* path that will 501). */
|
|
59
|
+
export function resolveVoice(env: NodeJS.ProcessEnv = process.env): {
|
|
60
|
+
provider: VoiceProvider;
|
|
61
|
+
tts: boolean;
|
|
62
|
+
stt: boolean;
|
|
63
|
+
openai: VoiceConfig;
|
|
64
|
+
} {
|
|
65
|
+
const v = getSettings().voice;
|
|
66
|
+
const openai = voiceConfig(env);
|
|
67
|
+
if (v.provider === "dashscope") {
|
|
68
|
+
const ready = Boolean(v.baseUrl && v.apiKey);
|
|
69
|
+
return { provider: ready ? "dashscope" : "browser", tts: ready, stt: ready, openai };
|
|
70
|
+
}
|
|
71
|
+
if (v.provider === "openai") {
|
|
72
|
+
const has = Boolean(v.baseUrl && v.apiKey);
|
|
73
|
+
return {
|
|
74
|
+
provider: has || openai.tts || openai.stt ? "openai" : "browser",
|
|
75
|
+
tts: has || Boolean(openai.tts),
|
|
76
|
+
stt: has || Boolean(openai.stt),
|
|
77
|
+
openai,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
// provider "browser": env bootstrap still counts (a test server or an operator who
|
|
81
|
+
// prefers env over the page)
|
|
82
|
+
return { provider: "browser", tts: Boolean(openai.tts), stt: Boolean(openai.stt), openai };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** What the browser asks before it decides who speaks. */
|
|
86
|
+
export function voiceCapabilities(): Record<string, unknown> {
|
|
87
|
+
const v = getSettings().voice;
|
|
88
|
+
const r = resolveVoice();
|
|
89
|
+
return {
|
|
90
|
+
provider: r.provider,
|
|
91
|
+
tts: {
|
|
92
|
+
server: r.tts,
|
|
93
|
+
model: r.provider === "dashscope" ? v.ttsModel : (r.openai.tts?.model ?? null),
|
|
94
|
+
voice: r.provider === "dashscope" ? v.ttsVoice : (r.openai.tts?.voice ?? null),
|
|
95
|
+
},
|
|
96
|
+
stt: {
|
|
97
|
+
server: r.stt,
|
|
98
|
+
streaming: r.provider === "dashscope" && v.asrStream,
|
|
99
|
+
model: r.provider === "dashscope" ? v.asrModel : null,
|
|
100
|
+
batchModel: r.provider === "dashscope" ? v.asrBatchModel : (r.openai.stt?.model ?? null),
|
|
101
|
+
language: r.openai.stt?.language || null,
|
|
102
|
+
},
|
|
103
|
+
hotwords: { fixed: v.hotwords.length, dynamic: v.dynamicHotwords, limit: v.hotwordLimit },
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export class VoiceError extends Error {
|
|
108
|
+
constructor(message: string, readonly code: "not_configured" | "upstream" | "timeout") {
|
|
109
|
+
super(message);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const TIMEOUT_MS = 60_000;
|
|
114
|
+
|
|
115
|
+
async function withTimeout<T>(label: string, run: (signal: AbortSignal) => Promise<T>): Promise<T> {
|
|
116
|
+
const ac = new AbortController();
|
|
117
|
+
const timer = setTimeout(() => ac.abort(), TIMEOUT_MS);
|
|
118
|
+
try {
|
|
119
|
+
return await run(ac.signal);
|
|
120
|
+
} catch (e) {
|
|
121
|
+
if ((e as Error).name === "AbortError") throw new VoiceError(`${label} timed out after ${TIMEOUT_MS / 1000}s`, "timeout");
|
|
122
|
+
if (e instanceof VoiceError || e instanceof DashscopeError) throw e;
|
|
123
|
+
// DNS/connection failure: the operator's endpoint is the problem, not this process —
|
|
124
|
+
// report it as an upstream error so the UI says what actually happened.
|
|
125
|
+
throw new VoiceError(`${label}: cannot reach the voice endpoint (${String((e as Error)?.message ?? e)})`, "upstream");
|
|
126
|
+
} finally {
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function wrapDashscope(e: unknown): VoiceError {
|
|
132
|
+
if (e instanceof DashscopeError) {
|
|
133
|
+
return new VoiceError(e.message, e.code === "timeout" ? "timeout" : e.code === "not_configured" ? "not_configured" : "upstream");
|
|
134
|
+
}
|
|
135
|
+
return new VoiceError(String((e as Error)?.message ?? e), "upstream");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Text -> audio bytes, routed. The browser only ever sees bytes: the key and the
|
|
139
|
+
* provider's signed result URL stay server-side. */
|
|
140
|
+
export async function synthesize(text: string, opts: { voice?: string; speed?: number } = {}): Promise<{
|
|
141
|
+
contentType: string;
|
|
142
|
+
audio: Buffer;
|
|
143
|
+
}> {
|
|
144
|
+
const v = getSettings().voice;
|
|
145
|
+
const r = resolveVoice();
|
|
146
|
+
if (r.provider === "dashscope") return dashscopeTts(text, opts).catch((e) => { throw wrapDashscope(e); });
|
|
147
|
+
// openai-compatible: settings endpoint+key when complete, else the env bootstrap
|
|
148
|
+
const fromSettings = v.baseUrl && v.apiKey ? { baseUrl: trimmed(v.baseUrl), key: v.apiKey, model: v.ttsModel || "tts-1", voice: v.ttsVoice } : null;
|
|
149
|
+
const cfg = fromSettings ?? r.openai.tts;
|
|
150
|
+
if (!cfg) throw new VoiceError("server TTS is not configured (settings page, or AGENTUS_TTS_BASE_URL)", "not_configured");
|
|
151
|
+
const res = await withTimeout("tts", (signal) => fetch(`${cfg.baseUrl}/audio/speech`, {
|
|
152
|
+
method: "POST",
|
|
153
|
+
signal,
|
|
154
|
+
headers: { "content-type": "application/json", ...(cfg.key ? { authorization: `Bearer ${cfg.key}` } : {}) },
|
|
155
|
+
body: JSON.stringify({
|
|
156
|
+
model: cfg.model,
|
|
157
|
+
voice: opts.voice || cfg.voice || "alloy",
|
|
158
|
+
input: text,
|
|
159
|
+
...(opts.speed && opts.speed !== 1 ? { speed: opts.speed } : {}),
|
|
160
|
+
}),
|
|
161
|
+
}));
|
|
162
|
+
if (!res.ok) throw new VoiceError(`tts upstream ${res.status}: ${(await res.text().catch(() => "")).slice(0, 400)}`, "upstream");
|
|
163
|
+
return { contentType: res.headers.get("content-type") ?? "audio/mpeg", audio: Buffer.from(await res.arrayBuffer()) };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Audio bytes -> transcript, routed. `sessionId` lets the provider pull context hotwords
|
|
167
|
+
* from the transcript it already has (dynamic hotwords). */
|
|
168
|
+
export async function transcribe(
|
|
169
|
+
audio: Buffer,
|
|
170
|
+
mime: string,
|
|
171
|
+
opts: { filename?: string; language?: string; sessionId?: string; contextWords?: string[] } = {},
|
|
172
|
+
): Promise<string> {
|
|
173
|
+
const v = getSettings().voice;
|
|
174
|
+
const r = resolveVoice();
|
|
175
|
+
if (r.provider === "dashscope") {
|
|
176
|
+
return dashscopeAsr(audio, mime, { contextWords: opts.contextWords ?? hotwordsForRequest(opts.sessionId) })
|
|
177
|
+
.catch((e) => { throw wrapDashscope(e); });
|
|
178
|
+
}
|
|
179
|
+
const fromSettings = v.baseUrl && v.apiKey ? { baseUrl: trimmed(v.baseUrl), key: v.apiKey, model: v.asrBatchModel, language: "" } : null;
|
|
180
|
+
const cfg = fromSettings ?? r.openai.stt;
|
|
181
|
+
if (!cfg) throw new VoiceError("server STT is not configured (settings page, or AGENTUS_STT_BASE_URL)", "not_configured");
|
|
182
|
+
const form = new FormData();
|
|
183
|
+
const name = opts.filename || `dictation.${mimeToExt(mime)}`;
|
|
184
|
+
form.append("file", new Blob([new Uint8Array(audio)], { type: mime || "audio/webm" }), name);
|
|
185
|
+
form.append("model", cfg.model);
|
|
186
|
+
if (opts.language || cfg.language) form.append("language", opts.language || cfg.language);
|
|
187
|
+
const res = await withTimeout("stt", (signal) => fetch(`${cfg.baseUrl}/audio/transcriptions`, {
|
|
188
|
+
method: "POST",
|
|
189
|
+
signal,
|
|
190
|
+
headers: cfg.key ? { authorization: `Bearer ${cfg.key}` } : undefined,
|
|
191
|
+
body: form,
|
|
192
|
+
}));
|
|
193
|
+
if (!res.ok) throw new VoiceError(`stt upstream ${res.status}: ${(await res.text().catch(() => "")).slice(0, 400)}`, "upstream");
|
|
194
|
+
const data = (await res.json()) as { text?: string };
|
|
195
|
+
return String(data?.text ?? "").trim();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Hotword extraction needs the store, which lives outside this module: injected at
|
|
199
|
+
* boot (index.ts) so voice.ts stays free of database imports. */
|
|
200
|
+
type HotwordFn = (sessionId?: string) => string[];
|
|
201
|
+
let hotwordsForRequest: HotwordFn = () => [];
|
|
202
|
+
export function setHotwordSource(fn: HotwordFn): void {
|
|
203
|
+
hotwordsForRequest = fn;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The model ids the configured endpoint serves, split into the two lists the settings
|
|
207
|
+
* page offers. The operator named two defaults but asked for "其它的也可选", and every
|
|
208
|
+
* Bailian tenant advertises a different set — so we ask the endpoint instead of
|
|
209
|
+
* hard-coding a catalogue. */
|
|
210
|
+
export async function listVoiceModels(): Promise<{ total: number; asr: string[]; tts: string[] }> {
|
|
211
|
+
const v = getSettings().voice;
|
|
212
|
+
const r = resolveVoice();
|
|
213
|
+
let url = "";
|
|
214
|
+
let key = "";
|
|
215
|
+
if (r.provider === "dashscope") {
|
|
216
|
+
const { compat } = dashscopeUrls(v.baseUrl);
|
|
217
|
+
url = `${compat}/models`;
|
|
218
|
+
key = v.apiKey;
|
|
219
|
+
} else {
|
|
220
|
+
// the settings page's endpoint first: resolveVoice() falls back to the env bootstrap,
|
|
221
|
+
// so an operator who typed an endpoint must not be told "not configured" (the page's
|
|
222
|
+
// "重新读取" button would be a lie).
|
|
223
|
+
const base = trimmed(v.baseUrl) || r.openai.tts?.baseUrl || r.openai.stt?.baseUrl || "";
|
|
224
|
+
if (!base) throw new VoiceError("no voice endpoint configured", "not_configured");
|
|
225
|
+
key = (v.baseUrl && v.apiKey ? v.apiKey : "") || r.openai.tts?.key || r.openai.stt?.key || "";
|
|
226
|
+
url = `${base}/models`;
|
|
227
|
+
}
|
|
228
|
+
const res = await withTimeout("models", (signal) => fetch(url, {
|
|
229
|
+
signal,
|
|
230
|
+
headers: key ? { authorization: `Bearer ${key}` } : undefined,
|
|
231
|
+
}));
|
|
232
|
+
if (!res.ok) throw new VoiceError(`models upstream ${res.status}: ${(await res.text().catch(() => "")).slice(0, 300)}`, "upstream");
|
|
233
|
+
const data = (await res.json()) as { data?: { id?: string }[]; models?: { name?: string }[] };
|
|
234
|
+
const ids = (data.data ?? []).map((m) => String(m.id ?? "")).filter(Boolean);
|
|
235
|
+
for (const m of data.models ?? []) if (m.name) ids.push(String(m.name));
|
|
236
|
+
const asr = ids.filter((id) => /asr|speech-recog|recognition|paraformer|sensevoice/i.test(id));
|
|
237
|
+
const tts = ids.filter((id) => /tts|speech-synth|cosyvoice|sambert/i.test(id));
|
|
238
|
+
return { total: ids.length, asr, tts };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function mimeToExt(mime: string): string {
|
|
242
|
+
const m = mime.toLowerCase();
|
|
243
|
+
if (m.includes("webm")) return "webm";
|
|
244
|
+
if (m.includes("ogg")) return "ogg";
|
|
245
|
+
if (m.includes("wav")) return "wav";
|
|
246
|
+
if (m.includes("mp4") || m.includes("m4a") || m.includes("aac")) return "m4a";
|
|
247
|
+
if (m.includes("mpeg") || m.includes("mp3")) return "mp3";
|
|
248
|
+
if (m.includes("flac")) return "flac";
|
|
249
|
+
if (m.includes("pcm")) return "pcm";
|
|
250
|
+
return "webm";
|
|
251
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agentus/shared",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"typecheck": "tsc --noEmit"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"typescript": "^5.6.0"
|
|
13
|
+
}
|
|
14
|
+
}
|