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
|
# Rust
|
||||||
reference/**
|
target
|
||||||
.env
|
|
||||||
*.env
|
|
||||||
AGENTS.md
|
|
||||||
CLAUDE.md
|
|
||||||
Cargo.lock
|
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/
|
.playwright-cli/
|
||||||
.venv
|
.venv
|
||||||
|
.claude
|
||||||
|
|
||||||
|
# Project specific
|
||||||
|
AGENTS.md
|
||||||
|
CLAUDE.md
|
||||||
PicoBot.code-workspace
|
PicoBot.code-workspace
|
||||||
.picobot
|
.picobot
|
||||||
.claude
|
|
||||||
output
|
output
|
||||||
.python-version
|
.python-version
|
||||||
pyproject.toml
|
pyproject.toml
|
||||||
|
|||||||
@ -46,3 +46,4 @@ rmcp = { git = "https://github.com/modelcontextprotocol/rust-sdk", branch = "mai
|
|||||||
] }
|
] }
|
||||||
schemars = "1.0"
|
schemars = "1.0"
|
||||||
http = "1"
|
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;
|
let tool_results = self.execute_tools(&response.tool_calls).await;
|
||||||
|
|
||||||
for (tool_call, result) in response.tool_calls.iter().zip(tool_results.iter()) {
|
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
|
// Truncate tool result if too large
|
||||||
let truncated_output =
|
let truncated_output =
|
||||||
truncate_tool_result(&result.output, self.runtime_config.tool_result_max_chars);
|
truncate_tool_result(&result.output, self.runtime_config.tool_result_max_chars);
|
||||||
@ -1202,6 +1193,15 @@ impl AgentLoop {
|
|||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let tool_name = tool_call.name.clone();
|
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
|
// Record ToolCallStart event
|
||||||
if let Some(ref observer) = self.observer {
|
if let Some(ref observer) = self.observer {
|
||||||
observer.record_event(&ObserverEvent::ToolCallStart {
|
observer.record_event(&ObserverEvent::ToolCallStart {
|
||||||
|
|||||||
@ -30,6 +30,7 @@ use std::collections::HashMap;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::sync::Semaphore;
|
use tokio::sync::Semaphore;
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
|
|
||||||
use crate::bus::MessageBus;
|
use crate::bus::MessageBus;
|
||||||
use crate::channels::ChannelManager;
|
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_host = host.unwrap_or_else(|| state.config.gateway.host.clone());
|
||||||
let bind_port = port.unwrap_or(state.config.gateway.port);
|
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()
|
let app = Router::new()
|
||||||
.route("/health", routing::get(http::health))
|
.route("/health", routing::get(http::health))
|
||||||
.route("/ws", routing::get(ws::ws_handler))
|
.route("/ws", routing::get(ws::ws_handler))
|
||||||
|
.fallback_service(ServeDir::new(&static_dir))
|
||||||
.with_state(state.clone());
|
.with_state(state.clone());
|
||||||
|
|
||||||
let addr = format!("{}:{}", bind_host, bind_port);
|
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