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

@@ -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>
)
}