feat: character system v2 — schema upgrade, memory system, per-character TTS routing
Character schema v2: background, dialogue_style, appearance, skills, gaze_presets with automatic v1→v2 migration. LLM-assisted character creation via Character MCP server. Two-tier memory system (personal per-character + general shared) with budget-based injection into LLM system prompt. Per-character TTS voice routing via state file — Wyoming TTS server reads active config to route between Kokoro (local) and ElevenLabs (cloud PCM 24kHz). Dashboard: memories page, conversation history, character profile on cards, auto-TTS engine selection from character config. Also includes VTube Studio expression bridge and ComfyUI API guide. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,100 @@
|
||||
export default function MessageBubble({ message, onReplay }) {
|
||||
import { useState } from 'react'
|
||||
|
||||
function Avatar({ character }) {
|
||||
const name = character?.name || 'AI'
|
||||
const image = character?.image || null
|
||||
|
||||
if (image) {
|
||||
return <img src={image} alt={name} className="w-8 h-8 rounded-full object-cover shrink-0 mt-0.5 ring-1 ring-gray-700" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-8 h-8 rounded-full bg-indigo-600/20 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<span className="text-indigo-400 text-sm">{name[0]}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ImageOverlay({ src, onClose }) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center cursor-zoom-out"
|
||||
onClick={onClose}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt="Full size"
|
||||
className="max-w-[90vw] max-h-[90vh] object-contain rounded-lg shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-white/70 hover:text-white transition-colors p-2"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const IMAGE_URL_RE = /(https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp))/gi
|
||||
|
||||
function RichContent({ text }) {
|
||||
const [overlayImage, setOverlayImage] = useState(null)
|
||||
const parts = []
|
||||
let lastIndex = 0
|
||||
let match
|
||||
|
||||
IMAGE_URL_RE.lastIndex = 0
|
||||
while ((match = IMAGE_URL_RE.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({ type: 'text', value: text.slice(lastIndex, match.index) })
|
||||
}
|
||||
parts.push({ type: 'image', value: match[1] })
|
||||
lastIndex = IMAGE_URL_RE.lastIndex
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
parts.push({ type: 'text', value: text.slice(lastIndex) })
|
||||
}
|
||||
|
||||
if (parts.length === 1 && parts[0].type === 'text') {
|
||||
return <>{text}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) =>
|
||||
part.type === 'image' ? (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setOverlayImage(part.value)}
|
||||
className="block my-2 cursor-zoom-in"
|
||||
>
|
||||
<img
|
||||
src={part.value}
|
||||
alt="Generated image"
|
||||
className="rounded-xl max-w-full max-h-80 object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span key={i}>{part.value}</span>
|
||||
)
|
||||
)}
|
||||
{overlayImage && <ImageOverlay src={overlayImage} onClose={() => setOverlayImage(null)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MessageBubble({ message, onReplay, character }) {
|
||||
const isUser = message.role === 'user'
|
||||
|
||||
return (
|
||||
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} px-4 py-1.5`}>
|
||||
<div className={`flex items-start gap-3 max-w-[80%] ${isUser ? 'flex-row-reverse' : ''}`}>
|
||||
{!isUser && (
|
||||
<div className="w-8 h-8 rounded-full bg-indigo-600/20 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<span className="text-indigo-400 text-sm">AI</span>
|
||||
</div>
|
||||
)}
|
||||
{!isUser && <Avatar character={character} />}
|
||||
<div>
|
||||
<div
|
||||
className={`rounded-2xl px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap ${
|
||||
@@ -19,7 +105,7 @@ export default function MessageBubble({ message, onReplay }) {
|
||||
: 'bg-gray-800 text-gray-100'
|
||||
}`}
|
||||
>
|
||||
{message.content}
|
||||
{isUser ? message.content : <RichContent text={message.content} />}
|
||||
</div>
|
||||
{!isUser && !message.isError && onReplay && (
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user