feat(phase-b): Supabase navigation store, snapshot service, auth wiring
navigationStore: rewritten off Neo4j db names — Supabase whiteboard_rooms table, setAuthInfo(token, userId) pattern, auto-creates default room per context on first use snapshotService: rewritten to Supabase Storage REST (/storage/v1/object/authenticated/cc.users/…), setAccessToken() instance method, static methods take accessToken not dbName AuthContext/NeoUserContext: auth injected into nav store, no Neo4j db names required singlePlayerPage: loadNodeData no longer calls Neo4j; snapshot wired via accessToken navigation types: NeoGraphNode updated for Supabase-backed tree structure transcriptionStore/Service: getSession() removed, accessToken via AuthContext LLMConfigModal: auth context wiring fixes GraphNavigator/GraphSidebar: updated nav components Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
@@ -7,6 +7,14 @@ export interface TranscriptionConfig {
|
||||
useVad?: boolean;
|
||||
}
|
||||
|
||||
export interface ServerSegment {
|
||||
text: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
type ServerSegmentsCallback = (segments: ServerSegment[], isLastLive: boolean) => void;
|
||||
|
||||
export class TranscriptionService {
|
||||
private socket: WebSocket | null = null;
|
||||
private stream: MediaStream | null = null;
|
||||
@@ -14,27 +22,29 @@ export class TranscriptionService {
|
||||
private mediaStreamSource: MediaStreamAudioSourceNode | null = null;
|
||||
private workletNode: AudioWorkletNode | null = null;
|
||||
private selectedDeviceId: string = '';
|
||||
private finalizedSegmentCount: number = 0;
|
||||
private onTranscriptionUpdate: ((text: string, isFinal: boolean, metadata: { start: number, end: number }) => void) | null = null;
|
||||
private intentionalStop: boolean = false;
|
||||
private onServerSegments: ServerSegmentsCallback | null = null;
|
||||
private onDisconnect: (() => void) | null = null;
|
||||
|
||||
constructor(deviceId: string = '') {
|
||||
this.selectedDeviceId = deviceId;
|
||||
}
|
||||
|
||||
setTranscriptionCallback(callback: (text: string, isFinal: boolean, metadata: { start: number, end: number }) => void) {
|
||||
this.onTranscriptionUpdate = callback;
|
||||
setServerSegmentsCallback(callback: ServerSegmentsCallback) {
|
||||
this.onServerSegments = callback;
|
||||
}
|
||||
|
||||
setDisconnectCallback(callback: () => void) {
|
||||
this.onDisconnect = callback;
|
||||
}
|
||||
|
||||
async startTranscription(config: TranscriptionConfig = {}) {
|
||||
console.log('🎙️ Starting transcription service...');
|
||||
this.intentionalStop = false;
|
||||
|
||||
try {
|
||||
logger.info('transcription-service', '🔊 Requesting microphone access...');
|
||||
|
||||
// Call getUserMedia directly — this triggers the browser permission prompt.
|
||||
// The old code called enumerateDevices() first to find a device ID, but
|
||||
// without microphone permission deviceId is always (empty string, falsy),
|
||||
// causing an early return that never prompted the user for permission.
|
||||
const audioConstraints: MediaTrackConstraints = this.selectedDeviceId
|
||||
? { deviceId: { exact: this.selectedDeviceId } }
|
||||
: { echoCancellation: true, noiseSuppression: true };
|
||||
@@ -60,13 +70,13 @@ export class TranscriptionService {
|
||||
clearTimeout(connectionTimeout);
|
||||
logger.info('transcription-service', '✅ WebSocket connected');
|
||||
|
||||
// Send initial configuration — audio capture starts only after SERVER_READY.
|
||||
ws.send(JSON.stringify({
|
||||
uid: uuid,
|
||||
language: config.language || 'en',
|
||||
task: config.task || 'transcribe',
|
||||
model: config.modelSize || 'base',
|
||||
model: config.modelSize || 'large-v3',
|
||||
use_vad: config.useVad ?? true,
|
||||
max_connection_time: 7200, // server default is 600 s — set to 2 h
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -76,17 +86,18 @@ export class TranscriptionService {
|
||||
|
||||
ws.onclose = () => {
|
||||
logger.info('transcription-service', '🔌 WebSocket closed');
|
||||
const wasIntentional = this.intentionalStop;
|
||||
this.cleanup();
|
||||
if (!wasIntentional && this.onDisconnect) {
|
||||
this.onDisconnect();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.uid !== uuid) {
|
||||
return;
|
||||
}
|
||||
if (data.uid !== uuid) return;
|
||||
|
||||
if (data.message === 'SERVER_READY') {
|
||||
// Server is ready — now safe to start streaming audio.
|
||||
logger.info('transcription-service', '🟢 Server ready, starting audio capture');
|
||||
this.setupAudioProcessing();
|
||||
return;
|
||||
@@ -94,37 +105,29 @@ export class TranscriptionService {
|
||||
|
||||
if (data.status === 'WAIT') {
|
||||
logger.info('transcription-service', `⏳ Wait time: ${Math.round(data.message)} minutes`);
|
||||
this.intentionalStop = true;
|
||||
this.cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.message === 'DISCONNECT') {
|
||||
logger.info('transcription-service', '🔕 Server requested disconnection');
|
||||
this.intentionalStop = true;
|
||||
this.cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.onTranscriptionUpdate && data.segments && data.segments.length > 0) {
|
||||
const segments = data.segments;
|
||||
const lastIdx = segments.length - 1;
|
||||
|
||||
// Only emit segments we have not finalized yet — avoids re-processing the
|
||||
// full array on every message (which caused the stuck last segment bug).
|
||||
for (let i = this.finalizedSegmentCount; i < lastIdx; i++) {
|
||||
const seg = segments[i];
|
||||
this.onTranscriptionUpdate(seg.text, true, {
|
||||
start: parseFloat(seg.start),
|
||||
end: parseFloat(seg.end),
|
||||
});
|
||||
this.finalizedSegmentCount = i + 1;
|
||||
}
|
||||
|
||||
// Always update the live (last) segment
|
||||
const lastSeg = segments[lastIdx];
|
||||
this.onTranscriptionUpdate(lastSeg.text, lastSeg.completed ?? false, {
|
||||
start: parseFloat(lastSeg.start),
|
||||
end: parseFloat(lastSeg.end),
|
||||
});
|
||||
// Pass the full segment window directly to the store — the store owns
|
||||
// all boundary and archival decisions, matching the WhisperLive reference
|
||||
// frontend which simply re-renders the server's authoritative segment list.
|
||||
if (this.onServerSegments && data.segments && data.segments.length > 0) {
|
||||
const segs: ServerSegment[] = data.segments.map((s: any) => ({
|
||||
text: String(s.text ?? ''),
|
||||
start: parseFloat(s.start ?? 0),
|
||||
end: parseFloat(s.end ?? 0),
|
||||
}));
|
||||
const isLastLive = !(data.segments[data.segments.length - 1]?.completed);
|
||||
this.onServerSegments(segs, isLastLive);
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -134,26 +137,18 @@ export class TranscriptionService {
|
||||
}
|
||||
|
||||
private async setupAudioProcessing() {
|
||||
if (!this.stream || !this.socket) {
|
||||
return;
|
||||
}
|
||||
if (!this.stream || !this.socket) return;
|
||||
|
||||
try {
|
||||
// Request 16 kHz from the browser — it resamples natively so we send
|
||||
// the correct rate to the server without any JS resampling overhead.
|
||||
this.audioContext = new AudioContext({ sampleRate: 16000 });
|
||||
|
||||
await this.audioContext.audioWorklet.addModule('/audioWorklet.js');
|
||||
|
||||
this.mediaStreamSource = this.audioContext.createMediaStreamSource(this.stream);
|
||||
this.workletNode = new AudioWorkletNode(this.audioContext, 'audio-processor');
|
||||
|
||||
// The worklet accumulates 4096 samples (256 ms at 16 kHz) before posting,
|
||||
// matching the reference frontend chunk size and eliminating the tiny-frame
|
||||
// flood that was overwhelming the server during silence.
|
||||
this.workletNode.port.onmessage = (event) => {
|
||||
if (this.socket?.readyState === WebSocket.OPEN) {
|
||||
this.socket.send(event.data); // event.data is a transferred ArrayBuffer
|
||||
this.socket.send(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -165,7 +160,7 @@ export class TranscriptionService {
|
||||
}
|
||||
|
||||
stopTranscription() {
|
||||
// Signal the server cleanly so it can finalise the last segment.
|
||||
this.intentionalStop = true;
|
||||
if (this.socket?.readyState === WebSocket.OPEN) {
|
||||
this.socket.send('END_OF_AUDIO');
|
||||
}
|
||||
@@ -173,27 +168,22 @@ export class TranscriptionService {
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
this.finalizedSegmentCount = 0;
|
||||
if (this.workletNode) {
|
||||
this.workletNode.disconnect();
|
||||
this.workletNode = null;
|
||||
}
|
||||
|
||||
if (this.mediaStreamSource) {
|
||||
this.mediaStreamSource.disconnect();
|
||||
this.mediaStreamSource = null;
|
||||
}
|
||||
|
||||
if (this.audioContext) {
|
||||
this.audioContext.close();
|
||||
this.audioContext = null;
|
||||
}
|
||||
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
this.stream = null;
|
||||
}
|
||||
|
||||
if (this.socket) {
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
|
||||
@@ -1,15 +1,49 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Close from '@mui/icons-material/Close';
|
||||
import { useTranscriptionStore, LLMConfig } from '../../../../../stores/transcriptionStore';
|
||||
|
||||
const PROVIDERS = [
|
||||
{ value: 'openai', label: 'OpenAI' },
|
||||
{ value: 'anthropic', label: 'Anthropic' },
|
||||
{ value: 'ollama', label: 'Ollama' },
|
||||
{ value: 'ollama', label: 'Ollama (local)' },
|
||||
{ value: 'openrouter', label: 'OpenRouter' },
|
||||
{ value: 'google', label: 'Google' },
|
||||
{ value: 'google', label: 'Google Gemini' },
|
||||
] as const;
|
||||
|
||||
const WHISPER_MODELS = [
|
||||
{ value: 'tiny', label: 'Tiny (fastest, least accurate)' },
|
||||
{ value: 'tiny.en', label: 'Tiny English' },
|
||||
{ value: 'base', label: 'Base' },
|
||||
{ value: 'base.en', label: 'Base English' },
|
||||
{ value: 'small', label: 'Small' },
|
||||
{ value: 'small.en', label: 'Small English' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'medium.en', label: 'Medium English' },
|
||||
{ value: 'large-v2', label: 'Large v2' },
|
||||
{ value: 'large-v3', label: 'Large v3 (best accuracy)' },
|
||||
];
|
||||
|
||||
const fieldStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '7px 10px',
|
||||
border: '1px solid var(--color-divider)',
|
||||
borderRadius: '6px',
|
||||
backgroundColor: 'var(--color-muted)',
|
||||
color: 'var(--color-text)',
|
||||
fontSize: '13px',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-2)',
|
||||
marginBottom: '4px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
};
|
||||
|
||||
const LLMConfigModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, onClose }) => {
|
||||
const { llmConfig, setLLMConfig } = useTranscriptionStore();
|
||||
const [form, setForm] = useState<LLMConfig>(llmConfig);
|
||||
@@ -25,97 +59,196 @@ const LLMConfigModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ is
|
||||
const handleSave = () => {
|
||||
setLLMConfig(form);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
setTimeout(() => {
|
||||
setSaved(false);
|
||||
onClose();
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] overflow-y-auto">
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 99999,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div style={{ position: 'absolute', inset: 0, backgroundColor: 'rgba(0,0,0,0.5)' }} />
|
||||
|
||||
{/* Modal panel */}
|
||||
<div className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 w-full max-w-md mx-auto">
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
maxWidth: '420px',
|
||||
backgroundColor: 'var(--color-panel)',
|
||||
border: '1px solid var(--color-divider)',
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.4)',
|
||||
overflow: 'hidden',
|
||||
zIndex: 1,
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="bg-gray-50 px-4 py-3 flex items-center justify-between border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">
|
||||
LLM Provider Settings
|
||||
</h3>
|
||||
<div style={{
|
||||
padding: '14px 16px',
|
||||
borderBottom: '1px solid var(--color-divider)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}>
|
||||
<span style={{ fontSize: '14px', fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
Settings
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-500 focus:outline-none"
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--color-text-2)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '18px',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
<Close sx={{ fontSize: 20 }} />
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 py-5 sm:p-6 space-y-4">
|
||||
{/* Provider dropdown */}
|
||||
<div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '14px', maxHeight: '80vh', overflowY: 'auto' }}>
|
||||
|
||||
{/* ── Transcription section ── */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Provider
|
||||
</label>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
color: 'var(--color-text-3)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
marginBottom: '10px',
|
||||
paddingBottom: '6px',
|
||||
borderBottom: '1px solid var(--color-divider)',
|
||||
}}>
|
||||
Transcription
|
||||
</div>
|
||||
<label style={labelStyle}>Whisper Model</label>
|
||||
<select
|
||||
value={form.provider}
|
||||
onChange={(e) => setForm({ ...form, provider: e.target.value as LLMConfig['provider'] })}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
value={form.whisperModel || 'large-v3'}
|
||||
onChange={(e) => setForm({ ...form, whisperModel: e.target.value })}
|
||||
style={fieldStyle}
|
||||
>
|
||||
{PROVIDERS.map((p) => (
|
||||
<option key={p.value} value={p.value}>
|
||||
{p.label}
|
||||
</option>
|
||||
{WHISPER_MODELS.map((m) => (
|
||||
<option key={m.value} value={m.value}>{m.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<div style={{ fontSize: '11px', color: 'var(--color-text-3)', marginTop: '4px' }}>
|
||||
Larger models are more accurate but slower to load. Server has large-v3 downloaded.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model name */}
|
||||
{/* ── LLM section ── */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Model
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.model}
|
||||
onChange={(e) => setForm({ ...form, model: e.target.value })}
|
||||
placeholder="e.g. gpt-4o, claude-sonnet-4-20250514"
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
color: 'var(--color-text-3)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
marginBottom: '10px',
|
||||
paddingBottom: '6px',
|
||||
borderBottom: '1px solid var(--color-divider)',
|
||||
}}>
|
||||
AI Summary Provider
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
API Key
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.apiKey}
|
||||
onChange={(e) => setForm({ ...form, apiKey: e.target.value })}
|
||||
placeholder="sk-..."
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Provider</label>
|
||||
<select
|
||||
value={form.provider}
|
||||
onChange={(e) => setForm({ ...form, provider: e.target.value as LLMConfig['provider'] })}
|
||||
style={fieldStyle}
|
||||
>
|
||||
{PROVIDERS.map((p) => (
|
||||
<option key={p.value} value={p.value}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Note */}
|
||||
<p className="text-xs text-gray-500">
|
||||
API keys are stored locally in your browser only. They are never sent to Supabase or stored on any server.
|
||||
</p>
|
||||
<div>
|
||||
<label style={labelStyle}>Model</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.model}
|
||||
onChange={(e) => setForm({ ...form, model: e.target.value })}
|
||||
placeholder={
|
||||
form.provider === 'ollama' ? 'e.g. gemma4:e4b, llama3.2' :
|
||||
form.provider === 'anthropic' ? 'e.g. claude-sonnet-4-6' :
|
||||
form.provider === 'google' ? 'e.g. gemini-2.0-flash' :
|
||||
'e.g. gpt-4o, gpt-4o-mini'
|
||||
}
|
||||
style={fieldStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.provider === 'ollama' && (
|
||||
<div>
|
||||
<label style={labelStyle}>Ollama Base URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.baseUrl || ''}
|
||||
onChange={(e) => setForm({ ...form, baseUrl: e.target.value })}
|
||||
placeholder="https://ollama.kevlarai.com"
|
||||
style={fieldStyle}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>
|
||||
{form.provider === 'ollama' ? 'API Key (optional — leave blank if unrestricted)' : 'API Key'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.apiKey}
|
||||
onChange={(e) => setForm({ ...form, apiKey: e.target.value })}
|
||||
placeholder={form.provider === 'ollama' ? 'Leave blank or any value' : 'sk-...'}
|
||||
style={fieldStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '11px', color: 'var(--color-text-3)', marginTop: '8px' }}>
|
||||
API keys are stored in your browser only.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save button */}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className={`w-full rounded-md px-4 py-2 text-sm font-medium text-white transition-colors ${
|
||||
saved
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
style={{
|
||||
padding: '9px',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
backgroundColor: saved ? '#16a34a' : '#2563eb',
|
||||
color: '#fff',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 200ms',
|
||||
}}
|
||||
>
|
||||
{saved ? '✓ Saved!' : 'Save Settings'}
|
||||
{saved ? '✓ Saved' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user