ai_co_discuss 0.3.27 → 0.3.28
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/backend/server.py +6 -1
- package/docs/runner.md +2 -2
- package/package.json +1 -1
- package/runner/tts.mjs +50 -8
- package/web/app.js +4085 -4081
- package/web/modern.css +1 -1
- package/web/modern.js +3882 -3537
package/backend/server.py
CHANGED
|
@@ -667,7 +667,8 @@ def list_rooms(connection, params=None):
|
|
|
667
667
|
identifier = submission_value(params["submission_id"]) if "submission_id" in params else None
|
|
668
668
|
where = "WHERE EXISTS (SELECT 1 FROM room_creations WHERE room_id=rooms.id AND submission_id=?)" if identifier else ""
|
|
669
669
|
rows = connection.execute(f"""SELECT rooms.*,
|
|
670
|
-
COALESCE(stats.message_count,0) AS message_count,COALESCE(stats.latest_id,0) AS latest_id
|
|
670
|
+
COALESCE(stats.message_count,0) AS message_count,COALESCE(stats.latest_id,0) AS latest_id,
|
|
671
|
+
(SELECT created_at FROM messages WHERE id=stats.latest_id) AS latest_at
|
|
671
672
|
FROM rooms LEFT JOIN room_message_stats stats ON stats.room_id=rooms.id {where}
|
|
672
673
|
GROUP BY rooms.id ORDER BY rooms.created_at, rooms.id""", (identifier,) if identifier else ())
|
|
673
674
|
directory = {row["room_id"]: dict(row) for row in connection.execute("SELECT * FROM room_directory")}
|
|
@@ -1311,6 +1312,10 @@ def message_filters(room_id, params):
|
|
|
1311
1312
|
raise ValueError("发言职责无效")
|
|
1312
1313
|
clauses.append("COALESCE(details.kind,'opinion')=?")
|
|
1313
1314
|
values.append(params["kind"])
|
|
1315
|
+
if params.get("host") == "1":
|
|
1316
|
+
# Host view: moderation notes plus whatever the current host said.
|
|
1317
|
+
clauses.append("(COALESCE(details.kind,'opinion')='moderation' OR messages.author=(SELECT host FROM topic_settings WHERE room_id=?))")
|
|
1318
|
+
values.append(room_id)
|
|
1314
1319
|
if "submission_id" in params:
|
|
1315
1320
|
clauses.append("submission_id=?")
|
|
1316
1321
|
values.append(submission_value(params["submission_id"]))
|
package/docs/runner.md
CHANGED
|
@@ -90,9 +90,9 @@ API 成员可以担任专家或主持人。房间指定了项目目录时,它
|
|
|
90
90
|
|
|
91
91
|
## 朗读与语音输入(可选)
|
|
92
92
|
|
|
93
|
-
在火山引擎控制台开通「语音合成2.0」(朗读)和「录音文件识别2.0」(语音输入),复制 API Key
|
|
93
|
+
在火山引擎控制台开通「语音合成2.0」(朗读)和「录音文件识别2.0」(语音输入),复制 API Key,填进网页“设置”的“语音”。朗读可设一个通用音色,并按 AI 平台(Claude、Codex、Gemini 等)分别配音色,没配的平台、你自己的消息和程序消息用通用音色;还可选语速、改语气提示(默认是“像给朋友讲故事一样”的自然语气)。密钥只保存在本机,费用按你的账号计费。
|
|
94
94
|
|
|
95
|
-
-
|
|
95
|
+
- 朗读:每条消息的工具栏有“朗读”(手机上在“…”菜单里),在消息里选中文字后点“听当前”从选中处开始;读完这条会接着读后面的消息直到最新,点输入框右下角的播报按钮开启语音播报:之后的新回复会自动朗读回答摘要(没写摘要的短回答读全文,长回答读第一段),主持人的发言会报“主持人”;讨论中还会提示新一轮的发言顺序和轮到谁,执行失败、操作待你允许和讨论结束也会播一句。同时到达的回复依次播报,积压较多时只播最新 3 条。锁屏或切到其他 App 时继续播放,锁屏界面和耳机按键可以暂停、继续、跳到下一条;读过的段落会缓存在电脑上,重听不再计费。正在读的句子会高亮并自动滚到视野内,点一下高亮的句子暂停,再点继续;代码块跳过。
|
|
96
96
|
- 语音输入:点输入框右下角的麦克风开始说话,再点一次转成文字插入输入框,单次最长 5 分钟。浏览器会先请求麦克风权限。
|
|
97
97
|
|
|
98
98
|
两者都由本机服务调用火山引擎,手机等其他设备经已配对电脑使用,电脑关机时不可用。
|
package/package.json
CHANGED
package/runner/tts.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Read-aloud: synthesize short text with Volcengine Doubao TTS 2.0; the key stays in the local private store. */
|
|
2
|
-
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
|
|
6
6
|
import { read_saved_api_key } from './discussion_api.mjs';
|
|
@@ -15,6 +15,14 @@ const TTS_TIMEOUT_MS = 45_000;
|
|
|
15
15
|
export const TTS_MAX_TEXT = 300;
|
|
16
16
|
const MAX_CONTEXT = 200;
|
|
17
17
|
const SETTINGS_FILE = 'tts-settings.json';
|
|
18
|
+
const SPEAKER_PATTERN = /^[\w.-]{1,128}$/;
|
|
19
|
+
// Trailing silence the page may ask for at a paragraph or message end; it is part of the audio, so it also plays in the background.
|
|
20
|
+
const MAX_SILENCE_MS = 3000;
|
|
21
|
+
// Synthesized paragraphs are kept on this computer, so hearing the same text again is instant and not billed again.
|
|
22
|
+
const CACHE_DIR = 'tts-cache';
|
|
23
|
+
const CACHE_LIMIT = 400;
|
|
24
|
+
const CACHE_PRUNE = 100;
|
|
25
|
+
const MAX_MEMBER_VOICES = 50;
|
|
18
26
|
// A default tone instruction (context_texts) so reading sounds told rather than recited; users may change or clear it.
|
|
19
27
|
export const TTS_DEFAULT_CONTEXT = '请用自然放松的语气朗读,像给朋友讲故事一样,有轻重缓急,句子之间自然停顿。';
|
|
20
28
|
export const TTS_DEFAULTS = { speaker: 'zh_female_vv_uranus_bigtts', speech_rate: 0, context: TTS_DEFAULT_CONTEXT };
|
|
@@ -55,20 +63,27 @@ const STATUS_MESSAGES = {
|
|
|
55
63
|
export function tts_settings(home) {
|
|
56
64
|
const path = join(home, SETTINGS_FILE);
|
|
57
65
|
const saved = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {};
|
|
58
|
-
return { ...TTS_DEFAULTS, ...saved, voices: TTS_VOICES, key_base_url: TTS_BASE_URL };
|
|
66
|
+
return { ...TTS_DEFAULTS, member_voices: {}, ...saved, voices: TTS_VOICES, key_base_url: TTS_BASE_URL };
|
|
59
67
|
}
|
|
60
68
|
|
|
61
69
|
/** Validate and save the read-aloud settings the page edits. */
|
|
62
70
|
export function save_tts_settings(home, input) {
|
|
63
71
|
const speaker = String(input.speaker ?? TTS_DEFAULTS.speaker).trim();
|
|
64
|
-
if (
|
|
72
|
+
if (!SPEAKER_PATTERN.test(speaker)) throw new Error('请填写有效的音色 ID');
|
|
65
73
|
const speech_rate = Number(input.speech_rate ?? 0);
|
|
66
74
|
if (!Number.isInteger(speech_rate) || speech_rate < -50 || speech_rate > 100) throw new Error('语速须为 -50 到 100 的整数');
|
|
67
75
|
const context = String(input.context ?? '').trim();
|
|
68
76
|
if (context.length > MAX_CONTEXT) throw new Error(`语气提示不超过 ${MAX_CONTEXT} 字`);
|
|
69
77
|
const api_key_ref = input.api_key_ref || undefined;
|
|
70
78
|
if (api_key_ref && !/^[a-f0-9-]{36}$/i.test(api_key_ref)) throw new Error('API Key 引用无效,请重新填写');
|
|
71
|
-
|
|
79
|
+
// Voices chosen per AI platform; members of other platforms, the user and program messages use the default voice.
|
|
80
|
+
const member_voices = {};
|
|
81
|
+
for (const [name, voice] of Object.entries(input.member_voices || {}).slice(0, MAX_MEMBER_VOICES)) {
|
|
82
|
+
if (!voice) continue;
|
|
83
|
+
if (String(name).length > 40 || !SPEAKER_PATTERN.test(voice)) throw new Error('成员音色无效,请重新选择');
|
|
84
|
+
member_voices[name] = voice;
|
|
85
|
+
}
|
|
86
|
+
const settings = { speaker, speech_rate, context, member_voices, ...(api_key_ref ? { api_key_ref } : {}) };
|
|
72
87
|
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
73
88
|
atomic_json(join(home, SETTINGS_FILE), settings);
|
|
74
89
|
return tts_settings(home);
|
|
@@ -101,8 +116,24 @@ function word_marks(objects) {
|
|
|
101
116
|
return objects.flatMap(item => item.sentence?.words || []).map(word => ({ w: String(word.word || ''), t: Math.round(Number(word.startTime || 0) * 1000) }));
|
|
102
117
|
}
|
|
103
118
|
|
|
119
|
+
/** Read a cached synthesis, refreshing its time so the most recently heard stay; null when absent or unreadable. */
|
|
120
|
+
function cache_read(path) {
|
|
121
|
+
try { const value = JSON.parse(readFileSync(path, 'utf8')); atomic_json(path, value); return value; } catch { return null; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Keep the cache bounded: past the limit, drop the least recently heard entries. */
|
|
125
|
+
function cache_write(directory, path, value) {
|
|
126
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
127
|
+
atomic_json(path, value);
|
|
128
|
+
const names = readdirSync(directory).filter(name => name.endsWith('.json'));
|
|
129
|
+
if (names.length <= CACHE_LIMIT) return;
|
|
130
|
+
const aged = names.map(name => ({ name, time: statSync(join(directory, name)).mtimeMs })).sort((a, b) => a.time - b.time);
|
|
131
|
+
for (const { name } of aged.slice(0, CACHE_PRUNE)) rmSync(join(directory, name), { force: true });
|
|
132
|
+
}
|
|
133
|
+
|
|
104
134
|
/**
|
|
105
135
|
* Synthesize one paragraph and return it as base64 MP3 with word timings for highlighting.
|
|
136
|
+
* `speaker` may name another voice for this paragraph (a member's own voice); `silence_ms` appends trailing silence.
|
|
106
137
|
* Requests of one reading pass share `section_id`, so the service keeps the earlier paragraphs as context.
|
|
107
138
|
*/
|
|
108
139
|
export async function tts_speak(home, input, request = fetch) {
|
|
@@ -111,10 +142,17 @@ export async function tts_speak(home, input, request = fetch) {
|
|
|
111
142
|
if (text.length > TTS_MAX_TEXT) throw new Error(`单次朗读不超过 ${TTS_MAX_TEXT} 字`);
|
|
112
143
|
const settings = tts_settings(home);
|
|
113
144
|
if (!settings.api_key_ref) throw new Error('尚未配置语音:请在设置的“语音”中填写火山引擎 API Key');
|
|
145
|
+
const speaker = SPEAKER_PATTERN.test(input.speaker || '') ? input.speaker : settings.speaker;
|
|
146
|
+
const silence = Math.max(0, Math.min(MAX_SILENCE_MS, Math.round(Number(input.silence_ms) || 0)));
|
|
147
|
+
const directory = join(home, CACHE_DIR);
|
|
148
|
+
const cache_path = join(directory, `${createHash('sha256').update(JSON.stringify([text, speaker, settings.speech_rate, settings.context, silence])).digest('hex')}.json`);
|
|
149
|
+
const cached = existsSync(cache_path) && cache_read(cache_path);
|
|
150
|
+
if (cached?.audio) return { ...cached, cached: true };
|
|
114
151
|
const key = read_saved_api_key(home, TTS_BASE_URL, settings.api_key_ref);
|
|
115
152
|
const section_id = /^[a-f0-9-]{36}$/i.test(input.section_id || '') ? input.section_id : undefined;
|
|
116
|
-
const additions = { ...(settings.context ? { context_texts: [settings.context] } : {}), ...(section_id ? { section_id } : {})
|
|
117
|
-
|
|
153
|
+
const additions = { ...(settings.context ? { context_texts: [settings.context] } : {}), ...(section_id ? { section_id } : {}),
|
|
154
|
+
...(silence ? { silence_duration: silence } : {}) };
|
|
155
|
+
const body = { user: { uid: 'codiscuss' }, req_params: { text, speaker,
|
|
118
156
|
audio_params: { format: 'mp3', sample_rate: 24000, speech_rate: settings.speech_rate, enable_subtitle: true },
|
|
119
157
|
...(Object.keys(additions).length ? { additions: JSON.stringify(additions) } : {}) } };
|
|
120
158
|
let response;
|
|
@@ -127,7 +165,11 @@ export async function tts_speak(home, input, request = fetch) {
|
|
|
127
165
|
const objects = split_json_objects(await response.text());
|
|
128
166
|
const chunks = [];
|
|
129
167
|
for (const item of objects) {
|
|
130
|
-
if (item.code === TTS_DONE_CODE)
|
|
168
|
+
if (item.code === TTS_DONE_CODE) {
|
|
169
|
+
const result = { audio: Buffer.concat(chunks).toString('base64'), format: 'mp3', marks: word_marks(objects) };
|
|
170
|
+
cache_write(directory, cache_path, result);
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
131
173
|
if (item.code !== 0) throw tts_error(item.code, item.message);
|
|
132
174
|
if (item.data) chunks.push(Buffer.from(item.data, 'base64'));
|
|
133
175
|
}
|