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
+
+ refetch()}
+ disabled={isFetching}
+ >
+
+ Refresh
+
+
+
+ {displayedRecordings.map((recording) => (
+
+ ))}
+
+ {hasMore && (
+
+
+ Load More
+
+ )}
+
+
+ );
+}
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 */}
+
{
+ e.stopPropagation();
+ handlePlay();
+ }}
+ >
+
+
+
+ {/* 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 */}
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* 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() {
)}
-