diff --git a/backend/app/routes/setup.ts b/backend/app/routes/setup.ts index 68f4cbcc..2fc7e8ff 100644 --- a/backend/app/routes/setup.ts +++ b/backend/app/routes/setup.ts @@ -73,8 +73,12 @@ export async function setupHandler(req: Request, res: Response) { }); if (existingKeys && existingKeys.length > 0) { - // API keys already exist, refuse to create new ones - res.json({ created: false }); + // API keys already exist, refuse to create new ones via web UI + res.status(400).json({ + created: false, + error: "keys_exist", + message: "API keys already exist. Generate new keys via CLI.", + }); return; } diff --git a/backend/server.ts b/backend/server.ts index f25da1ed..515bfe3d 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -335,21 +335,24 @@ async function configureCli() { .option("name", { alias: "n", type: "string", - describe: "The name of the token.", - default: `test_${Math.floor(Date.now() / 1000)}`, + describe: "The name of the token (e.g. browser-ui, cli, mobile).", + default: "default", }), async (args: ArgumentsCamelCase<{ owner: string; name: string }>) => { const owner = String(args.owner); const name = String(args.name); - console.log(`Owner: ${owner}`); - console.log(`Name: ${name}`); - console.log("Generating token..."); + console.log("Generating API key..."); await setupResources(); const { apiKey, clientId } = await generateApiKeyWithId(owner, name, [ { resource: "**", action: "**", effect: "allow" } as Policy, ]); + console.log(""); + console.log(`Created API key "${name}" (owner: ${owner})`); + console.log(""); console.log(`MYCELIA_CLIENT_ID=${clientId}`); console.log(`MYCELIA_TOKEN=${apiKey}`); + console.log(""); + console.log("Copy these values to your .env file or enter them in the setup page."); }, ) .command( diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 7ffe2336..aaafacaa 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button.tsx"; import { useJobsListener } from "@/hooks/useJobsListener"; import { Badge } from "@/components/ui/badge"; import { NotificationCenter } from "@/components/NotificationCenter"; +import { RecordingIndicator } from "@/components/RecordingIndicator"; const Layout = () => { useTheme(); @@ -105,6 +106,7 @@ const Layout = () => {
+ {isPlaying && ( diff --git a/frontend/src/components/RecordingIndicator.tsx b/frontend/src/components/RecordingIndicator.tsx new file mode 100644 index 00000000..d7f76790 --- /dev/null +++ b/frontend/src/components/RecordingIndicator.tsx @@ -0,0 +1,75 @@ +import { Link } from "react-router-dom"; +import { Mic, Square } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + useRecordingStore, + recordingResources, + formatDuration, +} from "@/stores/recordingStore"; +import { cn } from "@/lib/utils"; + +export function RecordingIndicator() { + const { isRecording, recordingDuration, deviceLabel } = useRecordingStore(); + + if (!isRecording) { + return null; + } + + const handleStop = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (recordingResources.stopRecordingFn) { + recordingResources.stopRecordingFn(); + } + }; + + return ( + + + + + + + + +

+ Recording{deviceLabel ? ` from ${deviceLabel}` : ""} +

+

Click to view, stop to end

+
+
+ ); +} diff --git a/frontend/src/components/SettingsLayout.tsx b/frontend/src/components/SettingsLayout.tsx index 4161f3be..2501863d 100644 --- a/frontend/src/components/SettingsLayout.tsx +++ b/frontend/src/components/SettingsLayout.tsx @@ -15,7 +15,7 @@ const SettingsLayout = () => { name: "API", path: "/settings/api", icon: Key, - description: "API configuration and authentication", + description: "API endpoint, credentials & keys", }, ]; @@ -32,13 +32,6 @@ const SettingsLayout = () => { icon: ScrollText, description: "Manage prompt templates", }, - { - name: "API Keys", - path: "/settings/api-keys", - icon: Key, - description: "Manage API keys and policies", - }, - { name: "Configuration", path: "/settings/config", diff --git a/frontend/src/components/audio/RecentRecordingsList.tsx b/frontend/src/components/audio/RecentRecordingsList.tsx new file mode 100644 index 00000000..84eafe14 --- /dev/null +++ b/frontend/src/components/audio/RecentRecordingsList.tsx @@ -0,0 +1,122 @@ +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { api } from "@/lib/api"; +import { RecordingCard, type SourceFileRecord } from "./RecordingCard"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Loader2, RefreshCw, ChevronDown, ListMusic } from "lucide-react"; + +const PAGE_SIZE = 5; + +export function RecentRecordingsList() { + const [displayCount, setDisplayCount] = useState(PAGE_SIZE); + + const { + data: recordings, + isLoading, + refetch, + isFetching, + } = useQuery({ + queryKey: ["recent-web-recordings", displayCount], + queryFn: async () => { + // Query source_files for web recordings (streaming_api from web) + const results = await api.callResource("mongo", { + action: "find", + collection: "source_files", + query: { + $or: [ + { "metadata.source": "websocket" }, + { importer: "streaming_api", "platform.node": "web" }, + ], + }, + options: { + sort: { start: -1 }, + limit: displayCount + 1, // +1 to check if there's more + }, + }) as SourceFileRecord[]; + + return results; + }, + refetchInterval: 30000, // Refetch every 30 seconds + }); + + const hasMore = recordings && recordings.length > displayCount; + const displayedRecordings = recordings?.slice(0, displayCount) || []; + + const handleLoadMore = () => { + setDisplayCount((prev) => prev + PAGE_SIZE); + }; + + if (isLoading) { + return ( + + + + + Recent Recordings + + + + + + + ); + } + + if (!recordings || recordings.length === 0) { + return ( + + + + + Recent Recordings + + + +

+ No recordings yet. Start recording to see your audio here. +

+
+
+ ); + } + + return ( + + + + + Recent Recordings + + + + + {displayedRecordings.map((recording) => ( + + ))} + + {hasMore && ( + + )} + + + ); +} diff --git a/frontend/src/components/audio/RecordingCard.tsx b/frontend/src/components/audio/RecordingCard.tsx new file mode 100644 index 00000000..fb248606 --- /dev/null +++ b/frontend/src/components/audio/RecordingCard.tsx @@ -0,0 +1,295 @@ +import { useState } from "react"; +import { format, formatDistanceToNow } from "date-fns"; +import { + ChevronDown, + ChevronUp, + Clock, + Mic, + Play, + Settings2, + FileAudio, + Loader2, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { useAudioPlayer } from "@/modules/audio/player"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import { ObjectId } from "bson"; + +export interface SourceFileRecord { + _id: string; + start: Date; + size?: number; + extension?: string; + ingested?: boolean; + importer?: string; + platform?: { + system?: string; + node?: string; + }; + metadata?: { + rate?: number; + width?: number; + channels?: number; + format?: string; + source?: string; + echoCancellation?: boolean; + noiseSuppression?: boolean; + autoGainControl?: boolean; + }; + processing_status?: string; +} + +interface RecordingCardProps { + recording: SourceFileRecord; +} + +export function RecordingCard({ recording }: RecordingCardProps) { + const [isExpanded, setIsExpanded] = useState(false); + const { resetDate, setIsPlaying } = useAudioPlayer(); + + // Fetch chunk count when expanded + const { data: chunkCount, isLoading: isLoadingChunks } = useQuery({ + queryKey: ["recording-chunks", recording._id], + queryFn: async () => { + const count = await api.callResource("mongo", { + action: "count", + collection: "audio_chunks", + query: { original_id: new ObjectId(recording._id) }, + }); + return count as number; + }, + enabled: isExpanded, + }); + + // Calculate duration from chunks (approximate based on chunk count) + const { data: durationData, isLoading: isLoadingDuration } = useQuery({ + queryKey: ["recording-duration", recording._id], + queryFn: async () => { + // Get first and last chunk to calculate duration + const chunks = await api.callResource("mongo", { + action: "find", + collection: "audio_chunks", + query: { original_id: new ObjectId(recording._id) }, + options: { + sort: { start: 1 }, + limit: 1, + projection: { start: 1 }, + }, + }) as Array<{ start: Date }>; + + const lastChunks = await api.callResource("mongo", { + action: "find", + collection: "audio_chunks", + query: { original_id: new ObjectId(recording._id) }, + options: { + sort: { start: -1 }, + limit: 1, + projection: { start: 1 }, + }, + }) as Array<{ start: Date }>; + + if (chunks.length > 0 && lastChunks.length > 0) { + const firstTime = new Date(chunks[0].start).getTime(); + const lastTime = new Date(lastChunks[0].start).getTime(); + // Add approximate chunk duration (1 second for opus at default settings) + return Math.ceil((lastTime - firstTime) / 1000) + 1; + } + return 0; + }, + enabled: true, + }); + + const handlePlay = () => { + resetDate(new Date(recording.start)); + setIsPlaying(true); + }; + + const formatDurationDisplay = (seconds: number) => { + if (seconds < 60) return `${seconds}s`; + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + if (mins < 60) return `${mins}m ${secs}s`; + const hours = Math.floor(mins / 60); + const remainingMins = mins % 60; + return `${hours}h ${remainingMins}m`; + }; + + const startDate = new Date(recording.start); + const metadata = recording.metadata || {}; + + return ( + + + {/* Main row - always visible */} +
setIsExpanded(!isExpanded)} + > + {/* Play button */} + + + {/* Recording info */} +
+
+ + {format(startDate, "MMM d, yyyy 'at' h:mm a")} + + + ({formatDistanceToNow(startDate, { addSuffix: true })}) + +
+
+ {durationData !== undefined && !isLoadingDuration ? ( + + + {formatDurationDisplay(durationData)} + + ) : ( + + + + + )} + {metadata.rate && ( + + {metadata.rate / 1000} kHz + + )} + {recording.processing_status && ( + + {recording.processing_status} + + )} +
+
+ + {/* Expand button */} + +
+ + {/* Expanded details */} + {isExpanded && ( +
+ {/* Audio settings */} +
+
+ + Recording Settings +
+
+
+ + Sample Rate: + + {metadata.rate ? `${metadata.rate / 1000} kHz` : "Unknown"} + +
+
+ Channels: + {metadata.channels || 1} +
+
+ Format: + {metadata.format || "pcm"} +
+
+
+ + {/* Processing options */} +
+
+ Audio Processing +
+
+ + Echo Cancellation: {metadata.echoCancellation ? "On" : "Off"} + + + Noise Suppression: {metadata.noiseSuppression ? "On" : "Off"} + + + Auto Gain: {metadata.autoGainControl ? "On" : "Off"} + +
+
+ + {/* File info */} +
+
+ + File Details +
+
+ {recording.size !== undefined && ( +
+ Size: + + {(recording.size / 1024).toFixed(1)} KB + +
+ )} + {!isLoadingChunks && chunkCount !== undefined && ( +
+ Chunks: + {chunkCount} +
+ )} +
+ Ingested: + + {recording.ingested ? "Yes" : "No"} + +
+ {recording.importer && ( +
+ Source: + {recording.importer} +
+ )} +
+
+
+ )} +
+
+ ); +} diff --git a/frontend/src/hooks/useAudioRecording.ts b/frontend/src/hooks/useAudioRecording.ts index 9a50f811..4afcca63 100644 --- a/frontend/src/hooks/useAudioRecording.ts +++ b/frontend/src/hooks/useAudioRecording.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { apiClient } from "@/lib/api"; import { useSettingsStore } from "@/stores/settingsStore"; +import { useRecordingStore, recordingResources } from "@/stores/recordingStore"; import { toast } from "sonner"; export type RecordingStep = @@ -198,8 +199,42 @@ export const useAudioRecording = (): AudioRecordingReturn => { }, [canAccessMicrophone, refreshDevices]); useEffect(() => { - refreshDevices(); - }, [refreshDevices]); + const initDevices = async () => { + await refreshDevices(); + // Auto-request permission if no devices detected (likely need permission first) + if (canAccessMicrophone && navigator.mediaDevices) { + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + const audioInputs = devices.filter( + (d) => d.kind === "audioinput" && d.deviceId !== "", + ); + // If devices have empty labels, we likely need permission + if (audioInputs.length === 0 || audioInputs.every((d) => !d.label)) { + // Try to get permission silently - will refresh devices on success + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + stream.getTracks().forEach((track) => track.stop()); + await refreshDevices(); + } catch { + // Permission denied or not available - user will need to click the button + } + } + } catch { + // enumerateDevices failed + } + } + }; + + initDevices(); + + // Auto-refresh when devices change (plug/unplug) + const handleDeviceChange = () => refreshDevices(); + navigator.mediaDevices?.addEventListener("devicechange", handleDeviceChange); + + return () => { + navigator.mediaDevices?.removeEventListener("devicechange", handleDeviceChange); + }; + }, [refreshDevices, canAccessMicrophone]); const getMicrophoneAccess = useCallback(async (): Promise => { if (!canAccessMicrophone) { @@ -477,6 +512,10 @@ export const useAudioRecording = (): AudioRecordingReturn => { channels: 1, mode: "streaming", timestamp: sessionStartTimestamp, + // Include audio processing settings for metadata storage + echoCancellation, + noiseSuppression, + autoGainControl, }; const bitDepth = audioFormat.width * 8; @@ -553,8 +592,42 @@ export const useAudioRecording = (): AudioRecordingReturn => { setIsRecording(false); setCurrentStep("idle"); setRecordingDuration(0); + + // Clear global resources + recordingResources.stopRecordingFn = null; + recordingResources.analyserRef = null; }, [isRecording, sendWyomingMessage, cleanup]); + // Sync state to global store for navbar indicator + const globalStore = useRecordingStore(); + useEffect(() => { + globalStore.setIsRecording(isRecording); + }, [isRecording, globalStore]); + + useEffect(() => { + globalStore.setRecordingDuration(recordingDuration); + }, [recordingDuration, globalStore]); + + useEffect(() => { + globalStore.setCurrentStep(currentStep); + }, [currentStep, globalStore]); + + useEffect(() => { + globalStore.setError(error); + }, [error, globalStore]); + + // Register stopRecording in global resources so it can be called from anywhere + useEffect(() => { + if (isRecording) { + recordingResources.stopRecordingFn = stopRecording; + recordingResources.analyserRef = analyserRef.current; + // Set device label for indicator + const selectedDevice = availableDevices.find(d => d.deviceId === selectedDeviceId); + globalStore.setDeviceLabel(selectedDevice?.label || null); + globalStore.setSampleRate(sampleRate); + } + }, [isRecording, stopRecording, availableDevices, selectedDeviceId, sampleRate, globalStore]); + useEffect(() => { return () => { cleanup(); diff --git a/frontend/src/pages/CreateAudioRecordPage.tsx b/frontend/src/pages/CreateAudioRecordPage.tsx index 84569431..e2d48d67 100644 --- a/frontend/src/pages/CreateAudioRecordPage.tsx +++ b/frontend/src/pages/CreateAudioRecordPage.tsx @@ -2,7 +2,7 @@ import { Radio, RefreshCw } from "lucide-react"; import { useAudioRecording } from "@/hooks/useAudioRecording"; import { RecordingControls } from "@/components/audio/RecordingControls"; import { RecordingStatus } from "@/components/audio/RecordingStatus"; -import { AudioVisualizer } from "@/components/audio/AudioVisualizer"; +import { RecentRecordingsList } from "@/components/audio/RecentRecordingsList"; import { Card } from "@/components/ui/card"; import { Select, @@ -89,7 +89,7 @@ export default function CreateAudioRecordPage() {
)} -
+
+

+ 16 kHz is optimal for transcription. Higher rates improve quality but increase file size. +

-
-
- - setEchoCancellation(checked === true)} - disabled={recording.isRecording} - /> - +
+
+
+ + setEchoCancellation(checked === true)} + disabled={recording.isRecording} + /> + +
+

+ Removes feedback from speakers. Enable when recording near playback. +

-
- - setNoiseSuppression(checked === true)} - disabled={recording.isRecording} - /> - +
+
+ + setNoiseSuppression(checked === true)} + disabled={recording.isRecording} + /> + +
+

+ Reduces background noise (fans, AC). May slightly affect voice quality. +

-
- - setAutoGainControl(checked === true)} - disabled={recording.isRecording} - /> - +
+
+ + setAutoGainControl(checked === true)} + disabled={recording.isRecording} + /> + +
+

+ Automatically adjusts volume levels. Good for varying voice volumes. +

@@ -172,6 +190,8 @@ export default function CreateAudioRecordPage() { + +
); } diff --git a/frontend/src/pages/SetupPage.tsx b/frontend/src/pages/SetupPage.tsx index 65fb279e..f5d5da4b 100644 --- a/frontend/src/pages/SetupPage.tsx +++ b/frontend/src/pages/SetupPage.tsx @@ -2,12 +2,26 @@ import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { useSettingsStore } from "@/stores/settingsStore"; import { exchangeApiKeyForJWT } from "@/lib/auth"; -import { Loader2, CheckCircle2, XCircle, Sparkles } from "lucide-react"; +import { Loader2, CheckCircle2, XCircle, Terminal, Copy, Check, ClipboardPaste } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; -type SetupStatus = "idle" | "verifying" | "creating_new" | "success" | "error"; +type ServerType = "docker" | "deno" | "custom"; + +const SERVER_ENDPOINTS: Record, string> = { + docker: "https://localhost:4433", + deno: "http://localhost:5173", +}; + +type SetupStatus = "idle" | "verifying" | "creating_new" | "success" | "error" | "keys_exist"; interface SetupResponse { created: boolean; @@ -45,12 +59,46 @@ export default function SetupPage() { setClientSecret, } = useSettingsStore(); - const [localEndpoint, setLocalEndpoint] = useState(apiEndpoint); + const [serverType, setServerType] = useState("docker"); + const [localEndpoint, setLocalEndpoint] = useState(apiEndpoint || SERVER_ENDPOINTS.docker); const [localClientId, setLocalClientId] = useState(""); const [localToken, setLocalToken] = useState(""); + const [pastedCredentials, setPastedCredentials] = useState(""); const [status, setStatus] = useState("idle"); const [errorMessage, setErrorMessage] = useState(null); + const handleServerTypeChange = (value: ServerType) => { + setServerType(value); + if (value !== "custom") { + setLocalEndpoint(SERVER_ENDPOINTS[value]); + } + }; + + const parseCredentials = () => { + const lines = pastedCredentials.trim().split("\n"); + let foundClientId = ""; + let foundToken = ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith("MYCELIA_CLIENT_ID=")) { + foundClientId = trimmed.replace("MYCELIA_CLIENT_ID=", "").trim(); + } else if (trimmed.startsWith("MYCELIA_TOKEN=")) { + foundToken = trimmed.replace("MYCELIA_TOKEN=", "").trim(); + } + } + + if (foundClientId) setLocalClientId(foundClientId); + if (foundToken) setLocalToken(foundToken); + + if (!foundClientId && !foundToken) { + setErrorMessage("Could not find MYCELIA_CLIENT_ID or MYCELIA_TOKEN in the pasted text"); + } else { + setPastedCredentials(""); + setErrorMessage(null); + } + }; + // Redirect if already configured useEffect(() => { if (clientId && clientSecret) { @@ -107,11 +155,17 @@ export default function SetupPage() { body: JSON.stringify({ create: true }), }); - if (!response.ok) { - throw new Error(`Server returned ${response.status}`); + const data: SetupResponse = await response.json(); + + // Handle keys_exist error specifically + if (data.error === "keys_exist") { + setStatus("keys_exist"); + return; } - const data: SetupResponse = await response.json(); + if (!response.ok) { + throw new Error(data.error || `Server returned ${response.status}`); + } if (data.created && data.clientId && data.clientSecret) { setClientId(data.clientId); @@ -132,6 +186,15 @@ export default function SetupPage() { }; const isLoading = status === "verifying" || status === "creating_new"; + const [copiedCommand, setCopiedCommand] = useState(false); + + const copyCommand = async (command: string) => { + await navigator.clipboard.writeText(command); + setCopiedCommand(true); + setTimeout(() => setCopiedCommand(false), 2000); + }; + + const isRemoteServer = !localEndpoint.includes("localhost") && !localEndpoint.includes("127.0.0.1"); return (
@@ -165,6 +228,115 @@ export default function SetupPage() { Continuing to next step...

+ ) : status === "keys_exist" ? ( +
+
+ +

+ Generate Credentials via CLI +

+

+ API keys already exist on this server. Generate new credentials using the command line. +

+
+ +
+ {isRemoteServer ? ( + <> +
+

+ Remote server detected. SSH into your server and run: +

+
+
+
+                        docker compose exec backend deno run -A server.ts token-create --name browser-ui
+                      
+ +
+

+ Server endpoint: {localEndpoint} +

+ + ) : ( + <> +

+ Run one of these commands in your terminal: +

+ + {/* Docker option */} +
+

Docker (recommended)

+
+
+                          docker compose exec backend deno run -A server.ts token-create --name browser-ui
+                        
+ +
+

+ Server endpoint: https://localhost:4433 +

+
+ + {/* Non-Docker option */} +
+

Local development (deno task dev)

+
+
+                          cd backend && deno run -A server.ts token-create --name browser-ui
+                        
+ +
+

+ Server endpoint: http://localhost:5173 +

+
+ + )} + +
+

This will output your credentials:

+
+{`MYCELIA_CLIENT_ID=abc123...
+MYCELIA_TOKEN=mycelia_xyz...`}
+                  
+
+
+ +
+ +
+
) : (
{ e.preventDefault(); verifyAndSaveCredentials(); }}> {errorMessage && ( @@ -178,18 +350,77 @@ export default function SetupPage() {
-
+ + {serverType === "custom" && ( +
+ + setLocalEndpoint(e.target.value)} + placeholder="https://your-server.com" + disabled={isLoading} + className="bg-white/10 border-white/20 text-white placeholder:text-slate-400 focus:border-purple-400 disabled:opacity-50" + /> +
+ )} + + {serverType !== "custom" && ( +

+ Server endpoint: {localEndpoint} +

+ )} + + {/* Paste credentials section */} +
+ +