Update agent
This commit is contained in:
25
Wireframes/Desktop/MeetVault-Desktop-Demo/src/App.tsx
Normal file
25
Wireframes/Desktop/MeetVault-Desktop-Demo/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
return <span className={`badge badge-${status}`}>{status[0].toUpperCase() + status.slice(1)}</span>;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
11
Wireframes/Desktop/MeetVault-Desktop-Demo/src/lib/backend.ts
Normal file
11
Wireframes/Desktop/MeetVault-Desktop-Demo/src/lib/backend.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
124
Wireframes/Desktop/MeetVault-Desktop-Demo/src/lib/capture.ts
Normal file
124
Wireframes/Desktop/MeetVault-Desktop-Demo/src/lib/capture.ts
Normal 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();
|
||||
}),
|
||||
};
|
||||
}
|
||||
45
Wireframes/Desktop/MeetVault-Desktop-Demo/src/lib/liveBus.ts
Normal file
45
Wireframes/Desktop/MeetVault-Desktop-Demo/src/lib/liveBus.ts
Normal 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));
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function isTauriRuntime(): boolean {
|
||||
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||
}
|
||||
8
Wireframes/Desktop/MeetVault-Desktop-Demo/src/main.tsx
Normal file
8
Wireframes/Desktop/MeetVault-Desktop-Demo/src/main.tsx
Normal 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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }),
|
||||
},
|
||||
),
|
||||
);
|
||||
138
Wireframes/Desktop/MeetVault-Desktop-Demo/src/styles.css
Normal file
138
Wireframes/Desktop/MeetVault-Desktop-Demo/src/styles.css
Normal 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; }
|
||||
}
|
||||
82
Wireframes/Desktop/MeetVault-Desktop-Demo/src/types.ts
Normal file
82
Wireframes/Desktop/MeetVault-Desktop-Demo/src/types.ts
Normal 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;
|
||||
Reference in New Issue
Block a user