SQLite + sqlite-vec replaces JSON memory files with semantic search, follow-up injection, privacy levels, and lifecycle management. Six prompt styles (quick/standard/creative/roleplayer/game-master/storyteller) with per-style Claude model tiering (Haiku/Sonnet/Opus), temperature control, and section stripping. Characters can set default style and per-style overrides. Dream character import and GAZE character linking in the dashboard editor with auto-populated fields, cover image resolution, and preset assignment. Bridge: session isolation (conversation_id / 12h satellite buckets), model routing refactor, PUT/DELETE support, memory REST endpoints. Dashboard: mobile-responsive sidebar, retry button, style picker in chat, follow-up banner, memory lifecycle/privacy UI, cloud model options in editor. Wyoming TTS: upgraded to v1.8.0 for HA 1.7.2 compatibility. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
147 lines
5.1 KiB
JavaScript
147 lines
5.1 KiB
JavaScript
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, onRetry, character }) {
|
|
const isUser = message.role === 'user'
|
|
|
|
return (
|
|
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} px-3 sm:px-4 py-1.5`}>
|
|
<div className={`flex items-start gap-2 sm:gap-3 max-w-[92%] sm:max-w-[80%] ${isUser ? 'flex-row-reverse' : ''}`}>
|
|
{!isUser && <Avatar character={character} />}
|
|
<div>
|
|
<div
|
|
className={`rounded-2xl px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap ${
|
|
isUser
|
|
? 'bg-indigo-600 text-white'
|
|
: message.isError
|
|
? 'bg-red-900/40 text-red-200 border border-red-800/50'
|
|
: 'bg-gray-800 text-gray-100'
|
|
}`}
|
|
>
|
|
{isUser ? message.content : <RichContent text={message.content} />}
|
|
</div>
|
|
{!isUser && (
|
|
<div className="flex items-center gap-2 mt-1 ml-1">
|
|
{message.model && (
|
|
<span className="text-[10px] text-gray-500 font-mono">
|
|
{message.model}
|
|
</span>
|
|
)}
|
|
{message.isError && onRetry && (
|
|
<button
|
|
onClick={() => onRetry(message.id)}
|
|
className="text-red-400 hover:text-red-300 transition-colors flex items-center gap-1 text-xs"
|
|
title="Retry"
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182" />
|
|
</svg>
|
|
Retry
|
|
</button>
|
|
)}
|
|
{!message.isError && onReplay && (
|
|
<button
|
|
onClick={() => onReplay(message.content)}
|
|
className="text-gray-500 hover:text-indigo-400 transition-colors"
|
|
title="Replay audio"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.536 8.464a5 5 0 010 7.072M17.95 6.05a8 8 0 010 11.9M6.5 9H4a1 1 0 00-1 1v4a1 1 0 001 1h2.5l4 4V5l-4 4z" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|