feat: 添加 React Web UI 前端界面

- 使用 React 18 + TypeScript + Vite + Tailwind CSS 构建前端
- 实现 WebSocket 实时通信(useWebSocket hook)
- 添加聊天界面组件(MessageList, MessageBubble, MessageInput)
- 集成 Topic 管理(新建、列出、切换)
- 支持 Markdown 渲染(react-markdown + remark-gfm)
- 添加工具调用展示面板
- 实现深色科技主题(Tech Dark)
- 后端集成静态文件服务(tower-http)
- 添加 Makefile 和 build.sh 构建脚本
- 更新 .gitignore 忽略前端构建产物

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
oudecheng 2026-05-26 17:43:15 +08:00
parent 644f5f9132
commit 624d8e8943
29 changed files with 4859 additions and 16 deletions

30
.gitignore vendored
View File

@ -1,15 +1,31 @@
/target
reference/**
.env
*.env
AGENTS.md
CLAUDE.md
# Rust
target
Cargo.lock
# Frontend
web/node_modules
web/dist
web/*.log
web/.env
web/.env.*
web/.cache
web/coverage
web/*.local
# Build output
static
# IDE & Tools
reference/**
.playwright-cli/
.venv
.claude
# Project specific
AGENTS.md
CLAUDE.md
PicoBot.code-workspace
.picobot
.claude
output
.python-version
pyproject.toml

View File

@ -46,3 +46,4 @@ rmcp = { git = "https://github.com/modelcontextprotocol/rust-sdk", branch = "mai
] }
schemars = "1.0"
http = "1"
tower-http = { version = "0.6", features = ["fs"] }

70
Makefile Normal file
View File

@ -0,0 +1,70 @@
# PicoBot Web UI Makefile
.PHONY: dev dev-backend dev-frontend build clean install
# Default target
all: build
# Install dependencies
install:
@echo "Installing frontend dependencies..."
cd web && npm install
# Development - start both backend and frontend
dev:
@echo "Starting development servers..."
@make dev-backend &
@sleep 3
@make dev-frontend
@wait
# Start backend only
dev-backend:
@echo "Starting Rust backend..."
cargo run -- gateway
# Start frontend only
dev-frontend:
@echo "Starting frontend dev server..."
cd web && npm run dev
# Build for production
build:
@echo "Building PicoBot Web UI..."
cd web && npm run build
cargo build --release
@echo "Build complete!"
# Run production build
run:
cargo run --release -- gateway
# Clean build artifacts
clean:
@echo "Cleaning build artifacts..."
rm -rf static/*
cd web && rm -rf dist node_modules
cargo clean
# Check code formatting and linting
check:
@echo "Checking frontend..."
cd web && npm run build
@echo "Checking Rust code..."
cargo check
cargo clippy
# Help
help:
@echo "PicoBot Web UI Makefile"
@echo ""
@echo "Available targets:"
@echo " make install - Install frontend dependencies"
@echo " make dev - Start both backend and frontend (development)"
@echo " make dev-backend - Start Rust backend only"
@echo " make dev-frontend - Start frontend dev server only"
@echo " make build - Build for production"
@echo " make run - Run production build"
@echo " make clean - Clean build artifacts"
@echo " make check - Check code formatting and linting"
@echo " make help - Show this help message"

37
build.sh Normal file
View File

@ -0,0 +1,37 @@
#!/bin/bash
set -e
echo "========================================"
echo "PicoBot Web UI Build Script"
echo "========================================"
# Check if we're in the right directory
if [ ! -f "Cargo.toml" ]; then
echo "Error: Please run this script from the project root directory"
exit 1
fi
echo ""
echo "Step 1: Building frontend..."
echo "----------------------------------------"
cd web
npm install
npm run build
cd ..
echo ""
echo "Step 2: Building Rust backend..."
echo "----------------------------------------"
cargo build --release
echo ""
echo "========================================"
echo "Build complete!"
echo "========================================"
echo ""
echo "To run the application:"
echo " cargo run --release -- gateway"
echo ""
echo "Then open your browser to:"
echo " http://127.0.0.1:19876"
echo ""

View File

@ -949,15 +949,6 @@ impl AgentLoop {
let tool_results = self.execute_tools(&response.tool_calls).await;
for (tool_call, result) in response.tool_calls.iter().zip(tool_results.iter()) {
// Log function call with name and arguments
let args_str = match &tool_call.arguments {
serde_json::Value::Object(obj) if obj.is_empty() => "{}".to_string(),
other => {
serde_json::to_string_pretty(other).unwrap_or_else(|_| other.to_string())
}
};
tracing::info!(tool = %tool_call.name, args = %args_str, "Calling tool");
// Truncate tool result if too large
let truncated_output =
truncate_tool_result(&result.output, self.runtime_config.tool_result_max_chars);
@ -1202,6 +1193,15 @@ impl AgentLoop {
let start = Instant::now();
let tool_name = tool_call.name.clone();
// Log function call with name and arguments before execution
let args_str = match &tool_call.arguments {
serde_json::Value::Object(obj) if obj.is_empty() => "{}".to_string(),
other => {
serde_json::to_string_pretty(other).unwrap_or_else(|_| other.to_string())
}
};
tracing::info!(tool = %tool_call.name, args = %args_str, "Calling tool");
// Record ToolCallStart event
if let Some(ref observer) = self.observer {
observer.record_event(&ObserverEvent::ToolCallStart {

View File

@ -30,6 +30,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio::sync::Semaphore;
use tower_http::services::ServeDir;
use crate::bus::MessageBus;
use crate::channels::ChannelManager;
@ -182,9 +183,12 @@ pub async fn run(
let bind_host = host.unwrap_or_else(|| state.config.gateway.host.clone());
let bind_port = port.unwrap_or(state.config.gateway.port);
let static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| "static".to_string());
let app = Router::new()
.route("/health", routing::get(http::health))
.route("/ws", routing::get(ws::ws_handler))
.fallback_service(ServeDir::new(&static_dir))
.with_state(state.clone());
let addr = format!("{}:{}", bind_host, bind_port);

41
web/.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Build outputs
dist
dist-ssr
*.local
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Environment variables
.env
.env.*
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Testing
coverage
# Cache
.cache
temp
*.tmp

12
web/index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PicoBot</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3062
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
web/package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "picobot-web",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"lucide-react": "^1.16.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"typescript": "^6.0.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.0",
"@vitejs/plugin-react": "^6.0.2",
"autoprefixer": "^10.5.0",
"postcss": "^8.5.15",
"tailwindcss": "^4.3.0",
"vite": "^8.0.14"
}
}

5
web/postcss.config.js Normal file
View File

@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}

156
web/src/App.tsx Normal file
View File

@ -0,0 +1,156 @@
import { useCallback, useMemo } from 'react'
import { Zap, Cpu, Activity } from 'lucide-react'
import { ChatContainer } from './components/Chat/ChatContainer'
import { TopicList } from './components/Sidebar/TopicList'
import { ToolPanel } from './components/Panel/ToolPanel'
import { ConnectionStatus } from './components/ConnectionStatus'
import { useWebSocket } from './hooks/useWebSocket'
import { useChat } from './hooks/useChat'
import type { Command } from './types/protocol'
const WS_URL = 'ws://127.0.0.1:19876/ws'
function App() {
const {
messages,
currentSessionId,
currentTopicId,
topics,
isLoading,
handleMessage,
handleCommand,
handleServerMessage,
} = useChat()
const { status, sendMessage } = useWebSocket({
url: WS_URL,
onMessage: handleServerMessage,
})
const handleSendMessage = useCallback(
(content: string) => {
if (content.startsWith('/')) {
const parts = content.slice(1).split(' ')
const command = parts[0]
const args = parts.slice(1)
let cmd: Command
switch (command) {
case 'new':
cmd = { type: 'create_session', title: args.join(' ') || undefined }
break
case 'list':
cmd = { type: 'list_sessions', include_archived: args[0] === 'all' }
break
case 'use':
if (args[0]) {
cmd = { type: 'switch_session', session_id: args[0] }
} else {
alert('Usage: /use <session_id>')
return
}
break
case 'save':
cmd = { type: 'save_topic', filepath: args[0] || undefined, include_subagents: false }
break
default:
alert(`Unknown command: /${command}`)
return
}
handleCommand(cmd)
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
} else {
handleMessage(content)
sendMessage({
type: 'message',
content,
chat_id: currentTopicId ?? undefined,
})
}
},
[sendMessage, handleMessage, handleCommand, currentTopicId]
)
const handleCreateTopic = useCallback(() => {
const title = prompt('Enter topic title:')
if (title) {
const cmd: Command = { type: 'create_session', title }
handleCommand(cmd)
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
}
}, [sendMessage, handleCommand])
const handleSwitchTopic = useCallback(
(topicId: string) => {
const cmd: Command = { type: 'switch_session', session_id: topicId }
handleCommand(cmd)
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
},
[sendMessage, handleCommand]
)
const toolMessages = useMemo(() => messages, [messages])
return (
<div className="flex h-screen flex-col bg-[#0a0a0f] text-white overflow-hidden">
{/* Header */}
<header className="flex items-center justify-between border-b border-white/8 bg-[#12121a]/80 backdrop-blur-md px-6 py-4">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Zap className="h-6 w-6 text-[#00f0ff]" />
<h1 className="text-xl font-bold tracking-tight">
<span className="text-white">Pico</span>
<span className="text-[#00f0ff] glow-text">Bot</span>
</h1>
</div>
<div className="h-4 w-px bg-white/20" />
<ConnectionStatus status={status} />
</div>
<div className="flex items-center gap-4 text-sm text-zinc-400">
<div className="flex items-center gap-2">
<Cpu className="h-4 w-4 text-[#00f0ff]" />
<span>AI Ready</span>
</div>
{currentSessionId && (
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-emerald-400" />
<span className="font-mono text-xs">
{currentSessionId.slice(0, 8)}...
</span>
</div>
)}
</div>
</header>
{/* Main Content */}
<div className="flex flex-1 overflow-hidden">
{/* Left Sidebar - Topic List */}
<div className="w-72 shrink-0 border-r border-white/8 bg-[#12121a]/50">
<TopicList
topics={topics}
currentTopicId={currentTopicId}
onCreateTopic={handleCreateTopic}
onSwitchTopic={handleSwitchTopic}
/>
</div>
{/* Center - Chat */}
<div className="flex-1 min-w-0 bg-[#0a0a0f]">
<ChatContainer
messages={messages}
isLoading={isLoading}
onSendMessage={handleSendMessage}
/>
</div>
{/* Right Sidebar - Tool Panel */}
<div className="w-80 shrink-0 border-l border-white/8 bg-[#12121a]/50">
<ToolPanel messages={toolMessages} />
</div>
</div>
</div>
)
}
export default App

View File

@ -0,0 +1,24 @@
import { MessageList } from './MessageList'
import { MessageInput } from './MessageInput'
import type { ChatMessage } from '../../types/protocol'
interface ChatContainerProps {
messages: ChatMessage[]
isLoading: boolean
onSendMessage: (content: string) => void
}
export function ChatContainer({
messages,
isLoading,
onSendMessage,
}: ChatContainerProps) {
return (
<div className="flex h-full flex-col">
<div className="flex-1 overflow-hidden">
<MessageList messages={messages} />
</div>
<MessageInput onSend={onSendMessage} disabled={isLoading} />
</div>
)
}

View File

@ -0,0 +1,167 @@
import { User, Bot, Wrench, CheckCircle, AlertCircle, Terminal } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import type { ChatMessage } from '../../types/protocol'
interface MessageBubbleProps {
message: ChatMessage
}
export function MessageBubble({ message }: MessageBubbleProps) {
const isUser = message.role === 'user'
const isTool = message.role === 'tool'
const getIcon = () => {
if (isUser) return <User className="h-4 w-4" />
if (isTool) {
if (message.type === 'tool_call') return <Terminal className="h-4 w-4" />
if (message.type === 'tool_result') return <CheckCircle className="h-4 w-4" />
if (message.type === 'tool_pending') return <AlertCircle className="h-4 w-4" />
return <Wrench className="h-4 w-4" />
}
return <Bot className="h-4 w-4" />
}
const getContainerStyles = () => {
if (isUser) {
return 'bg-gradient-to-br from-[#00f0ff]/20 to-[#3b82f6]/20 border-[#00f0ff]/30 text-white'
}
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-zinc-800/50 border-zinc-700 text-zinc-300'
}
return 'bg-[#1a1a25] border-white/10 text-white'
}
const getAvatarStyles = () => {
if (isUser) return 'bg-gradient-to-br from-[#00f0ff] to-[#3b82f6]'
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-[#8b5cf6] to-[#ec4899]'
}
const formatTime = (timestamp: number) => {
return new Date(timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})
}
return (
<div className={`flex gap-4 ${isUser ? 'flex-row-reverse' : 'flex-row'} animate-slide-in`}>
<div
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${getAvatarStyles()} shadow-lg`}
>
{getIcon()}
</div>
<div className={`max-w-[80%] ${isUser ? 'text-right' : 'text-left'}`}>
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs font-medium ${isUser ? 'text-[#00f0ff]' : 'text-zinc-400'}`}>
{isUser ? 'You' : isTool ? message.toolName || 'Tool' : 'Assistant'}
</span>
<span className="text-xs text-zinc-600">{formatTime(message.timestamp)}</span>
</div>
<div
className={`inline-block rounded-2xl border px-5 py-3 text-left shadow-lg ${getContainerStyles()}`}
>
{isTool && message.toolName && (
<div className="mb-2 text-xs font-semibold opacity-70 flex items-center gap-1">
<Terminal className="h-3 w-3" />
{message.toolName}
</div>
)}
{isUser ? (
// 用户消息保持纯文本
<div className="whitespace-pre-wrap text-sm leading-relaxed">{message.content}</div>
) : (
// AI 和工具消息使用 Markdown 渲染
<div className="markdown-content text-sm leading-relaxed">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// 自定义代码块渲染
code({ className, children, ...props }) {
const isInline = !className
if (isInline) {
return (
<code
className="bg-black/30 px-1.5 py-0.5 rounded text-[#00f0ff] font-mono text-xs"
{...props}
>
{children}
</code>
)
}
return (
<pre className="bg-black/40 rounded-lg p-3 overflow-x-auto my-2">
<code className={`${className} font-mono text-xs`} {...props}>
{children}
</code>
</pre>
)
},
// 标题样式
h1: ({ children }) => (
<h1 className="text-xl font-bold text-white mb-2 mt-4">{children}</h1>
),
h2: ({ children }) => (
<h2 className="text-lg font-bold text-white mb-2 mt-3">{children}</h2>
),
h3: ({ children }) => (
<h3 className="text-base font-bold text-white mb-1 mt-2">{children}</h3>
),
// 段落
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
// 列表
ul: ({ children }) => <ul className="list-disc list-inside mb-2 space-y-1">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal list-inside mb-2 space-y-1">{children}</ol>,
// 链接
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-[#00f0ff] hover:underline"
>
{children}
</a>
),
// 表格
table: ({ children }) => (
<table className="w-full border-collapse mb-2 text-xs">{children}</table>
),
thead: ({ children }) => <thead className="bg-white/10">{children}</thead>,
th: ({ children }) => (
<th className="border border-white/10 px-2 py-1 text-left font-semibold">{children}</th>
),
td: ({ children }) => (
<td className="border border-white/10 px-2 py-1">{children}</td>
),
// 引用块
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-[#00f0ff]/50 pl-3 my-2 text-zinc-400">
{children}
</blockquote>
),
// 分隔线
hr: () => <hr className="border-white/10 my-3" />,
// 加粗和斜体
strong: ({ children }) => <strong className="font-bold text-white">{children}</strong>,
em: ({ children }) => <em className="italic text-zinc-300">{children}</em>,
}}
>
{message.content}
</ReactMarkdown>
</div>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,76 @@
import { Send, Loader2, Sparkles } from 'lucide-react'
import { useState, useRef, useEffect } from 'react'
interface MessageInputProps {
onSend: (content: string) => void
disabled?: boolean
placeholder?: string
}
export function MessageInput({
onSend,
disabled = false,
placeholder = '输入消息...按 / 查看命令',
}: MessageInputProps) {
const [content, setContent] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
const textarea = textareaRef.current
if (textarea) {
textarea.style.height = 'auto'
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
}
}, [content])
const handleSend = () => {
if (content.trim() && !disabled) {
onSend(content.trim())
setContent('')
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
return (
<div className="border-t border-white/8 bg-[#12121a]/80 backdrop-blur-md p-4">
<div className="flex gap-3 items-end max-w-5xl mx-auto">
<div className="flex-1 relative">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
rows={1}
className="w-full resize-none rounded-xl border border-white/10 bg-[#1a1a25] px-4 py-3 pr-12 text-sm text-white placeholder:text-zinc-500 focus:border-[#00f0ff]/50 focus:outline-none focus:ring-1 focus:ring-[#00f0ff]/20 disabled:opacity-50 transition-all"
/>
<Sparkles className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-600 pointer-events-none" />
</div>
<button
onClick={handleSend}
disabled={disabled || !content.trim()}
className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-r from-[#00f0ff] to-[#3b82f6] text-white shadow-lg shadow-[#00f0ff]/20 hover:shadow-xl hover:shadow-[#00f0ff]/30 hover:scale-105 disabled:opacity-50 disabled:hover:scale-100 disabled:cursor-not-allowed transition-all"
>
{disabled ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</button>
</div>
<div className="mt-2 text-center text-xs text-zinc-500">
Enter Shift+Enter
</div>
</div>
)
}

View File

@ -0,0 +1,49 @@
import { useEffect, useRef } from 'react'
import { MessageBubble } from './MessageBubble'
import type { ChatMessage } from '../../types/protocol'
import { Sparkles } from 'lucide-react'
interface MessageListProps {
messages: ChatMessage[]
}
export function MessageList({ messages }: MessageListProps) {
const bottomRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: 'smooth' })
}
}, [messages])
if (messages.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center animate-fade-in">
<div className="mb-6 inline-flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-[#00f0ff]/20 to-[#3b82f6]/20 shadow-2xl shadow-[#00f0ff]/20">
<Sparkles className="h-10 w-10 text-[#00f0ff]" />
</div>
<h2 className="mb-2 text-2xl font-bold text-white"></h2>
<p className="text-zinc-500"> AI </p>
<div className="mt-8 flex items-center justify-center gap-4 text-sm text-zinc-600">
<span className="px-3 py-1 rounded-full bg-zinc-800/50 border border-zinc-700">/new </span>
<span className="px-3 py-1 rounded-full bg-zinc-800/50 border border-zinc-700">/list </span>
</div>
</div>
</div>
)
}
return (
<div
ref={containerRef}
className="h-full overflow-y-auto p-6 space-y-6"
>
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
<div ref={bottomRef} />
</div>
)
}

View File

@ -0,0 +1,48 @@
import { Wifi, WifiOff, Loader2 } from 'lucide-react'
import type { ConnectionStatus } from '../types/protocol'
interface ConnectionStatusProps {
status: ConnectionStatus
}
export function ConnectionStatus({ status }: ConnectionStatusProps) {
const getStatusConfig = () => {
switch (status) {
case 'connecting':
return {
icon: <Loader2 className="h-3 w-3 animate-spin" />,
text: '连接中',
className: 'text-amber-400 bg-amber-400/10 border-amber-400/30',
}
case 'connected':
return {
icon: <Wifi className="h-3 w-3" />,
text: '已连接',
className: 'text-emerald-400 bg-emerald-400/10 border-emerald-400/30',
}
case 'disconnected':
return {
icon: <WifiOff className="h-3 w-3" />,
text: '已断开',
className: 'text-zinc-400 bg-zinc-400/10 border-zinc-400/30',
}
case 'error':
return {
icon: <WifiOff className="h-3 w-3" />,
text: '连接错误',
className: 'text-red-400 bg-red-400/10 border-red-400/30',
}
}
}
const config = getStatusConfig()
if (!config) return null
return (
<div className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs border ${config.className}`}>
{config.icon}
<span>{config.text}</span>
</div>
)
}

View File

@ -0,0 +1,136 @@
import { ChevronDown, ChevronRight, Play, Check, AlertTriangle, Terminal } from 'lucide-react'
import { useState } from 'react'
import type { ChatMessage } from '../../types/protocol'
interface ToolPanelProps {
messages: ChatMessage[]
}
interface ToolCallItem {
id: string
toolName: string
status: 'calling' | 'result' | 'pending'
arguments?: unknown
content: string
}
export function ToolPanel({ messages }: ToolPanelProps) {
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set())
const toolCalls: ToolCallItem[] = messages
.filter((m) => m.role === 'tool' && m.type && m.type.startsWith('tool_'))
.map((m) => ({
id: m.id,
toolName: m.toolName || 'Unknown',
status: m.type === 'tool_call' ? 'calling' : m.type === 'tool_result' ? 'result' : 'pending',
arguments: m.arguments,
content: m.content,
}))
const toggleExpand = (id: string) => {
setExpandedTools((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
const getStatusIcon = (status: ToolCallItem['status']) => {
switch (status) {
case 'calling':
return <Play className="h-3 w-3 text-amber-400" />
case 'result':
return <Check className="h-3 w-3 text-emerald-400" />
case 'pending':
return <AlertTriangle className="h-3 w-3 text-orange-400" />
}
}
const getStatusText = (status: ToolCallItem['status']) => {
switch (status) {
case 'calling':
return '执行中'
case 'result':
return '已完成'
case 'pending':
return '待确认'
}
}
if (toolCalls.length === 0) {
return (
<div className="flex h-full flex-col">
<div className="border-b border-white/8 p-4 font-semibold text-white flex items-center gap-2">
<Terminal className="h-4 w-4 text-[#00f0ff]" />
</div>
<div className="flex flex-1 items-center justify-center p-4 text-sm text-zinc-500">
<div className="text-center">
<Terminal className="h-10 w-10 mx-auto mb-3 opacity-30" />
</div>
</div>
</div>
)
}
return (
<div className="flex h-full flex-col">
<div className="border-b border-white/8 p-4 font-semibold text-white flex items-center gap-2">
<Terminal className="h-4 w-4 text-[#00f0ff]" />
<span className="ml-auto text-xs px-2 py-0.5 rounded-full bg-[#00f0ff]/10 text-[#00f0ff]">
{toolCalls.length}
</span>
</div>
<div className="flex-1 overflow-y-auto p-3">
<div className="space-y-2">
{toolCalls.map((tool) => (
<div
key={tool.id}
className="rounded-xl border border-white/8 bg-[#1a1a25]/50 text-sm overflow-hidden"
>
<button
onClick={() => toggleExpand(tool.id)}
className="flex w-full items-center justify-between px-3 py-2.5 hover:bg-white/5 transition-colors"
>
<div className="flex items-center gap-2">
{getStatusIcon(tool.status)}
<span className="font-medium text-zinc-300">{tool.toolName}</span>
<span className="text-xs text-zinc-500">
{getStatusText(tool.status)}
</span>
</div>
{expandedTools.has(tool.id) ? (
<ChevronDown className="h-4 w-4 text-zinc-500" />
) : (
<ChevronRight className="h-4 w-4 text-zinc-500" />
)}
</button>
{expandedTools.has(tool.id) && (
<div className="border-t border-white/8 px-3 py-2 bg-black/20">
{tool.arguments ? (
<div className="mb-2">
<div className="text-xs font-medium text-zinc-500 mb-1">:</div>
<pre className="rounded-lg bg-black/40 p-2 text-xs overflow-x-auto text-zinc-400 font-mono">{String(JSON.stringify(tool.arguments, null, 2))}</pre>
</div>
) : null}
<div>
<div className="text-xs font-medium text-zinc-500 mb-1">:</div>
<div className="max-h-32 overflow-y-auto rounded-lg bg-black/40 p-2 text-xs whitespace-pre-wrap text-zinc-400 font-mono">
{tool.content}
</div>
</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,75 @@
import { Plus, MessageSquare, Hash } from 'lucide-react'
import type { Topic } from '../../types/protocol'
interface TopicListProps {
topics: Topic[]
currentTopicId: string | null
onCreateTopic: () => void
onSwitchTopic: (topicId: string) => void
}
export function TopicList({
topics,
currentTopicId,
onCreateTopic,
onSwitchTopic,
}: TopicListProps) {
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b border-white/8 p-4">
<h2 className="font-semibold text-white flex items-center gap-2">
<Hash className="h-4 w-4 text-[#00f0ff]" />
Topics
</h2>
<button
onClick={onCreateTopic}
className="flex items-center gap-1 rounded-lg bg-[#00f0ff]/10 px-3 py-1.5 text-sm text-[#00f0ff] hover:bg-[#00f0ff]/20 border border-[#00f0ff]/30 transition-all"
>
<Plus className="h-4 w-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-3">
{topics.length === 0 ? (
<div className="p-4 text-center text-sm text-zinc-500">
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
Topic
</div>
) : (
<div className="space-y-1">
{topics.map((topic, index) => (
<button
key={topic.id}
onClick={() => onSwitchTopic(topic.id)}
className={`w-full rounded-xl px-3 py-3 text-left text-sm transition-all ${
topic.id === currentTopicId
? 'bg-gradient-to-r from-[#00f0ff]/20 to-transparent border border-[#00f0ff]/30'
: 'hover:bg-white/5 border border-transparent'
}`}
>
<div className="flex items-start gap-3">
<span className="mt-0.5 text-xs text-zinc-500 font-mono">{index + 1}</span>
<div className="min-w-0 flex-1">
<div className={`truncate font-medium ${
topic.id === currentTopicId ? 'text-[#00f0ff]' : 'text-zinc-300'
}`}>
{topic.title}
</div>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-zinc-500">
{topic.message_count}
</span>
{topic.id === currentTopicId && (
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[#00f0ff] shadow-lg shadow-[#00f0ff]/50" />
)}
</div>
</div>
</div>
</button>
))}
</div>
)}
</div>
</div>
)
}

205
web/src/hooks/useChat.ts Normal file
View File

@ -0,0 +1,205 @@
import { useState, useCallback, useRef } from 'react'
import type {
Command,
ChatMessage,
Topic,
WsOutbound,
AssistantResponse,
ToolCall,
ToolResult,
ToolPending,
SessionEstablished,
SessionCreated,
SessionList,
} from '../types/protocol'
interface UseChatReturn {
messages: ChatMessage[]
currentSessionId: string | null
currentTopicId: string | null
topics: Topic[]
isLoading: boolean
handleMessage: (content: string) => void
handleCommand: (command: Command) => void
clearMessages: () => void
handleServerMessage: (message: WsOutbound) => void
}
export function useChat(): UseChatReturn {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null)
const [currentTopicId, setCurrentTopicId] = useState<string | null>(null)
const [topics, setTopics] = useState<Topic[]>([])
const [isLoading, setIsLoading] = useState(false)
// Message ID generator
const messageIdCounter = useRef(0)
const generateMessageId = () => {
messageIdCounter.current += 1
return `msg_${Date.now()}_${messageIdCounter.current}`
}
const handleServerMessage = useCallback((message: WsOutbound) => {
switch (message.type) {
case 'session_established': {
const msg = message as SessionEstablished
setCurrentSessionId(msg.session_id)
break
}
case 'session_created': {
const msg = message as SessionCreated
setCurrentTopicId(msg.session_id)
setIsLoading(false)
break
}
case 'session_list': {
const msg = message as SessionList
// Convert sessions to topics format
const newTopics = msg.sessions.map((s) => ({
id: s.session_id,
session_id: s.session_id,
title: s.title,
message_count: Number(s.message_count),
created_at: s.last_active_at,
updated_at: s.last_active_at,
}))
setTopics(newTopics)
if (msg.current_session_id) {
setCurrentTopicId(msg.current_session_id)
}
break
}
case 'assistant_response': {
const msg = message as AssistantResponse
setMessages((prev) => [
...prev,
{
id: msg.id,
role: 'assistant',
content: msg.content,
timestamp: Date.now(),
type: 'message',
},
])
setIsLoading(false)
break
}
case 'tool_call': {
const msg = message as ToolCall
setMessages((prev) => [
...prev,
{
id: msg.id,
role: 'tool',
content: msg.content,
timestamp: Date.now(),
type: 'tool_call',
toolName: msg.tool_name,
arguments: msg.arguments,
},
])
break
}
case 'tool_result': {
const msg = message as ToolResult
setMessages((prev) => [
...prev,
{
id: msg.id,
role: 'tool',
content: msg.content,
timestamp: Date.now(),
type: 'tool_result',
toolName: msg.tool_name,
},
])
break
}
case 'tool_pending': {
const msg = message as ToolPending
setMessages((prev) => [
...prev,
{
id: msg.id,
role: 'tool',
content: `${msg.content}\n\n${msg.resume_hint}`,
timestamp: Date.now(),
type: 'tool_pending',
toolName: msg.tool_name,
},
])
break
}
case 'error': {
setMessages((prev) => [
...prev,
{
id: generateMessageId(),
role: 'assistant',
content: `Error: ${message.message}`,
timestamp: Date.now(),
type: 'message',
},
])
setIsLoading(false)
break
}
}
}, [])
const handleMessage = useCallback((content: string) => {
// Add user message to list
setMessages((prev) => [
...prev,
{
id: generateMessageId(),
role: 'user',
content,
timestamp: Date.now(),
type: 'message',
},
])
setIsLoading(true)
}, [])
const handleCommand = useCallback((command: Command) => {
// Handle local state updates for commands
switch (command.type) {
case 'create_session':
// Optimistically update
setIsLoading(true)
break
case 'list_sessions':
setIsLoading(true)
break
case 'switch_session':
setCurrentTopicId(command.session_id)
// Clear messages when switching topic
setMessages([])
break
}
}, [])
const clearMessages = useCallback(() => {
setMessages([])
}, [])
return {
messages,
currentSessionId,
currentTopicId,
topics,
isLoading,
handleMessage,
handleCommand,
clearMessages,
handleServerMessage,
}
}

View File

@ -0,0 +1,127 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import type { WsInbound, WsOutbound, ConnectionStatus } from '../types/protocol'
interface UseWebSocketOptions {
url: string
onMessage?: (message: WsOutbound) => void
onConnect?: () => void
onDisconnect?: () => void
onError?: (error: Event) => void
reconnectInterval?: number
maxReconnectAttempts?: number
}
interface UseWebSocketReturn {
status: ConnectionStatus
sendMessage: (message: WsInbound) => boolean
connect: () => void
disconnect: () => void
}
export function useWebSocket({
url,
onMessage,
onConnect,
onDisconnect,
onError,
reconnectInterval = 3000,
maxReconnectAttempts = 5,
}: UseWebSocketOptions): UseWebSocketReturn {
const [status, setStatus] = useState<ConnectionStatus>('disconnected')
const wsRef = useRef<WebSocket | null>(null)
const reconnectAttemptsRef = useRef(0)
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isManualDisconnectRef = useRef(false)
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
return
}
isManualDisconnectRef.current = false
setStatus('connecting')
try {
const ws = new WebSocket(url)
wsRef.current = ws
ws.onopen = () => {
setStatus('connected')
reconnectAttemptsRef.current = 0
onConnect?.()
}
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data) as WsOutbound
onMessage?.(message)
} catch (error) {
console.error('Failed to parse message:', error)
}
}
ws.onerror = (error) => {
setStatus('error')
onError?.(error)
}
ws.onclose = () => {
setStatus('disconnected')
onDisconnect?.()
// Auto reconnect if not manually disconnected
if (!isManualDisconnectRef.current && reconnectAttemptsRef.current < maxReconnectAttempts) {
reconnectAttemptsRef.current += 1
reconnectTimerRef.current = setTimeout(() => {
connect()
}, reconnectInterval)
}
}
} catch (error) {
setStatus('error')
console.error('WebSocket connection error:', error)
}
}, [url, onMessage, onConnect, onDisconnect, onError, reconnectInterval, maxReconnectAttempts])
const disconnect = useCallback(() => {
isManualDisconnectRef.current = true
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current)
reconnectTimerRef.current = null
}
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
setStatus('disconnected')
}, [])
const sendMessage = useCallback((message: WsInbound): boolean => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message))
return true
}
console.warn('WebSocket is not connected')
return false
}, [])
// Auto connect on mount
useEffect(() => {
connect()
// Cleanup on unmount
return () => {
disconnect()
}
}, [connect, disconnect])
return {
status,
sendMessage,
connect,
disconnect,
}
}

207
web/src/index.css Normal file
View File

@ -0,0 +1,207 @@
@import "tailwindcss";
/* PicoBot Theme - Tech Dark */
:root {
/* Core colors */
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-tertiary: #1a1a25;
--bg-hover: #252535;
/* Accent colors */
--accent-cyan: #00f0ff;
--accent-blue: #3b82f6;
--accent-purple: #8b5cf6;
--accent-green: #10b981;
--accent-amber: #f59e0b;
/* Text colors */
--text-primary: #ffffff;
--text-secondary: #a1a1aa;
--text-muted: #71717a;
--text-accent: var(--accent-cyan);
/* Border */
--border-color: rgba(255, 255, 255, 0.08);
--border-accent: rgba(0, 240, 255, 0.3);
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--accent-blue);
}
/* Base styles */
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', monospace;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
}
/* Custom selection */
::selection {
background: rgba(0, 240, 255, 0.3);
color: var(--text-primary);
}
/* Glowing text effect */
.glow-text {
text-shadow: 0 0 10px rgba(0, 240, 255, 0.5),
0 0 20px rgba(0, 240, 255, 0.3),
0 0 30px rgba(0, 240, 255, 0.1);
}
/* Gradient border */
.gradient-border {
position: relative;
}
.gradient-border::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(135deg, var(--accent-cyan), var(--accent-purple));
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
}
/* Animations */
@keyframes pulse-glow {
0%, 100% {
box-shadow: 0 0 5px var(--accent-cyan),
0 0 10px var(--accent-cyan),
0 0 20px var(--accent-cyan);
}
50% {
box-shadow: 0 0 10px var(--accent-cyan),
0 0 20px var(--accent-cyan),
0 0 40px var(--accent-cyan);
}
}
@keyframes slide-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes typing-dot {
0%, 60%, 100% {
transform: translateY(0);
}
30% {
transform: translateY(-4px);
}
}
.animate-slide-in {
animation: slide-in 0.3s ease-out;
}
.animate-fade-in {
animation: fade-in 0.2s ease-out;
}
.typing-indicator span {
animation: typing-dot 1.4s infinite;
display: inline-block;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
/* Code block styling */
pre {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 12px;
overflow-x: auto;
}
code {
font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', monospace;
font-size: 0.9em;
}
/* Focus styles */
input:focus,
textarea:focus {
outline: none;
border-color: var(--accent-cyan);
box-shadow: 0 0 0 2px rgba(0, 240, 255, 0.2);
}
/* Button hover effects */
button {
transition: all 0.2s ease;
}
button:hover:not(:disabled) {
transform: translateY(-1px);
}
button:active:not(:disabled) {
transform: translateY(0);
}
/* Link styles */
a {
color: var(--accent-cyan);
text-decoration: none;
transition: all 0.2s;
}
a:hover {
text-shadow: 0 0 8px var(--accent-cyan);
}

10
web/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

216
web/src/types/protocol.ts Normal file
View File

@ -0,0 +1,216 @@
// WebSocket Protocol Types - Aligned with Rust backend
// ============================================================================
// Inbound Messages (Client -> Server)
// ============================================================================
export interface WsInboundMessage {
type: 'message'
content: string
channel?: string
chat_id?: string
sender_id?: string
}
export interface WsInboundCommand {
type: 'command'
payload: string
}
export interface WsInboundPing {
type: 'ping'
}
export type WsInbound = WsInboundMessage | WsInboundCommand | WsInboundPing
// ============================================================================
// Outbound Messages (Server -> Client)
// ============================================================================
export interface AssistantResponse {
type: 'assistant_response'
id: string
content: string
role: string
}
export interface ToolCall {
type: 'tool_call'
id: string
tool_call_id: string
tool_name: string
arguments: unknown
content: string
role: string
}
export interface ToolResult {
type: 'tool_result'
id: string
tool_call_id: string
tool_name: string
content: string
role: string
}
export interface ToolPending {
type: 'tool_pending'
id: string
tool_call_id: string
tool_name: string
content: string
role: string
resume_hint: string
}
export interface WsError {
type: 'error'
code: string
message: string
}
export interface SessionEstablished {
type: 'session_established'
session_id: string
}
export interface SessionCreated {
type: 'session_created'
session_id: string
title: string
}
export interface SessionSummary {
session_id: string
title: string
channel_name: string
chat_id: string
message_count: number
last_active_at: number
archived_at?: number
}
export interface SessionList {
type: 'session_list'
sessions: SessionSummary[]
current_session_id?: string
}
export interface SessionLoaded {
type: 'session_loaded'
session_id: string
title: string
message_count: number
}
export interface SessionSaved {
type: 'session_saved'
session_id: string
filepath: string
}
export interface Pong {
type: 'pong'
}
export type WsOutbound =
| AssistantResponse
| ToolCall
| ToolResult
| ToolPending
| WsError
| SessionEstablished
| SessionCreated
| SessionList
| SessionLoaded
| SessionSaved
| Pong
// ============================================================================
// Commands
// ============================================================================
export interface CreateSessionCommand {
type: 'create_session'
title?: string
}
export interface ListSessionsCommand {
type: 'list_sessions'
include_archived: boolean
}
export interface SwitchSessionCommand {
type: 'switch_session'
session_id: string
}
export interface SaveTopicCommand {
type: 'save_topic'
filepath?: string
include_subagents: boolean
}
export interface SaveSessionCommand {
type: 'save_session'
filepath?: string
include_all: boolean
include_subagents: boolean
}
export interface LoadSessionCommand {
type: 'load_session'
session_id: string
}
export interface GetCurrentSessionCommand {
type: 'get_current_session'
}
export interface HelpCommand {
type: 'help'
}
export type Command =
| CreateSessionCommand
| ListSessionsCommand
| SwitchSessionCommand
| SaveTopicCommand
| SaveSessionCommand
| LoadSessionCommand
| GetCurrentSessionCommand
| HelpCommand
// ============================================================================
// UI Types
// ============================================================================
export interface ChatMessage {
id: string
role: 'user' | 'assistant' | 'tool'
content: string
timestamp: number
type?: 'message' | 'tool_call' | 'tool_result' | 'tool_pending'
toolName?: string
arguments?: unknown
}
export interface Topic {
id: string
session_id: string
title: string
description?: string
message_count: number
created_at: number
updated_at: number
}
export interface Session {
id: string
channel_name: string
title?: string
created_at: number
updated_at: number
}
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'error'

1
web/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

11
web/tailwind.config.js Normal file
View File

@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

21
web/tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"ignoreDeprecations": "6.0"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
web/tsconfig.node.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

27
web/vite.config.ts Normal file
View File

@ -0,0 +1,27 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 3000,
proxy: {
'/ws': {
target: 'ws://127.0.0.1:19876',
ws: true,
},
'/health': 'http://127.0.0.1:19876',
},
},
build: {
outDir: '../static',
emptyOutDir: true,
},
})