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.
337 lines
12 KiB
337 lines
12 KiB
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);
|