feat: 完善 Server 层并添加 CLI 和 Web 前端

Server 层增强:
- 添加 Agent 适配层,支持动态加载 core 模块
- 实现 Token 认证机制,支持本地/远程模式
- WebSocket 集成 Agent 实时对话

CLI 模块 (packages/cli):
- serve 命令启动 HTTP Server
- attach 命令连接远程 Server
- API Client 封装

Web 前端 (packages/web):
- React 18 + Vite + Tailwind CSS
- 会话管理侧边栏
- WebSocket 实时聊天界面
- 流式消息显示
This commit is contained in:
2025-12-12 11:22:25 +08:00
parent 5e32375f0e
commit 168996a475
35 changed files with 4028 additions and 52 deletions
+85
View File
@@ -0,0 +1,85 @@
/**
* Chat Input Component
*/
import { useState, useRef, useEffect } from 'react';
import { Send, Square } from 'lucide-react';
import clsx from 'clsx';
interface ChatInputProps {
onSend: (content: string) => void;
onCancel: () => void;
isLoading: boolean;
disabled?: boolean;
}
export function ChatInput({ onSend, onCancel, isLoading, disabled }: ChatInputProps) {
const [input, setInput] = 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`;
}
}, [input]);
const handleSubmit = () => {
const trimmed = input.trim();
if (!trimmed || isLoading || disabled) return;
onSend(trimmed);
setInput('');
// 重置高度
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
return (
<div className="border-t border-gray-700 p-4 bg-gray-900">
<div className="max-w-4xl mx-auto flex gap-2">
<div className="flex-1 relative">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message... (Shift+Enter for new line)"
disabled={disabled}
rows={1}
className={clsx(
'w-full resize-none rounded-lg border border-gray-600 bg-gray-800 px-4 py-3',
'text-gray-100 placeholder-gray-500',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
/>
</div>
<button
onClick={isLoading ? onCancel : handleSubmit}
disabled={!isLoading && (!input.trim() || disabled)}
className={clsx(
'px-4 py-3 rounded-lg flex items-center justify-center transition-colors',
isLoading
? 'bg-red-600 hover:bg-red-700 text-white'
: 'bg-primary-600 hover:bg-primary-700 text-white',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{isLoading ? <Square size={20} /> : <Send size={20} />}
</button>
</div>
</div>
);
}
@@ -0,0 +1,80 @@
/**
* Chat Message Component
*/
import { User, Bot } from 'lucide-react';
import clsx from 'clsx';
import type { Message } from '../api/client';
interface ChatMessageProps {
message: Message;
}
export function ChatMessage({ message }: ChatMessageProps) {
const isUser = message.role === 'user';
return (
<div
className={clsx(
'flex gap-4 p-4 rounded-lg',
isUser ? 'bg-gray-800' : 'bg-gray-850'
)}
>
<div
className={clsx(
'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0',
isUser ? 'bg-primary-600' : 'bg-green-600'
)}
>
{isUser ? <User size={18} /> : <Bot size={18} />}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm text-gray-400 mb-1">
{isUser ? 'You' : 'AI Assistant'}
</div>
<div className="message-content whitespace-pre-wrap break-words">
{message.content}
</div>
</div>
</div>
);
}
interface StreamingMessageProps {
content: string;
}
export function StreamingMessage({ content }: StreamingMessageProps) {
return (
<div className="flex gap-4 p-4 rounded-lg bg-gray-850">
<div className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 bg-green-600">
<Bot size={18} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm text-gray-400 mb-1">AI Assistant</div>
<div className="message-content whitespace-pre-wrap break-words">
{content}
<span className="inline-block w-2 h-4 bg-gray-400 animate-pulse ml-1" />
</div>
</div>
</div>
);
}
export function TypingIndicator() {
return (
<div className="flex gap-4 p-4 rounded-lg bg-gray-850">
<div className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 bg-green-600">
<Bot size={18} />
</div>
<div className="flex-1">
<div className="text-sm text-gray-400 mb-1">AI Assistant</div>
<div className="typing-indicator">
<span />
<span />
<span />
</div>
</div>
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
/**
* Sidebar Component
*/
import { useState, useEffect } from 'react';
import { Plus, MessageSquare, Trash2, RefreshCw } from 'lucide-react';
import clsx from 'clsx';
import { listSessions, createSession, deleteSession, type Session } from '../api/client';
interface SidebarProps {
currentSessionId: string | null;
onSelectSession: (id: string) => void;
onCreateSession: (session: Session) => void;
}
export function Sidebar({ currentSessionId, onSelectSession, onCreateSession }: SidebarProps) {
const [sessions, setSessions] = useState<Session[]>([]);
const [isLoading, setIsLoading] = useState(false);
const loadSessions = async () => {
setIsLoading(true);
try {
const { data } = await listSessions();
setSessions(data);
} catch (error) {
console.error('Failed to load sessions:', error);
} finally {
setIsLoading(false);
}
};
const handleCreate = async () => {
try {
const { data } = await createSession();
setSessions((prev) => [data, ...prev]);
onCreateSession(data);
} catch (error) {
console.error('Failed to create session:', error);
}
};
const handleDelete = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
try {
await deleteSession(id);
setSessions((prev) => prev.filter((s) => s.id !== id));
if (currentSessionId === id) {
const remaining = sessions.filter((s) => s.id !== id);
if (remaining.length > 0) {
onSelectSession(remaining[0].id);
}
}
} catch (error) {
console.error('Failed to delete session:', error);
}
};
useEffect(() => {
loadSessions();
}, []);
return (
<div className="w-64 bg-gray-800 border-r border-gray-700 flex flex-col">
{/* Header */}
<div className="p-4 border-b border-gray-700">
<button
onClick={handleCreate}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg transition-colors"
>
<Plus size={18} />
<span>New Chat</span>
</button>
</div>
{/* Session List */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="p-4 text-center text-gray-500">
<RefreshCw className="animate-spin inline-block" size={20} />
</div>
) : sessions.length === 0 ? (
<div className="p-4 text-center text-gray-500">
No conversations yet
</div>
) : (
<div className="p-2 space-y-1">
{sessions.map((session) => (
<div
key={session.id}
onClick={() => onSelectSession(session.id)}
className={clsx(
'flex items-center gap-2 p-3 rounded-lg cursor-pointer group',
'hover:bg-gray-700 transition-colors',
currentSessionId === session.id && 'bg-gray-700'
)}
>
<MessageSquare size={18} className="text-gray-400 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm truncate">
{session.name || `Chat ${session.id.slice(0, 8)}`}
</div>
<div className="text-xs text-gray-500">
{session.messageCount} messages
</div>
</div>
<button
onClick={(e) => handleDelete(session.id, e)}
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-gray-600 rounded transition-all"
>
<Trash2 size={14} className="text-gray-400" />
</button>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-700 text-center text-xs text-gray-500">
AI Assistant v1.0
</div>
</div>
);
}