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:
@@ -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