From 7708112649e68e05f18127c731fcbb88dcaf573a Mon Sep 17 00:00:00 2001 From: ooodc <549496103@qq.com> Date: Sun, 7 Jun 2026 19:50:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=AE=B0=E5=BF=86?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81=E5=88=97=E5=87=BA?= =?UTF-8?q?=E6=89=80=E6=9C=89=E8=AE=B0=E5=BF=86=E5=B9=B6=E5=9C=A8=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E5=B1=95=E7=A4=BA=EF=BC=8C=E4=BC=98=E5=8C=96=E8=AE=B0?= =?UTF-8?q?=E5=BF=86=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/command/handlers/list_memories.rs | 74 +++++++++ src/command/handlers/mod.rs | 1 + src/command/mod.rs | 3 + src/gateway/ws.rs | 10 ++ src/protocol/mod.rs | 15 ++ web/src/App.tsx | 63 +++++++- web/src/components/Panel/MemoryPanel.tsx | 189 +++++++++++++++++++++++ web/src/hooks/useChat.ts | 19 +++ web/src/types/protocol.ts | 20 +++ 9 files changed, 387 insertions(+), 7 deletions(-) create mode 100644 src/command/handlers/list_memories.rs create mode 100644 web/src/components/Panel/MemoryPanel.tsx diff --git a/src/command/handlers/list_memories.rs b/src/command/handlers/list_memories.rs new file mode 100644 index 0000000..d6cc18c --- /dev/null +++ b/src/command/handlers/list_memories.rs @@ -0,0 +1,74 @@ +use crate::command::context::CommandContext; +use crate::command::handler::{CommandHandler, CommandMetadata}; +use crate::command::response::{CommandError, CommandResponse}; +use crate::command::Command; +use crate::storage::SessionStore; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +/// Memory 摘要信息(发送给前端) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemorySummary { + pub id: String, + pub namespace: String, + pub memory_key: String, + pub content: String, + pub created_at: i64, + pub updated_at: i64, +} + +pub struct ListMemoriesCommandHandler { + store: Arc, +} + +impl ListMemoriesCommandHandler { + pub fn new(store: Arc) -> Self { + Self { store } + } +} + +#[async_trait] +impl CommandHandler for ListMemoriesCommandHandler { + fn can_handle(&self, cmd: &Command) -> bool { + matches!(cmd, Command::ListMemories) + } + + fn metadata(&self) -> Option { + Some(CommandMetadata { + name: "list_memories", + description: "列出所有记忆", + usage: "/list_memories", + }) + } + + async fn handle( + &self, + _cmd: Command, + ctx: CommandContext, + ) -> Result { + let records = self + .store + .list_memories_for_scope("user", crate::storage::GLOBAL_SCOPE_KEY) + .map_err(|e| CommandError::new("LIST_MEMORIES_ERROR", e.to_string()))?; + + let summaries: Vec = records + .into_iter() + .filter(|m| m.namespace != "_meta") + .map(|m| MemorySummary { + id: m.id, + namespace: m.namespace, + memory_key: m.memory_key, + content: m.content, + created_at: m.created_at, + updated_at: m.updated_at, + }) + .collect(); + + let memories_json = serde_json::to_string(&summaries) + .map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?; + + Ok(CommandResponse::success(ctx.request_id) + .with_metadata("memories", &memories_json)) + } +} diff --git a/src/command/handlers/mod.rs b/src/command/handlers/mod.rs index ba7497d..32befa8 100644 --- a/src/command/handlers/mod.rs +++ b/src/command/handlers/mod.rs @@ -2,6 +2,7 @@ pub mod delete_topic; pub mod get_current; pub mod help; pub mod list_channels; +pub mod list_memories; pub mod list_scheduler_jobs; pub mod list_sessions; pub mod list_sessions_by_channel; diff --git a/src/command/mod.rs b/src/command/mod.rs index 6d5ba74..0af5bac 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -56,6 +56,8 @@ pub enum Command { DeleteTopic { topic_id: String }, /// 停止当前正在执行的 Agent StopExecution, + /// 列出所有记忆 + ListMemories, } impl Command { @@ -78,6 +80,7 @@ impl Command { Command::LoadChatMessages { .. } => "load_chat_messages", Command::DeleteTopic { .. } => "delete_topic", Command::StopExecution => "stop_execution", + Command::ListMemories => "list_memories", } } } diff --git a/src/gateway/ws.rs b/src/gateway/ws.rs index 6c4c20f..ea4a095 100644 --- a/src/gateway/ws.rs +++ b/src/gateway/ws.rs @@ -9,6 +9,7 @@ use crate::command::handlers::delete_topic::DeleteTopicCommandHandler; use crate::command::handlers::get_current::GetCurrentSessionCommandHandler; use crate::command::handlers::help::HelpCommandHandler; use crate::command::handlers::list_channels::ListChannelsCommandHandler; +use crate::command::handlers::list_memories::ListMemoriesCommandHandler; use crate::command::handlers::list_scheduler_jobs::ListSchedulerJobsCommandHandler; use crate::command::handlers::list_sessions::ListSessionsCommandHandler; use crate::command::handlers::list_sessions_by_channel::ListSessionsByChannelCommandHandler; @@ -414,6 +415,8 @@ async fn handle_inbound( router.register(Box::new(HelpCommandHandler::new(metadata))); // 注册 list_scheduler_jobs 处理器 router.register(Box::new(ListSchedulerJobsCommandHandler::new(store.clone()))); + // 注册 list_memories 处理器 + router.register(Box::new(ListMemoriesCommandHandler::new(store.clone()))); // 注册 load_chat_messages 处理器 router.register(Box::new(LoadChatMessagesCommandHandler::new())); // 注册 stop_execution 处理器 @@ -502,6 +505,13 @@ async fn handle_inbound( } } + // 处理记忆列表 + if let Some(memories_json) = response.metadata.get("memories") { + if let Ok(memories) = serde_json::from_str::>(memories_json) { + let _ = sender.send(WsOutbound::MemoryList { memories }).await; + } + } + // 处理加载聊天消息请求 if let Some(load_chat_id) = response.metadata.get("load_chat_id") { let load_chat_channel = response.metadata.get("load_chat_channel") diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 440b28e..764cc95 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -63,6 +63,17 @@ pub struct SchedulerJobSessionLookup { pub chat_id: String, } +/// Memory 摘要(发送给前端) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemorySummary { + pub id: String, + pub namespace: String, + pub memory_key: String, + pub content: String, + pub created_at: i64, + pub updated_at: i64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SchedulerJobSummary { pub id: String, @@ -222,6 +233,10 @@ pub enum WsOutbound { SchedulerJobList { jobs: Vec, }, + #[serde(rename = "memory_list")] + MemoryList { + memories: Vec, + }, #[serde(rename = "execution_cancelled")] ExecutionCancelled { message: String }, #[serde(rename = "pong")] diff --git a/web/src/App.tsx b/web/src/App.tsx index 12f680f..b573ed3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,9 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Zap, ArrowLeft, Bot, Clock, Sun, Moon } from 'lucide-react' +import { Zap, ArrowLeft, Bot, Clock, Sun, Moon, PanelRightOpen } from 'lucide-react' import { ChatContainer } from './components/Chat/ChatContainer' import { TopicList } from './components/Sidebar/TopicList' import { SchedulerJobList } from './components/Sidebar/SchedulerJobList' -import { ToolPanel } from './components/Panel/ToolPanel' +import { MemoryPanel } from './components/Panel/MemoryPanel' import { ConnectionStatus } from './components/ConnectionStatus' import { ChannelSelector } from './components/Header/ChannelSelector' import { SessionSelector } from './components/Header/SessionSelector' @@ -32,6 +32,9 @@ function App() { isReadOnly, // 子智能体视图 subAgentView, + // 记忆 + memories, + requestMemoryList, // 定时任务 schedulerJobs, sidebarTab, @@ -73,6 +76,19 @@ function App() { // ---- 主题状态 ---- + const [memoryPanelOpen, setMemoryPanelOpen] = useState(() => { + try { + return localStorage.getItem('picobot-memory-panel-open') !== 'false' + } catch { + return false + } + }) + + const toggleMemoryPanel = useCallback((open: boolean) => { + setMemoryPanelOpen(open) + localStorage.setItem('picobot-memory-panel-open', String(open)) + }, []) + const [theme, setTheme] = useState<'dark' | 'light'>(() => { const saved = localStorage.getItem('picobot-theme') return saved === 'light' ? 'light' : 'dark' @@ -283,6 +299,21 @@ function App() { } }, [sidebarTab, status, handleCommand, sendMessage, requestSchedulerJobList]) + // 连接就绪时自动拉取记忆列表 + useEffect(() => { + if (status === 'connected') { + const cmd = requestMemoryList() + handleCommand(cmd) + sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) + } + }, [status, handleCommand, sendMessage, requestMemoryList]) + + const handleRefreshMemories = useCallback(() => { + const cmd = requestMemoryList() + handleCommand(cmd) + sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) + }, [handleCommand, sendMessage, requestMemoryList]) + const handleRefreshSchedulerJobs = useCallback(() => { const cmd = requestSchedulerJobList() handleCommand(cmd) @@ -360,7 +391,6 @@ function App() { return result }, [messages]) - const toolMessages = messages return (
@@ -402,7 +432,7 @@ function App() { {/* Main Content */} -
+
{/* Left Sidebar */}
{/* Tab 栏 */} @@ -535,10 +565,29 @@ function App() {
- {/* Right Sidebar - Tool Panel */} -
- + {/* Right Sidebar - Memory Panel (collapsible) */} +
+
+ toggleMemoryPanel(false)} + /> +
+ + {/* Reopen button — visible when panel is collapsed */} + {!memoryPanelOpen && ( +
+ +
+ )}
) diff --git a/web/src/components/Panel/MemoryPanel.tsx b/web/src/components/Panel/MemoryPanel.tsx new file mode 100644 index 0000000..2000132 --- /dev/null +++ b/web/src/components/Panel/MemoryPanel.tsx @@ -0,0 +1,189 @@ +import { useState } from 'react' +import { Brain, Lightbulb, Sparkles, RefreshCw, X, ChevronDown, ChevronRight, User, ListTodo, FileText, Tag } from 'lucide-react' +import type { MemorySummary } from '../../types/protocol' + +interface MemoryPanelProps { + memories: MemorySummary[] + onRefresh: () => void + onClose?: () => void +} + +interface NamespaceConfig { + label: string + icon: typeof Brain + accent: string + accentBorder: string +} + +const NAMESPACE_CONFIG: Record = { + user: { label: '用户事实', icon: Brain, accent: 'text-emerald-400', accentBorder: 'border-emerald-400/40' }, + preferences:{ label: '偏好', icon: Lightbulb, accent: 'text-amber-400', accentBorder: 'border-amber-400/40' }, + patterns: { label: '行为模式', icon: Sparkles, accent: 'text-violet-400', accentBorder: 'border-violet-400/40' }, + profile: { label: '档案', icon: User, accent: 'text-cyan-400', accentBorder: 'border-cyan-400/40' }, + tasks: { label: '任务', icon: ListTodo, accent: 'text-rose-400', accentBorder: 'border-rose-400/40' }, + notes: { label: '笔记', icon: FileText, accent: 'text-sky-400', accentBorder: 'border-sky-400/40' }, + tags: { label: '标签', icon: Tag, accent: 'text-pink-400', accentBorder: 'border-pink-400/40' }, +} + +function getNsConfig(ns: string): NamespaceConfig { + return NAMESPACE_CONFIG[ns] ?? { + label: ns, + icon: Tag, + accent: 'text-[var(--text-secondary)]', + accentBorder: 'border-[var(--border-color)]', + } +} + +function fmtKey(key: string): string { + return key + .replace(/_/g, ' ') + .replace(/\b\w/g, c => c.toUpperCase()) +} + +function MemoryCard({ memory, config }: { memory: MemorySummary; config: NamespaceConfig }) { + return ( +
+
+ + {fmtKey(memory.memory_key)} + +

+ {memory.content} +

+
+
+ ) +} + +function SectionHeader({ config, count, isCollapsed, onClick }: { config: NamespaceConfig; count: number; isCollapsed: boolean; onClick: () => void }) { + const Icon = config.icon + return ( + + ) +} + +export function MemoryPanel({ memories, onRefresh, onClose }: MemoryPanelProps) { + const [collapsed, setCollapsed] = useState>(() => { + try { + const saved = localStorage.getItem('picobot-memory-collapsed') + return saved ? new Set(JSON.parse(saved)) : new Set() + } catch { return new Set() } + }) + + const toggleCollapse = (ns: string) => { + setCollapsed(prev => { + const next = new Set(prev) + if (next.has(ns)) next.delete(ns) + else next.add(ns) + localStorage.setItem('picobot-memory-collapsed', JSON.stringify([...next])) + return next + }) + } + + const grouped = new Map() + for (const m of memories) { + const list = grouped.get(m.namespace) || [] + list.push(m) + grouped.set(m.namespace, list) + } + + const sortedNamespaces = Array.from(grouped.keys()).sort((a, b) => { + const order = ['user', 'preferences', 'patterns', 'profile', 'tasks', 'notes', 'tags'] + const ai = order.indexOf(a) + const bi = order.indexOf(b) + if (ai !== -1 && bi !== -1) return ai - bi + if (ai !== -1) return -1 + if (bi !== -1) return 1 + return a.localeCompare(b) + }) + + return ( +
+ {/* ---- 标题栏 ---- */} +
+
+ +
+ 记忆 + {memories.length > 0 && ( + + {memories.length} + + )} +
+ + {onClose && ( + + )} +
+
+ + {/* ---- 空状态 ---- */} + {memories.length === 0 && ( +
+
+
+
+ +
+

+ PicoBot 会在对话中
自动学习并记录关于你的信息 +

+
+
+ )} + + {/* ---- 记忆列表 ---- */} + {memories.length > 0 && ( +
+ {sortedNamespaces.map((namespace) => { + const config = getNsConfig(namespace) + const items = grouped.get(namespace)! + const isCollapsed = collapsed.has(namespace) + + return ( +
+ toggleCollapse(namespace)} + /> + {!isCollapsed && ( +
+ {items.map(m => ( + + ))} +
+ )} +
+ ) + })} +
+ )} +
+ ) +} diff --git a/web/src/hooks/useChat.ts b/web/src/hooks/useChat.ts index 2bbec72..b8ea2d9 100644 --- a/web/src/hooks/useChat.ts +++ b/web/src/hooks/useChat.ts @@ -16,6 +16,8 @@ import type { TaskMessagesLoaded, TaskStarted, Attachment, + MemorySummary, + MemoryList, SchedulerJobList, SchedulerJobSummary, SchedulerJobSessionLookup, @@ -77,6 +79,10 @@ interface UseChatReturn { enterSubAgentView: (taskId: string, description: string) => Command exitSubAgentView: () => void + // 记忆状态 + memories: MemorySummary[] + requestMemoryList: () => Command + // 定时任务状态 schedulerJobs: SchedulerJobSummary[] sidebarTab: 'topics' | 'scheduler' @@ -123,6 +129,7 @@ export function useChat(): UseChatReturn { const [sessions, setSessions] = useState([]) const [selectedSessionId, setSelectedSessionId] = useState(null) const [subAgentView, setSubAgentView] = useState(null) + const [memories, setMemories] = useState([]) const [schedulerJobs, setSchedulerJobs] = useState([]) const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics') const [schedulerView, setSchedulerView] = useState(null) @@ -512,6 +519,11 @@ export function useChat(): UseChatReturn { setSchedulerJobs(msg.jobs) break } + case 'memory_list': { + const msg = message as MemoryList + setMemories(msg.memories) + break + } case 'channel_list': { const msg = message as ChannelList @@ -665,6 +677,11 @@ export function useChat(): UseChatReturn { setSubAgentView(null) }, []) + // 记忆方法 + const requestMemoryList = useCallback((): Command => { + return { type: 'list_memories' } + }, []) + // 定时任务方法 const requestSchedulerJobList = useCallback((): Command => { return { type: 'list_scheduler_jobs' } @@ -746,6 +763,8 @@ export function useChat(): UseChatReturn { selectSession, enterSubAgentView, exitSubAgentView, + memories, + requestMemoryList, schedulerJobs, sidebarTab, setSidebarTab, diff --git a/web/src/types/protocol.ts b/web/src/types/protocol.ts index 4154a3e..e996180 100644 --- a/web/src/types/protocol.ts +++ b/web/src/types/protocol.ts @@ -173,6 +173,20 @@ export interface Pong { type: 'pong' } +export interface MemorySummary { + id: string + namespace: string + memory_key: string + content: string + created_at: number + updated_at: number +} + +export interface MemoryList { + type: 'memory_list' + memories: MemorySummary[] +} + export interface SchedulerJobSessionLookup { channel: string chat_id: string @@ -229,6 +243,7 @@ export type WsOutbound = | ChannelList | TaskMessagesLoaded | SchedulerJobList + | MemoryList | ExecutionCancelled | Pong @@ -316,6 +331,10 @@ export interface StopExecutionCommand { type: 'stop_execution' } +export interface ListMemoriesCommand { + type: 'list_memories' +} + export type Command = | CreateSessionCommand | ListSessionsCommand @@ -333,6 +352,7 @@ export type Command = | LoadChatMessagesCommand | DeleteTopicCommand | StopExecutionCommand + | ListMemoriesCommand // ============================================================================ // UI Types