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
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}/`);
|
|
});
|