feat(web): 添加话题重命名功能

新增 RenameTopic 命令与 TopicRenamed 协议消息,复用存储层已有的
update_topic_title 方法。后端响应携带刷新后的完整 topic 列表,
前端零额外往返即可同步侧边栏。

前端 TopicList 侧边栏增加内联编辑入口:悬停显示铅笔图标,点击进入
编辑模式,Enter 提交 / Esc 取消 / blur 取消;通过 onMouseDown
preventDefault 防止按钮点击时 input 提前失焦。

包含 5 个单元测试覆盖成功、空标题、不存在、标题未变跳过写入、
can_handle 等场景。
This commit is contained in:
oudecheng 2026-08-03 22:10:25 +08:00
parent c484a918b5
commit 2a5a0277c0
12 changed files with 542 additions and 84 deletions

View File

@ -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) => {

View File

@ -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;

View 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(),
}));
}
}

View File

@ -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",

View File

@ -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)));

View File

@ -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)));

View File

@ -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,

View File

@ -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

View File

@ -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>

View File

@ -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,
}
}

View File

@ -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,

View File

@ -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