deerflow2/frontend/src/components/workspace/settings/memory-settings-page.tsx

515 lines
18 KiB
TypeScript

"use client";
import { Trash2Icon } from "lucide-react";
import Link from "next/link";
import { useDeferredValue, useState } from "react";
import { toast } from "sonner";
import { Streamdown } from "streamdown";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useI18n } from "@/core/i18n/hooks";
import {
useClearMemory,
useDeleteMemoryFact,
useMemory,
} from "@/core/memory/hooks";
import type { UserMemory } from "@/core/memory/types";
import { streamdownPlugins } from "@/core/streamdown/plugins";
import { pathOfThread } from "@/core/threads/utils";
import { formatTimeAgo } from "@/core/utils/datetime";
import { SettingsSection } from "./settings-section";
type MemoryViewFilter = "all" | "facts" | "summaries";
type MemoryFact = UserMemory["facts"][number];
type MemorySection = {
title: string;
summary: string;
updatedAt?: string;
};
type MemorySectionGroup = {
title: string;
sections: MemorySection[];
};
function confidenceToLevelKey(confidence: unknown): {
key: "veryHigh" | "high" | "normal" | "unknown";
value?: number;
} {
if (typeof confidence !== "number" || !Number.isFinite(confidence)) {
return { key: "unknown" };
}
const value = Math.min(1, Math.max(0, confidence));
if (value >= 0.85) return { key: "veryHigh", value };
if (value >= 0.65) return { key: "high", value };
return { key: "normal", value };
}
function formatMemorySection(
section: MemorySection,
t: ReturnType<typeof useI18n>["t"],
): string {
const content =
section.summary.trim() ||
`<span class="text-muted-foreground">${t.settings.memory.markdown.empty}</span>`;
return [
`### ${section.title}`,
content,
"",
section.updatedAt &&
`> ${t.settings.memory.markdown.updatedAt}: \`${formatTimeAgo(section.updatedAt)}\``,
]
.filter(Boolean)
.join("\n");
}
function buildMemorySectionGroups(
memory: UserMemory,
t: ReturnType<typeof useI18n>["t"],
): MemorySectionGroup[] {
return [
{
title: t.settings.memory.markdown.userContext,
sections: [
{
title: t.settings.memory.markdown.work,
summary: memory.user.workContext.summary,
updatedAt: memory.user.workContext.updatedAt,
},
{
title: t.settings.memory.markdown.personal,
summary: memory.user.personalContext.summary,
updatedAt: memory.user.personalContext.updatedAt,
},
{
title: t.settings.memory.markdown.topOfMind,
summary: memory.user.topOfMind.summary,
updatedAt: memory.user.topOfMind.updatedAt,
},
],
},
{
title: t.settings.memory.markdown.historyBackground,
sections: [
{
title: t.settings.memory.markdown.recentMonths,
summary: memory.history.recentMonths.summary,
updatedAt: memory.history.recentMonths.updatedAt,
},
{
title: t.settings.memory.markdown.earlierContext,
summary: memory.history.earlierContext.summary,
updatedAt: memory.history.earlierContext.updatedAt,
},
{
title: t.settings.memory.markdown.longTermBackground,
summary: memory.history.longTermBackground.summary,
updatedAt: memory.history.longTermBackground.updatedAt,
},
],
},
];
}
function summariesToMarkdown(
memory: UserMemory,
sectionGroups: MemorySectionGroup[],
t: ReturnType<typeof useI18n>["t"],
) {
const parts: string[] = [];
parts.push(`## ${t.settings.memory.markdown.overview}`);
parts.push(
`- **${t.common.lastUpdated}**: \`${formatTimeAgo(memory.lastUpdated)}\``,
);
for (const group of sectionGroups) {
parts.push(`\n## ${group.title}`);
for (const section of group.sections) {
parts.push(formatMemorySection(section, t));
}
}
const markdown = parts.join("\n\n");
const lines = markdown.split("\n");
const out: string[] = [];
let i = 0;
for (const line of lines) {
i++;
if (i !== 1 && line.startsWith("## ")) {
if (out.length === 0 || out[out.length - 1] !== "---") {
out.push("---");
}
}
out.push(line);
}
return out.join("\n");
}
function isMemorySummaryEmpty(memory: UserMemory) {
return (
memory.user.workContext.summary.trim() === "" &&
memory.user.personalContext.summary.trim() === "" &&
memory.user.topOfMind.summary.trim() === "" &&
memory.history.recentMonths.summary.trim() === "" &&
memory.history.earlierContext.summary.trim() === "" &&
memory.history.longTermBackground.summary.trim() === ""
);
}
function truncateFactPreview(content: string, maxLength = 140) {
const normalized = content.replace(/\s+/g, " ").trim();
if (normalized.length <= maxLength) {
return normalized;
}
const ellipsis = "...";
if (maxLength <= ellipsis.length) {
return normalized.slice(0, maxLength);
}
return `${normalized.slice(0, maxLength - ellipsis.length)}${ellipsis}`;
}
function upperFirst(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
export function MemorySettingsPage() {
const { t } = useI18n();
const { memory, isLoading, error } = useMemory();
const clearMemory = useClearMemory();
const deleteMemoryFact = useDeleteMemoryFact();
const [clearDialogOpen, setClearDialogOpen] = useState(false);
const [factToDelete, setFactToDelete] = useState<MemoryFact | null>(null);
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<MemoryViewFilter>("all");
const deferredQuery = useDeferredValue(query);
const normalizedQuery = deferredQuery.trim().toLowerCase();
const clearAllLabel = t.settings.memory.clearAll ?? "Clear all memory";
const clearAllConfirmTitle =
t.settings.memory.clearAllConfirmTitle ?? "Clear all memory?";
const clearAllConfirmDescription =
t.settings.memory.clearAllConfirmDescription ??
"This will remove all saved summaries and facts. This action cannot be undone.";
const clearAllSuccess =
t.settings.memory.clearAllSuccess ?? "All memory cleared";
const factDeleteConfirmTitle =
t.settings.memory.factDeleteConfirmTitle ?? "Delete this fact?";
const factDeleteConfirmDescription =
t.settings.memory.factDeleteConfirmDescription ??
"This fact will be removed from memory immediately. This action cannot be undone.";
const factDeleteSuccess =
t.settings.memory.factDeleteSuccess ?? "Fact deleted";
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 memoryFullyEmpty =
t.settings.memory.memoryFullyEmpty ?? "No memory saved yet.";
const factPreviewLabel =
t.settings.memory.factPreviewLabel ?? "Fact to delete";
const searchPlaceholder =
t.settings.memory.searchPlaceholder ?? "Search memory";
const filterAll = t.settings.memory.filterAll ?? "All";
const filterFacts = t.settings.memory.filterFacts ?? "Facts";
const filterSummaries = t.settings.memory.filterSummaries ?? "Summaries";
const noMatches =
t.settings.memory.noMatches ?? "No matching memory found";
const sectionGroups = memory ? buildMemorySectionGroups(memory, t) : [];
const filteredSectionGroups = sectionGroups
.map((group) => ({
...group,
sections: group.sections.filter((section) =>
normalizedQuery
? `${section.title} ${section.summary}`
.toLowerCase()
.includes(normalizedQuery)
: true,
),
}))
.filter((group) => group.sections.length > 0);
const filteredFacts = memory
? memory.facts.filter((fact) =>
normalizedQuery
? `${fact.content} ${fact.category}`
.toLowerCase()
.includes(normalizedQuery)
: true,
)
: [];
const showSummaries = filter !== "facts";
const showFacts = filter !== "summaries";
const shouldRenderSummariesBlock =
showSummaries && (filteredSectionGroups.length > 0 || !normalizedQuery);
const shouldRenderFactsBlock =
showFacts &&
(filteredFacts.length > 0 || !normalizedQuery || filter === "facts");
const hasMatchingVisibleContent =
!memory ||
(showSummaries && filteredSectionGroups.length > 0) ||
(showFacts && filteredFacts.length > 0);
async function handleClearMemory() {
try {
await clearMemory.mutateAsync();
toast.success(clearAllSuccess);
setClearDialogOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleDeleteFact() {
if (!factToDelete) return;
try {
await deleteMemoryFact.mutateAsync(factToDelete.id);
toast.success(factDeleteSuccess);
setFactToDelete(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
return (
<>
<SettingsSection
title={t.settings.memory.title}
description={t.settings.memory.description}
>
{isLoading ? (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
) : error ? (
<div>Error: {error.message}</div>
) : !memory ? (
<div className="text-muted-foreground text-sm">
{t.settings.memory.empty}
</div>
) : (
<div className="space-y-4">
{isMemorySummaryEmpty(memory) && memory.facts.length === 0 ? (
<div className="text-muted-foreground rounded-lg border border-dashed p-4 text-sm">
{memoryFullyEmpty}
</div>
) : null}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 flex-col gap-3 sm:flex-row sm:items-center">
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={searchPlaceholder}
className="sm:max-w-xs"
/>
<ToggleGroup
type="single"
value={filter}
onValueChange={(value) => {
if (value) setFilter(value as MemoryViewFilter);
}}
variant="outline"
>
<ToggleGroupItem value="all">{filterAll}</ToggleGroupItem>
<ToggleGroupItem value="facts">{filterFacts}</ToggleGroupItem>
<ToggleGroupItem value="summaries">
{filterSummaries}
</ToggleGroupItem>
</ToggleGroup>
</div>
<Button
variant="destructive"
onClick={() => setClearDialogOpen(true)}
disabled={clearMemory.isPending}
>
{clearMemory.isPending ? t.common.loading : clearAllLabel}
</Button>
</div>
{!hasMatchingVisibleContent && normalizedQuery ? (
<div className="text-muted-foreground rounded-lg border border-dashed p-4 text-sm">
{noMatches}
</div>
) : null}
{shouldRenderSummariesBlock ? (
<div className="rounded-lg border p-4">
<div className="text-muted-foreground mb-4 text-sm">
{summaryReadOnly}
</div>
<Streamdown
className="size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
{...streamdownPlugins}
>
{summariesToMarkdown(memory, filteredSectionGroups, t)}
</Streamdown>
</div>
) : null}
{shouldRenderFactsBlock ? (
<div className="rounded-lg border p-4">
<div className="mb-4">
<h3 className="text-base font-medium">
{t.settings.memory.markdown.facts}
</h3>
</div>
{filteredFacts.length === 0 ? (
<div className="text-muted-foreground text-sm">
{normalizedQuery ? noMatches : noFacts}
</div>
) : (
<div className="space-y-3">
{filteredFacts.map((fact) => {
const { key } = confidenceToLevelKey(fact.confidence);
const confidenceText =
t.settings.memory.markdown.table.confidenceLevel[key];
return (
<div
key={fact.id}
className="flex flex-col gap-3 rounded-md border p-3 sm:flex-row sm:items-start sm:justify-between"
>
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm">
<span>
<span className="text-muted-foreground">
{t.settings.memory.markdown.table.category}:
</span>{" "}
{upperFirst(fact.category)}
</span>
<span>
<span className="text-muted-foreground">
{t.settings.memory.markdown.table.confidence}:
</span>{" "}
{confidenceText}
</span>
<span>
<span className="text-muted-foreground">
{t.settings.memory.markdown.table.createdAt}:
</span>{" "}
{formatTimeAgo(fact.createdAt)}
</span>
</div>
<p className="break-words text-sm">{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>
</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>
);
})}
</div>
)}
</div>
) : null}
</div>
)}
</SettingsSection>
<Dialog open={clearDialogOpen} onOpenChange={setClearDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{clearAllConfirmTitle}</DialogTitle>
<DialogDescription>
{clearAllConfirmDescription}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setClearDialogOpen(false)}
disabled={clearMemory.isPending}
>
{t.common.cancel}
</Button>
<Button
variant="destructive"
onClick={() => void handleClearMemory()}
disabled={clearMemory.isPending}
>
{clearMemory.isPending ? t.common.loading : clearAllLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={factToDelete !== null}
onOpenChange={(open) => {
if (!open) {
setFactToDelete(null);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{factDeleteConfirmTitle}</DialogTitle>
<DialogDescription>
{factDeleteConfirmDescription}
</DialogDescription>
</DialogHeader>
{factToDelete ? (
<div className="bg-muted rounded-md border p-3 text-sm">
<div className="text-muted-foreground mb-1 font-medium">
{factPreviewLabel}
</div>
<p className="break-words">
{truncateFactPreview(factToDelete.content)}
</p>
</div>
) : null}
<DialogFooter>
<Button
variant="outline"
onClick={() => setFactToDelete(null)}
disabled={deleteMemoryFact.isPending}
>
{t.common.cancel}
</Button>
<Button
variant="destructive"
onClick={() => void handleDeleteFact()}
disabled={deleteMemoryFact.isPending}
>
{deleteMemoryFact.isPending ? t.common.loading : t.common.delete}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}