mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-20 23:21:06 +00:00
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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user