diff --git a/src/command/adapters/websocket.rs b/src/command/adapters/websocket.rs index 066d6e9..739d4a6 100644 --- a/src/command/adapters/websocket.rs +++ b/src/command/adapters/websocket.rs @@ -84,7 +84,33 @@ impl OutputAdapter for WebSocketOutputAdapter { }, MessageKind::Notification => { // 根据元数据判断具体类型 - if let Some(topics_json) = response.metadata.get("topics") { + // 优先识别话题重命名(同时含 topics + topic_id + title) + if let (Some(topics_json), Some(topic_id), Some(title)) = ( + response.metadata.get("topics"), + response.metadata.get("topic_id"), + response.metadata.get("title"), + ) { + match serde_json::from_str::>(topics_json) { + Ok(topics) => { + let session_id = response.metadata.get("session_id") + .cloned() + .unwrap_or_default(); + WsOutbound::TopicRenamed { + topics, + session_id, + topic_id: topic_id.clone(), + title: title.clone(), + } + } + Err(_) => WsOutbound::AssistantResponse { + id: response.request_id.to_string(), + content: msg.content.clone(), + role: "assistant".to_string(), + attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()), + reasoning_content: None, user_message_id: None, + }, + } + } else if let Some(topics_json) = response.metadata.get("topics") { // Topic 列表响应 - 优先检查 topics match serde_json::from_str::>(topics_json) { Ok(topics) => { diff --git a/src/command/handlers/mod.rs b/src/command/handlers/mod.rs index 1e8e8cf..bd702f2 100644 --- a/src/command/handlers/mod.rs +++ b/src/command/handlers/mod.rs @@ -13,6 +13,7 @@ pub mod list_topics; pub mod load_chat_messages; pub mod load_task_messages; pub mod load_topic; +pub mod rename_topic; pub mod save_session; pub mod save_topic; pub mod session; diff --git a/src/command/handlers/rename_topic.rs b/src/command/handlers/rename_topic.rs new file mode 100644 index 0000000..299b124 --- /dev/null +++ b/src/command/handlers/rename_topic.rs @@ -0,0 +1,268 @@ +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::storage::SessionStore; +use async_trait::async_trait; +use std::sync::Arc; + +/// 重命名话题命令处理器 +pub struct RenameTopicCommandHandler { + store: Arc, +} + +impl RenameTopicCommandHandler { + pub fn new(store: Arc) -> Self { + Self { store } + } +} + +#[async_trait] +impl CommandHandler for RenameTopicCommandHandler { + fn can_handle(&self, cmd: &Command) -> bool { + matches!(cmd, Command::RenameTopic { .. }) + } + + fn metadata(&self) -> Option { + Some(CommandMetadata { + name: "rename", + description: "重命名指定话题", + usage: "/rename ", + }) + } + + async fn handle( + &self, + cmd: Command, + ctx: CommandContext, + ) -> Result { + match cmd { + Command::RenameTopic { topic_id, title } => { + handle_rename_topic(self, topic_id, title, ctx).await + } + _ => unreachable!(), + } + } +} + +async fn handle_rename_topic( + handler: &RenameTopicCommandHandler, + topic_id: String, + title: String, + ctx: CommandContext, +) -> Result { + let session_id = ctx + .session_id + .as_deref() + .ok_or_else(|| CommandError::new("NO_SESSION", "No active session"))?; + + // 校验新标题非空 + let trimmed_title = title.trim(); + if trimmed_title.is_empty() { + return Err(CommandError::new( + "INVALID_TITLE", + "Topic title must not be empty", + )); + } + + // 验证话题存在 + 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 old_title = topic.title.clone(); + + // 标题未变化时直接返回当前列表,避免无意义写入 + if old_title == trimmed_title { + let topics = handler + .store + .list_topics(session_id) + .map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?; + let topic_summaries = serialize_summaries(&topics); + + return Ok(CommandResponse::success(ctx.request_id) + .with_message(MessageKind::Notification, &format!("✓ 话题标题未变化: {}", trimmed_title)) + .with_metadata("topics", &topic_summaries) + .with_metadata("topic_id", &topic_id) + .with_metadata("title", trimmed_title) + .with_metadata("session_id", session_id)); + } + + // 执行重命名(存储层方法已存在) + handler + .store + .update_topic_title(&topic_id, trimmed_title) + .map_err(|e| CommandError::new("RENAME_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 = serialize_summaries(&topics); + + let message = format!("✓ 已重命名话题: {} → {}", old_title, trimmed_title); + + Ok(CommandResponse::success(ctx.request_id) + .with_message(MessageKind::Notification, &message) + .with_metadata("topics", &topic_summaries) + .with_metadata("topic_id", &topic_id) + .with_metadata("title", trimmed_title) + .with_metadata("session_id", session_id)) +} + +fn serialize_summaries(topics: &[crate::storage::TopicRecord]) -> String { + let summaries: Vec = topics + .iter() + .map(|t| TopicSummary { + topic_id: t.id.clone(), + session_id: t.session_id.clone(), + title: t.title.clone(), + description: t.description.clone().filter(|d| !d.is_empty()), + message_count: t.message_count, + created_at: t.created_at, + last_active_at: t.last_active_at, + }) + .collect(); + serde_json::to_string(&summaries).unwrap_or_else(|_| "[]".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::SessionStore; + + fn create_test_handler() -> RenameTopicCommandHandler { + let store = Arc::new(SessionStore::in_memory().unwrap()); + RenameTopicCommandHandler::new(store) + } + + #[tokio::test] + async fn test_rename_topic_success() { + let handler = create_test_handler(); + let store = handler.store.clone(); + + let session = store.create_session("test_channel", Some("test")).unwrap(); + let topic = store + .create_topic(&session.id, "old title", None) + .unwrap(); + + let ctx = CommandContext::new("test", "test_channel") + .with_session_id(&session.id) + .with_chat_id(&session.id); + let cmd = Command::RenameTopic { + topic_id: topic.id.clone(), + title: "new title".to_string(), + }; + + let result = handler.handle(cmd, ctx).await; + assert!(result.is_ok()); + + let resp = result.unwrap(); + assert!(resp.success); + assert_eq!(resp.metadata.get("title").map(String::as_str), Some("new title")); + assert_eq!(resp.metadata.get("topic_id").map(String::as_str), Some(topic.id.as_str())); + assert!(resp.metadata.contains_key("topics")); + + // 验证存储层已更新 + let updated = store.get_topic(&topic.id).unwrap().unwrap(); + assert_eq!(updated.title, "new title"); + } + + #[tokio::test] + async fn test_rename_topic_empty_title() { + let handler = create_test_handler(); + let store = handler.store.clone(); + + let session = store.create_session("test_channel", Some("test")).unwrap(); + let topic = store + .create_topic(&session.id, "old title", None) + .unwrap(); + + let ctx = CommandContext::new("test", "test_channel") + .with_session_id(&session.id) + .with_chat_id(&session.id); + let cmd = Command::RenameTopic { + topic_id: topic.id.clone(), + title: " ".to_string(), + }; + + let result = handler.handle(cmd, ctx).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.code, "INVALID_TITLE"); + + // 标题未被修改 + let unchanged = store.get_topic(&topic.id).unwrap().unwrap(); + assert_eq!(unchanged.title, "old title"); + } + + #[tokio::test] + async fn test_rename_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::RenameTopic { + topic_id: "nonexistent".to_string(), + title: "new title".to_string(), + }; + + let result = handler.handle(cmd, ctx).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.code, "TOPIC_NOT_FOUND"); + } + + #[tokio::test] + async fn test_rename_topic_same_title_skips_write() { + let handler = create_test_handler(); + let store = handler.store.clone(); + + let session = store.create_session("test_channel", Some("test")).unwrap(); + let topic = store + .create_topic(&session.id, "same title", None) + .unwrap(); + let original_updated_at = store.get_topic(&topic.id).unwrap().unwrap().updated_at; + + // 等待一秒确保 updated_at 会变化(如果真的写入) + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + let ctx = CommandContext::new("test", "test_channel") + .with_session_id(&session.id) + .with_chat_id(&session.id); + let cmd = Command::RenameTopic { + topic_id: topic.id.clone(), + title: "same title".to_string(), + }; + + let result = handler.handle(cmd, ctx).await; + assert!(result.is_ok()); + + // updated_at 未变化说明未触发写入 + let after = store.get_topic(&topic.id).unwrap().unwrap(); + assert_eq!(after.updated_at, original_updated_at); + } + + #[test] + fn test_can_handle() { + let handler = create_test_handler(); + assert!(handler.can_handle(&Command::RenameTopic { + topic_id: "test".to_string(), + title: "test".to_string(), + })); + assert!(!handler.can_handle(&Command::Help)); + assert!(!handler.can_handle(&Command::DeleteTopic { + topic_id: "test".to_string(), + })); + } +} diff --git a/src/command/mod.rs b/src/command/mod.rs index 90e3544..35d6c7f 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -54,6 +54,8 @@ pub enum Command { }, /// 删除指定话题 DeleteTopic { topic_id: String }, + /// 重命名指定话题 + RenameTopic { topic_id: String, title: String }, /// 停止当前正在执行的 Agent StopExecution, /// 列出所有记忆 @@ -100,6 +102,7 @@ impl Command { Command::ListSchedulerJobs => "list_scheduler_jobs", Command::LoadChatMessages { .. } => "load_chat_messages", Command::DeleteTopic { .. } => "delete_topic", + Command::RenameTopic { .. } => "rename_topic", Command::StopExecution => "stop_execution", Command::ListMemories => "list_memories", Command::CreateMemory { .. } => "create_memory", diff --git a/src/gateway/processor.rs b/src/gateway/processor.rs index 12a21e0..34a096a 100644 --- a/src/gateway/processor.rs +++ b/src/gateway/processor.rs @@ -13,6 +13,7 @@ use crate::command::handlers::get_current::GetCurrentSessionCommandHandler; use crate::command::handlers::help::HelpCommandHandler; use crate::command::handlers::list_sessions::ListSessionsCommandHandler; use crate::command::handlers::load_topic::LoadTopicCommandHandler; +use crate::command::handlers::rename_topic::RenameTopicCommandHandler; use crate::command::handlers::save_session::SaveSessionCommandHandler; use crate::command::handlers::save_topic::SaveTopicCommandHandler; use crate::command::handlers::session::SessionCommandHandler; @@ -104,6 +105,9 @@ impl InboundProcessor { .with_session_manager(session_manager.clone()), )); + // 注册 rename_topic 处理器 + command_router.register(Box::new(RenameTopicCommandHandler::new(store.clone()))); + // 注册 help 处理器(最后注册,获取所有已注册命令的元数据) let metadata = command_router.metadata_arc(); command_router.register(Box::new(HelpCommandHandler::new(metadata))); diff --git a/src/gateway/ws.rs b/src/gateway/ws.rs index f892407..9ae8f7b 100644 --- a/src/gateway/ws.rs +++ b/src/gateway/ws.rs @@ -20,6 +20,7 @@ use crate::command::handlers::list_topics::ListTopicsCommandHandler; use crate::command::handlers::load_chat_messages::LoadChatMessagesCommandHandler; use crate::command::handlers::load_task_messages::LoadTaskMessagesCommandHandler; use crate::command::handlers::load_topic::LoadTopicCommandHandler; +use crate::command::handlers::rename_topic::RenameTopicCommandHandler; use crate::command::handlers::save_session::SaveSessionCommandHandler; use crate::command::handlers::save_topic::SaveTopicCommandHandler; use crate::command::handlers::session::SessionCommandHandler; @@ -453,6 +454,8 @@ async fn handle_inbound( DeleteTopicCommandHandler::new(store.clone()) .with_session_manager(state.session_manager.clone()), )); + // 注册 rename_topic 处理器 + router.register(Box::new(RenameTopicCommandHandler::new(store.clone()))); // 注册 help 处理器 let metadata = router.metadata_arc(); router.register(Box::new(HelpCommandHandler::new(metadata))); diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index a15c496..bb5a653 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -248,6 +248,13 @@ pub enum WsOutbound { topics: Vec, session_id: String, }, + #[serde(rename = "topic_renamed")] + TopicRenamed { + topic_id: String, + title: String, + topics: Vec, + session_id: String, + }, #[serde(rename = "session_loaded")] SessionLoaded { session_id: String, diff --git a/web/src/App.tsx b/web/src/App.tsx index 21e9cad..b916032 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -84,6 +84,7 @@ function App() { createTopic, switchTopic, deleteTopic, + renameTopic, requestSessionList, requestTopicList, topicRefreshTrigger, @@ -348,6 +349,15 @@ function App() { [sendMessage, handleCommand, deleteTopic, selectedTopic, selectTopic, clearMessages] ) + const handleRenameTopic = useCallback( + (topicId: string, title: string) => { + const cmd = renameTopic(topicId, title) + handleCommand(cmd) + sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) + }, + [sendMessage, handleCommand, renameTopic] + ) + const handleNavigateToSubAgent = useCallback( (taskId: string, description: string, subagentType?: string) => { const cmd = enterSubAgentView(taskId, description, subagentType) @@ -690,6 +700,7 @@ function App() { onRefresh={handleRefreshTopics} onSwitchTopic={handleSwitchTopic} onDeleteTopic={handleDeleteTopic} + onRenameTopic={handleRenameTopic} /> ) : ( void onSwitchTopic: (topicId: string) => void onDeleteTopic: (topicId: string) => void + onRenameTopic: (topicId: string, title: string) => void } function formatTime(timestamp: number): string { @@ -38,8 +39,42 @@ export function TopicList({ onRefresh, onSwitchTopic, onDeleteTopic, + onRenameTopic, }: TopicListProps) { const [confirmDeleteId, setConfirmDeleteId] = useState(null) + const [editingTopicId, setEditingTopicId] = useState(null) + const [editingTitle, setEditingTitle] = useState('') + const editInputRef = useRef(null) + + // 进入编辑模式时自动聚焦 input + useEffect(() => { + if (editingTopicId && editInputRef.current) { + editInputRef.current.focus() + editInputRef.current.select() + } + }, [editingTopicId]) + + const startEdit = useCallback((topic: Topic) => { + setConfirmDeleteId(null) + setEditingTopicId(topic.id) + setEditingTitle(topic.title) + }, []) + + const cancelEdit = useCallback(() => { + setEditingTopicId(null) + setEditingTitle('') + }, []) + + const commitEdit = useCallback(() => { + const trimmed = editingTitle.trim() + if (!trimmed || !editingTopicId) { + cancelEdit() + return + } + onRenameTopic(editingTopicId, trimmed) + setEditingTopicId(null) + setEditingTitle('') + }, [editingTitle, editingTopicId, onRenameTopic, cancelEdit]) // Pagination — dynamically sized to fill one screen without scrolling const ESTIMATED_ITEM_HEIGHT = 64 // py-3(24px) + title(20px) + mt-1.5(6px) + meta(14px) @@ -138,81 +173,140 @@ export function TopicList({
{pagedTopics.map((topic, index) => (
- - - {/* Delete button — visible on group hover */} -
- {confirmDeleteId === topic.id ? ( - - 确认删除? - - - - ) : ( - - )} -
+ + + ) : ( + <> + + + {/* 编辑/删除按钮 — 悬停可见 */} +
+ {confirmDeleteId === topic.id ? ( + + 确认删除? + + + + ) : ( +
+ + +
+ )} +
+ + )}
))}
diff --git a/web/src/hooks/chat/useTopics.ts b/web/src/hooks/chat/useTopics.ts index 6cfeb55..4fd76c7 100644 --- a/web/src/hooks/chat/useTopics.ts +++ b/web/src/hooks/chat/useTopics.ts @@ -1,5 +1,5 @@ import { useState, useCallback, useRef, useEffect, type Dispatch, type SetStateAction, type MutableRefObject } from 'react' -import type { Topic, TopicList, TopicSummary, Command } from '../../types/protocol' +import type { Topic, TopicList, TopicRenamed, TopicSummary, Command } from '../../types/protocol' export interface UseTopicsReturn { topics: Topic[] @@ -13,12 +13,28 @@ export interface UseTopicsReturn { pendingNewTopicRef: MutableRefObject /** 处理 topic_list 消息:映射格式并按 pendingNewTopic 自动聚焦,返回是否自动聚焦了新话题 */ handleTopicList: (msg: TopicList) => boolean + /** 处理 topic_renamed 消息:用刷新后的列表替换本地状态(不改 selectedTopic) */ + handleTopicRenamed: (msg: TopicRenamed) => void createTopic: (title?: string) => Command switchTopic: (topicId: string) => Command deleteTopic: (topicId: string) => Command + renameTopic: (topicId: string, title: string) => Command requestTopicList: (sessionId: string | null) => Command | null } +/** 将后端 TopicSummary[] 映射为前端 Topic[] */ +function mapTopicSummaries(summaries: TopicSummary[]): Topic[] { + return summaries.map(t => ({ + id: t.topic_id, + session_id: t.session_id, + title: t.title, + description: t.description || undefined, + message_count: Number(t.message_count), + created_at: t.created_at, + updated_at: t.last_active_at, + })) +} + export function useTopics(): UseTopicsReturn { const [topics, setTopics] = useState([]) const [selectedTopic, setSelectedTopic] = useState(null) @@ -42,15 +58,7 @@ export function useTopics(): UseTopicsReturn { }, []) const handleTopicList = useCallback((msg: TopicList): boolean => { - const newTopics: Topic[] = msg.topics.map((t: TopicSummary) => ({ - id: t.topic_id, - session_id: t.session_id, - title: t.title, - description: t.description || undefined, - message_count: Number(t.message_count), - created_at: t.created_at, - updated_at: t.last_active_at, - })) + const newTopics = mapTopicSummaries(msg.topics) setTopics(newTopics) // 新建话题后自动聚焦到新话题(列表按 last_active_at DESC 排序,第一个即最新) @@ -64,6 +72,11 @@ export function useTopics(): UseTopicsReturn { return false }, []) + const handleTopicRenamed = useCallback((msg: TopicRenamed): void => { + // 后端返回刷新后的完整列表,直接替换;selectedTopic 基于 id 不变,无需调整 + setTopics(mapTopicSummaries(msg.topics)) + }, []) + const createTopic = useCallback((title?: string): Command => { pendingNewTopicRef.current = true return { @@ -80,6 +93,10 @@ export function useTopics(): UseTopicsReturn { return { type: 'delete_topic', topic_id: topicId } }, []) + const renameTopic = useCallback((topicId: string, title: string): Command => { + return { type: 'rename_topic', topic_id: topicId, title } + }, []) + const requestTopicList = useCallback((sessionId: string | null): Command | null => { if (!sessionId) return null return { type: 'list_topics', session_id: sessionId } @@ -96,9 +113,11 @@ export function useTopics(): UseTopicsReturn { selectedTopicRef, pendingNewTopicRef, handleTopicList, + handleTopicRenamed, createTopic, switchTopic, deleteTopic, + renameTopic, requestTopicList, } } diff --git a/web/src/hooks/useChat.ts b/web/src/hooks/useChat.ts index ccea9ba..9fa6643 100644 --- a/web/src/hooks/useChat.ts +++ b/web/src/hooks/useChat.ts @@ -67,6 +67,7 @@ interface UseChatReturn { createTopic: (title?: string) => Command switchTopic: (topicId: string) => Command deleteTopic: (topicId: string) => Command + renameTopic: (topicId: string, title: string) => Command // 初始化方法 requestSessionList: () => Command @@ -181,6 +182,10 @@ export function useChat(): UseChatReturn { return } + case 'topic_renamed': + topics.handleTopicRenamed(message) + return + case 'scheduler_job_list': scheduler.setSchedulerJobs(message.jobs) return @@ -326,6 +331,7 @@ export function useChat(): UseChatReturn { createTopic: topics.createTopic, switchTopic: topics.switchTopic, deleteTopic: topics.deleteTopic, + renameTopic: topics.renameTopic, requestSessionList, requestTopicList, topicRefreshTrigger: topics.topicRefreshTrigger, diff --git a/web/src/types/protocol.ts b/web/src/types/protocol.ts index a21a7a7..a9dd1c3 100644 --- a/web/src/types/protocol.ts +++ b/web/src/types/protocol.ts @@ -165,6 +165,14 @@ export interface TopicList { session_id: string } +export interface TopicRenamed { + type: 'topic_renamed' + topic_id: string + title: string + topics: TopicSummary[] + session_id: string +} + export interface Channel { id: string name: string @@ -303,6 +311,7 @@ export type WsOutbound = | SessionLoaded | SessionSaved | TopicList + | TopicRenamed | ChannelList | TaskMessagesLoaded | SchedulerJobList @@ -392,6 +401,12 @@ export interface DeleteTopicCommand { topic_id: string } +export interface RenameTopicCommand { + type: 'rename_topic' + topic_id: string + title: string +} + export interface StopExecutionCommand { type: 'stop_execution' } @@ -443,6 +458,7 @@ export type Command = | ListSchedulerJobsCommand | LoadChatMessagesCommand | DeleteTopicCommand + | RenameTopicCommand | StopExecutionCommand | ListMemoriesCommand | CreateMemoryCommand