- 新增 V1 记忆系统(memory.json)支持:提取结果强制中文、历史字段修复为增量累积日志 - 用户设置页面:V1 模式下新增 agent 选择器,可查看各 agent 的私有记忆 - 管理员记忆页面:区分 V1/V2 渲染,V1 支持完整 CRUD(用户画像/历史摘要/事实记录) - 后端新增 V1 记忆 CRUD API(sections/facts),前端同步添加对应 hooks 和 API 调用 - 新建专家页面:标记 bootstrap 线程类型,跳过记忆保存,侧边栏过滤该类线程 - 输入框工具栏新增「记忆」开关:关闭时 V1/V2 均不注入,开启时按 config.yaml 版本正常注入 - 修复 agent 目录不存在时的 FileNotFoundError,回退至默认配置 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
495 lines
19 KiB
Python
495 lines
19 KiB
Python
"""Memory V2 记忆 API 路由。
|
|
|
|
所有端点用 ``/api/memory/v2`` 前缀。Memory V2 重写后,旧的 facts/history
|
|
端点 (``/api/memory``、``/memory/facts`` 等) 已移除。
|
|
|
|
端点:
|
|
- ``GET /memory/v2`` 读取 builtin 本地记忆 (USER.md / MEMORY.md)
|
|
- ``POST /memory/v2/entries`` 新增一条记忆条目
|
|
- ``PUT /memory/v2/entries`` 替换一条记忆条目
|
|
- ``DELETE /memory/v2/entries`` 删除一条记忆条目
|
|
- ``GET /memory/v2/config`` 读取生效配置 + 用户可改字段白名单
|
|
- ``PATCH /memory/v2/config`` 写入 per-user 配置覆盖 (白名单字段)
|
|
- ``DELETE /memory/v2/config`` 撤销 per-user 配置覆盖
|
|
- ``POST /memory/v2/search`` 代理 Hindsight 检索 (recall / reflect)
|
|
|
|
可选 ``agent`` 查询参数:省略或传 ``global`` 表示全局桶;传 agent 名表示该
|
|
agent 的工作记忆桶。USER.md 始终是 per-user 全局的;MEMORY.md 按 agent 隔离。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, Request
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.gateway.deps import get_memory_config_store
|
|
from deerflow.config.agents_config import AGENT_NAME_PATTERN
|
|
from deerflow.config.memory_config import (
|
|
USER_WRITABLE_FIELDS,
|
|
delete_user_memory_override_db,
|
|
get_effective_memory_config,
|
|
save_user_memory_override_db,
|
|
)
|
|
from deerflow.config.paths import get_paths
|
|
from deerflow.runtime.user_context import get_effective_user_id
|
|
|
|
router = APIRouter(prefix="/api", tags=["memory"])
|
|
|
|
|
|
# 表示"全局桶 (无 agent)"的 agent 取值。
|
|
_GLOBAL_AGENT_VALUES: frozenset[str] = frozenset({"", "global", "__global__"})
|
|
|
|
|
|
def _resolve_agent_name(agent: str | None) -> str | None:
|
|
"""校验 ``agent`` 查询参数,把全局哨兵规整为 ``None``。
|
|
|
|
返回 ``None`` 表示全局桶,否则返回小写的 agent 名。非法名抛 400。
|
|
"""
|
|
if agent is None:
|
|
return None
|
|
stripped = agent.strip()
|
|
if stripped.lower() in _GLOBAL_AGENT_VALUES:
|
|
return None
|
|
if not AGENT_NAME_PATTERN.match(stripped):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Invalid agent name {stripped!r}: must match {AGENT_NAME_PATTERN.pattern}",
|
|
)
|
|
return stripped.lower()
|
|
|
|
|
|
_AgentQuery = Query(
|
|
None,
|
|
description="可选 agent 名。省略或传 'global' 表示全局桶;传 agent 名表示该 agent 的工作记忆桶。",
|
|
examples=["my-agent"],
|
|
pattern=r"^[A-Za-z0-9-]+$|^global$|^$",
|
|
)
|
|
|
|
|
|
# ===========================================================================
|
|
# 响应 / 请求模型
|
|
# ===========================================================================
|
|
|
|
|
|
class MemoryFact(BaseModel):
|
|
"""V1 记忆中的单条事实。"""
|
|
|
|
id: str = Field(default="", description="事实 UUID")
|
|
content: str = Field(default="", description="事实文本")
|
|
category: str = Field(default="knowledge", description="事实类别")
|
|
confidence: float = Field(default=0.0, description="置信度 0-1")
|
|
createdAt: str = Field(default="", description="创建时间 ISO 格式")
|
|
sourceError: str = Field(default="", description="纠错类事实:被纠正的原错误描述")
|
|
|
|
|
|
class MemoryV2DataResponse(BaseModel):
|
|
"""Memory 数据视图 —— V1/V2 共用,多余字段各版本置空。"""
|
|
|
|
# V2 字段
|
|
user_entries: list[str] = Field(default_factory=list, description="V2: USER.md 条目 (用户画像)")
|
|
memory_entries: list[str] = Field(default_factory=list, description="V2: MEMORY.md 条目 (agent 工作笔记)")
|
|
user_usage: str = Field(default="", description="字符/计数用量描述")
|
|
memory_usage: str = Field(default="", description="字符/计数用量描述")
|
|
|
|
# V1 字段
|
|
facts: list[MemoryFact] = Field(default_factory=list, description="V1: facts 列表 (含置信度)")
|
|
user_context: dict[str, str] = Field(default_factory=dict, description="V1: userContext 摘要")
|
|
history: dict[str, str] = Field(default_factory=dict, description="V1: history 历史摘要")
|
|
|
|
|
|
class MemoryV2ConfigResponse(BaseModel):
|
|
"""Memory 生效配置 (含三层覆盖合并结果) + 用户可改字段白名单 + 版本号。"""
|
|
|
|
config: dict[str, Any] = Field(..., description="按 per-user 覆盖合并后的生效配置")
|
|
writable_fields: list[str] = Field(..., description="用户可在运行时覆盖的字段 (点号路径)")
|
|
version: str = Field(default="v2", description="当前记忆版本:v1 或 v2")
|
|
|
|
|
|
class MemoryConfigPatchRequest(BaseModel):
|
|
"""Memory V2 配置覆盖请求。
|
|
|
|
overrides 支持嵌套形式 ``{"injection": {"enabled": false}}`` 或点号扁平形式
|
|
``{"injection.enabled": false}``;非白名单字段会被忽略。
|
|
"""
|
|
|
|
overrides: dict[str, Any] = Field(..., description="要写入 per-user 覆盖的字段")
|
|
|
|
|
|
class MemoryEntryCreateRequest(BaseModel):
|
|
"""新增一条 V2 记忆条目。"""
|
|
|
|
target: str = Field(..., description="memory(工作笔记) 或 user(用户画像)")
|
|
content: str = Field(..., min_length=1, description="条目内容")
|
|
|
|
|
|
class MemoryEntryReplaceRequest(BaseModel):
|
|
"""替换一条 V2 记忆条目。"""
|
|
|
|
target: str = Field(..., description="memory 或 user")
|
|
old_text: str = Field(..., min_length=1, description="短唯一子串,定位要替换的条目")
|
|
content: str = Field(..., min_length=1, description="新内容")
|
|
|
|
|
|
class MemorySearchRequest(BaseModel):
|
|
"""Hindsight 检索请求。"""
|
|
|
|
query: str = Field(..., min_length=1, description="检索查询")
|
|
mode: str = Field(default="recall", description="recall(返回原始片段) 或 reflect(LLM 综合)")
|
|
|
|
|
|
class MemorySearchResponse(BaseModel):
|
|
"""Hindsight 检索结果。"""
|
|
|
|
available: bool = Field(..., description="Hindsight 是否可用 (未配置/未装包时为 false)")
|
|
results: list[str] = Field(default_factory=list, description="recall 模式的检索结果")
|
|
text: str = Field(default="", description="reflect 模式的综合结论")
|
|
|
|
|
|
# ===========================================================================
|
|
# 辅助函数
|
|
# ===========================================================================
|
|
|
|
|
|
def _is_v1(user_id: str) -> bool:
|
|
"""判断当前用户是否处于 V1 记忆模式。"""
|
|
config = get_effective_memory_config(user_id)
|
|
return getattr(config, "version", "v2") == "v1"
|
|
|
|
|
|
def _build_v1_provider(user_id: str, agent_name: str | None = None):
|
|
"""构建并初始化 V1BuiltinProvider。"""
|
|
from deerflow.agents.memory.providers.v1_builtin import V1BuiltinProvider
|
|
|
|
config = get_effective_memory_config(user_id)
|
|
v1_cfg = config.v1
|
|
provider = V1BuiltinProvider(
|
|
max_facts=v1_cfg.max_facts,
|
|
fact_confidence_threshold=v1_cfg.fact_confidence_threshold,
|
|
debounce_seconds=v1_cfg.debounce_seconds,
|
|
model_name=v1_cfg.model_name,
|
|
token_budget=v1_cfg.token_budget,
|
|
)
|
|
provider.initialize(
|
|
user_id=user_id,
|
|
agent_id=agent_name,
|
|
base_dir=str(get_paths().base_dir),
|
|
)
|
|
return provider
|
|
|
|
|
|
def _build_builtin_provider(user_id: str, agent_name: str | None):
|
|
"""按生效配置构建并初始化一个 V2 builtin provider。"""
|
|
from deerflow.agents.memory.providers.builtin import BuiltinFileProvider
|
|
|
|
config = get_effective_memory_config(user_id)
|
|
provider = BuiltinFileProvider(
|
|
memory_char_limit=config.builtin.memory_char_limit,
|
|
user_char_limit=config.builtin.user_char_limit,
|
|
deduplicate_on_load=config.builtin.deduplicate_on_load,
|
|
security=config.security,
|
|
)
|
|
provider.initialize(
|
|
user_id=user_id,
|
|
agent_id=agent_name or "default",
|
|
base_dir=str(get_paths().base_dir),
|
|
)
|
|
return provider
|
|
|
|
|
|
def _validate_v2_target(target: str) -> str:
|
|
"""校验 V2 entries 的 target,非法时抛 400。"""
|
|
if target not in ("memory", "user"):
|
|
raise HTTPException(status_code=400, detail=f"非法 target '{target}',只能是 'memory' 或 'user'。")
|
|
return target
|
|
|
|
|
|
def _v2_data_response(provider) -> MemoryV2DataResponse:
|
|
"""从 V2 builtin provider 读出当前数据。"""
|
|
return MemoryV2DataResponse(
|
|
user_entries=provider.read("user"),
|
|
memory_entries=provider.read("memory"),
|
|
user_usage=provider.usage("user"),
|
|
memory_usage=provider.usage("memory"),
|
|
)
|
|
|
|
|
|
def _v1_data_response(provider) -> MemoryV2DataResponse:
|
|
"""从 V1 provider 读出当前数据,转换为统一响应格式。
|
|
|
|
支持新格式 {user: {workContext: {summary, updatedAt}}} 和老格式 {userContext: {workContext: str}}。
|
|
"""
|
|
data = provider.read_v1_data()
|
|
raw_facts = data.get("facts", [])
|
|
facts = [
|
|
MemoryFact(
|
|
id=str(f.get("id", "")),
|
|
content=str(f.get("content", "")),
|
|
category=str(f.get("category", "knowledge")),
|
|
confidence=float(f.get("confidence", 0)),
|
|
createdAt=str(f.get("createdAt", "")),
|
|
sourceError=str(f.get("sourceError", "")),
|
|
)
|
|
for f in raw_facts
|
|
]
|
|
|
|
def _extract_section(section: dict) -> dict[str, str]:
|
|
"""从 {key: {summary: str, updatedAt: str}} 或 {key: str} 中提取摘要字符串。"""
|
|
result = {}
|
|
for k, v in section.items():
|
|
if isinstance(v, dict):
|
|
summary = v.get("summary", "")
|
|
else:
|
|
summary = str(v)
|
|
if summary:
|
|
result[k] = summary
|
|
return result
|
|
|
|
# 新格式用 "user" key,老格式用 "userContext" key(已由 storage 迁移,此处兜底)
|
|
ctx_raw = data.get("user") or data.get("userContext") or {}
|
|
history_raw = data.get("history") or {}
|
|
|
|
return MemoryV2DataResponse(
|
|
facts=sorted(facts, key=lambda f: f.confidence, reverse=True),
|
|
user_context=_extract_section(ctx_raw),
|
|
history=_extract_section(history_raw),
|
|
memory_usage=provider.usage_text(),
|
|
)
|
|
|
|
|
|
def _raise_if_write_failed(result: dict) -> None:
|
|
"""builtin provider 写操作失败时抛 400。"""
|
|
if not result.get("success"):
|
|
raise HTTPException(status_code=400, detail=result.get("error", "记忆写入失败。"))
|
|
|
|
|
|
# ===========================================================================
|
|
# 数据端点
|
|
# ===========================================================================
|
|
|
|
|
|
@router.get(
|
|
"/memory/v2",
|
|
response_model=MemoryV2DataResponse,
|
|
summary="Get Memory Data",
|
|
description="读取记忆数据。V1 模式返回 facts 列表;V2 模式返回 USER.md / MEMORY.md 条目。",
|
|
)
|
|
async def get_memory_v2(agent: str | None = _AgentQuery) -> MemoryV2DataResponse:
|
|
"""读取当前用户的本地记忆。"""
|
|
agent_name = _resolve_agent_name(agent)
|
|
user_id = get_effective_user_id()
|
|
try:
|
|
if _is_v1(user_id):
|
|
provider = _build_v1_provider(user_id, agent_name)
|
|
return _v1_data_response(provider)
|
|
provider = _build_builtin_provider(user_id, agent_name)
|
|
return _v2_data_response(provider)
|
|
except Exception as exc: # noqa: BLE001
|
|
raise HTTPException(status_code=500, detail="Failed to read memory data.") from exc
|
|
|
|
|
|
def _assert_not_v1(user_id: str) -> None:
|
|
"""V1 模式下写入操作返回 400。"""
|
|
if _is_v1(user_id):
|
|
raise HTTPException(status_code=400, detail="V1 模式下记忆由系统自动提取,不支持手动修改。")
|
|
|
|
|
|
@router.post(
|
|
"/memory/v2/entries",
|
|
response_model=MemoryV2DataResponse,
|
|
summary="Add Memory V2 Entry",
|
|
description="手动往 USER.md / MEMORY.md 新增一条记忆条目 (仅 V2 模式)。",
|
|
)
|
|
async def add_memory_v2_entry(
|
|
request: MemoryEntryCreateRequest,
|
|
agent: str | None = _AgentQuery,
|
|
) -> MemoryV2DataResponse:
|
|
"""手动新增一条 V2 记忆条目。"""
|
|
user_id = get_effective_user_id()
|
|
_assert_not_v1(user_id)
|
|
_validate_v2_target(request.target)
|
|
try:
|
|
provider = _build_builtin_provider(user_id, _resolve_agent_name(agent))
|
|
result = provider.add(request.target, request.content)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc: # noqa: BLE001
|
|
raise HTTPException(status_code=500, detail="Failed to add memory entry.") from exc
|
|
_raise_if_write_failed(result)
|
|
return _v2_data_response(provider)
|
|
|
|
|
|
@router.put(
|
|
"/memory/v2/entries",
|
|
response_model=MemoryV2DataResponse,
|
|
summary="Replace Memory V2 Entry",
|
|
description="用 old_text 定位一条记忆条目并替换为新内容 (仅 V2 模式)。",
|
|
)
|
|
async def replace_memory_v2_entry(
|
|
request: MemoryEntryReplaceRequest,
|
|
agent: str | None = _AgentQuery,
|
|
) -> MemoryV2DataResponse:
|
|
"""手动替换一条 V2 记忆条目。"""
|
|
user_id = get_effective_user_id()
|
|
_assert_not_v1(user_id)
|
|
_validate_v2_target(request.target)
|
|
try:
|
|
provider = _build_builtin_provider(user_id, _resolve_agent_name(agent))
|
|
result = provider.replace(request.target, request.old_text, request.content)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc: # noqa: BLE001
|
|
raise HTTPException(status_code=500, detail="Failed to replace memory entry.") from exc
|
|
_raise_if_write_failed(result)
|
|
return _v2_data_response(provider)
|
|
|
|
|
|
@router.delete(
|
|
"/memory/v2/entries",
|
|
response_model=MemoryV2DataResponse,
|
|
summary="Delete Memory V2 Entry",
|
|
description="用 old_text 定位并删除一条记忆条目 (仅 V2 模式)。",
|
|
)
|
|
async def delete_memory_v2_entry(
|
|
target: str = Query(..., description="memory 或 user"),
|
|
old_text: str = Query(..., min_length=1, description="短唯一子串,定位要删除的条目"),
|
|
agent: str | None = _AgentQuery,
|
|
) -> MemoryV2DataResponse:
|
|
"""手动删除一条 V2 记忆条目。"""
|
|
user_id = get_effective_user_id()
|
|
_assert_not_v1(user_id)
|
|
_validate_v2_target(target)
|
|
try:
|
|
provider = _build_builtin_provider(user_id, _resolve_agent_name(agent))
|
|
result = provider.remove(target, old_text)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc: # noqa: BLE001
|
|
raise HTTPException(status_code=500, detail="Failed to delete memory entry.") from exc
|
|
_raise_if_write_failed(result)
|
|
return _v2_data_response(provider)
|
|
|
|
|
|
# ===========================================================================
|
|
# 配置端点
|
|
# ===========================================================================
|
|
|
|
|
|
@router.get(
|
|
"/memory/v2/config",
|
|
response_model=MemoryV2ConfigResponse,
|
|
summary="Get Memory V2 Effective Config",
|
|
description="读取按 per-user 覆盖合并后的生效记忆配置,以及用户可改字段白名单。",
|
|
)
|
|
async def get_memory_v2_config() -> MemoryV2ConfigResponse:
|
|
"""读取当前用户的生效记忆配置。"""
|
|
user_id = get_effective_user_id()
|
|
config = get_effective_memory_config(user_id)
|
|
return MemoryV2ConfigResponse(
|
|
config=config.model_dump(),
|
|
writable_fields=sorted(USER_WRITABLE_FIELDS),
|
|
version=getattr(config, "version", "v2"),
|
|
)
|
|
|
|
|
|
@router.patch(
|
|
"/memory/v2/config",
|
|
response_model=MemoryV2ConfigResponse,
|
|
summary="Patch Memory V2 Config",
|
|
description="把 hindsight.recall_budget 写入当前用户的 per-user 配置覆盖 (DB 存储)。",
|
|
)
|
|
async def patch_memory_v2_config(request: MemoryConfigPatchRequest, req: Request) -> MemoryV2ConfigResponse:
|
|
"""更新当前用户的 per-user 记忆配置覆盖 (仅 hindsight.recall_budget)。"""
|
|
user_id = get_effective_user_id()
|
|
store = get_memory_config_store(req)
|
|
try:
|
|
await save_user_memory_override_db(user_id, request.overrides, store)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
except Exception as exc: # noqa: BLE001
|
|
raise HTTPException(status_code=500, detail="Failed to save memory config override.") from exc
|
|
|
|
config = get_effective_memory_config(user_id)
|
|
return MemoryV2ConfigResponse(
|
|
config=config.model_dump(),
|
|
writable_fields=sorted(USER_WRITABLE_FIELDS),
|
|
version=getattr(config, "version", "v2"),
|
|
)
|
|
|
|
|
|
@router.delete(
|
|
"/memory/v2/config",
|
|
response_model=MemoryV2ConfigResponse,
|
|
summary="Delete Memory V2 Config Override",
|
|
description=(
|
|
"撤销当前用户的 per-user 配置覆盖。传 ?field=hindsight.recall_budget 只撤销该字段;"
|
|
"不传 field 则清空全部覆盖。"
|
|
),
|
|
)
|
|
async def delete_memory_v2_config(
|
|
req: Request,
|
|
field: str | None = Query(None, description="要撤销的点号路径字段;省略则清空全部覆盖"),
|
|
) -> MemoryV2ConfigResponse:
|
|
"""撤销当前用户的 per-user 记忆配置覆盖。"""
|
|
user_id = get_effective_user_id()
|
|
store = get_memory_config_store(req)
|
|
try:
|
|
await delete_user_memory_override_db(user_id, field, store)
|
|
except Exception as exc: # noqa: BLE001
|
|
raise HTTPException(status_code=500, detail="Failed to delete memory config override.") from exc
|
|
|
|
config = get_effective_memory_config(user_id)
|
|
return MemoryV2ConfigResponse(
|
|
config=config.model_dump(),
|
|
writable_fields=sorted(USER_WRITABLE_FIELDS),
|
|
version=getattr(config, "version", "v2"),
|
|
)
|
|
|
|
|
|
# ===========================================================================
|
|
# Hindsight 检索代理端点
|
|
# ===========================================================================
|
|
|
|
|
|
@router.post(
|
|
"/memory/v2/search",
|
|
response_model=MemorySearchResponse,
|
|
summary="Search Hindsight Memory",
|
|
description=(
|
|
"代理 Hindsight 检索。mode=recall 返回原始记忆片段;mode=reflect 返回 LLM 综合结论。"
|
|
"Hindsight 未配置或不可用时返回 available=false。"
|
|
),
|
|
)
|
|
async def search_memory_v2(
|
|
request: MemorySearchRequest,
|
|
agent: str | None = _AgentQuery,
|
|
) -> MemorySearchResponse:
|
|
"""代理 Hindsight 检索 (recall / reflect)。"""
|
|
user_id = get_effective_user_id()
|
|
config = get_effective_memory_config(user_id)
|
|
if not config.enabled or config.provider != "hindsight":
|
|
return MemorySearchResponse(available=False)
|
|
|
|
try:
|
|
from deerflow.agents.memory.providers.hindsight import HindsightProvider
|
|
|
|
provider = HindsightProvider(config.hindsight)
|
|
if not provider.is_available():
|
|
return MemorySearchResponse(available=False)
|
|
provider.initialize(
|
|
user_id=user_id,
|
|
agent_id=_resolve_agent_name(agent) or "default",
|
|
thread_id="",
|
|
base_dir="",
|
|
)
|
|
if request.mode == "reflect":
|
|
text = provider.reflect(request.query)
|
|
return MemorySearchResponse(available=True, text=text)
|
|
results = provider.recall(request.query)
|
|
return MemorySearchResponse(available=True, results=results)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc: # noqa: BLE001
|
|
raise HTTPException(status_code=500, detail="Failed to search Hindsight memory.") from exc
|