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,45 +1,124 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { sendMessage } from '../lib/api'
|
||||
import { getConversation, saveConversation } from '../lib/conversationApi'
|
||||
|
||||
export function useChat() {
|
||||
export function useChat(conversationId, conversationMeta, onConversationUpdate) {
|
||||
const [messages, setMessages] = useState([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isLoadingConv, setIsLoadingConv] = useState(false)
|
||||
const convRef = useRef(null)
|
||||
const idRef = useRef(conversationId)
|
||||
|
||||
const send = useCallback(async (text) => {
|
||||
// Keep idRef in sync
|
||||
useEffect(() => { idRef.current = conversationId }, [conversationId])
|
||||
|
||||
// Load conversation from server when ID changes
|
||||
useEffect(() => {
|
||||
if (!conversationId) {
|
||||
setMessages([])
|
||||
convRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
setIsLoadingConv(true)
|
||||
|
||||
getConversation(conversationId).then(conv => {
|
||||
if (cancelled) return
|
||||
if (conv) {
|
||||
convRef.current = conv
|
||||
setMessages(conv.messages || [])
|
||||
} else {
|
||||
convRef.current = null
|
||||
setMessages([])
|
||||
}
|
||||
setIsLoadingConv(false)
|
||||
}).catch(() => {
|
||||
if (!cancelled) {
|
||||
convRef.current = null
|
||||
setMessages([])
|
||||
setIsLoadingConv(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [conversationId])
|
||||
|
||||
// Persist conversation to server
|
||||
const persist = useCallback(async (updatedMessages, title, overrideId) => {
|
||||
const id = overrideId || idRef.current
|
||||
if (!id) return
|
||||
const now = new Date().toISOString()
|
||||
const conv = {
|
||||
id,
|
||||
title: title || convRef.current?.title || '',
|
||||
characterId: conversationMeta?.characterId || convRef.current?.characterId || '',
|
||||
characterName: conversationMeta?.characterName || convRef.current?.characterName || '',
|
||||
createdAt: convRef.current?.createdAt || now,
|
||||
updatedAt: now,
|
||||
messages: updatedMessages,
|
||||
}
|
||||
convRef.current = conv
|
||||
await saveConversation(conv).catch(() => {})
|
||||
if (onConversationUpdate) {
|
||||
onConversationUpdate(id, {
|
||||
title: conv.title,
|
||||
updatedAt: conv.updatedAt,
|
||||
messageCount: conv.messages.length,
|
||||
})
|
||||
}
|
||||
}, [conversationMeta, onConversationUpdate])
|
||||
|
||||
// send accepts an optional overrideId for when the conversation was just created
|
||||
const send = useCallback(async (text, overrideId) => {
|
||||
if (!text.trim() || isLoading) return null
|
||||
|
||||
const userMsg = { id: Date.now(), role: 'user', content: text.trim(), timestamp: new Date() }
|
||||
setMessages((prev) => [...prev, userMsg])
|
||||
const userMsg = { id: Date.now(), role: 'user', content: text.trim(), timestamp: new Date().toISOString() }
|
||||
const isFirstMessage = messages.length === 0
|
||||
const newMessages = [...messages, userMsg]
|
||||
setMessages(newMessages)
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const response = await sendMessage(text.trim())
|
||||
const response = await sendMessage(text.trim(), conversationMeta?.characterId || null)
|
||||
const assistantMsg = {
|
||||
id: Date.now() + 1,
|
||||
role: 'assistant',
|
||||
content: response,
|
||||
timestamp: new Date(),
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
setMessages((prev) => [...prev, assistantMsg])
|
||||
const allMessages = [...newMessages, assistantMsg]
|
||||
setMessages(allMessages)
|
||||
|
||||
const title = isFirstMessage
|
||||
? text.trim().slice(0, 80) + (text.trim().length > 80 ? '...' : '')
|
||||
: undefined
|
||||
await persist(allMessages, title, overrideId)
|
||||
|
||||
return response
|
||||
} catch (err) {
|
||||
const errorMsg = {
|
||||
id: Date.now() + 1,
|
||||
role: 'assistant',
|
||||
content: `Error: ${err.message}`,
|
||||
timestamp: new Date(),
|
||||
timestamp: new Date().toISOString(),
|
||||
isError: true,
|
||||
}
|
||||
setMessages((prev) => [...prev, errorMsg])
|
||||
const allMessages = [...newMessages, errorMsg]
|
||||
setMessages(allMessages)
|
||||
await persist(allMessages, undefined, overrideId)
|
||||
return null
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [isLoading])
|
||||
}, [isLoading, messages, persist])
|
||||
|
||||
const clearHistory = useCallback(() => {
|
||||
const clearHistory = useCallback(async () => {
|
||||
setMessages([])
|
||||
}, [])
|
||||
if (idRef.current) {
|
||||
await persist([], undefined)
|
||||
}
|
||||
}, [persist])
|
||||
|
||||
return { messages, isLoading, send, clearHistory }
|
||||
return { messages, isLoading, isLoadingConv, send, clearHistory }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user