privateboard 0.1.36 → 0.1.38
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/dist/boot.js +1142 -50
- package/dist/boot.js.map +1 -1
- package/dist/cli.js +1142 -50
- package/dist/cli.js.map +1 -1
- package/dist/server.js +1121 -50
- package/dist/server.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +3 -2
- package/public/adjourn-overlay.css +2 -2
- package/public/agent-overlay.css +27 -15
- package/public/agent-overlay.js +3 -1
- package/public/agent-profile.css +328 -32
- package/public/agent-profile.js +414 -43
- package/public/app-updater.css +1 -1
- package/public/app.js +1807 -35
- package/public/avatars/chair-blink.svg +1 -0
- package/public/home-3d-loader.js +6 -0
- package/public/home.html +3 -3
- package/public/i18n.js +279 -0
- package/public/icons/folded-sidebar.png +0 -0
- package/public/index.html +410 -147
- package/public/mention-picker.js +1 -1
- package/public/new-agent.css +7 -7
- package/public/onboarding.css +5 -5
- package/public/quote-cta.css +5 -4
- package/public/quote-cta.js +50 -5
- package/public/report.html +27 -7
- package/public/room-settings.css +24 -9
- package/public/thread.css +1211 -0
- package/public/user-settings.css +6 -6
- package/public/user-settings.js +37 -20
- package/public/voice-3d.js +167 -3
- package/public/voice-clone.css +875 -0
- package/public/voice-clone.js +1351 -0
- package/public/voice-replay.css +3 -3
- package/public/icons/search.png +0 -0
package/dist/server.js
CHANGED
|
@@ -888,6 +888,30 @@ var init_room_name_auto = __esm({
|
|
|
888
888
|
}
|
|
889
889
|
});
|
|
890
890
|
|
|
891
|
+
// src/storage/migrations/053_room_threads.sql
|
|
892
|
+
var room_threads_default;
|
|
893
|
+
var init_room_threads = __esm({
|
|
894
|
+
"src/storage/migrations/053_room_threads.sql"() {
|
|
895
|
+
room_threads_default = "-- 053_room_threads.sql \xB7 Private 1:1 threads with a single director.\n--\n-- A \"thread\" is a lightweight room spawned from a live room (the\n-- parent) for the user to pull one director aside without exposing\n-- the conversation to the other directors. The thread reuses the\n-- regular `rooms` / `messages` / `room_members` plumbing \u2014 only two\n-- discriminators tell it apart:\n--\n-- room_kind \xB7 'main' (default \xB7 all existing rows) or 'thread'\n-- thread_director_id \xB7 the single director the thread is with;\n-- NULL on main rooms\n--\n-- parent_room_id (added in 020) is reused to point at the parent\n-- main room. The follow-up-room feature also uses parent_room_id,\n-- so callers must disambiguate via room_kind (follow-up = 'main'\n-- with parent_room_id set; thread = 'thread' with both set).\n--\n-- No FK constraints \u2014 main room deletion handled at storage layer\n-- so a future hard-delete path can cascade thread rooms explicitly.\n\nALTER TABLE rooms ADD COLUMN room_kind TEXT NOT NULL DEFAULT 'main';\nALTER TABLE rooms ADD COLUMN thread_director_id TEXT NULL;\n\n-- Index the parent \u2192 child relationship for the common dock-bar /\n-- \"list threads in this room\" query. Filter to threads only so the\n-- much larger main-room population doesn't dominate the index.\nCREATE INDEX IF NOT EXISTS idx_rooms_parent_thread\n ON rooms (parent_room_id, room_kind)\n WHERE room_kind = 'thread';\n";
|
|
896
|
+
}
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
// src/storage/migrations/054_voice_clone_jobs.sql
|
|
900
|
+
var voice_clone_jobs_default;
|
|
901
|
+
var init_voice_clone_jobs = __esm({
|
|
902
|
+
"src/storage/migrations/054_voice_clone_jobs.sql"() {
|
|
903
|
+
voice_clone_jobs_default = "-- 054_voice_clone_jobs.sql \xB7 Persist voice-clone jobs across a process restart.\n--\n-- A clone job spans 5-60 s (YouTube fetch \u2192 upload \u2192 clone) and the\n-- user can minimize the modal into a bottom-right pill. If the\n-- process is killed mid-job (Ctrl+C, hard restart) the row would\n-- otherwise be left in `running` forever; boot-time recovery (see\n-- src/boot.ts) flips stale `running` rows to `failed` so the UI can\n-- surface \"last clone was interrupted\" instead of silently losing it.\n--\n-- Fields:\n-- id uuid \xB7 primary key, also exposed to client as jobId\n-- agent_id director receiving the cloned voice\n-- provider 'minimax' | 'elevenlabs' \xB7 resolved from active credential at start\n-- source_kind 'file' | 'youtube'\n-- source_ref absolute filesystem path (file) or YouTube URL (youtube)\n-- label user-supplied display label for the new voice; nullable\n-- status 'queued' | 'running' | 'done' | 'failed' | 'cancelled'\n-- current_stage 'fetch' | 'upload' | 'clone' \xB7 which 3rd of the pipeline\n-- pct 0-100 overall progress (each stage covers ~33 pp)\n-- voice_id provider-issued voice id when status='done'; NULL otherwise\n-- error_code short token like 'yt_age_gated' / 'provider_quota'\n-- error_message human-readable detail for the modal\n-- created_at epoch millis\n-- updated_at epoch millis \xB7 refreshed on every progress write\n\nCREATE TABLE IF NOT EXISTS clone_jobs (\n id TEXT PRIMARY KEY,\n agent_id TEXT NOT NULL,\n provider TEXT NOT NULL,\n source_kind TEXT NOT NULL,\n source_ref TEXT NOT NULL,\n label TEXT,\n status TEXT NOT NULL DEFAULT 'queued',\n current_stage TEXT NOT NULL DEFAULT 'fetch',\n pct INTEGER NOT NULL DEFAULT 0,\n voice_id TEXT,\n error_code TEXT,\n error_message TEXT,\n created_at INTEGER NOT NULL,\n updated_at INTEGER NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_clone_jobs_status_updated\n ON clone_jobs (status, updated_at DESC);\n\nCREATE INDEX IF NOT EXISTS idx_clone_jobs_agent_running\n ON clone_jobs (agent_id, status)\n WHERE status IN ('queued', 'running');\n";
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
// src/storage/migrations/055_voice_labels.sql
|
|
908
|
+
var voice_labels_default;
|
|
909
|
+
var init_voice_labels = __esm({
|
|
910
|
+
"src/storage/migrations/055_voice_labels.sql"() {
|
|
911
|
+
voice_labels_default = "-- 055_voice_labels.sql \xB7 Persist user-supplied friendly names for\n-- provider voice_ids that don't have a name field of their own.\n--\n-- MiniMax's `voice_clone` API has no `name` parameter \u2014 the voice_id\n-- IS the dashboard label \u2014 so when the user typed \"Chloe\" in the\n-- clone modal, that string never reaches MiniMax. Previously we\n-- mirrored the label to localStorage, which dies the moment the\n-- user clears site data or moves to another machine. This table is\n-- the durable record. `listVoicesPage` merges it into the catalogue\n-- response so the picker / trigger / message-author labels stay\n-- friendly across reloads, devices, and DB exports.\n--\n-- Fields:\n-- voice_id TEXT PRIMARY KEY \xB7 provider-issued voice id\n-- provider TEXT NOT NULL \xB7 'minimax' | 'elevenlabs'\n-- label TEXT NOT NULL \xB7 user-typed name from the clone modal\n-- created_at INTEGER NOT NULL \xB7 epoch ms\n-- updated_at INTEGER NOT NULL \xB7 epoch ms \xB7 refreshed on rename\n\nCREATE TABLE IF NOT EXISTS voice_labels (\n voice_id TEXT PRIMARY KEY,\n provider TEXT NOT NULL,\n label TEXT NOT NULL,\n created_at INTEGER NOT NULL,\n updated_at INTEGER NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS voice_labels_provider_idx\n ON voice_labels(provider);\n";
|
|
912
|
+
}
|
|
913
|
+
});
|
|
914
|
+
|
|
891
915
|
// src/storage/db.ts
|
|
892
916
|
var db_exports = {};
|
|
893
917
|
__export(db_exports, {
|
|
@@ -1014,6 +1038,9 @@ var init_db = __esm({
|
|
|
1014
1038
|
init_agent_provider_buckets();
|
|
1015
1039
|
init_search_credentials();
|
|
1016
1040
|
init_room_name_auto();
|
|
1041
|
+
init_room_threads();
|
|
1042
|
+
init_voice_clone_jobs();
|
|
1043
|
+
init_voice_labels();
|
|
1017
1044
|
MIGRATIONS = [
|
|
1018
1045
|
{ name: "001_init.sql", sql: init_default },
|
|
1019
1046
|
{ name: "002_default_opus.sql", sql: default_opus_default },
|
|
@@ -1066,7 +1093,10 @@ var init_db = __esm({
|
|
|
1066
1093
|
{ name: "049_voice_credentials.sql", sql: voice_credentials_default },
|
|
1067
1094
|
{ name: "050_agent_provider_buckets.sql", sql: agent_provider_buckets_default },
|
|
1068
1095
|
{ name: "051_search_credentials.sql", sql: search_credentials_default },
|
|
1069
|
-
{ name: "052_room_name_auto.sql", sql: room_name_auto_default }
|
|
1096
|
+
{ name: "052_room_name_auto.sql", sql: room_name_auto_default },
|
|
1097
|
+
{ name: "053_room_threads.sql", sql: room_threads_default },
|
|
1098
|
+
{ name: "054_voice_clone_jobs.sql", sql: voice_clone_jobs_default },
|
|
1099
|
+
{ name: "055_voice_labels.sql", sql: voice_labels_default }
|
|
1070
1100
|
];
|
|
1071
1101
|
_db = null;
|
|
1072
1102
|
}
|
|
@@ -1183,8 +1213,8 @@ var init_persona_jobs = __esm({
|
|
|
1183
1213
|
// src/server.ts
|
|
1184
1214
|
import { serve } from "@hono/node-server";
|
|
1185
1215
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
1186
|
-
import { Hono as
|
|
1187
|
-
import { existsSync as
|
|
1216
|
+
import { Hono as Hono17 } from "hono";
|
|
1217
|
+
import { existsSync as existsSync3 } from "fs";
|
|
1188
1218
|
|
|
1189
1219
|
// src/routes/agents.ts
|
|
1190
1220
|
import { Hono } from "hono";
|
|
@@ -13464,7 +13494,7 @@ function finalizeStreamingMessage(messageId, reason) {
|
|
|
13464
13494
|
|
|
13465
13495
|
// src/storage/rooms.ts
|
|
13466
13496
|
init_db();
|
|
13467
|
-
var ROOM_COLS = "id, number, name, subject, mode, intensity, delivery_mode, vote_trigger, status, brief_style, awaiting_continue, awaiting_clarify, created_at, paused_at, adjourned_at, incognito, parent_room_id, parent_brief_id, name_auto";
|
|
13497
|
+
var ROOM_COLS = "id, number, name, subject, mode, intensity, delivery_mode, vote_trigger, status, brief_style, awaiting_continue, awaiting_clarify, created_at, paused_at, adjourned_at, incognito, parent_room_id, parent_brief_id, name_auto, room_kind, thread_director_id";
|
|
13468
13498
|
function mapRow8(row) {
|
|
13469
13499
|
return {
|
|
13470
13500
|
id: row.id,
|
|
@@ -13485,7 +13515,9 @@ function mapRow8(row) {
|
|
|
13485
13515
|
incognito: row.incognito === 1,
|
|
13486
13516
|
parentRoomId: row.parent_room_id,
|
|
13487
13517
|
parentBriefId: row.parent_brief_id,
|
|
13488
|
-
nameAuto: row.name_auto === 1
|
|
13518
|
+
nameAuto: row.name_auto === 1,
|
|
13519
|
+
kind: row.room_kind === "thread" ? "thread" : "main",
|
|
13520
|
+
threadDirectorId: row.thread_director_id
|
|
13489
13521
|
};
|
|
13490
13522
|
}
|
|
13491
13523
|
function mapMember(row) {
|
|
@@ -13497,7 +13529,9 @@ function mapMember(row) {
|
|
|
13497
13529
|
};
|
|
13498
13530
|
}
|
|
13499
13531
|
function listRooms() {
|
|
13500
|
-
const rows = getDb().prepare(
|
|
13532
|
+
const rows = getDb().prepare(
|
|
13533
|
+
`SELECT ${ROOM_COLS} FROM rooms WHERE room_kind = 'main' ORDER BY created_at DESC`
|
|
13534
|
+
).all();
|
|
13501
13535
|
return rows.map(mapRow8);
|
|
13502
13536
|
}
|
|
13503
13537
|
function getRoom(id) {
|
|
@@ -13517,11 +13551,65 @@ function listAllRoomMembers(roomId) {
|
|
|
13517
13551
|
return rows.map(mapMember);
|
|
13518
13552
|
}
|
|
13519
13553
|
function listFollowUpRooms(parentRoomId) {
|
|
13520
|
-
const rows = getDb().prepare(
|
|
13554
|
+
const rows = getDb().prepare(
|
|
13555
|
+
`SELECT ${ROOM_COLS} FROM rooms WHERE parent_room_id = ? AND room_kind = 'main' ORDER BY created_at DESC`
|
|
13556
|
+
).all(parentRoomId);
|
|
13557
|
+
return rows.map(mapRow8);
|
|
13558
|
+
}
|
|
13559
|
+
function listThreadsForRoom(parentRoomId, opts = {}) {
|
|
13560
|
+
const params = [parentRoomId];
|
|
13561
|
+
let sql = `SELECT ${ROOM_COLS} FROM rooms WHERE parent_room_id = ? AND room_kind = 'thread'`;
|
|
13562
|
+
if (opts.directorId) {
|
|
13563
|
+
sql += ` AND thread_director_id = ?`;
|
|
13564
|
+
params.push(opts.directorId);
|
|
13565
|
+
}
|
|
13566
|
+
sql += ` ORDER BY created_at DESC`;
|
|
13567
|
+
const rows = getDb().prepare(sql).all(...params);
|
|
13521
13568
|
return rows.map(mapRow8);
|
|
13522
13569
|
}
|
|
13570
|
+
function createThread(parentRoomId, directorId) {
|
|
13571
|
+
const parent = getRoom(parentRoomId);
|
|
13572
|
+
if (!parent) throw new Error(`createThread \xB7 parent room ${parentRoomId} not found`);
|
|
13573
|
+
if (parent.kind !== "main") {
|
|
13574
|
+
throw new Error(`createThread \xB7 parent room ${parentRoomId} is a ${parent.kind}; threads can only spawn from main rooms`);
|
|
13575
|
+
}
|
|
13576
|
+
const parentMembers = listRoomMembers(parentRoomId);
|
|
13577
|
+
const isMember = parentMembers.some((m) => m.agentId === directorId);
|
|
13578
|
+
if (!isMember) {
|
|
13579
|
+
throw new Error(`createThread \xB7 director ${directorId} is not a member of parent room ${parentRoomId}`);
|
|
13580
|
+
}
|
|
13581
|
+
const db = getDb();
|
|
13582
|
+
const id = newId();
|
|
13583
|
+
const number = nextRoomNumber();
|
|
13584
|
+
const now = Date.now();
|
|
13585
|
+
const subject = parent.subject;
|
|
13586
|
+
const name = subject.slice(0, 60);
|
|
13587
|
+
const mode = parent.mode;
|
|
13588
|
+
const intensity = parent.intensity;
|
|
13589
|
+
const deliveryMode = "text";
|
|
13590
|
+
const voteTrigger = "manual";
|
|
13591
|
+
const insertRoom = db.prepare(
|
|
13592
|
+
`INSERT INTO rooms (
|
|
13593
|
+
id, number, name, subject, mode, intensity, delivery_mode, vote_trigger,
|
|
13594
|
+
brief_style, status, created_at,
|
|
13595
|
+
parent_room_id, parent_brief_id, name_auto, room_kind, thread_director_id
|
|
13596
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 'live', ?, ?, NULL, 1, 'thread', ?)`
|
|
13597
|
+
);
|
|
13598
|
+
const insertMember = db.prepare(
|
|
13599
|
+
"INSERT INTO room_members (room_id, agent_id, position, joined_at) VALUES (?, ?, ?, ?)"
|
|
13600
|
+
);
|
|
13601
|
+
const tx = db.transaction(() => {
|
|
13602
|
+
insertRoom.run(id, number, name, subject, mode, intensity, deliveryMode, voteTrigger, now, parentRoomId, directorId);
|
|
13603
|
+
insertMember.run(id, directorId, 0, now);
|
|
13604
|
+
});
|
|
13605
|
+
tx();
|
|
13606
|
+
return {
|
|
13607
|
+
room: getRoom(id),
|
|
13608
|
+
members: listRoomMembers(id)
|
|
13609
|
+
};
|
|
13610
|
+
}
|
|
13523
13611
|
function recentDirectorAppearances(windowSize) {
|
|
13524
|
-
const rooms = getDb().prepare("SELECT id FROM rooms ORDER BY created_at DESC LIMIT ?").all(Math.max(1, Math.floor(windowSize)));
|
|
13612
|
+
const rooms = getDb().prepare("SELECT id FROM rooms WHERE room_kind = 'main' ORDER BY created_at DESC LIMIT ?").all(Math.max(1, Math.floor(windowSize)));
|
|
13525
13613
|
const counts = /* @__PURE__ */ new Map();
|
|
13526
13614
|
if (rooms.length === 0) return counts;
|
|
13527
13615
|
const placeholders = rooms.map(() => "?").join(",");
|
|
@@ -13592,6 +13680,18 @@ function setRoomNameFromAuto(roomId, name) {
|
|
|
13592
13680
|
const r = getDb().prepare("UPDATE rooms SET name = ? WHERE id = ? AND name_auto = 1").run(trimmed, roomId);
|
|
13593
13681
|
return r.changes > 0;
|
|
13594
13682
|
}
|
|
13683
|
+
function forceRoomAutoName(roomId, name) {
|
|
13684
|
+
const trimmed = name.trim();
|
|
13685
|
+
if (!trimmed) return false;
|
|
13686
|
+
const r = getDb().prepare("UPDATE rooms SET name = ?, name_auto = 1 WHERE id = ?").run(trimmed, roomId);
|
|
13687
|
+
return r.changes > 0;
|
|
13688
|
+
}
|
|
13689
|
+
function setRoomSubject(roomId, next) {
|
|
13690
|
+
const trimmed = next.trim();
|
|
13691
|
+
if (!trimmed) return false;
|
|
13692
|
+
const r = getDb().prepare("UPDATE rooms SET subject = ? WHERE id = ?").run(trimmed, roomId);
|
|
13693
|
+
return r.changes > 0;
|
|
13694
|
+
}
|
|
13595
13695
|
function addRoomMember(roomId, agentId) {
|
|
13596
13696
|
const db = getDb();
|
|
13597
13697
|
const existing = db.prepare("SELECT agent_id, position, joined_at, removed_at FROM room_members WHERE room_id = ? AND agent_id = ?").get(roomId, agentId);
|
|
@@ -14193,6 +14293,52 @@ function getActiveVoiceKeyPlaintext() {
|
|
|
14193
14293
|
return getVoiceCredentialKey(active.id);
|
|
14194
14294
|
}
|
|
14195
14295
|
|
|
14296
|
+
// src/storage/voice-labels.ts
|
|
14297
|
+
init_db();
|
|
14298
|
+
function rowToLabel(r) {
|
|
14299
|
+
return {
|
|
14300
|
+
voiceId: r.voice_id,
|
|
14301
|
+
provider: r.provider,
|
|
14302
|
+
label: r.label,
|
|
14303
|
+
createdAt: r.created_at,
|
|
14304
|
+
updatedAt: r.updated_at
|
|
14305
|
+
};
|
|
14306
|
+
}
|
|
14307
|
+
function setVoiceLabel(input) {
|
|
14308
|
+
const now = Date.now();
|
|
14309
|
+
const id = (input.voiceId || "").trim();
|
|
14310
|
+
const label = (input.label || "").trim();
|
|
14311
|
+
if (!id || !label) return;
|
|
14312
|
+
getDb().prepare(
|
|
14313
|
+
`INSERT INTO voice_labels (voice_id, provider, label, created_at, updated_at)
|
|
14314
|
+
VALUES (?, ?, ?, ?, ?)
|
|
14315
|
+
ON CONFLICT(voice_id) DO UPDATE SET
|
|
14316
|
+
provider = excluded.provider,
|
|
14317
|
+
label = excluded.label,
|
|
14318
|
+
updated_at = excluded.updated_at`
|
|
14319
|
+
).run(id, input.provider, label, now, now);
|
|
14320
|
+
}
|
|
14321
|
+
function getVoiceLabelMap(voiceIds) {
|
|
14322
|
+
const out = /* @__PURE__ */ new Map();
|
|
14323
|
+
if (voiceIds.length === 0) return out;
|
|
14324
|
+
const CHUNK = 500;
|
|
14325
|
+
for (let i = 0; i < voiceIds.length; i += CHUNK) {
|
|
14326
|
+
const slice = voiceIds.slice(i, i + CHUNK);
|
|
14327
|
+
const placeholders = slice.map(() => "?").join(",");
|
|
14328
|
+
const rows = getDb().prepare(`SELECT voice_id, label FROM voice_labels WHERE voice_id IN (${placeholders})`).all(...slice);
|
|
14329
|
+
for (const r of rows) out.set(r.voice_id, r.label);
|
|
14330
|
+
}
|
|
14331
|
+
return out;
|
|
14332
|
+
}
|
|
14333
|
+
function listVoiceLabels() {
|
|
14334
|
+
const rows = getDb().prepare(`SELECT * FROM voice_labels ORDER BY updated_at DESC`).all();
|
|
14335
|
+
return rows.map(rowToLabel);
|
|
14336
|
+
}
|
|
14337
|
+
function deleteVoiceLabel(voiceId) {
|
|
14338
|
+
const r = getDb().prepare(`DELETE FROM voice_labels WHERE voice_id = ?`).run(voiceId);
|
|
14339
|
+
return r.changes > 0;
|
|
14340
|
+
}
|
|
14341
|
+
|
|
14196
14342
|
// src/voice/registry.ts
|
|
14197
14343
|
function minimaxBaseUrl() {
|
|
14198
14344
|
const region = getPrefs().minimaxRegion;
|
|
@@ -14328,6 +14474,7 @@ async function fetchAllElevenLabsV2Voices(apiKey) {
|
|
|
14328
14474
|
}
|
|
14329
14475
|
const json = await res.json();
|
|
14330
14476
|
const rows = elevenLabsV2VoiceRows(json.voices);
|
|
14477
|
+
rows.sort((a, b) => elevenLabsCategoryRank(a.category) - elevenLabsCategoryRank(b.category));
|
|
14331
14478
|
for (const r of rows) {
|
|
14332
14479
|
out.push({
|
|
14333
14480
|
provider: "elevenlabs",
|
|
@@ -14362,6 +14509,11 @@ async function fetchAllElevenLabsV2Voices(apiKey) {
|
|
|
14362
14509
|
);
|
|
14363
14510
|
return { voices: out, error: lastError };
|
|
14364
14511
|
}
|
|
14512
|
+
function elevenLabsCategoryRank(category) {
|
|
14513
|
+
if (category === "cloned" || category === "professional") return 0;
|
|
14514
|
+
if (category === "generated") return 2;
|
|
14515
|
+
return 1;
|
|
14516
|
+
}
|
|
14365
14517
|
function elevenLabsV2VoiceRows(raw) {
|
|
14366
14518
|
if (!Array.isArray(raw)) return [];
|
|
14367
14519
|
const out = [];
|
|
@@ -14417,8 +14569,8 @@ async function fetchAllMiniMaxVoices(apiKey) {
|
|
|
14417
14569
|
}
|
|
14418
14570
|
const json = await res.json();
|
|
14419
14571
|
const rows = [
|
|
14420
|
-
...voiceRows(json.system_voice, "system"),
|
|
14421
14572
|
...voiceRows(json.voice_cloning, "clone"),
|
|
14573
|
+
...voiceRows(json.system_voice, "system"),
|
|
14422
14574
|
...voiceRows(json.voice_generation, "generated")
|
|
14423
14575
|
];
|
|
14424
14576
|
if (rows.length === 0) {
|
|
@@ -14482,7 +14634,7 @@ async function listVoicesPage(cursorStr, pageSize) {
|
|
|
14482
14634
|
if (activeProvider === "elevenlabs") {
|
|
14483
14635
|
const { voices: all, error } = await getElevenLabsVoicesCached(activeKey);
|
|
14484
14636
|
const offset = cursor && cursor.src === "el" ? cursor.offset ?? 0 : 0;
|
|
14485
|
-
const slice = all.slice(offset, offset + size);
|
|
14637
|
+
const slice = mergeCustomLabels(all.slice(offset, offset + size));
|
|
14486
14638
|
const next = offset + slice.length;
|
|
14487
14639
|
const hasMore = next < all.length;
|
|
14488
14640
|
const nextCursor = hasMore ? encodeCursor({ src: "el", offset: next }) : null;
|
|
@@ -14503,7 +14655,7 @@ async function listVoicesPage(cursorStr, pageSize) {
|
|
|
14503
14655
|
if (activeProvider === "minimax") {
|
|
14504
14656
|
const all = await getMiniMaxVoicesCached(activeKey);
|
|
14505
14657
|
const offset = cursor && cursor.src === "mm" ? cursor.offset ?? 0 : 0;
|
|
14506
|
-
const slice = all.slice(offset, offset + size);
|
|
14658
|
+
const slice = mergeCustomLabels(all.slice(offset, offset + size));
|
|
14507
14659
|
const next = offset + slice.length;
|
|
14508
14660
|
const hasMore = next < all.length;
|
|
14509
14661
|
const nextCursor = hasMore ? encodeCursor({ src: "mm", offset: next }) : null;
|
|
@@ -14519,6 +14671,22 @@ async function listVoicesPage(cursorStr, pageSize) {
|
|
|
14519
14671
|
configured: true
|
|
14520
14672
|
};
|
|
14521
14673
|
}
|
|
14674
|
+
function mergeCustomLabels(voices) {
|
|
14675
|
+
const ids = voices.map((v) => v.voiceId).filter((id) => !!id);
|
|
14676
|
+
if (ids.length === 0) return voices;
|
|
14677
|
+
const labelMap = getVoiceLabelMap(ids);
|
|
14678
|
+
if (labelMap.size === 0) return voices;
|
|
14679
|
+
return voices.map((v) => {
|
|
14680
|
+
const custom = v.voiceId ? labelMap.get(v.voiceId) : void 0;
|
|
14681
|
+
if (!custom) return v;
|
|
14682
|
+
if (v.label && v.label !== v.voiceId) return v;
|
|
14683
|
+
return { ...v, label: custom };
|
|
14684
|
+
});
|
|
14685
|
+
}
|
|
14686
|
+
function invalidateVoicesCache() {
|
|
14687
|
+
miniMaxCache.clear();
|
|
14688
|
+
elevenLabsCache.clear();
|
|
14689
|
+
}
|
|
14522
14690
|
async function listAvailableVoices() {
|
|
14523
14691
|
const voices = [];
|
|
14524
14692
|
let cursor = null;
|
|
@@ -18280,7 +18448,9 @@ var TONE_GUIDANCE = {
|
|
|
18280
18448
|
"1\u20132 \u53E5\u3002\u7ED9\u8FD9\u4E2A idea \u4E00\u4E2A**\u66F4\u6709\u4F20\u64AD\u529B\u7684\u8BF4\u6CD5**\u2014\u2014\u4E00\u53E5 slogan\u3001\u4E00\u4E2A\u65B0\u540D\u5B57\u3001\u4E00\u4E2A\u5BF9\u5916\u8BB2\u5F97\u6E05\u695A\u7684\u5B9A\u4F4D\u3001\u4E00\u4E2A\u8BA9\u4EBA\u8BB0\u4F4F\u7684\u6BD4\u55BB\u3002",
|
|
18281
18449
|
"",
|
|
18282
18450
|
"\u3010\u4E00\u4E2A\u5177\u4F53\u505A\u6CD5\u3011",
|
|
18283
|
-
"1\u20133 \u53E5\u3002\u7ED9\u4E00\u4E2A**\u6700\u5C0F\u53EF\u6267\u884C**\u7684\u5177\u4F53\u52A8\u4F5C / \u5B9E\u9A8C / \u7B2C\u4E00\u6B65\u5F62\u6001\
|
|
18451
|
+
"1\u20133 \u53E5\u3002\u7ED9\u4E00\u4E2A**\u6700\u5C0F\u53EF\u6267\u884C**\u7684\u5177\u4F53\u52A8\u4F5C / \u5B9E\u9A8C / \u7B2C\u4E00\u6B65\u5F62\u6001\u2014\u2014\u53EF\u4EE5\u662F\u4E00\u4E2A\u539F\u578B\u3001\u4E00\u901A\u7535\u8BDD\u3001\u4E00\u6B21\u5C0F\u6D4B\u3001\u4E00\u4EFD\u8349\u6848\u3001\u4E00\u4E2A\u80FD\u8DD1\u901A\u7684\u6700\u5C0F\u95ED\u73AF\u3002\u91CD\u70B9\u662F\u300C\u80FD\u7ACB\u523B\u52A8\u624B\u3001\u89C4\u6A21\u53EF\u63A7\u300D\uFF0C\u4E0D\u662F\u5B8F\u5927\u84DD\u56FE\u3002",
|
|
18452
|
+
" \xB7 **\u4E0D\u8981\u7528\u6A21\u677F\u8154**\u5199\u300C\u4E0B\u5468\u5C31\u80FD\u505A\u7684\u4E8B\u300D\u300C\u8FD9\u5468\u5C31\u80FD\u52A8\u624B\u300D\u300C\u660E\u5929\u5C31\u80FD\u5F00\u59CB\u300D\u8FD9\u4E00\u7C7B**\u6B7B\u677F\u65F6\u95F4\u8868\u8FBE**\u2014\u2014\u4EFB\u4F55 director \u4E00\u65E6\u673A\u68B0\u590D\u8BFB\u300C\u4E0B\u5468\u5C31\u80FD\u505A\u300D\u300C\u4E0B\u5468\u53EF\u4EE5\u2026\u300D/\u300Cnext week we can\u2026\u300D/\u300Cby next week\u2026\u300D\uFF0C\u6574\u6BB5\u90FD\u4F1A\u88AB\u89C6\u4E3A\u6A21\u677F\u586B\u5145\u800C\u975E\u771F\u6B63\u8D21\u732E\u3002\u8FD9\u79CD phrasing **\u6574\u8F6E\u91CC\u6700\u591A\u51FA\u73B0\u4E00\u6B21**\uFF0C\u4E0D\u8981\u6BCF\u4E2A director \u90FD\u91CD\u590D\u3002",
|
|
18453
|
+
" \xB7 \u8868\u8FBE\u300C\u6700\u5C0F\u53EF\u6267\u884C\u300D\u7528\u5404\u81EA\u7684\u8BDD\uFF1A\u4F8B\u5982\u300C\u4E00\u4E2A\u4E0B\u5348\u5C31\u80FD\u62FC\u51FA\u539F\u578B\u300D\u300C\u5148\u627E 3 \u4E2A\u76EE\u6807\u7528\u6237\u804A\u4E00\u804A\u300D\u300C\u62FF\u73B0\u6210\u6570\u636E\u5148\u8DD1\u4E00\u7248\u300D\u300C\u5199\u4E00\u9875 brief \u53D1\u7ED9 X\u300D\u300C\u5728 X \u5E73\u53F0\u4E0A\u6302\u4E2A\u843D\u5730\u9875\u6D4B\u70B9\u51FB\u300D\u300C\u5148\u505A\u5185\u6D4B\u7248\u7ED9\u5C0F\u8303\u56F4\u7528\u6237\u300D\u2014\u2014\u4F60\u7684\u89D2\u8272\u80CC\u666F\u51B3\u5B9A\u4F60\u600E\u4E48\u63CF\u8FF0\u8FD9\u4E2A\u6700\u5C0F\u52A8\u4F5C\uFF0C\u800C\u4E0D\u662F\u5957\u65F6\u95F4\u8BCD\u3002",
|
|
18284
18454
|
"",
|
|
18285
18455
|
"\u3010\u6211\u8865\u5145\u7684\u65B0\u65B9\u5411\u3011",
|
|
18286
18456
|
"1\u20133 \u53E5\u3002\u4ECE\u4F60\u72EC\u7279\u7684\u89D2\u8272\u89C6\u89D2\uFF0C\u5F00\u4E00\u4E2A**\u623F\u95F4\u91CC\u8FD8\u6CA1\u4EBA\u8BB2\u8FC7\u7684\u65B9\u5411**\u3002\u53EF\u4EE5\u662F\u90BB\u8FD1\u9886\u57DF\u7684\u7C7B\u6BD4\u3001\u672A\u88AB\u6CE8\u610F\u7684\u7528\u6237\u573A\u666F\u3001\u8DE8\u5B66\u79D1\u7684\u8FDE\u63A5\u3001\u534A\u6210\u54C1\u5F0F\u7684\u300C\u5982\u679C\u2026\u4F1A\u600E\u6837\u300D\u3002\u8FD9\u91CC\u662F\u4F60 contrarian DNA \u7684\u552F\u4E00\u51FA\u53E3\u2014\u2014\u628A\u5B83\u7528\u5728\u300C\u5F00\u522B\u4EBA\u6CA1\u5F00\u8FC7\u7684\u65B9\u5411\u300D\u4E0A\uFF0C\u4E0D\u662F\u300C\u6307\u51FA\u522B\u4EBA\u7684\u76F2\u70B9\u300D\u3002",
|
|
@@ -18582,6 +18752,15 @@ Name: ${prefs.name}
|
|
|
18582
18752
|
interestLines.push(``);
|
|
18583
18753
|
}
|
|
18584
18754
|
}
|
|
18755
|
+
const threadModeBlock = room.kind === "thread" ? [
|
|
18756
|
+
``,
|
|
18757
|
+
`\u2500\u2500\u2500 PRIVATE ASIDE \xB7 1:1 WITH THE USER \u2500\u2500\u2500`,
|
|
18758
|
+
`This is a private thread the user pulled you into from the main boardroom (room "${room.subject}"). The transcript below shows BOTH (a) the main room conversation up to the moment the user opened this thread, AND (b) this thread's own messages \u2014 chronologically merged so you have full context.`,
|
|
18759
|
+
`Crucially: the other directors are NOT here. They cannot see this conversation. Anything you say below is between you and the user. The chair is not moderating; there is no round-robin; there will be no brief.`,
|
|
18760
|
+
`Your posture \xB7 drop the "speak into the room" framing. You're having a candid 1:1 \u2014 be more personal, more specific, willing to commit to a view, willing to say what you wouldn't put on the record. Stay yourself (your lens, your discipline) but you don't have to "represent your seat" \u2014 just talk with this person.`,
|
|
18761
|
+
`Do not address other directors by name as if they're listening (they aren't). You CAN reference what they said in the main room (it's part of your context) \u2014 "Socrates earlier framed it as X, but between you and me, I think the sharper question is \u2026".`,
|
|
18762
|
+
`No \`@handle\` tokens in prose \u2014 the same handle-vs-name rule applies (use NAME if you reference someone, never the raw handle).`
|
|
18763
|
+
].join("\n") : "";
|
|
18585
18764
|
const system = {
|
|
18586
18765
|
role: "system",
|
|
18587
18766
|
content: [
|
|
@@ -18592,6 +18771,7 @@ Name: ${prefs.name}
|
|
|
18592
18771
|
`Other directors at the table:`,
|
|
18593
18772
|
` \xB7 ${others_summary}`,
|
|
18594
18773
|
youSection,
|
|
18774
|
+
...threadModeBlock ? [threadModeBlock] : [],
|
|
18595
18775
|
...memoryBlock ? [memoryBlock] : [],
|
|
18596
18776
|
...interestLines,
|
|
18597
18777
|
...priorContext && priorContext.trim() ? [priorContext] : [],
|
|
@@ -18614,8 +18794,14 @@ Name: ${prefs.name}
|
|
|
18614
18794
|
`\u2500\u2500\u2500 INTENSITY \xB7 ${intensity.toUpperCase()} \u2500\u2500\u2500`,
|
|
18615
18795
|
intensityLine,
|
|
18616
18796
|
``,
|
|
18617
|
-
|
|
18618
|
-
|
|
18797
|
+
// Round-mode block is only meaningful in main rooms (opening
|
|
18798
|
+
// parallel sweep vs reactive build-on). Threads are a continuous
|
|
18799
|
+
// 1:1 with no rounds, no peers — skip this block entirely so the
|
|
18800
|
+
// model isn't told to "engage other directors" who aren't here.
|
|
18801
|
+
...room.kind === "thread" ? [] : [
|
|
18802
|
+
`\u2500\u2500\u2500 ROUND MODE \xB7 ${opening ? "OPENING (PARALLEL)" : "REACTIVE"} \u2500\u2500\u2500`,
|
|
18803
|
+
opening ? OPENING_BLOCK : REACTIVE_BLOCK
|
|
18804
|
+
],
|
|
18619
18805
|
...chairBriefBlock ? [chairBriefBlock] : [],
|
|
18620
18806
|
...activeSkillsBlock ? ["", activeSkillsBlock] : [],
|
|
18621
18807
|
...sharedMaterials && sharedMaterials.trim() ? ["", sharedMaterials] : [],
|
|
@@ -19498,6 +19684,15 @@ function extractProviderHint(message) {
|
|
|
19498
19684
|
// src/orchestrator/context.ts
|
|
19499
19685
|
function buildDirectorContext(roomId) {
|
|
19500
19686
|
const room = getRoom(roomId);
|
|
19687
|
+
if (room && room.kind === "thread" && room.parentRoomId) {
|
|
19688
|
+
const threadOwn = listMessages(roomId);
|
|
19689
|
+
const parentSnapshot = listMessages(room.parentRoomId).filter((m) => m.createdAt < room.createdAt);
|
|
19690
|
+
const merged = [...parentSnapshot, ...threadOwn].sort(
|
|
19691
|
+
(a, b) => a.createdAt - b.createdAt
|
|
19692
|
+
);
|
|
19693
|
+
const currentRound2 = merged.length > 0 ? Math.max(...merged.map((m) => m.roundNum ?? 0), 0) : 0;
|
|
19694
|
+
return { historyMessages: merged, summaryPreamble: "", currentRound: currentRound2 };
|
|
19695
|
+
}
|
|
19501
19696
|
const allMessages = listMessages(roomId);
|
|
19502
19697
|
if (allMessages.length === 0) {
|
|
19503
19698
|
return { historyMessages: [], summaryPreamble: "", currentRound: 0 };
|
|
@@ -20057,7 +20252,8 @@ function ensureState(roomId) {
|
|
|
20057
20252
|
lastFrameBreakerAgentId: null,
|
|
20058
20253
|
billingHaltedThisTurn: false,
|
|
20059
20254
|
voiceWaiters: /* @__PURE__ */ new Map(),
|
|
20060
|
-
voicePredone: /* @__PURE__ */ new Set()
|
|
20255
|
+
voicePredone: /* @__PURE__ */ new Set(),
|
|
20256
|
+
activeMessageId: null
|
|
20061
20257
|
};
|
|
20062
20258
|
_state.set(roomId, s);
|
|
20063
20259
|
}
|
|
@@ -20184,6 +20380,7 @@ async function chairInterrupt(roomId) {
|
|
|
20184
20380
|
}
|
|
20185
20381
|
state.preWarmed = null;
|
|
20186
20382
|
}
|
|
20383
|
+
state.activeMessageId = null;
|
|
20187
20384
|
if (interruptedAgentId) {
|
|
20188
20385
|
const recent = listRecentMessages(roomId, 8);
|
|
20189
20386
|
for (let i = recent.length - 1; i >= 0; i--) {
|
|
@@ -20319,7 +20516,8 @@ function emitQueueUpdate(roomId, s) {
|
|
|
20319
20516
|
round: {
|
|
20320
20517
|
spoken: s.speakersThisTurn,
|
|
20321
20518
|
total: s.maxSpeakersThisTurn
|
|
20322
|
-
}
|
|
20519
|
+
},
|
|
20520
|
+
activeMessageId: s.activeMessageId
|
|
20323
20521
|
};
|
|
20324
20522
|
roomBus.emit(roomId, update);
|
|
20325
20523
|
}
|
|
@@ -20350,6 +20548,7 @@ function tickRoom(roomId, opts) {
|
|
|
20350
20548
|
}
|
|
20351
20549
|
state.preWarmed = null;
|
|
20352
20550
|
}
|
|
20551
|
+
state.activeMessageId = null;
|
|
20353
20552
|
for (const [, waiter] of state.voiceWaiters) {
|
|
20354
20553
|
waiter.resolve();
|
|
20355
20554
|
}
|
|
@@ -20366,7 +20565,7 @@ function tickRoom(roomId, opts) {
|
|
|
20366
20565
|
state.maxSpeakersThisTurn = plan.length;
|
|
20367
20566
|
emitQueueUpdate(roomId, state);
|
|
20368
20567
|
const tickKind = opts.kind ?? "user";
|
|
20369
|
-
if (!opts.forceSpeakerId && tickKind !== "force") {
|
|
20568
|
+
if (!opts.forceSpeakerId && tickKind !== "force" && room.kind !== "thread") {
|
|
20370
20569
|
announceRoundOpen(roomId, opts.roundNum, tickKind === "user");
|
|
20371
20570
|
}
|
|
20372
20571
|
rlog(roomId, "tick", {
|
|
@@ -20465,6 +20664,21 @@ async function runPickerThenPrewarm(roomId, _currentMessageId) {
|
|
|
20465
20664
|
state.inflight.delete(sentinel);
|
|
20466
20665
|
state.inflight.set(info.messageId, ac);
|
|
20467
20666
|
}
|
|
20667
|
+
if (state.preWarmed !== preWarmed && state.activeMessageId === null) {
|
|
20668
|
+
state.activeMessageId = info.messageId;
|
|
20669
|
+
const m = getMessage(info.messageId);
|
|
20670
|
+
if (m) {
|
|
20671
|
+
const newMeta = { ...m.meta || {}, preWarmed: false };
|
|
20672
|
+
updateMessageBody(info.messageId, m.body, newMeta);
|
|
20673
|
+
roomBus.emit(roomId, {
|
|
20674
|
+
type: "message-updated",
|
|
20675
|
+
messageId: info.messageId,
|
|
20676
|
+
body: m.body,
|
|
20677
|
+
meta: newMeta
|
|
20678
|
+
});
|
|
20679
|
+
}
|
|
20680
|
+
emitQueueUpdate(roomId, state);
|
|
20681
|
+
}
|
|
20468
20682
|
}
|
|
20469
20683
|
// Chain trigger lives in pumpQueue's consume point, NOT here.
|
|
20470
20684
|
// Rationale: B's `message-final` fires while B is still occupying
|
|
@@ -20699,9 +20913,25 @@ async function pumpQueue(roomId) {
|
|
|
20699
20913
|
ac = state.preWarmed.abortController;
|
|
20700
20914
|
streamPromise = state.preWarmed.promise;
|
|
20701
20915
|
state.preWarmed = null;
|
|
20916
|
+
if (justConsumed.messageId) {
|
|
20917
|
+
state.activeMessageId = justConsumed.messageId;
|
|
20918
|
+
const m = getMessage(justConsumed.messageId);
|
|
20919
|
+
if (m) {
|
|
20920
|
+
const newMeta = { ...m.meta || {}, preWarmed: false };
|
|
20921
|
+
updateMessageBody(justConsumed.messageId, m.body, newMeta);
|
|
20922
|
+
roomBus.emit(roomId, {
|
|
20923
|
+
type: "message-updated",
|
|
20924
|
+
messageId: justConsumed.messageId,
|
|
20925
|
+
body: m.body,
|
|
20926
|
+
meta: newMeta
|
|
20927
|
+
});
|
|
20928
|
+
}
|
|
20929
|
+
emitQueueUpdate(roomId, state);
|
|
20930
|
+
}
|
|
20702
20931
|
rlog(roomId, "speaker-prewarm-consumed", {
|
|
20703
20932
|
agent: speaker.name,
|
|
20704
|
-
agentId: speaker.id
|
|
20933
|
+
agentId: speaker.id,
|
|
20934
|
+
messageId: justConsumed.messageId || "(pending)"
|
|
20705
20935
|
});
|
|
20706
20936
|
schedulePreWarm(roomId, justConsumed.messageId);
|
|
20707
20937
|
} else {
|
|
@@ -20726,6 +20956,8 @@ async function pumpQueue(roomId) {
|
|
|
20726
20956
|
state.inflight.delete(sentinel);
|
|
20727
20957
|
state.inflight.set(info.messageId, ac);
|
|
20728
20958
|
}
|
|
20959
|
+
state.activeMessageId = info.messageId;
|
|
20960
|
+
emitQueueUpdate(roomId, state);
|
|
20729
20961
|
},
|
|
20730
20962
|
onMessageFinal: (info) => {
|
|
20731
20963
|
schedulePreWarm(roomId, info.messageId);
|
|
@@ -20769,6 +21001,10 @@ async function pumpQueue(roomId) {
|
|
|
20769
21001
|
if (val === ac) keysToDel.push(key);
|
|
20770
21002
|
}
|
|
20771
21003
|
for (const key of keysToDel) state.inflight.delete(key);
|
|
21004
|
+
if (state.activeMessageId) {
|
|
21005
|
+
state.activeMessageId = null;
|
|
21006
|
+
emitQueueUpdate(roomId, state);
|
|
21007
|
+
}
|
|
20772
21008
|
}
|
|
20773
21009
|
if (state.queue[0] !== entry) {
|
|
20774
21010
|
continue;
|
|
@@ -20861,6 +21097,9 @@ async function pumpQueue(roomId) {
|
|
|
20861
21097
|
});
|
|
20862
21098
|
if (reachedCap) {
|
|
20863
21099
|
const room = getRoom(roomId);
|
|
21100
|
+
if (room && room.kind === "thread") {
|
|
21101
|
+
return;
|
|
21102
|
+
}
|
|
20864
21103
|
if (room && room.status === "live" && !room.awaitingContinue && !room.awaitingClarify && room.voteTrigger === "manual") {
|
|
20865
21104
|
const nextRound = nextUserRoundNum(roomId);
|
|
20866
21105
|
rlog(roomId, "manual-auto-continue", {
|
|
@@ -23160,17 +23399,44 @@ var REJECT_PHRASES = /* @__PURE__ */ new Set([
|
|
|
23160
23399
|
]);
|
|
23161
23400
|
async function generateRoomTitle(roomId) {
|
|
23162
23401
|
const room = getRoom(roomId);
|
|
23163
|
-
if (!room)
|
|
23164
|
-
|
|
23402
|
+
if (!room) {
|
|
23403
|
+
process.stderr.write(`[room-title] room=${roomId} skip=no-room
|
|
23404
|
+
`);
|
|
23405
|
+
return { kind: "skipped", reason: "no-room" };
|
|
23406
|
+
}
|
|
23407
|
+
if (!room.nameAuto) {
|
|
23408
|
+
process.stderr.write(`[room-title] room=${roomId} kind=${room.kind} skip=user-named
|
|
23409
|
+
`);
|
|
23410
|
+
return { kind: "skipped", reason: "user-named" };
|
|
23411
|
+
}
|
|
23165
23412
|
const subject = room.subject.trim();
|
|
23166
|
-
if (!subject)
|
|
23413
|
+
if (!subject) {
|
|
23414
|
+
process.stderr.write(`[room-title] room=${roomId} kind=${room.kind} skip=no-subject
|
|
23415
|
+
`);
|
|
23416
|
+
return { kind: "skipped", reason: "no-subject" };
|
|
23417
|
+
}
|
|
23167
23418
|
const fallbackName = room.subject.slice(0, 60);
|
|
23168
23419
|
if (room.name !== fallbackName) {
|
|
23420
|
+
process.stderr.write(
|
|
23421
|
+
`[room-title] room=${roomId} kind=${room.kind} skip=already-renamed name="${room.name.slice(0, 30)}" fallback="${fallbackName.slice(0, 30)}"
|
|
23422
|
+
`
|
|
23423
|
+
);
|
|
23169
23424
|
return { kind: "skipped", reason: "already-renamed", detail: room.name.slice(0, 60) };
|
|
23170
23425
|
}
|
|
23171
|
-
const
|
|
23172
|
-
if (!
|
|
23173
|
-
const
|
|
23426
|
+
const r = await distillTitle(subject, `room=${roomId} kind=${room.kind}`);
|
|
23427
|
+
if (!r.ok) return { kind: "skipped", reason: r.reason, detail: r.detail };
|
|
23428
|
+
const updated = setRoomNameFromAuto(roomId, r.phrase);
|
|
23429
|
+
if (!updated) return { kind: "skipped", reason: "race-after-rename" };
|
|
23430
|
+
roomBus.emit(roomId, {
|
|
23431
|
+
type: "config-event",
|
|
23432
|
+
kind: "settings-changed",
|
|
23433
|
+
payload: { changes: { name: { from: room.name, to: r.phrase } } },
|
|
23434
|
+
createdAt: Date.now()
|
|
23435
|
+
});
|
|
23436
|
+
return { kind: "ok", before: room.name, after: r.phrase };
|
|
23437
|
+
}
|
|
23438
|
+
function buildTitlePrompt(text) {
|
|
23439
|
+
return `You are titling a conversation for a sidebar entry, the way ChatGPT does it. Read the user's opening question and write the title that another reader would expect to see for THIS conversation \u2014 specific enough to distinguish it from any neighbouring entry in the same domain.
|
|
23174
23440
|
|
|
23175
23441
|
How to write a representative title:
|
|
23176
23442
|
1. Identify the CORE SUBJECT or TASK (the noun, the deliverable, the decision being made).
|
|
@@ -23205,47 +23471,95 @@ Input: I want to redesign our onboarding email sequence \u2014 currently 5 email
|
|
|
23205
23471
|
Output: Onboarding email redesign
|
|
23206
23472
|
|
|
23207
23473
|
--- User's opening question ---
|
|
23208
|
-
${
|
|
23474
|
+
${text}
|
|
23209
23475
|
|
|
23210
23476
|
--- Title ---
|
|
23211
23477
|
`;
|
|
23478
|
+
}
|
|
23479
|
+
async function distillTitle(text, ctx) {
|
|
23480
|
+
const modelV = utilityModelFor();
|
|
23481
|
+
if (!modelV) {
|
|
23482
|
+
process.stderr.write(`[room-title] ${ctx} skip=no-model
|
|
23483
|
+
`);
|
|
23484
|
+
return { ok: false, reason: "no-model" };
|
|
23485
|
+
}
|
|
23486
|
+
process.stderr.write(`[room-title] ${ctx} model=${modelV} input="${text.slice(0, 40)}\u2026" \xB7 calling LLM
|
|
23487
|
+
`);
|
|
23212
23488
|
let raw = "";
|
|
23213
23489
|
try {
|
|
23214
23490
|
raw = await callLLM({
|
|
23215
23491
|
modelV,
|
|
23216
23492
|
carrier: null,
|
|
23217
|
-
messages: [{ role: "user", content:
|
|
23218
|
-
// Low but not zero · 0.2
|
|
23219
|
-
//
|
|
23220
|
-
//
|
|
23493
|
+
messages: [{ role: "user", content: buildTitlePrompt(text) }],
|
|
23494
|
+
// Low but not zero · 0.2 kept locking onto a generic first-noun
|
|
23495
|
+
// pick; 0.4 lets the model trade off alternatives without
|
|
23496
|
+
// wandering into creative territory.
|
|
23221
23497
|
temperature: 0.4,
|
|
23222
|
-
// 40
|
|
23223
|
-
//
|
|
23224
|
-
// a small margin without inviting paragraphs.
|
|
23498
|
+
// 40 truncated mid-title for models that think briefly first;
|
|
23499
|
+
// 80 fits the title plus margin without inviting paragraphs.
|
|
23225
23500
|
maxTokens: 80
|
|
23226
23501
|
});
|
|
23227
23502
|
} catch (e) {
|
|
23228
23503
|
const detail = e instanceof Error ? e.message : String(e);
|
|
23229
|
-
process.stderr.write(`[room-title] LLM call failed
|
|
23504
|
+
process.stderr.write(`[room-title] ${ctx} LLM call failed: ${detail}
|
|
23230
23505
|
`);
|
|
23231
|
-
return {
|
|
23506
|
+
return { ok: false, reason: "llm-error", detail };
|
|
23232
23507
|
}
|
|
23233
23508
|
if (!raw.trim()) {
|
|
23234
|
-
|
|
23509
|
+
process.stderr.write(`[room-title] ${ctx} skip=empty-output model=${modelV}
|
|
23510
|
+
`);
|
|
23511
|
+
return { ok: false, reason: "empty-output", detail: `model=${modelV}` };
|
|
23235
23512
|
}
|
|
23236
23513
|
const phrase = sanitiseTitle(raw);
|
|
23237
23514
|
if (!phrase) {
|
|
23238
|
-
|
|
23515
|
+
process.stderr.write(`[room-title] ${ctx} skip=rejected-generic raw="${raw.trim().slice(0, 80)}"
|
|
23516
|
+
`);
|
|
23517
|
+
return { ok: false, reason: "rejected-generic", detail: raw.trim().slice(0, 80) };
|
|
23518
|
+
}
|
|
23519
|
+
process.stderr.write(`[room-title] ${ctx} llm_raw="${raw.trim().slice(0, 60)}" phrase="${phrase}"
|
|
23520
|
+
`);
|
|
23521
|
+
return { ok: true, phrase };
|
|
23522
|
+
}
|
|
23523
|
+
function threadSeedText(body) {
|
|
23524
|
+
return body.replace(/^\s*[—–-]\s*@.*$/gm, "").replace(/^\s*>\s?/gm, "").replace(/\n{2,}/g, "\n").trim();
|
|
23525
|
+
}
|
|
23526
|
+
async function generateThreadTitle(threadId) {
|
|
23527
|
+
const room = getRoom(threadId);
|
|
23528
|
+
if (!room) {
|
|
23529
|
+
process.stderr.write(`[thread-title] thread=${threadId} skip=no-room
|
|
23530
|
+
`);
|
|
23531
|
+
return { kind: "skipped", reason: "no-room" };
|
|
23532
|
+
}
|
|
23533
|
+
if (room.kind !== "thread") {
|
|
23534
|
+
return { kind: "skipped", reason: "not-thread" };
|
|
23535
|
+
}
|
|
23536
|
+
const firstUser = listMessages(threadId).find((m) => m.authorKind === "user");
|
|
23537
|
+
if (!firstUser || !firstUser.body.trim()) {
|
|
23538
|
+
return { kind: "skipped", reason: "no-message" };
|
|
23239
23539
|
}
|
|
23240
|
-
const
|
|
23540
|
+
const seed = threadSeedText(firstUser.body);
|
|
23541
|
+
if (!seed) {
|
|
23542
|
+
return { kind: "skipped", reason: "no-subject" };
|
|
23543
|
+
}
|
|
23544
|
+
const name = (room.name || "").trim();
|
|
23545
|
+
const isPlaceholder = /^thread:/.test(name);
|
|
23546
|
+
const isRawTruncation = name === room.subject.slice(0, 60) || name === firstUser.body.slice(0, 60);
|
|
23547
|
+
if (!isPlaceholder && !isRawTruncation) {
|
|
23548
|
+
return { kind: "skipped", reason: "already-renamed", detail: name.slice(0, 60) };
|
|
23549
|
+
}
|
|
23550
|
+
const r = await distillTitle(seed, `thread=${threadId}`);
|
|
23551
|
+
if (!r.ok) return { kind: "skipped", reason: r.reason, detail: r.detail };
|
|
23552
|
+
const updated = forceRoomAutoName(threadId, r.phrase);
|
|
23241
23553
|
if (!updated) return { kind: "skipped", reason: "race-after-rename" };
|
|
23242
|
-
roomBus.emit(
|
|
23554
|
+
roomBus.emit(threadId, {
|
|
23243
23555
|
type: "config-event",
|
|
23244
23556
|
kind: "settings-changed",
|
|
23245
|
-
payload: { changes: { name: { from:
|
|
23557
|
+
payload: { changes: { name: { from: name, to: r.phrase } } },
|
|
23246
23558
|
createdAt: Date.now()
|
|
23247
23559
|
});
|
|
23248
|
-
|
|
23560
|
+
process.stderr.write(`[thread-title] OK thread=${threadId} "${name.slice(0, 30)}" \u2192 "${r.phrase}"
|
|
23561
|
+
`);
|
|
23562
|
+
return { kind: "ok", before: name, after: r.phrase };
|
|
23249
23563
|
}
|
|
23250
23564
|
function sanitiseTitle(raw) {
|
|
23251
23565
|
let s = raw.trim();
|
|
@@ -23640,6 +23954,16 @@ function roomsRouter() {
|
|
|
23640
23954
|
return c.json({ deferred: true });
|
|
23641
23955
|
}
|
|
23642
23956
|
const roundNum = nextUserRoundNum(id);
|
|
23957
|
+
let triggerThreadTitle = false;
|
|
23958
|
+
if (room.kind === "thread") {
|
|
23959
|
+
const priorMsgs = listMessages(id);
|
|
23960
|
+
const priorUser = priorMsgs.some((m) => m.authorKind === "user");
|
|
23961
|
+
if (!priorUser) {
|
|
23962
|
+
setRoomSubject(id, text);
|
|
23963
|
+
setRoomNameFromAuto(id, text.slice(0, 60));
|
|
23964
|
+
triggerThreadTitle = true;
|
|
23965
|
+
}
|
|
23966
|
+
}
|
|
23643
23967
|
const msg = insertMessage({
|
|
23644
23968
|
roomId: id,
|
|
23645
23969
|
authorKind: "user",
|
|
@@ -23648,6 +23972,32 @@ function roomsRouter() {
|
|
|
23648
23972
|
meta: mentions.length ? { mentions } : {},
|
|
23649
23973
|
roundNum
|
|
23650
23974
|
});
|
|
23975
|
+
if (triggerThreadTitle) {
|
|
23976
|
+
const before = getRoom(id);
|
|
23977
|
+
process.stderr.write(
|
|
23978
|
+
`[thread-title] firing for thread=${id} subject="${(before?.subject ?? "").slice(0, 40)}" name="${before?.name ?? ""}" nameAuto=${before?.nameAuto}
|
|
23979
|
+
`
|
|
23980
|
+
);
|
|
23981
|
+
generateThreadTitle(id).then((result) => {
|
|
23982
|
+
if (result.kind === "ok") {
|
|
23983
|
+
process.stderr.write(
|
|
23984
|
+
`[thread-title] OK thread=${id} "${result.before.slice(0, 40)}" \u2192 "${result.after}"
|
|
23985
|
+
`
|
|
23986
|
+
);
|
|
23987
|
+
} else {
|
|
23988
|
+
const tail = result.detail ? ` detail="${result.detail.slice(0, 100)}"` : "";
|
|
23989
|
+
process.stderr.write(
|
|
23990
|
+
`[thread-title] SKIP thread=${id} reason=${result.reason}${tail}
|
|
23991
|
+
`
|
|
23992
|
+
);
|
|
23993
|
+
}
|
|
23994
|
+
}).catch((e) => {
|
|
23995
|
+
process.stderr.write(
|
|
23996
|
+
`[thread-title] THROW thread=${id} ${e instanceof Error ? e.message : String(e)}
|
|
23997
|
+
`
|
|
23998
|
+
);
|
|
23999
|
+
});
|
|
24000
|
+
}
|
|
23651
24001
|
roomBus.emit(id, {
|
|
23652
24002
|
type: "message-appended",
|
|
23653
24003
|
messageId: msg.id,
|
|
@@ -23685,7 +24035,7 @@ function roomsRouter() {
|
|
|
23685
24035
|
return c.json(msg);
|
|
23686
24036
|
}
|
|
23687
24037
|
const chair = getChairAgent();
|
|
23688
|
-
const chairMentioned = !!chair && (mentions.includes(chair.id) || /(?:^|\s)@chair\b/i.test(text));
|
|
24038
|
+
const chairMentioned = !!chair && room.kind !== "thread" && (mentions.includes(chair.id) || /(?:^|\s)@chair\b/i.test(text));
|
|
23689
24039
|
if (chairMentioned) {
|
|
23690
24040
|
void chairInterrupt(id).catch((e) => {
|
|
23691
24041
|
process.stderr.write(
|
|
@@ -23704,6 +24054,62 @@ function roomsRouter() {
|
|
|
23704
24054
|
abortRoom(id);
|
|
23705
24055
|
return c.json({ ok: true });
|
|
23706
24056
|
});
|
|
24057
|
+
r.post("/:id/threads", async (c) => {
|
|
24058
|
+
const parentId = c.req.param("id");
|
|
24059
|
+
const parent = getRoom(parentId);
|
|
24060
|
+
if (!parent) return c.json({ error: "parent room not found" }, 404);
|
|
24061
|
+
if (parent.kind !== "main") {
|
|
24062
|
+
return c.json({ error: "threads can only spawn from main rooms" }, 400);
|
|
24063
|
+
}
|
|
24064
|
+
let body;
|
|
24065
|
+
try {
|
|
24066
|
+
body = await c.req.json();
|
|
24067
|
+
} catch {
|
|
24068
|
+
return c.json({ error: "invalid JSON body" }, 400);
|
|
24069
|
+
}
|
|
24070
|
+
const b = body ?? {};
|
|
24071
|
+
const directorId = typeof b.directorId === "string" ? b.directorId.trim() : "";
|
|
24072
|
+
if (!directorId) return c.json({ error: "directorId is required" }, 400);
|
|
24073
|
+
const agent = getAgent(directorId);
|
|
24074
|
+
if (!agent) return c.json({ error: "director not found" }, 404);
|
|
24075
|
+
if (agent.roleKind === "moderator") {
|
|
24076
|
+
return c.json({ error: "cannot open a thread with the chair" }, 400);
|
|
24077
|
+
}
|
|
24078
|
+
try {
|
|
24079
|
+
const existing = listThreadsForRoom(parentId, { directorId });
|
|
24080
|
+
if (existing.length > 0) {
|
|
24081
|
+
const newest = existing[0];
|
|
24082
|
+
const members = listRoomMembers(newest.id);
|
|
24083
|
+
return c.json({ room: newest, members });
|
|
24084
|
+
}
|
|
24085
|
+
const result = createThread(parentId, directorId);
|
|
24086
|
+
return c.json(result);
|
|
24087
|
+
} catch (e) {
|
|
24088
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
24089
|
+
return c.json({ error: msg }, 400);
|
|
24090
|
+
}
|
|
24091
|
+
});
|
|
24092
|
+
r.get("/:id/threads", (c) => {
|
|
24093
|
+
const parentId = c.req.param("id");
|
|
24094
|
+
if (!getRoom(parentId)) return c.json({ error: "not found" }, 404);
|
|
24095
|
+
const directorId = c.req.query("directorId");
|
|
24096
|
+
const threads = listThreadsForRoom(
|
|
24097
|
+
parentId,
|
|
24098
|
+
directorId ? { directorId } : {}
|
|
24099
|
+
);
|
|
24100
|
+
const enriched = threads.map((t) => {
|
|
24101
|
+
const msgs = listMessages(t.id);
|
|
24102
|
+
const messageCount = msgs.filter(
|
|
24103
|
+
(m) => !(m.meta?.streaming === true)
|
|
24104
|
+
).length;
|
|
24105
|
+
return { ...t, messageCount };
|
|
24106
|
+
});
|
|
24107
|
+
for (const t of enriched) {
|
|
24108
|
+
if (t.messageCount > 0) void generateThreadTitle(t.id).catch(() => {
|
|
24109
|
+
});
|
|
24110
|
+
}
|
|
24111
|
+
return c.json({ threads: enriched });
|
|
24112
|
+
});
|
|
23707
24113
|
r.post("/:id/messages/:messageId/voice-done", (c) => {
|
|
23708
24114
|
const id = c.req.param("id");
|
|
23709
24115
|
const messageId = c.req.param("messageId");
|
|
@@ -24511,8 +24917,639 @@ function usageRouter() {
|
|
|
24511
24917
|
return r;
|
|
24512
24918
|
}
|
|
24513
24919
|
|
|
24514
|
-
// src/routes/voice-
|
|
24920
|
+
// src/routes/voice-clone.ts
|
|
24515
24921
|
import { Hono as Hono13 } from "hono";
|
|
24922
|
+
import { streamSSE as streamSSE3 } from "hono/streaming";
|
|
24923
|
+
import { randomBytes as randomBytes9 } from "crypto";
|
|
24924
|
+
import { mkdirSync as mkdirSync2, writeFileSync, statSync as statSync2, rmSync, existsSync as existsSync2 } from "fs";
|
|
24925
|
+
import { tmpdir } from "os";
|
|
24926
|
+
import { join as join4 } from "path";
|
|
24927
|
+
|
|
24928
|
+
// src/storage/clone-jobs.ts
|
|
24929
|
+
init_db();
|
|
24930
|
+
import { randomBytes as randomBytes7 } from "crypto";
|
|
24931
|
+
function rowToJob(r) {
|
|
24932
|
+
return {
|
|
24933
|
+
id: r.id,
|
|
24934
|
+
agentId: r.agent_id,
|
|
24935
|
+
provider: r.provider,
|
|
24936
|
+
sourceKind: r.source_kind,
|
|
24937
|
+
sourceRef: r.source_ref,
|
|
24938
|
+
label: r.label,
|
|
24939
|
+
status: r.status,
|
|
24940
|
+
currentStage: r.current_stage,
|
|
24941
|
+
pct: r.pct,
|
|
24942
|
+
voiceId: r.voice_id,
|
|
24943
|
+
errorCode: r.error_code,
|
|
24944
|
+
errorMessage: r.error_message,
|
|
24945
|
+
createdAt: r.created_at,
|
|
24946
|
+
updatedAt: r.updated_at
|
|
24947
|
+
};
|
|
24948
|
+
}
|
|
24949
|
+
function createCloneJob(input) {
|
|
24950
|
+
const id = randomBytes7(8).toString("hex");
|
|
24951
|
+
const now = Date.now();
|
|
24952
|
+
getDb().prepare(
|
|
24953
|
+
`INSERT INTO clone_jobs (id, agent_id, provider, source_kind, source_ref, label,
|
|
24954
|
+
status, current_stage, pct, created_at, updated_at)
|
|
24955
|
+
VALUES (?, ?, ?, ?, ?, ?, 'queued', 'fetch', 0, ?, ?)`
|
|
24956
|
+
).run(id, input.agentId, input.provider, input.sourceKind, input.sourceRef, input.label ?? null, now, now);
|
|
24957
|
+
const row = getDb().prepare(`SELECT * FROM clone_jobs WHERE id = ?`).get(id);
|
|
24958
|
+
return rowToJob(row);
|
|
24959
|
+
}
|
|
24960
|
+
function getCloneJob(id) {
|
|
24961
|
+
const row = getDb().prepare(`SELECT * FROM clone_jobs WHERE id = ?`).get(id);
|
|
24962
|
+
return row ? rowToJob(row) : null;
|
|
24963
|
+
}
|
|
24964
|
+
function findActiveJobForAgent(agentId) {
|
|
24965
|
+
const row = getDb().prepare(`SELECT * FROM clone_jobs WHERE agent_id = ? AND status IN ('queued', 'running') ORDER BY created_at DESC LIMIT 1`).get(agentId);
|
|
24966
|
+
return row ? rowToJob(row) : null;
|
|
24967
|
+
}
|
|
24968
|
+
function findAnyActiveJob() {
|
|
24969
|
+
const row = getDb().prepare(`SELECT * FROM clone_jobs WHERE status IN ('queued', 'running') ORDER BY created_at DESC LIMIT 1`).get();
|
|
24970
|
+
return row ? rowToJob(row) : null;
|
|
24971
|
+
}
|
|
24972
|
+
function updateCloneJobProgress(id, patch) {
|
|
24973
|
+
const cur = getCloneJob(id);
|
|
24974
|
+
if (!cur) return null;
|
|
24975
|
+
const next = {
|
|
24976
|
+
status: patch.status ?? cur.status,
|
|
24977
|
+
currentStage: patch.currentStage ?? cur.currentStage,
|
|
24978
|
+
pct: patch.pct ?? cur.pct,
|
|
24979
|
+
voiceId: patch.voiceId !== void 0 ? patch.voiceId : cur.voiceId,
|
|
24980
|
+
errorCode: patch.errorCode !== void 0 ? patch.errorCode : cur.errorCode,
|
|
24981
|
+
errorMessage: patch.errorMessage !== void 0 ? patch.errorMessage : cur.errorMessage
|
|
24982
|
+
};
|
|
24983
|
+
getDb().prepare(
|
|
24984
|
+
`UPDATE clone_jobs SET status=?, current_stage=?, pct=?, voice_id=?, error_code=?, error_message=?, updated_at=?
|
|
24985
|
+
WHERE id=?`
|
|
24986
|
+
).run(
|
|
24987
|
+
next.status,
|
|
24988
|
+
next.currentStage,
|
|
24989
|
+
next.pct,
|
|
24990
|
+
next.voiceId,
|
|
24991
|
+
next.errorCode,
|
|
24992
|
+
next.errorMessage,
|
|
24993
|
+
Date.now(),
|
|
24994
|
+
id
|
|
24995
|
+
);
|
|
24996
|
+
return getCloneJob(id);
|
|
24997
|
+
}
|
|
24998
|
+
|
|
24999
|
+
// src/voice/clone.ts
|
|
25000
|
+
import { readFileSync, statSync } from "fs";
|
|
25001
|
+
import { basename } from "path";
|
|
25002
|
+
import { randomBytes as randomBytes8 } from "crypto";
|
|
25003
|
+
var CloneError = class extends Error {
|
|
25004
|
+
code;
|
|
25005
|
+
detail;
|
|
25006
|
+
constructor(code, message, detail = "") {
|
|
25007
|
+
super(message);
|
|
25008
|
+
this.name = "CloneError";
|
|
25009
|
+
this.code = code;
|
|
25010
|
+
this.detail = detail;
|
|
25011
|
+
}
|
|
25012
|
+
};
|
|
25013
|
+
var MAX_AUDIO_BYTES = 20 * 1024 * 1024;
|
|
25014
|
+
var MIN_AUDIO_BYTES = 32 * 1024;
|
|
25015
|
+
function extractMiniMaxGroupId(jwt) {
|
|
25016
|
+
const parts = jwt.split(".");
|
|
25017
|
+
if (parts.length !== 3) return null;
|
|
25018
|
+
try {
|
|
25019
|
+
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
25020
|
+
const candidates = ["GroupID", "group_id", "groupId", "g"];
|
|
25021
|
+
for (const k of candidates) {
|
|
25022
|
+
const v = payload[k];
|
|
25023
|
+
if (typeof v === "string" && v.trim()) return v.trim();
|
|
25024
|
+
}
|
|
25025
|
+
} catch {
|
|
25026
|
+
}
|
|
25027
|
+
return null;
|
|
25028
|
+
}
|
|
25029
|
+
async function cloneFromAudio(input) {
|
|
25030
|
+
validateAudioFile(input.audioPath);
|
|
25031
|
+
if (input.provider === "minimax") return cloneMiniMax(input);
|
|
25032
|
+
if (input.provider === "elevenlabs") return cloneElevenLabs(input);
|
|
25033
|
+
throw new CloneError("provider_unknown", `Unsupported provider ${String(input.provider)}`);
|
|
25034
|
+
}
|
|
25035
|
+
function validateAudioFile(path) {
|
|
25036
|
+
let size;
|
|
25037
|
+
try {
|
|
25038
|
+
size = statSync(path).size;
|
|
25039
|
+
} catch (e) {
|
|
25040
|
+
throw new CloneError("audio_unreadable", "Could not read audio file", String(e));
|
|
25041
|
+
}
|
|
25042
|
+
if (size < MIN_AUDIO_BYTES) throw new CloneError("audio_too_short", "Audio file is too small to clone from");
|
|
25043
|
+
if (size > MAX_AUDIO_BYTES) throw new CloneError("audio_too_large", "Audio file exceeds 20MB");
|
|
25044
|
+
}
|
|
25045
|
+
async function cloneMiniMax(input) {
|
|
25046
|
+
const groupId = input.miniMaxGroupId && input.miniMaxGroupId.trim() || extractMiniMaxGroupId(input.apiKey);
|
|
25047
|
+
if (!groupId) {
|
|
25048
|
+
throw new CloneError(
|
|
25049
|
+
"missing_group_id",
|
|
25050
|
+
'MiniMax needs a Group ID for voice cloning. Paste it into the "MiniMax Group ID" field on the clone dialog, or re-paste a JWT-format key that already carries the ID.'
|
|
25051
|
+
);
|
|
25052
|
+
}
|
|
25053
|
+
const baseUrl = input.miniMaxBaseUrl || "https://api.minimaxi.com";
|
|
25054
|
+
input.onProgress?.(0, "upload");
|
|
25055
|
+
const fileBuf = readFileSync(input.audioPath);
|
|
25056
|
+
const fileName = basename(input.audioPath);
|
|
25057
|
+
const upRes = await streamMultipartUpload({
|
|
25058
|
+
url: `${baseUrl}/v1/files/upload?GroupId=${encodeURIComponent(groupId)}`,
|
|
25059
|
+
headers: { "authorization": `Bearer ${input.apiKey}` },
|
|
25060
|
+
fields: { purpose: "voice_clone" },
|
|
25061
|
+
files: [{ fieldName: "file", bytes: fileBuf, mime: mimeForName(fileName), fileName }],
|
|
25062
|
+
onProgress: (pct) => input.onProgress?.(pct, "upload"),
|
|
25063
|
+
signal: input.signal
|
|
25064
|
+
});
|
|
25065
|
+
if (!upRes.ok) throw await translateMinimaxError(upRes, "upload");
|
|
25066
|
+
const upJson = await upRes.json();
|
|
25067
|
+
const fileId = upJson.file?.file_id;
|
|
25068
|
+
if (!fileId) {
|
|
25069
|
+
const msg = upJson.base_resp?.status_msg || "unknown error";
|
|
25070
|
+
throw new CloneError("provider_unknown", `MiniMax upload returned no file_id: ${msg}`);
|
|
25071
|
+
}
|
|
25072
|
+
input.onProgress?.(100, "upload");
|
|
25073
|
+
input.onProgress?.(0, "clone");
|
|
25074
|
+
const voiceId = buildMiniMaxVoiceId(input.agentId, input.label || null);
|
|
25075
|
+
const cloneRes = await fetch(`${baseUrl}/v1/voice_clone?GroupId=${encodeURIComponent(groupId)}`, {
|
|
25076
|
+
method: "POST",
|
|
25077
|
+
headers: {
|
|
25078
|
+
"authorization": `Bearer ${input.apiKey}`,
|
|
25079
|
+
"content-type": "application/json"
|
|
25080
|
+
},
|
|
25081
|
+
body: JSON.stringify({
|
|
25082
|
+
file_id: fileId,
|
|
25083
|
+
voice_id: voiceId,
|
|
25084
|
+
need_noise_reduction: true,
|
|
25085
|
+
need_volume_normalization: true
|
|
25086
|
+
}),
|
|
25087
|
+
signal: input.signal
|
|
25088
|
+
});
|
|
25089
|
+
if (!cloneRes.ok) throw await translateMinimaxError(cloneRes, "clone");
|
|
25090
|
+
const cloneJson = await cloneRes.json();
|
|
25091
|
+
const status = cloneJson.base_resp?.status_code ?? 0;
|
|
25092
|
+
if (status !== 0) {
|
|
25093
|
+
const msg = cloneJson.base_resp?.status_msg || "unknown error";
|
|
25094
|
+
if (status === 1008 || /insufficient/i.test(msg)) {
|
|
25095
|
+
throw new CloneError("provider_quota", "MiniMax balance is insufficient for voice cloning.", msg);
|
|
25096
|
+
}
|
|
25097
|
+
if (/voice[_ ]id/i.test(msg)) {
|
|
25098
|
+
throw new CloneError("provider_invalid_voice_id", `MiniMax rejected the voice_id: ${msg}`);
|
|
25099
|
+
}
|
|
25100
|
+
throw new CloneError("provider_unknown", `MiniMax voice_clone failed (${status}): ${msg}`);
|
|
25101
|
+
}
|
|
25102
|
+
input.onProgress?.(100, "clone");
|
|
25103
|
+
return { voiceId, label: input.label?.trim() || `Cloned \xB7 ${voiceId}` };
|
|
25104
|
+
}
|
|
25105
|
+
async function translateMinimaxError(res, where) {
|
|
25106
|
+
const text = await res.text().catch(() => "");
|
|
25107
|
+
if (res.status === 401 || res.status === 403) {
|
|
25108
|
+
return new CloneError("provider_auth", "MiniMax rejected the API key. Re-check it in voice settings.", text);
|
|
25109
|
+
}
|
|
25110
|
+
if (res.status === 402 || /insufficient/i.test(text)) {
|
|
25111
|
+
return new CloneError("provider_quota", "MiniMax balance is insufficient for voice cloning.", text);
|
|
25112
|
+
}
|
|
25113
|
+
return new CloneError("provider_unknown", `MiniMax ${where} returned HTTP ${res.status}`, text);
|
|
25114
|
+
}
|
|
25115
|
+
function buildMiniMaxVoiceId(agentId, label) {
|
|
25116
|
+
const ts = Date.now().toString(36);
|
|
25117
|
+
const sanitizedLabel = (label || "").replace(/[^A-Za-z0-9_-]/g, "").slice(0, 16);
|
|
25118
|
+
if (sanitizedLabel && sanitizedLabel.length >= 2) {
|
|
25119
|
+
return `${sanitizedLabel}_${ts}`;
|
|
25120
|
+
}
|
|
25121
|
+
const safeAgent = agentId.replace(/[^A-Za-z0-9]/g, "").slice(0, 8) || "director";
|
|
25122
|
+
return `pb_${safeAgent}_${ts}`;
|
|
25123
|
+
}
|
|
25124
|
+
async function cloneElevenLabs(input) {
|
|
25125
|
+
input.onProgress?.(0, "upload");
|
|
25126
|
+
const fileBuf = readFileSync(input.audioPath);
|
|
25127
|
+
const fileName = basename(input.audioPath);
|
|
25128
|
+
const label = input.label?.trim() || `Cloned \xB7 ${input.agentId.slice(0, 8)}`;
|
|
25129
|
+
const res = await streamMultipartUpload({
|
|
25130
|
+
url: `https://api.elevenlabs.io/v1/voices/add`,
|
|
25131
|
+
headers: { "xi-api-key": input.apiKey },
|
|
25132
|
+
fields: { name: label },
|
|
25133
|
+
files: [{ fieldName: "files", bytes: fileBuf, mime: mimeForName(fileName), fileName }],
|
|
25134
|
+
onProgress: (pct) => input.onProgress?.(pct, "upload"),
|
|
25135
|
+
signal: input.signal
|
|
25136
|
+
});
|
|
25137
|
+
input.onProgress?.(100, "upload");
|
|
25138
|
+
input.onProgress?.(0, "clone");
|
|
25139
|
+
if (!res.ok) {
|
|
25140
|
+
const text = await res.text().catch(() => "");
|
|
25141
|
+
if (res.status === 401) throw new CloneError("provider_auth", "ElevenLabs rejected the API key.", text);
|
|
25142
|
+
if (res.status === 402 || /paid_plan_required|quota_exceeded|insufficient/i.test(text)) {
|
|
25143
|
+
throw new CloneError("provider_quota", "ElevenLabs subscription doesn't allow voice cloning, or you're out of credits.", text);
|
|
25144
|
+
}
|
|
25145
|
+
throw new CloneError("provider_unknown", `ElevenLabs voices/add returned HTTP ${res.status}`, text);
|
|
25146
|
+
}
|
|
25147
|
+
const json = await res.json();
|
|
25148
|
+
const voiceId = json.voice_id;
|
|
25149
|
+
if (!voiceId) throw new CloneError("provider_unknown", "ElevenLabs returned no voice_id");
|
|
25150
|
+
input.onProgress?.(100, "clone");
|
|
25151
|
+
return { voiceId, label };
|
|
25152
|
+
}
|
|
25153
|
+
async function streamMultipartUpload(opts) {
|
|
25154
|
+
const boundary = `----pb-vc-${randomBytes8(8).toString("hex")}`;
|
|
25155
|
+
const CRLF = "\r\n";
|
|
25156
|
+
const enc = (s) => Buffer.from(s, "utf8");
|
|
25157
|
+
const partsBeforeFiles = [];
|
|
25158
|
+
for (const [k, v] of Object.entries(opts.fields)) {
|
|
25159
|
+
partsBeforeFiles.push(enc(`--${boundary}${CRLF}`));
|
|
25160
|
+
partsBeforeFiles.push(enc(`Content-Disposition: form-data; name="${k}"${CRLF}${CRLF}`));
|
|
25161
|
+
partsBeforeFiles.push(enc(`${v}${CRLF}`));
|
|
25162
|
+
}
|
|
25163
|
+
const filePreludes = opts.files.map((f) => enc(
|
|
25164
|
+
`--${boundary}${CRLF}Content-Disposition: form-data; name="${f.fieldName}"; filename="${f.fileName}"${CRLF}Content-Type: ${f.mime}${CRLF}${CRLF}`
|
|
25165
|
+
));
|
|
25166
|
+
const fileEndings = opts.files.map(() => enc(CRLF));
|
|
25167
|
+
const closing = enc(`--${boundary}--${CRLF}`);
|
|
25168
|
+
let total = 0;
|
|
25169
|
+
for (const b of partsBeforeFiles) total += b.length;
|
|
25170
|
+
for (let i = 0; i < opts.files.length; i++) {
|
|
25171
|
+
total += filePreludes[i].length + opts.files[i].bytes.length + fileEndings[i].length;
|
|
25172
|
+
}
|
|
25173
|
+
total += closing.length;
|
|
25174
|
+
const CHUNK_SIZE = 64 * 1024;
|
|
25175
|
+
let step = { kind: "fixed", idx: 0, list: partsBeforeFiles };
|
|
25176
|
+
let sent = 0;
|
|
25177
|
+
const stream = new ReadableStream({
|
|
25178
|
+
pull(controller) {
|
|
25179
|
+
for (; ; ) {
|
|
25180
|
+
if (step.kind === "done") {
|
|
25181
|
+
controller.close();
|
|
25182
|
+
return;
|
|
25183
|
+
}
|
|
25184
|
+
if (step.kind === "fixed") {
|
|
25185
|
+
if (step.idx >= step.list.length) {
|
|
25186
|
+
if (opts.files.length === 0) step = { kind: "closing" };
|
|
25187
|
+
else {
|
|
25188
|
+
controller.enqueue(filePreludes[0]);
|
|
25189
|
+
sent += filePreludes[0].length;
|
|
25190
|
+
opts.onProgress?.(Math.min(99, sent / total * 100));
|
|
25191
|
+
step = { kind: "fileBody", fileIdx: 0, off: 0 };
|
|
25192
|
+
return;
|
|
25193
|
+
}
|
|
25194
|
+
continue;
|
|
25195
|
+
}
|
|
25196
|
+
const chunk = step.list[step.idx++];
|
|
25197
|
+
controller.enqueue(chunk);
|
|
25198
|
+
sent += chunk.length;
|
|
25199
|
+
opts.onProgress?.(Math.min(99, sent / total * 100));
|
|
25200
|
+
return;
|
|
25201
|
+
}
|
|
25202
|
+
if (step.kind === "fileBody") {
|
|
25203
|
+
const file = opts.files[step.fileIdx];
|
|
25204
|
+
if (step.off >= file.bytes.length) {
|
|
25205
|
+
const ending = fileEndings[step.fileIdx];
|
|
25206
|
+
controller.enqueue(ending);
|
|
25207
|
+
sent += ending.length;
|
|
25208
|
+
opts.onProgress?.(Math.min(99, sent / total * 100));
|
|
25209
|
+
const nextIdx = step.fileIdx + 1;
|
|
25210
|
+
if (nextIdx >= opts.files.length) {
|
|
25211
|
+
step = { kind: "closing" };
|
|
25212
|
+
} else {
|
|
25213
|
+
controller.enqueue(filePreludes[nextIdx]);
|
|
25214
|
+
sent += filePreludes[nextIdx].length;
|
|
25215
|
+
opts.onProgress?.(Math.min(99, sent / total * 100));
|
|
25216
|
+
step = { kind: "fileBody", fileIdx: nextIdx, off: 0 };
|
|
25217
|
+
}
|
|
25218
|
+
return;
|
|
25219
|
+
}
|
|
25220
|
+
const slice = file.bytes.subarray(step.off, step.off + CHUNK_SIZE);
|
|
25221
|
+
controller.enqueue(slice);
|
|
25222
|
+
step.off += slice.length;
|
|
25223
|
+
sent += slice.length;
|
|
25224
|
+
opts.onProgress?.(Math.min(99, sent / total * 100));
|
|
25225
|
+
return;
|
|
25226
|
+
}
|
|
25227
|
+
if (step.kind === "closing") {
|
|
25228
|
+
controller.enqueue(closing);
|
|
25229
|
+
sent += closing.length;
|
|
25230
|
+
opts.onProgress?.(100);
|
|
25231
|
+
step = { kind: "done" };
|
|
25232
|
+
return;
|
|
25233
|
+
}
|
|
25234
|
+
}
|
|
25235
|
+
},
|
|
25236
|
+
cancel() {
|
|
25237
|
+
step = { kind: "done" };
|
|
25238
|
+
}
|
|
25239
|
+
});
|
|
25240
|
+
const fetchInit = {
|
|
25241
|
+
method: "POST",
|
|
25242
|
+
headers: {
|
|
25243
|
+
...opts.headers,
|
|
25244
|
+
"content-type": `multipart/form-data; boundary=${boundary}`,
|
|
25245
|
+
"content-length": String(total)
|
|
25246
|
+
},
|
|
25247
|
+
body: stream,
|
|
25248
|
+
duplex: "half",
|
|
25249
|
+
signal: opts.signal
|
|
25250
|
+
};
|
|
25251
|
+
return await fetch(opts.url, fetchInit);
|
|
25252
|
+
}
|
|
25253
|
+
function mimeForName(name) {
|
|
25254
|
+
const lower = name.toLowerCase();
|
|
25255
|
+
if (lower.endsWith(".mp3")) return "audio/mpeg";
|
|
25256
|
+
if (lower.endsWith(".m4a")) return "audio/mp4";
|
|
25257
|
+
if (lower.endsWith(".wav")) return "audio/wav";
|
|
25258
|
+
if (lower.endsWith(".webm")) return "audio/webm";
|
|
25259
|
+
if (lower.endsWith(".ogg")) return "audio/ogg";
|
|
25260
|
+
return "application/octet-stream";
|
|
25261
|
+
}
|
|
25262
|
+
|
|
25263
|
+
// src/routes/voice-clone.ts
|
|
25264
|
+
var listeners = /* @__PURE__ */ new Map();
|
|
25265
|
+
function emit(ev) {
|
|
25266
|
+
const set = listeners.get(ev.jobId);
|
|
25267
|
+
if (!set) return;
|
|
25268
|
+
for (const fn of set) {
|
|
25269
|
+
try {
|
|
25270
|
+
fn(ev);
|
|
25271
|
+
} catch {
|
|
25272
|
+
}
|
|
25273
|
+
}
|
|
25274
|
+
}
|
|
25275
|
+
function subscribe(jobId, fn) {
|
|
25276
|
+
let set = listeners.get(jobId);
|
|
25277
|
+
if (!set) {
|
|
25278
|
+
set = /* @__PURE__ */ new Set();
|
|
25279
|
+
listeners.set(jobId, set);
|
|
25280
|
+
}
|
|
25281
|
+
set.add(fn);
|
|
25282
|
+
return () => {
|
|
25283
|
+
set?.delete(fn);
|
|
25284
|
+
if (set?.size === 0) listeners.delete(jobId);
|
|
25285
|
+
};
|
|
25286
|
+
}
|
|
25287
|
+
var aborters = /* @__PURE__ */ new Map();
|
|
25288
|
+
var workerExtras = /* @__PURE__ */ new Map();
|
|
25289
|
+
function overallPct(stage, innerPct) {
|
|
25290
|
+
const stageIdx = stage === "fetch" ? 0 : stage === "upload" ? 1 : 2;
|
|
25291
|
+
return Math.round(stageIdx * (100 / 3) + innerPct / 3);
|
|
25292
|
+
}
|
|
25293
|
+
function pushProgress(jobId, stage, innerPct, message) {
|
|
25294
|
+
const pct = overallPct(stage, innerPct);
|
|
25295
|
+
updateCloneJobProgress(jobId, { status: "running", currentStage: stage, pct });
|
|
25296
|
+
emit({ jobId, stage, pct, status: "running", message, ts: Date.now() });
|
|
25297
|
+
}
|
|
25298
|
+
async function runWorker(job) {
|
|
25299
|
+
const aborter = new AbortController();
|
|
25300
|
+
aborters.set(job.id, aborter);
|
|
25301
|
+
try {
|
|
25302
|
+
const apiKey = getActiveVoiceKeyPlaintext();
|
|
25303
|
+
if (!apiKey) {
|
|
25304
|
+
throw new CloneError("provider_auth", "No active voice credential. Configure one in voice settings first.");
|
|
25305
|
+
}
|
|
25306
|
+
const audioPath = job.sourceRef;
|
|
25307
|
+
pushProgress(job.id, "fetch", 100, "Using uploaded audio");
|
|
25308
|
+
const extras = workerExtras.get(job.id) || {};
|
|
25309
|
+
const { voiceId, label } = await cloneFromAudio({
|
|
25310
|
+
provider: job.provider,
|
|
25311
|
+
apiKey,
|
|
25312
|
+
audioPath,
|
|
25313
|
+
agentId: job.agentId,
|
|
25314
|
+
label: job.label,
|
|
25315
|
+
miniMaxBaseUrl: job.provider === "minimax" ? minimaxBaseUrlFromPref() : void 0,
|
|
25316
|
+
miniMaxGroupId: job.provider === "minimax" && typeof extras.miniMaxGroupId === "string" ? extras.miniMaxGroupId : null,
|
|
25317
|
+
signal: aborter.signal,
|
|
25318
|
+
onProgress: (pct, stage) => {
|
|
25319
|
+
if (aborter.signal.aborted) return;
|
|
25320
|
+
pushProgress(job.id, stage, pct);
|
|
25321
|
+
}
|
|
25322
|
+
});
|
|
25323
|
+
const agent = getAgent(job.agentId);
|
|
25324
|
+
const existing = agent?.voice;
|
|
25325
|
+
const cloneModel = job.provider === "minimax" ? "speech-2.8-hd" : "eleven_multilingual_v2";
|
|
25326
|
+
const updated = updateAgent(job.agentId, {
|
|
25327
|
+
voice: {
|
|
25328
|
+
provider: job.provider,
|
|
25329
|
+
model: cloneModel,
|
|
25330
|
+
voiceId,
|
|
25331
|
+
...existing?.speed != null ? { speed: existing.speed } : {},
|
|
25332
|
+
...existing?.pitch != null ? { pitch: existing.pitch } : {},
|
|
25333
|
+
...existing?.volume != null ? { volume: existing.volume } : {},
|
|
25334
|
+
...existing?.emotion ? { emotion: existing.emotion } : {}
|
|
25335
|
+
}
|
|
25336
|
+
});
|
|
25337
|
+
if (updated?.voice) writeVoiceBucketEntry(job.agentId, job.provider, updated.voice);
|
|
25338
|
+
if (job.label) setVoiceLabel({ voiceId, provider: job.provider, label: job.label });
|
|
25339
|
+
invalidateVoicesCache();
|
|
25340
|
+
updateCloneJobProgress(job.id, {
|
|
25341
|
+
status: "done",
|
|
25342
|
+
currentStage: "clone",
|
|
25343
|
+
pct: 100,
|
|
25344
|
+
voiceId,
|
|
25345
|
+
errorCode: null,
|
|
25346
|
+
errorMessage: null
|
|
25347
|
+
});
|
|
25348
|
+
emit({
|
|
25349
|
+
jobId: job.id,
|
|
25350
|
+
stage: "clone",
|
|
25351
|
+
pct: 100,
|
|
25352
|
+
status: "done",
|
|
25353
|
+
voiceId,
|
|
25354
|
+
message: label,
|
|
25355
|
+
provider: job.provider,
|
|
25356
|
+
ts: Date.now()
|
|
25357
|
+
});
|
|
25358
|
+
} catch (e) {
|
|
25359
|
+
const { code, message } = normaliseError(e);
|
|
25360
|
+
updateCloneJobProgress(job.id, {
|
|
25361
|
+
status: aborters.has(job.id) ? "failed" : "cancelled",
|
|
25362
|
+
errorCode: code,
|
|
25363
|
+
errorMessage: message
|
|
25364
|
+
});
|
|
25365
|
+
emit({
|
|
25366
|
+
jobId: job.id,
|
|
25367
|
+
stage: getCloneJob(job.id)?.currentStage || "fetch",
|
|
25368
|
+
pct: getCloneJob(job.id)?.pct ?? 0,
|
|
25369
|
+
status: aborters.has(job.id) ? "failed" : "cancelled",
|
|
25370
|
+
errorCode: code,
|
|
25371
|
+
errorMessage: message,
|
|
25372
|
+
ts: Date.now()
|
|
25373
|
+
});
|
|
25374
|
+
} finally {
|
|
25375
|
+
aborters.delete(job.id);
|
|
25376
|
+
workerExtras.delete(job.id);
|
|
25377
|
+
}
|
|
25378
|
+
}
|
|
25379
|
+
function normaliseError(e) {
|
|
25380
|
+
if (e instanceof CloneError) {
|
|
25381
|
+
const detail = e.detail ? `
|
|
25382
|
+
${e.detail.slice(-360)}` : "";
|
|
25383
|
+
return { code: e.code, message: `${e.message}${detail}` };
|
|
25384
|
+
}
|
|
25385
|
+
if (e instanceof Error && e.name === "AbortError") return { code: "cancelled", message: "Clone was cancelled." };
|
|
25386
|
+
return { code: "unknown", message: e instanceof Error ? e.message : String(e) };
|
|
25387
|
+
}
|
|
25388
|
+
function minimaxBaseUrlFromPref() {
|
|
25389
|
+
try {
|
|
25390
|
+
const region = getPrefs().minimaxRegion;
|
|
25391
|
+
return region === "intl" ? "https://api.minimax.io" : "https://api.minimaxi.com";
|
|
25392
|
+
} catch {
|
|
25393
|
+
return "https://api.minimaxi.com";
|
|
25394
|
+
}
|
|
25395
|
+
}
|
|
25396
|
+
function voiceCloneRouter() {
|
|
25397
|
+
const r = new Hono13();
|
|
25398
|
+
r.post("/upload", async (c) => {
|
|
25399
|
+
const ct = c.req.header("content-type") || "";
|
|
25400
|
+
if (!ct.toLowerCase().startsWith("multipart/form-data")) {
|
|
25401
|
+
return c.json({ error: "expected multipart/form-data" }, 400);
|
|
25402
|
+
}
|
|
25403
|
+
const form = await c.req.formData();
|
|
25404
|
+
const file = form.get("file");
|
|
25405
|
+
if (!(file instanceof File)) {
|
|
25406
|
+
return c.json({ error: "missing file field" }, 400);
|
|
25407
|
+
}
|
|
25408
|
+
const safeName = String(file.name || "source").replace(/[^A-Za-z0-9_.\- ]/g, "_") || "source";
|
|
25409
|
+
const dir = join4(tmpdir(), `pb-voice-clone-${randomBytes9(6).toString("hex")}`);
|
|
25410
|
+
mkdirSync2(dir, { recursive: true });
|
|
25411
|
+
const path = join4(dir, safeName);
|
|
25412
|
+
const buf = Buffer.from(await file.arrayBuffer());
|
|
25413
|
+
writeFileSync(path, buf);
|
|
25414
|
+
return c.json({ filePath: path, size: buf.length, name: safeName });
|
|
25415
|
+
});
|
|
25416
|
+
r.post("/start", async (c) => {
|
|
25417
|
+
const body = await c.req.json();
|
|
25418
|
+
const agentId = body.agentId?.trim();
|
|
25419
|
+
const source = body.source || {};
|
|
25420
|
+
if (!agentId) return c.json({ error: "missing agentId" }, 400);
|
|
25421
|
+
if (!getAgent(agentId)) return c.json({ error: "unknown agent" }, 404);
|
|
25422
|
+
if (findAnyActiveJob()) {
|
|
25423
|
+
return c.json({ error: "another clone job is in progress" }, 409);
|
|
25424
|
+
}
|
|
25425
|
+
if (findActiveJobForAgent(agentId)) {
|
|
25426
|
+
return c.json({ error: "this director already has a clone in progress" }, 409);
|
|
25427
|
+
}
|
|
25428
|
+
if (source.kind !== "file" || !source.filePath) {
|
|
25429
|
+
return c.json({ error: "source must be { kind: 'file', filePath }" }, 400);
|
|
25430
|
+
}
|
|
25431
|
+
if (!existsSync2(source.filePath) || !statSync2(source.filePath).isFile()) {
|
|
25432
|
+
return c.json({ error: "uploaded file is missing" }, 400);
|
|
25433
|
+
}
|
|
25434
|
+
const kind = "file";
|
|
25435
|
+
const ref = source.filePath;
|
|
25436
|
+
const provider = getActiveVoiceProvider();
|
|
25437
|
+
if (provider !== "minimax" && provider !== "elevenlabs") {
|
|
25438
|
+
return c.json({ error: "active voice credential must be minimax or elevenlabs" }, 400);
|
|
25439
|
+
}
|
|
25440
|
+
const label = (body.label || "").trim();
|
|
25441
|
+
if (!label) {
|
|
25442
|
+
return c.json({ error: "label is required" }, 400);
|
|
25443
|
+
}
|
|
25444
|
+
const job = createCloneJob({
|
|
25445
|
+
agentId,
|
|
25446
|
+
provider,
|
|
25447
|
+
sourceKind: kind,
|
|
25448
|
+
sourceRef: ref,
|
|
25449
|
+
label
|
|
25450
|
+
});
|
|
25451
|
+
const extras = {};
|
|
25452
|
+
if (body.miniMaxGroupId) extras.miniMaxGroupId = body.miniMaxGroupId.trim();
|
|
25453
|
+
workerExtras.set(job.id, extras);
|
|
25454
|
+
void runWorker(job);
|
|
25455
|
+
return c.json({ jobId: job.id, status: job.status });
|
|
25456
|
+
});
|
|
25457
|
+
r.get("/active", (c) => {
|
|
25458
|
+
const j = findAnyActiveJob();
|
|
25459
|
+
return c.json({ job: j ?? null });
|
|
25460
|
+
});
|
|
25461
|
+
r.get("/:id", (c) => {
|
|
25462
|
+
const j = getCloneJob(c.req.param("id"));
|
|
25463
|
+
if (!j) return c.json({ error: "not found" }, 404);
|
|
25464
|
+
return c.json({ job: j });
|
|
25465
|
+
});
|
|
25466
|
+
r.get("/:id/stream", async (c) => {
|
|
25467
|
+
const id = c.req.param("id");
|
|
25468
|
+
const initial = getCloneJob(id);
|
|
25469
|
+
if (!initial) return c.json({ error: "not found" }, 404);
|
|
25470
|
+
return streamSSE3(c, async (s) => {
|
|
25471
|
+
await s.writeSSE({
|
|
25472
|
+
event: "snapshot",
|
|
25473
|
+
data: JSON.stringify({
|
|
25474
|
+
jobId: initial.id,
|
|
25475
|
+
stage: initial.currentStage,
|
|
25476
|
+
pct: initial.pct,
|
|
25477
|
+
status: initial.status,
|
|
25478
|
+
voiceId: initial.voiceId,
|
|
25479
|
+
errorCode: initial.errorCode,
|
|
25480
|
+
errorMessage: initial.errorMessage,
|
|
25481
|
+
ts: Date.now()
|
|
25482
|
+
})
|
|
25483
|
+
});
|
|
25484
|
+
if (initial.status === "done" || initial.status === "failed" || initial.status === "cancelled") {
|
|
25485
|
+
await s.writeSSE({ event: "end", data: JSON.stringify({ jobId: id, status: initial.status }) });
|
|
25486
|
+
return;
|
|
25487
|
+
}
|
|
25488
|
+
const queue = [];
|
|
25489
|
+
let wake = null;
|
|
25490
|
+
let closed = false;
|
|
25491
|
+
const off = subscribe(id, (ev) => {
|
|
25492
|
+
queue.push(ev);
|
|
25493
|
+
if (wake) {
|
|
25494
|
+
wake();
|
|
25495
|
+
wake = null;
|
|
25496
|
+
}
|
|
25497
|
+
});
|
|
25498
|
+
s.onAbort(() => {
|
|
25499
|
+
closed = true;
|
|
25500
|
+
off();
|
|
25501
|
+
if (wake) {
|
|
25502
|
+
wake();
|
|
25503
|
+
wake = null;
|
|
25504
|
+
}
|
|
25505
|
+
});
|
|
25506
|
+
while (!closed) {
|
|
25507
|
+
if (queue.length === 0) {
|
|
25508
|
+
await new Promise((res) => {
|
|
25509
|
+
wake = res;
|
|
25510
|
+
});
|
|
25511
|
+
if (closed) break;
|
|
25512
|
+
}
|
|
25513
|
+
const ev = queue.shift();
|
|
25514
|
+
await s.writeSSE({ event: "progress", data: JSON.stringify(ev) });
|
|
25515
|
+
if (ev.status === "done" || ev.status === "failed" || ev.status === "cancelled") {
|
|
25516
|
+
await s.writeSSE({ event: "end", data: JSON.stringify({ jobId: id, status: ev.status }) });
|
|
25517
|
+
break;
|
|
25518
|
+
}
|
|
25519
|
+
}
|
|
25520
|
+
off();
|
|
25521
|
+
});
|
|
25522
|
+
});
|
|
25523
|
+
r.delete("/:id", (c) => {
|
|
25524
|
+
const id = c.req.param("id");
|
|
25525
|
+
const job = getCloneJob(id);
|
|
25526
|
+
if (!job) return c.json({ error: "not found" }, 404);
|
|
25527
|
+
const aborter = aborters.get(id);
|
|
25528
|
+
if (aborter) {
|
|
25529
|
+
aborter.abort();
|
|
25530
|
+
aborters.delete(id);
|
|
25531
|
+
}
|
|
25532
|
+
updateCloneJobProgress(id, {
|
|
25533
|
+
status: "cancelled",
|
|
25534
|
+
errorCode: "cancelled",
|
|
25535
|
+
errorMessage: "Cancelled by user."
|
|
25536
|
+
});
|
|
25537
|
+
emit({
|
|
25538
|
+
jobId: id,
|
|
25539
|
+
stage: job.currentStage,
|
|
25540
|
+
pct: job.pct,
|
|
25541
|
+
status: "cancelled",
|
|
25542
|
+
errorCode: "cancelled",
|
|
25543
|
+
errorMessage: "Cancelled by user.",
|
|
25544
|
+
ts: Date.now()
|
|
25545
|
+
});
|
|
25546
|
+
return c.json({ ok: true });
|
|
25547
|
+
});
|
|
25548
|
+
return r;
|
|
25549
|
+
}
|
|
25550
|
+
|
|
25551
|
+
// src/routes/voice-credentials.ts
|
|
25552
|
+
import { Hono as Hono14 } from "hono";
|
|
24516
25553
|
|
|
24517
25554
|
// src/storage/reconcile-voices.ts
|
|
24518
25555
|
var MINIMAX_SEED_VOICES = [
|
|
@@ -24679,7 +25716,7 @@ function pickNextActiveVoiceId(removedProvider) {
|
|
|
24679
25716
|
return sorted[0]?.id ?? null;
|
|
24680
25717
|
}
|
|
24681
25718
|
function voiceCredentialsRouter() {
|
|
24682
|
-
const r = new
|
|
25719
|
+
const r = new Hono14();
|
|
24683
25720
|
r.get("/", (c) => {
|
|
24684
25721
|
const activeId = getPrefs().activeVoiceCredentialId;
|
|
24685
25722
|
const items = listVoiceCredentials().map((m) => payloadFor3(m, activeId));
|
|
@@ -24747,8 +25784,9 @@ function voiceCredentialsRouter() {
|
|
|
24747
25784
|
const label = typeof labelRaw === "string" ? labelRaw : null;
|
|
24748
25785
|
const meta = createVoiceCredential(provider, label, key);
|
|
24749
25786
|
if (!meta) return c.json({ error: "failed to create credential" }, 500);
|
|
24750
|
-
const
|
|
24751
|
-
|
|
25787
|
+
const priorActiveId = getPrefs().activeVoiceCredentialId;
|
|
25788
|
+
const priorActive = priorActiveId ? getVoiceCredentialMeta(priorActiveId) : null;
|
|
25789
|
+
if (!priorActive) {
|
|
24752
25790
|
updatePrefs({ activeVoiceCredentialId: meta.id });
|
|
24753
25791
|
try {
|
|
24754
25792
|
reconcileAgentVoices({ reason: "first-key", priorProvider: null });
|
|
@@ -24758,6 +25796,8 @@ function voiceCredentialsRouter() {
|
|
|
24758
25796
|
`
|
|
24759
25797
|
);
|
|
24760
25798
|
}
|
|
25799
|
+
} else if (priorActive.provider === provider) {
|
|
25800
|
+
updatePrefs({ activeVoiceCredentialId: meta.id });
|
|
24761
25801
|
}
|
|
24762
25802
|
const activeId = getPrefs().activeVoiceCredentialId;
|
|
24763
25803
|
return c.json(payloadFor3(meta, activeId), 201);
|
|
@@ -24796,8 +25836,37 @@ function voiceCredentialsRouter() {
|
|
|
24796
25836
|
return r;
|
|
24797
25837
|
}
|
|
24798
25838
|
|
|
25839
|
+
// src/routes/voice-labels.ts
|
|
25840
|
+
import { Hono as Hono15 } from "hono";
|
|
25841
|
+
function voiceLabelsRouter() {
|
|
25842
|
+
const r = new Hono15();
|
|
25843
|
+
r.get("/", (c) => {
|
|
25844
|
+
return c.json({ labels: listVoiceLabels() });
|
|
25845
|
+
});
|
|
25846
|
+
r.put("/:voiceId", async (c) => {
|
|
25847
|
+
const voiceId = c.req.param("voiceId");
|
|
25848
|
+
if (!voiceId) return c.json({ error: "missing voiceId" }, 400);
|
|
25849
|
+
const body = await c.req.json();
|
|
25850
|
+
const provider = body.provider === "minimax" || body.provider === "elevenlabs" ? body.provider : null;
|
|
25851
|
+
if (!provider) return c.json({ error: "provider must be minimax or elevenlabs" }, 400);
|
|
25852
|
+
const label = (body.label || "").trim();
|
|
25853
|
+
if (!label) return c.json({ error: "label is required" }, 400);
|
|
25854
|
+
setVoiceLabel({ voiceId, provider, label });
|
|
25855
|
+
invalidateVoicesCache();
|
|
25856
|
+
return c.json({ ok: true, voiceId, provider, label });
|
|
25857
|
+
});
|
|
25858
|
+
r.delete("/:voiceId", (c) => {
|
|
25859
|
+
const voiceId = c.req.param("voiceId");
|
|
25860
|
+
if (!voiceId) return c.json({ error: "missing voiceId" }, 400);
|
|
25861
|
+
const removed = deleteVoiceLabel(voiceId);
|
|
25862
|
+
if (removed) invalidateVoicesCache();
|
|
25863
|
+
return c.json({ ok: true, removed });
|
|
25864
|
+
});
|
|
25865
|
+
return r;
|
|
25866
|
+
}
|
|
25867
|
+
|
|
24799
25868
|
// src/routes/voices.ts
|
|
24800
|
-
import { Hono as
|
|
25869
|
+
import { Hono as Hono16 } from "hono";
|
|
24801
25870
|
function ttsErrorMessage(e, providerLabel) {
|
|
24802
25871
|
if (!(e instanceof Error)) return String(e);
|
|
24803
25872
|
const cause = e.cause;
|
|
@@ -24842,7 +25911,7 @@ function ttsCacheSet(key, val) {
|
|
|
24842
25911
|
}
|
|
24843
25912
|
}
|
|
24844
25913
|
function voicesRouter() {
|
|
24845
|
-
const r = new
|
|
25914
|
+
const r = new Hono16();
|
|
24846
25915
|
r.get("/", async (c) => {
|
|
24847
25916
|
const url = new URL(c.req.url);
|
|
24848
25917
|
const cursor = url.searchParams.get("cursor");
|
|
@@ -25009,7 +26078,7 @@ function voicesRouter() {
|
|
|
25009
26078
|
init_paths();
|
|
25010
26079
|
|
|
25011
26080
|
// src/version.ts
|
|
25012
|
-
var VERSION = "0.1.
|
|
26081
|
+
var VERSION = "0.1.38";
|
|
25013
26082
|
|
|
25014
26083
|
// src/utils/render-picker-catalog.ts
|
|
25015
26084
|
function renderPickerCatalog() {
|
|
@@ -25021,9 +26090,9 @@ function renderPickerCatalog() {
|
|
|
25021
26090
|
|
|
25022
26091
|
// src/server.ts
|
|
25023
26092
|
function createApp() {
|
|
25024
|
-
const app = new
|
|
26093
|
+
const app = new Hono17();
|
|
25025
26094
|
const dir = publicDir();
|
|
25026
|
-
if (!
|
|
26095
|
+
if (!existsSync3(dir)) {
|
|
25027
26096
|
throw new Error(
|
|
25028
26097
|
`public/ directory not found at: ${dir}
|
|
25029
26098
|
Build the package or check that public/ is bundled alongside dist/.`
|
|
@@ -25070,6 +26139,8 @@ Build the package or check that public/ is bundled alongside dist/.`
|
|
|
25070
26139
|
app.route("/api/usage", usageRouter());
|
|
25071
26140
|
app.route("/api/voices", voicesRouter());
|
|
25072
26141
|
app.route("/api/voice-credentials", voiceCredentialsRouter());
|
|
26142
|
+
app.route("/api/voice-clone", voiceCloneRouter());
|
|
26143
|
+
app.route("/api/voice-labels", voiceLabelsRouter());
|
|
25073
26144
|
app.route("/api/search", searchRouter());
|
|
25074
26145
|
app.route("/api/search-credentials", searchCredentialsRouter());
|
|
25075
26146
|
app.use(
|