sandbox/server/ai/trainer.js

197 lines
8.0 KiB
JavaScript

import db from '../db.js';
const LLM_HOST = process.env.LLM_HOST || null;
const LLM_MODEL = process.env.LLM_MODEL || 'qwen7b.Q4_K_M.gguf';
async function queryLLM(messages) {
if (!LLM_HOST) return null;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 180000);
try {
const response = await fetch(`${LLM_HOST}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: LLM_MODEL, messages, max_tokens: 500, temperature: 0.8, stream: false }),
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) return null;
const data = await response.json();
return data.choices?.[0]?.message?.content || null;
} catch (e) {
clearTimeout(timeout);
return null;
}
}
const DEFAULT_KNOWLEDGE = {
character_archetypes: [
{
name: 'The Hero',
traits: ['brave', 'selfless', 'determined', 'courageous'],
backstory_template: 'A {adjective} individual driven by {motivation} to {goal}.',
needs_priority: { Food: 3, Energy: 4, Intimate: 1 }
},
{
name: 'The Sage',
traits: ['wise', 'patient', 'introspective', 'knowledgeable'],
backstory_template: 'Through years of study and experience, this {adjective} soul has gained {knowledge}.',
needs_priority: { Food: 1, Energy: 2, Intimate: 1 }
},
{
name: 'The Rogue',
traits: ['cunning', 'adaptive', 'independent', 'resourceful'],
backstory_template: 'Living on the edge, this {adjective} character uses their {skill} to survive.',
needs_priority: { Food: 4, Energy: 3, Intimate: 3 }
},
{
name: 'The Caregiver',
traits: ['nurturing', 'selfless', 'compassionate', 'protective'],
backstory_template: 'Driven by {motivation}, this {adjective} soul puts others before themselves.',
needs_priority: { Food: 2, Energy: 3, Intimate: 2 }
},
{
name: 'The Wild One',
traits: ['untamed', 'instinctual', 'free-spirited', 'primal'],
backstory_template: 'Born of {origin}, this {adjective} being follows the call of the wild.',
needs_priority: { Food: 5, Energy: 5, Bladder: 4, Bowel: 4, Hormones: 5, Intimate: 5 }
}
],
name_generators: {
fantasy: ['Kaelen', 'Lyra', 'Thorn', 'Elara', 'Bryn', 'Zephyr', 'Mira', 'Orion', 'Sable', 'Finn'],
modern: ['Alex', 'Jordan', 'Riley', 'Sam', 'Taylor', 'Morgan', 'Casey', 'Avery', 'Quinn', 'Drew'],
gothic: ['Vladimir', 'Isabella', 'Mortimer', 'Ophelia', 'Caspian', 'Seraphina', 'Damien', 'Raven', 'Lucian', 'Vesper'],
cyberpunk: ['Neon', 'Pixel', 'Cipher', 'Blade', 'Vex', 'Synthia', 'Zero', 'Echo', 'Byte', 'Nyx']
},
motivations: ['justice', 'knowledge', 'power', 'love', 'survival', 'redemption', 'freedom', 'discovery', 'revenge', 'balance'],
origins: ['the ancient forests', 'a forgotten kingdom', 'the stars above', 'the depths of the sea', 'a laboratory', 'the void between worlds', 'a nomadic tribe', 'an order of knights']
};
function getRandomItem(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
function generateName(style) {
const generators = DEFAULT_KNOWLEDGE.name_generators;
const names = generators[style] || generators.fantasy;
return getRandomItem(names);
}
function generateArchetype() {
const archetypes = DEFAULT_KNOWLEDGE.character_archetypes;
const archetype = getRandomItem(archetypes);
const adjective = getRandomItem(archetype.traits);
const motivation = getRandomItem(DEFAULT_KNOWLEDGE.motivations);
const origin = getRandomItem(DEFAULT_KNOWLEDGE.origins);
const goal = getRandomItem(['save their people', 'find the truth', 'protect the innocent', 'gain ultimate power', 'achieve inner peace', 'survive the coming storm']);
const knowledge = getRandomItem(['ancient wisdom', 'forgotten secrets', 'the art of diplomacy', 'alchemical mastery', 'technological prowess']);
const backstory = archetype.backstory_template
.replace('{adjective}', adjective)
.replace('{motivation}', motivation)
.replace('{goal}', goal)
.replace('{knowledge}', knowledge)
.replace('{origin}', origin)
.replace('{skill}', `${motivation} and ${adjective}ness`);
return {
archetype: archetype.name,
traits: archetype.traits,
backstory,
needs_priority: archetype.needs_priority,
motivation,
origin
};
}
function getTrainingData(userId) {
const data = db.prepare('SELECT prompt, response, category FROM ai_training_data WHERE user_id = ? ORDER BY created_at DESC LIMIT 50').all(userId);
return data;
}
function generateCharacterSuggestion(prompt, userId) {
const userTraining = getTrainingData(userId);
const archetype = generateArchetype();
const nameStyle = prompt.toLowerCase().includes('fantasy') ? 'fantasy' :
prompt.toLowerCase().includes('cyber') ? 'cyberpunk' :
prompt.toLowerCase().includes('goth') ? 'gothic' :
prompt.toLowerCase().includes('modern') ? 'modern' : 'fantasy';
const name = generateName(nameStyle);
const defaultNeeds = [
{ name: 'Food', enabled: true, initial_value: 80, min_value: 0, max_value: 100, decay_rate: 1.5, priority: 3 },
{ name: 'Energy', enabled: true, initial_value: 90, min_value: 0, max_value: 100, decay_rate: 2, priority: 4 },
{ name: 'Bladder', enabled: true, initial_value: 30, min_value: 0, max_value: 100, decay_rate: 0.8, priority: 2 },
{ name: 'Bowel', enabled: false, initial_value: 20, min_value: 0, max_value: 100, decay_rate: 0.5, priority: 1 },
{ name: 'Hormones', enabled: true, initial_value: 40, min_value: 0, max_value: 100, decay_rate: 0.3, priority: 1 },
{ name: 'Intimate', enabled: true, initial_value: 30, min_value: 0, max_value: 100, decay_rate: 0.4, priority: 2 }
];
const needsWithPriority = defaultNeeds.map(n => ({
...n,
priority: archetype.needs_priority[n.name] || n.priority
}));
const response = {
name,
description: `A ${nameStyle.toLowerCase()} character embodying the ${archetype.archetype.toLowerCase()} archetype.`,
personality_traits: archetype.traits,
backstory: archetype.backstory,
suggested_needs: needsWithPriority,
suggested_ui_elements: needsWithPriority
.filter(n => n.enabled)
.map(n => ({
need_name: n.name,
element_type: n.name === 'Energy' ? 'gauge' : 'progress_bar',
config: { color: getColorForNeed(n.name), label: n.name, animation: 'smooth' }
})),
reasoning: `Based on your request, I drew inspiration from the ${archetype.archetype.toLowerCase()} archetype. ${archetype.motivation} drives this character forward. I prioritized needs that match their instincts.`
};
if (userTraining.length > 0) {
const recentTraining = userTraining[0];
response.training_influence = `Drawing from your past preferences: "${recentTraining.prompt}" -> "${recentTraining.response}"`;
}
return response;
}
function getColorForNeed(needName) {
const colors = {
Food: '#e74c3c',
Energy: '#f39c12',
Bladder: '#3498db',
Bowel: '#8e44ad',
Hormones: '#e91e63',
Intimate: '#ff6b6b'
};
return colors[needName] || '#95a5a6';
}
async function processTrainingPrompt(prompt, userId) {
try {
const messages = [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: `Create a character based on this request: "${prompt}". Return ONLY valid JSON.` }
];
const ollamaResponse = await queryLLM(messages);
if (ollamaResponse) {
const cleaned = ollamaResponse.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
const parsed = JSON.parse(cleaned);
const valid = validateSuggestion(parsed);
if (valid) return parsed;
}
} catch {
// Fallback to rule-based
}
return generateCharacterSuggestion(prompt, userId);
}
function validateSuggestion(s) {
if (!s.name || !s.description || !Array.isArray(s.personality_traits)) return false;
if (!Array.isArray(s.suggested_needs) || s.suggested_needs.length === 0) return false;
return true;
}
export { generateCharacterSuggestion, processTrainingPrompt, DEFAULT_KNOWLEDGE };