feat(ui,api,admin): sveltekit admin spa, usage by day/agent/conversation/model, rate limits, memory tree, turn snapshot

This commit is contained in:
hh
2026-08-28 22:14:15 +02:00
parent 61562e947d
commit 98796a82c6
158 changed files with 8459 additions and 2531 deletions
+194
View File
@@ -0,0 +1,194 @@
<script lang="ts">
import { onMount, untrack } from "svelte";
import type { ApiClient } from "$lib/api/client";
import ErrorNote from "$lib/components/error-note.svelte";
import { Skeleton } from "$lib/components/ui/skeleton";
import * as Tabs from "$lib/components/ui/tabs";
import ActivityFeed from "./activity-feed.svelte";
import BindingsList from "./bindings-list.svelte";
import Composer from "./composer.svelte";
import { ConversationFeed } from "./conversation.svelte";
import ConversationHeader from "./conversation-header.svelte";
import HistoryView from "./history-view.svelte";
import QueueList from "./queue-list.svelte";
import RawEntries from "./raw-entries.svelte";
let {
client,
id,
href,
onOpen,
compact = false,
}: {
client: ApiClient;
id: string;
href: (id: string) => string;
onOpen: (id: string) => void;
compact?: boolean;
} = $props();
const feed = new ConversationFeed(
() => client,
untrack(() => id)
);
let tab = $state("activity");
let historyKey = $state(0);
let landed = $state(false);
onMount(() => {
feed.start();
return () => feed.stop();
});
const info = $derived(feed.model.conversation);
function refresh() {
feed.refresh().catch(() => undefined);
historyKey += 1;
}
$effect(() => {
const { running } = feed.model;
if (!running) {
untrack(() => {
historyKey += 1;
});
}
});
$effect(() => {
if (feed.loaded && !landed) {
landed = true;
tab = feed.model.running ? "activity" : "history";
}
});
</script>
<div class="flex h-full min-h-0 flex-col">
{#if feed.error && !info}
<div class="p-4"><ErrorNote message={feed.error} retry={refresh} /></div>
{:else if !info}
<div class="flex flex-col gap-3 p-4">
<Skeleton class="h-8 w-1/2" />
<Skeleton class="h-24 w-full" />
</div>
{:else}
<ConversationHeader
{client}
{href}
{info}
liveDetail={feed.live.detail}
liveState={feed.live.state}
onChanged={refresh}
{onOpen}
/>
<div
class="grid min-h-0 flex-1 grid-cols-1 {compact
? ''
: 'lg:grid-cols-[minmax(0,1fr)_18rem]'}"
>
<div class="flex min-h-0 flex-col">
<Tabs.Root class="flex min-h-0 flex-1 flex-col gap-0" bind:value={tab}>
<Tabs.List class="mx-4 mt-2 w-fit sm:mx-6">
<Tabs.Trigger value="activity">Activity</Tabs.Trigger>
<Tabs.Trigger value="history">History</Tabs.Trigger>
<Tabs.Trigger value="raw">Raw</Tabs.Trigger>
<Tabs.Trigger class={compact ? "" : "lg:hidden"} value="meta"
>Meta</Tabs.Trigger
>
</Tabs.List>
<div class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6">
<Tabs.Content value="activity">
<ActivityFeed
{client}
connected={feed.live.state === "open"}
conversationId={id}
model={feed.model}
/>
</Tabs.Content>
<Tabs.Content value="history">
<HistoryView
{client}
conversationId={id}
refreshKey={historyKey}
/>
</Tabs.Content>
<Tabs.Content value="raw">
<RawEntries {client} conversationId={id} />
</Tabs.Content>
<Tabs.Content value="meta">{@render rail()}</Tabs.Content>
</div>
</Tabs.Root>
<Composer
{client}
conversationId={id}
disabled={info.status !== "open"}
/>
</div>
{#if !compact}
<aside
class="hidden min-h-0 overflow-y-auto border-l bg-sidebar/50 p-4 lg:block"
>
{@render rail()}
</aside>
{/if}
</div>
{/if}
</div>
{#snippet rail()}
{#if info}
<div class="flex flex-col gap-5">
<section class="flex flex-col gap-1.5">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Queue
</h2>
<QueueList items={info.queue} />
</section>
<section class="flex flex-col gap-1.5">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Windows
</h2>
<BindingsList
bindings={info.bindings}
{client}
conversationId={id}
onChanged={refresh}
/>
</section>
<section class="flex flex-col gap-1.5">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Flags
</h2>
{#if Object.keys(info.flags).length === 0}
<p class="text-muted-foreground text-xs">No flags set.</p>
{:else}
<dl
class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 text-xs"
>
{#each Object.entries(info.flags) as [key, value] (key)}
<dt class="text-muted-foreground">{key}</dt>
<dd class="truncate">{JSON.stringify(value)}</dd>
{/each}
</dl>
{/if}
</section>
<section class="flex flex-col gap-1.5">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Session
</h2>
<p class="tabular break-all text-muted-foreground text-xs">
{info.session_id ?? "no session yet"}
</p>
</section>
</div>
{/if}
{/snippet}