feat(ui): endpoint catalog with copyable urls on the now page
This commit is contained in:
@@ -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>
|
||||
@@ -7,6 +7,7 @@
|
||||
UsageResponse,
|
||||
} from "$lib/api/types";
|
||||
import EmptyState from "$lib/components/empty-state.svelte";
|
||||
import Endpoints from "$lib/components/endpoints.svelte";
|
||||
import ErrorNote from "$lib/components/error-note.svelte";
|
||||
import KindBadge from "$lib/components/kind-badge.svelte";
|
||||
import LimitBar from "$lib/components/limit-bar.svelte";
|
||||
@@ -88,15 +89,6 @@
|
||||
});
|
||||
const windows = $derived(gateway.limits?.windows ?? []);
|
||||
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>
|
||||
|
||||
<svelte:head><title>Now · Beaver</title></svelte:head>
|
||||
@@ -347,34 +339,10 @@
|
||||
<h2
|
||||
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
|
||||
>
|
||||
Frontends
|
||||
Endpoints
|
||||
</h2>
|
||||
{#if agents}
|
||||
<ul class="flex flex-col">
|
||||
{#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>
|
||||
<Endpoints catalog={agents} />
|
||||
{:else}
|
||||
<Skeleton class="h-20 w-full" />
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user