You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

522 lines
22 KiB

const http = require('http');
const fs = require('fs');
const path = require('path');
const fetch = require('node-fetch');
const PORT = 3030;
const STATE_FILE = path.join(__dirname, 'data', 'story_state.json');
const API_URL = 'https://open-webui.spacek.blue/api/chat/completions';
const API_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjAzNmEwYjQyLWNmMDktNDVmNy05MWUzLTBkYjI2YmE1MDRlNSIsImp0aSI6ImY0ZmM0OTM2LTliOTktNDU4Ny05MjM3LTY1OWJkN2UzYTYyOSIsImlhdCI6MTc3NzkzMTAyNX0.3E6pc8R9kVHIG2sQXH0pPADJkhbwZVw1QVniqVVYwcU';
const MODEL = 'gemma4:26b'; // Default model for generation and analysis
// Prompts moved from frontend to backend
const PROMPTS = {
updateWorldState: (history) => `Analyze the following recent history of an interactive fiction story.
Based on the events, update the world state.
Return ONLY a JSON object with the following structure:
{
"lore": "updated or expanded lore",
"characters": [{"name": "name", "description": "description", "possessions": ["item1", "item2"]}],
"plot_plans": [{"description": "plot point", "relevance_timer": 5}],
"history_summary": "a brief summary of what just happened"
}
History:
${history.slice(-5).join('\n')}
Ensure the updates are consistent with the existing lore and characters. If no new significant details are found, return the existing lore and characters but updated with any new developments.`,
suggest: (history) => `Based on the following recent history of an interactive fiction story, suggest three short, distinct, and interesting actions the player could take.
Return ONLY a JSON array of strings.
Example: ["Open the heavy door", "Search the desk", "Call out into the darkness"]
History:
${history.slice(-5).join('\n')}`,
generate: (state, userAction) => `You are an expert storyteller. Your goal is to weave a deeply immersive, long-form narrative that brings the world to life, regardless of the genre.
Current World Context:
Lore: ${state.lore || "None"}
Characters: ${ (state.characters || []).map(c => c.name + ": " + c.description).join(", ") || "None" }
Plot Plans: ${ (state.plot_plans || []).map(p => p.description + " (reras: " + p.relevance_timer + ")").join("\n") || "None" }
Recent History:
${(state.history || []).slice(-15).join("\n")}
Your Mandate:
1. **Immersive Detail**: Provide rich, sensory-heavy descriptions. Focus on sounds, smells, textures, and the weight of the atmosphere. Do not rush the story; let the moments linger.
2. **Long-form Narrative**: Your responses should be substantial and detailed, painting a complete picture of the scene and the impact of the user's actions.
3. **World Reactivity & Consequence**: Every user action must have a tangible, realistic consequence within the established lore and plot. The player's input is an *attempt* at action, not an absolute truth. You decide if the action succeeds, fails, or has unforeseen side effects. The player is a character in this world, subject to its dangers and limitations.
4. **Character Depth**: When characters are present, imbue them with distinct voices, subtle mannerisms, and hidden motivations.
5. **Avoid AI Tropes**: Do not use cliché narrator phrases like "As the adventure unfolds," "The air was thick with...", or "A sense of [emotion] washed oly you." Avoid summarizing the user's actions back to them. Do not break the fourth wall.
6. **Maintain Agency**: NEVER speak, think, or act for the player character. The player's agency is sacred.
7. **Narrative Flow**: Use the plot plans to guide the unfolding drama, but allow the user's unexpected actions to organically shape the journey.
User Action Attempt: ${userAction}`,
generateWorld: (suggestions) => `You are an expert world-builder and storyteller.
The user wants to start a new interactive fiction adventure with the following themes/suggestions: "${suggestions}".
Your task is to create a comprehensive and detailed foundation for this world.
Return ONLY a JSON object with the following structure:
{
"lore": "A rich, detailed description of the world's setting, history, and magic/technology systems.",
"characters": [{"name": "name", "description": "description", "possessions": ["item1", "item2"]}],
"plot_plans": [{"description": "an initial plot point to kick off the story", "relevance_timer": 10}],
"intro": "A beautiful, immersive, and long-form introduction to the story that sets the scene and places the player in the starting location. This should be the first piece of text the player reads."
}
Ensure the lore is deep and the characters feel alive. The intro should be evocative and atmospheric, making the player want to take their first action immediately.`
};
let currentStatus = 'idle';
// Helper to read state
function readState() {
try {
if (fs.existsSync(STATE_FILE)) {
const data = fs.readFileSync(STATE_FILE, 'utf8');
return JSON.parse(data);
}
} catch (err) {
console.error('Error reading state:', err);
}
return {
lore: '',
characters: [],
plot_plans: [],
history: [],
world_details: {} // For any other world properties
};
}
// Helper to write state
function writeState(state) {
try {
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
} catch (err) {
console.error('Error writing state:', err);
}
}
// New helper to update world state using AI analysis
async function updateWorldState(history) {
currentStatus = 'updating world state';
console.log('--- World State Update Started ---');
console.log('History length:', history.length);
const currentState = readState();
const analysisPrompt = PROMPTS.updateWorldState(history);
try {
console.log('Sending request to OpenAI-compatible API at:', API_URL);
const response = await fetch(API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`
},
body: JSON.stringify({
model: MODEL,
messages: [{ role: 'user', content: analysisPrompt }],
stream: false,
response_format: { type: 'json_object' }
})
});
console.log('API response received. Parsing JSON...');
// THIS SOMETIMES FAILS BECAUSE THE JSON WAS INVALID
const data = await response.json();
// OpenAI-compatible response structure: data.choices[0].message.content
let content = '';
if (data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) {
content = data.choices[0].message.content;
} else {
throw new Error('Unexpected API response structure: ' + JSON.stringify(data));
}
// Robustly extract JSON object from the response, handling potential markdown or preamble
let updates;
try {
const startIdx = content.indexOf('{');
const endIdx = content.lastIndexOf('}');
if (startIdx !== -1 && endIdx !== -1) {
const jsonStr = content.substring(startIdx, endIdx + 1);
updates = JSON.parse(jsonStr);
} else {
throw new Error('No JSON object found in response content');
}
} catch (err) {
console.error('Failed to parse updates JSON:', err);
throw err;
}
const newState = {
...currentState,
lore: updates.lore || currentState.lore,
characters: updates.characters || currentState.characters,
plot_plans: updates.plot_plans || currentState.plot_plans,
// We keep the summary if needed for something else
};
writeState(newState);
console.log('--- World State Update Completed ---');
} catch (err) {
console.error('Error updating world state:', err);
currentStatus = 'error updating world state';
} finally {
if (currentStatus !== 'error updating world state') {
currentStatus = 'idle';
}
}
}
const server = http.createServer(async (req, res) => {
// CORS Headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
// Allow client to listen for Server-Sent Events
res.setHeader('Access-Control-Allow-Credentials', 'token');
res.setHeader('Access-Control-Allow-Credentials', 'true');
const url = new URL(req.url, `http://${req.headers.host}`);
if (url.pathname === '/favicon.ico') {
res.writeHead(204);
res.end();
return;
}
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
if (url.pathname === '/api/state' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(readState()));
}
else if (url.pathname === '/api/status' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: currentStatus }));
}
else if (url.pathname === '/api/state' && req.method === 'POST') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', () => {
try {
const newState = JSON.parse(body);
writeState(newState);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ message: 'State updated' }));
} catch (err) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
}
});
}
else if (url.pathname === '/api/suggest' && req.method === 'POST') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const { history, model } = JSON.parse(body);
const analysisPrompt = PROMPTS.suggest(history);
const response = await fetch(API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`
},
body: JSON.stringify({
model: model || MODEL,
messages: [{ role: 'user', content: analysisPrompt }],
stream: false,
response_format: { type: 'json_object' }
})
});
const data = await response.json();
let content = '';
if (data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) {
content = data.choices[0].message.content;
}
// Extract JSON array from response
let suggestions = [];
const startIdx = content.indexOf('[');
const endIdx = content.lastIndexOf(']');
if (startIdx !== -1 && endIdx !== -1) {
suggestions = JSON.parse(content.substring(startIdx, endIdx + 1));
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ suggestions }));
} catch (err) {
console.error('Error in /api/suggest:', err);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Internal Server Error' }));
}
});
}
else if (url.pathname === '/api/generate' && req.method === 'POST') {
currentStatus = 'generating story';
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const { prompt: userAction, model, stream } = JSON.parse(body);
// Construct the full prompt on the backend
const currentState = readState();
const systemPrompt = PROMPTS.generate(currentState, userAction);
const apiOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`
},
body: JSON.stringify({
model: model || MODEL,
messages: [{ role: 'user', content: systemPrompt }],
stream: stream !== undefined ? stream : false
})
};
if (stream) {
console.log('Starting stream to client...');
const apiOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`
},
body: JSON.stringify({
model: model || MODEL,
messages: [{ role: 'user', content: systemPrompt }],
stream: true
})
};
const response = await fetch(API_URL, apiOptions);
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
let accumulatedResponse = "";
response.body.on('data', (chunk) => {
const lines = chunk.toString().split('\n');
for (const line of lines) {
if (!line.trim() || line.trim() === 'data: [DONE]') continue;
try {
// Remove 'data: ' prefix
const jsonStr = line.replace(/^data: /, '');
const json = JSON.parse(jsonStr);
const content = json.choices[0].delta.content || '';
if (content) {
accumulatedResponse += content;
res.write(JSON.stringify({ response: content }) + '\n');
}
} catch (err) {
// Handle parsing errors for partial or malformed chunks
}
}
});
response.body.on('end', async () => {
console.log('Stream ended. Updating history and triggering background world state update...');
const updatedHistory = [...currentState.history, `User: ${userAction}`, `AI: ${accumulatedResponse}`];
const newState = {
...currentState,
history: updatedHistory
};
writeState(newState);
updateWorldState(updatedHistory)
.then(summary => {
if (summary) {
console.log('Background update summary:', summary);
}
})
.catch(err => {
console.error('Error in background world state update:', err);
});
res.end();
});
} else {
const apiOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`
},
body: JSON.stringify({
model: model || MODEL,
messages: [{ role: 'user', content: systemPrompt }],
stream: false
})
};
console.log('Sending request to OpenAI-compatible API...');
const response = await fetch(API_URL, apiOptions);
const data = await response.json();
let content = '';
if (data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) {
content = data.choices[0].message.content;
} else {
throw new Error('Unexpected API response structure: ' + JSON.stringify(data));
}
const ollamaFormatChunk = JSON.stringify({ response: content });
res.write(ollamaFormatChunk);
const updatedHistory = [...currentState.history, `User: ${userAction}`, `AI: ${content}`];
const newState = {
...currentState,
history: updatedHistory
};
writeState(newState);
updateWorldState(updatedHistory)
.then(summary => {
if (summary) {
console.log('Background update summary:', summary);
}
})
.catch(err => {
console.error('Error in background world state update:', err);
});
res.end();
}
} catch (err) {
currentStatus = 'idle';
console.error('Error generating response:', err);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Generation failed' }));
}
});
}
else if (url.pathname === '/api/generate-world' && req.method === 'POST') {
currentStatus = 'generating world';
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const { suggestions, model } = JSON.parse(body);
const analysisPrompt = PROMPTS.generateWorld(suggestions);
const response = await fetch(API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`
},
body: JSON.stringify({
model: model || MODEL,
messages: [{ role: 'user', content: analysisPrompt }],
stream: false,
response_format: { type: 'json_object' }
})
});
const data = await response.json();
let content = '';
if (data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) {
content = data.choices[0].message.content;
}
let worldData;
const startIdx = content.indexOf('{');
const endIdx = content.lastIndexOf('}');
if (startIdx !== -1 && endIdx !== -1) {
worldData = JSON.parse(content.substring(startIdx, endIdx + 1));
} else {
throw new Error('No JSON object found in response content');
}
const intro = worldData.intro || "";
const newState = {
lore: worldData.lore || '',
characters: worldData.characters || [],
plot_plans: worldData.plot_plans || [],
history: [ `AI: ${intro}` ]
};
writeState(newState);
// For world generation, we don't stream, we just send the intro as one chunk
const responseChunk = JSON.stringify({ response: intro });
res.write(responseChunk);
res.end();
} catch (err) {
console.error('Error in /api/generate-world:', err);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'World generation failed' }));
} finally {
currentStatus = 'idle';
}
});
}
else if (url.pathname === '/api/state' && req.method === 'POST') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', () => {
try {
const newState = JSON.parse(body);
writeState(newState);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ message: 'State updated' }));
} catch (err) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
}
});
}
else {
// Serve static files from the frontend directory
const filePath = path.join(__dirname, '..', 'frontend', url.pathname === '/' ? 'index.html' : url.pathname);
fs.access(filePath, fs.constants.F_OK, (err) => {
if (err) {
res.writeHead(404);
res.end('File not found');
return;
}
const stream = fs.createReadStream(filePath);
// Determine content type
const ext = path.extname(filePath);
const mimeTypes = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpg',
'.gif': 'image/gif'
};
const contentType = mimeTypes[ext] || 'application/octet-stream';
res.writeHead(200, { 'Content-Type': contentType });
stream.pipe(res);
});
}
});
server.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}/`);
});

Powered by TurnKey Linux.