chore: 清空 v2 实现,重建新架构骨架(人格化通用 Agent 运行时)
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user