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