- 新增 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>
416 lines
17 KiB
Python
416 lines
17 KiB
Python
"""Admin-only window into another user's activity.
|
||
|
||
Lets an administrator inspect any user's conversation history (threads + the
|
||
Q&A messages inside them) and their accumulated memory (global + per-agent
|
||
buckets). All endpoints require ``system_role == "admin"``; they are strictly
|
||
read-only.
|
||
|
||
The underlying stores normally auto-scope every query to the *calling* user
|
||
via a contextvar. These endpoints pass an explicit ``user_id`` so the admin
|
||
reads the *target* user's data instead — which is exactly what those store
|
||
methods' ``user_id`` parameter is designed for.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
|
||
from fastapi import APIRouter, HTTPException, Query, Request
|
||
from pydantic import BaseModel, Field
|
||
|
||
from app.gateway.deps import (
|
||
get_agent_store,
|
||
get_checkpointer,
|
||
get_current_user_from_request,
|
||
get_thread_store,
|
||
)
|
||
from deerflow.agents.memory import BuiltinFileProvider
|
||
from deerflow.config.paths import get_paths
|
||
from deerflow.runtime import serialize_channel_values
|
||
from app.gateway.routers.memory import _build_v1_provider, _is_v1
|
||
|
||
logger = logging.getLogger(__name__)
|
||
router = APIRouter(prefix="/api/admin/users", tags=["admin-users"])
|
||
|
||
|
||
async def _require_admin(request: Request) -> None:
|
||
"""Reject non-admin callers. In no-auth mode there is no user → allow."""
|
||
try:
|
||
user = await get_current_user_from_request(request)
|
||
except HTTPException:
|
||
raise
|
||
if user is None:
|
||
return
|
||
if getattr(user, "system_role", None) != "admin":
|
||
raise HTTPException(status_code=403, detail="Admin only")
|
||
|
||
|
||
# ── Threads ────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class AdminThreadItem(BaseModel):
|
||
thread_id: str
|
||
display_name: str = ""
|
||
status: str = "idle"
|
||
created_at: str = ""
|
||
updated_at: str = ""
|
||
|
||
|
||
@router.get("/{user_id}/threads", response_model=list[AdminThreadItem])
|
||
async def list_user_threads(
|
||
user_id: str,
|
||
request: Request,
|
||
limit: int = Query(default=100, ge=1, le=500),
|
||
offset: int = Query(default=0, ge=0),
|
||
) -> list[AdminThreadItem]:
|
||
"""List one user's conversation threads, newest first."""
|
||
await _require_admin(request)
|
||
repo = get_thread_store(request)
|
||
rows = await repo.search(limit=limit, offset=offset, user_id=user_id)
|
||
return [
|
||
AdminThreadItem(
|
||
thread_id=r["thread_id"],
|
||
display_name=r.get("display_name") or "",
|
||
status=r.get("status", "idle"),
|
||
created_at=str(r.get("created_at") or ""),
|
||
updated_at=str(r.get("updated_at") or ""),
|
||
)
|
||
for r in rows
|
||
]
|
||
|
||
|
||
@router.get("/{user_id}/threads/{thread_id}/messages", response_model=list[dict])
|
||
async def list_user_thread_messages(
|
||
user_id: str,
|
||
thread_id: str,
|
||
request: Request,
|
||
limit: int = Query(default=400, ge=1, le=1000),
|
||
) -> list[dict]:
|
||
"""Return the conversation messages of one of the user's threads.
|
||
|
||
Read straight from the LangGraph checkpoint — the same source the chat UI
|
||
renders — rather than the optional ``run_events`` audit log, which may be
|
||
unconfigured (``run_events.backend=memory``) or empty for older threads.
|
||
"""
|
||
await _require_admin(request)
|
||
|
||
repo = get_thread_store(request)
|
||
# ``user_id=None`` bypasses the owner filter so the admin can fetch any row;
|
||
# we then assert the thread really belongs to the target user.
|
||
thread = await repo.get(thread_id, user_id=None)
|
||
if thread is None:
|
||
raise HTTPException(status_code=404, detail="Thread not found")
|
||
owner = thread.get("user_id")
|
||
if owner is not None and owner != user_id:
|
||
raise HTTPException(status_code=404, detail="Thread does not belong to this user")
|
||
|
||
checkpointer = get_checkpointer(request)
|
||
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||
try:
|
||
checkpoint_tuple = await checkpointer.aget_tuple(config)
|
||
except Exception as exc: # noqa: BLE001 - surface a clean error
|
||
logger.error("Failed to read checkpoint for thread %s: %s", thread_id, exc, exc_info=True)
|
||
raise HTTPException(status_code=500, detail="Failed to read thread state") from exc
|
||
if checkpoint_tuple is None:
|
||
return []
|
||
|
||
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
|
||
channel_values = checkpoint.get("channel_values", {}) if isinstance(checkpoint, dict) else {}
|
||
values = serialize_channel_values(channel_values)
|
||
messages = values.get("messages", [])
|
||
if not isinstance(messages, list):
|
||
return []
|
||
# Keep the most recent ``limit`` messages — long threads stay bounded.
|
||
return messages[-limit:]
|
||
|
||
|
||
# ── Memory ─────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class AdminMemoryBucket(BaseModel):
|
||
name: str = Field(..., description="Bucket name: 'global' or the agent id (used for CRUD)")
|
||
display_name: str = Field("", description="Human-readable name shown in the UI")
|
||
data: dict = Field(default_factory=dict, description="Raw memory data for the bucket")
|
||
|
||
|
||
class AdminMemoryEntryAddRequest(BaseModel):
|
||
content: str = Field(..., min_length=1, description="Entry content to add")
|
||
agent: str | None = Field(None, description="Agent name; None = global USER.md bucket")
|
||
|
||
|
||
class AdminMemoryEntryReplaceRequest(BaseModel):
|
||
old_text: str = Field(..., min_length=1, description="Short unique substring to locate the entry")
|
||
content: str = Field(..., min_length=1, description="New content to replace with")
|
||
agent: str | None = Field(None, description="Agent name; None = global USER.md bucket")
|
||
|
||
|
||
class AdminMemoryResponse(BaseModel):
|
||
user_id: str
|
||
version: str = Field(default="v2", description="记忆版本:v1 或 v2")
|
||
buckets: list[AdminMemoryBucket] = Field(default_factory=list)
|
||
|
||
|
||
def _read_memory_bucket(user_id: str, agent_id: str | None) -> dict:
|
||
"""Read one Memory V2 file into a JSON-able ``{"entries": [...]}`` dict."""
|
||
provider = BuiltinFileProvider()
|
||
provider.initialize(user_id=user_id, agent_id=agent_id)
|
||
target = "user" if agent_id is None else "memory"
|
||
return {"entries": provider.read(target)}
|
||
|
||
|
||
def _read_v1_memory_bucket(user_id: str, agent_id: str | None) -> dict:
|
||
"""Read one V1 memory.json into its raw dict (facts / user / history)."""
|
||
provider = _build_v1_provider(user_id, agent_id)
|
||
return provider.read_v1_data()
|
||
|
||
|
||
@router.get("/{user_id}/memory", response_model=AdminMemoryResponse)
|
||
async def get_user_memory(user_id: str, request: Request) -> AdminMemoryResponse:
|
||
"""Return every memory bucket for one user (V1 or V2 depending on config)."""
|
||
await _require_admin(request)
|
||
|
||
# Source agents from the database: only agents owned by this specific user.
|
||
agent_store = get_agent_store(request)
|
||
all_agents = await agent_store.list_visible(user_id)
|
||
owned_agents = sorted(
|
||
[a for a in all_agents if a.get("user_id") == user_id],
|
||
key=lambda a: (a.get("name") or a["id"]).lower(),
|
||
)
|
||
|
||
if _is_v1(user_id):
|
||
# V1 模式:每个桶读 memory.json,内容为 {facts, user, history}
|
||
buckets: list[AdminMemoryBucket] = []
|
||
try:
|
||
buckets.append(AdminMemoryBucket(name="global", display_name="用户记忆", data=_read_v1_memory_bucket(user_id, None)))
|
||
except Exception:
|
||
buckets.append(AdminMemoryBucket(name="global", display_name="用户记忆", data={}))
|
||
|
||
for agent in owned_agents:
|
||
agent_id: str = agent["id"]
|
||
display_name: str = agent.get("name") or agent_id
|
||
try:
|
||
data = _read_v1_memory_bucket(user_id, agent_id)
|
||
except Exception:
|
||
data = {}
|
||
buckets.append(AdminMemoryBucket(name=agent_id, display_name=display_name, data=data))
|
||
|
||
return AdminMemoryResponse(user_id=user_id, version="v1", buckets=buckets)
|
||
|
||
# V2 模式:读 USER.md / MEMORY.md,内容为 {entries: [...]}
|
||
buckets = [
|
||
AdminMemoryBucket(name="global", display_name="用户画像", data=_read_memory_bucket(user_id, None))
|
||
]
|
||
for agent in owned_agents:
|
||
agent_id = agent["id"]
|
||
display_name = agent.get("name") or agent_id
|
||
try:
|
||
data = _read_memory_bucket(user_id, agent_id)
|
||
except Exception:
|
||
data = {"entries": []}
|
||
buckets.append(AdminMemoryBucket(name=agent_id, display_name=display_name, data=data))
|
||
|
||
return AdminMemoryResponse(user_id=user_id, version="v2", buckets=buckets)
|
||
|
||
|
||
@router.post("/{user_id}/memory/entries", response_model=AdminMemoryBucket)
|
||
async def admin_add_memory_entry(
|
||
user_id: str,
|
||
body: AdminMemoryEntryAddRequest,
|
||
request: Request,
|
||
) -> AdminMemoryBucket:
|
||
"""Add a memory entry for a user (admin only)."""
|
||
await _require_admin(request)
|
||
target = "user" if body.agent is None else "memory"
|
||
provider = BuiltinFileProvider()
|
||
provider.initialize(user_id=user_id, agent_id=body.agent)
|
||
try:
|
||
result = provider.add(target, body.content)
|
||
if result is False:
|
||
raise HTTPException(status_code=400, detail="Failed to add entry (quota exceeded or write error)")
|
||
except HTTPException:
|
||
raise
|
||
except Exception as exc:
|
||
logger.error("admin_add_memory_entry failed for user %s: %s", user_id, exc, exc_info=True)
|
||
raise HTTPException(status_code=500, detail="Failed to write memory entry") from exc
|
||
bucket_name = "global" if body.agent is None else body.agent
|
||
return AdminMemoryBucket(name=bucket_name, data=_read_memory_bucket(user_id, body.agent))
|
||
|
||
|
||
@router.put("/{user_id}/memory/entries", response_model=AdminMemoryBucket)
|
||
async def admin_replace_memory_entry(
|
||
user_id: str,
|
||
body: AdminMemoryEntryReplaceRequest,
|
||
request: Request,
|
||
) -> AdminMemoryBucket:
|
||
"""Replace a memory entry for a user (admin only)."""
|
||
await _require_admin(request)
|
||
target = "user" if body.agent is None else "memory"
|
||
provider = BuiltinFileProvider()
|
||
provider.initialize(user_id=user_id, agent_id=body.agent)
|
||
try:
|
||
result = provider.replace(target, body.old_text, body.content)
|
||
if result is False:
|
||
raise HTTPException(status_code=404, detail="Entry not found or write failed")
|
||
except HTTPException:
|
||
raise
|
||
except Exception as exc:
|
||
logger.error("admin_replace_memory_entry failed for user %s: %s", user_id, exc, exc_info=True)
|
||
raise HTTPException(status_code=500, detail="Failed to replace memory entry") from exc
|
||
bucket_name = "global" if body.agent is None else body.agent
|
||
return AdminMemoryBucket(name=bucket_name, data=_read_memory_bucket(user_id, body.agent))
|
||
|
||
|
||
@router.delete("/{user_id}/memory/entries", response_model=AdminMemoryBucket)
|
||
async def admin_delete_memory_entry(
|
||
user_id: str,
|
||
request: Request,
|
||
old_text: str = Query(..., min_length=1, description="Short unique substring to locate the entry"),
|
||
agent: str | None = Query(None, description="Agent name; None = global USER.md bucket"),
|
||
) -> AdminMemoryBucket:
|
||
"""Delete a memory entry for a user (admin only)."""
|
||
await _require_admin(request)
|
||
target = "user" if agent is None else "memory"
|
||
provider = BuiltinFileProvider()
|
||
provider.initialize(user_id=user_id, agent_id=agent)
|
||
try:
|
||
result = provider.remove(target, old_text)
|
||
if result is False:
|
||
raise HTTPException(status_code=404, detail="Entry not found")
|
||
except HTTPException:
|
||
raise
|
||
except Exception as exc:
|
||
logger.error("admin_delete_memory_entry failed for user %s: %s", user_id, exc, exc_info=True)
|
||
raise HTTPException(status_code=500, detail="Failed to delete memory entry") from exc
|
||
bucket_name = "global" if agent is None else agent
|
||
return AdminMemoryBucket(name=bucket_name, data=_read_memory_bucket(user_id, agent))
|
||
|
||
|
||
# ── V1 Memory CRUD (admin) ─────────────────────────────────────────────────
|
||
|
||
_V1_USER_FIELDS = {"workContext", "personalContext", "topOfMind"}
|
||
_V1_HISTORY_FIELDS = {"recentMonths", "earlierContext", "longTermBackground"}
|
||
|
||
|
||
class AdminV1SectionUpdateRequest(BaseModel):
|
||
section_type: str = Field(..., description="'user' 或 'history'")
|
||
field: str = Field(..., description="字段名,如 workContext / recentMonths")
|
||
summary: str = Field(..., description="新的摘要文本")
|
||
agent: str | None = Field(None, description="Agent id;None 表示用户级全局记忆")
|
||
|
||
|
||
class AdminV1FactAddRequest(BaseModel):
|
||
content: str = Field(..., min_length=1)
|
||
category: str = Field(default="context")
|
||
confidence: float = Field(default=0.8, ge=0.0, le=1.0)
|
||
agent: str | None = Field(None)
|
||
|
||
|
||
class AdminV1FactUpdateRequest(BaseModel):
|
||
content: str | None = Field(None)
|
||
category: str | None = Field(None)
|
||
confidence: float | None = Field(None, ge=0.0, le=1.0)
|
||
agent: str | None = Field(None)
|
||
|
||
|
||
@router.put("/{user_id}/memory/v1/sections", response_model=AdminMemoryBucket)
|
||
async def admin_update_v1_section(
|
||
user_id: str,
|
||
body: AdminV1SectionUpdateRequest,
|
||
request: Request,
|
||
) -> AdminMemoryBucket:
|
||
"""更新 V1 记忆的某个摘要字段(用户画像 / 历史)。"""
|
||
await _require_admin(request)
|
||
if body.section_type == "user" and body.field not in _V1_USER_FIELDS:
|
||
raise HTTPException(status_code=400, detail=f"无效 user 字段:{body.field}")
|
||
if body.section_type == "history" and body.field not in _V1_HISTORY_FIELDS:
|
||
raise HTTPException(status_code=400, detail=f"无效 history 字段:{body.field}")
|
||
if body.section_type not in ("user", "history"):
|
||
raise HTTPException(status_code=400, detail="section_type 须为 'user' 或 'history'")
|
||
|
||
from deerflow.agents.memory.v1.storage import get_memory_storage, utc_now_iso_z
|
||
storage = get_memory_storage()
|
||
data = storage.load(body.agent, user_id=user_id)
|
||
data.setdefault(body.section_type, {})[body.field] = {
|
||
"summary": body.summary,
|
||
"updatedAt": utc_now_iso_z(),
|
||
}
|
||
if not storage.save(data, body.agent, user_id=user_id):
|
||
raise HTTPException(status_code=500, detail="写入失败")
|
||
|
||
bucket_name = "global" if body.agent is None else body.agent
|
||
return AdminMemoryBucket(name=bucket_name, data=_read_v1_memory_bucket(user_id, body.agent))
|
||
|
||
|
||
@router.post("/{user_id}/memory/v1/facts", response_model=AdminMemoryBucket)
|
||
async def admin_add_v1_fact(
|
||
user_id: str,
|
||
body: AdminV1FactAddRequest,
|
||
request: Request,
|
||
) -> AdminMemoryBucket:
|
||
"""新增一条 V1 事实记录。"""
|
||
await _require_admin(request)
|
||
try:
|
||
from deerflow.agents.memory.v1.updater import create_memory_fact
|
||
create_memory_fact(
|
||
body.content,
|
||
category=body.category,
|
||
confidence=body.confidence,
|
||
agent_name=body.agent,
|
||
user_id=user_id,
|
||
)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
except Exception as exc:
|
||
raise HTTPException(status_code=500, detail="新增失败") from exc
|
||
bucket_name = "global" if body.agent is None else body.agent
|
||
return AdminMemoryBucket(name=bucket_name, data=_read_v1_memory_bucket(user_id, body.agent))
|
||
|
||
|
||
@router.put("/{user_id}/memory/v1/facts/{fact_id}", response_model=AdminMemoryBucket)
|
||
async def admin_update_v1_fact(
|
||
user_id: str,
|
||
fact_id: str,
|
||
body: AdminV1FactUpdateRequest,
|
||
request: Request,
|
||
) -> AdminMemoryBucket:
|
||
"""更新一条 V1 事实记录(内容 / 类别 / 置信度)。"""
|
||
await _require_admin(request)
|
||
try:
|
||
from deerflow.agents.memory.v1.updater import update_memory_fact
|
||
update_memory_fact(
|
||
fact_id,
|
||
content=body.content,
|
||
category=body.category,
|
||
confidence=body.confidence,
|
||
agent_name=body.agent,
|
||
user_id=user_id,
|
||
)
|
||
except KeyError:
|
||
raise HTTPException(status_code=404, detail=f"事实 '{fact_id}' 不存在")
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
except Exception as exc:
|
||
raise HTTPException(status_code=500, detail="更新失败") from exc
|
||
bucket_name = "global" if body.agent is None else body.agent
|
||
return AdminMemoryBucket(name=bucket_name, data=_read_v1_memory_bucket(user_id, body.agent))
|
||
|
||
|
||
@router.delete("/{user_id}/memory/v1/facts/{fact_id}", response_model=AdminMemoryBucket)
|
||
async def admin_delete_v1_fact(
|
||
user_id: str,
|
||
fact_id: str,
|
||
request: Request,
|
||
agent: str | None = Query(None, description="Agent id;None 表示用户级全局记忆"),
|
||
) -> AdminMemoryBucket:
|
||
"""删除一条 V1 事实记录。"""
|
||
await _require_admin(request)
|
||
try:
|
||
from deerflow.agents.memory.v1.updater import delete_memory_fact
|
||
delete_memory_fact(fact_id, agent_name=agent, user_id=user_id)
|
||
except KeyError:
|
||
raise HTTPException(status_code=404, detail=f"事实 '{fact_id}' 不存在")
|
||
except Exception as exc:
|
||
raise HTTPException(status_code=500, detail="删除失败") from exc
|
||
bucket_name = "global" if agent is None else agent
|
||
return AdminMemoryBucket(name=bucket_name, data=_read_v1_memory_bucket(user_id, agent))
|