feat: 添加记忆 CRUD 功能,支持创建、更新和删除记忆,优化记忆面板交互
This commit is contained in:
parent
7708112649
commit
cca913b610
118
src/command/handlers/memory_crud.rs
Normal file
118
src/command/handlers/memory_crud.rs
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
use crate::command::context::CommandContext;
|
||||||
|
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||||
|
use crate::command::response::{CommandError, CommandResponse};
|
||||||
|
use crate::command::Command;
|
||||||
|
use crate::storage::{MemoryUpsert, SessionStore, GLOBAL_SCOPE_KEY};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct MemoryCrudCommandHandler {
|
||||||
|
store: Arc<SessionStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemoryCrudCommandHandler {
|
||||||
|
pub fn new(store: Arc<SessionStore>) -> Self {
|
||||||
|
Self { store }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通过 ID 查找记忆的 namespace 和 memory_key
|
||||||
|
fn find_by_id(
|
||||||
|
store: &SessionStore,
|
||||||
|
id: &str,
|
||||||
|
) -> Result<Option<(String, String)>, CommandError> {
|
||||||
|
let records = store
|
||||||
|
.list_memories_for_scope("user", GLOBAL_SCOPE_KEY)
|
||||||
|
.map_err(|e| CommandError::new("LIST_ERROR", e.to_string()))?;
|
||||||
|
Ok(records
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.id == id)
|
||||||
|
.map(|r| (r.namespace.clone(), r.memory_key.clone())))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl CommandHandler for MemoryCrudCommandHandler {
|
||||||
|
fn can_handle(&self, cmd: &Command) -> bool {
|
||||||
|
matches!(
|
||||||
|
cmd,
|
||||||
|
Command::CreateMemory { .. } | Command::UpdateMemory { .. } | Command::DeleteMemory { .. }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata(&self) -> Option<CommandMetadata> {
|
||||||
|
Some(CommandMetadata {
|
||||||
|
name: "memory_crud",
|
||||||
|
description: "创建、更新、删除记忆",
|
||||||
|
usage: "/memory_crud",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle(
|
||||||
|
&self,
|
||||||
|
cmd: Command,
|
||||||
|
ctx: CommandContext,
|
||||||
|
) -> Result<CommandResponse, CommandError> {
|
||||||
|
let scope_key = GLOBAL_SCOPE_KEY.to_string();
|
||||||
|
|
||||||
|
match cmd {
|
||||||
|
Command::CreateMemory {
|
||||||
|
namespace,
|
||||||
|
key,
|
||||||
|
content,
|
||||||
|
} => {
|
||||||
|
let input = MemoryUpsert {
|
||||||
|
scope_kind: "user".to_string(),
|
||||||
|
scope_key,
|
||||||
|
namespace,
|
||||||
|
memory_key: key,
|
||||||
|
content,
|
||||||
|
source_type: "manual".to_string(),
|
||||||
|
source_session_id: None,
|
||||||
|
source_message_id: None,
|
||||||
|
source_message_seq: None,
|
||||||
|
source_channel_name: None,
|
||||||
|
source_chat_id: None,
|
||||||
|
};
|
||||||
|
self.store
|
||||||
|
.put_memory(&input)
|
||||||
|
.map_err(|e| CommandError::new("CREATE_ERROR", e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::UpdateMemory { id, content } => {
|
||||||
|
let Some((namespace, memory_key)) = find_by_id(&self.store, &id)? else {
|
||||||
|
return Err(CommandError::new("NOT_FOUND", "记忆不存在"));
|
||||||
|
};
|
||||||
|
let input = MemoryUpsert {
|
||||||
|
scope_kind: "user".to_string(),
|
||||||
|
scope_key,
|
||||||
|
namespace,
|
||||||
|
memory_key,
|
||||||
|
content,
|
||||||
|
source_type: "manual".to_string(),
|
||||||
|
source_session_id: None,
|
||||||
|
source_message_id: None,
|
||||||
|
source_message_seq: None,
|
||||||
|
source_channel_name: None,
|
||||||
|
source_chat_id: None,
|
||||||
|
};
|
||||||
|
self.store
|
||||||
|
.update_memory(&input)
|
||||||
|
.map_err(|e| CommandError::new("UPDATE_ERROR", e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::DeleteMemory { id } => {
|
||||||
|
let Some((namespace, memory_key)) = find_by_id(&self.store, &id)? else {
|
||||||
|
return Err(CommandError::new("NOT_FOUND", "记忆不存在"));
|
||||||
|
};
|
||||||
|
self.store
|
||||||
|
.delete_memory("user", &scope_key, &namespace, &memory_key)
|
||||||
|
.map_err(|e| CommandError::new("DELETE_ERROR", e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(CommandResponse::success(ctx.request_id)
|
||||||
|
.with_metadata("memory_updated", "true"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ pub mod help;
|
|||||||
pub mod list_channels;
|
pub mod list_channels;
|
||||||
pub mod list_memories;
|
pub mod list_memories;
|
||||||
pub mod list_scheduler_jobs;
|
pub mod list_scheduler_jobs;
|
||||||
|
pub mod memory_crud;
|
||||||
pub mod list_sessions;
|
pub mod list_sessions;
|
||||||
pub mod list_sessions_by_channel;
|
pub mod list_sessions_by_channel;
|
||||||
pub mod list_topics;
|
pub mod list_topics;
|
||||||
|
|||||||
@ -58,6 +58,19 @@ pub enum Command {
|
|||||||
StopExecution,
|
StopExecution,
|
||||||
/// 列出所有记忆
|
/// 列出所有记忆
|
||||||
ListMemories,
|
ListMemories,
|
||||||
|
/// 创建新记忆
|
||||||
|
CreateMemory {
|
||||||
|
namespace: String,
|
||||||
|
key: String,
|
||||||
|
content: String,
|
||||||
|
},
|
||||||
|
/// 更新已有记忆
|
||||||
|
UpdateMemory {
|
||||||
|
id: String,
|
||||||
|
content: String,
|
||||||
|
},
|
||||||
|
/// 删除记忆
|
||||||
|
DeleteMemory { id: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Command {
|
impl Command {
|
||||||
@ -81,6 +94,9 @@ impl Command {
|
|||||||
Command::DeleteTopic { .. } => "delete_topic",
|
Command::DeleteTopic { .. } => "delete_topic",
|
||||||
Command::StopExecution => "stop_execution",
|
Command::StopExecution => "stop_execution",
|
||||||
Command::ListMemories => "list_memories",
|
Command::ListMemories => "list_memories",
|
||||||
|
Command::CreateMemory { .. } => "create_memory",
|
||||||
|
Command::UpdateMemory { .. } => "update_memory",
|
||||||
|
Command::DeleteMemory { .. } => "delete_memory",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ 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_memories::ListMemoriesCommandHandler;
|
||||||
use crate::command::handlers::list_scheduler_jobs::ListSchedulerJobsCommandHandler;
|
use crate::command::handlers::list_scheduler_jobs::ListSchedulerJobsCommandHandler;
|
||||||
|
use crate::command::handlers::memory_crud::MemoryCrudCommandHandler;
|
||||||
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;
|
||||||
use crate::command::handlers::list_topics::ListTopicsCommandHandler;
|
use crate::command::handlers::list_topics::ListTopicsCommandHandler;
|
||||||
@ -417,6 +418,8 @@ async fn handle_inbound(
|
|||||||
router.register(Box::new(ListSchedulerJobsCommandHandler::new(store.clone())));
|
router.register(Box::new(ListSchedulerJobsCommandHandler::new(store.clone())));
|
||||||
// 注册 list_memories 处理器
|
// 注册 list_memories 处理器
|
||||||
router.register(Box::new(ListMemoriesCommandHandler::new(store.clone())));
|
router.register(Box::new(ListMemoriesCommandHandler::new(store.clone())));
|
||||||
|
// 注册 memory_crud 处理器
|
||||||
|
router.register(Box::new(MemoryCrudCommandHandler::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 处理器
|
||||||
@ -512,6 +515,25 @@ async fn handle_inbound(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 记忆 CRUD 后自动刷新列表
|
||||||
|
if response.metadata.get("memory_updated").map(|v| v.as_str()) == Some("true") {
|
||||||
|
if let Ok(records) = store.list_memories_for_scope("user", crate::storage::GLOBAL_SCOPE_KEY) {
|
||||||
|
let memories: Vec<crate::protocol::MemorySummary> = records
|
||||||
|
.into_iter()
|
||||||
|
.filter(|m| m.namespace != "_meta")
|
||||||
|
.map(|m| crate::protocol::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 _ = 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")
|
||||||
|
|||||||
@ -35,6 +35,9 @@ function App() {
|
|||||||
// 记忆
|
// 记忆
|
||||||
memories,
|
memories,
|
||||||
requestMemoryList,
|
requestMemoryList,
|
||||||
|
createMemory,
|
||||||
|
updateMemory,
|
||||||
|
deleteMemory,
|
||||||
// 定时任务
|
// 定时任务
|
||||||
schedulerJobs,
|
schedulerJobs,
|
||||||
sidebarTab,
|
sidebarTab,
|
||||||
@ -314,6 +317,11 @@ function App() {
|
|||||||
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
||||||
}, [handleCommand, sendMessage, requestMemoryList])
|
}, [handleCommand, sendMessage, requestMemoryList])
|
||||||
|
|
||||||
|
const sendMemoryCommand = useCallback((cmd: Command) => {
|
||||||
|
handleCommand(cmd)
|
||||||
|
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
||||||
|
}, [handleCommand, sendMessage])
|
||||||
|
|
||||||
const handleRefreshSchedulerJobs = useCallback(() => {
|
const handleRefreshSchedulerJobs = useCallback(() => {
|
||||||
const cmd = requestSchedulerJobList()
|
const cmd = requestSchedulerJobList()
|
||||||
handleCommand(cmd)
|
handleCommand(cmd)
|
||||||
@ -572,6 +580,10 @@ function App() {
|
|||||||
memories={memories}
|
memories={memories}
|
||||||
onRefresh={handleRefreshMemories}
|
onRefresh={handleRefreshMemories}
|
||||||
onClose={() => toggleMemoryPanel(false)}
|
onClose={() => toggleMemoryPanel(false)}
|
||||||
|
onCreateMemory={createMemory}
|
||||||
|
onUpdateMemory={updateMemory}
|
||||||
|
onDeleteMemory={deleteMemory}
|
||||||
|
sendCommand={sendMemoryCommand}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,182 +1,240 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Brain, Lightbulb, Sparkles, RefreshCw, X, ChevronDown, ChevronRight, User, ListTodo, FileText, Tag } from 'lucide-react'
|
import { Brain, User, Library, History, Cpu, Globe, Star, Package, RefreshCw, X, ChevronDown, ChevronRight, Plus, Pencil, Trash2, Check } from 'lucide-react'
|
||||||
import type { MemorySummary } from '../../types/protocol'
|
import type { MemorySummary, Command } from '../../types/protocol'
|
||||||
|
|
||||||
|
/* ── types ────────────────────────────────────────────── */
|
||||||
|
|
||||||
interface MemoryPanelProps {
|
interface MemoryPanelProps {
|
||||||
memories: MemorySummary[]
|
memories: MemorySummary[]
|
||||||
onRefresh: () => void
|
onRefresh: () => void
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
|
onCreateMemory: (ns: string, key: string, content: string) => Command
|
||||||
|
onUpdateMemory: (id: string, content: string) => Command
|
||||||
|
onDeleteMemory: (id: string) => Command
|
||||||
|
sendCommand: (cmd: Command) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NamespaceConfig {
|
interface NamespaceConfig { label: string; icon: typeof Brain; accent: string; accentBorder: string }
|
||||||
label: string
|
|
||||||
icon: typeof Brain
|
const NS: Record<string, NamespaceConfig> = {
|
||||||
accent: string
|
user: { label: '用户记忆', icon: User, accent: 'text-cyan-400', accentBorder: 'border-cyan-400/40' },
|
||||||
accentBorder: string
|
semantic: { label: '语义记忆', icon: Library, accent: 'text-amber-400', accentBorder: 'border-amber-400/40' },
|
||||||
|
episodic: { label: '情景记忆', icon: History, accent: 'text-purple-400', accentBorder: 'border-purple-400/40' },
|
||||||
|
skill: { label: '技能记忆', icon: Cpu, accent: 'text-green-400', accentBorder: 'border-green-400/40' },
|
||||||
|
environment: { label: '环境记忆', icon: Globe, accent: 'text-sky-400', accentBorder: 'border-sky-400/40' },
|
||||||
|
reflection: { label: '反思记忆', icon: Star, accent: 'text-rose-400', accentBorder: 'border-rose-400/40' },
|
||||||
|
other: { label: '其他', icon: Package, accent: 'text-stone-400', accentBorder: 'border-stone-400/40' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const NAMESPACE_CONFIG: Record<string, NamespaceConfig> = {
|
function cfg(ns: string): NamespaceConfig {
|
||||||
user: { label: '用户事实', icon: Brain, accent: 'text-emerald-400', accentBorder: 'border-emerald-400/40' },
|
return NS[ns] ?? { label: ns, icon: Package, accent: 'text-[var(--text-secondary)]', accentBorder: 'border-[var(--border-color)]' }
|
||||||
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 {
|
function fmtKey(k: string) { return k.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) }
|
||||||
return NAMESPACE_CONFIG[ns] ?? {
|
|
||||||
label: ns,
|
const NS_OPTIONS = Object.entries(NS)
|
||||||
icon: Tag,
|
|
||||||
accent: 'text-[var(--text-secondary)]',
|
/* ── Memory card with edit/delete ──────────────────────── */
|
||||||
accentBorder: 'border-[var(--border-color)]',
|
|
||||||
|
function MemoryCard({ memory, config, onUpdate, onDelete }:
|
||||||
|
{ memory: MemorySummary; config: NamespaceConfig; onUpdate: (id: string, content: string) => void; onDelete: (id: string) => void }) {
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [editContent, setEditContent] = useState(memory.content)
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (editContent.trim() && editContent !== memory.content) {
|
||||||
|
onUpdate(memory.id, editContent.trim())
|
||||||
|
}
|
||||||
|
setEditing(false)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function fmtKey(key: string): string {
|
const handleDelete = () => {
|
||||||
return key
|
if (confirmDelete) {
|
||||||
.replace(/_/g, ' ')
|
onDelete(memory.id)
|
||||||
.replace(/\b\w/g, c => c.toUpperCase())
|
} else {
|
||||||
}
|
setConfirmDelete(true)
|
||||||
|
setTimeout(() => setConfirmDelete(false), 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function MemoryCard({ memory, config }: { memory: MemorySummary; config: NamespaceConfig }) {
|
|
||||||
return (
|
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={`group rounded-lg bg-[var(--overlay-hover)] border-l-2 ${config.accentBorder} border border-[var(--border-color)] overflow-hidden transition-all duration-200 hover:border-[var(--border-accent)]`}>
|
||||||
<div className="px-3 py-2.5">
|
<div className="px-3 py-2.5">
|
||||||
<span className={`block text-[10px] font-mono uppercase tracking-wider ${config.accent} opacity-60 mb-0.5`}>
|
{/* header row */}
|
||||||
|
<div className="flex items-center justify-between mb-0.5">
|
||||||
|
<span className={`text-[10px] font-mono uppercase tracking-wider ${config.accent} opacity-60`}>
|
||||||
{fmtKey(memory.memory_key)}
|
{fmtKey(memory.memory_key)}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-sm text-[var(--text-secondary)] leading-relaxed">
|
{/* action buttons — visible on hover */}
|
||||||
{memory.content}
|
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
</p>
|
<button onClick={() => { setEditing(!editing); setEditContent(memory.content) }}
|
||||||
|
className={`p-1 rounded hover:bg-[var(--overlay-subtle)] ${editing ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'} hover:text-[var(--accent-cyan)] transition-colors`} title="编辑">
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
<button onClick={handleDelete}
|
||||||
|
className={`p-1 rounded hover:bg-red-500/10 ${confirmDelete ? 'text-red-400' : 'text-[var(--text-muted)]'} hover:text-red-400 transition-colors`} title={confirmDelete ? '再次点击确认删除' : '删除'}>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* content */}
|
||||||
|
{editing ? (
|
||||||
|
<div className="flex gap-1.5 mt-1">
|
||||||
|
<textarea value={editContent} onChange={e => setEditContent(e.target.value)}
|
||||||
|
className="flex-1 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg px-2 py-1.5 text-[var(--text-primary)] resize-none focus:outline-none focus:border-[var(--accent-cyan)] min-h-[120px]"
|
||||||
|
autoFocus onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSave() } }} />
|
||||||
|
<button onClick={handleSave}
|
||||||
|
className="shrink-0 p-1.5 rounded-lg bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors" title="保存">
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-[var(--text-secondary)] leading-relaxed">{memory.content}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionHeader({ config, count, isCollapsed, onClick }: { config: NamespaceConfig; count: number; isCollapsed: boolean; onClick: () => void }) {
|
/* ── Add memory form ───────────────────────────────────── */
|
||||||
|
|
||||||
|
function AddMemoryForm({ onAdd, onCancel }: { onAdd: (ns: string, key: string, content: string) => void; onCancel: () => void }) {
|
||||||
|
const [ns, setNs] = useState('user')
|
||||||
|
const [key, setKey] = useState('')
|
||||||
|
const [content, setContent] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!key.trim() || !content.trim()) return
|
||||||
|
onAdd(ns, key.trim(), content.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-[var(--border-accent)] bg-[var(--bg-tertiary)]/80 p-3 space-y-2.5 animate-fade-in">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select value={ns} onChange={e => setNs(e.target.value)}
|
||||||
|
className="text-xs bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg px-2 py-1.5 text-[var(--text-primary)]">
|
||||||
|
{NS_OPTIONS.map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<input value={key} onChange={e => setKey(e.target.value)} placeholder="键名 (如 work_preference)"
|
||||||
|
className="flex-1 text-xs bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg px-2 py-1.5 text-[var(--text-primary)] placeholder:text-[var(--text-muted)]" />
|
||||||
|
</div>
|
||||||
|
<textarea value={content} onChange={e => setContent(e.target.value)} placeholder="内容..."
|
||||||
|
className="w-full text-sm bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg px-2.5 py-2 text-[var(--text-primary)] placeholder:text-[var(--text-muted)] resize-none min-h-[120px]"
|
||||||
|
autoFocus onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit() } }} />
|
||||||
|
<div className="flex justify-end gap-1.5">
|
||||||
|
<button onClick={onCancel} className="px-3 py-1 rounded-lg text-xs text-[var(--text-muted)] hover:bg-[var(--overlay-hover)] transition-colors">取消</button>
|
||||||
|
<button onClick={handleSubmit} disabled={!key.trim() || !content.trim()}
|
||||||
|
className="px-3 py-1 rounded-lg text-xs bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors disabled:opacity-40 disabled:cursor-not-allowed">创建</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section header ────────────────────────────────────── */
|
||||||
|
|
||||||
|
function SectionHeader({ config, count, isCollapsed, onClick }:
|
||||||
|
{ config: NamespaceConfig; count: number; isCollapsed: boolean; onClick: () => void }) {
|
||||||
const Icon = config.icon
|
const Icon = config.icon
|
||||||
return (
|
return (
|
||||||
<button
|
<button onClick={onClick} className="group flex items-center gap-2 w-full py-1.5 rounded-lg transition-colors hover:bg-[var(--overlay-hover)]">
|
||||||
onClick={onClick}
|
{isCollapsed ? <ChevronRight className="h-3 w-3 text-[var(--text-muted)]" /> : <ChevronDown className="h-3 w-3 text-[var(--text-muted)]" />}
|
||||||
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}`}>
|
<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" />
|
<Icon className="h-3 w-3" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-semibold text-[var(--text-primary)] tracking-tight">
|
<span className="text-xs font-semibold text-[var(--text-primary)] tracking-tight">{config.label}</span>
|
||||||
{config.label}
|
<span className="text-[10px] text-[var(--text-muted)] font-mono tabular-nums ml-auto">{count}</span>
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-[var(--text-muted)] font-mono tabular-nums ml-auto">
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MemoryPanel({ memories, onRefresh, onClose }: MemoryPanelProps) {
|
/* ── main component ────────────────────────────────────── */
|
||||||
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) => {
|
export function MemoryPanel({ memories, onRefresh, onClose, onCreateMemory, onUpdateMemory, onDeleteMemory, sendCommand }: MemoryPanelProps) {
|
||||||
|
const [collapsed, setCollapsed] = useState<Set<string>>(() => {
|
||||||
|
try { const s = localStorage.getItem('picobot-memory-collapsed'); return s ? new Set(JSON.parse(s)) : new Set() }
|
||||||
|
catch (_) { return new Set() }
|
||||||
|
})
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false)
|
||||||
|
|
||||||
|
const toggle = (ns: string) => {
|
||||||
setCollapsed(prev => {
|
setCollapsed(prev => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
if (next.has(ns)) next.delete(ns)
|
if (next.has(ns)) { next.delete(ns) } else { next.add(ns) }
|
||||||
else next.add(ns)
|
|
||||||
localStorage.setItem('picobot-memory-collapsed', JSON.stringify([...next]))
|
localStorage.setItem('picobot-memory-collapsed', JSON.stringify([...next]))
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const grouped = new Map<string, MemorySummary[]>()
|
const grouped = new Map<string, MemorySummary[]>()
|
||||||
for (const m of memories) {
|
for (const m of memories) { const l = grouped.get(m.namespace) || []; l.push(m); grouped.set(m.namespace, l) }
|
||||||
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', 'semantic', 'episodic', 'skill', 'environment', 'reflection', 'other']
|
||||||
const order = ['user', 'preferences', 'patterns', 'profile', 'tasks', 'notes', 'tags']
|
const sorted = Array.from(grouped.keys()).sort((a, b) => {
|
||||||
const ai = order.indexOf(a)
|
const ai = order.indexOf(a); const bi = order.indexOf(b)
|
||||||
const bi = order.indexOf(b)
|
|
||||||
if (ai !== -1 && bi !== -1) return ai - bi
|
if (ai !== -1 && bi !== -1) return ai - bi
|
||||||
if (ai !== -1) return -1
|
if (ai !== -1) return -1; if (bi !== -1) return 1
|
||||||
if (bi !== -1) return 1
|
|
||||||
return a.localeCompare(b)
|
return a.localeCompare(b)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const handleCreate = (ns: string, key: string, content: string) => {
|
||||||
|
sendCommand(onCreateMemory(ns, key, content))
|
||||||
|
setShowAddForm(false)
|
||||||
|
}
|
||||||
|
const handleUpdate = (id: string, content: string) => { sendCommand(onUpdateMemory(id, content)) }
|
||||||
|
const handleDelete = (id: string) => { sendCommand(onDeleteMemory(id)) }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* ---- 标题栏 ---- */}
|
{/* title bar */}
|
||||||
<div className="shrink-0 border-b border-[var(--border-color)] px-4 py-3 flex items-center gap-2.5">
|
<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">
|
<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)]" />
|
<Brain className="h-3.5 w-3.5 text-[var(--accent-cyan)]" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-bold text-[var(--text-primary)] tracking-tight">记忆</span>
|
<span className="text-sm font-bold text-[var(--text-primary)] tracking-tight">记忆</span>
|
||||||
{memories.length > 0 && (
|
{memories.length > 0 && <span className="text-[11px] font-mono text-[var(--text-muted)] tabular-nums ml-0.5">{memories.length}</span>}
|
||||||
<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">
|
<div className="ml-auto flex items-center gap-0.5">
|
||||||
|
<button onClick={() => setShowAddForm(!showAddForm)} className={`p-1.5 rounded-lg transition-colors ${showAddForm ? 'bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)]' : 'text-[var(--text-muted)] hover:bg-[var(--overlay-hover)] hover:text-[var(--accent-cyan)]'}`} title="新增记忆">
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
<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="刷新">
|
<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" />
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
{onClose && (
|
{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>}
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ---- 空状态 ---- */}
|
{/* add form */}
|
||||||
{memories.length === 0 && (
|
{showAddForm && <div className="px-3 pt-2"><AddMemoryForm onAdd={handleCreate} onCancel={() => setShowAddForm(false)} /></div>}
|
||||||
|
|
||||||
|
{/* empty */}
|
||||||
|
{memories.length === 0 && !showAddForm && (
|
||||||
<div className="flex flex-1 items-center justify-center p-8">
|
<div className="flex flex-1 items-center justify-center p-8">
|
||||||
<div className="text-center select-none">
|
<div className="text-center select-none">
|
||||||
<div className="relative inline-block mb-5">
|
<div className="relative inline-block mb-5">
|
||||||
<div className="absolute inset-0 rounded-full bg-[var(--accent-cyan)]/10 blur-xl animate-pulse" />
|
<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" />
|
<Brain className="relative h-12 w-12 text-[var(--accent-cyan)]/25" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-[var(--text-muted)] leading-relaxed">
|
<p className="text-sm text-[var(--text-muted)] leading-relaxed">PicoBot 会在对话中<br />自动学习并记录关于你的信息</p>
|
||||||
PicoBot 会在对话中<br />自动学习并记录关于你的信息
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ---- 记忆列表 ---- */}
|
{/* list */}
|
||||||
{memories.length > 0 && (
|
{memories.length > 0 && (
|
||||||
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-3">
|
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-3">
|
||||||
{sortedNamespaces.map((namespace) => {
|
{sorted.map(ns => {
|
||||||
const config = getNsConfig(namespace)
|
const c = cfg(ns)
|
||||||
const items = grouped.get(namespace)!
|
const items = grouped.get(ns)!
|
||||||
const isCollapsed = collapsed.has(namespace)
|
const closed = collapsed.has(ns)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={namespace}>
|
<div key={ns}>
|
||||||
<SectionHeader
|
<SectionHeader config={c} count={items.length} isCollapsed={closed} onClick={() => toggle(ns)} />
|
||||||
config={config}
|
{!closed && (
|
||||||
count={items.length}
|
|
||||||
isCollapsed={isCollapsed}
|
|
||||||
onClick={() => toggleCollapse(namespace)}
|
|
||||||
/>
|
|
||||||
{!isCollapsed && (
|
|
||||||
<div className="mt-1.5 space-y-1.5">
|
<div className="mt-1.5 space-y-1.5">
|
||||||
{items.map(m => (
|
{items.map(m => <MemoryCard key={m.id} memory={m} config={c} onUpdate={handleUpdate} onDelete={handleDelete} />)}
|
||||||
<MemoryCard key={m.id} memory={m} config={config} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -82,6 +82,9 @@ interface UseChatReturn {
|
|||||||
// 记忆状态
|
// 记忆状态
|
||||||
memories: MemorySummary[]
|
memories: MemorySummary[]
|
||||||
requestMemoryList: () => Command
|
requestMemoryList: () => Command
|
||||||
|
createMemory: (namespace: string, key: string, content: string) => Command
|
||||||
|
updateMemory: (id: string, content: string) => Command
|
||||||
|
deleteMemory: (id: string) => Command
|
||||||
|
|
||||||
// 定时任务状态
|
// 定时任务状态
|
||||||
schedulerJobs: SchedulerJobSummary[]
|
schedulerJobs: SchedulerJobSummary[]
|
||||||
@ -682,6 +685,18 @@ export function useChat(): UseChatReturn {
|
|||||||
return { type: 'list_memories' }
|
return { type: 'list_memories' }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const createMemory = useCallback((namespace: string, key: string, content: string): Command => {
|
||||||
|
return { type: 'create_memory', namespace, key, content }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateMemory = useCallback((id: string, content: string): Command => {
|
||||||
|
return { type: 'update_memory', id, content }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const deleteMemory = useCallback((id: string): Command => {
|
||||||
|
return { type: 'delete_memory', id }
|
||||||
|
}, [])
|
||||||
|
|
||||||
// 定时任务方法
|
// 定时任务方法
|
||||||
const requestSchedulerJobList = useCallback((): Command => {
|
const requestSchedulerJobList = useCallback((): Command => {
|
||||||
return { type: 'list_scheduler_jobs' }
|
return { type: 'list_scheduler_jobs' }
|
||||||
@ -765,6 +780,9 @@ export function useChat(): UseChatReturn {
|
|||||||
exitSubAgentView,
|
exitSubAgentView,
|
||||||
memories,
|
memories,
|
||||||
requestMemoryList,
|
requestMemoryList,
|
||||||
|
createMemory,
|
||||||
|
updateMemory,
|
||||||
|
deleteMemory,
|
||||||
schedulerJobs,
|
schedulerJobs,
|
||||||
sidebarTab,
|
sidebarTab,
|
||||||
setSidebarTab,
|
setSidebarTab,
|
||||||
|
|||||||
@ -335,6 +335,24 @@ export interface ListMemoriesCommand {
|
|||||||
type: 'list_memories'
|
type: 'list_memories'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateMemoryCommand {
|
||||||
|
type: 'create_memory'
|
||||||
|
namespace: string
|
||||||
|
key: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateMemoryCommand {
|
||||||
|
type: 'update_memory'
|
||||||
|
id: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeleteMemoryCommand {
|
||||||
|
type: 'delete_memory'
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
export type Command =
|
export type Command =
|
||||||
| CreateSessionCommand
|
| CreateSessionCommand
|
||||||
| ListSessionsCommand
|
| ListSessionsCommand
|
||||||
@ -353,6 +371,9 @@ export type Command =
|
|||||||
| DeleteTopicCommand
|
| DeleteTopicCommand
|
||||||
| StopExecutionCommand
|
| StopExecutionCommand
|
||||||
| ListMemoriesCommand
|
| ListMemoriesCommand
|
||||||
|
| CreateMemoryCommand
|
||||||
|
| UpdateMemoryCommand
|
||||||
|
| DeleteMemoryCommand
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// UI Types
|
// UI Types
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user