feat: add memory management actions and local filters in memory settings (#1467)

* Add MVP memory management actions

* Fix memory settings locale coverage

* Polish memory management interactions

* Add memory search and type filters

* Refine memory settings review feedback

* docs: simplify memory settings review setup

* fix: restore memory updater compatibility helpers

* fix: address memory settings review feedback

* docs: soften memory sample review wording

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: JeffJiang <for-eleven@hotmail.com>
This commit is contained in:
Admire 2026-03-29 13:14:45 +08:00 committed by GitHub
parent 481494b9c0
commit 7eb3a150b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1025 additions and 130 deletions

View File

@ -258,12 +258,19 @@ Prerequisite: complete the "Configuration" steps above first (`make config` and
make setup-sandbox make setup-sandbox
``` ```
4. **Start services**: 4. **(Optional) Load sample memory data for local review**:
```bash
python scripts/load_memory_sample.py
```
This copies the sample fixture into the default local runtime memory file so reviewers can immediately test `Settings > Memory`.
See [backend/docs/MEMORY_SETTINGS_REVIEW.md](backend/docs/MEMORY_SETTINGS_REVIEW.md) for the shortest review flow.
5. **Start services**:
```bash ```bash
make dev make dev
``` ```
5. **Access**: http://localhost:2026 6. **Access**: http://localhost:2026
### Advanced ### Advanced
#### Sandbox Mode #### Sandbox Mode

View File

@ -1,9 +1,14 @@
"""Memory API router for retrieving and managing global memory data.""" """Memory API router for retrieving and managing global memory data."""
from fastapi import APIRouter from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from deerflow.agents.memory.updater import get_memory_data, reload_memory_data from deerflow.agents.memory.updater import (
clear_memory_data,
delete_memory_fact,
get_memory_data,
reload_memory_data,
)
from deerflow.config.memory_config import get_memory_config from deerflow.config.memory_config import get_memory_config
router = APIRouter(prefix="/api", tags=["memory"]) router = APIRouter(prefix="/api", tags=["memory"])
@ -135,6 +140,40 @@ async def reload_memory() -> MemoryResponse:
return MemoryResponse(**memory_data) return MemoryResponse(**memory_data)
@router.delete(
"/memory",
response_model=MemoryResponse,
summary="Clear All Memory Data",
description="Delete all saved memory data and reset the memory structure to an empty state.",
)
async def clear_memory() -> MemoryResponse:
"""Clear all persisted memory data."""
try:
memory_data = clear_memory_data()
except OSError as exc:
raise HTTPException(status_code=500, detail="Failed to clear memory data.") from exc
return MemoryResponse(**memory_data)
@router.delete(
"/memory/facts/{fact_id}",
response_model=MemoryResponse,
summary="Delete Memory Fact",
description="Delete a single saved memory fact by its fact id.",
)
async def delete_memory_fact_endpoint(fact_id: str) -> MemoryResponse:
"""Delete a single fact from memory by fact id."""
try:
memory_data = delete_memory_fact(fact_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail=f"Memory fact '{fact_id}' not found.") from exc
except OSError as exc:
raise HTTPException(status_code=500, detail="Failed to delete memory fact.") from exc
return MemoryResponse(**memory_data)
@router.get( @router.get(
"/memory/config", "/memory/config",
response_model=MemoryConfigResponse, response_model=MemoryConfigResponse,

View File

@ -0,0 +1,39 @@
# Memory Settings Review
Use this when reviewing the Memory Settings search, filter, delete, and clear-all flow locally.
## Quick Review
1. Start DeerFlow locally.
```bash
make dev
```
2. Load the sample memory fixture.
```bash
python scripts/load_memory_sample.py
```
3. Open the app and review `Settings > Memory`.
Default local URLs:
- App: `http://localhost:2026`
- Local frontend-only fallback: `http://localhost:3000`
## What To Check
- Search `memory` and confirm multiple facts are matched.
- Search `Chinese` and confirm text filtering works.
- Search `workflow` and confirm category text is also searchable.
- Switch between `All`, `Facts`, and `Summaries`.
- Delete the disposable sample fact and confirm the list updates immediately.
- Clear all memory and confirm the page enters the empty state.
## Fixture Files
- Sample fixture: `backend/docs/memory-settings-sample.json`
- Default local runtime target: `backend/.deer-flow/memory.json`
The loader script creates a timestamped backup automatically before overwriting an existing runtime memory file.

View File

@ -0,0 +1,106 @@
{
"version": "1.0",
"lastUpdated": "2026-03-28T10:30:00Z",
"user": {
"workContext": {
"summary": "Working on DeerFlow memory management UX, including local search, local filters, clear-all, and single-fact deletion in Settings > Memory.",
"updatedAt": "2026-03-28T10:30:00Z"
},
"personalContext": {
"summary": "Prefers Chinese during collaboration, but wants GitHub PR titles and bodies written in English with a Chinese translation provided alongside them.",
"updatedAt": "2026-03-28T10:28:00Z"
},
"topOfMind": {
"summary": "Wants reviewers to be able to reproduce the memory search and filter flow quickly with pre-populated sample data.",
"updatedAt": "2026-03-28T10:26:00Z"
}
},
"history": {
"recentMonths": {
"summary": "Recently contributed multiple DeerFlow pull requests covering memory, uploads, and compatibility fixes.",
"updatedAt": "2026-03-28T10:24:00Z"
},
"earlierContext": {
"summary": "Often prefers shipping smaller, reviewable changes with explicit validation notes.",
"updatedAt": "2026-03-28T10:22:00Z"
},
"longTermBackground": {
"summary": "Actively building open-source contribution experience and improving end-to-end delivery quality.",
"updatedAt": "2026-03-28T10:20:00Z"
}
},
"facts": [
{
"id": "fact_review_001",
"content": "User prefers Chinese for day-to-day collaboration.",
"category": "preference",
"confidence": 0.95,
"createdAt": "2026-03-28T09:50:00Z",
"source": "thread_pref_cn"
},
{
"id": "fact_review_002",
"content": "PR titles and bodies should be drafted in English and accompanied by a Chinese translation.",
"category": "workflow",
"confidence": 0.93,
"createdAt": "2026-03-28T09:52:00Z",
"source": "thread_pr_style"
},
{
"id": "fact_review_003",
"content": "User implemented memory search and filter improvements in the DeerFlow settings page.",
"category": "project",
"confidence": 0.91,
"createdAt": "2026-03-28T09:54:00Z",
"source": "thread_memory_filters"
},
{
"id": "fact_review_004",
"content": "User added clear-all memory support through the gateway memory API.",
"category": "project",
"confidence": 0.89,
"createdAt": "2026-03-28T09:56:00Z",
"source": "thread_memory_clear"
},
{
"id": "fact_review_005",
"content": "User added single-fact deletion support for persisted memory entries.",
"category": "project",
"confidence": 0.9,
"createdAt": "2026-03-28T09:58:00Z",
"source": "thread_memory_delete"
},
{
"id": "fact_review_006",
"content": "Reviewer can search for keyword memory to see multiple matching facts.",
"category": "testing",
"confidence": 0.84,
"createdAt": "2026-03-28T10:00:00Z",
"source": "thread_review_demo"
},
{
"id": "fact_review_007",
"content": "Reviewer can search for keyword Chinese to verify cross-category matching.",
"category": "testing",
"confidence": 0.82,
"createdAt": "2026-03-28T10:02:00Z",
"source": "thread_review_demo"
},
{
"id": "fact_review_008",
"content": "Reviewer can search for workflow to verify category text is included in local filtering.",
"category": "testing",
"confidence": 0.81,
"createdAt": "2026-03-28T10:04:00Z",
"source": "thread_review_demo"
},
{
"id": "fact_review_009",
"content": "Delete fact testing can target this disposable sample entry.",
"category": "testing",
"confidence": 0.78,
"createdAt": "2026-03-28T10:06:00Z",
"source": "thread_delete_demo"
}
]
}

View File

@ -25,6 +25,8 @@ from deerflow.agents.memory.storage import (
) )
from deerflow.agents.memory.updater import ( from deerflow.agents.memory.updater import (
MemoryUpdater, MemoryUpdater,
clear_memory_data,
delete_memory_fact,
get_memory_data, get_memory_data,
reload_memory_data, reload_memory_data,
update_memory_from_conversation, update_memory_from_conversation,
@ -47,6 +49,8 @@ __all__ = [
"get_memory_storage", "get_memory_storage",
# Updater # Updater
"MemoryUpdater", "MemoryUpdater",
"clear_memory_data",
"delete_memory_fact",
"get_memory_data", "get_memory_data",
"reload_memory_data", "reload_memory_data",
"update_memory_from_conversation", "update_memory_from_conversation",

View File

@ -11,12 +11,22 @@ from deerflow.agents.memory.prompt import (
MEMORY_UPDATE_PROMPT, MEMORY_UPDATE_PROMPT,
format_conversation_for_update, format_conversation_for_update,
) )
from deerflow.agents.memory.storage import get_memory_storage from deerflow.agents.memory.storage import create_empty_memory, get_memory_storage
from deerflow.config.memory_config import get_memory_config from deerflow.config.memory_config import get_memory_config
from deerflow.models import create_chat_model from deerflow.models import create_chat_model
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _create_empty_memory() -> dict[str, Any]:
"""Backward-compatible wrapper around the storage-layer empty-memory factory."""
return create_empty_memory()
def _save_memory_to_file(memory_data: dict[str, Any], agent_name: str | None = None) -> bool:
"""Backward-compatible wrapper around the configured memory storage save path."""
return get_memory_storage().save(memory_data, agent_name)
def get_memory_data(agent_name: str | None = None) -> dict[str, Any]: def get_memory_data(agent_name: str | None = None) -> dict[str, Any]:
"""Get the current memory data via storage provider.""" """Get the current memory data via storage provider."""
return get_memory_storage().load(agent_name) return get_memory_storage().load(agent_name)
@ -26,6 +36,31 @@ 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 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()
if not _save_memory_to_file(cleared_memory, agent_name):
raise OSError("Failed to save cleared memory data")
return cleared_memory
def delete_memory_fact(fact_id: str, agent_name: str | None = None) -> dict[str, Any]:
"""Delete a fact by its id and persist the updated memory data."""
memory_data = get_memory_data(agent_name)
facts = memory_data.get("facts", [])
updated_facts = [fact for fact in facts if fact.get("id") != fact_id]
if len(updated_facts) == len(facts):
raise KeyError(fact_id)
updated_memory = dict(memory_data)
updated_memory["facts"] = updated_facts
if not _save_memory_to_file(updated_memory, agent_name):
raise OSError(f"Failed to save memory data after deleting fact '{fact_id}'")
return updated_memory
def _extract_text(content: Any) -> str: def _extract_text(content: Any) -> str:
"""Extract plain text from LLM response content (str or list of content blocks). """Extract plain text from LLM response content (str or list of content blocks).

View File

@ -682,6 +682,18 @@ class DeerFlowClient:
return reload_memory_data() return reload_memory_data()
def clear_memory(self) -> dict:
"""Clear all persisted memory data."""
from deerflow.agents.memory.updater import clear_memory_data
return clear_memory_data()
def delete_memory_fact(self, fact_id: str) -> dict:
"""Delete a single fact from memory by fact id."""
from deerflow.agents.memory.updater import delete_memory_fact
return delete_memory_fact(fact_id)
def get_memory_config(self) -> dict: def get_memory_config(self) -> dict:
"""Get memory system configuration. """Get memory system configuration.

View File

@ -674,6 +674,19 @@ class TestMemoryManagement:
result = client.reload_memory() result = client.reload_memory()
assert result == data assert result == data
def test_clear_memory(self, client):
data = {"version": "1.0", "facts": []}
with patch("deerflow.agents.memory.updater.clear_memory_data", return_value=data):
result = client.clear_memory()
assert result == data
def test_delete_memory_fact(self, client):
data = {"version": "1.0", "facts": []}
with patch("deerflow.agents.memory.updater.delete_memory_fact", return_value=data) as delete_fact:
result = client.delete_memory_fact("fact_123")
delete_fact.assert_called_once_with("fact_123")
assert result == data
def test_get_memory_config(self, client): def test_get_memory_config(self, client):
config = MagicMock() config = MagicMock()
config.enabled = True config.enabled = True

View File

@ -0,0 +1,72 @@
from unittest.mock import patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.gateway.routers import memory
def _sample_memory(facts: list[dict] | None = None) -> dict:
return {
"version": "1.0",
"lastUpdated": "2026-03-26T12:00:00Z",
"user": {
"workContext": {"summary": "", "updatedAt": ""},
"personalContext": {"summary": "", "updatedAt": ""},
"topOfMind": {"summary": "", "updatedAt": ""},
},
"history": {
"recentMonths": {"summary": "", "updatedAt": ""},
"earlierContext": {"summary": "", "updatedAt": ""},
"longTermBackground": {"summary": "", "updatedAt": ""},
},
"facts": facts or [],
}
def test_clear_memory_route_returns_cleared_memory() -> None:
app = FastAPI()
app.include_router(memory.router)
with patch("app.gateway.routers.memory.clear_memory_data", return_value=_sample_memory()):
with TestClient(app) as client:
response = client.delete("/api/memory")
assert response.status_code == 200
assert response.json()["facts"] == []
def test_delete_memory_fact_route_returns_updated_memory() -> None:
app = FastAPI()
app.include_router(memory.router)
updated_memory = _sample_memory(
facts=[
{
"id": "fact_keep",
"content": "User likes Python",
"category": "preference",
"confidence": 0.9,
"createdAt": "2026-03-20T00:00:00Z",
"source": "thread-1",
}
]
)
with patch("app.gateway.routers.memory.delete_memory_fact", return_value=updated_memory):
with TestClient(app) as client:
response = client.delete("/api/memory/facts/fact_delete")
assert response.status_code == 200
assert response.json()["facts"] == updated_memory["facts"]
def test_delete_memory_fact_route_returns_404_for_missing_fact() -> None:
app = FastAPI()
app.include_router(memory.router)
with patch("app.gateway.routers.memory.delete_memory_fact", side_effect=KeyError("fact_missing")):
with TestClient(app) as client:
response = client.delete("/api/memory/facts/fact_missing")
assert response.status_code == 404
assert response.json()["detail"] == "Memory fact 'fact_missing' not found."

View File

@ -1,7 +1,12 @@
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from deerflow.agents.memory.prompt import format_conversation_for_update from deerflow.agents.memory.prompt import format_conversation_for_update
from deerflow.agents.memory.updater import MemoryUpdater, _extract_text from deerflow.agents.memory.updater import (
MemoryUpdater,
_extract_text,
clear_memory_data,
delete_memory_fact,
)
from deerflow.config.memory_config import MemoryConfig from deerflow.config.memory_config import MemoryConfig
@ -138,6 +143,57 @@ def test_apply_updates_preserves_threshold_and_max_facts_trimming() -> None:
assert result["facts"][1]["source"] == "thread-9" assert result["facts"][1]["source"] == "thread-9"
def test_clear_memory_data_resets_all_sections() -> None:
with patch("deerflow.agents.memory.updater._save_memory_to_file", return_value=True):
result = clear_memory_data()
assert result["version"] == "1.0"
assert result["facts"] == []
assert result["user"]["workContext"]["summary"] == ""
assert result["history"]["recentMonths"]["summary"] == ""
def test_delete_memory_fact_removes_only_matching_fact() -> None:
current_memory = _make_memory(
facts=[
{
"id": "fact_keep",
"content": "User likes Python",
"category": "preference",
"confidence": 0.9,
"createdAt": "2026-03-18T00:00:00Z",
"source": "thread-a",
},
{
"id": "fact_delete",
"content": "User prefers tabs",
"category": "preference",
"confidence": 0.8,
"createdAt": "2026-03-18T00:00:00Z",
"source": "thread-b",
},
]
)
with (
patch("deerflow.agents.memory.updater.get_memory_data", return_value=current_memory),
patch("deerflow.agents.memory.updater._save_memory_to_file", return_value=True),
):
result = delete_memory_fact("fact_delete")
assert [fact["id"] for fact in result["facts"]] == ["fact_keep"]
def test_delete_memory_fact_raises_for_unknown_id() -> None:
with patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()):
try:
delete_memory_fact("fact_missing")
except KeyError as exc:
assert exc.args == ("fact_missing",)
else:
raise AssertionError("Expected KeyError for missing fact id")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _extract_text — LLM response content normalization # _extract_text — LLM response content normalization
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -1,9 +1,28 @@
"use client"; "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 { 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 { useI18n } from "@/core/i18n/hooks";
import { useMemory } from "@/core/memory/hooks"; import {
useClearMemory,
useDeleteMemoryFact,
useMemory,
} from "@/core/memory/hooks";
import type { UserMemory } from "@/core/memory/types"; import type { UserMemory } from "@/core/memory/types";
import { streamdownPlugins } from "@/core/streamdown/plugins"; import { streamdownPlugins } from "@/core/streamdown/plugins";
import { pathOfThread } from "@/core/threads/utils"; import { pathOfThread } from "@/core/threads/utils";
@ -11,6 +30,20 @@ import { formatTimeAgo } from "@/core/utils/datetime";
import { SettingsSection } from "./settings-section"; 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): { function confidenceToLevelKey(confidence: unknown): {
key: "veryHigh" | "high" | "normal" | "unknown"; key: "veryHigh" | "high" | "normal" | "unknown";
value?: number; value?: number;
@ -19,41 +52,82 @@ function confidenceToLevelKey(confidence: unknown): {
return { key: "unknown" }; return { key: "unknown" };
} }
// Clamp to [0, 1] since confidence is expected to be a probability-like score.
const value = Math.min(1, Math.max(0, confidence)); const value = Math.min(1, Math.max(0, confidence));
// 3 levels:
// - veryHigh: [0.85, 1]
// - high: [0.65, 0.85)
// - normal: [0, 0.65)
if (value >= 0.85) return { key: "veryHigh", value }; if (value >= 0.85) return { key: "veryHigh", value };
if (value >= 0.65) return { key: "high", value }; if (value >= 0.65) return { key: "high", value };
return { key: "normal", value }; return { key: "normal", value };
} }
function formatMemorySection( function formatMemorySection(
title: string, section: MemorySection,
summary: string,
updatedAt: string | undefined,
t: ReturnType<typeof useI18n>["t"], t: ReturnType<typeof useI18n>["t"],
): string { ): string {
const content = const content =
summary.trim() || section.summary.trim() ||
`<span class="text-muted-foreground">${t.settings.memory.markdown.empty}</span>`; `<span class="text-muted-foreground">${t.settings.memory.markdown.empty}</span>`;
return [ return [
`### ${title}`, `### ${section.title}`,
content, content,
"", "",
updatedAt && section.updatedAt &&
`> ${t.settings.memory.markdown.updatedAt}: \`${formatTimeAgo(updatedAt)}\``, `> ${t.settings.memory.markdown.updatedAt}: \`${formatTimeAgo(section.updatedAt)}\``,
] ]
.filter(Boolean) .filter(Boolean)
.join("\n"); .join("\n");
} }
function memoryToMarkdown( function buildMemorySectionGroups(
memory: UserMemory, memory: UserMemory,
t: ReturnType<typeof useI18n>["t"], 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[] = []; const parts: string[] = [];
@ -62,83 +136,14 @@ function memoryToMarkdown(
`- **${t.common.lastUpdated}**: \`${formatTimeAgo(memory.lastUpdated)}\``, `- **${t.common.lastUpdated}**: \`${formatTimeAgo(memory.lastUpdated)}\``,
); );
parts.push(`\n## ${t.settings.memory.markdown.userContext}`); for (const group of sectionGroups) {
parts.push( parts.push(`\n## ${group.title}`);
formatMemorySection( for (const section of group.sections) {
t.settings.memory.markdown.work, parts.push(formatMemorySection(section, t));
memory.user.workContext.summary, }
memory.user.workContext.updatedAt,
t,
),
);
parts.push(
formatMemorySection(
t.settings.memory.markdown.personal,
memory.user.personalContext.summary,
memory.user.personalContext.updatedAt,
t,
),
);
parts.push(
formatMemorySection(
t.settings.memory.markdown.topOfMind,
memory.user.topOfMind.summary,
memory.user.topOfMind.updatedAt,
t,
),
);
parts.push(`\n## ${t.settings.memory.markdown.historyBackground}`);
parts.push(
formatMemorySection(
t.settings.memory.markdown.recentMonths,
memory.history.recentMonths.summary,
memory.history.recentMonths.updatedAt,
t,
),
);
parts.push(
formatMemorySection(
t.settings.memory.markdown.earlierContext,
memory.history.earlierContext.summary,
memory.history.earlierContext.updatedAt,
t,
),
);
parts.push(
formatMemorySection(
t.settings.memory.markdown.longTermBackground,
memory.history.longTermBackground.summary,
memory.history.longTermBackground.updatedAt,
t,
),
);
parts.push(`\n## ${t.settings.memory.markdown.facts}`);
if (memory.facts.length === 0) {
parts.push(
`<span class="text-muted-foreground">${t.settings.memory.markdown.empty}</span>`,
);
} else {
parts.push(
[
`| ${t.settings.memory.markdown.table.category} | ${t.settings.memory.markdown.table.confidence} | ${t.settings.memory.markdown.table.content} | ${t.settings.memory.markdown.table.source} | ${t.settings.memory.markdown.table.createdAt} |`,
"|---|---|---|---|---|",
...memory.facts.map((f) => {
const { key, value } = confidenceToLevelKey(f.confidence);
const levelLabel =
t.settings.memory.markdown.table.confidenceLevel[key];
const confidenceText =
typeof value === "number" ? `${levelLabel}` : levelLabel;
return `| ${upperFirst(f.category)} | ${confidenceText} | ${f.content} | [${t.settings.memory.markdown.table.view}](${pathOfThread(f.source)}) | ${formatTimeAgo(f.createdAt)} |`;
}),
].join("\n"),
);
} }
const markdown = parts.join("\n\n"); const markdown = parts.join("\n\n");
// Ensure every level-2 heading (##) is preceded by a horizontal rule.
const lines = markdown.split("\n"); const lines = markdown.split("\n");
const out: string[] = []; const out: string[] = [];
let i = 0; let i = 0;
@ -155,36 +160,355 @@ function memoryToMarkdown(
return out.join("\n"); return out.join("\n");
} }
export function MemorySettingsPage() { function isMemorySummaryEmpty(memory: UserMemory) {
const { t } = useI18n();
const { memory, isLoading, error } = useMemory();
return ( return (
<SettingsSection memory.user.workContext.summary.trim() === "" &&
title={t.settings.memory.title} memory.user.personalContext.summary.trim() === "" &&
description={t.settings.memory.description} memory.user.topOfMind.summary.trim() === "" &&
> memory.history.recentMonths.summary.trim() === "" &&
{isLoading ? ( memory.history.earlierContext.summary.trim() === "" &&
<div className="text-muted-foreground text-sm">{t.common.loading}</div> memory.history.longTermBackground.summary.trim() === ""
) : error ? (
<div>Error: {error.message}</div>
) : !memory ? (
<div className="text-muted-foreground text-sm">
{t.settings.memory.empty}
</div>
) : (
<div className="rounded-lg border p-4">
<Streamdown
className="size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
{...streamdownPlugins}
>
{memoryToMarkdown(memory, t)}
</Streamdown>
</div>
)}
</SettingsSection>
); );
} }
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) { function upperFirst(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1); 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>
</>
);
}

View File

@ -4,7 +4,6 @@ function getBaseOrigin() {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
return window.location.origin; return window.location.origin;
} }
return undefined; return undefined;
} }
@ -13,7 +12,9 @@ export function getBackendBaseURL() {
return new URL( return new URL(
env.NEXT_PUBLIC_BACKEND_BASE_URL, env.NEXT_PUBLIC_BACKEND_BASE_URL,
getBaseOrigin(), getBaseOrigin(),
).toString(); )
.toString()
.replace(/\/+$/, "");
} else { } else {
return ""; return "";
} }

View File

@ -311,6 +311,25 @@ 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",
clearAll: "Clear all memory",
clearAllConfirmTitle: "Clear all memory?",
clearAllConfirmDescription:
"This will remove all saved summaries and facts. This action cannot be undone.",
clearAllSuccess: "All memory cleared",
factDeleteConfirmTitle: "Delete this fact?",
factDeleteConfirmDescription:
"This fact will be removed from memory immediately. This action cannot be undone.",
factDeleteSuccess: "Fact deleted",
noFacts: "No saved facts yet.",
summaryReadOnly:
"Summary sections are read-only for now. You can currently clear all memory or delete individual facts.",
memoryFullyEmpty: "No memory saved yet.",
factPreviewLabel: "Fact to delete",
searchPlaceholder: "Search memory",
filterAll: "All",
filterFacts: "Facts",
filterSummaries: "Summaries",
noMatches: "No matching memory found.",
markdown: { markdown: {
overview: "Overview", overview: "Overview",
userContext: "User context", userContext: "User context",

View File

@ -247,7 +247,23 @@ export interface Translations {
description: string; description: string;
empty: string; empty: string;
rawJson: string; rawJson: string;
markdown: { clearAll: string;
clearAllConfirmTitle: string;
clearAllConfirmDescription: string;
clearAllSuccess: string;
factDeleteConfirmTitle: string;
factDeleteConfirmDescription: string;
factDeleteSuccess: string;
noFacts: string;
summaryReadOnly: string;
memoryFullyEmpty: string;
factPreviewLabel: string;
searchPlaceholder: string;
filterAll: string;
filterFacts: string;
filterSummaries: string;
noMatches: string;
markdown: {
overview: string; overview: string;
userContext: string; userContext: string;
work: string; work: string;

View File

@ -298,6 +298,24 @@ export const zhCN: Translations = {
"DeerFlow 会在后台不断从你的对话中自动学习。这些记忆能帮助 DeerFlow 更好地理解你,并提供更个性化的体验。", "DeerFlow 会在后台不断从你的对话中自动学习。这些记忆能帮助 DeerFlow 更好地理解你,并提供更个性化的体验。",
empty: "暂无可展示的记忆数据。", empty: "暂无可展示的记忆数据。",
rawJson: "原始 JSON", rawJson: "原始 JSON",
clearAll: "清空全部记忆",
clearAllConfirmTitle: "要清空全部记忆吗?",
clearAllConfirmDescription:
"这会删除所有已保存的摘要和事实。此操作无法撤销。",
clearAllSuccess: "已清空全部记忆",
factDeleteConfirmTitle: "要删除这条事实吗?",
factDeleteConfirmDescription:
"这条事实会立即从记忆中删除。此操作无法撤销。",
factDeleteSuccess: "事实已删除",
noFacts: "还没有保存的事实。",
summaryReadOnly: "摘要分区当前仍为只读。现在你可以清空全部记忆或删除单条事实。",
memoryFullyEmpty: "还没有保存任何记忆。",
factPreviewLabel: "即将删除的事实",
searchPlaceholder: "搜索记忆",
filterAll: "全部",
filterFacts: "事实",
filterSummaries: "摘要",
noMatches: "没有找到匹配的记忆。",
markdown: { markdown: {
overview: "概览", overview: "概览",
userContext: "用户上下文", userContext: "用户上下文",

View File

@ -2,8 +2,38 @@ import { getBackendBaseURL } from "../config";
import type { UserMemory } from "./types"; import type { UserMemory } from "./types";
export async function loadMemory() { async function readMemoryResponse(
const memory = await fetch(`${getBackendBaseURL()}/api/memory`); response: Response,
const json = await memory.json(); fallbackMessage: string,
return json as UserMemory; ): Promise<UserMemory> {
if (!response.ok) {
const errorData = (await response.json().catch(() => ({}))) as {
detail?: string;
};
throw new Error(errorData.detail ?? `${fallbackMessage}: ${response.statusText}`);
}
return response.json() as Promise<UserMemory>;
}
export async function loadMemory(): Promise<UserMemory> {
const response = await fetch(`${getBackendBaseURL()}/api/memory`);
return readMemoryResponse(response, "Failed to fetch memory");
}
export async function clearMemory(): Promise<UserMemory> {
const response = await fetch(`${getBackendBaseURL()}/api/memory`, {
method: "DELETE",
});
return readMemoryResponse(response, "Failed to clear memory");
}
export async function deleteMemoryFact(factId: string): Promise<UserMemory> {
const response = await fetch(
`${getBackendBaseURL()}/api/memory/facts/${encodeURIComponent(factId)}`,
{
method: "DELETE",
},
);
return readMemoryResponse(response, "Failed to delete memory fact");
} }

View File

@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { loadMemory } from "./api"; import { clearMemory, deleteMemoryFact, loadMemory } from "./api";
import type { UserMemory } from "./types";
export function useMemory() { export function useMemory() {
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
@ -9,3 +10,25 @@ export function useMemory() {
}); });
return { memory: data ?? null, isLoading, error }; return { memory: data ?? null, isLoading, error };
} }
export function useClearMemory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => clearMemory(),
onSuccess: (memory) => {
queryClient.setQueryData<UserMemory>(["memory"], memory);
},
});
}
export function useDeleteMemoryFact() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (factId: string) => deleteMemoryFact(factId),
onSuccess: (memory) => {
queryClient.setQueryData<UserMemory>(["memory"], memory);
},
});
}

View File

@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Load the Memory Settings review sample into a local DeerFlow runtime."""
from __future__ import annotations
import argparse
import json
import shutil
from datetime import datetime
from pathlib import Path
def default_source(repo_root: Path) -> Path:
return repo_root / "backend" / "docs" / "memory-settings-sample.json"
def default_target(repo_root: Path) -> Path:
return repo_root / "backend" / ".deer-flow" / "memory.json"
def parse_args(repo_root: Path) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Copy the Memory Settings sample data into the local runtime memory file.",
)
parser.add_argument(
"--source",
type=Path,
default=default_source(repo_root),
help="Path to the sample JSON file.",
)
parser.add_argument(
"--target",
type=Path,
default=default_target(repo_root),
help="Path to the runtime memory.json file.",
)
parser.add_argument(
"--no-backup",
action="store_true",
help="Overwrite the target without writing a backup copy first.",
)
return parser.parse_args()
def validate_json_file(path: Path) -> None:
with path.open(encoding="utf-8") as handle:
json.load(handle)
def main() -> int:
repo_root = Path(__file__).resolve().parents[1]
args = parse_args(repo_root)
source = args.source.resolve()
target = args.target.resolve()
if not source.exists():
raise SystemExit(f"Sample file not found: {source}")
validate_json_file(source)
target.parent.mkdir(parents=True, exist_ok=True)
backup_path: Path | None = None
if target.exists() and not args.no_backup:
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup_path = target.with_name(f"{target.name}.bak-{timestamp}")
shutil.copy2(target, backup_path)
shutil.copy2(source, target)
print(f"Loaded sample memory into: {target}")
if backup_path is not None:
print(f"Backup created at: {backup_path}")
else:
print("No backup created.")
return 0
if __name__ == "__main__":
raise SystemExit(main())