Skip to content
241 linesCodeBlameRaw
1import { Bot, Brain, Pencil, Pin, PinOff, Trash2, User as UserIcon } from "lucide-react";
2import { useEffect, useRef, useState } from "react";
3import { data, useFetcher, useOutletContext } from "react-router";
4
5import type { AgentMemory, AgentMemoryScope, WorkspaceAgent } from "@g1t/contracts";
6
7import type { Route } from "./+types/memory";
8import { agentsAction, answer, readOrNull } from "../../../components/agents/actions.server";
9import { type ActionResult, Confirm } from "../../../components/agents/dialogs";
10import { memoryGroups } from "../../../components/agents/format";
11import { Quiet } from "../../../components/agents/parts";
12import { TimeAgo } from "../../../components/ui";
13import { Button } from "../../../components/ui/button";
14import { Card } from "../../../components/ui/card";
15import { Hint } from "../../../components/ui/hint";
16import { SelectField } from "../../../components/ui/select";
17import { Textarea } from "../../../components/ui/textarea";
18import { sidebarOrNull } from "../../../lib/chat.server";
19import { workspaceAgents } from "../../../lib/services.server";
20import { requireUser, roleIn } from "../../../lib/session.server";
21
22/** The longest fact the agents service keeps. */
23const MAX_FACT = 500;
24
25/** What the agent remembers that the viewer may see, and the channels they could give it facts for. */
26export async function loader({ params, context, request }: Route.LoaderArgs) {
27 const viewer = requireUser(context, request);
28 const role = roleIn(viewer, params.owner);
29 if (!role) throw data(null, { status: 404 });
30 const slug = params.owner.toLowerCase();
31 const [memories, sidebar] = await Promise.all([readOrNull(workspaceAgents.memories(slug, params.handle.toLowerCase(), viewer)), sidebarOrNull(slug, viewer)]);
32 const channels = (sidebar?.entries ?? [])
33 .filter((entry) => entry.channel.kind === "channel" && entry.channel.name)
34 .map((entry) => ({ id: entry.channel.id, name: entry.channel.name! }))
35 .sort((a, b) => a.name.localeCompare(b.name));
36 return { memories, channels, isOwner: role === "owner", me: viewer.username };
37}
38
39export async function action({ params, context, request }: Route.ActionArgs): Promise<ActionResult> {
40 const { viewer, slug, form } = await agentsAction(request, context, params.owner);
41 const handle = params.handle.toLowerCase();
42 const intent = String(form.get("intent") ?? "");
43 const id = String(form.get("id") ?? "");
44 if (intent === "remember") {
45 const body = String(form.get("body") ?? "").trim();
46 if (!body) return { ok: false, intent, error: "Write what it should remember." };
47 const asked = String(form.get("scope") ?? "person");
48 const scope: AgentMemoryScope = asked === "workspace" || asked === "channel" ? asked : "person";
49 const channel = String(form.get("channel") ?? "");
50 if (scope === "channel" && !channel) return { ok: false, intent, error: "Choose the channel it's for." };
51 return answer(intent, workspaceAgents.remember(slug, handle, viewer, { body, scope, scope_ref: scope === "channel" ? channel : null }));
52 }
53 if (intent === "edit") return answer(intent, workspaceAgents.updateMemory(slug, handle, viewer, id, { body: String(form.get("body") ?? "") }));
54 if (intent === "pin") return answer(intent, workspaceAgents.updateMemory(slug, handle, viewer, id, { pinned: form.get("pinned") === "true" }));
55 if (intent === "forget") return answer(intent, workspaceAgents.forget(slug, handle, viewer, id));
56 return { ok: false, intent, error: "Unknown request." };
57}
58
59/**
60 * What the agent remembers, by where it may be recalled: the whole
61 * workspace, one conversation, or only the viewer's own DMs with it. Each
62 * fact carries where it came from.
63 */
64export default function MemoryTab({ loaderData }: Route.ComponentProps) {
65 const agent = useOutletContext<WorkspaceAgent>();
66 const { memories, channels, isOwner } = loaderData;
67 if (!memories) return <Quiet title="Memory can't be shown right now">The agents service didn&apos;t answer. Reload in a moment.</Quiet>;
68 const groups = memoryGroups(memories);
69 return (
70 <div className="space-y-8">
71 <p className="max-w-2xl text-sm text-muted">
72 {agent.display_name} has no hidden memory: only these facts, each recalled only where it belongs. <strong className="font-medium text-fg-soft">Workspace</strong> facts
73 are recalled anywhere; a <strong className="font-medium text-fg-soft">conversation</strong>&apos;s only there; <strong className="font-medium text-fg-soft">just you</strong>{" "}
74 only in your direct messages with it. You see the facts from conversations you&apos;re in.
75 </p>
76 <AddFact agent={agent} channels={channels} isOwner={isOwner} />
77 {groups.length === 0 ? (
78 <Quiet title="Nothing remembered yet" icon={<Brain size={20} />}>
79 When a session learns something worth keeping, or someone tells {agent.display_name} to remember it, the fact shows here with where it came from.
80 </Quiet>
81 ) : (
82 groups.map((group) => (
83 <section key={group.scope}>
84 <div className="flex flex-wrap items-baseline gap-x-3">
85 <h2 className="text-sm font-medium">
86 {group.title} <span className="text-faint">{group.memories.length}</span>
87 </h2>
88 <p className="text-xs text-faint">{group.about}</p>
89 </div>
90 <Card asChild className="mt-3 divide-y divide-line/60 overflow-hidden">
91 <ul>
92 {group.memories.map((memory) => (
93 <Fact key={memory.id} memory={memory} agent={agent} canChange={memory.scope !== "workspace" || isOwner} />
94 ))}
95 </ul>
96 </Card>
97 </section>
98 ))
99 )}
100 </div>
101 );
102}
103
104function AddFact({ agent, channels, isOwner }: { agent: WorkspaceAgent; channels: { id: string; name: string }[]; isOwner: boolean }) {
105 const fetcher = useFetcher<ActionResult>({ key: "remember" });
106 const form = useRef<HTMLFormElement>(null);
107 const [scope, setScope] = useState<AgentMemoryScope>("person");
108 const busy = fetcher.state !== "idle";
109 useEffect(() => {
110 if (fetcher.state === "idle" && fetcher.data?.ok) form.current?.reset();
111 }, [fetcher.state, fetcher.data]);
112 const options = [
113 { value: "person", label: "Just me", description: "Recalled only in your direct messages with it." },
114 ...(isOwner ? [{ value: "workspace", label: "This workspace", description: "Recalled anywhere, for anyone. Owners only." }] : []),
115 ...(channels.length > 0 ? [{ value: "channel", label: "A channel", description: "Recalled only in that channel and its threads." }] : []),
116 ];
117 return (
118 <Card asChild className="p-4">
119 <fetcher.Form ref={form} method="post">
120 <input type="hidden" name="intent" value="remember" />
121 <label htmlFor="fact" className="text-sm font-medium">
122 Add a fact
123 </label>
124 <Textarea
125 id="fact"
126 name="body"
127 rows={2}
128 maxLength={MAX_FACT}
129 required
130 placeholder={`Something ${agent.display_name} should know, such as: Releases go out on Thursdays.`}
131 className="mt-2"
132 />
133 <div className="mt-3 flex flex-wrap items-center gap-2">
134 <div className="w-44">
135 <SelectField name="scope" value={scope} onValueChange={(v) => setScope(v as AgentMemoryScope)} options={options} size="sm" aria-label="Who it's recalled for" />
136 </div>
137 {scope === "channel" && (
138 <div className="w-48">
139 <SelectField name="channel" options={channels.map((c) => ({ value: c.id, label: `#${c.name}` }))} placeholder="Choose a channel" size="sm" aria-label="Channel" required />
140 </div>
141 )}
142 <span className="grow text-xs text-danger">{fetcher.data && !fetcher.data.ok && fetcher.data.intent === "remember" ? fetcher.data.error : ""}</span>
143 <Button type="submit" disabled={busy} variant="accent" size="sm">
144 {busy ? "Saving…" : "Remember"}
145 </Button>
146 </div>
147 </fetcher.Form>
148 </Card>
149 );
150}
151
152function Fact({ memory, agent, canChange }: { memory: AgentMemory; agent: WorkspaceAgent; canChange: boolean }) {
153 const [editing, setEditing] = useState(false);
154 const edit = useFetcher<ActionResult>({ key: `edit-${memory.id}` });
155 const pin = useFetcher<ActionResult>({ key: `pin-${memory.id}` });
156 useEffect(() => {
157 if (edit.state === "idle" && edit.data?.ok) setEditing(false);
158 }, [edit.state, edit.data]);
159 const pinned = pin.formData ? pin.formData.get("pinned") === "true" : memory.pinned;
160 const by =
161 memory.created_by_kind === "agent" ? (
162 <span className="inline-flex items-center gap-1">
163 <Bot size={11} />
164 Kept by {memory.created_by === agent.handle ? agent.display_name : `@${memory.created_by}`}
165 </span>
166 ) : (
167 <span className="inline-flex items-center gap-1">
168 <UserIcon size={11} />
169 Told by @{memory.created_by}
170 </span>
171 );
172 return (
173 <li className="group px-4 py-3">
174 {editing ? (
175 <edit.Form method="post" className="space-y-2">
176 <input type="hidden" name="intent" value="edit" />
177 <input type="hidden" name="id" value={memory.id} />
178 <Textarea name="body" defaultValue={memory.body} rows={2} maxLength={MAX_FACT} required autoFocus aria-label="The fact" />
179 <div className="flex items-center justify-end gap-2">
180 <span className="grow text-xs text-danger">{edit.data && !edit.data.ok ? edit.data.error : ""}</span>
181 <Button type="button" variant="outline" size="sm" onClick={() => setEditing(false)}>
182 Cancel
183 </Button>
184 <Button type="submit" variant="accent" size="sm" disabled={edit.state !== "idle"}>
185 {edit.state !== "idle" ? "Saving…" : "Save"}
186 </Button>
187 </div>
188 </edit.Form>
189 ) : (
190 <div className="flex items-start gap-3">
191 {pinned && (
192 <Hint label="Pinned: always recalled where it belongs">
193 <Pin size={13} className="mt-1 shrink-0 text-accent" aria-label="Pinned" />
194 </Hint>
195 )}
196 <div className="min-w-0 grow">
197 <p className="text-sm text-fg">{memory.body}</p>
198 <p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-faint">
199 {memory.scope === "channel" && memory.scope_label && <span className="text-muted">#{memory.scope_label}</span>}
200 {by}
201 {memory.source_label && <span>from {memory.source_label}</span>}
202 <TimeAgo at={memory.updated_at} />
203 </p>
204 </div>
205 {canChange && (
206 <div className="flex shrink-0 items-center gap-0.5 opacity-100 transition-opacity sm:opacity-0 sm:group-focus-within:opacity-100 sm:group-hover:opacity-100">
207 <pin.Form method="post">
208 <input type="hidden" name="intent" value="pin" />
209 <input type="hidden" name="id" value={memory.id} />
210 <input type="hidden" name="pinned" value={pinned ? "false" : "true"} />
211 <Hint label={pinned ? "Unpin" : "Pin"}>
212 <Button type="submit" aria-label={pinned ? "Unpin" : "Pin"} variant="ghost" size="icon-xs" className="text-faint max-sm:size-10">
213 {pinned ? <PinOff size={14} /> : <Pin size={14} />}
214 </Button>
215 </Hint>
216 </pin.Form>
217 <Hint label="Edit">
218 <Button type="button" aria-label="Edit" onClick={() => setEditing(true)} variant="ghost" size="icon-xs" className="text-faint max-sm:size-10">
219 <Pencil size={14} />
220 </Button>
221 </Hint>
222 <Confirm
223 title="Forget this fact?"
224 confirm="Forget"
225 fields={{ intent: "forget", id: memory.id }}
226 fetcherKey={`forget-${memory.id}`}
227 trigger={
228 <Button type="button" aria-label="Forget" variant="ghost" size="icon-xs" className="text-faint max-sm:size-10 hover:text-danger">
229 <Trash2 size={14} />
230 </Button>
231 }
232 >
233 {agent.display_name} won&apos;t recall &ldquo;{memory.body}&rdquo; again, anywhere. It can learn it again if it comes up.
234 </Confirm>
235 </div>
236 )}
237 </div>
238 )}
239 </li>
240 );
241}