From c870b1036824509283697de099c9e046596c092f Mon Sep 17 00:00:00 2001 From: caoqianming Date: Sat, 13 Jun 2026 12:20:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(memory):=20=E5=8F=8C=E5=B1=82=E8=AE=B0?= =?UTF-8?q?=E5=BF=86=E5=8D=87=E7=BA=A7=E4=B8=BA=20agent=20=E8=87=AA?= =?UTF-8?q?=E7=AE=A1=20+=20=E5=89=8D=E7=AB=AF=E5=8F=AA=E8=AF=BB=E8=AE=B0?= =?UTF-8?q?=E5=BF=86=E9=9D=A2=E6=9D=BF=20+=20bump=200.12.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 写入路径从纯手工改为 agent 自管(prompt 契约,非后台蒸馏):memory_block 注入可写路径锚点 + 「记忆维护契约」,契约/锚点常驻(记忆为空也注,解新用户 冷启动)。extended 索引从首行标题升为优先 frontmatter description(缺则退回 首行,平滑兼容存量)。修旧 bug:extended 路径在 docker 下注的是宿主路径指不到, 改按 backend 给 host 绝对路径 / /workspace/.memory。 前端记忆面板取舍 = GUI 当眼睛、模型当手:左栏「记忆」按钮开只读 modal 看全貌 (GET /v1/memory + GET /v1/memory/extended/{filename},零写/删 API,路径穿越 校验收口在 core/memory.py)。"看全貌"是读不是 operation,走 LLM 又贵又只拿 转述;"改"全走对话(agent 自管),单一写入口 + 自然语言 + 不会写坏 frontmatter。 对照业界:Claude(同文件式)给全套 view+edit,ChatGPT/Gemini 黑箱只给看/删。 单测覆盖:frontmatter 解析 / legacy 兜底 / 空记忆常驻契约 / host·docker 路径 / 只读视图 / 单篇读 / 文件名安全 / 越界拦截。 Co-Authored-By: Claude Opus 4.8 (1M context) --- DESIGN.md | 8 +- PROGRESS.md | 12 ++- core/__init__.py | 2 +- core/agent_builder.py | 18 ++-- core/memory.py | 182 ++++++++++++++++++++++++++++++++++------ web/app.py | 28 +++++++ web/static/dev.html | 49 +++++++++++ web/static/js/main.js | 2 + web/static/js/memory.js | 100 ++++++++++++++++++++++ 9 files changed, 363 insertions(+), 38 deletions(-) create mode 100644 web/static/js/memory.js diff --git a/DESIGN.md b/DESIGN.md index 46a838a..a9b24ff 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -108,12 +108,16 @@ yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` / | 层 | 文件 | 加载 | 适合 | |---|---|---|---| | Core | `core.md` | 每次 build_agent 进 system prompt | 跨任务高频精炼事实(几百 token) | -| Extended | `extended/*.md` | 索引(标题+绝对路径)进 prompt,内容靠 `read` 工具按需拉 | 大量低频专题 | +| Extended | `extended/*.md` | 索引(frontmatter `description`,缺则退回首行标题 — legacy 兼容)+ 可写绝对路径进 prompt,内容靠 `read` 工具按需拉 | 大量低频专题 | -**system prompt 每次 build_agent 重建**(resume 也是),memory 演化即时生效。memory 由人填(也允许 agent 用 `write` 写),系统不自动维护 — **事实由用户判断,不由 LLM 自动总结**。 +**system prompt 每次 build_agent 重建**(resume 也是),memory 演化即时生效。 + +**写入路径 = agent 自管(prompt 契约,非后台蒸馏)**:`memory_block` 把 `.memory/` 的**可写绝对路径锚点** + 一段「记忆维护契约」一起注进 prompt(契约 + 锚点常驻,即使记忆为空,否则新用户冷启动不知道自己能记)。契约规定:学到跨 task 复用的稳定事实就当场用已有 `write`/`edit` 存,写前 `grep`/`read` 查重(更新而非堆重复),extended 一事一文件 + frontmatter `description`(这行进索引决定召回)。**不引专用 `remember` 工具**(复用 fs 工具,改动最小);**不做后台自动蒸馏**(不烧额外 token,人仍可审核/手编)。路径锚点按 backend 给 host 绝对路径 / docker `/workspace/.memory`(同 working_dir 的容器路径转译)。 **memory 永远在 FS,不入 DB**:本地 `workspace/users//.memory/`,SaaS `/users//.memory/`(bind mount 进容器)。**dotfile `.memory/` 命名**避免项目名取 `memory` 时撞;`validate_task_name` 拒 `.` 起头双向防呆。理由:用户笔记语义,FS 读写 + 编辑器手编是产品的一部分;跨 task 共享靠"同一 user 同一目录"自动达成,无需 schema。 +**前端记忆面板 = 只读窗口,"改"全走对话(取舍)**:web 左栏「记忆」按钮开只读 modal,直接读 FS 渲染全貌(`GET /v1/memory` 全貌 + `GET /v1/memory/extended/{filename}` 单篇),**故意不提供写/删 API**。理由:① "看全貌"是读、不是 operation —— 走 LLM 反而又贵又只能拿到转述,看地面真相必须直读 FS;② "改"走对话(agent 自管,上文契约)= 单一写入口、自然语言、能合并改写,且用户不会写坏 frontmatter。对照业界:Claude(同为文件式记忆)给全套 view+edit;ChatGPT/Gemini 黑箱式只给看/删、长期不支持内联编辑。我们取"GUI 当眼睛、模型当手":既守住文件式记忆的透明卖点,又不引第二套写代码。后续若"删一条 / prune 臃肿 core.md"这类确定性精确操作摩擦明显,再单加直接的 delete(delete 是唯一廉价且确定性强、值得直连的 mutation,同 ChatGPT 做法)。路径穿越校验收口在 `core/memory.py`(只许 `.memory/extended/` 下扁平 `.md` + resolve 子树兜底)。 + --- ## 4. 模型路由 diff --git a/PROGRESS.md b/PROGRESS.md index 13fe627..be203d4 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-06-12(进入公测期:CLAUDE.md「开发阶段心智」翻新为"对外契约必须向后兼容,仅内部实现仍可最优重写") +最后更新:2026-06-12(双层记忆升级为 agent 自管 + 前端只读记忆面板) --- @@ -21,6 +21,16 @@ ## 已完成关键能力 +### 2026-06-12 / 双层记忆升级为 agent 自管(写入路径) + +- **背景**:`.memory/`(core.md + extended/)存储原语已在,但纯手工维护 —— 系统不往里写,用户也不会主动整理 → 记忆形同虚设。**这轮补「写入」与「召回」两条路,不碰存储/DB,不破坏存量 `.memory/` 数据。** +- **写入 = agent 自管(选型:不引专用工具、不做后台蒸馏)**:`memory_block` 把 `.memory/` 可写绝对路径锚点 + 一段「记忆维护契约」注进 prompt,**契约+锚点常驻(即使记忆为空,解新用户冷启动不知道能记)**。agent 学到跨 task 稳定事实就用已有 `write`/`edit`/`grep` 维护,写前查重、extended 一事一文件 + frontmatter `description`。复用 fs 工具改动最小,人仍可审核手编。 +- **召回升级**:extended 索引从「读首行当标题」升成**优先解析 frontmatter `description`**(召回依据更准),无 frontmatter 的存量文件退回首行标题(**公测期平滑兼容**)。 +- **docker 路径转译**:发现旧 extended 索引注的是宿主绝对路径,docker 下 agent 看到的是 `/workspace/...` → 指不到。`mem_dir_display` 按 backend 给 host 绝对路径 / `/workspace/.memory`,与 working_dir 同套转译。 +- 改动文件:`core/memory.py`(frontmatter 解析 + 契约 + 路径锚点)、`core/agent_builder.py`(算 `mem_dir_display` 传入)、`DESIGN.md` §3.7 同步心智+语义。单测覆盖 frontmatter 解析 / legacy 兜底 / 空记忆常驻契约 / host·docker 路径。明确不做:向量/RAG、全文搜索端点(正交,要做单开)。 + +- **前端只读记忆面板(GUI 当眼睛、模型当手)**:左栏「记忆」按钮(技能旁)开只读 modal 看全貌。**取舍**:查完业界(Claude 文件式给全套 view+edit;ChatGPT/Gemini 黑箱只给看/删)后定为 **GUI 只读 + "改"全走对话**(agent 自管已建好)—— "看全貌"是读不是 operation,走 LLM 又贵又只拿转述;"改"走对话 = 单一写入口 + 自然语言 + 不会写坏 frontmatter。后端只加 2 个只读端点 `GET /v1/memory`、`GET /v1/memory/extended/{filename}`(路径穿越校验收口在 `core/memory.py::read_extended_file`),**零写/删 API**。前端新增 `web/static/js/memory.js` + modal/CSS,复用 skills-modal 同构。契约里补明「用户说记住/改/忘掉是直接指令」。单测覆盖只读视图 / 单篇读 / 文件名安全 / 越界拦截。bump 0.11.1 → 0.12.0(本批含 agent 自管 + 记忆面板,同一 minor)。 + ### 2026-06-12 / 进入公测期:对外兼容策略 - 项目进入公测(对外真实用户在用)。`CLAUDE.md`「开发阶段心智」从"开发期可随意 break、不写兼容层"翻新为**对外契约(用户数据 / DB schema / 对外 API / CLI·env·文件布局)必须向后兼容,仅纯内部实现仍以最优为准放手重构**;拿不准 → 当对外契约处理。版本号段同步:公测保持 `0.x`,1.0 留给"对外冻结行为 / 正式 GA"。同条记忆 `feedback_dev_phase_no_compat` 一并翻新。bump 0.11.0 → 0.11.1。 diff --git a/core/__init__.py b/core/__init__.py index 33d8464..34ab5fa 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.11.1" +__version__ = "0.12.0" diff --git a/core/agent_builder.py b/core/agent_builder.py index 92bdf75..8efdd47 100644 --- a/core/agent_builder.py +++ b/core/agent_builder.py @@ -252,16 +252,20 @@ def _build_system_prompt( prompt = (ROOT / cfg["system_prompt"]).read_text(encoding="utf-8") if skills.skills: prompt += f"\n\n## 可用 skill (用 load_skill 加载完整指引)\n{skills.discovery_block()}" - prompt += memory_block(workspace_dir, user_id) - if media_enabled: - prompt += "\n\n" + _MEDIA_TOOLS_BLOCK # docker backend 下 shell/run_python/fs 工具全在容器里跑,容器把 # `/users/` bind 到 `/workspace`、`--workdir /workspace/` - # (executor_docker.py:99-100)。此时 prompt 必须给**容器路径**,否则 LLM - # 拿着宿主绝对路径在沙盒里 find 不到任何东西(host 路径容器内根本不存在)。 - # host backend 不变,直接用宿主绝对路径。 - wd_abs = working_dir.resolve() + # (executor_docker.py:99-100)。此时 prompt 给 agent 的所有可写/可读绝对路径 + # (含 .memory/ 写入锚点)都必须是**容器路径**,否则 LLM 拿着宿主绝对路径在沙盒里 + # find 不到任何东西。host backend 不变,直接用宿主绝对路径。 is_docker = os.getenv("ZCBOT_SANDBOX_BACKEND", "host").lower() == "docker" + # .memory/ 在 agent 视角下的可写路径:docker 给容器路径,host 给宿主绝对路径。 + mem_dir_display = "/workspace/.memory" if is_docker else str( + user_root(workspace_dir, user_id) / ".memory" + ) + prompt += memory_block(workspace_dir, user_id, mem_dir_display) + if media_enabled: + prompt += "\n\n" + _MEDIA_TOOLS_BLOCK + wd_abs = working_dir.resolve() if is_docker: try: wd_rel = wd_abs.relative_to(user_root(workspace_dir, user_id)) diff --git a/core/memory.py b/core/memory.py index 2b9fc82..9903377 100644 --- a/core/memory.py +++ b/core/memory.py @@ -2,8 +2,9 @@ core.md —— 注 system prompt,每次都看到。装稳定事实 (用户偏好 / 常用命令 / 项目约定 / 模型 quirk 备忘等) - extended/.md —— 索引(标题+路径)注 prompt,内容 agent 用 `read` 按需拉。 - 装少数任务才用的专题资料(某 API 速查 / 某历史事件等) + extended/.md —— 索引(frontmatter `description` + 路径)注 prompt,内容 agent + 用 `read` 按需拉。装少数任务才用的专题资料(某 API 速查 / 某历史 + 事件等)。description 是召回依据:写得准,模型才知道何时该拉。 为什么这样切: core 一直挂在上下文里,token 成本固定 ⇒ 只放跨任务高频用的精炼内容 @@ -14,11 +15,17 @@ memory 是 per-user(同一 workspace 内按 user_id 隔离),同 user 的所有 t 项目名取 `memory` 时撞名;`.` 起头也被 `validate_task_name` 拒,双向防呆。 user_id 由 web auth 入口(JWT `sub`)透传到 build_agent。SaaS 化时 `` 替换 `workspace`,布局不变(§7.0)。 + +写入路径(agent 自管):memory_block 把 `.memory/` 的**可写绝对路径**(host 绝对路径 / +docker `/workspace/.memory`)连同「记忆维护契约」一起注进 prompt,agent 用已有 +`write`/`edit`/`grep` 直接维护 —— 不引专用工具。契约 + 锚点即使记忆为空也常驻, +否则新用户冷启动永远不知道自己能记。 """ from __future__ import annotations +import re from pathlib import Path -from typing import List, Tuple +from typing import Any, Dict, List, Optional, Tuple from uuid import UUID @@ -26,18 +33,42 @@ def _memory_dir(workspace_dir: Path, user_id: UUID) -> Path: return workspace_dir / "users" / str(user_id) / ".memory" -def _read_first_title(p: Path) -> str: - """取文件第一个非空 h1/h2 行作为标题;没有就用文件名 stem。""" - try: - for raw in p.read_text(encoding="utf-8").splitlines(): - line = raw.strip() - if line.startswith("#"): - return line.lstrip("#").strip() - if line: - return line[:60] - except (OSError, UnicodeDecodeError): - pass - return p.stem +def _parse_frontmatter_description(text: str) -> Optional[str]: + """取 YAML frontmatter 里的 `description:` 一行;没有 frontmatter 返回 None。 + + 只认文件最开头的 `---` ... `---` 块,块内首个 `description:` 行的值。 + 刻意不引 yaml 依赖 —— 记忆文件 frontmatter 就这一个字段够用,手解析最省。 + """ + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return None + for raw in lines[1:]: + stripped = raw.strip() + if stripped == "---": + break + if stripped.startswith("description:"): + val = stripped[len("description:"):].strip() + # 去掉可能的引号 + if len(val) >= 2 and val[0] in "'\"" and val[-1] == val[0]: + val = val[1:-1] + return val.strip() or None + return None + + +def _read_first_title(text: str, stem: str) -> str: + """取文件第一个非空 h1/h2 行作为标题;没有就用文件名 stem。 + + legacy 兜底:存量 extended 文件没 frontmatter,退回首行当标题(平滑兼容)。 + """ + for raw in text.splitlines(): + line = raw.strip() + if line == "---": + continue + if line.startswith("#"): + return line.lstrip("#").strip() + if line: + return line[:60] + return stem def _load_core(workspace_dir: Path, user_id: UUID) -> str: @@ -50,31 +81,128 @@ def _load_core(workspace_dir: Path, user_id: UUID) -> str: return "" -def _extended_index(workspace_dir: Path, user_id: UUID) -> List[Tuple[str, Path]]: - """返回 [(title, abs_path), ...],按文件名排序。""" +def _extended_index(workspace_dir: Path, user_id: UUID) -> List[Tuple[str, str]]: + """返回 [(description_or_title, filename), ...],按文件名排序。 + + 优先 frontmatter `description`;没有则退回首行标题(legacy 兼容)。 + 返回 filename(非绝对路径)—— 路径由 memory_block 按 backend 拼 display 前缀, + docker 下要给容器路径而非宿主路径。 + """ ext_dir = _memory_dir(workspace_dir, user_id) / "extended" if not ext_dir.is_dir(): return [] - items: List[Tuple[str, Path]] = [] + items: List[Tuple[str, str]] = [] for p in sorted(ext_dir.glob("*.md")): - if p.is_file(): - items.append((_read_first_title(p), p.resolve())) + if not p.is_file(): + continue + try: + text = p.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + text = "" + label = _parse_frontmatter_description(text) or _read_first_title(text, p.stem) + items.append((label, p.name)) return items -def memory_block(workspace_dir: Path, user_id: UUID) -> str: - """构造注入 system prompt 的记忆段;两块都空就返回空串。""" +_CONTRACT = """\ +你可以**主动维护**这份记忆(用已有 `write`/`edit`/`grep`),把跨 task 复用的事实存下来, +下次别的 task 一开场就能用。规矩: + +- **什么值得记**:用户稳定偏好、项目长期约定、反复用到的事实、踩过的坑(及为什么)。 + **不要记**:只跟当前 task 有关的一次性信息、能从产物/代码里直接看到的东西。 +- **core.md(常驻,贵)**:只放跨 task 高频、精炼的稳定事实 —— 它每轮都占 token。 +- **extended/.md(按需,便宜)**:低频专题资料,一事一文件。文件开头写 + frontmatter `description:` 一行(这行进上面索引,决定何时被召回),正文是事实本身: + ``` + --- + description: <一句话说清这份资料是什么、何时该拉> + --- + <内容> + ``` +- **写前先查重**:`grep`/`read` 看现有记忆有没有,有就 `edit` 更新、别堆重复;发现记错了就删。 +- **用户让你"记住 / 改 / 忘掉"某事时,这是直接指令**:照办 —— "记住"就写、"改成"就 `edit`、 + "忘掉 / 删掉"就把对应条目从 core.md 删掉或删掉那个 extended 文件。改完回一句确认即可。 +- 记忆即时生效(每个新 task 重读),不用通知用户。""" + + +def memory_block( + workspace_dir: Path, + user_id: UUID, + mem_dir_display: Optional[str] = None, +) -> str: + """构造注入 system prompt 的记忆段。 + + mem_dir_display: `.memory/` 在 agent 视角下的可写绝对路径前缀。host backend 传 + None ⇒ 用宿主绝对路径;docker backend 传 `/workspace/.memory`(容器内路径)。 + 与旧版不同:契约 + 写入锚点常驻(即使记忆空),让 agent 知道自己能记;core / + extended 两段仍按有无内容才出现。 + """ core = _load_core(workspace_dir, user_id) ext = _extended_index(workspace_dir, user_id) - if not core and not ext: - return "" - parts = ["\n\n## 记忆 (user 级,跨 task 共享)"] + real_dir = _memory_dir(workspace_dir, user_id) + base = mem_dir_display if mem_dir_display is not None else str(real_dir) + base = base.rstrip("/") + + parts = ["\n\n## 记忆 (user 级,跨 task 共享)\n"] + parts.append(_CONTRACT) + parts.append( + f"\n\n**写到这里**:core → `{base}/core.md`;" + f"专题 → `{base}/extended/.md`\n" + ) if core: parts.append("\n### Core (常驻 prompt)\n") parts.append(core) if ext: parts.append("\n\n### Extended (按需用 `read` 加载)\n") - for title, path in ext: - parts.append(f"- `{path}` — {title}\n") + for label, name in ext: + parts.append(f"- `{base}/extended/{name}` — {label}\n") return "".join(parts) + + +# ── 只读视图(web GUI 用) ───────────────────────────────────────────── +# 前端「记忆」弹框只读展示用。**故意不提供写/删 API** —— 改记忆全走对话(agent +# 自管,见 _CONTRACT),GUI 当"眼睛"不当"手":看全貌靠直接读 FS(便宜、是地面真相), +# 改靠模型(统一写入口、自然语言、能合并改写)。详见 DESIGN §3.7。 + +_EXTENDED_NAME_RE = re.compile(r"^[\w\-.]+\.md$") + + +def _is_safe_extended_name(name: str) -> bool: + """防穿越:只许 `.memory/extended/` 下的扁平 `.md` 文件名。 + + 拒斜杠 / `..` / dotfile / 非 .md。配合调用处 resolve 再兜一层子树校验。 + """ + if not name or "/" in name or "\\" in name or name.startswith("."): + return False + return bool(_EXTENDED_NAME_RE.match(name)) + + +def memory_view(workspace_dir: Path, user_id: UUID) -> Dict[str, Any]: + """记忆全貌(只读):core 原文 + extended 列表(filename + description)。 + + 一次填满前端弹框。core 给原文(非 strip 前的注入版)让用户看到真实落盘内容。 + """ + return { + "core": _load_core(workspace_dir, user_id), + "extended": [ + {"filename": name, "description": label} + for label, name in _extended_index(workspace_dir, user_id) + ], + } + + +def read_extended_file( + workspace_dir: Path, user_id: UUID, filename: str +) -> Optional[str]: + """读单篇 extended 原文;文件名非法 / 越界 / 不存在 → None(调用方转 404)。""" + if not _is_safe_extended_name(filename): + return None + ext_dir = (_memory_dir(workspace_dir, user_id) / "extended").resolve() + target = (ext_dir / filename).resolve() + if ext_dir not in target.parents or not target.is_file(): + return None + try: + return target.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return None diff --git a/web/app.py b/web/app.py index 698e186..7e62178 100644 --- a/web/app.py +++ b/web/app.py @@ -1286,6 +1286,34 @@ def create_app() -> FastAPI: raise HTTPException(400, "拒绝删除 .skills 之外的路径") shutil.rmtree(target) + @app.get("/v1/memory", tags=["memory"]) + def get_memory(user_id: UUID = Depends(require_user)): + """记忆全貌(只读):core.md 原文 + extended 列表(filename + description)。 + + 前端「记忆」弹框一次拉满。**只读** —— 改记忆全走对话(agent 自管,见 + `core/memory.py::_CONTRACT`),GUI 当"眼睛"不当"手"(DESIGN §3.7)。 + 每次现读 FS(同 skills 端点),agent 刚写的即时可见。 + """ + from core.agent_builder import resolve_workspace + from core.memory import memory_view + ws = resolve_workspace(None) + return memory_view(ws, user_id) + + @app.get("/v1/memory/extended/{filename}", tags=["memory"]) + def get_memory_extended(filename: str, user_id: UUID = Depends(require_user)): + """读单篇 extended 原文(点开列表项时拉)。文件名非法 / 不存在 → 404。 + + 路径穿越校验收口在 `core/memory.py::read_extended_file`(只许 `.memory/extended/` + 下扁平 `.md`,再 resolve 兜一层子树)。 + """ + from core.agent_builder import resolve_workspace + from core.memory import read_extended_file + ws = resolve_workspace(None) + content = read_extended_file(ws, user_id, filename) + if content is None: + raise HTTPException(404, f"memory file not found: {filename!r}") + return {"filename": filename, "content": content} + @app.delete("/v1/tasks/{task_id}", status_code=204, tags=["tasks"]) def delete_task(task_id: str, user_id: UUID = Depends(require_user)): """硬删除:DELETE DB 行(messages / usage_events CASCADE)。 diff --git a/web/static/dev.html b/web/static/dev.html index 05f5911..47b4084 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -292,6 +292,34 @@ .sk-pane { width: auto; max-height: 26vh; border-right: none; border-bottom: 1px solid var(--border); } } + /* ───── 记忆查看 modal(只读两栏;改走对话)───── */ + #memory-modal { z-index: 112; } + #memory-modal .card { + width: 880px; max-width: 94vw; height: 80vh; max-height: 80vh; + display: flex; flex-direction: column; + } + #memory-modal h3 { + margin: 0; padding: 12px 16px; font-size: 16px; + border-bottom: 1px solid var(--border); + display: flex; align-items: center; gap: 8px; + } + #memory-modal h3 .spacer { flex: 1; } + #memory-modal h3 svg { opacity: .85; } + #memory-modal .sk-x { + border: none; background: transparent; font-size: 16px; + cursor: pointer; color: var(--muted); padding: 2px 6px; + } + #mem-hint { + padding: 8px 16px; font-size: 12px; color: var(--muted); + border-bottom: 1px solid var(--border); background: #fafafa; + } + #mem-cols { flex: 1; display: flex; min-height: 0; } + #mem-detail { flex: 1; min-width: 0; overflow: auto; padding: 16px 20px; } + @media (max-width: 760px) { + #memory-modal .card { width: 96vw; height: 88vh; max-height: 88vh; } + #mem-cols { flex-direction: column; } + } + /* ───── 3-pane layout ───── */ #app { display: none; height: 100vh; } #app.ready { @@ -1109,6 +1137,23 @@ + + +
@@ -1182,6 +1227,10 @@ 技能 +
diff --git a/web/static/js/main.js b/web/static/js/main.js index d3409b9..928b3e5 100644 --- a/web/static/js/main.js +++ b/web/static/js/main.js @@ -7,6 +7,7 @@ import { $ } from "./dom.js"; import { api } from "./api.js"; import { closeChpwModal } from "./auth.js"; import { closeSkillsModal } from "./skills.js"; +import { closeMemoryModal } from "./memory.js"; import { closeFilePreview, closeMiniPreview } from "./preview.js"; import { closeSrcPicker, loadFiles } from "./files.js"; import { loadFolderSuggestions } from "./newtask.js"; @@ -85,6 +86,7 @@ document.addEventListener("keydown", (e) => { // 多模态共存:优先关靠前栈顶 — 小预览(z 96)→ 选入(z 95)→ 文件预览(z 90)→ 新任务(z 80) if ($("chpw-modal").classList.contains("show")) { closeChpwModal(); return; } if ($("skills-modal").classList.contains("show")) { closeSkillsModal(); return; } + if ($("memory-modal").classList.contains("show")) { closeMemoryModal(); return; } if ($("mini-preview-modal").classList.contains("show")) { closeMiniPreview(); return; } if ($("src-picker-modal").classList.contains("show")) { closeSrcPicker(); return; } if ($("file-preview-modal").classList.contains("show")) { closeFilePreview(); return; } diff --git a/web/static/js/memory.js b/web/static/js/memory.js new file mode 100644 index 0000000..22398a4 --- /dev/null +++ b/web/static/js/memory.js @@ -0,0 +1,100 @@ +// 记忆 modal:只读两栏 master-detail。左栏列 Core(常驻)+ Extended 各条(带 description), +// 右栏渲染选中项原文(markdown)。左侧 rail 底部「记忆」按钮触发。 +// **只读** —— 改记忆全走对话(agent 自管,见 core/memory.py 的 _CONTRACT)。 +// GUI 当"眼睛"不当"手":看全貌靠直接读 FS(便宜、是地面真相),改靠模型(DESIGN §3.7)。 +// 后端:GET /v1/memory(全貌)、GET /v1/memory/extended/{filename}(单篇原文)。 +import { $ } from "./dom.js"; +import { api } from "./api.js"; +import { escapeHtml } from "./format.js"; +import { renderMd, highlightIn } from "./markdown.js"; + +const PLACEHOLDER = '
← 选 Core 或某条专题查看
'; +let _cache = null; // 本次打开的 {core, extended} 快照;渲染右栏 Core 复用,免二次请求 + +function openMemoryModal() { + $("memory-modal").classList.add("show"); + $("mem-detail").innerHTML = PLACEHOLDER; + renderList(); +} +export function closeMemoryModal() { + $("memory-modal").classList.remove("show"); +} + +async function renderList() { + const list = $("mem-list"); + list.innerHTML = '
加载中…
'; + let data; + try { + data = await api("GET", "/v1/memory"); + } catch (e) { + list.innerHTML = `
加载失败: ${escapeHtml(e.message)}
`; + return; + } + _cache = data; + const ext = data.extended || []; + const coreEmpty = !data.core || !data.core.trim(); + + let html = '
常驻 (Core)
'; + html += `
+
core.md${coreEmpty ? ' ' : ""}
+
每轮注入,跨任务高频事实
+
`; + html += `
专题 (Extended ${ext.length})
`; + html += ext.length + ? ext + .map( + (e) => `
+
${escapeHtml(e.filename)}
+
${escapeHtml(e.description || "")}
+
` + ) + .join("") + : '
还没有。在对话里让我「记住某专题」即可。
'; + list.innerHTML = html; +} + +function highlightSel(itemEl) { + $("mem-list").querySelectorAll(".sk-item.active").forEach((el) => el.classList.remove("active")); + if (itemEl) itemEl.classList.add("active"); +} + +function showCore(itemEl) { + highlightSel(itemEl); + const detail = $("mem-detail"); + const core = (_cache && _cache.core) || ""; + detail.innerHTML = core.trim() + ? '
core.md常驻
' + + `
${renderMd(core)}
` + : '
core.md 还是空的。在对话里跟我说你的偏好 / 项目约定,我会记进来。
'; + highlightIn(detail); +} + +async function showExt(filename, itemEl) { + highlightSel(itemEl); + const detail = $("mem-detail"); + detail.innerHTML = '
加载中…
'; + let data; + try { + data = await api("GET", "/v1/memory/extended/" + encodeURIComponent(filename)); + } catch (e) { + detail.innerHTML = `
加载失败: ${escapeHtml(e.message)}
`; + return; + } + detail.innerHTML = + `
${escapeHtml(filename)}按需
` + + `
${renderMd(data.content)}
`; + highlightIn(detail); +} + +// ───── 顶层绑定 ───── +$("hd-memory").onclick = openMemoryModal; +$("mem-close").onclick = closeMemoryModal; +$("memory-modal").addEventListener("click", (e) => { + if (e.target.id === "memory-modal") closeMemoryModal(); // 点遮罩关闭 +}); +$("mem-list").addEventListener("click", (e) => { + const item = e.target.closest(".sk-item"); + if (!item) return; + if (item.getAttribute("data-kind") === "core") showCore(item); + else showExt(item.getAttribute("data-file"), item); +});