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:
parent
624d8e8943
commit
e9e1439428
@ -80,11 +80,97 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
||||
},
|
||||
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 {
|
||||
session_id: session_id.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 {
|
||||
// 默认通知
|
||||
WsOutbound::AssistantResponse {
|
||||
|
||||
75
src/command/handlers/list_channels.rs
Normal file
75
src/command/handlers/list_channels.rs
Normal 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()))
|
||||
}
|
||||
88
src/command/handlers/list_sessions_by_channel.rs
Normal file
88
src/command/handlers/list_sessions_by_channel.rs
Normal 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()))
|
||||
}
|
||||
96
src/command/handlers/list_topics.rs
Normal file
96
src/command/handlers/list_topics.rs
Normal 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()))
|
||||
}
|
||||
@ -1,6 +1,9 @@
|
||||
pub mod get_current;
|
||||
pub mod help;
|
||||
pub mod list_channels;
|
||||
pub mod list_sessions;
|
||||
pub mod list_sessions_by_channel;
|
||||
pub mod list_topics;
|
||||
pub mod load_session;
|
||||
pub mod save_session;
|
||||
pub mod save_topic;
|
||||
|
||||
@ -34,6 +34,15 @@ pub enum Command {
|
||||
GetCurrentSession,
|
||||
/// 显示所有支持的命令
|
||||
Help,
|
||||
/// 列出所有可用通道
|
||||
ListChannels,
|
||||
/// 列出指定通道的所有会话
|
||||
ListSessionsByChannel {
|
||||
channel_name: String,
|
||||
include_archived: bool,
|
||||
},
|
||||
/// 列出 Session 的所有 Topics
|
||||
ListTopics { session_id: String },
|
||||
}
|
||||
|
||||
impl Command {
|
||||
@ -48,6 +57,9 @@ impl Command {
|
||||
Command::SwitchSession { .. } => "switch_session",
|
||||
Command::GetCurrentSession => "get_current_session",
|
||||
Command::Help => "help",
|
||||
Command::ListChannels => "list_channels",
|
||||
Command::ListSessionsByChannel { .. } => "list_sessions_by_channel",
|
||||
Command::ListTopics { .. } => "list_topics",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,7 +7,10 @@ use crate::command::context::CommandContext;
|
||||
use crate::command::handler::CommandRouter;
|
||||
use crate::command::handlers::get_current::GetCurrentSessionCommandHandler;
|
||||
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_by_channel::ListSessionsByChannelCommandHandler;
|
||||
use crate::command::handlers::list_topics::ListTopicsCommandHandler;
|
||||
use crate::command::handlers::load_session::LoadSessionCommandHandler;
|
||||
use crate::command::handlers::save_session::SaveSessionCommandHandler;
|
||||
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 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,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to create initial CLI session");
|
||||
tracing::error!(error = %e, "Failed to create initial WebSocket session");
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 使用最新的 Session
|
||||
websocket_sessions[0].clone()
|
||||
};
|
||||
|
||||
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(),
|
||||
)
|
||||
.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
|
||||
.send(WsOutbound::SessionEstablished {
|
||||
@ -63,6 +78,42 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
|
||||
})
|
||||
.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 receiver = receiver;
|
||||
@ -234,6 +285,12 @@ async fn handle_inbound(
|
||||
router.register(Box::new(session_handler));
|
||||
// 注册 list_sessions 处理器
|
||||
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 处理器
|
||||
let switch_handler = SwitchSessionCommandHandler::new(store.clone())
|
||||
.with_session_manager(state.session_manager.clone());
|
||||
|
||||
@ -14,6 +14,26 @@ pub struct SessionSummary {
|
||||
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)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum WsInbound {
|
||||
@ -81,6 +101,17 @@ pub enum WsOutbound {
|
||||
sessions: Vec<SessionSummary>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
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")]
|
||||
SessionLoaded {
|
||||
|
||||
149
web/src/App.tsx
149
web/src/App.tsx
@ -1,7 +1,9 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useCallback, useMemo, useEffect } from 'react'
|
||||
import { Zap, Cpu, Activity } from 'lucide-react'
|
||||
import { ChatContainer } from './components/Chat/ChatContainer'
|
||||
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 { ConnectionStatus } from './components/ConnectionStatus'
|
||||
import { useWebSocket } from './hooks/useWebSocket'
|
||||
@ -12,14 +14,24 @@ const WS_URL = 'ws://127.0.0.1:19876/ws'
|
||||
|
||||
function App() {
|
||||
const {
|
||||
// 消息
|
||||
messages,
|
||||
currentSessionId,
|
||||
currentTopicId,
|
||||
topics,
|
||||
isLoading,
|
||||
// 三级状态
|
||||
channels,
|
||||
selectedChannel,
|
||||
sessions,
|
||||
selectedSession,
|
||||
topics,
|
||||
selectedTopic,
|
||||
isReadOnly,
|
||||
// 方法
|
||||
handleMessage,
|
||||
handleCommand,
|
||||
handleServerMessage,
|
||||
selectChannel,
|
||||
selectSession,
|
||||
selectTopic,
|
||||
} = useChat()
|
||||
|
||||
const { status, sendMessage } = useWebSocket({
|
||||
@ -27,8 +39,64 @@ function App() {
|
||||
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(
|
||||
(content: string) => {
|
||||
if (isReadOnly) {
|
||||
return
|
||||
}
|
||||
|
||||
if (content.startsWith('/')) {
|
||||
const parts = content.slice(1).split(' ')
|
||||
const command = parts[0]
|
||||
@ -44,7 +112,7 @@ function App() {
|
||||
break
|
||||
case 'use':
|
||||
if (args[0]) {
|
||||
cmd = { type: 'switch_session', session_id: args[0] }
|
||||
cmd = { type: 'load_session', session_id: args[0] }
|
||||
} else {
|
||||
alert('Usage: /use <session_id>')
|
||||
return
|
||||
@ -65,29 +133,51 @@ function App() {
|
||||
sendMessage({
|
||||
type: 'message',
|
||||
content,
|
||||
chat_id: currentTopicId ?? undefined,
|
||||
chat_id: selectedSession ?? undefined,
|
||||
})
|
||||
}
|
||||
},
|
||||
[sendMessage, handleMessage, handleCommand, currentTopicId]
|
||||
[sendMessage, handleMessage, handleCommand, selectedSession, isReadOnly]
|
||||
)
|
||||
|
||||
const handleCreateTopic = useCallback(() => {
|
||||
if (isReadOnly || !selectedSession) {
|
||||
return
|
||||
}
|
||||
|
||||
const title = prompt('Enter topic title:')
|
||||
if (title) {
|
||||
// TODO: 实现 create_topic 命令
|
||||
// 目前 Session 和 Topic 是同一个概念,简化处理
|
||||
const cmd: Command = { type: 'create_session', title }
|
||||
handleCommand(cmd)
|
||||
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
||||
}
|
||||
}, [sendMessage, handleCommand])
|
||||
}, [sendMessage, handleCommand, selectedSession, isReadOnly])
|
||||
|
||||
const handleSwitchTopic = useCallback(
|
||||
(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)
|
||||
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])
|
||||
@ -112,11 +202,11 @@ function App() {
|
||||
<Cpu className="h-4 w-4 text-[#00f0ff]" />
|
||||
<span>AI Ready</span>
|
||||
</div>
|
||||
{currentSessionId && (
|
||||
{selectedSession && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-emerald-400" />
|
||||
<span className="font-mono text-xs">
|
||||
{currentSessionId.slice(0, 8)}...
|
||||
{selectedSession.slice(0, 8)}...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@ -125,21 +215,50 @@ function App() {
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left Sidebar - Topic List */}
|
||||
<div className="w-72 shrink-0 border-r border-white/8 bg-[#12121a]/50">
|
||||
{/* Left Sidebar - 三级选择器 */}
|
||||
<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
|
||||
sessionId={selectedSession}
|
||||
sessionTitle={selectedSessionTitle}
|
||||
topics={topics}
|
||||
currentTopicId={currentTopicId}
|
||||
currentTopicId={selectedTopic}
|
||||
isReadOnly={isReadOnly}
|
||||
onCreateTopic={handleCreateTopic}
|
||||
onSwitchTopic={handleSwitchTopic}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center - Chat */}
|
||||
<div className="flex-1 min-w-0 bg-[#0a0a0f]">
|
||||
<ChatContainer
|
||||
messages={messages}
|
||||
isLoading={isLoading}
|
||||
isReadOnly={isReadOnly}
|
||||
channelName={currentChannelName}
|
||||
onSendMessage={handleSendMessage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -5,12 +5,16 @@ import type { ChatMessage } from '../../types/protocol'
|
||||
interface ChatContainerProps {
|
||||
messages: ChatMessage[]
|
||||
isLoading: boolean
|
||||
isReadOnly?: boolean
|
||||
channelName?: string
|
||||
onSendMessage: (content: string) => void
|
||||
}
|
||||
|
||||
export function ChatContainer({
|
||||
messages,
|
||||
isLoading,
|
||||
isReadOnly = false,
|
||||
channelName,
|
||||
onSendMessage,
|
||||
}: ChatContainerProps) {
|
||||
return (
|
||||
@ -18,7 +22,12 @@ export function ChatContainer({
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<MessageList messages={messages} />
|
||||
</div>
|
||||
<MessageInput onSend={onSendMessage} disabled={isLoading} />
|
||||
<MessageInput
|
||||
onSend={onSendMessage}
|
||||
disabled={isLoading}
|
||||
isReadOnly={isReadOnly}
|
||||
channelName={channelName}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
interface MessageInputProps {
|
||||
onSend: (content: string) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
isReadOnly?: boolean
|
||||
channelName?: string
|
||||
}
|
||||
|
||||
export function MessageInput({
|
||||
onSend,
|
||||
disabled = false,
|
||||
placeholder = '输入消息...按 / 查看命令',
|
||||
isReadOnly = false,
|
||||
channelName,
|
||||
}: MessageInputProps) {
|
||||
const [content, setContent] = useState('')
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
@ -24,7 +28,7 @@ export function MessageInput({
|
||||
}, [content])
|
||||
|
||||
const handleSend = () => {
|
||||
if (content.trim() && !disabled) {
|
||||
if (content.trim() && !disabled && !isReadOnly) {
|
||||
onSend(content.trim())
|
||||
setContent('')
|
||||
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 (
|
||||
<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">
|
||||
|
||||
127
web/src/components/Sidebar/ChannelSelector.tsx
Normal file
127
web/src/components/Sidebar/ChannelSelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
114
web/src/components/Sidebar/SessionSelector.tsx
Normal file
114
web/src/components/Sidebar/SessionSelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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'
|
||||
|
||||
interface TopicListProps {
|
||||
sessionId: string | null
|
||||
sessionTitle: string
|
||||
topics: Topic[]
|
||||
currentTopicId: string | null
|
||||
isReadOnly: boolean
|
||||
onCreateTopic: () => void
|
||||
onSwitchTopic: (topicId: string) => void
|
||||
}
|
||||
|
||||
export function TopicList({
|
||||
sessionId,
|
||||
sessionTitle,
|
||||
topics,
|
||||
currentTopicId,
|
||||
isReadOnly,
|
||||
onCreateTopic,
|
||||
onSwitchTopic,
|
||||
}: TopicListProps) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between border-b border-white/8 p-4">
|
||||
<h2 className="font-semibold text-white flex items-center gap-2">
|
||||
<Hash className="h-4 w-4 text-[#00f0ff]" />
|
||||
<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">
|
||||
<Layers className="h-4 w-4 text-[#00f0ff]" />
|
||||
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>
|
||||
<button
|
||||
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" />
|
||||
新建
|
||||
</button>
|
||||
</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">
|
||||
{topics.length === 0 ? (
|
||||
{!sessionId ? (
|
||||
<div className="p-4 text-center text-sm text-zinc-500">
|
||||
<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 className="space-y-1">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { useState, useCallback, useRef, useMemo } from 'react'
|
||||
import type {
|
||||
Command,
|
||||
ChatMessage,
|
||||
@ -11,27 +11,68 @@ import type {
|
||||
SessionEstablished,
|
||||
SessionCreated,
|
||||
SessionList,
|
||||
SessionLoaded,
|
||||
Channel,
|
||||
ChannelList,
|
||||
TopicList,
|
||||
TopicSummary,
|
||||
} 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 {
|
||||
// 消息
|
||||
messages: ChatMessage[]
|
||||
currentSessionId: string | null
|
||||
currentTopicId: string | null
|
||||
topics: Topic[]
|
||||
isLoading: boolean
|
||||
|
||||
// 三级选择状态
|
||||
channels: Channel[]
|
||||
selectedChannel: string | null
|
||||
|
||||
sessions: Session[]
|
||||
selectedSession: string | null
|
||||
|
||||
topics: Topic[]
|
||||
selectedTopic: string | null
|
||||
|
||||
// 是否只读
|
||||
isReadOnly: boolean
|
||||
|
||||
// 方法
|
||||
handleMessage: (content: string) => void
|
||||
handleCommand: (command: Command) => void
|
||||
clearMessages: () => void
|
||||
handleServerMessage: (message: WsOutbound) => void
|
||||
|
||||
// 三级选择方法
|
||||
selectChannel: (channelId: string) => void
|
||||
selectSession: (sessionId: string) => void
|
||||
selectTopic: (topicId: string) => void
|
||||
}
|
||||
|
||||
export function useChat(): UseChatReturn {
|
||||
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 [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
|
||||
const messageIdCounter = useRef(0)
|
||||
const generateMessageId = () => {
|
||||
@ -40,38 +81,94 @@ export function useChat(): UseChatReturn {
|
||||
}
|
||||
|
||||
const handleServerMessage = useCallback((message: WsOutbound) => {
|
||||
console.log('Received message:', message) // 调试日志
|
||||
switch (message.type) {
|
||||
case 'session_established': {
|
||||
const msg = message as SessionEstablished
|
||||
setCurrentSessionId(msg.session_id)
|
||||
// 不在这里自动选择,等 channel_list 和 session_list
|
||||
console.log('Session established:', msg.session_id)
|
||||
break
|
||||
}
|
||||
|
||||
case 'session_created': {
|
||||
const msg = message as SessionCreated
|
||||
setCurrentTopicId(msg.session_id)
|
||||
setIsLoading(false)
|
||||
case 'channel_list': {
|
||||
const msg = message as ChannelList
|
||||
setChannels(msg.channels)
|
||||
// 默认选中第一个可写通道
|
||||
if (!selectedChannel && msg.channels.length > 0) {
|
||||
const writableChannel = msg.channels.find((c) => c.isWritable)
|
||||
const defaultChannel = writableChannel || msg.channels[0]
|
||||
setSelectedChannel(defaultChannel.id)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'session_list': {
|
||||
const msg = message as SessionList
|
||||
// Convert sessions to topics format
|
||||
const newTopics = msg.sessions.map((s) => ({
|
||||
console.log('Session list received:', msg) // 调试日志
|
||||
// 按通道筛选 Session
|
||||
const newSessions = msg.sessions.map((s) => ({
|
||||
id: s.session_id,
|
||||
session_id: s.session_id,
|
||||
title: s.title,
|
||||
channel_name: s.channel_name || msg.channel_name || 'unknown',
|
||||
message_count: Number(s.message_count),
|
||||
created_at: s.last_active_at,
|
||||
updated_at: s.last_active_at,
|
||||
}))
|
||||
setTopics(newTopics)
|
||||
if (msg.current_session_id) {
|
||||
setCurrentTopicId(msg.current_session_id)
|
||||
console.log('Parsed sessions:', newSessions) // 调试日志
|
||||
setSessions(newSessions)
|
||||
// 默认选中最新的 Session
|
||||
if (!selectedSession && newSessions.length > 0) {
|
||||
setSelectedSession(newSessions[0].id)
|
||||
}
|
||||
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': {
|
||||
const msg = message as AssistantResponse
|
||||
setMessages((prev) => [
|
||||
@ -152,10 +249,9 @@ export function useChat(): UseChatReturn {
|
||||
break
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}, [selectedChannel, selectedSession])
|
||||
|
||||
const handleMessage = useCallback((content: string) => {
|
||||
// Add user message to list
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
@ -170,20 +266,25 @@ export function useChat(): UseChatReturn {
|
||||
}, [])
|
||||
|
||||
const handleCommand = useCallback((command: Command) => {
|
||||
// Handle local state updates for commands
|
||||
switch (command.type) {
|
||||
case 'create_session':
|
||||
// Optimistically update
|
||||
setIsLoading(true)
|
||||
break
|
||||
case 'list_sessions':
|
||||
case 'list_sessions_by_channel':
|
||||
setIsLoading(true)
|
||||
break
|
||||
case 'switch_session':
|
||||
setCurrentTopicId(command.session_id)
|
||||
// Clear messages when switching topic
|
||||
case 'load_session':
|
||||
setIsLoading(true)
|
||||
setMessages([])
|
||||
break
|
||||
case 'list_topics':
|
||||
setIsLoading(true)
|
||||
break
|
||||
case 'list_channels':
|
||||
setIsLoading(true)
|
||||
break
|
||||
}
|
||||
}, [])
|
||||
|
||||
@ -191,15 +292,53 @@ export function useChat(): UseChatReturn {
|
||||
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 {
|
||||
messages,
|
||||
currentSessionId,
|
||||
currentTopicId,
|
||||
topics,
|
||||
isLoading,
|
||||
channels,
|
||||
selectedChannel,
|
||||
sessions,
|
||||
selectedSession,
|
||||
topics,
|
||||
selectedTopic,
|
||||
isReadOnly,
|
||||
handleMessage,
|
||||
handleCommand,
|
||||
clearMessages,
|
||||
handleServerMessage,
|
||||
selectChannel,
|
||||
selectSession,
|
||||
selectTopic,
|
||||
}
|
||||
}
|
||||
|
||||
@ -94,6 +94,7 @@ export interface SessionList {
|
||||
type: 'session_list'
|
||||
sessions: SessionSummary[]
|
||||
current_session_id?: string
|
||||
channel_name?: string // 新增:标识所属通道
|
||||
}
|
||||
|
||||
export interface SessionLoaded {
|
||||
@ -109,6 +110,33 @@ export interface SessionSaved {
|
||||
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 {
|
||||
type: 'pong'
|
||||
}
|
||||
@ -124,6 +152,8 @@ export type WsOutbound =
|
||||
| SessionList
|
||||
| SessionLoaded
|
||||
| SessionSaved
|
||||
| TopicList
|
||||
| ChannelList
|
||||
| Pong
|
||||
|
||||
// ============================================================================
|
||||
@ -171,6 +201,21 @@ export interface HelpCommand {
|
||||
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 =
|
||||
| CreateSessionCommand
|
||||
| ListSessionsCommand
|
||||
@ -180,6 +225,9 @@ export type Command =
|
||||
| LoadSessionCommand
|
||||
| GetCurrentSessionCommand
|
||||
| HelpCommand
|
||||
| ListChannelsCommand
|
||||
| ListSessionsByChannelCommand
|
||||
| ListTopicsCommand
|
||||
|
||||
// ============================================================================
|
||||
// UI Types
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user