feat: 添加记忆功能,支持列出所有记忆并在前端展示,优化记忆面板
This commit is contained in:
parent
1f04c62d0d
commit
7708112649
74
src/command/handlers/list_memories.rs
Normal file
74
src/command/handlers/list_memories.rs
Normal file
@ -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<SessionStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListMemoriesCommandHandler {
|
||||||
|
pub fn new(store: Arc<SessionStore>) -> Self {
|
||||||
|
Self { store }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl CommandHandler for ListMemoriesCommandHandler {
|
||||||
|
fn can_handle(&self, cmd: &Command) -> bool {
|
||||||
|
matches!(cmd, Command::ListMemories)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata(&self) -> Option<CommandMetadata> {
|
||||||
|
Some(CommandMetadata {
|
||||||
|
name: "list_memories",
|
||||||
|
description: "列出所有记忆",
|
||||||
|
usage: "/list_memories",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle(
|
||||||
|
&self,
|
||||||
|
_cmd: Command,
|
||||||
|
ctx: CommandContext,
|
||||||
|
) -> Result<CommandResponse, CommandError> {
|
||||||
|
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<MemorySummary> = 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ pub mod delete_topic;
|
|||||||
pub mod get_current;
|
pub mod get_current;
|
||||||
pub mod help;
|
pub mod help;
|
||||||
pub mod list_channels;
|
pub mod list_channels;
|
||||||
|
pub mod list_memories;
|
||||||
pub mod list_scheduler_jobs;
|
pub mod list_scheduler_jobs;
|
||||||
pub mod list_sessions;
|
pub mod list_sessions;
|
||||||
pub mod list_sessions_by_channel;
|
pub mod list_sessions_by_channel;
|
||||||
|
|||||||
@ -56,6 +56,8 @@ pub enum Command {
|
|||||||
DeleteTopic { topic_id: String },
|
DeleteTopic { topic_id: String },
|
||||||
/// 停止当前正在执行的 Agent
|
/// 停止当前正在执行的 Agent
|
||||||
StopExecution,
|
StopExecution,
|
||||||
|
/// 列出所有记忆
|
||||||
|
ListMemories,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Command {
|
impl Command {
|
||||||
@ -78,6 +80,7 @@ impl Command {
|
|||||||
Command::LoadChatMessages { .. } => "load_chat_messages",
|
Command::LoadChatMessages { .. } => "load_chat_messages",
|
||||||
Command::DeleteTopic { .. } => "delete_topic",
|
Command::DeleteTopic { .. } => "delete_topic",
|
||||||
Command::StopExecution => "stop_execution",
|
Command::StopExecution => "stop_execution",
|
||||||
|
Command::ListMemories => "list_memories",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ use crate::command::handlers::delete_topic::DeleteTopicCommandHandler;
|
|||||||
use crate::command::handlers::get_current::GetCurrentSessionCommandHandler;
|
use crate::command::handlers::get_current::GetCurrentSessionCommandHandler;
|
||||||
use crate::command::handlers::help::HelpCommandHandler;
|
use crate::command::handlers::help::HelpCommandHandler;
|
||||||
use crate::command::handlers::list_channels::ListChannelsCommandHandler;
|
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_scheduler_jobs::ListSchedulerJobsCommandHandler;
|
||||||
use crate::command::handlers::list_sessions::ListSessionsCommandHandler;
|
use crate::command::handlers::list_sessions::ListSessionsCommandHandler;
|
||||||
use crate::command::handlers::list_sessions_by_channel::ListSessionsByChannelCommandHandler;
|
use crate::command::handlers::list_sessions_by_channel::ListSessionsByChannelCommandHandler;
|
||||||
@ -414,6 +415,8 @@ async fn handle_inbound(
|
|||||||
router.register(Box::new(HelpCommandHandler::new(metadata)));
|
router.register(Box::new(HelpCommandHandler::new(metadata)));
|
||||||
// 注册 list_scheduler_jobs 处理器
|
// 注册 list_scheduler_jobs 处理器
|
||||||
router.register(Box::new(ListSchedulerJobsCommandHandler::new(store.clone())));
|
router.register(Box::new(ListSchedulerJobsCommandHandler::new(store.clone())));
|
||||||
|
// 注册 list_memories 处理器
|
||||||
|
router.register(Box::new(ListMemoriesCommandHandler::new(store.clone())));
|
||||||
// 注册 load_chat_messages 处理器
|
// 注册 load_chat_messages 处理器
|
||||||
router.register(Box::new(LoadChatMessagesCommandHandler::new()));
|
router.register(Box::new(LoadChatMessagesCommandHandler::new()));
|
||||||
// 注册 stop_execution 处理器
|
// 注册 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::<Vec<crate::protocol::MemorySummary>>(memories_json) {
|
||||||
|
let _ = sender.send(WsOutbound::MemoryList { memories }).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 处理加载聊天消息请求
|
// 处理加载聊天消息请求
|
||||||
if let Some(load_chat_id) = response.metadata.get("load_chat_id") {
|
if let Some(load_chat_id) = response.metadata.get("load_chat_id") {
|
||||||
let load_chat_channel = response.metadata.get("load_chat_channel")
|
let load_chat_channel = response.metadata.get("load_chat_channel")
|
||||||
|
|||||||
@ -63,6 +63,17 @@ pub struct SchedulerJobSessionLookup {
|
|||||||
pub chat_id: String,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct SchedulerJobSummary {
|
pub struct SchedulerJobSummary {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@ -222,6 +233,10 @@ pub enum WsOutbound {
|
|||||||
SchedulerJobList {
|
SchedulerJobList {
|
||||||
jobs: Vec<SchedulerJobSummary>,
|
jobs: Vec<SchedulerJobSummary>,
|
||||||
},
|
},
|
||||||
|
#[serde(rename = "memory_list")]
|
||||||
|
MemoryList {
|
||||||
|
memories: Vec<MemorySummary>,
|
||||||
|
},
|
||||||
#[serde(rename = "execution_cancelled")]
|
#[serde(rename = "execution_cancelled")]
|
||||||
ExecutionCancelled { message: String },
|
ExecutionCancelled { message: String },
|
||||||
#[serde(rename = "pong")]
|
#[serde(rename = "pong")]
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
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 { ChatContainer } from './components/Chat/ChatContainer'
|
||||||
import { TopicList } from './components/Sidebar/TopicList'
|
import { TopicList } from './components/Sidebar/TopicList'
|
||||||
import { SchedulerJobList } from './components/Sidebar/SchedulerJobList'
|
import { SchedulerJobList } from './components/Sidebar/SchedulerJobList'
|
||||||
import { ToolPanel } from './components/Panel/ToolPanel'
|
import { MemoryPanel } from './components/Panel/MemoryPanel'
|
||||||
import { ConnectionStatus } from './components/ConnectionStatus'
|
import { ConnectionStatus } from './components/ConnectionStatus'
|
||||||
import { ChannelSelector } from './components/Header/ChannelSelector'
|
import { ChannelSelector } from './components/Header/ChannelSelector'
|
||||||
import { SessionSelector } from './components/Header/SessionSelector'
|
import { SessionSelector } from './components/Header/SessionSelector'
|
||||||
@ -32,6 +32,9 @@ function App() {
|
|||||||
isReadOnly,
|
isReadOnly,
|
||||||
// 子智能体视图
|
// 子智能体视图
|
||||||
subAgentView,
|
subAgentView,
|
||||||
|
// 记忆
|
||||||
|
memories,
|
||||||
|
requestMemoryList,
|
||||||
// 定时任务
|
// 定时任务
|
||||||
schedulerJobs,
|
schedulerJobs,
|
||||||
sidebarTab,
|
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 [theme, setTheme] = useState<'dark' | 'light'>(() => {
|
||||||
const saved = localStorage.getItem('picobot-theme')
|
const saved = localStorage.getItem('picobot-theme')
|
||||||
return saved === 'light' ? 'light' : 'dark'
|
return saved === 'light' ? 'light' : 'dark'
|
||||||
@ -283,6 +299,21 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [sidebarTab, status, handleCommand, sendMessage, requestSchedulerJobList])
|
}, [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 handleRefreshSchedulerJobs = useCallback(() => {
|
||||||
const cmd = requestSchedulerJobList()
|
const cmd = requestSchedulerJobList()
|
||||||
handleCommand(cmd)
|
handleCommand(cmd)
|
||||||
@ -360,7 +391,6 @@ function App() {
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
}, [messages])
|
}, [messages])
|
||||||
const toolMessages = messages
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col bg-[var(--bg-primary)] text-[var(--text-primary)] overflow-hidden">
|
<div className="flex h-screen flex-col bg-[var(--bg-primary)] text-[var(--text-primary)] overflow-hidden">
|
||||||
@ -402,7 +432,7 @@ function App() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden relative">
|
||||||
{/* Left Sidebar */}
|
{/* Left Sidebar */}
|
||||||
<div className={`w-72 shrink-0 border-r border-[var(--border-color)] bg-[var(--bg-secondary)]/50 flex flex-col ${subAgentView || schedulerView ? 'opacity-50 pointer-events-none' : ''}`}>
|
<div className={`w-72 shrink-0 border-r border-[var(--border-color)] bg-[var(--bg-secondary)]/50 flex flex-col ${subAgentView || schedulerView ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||||
{/* Tab 栏 */}
|
{/* Tab 栏 */}
|
||||||
@ -535,11 +565,30 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Sidebar - Tool Panel */}
|
{/* Right Sidebar - Memory Panel (collapsible) */}
|
||||||
<div className="w-80 shrink-0 border-l border-[var(--border-color)] bg-[var(--bg-secondary)]/50">
|
<div className={`shrink-0 border-l border-[var(--border-color)] bg-[var(--bg-secondary)]/50 transition-all duration-300 ease-out overflow-hidden ${memoryPanelOpen ? 'w-80' : 'w-0 border-l-0'}`}>
|
||||||
<ToolPanel messages={toolMessages} />
|
<div className={`w-80 ${memoryPanelOpen ? '' : 'invisible'}`}>
|
||||||
|
<MemoryPanel
|
||||||
|
memories={memories}
|
||||||
|
onRefresh={handleRefreshMemories}
|
||||||
|
onClose={() => toggleMemoryPanel(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Reopen button — visible when panel is collapsed */}
|
||||||
|
{!memoryPanelOpen && (
|
||||||
|
<div className="absolute right-0 top-1/2 -translate-y-1/2 z-10">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleMemoryPanel(true)}
|
||||||
|
className="flex items-center gap-1.5 px-2 py-4 rounded-l-xl bg-[var(--bg-secondary)]/80 backdrop-blur-sm border border-r-0 border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:border-[var(--accent-cyan)]/30 transition-all duration-300 shadow-lg"
|
||||||
|
title="展开记忆面板"
|
||||||
|
>
|
||||||
|
<PanelRightOpen className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
189
web/src/components/Panel/MemoryPanel.tsx
Normal file
189
web/src/components/Panel/MemoryPanel.tsx
Normal file
@ -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<string, NamespaceConfig> = {
|
||||||
|
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 (
|
||||||
|
<div className={`group rounded-lg bg-[var(--overlay-hover)] border-l-2 ${config.accentBorder} border border-[var(--border-color)] overflow-hidden transition-all duration-200 hover:bg-[var(--overlay-subtle)] hover:border-[var(--border-accent)]`}>
|
||||||
|
<div className="px-3 py-2.5">
|
||||||
|
<span className={`block text-[10px] font-mono uppercase tracking-wider ${config.accent} opacity-60 mb-0.5`}>
|
||||||
|
{fmtKey(memory.memory_key)}
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)] leading-relaxed">
|
||||||
|
{memory.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHeader({ config, count, isCollapsed, onClick }: { config: NamespaceConfig; count: number; isCollapsed: boolean; onClick: () => void }) {
|
||||||
|
const Icon = config.icon
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="group flex items-center gap-2 w-full py-1.5 rounded-lg transition-colors hover:bg-[var(--overlay-hover)]"
|
||||||
|
>
|
||||||
|
<span className="transition-transform duration-200">
|
||||||
|
{isCollapsed
|
||||||
|
? <ChevronRight className="h-3 w-3 text-[var(--text-muted)]" />
|
||||||
|
: <ChevronDown className="h-3 w-3 text-[var(--text-muted)]" />
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<div className={`flex items-center justify-center w-5 h-5 rounded-md bg-[var(--overlay-hover)] ${config.accent}`}>
|
||||||
|
<Icon className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-semibold text-[var(--text-primary)] tracking-tight">
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-[var(--text-muted)] font-mono tabular-nums ml-auto">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MemoryPanel({ memories, onRefresh, onClose }: MemoryPanelProps) {
|
||||||
|
const [collapsed, setCollapsed] = useState<Set<string>>(() => {
|
||||||
|
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<string, MemorySummary[]>()
|
||||||
|
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 (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* ---- 标题栏 ---- */}
|
||||||
|
<div className="shrink-0 border-b border-[var(--border-color)] px-4 py-3 flex items-center gap-2.5">
|
||||||
|
<div className="flex items-center justify-center w-6 h-6 rounded-lg bg-[var(--accent-cyan)]/10">
|
||||||
|
<Brain className="h-3.5 w-3.5 text-[var(--accent-cyan)]" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-bold text-[var(--text-primary)] tracking-tight">记忆</span>
|
||||||
|
{memories.length > 0 && (
|
||||||
|
<span className="text-[11px] font-mono text-[var(--text-muted)] tabular-nums ml-0.5">
|
||||||
|
{memories.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex items-center gap-0.5">
|
||||||
|
<button onClick={onRefresh} className="p-1.5 rounded-lg hover:bg-[var(--overlay-hover)] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] transition-colors" title="刷新">
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
{onClose && (
|
||||||
|
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--overlay-hover)] text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors" title="收起">
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- 空状态 ---- */}
|
||||||
|
{memories.length === 0 && (
|
||||||
|
<div className="flex flex-1 items-center justify-center p-8">
|
||||||
|
<div className="text-center select-none">
|
||||||
|
<div className="relative inline-block mb-5">
|
||||||
|
<div className="absolute inset-0 rounded-full bg-[var(--accent-cyan)]/10 blur-xl animate-pulse" />
|
||||||
|
<Brain className="relative h-12 w-12 text-[var(--accent-cyan)]/25" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--text-muted)] leading-relaxed">
|
||||||
|
PicoBot 会在对话中<br />自动学习并记录关于你的信息
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- 记忆列表 ---- */}
|
||||||
|
{memories.length > 0 && (
|
||||||
|
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-3">
|
||||||
|
{sortedNamespaces.map((namespace) => {
|
||||||
|
const config = getNsConfig(namespace)
|
||||||
|
const items = grouped.get(namespace)!
|
||||||
|
const isCollapsed = collapsed.has(namespace)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={namespace}>
|
||||||
|
<SectionHeader
|
||||||
|
config={config}
|
||||||
|
count={items.length}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
onClick={() => toggleCollapse(namespace)}
|
||||||
|
/>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div className="mt-1.5 space-y-1.5">
|
||||||
|
{items.map(m => (
|
||||||
|
<MemoryCard key={m.id} memory={m} config={config} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -16,6 +16,8 @@ import type {
|
|||||||
TaskMessagesLoaded,
|
TaskMessagesLoaded,
|
||||||
TaskStarted,
|
TaskStarted,
|
||||||
Attachment,
|
Attachment,
|
||||||
|
MemorySummary,
|
||||||
|
MemoryList,
|
||||||
SchedulerJobList,
|
SchedulerJobList,
|
||||||
SchedulerJobSummary,
|
SchedulerJobSummary,
|
||||||
SchedulerJobSessionLookup,
|
SchedulerJobSessionLookup,
|
||||||
@ -77,6 +79,10 @@ interface UseChatReturn {
|
|||||||
enterSubAgentView: (taskId: string, description: string) => Command
|
enterSubAgentView: (taskId: string, description: string) => Command
|
||||||
exitSubAgentView: () => void
|
exitSubAgentView: () => void
|
||||||
|
|
||||||
|
// 记忆状态
|
||||||
|
memories: MemorySummary[]
|
||||||
|
requestMemoryList: () => Command
|
||||||
|
|
||||||
// 定时任务状态
|
// 定时任务状态
|
||||||
schedulerJobs: SchedulerJobSummary[]
|
schedulerJobs: SchedulerJobSummary[]
|
||||||
sidebarTab: 'topics' | 'scheduler'
|
sidebarTab: 'topics' | 'scheduler'
|
||||||
@ -123,6 +129,7 @@ export function useChat(): UseChatReturn {
|
|||||||
const [sessions, setSessions] = useState<SessionSummary[]>([])
|
const [sessions, setSessions] = useState<SessionSummary[]>([])
|
||||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
|
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
|
||||||
const [subAgentView, setSubAgentView] = useState<SubAgentView | null>(null)
|
const [subAgentView, setSubAgentView] = useState<SubAgentView | null>(null)
|
||||||
|
const [memories, setMemories] = useState<MemorySummary[]>([])
|
||||||
const [schedulerJobs, setSchedulerJobs] = useState<SchedulerJobSummary[]>([])
|
const [schedulerJobs, setSchedulerJobs] = useState<SchedulerJobSummary[]>([])
|
||||||
const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics')
|
const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics')
|
||||||
const [schedulerView, setSchedulerView] = useState<SchedulerJobView | null>(null)
|
const [schedulerView, setSchedulerView] = useState<SchedulerJobView | null>(null)
|
||||||
@ -512,6 +519,11 @@ export function useChat(): UseChatReturn {
|
|||||||
setSchedulerJobs(msg.jobs)
|
setSchedulerJobs(msg.jobs)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case 'memory_list': {
|
||||||
|
const msg = message as MemoryList
|
||||||
|
setMemories(msg.memories)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case 'channel_list': {
|
case 'channel_list': {
|
||||||
const msg = message as ChannelList
|
const msg = message as ChannelList
|
||||||
@ -665,6 +677,11 @@ export function useChat(): UseChatReturn {
|
|||||||
setSubAgentView(null)
|
setSubAgentView(null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// 记忆方法
|
||||||
|
const requestMemoryList = useCallback((): Command => {
|
||||||
|
return { type: 'list_memories' }
|
||||||
|
}, [])
|
||||||
|
|
||||||
// 定时任务方法
|
// 定时任务方法
|
||||||
const requestSchedulerJobList = useCallback((): Command => {
|
const requestSchedulerJobList = useCallback((): Command => {
|
||||||
return { type: 'list_scheduler_jobs' }
|
return { type: 'list_scheduler_jobs' }
|
||||||
@ -746,6 +763,8 @@ export function useChat(): UseChatReturn {
|
|||||||
selectSession,
|
selectSession,
|
||||||
enterSubAgentView,
|
enterSubAgentView,
|
||||||
exitSubAgentView,
|
exitSubAgentView,
|
||||||
|
memories,
|
||||||
|
requestMemoryList,
|
||||||
schedulerJobs,
|
schedulerJobs,
|
||||||
sidebarTab,
|
sidebarTab,
|
||||||
setSidebarTab,
|
setSidebarTab,
|
||||||
|
|||||||
@ -173,6 +173,20 @@ export interface Pong {
|
|||||||
type: '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 {
|
export interface SchedulerJobSessionLookup {
|
||||||
channel: string
|
channel: string
|
||||||
chat_id: string
|
chat_id: string
|
||||||
@ -229,6 +243,7 @@ export type WsOutbound =
|
|||||||
| ChannelList
|
| ChannelList
|
||||||
| TaskMessagesLoaded
|
| TaskMessagesLoaded
|
||||||
| SchedulerJobList
|
| SchedulerJobList
|
||||||
|
| MemoryList
|
||||||
| ExecutionCancelled
|
| ExecutionCancelled
|
||||||
| Pong
|
| Pong
|
||||||
|
|
||||||
@ -316,6 +331,10 @@ export interface StopExecutionCommand {
|
|||||||
type: 'stop_execution'
|
type: 'stop_execution'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ListMemoriesCommand {
|
||||||
|
type: 'list_memories'
|
||||||
|
}
|
||||||
|
|
||||||
export type Command =
|
export type Command =
|
||||||
| CreateSessionCommand
|
| CreateSessionCommand
|
||||||
| ListSessionsCommand
|
| ListSessionsCommand
|
||||||
@ -333,6 +352,7 @@ export type Command =
|
|||||||
| LoadChatMessagesCommand
|
| LoadChatMessagesCommand
|
||||||
| DeleteTopicCommand
|
| DeleteTopicCommand
|
||||||
| StopExecutionCommand
|
| StopExecutionCommand
|
||||||
|
| ListMemoriesCommand
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// UI Types
|
// UI Types
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user