feat: 多通道消息支持与 Session 选择器
后端: - list_channels 从 ChannelManager 动态查询通道列表(合并 websocket + 所有已注册通道) - build_channel_list 移至 ChannelManager,网关层直接依赖领域层 - get_current_topic 自动创建默认话题(修复微信等通道无话题的问题) - is_channel_writable: 仅 websocket 可写,其余通道只读 前端: - 右上角通道选择器 + Session 选择器(Portal 渲染,固定宽度居中) - 只读通道显示刷新按钮替代新建按钮 - 话题列表时间戳修复(秒→毫秒) - 移除冗余的 SessionInfo、AI Ready、所属会话等 UI - 修复 scheduler view 路由无条件拦截消息的 bug
This commit is contained in:
parent
99b6f54f67
commit
6f33ec7604
@ -1,4 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
@ -8,6 +8,7 @@ use crate::channels::cli::CliChannel;
|
||||
use crate::channels::feishu::FeishuChannel;
|
||||
use crate::channels::wechat::WechatChannel;
|
||||
use crate::config::{Config, TaggedChannelConfig};
|
||||
use crate::protocol::Channel as ProtocolChannel;
|
||||
|
||||
/// ChannelManager manages all Channel instances and the MessageBus
|
||||
#[derive(Clone)]
|
||||
@ -135,6 +136,65 @@ impl ChannelManager {
|
||||
.map(|(name, channel)| (name.clone(), channel.clone()))
|
||||
.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)]
|
||||
|
||||
@ -1,34 +1,19 @@
|
||||
use crate::channels::manager::ChannelManager;
|
||||
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;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// 列出通道命令处理器
|
||||
pub struct ListChannelsCommandHandler;
|
||||
|
||||
impl ListChannelsCommandHandler {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
pub struct ListChannelsCommandHandler {
|
||||
channel_manager: Arc<ChannelManager>,
|
||||
}
|
||||
|
||||
/// 获取默认通道列表(公开供其他模块使用)
|
||||
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,
|
||||
},
|
||||
]
|
||||
impl ListChannelsCommandHandler {
|
||||
pub fn new(channel_manager: Arc<ChannelManager>) -> Self {
|
||||
Self { channel_manager }
|
||||
}
|
||||
}
|
||||
|
||||
@ -52,16 +37,17 @@ impl CommandHandler for ListChannelsCommandHandler {
|
||||
ctx: CommandContext,
|
||||
) -> Result<CommandResponse, CommandError> {
|
||||
match cmd {
|
||||
Command::ListChannels => handle_list_channels(ctx).await,
|
||||
Command::ListChannels => handle_list_channels(self, ctx).await,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_list_channels(
|
||||
handler: &ListChannelsCommandHandler,
|
||||
ctx: CommandContext,
|
||||
) -> 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)
|
||||
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
|
||||
|
||||
@ -618,6 +618,31 @@ impl SessionManager {
|
||||
topic_title = %latest_topic.title,
|
||||
"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;
|
||||
|
||||
// 连接建立后立即发送通道列表
|
||||
let channels = ListChannelsCommandHandler::get_default_channels();
|
||||
// 连接建立后立即发送通道列表(合并 websocket + ChannelManager 动态通道)
|
||||
let channels = state.channel_manager.build_channel_list().await;
|
||||
let _ = sender
|
||||
.send(WsOutbound::ChannelList { channels })
|
||||
.await;
|
||||
@ -378,7 +378,7 @@ async fn handle_inbound(
|
||||
// 注册 list_sessions_by_channel 处理器
|
||||
router.register(Box::new(ListSessionsByChannelCommandHandler::new(store.clone())));
|
||||
// 注册 list_channels 处理器
|
||||
router.register(Box::new(ListChannelsCommandHandler::new()));
|
||||
router.register(Box::new(ListChannelsCommandHandler::new(Arc::new(state.channel_manager.clone()))));
|
||||
// 注册 list_topics 处理器
|
||||
router.register(Box::new(ListTopicsCommandHandler::new(store.clone())));
|
||||
// 注册 switch_topic 处理器
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
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 { TopicList } from './components/Sidebar/TopicList'
|
||||
import { SessionInfo } from './components/Sidebar/SessionInfo'
|
||||
import { SchedulerJobList } from './components/Sidebar/SchedulerJobList'
|
||||
import { ToolPanel } from './components/Panel/ToolPanel'
|
||||
import { ConnectionStatus } from './components/ConnectionStatus'
|
||||
import { ChannelSelector } from './components/Header/ChannelSelector'
|
||||
import { SessionSelector } from './components/Header/SessionSelector'
|
||||
import { useWebSocket } from './hooks/useWebSocket'
|
||||
import { useChat } from './hooks/useChat'
|
||||
import type { ChatMessage, Command, Attachment, SchedulerJobSessionLookup } from './types/protocol'
|
||||
@ -17,7 +18,6 @@ function App() {
|
||||
|
||||
const {
|
||||
// 连接状态
|
||||
connectionId,
|
||||
isConnected,
|
||||
// Session 状态
|
||||
session,
|
||||
@ -40,6 +40,15 @@ function App() {
|
||||
schedulerView,
|
||||
enterSchedulerJobView,
|
||||
exitSchedulerJobView,
|
||||
// 通道
|
||||
channels,
|
||||
selectedChannel,
|
||||
selectChannel,
|
||||
requestChannelList,
|
||||
// Session
|
||||
sessions,
|
||||
selectedSessionId,
|
||||
selectSession,
|
||||
// 方法
|
||||
handleMessage,
|
||||
handleCommand,
|
||||
@ -85,15 +94,23 @@ function App() {
|
||||
|
||||
// ---- WebSocket 初始化 ----
|
||||
|
||||
// 连接建立后自动加载 Session
|
||||
// Step 1: 连接建立后先请求通道列表
|
||||
useEffect(() => {
|
||||
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()
|
||||
handleCommand(sessionCmd)
|
||||
sendMessage({ type: 'command', payload: JSON.stringify(sessionCmd) })
|
||||
}
|
||||
}, [isConnected, status, handleCommand, sendMessage, requestSessionList])
|
||||
}, [channels.length, status, handleCommand, sendMessage, requestSessionList])
|
||||
|
||||
// Session 加载后自动加载 Topics
|
||||
useEffect(() => {
|
||||
@ -193,6 +210,15 @@ function App() {
|
||||
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
||||
}, [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(
|
||||
(topicId: string) => {
|
||||
selectTopic(topicId)
|
||||
@ -244,6 +270,24 @@ function App() {
|
||||
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 result: ChatMessage[] = []
|
||||
const toolCallIndex = new Map<string, number>()
|
||||
@ -312,18 +356,16 @@ function App() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-[var(--text-secondary)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4 text-[var(--accent-cyan)]" />
|
||||
<span>AI Ready</span>
|
||||
</div>
|
||||
{session && (
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-emerald-400" />
|
||||
<span className="font-mono text-xs">
|
||||
{session.title}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ChannelSelector
|
||||
channels={channels}
|
||||
selectedChannel={selectedChannel}
|
||||
onSelectChannel={handleSwitchChannel}
|
||||
/>
|
||||
<SessionSelector
|
||||
sessions={sessions}
|
||||
selectedSessionId={selectedSessionId}
|
||||
onSelectSession={handleSelectSession}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@ -331,12 +373,6 @@ function App() {
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* 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' : ''}`}>
|
||||
<SessionInfo
|
||||
session={session}
|
||||
connectionId={connectionId}
|
||||
/>
|
||||
<div className="border-b border-[var(--border-color)]" />
|
||||
|
||||
{/* Tab 栏 */}
|
||||
<div className="flex border-b border-[var(--border-color)]">
|
||||
<button
|
||||
@ -365,11 +401,11 @@ function App() {
|
||||
{sidebarTab === 'topics' ? (
|
||||
<TopicList
|
||||
sessionId={sessionId}
|
||||
sessionTitle={session?.title ?? ''}
|
||||
topics={topics}
|
||||
currentTopicId={selectedTopic}
|
||||
isReadOnly={isReadOnly}
|
||||
onCreateTopic={handleCreateTopic}
|
||||
onRefresh={handleRefreshTopics}
|
||||
onSwitchTopic={handleSwitchTopic}
|
||||
/>
|
||||
) : (
|
||||
@ -457,7 +493,7 @@ function App() {
|
||||
channelName={
|
||||
schedulerView ? `定时任务: ${schedulerView.description}` :
|
||||
subAgentView ? `子智能体: ${subAgentView.description}` :
|
||||
(session?.title ?? 'PicoBot')
|
||||
(session?.title ?? channels.find(c => c.id === selectedChannel)?.name ?? 'PicoBot')
|
||||
}
|
||||
onSendMessage={subAgentView || schedulerView ? () => {} : handleSendMessage}
|
||||
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
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
78
web/src/components/Sidebar/SessionSelector.tsx
Normal file
78
web/src/components/Sidebar/SessionSelector.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import { MessageSquare } 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) {
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<div className="border-b border-[var(--border-color)] px-4 py-2.5">
|
||||
<p className="text-xs text-[var(--text-muted)]">暂无会话</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-[var(--border-color)]">
|
||||
<div className="px-4 py-2 flex items-center justify-between">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--text-muted)]">
|
||||
会话
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--text-muted)]">{sessions.length}</span>
|
||||
</div>
|
||||
<div className="max-h-40 overflow-y-auto px-2 pb-2">
|
||||
{sessions.map((s) => {
|
||||
const isActive = s.session_id === selectedSessionId
|
||||
return (
|
||||
<button
|
||||
key={s.session_id}
|
||||
onClick={() => onSelectSession(s.session_id)}
|
||||
className={`
|
||||
group relative w-full flex items-center gap-2.5 px-2 py-2 rounded-lg text-left
|
||||
transition-all duration-150
|
||||
${isActive
|
||||
? 'bg-[var(--accent-cyan)]/10'
|
||||
: 'hover:bg-[var(--overlay-hover)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Left accent bar */}
|
||||
<div
|
||||
className={`
|
||||
absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-4 rounded-r-full
|
||||
transition-all duration-200
|
||||
${isActive ? 'bg-[var(--accent-cyan)]' : 'bg-transparent'}
|
||||
`}
|
||||
/>
|
||||
<MessageSquare
|
||||
className={`h-3.5 w-3.5 flex-shrink-0 ${
|
||||
isActive ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'
|
||||
}`}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className={`text-xs truncate ${
|
||||
isActive ? 'text-[var(--text-primary)] font-medium' : 'text-[var(--text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
{s.title}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--text-muted)] flex-shrink-0">
|
||||
{s.message_count}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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'
|
||||
|
||||
interface TopicListProps {
|
||||
sessionId: string | null
|
||||
sessionTitle: string
|
||||
topics: Topic[]
|
||||
currentTopicId: string | null
|
||||
isReadOnly: boolean
|
||||
onCreateTopic: () => void
|
||||
onRefresh: () => void
|
||||
onSwitchTopic: (topicId: string) => void
|
||||
}
|
||||
|
||||
@ -29,11 +29,11 @@ function formatTime(timestamp: number): string {
|
||||
|
||||
export function TopicList({
|
||||
sessionId,
|
||||
sessionTitle,
|
||||
topics,
|
||||
currentTopicId,
|
||||
isReadOnly,
|
||||
onCreateTopic,
|
||||
onRefresh,
|
||||
onSwitchTopic,
|
||||
}: TopicListProps) {
|
||||
return (
|
||||
@ -47,11 +47,26 @@ export function TopicList({
|
||||
<span className="text-xs text-[var(--text-muted)]">({topics.length})</span>
|
||||
)}
|
||||
</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
|
||||
onClick={onCreateTopic}
|
||||
disabled={isReadOnly || !sessionId}
|
||||
disabled={!sessionId}
|
||||
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(--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" />
|
||||
新建
|
||||
</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 列表 */}
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
|
||||
@ -10,15 +10,17 @@ import type {
|
||||
ToolPending,
|
||||
SessionEstablished,
|
||||
SessionList,
|
||||
SessionSummary,
|
||||
TopicList,
|
||||
TopicSummary,
|
||||
Session,
|
||||
TaskMessagesLoaded,
|
||||
TaskStarted,
|
||||
Attachment,
|
||||
SchedulerJobList,
|
||||
SchedulerJobSummary,
|
||||
SchedulerJobSessionLookup,
|
||||
Channel,
|
||||
ChannelList,
|
||||
} from '../types/protocol'
|
||||
|
||||
// 简化后的层级状态
|
||||
@ -28,7 +30,9 @@ interface UseChatReturn {
|
||||
isConnected: boolean
|
||||
|
||||
// 简化的层级状态
|
||||
session: Session | null
|
||||
sessions: SessionSummary[]
|
||||
selectedSessionId: string | null
|
||||
session: SessionSummary | null
|
||||
sessionId: string | null
|
||||
chatId: string
|
||||
topics: Topic[]
|
||||
@ -38,7 +42,12 @@ interface UseChatReturn {
|
||||
messages: ChatMessage[]
|
||||
isLoading: boolean
|
||||
|
||||
// 是否只读(WebSocket 通道始终可写)
|
||||
// 通道状态
|
||||
channels: Channel[]
|
||||
selectedChannel: string
|
||||
isWritable: boolean
|
||||
|
||||
// 是否只读
|
||||
isReadOnly: boolean
|
||||
|
||||
// 子智能体视图
|
||||
@ -58,6 +67,9 @@ interface UseChatReturn {
|
||||
// 初始化方法
|
||||
requestSessionList: () => Command
|
||||
requestTopicList: () => Command | null
|
||||
requestChannelList: () => Command
|
||||
selectChannel: (channelId: string) => void
|
||||
selectSession: (sessionId: string) => void
|
||||
|
||||
// 子智能体导航方法
|
||||
enterSubAgentView: (taskId: string, description: string) => Command
|
||||
@ -95,7 +107,6 @@ interface SchedulerJobView {
|
||||
messages: ChatMessage[]
|
||||
}
|
||||
|
||||
const DEFAULT_CHANNEL = 'websocket'
|
||||
const DEFAULT_CHAT_ID = 'default'
|
||||
|
||||
export function useChat(): UseChatReturn {
|
||||
@ -104,13 +115,16 @@ export function useChat(): UseChatReturn {
|
||||
|
||||
// 简化的状态管理
|
||||
const [connectionId, setConnectionId] = useState<string | null>(null)
|
||||
const [session, setSession] = useState<Session | null>(null)
|
||||
const [topics, setTopics] = useState<Topic[]>([])
|
||||
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 [schedulerJobs, setSchedulerJobs] = useState<SchedulerJobSummary[]>([])
|
||||
const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics')
|
||||
const [schedulerView, setSchedulerView] = useState<SchedulerJobView | null>(null)
|
||||
const [channels, setChannels] = useState<Channel[]>([])
|
||||
const [selectedChannel, setSelectedChannel] = useState<string>('websocket')
|
||||
|
||||
// Message ID generator
|
||||
const messageIdCounter = useRef(0)
|
||||
@ -124,8 +138,16 @@ export function useChat(): UseChatReturn {
|
||||
const schedulerViewRef = useRef<SchedulerJobView | null>(null)
|
||||
|
||||
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 isWritable = useMemo(
|
||||
() => channels.find(c => c.id === selectedChannel)?.isWritable ?? false,
|
||||
[channels, selectedChannel]
|
||||
)
|
||||
|
||||
// Extract subagent_task_id from a message if present
|
||||
const getSubagentTaskId = (message: WsOutbound): string | undefined => {
|
||||
@ -225,7 +247,7 @@ export function useChat(): UseChatReturn {
|
||||
// Route to scheduler job view if active
|
||||
const currentSchedulerView = schedulerViewRef.current
|
||||
if (currentSchedulerView) {
|
||||
// Route all chat messages to the scheduler view
|
||||
// Route chat messages to the scheduler view
|
||||
const chatMsg = serverMessageToChatMessage(message)
|
||||
if (chatMsg) {
|
||||
setSchedulerView((prev) =>
|
||||
@ -233,9 +255,10 @@ export function useChat(): UseChatReturn {
|
||||
? { ...prev, messages: [...prev.messages, chatMsg] }
|
||||
: prev
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Non-chat messages (session_list, topic_list, etc.) fall through to main handler
|
||||
}
|
||||
|
||||
// Route to sub-agent view if active
|
||||
const currentSubAgentView = subAgentViewRef.current
|
||||
@ -310,17 +333,22 @@ export function useChat(): UseChatReturn {
|
||||
const msg = message as SessionList
|
||||
console.log('Session list received:', msg)
|
||||
|
||||
// 自动选择第一个 Session(WebSocket 通道只有一个)
|
||||
if (msg.sessions.length > 0) {
|
||||
const firstSession = msg.sessions[0]
|
||||
setSession({
|
||||
id: firstSession.session_id,
|
||||
title: firstSession.title,
|
||||
channel_name: firstSession.channel_name,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
})
|
||||
// 清空旧数据(切换通道时避免数据污染)
|
||||
setTopics([])
|
||||
setSelectedTopic(null)
|
||||
setMessages([])
|
||||
|
||||
// 存储全部 session
|
||||
setSessions(msg.sessions)
|
||||
|
||||
// 自动选中:优先保持当前选中,否则选第一个
|
||||
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)
|
||||
break
|
||||
}
|
||||
@ -466,7 +494,12 @@ export function useChat(): UseChatReturn {
|
||||
break
|
||||
}
|
||||
|
||||
case 'channel_list':
|
||||
case 'channel_list': {
|
||||
const msg = message as ChannelList
|
||||
console.log('Channel list received:', msg)
|
||||
setChannels(msg.channels)
|
||||
break
|
||||
}
|
||||
case 'pong':
|
||||
// 忽略这些消息
|
||||
break
|
||||
@ -529,11 +562,35 @@ export function useChat(): UseChatReturn {
|
||||
const requestSessionList = useCallback((): Command => {
|
||||
return {
|
||||
type: 'list_sessions_by_channel',
|
||||
channel_name: DEFAULT_CHANNEL,
|
||||
channel_name: selectedChannel,
|
||||
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 => {
|
||||
if (!sessionId) return null
|
||||
return {
|
||||
@ -618,13 +675,15 @@ export function useChat(): UseChatReturn {
|
||||
return messages
|
||||
}, [subAgentView, schedulerView, messages])
|
||||
|
||||
// WebSocket 通道始终可写
|
||||
const isReadOnly = false
|
||||
// 只读状态由当前通道决定
|
||||
const isReadOnly = !isWritable
|
||||
|
||||
return {
|
||||
connectionId,
|
||||
isConnected,
|
||||
session,
|
||||
sessions,
|
||||
selectedSessionId,
|
||||
session: selectedSession,
|
||||
sessionId,
|
||||
chatId,
|
||||
topics,
|
||||
@ -632,6 +691,9 @@ export function useChat(): UseChatReturn {
|
||||
messages: resolvedMessages,
|
||||
isLoading,
|
||||
isReadOnly,
|
||||
isWritable,
|
||||
channels,
|
||||
selectedChannel,
|
||||
subAgentView,
|
||||
handleMessage,
|
||||
handleCommand,
|
||||
@ -642,6 +704,9 @@ export function useChat(): UseChatReturn {
|
||||
switchTopic,
|
||||
requestSessionList,
|
||||
requestTopicList,
|
||||
requestChannelList,
|
||||
selectChannel,
|
||||
selectSession,
|
||||
enterSubAgentView,
|
||||
exitSubAgentView,
|
||||
schedulerJobs,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user