feat(ui): endpoint catalog with copyable urls on the now page

This commit is contained in:
hh
2026-08-28 22:30:11 +02:00
parent b6c5a80326
commit 8ea4cea3ef
2 changed files with 192 additions and 35 deletions
+189
View File
@@ -0,0 +1,189 @@
<script lang="ts">
import CopyIcon from "@lucide/svelte/icons/copy";
import { toast } from "svelte-sonner";
import type { AgentsResponse, FrontendInfo } from "$lib/api/types";
import KindBadge from "$lib/components/kind-badge.svelte";
import { Button } from "$lib/components/ui/button";
let { catalog }: { catalog: AgentsResponse } = $props();
const API_SUFFIX = /\/api$/;
interface Endpoint {
hint: string;
label: string;
url: string;
}
interface Group {
endpoints: Endpoint[];
frontend: FrontendInfo;
note: string;
}
function baseOf(fe: FrontendInfo): string {
if (fe.public_base_url) {
return fe.public_base_url;
}
return fe.port
? `${window.location.protocol}//${window.location.hostname}:${fe.port}`
: "";
}
function describe(fe: FrontendInfo): Group {
const base = baseOf(fe);
switch (fe.type) {
case "AnthropicMessagesFrontend":
return {
endpoints: [
{
hint: "Anthropic SDK, Cursor, LibreChat: base_url = the root above, model = agent name, x-api-key = a token with scope messages",
label: "messages",
url: `${base}/v1/messages`,
},
],
frontend: fe,
note: "",
};
case "McpServerFrontend":
return {
endpoints: [
...catalog.mcps.map((m) => ({
hint: `${m.kind} MCP, streamable HTTP; bearer with scope mcp`,
label: m.name,
url: `${base}/${m.name}/`,
})),
...(catalog.mcps.length > 0
? [
{
hint: "every namespace in one server",
label: "all",
url: `${base}/all/`,
},
]
: []),
],
frontend: fe,
note:
catalog.mcps.length === 0
? "no MCP servers declared in config.py"
: "",
};
case "MarkdownFrontend":
return {
endpoints: [
{
hint: "Obsidian plugin → Base URL (the plugin appends /chat and /chat/stream)",
label: "base",
url: base,
},
{
hint: "POST {filename, content, agent?} with a bearer",
label: "chat",
url: `${base}/chat`,
},
],
frontend: fe,
note: "",
};
case "ApiFrontend":
return {
endpoints: [
{
hint: "Obsidian plugin → API origin; the admin uses it too. Routes live under /api/…",
label: "api origin",
url: fe.public_base_url
? fe.public_base_url.replace(API_SUFFIX, "")
: base,
},
{
hint: "SSE of the whole gateway; per conversation: /api/conversations/{id}/events",
label: "events",
url: `${fe.public_base_url ? fe.public_base_url.replace(API_SUFFIX, "") : base}/api/events`,
},
],
frontend: fe,
note: "",
};
case "TelegramFrontend":
return {
endpoints: [],
frontend: fe,
note: "long polling from the gateway; nothing to connect",
};
case "AdminFrontend":
return {
endpoints: [
{ hint: "this console", label: "admin", url: `${base}/admin/` },
],
frontend: fe,
note: "",
};
default:
return {
endpoints: base ? [{ hint: "", label: "base", url: base }] : [],
frontend: fe,
note: "",
};
}
}
const groups = $derived(catalog.frontends.map(describe));
function copy(text: string) {
navigator.clipboard
.writeText(text)
.then(() => toast.success("Copied"))
.catch(() => toast.error("Clipboard is not available"));
}
</script>
<div class="flex flex-col">
{#each groups as group (group.frontend.type + group.frontend.name)}
<div class="flex flex-col gap-1 border-b py-2">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-sm">{group.frontend.name}</span>
<span class="text-muted-foreground text-xs">{group.frontend.type}</span>
<span class="flex gap-1">
{#each group.frontend.kinds as k (k)}
<KindBadge kind={k} />
{/each}
</span>
{#if Object.keys(group.frontend.default_agents).length > 0}
<span class="text-muted-foreground text-xs">
{Object.entries(group.frontend.default_agents)
.map(([k, v]) => `${k}${v}`)
.join(", ")}
</span>
{/if}
</div>
{#if group.note}
<p class="text-muted-foreground text-xs">{group.note}</p>
{/if}
{#each group.endpoints as ep (ep.label)}
<div class="ledger-grid grid-cols-[6rem_minmax(0,1fr)_auto] py-0.5">
<span class="text-muted-foreground text-xs">{ep.label}</span>
<span class="flex min-w-0 flex-col">
<code class="truncate text-xs" title={ep.url}>{ep.url}</code>
{#if ep.hint}
<span
class="truncate text-muted-foreground text-xs"
title={ep.hint}
>
{ep.hint}
</span>
{/if}
</span>
<Button
aria-label="Copy {ep.label} URL"
onclick={() => copy(ep.url)}
size="icon-sm"
variant="ghost"
>
<CopyIcon class="size-3.5" />
</Button>
</div>
{/each}
</div>
{/each}
</div>
+3 -35
View File
@@ -7,6 +7,7 @@
UsageResponse, UsageResponse,
} from "$lib/api/types"; } from "$lib/api/types";
import EmptyState from "$lib/components/empty-state.svelte"; import EmptyState from "$lib/components/empty-state.svelte";
import Endpoints from "$lib/components/endpoints.svelte";
import ErrorNote from "$lib/components/error-note.svelte"; import ErrorNote from "$lib/components/error-note.svelte";
import KindBadge from "$lib/components/kind-badge.svelte"; import KindBadge from "$lib/components/kind-badge.svelte";
import LimitBar from "$lib/components/limit-bar.svelte"; import LimitBar from "$lib/components/limit-bar.svelte";
@@ -88,15 +89,6 @@
}); });
const windows = $derived(gateway.limits?.windows ?? []); const windows = $derived(gateway.limits?.windows ?? []);
const tape = $derived(gateway.tape.slice(0, 40)); const tape = $derived(gateway.tape.slice(0, 40));
function frontendUrl(port: number | null, publicBase: string | null) {
if (publicBase) {
return publicBase;
}
return port
? `${window.location.protocol}//${window.location.hostname}:${port}`
: "";
}
</script> </script>
<svelte:head><title>Now · Beaver</title></svelte:head> <svelte:head><title>Now · Beaver</title></svelte:head>
@@ -347,34 +339,10 @@
<h2 <h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide" class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
> >
Frontends Endpoints
</h2> </h2>
{#if agents} {#if agents}
<ul class="flex flex-col"> <Endpoints catalog={agents} />
{#each agents.frontends as fe (fe.type + fe.name)}
<li
class="ledger-grid grid-cols-[minmax(0,1fr)_auto] border-b py-1.5 text-sm"
>
<span class="flex min-w-0 flex-col">
<span class="truncate font-medium">{fe.name}</span>
<span class="truncate text-muted-foreground text-xs">
{frontendUrl(fe.port, fe.public_base_url) || fe.type}
{#if Object.keys(fe.default_agents).length > 0}
·
{Object.entries(fe.default_agents)
.map(([k, v]) => `${k}${v}`)
.join(", ")}
{/if}
</span>
</span>
<span class="flex gap-1">
{#each fe.kinds as k (k)}
<KindBadge kind={k} />
{/each}
</span>
</li>
{/each}
</ul>
{:else} {:else}
<Skeleton class="h-20 w-full" /> <Skeleton class="h-20 w-full" />
{/if} {/if}