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

10
frontend/README.md Normal file
View File

@@ -0,0 +1,10 @@
# Frontend
Vite React dashboard for the AI Orchestrator demo. It shows transcript ingestion, AI reasoning, validation, policy decisions, pending review actions, execution logs, retrieved memories, and REST backend state.
Run locally:
```bash
npm install
npm run dev
```

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Orchestrator Wedding Demo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1655
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
frontend/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "ai-orchestrator-wedding-demo-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc && vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"@vitejs/plugin-react": "^4.3.1",
"lucide-react": "^0.468.0",
"typescript": "^5.6.2",
"vite": "^5.4.8",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^22.7.4",
"@types/react": "^18.3.9",
"@types/react-dom": "^18.3.0"
}
}

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" />

21
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": []
}

22
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const backend = process.env.VITE_BACKEND_URL ?? "http://localhost:9000";
const backendWs = backend.replace(/^http/, "ws");
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/dashboard": backend,
"/pending-actions": backend,
"/transcriptions": backend,
"/tools": backend,
"/ws": {
target: backendWs,
ws: true
}
}
}
});