feat(ui,api,admin): sveltekit admin spa, usage by day/agent/conversation/model, rate limits, memory tree, turn snapshot
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
<script lang="ts">
|
||||
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||
import FileTextIcon from "@lucide/svelte/icons/file-text";
|
||||
import FolderIcon from "@lucide/svelte/icons/folder";
|
||||
import { onMount } from "svelte";
|
||||
import type { MemoryFile, MemoryNode, MemoryTree } from "$lib/api/types";
|
||||
import EmptyState from "$lib/components/empty-state.svelte";
|
||||
import ErrorNote from "$lib/components/error-note.svelte";
|
||||
import PageHeader from "$lib/components/page-header.svelte";
|
||||
import { Skeleton } from "$lib/components/ui/skeleton";
|
||||
import { fmtBytes, fmtDateTime, fmtRelative } from "$lib/format";
|
||||
import { session } from "$lib/session.svelte";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
let tree = $state<MemoryTree | null>(null);
|
||||
let failure = $state<string | null>(null);
|
||||
let notConfigured = $state(false);
|
||||
let selected = $state<string | null>(null);
|
||||
let file = $state<MemoryFile | null>(null);
|
||||
let fileError = $state<string | null>(null);
|
||||
let collapsed = $state<Set<string>>(new Set());
|
||||
|
||||
async function load() {
|
||||
const { client } = session;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
failure = null;
|
||||
notConfigured = false;
|
||||
try {
|
||||
tree = await client.memory();
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
if (message.includes("memory root")) {
|
||||
notConfigured = true;
|
||||
} else {
|
||||
failure = message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function open(path: string) {
|
||||
const { client } = session;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
selected = path;
|
||||
file = null;
|
||||
fileError = null;
|
||||
try {
|
||||
file = await client.memoryFile(path);
|
||||
} catch (cause) {
|
||||
fileError = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(path: string) {
|
||||
const next = new Set(collapsed);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
collapsed = next;
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
const count = $derived.by(() => {
|
||||
let files = 0;
|
||||
const walk = (nodes: MemoryNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (node.type === "file") {
|
||||
files += 1;
|
||||
} else if (node.children) {
|
||||
walk(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(tree?.tree ?? []);
|
||||
return files;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Memory · Beaver</title></svelte:head>
|
||||
|
||||
<PageHeader
|
||||
subtitle={tree ? `${count} files · ${tree.root}` : ""}
|
||||
title="Memory"
|
||||
/>
|
||||
|
||||
<div class="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[18rem_minmax(0,1fr)]">
|
||||
<div
|
||||
class="min-h-0 overflow-y-auto border-b bg-sidebar/50 p-2 md:border-r md:border-b-0"
|
||||
>
|
||||
{#if failure}
|
||||
<ErrorNote message={failure} retry={load} />
|
||||
{:else if notConfigured}
|
||||
<EmptyState
|
||||
hint="Set ApiFrontend(memory_root=...) in config.py to the agent's zone of the vault."
|
||||
title="No memory root"
|
||||
/>
|
||||
{:else if !tree}
|
||||
<div class="flex flex-col gap-2 p-1">
|
||||
<Skeleton class="h-6 w-3/4" />
|
||||
<Skeleton class="h-6 w-1/2" />
|
||||
<Skeleton class="h-6 w-2/3" />
|
||||
</div>
|
||||
{:else if tree.tree.length === 0}
|
||||
<EmptyState hint="The zone is empty." title="Nothing here" />
|
||||
{:else}
|
||||
{@render nodes(tree.tree, 0)}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-h-0 overflow-y-auto">
|
||||
{#if !selected}
|
||||
<div class="p-6 text-muted-foreground text-sm">
|
||||
Pick a file to read it. This is the agent's own zone: state, handouts,
|
||||
prompts, skills - everything it can write.
|
||||
</div>
|
||||
{:else if fileError}
|
||||
<div class="p-4">
|
||||
<ErrorNote message={fileError} retry={() => open(selected ?? "")} />
|
||||
</div>
|
||||
{:else if !file}
|
||||
<div class="flex flex-col gap-2 p-6">
|
||||
<Skeleton class="h-5 w-1/3" />
|
||||
<Skeleton class="h-40 w-full" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-baseline gap-3 border-b px-4 py-2 text-xs sm:px-6">
|
||||
<span class="font-medium text-sm">{file.path}</span>
|
||||
<span class="tabular text-muted-foreground">{fmtBytes(file.size)}</span>
|
||||
<span
|
||||
class="tabular text-muted-foreground"
|
||||
title={fmtDateTime(file.mtime)}
|
||||
>
|
||||
modified {fmtRelative(file.mtime)}
|
||||
</span>
|
||||
</div>
|
||||
<pre
|
||||
class="max-w-[90ch] px-4 py-3 text-sm whitespace-pre-wrap break-words sm:px-6"
|
||||
>{file.content}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#snippet nodes(list: MemoryNode[], depth: number)}
|
||||
<ul class="flex flex-col">
|
||||
{#each list as node (node.path)}
|
||||
<li>
|
||||
{#if node.type === "dir"}
|
||||
<button
|
||||
aria-expanded={!collapsed.has(node.path)}
|
||||
class="row-hover flex h-7 w-full items-center gap-1.5 rounded-md pr-2 text-left text-sm"
|
||||
onclick={() => toggle(node.path)}
|
||||
style="padding-left: {depth * 0.75 + 0.25}rem"
|
||||
type="button"
|
||||
>
|
||||
<ChevronRightIcon
|
||||
class={cn(
|
||||
"size-3.5 shrink-0 text-icon transition-transform duration-150",
|
||||
!collapsed.has(node.path) && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
<FolderIcon class="size-4 shrink-0 text-icon" />
|
||||
<span class="truncate">{node.name}</span>
|
||||
</button>
|
||||
{#if !collapsed.has(node.path) && node.children}
|
||||
{@render nodes(node.children, depth + 1)}
|
||||
{/if}
|
||||
{:else}
|
||||
<button
|
||||
aria-current={selected === node.path ? "true" : undefined}
|
||||
class={cn(
|
||||
"row-hover flex h-7 w-full items-center gap-1.5 rounded-md pr-2 text-left text-sm",
|
||||
selected === node.path && "bg-sidebar-accent font-medium"
|
||||
)}
|
||||
onclick={() => open(node.path)}
|
||||
style="padding-left: {depth * 0.75 + 1.5}rem"
|
||||
type="button"
|
||||
>
|
||||
<FileTextIcon class="size-4 shrink-0 text-icon" />
|
||||
<span class="truncate">{node.name}</span>
|
||||
<span class="tabular ml-auto text-muted-foreground text-xs">
|
||||
{fmtBytes(node.size)}
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/snippet}
|
||||
Reference in New Issue
Block a user