197 lines
8.0 KiB
JavaScript
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 };
|