client sdk

This commit is contained in:
valenti
2026-10-21 17:56:04 +01:00
parent 1cafe3dd3c
commit d6bc2437c9
237 changed files with 5227 additions and 1019 deletions
+158
View File
@@ -0,0 +1,158 @@
# audio-agent-client — JavaScript SDK
Client SDK for the `audio-agent-engine` engine. A single ESM file, zero
dependencies, no build step: copy `audio-agent-client.js` wherever you want
(another server, a CDN, your own project) and import it. The engine also serves
it at `GET /sdk/audio-agent-client.js`.
Files: [`audio-agent-client.js`](audio-agent-client.js) ·
[`audio-agent-client.d.ts`](audio-agent-client.d.ts) ·
[`demo.html`](demo.html).
---
## Demo
[`demo.html`](demo.html) is a complete client built on the SDK:
connect/disconnect, session state, streaming agent replies with word-level
*karaoke* highlighting, chat bubbles, inline chips for `cue` / `tool_call` /
`audio_source`, a barge-in counter, a microphone toggle with a level meter, a
text-turn input, an external audio source (play/stop/volume), a **System prompt**
panel (shows the server-assigned session id and lets you reload / override /
clear the session's LLM prompt over REST), automatic phone-number authentication
(read from `?phoneNumber=…` in the page URL at session start, via the engine's
`/api/auth/phone-login` proxy; when a live session id is present the engine also
stores a sanitized user profile as hidden LLM context), a default runtime tool
set installed via `POST /api/tools` and a raw event log. A tool definition may
carry an extra `audio: true` flag (the backend ignores it): when the model calls
such a tool, the demo rebuilds the call's URL, puts it in the external audio
source field and starts playback after a fixed post-`assistant_end` delay, so
media results (e.g. `get_today_podcast_mp3`) do not overlap the agent's spoken
reply while still autoplaying reliably. When the model emits a
`<chat_markdown>...</chat_markdown>` block, the demo renders that document as
formatted Markdown in chat while keeping it out of TTS.
The engine serves it at `GET /sdk/demo.html`; you can also copy it elsewhere (it
imports `./audio-agent-client.js` relatively). Opened from `file://` it works but
without a microphone: it warns via a banner (a *secure context* is required).
## Why
The client is **decoupled** from the engine: the engine stays put, the UI can
live on another origin/server. The SDK wraps the protocol (WebSocket + REST) and
the PCM audio so you don't reimplement framing, sampling and events.
## Install
Nothing to install. Copy the file and import it, or load it from the engine:
```js
import { AudioSession, AudioAgentRest } from 'http://localhost:8787/sdk/audio-agent-client.js';
```
If the client is on **another origin**, enable CORS on the engine:
```dotenv
# engine .env
CORS_ALLOW_ORIGIN=https://your-app.example # or * for any origin
```
The WebSocket (`/ws`) needs **no** CORS (the handshake is not a CORS request).
For the microphone the browser requires a *secure context*: `https://` or
`localhost`.
## Quick start (browser)
```js
import { AudioSession } from './audio-agent-client.js';
const session = new AudioSession({ baseUrl: 'https://engine.example.com:8787' });
session.on('user', (e) => console.log('user:', e.text));
session.on('assistant_delta', (e) => (bubble.textContent += e.text));
session.on('error', (e) => console.error(e));
await session.start(); // connect + microphone + playback
session.sendUserText('hello'); // text turn (LLM → TTS)
session.interrupt(); // barge-in
await session.stop();
```
## API
### `new AudioAgentClient(options)`
| Option | Default | Description |
|---|---|---|
| `url` | — | Full `ws://` / `wss://` URL. Wins over `baseUrl`. |
| `baseUrl` | — | Engine origin (`http://host:port`); `/ws` is appended. |
| `path` | `/ws` | Path appended to `baseUrl`. |
| `autoReconnect` | `false` | Reconnect after an unexpected close. |
| `reconnectMinDelayMs` / `reconnectMaxDelayMs` | `500` / `8000` | Exponential backoff. |
| `WebSocket` | global | WebSocket implementation (Node < 22: pass the `ws` one). |
Methods: `connect()`, `close()`, `sendAudio(chunk)`, `sendUserText(text)`,
`interrupt()`, `playAudioSource(url)`, `stopAudioSource()`,
`setAudioSourceVolume(gain)`, `setReference(path, text?)`, `clearReference()`,
`speak(text)`, `sendText(text)`, `sendControl(obj)`.
Properties: `state` (`idle`/`connecting`/`open`/`closed`), `connected`.
`sendAudio` accepts `Int16Array`, `Float32Array` (normalized -1..1),
`ArrayBuffer`, a view, or `number[]`. The engine expects **16-bit LE mono PCM at
44.1 kHz**; sending **882-sample** frames (20 ms) keeps the cadence.
Events: `open`, `close`, `state`, `reconnecting`, plus one event per server
`type` — `session`, `cues`, `user`, `assistant_delta`, `assistant_markdown`,
`assistant_end`, `cue`, `barge_in`, `tool_call`, `audio_source`, `error` — and:
- `audio` → `{ samples, buffer, sampleRate }` with the decoded PCM frames;
- `event` → any raw JSON message;
- `error` → either a transport error `{ error, event, fatal }` or the server
event `{ type: 'error', text }`.
The `session` event (`{ type: 'session', id }`) is sent once at connect time and
carries the server-assigned session id. It is the key used by the REST prompt
endpoints, so capture it to read or override the system prompt for your session.
Every `on()` returns an unsubscribe function.
### `new AudioAgentRest(baseUrl, options?)`
| Method | Endpoint |
|---|---|
| `health()` | `GET /health` |
| `getTools()` | `GET /api/tools` |
| `setTools(tools)` | `POST /api/tools` |
| `getPrompt(sessionId?)` | `GET /api/prompt` |
| `setPrompt(prompt, sessionId?)` | `POST /api/prompt` |
| `phoneLogin(phoneNumber, sessionId?)` | `POST /api/auth/phone-login` |
| `getCustomVoicePhrases()` | `GET /api/customvoice/phrases` |
| `setCustomVoice(lang, audio, text?)` | `POST /api/customvoice` |
| `clearCustomVoice()` | `POST /api/customvoice/clear` |
| `transcribe(audio)` | `POST /api/transcribe` |
| `openapi()` | `GET /api-docs/openapi.json` |
On a non-2xx response the thrown error carries `status` and `body`.
### Audio helpers (browser only)
- **`MicCapture`** — microphone → `frame` events (`Int16Array`, 882 samples).
`await start()`, `stop()`.
- **`PcmPlayer`** — jitter-buffered playback (`bufferMs`, default 150).
`push(chunk)`, `flush()` (barge-in), `setGain()`, `close()`.
- **`AudioSession`** — wires client + mic + player and re-emits client events.
`await start({ microphone = true })`, `stop()`, `sendUserText()`, `interrupt()`.
### PCM utilities
`int16ArrayFromBytes(buffer)`, `float32ToInt16(floats)`,
`int16ToFloat32(ints)`, `toInt16(chunk)` and the `AUDIO` constant
(`sampleRate: 44100`, `frameSamples: 882`, `frameBytes: 1764`).
## Common pitfalls
- **Microphone denied** → the page is not a *secure context*: use `https://` or
`localhost`.
- **`Failed to fetch` on REST from another origin** → set `CORS_ALLOW_ORIGIN` on
the engine.
- **No playback** → the `AudioContext` starts suspended until a user gesture:
call `start()` / `player.resume()` from a click.
- **Node: `no WebSocket implementation`** → Node < 22: pass the `ws` `WebSocket`.
+336
View File
@@ -0,0 +1,336 @@
/**
* Type declarations for `audio-agent-client.js`.
*
* The SDK is plain ESM with no build step; this file exists so TypeScript
* consumers get autocompletion and type checking. Keep it in sync with the JS.
*/
export interface AudioFormat {
readonly sampleRate: 44100;
readonly frameSamples: 882;
readonly frameBytes: 1764;
readonly sttSampleRate: 16000;
}
export const AUDIO: AudioFormat;
export const CLIENT_EVENTS: readonly string[];
/** Union accepted by every audio-sending helper. */
export type AudioChunk =
| Int16Array
| Float32Array
| ArrayBuffer
| ArrayBufferView
| number[];
export function int16ArrayFromBytes(buffer: ArrayBuffer | ArrayBufferView): Int16Array;
export function float32ToInt16(floats: Float32Array | number[]): Int16Array;
export function int16ToFloat32(ints: Int16Array): Float32Array;
export function toInt16(chunk: AudioChunk): Int16Array;
/** Any object with a `type` discriminator, as emitted by the engine. */
export interface ServerEvent {
type: string;
[key: string]: unknown;
}
/** Server-assigned session id, sent once at connect time. */
export interface SessionEvent extends ServerEvent {
type: 'session';
id: string;
}
export interface CuesEvent extends ServerEvent {
type: 'cues';
lang: string;
config: Record<string, unknown>;
}
export interface UserEvent extends ServerEvent {
type: 'user';
text: string;
}
export interface AssistantDeltaEvent extends ServerEvent {
type: 'assistant_delta';
text: string;
}
export interface AssistantMarkdownEvent extends ServerEvent {
type: 'assistant_markdown';
text: string;
}
export interface AssistantEndEvent extends ServerEvent {
type: 'assistant_end';
}
export interface CueEvent extends ServerEvent {
type: 'cue';
kind: 'filler' | 'start_filler' | 'backchannel';
name: string;
category?: string;
pause_ms?: number;
}
export interface BargeInEvent extends ServerEvent {
type: 'barge_in';
source: string;
prob?: number;
}
export interface ToolCallEvent extends ServerEvent {
type: 'tool_call';
name: string;
arguments: unknown;
}
export interface AudioSourceEvent extends ServerEvent {
type: 'audio_source';
state: 'loading' | 'playing' | 'stopped' | 'error';
url: string;
text?: string;
}
export interface ErrorEvent extends ServerEvent {
type: 'error';
text: string;
}
/** Payload of the local `audio` event: one decoded PCM frame. */
export interface AudioFrame {
samples: Int16Array;
buffer: ArrayBuffer;
sampleRate: number;
}
/** Payload of the local `close` event. */
export interface CloseEvent {
code: number;
reason: string;
wasClean: boolean;
}
/** Payload for connection-level failures (as opposed to a server `error` event). */
export interface TransportError {
error: Error;
event?: unknown;
fatal: boolean;
}
export interface AudioAgentClientOptions {
/** Full `ws://`/`wss://` URL; wins over `baseUrl`. */
url?: string;
/** Engine origin, e.g. `http://localhost:8787`; `/ws` is appended. */
baseUrl?: string;
/** Path appended to `baseUrl` (default `/ws`). */
path?: string;
/** Reconnect automatically after an unexpected close (default `false`). */
autoReconnect?: boolean;
reconnectMinDelayMs?: number;
reconnectMaxDelayMs?: number;
/** WebSocket implementation to use (required on Node < 22, e.g. the `ws` package). */
WebSocket?: typeof WebSocket;
}
export type ConnectionState = 'idle' | 'connecting' | 'open' | 'closed';
export class AudioAgentClient {
constructor(options?: AudioAgentClientOptions);
readonly url: string;
autoReconnect: boolean;
reconnectMinDelayMs: number;
reconnectMaxDelayMs: number;
readonly state: ConnectionState;
readonly connected: boolean;
connect(): Promise<this>;
close(code?: number, reason?: string): void;
sendAudio(chunk: AudioChunk): boolean;
sendText(text: string): boolean;
sendControl(message: Record<string, unknown>): boolean;
sendUserText(text: string): boolean;
interrupt(): boolean;
playAudioSource(url: string): boolean;
stopAudioSource(): boolean;
setAudioSourceVolume(gain: number): boolean;
setReference(path: string, text?: string): boolean;
clearReference(): boolean;
speak(text: string): boolean;
on(type: 'audio', handler: (frame: AudioFrame) => void): () => void;
on(type: 'open', handler: (info: { url: string }) => void): () => void;
on(type: 'close', handler: (info: CloseEvent) => void): () => void;
on(type: 'state', handler: (state: ConnectionState) => void): () => void;
on(
type: 'reconnecting',
handler: (info: { attempt: number; delay: number }) => void,
): () => void;
on(type: 'error', handler: (payload: TransportError | ErrorEvent) => void): () => void;
on(type: 'event' | 'unknown', handler: (event: ServerEvent) => void): () => void;
on(type: string, handler: (payload: any) => void): () => void;
once(type: string, handler: (payload: any) => void): () => void;
off(type: string, handler: (payload: any) => void): void;
emit(type: string, payload?: unknown): void;
}
export interface AudioAgentRestOptions {
fetch?: typeof fetch;
headers?: Record<string, string>;
}
export interface ToolsResponse {
ok: boolean;
count: number;
source: 'file' | 'request' | 'none';
tools: object[];
}
export interface PhrasesResponse {
default: string;
languages: Array<{ code: string; label: string; phrase: string }>;
}
export interface SetVoiceResponse {
ok: boolean;
lang: string;
audio: string;
phrase: string;
}
export interface TranscribeResponse {
ok: boolean;
text: string;
duration_ms: number;
tokens: number;
}
/** Where the effective system prompt comes from (decreasing precedence). */
export type PromptSource = 'session' | 'global' | 'env' | 'default' | 'none';
/** Response of `GET`/`POST /api/prompt`. */
export interface PromptResponse {
ok: boolean;
session_id: string | null;
source: PromptSource;
prompt: string;
has_override: boolean;
direct_answers_applied: boolean;
}
/** Response of `POST /api/auth/phone-login` (upstream AquaChile payload). */
export interface PhoneLoginResponse {
sessionToken?: string;
user?: Record<string, any>;
[key: string]: any;
}
/** Error thrown by {@link AudioAgentRest}, carrying the HTTP status and body. */
export interface RestError extends Error {
status: number;
body: unknown;
}
export class AudioAgentRest {
constructor(baseUrl: string, options?: AudioAgentRestOptions);
readonly baseUrl: string;
health(): Promise<string>;
getTools(): Promise<ToolsResponse>;
setTools(tools: object[]): Promise<ToolsResponse>;
getPrompt(sessionId?: string): Promise<PromptResponse>;
setPrompt(prompt: string | null, sessionId?: string | null): Promise<PromptResponse>;
phoneLogin(phoneNumber: string, sessionId?: string | null): Promise<PhoneLoginResponse>;
getCustomVoicePhrases(): Promise<PhrasesResponse>;
setCustomVoice(
lang: string,
audio: Blob | ArrayBuffer | ArrayBufferView,
text?: string,
): Promise<SetVoiceResponse>;
clearCustomVoice(): Promise<{ ok: boolean }>;
transcribe(audio: Blob | ArrayBuffer | ArrayBufferView): Promise<TranscribeResponse>;
openapi(): Promise<object>;
}
export interface MicCaptureOptions {
sampleRate?: number;
frameSamples?: number;
constraints?: MediaStreamConstraints;
}
export class MicCapture {
constructor(options?: MicCaptureOptions);
readonly sampleRate: number;
readonly frameSamples: number;
readonly contextSampleRate: number | null;
start(): Promise<void>;
stop(): void;
on(type: 'frame', handler: (samples: Int16Array) => void): () => void;
on(type: 'start', handler: (info: { sampleRate: number }) => void): () => void;
on(type: 'stop', handler: () => void): () => void;
on(type: string, handler: (payload: any) => void): () => void;
once(type: string, handler: (payload: any) => void): () => void;
off(type: string, handler: (payload: any) => void): void;
}
export interface PcmPlayerOptions {
sampleRate?: number;
bufferMs?: number;
gain?: number;
}
export class PcmPlayer {
constructor(options?: PcmPlayerOptions);
readonly sampleRate: number;
bufferMs: number;
gain: number;
resume(): Promise<void>;
push(chunk: AudioChunk): void;
setGain(value: number): void;
flush(): void;
close(): void;
isIdle(): boolean;
on(type: 'chunk', handler: (info: { startAt: number; samples: number }) => void): () => void;
on(type: 'flush', handler: () => void): () => void;
on(type: 'idle', handler: () => void): () => void;
on(type: string, handler: (payload: any) => void): () => void;
once(type: string, handler: (payload: any) => void): () => void;
off(type: string, handler: (payload: any) => void): void;
}
export interface AudioSessionOptions extends AudioAgentClientOptions {
client?: AudioAgentClient;
player?: PcmPlayer;
/** Playback jitter buffer in ms (default 150). */
bufferMs?: number;
/** Stream the microphone automatically (default `true`). */
forwardMic?: boolean;
}
export class AudioSession {
constructor(options?: AudioSessionOptions);
readonly client: AudioAgentClient;
readonly player: PcmPlayer;
readonly mic: MicCapture | null;
forwardMic: boolean;
start(options?: { microphone?: boolean }): Promise<void>;
stop(): Promise<void>;
sendUserText(text: string): boolean;
interrupt(): boolean;
on(type: 'audio' | 'session' | 'assistant_delta' | 'assistant_markdown' | 'cues' | 'user' | 'cue' | 'barge_in'
| 'tool_call' | 'audio_source' | 'error' | 'assistant_end' | 'open' | 'close'
| 'state' | 'reconnecting' | string, handler: (payload: any) => void): () => void;
once(type: string, handler: (payload: any) => void): () => void;
off(type: string, handler: (payload: any) => void): void;
emit(type: string, payload?: unknown): void;
}
declare const _default: {
AUDIO: AudioFormat;
CLIENT_EVENTS: readonly string[];
int16ArrayFromBytes: typeof int16ArrayFromBytes;
float32ToInt16: typeof float32ToInt16;
int16ToFloat32: typeof int16ToFloat32;
toInt16: typeof toInt16;
AudioAgentClient: typeof AudioAgentClient;
AudioAgentRest: typeof AudioAgentRest;
MicCapture: typeof MicCapture;
PcmPlayer: typeof PcmPlayer;
AudioSession: typeof AudioSession;
};
export default _default;
File diff suppressed because it is too large Load Diff
+976
View File
@@ -0,0 +1,976 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>audio-agent — SDK demo</title>
<style>
:root {
--bg: #0b0f1a;
--panel: #141b2d;
--accent: #4f8cff;
--accent-dim: #2a4a8a;
--ok: #3ddc97;
--warn: #ffb454;
--err: #ff5470;
--text: #e6edf7;
--muted: #8a94a6;
--border: #243049;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
display: flex;
justify-content: center;
padding: 18px;
}
.app {
display: flex;
flex-direction: column;
width: 100%;
max-width: 900px;
height: calc(100vh - 36px);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
overflow: hidden;
}
header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
header h1 { font-size: 15px; margin: 0; white-space: nowrap; }
header h1 span { color: var(--muted); font-weight: 400; }
.status { display: inline-flex; align-items: center; gap: 7px; font-size: 12px; color: var(--muted); }
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); flex: none; }
.dot.on { background: var(--ok); box-shadow: 0 0 8px var(--ok); }
.dot.err { background: var(--err); box-shadow: 0 0 8px var(--err); }
.dot.warn { background: var(--warn); box-shadow: 0 0 8px var(--warn); }
/* Authenticated user name, shown in the header after phone-login. */
#userStatus {
display: none;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--text);
background: var(--accent-dim);
border: 1px solid var(--accent);
border-radius: 999px;
padding: 3px 11px;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#userStatus.show { display: inline-flex; }
#userStatus::before { content: "\1F464"; font-size: 12px; line-height: 1; }
#userStatus.bad { background: transparent; border-color: var(--err); color: var(--err); }
#userStatus.bad::before { content: "\26A0"; }
input[type="text"], input[type="url"], input[type="number"], textarea {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
padding: 8px 10px;
font-size: 13px;
font-family: inherit;
outline: none;
min-width: 0;
}
input:focus, textarea:focus { border-color: var(--accent); }
textarea { width: 100%; resize: vertical; min-height: 68px; line-height: 1.4; }
#promptPanel { padding: 0 16px 12px; display: flex; flex-direction: column; gap: 8px; }
#promptPanel .meta { font-size: 11.5px; color: var(--muted); }
#promptPanel .meta b { color: var(--text); font-weight: 600; }
button {
background: var(--accent-dim);
color: var(--text);
border: 1px solid var(--accent);
border-radius: 8px;
padding: 8px 13px;
font-size: 13px;
font-family: inherit;
cursor: pointer;
transition: all .15s;
white-space: nowrap;
}
button:hover:not(:disabled) { background: var(--accent); }
button:disabled { opacity: .4; cursor: not-allowed; }
button.ghost { background: transparent; border-color: var(--border); }
button.ghost:hover:not(:disabled) { background: var(--border); }
button.danger { border-color: var(--err); background: rgba(255,84,112,.15); }
button.danger:hover:not(:disabled) { background: var(--err); }
button.active { background: var(--ok); border-color: var(--ok); color: #06170f; }
.banner {
display: none;
margin: 10px 16px 0;
padding: 9px 12px;
font-size: 12.5px;
line-height: 1.4;
border: 1px solid var(--warn);
border-radius: 8px;
background: rgba(255,180,84,.08);
color: var(--warn);
}
.banner.show { display: block; }
#chat {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.hint { color: var(--muted); font-size: 13px; text-align: center; margin: auto; }
.msg { display: flex; flex-direction: column; gap: 4px; max-width: 82%; }
.msg .who { font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px; color: var(--muted); }
.msg .bubble {
padding: 9px 12px;
border-radius: 12px;
font-size: 14px;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
}
.msg.user { align-self: flex-end; align-items: flex-end; }
.msg.user .bubble { background: var(--accent-dim); border: 1px solid var(--accent); }
.msg.agent { align-self: flex-start; }
.msg.agent .bubble { background: var(--bg); border: 1px solid var(--border); }
.msg.error .bubble { border-color: var(--err); color: var(--err); }
.msg .kw { color: var(--muted); transition: color .18s ease; }
.msg .kw.done { color: var(--text); }
.msg .kw.now { color: var(--accent); text-shadow: 0 0 8px rgba(79,140,255,.55); }
.msg .bubble.markdown { white-space: normal; overflow-x: auto; }
.msg .bubble.markdown > :first-child { margin-top: 0; }
.msg .bubble.markdown > :last-child { margin-bottom: 0; }
.msg .bubble.markdown h1,
.msg .bubble.markdown h2,
.msg .bubble.markdown h3,
.msg .bubble.markdown h4 { margin: 0 0 10px; line-height: 1.25; }
.msg .bubble.markdown p,
.msg .bubble.markdown ul,
.msg .bubble.markdown ol,
.msg .bubble.markdown blockquote,
.msg .bubble.markdown pre,
.msg .bubble.markdown table { margin: 0 0 10px; }
.msg .bubble.markdown ul,
.msg .bubble.markdown ol { padding-left: 20px; }
.msg .bubble.markdown li + li { margin-top: 4px; }
.msg .bubble.markdown code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12.5px;
background: rgba(79,140,255,.10);
border: 1px solid var(--border);
border-radius: 6px;
padding: 1px 5px;
}
.msg .bubble.markdown pre {
background: #0a1222;
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 12px;
overflow-x: auto;
}
.msg .bubble.markdown pre code {
background: transparent;
border: 0;
padding: 0;
font-size: 12px;
}
.msg .bubble.markdown blockquote {
border-left: 3px solid var(--accent);
padding-left: 10px;
color: var(--muted);
}
.msg .bubble.markdown table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.msg .bubble.markdown th,
.msg .bubble.markdown td {
border: 1px solid var(--border);
padding: 6px 8px;
text-align: left;
vertical-align: top;
}
.msg .bubble.markdown th { color: var(--text); background: rgba(79,140,255,.10); }
.msg .bubble.markdown a { color: #8db6ff; }
/* Inline chips for cue / tool_call / audio_source events. */
.chips { display: flex; flex-wrap: wrap; gap: 6px; align-self: flex-start; }
.chip {
font-size: 11.5px;
color: var(--muted);
border: 1px solid var(--border);
border-radius: 999px;
padding: 3px 10px;
white-space: nowrap;
}
.chip b { color: var(--text); font-weight: 600; }
.chip.cue { border-color: var(--accent); color: var(--accent); }
.chip.tool { border-color: var(--ok); color: var(--ok); }
.chip.flash { animation: flash .5s ease; }
@keyframes flash { from { background: rgba(255,84,112,.35); } to { background: transparent; } }
footer { border-top: 1px solid var(--border); padding: 10px 16px; display: flex; flex-direction: column; gap: 9px; }
.row { display: flex; align-items: center; gap: 8px; }
.row.grow input[type="text"] { flex: 1; }
.meters { display: flex; align-items: center; gap: 12px; font-size: 12px; color: var(--muted); flex-wrap: wrap; }
.bargein { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--border); border-radius: 999px; padding: 4px 10px; transition: border-color .2s, color .2s, box-shadow .2s; }
.bargein.active { border-color: var(--err); color: var(--err); box-shadow: 0 0 12px -2px var(--err); }
.bargein b { color: var(--text); font-variant-numeric: tabular-nums; }
.mic { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--border); border-radius: 999px; padding: 4px 10px; }
.mic .bar { display: inline-flex; gap: 2px; align-items: flex-end; height: 12px; }
.mic .bar i { width: 3px; background: var(--muted); border-radius: 1px; height: 3px; transition: height .08s; }
.mic.on .bar i { background: var(--ok); }
details { border-top: 1px solid var(--border); }
details summary { cursor: pointer; padding: 8px 16px; font-size: 12px; color: var(--muted); user-select: none; }
#log { max-height: 190px; overflow-y: auto; padding: 0 16px 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; color: var(--muted); }
#log div { padding: 1px 0; white-space: pre-wrap; word-break: break-all; }
#log .l-user { color: var(--text); }
#log .l-cue { color: var(--accent); }
#log .l-tool { color: var(--ok); }
#log .l-err { color: var(--err); }
</style>
</head>
<body>
<div class="app">
<header>
<h1>audio-agent <span>· SDK demo</span></h1>
<span class="status"><i class="dot" id="dot"></i><span id="state">disconnected</span></span>
<span class="status" id="userStatus"></span>
<span style="flex:1"></span>
<input type="text" id="baseUrl" style="width: 230px" spellcheck="false" />
<button id="connectBtn">Connect</button>
</header>
<div class="banner" id="insecure">
The microphone requires a <b>secure context</b>: open this page over <code>https://</code> or
<code>localhost</code>, not from <code>file://</code>.
</div>
<div id="chat"><div class="hint" id="hint">Connect to start talking to the agent.</div></div>
<footer>
<div class="row grow">
<input type="text" id="textInput" placeholder="Type a message (text turn)…" autocomplete="off" />
<button id="sendBtn" disabled>Send</button>
</div>
<div class="row grow">
<input type="url" id="sourceUrl" placeholder="External audio source URL (radio / stream)…" style="flex:1" spellcheck="false" />
<button id="sourcePlay" class="ghost" disabled>Play</button>
<button id="sourceStop" class="ghost" disabled>Stop</button>
<input type="range" id="sourceVol" min="0" max="100" value="70" title="External source volume" disabled style="width:90px" />
</div>
<div class="meters">
<button id="micBtn" disabled>Microphone: OFF</button>
<span class="mic" id="micMeter"><span>mic</span><span class="bar"><i></i><i></i><i></i><i></i><i></i></span></span>
<button id="interruptBtn" class="danger" disabled>Interrupt</button>
<span class="bargein" id="bargein"><span>barge-in</span><b id="bargeCount">0</b></span>
<span style="flex:1"></span>
<label title="Local playback buffer" style="display:inline-flex;align-items:center;gap:6px">
buffer <input type="number" id="bufferMs" value="150" min="20" max="1000" step="10" style="width:70px" /> ms
</label>
</div>
</footer>
<details id="promptDetails">
<summary>System prompt (per session)</summary>
<div id="promptPanel">
<div class="meta">
session <b id="sessionId">—</b> · source <b id="promptSource">—</b> ·
override <b id="promptOverride">—</b>
</div>
<textarea id="promptText" spellcheck="false"
placeholder="Connect to load the session's system prompt…" disabled></textarea>
<div class="row">
<button id="promptLoad" class="ghost" disabled>Reload</button>
<button id="promptApply" disabled>Apply override</button>
<button id="promptClear" class="ghost" disabled>Clear override</button>
<span style="flex:1"></span>
</div>
</div>
</details>
<details>
<summary>Event log (raw SDK events)</summary>
<div id="log"></div>
</details>
</div>
<script type="module">
import { AudioSession, AudioAgentRest, MicCapture, AUDIO } from './audio-agent-client.js';
/* System prompt applied automatically to every new session (per-session
override via POST /api/prompt). Edit it freely, or clear the override from the
"System prompt" panel below. */
const SESSION_PROMPT = `You are Vocero, a friendly, proactive AI voice assistant for AquaChile, helping users understand operational data, KPIs, reports and alerts.
Always speak exclusively in Spanish, regardless of the user's language. Use natural, professional Chilean Spanish.
Keep spoken answers short, usually one to three sentences, without markdown, lists or unnecessary details.
Prioritize relevant information, interpret verified data and suggest actions when appropriate. Never invent facts or figures.
Maintain conversation context, especially when initiated from an alert or report. Provide deeper explanations only when requested.
Use the available tools immediately on the first relevant user request instead of replying generically.
If the user asks for today's podcast, the daily podcast, the MP3, the audio file, or asks to play or listen to the podcast, call get_today_podcast_mp3 right away.
If the user asks for today's report, the day report, the daily briefing, alerts, or operational highlights for a date, call get_public_day_report right away.
When a tool can provide the requested data or media, call it before answering. Do not ask the user to repeat the request just to trigger a tool.
If the user explicitly asks for a Markdown document, a report in Markdown, or a long structured written document, first give a very short spoken introduction outside the block, then put the full Markdown document inside <chat_markdown>...</chat_markdown>.
Only the Markdown document itself may appear inside <chat_markdown>...</chat_markdown>. Do not repeat the full document outside that block.
Answer directly without revealing reasoning or internal processes.`;
/* Runtime tools installed by the demo through `POST /api/tools`. The backend
executes them server-side; the browser only installs the definitions and
observes `tool_call` events over WebSocket. */
const DEMO_TOOLS = [
{
name: 'get_public_day_report',
description: 'Use this immediately when the user asks for a day report, daily briefing, alerts, operational highlights, or a report for a specific date. Returns the AquaChile public day briefing ordered by severity and recency.',
parameters: {
type: 'object',
properties: {
limit: {
type: 'integer',
description: 'Maximum number of insights to return after severity and recency ordering.',
minimum: 1,
maximum: 10,
default: 3,
},
date: {
type: 'string',
description: 'Exact report date in YYYY-MM-DD format. Omit it to use today or the latest earlier processed report date.',
},
},
},
url: 'https://aquachile.azurianlab.com/api/v1/day-report',
method: 'GET',
headers: {
accept: '*/*',
},
},
{
name: 'get_today_podcast_mp3',
description: 'Use this immediately when the user asks to listen to, play, open, or get today\'s AquaChile podcast or its MP3/audio file. Returns the MP3 file of the latest published daily AquaChile podcast episode.',
parameters: {
type: 'object',
properties: {
download: {
type: 'boolean',
description: 'When true (default) the MP3 is returned as a download attachment. When false it is served inline for playback.',
default: true,
},
},
},
url: 'https://aquachile.azurianlab.com/api/v1/podcast/today/mp3',
method: 'GET',
headers: {
accept: '*/*',
},
// `audio: true` marks a tool whose result is a media file. The backend
// ignores this extra field; the demo uses it to route the call's URL to the
// engine's external audio source and start playback.
audio: true,
},
];
/* Verbs whose arguments travel as query parameters (same rule the backend
applies), so the demo can rebuild the exact URL a tool call used. */
const QUERY_VERBS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE', 'DELETE']);
/** Effective URL of a tool call, mirroring the backend's argument placement. */
function toolUrl(def, args = {}) {
const url = new URL(def.url);
if (QUERY_VERBS.has((def.method || 'POST').toUpperCase())) {
for (const [k, v] of Object.entries(args || {})) {
if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
}
}
return url.toString();
}
/* Phone-login: the page reads `phoneNumber` from its URL at session start and
authenticates through the engine's server-side proxy (`POST
/api/auth/phone-login`), which forwards to the AquaChile auth API. The proxy
exists because that API blocks browser (CORS) calls from this origin. */
/* ------------------------------------------------------------------ helpers */
const $ = (id) => document.getElementById(id);
const chat = $('chat');
const hint = $('hint');
const logEl = $('log');
function log(kind, text) {
const line = document.createElement('div');
line.className = 'l-' + kind;
line.textContent = `${new Date().toLocaleTimeString()} ${text}`;
logEl.appendChild(line);
logEl.scrollTop = logEl.scrollHeight;
}
function setState(text, cls) {
$('state').textContent = text;
$('dot').className = 'dot' + (cls ? ' ' + cls : '');
}
function escapeHtml(s) {
return s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
}
/** Append a bubble to the chat (scrolls to the bottom). */
function addBubble(who, cls) {
hint.remove();
const wrap = document.createElement('div');
wrap.className = 'msg ' + cls;
const label = document.createElement('div');
label.className = 'who';
label.textContent = who;
const bubble = document.createElement('div');
bubble.className = 'bubble';
wrap.append(label, bubble);
chat.appendChild(wrap);
chat.scrollTop = chat.scrollHeight;
return bubble;
}
function addChip(cls, html) {
hint.remove();
let chips = chat.lastElementChild;
if (!chips || !chips.classList.contains('chips')) {
chips = document.createElement('div');
chips.className = 'chips';
chat.appendChild(chips);
}
const chip = document.createElement('span');
chip.className = 'chip ' + cls;
chip.innerHTML = html;
chips.appendChild(chip);
chat.scrollTop = chat.scrollHeight;
}
/* ------------------------------------------------------------ karaoke render */
// The engine streams the reply in `assistant_delta` chunks; the words of the
// newest chunk are highlighted until the next one arrives.
function renderKaraoke(bubble, fullText, latest) {
const words = fullText.split(/(\s+)/);
const latestWords = new Set(latest.trim().split(/\s+/).filter(Boolean));
bubble.innerHTML = words
.map((w) => {
if (!w.trim()) return escapeHtml(w);
const cls = latestWords.has(w) ? 'now' : 'done';
return `<span class="kw ${cls}">${escapeHtml(w)}</span>`;
})
.join('');
}
function renderInlineMarkdown(text) {
let html = escapeHtml(text);
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer noopener">$1</a>');
return html;
}
function markdownTableCells(line) {
return line.trim().replace(/^\||\|$/g, '').split('|').map((cell) => cell.trim());
}
function renderMarkdown(text) {
const lines = String(text || '').replace(/\r\n?/g, '\n').split('\n');
const html = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trim();
if (!trimmed) {
i++;
continue;
}
const fence = line.match(/^```(\w+)?\s*$/);
if (fence) {
const lang = fence[1] ? ` class="language-${escapeHtml(fence[1])}"` : '';
const body = [];
i++;
while (i < lines.length && !/^```/.test(lines[i])) {
body.push(lines[i]);
i++;
}
if (i < lines.length) i++;
html.push(`<pre><code${lang}>${escapeHtml(body.join('\n'))}</code></pre>`);
continue;
}
const heading = line.match(/^(#{1,4})\s+(.*)$/);
if (heading) {
const level = heading[1].length;
html.push(`<h${level}>${renderInlineMarkdown(heading[2].trim())}</h${level}>`);
i++;
continue;
}
if (trimmed.includes('|') && i + 1 < lines.length && /^[\s|:-]+$/.test(lines[i + 1].trim())) {
const head = markdownTableCells(line);
const rows = [];
i += 2;
while (i < lines.length && lines[i].includes('|') && lines[i].trim()) {
rows.push(markdownTableCells(lines[i]));
i++;
}
const thead = `<thead><tr>${head.map((cell) => `<th>${renderInlineMarkdown(cell)}</th>`).join('')}</tr></thead>`;
const tbody = rows.length
? `<tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${renderInlineMarkdown(cell)}</td>`).join('')}</tr>`).join('')}</tbody>`
: '';
html.push(`<table>${thead}${tbody}</table>`);
continue;
}
if (/^\s*>\s?/.test(line)) {
const quote = [];
while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
quote.push(lines[i].replace(/^\s*>\s?/, ''));
i++;
}
html.push(`<blockquote>${quote.map((part) => renderInlineMarkdown(part)).join('<br>')}</blockquote>`);
continue;
}
if (/^\s*[-*+]\s+/.test(line)) {
const items = [];
while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) {
items.push(lines[i].replace(/^\s*[-*+]\s+/, ''));
i++;
}
html.push(`<ul>${items.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join('')}</ul>`);
continue;
}
if (/^\s*\d+\.\s+/.test(line)) {
const items = [];
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
items.push(lines[i].replace(/^\s*\d+\.\s+/, ''));
i++;
}
html.push(`<ol>${items.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join('')}</ol>`);
continue;
}
const para = [];
while (
i < lines.length &&
lines[i].trim() &&
!/^```/.test(lines[i]) &&
!/^(#{1,4})\s+/.test(lines[i]) &&
!/^\s*>\s?/.test(lines[i]) &&
!/^\s*[-*+]\s+/.test(lines[i]) &&
!/^\s*\d+\.\s+/.test(lines[i]) &&
!(lines[i].includes('|') && i + 1 < lines.length && /^[\s|:-]+$/.test(lines[i + 1].trim()))
) {
para.push(lines[i].trim());
i++;
}
html.push(`<p>${renderInlineMarkdown(para.join(' '))}</p>`);
}
return html.join('') || `<p>${renderInlineMarkdown(text)}</p>`;
}
function addMarkdownBubble(text) {
const bubble = addBubble('agent', 'agent');
bubble.classList.add('markdown');
bubble.innerHTML = renderMarkdown(text);
return bubble;
}
/* -------------------------------------------------------------------- state */
let session = null;
let rest = null;
let sessionId = null;
let userData = null;
let mic = null;
let micAnalyser = null;
let micRaf = 0;
let agentBubble = null;
let agentText = '';
let bargeCount = 0;
let pendingExternalSourceUrl = '';
let agentTextEnded = false;
let pendingExternalSourceTimer = 0;
const AGENT_AUDIO_AUTOPLAY_DELAY_MS = 8000;
const secure = window.isSecureContext ||
['localhost', '127.0.0.1', '[::1]'].includes(location.hostname);
if (!secure) $('insecure').classList.add('show');
if (location.protocol.startsWith('http')) $('baseUrl').value = location.origin;
else $('baseUrl').value = 'http://localhost:8787';
/* --------------------------------------------------------------- mic meter */
async function micMeterOn(stream) {
micAnalyser = null;
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const src = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser();
analyser.fftSize = 256;
src.connect(analyser);
const data = new Uint8Array(analyser.frequencyBinCount);
const bars = [...$('micMeter').querySelectorAll('.bar i')];
const tick = () => {
analyser.getByteFrequencyData(data);
for (let i = 0; i < bars.length; i++) {
const v = data[i * 3] / 255;
bars[i].style.height = Math.max(3, v * 12) + 'px';
}
micRaf = requestAnimationFrame(tick);
};
tick();
micAnalyser = ctx;
}
function micMeterOff() {
cancelAnimationFrame(micRaf);
if (micAnalyser) micAnalyser.close().catch(() => {});
micAnalyser = null;
for (const b of $('micMeter').querySelectorAll('.bar i')) b.style.height = '3px';
$('micMeter').classList.remove('on');
}
async function micStart() {
if (mic) return;
mic = new MicCapture({ sampleRate: AUDIO.sampleRate });
mic.on('frame', (samples) => session.client.sendAudio(samples));
await mic.start();
$('micMeter').classList.add('on');
// The SDK does not expose the MediaStream, so re-open one just for the meter.
navigator.mediaDevices
.getUserMedia({ audio: true })
.then((stream) => micMeterOn(stream))
.catch(() => {});
$('micBtn').textContent = 'Microphone: ON';
$('micBtn').classList.add('active');
log('user', 'mic → ON');
}
function micStop() {
if (!mic) return;
mic.stop();
mic = null;
micMeterOff();
$('micBtn').textContent = 'Microphone: OFF';
$('micBtn').classList.remove('active');
log('user', 'mic → OFF');
}
function clearPendingExternalSourceAutoplay() {
if (pendingExternalSourceTimer) clearTimeout(pendingExternalSourceTimer);
pendingExternalSourceTimer = 0;
}
function schedulePendingExternalSourceAutoplay() {
if (!session || !pendingExternalSourceUrl || !agentTextEnded) return;
clearPendingExternalSourceAutoplay();
const url = pendingExternalSourceUrl;
pendingExternalSourceTimer = setTimeout(() => {
pendingExternalSourceTimer = 0;
if (!session) return;
if (!pendingExternalSourceUrl || pendingExternalSourceUrl !== url) return;
if (!agentTextEnded) return;
pendingExternalSourceUrl = '';
agentTextEnded = false;
playExternalSource(url);
}, AGENT_AUDIO_AUTOPLAY_DELAY_MS);
}
/* ------------------------------------------------------------ connect / wire */
function wire(s) {
s.on('state', (st) => {
if (st === 'connecting') setState('connecting…', 'warn');
else if (st === 'open') setState('connected', 'on');
else if (st === 'closed') setState('disconnected', '');
});
s.on('reconnecting', (e) => setState(`reconnecting… (${e.attempt + 1})`, 'warn'));
s.on('session', async (e) => {
sessionId = e.id;
log('user', `session id = ${e.id}`);
// Apply the demo's system prompt for the whole session, right at startup.
try {
showPrompt(await rest.setPrompt(SESSION_PROMPT, sessionId));
log('tool', `session prompt applied (session ${sessionId})`);
} catch (err) {
log('err', `prompt apply failed: ${err.message}`);
refreshPrompt();
}
// Authenticate the user identified by ?phoneNumber=… in the page URL.
await authenticateUser();
});
s.on('cues', (e) => {
log('cue', `cues lang=${e.lang} ${JSON.stringify(e.config ?? {})}`);
});
s.on('user', (e) => {
clearPendingExternalSourceAutoplay();
agentTextEnded = false;
pendingExternalSourceUrl = '';
log('user', `user: ${e.text}`);
});
s.on('assistant_delta', (e) => {
agentTextEnded = false;
if (!agentBubble) agentBubble = addBubble('agent', 'agent');
agentText += e.text;
renderKaraoke(agentBubble, agentText, e.text);
chat.scrollTop = chat.scrollHeight;
});
s.on('assistant_markdown', (e) => {
addMarkdownBubble(e.text ?? '');
log('tool', `assistant_markdown (${String(e.text ?? '').length} char)`);
});
s.on('assistant_end', () => {
if (agentBubble) renderKaraoke(agentBubble, agentText, '');
log('user', `assistant_end (${agentText.length} char)`);
agentBubble = null;
agentText = '';
agentTextEnded = true;
schedulePendingExternalSourceAutoplay();
});
s.on('cue', (e) => {
log('cue', `cue ${e.kind} ${e.name ?? ''} ${e.category ?? ''}`);
addChip('cue flash', `cue <b>${escapeHtml(e.kind)}</b>${e.name ? ' · ' + escapeHtml(e.name) : ''}`);
});
s.on('barge_in', (e) => {
bargeCount++;
$('bargeCount').textContent = bargeCount;
$('bargein').classList.add('active');
setTimeout(() => $('bargein').classList.remove('active'), 700);
clearPendingExternalSourceAutoplay();
agentTextEnded = false;
pendingExternalSourceUrl = '';
log('cue', `barge_in source=${e.source ?? '?'} prob=${e.prob ?? '-'}`);
});
s.on('tool_call', (e) => {
log('tool', `tool_call ${e.name} ${JSON.stringify(e.arguments ?? {})}`);
addChip('tool', `tool <b>${escapeHtml(e.name)}</b>`);
// A tool flagged `audio` returns a media file: queue its URL and start
// playback only after the agent finishes speaking for this turn.
const def = DEMO_TOOLS.find((t) => t.name === e.name);
if (def?.audio) {
try {
pendingExternalSourceUrl = toolUrl(def, e.arguments);
$('sourceUrl').value = pendingExternalSourceUrl;
addChip('tool', `audio <b>queued</b>`);
if (agentTextEnded) schedulePendingExternalSourceAutoplay();
} catch (err) {
log('err', `audio tool playback failed: ${err.message}`);
}
}
});
s.on('audio_source', (e) => {
log('cue', `audio_source ${e.state} ${e.url ?? ''}`);
if (e.state === 'error') addChip('chip flash', `source <b>error</b> ${escapeHtml(e.text ?? '')}`);
});
s.on('error', (e) => {
if (e.text) {
log('err', `server error: ${e.text}`);
addBubble('error', 'error').textContent = e.text;
} else {
log('err', `socket error${e.fatal ? ' (fatal)' : ''}: ${e.error?.message ?? ''}`);
}
});
s.on('audio', () => {}); // frames are played by the session; logged nowhere (too noisy)
}
async function connect() {
const baseUrl = $('baseUrl').value.trim();
rest = new AudioAgentRest(baseUrl);
session = new AudioSession({
baseUrl,
autoReconnect: true,
forwardMic: false, // the mic is toggled separately below
bufferMs: Number($('bufferMs').value) || 150,
});
wire(session);
try {
await session.start({ microphone: false });
} catch (err) {
log('err', `connect failed: ${err.message}`);
setState('error', 'err');
return;
}
$('connectBtn').textContent = 'Disconnect';
for (const id of ['sendBtn', 'micBtn', 'interruptBtn', 'sourcePlay', 'sourceStop', 'sourceVol',
'promptLoad', 'promptApply', 'promptClear', 'promptText']) {
$(id).disabled = false;
}
try {
const tools = await rest.setTools(DEMO_TOOLS);
log('tool', `demo tools installed (${tools.count})`);
} catch (err) {
log('err', `tool install failed: ${err.message}`);
}
log('user', `connected → ${session.client.url}`);
}
function disconnect() {
micStop();
if (session) session.stop();
session = null;
rest = null;
sessionId = null;
userData = null;
clearPendingExternalSourceAutoplay();
pendingExternalSourceUrl = '';
agentTextEnded = false;
setUser('');
$('sessionId').textContent = '—';
$('promptSource').textContent = '—';
$('promptOverride').textContent = '—';
$('promptText').value = '';
$('connectBtn').textContent = 'Connect';
for (const id of ['sendBtn', 'micBtn', 'interruptBtn', 'sourcePlay', 'sourceStop', 'sourceVol',
'promptLoad', 'promptApply', 'promptClear', 'promptText']) {
$(id).disabled = true;
}
setState('disconnected', '');
}
/* ----------------------------------------------------------- system prompt */
function showPrompt(res) {
$('sessionId').textContent = res.session_id ?? '—';
$('promptSource').textContent = res.source;
$('promptOverride').textContent = res.has_override ? 'yes' : 'no';
$('promptText').value = res.prompt;
}
async function refreshPrompt() {
if (!rest) return;
try {
showPrompt(await rest.getPrompt(sessionId));
} catch (err) {
log('err', `prompt load failed: ${err.message}`);
}
}
/* --------------------------------------------------------------- phone auth */
/** Read `phoneNumber` from the page URL. Kept raw (not via URLSearchParams) so
* a leading `+` in `?phoneNumber=+44…` survives instead of becoming a space. */
function phoneFromUrl() {
const m = location.search.match(/[?&](?:phoneNumber|phone)=([^&]*)/i);
return m ? decodeURIComponent(m[1]) : '';
}
/** Best-effort human label out of the login payload (unknown schema). */
function userLabel(u) {
if (!u || typeof u !== 'object') return '';
const cand = [
u.name, u.fullName, u.displayName, u.email, u.phoneNumber,
u.user?.name, u.user?.fullName, u.user?.email,
];
return cand.find((v) => typeof v === 'string' && v) || '';
}
/** Show (or clear) the authenticated user's name in the header. */
function setUser(label, ok = true) {
const el = $('userStatus');
el.textContent = label || '';
el.classList.toggle('show', !!label);
el.classList.toggle('bad', !ok);
el.title = label ? `${label} · session ${sessionId ?? '—'}` : '';
}
/** Authenticate the user identified in the URL, at session start. */
async function authenticateUser() {
const phoneNumber = phoneFromUrl();
if (!phoneNumber) {
log('user', 'no phoneNumber in URL — skipping phone-login');
return;
}
try {
userData = await rest.phoneLogin(phoneNumber, sessionId);
const label = userLabel(userData) || phoneNumber;
setUser(label);
addChip('tool', `user <b>${escapeHtml(label)}</b>`);
log('tool', `phone-login ok (${phoneNumber}) → ${JSON.stringify(userData).slice(0, 300)}`);
} catch (err) {
setUser('auth failed', false);
log('err', `phone-login failed: ${err.message}`);
}
}
/* -------------------------------------------------------------------- events */
$('connectBtn').onclick = () => (session ? disconnect() : connect());
$('sendBtn').onclick = () => {
const text = $('textInput').value.trim();
if (!text || !session) return;
session.sendUserText(text);
$('textInput').value = '';
};
$('textInput').onkeydown = (e) => {
if (e.key === 'Enter') $('sendBtn').click();
};
$('micBtn').onclick = async () => {
if (!session) return;
try {
mic ? micStop() : await micStart();
} catch (err) {
log('err', `mic error: ${err.message}`);
}
};
$('interruptBtn').onclick = () => session && session.interrupt();
/** Route a URL to the engine's external audio source channel and start it. */
function playExternalSource(url) {
if (!session || !url) return;
$('sourceUrl').value = url;
session.client.playAudioSource(url);
log('cue', `audio_source play ${url}`);
}
$('sourcePlay').onclick = () => playExternalSource($('sourceUrl').value.trim());
$('sourceStop').onclick = () => session && session.client.stopAudioSource();
$('sourceVol').oninput = (e) => session && session.client.setAudioSourceVolume(e.target.value / 100);
$('promptLoad').onclick = () => refreshPrompt();
$('promptApply').onclick = async () => {
if (!rest) return;
const prompt = $('promptText').value;
if (!prompt.trim()) return;
try {
showPrompt(await rest.setPrompt(prompt, sessionId));
log('tool', `prompt override set (session ${sessionId})`);
} catch (err) {
log('err', `prompt apply failed: ${err.message}`);
}
};
$('promptClear').onclick = async () => {
if (!rest) return;
try {
showPrompt(await rest.setPrompt(null, sessionId));
log('tool', `prompt override cleared (session ${sessionId})`);
} catch (err) {
log('err', `prompt clear failed: ${err.message}`);
}
};
setState('disconnected', '');
</script>
</body>
</html>