refactor: 将 Session 命令重构为 Topic 命令

- 新增 LoadTopic 命令处理器,替代 LoadSession
- 新增 SwitchTopic 命令处理器,替代 SwitchSession
- 删除 LoadSession 和 SwitchSession 处理器
- 更新 Command 枚举:LoadSession -> LoadTopic, SwitchSession -> SwitchTopic
- 同步更新前端协议类型定义
- 调整适配器和网关代码以适应新命令

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
oudecheng 2026-05-27 16:01:07 +08:00
parent 10fb67320a
commit 542e11d0b3
11 changed files with 137 additions and 61 deletions

View File

@ -120,11 +120,11 @@ impl InputAdapter for ChannelInputAdapter {
})); }));
} }
// 解析 /use 命令 - 切换会话(支持 session_id 或序号) // 解析 /use 命令 - 切换话题(支持 topic_id 或序号)
if let Some(session_id) = trimmed.strip_prefix("/use ") { if let Some(topic_id) = trimmed.strip_prefix("/use ") {
let session_id = session_id.trim(); let topic_id = topic_id.trim();
return Ok(Some(Command::SwitchSession { return Ok(Some(Command::SwitchTopic {
session_id: session_id.to_string(), topic_id: topic_id.to_string(),
})); }));
} }

View File

@ -121,11 +121,11 @@ impl InputAdapter for CliInputAdapter {
})); }));
} }
// 解析 /use 命令 - 切换会话(支持 session_id 或序号) // 解析 /use 命令 - 切换话题(支持 topic_id 或序号)
if let Some(session_id) = trimmed.strip_prefix("/use ") { if let Some(topic_id) = trimmed.strip_prefix("/use ") {
let session_id = session_id.trim(); let topic_id = topic_id.trim();
return Ok(Some(Command::SwitchSession { return Ok(Some(Command::SwitchTopic {
session_id: session_id.to_string(), topic_id: topic_id.to_string(),
})); }));
} }

View File

@ -7,27 +7,27 @@ use async_trait::async_trait;
use std::sync::Arc; use std::sync::Arc;
/// 加载话题命令处理器 /// 加载话题命令处理器
pub struct LoadSessionCommandHandler { pub struct LoadTopicCommandHandler {
store: Arc<SessionStore>, store: Arc<SessionStore>,
} }
impl LoadSessionCommandHandler { impl LoadTopicCommandHandler {
pub fn new(store: Arc<SessionStore>) -> Self { pub fn new(store: Arc<SessionStore>) -> Self {
Self { store } Self { store }
} }
} }
#[async_trait] #[async_trait]
impl CommandHandler for LoadSessionCommandHandler { impl CommandHandler for LoadTopicCommandHandler {
fn can_handle(&self, cmd: &Command) -> bool { fn can_handle(&self, cmd: &Command) -> bool {
matches!(cmd, Command::LoadSession { .. }) matches!(cmd, Command::LoadTopic { .. })
} }
fn metadata(&self) -> Option<CommandMetadata> { fn metadata(&self) -> Option<CommandMetadata> {
Some(CommandMetadata { Some(CommandMetadata {
name: "load", name: "load",
description: "加载指定话题", description: "加载指定话题",
usage: "/load <session_id>", usage: "/load <topic_id>",
}) })
} }
@ -37,16 +37,16 @@ impl CommandHandler for LoadSessionCommandHandler {
ctx: CommandContext, ctx: CommandContext,
) -> Result<CommandResponse, CommandError> { ) -> Result<CommandResponse, CommandError> {
match cmd { match cmd {
Command::LoadSession { session_id } => { Command::LoadTopic { topic_id } => {
handle_load_session(self, session_id, ctx).await handle_load_topic(self, topic_id, ctx).await
} }
_ => unreachable!(), _ => unreachable!(),
} }
} }
} }
async fn handle_load_session( async fn handle_load_topic(
handler: &LoadSessionCommandHandler, handler: &LoadTopicCommandHandler,
topic_id: String, topic_id: String,
ctx: CommandContext, ctx: CommandContext,
) -> Result<CommandResponse, CommandError> { ) -> Result<CommandResponse, CommandError> {
@ -61,4 +61,4 @@ async fn handle_load_session(
.with_metadata("topic_id", &topic.id) .with_metadata("topic_id", &topic.id)
.with_metadata("title", &topic.title) .with_metadata("title", &topic.title)
.with_metadata("message_count", &topic.message_count.to_string())) .with_metadata("message_count", &topic.message_count.to_string()))
} }

View File

@ -4,11 +4,11 @@ pub mod list_channels;
pub mod list_sessions; pub mod list_sessions;
pub mod list_sessions_by_channel; pub mod list_sessions_by_channel;
pub mod list_topics; pub mod list_topics;
pub mod load_session; pub mod load_topic;
pub mod save_session; pub mod save_session;
pub mod save_topic; pub mod save_topic;
pub mod session; pub mod session;
pub mod switch_session; pub mod switch_topic;
// 导出公共函数供其他模块复用 // 导出公共函数供其他模块复用
pub use save_session::{ pub use save_session::{

View File

@ -8,12 +8,12 @@ use async_trait::async_trait;
use std::sync::Arc; use std::sync::Arc;
/// 切换话题命令处理器 /// 切换话题命令处理器
pub struct SwitchSessionCommandHandler { pub struct SwitchTopicCommandHandler {
store: Arc<SessionStore>, store: Arc<SessionStore>,
session_manager: Option<SessionManager>, session_manager: Option<SessionManager>,
} }
impl SwitchSessionCommandHandler { impl SwitchTopicCommandHandler {
pub fn new(store: Arc<SessionStore>) -> Self { pub fn new(store: Arc<SessionStore>) -> Self {
Self { store, session_manager: None } Self { store, session_manager: None }
} }
@ -25,16 +25,16 @@ impl SwitchSessionCommandHandler {
} }
#[async_trait] #[async_trait]
impl CommandHandler for SwitchSessionCommandHandler { impl CommandHandler for SwitchTopicCommandHandler {
fn can_handle(&self, cmd: &Command) -> bool { fn can_handle(&self, cmd: &Command) -> bool {
matches!(cmd, Command::SwitchSession { .. }) matches!(cmd, Command::SwitchTopic { .. })
} }
fn metadata(&self) -> Option<CommandMetadata> { fn metadata(&self) -> Option<CommandMetadata> {
Some(CommandMetadata { Some(CommandMetadata {
name: "use", name: "use",
description: "切换到指定话题", description: "切换到指定话题",
usage: "/use <session_id>", usage: "/use <topic_id>",
}) })
} }
@ -44,16 +44,16 @@ impl CommandHandler for SwitchSessionCommandHandler {
ctx: CommandContext, ctx: CommandContext,
) -> Result<CommandResponse, CommandError> { ) -> Result<CommandResponse, CommandError> {
match cmd { match cmd {
Command::SwitchSession { session_id } => { Command::SwitchTopic { topic_id } => {
handle_switch_session(self, session_id, ctx).await handle_switch_topic(self, topic_id, ctx).await
} }
_ => unreachable!(), _ => unreachable!(),
} }
} }
} }
async fn handle_switch_session( async fn handle_switch_topic(
handler: &SwitchSessionCommandHandler, handler: &SwitchTopicCommandHandler,
topic_id: String, topic_id: String,
ctx: CommandContext, ctx: CommandContext,
) -> Result<CommandResponse, CommandError> { ) -> Result<CommandResponse, CommandError> {
@ -112,4 +112,4 @@ async fn handle_switch_session(
.with_metadata("topic_id", &topic.id) .with_metadata("topic_id", &topic.id)
.with_metadata("title", &topic.title) .with_metadata("title", &topic.title)
.with_metadata("message_count", &msg_count.to_string())) .with_metadata("message_count", &msg_count.to_string()))
} }

View File

@ -27,9 +27,9 @@ pub enum Command {
/// 列出当前 Session 的所有话题 /// 列出当前 Session 的所有话题
ListSessions { include_archived: bool }, ListSessions { include_archived: bool },
/// 加载指定话题 /// 加载指定话题
LoadSession { session_id: String }, LoadTopic { topic_id: String },
/// 切换到指定话题(清理当前历史并加载新话题) /// 切换到指定话题(清理当前历史并加载新话题)
SwitchSession { session_id: String }, SwitchTopic { topic_id: String },
/// 获取当前话题信息 /// 获取当前话题信息
GetCurrentSession, GetCurrentSession,
/// 显示所有支持的命令 /// 显示所有支持的命令
@ -53,8 +53,8 @@ impl Command {
Command::SaveTopic { .. } => "save_topic", Command::SaveTopic { .. } => "save_topic",
Command::SaveSession { .. } => "save_session", Command::SaveSession { .. } => "save_session",
Command::ListSessions { .. } => "list_sessions", Command::ListSessions { .. } => "list_sessions",
Command::LoadSession { .. } => "load_session", Command::LoadTopic { .. } => "load_topic",
Command::SwitchSession { .. } => "switch_session", Command::SwitchTopic { .. } => "switch_topic",
Command::GetCurrentSession => "get_current_session", Command::GetCurrentSession => "get_current_session",
Command::Help => "help", Command::Help => "help",
Command::ListChannels => "list_channels", Command::ListChannels => "list_channels",

View File

@ -10,11 +10,11 @@ use crate::command::handler::CommandRouter;
use crate::command::handlers::get_current::GetCurrentSessionCommandHandler; use crate::command::handlers::get_current::GetCurrentSessionCommandHandler;
use crate::command::handlers::help::HelpCommandHandler; use crate::command::handlers::help::HelpCommandHandler;
use crate::command::handlers::list_sessions::ListSessionsCommandHandler; use crate::command::handlers::list_sessions::ListSessionsCommandHandler;
use crate::command::handlers::load_session::LoadSessionCommandHandler; use crate::command::handlers::load_topic::LoadTopicCommandHandler;
use crate::command::handlers::save_session::SaveSessionCommandHandler; use crate::command::handlers::save_session::SaveSessionCommandHandler;
use crate::command::handlers::save_topic::SaveTopicCommandHandler; use crate::command::handlers::save_topic::SaveTopicCommandHandler;
use crate::command::handlers::session::SessionCommandHandler; use crate::command::handlers::session::SessionCommandHandler;
use crate::command::handlers::switch_session::SwitchSessionCommandHandler; use crate::command::handlers::switch_topic::SwitchTopicCommandHandler;
use crate::config::LLMProviderConfig; use crate::config::LLMProviderConfig;
use crate::gateway::agent_prompt_provider::AgentPromptProvider; use crate::gateway::agent_prompt_provider::AgentPromptProvider;
use crate::providers::{create_provider, ProviderRuntimeConfig}; use crate::providers::{create_provider, ProviderRuntimeConfig};
@ -52,8 +52,8 @@ impl InboundProcessor {
// 注册 list_sessions 处理器 // 注册 list_sessions 处理器
command_router.register(Box::new(ListSessionsCommandHandler::new(store.clone()))); command_router.register(Box::new(ListSessionsCommandHandler::new(store.clone())));
// 注册 switch_session 处理器 // 注册 switch_topic 处理器
let switch_handler = SwitchSessionCommandHandler::new(store.clone()) let switch_handler = SwitchTopicCommandHandler::new(store.clone())
.with_session_manager(session_manager.clone()); .with_session_manager(session_manager.clone());
command_router.register(Box::new(switch_handler)); command_router.register(Box::new(switch_handler));
@ -76,8 +76,8 @@ impl InboundProcessor {
.with_system_prompt_provider(system_prompt_provider.clone()) .with_system_prompt_provider(system_prompt_provider.clone())
)); ));
// 注册 load_session 处理器 // 注册 load_topic 处理器
command_router.register(Box::new(LoadSessionCommandHandler::new(store.clone()))); command_router.register(Box::new(LoadTopicCommandHandler::new(store.clone())));
// 注册 save_session 处理器 // 注册 save_session 处理器
command_router.register(Box::new(SaveSessionCommandHandler::new( command_router.register(Box::new(SaveSessionCommandHandler::new(

View File

@ -11,10 +11,10 @@ use crate::command::handlers::list_channels::ListChannelsCommandHandler;
use crate::command::handlers::list_sessions::ListSessionsCommandHandler; use crate::command::handlers::list_sessions::ListSessionsCommandHandler;
use crate::command::handlers::list_sessions_by_channel::ListSessionsByChannelCommandHandler; use crate::command::handlers::list_sessions_by_channel::ListSessionsByChannelCommandHandler;
use crate::command::handlers::list_topics::ListTopicsCommandHandler; use crate::command::handlers::list_topics::ListTopicsCommandHandler;
use crate::command::handlers::load_session::LoadSessionCommandHandler; use crate::command::handlers::load_topic::LoadTopicCommandHandler;
use crate::command::handlers::save_session::SaveSessionCommandHandler; use crate::command::handlers::save_session::SaveSessionCommandHandler;
use crate::command::handlers::session::SessionCommandHandler; use crate::command::handlers::session::SessionCommandHandler;
use crate::command::handlers::switch_session::SwitchSessionCommandHandler; use crate::command::handlers::switch_topic::SwitchTopicCommandHandler;
use crate::gateway::agent_prompt_provider::AgentPromptProvider; use crate::gateway::agent_prompt_provider::AgentPromptProvider;
use crate::protocol::{WsInbound, WsOutbound, parse_inbound, serialize_outbound}; use crate::protocol::{WsInbound, WsOutbound, parse_inbound, serialize_outbound};
use crate::skills::SkillPromptProvider; use crate::skills::SkillPromptProvider;
@ -291,14 +291,14 @@ async fn handle_inbound(
router.register(Box::new(ListChannelsCommandHandler::new())); router.register(Box::new(ListChannelsCommandHandler::new()));
// 注册 list_topics 处理器 // 注册 list_topics 处理器
router.register(Box::new(ListTopicsCommandHandler::new(store.clone()))); router.register(Box::new(ListTopicsCommandHandler::new(store.clone())));
// 注册 switch_session 处理器 // 注册 switch_topic 处理器
let switch_handler = SwitchSessionCommandHandler::new(store.clone()) let switch_handler = SwitchTopicCommandHandler::new(store.clone())
.with_session_manager(state.session_manager.clone()); .with_session_manager(state.session_manager.clone());
router.register(Box::new(switch_handler)); router.register(Box::new(switch_handler));
// 注册 get_current 处理器 // 注册 get_current 处理器
router.register(Box::new(GetCurrentSessionCommandHandler::new(store.clone()))); router.register(Box::new(GetCurrentSessionCommandHandler::new(store.clone())));
// 注册 load_session 处理器 // 注册 load_topic 处理器
router.register(Box::new(LoadSessionCommandHandler::new(store.clone()))); router.register(Box::new(LoadTopicCommandHandler::new(store.clone())));
router.register(Box::new(SaveSessionCommandHandler::new( router.register(Box::new(SaveSessionCommandHandler::new(
store.clone(), store.clone(),
state.task_repository.clone(), state.task_repository.clone(),
@ -397,6 +397,82 @@ fn resolve_ws_sender_id(sender_id: Option<&str>, runtime_session_id: &str) -> St
.unwrap_or_else(|| runtime_session_id.to_string()) .unwrap_or_else(|| runtime_session_id.to_string())
} }
/// 加载并发送话题历史消息
async fn send_topic_history(
store: &Arc<crate::storage::SessionStore>,
topic_id: &str,
sender: &mpsc::Sender<WsOutbound>,
) -> Result<(), Box<dyn std::error::Error>> {
// 加载话题消息
let messages = store.load_messages_for_topic(topic_id)?;
tracing::info!(topic_id = %topic_id, message_count = messages.len(), "Sending topic history");
// 将消息转换为 WsOutbound 并发送
for msg in messages {
let outbound = chat_message_to_ws_outbound(&msg);
if let Some(outbound) = outbound {
let _ = sender.send(outbound).await;
}
}
Ok(())
}
/// 将 ChatMessage 转换为 WsOutbound
fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Option<WsOutbound> {
use crate::bus::message::ToolMessageState;
match msg.role.as_str() {
"assistant" => {
if let Some(tool_calls) = &msg.tool_calls {
// 如果有 tool_calls发送 ToolCall 消息
if let Some(tool_call) = tool_calls.first() {
return Some(WsOutbound::ToolCall {
id: msg.id.clone(),
tool_call_id: tool_call.id.clone(),
tool_name: tool_call.name.clone(),
arguments: tool_call.arguments.clone(),
content: format!("{}\nargs: {}", tool_call.name, tool_call.arguments),
role: msg.role.clone(),
});
}
}
// 普通助手消息
Some(WsOutbound::AssistantResponse {
id: msg.id.clone(),
content: msg.content.clone(),
role: msg.role.clone(),
})
}
"tool" => {
let tool_state = msg.tool_state.as_ref().unwrap_or(&ToolMessageState::Completed);
match tool_state {
ToolMessageState::Completed => Some(WsOutbound::ToolResult {
id: msg.id.clone(),
tool_call_id: msg.tool_call_id.clone().unwrap_or_default(),
tool_name: msg.tool_name.clone().unwrap_or_default(),
content: msg.content.clone(),
role: msg.role.clone(),
}),
ToolMessageState::PendingUserAction => Some(WsOutbound::ToolPending {
id: msg.id.clone(),
tool_call_id: msg.tool_call_id.clone().unwrap_or_default(),
tool_name: msg.tool_name.clone().unwrap_or_default(),
content: msg.content.clone(),
role: msg.role.clone(),
resume_hint: "完成外部操作后,直接发一条继续消息即可。".to_string(),
}),
}
}
"user" => {
// 用户消息不通过 WsOutbound 发送,前端自己维护
None
}
_ => None,
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::resolve_ws_sender_id; use super::resolve_ws_sender_id;

View File

@ -85,7 +85,7 @@ function App() {
break break
case 'use': case 'use':
if (args[0]) { if (args[0]) {
cmd = { type: 'switch_session', session_id: args[0] } cmd = { type: 'switch_topic', topic_id: args[0] }
} else { } else {
alert('Usage: /use <topic_id>') alert('Usage: /use <topic_id>')
return return

View File

@ -244,8 +244,8 @@ export function useChat(): UseChatReturn {
const handleCommand = useCallback((command: Command) => { const handleCommand = useCallback((command: Command) => {
switch (command.type) { switch (command.type) {
case 'create_session': case 'create_session':
case 'switch_session': case 'switch_topic':
case 'load_session': case 'load_topic':
case 'list_sessions': case 'list_sessions':
case 'list_sessions_by_channel': case 'list_sessions_by_channel':
case 'list_topics': case 'list_topics':
@ -273,8 +273,8 @@ export function useChat(): UseChatReturn {
const switchTopic = useCallback((topicId: string): Command => { const switchTopic = useCallback((topicId: string): Command => {
return { return {
type: 'switch_session', type: 'switch_topic',
session_id: topicId, topic_id: topicId,
} }
}, []) }, [])

View File

@ -170,9 +170,9 @@ export interface ListSessionsCommand {
include_archived: boolean include_archived: boolean
} }
export interface SwitchSessionCommand { export interface SwitchTopicCommand {
type: 'switch_session' type: 'switch_topic'
session_id: string topic_id: string
} }
export interface SaveTopicCommand { export interface SaveTopicCommand {
@ -188,9 +188,9 @@ export interface SaveSessionCommand {
include_subagents: boolean include_subagents: boolean
} }
export interface LoadSessionCommand { export interface LoadTopicCommand {
type: 'load_session' type: 'load_topic'
session_id: string topic_id: string
} }
export interface GetCurrentSessionCommand { export interface GetCurrentSessionCommand {
@ -219,10 +219,10 @@ export interface ListTopicsCommand {
export type Command = export type Command =
| CreateSessionCommand | CreateSessionCommand
| ListSessionsCommand | ListSessionsCommand
| SwitchSessionCommand | SwitchTopicCommand
| SaveTopicCommand | SaveTopicCommand
| SaveSessionCommand | SaveSessionCommand
| LoadSessionCommand | LoadTopicCommand
| GetCurrentSessionCommand | GetCurrentSessionCommand
| HelpCommand | HelpCommand
| ListChannelsCommand | ListChannelsCommand