diff --git a/agent/memory/service.py b/agent/memory/service.py index 567a1f5f..eb565b13 100644 --- a/agent/memory/service.py +++ b/agent/memory/service.py @@ -32,68 +32,80 @@ class MemoryService: # ------------------------------------------------------------------ # list — paginated file metadata # ------------------------------------------------------------------ - def list_files(self, page: int = 1, page_size: int = 20) -> dict: + def list_files(self, page: int = 1, page_size: int = 20, category: str = "memory") -> dict: """ - List all memory files with metadata (without content). + List memory or dream files with metadata (without content). - Returns:: - - { - "page": 1, - "page_size": 20, - "total": 15, - "list": [ - {"filename": "MEMORY.md", "type": "global", "size": 2048, "updated_at": "2026-02-20 10:00:00"}, - {"filename": "2026-02-20.md", "type": "daily", "size": 512, "updated_at": "2026-02-20 09:30:00"}, - ... - ] - } + Args: + category: ``"memory"`` (default) — MEMORY.md + daily files; + ``"dream"`` — dream diary files from memory/dreams/ """ + if category == "dream": + files = self._list_dream_files() + else: + files = self._list_memory_files() + + total = len(files) + start = (page - 1) * page_size + end = start + page_size + + return { + "page": page, + "page_size": page_size, + "total": total, + "list": files[start:end], + } + + def _list_memory_files(self) -> List[dict]: + """MEMORY.md + memory/*.md (newest first).""" files: List[dict] = [] - # 1. Global memory — MEMORY.md in workspace root global_path = os.path.join(self.workspace_root, "MEMORY.md") if os.path.isfile(global_path): files.append(self._file_info(global_path, "MEMORY.md", "global")) - # 2. Daily memory files — memory/*.md (sorted newest first) if os.path.isdir(self.memory_dir): daily_files = [] for name in os.listdir(self.memory_dir): full = os.path.join(self.memory_dir, name) if os.path.isfile(full) and name.endswith(".md"): daily_files.append((name, full)) - # Sort by filename descending (newest date first) daily_files.sort(key=lambda x: x[0], reverse=True) for name, full in daily_files: files.append(self._file_info(full, name, "daily")) - total = len(files) + return files - # Paginate - start = (page - 1) * page_size - end = start + page_size - page_items = files[start:end] + def _list_dream_files(self) -> List[dict]: + """memory/dreams/*.md (newest first).""" + files: List[dict] = [] + dreams_dir = os.path.join(self.memory_dir, "dreams") - return { - "page": page, - "page_size": page_size, - "total": total, - "list": page_items, - } + if os.path.isdir(dreams_dir): + entries = [] + for name in os.listdir(dreams_dir): + full = os.path.join(dreams_dir, name) + if os.path.isfile(full) and name.endswith(".md"): + entries.append((name, full)) + entries.sort(key=lambda x: x[0], reverse=True) + for name, full in entries: + files.append(self._file_info(full, name, "dream")) + + return files # ------------------------------------------------------------------ # content — read a single file # ------------------------------------------------------------------ - def get_content(self, filename: str) -> dict: + def get_content(self, filename: str, category: str = "memory") -> dict: """ - Read the full content of a memory file. + Read the full content of a memory or dream file. - :param filename: File name, e.g. ``MEMORY.md`` or ``2026-02-20.md`` + :param filename: File name, e.g. ``MEMORY.md``, ``2026-02-20.md`` + :param category: ``"memory"`` or ``"dream"`` :return: dict with ``filename`` and ``content`` :raises FileNotFoundError: if the file does not exist """ - path = self._resolve_path(filename) + path = self._resolve_path(filename, category) if not os.path.isfile(path): raise FileNotFoundError(f"Memory file not found: {filename}") @@ -113,7 +125,7 @@ class MemoryService: Dispatch a memory management action. :param action: ``list`` or ``content`` - :param payload: action-specific payload + :param payload: action-specific payload (supports ``category``: ``"memory"`` | ``"dream"``) :return: protocol-compatible response dict """ payload = payload or {} @@ -121,14 +133,16 @@ class MemoryService: if action == "list": page = payload.get("page", 1) page_size = payload.get("page_size", 20) - result_payload = self.list_files(page=page, page_size=page_size) + category = payload.get("category", "memory") + result_payload = self.list_files(page=page, page_size=page_size, category=category) return {"action": action, "code": 200, "message": "success", "payload": result_payload} elif action == "content": filename = payload.get("filename") if not filename: return {"action": action, "code": 400, "message": "filename is required", "payload": None} - result_payload = self.get_content(filename) + category = payload.get("category", "memory") + result_payload = self.get_content(filename, category=category) return {"action": action, "code": 200, "message": "success", "payload": result_payload} else: @@ -145,18 +159,20 @@ class MemoryService: # ------------------------------------------------------------------ # internal helpers # ------------------------------------------------------------------ - def _resolve_path(self, filename: str) -> str: + def _resolve_path(self, filename: str, category: str = "memory") -> str: """ Safely resolve a filename to its absolute path within the allowed directory. - ``MEMORY.md`` → ``{workspace_root}/MEMORY.md`` - - ``2026-02-20.md`` → ``{workspace_root}/memory/2026-02-20.md`` + - ``2026-02-20.md`` (memory) → ``{workspace_root}/memory/2026-02-20.md`` + - ``2026-02-20.md`` (dream) → ``{workspace_root}/memory/dreams/2026-02-20.md`` - Raises ValueError if the resolved path escapes the allowed directory - (path traversal protection). + Raises ValueError if the resolved path escapes the allowed directory. """ if filename == "MEMORY.md": base_dir = self.workspace_root + elif category == "dream": + base_dir = os.path.join(self.memory_dir, "dreams") else: base_dir = self.memory_dir diff --git a/channel/web/chat.html b/channel/web/chat.html index c4aa17f9..4f6d10ac 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -660,6 +660,16 @@

记忆管理

查看 Agent 记忆文件和内容

+
+ + +
diff --git a/channel/web/static/css/console.css b/channel/web/static/css/console.css index de176e3e..9e6c2913 100644 --- a/channel/web/static/css/console.css +++ b/channel/web/static/css/console.css @@ -890,15 +890,15 @@ ============================================================ */ /* Tab toggle */ -.knowledge-tab { +.knowledge-tab, .memory-tab { color: #64748b; } -.knowledge-tab.active { +.knowledge-tab.active, .memory-tab.active { background: #fff; color: #334155; box-shadow: 0 1px 3px rgba(0,0,0,0.08); } -.dark .knowledge-tab.active { +.dark .knowledge-tab.active, .dark .memory-tab.active { background: rgba(255,255,255,0.1); color: #e2e8f0; } diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index b6058bd4..c6acfe98 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -55,6 +55,7 @@ const I18N = { skills_section_title: '技能', skill_enable: '启用', skill_disable: '禁用', skill_toggle_error: '操作失败,请稍后再试', memory_title: '记忆管理', memory_desc: '查看 Agent 记忆文件和内容', + memory_tab_files: '记忆文件', memory_tab_dreams: '梦境日记', memory_loading: '加载记忆文件中...', memory_loading_desc: '记忆文件将显示在此处', memory_back: '返回列表', memory_col_name: '文件名', memory_col_type: '类型', memory_col_size: '大小', memory_col_updated: '更新时间', @@ -139,6 +140,7 @@ const I18N = { skills_section_title: 'Skills', skill_enable: 'Enable', skill_disable: 'Disable', skill_toggle_error: 'Operation failed, please try again', memory_title: 'Memory', memory_desc: 'View agent memory files and contents', + memory_tab_files: 'Memory Files', memory_tab_dreams: 'Dream Diary', memory_loading: 'Loading memory files...', memory_loading_desc: 'Memory files will be displayed here', memory_back: 'Back to list', memory_col_name: 'Filename', memory_col_type: 'Type', memory_col_size: 'Size', memory_col_updated: 'Updated', @@ -2495,12 +2497,20 @@ function toggleSkill(name, currentlyEnabled) { // Memory View // ===================================================================== let memoryPage = 1; +let memoryCategory = 'memory'; // 'memory' | 'dream' const memoryPageSize = 10; +function switchMemoryTab(tab) { + document.querySelectorAll('.memory-tab').forEach(el => el.classList.remove('active')); + document.getElementById('memory-tab-' + tab).classList.add('active'); + memoryCategory = tab === 'dreams' ? 'dream' : 'memory'; + loadMemoryView(1); +} + function loadMemoryView(page) { page = page || 1; memoryPage = page; - fetch(`/api/memory?page=${page}&page_size=${memoryPageSize}`).then(r => r.json()).then(data => { + fetch(`/api/memory?page=${page}&page_size=${memoryPageSize}&category=${memoryCategory}`).then(r => r.json()).then(data => { if (data.status !== 'success') return; const emptyEl = document.getElementById('memory-empty'); const listEl = document.getElementById('memory-list'); @@ -2508,7 +2518,15 @@ function loadMemoryView(page) { const total = data.total || 0; if (total === 0) { - emptyEl.querySelector('p').textContent = currentLang === 'zh' ? '暂无记忆文件' : 'No memory files'; + const emptyIcon = emptyEl.querySelector('i'); + const emptyTitle = emptyEl.querySelector('p'); + if (memoryCategory === 'dream') { + emptyIcon.className = 'fas fa-moon text-purple-400 text-xl'; + emptyTitle.textContent = currentLang === 'zh' ? '暂无梦境日记' : 'No dream diaries yet'; + } else { + emptyIcon.className = 'fas fa-brain text-purple-400 text-xl'; + emptyTitle.textContent = currentLang === 'zh' ? '暂无记忆文件' : 'No memory files'; + } emptyEl.classList.remove('hidden'); listEl.classList.add('hidden'); return; @@ -2521,10 +2539,15 @@ function loadMemoryView(page) { files.forEach(f => { const tr = document.createElement('tr'); tr.className = 'border-b border-slate-100 dark:border-white/5 hover:bg-slate-50 dark:hover:bg-white/5 cursor-pointer transition-colors'; - tr.onclick = () => openMemoryFile(f.filename); - const typeLabel = f.type === 'global' - ? 'Global' - : 'Daily'; + tr.onclick = () => openMemoryFile(f.filename, memoryCategory); + let typeLabel; + if (f.type === 'global') { + typeLabel = 'Global'; + } else if (f.type === 'dream') { + typeLabel = 'Dream'; + } else { + typeLabel = 'Daily'; + } const sizeStr = f.size < 1024 ? f.size + ' B' : (f.size / 1024).toFixed(1) + ' KB'; tr.innerHTML = ` ${escapeHtml(f.filename)} @@ -2546,8 +2569,9 @@ function loadMemoryView(page) { }).catch(() => {}); } -function openMemoryFile(filename) { - fetch(`/api/memory/content?filename=${encodeURIComponent(filename)}`).then(r => r.json()).then(data => { +function openMemoryFile(filename, category) { + category = category || 'memory'; + fetch(`/api/memory/content?filename=${encodeURIComponent(filename)}&category=${category}`).then(r => r.json()).then(data => { if (data.status !== 'success') return; document.getElementById('memory-panel-list').classList.add('hidden'); const panel = document.getElementById('memory-panel-viewer'); @@ -3444,10 +3468,9 @@ navigateTo = function(viewId) { if (viewId === 'config') loadConfigView(); else if (viewId === 'skills') loadSkillsView(); else if (viewId === 'memory') { - // Always start from the list panel when navigating to memory document.getElementById('memory-panel-viewer').classList.add('hidden'); document.getElementById('memory-panel-list').classList.remove('hidden'); - loadMemoryView(1); + switchMemoryTab('files'); } else if (viewId === 'knowledge') loadKnowledgeView(); else if (viewId === 'channels') loadChannelsView(); diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 5b74c702..49f9081b 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -1538,10 +1538,13 @@ class MemoryHandler: web.header('Content-Type', 'application/json; charset=utf-8') try: from agent.memory.service import MemoryService - params = web.input(page='1', page_size='20') + params = web.input(page='1', page_size='20', category='memory') workspace_root = _get_workspace_root() service = MemoryService(workspace_root) - result = service.list_files(page=int(params.page), page_size=int(params.page_size)) + result = service.list_files( + page=int(params.page), page_size=int(params.page_size), + category=params.category, + ) return json.dumps({"status": "success", **result}, ensure_ascii=False) except Exception as e: logger.error(f"[WebChannel] Memory API error: {e}") @@ -1554,12 +1557,12 @@ class MemoryContentHandler: web.header('Content-Type', 'application/json; charset=utf-8') try: from agent.memory.service import MemoryService - params = web.input(filename='') + params = web.input(filename='', category='memory') if not params.filename: return json.dumps({"status": "error", "message": "filename required"}) workspace_root = _get_workspace_root() service = MemoryService(workspace_root) - result = service.get_content(params.filename) + result = service.get_content(params.filename, category=params.category) return json.dumps({"status": "success", **result}, ensure_ascii=False) except ValueError: return json.dumps({"status": "error", "message": "invalid filename"})