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