mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-04-25 11:18:22 +00:00
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:
parent
481494b9c0
commit
7eb3a150b5
11
README.md
11
README.md
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
39
backend/docs/MEMORY_SETTINGS_REVIEW.md
Normal file
39
backend/docs/MEMORY_SETTINGS_REVIEW.md
Normal 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.
|
||||||
106
backend/docs/memory-settings-sample.json
Normal file
106
backend/docs/memory-settings-sample.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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).
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
72
backend/tests/test_memory_router.py
Normal file
72
backend/tests/test_memory_router.py
Normal 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."
|
||||||
@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -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 "";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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: "用户上下文",
|
||||||
|
|||||||
@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
81
scripts/load_memory_sample.py
Normal file
81
scripts/load_memory_sample.py
Normal 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())
|
||||||
Loading…
x
Reference in New Issue
Block a user