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>
175 lines
5.8 KiB
JavaScript
175 lines
5.8 KiB
JavaScript
import { useState, useCallback, useEffect, useRef } from 'react'
|
|
import { sendMessage } from '../lib/api'
|
|
import { getConversation, saveConversation } from '../lib/conversationApi'
|
|
|
|
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)
|
|
|
|
// 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
|
|
// and an optional promptStyle to control response style
|
|
const send = useCallback(async (text, overrideId, promptStyle) => {
|
|
if (!text.trim() || isLoading) return null
|
|
|
|
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 activeConvId = overrideId || idRef.current
|
|
const { response, model, prompt_style } = await sendMessage(text.trim(), conversationMeta?.characterId || null, promptStyle, activeConvId)
|
|
const assistantMsg = {
|
|
id: Date.now() + 1,
|
|
role: 'assistant',
|
|
content: response,
|
|
timestamp: new Date().toISOString(),
|
|
...(model && { model }),
|
|
...(prompt_style && { prompt_style }),
|
|
}
|
|
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().toISOString(),
|
|
isError: true,
|
|
}
|
|
const allMessages = [...newMessages, errorMsg]
|
|
setMessages(allMessages)
|
|
await persist(allMessages, undefined, overrideId)
|
|
return null
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}, [isLoading, messages, persist])
|
|
|
|
// Retry: remove the error message, re-send the preceding user message
|
|
const retry = useCallback(async (errorMsgId, promptStyle) => {
|
|
const idx = messages.findIndex(m => m.id === errorMsgId)
|
|
if (idx < 1) return null
|
|
// Find the user message right before the error
|
|
const userMsg = messages[idx - 1]
|
|
if (!userMsg || userMsg.role !== 'user') return null
|
|
// Remove the error message
|
|
const cleaned = messages.filter(m => m.id !== errorMsgId)
|
|
setMessages(cleaned)
|
|
await persist(cleaned)
|
|
// Re-send (but we need to temporarily set messages back without the error so send picks up correctly)
|
|
// Instead, inline the send logic with the cleaned message list
|
|
setIsLoading(true)
|
|
try {
|
|
const activeConvId = idRef.current
|
|
const { response, model, prompt_style } = await sendMessage(userMsg.content, conversationMeta?.characterId || null, promptStyle, activeConvId)
|
|
const assistantMsg = {
|
|
id: Date.now() + 1,
|
|
role: 'assistant',
|
|
content: response,
|
|
timestamp: new Date().toISOString(),
|
|
...(model && { model }),
|
|
...(prompt_style && { prompt_style }),
|
|
}
|
|
const allMessages = [...cleaned, assistantMsg]
|
|
setMessages(allMessages)
|
|
await persist(allMessages)
|
|
return response
|
|
} catch (err) {
|
|
const newError = {
|
|
id: Date.now() + 1,
|
|
role: 'assistant',
|
|
content: `Error: ${err.message}`,
|
|
timestamp: new Date().toISOString(),
|
|
isError: true,
|
|
}
|
|
const allMessages = [...cleaned, newError]
|
|
setMessages(allMessages)
|
|
await persist(allMessages)
|
|
return null
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}, [messages, persist, conversationMeta])
|
|
|
|
const clearHistory = useCallback(async () => {
|
|
setMessages([])
|
|
if (idRef.current) {
|
|
await persist([], undefined)
|
|
}
|
|
}, [persist])
|
|
|
|
return { messages, isLoading, isLoadingConv, send, retry, clearHistory }
|
|
}
|