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:
@@ -2,6 +2,267 @@ import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
const CHARACTERS_DIR = '/Users/aodhan/homeai-data/characters'
|
||||
const SATELLITE_MAP_PATH = '/Users/aodhan/homeai-data/satellite-map.json'
|
||||
const CONVERSATIONS_DIR = '/Users/aodhan/homeai-data/conversations'
|
||||
const MEMORIES_DIR = '/Users/aodhan/homeai-data/memories'
|
||||
const GAZE_HOST = 'http://10.0.0.101:5782'
|
||||
const GAZE_API_KEY = process.env.GAZE_API_KEY || ''
|
||||
|
||||
function characterStoragePlugin() {
|
||||
return {
|
||||
name: 'character-storage',
|
||||
configureServer(server) {
|
||||
const ensureDir = async () => {
|
||||
const { mkdir } = await import('fs/promises')
|
||||
await mkdir(CHARACTERS_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
// GET /api/characters — list all profiles
|
||||
server.middlewares.use('/api/characters', async (req, res, next) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET,POST,DELETE', 'Access-Control-Allow-Headers': 'Content-Type' })
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
const { readdir, readFile, writeFile, unlink } = await import('fs/promises')
|
||||
await ensureDir()
|
||||
|
||||
// req.url has the mount prefix stripped by connect, so "/" means /api/characters
|
||||
const url = new URL(req.url, 'http://localhost')
|
||||
const subPath = url.pathname.replace(/^\/+/, '')
|
||||
|
||||
// GET /api/characters/:id — single profile
|
||||
if (req.method === 'GET' && subPath) {
|
||||
try {
|
||||
const safeId = subPath.replace(/[^a-zA-Z0-9_\-\.]/g, '_')
|
||||
const raw = await readFile(`${CHARACTERS_DIR}/${safeId}.json`, 'utf-8')
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
||||
res.end(raw)
|
||||
} catch {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
||||
res.end(JSON.stringify({ error: 'Not found' }))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && !subPath) {
|
||||
try {
|
||||
const files = (await readdir(CHARACTERS_DIR)).filter(f => f.endsWith('.json'))
|
||||
const profiles = []
|
||||
for (const file of files) {
|
||||
try {
|
||||
const raw = await readFile(`${CHARACTERS_DIR}/${file}`, 'utf-8')
|
||||
profiles.push(JSON.parse(raw))
|
||||
} catch { /* skip corrupt files */ }
|
||||
}
|
||||
// Sort by addedAt descending
|
||||
profiles.sort((a, b) => (b.addedAt || '').localeCompare(a.addedAt || ''))
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
||||
res.end(JSON.stringify(profiles))
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: err.message }))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && !subPath) {
|
||||
try {
|
||||
const chunks = []
|
||||
for await (const chunk of req) chunks.push(chunk)
|
||||
const profile = JSON.parse(Buffer.concat(chunks).toString())
|
||||
if (!profile.id) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: 'Missing profile id' }))
|
||||
return
|
||||
}
|
||||
// Sanitize filename — only allow alphanumeric, underscore, dash, dot
|
||||
const safeId = profile.id.replace(/[^a-zA-Z0-9_\-\.]/g, '_')
|
||||
await writeFile(`${CHARACTERS_DIR}/${safeId}.json`, JSON.stringify(profile, null, 2))
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
||||
res.end(JSON.stringify({ ok: true }))
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: err.message }))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE' && subPath) {
|
||||
try {
|
||||
const safeId = subPath.replace(/[^a-zA-Z0-9_\-\.]/g, '_')
|
||||
await unlink(`${CHARACTERS_DIR}/${safeId}.json`).catch(() => {})
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
||||
res.end(JSON.stringify({ ok: true }))
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: err.message }))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function satelliteMapPlugin() {
|
||||
return {
|
||||
name: 'satellite-map',
|
||||
configureServer(server) {
|
||||
server.middlewares.use('/api/satellite-map', async (req, res, next) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET,POST', 'Access-Control-Allow-Headers': 'Content-Type' })
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
const { readFile, writeFile } = await import('fs/promises')
|
||||
|
||||
if (req.method === 'GET') {
|
||||
try {
|
||||
const raw = await readFile(SATELLITE_MAP_PATH, 'utf-8')
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
||||
res.end(raw)
|
||||
} catch {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
||||
res.end(JSON.stringify({ default: 'aria_default', satellites: {} }))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
try {
|
||||
const chunks = []
|
||||
for await (const chunk of req) chunks.push(chunk)
|
||||
const data = JSON.parse(Buffer.concat(chunks).toString())
|
||||
await writeFile(SATELLITE_MAP_PATH, JSON.stringify(data, null, 2))
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
||||
res.end(JSON.stringify({ ok: true }))
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: err.message }))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function conversationStoragePlugin() {
|
||||
return {
|
||||
name: 'conversation-storage',
|
||||
configureServer(server) {
|
||||
const ensureDir = async () => {
|
||||
const { mkdir } = await import('fs/promises')
|
||||
await mkdir(CONVERSATIONS_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
server.middlewares.use('/api/conversations', async (req, res, next) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET,POST,DELETE', 'Access-Control-Allow-Headers': 'Content-Type' })
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
const { readdir, readFile, writeFile, unlink } = await import('fs/promises')
|
||||
await ensureDir()
|
||||
|
||||
const url = new URL(req.url, 'http://localhost')
|
||||
const subPath = url.pathname.replace(/^\/+/, '')
|
||||
|
||||
// GET /api/conversations/:id — single conversation with messages
|
||||
if (req.method === 'GET' && subPath) {
|
||||
try {
|
||||
const safeId = subPath.replace(/[^a-zA-Z0-9_\-\.]/g, '_')
|
||||
const raw = await readFile(`${CONVERSATIONS_DIR}/${safeId}.json`, 'utf-8')
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
||||
res.end(raw)
|
||||
} catch {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
||||
res.end(JSON.stringify({ error: 'Not found' }))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// GET /api/conversations — list metadata (no messages)
|
||||
if (req.method === 'GET' && !subPath) {
|
||||
try {
|
||||
const files = (await readdir(CONVERSATIONS_DIR)).filter(f => f.endsWith('.json'))
|
||||
const list = []
|
||||
for (const file of files) {
|
||||
try {
|
||||
const raw = await readFile(`${CONVERSATIONS_DIR}/${file}`, 'utf-8')
|
||||
const conv = JSON.parse(raw)
|
||||
list.push({
|
||||
id: conv.id,
|
||||
title: conv.title || '',
|
||||
characterId: conv.characterId || '',
|
||||
characterName: conv.characterName || '',
|
||||
createdAt: conv.createdAt || '',
|
||||
updatedAt: conv.updatedAt || '',
|
||||
messageCount: (conv.messages || []).length,
|
||||
})
|
||||
} catch { /* skip corrupt files */ }
|
||||
}
|
||||
list.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''))
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
||||
res.end(JSON.stringify(list))
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: err.message }))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// POST /api/conversations — create or update
|
||||
if (req.method === 'POST' && !subPath) {
|
||||
try {
|
||||
const chunks = []
|
||||
for await (const chunk of req) chunks.push(chunk)
|
||||
const conv = JSON.parse(Buffer.concat(chunks).toString())
|
||||
if (!conv.id) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: 'Missing conversation id' }))
|
||||
return
|
||||
}
|
||||
const safeId = conv.id.replace(/[^a-zA-Z0-9_\-\.]/g, '_')
|
||||
await writeFile(`${CONVERSATIONS_DIR}/${safeId}.json`, JSON.stringify(conv, null, 2))
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
||||
res.end(JSON.stringify({ ok: true }))
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: err.message }))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// DELETE /api/conversations/:id
|
||||
if (req.method === 'DELETE' && subPath) {
|
||||
try {
|
||||
const safeId = subPath.replace(/[^a-zA-Z0-9_\-\.]/g, '_')
|
||||
await unlink(`${CONVERSATIONS_DIR}/${safeId}.json`).catch(() => {})
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
||||
res.end(JSON.stringify({ ok: true }))
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: err.message }))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function healthCheckPlugin() {
|
||||
return {
|
||||
name: 'health-check-proxy',
|
||||
@@ -121,6 +382,273 @@ function healthCheckPlugin() {
|
||||
};
|
||||
}
|
||||
|
||||
function gazeProxyPlugin() {
|
||||
return {
|
||||
name: 'gaze-proxy',
|
||||
configureServer(server) {
|
||||
server.middlewares.use('/api/gaze/presets', async (req, res) => {
|
||||
if (!GAZE_API_KEY) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
||||
res.end(JSON.stringify({ presets: [] }))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const http = await import('http')
|
||||
const url = new URL(`${GAZE_HOST}/api/v1/presets`)
|
||||
const proxyRes = await new Promise((resolve, reject) => {
|
||||
const r = http.default.get(url, { headers: { 'X-API-Key': GAZE_API_KEY }, timeout: 5000 }, resolve)
|
||||
r.on('error', reject)
|
||||
r.on('timeout', () => { r.destroy(); reject(new Error('timeout')) })
|
||||
})
|
||||
const chunks = []
|
||||
for await (const chunk of proxyRes) chunks.push(chunk)
|
||||
res.writeHead(proxyRes.statusCode, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
||||
res.end(Buffer.concat(chunks))
|
||||
} catch {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
||||
res.end(JSON.stringify({ presets: [] }))
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function memoryStoragePlugin() {
|
||||
return {
|
||||
name: 'memory-storage',
|
||||
configureServer(server) {
|
||||
const ensureDirs = async () => {
|
||||
const { mkdir } = await import('fs/promises')
|
||||
await mkdir(`${MEMORIES_DIR}/personal`, { recursive: true })
|
||||
}
|
||||
|
||||
const readJsonFile = async (path, fallback) => {
|
||||
const { readFile } = await import('fs/promises')
|
||||
try {
|
||||
return JSON.parse(await readFile(path, 'utf-8'))
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
const writeJsonFile = async (path, data) => {
|
||||
const { writeFile } = await import('fs/promises')
|
||||
await writeFile(path, JSON.stringify(data, null, 2))
|
||||
}
|
||||
|
||||
// Personal memories: /api/memories/personal/:characterId[/:memoryId]
|
||||
server.middlewares.use('/api/memories/personal', async (req, res, next) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET,POST,DELETE', 'Access-Control-Allow-Headers': 'Content-Type' })
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
await ensureDirs()
|
||||
const url = new URL(req.url, 'http://localhost')
|
||||
const parts = url.pathname.replace(/^\/+/, '').split('/')
|
||||
const characterId = parts[0] ? parts[0].replace(/[^a-zA-Z0-9_\-\.]/g, '_') : null
|
||||
const memoryId = parts[1] || null
|
||||
|
||||
if (!characterId) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: 'Missing character ID' }))
|
||||
return
|
||||
}
|
||||
|
||||
const filePath = `${MEMORIES_DIR}/personal/${characterId}.json`
|
||||
|
||||
if (req.method === 'GET') {
|
||||
const data = await readJsonFile(filePath, { characterId, memories: [] })
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
||||
res.end(JSON.stringify(data))
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
try {
|
||||
const chunks = []
|
||||
for await (const chunk of req) chunks.push(chunk)
|
||||
const memory = JSON.parse(Buffer.concat(chunks).toString())
|
||||
const data = await readJsonFile(filePath, { characterId, memories: [] })
|
||||
if (memory.id) {
|
||||
const idx = data.memories.findIndex(m => m.id === memory.id)
|
||||
if (idx >= 0) {
|
||||
data.memories[idx] = { ...data.memories[idx], ...memory }
|
||||
} else {
|
||||
data.memories.push(memory)
|
||||
}
|
||||
} else {
|
||||
memory.id = 'm_' + Date.now()
|
||||
memory.createdAt = memory.createdAt || new Date().toISOString()
|
||||
data.memories.push(memory)
|
||||
}
|
||||
await writeJsonFile(filePath, data)
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
||||
res.end(JSON.stringify({ ok: true, memory }))
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: err.message }))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE' && memoryId) {
|
||||
try {
|
||||
const data = await readJsonFile(filePath, { characterId, memories: [] })
|
||||
data.memories = data.memories.filter(m => m.id !== memoryId)
|
||||
await writeJsonFile(filePath, data)
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
||||
res.end(JSON.stringify({ ok: true }))
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: err.message }))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
// General memories: /api/memories/general[/:memoryId]
|
||||
server.middlewares.use('/api/memories/general', async (req, res, next) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET,POST,DELETE', 'Access-Control-Allow-Headers': 'Content-Type' })
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
await ensureDirs()
|
||||
const url = new URL(req.url, 'http://localhost')
|
||||
const memoryId = url.pathname.replace(/^\/+/, '') || null
|
||||
const filePath = `${MEMORIES_DIR}/general.json`
|
||||
|
||||
if (req.method === 'GET') {
|
||||
const data = await readJsonFile(filePath, { memories: [] })
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
||||
res.end(JSON.stringify(data))
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
try {
|
||||
const chunks = []
|
||||
for await (const chunk of req) chunks.push(chunk)
|
||||
const memory = JSON.parse(Buffer.concat(chunks).toString())
|
||||
const data = await readJsonFile(filePath, { memories: [] })
|
||||
if (memory.id) {
|
||||
const idx = data.memories.findIndex(m => m.id === memory.id)
|
||||
if (idx >= 0) {
|
||||
data.memories[idx] = { ...data.memories[idx], ...memory }
|
||||
} else {
|
||||
data.memories.push(memory)
|
||||
}
|
||||
} else {
|
||||
memory.id = 'm_' + Date.now()
|
||||
memory.createdAt = memory.createdAt || new Date().toISOString()
|
||||
data.memories.push(memory)
|
||||
}
|
||||
await writeJsonFile(filePath, data)
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
||||
res.end(JSON.stringify({ ok: true, memory }))
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: err.message }))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE' && memoryId) {
|
||||
try {
|
||||
const data = await readJsonFile(filePath, { memories: [] })
|
||||
data.memories = data.memories.filter(m => m.id !== memoryId)
|
||||
await writeJsonFile(filePath, data)
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
||||
res.end(JSON.stringify({ ok: true }))
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: err.message }))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function characterLookupPlugin() {
|
||||
return {
|
||||
name: 'character-lookup',
|
||||
configureServer(server) {
|
||||
server.middlewares.use('/api/character-lookup', async (req, res) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST', 'Access-Control-Allow-Headers': 'Content-Type' })
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
if (req.method !== 'POST') {
|
||||
res.writeHead(405, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: 'POST only' }))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const chunks = []
|
||||
for await (const chunk of req) chunks.push(chunk)
|
||||
const { name, franchise } = JSON.parse(Buffer.concat(chunks).toString())
|
||||
if (!name || !franchise) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: 'Missing name or franchise' }))
|
||||
return
|
||||
}
|
||||
|
||||
const { execFile } = await import('child_process')
|
||||
const { promisify } = await import('util')
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
// Call the MCP fetcher inside the running Docker container
|
||||
const safeName = name.replace(/'/g, "\\'")
|
||||
const safeFranchise = franchise.replace(/'/g, "\\'")
|
||||
const pyScript = `
|
||||
import asyncio, json
|
||||
from character_details.fetcher import fetch_character
|
||||
c = asyncio.run(fetch_character('${safeName}', '${safeFranchise}'))
|
||||
print(json.dumps(c.model_dump(), default=str))
|
||||
`.trim()
|
||||
|
||||
const { stdout } = await execFileAsync(
|
||||
'docker',
|
||||
['exec', 'character-browser-character-mcp-1', 'python', '-c', pyScript],
|
||||
{ timeout: 30000 }
|
||||
)
|
||||
|
||||
const data = JSON.parse(stdout.trim())
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
||||
res.end(JSON.stringify({
|
||||
name: data.name || name,
|
||||
franchise: data.franchise || franchise,
|
||||
description: data.description || '',
|
||||
background: data.background || '',
|
||||
appearance: data.appearance || '',
|
||||
personality: data.personality || '',
|
||||
abilities: data.abilities || [],
|
||||
notable_quotes: data.notable_quotes || [],
|
||||
relationships: data.relationships || [],
|
||||
sources: data.sources || [],
|
||||
}))
|
||||
} catch (err) {
|
||||
console.error('[character-lookup] failed:', err?.message || err)
|
||||
const status = err?.message?.includes('timeout') ? 504 : 500
|
||||
res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
||||
res.end(JSON.stringify({ error: err?.message || 'Lookup failed' }))
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function bridgeProxyPlugin() {
|
||||
return {
|
||||
name: 'bridge-proxy',
|
||||
@@ -172,10 +700,11 @@ function bridgeProxyPlugin() {
|
||||
proxyReq.write(body)
|
||||
proxyReq.end()
|
||||
})
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error(`[bridge-proxy] ${targetPath} failed:`, err?.message || err)
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(502, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: 'Bridge unreachable' }))
|
||||
res.end(JSON.stringify({ error: `Bridge unreachable: ${err?.message || 'unknown'}` }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,6 +718,12 @@ function bridgeProxyPlugin() {
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
characterStoragePlugin(),
|
||||
satelliteMapPlugin(),
|
||||
conversationStoragePlugin(),
|
||||
memoryStoragePlugin(),
|
||||
gazeProxyPlugin(),
|
||||
characterLookupPlugin(),
|
||||
healthCheckPlugin(),
|
||||
bridgeProxyPlugin(),
|
||||
tailwindcss(),
|
||||
|
||||
Reference in New Issue
Block a user