feat(ui): add light theme with system auto-switch + Cyrene avatar
- Miuix day/night dual theme CSS variables - themeStore: light default, follows prefers-color-scheme - Header: sun/moon toggle button - All components migrated to CSS variables - Cyrene avatar image replaces 'C' logo everywhere - Favicon added
This commit is contained in:
1 parent
78c5f0f1b0
commit
5faa955bce
24 files changed
+337
-218
No files matched your search
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="/images/cyrene-avatar.png" />
|
||||
<title>Cyrene — 昔涟</title>
|
||||
</head>
|
||||
<body class="bg-gray-950 text-gray-100">
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
@@ -18,9 +18,9 @@ export default function AdminLayout() {
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Sidebar */}
|
||||
<nav className="w-52 bg-[#000] border-r border-[#393939] shrink-0 flex flex-col">
|
||||
<div className="p-4 border-b border-[#393939]">
|
||||
<h2 className="text-sm font-semibold text-white/80">管理面板</h2>
|
||||
<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 => (
|
||||
@@ -30,8 +30,8 @@ export default function AdminLayout() {
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2.5 rounded-[14px] text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-[#277AF7]/15 text-[#4788FF] font-medium'
|
||||
: 'text-white/50 hover:text-white/80 hover:bg-white/5'
|
||||
? '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)]'
|
||||
}`
|
||||
}
|
||||
>
|
||||
@@ -43,7 +43,7 @@ export default function AdminLayout() {
|
||||
</nav>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 overflow-auto bg-[#242424]">
|
||||
<div className="flex-1 overflow-auto bg-[var(--mx-bg)]">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,10 +40,10 @@ export default function ChatContainer() {
|
||||
<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] bg-[#277AF7] flex items-center justify-center shadow-lg shadow-[#277AF7]/20 mb-2">
|
||||
<span className="text-2xl font-bold text-white">C</span>
|
||||
<div className="w-16 h-16 rounded-[20px] bg-[var(--mx-primary)] flex items-center justify-center shadow-lg shadow-[var(--mx-primary)]/20 mb-2">
|
||||
<img src="/images/cyrene-avatar.png" alt="Cyrene" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<p className="text-white/50 text-sm">开始新对话吧~</p>
|
||||
<p className="text-[var(--mx-text-dim)] text-sm">开始新对话吧~</p>
|
||||
</div>
|
||||
) : (
|
||||
<MessageList messages={messages} />
|
||||
@@ -53,7 +53,7 @@ export default function ChatContainer() {
|
||||
</div>
|
||||
|
||||
{/* Input bar */}
|
||||
<div className="shrink-0 border-t border-[#393939] bg-[#000]">
|
||||
<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>
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function ChatInput({ value, onChange, onSend, disabled }: ChatInp
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-2 bg-[#000] rounded-[20px] border border-[#404040] focus-within:border-[#277AF7] transition-colors p-1.5">
|
||||
<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}
|
||||
@@ -22,12 +22,12 @@ export default function ChatInput({ value, onChange, onSend, disabled }: ChatInp
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
placeholder="输入消息..."
|
||||
className="flex-1 bg-transparent px-3 py-1.5 text-sm text-[#F2F2F2] placeholder:text-white/30 outline-none disabled:opacity-40"
|
||||
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={onSend}
|
||||
disabled={disabled || !value.trim()}
|
||||
className="w-9 h-9 bg-[#277AF7] hover:bg-[#3482FF] active:scale-90 disabled:bg-[#253E64] text-white rounded-full flex items-center justify-center shrink-0 transition-all duration-200"
|
||||
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"/>
|
||||
|
||||
@@ -19,7 +19,7 @@ function MessageBubble({ message }: { message: Message }) {
|
||||
if (isAction) {
|
||||
return (
|
||||
<div className="flex justify-center py-1">
|
||||
<span className="text-xs text-[#4788FF] bg-[#2B3B54]/80 px-3 py-1 rounded-full">
|
||||
<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>
|
||||
@@ -29,7 +29,7 @@ function MessageBubble({ message }: { message: Message }) {
|
||||
if (isSystem) {
|
||||
return (
|
||||
<div className="flex justify-center py-1">
|
||||
<span className="text-xs text-[#ffdad6] bg-[#2E0603]/80 px-3 py-1 rounded-full">{message.content}</span>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -38,17 +38,17 @@ function MessageBubble({ message }: { message: Message }) {
|
||||
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
|
||||
{/* Avatar for AI */}
|
||||
{!isUser && (
|
||||
<div className="w-8 h-8 rounded-xl bg-[#277AF7] flex items-center justify-center shrink-0 mr-2 mt-1">
|
||||
<span className="text-xs font-bold text-white">C</span>
|
||||
<div className="w-8 h-8 rounded-xl bg-[var(--mx-primary)] flex items-center justify-center 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-[#277AF7] text-white rounded-[18px] rounded-br-[6px]'
|
||||
? 'bg-[var(--mx-primary)] text-white rounded-[18px] rounded-br-[6px]'
|
||||
: isEmpty
|
||||
? 'bg-transparent text-transparent'
|
||||
: 'bg-[#000] text-[#F2F2F2] rounded-[18px] rounded-bl-[6px] border border-[#404040]'
|
||||
: '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">
|
||||
|
||||
@@ -1,41 +1,51 @@
|
||||
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 bg-[#242424] text-[#F2F2F2]">
|
||||
<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 border-[#393939] bg-[#000]">
|
||||
<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 bg-[#277AF7] flex items-center justify-center">
|
||||
<span className="text-sm font-bold text-white">C</span>
|
||||
<div className="w-7 h-7 rounded-lg overflow-hidden flex items-center justify-center" style={{ background: 'var(--mx-primary)' }}>
|
||||
<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 ${
|
||||
location.pathname.startsWith('/chat')
|
||||
? 'bg-[#277AF7]/15 text-[#4788FF]'
|
||||
: 'text-white/50 hover:text-white/80'
|
||||
}`}
|
||||
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 ${
|
||||
location.pathname.startsWith('/admin')
|
||||
? 'bg-[#277AF7]/15 text-[#4788FF]'
|
||||
: 'text-white/50 hover:text-white/80'
|
||||
}`}
|
||||
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 bg-[#393939] mx-1" />
|
||||
<span className="text-xs text-white/40">{username}</span>
|
||||
<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 text-white/40 hover:text-[#F12522] transition-colors"
|
||||
className="px-3 py-1.5 rounded-full text-sm transition-colors"
|
||||
style={{ color: 'var(--mx-text-dim)' }}
|
||||
>退出</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -16,10 +16,10 @@ export default function Sidebar() {
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="w-60 bg-[#000] border-r border-[#393939] shrink-0 flex flex-col">
|
||||
<aside className="w-60 bg-[var(--mx-surface)] border-r border-[var(--mx-divider)] shrink-0 flex flex-col">
|
||||
<div className="p-3">
|
||||
<button onClick={handleNewChat}
|
||||
className="w-full py-2.5 bg-[#277AF7] hover:bg-[#3482FF] active:scale-[0.98] text-white font-medium text-sm rounded-full transition-all duration-200"
|
||||
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>
|
||||
@@ -27,11 +27,11 @@ export default function Sidebar() {
|
||||
|
||||
<div className="flex-1 overflow-auto px-2 pb-2">
|
||||
{currentSessionId ? (
|
||||
<div className="px-3 py-2 bg-[#242424] rounded-[14px]">
|
||||
<p className="text-xs text-white/40 truncate">{currentSessionId.slice(0, 12)}...</p>
|
||||
<div className="px-3 py-2 bg-[var(--mx-bg)] rounded-[14px]">
|
||||
<p className="text-xs text-[var(--mx-text-dim)] truncate">{currentSessionId.slice(0, 12)}...</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="px-3 py-2 text-xs text-white/30 text-center">暂无对话</p>
|
||||
<p className="px-3 py-2 text-xs text-[var(--mx-text-dim)] text-center">暂无对话</p>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
+100
-51
@@ -1,33 +1,46 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
/* Miuix Dark Color Scheme — Xiaomi HyperOS */
|
||||
--color-mx-primary: #277AF7;
|
||||
--color-mx-primary-hover: #3482FF;
|
||||
--color-mx-primary-container: #338FE4;
|
||||
--color-mx-on-primary: #FFFFFF;
|
||||
/* =============================================
|
||||
Miuix HyperOS Design Tokens
|
||||
日间 (Light) + 夜间 (Dark)
|
||||
============================================= */
|
||||
|
||||
--color-mx-bg: #242424;
|
||||
--color-mx-bg-elevated: #000000;
|
||||
--color-mx-bg-high: #2D2D2D;
|
||||
/* 夜间模式 (默认) */
|
||||
:root {
|
||||
--mx-primary: #3482FF;
|
||||
--mx-primary-hover: #277AF7;
|
||||
--mx-primary-container: #338FE4;
|
||||
--mx-on-primary: #FFFFFF;
|
||||
|
||||
--color-mx-surface: #000000;
|
||||
--color-mx-surface-variant: #242424;
|
||||
--color-mx-surface-high: #2D2D2D;
|
||||
--mx-bg: #242424;
|
||||
--mx-bg-elevated: #000000;
|
||||
--mx-bg-high: #2D2D2D;
|
||||
|
||||
--color-mx-text: #F2F2F2;
|
||||
--color-mx-text-secondary: rgba(255,255,255,0.8);
|
||||
--color-mx-text-dim: rgba(255,255,255,0.5);
|
||||
--color-mx-text-disabled: #666666;
|
||||
--mx-surface: #000000;
|
||||
--mx-surface-variant: #242424;
|
||||
--mx-surface-high: #2D2D2D;
|
||||
|
||||
--color-mx-outline: #404040;
|
||||
--color-mx-divider: #393939;
|
||||
--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;
|
||||
|
||||
--color-mx-error: #F12522;
|
||||
--color-mx-error-container: #2E0603;
|
||||
--mx-outline: #404040;
|
||||
--mx-divider: #393939;
|
||||
|
||||
--color-mx-tertiary: #2B3B54;
|
||||
--color-mx-tertiary-text: #4788FF;
|
||||
--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;
|
||||
@@ -37,64 +50,100 @@
|
||||
--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(--color-mx-bg);
|
||||
color: var(--color-mx-text);
|
||||
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(--color-mx-outline); border-radius: 2px; }
|
||||
::-webkit-scrollbar-thumb { background: var(--mx-outline); border-radius: 2px; }
|
||||
|
||||
/* Smooth focus ring */
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--color-mx-primary);
|
||||
outline: 2px solid var(--mx-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-mx-button);
|
||||
}
|
||||
|
||||
/* Glass effect for header */
|
||||
/* =============================================
|
||||
Utility classes (using CSS variables)
|
||||
============================================= */
|
||||
|
||||
.mx-glass {
|
||||
background: rgba(36, 36, 36, 0.85);
|
||||
background: rgba(36,36,36,0.85);
|
||||
backdrop-filter: blur(20px) saturate(1.5);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.5);
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
.mx-transition {
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
[data-theme="light"] .mx-glass {
|
||||
background: rgba(255,255,255,0.85);
|
||||
}
|
||||
|
||||
/* Card surface */
|
||||
.mx-card {
|
||||
background: var(--color-mx-surface);
|
||||
background: var(--mx-surface);
|
||||
border-radius: var(--radius-mx-card);
|
||||
border: 0.5px solid var(--color-mx-outline);
|
||||
border: 0.5px solid var(--mx-outline);
|
||||
}
|
||||
|
||||
/* Button base */
|
||||
.mx-btn {
|
||||
background: var(--color-mx-primary);
|
||||
color: var(--color-mx-on-primary);
|
||||
.mx-btn-primary {
|
||||
background: var(--mx-primary);
|
||||
color: var(--mx-on-primary);
|
||||
border-radius: var(--radius-mx-button);
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.mx-btn:hover { background: var(--color-mx-primary-hover); }
|
||||
.mx-btn:active { transform: scale(0.97); }
|
||||
.mx-btn:disabled { background: #253E64; color: #677993; }
|
||||
.mx-btn-primary:hover { background: var(--mx-primary-hover); }
|
||||
.mx-btn-primary:active { transform: scale(0.97); }
|
||||
|
||||
/* Input base */
|
||||
.mx-input {
|
||||
background: var(--color-mx-surface);
|
||||
border: 0.5px solid var(--color-mx-outline);
|
||||
background: var(--mx-surface);
|
||||
border: 0.5px solid var(--mx-outline);
|
||||
border-radius: var(--radius-mx-input);
|
||||
color: var(--color-mx-text);
|
||||
color: var(--mx-text);
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.mx-input:focus { border-color: var(--color-mx-primary); }
|
||||
.mx-input::placeholder { color: var(--color-mx-text-dim); }
|
||||
.mx-input:focus { border-color: var(--mx-primary); }
|
||||
.mx-input::placeholder { color: var(--mx-text-dim); }
|
||||
@@ -3,6 +3,7 @@ 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)
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -25,20 +25,20 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#242424] p-4">
|
||||
<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] bg-[#277AF7] flex items-center justify-center shadow-lg shadow-[#277AF7]/20">
|
||||
<span className="text-3xl font-bold text-white">C</span>
|
||||
<div className="mx-auto w-20 h-20 rounded-[22px] bg-[var(--mx-primary)] flex items-center justify-center shadow-lg shadow-[var(--mx-primary)]/20">
|
||||
<img src="/images/cyrene-avatar.png" alt="Cyrene" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-[#F2F2F2]">Cyrene</h1>
|
||||
<p className="text-sm text-white/50">LLM 智能体平台</p>
|
||||
<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-[#2E0603] border border-[#F12522]/30 rounded-[14px] px-4 py-3 text-sm text-[#ffdad6] text-center">
|
||||
<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>
|
||||
)}
|
||||
@@ -49,19 +49,19 @@ export default function LoginPage() {
|
||||
type="text" value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
placeholder="用户名"
|
||||
className="w-full bg-[#000] border border-[#404040] focus:border-[#277AF7] rounded-[14px] px-4 py-3 text-[#F2F2F2] placeholder:text-white/50 outline-none transition-colors"
|
||||
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-[#000] border border-[#404040] focus:border-[#277AF7] rounded-[14px] px-4 py-3 text-[#F2F2F2] placeholder:text-white/50 outline-none transition-colors"
|
||||
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-[#277AF7] hover:bg-[#3482FF] active:scale-[0.97] disabled:bg-[#253E64] disabled:text-[#677993] text-white font-medium py-3 rounded-full transition-all duration-200"
|
||||
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>
|
||||
|
||||
@@ -9,26 +9,26 @@ export default function BridgePage() {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<h2 className="text-lg font-semibold">平台桥接</h2>
|
||||
<p className="text-xs text-white/40">多平台消息桥接 — QQ (OBv11) / Telegram / Discord / Webhook。通过环境变量配置启用。</p>
|
||||
<p className="text-xs text-[var(--mx-text-dim)]">多平台消息桥接 — QQ (OBv11) / Telegram / Discord / Webhook。通过环境变量配置启用。</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{adapters.map(a => (
|
||||
<div key={a.name} className="bg-[#000] border border-[#404040] rounded-[18px] px-4 py-3">
|
||||
<div key={a.name} className="bg-[var(--mx-surface)] border border-[var(--mx-outline)] rounded-[18px] px-4 py-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-[#F2F2F2]/30" />
|
||||
<span className="text-sm text-[#F2F2F2]">{a.name}</span>
|
||||
<span className="text-sm text-[var(--mx-text)]">{a.name}</span>
|
||||
</div>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-[#2B3B54]/50 text-[#4788FF]">{a.platform}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-[var(--mx-badge-blue)]/50 text-[var(--mx-tertiary-text)]">{a.platform}</span>
|
||||
</div>
|
||||
<p className="text-xs text-white/30 pl-7">{a.desc}</p>
|
||||
<p className="text-xs text-[var(--mx-text-dim)] pl-7">{a.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-[#000] border border-[#404040] rounded-[18px] p-4 mt-4">
|
||||
<h3 className="text-sm font-medium text-[#F2F2F2] mb-2">群聊行为规则</h3>
|
||||
<ul className="text-xs text-white/40 space-y-1 list-disc list-inside">
|
||||
<div className="bg-[var(--mx-surface)] border border-[var(--mx-outline)] rounded-[18px] p-4 mt-4">
|
||||
<h3 className="text-sm font-medium text-[var(--mx-text)] mb-2">群聊行为规则</h3>
|
||||
<ul className="text-xs text-[var(--mx-text-dim)] space-y-1 list-disc list-inside">
|
||||
<li>消息最大 500 字,超出截断</li>
|
||||
<li>最短发送间隔 3 秒</li>
|
||||
<li>每轮最多 2 条消息</li>
|
||||
|
||||
@@ -18,22 +18,22 @@ export default function DashboardPage() {
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<StatCard label="服务" value={`${serviceCount}/${totalServices}`} sub="运行中/总数" color="#4788FF" />
|
||||
<StatCard label="插件" value="11" sub="已加载" color="#277AF7" />
|
||||
<StatCard label="运行时间" value={data?.uptime_sec ? formatUptime(data.uptime_sec) : '...'} sub="since start" color="#F2F2F2" />
|
||||
<StatCard label="版本" value="2.0.0-dev" sub="" color="rgba(255,255,255,0.5)" />
|
||||
<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-[#000] rounded-[18px] border border-[#404040] divide-y divide-[#393939]">
|
||||
<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-[#4788FF]' : 'bg-[#404040]'}`} />
|
||||
<span className="text-sm text-[#F2F2F2]">{name}</span>
|
||||
<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-white/40">{services[name] ? '运行中' : '未启动'}</span>
|
||||
<span className="text-xs text-[var(--mx-text-dim)]">{services[name] ? '运行中' : '未启动'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -54,10 +54,10 @@ export default function DashboardPage() {
|
||||
|
||||
function StatCard({ label, value, sub, color }: { label:string; value:string; sub:string; color:string }) {
|
||||
return (
|
||||
<div className="bg-[#000] border border-[#404040] rounded-[18px] px-4 py-3">
|
||||
<p className="text-xs text-white/40 mb-1">{label}</p>
|
||||
<p className="text-xl font-bold" style={{color}}>{value}</p>
|
||||
{sub && <p className="text-xs text-white/30 mt-0.5">{sub}</p>}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -65,7 +65,7 @@ function StatCard({ label, value, sub, color }: { label:string; value:string; su
|
||||
function Section({ title, children }: { title:string; children:React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-white/60 mb-3">{title}</h3>
|
||||
<h3 className="text-sm font-medium text-[var(--mx-text-secondary)] mb-3">{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -76,10 +76,10 @@ import { Link } from 'react-router-dom'
|
||||
function QuickLink({ to, label, desc }: { to:string; label:string; desc:string }) {
|
||||
return (
|
||||
<Link to={to}
|
||||
className="block bg-[#000] border border-[#404040] rounded-[18px] px-4 py-3 hover:border-[#277AF7]/50 transition-colors"
|
||||
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-[#F2F2F2] font-medium">{label}</p>
|
||||
<p className="text-xs text-white/30 mt-1">{desc}</p>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,16 +26,16 @@ export default function IoTManagementPage() {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<h2 className="text-lg font-semibold">IoT 设备管理</h2>
|
||||
<p className="text-xs text-white/40">8 个模拟智能家居设备(测试环境)</p>
|
||||
<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-[#000] border border-[#404040] rounded-[18px] px-4 py-3 flex items-center justify-between">
|
||||
<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-[#F2F2F2]">{d.name}</p>
|
||||
<p className="text-xs text-white/30">
|
||||
<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}%`}
|
||||
@@ -45,7 +45,7 @@ export default function IoTManagementPage() {
|
||||
</div>
|
||||
{d.type !== 'sensor' && (
|
||||
<button onClick={() => toggle(d.id)}
|
||||
className={`w-12 h-7 rounded-full relative transition-colors ${isOn ? 'bg-[#277AF7]' : 'bg-[#404040]'}`}
|
||||
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>
|
||||
|
||||
@@ -34,13 +34,13 @@ export default function LLMCallsPage() {
|
||||
</div>
|
||||
|
||||
{/* Calls table */}
|
||||
<div className="bg-[#000] rounded-[18px] border border-[#404040] overflow-hidden">
|
||||
<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-white/30 text-sm">暂无 LLM 调用记录</div>
|
||||
<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-[#393939] text-white/40 text-xs">
|
||||
<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>
|
||||
@@ -51,16 +51,16 @@ export default function LLMCallsPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{calls.map((c, i) => (
|
||||
<tr key={c.id || i} className="border-b border-[#393939]/50">
|
||||
<td className="px-4 py-2.5 text-white/50 text-xs font-mono">{c.time?.slice(11,19) || '-'}</td>
|
||||
<td className="px-4 py-2.5 text-[#F2F2F2]">{c.model || '-'}</td>
|
||||
<td className="px-4 py-2.5 text-white/50 text-xs">
|
||||
<span className="px-1.5 py-0.5 rounded bg-[#2B3B54]/50 text-[#4788FF]">{c.purpose || '-'}</span>
|
||||
<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-white/50 text-xs text-right">{c.duration_ms}ms</td>
|
||||
<td className="px-4 py-2.5 text-white/50 text-xs text-right">{(c.prompt_tokens||0)+(c.completion_tokens||0)}</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-[#4788FF]' : 'text-[#F12522]'}`}>
|
||||
<span className={`text-xs ${c.success ? 'text-[var(--mx-tertiary-text)]' : 'text-[var(--mx-error)]'}`}>
|
||||
{c.success ? '✓' : '✗'}
|
||||
</span>
|
||||
</td>
|
||||
@@ -76,9 +76,9 @@ export default function LLMCallsPage() {
|
||||
|
||||
function MiniStat({ label, value }: { label:string; value:string|number }) {
|
||||
return (
|
||||
<div className="bg-[#000] border border-[#404040] rounded-[18px] px-4 py-3">
|
||||
<p className="text-xs text-white/40 mb-1">{label}</p>
|
||||
<p className="text-lg font-bold text-[#F2F2F2]">{value}</p>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -52,10 +52,10 @@ export default function MemoryPage() {
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') fetchMemories(search) }}
|
||||
placeholder="搜索记忆..."
|
||||
className="flex-1 bg-[#000] border border-[#404040] focus:border-[#277AF7] rounded-[14px] px-4 py-2.5 text-sm text-[#F2F2F2] placeholder:text-white/30 outline-none"
|
||||
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-[#277AF7] hover:bg-[#3482FF] text-white text-sm font-medium rounded-full">搜索</button>
|
||||
<button onClick={() => { setSearch(''); fetchMemories() }} className="px-4 py-2.5 bg-[#000] border border-[#404040] text-white/50 text-sm rounded-full">全部</button>
|
||||
<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 */}
|
||||
@@ -64,33 +64,33 @@ export default function MemoryPage() {
|
||||
type="text" value={newContent}
|
||||
onChange={e => setNewContent(e.target.value)}
|
||||
placeholder="添加新记忆..."
|
||||
className="flex-1 bg-[#000] border border-[#404040] focus:border-[#277AF7] rounded-[14px] px-4 py-2.5 text-sm text-[#F2F2F2] placeholder:text-white/30 outline-none"
|
||||
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-[#277AF7] hover:bg-[#3482FF] disabled:bg-[#253E64] text-white text-sm font-medium rounded-full"
|
||||
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-white/30 py-8 text-sm">加载中...</p>
|
||||
<p className="text-center text-[var(--mx-text-dim)] py-8 text-sm">加载中...</p>
|
||||
) : memories.length === 0 ? (
|
||||
<p className="text-center text-white/30 py-8 text-sm">暂无记忆</p>
|
||||
<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-[#000] border border-[#404040] rounded-[18px] px-4 py-3 flex items-start justify-between">
|
||||
<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-[#F2F2F2] truncate">{m.content}</p>
|
||||
<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-[#2B3B54]/50 text-[#4788FF]">{m.category}</span>
|
||||
<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-white/30">{kw}</span>
|
||||
<span key={i} className="text-xs text-[var(--mx-text-dim)]">{kw}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => handleDelete(m.id)}
|
||||
className="text-xs text-[#F12522]/70 hover:text-[#F12522] shrink-0 ml-3 mt-0.5"
|
||||
className="text-xs text-[var(--mx-error)]/70 hover:text-[var(--mx-error)] shrink-0 ml-3 mt-0.5"
|
||||
>删除</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -43,11 +43,11 @@ export default function ModelsPage() {
|
||||
<h2 className="text-lg font-semibold">模型配置</h2>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 bg-[#000] rounded-full p-1 w-fit border border-[#404040]">
|
||||
<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-[#277AF7] text-white' : 'text-white/40 hover:text-white/70'
|
||||
tab === t ? 'bg-[var(--mx-primary)] text-white' : 'text-[var(--mx-text-dim)] hover:text-[var(--mx-text-secondary)]'
|
||||
}`}
|
||||
>{t === 'providers' ? '提供商' : t === 'models' ? '模型' : '路由'}</button>
|
||||
))}
|
||||
@@ -57,12 +57,12 @@ export default function ModelsPage() {
|
||||
{tab === 'providers' && (
|
||||
<div className="space-y-2">
|
||||
{providers.map(p => (
|
||||
<div key={p.name} className="bg-[#000] border border-[#404040] rounded-[18px] px-4 py-3 flex items-center justify-between">
|
||||
<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-[#F2F2F2]">{p.name}</p>
|
||||
<p className="text-xs text-white/30">{p.base_url} · timeout {p.timeout_sec}s · retries {p.max_retries}</p>
|
||||
<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-[#F12522]/60 hover:text-[#F12522]">删除</button>
|
||||
<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)} />
|
||||
@@ -72,13 +72,13 @@ export default function ModelsPage() {
|
||||
{tab === 'models' && (
|
||||
<div className="space-y-2">
|
||||
{models.map(m => (
|
||||
<div key={m.id} className="bg-[#000] border border-[#404040] rounded-[18px] px-4 py-3 flex items-center justify-between">
|
||||
<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-[#F2F2F2]">{m.name} <span className={`ml-2 text-xs ${m.enabled ? 'text-[#4788FF]' : 'text-white/30'}`}>{m.enabled ? '启用' : '禁用'}</span></p>
|
||||
<p className="text-xs text-white/30">{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-[#2B3B54]/50 text-[#4788FF]">{t}</span>)}</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-[#F12522]/60 hover:text-[#F12522]">删除</button>
|
||||
<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)} />
|
||||
@@ -88,12 +88,12 @@ export default function ModelsPage() {
|
||||
{tab === 'routing' && (
|
||||
<div className="space-y-2">
|
||||
{routing.map(r => (
|
||||
<div key={r.purpose} className="bg-[#000] border border-[#404040] rounded-[18px] px-4 py-3 flex items-center justify-between">
|
||||
<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-[#F2F2F2]">{r.purpose}</p>
|
||||
<p className="text-xs text-white/30">fallback: {r.fallback_chain?.join(' → ') || 'none'} {r.required ? '(required)' : ''}</p>
|
||||
<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-[#F12522]/60 hover:text-[#F12522]">删除</button>
|
||||
<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)} />
|
||||
@@ -109,9 +109,9 @@ function AddProviderForm({ onSave }: { onSave: (name:string, body:any) => void }
|
||||
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-[#000] border border-[#404040] rounded-[14px] px-3 py-2 text-sm text-[#F2F2F2] placeholder:text-white/30 outline-none" />
|
||||
<input value={url} onChange={e=>setUrl(e.target.value)} placeholder="Base URL" className="flex-1 bg-[#000] border border-[#404040] rounded-[14px] px-3 py-2 text-sm text-[#F2F2F2] placeholder:text-white/30 outline-none" />
|
||||
<button onClick={save} className="px-4 py-2 bg-[#277AF7] text-white text-sm rounded-full font-medium">添加</button>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -122,13 +122,13 @@ function AddModelForm({ providers, onSave }: { providers:Provider[]; onSave: (na
|
||||
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-[#000] border border-[#404040] rounded-[14px] px-3 py-2 text-sm w-32 text-[#F2F2F2] placeholder:text-white/30 outline-none" />
|
||||
<input value={name} onChange={e=>setName(e.target.value)} placeholder="模型名" className="bg-[#000] border border-[#404040] rounded-[14px] px-3 py-2 text-sm w-32 text-[#F2F2F2] placeholder:text-white/30 outline-none" />
|
||||
<select value={prov} onChange={e=>setProv(e.target.value)} className="bg-[#000] border border-[#404040] rounded-[14px] px-3 py-2 text-sm text-[#F2F2F2] outline-none">
|
||||
<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-[#000] border border-[#404040] rounded-[14px] px-3 py-2 text-sm flex-1 text-[#F2F2F2] placeholder:text-white/30 outline-none" />
|
||||
<button onClick={save} className="px-4 py-2 bg-[#277AF7] text-white text-sm rounded-full font-medium">添加</button>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -139,9 +139,9 @@ function AddRoutingForm({ onSave }: { onSave: (name:string, body:any) => void })
|
||||
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-[#000] border border-[#404040] rounded-[14px] px-3 py-2 text-sm text-[#F2F2F2] placeholder:text-white/30 outline-none" />
|
||||
<input value={chain} onChange={e=>setChain(e.target.value)} placeholder="fallback chain (逗号分隔)" className="flex-2 bg-[#000] border border-[#404040] rounded-[14px] px-3 py-2 text-sm text-[#F2F2F2] placeholder:text-white/30 outline-none" />
|
||||
<button onClick={save} className="px-4 py-2 bg-[#277AF7] text-white text-sm rounded-full font-medium">添加</button>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -37,21 +37,21 @@ export default function PerformancePage() {
|
||||
|
||||
{/* Memory chart */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-white/60 mb-3">内存使用趋势 (最近 {history.length} 次采样)</h3>
|
||||
<div className="bg-[#000] border border-[#404040] rounded-[18px] p-4">
|
||||
<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-[#277AF7]/60 hover:bg-[#277AF7] rounded-t-sm transition-colors"
|
||||
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-white/30">
|
||||
<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>
|
||||
@@ -60,8 +60,8 @@ export default function PerformancePage() {
|
||||
|
||||
{/* GC chart */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-white/60 mb-3">GC 次数趋势</h3>
|
||||
<div className="bg-[#000] border border-[#404040] rounded-[18px] p-4">
|
||||
<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))
|
||||
@@ -83,10 +83,10 @@ export default function PerformancePage() {
|
||||
|
||||
function Stat({ label, value, sub }: { label:string; value:string|number; sub:string }) {
|
||||
return (
|
||||
<div className="bg-[#000] border border-[#404040] rounded-[18px] px-4 py-3">
|
||||
<p className="text-xs text-white/40 mb-1">{label}</p>
|
||||
<p className="text-lg font-bold text-[#F2F2F2]">{value}</p>
|
||||
<p className="text-xs text-white/20 mt-0.5">{sub}</p>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -19,17 +19,17 @@ export default function PluginsPage() {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<h2 className="text-lg font-semibold">插件管理</h2>
|
||||
<p className="text-xs text-white/40">已加载 {builtInPlugins.length} 个插件。工具插件为 LLM 提供可调用能力。</p>
|
||||
<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-[#000] border border-[#404040] rounded-[18px] px-4 py-3 flex items-center justify-between">
|
||||
<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-[#F2F2F2]">{p.name}</p>
|
||||
<p className="text-xs text-white/30">{p.desc}</p>
|
||||
<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-[#2B3B54] text-[#4788FF]' : 'bg-[#2B3B54]/50 text-white/50'
|
||||
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>
|
||||
))}
|
||||
|
||||
@@ -20,21 +20,21 @@ export default function ServicesPage() {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<h2 className="text-lg font-semibold">服务管理</h2>
|
||||
<p className="text-xs text-white/40">单二进制模式 — 所有服务编译在同一进程。服务不可单独启停。</p>
|
||||
<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-[#000] border border-[#404040] rounded-[18px] px-4 py-3 flex items-center justify-between">
|
||||
<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-[#F2F2F2]">{svc.name}</p>
|
||||
<p className="text-xs text-white/30">{svc.desc} · 端口 {svc.port}</p>
|
||||
<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-[#4788FF]' : 'text-white/30'}`}>
|
||||
<span className={`text-xs ${alive ? 'text-[var(--mx-tertiary-text)]' : 'text-[var(--mx-text-dim)]'}`}>
|
||||
{alive ? '运行中' : '未启动'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -41,10 +41,10 @@ export default function SystemPage() {
|
||||
|
||||
{/* Plugins list */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-white/60 mb-3">已加载插件 ({plugins.length})</h3>
|
||||
<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-[#000] border border-[#404040] rounded-full text-xs text-[#F2F2F2]">
|
||||
<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>
|
||||
))}
|
||||
@@ -53,13 +53,13 @@ export default function SystemPage() {
|
||||
|
||||
{/* Sessions */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-white/60 mb-3">活跃会话 ({sessions.length})</h3>
|
||||
<h3 className="text-sm font-medium text-[var(--mx-text-secondary)] mb-3">活跃会话 ({sessions.length})</h3>
|
||||
{sessions.length === 0 ? (
|
||||
<p className="text-xs text-white/30">暂无活跃会话</p>
|
||||
<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-[#000] border border-[#404040] rounded-[14px] px-4 py-2 text-xs text-white/50">
|
||||
<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>
|
||||
))}
|
||||
@@ -72,9 +72,9 @@ export default function SystemPage() {
|
||||
|
||||
function StatRow({ label, value }: { label:string; value:string|number }) {
|
||||
return (
|
||||
<div className="bg-[#000] border border-[#404040] rounded-[18px] px-4 py-3">
|
||||
<p className="text-xs text-white/40 mb-1">{label}</p>
|
||||
<p className="text-lg font-bold text-[#F2F2F2]">{value}</p>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -28,16 +28,16 @@ export default function TracePage() {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<h2 className="text-lg font-semibold">全链路追踪</h2>
|
||||
<p className="text-xs text-white/40">最近 {events.length} 条追踪事件</p>
|
||||
<p className="text-xs text-[var(--mx-text-dim)]">最近 {events.length} 条追踪事件</p>
|
||||
|
||||
{events.length === 0 ? (
|
||||
<div className="p-8 text-center text-white/30 text-sm">暂无追踪数据 — 发送消息后刷新</div>
|
||||
<div className="p-8 text-center text-[var(--mx-text-dim)] text-sm">暂无追踪数据 — 发送消息后刷新</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{events.map((ev, i) => (
|
||||
<div key={ev.id || i}
|
||||
className={`bg-[#000] border rounded-[14px] px-4 py-3 flex items-center gap-3 ${
|
||||
ev.status === 'error' ? 'border-[#F12522]/30' : 'border-[#404040]'
|
||||
className={`bg-[var(--mx-surface)] border rounded-[14px] px-4 py-3 flex items-center gap-3 ${
|
||||
ev.status === 'error' ? 'border-[var(--mx-error)]/30' : 'border-[var(--mx-outline)]'
|
||||
}`}
|
||||
>
|
||||
{/* Hop icon */}
|
||||
@@ -46,19 +46,19 @@ export default function TracePage() {
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-[#F2F2F2]">{ev.label || ev.hop}</span>
|
||||
<span className="text-sm text-[var(--mx-text)]">{ev.label || ev.hop}</span>
|
||||
{ev.duration_ms > 0 && (
|
||||
<span className="text-xs text-white/30">{ev.duration_ms}ms</span>
|
||||
<span className="text-xs text-[var(--mx-text-dim)]">{ev.duration_ms}ms</span>
|
||||
)}
|
||||
</div>
|
||||
{ev.detail && (
|
||||
<p className="text-xs text-white/30 truncate mt-0.5">{ev.detail}</p>
|
||||
<p className="text-xs text-[var(--mx-text-dim)] truncate mt-0.5">{ev.detail}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className={`text-xs font-mono text-white/30`}>
|
||||
<span className={`text-xs font-mono text-[var(--mx-text-dim)]`}>
|
||||
{ev.timestamp ? ev.timestamp.slice(11, 19) : ''}
|
||||
</span>
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user