Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions backend/app/routes/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
13 changes: 8 additions & 5 deletions backend/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -105,6 +106,7 @@ const Layout = () => {
</div>
</div>
<div className="flex items-center gap-2">
<RecordingIndicator />
<NotificationCenter />
{isPlaying && (
<Link to="/audio">
Expand Down
75 changes: 75 additions & 0 deletions frontend/src/components/RecordingIndicator.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<Link to="/audio/record">
<Button
variant="ghost"
size="sm"
className="gap-2 px-2 h-9 text-red-500 hover:text-red-600 hover:bg-red-500/10"
>
{/* Pulsing recording dot */}
<span className="relative flex h-3 w-3">
<span
className={cn(
"animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75",
)}
/>
<span className="relative inline-flex rounded-full h-3 w-3 bg-red-500" />
</span>

{/* Duration */}
<span className="font-mono text-sm font-medium">
{formatDuration(recordingDuration)}
</span>

{/* Stop button */}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 p-0 hover:bg-red-500/20"
onClick={handleStop}
>
<Square className="h-3.5 w-3.5 fill-current" />
</Button>
</Button>
</Link>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>
Recording{deviceLabel ? ` from ${deviceLabel}` : ""}
</p>
<p className="text-xs text-muted-foreground">Click to view, stop to end</p>
</TooltipContent>
</Tooltip>
);
}
9 changes: 1 addition & 8 deletions frontend/src/components/SettingsLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const SettingsLayout = () => {
name: "API",
path: "/settings/api",
icon: Key,
description: "API configuration and authentication",
description: "API endpoint, credentials & keys",
},
];

Expand All @@ -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",
Expand Down
122 changes: 122 additions & 0 deletions frontend/src/components/audio/RecentRecordingsList.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<ListMusic className="h-5 w-5" />
Recent Recordings
</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</CardContent>
</Card>
);
}

if (!recordings || recordings.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<ListMusic className="h-5 w-5" />
Recent Recordings
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground text-center py-4">
No recordings yet. Start recording to see your audio here.
</p>
</CardContent>
</Card>
);
}

return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="flex items-center gap-2 text-lg">
<ListMusic className="h-5 w-5" />
Recent Recordings
</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
>
<RefreshCw
className={`h-4 w-4 mr-1 ${isFetching ? "animate-spin" : ""}`}
/>
Refresh
</Button>
</CardHeader>
<CardContent className="space-y-3">
{displayedRecordings.map((recording) => (
<RecordingCard key={recording._id} recording={recording} />
))}

{hasMore && (
<Button
variant="outline"
className="w-full"
onClick={handleLoadMore}
disabled={isFetching}
>
<ChevronDown className="h-4 w-4 mr-2" />
Load More
</Button>
)}
</CardContent>
</Card>
);
}
Loading