feat: support manual add and edit for memory facts (#1538)

* feat: support manual add and edit for memory facts

* fix: restore memory updater save helper

* fix: address memory fact review feedback

* fix: remove duplicate memory fact edit action

* docs: simplify memory fact review setup

* docs: relax memory review startup instructions

* fix: clear rebase marker in memory settings page

* fix: address memory fact review and format issues

* fix: address memory fact review feedback

* refactor: make memory fact updates explicit patch semantics

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Admire
2026-03-29 23:53:23 +08:00
committed by GitHub
parent cdb2a3a017
commit fc7de7fffe
15 changed files with 977 additions and 52 deletions
@@ -1,8 +1,8 @@
"use client";
import { Trash2Icon } from "lucide-react";
import { PenLineIcon, PlusIcon, Trash2Icon } from "lucide-react";
import Link from "next/link";
import { useDeferredValue, useState } from "react";
import { useDeferredValue, useId, useState } from "react";
import { toast } from "sonner";
import { Streamdown } from "streamdown";
@@ -16,14 +16,21 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useI18n } from "@/core/i18n/hooks";
import {
useClearMemory,
useCreateMemoryFact,
useDeleteMemoryFact,
useMemory,
useUpdateMemoryFact,
} from "@/core/memory/hooks";
import type { UserMemory } from "@/core/memory/types";
import type {
MemoryFactInput,
MemoryFactPatchInput,
UserMemory,
} from "@/core/memory/types";
import { streamdownPlugins } from "@/core/streamdown/plugins";
import { pathOfThread } from "@/core/threads/utils";
import { formatTimeAgo } from "@/core/utils/datetime";
@@ -44,6 +51,18 @@ type MemorySectionGroup = {
sections: MemorySection[];
};
type FactFormState = {
content: string;
category: string;
confidence: string;
};
const DEFAULT_FACT_FORM_STATE: FactFormState = {
content: "",
category: "context",
confidence: "0.8",
};
function confidenceToLevelKey(confidence: unknown): {
key: "veryHigh" | "high" | "normal" | "unknown";
value?: number;
@@ -191,13 +210,24 @@ export function MemorySettingsPage() {
const { t } = useI18n();
const { memory, isLoading, error } = useMemory();
const clearMemory = useClearMemory();
const createMemoryFact = useCreateMemoryFact();
const deleteMemoryFact = useDeleteMemoryFact();
const updateMemoryFact = useUpdateMemoryFact();
const [clearDialogOpen, setClearDialogOpen] = useState(false);
const [factToDelete, setFactToDelete] = useState<MemoryFact | null>(null);
const [factToEdit, setFactToEdit] = useState<MemoryFact | null>(null);
const [factEditorOpen, setFactEditorOpen] = useState(false);
const [factForm, setFactForm] = useState<FactFormState>(
DEFAULT_FACT_FORM_STATE,
);
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<MemoryViewFilter>("all");
const deferredQuery = useDeferredValue(query);
const normalizedQuery = deferredQuery.trim().toLowerCase();
const factContentInputId = useId();
const factCategoryInputId = useId();
const factConfidenceInputId = useId();
const factConfidenceHintId = useId();
const clearAllLabel = t.settings.memory.clearAll ?? "Clear all memory";
const clearAllConfirmTitle =
@@ -214,10 +244,23 @@ export function MemorySettingsPage() {
"This fact will be removed from memory immediately. This action cannot be undone.";
const factDeleteSuccess =
t.settings.memory.factDeleteSuccess ?? "Fact deleted";
const addFactLabel = t.settings.memory.addFact;
const addFactTitle = t.settings.memory.addFactTitle;
const editFactTitle = t.settings.memory.editFactTitle;
const addFactSuccess = t.settings.memory.addFactSuccess;
const editFactSuccess = t.settings.memory.editFactSuccess;
const factContentLabel = t.settings.memory.factContentLabel;
const factCategoryLabel = t.settings.memory.factCategoryLabel;
const factConfidenceLabel = t.settings.memory.factConfidenceLabel;
const factContentPlaceholder = t.settings.memory.factContentPlaceholder;
const factCategoryPlaceholder = t.settings.memory.factCategoryPlaceholder;
const factConfidenceHint = t.settings.memory.factConfidenceHint;
const factSave = t.settings.memory.factSave;
const factValidationContent = t.settings.memory.factValidationContent;
const factValidationConfidence = t.settings.memory.factValidationConfidence;
const manualFactSource = t.settings.memory.manualFactSource;
const noFacts = t.settings.memory.noFacts ?? "No saved facts yet.";
const summaryReadOnly =
t.settings.memory.summaryReadOnly ??
"Summary sections are read-only for now. You can currently clear all memory or delete individual facts.";
const summaryReadOnly = t.settings.memory.summaryReadOnly;
const memoryFullyEmpty =
t.settings.memory.memoryFullyEmpty ?? "No memory saved yet.";
const factPreviewLabel =
@@ -287,6 +330,68 @@ export function MemorySettingsPage() {
}
}
function openCreateFactDialog() {
setFactToEdit(null);
setFactForm(DEFAULT_FACT_FORM_STATE);
setFactEditorOpen(true);
}
function openEditFactDialog(fact: MemoryFact) {
setFactToEdit(fact);
setFactForm({
content: fact.content,
category: fact.category,
confidence: String(fact.confidence),
});
setFactEditorOpen(true);
}
async function handleSaveFact() {
const trimmedContent = factForm.content.trim();
if (!trimmedContent) {
toast.error(factValidationContent);
return;
}
const confidence = Number(factForm.confidence);
if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) {
toast.error(factValidationConfidence);
return;
}
const input: MemoryFactInput = {
content: trimmedContent,
category: factForm.category.trim() || "context",
confidence,
};
try {
if (factToEdit) {
const patchInput: MemoryFactPatchInput = {
content: input.content,
category: input.category,
confidence: input.confidence,
};
await updateMemoryFact.mutateAsync({
factId: factToEdit.id,
input: patchInput,
});
toast.success(editFactSuccess);
} else {
await createMemoryFact.mutateAsync(input);
toast.success(addFactSuccess);
}
setFactEditorOpen(false);
setFactToEdit(null);
setFactForm(DEFAULT_FACT_FORM_STATE);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
const isFactFormPending =
createMemoryFact.isPending || updateMemoryFact.isPending;
return (
<>
<SettingsSection
@@ -335,13 +440,19 @@ export function MemorySettingsPage() {
</ToggleGroup>
</div>
<Button
variant="destructive"
onClick={() => setClearDialogOpen(true)}
disabled={clearMemory.isPending}
>
{clearMemory.isPending ? t.common.loading : clearAllLabel}
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={openCreateFactDialog}>
<PlusIcon className="mr-2 h-4 w-4" />
{addFactLabel}
</Button>
<Button
variant="destructive"
onClick={() => setClearDialogOpen(true)}
disabled={clearMemory.isPending}
>
{clearMemory.isPending ? t.common.loading : clearAllLabel}
</Button>
</div>
</div>
{!hasMatchingVisibleContent && normalizedQuery ? (
@@ -412,25 +523,45 @@ export function MemorySettingsPage() {
<p className="text-sm break-words">
{fact.content}
</p>
<Link
href={pathOfThread(fact.source)}
className="text-primary text-sm underline-offset-4 hover:underline"
>
{t.settings.memory.markdown.table.view}
</Link>
{fact.source === "manual" ? (
<span className="text-muted-foreground text-sm">
{manualFactSource}
</span>
) : (
<Link
href={pathOfThread(fact.source)}
className="text-primary text-sm underline-offset-4 hover:underline"
>
{t.settings.memory.markdown.table.view}
</Link>
)}
</div>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive shrink-0"
onClick={() => setFactToDelete(fact)}
disabled={deleteMemoryFact.isPending}
title={t.common.delete}
aria-label={t.common.delete}
>
<Trash2Icon className="h-4 w-4" />
</Button>
<div className="flex shrink-0 items-center gap-1 self-start sm:ml-3">
<Button
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => openEditFactDialog(fact)}
disabled={deleteMemoryFact.isPending}
title={t.common.edit}
aria-label={t.common.edit}
>
<PenLineIcon className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive shrink-0"
onClick={() => setFactToDelete(fact)}
disabled={deleteMemoryFact.isPending}
title={t.common.delete}
aria-label={t.common.delete}
>
<Trash2Icon className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
@@ -467,6 +598,118 @@ export function MemorySettingsPage() {
</DialogContent>
</Dialog>
<Dialog
open={factEditorOpen}
onOpenChange={(open) => {
setFactEditorOpen(open);
if (!open) {
setFactToEdit(null);
setFactForm(DEFAULT_FACT_FORM_STATE);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{factToEdit ? editFactTitle : addFactTitle}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label
className="text-sm font-medium"
htmlFor={factContentInputId}
>
{factContentLabel}
</label>
<Textarea
id={factContentInputId}
value={factForm.content}
onChange={(event) =>
setFactForm((current) => ({
...current,
content: event.target.value,
}))
}
placeholder={factContentPlaceholder}
rows={4}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<label
className="text-sm font-medium"
htmlFor={factCategoryInputId}
>
{factCategoryLabel}
</label>
<Input
id={factCategoryInputId}
value={factForm.category}
onChange={(event) =>
setFactForm((current) => ({
...current,
category: event.target.value,
}))
}
placeholder={factCategoryPlaceholder}
/>
</div>
<div className="space-y-2">
<label
className="text-sm font-medium"
htmlFor={factConfidenceInputId}
>
{factConfidenceLabel}
</label>
<Input
id={factConfidenceInputId}
aria-describedby={factConfidenceHintId}
type="number"
min="0"
max="1"
step="0.01"
value={factForm.confidence}
onChange={(event) =>
setFactForm((current) => ({
...current,
confidence: event.target.value,
}))
}
/>
<div
className="text-muted-foreground text-xs"
id={factConfidenceHintId}
>
{factConfidenceHint}
</div>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setFactEditorOpen(false);
setFactToEdit(null);
setFactForm(DEFAULT_FACT_FORM_STATE);
}}
disabled={isFactFormPending}
>
{t.common.cancel}
</Button>
<Button
onClick={() => void handleSaveFact()}
disabled={isFactFormPending}
>
{isFactFormPending ? t.common.loading : factSave}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={factToDelete !== null}
onOpenChange={(open) => {