feat: support manual add and edit for memory facts (#1538)
* feat: support manual add and edit for memory facts * fix: restore memory updater save helper * fix: address memory fact review feedback * fix: remove duplicate memory fact edit action * docs: simplify memory fact review setup * docs: relax memory review startup instructions * fix: clear rebase marker in memory settings page * fix: address memory fact review and format issues * fix: address memory fact review feedback * refactor: make memory fact updates explicit patch semantics --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
cdb2a3a017
commit
fc7de7fffe
|
|
@ -5,9 +5,11 @@ from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from deerflow.agents.memory.updater import (
|
from deerflow.agents.memory.updater import (
|
||||||
clear_memory_data,
|
clear_memory_data,
|
||||||
|
create_memory_fact,
|
||||||
delete_memory_fact,
|
delete_memory_fact,
|
||||||
get_memory_data,
|
get_memory_data,
|
||||||
reload_memory_data,
|
reload_memory_data,
|
||||||
|
update_memory_fact,
|
||||||
)
|
)
|
||||||
from deerflow.config.memory_config import get_memory_config
|
from deerflow.config.memory_config import get_memory_config
|
||||||
|
|
||||||
|
|
@ -58,6 +60,31 @@ class MemoryResponse(BaseModel):
|
||||||
facts: list[Fact] = Field(default_factory=list)
|
facts: list[Fact] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _map_memory_fact_value_error(exc: ValueError) -> HTTPException:
|
||||||
|
"""Convert updater validation errors into stable API responses."""
|
||||||
|
if exc.args and exc.args[0] == "confidence":
|
||||||
|
detail = "Invalid confidence value; must be between 0 and 1."
|
||||||
|
else:
|
||||||
|
detail = "Memory fact content cannot be empty."
|
||||||
|
return HTTPException(status_code=400, detail=detail)
|
||||||
|
|
||||||
|
|
||||||
|
class FactCreateRequest(BaseModel):
|
||||||
|
"""Request model for creating a memory fact."""
|
||||||
|
|
||||||
|
content: str = Field(..., min_length=1, description="Fact content")
|
||||||
|
category: str = Field(default="context", description="Fact category")
|
||||||
|
confidence: float = Field(default=0.5, ge=0.0, le=1.0, description="Confidence score (0-1)")
|
||||||
|
|
||||||
|
|
||||||
|
class FactPatchRequest(BaseModel):
|
||||||
|
"""PATCH request model that preserves existing values for omitted fields."""
|
||||||
|
|
||||||
|
content: str | None = Field(default=None, min_length=1, description="Fact content")
|
||||||
|
category: str | None = Field(default=None, description="Fact category")
|
||||||
|
confidence: float | None = Field(default=None, ge=0.0, le=1.0, description="Confidence score (0-1)")
|
||||||
|
|
||||||
|
|
||||||
class MemoryConfigResponse(BaseModel):
|
class MemoryConfigResponse(BaseModel):
|
||||||
"""Response model for memory configuration."""
|
"""Response model for memory configuration."""
|
||||||
|
|
||||||
|
|
@ -156,6 +183,28 @@ async def clear_memory() -> MemoryResponse:
|
||||||
return MemoryResponse(**memory_data)
|
return MemoryResponse(**memory_data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/memory/facts",
|
||||||
|
response_model=MemoryResponse,
|
||||||
|
summary="Create Memory Fact",
|
||||||
|
description="Create a single saved memory fact manually.",
|
||||||
|
)
|
||||||
|
async def create_memory_fact_endpoint(request: FactCreateRequest) -> MemoryResponse:
|
||||||
|
"""Create a single fact manually."""
|
||||||
|
try:
|
||||||
|
memory_data = create_memory_fact(
|
||||||
|
content=request.content,
|
||||||
|
category=request.category,
|
||||||
|
confidence=request.confidence,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise _map_memory_fact_value_error(exc) from exc
|
||||||
|
except OSError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create memory fact.") from exc
|
||||||
|
|
||||||
|
return MemoryResponse(**memory_data)
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/memory/facts/{fact_id}",
|
"/memory/facts/{fact_id}",
|
||||||
response_model=MemoryResponse,
|
response_model=MemoryResponse,
|
||||||
|
|
@ -174,6 +223,31 @@ async def delete_memory_fact_endpoint(fact_id: str) -> MemoryResponse:
|
||||||
return MemoryResponse(**memory_data)
|
return MemoryResponse(**memory_data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/memory/facts/{fact_id}",
|
||||||
|
response_model=MemoryResponse,
|
||||||
|
summary="Patch Memory Fact",
|
||||||
|
description="Partially update a single saved memory fact by its fact id while preserving omitted fields.",
|
||||||
|
)
|
||||||
|
async def update_memory_fact_endpoint(fact_id: str, request: FactPatchRequest) -> MemoryResponse:
|
||||||
|
"""Partially update a single fact manually."""
|
||||||
|
try:
|
||||||
|
memory_data = update_memory_fact(
|
||||||
|
fact_id=fact_id,
|
||||||
|
content=request.content,
|
||||||
|
category=request.category,
|
||||||
|
confidence=request.confidence,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise _map_memory_fact_value_error(exc) from exc
|
||||||
|
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 update memory fact.") from exc
|
||||||
|
|
||||||
|
return MemoryResponse(**memory_data)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/memory/config",
|
"/memory/config",
|
||||||
response_model=MemoryConfigResponse,
|
response_model=MemoryConfigResponse,
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,58 @@
|
||||||
# Memory Settings Review
|
# Memory Settings Review
|
||||||
|
|
||||||
Use this when reviewing the Memory Settings search, filter, delete, and clear-all flow locally.
|
Use this when reviewing the Memory Settings add/edit flow locally with the fewest possible manual steps.
|
||||||
|
|
||||||
## Quick Review
|
## Quick Review
|
||||||
|
|
||||||
1. Start DeerFlow locally.
|
1. Start DeerFlow locally using any working development setup you already use.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make dev
|
make dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make docker-start
|
||||||
|
```
|
||||||
|
|
||||||
|
If you already have DeerFlow running locally, you can reuse that existing setup.
|
||||||
|
|
||||||
2. Load the sample memory fixture.
|
2. Load the sample memory fixture.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python scripts/load_memory_sample.py
|
python scripts/load_memory_sample.py
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Open the app and review `Settings > Memory`.
|
3. Open `Settings > Memory`.
|
||||||
|
|
||||||
Default local URLs:
|
Default local URLs:
|
||||||
- App: `http://localhost:2026`
|
- App: `http://localhost:2026`
|
||||||
- Local frontend-only fallback: `http://localhost:3000`
|
- Local frontend-only fallback: `http://localhost:3000`
|
||||||
|
|
||||||
## What To Check
|
## Minimal Manual Test
|
||||||
|
|
||||||
- Search `memory` and confirm multiple facts are matched.
|
1. Click `Add fact`.
|
||||||
- Search `Chinese` and confirm text filtering works.
|
2. Create a new fact with:
|
||||||
- Search `workflow` and confirm category text is also searchable.
|
- Content: `Reviewer-added memory fact`
|
||||||
|
- Category: `testing`
|
||||||
|
- Confidence: `0.88`
|
||||||
|
3. Confirm the new fact appears immediately and shows `Manual` as the source.
|
||||||
|
4. Edit the sample fact `This sample fact is intended for edit testing.` and change it to:
|
||||||
|
- Content: `This sample fact was edited during manual review.`
|
||||||
|
- Category: `testing`
|
||||||
|
- Confidence: `0.91`
|
||||||
|
5. Confirm the edited fact updates immediately.
|
||||||
|
6. Refresh the page and confirm both the newly added fact and the edited fact still persist.
|
||||||
|
|
||||||
|
## Optional Sanity Checks
|
||||||
|
|
||||||
|
- Search `Reviewer-added` and confirm the new fact is matched.
|
||||||
|
- Search `workflow` and confirm category text is searchable.
|
||||||
- Switch between `All`, `Facts`, and `Summaries`.
|
- Switch between `All`, `Facts`, and `Summaries`.
|
||||||
- Delete the disposable sample fact and confirm the list updates immediately.
|
- Delete the disposable sample fact `Delete fact testing can target this disposable sample entry.` and confirm the list updates immediately.
|
||||||
- Clear all memory and confirm the page enters the empty state.
|
- Clear all memory and confirm the page enters the empty state.
|
||||||
|
|
||||||
## Fixture Files
|
## Fixture Files
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,14 @@
|
||||||
"confidence": 0.78,
|
"confidence": 0.78,
|
||||||
"createdAt": "2026-03-28T10:06:00Z",
|
"createdAt": "2026-03-28T10:06:00Z",
|
||||||
"source": "thread_delete_demo"
|
"source": "thread_delete_demo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fact_review_010",
|
||||||
|
"content": "This sample fact is intended for edit testing.",
|
||||||
|
"category": "testing",
|
||||||
|
"confidence": 0.8,
|
||||||
|
"createdAt": "2026-03-28T10:08:00Z",
|
||||||
|
"source": "manual"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
@ -40,12 +41,54 @@ def reload_memory_data(agent_name: str | None = None) -> dict[str, Any]:
|
||||||
|
|
||||||
def clear_memory_data(agent_name: str | None = None) -> dict[str, Any]:
|
def clear_memory_data(agent_name: str | None = None) -> dict[str, Any]:
|
||||||
"""Clear all stored memory data and persist an empty structure."""
|
"""Clear all stored memory data and persist an empty structure."""
|
||||||
cleared_memory = _create_empty_memory()
|
cleared_memory = create_empty_memory()
|
||||||
if not _save_memory_to_file(cleared_memory, agent_name):
|
if not _save_memory_to_file(cleared_memory, agent_name):
|
||||||
raise OSError("Failed to save cleared memory data")
|
raise OSError("Failed to save cleared memory data")
|
||||||
return cleared_memory
|
return cleared_memory
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_confidence(confidence: float) -> float:
|
||||||
|
"""Validate persisted fact confidence so stored JSON stays standards-compliant."""
|
||||||
|
if not math.isfinite(confidence) or confidence < 0 or confidence > 1:
|
||||||
|
raise ValueError("confidence")
|
||||||
|
return confidence
|
||||||
|
|
||||||
|
|
||||||
|
def create_memory_fact(
|
||||||
|
content: str,
|
||||||
|
category: str = "context",
|
||||||
|
confidence: float = 0.5,
|
||||||
|
agent_name: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new fact and persist the updated memory data."""
|
||||||
|
normalized_content = content.strip()
|
||||||
|
if not normalized_content:
|
||||||
|
raise ValueError("content")
|
||||||
|
|
||||||
|
normalized_category = category.strip() or "context"
|
||||||
|
validated_confidence = _validate_confidence(confidence)
|
||||||
|
now = datetime.utcnow().isoformat() + "Z"
|
||||||
|
memory_data = get_memory_data(agent_name)
|
||||||
|
updated_memory = dict(memory_data)
|
||||||
|
facts = list(memory_data.get("facts", []))
|
||||||
|
facts.append(
|
||||||
|
{
|
||||||
|
"id": f"fact_{uuid.uuid4().hex[:8]}",
|
||||||
|
"content": normalized_content,
|
||||||
|
"category": normalized_category,
|
||||||
|
"confidence": validated_confidence,
|
||||||
|
"createdAt": now,
|
||||||
|
"source": "manual",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
updated_memory["facts"] = facts
|
||||||
|
|
||||||
|
if not _save_memory_to_file(updated_memory, agent_name):
|
||||||
|
raise OSError("Failed to save memory data after creating fact")
|
||||||
|
|
||||||
|
return updated_memory
|
||||||
|
|
||||||
|
|
||||||
def delete_memory_fact(fact_id: str, agent_name: str | None = None) -> dict[str, Any]:
|
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."""
|
"""Delete a fact by its id and persist the updated memory data."""
|
||||||
memory_data = get_memory_data(agent_name)
|
memory_data = get_memory_data(agent_name)
|
||||||
|
|
@ -63,6 +106,47 @@ def delete_memory_fact(fact_id: str, agent_name: str | None = None) -> dict[str,
|
||||||
return updated_memory
|
return updated_memory
|
||||||
|
|
||||||
|
|
||||||
|
def update_memory_fact(
|
||||||
|
fact_id: str,
|
||||||
|
content: str | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
confidence: float | None = None,
|
||||||
|
agent_name: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Update an existing fact and persist the updated memory data."""
|
||||||
|
memory_data = get_memory_data(agent_name)
|
||||||
|
updated_memory = dict(memory_data)
|
||||||
|
updated_facts: list[dict[str, Any]] = []
|
||||||
|
found = False
|
||||||
|
|
||||||
|
for fact in memory_data.get("facts", []):
|
||||||
|
if fact.get("id") == fact_id:
|
||||||
|
found = True
|
||||||
|
updated_fact = dict(fact)
|
||||||
|
if content is not None:
|
||||||
|
normalized_content = content.strip()
|
||||||
|
if not normalized_content:
|
||||||
|
raise ValueError("content")
|
||||||
|
updated_fact["content"] = normalized_content
|
||||||
|
if category is not None:
|
||||||
|
updated_fact["category"] = category.strip() or "context"
|
||||||
|
if confidence is not None:
|
||||||
|
updated_fact["confidence"] = _validate_confidence(confidence)
|
||||||
|
updated_facts.append(updated_fact)
|
||||||
|
else:
|
||||||
|
updated_facts.append(fact)
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
raise KeyError(fact_id)
|
||||||
|
|
||||||
|
updated_memory["facts"] = updated_facts
|
||||||
|
|
||||||
|
if not _save_memory_to_file(updated_memory, agent_name):
|
||||||
|
raise OSError(f"Failed to save memory data after updating 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).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -688,12 +688,35 @@ class DeerFlowClient:
|
||||||
|
|
||||||
return clear_memory_data()
|
return clear_memory_data()
|
||||||
|
|
||||||
|
def create_memory_fact(self, content: str, category: str = "context", confidence: float = 0.5) -> dict:
|
||||||
|
"""Create a single fact manually."""
|
||||||
|
from deerflow.agents.memory.updater import create_memory_fact
|
||||||
|
|
||||||
|
return create_memory_fact(content=content, category=category, confidence=confidence)
|
||||||
|
|
||||||
def delete_memory_fact(self, fact_id: str) -> dict:
|
def delete_memory_fact(self, fact_id: str) -> dict:
|
||||||
"""Delete a single fact from memory by fact id."""
|
"""Delete a single fact from memory by fact id."""
|
||||||
from deerflow.agents.memory.updater import delete_memory_fact
|
from deerflow.agents.memory.updater import delete_memory_fact
|
||||||
|
|
||||||
return delete_memory_fact(fact_id)
|
return delete_memory_fact(fact_id)
|
||||||
|
|
||||||
|
def update_memory_fact(
|
||||||
|
self,
|
||||||
|
fact_id: str,
|
||||||
|
content: str | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
confidence: float | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Update a single fact manually, preserving omitted fields."""
|
||||||
|
from deerflow.agents.memory.updater import update_memory_fact
|
||||||
|
|
||||||
|
return update_memory_fact(
|
||||||
|
fact_id=fact_id,
|
||||||
|
content=content,
|
||||||
|
category=category,
|
||||||
|
confidence=confidence,
|
||||||
|
)
|
||||||
|
|
||||||
def get_memory_config(self) -> dict:
|
def get_memory_config(self) -> dict:
|
||||||
"""Get memory system configuration.
|
"""Get memory system configuration.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -673,6 +673,21 @@ class TestMemoryManagement:
|
||||||
result = client.clear_memory()
|
result = client.clear_memory()
|
||||||
assert result == data
|
assert result == data
|
||||||
|
|
||||||
|
def test_create_memory_fact(self, client):
|
||||||
|
data = {"version": "1.0", "facts": []}
|
||||||
|
with patch("deerflow.agents.memory.updater.create_memory_fact", return_value=data) as create_fact:
|
||||||
|
result = client.create_memory_fact(
|
||||||
|
"User prefers concise code reviews.",
|
||||||
|
category="preference",
|
||||||
|
confidence=0.88,
|
||||||
|
)
|
||||||
|
create_fact.assert_called_once_with(
|
||||||
|
content="User prefers concise code reviews.",
|
||||||
|
category="preference",
|
||||||
|
confidence=0.88,
|
||||||
|
)
|
||||||
|
assert result == data
|
||||||
|
|
||||||
def test_delete_memory_fact(self, client):
|
def test_delete_memory_fact(self, client):
|
||||||
data = {"version": "1.0", "facts": []}
|
data = {"version": "1.0", "facts": []}
|
||||||
with patch("deerflow.agents.memory.updater.delete_memory_fact", return_value=data) as delete_fact:
|
with patch("deerflow.agents.memory.updater.delete_memory_fact", return_value=data) as delete_fact:
|
||||||
|
|
@ -680,6 +695,38 @@ class TestMemoryManagement:
|
||||||
delete_fact.assert_called_once_with("fact_123")
|
delete_fact.assert_called_once_with("fact_123")
|
||||||
assert result == data
|
assert result == data
|
||||||
|
|
||||||
|
def test_update_memory_fact(self, client):
|
||||||
|
data = {"version": "1.0", "facts": []}
|
||||||
|
with patch("deerflow.agents.memory.updater.update_memory_fact", return_value=data) as update_fact:
|
||||||
|
result = client.update_memory_fact(
|
||||||
|
"fact_123",
|
||||||
|
"User prefers spaces",
|
||||||
|
category="workflow",
|
||||||
|
confidence=0.91,
|
||||||
|
)
|
||||||
|
update_fact.assert_called_once_with(
|
||||||
|
fact_id="fact_123",
|
||||||
|
content="User prefers spaces",
|
||||||
|
category="workflow",
|
||||||
|
confidence=0.91,
|
||||||
|
)
|
||||||
|
assert result == data
|
||||||
|
|
||||||
|
def test_update_memory_fact_preserves_omitted_fields(self, client):
|
||||||
|
data = {"version": "1.0", "facts": []}
|
||||||
|
with patch("deerflow.agents.memory.updater.update_memory_fact", return_value=data) as update_fact:
|
||||||
|
result = client.update_memory_fact(
|
||||||
|
"fact_123",
|
||||||
|
"User prefers spaces",
|
||||||
|
)
|
||||||
|
update_fact.assert_called_once_with(
|
||||||
|
fact_id="fact_123",
|
||||||
|
content="User prefers spaces",
|
||||||
|
category=None,
|
||||||
|
confidence=None,
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,37 @@ def test_clear_memory_route_returns_cleared_memory() -> None:
|
||||||
assert response.json()["facts"] == []
|
assert response.json()["facts"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_memory_fact_route_returns_updated_memory() -> None:
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(memory.router)
|
||||||
|
updated_memory = _sample_memory(
|
||||||
|
facts=[
|
||||||
|
{
|
||||||
|
"id": "fact_new",
|
||||||
|
"content": "User prefers concise code reviews.",
|
||||||
|
"category": "preference",
|
||||||
|
"confidence": 0.88,
|
||||||
|
"createdAt": "2026-03-20T00:00:00Z",
|
||||||
|
"source": "manual",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("app.gateway.routers.memory.create_memory_fact", return_value=updated_memory):
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.post(
|
||||||
|
"/api/memory/facts",
|
||||||
|
json={
|
||||||
|
"content": "User prefers concise code reviews.",
|
||||||
|
"category": "preference",
|
||||||
|
"confidence": 0.88,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["facts"] == updated_memory["facts"]
|
||||||
|
|
||||||
|
|
||||||
def test_delete_memory_fact_route_returns_updated_memory() -> None:
|
def test_delete_memory_fact_route_returns_updated_memory() -> None:
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.include_router(memory.router)
|
app.include_router(memory.router)
|
||||||
|
|
@ -70,3 +101,106 @@ def test_delete_memory_fact_route_returns_404_for_missing_fact() -> None:
|
||||||
|
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
assert response.json()["detail"] == "Memory fact 'fact_missing' not found."
|
assert response.json()["detail"] == "Memory fact 'fact_missing' not found."
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_memory_fact_route_returns_updated_memory() -> None:
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(memory.router)
|
||||||
|
updated_memory = _sample_memory(
|
||||||
|
facts=[
|
||||||
|
{
|
||||||
|
"id": "fact_edit",
|
||||||
|
"content": "User prefers spaces",
|
||||||
|
"category": "workflow",
|
||||||
|
"confidence": 0.91,
|
||||||
|
"createdAt": "2026-03-20T00:00:00Z",
|
||||||
|
"source": "manual",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("app.gateway.routers.memory.update_memory_fact", return_value=updated_memory):
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.patch(
|
||||||
|
"/api/memory/facts/fact_edit",
|
||||||
|
json={
|
||||||
|
"content": "User prefers spaces",
|
||||||
|
"category": "workflow",
|
||||||
|
"confidence": 0.91,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["facts"] == updated_memory["facts"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_memory_fact_route_preserves_omitted_fields() -> None:
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(memory.router)
|
||||||
|
updated_memory = _sample_memory(
|
||||||
|
facts=[
|
||||||
|
{
|
||||||
|
"id": "fact_edit",
|
||||||
|
"content": "User prefers spaces",
|
||||||
|
"category": "preference",
|
||||||
|
"confidence": 0.8,
|
||||||
|
"createdAt": "2026-03-20T00:00:00Z",
|
||||||
|
"source": "manual",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("app.gateway.routers.memory.update_memory_fact", return_value=updated_memory) as update_fact:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.patch(
|
||||||
|
"/api/memory/facts/fact_edit",
|
||||||
|
json={
|
||||||
|
"content": "User prefers spaces",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
update_fact.assert_called_once_with(
|
||||||
|
fact_id="fact_edit",
|
||||||
|
content="User prefers spaces",
|
||||||
|
category=None,
|
||||||
|
confidence=None,
|
||||||
|
)
|
||||||
|
assert response.json()["facts"] == updated_memory["facts"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_memory_fact_route_returns_404_for_missing_fact() -> None:
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(memory.router)
|
||||||
|
|
||||||
|
with patch("app.gateway.routers.memory.update_memory_fact", side_effect=KeyError("fact_missing")):
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.patch(
|
||||||
|
"/api/memory/facts/fact_missing",
|
||||||
|
json={
|
||||||
|
"content": "User prefers spaces",
|
||||||
|
"category": "workflow",
|
||||||
|
"confidence": 0.91,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert response.json()["detail"] == "Memory fact 'fact_missing' not found."
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_memory_fact_route_returns_specific_error_for_invalid_confidence() -> None:
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(memory.router)
|
||||||
|
|
||||||
|
with patch("app.gateway.routers.memory.update_memory_fact", side_effect=ValueError("confidence")):
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.patch(
|
||||||
|
"/api/memory/facts/fact_edit",
|
||||||
|
json={
|
||||||
|
"content": "User prefers spaces",
|
||||||
|
"confidence": 0.91,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json()["detail"] == "Invalid confidence value; must be between 0 and 1."
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ from deerflow.agents.memory.updater import (
|
||||||
MemoryUpdater,
|
MemoryUpdater,
|
||||||
_extract_text,
|
_extract_text,
|
||||||
clear_memory_data,
|
clear_memory_data,
|
||||||
|
create_memory_fact,
|
||||||
delete_memory_fact,
|
delete_memory_fact,
|
||||||
|
update_memory_fact,
|
||||||
)
|
)
|
||||||
from deerflow.config.memory_config import MemoryConfig
|
from deerflow.config.memory_config import MemoryConfig
|
||||||
|
|
||||||
|
|
@ -184,6 +186,43 @@ def test_delete_memory_fact_removes_only_matching_fact() -> None:
|
||||||
assert [fact["id"] for fact in result["facts"]] == ["fact_keep"]
|
assert [fact["id"] for fact in result["facts"]] == ["fact_keep"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_memory_fact_appends_manual_fact() -> None:
|
||||||
|
with (
|
||||||
|
patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()),
|
||||||
|
patch("deerflow.agents.memory.updater._save_memory_to_file", return_value=True),
|
||||||
|
):
|
||||||
|
result = create_memory_fact(
|
||||||
|
content=" User prefers concise code reviews. ",
|
||||||
|
category="preference",
|
||||||
|
confidence=0.88,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(result["facts"]) == 1
|
||||||
|
assert result["facts"][0]["content"] == "User prefers concise code reviews."
|
||||||
|
assert result["facts"][0]["category"] == "preference"
|
||||||
|
assert result["facts"][0]["confidence"] == 0.88
|
||||||
|
assert result["facts"][0]["source"] == "manual"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_memory_fact_rejects_empty_content() -> None:
|
||||||
|
try:
|
||||||
|
create_memory_fact(content=" ")
|
||||||
|
except ValueError as exc:
|
||||||
|
assert exc.args == ("content",)
|
||||||
|
else:
|
||||||
|
raise AssertionError("Expected ValueError for empty fact content")
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_memory_fact_rejects_invalid_confidence() -> None:
|
||||||
|
for confidence in (-0.1, 1.1, float("nan"), float("inf"), float("-inf")):
|
||||||
|
try:
|
||||||
|
create_memory_fact(content="User likes tests", confidence=confidence)
|
||||||
|
except ValueError as exc:
|
||||||
|
assert exc.args == ("confidence",)
|
||||||
|
else:
|
||||||
|
raise AssertionError("Expected ValueError for invalid fact confidence")
|
||||||
|
|
||||||
|
|
||||||
def test_delete_memory_fact_raises_for_unknown_id() -> None:
|
def test_delete_memory_fact_raises_for_unknown_id() -> None:
|
||||||
with patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()):
|
with patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()):
|
||||||
try:
|
try:
|
||||||
|
|
@ -194,6 +233,121 @@ def test_delete_memory_fact_raises_for_unknown_id() -> None:
|
||||||
raise AssertionError("Expected KeyError for missing fact id")
|
raise AssertionError("Expected KeyError for missing fact id")
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_memory_fact_updates_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_edit",
|
||||||
|
"content": "User prefers tabs",
|
||||||
|
"category": "preference",
|
||||||
|
"confidence": 0.8,
|
||||||
|
"createdAt": "2026-03-18T00:00:00Z",
|
||||||
|
"source": "manual",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
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 = update_memory_fact(
|
||||||
|
fact_id="fact_edit",
|
||||||
|
content="User prefers spaces",
|
||||||
|
category="workflow",
|
||||||
|
confidence=0.91,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["facts"][0]["content"] == "User likes Python"
|
||||||
|
assert result["facts"][1]["content"] == "User prefers spaces"
|
||||||
|
assert result["facts"][1]["category"] == "workflow"
|
||||||
|
assert result["facts"][1]["confidence"] == 0.91
|
||||||
|
assert result["facts"][1]["createdAt"] == "2026-03-18T00:00:00Z"
|
||||||
|
assert result["facts"][1]["source"] == "manual"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_memory_fact_preserves_omitted_fields() -> None:
|
||||||
|
current_memory = _make_memory(
|
||||||
|
facts=[
|
||||||
|
{
|
||||||
|
"id": "fact_edit",
|
||||||
|
"content": "User prefers tabs",
|
||||||
|
"category": "preference",
|
||||||
|
"confidence": 0.8,
|
||||||
|
"createdAt": "2026-03-18T00:00:00Z",
|
||||||
|
"source": "manual",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
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 = update_memory_fact(
|
||||||
|
fact_id="fact_edit",
|
||||||
|
content="User prefers spaces",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["facts"][0]["content"] == "User prefers spaces"
|
||||||
|
assert result["facts"][0]["category"] == "preference"
|
||||||
|
assert result["facts"][0]["confidence"] == 0.8
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_memory_fact_raises_for_unknown_id() -> None:
|
||||||
|
with patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()):
|
||||||
|
try:
|
||||||
|
update_memory_fact(
|
||||||
|
fact_id="fact_missing",
|
||||||
|
content="User prefers concise code reviews.",
|
||||||
|
category="preference",
|
||||||
|
confidence=0.88,
|
||||||
|
)
|
||||||
|
except KeyError as exc:
|
||||||
|
assert exc.args == ("fact_missing",)
|
||||||
|
else:
|
||||||
|
raise AssertionError("Expected KeyError for missing fact id")
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_memory_fact_rejects_invalid_confidence() -> None:
|
||||||
|
current_memory = _make_memory(
|
||||||
|
facts=[
|
||||||
|
{
|
||||||
|
"id": "fact_edit",
|
||||||
|
"content": "User prefers tabs",
|
||||||
|
"category": "preference",
|
||||||
|
"confidence": 0.8,
|
||||||
|
"createdAt": "2026-03-18T00:00:00Z",
|
||||||
|
"source": "manual",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
for confidence in (-0.1, 1.1, float("nan"), float("inf"), float("-inf")):
|
||||||
|
with patch(
|
||||||
|
"deerflow.agents.memory.updater.get_memory_data",
|
||||||
|
return_value=current_memory,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
update_memory_fact(
|
||||||
|
fact_id="fact_edit",
|
||||||
|
content="User prefers spaces",
|
||||||
|
confidence=confidence,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
assert exc.args == ("confidence",)
|
||||||
|
else:
|
||||||
|
raise AssertionError("Expected ValueError for invalid fact confidence")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _extract_text — LLM response content normalization
|
# _extract_text — LLM response content normalization
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Trash2Icon } from "lucide-react";
|
import { PenLineIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useDeferredValue, useState } from "react";
|
import { useDeferredValue, useId, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Streamdown } from "streamdown";
|
import { Streamdown } from "streamdown";
|
||||||
|
|
||||||
|
|
@ -16,14 +16,21 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
useClearMemory,
|
useClearMemory,
|
||||||
|
useCreateMemoryFact,
|
||||||
useDeleteMemoryFact,
|
useDeleteMemoryFact,
|
||||||
useMemory,
|
useMemory,
|
||||||
|
useUpdateMemoryFact,
|
||||||
} from "@/core/memory/hooks";
|
} from "@/core/memory/hooks";
|
||||||
import type { UserMemory } from "@/core/memory/types";
|
import type {
|
||||||
|
MemoryFactInput,
|
||||||
|
MemoryFactPatchInput,
|
||||||
|
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";
|
||||||
import { formatTimeAgo } from "@/core/utils/datetime";
|
import { formatTimeAgo } from "@/core/utils/datetime";
|
||||||
|
|
@ -44,6 +51,18 @@ type MemorySectionGroup = {
|
||||||
sections: MemorySection[];
|
sections: MemorySection[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type FactFormState = {
|
||||||
|
content: string;
|
||||||
|
category: string;
|
||||||
|
confidence: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_FACT_FORM_STATE: FactFormState = {
|
||||||
|
content: "",
|
||||||
|
category: "context",
|
||||||
|
confidence: "0.8",
|
||||||
|
};
|
||||||
|
|
||||||
function confidenceToLevelKey(confidence: unknown): {
|
function confidenceToLevelKey(confidence: unknown): {
|
||||||
key: "veryHigh" | "high" | "normal" | "unknown";
|
key: "veryHigh" | "high" | "normal" | "unknown";
|
||||||
value?: number;
|
value?: number;
|
||||||
|
|
@ -191,13 +210,24 @@ export function MemorySettingsPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { memory, isLoading, error } = useMemory();
|
const { memory, isLoading, error } = useMemory();
|
||||||
const clearMemory = useClearMemory();
|
const clearMemory = useClearMemory();
|
||||||
|
const createMemoryFact = useCreateMemoryFact();
|
||||||
const deleteMemoryFact = useDeleteMemoryFact();
|
const deleteMemoryFact = useDeleteMemoryFact();
|
||||||
|
const updateMemoryFact = useUpdateMemoryFact();
|
||||||
const [clearDialogOpen, setClearDialogOpen] = useState(false);
|
const [clearDialogOpen, setClearDialogOpen] = useState(false);
|
||||||
const [factToDelete, setFactToDelete] = useState<MemoryFact | null>(null);
|
const [factToDelete, setFactToDelete] = useState<MemoryFact | null>(null);
|
||||||
|
const [factToEdit, setFactToEdit] = useState<MemoryFact | null>(null);
|
||||||
|
const [factEditorOpen, setFactEditorOpen] = useState(false);
|
||||||
|
const [factForm, setFactForm] = useState<FactFormState>(
|
||||||
|
DEFAULT_FACT_FORM_STATE,
|
||||||
|
);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [filter, setFilter] = useState<MemoryViewFilter>("all");
|
const [filter, setFilter] = useState<MemoryViewFilter>("all");
|
||||||
const deferredQuery = useDeferredValue(query);
|
const deferredQuery = useDeferredValue(query);
|
||||||
const normalizedQuery = deferredQuery.trim().toLowerCase();
|
const normalizedQuery = deferredQuery.trim().toLowerCase();
|
||||||
|
const factContentInputId = useId();
|
||||||
|
const factCategoryInputId = useId();
|
||||||
|
const factConfidenceInputId = useId();
|
||||||
|
const factConfidenceHintId = useId();
|
||||||
|
|
||||||
const clearAllLabel = t.settings.memory.clearAll ?? "Clear all memory";
|
const clearAllLabel = t.settings.memory.clearAll ?? "Clear all memory";
|
||||||
const clearAllConfirmTitle =
|
const clearAllConfirmTitle =
|
||||||
|
|
@ -214,10 +244,23 @@ export function MemorySettingsPage() {
|
||||||
"This fact will be removed from memory immediately. This action cannot be undone.";
|
"This fact will be removed from memory immediately. This action cannot be undone.";
|
||||||
const factDeleteSuccess =
|
const factDeleteSuccess =
|
||||||
t.settings.memory.factDeleteSuccess ?? "Fact deleted";
|
t.settings.memory.factDeleteSuccess ?? "Fact deleted";
|
||||||
|
const addFactLabel = t.settings.memory.addFact;
|
||||||
|
const addFactTitle = t.settings.memory.addFactTitle;
|
||||||
|
const editFactTitle = t.settings.memory.editFactTitle;
|
||||||
|
const addFactSuccess = t.settings.memory.addFactSuccess;
|
||||||
|
const editFactSuccess = t.settings.memory.editFactSuccess;
|
||||||
|
const factContentLabel = t.settings.memory.factContentLabel;
|
||||||
|
const factCategoryLabel = t.settings.memory.factCategoryLabel;
|
||||||
|
const factConfidenceLabel = t.settings.memory.factConfidenceLabel;
|
||||||
|
const factContentPlaceholder = t.settings.memory.factContentPlaceholder;
|
||||||
|
const factCategoryPlaceholder = t.settings.memory.factCategoryPlaceholder;
|
||||||
|
const factConfidenceHint = t.settings.memory.factConfidenceHint;
|
||||||
|
const factSave = t.settings.memory.factSave;
|
||||||
|
const factValidationContent = t.settings.memory.factValidationContent;
|
||||||
|
const factValidationConfidence = t.settings.memory.factValidationConfidence;
|
||||||
|
const manualFactSource = t.settings.memory.manualFactSource;
|
||||||
const noFacts = t.settings.memory.noFacts ?? "No saved facts yet.";
|
const noFacts = t.settings.memory.noFacts ?? "No saved facts yet.";
|
||||||
const summaryReadOnly =
|
const summaryReadOnly = t.settings.memory.summaryReadOnly;
|
||||||
t.settings.memory.summaryReadOnly ??
|
|
||||||
"Summary sections are read-only for now. You can currently clear all memory or delete individual facts.";
|
|
||||||
const memoryFullyEmpty =
|
const memoryFullyEmpty =
|
||||||
t.settings.memory.memoryFullyEmpty ?? "No memory saved yet.";
|
t.settings.memory.memoryFullyEmpty ?? "No memory saved yet.";
|
||||||
const factPreviewLabel =
|
const factPreviewLabel =
|
||||||
|
|
@ -287,6 +330,68 @@ export function MemorySettingsPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCreateFactDialog() {
|
||||||
|
setFactToEdit(null);
|
||||||
|
setFactForm(DEFAULT_FACT_FORM_STATE);
|
||||||
|
setFactEditorOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditFactDialog(fact: MemoryFact) {
|
||||||
|
setFactToEdit(fact);
|
||||||
|
setFactForm({
|
||||||
|
content: fact.content,
|
||||||
|
category: fact.category,
|
||||||
|
confidence: String(fact.confidence),
|
||||||
|
});
|
||||||
|
setFactEditorOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveFact() {
|
||||||
|
const trimmedContent = factForm.content.trim();
|
||||||
|
if (!trimmedContent) {
|
||||||
|
toast.error(factValidationContent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confidence = Number(factForm.confidence);
|
||||||
|
if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) {
|
||||||
|
toast.error(factValidationConfidence);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const input: MemoryFactInput = {
|
||||||
|
content: trimmedContent,
|
||||||
|
category: factForm.category.trim() || "context",
|
||||||
|
confidence,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (factToEdit) {
|
||||||
|
const patchInput: MemoryFactPatchInput = {
|
||||||
|
content: input.content,
|
||||||
|
category: input.category,
|
||||||
|
confidence: input.confidence,
|
||||||
|
};
|
||||||
|
await updateMemoryFact.mutateAsync({
|
||||||
|
factId: factToEdit.id,
|
||||||
|
input: patchInput,
|
||||||
|
});
|
||||||
|
toast.success(editFactSuccess);
|
||||||
|
} else {
|
||||||
|
await createMemoryFact.mutateAsync(input);
|
||||||
|
toast.success(addFactSuccess);
|
||||||
|
}
|
||||||
|
setFactEditorOpen(false);
|
||||||
|
setFactToEdit(null);
|
||||||
|
setFactForm(DEFAULT_FACT_FORM_STATE);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFactFormPending =
|
||||||
|
createMemoryFact.isPending || updateMemoryFact.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
|
|
@ -335,6 +440,11 @@ export function MemorySettingsPage() {
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" onClick={openCreateFactDialog}>
|
||||||
|
<PlusIcon className="mr-2 h-4 w-4" />
|
||||||
|
{addFactLabel}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => setClearDialogOpen(true)}
|
onClick={() => setClearDialogOpen(true)}
|
||||||
|
|
@ -343,6 +453,7 @@ export function MemorySettingsPage() {
|
||||||
{clearMemory.isPending ? t.common.loading : clearAllLabel}
|
{clearMemory.isPending ? t.common.loading : clearAllLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{!hasMatchingVisibleContent && normalizedQuery ? (
|
{!hasMatchingVisibleContent && normalizedQuery ? (
|
||||||
<div className="text-muted-foreground rounded-lg border border-dashed p-4 text-sm">
|
<div className="text-muted-foreground rounded-lg border border-dashed p-4 text-sm">
|
||||||
|
|
@ -412,14 +523,33 @@ export function MemorySettingsPage() {
|
||||||
<p className="text-sm break-words">
|
<p className="text-sm break-words">
|
||||||
{fact.content}
|
{fact.content}
|
||||||
</p>
|
</p>
|
||||||
|
{fact.source === "manual" ? (
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
{manualFactSource}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
<Link
|
<Link
|
||||||
href={pathOfThread(fact.source)}
|
href={pathOfThread(fact.source)}
|
||||||
className="text-primary text-sm underline-offset-4 hover:underline"
|
className="text-primary text-sm underline-offset-4 hover:underline"
|
||||||
>
|
>
|
||||||
{t.settings.memory.markdown.table.view}
|
{t.settings.memory.markdown.table.view}
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-1 self-start sm:ml-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={() => openEditFactDialog(fact)}
|
||||||
|
disabled={deleteMemoryFact.isPending}
|
||||||
|
title={t.common.edit}
|
||||||
|
aria-label={t.common.edit}
|
||||||
|
>
|
||||||
|
<PenLineIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
@ -432,6 +562,7 @@ export function MemorySettingsPage() {
|
||||||
<Trash2Icon className="h-4 w-4" />
|
<Trash2Icon className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -467,6 +598,118 @@ export function MemorySettingsPage() {
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={factEditorOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setFactEditorOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setFactToEdit(null);
|
||||||
|
setFactForm(DEFAULT_FACT_FORM_STATE);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{factToEdit ? editFactTitle : addFactTitle}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
className="text-sm font-medium"
|
||||||
|
htmlFor={factContentInputId}
|
||||||
|
>
|
||||||
|
{factContentLabel}
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
id={factContentInputId}
|
||||||
|
value={factForm.content}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFactForm((current) => ({
|
||||||
|
...current,
|
||||||
|
content: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder={factContentPlaceholder}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
className="text-sm font-medium"
|
||||||
|
htmlFor={factCategoryInputId}
|
||||||
|
>
|
||||||
|
{factCategoryLabel}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id={factCategoryInputId}
|
||||||
|
value={factForm.category}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFactForm((current) => ({
|
||||||
|
...current,
|
||||||
|
category: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder={factCategoryPlaceholder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
className="text-sm font-medium"
|
||||||
|
htmlFor={factConfidenceInputId}
|
||||||
|
>
|
||||||
|
{factConfidenceLabel}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id={factConfidenceInputId}
|
||||||
|
aria-describedby={factConfidenceHintId}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
value={factForm.confidence}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFactForm((current) => ({
|
||||||
|
...current,
|
||||||
|
confidence: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="text-muted-foreground text-xs"
|
||||||
|
id={factConfidenceHintId}
|
||||||
|
>
|
||||||
|
{factConfidenceHint}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setFactEditorOpen(false);
|
||||||
|
setFactToEdit(null);
|
||||||
|
setFactForm(DEFAULT_FACT_FORM_STATE);
|
||||||
|
}}
|
||||||
|
disabled={isFactFormPending}
|
||||||
|
>
|
||||||
|
{t.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => void handleSaveFact()}
|
||||||
|
disabled={isFactFormPending}
|
||||||
|
>
|
||||||
|
{isFactFormPending ? t.common.loading : factSave}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={factToDelete !== null}
|
open={factToDelete !== null}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export const enUS: Translations = {
|
||||||
home: "Home",
|
home: "Home",
|
||||||
settings: "Settings",
|
settings: "Settings",
|
||||||
delete: "Delete",
|
delete: "Delete",
|
||||||
|
edit: "Edit",
|
||||||
rename: "Rename",
|
rename: "Rename",
|
||||||
share: "Share",
|
share: "Share",
|
||||||
openInNewWindow: "Open in new window",
|
openInNewWindow: "Open in new window",
|
||||||
|
|
@ -312,6 +313,11 @@ 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",
|
||||||
|
addFact: "Add fact",
|
||||||
|
addFactTitle: "Add memory fact",
|
||||||
|
editFactTitle: "Edit memory fact",
|
||||||
|
addFactSuccess: "Fact created",
|
||||||
|
editFactSuccess: "Fact updated",
|
||||||
clearAll: "Clear all memory",
|
clearAll: "Clear all memory",
|
||||||
clearAllConfirmTitle: "Clear all memory?",
|
clearAllConfirmTitle: "Clear all memory?",
|
||||||
clearAllConfirmDescription:
|
clearAllConfirmDescription:
|
||||||
|
|
@ -321,9 +327,19 @@ export const enUS: Translations = {
|
||||||
factDeleteConfirmDescription:
|
factDeleteConfirmDescription:
|
||||||
"This fact will be removed from memory immediately. This action cannot be undone.",
|
"This fact will be removed from memory immediately. This action cannot be undone.",
|
||||||
factDeleteSuccess: "Fact deleted",
|
factDeleteSuccess: "Fact deleted",
|
||||||
|
factContentLabel: "Content",
|
||||||
|
factCategoryLabel: "Category",
|
||||||
|
factConfidenceLabel: "Confidence",
|
||||||
|
factContentPlaceholder: "Describe the memory fact you want to save",
|
||||||
|
factCategoryPlaceholder: "context",
|
||||||
|
factConfidenceHint: "Use a number between 0 and 1.",
|
||||||
|
factSave: "Save fact",
|
||||||
|
factValidationContent: "Fact content cannot be empty.",
|
||||||
|
factValidationConfidence: "Confidence must be a number between 0 and 1.",
|
||||||
|
manualFactSource: "Manual",
|
||||||
noFacts: "No saved facts yet.",
|
noFacts: "No saved facts yet.",
|
||||||
summaryReadOnly:
|
summaryReadOnly:
|
||||||
"Summary sections are read-only for now. You can currently clear all memory or delete individual facts.",
|
"Summary sections are read-only for now. You can currently add, edit, or delete individual facts, or clear all memory.",
|
||||||
memoryFullyEmpty: "No memory saved yet.",
|
memoryFullyEmpty: "No memory saved yet.",
|
||||||
factPreviewLabel: "Fact to delete",
|
factPreviewLabel: "Fact to delete",
|
||||||
searchPlaceholder: "Search memory",
|
searchPlaceholder: "Search memory",
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export interface Translations {
|
||||||
home: string;
|
home: string;
|
||||||
settings: string;
|
settings: string;
|
||||||
delete: string;
|
delete: string;
|
||||||
|
edit: string;
|
||||||
rename: string;
|
rename: string;
|
||||||
share: string;
|
share: string;
|
||||||
openInNewWindow: string;
|
openInNewWindow: string;
|
||||||
|
|
@ -247,6 +248,11 @@ export interface Translations {
|
||||||
description: string;
|
description: string;
|
||||||
empty: string;
|
empty: string;
|
||||||
rawJson: string;
|
rawJson: string;
|
||||||
|
addFact: string;
|
||||||
|
addFactTitle: string;
|
||||||
|
editFactTitle: string;
|
||||||
|
addFactSuccess: string;
|
||||||
|
editFactSuccess: string;
|
||||||
clearAll: string;
|
clearAll: string;
|
||||||
clearAllConfirmTitle: string;
|
clearAllConfirmTitle: string;
|
||||||
clearAllConfirmDescription: string;
|
clearAllConfirmDescription: string;
|
||||||
|
|
@ -254,6 +260,16 @@ export interface Translations {
|
||||||
factDeleteConfirmTitle: string;
|
factDeleteConfirmTitle: string;
|
||||||
factDeleteConfirmDescription: string;
|
factDeleteConfirmDescription: string;
|
||||||
factDeleteSuccess: string;
|
factDeleteSuccess: string;
|
||||||
|
factContentLabel: string;
|
||||||
|
factCategoryLabel: string;
|
||||||
|
factConfidenceLabel: string;
|
||||||
|
factContentPlaceholder: string;
|
||||||
|
factCategoryPlaceholder: string;
|
||||||
|
factConfidenceHint: string;
|
||||||
|
factSave: string;
|
||||||
|
factValidationContent: string;
|
||||||
|
factValidationConfidence: string;
|
||||||
|
manualFactSource: string;
|
||||||
noFacts: string;
|
noFacts: string;
|
||||||
summaryReadOnly: string;
|
summaryReadOnly: string;
|
||||||
memoryFullyEmpty: string;
|
memoryFullyEmpty: string;
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export const zhCN: Translations = {
|
||||||
home: "首页",
|
home: "首页",
|
||||||
settings: "设置",
|
settings: "设置",
|
||||||
delete: "删除",
|
delete: "删除",
|
||||||
|
edit: "编辑",
|
||||||
rename: "重命名",
|
rename: "重命名",
|
||||||
share: "分享",
|
share: "分享",
|
||||||
openInNewWindow: "在新窗口打开",
|
openInNewWindow: "在新窗口打开",
|
||||||
|
|
@ -298,6 +299,11 @@ export const zhCN: Translations = {
|
||||||
"DeerFlow 会在后台不断从你的对话中自动学习。这些记忆能帮助 DeerFlow 更好地理解你,并提供更个性化的体验。",
|
"DeerFlow 会在后台不断从你的对话中自动学习。这些记忆能帮助 DeerFlow 更好地理解你,并提供更个性化的体验。",
|
||||||
empty: "暂无可展示的记忆数据。",
|
empty: "暂无可展示的记忆数据。",
|
||||||
rawJson: "原始 JSON",
|
rawJson: "原始 JSON",
|
||||||
|
addFact: "添加事实",
|
||||||
|
addFactTitle: "添加记忆事实",
|
||||||
|
editFactTitle: "编辑记忆事实",
|
||||||
|
addFactSuccess: "事实已创建",
|
||||||
|
editFactSuccess: "事实已更新",
|
||||||
clearAll: "清空全部记忆",
|
clearAll: "清空全部记忆",
|
||||||
clearAllConfirmTitle: "要清空全部记忆吗?",
|
clearAllConfirmTitle: "要清空全部记忆吗?",
|
||||||
clearAllConfirmDescription:
|
clearAllConfirmDescription:
|
||||||
|
|
@ -307,9 +313,19 @@ export const zhCN: Translations = {
|
||||||
factDeleteConfirmDescription:
|
factDeleteConfirmDescription:
|
||||||
"这条事实会立即从记忆中删除。此操作无法撤销。",
|
"这条事实会立即从记忆中删除。此操作无法撤销。",
|
||||||
factDeleteSuccess: "事实已删除",
|
factDeleteSuccess: "事实已删除",
|
||||||
|
factContentLabel: "内容",
|
||||||
|
factCategoryLabel: "类别",
|
||||||
|
factConfidenceLabel: "置信度",
|
||||||
|
factContentPlaceholder: "描述你想保存的记忆事实",
|
||||||
|
factCategoryPlaceholder: "context",
|
||||||
|
factConfidenceHint: "请输入 0 到 1 之间的数字。",
|
||||||
|
factSave: "保存事实",
|
||||||
|
factValidationContent: "事实内容不能为空。",
|
||||||
|
factValidationConfidence: "置信度必须是 0 到 1 之间的数字。",
|
||||||
|
manualFactSource: "手动添加",
|
||||||
noFacts: "还没有保存的事实。",
|
noFacts: "还没有保存的事实。",
|
||||||
summaryReadOnly:
|
summaryReadOnly:
|
||||||
"摘要分区当前仍为只读。现在你可以清空全部记忆或删除单条事实。",
|
"摘要分区当前仍为只读。你可以在下方添加、编辑或删除事实,或清空全部记忆。",
|
||||||
memoryFullyEmpty: "还没有保存任何记忆。",
|
memoryFullyEmpty: "还没有保存任何记忆。",
|
||||||
factPreviewLabel: "即将删除的事实",
|
factPreviewLabel: "即将删除的事实",
|
||||||
searchPlaceholder: "搜索记忆",
|
searchPlaceholder: "搜索记忆",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { getBackendBaseURL } from "../config";
|
import { getBackendBaseURL } from "../config";
|
||||||
|
|
||||||
import type { UserMemory } from "./types";
|
import type {
|
||||||
|
MemoryFactInput,
|
||||||
|
MemoryFactPatchInput,
|
||||||
|
UserMemory,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
async function readMemoryResponse(
|
async function readMemoryResponse(
|
||||||
response: Response,
|
response: Response,
|
||||||
|
|
@ -39,3 +43,33 @@ export async function deleteMemoryFact(factId: string): Promise<UserMemory> {
|
||||||
);
|
);
|
||||||
return readMemoryResponse(response, "Failed to delete memory fact");
|
return readMemoryResponse(response, "Failed to delete memory fact");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createMemoryFact(
|
||||||
|
input: MemoryFactInput,
|
||||||
|
): Promise<UserMemory> {
|
||||||
|
const response = await fetch(`${getBackendBaseURL()}/api/memory/facts`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
return readMemoryResponse(response, "Failed to create memory fact");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMemoryFact(
|
||||||
|
factId: string,
|
||||||
|
input: MemoryFactPatchInput,
|
||||||
|
): Promise<UserMemory> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${getBackendBaseURL()}/api/memory/facts/${encodeURIComponent(factId)}`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return readMemoryResponse(response, "Failed to update memory fact");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,17 @@
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { clearMemory, deleteMemoryFact, loadMemory } from "./api";
|
import {
|
||||||
import type { UserMemory } from "./types";
|
clearMemory,
|
||||||
|
createMemoryFact,
|
||||||
|
deleteMemoryFact,
|
||||||
|
loadMemory,
|
||||||
|
updateMemoryFact,
|
||||||
|
} from "./api";
|
||||||
|
import type {
|
||||||
|
MemoryFactInput,
|
||||||
|
MemoryFactPatchInput,
|
||||||
|
UserMemory,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
export function useMemory() {
|
export function useMemory() {
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
|
|
@ -32,3 +42,31 @@ export function useDeleteMemoryFact() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useCreateMemoryFact() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: MemoryFactInput) => createMemoryFact(input),
|
||||||
|
onSuccess: (memory) => {
|
||||||
|
queryClient.setQueryData<UserMemory>(["memory"], memory);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateMemoryFact() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
factId,
|
||||||
|
input,
|
||||||
|
}: {
|
||||||
|
factId: string;
|
||||||
|
input: MemoryFactPatchInput;
|
||||||
|
}) => updateMemoryFact(factId, input),
|
||||||
|
onSuccess: (memory) => {
|
||||||
|
queryClient.setQueryData<UserMemory>(["memory"], memory);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,24 @@
|
||||||
|
export interface MemoryFact {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
category: string;
|
||||||
|
confidence: number;
|
||||||
|
createdAt: string;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoryFactInput {
|
||||||
|
content: string;
|
||||||
|
category: string;
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoryFactPatchInput {
|
||||||
|
content?: string;
|
||||||
|
category?: string;
|
||||||
|
confidence?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserMemory {
|
export interface UserMemory {
|
||||||
version: string;
|
version: string;
|
||||||
lastUpdated: string;
|
lastUpdated: string;
|
||||||
|
|
@ -29,12 +50,5 @@ export interface UserMemory {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
facts: {
|
facts: MemoryFact[];
|
||||||
id: string;
|
|
||||||
content: string;
|
|
||||||
category: string;
|
|
||||||
confidence: number;
|
|
||||||
createdAt: string;
|
|
||||||
source: string;
|
|
||||||
}[];
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue