chore: 清空 v2 实现,重建新架构骨架(人格化通用 Agent 运行时)

This commit is contained in:
Aska committed 2026-09-05 23:09:26 +08:00
1 parent 9c8d5a9e8d
commit 4be3826af7
151 files changed
+3 -19760

No files matched your search

-54
View File
@@ -1,54 +0,0 @@
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 AdminLayout from './components/admin/AdminLayout'
import DashboardPage from './pages/admin/DashboardPage'
import ServicesPage from './pages/admin/ServicesPage'
import IoTManagementPage from './pages/admin/IoTManagementPage'
import PluginsPage from './pages/admin/PluginsPage'
import MemoryPage from './pages/admin/MemoryPage'
import LLMCallsPage from './pages/admin/LLMCallsPage'
import SystemPage from './pages/admin/SystemPage'
import ModelsPage from './pages/admin/ModelsPage'
import BridgePage from './pages/admin/BridgePage'
import TracePage from './pages/admin/TracePage'
import PerformancePage from './pages/admin/PerformancePage'
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" element={<AdminLayout />}>
<Route index element={<Navigate to="/admin/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="services" element={<ServicesPage />} />
<Route path="iot" element={<IoTManagementPage />} />
<Route path="plugins" element={<PluginsPage />} />
<Route path="memory" element={<MemoryPage />} />
<Route path="llm-calls" element={<LLMCallsPage />} />
<Route path="models" element={<ModelsPage />} />
<Route path="bridge" element={<BridgePage />} />
<Route path="trace" element={<TracePage />} />
<Route path="perf" element={<PerformancePage />} />
<Route path="system" element={<SystemPage />} />
</Route>
</Routes>
</AppLayout>
)
}
-84
View File
@@ -1,84 +0,0 @@
// 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 }
// downloadSession fetches a session export with auth and saves it as a file.
export async function downloadSession(id: string, format: 'markdown' | 'txt' | 'json' = 'markdown') {
const res = await fetch(`${BASE}/sessions/${id}/export?format=${format}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) throw new Error(`export failed: ${res.status}`)
const blob = await res.blob()
const ext = format === 'markdown' ? 'md' : format
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `session-${id.slice(0, 8)}.${ext}`
a.click()
URL.revokeObjectURL(url)
}
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; updated_at: string }[] }>('/sessions'),
create: () => request<{ session_id: string }>('/sessions', { method: 'POST' }),
delete: (id: string) => request<void>(`/sessions/${id}`, { method: 'DELETE' }),
messages: (id: string) =>
request<{ messages: { id: number; session_id: string; role: string; content: string; created_at: string }[] }>(`/sessions/${id}/messages`),
},
messages: {
search: (q: string) =>
request<{ messages: { id: number; session_id: string; role: string; content: string; created_at: string }[]; total: number }>(
`/messages/search?q=${encodeURIComponent(q)}`),
},
voice: {
// tts returns the synthesized audio as a Blob (audio/mpeg).
tts: async (text: string): Promise<Blob> => {
const res = await fetch(`${BASE}/voice/tts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify({ text }),
})
if (!res.ok) throw new Error(`tts failed: ${res.status}`)
return res.blob()
},
// stt sends base64-encoded audio and returns the recognized text.
stt: (audioBase64: string) =>
request<{ text: string }>('/voice/stt', { method: 'POST', body: JSON.stringify({ audio_data: audioBase64 }) }),
},
admin: {
dashboard: () => request<{ dashboard: Record<string, unknown> }>('/admin/dashboard'),
},
}
@@ -1,51 +0,0 @@
import { Outlet, NavLink } from 'react-router-dom'
const navItems = [
{ to: '/admin/dashboard', label: '仪表盘', icon: '◉' },
{ to: '/admin/services', label: '服务管理', icon: '⚙' },
{ to: '/admin/iot', label: 'IoT 设备', icon: '⌂' },
{ to: '/admin/models', label: '模型配置', icon: '◆' },
{ to: '/admin/plugins', label: '插件管理', icon: '◇' },
{ to: '/admin/memory', label: '记忆管理', icon: '♡' },
{ to: '/admin/bridge', label: '平台桥接', icon: '↗' },
{ to: '/admin/trace', label: '链路追踪', icon: '▸' },
{ to: '/admin/perf', label: '性能监控', icon: '▴' },
{ to: '/admin/llm-calls', label: 'LLM 日志', icon: '🧠' },
{ to: '/admin/system', label: '系统信息', icon: '⊞' },
]
export default function AdminLayout() {
return (
<div className="flex h-full">
{/* Sidebar */}
<nav className="w-52 bg-[var(--mx-surface)] border-r border-[var(--mx-divider)] shrink-0 flex flex-col">
<div className="p-4 border-b border-[var(--mx-divider)]">
<h2 className="text-sm font-semibold text-[var(--mx-text-secondary)]">管理面板</h2>
</div>
<div className="flex-1 p-2 space-y-0.5">
{navItems.map(item => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-[14px] text-sm transition-colors ${
isActive
? 'bg-[var(--mx-primary)]/15 text-[var(--mx-tertiary-text)] font-medium'
: 'text-[var(--mx-text-dim)] hover:text-[var(--mx-text-secondary)] hover:bg-[color-mix(in srgb, var(--mx-primary) 5%, transparent)]'
}`
}
>
<span className="text-base">{item.icon}</span>
<span>{item.label}</span>
</NavLink>
))}
</div>
</nav>
{/* Content area */}
<div className="flex-1 overflow-auto bg-[var(--mx-bg)]">
<Outlet />
</div>
</div>
)
}
@@ -1,77 +0,0 @@
import { useEffect, useRef } from 'react'
import { useChatStore } from '../../store/chatStore'
import { useWebSocket } from '../../hooks/useWebSocket'
import MessageList from './MessageList'
import ChatInput from './ChatInput'
function moodEmoji(mood: string): string {
const map: Record<string, string> = { 开心: '😊', 平静: '😌', 难过: '😢', 害羞: '☺️', 感动: '🥹' }
return map[mood] || '😌'
}
export default function ChatContainer() {
const { send } = useWebSocket()
const { messages, isStreaming, currentSessionId, inputText, mood, 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)
// Placeholder for assistant
addMessage({
id: crypto.randomUUID(), session_id: currentSessionId,
role: 'assistant' as const, 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 relative">
{/* Mood badge */}
{mood && (
<div className="absolute top-3 right-4 z-10 px-3 py-1 rounded-full text-xs border"
style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)', color: 'var(--mx-text-dim)' }}
title={`好感度 ${mood.affection}/100`}>
{moodEmoji(mood.mood)} {mood.mood}
</div>
)}
{/* Chat area */}
<div className="flex-1 overflow-auto">
<div className="max-w-2xl mx-auto px-4 py-6">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center space-y-2 pt-32">
<div className="w-16 h-16 rounded-[20px] overflow-hidden shadow-lg mb-2" style={{ boxShadow: '0 0 24px color-mix(in srgb, var(--mx-primary) 20%, transparent)' }}>
<img src="/images/cyrene-avatar.png" alt="Cyrene" className="w-full h-full object-cover" />
</div>
<p className="text-[var(--mx-text-dim)] text-sm">开始新对话吧~</p>
</div>
) : (
<MessageList messages={messages} />
)}
<div ref={bottomRef} />
</div>
</div>
{/* Input bar */}
<div className="shrink-0 border-t border-[var(--mx-divider)] bg-[var(--mx-surface)]">
<div className="max-w-2xl mx-auto px-4 py-3">
<ChatInput value={inputText} onChange={setInput} onSend={handleSend} disabled={isStreaming} />
</div>
</div>
</div>
)
}
@@ -1,86 +0,0 @@
import { useRef, useState } from 'react'
import { api } from '../../api/client'
interface ChatInputProps {
value: string
onChange: (v: string) => void
onSend: () => void
disabled?: boolean
}
export default function ChatInput({ value, onChange, onSend, disabled }: ChatInputProps) {
const [recording, setRecording] = useState(false)
const recorderRef = useRef<MediaRecorder | null>(null)
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
if (!disabled && value.trim()) onSend()
}
}
const toggleRecord = async () => {
if (recording) {
recorderRef.current?.stop()
return
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const recorder = new MediaRecorder(stream)
const chunks: Blob[] = []
recorder.ondataavailable = (e) => chunks.push(e.data)
recorder.onstop = async () => {
stream.getTracks().forEach((t) => t.stop())
setRecording(false)
const blob = new Blob(chunks, { type: recorder.mimeType })
const dataUrl = await new Promise<string>((resolve) => {
const reader = new FileReader()
reader.onloadend = () => resolve(reader.result as string)
reader.readAsDataURL(blob)
})
try {
const { text } = await api.voice.stt(dataUrl.split(',')[1] || '')
if (text.trim()) onChange(value ? `${value} ${text}` : text)
} catch { /* STT unavailable */ }
}
recorderRef.current = recorder
recorder.start()
setRecording(true)
} catch { /* mic permission denied */ }
}
return (
<div className="flex items-end gap-2 bg-[var(--mx-surface)] rounded-[20px] border border-[var(--mx-outline)] focus-within:border-[var(--mx-primary)] transition-colors p-1.5">
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
disabled={disabled}
placeholder={recording ? '正在录音,点击 ⏹ 结束…' : '输入消息...'}
className="flex-1 bg-transparent px-3 py-1.5 text-sm text-[var(--mx-text)] placeholder:text-[var(--mx-text-dim)] outline-none disabled:opacity-40"
/>
<button
onClick={toggleRecord}
disabled={disabled}
title={recording ? '结束录音并识别' : '语音输入'}
className={`w-9 h-9 rounded-full flex items-center justify-center shrink-0 transition-all duration-200 ${
recording
? 'bg-red-500 animate-pulse text-white'
: 'text-[var(--mx-text-dim)] hover:text-[var(--mx-text)]'
}`}
>
{recording ? '⏹' : '🎤'}
</button>
<button
onClick={onSend}
disabled={disabled || !value.trim()}
className="w-9 h-9 bg-[var(--mx-primary)] hover:bg-[var(--mx-primary-hover)] active:scale-90 disabled:bg-[#253E64] text-white rounded-full flex items-center justify-center shrink-0 transition-all duration-200"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M2 8L14 2L8 14L6.5 9.5L2 8Z" fill="currentColor" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
</div>
)
}
@@ -1,107 +0,0 @@
import { useRef, useState } from 'react'
import type { Message } from '../../types/chat'
import { api } from '../../api/client'
export default function MessageList({ messages }: { messages: Message[] }) {
return (
<div className="space-y-3">
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
</div>
)
}
// SpeakButton fetches TTS audio for a message and plays/stops it.
function SpeakButton({ text }: { text: string }) {
const [state, setState] = useState<'idle' | 'loading' | 'playing'>('idle')
const audioRef = useRef<HTMLAudioElement | null>(null)
const toggle = async () => {
if (state === 'playing') {
audioRef.current?.pause()
audioRef.current = null
setState('idle')
return
}
setState('loading')
try {
const blob = await api.voice.tts(text)
const audio = new Audio(URL.createObjectURL(blob))
audioRef.current = audio
audio.onended = () => { setState('idle'); audioRef.current = null }
await audio.play()
setState('playing')
} catch {
setState('idle')
}
}
return (
<button onClick={toggle}
className="ml-2 text-xs opacity-40 hover:opacity-100 transition-opacity align-middle"
title={state === 'playing' ? '停止' : '朗读'}>
{state === 'loading' ? '⏳' : state === 'playing' ? '⏹' : '🔊'}
</button>
)
}
function MessageBubble({ message }: { message: Message }) {
const isUser = message.role === 'user'
const isAction = message.role === 'action'
const isSystem = message.role === 'system'
const isEmpty = message.role === 'assistant' && !message.content
if (isAction) {
return (
<div className="flex justify-center py-1">
<span className="text-xs text-[var(--mx-tertiary-text)] bg-[var(--mx-badge-blue)]/80 px-3 py-1 rounded-full">
{message.content}
</span>
</div>
)
}
if (isSystem) {
return (
<div className="flex justify-center py-1">
<span className="text-xs text-[var(--mx-error-text)] bg-[var(--mx-error-container)]/80 px-3 py-1 rounded-full">{message.content}</span>
</div>
)
}
return (
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
{/* Avatar for AI */}
{!isUser && (
<div className="w-8 h-8 rounded-xl overflow-hidden shrink-0 mr-2 mt-1">
<img src="/images/cyrene-avatar.png" alt="Cyrene" className="w-full h-full object-cover" />
</div>
)}
<div className={`max-w-[75%] px-4 py-2.5 text-sm leading-relaxed ${
isUser
? 'bg-[var(--mx-primary)] text-white rounded-[18px] rounded-br-[6px]'
: isEmpty
? 'bg-transparent text-transparent'
: 'bg-[var(--mx-surface)] text-[var(--mx-text)] rounded-[18px] rounded-bl-[6px] border border-[var(--mx-outline)]'
}`}>
{isEmpty ? (
<span className="flex gap-1.5 py-1">
<span className="w-2 h-2 rounded-full bg-[#404040] animate-bounce" style={{animationDelay:'0ms'}} />
<span className="w-2 h-2 rounded-full bg-[#404040] animate-bounce" style={{animationDelay:'150ms'}} />
<span className="w-2 h-2 rounded-full bg-[#404040] animate-bounce" style={{animationDelay:'300ms'}} />
</span>
) : (
<>
{message.content}
{!isUser && message.role === 'assistant' && <SpeakButton text={message.content} />}
</>
)}
</div>
{/* Spacer for user messages */}
{isUser && <div className="w-8 shrink-0 ml-2" />}
</div>
)
}
@@ -1,86 +0,0 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../../api/client'
interface SearchResult {
id: number
session_id: string
role: string
content: string
created_at: string
}
function snippet(content: string, q: string): string {
const idx = content.indexOf(q)
if (idx < 0) return content.slice(0, 80)
const start = Math.max(0, idx - 20)
return (start > 0 ? '…' : '') + content.slice(start, start + 80) + (start + 80 < content.length ? '…' : '')
}
export default function SearchModal({ onClose }: { onClose: () => void }) {
const navigate = useNavigate()
const [q, setQ] = useState('')
const [results, setResults] = useState<SearchResult[]>([])
const [searched, setSearched] = useState(false)
const [loading, setLoading] = useState(false)
const search = async () => {
const query = q.trim()
if (!query) return
setLoading(true)
try {
const data = await api.messages.search(query)
setResults(data.messages || [])
setSearched(true)
} catch { /* ignore */ } finally {
setLoading(false)
}
}
const jump = (sessionId: string) => {
onClose()
navigate(`/chat/${sessionId}`)
}
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-24" style={{ background: 'rgba(0,0,0,0.4)' }}
onClick={onClose}>
<div className="w-full max-w-lg rounded-[18px] border p-4 space-y-3"
style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)' }}
onClick={(e) => e.stopPropagation()}>
<div className="flex gap-2">
<input
autoFocus
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && search()}
placeholder="搜索所有会话的消息…"
className="flex-1 rounded-[14px] border px-3 py-2 text-sm outline-none"
style={{ background: 'var(--mx-bg)', borderColor: 'var(--mx-outline)', color: 'var(--mx-text)' }}
/>
<button onClick={search} disabled={loading}
className="px-4 py-2 rounded-full text-sm font-medium text-white"
style={{ background: 'var(--mx-primary)' }}>
{loading ? '…' : '搜索'}
</button>
</div>
<div className="max-h-80 overflow-auto space-y-1">
{searched && results.length === 0 && (
<p className="text-center text-sm py-6" style={{ color: 'var(--mx-text-dim)' }}>没有找到相关消息</p>
)}
{results.map((m) => (
<div key={m.id} onClick={() => jump(m.session_id)}
className="px-3 py-2 rounded-[14px] cursor-pointer transition-colors hover:opacity-80"
style={{ background: 'var(--mx-bg)' }}>
<p className="text-sm" style={{ color: 'var(--mx-text)' }}>{snippet(m.content, q)}</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--mx-text-dim)' }}>
{m.role === 'user' ? '你' : '昔涟'} · {new Date(m.created_at).toLocaleString('zh-CN')}
</p>
</div>
))}
</div>
</div>
</div>
)
}
@@ -1,57 +0,0 @@
import { ReactNode } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { useAuthStore } from '../../store/authStore'
import { useThemeStore } from '../../store/themeStore'
export default function AppLayout({ children }: { children: ReactNode }) {
const { username, logout } = useAuthStore()
const { theme, toggle } = useThemeStore()
const location = useLocation()
return (
<div className="h-screen flex flex-col text-[var(--mx-text)]" style={{ background: 'var(--mx-bg)' }}>
{/* Header */}
<header className="h-14 shrink-0 flex items-center justify-between px-4 border-b mx-glass" style={{ borderColor: 'var(--mx-divider)', background: 'var(--mx-surface)' }}>
<Link to="/chat" className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg overflow-hidden">
<img src="/images/cyrene-avatar.png" alt="Cyrene" className="w-full h-full object-cover" />
</div>
<span className="font-semibold text-base">Cyrene</span>
</Link>
<nav className="flex items-center gap-1">
<Link to="/chat"
className="px-3 py-1.5 rounded-full text-sm transition-colors"
style={{
background: location.pathname.startsWith('/chat') ? 'color-mix(in srgb, var(--mx-primary) 15%, transparent)' : 'transparent',
color: location.pathname.startsWith('/chat') ? 'var(--mx-tertiary-text)' : 'var(--mx-text-dim)',
}}
>聊天</Link>
<Link to="/admin/dashboard"
className="px-3 py-1.5 rounded-full text-sm transition-colors"
style={{
background: location.pathname.startsWith('/admin') ? 'color-mix(in srgb, var(--mx-primary) 15%, transparent)' : 'transparent',
color: location.pathname.startsWith('/admin') ? 'var(--mx-tertiary-text)' : 'var(--mx-text-dim)',
}}
>管理</Link>
<div className="w-px h-4 mx-1" style={{ background: 'var(--mx-divider)' }} />
{/* Theme toggle */}
<button onClick={toggle}
className="w-8 h-8 rounded-full flex items-center justify-center text-lg transition-colors hover:opacity-80"
title={theme === 'dark' ? '切换日间模式' : '切换夜间模式'}
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
<span className="text-xs" style={{ color: 'var(--mx-text-dim)' }}>{username}</span>
<button onClick={logout}
className="px-3 py-1.5 rounded-full text-sm transition-colors"
style={{ color: 'var(--mx-text-dim)' }}
>退出</button>
</nav>
</header>
{/* Content */}
<main className="flex-1 overflow-hidden">{children}</main>
</div>
)
}
@@ -1,110 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useChatStore } from '../../store/chatStore'
import { api, downloadSession } from '../../api/client'
interface SessionItem { id: string; title: string; updated_at: string }
function relativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime()
const min = Math.floor(diff / 60000)
if (min < 1) return '刚刚'
if (min < 60) return `${min} 分钟前`
const hour = Math.floor(min / 60)
if (hour < 24) return `${hour} 小时前`
return `${Math.floor(hour / 24)} 天前`
}
export default function Sidebar({ onSearch }: { onSearch?: () => void }) {
const navigate = useNavigate()
const { currentSessionId, setSessionId, clearMessages, messages } = useChatStore()
const [sessions, setSessions] = useState<SessionItem[]>([])
const fetchSessions = useCallback(async () => {
try {
const data = await api.sessions.list()
setSessions(data.sessions || [])
} catch { /* ignore */ }
}, [])
useEffect(() => { fetchSessions() }, [fetchSessions])
// Refresh the list when a round finishes so new titles/ordering appear
useEffect(() => { fetchSessions() }, [messages.length, fetchSessions])
const handleNewChat = async () => {
try {
const { session_id } = await api.sessions.create()
setSessionId(session_id)
clearMessages()
navigate(`/chat/${session_id}`)
fetchSessions()
} catch { /* ignore */ }
}
const handleDelete = async (id: string) => {
if (!window.confirm('删除这个会话及其全部消息?')) return
try {
await api.sessions.delete(id)
if (id === currentSessionId) {
clearMessages()
navigate('/chat')
}
fetchSessions()
} catch { /* ignore */ }
}
return (
<aside className="w-60 bg-[var(--mx-surface)] border-r border-[var(--mx-divider)] shrink-0 flex flex-col">
<div className="p-3 space-y-2">
<button onClick={handleNewChat}
className="w-full py-2.5 bg-[var(--mx-primary)] hover:bg-[var(--mx-primary-hover)] active:scale-[0.98] text-white font-medium text-sm rounded-full transition-all duration-200"
>
+ 新对话
</button>
{onSearch && (
<button onClick={onSearch}
className="w-full py-2 text-sm rounded-full border transition-colors hover:opacity-80"
style={{ borderColor: 'var(--mx-outline)', color: 'var(--mx-text-dim)' }}
>
🔍 搜索消息
</button>
)}
</div>
<div className="flex-1 overflow-auto px-2 pb-2 space-y-1">
{sessions.length === 0 ? (
<p className="px-3 py-2 text-xs text-[var(--mx-text-dim)] text-center">暂无对话</p>
) : (
sessions.map((s) => (
<div key={s.id}
onClick={() => navigate(`/chat/${s.id}`)}
className="group px-3 py-2 rounded-[14px] cursor-pointer transition-colors"
style={{
background: s.id === currentSessionId ? 'color-mix(in srgb, var(--mx-primary) 12%, transparent)' : 'transparent',
}}
>
<div className="flex items-center justify-between gap-1">
<p className="text-sm truncate flex-1" style={{ color: 'var(--mx-text)' }}>
{s.title || '未命名会话'}
</p>
<button
onClick={(e) => { e.stopPropagation(); downloadSession(s.id, 'markdown').catch(() => {}) }}
className="opacity-0 group-hover:opacity-100 text-xs px-1 transition-opacity"
style={{ color: 'var(--mx-text-dim)' }}
title="导出为 Markdown"
>⤓</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete(s.id) }}
className="opacity-0 group-hover:opacity-100 text-xs px-1 transition-opacity"
style={{ color: 'var(--mx-error)' }}
title="删除会话"
>✕</button>
</div>
<p className="text-xs mt-0.5" style={{ color: 'var(--mx-text-dim)' }}>{relativeTime(s.updated_at)}</p>
</div>
))
)}
</div>
</aside>
)
}
-92
View File
@@ -1,92 +0,0 @@
import { useEffect, useRef, useCallback } from 'react'
import { useChatStore } from '../store/chatStore'
import { useAuthStore } from '../store/authStore'
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, setMood, currentSessionId } = useChatStore()
const connect = useCallback(() => {
const token = getToken()
if (!token) return
const uid = useAuthStore.getState().username || 'web'
const ws = new WebSocket(`ws://localhost:8080/ws/chat?token=${encodeURIComponent(token)}&user_id=${encodeURIComponent(uid)}`)
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 'thinking':
if (msg.content) {
addMessage({
id: crypto.randomUUID(), session_id: currentSessionId || '',
role: 'system', content: `💭 ${msg.content}`, created_at: new Date().toISOString(),
})
}
break
case 'mood':
if (msg.mood) setMood({ mood: msg.mood, intensity: msg.intensity || 0, affection: msg.affection || 0 })
break
case 'reminder':
if (msg.content) {
addMessage({
id: crypto.randomUUID(), session_id: currentSessionId || '',
role: 'system', content: `⏰ ${msg.content}`, created_at: new Date().toISOString(),
})
}
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 }
}
-149
View File
@@ -1,149 +0,0 @@
@import "tailwindcss";
/* =============================================
Miuix HyperOS Design Tokens
日间 (Light) + 夜间 (Dark)
============================================= */
/* 夜间模式 (默认) */
:root {
--mx-primary: #3482FF;
--mx-primary-hover: #277AF7;
--mx-primary-container: #338FE4;
--mx-on-primary: #FFFFFF;
--mx-bg: #242424;
--mx-bg-elevated: #000000;
--mx-bg-high: #2D2D2D;
--mx-surface: #000000;
--mx-surface-variant: #242424;
--mx-surface-high: #2D2D2D;
--mx-text: #F2F2F2;
--mx-text-secondary: rgba(255,255,255,0.8);
--mx-text-dim: rgba(255,255,255,0.5);
--mx-text-disabled: #666666;
--mx-outline: #404040;
--mx-divider: #393939;
--mx-error: #F12522;
--mx-error-container: #2E0603;
--mx-error-text: #ffdad6;
--mx-tertiary: #2B3B54;
--mx-tertiary-text: #4788FF;
--mx-badge-blue: #2B3B54;
--mx-switch-off: #404040;
--mx-switch-on: #3482FF;
--mx-stat-value: #F2F2F2;
--radius-mx-card: 18px;
--radius-mx-button: 9999px;
--radius-mx-input: 14px;
--radius-mx-bubble: 18px;
--font-mx: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", sans-serif;
}
/* 日间模式 */
[data-theme="light"] {
--mx-primary: #3482FF;
--mx-primary-hover: #277AF7;
--mx-primary-container: #5D9BFF;
--mx-on-primary: #FFFFFF;
--mx-bg: #FFFFFF;
--mx-bg-elevated: #F7F7F7;
--mx-bg-high: #E8E8E8;
--mx-surface: #FFFFFF;
--mx-surface-variant: #F7F7F7;
--mx-surface-high: #E8E8E8;
--mx-text: #000000;
--mx-text-secondary: rgba(0,0,0,0.7);
--mx-text-dim: rgba(0,0,0,0.4);
--mx-text-disabled: #B2B2B2;
--mx-outline: #D9D9D9;
--mx-divider: #E0E0E0;
--mx-error: #E94634;
--mx-error-container: #FDF6F4;
--mx-error-text: #410002;
--mx-tertiary: #EAF2FF;
--mx-tertiary-text: #3482FF;
--mx-badge-blue: #EAF2FF;
--mx-switch-off: #D9D9D9;
--mx-switch-on: #3482FF;
--mx-stat-value: #000000;
}
/* =============================================
Base styles
============================================= */
body {
margin: 0;
background: var(--mx-bg);
color: var(--mx-text);
font-family: var(--font-mx);
-webkit-font-smoothing: antialiased;
}
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--mx-outline); border-radius: 2px; }
*:focus-visible {
outline: 2px solid var(--mx-primary);
outline-offset: 2px;
border-radius: var(--radius-mx-button);
}
/* =============================================
Utility classes (using CSS variables)
============================================= */
.mx-glass {
background: rgba(36,36,36,0.85);
backdrop-filter: blur(20px) saturate(1.5);
-webkit-backdrop-filter: blur(20px) saturate(1.5);
}
[data-theme="light"] .mx-glass {
background: rgba(255,255,255,0.85);
}
.mx-card {
background: var(--mx-surface);
border-radius: var(--radius-mx-card);
border: 0.5px solid var(--mx-outline);
}
.mx-btn-primary {
background: var(--mx-primary);
color: var(--mx-on-primary);
border-radius: var(--radius-mx-button);
font-weight: 500;
}
.mx-btn-primary:hover { background: var(--mx-primary-hover); }
.mx-btn-primary:active { transform: scale(0.97); }
.mx-input {
background: var(--mx-surface);
border: 0.5px solid var(--mx-outline);
border-radius: var(--radius-mx-input);
color: var(--mx-text);
outline: none;
}
.mx-input:focus { border-color: var(--mx-primary); }
.mx-input::placeholder { color: var(--mx-text-dim); }
-19
View File
@@ -1,19 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
import './store/themeStore' // Initialize theme (light mode default, follows system)
// Register PWA service worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
)
-71
View File
@@ -1,71 +0,0 @@
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 [loading, setLoading] = useState(false)
const navigate = useNavigate()
const login = useAuthStore((s) => s.login)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await login(username, password)
navigate('/chat')
} catch (err) {
setError(err instanceof Error ? err.message : '登录失败')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-[var(--mx-bg)] p-4">
<form onSubmit={handleSubmit} className="w-full max-w-sm space-y-5">
{/* Logo area */}
<div className="text-center space-y-2 mb-8">
<div className="mx-auto w-20 h-20 rounded-[22px] overflow-hidden shadow-lg" style={{ boxShadow: '0 0 24px color-mix(in srgb, var(--mx-primary) 20%, transparent)' }}>
<img src="/images/cyrene-avatar.png" alt="Cyrene" className="w-full h-full object-cover" />
</div>
<h1 className="text-2xl font-semibold text-[var(--mx-text)]">Cyrene</h1>
<p className="text-sm text-[var(--mx-text-dim)]">LLM 智能体平台</p>
</div>
{/* Error */}
{error && (
<div className="bg-[var(--mx-error-container)] border border-[var(--mx-error)]/30 rounded-[14px] px-4 py-3 text-sm text-[var(--mx-error-text)] text-center">
{error}
</div>
)}
{/* Inputs */}
<div className="space-y-3">
<input
type="text" value={username}
onChange={e => setUsername(e.target.value)}
placeholder="用户名"
className="w-full bg-[var(--mx-surface)] border border-[var(--mx-outline)] focus:border-[var(--mx-primary)] rounded-[14px] px-4 py-3 text-[var(--mx-text)] placeholder:text-[var(--mx-text-dim)] outline-none transition-colors"
/>
<input
type="password" value={password}
onChange={e => setPassword(e.target.value)}
placeholder="密码"
className="w-full bg-[var(--mx-surface)] border border-[var(--mx-outline)] focus:border-[var(--mx-primary)] rounded-[14px] px-4 py-3 text-[var(--mx-text)] placeholder:text-[var(--mx-text-dim)] outline-none transition-colors"
/>
</div>
{/* Submit */}
<button type="submit" disabled={loading}
className="w-full bg-[var(--mx-primary)] hover:bg-[var(--mx-primary-hover)] active:scale-[0.97] disabled:bg-[#253E64] disabled:text-[#677993] text-white font-medium py-3 rounded-full transition-all duration-200"
>
{loading ? '登录中...' : '登录'}
</button>
</form>
</div>
)
}
-192
View File
@@ -1,192 +0,0 @@
import { useEffect, useState } from 'react'
import { getToken } from '../../api/client'
interface FieldDef { key: string; label: string; placeholder: string; type?: string }
interface PlatformDef { value: string; label: string; fields: FieldDef[] }
const platformTypes: PlatformDef[] = [
{ value: 'qq-obv11', label: 'QQ (OBv11)', fields: [
{ key: 'mode', label: '连接模式', placeholder: '', type: 'select' },
{ key: 'ws_url', label: 'NapCat OneBot WS 地址', placeholder: 'ws://127.0.0.1:10311' },
{ key: 'access_token', label: 'Access Token (可选)', placeholder: '留空则不验证' },
{ key: 'bot_qq', label: '机器人 QQ 号', placeholder: '用于 @ 检测' },
{ key: 'http_url', label: 'NapCat HTTP API 地址 (可选)', placeholder: 'http://127.0.0.1:6099' },
{ key: 'http_token', label: 'HTTP API Token (可选)', placeholder: '留空则使用 Access Token' },
{ key: 'bot_port', label: '本地监听端口', placeholder: '8096' },
{ key: 'send_interval_ms', label: '消息发送间隔 (ms, 防封控)', placeholder: '2000' },
{ key: 'admin_uids', label: '管理员 QQ 号 (逗号分隔)', placeholder: '123456789,987654321' },
]},
{ value: 'telegram', label: 'Telegram', fields: [
{ key: 'token', label: 'Bot Token', placeholder: '123456:ABC-DEF...' },
{ key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://your-domain.com' },
{ key: 'admin_uids', label: '管理员账号ID (逗号分隔)', placeholder: '123456789,987654321' },
]},
{ value: 'discord', label: 'Discord', fields: [
{ key: 'token', label: 'Bot Token', placeholder: '...' },
{ key: 'webhook_url', label: 'Webhook URL (可选)', placeholder: 'https://...' },
]},
{ value: 'webhook', label: 'Webhook', fields: [
{ key: 'listen_addr', label: '监听地址', placeholder: ':8097' },
{ key: 'api_key', label: 'API Key (可选)', placeholder: '留空则不校验 X-API-Key' },
]},
]
interface Adapter { name:string; type:string; enabled:boolean; settings:Record<string,string>; created_at:string }
export default function BridgePage() {
const [adapters, setAdapters] = useState<Adapter[]>([])
const [editing, setEditing] = useState(false)
const [name, setName] = useState('')
const [adType, setAdType] = useState('qq-obv11')
const [settings, setSettings] = useState<Record<string,string>>({})
useEffect(() => { fetchAll() }, [])
const fetchAll = async () => {
const token = getToken()
const res = await fetch('/api/v1/admin/bridge/config', { headers: { Authorization: `Bearer ${token}` } })
const data = await res.json()
setAdapters(data.adapters || [])
}
const save = async () => {
if (!name.trim()) return
const token = getToken()
await fetch(`/api/v1/admin/bridge/config/${name}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ type: adType, enabled: true, settings }),
})
setEditing(false); setName(''); setSettings({}); fetchAll()
}
const toggle = async (a: Adapter) => {
const token = getToken()
await fetch(`/api/v1/admin/bridge/config/${a.name}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ type: a.type, enabled: !a.enabled, settings: a.settings }),
})
fetchAll()
}
const remove = async (name: string) => {
const token = getToken()
await fetch(`/api/v1/admin/bridge/config/${name}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } })
fetchAll()
}
const startEdit = (a?: Adapter) => {
if (a) { setName(a.name); setAdType(a.type); setSettings(a.settings || {}) }
else { setName(''); setAdType('qq-obv11'); setSettings({}) }
setEditing(true)
}
const fields = platformTypes.find(p => p.value === adType)?.fields || []
return (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">平台桥接配置</h2>
<button onClick={() => startEdit()}
className="px-4 py-2 rounded-full text-sm font-medium text-white transition-colors"
style={{ background: 'var(--mx-primary)' }}
>+ 添加适配器</button>
</div>
<p className="text-xs" style={{ color: 'var(--mx-text-dim)' }}>
配置文件: configs/bridge.json · 支持热重载 · 仅在有适配器时创建
</p>
{/* Adapter list */}
{adapters.length === 0 && !editing && (
<div className="rounded-[18px] border p-8 text-center text-sm" style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)', color: 'var(--mx-text-dim)' }}>
暂无平台适配器。点击「添加适配器」开始配置。
</div>
)}
<div className="space-y-2">
{adapters.map(a => (
<div key={a.name}
className="rounded-[18px] border px-4 py-3 flex items-center justify-between"
style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)' }}
>
<div className="flex items-center gap-3">
<div className="w-2.5 h-2.5 rounded-full"
style={{ background: a.enabled ? 'var(--mx-tertiary-text)' : 'var(--mx-outline)' }} />
<div>
<p className="text-sm" style={{ color: 'var(--mx-text)' }}>{a.name}</p>
<p className="text-xs" style={{ color: 'var(--mx-text-dim)' }}>
{platformTypes.find(p=>p.value===a.type)?.label || a.type}
{a.settings?.mode && ` · ${a.settings.mode === 'server' ? '服务端模式' : '客户端模式'}`}
{a.created_at && ` · ${a.created_at.slice(0,10)}`}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button onClick={() => toggle(a)}
className="w-11 h-6 rounded-full relative transition-colors"
style={{ background: a.enabled ? 'var(--mx-primary)' : 'var(--mx-outline)' }}
>
<div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${a.enabled ? 'translate-x-5.5' : 'translate-x-0.5'}`} />
</button>
<button onClick={() => startEdit(a)} className="text-xs px-2 py-1 rounded-full" style={{ color: 'var(--mx-text-dim)' }}>编辑</button>
<button onClick={() => remove(a.name)} className="text-xs px-2 py-1 rounded-full" style={{ color: 'var(--mx-error)' }}>删除</button>
</div>
</div>
))}
</div>
{/* Edit panel */}
{editing && (
<div className="rounded-[18px] border p-4 space-y-3" style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)' }}>
<h3 className="text-sm font-medium" style={{ color: 'var(--mx-text)' }}>{name ? '编辑适配器' : '新建适配器'}</h3>
<div className="flex gap-2">
<input value={name} onChange={e => setName(e.target.value)} placeholder="名称 (如 qq-main)"
className="flex-1 rounded-[14px] border px-3 py-2 text-sm outline-none"
style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)', color: 'var(--mx-text)' }} />
<select value={adType} onChange={e => setAdType(e.target.value)}
className="rounded-[14px] border px-3 py-2 text-sm outline-none"
style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)', color: 'var(--mx-text)' }}>
{platformTypes.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
</select>
</div>
{fields.map(f => (
<div key={f.key} className="space-y-1">
<label className="text-xs" style={{ color: 'var(--mx-text-dim)' }}>{f.label}</label>
{f.type === 'select' ? (
<select value={settings[f.key] || 'client'} onChange={e => setSettings({...settings, [f.key]: e.target.value})}
className="w-full rounded-[14px] border px-3 py-2 text-sm outline-none"
style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)', color: 'var(--mx-text)' }}>
<option value="client">客户端模式 (主动连接 NapCat)</option>
<option value="server">服务端模式 (等待 NapCat 连接)</option>
</select>
) : (
<input value={settings[f.key] || ''} onChange={e => setSettings({...settings, [f.key]: e.target.value})}
placeholder={f.placeholder}
className="w-full rounded-[14px] border px-3 py-2 text-sm outline-none"
style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)', color: 'var(--mx-text)' }} />
)}
</div>
))}
<div className="flex gap-2 pt-2">
<button onClick={save} className="px-4 py-2 rounded-full text-sm font-medium text-white" style={{ background: 'var(--mx-primary)' }}>保存</button>
<button onClick={() => setEditing(false)} className="px-4 py-2 rounded-full text-sm" style={{ border: '1px solid var(--mx-outline)', color: 'var(--mx-text-dim)' }}>取消</button>
</div>
</div>
)}
{/* Rules */}
<div className="rounded-[18px] border p-4" style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)' }}>
<h3 className="text-sm font-medium mb-2" style={{ color: 'var(--mx-text)' }}>群聊行为规则</h3>
<ul className="text-xs space-y-1 list-disc list-inside" style={{ color: 'var(--mx-text-dim)' }}>
<li>消息最大 500 字,超出自动截断</li>
<li>最短发送间隔 3 秒</li>
<li>每轮最多 2 条消息</li>
<li>非 @ 消息走审查模式(LLM 自主判断是否回复)</li>
<li>闭嘴关键词:闭嘴 / 别说话 / 安静 / shut up</li>
</ul>
</div>
</div>
)
}
@@ -1,91 +0,0 @@
import { useEffect, useState } from 'react'
import { api } from '../../api/client'
export default function DashboardPage() {
const [data, setData] = useState<any>(null)
useEffect(() => {
api.admin.dashboard().then(r => setData(r.dashboard)).catch(() => {})
}, [])
const services = data?.services || {}
const serviceCount = Object.values(services).filter(Boolean).length
const totalServices = Object.keys(services).length || 3
return (
<div className="p-6 space-y-6">
<h2 className="text-lg font-semibold">仪表盘</h2>
{/* Stats */}
<div className="grid grid-cols-4 gap-3">
<StatCard label="服务" value={`${serviceCount}/${totalServices}`} sub="运行中/总数" color='var(--mx-tertiary-text)' />
<StatCard label="插件" value="11" sub="已加载" color='var(--mx-primary)' />
<StatCard label="运行时间" value={data?.uptime_sec ? formatUptime(data.uptime_sec) : '...'} sub="since start" color='var(--mx-stat-value)' />
<StatCard label="版本" value="2.0.0-dev" sub="" color="var(--mx-text-dim)" />
</div>
{/* Service Status */}
<Section title="服务状态">
<div className="bg-[var(--mx-surface)] rounded-[18px] border border-[var(--mx-outline)] divide-y divide-[var(--mx-divider)]">
{['ai_core', 'memory', 'voice'].map(name => (
<div key={name} className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3">
<span className={`w-2.5 h-2.5 rounded-full ${services[name] ? 'bg-[var(--mx-tertiary-text)]' : 'bg-[var(--mx-outline)]'}`} />
<span className="text-sm text-[var(--mx-text)]">{name}</span>
</div>
<span className="text-xs text-[var(--mx-text-dim)]">{services[name] ? '运行中' : '未启动'}</span>
</div>
))}
</div>
</Section>
{/* Quick Links */}
<Section title="快捷入口">
<div className="grid grid-cols-2 gap-3">
<QuickLink to="/admin/iot" label="IoT 设备" desc="查看和操控智能家居设备" />
<QuickLink to="/admin/plugins" label="插件管理" desc="管理已加载的工具插件" />
<QuickLink to="/admin/memory" label="记忆管理" desc="搜索、添加、删除记忆" />
<QuickLink to="/admin/services" label="服务管理" desc="查看服务运行状态和日志" />
</div>
</Section>
</div>
)
}
function StatCard({ label, value, sub, color }: { label:string; value:string; sub:string; color:string }) {
return (
<div className="bg-[var(--mx-surface)] border border-[var(--mx-outline)] rounded-[18px] px-4 py-3">
<p className="text-xs text-[var(--mx-text-dim)] mb-1">{label}</p>
<p className="text-xl font-bold" style={{ color }}>{value}</p>
{sub && <p className="text-xs text-[var(--mx-text-dim)] mt-0.5">{sub}</p>}
</div>
)
}
function Section({ title, children }: { title:string; children:React.ReactNode }) {
return (
<div>
<h3 className="text-sm font-medium text-[var(--mx-text-secondary)] mb-3">{title}</h3>
{children}
</div>
)
}
import { Link } from 'react-router-dom'
function QuickLink({ to, label, desc }: { to:string; label:string; desc:string }) {
return (
<Link to={to}
className="block bg-[var(--mx-surface)] border border-[var(--mx-outline)] rounded-[18px] px-4 py-3 hover:border-[var(--mx-primary)]/50 transition-colors"
>
<p className="text-sm text-[var(--mx-text)] font-medium">{label}</p>
<p className="text-xs text-[var(--mx-text-dim)] mt-1">{desc}</p>
</Link>
)
}
function formatUptime(s: number) {
if (s < 60) return `${s}s`
if (s < 3600) return `${Math.floor(s/60)}m`
return `${Math.floor(s/3600)}h${Math.floor((s%3600)/60)}m`
}
@@ -1,59 +0,0 @@
import { useEffect, useState } from 'react'
import type { IotDevice } from '../../types/chat'
const mockDevices: IotDevice[] = [
{ id: 'light-livingroom', name: '客厅灯', type: 'light', state: { power: false, brightness: 80 }, online: true },
{ id: 'light-bedroom', name: '卧室灯', type: 'light', state: { power: false, brightness: 60 }, online: true },
{ id: 'ac-livingroom', name: '客厅空调', type: 'ac', state: { power: false, temperature: 26, mode: 'cool' }, online: true },
{ id: 'ac-bedroom', name: '卧室空调', type: 'ac', state: { power: false, temperature: 25, mode: 'heat' }, online: true },
{ id: 'curtain-livingroom', name: '客厅窗帘', type: 'curtain', state: { power: false, position: 0 }, online: true },
{ id: 'sensor-temperature', name: '温度传感器', type: 'sensor', state: { temperature: 25.0 }, online: true },
{ id: 'sensor-humidity', name: '湿度传感器', type: 'sensor', state: { humidity: 55.0 }, online: true },
{ id: 'lock-door', name: '智能门锁', type: 'lock', state: { locked: true, battery: 85 }, online: true },
]
export default function IoTManagementPage() {
const [devices, setDevices] = useState<IotDevice[]>(mockDevices)
const toggle = (id: string) => {
setDevices(prev => prev.map(d => {
if (d.id !== id) return d
const newState = { ...d.state, power: !(d.state.power as boolean) }
return { ...d, state: newState }
}))
}
return (
<div className="p-6 space-y-4">
<h2 className="text-lg font-semibold">IoT 设备管理</h2>
<p className="text-xs text-[var(--mx-text-dim)]">8 个模拟智能家居设备(测试环境)</p>
<div className="space-y-2">
{devices.map(d => {
const isOn = d.type !== 'sensor' && d.state.power === true
return (
<div key={d.id} className="bg-[var(--mx-surface)] border border-[var(--mx-outline)] rounded-[18px] px-4 py-3 flex items-center justify-between">
<div>
<p className="text-sm text-[var(--mx-text)]">{d.name}</p>
<p className="text-xs text-[var(--mx-text-dim)]">
{d.type === 'light' && `亮度: ${d.state.brightness}%`}
{d.type === 'ac' && `${d.state.temperature}°C ${d.state.mode}`}
{d.type === 'curtain' && `位置: ${d.state.position}%`}
{d.type === 'sensor' && `${JSON.stringify(d.state)}`}
{d.type === 'lock' && `电量: ${d.state.battery}%`}
</p>
</div>
{d.type !== 'sensor' && (
<button onClick={() => toggle(d.id)}
className={`w-12 h-7 rounded-full relative transition-colors ${isOn ? 'bg-[var(--mx-primary)]' : 'bg-[#404040]'}`}
>
<div className={`absolute top-0.5 w-6 h-6 rounded-full bg-white transition-transform ${isOn ? 'translate-x-6' : 'translate-x-0.5'}`} />
</button>
)}
</div>
)
})}
</div>
</div>
)
}
@@ -1,84 +0,0 @@
import { useEffect, useState } from 'react'
import { getToken } from '../../api/client'
export default function LLMCallsPage() {
const [calls, setCalls] = useState<any[]>([])
const [stats, setStats] = useState<any>({})
useEffect(() => {
fetchLLM()
const interval = setInterval(fetchLLM, 5000)
return () => clearInterval(interval)
}, [])
const fetchLLM = async () => {
try {
const token = getToken()
const res = await fetch('/api/v1/admin/llm-calls', { headers: { Authorization: `Bearer ${token}` } })
const data = await res.json()
setCalls(data.calls || [])
setStats(data.stats || {})
} catch {}
}
return (
<div className="p-6 space-y-4">
<h2 className="text-lg font-semibold">LLM 调用日志</h2>
{/* Stats */}
<div className="grid grid-cols-4 gap-3">
<MiniStat label="总调用" value={stats.total_calls || 0} />
<MiniStat label="成功率" value={stats.total_calls ? `${Math.round(stats.success_calls/stats.total_calls*100)}%` : '-'} />
<MiniStat label="平均耗时" value={stats.avg_duration_ms ? `${stats.avg_duration_ms}ms` : '-'} />
<MiniStat label="总 Token" value={stats.total_tokens || 0} />
</div>
{/* Calls table */}
<div className="bg-[var(--mx-surface)] rounded-[18px] border border-[var(--mx-outline)] overflow-hidden">
{calls.length === 0 ? (
<div className="p-8 text-center text-[var(--mx-text-dim)] text-sm">暂无 LLM 调用记录</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--mx-divider)] text-[var(--mx-text-dim)] text-xs">
<th className="text-left px-4 py-2 font-normal">时间</th>
<th className="text-left px-4 py-2 font-normal">模型</th>
<th className="text-left px-4 py-2 font-normal">用途</th>
<th className="text-right px-4 py-2 font-normal">耗时</th>
<th className="text-right px-4 py-2 font-normal">Token</th>
<th className="text-center px-4 py-2 font-normal">状态</th>
</tr>
</thead>
<tbody>
{calls.map((c, i) => (
<tr key={c.id || i} className="border-b border-[var(--mx-divider)]/50">
<td className="px-4 py-2.5 text-[var(--mx-text-dim)] text-xs font-mono">{c.time?.slice(11,19) || '-'}</td>
<td className="px-4 py-2.5 text-[var(--mx-text)]">{c.model || '-'}</td>
<td className="px-4 py-2.5 text-[var(--mx-text-dim)] text-xs">
<span className="px-1.5 py-0.5 rounded bg-[var(--mx-badge-blue)]/50 text-[var(--mx-tertiary-text)]">{c.purpose || '-'}</span>
</td>
<td className="px-4 py-2.5 text-[var(--mx-text-dim)] text-xs text-right">{c.duration_ms}ms</td>
<td className="px-4 py-2.5 text-[var(--mx-text-dim)] text-xs text-right">{(c.prompt_tokens||0)+(c.completion_tokens||0)}</td>
<td className="px-4 py-2.5 text-center">
<span className={`text-xs ${c.success ? 'text-[var(--mx-tertiary-text)]' : 'text-[var(--mx-error)]'}`}>
{c.success ? '✓' : '✗'}
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)
}
function MiniStat({ label, value }: { label:string; value:string|number }) {
return (
<div className="bg-[var(--mx-surface)] border border-[var(--mx-outline)] rounded-[18px] px-4 py-3">
<p className="text-xs text-[var(--mx-text-dim)] mb-1">{label}</p>
<p className="text-lg font-bold text-[var(--mx-text)]">{value}</p>
</div>
)
}
-101
View File
@@ -1,101 +0,0 @@
import { useState, useCallback } from 'react'
import { api, getToken } from '../../api/client'
interface MemoryEntry {
id: string; user_id: string; content: string; summary: string;
keywords: string[]; category: string; priority: number;
}
export default function MemoryPage() {
const [memories, setMemories] = useState<MemoryEntry[]>([])
const [search, setSearch] = useState('')
const [loading, setLoading] = useState(false)
const [newContent, setNewContent] = useState('')
const fetchMemories = useCallback(async (q?: string) => {
setLoading(true)
try {
const token = getToken()
const path = q ? `/api/v1/memory/search?q=${encodeURIComponent(q)}` : '/api/v1/memory'
const res = await fetch(path, { headers: { Authorization: `Bearer ${token}` } })
const data = await res.json()
setMemories(Array.isArray(data?.data) ? data.data : [])
} catch { } finally { setLoading(false) }
}, [])
const handleAdd = async () => {
if (!newContent.trim()) return
const token = getToken()
await fetch('/api/v1/memory', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ content: newContent, category: 'fact', priority: 0.5 }),
})
setNewContent('')
fetchMemories(search || undefined)
}
const handleDelete = async (id: string) => {
const token = getToken()
await fetch(`/api/v1/memory/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } })
fetchMemories(search || undefined)
}
return (
<div className="p-6 space-y-4">
<h2 className="text-lg font-semibold">记忆管理</h2>
{/* Search */}
<div className="flex gap-2">
<input
type="text" value={search}
onChange={e => setSearch(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') fetchMemories(search) }}
placeholder="搜索记忆..."
className="flex-1 bg-[var(--mx-surface)] border border-[var(--mx-outline)] focus:border-[var(--mx-primary)] rounded-[14px] px-4 py-2.5 text-sm text-[var(--mx-text)] placeholder:text-[var(--mx-text-dim)] outline-none"
/>
<button onClick={() => fetchMemories(search)} className="px-4 py-2.5 bg-[var(--mx-primary)] hover:bg-[var(--mx-primary-hover)] text-white text-sm font-medium rounded-full">搜索</button>
<button onClick={() => { setSearch(''); fetchMemories() }} className="px-4 py-2.5 bg-[var(--mx-surface)] border border-[var(--mx-outline)] text-[var(--mx-text-dim)] text-sm rounded-full">全部</button>
</div>
{/* Add */}
<div className="flex gap-2">
<input
type="text" value={newContent}
onChange={e => setNewContent(e.target.value)}
placeholder="添加新记忆..."
className="flex-1 bg-[var(--mx-surface)] border border-[var(--mx-outline)] focus:border-[var(--mx-primary)] rounded-[14px] px-4 py-2.5 text-sm text-[var(--mx-text)] placeholder:text-[var(--mx-text-dim)] outline-none"
/>
<button onClick={handleAdd} disabled={!newContent.trim()}
className="px-4 py-2.5 bg-[var(--mx-primary)] hover:bg-[var(--mx-primary-hover)] disabled:bg-[#253E64] text-white text-sm font-medium rounded-full"
>添加</button>
</div>
{/* List */}
{loading ? (
<p className="text-center text-[var(--mx-text-dim)] py-8 text-sm">加载中...</p>
) : memories.length === 0 ? (
<p className="text-center text-[var(--mx-text-dim)] py-8 text-sm">暂无记忆</p>
) : (
<div className="space-y-2">
{memories.map(m => (
<div key={m.id} className="bg-[var(--mx-surface)] border border-[var(--mx-outline)] rounded-[18px] px-4 py-3 flex items-start justify-between">
<div className="flex-1 min-w-0">
<p className="text-sm text-[var(--mx-text)] truncate">{m.content}</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--mx-badge-blue)]/50 text-[var(--mx-tertiary-text)]">{m.category}</span>
{m.keywords?.slice(0, 3).map((kw, i) => (
<span key={i} className="text-xs text-[var(--mx-text-dim)]">{kw}</span>
))}
</div>
</div>
<button onClick={() => handleDelete(m.id)}
className="text-xs text-[var(--mx-error)]/70 hover:text-[var(--mx-error)] shrink-0 ml-3 mt-0.5"
>删除</button>
</div>
))}
</div>
)}
</div>
)
}
-147
View File
@@ -1,147 +0,0 @@
import { useEffect, useState } from 'react'
import { getToken } from '../../api/client'
type Provider = { name:string; base_url:string; timeout_sec:number; max_retries:number }
type Model = { id:string; name:string; provider:string; description:string; tags:string[]; enabled:boolean }
type Routing = { purpose:string; fallback_chain:string[]; required:boolean }
export default function ModelsPage() {
const [providers, setProviders] = useState<Provider[]>([])
const [models, setModels] = useState<Model[]>([])
const [routing, setRouting] = useState<Routing[]>([])
const [tab, setTab] = useState<'providers'|'models'|'routing'>('providers')
const fetchAll = async () => {
const token = getToken()
const res = await fetch('/api/v1/admin/models/config', { headers: { Authorization: `Bearer ${token}` } })
const data = await res.json()
setProviders(data.providers || [])
setModels(data.models || [])
setRouting(data.routing || [])
}
useEffect(() => { fetchAll() }, [])
const save = async (kind: string, name: string, body: any) => {
const token = getToken()
await fetch(`/api/v1/admin/models/config/${kind}/${name}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(body),
})
fetchAll()
}
const remove = async (kind: string, name: string) => {
const token = getToken()
await fetch(`/api/v1/admin/models/config/${kind}/${name}`, {
method: 'DELETE', headers: { Authorization: `Bearer ${token}` },
})
fetchAll()
}
return (
<div className="p-6 space-y-4">
<h2 className="text-lg font-semibold">模型配置</h2>
{/* Tabs */}
<div className="flex gap-1 bg-[var(--mx-surface)] rounded-full p-1 w-fit border border-[var(--mx-outline)]">
{(['providers','models','routing'] as const).map(t => (
<button key={t} onClick={() => setTab(t)}
className={`px-4 py-1.5 rounded-full text-sm transition-colors ${
tab === t ? 'bg-[var(--mx-primary)] text-white' : 'text-[var(--mx-text-dim)] hover:text-[var(--mx-text-secondary)]'
}`}
>{t === 'providers' ? '提供商' : t === 'models' ? '模型' : '路由'}</button>
))}
</div>
{/* Tab content */}
{tab === 'providers' && (
<div className="space-y-2">
{providers.map(p => (
<div key={p.name} className="bg-[var(--mx-surface)] border border-[var(--mx-outline)] rounded-[18px] px-4 py-3 flex items-center justify-between">
<div>
<p className="text-sm text-[var(--mx-text)]">{p.name}</p>
<p className="text-xs text-[var(--mx-text-dim)]">{p.base_url} · timeout {p.timeout_sec}s · retries {p.max_retries}</p>
</div>
<button onClick={() => remove('providers', p.name)} className="text-xs text-[var(--mx-error)]/60 hover:text-[var(--mx-error)]">删除</button>
</div>
))}
<AddProviderForm onSave={(name, body) => save('providers', name, body)} />
</div>
)}
{tab === 'models' && (
<div className="space-y-2">
{models.map(m => (
<div key={m.id} className="bg-[var(--mx-surface)] border border-[var(--mx-outline)] rounded-[18px] px-4 py-3 flex items-center justify-between">
<div>
<p className="text-sm text-[var(--mx-text)]">{m.name} <span className={`ml-2 text-xs ${m.enabled ? 'text-[var(--mx-tertiary-text)]' : 'text-[var(--mx-text-dim)]'}`}>{m.enabled ? '启用' : '禁用'}</span></p>
<p className="text-xs text-[var(--mx-text-dim)]">{m.id} · {m.provider} · {m.description}</p>
<div className="flex gap-1 mt-1">{(m.tags||[]).map(t => <span key={t} className="text-xs px-1.5 py-0.5 rounded bg-[var(--mx-badge-blue)]/50 text-[var(--mx-tertiary-text)]">{t}</span>)}</div>
</div>
<button onClick={() => remove('models', m.id)} className="text-xs text-[var(--mx-error)]/60 hover:text-[var(--mx-error)]">删除</button>
</div>
))}
<AddModelForm providers={providers} onSave={(name, body) => save('models', name, body)} />
</div>
)}
{tab === 'routing' && (
<div className="space-y-2">
{routing.map(r => (
<div key={r.purpose} className="bg-[var(--mx-surface)] border border-[var(--mx-outline)] rounded-[18px] px-4 py-3 flex items-center justify-between">
<div>
<p className="text-sm text-[var(--mx-text)]">{r.purpose}</p>
<p className="text-xs text-[var(--mx-text-dim)]">fallback: {r.fallback_chain?.join(' → ') || 'none'} {r.required ? '(required)' : ''}</p>
</div>
<button onClick={() => remove('routing', r.purpose)} className="text-xs text-[var(--mx-error)]/60 hover:text-[var(--mx-error)]">删除</button>
</div>
))}
<AddRoutingForm onSave={(name, body) => save('routing', name, body)} />
</div>
)}
</div>
)
}
function AddProviderForm({ onSave }: { onSave: (name:string, body:any) => void }) {
const [name, setName] = useState('')
const [url, setUrl] = useState('')
const save = () => { if(name&&url) { onSave(name, {base_url:url, timeout_sec:120, max_retries:3}); setName(''); setUrl('') } }
return (
<div className="flex gap-2">
<input value={name} onChange={e=>setName(e.target.value)} placeholder="名称" className="flex-1 bg-[var(--mx-surface)] border border-[var(--mx-outline)] rounded-[14px] px-3 py-2 text-sm text-[var(--mx-text)] placeholder:text-[var(--mx-text-dim)] outline-none" />
<input value={url} onChange={e=>setUrl(e.target.value)} placeholder="Base URL" className="flex-1 bg-[var(--mx-surface)] border border-[var(--mx-outline)] rounded-[14px] px-3 py-2 text-sm text-[var(--mx-text)] placeholder:text-[var(--mx-text-dim)] outline-none" />
<button onClick={save} className="px-4 py-2 bg-[var(--mx-primary)] text-white text-sm rounded-full font-medium">添加</button>
</div>
)
}
function AddModelForm({ providers, onSave }: { providers:Provider[]; onSave: (name:string, body:any) => void }) {
const [id, setId] = useState(''); const [name, setName] = useState('')
const [prov, setProv] = useState(providers[0]?.name||''); const [desc, setDesc] = useState('')
const save = () => { if(id&&name) { onSave(id, {name, provider:prov, description:desc, tags:[], params:{}, enabled:true}); setId(''); setName('') } }
return (
<div className="flex flex-wrap gap-2">
<input value={id} onChange={e=>setId(e.target.value)} placeholder="ID" className="bg-[var(--mx-surface)] border border-[var(--mx-outline)] rounded-[14px] px-3 py-2 text-sm w-32 text-[var(--mx-text)] placeholder:text-[var(--mx-text-dim)] outline-none" />
<input value={name} onChange={e=>setName(e.target.value)} placeholder="模型名" className="bg-[var(--mx-surface)] border border-[var(--mx-outline)] rounded-[14px] px-3 py-2 text-sm w-32 text-[var(--mx-text)] placeholder:text-[var(--mx-text-dim)] outline-none" />
<select value={prov} onChange={e=>setProv(e.target.value)} className="bg-[var(--mx-surface)] border border-[var(--mx-outline)] rounded-[14px] px-3 py-2 text-sm text-[var(--mx-text)] outline-none">
{providers.map(p => <option key={p.name}>{p.name}</option>)}
</select>
<input value={desc} onChange={e=>setDesc(e.target.value)} placeholder="描述" className="bg-[var(--mx-surface)] border border-[var(--mx-outline)] rounded-[14px] px-3 py-2 text-sm flex-1 text-[var(--mx-text)] placeholder:text-[var(--mx-text-dim)] outline-none" />
<button onClick={save} className="px-4 py-2 bg-[var(--mx-primary)] text-white text-sm rounded-full font-medium">添加</button>
</div>
)
}
function AddRoutingForm({ onSave }: { onSave: (name:string, body:any) => void }) {
const [purpose, setPurpose] = useState('')
const [chain, setChain] = useState('')
const save = () => { if(purpose) { onSave(purpose, {fallback_chain: chain.split(',').map(s=>s.trim()).filter(Boolean), required:true}); setPurpose(''); setChain('') } }
return (
<div className="flex gap-2">
<input value={purpose} onChange={e=>setPurpose(e.target.value)} placeholder="用途 (chat/intent/thinking)" className="flex-1 bg-[var(--mx-surface)] border border-[var(--mx-outline)] rounded-[14px] px-3 py-2 text-sm text-[var(--mx-text)] placeholder:text-[var(--mx-text-dim)] outline-none" />
<input value={chain} onChange={e=>setChain(e.target.value)} placeholder="fallback chain (逗号分隔)" className="flex-2 bg-[var(--mx-surface)] border border-[var(--mx-outline)] rounded-[14px] px-3 py-2 text-sm text-[var(--mx-text)] placeholder:text-[var(--mx-text-dim)] outline-none" />
<button onClick={save} className="px-4 py-2 bg-[var(--mx-primary)] text-white text-sm rounded-full font-medium">添加</button>
</div>
)
}
@@ -1,92 +0,0 @@
import { useEffect, useState } from 'react'
import { getToken } from '../../api/client'
export default function PerformancePage() {
const [latest, setLatest] = useState<any>({})
const [history, setHistory] = useState<any[]>([])
useEffect(() => {
fetchPerf()
const interval = setInterval(fetchPerf, 10000)
return () => clearInterval(interval)
}, [])
const fetchPerf = async () => {
try {
const token = getToken()
const res = await fetch('/api/v1/admin/perf', { headers: { Authorization: `Bearer ${token}` } })
const data = await res.json()
setLatest(data.latest || {})
setHistory(data.history || [])
} catch {}
}
const maxAlloc = Math.max(10, ...history.map((h: any) => h.alloc_mb || 0))
return (
<div className="p-6 space-y-6">
<h2 className="text-lg font-semibold">性能监控</h2>
{/* Current stats */}
<div className="grid grid-cols-4 gap-3">
<Stat label="内存分配" value={`${(latest.alloc_mb||0).toFixed(1)} MB`} sub="Heap Alloc" />
<Stat label="系统内存" value={`${(latest.sys_mb||0).toFixed(1)} MB`} sub="Sys" />
<Stat label="GC 次数" value={latest.num_gc || 0} sub="NumGC" />
<Stat label="Goroutines" value={latest.goroutines || 0} sub="并发" />
</div>
{/* Memory chart */}
<div>
<h3 className="text-sm font-medium text-[var(--mx-text-secondary)] mb-3">内存使用趋势 (最近 {history.length} 次采样)</h3>
<div className="bg-[var(--mx-surface)] border border-[var(--mx-outline)] rounded-[18px] p-4">
<div className="flex items-end gap-1 h-32">
{history.slice(-60).map((h: any, i: number) => {
const pct = maxAlloc > 0 ? ((h.alloc_mb || 0) / maxAlloc) * 100 : 0
return (
<div key={i}
className="flex-1 bg-[var(--mx-primary)]/60 hover:bg-[var(--mx-primary)] rounded-t-sm transition-colors"
style={{ height: `${Math.max(2, pct)}%` }}
title={`${(h.alloc_mb||0).toFixed(1)}MB`}
/>
)
})}
</div>
<div className="flex justify-between mt-2 text-xs text-[var(--mx-text-dim)]">
<span>{history[0]?.timestamp?.slice(11, 19) || '--'}</span>
<span>{history[history.length-1]?.timestamp?.slice(11, 19) || '--'}</span>
</div>
</div>
</div>
{/* GC chart */}
<div>
<h3 className="text-sm font-medium text-[var(--mx-text-secondary)] mb-3">GC 次数趋势</h3>
<div className="bg-[var(--mx-surface)] border border-[var(--mx-outline)] rounded-[18px] p-4">
<div className="flex items-end gap-1 h-24">
{history.slice(-60).map((h: any, i: number) => {
const maxGC = Math.max(1, ...history.map((x: any) => x.num_gc || 0))
const pct = ((h.num_gc || 0) / maxGC) * 100
return (
<div key={i}
className="flex-1 bg-[#4788FF]/40 hover:bg-[#4788FF] rounded-t-sm transition-colors"
style={{ height: `${Math.max(2, pct)}%` }}
title={`GC: ${h.num_gc}`}
/>
)
})}
</div>
</div>
</div>
</div>
)
}
function Stat({ label, value, sub }: { label:string; value:string|number; sub:string }) {
return (
<div className="bg-[var(--mx-surface)] border border-[var(--mx-outline)] rounded-[18px] px-4 py-3">
<p className="text-xs text-[var(--mx-text-dim)] mb-1">{label}</p>
<p className="text-lg font-bold text-[var(--mx-text)]">{value}</p>
<p className="text-xs text-[var(--mx-text-disabled)] mt-0.5">{sub}</p>
</div>
)
}
@@ -1,39 +0,0 @@
import { useEffect, useState } from 'react'
import { api } from '../../api/client'
const builtInPlugins = [
{ name: 'iot-mock', desc: '8 个模拟智能家居设备', type: 'IoT' },
{ name: 'calculator', desc: '数学表达式求值', type: '工具' },
{ name: 'datetime', desc: '日期时间计算', type: '工具' },
{ name: 'crypto', desc: '哈希/加解密', type: '工具' },
{ name: 'random', desc: '随机数生成', type: '工具' },
{ name: 'text', desc: '文本处理(统计/翻转)', type: '工具' },
{ name: 'json', desc: 'JSON 解析/格式化', type: '工具' },
{ name: 'markdown', desc: 'Markdown 渲染', type: '工具' },
{ name: 'file', desc: '文件操作', type: '工具' },
{ name: 'http', desc: 'HTTP 请求', type: '工具' },
{ name: 'web-fetch', desc: '网页内容抓取', type: '工具' },
]
export default function PluginsPage() {
return (
<div className="p-6 space-y-4">
<h2 className="text-lg font-semibold">插件管理</h2>
<p className="text-xs text-[var(--mx-text-dim)]">已加载 {builtInPlugins.length} 个插件。工具插件为 LLM 提供可调用能力。</p>
<div className="grid grid-cols-2 gap-2">
{builtInPlugins.map(p => (
<div key={p.name} className="bg-[var(--mx-surface)] border border-[var(--mx-outline)] rounded-[18px] px-4 py-3 flex items-center justify-between">
<div>
<p className="text-sm text-[var(--mx-text)]">{p.name}</p>
<p className="text-xs text-[var(--mx-text-dim)]">{p.desc}</p>
</div>
<span className={`text-xs px-2 py-0.5 rounded-full ${
p.type === 'IoT' ? 'bg-[var(--mx-badge-blue)] text-[var(--mx-tertiary-text)]' : 'bg-[var(--mx-badge-blue)]/50 text-[var(--mx-text-dim)]'
}`}>{p.type}</span>
</div>
))}
</div>
</div>
)
}
@@ -1,46 +0,0 @@
import { useEffect, useState } from 'react'
import { api } from '../../api/client'
const builtInServices = [
{ id: 'ai_core', name: 'AI 核心', desc: 'LLM 编排引擎', port: '8081' },
{ id: 'memory', name: '记忆服务', desc: 'pgvector 语义检索', port: '8091' },
{ id: 'voice', name: '语音服务', desc: 'STT / TTS', port: '8093' },
{ id: 'gateway', name: '网关', desc: 'API + WebSocket 入口', port: '8080' },
]
export default function ServicesPage() {
const [services, setServices] = useState<Record<string, boolean>>({})
useEffect(() => {
api.admin.dashboard().then(r => {
setServices(r.dashboard?.services as Record<string, boolean> || {})
})
}, [])
return (
<div className="p-6 space-y-4">
<h2 className="text-lg font-semibold">服务管理</h2>
<p className="text-xs text-[var(--mx-text-dim)]">单二进制模式 — 所有服务编译在同一进程。服务不可单独启停。</p>
<div className="space-y-2">
{builtInServices.map(svc => {
const alive = services[svc.id] ?? false
return (
<div key={svc.id} className="bg-[var(--mx-surface)] border border-[var(--mx-outline)] rounded-[18px] px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className={`w-2.5 h-2.5 rounded-full ${alive ? 'bg-[#4788FF]' : 'bg-[#404040]'}`} />
<div>
<p className="text-sm text-[var(--mx-text)]">{svc.name}</p>
<p className="text-xs text-[var(--mx-text-dim)]">{svc.desc} · 端口 {svc.port}</p>
</div>
</div>
<span className={`text-xs ${alive ? 'text-[var(--mx-tertiary-text)]' : 'text-[var(--mx-text-dim)]'}`}>
{alive ? '运行中' : '未启动'}
</span>
</div>
)
})}
</div>
</div>
)
}
@@ -1,80 +0,0 @@
import { useEffect, useState } from 'react'
import { getToken } from '../../api/client'
export default function SystemPage() {
const [info, setInfo] = useState<any>({})
const [sessions, setSessions] = useState<any[]>([])
const [plugins, setPlugins] = useState<any[]>([])
useEffect(() => {
fetchAll()
const interval = setInterval(fetchAll, 10000)
return () => clearInterval(interval)
}, [])
const fetchAll = async () => {
const token = getToken()
const headers = { Authorization: `Bearer ${token}` }
try {
const [sysRes, sessionRes, pluginRes] = await Promise.all([
fetch('/api/v1/admin/system', { headers }),
fetch('/api/v1/admin/sessions', { headers }),
fetch('/api/v1/admin/plugins', { headers }),
])
if (sysRes.ok) setInfo((await sysRes.json()).system)
if (sessionRes.ok) setSessions((await sessionRes.json()).sessions || [])
if (pluginRes.ok) setPlugins((await pluginRes.json()).plugins || [])
} catch {}
}
return (
<div className="p-6 space-y-6">
<h2 className="text-lg font-semibold">系统信息</h2>
{/* System Info */}
<div className="grid grid-cols-4 gap-3">
<StatRow label="版本" value={info.version || '-'} />
<StatRow label="Go 版本" value={info.go_version || '-'} />
<StatRow label="运行时间" value={info.uptime_sec ? `${Math.floor(info.uptime_sec/60)}m` : '-'} />
<StatRow label="WS 连接" value={info.ws_clients ?? 0} />
</div>
{/* Plugins list */}
<div>
<h3 className="text-sm font-medium text-[var(--mx-text-secondary)] mb-3">已加载插件 ({plugins.length})</h3>
<div className="flex flex-wrap gap-2">
{plugins.map((p, i) => (
<span key={i} className="px-3 py-1.5 bg-[var(--mx-surface)] border border-[var(--mx-outline)] rounded-full text-xs text-[var(--mx-text)]">
{p.name}
</span>
))}
</div>
</div>
{/* Sessions */}
<div>
<h3 className="text-sm font-medium text-[var(--mx-text-secondary)] mb-3">活跃会话 ({sessions.length})</h3>
{sessions.length === 0 ? (
<p className="text-xs text-[var(--mx-text-dim)]">暂无活跃会话</p>
) : (
<div className="space-y-1">
{sessions.map((s: any, i) => (
<div key={i} className="bg-[var(--mx-surface)] border border-[var(--mx-outline)] rounded-[14px] px-4 py-2 text-xs text-[var(--mx-text-dim)]">
{s.session_id || s.id || '-'}
</div>
))}
</div>
)}
</div>
</div>
)
}
function StatRow({ label, value }: { label:string; value:string|number }) {
return (
<div className="bg-[var(--mx-surface)] border border-[var(--mx-outline)] rounded-[18px] px-4 py-3">
<p className="text-xs text-[var(--mx-text-dim)] mb-1">{label}</p>
<p className="text-lg font-bold text-[var(--mx-text)]">{value}</p>
</div>
)
}
-187
View File
@@ -1,187 +0,0 @@
import { useEffect, useState, useRef } from 'react'
import { getToken } from '../../api/client'
// Hop color map — waterfall bar colors
const hopColors: Record<string, string> = {
msg_received: '#06b6d4', dispatch: '#8b5cf6', intent_analysis: '#f59e0b',
intent_fast: '#fbbf24', persona_load: '#ec4899', subsession: '#10b981',
memory_search: '#3b82f6', iot_query: '#06b6d4', context_build: '#6b7280',
llm_request: '#6366f1', llm_first_token: '#818cf8', llm_stream: '#a5b4fc',
review_parse: '#8b5cf6', ws_push: '#f97316', db_persist: '#22c55e',
memory_extract: '#ec4899', background_think: '#14b8a6',
}
const hopIcons: Record<string, string> = {
msg_received: '📥', dispatch: '🚀', intent_analysis: '🎯', intent_fast: '⚡',
persona_load: '👤', subsession: '⚙', memory_search: '🧠', iot_query: '⌂',
context_build: '📦', llm_request: '🤖', llm_first_token: '💬', llm_stream: '▸',
review_parse: '📝', ws_push: '📤', db_persist: '💾', memory_extract: '♻',
background_think: '🔄',
}
export default function TracePage() {
const [traces, setTraces] = useState<any[]>([])
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [selected, setSelected] = useState<any>(null)
const intervalRef = useRef<number>(0)
useEffect(() => {
fetchTraces()
intervalRef.current = window.setInterval(fetchTraces, 3000)
return () => clearInterval(intervalRef.current)
}, [])
const fetchTraces = async () => {
try {
const token = getToken()
const res = await fetch('/api/v1/admin/trace', { headers: { Authorization: `Bearer ${token}` } })
const data = await res.json()
setTraces(data.traces || [])
} catch {}
}
const toggleExpand = (id: string) => {
const next = new Set(expanded)
if (next.has(id)) next.delete(id); else next.add(id)
setExpanded(next)
}
// Calculate global time scale
const maxDuration = Math.max(100, ...traces.map((t: any) => {
if (t.end_time && t.start_time) {
return new Date(t.end_time).getTime() - new Date(t.start_time).getTime()
}
const maxSpan = Math.max(0, ...(t.spans || []).map((s: any) => (s.start_ms + s.duration)))
return maxSpan
}))
return (
<div className="p-6 space-y-4 h-full flex flex-col" style={{ color: 'var(--mx-text)' }}>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">全链路追踪</h2>
<button onClick={fetchTraces}
className="px-3 py-1.5 rounded-full text-xs border"
style={{ borderColor: 'var(--mx-outline)', color: 'var(--mx-text-dim)' }}
>刷新</button>
</div>
{/* Waterfall charts */}
<div className="flex-1 overflow-auto space-y-3">
{traces.length === 0 ? (
<div className="text-center py-12 text-sm" style={{ color: 'var(--mx-text-dim)' }}>
暂无追踪数据 — 发送消息后自动记录
</div>
) : (
traces.map((trace: any) => {
const totalMs = trace.end_time
? new Date(trace.end_time).getTime() - new Date(trace.start_time).getTime()
: Date.now() - new Date(trace.start_time).getTime()
const isRunning = trace.status === 'running'
return (
<div key={trace.id}
className="rounded-[18px] border overflow-hidden"
style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)' }}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 cursor-pointer"
onClick={() => toggleExpand(trace.id)}
>
<div className="flex items-center gap-3 min-w-0">
{isRunning ? (
<span className="w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse shrink-0" />
) : trace.status === 'error' ? (
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{background:'var(--mx-error)'}} />
) : (
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{background:'var(--mx-tertiary-text)'}} />
)}
<span className="text-sm truncate">{trace.user_msg || '(无消息)'}</span>
</div>
<div className="flex items-center gap-3 text-xs shrink-0" style={{ color: 'var(--mx-text-dim)' }}>
<span>{totalMs}ms</span>
<span>{trace.spans?.length || 0} 环节</span>
<span>{expanded.has(trace.id) ? '▼' : '▶'}</span>
</div>
</div>
{/* Expanded: waterfall view */}
{expanded.has(trace.id) && (
<div className="px-4 pb-4 space-y-1">
{/* Time scale ruler */}
<div className="flex justify-between text-xs px-1" style={{ color: 'var(--mx-text-dim)' }}>
<span>0ms</span>
<span>{Math.round(maxDuration/2)}ms</span>
<span>{Math.round(maxDuration)}ms</span>
</div>
{/* Span bars */}
{(trace.spans || []).map((span: any, i: number) => {
const pct = maxDuration > 0 ? (span.duration / maxDuration) * 100 : 0
const leftPct = maxDuration > 0 ? (span.start_ms / maxDuration) * 100 : 0
const color = hopColors[span.name] || '#6b7280'
return (
<div key={i}
className="flex items-center gap-2 group cursor-pointer hover:opacity-90"
onClick={() => setSelected({...span, trace_id: trace.id, trace_msg: trace.user_msg})}
>
{/* Label */}
<div className="w-24 shrink-0 text-right">
<span className="text-xs">{hopIcons[span.name] || '●'} {span.label}</span>
</div>
{/* Bar */}
<div className="flex-1 relative h-5">
<div className="absolute top-0 h-4 rounded-full opacity-20"
style={{ left: `${leftPct}%`, width: `${Math.max(pct, 1)}%`, background: color }} />
<div className="absolute top-0 h-4 rounded-full"
style={{ left: `${leftPct}%`, width: `${Math.max(pct, 1)}%`, background: color }} />
</div>
{/* Duration */}
<div className="w-14 shrink-0 text-right text-xs" style={{ color: 'var(--mx-text-dim)' }}>
{span.duration}ms
</div>
{/* Status dot */}
<div className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ background: span.status === 'error' ? 'var(--mx-error)' : span.status === 'running' ? '#fbbf24' : color }} />
</div>
)
})}
</div>
)}
</div>
)
})
)}
</div>
{/* Detail popup */}
{selected && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setSelected(null)}>
<div className="rounded-[18px] border p-6 max-w-md w-full mx-4 space-y-3"
style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)' }}
onClick={e => e.stopPropagation()}
>
<h3 className="text-sm font-semibold">{hopIcons[selected.name]} {selected.label}</h3>
<div className="grid grid-cols-2 gap-2 text-xs">
<div><span style={{color:'var(--mx-text-dim)'}}>耗时</span><p>{selected.duration}ms</p></div>
<div><span style={{color:'var(--mx-text-dim)'}}>偏移</span><p>{selected.start_ms}ms</p></div>
<div><span style={{color:'var(--mx-text-dim)'}}>状态</span><p>{selected.status}</p></div>
<div><span style={{color:'var(--mx-text-dim)'}}>Hop</span><p>{selected.name}</p></div>
</div>
{selected.detail && (
<div className="rounded-[14px] border p-3 text-xs font-mono whitespace-pre-wrap max-h-40 overflow-auto"
style={{ background: 'var(--mx-bg)', borderColor: 'var(--mx-outline)', color: 'var(--mx-text-dim)' }}>
{selected.detail}
</div>
)}
<button onClick={() => setSelected(null)}
className="w-full py-2 rounded-full text-sm text-white" style={{background:'var(--mx-primary)'}}>关闭</button>
</div>
</div>
)}
</div>
)
}
-57
View File
@@ -1,57 +0,0 @@
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { useChatStore } from '../../store/chatStore'
import ChatContainer from '../../components/chat/ChatContainer'
import SearchModal from '../../components/chat/SearchModal'
import Sidebar from '../../components/layout/Sidebar'
import { api } from '../../api/client'
import type { Message } from '../../types/chat'
export default function ChatPage() {
const { sessionId } = useParams()
const { setSessionId, setMessages } = useChatStore()
const [showSearch, setShowSearch] = useState(false)
useEffect(() => {
if (!sessionId) return
setSessionId(sessionId)
// Load persisted history for the session
let cancelled = false
api.sessions.messages(sessionId).then((data) => {
if (cancelled) return
const msgs: Message[] = (data.messages || []).map((m) => ({
id: `db-${m.id}`,
session_id: m.session_id,
role: m.role === 'user' ? 'user' : 'assistant',
content: m.content,
created_at: m.created_at,
}))
setMessages(msgs)
}).catch(() => { /* history unavailable */ })
return () => { cancelled = true }
}, [sessionId, setSessionId, setMessages])
// Ctrl+K opens message search
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault()
setShowSearch(true)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
return (
<div className="flex h-full">
<Sidebar onSearch={() => setShowSearch(true)} />
<div className="flex-1 min-w-0">
<ChatContainer />
</div>
{showSearch && <SearchModal onClose={() => setShowSearch(false)} />}
</div>
)
}
-27
View File
@@ -1,27 +0,0 @@
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' })
},
}))
-53
View File
@@ -1,53 +0,0 @@
import { create } from 'zustand'
import type { Message, IotDevice } from '../types/chat'
interface MoodState {
mood: string
intensity: number
affection: number
}
interface ChatState {
messages: Message[]
isStreaming: boolean
currentSessionId: string | null
iotDevices: IotDevice[]
inputText: string
mood: MoodState | null
setInput: (text: string) => void
setSessionId: (id: string) => void
setMessages: (msgs: Message[]) => void
setMood: (m: MoodState) => 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: '',
mood: null,
setInput: (text) => set({ inputText: text }),
setSessionId: (id) => set({ currentSessionId: id }),
setMessages: (msgs) => set({ messages: msgs }),
setMood: (m) => set({ mood: m }),
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: [] }),
}))
-58
View File
@@ -1,58 +0,0 @@
import { create } from 'zustand'
type Theme = 'light' | 'dark'
function getSystemTheme(): Theme {
if (typeof window === 'undefined') return 'light'
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function getStoredTheme(): Theme | null {
try {
const stored = localStorage.getItem('cyrene_theme')
if (stored === 'light' || stored === 'dark') return stored
} catch {}
return null
}
interface ThemeState {
theme: Theme
setTheme: (t: Theme) => void
toggle: () => void
}
export const useThemeStore = create<ThemeState>((set, get) => ({
theme: getStoredTheme() || 'light',
setTheme: (t: Theme) => {
document.documentElement.setAttribute('data-theme', t)
localStorage.setItem('cyrene_theme', t)
set({ theme: t })
},
toggle: () => {
const next = get().theme === 'dark' ? 'light' : 'dark'
get().setTheme(next)
},
}))
// Initialize theme on app start
if (typeof document !== 'undefined') {
const stored = getStoredTheme()
if (stored) {
document.documentElement.setAttribute('data-theme', stored)
} else {
// Follow system preference
const sys = getSystemTheme()
document.documentElement.setAttribute('data-theme', sys)
}
// Listen for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!getStoredTheme()) {
const sys = e.matches ? 'dark' : 'light'
document.documentElement.setAttribute('data-theme', sys)
useThemeStore.setState({ theme: sys })
}
})
}
-38
View File
@@ -1,38 +0,0 @@
// 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
mood?: string
intensity?: number
affection?: number
}