diff --git a/web/src/App.tsx b/web/src/App.tsx index 07739f1..33148cc 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'; import { Zap, ArrowLeft, @@ -11,6 +11,8 @@ import { Brain, Settings as SettingsIcon, ChevronRight, + MessageSquare, + Calendar, } from 'lucide-react'; import { ChatContainer } from './components/Chat/ChatContainer'; import { TopicList } from './components/Sidebar/TopicList'; @@ -200,6 +202,75 @@ function App() { // 设置弹窗关闭计数器:每次关闭时递增,用于通知 ExpertSelector 刷新已选专家状态 const [settingsClosedTick, setSettingsClosedTick] = useState(0); + // ---- 三栏列宽(拖拽调宽,对齐 harness AppFrame)---- + const [sidebarWidth, setSidebarWidth] = useState(() => { + const parsed = parseInt(localStorage.getItem('picobot-sidebar-width') ?? '', 10); + return Number.isFinite(parsed) ? Math.min(Math.max(parsed, 216), 400) : 288; + }); + const [rightPanelWidth, setRightPanelWidth] = useState(() => { + const parsed = parseInt(localStorage.getItem('picobot-right-panel-width') ?? '', 10); + return Number.isFinite(parsed) ? Math.min(Math.max(parsed, 240), 420) : 320; + }); + + // 拖拽期间禁用 width transition,避免面板"追鼠标"延迟感 + const [isResizing, setIsResizing] = useState(false); + + const startSidebarDrag = useCallback( + (e: ReactMouseEvent) => { + e.preventDefault(); + const startX = e.clientX; + const startWidth = sidebarWidth; + setIsResizing(true); + const onMove = (ev: MouseEvent) => { + setSidebarWidth(Math.min(Math.max(startWidth + ev.clientX - startX, 216), 400)); + }; + const onUp = (ev: MouseEvent) => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + setIsResizing(false); + localStorage.setItem( + 'picobot-sidebar-width', + String(Math.min(Math.max(startWidth + ev.clientX - startX, 216), 400)), + ); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + }, + [sidebarWidth], + ); + + const startRightPanelDrag = useCallback( + (e: ReactMouseEvent) => { + e.preventDefault(); + const startX = e.clientX; + const startWidth = rightPanelWidth; + setIsResizing(true); + const onMove = (ev: MouseEvent) => { + setRightPanelWidth(Math.min(Math.max(startWidth - (ev.clientX - startX), 240), 420)); + }; + const onUp = (ev: MouseEvent) => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + setIsResizing(false); + localStorage.setItem( + 'picobot-right-panel-width', + String(Math.min(Math.max(startWidth - (ev.clientX - startX), 240), 420)), + ); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + }, + [rightPanelWidth], + ); + const handleSaveConnection = useCallback((host: string, port: number) => { setGatewaySettings({ host, port }); }, []); @@ -615,189 +686,222 @@ function App() { }, [schedulerView, subAgentView, selectedTopic]); return ( -
- {/* Header */} -
-
-
- -

- Pico - Bot -

- {appVersion && ( - - v{appVersion} - - )} -
-
- - {/* 主题切换按钮 */} -
- - - +
+ {/* 左栏 — harness 风格 shell(Logo/连接/通道/会话/列表/底部操作区) */} +
- {/* Main Content */} -
- {/* Left Sidebar — smooth width animation via will-change + overflow-hidden */} + {sidebarCollapsed ? ( + /* 折叠 rail */ +
+ + +
+ + +
+ +
+
+ ) : ( + <> +
+ +
+
+ + +
+ + {/* Tab 栏 */} +
+ + +
+ +
+ {sidebarTab === 'topics' ? ( + + ) : ( + + )} +
+ + {/* 底部操作区:思考 / 主题 / 配置 */} +
+ + +
+ +
+ + )} + + + {/* 左栏拖拽调宽手柄 */} + {!sidebarCollapsed && (
- {/* Tab 栏 + collapse toggle */} -
- {sidebarCollapsed ? ( - /* 收起态:紧凑竖排 tab 按钮 */ -
- - - {/* 展开按钮 */} - -
- ) : ( - <> - - - {/* 收起按钮 */} - - - )} -
+ onMouseDown={startSidebarDrag} + className="w-1.5 shrink-0 cursor-col-resize transition-colors hover:bg-[var(--accent-cyan)]/40 active:bg-[var(--accent-cyan)]/60" + role="separator" + aria-orientation="vertical" + /> + )} -
- {sidebarCollapsed ? null : sidebarTab === 'topics' ? ( - - ) : ( - - )} -
-
- - {/* Center - Chat */} -
+ {/* 中栏 — 对话区 */} +
{/* Scheduler job view back bar */} {schedulerView && (
@@ -810,7 +914,7 @@ function App() {
- + 定时任务: {schedulerView.description} @@ -861,13 +965,13 @@ function App() { : level.status; const statusColor = level.status === 'completed' - ? 'text-emerald-400' + ? 'text-[var(--accent-green)]' : level.status === 'failed' - ? 'text-red-400' + ? 'text-[rgb(242,90,90)]' : level.status === 'timeout' - ? 'text-amber-400' + ? 'text-[var(--accent-amber)]' : level.status === 'running' - ? 'text-amber-400' + ? 'text-[var(--accent-amber)]' : 'text-[var(--text-secondary)]'; return (
@@ -935,11 +1039,25 @@ function App() {
- {/* Right Sidebar - Todo / Memory / Skill Panel (collapsible, tabbed) */} + {/* 右栏拖拽调宽手柄 */} + {!rightSidebarCollapsed && (
+ onMouseDown={startRightPanelDrag} + className="w-1.5 shrink-0 cursor-col-resize transition-colors hover:bg-[var(--accent-cyan)]/40 active:bg-[var(--accent-cyan)]/60" + role="separator" + aria-orientation="vertical" + /> + )} + + {/* 右栏 — Token 统计 / 待办 / 记忆 / 技能(可折叠) */} +
{rightSidebarCollapsed ? ( // 折叠态:窄条 + 展开按钮 ) : ( -
+
{/* Token 用量面板(主代理/子代理共用,按视图切换数据源) */} )}
-
{/* 系统配置页面 */} {configPageOpen && ( diff --git a/web/src/components/Chat/ChatContainer.tsx b/web/src/components/Chat/ChatContainer.tsx index b313272..fbb9d4b 100644 --- a/web/src/components/Chat/ChatContainer.tsx +++ b/web/src/components/Chat/ChatContainer.tsx @@ -3,6 +3,7 @@ import { MessageList } from './MessageList'; import { MessageInput } from './MessageInput'; import { ExpertSelector } from './ExpertSelector'; import { ModelSelector } from './ModelSelector'; +import { Zap } from 'lucide-react'; import type { ChatMessage, Attachment } from '../../types/protocol'; interface ChatContainerProps { @@ -49,36 +50,72 @@ export function ChatContainer({ description: string; } | null>(null); - return ( -
-
- -
-
- - -
- + + +
+ ); + + const input = ( + + ); + + /* Hero 空状态:空话题 + 非加载 + 可写。 + 注意:composer 保持在组件树中的固定位置(不随分支 remount), + 避免空→非空切换时丢失未发送草稿。 */ + const isHero = messages.length === 0 && !isLoading && !isReadOnly; + + return ( +
+ {isHero && ( +
+ )} + {isHero ? ( +
+
+ +
+

PicoBot

+

有什么可以帮你的?

+
+ ) : ( +
+ +
+ )} +
+
+ {selectors} + {input} +
+
); } diff --git a/web/src/components/Chat/ExpertSelector.tsx b/web/src/components/Chat/ExpertSelector.tsx index 29a4b59..75b5b27 100644 --- a/web/src/components/Chat/ExpertSelector.tsx +++ b/web/src/components/Chat/ExpertSelector.tsx @@ -256,7 +256,7 @@ export function ExpertSelector({
)}
- {error && {error}} + {error && {error}}
); } diff --git a/web/src/components/Chat/MessageBubble.tsx b/web/src/components/Chat/MessageBubble.tsx index b08c5f4..eb64e60 100644 --- a/web/src/components/Chat/MessageBubble.tsx +++ b/web/src/components/Chat/MessageBubble.tsx @@ -1,28 +1,23 @@ import { useState, useEffect, memo } from 'react'; import { - User, - Bot, - Wrench, CheckCircle, - AlertCircle, - Terminal, - File, - Image, - FileText, - Music, - Video, - Download, - ChevronDown, ChevronRight, - Copy, - Check, - Loader2, - XCircle, + ChevronDown, Clock, Loader, - X, + Loader2, + XCircle, + Copy, + Check, Brain, Maximize2, + Download, + File, + FileText, + Image, + Music, + Video, + X, } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -289,7 +284,7 @@ function CopyButton({ text, className = '' }: { text: string; className?: string title="复制" > {copied ? ( - + ) : ( )} @@ -385,25 +380,25 @@ export const MessageBubble = memo(function MessageBubble({ const statusConfig = { calling: { - dot: 'bg-amber-400 animate-pulse', - fullBorder: 'border-amber-500/30', - iconColor: 'text-amber-400', - avatarBg: 'bg-amber-500/20', - avatarIcon: 'text-amber-400', + dot: 'bg-[var(--accent-cyan)] animate-pulse', + fullBorder: 'border-[var(--accent-cyan)]/30', + iconColor: 'text-[var(--accent-cyan)]', + avatarBg: 'bg-[var(--accent-cyan)]/10', + avatarIcon: 'text-[var(--accent-cyan)]', }, result: { - dot: 'bg-emerald-400', - fullBorder: 'border-emerald-500/30', - iconColor: 'text-emerald-400', - avatarBg: 'bg-emerald-500/20', - avatarIcon: 'text-emerald-400', + dot: 'bg-[var(--accent-green)]', + fullBorder: 'border-[var(--accent-green)]/30', + iconColor: 'text-[var(--accent-green)]', + avatarBg: 'bg-[var(--accent-green)]/10', + avatarIcon: 'text-[var(--accent-green)]', }, pending: { - dot: 'bg-orange-400 animate-pulse', - fullBorder: 'border-orange-500/30', - iconColor: 'text-orange-400', - avatarBg: 'bg-orange-500/20', - avatarIcon: 'text-orange-400', + dot: 'bg-[var(--accent-amber)] animate-pulse', + fullBorder: 'border-[var(--accent-amber)]/30', + iconColor: 'text-[var(--accent-amber)]', + avatarBg: 'bg-[var(--accent-amber)]/10', + avatarIcon: 'text-[var(--accent-amber)]', }, }[status]; @@ -450,35 +445,39 @@ export const MessageBubble = memo(function MessageBubble({ // - interrupted: 子代理因服务器重启被中断 const taskStatusConfig: Record = { running: { - dot: 'bg-amber-400 animate-pulse', - borderColor: 'border-amber-500/40', - iconColor: 'text-amber-400', + dot: 'bg-[var(--accent-amber)] animate-pulse', + borderColor: 'border-[var(--accent-amber)]/40', + iconColor: 'text-[var(--accent-amber)]', }, success: { - dot: 'bg-emerald-400', - borderColor: 'border-emerald-500/40', - iconColor: 'text-emerald-400', + dot: 'bg-[var(--accent-green)]', + borderColor: 'border-[var(--accent-green)]/40', + iconColor: 'text-[var(--accent-green)]', }, completed: { - dot: 'bg-emerald-400', - borderColor: 'border-emerald-500/40', - iconColor: 'text-emerald-400', + dot: 'bg-[var(--accent-green)]', + borderColor: 'border-[var(--accent-green)]/40', + iconColor: 'text-[var(--accent-green)]', + }, + failed: { + dot: 'bg-[rgb(242,90,90)]', + borderColor: 'border-[rgb(242,90,90)]/40', + iconColor: 'text-[rgb(242,90,90)]', }, - failed: { dot: 'bg-red-400', borderColor: 'border-red-500/40', iconColor: 'text-red-400' }, timeout: { - dot: 'bg-amber-400', - borderColor: 'border-amber-500/40', - iconColor: 'text-amber-400', + dot: 'bg-[var(--accent-amber)]', + borderColor: 'border-[var(--accent-amber)]/40', + iconColor: 'text-[var(--accent-amber)]', }, cancelled: { - dot: 'bg-zinc-400', - borderColor: 'border-zinc-500/40', - iconColor: 'text-zinc-400', + dot: 'bg-[var(--text-muted)]', + borderColor: 'border-[var(--border-color)]', + iconColor: 'text-[var(--text-muted)]', }, interrupted: { - dot: 'bg-orange-400', - borderColor: 'border-orange-500/40', - iconColor: 'text-orange-400', + dot: 'bg-[var(--accent-amber)]', + borderColor: 'border-[var(--accent-amber)]/40', + iconColor: 'text-[var(--accent-amber)]', }, }; @@ -486,38 +485,13 @@ export const MessageBubble = memo(function MessageBubble({ const taskStyle = taskResult ? (taskStatusConfig[taskResult.status] ?? taskStatusConfig.failed) : null; return ( -
+
setToolExpanded(!toolExpanded)} + className={`group w-full cursor-pointer rounded-xl border bg-[var(--bg-secondary)]/60 transition-colors duration-300 hover:bg-[var(--bg-secondary)] ${ + taskStyle ? taskStyle.borderColor : statusConfig.fullBorder }`} > - {isTaskTool ? ( - - ) : ( - - )} -
-
-
- - {message.toolName || 'Tool'} - - {isTaskTool && ( - - 子智能体·{subagentType} - - )} - - {formatTime(message.timestamp)} - -
-
setToolExpanded(!toolExpanded)} - className={`cursor-pointer rounded-xl border bg-[var(--bg-tertiary)]/60 w-full transition-all duration-500 hover:bg-[var(--bg-tertiary)]/80 group ${ - taskStyle ? taskStyle.borderColor : statusConfig.fullBorder - }`} - > {/* Header row */}
@@ -742,7 +716,15 @@ export const MessageBubble = memo(function MessageBubble({ )}
)} -
+
+
+ {message.toolName || 'Tool'} + {isTaskTool && ( + + 子智能体·{subagentType} + + )} + {formatTime(message.timestamp)}
{lightboxElement} {showDetailModal && ( @@ -776,76 +758,39 @@ export const MessageBubble = memo(function MessageBubble({ return null; } - const getIcon = () => { - if (isUser) return ; - if (isTool) { - if (message.type === 'tool_call') return ; - if (message.type === 'tool_result') return ; - if (message.type === 'tool_pending') return ; - return ; - } - return ; - }; - const getContainerStyles = () => { - if (isUser) { - return 'bg-gradient-to-br from-[var(--accent-cyan)]/20 to-[var(--accent-blue)]/20 border-[var(--accent-cyan)]/30 text-[var(--text-primary)]'; - } - if (isTool) { - if (message.type === 'tool_call') return 'bg-amber-500/10 border-amber-500/30 text-amber-100'; - if (message.type === 'tool_result') - return 'bg-emerald-500/10 border-emerald-500/30 text-emerald-100'; - if (message.type === 'tool_pending') - return 'bg-orange-500/10 border-orange-500/30 text-orange-100'; - return 'bg-[var(--bg-hover)] border-[var(--border-color)] text-[var(--text-secondary)]'; - } - return 'bg-[var(--bg-tertiary)] border-[var(--border-color)] text-[var(--text-primary)]'; - }; - - const getAvatarStyles = () => { - if (isUser) return 'bg-gradient-to-br from-[var(--accent-cyan)] to-[var(--accent-blue)]'; - if (isTool) { - if (message.type === 'tool_call') return 'bg-amber-500'; - if (message.type === 'tool_result') return 'bg-emerald-500'; - if (message.type === 'tool_pending') return 'bg-orange-500'; - return 'bg-zinc-700'; - } - return 'bg-gradient-to-br from-[var(--accent-purple)] to-[#ec4899]'; - }; return (
-
- {getIcon()} -
-
-
- - {isUser ? 'You' : isTool ? message.toolName || 'Tool' : 'Assistant'} + {!isUser && ( +
+ {isTool && message.toolName ? ( + + {message.toolName} + + ) : null} + + {formatTime(message.timestamp)} - {formatTime(message.timestamp)}
-
- {isTool && message.toolName && ( -
- - {message.toolName} -
- )} - {isUser ? ( - // 用户消息保持纯文本 -
{message.content}
- ) : ( + )} +
+ {isUser ? ( + // 用户消息保持纯文本(右对齐柔和气泡) +
+ {message.content} +
+ ) : ( // 模型思考内容(仅助手消息,非工具消息) <> {showThinking && !isTool && message.reasoningContent && ( @@ -855,7 +800,7 @@ export const MessageBubble = memo(function MessageBubble({ )} {/* AI 和工具消息使用 Markdown 渲染 */} {message.content.trim() && ( -
+
)} -
+ {isUser && ( +
+ + {formatTime(message.timestamp)} + + +
+ )} {lightboxElement}
); diff --git a/web/src/components/Chat/MessageInput.tsx b/web/src/components/Chat/MessageInput.tsx index df97dd9..9352a16 100644 --- a/web/src/components/Chat/MessageInput.tsx +++ b/web/src/components/Chat/MessageInput.tsx @@ -2,7 +2,6 @@ import { Send, Loader2, Square, - Sparkles, Eye, Paperclip, X, @@ -284,23 +283,21 @@ export function MessageInput({ // 只读模式:显示提示占位符 if (isReadOnly) { return ( -
-
-
-
-
- - 只读模式 -
-

- {channelName ? ( - <>{channelName} 通道仅支持查看历史消息 - ) : ( - '当前通道仅支持查看历史消息' - )} -

-

请切换至 WebSocket 通道进行输入

+
+
+
+
+ + 只读模式
+

+ {channelName ? ( + <>{channelName} 通道仅支持查看历史消息 + ) : ( + '当前通道仅支持查看历史消息' + )} +

+

请切换至 WebSocket 通道进行输入

@@ -308,70 +305,87 @@ export function MessageInput({ } return ( -
-
- {/* 错误提示 */} - {error && ( -
- {error} -
- )} +
+ {/* 错误提示 */} + {error && ( +
+ {error} +
+ )} - {/* 附件预览列表 */} - {attachments.length > 0 && ( -
- {attachments.map((att, index) => ( -
0 && ( +
+ {attachments.map((att, index) => ( +
+ {att.preview ? ( + {att.attachment.file_name} + ) : ( +
+ {getAttachmentIcon(att.attachment.media_type)} +
+ )} + + {att.attachment.file_name} + + -
- ))} + + +
+ ))} +
+ )} + + {/* Composer 卡片 */} +
+ {/* 拖拽提示 */} + {isDragging && ( +
+
拖放文件到这里
)} - {/* 输入区域 */} -
- {/* 拖拽提示 */} - {isDragging && ( -
-
拖放文件到这里
-
- )} +