Implement AI orchestration wedding demo
This commit is contained in:
10
frontend/README.md
Normal file
10
frontend/README.md
Normal 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
12
frontend/index.html
Normal 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
1655
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
frontend/package.json
Normal file
24
frontend/package.json
Normal 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
321
frontend/src/main.tsx
Normal 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
262
frontend/src/styles.css
Normal 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
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal 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
22
frontend/vite.config.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user