From 9a557751d618bf3c5d2c30f80233e940837f0599 Mon Sep 17 00:00:00 2001 From: Admire <64821731+LittleChenLiya@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:25:47 +0800 Subject: [PATCH] feat: support memory import and export (#1521) * feat: support memory import and export * fix(memory): address review feedback * style: format memory settings page --------- Co-authored-by: Willem Jiang --- backend/app/gateway/routers/memory.py | 29 ++ .../harness/deerflow/agents/memory/updater.py | 19 ++ backend/packages/harness/deerflow/client.py | 12 + backend/tests/test_client.py | 15 + backend/tests/test_memory_router.py | 48 ++++ backend/tests/test_memory_updater.py | 32 ++- .../src/app/api/memory/[...path]/route.ts | 55 ++++ frontend/src/app/api/memory/route.ts | 35 +++ .../settings/memory-settings-page.tsx | 258 ++++++++++++++++-- frontend/src/core/i18n/locales/en-US.ts | 13 +- frontend/src/core/i18n/locales/types.ts | 11 +- frontend/src/core/i18n/locales/zh-CN.ts | 15 +- frontend/src/core/memory/api.ts | 77 +++++- frontend/src/core/memory/hooks.ts | 12 + 14 files changed, 604 insertions(+), 27 deletions(-) create mode 100644 frontend/src/app/api/memory/[...path]/route.ts create mode 100644 frontend/src/app/api/memory/route.ts diff --git a/backend/app/gateway/routers/memory.py b/backend/app/gateway/routers/memory.py index 2d0863087..7a074100a 100644 --- a/backend/app/gateway/routers/memory.py +++ b/backend/app/gateway/routers/memory.py @@ -8,6 +8,7 @@ from deerflow.agents.memory.updater import ( create_memory_fact, delete_memory_fact, get_memory_data, + import_memory_data, reload_memory_data, update_memory_fact, ) @@ -248,6 +249,34 @@ async def update_memory_fact_endpoint(fact_id: str, request: FactPatchRequest) - return MemoryResponse(**memory_data) +@router.get( + "/memory/export", + response_model=MemoryResponse, + summary="Export Memory Data", + description="Export the current global memory data as JSON for backup or transfer.", +) +async def export_memory() -> MemoryResponse: + """Export the current memory data.""" + memory_data = get_memory_data() + return MemoryResponse(**memory_data) + + +@router.post( + "/memory/import", + response_model=MemoryResponse, + summary="Import Memory Data", + description="Import and overwrite the current global memory data from a JSON payload.", +) +async def import_memory(request: MemoryResponse) -> MemoryResponse: + """Import and persist memory data.""" + try: + memory_data = import_memory_data(request.model_dump()) + except OSError as exc: + raise HTTPException(status_code=500, detail="Failed to import memory data.") from exc + + return MemoryResponse(**memory_data) + + @router.get( "/memory/config", response_model=MemoryConfigResponse, diff --git a/backend/packages/harness/deerflow/agents/memory/updater.py b/backend/packages/harness/deerflow/agents/memory/updater.py index 0a4369bcd..e8c8b5898 100644 --- a/backend/packages/harness/deerflow/agents/memory/updater.py +++ b/backend/packages/harness/deerflow/agents/memory/updater.py @@ -39,6 +39,25 @@ def reload_memory_data(agent_name: str | None = None) -> dict[str, Any]: return get_memory_storage().reload(agent_name) +def import_memory_data(memory_data: dict[str, Any], agent_name: str | None = None) -> dict[str, Any]: + """Persist imported memory data via storage provider. + + Args: + memory_data: Full memory payload to persist. + agent_name: If provided, imports into per-agent memory. + + Returns: + The saved memory data after storage normalization. + + Raises: + OSError: If persisting the imported memory fails. + """ + storage = get_memory_storage() + if not storage.save(memory_data, agent_name): + raise OSError("Failed to save imported memory data") + return storage.load(agent_name) + + def clear_memory_data(agent_name: str | None = None) -> dict[str, Any]: """Clear all stored memory data and persist an empty structure.""" cleared_memory = create_empty_memory() diff --git a/backend/packages/harness/deerflow/client.py b/backend/packages/harness/deerflow/client.py index 8d3544c81..5357e8f2a 100644 --- a/backend/packages/harness/deerflow/client.py +++ b/backend/packages/harness/deerflow/client.py @@ -507,6 +507,18 @@ class DeerFlowClient: return get_memory_data() + def export_memory(self) -> dict: + """Export current memory data for backup or transfer.""" + from deerflow.agents.memory.updater import get_memory_data + + return get_memory_data() + + def import_memory(self, memory_data: dict) -> dict: + """Import and persist full memory data.""" + from deerflow.agents.memory.updater import import_memory_data + + return import_memory_data(memory_data) + def get_model(self, name: str) -> dict | None: """Get a specific model's configuration by name. diff --git a/backend/tests/test_client.py b/backend/tests/test_client.py index a50ea5898..b1912937d 100644 --- a/backend/tests/test_client.py +++ b/backend/tests/test_client.py @@ -145,6 +145,13 @@ class TestConfigQueries: mock_mem.assert_called_once() assert result == memory + def test_export_memory(self, client): + memory = {"version": "1.0", "facts": []} + with patch("deerflow.agents.memory.updater.get_memory_data", return_value=memory) as mock_mem: + result = client.export_memory() + mock_mem.assert_called_once() + assert result == memory + # --------------------------------------------------------------------------- # stream / chat @@ -661,6 +668,14 @@ class TestSkillsManagement: class TestMemoryManagement: + def test_import_memory(self, client): + imported = {"version": "1.0", "facts": []} + with patch("deerflow.agents.memory.updater.import_memory_data", return_value=imported) as mock_import: + result = client.import_memory(imported) + + mock_import.assert_called_once_with(imported) + assert result == imported + def test_reload_memory(self, client): data = {"version": "1.0", "facts": []} with patch("deerflow.agents.memory.updater.reload_memory_data", return_value=data): diff --git a/backend/tests/test_memory_router.py b/backend/tests/test_memory_router.py index ce4e6aea7..39134c61d 100644 --- a/backend/tests/test_memory_router.py +++ b/backend/tests/test_memory_router.py @@ -24,6 +24,54 @@ def _sample_memory(facts: list[dict] | None = None) -> dict: } +def test_export_memory_route_returns_current_memory() -> None: + app = FastAPI() + app.include_router(memory.router) + exported_memory = _sample_memory( + facts=[ + { + "id": "fact_export", + "content": "User prefers concise responses.", + "category": "preference", + "confidence": 0.9, + "createdAt": "2026-03-20T00:00:00Z", + "source": "thread-1", + } + ] + ) + + with patch("app.gateway.routers.memory.get_memory_data", return_value=exported_memory): + with TestClient(app) as client: + response = client.get("/api/memory/export") + + assert response.status_code == 200 + assert response.json()["facts"] == exported_memory["facts"] + + +def test_import_memory_route_returns_imported_memory() -> None: + app = FastAPI() + app.include_router(memory.router) + imported_memory = _sample_memory( + facts=[ + { + "id": "fact_import", + "content": "User works on DeerFlow.", + "category": "context", + "confidence": 0.87, + "createdAt": "2026-03-20T00:00:00Z", + "source": "manual", + } + ] + ) + + with patch("app.gateway.routers.memory.import_memory_data", return_value=imported_memory): + with TestClient(app) as client: + response = client.post("/api/memory/import", json=imported_memory) + + assert response.status_code == 200 + assert response.json()["facts"] == imported_memory["facts"] + + def test_clear_memory_route_returns_cleared_memory() -> None: app = FastAPI() app.include_router(memory.router) diff --git a/backend/tests/test_memory_updater.py b/backend/tests/test_memory_updater.py index a360a641b..f7b48228a 100644 --- a/backend/tests/test_memory_updater.py +++ b/backend/tests/test_memory_updater.py @@ -7,6 +7,7 @@ from deerflow.agents.memory.updater import ( clear_memory_data, create_memory_fact, delete_memory_fact, + import_memory_data, update_memory_fact, ) from deerflow.config.memory_config import MemoryConfig @@ -233,6 +234,31 @@ def test_delete_memory_fact_raises_for_unknown_id() -> None: raise AssertionError("Expected KeyError for missing fact id") +def test_import_memory_data_saves_and_returns_imported_memory() -> None: + imported_memory = _make_memory( + facts=[ + { + "id": "fact_import", + "content": "User works on DeerFlow.", + "category": "context", + "confidence": 0.87, + "createdAt": "2026-03-20T00:00:00Z", + "source": "manual", + } + ] + ) + mock_storage = MagicMock() + mock_storage.save.return_value = True + mock_storage.load.return_value = imported_memory + + with patch("deerflow.agents.memory.updater.get_memory_storage", return_value=mock_storage): + result = import_memory_data(imported_memory) + + mock_storage.save.assert_called_once_with(imported_memory, None) + mock_storage.load.assert_called_once_with(None) + assert result == imported_memory + + def test_update_memory_fact_updates_only_matching_fact() -> None: current_memory = _make_memory( facts=[ @@ -349,7 +375,7 @@ def test_update_memory_fact_rejects_invalid_confidence() -> None: # --------------------------------------------------------------------------- -# _extract_text — LLM response content normalization +# _extract_text - LLM response content normalization # --------------------------------------------------------------------------- @@ -409,7 +435,7 @@ class TestExtractText: # --------------------------------------------------------------------------- -# format_conversation_for_update — handles mixed list content +# format_conversation_for_update - handles mixed list content # --------------------------------------------------------------------------- @@ -439,7 +465,7 @@ class TestFormatConversationForUpdate: # --------------------------------------------------------------------------- -# update_memory — structured LLM response handling +# update_memory - structured LLM response handling # --------------------------------------------------------------------------- diff --git a/frontend/src/app/api/memory/[...path]/route.ts b/frontend/src/app/api/memory/[...path]/route.ts new file mode 100644 index 000000000..8de48d742 --- /dev/null +++ b/frontend/src/app/api/memory/[...path]/route.ts @@ -0,0 +1,55 @@ +import type { NextRequest } from "next/server"; + +const BACKEND_BASE_URL = + process.env.NEXT_PUBLIC_BACKEND_BASE_URL ?? "http://127.0.0.1:8001"; + +function buildBackendUrl(pathname: string) { + return new URL(pathname, BACKEND_BASE_URL); +} + +async function proxyRequest(request: NextRequest, pathname: string) { + const headers = new Headers(request.headers); + headers.delete("host"); + headers.delete("connection"); + headers.delete("content-length"); + + const hasBody = !["GET", "HEAD"].includes(request.method); + const response = await fetch(buildBackendUrl(pathname), { + method: request.method, + headers, + body: hasBody ? await request.arrayBuffer() : undefined, + }); + + return new Response(await response.arrayBuffer(), { + status: response.status, + headers: response.headers, + }); +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> }, +) { + return proxyRequest(request, `/api/memory/${(await params).path.join("/")}`); +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> }, +) { + return proxyRequest(request, `/api/memory/${(await params).path.join("/")}`); +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> }, +) { + return proxyRequest(request, `/api/memory/${(await params).path.join("/")}`); +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> }, +) { + return proxyRequest(request, `/api/memory/${(await params).path.join("/")}`); +} diff --git a/frontend/src/app/api/memory/route.ts b/frontend/src/app/api/memory/route.ts new file mode 100644 index 000000000..22a840465 --- /dev/null +++ b/frontend/src/app/api/memory/route.ts @@ -0,0 +1,35 @@ +import type { NextRequest } from "next/server"; + +const BACKEND_BASE_URL = + process.env.NEXT_PUBLIC_BACKEND_BASE_URL ?? "http://127.0.0.1:8010"; + +function buildBackendUrl(pathname: string) { + return new URL(pathname, BACKEND_BASE_URL); +} + +async function proxyRequest(request: NextRequest, pathname: string) { + const headers = new Headers(request.headers); + headers.delete("host"); + headers.delete("connection"); + headers.delete("content-length"); + + const hasBody = !["GET", "HEAD"].includes(request.method); + const response = await fetch(buildBackendUrl(pathname), { + method: request.method, + headers, + body: hasBody ? await request.arrayBuffer() : undefined, + }); + + return new Response(await response.arrayBuffer(), { + status: response.status, + headers: response.headers, + }); +} + +export async function GET(request: NextRequest) { + return proxyRequest(request, "/api/memory"); +} + +export async function DELETE(request: NextRequest) { + return proxyRequest(request, "/api/memory"); +} diff --git a/frontend/src/components/workspace/settings/memory-settings-page.tsx b/frontend/src/components/workspace/settings/memory-settings-page.tsx index 58208c7ab..ae15fa47d 100644 --- a/frontend/src/components/workspace/settings/memory-settings-page.tsx +++ b/frontend/src/components/workspace/settings/memory-settings-page.tsx @@ -1,8 +1,14 @@ "use client"; -import { PenLineIcon, PlusIcon, Trash2Icon } from "lucide-react"; +import { + DownloadIcon, + PenLineIcon, + PlusIcon, + Trash2Icon, + UploadIcon, +} from "lucide-react"; import Link from "next/link"; -import { useDeferredValue, useId, useState } from "react"; +import { useDeferredValue, useId, useRef, useState } from "react"; import { toast } from "sonner"; import { Streamdown } from "streamdown"; @@ -19,10 +25,12 @@ 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"; @@ -51,6 +59,65 @@ type MemorySectionGroup = { 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; @@ -212,6 +279,8 @@ export function MemorySettingsPage() { 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); @@ -222,6 +291,10 @@ export function MemorySettingsPage() { ); 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(); @@ -258,7 +331,6 @@ export function MemorySettingsPage() { 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; const memoryFullyEmpty = @@ -271,6 +343,11 @@ export function MemorySettingsPage() { 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 @@ -308,6 +385,68 @@ export function MemorySettingsPage() { (showSummaries && filteredSectionGroups.length > 0) || (showFacts && filteredFacts.length > 0); + async function handleExportMemory() { + try { + setIsExporting(true); + const exportedMemory = await exportMemory(); + const fileName = `deerflow-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(); @@ -416,7 +555,7 @@ export function MemorySettingsPage() { ) : null} -
+
-
+
+ void handleImportFileSelection(event)} + /> + +

{fact.content}

- {fact.source === "manual" ? ( - - {manualFactSource} - - ) : ( - - {t.settings.memory.markdown.table.view} - - )}
@@ -753,6 +918,65 @@ export function MemorySettingsPage() { + + { + if (!open) { + setPendingImport(null); + } + }} + > + + + {t.settings.memory.importConfirmTitle} + + {t.settings.memory.importConfirmDescription} + + + {pendingImport ? ( +
+
+ + {t.settings.memory.importFileLabel}: + {" "} + {pendingImport.fileName} +
+
+ + {t.settings.memory.markdown.facts}: + {" "} + {pendingImport.memory.facts.length} +
+
+ + {t.common.lastUpdated}: + {" "} + {pendingImport.memory.lastUpdated + ? formatTimeAgo(pendingImport.memory.lastUpdated) + : "-"} +
+
+ ) : null} + + + + +
+
); } diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 00af5a538..1cf758a0a 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -44,6 +44,7 @@ export const enUS: Translations = { save: "Save", install: "Install", create: "Create", + import: "Import", export: "Export", exportAsMarkdown: "Export as Markdown", exportAsJSON: "Export as JSON", @@ -313,6 +314,17 @@ export const enUS: Translations = { "DeerFlow automatically learns from your conversations in the background. These memories help DeerFlow understand you better and deliver a more personalized experience.", empty: "No memory data to display.", rawJson: "Raw JSON", + exportButton: "Export memory", + exportSuccess: "Memory exported", + importButton: "Import memory", + importConfirmTitle: "Import memory?", + importConfirmDescription: + "This will overwrite your current memory with the selected JSON backup.", + importFileLabel: "Selected file", + importInvalidFile: + "Failed to read the selected memory file. Please choose a valid JSON export.", + importSuccess: "Memory imported", + manualFactSource: "Manual", addFact: "Add fact", addFactTitle: "Add memory fact", editFactTitle: "Edit memory fact", @@ -336,7 +348,6 @@ export const enUS: Translations = { factSave: "Save fact", factValidationContent: "Fact content cannot be empty.", factValidationConfidence: "Confidence must be a number between 0 and 1.", - manualFactSource: "Manual", noFacts: "No saved facts yet.", summaryReadOnly: "Summary sections are read-only for now. You can currently add, edit, or delete individual facts, or clear all memory.", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 0244b77dd..f3f343157 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -33,6 +33,7 @@ export interface Translations { save: string; install: string; create: string; + import: string; export: string; exportAsMarkdown: string; exportAsJSON: string; @@ -248,6 +249,15 @@ export interface Translations { description: string; empty: string; rawJson: string; + exportButton: string; + exportSuccess: string; + importButton: string; + importConfirmTitle: string; + importConfirmDescription: string; + importFileLabel: string; + importInvalidFile: string; + importSuccess: string; + manualFactSource: string; addFact: string; addFactTitle: string; editFactTitle: string; @@ -269,7 +279,6 @@ export interface Translations { factSave: string; factValidationContent: string; factValidationConfidence: string; - manualFactSource: string; noFacts: string; summaryReadOnly: string; memoryFullyEmpty: string; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 019c46d7b..bd3e3c0ae 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -1,4 +1,4 @@ -import { +import { CompassIcon, GraduationCapIcon, ImageIcon, @@ -44,6 +44,7 @@ export const zhCN: Translations = { save: "保存", install: "安装", create: "创建", + import: "导入", export: "导出", exportAsMarkdown: "导出为 Markdown", exportAsJSON: "导出为 JSON", @@ -299,6 +300,15 @@ export const zhCN: Translations = { "DeerFlow 会在后台不断从你的对话中自动学习。这些记忆能帮助 DeerFlow 更好地理解你,并提供更个性化的体验。", empty: "暂无可展示的记忆数据。", rawJson: "原始 JSON", + exportButton: "导出记忆", + exportSuccess: "记忆已导出", + importButton: "导入记忆", + importConfirmTitle: "导入记忆?", + importConfirmDescription: "这会用选中的 JSON 备份覆盖当前记忆。", + importFileLabel: "已选择文件", + importInvalidFile: "读取记忆文件失败,请选择有效的 JSON 导出文件。", + importSuccess: "记忆已导入", + manualFactSource: "手动添加", addFact: "添加事实", addFactTitle: "添加记忆事实", editFactTitle: "编辑记忆事实", @@ -322,10 +332,9 @@ export const zhCN: Translations = { factSave: "保存事实", factValidationContent: "事实内容不能为空。", factValidationConfidence: "置信度必须是 0 到 1 之间的数字。", - manualFactSource: "手动添加", noFacts: "还没有保存的事实。", summaryReadOnly: - "摘要分区当前仍为只读。你可以在下方添加、编辑或删除事实,或清空全部记忆。", + "摘要分区当前仍为只读。现在你可以清空全部记忆或删除单条事实。", memoryFullyEmpty: "还没有保存任何记忆。", factPreviewLabel: "即将删除的事实", searchPlaceholder: "搜索记忆", diff --git a/frontend/src/core/memory/api.ts b/frontend/src/core/memory/api.ts index f6c1cd9aa..5fcf8e4c0 100644 --- a/frontend/src/core/memory/api.ts +++ b/frontend/src/core/memory/api.ts @@ -10,12 +10,69 @@ async function readMemoryResponse( response: Response, fallbackMessage: string, ): Promise { + function formatErrorDetail(detail: unknown): string | null { + if (typeof detail === "string") { + return detail; + } + + if (Array.isArray(detail)) { + const parts = detail + .map((item) => { + if (typeof item === "string") { + return item; + } + + if (item && typeof item === "object") { + const record = item as Record; + if (typeof record.msg === "string") { + return record.msg; + } + + try { + return JSON.stringify(record); + } catch { + return null; + } + } + + return String(item); + }) + .filter(Boolean); + + return parts.length > 0 ? parts.join("; ") : null; + } + + if (detail && typeof detail === "object") { + try { + return JSON.stringify(detail); + } catch { + return null; + } + } + + if ( + typeof detail === "string" || + typeof detail === "number" || + typeof detail === "boolean" || + typeof detail === "bigint" + ) { + return String(detail); + } + + if (typeof detail === "symbol") { + return detail.description ?? null; + } + + return null; + } + if (!response.ok) { const errorData = (await response.json().catch(() => ({}))) as { - detail?: string; + detail?: unknown; }; + const detailMessage = formatErrorDetail(errorData.detail); throw new Error( - errorData.detail ?? `${fallbackMessage}: ${response.statusText}`, + detailMessage ?? `${fallbackMessage}: ${response.statusText}`, ); } @@ -44,6 +101,22 @@ export async function deleteMemoryFact(factId: string): Promise { return readMemoryResponse(response, "Failed to delete memory fact"); } +export async function exportMemory(): Promise { + const response = await fetch(`${getBackendBaseURL()}/api/memory/export`); + return readMemoryResponse(response, "Failed to export memory"); +} + +export async function importMemory(memory: UserMemory): Promise { + const response = await fetch(`${getBackendBaseURL()}/api/memory/import`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(memory), + }); + return readMemoryResponse(response, "Failed to import memory"); +} + export async function createMemoryFact( input: MemoryFactInput, ): Promise { diff --git a/frontend/src/core/memory/hooks.ts b/frontend/src/core/memory/hooks.ts index d458de29e..36836ca5b 100644 --- a/frontend/src/core/memory/hooks.ts +++ b/frontend/src/core/memory/hooks.ts @@ -4,6 +4,7 @@ import { clearMemory, createMemoryFact, deleteMemoryFact, + importMemory, loadMemory, updateMemoryFact, } from "./api"; @@ -43,6 +44,17 @@ export function useDeleteMemoryFact() { }); } +export function useImportMemory() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (memory: UserMemory) => importMemory(memory), + onSuccess: (memory) => { + queryClient.setQueryData(["memory"], memory); + }, + }); +} + export function useCreateMemoryFact() { const queryClient = useQueryClient();