Compare commits

..

No commits in common. "02339465b67b096b465e6a1b93a8d6d79064124b" and "99b6f54f67cc9095a78aaa76c3c786bfbd8e2a2d" have entirely different histories.

9 changed files with 101 additions and 717 deletions

View File

@ -1,4 +1,4 @@
use std::collections::{HashMap, HashSet}; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::RwLock;
@ -8,7 +8,6 @@ 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)]
@ -136,65 +135,6 @@ 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)]

View File

@ -1,19 +1,34 @@
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(channel_manager: Arc<ChannelManager>) -> Self { pub fn new() -> Self {
Self { channel_manager } 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,
},
]
} }
} }
@ -37,17 +52,16 @@ impl CommandHandler for ListChannelsCommandHandler {
ctx: CommandContext, ctx: CommandContext,
) -> Result<CommandResponse, CommandError> { ) -> Result<CommandResponse, CommandError> {
match cmd { match cmd {
Command::ListChannels => handle_list_channels(self, ctx).await, Command::ListChannels => handle_list_channels(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 = handler.channel_manager.build_channel_list().await; let channels = ListChannelsCommandHandler::get_default_channels();
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()))?;

View File

@ -618,31 +618,6 @@ 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"
);
}
}
} }
} }

View File

@ -164,8 +164,8 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
}) })
.await; .await;
// 连接建立后立即发送通道列表(合并 websocket + ChannelManager 动态通道) // 连接建立后立即发送通道列表
let channels = state.channel_manager.build_channel_list().await; let channels = ListChannelsCommandHandler::get_default_channels();
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(Arc::new(state.channel_manager.clone())))); router.register(Box::new(ListChannelsCommandHandler::new()));
// 注册 list_topics 处理器 // 注册 list_topics 处理器
router.register(Box::new(ListTopicsCommandHandler::new(store.clone()))); router.register(Box::new(ListTopicsCommandHandler::new(store.clone())));
// 注册 switch_topic 处理器 // 注册 switch_topic 处理器

View File

@ -1,12 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Zap, ArrowLeft, Bot, Clock, Sun, Moon } from 'lucide-react' import { Zap, Cpu, MessageSquare, 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'
@ -18,6 +17,7 @@ function App() {
const { const {
// 连接状态 // 连接状态
connectionId,
isConnected, isConnected,
// Session 状态 // Session 状态
session, session,
@ -40,15 +40,6 @@ function App() {
schedulerView, schedulerView,
enterSchedulerJobView, enterSchedulerJobView,
exitSchedulerJobView, exitSchedulerJobView,
// 通道
channels,
selectedChannel,
selectChannel,
requestChannelList,
// Session
sessions,
selectedSessionId,
selectSession,
// 方法 // 方法
handleMessage, handleMessage,
handleCommand, handleCommand,
@ -94,23 +85,15 @@ function App() {
// ---- WebSocket 初始化 ---- // ---- WebSocket 初始化 ----
// Step 1: 连接建立后先请求通道列表 // 连接建立后自动加载 Session
useEffect(() => { useEffect(() => {
if (isConnected && status === 'connected') { if (isConnected && status === 'connected') {
const cmd = requestChannelList() // 1. 请求 Session 列表(会自动选择第一个)
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) })
} }
}, [channels.length, status, handleCommand, sendMessage, requestSessionList]) }, [isConnected, status, handleCommand, sendMessage, requestSessionList])
// Session 加载后自动加载 Topics // Session 加载后自动加载 Topics
useEffect(() => { useEffect(() => {
@ -210,15 +193,6 @@ 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)
@ -270,24 +244,6 @@ 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>()
@ -356,16 +312,18 @@ 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)]">
<ChannelSelector <div className="flex items-center gap-2">
channels={channels} <Cpu className="h-4 w-4 text-[var(--accent-cyan)]" />
selectedChannel={selectedChannel} <span>AI Ready</span>
onSelectChannel={handleSwitchChannel} </div>
/> {session && (
<SessionSelector <div className="flex items-center gap-2">
sessions={sessions} <MessageSquare className="h-4 w-4 text-emerald-400" />
selectedSessionId={selectedSessionId} <span className="font-mono text-xs">
onSelectSession={handleSelectSession} {session.title}
/> </span>
</div>
)}
</div> </div>
</header> </header>
@ -373,6 +331,12 @@ 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
@ -401,11 +365,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}
/> />
) : ( ) : (
@ -493,7 +457,7 @@ function App() {
channelName={ channelName={
schedulerView ? `定时任务: ${schedulerView.description}` : schedulerView ? `定时任务: ${schedulerView.description}` :
subAgentView ? `子智能体: ${subAgentView.description}` : subAgentView ? `子智能体: ${subAgentView.description}` :
(session?.title ?? channels.find(c => c.id === selectedChannel)?.name ?? 'PicoBot') (session?.title ?? 'PicoBot')
} }
onSendMessage={subAgentView || schedulerView ? () => {} : handleSendMessage} onSendMessage={subAgentView || schedulerView ? () => {} : handleSendMessage}
onNavigateToSubAgent={handleNavigateToSubAgent} onNavigateToSubAgent={handleNavigateToSubAgent}

View File

@ -1,260 +0,0 @@
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
)}
</>
)
}

View File

@ -1,176 +0,0 @@
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
)}
</>
)
}

View File

@ -1,13 +1,13 @@
import { Plus, MessageSquare, Layers, Hash, Clock, RefreshCw } from 'lucide-react' import { Plus, MessageSquare, Layers, Hash, Clock } 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,26 +47,11 @@ 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={!sessionId} disabled={isReadOnly || !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 ${
!sessionId isReadOnly || !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'
}`} }`}
@ -74,9 +59,16 @@ export function TopicList({
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</button> </button>
)}
</div> </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>
)}
{/* Topics 列表 */} {/* Topics 列表 */}
<div className="flex-1 overflow-y-auto p-3"> <div className="flex-1 overflow-y-auto p-3">
{!sessionId ? ( {!sessionId ? (

View File

@ -10,17 +10,15 @@ 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'
// 简化后的层级状态 // 简化后的层级状态
@ -30,9 +28,7 @@ interface UseChatReturn {
isConnected: boolean isConnected: boolean
// 简化的层级状态 // 简化的层级状态
sessions: SessionSummary[] session: Session | null
selectedSessionId: string | null
session: SessionSummary | null
sessionId: string | null sessionId: string | null
chatId: string chatId: string
topics: Topic[] topics: Topic[]
@ -42,12 +38,7 @@ interface UseChatReturn {
messages: ChatMessage[] messages: ChatMessage[]
isLoading: boolean isLoading: boolean
// 通道状态 // 是否只读WebSocket 通道始终可写)
channels: Channel[]
selectedChannel: string
isWritable: boolean
// 是否只读
isReadOnly: boolean isReadOnly: boolean
// 子智能体视图 // 子智能体视图
@ -67,9 +58,6 @@ 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
@ -107,6 +95,7 @@ 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 {
@ -115,16 +104,13 @@ 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)
@ -138,16 +124,8 @@ 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 selectedSession = useMemo( const sessionId = useMemo(() => session?.id ?? null, [session])
() => 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 => {
@ -247,7 +225,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 chat messages to the scheduler view // Route all chat messages to the scheduler view
const chatMsg = serverMessageToChatMessage(message) const chatMsg = serverMessageToChatMessage(message)
if (chatMsg) { if (chatMsg) {
setSchedulerView((prev) => setSchedulerView((prev) =>
@ -255,9 +233,8 @@ export function useChat(): UseChatReturn {
? { ...prev, messages: [...prev.messages, chatMsg] } ? { ...prev, messages: [...prev.messages, chatMsg] }
: prev : prev
) )
return
} }
// Non-chat messages (session_list, topic_list, etc.) fall through to main handler return
} }
// Route to sub-agent view if active // Route to sub-agent view if active
@ -333,22 +310,17 @@ 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)
// 清空旧数据(切换通道时避免数据污染) // 自动选择第一个 SessionWebSocket 通道只有一个)
setTopics([]) if (msg.sessions.length > 0) {
setSelectedTopic(null) const firstSession = msg.sessions[0]
setMessages([]) setSession({
id: firstSession.session_id,
// 存储全部 session title: firstSession.title,
setSessions(msg.sessions) channel_name: firstSession.channel_name,
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
} }
@ -494,12 +466,7 @@ 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
@ -562,35 +529,11 @@ 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: selectedChannel, channel_name: DEFAULT_CHANNEL,
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 {
@ -675,15 +618,13 @@ export function useChat(): UseChatReturn {
return messages return messages
}, [subAgentView, schedulerView, messages]) }, [subAgentView, schedulerView, messages])
// 只读状态由当前通道决定 // WebSocket 通道始终可写
const isReadOnly = !isWritable const isReadOnly = false
return { return {
connectionId, connectionId,
isConnected, isConnected,
sessions, session,
selectedSessionId,
session: selectedSession,
sessionId, sessionId,
chatId, chatId,
topics, topics,
@ -691,9 +632,6 @@ export function useChat(): UseChatReturn {
messages: resolvedMessages, messages: resolvedMessages,
isLoading, isLoading,
isReadOnly, isReadOnly,
isWritable,
channels,
selectedChannel,
subAgentView, subAgentView,
handleMessage, handleMessage,
handleCommand, handleCommand,
@ -704,9 +642,6 @@ export function useChat(): UseChatReturn {
switchTopic, switchTopic,
requestSessionList, requestSessionList,
requestTopicList, requestTopicList,
requestChannelList,
selectChannel,
selectSession,
enterSubAgentView, enterSubAgentView,
exitSubAgentView, exitSubAgentView,
schedulerJobs, schedulerJobs,