Update agent

This commit is contained in:
2026-09-30 00:50:44 +07:00
parent d77a09cff4
commit 9eff88215c
48 changed files with 6374 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
import { HashRouter, Route, Routes } from 'react-router-dom';
import { Layout } from './components/Layout';
import { Dashboard } from './pages/Dashboard';
import { LiveMeeting } from './pages/LiveMeeting';
import { MeetingDetails } from './pages/MeetingDetails';
import { Meetings } from './pages/Meetings';
import { Overlay } from './pages/Overlay';
import { SettingsPage } from './pages/Settings';
export default function App() {
return (
<HashRouter>
<Routes>
<Route path="/overlay" element={<Overlay />} />
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/live" element={<LiveMeeting />} />
<Route path="/meetings" element={<Meetings />} />
<Route path="/meetings/:id" element={<MeetingDetails />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
</Routes>
</HashRouter>
);
}

View File

@@ -0,0 +1,22 @@
import { Gauge, History, Settings } from 'lucide-react';
import { NavLink, Outlet } from 'react-router-dom';
export function Layout() {
return (
<div className="app-shell">
<aside className="sidebar">
<div className="brand">MeetVault</div>
<nav>
<NavLink to="/" end><Gauge size={18} /> Dashboard</NavLink>
<NavLink to="/meetings"><History size={18} /> Meetings</NavLink>
<NavLink to="/settings"><Settings size={18} /> Settings</NavLink>
</nav>
<div className="sidebar-note">
<strong>Demo mode</strong>
<span>Local files · no login</span>
</div>
</aside>
<main className="main-area"><Outlet /></main>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { LoaderCircle } from 'lucide-react';
export function ProcessingCard() {
return (
<div className="processing-card">
<LoaderCircle className="spin" size={22} />
<div>
<strong>Processing</strong>
<p>Processing (The job). Please wait a moment</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,3 @@
export function StatusBadge({ status }: { status: string }) {
return <span className={`badge badge-${status}`}>{status[0].toUpperCase() + status.slice(1)}</span>;
}

View File

@@ -0,0 +1,19 @@
import { Film } from 'lucide-react';
import { mediaUrl } from '../lib/localFiles';
export function VideoPlayer({ path, mime }: { path?: string; mime?: string }) {
const url = mediaUrl(path);
if (!url) {
return (
<div className="video-empty">
<Film size={34} />
<strong>No local recording yet</strong>
<span>Start a meeting with system audio/screen capture to create a video, or microphone-only for audio.</span>
</div>
);
}
if (mime?.startsWith('audio/')) {
return <div className="audio-wrap"><audio controls src={url} /></div>;
}
return <video className="video-player" controls src={url} />;
}

View File

@@ -0,0 +1,11 @@
export interface DemoJobResult {
status: 'processing';
message: string;
}
export async function requestDemoBackendJob(): Promise<DemoJobResult> {
return {
status: 'processing',
message: 'Processing (The job). Please wait a moment',
};
}

View File

@@ -0,0 +1,124 @@
export interface CaptureOptions {
captureSystemAudio: boolean;
microphoneDeviceId?: string;
onPcm: (pcm: Int16Array) => void;
onLevel?: (level: number) => void;
}
export interface CaptureController {
hasVideo: boolean;
stop: () => Promise<Blob>;
pause: () => void;
resume: () => void;
}
function downsampleFloat32(input: Float32Array, sourceRate: number, targetRate = 16000): Float32Array {
if (sourceRate === targetRate) return input;
const ratio = sourceRate / targetRate;
const length = Math.round(input.length / ratio);
const output = new Float32Array(length);
let offset = 0;
for (let i = 0; i < length; i++) {
const next = Math.round((i + 1) * ratio);
let sum = 0;
let count = 0;
for (; offset < next && offset < input.length; offset++) {
sum += input[offset];
count++;
}
output[i] = count ? sum / count : 0;
}
return output;
}
function floatToInt16(input: Float32Array): Int16Array {
const output = new Int16Array(input.length);
for (let i = 0; i < input.length; i++) {
const sample = Math.max(-1, Math.min(1, input[i]));
output[i] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
}
return output;
}
function pickMime(hasVideo: boolean): string {
const candidates = hasVideo
? ['video/webm;codecs=vp8,opus', 'video/webm;codecs=vp9,opus', 'video/webm']
: ['audio/webm;codecs=opus', 'audio/webm'];
return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? '';
}
export async function startCapture(options: CaptureOptions): Promise<CaptureController> {
const mic = await navigator.mediaDevices.getUserMedia({
audio: options.microphoneDeviceId && options.microphoneDeviceId !== 'default'
? { deviceId: { exact: options.microphoneDeviceId }, noiseSuppression: true, autoGainControl: true }
: { noiseSuppression: true, autoGainControl: true },
video: false,
});
let display: MediaStream | undefined;
if (options.captureSystemAudio) {
display = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
}
const context = new AudioContext();
await context.resume();
const mix = context.createGain();
const destination = context.createMediaStreamDestination();
const processor = context.createScriptProcessor(4096, 1, 1);
const silent = context.createGain();
silent.gain.value = 0;
context.createMediaStreamSource(mic).connect(mix);
if (display && display.getAudioTracks().length) {
context.createMediaStreamSource(new MediaStream(display.getAudioTracks())).connect(mix);
}
mix.connect(destination);
mix.connect(processor);
processor.connect(silent);
silent.connect(context.destination);
processor.onaudioprocess = (event) => {
const channel = event.inputBuffer.getChannelData(0);
let rms = 0;
for (let i = 0; i < channel.length; i++) rms += channel[i] * channel[i];
options.onLevel?.(Math.sqrt(rms / channel.length));
const downsampled = downsampleFloat32(channel, context.sampleRate, 16000);
options.onPcm(floatToInt16(downsampled));
};
const recordingStream = new MediaStream();
destination.stream.getAudioTracks().forEach((track) => recordingStream.addTrack(track));
const videoTrack = display?.getVideoTracks()[0];
if (videoTrack) recordingStream.addTrack(videoTrack);
const hasVideo = Boolean(videoTrack);
const mimeType = pickMime(hasVideo);
const recorder = new MediaRecorder(recordingStream, mimeType ? { mimeType } : undefined);
const chunks: BlobPart[] = [];
recorder.ondataavailable = (event) => {
if (event.data.size) chunks.push(event.data);
};
recorder.start(1000);
return {
hasVideo,
pause: () => {
if (recorder.state === 'recording') recorder.pause();
},
resume: () => {
if (recorder.state === 'paused') recorder.resume();
},
stop: () => new Promise<Blob>((resolve) => {
recorder.onstop = async () => {
processor.disconnect();
silent.disconnect();
mic.getTracks().forEach((track) => track.stop());
display?.getTracks().forEach((track) => track.stop());
await context.close();
resolve(new Blob(chunks, { type: recorder.mimeType || (hasVideo ? 'video/webm' : 'audio/webm') }));
};
recorder.stop();
}),
};
}

View File

@@ -0,0 +1,45 @@
import { emit } from '@tauri-apps/api/event';
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
import type { LiveState } from '../types';
import { isTauriRuntime } from './tauri';
const KEY = 'meetvault-live-state-v1';
export async function broadcastLiveState(state: LiveState): Promise<void> {
localStorage.setItem(KEY, JSON.stringify(state));
if (isTauriRuntime()) {
try { await emit('meetvault:live-state', state); } catch { /* ignored in web preview */ }
}
}
export function getCachedLiveState(): LiveState | undefined {
const raw = localStorage.getItem(KEY);
if (!raw) return undefined;
try { return JSON.parse(raw) as LiveState; } catch { return undefined; }
}
export async function openOverlayWindow(): Promise<boolean> {
if (!isTauriRuntime()) return false;
const existing = await WebviewWindow.getByLabel('overlay');
if (existing) {
await existing.show();
await existing.setFocus();
return true;
}
const overlay = new WebviewWindow('overlay', {
url: 'index.html#/overlay',
title: 'MeetVault Live Overlay',
width: 900,
height: 250,
decorations: false,
transparent: true,
alwaysOnTop: true,
resizable: true,
skipTaskbar: true,
shadow: false,
});
return new Promise((resolve) => {
overlay.once('tauri://created', () => resolve(true));
overlay.once('tauri://error', () => resolve(false));
});
}

View File

@@ -0,0 +1,30 @@
import { convertFileSrc } from '@tauri-apps/api/core';
import { appLocalDataDir, join } from '@tauri-apps/api/path';
import { BaseDirectory, mkdir, writeFile, writeTextFile } from '@tauri-apps/plugin-fs';
import type { Meeting } from '../types';
import { isTauriRuntime } from './tauri';
export async function saveMeetingLocally(meeting: Meeting, media: Blob): Promise<{ mediaPath: string; mediaMime: string }> {
if (!isTauriRuntime()) {
return { mediaPath: URL.createObjectURL(media), mediaMime: media.type || 'video/webm' };
}
const dir = `meetings/${meeting.id}`;
await mkdir(dir, { baseDir: BaseDirectory.AppLocalData, recursive: true });
const extension = media.type.startsWith('audio/') ? 'webm' : 'webm';
const relativeMediaPath = `${dir}/recording.${extension}`;
const bytes = new Uint8Array(await media.arrayBuffer());
await writeFile(relativeMediaPath, bytes, { baseDir: BaseDirectory.AppLocalData });
await writeTextFile(`${dir}/meeting.json`, JSON.stringify(meeting, null, 2), { baseDir: BaseDirectory.AppLocalData });
await writeTextFile(`${dir}/transcript.json`, JSON.stringify(meeting.transcript, null, 2), { baseDir: BaseDirectory.AppLocalData });
const root = await appLocalDataDir();
const mediaPath = await join(root, relativeMediaPath);
return { mediaPath, mediaMime: media.type || 'video/webm' };
}
export function mediaUrl(path?: string): string | undefined {
if (!path) return undefined;
if (path.startsWith('blob:') || path.startsWith('http:') || path.startsWith('https:')) return path;
return isTauriRuntime() ? convertFileSrc(path) : path;
}

View File

@@ -0,0 +1,92 @@
import type { AppSettings, SpeechEvent } from '../types';
export class SpeechClient {
private ws?: WebSocket;
private mockTimer = 0;
private mockSamples = 0;
private mockIndex = 0;
private onEvent: (event: SpeechEvent) => void;
private settings: AppSettings;
constructor(settings: AppSettings, onEvent: (event: SpeechEvent) => void) {
this.settings = settings;
this.onEvent = onEvent;
}
async connect(): Promise<void> {
if (this.settings.speechMode === 'mock') {
this.onEvent({ type: 'ready', engine: 'mock-whisper', diarization: 'mock-diart' });
return;
}
await new Promise<void>((resolve, reject) => {
const ws = new WebSocket(this.settings.speechEngineUrl);
this.ws = ws;
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
ws.send(JSON.stringify({
type: 'config',
model: this.settings.whisperModel,
compute: this.settings.computeDevice,
language: this.settings.language,
diarization: this.settings.diarization,
sample_rate: 16000,
}));
resolve();
};
ws.onerror = () => reject(new Error('Could not connect to the local speech engine. Start speech-engine/server.py or switch Settings → Speech engine mode to Mock.'));
ws.onmessage = (message) => {
try {
const event = JSON.parse(String(message.data)) as SpeechEvent;
this.onEvent(event);
} catch {
// Ignore malformed diagnostics.
}
};
ws.onclose = () => this.onEvent({ type: 'health', message: 'Speech engine disconnected' });
});
}
sendPcm(pcm: Int16Array): void {
if (this.settings.speechMode === 'mock') {
this.mockSamples += pcm.length;
if (this.mockSamples > 16000 * 2.6) {
this.mockSamples = 0;
this.emitMockLine();
}
return;
}
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(pcm.buffer.slice(pcm.byteOffset, pcm.byteOffset + pcm.byteLength));
}
}
stop(): void {
if (this.mockTimer) window.clearTimeout(this.mockTimer);
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify({ type: 'stop' }));
this.ws?.close();
}
private emitMockLine(): void {
const lines = [
['speaker_1', 'We should finalize the upload flow before Friday.'],
['speaker_2', 'I can take the local storage and video playback work.'],
['speaker_1', 'Great. The backend can remain in processing state for the demo.'],
['speaker_3', 'Can we also keep the live overlay visible while another app is open?'],
];
const [speakerId, text] = lines[this.mockIndex % lines.length];
this.mockIndex += 1;
const now = this.mockIndex * 2.6;
this.onEvent({
type: 'final',
segment: {
id: crypto.randomUUID(),
speakerId,
start: Math.max(0, now - 2.4),
end: now,
text,
final: true,
},
});
}
}

View File

@@ -0,0 +1,3 @@
export function isTauriRuntime(): boolean {
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
}

View File

@@ -0,0 +1,8 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode><App /></React.StrictMode>,
);

View File

@@ -0,0 +1,64 @@
import { HardDrive, MonitorUp, Mic2, Plus } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { StatusBadge } from '../components/StatusBadge';
import { useAppStore } from '../store/useAppStore';
function fmt(seconds: number) {
if (!seconds) return '—';
return `${Math.round(seconds / 60)} min`;
}
export function Dashboard() {
const navigate = useNavigate();
const meetings = useAppStore((s) => s.meetings);
const settings = useAppStore((s) => s.settings);
return (
<div className="page dashboard-page">
<div className="page-heading-row">
<div>
<h1>Good morning</h1>
<p>Record, transcribe, identify speakers, and review meetings locally.</p>
</div>
<button className="btn primary" onClick={() => navigate('/live?start=1')}><Plus size={18} /> Start new meeting</button>
</div>
<section className="dashboard-cards">
<article className="card dashboard-card">
<div className="card-title"><Mic2 size={18} /> Quick start</div>
<p>Microphone: {settings.microphoneDeviceId === 'default' ? 'Default system microphone' : settings.microphoneDeviceId}</p>
<p>Local STT: Whisper {settings.whisperModel}</p>
<button className="btn primary small" onClick={() => navigate('/live?start=1')}>Start recording</button>
</article>
<article className="card dashboard-card">
<div className="card-title"><MonitorUp size={18} /> Live overlay</div>
<p>Overlay: {settings.overlayEnabled ? 'Enabled' : 'Disabled'}</p>
<p>Speaker labels: On · renameable live</p>
<button className="btn secondary small" onClick={() => navigate('/live')}>Preview / configure</button>
</article>
<article className="card dashboard-card">
<div className="card-title"><HardDrive size={18} /> Local storage</div>
<p>Files are stored locally for this demo.</p>
<p>No login, subscription, PostgreSQL, or SeaweedFS is used yet.</p>
<span className="badge badge-ready">Local first</span>
</article>
</section>
<section className="section-block">
<h2>Recent meetings</h2>
<div className="table-card">
<div className="table-row table-head"><span>Title</span><span>Date</span><span>Duration</span><span>Status</span><span>Actions</span></div>
{meetings.slice(0, 6).map((meeting) => (
<div className="table-row" key={meeting.id}>
<strong>{meeting.title}</strong>
<span>{new Date(meeting.startedAt).toLocaleDateString()}</span>
<span>{fmt(meeting.durationSeconds)}</span>
<span><StatusBadge status={meeting.status} /></span>
<span><button className="btn secondary tiny" onClick={() => navigate(`/meetings/${meeting.id}`)}>Open</button></span>
</div>
))}
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,178 @@
import { listen } from '@tauri-apps/api/event';
import { Mic, MonitorUp, Pause, Play, Square } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import type { SpeechEvent } from '../types';
import { startCapture, type CaptureController } from '../lib/capture';
import { broadcastLiveState, openOverlayWindow } from '../lib/liveBus';
import { saveMeetingLocally } from '../lib/localFiles';
import { SpeechClient } from '../lib/speechClient';
import { isTauriRuntime } from '../lib/tauri';
import { useAppStore } from '../store/useAppStore';
function secondsFmt(total: number) {
const m = Math.floor(total / 60).toString().padStart(2, '0');
const s = Math.floor(total % 60).toString().padStart(2, '0');
return `${m}:${s}`;
}
export function LiveMeeting() {
const navigate = useNavigate();
const [params] = useSearchParams();
const meetings = useAppStore((s) => s.meetings);
const activeMeetingId = useAppStore((s) => s.activeMeetingId);
const createMeeting = useAppStore((s) => s.createMeeting);
const appendSegment = useAppStore((s) => s.appendSegment);
const renameSpeaker = useAppStore((s) => s.renameSpeaker);
const finishMeeting = useAppStore((s) => s.finishMeeting);
const settings = useAppStore((s) => s.settings);
const [meetingId, setMeetingId] = useState<string | undefined>(activeMeetingId);
const [engineState, setEngineState] = useState('Idle');
const [partial, setPartial] = useState('');
const [elapsed, setElapsed] = useState(0);
const [micLevel, setMicLevel] = useState(0);
const [running, setRunning] = useState(false);
const [paused, setPaused] = useState(false);
const [error, setError] = useState<string>();
const captureRef = useRef<CaptureController>();
const speechRef = useRef<SpeechClient>();
const timerRef = useRef<number>();
const hasAutoStarted = useRef(false);
const meeting = useMemo(() => meetings.find((m) => m.id === meetingId), [meetings, meetingId]);
async function pushLiveState(id = meetingId) {
const current = useAppStore.getState().meetings.find((m) => m.id === id);
if (!current) return;
await broadcastLiveState({ meetingId: current.id, title: current.title, elapsedSeconds: elapsed, transcript: current.transcript, speakerNames: current.speakerNames, recording: running });
}
useEffect(() => { if (meetingId) void pushLiveState(); }, [meeting?.transcript.length, meeting?.speakerNames, elapsed, running]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!isTauriRuntime()) return;
let unlisten: (() => void) | undefined;
listen<{ meetingId?: string; speakerId: string; name: string }>('meetvault:rename-speaker', (event) => {
if (!event.payload.meetingId || event.payload.meetingId === meetingId) renameSpeaker(meetingId!, event.payload.speakerId, event.payload.name);
}).then((fn) => { unlisten = fn; });
return () => unlisten?.();
}, [meetingId, renameSpeaker]);
useEffect(() => {
if (params.get('start') === '1' && !hasAutoStarted.current) {
hasAutoStarted.current = true;
void startLive();
}
return () => {
if (timerRef.current) window.clearInterval(timerRef.current);
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
async function startLive() {
try {
setError(undefined);
let id = meetingId;
if (!id) {
id = createMeeting('Weekly Product Sync');
setMeetingId(id);
}
const client = new SpeechClient(settings, (event: SpeechEvent) => {
if (event.type === 'final') {
appendSegment(id!, event.segment);
setPartial('');
} else if (event.type === 'partial') {
setPartial(event.text);
} else if (event.type === 'ready') {
setEngineState('Whisper + diart ready');
} else if (event.type === 'health') {
setEngineState(event.message || 'Healthy');
} else if (event.type === 'error') {
setError(event.message);
}
});
await client.connect();
speechRef.current = client;
const capture = await startCapture({
captureSystemAudio: settings.captureSystemAudio,
microphoneDeviceId: settings.microphoneDeviceId,
onPcm: (pcm) => client.sendPcm(pcm),
onLevel: setMicLevel,
});
captureRef.current = capture;
setRunning(true);
setEngineState('Healthy');
const started = Date.now();
timerRef.current = window.setInterval(() => setElapsed(Math.floor((Date.now() - started) / 1000)), 1000);
if (settings.overlayEnabled) await openOverlayWindow();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setEngineState('Unavailable');
}
}
async function togglePause() {
if (!captureRef.current) return;
if (paused) captureRef.current.resume(); else captureRef.current.pause();
setPaused(!paused);
}
async function finish() {
if (!meetingId || !captureRef.current) return;
if (timerRef.current) window.clearInterval(timerRef.current);
speechRef.current?.stop();
const media = await captureRef.current.stop();
const current = useAppStore.getState().meetings.find((m) => m.id === meetingId)!;
const saved = await saveMeetingLocally(current, media);
finishMeeting(meetingId, saved.mediaPath, saved.mediaMime);
setRunning(false);
await pushLiveState(meetingId);
navigate(`/meetings/${meetingId}`);
}
function rename(speakerId: string) {
if (!meetingId) return;
const next = window.prompt('Speaker name', meeting?.speakerNames[speakerId] || speakerId);
if (next) renameSpeaker(meetingId, speakerId, next);
}
const speakers = Array.from(new Set(meeting?.transcript.map((s) => s.speakerId) ?? []));
return (
<div className="page live-page">
<div className="page-heading-row">
<div><h1>{meeting?.title || 'Live Meeting'}</h1><p>Real-time local transcription and speaker diarization.</p></div>
<div className="rec-badge">● REC {secondsFmt(elapsed)}</div>
</div>
{error && <div className="error-banner">{error}</div>}
{!running && <div className="start-live-panel card"><h2>Ready to start</h2><p>Live transcription uses the local speech engine. Switch Settings → Speech engine mode to Mock if you only want to test the UI.</p><button className="btn primary" onClick={startLive}><Mic size={18} /> Start live meeting</button></div>}
<div className="live-grid">
<section className="card transcript-panel">
<h2>Live transcription</h2>
<div className="transcript-live-scroll">
{meeting?.transcript.map((segment) => (
<div className="live-line" key={segment.id}>
<button className="speaker-pill" onClick={() => rename(segment.speakerId)}>{meeting.speakerNames[segment.speakerId] || segment.speakerId}</button>
<span>{segment.text}</span>
</div>
))}
{partial && <div className="partial-line">Listening… {partial}</div>}
</div>
<div className="level-row"><span>Mic</span><div className="level-track"><i style={{ width: `${Math.min(100, micLevel * 420)}%` }} /></div><span>{engineState}</span></div>
<div className="live-actions"><button className="btn secondary" disabled={!running} onClick={togglePause}>{paused ? <Play size={16} /> : <Pause size={16} />} {paused ? 'Resume' : 'Pause'}</button><button className="btn primary" onClick={openOverlayWindow}><MonitorUp size={16} /> Show overlay</button><button className="btn danger" disabled={!running} onClick={finish}><Square size={16} /> Finish meeting</button></div>
</section>
<aside className="card live-side">
<h2>Speakers</h2>
<div className="speaker-editor-list">
{speakers.length === 0 && <span className="muted">Speaker labels will appear after diarization starts.</span>}
{speakers.map((speakerId) => <div className="speaker-editor" key={speakerId}><input value={meeting?.speakerNames[speakerId] || speakerId} onChange={(e) => renameSpeaker(meetingId!, speakerId, e.target.value)} /><button className="btn secondary tiny" onClick={() => rename(speakerId)}>Rename</button></div>)}
</div>
<h3>Audio sources</h3><div className="source-chips"><span>✓ Microphone</span><span>{settings.captureSystemAudio ? '✓' : '○'} System audio</span></div>
<h3>Local transcription</h3><div className="engine-summary">Whisper {settings.whisperModel}<br />Compute: {settings.computeDevice}<br />Diarization: {settings.diarization ? 'diart enabled' : 'disabled'}</div><span className="badge badge-ready">{engineState}</span>
</aside>
</div>
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { ArrowLeft, Copy, RefreshCw, BellRing } from 'lucide-react';
import { useNavigate, useParams } from 'react-router-dom';
import { ProcessingCard } from '../components/ProcessingCard';
import { StatusBadge } from '../components/StatusBadge';
import { VideoPlayer } from '../components/VideoPlayer';
import { requestDemoBackendJob } from '../lib/backend';
import { useAppStore } from '../store/useAppStore';
function ts(value: number) {
const m = Math.floor(value / 60).toString().padStart(2, '0');
const s = Math.floor(value % 60).toString().padStart(2, '0');
return `${m}:${s}`;
}
export function MeetingDetails() {
const { id } = useParams();
const navigate = useNavigate();
const meeting = useAppStore((s) => s.meetings.find((m) => m.id === id));
const renameSpeaker = useAppStore((s) => s.renameSpeaker);
const setProcessing = useAppStore((s) => s.setProcessing);
if (!meeting) return <div className="page"><h1>Meeting not found</h1></div>;
async function backendAction() {
await requestDemoBackendJob();
setProcessing(meeting!.id);
}
async function copyTranscript() {
const text = meeting!.transcript.map((s) => `${meeting!.speakerNames[s.speakerId] || s.speakerId}: ${s.text}`).join('\n');
await navigator.clipboard.writeText(text);
}
function rename(speakerId: string) {
const next = window.prompt('Speaker name', meeting!.speakerNames[speakerId] || speakerId);
if (next) renameSpeaker(meeting!.id, speakerId, next);
}
return (
<div className="page details-page">
<button className="btn ghost" onClick={() => navigate('/meetings')}><ArrowLeft size={17} /> Back to meetings</button>
<div className="page-heading-row">
<div><h1>{meeting.title}</h1><p>{new Date(meeting.startedAt).toLocaleString()} · {Math.round(meeting.durationSeconds / 60)} min · {Object.keys(meeting.speakerNames).length || 'Detected'} speakers</p></div>
<StatusBadge status={meeting.status} />
</div>
<div className="details-top-grid">
<div className="card video-card"><VideoPlayer path={meeting.mediaPath} mime={meeting.mediaMime} /></div>
<div className="card info-card">
<h3>Meeting information</h3>
<dl><dt>Storage</dt><dd>Local</dd><dt>Status</dt><dd>{meeting.status}</dd><dt>Started</dt><dd>{new Date(meeting.startedAt).toLocaleString()}</dd><dt>Ended</dt><dd>{meeting.endedAt ? new Date(meeting.endedAt).toLocaleString() : '—'}</dd></dl>
</div>
</div>
<div className="tabs-bar">Transcript <span>Summary</span> <span>Action items</span> <span>Files</span></div>
<div className="details-bottom-grid">
<section>
<div className="section-title-row"><h2>Transcript</h2><button className="btn secondary tiny" onClick={copyTranscript}><Copy size={15} /> Copy</button></div>
<div className="transcript-list card">
{meeting.transcript.length === 0 && <div className="empty-state">No transcript stored for this demo meeting.</div>}
{meeting.transcript.map((segment) => (
<div className="transcript-line" key={segment.id}>
<button className="speaker-pill" onClick={() => rename(segment.speakerId)}>{meeting.speakerNames[segment.speakerId] || segment.speakerId} {ts(segment.start)}</button>
<p>{segment.text}</p>
</div>
))}
</div>
</section>
<section>
<h2>Backend demo</h2>
<ProcessingCard />
<div className="card backend-actions">
<p>Summary, tasks and reminders are intentionally not implemented in this demo backend.</p>
<div><button className="btn secondary" onClick={backendAction}><RefreshCw size={16} /> Regenerate summary</button><button className="btn primary" onClick={backendAction}><BellRing size={16} /> Create reminder</button></div>
</div>
</section>
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { Search } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { StatusBadge } from '../components/StatusBadge';
import { useAppStore } from '../store/useAppStore';
export function Meetings() {
const navigate = useNavigate();
const meetings = useAppStore((s) => s.meetings);
const [query, setQuery] = useState('');
const filtered = useMemo(() => meetings.filter((m) => m.title.toLowerCase().includes(query.toLowerCase())), [meetings, query]);
return (
<div className="page">
<div className="page-heading-row"><div><h1>Meetings</h1><p>Local recordings and transcripts.</p></div></div>
<div className="search-box"><Search size={18} /><input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search meetings" /></div>
<div className="meeting-grid">
{filtered.map((meeting) => (
<button className="meeting-card" key={meeting.id} onClick={() => navigate(`/meetings/${meeting.id}`)}>
<div><strong>{meeting.title}</strong><span>{new Date(meeting.startedAt).toLocaleString()}</span></div>
<StatusBadge status={meeting.status} />
<span>{meeting.durationSeconds ? `${Math.round(meeting.durationSeconds / 60)} min` : 'In progress'}</span>
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { listen, emit } from '@tauri-apps/api/event';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { useEffect, useMemo, useState } from 'react';
import type { LiveState } from '../types';
import { getCachedLiveState } from '../lib/liveBus';
import { useAppStore } from '../store/useAppStore';
import { isTauriRuntime } from '../lib/tauri';
export function Overlay() {
const settings = useAppStore((s) => s.settings);
const [state, setState] = useState<LiveState | undefined>(() => getCachedLiveState());
useEffect(() => {
if (!isTauriRuntime()) return;
let unlisten: (() => void) | undefined;
listen<LiveState>('meetvault:live-state', (event) => setState(event.payload)).then((fn) => { unlisten = fn; });
return () => unlisten?.();
}, []);
const visible = useMemo(() => state?.transcript.slice(-settings.overlayLines) ?? [], [state, settings.overlayLines]);
async function rename(speakerId: string) {
const current = state?.speakerNames[speakerId] || speakerId;
const next = window.prompt('Rename speaker', current);
if (!next) return;
if (isTauriRuntime()) await emit('meetvault:rename-speaker', { meetingId: state?.meetingId, speakerId, name: next });
}
return (
<div className="overlay-root" style={{ opacity: settings.overlayOpacity / 100, fontSize: settings.overlayFontSize }}>
<div className="overlay-header"><span>MeetVault live captions</span>{isTauriRuntime() && <button onClick={() => getCurrentWindow().hide()}>Hide</button>}</div>
{visible.length === 0 ? <div className="overlay-empty">Waiting for speech…</div> : visible.map((segment) => (
<div className="overlay-line" key={segment.id}>
<button onClick={() => rename(segment.speakerId)}>{state?.speakerNames[segment.speakerId] || segment.speakerId}</button>
<span>{segment.text}</span>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { useEffect, useState } from 'react';
import { Save } from 'lucide-react';
import { useAppStore } from '../store/useAppStore';
export function SettingsPage() {
const settings = useAppStore((s) => s.settings);
const setSettings = useAppStore((s) => s.setSettings);
const [draft, setDraft] = useState(settings);
const [saved, setSaved] = useState(false);
useEffect(() => setDraft(settings), [settings]);
const set = <K extends keyof typeof draft>(key: K, value: (typeof draft)[K]) => setDraft((d) => ({ ...d, [key]: value }));
return (
<div className="page settings-page">
<div className="page-heading-row"><div><h1>Settings</h1><p>Configure MeetVault desktop behavior and local transcription.</p></div></div>
<section className="settings-section"><h2>General</h2><div className="settings-card card three-col">
<label className="toggle-row"><input type="checkbox" checked={draft.launchAtStartup} onChange={(e) => set('launchAtStartup', e.target.checked)} /> Launch MeetVault at startup</label>
<label><span>Global hotkey</span><input value={draft.globalHotkey} onChange={(e) => set('globalHotkey', e.target.value)} /></label>
<label><span>Theme</span><select value={draft.theme} onChange={(e) => set('theme', e.target.value as typeof draft.theme)}><option value="system">System default</option><option value="light">Light</option><option value="dark">Dark</option></select></label>
<label className="toggle-row"><input type="checkbox" checked={draft.keepInTray} onChange={(e) => set('keepInTray', e.target.checked)} /> Keep running in system tray</label>
</div></section>
<section className="settings-section"><h2>Audio</h2><div className="settings-card card three-col">
<label><span>Microphone</span><input value={draft.microphoneDeviceId} onChange={(e) => set('microphoneDeviceId', e.target.value)} /></label>
<label className="toggle-row"><input type="checkbox" checked={draft.captureSystemAudio} onChange={(e) => set('captureSystemAudio', e.target.checked)} /> Capture system audio / screen</label>
<label className="toggle-row"><input type="checkbox" checked={draft.monitorMicLevel} onChange={(e) => set('monitorMicLevel', e.target.checked)} /> Monitor microphone level</label>
</div></section>
<section className="settings-section"><h2>Transcription</h2><div className="settings-card card four-col">
<label><span>Model</span><select value={draft.whisperModel} onChange={(e) => set('whisperModel', e.target.value as typeof draft.whisperModel)}><option>base</option><option>small</option><option>medium</option></select></label>
<label><span>Compute</span><select value={draft.computeDevice} onChange={(e) => set('computeDevice', e.target.value as typeof draft.computeDevice)}><option value="auto">Auto</option><option value="cpu">CPU</option><option value="cuda">CUDA</option></select></label>
<label><span>Language</span><input value={draft.language} onChange={(e) => set('language', e.target.value)} /></label>
<label className="toggle-row"><input type="checkbox" checked={draft.diarization} onChange={(e) => set('diarization', e.target.checked)} /> Speaker diarization</label>
</div></section>
<section className="settings-section"><h2>Overlay</h2><div className="settings-card card four-col">
<label className="toggle-row"><input type="checkbox" checked={draft.overlayEnabled} onChange={(e) => set('overlayEnabled', e.target.checked)} /> Enable overlay</label>
<label><span>Opacity</span><input type="range" min="40" max="100" value={draft.overlayOpacity} onChange={(e) => set('overlayOpacity', Number(e.target.value))} /></label>
<label><span>Font size</span><input type="number" min="14" max="36" value={draft.overlayFontSize} onChange={(e) => set('overlayFontSize', Number(e.target.value))} /></label>
<label><span>Visible lines</span><input type="number" min="1" max="8" value={draft.overlayLines} onChange={(e) => set('overlayLines', Number(e.target.value))} /></label>
</div></section>
<section className="settings-section"><h2>Local speech engine</h2><div className="settings-card card three-col">
<label><span>Mode</span><select value={draft.speechMode} onChange={(e) => set('speechMode', e.target.value as typeof draft.speechMode)}><option value="real">Real Whisper + diart</option><option value="mock">Mock demo</option></select></label>
<label className="wide"><span>WebSocket URL</span><input value={draft.speechEngineUrl} onChange={(e) => set('speechEngineUrl', e.target.value)} /></label>
<div className="settings-help">Real mode requires <code>speech-engine/server.py</code>. Mock mode lets the UI run without ML dependencies.</div>
</div></section>
<div className="save-bar"><span>{saved ? 'Saved.' : 'Local settings only.'}</span><button className="btn primary" onClick={() => { setSettings(draft); setSaved(true); window.setTimeout(() => setSaved(false), 1200); }}><Save size={17} /> Save changes</button></div>
</div>
);
}

View File

@@ -0,0 +1,144 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { AppSettings, Meeting, TranscriptSegment } from '../types';
const defaultSettings: AppSettings = {
launchAtStartup: false,
keepInTray: true,
globalHotkey: 'Ctrl + Shift + M',
theme: 'system',
microphoneDeviceId: 'default',
captureSystemAudio: false,
monitorMicLevel: true,
whisperModel: 'small',
computeDevice: 'auto',
language: 'auto',
diarization: true,
overlayEnabled: true,
overlayOpacity: 92,
overlayFontSize: 20,
overlayLines: 4,
overlayClickThrough: false,
speechEngineUrl: 'ws://127.0.0.1:8765/ws/live',
speechMode: 'real',
};
const demoMeetings: Meeting[] = [
{
id: 'demo-weekly-product-sync',
title: 'Weekly Product Sync',
startedAt: '2026-09-27T09:00:00.000Z',
endedAt: '2026-09-27T09:48:05.000Z',
durationSeconds: 2885,
status: 'processing',
transcript: [
{ id: 'd1', speakerId: 'speaker_1', start: 1121, end: 1126, text: 'We should finalize the API contract before tomorrow.', final: true },
{ id: 'd2', speakerId: 'speaker_2', start: 1129, end: 1135, text: 'I will update the database migration tonight.', final: true },
{ id: 'd3', speakerId: 'speaker_1', start: 1142, end: 1148, text: "Perfect. I'll prepare the release notes after that.", final: true },
],
speakerNames: { speaker_1: 'Alice', speaker_2: 'Bob' },
localOnly: true,
},
{
id: 'demo-backend-review',
title: 'Backend Architecture Review',
startedAt: '2026-09-26T08:30:00.000Z',
endedAt: '2026-09-26T09:31:00.000Z',
durationSeconds: 3660,
status: 'ready',
transcript: [],
speakerNames: {},
localOnly: true,
},
];
interface AppState {
settings: AppSettings;
meetings: Meeting[];
activeMeetingId?: string;
processingMessage?: string;
setSettings: (patch: Partial<AppSettings>) => void;
createMeeting: (title?: string) => string;
appendSegment: (meetingId: string, segment: TranscriptSegment) => void;
renameSpeaker: (meetingId: string, speakerId: string, name: string) => void;
finishMeeting: (meetingId: string, mediaPath?: string, mediaMime?: string) => void;
setMeetingMedia: (meetingId: string, mediaPath: string, mediaMime: string) => void;
setProcessing: (meetingId: string) => void;
clearProcessingMessage: () => void;
}
export const useAppStore = create<AppState>()(
persist(
(set, get) => ({
settings: defaultSettings,
meetings: demoMeetings,
activeMeetingId: undefined,
processingMessage: undefined,
setSettings: (patch) => set((state) => ({ settings: { ...state.settings, ...patch } })),
createMeeting: (title = 'Untitled Meeting') => {
const id = crypto.randomUUID();
const meeting: Meeting = {
id,
title,
startedAt: new Date().toISOString(),
durationSeconds: 0,
status: 'recording',
transcript: [],
speakerNames: {},
localOnly: true,
};
set((state) => ({ meetings: [meeting, ...state.meetings], activeMeetingId: id }));
return id;
},
appendSegment: (meetingId, segment) => {
set((state) => ({
meetings: state.meetings.map((m) => {
if (m.id !== meetingId) return m;
const exists = m.transcript.some((s) => s.id === segment.id);
return {
...m,
transcript: exists ? m.transcript.map((s) => (s.id === segment.id ? segment : s)) : [...m.transcript, segment],
};
}),
}));
},
renameSpeaker: (meetingId, speakerId, name) => {
set((state) => ({
meetings: state.meetings.map((m) => m.id === meetingId
? { ...m, speakerNames: { ...m.speakerNames, [speakerId]: name.trim() || speakerId } }
: m),
}));
},
finishMeeting: (meetingId, mediaPath, mediaMime) => {
const meeting = get().meetings.find((m) => m.id === meetingId);
if (!meeting) return;
const endedAt = new Date().toISOString();
const durationSeconds = Math.max(1, Math.round((Date.parse(endedAt) - Date.parse(meeting.startedAt)) / 1000));
set((state) => ({
meetings: state.meetings.map((m) => m.id === meetingId ? {
...m,
endedAt,
durationSeconds,
status: 'processing',
mediaPath: mediaPath ?? m.mediaPath,
mediaMime: mediaMime ?? m.mediaMime,
} : m),
activeMeetingId: undefined,
processingMessage: 'Processing (The job). Please wait a moment',
}));
},
setMeetingMedia: (meetingId, mediaPath, mediaMime) => set((state) => ({
meetings: state.meetings.map((m) => m.id === meetingId ? { ...m, mediaPath, mediaMime } : m),
})),
setProcessing: (meetingId) => set((state) => ({
meetings: state.meetings.map((m) => m.id === meetingId ? { ...m, status: 'processing' } : m),
processingMessage: 'Processing (The job). Please wait a moment',
})),
clearProcessingMessage: () => set({ processingMessage: undefined }),
}),
{
name: 'meetvault-demo-store-v1',
partialize: (state) => ({ settings: state.settings, meetings: state.meetings }),
},
),
);

View File

@@ -0,0 +1,138 @@
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #13233f;
background: #f5f7fb;
font-synthesis: none;
}
* { box-sizing: border-box; }
html, body, #root { margin: 0; min-width: 100%; min-height: 100%; }
body { min-height: 100vh; background: #f5f7fb; }
button, input, select { font: inherit; }
button { cursor: pointer; }
code { background: #eef2f8; padding: 2px 6px; border-radius: 6px; }
.app-shell { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }
.sidebar { background: #20252d; color: white; padding: 28px 22px; display: flex; flex-direction: column; gap: 36px; }
.brand { font-size: 22px; font-weight: 800; letter-spacing: -0.02em; }
.sidebar nav { display: grid; gap: 10px; }
.sidebar nav a { color: #d8dee8; text-decoration: none; display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-radius: 10px; font-weight: 650; }
.sidebar nav a.active { background: #2d66ec; color: white; }
.sidebar-note { margin-top: auto; background: #f4f6fa; color: #27354d; padding: 14px; border-radius: 12px; display: grid; gap: 4px; font-size: 12px; }
.main-area { min-width: 0; }
.page { padding: 42px 48px 56px; max-width: 1500px; margin: 0 auto; }
.page h1 { font-size: 28px; margin: 0 0 8px; letter-spacing: -0.02em; }
.page h2 { font-size: 18px; margin: 0; }
.page h3 { font-size: 15px; margin: 24px 0 10px; }
.page p { color: #5b6780; }
.page-heading-row { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; margin-bottom: 36px; }
.page-heading-row p { margin: 0; }
.btn { border: 0; border-radius: 9px; padding: 11px 15px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-weight: 700; }
.btn.primary { background: #2d66ec; color: white; }
.btn.secondary { background: #edf2ff; color: #2453c3; border: 1px solid #cad7ff; }
.btn.danger { background: #fff0ef; color: #c3382d; border: 1px solid #ffd0cb; }
.btn.ghost { background: transparent; color: #2f4b7c; padding-left: 0; }
.btn.small { padding: 9px 13px; }
.btn.tiny { padding: 7px 11px; font-size: 12px; }
.btn:disabled { opacity: .45; cursor: default; }
.card { background: white; border: 1px solid #dfe5ef; border-radius: 18px; box-shadow: 0 1px 0 rgba(20, 40, 80, .02); }
.badge { display: inline-flex; align-items: center; justify-content: center; min-width: 82px; padding: 6px 10px; border-radius: 7px; font-size: 12px; font-weight: 750; }
.badge-ready { background: #eafff5; color: #137b58; border: 1px solid #bdebd9; }
.badge-processing { background: #eef3ff; color: #3b5fc5; border: 1px solid #ccd8ff; }
.badge-recording { background: #fff1ef; color: #c43b30; border: 1px solid #ffd1cb; }
.dashboard-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; }
.dashboard-card { padding: 24px; min-height: 190px; }
.dashboard-card p { margin: 8px 0; font-size: 14px; }
.card-title { display: flex; align-items: center; gap: 9px; font-weight: 800; margin-bottom: 22px; }
.section-block { margin-top: 46px; }
.section-block h2 { margin-bottom: 18px; }
.table-card { background: white; border: 1px solid #dfe5ef; border-radius: 18px; padding: 10px 20px; }
.table-row { display: grid; grid-template-columns: 2.3fr 1fr .8fr .8fr .7fr; align-items: center; min-height: 64px; gap: 20px; border-bottom: 1px solid #eef1f6; }
.table-row:last-child { border-bottom: 0; }
.table-head { min-height: 46px; color: #6b7891; font-size: 12px; }
.search-box { display: flex; gap: 10px; align-items: center; background: white; border: 1px solid #dfe5ef; border-radius: 12px; padding: 0 14px; max-width: 540px; margin-bottom: 24px; }
.search-box input { border: 0; outline: 0; width: 100%; padding: 13px 0; background: transparent; }
.meeting-grid { display: grid; gap: 12px; }
.meeting-card { border: 1px solid #dfe5ef; border-radius: 15px; background: white; padding: 18px 20px; display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 22px; text-align: left; color: inherit; }
.meeting-card div { display: grid; gap: 4px; }
.meeting-card div span { color: #78849a; font-size: 12px; }
.live-grid { display: grid; grid-template-columns: minmax(0, 1.75fr) minmax(310px, .85fr); gap: 28px; }
.transcript-panel { min-height: 620px; padding: 28px; display: flex; flex-direction: column; }
.transcript-live-scroll { flex: 1; overflow: auto; padding: 18px 0; display: grid; align-content: start; gap: 18px; }
.live-line { display: grid; grid-template-columns: 118px 1fr; gap: 16px; align-items: start; }
.speaker-pill { background: #eefbf5; border: 1px solid #b8ebd6; color: #147056; border-radius: 7px; padding: 7px 10px; font-weight: 750; font-size: 12px; }
.live-line:nth-child(even) .speaker-pill, .transcript-line:nth-child(even) .speaker-pill { background: #eef2ff; border-color: #ccd7ff; color: #3556b2; }
.partial-line { background: #f2f4f8; border: 1px solid #e1e5ec; border-radius: 10px; padding: 14px; text-align: center; color: #66738a; }
.level-row { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 12px; color: #6e7a91; font-size: 12px; }
.level-track { height: 7px; border-radius: 999px; background: #edf0f5; overflow: hidden; }
.level-track i { display: block; height: 100%; background: #2d66ec; }
.live-actions { display: flex; align-items: center; gap: 10px; margin-top: 22px; }
.live-actions .danger { margin-left: auto; }
.live-side { padding: 28px; min-height: 620px; }
.speaker-editor-list { display: grid; gap: 12px; }
.speaker-editor { display: grid; grid-template-columns: 1fr auto; gap: 10px; }
.speaker-editor input, .settings-card input, .settings-card select { width: 100%; border: 1px solid #cad4e4; border-radius: 9px; background: white; padding: 10px 12px; color: #30415f; }
.source-chips { display: flex; gap: 10px; }
.source-chips span { background: #f2f4f8; padding: 10px 13px; border-radius: 9px; font-size: 12px; }
.engine-summary { line-height: 1.6; color: #58657b; margin-bottom: 14px; }
.rec-badge { background: #fff0ef; color: #c4392e; border: 1px solid #ffccc6; border-radius: 8px; padding: 10px 18px; }
.start-live-panel { padding: 22px; margin-bottom: 24px; }
.error-banner { background: #fff0ef; color: #9e2f27; border: 1px solid #ffd0ca; border-radius: 10px; padding: 12px 14px; margin-bottom: 20px; }
.muted { color: #8792a7; font-size: 13px; }
.details-top-grid { display: grid; grid-template-columns: 1.35fr .85fr; gap: 26px; }
.video-card { min-height: 330px; overflow: hidden; display: grid; place-items: center; }
.video-player { width: 100%; height: 100%; min-height: 330px; background: #10141d; object-fit: contain; }
.audio-wrap { width: 100%; padding: 40px; }
.audio-wrap audio { width: 100%; }
.video-empty { min-height: 330px; display: grid; place-items: center; align-content: center; gap: 10px; color: #7b879c; text-align: center; padding: 24px; }
.video-empty span { max-width: 470px; font-size: 13px; }
.info-card { padding: 24px; }
dl { display: grid; grid-template-columns: 110px 1fr; gap: 12px 16px; font-size: 13px; }
dt { color: #8390a6; } dd { margin: 0; }
.tabs-bar { margin: 30px 0 20px; background: #eaf0ff; border: 1px solid #cbd8ff; color: #2d55bd; border-radius: 10px; padding: 12px 18px; font-weight: 800; display: flex; gap: 32px; }
.tabs-bar span { font-weight: 600; color: #6680bd; }
.details-bottom-grid { display: grid; grid-template-columns: 1.2fr .8fr; gap: 28px; }
.section-title-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.transcript-list { padding: 20px; min-height: 280px; }
.transcript-line { display: grid; grid-template-columns: 140px 1fr; gap: 18px; align-items: start; padding: 12px 0; border-bottom: 1px solid #edf0f5; }
.transcript-line p { margin: 4px 0 0; color: #23334e; }
.empty-state { color: #8490a5; padding: 30px; text-align: center; }
.processing-card { display: flex; gap: 14px; align-items: center; background: #fff9e8; border: 1px solid #f3dea0; color: #765614; border-radius: 14px; padding: 18px; margin: 12px 0; }
.processing-card p { margin: 4px 0 0; color: #876a27; }
.spin { animation: spin 1.2s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.backend-actions { padding: 20px; }
.backend-actions div { display: flex; gap: 10px; margin-top: 18px; flex-wrap: wrap; }
.settings-section { margin-top: 28px; }
.settings-section h2 { margin-bottom: 12px; }
.settings-card { padding: 22px; display: grid; gap: 18px; }
.settings-card.three-col { grid-template-columns: repeat(3, 1fr); }
.settings-card.four-col { grid-template-columns: repeat(4, 1fr); }
.settings-card label { display: grid; gap: 7px; color: #53617a; font-size: 12px; }
.settings-card .toggle-row { display: flex; align-items: center; gap: 10px; background: #f2f4f8; padding: 11px 13px; border-radius: 9px; align-self: end; min-height: 42px; }
.settings-card .toggle-row input { width: auto; }
.settings-card .wide { grid-column: span 2; }
.settings-help { color: #6e7b90; font-size: 12px; align-self: end; }
.save-bar { position: sticky; bottom: 0; margin-top: 30px; background: rgba(245,247,251,.92); backdrop-filter: blur(8px); display: flex; justify-content: flex-end; align-items: center; gap: 16px; padding: 12px 0; }
.save-bar span { color: #6f7b90; font-size: 12px; }
.overlay-root { min-height: 100vh; background: rgba(21, 28, 42, .96); color: white; padding: 18px 26px; border-radius: 20px; overflow: hidden; }
.overlay-header { display: flex; justify-content: space-between; color: #a9b4c7; font-size: 11px; margin-bottom: 8px; }
.overlay-header button { border: 0; background: rgba(255,255,255,.1); color: white; border-radius: 7px; padding: 5px 9px; }
.overlay-line { display: grid; grid-template-columns: 110px 1fr; gap: 16px; align-items: baseline; padding: 5px 0; }
.overlay-line button { color: #85afff; font-weight: 800; background: none; border: 0; text-align: left; padding: 0; font-size: .72em; }
.overlay-line:nth-child(even) button { color: #79e7b7; }
.overlay-empty { color: #d7dde8; padding: 28px 0; text-align: center; }
@media (max-width: 1050px) {
.app-shell { grid-template-columns: 190px 1fr; }
.page { padding: 28px; }
.dashboard-cards { grid-template-columns: 1fr; }
.live-grid, .details-top-grid, .details-bottom-grid { grid-template-columns: 1fr; }
.settings-card.three-col, .settings-card.four-col { grid-template-columns: 1fr 1fr; }
}

View File

@@ -0,0 +1,82 @@
export type MeetingStatus = 'recording' | 'processing' | 'ready';
export interface TranscriptSegment {
id: string;
speakerId: string;
start: number;
end: number;
text: string;
final: boolean;
}
export interface Meeting {
id: string;
title: string;
description?: string;
startedAt: string;
endedAt?: string;
durationSeconds: number;
status: MeetingStatus;
mediaPath?: string;
mediaMime?: string;
transcript: TranscriptSegment[];
speakerNames: Record<string, string>;
localOnly: boolean;
}
export interface LiveState {
meetingId: string;
title: string;
elapsedSeconds: number;
transcript: TranscriptSegment[];
speakerNames: Record<string, string>;
recording: boolean;
}
export interface AppSettings {
launchAtStartup: boolean;
keepInTray: boolean;
globalHotkey: string;
theme: 'system' | 'light' | 'dark';
microphoneDeviceId: string;
captureSystemAudio: boolean;
monitorMicLevel: boolean;
whisperModel: 'base' | 'small' | 'medium';
computeDevice: 'auto' | 'cpu' | 'cuda';
language: string;
diarization: boolean;
overlayEnabled: boolean;
overlayOpacity: number;
overlayFontSize: number;
overlayLines: number;
overlayClickThrough: boolean;
speechEngineUrl: string;
speechMode: 'real' | 'mock';
}
export interface SpeechFinalEvent {
type: 'final';
segment: TranscriptSegment;
}
export interface SpeechPartialEvent {
type: 'partial';
text: string;
speaker_id?: string;
start?: number;
end?: number;
}
export interface SpeechHealthEvent {
type: 'ready' | 'health';
engine?: string;
diarization?: string;
message?: string;
}
export interface SpeechErrorEvent {
type: 'error';
message: string;
}
export type SpeechEvent = SpeechFinalEvent | SpeechPartialEvent | SpeechHealthEvent | SpeechErrorEvent;