chore: 清空 v2 实现,重建新架构骨架(人格化通用 Agent 运行时)
This commit is contained in:
1 parent
9c8d5a9e8d
commit
4be3826af7
151 files changed
+3
-19760
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user