feat(dream): add Dream Diary tab to memory management page

- Backend: MemoryService supports category param (memory/dream), lists memory/dreams/*.md
- Backend: MemoryContentHandler resolves dream files from memory/dreams/ directory
- Frontend: add tab switcher (Memory Files / Dream Diary) matching knowledge tab style
- Frontend: dream entries show purple "Dream" badge, empty state with moon icon
- Cloud dispatch passes category param for consistency
This commit is contained in:
zhayujie
2026-04-13 22:08:15 +08:00
parent ea1a0c8b3d
commit 94d8c7e366
5 changed files with 108 additions and 56 deletions

View File

@@ -32,68 +32,80 @@ class MemoryService:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# list — paginated file metadata # 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:: 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)
"page": 1, start = (page - 1) * page_size
"page_size": 20, end = start + page_size
"total": 15,
"list": [ return {
{"filename": "MEMORY.md", "type": "global", "size": 2048, "updated_at": "2026-02-20 10:00:00"}, "page": page,
{"filename": "2026-02-20.md", "type": "daily", "size": 512, "updated_at": "2026-02-20 09:30:00"}, "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] = [] files: List[dict] = []
# 1. Global memory — MEMORY.md in workspace root
global_path = os.path.join(self.workspace_root, "MEMORY.md") global_path = os.path.join(self.workspace_root, "MEMORY.md")
if os.path.isfile(global_path): if os.path.isfile(global_path):
files.append(self._file_info(global_path, "MEMORY.md", "global")) 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): if os.path.isdir(self.memory_dir):
daily_files = [] daily_files = []
for name in os.listdir(self.memory_dir): for name in os.listdir(self.memory_dir):
full = os.path.join(self.memory_dir, name) full = os.path.join(self.memory_dir, name)
if os.path.isfile(full) and name.endswith(".md"): if os.path.isfile(full) and name.endswith(".md"):
daily_files.append((name, full)) daily_files.append((name, full))
# Sort by filename descending (newest date first)
daily_files.sort(key=lambda x: x[0], reverse=True) daily_files.sort(key=lambda x: x[0], reverse=True)
for name, full in daily_files: for name, full in daily_files:
files.append(self._file_info(full, name, "daily")) files.append(self._file_info(full, name, "daily"))
total = len(files) return files
# Paginate def _list_dream_files(self) -> List[dict]:
start = (page - 1) * page_size """memory/dreams/*.md (newest first)."""
end = start + page_size files: List[dict] = []
page_items = files[start:end] dreams_dir = os.path.join(self.memory_dir, "dreams")
return { if os.path.isdir(dreams_dir):
"page": page, entries = []
"page_size": page_size, for name in os.listdir(dreams_dir):
"total": total, full = os.path.join(dreams_dir, name)
"list": page_items, 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 # 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`` :return: dict with ``filename`` and ``content``
:raises FileNotFoundError: if the file does not exist :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): if not os.path.isfile(path):
raise FileNotFoundError(f"Memory file not found: {filename}") raise FileNotFoundError(f"Memory file not found: {filename}")
@@ -113,7 +125,7 @@ class MemoryService:
Dispatch a memory management action. Dispatch a memory management action.
:param action: ``list`` or ``content`` :param action: ``list`` or ``content``
:param payload: action-specific payload :param payload: action-specific payload (supports ``category``: ``"memory"`` | ``"dream"``)
:return: protocol-compatible response dict :return: protocol-compatible response dict
""" """
payload = payload or {} payload = payload or {}
@@ -121,14 +133,16 @@ class MemoryService:
if action == "list": if action == "list":
page = payload.get("page", 1) page = payload.get("page", 1)
page_size = payload.get("page_size", 20) 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} return {"action": action, "code": 200, "message": "success", "payload": result_payload}
elif action == "content": elif action == "content":
filename = payload.get("filename") filename = payload.get("filename")
if not filename: if not filename:
return {"action": action, "code": 400, "message": "filename is required", "payload": None} 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} return {"action": action, "code": 200, "message": "success", "payload": result_payload}
else: else:
@@ -145,18 +159,20 @@ class MemoryService:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# internal helpers # 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. Safely resolve a filename to its absolute path within the allowed directory.
- ``MEMORY.md`` → ``{workspace_root}/MEMORY.md`` - ``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 Raises ValueError if the resolved path escapes the allowed directory.
(path traversal protection).
""" """
if filename == "MEMORY.md": if filename == "MEMORY.md":
base_dir = self.workspace_root base_dir = self.workspace_root
elif category == "dream":
base_dir = os.path.join(self.memory_dir, "dreams")
else: else:
base_dir = self.memory_dir base_dir = self.memory_dir

View File

@@ -660,6 +660,16 @@
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="memory_title">记忆管理</h2> <h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="memory_title">记忆管理</h2>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="memory_desc">查看 Agent 记忆文件和内容</p> <p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="memory_desc">查看 Agent 记忆文件和内容</p>
</div> </div>
<div class="flex items-center bg-slate-100 dark:bg-white/10 rounded-lg p-0.5">
<button id="memory-tab-files" onclick="switchMemoryTab('files')"
class="memory-tab px-3 py-1.5 rounded-md text-xs font-medium cursor-pointer transition-colors duration-150 active">
<i class="fas fa-file-lines mr-1.5"></i><span data-i18n="memory_tab_files">记忆文件</span>
</button>
<button id="memory-tab-dreams" onclick="switchMemoryTab('dreams')"
class="memory-tab px-3 py-1.5 rounded-md text-xs font-medium cursor-pointer transition-colors duration-150">
<i class="fas fa-moon mr-1.5"></i><span data-i18n="memory_tab_dreams">梦境日记</span>
</button>
</div>
</div> </div>
<div id="memory-empty" class="flex flex-col items-center justify-center py-20"> <div id="memory-empty" class="flex flex-col items-center justify-center py-20">
<div class="w-16 h-16 rounded-2xl bg-purple-50 dark:bg-purple-900/20 flex items-center justify-center mb-4"> <div class="w-16 h-16 rounded-2xl bg-purple-50 dark:bg-purple-900/20 flex items-center justify-center mb-4">

View File

@@ -890,15 +890,15 @@
============================================================ */ ============================================================ */
/* Tab toggle */ /* Tab toggle */
.knowledge-tab { .knowledge-tab, .memory-tab {
color: #64748b; color: #64748b;
} }
.knowledge-tab.active { .knowledge-tab.active, .memory-tab.active {
background: #fff; background: #fff;
color: #334155; color: #334155;
box-shadow: 0 1px 3px rgba(0,0,0,0.08); 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); background: rgba(255,255,255,0.1);
color: #e2e8f0; color: #e2e8f0;
} }

View File

@@ -55,6 +55,7 @@ const I18N = {
skills_section_title: '技能', skill_enable: '启用', skill_disable: '禁用', skills_section_title: '技能', skill_enable: '启用', skill_disable: '禁用',
skill_toggle_error: '操作失败,请稍后再试', skill_toggle_error: '操作失败,请稍后再试',
memory_title: '记忆管理', memory_desc: '查看 Agent 记忆文件和内容', memory_title: '记忆管理', memory_desc: '查看 Agent 记忆文件和内容',
memory_tab_files: '记忆文件', memory_tab_dreams: '梦境日记',
memory_loading: '加载记忆文件中...', memory_loading_desc: '记忆文件将显示在此处', memory_loading: '加载记忆文件中...', memory_loading_desc: '记忆文件将显示在此处',
memory_back: '返回列表', memory_back: '返回列表',
memory_col_name: '文件名', memory_col_type: '类型', memory_col_size: '大小', memory_col_updated: '更新时间', 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', skills_section_title: 'Skills', skill_enable: 'Enable', skill_disable: 'Disable',
skill_toggle_error: 'Operation failed, please try again', skill_toggle_error: 'Operation failed, please try again',
memory_title: 'Memory', memory_desc: 'View agent memory files and contents', 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_loading: 'Loading memory files...', memory_loading_desc: 'Memory files will be displayed here',
memory_back: 'Back to list', memory_back: 'Back to list',
memory_col_name: 'Filename', memory_col_type: 'Type', memory_col_size: 'Size', memory_col_updated: 'Updated', 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 // Memory View
// ===================================================================== // =====================================================================
let memoryPage = 1; let memoryPage = 1;
let memoryCategory = 'memory'; // 'memory' | 'dream'
const memoryPageSize = 10; 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) { function loadMemoryView(page) {
page = page || 1; page = page || 1;
memoryPage = page; 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; if (data.status !== 'success') return;
const emptyEl = document.getElementById('memory-empty'); const emptyEl = document.getElementById('memory-empty');
const listEl = document.getElementById('memory-list'); const listEl = document.getElementById('memory-list');
@@ -2508,7 +2518,15 @@ function loadMemoryView(page) {
const total = data.total || 0; const total = data.total || 0;
if (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'); emptyEl.classList.remove('hidden');
listEl.classList.add('hidden'); listEl.classList.add('hidden');
return; return;
@@ -2521,10 +2539,15 @@ function loadMemoryView(page) {
files.forEach(f => { files.forEach(f => {
const tr = document.createElement('tr'); 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.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); tr.onclick = () => openMemoryFile(f.filename, memoryCategory);
const typeLabel = f.type === 'global' let typeLabel;
? '<span class="px-2 py-0.5 rounded-full text-xs bg-primary-50 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400">Global</span>' if (f.type === 'global') {
: '<span class="px-2 py-0.5 rounded-full text-xs bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">Daily</span>'; typeLabel = '<span class="px-2 py-0.5 rounded-full text-xs bg-primary-50 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400">Global</span>';
} else if (f.type === 'dream') {
typeLabel = '<span class="px-2 py-0.5 rounded-full text-xs bg-violet-50 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400">Dream</span>';
} else {
typeLabel = '<span class="px-2 py-0.5 rounded-full text-xs bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">Daily</span>';
}
const sizeStr = f.size < 1024 ? f.size + ' B' : (f.size / 1024).toFixed(1) + ' KB'; const sizeStr = f.size < 1024 ? f.size + ' B' : (f.size / 1024).toFixed(1) + ' KB';
tr.innerHTML = ` tr.innerHTML = `
<td class="px-4 py-3 text-sm font-mono text-slate-700 dark:text-slate-200">${escapeHtml(f.filename)}</td> <td class="px-4 py-3 text-sm font-mono text-slate-700 dark:text-slate-200">${escapeHtml(f.filename)}</td>
@@ -2546,8 +2569,9 @@ function loadMemoryView(page) {
}).catch(() => {}); }).catch(() => {});
} }
function openMemoryFile(filename) { function openMemoryFile(filename, category) {
fetch(`/api/memory/content?filename=${encodeURIComponent(filename)}`).then(r => r.json()).then(data => { category = category || 'memory';
fetch(`/api/memory/content?filename=${encodeURIComponent(filename)}&category=${category}`).then(r => r.json()).then(data => {
if (data.status !== 'success') return; if (data.status !== 'success') return;
document.getElementById('memory-panel-list').classList.add('hidden'); document.getElementById('memory-panel-list').classList.add('hidden');
const panel = document.getElementById('memory-panel-viewer'); const panel = document.getElementById('memory-panel-viewer');
@@ -3444,10 +3468,9 @@ navigateTo = function(viewId) {
if (viewId === 'config') loadConfigView(); if (viewId === 'config') loadConfigView();
else if (viewId === 'skills') loadSkillsView(); else if (viewId === 'skills') loadSkillsView();
else if (viewId === 'memory') { 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-viewer').classList.add('hidden');
document.getElementById('memory-panel-list').classList.remove('hidden'); document.getElementById('memory-panel-list').classList.remove('hidden');
loadMemoryView(1); switchMemoryTab('files');
} }
else if (viewId === 'knowledge') loadKnowledgeView(); else if (viewId === 'knowledge') loadKnowledgeView();
else if (viewId === 'channels') loadChannelsView(); else if (viewId === 'channels') loadChannelsView();

View File

@@ -1538,10 +1538,13 @@ class MemoryHandler:
web.header('Content-Type', 'application/json; charset=utf-8') web.header('Content-Type', 'application/json; charset=utf-8')
try: try:
from agent.memory.service import MemoryService 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() workspace_root = _get_workspace_root()
service = MemoryService(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) return json.dumps({"status": "success", **result}, ensure_ascii=False)
except Exception as e: except Exception as e:
logger.error(f"[WebChannel] Memory API error: {e}") logger.error(f"[WebChannel] Memory API error: {e}")
@@ -1554,12 +1557,12 @@ class MemoryContentHandler:
web.header('Content-Type', 'application/json; charset=utf-8') web.header('Content-Type', 'application/json; charset=utf-8')
try: try:
from agent.memory.service import MemoryService from agent.memory.service import MemoryService
params = web.input(filename='') params = web.input(filename='', category='memory')
if not params.filename: if not params.filename:
return json.dumps({"status": "error", "message": "filename required"}) return json.dumps({"status": "error", "message": "filename required"})
workspace_root = _get_workspace_root() workspace_root = _get_workspace_root()
service = MemoryService(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) return json.dumps({"status": "success", **result}, ensure_ascii=False)
except ValueError: except ValueError:
return json.dumps({"status": "error", "message": "invalid filename"}) return json.dumps({"status": "error", "message": "invalid filename"})