diff --git a/frontend-web/src/components/workspace/settings/memory-settings-page.tsx b/frontend-web/src/components/workspace/settings/memory-settings-page.tsx index b6f59515..3f120123 100644 --- a/frontend-web/src/components/workspace/settings/memory-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/memory-settings-page.tsx @@ -1,982 +1,652 @@ -"use client"; - -import { - DownloadIcon, - PenLineIcon, - PlusIcon, - Trash2Icon, - UploadIcon, -} from "lucide-react"; -import Link from "next/link"; -import { useDeferredValue, useId, useRef, 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 { Textarea } from "@/components/ui/textarea"; -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; -import { useI18n } from "@/core/i18n/hooks"; -import { exportMemory } from "@/core/memory/api"; -import { - useClearMemory, - useCreateMemoryFact, - useDeleteMemoryFact, - useImportMemory, - useMemory, - useUpdateMemoryFact, -} from "@/core/memory/hooks"; -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"; - -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[]; -}; - -type PendingImport = { - fileName: string; - memory: UserMemory; -}; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function isMemorySection(value: unknown): value is { - summary: string; - updatedAt: string; -} { - return ( - isRecord(value) && - typeof value.summary === "string" && - typeof value.updatedAt === "string" - ); -} - -function isMemoryFact(value: unknown): value is UserMemory["facts"][number] { - return ( - isRecord(value) && - typeof value.id === "string" && - typeof value.content === "string" && - typeof value.category === "string" && - typeof value.confidence === "number" && - Number.isFinite(value.confidence) && - typeof value.createdAt === "string" && - typeof value.source === "string" - ); -} - -function isImportedMemory(value: unknown): value is UserMemory { - if (!isRecord(value)) { - return false; - } - - if ( - typeof value.version !== "string" || - typeof value.lastUpdated !== "string" || - !isRecord(value.user) || - !isRecord(value.history) || - !Array.isArray(value.facts) - ) { - return false; - } - - return ( - isMemorySection(value.user.workContext) && - isMemorySection(value.user.personalContext) && - isMemorySection(value.user.topOfMind) && - isMemorySection(value.history.recentMonths) && - isMemorySection(value.history.earlierContext) && - isMemorySection(value.history.longTermBackground) && - value.facts.every(isMemoryFact) - ); -} - -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; -} { - 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["t"], -): string { - const content = - section.summary.trim() || - `${t.settings.memory.markdown.empty}`; - return [ - `### ${section.title}`, - content, - "", - section.updatedAt && - `> ${t.settings.memory.markdown.updatedAt}: \`${formatTimeAgo(section.updatedAt)}\``, - ] - .filter(Boolean) - .join("\n"); -} - -function buildMemorySectionGroups( - memory: UserMemory, - t: ReturnType["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["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 createMemoryFact = useCreateMemoryFact(); - const deleteMemoryFact = useDeleteMemoryFact(); - const importMemoryMutation = useImportMemory(); - const fileInputRef = useRef(null); - const updateMemoryFact = useUpdateMemoryFact(); - const [clearDialogOpen, setClearDialogOpen] = useState(false); - const [factToDelete, setFactToDelete] = useState(null); - const [factToEdit, setFactToEdit] = useState(null); - const [factEditorOpen, setFactEditorOpen] = useState(false); - const [factForm, setFactForm] = useState( - DEFAULT_FACT_FORM_STATE, - ); - const [query, setQuery] = useState(""); - const [filter, setFilter] = useState("all"); - const [pendingImport, setPendingImport] = useState( - null, - ); - const [isExporting, setIsExporting] = useState(false); - 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 = - 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 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 noFacts = t.settings.memory.noFacts ?? "No saved facts yet."; - const summaryReadOnly = t.settings.memory.summaryReadOnly; - 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 exportButton = t.settings.memory.exportButton ?? t.common.export; - const exportSuccess = - t.settings.memory.exportSuccess ?? t.common.exportSuccess; - const importButton = t.settings.memory.importButton ?? t.common.import; - const importSuccess = t.settings.memory.importSuccess ?? "Memory imported"; - - 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 handleExportMemory() { - try { - setIsExporting(true); - const exportedMemory = await exportMemory(); - const fileName = `cm-assistant-memory-${(exportedMemory.lastUpdated || new Date().toISOString()).replace(/[:.]/g, "-")}.json`; - const blob = new Blob([JSON.stringify(exportedMemory, null, 2)], { - type: "application/json", - }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = fileName; - document.body.appendChild(link); - link.click(); - link.remove(); - URL.revokeObjectURL(url); - toast.success(exportSuccess); - } catch (err) { - toast.error(err instanceof Error ? err.message : String(err)); - } finally { - setIsExporting(false); - } - } - - async function handleImportFileSelection(event: { - target: HTMLInputElement; - }) { - const file = event.target.files?.[0]; - event.target.value = ""; - if (!file) { - return; - } - - try { - const parsed: unknown = JSON.parse(await file.text()); - if (!isImportedMemory(parsed)) { - toast.error(t.settings.memory.importInvalidFile); - return; - } - setPendingImport({ - fileName: file.name, - memory: parsed, - }); - } catch { - toast.error(t.settings.memory.importInvalidFile); - } - } - - async function handleConfirmImport() { - if (!pendingImport) { - return; - } - - try { - await importMemoryMutation.mutateAsync(pendingImport.memory); - toast.success(importSuccess); - setPendingImport(null); - } catch (err) { - toast.error(err instanceof Error ? err.message : String(err)); - } - } - - 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)); - } - } - - 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 ( - <> - - {isLoading ? ( -
- {t.common.loading} -
- ) : error ? ( -
Error: {error.message}
- ) : !memory ? ( -
- {t.settings.memory.empty} -
- ) : ( -
- {isMemorySummaryEmpty(memory) && memory.facts.length === 0 ? ( -
- {memoryFullyEmpty} -
- ) : null} - -
-
- setQuery(event.target.value)} - placeholder={searchPlaceholder} - className="sm:max-w-xs" - /> - { - if (value) setFilter(value as MemoryViewFilter); - }} - variant="outline" - > - {filterAll} - {filterFacts} - - {filterSummaries} - - -
- -
- void handleImportFileSelection(event)} - /> - - - - -
-
- - {!hasMatchingVisibleContent && normalizedQuery ? ( -
- {noMatches} -
- ) : null} - - {shouldRenderSummariesBlock ? ( -
-
- {summaryReadOnly} -
- - {summariesToMarkdown(memory, filteredSectionGroups, t)} - -
- ) : null} - - {shouldRenderFactsBlock ? ( -
-
-

- {t.settings.memory.markdown.facts} -

-
- - {filteredFacts.length === 0 ? ( -
- {normalizedQuery ? noMatches : noFacts} -
- ) : ( -
- {filteredFacts.map((fact) => { - const { key } = confidenceToLevelKey(fact.confidence); - const confidenceText = - t.settings.memory.markdown.table.confidenceLevel[key]; - - return ( -
-
-
- - - {t.settings.memory.markdown.table.category}: - {" "} - {upperFirst(fact.category)} - - - - {t.settings.memory.markdown.table.confidence}: - {" "} - {confidenceText} - - - - {t.settings.memory.markdown.table.createdAt}: - {" "} - {formatTimeAgo(fact.createdAt)} - - - - {t.settings.memory.markdown.table.source}: - {" "} - {fact.source === "manual" ? ( - t.settings.memory.manualFactSource - ) : ( - - {t.settings.memory.markdown.table.view} - - )} - -
-

- {fact.content} -

-
- -
- - - -
-
- ); - })} -
- )} -
- ) : null} -
- )} -
- - - - - {clearAllConfirmTitle} - {clearAllConfirmDescription} - - - - - - - - - { - setFactEditorOpen(open); - if (!open) { - setFactToEdit(null); - setFactForm(DEFAULT_FACT_FORM_STATE); - } - }} - > - - - - {factToEdit ? editFactTitle : addFactTitle} - - -
-
- -