Quellcode durchsuchen

feat: 五大记忆系统对比演示项目(FastAPI + Next.js)

杨一林 vor 1 Monat
Commit
bc08805213

+ 35 - 0
.env.example

@@ -0,0 +1,35 @@
+# ------------------------------
+# App
+# ------------------------------
+APP_ENV=development
+WORKSPACE_ID=local-workspace
+DATA_DIR=./data
+DEMO_FALLBACK=true
+
+# ------------------------------
+# OpenAI-compatible model API
+# ------------------------------
+LLM_BASE_URL=https://api.openai.com/v1
+LLM_API_KEY=
+LLM_MODEL=gpt-4o-mini
+EMBEDDING_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
+EMBEDDING_API_KEY=
+EMBEDDING_MODEL=text-embedding-v4
+EMBEDDING_DIMENSIONS=1536
+EMBEDDING_MIN_SCORE=0.35
+
+# ------------------------------
+# Docker infrastructure
+# ------------------------------
+DATABASE_URL=postgresql://memory:memory@localhost:54329/memory_agents
+REDIS_URL=redis://localhost:6379/0
+
+# ------------------------------
+# Optional real framework services
+# ------------------------------
+LETTA_BASE_URL=http://localhost:8283
+LETTA_API_KEY=
+LETTA_ENABLED=false
+MEM0_ENABLED=false
+REME_ENABLED=true
+MEMU_ENABLED=false

+ 22 - 0
.gitignore

@@ -0,0 +1,22 @@
+.env
+.env.*
+!.env.example
+!.env.local.example
+__pycache__/
+*.py[cod]
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
+.venv*/
+*.egg-info/
+.coverage
+htmlcov/
+logs/
+node_modules/
+.next/
+out/
+data/
+backend/data/
+frontend/.next/
+frontend/.env.local
+.DS_Store

+ 212 - 0
README.md

@@ -0,0 +1,212 @@
+# Memory Agents Lab
+
+五种 Agent 记忆范式的 Web 对比实验台:在同一个 AI 编程助手场景中观察记忆的写入、检索、治理和恢复。**当前完成可验收闭环的是 Text2Mem 和 ReMe**;Mem0、Letta 和 memU 只保留未启用的真实 SDK 适配器骨架。
+
+## 当前实现状态
+
+| 系统 | 当前实现 | 默认状态 |
+|---|---|---|
+| Text2Mem | 内置教学引擎:Encode / Retrieve、记忆分类、去重、混合检索、持久化、删除与审计 | 可直接使用 |
+| ReMe | `reme-ai[core]>=0.4,<0.5`:Markdown + 多查询 BM25 + 可选 Qwen/pgvector 向量召回 | 当前已启用 |
+| Mem0 | `mem0ai` 试验性适配器骨架,尚未完成公共记忆面板、删除和重置闭环 | 未启用 |
+| Letta | `letta-client` 试验性适配器骨架,尚未完成 Agent ID 持久化与记忆投影 | 未启用 |
+| memU | `memu-py` 试验性适配器骨架,尚未完成队列、预算和候选记忆审批闭环 | 未启用 |
+
+未配置或未安装的系统会在 Web 端显示 `unavailable` 和具体配置提示,不会用模拟代码冒充真实框架。
+
+## 运行方式
+
+### 1. 启动基础设施
+
+在项目根目录执行:
+
+```bash
+docker compose up -d
+```
+
+默认服务:
+
+- PostgreSQL + pgvector:`localhost:54329`
+- Redis:`localhost:6379`
+
+### 2. 配置环境变量
+
+```bash
+cp .env.example .env
+```
+
+至少可以先保持模型 API 为空,Text2Mem 仍可用本地证据模式运行;模型服务临时失败时也会自动退回本地证据。接入自然语言回答时填写:
+
+```dotenv
+LLM_BASE_URL=https://api.openai.com/v1
+LLM_API_KEY=your-key
+LLM_MODEL=gpt-4o-mini
+EMBEDDING_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
+EMBEDDING_API_KEY=your-dashscope-key
+EMBEDDING_MODEL=text-embedding-v4
+EMBEDDING_DIMENSIONS=1536
+EMBEDDING_MIN_SCORE=0.35
+```
+
+接口是 OpenAI-compatible 形式,因此可以替换为 DeepSeek、通义、硅基流动、OpenRouter 或本地 Ollama 等服务。不要把真实 API Key 写入代码或提交到 Git。
+
+`EMBEDDING_MIN_SCORE` 是项目 pgvector 召回阈值。当前 `text-embedding-v4` 测试值使用 `0.35`;更换模型后应在测试集上重新标定。
+
+### 3. 启动 FastAPI
+
+当前 ReMe 适配器使用 Python 3.13 环境。推荐用 `uv`:
+
+```bash
+cd backend
+uv venv --python 3.13 .venv-reme
+source .venv-reme/bin/activate
+uv pip install -e '.[reme]'
+uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
+```
+
+如果本机没有 Python 3.13,先执行 `uv python install 3.13`。
+
+后端接口文档:<http://localhost:8000/docs>
+
+### 4. 启动 Next.js
+
+另开终端:
+
+```bash
+cd frontend
+cp .env.local.example .env.local
+npm install
+npm run dev
+```
+
+本机打开:<http://localhost:3000>;局域网设备打开:`http://本机局域网IP:3000`。前端会在每次请求时根据当前页面动态计算同一主机的 `:8000` 后端地址;Next.js 开发服务器也会在启动时自动读取本机网卡地址,不需要写死局域网 IP。
+
+## 框架适配边界
+
+### Mem0(后续项)
+
+Mem0 当前只有试验性调用骨架,不应仅因 `add/search` 能返回就判定为完成。正式启用前还需补齐右侧记忆投影、单条删除、重置、冲突与租户隔离测试。
+
+当前适配器使用官方 Python SDK 的 `Memory.add(..., user_id=...)` 与 `Memory.search(..., user_id=...)` 形状。
+
+```bash
+cd backend
+uv pip install mem0ai
+```
+
+当前在 `.env` 中保持关闭:
+
+```dotenv
+MEM0_ENABLED=false
+```
+
+Mem0 的模型、Embedding 和向量后端请按目标版本官方文档配置;不要假设某个后端或企业功能会自动启用。只有补齐公共记忆投影、删除、重置、冲突和隔离闭环后,才可改为 `true`。
+
+### Letta(后续项)
+
+Letta 当前只有试验性调用骨架。后端重启后的 Agent ID 恢复、Core / Archival / Recall 投影、删除和工具权限尚未实现,因此当前保持关闭。
+
+```bash
+cd backend
+uv pip install letta-client
+```
+
+另外启动目标版本的 Letta 服务端,并设置:
+
+```dotenv
+LETTA_BASE_URL=http://localhost:8283
+LETTA_API_KEY=
+LETTA_ENABLED=false
+```
+
+适配器使用 `client.agents.create(...)` 和 `client.agents.messages.create(...)`。如果 SDK 或服务端版本的接口发生变化,Web 端会显示调用错误,需按目标版本文档更新适配器。
+
+### ReMe(当前启用)
+
+当前适配器使用官方 SDK 依赖和 Python 3.13:
+
+```bash
+cd backend
+source .venv-reme/bin/activate
+uv pip install -e '.[reme]'
+```
+
+回到本项目后:
+
+```dotenv
+REME_ENABLED=true
+```
+
+适配器使用当前官方 `reme.reme.ReMe`,通过 `auto_memory` 写入 Markdown 文件,再通过 `reindex` 完成索引。ReMe 0.4.1.0 的 `search` 能支持向量 + BM25/RRF,但项目当前保持其内部 `embedding_store` 为空,实际使用多查询 BM25;配置 Qwen Embedding 后,再由项目的 pgvector 索引提供向量召回。两路结果去重后,模型只依据证据回答。
+
+ReMe 只对稳定偏好、项目事实、进度变化、可复用故障经验和长期流程规则运行 `auto_memory`;普通提问只检索,不写入长期记忆。右侧面板的一项代表一个 Markdown 记忆文件,一个文件可包含多条事实;删除按钮删除的是整个文件。
+
+为避免本地实验在无人操作时修改文件或产生模型费用,适配器关闭了 ReMe 默认文件监听、资源整理、摘要监听和 Dream 定时任务的自动调度;每次聊天仍会显式执行 `reindex`。需要扩展能力时应显式接入对应 Job,当前不会在后台主动整理或推送。
+
+### memU(后续项)
+
+memU 当前只有试验性调用骨架,队列、重试、预算、同意与主动触达关闭开关尚未实现,因此当前保持关闭。
+
+memU 官方仓库当前建议从源码安装,并要求较新的 Python 版本。请根据官方仓库锁定 commit:
+
+```bash
+git clone https://github.com/NevaMind-AI/memU.git /tmp/memU
+cd /tmp/memU
+git rev-parse HEAD
+uv pip install -e .
+```
+
+回到本项目后仍保持关闭:
+
+```dotenv
+MEMU_ENABLED=false
+```
+
+后续适配器目标是调用 `memorize()` 与 `retrieve()`,并把兴趣推断视为待审批候选记忆,不执行主动触达;当前尚未形成可验收闭环。
+
+## 记忆治理边界
+
+- 当前是单工作区模式,默认 `WORKSPACE_ID=local-workspace`。
+- 数据模型保留 `workspace_id`,为未来登录和多租户隔离预留。
+- Text2Mem 的写入带有 IR、来源、置信度、时间和审计信息。
+- `/api/reset` 已对 Text2Mem 和 ReMe 完成闭环;未启用适配器不得据此假定外部框架数据也已删除。
+- 主动推送默认关闭。
+- 模型生成的推断不能自动伪装成用户事实。
+
+## API
+
+```text
+GET  /api/health
+GET  /api/systems
+POST /api/chat
+GET  /api/memories?system=text2mem
+DELETE /api/memories/{memory_id}?system=text2mem
+GET  /api/audit?system=text2mem
+POST /api/reset
+```
+
+## 测试
+
+```bash
+cd backend
+source .venv-reme/bin/activate
+uv pip install "pytest>=8.3,<9" "pytest-asyncio>=0.25,<1" "ruff>=0.9,<1"
+python -m pytest -q
+ruff check app tests
+```
+
+前端检查:
+
+```bash
+cd frontend
+npm run build
+```
+
+## 官方资料核验入口
+
+- Mem0 Python Quickstart:<https://docs.mem0.ai/open-source/python-quickstart>
+- Letta Python SDK:<https://docs.letta.com/api/python>
+- ReMe 官方仓库:<https://github.com/agentscope-ai/ReMe>
+- memU 官方仓库:<https://github.com/NevaMind-AI/memU>
+
+这些项目的 API、安装方式、Python 要求和后端支持会变化。每次启用可选适配器时,都应把版本或 commit 记录到本项目的环境记录中。

+ 1 - 0
backend/app/__init__.py

@@ -0,0 +1 @@
+

+ 1 - 0
backend/app/adapters/__init__.py

@@ -0,0 +1 @@
+

+ 65 - 0
backend/app/adapters/base.py

@@ -0,0 +1,65 @@
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from typing import Any
+
+from ..config import settings
+from ..db import repository, utcnow
+from ..llm import llm
+from ..schemas import AuditEvent, ChatResponse, MemoryItem, SystemDescriptor
+
+
+class AdapterUnavailable(RuntimeError):
+    pass
+
+
+class MemoryAgent(ABC):
+    id: str
+    descriptor: SystemDescriptor
+
+    def __init__(self) -> None:
+        self.repo = repository
+        self.llm = llm
+
+    @property
+    def status(self) -> SystemDescriptor:
+        return self.descriptor
+
+    async def memories(self, query: str | None = None) -> list[MemoryItem]:
+        return await self.repo.list_memories(self.id, query)
+
+    async def audit(self) -> list[AuditEvent]:
+        return await self.repo.list_audit(self.id)
+
+    async def reset(self) -> None:
+        await self.repo.reset(self.id)
+
+    async def delete_memory(self, memory_id: str) -> bool:
+        deleted = await self.repo.delete_memory(self.id, memory_id)
+        if deleted:
+            await self.repo.delete_embedding(self.id, memory_id)
+            await self._audit("DELETE/Memory", target=memory_id)
+        return deleted
+
+    async def _audit(
+        self,
+        operation: str,
+        target: str | None = None,
+        status: str = "ok",
+        details: dict[str, Any] | None = None,
+    ) -> AuditEvent:
+        event = AuditEvent(
+            id=self.repo.memory_id("audit"),
+            system=self.id,
+            workspace_id=settings.workspace_id,
+            operation=operation,
+            target=target,
+            status=status,
+            details=details or {},
+            created_at=utcnow(),
+        )
+        return await self.repo.add_audit(event)
+
+    @abstractmethod
+    async def chat(self, message: str) -> ChatResponse:
+        raise NotImplementedError

+ 78 - 0
backend/app/adapters/letta.py

@@ -0,0 +1,78 @@
+from __future__ import annotations
+
+from typing import Any
+
+from ..config import settings
+from ..schemas import ChatResponse, SystemDescriptor
+from .base import AdapterUnavailable, MemoryAgent
+
+
+class LettaAgent(MemoryAgent):
+    id = "letta"
+
+    def __init__(self) -> None:
+        super().__init__()
+        self._client: Any | None = None
+        self._agent_id: str | None = None
+        self._import_error: str | None = None
+        try:
+            from letta_client import Letta  # type: ignore
+
+            self._client_class = Letta
+        except Exception as exc:
+            self._client_class = None
+            self._import_error = str(exc)
+        ready = bool(self._client_class and settings.letta_enabled)
+        self.descriptor = SystemDescriptor(
+            id="letta",
+            name="Letta",
+            paradigm="有状态 Agent 与虚拟内存",
+            description="用 Core、Archival、Recall 三层状态,让 Agent 在有限上下文和外部记忆间协作。",
+            available=ready,
+            mode="real-sdk" if ready else "unavailable",
+            status="ready" if ready else "not-configured",
+            package="letta-client",
+            setup_hint=None if ready else (
+                "当前仅保留 Letta 适配骨架,请保持 LETTA_ENABLED=false。"
+                "完成 Agent ID 恢复、分层记忆投影与删除闭环后再启用。"
+            ),
+        )
+
+    def _ensure_client(self) -> Any:
+        if not self._client_class or not settings.letta_enabled:
+            raise AdapterUnavailable(self.descriptor.setup_hint or "Letta SDK 不可用")
+        if self._client is None:
+            kwargs = {"base_url": settings.letta_base_url}
+            if settings.letta_api_key:
+                kwargs["token"] = settings.letta_api_key
+            self._client = self._client_class(**kwargs)
+        return self._client
+
+    async def chat(self, message: str) -> ChatResponse:
+        client = self._ensure_client()
+        try:
+            if self._agent_id is None:
+                agent = client.agents.create(
+                    name="memory-agents-solutions",
+                    memory_blocks=[
+                        {"label": "persona", "value": "你是一个严谨的 AI 编程助手。"},
+                        {"label": "workspace", "value": f"工作区:{settings.workspace_id}"},
+                    ],
+                )
+                self._agent_id = agent.id
+            response = client.agents.messages.create(
+                agent_id=self._agent_id,
+                messages=[{"role": "user", "content": message}],
+            )
+            if hasattr(response, "__await__"):
+                response = await response
+            answer = str(response)
+        except Exception as exc:
+            await self._audit("LETTA/message", status="error", details={"error": str(exc)})
+            raise AdapterUnavailable(f"Letta 调用失败:{exc}") from exc
+        await self._audit("LETTA/message", details={"agent_id": self._agent_id})
+        return ChatResponse(
+            system="letta", answer=answer, mode="real-sdk",
+            memory_context=await self.memories(), memory_events=[{"event": "agent_message", "agent_id": self._agent_id}],
+            audit_events=await self.audit(),
+        )

+ 104 - 0
backend/app/adapters/mem0.py

@@ -0,0 +1,104 @@
+from __future__ import annotations
+
+from typing import Any
+
+from ..config import settings
+from ..llm import LLMUnavailable
+from ..schemas import ChatResponse, SystemDescriptor
+from .base import AdapterUnavailable, MemoryAgent
+
+
+class Mem0Agent(MemoryAgent):
+    id = "mem0"
+
+    def __init__(self) -> None:
+        super().__init__()
+        self._memory: Any | None = None
+        self._import_error: str | None = None
+        try:
+            from mem0 import Memory  # type: ignore
+
+            self._memory_class = Memory
+        except Exception as exc:
+            self._memory_class = None
+            self._import_error = str(exc)
+        embedding_ready = bool(
+            settings.embedding_api_key
+            and settings.embedding_base_url
+            and settings.embedding_model
+        )
+        ready = bool(self._memory_class and settings.mem0_enabled and embedding_ready)
+        self.descriptor = SystemDescriptor(
+            id="mem0",
+            name="Mem0",
+            paradigm="自动提取与冲突消解",
+            description="将对话提炼为长期记忆,并按逻辑用户标识执行检索和更新。",
+            available=ready,
+            mode="real-sdk" if ready else "unavailable",
+            status="ready" if ready else "not-configured",
+            package="mem0ai",
+            setup_hint=None if ready else (
+                "当前仅保留 Mem0 适配骨架,请保持 MEM0_ENABLED=false。"
+                "完成公共记忆投影、删除、重置、冲突和隔离闭环后再启用。"
+            ),
+        )
+
+    async def _client(self) -> Any:
+        if not self._memory_class or not settings.mem0_enabled:
+            raise AdapterUnavailable(self.descriptor.setup_hint or "Mem0 不可用")
+        if self._memory is None:
+            if not settings.embedding_api_key or not settings.embedding_model:
+                raise AdapterUnavailable(self.descriptor.setup_hint or "Mem0 缺少 Embedding 配置")
+            llm_provider = "deepseek" if "deepseek" in settings.llm_base_url.lower() else "openai"
+            llm_config = {"model": settings.llm_model, "api_key": settings.llm_api_key}
+            llm_config["deepseek_base_url" if llm_provider == "deepseek" else "openai_base_url"] = settings.llm_base_url
+            self._memory = self._memory_class.from_config({
+                "llm": {"provider": llm_provider, "config": llm_config},
+                "embedder": {
+                    "provider": "openai",
+                    "config": {
+                        "model": settings.embedding_model,
+                        "api_key": settings.embedding_api_key,
+                        "openai_base_url": settings.embedding_base_url,
+                        "embedding_dims": settings.embedding_dimensions,
+                    },
+                },
+                "vector_store": {
+                    "provider": "pgvector",
+                    "config": {
+                        "connection_string": settings.database_url,
+                        "collection_name": "mem0_memory",
+                        "embedding_model_dims": settings.embedding_dimensions,
+                    },
+                },
+            })
+        return self._memory
+
+    async def chat(self, message: str) -> ChatResponse:
+        memory = await self._client()
+        add_result = memory.add(
+            [{"role": "user", "content": message}],
+            user_id=settings.workspace_id,
+        )
+        if hasattr(add_result, "__await__"):
+            add_result = await add_result
+        search_result = memory.search(message, user_id=settings.workspace_id, limit=5)
+        if hasattr(search_result, "__await__"):
+            search_result = await search_result
+        context = search_result.get("results", search_result if isinstance(search_result, list) else [])
+        context_text = "\n".join(f"- {item.get('memory', item)}" for item in context)
+        try:
+            answer = await self.llm.chat(
+                "你是使用 Mem0 记忆的 AI 编程助手。只把 Mem0 返回的内容当作记忆证据。",
+                f"Mem0 召回:\n{context_text or '(暂无)'}\n\n用户:{message}",
+            )
+            mode = "real-sdk+llm"
+        except LLMUnavailable:
+            answer = f"Mem0 已完成写入和检索。\n\n召回内容:\n{context_text or '(暂无)'}"
+            mode = "real-sdk"
+        await self._audit("MEM0/add+search", details={"raw_add": str(add_result)[:1000]})
+        return ChatResponse(
+            system="mem0", answer=answer, mode=mode,
+            memory_context=await self.memories(), memory_events=[{"event": "add", "result": add_result}],
+            audit_events=await self.audit(),
+        )

+ 113 - 0
backend/app/adapters/memu.py

@@ -0,0 +1,113 @@
+from __future__ import annotations
+
+import json
+from typing import Any
+from uuid import uuid4
+
+from ..config import settings
+from ..schemas import ChatResponse, SystemDescriptor
+from .base import AdapterUnavailable, MemoryAgent
+
+
+class MemUAgent(MemoryAgent):
+    id = "memu"
+
+    def __init__(self) -> None:
+        super().__init__()
+        self._service: Any | None = None
+        self._import_error: str | None = None
+        try:
+            from memu import MemoryService, MemUService  # type: ignore
+
+            self._service_classes = (MemoryService, MemUService)
+        except Exception as exc:
+            try:
+                from memu import MemoryService  # type: ignore
+
+                self._service_classes = (MemoryService,)
+            except Exception:
+                self._service_classes = ()
+                self._import_error = str(exc)
+        embedding_ready = bool(
+            settings.embedding_api_key
+            and settings.embedding_base_url
+            and settings.embedding_model
+        )
+        ready = bool(self._service_classes and settings.memu_enabled and embedding_ready)
+        self.descriptor = SystemDescriptor(
+            id="memu",
+            name="memU",
+            paradigm="主动式异步记忆管线",
+            description="把对话摄入、意图提取、候选更新和主动检索拆成后台记忆服务。",
+            available=ready,
+            mode="real-sdk" if ready else "unavailable",
+            status="ready" if ready else "not-configured",
+            package="memU 官方源代码",
+            setup_hint=None if ready else (
+                "当前仅保留 memU 适配骨架,请保持 MEMU_ENABLED=false。"
+                "完成队列、预算、审批和停止开关后再启用。"
+            ),
+        )
+
+    async def _ensure(self) -> Any:
+        if not self._service_classes or not settings.memu_enabled:
+            raise AdapterUnavailable(self.descriptor.setup_hint or "memU 不可用")
+        if self._service is None:
+            service_class = self._service_classes[0]
+            self._service = service_class(
+                llm_profiles={
+                    "default": {
+                        "provider": "openai",
+                        "base_url": settings.llm_base_url,
+                        "api_key": settings.llm_api_key,
+                        "chat_model": settings.llm_model,
+                        "client_backend": "sdk",
+                    },
+                    "embedding": {
+                        "provider": "openai",
+                        "base_url": settings.embedding_base_url,
+                        "api_key": settings.embedding_api_key,
+                        "embed_model": settings.embedding_model,
+                        "client_backend": "sdk",
+                    },
+                },
+                database_config={
+                    "metadata_store": {
+                        "provider": "sqlite",
+                        "dsn": f"sqlite:///{settings.data_dir / 'memu.sqlite3'}",
+                    },
+                },
+            )
+        return self._service
+
+    async def chat(self, message: str) -> ChatResponse:
+        service = await self._ensure()
+        conversation_dir = settings.data_dir / "memu" / "conversations"
+        conversation_dir.mkdir(parents=True, exist_ok=True)
+        resource_path = conversation_dir / f"{uuid4().hex}.json"
+        resource_path.write_text(
+            json.dumps([{"role": "user", "content": message}], ensure_ascii=False),
+            encoding="utf-8",
+        )
+        memorize = service.memorize(
+            resource_url=str(resource_path),
+            modality="conversation",
+            user={"user_id": settings.workspace_id, "content": message},
+        )
+        if hasattr(memorize, "__await__"):
+            await memorize
+        context = service.retrieve(
+            queries=[{"role": "user", "content": {"text": message}}],
+            where={"user_id": settings.workspace_id},
+        )
+        if hasattr(context, "__await__"):
+            context = await context
+        await self._audit("MEMU/memorize+retrieve", details={"context": str(context)[:1200]})
+        return ChatResponse(
+            system="memu",
+            answer=f"memU 已完成异步记忆摄入与主动检索。\n\n当前上下文:\n{context}",
+            mode="real-sdk",
+            memory_context=await self.memories(),
+            memory_events=[{"event": "memorize", "candidate": True, "context": str(context)[:2000]}],
+            audit_events=await self.audit(),
+        )

+ 411 - 0
backend/app/adapters/reme.py

@@ -0,0 +1,411 @@
+from __future__ import annotations
+
+import hashlib
+import re
+from datetime import datetime, timezone
+from typing import Any
+
+from ..embeddings import embedding_client, embedding_fingerprint
+from ..config import settings
+from ..schemas import ChatResponse, MemoryItem, SystemDescriptor
+from .base import AdapterUnavailable, MemoryAgent
+
+
+class ReMeAgent(MemoryAgent):
+    id = "reme"
+    _PASSIVE_JOB_OVERRIDES = {
+        # ReMe starts these as background/cron jobs by default. This local lab
+        # keeps memory work request-driven to avoid silent file changes or LLM
+        # cost; chat() performs an explicit reindex before every retrieval.
+        "index_update_loop": {"backend": "base", "enable_serve": False},
+        "resource_watch_loop": {"backend": "base", "enable_serve": False},
+        "digest_watch_loop": {"backend": "base", "enable_serve": False},
+        "dream_cron": {"backend": "base", "enable_serve": False},
+    }
+
+    # These lightweight expansions strengthen ReMe's BM25 branch. The project
+    # intentionally leaves ReMe's internal embedding_store disabled and uses
+    # pgvector as the separate Embedding retrieval signal when configured.
+    _QUERY_EXPANSIONS = {
+        "部署": ("deploy", "发布", "上线", "测试环境", "索引构建", "批次"),
+        "上线": ("deploy", "发布", "测试环境", "索引构建"),
+        "问题": ("超时", "错误", "失败", "异常", "报错", "故障", "冲突"),
+        "故障": ("超时", "错误", "失败", "异常", "报错", "冲突"),
+        "错误": ("失败", "异常", "报错", "故障"),
+        "超时": ("timeout", "索引", "构建", "分批", "批次"),
+        "连接": ("connection", "connect", "API", "数据库", "服务"),
+        "索引": ("index", "向量", "构建", "分批", "批次"),
+        "检索": ("retrieval", "RAG", "向量", "关键词", "重排"),
+        "知识库": ("RAG", "文档", "向量", "检索", "重排"),
+        "上次": ("之前", "最近", "历史"),
+    }
+    _QUERY_STOPWORDS = frozenset(
+        {
+            "什么",
+            "哪些",
+            "哪个",
+            "怎么",
+            "如何",
+            "是否",
+            "有没有",
+            "请问",
+            "告诉我",
+            "根据",
+            "当前",
+            "相关",
+            "一下",
+            "遇到",
+            "遇到了",
+            "发生",
+            "发生了",
+            "的",
+            "吗",
+            "呢",
+            "?",
+            "?",
+        }
+    )
+    _EXPLICIT_WRITE_RE = re.compile(r"记住|记下|请记录|请保存")
+    _DECLARATIVE_RE = re.compile(
+        r"我(?:偏好|喜欢|习惯|要求|正在|在开发|决定|选择|使用)|"
+        r"项目(?:目前|现在|已经|使用|采用|数据库|前端|后端)|"
+        r"(?:前端|后端|数据库|对话模型|向量模型)(?:使用|采用|选择|是|改成)|"
+        r"更新一下|以后|上次|之前|曾经|长期规则|代码要求"
+    )
+    _QUESTION_RE = re.compile(
+        r"[??]$|(?:什么|哪些|哪个|怎么|如何|是否|有没有|多少|为什么).*(?:[??]|$)"
+    )
+
+    def __init__(self) -> None:
+        super().__init__()
+        self._service: Any | None = None
+        try:
+            from reme.reme import ReMe  # type: ignore
+            from reme.config import resolve_app_config  # type: ignore
+
+            self._class = ReMe
+            self._resolve_config = resolve_app_config
+        except Exception:
+            self._class = None
+            self._resolve_config = None
+        ready = bool(self._class and settings.reme_enabled)
+        self.descriptor = SystemDescriptor(
+            id="reme",
+            name="ReMe",
+            paradigm="文件即记忆",
+            description="把长期记忆组织为可读 Markdown 文件,并融合 ReMe 检索与 pgvector 召回。",
+            available=ready,
+            mode="real-sdk" if ready else "unavailable",
+            status="ready" if ready else "not-configured",
+            package="reme-ai[core]>=0.4,<0.5",
+            setup_hint=None if ready else "安装 reme-ai[core],并设置 REME_ENABLED=true。",
+        )
+
+    async def memories(self, query: str | None = None) -> list[MemoryItem]:
+        """Expose ReMe's Markdown files in the common inspector contract."""
+        root = settings.data_dir / "reme"
+        if not root.exists():
+            return []
+        items: list[MemoryItem] = []
+        for path in sorted(root.glob("daily/**/*.md")):
+            content = path.read_text(encoding="utf-8", errors="replace").strip()
+            if not content:
+                continue
+            if query and query.lower() not in f"{path} {content}".lower():
+                continue
+            updated = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
+            digest = hashlib.sha1(str(path).encode("utf-8")).hexdigest()[:16]
+            items.append(MemoryItem(
+                id=f"reme_{digest}",
+                system=self.id,
+                workspace_id=settings.workspace_id,
+                content=content,
+                memory_type="file-memory",
+                source=str(path.relative_to(root)),
+                confidence=1.0,
+                created_at=updated,
+                updated_at=updated,
+                metadata={"path": str(path.relative_to(root)), "retrieval": "hybrid"},
+            ))
+        return items
+
+    async def delete_memory(self, memory_id: str) -> bool:
+        root = settings.data_dir / "reme"
+        deleted = False
+        for path in root.glob("daily/**/*.md"):
+            digest = hashlib.sha1(str(path).encode("utf-8")).hexdigest()[:16]
+            if f"reme_{digest}" != memory_id:
+                continue
+            path.unlink(missing_ok=True)
+            deleted = True
+            break
+        if deleted:
+            await self.repo.delete_embedding(self.id, memory_id)
+        if deleted and self._class and settings.reme_enabled:
+            service = await self._ensure()
+            await service.run_job("reindex")
+        if deleted:
+            await self._audit("DELETE/FileMemory", target=memory_id)
+        return deleted
+
+    async def _ensure(self) -> Any:
+        if not self._class or not settings.reme_enabled:
+            raise AdapterUnavailable(self.descriptor.setup_hint or "ReMe 不可用")
+        if self._service is None:
+            working_dir = str(settings.data_dir / "reme")
+            config = self._resolve_config(
+                log_config=False,
+                workspace_dir=working_dir,
+                enable_logo=False,
+                log_to_console=False,
+                service={"backend": "http"},
+                jobs=self._PASSIVE_JOB_OVERRIDES,
+            )
+            self._service = self._class(**config)
+            await self._service.start()
+        return self._service
+
+    @classmethod
+    def _should_memorize(cls, message: str) -> bool:
+        normalized = " ".join(message.strip().split())
+        if not normalized:
+            return False
+        if cls._EXPLICIT_WRITE_RE.search(normalized):
+            return True
+        if cls._QUESTION_RE.search(normalized):
+            return False
+        return bool(cls._DECLARATIVE_RE.search(normalized))
+
+    @classmethod
+    def _heuristic_queries(cls, message: str) -> list[str]:
+        """Build short queries for ReMe's file search."""
+        normalized = message.strip()
+        queries: list[str] = [normalized] if normalized else []
+
+        # Preserve explicit Latin/domain tokens such as RAG, FastAPI and
+        # PostgreSQL; ReMe's tokenizer can match these reliably.
+        queries.extend(re.findall(r"[A-Za-z][A-Za-z0-9_.:/-]{1,}", normalized))
+
+        for trigger, expansions in cls._QUERY_EXPANSIONS.items():
+            if trigger in normalized:
+                queries.extend((trigger, *expansions))
+
+        # Add short Chinese chunks as a fallback, while excluding question
+        # words that add noise to a lexical search.
+        for chunk in re.findall(r"[\u4e00-\u9fff]{2,}", normalized):
+            if chunk not in cls._QUERY_STOPWORDS:
+                queries.append(chunk)
+                if len(chunk) > 2:
+                    queries.extend(
+                        chunk[index : index + 2]
+                        for index in range(len(chunk) - 1)
+                        if chunk[index : index + 2] not in cls._QUERY_STOPWORDS
+                    )
+
+        return cls._dedupe_queries(queries)
+
+    @staticmethod
+    def _dedupe_queries(queries: list[str]) -> list[str]:
+        unique: list[str] = []
+        seen: set[str] = set()
+        for query in queries:
+            cleaned = " ".join(str(query).strip().split())
+            if not cleaned:
+                continue
+            key = cleaned.casefold()
+            if key in seen:
+                continue
+            seen.add(key)
+            unique.append(cleaned)
+        return unique[:12]
+
+    async def _rewrite_query(self, message: str) -> list[str]:
+        """Combine deterministic expansions with optional LLM query rewrite."""
+        queries = self._heuristic_queries(message)
+        try:
+            rewritten = await self.llm.json(
+                (
+                    "你是记忆检索查询改写器。只输出 JSON,不要回答用户问题。"
+                    "把用户问题改写成 2 到 5 个适合混合文件检索的短查询,"
+                    "保留专有名词,并补充可能出现在记忆里的中英文同义词。"
+                    '格式必须是 {"queries": ["..."]}。'
+                ),
+                f"用户问题:{message}",
+            )
+            generated = rewritten.get("queries", [])
+            if isinstance(generated, list):
+                queries.extend(str(item) for item in generated if str(item).strip())
+        except Exception:
+            # ReMe remains usable when the optional rewrite call fails. The
+            # deterministic expansions above cover the common engineering terms.
+            pass
+        return self._dedupe_queries(queries)
+
+    @staticmethod
+    def _merge_search_results(results: list[str]) -> str:
+        """Merge duplicate snippets returned by multiple ReMe search queries."""
+        merged: list[str] = []
+        seen: set[str] = set()
+        for result in results:
+            for block in re.split(r"(?=^========== )", result.strip(), flags=re.MULTILINE):
+                block = block.strip()
+                if not block:
+                    continue
+                fingerprint_source = block
+                if block.startswith("==========") and "\n" in block:
+                    fingerprint_source = block.split("\n", 1)[1]
+                fingerprint = re.sub(r"\[(?:vector_)?score=[^\]]+\]", "", fingerprint_source)
+                fingerprint = " ".join(fingerprint.split()).casefold()
+                if fingerprint in seen:
+                    continue
+                seen.add(fingerprint)
+                merged.append(block)
+        return "\n\n".join(merged)
+
+    async def _search_memory(self, service: Any, queries: list[str]) -> tuple[str, list[str]]:
+        results: list[str] = []
+        used_queries: list[str] = []
+        for query in queries:
+            search_result = await service.run_job("search", query=query, limit=5)
+            answer = str(getattr(search_result, "answer", "") or "").strip()
+            if answer:
+                results.append(answer)
+                used_queries.append(query)
+        return self._merge_search_results(results), used_queries
+
+    async def _sync_embeddings(self) -> dict[str, Any]:
+        """Embed changed ReMe files and remove vectors for deleted files."""
+        if not embedding_client.configured:
+            return {"enabled": False, "embedded": 0, "removed": 0}
+
+        items = await self.memories()
+        current_ids = {item.id for item in items}
+        previous_hashes = await self.repo.embedding_hashes(self.id)
+        stale_ids = set(previous_hashes) - current_ids
+        for memory_id in stale_ids:
+            await self.repo.delete_embedding(self.id, memory_id)
+
+        pending = [
+            item
+            for item in items
+            if previous_hashes.get(item.id) != embedding_fingerprint(item.content)
+        ]
+        if not pending:
+            return {"enabled": True, "embedded": 0, "removed": len(stale_ids)}
+
+        vectors = await embedding_client.embed([item.content for item in pending])
+        for item, vector in zip(pending, vectors):
+            await self.repo.upsert_embedding(
+                system=self.id,
+                memory_id=item.id,
+                content=item.content,
+                source=item.source,
+                content_hash=embedding_fingerprint(item.content),
+                embedding=vector,
+            )
+        return {"enabled": True, "embedded": len(pending), "removed": len(stale_ids)}
+
+    async def _semantic_search(self, message: str) -> tuple[str, int]:
+        if not embedding_client.configured:
+            return "", 0
+        try:
+            query_vector = (await embedding_client.embed([message]))[0]
+            matches = await self.repo.search_embeddings(self.id, query_vector, limit=5)
+        except Exception:
+            return "", 0
+        matches = [
+            match
+            for match in matches
+            if float(match.get("score", 0.0)) >= settings.embedding_min_score
+        ]
+        blocks = [
+            (
+                f"========== {match['source']} [vector_score={float(match['score']):.4f}] =========="
+                f"\n{match['content']}"
+            )
+            for match in matches
+        ]
+        return self._merge_search_results(blocks), len(matches)
+
+    async def _answer_from_memory(self, message: str, evidence: str) -> str:
+        if not evidence:
+            return "没有检索到与这个问题相关的历史记忆。"
+        try:
+            return await self.llm.chat(
+                (
+                    "你是一个使用文件记忆的 AI 助手。"
+                    "只能依据提供的记忆证据回答,不要编造证据中没有的事实。"
+                    "如果证据不足,要明确说明不确定。直接回答用户问题,简洁自然。"
+                ),
+                f"用户问题:{message}\n\n记忆证据:\n{evidence}",
+            )
+        except Exception:
+            return f"根据检索到的记忆:\n\n{evidence}"
+
+    async def chat(self, message: str) -> ChatResponse:
+        service = await self._ensure()
+        write_candidate = self._should_memorize(message)
+        memory_result: Any | None = None
+        if write_candidate:
+            memory_result = await service.run_job(
+                "auto_memory",
+                messages=[{"name": "user", "role": "user", "content": message}],
+                session_id=settings.workspace_id,
+                memory_hint=(
+                    "只记录稳定偏好、项目事实、进度变化、可复用的故障经验和长期流程规则。"
+                    "问题、寒暄和临时请求不应写入长期记忆。"
+                ),
+            )
+        reindex_result = await service.run_job("reindex")
+        try:
+            embedding_sync = await self._sync_embeddings()
+        except Exception as exc:
+            # A temporary Embedding outage should not take down file-memory
+            # retrieval; ReMe's built-in file search remains available.
+            embedding_sync = {"enabled": True, "embedded": 0, "removed": 0, "error": str(exc)}
+        queries = await self._rewrite_query(message)
+        lexical_search, used_queries = await self._search_memory(service, queries)
+        semantic_search, semantic_count = await self._semantic_search(message)
+        search = self._merge_search_results([lexical_search, semantic_search])
+        answer = await self._answer_from_memory(message, search)
+        await self._audit(
+            "REME/auto_memory+search",
+            details={
+                "write_candidate": write_candidate,
+                "memory": str(getattr(memory_result, "answer", "skipped"))[:800],
+                "reindex": str(reindex_result.metadata)[:800],
+                "embedding_sync": embedding_sync,
+                "queries": queries,
+                "used_queries": used_queries,
+                "semantic_results": semantic_count,
+                "search": str(search)[:1000],
+            },
+        )
+        return ChatResponse(
+            system="reme",
+            answer=answer,
+            mode="real-sdk",
+            memory_context=await self.memories(),
+            memory_events=[
+                {
+                    "event": "file-memory",
+                    "write_candidate": write_candidate,
+                    "queries": queries,
+                    "retrieval": "hybrid",
+                    "semantic_results": semantic_count,
+                    "result": str(search)[:2000],
+                }
+            ],
+            audit_events=await self.audit(),
+        )
+
+    async def reset(self) -> None:
+        await super().reset()
+        if self._service is not None:
+            await self._service.close()
+            self._service = None
+        working_dir = settings.data_dir / "reme"
+        if working_dir.exists():
+            import shutil
+
+            shutil.rmtree(working_dir)

+ 306 - 0
backend/app/adapters/text2mem.py

@@ -0,0 +1,306 @@
+from __future__ import annotations
+
+import re
+from typing import Any
+
+from ..config import settings
+from ..db import utcnow
+from ..embeddings import embedding_client, embedding_fingerprint
+from ..llm import LLMUnavailable
+from ..schemas import ChatResponse, MemoryItem, SystemDescriptor
+from .base import MemoryAgent
+
+
+class Text2MemAgent(MemoryAgent):
+    id = "text2mem"
+
+    _EXPLICIT_WRITE_RE = re.compile(r"记住|记下|请记录|请保存")
+    _DECLARATIVE_RE = re.compile(
+        r"我(?:偏好|喜欢|习惯|要求|正在|在开发|决定|选择|使用)|"
+        r"项目(?:目前|现在|已经|使用|采用|数据库|前端|后端)|"
+        r"(?:前端|后端|数据库|对话模型|向量模型)(?:使用|采用|选择|是|改成)|"
+        r"更新一下|以后|上次|之前|曾经|长期规则|代码要求"
+    )
+    _QUESTION_RE = re.compile(
+        r"[??]$|(?:什么|哪些|哪个|怎么|如何|是否|有没有|多少|为什么).*(?:[??]|$)"
+    )
+    _STOPWORDS = frozenset(
+        {
+            "什么",
+            "哪些",
+            "哪个",
+            "怎么",
+            "如何",
+            "是否",
+            "有没有",
+            "请问",
+            "告诉我",
+            "我的",
+            "当前",
+            "相关",
+            "一下",
+            "遇到",
+            "遇到了",
+            "的",
+            "吗",
+            "呢",
+        }
+    )
+
+    def __init__(self) -> None:
+        super().__init__()
+        self.descriptor = SystemDescriptor(
+            id="text2mem",
+            name="Text2Mem",
+            paradigm="IR 操作契约",
+            description=(
+                "当前实现 Encode / Retrieve、持久化、删除与审计;"
+                "Update / Lock / Expire 等完整 IR 操作仍是后续项。"
+            ),
+            available=True,
+            mode="native",
+            status="ready",
+            package="本项目内置教学实现",
+        )
+
+    @classmethod
+    def _should_encode(cls, message: str) -> bool:
+        normalized = " ".join(message.strip().split())
+        if not normalized:
+            return False
+        if cls._EXPLICIT_WRITE_RE.search(normalized):
+            return True
+        if cls._QUESTION_RE.search(normalized):
+            return False
+        return bool(cls._DECLARATIVE_RE.search(normalized))
+
+    @staticmethod
+    def _memory_type(content: str) -> str:
+        if re.search(r"上次|之前|曾经|遇到|发生|故障|失败", content):
+            return "episodic"
+        if re.search(r"流程|步骤|先.+再|以后.+按", content):
+            return "procedural"
+        if re.search(r"更新一下|进度|已完成|完成了|下一步|当前在做", content):
+            return "task-state"
+        return "semantic"
+
+    @classmethod
+    def _query_type(cls, message: str) -> str | None:
+        if re.search(r"流程|步骤|怎么修复|如何修复|操作方法", message):
+            return "procedural"
+        if re.search(r"上次|之前|历史|曾经|遇到|发生|故障", message):
+            return "episodic"
+        if re.search(r"进度|完成|下一步|现在做到", message):
+            return "task-state"
+        if re.search(r"偏好|喜欢|技术栈|数据库|前端|后端|项目", message):
+            return "semantic"
+        return None
+
+    @classmethod
+    def _tokens(cls, text: str) -> set[str]:
+        tokens = {item.casefold() for item in re.findall(r"[A-Za-z][A-Za-z0-9_.+-]{1,}", text)}
+        for chunk in re.findall(r"[\u4e00-\u9fff]{2,}", text):
+            if chunk not in cls._STOPWORDS:
+                tokens.add(chunk)
+            if len(chunk) > 2:
+                tokens.update(
+                    chunk[index : index + 2]
+                    for index in range(len(chunk) - 1)
+                    if chunk[index : index + 2] not in cls._STOPWORDS
+                )
+        return tokens
+
+    async def _encode(self, content: str) -> tuple[MemoryItem, bool]:
+        normalized = " ".join(content.strip().split()).casefold()
+        for existing in await self.repo.list_memories(self.id):
+            if " ".join(existing.content.strip().split()).casefold() == normalized:
+                await self._audit(
+                    "ENC/Encode",
+                    existing.id,
+                    status="deduplicated",
+                    details={"source": "user_direct"},
+                )
+                return existing, False
+
+        now = utcnow()
+        item = MemoryItem(
+            id=self.repo.memory_id("t2m"),
+            system=self.id,
+            workspace_id=settings.workspace_id,
+            content=content.strip(),
+            memory_type=self._memory_type(content),
+            source="user_direct",
+            confidence=1.0,
+            valid_from=now,
+            created_at=now,
+            updated_at=now,
+            metadata={
+                "ir": {"stage": "ENC", "op": "Encode"},
+                "policy": {"confirmation": True, "locked": False},
+            },
+        )
+        await self.repo.add_memory(item)
+        await self._audit(
+            "ENC/Encode",
+            item.id,
+            details={"source": "user_direct", "memory_type": item.memory_type},
+        )
+        return item, True
+
+    async def _sync_embeddings(self, items: list[MemoryItem]) -> dict[str, Any]:
+        if not embedding_client.configured:
+            return {"enabled": False, "embedded": 0, "removed": 0}
+
+        current_ids = {item.id for item in items}
+        previous_hashes = await self.repo.embedding_hashes(self.id)
+        stale_ids = set(previous_hashes) - current_ids
+        for memory_id in stale_ids:
+            await self.repo.delete_embedding(self.id, memory_id)
+
+        pending = [
+            item
+            for item in items
+            if previous_hashes.get(item.id) != embedding_fingerprint(item.content)
+        ]
+        if pending:
+            vectors = await embedding_client.embed([item.content for item in pending])
+            for item, vector in zip(pending, vectors):
+                await self.repo.upsert_embedding(
+                    system=self.id,
+                    memory_id=item.id,
+                    content=item.content,
+                    source=item.source,
+                    content_hash=embedding_fingerprint(item.content),
+                    embedding=vector,
+                )
+        return {"enabled": True, "embedded": len(pending), "removed": len(stale_ids)}
+
+    async def _retrieve(self, message: str, limit: int = 5) -> tuple[list[MemoryItem], dict[str, Any]]:
+        items = await self.repo.list_memories(self.id)
+        if not items:
+            return [], {"strategy": "empty", "embedding": {"enabled": False}}
+
+        query_tokens = self._tokens(message)
+        expected_type = self._query_type(message)
+        token_overlap: dict[str, float] = {}
+        lexical_scores: dict[str, float] = {}
+        for item in items:
+            item_tokens = self._tokens(item.content)
+            overlap = len(query_tokens & item_tokens) / max(1, len(query_tokens))
+            token_overlap[item.id] = overlap
+            type_bonus = 0.35 if expected_type and item.memory_type == expected_type else 0.0
+            lexical_scores[item.id] = overlap + type_bonus
+
+        semantic_scores: dict[str, float] = {}
+        try:
+            embedding_status = await self._sync_embeddings(items)
+            if embedding_status["enabled"]:
+                query_vector = (await embedding_client.embed([message]))[0]
+                semantic_matches = await self.repo.search_embeddings(
+                    self.id,
+                    query_vector,
+                    limit=min(max(limit * 2, 5), 20),
+                )
+                semantic_scores = {
+                    str(match["memory_id"]): float(match["score"])
+                    for match in semantic_matches
+                }
+        except Exception as exc:
+            embedding_status = {"enabled": True, "error": str(exc), "embedded": 0, "removed": 0}
+
+        ranked: list[tuple[float, MemoryItem]] = []
+        for item in items:
+            lexical = lexical_scores[item.id]
+            semantic = semantic_scores.get(item.id, 0.0)
+            if (
+                expected_type
+                and item.memory_type != expected_type
+                and token_overlap[item.id] < 0.25
+                and semantic < 0.65
+            ):
+                continue
+            if lexical <= 0 and semantic < settings.embedding_min_score:
+                continue
+            ranked.append((lexical + max(semantic, 0.0), item))
+
+        if not ranked and expected_type:
+            ranked = [
+                (0.1, item)
+                for item in items
+                if item.memory_type == expected_type
+            ]
+
+        ranked.sort(key=lambda pair: (pair[0], pair[1].updated_at), reverse=True)
+        selected = [item for _, item in ranked[:limit]]
+        return selected, {
+            "strategy": "lexical+type+embedding" if semantic_scores else "lexical+type",
+            "expected_type": expected_type,
+            "embedding": embedding_status,
+            "matches": [
+                {
+                    "memory_id": item.id,
+                    "lexical": round(lexical_scores[item.id], 4),
+                    "semantic": round(semantic_scores.get(item.id, 0.0), 4),
+                }
+                for item in selected
+            ],
+        }
+
+    async def chat(self, message: str) -> ChatResponse:
+        events: list[dict[str, Any]] = []
+        if self._should_encode(message):
+            item, created = await self._encode(message)
+            events.append(
+                {
+                    "event": "ENC/Encode",
+                    "memory_id": item.id,
+                    "content": item.content,
+                    "memory_type": item.memory_type,
+                    "status": "created" if created else "deduplicated",
+                }
+            )
+
+        context, retrieval = await self._retrieve(message)
+        system_prompt = (
+            "你是一个 AI 编程助手。Text2Mem 只允许通过显式 IR 记忆操作读写记忆。"
+            "回答时区分当前用户陈述、历史记忆和推断,不要把推断写成事实。"
+            "只能依据给定的记忆证据回答历史问题;证据不足时要明确说明。"
+        )
+        context_text = "\n".join(
+            f"- [{item.memory_type} | {item.source} | {item.updated_at.isoformat()}] {item.content}"
+            for item in context
+        ) or "(暂无命中记忆)"
+        try:
+            answer = await self.llm.chat(system_prompt, f"记忆上下文:\n{context_text}\n\n用户:{message}")
+            mode = "llm"
+        except LLMUnavailable:
+            mode = "local-fallback"
+            answer = (
+                "Text2Mem 本地模式:我已按显式 IR 规则处理本轮输入。\n\n"
+                f"当前命中记忆:{context_text}\n\n"
+                "配置 LLM_API_KEY 后,可让模型基于这些记忆生成自然语言回答。"
+            )
+        except Exception as exc:
+            mode = "local-fallback"
+            retrieval["llm_error"] = str(exc)[:500]
+            answer = (
+                "模型调用失败,Text2Mem 已退回本地证据模式;记忆写入和检索结果不受影响。\n\n"
+                f"当前命中记忆:{context_text}"
+            )
+        await self._audit(
+            "RET/Retrieve",
+            details={
+                "query": message,
+                "count": len(context),
+                "memory_ids": [item.id for item in context],
+                **retrieval,
+            },
+        )
+        return ChatResponse(
+            system="text2mem",
+            answer=answer,
+            mode=mode,
+            memory_context=context,
+            memory_events=events,
+            audit_events=await self.audit(),
+        )

+ 66 - 0
backend/app/config.py

@@ -0,0 +1,66 @@
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+from pathlib import Path
+
+from dotenv import load_dotenv
+
+
+ROOT_DIR = Path(__file__).resolve().parents[2]
+load_dotenv(ROOT_DIR.parent / ".env")
+load_dotenv(ROOT_DIR / ".env")
+
+
+def _bool(name: str, default: bool = False) -> bool:
+    value = os.getenv(name)
+    if value is None:
+        return default
+    return value.strip().lower() in {"1", "true", "yes", "on"}
+
+
+@dataclass(frozen=True)
+class Settings:
+    app_env: str = os.getenv("APP_ENV", "development")
+    workspace_id: str = os.getenv("WORKSPACE_ID", "local-workspace")
+    data_dir: Path = Path(os.getenv("DATA_DIR", str(ROOT_DIR / "data")))
+    demo_fallback: bool = _bool("DEMO_FALLBACK", True)
+    llm_base_url: str = os.getenv("LLM_BASE_URL", "https://api.openai.com/v1").rstrip("/")
+    llm_api_key: str = os.getenv("LLM_API_KEY", "")
+    llm_model: str = os.getenv("LLM_MODEL", "gpt-4o-mini")
+    embedding_base_url: str = os.getenv("EMBEDDING_BASE_URL", "https://api.openai.com/v1").rstrip("/")
+    embedding_api_key: str = os.getenv("EMBEDDING_API_KEY", "")
+    embedding_model: str = os.getenv("EMBEDDING_MODEL", "text-embedding-3-small")
+    embedding_dimensions: int = int(os.getenv("EMBEDDING_DIMENSIONS", "1536"))
+    embedding_min_score: float = float(os.getenv("EMBEDDING_MIN_SCORE", "0.35"))
+    database_url: str = os.getenv(
+        "DATABASE_URL",
+        "postgresql://memory:memory@localhost:54329/memory_agents",
+    )
+    redis_url: str = os.getenv("REDIS_URL", "redis://localhost:6379/0")
+    letta_base_url: str = os.getenv("LETTA_BASE_URL", "http://localhost:8283").rstrip("/")
+    letta_api_key: str = os.getenv("LETTA_API_KEY", "")
+    letta_enabled: bool = _bool("LETTA_ENABLED")
+    mem0_enabled: bool = _bool("MEM0_ENABLED")
+    reme_enabled: bool = _bool("REME_ENABLED")
+    memu_enabled: bool = _bool("MEMU_ENABLED")
+
+
+settings = Settings()
+if not 0.0 <= settings.embedding_min_score <= 1.0:
+    raise ValueError("EMBEDDING_MIN_SCORE 必须在 0 到 1 之间")
+settings.data_dir.mkdir(parents=True, exist_ok=True)
+
+# Some optional SDKs, especially Mem0's default local client, look for the
+# conventional OpenAI environment names. Keep the project-facing configuration
+# unified while making those SDKs inherit the same OpenAI-compatible endpoint.
+if settings.llm_api_key:
+    os.environ.setdefault("OPENAI_API_KEY", settings.llm_api_key)
+    os.environ.setdefault("DEEPSEEK_API_KEY", settings.llm_api_key)
+if settings.llm_base_url:
+    os.environ.setdefault("OPENAI_BASE_URL", settings.llm_base_url)
+    os.environ.setdefault("OPENAI_API_BASE", settings.llm_base_url)
+    os.environ.setdefault("LLM_BACKEND", "openai")
+    os.environ.setdefault("LLM_API_KEY", settings.llm_api_key)
+    os.environ.setdefault("LLM_BASE_URL", settings.llm_base_url)
+    os.environ.setdefault("LLM_MODEL_NAME", settings.llm_model)

+ 466 - 0
backend/app/db.py

@@ -0,0 +1,466 @@
+from __future__ import annotations
+
+import json
+import math
+import uuid
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+from .config import settings
+from .schemas import AuditEvent, MemoryItem
+
+
+def utcnow() -> datetime:
+    return datetime.now(timezone.utc)
+
+
+class MemoryRepository:
+    """PostgreSQL-first repository with a transparent local JSON fallback.
+
+    The fallback is intentionally exposed in the API status as `local-json`.
+    It is for bootstrapping the Web UI only; Docker PostgreSQL is the intended
+    persistence layer for the project.
+    """
+
+    def __init__(self) -> None:
+        self.pool: Any | None = None
+        self.storage_mode = "local-json"
+        self.initialization_error: str | None = None
+        self.path = Path(settings.data_dir) / "local-memory.json"
+        self.memories: dict[str, list[MemoryItem]] = {}
+        self.audit: list[AuditEvent] = []
+        self.embeddings: dict[tuple[str, str], dict[str, Any]] = {}
+
+    async def initialize(self) -> None:
+        try:
+            import asyncpg  # type: ignore
+
+            dimension = settings.embedding_dimensions
+            if not 1 <= dimension <= 2000:
+                raise ValueError("EMBEDDING_DIMENSIONS 必须在 1 到 2000 之间")
+            self.pool = await asyncpg.create_pool(settings.database_url, timeout=2)
+            async with self.pool.acquire() as conn:
+                await conn.execute(
+                    f"""
+                    CREATE TABLE IF NOT EXISTS memory_items (
+                        id TEXT PRIMARY KEY,
+                        system TEXT NOT NULL,
+                        workspace_id TEXT NOT NULL,
+                        content TEXT NOT NULL,
+                        memory_type TEXT NOT NULL,
+                        scope TEXT NOT NULL,
+                        source TEXT NOT NULL,
+                        confidence DOUBLE PRECISION NOT NULL,
+                        valid_from TIMESTAMPTZ,
+                        valid_to TIMESTAMPTZ,
+                        created_at TIMESTAMPTZ NOT NULL,
+                        updated_at TIMESTAMPTZ NOT NULL,
+                        metadata JSONB NOT NULL DEFAULT '{{}}'::jsonb
+                    );
+                    CREATE INDEX IF NOT EXISTS memory_items_lookup
+                    ON memory_items (workspace_id, system, updated_at DESC);
+                    CREATE TABLE IF NOT EXISTS audit_events (
+                        id TEXT PRIMARY KEY,
+                        system TEXT NOT NULL,
+                        workspace_id TEXT NOT NULL,
+                        operation TEXT NOT NULL,
+                        target TEXT,
+                        status TEXT NOT NULL,
+                        details JSONB NOT NULL DEFAULT '{{}}'::jsonb,
+                        created_at TIMESTAMPTZ NOT NULL
+                    );
+                    CREATE INDEX IF NOT EXISTS audit_events_lookup
+                    ON audit_events (workspace_id, system, created_at DESC);
+                    CREATE TABLE IF NOT EXISTS memory_embeddings (
+                        workspace_id TEXT NOT NULL,
+                        system TEXT NOT NULL,
+                        memory_id TEXT NOT NULL,
+                        content TEXT NOT NULL,
+                        source TEXT NOT NULL,
+                        content_hash TEXT NOT NULL,
+                        embedding vector({dimension}) NOT NULL,
+                        updated_at TIMESTAMPTZ NOT NULL,
+                        PRIMARY KEY (workspace_id, system, memory_id)
+                    );
+                    CREATE INDEX IF NOT EXISTS memory_embeddings_lookup
+                    ON memory_embeddings (workspace_id, system, updated_at DESC);
+                    CREATE INDEX IF NOT EXISTS memory_embeddings_vector_lookup
+                    ON memory_embeddings USING hnsw (embedding vector_cosine_ops);
+                    """
+                )
+                vector_type = await conn.fetchval(
+                    """
+                    SELECT format_type(attribute.atttypid, attribute.atttypmod)
+                    FROM pg_attribute AS attribute
+                    JOIN pg_class AS relation ON relation.oid=attribute.attrelid
+                    WHERE relation.relname='memory_embeddings'
+                      AND attribute.attname='embedding'
+                      AND attribute.attnum > 0
+                    """
+                )
+                expected_type = f"vector({dimension})"
+                if vector_type != expected_type:
+                    raise RuntimeError(
+                        f"memory_embeddings 当前为 {vector_type},但配置要求 {expected_type}。"
+                        "请迁移或重建该向量表后重试。"
+                    )
+            self.storage_mode = "postgres"
+            self.initialization_error = None
+        except Exception as exc:
+            if self.pool:
+                await self.pool.close()
+            self.pool = None
+            self.initialization_error = str(exc)
+            self._load_local()
+
+    def _load_local(self) -> None:
+        if not self.path.exists():
+            return
+        try:
+            payload = json.loads(self.path.read_text(encoding="utf-8"))
+            self.memories = {
+                system: [MemoryItem.model_validate(item) for item in items]
+                for system, items in payload.get("memories", {}).items()
+            }
+            self.audit = [AuditEvent.model_validate(item) for item in payload.get("audit", [])]
+            self.embeddings = {
+                (system, memory_id): item
+                for system, items in payload.get("embeddings", {}).items()
+                for memory_id, item in items.items()
+            }
+        except Exception:
+            self.memories = {}
+            self.audit = []
+            self.embeddings = {}
+
+    def _persist_local(self) -> None:
+        self.path.parent.mkdir(parents=True, exist_ok=True)
+        self.path.write_text(
+            json.dumps(
+                {
+                    "memories": {
+                        system: [item.model_dump(mode="json") for item in items]
+                        for system, items in self.memories.items()
+                    },
+                    "audit": [event.model_dump(mode="json") for event in self.audit],
+                    "embeddings": {
+                        system: {
+                            memory_id: item
+                            for (item_system, memory_id), item in self.embeddings.items()
+                            if item_system == system
+                        }
+                        for system in {item_system for item_system, _ in self.embeddings}
+                    },
+                },
+                ensure_ascii=False,
+                indent=2,
+            ),
+            encoding="utf-8",
+        )
+
+    @staticmethod
+    def _json_value(value: Any) -> dict[str, Any]:
+        if isinstance(value, str):
+            try:
+                parsed = json.loads(value)
+                return parsed if isinstance(parsed, dict) else {}
+            except json.JSONDecodeError:
+                return {}
+        return value or {}
+
+    async def add_memory(self, item: MemoryItem) -> MemoryItem:
+        if self.pool:
+            async with self.pool.acquire() as conn:
+                await conn.execute(
+                    """
+                    INSERT INTO memory_items
+                    (id, system, workspace_id, content, memory_type, scope, source,
+                     confidence, valid_from, valid_to, created_at, updated_at, metadata)
+                    VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13::jsonb)
+                    ON CONFLICT (id) DO UPDATE SET
+                      system=EXCLUDED.system,
+                      workspace_id=EXCLUDED.workspace_id,
+                      content=EXCLUDED.content,
+                      memory_type=EXCLUDED.memory_type,
+                      scope=EXCLUDED.scope,
+                      source=EXCLUDED.source,
+                      confidence=EXCLUDED.confidence,
+                      valid_from=EXCLUDED.valid_from,
+                      valid_to=EXCLUDED.valid_to,
+                      updated_at=EXCLUDED.updated_at,
+                      metadata=EXCLUDED.metadata
+                    """,
+                    item.id,
+                    item.system,
+                    item.workspace_id,
+                    item.content,
+                    item.memory_type,
+                    item.scope,
+                    item.source,
+                    item.confidence,
+                    item.valid_from,
+                    item.valid_to,
+                    item.created_at,
+                    item.updated_at,
+                    json.dumps(item.metadata, ensure_ascii=False),
+                )
+        else:
+            items = self.memories.setdefault(item.system, [])
+            items[:] = [existing for existing in items if existing.id != item.id]
+            items.append(item)
+            self._persist_local()
+        return item
+
+    async def add_audit(self, event: AuditEvent) -> AuditEvent:
+        if self.pool:
+            async with self.pool.acquire() as conn:
+                await conn.execute(
+                    """
+                    INSERT INTO audit_events
+                    (id, system, workspace_id, operation, target, status, details, created_at)
+                    VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8)
+                    """,
+                    event.id,
+                    event.system,
+                    event.workspace_id,
+                    event.operation,
+                    event.target,
+                    event.status,
+                    json.dumps(event.details, ensure_ascii=False),
+                    event.created_at,
+                )
+        else:
+            self.audit.append(event)
+            self._persist_local()
+        return event
+
+    async def list_memories(self, system: str, query: str | None = None) -> list[MemoryItem]:
+        if self.pool:
+            async with self.pool.acquire() as conn:
+                rows = await conn.fetch(
+                    """
+                    SELECT * FROM memory_items
+                    WHERE workspace_id=$1 AND system=$2
+                      AND ($3::text IS NULL OR content ILIKE '%' || $3 || '%')
+                    ORDER BY updated_at DESC
+                    LIMIT 100
+                    """,
+                    settings.workspace_id,
+                    system,
+                    query or None,
+                )
+            return [
+                MemoryItem(
+                    id=row["id"], system=row["system"], workspace_id=row["workspace_id"],
+                    content=row["content"], memory_type=row["memory_type"], scope=row["scope"],
+                    source=row["source"], confidence=row["confidence"],
+                    valid_from=row["valid_from"], valid_to=row["valid_to"],
+                    created_at=row["created_at"], updated_at=row["updated_at"],
+                    metadata=self._json_value(row["metadata"]),
+                )
+                for row in rows
+            ]
+        items = list(self.memories.get(system, []))
+        if query:
+            lowered = query.lower()
+            items = [item for item in items if lowered in item.content.lower()]
+        return sorted(items, key=lambda item: item.updated_at, reverse=True)[:100]
+
+    async def embedding_hashes(self, system: str) -> dict[str, str]:
+        if self.pool:
+            async with self.pool.acquire() as conn:
+                rows = await conn.fetch(
+                    """
+                    SELECT memory_id, content_hash FROM memory_embeddings
+                    WHERE workspace_id=$1 AND system=$2
+                    """,
+                    settings.workspace_id,
+                    system,
+                )
+            return {row["memory_id"]: row["content_hash"] for row in rows}
+        return {
+            memory_id: str(item["content_hash"])
+            for (item_system, memory_id), item in self.embeddings.items()
+            if item_system == system
+        }
+
+    async def upsert_embedding(
+        self,
+        system: str,
+        memory_id: str,
+        content: str,
+        source: str,
+        content_hash: str,
+        embedding: list[float],
+    ) -> None:
+        if self.pool:
+            async with self.pool.acquire() as conn:
+                await conn.execute(
+                    """
+                    INSERT INTO memory_embeddings
+                    (workspace_id, system, memory_id, content, source, content_hash, embedding, updated_at)
+                    VALUES ($1,$2,$3,$4,$5,$6,$7::vector,$8)
+                    ON CONFLICT (workspace_id, system, memory_id) DO UPDATE SET
+                      content=EXCLUDED.content,
+                      source=EXCLUDED.source,
+                      content_hash=EXCLUDED.content_hash,
+                      embedding=EXCLUDED.embedding,
+                      updated_at=EXCLUDED.updated_at
+                    """,
+                    settings.workspace_id,
+                    system,
+                    memory_id,
+                    content,
+                    source,
+                    content_hash,
+                    "[" + ",".join(str(value) for value in embedding) + "]",
+                    utcnow(),
+                )
+            return
+        self.embeddings[(system, memory_id)] = {
+            "content": content,
+            "source": source,
+            "content_hash": content_hash,
+            "embedding": embedding,
+            "updated_at": utcnow().isoformat(),
+        }
+        self._persist_local()
+
+    async def search_embeddings(
+        self,
+        system: str,
+        query_embedding: list[float],
+        limit: int = 5,
+    ) -> list[dict[str, Any]]:
+        if self.pool:
+            async with self.pool.acquire() as conn:
+                rows = await conn.fetch(
+                    """
+                    SELECT memory_id, content, source,
+                           1 - (embedding <=> $3::vector) AS score
+                    FROM memory_embeddings
+                    WHERE workspace_id=$1 AND system=$2
+                    ORDER BY embedding <=> $3::vector
+                    LIMIT $4
+                    """,
+                    settings.workspace_id,
+                    system,
+                    "[" + ",".join(str(value) for value in query_embedding) + "]",
+                    limit,
+                )
+            return [dict(row) for row in rows]
+
+        def cosine(left: list[float], right: list[float]) -> float:
+            denominator = math.sqrt(sum(value * value for value in left)) * math.sqrt(
+                sum(value * value for value in right)
+            )
+            if not denominator:
+                return 0.0
+            return sum(a * b for a, b in zip(left, right)) / denominator
+
+        matches = []
+        for (item_system, memory_id), item in self.embeddings.items():
+            if item_system != system:
+                continue
+            matches.append(
+                {
+                    "memory_id": memory_id,
+                    "content": item["content"],
+                    "source": item["source"],
+                    "score": cosine(query_embedding, item["embedding"]),
+                }
+            )
+        return sorted(matches, key=lambda item: item["score"], reverse=True)[:limit]
+
+    async def delete_embedding(self, system: str, memory_id: str) -> None:
+        if self.pool:
+            async with self.pool.acquire() as conn:
+                await conn.execute(
+                    "DELETE FROM memory_embeddings WHERE workspace_id=$1 AND system=$2 AND memory_id=$3",
+                    settings.workspace_id,
+                    system,
+                    memory_id,
+                )
+            return
+        self.embeddings.pop((system, memory_id), None)
+        self._persist_local()
+
+    async def delete_memory(self, system: str, memory_id: str) -> bool:
+        if self.pool:
+            async with self.pool.acquire() as conn:
+                result = await conn.execute(
+                    "DELETE FROM memory_items WHERE workspace_id=$1 AND system=$2 AND id=$3",
+                    settings.workspace_id, system, memory_id,
+                )
+            return result.endswith(" 1")
+        items = self.memories.get(system, [])
+        remaining = [item for item in items if item.id != memory_id]
+        deleted = len(remaining) != len(items)
+        self.memories[system] = remaining
+        if deleted:
+            self._persist_local()
+        return deleted
+
+    async def list_audit(self, system: str | None = None) -> list[AuditEvent]:
+        if self.pool:
+            async with self.pool.acquire() as conn:
+                rows = await conn.fetch(
+                    """
+                    SELECT * FROM audit_events
+                    WHERE workspace_id=$1 AND ($2::text IS NULL OR system=$2)
+                    ORDER BY created_at DESC LIMIT 200
+                    """,
+                    settings.workspace_id,
+                    system,
+                )
+            return [
+                AuditEvent(
+                    id=row["id"], system=row["system"], workspace_id=row["workspace_id"],
+                    operation=row["operation"], target=row["target"], status=row["status"],
+                    details=self._json_value(row["details"]), created_at=row["created_at"],
+                )
+                for row in rows
+            ]
+        events = self.audit if system is None else [event for event in self.audit if event.system == system]
+        return sorted(events, key=lambda event: event.created_at, reverse=True)[:200]
+
+    async def reset(self, system: str | None = None) -> None:
+        if self.pool:
+            async with self.pool.acquire() as conn:
+                if system:
+                    await conn.execute(
+                        "DELETE FROM memory_items WHERE workspace_id=$1 AND system=$2",
+                        settings.workspace_id, system,
+                    )
+                    await conn.execute(
+                        "DELETE FROM memory_embeddings WHERE workspace_id=$1 AND system=$2",
+                        settings.workspace_id, system,
+                    )
+                    await conn.execute(
+                        "DELETE FROM audit_events WHERE workspace_id=$1 AND system=$2",
+                        settings.workspace_id, system,
+                    )
+                else:
+                    await conn.execute("DELETE FROM memory_items WHERE workspace_id=$1", settings.workspace_id)
+                    await conn.execute("DELETE FROM memory_embeddings WHERE workspace_id=$1", settings.workspace_id)
+                    await conn.execute("DELETE FROM audit_events WHERE workspace_id=$1", settings.workspace_id)
+            return
+        if system:
+            self.memories.pop(system, None)
+            self.embeddings = {
+                key: value for key, value in self.embeddings.items() if key[0] != system
+            }
+            self.audit[:] = [event for event in self.audit if event.system != system]
+        else:
+            self.memories.clear()
+            self.embeddings.clear()
+            self.audit.clear()
+        self._persist_local()
+
+    @staticmethod
+    def memory_id(prefix: str) -> str:
+        return f"{prefix}_{uuid.uuid4().hex[:12]}"
+
+
+repository = MemoryRepository()

+ 76 - 0
backend/app/embeddings.py

@@ -0,0 +1,76 @@
+from __future__ import annotations
+
+import hashlib
+from typing import Any
+
+import httpx
+
+from .config import settings
+
+
+class EmbeddingUnavailable(RuntimeError):
+    pass
+
+
+def embedding_fingerprint(content: str) -> str:
+    """Invalidate stored vectors when either the text or model config changes."""
+    payload = (
+        f"{settings.embedding_base_url}\0{settings.embedding_model}\0"
+        f"{settings.embedding_dimensions}\0{content}"
+    )
+    return hashlib.sha256(payload.encode("utf-8")).hexdigest()
+
+
+class OpenAICompatibleEmbeddingClient:
+    """Embedding client for DashScope and other OpenAI-compatible endpoints."""
+
+    @property
+    def configured(self) -> bool:
+        return bool(
+            settings.embedding_api_key
+            and settings.embedding_base_url
+            and settings.embedding_model
+        )
+
+    async def embed(self, inputs: list[str]) -> list[list[float]]:
+        if not self.configured:
+            raise EmbeddingUnavailable(
+                "未配置 EMBEDDING_API_KEY / EMBEDDING_BASE_URL / EMBEDDING_MODEL"
+            )
+        if not inputs:
+            return []
+
+        payload: dict[str, Any] = {
+            "model": settings.embedding_model,
+            "input": inputs,
+            "encoding_format": "float",
+        }
+        if settings.embedding_dimensions:
+            payload["dimensions"] = settings.embedding_dimensions
+
+        async with httpx.AsyncClient(timeout=60) as client:
+            response = await client.post(
+                f"{settings.embedding_base_url}/embeddings",
+                headers={"Authorization": f"Bearer {settings.embedding_api_key}"},
+                json=payload,
+            )
+            response.raise_for_status()
+            body = response.json()
+
+        data = body.get("data")
+        if not isinstance(data, list):
+            raise EmbeddingUnavailable("Embedding API 返回中缺少 data 数组")
+        ordered = sorted(data, key=lambda item: item.get("index", 0))
+        vectors = [item.get("embedding") for item in ordered]
+        if len(vectors) != len(inputs) or not all(isinstance(item, list) for item in vectors):
+            raise EmbeddingUnavailable("Embedding API 返回的向量数量不匹配")
+        if settings.embedding_dimensions and any(
+            len(vector) != settings.embedding_dimensions for vector in vectors
+        ):
+            raise EmbeddingUnavailable(
+                f"Embedding 向量维度与配置不一致,期望 {settings.embedding_dimensions}"
+            )
+        return [[float(value) for value in vector] for vector in vectors]
+
+
+embedding_client = OpenAICompatibleEmbeddingClient()

+ 52 - 0
backend/app/llm.py

@@ -0,0 +1,52 @@
+from __future__ import annotations
+
+import json
+from typing import Any
+
+import httpx
+
+from .config import settings
+
+
+class LLMUnavailable(RuntimeError):
+    pass
+
+
+class OpenAICompatibleClient:
+    @property
+    def configured(self) -> bool:
+        return bool(settings.llm_api_key and settings.llm_base_url and settings.llm_model)
+
+    async def chat(self, system: str, user: str, temperature: float = 0.2) -> str:
+        if not self.configured:
+            raise LLMUnavailable("未配置 LLM_API_KEY / LLM_BASE_URL / LLM_MODEL")
+        async with httpx.AsyncClient(timeout=60) as client:
+            response = await client.post(
+                f"{settings.llm_base_url}/chat/completions",
+                headers={"Authorization": f"Bearer {settings.llm_api_key}"},
+                json={
+                    "model": settings.llm_model,
+                    "temperature": temperature,
+                    "messages": [
+                        {"role": "system", "content": system},
+                        {"role": "user", "content": user},
+                    ],
+                },
+            )
+            response.raise_for_status()
+            data = response.json()
+            return data["choices"][0]["message"]["content"]
+
+    async def json(self, system: str, user: str) -> dict[str, Any]:
+        raw = await self.chat(system, user, temperature=0.1)
+        cleaned = raw.strip()
+        if "```" in cleaned:
+            cleaned = cleaned.replace("```json", "").replace("```", "").strip()
+        start, end = cleaned.find("{"), cleaned.rfind("}")
+        if start >= 0 and end > start:
+            cleaned = cleaned[start : end + 1]
+        return json.loads(cleaned)
+
+
+llm = OpenAICompatibleClient()
+

+ 110 - 0
backend/app/main.py

@@ -0,0 +1,110 @@
+from __future__ import annotations
+
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI, HTTPException, Query
+from fastapi.middleware.cors import CORSMiddleware
+
+from .config import settings
+from .db import repository
+from .embeddings import embedding_client
+from .registry import agents
+from .schemas import ChatRequest, ChatResponse, ResetRequest
+from .adapters.base import AdapterUnavailable
+
+
+@asynccontextmanager
+async def lifespan(_: FastAPI):
+    await repository.initialize()
+    yield
+    if repository.pool:
+        await repository.pool.close()
+
+
+app = FastAPI(title="Memory Agents Web API", version="0.1.0", lifespan=lifespan)
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
+    allow_origin_regex=(
+        r"^https?://(?:192\.168\.\d+\.\d+|10\.\d+\.\d+|172\.(?:1[6-9]|2\d|3[0-1])\.\d+\.\d+):3000$"
+    ),
+    allow_credentials=True,
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+
+
+@app.get("/api/health")
+async def health() -> dict:
+    result = {
+        "status": "ok",
+        "storage": repository.storage_mode,
+        "workspace_id": settings.workspace_id,
+        "llm_configured": bool(settings.llm_api_key),
+        "embedding_configured": embedding_client.configured,
+    }
+    if repository.initialization_error:
+        result["storage_error"] = repository.initialization_error
+    return result
+
+
+@app.get("/api/systems")
+async def systems():
+    return {
+        "storage": repository.storage_mode,
+        "systems": [agent.status for agent in agents.values()],
+    }
+
+
+@app.post("/api/chat", response_model=ChatResponse)
+async def chat(request: ChatRequest):
+    agent = agents[request.system]
+    try:
+        return await agent.chat(request.message)
+    except AdapterUnavailable as exc:
+        raise HTTPException(status_code=409, detail=str(exc)) from exc
+    except Exception as exc:
+        raise HTTPException(status_code=502, detail=f"{request.system} 执行失败:{exc}") from exc
+
+
+@app.get("/api/memories")
+async def memories(system: str = Query(...), query: str | None = None):
+    if system not in agents:
+        raise HTTPException(status_code=404, detail="未知记忆系统")
+    return {"system": system, "memories": await agents[system].memories(query)}
+
+
+@app.delete("/api/memories/{memory_id}")
+async def delete_memory(memory_id: str, system: str = Query(...)):
+    if system not in agents:
+        raise HTTPException(status_code=404, detail="未知记忆系统")
+    deleted = await agents[system].delete_memory(memory_id)
+    if not deleted:
+        raise HTTPException(status_code=404, detail="记忆不存在或已删除")
+    return {"status": "ok", "system": system, "memory_id": memory_id}
+
+
+@app.get("/api/audit")
+async def audit(system: str | None = None):
+    if system is not None and system not in agents:
+        raise HTTPException(status_code=404, detail="未知记忆系统")
+    if system:
+        events = await agents[system].audit()
+    else:
+        events = await repository.list_audit()
+    return {"system": system, "events": events}
+
+
+@app.post("/api/reset")
+async def reset(request: ResetRequest):
+    if request.system is None:
+        # Some adapters own storage outside the common repository. Calling each
+        # adapter keeps framework files/indexes (notably ReMe) in sync with the
+        # PostgreSQL projection when the user requests a global reset.
+        for agent in agents.values():
+            await agent.reset()
+    elif request.system in agents:
+        await agents[request.system].reset()
+    else:
+        raise HTTPException(status_code=404, detail="未知记忆系统")
+    return {"status": "ok", "system": request.system}

+ 15 - 0
backend/app/registry.py

@@ -0,0 +1,15 @@
+from .adapters.base import MemoryAgent
+from .adapters.letta import LettaAgent
+from .adapters.mem0 import Mem0Agent
+from .adapters.memu import MemUAgent
+from .adapters.reme import ReMeAgent
+from .adapters.text2mem import Text2MemAgent
+
+
+def build_registry() -> dict[str, MemoryAgent]:
+    agents = [Text2MemAgent(), Mem0Agent(), LettaAgent(), ReMeAgent(), MemUAgent()]
+    return {agent.id: agent for agent in agents}
+
+
+agents = build_registry()
+

+ 67 - 0
backend/app/schemas.py

@@ -0,0 +1,67 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any, Literal
+
+from pydantic import BaseModel, Field
+
+
+SystemName = Literal["text2mem", "mem0", "letta", "reme", "memu"]
+
+
+class MemoryItem(BaseModel):
+    id: str
+    system: str
+    workspace_id: str
+    content: str
+    memory_type: str = "semantic"
+    scope: str = "workspace"
+    source: str = "user_direct"
+    confidence: float = 1.0
+    valid_from: datetime | None = None
+    valid_to: datetime | None = None
+    created_at: datetime
+    updated_at: datetime
+    metadata: dict[str, Any] = Field(default_factory=dict)
+
+
+class AuditEvent(BaseModel):
+    id: str
+    system: str
+    workspace_id: str
+    operation: str
+    target: str | None = None
+    status: str = "ok"
+    details: dict[str, Any] = Field(default_factory=dict)
+    created_at: datetime
+
+
+class SystemDescriptor(BaseModel):
+    id: SystemName
+    name: str
+    paradigm: str
+    description: str
+    available: bool
+    mode: str
+    status: str
+    package: str | None = None
+    setup_hint: str | None = None
+
+
+class ChatRequest(BaseModel):
+    system: SystemName
+    message: str = Field(min_length=1, max_length=10000)
+
+
+class ChatResponse(BaseModel):
+    system: SystemName
+    answer: str
+    mode: str
+    memory_context: list[MemoryItem] = Field(default_factory=list)
+    memory_events: list[dict[str, Any]] = Field(default_factory=list)
+    audit_events: list[AuditEvent] = Field(default_factory=list)
+
+
+class ResetRequest(BaseModel):
+    system: SystemName | None = None
+

+ 38 - 0
backend/pyproject.toml

@@ -0,0 +1,38 @@
+[project]
+name = "memory-agents-api"
+version = "0.1.0"
+description = "Web API for comparing five agent memory paradigms"
+requires-python = ">=3.11"
+dependencies = [
+  "fastapi>=0.115,<1",
+  "uvicorn[standard]>=0.34,<1",
+  "pydantic>=2.10,<3",
+  "httpx>=0.27,<1",
+  "python-dotenv>=1.0,<2",
+  "asyncpg>=0.30,<1",
+]
+
+[dependency-groups]
+dev = [
+  "pytest>=8.3,<9",
+  "pytest-asyncio>=0.25,<1",
+  "ruff>=0.9,<1",
+]
+
+[project.optional-dependencies]
+reme = [
+  "reme-ai[core]>=0.4,<0.5",
+]
+mem0 = [
+  "mem0ai>=2,<3",
+  "psycopg[binary,pool]>=3.2,<4",
+]
+letta = [
+  "letta-client>=1.12,<2",
+]
+memu = [
+  "memu-py>=1.5,<2",
+]
+
+[tool.setuptools]
+packages = ["app", "app.adapters"]

+ 7 - 0
backend/requirements-frameworks.txt

@@ -0,0 +1,7 @@
+# Optional framework integrations. Prefer the matching pyproject extra, for
+# example: uv pip install -e '.[reme]'. Do not install every framework into one
+# environment unless their dependency sets have been checked together.
+mem0ai>=2,<3
+letta-client>=1.12,<2
+reme-ai[core]>=0.4,<0.5
+memu-py>=1.5,<2

+ 24 - 0
backend/tests/test_main.py

@@ -0,0 +1,24 @@
+import pytest
+
+import app.main as main_module
+from app.schemas import ResetRequest
+
+
+@pytest.mark.asyncio
+async def test_global_reset_calls_each_adapter(monkeypatch):
+    class FakeAgent:
+        def __init__(self) -> None:
+            self.reset_count = 0
+
+        async def reset(self) -> None:
+            self.reset_count += 1
+
+    first = FakeAgent()
+    second = FakeAgent()
+    monkeypatch.setattr(main_module, "agents", {"first": first, "second": second})
+
+    result = await main_module.reset(ResetRequest())
+
+    assert result == {"status": "ok", "system": None}
+    assert first.reset_count == 1
+    assert second.reset_count == 1

+ 112 - 0
backend/tests/test_reme.py

@@ -0,0 +1,112 @@
+from types import SimpleNamespace
+
+import pytest
+
+import app.adapters.reme as module
+from app.adapters.reme import ReMeAgent
+
+
+def test_reme_heuristic_query_expands_deployment_question():
+    queries = ReMeAgent._heuristic_queries("上次部署遇到什么问题?")
+
+    assert queries[0] == "上次部署遇到什么问题?"
+    assert "部署" in queries
+    assert "索引构建" in queries
+    assert "批次" in queries
+    assert "超时" in queries
+
+
+def test_reme_merge_search_results_deduplicates_documents():
+    first = "========== daily/2026-07-15.md:5-8 [score=0.4] ==========\n向量索引构建超时"
+    second = "========== daily/2026-07-15.md:5-8 [score=0.8] ==========\n向量索引构建超时"
+
+    merged = ReMeAgent._merge_search_results([first, second])
+
+    assert merged.count("daily/2026-07-15.md") == 1
+    assert "向量索引构建超时" in merged
+
+
+@pytest.mark.asyncio
+async def test_reme_search_memory_uses_multiple_queries():
+    class FakeService:
+        def __init__(self):
+            self.queries: list[str] = []
+
+        async def run_job(self, job: str, *, query: str, limit: int):
+            assert job == "search"
+            assert limit == 5
+            self.queries.append(query)
+            if query == "索引":
+                return SimpleNamespace(
+                    answer="========== note.md:1 [score=1.0] ==========\n向量索引构建超时"
+                )
+            return SimpleNamespace(answer="")
+
+    service = FakeService()
+    agent = ReMeAgent()
+
+    result, used_queries = await agent._search_memory(service, ["原始问题", "索引"])
+
+    assert service.queries == ["原始问题", "索引"]
+    assert used_queries == ["索引"]
+    assert "向量索引构建超时" in result
+
+
+def test_reme_write_classifier_separates_statements_from_queries():
+    assert ReMeAgent._should_memorize(
+        "上次部署测试环境时遇到向量索引构建超时。"
+    ) is True
+    assert ReMeAgent._should_memorize(
+        "以后修复代码时按这个流程:先复现,再定位,最后验证。"
+    ) is True
+    assert ReMeAgent._should_memorize("上次部署遇到了什么问题?") is False
+
+
+def test_reme_disables_automatic_background_and_dream_jobs():
+    assert set(ReMeAgent._PASSIVE_JOB_OVERRIDES) == {
+        "index_update_loop",
+        "resource_watch_loop",
+        "digest_watch_loop",
+        "dream_cron",
+    }
+    assert all(
+        config == {"backend": "base", "enable_serve": False}
+        for config in ReMeAgent._PASSIVE_JOB_OVERRIDES.values()
+    )
+
+
+@pytest.mark.asyncio
+async def test_reme_semantic_search_filters_low_similarity(monkeypatch):
+    class FakeEmbedding:
+        configured = True
+
+        async def embed(self, _texts):
+            return [[1.0, 0.0]]
+
+    class FakeRepo:
+        async def search_embeddings(self, _system, _query_vector, limit):
+            assert limit == 5
+            return [
+                {
+                    "memory_id": "relevant",
+                    "source": "daily/relevant.md",
+                    "content": "向量索引构建超时,通过分批导入解决。",
+                    "score": 0.40,
+                },
+                {
+                    "memory_id": "irrelevant",
+                    "source": "daily/irrelevant.md",
+                    "content": "前端按钮颜色是蓝色。",
+                    "score": 0.20,
+                },
+            ]
+
+    monkeypatch.setattr(module, "embedding_client", FakeEmbedding())
+    agent = ReMeAgent()
+    agent.repo = FakeRepo()
+
+    evidence, count = await agent._semantic_search("上次部署遇到了什么问题?")
+
+    assert count == 1
+    assert "向量索引构建超时" in evidence
+    assert "按钮颜色" not in evidence

+ 95 - 0
backend/tests/test_text2mem.py

@@ -0,0 +1,95 @@
+import pytest
+
+import app.adapters.text2mem as module
+from app.adapters.text2mem import Text2MemAgent
+from app.db import MemoryRepository
+from app.llm import LLMUnavailable
+
+
+class OfflineLLM:
+    async def chat(self, *_args, **_kwargs):
+        raise LLMUnavailable("test offline")
+
+
+class DisabledEmbedding:
+    configured = False
+
+
+class FailingLLM:
+    async def chat(self, *_args, **_kwargs):
+        raise RuntimeError("temporary provider failure")
+
+
+def build_agent(monkeypatch, tmp_path) -> tuple[Text2MemAgent, MemoryRepository]:
+    repo = MemoryRepository()
+    repo.path = tmp_path / "memory.json"
+    monkeypatch.setattr(module, "embedding_client", DisabledEmbedding())
+    agent = Text2MemAgent()
+    agent.repo = repo
+    agent.llm = OfflineLLM()
+    return agent, repo
+
+
+@pytest.mark.asyncio
+async def test_text2mem_explicit_write(monkeypatch, tmp_path):
+    agent, repo = build_agent(monkeypatch, tmp_path)
+
+    response = await agent.chat("记住:我偏好使用 Python 和 FastAPI")
+
+    assert response.system == "text2mem"
+    assert response.memory_events[0]["status"] == "created"
+    memories = await repo.list_memories("text2mem")
+    assert memories[0].content.startswith("记住")
+    assert memories[0].memory_type == "semantic"
+
+
+@pytest.mark.asyncio
+async def test_text2mem_writes_all_four_memory_types_and_does_not_store_question(
+    monkeypatch,
+    tmp_path,
+):
+    agent, repo = build_agent(monkeypatch, tmp_path)
+    statements = [
+        "记住:我偏好使用 Python 和 FastAPI,代码要求严格类型注解。",
+        "我正在开发一个企业内部智能知识库问答项目,使用通义千问和 RAG。",
+        "更新一下:检索重排功能已经完成 75%,下一步补充效果评测集。",
+        "上次部署测试环境时遇到向量索引构建超时,最后通过分批导入文档解决。",
+        "以后修复代码时按这个流程:先复现问题,再定位原因,修改后运行测试。",
+    ]
+    for statement in statements:
+        await agent.chat(statement)
+
+    memories = await repo.list_memories("text2mem")
+    assert len(memories) == 5
+    assert [item.memory_type for item in memories].count("semantic") == 2
+    assert {item.memory_type for item in memories} == {
+        "semantic",
+        "episodic",
+        "procedural",
+        "task-state",
+    }
+
+    response = await agent.chat("上次部署遇到了什么问题?")
+
+    assert len(await repo.list_memories("text2mem")) == 5
+    assert response.memory_events == []
+    assert any("向量索引构建超时" in item.content for item in response.memory_context)
+    assert all(item.memory_type == "episodic" for item in response.memory_context)
+
+
+def test_text2mem_write_classifier_rejects_normal_questions():
+    assert Text2MemAgent._should_encode("我的项目使用什么数据库?") is False
+    assert Text2MemAgent._should_encode("上次部署遇到了什么问题?") is False
+    assert Text2MemAgent._should_encode("记住:项目数据库使用 PostgreSQL。") is True
+
+
+@pytest.mark.asyncio
+async def test_text2mem_falls_back_when_configured_model_call_fails(monkeypatch, tmp_path):
+    agent, _ = build_agent(monkeypatch, tmp_path)
+    agent.llm = FailingLLM()
+
+    response = await agent.chat("记住:我偏好使用 Python 和 FastAPI。")
+
+    assert response.mode == "local-fallback"
+    assert "模型调用失败" in response.answer
+    assert response.memory_events[0]["status"] == "created"

+ 36 - 0
docker-compose.yml

@@ -0,0 +1,36 @@
+services:
+  postgres:
+    image: pgvector/pgvector:pg16
+    container_name: memory-agents-postgres
+    environment:
+      POSTGRES_USER: memory
+      POSTGRES_PASSWORD: memory
+      POSTGRES_DB: memory_agents
+    ports:
+      - "54329:5432"
+    volumes:
+      - memory_agents_postgres:/var/lib/postgresql/data
+      - ./infra/initdb:/docker-entrypoint-initdb.d:ro
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U memory -d memory_agents"]
+      interval: 5s
+      timeout: 5s
+      retries: 12
+
+  redis:
+    image: redis:7-alpine
+    container_name: memory-agents-redis
+    ports:
+      - "6379:6379"
+    volumes:
+      - memory_agents_redis:/data
+    healthcheck:
+      test: ["CMD", "redis-cli", "ping"]
+      interval: 5s
+      timeout: 3s
+      retries: 12
+
+volumes:
+  memory_agents_postgres:
+  memory_agents_redis:
+

+ 3 - 0
frontend/.env.local.example

@@ -0,0 +1,3 @@
+# Leave unset to use the host serving the page, with backend port 8000.
+# Set this only when the backend uses a different host or port.
+# NEXT_PUBLIC_API_URL=https://api.example.com

Datei-Diff unterdrückt, da er zu groß ist
+ 10 - 0
frontend/app/globals.css


+ 15 - 0
frontend/app/layout.tsx

@@ -0,0 +1,15 @@
+import type { Metadata } from "next";
+import "./globals.css";
+
+export const metadata: Metadata = {
+  title: "Memory Agents Lab",
+  description: "五种 Agent 记忆系统的可视化实战项目",
+};
+
+export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
+  return (
+    <html lang="zh-CN" suppressHydrationWarning>
+      <body>{children}</body>
+    </html>
+  );
+}

+ 177 - 0
frontend/app/page.tsx

@@ -0,0 +1,177 @@
+"use client";
+
+import { FormEvent, useEffect, useMemo, useState } from "react";
+import { Activity, Archive, BrainCircuit, ChevronRight, Database, History, Lock, RefreshCw, Send, Settings2, ShieldCheck, Sparkles, Trash2, Wrench } from "lucide-react";
+
+function apiUrl(path: string): string {
+  if (typeof window === "undefined") return path;
+  const configured = process.env.NEXT_PUBLIC_API_URL?.trim().replace(/\/$/, "");
+  const runtimeBase = `${window.location.protocol}//${window.location.hostname}:8000`;
+  return `${configured || runtimeBase}${path}`;
+}
+
+async function apiFetch(path: string, init?: RequestInit, timeoutMs = 10000): Promise<Response> {
+  const controller = new AbortController();
+  const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
+  try {
+    return await fetch(apiUrl(path), { ...init, signal: controller.signal });
+  } finally {
+    window.clearTimeout(timeout);
+  }
+}
+
+type SystemId = "text2mem" | "mem0" | "letta" | "reme" | "memu";
+type SystemInfo = { id: SystemId; name: string; paradigm: string; description: string; available: boolean; mode: string; status: string; package?: string; setup_hint?: string };
+type Memory = { id: string; system: string; content: string; memory_type: string; source: string; confidence: number; created_at: string; updated_at: string; metadata: Record<string, unknown> };
+type Audit = { id: string; system: string; operation: string; status: string; target?: string; details: Record<string, unknown>; created_at: string };
+type Message = { role: "user" | "assistant" | "system"; content: string; mode?: string; events?: string[] };
+
+const fallbackSystems: SystemInfo[] = [
+  { id: "text2mem", name: "Text2Mem", paradigm: "IR 操作契约", description: "显式定义记忆操作、授权与生命周期。", available: true, mode: "native", status: "ready" },
+  { id: "mem0", name: "Mem0", paradigm: "自动提取", description: "自动提取事实,处理更新与冲突。", available: false, mode: "unavailable", status: "not-configured" },
+  { id: "letta", name: "Letta", paradigm: "有状态 Agent", description: "Core / Archival / Recall 分层工作记忆。", available: false, mode: "unavailable", status: "not-installed" },
+  { id: "reme", name: "ReMe", paradigm: "文件即记忆", description: "Markdown 文件、日志和混合检索。", available: false, mode: "unavailable", status: "not-configured" },
+  { id: "memu", name: "memU", paradigm: "主动式管线", description: "后台整理、候选记忆和主动检索。", available: false, mode: "unavailable", status: "not-configured" },
+];
+
+const icons: Record<SystemId, typeof BrainCircuit> = { text2mem: ShieldCheck, mem0: BrainCircuit, letta: Database, reme: Archive, memu: Sparkles };
+
+function initialMessages(): Record<SystemId, Message[]> {
+  return {
+    text2mem: [{ role: "assistant", content: "你好,我是 Text2Mem Agent。告诉我你的技术偏好、项目进展或希望我记住的工程规则。" }],
+    mem0: [{ role: "assistant", content: "Mem0 适配器当前未启用;完成记忆投影、删除、重置和隔离闭环后再开放对话。" }],
+    letta: [{ role: "assistant", content: "Letta 适配器当前未启用;完成 Agent ID 恢复和分层记忆投影后再开放对话。" }],
+    reme: [{ role: "assistant", content: "你好,我是 ReMe Agent。当前对话只会使用 ReMe 的文件记忆。" }],
+    memu: [{ role: "assistant", content: "memU 适配器当前未启用;完成队列、预算、审批和停止开关后再开放对话。" }],
+  };
+}
+
+function setupSummary(system: SystemInfo): string {
+  if (system.setup_hint) return system.setup_hint;
+  if (system.id === "text2mem") return "本项目内置教学实现,无需额外框架服务。";
+  if (system.id === "reme") return "已连接 ReMe 真实 SDK;右侧每一项代表一个 Markdown 记忆文件。";
+  return "当前为未启用的适配器骨架。";
+}
+
+export default function Home() {
+  const [systems, setSystems] = useState<SystemInfo[]>(fallbackSystems);
+  const [selected, setSelected] = useState<SystemId>("text2mem");
+  const [messagesBySystem, setMessagesBySystem] = useState<Record<SystemId, Message[]>>(initialMessages);
+  const [input, setInput] = useState("");
+  const [memories, setMemories] = useState<Memory[]>([]);
+  const [audit, setAudit] = useState<Audit[]>([]);
+  const [loading, setLoading] = useState(false);
+  const [resetting, setResetting] = useState(false);
+  const [deletingId, setDeletingId] = useState<string | null>(null);
+  const [notice, setNotice] = useState("");
+
+  const current = useMemo(() => systems.find((item) => item.id === selected) ?? fallbackSystems[0], [systems, selected]);
+  const messages = messagesBySystem[selected];
+  const memoryNoun = selected === "reme" ? "记忆文件" : "记忆";
+
+  async function loadSystems() {
+    try {
+      const response = await apiFetch("/api/systems");
+      if (!response.ok) throw new Error("API unavailable");
+      const data = await response.json();
+      setSystems(data.systems);
+      setNotice(`已连接:${apiUrl("")} · 存储:${data.storage}`);
+    } catch (error) {
+      const reason = error instanceof Error ? error.message : "未知错误";
+      setNotice(`连接失败:${apiUrl("")} · ${reason}`);
+    }
+  }
+
+  async function loadDetails(system: SystemId = selected) {
+    try {
+      const [memoryResponse, auditResponse] = await Promise.all([
+        apiFetch(`/api/memories?system=${system}`),
+        apiFetch(`/api/audit?system=${system}`),
+      ]);
+      if (memoryResponse.ok) setMemories((await memoryResponse.json()).memories);
+      if (auditResponse.ok) setAudit((await auditResponse.json()).events);
+    } catch {
+      setMemories([]);
+      setAudit([]);
+    }
+  }
+
+  useEffect(() => { void loadSystems(); void loadDetails(); }, []);
+  useEffect(() => { void loadDetails(selected); }, [selected]);
+
+  async function submit(event: FormEvent) {
+    event.preventDefault();
+    const content = input.trim();
+    if (!content || loading) return;
+    const system = selected;
+    setInput("");
+    setMessagesBySystem((all) => ({ ...all, [system]: [...all[system], { role: "user", content }] }));
+    setLoading(true);
+    try {
+      const response = await apiFetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ system, message: content }) }, 120000);
+      const data = await response.json();
+      if (!response.ok) throw new Error(data.detail ?? "执行失败");
+      setMessagesBySystem((all) => ({ ...all, [system]: [...all[system], { role: "assistant", content: data.answer, mode: data.mode, events: data.memory_events?.map((item: { event?: string }) => item.event).filter(Boolean) }] }));
+      await loadDetails(system);
+    } catch (error) {
+      setMessagesBySystem((all) => ({ ...all, [system]: [...all[system], { role: "system", content: error instanceof Error ? error.message : "执行失败" }] }));
+    } finally {
+      setLoading(false);
+    }
+  }
+
+  async function reset() {
+    if (!window.confirm(`确定清空 ${current.name} 的本地记忆和审计记录吗?`)) return;
+    const system = selected;
+    setResetting(true);
+    try {
+      const response = await apiFetch("/api/reset", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ system }) }, 60000);
+      const data = await response.json();
+      if (!response.ok) throw new Error(data.detail ?? "清空失败");
+      setMessagesBySystem((all) => ({ ...all, [system]: [...initialMessages()[system], { role: "system", content: `${current.name} 的记忆已清空。` }] }));
+      await loadDetails(system);
+    } catch (error) {
+      setNotice(error instanceof Error ? error.message : "清空失败");
+    } finally {
+      setResetting(false);
+    }
+  }
+
+  async function deleteMemory(memory: Memory) {
+    const warning = selected === "reme" ? "该文件可能包含多条事实,删除后会重建 ReMe 索引。\n\n" : "";
+    if (!window.confirm(`确定删除这个${current.name}${memoryNoun}吗?\n\n${warning}${memory.content.slice(0, 120)}`)) return;
+    const system = selected;
+    setDeletingId(memory.id);
+    try {
+      const response = await apiFetch(`/api/memories/${encodeURIComponent(memory.id)}?system=${system}`, { method: "DELETE" }, 60000);
+      const data = await response.json();
+      if (!response.ok) throw new Error(data.detail ?? "删除失败");
+      await loadDetails(system);
+    } catch (error) {
+      setNotice(error instanceof Error ? error.message : "删除失败");
+    } finally {
+      setDeletingId(null);
+    }
+  }
+
+  const Icon = icons[selected];
+  return (
+    <main className="app-shell">
+      <header className="topbar">
+        <div className="brand"><div className="brand-mark"><BrainCircuit size={20} /></div><div><div className="brand-title">Memory Agents Lab</div><div className="brand-subtitle">五种记忆范式 · 同一场景 · 独立证据</div></div></div>
+        <div className="top-actions"><span className="workspace-pill"><Activity size={14} /> local-workspace</span><button className="icon-button" onClick={() => { void loadSystems(); void loadDetails(); }} title="刷新状态"><RefreshCw size={16} /></button></div>
+      </header>
+
+      <section className="hero"><div><p className="eyebrow">AI PROGRAMMING ASSISTANT</p><h1>把记忆从黑箱,<span>变成可观察的系统。</span></h1><p className="hero-copy">五种记忆范式使用同一套工程场景;当前可实测 Text2Mem 与 ReMe,其余适配器保留为后续扩展。</p></div><div className="hero-status"><div className="status-label"><span className="pulse" />系统状态</div><strong>{notice || "正在连接后端…"}</strong><small>模型 API、数据库和框架均可按需配置</small></div></section>
+
+      <div className="workspace-grid">
+        <aside className="system-rail panel"><div className="panel-heading"><div><span className="section-kicker">PARADIGMS</span><h2>记忆系统</h2></div><Settings2 size={17} className="muted" /></div><div className="system-list">{systems.map((system) => { const SystemIcon = icons[system.id]; return <button key={system.id} className={`system-card ${selected === system.id ? "selected" : ""}`} onClick={() => setSelected(system.id)}><div className="system-icon"><SystemIcon size={17} /></div><div className="system-copy"><strong>{system.name}</strong><span>{system.paradigm}</span></div><div className={`availability ${system.available ? "on" : "off"}`} title={system.available ? "可用" : "未配置"} />{selected === system.id && <ChevronRight size={15} className="selected-arrow" />}</button>; })}</div><div className="rail-note"><Lock size={15} /><span>当前是单工作区模式。未来可扩展用户与租户隔离。</span></div></aside>
+
+        <section className="chat-panel panel"><div className="chat-header"><div className="chat-system-title"><div className="system-icon large"><Icon size={20} /></div><div><div className="section-kicker">ACTIVE AGENT</div><h2>{current.name}<span className="mode-chip">{current.mode}</span></h2></div></div><button className="reset-button" onClick={reset} disabled={resetting}><Trash2 size={15} />{resetting ? "清空中" : "清空记忆"}</button></div><div className="agent-description">{current.description} <span className="separator">·</span> {setupSummary(current)}</div><div className="messages">{messages.map((message, index) => <div key={`${message.role}-${index}`} className={`message-row ${message.role}`}><div className="message-avatar">{message.role === "user" ? "你" : message.role === "system" ? "!" : "M"}</div><div className="message-bubble"><div className="message-role">{message.role === "user" ? "USER" : message.role === "system" ? "SYSTEM" : current.name.toUpperCase()}{message.mode && <span className="message-mode">{message.mode}</span>}</div><div className="message-content">{message.content}</div>{message.events && message.events.length > 0 && <div className="event-tags">{message.events.map((event) => <span key={event}>{event}</span>)}</div>}</div></div>)}</div><form className="composer" onSubmit={submit}><textarea value={input} onChange={(event) => setInput(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); void submit(event); } }} placeholder={current.available ? "例如:记住我偏好用 Python + FastAPI,项目数据库选 PostgreSQL…" : "该适配器当前未启用,请先查看上方配置说明。"} rows={2} disabled={!current.available} /><button className="send-button" disabled={loading || !input.trim() || !current.available}>{loading ? <RefreshCw size={17} className="spin" /> : <Send size={17} />}<span>{loading ? "处理中" : "发送"}</span></button></form></section>
+
+        <aside className="inspector panel"><div className="inspector-tabs"><span className="active"><Archive size={15} />记忆</span><span><History size={15} />审计</span></div><div className="inspector-content"><div className="inspector-summary"><div><span className="section-kicker">MEMORY STORE</span><h2>{memories.length}<small> {selected === "reme" ? "个记忆文件" : "条记忆"}</small></h2></div><span className="storage-badge"><Database size={13} />持久化</span></div>{memories.length === 0 ? <div className="empty-state"><Archive size={25} /><strong>还没有记忆</strong><span>在对话中使用“记住”“偏好”“项目”等表达,观察写入事件。</span></div> : <div className="memory-list">{memories.map((memory) => <article className="memory-item" key={memory.id}><div className="memory-meta"><span>{memory.memory_type}</span><time>{new Date(memory.updated_at).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}</time><button className="memory-delete-button" type="button" aria-label={`删除${memoryNoun}:${memory.content.slice(0, 30)}`} title={`删除这个${memoryNoun}`} disabled={deletingId === memory.id} onClick={() => { void deleteMemory(memory); }}><Trash2 size={12} />{deletingId === memory.id ? "删除中" : "删除"}</button></div><p>{memory.content}</p><div className="memory-source">来源:{memory.source} · 置信度:{Math.round(memory.confidence * 100)}%</div></article>)}</div>}<div className="audit-preview"><div className="subheading"><span>最近审计</span><span className="count-badge">{audit.length}</span></div>{audit.slice(0, 5).map((event) => <div className="audit-row" key={event.id}><span className="audit-dot" /><span>{event.operation}</span><time>{new Date(event.created_at).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}</time></div>)}</div></div></aside>
+      </div>
+      <footer><Wrench size={14} /> <span>Text2Mem 和 ReMe 已完成当前阶段闭环;Mem0、Letta 和 memU 仅保留未启用适配器骨架。</span><span className="footer-link">见 README →</span></footer>
+    </main>
+  );
+}

+ 6 - 0
frontend/next-env.d.ts

@@ -0,0 +1,6 @@
+/// <reference types="next" />
+/// <reference types="next/image-types/global" />
+import "./.next/types/routes.d.ts";
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

+ 16 - 0
frontend/next.config.mjs

@@ -0,0 +1,16 @@
+import os from "node:os";
+
+const localNetworkHosts = Object.values(os.networkInterfaces())
+  .flat()
+  .filter((address) => address && address.family === "IPv4" && !address.internal)
+  .map((address) => address.address);
+
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+  reactStrictMode: true,
+  // Next.js protects development assets with an origin allowlist. Detect the
+  // machine's current LAN addresses at startup so no local IP is hard-coded.
+  allowedDevOrigins: localNetworkHosts,
+};
+
+export default nextConfig;

+ 1046 - 0
frontend/package-lock.json

@@ -0,0 +1,1046 @@
+{
+  "name": "memory-agents-web",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "memory-agents-web",
+      "version": "0.1.0",
+      "dependencies": {
+        "lucide-react": "0.468.0",
+        "next": "16.2.10",
+        "react": "19.2.7",
+        "react-dom": "19.2.7"
+      },
+      "devDependencies": {
+        "@types/node": "22.10.2",
+        "@types/react": "19.0.3",
+        "@types/react-dom": "19.0.2",
+        "typescript": "5.7.2"
+      }
+    },
+    "node_modules/@emnapi/runtime": {
+      "version": "1.11.2",
+      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
+      "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@img/colour": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+      "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+      "license": "MIT",
+      "optional": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@img/sharp-darwin-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+      "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-darwin-arm64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-darwin-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+      "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-darwin-x64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-libvips-darwin-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+      "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-darwin-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+      "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-arm": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+      "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+      "cpu": [
+        "arm"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+      "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+      "cpu": [
+        "arm64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-ppc64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+      "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-riscv64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+      "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-s390x": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+      "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+      "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+      "cpu": [
+        "x64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+      "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+      "cpu": [
+        "arm64"
+      ],
+      "libc": [
+        "musl"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+      "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+      "cpu": [
+        "x64"
+      ],
+      "libc": [
+        "musl"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-linux-arm": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+      "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+      "cpu": [
+        "arm"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-arm": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+      "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+      "cpu": [
+        "arm64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-arm64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-ppc64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+      "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-ppc64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-riscv64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+      "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+      "cpu": [
+        "riscv64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-riscv64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-s390x": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+      "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+      "cpu": [
+        "s390x"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-s390x": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+      "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+      "cpu": [
+        "x64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-x64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linuxmusl-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+      "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+      "cpu": [
+        "arm64"
+      ],
+      "libc": [
+        "musl"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linuxmusl-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+      "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+      "cpu": [
+        "x64"
+      ],
+      "libc": [
+        "musl"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-wasm32": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+      "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+      "cpu": [
+        "wasm32"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/runtime": "^1.7.0"
+      },
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+      "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-ia32": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+      "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+      "cpu": [
+        "ia32"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+      "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@next/env": {
+      "version": "16.2.10",
+      "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.10.tgz",
+      "integrity": "sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==",
+      "license": "MIT"
+    },
+    "node_modules/@next/swc-darwin-arm64": {
+      "version": "16.2.10",
+      "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.10.tgz",
+      "integrity": "sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-darwin-x64": {
+      "version": "16.2.10",
+      "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.10.tgz",
+      "integrity": "sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-linux-arm64-gnu": {
+      "version": "16.2.10",
+      "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.10.tgz",
+      "integrity": "sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==",
+      "cpu": [
+        "arm64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-linux-arm64-musl": {
+      "version": "16.2.10",
+      "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.10.tgz",
+      "integrity": "sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==",
+      "cpu": [
+        "arm64"
+      ],
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-linux-x64-gnu": {
+      "version": "16.2.10",
+      "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.10.tgz",
+      "integrity": "sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==",
+      "cpu": [
+        "x64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-linux-x64-musl": {
+      "version": "16.2.10",
+      "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.10.tgz",
+      "integrity": "sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==",
+      "cpu": [
+        "x64"
+      ],
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-win32-arm64-msvc": {
+      "version": "16.2.10",
+      "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.10.tgz",
+      "integrity": "sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-win32-x64-msvc": {
+      "version": "16.2.10",
+      "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.10.tgz",
+      "integrity": "sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@swc/helpers": {
+      "version": "0.5.15",
+      "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
+      "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "tslib": "^2.8.0"
+      }
+    },
+    "node_modules/@types/node": {
+      "version": "22.10.2",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz",
+      "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~6.20.0"
+      }
+    },
+    "node_modules/@types/react": {
+      "version": "19.0.3",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.3.tgz",
+      "integrity": "sha512-UavfHguIjnnuq9O67uXfgy/h3SRJbidAYvNjLceB+2RIKVRBzVsh0QO+Pw6BCSQqFS9xwzKfwstXx0m6AbAREA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "csstype": "^3.0.2"
+      }
+    },
+    "node_modules/@types/react-dom": {
+      "version": "19.0.2",
+      "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.2.tgz",
+      "integrity": "sha512-c1s+7TKFaDRRxr1TxccIX2u7sfCnc3RxkVyBIUA2lCpyqCF+QoAwQ/CBg7bsMdVwP120HEH143VQezKtef5nCg==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "^19.0.0"
+      }
+    },
+    "node_modules/baseline-browser-mapping": {
+      "version": "2.10.43",
+      "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz",
+      "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==",
+      "license": "Apache-2.0",
+      "bin": {
+        "baseline-browser-mapping": "dist/cli.cjs"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/caniuse-lite": {
+      "version": "1.0.30001805",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz",
+      "integrity": "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "CC-BY-4.0"
+    },
+    "node_modules/client-only": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
+      "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
+      "license": "MIT"
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "license": "Apache-2.0",
+      "optional": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/lucide-react": {
+      "version": "0.468.0",
+      "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz",
+      "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==",
+      "license": "ISC",
+      "peerDependencies": {
+        "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
+      }
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.16",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+      "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/next": {
+      "version": "16.2.10",
+      "resolved": "https://registry.npmjs.org/next/-/next-16.2.10.tgz",
+      "integrity": "sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==",
+      "license": "MIT",
+      "dependencies": {
+        "@next/env": "16.2.10",
+        "@swc/helpers": "0.5.15",
+        "baseline-browser-mapping": "^2.9.19",
+        "caniuse-lite": "^1.0.30001579",
+        "postcss": "8.4.31",
+        "styled-jsx": "5.1.6"
+      },
+      "bin": {
+        "next": "dist/bin/next"
+      },
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "optionalDependencies": {
+        "@next/swc-darwin-arm64": "16.2.10",
+        "@next/swc-darwin-x64": "16.2.10",
+        "@next/swc-linux-arm64-gnu": "16.2.10",
+        "@next/swc-linux-arm64-musl": "16.2.10",
+        "@next/swc-linux-x64-gnu": "16.2.10",
+        "@next/swc-linux-x64-musl": "16.2.10",
+        "@next/swc-win32-arm64-msvc": "16.2.10",
+        "@next/swc-win32-x64-msvc": "16.2.10",
+        "sharp": "^0.34.5"
+      },
+      "peerDependencies": {
+        "@opentelemetry/api": "^1.1.0",
+        "@playwright/test": "^1.51.1",
+        "babel-plugin-react-compiler": "*",
+        "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+        "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+        "sass": "^1.3.0"
+      },
+      "peerDependenciesMeta": {
+        "@opentelemetry/api": {
+          "optional": true
+        },
+        "@playwright/test": {
+          "optional": true
+        },
+        "babel-plugin-react-compiler": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "license": "ISC"
+    },
+    "node_modules/postcss": {
+      "version": "8.5.19",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
+      "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.12",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/react": {
+      "version": "19.2.7",
+      "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
+      "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-dom": {
+      "version": "19.2.7",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
+      "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
+      "license": "MIT",
+      "dependencies": {
+        "scheduler": "^0.27.0"
+      },
+      "peerDependencies": {
+        "react": "^19.2.7"
+      }
+    },
+    "node_modules/scheduler": {
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+      "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+      "license": "MIT"
+    },
+    "node_modules/semver": {
+      "version": "7.8.5",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+      "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+      "license": "ISC",
+      "optional": true,
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/sharp": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+      "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+      "hasInstallScript": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "dependencies": {
+        "@img/colour": "^1.0.0",
+        "detect-libc": "^2.1.2",
+        "semver": "^7.7.3"
+      },
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-darwin-arm64": "0.34.5",
+        "@img/sharp-darwin-x64": "0.34.5",
+        "@img/sharp-libvips-darwin-arm64": "1.2.4",
+        "@img/sharp-libvips-darwin-x64": "1.2.4",
+        "@img/sharp-libvips-linux-arm": "1.2.4",
+        "@img/sharp-libvips-linux-arm64": "1.2.4",
+        "@img/sharp-libvips-linux-ppc64": "1.2.4",
+        "@img/sharp-libvips-linux-riscv64": "1.2.4",
+        "@img/sharp-libvips-linux-s390x": "1.2.4",
+        "@img/sharp-libvips-linux-x64": "1.2.4",
+        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+        "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+        "@img/sharp-linux-arm": "0.34.5",
+        "@img/sharp-linux-arm64": "0.34.5",
+        "@img/sharp-linux-ppc64": "0.34.5",
+        "@img/sharp-linux-riscv64": "0.34.5",
+        "@img/sharp-linux-s390x": "0.34.5",
+        "@img/sharp-linux-x64": "0.34.5",
+        "@img/sharp-linuxmusl-arm64": "0.34.5",
+        "@img/sharp-linuxmusl-x64": "0.34.5",
+        "@img/sharp-wasm32": "0.34.5",
+        "@img/sharp-win32-arm64": "0.34.5",
+        "@img/sharp-win32-ia32": "0.34.5",
+        "@img/sharp-win32-x64": "0.34.5"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/styled-jsx": {
+      "version": "5.1.6",
+      "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
+      "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
+      "license": "MIT",
+      "dependencies": {
+        "client-only": "0.0.1"
+      },
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "peerDependencies": {
+        "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
+      },
+      "peerDependenciesMeta": {
+        "@babel/core": {
+          "optional": true
+        },
+        "babel-plugin-macros": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "license": "0BSD"
+    },
+    "node_modules/typescript": {
+      "version": "5.7.2",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz",
+      "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "6.20.0",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
+      "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
+      "dev": true,
+      "license": "MIT"
+    }
+  }
+}

+ 26 - 0
frontend/package.json

@@ -0,0 +1,26 @@
+{
+  "name": "memory-agents-web",
+  "private": true,
+  "version": "0.1.0",
+  "scripts": {
+    "dev": "next dev --hostname 0.0.0.0",
+    "build": "next build",
+    "start": "next start --hostname 0.0.0.0",
+    "lint": "next lint"
+  },
+  "dependencies": {
+    "next": "16.2.10",
+    "react": "19.2.7",
+    "react-dom": "19.2.7",
+    "lucide-react": "0.468.0"
+  },
+  "devDependencies": {
+    "@types/node": "22.10.2",
+    "@types/react": "19.0.3",
+    "@types/react-dom": "19.0.2",
+    "typescript": "5.7.2"
+  },
+  "overrides": {
+    "postcss": "8.5.19"
+  }
+}

+ 36 - 0
frontend/tsconfig.json

@@ -0,0 +1,36 @@
+{
+  "compilerOptions": {
+    "target": "ES2017",
+    "lib": [
+      "dom",
+      "dom.iterable",
+      "esnext"
+    ],
+    "allowJs": false,
+    "skipLibCheck": true,
+    "strict": true,
+    "noEmit": true,
+    "esModuleInterop": true,
+    "module": "esnext",
+    "moduleResolution": "bundler",
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "jsx": "react-jsx",
+    "incremental": true,
+    "plugins": [
+      {
+        "name": "next"
+      }
+    ]
+  },
+  "include": [
+    "next-env.d.ts",
+    ".next/types/**/*.ts",
+    "**/*.ts",
+    "**/*.tsx",
+    ".next/dev/types/**/*.ts"
+  ],
+  "exclude": [
+    "node_modules"
+  ]
+}

+ 2 - 0
infra/initdb/001_extensions.sql

@@ -0,0 +1,2 @@
+CREATE EXTENSION IF NOT EXISTS vector;
+

+ 476 - 0
使用文档.md

@@ -0,0 +1,476 @@
+# Memory Agents Lab 使用文档
+
+本文档面向第一次运行本项目的使用者,说明如何启动 Web 应用、配置模型 API 和 Docker 基础设施。当前可验收的是 Text2Mem 与 ReMe;Mem0、Letta 和 memU 仅保留未启用适配器骨架。
+
+项目目录:
+
+```text
+/memory-agents-web/
+```
+
+## 一、项目组成
+
+```text
+memory-agents-web/
+├── backend/                 # FastAPI 后端和五种记忆适配器
+├── frontend/                # Next.js Web 前端
+├── infra/initdb/            # PostgreSQL 初始化脚本
+├── docker-compose.yml       # PostgreSQL + pgvector + Redis
+├── .env.example             # 后端和基础设施配置模板
+└── 使用文档.md              # 本文档
+```
+
+当前项目是单工作区模式,不需要注册和登录。默认工作区标识为 `local-workspace`。
+
+## 二、环境要求
+
+基础运行环境:
+
+- macOS、Linux 或 Windows + WSL;
+- Docker Desktop;
+- Docker Compose;
+- Python 3.13(运行当前 ReMe 适配器);
+- Node.js 20+;
+- npm;
+- uv,推荐使用它创建 Python 虚拟环境。
+
+只运行 Text2Mem 或基础 Web 界面时,Python 3.11 也可以;但按当前默认配置同时运行 ReMe 时,请统一使用 Python 3.13。
+
+检查环境:
+
+```bash
+docker --version
+docker compose version
+python3 --version
+node --version
+npm --version
+uv --version
+```
+
+## 三、配置文件
+
+### 1. 创建后端配置文件
+
+在项目根目录执行:
+
+```bash
+cd "/memory-agents-web"
+cp .env.example .env
+```
+
+后端启动时会读取项目根目录下的 `.env`。
+
+### 2. 根目录 `.env` 配置项
+
+#### 项目运行配置
+
+```dotenv
+APP_ENV=development
+WORKSPACE_ID=local-workspace
+DATA_DIR=./data
+DEMO_FALLBACK=true
+```
+
+说明:
+
+- `WORKSPACE_ID`:当前单工作区标识;不要在同一个数据库中随意改动,否则会看不到之前的记忆。
+- `DATA_DIR`:本地文件型适配器和降级存储的数据目录。
+- `DEMO_FALLBACK`:保留本地启动能力。真实框架没有安装时不会伪装成已接入,而是显示不可用状态。
+
+#### OpenAI-compatible 模型 API
+
+```dotenv
+LLM_BASE_URL=https://api.openai.com/v1
+LLM_API_KEY=你的模型API-Key
+LLM_MODEL=gpt-4o-mini
+
+EMBEDDING_BASE_URL=https://api.openai.com/v1
+EMBEDDING_API_KEY=你的Embedding API-Key
+EMBEDDING_MODEL=text-embedding-3-small
+EMBEDDING_DIMENSIONS=1536
+EMBEDDING_MIN_SCORE=0.35
+```
+
+当前 ReMe 第二阶段推荐使用通义千问 Embedding:
+
+```dotenv
+EMBEDDING_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
+EMBEDDING_API_KEY=你的DashScope-API-Key
+EMBEDDING_MODEL=text-embedding-v4
+EMBEDDING_DIMENSIONS=1536
+EMBEDDING_MIN_SCORE=0.35
+```
+
+这组配置使用 OpenAI-compatible `/embeddings` 接口。也可以替换为其他兼容 OpenAI API 的 Embedding 服务。`EMBEDDING_MIN_SCORE` 控制项目 pgvector 结果进入回答证据的最低余弦相似度;`0.35` 是当前 `text-embedding-v4` 测试起点,更换模型后应重新标定。
+
+如果只想先体验 Text2Mem,可以暂时不填写 `LLM_API_KEY`。此时 Text2Mem 会使用本地模式回答,并仍然记录记忆和审计事件。
+
+项目启动时会把 `LLM_*` 配置同步给部分依赖 OpenAI 环境变量命名的 SDK。如果某个 SDK 使用特殊配置方式,应以该 SDK 当前版本文档为准。
+
+#### Docker 基础设施连接
+
+```dotenv
+DATABASE_URL=postgresql://memory:memory@localhost:54329/memory_agents
+REDIS_URL=redis://localhost:6379/0
+```
+
+对应的 Docker 服务为:
+
+| 服务 | 地址 | 用途 |
+|---|---|---|
+| PostgreSQL + pgvector | `localhost:54329` | 结构化记忆、向量扩展、审计数据 |
+| Redis | `localhost:6379` | 缓存和异步任务基础设施 |
+
+#### 可选框架配置
+
+```dotenv
+LETTA_BASE_URL=http://localhost:8283
+LETTA_API_KEY=
+LETTA_ENABLED=false
+
+MEM0_ENABLED=false
+REME_ENABLED=true
+MEMU_ENABLED=false
+```
+
+当前只应保持 `REME_ENABLED=true`。Mem0、Letta 和 memU 即使安装了 SDK,也仍应保持关闭,直到各自的持久化、删除、重置和验收闭环补齐。
+
+### 3. 前端配置文件
+
+前端使用单独的配置文件:
+
+```bash
+cd frontend
+cp .env.local.example .env.local
+```
+
+默认保持未设置:
+
+```dotenv
+# NEXT_PUBLIC_API_URL=https://api.example.com
+```
+
+未设置时,前端会在浏览器运行时使用当前页面的协议和主机名,动态组合 `:8000` 后端地址。只有后端使用不同主机或端口时才设置 `NEXT_PUBLIC_API_URL`;不要为局域网访问写死 `localhost` 或某个局域网 IP。
+
+## 四、启动基础设施
+
+回到项目根目录:
+
+```bash
+cd "/memory-agents-web"
+docker compose up -d
+```
+
+查看运行状态:
+
+```bash
+docker compose ps
+```
+
+正常情况下,两个服务都应显示 `healthy` 或 `running`。
+
+检查 pgvector:
+
+```bash
+docker compose exec postgres \
+  psql -U memory -d memory_agents \
+  -c "SELECT extname FROM pg_extension WHERE extname = 'vector';"
+```
+
+停止服务但保留数据卷:
+
+```bash
+docker compose down
+```
+
+停止服务并删除数据库数据:
+
+```bash
+docker compose down -v
+```
+
+`docker compose down -v` 会删除本项目的 PostgreSQL 和 Redis 数据,只有在确认不需要历史记忆时使用。
+
+## 五、启动 FastAPI 后端
+
+### 推荐方式:Text2Mem + ReMe(Python 3.13)
+
+```bash
+cd "/memory-agents-web/backend"
+uv venv --python 3.13 .venv-reme
+source .venv-reme/bin/activate
+uv pip install -e '.[reme]'
+uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
+```
+
+后端地址:
+
+- API:<http://localhost:8000>
+- Swagger 文档:<http://localhost:8000/docs>
+- 健康检查:<http://localhost:8000/api/health>
+
+如果已经创建过 `.venv-reme`,后续只需要:
+
+```bash
+cd backend
+source .venv-reme/bin/activate
+uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
+```
+
+### 精简方式:只运行 Text2Mem(Python 3.11+)
+
+如果将 `.env` 中 `REME_ENABLED=false`,可以只安装基础依赖:
+
+```bash
+cd backend
+uv venv --python 3.11 .venv
+source .venv/bin/activate
+uv pip install -e .
+uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
+```
+
+不要在一个已经安装大量旧版框架依赖的虚拟环境中强行混用不同 Python 版本要求。
+
+## 六、启动 Next.js 前端
+
+另开一个终端窗口:
+
+```bash
+cd "/memory-agents-web/frontend"
+cp .env.local.example .env.local
+npm install
+npm run dev
+```
+
+本机打开:<http://localhost:3000>;局域网设备打开:`http://本机局域网IP:3000`。前端会在每次请求时根据当前页面动态计算同一主机的 `:8000` 后端地址;Next.js 开发服务器也会在启动时自动读取本机网卡地址,不需要写死局域网 IP。
+
+前端页面包含:
+
+- 五种记忆系统切换;
+- AI 编程助手对话区;
+- 当前系统状态;
+- 当前记忆列表;
+- 最近审计事件;
+- 清空当前系统记忆按钮。
+
+## 七、五种适配器状态
+
+### 1. Text2Mem
+
+Text2Mem 是本项目内置的教学型 IR 执行引擎,不需要额外安装 SDK。
+
+在对话中发送类似内容:
+
+```text
+记住:我偏好使用 Python + FastAPI,数据库选 PostgreSQL。
+```
+
+系统会记录:
+
+- `ENC/Encode` 写入操作;
+- 记忆内容、来源和置信度;
+- `RET/Retrieve` 检索操作;
+- 审计事件。
+
+当前还会将陈述分为 `semantic`、`episodic`、`procedural` 和 `task-state`,对完全重复的内容去重,并使用关键词、记忆类型与可选 Embedding 进行融合检索。“上次部署遇到了什么问题?”这类问句只检索,不会被误存为新记忆。
+
+### 2. Mem0(后续项)
+
+当前只有试验性 `add/search` 骨架,右侧记忆投影、单条删除、重置和冲突验收尚未完成。下列命令只用于后续开发,不表示当前已可用。
+
+安装 SDK:
+
+```bash
+cd backend
+source .venv-reme/bin/activate
+uv pip install mem0ai
+```
+
+当前在根目录 `.env` 中保持关闭:
+
+```dotenv
+MEM0_ENABLED=false
+```
+
+Mem0 的模型、Embedding 和向量后端会随版本变化。后续补齐闭环时请先核对官方 Python Quickstart:<https://docs.mem0.ai/open-source/python-quickstart>。
+
+### 3. Letta(后续项)
+
+当前尚未完成 Agent ID 持久化、Core / Archival / Recall 投影和删除闭环。下列命令只用于后续开发。
+
+安装客户端:
+
+```bash
+cd backend
+source .venv-reme/bin/activate
+uv pip install letta-client
+```
+
+启动目标版本的 Letta 服务端,并确认服务地址。例如:
+
+```dotenv
+LETTA_BASE_URL=http://localhost:8283
+LETTA_API_KEY=
+```
+
+当前保持 `LETTA_ENABLED=false`,不会启动或调用 Letta。
+
+Letta 的 Python 客户端文档:<https://docs.letta.com/api/python>。
+
+注意:Letta 客户端和服务端需要版本匹配。如果服务端 API 变化,Web 端会显示调用错误,不会自动降级成假数据。
+
+### 4. ReMe(当前启用)
+
+当前适配器使用官方 SDK 依赖和 Python 3.13:
+
+```bash
+cd backend
+source .venv-reme/bin/activate
+uv pip install -e '.[reme]'
+```
+
+回到项目后启用:
+
+```dotenv
+REME_ENABLED=true
+```
+
+ReMe 会通过 `auto_memory` 将稳定偏好、项目事实、进度变化、可复用故障经验和长期流程规则写入 Markdown,再用 `reindex` 建立文件索引。ReMe 0.4.1.0 虽支持向量 + BM25/RRF,但本项目当前保持其内部 `embedding_store` 为空,实际使用查询改写、工程术语扩展和多查询 BM25;配置 Qwen Embedding 后,由项目的 pgvector 索引提供向量召回。两路结果去重后,模型只依据证据回答。官方仓库:<https://github.com/agentscope-ai/ReMe>。
+
+适配器会使用文件型记忆,并将数据放在项目 `DATA_DIR` 对应目录下。
+
+需要特别理解两点:
+
+- ReMe 只对稳定偏好、项目事实、进度变化、可复用故障经验和长期流程规则运行 `auto_memory`;普通问句只检索。
+- 右侧面板统计的是 Markdown 记忆文件数,不是原子事实数。一个文件可以合并多条对话事实;单条删除按钮实际删除整个文件并重建索引。
+- ReMe 默认的后台文件监听、资源整理、摘要监听和 Dream 定时任务已关闭自动调度,避免无人操作时修改文件或产生模型成本;每次聊天仍会显式 `reindex`,扩展能力需另行接入对应 Job。
+
+### 5. memU(后续项)
+
+当前尚未完成后台队列、重试、预算、同意与候选记忆审批闭环。下列命令只用于后续开发。
+
+memU 当前官方仓库要求较新的 Python 版本,建议使用 Python 3.13 虚拟环境。安装:
+
+```bash
+git clone https://github.com/NevaMind-AI/memU.git /tmp/memU
+cd /tmp/memU
+git rev-parse HEAD
+uv pip install -e .
+```
+
+当前保持关闭:
+
+```dotenv
+MEMU_ENABLED=false
+```
+
+memU 官方仓库:<https://github.com/NevaMind-AI/memU>。
+
+后续实现目标是只生成待审批的候选记忆,默认不向用户主动推送,也不自动执行高风险动作;这些尚不是当前可验收功能。
+
+## 八、常用 API
+
+```text
+GET  /api/health
+GET  /api/systems
+POST /api/chat
+GET  /api/memories?system=text2mem
+DELETE /api/memories/{memory_id}?system=text2mem
+GET  /api/audit?system=text2mem
+POST /api/reset
+```
+
+聊天请求示例:
+
+```bash
+curl -X POST http://localhost:8000/api/chat \
+  -H 'content-type: application/json' \
+  -d '{
+    "system": "text2mem",
+    "message": "记住:项目使用 PostgreSQL 和 FastAPI"
+  }'
+```
+
+## 九、测试和构建
+
+后端:
+
+```bash
+cd backend
+source .venv-reme/bin/activate
+uv pip install "pytest>=8.3,<9" "pytest-asyncio>=0.25,<1" "ruff>=0.9,<1"
+python -m pytest -q
+ruff check app tests
+python -m compileall -q app tests
+```
+
+前端:
+
+```bash
+cd frontend
+npm run build
+npm audit --omit=dev
+```
+
+## 十、常见问题
+
+### 页面显示“后端尚未启动”
+
+确认后端是否运行:
+
+```bash
+curl http://localhost:8000/api/health
+```
+
+如果无法访问,请先启动 FastAPI。
+
+### 页面显示 `postgres` 之外的存储模式
+
+这表示后端连接 PostgreSQL 失败,暂时使用了本地 JSON 降级存储。请检查:
+
+```bash
+docker compose ps
+docker compose logs postgres
+```
+
+并确认 `.env` 中的 `DATABASE_URL` 与 Docker 端口一致。
+
+`/api/health` 会在降级时附带 `storage_error`。如果更换了 Embedding 向量维度,现有 `memory_embeddings` 表与新维度不一致也会触发降级;应先迁移或在确认无需历史数据后重建数据卷。
+
+### 当前只有 Text2Mem 和 ReMe 可用
+
+当前项目只启用 Text2Mem 和 ReMe;Mem0、Letta、memU 保持关闭。查看 `/api/systems` 可以获得具体 `setup_hint`。
+
+### 模型 API 没有配置
+
+Text2Mem 仍可使用本地证据模式,但不会调用外部模型。若要让模型生成自然语言回答,请填写 `LLM_API_KEY` 并重启后端;模型服务临时失败时,Text2Mem 也会自动退回本地证据模式,写入和检索仍可继续。
+
+### ReMe 处理时间较长
+
+ReMe 一轮写入会依次执行记忆提取、重建索引、可选向量同步、检索和证据回答,通常比 Text2Mem 慢。前端聊天请求允许最长 120 秒,删除和重置允许 60 秒;处理中不要重复发送同一条消息。如果超过时限,检查后端日志和模型服务延迟。
+
+### 如何清空记忆
+
+可以在 Web 页面点击“清空记忆”,也可以调用:
+
+```bash
+curl -X POST http://localhost:8000/api/reset \
+  -H 'content-type: application/json' \
+  -d '{"system":"text2mem"}'
+```
+
+如果要清空所有系统:
+
+```bash
+curl -X POST http://localhost:8000/api/reset \
+  -H 'content-type: application/json' \
+  -d '{}'
+```
+
+## 十一、安全提醒
+
+- 不要把 `.env`、API Key、真实用户数据提交到 Git;
+- 不要在公开网络直接暴露没有登录和权限控制的单工作区 Web 端;
+- `docker compose down -v` 会删除本地数据库数据;
+- 当前没有注册、登录和多租户隔离,只适合本地学习、演示和受控内网;
+- 启用主动式记忆前,应先配置预算、频率限制、关闭开关和人工审批。

+ 242 - 0
内部使用文档.md

@@ -0,0 +1,242 @@
+# Memory Agents Lab 内部使用文档
+
+本文档只说明本项目内部开发环境的基础设施、后端和前端如何配置与启动。
+
+当前运行方式:
+
+- PostgreSQL、pgvector、Redis:Docker 启动;
+- FastAPI 后端:本地启动;
+- Next.js 前端:本地启动;
+- 当前可用记忆系统:Text2Mem、ReMe;
+- 当前为单工作区模式,不需要注册和登录。
+
+## 一、Docker 基础设施
+
+### 1. Docker 需要配置什么
+
+项目使用根目录下的 `docker-compose.yml`,默认不需要修改。
+
+| 服务 | 容器端口 | 本机端口 | 用途 |
+|---|---:|---:|---|
+| PostgreSQL + pgvector | 5432 | 54329 | 记忆、审计和向量数据 |
+| Redis | 6379 | 6379 | 缓存和异步任务基础设施 |
+
+后端 `.env` 中的连接配置应与上面的本机端口一致:
+
+```dotenv
+DATABASE_URL=postgresql://memory:memory@localhost:54329/memory_agents
+REDIS_URL=redis://localhost:6379/0
+```
+
+### 2. 启动 Docker 服务
+
+在项目根目录执行:
+
+```bash
+docker compose up -d
+docker compose ps
+```
+
+正常状态:PostgreSQL 和 Redis 都显示 `healthy` 或 `running`。
+
+检查 PostgreSQL 的 pgvector:
+
+```bash
+docker compose exec postgres \
+  psql -U memory -d memory_agents \
+  -c "SELECT extname FROM pg_extension WHERE extname = 'vector';"
+```
+
+停止服务但保留数据:
+
+```bash
+docker compose down
+```
+
+停止服务并删除数据库数据:
+
+```bash
+docker compose down -v
+```
+
+`docker compose down -v` 会删除 PostgreSQL 和 Redis 数据卷,请谨慎使用。
+
+## 二、本地后端服务
+
+### 1. 后端需要配置什么
+
+在项目根目录创建 `.env`:
+
+```bash
+cp .env.example .env
+```
+
+至少配置以下内容:
+
+```dotenv
+APP_ENV=development
+WORKSPACE_ID=local-workspace
+DATA_DIR=./data
+DEMO_FALLBACK=true
+
+# DeepSeek 对话模型
+LLM_BASE_URL=https://api.deepseek.com/v1
+LLM_API_KEY=你的DeepSeek_API_Key
+LLM_MODEL=deepseek-chat
+
+# Qwen Embedding
+EMBEDDING_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
+EMBEDDING_API_KEY=你的DashScope_API_Key
+EMBEDDING_MODEL=text-embedding-v4
+EMBEDDING_DIMENSIONS=1536
+EMBEDDING_MIN_SCORE=0.35
+
+# Docker 服务
+DATABASE_URL=postgresql://memory:memory@localhost:54329/memory_agents
+REDIS_URL=redis://localhost:6379/0
+
+# 当前启用状态
+LETTA_ENABLED=false
+MEM0_ENABLED=false
+REME_ENABLED=true
+MEMU_ENABLED=false
+```
+
+说明:
+
+- `LLM_*` 用于自然语言回答和 ReMe 的记忆提取;
+- `EMBEDDING_*` 用于 Text2Mem/ReMe 的向量召回;
+- DeepSeek Chat API 不替代 Embedding API;
+- `EMBEDDING_DIMENSIONS` 必须与 Embedding API 返回向量维度一致;
+- 不要把真实 API Key 提交到 Git;
+- `WORKSPACE_ID` 当前固定为 `local-workspace`,不要随意修改,否则可能看不到原有记忆。
+
+### 2. 创建 Python 环境
+
+当前 Text2Mem + ReMe 使用 Python 3.13:
+
+```bash
+cd "/memory-agents-web/backend"
+uv venv --python 3.13 .venv-reme
+source .venv-reme/bin/activate
+uv pip install -e '.[reme]'
+```
+
+如果已经创建过环境,只需激活:
+
+```bash
+cd "/memory-agents-web/backend"
+source .venv-reme/bin/activate
+```
+
+### 3. 启动后端
+
+```bash
+cd "/memory-agents-web/backend"
+source .venv-reme/bin/activate
+uvicorn app.main:app --host 0.0.0.0 --port 8000
+```
+
+后端地址:
+
+- API:<http://localhost:8000>
+- Swagger:<http://localhost:8000/docs>
+- 健康检查:<http://localhost:8000/api/health>
+
+检查后端:
+
+```bash
+curl http://localhost:8000/api/health
+```
+
+期望结果中包含:
+
+```json
+{
+  "status": "ok",
+  "storage": "postgres",
+  "llm_configured": true,
+  "embedding_configured": true
+}
+```
+
+## 三、本地前端服务
+
+### 1. 前端需要配置什么
+
+前端配置文件为 `frontend/.env.local`:
+
+```bash
+cd "/memory-agents-web/frontend"
+cp .env.local.example .env.local
+```
+
+默认保持空文件即可:
+
+```dotenv
+# NEXT_PUBLIC_API_URL=https://api.example.com
+```
+
+未设置 `NEXT_PUBLIC_API_URL` 时,前端会根据浏览器当前访问地址动态拼接后端地址:
+
+```text
+当前页面 http://localhost:3000       → 后端 http://localhost:8000
+当前页面 http://192.168.x.x:3000     → 后端 http://192.168.x.x:8000
+```
+
+因此不要把 `localhost` 或某个固定局域网 IP 写入前端配置。只有后端与前端不在同一主机或使用不同端口时,才配置 `NEXT_PUBLIC_API_URL`。
+
+### 2. 安装并启动前端
+
+```bash
+cd "/memory-agents-web/frontend"
+npm install
+npm run dev
+```
+
+前端会监听 `0.0.0.0:3000`。
+
+访问地址:
+
+- 本机:<http://localhost:3000>
+- 局域网:`http://本机局域网IP:3000`
+
+## 四、完整启动顺序
+
+建议打开三个终端窗口。
+
+终端 1:启动 Docker:
+
+```bash
+cd "/memory-agents-web"
+docker compose up -d
+```
+
+终端 2:启动后端:
+
+```bash
+cd "/memory-agents-web/backend"
+source .venv-reme/bin/activate
+uvicorn app.main:app --host 0.0.0.0 --port 8000
+```
+
+终端 3:启动前端:
+
+```bash
+cd "/memory-agents-web/frontend"
+npm run dev
+```
+
+最后打开:<http://localhost:3000>。
+
+## 五、停止本地服务
+
+前端和后端所在终端按 `Ctrl+C` 停止。
+
+Docker 服务执行:
+
+```bash
+cd "/memory-agents-web"
+docker compose down
+```
+

+ 568 - 0
测试文档.md

@@ -0,0 +1,568 @@
+# Memory Agents Lab 测试文档
+
+本文档用于判断配套 Web 项目的记忆链路是否“能运行”,以及是否体现了对应范式的设计效果。**当前只对 Text2Mem 和 ReMe 执行实测验收**;Mem0、Letta 和 memU 的章节是后续实现时的验收清单。
+
+测试对象:
+
+- Text2Mem:IR 操作契约与治理;
+- Mem0:自动提取、更新、冲突处理和检索;
+- Letta:有状态 Agent、Core / Archival / Recall;
+- ReMe:文件可读、可审阅和混合检索;
+- memU:异步摄入、候选记忆和主动式整理。
+
+## 一、先明确当前项目的测试边界
+
+本项目目前是一个可扩展的 Web 适配器项目,不等同于五个框架全部功能的完整复刻。
+
+| 系统 | 当前状态 | 本阶段可验证边界 |
+|---|---|---|
+| Text2Mem | 已完成当前闭环 | Encode / Retrieve、四类记忆标注、去重、混合检索、删除、审计和 PostgreSQL 优先持久化 |
+| ReMe | 已完成当前闭环 | 官方 `reme-ai 0.4.x` SDK、Markdown 记忆、多查询 BM25 + 可选 Qwen/pgvector 向量召回、文件删除与重建索引 |
+| Mem0 | 未启用适配器骨架 | 当前不做功能验收;需先补齐记忆投影、删除、重置、冲突和隔离闭环 |
+| Letta | 未启用适配器骨架 | 当前不做功能验收;需先补齐 Agent ID 恢复、Core / Archival / Recall 投影与删除 |
+| memU | 未启用适配器骨架 | 当前不做功能验收;需先补齐队列、预算、审批与停止开关 |
+
+因此,测试结果应分为:
+
+1. **基础可用**:系统能否启动、调用和返回结果;
+2. **范式效果**:是否体现该系统的核心设计价值;
+3. **生产质量**:准确率、延迟、成本、隔离、恢复和治理是否达标。
+
+## 二、测试前准备
+
+### 1. 启动基础设施
+
+```bash
+cd "/Users/marssheep/Desktop/miaoa/课件文档/AI/记忆系统实战项目/memory-agents-web"
+docker compose up -d
+docker compose ps
+```
+
+确认 PostgreSQL 和 Redis 都正常运行。
+
+### 2. 配置模型
+
+根目录 `.env` 至少应包含:
+
+```dotenv
+LLM_BASE_URL=https://api.deepseek.com/v1
+LLM_API_KEY=你的DeepSeek-Key
+LLM_MODEL=deepseek-chat
+
+DATABASE_URL=postgresql://memory:memory@localhost:54329/memory_agents
+REDIS_URL=redis://localhost:6379/0
+```
+
+需要 Embedding 的框架还必须配置独立的 Embedding 服务:
+
+```dotenv
+EMBEDDING_BASE_URL=你的Embedding服务地址
+EMBEDDING_API_KEY=你的Embedding-Key
+EMBEDDING_MODEL=你的Embedding模型名
+EMBEDDING_DIMENSIONS=1536
+EMBEDDING_MIN_SCORE=0.35
+```
+
+不要默认认为 DeepSeek Chat API 同时提供 Embedding API。
+
+### 3. 启动后端和前端
+
+后端:
+
+```bash
+cd backend
+source .venv-reme/bin/activate
+uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
+```
+
+前端:
+
+```bash
+cd frontend
+npm run dev
+```
+
+打开 <http://localhost:3000>。
+
+### 4. 检查系统状态
+
+```bash
+curl http://localhost:8000/api/health
+curl http://localhost:8000/api/systems
+```
+
+基础要求:
+
+- `health.status` 为 `ok`;
+- `health.storage` 为 `postgres`;
+- 响应中不存在 `storage_error`;
+- `llm_configured` 为 `true`;
+- 若要验收 Qwen + pgvector 二路召回,`embedding_configured` 必须为 `true`;
+- Text2Mem 的 `available` 为 `true`;
+- ReMe 的 `available` 为 `true`;
+- Mem0、Letta 和 memU 在当前阶段应为 `false`。
+
+## 三、统一测试数据
+
+当前先让 Text2Mem 和 ReMe 使用同一套对话;未来补齐其他三种适配器时继续复用这套数据,避免因为测试数据不同而误判。
+
+### 场景 A:稳定偏好
+
+```text
+记住:我偏好使用 Python 和 FastAPI,代码要求有严格类型注解。
+```
+
+### 场景 B:项目事实
+
+```text
+我正在开发一个企业内部智能知识库问答项目,使用通义千问和 RAG,数据库选择 PostgreSQL。
+```
+
+### 场景 C:状态变化
+
+```text
+项目目前完成了文档解析和向量化模块,正在开发检索重排功能。
+```
+
+```text
+更新一下:检索重排功能已经完成 75%,下一步是补充效果评测集和集成测试。
+```
+
+### 场景 D:历史事件
+
+```text
+上次部署测试环境时遇到向量索引构建超时,最后通过分批导入文档并调整批次大小解决。
+```
+
+### 场景 E:程序性记忆
+
+```text
+以后修复代码时按这个流程:先复现问题,再定位原因,修改后运行测试,最后记录验证结果。
+```
+
+### 场景 F:相似但不应混淆的内容
+
+```text
+我喜欢 Python,但这个项目的前端使用 TypeScript 和 React。
+```
+
+## 四、通用测试流程
+
+对 Text2Mem 和 ReMe 执行以下流程,并分别记录结果。不要在当前版本强行开启其他三个骨架。
+
+### 测试 1:启动与配置
+
+检查:
+
+- Web 页面能加载;
+- 系统状态显示正确;
+- 未配置的系统显示不可用原因,而不是假装成功;
+- 后端日志没有未处理异常。
+
+通过标准:系统状态与实际 SDK / 服务状态一致。
+
+### 测试 2:写入记忆
+
+依次发送场景 A、B、D、E。
+
+检查:
+
+- 是否写入了持久记忆;
+- 是否保留来源、时间和记忆类型;
+- 是否生成了审计事件;
+- 重启后端后记忆是否仍然存在。
+
+通过标准:重启后仍能读取重要记忆,且记忆不依赖当前页面状态。
+
+### 测试 3:精准检索
+
+依次提问:
+
+```text
+我的后端技术栈是什么?
+上次部署遇到什么问题?
+我要求的代码修复流程是什么?
+```
+
+检查:
+
+- 返回的是相关记忆;
+- 没有把无关内容大量塞入上下文;
+- 历史事件和程序流程没有混淆;
+- 回答能够区分事实、历史和推断。
+
+### 测试 4:状态变化和冲突
+
+先写入:
+
+```text
+前端使用 React。
+```
+
+再写入:
+
+```text
+更新一下,前端已经改成 Vue 3。
+```
+
+再提问:
+
+```text
+我当前的前端技术栈是什么?历史上用过什么?
+```
+
+通过标准:
+
+- 当前值为 Vue 3;
+- React 可以作为历史值保留,或明确标记为失效;
+- 不应无来源地同时回答“当前是 React 和 Vue 3”;
+- 结果最好包含更新时间、来源和冲突处理方式。
+
+> 这项是诊断性测试,不是当前 Text2Mem L2 的通过前提。Text2Mem 尚未实现 Update 和通用冲突消解;如果新旧值只是并存,应如实记录为当前限制,不能判为通过。
+
+### 测试 5:清空和恢复
+
+> 本测试会删除数据,只在专用测试工作区或确认不需要现有记忆后执行。
+
+1. 在 Web 页面清空当前系统记忆;
+2. 调用 `/api/memories` 确认记忆为空;
+3. 重启后端;
+4. 再次检查记忆是否仍为空;
+5. 重新写入一条记忆,确认系统可以恢复工作。
+
+## 五、Text2Mem 测试
+
+### 目标
+
+验证“先定义受限记忆操作,再由执行层校验和审计”的效果。
+
+### 基础测试
+
+在 Web 中选择 Text2Mem,发送:
+
+```text
+记住:我偏好使用 Python 和 FastAPI,代码要求有严格类型注解。
+```
+
+检查返回结果:
+
+- `mode` 为 `llm` 或 `local-fallback`;
+- `memory_events` 中出现 `ENC/Encode`;
+- 记忆面板出现一条新记忆;
+- 审计中出现 `ENC/Encode` 和 `RET/Retrieve`;
+- 数据库中能够查到记录。
+
+再依次发送场景 B、C 的最新进度、D、E,应当总共得到 5 条记忆,其中:
+
+- A、B 为 `semantic`;
+- D 为 `episodic`;
+- E 为 `procedural`;
+- C 为 `task-state`。
+
+然后发送“上次部署遇到了什么问题?”:记忆总数不应增加,召回上下文应包含“向量索引构建超时”。
+
+数据库检查:
+
+```bash
+docker compose exec postgres psql -U memory -d memory_agents \
+  -c "SELECT system, content, source, confidence FROM memory_items WHERE workspace_id='local-workspace' AND system='text2mem';"
+```
+
+### 范式效果验收
+
+| 验收项 | 通过标准 |
+|---|---|
+| 操作可见 | 能看到 Encode / Retrieve 等操作 |
+| 来源可追溯 | 记忆含来源、置信度和时间 |
+| 审计完整 | 写入和读取都有审计事件 |
+| 受限写入 | 问句不写入;稳定偏好、项目事实、历史故障和长期流程可写入 |
+| 分类检索 | 历史问题优先召回 episodic,流程问题优先召回 procedural |
+| 去重 | 完全相同的陈述重复发送后不新建记忆 |
+| 可恢复 | 后端重启后记忆仍在 |
+
+### 当前限制
+
+当前 Web 适配器实现了 Encode / Retrieve、记忆分类、去重、关键词 + 类型 + 可选 Embedding 检索、公共删除与审计路径。Lock、Expire、Merge、Split、Update 等完整 IR 处理器仍是后续扩展项,不要把当前 Web 版本误判为完整 Text2Mem 引擎。
+
+## 六、Mem0 后续验收清单(当前不执行)
+
+> 当前适配器未完成公共记忆投影、单条删除、重置和冲突验收闭环,应保持 `MEM0_ENABLED=false`。本章只供后续开发。
+
+### 前置条件
+
+```bash
+cd backend
+source .venv-reme/bin/activate
+uv pip install mem0ai
+```
+
+后续实现完成后才允许修改 `.env`:
+
+```dotenv
+MEM0_ENABLED=false  # 当前保持关闭
+```
+
+后续需同时配置可用的 Embedding 服务,并且只有在下方验收全部通过后才能把 Mem0 标记为可用。
+
+### 测试步骤
+
+1. 发送场景 A、B;
+2. 发送场景 C 的进度更新;
+3. 发送 React → Vue 3 的冲突测试;
+4. 提问“当前前端技术栈是什么”;
+5. 检查返回中的 Mem0 add / search 结果;
+6. 重启后端,再次检索。
+
+### 范式效果验收
+
+| 验收项 | 通过标准 |
+|---|---|
+| 自动提取 | 用户只聊天,系统能提取稳定事实 |
+| 冲突处理 | 新旧值有明确更新、失效或冲突标记 |
+| 相关检索 | 查询只返回相关记忆 |
+| 持久化 | 重启后记忆仍可检索 |
+| 范围隔离 | 使用固定 `WORKSPACE_ID`,不能读到其他命名空间 |
+
+右侧公共记忆列表、单条删除和 `/api/reset` 必须与 Mem0 内部存储一致;不能再以“只看 Mem0 检索结果”规避投影不完整问题。
+
+## 七、Letta 后续验收清单(当前不执行)
+
+> 当前适配器没有持久化 Agent ID,也没有完成 Core / Archival / Recall 投影与删除闭环,应保持 `LETTA_ENABLED=false`。
+
+### 前置条件
+
+```bash
+cd backend
+source .venv-reme/bin/activate
+uv pip install letta-client
+```
+
+启动目标版本的 Letta 服务端,并设置:
+
+```dotenv
+LETTA_BASE_URL=http://localhost:8283
+LETTA_API_KEY=
+```
+
+确认服务端和 Python 客户端版本匹配。
+
+### 测试步骤
+
+1. 选择 Letta;
+2. 发送项目名、技术栈和项目进度;
+3. 继续发送一条代码问题;
+4. 再提问“我的项目名和技术栈是什么”;
+5. 重启 FastAPI,但不要删除 Letta 服务端数据;
+6. 再次发送消息,确认 Agent 状态仍然存在。
+
+### 范式效果验收
+
+| 验收项 | 通过标准 |
+|---|---|
+| Core Memory | 高频项目上下文能持续出现在 Agent 状态中 |
+| Archival Memory | 长期内容需要搜索时能被召回 |
+| Recall Memory | 历史对话可以回溯 |
+| Agent 状态 | 不是每次请求都创建全新 Agent |
+| 工具权限 | Agent 只能访问当前工作区允许的记忆 |
+
+如果每次 Web 请求都创建新的 Letta Agent,说明状态复用逻辑没有通过,应记录为失败。
+
+## 八、ReMe 测试
+
+### 前置条件
+
+使用项目已约束的 `reme-ai[core]>=0.4,<0.5` 依赖:
+
+```bash
+cd backend
+source .venv-reme/bin/activate
+uv pip install -e '.[reme]'
+python -c "from importlib.metadata import version; print(version('reme-ai'))"
+```
+
+启用:
+
+```dotenv
+REME_ENABLED=true
+```
+
+### 测试步骤
+
+1. 发送场景 A、B、D、E;
+2. 检查 `DATA_DIR` 下是否生成文件型记忆;
+3. 使用 `find DATA_DIR/reme/daily -name '*.md' -print` 找到并打开 ReMe 记忆文件;文件可能是 `daily/YYYY-MM-DD.md` 日期索引,也可能位于日期子目录;
+4. 在 Web 对话框中分别提问“上次部署遇到了什么问题?”“我要求的代码修复流程是什么?”“我的后端技术偏好是什么?”;
+5. 确认上述问句本身没有被 `auto_memory` 写入新的 Markdown 记忆;
+6. 修改一个可审阅文件后发送下一条查询,让适配器自动执行 `reindex` 和向量同步;也可以通过 Web 删除整个记忆文件,删除接口会立即重建 ReMe 索引;
+7. 检查检索结果是否与文件内容一致。
+
+> **数量口径:** ReMe 的右侧面板按 Markdown 文件计数,不按原子事实计数。A、B、D、E 四段陈述可能被合并到少量文件,也可能按主题拆分;文件数量本身不能判断是否丢失。必须打开文件检查四类信息是否都存在,再通过三类查询验证召回。
+
+### 范式效果验收
+
+| 验收项 | 通过标准 |
+|---|---|
+| 文件可读 | 人可以直接打开并理解记忆内容 |
+| 文件可编辑 | 修改文件后系统能够重新读取或索引 |
+| 任务记忆 | 失败经验和成功流程能够再次召回 |
+| 混合检索 | ReMe 多查询 BM25 有效;配置项目 Embedding 后,pgvector 向量召回也有命中证据 |
+| 查询不污染 | 提问只触发检索,不新建长期记忆文件 |
+| 一致性 | 文件内容与索引结果没有明显不一致 |
+
+## 九、memU 后续验收清单(当前不执行)
+
+> 当前适配器没有完成后台队列、重试、预算、用户同意与停止开关,应保持 `MEMU_ENABLED=false`。本章只供后续开发。
+
+### 前置条件
+
+建议 Python 3.13:
+
+```bash
+cd backend
+uv venv --python 3.13 .venv-memu
+source .venv-memu/bin/activate
+uv pip install -e .
+git clone https://github.com/NevaMind-AI/memU.git /tmp/memU
+cd /tmp/memU
+git rev-parse HEAD
+uv pip install -e .
+```
+
+当前配置:
+
+```dotenv
+MEMU_ENABLED=false  # 当前保持关闭
+```
+
+### 测试步骤
+
+连续三天或模拟三组活动:
+
+```text
+第 1 组:我在研究 RAG 的文档分块策略。
+第 2 组:我想了解向量检索与关键词检索的融合方法。
+第 3 组:我正在设计知识库问答的重排序与效果评测。
+```
+
+检查:
+
+- 对话被摄入为活动记录;
+- retrieve 能返回当前问题相关的上下文;
+- 系统生成的是候选兴趣或候选记忆,而不是未经确认的用户事实;
+- 后台处理不会阻塞主对话;
+- 关闭主动策略后不会继续主动触达。
+
+### 范式效果验收
+
+| 验收项 | 通过标准 |
+|---|---|
+| 异步摄入 | 记忆整理与主对话解耦 |
+| 候选记忆 | 推断带有候选或置信度标记 |
+| 相关上下文 | 只返回当前任务需要的信息 |
+| 预算控制 | 有任务数量、Token 或时间上限 |
+| 主动边界 | 默认不推送、不自动执行高风险动作 |
+
+## 十、统一指标记录
+
+当前对 Text2Mem 和 ReMe 各至少记录 10 次同类查询;其他适配器实现后使用相同口径:
+
+| 指标 | 记录方式 |
+|---|---|
+| 写入成功率 | 成功写入次数 / 写入总次数 |
+| 检索命中率 | 返回正确记忆的查询数 / 查询总数 |
+| 冲突正确率 | 正确识别新旧值的次数 / 冲突测试次数 |
+| 无关召回率 | 返回无关记忆的次数 / 查询总数 |
+| p50 延迟 | 50% 请求低于该耗时 |
+| p95 延迟 | 95% 请求低于该耗时 |
+| Token 消耗 | 记录模型请求的输入和输出 Token |
+| 持久化恢复率 | 重启后仍能找回的记忆数 / 写入数 |
+| 审计完整率 | 有审计记录的操作数 / 总操作数 |
+| 失败率 | 失败请求数 / 总请求数 |
+
+建议记录到 CSV 或表格,不要只凭页面感觉判断“效果好”。
+
+## 十一、最终验收等级
+
+### L0:能启动
+
+- Docker 服务健康;
+- 后端 `/api/health` 正常;
+- 前端页面可打开;
+- 系统状态与实际配置一致。
+
+### L1:能记住
+
+- 能写入一条稳定记忆;
+- 能检索同一条记忆;
+- 重启后记忆还在;
+- 能看到基本审计记录。
+
+### L2:体现范式
+
+- 当前版本必须通过:Text2Mem 的操作、分类、去重和检索可见;ReMe 的文件可读、可审阅、可检索;
+- 后续版本才验收:Mem0 的自动提取与冲突处理、Letta 的持久 Agent 状态、memU 的后台整理与候选记忆边界。
+
+### L3:接近生产
+
+- 有真实数据集上的召回评测;
+- 有 p50 / p95、Token 和成本记录;
+- 有权限、删除、留存和审计策略;
+- 有失败重试和服务降级;
+- 有并发、重启、备份和恢复测试;
+- 有敏感信息和提示注入测试。
+
+只有达到 L2,才可以说“实现了对应的记忆范式效果”;达到 L0 或 L1,只能说“项目可以运行”。
+
+## 十二、故障排查
+
+### 系统显示未配置
+
+```bash
+curl http://localhost:8000/api/systems
+```
+
+检查:
+
+- Text2Mem 应始终可用;
+- ReMe 是否在 `.venv-reme` 中安装,并设置 `REME_ENABLED=true`;
+- Mem0、Letta 和 memU 当前应保持不可用;
+- 修改 `.env` 后是否重启后端。
+
+### 存储显示 `local-json`
+
+说明 PostgreSQL 连接失败。检查:
+
+```bash
+docker compose ps
+docker compose logs postgres
+```
+
+同时查看 `/api/health` 的 `storage_error`。如果更换了 `EMBEDDING_DIMENSIONS`,现有 `memory_embeddings` 表的向量维度与新配置不一致也会导致降级;应先迁移或重建向量表。
+
+### 模型调用失败
+
+检查:
+
+- `LLM_BASE_URL` 是否包含正确的 `/v1`;
+- API Key 是否有效;
+- 模型名是否可用;
+- Embedding 是否使用了独立且兼容的服务;
+- 是否被网络、代理或区域限制阻断。
+
+### 清理单个系统
+
+```bash
+curl -X POST http://localhost:8000/api/reset \
+  -H 'content-type: application/json' \
+  -d '{"system":"text2mem"}'
+```
+
+清理全部系统:
+
+```bash
+curl -X POST http://localhost:8000/api/reset \
+  -H 'content-type: application/json' \
+  -d '{}'
+```
+
+全局清理会调用每个适配器自己的重置逻辑,因此会同时删除 Text2Mem 的公共存储和 ReMe 的 Markdown 文件/派生向量。Mem0、Letta、memU 当前未启用;后续启用后必须另行验证其外部存储是否也被清理。

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.