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/cli.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
|
}
|
|
@@ -2609,8 +2639,8 @@ function runSeed() {
|
|
|
2609
2639
|
// src/server.ts
|
|
2610
2640
|
import { serve } from "@hono/node-server";
|
|
2611
2641
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
2612
|
-
import { Hono as
|
|
2613
|
-
import { existsSync as
|
|
2642
|
+
import { Hono as Hono17 } from "hono";
|
|
2643
|
+
import { existsSync as existsSync3 } from "fs";
|
|
2614
2644
|
|
|
2615
2645
|
// src/routes/agents.ts
|
|
2616
2646
|
import { Hono } from "hono";
|
|
@@ -14216,7 +14246,7 @@ function cleanupOrphanedStreams(opts = {}) {
|
|
|
14216
14246
|
|
|
14217
14247
|
// src/storage/rooms.ts
|
|
14218
14248
|
init_db();
|
|
14219
|
-
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";
|
|
14249
|
+
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";
|
|
14220
14250
|
function mapRow8(row) {
|
|
14221
14251
|
return {
|
|
14222
14252
|
id: row.id,
|
|
@@ -14237,7 +14267,9 @@ function mapRow8(row) {
|
|
|
14237
14267
|
incognito: row.incognito === 1,
|
|
14238
14268
|
parentRoomId: row.parent_room_id,
|
|
14239
14269
|
parentBriefId: row.parent_brief_id,
|
|
14240
|
-
nameAuto: row.name_auto === 1
|
|
14270
|
+
nameAuto: row.name_auto === 1,
|
|
14271
|
+
kind: row.room_kind === "thread" ? "thread" : "main",
|
|
14272
|
+
threadDirectorId: row.thread_director_id
|
|
14241
14273
|
};
|
|
14242
14274
|
}
|
|
14243
14275
|
function mapMember(row) {
|
|
@@ -14249,7 +14281,9 @@ function mapMember(row) {
|
|
|
14249
14281
|
};
|
|
14250
14282
|
}
|
|
14251
14283
|
function listRooms() {
|
|
14252
|
-
const rows = getDb().prepare(
|
|
14284
|
+
const rows = getDb().prepare(
|
|
14285
|
+
`SELECT ${ROOM_COLS} FROM rooms WHERE room_kind = 'main' ORDER BY created_at DESC`
|
|
14286
|
+
).all();
|
|
14253
14287
|
return rows.map(mapRow8);
|
|
14254
14288
|
}
|
|
14255
14289
|
function getRoom(id) {
|
|
@@ -14269,11 +14303,65 @@ function listAllRoomMembers(roomId) {
|
|
|
14269
14303
|
return rows.map(mapMember);
|
|
14270
14304
|
}
|
|
14271
14305
|
function listFollowUpRooms(parentRoomId) {
|
|
14272
|
-
const rows = getDb().prepare(
|
|
14306
|
+
const rows = getDb().prepare(
|
|
14307
|
+
`SELECT ${ROOM_COLS} FROM rooms WHERE parent_room_id = ? AND room_kind = 'main' ORDER BY created_at DESC`
|
|
14308
|
+
).all(parentRoomId);
|
|
14309
|
+
return rows.map(mapRow8);
|
|
14310
|
+
}
|
|
14311
|
+
function listThreadsForRoom(parentRoomId, opts = {}) {
|
|
14312
|
+
const params = [parentRoomId];
|
|
14313
|
+
let sql = `SELECT ${ROOM_COLS} FROM rooms WHERE parent_room_id = ? AND room_kind = 'thread'`;
|
|
14314
|
+
if (opts.directorId) {
|
|
14315
|
+
sql += ` AND thread_director_id = ?`;
|
|
14316
|
+
params.push(opts.directorId);
|
|
14317
|
+
}
|
|
14318
|
+
sql += ` ORDER BY created_at DESC`;
|
|
14319
|
+
const rows = getDb().prepare(sql).all(...params);
|
|
14273
14320
|
return rows.map(mapRow8);
|
|
14274
14321
|
}
|
|
14322
|
+
function createThread(parentRoomId, directorId) {
|
|
14323
|
+
const parent = getRoom(parentRoomId);
|
|
14324
|
+
if (!parent) throw new Error(`createThread \xB7 parent room ${parentRoomId} not found`);
|
|
14325
|
+
if (parent.kind !== "main") {
|
|
14326
|
+
throw new Error(`createThread \xB7 parent room ${parentRoomId} is a ${parent.kind}; threads can only spawn from main rooms`);
|
|
14327
|
+
}
|
|
14328
|
+
const parentMembers = listRoomMembers(parentRoomId);
|
|
14329
|
+
const isMember = parentMembers.some((m) => m.agentId === directorId);
|
|
14330
|
+
if (!isMember) {
|
|
14331
|
+
throw new Error(`createThread \xB7 director ${directorId} is not a member of parent room ${parentRoomId}`);
|
|
14332
|
+
}
|
|
14333
|
+
const db = getDb();
|
|
14334
|
+
const id = newId();
|
|
14335
|
+
const number = nextRoomNumber();
|
|
14336
|
+
const now = Date.now();
|
|
14337
|
+
const subject = parent.subject;
|
|
14338
|
+
const name = subject.slice(0, 60);
|
|
14339
|
+
const mode = parent.mode;
|
|
14340
|
+
const intensity = parent.intensity;
|
|
14341
|
+
const deliveryMode = "text";
|
|
14342
|
+
const voteTrigger = "manual";
|
|
14343
|
+
const insertRoom = db.prepare(
|
|
14344
|
+
`INSERT INTO rooms (
|
|
14345
|
+
id, number, name, subject, mode, intensity, delivery_mode, vote_trigger,
|
|
14346
|
+
brief_style, status, created_at,
|
|
14347
|
+
parent_room_id, parent_brief_id, name_auto, room_kind, thread_director_id
|
|
14348
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 'live', ?, ?, NULL, 1, 'thread', ?)`
|
|
14349
|
+
);
|
|
14350
|
+
const insertMember = db.prepare(
|
|
14351
|
+
"INSERT INTO room_members (room_id, agent_id, position, joined_at) VALUES (?, ?, ?, ?)"
|
|
14352
|
+
);
|
|
14353
|
+
const tx = db.transaction(() => {
|
|
14354
|
+
insertRoom.run(id, number, name, subject, mode, intensity, deliveryMode, voteTrigger, now, parentRoomId, directorId);
|
|
14355
|
+
insertMember.run(id, directorId, 0, now);
|
|
14356
|
+
});
|
|
14357
|
+
tx();
|
|
14358
|
+
return {
|
|
14359
|
+
room: getRoom(id),
|
|
14360
|
+
members: listRoomMembers(id)
|
|
14361
|
+
};
|
|
14362
|
+
}
|
|
14275
14363
|
function recentDirectorAppearances(windowSize) {
|
|
14276
|
-
const rooms = getDb().prepare("SELECT id FROM rooms ORDER BY created_at DESC LIMIT ?").all(Math.max(1, Math.floor(windowSize)));
|
|
14364
|
+
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)));
|
|
14277
14365
|
const counts = /* @__PURE__ */ new Map();
|
|
14278
14366
|
if (rooms.length === 0) return counts;
|
|
14279
14367
|
const placeholders = rooms.map(() => "?").join(",");
|
|
@@ -14344,6 +14432,18 @@ function setRoomNameFromAuto(roomId, name) {
|
|
|
14344
14432
|
const r = getDb().prepare("UPDATE rooms SET name = ? WHERE id = ? AND name_auto = 1").run(trimmed, roomId);
|
|
14345
14433
|
return r.changes > 0;
|
|
14346
14434
|
}
|
|
14435
|
+
function forceRoomAutoName(roomId, name) {
|
|
14436
|
+
const trimmed = name.trim();
|
|
14437
|
+
if (!trimmed) return false;
|
|
14438
|
+
const r = getDb().prepare("UPDATE rooms SET name = ?, name_auto = 1 WHERE id = ?").run(trimmed, roomId);
|
|
14439
|
+
return r.changes > 0;
|
|
14440
|
+
}
|
|
14441
|
+
function setRoomSubject(roomId, next) {
|
|
14442
|
+
const trimmed = next.trim();
|
|
14443
|
+
if (!trimmed) return false;
|
|
14444
|
+
const r = getDb().prepare("UPDATE rooms SET subject = ? WHERE id = ?").run(trimmed, roomId);
|
|
14445
|
+
return r.changes > 0;
|
|
14446
|
+
}
|
|
14347
14447
|
function addRoomMember(roomId, agentId) {
|
|
14348
14448
|
const db = getDb();
|
|
14349
14449
|
const existing = db.prepare("SELECT agent_id, position, joined_at, removed_at FROM room_members WHERE room_id = ? AND agent_id = ?").get(roomId, agentId);
|
|
@@ -14960,6 +15060,52 @@ function getActiveVoiceKeyPlaintext() {
|
|
|
14960
15060
|
return getVoiceCredentialKey(active.id);
|
|
14961
15061
|
}
|
|
14962
15062
|
|
|
15063
|
+
// src/storage/voice-labels.ts
|
|
15064
|
+
init_db();
|
|
15065
|
+
function rowToLabel(r) {
|
|
15066
|
+
return {
|
|
15067
|
+
voiceId: r.voice_id,
|
|
15068
|
+
provider: r.provider,
|
|
15069
|
+
label: r.label,
|
|
15070
|
+
createdAt: r.created_at,
|
|
15071
|
+
updatedAt: r.updated_at
|
|
15072
|
+
};
|
|
15073
|
+
}
|
|
15074
|
+
function setVoiceLabel(input) {
|
|
15075
|
+
const now = Date.now();
|
|
15076
|
+
const id = (input.voiceId || "").trim();
|
|
15077
|
+
const label = (input.label || "").trim();
|
|
15078
|
+
if (!id || !label) return;
|
|
15079
|
+
getDb().prepare(
|
|
15080
|
+
`INSERT INTO voice_labels (voice_id, provider, label, created_at, updated_at)
|
|
15081
|
+
VALUES (?, ?, ?, ?, ?)
|
|
15082
|
+
ON CONFLICT(voice_id) DO UPDATE SET
|
|
15083
|
+
provider = excluded.provider,
|
|
15084
|
+
label = excluded.label,
|
|
15085
|
+
updated_at = excluded.updated_at`
|
|
15086
|
+
).run(id, input.provider, label, now, now);
|
|
15087
|
+
}
|
|
15088
|
+
function getVoiceLabelMap(voiceIds) {
|
|
15089
|
+
const out = /* @__PURE__ */ new Map();
|
|
15090
|
+
if (voiceIds.length === 0) return out;
|
|
15091
|
+
const CHUNK = 500;
|
|
15092
|
+
for (let i = 0; i < voiceIds.length; i += CHUNK) {
|
|
15093
|
+
const slice = voiceIds.slice(i, i + CHUNK);
|
|
15094
|
+
const placeholders = slice.map(() => "?").join(",");
|
|
15095
|
+
const rows = getDb().prepare(`SELECT voice_id, label FROM voice_labels WHERE voice_id IN (${placeholders})`).all(...slice);
|
|
15096
|
+
for (const r of rows) out.set(r.voice_id, r.label);
|
|
15097
|
+
}
|
|
15098
|
+
return out;
|
|
15099
|
+
}
|
|
15100
|
+
function listVoiceLabels() {
|
|
15101
|
+
const rows = getDb().prepare(`SELECT * FROM voice_labels ORDER BY updated_at DESC`).all();
|
|
15102
|
+
return rows.map(rowToLabel);
|
|
15103
|
+
}
|
|
15104
|
+
function deleteVoiceLabel(voiceId) {
|
|
15105
|
+
const r = getDb().prepare(`DELETE FROM voice_labels WHERE voice_id = ?`).run(voiceId);
|
|
15106
|
+
return r.changes > 0;
|
|
15107
|
+
}
|
|
15108
|
+
|
|
14963
15109
|
// src/voice/registry.ts
|
|
14964
15110
|
function minimaxBaseUrl() {
|
|
14965
15111
|
const region = getPrefs().minimaxRegion;
|
|
@@ -15095,6 +15241,7 @@ async function fetchAllElevenLabsV2Voices(apiKey) {
|
|
|
15095
15241
|
}
|
|
15096
15242
|
const json = await res.json();
|
|
15097
15243
|
const rows = elevenLabsV2VoiceRows(json.voices);
|
|
15244
|
+
rows.sort((a, b) => elevenLabsCategoryRank(a.category) - elevenLabsCategoryRank(b.category));
|
|
15098
15245
|
for (const r of rows) {
|
|
15099
15246
|
out.push({
|
|
15100
15247
|
provider: "elevenlabs",
|
|
@@ -15129,6 +15276,11 @@ async function fetchAllElevenLabsV2Voices(apiKey) {
|
|
|
15129
15276
|
);
|
|
15130
15277
|
return { voices: out, error: lastError };
|
|
15131
15278
|
}
|
|
15279
|
+
function elevenLabsCategoryRank(category) {
|
|
15280
|
+
if (category === "cloned" || category === "professional") return 0;
|
|
15281
|
+
if (category === "generated") return 2;
|
|
15282
|
+
return 1;
|
|
15283
|
+
}
|
|
15132
15284
|
function elevenLabsV2VoiceRows(raw) {
|
|
15133
15285
|
if (!Array.isArray(raw)) return [];
|
|
15134
15286
|
const out = [];
|
|
@@ -15184,8 +15336,8 @@ async function fetchAllMiniMaxVoices(apiKey) {
|
|
|
15184
15336
|
}
|
|
15185
15337
|
const json = await res.json();
|
|
15186
15338
|
const rows = [
|
|
15187
|
-
...voiceRows(json.system_voice, "system"),
|
|
15188
15339
|
...voiceRows(json.voice_cloning, "clone"),
|
|
15340
|
+
...voiceRows(json.system_voice, "system"),
|
|
15189
15341
|
...voiceRows(json.voice_generation, "generated")
|
|
15190
15342
|
];
|
|
15191
15343
|
if (rows.length === 0) {
|
|
@@ -15249,7 +15401,7 @@ async function listVoicesPage(cursorStr, pageSize) {
|
|
|
15249
15401
|
if (activeProvider === "elevenlabs") {
|
|
15250
15402
|
const { voices: all, error } = await getElevenLabsVoicesCached(activeKey);
|
|
15251
15403
|
const offset = cursor && cursor.src === "el" ? cursor.offset ?? 0 : 0;
|
|
15252
|
-
const slice = all.slice(offset, offset + size);
|
|
15404
|
+
const slice = mergeCustomLabels(all.slice(offset, offset + size));
|
|
15253
15405
|
const next = offset + slice.length;
|
|
15254
15406
|
const hasMore = next < all.length;
|
|
15255
15407
|
const nextCursor = hasMore ? encodeCursor({ src: "el", offset: next }) : null;
|
|
@@ -15270,7 +15422,7 @@ async function listVoicesPage(cursorStr, pageSize) {
|
|
|
15270
15422
|
if (activeProvider === "minimax") {
|
|
15271
15423
|
const all = await getMiniMaxVoicesCached(activeKey);
|
|
15272
15424
|
const offset = cursor && cursor.src === "mm" ? cursor.offset ?? 0 : 0;
|
|
15273
|
-
const slice = all.slice(offset, offset + size);
|
|
15425
|
+
const slice = mergeCustomLabels(all.slice(offset, offset + size));
|
|
15274
15426
|
const next = offset + slice.length;
|
|
15275
15427
|
const hasMore = next < all.length;
|
|
15276
15428
|
const nextCursor = hasMore ? encodeCursor({ src: "mm", offset: next }) : null;
|
|
@@ -15286,6 +15438,22 @@ async function listVoicesPage(cursorStr, pageSize) {
|
|
|
15286
15438
|
configured: true
|
|
15287
15439
|
};
|
|
15288
15440
|
}
|
|
15441
|
+
function mergeCustomLabels(voices) {
|
|
15442
|
+
const ids = voices.map((v) => v.voiceId).filter((id) => !!id);
|
|
15443
|
+
if (ids.length === 0) return voices;
|
|
15444
|
+
const labelMap = getVoiceLabelMap(ids);
|
|
15445
|
+
if (labelMap.size === 0) return voices;
|
|
15446
|
+
return voices.map((v) => {
|
|
15447
|
+
const custom = v.voiceId ? labelMap.get(v.voiceId) : void 0;
|
|
15448
|
+
if (!custom) return v;
|
|
15449
|
+
if (v.label && v.label !== v.voiceId) return v;
|
|
15450
|
+
return { ...v, label: custom };
|
|
15451
|
+
});
|
|
15452
|
+
}
|
|
15453
|
+
function invalidateVoicesCache() {
|
|
15454
|
+
miniMaxCache.clear();
|
|
15455
|
+
elevenLabsCache.clear();
|
|
15456
|
+
}
|
|
15289
15457
|
async function listAvailableVoices() {
|
|
15290
15458
|
const voices = [];
|
|
15291
15459
|
let cursor = null;
|
|
@@ -19047,7 +19215,9 @@ var TONE_GUIDANCE = {
|
|
|
19047
19215
|
"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",
|
|
19048
19216
|
"",
|
|
19049
19217
|
"\u3010\u4E00\u4E2A\u5177\u4F53\u505A\u6CD5\u3011",
|
|
19050
|
-
"1\u20133 \u53E5\u3002\u7ED9\u4E00\u4E2A**\u6700\u5C0F\u53EF\u6267\u884C**\u7684\u5177\u4F53\u52A8\u4F5C / \u5B9E\u9A8C / \u7B2C\u4E00\u6B65\u5F62\u6001\
|
|
19218
|
+
"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",
|
|
19219
|
+
" \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",
|
|
19220
|
+
" \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",
|
|
19051
19221
|
"",
|
|
19052
19222
|
"\u3010\u6211\u8865\u5145\u7684\u65B0\u65B9\u5411\u3011",
|
|
19053
19223
|
"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",
|
|
@@ -19349,6 +19519,15 @@ Name: ${prefs.name}
|
|
|
19349
19519
|
interestLines.push(``);
|
|
19350
19520
|
}
|
|
19351
19521
|
}
|
|
19522
|
+
const threadModeBlock = room.kind === "thread" ? [
|
|
19523
|
+
``,
|
|
19524
|
+
`\u2500\u2500\u2500 PRIVATE ASIDE \xB7 1:1 WITH THE USER \u2500\u2500\u2500`,
|
|
19525
|
+
`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.`,
|
|
19526
|
+
`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.`,
|
|
19527
|
+
`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.`,
|
|
19528
|
+
`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".`,
|
|
19529
|
+
`No \`@handle\` tokens in prose \u2014 the same handle-vs-name rule applies (use NAME if you reference someone, never the raw handle).`
|
|
19530
|
+
].join("\n") : "";
|
|
19352
19531
|
const system = {
|
|
19353
19532
|
role: "system",
|
|
19354
19533
|
content: [
|
|
@@ -19359,6 +19538,7 @@ Name: ${prefs.name}
|
|
|
19359
19538
|
`Other directors at the table:`,
|
|
19360
19539
|
` \xB7 ${others_summary}`,
|
|
19361
19540
|
youSection,
|
|
19541
|
+
...threadModeBlock ? [threadModeBlock] : [],
|
|
19362
19542
|
...memoryBlock ? [memoryBlock] : [],
|
|
19363
19543
|
...interestLines,
|
|
19364
19544
|
...priorContext && priorContext.trim() ? [priorContext] : [],
|
|
@@ -19381,8 +19561,14 @@ Name: ${prefs.name}
|
|
|
19381
19561
|
`\u2500\u2500\u2500 INTENSITY \xB7 ${intensity.toUpperCase()} \u2500\u2500\u2500`,
|
|
19382
19562
|
intensityLine,
|
|
19383
19563
|
``,
|
|
19384
|
-
|
|
19385
|
-
|
|
19564
|
+
// Round-mode block is only meaningful in main rooms (opening
|
|
19565
|
+
// parallel sweep vs reactive build-on). Threads are a continuous
|
|
19566
|
+
// 1:1 with no rounds, no peers — skip this block entirely so the
|
|
19567
|
+
// model isn't told to "engage other directors" who aren't here.
|
|
19568
|
+
...room.kind === "thread" ? [] : [
|
|
19569
|
+
`\u2500\u2500\u2500 ROUND MODE \xB7 ${opening ? "OPENING (PARALLEL)" : "REACTIVE"} \u2500\u2500\u2500`,
|
|
19570
|
+
opening ? OPENING_BLOCK : REACTIVE_BLOCK
|
|
19571
|
+
],
|
|
19386
19572
|
...chairBriefBlock ? [chairBriefBlock] : [],
|
|
19387
19573
|
...activeSkillsBlock ? ["", activeSkillsBlock] : [],
|
|
19388
19574
|
...sharedMaterials && sharedMaterials.trim() ? ["", sharedMaterials] : [],
|
|
@@ -20265,6 +20451,15 @@ function extractProviderHint(message) {
|
|
|
20265
20451
|
// src/orchestrator/context.ts
|
|
20266
20452
|
function buildDirectorContext(roomId) {
|
|
20267
20453
|
const room = getRoom(roomId);
|
|
20454
|
+
if (room && room.kind === "thread" && room.parentRoomId) {
|
|
20455
|
+
const threadOwn = listMessages(roomId);
|
|
20456
|
+
const parentSnapshot = listMessages(room.parentRoomId).filter((m) => m.createdAt < room.createdAt);
|
|
20457
|
+
const merged = [...parentSnapshot, ...threadOwn].sort(
|
|
20458
|
+
(a, b) => a.createdAt - b.createdAt
|
|
20459
|
+
);
|
|
20460
|
+
const currentRound2 = merged.length > 0 ? Math.max(...merged.map((m) => m.roundNum ?? 0), 0) : 0;
|
|
20461
|
+
return { historyMessages: merged, summaryPreamble: "", currentRound: currentRound2 };
|
|
20462
|
+
}
|
|
20268
20463
|
const allMessages = listMessages(roomId);
|
|
20269
20464
|
if (allMessages.length === 0) {
|
|
20270
20465
|
return { historyMessages: [], summaryPreamble: "", currentRound: 0 };
|
|
@@ -20824,7 +21019,8 @@ function ensureState(roomId) {
|
|
|
20824
21019
|
lastFrameBreakerAgentId: null,
|
|
20825
21020
|
billingHaltedThisTurn: false,
|
|
20826
21021
|
voiceWaiters: /* @__PURE__ */ new Map(),
|
|
20827
|
-
voicePredone: /* @__PURE__ */ new Set()
|
|
21022
|
+
voicePredone: /* @__PURE__ */ new Set(),
|
|
21023
|
+
activeMessageId: null
|
|
20828
21024
|
};
|
|
20829
21025
|
_state.set(roomId, s);
|
|
20830
21026
|
}
|
|
@@ -20951,6 +21147,7 @@ async function chairInterrupt(roomId) {
|
|
|
20951
21147
|
}
|
|
20952
21148
|
state.preWarmed = null;
|
|
20953
21149
|
}
|
|
21150
|
+
state.activeMessageId = null;
|
|
20954
21151
|
if (interruptedAgentId) {
|
|
20955
21152
|
const recent = listRecentMessages(roomId, 8);
|
|
20956
21153
|
for (let i = recent.length - 1; i >= 0; i--) {
|
|
@@ -21086,7 +21283,8 @@ function emitQueueUpdate(roomId, s) {
|
|
|
21086
21283
|
round: {
|
|
21087
21284
|
spoken: s.speakersThisTurn,
|
|
21088
21285
|
total: s.maxSpeakersThisTurn
|
|
21089
|
-
}
|
|
21286
|
+
},
|
|
21287
|
+
activeMessageId: s.activeMessageId
|
|
21090
21288
|
};
|
|
21091
21289
|
roomBus.emit(roomId, update);
|
|
21092
21290
|
}
|
|
@@ -21117,6 +21315,7 @@ function tickRoom(roomId, opts) {
|
|
|
21117
21315
|
}
|
|
21118
21316
|
state.preWarmed = null;
|
|
21119
21317
|
}
|
|
21318
|
+
state.activeMessageId = null;
|
|
21120
21319
|
for (const [, waiter] of state.voiceWaiters) {
|
|
21121
21320
|
waiter.resolve();
|
|
21122
21321
|
}
|
|
@@ -21133,7 +21332,7 @@ function tickRoom(roomId, opts) {
|
|
|
21133
21332
|
state.maxSpeakersThisTurn = plan.length;
|
|
21134
21333
|
emitQueueUpdate(roomId, state);
|
|
21135
21334
|
const tickKind = opts.kind ?? "user";
|
|
21136
|
-
if (!opts.forceSpeakerId && tickKind !== "force") {
|
|
21335
|
+
if (!opts.forceSpeakerId && tickKind !== "force" && room.kind !== "thread") {
|
|
21137
21336
|
announceRoundOpen(roomId, opts.roundNum, tickKind === "user");
|
|
21138
21337
|
}
|
|
21139
21338
|
rlog(roomId, "tick", {
|
|
@@ -21232,6 +21431,21 @@ async function runPickerThenPrewarm(roomId, _currentMessageId) {
|
|
|
21232
21431
|
state.inflight.delete(sentinel);
|
|
21233
21432
|
state.inflight.set(info.messageId, ac);
|
|
21234
21433
|
}
|
|
21434
|
+
if (state.preWarmed !== preWarmed && state.activeMessageId === null) {
|
|
21435
|
+
state.activeMessageId = info.messageId;
|
|
21436
|
+
const m = getMessage(info.messageId);
|
|
21437
|
+
if (m) {
|
|
21438
|
+
const newMeta = { ...m.meta || {}, preWarmed: false };
|
|
21439
|
+
updateMessageBody(info.messageId, m.body, newMeta);
|
|
21440
|
+
roomBus.emit(roomId, {
|
|
21441
|
+
type: "message-updated",
|
|
21442
|
+
messageId: info.messageId,
|
|
21443
|
+
body: m.body,
|
|
21444
|
+
meta: newMeta
|
|
21445
|
+
});
|
|
21446
|
+
}
|
|
21447
|
+
emitQueueUpdate(roomId, state);
|
|
21448
|
+
}
|
|
21235
21449
|
}
|
|
21236
21450
|
// Chain trigger lives in pumpQueue's consume point, NOT here.
|
|
21237
21451
|
// Rationale: B's `message-final` fires while B is still occupying
|
|
@@ -21466,9 +21680,25 @@ async function pumpQueue(roomId) {
|
|
|
21466
21680
|
ac = state.preWarmed.abortController;
|
|
21467
21681
|
streamPromise = state.preWarmed.promise;
|
|
21468
21682
|
state.preWarmed = null;
|
|
21683
|
+
if (justConsumed.messageId) {
|
|
21684
|
+
state.activeMessageId = justConsumed.messageId;
|
|
21685
|
+
const m = getMessage(justConsumed.messageId);
|
|
21686
|
+
if (m) {
|
|
21687
|
+
const newMeta = { ...m.meta || {}, preWarmed: false };
|
|
21688
|
+
updateMessageBody(justConsumed.messageId, m.body, newMeta);
|
|
21689
|
+
roomBus.emit(roomId, {
|
|
21690
|
+
type: "message-updated",
|
|
21691
|
+
messageId: justConsumed.messageId,
|
|
21692
|
+
body: m.body,
|
|
21693
|
+
meta: newMeta
|
|
21694
|
+
});
|
|
21695
|
+
}
|
|
21696
|
+
emitQueueUpdate(roomId, state);
|
|
21697
|
+
}
|
|
21469
21698
|
rlog(roomId, "speaker-prewarm-consumed", {
|
|
21470
21699
|
agent: speaker.name,
|
|
21471
|
-
agentId: speaker.id
|
|
21700
|
+
agentId: speaker.id,
|
|
21701
|
+
messageId: justConsumed.messageId || "(pending)"
|
|
21472
21702
|
});
|
|
21473
21703
|
schedulePreWarm(roomId, justConsumed.messageId);
|
|
21474
21704
|
} else {
|
|
@@ -21493,6 +21723,8 @@ async function pumpQueue(roomId) {
|
|
|
21493
21723
|
state.inflight.delete(sentinel);
|
|
21494
21724
|
state.inflight.set(info.messageId, ac);
|
|
21495
21725
|
}
|
|
21726
|
+
state.activeMessageId = info.messageId;
|
|
21727
|
+
emitQueueUpdate(roomId, state);
|
|
21496
21728
|
},
|
|
21497
21729
|
onMessageFinal: (info) => {
|
|
21498
21730
|
schedulePreWarm(roomId, info.messageId);
|
|
@@ -21536,6 +21768,10 @@ async function pumpQueue(roomId) {
|
|
|
21536
21768
|
if (val === ac) keysToDel.push(key);
|
|
21537
21769
|
}
|
|
21538
21770
|
for (const key of keysToDel) state.inflight.delete(key);
|
|
21771
|
+
if (state.activeMessageId) {
|
|
21772
|
+
state.activeMessageId = null;
|
|
21773
|
+
emitQueueUpdate(roomId, state);
|
|
21774
|
+
}
|
|
21539
21775
|
}
|
|
21540
21776
|
if (state.queue[0] !== entry) {
|
|
21541
21777
|
continue;
|
|
@@ -21628,6 +21864,9 @@ async function pumpQueue(roomId) {
|
|
|
21628
21864
|
});
|
|
21629
21865
|
if (reachedCap) {
|
|
21630
21866
|
const room = getRoom(roomId);
|
|
21867
|
+
if (room && room.kind === "thread") {
|
|
21868
|
+
return;
|
|
21869
|
+
}
|
|
21631
21870
|
if (room && room.status === "live" && !room.awaitingContinue && !room.awaitingClarify && room.voteTrigger === "manual") {
|
|
21632
21871
|
const nextRound = nextUserRoundNum(roomId);
|
|
21633
21872
|
rlog(roomId, "manual-auto-continue", {
|
|
@@ -23927,17 +24166,44 @@ var REJECT_PHRASES = /* @__PURE__ */ new Set([
|
|
|
23927
24166
|
]);
|
|
23928
24167
|
async function generateRoomTitle(roomId) {
|
|
23929
24168
|
const room = getRoom(roomId);
|
|
23930
|
-
if (!room)
|
|
23931
|
-
|
|
24169
|
+
if (!room) {
|
|
24170
|
+
process.stderr.write(`[room-title] room=${roomId} skip=no-room
|
|
24171
|
+
`);
|
|
24172
|
+
return { kind: "skipped", reason: "no-room" };
|
|
24173
|
+
}
|
|
24174
|
+
if (!room.nameAuto) {
|
|
24175
|
+
process.stderr.write(`[room-title] room=${roomId} kind=${room.kind} skip=user-named
|
|
24176
|
+
`);
|
|
24177
|
+
return { kind: "skipped", reason: "user-named" };
|
|
24178
|
+
}
|
|
23932
24179
|
const subject = room.subject.trim();
|
|
23933
|
-
if (!subject)
|
|
24180
|
+
if (!subject) {
|
|
24181
|
+
process.stderr.write(`[room-title] room=${roomId} kind=${room.kind} skip=no-subject
|
|
24182
|
+
`);
|
|
24183
|
+
return { kind: "skipped", reason: "no-subject" };
|
|
24184
|
+
}
|
|
23934
24185
|
const fallbackName = room.subject.slice(0, 60);
|
|
23935
24186
|
if (room.name !== fallbackName) {
|
|
24187
|
+
process.stderr.write(
|
|
24188
|
+
`[room-title] room=${roomId} kind=${room.kind} skip=already-renamed name="${room.name.slice(0, 30)}" fallback="${fallbackName.slice(0, 30)}"
|
|
24189
|
+
`
|
|
24190
|
+
);
|
|
23936
24191
|
return { kind: "skipped", reason: "already-renamed", detail: room.name.slice(0, 60) };
|
|
23937
24192
|
}
|
|
23938
|
-
const
|
|
23939
|
-
if (!
|
|
23940
|
-
const
|
|
24193
|
+
const r = await distillTitle(subject, `room=${roomId} kind=${room.kind}`);
|
|
24194
|
+
if (!r.ok) return { kind: "skipped", reason: r.reason, detail: r.detail };
|
|
24195
|
+
const updated = setRoomNameFromAuto(roomId, r.phrase);
|
|
24196
|
+
if (!updated) return { kind: "skipped", reason: "race-after-rename" };
|
|
24197
|
+
roomBus.emit(roomId, {
|
|
24198
|
+
type: "config-event",
|
|
24199
|
+
kind: "settings-changed",
|
|
24200
|
+
payload: { changes: { name: { from: room.name, to: r.phrase } } },
|
|
24201
|
+
createdAt: Date.now()
|
|
24202
|
+
});
|
|
24203
|
+
return { kind: "ok", before: room.name, after: r.phrase };
|
|
24204
|
+
}
|
|
24205
|
+
function buildTitlePrompt(text) {
|
|
24206
|
+
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.
|
|
23941
24207
|
|
|
23942
24208
|
How to write a representative title:
|
|
23943
24209
|
1. Identify the CORE SUBJECT or TASK (the noun, the deliverable, the decision being made).
|
|
@@ -23972,47 +24238,95 @@ Input: I want to redesign our onboarding email sequence \u2014 currently 5 email
|
|
|
23972
24238
|
Output: Onboarding email redesign
|
|
23973
24239
|
|
|
23974
24240
|
--- User's opening question ---
|
|
23975
|
-
${
|
|
24241
|
+
${text}
|
|
23976
24242
|
|
|
23977
24243
|
--- Title ---
|
|
23978
24244
|
`;
|
|
24245
|
+
}
|
|
24246
|
+
async function distillTitle(text, ctx) {
|
|
24247
|
+
const modelV = utilityModelFor();
|
|
24248
|
+
if (!modelV) {
|
|
24249
|
+
process.stderr.write(`[room-title] ${ctx} skip=no-model
|
|
24250
|
+
`);
|
|
24251
|
+
return { ok: false, reason: "no-model" };
|
|
24252
|
+
}
|
|
24253
|
+
process.stderr.write(`[room-title] ${ctx} model=${modelV} input="${text.slice(0, 40)}\u2026" \xB7 calling LLM
|
|
24254
|
+
`);
|
|
23979
24255
|
let raw = "";
|
|
23980
24256
|
try {
|
|
23981
24257
|
raw = await callLLM({
|
|
23982
24258
|
modelV,
|
|
23983
24259
|
carrier: null,
|
|
23984
|
-
messages: [{ role: "user", content:
|
|
23985
|
-
// Low but not zero · 0.2
|
|
23986
|
-
//
|
|
23987
|
-
//
|
|
24260
|
+
messages: [{ role: "user", content: buildTitlePrompt(text) }],
|
|
24261
|
+
// Low but not zero · 0.2 kept locking onto a generic first-noun
|
|
24262
|
+
// pick; 0.4 lets the model trade off alternatives without
|
|
24263
|
+
// wandering into creative territory.
|
|
23988
24264
|
temperature: 0.4,
|
|
23989
|
-
// 40
|
|
23990
|
-
//
|
|
23991
|
-
// a small margin without inviting paragraphs.
|
|
24265
|
+
// 40 truncated mid-title for models that think briefly first;
|
|
24266
|
+
// 80 fits the title plus margin without inviting paragraphs.
|
|
23992
24267
|
maxTokens: 80
|
|
23993
24268
|
});
|
|
23994
24269
|
} catch (e) {
|
|
23995
24270
|
const detail = e instanceof Error ? e.message : String(e);
|
|
23996
|
-
process.stderr.write(`[room-title] LLM call failed
|
|
24271
|
+
process.stderr.write(`[room-title] ${ctx} LLM call failed: ${detail}
|
|
23997
24272
|
`);
|
|
23998
|
-
return {
|
|
24273
|
+
return { ok: false, reason: "llm-error", detail };
|
|
23999
24274
|
}
|
|
24000
24275
|
if (!raw.trim()) {
|
|
24001
|
-
|
|
24276
|
+
process.stderr.write(`[room-title] ${ctx} skip=empty-output model=${modelV}
|
|
24277
|
+
`);
|
|
24278
|
+
return { ok: false, reason: "empty-output", detail: `model=${modelV}` };
|
|
24002
24279
|
}
|
|
24003
24280
|
const phrase = sanitiseTitle(raw);
|
|
24004
24281
|
if (!phrase) {
|
|
24005
|
-
|
|
24282
|
+
process.stderr.write(`[room-title] ${ctx} skip=rejected-generic raw="${raw.trim().slice(0, 80)}"
|
|
24283
|
+
`);
|
|
24284
|
+
return { ok: false, reason: "rejected-generic", detail: raw.trim().slice(0, 80) };
|
|
24285
|
+
}
|
|
24286
|
+
process.stderr.write(`[room-title] ${ctx} llm_raw="${raw.trim().slice(0, 60)}" phrase="${phrase}"
|
|
24287
|
+
`);
|
|
24288
|
+
return { ok: true, phrase };
|
|
24289
|
+
}
|
|
24290
|
+
function threadSeedText(body) {
|
|
24291
|
+
return body.replace(/^\s*[—–-]\s*@.*$/gm, "").replace(/^\s*>\s?/gm, "").replace(/\n{2,}/g, "\n").trim();
|
|
24292
|
+
}
|
|
24293
|
+
async function generateThreadTitle(threadId) {
|
|
24294
|
+
const room = getRoom(threadId);
|
|
24295
|
+
if (!room) {
|
|
24296
|
+
process.stderr.write(`[thread-title] thread=${threadId} skip=no-room
|
|
24297
|
+
`);
|
|
24298
|
+
return { kind: "skipped", reason: "no-room" };
|
|
24299
|
+
}
|
|
24300
|
+
if (room.kind !== "thread") {
|
|
24301
|
+
return { kind: "skipped", reason: "not-thread" };
|
|
24006
24302
|
}
|
|
24007
|
-
const
|
|
24303
|
+
const firstUser = listMessages(threadId).find((m) => m.authorKind === "user");
|
|
24304
|
+
if (!firstUser || !firstUser.body.trim()) {
|
|
24305
|
+
return { kind: "skipped", reason: "no-message" };
|
|
24306
|
+
}
|
|
24307
|
+
const seed = threadSeedText(firstUser.body);
|
|
24308
|
+
if (!seed) {
|
|
24309
|
+
return { kind: "skipped", reason: "no-subject" };
|
|
24310
|
+
}
|
|
24311
|
+
const name = (room.name || "").trim();
|
|
24312
|
+
const isPlaceholder = /^thread:/.test(name);
|
|
24313
|
+
const isRawTruncation = name === room.subject.slice(0, 60) || name === firstUser.body.slice(0, 60);
|
|
24314
|
+
if (!isPlaceholder && !isRawTruncation) {
|
|
24315
|
+
return { kind: "skipped", reason: "already-renamed", detail: name.slice(0, 60) };
|
|
24316
|
+
}
|
|
24317
|
+
const r = await distillTitle(seed, `thread=${threadId}`);
|
|
24318
|
+
if (!r.ok) return { kind: "skipped", reason: r.reason, detail: r.detail };
|
|
24319
|
+
const updated = forceRoomAutoName(threadId, r.phrase);
|
|
24008
24320
|
if (!updated) return { kind: "skipped", reason: "race-after-rename" };
|
|
24009
|
-
roomBus.emit(
|
|
24321
|
+
roomBus.emit(threadId, {
|
|
24010
24322
|
type: "config-event",
|
|
24011
24323
|
kind: "settings-changed",
|
|
24012
|
-
payload: { changes: { name: { from:
|
|
24324
|
+
payload: { changes: { name: { from: name, to: r.phrase } } },
|
|
24013
24325
|
createdAt: Date.now()
|
|
24014
24326
|
});
|
|
24015
|
-
|
|
24327
|
+
process.stderr.write(`[thread-title] OK thread=${threadId} "${name.slice(0, 30)}" \u2192 "${r.phrase}"
|
|
24328
|
+
`);
|
|
24329
|
+
return { kind: "ok", before: name, after: r.phrase };
|
|
24016
24330
|
}
|
|
24017
24331
|
function sanitiseTitle(raw) {
|
|
24018
24332
|
let s = raw.trim();
|
|
@@ -24407,6 +24721,16 @@ function roomsRouter() {
|
|
|
24407
24721
|
return c.json({ deferred: true });
|
|
24408
24722
|
}
|
|
24409
24723
|
const roundNum = nextUserRoundNum(id);
|
|
24724
|
+
let triggerThreadTitle = false;
|
|
24725
|
+
if (room.kind === "thread") {
|
|
24726
|
+
const priorMsgs = listMessages(id);
|
|
24727
|
+
const priorUser = priorMsgs.some((m) => m.authorKind === "user");
|
|
24728
|
+
if (!priorUser) {
|
|
24729
|
+
setRoomSubject(id, text);
|
|
24730
|
+
setRoomNameFromAuto(id, text.slice(0, 60));
|
|
24731
|
+
triggerThreadTitle = true;
|
|
24732
|
+
}
|
|
24733
|
+
}
|
|
24410
24734
|
const msg = insertMessage({
|
|
24411
24735
|
roomId: id,
|
|
24412
24736
|
authorKind: "user",
|
|
@@ -24415,6 +24739,32 @@ function roomsRouter() {
|
|
|
24415
24739
|
meta: mentions.length ? { mentions } : {},
|
|
24416
24740
|
roundNum
|
|
24417
24741
|
});
|
|
24742
|
+
if (triggerThreadTitle) {
|
|
24743
|
+
const before = getRoom(id);
|
|
24744
|
+
process.stderr.write(
|
|
24745
|
+
`[thread-title] firing for thread=${id} subject="${(before?.subject ?? "").slice(0, 40)}" name="${before?.name ?? ""}" nameAuto=${before?.nameAuto}
|
|
24746
|
+
`
|
|
24747
|
+
);
|
|
24748
|
+
generateThreadTitle(id).then((result) => {
|
|
24749
|
+
if (result.kind === "ok") {
|
|
24750
|
+
process.stderr.write(
|
|
24751
|
+
`[thread-title] OK thread=${id} "${result.before.slice(0, 40)}" \u2192 "${result.after}"
|
|
24752
|
+
`
|
|
24753
|
+
);
|
|
24754
|
+
} else {
|
|
24755
|
+
const tail = result.detail ? ` detail="${result.detail.slice(0, 100)}"` : "";
|
|
24756
|
+
process.stderr.write(
|
|
24757
|
+
`[thread-title] SKIP thread=${id} reason=${result.reason}${tail}
|
|
24758
|
+
`
|
|
24759
|
+
);
|
|
24760
|
+
}
|
|
24761
|
+
}).catch((e) => {
|
|
24762
|
+
process.stderr.write(
|
|
24763
|
+
`[thread-title] THROW thread=${id} ${e instanceof Error ? e.message : String(e)}
|
|
24764
|
+
`
|
|
24765
|
+
);
|
|
24766
|
+
});
|
|
24767
|
+
}
|
|
24418
24768
|
roomBus.emit(id, {
|
|
24419
24769
|
type: "message-appended",
|
|
24420
24770
|
messageId: msg.id,
|
|
@@ -24452,7 +24802,7 @@ function roomsRouter() {
|
|
|
24452
24802
|
return c.json(msg);
|
|
24453
24803
|
}
|
|
24454
24804
|
const chair = getChairAgent();
|
|
24455
|
-
const chairMentioned = !!chair && (mentions.includes(chair.id) || /(?:^|\s)@chair\b/i.test(text));
|
|
24805
|
+
const chairMentioned = !!chair && room.kind !== "thread" && (mentions.includes(chair.id) || /(?:^|\s)@chair\b/i.test(text));
|
|
24456
24806
|
if (chairMentioned) {
|
|
24457
24807
|
void chairInterrupt(id).catch((e) => {
|
|
24458
24808
|
process.stderr.write(
|
|
@@ -24471,6 +24821,62 @@ function roomsRouter() {
|
|
|
24471
24821
|
abortRoom(id);
|
|
24472
24822
|
return c.json({ ok: true });
|
|
24473
24823
|
});
|
|
24824
|
+
r.post("/:id/threads", async (c) => {
|
|
24825
|
+
const parentId = c.req.param("id");
|
|
24826
|
+
const parent = getRoom(parentId);
|
|
24827
|
+
if (!parent) return c.json({ error: "parent room not found" }, 404);
|
|
24828
|
+
if (parent.kind !== "main") {
|
|
24829
|
+
return c.json({ error: "threads can only spawn from main rooms" }, 400);
|
|
24830
|
+
}
|
|
24831
|
+
let body;
|
|
24832
|
+
try {
|
|
24833
|
+
body = await c.req.json();
|
|
24834
|
+
} catch {
|
|
24835
|
+
return c.json({ error: "invalid JSON body" }, 400);
|
|
24836
|
+
}
|
|
24837
|
+
const b = body ?? {};
|
|
24838
|
+
const directorId = typeof b.directorId === "string" ? b.directorId.trim() : "";
|
|
24839
|
+
if (!directorId) return c.json({ error: "directorId is required" }, 400);
|
|
24840
|
+
const agent = getAgent(directorId);
|
|
24841
|
+
if (!agent) return c.json({ error: "director not found" }, 404);
|
|
24842
|
+
if (agent.roleKind === "moderator") {
|
|
24843
|
+
return c.json({ error: "cannot open a thread with the chair" }, 400);
|
|
24844
|
+
}
|
|
24845
|
+
try {
|
|
24846
|
+
const existing = listThreadsForRoom(parentId, { directorId });
|
|
24847
|
+
if (existing.length > 0) {
|
|
24848
|
+
const newest = existing[0];
|
|
24849
|
+
const members = listRoomMembers(newest.id);
|
|
24850
|
+
return c.json({ room: newest, members });
|
|
24851
|
+
}
|
|
24852
|
+
const result = createThread(parentId, directorId);
|
|
24853
|
+
return c.json(result);
|
|
24854
|
+
} catch (e) {
|
|
24855
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
24856
|
+
return c.json({ error: msg }, 400);
|
|
24857
|
+
}
|
|
24858
|
+
});
|
|
24859
|
+
r.get("/:id/threads", (c) => {
|
|
24860
|
+
const parentId = c.req.param("id");
|
|
24861
|
+
if (!getRoom(parentId)) return c.json({ error: "not found" }, 404);
|
|
24862
|
+
const directorId = c.req.query("directorId");
|
|
24863
|
+
const threads = listThreadsForRoom(
|
|
24864
|
+
parentId,
|
|
24865
|
+
directorId ? { directorId } : {}
|
|
24866
|
+
);
|
|
24867
|
+
const enriched = threads.map((t) => {
|
|
24868
|
+
const msgs = listMessages(t.id);
|
|
24869
|
+
const messageCount = msgs.filter(
|
|
24870
|
+
(m) => !(m.meta?.streaming === true)
|
|
24871
|
+
).length;
|
|
24872
|
+
return { ...t, messageCount };
|
|
24873
|
+
});
|
|
24874
|
+
for (const t of enriched) {
|
|
24875
|
+
if (t.messageCount > 0) void generateThreadTitle(t.id).catch(() => {
|
|
24876
|
+
});
|
|
24877
|
+
}
|
|
24878
|
+
return c.json({ threads: enriched });
|
|
24879
|
+
});
|
|
24474
24880
|
r.post("/:id/messages/:messageId/voice-done", (c) => {
|
|
24475
24881
|
const id = c.req.param("id");
|
|
24476
24882
|
const messageId = c.req.param("messageId");
|
|
@@ -25278,8 +25684,650 @@ function usageRouter() {
|
|
|
25278
25684
|
return r;
|
|
25279
25685
|
}
|
|
25280
25686
|
|
|
25281
|
-
// src/routes/voice-
|
|
25687
|
+
// src/routes/voice-clone.ts
|
|
25282
25688
|
import { Hono as Hono13 } from "hono";
|
|
25689
|
+
import { streamSSE as streamSSE3 } from "hono/streaming";
|
|
25690
|
+
import { randomBytes as randomBytes9 } from "crypto";
|
|
25691
|
+
import { mkdirSync as mkdirSync2, writeFileSync, statSync as statSync2, rmSync, existsSync as existsSync2 } from "fs";
|
|
25692
|
+
import { tmpdir } from "os";
|
|
25693
|
+
import { join as join4 } from "path";
|
|
25694
|
+
|
|
25695
|
+
// src/storage/clone-jobs.ts
|
|
25696
|
+
init_db();
|
|
25697
|
+
import { randomBytes as randomBytes7 } from "crypto";
|
|
25698
|
+
function rowToJob(r) {
|
|
25699
|
+
return {
|
|
25700
|
+
id: r.id,
|
|
25701
|
+
agentId: r.agent_id,
|
|
25702
|
+
provider: r.provider,
|
|
25703
|
+
sourceKind: r.source_kind,
|
|
25704
|
+
sourceRef: r.source_ref,
|
|
25705
|
+
label: r.label,
|
|
25706
|
+
status: r.status,
|
|
25707
|
+
currentStage: r.current_stage,
|
|
25708
|
+
pct: r.pct,
|
|
25709
|
+
voiceId: r.voice_id,
|
|
25710
|
+
errorCode: r.error_code,
|
|
25711
|
+
errorMessage: r.error_message,
|
|
25712
|
+
createdAt: r.created_at,
|
|
25713
|
+
updatedAt: r.updated_at
|
|
25714
|
+
};
|
|
25715
|
+
}
|
|
25716
|
+
function createCloneJob(input) {
|
|
25717
|
+
const id = randomBytes7(8).toString("hex");
|
|
25718
|
+
const now = Date.now();
|
|
25719
|
+
getDb().prepare(
|
|
25720
|
+
`INSERT INTO clone_jobs (id, agent_id, provider, source_kind, source_ref, label,
|
|
25721
|
+
status, current_stage, pct, created_at, updated_at)
|
|
25722
|
+
VALUES (?, ?, ?, ?, ?, ?, 'queued', 'fetch', 0, ?, ?)`
|
|
25723
|
+
).run(id, input.agentId, input.provider, input.sourceKind, input.sourceRef, input.label ?? null, now, now);
|
|
25724
|
+
const row = getDb().prepare(`SELECT * FROM clone_jobs WHERE id = ?`).get(id);
|
|
25725
|
+
return rowToJob(row);
|
|
25726
|
+
}
|
|
25727
|
+
function getCloneJob(id) {
|
|
25728
|
+
const row = getDb().prepare(`SELECT * FROM clone_jobs WHERE id = ?`).get(id);
|
|
25729
|
+
return row ? rowToJob(row) : null;
|
|
25730
|
+
}
|
|
25731
|
+
function findActiveJobForAgent(agentId) {
|
|
25732
|
+
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);
|
|
25733
|
+
return row ? rowToJob(row) : null;
|
|
25734
|
+
}
|
|
25735
|
+
function findAnyActiveJob() {
|
|
25736
|
+
const row = getDb().prepare(`SELECT * FROM clone_jobs WHERE status IN ('queued', 'running') ORDER BY created_at DESC LIMIT 1`).get();
|
|
25737
|
+
return row ? rowToJob(row) : null;
|
|
25738
|
+
}
|
|
25739
|
+
function updateCloneJobProgress(id, patch) {
|
|
25740
|
+
const cur = getCloneJob(id);
|
|
25741
|
+
if (!cur) return null;
|
|
25742
|
+
const next = {
|
|
25743
|
+
status: patch.status ?? cur.status,
|
|
25744
|
+
currentStage: patch.currentStage ?? cur.currentStage,
|
|
25745
|
+
pct: patch.pct ?? cur.pct,
|
|
25746
|
+
voiceId: patch.voiceId !== void 0 ? patch.voiceId : cur.voiceId,
|
|
25747
|
+
errorCode: patch.errorCode !== void 0 ? patch.errorCode : cur.errorCode,
|
|
25748
|
+
errorMessage: patch.errorMessage !== void 0 ? patch.errorMessage : cur.errorMessage
|
|
25749
|
+
};
|
|
25750
|
+
getDb().prepare(
|
|
25751
|
+
`UPDATE clone_jobs SET status=?, current_stage=?, pct=?, voice_id=?, error_code=?, error_message=?, updated_at=?
|
|
25752
|
+
WHERE id=?`
|
|
25753
|
+
).run(
|
|
25754
|
+
next.status,
|
|
25755
|
+
next.currentStage,
|
|
25756
|
+
next.pct,
|
|
25757
|
+
next.voiceId,
|
|
25758
|
+
next.errorCode,
|
|
25759
|
+
next.errorMessage,
|
|
25760
|
+
Date.now(),
|
|
25761
|
+
id
|
|
25762
|
+
);
|
|
25763
|
+
return getCloneJob(id);
|
|
25764
|
+
}
|
|
25765
|
+
function recoverStuckCloneJobs() {
|
|
25766
|
+
const r = getDb().prepare(
|
|
25767
|
+
`UPDATE clone_jobs
|
|
25768
|
+
SET status = 'failed',
|
|
25769
|
+
error_code = COALESCE(error_code, 'interrupted'),
|
|
25770
|
+
error_message = COALESCE(error_message, 'Process restarted while clone was in progress.'),
|
|
25771
|
+
updated_at = ?
|
|
25772
|
+
WHERE status IN ('queued', 'running')`
|
|
25773
|
+
).run(Date.now());
|
|
25774
|
+
return r.changes;
|
|
25775
|
+
}
|
|
25776
|
+
|
|
25777
|
+
// src/voice/clone.ts
|
|
25778
|
+
import { readFileSync, statSync } from "fs";
|
|
25779
|
+
import { basename } from "path";
|
|
25780
|
+
import { randomBytes as randomBytes8 } from "crypto";
|
|
25781
|
+
var CloneError = class extends Error {
|
|
25782
|
+
code;
|
|
25783
|
+
detail;
|
|
25784
|
+
constructor(code, message, detail = "") {
|
|
25785
|
+
super(message);
|
|
25786
|
+
this.name = "CloneError";
|
|
25787
|
+
this.code = code;
|
|
25788
|
+
this.detail = detail;
|
|
25789
|
+
}
|
|
25790
|
+
};
|
|
25791
|
+
var MAX_AUDIO_BYTES = 20 * 1024 * 1024;
|
|
25792
|
+
var MIN_AUDIO_BYTES = 32 * 1024;
|
|
25793
|
+
function extractMiniMaxGroupId(jwt) {
|
|
25794
|
+
const parts = jwt.split(".");
|
|
25795
|
+
if (parts.length !== 3) return null;
|
|
25796
|
+
try {
|
|
25797
|
+
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
25798
|
+
const candidates = ["GroupID", "group_id", "groupId", "g"];
|
|
25799
|
+
for (const k of candidates) {
|
|
25800
|
+
const v = payload[k];
|
|
25801
|
+
if (typeof v === "string" && v.trim()) return v.trim();
|
|
25802
|
+
}
|
|
25803
|
+
} catch {
|
|
25804
|
+
}
|
|
25805
|
+
return null;
|
|
25806
|
+
}
|
|
25807
|
+
async function cloneFromAudio(input) {
|
|
25808
|
+
validateAudioFile(input.audioPath);
|
|
25809
|
+
if (input.provider === "minimax") return cloneMiniMax(input);
|
|
25810
|
+
if (input.provider === "elevenlabs") return cloneElevenLabs(input);
|
|
25811
|
+
throw new CloneError("provider_unknown", `Unsupported provider ${String(input.provider)}`);
|
|
25812
|
+
}
|
|
25813
|
+
function validateAudioFile(path) {
|
|
25814
|
+
let size;
|
|
25815
|
+
try {
|
|
25816
|
+
size = statSync(path).size;
|
|
25817
|
+
} catch (e) {
|
|
25818
|
+
throw new CloneError("audio_unreadable", "Could not read audio file", String(e));
|
|
25819
|
+
}
|
|
25820
|
+
if (size < MIN_AUDIO_BYTES) throw new CloneError("audio_too_short", "Audio file is too small to clone from");
|
|
25821
|
+
if (size > MAX_AUDIO_BYTES) throw new CloneError("audio_too_large", "Audio file exceeds 20MB");
|
|
25822
|
+
}
|
|
25823
|
+
async function cloneMiniMax(input) {
|
|
25824
|
+
const groupId = input.miniMaxGroupId && input.miniMaxGroupId.trim() || extractMiniMaxGroupId(input.apiKey);
|
|
25825
|
+
if (!groupId) {
|
|
25826
|
+
throw new CloneError(
|
|
25827
|
+
"missing_group_id",
|
|
25828
|
+
'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.'
|
|
25829
|
+
);
|
|
25830
|
+
}
|
|
25831
|
+
const baseUrl = input.miniMaxBaseUrl || "https://api.minimaxi.com";
|
|
25832
|
+
input.onProgress?.(0, "upload");
|
|
25833
|
+
const fileBuf = readFileSync(input.audioPath);
|
|
25834
|
+
const fileName = basename(input.audioPath);
|
|
25835
|
+
const upRes = await streamMultipartUpload({
|
|
25836
|
+
url: `${baseUrl}/v1/files/upload?GroupId=${encodeURIComponent(groupId)}`,
|
|
25837
|
+
headers: { "authorization": `Bearer ${input.apiKey}` },
|
|
25838
|
+
fields: { purpose: "voice_clone" },
|
|
25839
|
+
files: [{ fieldName: "file", bytes: fileBuf, mime: mimeForName(fileName), fileName }],
|
|
25840
|
+
onProgress: (pct) => input.onProgress?.(pct, "upload"),
|
|
25841
|
+
signal: input.signal
|
|
25842
|
+
});
|
|
25843
|
+
if (!upRes.ok) throw await translateMinimaxError(upRes, "upload");
|
|
25844
|
+
const upJson = await upRes.json();
|
|
25845
|
+
const fileId = upJson.file?.file_id;
|
|
25846
|
+
if (!fileId) {
|
|
25847
|
+
const msg = upJson.base_resp?.status_msg || "unknown error";
|
|
25848
|
+
throw new CloneError("provider_unknown", `MiniMax upload returned no file_id: ${msg}`);
|
|
25849
|
+
}
|
|
25850
|
+
input.onProgress?.(100, "upload");
|
|
25851
|
+
input.onProgress?.(0, "clone");
|
|
25852
|
+
const voiceId = buildMiniMaxVoiceId(input.agentId, input.label || null);
|
|
25853
|
+
const cloneRes = await fetch(`${baseUrl}/v1/voice_clone?GroupId=${encodeURIComponent(groupId)}`, {
|
|
25854
|
+
method: "POST",
|
|
25855
|
+
headers: {
|
|
25856
|
+
"authorization": `Bearer ${input.apiKey}`,
|
|
25857
|
+
"content-type": "application/json"
|
|
25858
|
+
},
|
|
25859
|
+
body: JSON.stringify({
|
|
25860
|
+
file_id: fileId,
|
|
25861
|
+
voice_id: voiceId,
|
|
25862
|
+
need_noise_reduction: true,
|
|
25863
|
+
need_volume_normalization: true
|
|
25864
|
+
}),
|
|
25865
|
+
signal: input.signal
|
|
25866
|
+
});
|
|
25867
|
+
if (!cloneRes.ok) throw await translateMinimaxError(cloneRes, "clone");
|
|
25868
|
+
const cloneJson = await cloneRes.json();
|
|
25869
|
+
const status = cloneJson.base_resp?.status_code ?? 0;
|
|
25870
|
+
if (status !== 0) {
|
|
25871
|
+
const msg = cloneJson.base_resp?.status_msg || "unknown error";
|
|
25872
|
+
if (status === 1008 || /insufficient/i.test(msg)) {
|
|
25873
|
+
throw new CloneError("provider_quota", "MiniMax balance is insufficient for voice cloning.", msg);
|
|
25874
|
+
}
|
|
25875
|
+
if (/voice[_ ]id/i.test(msg)) {
|
|
25876
|
+
throw new CloneError("provider_invalid_voice_id", `MiniMax rejected the voice_id: ${msg}`);
|
|
25877
|
+
}
|
|
25878
|
+
throw new CloneError("provider_unknown", `MiniMax voice_clone failed (${status}): ${msg}`);
|
|
25879
|
+
}
|
|
25880
|
+
input.onProgress?.(100, "clone");
|
|
25881
|
+
return { voiceId, label: input.label?.trim() || `Cloned \xB7 ${voiceId}` };
|
|
25882
|
+
}
|
|
25883
|
+
async function translateMinimaxError(res, where) {
|
|
25884
|
+
const text = await res.text().catch(() => "");
|
|
25885
|
+
if (res.status === 401 || res.status === 403) {
|
|
25886
|
+
return new CloneError("provider_auth", "MiniMax rejected the API key. Re-check it in voice settings.", text);
|
|
25887
|
+
}
|
|
25888
|
+
if (res.status === 402 || /insufficient/i.test(text)) {
|
|
25889
|
+
return new CloneError("provider_quota", "MiniMax balance is insufficient for voice cloning.", text);
|
|
25890
|
+
}
|
|
25891
|
+
return new CloneError("provider_unknown", `MiniMax ${where} returned HTTP ${res.status}`, text);
|
|
25892
|
+
}
|
|
25893
|
+
function buildMiniMaxVoiceId(agentId, label) {
|
|
25894
|
+
const ts = Date.now().toString(36);
|
|
25895
|
+
const sanitizedLabel = (label || "").replace(/[^A-Za-z0-9_-]/g, "").slice(0, 16);
|
|
25896
|
+
if (sanitizedLabel && sanitizedLabel.length >= 2) {
|
|
25897
|
+
return `${sanitizedLabel}_${ts}`;
|
|
25898
|
+
}
|
|
25899
|
+
const safeAgent = agentId.replace(/[^A-Za-z0-9]/g, "").slice(0, 8) || "director";
|
|
25900
|
+
return `pb_${safeAgent}_${ts}`;
|
|
25901
|
+
}
|
|
25902
|
+
async function cloneElevenLabs(input) {
|
|
25903
|
+
input.onProgress?.(0, "upload");
|
|
25904
|
+
const fileBuf = readFileSync(input.audioPath);
|
|
25905
|
+
const fileName = basename(input.audioPath);
|
|
25906
|
+
const label = input.label?.trim() || `Cloned \xB7 ${input.agentId.slice(0, 8)}`;
|
|
25907
|
+
const res = await streamMultipartUpload({
|
|
25908
|
+
url: `https://api.elevenlabs.io/v1/voices/add`,
|
|
25909
|
+
headers: { "xi-api-key": input.apiKey },
|
|
25910
|
+
fields: { name: label },
|
|
25911
|
+
files: [{ fieldName: "files", bytes: fileBuf, mime: mimeForName(fileName), fileName }],
|
|
25912
|
+
onProgress: (pct) => input.onProgress?.(pct, "upload"),
|
|
25913
|
+
signal: input.signal
|
|
25914
|
+
});
|
|
25915
|
+
input.onProgress?.(100, "upload");
|
|
25916
|
+
input.onProgress?.(0, "clone");
|
|
25917
|
+
if (!res.ok) {
|
|
25918
|
+
const text = await res.text().catch(() => "");
|
|
25919
|
+
if (res.status === 401) throw new CloneError("provider_auth", "ElevenLabs rejected the API key.", text);
|
|
25920
|
+
if (res.status === 402 || /paid_plan_required|quota_exceeded|insufficient/i.test(text)) {
|
|
25921
|
+
throw new CloneError("provider_quota", "ElevenLabs subscription doesn't allow voice cloning, or you're out of credits.", text);
|
|
25922
|
+
}
|
|
25923
|
+
throw new CloneError("provider_unknown", `ElevenLabs voices/add returned HTTP ${res.status}`, text);
|
|
25924
|
+
}
|
|
25925
|
+
const json = await res.json();
|
|
25926
|
+
const voiceId = json.voice_id;
|
|
25927
|
+
if (!voiceId) throw new CloneError("provider_unknown", "ElevenLabs returned no voice_id");
|
|
25928
|
+
input.onProgress?.(100, "clone");
|
|
25929
|
+
return { voiceId, label };
|
|
25930
|
+
}
|
|
25931
|
+
async function streamMultipartUpload(opts) {
|
|
25932
|
+
const boundary = `----pb-vc-${randomBytes8(8).toString("hex")}`;
|
|
25933
|
+
const CRLF = "\r\n";
|
|
25934
|
+
const enc = (s) => Buffer.from(s, "utf8");
|
|
25935
|
+
const partsBeforeFiles = [];
|
|
25936
|
+
for (const [k, v] of Object.entries(opts.fields)) {
|
|
25937
|
+
partsBeforeFiles.push(enc(`--${boundary}${CRLF}`));
|
|
25938
|
+
partsBeforeFiles.push(enc(`Content-Disposition: form-data; name="${k}"${CRLF}${CRLF}`));
|
|
25939
|
+
partsBeforeFiles.push(enc(`${v}${CRLF}`));
|
|
25940
|
+
}
|
|
25941
|
+
const filePreludes = opts.files.map((f) => enc(
|
|
25942
|
+
`--${boundary}${CRLF}Content-Disposition: form-data; name="${f.fieldName}"; filename="${f.fileName}"${CRLF}Content-Type: ${f.mime}${CRLF}${CRLF}`
|
|
25943
|
+
));
|
|
25944
|
+
const fileEndings = opts.files.map(() => enc(CRLF));
|
|
25945
|
+
const closing = enc(`--${boundary}--${CRLF}`);
|
|
25946
|
+
let total = 0;
|
|
25947
|
+
for (const b of partsBeforeFiles) total += b.length;
|
|
25948
|
+
for (let i = 0; i < opts.files.length; i++) {
|
|
25949
|
+
total += filePreludes[i].length + opts.files[i].bytes.length + fileEndings[i].length;
|
|
25950
|
+
}
|
|
25951
|
+
total += closing.length;
|
|
25952
|
+
const CHUNK_SIZE = 64 * 1024;
|
|
25953
|
+
let step = { kind: "fixed", idx: 0, list: partsBeforeFiles };
|
|
25954
|
+
let sent = 0;
|
|
25955
|
+
const stream = new ReadableStream({
|
|
25956
|
+
pull(controller) {
|
|
25957
|
+
for (; ; ) {
|
|
25958
|
+
if (step.kind === "done") {
|
|
25959
|
+
controller.close();
|
|
25960
|
+
return;
|
|
25961
|
+
}
|
|
25962
|
+
if (step.kind === "fixed") {
|
|
25963
|
+
if (step.idx >= step.list.length) {
|
|
25964
|
+
if (opts.files.length === 0) step = { kind: "closing" };
|
|
25965
|
+
else {
|
|
25966
|
+
controller.enqueue(filePreludes[0]);
|
|
25967
|
+
sent += filePreludes[0].length;
|
|
25968
|
+
opts.onProgress?.(Math.min(99, sent / total * 100));
|
|
25969
|
+
step = { kind: "fileBody", fileIdx: 0, off: 0 };
|
|
25970
|
+
return;
|
|
25971
|
+
}
|
|
25972
|
+
continue;
|
|
25973
|
+
}
|
|
25974
|
+
const chunk = step.list[step.idx++];
|
|
25975
|
+
controller.enqueue(chunk);
|
|
25976
|
+
sent += chunk.length;
|
|
25977
|
+
opts.onProgress?.(Math.min(99, sent / total * 100));
|
|
25978
|
+
return;
|
|
25979
|
+
}
|
|
25980
|
+
if (step.kind === "fileBody") {
|
|
25981
|
+
const file = opts.files[step.fileIdx];
|
|
25982
|
+
if (step.off >= file.bytes.length) {
|
|
25983
|
+
const ending = fileEndings[step.fileIdx];
|
|
25984
|
+
controller.enqueue(ending);
|
|
25985
|
+
sent += ending.length;
|
|
25986
|
+
opts.onProgress?.(Math.min(99, sent / total * 100));
|
|
25987
|
+
const nextIdx = step.fileIdx + 1;
|
|
25988
|
+
if (nextIdx >= opts.files.length) {
|
|
25989
|
+
step = { kind: "closing" };
|
|
25990
|
+
} else {
|
|
25991
|
+
controller.enqueue(filePreludes[nextIdx]);
|
|
25992
|
+
sent += filePreludes[nextIdx].length;
|
|
25993
|
+
opts.onProgress?.(Math.min(99, sent / total * 100));
|
|
25994
|
+
step = { kind: "fileBody", fileIdx: nextIdx, off: 0 };
|
|
25995
|
+
}
|
|
25996
|
+
return;
|
|
25997
|
+
}
|
|
25998
|
+
const slice = file.bytes.subarray(step.off, step.off + CHUNK_SIZE);
|
|
25999
|
+
controller.enqueue(slice);
|
|
26000
|
+
step.off += slice.length;
|
|
26001
|
+
sent += slice.length;
|
|
26002
|
+
opts.onProgress?.(Math.min(99, sent / total * 100));
|
|
26003
|
+
return;
|
|
26004
|
+
}
|
|
26005
|
+
if (step.kind === "closing") {
|
|
26006
|
+
controller.enqueue(closing);
|
|
26007
|
+
sent += closing.length;
|
|
26008
|
+
opts.onProgress?.(100);
|
|
26009
|
+
step = { kind: "done" };
|
|
26010
|
+
return;
|
|
26011
|
+
}
|
|
26012
|
+
}
|
|
26013
|
+
},
|
|
26014
|
+
cancel() {
|
|
26015
|
+
step = { kind: "done" };
|
|
26016
|
+
}
|
|
26017
|
+
});
|
|
26018
|
+
const fetchInit = {
|
|
26019
|
+
method: "POST",
|
|
26020
|
+
headers: {
|
|
26021
|
+
...opts.headers,
|
|
26022
|
+
"content-type": `multipart/form-data; boundary=${boundary}`,
|
|
26023
|
+
"content-length": String(total)
|
|
26024
|
+
},
|
|
26025
|
+
body: stream,
|
|
26026
|
+
duplex: "half",
|
|
26027
|
+
signal: opts.signal
|
|
26028
|
+
};
|
|
26029
|
+
return await fetch(opts.url, fetchInit);
|
|
26030
|
+
}
|
|
26031
|
+
function mimeForName(name) {
|
|
26032
|
+
const lower = name.toLowerCase();
|
|
26033
|
+
if (lower.endsWith(".mp3")) return "audio/mpeg";
|
|
26034
|
+
if (lower.endsWith(".m4a")) return "audio/mp4";
|
|
26035
|
+
if (lower.endsWith(".wav")) return "audio/wav";
|
|
26036
|
+
if (lower.endsWith(".webm")) return "audio/webm";
|
|
26037
|
+
if (lower.endsWith(".ogg")) return "audio/ogg";
|
|
26038
|
+
return "application/octet-stream";
|
|
26039
|
+
}
|
|
26040
|
+
|
|
26041
|
+
// src/routes/voice-clone.ts
|
|
26042
|
+
var listeners = /* @__PURE__ */ new Map();
|
|
26043
|
+
function emit(ev) {
|
|
26044
|
+
const set = listeners.get(ev.jobId);
|
|
26045
|
+
if (!set) return;
|
|
26046
|
+
for (const fn of set) {
|
|
26047
|
+
try {
|
|
26048
|
+
fn(ev);
|
|
26049
|
+
} catch {
|
|
26050
|
+
}
|
|
26051
|
+
}
|
|
26052
|
+
}
|
|
26053
|
+
function subscribe(jobId, fn) {
|
|
26054
|
+
let set = listeners.get(jobId);
|
|
26055
|
+
if (!set) {
|
|
26056
|
+
set = /* @__PURE__ */ new Set();
|
|
26057
|
+
listeners.set(jobId, set);
|
|
26058
|
+
}
|
|
26059
|
+
set.add(fn);
|
|
26060
|
+
return () => {
|
|
26061
|
+
set?.delete(fn);
|
|
26062
|
+
if (set?.size === 0) listeners.delete(jobId);
|
|
26063
|
+
};
|
|
26064
|
+
}
|
|
26065
|
+
var aborters = /* @__PURE__ */ new Map();
|
|
26066
|
+
var workerExtras = /* @__PURE__ */ new Map();
|
|
26067
|
+
function overallPct(stage, innerPct) {
|
|
26068
|
+
const stageIdx = stage === "fetch" ? 0 : stage === "upload" ? 1 : 2;
|
|
26069
|
+
return Math.round(stageIdx * (100 / 3) + innerPct / 3);
|
|
26070
|
+
}
|
|
26071
|
+
function pushProgress(jobId, stage, innerPct, message) {
|
|
26072
|
+
const pct = overallPct(stage, innerPct);
|
|
26073
|
+
updateCloneJobProgress(jobId, { status: "running", currentStage: stage, pct });
|
|
26074
|
+
emit({ jobId, stage, pct, status: "running", message, ts: Date.now() });
|
|
26075
|
+
}
|
|
26076
|
+
async function runWorker(job) {
|
|
26077
|
+
const aborter = new AbortController();
|
|
26078
|
+
aborters.set(job.id, aborter);
|
|
26079
|
+
try {
|
|
26080
|
+
const apiKey = getActiveVoiceKeyPlaintext();
|
|
26081
|
+
if (!apiKey) {
|
|
26082
|
+
throw new CloneError("provider_auth", "No active voice credential. Configure one in voice settings first.");
|
|
26083
|
+
}
|
|
26084
|
+
const audioPath = job.sourceRef;
|
|
26085
|
+
pushProgress(job.id, "fetch", 100, "Using uploaded audio");
|
|
26086
|
+
const extras = workerExtras.get(job.id) || {};
|
|
26087
|
+
const { voiceId, label } = await cloneFromAudio({
|
|
26088
|
+
provider: job.provider,
|
|
26089
|
+
apiKey,
|
|
26090
|
+
audioPath,
|
|
26091
|
+
agentId: job.agentId,
|
|
26092
|
+
label: job.label,
|
|
26093
|
+
miniMaxBaseUrl: job.provider === "minimax" ? minimaxBaseUrlFromPref() : void 0,
|
|
26094
|
+
miniMaxGroupId: job.provider === "minimax" && typeof extras.miniMaxGroupId === "string" ? extras.miniMaxGroupId : null,
|
|
26095
|
+
signal: aborter.signal,
|
|
26096
|
+
onProgress: (pct, stage) => {
|
|
26097
|
+
if (aborter.signal.aborted) return;
|
|
26098
|
+
pushProgress(job.id, stage, pct);
|
|
26099
|
+
}
|
|
26100
|
+
});
|
|
26101
|
+
const agent = getAgent(job.agentId);
|
|
26102
|
+
const existing = agent?.voice;
|
|
26103
|
+
const cloneModel = job.provider === "minimax" ? "speech-2.8-hd" : "eleven_multilingual_v2";
|
|
26104
|
+
const updated = updateAgent(job.agentId, {
|
|
26105
|
+
voice: {
|
|
26106
|
+
provider: job.provider,
|
|
26107
|
+
model: cloneModel,
|
|
26108
|
+
voiceId,
|
|
26109
|
+
...existing?.speed != null ? { speed: existing.speed } : {},
|
|
26110
|
+
...existing?.pitch != null ? { pitch: existing.pitch } : {},
|
|
26111
|
+
...existing?.volume != null ? { volume: existing.volume } : {},
|
|
26112
|
+
...existing?.emotion ? { emotion: existing.emotion } : {}
|
|
26113
|
+
}
|
|
26114
|
+
});
|
|
26115
|
+
if (updated?.voice) writeVoiceBucketEntry(job.agentId, job.provider, updated.voice);
|
|
26116
|
+
if (job.label) setVoiceLabel({ voiceId, provider: job.provider, label: job.label });
|
|
26117
|
+
invalidateVoicesCache();
|
|
26118
|
+
updateCloneJobProgress(job.id, {
|
|
26119
|
+
status: "done",
|
|
26120
|
+
currentStage: "clone",
|
|
26121
|
+
pct: 100,
|
|
26122
|
+
voiceId,
|
|
26123
|
+
errorCode: null,
|
|
26124
|
+
errorMessage: null
|
|
26125
|
+
});
|
|
26126
|
+
emit({
|
|
26127
|
+
jobId: job.id,
|
|
26128
|
+
stage: "clone",
|
|
26129
|
+
pct: 100,
|
|
26130
|
+
status: "done",
|
|
26131
|
+
voiceId,
|
|
26132
|
+
message: label,
|
|
26133
|
+
provider: job.provider,
|
|
26134
|
+
ts: Date.now()
|
|
26135
|
+
});
|
|
26136
|
+
} catch (e) {
|
|
26137
|
+
const { code, message } = normaliseError(e);
|
|
26138
|
+
updateCloneJobProgress(job.id, {
|
|
26139
|
+
status: aborters.has(job.id) ? "failed" : "cancelled",
|
|
26140
|
+
errorCode: code,
|
|
26141
|
+
errorMessage: message
|
|
26142
|
+
});
|
|
26143
|
+
emit({
|
|
26144
|
+
jobId: job.id,
|
|
26145
|
+
stage: getCloneJob(job.id)?.currentStage || "fetch",
|
|
26146
|
+
pct: getCloneJob(job.id)?.pct ?? 0,
|
|
26147
|
+
status: aborters.has(job.id) ? "failed" : "cancelled",
|
|
26148
|
+
errorCode: code,
|
|
26149
|
+
errorMessage: message,
|
|
26150
|
+
ts: Date.now()
|
|
26151
|
+
});
|
|
26152
|
+
} finally {
|
|
26153
|
+
aborters.delete(job.id);
|
|
26154
|
+
workerExtras.delete(job.id);
|
|
26155
|
+
}
|
|
26156
|
+
}
|
|
26157
|
+
function normaliseError(e) {
|
|
26158
|
+
if (e instanceof CloneError) {
|
|
26159
|
+
const detail = e.detail ? `
|
|
26160
|
+
${e.detail.slice(-360)}` : "";
|
|
26161
|
+
return { code: e.code, message: `${e.message}${detail}` };
|
|
26162
|
+
}
|
|
26163
|
+
if (e instanceof Error && e.name === "AbortError") return { code: "cancelled", message: "Clone was cancelled." };
|
|
26164
|
+
return { code: "unknown", message: e instanceof Error ? e.message : String(e) };
|
|
26165
|
+
}
|
|
26166
|
+
function minimaxBaseUrlFromPref() {
|
|
26167
|
+
try {
|
|
26168
|
+
const region = getPrefs().minimaxRegion;
|
|
26169
|
+
return region === "intl" ? "https://api.minimax.io" : "https://api.minimaxi.com";
|
|
26170
|
+
} catch {
|
|
26171
|
+
return "https://api.minimaxi.com";
|
|
26172
|
+
}
|
|
26173
|
+
}
|
|
26174
|
+
function voiceCloneRouter() {
|
|
26175
|
+
const r = new Hono13();
|
|
26176
|
+
r.post("/upload", async (c) => {
|
|
26177
|
+
const ct = c.req.header("content-type") || "";
|
|
26178
|
+
if (!ct.toLowerCase().startsWith("multipart/form-data")) {
|
|
26179
|
+
return c.json({ error: "expected multipart/form-data" }, 400);
|
|
26180
|
+
}
|
|
26181
|
+
const form = await c.req.formData();
|
|
26182
|
+
const file = form.get("file");
|
|
26183
|
+
if (!(file instanceof File)) {
|
|
26184
|
+
return c.json({ error: "missing file field" }, 400);
|
|
26185
|
+
}
|
|
26186
|
+
const safeName = String(file.name || "source").replace(/[^A-Za-z0-9_.\- ]/g, "_") || "source";
|
|
26187
|
+
const dir = join4(tmpdir(), `pb-voice-clone-${randomBytes9(6).toString("hex")}`);
|
|
26188
|
+
mkdirSync2(dir, { recursive: true });
|
|
26189
|
+
const path = join4(dir, safeName);
|
|
26190
|
+
const buf = Buffer.from(await file.arrayBuffer());
|
|
26191
|
+
writeFileSync(path, buf);
|
|
26192
|
+
return c.json({ filePath: path, size: buf.length, name: safeName });
|
|
26193
|
+
});
|
|
26194
|
+
r.post("/start", async (c) => {
|
|
26195
|
+
const body = await c.req.json();
|
|
26196
|
+
const agentId = body.agentId?.trim();
|
|
26197
|
+
const source = body.source || {};
|
|
26198
|
+
if (!agentId) return c.json({ error: "missing agentId" }, 400);
|
|
26199
|
+
if (!getAgent(agentId)) return c.json({ error: "unknown agent" }, 404);
|
|
26200
|
+
if (findAnyActiveJob()) {
|
|
26201
|
+
return c.json({ error: "another clone job is in progress" }, 409);
|
|
26202
|
+
}
|
|
26203
|
+
if (findActiveJobForAgent(agentId)) {
|
|
26204
|
+
return c.json({ error: "this director already has a clone in progress" }, 409);
|
|
26205
|
+
}
|
|
26206
|
+
if (source.kind !== "file" || !source.filePath) {
|
|
26207
|
+
return c.json({ error: "source must be { kind: 'file', filePath }" }, 400);
|
|
26208
|
+
}
|
|
26209
|
+
if (!existsSync2(source.filePath) || !statSync2(source.filePath).isFile()) {
|
|
26210
|
+
return c.json({ error: "uploaded file is missing" }, 400);
|
|
26211
|
+
}
|
|
26212
|
+
const kind = "file";
|
|
26213
|
+
const ref = source.filePath;
|
|
26214
|
+
const provider = getActiveVoiceProvider();
|
|
26215
|
+
if (provider !== "minimax" && provider !== "elevenlabs") {
|
|
26216
|
+
return c.json({ error: "active voice credential must be minimax or elevenlabs" }, 400);
|
|
26217
|
+
}
|
|
26218
|
+
const label = (body.label || "").trim();
|
|
26219
|
+
if (!label) {
|
|
26220
|
+
return c.json({ error: "label is required" }, 400);
|
|
26221
|
+
}
|
|
26222
|
+
const job = createCloneJob({
|
|
26223
|
+
agentId,
|
|
26224
|
+
provider,
|
|
26225
|
+
sourceKind: kind,
|
|
26226
|
+
sourceRef: ref,
|
|
26227
|
+
label
|
|
26228
|
+
});
|
|
26229
|
+
const extras = {};
|
|
26230
|
+
if (body.miniMaxGroupId) extras.miniMaxGroupId = body.miniMaxGroupId.trim();
|
|
26231
|
+
workerExtras.set(job.id, extras);
|
|
26232
|
+
void runWorker(job);
|
|
26233
|
+
return c.json({ jobId: job.id, status: job.status });
|
|
26234
|
+
});
|
|
26235
|
+
r.get("/active", (c) => {
|
|
26236
|
+
const j = findAnyActiveJob();
|
|
26237
|
+
return c.json({ job: j ?? null });
|
|
26238
|
+
});
|
|
26239
|
+
r.get("/:id", (c) => {
|
|
26240
|
+
const j = getCloneJob(c.req.param("id"));
|
|
26241
|
+
if (!j) return c.json({ error: "not found" }, 404);
|
|
26242
|
+
return c.json({ job: j });
|
|
26243
|
+
});
|
|
26244
|
+
r.get("/:id/stream", async (c) => {
|
|
26245
|
+
const id = c.req.param("id");
|
|
26246
|
+
const initial = getCloneJob(id);
|
|
26247
|
+
if (!initial) return c.json({ error: "not found" }, 404);
|
|
26248
|
+
return streamSSE3(c, async (s) => {
|
|
26249
|
+
await s.writeSSE({
|
|
26250
|
+
event: "snapshot",
|
|
26251
|
+
data: JSON.stringify({
|
|
26252
|
+
jobId: initial.id,
|
|
26253
|
+
stage: initial.currentStage,
|
|
26254
|
+
pct: initial.pct,
|
|
26255
|
+
status: initial.status,
|
|
26256
|
+
voiceId: initial.voiceId,
|
|
26257
|
+
errorCode: initial.errorCode,
|
|
26258
|
+
errorMessage: initial.errorMessage,
|
|
26259
|
+
ts: Date.now()
|
|
26260
|
+
})
|
|
26261
|
+
});
|
|
26262
|
+
if (initial.status === "done" || initial.status === "failed" || initial.status === "cancelled") {
|
|
26263
|
+
await s.writeSSE({ event: "end", data: JSON.stringify({ jobId: id, status: initial.status }) });
|
|
26264
|
+
return;
|
|
26265
|
+
}
|
|
26266
|
+
const queue = [];
|
|
26267
|
+
let wake = null;
|
|
26268
|
+
let closed = false;
|
|
26269
|
+
const off = subscribe(id, (ev) => {
|
|
26270
|
+
queue.push(ev);
|
|
26271
|
+
if (wake) {
|
|
26272
|
+
wake();
|
|
26273
|
+
wake = null;
|
|
26274
|
+
}
|
|
26275
|
+
});
|
|
26276
|
+
s.onAbort(() => {
|
|
26277
|
+
closed = true;
|
|
26278
|
+
off();
|
|
26279
|
+
if (wake) {
|
|
26280
|
+
wake();
|
|
26281
|
+
wake = null;
|
|
26282
|
+
}
|
|
26283
|
+
});
|
|
26284
|
+
while (!closed) {
|
|
26285
|
+
if (queue.length === 0) {
|
|
26286
|
+
await new Promise((res) => {
|
|
26287
|
+
wake = res;
|
|
26288
|
+
});
|
|
26289
|
+
if (closed) break;
|
|
26290
|
+
}
|
|
26291
|
+
const ev = queue.shift();
|
|
26292
|
+
await s.writeSSE({ event: "progress", data: JSON.stringify(ev) });
|
|
26293
|
+
if (ev.status === "done" || ev.status === "failed" || ev.status === "cancelled") {
|
|
26294
|
+
await s.writeSSE({ event: "end", data: JSON.stringify({ jobId: id, status: ev.status }) });
|
|
26295
|
+
break;
|
|
26296
|
+
}
|
|
26297
|
+
}
|
|
26298
|
+
off();
|
|
26299
|
+
});
|
|
26300
|
+
});
|
|
26301
|
+
r.delete("/:id", (c) => {
|
|
26302
|
+
const id = c.req.param("id");
|
|
26303
|
+
const job = getCloneJob(id);
|
|
26304
|
+
if (!job) return c.json({ error: "not found" }, 404);
|
|
26305
|
+
const aborter = aborters.get(id);
|
|
26306
|
+
if (aborter) {
|
|
26307
|
+
aborter.abort();
|
|
26308
|
+
aborters.delete(id);
|
|
26309
|
+
}
|
|
26310
|
+
updateCloneJobProgress(id, {
|
|
26311
|
+
status: "cancelled",
|
|
26312
|
+
errorCode: "cancelled",
|
|
26313
|
+
errorMessage: "Cancelled by user."
|
|
26314
|
+
});
|
|
26315
|
+
emit({
|
|
26316
|
+
jobId: id,
|
|
26317
|
+
stage: job.currentStage,
|
|
26318
|
+
pct: job.pct,
|
|
26319
|
+
status: "cancelled",
|
|
26320
|
+
errorCode: "cancelled",
|
|
26321
|
+
errorMessage: "Cancelled by user.",
|
|
26322
|
+
ts: Date.now()
|
|
26323
|
+
});
|
|
26324
|
+
return c.json({ ok: true });
|
|
26325
|
+
});
|
|
26326
|
+
return r;
|
|
26327
|
+
}
|
|
26328
|
+
|
|
26329
|
+
// src/routes/voice-credentials.ts
|
|
26330
|
+
import { Hono as Hono14 } from "hono";
|
|
25283
26331
|
|
|
25284
26332
|
// src/storage/reconcile-voices.ts
|
|
25285
26333
|
var MINIMAX_SEED_VOICES = [
|
|
@@ -25446,7 +26494,7 @@ function pickNextActiveVoiceId(removedProvider) {
|
|
|
25446
26494
|
return sorted[0]?.id ?? null;
|
|
25447
26495
|
}
|
|
25448
26496
|
function voiceCredentialsRouter() {
|
|
25449
|
-
const r = new
|
|
26497
|
+
const r = new Hono14();
|
|
25450
26498
|
r.get("/", (c) => {
|
|
25451
26499
|
const activeId = getPrefs().activeVoiceCredentialId;
|
|
25452
26500
|
const items = listVoiceCredentials().map((m) => payloadFor3(m, activeId));
|
|
@@ -25514,8 +26562,9 @@ function voiceCredentialsRouter() {
|
|
|
25514
26562
|
const label = typeof labelRaw === "string" ? labelRaw : null;
|
|
25515
26563
|
const meta = createVoiceCredential(provider, label, key);
|
|
25516
26564
|
if (!meta) return c.json({ error: "failed to create credential" }, 500);
|
|
25517
|
-
const
|
|
25518
|
-
|
|
26565
|
+
const priorActiveId = getPrefs().activeVoiceCredentialId;
|
|
26566
|
+
const priorActive = priorActiveId ? getVoiceCredentialMeta(priorActiveId) : null;
|
|
26567
|
+
if (!priorActive) {
|
|
25519
26568
|
updatePrefs({ activeVoiceCredentialId: meta.id });
|
|
25520
26569
|
try {
|
|
25521
26570
|
reconcileAgentVoices({ reason: "first-key", priorProvider: null });
|
|
@@ -25525,6 +26574,8 @@ function voiceCredentialsRouter() {
|
|
|
25525
26574
|
`
|
|
25526
26575
|
);
|
|
25527
26576
|
}
|
|
26577
|
+
} else if (priorActive.provider === provider) {
|
|
26578
|
+
updatePrefs({ activeVoiceCredentialId: meta.id });
|
|
25528
26579
|
}
|
|
25529
26580
|
const activeId = getPrefs().activeVoiceCredentialId;
|
|
25530
26581
|
return c.json(payloadFor3(meta, activeId), 201);
|
|
@@ -25563,8 +26614,37 @@ function voiceCredentialsRouter() {
|
|
|
25563
26614
|
return r;
|
|
25564
26615
|
}
|
|
25565
26616
|
|
|
26617
|
+
// src/routes/voice-labels.ts
|
|
26618
|
+
import { Hono as Hono15 } from "hono";
|
|
26619
|
+
function voiceLabelsRouter() {
|
|
26620
|
+
const r = new Hono15();
|
|
26621
|
+
r.get("/", (c) => {
|
|
26622
|
+
return c.json({ labels: listVoiceLabels() });
|
|
26623
|
+
});
|
|
26624
|
+
r.put("/:voiceId", async (c) => {
|
|
26625
|
+
const voiceId = c.req.param("voiceId");
|
|
26626
|
+
if (!voiceId) return c.json({ error: "missing voiceId" }, 400);
|
|
26627
|
+
const body = await c.req.json();
|
|
26628
|
+
const provider = body.provider === "minimax" || body.provider === "elevenlabs" ? body.provider : null;
|
|
26629
|
+
if (!provider) return c.json({ error: "provider must be minimax or elevenlabs" }, 400);
|
|
26630
|
+
const label = (body.label || "").trim();
|
|
26631
|
+
if (!label) return c.json({ error: "label is required" }, 400);
|
|
26632
|
+
setVoiceLabel({ voiceId, provider, label });
|
|
26633
|
+
invalidateVoicesCache();
|
|
26634
|
+
return c.json({ ok: true, voiceId, provider, label });
|
|
26635
|
+
});
|
|
26636
|
+
r.delete("/:voiceId", (c) => {
|
|
26637
|
+
const voiceId = c.req.param("voiceId");
|
|
26638
|
+
if (!voiceId) return c.json({ error: "missing voiceId" }, 400);
|
|
26639
|
+
const removed = deleteVoiceLabel(voiceId);
|
|
26640
|
+
if (removed) invalidateVoicesCache();
|
|
26641
|
+
return c.json({ ok: true, removed });
|
|
26642
|
+
});
|
|
26643
|
+
return r;
|
|
26644
|
+
}
|
|
26645
|
+
|
|
25566
26646
|
// src/routes/voices.ts
|
|
25567
|
-
import { Hono as
|
|
26647
|
+
import { Hono as Hono16 } from "hono";
|
|
25568
26648
|
function ttsErrorMessage(e, providerLabel) {
|
|
25569
26649
|
if (!(e instanceof Error)) return String(e);
|
|
25570
26650
|
const cause = e.cause;
|
|
@@ -25609,7 +26689,7 @@ function ttsCacheSet(key, val) {
|
|
|
25609
26689
|
}
|
|
25610
26690
|
}
|
|
25611
26691
|
function voicesRouter() {
|
|
25612
|
-
const r = new
|
|
26692
|
+
const r = new Hono16();
|
|
25613
26693
|
r.get("/", async (c) => {
|
|
25614
26694
|
const url = new URL(c.req.url);
|
|
25615
26695
|
const cursor = url.searchParams.get("cursor");
|
|
@@ -25776,7 +26856,7 @@ function voicesRouter() {
|
|
|
25776
26856
|
init_paths();
|
|
25777
26857
|
|
|
25778
26858
|
// src/version.ts
|
|
25779
|
-
var VERSION = "0.1.
|
|
26859
|
+
var VERSION = "0.1.38";
|
|
25780
26860
|
|
|
25781
26861
|
// src/utils/render-picker-catalog.ts
|
|
25782
26862
|
function renderPickerCatalog() {
|
|
@@ -25788,9 +26868,9 @@ function renderPickerCatalog() {
|
|
|
25788
26868
|
|
|
25789
26869
|
// src/server.ts
|
|
25790
26870
|
function createApp() {
|
|
25791
|
-
const app = new
|
|
26871
|
+
const app = new Hono17();
|
|
25792
26872
|
const dir = publicDir();
|
|
25793
|
-
if (!
|
|
26873
|
+
if (!existsSync3(dir)) {
|
|
25794
26874
|
throw new Error(
|
|
25795
26875
|
`public/ directory not found at: ${dir}
|
|
25796
26876
|
Build the package or check that public/ is bundled alongside dist/.`
|
|
@@ -25837,6 +26917,8 @@ Build the package or check that public/ is bundled alongside dist/.`
|
|
|
25837
26917
|
app.route("/api/usage", usageRouter());
|
|
25838
26918
|
app.route("/api/voices", voicesRouter());
|
|
25839
26919
|
app.route("/api/voice-credentials", voiceCredentialsRouter());
|
|
26920
|
+
app.route("/api/voice-clone", voiceCloneRouter());
|
|
26921
|
+
app.route("/api/voice-labels", voiceLabelsRouter());
|
|
25840
26922
|
app.route("/api/search", searchRouter());
|
|
25841
26923
|
app.route("/api/search-credentials", searchCredentialsRouter());
|
|
25842
26924
|
app.use(
|
|
@@ -25933,6 +27015,16 @@ async function bootApp(opts = {}) {
|
|
|
25933
27015
|
}
|
|
25934
27016
|
} catch (e) {
|
|
25935
27017
|
process.stderr.write(`[boot] persona-job recovery failed: ${errMsg(e)}
|
|
27018
|
+
`);
|
|
27019
|
+
}
|
|
27020
|
+
try {
|
|
27021
|
+
const failed = recoverStuckCloneJobs();
|
|
27022
|
+
if (failed > 0) {
|
|
27023
|
+
process.stderr.write(`[boot] marked ${failed} voice-clone job(s) failed (server restarted mid-clone)
|
|
27024
|
+
`);
|
|
27025
|
+
}
|
|
27026
|
+
} catch (e) {
|
|
27027
|
+
process.stderr.write(`[boot] voice-clone recovery failed: ${errMsg(e)}
|
|
25936
27028
|
`);
|
|
25937
27029
|
}
|
|
25938
27030
|
void (async () => {
|