feat: 添加删除话题功能,包括命令处理器和前端交互

This commit is contained in:
ooodc 2026-06-07 14:09:14 +08:00
parent bf66c00950
commit b5e2886068
9 changed files with 310 additions and 32 deletions

View File

@ -0,0 +1,181 @@
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::handlers::list_topics::TopicSummary;
use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::gateway::session::SessionManager;
use crate::storage::SessionStore;
use async_trait::async_trait;
use std::sync::Arc;
/// 删除话题命令处理器
pub struct DeleteTopicCommandHandler {
store: Arc<SessionStore>,
session_manager: Option<SessionManager>,
}
impl DeleteTopicCommandHandler {
pub fn new(store: Arc<SessionStore>) -> Self {
Self {
store,
session_manager: None,
}
}
pub fn with_session_manager(mut self, session_manager: SessionManager) -> Self {
self.session_manager = Some(session_manager);
self
}
}
#[async_trait]
impl CommandHandler for DeleteTopicCommandHandler {
fn can_handle(&self, cmd: &Command) -> bool {
matches!(cmd, Command::DeleteTopic { .. })
}
fn metadata(&self) -> Option<CommandMetadata> {
Some(CommandMetadata {
name: "delete",
description: "删除指定话题",
usage: "/delete <topic_id>",
})
}
async fn handle(
&self,
cmd: Command,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
match cmd {
Command::DeleteTopic { topic_id } => handle_delete_topic(self, topic_id, ctx).await,
_ => unreachable!(),
}
}
}
async fn handle_delete_topic(
handler: &DeleteTopicCommandHandler,
topic_id: String,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
let session_id = ctx
.session_id
.as_deref()
.ok_or_else(|| CommandError::new("NO_SESSION", "No active session"))?;
// 验证话题存在
let topic = handler
.store
.get_topic(&topic_id)
.map_err(|e| CommandError::new("GET_TOPIC_ERROR", e.to_string()))?
.ok_or_else(|| {
CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id))
})?;
let topic_title = topic.title.clone();
// 删除话题(存储层方法已存在)
handler
.store
.delete_topic(&topic_id)
.map_err(|e| CommandError::new("DELETE_TOPIC_ERROR", e.to_string()))?;
// 查询更新后的话题列表,返回给前端刷新侧边栏
let topics = handler
.store
.list_topics(session_id)
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?;
let topic_summaries: Vec<TopicSummary> = topics
.into_iter()
.map(|t| TopicSummary {
topic_id: t.id,
session_id: t.session_id,
title: t.title,
description: t.description.filter(|d| !d.is_empty()),
message_count: t.message_count,
created_at: t.created_at,
last_active_at: t.last_active_at,
})
.collect();
let topics_json =
serde_json::to_string(&topic_summaries)
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
let message = format!("✓ 已删除话题: {}", topic_title);
Ok(CommandResponse::success(ctx.request_id)
.with_message(MessageKind::Notification, &message)
.with_metadata("topics", &topics_json)
.with_metadata("deleted_topic_id", &topic_id))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::SessionStore;
use std::sync::Arc;
fn create_test_handler() -> DeleteTopicCommandHandler {
let store = Arc::new(SessionStore::in_memory().unwrap());
DeleteTopicCommandHandler::new(store)
}
#[tokio::test]
async fn test_delete_topic_success() {
let handler = create_test_handler();
let store = handler.store.clone();
// 先创建 session 和 topic
let session = store.create_session("test_channel", Some("test")).unwrap();
let topic = store
.create_topic(&session.id, "test topic", None)
.unwrap();
let ctx = CommandContext::new("test", "test_channel")
.with_session_id(&session.id)
.with_chat_id(&session.id);
let cmd = Command::DeleteTopic {
topic_id: topic.id.clone(),
};
let result = handler.handle(cmd, ctx).await;
assert!(result.is_ok());
let resp = result.unwrap();
assert!(resp.success);
assert!(resp.metadata.contains_key("deleted_topic_id"));
// 验证话题已被删除
let deleted = store.get_topic(&topic.id).unwrap();
assert!(deleted.is_none());
}
#[tokio::test]
async fn test_delete_nonexistent_topic() {
let handler = create_test_handler();
let store = handler.store.clone();
let session = store.create_session("test_channel", Some("test")).unwrap();
let ctx = CommandContext::new("test", "test_channel")
.with_session_id(&session.id)
.with_chat_id(&session.id);
let cmd = Command::DeleteTopic {
topic_id: "nonexistent".to_string(),
};
let result = handler.handle(cmd, ctx).await;
assert!(result.is_err());
}
#[test]
fn test_can_handle() {
let handler = create_test_handler();
assert!(handler.can_handle(&Command::DeleteTopic {
topic_id: "test".to_string()
}));
assert!(!handler.can_handle(&Command::Help));
}
}

View File

@ -1,3 +1,4 @@
pub mod delete_topic;
pub mod get_current;
pub mod help;
pub mod list_channels;

View File

@ -52,6 +52,8 @@ pub enum Command {
channel: String,
chat_id: String,
},
/// 删除指定话题
DeleteTopic { topic_id: String },
/// 停止当前正在执行的 Agent
StopExecution,
}
@ -74,6 +76,7 @@ impl Command {
Command::LoadTaskMessages { .. } => "load_task_messages",
Command::ListSchedulerJobs => "list_scheduler_jobs",
Command::LoadChatMessages { .. } => "load_chat_messages",
Command::DeleteTopic { .. } => "delete_topic",
Command::StopExecution => "stop_execution",
}
}

View File

@ -7,6 +7,7 @@ use crate::bus::{InboundMessage, MessageBus, OutboundMessage};
use crate::command::adapter::InputAdapter;
use crate::command::adapters::channel::ChannelInputAdapter;
use crate::command::handler::CommandRouter;
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_sessions::ListSessionsCommandHandler;
@ -97,6 +98,12 @@ impl InboundProcessor {
system_prompt_provider,
).with_session_manager(session_manager.clone())));
// 注册 delete_topic 处理器
command_router.register(Box::new(
DeleteTopicCommandHandler::new(store.clone())
.with_session_manager(session_manager.clone()),
));
// 注册 help 处理器(最后注册,获取所有已注册命令的元数据)
let metadata = command_router.metadata_arc();
command_router.register(Box::new(HelpCommandHandler::new(metadata)));

View File

@ -5,6 +5,7 @@ use crate::command::adapter::{InputAdapter, OutputAdapter};
use crate::command::adapters::websocket::{WebSocketInputAdapter, WebSocketOutputAdapter};
use crate::command::context::CommandContext;
use crate::command::handler::CommandRouter;
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;
@ -403,6 +404,11 @@ async fn handle_inbound(
state.task_repository.clone(),
system_prompt_provider.clone(),
)));
// 注册 delete_topic 处理器
router.register(Box::new(
DeleteTopicCommandHandler::new(store.clone())
.with_session_manager(state.session_manager.clone()),
));
// 注册 help 处理器
let metadata = router.metadata_arc();
router.register(Box::new(HelpCommandHandler::new(metadata)));

View File

@ -52,10 +52,12 @@ function App() {
// 方法
handleMessage,
handleCommand,
clearMessages,
handleServerMessage,
selectTopic,
createTopic,
switchTopic,
deleteTopic,
requestSessionList,
requestTopicList,
enterSubAgentView,
@ -229,6 +231,20 @@ function App() {
[sendMessage, handleCommand, switchTopic, selectTopic]
)
const handleDeleteTopic = useCallback(
(topicId: string) => {
const cmd = deleteTopic(topicId)
handleCommand(cmd)
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
// 如果删除的是当前选中话题,清空选中状态和消息
if (topicId === selectedTopic) {
selectTopic('')
clearMessages()
}
},
[sendMessage, handleCommand, deleteTopic, selectedTopic, selectTopic, clearMessages]
)
const handleNavigateToSubAgent = useCallback(
(taskId: string, description: string) => {
const cmd = enterSubAgentView(taskId, description)
@ -407,6 +423,7 @@ function App() {
onCreateTopic={handleCreateTopic}
onRefresh={handleRefreshTopics}
onSwitchTopic={handleSwitchTopic}
onDeleteTopic={handleDeleteTopic}
/>
) : (
<SchedulerJobList

View File

@ -1,4 +1,5 @@
import { Plus, MessageSquare, Layers, Hash, Clock, RefreshCw } from 'lucide-react'
import { useState } from 'react'
import { Plus, MessageSquare, Layers, Hash, Clock, RefreshCw, Trash2, Check, X } from 'lucide-react'
import type { Topic } from '../../types/protocol'
interface TopicListProps {
@ -9,6 +10,7 @@ interface TopicListProps {
onCreateTopic: () => void
onRefresh: () => void
onSwitchTopic: (topicId: string) => void
onDeleteTopic: (topicId: string) => void
}
function formatTime(timestamp: number): string {
@ -35,7 +37,10 @@ export function TopicList({
onCreateTopic,
onRefresh,
onSwitchTopic,
onDeleteTopic,
}: TopicListProps) {
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
return (
<div className="flex h-full flex-col">
{/* Header */}
@ -93,41 +98,83 @@ export function TopicList({
) : (
<div className="space-y-1">
{topics.map((topic, index) => (
<button
key={topic.id}
onClick={() => onSwitchTopic(topic.id)}
className={`w-full rounded-xl px-3 py-3 text-left text-sm transition-all ${
topic.id === currentTopicId
? 'bg-gradient-to-r from-[var(--accent-cyan)]/20 to-transparent border border-[var(--accent-cyan)]/30'
: 'hover:bg-[var(--overlay-hover)] border border-transparent'
}`}
>
<div className="flex items-start gap-3">
<span className="mt-0.5 text-xs text-[var(--text-muted)] font-mono w-4">
{index + 1}
</span>
<div className="min-w-0 flex-1">
<div className={`truncate font-medium ${
topic.id === currentTopicId ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-secondary)]'
}`}>
{topic.description || topic.title}
</div>
<div className="flex items-center gap-3 mt-1.5">
<span className="text-xs text-[var(--text-muted)] flex items-center gap-1">
<Hash className="h-3 w-3" />
{topic.message_count}
</span>
<span className="text-xs text-[var(--text-muted)] flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatTime(topic.updated_at)}
</span>
<div key={topic.id} className="group relative">
<button
onClick={() => onSwitchTopic(topic.id)}
className={`w-full rounded-xl pl-3 pr-8 py-3 text-left text-sm transition-all ${
topic.id === currentTopicId
? 'bg-gradient-to-r from-[var(--accent-cyan)]/20 to-transparent border border-[var(--accent-cyan)]/30'
: 'hover:bg-[var(--overlay-hover)] border border-transparent'
}`}
>
<div className="flex items-start gap-3">
<span className="mt-0.5 text-xs text-[var(--text-muted)] font-mono w-4">
{index + 1}
</span>
<div className="min-w-0 flex-1">
<div className={`truncate font-medium ${
topic.id === currentTopicId ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-secondary)]'
}`}>
{topic.description || topic.title}
</div>
<div className="flex items-center gap-3 mt-1.5">
<span className="text-xs text-[var(--text-muted)] flex items-center gap-1">
<Hash className="h-3 w-3" />
{topic.message_count}
</span>
<span className="text-xs text-[var(--text-muted)] flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatTime(topic.updated_at)}
</span>
</div>
</div>
{topic.id === currentTopicId && (
<span className="inline-block h-2 w-2 rounded-full bg-[var(--accent-cyan)] shadow-lg shadow-[var(--shadow-glow-soft)] mt-1.5" />
)}
</div>
{topic.id === currentTopicId && (
<span className="inline-block h-2 w-2 rounded-full bg-[var(--accent-cyan)] shadow-lg shadow-[var(--shadow-glow-soft)] mt-1.5" />
</button>
{/* Delete button — visible on group hover */}
<div className="absolute top-2.5 right-2.5">
{confirmDeleteId === topic.id ? (
<span className="flex items-center gap-1.5 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-color)] px-2 py-1 shadow-lg animate-scale-in">
<span className="text-xs text-red-400 whitespace-nowrap">?</span>
<button
onClick={(e) => {
e.stopPropagation()
onDeleteTopic(topic.id)
setConfirmDeleteId(null)
}}
className="flex items-center justify-center h-5 w-5 rounded bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/30 transition-colors"
title="确认"
>
<Check className="h-3 w-3" />
</button>
<button
onClick={(e) => {
e.stopPropagation()
setConfirmDeleteId(null)
}}
className="flex items-center justify-center h-5 w-5 rounded bg-zinc-500/20 text-zinc-400 hover:bg-zinc-500/30 transition-colors"
title="取消"
>
<X className="h-3 w-3" />
</button>
</span>
) : (
<button
onClick={(e) => {
e.stopPropagation()
setConfirmDeleteId(topic.id)
}}
className="opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center h-6 w-6 rounded-md text-[var(--text-muted)] hover:text-red-400 hover:bg-red-500/10"
title="删除话题"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</button>
</div>
))}
</div>
)}

View File

@ -63,6 +63,7 @@ interface UseChatReturn {
selectTopic: (topicId: string) => void
createTopic: (title?: string) => Command
switchTopic: (topicId: string) => Command
deleteTopic: (topicId: string) => Command
// 初始化方法
requestSessionList: () => Command
@ -528,6 +529,7 @@ export function useChat(): UseChatReturn {
case 'load_topic':
case 'list_sessions':
case 'list_sessions_by_channel':
case 'delete_topic':
case 'list_topics':
setIsLoading(true)
break
@ -558,6 +560,13 @@ export function useChat(): UseChatReturn {
}
}, [])
const deleteTopic = useCallback((topicId: string): Command => {
return {
type: 'delete_topic',
topic_id: topicId,
}
}, [])
// 初始化方法
const requestSessionList = useCallback((): Command => {
return {
@ -702,6 +711,7 @@ export function useChat(): UseChatReturn {
selectTopic,
createTopic,
switchTopic,
deleteTopic,
requestSessionList,
requestTopicList,
requestChannelList,

View File

@ -303,6 +303,11 @@ export interface LoadChatMessagesCommand {
chat_id: string
}
export interface DeleteTopicCommand {
type: 'delete_topic'
topic_id: string
}
export interface StopExecutionCommand {
type: 'stop_execution'
}
@ -322,6 +327,7 @@ export type Command =
| LoadTaskMessagesCommand
| ListSchedulerJobsCommand
| LoadChatMessagesCommand
| DeleteTopicCommand
| StopExecutionCommand
// ============================================================================