feat(web): 添加话题重命名功能
新增 RenameTopic 命令与 TopicRenamed 协议消息,复用存储层已有的 update_topic_title 方法。后端响应携带刷新后的完整 topic 列表, 前端零额外往返即可同步侧边栏。 前端 TopicList 侧边栏增加内联编辑入口:悬停显示铅笔图标,点击进入 编辑模式,Enter 提交 / Esc 取消 / blur 取消;通过 onMouseDown preventDefault 防止按钮点击时 input 提前失焦。 包含 5 个单元测试覆盖成功、空标题、不存在、标题未变跳过写入、 can_handle 等场景。
This commit is contained in:
parent
c484a918b5
commit
2a5a0277c0
@ -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::<Vec<crate::protocol::TopicSummary>>(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::<Vec<crate::protocol::TopicSummary>>(topics_json) {
|
||||
Ok(topics) => {
|
||||
|
||||
@ -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;
|
||||
|
||||
268
src/command/handlers/rename_topic.rs
Normal file
268
src/command/handlers/rename_topic.rs
Normal file
@ -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<SessionStore>,
|
||||
}
|
||||
|
||||
impl RenameTopicCommandHandler {
|
||||
pub fn new(store: Arc<SessionStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CommandHandler for RenameTopicCommandHandler {
|
||||
fn can_handle(&self, cmd: &Command) -> bool {
|
||||
matches!(cmd, Command::RenameTopic { .. })
|
||||
}
|
||||
|
||||
fn metadata(&self) -> Option<CommandMetadata> {
|
||||
Some(CommandMetadata {
|
||||
name: "rename",
|
||||
description: "重命名指定话题",
|
||||
usage: "/rename <topic_id> <new_title>",
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
cmd: Command,
|
||||
ctx: CommandContext,
|
||||
) -> Result<CommandResponse, CommandError> {
|
||||
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<CommandResponse, CommandError> {
|
||||
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<TopicSummary> = 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(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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)));
|
||||
|
||||
@ -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)));
|
||||
|
||||
@ -248,6 +248,13 @@ pub enum WsOutbound {
|
||||
topics: Vec<TopicSummary>,
|
||||
session_id: String,
|
||||
},
|
||||
#[serde(rename = "topic_renamed")]
|
||||
TopicRenamed {
|
||||
topic_id: String,
|
||||
title: String,
|
||||
topics: Vec<TopicSummary>,
|
||||
session_id: String,
|
||||
},
|
||||
#[serde(rename = "session_loaded")]
|
||||
SessionLoaded {
|
||||
session_id: String,
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
) : (
|
||||
<SchedulerJobList
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
|
||||
import { Plus, MessageSquare, Layers, Hash, Clock, RefreshCw, Trash2, Check, X, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { Plus, MessageSquare, Layers, Hash, Clock, RefreshCw, Trash2, Check, X, ChevronLeft, ChevronRight, Edit2 } from 'lucide-react'
|
||||
import type { Topic } from '../../types/protocol'
|
||||
|
||||
interface TopicListProps {
|
||||
@ -11,6 +11,7 @@ interface TopicListProps {
|
||||
onRefresh: () => 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<string | null>(null)
|
||||
const [editingTopicId, setEditingTopicId] = useState<string | null>(null)
|
||||
const [editingTitle, setEditingTitle] = useState('')
|
||||
const editInputRef = useRef<HTMLInputElement>(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({
|
||||
<div className="space-y-1">
|
||||
{pagedTopics.map((topic, index) => (
|
||||
<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">
|
||||
{currentPage * pageSize + 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>
|
||||
</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)
|
||||
{editingTopicId === topic.id ? (
|
||||
// 编辑模式:内联输入框 + 提交/取消按钮
|
||||
// 按钮使用 onMouseDown preventDefault 防止 input blur 提前触发
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
commitEdit()
|
||||
}}
|
||||
className="w-full rounded-xl pl-3 pr-1.5 py-2 flex items-center gap-2 bg-[var(--bg-tertiary)] border border-[var(--accent-cyan)]/40"
|
||||
>
|
||||
<input
|
||||
ref={editInputRef}
|
||||
value={editingTitle}
|
||||
onChange={(e) => setEditingTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelEdit()
|
||||
}
|
||||
}}
|
||||
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="删除话题"
|
||||
onBlur={cancelEdit}
|
||||
className="flex-1 min-w-0 bg-transparent text-sm text-[var(--text-primary)] outline-none border-none focus:ring-0 placeholder:text-[var(--text-muted)]"
|
||||
placeholder="话题标题"
|
||||
maxLength={120}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className="flex items-center justify-center h-6 w-6 rounded-md bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/30 transition-colors shrink-0"
|
||||
title="确认 (Enter)"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={cancelEdit}
|
||||
className="flex items-center justify-center h-6 w-6 rounded-md bg-zinc-500/20 text-zinc-400 hover:bg-zinc-500/30 transition-colors shrink-0"
|
||||
title="取消 (Esc)"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<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">
|
||||
{currentPage * pageSize + 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>
|
||||
</button>
|
||||
|
||||
{/* 编辑/删除按钮 — 悬停可见 */}
|
||||
<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>
|
||||
) : (
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
startEdit(topic)
|
||||
}}
|
||||
className="flex items-center justify-center h-6 w-6 rounded-md text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/10 transition-colors"
|
||||
title="重命名话题"
|
||||
>
|
||||
<Edit2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setConfirmDeleteId(topic.id)
|
||||
}}
|
||||
className="flex items-center justify-center h-6 w-6 rounded-md text-[var(--text-muted)] hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
title="删除话题"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -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<boolean>
|
||||
/** 处理 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<Topic[]>([])
|
||||
const [selectedTopic, setSelectedTopic] = useState<string | null>(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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user