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:
parent
644f5f9132
commit
624d8e8943
30
.gitignore
vendored
30
.gitignore
vendored
@ -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
|
||||
|
||||
@ -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
70
Makefile
Normal 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
37
build.sh
Normal 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 ""
|
||||
@ -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 {
|
||||
|
||||
@ -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
41
web/.gitignore
vendored
Normal 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
12
web/index.html
Normal 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
3062
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
web/package.json
Normal file
29
web/package.json
Normal 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
5
web/postcss.config.js
Normal file
@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
156
web/src/App.tsx
Normal file
156
web/src/App.tsx
Normal 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
|
||||
24
web/src/components/Chat/ChatContainer.tsx
Normal file
24
web/src/components/Chat/ChatContainer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
167
web/src/components/Chat/MessageBubble.tsx
Normal file
167
web/src/components/Chat/MessageBubble.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
web/src/components/Chat/MessageInput.tsx
Normal file
76
web/src/components/Chat/MessageInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
web/src/components/Chat/MessageList.tsx
Normal file
49
web/src/components/Chat/MessageList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
web/src/components/ConnectionStatus.tsx
Normal file
48
web/src/components/ConnectionStatus.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
136
web/src/components/Panel/ToolPanel.tsx
Normal file
136
web/src/components/Panel/ToolPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
75
web/src/components/Sidebar/TopicList.tsx
Normal file
75
web/src/components/Sidebar/TopicList.tsx
Normal 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
205
web/src/hooks/useChat.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
127
web/src/hooks/useWebSocket.ts
Normal file
127
web/src/hooks/useWebSocket.ts
Normal 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
207
web/src/index.css
Normal 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
10
web/src/main.tsx
Normal 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
216
web/src/types/protocol.ts
Normal 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
1
web/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
11
web/tailwind.config.js
Normal file
11
web/tailwind.config.js
Normal 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
21
web/tsconfig.json
Normal 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
10
web/tsconfig.node.json
Normal 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
27
web/vite.config.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user