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 <willem.jiang@gmail.com>
This commit is contained in:
Admire 2026-03-30 17:25:47 +08:00 committed by GitHub
parent 2330c38209
commit 9a557751d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 604 additions and 27 deletions

View File

@ -8,6 +8,7 @@ from deerflow.agents.memory.updater import (
create_memory_fact, create_memory_fact,
delete_memory_fact, delete_memory_fact,
get_memory_data, get_memory_data,
import_memory_data,
reload_memory_data, reload_memory_data,
update_memory_fact, update_memory_fact,
) )
@ -248,6 +249,34 @@ async def update_memory_fact_endpoint(fact_id: str, request: FactPatchRequest) -
return MemoryResponse(**memory_data) 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( @router.get(
"/memory/config", "/memory/config",
response_model=MemoryConfigResponse, response_model=MemoryConfigResponse,

View File

@ -39,6 +39,25 @@ def reload_memory_data(agent_name: str | None = None) -> dict[str, Any]:
return get_memory_storage().reload(agent_name) 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]: def clear_memory_data(agent_name: str | None = None) -> dict[str, Any]:
"""Clear all stored memory data and persist an empty structure.""" """Clear all stored memory data and persist an empty structure."""
cleared_memory = create_empty_memory() cleared_memory = create_empty_memory()

View File

@ -507,6 +507,18 @@ class DeerFlowClient:
return get_memory_data() 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: def get_model(self, name: str) -> dict | None:
"""Get a specific model's configuration by name. """Get a specific model's configuration by name.

View File

@ -145,6 +145,13 @@ class TestConfigQueries:
mock_mem.assert_called_once() mock_mem.assert_called_once()
assert result == memory 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 # stream / chat
@ -661,6 +668,14 @@ class TestSkillsManagement:
class TestMemoryManagement: 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): def test_reload_memory(self, client):
data = {"version": "1.0", "facts": []} data = {"version": "1.0", "facts": []}
with patch("deerflow.agents.memory.updater.reload_memory_data", return_value=data): with patch("deerflow.agents.memory.updater.reload_memory_data", return_value=data):

View File

@ -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: def test_clear_memory_route_returns_cleared_memory() -> None:
app = FastAPI() app = FastAPI()
app.include_router(memory.router) app.include_router(memory.router)

View File

@ -7,6 +7,7 @@ from deerflow.agents.memory.updater import (
clear_memory_data, clear_memory_data,
create_memory_fact, create_memory_fact,
delete_memory_fact, delete_memory_fact,
import_memory_data,
update_memory_fact, update_memory_fact,
) )
from deerflow.config.memory_config import MemoryConfig 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") 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: def test_update_memory_fact_updates_only_matching_fact() -> None:
current_memory = _make_memory( current_memory = _make_memory(
facts=[ 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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -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("/")}`);
}

View File

@ -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");
}

View File

@ -1,8 +1,14 @@
"use client"; "use client";
import { PenLineIcon, PlusIcon, Trash2Icon } from "lucide-react"; import {
DownloadIcon,
PenLineIcon,
PlusIcon,
Trash2Icon,
UploadIcon,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useDeferredValue, useId, useState } from "react"; import { useDeferredValue, useId, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Streamdown } from "streamdown"; import { Streamdown } from "streamdown";
@ -19,10 +25,12 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { exportMemory } from "@/core/memory/api";
import { import {
useClearMemory, useClearMemory,
useCreateMemoryFact, useCreateMemoryFact,
useDeleteMemoryFact, useDeleteMemoryFact,
useImportMemory,
useMemory, useMemory,
useUpdateMemoryFact, useUpdateMemoryFact,
} from "@/core/memory/hooks"; } from "@/core/memory/hooks";
@ -51,6 +59,65 @@ type MemorySectionGroup = {
sections: MemorySection[]; sections: MemorySection[];
}; };
type PendingImport = {
fileName: string;
memory: UserMemory;
};
function isRecord(value: unknown): value is Record<string, unknown> {
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 = { type FactFormState = {
content: string; content: string;
category: string; category: string;
@ -212,6 +279,8 @@ export function MemorySettingsPage() {
const clearMemory = useClearMemory(); const clearMemory = useClearMemory();
const createMemoryFact = useCreateMemoryFact(); const createMemoryFact = useCreateMemoryFact();
const deleteMemoryFact = useDeleteMemoryFact(); const deleteMemoryFact = useDeleteMemoryFact();
const importMemoryMutation = useImportMemory();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const updateMemoryFact = useUpdateMemoryFact(); const updateMemoryFact = useUpdateMemoryFact();
const [clearDialogOpen, setClearDialogOpen] = useState(false); const [clearDialogOpen, setClearDialogOpen] = useState(false);
const [factToDelete, setFactToDelete] = useState<MemoryFact | null>(null); const [factToDelete, setFactToDelete] = useState<MemoryFact | null>(null);
@ -222,6 +291,10 @@ export function MemorySettingsPage() {
); );
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [filter, setFilter] = useState<MemoryViewFilter>("all"); const [filter, setFilter] = useState<MemoryViewFilter>("all");
const [pendingImport, setPendingImport] = useState<PendingImport | null>(
null,
);
const [isExporting, setIsExporting] = useState(false);
const deferredQuery = useDeferredValue(query); const deferredQuery = useDeferredValue(query);
const normalizedQuery = deferredQuery.trim().toLowerCase(); const normalizedQuery = deferredQuery.trim().toLowerCase();
const factContentInputId = useId(); const factContentInputId = useId();
@ -258,7 +331,6 @@ export function MemorySettingsPage() {
const factSave = t.settings.memory.factSave; const factSave = t.settings.memory.factSave;
const factValidationContent = t.settings.memory.factValidationContent; const factValidationContent = t.settings.memory.factValidationContent;
const factValidationConfidence = t.settings.memory.factValidationConfidence; const factValidationConfidence = t.settings.memory.factValidationConfidence;
const manualFactSource = t.settings.memory.manualFactSource;
const noFacts = t.settings.memory.noFacts ?? "No saved facts yet."; const noFacts = t.settings.memory.noFacts ?? "No saved facts yet.";
const summaryReadOnly = t.settings.memory.summaryReadOnly; const summaryReadOnly = t.settings.memory.summaryReadOnly;
const memoryFullyEmpty = const memoryFullyEmpty =
@ -271,6 +343,11 @@ export function MemorySettingsPage() {
const filterFacts = t.settings.memory.filterFacts ?? "Facts"; const filterFacts = t.settings.memory.filterFacts ?? "Facts";
const filterSummaries = t.settings.memory.filterSummaries ?? "Summaries"; const filterSummaries = t.settings.memory.filterSummaries ?? "Summaries";
const noMatches = t.settings.memory.noMatches ?? "No matching memory found"; 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 sectionGroups = memory ? buildMemorySectionGroups(memory, t) : [];
const filteredSectionGroups = sectionGroups const filteredSectionGroups = sectionGroups
@ -308,6 +385,68 @@ export function MemorySettingsPage() {
(showSummaries && filteredSectionGroups.length > 0) || (showSummaries && filteredSectionGroups.length > 0) ||
(showFacts && filteredFacts.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() { async function handleClearMemory() {
try { try {
await clearMemory.mutateAsync(); await clearMemory.mutateAsync();
@ -416,7 +555,7 @@ export function MemorySettingsPage() {
</div> </div>
) : null} ) : null}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div className="flex flex-1 flex-col gap-3 sm:flex-row sm:items-center"> <div className="flex flex-1 flex-col gap-3 sm:flex-row sm:items-center">
<Input <Input
value={query} value={query}
@ -440,7 +579,30 @@ export function MemorySettingsPage() {
</ToggleGroup> </ToggleGroup>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-wrap gap-2">
<input
ref={fileInputRef}
type="file"
accept=".json,application/json"
className="hidden"
onChange={(event) => void handleImportFileSelection(event)}
/>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={importMemoryMutation.isPending}
>
<UploadIcon className="mr-2 h-4 w-4" />
{importButton}
</Button>
<Button
variant="outline"
onClick={() => void handleExportMemory()}
disabled={isExporting}
>
<DownloadIcon className="mr-2 h-4 w-4" />
{isExporting ? t.common.loading : exportButton}
</Button>
<Button variant="outline" onClick={openCreateFactDialog}> <Button variant="outline" onClick={openCreateFactDialog}>
<PlusIcon className="mr-2 h-4 w-4" /> <PlusIcon className="mr-2 h-4 w-4" />
{addFactLabel} {addFactLabel}
@ -519,22 +681,25 @@ export function MemorySettingsPage() {
</span>{" "} </span>{" "}
{formatTimeAgo(fact.createdAt)} {formatTimeAgo(fact.createdAt)}
</span> </span>
<span>
<span className="text-muted-foreground">
{t.settings.memory.markdown.table.source}:
</span>{" "}
{fact.source === "manual" ? (
t.settings.memory.manualFactSource
) : (
<Link
href={pathOfThread(fact.source)}
className="text-primary underline-offset-4 hover:underline"
>
{t.settings.memory.markdown.table.view}
</Link>
)}
</span>
</div> </div>
<p className="text-sm break-words"> <p className="text-sm break-words">
{fact.content} {fact.content}
</p> </p>
{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> </div>
<div className="flex shrink-0 items-center gap-1 self-start sm:ml-3"> <div className="flex shrink-0 items-center gap-1 self-start sm:ml-3">
@ -753,6 +918,65 @@ export function MemorySettingsPage() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog
open={pendingImport !== null}
onOpenChange={(open) => {
if (!open) {
setPendingImport(null);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.settings.memory.importConfirmTitle}</DialogTitle>
<DialogDescription>
{t.settings.memory.importConfirmDescription}
</DialogDescription>
</DialogHeader>
{pendingImport ? (
<div className="bg-muted rounded-md border p-3 text-sm">
<div>
<span className="text-muted-foreground">
{t.settings.memory.importFileLabel}:
</span>{" "}
{pendingImport.fileName}
</div>
<div>
<span className="text-muted-foreground">
{t.settings.memory.markdown.facts}:
</span>{" "}
{pendingImport.memory.facts.length}
</div>
<div>
<span className="text-muted-foreground">
{t.common.lastUpdated}:
</span>{" "}
{pendingImport.memory.lastUpdated
? formatTimeAgo(pendingImport.memory.lastUpdated)
: "-"}
</div>
</div>
) : null}
<DialogFooter>
<Button
variant="outline"
onClick={() => setPendingImport(null)}
disabled={importMemoryMutation.isPending}
>
{t.common.cancel}
</Button>
<Button
onClick={() => void handleConfirmImport()}
disabled={importMemoryMutation.isPending}
>
{importMemoryMutation.isPending
? t.common.loading
: t.common.import}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</> </>
); );
} }

View File

@ -44,6 +44,7 @@ export const enUS: Translations = {
save: "Save", save: "Save",
install: "Install", install: "Install",
create: "Create", create: "Create",
import: "Import",
export: "Export", export: "Export",
exportAsMarkdown: "Export as Markdown", exportAsMarkdown: "Export as Markdown",
exportAsJSON: "Export as JSON", 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.", "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.", empty: "No memory data to display.",
rawJson: "Raw JSON", 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", addFact: "Add fact",
addFactTitle: "Add memory fact", addFactTitle: "Add memory fact",
editFactTitle: "Edit memory fact", editFactTitle: "Edit memory fact",
@ -336,7 +348,6 @@ export const enUS: Translations = {
factSave: "Save fact", factSave: "Save fact",
factValidationContent: "Fact content cannot be empty.", factValidationContent: "Fact content cannot be empty.",
factValidationConfidence: "Confidence must be a number between 0 and 1.", factValidationConfidence: "Confidence must be a number between 0 and 1.",
manualFactSource: "Manual",
noFacts: "No saved facts yet.", noFacts: "No saved facts yet.",
summaryReadOnly: summaryReadOnly:
"Summary sections are read-only for now. You can currently add, edit, or delete individual facts, or clear all memory.", "Summary sections are read-only for now. You can currently add, edit, or delete individual facts, or clear all memory.",

View File

@ -33,6 +33,7 @@ export interface Translations {
save: string; save: string;
install: string; install: string;
create: string; create: string;
import: string;
export: string; export: string;
exportAsMarkdown: string; exportAsMarkdown: string;
exportAsJSON: string; exportAsJSON: string;
@ -248,6 +249,15 @@ export interface Translations {
description: string; description: string;
empty: string; empty: string;
rawJson: string; rawJson: string;
exportButton: string;
exportSuccess: string;
importButton: string;
importConfirmTitle: string;
importConfirmDescription: string;
importFileLabel: string;
importInvalidFile: string;
importSuccess: string;
manualFactSource: string;
addFact: string; addFact: string;
addFactTitle: string; addFactTitle: string;
editFactTitle: string; editFactTitle: string;
@ -269,7 +279,6 @@ export interface Translations {
factSave: string; factSave: string;
factValidationContent: string; factValidationContent: string;
factValidationConfidence: string; factValidationConfidence: string;
manualFactSource: string;
noFacts: string; noFacts: string;
summaryReadOnly: string; summaryReadOnly: string;
memoryFullyEmpty: string; memoryFullyEmpty: string;

View File

@ -1,4 +1,4 @@
import { import {
CompassIcon, CompassIcon,
GraduationCapIcon, GraduationCapIcon,
ImageIcon, ImageIcon,
@ -44,6 +44,7 @@ export const zhCN: Translations = {
save: "保存", save: "保存",
install: "安装", install: "安装",
create: "创建", create: "创建",
import: "导入",
export: "导出", export: "导出",
exportAsMarkdown: "导出为 Markdown", exportAsMarkdown: "导出为 Markdown",
exportAsJSON: "导出为 JSON", exportAsJSON: "导出为 JSON",
@ -299,6 +300,15 @@ export const zhCN: Translations = {
"DeerFlow 会在后台不断从你的对话中自动学习。这些记忆能帮助 DeerFlow 更好地理解你,并提供更个性化的体验。", "DeerFlow 会在后台不断从你的对话中自动学习。这些记忆能帮助 DeerFlow 更好地理解你,并提供更个性化的体验。",
empty: "暂无可展示的记忆数据。", empty: "暂无可展示的记忆数据。",
rawJson: "原始 JSON", rawJson: "原始 JSON",
exportButton: "导出记忆",
exportSuccess: "记忆已导出",
importButton: "导入记忆",
importConfirmTitle: "导入记忆?",
importConfirmDescription: "这会用选中的 JSON 备份覆盖当前记忆。",
importFileLabel: "已选择文件",
importInvalidFile: "读取记忆文件失败,请选择有效的 JSON 导出文件。",
importSuccess: "记忆已导入",
manualFactSource: "手动添加",
addFact: "添加事实", addFact: "添加事实",
addFactTitle: "添加记忆事实", addFactTitle: "添加记忆事实",
editFactTitle: "编辑记忆事实", editFactTitle: "编辑记忆事实",
@ -322,10 +332,9 @@ export const zhCN: Translations = {
factSave: "保存事实", factSave: "保存事实",
factValidationContent: "事实内容不能为空。", factValidationContent: "事实内容不能为空。",
factValidationConfidence: "置信度必须是 0 到 1 之间的数字。", factValidationConfidence: "置信度必须是 0 到 1 之间的数字。",
manualFactSource: "手动添加",
noFacts: "还没有保存的事实。", noFacts: "还没有保存的事实。",
summaryReadOnly: summaryReadOnly:
"摘要分区当前仍为只读。你可以在下方添加、编辑或删除事实,或清空全部记忆。", "摘要分区当前仍为只读。现在你可以清空全部记忆或删除单条事实。",
memoryFullyEmpty: "还没有保存任何记忆。", memoryFullyEmpty: "还没有保存任何记忆。",
factPreviewLabel: "即将删除的事实", factPreviewLabel: "即将删除的事实",
searchPlaceholder: "搜索记忆", searchPlaceholder: "搜索记忆",

View File

@ -10,12 +10,69 @@ async function readMemoryResponse(
response: Response, response: Response,
fallbackMessage: string, fallbackMessage: string,
): Promise<UserMemory> { ): Promise<UserMemory> {
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<string, unknown>;
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) { if (!response.ok) {
const errorData = (await response.json().catch(() => ({}))) as { const errorData = (await response.json().catch(() => ({}))) as {
detail?: string; detail?: unknown;
}; };
const detailMessage = formatErrorDetail(errorData.detail);
throw new Error( throw new Error(
errorData.detail ?? `${fallbackMessage}: ${response.statusText}`, detailMessage ?? `${fallbackMessage}: ${response.statusText}`,
); );
} }
@ -44,6 +101,22 @@ export async function deleteMemoryFact(factId: string): Promise<UserMemory> {
return readMemoryResponse(response, "Failed to delete memory fact"); return readMemoryResponse(response, "Failed to delete memory fact");
} }
export async function exportMemory(): Promise<UserMemory> {
const response = await fetch(`${getBackendBaseURL()}/api/memory/export`);
return readMemoryResponse(response, "Failed to export memory");
}
export async function importMemory(memory: UserMemory): Promise<UserMemory> {
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( export async function createMemoryFact(
input: MemoryFactInput, input: MemoryFactInput,
): Promise<UserMemory> { ): Promise<UserMemory> {

View File

@ -4,6 +4,7 @@ import {
clearMemory, clearMemory,
createMemoryFact, createMemoryFact,
deleteMemoryFact, deleteMemoryFact,
importMemory,
loadMemory, loadMemory,
updateMemoryFact, updateMemoryFact,
} from "./api"; } 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<UserMemory>(["memory"], memory);
},
});
}
export function useCreateMemoryFact() { export function useCreateMemoryFact() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();