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/
|
||||
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