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 或序号)
if let Some(session_id) = trimmed.strip_prefix("/use ") {
let session_id = session_id.trim();
return Ok(Some(Command::SwitchSession {
session_id: session_id.to_string(),
// 解析 /use 命令 - 切换话题(支持 topic_id 或序号)
if let Some(topic_id) = trimmed.strip_prefix("/use ") {
let topic_id = topic_id.trim();
return Ok(Some(Command::SwitchTopic {
topic_id: topic_id.to_string(),
}));
}

View File

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

View File

@ -7,27 +7,27 @@ use async_trait::async_trait;
use std::sync::Arc;
/// 加载话题命令处理器
pub struct LoadSessionCommandHandler {
pub struct LoadTopicCommandHandler {
store: Arc<SessionStore>,
}
impl LoadSessionCommandHandler {
impl LoadTopicCommandHandler {
pub fn new(store: Arc<SessionStore>) -> Self {
Self { store }
}
}
#[async_trait]
impl CommandHandler for LoadSessionCommandHandler {
impl CommandHandler for LoadTopicCommandHandler {
fn can_handle(&self, cmd: &Command) -> bool {
matches!(cmd, Command::LoadSession { .. })
matches!(cmd, Command::LoadTopic { .. })
}
fn metadata(&self) -> Option<CommandMetadata> {
Some(CommandMetadata {
name: "load",
description: "加载指定话题",
usage: "/load <session_id>",
usage: "/load <topic_id>",
})
}
@ -37,16 +37,16 @@ impl CommandHandler for LoadSessionCommandHandler {
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
match cmd {
Command::LoadSession { session_id } => {
handle_load_session(self, session_id, ctx).await
Command::LoadTopic { topic_id } => {
handle_load_topic(self, topic_id, ctx).await
}
_ => unreachable!(),
}
}
}
async fn handle_load_session(
handler: &LoadSessionCommandHandler,
async fn handle_load_topic(
handler: &LoadTopicCommandHandler,
topic_id: String,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
@ -61,4 +61,4 @@ async fn handle_load_session(
.with_metadata("topic_id", &topic.id)
.with_metadata("title", &topic.title)
.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_by_channel;
pub mod list_topics;
pub mod load_session;
pub mod load_topic;
pub mod save_session;
pub mod save_topic;
pub mod session;
pub mod switch_session;
pub mod switch_topic;
// 导出公共函数供其他模块复用
pub use save_session::{

View File

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

View File

@ -27,9 +27,9 @@ pub enum Command {
/// 列出当前 Session 的所有话题
ListSessions { include_archived: bool },
/// 加载指定话题
LoadSession { session_id: String },
LoadTopic { topic_id: String },
/// 切换到指定话题(清理当前历史并加载新话题)
SwitchSession { session_id: String },
SwitchTopic { topic_id: String },
/// 获取当前话题信息
GetCurrentSession,
/// 显示所有支持的命令
@ -53,8 +53,8 @@ impl Command {
Command::SaveTopic { .. } => "save_topic",
Command::SaveSession { .. } => "save_session",
Command::ListSessions { .. } => "list_sessions",
Command::LoadSession { .. } => "load_session",
Command::SwitchSession { .. } => "switch_session",
Command::LoadTopic { .. } => "load_topic",
Command::SwitchTopic { .. } => "switch_topic",
Command::GetCurrentSession => "get_current_session",
Command::Help => "help",
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::help::HelpCommandHandler;
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_topic::SaveTopicCommandHandler;
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::gateway::agent_prompt_provider::AgentPromptProvider;
use crate::providers::{create_provider, ProviderRuntimeConfig};
@ -52,8 +52,8 @@ impl InboundProcessor {
// 注册 list_sessions 处理器
command_router.register(Box::new(ListSessionsCommandHandler::new(store.clone())));
// 注册 switch_session 处理器
let switch_handler = SwitchSessionCommandHandler::new(store.clone())
// 注册 switch_topic 处理器
let switch_handler = SwitchTopicCommandHandler::new(store.clone())
.with_session_manager(session_manager.clone());
command_router.register(Box::new(switch_handler));
@ -76,8 +76,8 @@ impl InboundProcessor {
.with_system_prompt_provider(system_prompt_provider.clone())
));
// 注册 load_session 处理器
command_router.register(Box::new(LoadSessionCommandHandler::new(store.clone())));
// 注册 load_topic 处理器
command_router.register(Box::new(LoadTopicCommandHandler::new(store.clone())));
// 注册 save_session 处理器
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_by_channel::ListSessionsByChannelCommandHandler;
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::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::protocol::{WsInbound, WsOutbound, parse_inbound, serialize_outbound};
use crate::skills::SkillPromptProvider;
@ -291,14 +291,14 @@ async fn handle_inbound(
router.register(Box::new(ListChannelsCommandHandler::new()));
// 注册 list_topics 处理器
router.register(Box::new(ListTopicsCommandHandler::new(store.clone())));
// 注册 switch_session 处理器
let switch_handler = SwitchSessionCommandHandler::new(store.clone())
// 注册 switch_topic 处理器
let switch_handler = SwitchTopicCommandHandler::new(store.clone())
.with_session_manager(state.session_manager.clone());
router.register(Box::new(switch_handler));
// 注册 get_current 处理器
router.register(Box::new(GetCurrentSessionCommandHandler::new(store.clone())));
// 注册 load_session 处理器
router.register(Box::new(LoadSessionCommandHandler::new(store.clone())));
// 注册 load_topic 处理器
router.register(Box::new(LoadTopicCommandHandler::new(store.clone())));
router.register(Box::new(SaveSessionCommandHandler::new(
store.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())
}
/// 加载并发送话题历史消息
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)]
mod tests {
use super::resolve_ws_sender_id;

View File

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

View File

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

View File

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