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 aaaf459e6d
commit 00618ab7ad
158 changed files with 8459 additions and 2531 deletions
+292
View File
@@ -0,0 +1,292 @@
<script lang="ts">
import CopyIcon from "@lucide/svelte/icons/copy";
import GitBranchIcon from "@lucide/svelte/icons/git-branch";
import MoreHorizontalIcon from "@lucide/svelte/icons/more-horizontal";
import { toast } from "svelte-sonner";
import type { ApiClient } from "$lib/api/client";
import type { LiveState } from "$lib/api/live.svelte";
import type { ConversationInfo } from "$lib/api/types";
import KindBadge from "$lib/components/kind-badge.svelte";
import LiveDot from "$lib/components/live-dot.svelte";
import StatusPill from "$lib/components/status-pill.svelte";
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import * as Select from "$lib/components/ui/select";
import { fmtRelative, shortId } from "$lib/format";
let {
client,
info,
liveState,
liveDetail = null,
href,
onChanged,
onOpen,
}: {
client: ApiClient;
info: ConversationInfo;
liveState: LiveState;
liveDetail?: string | null;
href: (id: string) => string;
onChanged: () => void;
onOpen: (id: string) => void;
} = $props();
let branchOpen = $state(false);
let renameOpen = $state(false);
let busy = $state(false);
let seed = $state("clean");
let branchTitle = $state("");
let branchText = $state("");
let newTitle = $state("");
const SEEDS = [
{ label: "clean - empty context", value: "clean" },
{ label: "copy - copy of this history", value: "copy" },
{ label: "brief - your text as the seed", value: "brief" },
{ label: "morning - the handout", value: "morning" },
];
async function run(action: () => Promise<unknown>, done: string) {
busy = true;
try {
await action();
toast.success(done);
onChanged();
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
busy = false;
}
}
async function branch() {
busy = true;
try {
const child = await client.branch(info.id, {
seed,
text: branchText || undefined,
title: branchTitle || undefined,
});
branchOpen = false;
toast.success("Branch opened");
onOpen(child.id);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
busy = false;
}
}
function copyId() {
navigator.clipboard
.writeText(info.id)
.then(() => toast.success("Id copied"))
.catch(() => toast.error("Clipboard is not available"));
}
</script>
<header class="flex flex-col gap-1.5 border-b px-4 py-2.5 sm:px-6">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<KindBadge kind={info.kind} />
<h1 class="min-w-0 truncate font-semibold text-base tracking-tight">
{info.title || `${info.kind} ${shortId(info.id)}`}
</h1>
<StatusPill status={info.running_turn ? "running" : info.status} />
{#if info.pending_question}
<span class="font-medium text-link text-xs">question pending</span>
{/if}
<div class="ml-auto flex items-center gap-1">
<LiveDot detail={liveDetail} state={liveState} />
<Button
disabled={busy || info.status !== "open"}
onclick={() => {
branchOpen = true;
}}
size="sm"
variant="outline"
>
<GitBranchIcon class="size-4" />
Branch
</Button>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
aria-label="More actions"
size="icon-sm"
variant="ghost"
>
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={copyId}>
<CopyIcon class="size-4" />
Copy id
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() => {
newTitle = info.title ?? "";
renameOpen = true;
}}
>
Rename
</DropdownMenu.Item>
<DropdownMenu.Separator />
{#if info.parent && info.status === "open"}
<DropdownMenu.Item
onclick={() =>
run(() => client.merge(info.id), "Merged into the parent")}
>
Merge into parent
</DropdownMenu.Item>
{/if}
{#if info.status === "open"}
<DropdownMenu.Item
onclick={() =>
run(() => client.update(info.id, { status: "closed" }), "Closed")}
>
Close
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() =>
run(
() => client.update(info.id, { status: "archived" }),
"Archived"
)}
>
Archive
</DropdownMenu.Item>
{:else}
<DropdownMenu.Item
onclick={() =>
run(() => client.update(info.id, { status: "open" }), "Reopened")}
>
Reopen
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</div>
<div
class="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-muted-foreground text-xs"
>
<span>{info.agent}</span>
<span>via {info.origin}</span>
{#if info.parent}
<a class="text-link hover:underline" href={href(info.parent)}>
parent {shortId(info.parent)}
</a>
{/if}
<span class="tabular" title={info.id}>{shortId(info.id)}</span>
<span class="tabular">
{info.live ? "session live" : "no live session"}
{info.busy ? " · busy" : ""}
</span>
<span class="tabular">
last activity {fmtRelative(info.last_activity_at)}
</span>
</div>
</header>
<Dialog.Root bind:open={branchOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Branch off this conversation</Dialog.Title>
<Dialog.Description>
A branch gets its own window and session and ends with a merge back into
this thread.
</Dialog.Description>
</Dialog.Header>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1.5">
<Label>Seed</Label>
<Select.Root
onValueChange={(value) => {
seed = value;
}}
type="single"
value={seed}
>
<Select.Trigger class="w-full">
{SEEDS.find((s) => s.value === seed)?.label}
</Select.Trigger>
<Select.Content>
{#each SEEDS as option (option.value)}
<Select.Item label={option.label} value={option.value} />
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="flex flex-col gap-1.5">
<Label for="branch-title">Title</Label>
<Input
id="branch-title"
placeholder="optional"
bind:value={branchTitle}
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="branch-text">First message</Label>
<textarea
class="min-h-20 rounded-md border bg-input/30 px-3 py-2 text-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/30"
id="branch-text"
placeholder={seed === "brief" ? "required for a brief seed" : "optional"}
bind:value={branchText}
></textarea>
</div>
</div>
<Dialog.Footer>
<Button
onclick={() => {
branchOpen = false;
}}
variant="ghost"
>
Cancel
</Button>
<Button
disabled={busy || (seed === "brief" && !branchText.trim())}
onclick={branch}
>
Open branch
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root bind:open={renameOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Rename</Dialog.Title>
</Dialog.Header>
<Input placeholder="Title" bind:value={newTitle} />
<Dialog.Footer>
<Button
onclick={() => {
renameOpen = false;
}}
variant="ghost"
>
Cancel
</Button>
<Button
disabled={busy}
onclick={() =>
run(async () => {
await client.update(info.id, { title: newTitle });
renameOpen = false;
}, "Renamed")}
>
Save
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>