evOo-local/apps/web/src/pages/KnowledgeSection.tsx
cynarAI b2fb517286
Simplify the memory settings: what is remembered first, provider setup under Advanced (#164)
The memory pane opened with an untranslated provider description, a
provider picker, a scope toggle and API key fields, and pushed what the
agents actually remember to the bottom. Most people never connect a
semantic-recall provider, so that setup now sits behind a collapsed
Advanced disclosure (open while a provider is connected), and the
remembered documents come first.

- Drop the provider descriptions, the "Shared documents" heading and
  the per-document revision label.
- Replace the Isolated/Shared toggle pair with one "Share between bots"
  switch.
- Remove the unused standalone dialog mode of the memory pane.
- German: call the section "Gedächtnis" so it no longer reads like
  reminders ("Erinnerungen").


Claude-Session: https://claude.ai/code/session_01LWzKXyE6uDmmmzuod4oXNQ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 20:30:30 +02:00

468 lines
14 KiB
TypeScript

import { Trans, useLingui } from "@lingui/react/macro";
import type { AgentSkill, AgentSkillCatalogEntry, MemoryDocument } from "@rakazo/contracts";
import {
Button,
Skeleton,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Textarea,
} from "@rakazo/ui-web";
import { useEffect, useRef, useState } from "react";
import { downloadArtifactBytes } from "../lib/artifact-open";
import { rpc } from "../lib/rpc";
const fieldClass = "mt-2 w-full font-mono text-ui-body-sm leading-relaxed";
function rowClass(open: boolean): string {
return `h-auto w-full justify-start whitespace-normal px-2.5 py-2.5 text-start ${open ? "bg-muted" : ""}`;
}
/**
* Bot memory and the current user's skills in this space.
* User-scoped memory shared across bots lives in the Memory settings overlay.
*/
export function KnowledgeSection({
botId,
onSkillsChange,
}: {
botId: string;
onSkillsChange: (skills: AgentSkillCatalogEntry[]) => void;
}) {
const { t } = useLingui();
return (
<section className="mt-6" data-testid="bot-knowledge">
<Tabs defaultValue="memory">
<TabsList aria-label={t`Knowledge`}>
<TabsTrigger value="memory">
<Trans>Memory</Trans>
</TabsTrigger>
<TabsTrigger value="skills">
<Trans>Skills</Trans>
</TabsTrigger>
</TabsList>
<TabsContent value="memory">
<MemoryDocumentList
key={botId}
load={() => rpc.memory.list({ botId, scope: "bot" })}
exportFilename="memory.md"
testId="bot-knowledge-memory"
/>
</TabsContent>
<TabsContent value="skills">
<AgentSkills onSkillsChange={onSkillsChange} />
</TabsContent>
</Tabs>
</section>
);
}
/** The space's shared memory documents, mounted in the Memory settings. */
export function SpaceMemorySection() {
return (
<div data-testid="space-memory-documents">
<MemoryDocumentList
load={() => rpc.memory.list({ scope: "user" })}
exportFilename="space-memory.md"
testId="space-memory-list"
/>
</div>
);
}
function MemoryDocumentList({
load,
exportFilename,
testId,
}: {
load: () => Promise<MemoryDocument[]>;
exportFilename: string;
testId: string;
}) {
const { t } = useLingui();
const [loading, setLoading] = useState(true);
const [docs, setDocs] = useState<MemoryDocument[]>([]);
const [openId, setOpenId] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const generation = useRef(0);
const loadRef = useRef(load);
loadRef.current = load;
useEffect(() => {
const current = ++generation.current;
void loadRef
.current()
.then((list) => {
if (current !== generation.current) return;
setDocs(list);
setLoading(false);
})
.catch(() => {
if (current !== generation.current) return;
setDocs([]);
setLoading(false);
setError(t`Could not load`);
});
return () => {
generation.current += 1;
};
}, [t]);
function openDoc(doc: MemoryDocument) {
setOpenId(doc.id);
setDraft(doc.content);
setError(null);
}
async function save(doc: MemoryDocument) {
if (busy) return;
setBusy(true);
setError(null);
try {
const updated = await rpc.memory.update({ documentId: doc.id, content: draft });
setDocs((current) => current.map((entry) => (entry.id === updated.id ? updated : entry)));
setOpenId(null);
} catch {
setError(t`Could not save`);
} finally {
setBusy(false);
}
}
async function exportMarkdown() {
if (busy) return;
setBusy(true);
setError(null);
try {
// Re-fetch through the section's own loader so the file is current,
// then sync the list so the export matches what is displayed.
const fresh = await loadRef.current();
generation.current += 1;
setDocs(fresh);
const markdown = fresh.map((doc) => `# ${doc.path}\n\n${doc.content}`).join("\n\n");
downloadArtifactBytes(exportFilename, "text/markdown", new TextEncoder().encode(markdown));
} catch {
setError(t`Could not load`);
} finally {
setBusy(false);
}
}
return (
<div data-testid={testId}>
{error ? <div className="px-2.5 pb-2 text-ui-body-sm text-destructive">{error}</div> : null}
{loading ? <Skeleton className="h-10 w-full" /> : null}
{!loading && docs.length === 0 && !error ? (
<div className="px-2.5 py-1 text-ui-body text-muted-foreground">
<Trans>Nothing remembered yet</Trans>
</div>
) : null}
{docs.map((doc) => (
<div key={doc.id}>
<Button
variant="ghost"
type="button"
disabled={busy}
onClick={() => (openId === doc.id ? setOpenId(null) : openDoc(doc))}
className={rowClass(openId === doc.id)}
>
<span className="min-w-0 truncate text-ui-body text-foreground" dir="auto">
{doc.path}
</span>
</Button>
{openId === doc.id ? (
<div className="px-2.5 pb-2">
<Textarea
aria-label={doc.path}
disabled={busy}
value={draft}
onChange={(event) => setDraft(event.target.value)}
rows={Math.min(16, Math.max(4, draft.split("\n").length + 1))}
className={fieldClass}
dir="auto"
/>
<div className="mt-2 flex gap-2">
<Button
variant="ghost"
type="button"
disabled={busy || draft === doc.content}
onClick={() => void save(doc)}
className="rounded-lg bg-muted px-3 py-1.5 text-ui-body-sm text-foreground disabled:opacity-50"
>
<Trans>Save</Trans>
</Button>
<Button
variant="ghost"
type="button"
disabled={busy}
onClick={() => setOpenId(null)}
className="rounded-lg px-3 py-1.5 text-ui-body-sm text-muted-foreground"
>
<Trans>Cancel</Trans>
</Button>
</div>
</div>
) : null}
</div>
))}
{docs.length ? (
<Button
variant="ghost"
type="button"
disabled={busy}
onClick={() => void exportMarkdown()}
className="mt-2 px-2.5 text-ui-body-sm text-muted-foreground hover:text-foreground"
>
<Trans>Download as markdown</Trans>
</Button>
) : null}
</div>
);
}
const NEW_SKILL_TEMPLATE = `---
name: my-skill
description: What this skill does and when the agent should use it.
---
Steps the agent should follow.
`;
function AgentSkills({
onSkillsChange,
}: {
onSkillsChange: (skills: AgentSkillCatalogEntry[]) => void;
}) {
const { t } = useLingui();
const [loading, setLoading] = useState(true);
const [skills, setSkills] = useState<AgentSkillCatalogEntry[]>([]);
const [open, setOpen] = useState<AgentSkill | null>(null);
const [creating, setCreating] = useState(false);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const generation = useRef(0);
const selection = useRef(0);
async function refresh() {
const current = ++generation.current;
const list = await rpc.agentSkills.list();
if (current !== generation.current) return;
setSkills(list);
onSkillsChange(list);
}
useEffect(() => {
const current = ++generation.current;
void rpc.agentSkills
.list()
.then((list) => {
if (current !== generation.current) return;
setSkills(list);
setLoading(false);
})
.catch(() => {
if (current !== generation.current) return;
setSkills([]);
setLoading(false);
setError(t`Could not load`);
});
return () => {
generation.current += 1;
selection.current += 1;
};
}, [t]);
async function openSkill(entry: AgentSkillCatalogEntry) {
if (busy) return;
const current = ++selection.current;
setCreating(false);
setConfirmingDelete(false);
setError(null);
try {
const skill = await rpc.agentSkills.get({ skillId: entry.id });
// A slower response must not clobber a newer selection or a fresh draft.
if (current !== selection.current) return;
setOpen(skill);
setDraft(skill.content);
} catch {
if (current !== selection.current) return;
setError(t`Could not load skill`);
}
}
async function save() {
if (busy || !draft.trim()) return;
setBusy(true);
setError(null);
try {
if (creating) {
await rpc.agentSkills.create({ content: draft });
} else if (open) {
await rpc.agentSkills.update({ skillId: open.id, content: draft });
}
setOpen(null);
setCreating(false);
setConfirmingDelete(false);
try {
await refresh();
} catch {
setError(t`Could not load`);
}
} catch {
setError(t`Could not save skill`);
} finally {
setBusy(false);
}
}
async function remove(skill: AgentSkill) {
if (busy) return;
if (!confirmingDelete) {
setConfirmingDelete(true);
return;
}
const skillId = skill.id;
setBusy(true);
setError(null);
try {
await rpc.agentSkills.remove({ skillId });
if (open?.id === skillId) {
setOpen(null);
setConfirmingDelete(false);
}
try {
await refresh();
} catch {
setError(t`Could not load`);
}
} catch {
setError(t`Could not delete skill`);
} finally {
setBusy(false);
}
}
const editorOpen = creating || open;
return (
<div data-testid="bot-knowledge-skills">
{error ? <div className="px-2.5 pb-2 text-ui-body-sm text-destructive">{error}</div> : null}
{loading ? <Skeleton className="h-10 w-full" /> : null}
{!loading && skills.length === 0 && !editorOpen && !error ? (
<div className="px-2.5 py-1 text-ui-body text-muted-foreground">
<Trans>No skills yet</Trans>
</div>
) : null}
{!editorOpen
? skills.map((entry) => (
<Button
variant="ghost"
key={entry.id}
type="button"
disabled={busy}
onClick={() => void openSkill(entry)}
className={`${rowClass(false)} block`}
>
<span className="flex w-full items-baseline justify-between gap-3">
<span className="min-w-0 truncate text-ui-body text-foreground" dir="auto">
{entry.name}
</span>
{entry.readOnly ? (
<span className="shrink-0 text-ui-body-sm text-muted-foreground">
<Trans>read-only</Trans>
</span>
) : null}
</span>
<span
className="mt-0.5 block truncate text-ui-body-sm text-muted-foreground"
dir="auto"
>
{entry.description}
</span>
</Button>
))
: null}
{editorOpen ? (
<div className="px-2.5 pb-2">
{open ? (
<div className="pb-1 text-ui-body text-foreground" dir="auto">
{open.name}
</div>
) : null}
<Textarea
aria-label="SKILL.md"
disabled={busy}
value={draft}
onChange={(event) => setDraft(event.target.value)}
rows={Math.min(20, Math.max(8, draft.split("\n").length + 1))}
readOnly={Boolean(open?.readOnly)}
className={fieldClass}
dir="auto"
/>
<div className="mt-2 flex items-center gap-2">
{!open?.readOnly ? (
<Button
variant="ghost"
type="button"
disabled={busy || !draft.trim() || (!creating && draft === open?.content)}
onClick={() => void save()}
className="rounded-lg bg-muted px-3 py-1.5 text-ui-body-sm text-foreground disabled:opacity-50"
>
<Trans>Save</Trans>
</Button>
) : null}
<Button
variant="ghost"
type="button"
disabled={busy}
onClick={() => {
selection.current += 1;
setOpen(null);
setCreating(false);
setConfirmingDelete(false);
}}
className="rounded-lg px-3 py-1.5 text-ui-body-sm text-muted-foreground"
>
{open?.readOnly ? <Trans>Close</Trans> : <Trans>Cancel</Trans>}
</Button>
{open && !open.readOnly ? (
<Button
variant="ghost"
type="button"
disabled={busy}
onClick={() => void remove(open)}
className={`ms-auto rounded-lg px-3 py-1.5 text-ui-body-sm ${
confirmingDelete ? "bg-destructive/10 text-destructive" : "text-destructive"
}`}
>
{confirmingDelete ? <Trans>Confirm delete</Trans> : <Trans>Delete</Trans>}
</Button>
) : null}
</div>
</div>
) : (
<Button
variant="ghost"
type="button"
disabled={busy}
onClick={() => {
selection.current += 1;
setOpen(null);
setCreating(true);
setConfirmingDelete(false);
setDraft(NEW_SKILL_TEMPLATE);
setError(null);
}}
className="mt-2 px-2.5 text-ui-body-sm text-muted-foreground hover:text-foreground"
>
<Trans>New skill</Trans>
</Button>
)}
</div>
);
}