parent
29d3dc05db
commit
a44cd4217c
@ -0,0 +1,49 @@
|
||||
# Node.js dependencies
|
||||
node_modules/
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pm-error.log*
|
||||
|
||||
# Runtime data
|
||||
pids/
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Dependency directories
|
||||
jspm_packages/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.test
|
||||
|
||||
# IDE/editor directories
|
||||
.vscode/
|
||||
.idea/
|
||||
*.sublime-workspace
|
||||
*.sublime-project
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
|
||||
# Temporary files
|
||||
*.log
|
||||
*.tmp
|
||||
*.temp
|
||||
*.swp
|
||||
*~
|
||||
@ -0,0 +1,82 @@
|
||||
{
|
||||
"name": "text-adventure-backend",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "text-adventure-backend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.6.7"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"requires": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
|
||||
},
|
||||
"webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
|
||||
},
|
||||
"whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"requires": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "text-adventure-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend for text adventure app",
|
||||
"main": "server.js",
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.6.7"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,521 @@
|
||||
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}/`);
|
||||
});
|
||||
@ -0,0 +1,162 @@
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background-color: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
width: 300px;
|
||||
background-color: #252526;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid #333;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
color: #9cdcfe;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
background-color: #3c3c3c;
|
||||
color: #d4d4d4;
|
||||
border: 1px solid #555;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 10px 15px;
|
||||
background-color: #0e639c;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #1177bb;
|
||||
}
|
||||
|
||||
#chat-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 20px;
|
||||
padding: 10px;
|
||||
background-color: #1e1e1e;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#input-area {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#user-input {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
background-color: #3c3c3c;
|
||||
color: #d4d4d4;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 15px;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.user-message {
|
||||
background-color: #2d2d2d;
|
||||
border-left: 4px solid #0e639c;
|
||||
}
|
||||
|
||||
.suggestion-button {
|
||||
background-color: #2d2d2d;
|
||||
color: #d4d4d4;
|
||||
border: 1px solid #3e3e42;
|
||||
padding: 6px 12px;
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.suggestion-button:hover {
|
||||
background-color: #0e639c;
|
||||
color: white;
|
||||
border-color: #0e639c;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.suggestion-button:active {
|
||||
transform: translateY(0);
|
||||
background-color: #1177bb;
|
||||
}
|
||||
|
||||
#suggestions-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for suggestions container */
|
||||
#suggestions-container::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
#suggestions-container::-webkit-scrollbar-thumb {
|
||||
background: #3e3e42;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
#suggestions-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
margin-bottom: 20px;
|
||||
padding: 10px;
|
||||
background-color: #333;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
color: #ce9178;
|
||||
font-style: italic;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
|
||||
.ai-message {
|
||||
background-color: #333;
|
||||
border-left: 4px solid #4ec9b0;
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI Text Adventure Engine</title>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script defer src="js/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="sidebar">
|
||||
<h2>World Engine</h2>
|
||||
<div id="status-message" class="status-message">Ready</div>
|
||||
<div class="section">
|
||||
<label for="lore">Lore</label>
|
||||
<textarea id="lore" rows="5"></textarea>
|
||||
</div>
|
||||
<div class="section">
|
||||
<label for="characters">Characters (comma separated)</label>
|
||||
<textarea id="characters" rows="5"></textarea>
|
||||
</div>
|
||||
<div class="section">
|
||||
<label for="plot">Plot Plans</label>
|
||||
<textarea id="plot" rows="5"></textarea>
|
||||
</div>
|
||||
<div class="section">
|
||||
<label for="world-gen">World Generation Ideas</label>
|
||||
<textarea id="world-gen" rows="3" placeholder="e.g. Cyberpunk, dark fantasy, space opera..."></textarea>
|
||||
</div>
|
||||
<button onclick="updateState()">Save World State</button>
|
||||
<button onclick="generateWorld()" style="margin-top: 10px; background-color: #28a745;">Generate New World</button>
|
||||
</div>
|
||||
|
||||
<div id="main">
|
||||
<div id="chat-container"></div>
|
||||
<div id="suggestions-container"></div>
|
||||
<div id="input-area">
|
||||
<input type="text" id="user-input" placeholder="Enter your action..." onkeypress="if(event.key === 'Enter') sendTurn()">
|
||||
<button onclick="sendTurn()">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,336 @@
|
||||
const BACKEND_URL = "https://text-adventure.spacek.blue";
|
||||
const MODEL = 'gemma4:26b'; // Default model for generation and analysis
|
||||
const chatContainer = document.getElementById('chat-container');
|
||||
const userInput = document.getElementById('user-input');
|
||||
const loreArea = document.getElementById('lore');
|
||||
const charactersArea = document.getElementById('characters');
|
||||
const plotArea = document.getElementById('plot');
|
||||
const statusElement = document.getElementById('status-message');
|
||||
const worldGenArea = document.getElementById('world-gen');
|
||||
|
||||
async function pollStatus() {
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/api/status`);
|
||||
const data = await response.json();
|
||||
statusElement.textContent = `Status: ${data.status}`;
|
||||
statusElement.className = `status-message status-${data.status}`;
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch status:", err);
|
||||
}
|
||||
}
|
||||
setInterval(pollStatus, 1000);
|
||||
|
||||
function formatStateForDisplay(state) {
|
||||
return {
|
||||
lore: state.lore || "",
|
||||
characters: (state.characters || []).map(c => `${c.name}: ${c.description}, possessions: ${(c.possessions || []).join(', ')}`).join("\n"),
|
||||
plots: (state.plot_plans || []).map(p => `${p.description} (relevance: ${p.relevance_timer})`).join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
async function loadState() {
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/api/state`);
|
||||
const state = await response.json();
|
||||
const formatted = formatStateForDisplay(state);
|
||||
|
||||
loreArea.value = formatted.lore;
|
||||
charactersArea.value = formatted.characters;
|
||||
plotArea.value = formatted.plots;
|
||||
|
||||
chatContainer.innerHTML = "";
|
||||
if (state.history) {
|
||||
state.history.forEach(msg => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'message';
|
||||
if (msg.startsWith("User:")) {
|
||||
div.className += ' user-message';
|
||||
div.textContent = msg;
|
||||
} else if (msg.startsWith("AI:")) {
|
||||
div.className += ' ai-message';
|
||||
const content = msg.substring(4).trim();
|
||||
div.innerHTML = `AI: ${marked.parse(content)}`;
|
||||
} else {
|
||||
div.textContent = msg;
|
||||
}
|
||||
chatContainer.appendChild(div);
|
||||
});
|
||||
}
|
||||
// Generate suggestions after loading state to ensure they are ready for the user
|
||||
generateSuggestions();
|
||||
} catch (err) {
|
||||
console.error("Failed to load state from backend:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function appendMessage(msg) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'message';
|
||||
if (msg.startsWith("User:")) {
|
||||
div.className += ' user-message';
|
||||
} else if (msg.startsWith("AI:")) {
|
||||
div.className += ' ai-message';
|
||||
}
|
||||
|
||||
if (msg.startsWith("AI:")) {
|
||||
const content = msg.substring(4).trim();
|
||||
div.innerHTML = `AI: ${marked.parse(content)}`;
|
||||
} else {
|
||||
div.textContent = msg;
|
||||
}
|
||||
|
||||
chatContainer.appendChild(div);
|
||||
chatContainer.scrollTop = chatContainer.scrollHeight;
|
||||
}
|
||||
|
||||
async function sendTurn() {
|
||||
const text = userInput.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
userInput.value = "";
|
||||
appendMessage(`User: ${text}`);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt: text,
|
||||
model: MODEL,
|
||||
stream: true
|
||||
})
|
||||
});
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let aiResponseText = "";
|
||||
appendMessage(`AI: ${aiResponseText}`);
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const json = JSON.parse(line);
|
||||
if (json.response) {
|
||||
const content = json.response;
|
||||
aiResponseText += content;
|
||||
|
||||
// Find the message element that corresponds to this specific AI response stream.
|
||||
// We look for the latest message that has not been fully updated yet,
|
||||
// or we create a new one if none exist.
|
||||
const messages = chatContainer.querySelectorAll('.message');
|
||||
let targetMessage = null;
|
||||
|
||||
// Iterate backwards to find the most recent AI message that we are currently streaming into
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].classList.contains('ai-message')) {
|
||||
targetMessage = messages[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetMessage) {
|
||||
targetMessage.innerHTML = `AI: ${marked.parse(aiResponseText)}`;
|
||||
} else {
|
||||
appendMessage(`AI: ${aiResponseText}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Partial JSON or other issues
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh state after generation and background update
|
||||
// Increased delay to ensure backend has finished writing to disk
|
||||
setTimeout(loadState, 3000);
|
||||
|
||||
} catch (err) {
|
||||
console.error("Error during generation:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateSuggestions() {
|
||||
const suggestionsContainer = document.getElementById('suggestions-container');
|
||||
suggestionsContainer.innerHTML = "Generating suggestions...";
|
||||
|
||||
try {
|
||||
const stateResponse = await fetch(`${BACKEND_URL}/api/state`);
|
||||
const state = await stateResponse.json();
|
||||
const history = state.history || [];
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}/api/suggest`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
history: history,
|
||||
model: MODEL
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const suggestions = data.suggestions;
|
||||
|
||||
suggestionsContainer.innerHTML = "";
|
||||
suggestions.forEach(suggestion => {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = suggestion;
|
||||
btn.className = 'suggestion-button';
|
||||
btn.onclick = () => {
|
||||
userInput.value = suggestion;
|
||||
suggestionsContainer.innerHTML = "";
|
||||
};
|
||||
suggestionsContainer.appendChild(btn);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error generating suggestions:", err);
|
||||
suggestionsContainer.innerHTML = "Failed to load suggestions.";
|
||||
}
|
||||
}
|
||||
|
||||
async function updateState() {
|
||||
const parseCharacters = (text) => {
|
||||
return text.split('\n').map(line => {
|
||||
const match = line.match(/^(.+?):\s*(.+?),\s*possessions:\s*(.+)$/);
|
||||
if (match) {
|
||||
const [, name, description, possessionsStr] = match;
|
||||
const possessions = possessionsStr.split(',').map(p => p.trim()).filter(p => p);
|
||||
return { name: name.trim(), description: description.trim(), possessions };
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
};
|
||||
|
||||
const parsePlotPlans = (text) => {
|
||||
return text.split('\n').map(line => {
|
||||
const match = line.match(/^(.+?)\s*\(relevance:\s*(\d+)\)$/);
|
||||
if (match) {
|
||||
const [, description, relevance_timer] = match;
|
||||
return { description: description.trim(), relevance_timer: parseInt(relevance_timer) };
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
};
|
||||
|
||||
const newState = {
|
||||
lore: loreArea.value,
|
||||
characters: parseCharacters(charactersArea.value),
|
||||
plot_plans: parsePlotPlans(plotArea.value),
|
||||
history: []
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/api/state`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newState)
|
||||
});
|
||||
if (response.ok) {
|
||||
alert("World state updated successfully!");
|
||||
loadState();
|
||||
} else {
|
||||
alert("Failed to update world state.");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error updating state:", err);
|
||||
alert("Error updating state.");
|
||||
}
|
||||
}
|
||||
|
||||
async function generateWorld() {
|
||||
const suggestions = worldGenArea.value.trim();
|
||||
if (!suggestions) {
|
||||
alert("Please enter some ideas for the world first!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm("This will overwrite the current world state. Are you sure?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
statusElement.textContent = "Status: Generating World...";
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/api/generate-world`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
suggestions: suggestions,
|
||||
model: MODEL
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('World generation failed');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let aiResponseText = "";
|
||||
|
||||
// We create a placeholder for the streaming intro
|
||||
const div = document.createElement('div');
|
||||
div.className = 'message ai-message';
|
||||
div.innerHTML = `AI: `;
|
||||
chatContainer.appendChild(div);
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const json = JSON.parse(line);
|
||||
if (json.response) {
|
||||
aiResponseText += json.response;
|
||||
div.innerHTML = `AI: ${marked.parse(aiResponseText)}`;
|
||||
chatContainer.scrollTop = chatContainer.scrollHeight;
|
||||
}
|
||||
} catch (e) {
|
||||
// Partial JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh state after generation
|
||||
setTimeout(loadState, 3000);
|
||||
|
||||
} catch (err) {
|
||||
console.error("Error during world generation:", err);
|
||||
alert("Error during world generation. Check console for details.");
|
||||
}
|
||||
}
|
||||
|
||||
// Initial load
|
||||
loadState();
|
||||
|
||||
// Periodic sync of text areas from backend
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/api/state`);
|
||||
const state = await response.json();
|
||||
const formatted = formatStateForDisplay(state);
|
||||
|
||||
if (loreArea.value !== formatted.lore) {
|
||||
loreArea.value = formatted.lore;
|
||||
}
|
||||
if (charactersArea.value !== formatted.characters) {
|
||||
charactersArea.value = formatted.characters;
|
||||
}
|
||||
if (plotArea.value !== formatted.plots) {
|
||||
plotArea.value = formatted.plots;
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently fail
|
||||
}
|
||||
}, 2000);
|
||||
@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "text_adventure_app",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
Loading…
Reference in new issue