feat: 添加通道和话题管理功能

后端:
- 新增 ListChannels 命令,列出所有可用通道 (WebSocket/CLI)
- 新增 ListSessionsByChannel 命令,支持按通道筛选会话
- 新增 ListTopics 命令,列出 Session 的所有 Topics
- 添加 Channel 和 TopicSummary 数据结构
- 更新 WebSocket 协议,支持 channel_list 和 topic_list 消息

前端:
- 新增 ChannelSelector 组件用于通道选择
- 新增 SessionSelector 组件用于会话选择
- 更新 TopicList 组件支持话题展示
- 更新 useChat hook 和协议类型定义

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
oudecheng 2026-05-27 14:55:09 +08:00
parent 624d8e8943
commit e9e1439428
16 changed files with 1132 additions and 65 deletions

View File

@ -80,11 +80,97 @@ impl OutputAdapter for WebSocketOutputAdapter {
}, },
MessageKind::Notification => { MessageKind::Notification => {
// 根据元数据判断具体类型 // 根据元数据判断具体类型
if let Some(session_id) = response.metadata.get("session_id") { if let Some(topics_json) = response.metadata.get("topics") {
// Topic 列表响应 - 优先检查 topics
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::TopicList {
topics,
session_id,
}
}
Err(_) => WsOutbound::AssistantResponse {
id: response.request_id.to_string(),
content: msg.content.clone(),
role: "assistant".to_string(),
},
}
} else if let Some(session_id) = response.metadata.get("session_id") {
// 有 session_id 但没有 topic_id 的是创建会话
if response.metadata.get("topic_id").is_none() {
WsOutbound::SessionCreated { WsOutbound::SessionCreated {
session_id: session_id.clone(), session_id: session_id.clone(),
title: msg.content.clone(), title: msg.content.clone(),
} }
} else {
// 加载会话
let message_count = response.metadata.get("message_count")
.and_then(|s| s.parse().ok())
.unwrap_or(0);
WsOutbound::SessionLoaded {
session_id: session_id.clone(),
title: msg.content.clone(),
message_count,
}
}
} else if let Some(topic_id) = response.metadata.get("topic_id") {
// 只有 topic_id可能是加载话题
let message_count = response.metadata.get("message_count")
.and_then(|s| s.parse().ok())
.unwrap_or(0);
WsOutbound::SessionLoaded {
session_id: topic_id.clone(),
title: msg.content.clone(),
message_count,
}
} else if let Some(channels_json) = response.metadata.get("channels") {
// 通道列表响应
match serde_json::from_str::<Vec<crate::protocol::Channel>>(channels_json) {
Ok(channels) => WsOutbound::ChannelList { channels },
Err(_) => WsOutbound::AssistantResponse {
id: response.request_id.to_string(),
content: msg.content.clone(),
role: "assistant".to_string(),
},
}
} else if let Some(sessions_json) = response.metadata.get("sessions") {
// 会话列表响应
match serde_json::from_str::<Vec<crate::protocol::SessionSummary>>(sessions_json) {
Ok(sessions) => {
let channel_name = response.metadata.get("channel_name").cloned();
WsOutbound::SessionList {
sessions,
current_session_id: None,
channel_name,
}
}
Err(_) => WsOutbound::AssistantResponse {
id: response.request_id.to_string(),
content: msg.content.clone(),
role: "assistant".to_string(),
},
}
} else if let Some(topics_json) = response.metadata.get("topics") {
// Topic 列表响应
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::TopicList {
topics,
session_id,
}
}
Err(_) => WsOutbound::AssistantResponse {
id: response.request_id.to_string(),
content: msg.content.clone(),
role: "assistant".to_string(),
},
}
} else { } else {
// 默认通知 // 默认通知
WsOutbound::AssistantResponse { WsOutbound::AssistantResponse {

View File

@ -0,0 +1,75 @@
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::protocol::Channel;
use async_trait::async_trait;
/// 列出通道命令处理器
pub struct ListChannelsCommandHandler;
impl ListChannelsCommandHandler {
pub fn new() -> Self {
Self
}
/// 获取默认通道列表(公开供其他模块使用)
pub fn get_default_channels() -> Vec<Channel> {
vec![
Channel {
id: "websocket".to_string(),
name: "WebSocket".to_string(),
description: Some("Web 前端通道".to_string()),
is_writable: true,
},
Channel {
id: "cli".to_string(),
name: "命令行".to_string(),
description: Some("CLI 命令行通道".to_string()),
is_writable: false,
},
]
}
}
#[async_trait]
impl CommandHandler for ListChannelsCommandHandler {
fn can_handle(&self, cmd: &Command) -> bool {
matches!(cmd, Command::ListChannels)
}
fn metadata(&self) -> Option<CommandMetadata> {
Some(CommandMetadata {
name: "channels",
description: "列出所有可用通道",
usage: "/channels",
})
}
async fn handle(
&self,
cmd: Command,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
match cmd {
Command::ListChannels => handle_list_channels(ctx).await,
_ => unreachable!(),
}
}
}
async fn handle_list_channels(
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
let channels = ListChannelsCommandHandler::get_default_channels();
let channels_json = serde_json::to_string(&channels)
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
let message = format!("Available channels: {}", channels.len());
Ok(CommandResponse::success(ctx.request_id)
.with_message(MessageKind::Notification, &message)
.with_metadata("channels", &channels_json)
.with_metadata("count", &channels.len().to_string()))
}

View File

@ -0,0 +1,88 @@
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::protocol::SessionSummary;
use crate::storage::SessionStore;
use async_trait::async_trait;
use std::sync::Arc;
/// 按通道列出会话命令处理器
pub struct ListSessionsByChannelCommandHandler {
store: Arc<SessionStore>,
}
impl ListSessionsByChannelCommandHandler {
pub fn new(store: Arc<SessionStore>) -> Self {
Self { store }
}
}
#[async_trait]
impl CommandHandler for ListSessionsByChannelCommandHandler {
fn can_handle(&self, cmd: &Command) -> bool {
matches!(cmd, Command::ListSessionsByChannel { .. })
}
fn metadata(&self) -> Option<CommandMetadata> {
Some(CommandMetadata {
name: "list_by_channel",
description: "列出指定通道的所有会话",
usage: "/list_by_channel <channel_name>",
})
}
async fn handle(
&self,
cmd: Command,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
match cmd {
Command::ListSessionsByChannel {
channel_name,
include_archived,
} => handle_list_sessions_by_channel(self, channel_name, include_archived, ctx).await,
_ => unreachable!(),
}
}
}
async fn handle_list_sessions_by_channel(
handler: &ListSessionsByChannelCommandHandler,
channel_name: String,
include_archived: bool,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
let sessions = handler
.store
.list_sessions(&channel_name, include_archived)
.map_err(|e| CommandError::new("LIST_SESSIONS_ERROR", e.to_string()))?;
let summaries: Vec<SessionSummary> = sessions
.into_iter()
.map(|s| SessionSummary {
session_id: s.id,
title: s.title,
channel_name: s.channel_name,
chat_id: s.chat_id,
message_count: s.message_count,
last_active_at: s.last_active_at,
archived_at: s.archived_at,
})
.collect();
let sessions_json = serde_json::to_string(&summaries)
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
let message = format!(
"Found {} session(s) in channel '{}'",
summaries.len(),
channel_name
);
Ok(CommandResponse::success(ctx.request_id)
.with_message(MessageKind::Notification, &message)
.with_metadata("sessions", &sessions_json)
.with_metadata("channel_name", &channel_name)
.with_metadata("count", &summaries.len().to_string()))
}

View File

@ -0,0 +1,96 @@
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::storage::SessionStore;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
/// Topic 摘要信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TopicSummary {
pub topic_id: String,
pub session_id: String,
pub title: String,
pub message_count: i64,
pub created_at: i64,
pub last_active_at: i64,
}
/// 列出 Session 的 Topics 命令处理器
pub struct ListTopicsCommandHandler {
store: Arc<SessionStore>,
}
impl ListTopicsCommandHandler {
pub fn new(store: Arc<SessionStore>) -> Self {
Self { store }
}
}
#[async_trait]
impl CommandHandler for ListTopicsCommandHandler {
fn can_handle(&self, cmd: &Command) -> bool {
matches!(cmd, Command::ListTopics { .. })
}
fn metadata(&self) -> Option<CommandMetadata> {
Some(CommandMetadata {
name: "topics",
description: "列出 Session 的所有 Topics",
usage: "/topics <session_id>",
})
}
async fn handle(
&self,
cmd: Command,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
match cmd {
Command::ListTopics { session_id } => {
handle_list_topics(self, session_id, ctx).await
}
_ => unreachable!(),
}
}
}
async fn handle_list_topics(
handler: &ListTopicsCommandHandler,
session_id: String,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
let topics = handler
.store
.list_topics(&session_id)
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?;
let summaries: Vec<TopicSummary> = topics
.into_iter()
.map(|t| TopicSummary {
topic_id: t.id,
session_id: t.session_id,
title: t.title,
message_count: t.message_count,
created_at: t.created_at,
last_active_at: t.last_active_at,
})
.collect();
let topics_json = serde_json::to_string(&summaries)
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
let message = format!(
"Found {} topic(s) in session '{}'",
summaries.len(),
session_id
);
Ok(CommandResponse::success(ctx.request_id)
.with_message(MessageKind::Notification, &message)
.with_metadata("topics", &topics_json)
.with_metadata("session_id", &session_id)
.with_metadata("count", &summaries.len().to_string()))
}

View File

@ -1,6 +1,9 @@
pub mod get_current; pub mod get_current;
pub mod help; pub mod help;
pub mod list_channels;
pub mod list_sessions; pub mod list_sessions;
pub mod list_sessions_by_channel;
pub mod list_topics;
pub mod load_session; pub mod load_session;
pub mod save_session; pub mod save_session;
pub mod save_topic; pub mod save_topic;

View File

@ -34,6 +34,15 @@ pub enum Command {
GetCurrentSession, GetCurrentSession,
/// 显示所有支持的命令 /// 显示所有支持的命令
Help, Help,
/// 列出所有可用通道
ListChannels,
/// 列出指定通道的所有会话
ListSessionsByChannel {
channel_name: String,
include_archived: bool,
},
/// 列出 Session 的所有 Topics
ListTopics { session_id: String },
} }
impl Command { impl Command {
@ -48,6 +57,9 @@ impl Command {
Command::SwitchSession { .. } => "switch_session", Command::SwitchSession { .. } => "switch_session",
Command::GetCurrentSession => "get_current_session", Command::GetCurrentSession => "get_current_session",
Command::Help => "help", Command::Help => "help",
Command::ListChannels => "list_channels",
Command::ListSessionsByChannel { .. } => "list_sessions_by_channel",
Command::ListTopics { .. } => "list_topics",
} }
} }
} }

View File

@ -7,7 +7,10 @@ use crate::command::context::CommandContext;
use crate::command::handler::CommandRouter; 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_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_topics::ListTopicsCommandHandler;
use crate::command::handlers::load_session::LoadSessionCommandHandler; use crate::command::handlers::load_session::LoadSessionCommandHandler;
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;
@ -35,12 +38,24 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
let (sender, receiver) = mpsc::channel::<WsOutbound>(100); let (sender, receiver) = mpsc::channel::<WsOutbound>(100);
let cli_sessions = state.session_manager.cli_sessions(); let cli_sessions = state.session_manager.cli_sessions();
let initial_record = match cli_sessions.create(None) { let store = state.session_manager.store();
// 1. 先查询 websocket 通道的 Sessions
let websocket_sessions = store.list_sessions("websocket", false)
.unwrap_or_default();
// 2. 如果没有,自动创建一个默认 Session
let initial_record = if websocket_sessions.is_empty() {
match cli_sessions.create_with_channel("websocket", Some("默认会话")) {
Ok(record) => record, Ok(record) => record,
Err(e) => { Err(e) => {
tracing::error!(error = %e, "Failed to create initial CLI session"); tracing::error!(error = %e, "Failed to create initial WebSocket session");
return; return;
} }
}
} else {
// 使用最新的 Session
websocket_sessions[0].clone()
}; };
let runtime_session_id = uuid::Uuid::new_v4().to_string(); let runtime_session_id = uuid::Uuid::new_v4().to_string();
@ -55,7 +70,7 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
sender.clone(), sender.clone(),
) )
.await; .await;
tracing::info!(runtime_session_id = %runtime_session_id, session_id = %current_session_id, "CLI session established"); tracing::info!(runtime_session_id = %runtime_session_id, session_id = %current_session_id, "WebSocket session established");
let _ = sender let _ = sender
.send(WsOutbound::SessionEstablished { .send(WsOutbound::SessionEstablished {
@ -63,6 +78,42 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
}) })
.await; .await;
// 连接建立后立即发送通道列表
let channels = ListChannelsCommandHandler::get_default_channels();
let _ = sender
.send(WsOutbound::ChannelList { channels })
.await;
// 3. 重新查询 websocket 通道的 Session 列表(包含刚创建的)
let final_sessions = store.list_sessions("websocket", false)
.unwrap_or_default();
tracing::info!("Sending {} websocket sessions to client", final_sessions.len());
for s in &final_sessions {
tracing::info!(" - {}: {} (channel: {})", s.id, s.title, s.channel_name);
}
let session_summaries: Vec<crate::protocol::SessionSummary> = final_sessions
.into_iter()
.map(|s| crate::protocol::SessionSummary {
session_id: s.id,
title: s.title,
channel_name: s.channel_name,
chat_id: s.chat_id,
message_count: s.message_count,
last_active_at: s.last_active_at,
archived_at: s.archived_at,
})
.collect();
let _ = sender
.send(WsOutbound::SessionList {
sessions: session_summaries,
current_session_id: Some(current_session_id.clone()),
channel_name: Some("websocket".to_string()),
})
.await;
let (mut ws_sender, mut ws_receiver) = ws.split(); let (mut ws_sender, mut ws_receiver) = ws.split();
let mut receiver = receiver; let mut receiver = receiver;
@ -234,6 +285,12 @@ async fn handle_inbound(
router.register(Box::new(session_handler)); router.register(Box::new(session_handler));
// 注册 list_sessions 处理器 // 注册 list_sessions 处理器
router.register(Box::new(ListSessionsCommandHandler::new(store.clone()))); router.register(Box::new(ListSessionsCommandHandler::new(store.clone())));
// 注册 list_sessions_by_channel 处理器
router.register(Box::new(ListSessionsByChannelCommandHandler::new(store.clone())));
// 注册 list_channels 处理器
router.register(Box::new(ListChannelsCommandHandler::new()));
// 注册 list_topics 处理器
router.register(Box::new(ListTopicsCommandHandler::new(store.clone())));
// 注册 switch_session 处理器 // 注册 switch_session 处理器
let switch_handler = SwitchSessionCommandHandler::new(store.clone()) let switch_handler = SwitchSessionCommandHandler::new(store.clone())
.with_session_manager(state.session_manager.clone()); .with_session_manager(state.session_manager.clone());

View File

@ -14,6 +14,26 @@ pub struct SessionSummary {
pub archived_at: Option<i64>, pub archived_at: Option<i64>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Channel {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(rename = "isWritable")]
pub is_writable: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TopicSummary {
pub topic_id: String,
pub session_id: String,
pub title: String,
pub message_count: i64,
pub created_at: i64,
pub last_active_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")] #[serde(tag = "type")]
pub enum WsInbound { pub enum WsInbound {
@ -81,6 +101,17 @@ pub enum WsOutbound {
sessions: Vec<SessionSummary>, sessions: Vec<SessionSummary>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
current_session_id: Option<String>, current_session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
channel_name: Option<String>,
},
#[serde(rename = "channel_list")]
ChannelList {
channels: Vec<Channel>,
},
#[serde(rename = "topic_list")]
TopicList {
topics: Vec<TopicSummary>,
session_id: String,
}, },
#[serde(rename = "session_loaded")] #[serde(rename = "session_loaded")]
SessionLoaded { SessionLoaded {

View File

@ -1,7 +1,9 @@
import { useCallback, useMemo } from 'react' import { useCallback, useMemo, useEffect } from 'react'
import { Zap, Cpu, Activity } from 'lucide-react' import { Zap, Cpu, Activity } from 'lucide-react'
import { ChatContainer } from './components/Chat/ChatContainer' import { ChatContainer } from './components/Chat/ChatContainer'
import { TopicList } from './components/Sidebar/TopicList' import { TopicList } from './components/Sidebar/TopicList'
import { ChannelSelector } from './components/Sidebar/ChannelSelector'
import { SessionSelector } from './components/Sidebar/SessionSelector'
import { ToolPanel } from './components/Panel/ToolPanel' import { ToolPanel } from './components/Panel/ToolPanel'
import { ConnectionStatus } from './components/ConnectionStatus' import { ConnectionStatus } from './components/ConnectionStatus'
import { useWebSocket } from './hooks/useWebSocket' import { useWebSocket } from './hooks/useWebSocket'
@ -12,14 +14,24 @@ const WS_URL = 'ws://127.0.0.1:19876/ws'
function App() { function App() {
const { const {
// 消息
messages, messages,
currentSessionId,
currentTopicId,
topics,
isLoading, isLoading,
// 三级状态
channels,
selectedChannel,
sessions,
selectedSession,
topics,
selectedTopic,
isReadOnly,
// 方法
handleMessage, handleMessage,
handleCommand, handleCommand,
handleServerMessage, handleServerMessage,
selectChannel,
selectSession,
selectTopic,
} = useChat() } = useChat()
const { status, sendMessage } = useWebSocket({ const { status, sendMessage } = useWebSocket({
@ -27,8 +39,64 @@ function App() {
onMessage: handleServerMessage, onMessage: handleServerMessage,
}) })
// 获取选中通道的 Session
const channelSessions = useMemo(() => {
if (!selectedChannel) return []
return sessions.filter((s) => s.channel_name === selectedChannel)
}, [sessions, selectedChannel])
// 获取选中 Session 的 title
const selectedSessionTitle = useMemo(() => {
const session = sessions.find((s) => s.id === selectedSession)
return session?.title || ''
}, [sessions, selectedSession])
// 获取当前通道名称
const currentChannelName = useMemo(() => {
const channel = channels.find((c) => c.id === selectedChannel)
return channel?.name || selectedChannel || ''
}, [channels, selectedChannel])
// 通道变化时加载该通道的 Sessions
useEffect(() => {
if (selectedChannel && status === 'connected') {
const cmd: Command = {
type: 'list_sessions_by_channel',
channel_name: selectedChannel,
include_archived: false,
}
handleCommand(cmd)
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
}
}, [selectedChannel, status, handleCommand, sendMessage])
// Session 变化时加载该 Session 的 Topics
useEffect(() => {
if (selectedSession && status === 'connected') {
// 1. 加载 Session 信息
const cmd: Command = {
type: 'load_session',
session_id: selectedSession,
}
handleCommand(cmd)
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
// 2. 加载 Topics 列表
const topicsCmd: Command = {
type: 'list_topics',
session_id: selectedSession,
}
handleCommand(topicsCmd)
sendMessage({ type: 'command', payload: JSON.stringify(topicsCmd) })
}
}, [selectedSession, status, handleCommand, sendMessage])
const handleSendMessage = useCallback( const handleSendMessage = useCallback(
(content: string) => { (content: string) => {
if (isReadOnly) {
return
}
if (content.startsWith('/')) { if (content.startsWith('/')) {
const parts = content.slice(1).split(' ') const parts = content.slice(1).split(' ')
const command = parts[0] const command = parts[0]
@ -44,7 +112,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: 'load_session', session_id: args[0] }
} else { } else {
alert('Usage: /use <session_id>') alert('Usage: /use <session_id>')
return return
@ -65,29 +133,51 @@ function App() {
sendMessage({ sendMessage({
type: 'message', type: 'message',
content, content,
chat_id: currentTopicId ?? undefined, chat_id: selectedSession ?? undefined,
}) })
} }
}, },
[sendMessage, handleMessage, handleCommand, currentTopicId] [sendMessage, handleMessage, handleCommand, selectedSession, isReadOnly]
) )
const handleCreateTopic = useCallback(() => { const handleCreateTopic = useCallback(() => {
if (isReadOnly || !selectedSession) {
return
}
const title = prompt('Enter topic title:') const title = prompt('Enter topic title:')
if (title) { if (title) {
// TODO: 实现 create_topic 命令
// 目前 Session 和 Topic 是同一个概念,简化处理
const cmd: Command = { type: 'create_session', title } const cmd: Command = { type: 'create_session', title }
handleCommand(cmd) handleCommand(cmd)
sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
} }
}, [sendMessage, handleCommand]) }, [sendMessage, handleCommand, selectedSession, isReadOnly])
const handleSwitchTopic = useCallback( const handleSwitchTopic = useCallback(
(topicId: string) => { (topicId: string) => {
const cmd: Command = { type: 'switch_session', session_id: topicId } selectTopic(topicId)
// Topic 切换时重新加载
const cmd: Command = { type: 'load_session', session_id: topicId }
handleCommand(cmd) handleCommand(cmd)
sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
}, },
[sendMessage, handleCommand] [sendMessage, handleCommand, selectTopic]
)
const handleSelectChannel = useCallback(
(channelId: string) => {
selectChannel(channelId)
},
[selectChannel]
)
const handleSelectSession = useCallback(
(sessionId: string) => {
selectSession(sessionId)
},
[selectSession]
) )
const toolMessages = useMemo(() => messages, [messages]) const toolMessages = useMemo(() => messages, [messages])
@ -112,11 +202,11 @@ function App() {
<Cpu className="h-4 w-4 text-[#00f0ff]" /> <Cpu className="h-4 w-4 text-[#00f0ff]" />
<span>AI Ready</span> <span>AI Ready</span>
</div> </div>
{currentSessionId && ( {selectedSession && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-emerald-400" /> <Activity className="h-4 w-4 text-emerald-400" />
<span className="font-mono text-xs"> <span className="font-mono text-xs">
{currentSessionId.slice(0, 8)}... {selectedSession.slice(0, 8)}...
</span> </span>
</div> </div>
)} )}
@ -125,21 +215,50 @@ function App() {
{/* Main Content */} {/* Main Content */}
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* Left Sidebar - Topic List */} {/* Left Sidebar - 三级选择器 */}
<div className="w-72 shrink-0 border-r border-white/8 bg-[#12121a]/50"> <div className="w-72 shrink-0 border-r border-white/8 bg-[#12121a]/50 flex flex-col">
{/* Channel Selector */}
<ChannelSelector
channels={channels}
selectedChannel={selectedChannel}
onSelectChannel={handleSelectChannel}
/>
{/* Divider */}
<div className="border-b border-white/8" />
{/* Session Selector */}
<SessionSelector
sessions={channelSessions}
selectedSession={selectedSession}
channelId={selectedChannel || ''}
onSelectSession={handleSelectSession}
/>
{/* Divider */}
<div className="border-b border-white/8" />
{/* Topic List */}
<div className="flex-1 overflow-hidden">
<TopicList <TopicList
sessionId={selectedSession}
sessionTitle={selectedSessionTitle}
topics={topics} topics={topics}
currentTopicId={currentTopicId} currentTopicId={selectedTopic}
isReadOnly={isReadOnly}
onCreateTopic={handleCreateTopic} onCreateTopic={handleCreateTopic}
onSwitchTopic={handleSwitchTopic} onSwitchTopic={handleSwitchTopic}
/> />
</div> </div>
</div>
{/* Center - Chat */} {/* Center - Chat */}
<div className="flex-1 min-w-0 bg-[#0a0a0f]"> <div className="flex-1 min-w-0 bg-[#0a0a0f]">
<ChatContainer <ChatContainer
messages={messages} messages={messages}
isLoading={isLoading} isLoading={isLoading}
isReadOnly={isReadOnly}
channelName={currentChannelName}
onSendMessage={handleSendMessage} onSendMessage={handleSendMessage}
/> />
</div> </div>

View File

@ -5,12 +5,16 @@ import type { ChatMessage } from '../../types/protocol'
interface ChatContainerProps { interface ChatContainerProps {
messages: ChatMessage[] messages: ChatMessage[]
isLoading: boolean isLoading: boolean
isReadOnly?: boolean
channelName?: string
onSendMessage: (content: string) => void onSendMessage: (content: string) => void
} }
export function ChatContainer({ export function ChatContainer({
messages, messages,
isLoading, isLoading,
isReadOnly = false,
channelName,
onSendMessage, onSendMessage,
}: ChatContainerProps) { }: ChatContainerProps) {
return ( return (
@ -18,7 +22,12 @@ export function ChatContainer({
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<MessageList messages={messages} /> <MessageList messages={messages} />
</div> </div>
<MessageInput onSend={onSendMessage} disabled={isLoading} /> <MessageInput
onSend={onSendMessage}
disabled={isLoading}
isReadOnly={isReadOnly}
channelName={channelName}
/>
</div> </div>
) )
} }

View File

@ -1,16 +1,20 @@
import { Send, Loader2, Sparkles } from 'lucide-react' import { Send, Loader2, Sparkles, Eye } from 'lucide-react'
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect } from 'react'
interface MessageInputProps { interface MessageInputProps {
onSend: (content: string) => void onSend: (content: string) => void
disabled?: boolean disabled?: boolean
placeholder?: string placeholder?: string
isReadOnly?: boolean
channelName?: string
} }
export function MessageInput({ export function MessageInput({
onSend, onSend,
disabled = false, disabled = false,
placeholder = '输入消息...按 / 查看命令', placeholder = '输入消息...按 / 查看命令',
isReadOnly = false,
channelName,
}: MessageInputProps) { }: MessageInputProps) {
const [content, setContent] = useState('') const [content, setContent] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
@ -24,7 +28,7 @@ export function MessageInput({
}, [content]) }, [content])
const handleSend = () => { const handleSend = () => {
if (content.trim() && !disabled) { if (content.trim() && !disabled && !isReadOnly) {
onSend(content.trim()) onSend(content.trim())
setContent('') setContent('')
if (textareaRef.current) { if (textareaRef.current) {
@ -40,6 +44,34 @@ export function MessageInput({
} }
} }
// 只读模式:显示提示占位符
if (isReadOnly) {
return (
<div className="border-t border-white/8 bg-[#12121a]/80 backdrop-blur-md p-4">
<div className="max-w-5xl mx-auto">
<div className="rounded-xl border border-zinc-500/20 bg-[#1a1a25]/50 px-4 py-5 text-center">
<div className="flex flex-col items-center gap-2">
<div className="flex items-center gap-2 text-zinc-400">
<Eye className="h-5 w-5" />
<span className="font-medium"></span>
</div>
<p className="text-sm text-zinc-500">
{channelName ? (
<>{channelName} </>
) : (
'当前通道仅支持查看历史消息'
)}
</p>
<p className="text-xs text-zinc-600">
WebSocket
</p>
</div>
</div>
</div>
</div>
)
}
return ( return (
<div className="border-t border-white/8 bg-[#12121a]/80 backdrop-blur-md p-4"> <div className="border-t border-white/8 bg-[#12121a]/80 backdrop-blur-md p-4">
<div className="flex gap-3 items-end max-w-5xl mx-auto"> <div className="flex gap-3 items-end max-w-5xl mx-auto">

View File

@ -0,0 +1,127 @@
import { useState } from 'react'
import { Monitor, Smartphone, MessageSquare, Hash, ChevronDown, Eye, Pencil } from 'lucide-react'
import type { Channel } from '../../types/protocol'
interface ChannelSelectorProps {
channels: Channel[]
selectedChannel: string | null
onSelectChannel: (channelId: string) => void
}
const CHANNEL_ICONS: Record<string, React.ReactNode> = {
cli: <Monitor className="h-4 w-4" />,
websocket: <MessageSquare className="h-4 w-4" />,
feishu: <Smartphone className="h-4 w-4" />,
weixin: <Smartphone className="h-4 w-4" />,
wechat: <Smartphone className="h-4 w-4" />,
}
export function ChannelSelector({
channels,
selectedChannel,
onSelectChannel,
}: ChannelSelectorProps) {
const [isOpen, setIsOpen] = useState(false)
const selected = channels.find((c) => c.id === selectedChannel)
return (
<div className="relative">
{/* Header */}
<div className="flex items-center justify-between border-b border-white/8 px-4 py-3">
<h2 className="font-semibold text-white flex items-center gap-2 text-sm">
<Hash className="h-4 w-4 text-[#00f0ff]" />
</h2>
</div>
{/* Channel Dropdown */}
<div className="px-3 py-2">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between rounded-lg border border-white/10 bg-[#1a1a25]/80 px-3 py-2.5 text-left hover:bg-[#1a1a25] transition-all"
>
<div className="flex items-center gap-2.5">
<span className="text-zinc-400">
{selected ? CHANNEL_ICONS[selected.id] || <MessageSquare className="h-4 w-4" /> : <MessageSquare className="h-4 w-4" />}
</span>
<div className="flex flex-col">
<span className="text-sm font-medium text-white">
{selected?.name || '选择通道'}
</span>
{selected && (
<span className={`text-xs flex items-center gap-1 ${selected.isWritable ? 'text-emerald-400' : 'text-zinc-500'}`}>
{selected.isWritable ? (
<>
<Pencil className="h-3 w-3" />
</>
) : (
<>
<Eye className="h-3 w-3" />
</>
)}
</span>
)}
</div>
</div>
<ChevronDown
className={`h-4 w-4 text-zinc-500 transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
</button>
{/* Dropdown Menu */}
{isOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setIsOpen(false)}
/>
<div className="absolute left-3 right-3 z-20 mt-1 rounded-lg border border-white/10 bg-[#1a1a25] shadow-xl shadow-black/50 overflow-hidden">
{channels.length === 0 ? (
<div className="px-3 py-3 text-sm text-zinc-500 text-center">
</div>
) : (
channels.map((channel) => (
<button
key={channel.id}
onClick={() => {
onSelectChannel(channel.id)
setIsOpen(false)
}}
className={`w-full flex items-center justify-between px-3 py-2.5 text-left hover:bg-white/5 transition-colors ${
channel.id === selectedChannel ? 'bg-[#00f0ff]/10' : ''
}`}
>
<div className="flex items-center gap-2.5">
<span className={channel.id === selectedChannel ? 'text-[#00f0ff]' : 'text-zinc-400'}>
{CHANNEL_ICONS[channel.id] || <MessageSquare className="h-4 w-4" />}
</span>
<div className="flex flex-col">
<span className={`text-sm ${channel.id === selectedChannel ? 'text-white font-medium' : 'text-zinc-300'}`}>
{channel.name}
</span>
{channel.description && (
<span className="text-xs text-zinc-500">{channel.description}</span>
)}
</div>
</div>
<span className={`text-xs px-1.5 py-0.5 rounded ${
channel.isWritable
? 'bg-emerald-400/10 text-emerald-400'
: 'bg-zinc-500/10 text-zinc-500'
}`}>
{channel.isWritable ? '可输入' : '只读'}
</span>
</button>
))
)}
</div>
</>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,114 @@
import { useState } from 'react'
import { FolderOpen, ChevronDown, Hash } from 'lucide-react'
import type { Session } from '../../hooks/useChat'
interface SessionSelectorProps {
sessions: Session[]
selectedSession: string | null
channelId: string // 使用 channelId 而不是 channelName
onSelectSession: (sessionId: string) => void
}
export function SessionSelector({
sessions,
selectedSession,
channelId,
onSelectSession,
}: SessionSelectorProps) {
const [isOpen, setIsOpen] = useState(false)
const selected = sessions.find((s) => s.id === selectedSession)
// 按通道 ID 筛选 Session
const channelSessions = sessions.filter(
(s) => s.channel_name === channelId
)
return (
<div className="relative">
{/* Header */}
<div className="flex items-center justify-between border-b border-white/8 px-4 py-3">
<h2 className="font-semibold text-white flex items-center gap-2 text-sm">
<FolderOpen className="h-4 w-4 text-[#00f0ff]" />
Session
</h2>
</div>
{/* Session Dropdown */}
<div className="px-3 py-2">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between rounded-lg border border-white/10 bg-[#1a1a25]/80 px-3 py-2.5 text-left hover:bg-[#1a1a25] transition-all"
>
<div className="flex items-center gap-2.5">
<span className="text-zinc-400">
<FolderOpen className="h-4 w-4" />
</span>
<div className="flex flex-col">
<span className="text-sm font-medium text-white truncate max-w-[160px]">
{selected?.title || '选择 Session'}
</span>
{selected && (
<span className="text-xs text-zinc-500">
{selected.message_count}
</span>
)}
</div>
</div>
<ChevronDown
className={`h-4 w-4 text-zinc-500 transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
</button>
{/* Dropdown Menu */}
{isOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setIsOpen(false)}
/>
<div className="absolute left-3 right-3 z-20 mt-1 rounded-lg border border-white/10 bg-[#1a1a25] shadow-xl shadow-black/50 overflow-hidden">
{channelSessions.length === 0 ? (
<div className="px-3 py-3 text-sm text-zinc-500 text-center">
Session
</div>
) : (
channelSessions.map((session, index) => (
<button
key={session.id}
onClick={() => {
onSelectSession(session.id)
setIsOpen(false)
}}
className={`w-full flex items-center justify-between px-3 py-2.5 text-left hover:bg-white/5 transition-colors ${
session.id === selectedSession ? 'bg-[#00f0ff]/10' : ''
}`}
>
<div className="flex items-center gap-2.5">
<span className={session.id === selectedSession ? 'text-[#00f0ff]' : 'text-zinc-400'}>
<Hash className="h-4 w-4" />
</span>
<div className="flex flex-col">
<span className={`text-sm truncate max-w-[140px] ${
session.id === selectedSession ? 'text-white font-medium' : 'text-zinc-300'
}`}>
{session.title}
</span>
<span className="text-xs text-zinc-500">
{session.message_count}
</span>
</div>
</div>
<span className="text-xs text-zinc-600 font-mono">
{index + 1}
</span>
</button>
))
)}
</div>
</>
)}
</div>
</div>
)
}

View File

@ -1,39 +1,70 @@
import { Plus, MessageSquare, Hash } from 'lucide-react' import { Plus, MessageSquare, Eye, Layers } from 'lucide-react'
import type { Topic } from '../../types/protocol' import type { Topic } from '../../types/protocol'
interface TopicListProps { interface TopicListProps {
sessionId: string | null
sessionTitle: string
topics: Topic[] topics: Topic[]
currentTopicId: string | null currentTopicId: string | null
isReadOnly: boolean
onCreateTopic: () => void onCreateTopic: () => void
onSwitchTopic: (topicId: string) => void onSwitchTopic: (topicId: string) => void
} }
export function TopicList({ export function TopicList({
sessionId,
sessionTitle,
topics, topics,
currentTopicId, currentTopicId,
isReadOnly,
onCreateTopic, onCreateTopic,
onSwitchTopic, onSwitchTopic,
}: TopicListProps) { }: TopicListProps) {
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b border-white/8 p-4"> <div className="flex items-center justify-between border-b border-white/8 px-4 py-3">
<h2 className="font-semibold text-white flex items-center gap-2"> <h2 className="font-semibold text-white flex items-center gap-2 text-sm">
<Hash className="h-4 w-4 text-[#00f0ff]" /> <Layers className="h-4 w-4 text-[#00f0ff]" />
Topics Topics
{isReadOnly && (
<span className="text-xs px-1.5 py-0.5 rounded bg-zinc-500/20 text-zinc-400 flex items-center gap-1">
<Eye className="h-3 w-3" />
</span>
)}
</h2> </h2>
<button <button
onClick={onCreateTopic} onClick={onCreateTopic}
className="flex items-center gap-1 rounded-lg bg-[#00f0ff]/10 px-3 py-1.5 text-sm text-[#00f0ff] hover:bg-[#00f0ff]/20 border border-[#00f0ff]/30 transition-all" disabled={isReadOnly || !sessionId}
className={`flex items-center gap-1 rounded-lg px-3 py-1.5 text-sm transition-all ${
isReadOnly || !sessionId
? 'bg-zinc-500/10 text-zinc-500 cursor-not-allowed'
: 'bg-[#00f0ff]/10 text-[#00f0ff] hover:bg-[#00f0ff]/20 border border-[#00f0ff]/30'
}`}
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</button> </button>
</div> </div>
{/* Session 信息 */}
{sessionId && (
<div className="px-4 py-2 border-b border-white/8 bg-[#00f0ff]/5">
<p className="text-xs text-zinc-500"> Session</p>
<p className="text-sm text-zinc-300 truncate">{sessionTitle}</p>
</div>
)}
<div className="flex-1 overflow-y-auto p-3"> <div className="flex-1 overflow-y-auto p-3">
{topics.length === 0 ? ( {!sessionId ? (
<div className="p-4 text-center text-sm text-zinc-500"> <div className="p-4 text-center text-sm text-zinc-500">
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" /> <MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
Topic Session
</div>
) : topics.length === 0 ? (
<div className="p-4 text-center text-sm text-zinc-500">
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
Session Topics
</div> </div>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1">

View File

@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from 'react' import { useState, useCallback, useRef, useMemo } from 'react'
import type { import type {
Command, Command,
ChatMessage, ChatMessage,
@ -11,27 +11,68 @@ import type {
SessionEstablished, SessionEstablished,
SessionCreated, SessionCreated,
SessionList, SessionList,
SessionLoaded,
Channel,
ChannelList,
TopicList,
TopicSummary,
} from '../types/protocol' } from '../types/protocol'
// Session 类型
export interface Session {
id: string
title: string
channel_name: string
message_count: number
created_at: number
updated_at: number
}
// 三级状态管理
interface UseChatReturn { interface UseChatReturn {
// 消息
messages: ChatMessage[] messages: ChatMessage[]
currentSessionId: string | null
currentTopicId: string | null
topics: Topic[]
isLoading: boolean isLoading: boolean
// 三级选择状态
channels: Channel[]
selectedChannel: string | null
sessions: Session[]
selectedSession: string | null
topics: Topic[]
selectedTopic: string | null
// 是否只读
isReadOnly: boolean
// 方法
handleMessage: (content: string) => void handleMessage: (content: string) => void
handleCommand: (command: Command) => void handleCommand: (command: Command) => void
clearMessages: () => void clearMessages: () => void
handleServerMessage: (message: WsOutbound) => void handleServerMessage: (message: WsOutbound) => void
// 三级选择方法
selectChannel: (channelId: string) => void
selectSession: (sessionId: string) => void
selectTopic: (topicId: string) => void
} }
export function useChat(): UseChatReturn { export function useChat(): UseChatReturn {
const [messages, setMessages] = useState<ChatMessage[]>([]) const [messages, setMessages] = useState<ChatMessage[]>([])
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null)
const [currentTopicId, setCurrentTopicId] = useState<string | null>(null)
const [topics, setTopics] = useState<Topic[]>([])
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
// 三级选择状态
const [channels, setChannels] = useState<Channel[]>([])
const [selectedChannel, setSelectedChannel] = useState<string | null>(null)
const [sessions, setSessions] = useState<Session[]>([])
const [selectedSession, setSelectedSession] = useState<string | null>(null)
const [topics, setTopics] = useState<Topic[]>([])
const [selectedTopic, setSelectedTopic] = useState<string | null>(null)
// Message ID generator // Message ID generator
const messageIdCounter = useRef(0) const messageIdCounter = useRef(0)
const generateMessageId = () => { const generateMessageId = () => {
@ -40,38 +81,94 @@ export function useChat(): UseChatReturn {
} }
const handleServerMessage = useCallback((message: WsOutbound) => { const handleServerMessage = useCallback((message: WsOutbound) => {
console.log('Received message:', message) // 调试日志
switch (message.type) { switch (message.type) {
case 'session_established': { case 'session_established': {
const msg = message as SessionEstablished const msg = message as SessionEstablished
setCurrentSessionId(msg.session_id) // 不在这里自动选择,等 channel_list 和 session_list
console.log('Session established:', msg.session_id)
break break
} }
case 'session_created': { case 'channel_list': {
const msg = message as SessionCreated const msg = message as ChannelList
setCurrentTopicId(msg.session_id) setChannels(msg.channels)
setIsLoading(false) // 默认选中第一个可写通道
if (!selectedChannel && msg.channels.length > 0) {
const writableChannel = msg.channels.find((c) => c.isWritable)
const defaultChannel = writableChannel || msg.channels[0]
setSelectedChannel(defaultChannel.id)
}
break break
} }
case 'session_list': { case 'session_list': {
const msg = message as SessionList const msg = message as SessionList
// Convert sessions to topics format console.log('Session list received:', msg) // 调试日志
const newTopics = msg.sessions.map((s) => ({ // 按通道筛选 Session
const newSessions = msg.sessions.map((s) => ({
id: s.session_id, id: s.session_id,
session_id: s.session_id,
title: s.title, title: s.title,
channel_name: s.channel_name || msg.channel_name || 'unknown',
message_count: Number(s.message_count), message_count: Number(s.message_count),
created_at: s.last_active_at, created_at: s.last_active_at,
updated_at: s.last_active_at, updated_at: s.last_active_at,
})) }))
setTopics(newTopics) console.log('Parsed sessions:', newSessions) // 调试日志
if (msg.current_session_id) { setSessions(newSessions)
setCurrentTopicId(msg.current_session_id) // 默认选中最新的 Session
if (!selectedSession && newSessions.length > 0) {
setSelectedSession(newSessions[0].id)
} }
break break
} }
case 'session_created': {
const msg = message as SessionCreated
// 添加到 Session 列表
const newSession: Session = {
id: msg.session_id,
title: msg.title,
channel_name: selectedChannel || 'websocket',
message_count: 0,
created_at: Date.now(),
updated_at: Date.now(),
}
setSessions((prev) => [newSession, ...prev])
setSelectedSession(msg.session_id)
setIsLoading(false)
break
}
case 'session_loaded': {
const msg = message as SessionLoaded
setSelectedSession(msg.session_id)
setIsLoading(false)
setMessages([])
break
}
case 'topic_list': {
const msg = message as TopicList
console.log('Topic list received:', msg)
// 转换 topics 格式
const newTopics: Topic[] = msg.topics.map((t: TopicSummary) => ({
id: t.topic_id,
session_id: t.session_id,
title: t.title,
message_count: Number(t.message_count),
created_at: t.created_at,
updated_at: t.last_active_at,
}))
setTopics(newTopics)
// 默认选中第一个 Topic
if (newTopics.length > 0 && !selectedTopic) {
setSelectedTopic(newTopics[0].id)
}
setIsLoading(false)
break
}
case 'assistant_response': { case 'assistant_response': {
const msg = message as AssistantResponse const msg = message as AssistantResponse
setMessages((prev) => [ setMessages((prev) => [
@ -152,10 +249,9 @@ export function useChat(): UseChatReturn {
break break
} }
} }
}, []) }, [selectedChannel, selectedSession])
const handleMessage = useCallback((content: string) => { const handleMessage = useCallback((content: string) => {
// Add user message to list
setMessages((prev) => [ setMessages((prev) => [
...prev, ...prev,
{ {
@ -170,20 +266,25 @@ export function useChat(): UseChatReturn {
}, []) }, [])
const handleCommand = useCallback((command: Command) => { const handleCommand = useCallback((command: Command) => {
// Handle local state updates for commands
switch (command.type) { switch (command.type) {
case 'create_session': case 'create_session':
// Optimistically update
setIsLoading(true) setIsLoading(true)
break break
case 'list_sessions': case 'list_sessions':
case 'list_sessions_by_channel':
setIsLoading(true) setIsLoading(true)
break break
case 'switch_session': case 'switch_session':
setCurrentTopicId(command.session_id) case 'load_session':
// Clear messages when switching topic setIsLoading(true)
setMessages([]) setMessages([])
break break
case 'list_topics':
setIsLoading(true)
break
case 'list_channels':
setIsLoading(true)
break
} }
}, []) }, [])
@ -191,15 +292,53 @@ export function useChat(): UseChatReturn {
setMessages([]) setMessages([])
}, []) }, [])
// 三级选择方法
const selectChannel = useCallback((channelId: string) => {
setSelectedChannel(channelId)
// 切换通道时重置 Session 和 Topic
setSelectedSession(null)
setSelectedTopic(null)
setSessions([])
setTopics([])
setMessages([])
}, [])
const selectSession = useCallback((sessionId: string) => {
setSelectedSession(sessionId)
// 切换 Session 时重置 Topic
setSelectedTopic(null)
setTopics([])
setMessages([])
}, [])
const selectTopic = useCallback((topicId: string) => {
setSelectedTopic(topicId)
setMessages([])
}, [])
// 计算是否只读
const isReadOnly = useMemo(() => {
if (!selectedChannel) return true
const channel = channels.find((c) => c.id === selectedChannel)
return !channel?.isWritable
}, [selectedChannel, channels])
return { return {
messages, messages,
currentSessionId,
currentTopicId,
topics,
isLoading, isLoading,
channels,
selectedChannel,
sessions,
selectedSession,
topics,
selectedTopic,
isReadOnly,
handleMessage, handleMessage,
handleCommand, handleCommand,
clearMessages, clearMessages,
handleServerMessage, handleServerMessage,
selectChannel,
selectSession,
selectTopic,
} }
} }

View File

@ -94,6 +94,7 @@ export interface SessionList {
type: 'session_list' type: 'session_list'
sessions: SessionSummary[] sessions: SessionSummary[]
current_session_id?: string current_session_id?: string
channel_name?: string // 新增:标识所属通道
} }
export interface SessionLoaded { export interface SessionLoaded {
@ -109,6 +110,33 @@ export interface SessionSaved {
filepath: string filepath: string
} }
export interface TopicSummary {
topic_id: string
session_id: string
title: string
message_count: number
created_at: number
last_active_at: number
}
export interface TopicList {
type: 'topic_list'
topics: TopicSummary[]
session_id: string
}
export interface Channel {
id: string
name: string
description?: string
isWritable: boolean
}
export interface ChannelList {
type: 'channel_list'
channels: Channel[]
}
export interface Pong { export interface Pong {
type: 'pong' type: 'pong'
} }
@ -124,6 +152,8 @@ export type WsOutbound =
| SessionList | SessionList
| SessionLoaded | SessionLoaded
| SessionSaved | SessionSaved
| TopicList
| ChannelList
| Pong | Pong
// ============================================================================ // ============================================================================
@ -171,6 +201,21 @@ export interface HelpCommand {
type: 'help' type: 'help'
} }
export interface ListChannelsCommand {
type: 'list_channels'
}
export interface ListSessionsByChannelCommand {
type: 'list_sessions_by_channel'
channel_name: string
include_archived: boolean
}
export interface ListTopicsCommand {
type: 'list_topics'
session_id: string
}
export type Command = export type Command =
| CreateSessionCommand | CreateSessionCommand
| ListSessionsCommand | ListSessionsCommand
@ -180,6 +225,9 @@ export type Command =
| LoadSessionCommand | LoadSessionCommand
| GetCurrentSessionCommand | GetCurrentSessionCommand
| HelpCommand | HelpCommand
| ListChannelsCommand
| ListSessionsByChannelCommand
| ListTopicsCommand
// ============================================================================ // ============================================================================
// UI Types // UI Types