@lokutor/sdk 1.1.17 → 1.1.20
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/index.d.mts +193 -4
- package/dist/index.d.ts +193 -4
- package/dist/index.js +963 -219
- package/dist/index.mjs +960 -63
- package/package.json +13 -4
- package/src/audio-utils.ts +253 -0
- package/src/browser-audio.ts +395 -0
- package/src/client.ts +886 -0
- package/src/conversational-panel.ts +606 -0
- package/src/index.ts +27 -0
- package/src/node-audio.ts +115 -0
- package/src/types.ts +270 -0
- package/dist/chunk-UI24THO7.mjs +0 -44
- package/dist/node-audio-5HOWE6MC.mjs +0 -94
package/dist/index.mjs
CHANGED
|
@@ -1,9 +1,75 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
// src/types.ts
|
|
2
|
+
var VoiceStyle = /* @__PURE__ */ ((VoiceStyle2) => {
|
|
3
|
+
VoiceStyle2["F1"] = "F1";
|
|
4
|
+
VoiceStyle2["F2"] = "F2";
|
|
5
|
+
VoiceStyle2["F3"] = "F3";
|
|
6
|
+
VoiceStyle2["F4"] = "F4";
|
|
7
|
+
VoiceStyle2["F5"] = "F5";
|
|
8
|
+
VoiceStyle2["M1"] = "M1";
|
|
9
|
+
VoiceStyle2["M2"] = "M2";
|
|
10
|
+
VoiceStyle2["M3"] = "M3";
|
|
11
|
+
VoiceStyle2["M4"] = "M4";
|
|
12
|
+
VoiceStyle2["M5"] = "M5";
|
|
13
|
+
return VoiceStyle2;
|
|
14
|
+
})(VoiceStyle || {});
|
|
15
|
+
var Language = /* @__PURE__ */ ((Language2) => {
|
|
16
|
+
Language2["ENGLISH"] = "en";
|
|
17
|
+
Language2["SPANISH"] = "es";
|
|
18
|
+
Language2["FRENCH"] = "fr";
|
|
19
|
+
Language2["PORTUGUESE"] = "pt";
|
|
20
|
+
Language2["KOREAN"] = "ko";
|
|
21
|
+
return Language2;
|
|
22
|
+
})(Language || {});
|
|
23
|
+
var AUDIO_CONFIG = {
|
|
24
|
+
SAMPLE_RATE: 16e3,
|
|
25
|
+
SAMPLE_RATE_INPUT: 16e3,
|
|
26
|
+
SPEAKER_SAMPLE_RATE: 44100,
|
|
27
|
+
SAMPLE_RATE_OUTPUT: 44100,
|
|
28
|
+
CHANNELS: 1,
|
|
29
|
+
CHUNK_DURATION_MS: 20,
|
|
30
|
+
get CHUNK_SIZE() {
|
|
31
|
+
return Math.floor(this.SAMPLE_RATE * this.CHUNK_DURATION_MS / 1e3);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
var DEFAULT_URLS = {
|
|
35
|
+
VOICE_AGENT: "wss://api.lokutor.com/ws/agent",
|
|
36
|
+
TTS: "wss://api.lokutor.com/ws/tts"
|
|
37
|
+
};
|
|
38
|
+
var LokutorError = class extends Error {
|
|
39
|
+
code;
|
|
40
|
+
detail;
|
|
41
|
+
retryable;
|
|
42
|
+
original;
|
|
43
|
+
constructor(code, message, opts) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = "LokutorError";
|
|
46
|
+
this.code = code;
|
|
47
|
+
this.detail = opts?.detail;
|
|
48
|
+
this.retryable = opts?.retryable ?? isRetryableCode(code);
|
|
49
|
+
this.original = opts?.original;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
function isRetryableCode(code) {
|
|
53
|
+
const fatal = [
|
|
54
|
+
"auth.missing_key",
|
|
55
|
+
"auth.invalid_key",
|
|
56
|
+
"auth.time_limited",
|
|
57
|
+
"validation.invalid_voice",
|
|
58
|
+
"validation.invalid_language",
|
|
59
|
+
"validation.text_too_long",
|
|
60
|
+
"validation.speed_out_of_range",
|
|
61
|
+
"validation.steps_out_of_range",
|
|
62
|
+
"validation.invalid_request_format",
|
|
63
|
+
"internal.cancelled"
|
|
64
|
+
];
|
|
65
|
+
return !fatal.includes(code);
|
|
66
|
+
}
|
|
67
|
+
function isRetryable(error) {
|
|
68
|
+
if (error instanceof LokutorError) {
|
|
69
|
+
return error.retryable;
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
7
73
|
|
|
8
74
|
// src/audio-utils.ts
|
|
9
75
|
function pcm16ToFloat32(int16Data) {
|
|
@@ -64,6 +130,9 @@ function pcm16ToBytes(data) {
|
|
|
64
130
|
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
65
131
|
}
|
|
66
132
|
function bytesToPcm16(bytes) {
|
|
133
|
+
if (bytes.length % 2 !== 0) {
|
|
134
|
+
bytes = bytes.slice(0, bytes.length - 1);
|
|
135
|
+
}
|
|
67
136
|
return new Int16Array(bytes.buffer, bytes.byteOffset, bytes.length / 2);
|
|
68
137
|
}
|
|
69
138
|
function normalizeAudio(data, targetPeak = 0.95) {
|
|
@@ -113,11 +182,14 @@ var StreamResampler = class {
|
|
|
113
182
|
combined.set(this.inputBuffer);
|
|
114
183
|
combined.set(inputChunk, this.inputBuffer.length);
|
|
115
184
|
const ratio = this.inputRate / this.outputRate;
|
|
116
|
-
|
|
185
|
+
let outputLength = Math.floor(combined.length / ratio);
|
|
117
186
|
if (outputLength === 0 && !flush) {
|
|
118
187
|
this.inputBuffer = combined;
|
|
119
188
|
return new Float32Array(0);
|
|
120
189
|
}
|
|
190
|
+
if (flush && outputLength === 0 && combined.length > 0) {
|
|
191
|
+
outputLength = 1;
|
|
192
|
+
}
|
|
121
193
|
const output = new Float32Array(outputLength);
|
|
122
194
|
for (let i = 0; i < outputLength; i++) {
|
|
123
195
|
const pos = i * ratio;
|
|
@@ -126,10 +198,8 @@ var StreamResampler = class {
|
|
|
126
198
|
const weight = pos - left;
|
|
127
199
|
output[i] = combined[left] * (1 - weight) + combined[right] * weight;
|
|
128
200
|
}
|
|
129
|
-
const
|
|
130
|
-
this.inputBuffer = combined.slice(
|
|
131
|
-
combined.length - remainingSamples
|
|
132
|
-
);
|
|
201
|
+
const consumed = Math.floor(outputLength * ratio);
|
|
202
|
+
this.inputBuffer = combined.slice(consumed);
|
|
133
203
|
return output;
|
|
134
204
|
}
|
|
135
205
|
reset() {
|
|
@@ -291,6 +361,10 @@ var BrowserAudioManager = class {
|
|
|
291
361
|
console.warn("AudioContext not initialized");
|
|
292
362
|
return;
|
|
293
363
|
}
|
|
364
|
+
if (pcm16Data.length % 2 !== 0) {
|
|
365
|
+
console.warn(`Discarding odd-length PCM buffer (${pcm16Data.length} bytes)`);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
294
368
|
const int16Array = new Int16Array(
|
|
295
369
|
pcm16Data.buffer,
|
|
296
370
|
pcm16Data.byteOffset,
|
|
@@ -419,6 +493,54 @@ var BrowserAudioManager = class {
|
|
|
419
493
|
};
|
|
420
494
|
|
|
421
495
|
// src/client.ts
|
|
496
|
+
function sdkTraceEnabled() {
|
|
497
|
+
try {
|
|
498
|
+
if (typeof window === "undefined") return false;
|
|
499
|
+
const w = window;
|
|
500
|
+
return Boolean(w.LOKUTOR_TRACE) || window.localStorage?.getItem("lokutorTrace") === "1";
|
|
501
|
+
} catch {
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
function sdkTrace(...args) {
|
|
506
|
+
if (sdkTraceEnabled()) {
|
|
507
|
+
console.log("[SDK TRACE]", ...args);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
function nowMs() {
|
|
511
|
+
if (typeof performance !== "undefined" && performance.now) {
|
|
512
|
+
return performance.now();
|
|
513
|
+
}
|
|
514
|
+
return Date.now();
|
|
515
|
+
}
|
|
516
|
+
function wsToHttp(url) {
|
|
517
|
+
return url.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
|
|
518
|
+
}
|
|
519
|
+
async function fetchJson(url, timeoutMs = 1e4) {
|
|
520
|
+
const controller = new AbortController();
|
|
521
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
522
|
+
try {
|
|
523
|
+
const res = await fetch(url, {
|
|
524
|
+
signal: controller.signal,
|
|
525
|
+
headers: { Accept: "application/json" }
|
|
526
|
+
});
|
|
527
|
+
clearTimeout(timer);
|
|
528
|
+
if (!res.ok) {
|
|
529
|
+
throw new LokutorError("internal.error", `HTTP ${res.status} from ${url}`, {
|
|
530
|
+
detail: await res.text().catch(() => ""),
|
|
531
|
+
retryable: res.status >= 500
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
return await res.json();
|
|
535
|
+
} catch (err) {
|
|
536
|
+
clearTimeout(timer);
|
|
537
|
+
if (err instanceof LokutorError) throw err;
|
|
538
|
+
throw new LokutorError("internal.error", `Failed to fetch ${url}`, {
|
|
539
|
+
original: err,
|
|
540
|
+
retryable: true
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
422
544
|
function base64ToUint8Array(base64) {
|
|
423
545
|
const binaryString = atob(base64);
|
|
424
546
|
const bytes = new Uint8Array(binaryString.length);
|
|
@@ -427,6 +549,39 @@ function base64ToUint8Array(base64) {
|
|
|
427
549
|
}
|
|
428
550
|
return bytes;
|
|
429
551
|
}
|
|
552
|
+
function normalizeVisemes(payload) {
|
|
553
|
+
if (!Array.isArray(payload)) return [];
|
|
554
|
+
const normalized = [];
|
|
555
|
+
for (const item of payload) {
|
|
556
|
+
if (!item || typeof item !== "object") continue;
|
|
557
|
+
const c = String(item.c ?? item.char ?? "sil").toLowerCase();
|
|
558
|
+
const t = Number(item.t ?? item.timestamp ?? 0);
|
|
559
|
+
const v = Number(item.v ?? item.id ?? 0);
|
|
560
|
+
normalized.push({
|
|
561
|
+
v: Number.isFinite(v) ? v : 0,
|
|
562
|
+
c,
|
|
563
|
+
t: Number.isFinite(t) ? t : 0
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
return normalized;
|
|
567
|
+
}
|
|
568
|
+
function extractVisemePayload(msg) {
|
|
569
|
+
if (Array.isArray(msg?.data)) {
|
|
570
|
+
return normalizeVisemes(msg.data);
|
|
571
|
+
}
|
|
572
|
+
if (Array.isArray(msg?.data?.visemes)) {
|
|
573
|
+
return normalizeVisemes(msg.data.visemes);
|
|
574
|
+
}
|
|
575
|
+
if (msg?.data && !Array.isArray(msg.data) && typeof msg.data === "object") {
|
|
576
|
+
const singularInData = normalizeVisemes([msg.data]);
|
|
577
|
+
if (singularInData.length > 0) return singularInData;
|
|
578
|
+
}
|
|
579
|
+
if (msg && !Array.isArray(msg) && typeof msg === "object") {
|
|
580
|
+
const singularAtRoot = normalizeVisemes([msg]);
|
|
581
|
+
if (singularAtRoot.length > 0) return singularAtRoot;
|
|
582
|
+
}
|
|
583
|
+
return [];
|
|
584
|
+
}
|
|
430
585
|
var VoiceAgentClient = class {
|
|
431
586
|
ws = null;
|
|
432
587
|
apiKey;
|
|
@@ -454,11 +609,13 @@ var VoiceAgentClient = class {
|
|
|
454
609
|
reconnecting = false;
|
|
455
610
|
reconnectAttempts = 0;
|
|
456
611
|
maxReconnectAttempts = 5;
|
|
612
|
+
serverUrl;
|
|
457
613
|
constructor(config) {
|
|
458
614
|
this.apiKey = config.apiKey;
|
|
459
615
|
this.prompt = config.prompt;
|
|
460
616
|
this.voice = config.voice || "F1" /* F1 */;
|
|
461
617
|
this.language = config.language || "en" /* ENGLISH */;
|
|
618
|
+
this.serverUrl = config.serverUrl || DEFAULT_URLS.VOICE_AGENT;
|
|
462
619
|
this.onTranscription = config.onTranscription;
|
|
463
620
|
this.onResponse = config.onResponse;
|
|
464
621
|
this.onAudioCallback = config.onAudio;
|
|
@@ -486,13 +643,28 @@ var VoiceAgentClient = class {
|
|
|
486
643
|
}
|
|
487
644
|
}
|
|
488
645
|
return new Promise((resolve, reject) => {
|
|
646
|
+
let settled = false;
|
|
647
|
+
const settle = (fn) => {
|
|
648
|
+
if (!settled) {
|
|
649
|
+
settled = true;
|
|
650
|
+
fn();
|
|
651
|
+
}
|
|
652
|
+
};
|
|
489
653
|
try {
|
|
490
|
-
let url =
|
|
654
|
+
let url = this.serverUrl;
|
|
491
655
|
if (this.apiKey) {
|
|
492
656
|
const separator = url.includes("?") ? "&" : "?";
|
|
493
657
|
url += `${separator}api_key=${this.apiKey}`;
|
|
494
658
|
}
|
|
495
|
-
|
|
659
|
+
const redactedUrl = url.replace(/api_key=[^&]+/, "api_key=***");
|
|
660
|
+
sdkTrace("ws.connect", {
|
|
661
|
+
endpoint: this.serverUrl,
|
|
662
|
+
url: redactedUrl,
|
|
663
|
+
enableAudio: this.enableAudio,
|
|
664
|
+
wantVisemes: this.wantVisemes,
|
|
665
|
+
hasAudioManager: Boolean(this.audioManager)
|
|
666
|
+
});
|
|
667
|
+
console.log(`\u{1F517} Connecting to ${this.serverUrl}...`);
|
|
496
668
|
this.ws = new WebSocket(url);
|
|
497
669
|
this.ws.binaryType = "arraybuffer";
|
|
498
670
|
this.ws.onopen = async () => {
|
|
@@ -500,6 +672,7 @@ var VoiceAgentClient = class {
|
|
|
500
672
|
this.reconnectAttempts = 0;
|
|
501
673
|
this.reconnecting = false;
|
|
502
674
|
console.log("\u2705 Connected to voice agent!");
|
|
675
|
+
sdkTrace("ws.open");
|
|
503
676
|
this.sendConfig();
|
|
504
677
|
if (this.audioManager) {
|
|
505
678
|
await this.audioManager.startMicrophone((data) => {
|
|
@@ -508,22 +681,54 @@ var VoiceAgentClient = class {
|
|
|
508
681
|
}
|
|
509
682
|
});
|
|
510
683
|
}
|
|
511
|
-
resolve(true);
|
|
684
|
+
settle(() => resolve(true));
|
|
512
685
|
};
|
|
513
686
|
this.ws.onmessage = async (event) => {
|
|
514
687
|
if (event.data instanceof ArrayBuffer) {
|
|
688
|
+
sdkTrace("ws.message.binary", { bytes: event.data.byteLength });
|
|
515
689
|
this.handleBinaryMessage(new Uint8Array(event.data));
|
|
516
690
|
} else {
|
|
691
|
+
sdkTrace("ws.message.text", { length: String(event.data).length });
|
|
517
692
|
this.handleTextMessage(event.data.toString());
|
|
518
693
|
}
|
|
519
694
|
};
|
|
520
695
|
this.ws.onerror = (err) => {
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
696
|
+
const error = new LokutorError("ws.close", "WebSocket connection error", {
|
|
697
|
+
detail: `readyState=${this.ws?.readyState}, bufferedAmount=${this.ws?.bufferedAmount}`,
|
|
698
|
+
original: err,
|
|
699
|
+
retryable: true
|
|
700
|
+
});
|
|
701
|
+
console.error("\u274C WebSocket error:", error.message);
|
|
702
|
+
sdkTrace("ws.error", { code: error.code, message: error.message });
|
|
703
|
+
if (this.onError) this.onError(error);
|
|
704
|
+
if (!this.isConnected) {
|
|
705
|
+
settle(() => reject(error));
|
|
706
|
+
}
|
|
524
707
|
};
|
|
525
|
-
this.ws.onclose = () => {
|
|
708
|
+
this.ws.onclose = (event) => {
|
|
526
709
|
this.isConnected = false;
|
|
710
|
+
const diagnostic = {
|
|
711
|
+
code: event.code,
|
|
712
|
+
reason: event.reason,
|
|
713
|
+
wasClean: event.wasClean,
|
|
714
|
+
url: this.serverUrl,
|
|
715
|
+
isUserDisconnect: this.isUserDisconnect,
|
|
716
|
+
reconnectAttempts: this.reconnectAttempts
|
|
717
|
+
};
|
|
718
|
+
sdkTrace("ws.close", diagnostic);
|
|
719
|
+
if (!settled && !this.isUserDisconnect) {
|
|
720
|
+
const error = new LokutorError("ws.close", `WebSocket closed unexpectedly (code ${event.code})`, {
|
|
721
|
+
detail: event.reason || "No reason provided",
|
|
722
|
+
retryable: event.code !== 1008
|
|
723
|
+
});
|
|
724
|
+
settle(() => reject(error));
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
if (!event.wasClean && event.code === 1006 && this.reconnectAttempts === 0 && !this.isUserDisconnect) {
|
|
728
|
+
console.error("\u274C Connection rejected (code 1006). Likely causes: invalid API key, endpoint unavailable, or CORS blocked.");
|
|
729
|
+
console.error(" URL:", this.serverUrl.replace(/api_key=[^&]+/, "api_key=***"));
|
|
730
|
+
}
|
|
731
|
+
console.log(`\u{1F50C} WebSocket closed \u2014 code: ${event.code}, reason: "${event.reason || "none"}", clean: ${event.wasClean}`);
|
|
527
732
|
if (!this.isUserDisconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
528
733
|
this.reconnecting = true;
|
|
529
734
|
this.reconnectAttempts++;
|
|
@@ -539,37 +744,27 @@ var VoiceAgentClient = class {
|
|
|
539
744
|
}
|
|
540
745
|
};
|
|
541
746
|
} catch (err) {
|
|
542
|
-
|
|
543
|
-
|
|
747
|
+
const error = err instanceof LokutorError ? err : new LokutorError("internal.error", "Failed to create WebSocket connection", { original: err });
|
|
748
|
+
if (this.onError) this.onError(error);
|
|
749
|
+
settle(() => reject(error));
|
|
544
750
|
}
|
|
545
751
|
});
|
|
546
752
|
}
|
|
547
753
|
/**
|
|
548
754
|
* The "Golden Path" - Starts a managed session with hardware handled automatically.
|
|
549
|
-
* This is the recommended way to start a conversation in
|
|
755
|
+
* This is the recommended way to start a conversation in browser environments.
|
|
550
756
|
*/
|
|
551
757
|
async startManaged(config) {
|
|
552
758
|
this.enableAudio = true;
|
|
553
759
|
if (config?.audioManager) {
|
|
554
760
|
this.audioManager = config.audioManager;
|
|
555
761
|
} else if (!this.audioManager) {
|
|
556
|
-
if (typeof window
|
|
557
|
-
|
|
558
|
-
} else {
|
|
559
|
-
try {
|
|
560
|
-
const { NodeAudioManager } = await import("./node-audio-5HOWE6MC.mjs");
|
|
561
|
-
this.audioManager = new NodeAudioManager();
|
|
562
|
-
} catch (e) {
|
|
563
|
-
console.error('\u274C Failed to load NodeAudioManager. Please ensure "speaker" and "node-record-lpcm16" are installed.');
|
|
564
|
-
}
|
|
762
|
+
if (typeof window === "undefined") {
|
|
763
|
+
throw new LokutorError("internal.error", "startManaged() requires a browser environment. Pass a custom audioManager for non-browser runtimes.", { retryable: false });
|
|
565
764
|
}
|
|
765
|
+
this.audioManager = new BrowserAudioManager();
|
|
566
766
|
}
|
|
567
767
|
await this.connect();
|
|
568
|
-
if (this.audioManager && this.isConnected) {
|
|
569
|
-
await this.audioManager.startMicrophone((data) => {
|
|
570
|
-
this.sendAudio(data);
|
|
571
|
-
});
|
|
572
|
-
}
|
|
573
768
|
return this;
|
|
574
769
|
}
|
|
575
770
|
/**
|
|
@@ -577,10 +772,18 @@ var VoiceAgentClient = class {
|
|
|
577
772
|
*/
|
|
578
773
|
sendConfig() {
|
|
579
774
|
if (!this.ws || !this.isConnected) return;
|
|
580
|
-
this.ws.send(JSON.stringify({ type: "
|
|
775
|
+
this.ws.send(JSON.stringify({ type: "visemes", data: this.wantVisemes }));
|
|
581
776
|
this.ws.send(JSON.stringify({ type: "voice", data: this.voice }));
|
|
582
777
|
this.ws.send(JSON.stringify({ type: "language", data: this.language }));
|
|
583
|
-
this.ws.send(JSON.stringify({ type: "
|
|
778
|
+
this.ws.send(JSON.stringify({ type: "prompt", data: this.prompt }));
|
|
779
|
+
this.ws.send(JSON.stringify({ type: "rates", playback: 44100, input: 16e3 }));
|
|
780
|
+
sdkTrace("ws.send.config", {
|
|
781
|
+
promptLen: this.prompt?.length || 0,
|
|
782
|
+
voice: this.voice,
|
|
783
|
+
language: this.language,
|
|
784
|
+
visemes: this.wantVisemes,
|
|
785
|
+
tools: this.tools?.length || 0
|
|
786
|
+
});
|
|
584
787
|
if (this.tools && this.tools.length > 0) {
|
|
585
788
|
this.ws.send(JSON.stringify({ type: "tools", data: this.tools }));
|
|
586
789
|
}
|
|
@@ -614,6 +817,15 @@ var VoiceAgentClient = class {
|
|
|
614
817
|
handleTextMessage(text) {
|
|
615
818
|
try {
|
|
616
819
|
const msg = JSON.parse(text);
|
|
820
|
+
if (!msg || typeof msg !== "object") {
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
sdkTrace("ws.recv.type", {
|
|
824
|
+
type: msg.type,
|
|
825
|
+
hasData: Object.prototype.hasOwnProperty.call(msg, "data"),
|
|
826
|
+
dataKind: Array.isArray(msg.data) ? "array" : typeof msg.data,
|
|
827
|
+
generation: msg.generation ?? null
|
|
828
|
+
});
|
|
617
829
|
switch (msg.type) {
|
|
618
830
|
case "audio":
|
|
619
831
|
if (msg.data) {
|
|
@@ -626,7 +838,7 @@ var VoiceAgentClient = class {
|
|
|
626
838
|
this.messages.push({
|
|
627
839
|
role,
|
|
628
840
|
text: msg.data,
|
|
629
|
-
timestamp:
|
|
841
|
+
timestamp: nowMs()
|
|
630
842
|
});
|
|
631
843
|
if (msg.role === "user") {
|
|
632
844
|
if (this.onTranscription) this.onTranscription(msg.data);
|
|
@@ -658,19 +870,44 @@ var VoiceAgentClient = class {
|
|
|
658
870
|
console.log(`${icons[msg.data] || ""} Status: ${msg.data}`);
|
|
659
871
|
break;
|
|
660
872
|
case "visemes":
|
|
661
|
-
|
|
662
|
-
|
|
873
|
+
case "viseme": {
|
|
874
|
+
const msgGen = msg.generation ?? this.currentGeneration;
|
|
875
|
+
if (msgGen < this.currentGeneration) {
|
|
876
|
+
sdkTrace("visemes.discard", { msgGen, currentGen: this.currentGeneration });
|
|
877
|
+
break;
|
|
878
|
+
}
|
|
879
|
+
const normalized = extractVisemePayload(msg);
|
|
880
|
+
const explicitlyEmptyArray = Array.isArray(msg?.data) || Array.isArray(msg?.data?.visemes);
|
|
881
|
+
sdkTrace("visemes.recv", {
|
|
882
|
+
rawType: msg.type,
|
|
883
|
+
normalizedCount: normalized.length,
|
|
884
|
+
first: normalized[0] ?? null
|
|
885
|
+
});
|
|
886
|
+
if (normalized.length > 0 || explicitlyEmptyArray) {
|
|
887
|
+
this.emit("visemes", normalized);
|
|
663
888
|
}
|
|
664
889
|
break;
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
890
|
+
}
|
|
891
|
+
case "error": {
|
|
892
|
+
const backendCode = msg.data?.code ?? "internal.error";
|
|
893
|
+
const backendMessage = msg.data?.message ?? msg.data ?? "Unknown server error";
|
|
894
|
+
const backendDetail = msg.data?.detail;
|
|
895
|
+
const backendRetryable = msg.data?.retryable ?? true;
|
|
896
|
+
const error = new LokutorError(backendCode, backendMessage, {
|
|
897
|
+
detail: backendDetail,
|
|
898
|
+
retryable: backendRetryable
|
|
899
|
+
});
|
|
900
|
+
if (this.onError) this.onError(error);
|
|
901
|
+
console.error(`\u274C Server error: [${error.code}] ${error.message}`);
|
|
668
902
|
break;
|
|
903
|
+
}
|
|
669
904
|
case "tool_call":
|
|
670
905
|
console.log(`\u{1F6E0}\uFE0F Tool Call: ${msg.name}(${msg.arguments})`);
|
|
671
906
|
break;
|
|
672
907
|
}
|
|
673
908
|
} catch (e) {
|
|
909
|
+
sdkTrace("ws.recv.parse_error", { preview: text?.slice(0, 120) });
|
|
910
|
+
console.debug("Failed to parse message:", e);
|
|
674
911
|
}
|
|
675
912
|
}
|
|
676
913
|
/**
|
|
@@ -732,6 +969,21 @@ var VoiceAgentClient = class {
|
|
|
732
969
|
this.audioManager.cleanup();
|
|
733
970
|
}
|
|
734
971
|
this.isConnected = false;
|
|
972
|
+
this.reconnecting = false;
|
|
973
|
+
this.reconnectAttempts = 0;
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* Returns true if the client is currently connected.
|
|
977
|
+
*/
|
|
978
|
+
get connected() {
|
|
979
|
+
return this.isConnected;
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Returns the current generation counter.
|
|
983
|
+
* Useful for correlating audio/viseme chunks with utterances.
|
|
984
|
+
*/
|
|
985
|
+
get generation() {
|
|
986
|
+
return this.currentGeneration;
|
|
735
987
|
}
|
|
736
988
|
/**
|
|
737
989
|
* Toggles the microphone mute state (if managed by client)
|
|
@@ -754,17 +1006,70 @@ var VoiceAgentClient = class {
|
|
|
754
1006
|
}
|
|
755
1007
|
return 0;
|
|
756
1008
|
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Fetch available voice styles from the server.
|
|
1011
|
+
* No authentication required.
|
|
1012
|
+
*/
|
|
1013
|
+
static async fetchVoices(baseUrl) {
|
|
1014
|
+
const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
|
|
1015
|
+
const data = await fetchJson(`${url}/voices`);
|
|
1016
|
+
return data.voices || [];
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Fetch supported languages from the server.
|
|
1020
|
+
* No authentication required.
|
|
1021
|
+
*/
|
|
1022
|
+
static async fetchLanguages(baseUrl) {
|
|
1023
|
+
const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
|
|
1024
|
+
const data = await fetchJson(`${url}/languages`);
|
|
1025
|
+
return data.languages || [];
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Fetch loaded TTS model versions from the server.
|
|
1029
|
+
* No authentication required.
|
|
1030
|
+
*/
|
|
1031
|
+
static async fetchModels(baseUrl) {
|
|
1032
|
+
const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
|
|
1033
|
+
const data = await fetchJson(`${url}/models`);
|
|
1034
|
+
return data.models || [];
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* Fetch server configuration (limits and defaults).
|
|
1038
|
+
* No authentication required.
|
|
1039
|
+
*/
|
|
1040
|
+
static async fetchConfig(baseUrl) {
|
|
1041
|
+
const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
|
|
1042
|
+
return fetchJson(`${url}/config`);
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Fetch rich runtime status from the server.
|
|
1046
|
+
* No authentication required.
|
|
1047
|
+
*/
|
|
1048
|
+
static async fetchStatus(baseUrl) {
|
|
1049
|
+
const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
|
|
1050
|
+
return fetchJson(`${url}/status`);
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* Fetch health/liveness status from the server.
|
|
1054
|
+
* No authentication required.
|
|
1055
|
+
*/
|
|
1056
|
+
static async fetchHealth(baseUrl) {
|
|
1057
|
+
const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
|
|
1058
|
+
return fetchJson(`${url}/health`);
|
|
1059
|
+
}
|
|
757
1060
|
/**
|
|
758
1061
|
* Update the system prompt mid-conversation
|
|
759
1062
|
*/
|
|
760
1063
|
updatePrompt(newPrompt) {
|
|
761
1064
|
this.prompt = newPrompt;
|
|
762
|
-
if (this.ws && this.isConnected) {
|
|
1065
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.isConnected) {
|
|
763
1066
|
try {
|
|
764
1067
|
this.ws.send(JSON.stringify({ type: "prompt", data: newPrompt }));
|
|
765
1068
|
console.log(`\u2699\uFE0F Updated prompt: ${newPrompt.substring(0, 50)}...`);
|
|
766
1069
|
} catch (error) {
|
|
767
|
-
|
|
1070
|
+
const err = new LokutorError("internal.error", "Failed to update prompt", { original: error });
|
|
1071
|
+
if (this.onError) this.onError(err);
|
|
1072
|
+
console.error("Error updating prompt:", err.message);
|
|
768
1073
|
}
|
|
769
1074
|
} else {
|
|
770
1075
|
console.warn("Not connected - prompt will be updated on next connection");
|
|
@@ -827,37 +1132,52 @@ var TTSClient = class {
|
|
|
827
1132
|
visemes: options.visemes || false
|
|
828
1133
|
};
|
|
829
1134
|
ws.send(JSON.stringify(req));
|
|
830
|
-
startTime =
|
|
1135
|
+
startTime = nowMs();
|
|
831
1136
|
};
|
|
832
1137
|
ws.onmessage = async (event) => {
|
|
833
1138
|
refreshTimeout();
|
|
834
1139
|
if (event.data instanceof ArrayBuffer) {
|
|
835
1140
|
if (!firstByteReceived) {
|
|
836
|
-
const ttfb =
|
|
1141
|
+
const ttfb = nowMs() - startTime;
|
|
837
1142
|
if (options.onTTFB) options.onTTFB(ttfb);
|
|
838
1143
|
firstByteReceived = true;
|
|
839
1144
|
}
|
|
840
1145
|
if (options.onAudio) options.onAudio(new Uint8Array(event.data));
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
const text = event.data.toString();
|
|
1149
|
+
if (text === "EOS") {
|
|
1150
|
+
if (activityTimeout) clearTimeout(activityTimeout);
|
|
1151
|
+
ws.close();
|
|
1152
|
+
resolve();
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
try {
|
|
1156
|
+
const msg = JSON.parse(text);
|
|
1157
|
+
if (msg.type === "audio" && msg.data) {
|
|
1158
|
+
const audioBuffer = base64ToUint8Array(msg.data);
|
|
1159
|
+
if (!firstByteReceived) {
|
|
1160
|
+
const ttfb = nowMs() - startTime;
|
|
1161
|
+
if (options.onTTFB) options.onTTFB(ttfb);
|
|
1162
|
+
firstByteReceived = true;
|
|
1163
|
+
}
|
|
1164
|
+
if (options.onAudio) options.onAudio(audioBuffer);
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
if (msg.type === "visemes" && Array.isArray(msg.data) && options.onVisemes) {
|
|
1168
|
+
options.onVisemes(normalizeVisemes(msg.data));
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
if (Array.isArray(msg) && options.onVisemes) {
|
|
1172
|
+
options.onVisemes(normalizeVisemes(msg));
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
if (msg.type === "eos") {
|
|
844
1176
|
if (activityTimeout) clearTimeout(activityTimeout);
|
|
845
1177
|
ws.close();
|
|
846
1178
|
resolve();
|
|
847
|
-
return;
|
|
848
|
-
}
|
|
849
|
-
try {
|
|
850
|
-
const msg = JSON.parse(text);
|
|
851
|
-
if (Array.isArray(msg) && options.onVisemes) {
|
|
852
|
-
options.onVisemes(msg);
|
|
853
|
-
}
|
|
854
|
-
if (msg.type === "eos") {
|
|
855
|
-
if (activityTimeout) clearTimeout(activityTimeout);
|
|
856
|
-
ws.close();
|
|
857
|
-
resolve();
|
|
858
|
-
}
|
|
859
|
-
} catch (e) {
|
|
860
1179
|
}
|
|
1180
|
+
} catch (e) {
|
|
861
1181
|
}
|
|
862
1182
|
};
|
|
863
1183
|
ws.onerror = (err) => {
|
|
@@ -886,11 +1206,587 @@ async function simpleTTS(options) {
|
|
|
886
1206
|
const client = new TTSClient({ apiKey: options.apiKey });
|
|
887
1207
|
return client.synthesize(options);
|
|
888
1208
|
}
|
|
1209
|
+
|
|
1210
|
+
// src/conversational-panel.ts
|
|
1211
|
+
var PANEL_CSS = (
|
|
1212
|
+
/*css*/
|
|
1213
|
+
`
|
|
1214
|
+
.cv-panel {
|
|
1215
|
+
position: relative;
|
|
1216
|
+
width: 100%;
|
|
1217
|
+
aspect-ratio: 16 / 9;
|
|
1218
|
+
max-height: 800px;
|
|
1219
|
+
display: flex;
|
|
1220
|
+
flex-direction: column;
|
|
1221
|
+
align-items: center;
|
|
1222
|
+
justify-content: center;
|
|
1223
|
+
padding: 2rem 0;
|
|
1224
|
+
margin: 0;
|
|
1225
|
+
background: var(--cv-bg, #0a0a0a);
|
|
1226
|
+
box-shadow: inset 0 10px 40px rgba(0, 0, 0, 0.1), inset 0 0 100px rgba(0, 0, 0, 0.05);
|
|
1227
|
+
border-radius: 40px;
|
|
1228
|
+
overflow: hidden;
|
|
1229
|
+
container-type: inline-size;
|
|
1230
|
+
}
|
|
1231
|
+
.cv-curtain {
|
|
1232
|
+
position: absolute;
|
|
1233
|
+
inset: 0;
|
|
1234
|
+
display: flex;
|
|
1235
|
+
flex-direction: column;
|
|
1236
|
+
align-items: center;
|
|
1237
|
+
justify-content: center;
|
|
1238
|
+
background: var(--cv-bg, #0a0a0a);
|
|
1239
|
+
z-index: 100;
|
|
1240
|
+
transition: transform 1s cubic-bezier(0.16, 1, 0.3, 1);
|
|
1241
|
+
}
|
|
1242
|
+
.cv-curtain .cv-curtain-bg {
|
|
1243
|
+
position: absolute;
|
|
1244
|
+
inset: 0;
|
|
1245
|
+
background: url('/background_gradient.jpeg') center / cover no-repeat;
|
|
1246
|
+
z-index: -1;
|
|
1247
|
+
}
|
|
1248
|
+
.cv-curtain .cv-curtain-overlay {
|
|
1249
|
+
position: absolute;
|
|
1250
|
+
inset: 0;
|
|
1251
|
+
background: var(--cv-accent);
|
|
1252
|
+
opacity: 0.35;
|
|
1253
|
+
z-index: -1;
|
|
1254
|
+
}
|
|
1255
|
+
.cv-curtain.is-up { transform: translateY(-100%); }
|
|
1256
|
+
.cv-curtain-content {
|
|
1257
|
+
display: flex;
|
|
1258
|
+
flex-direction: column;
|
|
1259
|
+
align-items: center;
|
|
1260
|
+
gap: clamp(0.75rem, 1.5cqi, 1.5rem);
|
|
1261
|
+
color: #fff;
|
|
1262
|
+
text-align: center;
|
|
1263
|
+
z-index: 2;
|
|
1264
|
+
padding: clamp(1rem, 2cqi, 2rem);
|
|
1265
|
+
}
|
|
1266
|
+
.cv-curtain-title {
|
|
1267
|
+
font-size: clamp(1.2rem, 4cqi, 2.5rem);
|
|
1268
|
+
font-weight: 800;
|
|
1269
|
+
letter-spacing: -0.03em;
|
|
1270
|
+
margin: 0;
|
|
1271
|
+
text-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
|
1272
|
+
}
|
|
1273
|
+
.cv-curtain-desc {
|
|
1274
|
+
font-size: clamp(0.75rem, 2cqi, 1.1rem);
|
|
1275
|
+
opacity: 0.8;
|
|
1276
|
+
max-width: 400px;
|
|
1277
|
+
margin: 0;
|
|
1278
|
+
line-height: 1.5;
|
|
1279
|
+
}
|
|
1280
|
+
.cv-curtain-btn {
|
|
1281
|
+
margin-top: 1rem;
|
|
1282
|
+
padding: clamp(0.6rem, 1.5cqi, 1rem) clamp(1.5rem, 3cqi, 2.5rem);
|
|
1283
|
+
border-radius: 100px;
|
|
1284
|
+
background: #fff;
|
|
1285
|
+
color: #000;
|
|
1286
|
+
border: none;
|
|
1287
|
+
font-weight: 700;
|
|
1288
|
+
font-size: clamp(0.8rem, 1.5cqi, 1rem);
|
|
1289
|
+
display: flex;
|
|
1290
|
+
align-items: center;
|
|
1291
|
+
gap: 0.75rem;
|
|
1292
|
+
cursor: pointer;
|
|
1293
|
+
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
1294
|
+
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
|
1295
|
+
}
|
|
1296
|
+
.cv-curtain-btn:hover {
|
|
1297
|
+
transform: scale(1.05);
|
|
1298
|
+
background: #f0f0f0;
|
|
1299
|
+
box-shadow: 0 15px 40px rgba(0,0,0,0.3);
|
|
1300
|
+
}
|
|
1301
|
+
.cv-curtain-btn svg { transition: transform 0.3s ease; }
|
|
1302
|
+
.cv-curtain-btn:hover svg { transform: translateX(4px); }
|
|
1303
|
+
.cv-header {
|
|
1304
|
+
width: 100%;
|
|
1305
|
+
display: flex;
|
|
1306
|
+
flex-direction: column;
|
|
1307
|
+
align-items: center;
|
|
1308
|
+
gap: 0.25rem;
|
|
1309
|
+
z-index: 20;
|
|
1310
|
+
margin-bottom: auto;
|
|
1311
|
+
}
|
|
1312
|
+
.cv-title {
|
|
1313
|
+
font-size: clamp(1rem, 2.5cqi, 1.75rem);
|
|
1314
|
+
font-weight: 700;
|
|
1315
|
+
color: #e0e0e0;
|
|
1316
|
+
display: flex;
|
|
1317
|
+
align-items: center;
|
|
1318
|
+
gap: 1rem;
|
|
1319
|
+
letter-spacing: -0.02em;
|
|
1320
|
+
}
|
|
1321
|
+
.cv-title .cv-timer {
|
|
1322
|
+
font-variant-numeric: tabular-nums;
|
|
1323
|
+
color: var(--cv-accent);
|
|
1324
|
+
font-weight: 400;
|
|
1325
|
+
opacity: 0.8;
|
|
1326
|
+
}
|
|
1327
|
+
.cv-visualizer-wrap {
|
|
1328
|
+
position: absolute;
|
|
1329
|
+
top: 50%;
|
|
1330
|
+
left: 50%;
|
|
1331
|
+
transform: translate(-50%, -50%);
|
|
1332
|
+
width: clamp(140px, 40cqi, 280px);
|
|
1333
|
+
height: clamp(140px, 40cqi, 280px);
|
|
1334
|
+
display: flex;
|
|
1335
|
+
align-items: center;
|
|
1336
|
+
justify-content: center;
|
|
1337
|
+
z-index: 10;
|
|
1338
|
+
transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
|
1339
|
+
}
|
|
1340
|
+
.cv-is-speaking .cv-visualizer-wrap {
|
|
1341
|
+
animation: cv-pulse 2.5s infinite ease-in-out;
|
|
1342
|
+
}
|
|
1343
|
+
.cv-is-thinking .cv-visualizer-wrap {
|
|
1344
|
+
opacity: 0.5;
|
|
1345
|
+
transform: translate(-50%, -50%) scale(0.9);
|
|
1346
|
+
}
|
|
1347
|
+
@keyframes cv-pulse {
|
|
1348
|
+
0%, 100% { transform: translate(-50%, -50%) scale(1); }
|
|
1349
|
+
50% { transform: translate(-50%, -50%) scale(1.05); }
|
|
1350
|
+
}
|
|
1351
|
+
.cv-canvas {
|
|
1352
|
+
width: 100% !important;
|
|
1353
|
+
height: 100% !important;
|
|
1354
|
+
position: relative;
|
|
1355
|
+
z-index: 0;
|
|
1356
|
+
}
|
|
1357
|
+
.cv-canvas {
|
|
1358
|
+
width: 100% !important;
|
|
1359
|
+
height: 100% !important;
|
|
1360
|
+
position: relative;
|
|
1361
|
+
z-index: 0;
|
|
1362
|
+
}
|
|
1363
|
+
.cv-controls {
|
|
1364
|
+
width: 100%;
|
|
1365
|
+
display: flex;
|
|
1366
|
+
flex-direction: column;
|
|
1367
|
+
align-items: center;
|
|
1368
|
+
gap: 1.5rem;
|
|
1369
|
+
padding-top: 1rem;
|
|
1370
|
+
margin-top: auto;
|
|
1371
|
+
z-index: 20;
|
|
1372
|
+
}
|
|
1373
|
+
.cv-pill {
|
|
1374
|
+
display: flex;
|
|
1375
|
+
align-items: center;
|
|
1376
|
+
gap: 0.75rem;
|
|
1377
|
+
background: rgba(255, 255, 255, 0.03);
|
|
1378
|
+
backdrop-filter: blur(30px);
|
|
1379
|
+
-webkit-backdrop-filter: blur(30px);
|
|
1380
|
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
1381
|
+
padding: 0.3rem 0.75rem;
|
|
1382
|
+
border-radius: 100px;
|
|
1383
|
+
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
|
|
1384
|
+
}
|
|
1385
|
+
.cv-btn {
|
|
1386
|
+
display: flex;
|
|
1387
|
+
align-items: center;
|
|
1388
|
+
gap: 0.5rem;
|
|
1389
|
+
background: transparent;
|
|
1390
|
+
border: none;
|
|
1391
|
+
color: rgba(255,255,255,0.55);
|
|
1392
|
+
font-weight: 600;
|
|
1393
|
+
font-size: 0.75rem;
|
|
1394
|
+
cursor: pointer;
|
|
1395
|
+
transition: all 0.2s ease;
|
|
1396
|
+
padding: 0.35rem 0.75rem;
|
|
1397
|
+
border-radius: 50px;
|
|
1398
|
+
}
|
|
1399
|
+
.cv-btn:hover { color: #fff; background: rgba(255,255,255,0.05); }
|
|
1400
|
+
.cv-btn--end { color: #ff4444; }
|
|
1401
|
+
.cv-btn--end .cv-btn-box {
|
|
1402
|
+
background: #ff4444;
|
|
1403
|
+
width: 8px;
|
|
1404
|
+
height: 8px;
|
|
1405
|
+
border-radius: 2px;
|
|
1406
|
+
}
|
|
1407
|
+
.cv-btn.is-muted { color: var(--cv-accent); }
|
|
1408
|
+
.cv-error {
|
|
1409
|
+
position: absolute;
|
|
1410
|
+
bottom: 1.25rem;
|
|
1411
|
+
left: 50%;
|
|
1412
|
+
transform: translateX(-50%);
|
|
1413
|
+
background: #0a0a0a;
|
|
1414
|
+
color: #e0e0e0;
|
|
1415
|
+
padding: 0.75rem 1.5rem;
|
|
1416
|
+
border-radius: 12px;
|
|
1417
|
+
font-size: 0.875rem;
|
|
1418
|
+
font-weight: 500;
|
|
1419
|
+
display: none;
|
|
1420
|
+
align-items: center;
|
|
1421
|
+
gap: 0.75rem;
|
|
1422
|
+
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
|
1423
|
+
z-index: 1000;
|
|
1424
|
+
border: 1px solid rgba(255, 255, 255, 0.06);
|
|
1425
|
+
backdrop-filter: blur(20px);
|
|
1426
|
+
}
|
|
1427
|
+
.cv-error.is-visible { display: flex; }
|
|
1428
|
+
.cv-error-icon { color: var(--cv-accent); flex-shrink: 0; }
|
|
1429
|
+
`
|
|
1430
|
+
);
|
|
1431
|
+
var styleInjected = false;
|
|
1432
|
+
function injectStyles() {
|
|
1433
|
+
if (styleInjected) return;
|
|
1434
|
+
const style = document.createElement("style");
|
|
1435
|
+
style.textContent = PANEL_CSS;
|
|
1436
|
+
document.head.appendChild(style);
|
|
1437
|
+
styleInjected = true;
|
|
1438
|
+
}
|
|
1439
|
+
var ConversationalPanel = class {
|
|
1440
|
+
cfg;
|
|
1441
|
+
container;
|
|
1442
|
+
agent = null;
|
|
1443
|
+
visualizer = null;
|
|
1444
|
+
timerTicker = null;
|
|
1445
|
+
connectingSound = null;
|
|
1446
|
+
connectingFadeTimer = null;
|
|
1447
|
+
isRunning = false;
|
|
1448
|
+
// Cached DOM refs
|
|
1449
|
+
el;
|
|
1450
|
+
curtain;
|
|
1451
|
+
curtainTitle;
|
|
1452
|
+
curtainDesc;
|
|
1453
|
+
startBtn;
|
|
1454
|
+
errorEl;
|
|
1455
|
+
errorText;
|
|
1456
|
+
timerEl;
|
|
1457
|
+
canvas;
|
|
1458
|
+
muteBtn;
|
|
1459
|
+
stopBtn;
|
|
1460
|
+
visualizerWrap;
|
|
1461
|
+
// Callbacks
|
|
1462
|
+
onTranscription;
|
|
1463
|
+
onResponse;
|
|
1464
|
+
onStart;
|
|
1465
|
+
onStop;
|
|
1466
|
+
onError;
|
|
1467
|
+
constructor(cfg) {
|
|
1468
|
+
this.cfg = cfg;
|
|
1469
|
+
this.container = cfg.container;
|
|
1470
|
+
injectStyles();
|
|
1471
|
+
this.buildDOM();
|
|
1472
|
+
}
|
|
1473
|
+
buildDOM() {
|
|
1474
|
+
const accent = this.cfg.accentColor || "#a25a6b";
|
|
1475
|
+
const bg = this.cfg.backgroundColor || "#0a0a0a";
|
|
1476
|
+
this.container.style.setProperty("--cv-accent", accent);
|
|
1477
|
+
this.container.style.setProperty("--cv-bg", bg);
|
|
1478
|
+
this.el = document.createElement("div");
|
|
1479
|
+
this.el.className = "cv-panel";
|
|
1480
|
+
this.el.innerHTML = `
|
|
1481
|
+
<div class="cv-curtain">
|
|
1482
|
+
<div class="cv-curtain-bg"></div>
|
|
1483
|
+
<div class="cv-curtain-overlay"></div>
|
|
1484
|
+
<div class="cv-curtain-content">
|
|
1485
|
+
<h3 class="cv-curtain-title">${this.esc(this.cfg.title)}</h3>
|
|
1486
|
+
<p class="cv-curtain-desc">${this.esc(this.cfg.description)}</p>
|
|
1487
|
+
<button class="cv-curtain-btn">
|
|
1488
|
+
<span>Start Conversation</span>
|
|
1489
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="20" height="20">
|
|
1490
|
+
<path d="M5 12h14M12 5l7 7-7 7"/>
|
|
1491
|
+
</svg>
|
|
1492
|
+
</button>
|
|
1493
|
+
</div>
|
|
1494
|
+
<div class="cv-error">
|
|
1495
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
1496
|
+
width="20" height="20" class="cv-error-icon">
|
|
1497
|
+
<circle cx="12" cy="12" r="10"></circle>
|
|
1498
|
+
<path d="M12 8v4"></path>
|
|
1499
|
+
<path d="M12 16h.01"></path>
|
|
1500
|
+
</svg>
|
|
1501
|
+
<span class="cv-error-text"></span>
|
|
1502
|
+
</div>
|
|
1503
|
+
</div>
|
|
1504
|
+
<div class="cv-header">
|
|
1505
|
+
<h2 class="cv-title">${this.esc(this.cfg.title)} <span class="cv-timer">00:00</span></h2>
|
|
1506
|
+
</div>
|
|
1507
|
+
<div class="cv-visualizer-wrap">
|
|
1508
|
+
<canvas class="cv-canvas"></canvas>
|
|
1509
|
+
</div>
|
|
1510
|
+
<div class="cv-controls">
|
|
1511
|
+
<div class="cv-pill">
|
|
1512
|
+
<button class="cv-btn cv-btn--mute">
|
|
1513
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="18" height="18">
|
|
1514
|
+
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
|
|
1515
|
+
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
|
|
1516
|
+
</svg>
|
|
1517
|
+
<span>Mute</span>
|
|
1518
|
+
</button>
|
|
1519
|
+
<button class="cv-btn cv-btn--end">
|
|
1520
|
+
<div class="cv-btn-box"></div>
|
|
1521
|
+
<span>End call</span>
|
|
1522
|
+
</button>
|
|
1523
|
+
</div>
|
|
1524
|
+
</div>
|
|
1525
|
+
`;
|
|
1526
|
+
this.container.appendChild(this.el);
|
|
1527
|
+
this.curtain = this.el.querySelector(".cv-curtain");
|
|
1528
|
+
if (this.cfg.curtainBgSrc) {
|
|
1529
|
+
this.curtain.style.backgroundImage = `url('${this.cfg.curtainBgSrc}')`;
|
|
1530
|
+
}
|
|
1531
|
+
this.curtainTitle = this.el.querySelector(".cv-curtain-title");
|
|
1532
|
+
this.curtainDesc = this.el.querySelector(".cv-curtain-desc");
|
|
1533
|
+
this.startBtn = this.el.querySelector(".cv-curtain-btn");
|
|
1534
|
+
this.errorEl = this.el.querySelector(".cv-error");
|
|
1535
|
+
this.errorText = this.el.querySelector(".cv-error-text");
|
|
1536
|
+
this.timerEl = this.el.querySelector(".cv-timer");
|
|
1537
|
+
this.canvas = this.el.querySelector(".cv-canvas");
|
|
1538
|
+
this.muteBtn = this.el.querySelector(".cv-btn--mute");
|
|
1539
|
+
this.stopBtn = this.el.querySelector(".cv-btn--end");
|
|
1540
|
+
this.visualizerWrap = this.el.querySelector(".cv-visualizer-wrap");
|
|
1541
|
+
this.startBtn.addEventListener("click", () => this.start());
|
|
1542
|
+
this.stopBtn.addEventListener("click", () => this.stop());
|
|
1543
|
+
this.muteBtn.addEventListener("click", () => this.toggleMute());
|
|
1544
|
+
}
|
|
1545
|
+
esc(s) {
|
|
1546
|
+
const d = document.createElement("div");
|
|
1547
|
+
d.textContent = s;
|
|
1548
|
+
return d.innerHTML;
|
|
1549
|
+
}
|
|
1550
|
+
/** Start the conversation (called when user clicks "Start Conversation" or externally) */
|
|
1551
|
+
async start() {
|
|
1552
|
+
if (this.isRunning) return;
|
|
1553
|
+
this.isRunning = true;
|
|
1554
|
+
this.startBtn.disabled = true;
|
|
1555
|
+
this.errorEl.classList.remove("is-visible");
|
|
1556
|
+
this.playConnectingSound();
|
|
1557
|
+
try {
|
|
1558
|
+
const ConvoAgent = this.cfg.ConvoAgent;
|
|
1559
|
+
const SphereVisualizer = this.cfg.SphereVisualizer;
|
|
1560
|
+
const visualizer = new SphereVisualizer(this.canvas);
|
|
1561
|
+
visualizer.resize(280, 280);
|
|
1562
|
+
this.visualizer = visualizer;
|
|
1563
|
+
if (this.cfg.accentColor && typeof visualizer.setAccentColor === "function") {
|
|
1564
|
+
const hex = this.cfg.accentColor;
|
|
1565
|
+
visualizer.setAccentColor(
|
|
1566
|
+
parseInt(hex.slice(1, 3), 16) / 255,
|
|
1567
|
+
parseInt(hex.slice(3, 5), 16) / 255,
|
|
1568
|
+
parseInt(hex.slice(5, 7), 16) / 255
|
|
1569
|
+
);
|
|
1570
|
+
}
|
|
1571
|
+
const agentHandle = new ConvoAgent({
|
|
1572
|
+
apiKey: this.cfg.apiKey,
|
|
1573
|
+
prompt: this.cfg.prompt,
|
|
1574
|
+
voice: this.cfg.voice || "M1",
|
|
1575
|
+
language: this.cfg.language || "en",
|
|
1576
|
+
onStatusChange: (status) => {
|
|
1577
|
+
this.el.classList.remove("cv-is-speaking", "cv-is-thinking");
|
|
1578
|
+
if (status === "speaking") this.el.classList.add("cv-is-speaking");
|
|
1579
|
+
else if (status === "thinking") this.el.classList.add("cv-is-thinking");
|
|
1580
|
+
},
|
|
1581
|
+
onTranscription: (text) => {
|
|
1582
|
+
this.onTranscription?.(text);
|
|
1583
|
+
},
|
|
1584
|
+
onResponse: (text) => {
|
|
1585
|
+
this.onResponse?.(text);
|
|
1586
|
+
},
|
|
1587
|
+
onError: (err) => {
|
|
1588
|
+
if (!this.isRunning) return;
|
|
1589
|
+
this.onError?.(err);
|
|
1590
|
+
this.stop();
|
|
1591
|
+
this.showError("Connection issue. Try again in a moment.");
|
|
1592
|
+
}
|
|
1593
|
+
});
|
|
1594
|
+
this.agent = agentHandle;
|
|
1595
|
+
visualizer.setAudioClient(agentHandle);
|
|
1596
|
+
this.curtain.classList.add("is-up");
|
|
1597
|
+
try {
|
|
1598
|
+
agentHandle.unlockAudioForMobile?.();
|
|
1599
|
+
} catch (_) {
|
|
1600
|
+
}
|
|
1601
|
+
const ok = await agentHandle.connect();
|
|
1602
|
+
if (ok) {
|
|
1603
|
+
this.fadeOutConnectingSound();
|
|
1604
|
+
const wrap = this.visualizerWrap;
|
|
1605
|
+
if (wrap) {
|
|
1606
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
1607
|
+
const w = Math.floor(wrap.clientWidth * dpr);
|
|
1608
|
+
const h = Math.floor(wrap.clientHeight * dpr);
|
|
1609
|
+
visualizer.resize(w, h);
|
|
1610
|
+
}
|
|
1611
|
+
visualizer.start();
|
|
1612
|
+
this.startTimer();
|
|
1613
|
+
this.onStart?.();
|
|
1614
|
+
if (window.lucide) window.lucide.createIcons();
|
|
1615
|
+
} else {
|
|
1616
|
+
this.fadeOutConnectingSound();
|
|
1617
|
+
this.playErrorTone();
|
|
1618
|
+
this.curtain.classList.remove("is-up");
|
|
1619
|
+
this.showError("Could not connect. Please try again.");
|
|
1620
|
+
this.isRunning = false;
|
|
1621
|
+
}
|
|
1622
|
+
} catch (err) {
|
|
1623
|
+
this.fadeOutConnectingSound();
|
|
1624
|
+
this.playErrorTone();
|
|
1625
|
+
console.error("ConversationalPanel start error:", err);
|
|
1626
|
+
this.stop();
|
|
1627
|
+
this.showError("Something went wrong. Please try again.");
|
|
1628
|
+
} finally {
|
|
1629
|
+
this.startBtn.disabled = false;
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
/** Stop / disconnect the conversation */
|
|
1633
|
+
stop() {
|
|
1634
|
+
if (!this.isRunning && !this.agent && !this.visualizer) return;
|
|
1635
|
+
this.isRunning = false;
|
|
1636
|
+
this.fadeOutConnectingSound();
|
|
1637
|
+
this.errorEl.classList.remove("is-visible");
|
|
1638
|
+
if (this.agent) {
|
|
1639
|
+
try {
|
|
1640
|
+
this.agent.disconnect();
|
|
1641
|
+
} catch (_) {
|
|
1642
|
+
}
|
|
1643
|
+
this.agent = null;
|
|
1644
|
+
}
|
|
1645
|
+
if (this.timerTicker) {
|
|
1646
|
+
clearInterval(this.timerTicker);
|
|
1647
|
+
this.timerTicker = null;
|
|
1648
|
+
}
|
|
1649
|
+
if (this.visualizer) {
|
|
1650
|
+
this.visualizer.stop();
|
|
1651
|
+
this.visualizer = null;
|
|
1652
|
+
}
|
|
1653
|
+
this.curtain.classList.remove("is-up");
|
|
1654
|
+
this.el.classList.remove("cv-is-speaking", "cv-is-thinking");
|
|
1655
|
+
this.muteBtn.classList.remove("is-muted");
|
|
1656
|
+
const span = this.muteBtn.querySelector("span");
|
|
1657
|
+
if (span) span.textContent = "Mute";
|
|
1658
|
+
this.onStop?.();
|
|
1659
|
+
this.startBtn.disabled = false;
|
|
1660
|
+
}
|
|
1661
|
+
/** Update accent color dynamically */
|
|
1662
|
+
setColor(color) {
|
|
1663
|
+
this.cfg.accentColor = color;
|
|
1664
|
+
this.container.style.setProperty("--cv-accent", color);
|
|
1665
|
+
if (this.visualizer && typeof this.visualizer.setAccentColor === "function") {
|
|
1666
|
+
const r = parseInt(color.slice(1, 3), 16) / 255;
|
|
1667
|
+
const g = parseInt(color.slice(3, 5), 16) / 255;
|
|
1668
|
+
const b = parseInt(color.slice(5, 7), 16) / 255;
|
|
1669
|
+
this.visualizer.setAccentColor(r, g, b);
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
/** Update title text */
|
|
1673
|
+
setTitle(title) {
|
|
1674
|
+
this.cfg.title = title;
|
|
1675
|
+
this.curtainTitle.textContent = title;
|
|
1676
|
+
const h2 = this.el.querySelector(".cv-title");
|
|
1677
|
+
if (h2) h2.innerHTML = `${this.esc(title)} <span class="cv-timer">${this.timerEl?.textContent || "00:00"}</span>`;
|
|
1678
|
+
}
|
|
1679
|
+
/** Update description text */
|
|
1680
|
+
setDescription(desc) {
|
|
1681
|
+
this.cfg.description = desc;
|
|
1682
|
+
this.curtainDesc.textContent = desc;
|
|
1683
|
+
}
|
|
1684
|
+
/** Update prompt (only takes effect on next start()) */
|
|
1685
|
+
setPrompt(prompt) {
|
|
1686
|
+
this.cfg.prompt = prompt;
|
|
1687
|
+
}
|
|
1688
|
+
/** Show an error message */
|
|
1689
|
+
showError(text) {
|
|
1690
|
+
this.errorText.textContent = text;
|
|
1691
|
+
this.errorEl.classList.add("is-visible");
|
|
1692
|
+
setTimeout(() => {
|
|
1693
|
+
this.errorEl.classList.remove("is-visible");
|
|
1694
|
+
}, 5e3);
|
|
1695
|
+
}
|
|
1696
|
+
/** Destroy the component, removing all DOM and stopping any active session */
|
|
1697
|
+
destroy() {
|
|
1698
|
+
this.stop();
|
|
1699
|
+
this.el.remove();
|
|
1700
|
+
}
|
|
1701
|
+
// ─── internal helpers ────────────────────────────────────
|
|
1702
|
+
startTimer() {
|
|
1703
|
+
let elapsed = 0;
|
|
1704
|
+
this.timerEl.textContent = "00:00";
|
|
1705
|
+
this.timerTicker = window.setInterval(() => {
|
|
1706
|
+
elapsed++;
|
|
1707
|
+
const m = Math.floor(elapsed / 60).toString().padStart(2, "0");
|
|
1708
|
+
const s = (elapsed % 60).toString().padStart(2, "0");
|
|
1709
|
+
this.timerEl.textContent = `${m}:${s}`;
|
|
1710
|
+
}, 1e3);
|
|
1711
|
+
}
|
|
1712
|
+
toggleMute() {
|
|
1713
|
+
if (!this.agent) return;
|
|
1714
|
+
try {
|
|
1715
|
+
const muted = this.agent.toggleMute();
|
|
1716
|
+
this.muteBtn.classList.toggle("is-muted", muted);
|
|
1717
|
+
const span = this.muteBtn.querySelector("span");
|
|
1718
|
+
if (span) span.textContent = muted ? "Unmute" : "Mute";
|
|
1719
|
+
if (muted) {
|
|
1720
|
+
this.agent.pauseAudio?.();
|
|
1721
|
+
this.visualizer?.setDeactivated?.(true);
|
|
1722
|
+
} else {
|
|
1723
|
+
this.agent.resumeAudio?.();
|
|
1724
|
+
this.visualizer?.setDeactivated?.(false);
|
|
1725
|
+
}
|
|
1726
|
+
} catch (_) {
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
playConnectingSound() {
|
|
1730
|
+
this.fadeOutConnectingSound();
|
|
1731
|
+
const src = this.cfg.connectingSoundSrc || "/connecting.mp3";
|
|
1732
|
+
this.connectingSound = new Audio(src);
|
|
1733
|
+
this.connectingSound.volume = 0.6;
|
|
1734
|
+
this.connectingSound.play().catch(() => {
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
fadeOutConnectingSound() {
|
|
1738
|
+
if (this.connectingFadeTimer) {
|
|
1739
|
+
clearInterval(this.connectingFadeTimer);
|
|
1740
|
+
this.connectingFadeTimer = null;
|
|
1741
|
+
}
|
|
1742
|
+
if (!this.connectingSound) return;
|
|
1743
|
+
const startVol = this.connectingSound.volume;
|
|
1744
|
+
const steps = 15;
|
|
1745
|
+
let step = 0;
|
|
1746
|
+
this.connectingFadeTimer = window.setInterval(() => {
|
|
1747
|
+
step++;
|
|
1748
|
+
const progress = step / steps;
|
|
1749
|
+
if (this.connectingSound) this.connectingSound.volume = startVol * Math.max(0, 1 - progress);
|
|
1750
|
+
if (step >= steps) {
|
|
1751
|
+
if (this.connectingFadeTimer) {
|
|
1752
|
+
clearInterval(this.connectingFadeTimer);
|
|
1753
|
+
this.connectingFadeTimer = null;
|
|
1754
|
+
}
|
|
1755
|
+
if (this.connectingSound) {
|
|
1756
|
+
this.connectingSound.pause();
|
|
1757
|
+
this.connectingSound.currentTime = 0;
|
|
1758
|
+
this.connectingSound = null;
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
}, 100);
|
|
1762
|
+
}
|
|
1763
|
+
playErrorTone() {
|
|
1764
|
+
try {
|
|
1765
|
+
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
1766
|
+
const now = ctx.currentTime;
|
|
1767
|
+
[0, 0.3].forEach((offset, i) => {
|
|
1768
|
+
const osc = ctx.createOscillator();
|
|
1769
|
+
const gain = ctx.createGain();
|
|
1770
|
+
osc.connect(gain);
|
|
1771
|
+
gain.connect(ctx.destination);
|
|
1772
|
+
osc.type = "sine";
|
|
1773
|
+
osc.frequency.setValueAtTime(600 - i * 200, now + offset);
|
|
1774
|
+
gain.gain.setValueAtTime(0.25, now + offset);
|
|
1775
|
+
gain.gain.exponentialRampToValueAtTime(1e-3, now + offset + 0.35);
|
|
1776
|
+
osc.start(now + offset);
|
|
1777
|
+
osc.stop(now + offset + 0.4);
|
|
1778
|
+
});
|
|
1779
|
+
} catch (_) {
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
};
|
|
889
1783
|
export {
|
|
890
1784
|
AUDIO_CONFIG,
|
|
891
1785
|
BrowserAudioManager,
|
|
1786
|
+
ConversationalPanel,
|
|
892
1787
|
DEFAULT_URLS,
|
|
893
1788
|
Language,
|
|
1789
|
+
LokutorError,
|
|
894
1790
|
StreamResampler,
|
|
895
1791
|
TTSClient,
|
|
896
1792
|
VoiceAgentClient,
|
|
@@ -899,6 +1795,7 @@ export {
|
|
|
899
1795
|
bytesToPcm16,
|
|
900
1796
|
calculateRMS,
|
|
901
1797
|
float32ToPcm16,
|
|
1798
|
+
isRetryable,
|
|
902
1799
|
normalizeAudio,
|
|
903
1800
|
pcm16ToBytes,
|
|
904
1801
|
pcm16ToFloat32,
|