Implement AI orchestration wedding demo

This commit is contained in:
pelpanagiotis
2026-06-27 13:50:11 +03:00
commit 55d4fe95b4
67 changed files with 4736 additions and 0 deletions

321
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,321 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { Check, Clock, Play, RefreshCw, Send, ShieldCheck, X } from "lucide-react";
import "./styles.css";
type TranscriptChunk = { speaker: string; timestamp: string; text: string };
type PendingAction = {
id: number;
tool: string;
intent: string;
reasoning: string;
confidence: number;
payload: Record<string, unknown>;
extracted_entities: Record<string, unknown>;
validation: { valid: boolean; errors: string[] };
policy: { allowed: boolean; reasons: string[] };
status: string;
execution_result?: unknown;
created_at: string;
};
type Dashboard = {
transcript: TranscriptChunk[];
context: Record<string, unknown>;
memories: Array<Record<string, unknown>>;
pending_actions: PendingAction[];
tasks: Array<Record<string, unknown>>;
notes: Array<Record<string, unknown>>;
reservations: Array<Record<string, unknown>>;
events: Array<Record<string, unknown>>;
};
type TranscriptionJob = {
job_id: string;
status: "uploading" | "queued" | "processing" | "completed" | "error";
transcription_id?: string | null;
segments: Array<{
id: string;
speaker: string;
original_text: string;
edited_text: string;
start_ms: number;
end_ms: number;
}>;
generated_action_ids: number[];
error?: string | null;
};
const API = import.meta.env.VITE_API_URL ?? "";
const WS_URL = import.meta.env.VITE_WS_URL ?? `ws://${window.location.host}/ws/transcript`;
const sampleTranscript = [
{ speaker: "Bride", text: "Add a note that the customer prefers white flowers." },
{ speaker: "Planner", text: "Create a task for Maria to confirm the photographer." },
{ speaker: "Groom", text: "We should reserve the venue for July 15." },
{ speaker: "Bride", text: "I'll call the florist tomorrow." }
];
function JsonBlock({ value }: { value: unknown }) {
return <pre>{JSON.stringify(value, null, 2)}</pre>;
}
function App() {
const [dashboard, setDashboard] = React.useState<Dashboard | null>(null);
const [speaker, setSpeaker] = React.useState("Bride");
const [text, setText] = React.useState("Create a task for Maria to confirm the photographer.");
const [busy, setBusy] = React.useState(false);
const [isRecording, setIsRecording] = React.useState(false);
const [recordingError, setRecordingError] = React.useState<string | null>(null);
const [recordingSeconds, setRecordingSeconds] = React.useState(0);
const [transcriptionJob, setTranscriptionJob] = React.useState<TranscriptionJob | null>(null);
const mediaRecorderRef = React.useRef<MediaRecorder | null>(null);
const mediaStreamRef = React.useRef<MediaStream | null>(null);
const audioChunksRef = React.useRef<Blob[]>([]);
const recordingStartedAtRef = React.useRef<number | null>(null);
const refresh = React.useCallback(async () => {
const response = await fetch(`${API}/dashboard`);
setDashboard(await response.json());
}, []);
React.useEffect(() => {
refresh();
const interval = window.setInterval(refresh, 2500);
return () => window.clearInterval(interval);
}, [refresh]);
React.useEffect(() => {
if (!isRecording) {
return;
}
const timer = window.setInterval(() => {
const startedAt = recordingStartedAtRef.current;
setRecordingSeconds(startedAt ? Math.floor((Date.now() - startedAt) / 1000) : 0);
}, 500);
return () => window.clearInterval(timer);
}, [isRecording]);
React.useEffect(() => {
const jobId = transcriptionJob?.job_id;
if (!jobId || transcriptionJob.status === "completed" || transcriptionJob.status === "error") {
return;
}
const timer = window.setInterval(async () => {
const response = await fetch(`${API}/transcriptions/${jobId}`);
if (!response.ok) {
return;
}
const job = (await response.json()) as TranscriptionJob;
setTranscriptionJob(job);
if (job.status === "completed") {
await refresh();
}
}, 2000);
return () => window.clearInterval(timer);
}, [transcriptionJob, refresh]);
const sendChunk = React.useCallback(async (chunk = { speaker, text }) => {
setBusy(true);
await new Promise<void>((resolve, reject) => {
const ws = new WebSocket(WS_URL);
ws.onopen = () => ws.send(JSON.stringify({ ...chunk, timestamp: new Date().toISOString() }));
ws.onmessage = () => {
ws.close();
resolve();
};
ws.onerror = () => reject(new Error("WebSocket failed"));
}).finally(() => setBusy(false));
await refresh();
}, [speaker, text, refresh]);
const replay = async () => {
for (const chunk of sampleTranscript) {
await sendChunk(chunk);
await new Promise((resolve) => window.setTimeout(resolve, 450));
}
};
const pickMimeType = () => {
if (MediaRecorder.isTypeSupported("audio/webm;codecs=opus")) return "audio/webm;codecs=opus";
if (MediaRecorder.isTypeSupported("audio/webm")) return "audio/webm";
if (MediaRecorder.isTypeSupported("audio/mp4")) return "audio/mp4";
return "audio/webm";
};
const startMic = async () => {
setRecordingError(null);
setTranscriptionJob(null);
const stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
});
const recorder = new MediaRecorder(stream, { mimeType: pickMimeType() });
audioChunksRef.current = [];
mediaRecorderRef.current = recorder;
mediaStreamRef.current = stream;
recordingStartedAtRef.current = Date.now();
recorder.ondataavailable = (event) => {
if (event.data.size > 0) audioChunksRef.current.push(event.data);
};
recorder.start(250);
setRecordingSeconds(0);
setIsRecording(true);
};
const stopMic = async () => {
const recorder = mediaRecorderRef.current;
if (!recorder) return;
const blob = await new Promise<Blob | null>((resolve) => {
recorder.onstop = () => {
const chunks = audioChunksRef.current;
resolve(chunks.length > 0 ? new Blob(chunks, { type: recorder.mimeType }) : null);
};
try {
recorder.requestData();
} catch {
// Ignore browsers that have no buffered data yet.
}
recorder.stop();
});
mediaStreamRef.current?.getTracks().forEach((track) => track.stop());
mediaRecorderRef.current = null;
mediaStreamRef.current = null;
setIsRecording(false);
recordingStartedAtRef.current = null;
if (!blob) {
setRecordingError("No microphone audio was captured.");
return;
}
const form = new FormData();
const extension = blob.type.includes("mp4") ? "m4a" : "webm";
form.append("audio", blob, `meeting-recording.${extension}`);
const response = await fetch(`${API}/transcriptions`, { method: "POST", body: form });
if (!response.ok) {
const body = await response.json().catch(() => null);
setRecordingError(body?.detail ?? "Audio upload failed.");
return;
}
setTranscriptionJob((await response.json()) as TranscriptionJob);
};
const toggleMic = async () => {
try {
if (isRecording) {
await stopMic();
} else {
await startMic();
}
} catch (error) {
setRecordingError(error instanceof Error ? error.message : "Microphone access failed.");
setIsRecording(false);
mediaStreamRef.current?.getTracks().forEach((track) => track.stop());
}
};
const generateCall = async (id: number) => {
await fetch(`${API}/pending-actions/${id}/approve`, { method: "POST" });
await refresh();
};
const reject = async (id: number) => {
await fetch(`${API}/pending-actions/${id}/reject`, { method: "POST" });
await refresh();
};
const latest = dashboard?.pending_actions[0];
return (
<main className="shell">
<header className="topbar">
<div>
<h1>AI Orchestrator</h1>
<p>Wedding planning meeting control room</p>
</div>
<button title="Refresh dashboard" onClick={refresh}><RefreshCw size={18} /> Refresh</button>
</header>
<section className="grid">
<aside className="panel transcript">
<div className="panel-title"><Clock size={18} /> Live Transcript</div>
<div className="mic-box">
<button onClick={toggleMic} className={isRecording ? "recording" : ""}>
{isRecording ? <X size={17} /> : <Play size={17} />}
{isRecording ? "Stop Mic" : "Start Mic"}
</button>
<span>{isRecording ? `${recordingSeconds}s` : transcriptionJob ? transcriptionJob.status : "Soniox mic transcription"}</span>
{recordingError && <p className="error">{recordingError}</p>}
{transcriptionJob?.status === "completed" && (
<p className="success">Generated {transcriptionJob.generated_action_ids.length} pending action(s).</p>
)}
</div>
<div className="composer">
<input value={speaker} onChange={(event) => setSpeaker(event.target.value)} aria-label="Speaker" />
<textarea value={text} onChange={(event) => setText(event.target.value)} aria-label="Transcript text" />
<div className="actions-row">
<button onClick={() => sendChunk()} disabled={busy}><Send size={17} /> Send</button>
<button onClick={replay} disabled={busy}><Play size={17} /> Replay</button>
</div>
</div>
<div className="list">
{(dashboard?.transcript ?? []).map((chunk, index) => (
<article className="line" key={`${chunk.timestamp}-${index}`}>
<strong>{chunk.speaker}</strong>
<span>{new Date(chunk.timestamp).toLocaleTimeString()}</span>
<p>{chunk.text}</p>
</article>
))}
</div>
</aside>
<section className="panel reasoning">
<div className="panel-title"><ShieldCheck size={18} /> Planner, Policy, Memory</div>
{latest ? (
<div className="decision">
<div className="metric-row">
<span className="tag">{latest.intent}</span>
<span className="confidence">{Math.round(latest.confidence * 100)}%</span>
<span className={`status ${latest.status}`}>{latest.status.replace("_", " ")}</span>
</div>
<p className="reason">{latest.reasoning}</p>
<div className="columns">
<div><h2>Payload</h2><JsonBlock value={latest.payload} /></div>
<div><h2>Validation</h2><JsonBlock value={latest.validation} /></div>
</div>
<div className="columns">
<div><h2>Policy</h2><JsonBlock value={latest.policy} /></div>
<div><h2>Retrieved Memories</h2><JsonBlock value={dashboard?.memories ?? []} /></div>
</div>
</div>
) : (
<div className="empty">Send or replay transcript chunks to create reviewed AI actions.</div>
)}
</section>
<aside className="panel pending">
<div className="panel-title"><Check size={18} /> Pending Review</div>
<div className="list">
{(dashboard?.pending_actions ?? []).map((action) => (
<article className="action-card" key={action.id}>
<div className="card-head">
<strong>{action.tool}</strong>
<span className={`status ${action.status}`}>{action.status.replace("_", " ")}</span>
</div>
<p>{action.reasoning}</p>
<JsonBlock value={action.payload} />
<div className="actions-row">
<button onClick={() => generateCall(action.id)} disabled={action.status !== "pending_review"}><Check size={16} /> Generate Call</button>
<button onClick={() => reject(action.id)} disabled={action.status !== "pending_review"}><X size={16} /> Reject</button>
</div>
</article>
))}
</div>
</aside>
</section>
<section className="bottom">
<div className="panel"><div className="panel-title">Current Context</div><JsonBlock value={dashboard?.context ?? {}} /></div>
<div className="panel"><div className="panel-title">Execution Logs</div><JsonBlock value={dashboard?.events ?? []} /></div>
<div className="panel"><div className="panel-title">REST State</div><JsonBlock value={{ tasks: dashboard?.tasks, notes: dashboard?.notes, reservations: dashboard?.reservations }} /></div>
</section>
</main>
);
}
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);

262
frontend/src/styles.css Normal file
View File

@@ -0,0 +1,262 @@
:root {
color: #192126;
background: #f4f6f4;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; }
button, input, textarea { font: inherit; }
.shell {
min-height: 100vh;
padding: 18px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
h1, h2, p { margin: 0; }
h1 { font-size: 28px; letter-spacing: 0; }
h2 { font-size: 13px; margin-bottom: 8px; color: #526064; }
.topbar p { color: #667277; margin-top: 3px; }
button {
min-height: 38px;
border: 1px solid #b9c5bd;
border-radius: 7px;
background: #ffffff;
color: #1f2d31;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
}
button:hover:not(:disabled) { background: #edf4ef; }
button:disabled { opacity: 0.45; cursor: not-allowed; }
.grid {
display: grid;
grid-template-columns: minmax(260px, 0.85fr) minmax(360px, 1.5fr) minmax(280px, 0.95fr);
gap: 14px;
align-items: stretch;
min-width: 0;
}
.panel {
background: #ffffff;
border: 1px solid #d7dfda;
border-radius: 8px;
padding: 14px;
min-width: 0;
box-shadow: 0 1px 2px rgba(31, 45, 49, 0.05);
}
.grid > *,
.bottom > *,
.columns > *,
.decision > * {
min-width: 0;
}
.panel-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
margin-bottom: 12px;
}
.composer {
display: grid;
gap: 8px;
margin-bottom: 12px;
}
.mic-box {
display: grid;
gap: 7px;
margin-bottom: 12px;
padding: 10px;
border: 1px solid #dfe8e2;
border-radius: 8px;
background: #f8faf8;
}
.mic-box span {
color: #5d6a6e;
font-size: 13px;
}
button.recording {
background: #f8e7e5;
border-color: #dfb5ae;
color: #7c2c23;
}
.error {
color: #8d2d22;
font-size: 13px;
line-height: 1.35;
}
.success {
color: #225235;
font-size: 13px;
line-height: 1.35;
}
input, textarea {
width: 100%;
border: 1px solid #c6d1cb;
border-radius: 7px;
padding: 10px;
background: #fbfcfb;
color: #182327;
}
textarea {
min-height: 92px;
resize: vertical;
}
.actions-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.list {
display: grid;
gap: 10px;
max-height: 62vh;
overflow: auto;
padding-right: 2px;
}
.line, .action-card {
border: 1px solid #e0e6e1;
border-radius: 8px;
padding: 10px;
background: #fbfcfb;
}
.line {
display: grid;
gap: 5px;
}
.line span {
color: #718086;
font-size: 12px;
}
.reasoning {
min-height: 520px;
overflow: hidden;
}
.decision {
display: grid;
gap: 14px;
overflow: hidden;
}
.metric-row, .card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.tag, .confidence, .status {
border-radius: 999px;
padding: 5px 9px;
font-size: 12px;
font-weight: 700;
}
.tag { background: #e8f1ed; color: #25443b; }
.confidence { background: #e9eef4; color: #26405d; }
.status { background: #f1eee5; color: #5d4d27; }
.executed { background: #e6f3ea; color: #225235; }
.execution_failed, .rejected { background: #f8e7e5; color: #7c2c23; }
.pending_review { background: #fff4d8; color: #704b00; }
.reason {
line-height: 1.45;
color: #2f3b40;
}
.columns {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 12px;
overflow: hidden;
}
pre {
margin: 0;
overflow: auto;
max-width: 100%;
max-height: 250px;
border-radius: 7px;
border: 1px solid #e2e8e4;
background: #f7f9f8;
padding: 10px;
color: #203034;
font-size: 12px;
line-height: 1.45;
white-space: pre;
}
.action-card {
display: grid;
gap: 10px;
}
.action-card p {
color: #48575c;
line-height: 1.4;
}
.empty {
min-height: 380px;
display: grid;
place-items: center;
color: #607074;
text-align: center;
}
.bottom {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 14px;
margin-top: 14px;
min-width: 0;
}
@media (max-width: 1050px) {
.grid, .bottom {
grid-template-columns: 1fr;
}
.list {
max-height: 420px;
}
}
@media (max-width: 620px) {
.shell { padding: 12px; }
.topbar { align-items: flex-start; flex-direction: column; }
.columns { grid-template-columns: 1fr; }
h1 { font-size: 24px; }
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />