diff --git a/README.md b/README.md index 7d2d41a7a..82f16c3a7 100644 --- a/README.md +++ b/README.md @@ -258,12 +258,19 @@ Prerequisite: complete the "Configuration" steps above first (`make config` and 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 make dev ``` -5. **Access**: http://localhost:2026 +6. **Access**: http://localhost:2026 ### Advanced #### Sandbox Mode diff --git a/backend/app/gateway/routers/memory.py b/backend/app/gateway/routers/memory.py index 1a134245d..b161a70d0 100644 --- a/backend/app/gateway/routers/memory.py +++ b/backend/app/gateway/routers/memory.py @@ -1,9 +1,14 @@ """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 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 router = APIRouter(prefix="/api", tags=["memory"]) @@ -135,6 +140,40 @@ async def reload_memory() -> MemoryResponse: 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( "/memory/config", response_model=MemoryConfigResponse, diff --git a/backend/docs/MEMORY_SETTINGS_REVIEW.md b/backend/docs/MEMORY_SETTINGS_REVIEW.md new file mode 100644 index 000000000..3d3bcf7a0 --- /dev/null +++ b/backend/docs/MEMORY_SETTINGS_REVIEW.md @@ -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. diff --git a/backend/docs/memory-settings-sample.json b/backend/docs/memory-settings-sample.json new file mode 100644 index 000000000..41320e754 --- /dev/null +++ b/backend/docs/memory-settings-sample.json @@ -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" + } + ] +} diff --git a/backend/packages/harness/deerflow/agents/memory/__init__.py b/backend/packages/harness/deerflow/agents/memory/__init__.py index 218c0be9e..36f31bb72 100644 --- a/backend/packages/harness/deerflow/agents/memory/__init__.py +++ b/backend/packages/harness/deerflow/agents/memory/__init__.py @@ -25,6 +25,8 @@ from deerflow.agents.memory.storage import ( ) from deerflow.agents.memory.updater import ( MemoryUpdater, + clear_memory_data, + delete_memory_fact, get_memory_data, reload_memory_data, update_memory_from_conversation, @@ -47,6 +49,8 @@ __all__ = [ "get_memory_storage", # Updater "MemoryUpdater", + "clear_memory_data", + "delete_memory_fact", "get_memory_data", "reload_memory_data", "update_memory_from_conversation", diff --git a/backend/packages/harness/deerflow/agents/memory/updater.py b/backend/packages/harness/deerflow/agents/memory/updater.py index e90163b55..65da18ef4 100644 --- a/backend/packages/harness/deerflow/agents/memory/updater.py +++ b/backend/packages/harness/deerflow/agents/memory/updater.py @@ -11,12 +11,22 @@ from deerflow.agents.memory.prompt import ( MEMORY_UPDATE_PROMPT, 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.models import create_chat_model 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]: """Get the current memory data via storage provider.""" 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) +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: """Extract plain text from LLM response content (str or list of content blocks). diff --git a/backend/packages/harness/deerflow/client.py b/backend/packages/harness/deerflow/client.py index 964d76964..8fe38ae9e 100644 --- a/backend/packages/harness/deerflow/client.py +++ b/backend/packages/harness/deerflow/client.py @@ -682,6 +682,18 @@ class DeerFlowClient: 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: """Get memory system configuration. diff --git a/backend/tests/test_client.py b/backend/tests/test_client.py index 1c5cdc53e..3a1d6e346 100644 --- a/backend/tests/test_client.py +++ b/backend/tests/test_client.py @@ -674,6 +674,19 @@ class TestMemoryManagement: result = client.reload_memory() 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): config = MagicMock() config.enabled = True diff --git a/backend/tests/test_memory_router.py b/backend/tests/test_memory_router.py new file mode 100644 index 000000000..d99a97b97 --- /dev/null +++ b/backend/tests/test_memory_router.py @@ -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." diff --git a/backend/tests/test_memory_updater.py b/backend/tests/test_memory_updater.py index 341b67683..2f2cec7ca 100644 --- a/backend/tests/test_memory_updater.py +++ b/backend/tests/test_memory_updater.py @@ -1,7 +1,12 @@ from unittest.mock import MagicMock, patch 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 @@ -138,6 +143,57 @@ def test_apply_updates_preserves_threshold_and_max_facts_trimming() -> None: 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 # --------------------------------------------------------------------------- diff --git a/frontend/src/components/workspace/settings/memory-settings-page.tsx b/frontend/src/components/workspace/settings/memory-settings-page.tsx index 831aa38bd..94c4700b4 100644 --- a/frontend/src/components/workspace/settings/memory-settings-page.tsx +++ b/frontend/src/components/workspace/settings/memory-settings-page.tsx @@ -1,9 +1,28 @@ "use client"; +import { Trash2Icon } from "lucide-react"; +import Link from "next/link"; +import { useDeferredValue, useState } from "react"; +import { toast } from "sonner"; import { Streamdown } from "streamdown"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { useI18n } from "@/core/i18n/hooks"; -import { useMemory } from "@/core/memory/hooks"; +import { + useClearMemory, + useDeleteMemoryFact, + useMemory, +} from "@/core/memory/hooks"; import type { UserMemory } from "@/core/memory/types"; import { streamdownPlugins } from "@/core/streamdown/plugins"; import { pathOfThread } from "@/core/threads/utils"; @@ -11,6 +30,20 @@ import { formatTimeAgo } from "@/core/utils/datetime"; import { SettingsSection } from "./settings-section"; +type MemoryViewFilter = "all" | "facts" | "summaries"; +type MemoryFact = UserMemory["facts"][number]; + +type MemorySection = { + title: string; + summary: string; + updatedAt?: string; +}; + +type MemorySectionGroup = { + title: string; + sections: MemorySection[]; +}; + function confidenceToLevelKey(confidence: unknown): { key: "veryHigh" | "high" | "normal" | "unknown"; value?: number; @@ -19,41 +52,82 @@ function confidenceToLevelKey(confidence: 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)); - - // 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.65) return { key: "high", value }; return { key: "normal", value }; } function formatMemorySection( - title: string, - summary: string, - updatedAt: string | undefined, + section: MemorySection, t: ReturnType["t"], ): string { const content = - summary.trim() || + section.summary.trim() || `${t.settings.memory.markdown.empty}`; return [ - `### ${title}`, + `### ${section.title}`, content, "", - updatedAt && - `> ${t.settings.memory.markdown.updatedAt}: \`${formatTimeAgo(updatedAt)}\``, + section.updatedAt && + `> ${t.settings.memory.markdown.updatedAt}: \`${formatTimeAgo(section.updatedAt)}\``, ] .filter(Boolean) .join("\n"); } -function memoryToMarkdown( +function buildMemorySectionGroups( memory: UserMemory, t: ReturnType["t"], +): MemorySectionGroup[] { + return [ + { + title: t.settings.memory.markdown.userContext, + sections: [ + { + title: t.settings.memory.markdown.work, + summary: memory.user.workContext.summary, + updatedAt: memory.user.workContext.updatedAt, + }, + { + title: t.settings.memory.markdown.personal, + summary: memory.user.personalContext.summary, + updatedAt: memory.user.personalContext.updatedAt, + }, + { + title: t.settings.memory.markdown.topOfMind, + summary: memory.user.topOfMind.summary, + updatedAt: memory.user.topOfMind.updatedAt, + }, + ], + }, + { + title: t.settings.memory.markdown.historyBackground, + sections: [ + { + title: t.settings.memory.markdown.recentMonths, + summary: memory.history.recentMonths.summary, + updatedAt: memory.history.recentMonths.updatedAt, + }, + { + title: t.settings.memory.markdown.earlierContext, + summary: memory.history.earlierContext.summary, + updatedAt: memory.history.earlierContext.updatedAt, + }, + { + title: t.settings.memory.markdown.longTermBackground, + summary: memory.history.longTermBackground.summary, + updatedAt: memory.history.longTermBackground.updatedAt, + }, + ], + }, + ]; +} + +function summariesToMarkdown( + memory: UserMemory, + sectionGroups: MemorySectionGroup[], + t: ReturnType["t"], ) { const parts: string[] = []; @@ -62,83 +136,14 @@ function memoryToMarkdown( `- **${t.common.lastUpdated}**: \`${formatTimeAgo(memory.lastUpdated)}\``, ); - parts.push(`\n## ${t.settings.memory.markdown.userContext}`); - parts.push( - formatMemorySection( - t.settings.memory.markdown.work, - 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( - `${t.settings.memory.markdown.empty}`, - ); - } 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"), - ); + for (const group of sectionGroups) { + parts.push(`\n## ${group.title}`); + for (const section of group.sections) { + parts.push(formatMemorySection(section, t)); + } } const markdown = parts.join("\n\n"); - - // Ensure every level-2 heading (##) is preceded by a horizontal rule. const lines = markdown.split("\n"); const out: string[] = []; let i = 0; @@ -155,36 +160,355 @@ function memoryToMarkdown( return out.join("\n"); } -export function MemorySettingsPage() { - const { t } = useI18n(); - const { memory, isLoading, error } = useMemory(); +function isMemorySummaryEmpty(memory: UserMemory) { return ( - - {isLoading ? ( -
{t.common.loading}
- ) : error ? ( -
Error: {error.message}
- ) : !memory ? ( -
- {t.settings.memory.empty} -
- ) : ( -
- - {memoryToMarkdown(memory, t)} - -
- )} -
+ memory.user.workContext.summary.trim() === "" && + memory.user.personalContext.summary.trim() === "" && + memory.user.topOfMind.summary.trim() === "" && + memory.history.recentMonths.summary.trim() === "" && + memory.history.earlierContext.summary.trim() === "" && + memory.history.longTermBackground.summary.trim() === "" ); } +function truncateFactPreview(content: string, maxLength = 140) { + const normalized = content.replace(/\s+/g, " ").trim(); + if (normalized.length <= maxLength) { + return normalized; + } + const ellipsis = "..."; + if (maxLength <= ellipsis.length) { + return normalized.slice(0, maxLength); + } + return `${normalized.slice(0, maxLength - ellipsis.length)}${ellipsis}`; +} + function upperFirst(str: string) { return str.charAt(0).toUpperCase() + str.slice(1); } + +export function MemorySettingsPage() { + const { t } = useI18n(); + const { memory, isLoading, error } = useMemory(); + const clearMemory = useClearMemory(); + const deleteMemoryFact = useDeleteMemoryFact(); + const [clearDialogOpen, setClearDialogOpen] = useState(false); + const [factToDelete, setFactToDelete] = useState(null); + const [query, setQuery] = useState(""); + const [filter, setFilter] = useState("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 ( + <> + + {isLoading ? ( +
{t.common.loading}
+ ) : error ? ( +
Error: {error.message}
+ ) : !memory ? ( +
+ {t.settings.memory.empty} +
+ ) : ( +
+ {isMemorySummaryEmpty(memory) && memory.facts.length === 0 ? ( +
+ {memoryFullyEmpty} +
+ ) : null} + +
+
+ setQuery(event.target.value)} + placeholder={searchPlaceholder} + className="sm:max-w-xs" + /> + { + if (value) setFilter(value as MemoryViewFilter); + }} + variant="outline" + > + {filterAll} + {filterFacts} + + {filterSummaries} + + +
+ + +
+ + {!hasMatchingVisibleContent && normalizedQuery ? ( +
+ {noMatches} +
+ ) : null} + + {shouldRenderSummariesBlock ? ( +
+
+ {summaryReadOnly} +
+ + {summariesToMarkdown(memory, filteredSectionGroups, t)} + +
+ ) : null} + + {shouldRenderFactsBlock ? ( +
+
+

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

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

{fact.content}

+ + {t.settings.memory.markdown.table.view} + +
+ + +
+ ); + })} +
+ )} +
+ ) : null} +
+ )} +
+ + + + + {clearAllConfirmTitle} + + {clearAllConfirmDescription} + + + + + + + + + + { + if (!open) { + setFactToDelete(null); + } + }} + > + + + {factDeleteConfirmTitle} + + {factDeleteConfirmDescription} + + + {factToDelete ? ( +
+
+ {factPreviewLabel} +
+

+ {truncateFactPreview(factToDelete.content)} +

+
+ ) : null} + + + + +
+
+ + ); +} diff --git a/frontend/src/core/config/index.ts b/frontend/src/core/config/index.ts index 88c2e5dd6..5b5a263ef 100644 --- a/frontend/src/core/config/index.ts +++ b/frontend/src/core/config/index.ts @@ -4,7 +4,6 @@ function getBaseOrigin() { if (typeof window !== "undefined") { return window.location.origin; } - return undefined; } @@ -13,7 +12,9 @@ export function getBackendBaseURL() { return new URL( env.NEXT_PUBLIC_BACKEND_BASE_URL, getBaseOrigin(), - ).toString(); + ) + .toString() + .replace(/\/+$/, ""); } else { return ""; } diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 83e88c344..4c0299c68 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -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.", empty: "No memory data to display.", 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: { overview: "Overview", userContext: "User context", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 0d220edf5..98e9768f7 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -247,7 +247,23 @@ export interface Translations { description: string; empty: 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; userContext: string; work: string; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index d77a5468a..9eb3a43bc 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -298,6 +298,24 @@ export const zhCN: Translations = { "DeerFlow 会在后台不断从你的对话中自动学习。这些记忆能帮助 DeerFlow 更好地理解你,并提供更个性化的体验。", empty: "暂无可展示的记忆数据。", rawJson: "原始 JSON", + clearAll: "清空全部记忆", + clearAllConfirmTitle: "要清空全部记忆吗?", + clearAllConfirmDescription: + "这会删除所有已保存的摘要和事实。此操作无法撤销。", + clearAllSuccess: "已清空全部记忆", + factDeleteConfirmTitle: "要删除这条事实吗?", + factDeleteConfirmDescription: + "这条事实会立即从记忆中删除。此操作无法撤销。", + factDeleteSuccess: "事实已删除", + noFacts: "还没有保存的事实。", + summaryReadOnly: "摘要分区当前仍为只读。现在你可以清空全部记忆或删除单条事实。", + memoryFullyEmpty: "还没有保存任何记忆。", + factPreviewLabel: "即将删除的事实", + searchPlaceholder: "搜索记忆", + filterAll: "全部", + filterFacts: "事实", + filterSummaries: "摘要", + noMatches: "没有找到匹配的记忆。", markdown: { overview: "概览", userContext: "用户上下文", diff --git a/frontend/src/core/memory/api.ts b/frontend/src/core/memory/api.ts index b5d6f1130..27e8438a4 100644 --- a/frontend/src/core/memory/api.ts +++ b/frontend/src/core/memory/api.ts @@ -2,8 +2,38 @@ import { getBackendBaseURL } from "../config"; import type { UserMemory } from "./types"; -export async function loadMemory() { - const memory = await fetch(`${getBackendBaseURL()}/api/memory`); - const json = await memory.json(); - return json as UserMemory; +async function readMemoryResponse( + response: Response, + fallbackMessage: string, +): Promise { + 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; +} + +export async function loadMemory(): Promise { + const response = await fetch(`${getBackendBaseURL()}/api/memory`); + return readMemoryResponse(response, "Failed to fetch memory"); +} + +export async function clearMemory(): Promise { + const response = await fetch(`${getBackendBaseURL()}/api/memory`, { + method: "DELETE", + }); + return readMemoryResponse(response, "Failed to clear memory"); +} + +export async function deleteMemoryFact(factId: string): Promise { + const response = await fetch( + `${getBackendBaseURL()}/api/memory/facts/${encodeURIComponent(factId)}`, + { + method: "DELETE", + }, + ); + return readMemoryResponse(response, "Failed to delete memory fact"); } diff --git a/frontend/src/core/memory/hooks.ts b/frontend/src/core/memory/hooks.ts index 6e8454bf7..cca98f529 100644 --- a/frontend/src/core/memory/hooks.ts +++ b/frontend/src/core/memory/hooks.ts @@ -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() { const { data, isLoading, error } = useQuery({ @@ -9,3 +10,25 @@ export function useMemory() { }); return { memory: data ?? null, isLoading, error }; } + +export function useClearMemory() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => clearMemory(), + onSuccess: (memory) => { + queryClient.setQueryData(["memory"], memory); + }, + }); +} + +export function useDeleteMemoryFact() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (factId: string) => deleteMemoryFact(factId), + onSuccess: (memory) => { + queryClient.setQueryData(["memory"], memory); + }, + }); +} diff --git a/scripts/load_memory_sample.py b/scripts/load_memory_sample.py new file mode 100644 index 000000000..568951e8f --- /dev/null +++ b/scripts/load_memory_sample.py @@ -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())