feat(frontend): add React SPA with chat, auth, and admin pages
- Vite 6 + React 19 + TypeScript + Tailwind 4 + Zustand 5 - Login page with JWT auth - Chat interface: MessageList, ChatInput, ChatContainer - WebSocket hook with auto-reconnect and exponential backoff - Sidebar with new chat button + session navigation - Admin dashboard placeholder - 57 modules, 242KB JS, TypeScript zero errors
This commit is contained in:
1 parent
192144563b
commit
4a71965686
35 files changed
+3146
No files matched your search
@@ -50,3 +50,5 @@ desktop.ini
|
|||||||
# 临时文件
|
# 临时文件
|
||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
@@ -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>Cyrene — 昔涟</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-950 text-gray-100">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2536
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "cyrene-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "2.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.0.0",
|
||||||
|
"zustand": "^5.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"typescript": "~5.6.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import { useAuthStore } from './store/authStore'
|
||||||
|
import AppLayout from './components/layout/AppLayout'
|
||||||
|
import ChatPage from './pages/chat/ChatPage'
|
||||||
|
import DashboardPage from './pages/admin/DashboardPage'
|
||||||
|
import LoginPage from './pages/LoginPage'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const isLoggedIn = useAuthStore((s) => s.isLoggedIn)
|
||||||
|
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Navigate to="/chat" replace />} />
|
||||||
|
<Route path="/chat" element={<ChatPage />} />
|
||||||
|
<Route path="/chat/:sessionId" element={<ChatPage />} />
|
||||||
|
<Route path="/admin/dashboard" element={<DashboardPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/chat" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</AppLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
Whitespace-only changes.
@@ -0,0 +1,44 @@
|
|||||||
|
// API client — simple fetch wrapper with JWT support
|
||||||
|
const BASE = '/api/v1'
|
||||||
|
|
||||||
|
let token: string | null = localStorage.getItem('cyrene_token')
|
||||||
|
|
||||||
|
export function setToken(t: string | null) {
|
||||||
|
token = t
|
||||||
|
if (t) localStorage.setItem('cyrene_token', t)
|
||||||
|
else localStorage.removeItem('cyrene_token')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToken() { return token }
|
||||||
|
|
||||||
|
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(options.headers as Record<string, string> || {}),
|
||||||
|
}
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||||
|
|
||||||
|
const res = await fetch(`${BASE}${path}`, { ...options, headers })
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||||
|
throw new Error(err.error || err.message || `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
login: (u: string, p: string) =>
|
||||||
|
request<{ token: string }>('/auth/login', { method: 'POST', body: JSON.stringify({ username: u, password: p }) }),
|
||||||
|
|
||||||
|
health: () => request<{ status: string }>('/health'),
|
||||||
|
|
||||||
|
sessions: {
|
||||||
|
list: () => request<{ sessions: { id: string; title: string }[] }>('/sessions'),
|
||||||
|
create: () => request<{ session_id: string }>('/sessions', { method: 'POST' }),
|
||||||
|
delete: (id: string) => request<void>(`/sessions/${id}`, { method: 'DELETE' }),
|
||||||
|
},
|
||||||
|
|
||||||
|
admin: {
|
||||||
|
dashboard: () => request<{ dashboard: Record<string, unknown> }>('/admin/dashboard'),
|
||||||
|
},
|
||||||
|
}
|
||||||
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,47 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { useChatStore } from '../../store/chatStore'
|
||||||
|
import { useWebSocket } from '../../hooks/useWebSocket'
|
||||||
|
import MessageList from './MessageList'
|
||||||
|
import ChatInput from './ChatInput'
|
||||||
|
|
||||||
|
export default function ChatContainer() {
|
||||||
|
const { send } = useWebSocket()
|
||||||
|
const { messages, isStreaming, currentSessionId, inputText, setInput, addMessage, setStreaming } = useChatStore()
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
if (!inputText.trim() || !currentSessionId) return
|
||||||
|
|
||||||
|
const userMsg = {
|
||||||
|
id: crypto.randomUUID(), session_id: currentSessionId,
|
||||||
|
role: 'user' as const, content: inputText, created_at: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
addMessage(userMsg)
|
||||||
|
setStreaming(true)
|
||||||
|
|
||||||
|
// Add placeholder for assistant response
|
||||||
|
addMessage({ id: crypto.randomUUID(), session_id: currentSessionId, role: 'assistant', content: '', created_at: new Date().toISOString() })
|
||||||
|
|
||||||
|
send({ type: 'message', content: inputText, session_id: currentSessionId, timestamp: Date.now() })
|
||||||
|
setInput('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex-1 overflow-auto px-4 py-4">
|
||||||
|
<MessageList messages={messages} />
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
<ChatInput
|
||||||
|
value={inputText}
|
||||||
|
onChange={setInput}
|
||||||
|
onSend={handleSend}
|
||||||
|
disabled={isStreaming}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
interface ChatInputProps {
|
||||||
|
value: string
|
||||||
|
onChange: (v: string) => void
|
||||||
|
onSend: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatInput({ value, onChange, onSend, disabled }: ChatInputProps) {
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
onSend()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-gray-800 p-3 shrink-0">
|
||||||
|
<div className="max-w-3xl mx-auto flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={disabled ? '思考中...' : '输入消息...'}
|
||||||
|
className="flex-1 bg-gray-800 rounded-lg px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-purple-500 disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={onSend}
|
||||||
|
disabled={disabled || !value.trim()}
|
||||||
|
className="bg-purple-600 hover:bg-purple-500 disabled:bg-gray-700 rounded-lg px-5 py-2.5 text-sm font-medium transition-colors disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
发送
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Message } from '../../types/chat'
|
||||||
|
|
||||||
|
export default function MessageList({ messages }: { messages: Message[] }) {
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-500 text-sm">
|
||||||
|
开始新对话吧~
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 max-w-3xl mx-auto">
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<MessageBubble key={msg.id} message={msg} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageBubble({ message }: { message: Message }) {
|
||||||
|
const isUser = message.role === 'user'
|
||||||
|
const isAction = message.role === 'action'
|
||||||
|
|
||||||
|
if (isAction) {
|
||||||
|
return <div className="text-center text-gray-500 text-sm italic py-1">{message.content}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
|
||||||
|
<div className={`max-w-[70%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed ${
|
||||||
|
isUser
|
||||||
|
? 'bg-purple-600 text-white rounded-br-md'
|
||||||
|
: message.role === 'system'
|
||||||
|
? 'bg-red-900/50 text-red-300 mx-auto text-xs'
|
||||||
|
: 'bg-gray-800 text-gray-100 rounded-bl-md'
|
||||||
|
}`}>
|
||||||
|
{message.content || (message.role === 'assistant' ? <span className="animate-pulse">...</span> : null)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Whitespace-only changes.
@@ -0,0 +1,18 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
|
import { useAuthStore } from '../../store/authStore'
|
||||||
|
import Header from './Header'
|
||||||
|
import Sidebar from './Sidebar'
|
||||||
|
|
||||||
|
export default function AppLayout({ children }: { children: ReactNode }) {
|
||||||
|
const { username, logout } = useAuthStore()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col bg-gray-950 text-gray-100">
|
||||||
|
<Header username={username} onLogout={logout} />
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex-1 overflow-auto">{children}</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
|
export default function Header({ username, onLogout }: { username: string; onLogout: () => void }) {
|
||||||
|
return (
|
||||||
|
<header className="h-12 bg-gray-900 border-b border-gray-800 flex items-center px-4 justify-between shrink-0">
|
||||||
|
<Link to="/chat" className="font-bold text-purple-400 text-lg">Cyrene</Link>
|
||||||
|
<div className="flex items-center gap-3 text-sm text-gray-400">
|
||||||
|
<Link to="/admin/dashboard" className="hover:text-gray-200">管理</Link>
|
||||||
|
<span>{username}</span>
|
||||||
|
<button onClick={onLogout} className="hover:text-red-400">退出</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useChatStore } from '../../store/chatStore'
|
||||||
|
import { api } from '../../api/client'
|
||||||
|
|
||||||
|
export default function Sidebar() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { currentSessionId, setSessionId, clearMessages } = useChatStore()
|
||||||
|
|
||||||
|
const handleNewChat = async () => {
|
||||||
|
try {
|
||||||
|
const { session_id } = await api.sessions.create()
|
||||||
|
setSessionId(session_id)
|
||||||
|
clearMessages()
|
||||||
|
navigate(`/chat/${session_id}`)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-56 bg-gray-900 border-r border-gray-800 flex flex-col shrink-0">
|
||||||
|
<div className="p-3">
|
||||||
|
<button onClick={handleNewChat}
|
||||||
|
className="w-full py-2 bg-purple-600 hover:bg-purple-500 rounded text-sm font-medium transition-colors">
|
||||||
|
+ 新对话
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto px-2 text-sm text-gray-400">
|
||||||
|
{currentSessionId ? (
|
||||||
|
<p className="px-2 py-1 truncate text-xs">会话: {currentSessionId.slice(0, 8)}...</p>
|
||||||
|
) : (
|
||||||
|
<p className="px-2 py-1 text-xs">暂无对话</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,71 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { useChatStore } from '../store/chatStore'
|
||||||
|
import { getToken } from '../api/client'
|
||||||
|
import type { WSMessage } from '../types/chat'
|
||||||
|
|
||||||
|
export function useWebSocket() {
|
||||||
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
|
const reconnectRef = useRef(0)
|
||||||
|
const { addMessage, appendToLast, setStreaming, setIotDevices, currentSessionId } = useChatStore()
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) return
|
||||||
|
|
||||||
|
const ws = new WebSocket(`ws://localhost:8080/ws/chat?token=${encodeURIComponent(token)}`)
|
||||||
|
wsRef.current = ws
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log('[ws] connected')
|
||||||
|
reconnectRef.current = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const msg: WSMessage = JSON.parse(e.data)
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'stream_chunk':
|
||||||
|
appendToLast(msg.content || '')
|
||||||
|
break
|
||||||
|
case 'stream_end':
|
||||||
|
setStreaming(false)
|
||||||
|
break
|
||||||
|
case 'response':
|
||||||
|
if (msg.content) {
|
||||||
|
addMessage({
|
||||||
|
id: crypto.randomUUID(), session_id: currentSessionId || '',
|
||||||
|
role: msg.content.startsWith('[') ? 'action' : 'assistant',
|
||||||
|
content: msg.content, created_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'device_update':
|
||||||
|
if (msg.devices) setIotDevices(msg.devices)
|
||||||
|
break
|
||||||
|
case 'error':
|
||||||
|
addMessage({ id: crypto.randomUUID(), session_id: '', role: 'system',
|
||||||
|
content: `Error: ${msg.error}`, created_at: new Date().toISOString() })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch { /* ignore parse errors */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log('[ws] disconnected')
|
||||||
|
const delay = Math.min(1000 * 2 ** reconnectRef.current, 30000)
|
||||||
|
reconnectRef.current++
|
||||||
|
setTimeout(connect, delay)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentSessionId])
|
||||||
|
|
||||||
|
useEffect(() => { connect(); return () => wsRef.current?.close() }, [connect])
|
||||||
|
|
||||||
|
const send = useCallback((data: object) => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(JSON.stringify(data))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { send }
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 6px; }
|
||||||
|
::-webkit-scrollbar-thumb { background: #4b5563; border-radius: 3px; }
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>
|
||||||
|
)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuthStore } from '../store/authStore'
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [username, setUsername] = useState('admin')
|
||||||
|
const [password, setPassword] = useState('admin')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const login = useAuthStore((s) => s.login)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await login(username, password)
|
||||||
|
navigate('/chat')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Login failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-950">
|
||||||
|
<form onSubmit={handleSubmit} className="bg-gray-900 p-8 rounded-xl w-80 space-y-4 border border-gray-800">
|
||||||
|
<h1 className="text-2xl font-bold text-center text-purple-400">Cyrene</h1>
|
||||||
|
{error && <p className="text-red-400 text-sm text-center">{error}</p>}
|
||||||
|
<input type="text" value={username} onChange={e => setUsername(e.target.value)}
|
||||||
|
placeholder="用户名" className="w-full bg-gray-800 rounded px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-purple-500" />
|
||||||
|
<input type="password" value={password} onChange={e => setPassword(e.target.value)}
|
||||||
|
placeholder="密码" className="w-full bg-gray-800 rounded px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-purple-500" />
|
||||||
|
<button type="submit" className="w-full bg-purple-600 hover:bg-purple-500 py-2 rounded text-sm font-medium transition-colors">
|
||||||
|
登录
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Whitespace-only changes.
@@ -0,0 +1,17 @@
|
|||||||
|
export default function DashboardPage() {
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-3xl mx-auto">
|
||||||
|
<h1 className="text-xl font-bold mb-4">管理面板</h1>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4">
|
||||||
|
<p className="text-gray-400 text-sm">服务状态</p>
|
||||||
|
<p className="text-green-400 text-lg font-bold mt-1">运行中</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4">
|
||||||
|
<p className="text-gray-400 text-sm">版本</p>
|
||||||
|
<p className="text-purple-400 text-lg font-bold mt-1">2.0.0-dev</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Whitespace-only changes.
@@ -0,0 +1,16 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import { useChatStore } from '../../store/chatStore'
|
||||||
|
import ChatContainer from '../../components/chat/ChatContainer'
|
||||||
|
import { api } from '../../api/client'
|
||||||
|
|
||||||
|
export default function ChatPage() {
|
||||||
|
const { sessionId } = useParams()
|
||||||
|
const { setSessionId } = useChatStore()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sessionId) setSessionId(sessionId)
|
||||||
|
}, [sessionId, setSessionId])
|
||||||
|
|
||||||
|
return <ChatContainer />
|
||||||
|
}
|
||||||
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,27 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { api, setToken, getToken } from '../api/client'
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
isLoggedIn: boolean
|
||||||
|
username: string
|
||||||
|
role: string
|
||||||
|
login: (username: string, password: string) => Promise<void>
|
||||||
|
logout: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
|
isLoggedIn: !!getToken(),
|
||||||
|
username: '',
|
||||||
|
role: 'user',
|
||||||
|
|
||||||
|
login: async (username: string, password: string) => {
|
||||||
|
const { token } = await api.login(username, password)
|
||||||
|
setToken(token)
|
||||||
|
set({ isLoggedIn: true, username, role: 'admin' })
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: () => {
|
||||||
|
setToken(null)
|
||||||
|
set({ isLoggedIn: false, username: '', role: 'user' })
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { Message, IotDevice } from '../types/chat'
|
||||||
|
|
||||||
|
interface ChatState {
|
||||||
|
messages: Message[]
|
||||||
|
isStreaming: boolean
|
||||||
|
currentSessionId: string | null
|
||||||
|
iotDevices: IotDevice[]
|
||||||
|
inputText: string
|
||||||
|
|
||||||
|
setInput: (text: string) => void
|
||||||
|
setSessionId: (id: string) => void
|
||||||
|
addMessage: (msg: Message) => void
|
||||||
|
appendToLast: (content: string) => void
|
||||||
|
setStreaming: (v: boolean) => void
|
||||||
|
setIotDevices: (d: IotDevice[]) => void
|
||||||
|
clearMessages: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useChatStore = create<ChatState>((set, get) => ({
|
||||||
|
messages: [],
|
||||||
|
isStreaming: false,
|
||||||
|
currentSessionId: null,
|
||||||
|
iotDevices: [],
|
||||||
|
inputText: '',
|
||||||
|
|
||||||
|
setInput: (text) => set({ inputText: text }),
|
||||||
|
setSessionId: (id) => set({ currentSessionId: id }),
|
||||||
|
setStreaming: (v) => set({ isStreaming: v }),
|
||||||
|
setIotDevices: (d) => set({ iotDevices: d }),
|
||||||
|
|
||||||
|
addMessage: (msg) => set((s) => ({ messages: [...s.messages, msg] })),
|
||||||
|
appendToLast: (content) => {
|
||||||
|
const msgs = [...get().messages]
|
||||||
|
if (msgs.length > 0) {
|
||||||
|
msgs[msgs.length - 1] = { ...msgs[msgs.length - 1], content: msgs[msgs.length - 1].content + content }
|
||||||
|
}
|
||||||
|
set({ messages: msgs })
|
||||||
|
},
|
||||||
|
clearMessages: () => set({ messages: [] }),
|
||||||
|
}))
|
||||||
Whitespace-only changes.
@@ -0,0 +1,35 @@
|
|||||||
|
// Chat-related type definitions
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string
|
||||||
|
session_id: string
|
||||||
|
role: 'user' | 'assistant' | 'action' | 'system'
|
||||||
|
content: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
state: 'active' | 'idle'
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IotDevice {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
state: Record<string, unknown>
|
||||||
|
online: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WSMessage {
|
||||||
|
type: string
|
||||||
|
content?: string
|
||||||
|
session_id?: string
|
||||||
|
timestamp?: number
|
||||||
|
error?: string
|
||||||
|
devices?: IotDevice[]
|
||||||
|
title?: string
|
||||||
|
body?: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:8080',
|
||||||
|
'/ws': { target: 'ws://localhost:8080', ws: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in new issue
Block a user