236 lines
7.3 KiB
Svelte
236 lines
7.3 KiB
Svelte
<script lang="ts">
|
|
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
|
import { onMount } from "svelte";
|
|
import { page } from "$app/state";
|
|
import type { MemoryFile, MemoryNode, MemoryTree } from "$lib/api/types";
|
|
import ErrorNote from "$lib/components/error-note.svelte";
|
|
import { fmtBytes, fmtDateTime, fmtRelative } from "$lib/format";
|
|
import Markdown from "$lib/panel/markdown.svelte";
|
|
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 expanded = $state<Set<string>>(new Set());
|
|
let raw = $state(false);
|
|
const MD_SUFFIX = /\.md$/;
|
|
|
|
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;
|
|
const parts = path.split("/");
|
|
const next = new Set(expanded);
|
|
for (let i = 1; i < parts.length; i += 1) {
|
|
next.add(parts.slice(0, i).join("/"));
|
|
}
|
|
expanded = next;
|
|
try {
|
|
file = await client.memoryFile(path);
|
|
} catch (cause) {
|
|
fileError = cause instanceof Error ? cause.message : String(cause);
|
|
}
|
|
}
|
|
|
|
function toggle(path: string) {
|
|
const next = new Set(expanded);
|
|
if (next.has(path)) {
|
|
next.delete(path);
|
|
} else {
|
|
next.add(path);
|
|
}
|
|
expanded = next;
|
|
}
|
|
|
|
onMount(async () => {
|
|
await load();
|
|
const wanted = page.url.searchParams.get("path");
|
|
if (wanted) {
|
|
open(wanted);
|
|
}
|
|
});
|
|
|
|
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;
|
|
});
|
|
|
|
const isMarkdown = $derived(file?.path.endsWith(".md") ?? false);
|
|
</script>
|
|
|
|
<svelte:head><title>Memory · Beaver</title></svelte:head>
|
|
|
|
<div class="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[20rem_minmax(0,1fr)]">
|
|
<div
|
|
class="min-h-0 overflow-y-auto border-b px-3 py-3 md:border-r md:border-b-0"
|
|
>
|
|
<div class="flex items-baseline gap-2 px-1 pb-2">
|
|
<h1 class="font-semibold text-base tracking-tight">Memory</h1>
|
|
{#if tree}
|
|
<span class="tabular text-muted-foreground text-xs">{count} files</span>
|
|
{/if}
|
|
</div>
|
|
{#if failure}
|
|
<ErrorNote message={failure} retry={load} />
|
|
{:else if notConfigured}
|
|
<p class="doc px-1 text-muted-foreground text-sm">
|
|
No memory root. Point ApiFrontend(memory_root=…) at the agent's zone of
|
|
the vault.
|
|
</p>
|
|
{:else if !tree}
|
|
<p class="px-1 text-muted-foreground text-sm">Loading…</p>
|
|
{:else if tree.tree.length === 0}
|
|
<p class="px-1 text-muted-foreground text-sm">The zone is empty.</p>
|
|
{:else}
|
|
{@render nodes(tree.tree, 0)}
|
|
{/if}
|
|
</div>
|
|
<div class="min-h-0 overflow-y-auto">
|
|
{#if !selected}
|
|
<div class="mx-auto max-w-2xl px-6 py-10 text-muted-foreground text-sm">
|
|
<p>
|
|
The agent's own zone: state, handouts, observations, prompts, skills.
|
|
Pick a file on the left, or search for a line with ⌘K.
|
|
</p>
|
|
</div>
|
|
{:else if fileError}
|
|
<div class="p-4">
|
|
<ErrorNote message={fileError} retry={() => open(selected ?? "")} />
|
|
</div>
|
|
{:else if !file}
|
|
<p class="px-6 py-10 text-center text-muted-foreground text-xs">
|
|
Opening…
|
|
</p>
|
|
{:else}
|
|
<article class="mx-auto flex w-full max-w-3xl flex-col gap-4 px-6 py-6">
|
|
<header class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
|
<h2 class="min-w-0 truncate font-semibold text-lg tracking-tight">
|
|
{file.path.split("/").at(-1)?.replace(MD_SUFFIX, "")}
|
|
</h2>
|
|
{#if file.path.includes("/")}
|
|
<span class="truncate text-muted-foreground text-xs">
|
|
{file.path.split("/").slice(0, -1).join(" / ")}
|
|
</span>
|
|
{/if}
|
|
<span class="tabular text-muted-foreground text-xs">
|
|
{fmtBytes(file.size)}
|
|
</span>
|
|
<span
|
|
class="tabular text-muted-foreground text-xs"
|
|
title={fmtDateTime(file.mtime)}
|
|
>
|
|
{fmtRelative(file.mtime)}
|
|
</span>
|
|
{#if isMarkdown}
|
|
<button
|
|
class="rule-word ml-auto text-xs"
|
|
data-active={raw}
|
|
onclick={() => {
|
|
raw = !raw;
|
|
}}
|
|
type="button"
|
|
>
|
|
{raw ? "rendered" : "source"}
|
|
</button>
|
|
{/if}
|
|
</header>
|
|
{#if isMarkdown && !raw}
|
|
<Markdown class="prose-base" text={file.content} />
|
|
{:else}
|
|
<pre
|
|
class="whitespace-pre-wrap break-words text-sm leading-relaxed"
|
|
>{file.content}</pre>
|
|
{/if}
|
|
</article>
|
|
{/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"}
|
|
{@const isOpen = expanded.has(node.path)}
|
|
<button
|
|
aria-expanded={isOpen}
|
|
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.875 + 0.25}rem"
|
|
type="button"
|
|
>
|
|
<ChevronRightIcon
|
|
class={cn(
|
|
"size-3.5 shrink-0 text-icon transition-transform duration-150",
|
|
isOpen && "rotate-90"
|
|
)}
|
|
/>
|
|
<span class="truncate font-medium">{node.name}</span>
|
|
{#if !isOpen && node.children}
|
|
<span class="tabular ml-auto text-muted-foreground text-xs">
|
|
{node.children.length}
|
|
</span>
|
|
{/if}
|
|
</button>
|
|
{#if isOpen && 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-accent font-medium"
|
|
)}
|
|
onclick={() => open(node.path)}
|
|
style="padding-left: {depth * 0.875 + 1.5}rem"
|
|
type="button"
|
|
>
|
|
<span class="truncate">{node.name.replace(MD_SUFFIX, "")}</span>
|
|
<span class="tabular ml-auto text-muted-foreground text-xs">
|
|
{fmtBytes(node.size)}
|
|
</span>
|
|
</button>
|
|
{/if}
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/snippet}
|