Compare commits
2 Commits
99b6f54f67
...
02339465b6
| Author | SHA1 | Date | |
|---|---|---|---|
| 02339465b6 | |||
| 6f33ec7604 |
@ -1,4 +1,4 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
@ -8,6 +8,7 @@ use crate::channels::cli::CliChannel;
|
|||||||
use crate::channels::feishu::FeishuChannel;
|
use crate::channels::feishu::FeishuChannel;
|
||||||
use crate::channels::wechat::WechatChannel;
|
use crate::channels::wechat::WechatChannel;
|
||||||
use crate::config::{Config, TaggedChannelConfig};
|
use crate::config::{Config, TaggedChannelConfig};
|
||||||
|
use crate::protocol::Channel as ProtocolChannel;
|
||||||
|
|
||||||
/// ChannelManager manages all Channel instances and the MessageBus
|
/// ChannelManager manages all Channel instances and the MessageBus
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@ -135,6 +136,65 @@ impl ChannelManager {
|
|||||||
.map(|(name, channel)| (name.clone(), channel.clone()))
|
.map(|(name, channel)| (name.clone(), channel.clone()))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 构建面向前端的通道列表(合并 websocket + 动态注册的通道)
|
||||||
|
pub async fn build_channel_list(&self) -> Vec<ProtocolChannel> {
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
let mut channels: Vec<ProtocolChannel> = Vec::new();
|
||||||
|
|
||||||
|
// 1. WebSocket 通道 — Web 前端自己的连接,始终存在
|
||||||
|
seen.insert("websocket".to_string());
|
||||||
|
channels.push(ProtocolChannel {
|
||||||
|
id: "websocket".to_string(),
|
||||||
|
name: "WebSocket".to_string(),
|
||||||
|
description: Some("Web 前端通道".to_string()),
|
||||||
|
is_writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 所有动态注册的通道(cli, feishu, wechat 等)
|
||||||
|
for (name, _channel) in self.channels().await {
|
||||||
|
if seen.contains(&name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.insert(name.clone());
|
||||||
|
channels.push(ProtocolChannel {
|
||||||
|
id: name.clone(),
|
||||||
|
name: ChannelManager::channel_display_name(&name),
|
||||||
|
description: ChannelManager::channel_description(&name),
|
||||||
|
is_writable: ChannelManager::is_channel_writable(&name),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
channels
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通道名称 → 显示名称
|
||||||
|
fn channel_display_name(name: &str) -> String {
|
||||||
|
match name {
|
||||||
|
"websocket" => "WebSocket".to_string(),
|
||||||
|
"cli" => "命令行".to_string(),
|
||||||
|
"feishu" => "飞书".to_string(),
|
||||||
|
"wechat" => "微信".to_string(),
|
||||||
|
other => other.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通道名称 → 描述
|
||||||
|
fn channel_description(name: &str) -> Option<String> {
|
||||||
|
match name {
|
||||||
|
"websocket" => Some("Web 前端通道".to_string()),
|
||||||
|
"cli" => Some("命令行终端通道".to_string()),
|
||||||
|
"feishu" => Some("飞书消息通道".to_string()),
|
||||||
|
"wechat" => Some("微信消息通道".to_string()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断通道是否可写(从 Web 前端视角)
|
||||||
|
fn is_channel_writable(name: &str) -> bool {
|
||||||
|
// 只有 WebSocket 通道可写,其他通道(CLI、飞书、微信等)均为只读
|
||||||
|
name == "websocket"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@ -1,34 +1,19 @@
|
|||||||
|
use crate::channels::manager::ChannelManager;
|
||||||
use crate::command::context::CommandContext;
|
use crate::command::context::CommandContext;
|
||||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||||
use crate::command::Command;
|
use crate::command::Command;
|
||||||
use crate::protocol::Channel;
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
/// 列出通道命令处理器
|
/// 列出通道命令处理器
|
||||||
pub struct ListChannelsCommandHandler;
|
pub struct ListChannelsCommandHandler {
|
||||||
|
channel_manager: Arc<ChannelManager>,
|
||||||
|
}
|
||||||
|
|
||||||
impl ListChannelsCommandHandler {
|
impl ListChannelsCommandHandler {
|
||||||
pub fn new() -> Self {
|
pub fn new(channel_manager: Arc<ChannelManager>) -> Self {
|
||||||
Self
|
Self { channel_manager }
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取默认通道列表(公开供其他模块使用)
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,16 +37,17 @@ impl CommandHandler for ListChannelsCommandHandler {
|
|||||||
ctx: CommandContext,
|
ctx: CommandContext,
|
||||||
) -> Result<CommandResponse, CommandError> {
|
) -> Result<CommandResponse, CommandError> {
|
||||||
match cmd {
|
match cmd {
|
||||||
Command::ListChannels => handle_list_channels(ctx).await,
|
Command::ListChannels => handle_list_channels(self, ctx).await,
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_list_channels(
|
async fn handle_list_channels(
|
||||||
|
handler: &ListChannelsCommandHandler,
|
||||||
ctx: CommandContext,
|
ctx: CommandContext,
|
||||||
) -> Result<CommandResponse, CommandError> {
|
) -> Result<CommandResponse, CommandError> {
|
||||||
let channels = ListChannelsCommandHandler::get_default_channels();
|
let channels = handler.channel_manager.build_channel_list().await;
|
||||||
|
|
||||||
let channels_json = serde_json::to_string(&channels)
|
let channels_json = serde_json::to_string(&channels)
|
||||||
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
|
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
|
||||||
|
|||||||
@ -618,6 +618,31 @@ impl SessionManager {
|
|||||||
topic_title = %latest_topic.title,
|
topic_title = %latest_topic.title,
|
||||||
"Restored current topic from database"
|
"Restored current topic from database"
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
// 数据库中也没有话题,自动创建默认话题
|
||||||
|
let title = format!(
|
||||||
|
"话题 {}",
|
||||||
|
chrono::Local::now().format("%m/%d %H:%M")
|
||||||
|
);
|
||||||
|
match self.store.create_topic(&session_id, &title, None) {
|
||||||
|
Ok(topic) => {
|
||||||
|
guard.set_current_topic(chat_id, Some(topic.id.clone()));
|
||||||
|
tracing::info!(
|
||||||
|
chat_id = %chat_id,
|
||||||
|
topic_id = %topic.id,
|
||||||
|
topic_title = %topic.title,
|
||||||
|
session_id = %session_id,
|
||||||
|
"Auto-created default topic for new chat"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
error = %e,
|
||||||
|
session_id = %session_id,
|
||||||
|
"Failed to auto-create default topic"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -164,8 +164,8 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// 连接建立后立即发送通道列表
|
// 连接建立后立即发送通道列表(合并 websocket + ChannelManager 动态通道)
|
||||||
let channels = ListChannelsCommandHandler::get_default_channels();
|
let channels = state.channel_manager.build_channel_list().await;
|
||||||
let _ = sender
|
let _ = sender
|
||||||
.send(WsOutbound::ChannelList { channels })
|
.send(WsOutbound::ChannelList { channels })
|
||||||
.await;
|
.await;
|
||||||
@ -378,7 +378,7 @@ async fn handle_inbound(
|
|||||||
// 注册 list_sessions_by_channel 处理器
|
// 注册 list_sessions_by_channel 处理器
|
||||||
router.register(Box::new(ListSessionsByChannelCommandHandler::new(store.clone())));
|
router.register(Box::new(ListSessionsByChannelCommandHandler::new(store.clone())));
|
||||||
// 注册 list_channels 处理器
|
// 注册 list_channels 处理器
|
||||||
router.register(Box::new(ListChannelsCommandHandler::new()));
|
router.register(Box::new(ListChannelsCommandHandler::new(Arc::new(state.channel_manager.clone()))));
|
||||||
// 注册 list_topics 处理器
|
// 注册 list_topics 处理器
|
||||||
router.register(Box::new(ListTopicsCommandHandler::new(store.clone())));
|
router.register(Box::new(ListTopicsCommandHandler::new(store.clone())));
|
||||||
// 注册 switch_topic 处理器
|
// 注册 switch_topic 处理器
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Zap, Cpu, MessageSquare, ArrowLeft, Bot, Clock, Sun, Moon } from 'lucide-react'
|
import { Zap, ArrowLeft, Bot, Clock, Sun, Moon } 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 { SessionInfo } from './components/Sidebar/SessionInfo'
|
|
||||||
import { SchedulerJobList } from './components/Sidebar/SchedulerJobList'
|
import { SchedulerJobList } from './components/Sidebar/SchedulerJobList'
|
||||||
import { ToolPanel } from './components/Panel/ToolPanel'
|
import { ToolPanel } from './components/Panel/ToolPanel'
|
||||||
import { ConnectionStatus } from './components/ConnectionStatus'
|
import { ConnectionStatus } from './components/ConnectionStatus'
|
||||||
|
import { ChannelSelector } from './components/Header/ChannelSelector'
|
||||||
|
import { SessionSelector } from './components/Header/SessionSelector'
|
||||||
import { useWebSocket } from './hooks/useWebSocket'
|
import { useWebSocket } from './hooks/useWebSocket'
|
||||||
import { useChat } from './hooks/useChat'
|
import { useChat } from './hooks/useChat'
|
||||||
import type { ChatMessage, Command, Attachment, SchedulerJobSessionLookup } from './types/protocol'
|
import type { ChatMessage, Command, Attachment, SchedulerJobSessionLookup } from './types/protocol'
|
||||||
@ -17,7 +18,6 @@ function App() {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
// 连接状态
|
// 连接状态
|
||||||
connectionId,
|
|
||||||
isConnected,
|
isConnected,
|
||||||
// Session 状态
|
// Session 状态
|
||||||
session,
|
session,
|
||||||
@ -40,6 +40,15 @@ function App() {
|
|||||||
schedulerView,
|
schedulerView,
|
||||||
enterSchedulerJobView,
|
enterSchedulerJobView,
|
||||||
exitSchedulerJobView,
|
exitSchedulerJobView,
|
||||||
|
// 通道
|
||||||
|
channels,
|
||||||
|
selectedChannel,
|
||||||
|
selectChannel,
|
||||||
|
requestChannelList,
|
||||||
|
// Session
|
||||||
|
sessions,
|
||||||
|
selectedSessionId,
|
||||||
|
selectSession,
|
||||||
// 方法
|
// 方法
|
||||||
handleMessage,
|
handleMessage,
|
||||||
handleCommand,
|
handleCommand,
|
||||||
@ -85,15 +94,23 @@ function App() {
|
|||||||
|
|
||||||
// ---- WebSocket 初始化 ----
|
// ---- WebSocket 初始化 ----
|
||||||
|
|
||||||
// 连接建立后自动加载 Session
|
// Step 1: 连接建立后先请求通道列表
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isConnected && status === 'connected') {
|
if (isConnected && status === 'connected') {
|
||||||
// 1. 请求 Session 列表(会自动选择第一个)
|
const cmd = requestChannelList()
|
||||||
|
handleCommand(cmd)
|
||||||
|
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
||||||
|
}
|
||||||
|
}, [isConnected, status, handleCommand, sendMessage, requestChannelList])
|
||||||
|
|
||||||
|
// Step 2: 通道列表加载后,请求选中通道的 Session 列表
|
||||||
|
useEffect(() => {
|
||||||
|
if (channels.length > 0 && status === 'connected') {
|
||||||
const sessionCmd = requestSessionList()
|
const sessionCmd = requestSessionList()
|
||||||
handleCommand(sessionCmd)
|
handleCommand(sessionCmd)
|
||||||
sendMessage({ type: 'command', payload: JSON.stringify(sessionCmd) })
|
sendMessage({ type: 'command', payload: JSON.stringify(sessionCmd) })
|
||||||
}
|
}
|
||||||
}, [isConnected, status, handleCommand, sendMessage, requestSessionList])
|
}, [channels.length, status, handleCommand, sendMessage, requestSessionList])
|
||||||
|
|
||||||
// Session 加载后自动加载 Topics
|
// Session 加载后自动加载 Topics
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -193,6 +210,15 @@ function App() {
|
|||||||
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
||||||
}, [sendMessage, handleCommand, createTopic, sessionId, isReadOnly])
|
}, [sendMessage, handleCommand, createTopic, sessionId, isReadOnly])
|
||||||
|
|
||||||
|
const handleRefreshTopics = useCallback(() => {
|
||||||
|
if (!sessionId) return
|
||||||
|
const cmd = requestTopicList()
|
||||||
|
if (cmd) {
|
||||||
|
handleCommand(cmd)
|
||||||
|
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
||||||
|
}
|
||||||
|
}, [sessionId, requestTopicList, handleCommand, sendMessage])
|
||||||
|
|
||||||
const handleSwitchTopic = useCallback(
|
const handleSwitchTopic = useCallback(
|
||||||
(topicId: string) => {
|
(topicId: string) => {
|
||||||
selectTopic(topicId)
|
selectTopic(topicId)
|
||||||
@ -244,6 +270,24 @@ function App() {
|
|||||||
exitSchedulerJobView()
|
exitSchedulerJobView()
|
||||||
}, [exitSchedulerJobView])
|
}, [exitSchedulerJobView])
|
||||||
|
|
||||||
|
const handleSwitchChannel = useCallback(
|
||||||
|
(channelId: string) => {
|
||||||
|
if (channelId === selectedChannel) return
|
||||||
|
lastAutoSwitchedTopicRef.current = null
|
||||||
|
selectChannel(channelId)
|
||||||
|
},
|
||||||
|
[selectedChannel, selectChannel]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSelectSession = useCallback(
|
||||||
|
(sessionId: string) => {
|
||||||
|
if (sessionId === selectedSessionId) return
|
||||||
|
lastAutoSwitchedTopicRef.current = null
|
||||||
|
selectSession(sessionId)
|
||||||
|
},
|
||||||
|
[selectedSessionId, selectSession]
|
||||||
|
)
|
||||||
|
|
||||||
const chatMessages = useMemo(() => {
|
const chatMessages = useMemo(() => {
|
||||||
const result: ChatMessage[] = []
|
const result: ChatMessage[] = []
|
||||||
const toolCallIndex = new Map<string, number>()
|
const toolCallIndex = new Map<string, number>()
|
||||||
@ -312,18 +356,16 @@ function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-sm text-[var(--text-secondary)]">
|
<div className="flex items-center gap-4 text-sm text-[var(--text-secondary)]">
|
||||||
<div className="flex items-center gap-2">
|
<ChannelSelector
|
||||||
<Cpu className="h-4 w-4 text-[var(--accent-cyan)]" />
|
channels={channels}
|
||||||
<span>AI Ready</span>
|
selectedChannel={selectedChannel}
|
||||||
</div>
|
onSelectChannel={handleSwitchChannel}
|
||||||
{session && (
|
/>
|
||||||
<div className="flex items-center gap-2">
|
<SessionSelector
|
||||||
<MessageSquare className="h-4 w-4 text-emerald-400" />
|
sessions={sessions}
|
||||||
<span className="font-mono text-xs">
|
selectedSessionId={selectedSessionId}
|
||||||
{session.title}
|
onSelectSession={handleSelectSession}
|
||||||
</span>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -331,12 +373,6 @@ function App() {
|
|||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* Left Sidebar */}
|
{/* Left Sidebar */}
|
||||||
<div className={`w-72 shrink-0 border-r border-[var(--border-color)] bg-[var(--bg-secondary)]/50 flex flex-col ${subAgentView || schedulerView ? 'opacity-50 pointer-events-none' : ''}`}>
|
<div className={`w-72 shrink-0 border-r border-[var(--border-color)] bg-[var(--bg-secondary)]/50 flex flex-col ${subAgentView || schedulerView ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||||
<SessionInfo
|
|
||||||
session={session}
|
|
||||||
connectionId={connectionId}
|
|
||||||
/>
|
|
||||||
<div className="border-b border-[var(--border-color)]" />
|
|
||||||
|
|
||||||
{/* Tab 栏 */}
|
{/* Tab 栏 */}
|
||||||
<div className="flex border-b border-[var(--border-color)]">
|
<div className="flex border-b border-[var(--border-color)]">
|
||||||
<button
|
<button
|
||||||
@ -365,11 +401,11 @@ function App() {
|
|||||||
{sidebarTab === 'topics' ? (
|
{sidebarTab === 'topics' ? (
|
||||||
<TopicList
|
<TopicList
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
sessionTitle={session?.title ?? ''}
|
|
||||||
topics={topics}
|
topics={topics}
|
||||||
currentTopicId={selectedTopic}
|
currentTopicId={selectedTopic}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
onCreateTopic={handleCreateTopic}
|
onCreateTopic={handleCreateTopic}
|
||||||
|
onRefresh={handleRefreshTopics}
|
||||||
onSwitchTopic={handleSwitchTopic}
|
onSwitchTopic={handleSwitchTopic}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@ -457,7 +493,7 @@ function App() {
|
|||||||
channelName={
|
channelName={
|
||||||
schedulerView ? `定时任务: ${schedulerView.description}` :
|
schedulerView ? `定时任务: ${schedulerView.description}` :
|
||||||
subAgentView ? `子智能体: ${subAgentView.description}` :
|
subAgentView ? `子智能体: ${subAgentView.description}` :
|
||||||
(session?.title ?? 'PicoBot')
|
(session?.title ?? channels.find(c => c.id === selectedChannel)?.name ?? 'PicoBot')
|
||||||
}
|
}
|
||||||
onSendMessage={subAgentView || schedulerView ? () => {} : handleSendMessage}
|
onSendMessage={subAgentView || schedulerView ? () => {} : handleSendMessage}
|
||||||
onNavigateToSubAgent={handleNavigateToSubAgent}
|
onNavigateToSubAgent={handleNavigateToSubAgent}
|
||||||
|
|||||||
260
web/src/components/Header/ChannelSelector.tsx
Normal file
260
web/src/components/Header/ChannelSelector.tsx
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Monitor, MessageSquare, ChevronDown, Eye, Pencil, Smartphone } from 'lucide-react'
|
||||||
|
import type { Channel } from '../../types/protocol'
|
||||||
|
|
||||||
|
interface ChannelSelectorProps {
|
||||||
|
channels: Channel[]
|
||||||
|
selectedChannel: string
|
||||||
|
onSelectChannel: (channelId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHANNEL_ICONS: Record<string, { icon: React.ReactNode; color: string }> = {
|
||||||
|
cli: {
|
||||||
|
icon: <Monitor className="h-3.5 w-3.5" />,
|
||||||
|
color: 'var(--accent-amber)',
|
||||||
|
},
|
||||||
|
websocket: {
|
||||||
|
icon: <MessageSquare className="h-3.5 w-3.5" />,
|
||||||
|
color: 'var(--accent-cyan)',
|
||||||
|
},
|
||||||
|
feishu: {
|
||||||
|
icon: <Smartphone className="h-3.5 w-3.5" />,
|
||||||
|
color: 'var(--accent-blue)',
|
||||||
|
},
|
||||||
|
wechat: {
|
||||||
|
icon: <Smartphone className="h-3.5 w-3.5" />,
|
||||||
|
color: 'var(--accent-green)',
|
||||||
|
},
|
||||||
|
weixin: {
|
||||||
|
icon: <Smartphone className="h-3.5 w-3.5" />,
|
||||||
|
color: 'var(--accent-green)',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_ICON = {
|
||||||
|
icon: <MessageSquare className="h-3.5 w-3.5" />,
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChannelSelector({
|
||||||
|
channels,
|
||||||
|
selectedChannel,
|
||||||
|
onSelectChannel,
|
||||||
|
}: ChannelSelectorProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [dropdownPos, setDropdownPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 })
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
const selected = channels.find((c) => c.id === selectedChannel)
|
||||||
|
const iconConfig = selected ? (CHANNEL_ICONS[selected.id] || DEFAULT_ICON) : DEFAULT_ICON
|
||||||
|
|
||||||
|
// Calculate dropdown position when opening
|
||||||
|
const updatePosition = useCallback(() => {
|
||||||
|
if (triggerRef.current) {
|
||||||
|
const rect = triggerRef.current.getBoundingClientRect()
|
||||||
|
setDropdownPos({
|
||||||
|
top: rect.bottom + 8,
|
||||||
|
right: window.innerWidth - rect.right,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
updatePosition()
|
||||||
|
window.addEventListener('resize', updatePosition)
|
||||||
|
window.addEventListener('scroll', updatePosition, true)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', updatePosition)
|
||||||
|
window.removeEventListener('scroll', updatePosition, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, updatePosition])
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
const target = e.target as Node
|
||||||
|
if (
|
||||||
|
dropdownRef.current && !dropdownRef.current.contains(target) &&
|
||||||
|
triggerRef.current && !triggerRef.current.contains(target)
|
||||||
|
) {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClick)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick)
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
// Close on Escape
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setIsOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleKey)
|
||||||
|
return () => document.removeEventListener('keydown', handleKey)
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Trigger Button */}
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={`
|
||||||
|
group flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm w-44 justify-between
|
||||||
|
transition-all duration-200 ease-out
|
||||||
|
${isOpen
|
||||||
|
? 'border-[var(--accent-cyan)]/40 bg-[var(--overlay-subtle)] shadow-[0_0_12px_var(--shadow-glow-sm)]'
|
||||||
|
: 'border-[var(--border-color)] hover:border-[var(--border-accent)] hover:bg-[var(--overlay-hover)]'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Channel icon */}
|
||||||
|
<span
|
||||||
|
className="transition-colors duration-200 flex-shrink-0"
|
||||||
|
style={{ color: iconConfig.color }}
|
||||||
|
>
|
||||||
|
{iconConfig.icon}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Channel name */}
|
||||||
|
<span className="flex-1 min-w-0 text-[var(--text-primary)] font-medium text-xs tracking-wide truncate text-center">
|
||||||
|
{selected?.name || '选择通道'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Right indicators */}
|
||||||
|
<span className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
{selected ? (
|
||||||
|
<span
|
||||||
|
className={`h-1.5 w-1.5 rounded-full ${
|
||||||
|
selected.isWritable ? 'bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.5)]' : 'bg-zinc-500'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="w-1.5" />
|
||||||
|
)}
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-3.5 w-3.5 text-[var(--text-muted)] transition-transform duration-200 ${
|
||||||
|
isOpen ? 'rotate-180' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown Panel — rendered to body via Portal to avoid stacking context clipping */}
|
||||||
|
{isOpen && createPortal(
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
className="fixed z-[9999] w-56 animate-slide-in"
|
||||||
|
style={{ top: dropdownPos.top, right: dropdownPos.right }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
rounded-xl border border-[var(--border-color)]
|
||||||
|
bg-[var(--bg-secondary)]/95 backdrop-blur-xl
|
||||||
|
shadow-2xl shadow-black/40
|
||||||
|
overflow-hidden
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{/* Channel List */}
|
||||||
|
<div className="py-1">
|
||||||
|
{channels.length === 0 ? (
|
||||||
|
<div className="px-4 py-6 text-center text-xs text-[var(--text-muted)]">
|
||||||
|
暂无可用通道
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
channels.map((channel, index) => {
|
||||||
|
const cfg = CHANNEL_ICONS[channel.id] || DEFAULT_ICON
|
||||||
|
const isActive = channel.id === selectedChannel
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={channel.id}
|
||||||
|
onClick={() => {
|
||||||
|
onSelectChannel(channel.id)
|
||||||
|
setIsOpen(false)
|
||||||
|
}}
|
||||||
|
className={`
|
||||||
|
group/item relative w-full flex items-center gap-3 px-4 py-2.5 text-left
|
||||||
|
transition-all duration-150
|
||||||
|
hover:bg-[var(--overlay-hover)]
|
||||||
|
${isActive ? 'bg-[var(--overlay-subtle)]' : ''}
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
animationDelay: `${index * 40}ms`,
|
||||||
|
animation: 'fade-in 0.2s ease-out both',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Left accent bar */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 rounded-r-full
|
||||||
|
transition-all duration-200
|
||||||
|
${isActive ? 'bg-[var(--accent-cyan)] shadow-[0_0_8px_var(--accent-cyan)]' : 'bg-transparent'}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<span
|
||||||
|
className={`
|
||||||
|
flex-shrink-0 transition-colors duration-200
|
||||||
|
${isActive ? 'opacity-100' : 'opacity-60 group-hover/item:opacity-100'}
|
||||||
|
`}
|
||||||
|
style={{ color: cfg.color }}
|
||||||
|
>
|
||||||
|
{cfg.icon}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Name + Description */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div
|
||||||
|
className={`text-sm truncate transition-colors duration-200 ${
|
||||||
|
isActive
|
||||||
|
? 'text-[var(--text-primary)] font-medium'
|
||||||
|
: 'text-[var(--text-secondary)] group-hover/item:text-[var(--text-primary)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{channel.name}
|
||||||
|
</div>
|
||||||
|
{channel.description && (
|
||||||
|
<div className="text-[10px] text-[var(--text-muted)] truncate mt-0.5">
|
||||||
|
{channel.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Writable badge */}
|
||||||
|
<span
|
||||||
|
className={`
|
||||||
|
flex-shrink-0 flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px]
|
||||||
|
transition-all duration-200
|
||||||
|
${channel.isWritable
|
||||||
|
? 'bg-emerald-400/10 text-emerald-400'
|
||||||
|
: 'bg-zinc-500/10 text-zinc-500'
|
||||||
|
}
|
||||||
|
${isActive && channel.isWritable ? 'bg-emerald-400/15' : ''}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{channel.isWritable ? (
|
||||||
|
<Pencil className="h-2.5 w-2.5" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-2.5 w-2.5" />
|
||||||
|
)}
|
||||||
|
<span>{channel.isWritable ? '可写' : '只读'}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
176
web/src/components/Header/SessionSelector.tsx
Normal file
176
web/src/components/Header/SessionSelector.tsx
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { MessageSquare, ChevronDown } from 'lucide-react'
|
||||||
|
import type { SessionSummary } from '../../types/protocol'
|
||||||
|
|
||||||
|
interface SessionSelectorProps {
|
||||||
|
sessions: SessionSummary[]
|
||||||
|
selectedSessionId: string | null
|
||||||
|
onSelectSession: (sessionId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionSelector({
|
||||||
|
sessions,
|
||||||
|
selectedSessionId,
|
||||||
|
onSelectSession,
|
||||||
|
}: SessionSelectorProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [dropdownPos, setDropdownPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 })
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
const selected = sessions.find((s) => s.session_id === selectedSessionId)
|
||||||
|
|
||||||
|
const updatePosition = useCallback(() => {
|
||||||
|
if (triggerRef.current) {
|
||||||
|
const rect = triggerRef.current.getBoundingClientRect()
|
||||||
|
setDropdownPos({
|
||||||
|
top: rect.bottom + 8,
|
||||||
|
right: window.innerWidth - rect.right,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
updatePosition()
|
||||||
|
window.addEventListener('resize', updatePosition)
|
||||||
|
window.addEventListener('scroll', updatePosition, true)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', updatePosition)
|
||||||
|
window.removeEventListener('scroll', updatePosition, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, updatePosition])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
const target = e.target as Node
|
||||||
|
if (
|
||||||
|
dropdownRef.current && !dropdownRef.current.contains(target) &&
|
||||||
|
triggerRef.current && !triggerRef.current.contains(target)
|
||||||
|
) {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClick)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick)
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setIsOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleKey)
|
||||||
|
return () => document.removeEventListener('keydown', handleKey)
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
if (sessions.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
disabled={sessions.length <= 1}
|
||||||
|
className={`
|
||||||
|
group flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm w-44 justify-between
|
||||||
|
transition-all duration-200 ease-out
|
||||||
|
${sessions.length <= 1
|
||||||
|
? 'border-[var(--border-color)] cursor-default'
|
||||||
|
: isOpen
|
||||||
|
? 'border-[var(--accent-cyan)]/40 bg-[var(--overlay-subtle)] shadow-[0_0_12px_var(--shadow-glow-sm)]'
|
||||||
|
: 'border-[var(--border-color)] hover:border-[var(--border-accent)] hover:bg-[var(--overlay-hover)] cursor-pointer'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-3.5 w-3.5 flex-shrink-0 text-[var(--accent-cyan)]" />
|
||||||
|
<span className="flex-1 min-w-0 text-[var(--text-primary)] font-medium text-xs tracking-wide truncate text-center">
|
||||||
|
{selected?.title || '无会话'}
|
||||||
|
</span>
|
||||||
|
{sessions.length > 1 ? (
|
||||||
|
<span className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<span className="text-[10px] text-[var(--text-muted)]">{sessions.length}</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-3.5 w-3.5 text-[var(--text-muted)] transition-transform duration-200 ${
|
||||||
|
isOpen ? 'rotate-180' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
/* 占位保持图标位置一致 */
|
||||||
|
<span className="w-4 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && sessions.length > 1 && createPortal(
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
className="fixed z-[9999] w-56 animate-slide-in"
|
||||||
|
style={{ top: dropdownPos.top, right: dropdownPos.right }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
rounded-xl border border-[var(--border-color)]
|
||||||
|
bg-[var(--bg-secondary)]/95 backdrop-blur-xl
|
||||||
|
shadow-2xl shadow-black/40
|
||||||
|
overflow-hidden
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div className="py-1 max-h-60 overflow-y-auto">
|
||||||
|
{sessions.map((s, index) => {
|
||||||
|
const isActive = s.session_id === selectedSessionId
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s.session_id}
|
||||||
|
onClick={() => {
|
||||||
|
onSelectSession(s.session_id)
|
||||||
|
setIsOpen(false)
|
||||||
|
}}
|
||||||
|
className={`
|
||||||
|
group/item relative w-full flex items-center gap-3 px-4 py-2.5 text-left
|
||||||
|
transition-all duration-150
|
||||||
|
hover:bg-[var(--overlay-hover)]
|
||||||
|
${isActive ? 'bg-[var(--overlay-subtle)]' : ''}
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
animationDelay: `${index * 40}ms`,
|
||||||
|
animation: 'fade-in 0.2s ease-out both',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 rounded-r-full
|
||||||
|
transition-all duration-200
|
||||||
|
${isActive ? 'bg-[var(--accent-cyan)] shadow-[0_0_8px_var(--accent-cyan)]' : 'bg-transparent'}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
<MessageSquare
|
||||||
|
className={`h-3.5 w-3.5 flex-shrink-0 transition-colors duration-200 ${
|
||||||
|
isActive ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)] group-hover/item:text-[var(--text-secondary)]'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div
|
||||||
|
className={`text-sm truncate transition-colors duration-200 ${
|
||||||
|
isActive ? 'text-[var(--text-primary)] font-medium' : 'text-[var(--text-secondary)] group-hover/item:text-[var(--text-primary)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-[var(--text-muted)] flex-shrink-0">
|
||||||
|
{s.message_count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,13 +1,13 @@
|
|||||||
import { Plus, MessageSquare, Layers, Hash, Clock } from 'lucide-react'
|
import { Plus, MessageSquare, Layers, Hash, Clock, RefreshCw } from 'lucide-react'
|
||||||
import type { Topic } from '../../types/protocol'
|
import type { Topic } from '../../types/protocol'
|
||||||
|
|
||||||
interface TopicListProps {
|
interface TopicListProps {
|
||||||
sessionId: string | null
|
sessionId: string | null
|
||||||
sessionTitle: string
|
|
||||||
topics: Topic[]
|
topics: Topic[]
|
||||||
currentTopicId: string | null
|
currentTopicId: string | null
|
||||||
isReadOnly: boolean
|
isReadOnly: boolean
|
||||||
onCreateTopic: () => void
|
onCreateTopic: () => void
|
||||||
|
onRefresh: () => void
|
||||||
onSwitchTopic: (topicId: string) => void
|
onSwitchTopic: (topicId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -29,11 +29,11 @@ function formatTime(timestamp: number): string {
|
|||||||
|
|
||||||
export function TopicList({
|
export function TopicList({
|
||||||
sessionId,
|
sessionId,
|
||||||
sessionTitle,
|
|
||||||
topics,
|
topics,
|
||||||
currentTopicId,
|
currentTopicId,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
onCreateTopic,
|
onCreateTopic,
|
||||||
|
onRefresh,
|
||||||
onSwitchTopic,
|
onSwitchTopic,
|
||||||
}: TopicListProps) {
|
}: TopicListProps) {
|
||||||
return (
|
return (
|
||||||
@ -47,11 +47,26 @@ export function TopicList({
|
|||||||
<span className="text-xs text-[var(--text-muted)]">({topics.length})</span>
|
<span className="text-xs text-[var(--text-muted)]">({topics.length})</span>
|
||||||
)}
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
|
{isReadOnly ? (
|
||||||
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={!sessionId}
|
||||||
|
className={`flex items-center gap-1 rounded-lg px-3 py-1.5 text-sm transition-all ${
|
||||||
|
!sessionId
|
||||||
|
? 'bg-[var(--overlay-subtle)] text-[var(--text-muted)] cursor-not-allowed'
|
||||||
|
: 'bg-[var(--overlay-subtle)] text-[var(--text-secondary)] hover:bg-[var(--overlay-medium)] hover:text-[var(--accent-cyan)] border border-[var(--border-color)]'
|
||||||
|
}`}
|
||||||
|
title="刷新话题列表"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={onCreateTopic}
|
onClick={onCreateTopic}
|
||||||
disabled={isReadOnly || !sessionId}
|
disabled={!sessionId}
|
||||||
className={`flex items-center gap-1 rounded-lg px-3 py-1.5 text-sm transition-all ${
|
className={`flex items-center gap-1 rounded-lg px-3 py-1.5 text-sm transition-all ${
|
||||||
isReadOnly || !sessionId
|
!sessionId
|
||||||
? 'bg-[var(--overlay-subtle)] text-[var(--text-muted)] cursor-not-allowed'
|
? 'bg-[var(--overlay-subtle)] text-[var(--text-muted)] cursor-not-allowed'
|
||||||
: 'bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 border border-[var(--accent-cyan)]/30'
|
: 'bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 border border-[var(--accent-cyan)]/30'
|
||||||
}`}
|
}`}
|
||||||
@ -59,15 +74,8 @@ export function TopicList({
|
|||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
新建
|
新建
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Session 标题 */}
|
|
||||||
{sessionTitle && (
|
|
||||||
<div className="px-4 py-2 border-b border-[var(--border-color)] bg-[var(--accent-cyan)]/5">
|
|
||||||
<p className="text-xs text-[var(--text-muted)] mb-1">所属会话</p>
|
|
||||||
<p className="text-sm text-[var(--text-secondary)] font-medium truncate">{sessionTitle}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Topics 列表 */}
|
{/* Topics 列表 */}
|
||||||
<div className="flex-1 overflow-y-auto p-3">
|
<div className="flex-1 overflow-y-auto p-3">
|
||||||
|
|||||||
@ -10,15 +10,17 @@ import type {
|
|||||||
ToolPending,
|
ToolPending,
|
||||||
SessionEstablished,
|
SessionEstablished,
|
||||||
SessionList,
|
SessionList,
|
||||||
|
SessionSummary,
|
||||||
TopicList,
|
TopicList,
|
||||||
TopicSummary,
|
TopicSummary,
|
||||||
Session,
|
|
||||||
TaskMessagesLoaded,
|
TaskMessagesLoaded,
|
||||||
TaskStarted,
|
TaskStarted,
|
||||||
Attachment,
|
Attachment,
|
||||||
SchedulerJobList,
|
SchedulerJobList,
|
||||||
SchedulerJobSummary,
|
SchedulerJobSummary,
|
||||||
SchedulerJobSessionLookup,
|
SchedulerJobSessionLookup,
|
||||||
|
Channel,
|
||||||
|
ChannelList,
|
||||||
} from '../types/protocol'
|
} from '../types/protocol'
|
||||||
|
|
||||||
// 简化后的层级状态
|
// 简化后的层级状态
|
||||||
@ -28,7 +30,9 @@ interface UseChatReturn {
|
|||||||
isConnected: boolean
|
isConnected: boolean
|
||||||
|
|
||||||
// 简化的层级状态
|
// 简化的层级状态
|
||||||
session: Session | null
|
sessions: SessionSummary[]
|
||||||
|
selectedSessionId: string | null
|
||||||
|
session: SessionSummary | null
|
||||||
sessionId: string | null
|
sessionId: string | null
|
||||||
chatId: string
|
chatId: string
|
||||||
topics: Topic[]
|
topics: Topic[]
|
||||||
@ -38,7 +42,12 @@ interface UseChatReturn {
|
|||||||
messages: ChatMessage[]
|
messages: ChatMessage[]
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
|
|
||||||
// 是否只读(WebSocket 通道始终可写)
|
// 通道状态
|
||||||
|
channels: Channel[]
|
||||||
|
selectedChannel: string
|
||||||
|
isWritable: boolean
|
||||||
|
|
||||||
|
// 是否只读
|
||||||
isReadOnly: boolean
|
isReadOnly: boolean
|
||||||
|
|
||||||
// 子智能体视图
|
// 子智能体视图
|
||||||
@ -58,6 +67,9 @@ interface UseChatReturn {
|
|||||||
// 初始化方法
|
// 初始化方法
|
||||||
requestSessionList: () => Command
|
requestSessionList: () => Command
|
||||||
requestTopicList: () => Command | null
|
requestTopicList: () => Command | null
|
||||||
|
requestChannelList: () => Command
|
||||||
|
selectChannel: (channelId: string) => void
|
||||||
|
selectSession: (sessionId: string) => void
|
||||||
|
|
||||||
// 子智能体导航方法
|
// 子智能体导航方法
|
||||||
enterSubAgentView: (taskId: string, description: string) => Command
|
enterSubAgentView: (taskId: string, description: string) => Command
|
||||||
@ -95,7 +107,6 @@ interface SchedulerJobView {
|
|||||||
messages: ChatMessage[]
|
messages: ChatMessage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CHANNEL = 'websocket'
|
|
||||||
const DEFAULT_CHAT_ID = 'default'
|
const DEFAULT_CHAT_ID = 'default'
|
||||||
|
|
||||||
export function useChat(): UseChatReturn {
|
export function useChat(): UseChatReturn {
|
||||||
@ -104,13 +115,16 @@ export function useChat(): UseChatReturn {
|
|||||||
|
|
||||||
// 简化的状态管理
|
// 简化的状态管理
|
||||||
const [connectionId, setConnectionId] = useState<string | null>(null)
|
const [connectionId, setConnectionId] = useState<string | null>(null)
|
||||||
const [session, setSession] = useState<Session | null>(null)
|
|
||||||
const [topics, setTopics] = useState<Topic[]>([])
|
const [topics, setTopics] = useState<Topic[]>([])
|
||||||
const [selectedTopic, setSelectedTopic] = useState<string | null>(null)
|
const [selectedTopic, setSelectedTopic] = useState<string | null>(null)
|
||||||
|
const [sessions, setSessions] = useState<SessionSummary[]>([])
|
||||||
|
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
|
||||||
const [subAgentView, setSubAgentView] = useState<SubAgentView | null>(null)
|
const [subAgentView, setSubAgentView] = useState<SubAgentView | null>(null)
|
||||||
const [schedulerJobs, setSchedulerJobs] = useState<SchedulerJobSummary[]>([])
|
const [schedulerJobs, setSchedulerJobs] = useState<SchedulerJobSummary[]>([])
|
||||||
const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics')
|
const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics')
|
||||||
const [schedulerView, setSchedulerView] = useState<SchedulerJobView | null>(null)
|
const [schedulerView, setSchedulerView] = useState<SchedulerJobView | null>(null)
|
||||||
|
const [channels, setChannels] = useState<Channel[]>([])
|
||||||
|
const [selectedChannel, setSelectedChannel] = useState<string>('websocket')
|
||||||
|
|
||||||
// Message ID generator
|
// Message ID generator
|
||||||
const messageIdCounter = useRef(0)
|
const messageIdCounter = useRef(0)
|
||||||
@ -124,8 +138,16 @@ export function useChat(): UseChatReturn {
|
|||||||
const schedulerViewRef = useRef<SchedulerJobView | null>(null)
|
const schedulerViewRef = useRef<SchedulerJobView | null>(null)
|
||||||
|
|
||||||
const isConnected = useMemo(() => connectionId !== null, [connectionId])
|
const isConnected = useMemo(() => connectionId !== null, [connectionId])
|
||||||
const sessionId = useMemo(() => session?.id ?? null, [session])
|
const selectedSession = useMemo(
|
||||||
|
() => sessions.find(s => s.session_id === selectedSessionId) ?? null,
|
||||||
|
[sessions, selectedSessionId]
|
||||||
|
)
|
||||||
|
const sessionId = useMemo(() => selectedSession?.session_id ?? null, [selectedSession])
|
||||||
const chatId = useMemo(() => sessionId ?? DEFAULT_CHAT_ID, [sessionId])
|
const chatId = useMemo(() => sessionId ?? DEFAULT_CHAT_ID, [sessionId])
|
||||||
|
const isWritable = useMemo(
|
||||||
|
() => channels.find(c => c.id === selectedChannel)?.isWritable ?? false,
|
||||||
|
[channels, selectedChannel]
|
||||||
|
)
|
||||||
|
|
||||||
// Extract subagent_task_id from a message if present
|
// Extract subagent_task_id from a message if present
|
||||||
const getSubagentTaskId = (message: WsOutbound): string | undefined => {
|
const getSubagentTaskId = (message: WsOutbound): string | undefined => {
|
||||||
@ -225,7 +247,7 @@ export function useChat(): UseChatReturn {
|
|||||||
// Route to scheduler job view if active
|
// Route to scheduler job view if active
|
||||||
const currentSchedulerView = schedulerViewRef.current
|
const currentSchedulerView = schedulerViewRef.current
|
||||||
if (currentSchedulerView) {
|
if (currentSchedulerView) {
|
||||||
// Route all chat messages to the scheduler view
|
// Route chat messages to the scheduler view
|
||||||
const chatMsg = serverMessageToChatMessage(message)
|
const chatMsg = serverMessageToChatMessage(message)
|
||||||
if (chatMsg) {
|
if (chatMsg) {
|
||||||
setSchedulerView((prev) =>
|
setSchedulerView((prev) =>
|
||||||
@ -233,9 +255,10 @@ export function useChat(): UseChatReturn {
|
|||||||
? { ...prev, messages: [...prev.messages, chatMsg] }
|
? { ...prev, messages: [...prev.messages, chatMsg] }
|
||||||
: prev
|
: prev
|
||||||
)
|
)
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Non-chat messages (session_list, topic_list, etc.) fall through to main handler
|
||||||
|
}
|
||||||
|
|
||||||
// Route to sub-agent view if active
|
// Route to sub-agent view if active
|
||||||
const currentSubAgentView = subAgentViewRef.current
|
const currentSubAgentView = subAgentViewRef.current
|
||||||
@ -310,17 +333,22 @@ export function useChat(): UseChatReturn {
|
|||||||
const msg = message as SessionList
|
const msg = message as SessionList
|
||||||
console.log('Session list received:', msg)
|
console.log('Session list received:', msg)
|
||||||
|
|
||||||
// 自动选择第一个 Session(WebSocket 通道只有一个)
|
// 清空旧数据(切换通道时避免数据污染)
|
||||||
if (msg.sessions.length > 0) {
|
setTopics([])
|
||||||
const firstSession = msg.sessions[0]
|
setSelectedTopic(null)
|
||||||
setSession({
|
setMessages([])
|
||||||
id: firstSession.session_id,
|
|
||||||
title: firstSession.title,
|
// 存储全部 session
|
||||||
channel_name: firstSession.channel_name,
|
setSessions(msg.sessions)
|
||||||
created_at: Date.now(),
|
|
||||||
updated_at: Date.now(),
|
// 自动选中:优先保持当前选中,否则选第一个
|
||||||
})
|
setSelectedSessionId(prev => {
|
||||||
|
if (prev && msg.sessions.some(s => s.session_id === prev)) {
|
||||||
|
return prev
|
||||||
}
|
}
|
||||||
|
return msg.sessions.length > 0 ? msg.sessions[0].session_id : null
|
||||||
|
})
|
||||||
|
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@ -466,7 +494,12 @@ export function useChat(): UseChatReturn {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'channel_list':
|
case 'channel_list': {
|
||||||
|
const msg = message as ChannelList
|
||||||
|
console.log('Channel list received:', msg)
|
||||||
|
setChannels(msg.channels)
|
||||||
|
break
|
||||||
|
}
|
||||||
case 'pong':
|
case 'pong':
|
||||||
// 忽略这些消息
|
// 忽略这些消息
|
||||||
break
|
break
|
||||||
@ -529,11 +562,35 @@ export function useChat(): UseChatReturn {
|
|||||||
const requestSessionList = useCallback((): Command => {
|
const requestSessionList = useCallback((): Command => {
|
||||||
return {
|
return {
|
||||||
type: 'list_sessions_by_channel',
|
type: 'list_sessions_by_channel',
|
||||||
channel_name: DEFAULT_CHANNEL,
|
channel_name: selectedChannel,
|
||||||
include_archived: false,
|
include_archived: false,
|
||||||
}
|
}
|
||||||
|
}, [selectedChannel])
|
||||||
|
|
||||||
|
const requestChannelList = useCallback((): Command => {
|
||||||
|
return { type: 'list_channels' }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const selectChannel = useCallback((channelId: string) => {
|
||||||
|
if (channelId === selectedChannel) return
|
||||||
|
setSelectedChannel(channelId)
|
||||||
|
setSessions([])
|
||||||
|
setSelectedSessionId(null)
|
||||||
|
setTopics([])
|
||||||
|
setSelectedTopic(null)
|
||||||
|
setMessages([])
|
||||||
|
setIsLoading(true)
|
||||||
|
}, [selectedChannel])
|
||||||
|
|
||||||
|
const selectSession = useCallback((sessionId: string) => {
|
||||||
|
if (sessionId === selectedSessionId) return
|
||||||
|
setSelectedSessionId(sessionId)
|
||||||
|
setTopics([])
|
||||||
|
setSelectedTopic(null)
|
||||||
|
setMessages([])
|
||||||
|
setIsLoading(true)
|
||||||
|
}, [selectedSessionId])
|
||||||
|
|
||||||
const requestTopicList = useCallback((): Command | null => {
|
const requestTopicList = useCallback((): Command | null => {
|
||||||
if (!sessionId) return null
|
if (!sessionId) return null
|
||||||
return {
|
return {
|
||||||
@ -618,13 +675,15 @@ export function useChat(): UseChatReturn {
|
|||||||
return messages
|
return messages
|
||||||
}, [subAgentView, schedulerView, messages])
|
}, [subAgentView, schedulerView, messages])
|
||||||
|
|
||||||
// WebSocket 通道始终可写
|
// 只读状态由当前通道决定
|
||||||
const isReadOnly = false
|
const isReadOnly = !isWritable
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connectionId,
|
connectionId,
|
||||||
isConnected,
|
isConnected,
|
||||||
session,
|
sessions,
|
||||||
|
selectedSessionId,
|
||||||
|
session: selectedSession,
|
||||||
sessionId,
|
sessionId,
|
||||||
chatId,
|
chatId,
|
||||||
topics,
|
topics,
|
||||||
@ -632,6 +691,9 @@ export function useChat(): UseChatReturn {
|
|||||||
messages: resolvedMessages,
|
messages: resolvedMessages,
|
||||||
isLoading,
|
isLoading,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
|
isWritable,
|
||||||
|
channels,
|
||||||
|
selectedChannel,
|
||||||
subAgentView,
|
subAgentView,
|
||||||
handleMessage,
|
handleMessage,
|
||||||
handleCommand,
|
handleCommand,
|
||||||
@ -642,6 +704,9 @@ export function useChat(): UseChatReturn {
|
|||||||
switchTopic,
|
switchTopic,
|
||||||
requestSessionList,
|
requestSessionList,
|
||||||
requestTopicList,
|
requestTopicList,
|
||||||
|
requestChannelList,
|
||||||
|
selectChannel,
|
||||||
|
selectSession,
|
||||||
enterSubAgentView,
|
enterSubAgentView,
|
||||||
exitSubAgentView,
|
exitSubAgentView,
|
||||||
schedulerJobs,
|
schedulerJobs,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user