| 1 | import { Bot, Brain, Pencil, Pin, PinOff, Trash2, User as UserIcon } from "lucide-react"; |
| 2 | import { useEffect, useRef, useState } from "react"; |
| 3 | import { data, useFetcher, useOutletContext } from "react-router"; |
| 4 | |
| 5 | import type { AgentMemory, AgentMemoryScope, WorkspaceAgent } from "@g1t/contracts"; |
| 6 | |
| 7 | import type { Route } from "./+types/memory"; |
| 8 | import { agentsAction, answer, readOrNull } from "../../../components/agents/actions.server"; |
| 9 | import { type ActionResult, BUTTONS, Confirm } from "../../../components/agents/dialogs"; |
| 10 | import { memoryGroups } from "../../../components/agents/format"; |
| 11 | import { Quiet } from "../../../components/agents/parts"; |
| 12 | import { TimeAgo } from "../../../components/ui"; |
| 13 | import { Hint } from "../../../components/ui/hint"; |
| 14 | import { SelectField } from "../../../components/ui/select"; |
| 15 | import { Textarea } from "../../../components/ui/textarea"; |
| 16 | import { sidebarOrNull } from "../../../lib/chat.server"; |
| 17 | import { workspaceAgents } from "../../../lib/services.server"; |
| 18 | import { requireUser, roleIn } from "../../../lib/session.server"; |
| 19 | |
| 20 | /** The longest fact the agents service keeps. */ |
| 21 | const MAX_FACT = 500; |
| 22 | |
| 23 | /** What the agent remembers that the viewer may see, and the channels they could give it facts for. */ |
| 24 | export 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 | |
| 37 | export 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 | */ |
| 62 | export 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'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>'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'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 | |
| 100 | function 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 | |
| 146 | function 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't recall “{memory.body}” again, anywhere. It can learn it again if it comes up. |
| 228 | </Confirm> |
| 229 | </div> |
| 230 | )} |
| 231 | </div> |
| 232 | )} |
| 233 | </li> |
| 234 | ); |
| 235 | } |