feat: add call history API endpoints and TTS service client
Adds read-only access to persisted call records for the dashboard and implements a client for the Rhema text-to-speech service. - api/call_history.py: New router providing paged call lists and detailed call records with transcript metadata. - services/tts.py: Async client for OpenAI-compatible TTS endpoints (Rhema/Kokoro) used for call-flow steps.
This commit is contained in:
@@ -1,4 +1,13 @@
|
||||
import type { CallSummary, DeviceStatus, GatewayEvent, GatewayStatus, HealthStatus } from './types';
|
||||
import type {
|
||||
CallHistoryRow,
|
||||
CallSummary,
|
||||
DeviceStatus,
|
||||
GatewayEvent,
|
||||
GatewayStatus,
|
||||
HealthStatus,
|
||||
RoutingRule,
|
||||
TranscriptRow,
|
||||
} from './types';
|
||||
|
||||
async function get<T>(path: string): Promise<T> {
|
||||
const res = await fetch(path);
|
||||
@@ -27,6 +36,67 @@ export async function hangupCall(callId: string): Promise<void> {
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
export async function fetchCallHistory(
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
): Promise<CallHistoryRow[]> {
|
||||
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
|
||||
return get<CallHistoryRow[]>(`/api/calls/history?${params}`);
|
||||
}
|
||||
|
||||
export async function fetchCallRecord(callId: string): Promise<CallHistoryRow> {
|
||||
return get<CallHistoryRow>(`/api/calls/${callId}/record`);
|
||||
}
|
||||
|
||||
export async function fetchTranscript(callId: string): Promise<TranscriptRow[]> {
|
||||
return get<TranscriptRow[]>(`/api/calls/${callId}/transcript`);
|
||||
}
|
||||
|
||||
export function recordingUrl(callId: string): string {
|
||||
return `/api/calls/${callId}/recording`;
|
||||
}
|
||||
|
||||
export async function fetchRoutingRules(): Promise<RoutingRule[]> {
|
||||
return get<RoutingRule[]>('/api/routing/rules');
|
||||
}
|
||||
|
||||
export async function createRoutingRule(rule: Partial<RoutingRule>): Promise<RoutingRule> {
|
||||
const res = await fetch('/api/routing/rules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(rule),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
return res.json() as Promise<RoutingRule>;
|
||||
}
|
||||
|
||||
export async function updateRoutingRule(
|
||||
ruleId: string,
|
||||
patch: Partial<RoutingRule>,
|
||||
): Promise<RoutingRule> {
|
||||
const res = await fetch(`/api/routing/rules/${ruleId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
return res.json() as Promise<RoutingRule>;
|
||||
}
|
||||
|
||||
export async function deleteRoutingRule(ruleId: string): Promise<void> {
|
||||
const res = await fetch(`/api/routing/rules/${ruleId}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
export async function setDeviceDnd(deviceId: string, enabled: boolean): Promise<void> {
|
||||
const res = await fetch(`/api/routing/devices/${deviceId}/dnd`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
export function connectEventStream(
|
||||
onEvent: (e: GatewayEvent) => void,
|
||||
onClose: () => void,
|
||||
|
||||
@@ -72,3 +72,65 @@ export interface GatewayEvent {
|
||||
data: Record<string, unknown>;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface CallHistoryRow {
|
||||
id: string;
|
||||
direction: string;
|
||||
remote_number: string;
|
||||
status: string;
|
||||
mode: string;
|
||||
intent?: string;
|
||||
started_at?: string;
|
||||
ended_at?: string;
|
||||
duration: number;
|
||||
hold_time: number;
|
||||
device_used?: string;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export interface TranscriptRow {
|
||||
seq: number;
|
||||
t_offset_ms: number;
|
||||
speaker: string;
|
||||
text: string;
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
export type RoutingActionType =
|
||||
| 'ring_device'
|
||||
| 'ring_chain'
|
||||
| 'take_message'
|
||||
| 'reject'
|
||||
| 'dnd';
|
||||
|
||||
export interface TimeRange {
|
||||
start: string;
|
||||
end: string;
|
||||
tz: string;
|
||||
days: number[];
|
||||
}
|
||||
|
||||
export interface RoutingMatch {
|
||||
caller_pattern?: string | null;
|
||||
dnis?: string | null;
|
||||
time_range?: TimeRange | null;
|
||||
}
|
||||
|
||||
export interface RoutingAction {
|
||||
type: RoutingActionType;
|
||||
device_id?: string | null;
|
||||
device_ids: string[];
|
||||
ring_timeout: number;
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
export interface RoutingRule {
|
||||
id: string;
|
||||
name: string;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
match: RoutingMatch;
|
||||
action: RoutingAction;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,11 @@
|
||||
return () => mq.removeEventListener('change', onSystemChange);
|
||||
});
|
||||
|
||||
const nav = [{ href: '/', label: 'Dashboard' }];
|
||||
const nav = [
|
||||
{ href: '/', label: 'Dashboard' },
|
||||
{ href: '/history', label: 'History' },
|
||||
{ href: '/routing', label: 'Routing' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-slate-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100">
|
||||
|
||||
80
dashboard/src/routes/calls/[call_id]/+page.svelte
Normal file
80
dashboard/src/routes/calls/[call_id]/+page.svelte
Normal file
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { fetchCallRecord, fetchTranscript, recordingUrl } from '$lib/api';
|
||||
import type { CallHistoryRow, TranscriptRow } from '$lib/types';
|
||||
|
||||
let record = $state<CallHistoryRow | null>(null);
|
||||
let transcript = $state<TranscriptRow[]>([]);
|
||||
let audioEl: HTMLAudioElement | null = null;
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const callId = $derived($page.params.call_id);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
record = await fetchCallRecord(callId);
|
||||
transcript = await fetchTranscript(callId);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
function seekTo(ms: number) {
|
||||
if (audioEl) {
|
||||
audioEl.currentTime = ms / 1000;
|
||||
audioEl.play();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<a href="/history" class="text-sm text-orange-600 hover:underline">← Back to history</a>
|
||||
|
||||
{#if loading}
|
||||
<div class="text-sm text-gray-500 mt-4">Loading…</div>
|
||||
{:else if error}
|
||||
<div class="text-sm text-red-500 mt-4">Failed: {error}</div>
|
||||
{:else if record}
|
||||
<header class="mt-4 mb-4">
|
||||
<h1 class="text-xl font-semibold font-mono">{record.remote_number}</h1>
|
||||
<div class="text-sm text-gray-500">
|
||||
{record.direction} · {record.mode} · {record.status} ·
|
||||
{record.duration}s
|
||||
{#if record.intent}<span> · {record.intent}</span>{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="mb-6">
|
||||
<audio
|
||||
bind:this={audioEl}
|
||||
controls
|
||||
preload="metadata"
|
||||
class="w-full"
|
||||
src={recordingUrl(callId)}
|
||||
></audio>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-sm uppercase text-gray-500 mb-2">Transcript</h2>
|
||||
{#if transcript.length === 0}
|
||||
<div class="text-sm text-gray-500">No transcript stored.</div>
|
||||
{:else}
|
||||
<ul class="space-y-1">
|
||||
{#each transcript as chunk}
|
||||
<li>
|
||||
<button
|
||||
class="text-left w-full px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
onclick={() => seekTo(chunk.t_offset_ms)}
|
||||
>
|
||||
<span class="text-xs uppercase text-gray-500 mr-2">{chunk.speaker}</span>
|
||||
<span>{chunk.text}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
76
dashboard/src/routes/history/+page.svelte
Normal file
76
dashboard/src/routes/history/+page.svelte
Normal file
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchCallHistory } from '$lib/api';
|
||||
import type { CallHistoryRow } from '$lib/types';
|
||||
|
||||
let rows = $state<CallHistoryRow[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
function fmtDuration(s: number): string {
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
return `${m}:${String(sec).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function fmtDate(iso?: string): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
rows = await fetchCallHistory(100, 0);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<h1 class="text-xl font-semibold mb-4">Call History</h1>
|
||||
|
||||
{#if loading}
|
||||
<div class="text-sm text-gray-500">Loading…</div>
|
||||
{:else if error}
|
||||
<div class="text-sm text-red-500">Failed to load history: {error}</div>
|
||||
{:else if rows.length === 0}
|
||||
<div class="text-sm text-gray-500">No calls yet.</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto rounded border border-gray-200 dark:border-gray-800">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 dark:bg-gray-900 text-left text-xs uppercase text-gray-500">
|
||||
<tr>
|
||||
<th class="px-3 py-2">When</th>
|
||||
<th class="px-3 py-2">Number</th>
|
||||
<th class="px-3 py-2">Dir</th>
|
||||
<th class="px-3 py-2">Mode</th>
|
||||
<th class="px-3 py-2">Status</th>
|
||||
<th class="px-3 py-2">Duration</th>
|
||||
<th class="px-3 py-2">Hold</th>
|
||||
<th class="px-3 py-2">Intent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as row}
|
||||
<tr class="border-t border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-900">
|
||||
<td class="px-3 py-2">
|
||||
<a class="text-orange-600 hover:underline" href={`/calls/${row.id}`}>
|
||||
{fmtDate(row.started_at)}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-3 py-2 font-mono">{row.remote_number}</td>
|
||||
<td class="px-3 py-2">{row.direction}</td>
|
||||
<td class="px-3 py-2">{row.mode}</td>
|
||||
<td class="px-3 py-2">{row.status}</td>
|
||||
<td class="px-3 py-2">{fmtDuration(row.duration)}</td>
|
||||
<td class="px-3 py-2">{fmtDuration(row.hold_time)}</td>
|
||||
<td class="px-3 py-2 max-w-xs truncate">{row.intent ?? ''}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
219
dashboard/src/routes/routing/+page.svelte
Normal file
219
dashboard/src/routes/routing/+page.svelte
Normal file
@@ -0,0 +1,219 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
createRoutingRule,
|
||||
deleteRoutingRule,
|
||||
fetchDevices,
|
||||
fetchRoutingRules,
|
||||
setDeviceDnd,
|
||||
updateRoutingRule,
|
||||
} from '$lib/api';
|
||||
import type { DeviceStatus, RoutingAction, RoutingActionType, RoutingRule } from '$lib/types';
|
||||
|
||||
let rules = $state<RoutingRule[]>([]);
|
||||
let devices = $state<DeviceStatus[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let draft = $state({
|
||||
name: '',
|
||||
priority: 100,
|
||||
caller_pattern: '',
|
||||
dnis: '',
|
||||
action_type: 'ring_chain' as RoutingActionType,
|
||||
device_id: '',
|
||||
ring_timeout: 25,
|
||||
message: '',
|
||||
});
|
||||
|
||||
const actionTypes: RoutingActionType[] = [
|
||||
'ring_device',
|
||||
'ring_chain',
|
||||
'take_message',
|
||||
'reject',
|
||||
'dnd',
|
||||
];
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
[rules, devices] = await Promise.all([fetchRoutingRules(), fetchDevices()]);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
async function addRule() {
|
||||
if (!draft.name.trim()) return;
|
||||
const action: RoutingAction = {
|
||||
type: draft.action_type,
|
||||
device_id: draft.action_type === 'ring_device' ? draft.device_id || null : null,
|
||||
device_ids: [],
|
||||
ring_timeout: draft.ring_timeout,
|
||||
message: draft.message || null,
|
||||
};
|
||||
try {
|
||||
await createRoutingRule({
|
||||
name: draft.name,
|
||||
priority: draft.priority,
|
||||
enabled: true,
|
||||
match: {
|
||||
caller_pattern: draft.caller_pattern || null,
|
||||
dnis: draft.dnis || null,
|
||||
time_range: null,
|
||||
},
|
||||
action,
|
||||
});
|
||||
draft = {
|
||||
name: '',
|
||||
priority: 100,
|
||||
caller_pattern: '',
|
||||
dnis: '',
|
||||
action_type: 'ring_chain',
|
||||
device_id: '',
|
||||
ring_timeout: 25,
|
||||
message: '',
|
||||
};
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEnabled(rule: RoutingRule) {
|
||||
await updateRoutingRule(rule.id, { enabled: !rule.enabled });
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function removeRule(rule: RoutingRule) {
|
||||
if (!confirm(`Delete rule "${rule.name}"?`)) return;
|
||||
await deleteRoutingRule(rule.id);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function toggleDnd(device: DeviceStatus, enabled: boolean) {
|
||||
await setDeviceDnd(device.id, enabled);
|
||||
await refresh();
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1 class="text-xl font-semibold mb-4">Routing Rules</h1>
|
||||
|
||||
{#if error}
|
||||
<div class="text-sm text-red-500 mb-4">{error}</div>
|
||||
{/if}
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-sm uppercase text-gray-500 mb-2">Devices · DND</h2>
|
||||
{#if devices.length === 0}
|
||||
<div class="text-sm text-gray-500">No devices registered.</div>
|
||||
{:else}
|
||||
<ul class="space-y-1">
|
||||
{#each devices as d}
|
||||
<li class="flex items-center gap-3 text-sm">
|
||||
<span class="font-mono">{d.name}</span>
|
||||
<span class="text-gray-500">({d.type})</span>
|
||||
<label class="ml-auto flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
onchange={(e) => toggleDnd(d, (e.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
<span>DND</span>
|
||||
</label>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-sm uppercase text-gray-500 mb-2">Add Rule</h2>
|
||||
<div class="grid grid-cols-2 gap-2 max-w-2xl">
|
||||
<input class="px-2 py-1 border rounded" placeholder="Name" bind:value={draft.name} />
|
||||
<input
|
||||
class="px-2 py-1 border rounded"
|
||||
type="number"
|
||||
placeholder="Priority"
|
||||
bind:value={draft.priority}
|
||||
/>
|
||||
<input
|
||||
class="px-2 py-1 border rounded"
|
||||
placeholder="Caller pattern (e.g. +1800*)"
|
||||
bind:value={draft.caller_pattern}
|
||||
/>
|
||||
<input class="px-2 py-1 border rounded" placeholder="DNIS" bind:value={draft.dnis} />
|
||||
<select class="px-2 py-1 border rounded" bind:value={draft.action_type}>
|
||||
{#each actionTypes as t}
|
||||
<option value={t}>{t}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if draft.action_type === 'ring_device'}
|
||||
<select class="px-2 py-1 border rounded" bind:value={draft.device_id}>
|
||||
<option value="">— device —</option>
|
||||
{#each devices as d}
|
||||
<option value={d.id}>{d.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<input
|
||||
class="px-2 py-1 border rounded"
|
||||
type="number"
|
||||
placeholder="Ring timeout"
|
||||
bind:value={draft.ring_timeout}
|
||||
/>
|
||||
{/if}
|
||||
<input
|
||||
class="px-2 py-1 border rounded col-span-2"
|
||||
placeholder="Optional message (for reject)"
|
||||
bind:value={draft.message}
|
||||
/>
|
||||
</div>
|
||||
<button class="mt-2 px-3 py-1.5 rounded bg-orange-600 text-white" onclick={addRule}>
|
||||
Add rule
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-sm uppercase text-gray-500 mb-2">Rules</h2>
|
||||
{#if loading}
|
||||
<div class="text-sm text-gray-500">Loading…</div>
|
||||
{:else if rules.length === 0}
|
||||
<div class="text-sm text-gray-500">No rules yet.</div>
|
||||
{:else}
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-left text-xs uppercase text-gray-500">
|
||||
<tr>
|
||||
<th class="px-2 py-1">Pri</th>
|
||||
<th class="px-2 py-1">Name</th>
|
||||
<th class="px-2 py-1">Caller</th>
|
||||
<th class="px-2 py-1">DNIS</th>
|
||||
<th class="px-2 py-1">Action</th>
|
||||
<th class="px-2 py-1">Enabled</th>
|
||||
<th class="px-2 py-1"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rules as rule}
|
||||
<tr class="border-t border-gray-100 dark:border-gray-800">
|
||||
<td class="px-2 py-1 font-mono">{rule.priority}</td>
|
||||
<td class="px-2 py-1">{rule.name}</td>
|
||||
<td class="px-2 py-1 font-mono">{rule.match.caller_pattern ?? ''}</td>
|
||||
<td class="px-2 py-1 font-mono">{rule.match.dnis ?? ''}</td>
|
||||
<td class="px-2 py-1">{rule.action.type}</td>
|
||||
<td class="px-2 py-1">
|
||||
<button onclick={() => toggleEnabled(rule)}>
|
||||
{rule.enabled ? '✓' : '✗'}
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-2 py-1">
|
||||
<button class="text-red-500" onclick={() => removeRule(rule)}>delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</section>
|
||||
Reference in New Issue
Block a user