diff --git a/desktop/src/renderer/src/i18n.ts b/desktop/src/renderer/src/i18n.ts index d90cdb94..0a5e4bcd 100644 --- a/desktop/src/renderer/src/i18n.ts +++ b/desktop/src/renderer/src/i18n.ts @@ -162,15 +162,26 @@ const translations: Record> = { skills_loading_desc: '技能将在加载后显示', skill_enabled: '已启用', skill_disabled: '已禁用', - memory_title: '记忆', + memory_title: '记忆管理', memory_desc: '查看 Agent 记忆文件和内容', + memory_tab_files: '记忆文件', + memory_tab_dreams: '自主进化', memory_loading: '加载记忆文件中...', memory_loading_desc: '记忆文件将在加载后显示', memory_col_name: '文件名', memory_col_type: '类型', memory_col_size: '大小', memory_col_updated: '更新时间', - memory_back: '返回', + memory_back: '返回列表', + memory_empty_files: '暂无记忆文件', + memory_empty_evolution: '暂无进化记录', + memory_type_global: '全局', + memory_type_daily: '每日', + memory_type_evolution: '进化', + memory_type_dream: '梦境', + memory_doc_load_error: '内容加载失败', + memory_prev: '上一页', + memory_next: '下一页', channels_title: '通道管理', channels_desc: '查看和管理消息通道', channels_add: '连接', @@ -358,13 +369,24 @@ const translations: Record> = { skill_disabled: 'Disabled', memory_title: 'Memory', memory_desc: 'View agent memory files and contents', + memory_tab_files: 'Memory Files', + memory_tab_dreams: 'Self-Evolution', memory_loading: 'Loading memory files...', memory_loading_desc: 'Memory files will be displayed here', memory_col_name: 'Filename', memory_col_type: 'Type', memory_col_size: 'Size', memory_col_updated: 'Updated', - memory_back: 'Back', + memory_back: 'Back to list', + memory_empty_files: 'No memory files', + memory_empty_evolution: 'No evolution records yet', + memory_type_global: 'Global', + memory_type_daily: 'Daily', + memory_type_evolution: 'Evolution', + memory_type_dream: 'Dream', + memory_doc_load_error: 'Failed to load content', + memory_prev: 'Prev', + memory_next: 'Next', channels_title: 'Channels', channels_desc: 'View and manage messaging channels', channels_add: 'Connect', diff --git a/desktop/src/renderer/src/pages/MemoryPage.tsx b/desktop/src/renderer/src/pages/MemoryPage.tsx index 0e3193e4..4ef20335 100644 --- a/desktop/src/renderer/src/pages/MemoryPage.tsx +++ b/desktop/src/renderer/src/pages/MemoryPage.tsx @@ -1,142 +1,256 @@ -import React, { useState, useEffect } from 'react' +import React, { useCallback, useEffect, useState } from 'react' +import { Loader2, ArrowLeft, Brain, Sprout, FileText, ChevronLeft, ChevronRight } from 'lucide-react' +import type { LucideIcon } from 'lucide-react' import { t } from '../i18n' import apiClient from '../api/client' -import type { MemoryItem } from '../types' +import type { MemoryItem, MemoryCategory } from '../types' +import Markdown from '../components/Markdown' interface MemoryPageProps { baseUrl: string } +type Tab = 'files' | 'evolution' +const PAGE_SIZE = 10 + const formatSize = (bytes: number): string => { if (bytes < 1024) return bytes + ' B' - if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB' - return (bytes / (1024 * 1024)).toFixed(1) + ' MB' + return (bytes / 1024).toFixed(1) + ' KB' } -const formatTime = (ts: number): string => { - return new Date(ts * 1000).toLocaleString() +// Map a file's `type` to its display badge. +const typeBadge = (type: string): { label: string; cls: string } => { + switch (type) { + case 'global': + return { label: t('memory_type_global'), cls: 'bg-accent-soft text-accent' } + case 'evolution': + return { label: t('memory_type_evolution'), cls: 'bg-inset text-success' } + case 'dream': + return { label: t('memory_type_dream'), cls: 'bg-inset text-info' } + default: + return { label: t('memory_type_daily'), cls: 'bg-inset text-content-secondary' } + } } const MemoryPage: React.FC = ({ baseUrl }) => { + const [tab, setTab] = useState('files') const [items, setItems] = useState([]) const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) const [loading, setLoading] = useState(true) + const [viewing, setViewing] = useState(null) const [content, setContent] = useState('') - const [loadingContent, setLoadingContent] = useState(false) + const [docLoading, setDocLoading] = useState(false) + + const category: MemoryCategory = tab === 'evolution' ? 'evolution' : 'memory' + + const loadList = useCallback( + async (cat: MemoryCategory, p: number) => { + try { + setLoading(true) + const data = await apiClient.getMemoryList(p, PAGE_SIZE, cat) + setItems(data.list || []) + setTotal(data.total || 0) + setPage(data.page || p) + } catch (err) { + console.error('Failed to load memory:', err) + setItems([]) + setTotal(0) + } finally { + setLoading(false) + } + }, + [] + ) useEffect(() => { apiClient.setBaseUrl(baseUrl) - loadMemory() - }, [baseUrl]) + void loadList(category, 1) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [baseUrl, tab]) - const loadMemory = async () => { + const openFile = async (item: MemoryItem) => { + // In the evolution tab a file lives in its own dir (dream vs evolution). + const fileCategory: MemoryCategory = + item.type === 'dream' || item.type === 'evolution' ? (item.type as MemoryCategory) : category + setViewing(item.filename) + setDocLoading(true) + setContent('') try { - setLoading(true) - const data = await apiClient.getMemoryList() - setItems(data.list || []) - setTotal(data.total) - } catch (err) { - console.error('Failed to load memory:', err) - } finally { - setLoading(false) - } - } - - const viewFile = async (filename: string) => { - setViewing(filename) - setLoadingContent(true) - try { - const text = await apiClient.getMemoryContent(filename) + const text = await apiClient.getMemoryContent(item.filename, fileCategory) setContent(text) - } catch (err) { - setContent(`Failed to load: ${err}`) + } catch { + setContent(`> ${t('memory_doc_load_error')}`) } finally { - setLoadingContent(false) + setDocLoading(false) } } + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) + return ( -
-
- {!viewing ? ( - /* List panel */ - <> -
-
-

{t('memory_title')}

-

{t('memory_desc')}

-
-
- {loading ? ( -
-
- -
-

{t('memory_loading')}

-

{t('memory_loading_desc')}

-
- ) : items.length > 0 ? ( -
- - - - - - - - - - {items.map((item) => ( - viewFile(item.filename)} - className="border-b border-slate-100 dark:border-white/5 hover:bg-slate-50 dark:hover:bg-white/5 cursor-pointer transition-colors" - > - - - - - ))} - -
{t('memory_col_name')}{t('memory_col_size')}{t('memory_col_updated')}
{item.filename}{formatSize(item.size)}{item.updated_at}
-
- ) : ( -
-
- -
-

{t('memory_loading')}

-
- )} - - ) : ( - /* File viewer */ - <> -
- -

{viewing}

-
-
-
- {loadingContent ? ( -
Loading...
- ) : ( -
{content}
- )} -
-
- +
+ {/* Header */} +
+
+

{t('memory_title')}

+

{t('memory_desc')}

+
+ {!viewing && ( +
+ setTab('files')} /> + setTab('evolution')} + /> +
)}
+ + {viewing ? ( + /* File viewer */ +
+
+ +

{viewing}

+
+
+
+ {docLoading ? ( +
+ +
+ ) : ( + + )} +
+
+
+ ) : ( + /* List */ +
+
+ {loading ? ( +
+ + {t('memory_loading')} +
+ ) : items.length === 0 ? ( +
+ {tab === 'evolution' ? : } +

{tab === 'evolution' ? t('memory_empty_evolution') : t('memory_empty_files')}

+
+ ) : ( + <> +
+ + + + + + + + + + + {items.map((item) => { + const badge = typeBadge(item.type) + return ( + openFile(item)} + className="border-b border-subtle last:border-0 hover:bg-inset cursor-pointer transition-colors" + > + + + + + + ) + })} + +
{t('memory_col_name')}{t('memory_col_type')}{t('memory_col_size')}{t('memory_col_updated')}
+ + + {item.filename} + + + {badge.label} + {formatSize(item.size)}{item.updated_at}
+
+ + {totalPages > 1 && ( +
+ + {page} / {totalPages} + +
+ loadList(category, page - 1)} /> + = totalPages} + onClick={() => loadList(category, page + 1)} + iconRight + /> +
+
+ )} + + )} +
+
+ )}
) } +const Th: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +) + +const TabBtn: React.FC<{ icon: LucideIcon; label: string; active: boolean; onClick: () => void }> = ({ + icon: Icon, + label, + active, + onClick, +}) => ( + +) + +const PageBtn: React.FC<{ + icon: LucideIcon + label: string + disabled: boolean + onClick: () => void + iconRight?: boolean +}> = ({ icon: Icon, label, disabled, onClick, iconRight }) => ( + +) + export default MemoryPage