luckytri 1.2.3 → 1.3.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.
Files changed (74) hide show
  1. package/README.en.md +44 -1
  2. package/README.md +44 -1
  3. package/docs/en/CHANGELOG.md +15 -0
  4. package/docs/zh/CHANGELOG.md +15 -0
  5. package/package.json +1 -1
  6. package/public/app/assets/ChatsPage-C5-w410L.js +3 -0
  7. package/public/app/assets/{Empty-uSyRBXOT.js → Empty-DSurzLFZ.js} +1 -1
  8. package/public/app/assets/Field-GuXyA_aS.js +1 -0
  9. package/public/app/assets/HeartPage-BGmIrxCz.js +1 -0
  10. package/public/app/assets/LifePage-Dzln-0Y3.js +1 -0
  11. package/public/app/assets/MemoryPage-QsthmMZ9.js +1 -0
  12. package/public/app/assets/NaturePage-CIvHjgJa.js +3 -0
  13. package/public/app/assets/PeoplePage-bIoX24pj.js +1 -0
  14. package/public/app/assets/PluginsPage-EuJhCovw.js +2 -0
  15. package/public/app/assets/SystemPage-7t42B-Q3.js +1 -0
  16. package/public/app/assets/{SystemPage-B9M9g95D.css → SystemPage-BLMlD1M6.css} +1 -1
  17. package/public/app/assets/TimePage-CY-SwBq3.js +3 -0
  18. package/public/app/assets/index-Cn6TppEV.js +4 -0
  19. package/public/app/assets/{index-cDDC-1dI.css → index-JRbQoSJr.css} +1 -1
  20. package/public/app/assets/{knowledge-Ces-Bx70.js → knowledge-sUjcjbfI.js} +1 -1
  21. package/public/app/assets/media-Dh7c41PQ.js +1 -0
  22. package/public/app/assets/{mind-D3mdwFW3.js → mind-BMcoPQNd.js} +1 -1
  23. package/public/app/index.html +4 -4
  24. package/public/guide.en.html +3 -3
  25. package/public/guide.html +3 -3
  26. package/server/actions/index.js +268 -0
  27. package/server/app.js +4 -1
  28. package/server/channels/contract.js +3 -0
  29. package/server/channels/hub.js +15 -0
  30. package/server/channels/onebot/adapter.js +162 -21
  31. package/server/channels/onebot/enrich.js +132 -0
  32. package/server/channels/onebot/index.js +53 -3
  33. package/server/channels/onebot/music.js +43 -0
  34. package/server/core/conversation-grounding.js +1 -1
  35. package/server/core/dialogue-context.js +165 -15
  36. package/server/core/dialogue-contract.js +32 -33
  37. package/server/core/model-manager.js +35 -20
  38. package/server/core/model-presets.js +154 -1
  39. package/server/core/orchestrator.js +18 -0
  40. package/server/core/persona-manager.js +20 -2
  41. package/server/core/reply-delivery.js +328 -48
  42. package/server/core/response-generator.js +1 -1
  43. package/server/core/response-validator.js +85 -17
  44. package/server/core/turn-processor.js +63 -16
  45. package/server/core/turn.js +59 -1
  46. package/server/i18n/errors.js +26 -2
  47. package/server/index.js +30 -0
  48. package/server/mind/continuity.js +2 -4
  49. package/server/mind/discretion.js +16 -5
  50. package/server/mind/memory.js +7 -2
  51. package/server/mind/recall/index.js +2 -0
  52. package/server/plugins/zip-file.js +180 -0
  53. package/server/plugins/zip.js +32 -4
  54. package/server/stickers/api.js +90 -2
  55. package/server/stickers/library.js +8 -2
  56. package/server/stickers/offer.js +4 -1
  57. package/server/stickers/zip-import.js +136 -0
  58. package/server/studio/auto-update.js +224 -0
  59. package/server/studio/updates.js +154 -144
  60. package/server/voice/index.js +255 -0
  61. package/server/voice/sauc.js +152 -0
  62. package/server/web/link-reader.js +379 -0
  63. package/public/app/assets/ChatsPage-o-exjdFu.js +0 -3
  64. package/public/app/assets/Field-Bfa5dCkk.js +0 -1
  65. package/public/app/assets/HeartPage-Q5ts7Ko6.js +0 -1
  66. package/public/app/assets/LifePage-B4AsD_DM.js +0 -1
  67. package/public/app/assets/MemoryPage-ijD6lsxo.js +0 -1
  68. package/public/app/assets/NaturePage-B9-dv8vz.js +0 -3
  69. package/public/app/assets/PeoplePage-BG_XPian.js +0 -1
  70. package/public/app/assets/PluginsPage-CPYYMown.js +0 -2
  71. package/public/app/assets/SystemPage-CTduZhr3.js +0 -1
  72. package/public/app/assets/TimePage-DScn8IN-.js +0 -3
  73. package/public/app/assets/index-5FMs9YYQ.js +0 -4
  74. package/public/app/assets/media-BsG-i0Lv.js +0 -1
@@ -1,144 +1,154 @@
1
- import { SOURCE_CHECKOUT } from "../paths.js";
2
- import { USER_AGENT, VERSION } from "../version.js";
3
-
4
- const REGISTRY = "https://registry.npmjs.org/luckytri/latest";
5
- const SEMVER =
6
- /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
7
- function parts(value) {
8
- const m =
9
- typeof value === "string" && value.length <= 100 && value.match(SEMVER);
10
- if (!m || m.slice(1, 4).some((n) => !Number.isSafeInteger(Number(n))))
11
- return null;
12
- if (m[4]?.split(".").some((part) => /^0\d+$/.test(part))) return null;
13
- return { numbers: m.slice(1, 4).map(Number), pre: m[4]?.split(".") || [] };
14
- }
15
- export function compareVersions(a, b) {
16
- const left = parts(a),
17
- right = parts(b);
18
- if (!left || !right) throw Error("版本信息无效");
19
- for (let i = 0; i < 3; i++)
20
- if (left.numbers[i] !== right.numbers[i])
21
- return Math.sign(left.numbers[i] - right.numbers[i]);
22
- if (!left.pre.length || !right.pre.length)
23
- return Math.sign(Number(!left.pre.length) - Number(!right.pre.length));
24
- for (let i = 0; i < Math.max(left.pre.length, right.pre.length); i++) {
25
- const x = left.pre[i],
26
- y = right.pre[i];
27
- if (x === y) continue;
28
- if (x === undefined || y === undefined) return x === undefined ? -1 : 1;
29
- const xn = /^\d+$/.test(x),
30
- yn = /^\d+$/.test(y);
31
- if (xn && yn) return BigInt(x) < BigInt(y) ? -1 : 1;
32
- if (xn !== yn) return xn ? -1 : 1;
33
- return x < y ? -1 : 1;
34
- }
35
- return 0;
36
- }
37
-
38
- // A manual, bounded metadata read. It never installs code, runs commands or
39
- // sends instance data, model keys or npm login credentials to the registry.
40
- export class UpdateChecker {
41
- constructor({
42
- fetch: fetcher = globalThis.fetch,
43
- now = Date.now,
44
- current = VERSION,
45
- source = SOURCE_CHECKOUT,
46
- timeout = 10000,
47
- } = {}) {
48
- this.fetch = fetcher;
49
- this.now = now;
50
- this.current = current;
51
- this.source = source;
52
- this.timeout = timeout;
53
- this.latest = null;
54
- this.checkedAt = null;
55
- this.lastAttempt = null;
56
- this.error = "";
57
- this.pending = null;
58
- }
59
- status() {
60
- return {
61
- current: this.current,
62
- latest: this.latest,
63
- checkedAt: this.checkedAt,
64
- error: this.error,
65
- status: this.error
66
- ? "unavailable"
67
- : !this.latest
68
- ? "unchecked"
69
- : compareVersions(this.latest, this.current) > 0
70
- ? "available"
71
- : compareVersions(this.latest, this.current) === 0
72
- ? "current"
73
- : "ahead",
74
- installation: this.source ? "source" : "npm",
75
- npmUrl: "https://www.npmjs.com/package/luckytri",
76
- releaseUrl: "https://github.com/TodayYueC/LuckyTri/releases",
77
- };
78
- }
79
- check() {
80
- if (this.pending) return this.pending;
81
- const now = this.now();
82
- const elapsed =
83
- this.lastAttempt == null ? Infinity : now - this.lastAttempt;
84
- if (elapsed >= 0 && elapsed < (this.error ? 15000 : 300000))
85
- return Promise.resolve({ ...this.status(), cached: true });
86
- this.lastAttempt = now;
87
- this.pending = this.read().finally(() => {
88
- this.pending = null;
89
- });
90
- return this.pending;
91
- }
92
- async read() {
93
- let response;
94
- try {
95
- response = await this.fetch(REGISTRY, {
96
- headers: { Accept: "application/json", "User-Agent": USER_AGENT },
97
- redirect: "error",
98
- signal: AbortSignal.timeout(this.timeout),
99
- });
100
- if (!response.ok) throw Error(`HTTP ${response.status}`);
101
- if (Number(response.headers.get("content-length")) > 65536)
102
- throw Error("版本信息过大");
103
- const chunks = [];
104
- let bytes = 0;
105
- for await (const chunk of response.body) {
106
- bytes += chunk.length;
107
- if (bytes > 65536) throw Error("版本信息过大");
108
- chunks.push(Buffer.from(chunk));
109
- }
110
- const data = JSON.parse(Buffer.concat(chunks).toString("utf8"));
111
- if (data.name !== "luckytri" || !parts(data.version))
112
- throw Error("版本信息无效");
113
- this.latest = data.version;
114
- this.checkedAt = this.now();
115
- this.error = "";
116
- } catch {
117
- try {
118
- await response?.body?.cancel();
119
- } catch {
120
- /* Already consumed or cancelled. */
121
- }
122
- this.error = "暂时无法连接 npm 或读取版本信息,请稍后重试。";
123
- }
124
- return { ...this.status(), cached: false };
125
- }
126
- }
127
-
128
- export function mountUpdates(app, checker, updater) {
129
- const view = (info) => ({ ...info, operation: updater?.status() || null });
130
- app.get("/api/system/update", (_req, res) =>
131
- res.json(view(checker.status())),
132
- );
133
- app.post("/api/system/update/check", async (_req, res) =>
134
- res.json(view(await checker.check())),
135
- );
136
- app.post("/api/system/update/install", async (req, res) => {
137
- try {
138
- if (!updater) throw Error("当前实例不支持自动更新");
139
- res.json(await updater.install(req.body.version));
140
- } catch (error) {
141
- res.status(400).json({ error: error.message });
142
- }
143
- });
144
- }
1
+ import { SOURCE_CHECKOUT } from "../paths.js";
2
+ import { USER_AGENT, VERSION } from "../version.js";
3
+
4
+ const REGISTRY = "https://registry.npmjs.org/luckytri/latest";
5
+ const SEMVER =
6
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
7
+ function parts(value) {
8
+ const m =
9
+ typeof value === "string" && value.length <= 100 && value.match(SEMVER);
10
+ if (!m || m.slice(1, 4).some((n) => !Number.isSafeInteger(Number(n))))
11
+ return null;
12
+ if (m[4]?.split(".").some((part) => /^0\d+$/.test(part))) return null;
13
+ return { numbers: m.slice(1, 4).map(Number), pre: m[4]?.split(".") || [] };
14
+ }
15
+ export function compareVersions(a, b) {
16
+ const left = parts(a),
17
+ right = parts(b);
18
+ if (!left || !right) throw Error("版本信息无效");
19
+ for (let i = 0; i < 3; i++)
20
+ if (left.numbers[i] !== right.numbers[i])
21
+ return Math.sign(left.numbers[i] - right.numbers[i]);
22
+ if (!left.pre.length || !right.pre.length)
23
+ return Math.sign(Number(!left.pre.length) - Number(!right.pre.length));
24
+ for (let i = 0; i < Math.max(left.pre.length, right.pre.length); i++) {
25
+ const x = left.pre[i],
26
+ y = right.pre[i];
27
+ if (x === y) continue;
28
+ if (x === undefined || y === undefined) return x === undefined ? -1 : 1;
29
+ const xn = /^\d+$/.test(x),
30
+ yn = /^\d+$/.test(y);
31
+ if (xn && yn) return BigInt(x) < BigInt(y) ? -1 : 1;
32
+ if (xn !== yn) return xn ? -1 : 1;
33
+ return x < y ? -1 : 1;
34
+ }
35
+ return 0;
36
+ }
37
+
38
+ // A manual, bounded metadata read. It never installs code, runs commands or
39
+ // sends instance data, model keys or npm login credentials to the registry.
40
+ export class UpdateChecker {
41
+ constructor({
42
+ fetch: fetcher = globalThis.fetch,
43
+ now = Date.now,
44
+ current = VERSION,
45
+ source = SOURCE_CHECKOUT,
46
+ timeout = 10000,
47
+ } = {}) {
48
+ this.fetch = fetcher;
49
+ this.now = now;
50
+ this.current = current;
51
+ this.source = source;
52
+ this.timeout = timeout;
53
+ this.latest = null;
54
+ this.checkedAt = null;
55
+ this.lastAttempt = null;
56
+ this.error = "";
57
+ this.pending = null;
58
+ }
59
+ status() {
60
+ return {
61
+ current: this.current,
62
+ latest: this.latest,
63
+ checkedAt: this.checkedAt,
64
+ error: this.error,
65
+ status: this.error
66
+ ? "unavailable"
67
+ : !this.latest
68
+ ? "unchecked"
69
+ : compareVersions(this.latest, this.current) > 0
70
+ ? "available"
71
+ : compareVersions(this.latest, this.current) === 0
72
+ ? "current"
73
+ : "ahead",
74
+ installation: this.source ? "source" : "npm",
75
+ npmUrl: "https://www.npmjs.com/package/luckytri",
76
+ releaseUrl: "https://github.com/TodayYueC/LuckyTri/releases",
77
+ };
78
+ }
79
+ check() {
80
+ if (this.pending) return this.pending;
81
+ const now = this.now();
82
+ const elapsed =
83
+ this.lastAttempt == null ? Infinity : now - this.lastAttempt;
84
+ if (elapsed >= 0 && elapsed < (this.error ? 15000 : 300000))
85
+ return Promise.resolve({ ...this.status(), cached: true });
86
+ this.lastAttempt = now;
87
+ this.pending = this.read().finally(() => {
88
+ this.pending = null;
89
+ });
90
+ return this.pending;
91
+ }
92
+ async read() {
93
+ let response;
94
+ try {
95
+ response = await this.fetch(REGISTRY, {
96
+ headers: { Accept: "application/json", "User-Agent": USER_AGENT },
97
+ redirect: "error",
98
+ signal: AbortSignal.timeout(this.timeout),
99
+ });
100
+ if (!response.ok) throw Error(`HTTP ${response.status}`);
101
+ if (Number(response.headers.get("content-length")) > 65536)
102
+ throw Error("版本信息过大");
103
+ const chunks = [];
104
+ let bytes = 0;
105
+ for await (const chunk of response.body) {
106
+ bytes += chunk.length;
107
+ if (bytes > 65536) throw Error("版本信息过大");
108
+ chunks.push(Buffer.from(chunk));
109
+ }
110
+ const data = JSON.parse(Buffer.concat(chunks).toString("utf8"));
111
+ if (data.name !== "luckytri" || !parts(data.version))
112
+ throw Error("版本信息无效");
113
+ this.latest = data.version;
114
+ this.checkedAt = this.now();
115
+ this.error = "";
116
+ } catch {
117
+ try {
118
+ await response?.body?.cancel();
119
+ } catch {
120
+ /* Already consumed or cancelled. */
121
+ }
122
+ this.error = "暂时无法连接 npm 或读取版本信息,请稍后重试。";
123
+ }
124
+ return { ...this.status(), cached: false };
125
+ }
126
+ }
127
+
128
+ export function mountUpdates(app, checker, updater, autoUpdate) {
129
+ const view = (info) => ({
130
+ ...info,
131
+ operation: updater?.status() || null,
132
+ auto: autoUpdate?.view() || null,
133
+ });
134
+ app.post("/api/system/update/auto", (req, res) => {
135
+ if (!autoUpdate)
136
+ return res.status(400).json({ error: "当前实例不支持自动更新" });
137
+ autoUpdate.save(req.body || {});
138
+ res.json(view(checker.status()));
139
+ });
140
+ app.get("/api/system/update", (_req, res) =>
141
+ res.json(view(checker.status())),
142
+ );
143
+ app.post("/api/system/update/check", async (_req, res) =>
144
+ res.json(view(await checker.check())),
145
+ );
146
+ app.post("/api/system/update/install", async (req, res) => {
147
+ try {
148
+ if (!updater) throw Error("当前实例不支持自动更新");
149
+ res.json(await updater.install(req.body.version));
150
+ } catch (error) {
151
+ res.status(400).json({ error: error.message });
152
+ }
153
+ });
154
+ }
@@ -0,0 +1,255 @@
1
+ // Her voice: text to speech for a short line she chooses to say aloud.
2
+ // Off unless the owner configures a speech service and turns it on; even
3
+ // then saying it aloud (or singing) is her choice in the turn, never forced.
4
+ import { randomUUID } from "node:crypto";
5
+ import { saucTranscribe } from "./sauc.js";
6
+
7
+ export const VOICE_DEFAULTS = {
8
+ enabled: false,
9
+ protocol: "volc-openspeech-v3", // or openai-audio
10
+ baseUrl: "https://openspeech.bytedance.com/api/v3/plan/tts/unidirectional",
11
+ resourceId: "seed-tts-2.0",
12
+ model: "",
13
+ speaker: "zh_female_vv_uranus_bigtts",
14
+ // How her voice sounds, in words; sent as a tone hint where supported.
15
+ style: "",
16
+ format: "mp3",
17
+ sampleRate: 24000,
18
+ apiKey: "",
19
+ // Understanding voice messages people send (speech recognition).
20
+ listen: false,
21
+ // Agent Plan preset; volc-flash and openai-audio are the alternatives.
22
+ asrProtocol: "volc-plan-sauc",
23
+ asrBaseUrl:
24
+ "wss://openspeech.bytedance.com/api/v3/plan/sauc/bigmodel_nostream",
25
+ asrResourceId: "volc.seedasr.sauc.duration",
26
+ asrModel: "",
27
+ asrApiKey: "",
28
+ };
29
+ const MAX_CHARS = 60;
30
+
31
+ export function voiceSettings(repo) {
32
+ return { ...VOICE_DEFAULTS, ...(repo.config("voice") || {}) };
33
+ }
34
+ export function publicVoice(settings) {
35
+ const { apiKey, asrApiKey, ...safe } = settings;
36
+ return {
37
+ ...safe,
38
+ hasApiKey: !!apiKey,
39
+ hasAsrApiKey: !!asrApiKey,
40
+ ready: voiceReady(settings),
41
+ hearingReady: hearingReady(settings),
42
+ };
43
+ }
44
+ export function voiceReady(s) {
45
+ return !!(s.apiKey && s.baseUrl && s.speaker);
46
+ }
47
+ // Whether "say it aloud" is offered at all this turn.
48
+ export function voiceOffered(s) {
49
+ return s.enabled === true && voiceReady(s);
50
+ }
51
+ export function hearingReady(s) {
52
+ return !!((s.asrApiKey || s.apiKey) && s.asrBaseUrl);
53
+ }
54
+ export function hearingOn(s) {
55
+ return s.listen === true && hearingReady(s);
56
+ }
57
+
58
+ export async function transcribe(
59
+ s,
60
+ audio,
61
+ format = "mp3",
62
+ { fetch: f = globalThis.fetch } = {},
63
+ ) {
64
+ if (!hearingReady(s)) throw new Error("语音识别还没有配置");
65
+ const key = s.asrApiKey || s.apiKey;
66
+ if (s.asrProtocol === "volc-plan-sauc")
67
+ return saucTranscribe(
68
+ { url: s.asrBaseUrl, key, resourceId: s.asrResourceId },
69
+ audio,
70
+ );
71
+ const signal = AbortSignal.timeout(15000);
72
+ if (s.asrProtocol === "openai-audio") {
73
+ const form = new FormData();
74
+ form.append("model", s.asrModel || "whisper-1");
75
+ form.append("file", new Blob([audio]), `voice.${format}`);
76
+ const r = await f(
77
+ `${s.asrBaseUrl.replace(/\/$/, "")}/audio/transcriptions`,
78
+ {
79
+ method: "POST",
80
+ signal,
81
+ headers: { Authorization: `Bearer ${key}` },
82
+ body: form,
83
+ },
84
+ );
85
+ if (!r.ok)
86
+ throw Object.assign(new Error("语音识别失败"), { status: r.status });
87
+ return String((await r.json()).text || "");
88
+ }
89
+ const r = await f(s.asrBaseUrl, {
90
+ method: "POST",
91
+ signal,
92
+ headers: {
93
+ "X-Api-Key": key,
94
+ "X-Api-Resource-Id": s.asrResourceId || "volc.bigasr.auc_turbo",
95
+ "X-Api-Request-Id": randomUUID(),
96
+ "X-Api-Sequence": "-1",
97
+ "Content-Type": "application/json",
98
+ },
99
+ body: JSON.stringify({
100
+ user: { uid: "luckytri" },
101
+ audio: { data: Buffer.from(audio).toString("base64"), format },
102
+ request: { model_name: "bigmodel", enable_punc: true, enable_itn: true },
103
+ }),
104
+ });
105
+ if (!r.ok)
106
+ throw Object.assign(new Error("语音识别失败"), { status: r.status });
107
+ const data = await r.json();
108
+ return String(data?.result?.text || data?.text || "");
109
+ }
110
+
111
+ export function speakable(bubbles) {
112
+ const text = (bubbles || []).join(",");
113
+ return text.length > 0 && text.length <= MAX_CHARS;
114
+ }
115
+
116
+ export async function synthesize(
117
+ s,
118
+ text,
119
+ { fetch: f = globalThis.fetch } = {},
120
+ ) {
121
+ if (!voiceReady(s)) throw new Error("语音服务还没有配置");
122
+ const signal = AbortSignal.timeout(20000);
123
+ if (s.protocol === "openai-audio") {
124
+ const r = await f(`${s.baseUrl.replace(/\/$/, "")}/audio/speech`, {
125
+ method: "POST",
126
+ signal,
127
+ headers: {
128
+ Authorization: `Bearer ${s.apiKey}`,
129
+ "Content-Type": "application/json",
130
+ },
131
+ body: JSON.stringify({
132
+ model: s.model || "tts-1",
133
+ voice: s.speaker,
134
+ input: text,
135
+ response_format: s.format || "mp3",
136
+ ...(s.style ? { instructions: s.style } : {}),
137
+ }),
138
+ });
139
+ if (!r.ok)
140
+ throw Object.assign(new Error("语音合成失败"), { status: r.status });
141
+ return Buffer.from(await r.arrayBuffer());
142
+ }
143
+ const r = await f(s.baseUrl, {
144
+ method: "POST",
145
+ signal,
146
+ headers: {
147
+ "X-Api-Key": s.apiKey,
148
+ "X-Api-Resource-Id": s.resourceId || "seed-tts-2.0",
149
+ "X-Api-Request-Id": randomUUID(),
150
+ "Content-Type": "application/json",
151
+ },
152
+ body: JSON.stringify({
153
+ user: { uid: "luckytri" },
154
+ req_params: {
155
+ text,
156
+ speaker: s.speaker,
157
+ audio_params: {
158
+ format: s.format || "mp3",
159
+ sample_rate: Number(s.sampleRate) || 24000,
160
+ },
161
+ ...(s.style
162
+ ? { additions: JSON.stringify({ context_texts: [s.style] }) }
163
+ : {}),
164
+ },
165
+ }),
166
+ });
167
+ if (!r.ok)
168
+ throw Object.assign(new Error("语音合成失败"), { status: r.status });
169
+ // One JSON object per line; audio arrives as base64 chunks in `data`.
170
+ const chunks = [];
171
+ for (const line of (await r.text()).split("\n")) {
172
+ if (!line.trim()) continue;
173
+ let item;
174
+ try {
175
+ item = JSON.parse(line);
176
+ } catch {
177
+ continue;
178
+ }
179
+ if (item.data) chunks.push(Buffer.from(item.data, "base64"));
180
+ if (item.code && item.code !== 0 && item.code !== 20000000)
181
+ throw new Error("语音合成失败");
182
+ }
183
+ if (!chunks.length) throw new Error("语音合成没有返回声音");
184
+ return Buffer.concat(chunks);
185
+ }
186
+
187
+ export function mountVoice(app, system) {
188
+ const wrap = (fn) => async (req, res) => {
189
+ try {
190
+ await fn(req, res);
191
+ } catch (error) {
192
+ res.status(400).json({ error: error.message });
193
+ }
194
+ };
195
+ app.get("/api/core/voice", (req, res) =>
196
+ res.json(publicVoice(voiceSettings(system.repo))),
197
+ );
198
+ app.put(
199
+ "/api/core/voice",
200
+ wrap((req, res) => {
201
+ const b = req.body || {};
202
+ const now = voiceSettings(system.repo);
203
+ const next = { ...now };
204
+ for (const k of [
205
+ "protocol",
206
+ "baseUrl",
207
+ "resourceId",
208
+ "model",
209
+ "speaker",
210
+ "style",
211
+ "format",
212
+ ])
213
+ if (typeof b[k] === "string")
214
+ next[k] = b[k].trim().slice(0, k === "style" ? 200 : 300);
215
+ if (!["volc-openspeech-v3", "openai-audio"].includes(next.protocol))
216
+ throw Error("语音协议无效");
217
+ if (next.baseUrl && !/^https:\/\//.test(next.baseUrl))
218
+ throw Error("语音服务地址需要 https");
219
+ if (typeof b.apiKey === "string" && b.apiKey.trim())
220
+ next.apiKey = b.apiKey.trim();
221
+ if (b.clearKey === true) next.apiKey = "";
222
+ if (typeof b.asrApiKey === "string" && b.asrApiKey.trim())
223
+ next.asrApiKey = b.asrApiKey.trim();
224
+ if (
225
+ !["volc-plan-sauc", "volc-flash", "openai-audio"].includes(
226
+ next.asrProtocol,
227
+ )
228
+ )
229
+ throw Error("语音协议无效");
230
+ if (next.asrBaseUrl && !/^(?:https|wss):\/\//.test(next.asrBaseUrl))
231
+ throw Error("语音服务地址需要 https");
232
+ if (typeof b.listen === "boolean") next.listen = b.listen;
233
+ if (!hearingReady(next)) next.listen = false;
234
+ if (typeof b.enabled === "boolean") next.enabled = b.enabled;
235
+ // Without a working service the switch stays off.
236
+ if (!voiceReady(next)) next.enabled = false;
237
+ system.repo.saveConfig("voice", next);
238
+ res.json(publicVoice(next));
239
+ }),
240
+ );
241
+ app.post(
242
+ "/api/core/voice/test",
243
+ wrap(async (req, res) => {
244
+ const text = String(req.body?.text || "你好呀,今天过得怎么样?").slice(
245
+ 0,
246
+ MAX_CHARS,
247
+ );
248
+ const audio = await synthesize(voiceSettings(system.repo), text);
249
+ res.json({
250
+ format: voiceSettings(system.repo).format,
251
+ base64: audio.toString("base64"),
252
+ });
253
+ }),
254
+ );
255
+ }