AICastle / index.html
index.html54.8 KB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AICastle</title>
<style>
:root {
--bg: #12101e;
--bg2: #1a1730;
--bg3: #252040;
--text: #dcd8e8;
--text-dim: #7e7a92;
--accent: #d63b5a;
--accent2: #7c4dff;
--gold: #e0b44a;
--input-bg: #1e1b32;
--border: #2e2a45;
--green: #43b581;
--shadow: rgba(0,0,0,0.35);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Georgia', 'Times New Roman', serif; background: var(--bg); color: var(--text); min-height: 100vh; }
.app { display: flex; height: 100vh; }
.sidebar { width: 260px; background: var(--bg2); border-right: 1px solid var(--border); display: flex; flex-direction: column; flex-shrink: 0; }
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; background: var(--bg); }
.sidebar-header { padding: 20px; border-bottom: 1px solid var(--border); }
.sidebar-header h1 { font-size: 1.4em; color: var(--gold); letter-spacing: 3px; font-variant: small-caps; }
.sidebar-actions { padding: 12px; display: flex; flex-direction: column; gap: 6px; }
.sidebar-actions button { padding: 10px; border: 1px solid var(--border); background: var(--input-bg); color: var(--text); border-radius: 8px; cursor: pointer; font-size: 0.85em; transition: all 0.2s; }
.sidebar-actions button:hover { background: var(--bg3); border-color: var(--accent2); }
.sidebar-actions button.primary { background: linear-gradient(135deg, var(--accent) 0%, #b82e4a 100%); border-color: transparent; color: white; font-weight: bold; }
.sidebar-actions button.primary:hover { filter: brightness(1.15); }
.game-list { flex: 1; overflow-y: auto; padding: 8px; }
.game-item { padding: 12px 14px; border-radius: 8px; cursor: pointer; margin-bottom: 2px; transition: all 0.2s; border-left: 3px solid transparent; }
.game-item:hover { background: var(--bg3); }
.game-item.active { background: var(--bg3); border-left-color: var(--gold); }
.game-item .title { font-weight: bold; font-size: 0.9em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.game-item .meta { font-size: 0.7em; color: var(--text-dim); margin-top: 4px; }
.top-bar { padding: 10px 20px; background: var(--bg2); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; }
.top-bar .game-title { flex: 1; font-size: 1.05em; color: var(--gold); font-weight: normal; letter-spacing: 0.5px; }
.model-select { padding: 6px 10px; background: var(--input-bg); color: var(--text); border: 1px solid var(--border); border-radius: 6px; font-size: 0.8em; }
.top-bar button { padding: 6px 14px; background: var(--input-bg); color: var(--text-dim); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-size: 0.8em; transition: all 0.2s; letter-spacing: 0.5px; }
.top-bar button:hover { background: var(--bg3); color: var(--text); border-color: var(--accent2); }
.top-bar button.active { background: var(--gold); color: var(--bg); border-color: var(--gold); font-weight: 600; }
.top-bar .badge { font-size: 0.65em; background: var(--accent); color: white; border-radius: 10px; padding: 1px 6px; margin-left: 4px; vertical-align: middle; }
.story-area { flex: 1; overflow-y: auto; padding: 24px 48px; max-width: 900px; margin: 0 auto; width: 100%; }
.story-block { margin-bottom: 2px; padding: 14px 20px; border-radius: 6px; line-height: 1.8; white-space: pre-wrap; word-wrap: break-word; position: relative; font-size: 0.98em; }
.story-block.player { background: rgba(124, 77, 255, 0.08); border-left: 3px solid var(--accent2); margin: 20px 0; padding: 16px 20px; border-radius: 2px 8px 8px 2px; }
.story-block.player .block-text { font-style: italic; color: var(--text-dim); }
.story-block.player.say .block-text::before { content: '\201C'; color: var(--accent2); font-size: 1.3em; }
.story-block.player.say .block-text::after { content: '\201D'; color: var(--accent2); font-size: 1.3em; }
.story-block.player.do .block-text::before { content: '> '; color: var(--gold); font-style: normal; font-weight: 600; }
.story-block.player.story .block-text::before { content: '\2731 '; color: var(--gold); font-style: normal; }
.story-block.ai { background: transparent; }
.story-block .action-type { font-size: 0.6em; text-transform: uppercase; color: var(--accent2); letter-spacing: 2px; margin-bottom: 4px; font-weight: 700; }
.story-block .edit-btn { position: absolute; top: 50%; right: -36px; transform: translateY(-50%); background: var(--bg3); border: 1px solid var(--border); color: var(--text-dim); cursor: pointer; font-size: 0.7em; opacity: 0; transition: all 0.2s; padding: 4px 8px; border-radius: 4px; white-space: nowrap; }
.story-block:hover .edit-btn { opacity: 0.7; }
.story-block .edit-btn:hover { opacity: 1; color: var(--gold); border-color: var(--gold); }
.story-summary { margin: 0 0 24px; padding: 16px 20px; border-radius: 8px; background: rgba(224, 180, 74, 0.06); border: 1px dashed rgba(224, 180, 74, 0.3); color: var(--text-dim); font-size: 0.85em; line-height: 1.7; white-space: pre-wrap; }
.story-summary::before { content: 'Earlier in the story...'; display: block; font-size: 0.65em; text-transform: uppercase; letter-spacing: 2px; color: var(--gold); margin-bottom: 8px; font-weight: 700; opacity: 0.8; }
.streaming-cursor { display: inline-block; width: 2px; height: 18px; background: var(--gold); animation: blink 0.9s ease-in-out infinite; vertical-align: text-bottom; margin-left: 2px; border-radius: 1px; }
@keyframes blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } }
.input-area { padding: 16px 48px 20px; background: var(--bg2); border-top: 1px solid var(--border); max-width: 900px; margin: 0 auto; width: 100%; }
.action-buttons { display: flex; gap: 6px; margin-bottom: 10px; }
.action-buttons button { padding: 5px 18px; border: 1px solid var(--border); background: var(--input-bg); color: var(--text-dim); border-radius: 20px; cursor: pointer; font-size: 0.8em; transition: all 0.2s; letter-spacing: 0.5px; }
.action-buttons button:hover { color: var(--text); border-color: var(--accent2); }
.action-buttons button.active { background: var(--accent2); border-color: var(--accent2); color: white; font-weight: 600; }
.input-row { display: flex; gap: 8px; }
.input-row textarea { flex: 1; padding: 12px 14px; background: var(--input-bg); color: var(--text); border: 1px solid var(--border); border-radius: 10px; font-family: inherit; font-size: 0.95em; resize: none; min-height: 48px; max-height: 200px; line-height: 1.5; transition: border-color 0.2s; }
.input-row textarea:focus { outline: none; border-color: var(--accent2); box-shadow: 0 0 0 2px rgba(124, 77, 255, 0.15); }
.input-row .send-btn { padding: 12px 22px; background: linear-gradient(135deg, var(--accent) 0%, #b82e4a 100%); color: white; border: none; border-radius: 10px; cursor: pointer; font-size: 0.95em; font-weight: bold; transition: all 0.2s; align-self: flex-end; letter-spacing: 0.5px; }
.input-row .send-btn:hover { filter: brightness(1.15); transform: translateY(-1px); }
.input-row .send-btn:disabled { background: var(--border); cursor: not-allowed; filter: none; transform: none; }
.input-controls { display: flex; gap: 6px; margin-top: 8px; }
.input-controls button { padding: 4px 12px; background: none; border: 1px solid var(--border); color: var(--text-dim); border-radius: 6px; cursor: pointer; font-size: 0.75em; transition: all 0.2s; letter-spacing: 0.3px; }
.input-controls button:hover { color: var(--text); border-color: var(--text-dim); }
.welcome { display: flex; flex-direction: column; align-items: center; justify-content: center; flex: 1; padding: 40px; text-align: center; }
.welcome h2 { font-size: 2.2em; color: var(--gold); margin-bottom: 16px; letter-spacing: 3px; font-variant: small-caps; }
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.75); backdrop-filter: blur(4px); z-index: 100; align-items: center; justify-content: center; }
.modal-overlay.active { display: flex; }
.modal { background: var(--bg2); border: 1px solid var(--border); border-radius: 14px; padding: 28px; max-width: 600px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: 0 16px 48px var(--shadow); }
.modal h2 { color: var(--gold); margin-bottom: 18px; font-size: 1.2em; letter-spacing: 1px; }
.modal label { display: block; font-size: 0.85em; color: var(--text-dim); margin-bottom: 4px; margin-top: 14px; letter-spacing: 0.3px; }
.modal input, .modal textarea, .modal select { width: 100%; padding: 10px 12px; background: var(--input-bg); color: var(--text); border: 1px solid var(--border); border-radius: 8px; font-family: inherit; font-size: 0.9em; transition: border-color 0.2s; }
.modal input:focus, .modal textarea:focus { outline: none; border-color: var(--accent2); }
.modal textarea { min-height: 80px; resize: vertical; }
.modal .btn-row { display: flex; gap: 8px; margin-top: 22px; justify-content: flex-end; }
.modal .btn-row button { padding: 9px 22px; border-radius: 8px; cursor: pointer; font-size: 0.9em; border: 1px solid var(--border); transition: all 0.2s; }
.modal .btn-row .cancel { background: var(--input-bg); color: var(--text-dim); }
.modal .btn-row .cancel:hover { color: var(--text); }
.modal .btn-row .confirm { background: linear-gradient(135deg, var(--accent) 0%, #b82e4a 100%); color: white; border-color: transparent; }
.modal .btn-row .confirm:hover { filter: brightness(1.15); }
.panel { display: none; width: 380px; background: var(--bg2); border-left: 1px solid var(--border); flex-direction: column; flex-shrink: 0; }
.panel.active { display: flex; }
.panel-header { padding: 14px 16px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
.panel-header h3 { color: var(--gold); font-size: 0.95em; letter-spacing: 1px; font-weight: 600; }
.panel-header button { background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 1.2em; }
.panel-tabs { display: flex; border-bottom: 1px solid var(--border); background: rgba(0,0,0,0.15); }
.panel-tabs button { flex: 1; padding: 10px; background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-dim); cursor: pointer; font-size: 0.8em; transition: all 0.2s; letter-spacing: 0.5px; text-transform: uppercase; }
.panel-tabs button.active { color: var(--gold); border-bottom-color: var(--gold); }
.panel-tabs button:hover { color: var(--text); }
.panel-body { flex: 1; overflow-y: auto; padding: 14px; }
/* Story Cards */
.wi-card { background: var(--input-bg); border: 1px solid var(--border); border-radius: 10px; padding: 14px; margin-bottom: 8px; cursor: pointer; transition: all 0.2s; }
.wi-card:hover { border-color: var(--gold); box-shadow: 0 2px 12px rgba(224, 180, 74, 0.08); }
.wi-card .wi-header { display: flex; justify-content: space-between; align-items: center; }
.wi-card .wi-title { font-weight: bold; color: var(--gold); font-size: 0.88em; }
.wi-card .wi-keys { font-size: 0.72em; color: var(--text-dim); margin: 4px 0; opacity: 0.8; }
.wi-card .wi-entry { font-size: 0.82em; line-height: 1.6; max-height: 80px; overflow: hidden; white-space: pre-wrap; color: var(--text-dim); }
.wi-card.expanded .wi-entry { max-height: none; }
.wi-card .wi-actions { margin-top: 10px; display: flex; gap: 6px; }
.wi-card .wi-actions button { font-size: 0.72em; padding: 4px 12px; background: none; border: 1px solid var(--border); color: var(--text-dim); border-radius: 6px; cursor: pointer; transition: all 0.2s; }
.wi-card .wi-actions button:hover { color: var(--accent); border-color: var(--accent); }
.wi-card .wi-actions button.edit-card:hover { color: var(--gold); border-color: var(--gold); }
/* Settings */
.setting-group { margin-bottom: 18px; }
.setting-group h4 { color: var(--gold); font-size: 0.8em; margin-bottom: 10px; border-bottom: 1px solid var(--border); padding-bottom: 6px; letter-spacing: 1px; text-transform: uppercase; }
.setting-group label { display: block; font-size: 0.82em; color: var(--text-dim); margin-bottom: 4px; margin-top: 10px; }
.setting-group input[type="text"], .setting-group textarea, .setting-group select { width: 100%; padding: 8px 10px; background: var(--input-bg); color: var(--text); border: 1px solid var(--border); border-radius: 6px; font-family: inherit; font-size: 0.88em; transition: border-color 0.2s; }
.setting-group input[type="text"]:focus, .setting-group textarea:focus { outline: none; border-color: var(--accent2); }
.setting-group textarea { min-height: 60px; resize: vertical; }
.range-row { display: flex; align-items: center; gap: 10px; margin-top: 4px; }
.range-row input[type="range"] { flex: 1; accent-color: var(--gold); height: 4px; }
.range-row .range-val { font-size: 0.82em; color: var(--gold); min-width: 42px; text-align: right; font-variant-numeric: tabular-nums; }
.toggle-row { display: flex; align-items: center; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid rgba(46, 42, 69, 0.5); }
.toggle-row:last-child { border-bottom: none; }
.toggle-row span { font-size: 0.82em; }
.toggle { position: relative; width: 38px; height: 20px; cursor: pointer; }
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle .slider { position: absolute; inset: 0; background: var(--border); border-radius: 10px; transition: background 0.2s; }
.toggle .slider::before { content: ''; position: absolute; width: 14px; height: 14px; left: 3px; top: 3px; background: var(--text); border-radius: 50%; transition: transform 0.2s; }
.toggle input:checked + .slider { background: var(--green); }
.toggle input:checked + .slider::before { transform: translateX(18px); }
.auto-cards-status { font-size: 0.78em; padding: 8px 12px; background: var(--input-bg); border-radius: 6px; margin-top: 10px; color: var(--text-dim); border: 1px solid var(--border); }
.toast { position: fixed; bottom: 24px; right: 24px; background: var(--bg3); color: var(--text); padding: 12px 20px; border-radius: 10px; border: 1px solid var(--gold); z-index: 200; font-size: 0.85em; opacity: 0; transition: opacity 0.3s; pointer-events: none; box-shadow: 0 8px 24px var(--shadow); }
.toast.visible { opacity: 1; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
@media (max-width: 768px) {
.sidebar { display: none; }
.story-area, .input-area { padding-left: 16px; padding-right: 16px; }
.story-block .edit-btn { right: 4px; top: 4px; transform: none; }
}
</style>
</head>
<body>
<div class="app">
<div class="sidebar">
<div class="sidebar-header"><h1>AICastle</h1></div>
<div class="sidebar-actions">
<button class="primary" onclick="showModal('newGameModal')">New Adventure</button>
<button onclick="showModal('importModal')">Import from AI Dungeon</button>
</div>
<div class="game-list" id="gameList"></div>
</div>
<div class="main">
<div class="top-bar" id="topBar" style="display:none;">
<span class="game-title" id="gameTitle">Untitled</span>
<select class="model-select" id="modelSelect" onchange="switchModel(this.value)"></select>
<button id="btnWorld" onclick="togglePanel('world')">World</button>
<button id="btnSettings" onclick="togglePanel('settings')">Settings</button>
</div>
<div id="welcomeScreen" class="welcome"><h2>AICastle</h2></div>
<div id="storyArea" class="story-area" style="display:none;"></div>
<div id="inputArea" class="input-area" style="display:none;">
<div class="action-buttons">
<button class="active" data-type="do" onclick="setActionType('do')">Do</button>
<button data-type="say" onclick="setActionType('say')">Say</button>
<button data-type="story" onclick="setActionType('story')">Story</button>
</div>
<div class="input-row">
<textarea id="actionInput" placeholder="What do you do?" rows="1" onkeydown="handleInputKey(event)"></textarea>
<button class="send-btn" id="sendBtn" onclick="sendAction()">Send</button>
</div>
<div class="input-controls">
<button onclick="sendContinue()">Continue</button>
<button onclick="retryAction()">Retry</button>
<button onclick="undoAction()">Undo</button>
</div>
</div>
</div>
<!-- Right Panel -->
<div class="panel" id="rightPanel">
<div class="panel-tabs" id="panelTabs"></div>
<div class="panel-header">
<h3 id="panelTitle">Settings</h3>
</div>
<!-- Model Tab -->
<div class="panel-body panel-tab-content" id="tabModel">
<div class="setting-group">
<h4>Generation</h4>
<label title="Controls randomness. Higher = more creative and unpredictable, lower = more focused and deterministic.">Temperature</label>
<div class="range-row">
<input type="range" id="msTemp" min="0" max="2" step="0.05" oninput="updateRange(this)" onchange="saveModelSettings()">
<span class="range-val" id="msTempVal"></span>
</div>
<label title="Nucleus sampling. Only considers tokens whose cumulative probability is within this threshold. Lower = less variety.">Top P</label>
<div class="range-row">
<input type="range" id="msTopP" min="0" max="1" step="0.05" oninput="updateRange(this)" onchange="saveModelSettings()">
<span class="range-val" id="msTopPVal"></span>
</div>
<label title="Limits the model to the top K most likely tokens at each step. Lower = more predictable.">Top K</label>
<div class="range-row">
<input type="range" id="msTopK" min="1" max="100" step="1" oninput="updateRange(this)" onchange="saveModelSettings()">
<span class="range-val" id="msTopKVal"></span>
</div>
<label title="Penalizes repeated words and phrases. Higher = less repetition but can sound unnatural.">Repeat Penalty</label>
<div class="range-row">
<input type="range" id="msRepeat" min="1" max="2" step="0.05" oninput="updateRange(this)" onchange="saveModelSettings()">
<span class="range-val" id="msRepeatVal"></span>
</div>
<label title="Maximum number of tokens the model can generate per response. Higher = longer responses but slower.">Max Tokens</label>
<div class="range-row">
<input type="range" id="msMaxTok" min="128" max="4096" step="64" oninput="updateRange(this)" onchange="saveModelSettings()">
<span class="range-val" id="msMaxTokVal"></span>
</div>
<label title="Total token budget for the prompt + response. Must fit your model's limit. Higher uses more VRAM and is slower.">Context Window</label>
<div class="range-row">
<input type="range" id="msCtxWin" min="2048" max="32768" step="1024" oninput="updateRange(this)" onchange="saveModelSettings()">
<span class="range-val" id="msCtxWinVal"></span>
</div>
<label title="How many recent story actions are sent to the model. Cards and memory always provide long-term context. Lower = faster.">History Actions</label>
<div class="range-row">
<input type="range" id="msHistory" min="10" max="200" step="5" oninput="updateRange(this)" onchange="saveModelSettings()">
<span class="range-val" id="msHistoryVal"></span>
</div>
</div>
</div>
<!-- Plot Tab -->
<div class="panel-body panel-tab-content" id="tabPlot" style="display:none;">
<div class="setting-group">
<h4>Adventure</h4>
<label>Title</label>
<input type="text" id="settingsTitle" onchange="saveSettings()">
</div>
<div class="setting-group">
<h4>Memory</h4>
<textarea id="settingsMemory" rows="4" placeholder="Persistent context the AI always sees..." onchange="saveSettings()"></textarea>
</div>
<div class="setting-group">
<h4>Author's Note</h4>
<textarea id="settingsAuthorsNote" rows="3" placeholder="Style and tone guidance..." onchange="saveSettings()"></textarea>
</div>
<div class="setting-group" style="margin-top:16px;">
<button style="width:100%;padding:9px;background:var(--input-bg);color:var(--text);border:1px solid var(--border);border-radius:8px;cursor:pointer;font-size:0.85em;transition:all 0.2s;" onmouseover="this.style.borderColor='var(--gold)'" onmouseout="this.style.borderColor='var(--border)'" onclick="exportCurrentGame()">Export Adventure</button>
</div>
<div class="setting-group" style="margin-top:8px;">
<button style="width:100%;padding:9px;background:rgba(214,59,90,0.15);color:var(--accent);border:1px solid rgba(214,59,90,0.3);border-radius:8px;cursor:pointer;font-size:0.85em;transition:all 0.2s;" onmouseover="this.style.background='var(--accent)';this.style.color='white'" onmouseout="this.style.background='rgba(214,59,90,0.15)';this.style.color='var(--accent)'" onclick="deleteCurrentGame()">Delete Adventure</button>
</div>
</div>
<!-- Cards Tab -->
<div class="panel-body panel-tab-content" id="tabCards" style="display:none;">
<div class="setting-group">
<h4 style="display:flex;justify-content:space-between;align-items:center;">
Story Cards <span class="badge" id="cardCount">0</span>
<span style="font-size:0.8em;font-weight:normal;color:var(--text-dim);" id="cardCountLabel">0 cards</span>
</h4>
<div style="display:flex;gap:8px;margin-bottom:8px;">
<button style="flex:1;padding:7px;background:var(--input-bg);border:1px solid var(--border);color:var(--text);border-radius:8px;cursor:pointer;font-size:0.82em;transition:all 0.2s;" onmouseover="this.style.borderColor='var(--gold)'" onmouseout="this.style.borderColor='var(--border)'" onclick="showModal('addWIModal')">+ Add</button>
<button style="flex:1;padding:7px;background:rgba(124,77,255,0.15);border:1px solid rgba(124,77,255,0.3);color:var(--accent2);border-radius:8px;cursor:pointer;font-size:0.82em;transition:all 0.2s;" onmouseover="this.style.background='var(--accent2)';this.style.color='white'" onmouseout="this.style.background='rgba(124,77,255,0.15)';this.style.color='var(--accent2)'" onclick="runAutoCardsDetect()">Auto-Detect</button>
</div>
<input type="text" id="cardSearch" placeholder="Search cards..." style="width:100%;padding:6px 8px;background:var(--input-bg);color:var(--text);border:1px solid var(--border);border-radius:4px;font-size:0.85em;margin-bottom:8px;" oninput="filterCards()">
<div id="worldInfoList"></div>
</div>
</div>
<!-- Auto-Cards Tab -->
<div class="panel-body panel-tab-content" id="tabAutocards" style="display:none;">
<div class="setting-group">
<h4>Auto-Cards</h4>
<div class="toggle-row"><span>Enabled</span><label class="toggle"><input type="checkbox" id="acEnabled" onchange="saveAutoCards()"><span class="slider"></span></label></div>
<div class="toggle-row"><span>Memory updates</span><label class="toggle"><input type="checkbox" id="acMemoryUpdates" onchange="saveAutoCards()"><span class="slider"></span></label></div>
<div class="toggle-row"><span>Bulleted format</span><label class="toggle"><input type="checkbox" id="acBulleted" onchange="saveAutoCards()"><span class="slider"></span></label></div>
<div class="toggle-row"><span>Exclude ALL-CAPS</span><label class="toggle"><input type="checkbox" id="acExcludeAllcaps" onchange="saveAutoCards()"><span class="slider"></span></label></div>
<div class="toggle-row"><span>Detect from player</span><label class="toggle"><input type="checkbox" id="acDetectPlayer" onchange="saveAutoCards()"><span class="slider"></span></label></div>
</div>
<div class="setting-group">
<label>Cooldown turns</label>
<div class="range-row">
<input type="range" id="acCooldown" min="1" max="50" step="1" oninput="updateRange(this)" onchange="saveAutoCards()">
<span class="range-val" id="acCooldownVal"></span>
</div>
<label>Max entry length</label>
<div class="range-row">
<input type="range" id="acMaxLength" min="100" max="2000" step="50" oninput="updateRange(this)" onchange="saveAutoCards()">
<span class="range-val" id="acMaxLengthVal"></span>
</div>
<label>Memory bank length</label>
<div class="range-row">
<input type="range" id="acMemoryLength" min="500" max="5000" step="250" oninput="updateRange(this)" onchange="saveAutoCards()">
<span class="range-val" id="acMemoryLengthVal"></span>
</div>
<label>Min turns for detection</label>
<div class="range-row">
<input type="range" id="acMinTurns" min="1" max="20" step="1" oninput="updateRange(this)" onchange="saveAutoCards()">
<span class="range-val" id="acMinTurnsVal"></span>
</div>
</div>
<div class="auto-cards-status" id="acStatus"></div>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<!-- New Game Modal -->
<div class="modal-overlay" id="newGameModal">
<div class="modal">
<h2>New Adventure</h2>
<label>Model</label><select id="newGameModel"></select>
<label>Title</label><input id="newGameTitle" placeholder="My Adventure">
<label>Starting Prompt</label><textarea id="newGamePrompt" placeholder="You are a knight in a dark fantasy world..."></textarea>
<label>Memory (optional)</label><textarea id="newGameMemory" rows="3" placeholder="Persistent context..."></textarea>
<label>Author's Note (optional)</label><textarea id="newGameAuthorsNote" rows="2" placeholder="Style guidance..."></textarea>
<div class="btn-row"><button class="cancel" onclick="closeModal('newGameModal')">Cancel</button><button class="confirm" onclick="createGame()">Create</button></div>
</div>
</div>
<!-- Import Modal -->
<div class="modal-overlay" id="importModal">
<div class="modal">
<h2>Import from AI Dungeon</h2>
<label>Files (adventure text + story cards)</label>
<input type="file" id="importFile" accept=".json,.txt,.md,.zip" multiple style="margin-top:4px;">
<div class="btn-row"><button class="cancel" onclick="closeModal('importModal')">Cancel</button><button class="confirm" onclick="importFile()">Import</button></div>
</div>
</div>
<!-- Add Card Modal -->
<div class="modal-overlay" id="addWIModal">
<div class="modal">
<h2>Add Story Card</h2>
<label>Title</label><input id="wiTitle" placeholder="Character, location...">
<label>Keys (comma-separated triggers)</label><input id="wiKeys" placeholder="dragon, Aldric">
<label>Entry</label><textarea id="wiEntry" rows="4" placeholder="Information..."></textarea>
<div class="btn-row"><button class="cancel" onclick="closeModal('addWIModal')">Cancel</button><button class="confirm" onclick="addWorldInfo()">Add</button></div>
</div>
</div>
<!-- Edit Card Modal -->
<div class="modal-overlay" id="editCardModal">
<div class="modal">
<h2>Edit Story Card</h2>
<label>Title</label><input id="ecTitle">
<label>Keys</label><input id="ecKeys">
<label>Entry</label><textarea id="ecEntry" rows="6"></textarea>
<input type="hidden" id="ecIndex">
<div class="btn-row"><button class="cancel" onclick="closeModal('editCardModal')">Cancel</button><button class="confirm" onclick="saveCardEdit()">Save</button></div>
</div>
</div>
<!-- Edit Action Modal -->
<div class="modal-overlay" id="editModal">
<div class="modal">
<h2>Edit</h2>
<textarea id="editText" rows="8"></textarea>
<input type="hidden" id="editIndex">
<div class="btn-row"><button class="cancel" onclick="closeModal('editModal')">Cancel</button><button class="confirm" onclick="saveEdit()">Save</button></div>
</div>
</div>
<!-- Auto-Detect Modal -->
<div class="modal-overlay" id="reviewCardModal">
<div class="modal">
<h2>Review Card <span id="rcProgress" style="font-size:0.7em;color:var(--text-dim);"></span></h2>
<label>Title</label>
<input type="text" id="rcTitle" style="width:100%;margin-bottom:8px;">
<label>Keys</label>
<input type="text" id="rcKeys" style="width:100%;margin-bottom:8px;">
<label>Entry</label>
<textarea id="rcEntry" rows="8" style="width:100%;"></textarea>
<div class="btn-row" style="margin-top:12px;">
<button class="cancel" onclick="reviewReject()">Reject</button>
<button style="background:var(--input-bg);color:var(--text-dim);border:1px solid var(--border);border-radius:8px;padding:8px 18px;cursor:pointer;font-size:0.9em;transition:all 0.2s;" onmouseover="this.style.color='var(--text)';this.style.borderColor='var(--text-dim)'" onmouseout="this.style.color='var(--text-dim)';this.style.borderColor='var(--border)'" onclick="reviewRegen()">Regenerate</button>
<button class="confirm" onclick="reviewAccept()">Accept</button>
</div>
</div>
</div>
<script>
// ---------------------------------------------------------------------------
// Global state
// ---------------------------------------------------------------------------
let currentGame = null; // full GameState of the open adventure
let actionType = 'do'; // selected input mode: do | say | story
let streaming = false; // true while an AI response is being generated
let models = []; // Ollama models for the dropdowns
let activeTab = 'settings';
// The right panel has two groups (World / Settings), each with its own tabs.
const PANEL_GROUPS = {
world: {
tabs: {
plot: { label: 'Plot', title: 'Plot' },
cards: { label: 'Cards', title: 'Story Cards' },
},
default: 'plot',
},
settings: {
tabs: {
model: { label: 'Model', title: 'Model Tuning' },
autocards: { label: 'Auto-Cards', title: 'Auto-Cards' },
},
default: 'model',
},
};
// ---------------------------------------------------------------------------
// Small helpers
// ---------------------------------------------------------------------------
// Mirror a range slider's value into its "<id>Val" label.
function updateRange(el) {
const valEl = document.getElementById(el.id + 'Val');
if (valEl) valEl.textContent = el.value;
}
// Normalize a comma-separated trigger-key list: trim, strip punctuation,
// dedupe case-insensitively, capitalize.
function cleanKeys(raw) {
if (!raw) return '';
const parts = raw.split(',').map(k => k.trim()).filter(k => k.length > 0);
const seen = new Set();
const clean = [];
for (const p of parts) {
const base = p.replace(/['".,?!;:()\[\]—]/g, '').trim().toLowerCase();
if (base && !seen.has(base)) { seen.add(base); clean.push(base.charAt(0).toUpperCase() + base.slice(1)); }
}
return clean.join(', ');
}
// Strip leftover {title: ...} braces from imported card entries.
function cleanEntry(text) {
if (!text) return '';
return text.replace(/^\{[^}]*\}\s*[-–—\n]*/i, '').replace(/\{[^}]*\}\s*$/,'').trim();
}
// ---------------------------------------------------------------------------
// Data loading & rendering
// ---------------------------------------------------------------------------
async function init() { await loadModels(); await loadGames(); }
async function loadModels() {
try { const r = await fetch('/api/models'); const d = await r.json(); models = d.models; populateModelSelects(); } catch(e) {}
}
function populateModelSelects() {
['modelSelect','newGameModel'].forEach(id => {
const el = document.getElementById(id);
if (el) el.innerHTML = models.map(m => `<option value="${m.name}">${m.name}</option>`).join('');
});
}
async function loadGames() {
const r = await fetch('/api/games'); const d = await r.json();
document.getElementById('gameList').innerHTML = d.games.map(g => `
<div class="game-item ${currentGame?.id===g.id?'active':''}" onclick="loadGame('${g.id}')">
<div class="title">${esc(g.title)}</div>
<div class="meta">${g.action_count} actions · ${g.model}</div>
</div>`).join('');
}
async function loadGame(id) {
const r = await fetch(`/api/game/${id}`); const d = await r.json();
currentGame = d.game;
if (!currentGame.auto_cards) currentGame.auto_cards = {};
if (!currentGame.model_settings) currentGame.model_settings = {};
document.getElementById('welcomeScreen').style.display = 'none';
document.getElementById('topBar').style.display = 'flex';
document.getElementById('storyArea').style.display = 'block';
document.getElementById('inputArea').style.display = 'block';
document.getElementById('gameTitle').textContent = currentGame.title;
document.getElementById('cardCount').textContent = currentGame.world_info.length;
if (models.length) document.getElementById('modelSelect').value = currentGame.model;
document.getElementById('settingsTitle').value = currentGame.title;
document.getElementById('settingsMemory').value = currentGame.memory;
document.getElementById('settingsAuthorsNote').value = currentGame.authors_note;
loadModelSettingsUI();
loadAutoCardsUI();
renderStory();
renderWorldInfo();
loadGames();
setTimeout(() => { const a = document.getElementById('storyArea'); a.scrollTop = a.scrollHeight; }, 50);
}
function loadModelSettingsUI() {
const ms = currentGame.model_settings;
setRange('msTemp', ms.temperature ?? 0.8);
setRange('msTopP', ms.top_p ?? 0.9);
setRange('msTopK', ms.top_k ?? 40);
setRange('msRepeat', ms.repeat_penalty ?? 1.1);
setRange('msMaxTok', ms.max_tokens ?? 1024);
setRange('msCtxWin', ms.context_window ?? 8192);
setRange('msHistory', ms.history_actions ?? 40);
}
function setRange(id, val) {
const el = document.getElementById(id);
el.value = val;
document.getElementById(id + 'Val').textContent = val;
}
function loadAutoCardsUI() {
const ac = currentGame.auto_cards;
document.getElementById('acEnabled').checked = ac.enabled !== false;
document.getElementById('acMemoryUpdates').checked = ac.memory_updates !== false;
document.getElementById('acBulleted').checked = ac.bulleted_format !== false;
document.getElementById('acExcludeAllcaps').checked = ac.exclude_allcaps !== false;
document.getElementById('acDetectPlayer').checked = ac.detect_from_player === true;
setRange('acCooldown', ac.cooldown_turns || 22);
setRange('acMaxLength', ac.max_entry_length || 750);
setRange('acMemoryLength', ac.memory_bank_length || 2750);
setRange('acMinTurns', ac.min_turns_for_detection || 5);
document.getElementById('acStatus').textContent = `Turns since last card: ${ac.turns_since_last_card||0} / ${ac.cooldown_turns||22}`;
}
function renderStory() {
const area = document.getElementById('storyArea');
if (!currentGame) return;
let html = '';
if (currentGame.story_summary) {
html += `<div class="story-summary">${esc(currentGame.story_summary)}</div>`;
}
html += currentGame.actions.map((a,i) => {
const isP = a.type !== 'continue';
const lbl = isP ? a.type.toUpperCase() : '';
return `<div class="story-block ${isP ? 'player ' + a.type : 'ai'}">
${lbl?`<div class="action-type">${lbl}</div>`:''}
<span class="block-text">${esc(a.text)}</span>
<button class="edit-btn" onclick="showEditModal(${i})">edit</button>
</div>`;
}).join('');
area.innerHTML = html;
}
function renderWorldInfo() {
const list = document.getElementById('worldInfoList');
if (!currentGame) return;
const count = currentGame.world_info.length;
document.getElementById('cardCount').textContent = count;
document.getElementById('cardCountLabel').textContent = count + ' card' + (count!==1?'s':'');
list.innerHTML = currentGame.world_info.map((wi,i) => `
<div class="wi-card" id="wiCard${i}" onclick="toggleCardExpand(${i})">
<div class="wi-header">
<div class="wi-title">${esc(wi.title || 'Untitled')}</div>
</div>
<div class="wi-keys">${esc(cleanKeys(wi.keys))}</div>
<div class="wi-entry">${esc(cleanEntry(wi.entry))}</div>
<div class="wi-actions" onclick="event.stopPropagation()">
<button class="edit-card" onclick="showEditCardModal(${i})">Edit</button>
<button onclick="removeWorldInfo(${i})">Remove</button>
</div>
</div>`).join('');
}
function toggleCardExpand(i) { document.getElementById('wiCard'+i).classList.toggle('expanded'); }
function filterCards() {
const q = document.getElementById('cardSearch').value.toLowerCase();
document.querySelectorAll('.wi-card').forEach((card,i) => {
const wi = currentGame.world_info[i];
card.style.display = (!q || (wi.title||'').toLowerCase().includes(q) || (wi.entry||'').toLowerCase().includes(q)) ? '' : 'none';
});
}
// ---------------------------------------------------------------------------
// Right panel & tabs
// ---------------------------------------------------------------------------
let activePanel = null;
const ALL_TAB_IDS = ['tabPlot', 'tabCards', 'tabModel', 'tabAutocards'];
function buildPanelTabs(panelName, activeTabKey) {
const group = PANEL_GROUPS[panelName];
if (!group) return;
const container = document.getElementById('panelTabs');
container.innerHTML = Object.entries(group.tabs).map(([k,v]) =>
`<button data-tab="${k}" class="${k===activeTabKey?'active':''}" onclick="switchTab('${k}')">${v.label}</button>`
).join('');
}
function tabIdFromKey(key) {
const map = { plot:'tabPlot', cards:'tabCards', model:'tabModel', autocards:'tabAutocards' };
return map[key];
}
function panelForTab(tabKey) {
for (const [pn, pg] of Object.entries(PANEL_GROUPS)) {
if (tabKey in pg.tabs) return pn;
}
return null;
}
function switchTab(tab) {
activeTab = tab;
const pn = panelForTab(tab);
if (pn) {
activePanel = pn;
buildPanelTabs(pn, tab);
document.getElementById('panelTitle').textContent = PANEL_GROUPS[pn].tabs[tab]?.title || '';
}
ALL_TAB_IDS.forEach(id => { document.getElementById(id).style.display = 'none'; });
const el = document.getElementById(tabIdFromKey(tab));
if (el) el.style.display = '';
updateTopBarHighlight();
}
function updateTopBarHighlight() {
const btnWorld = document.getElementById('btnWorld');
const btnSettings = document.getElementById('btnSettings');
const panel = document.getElementById('rightPanel');
const open = panel.classList.contains('active');
btnWorld?.classList.toggle('active', open && activePanel === 'world');
btnSettings?.classList.toggle('active', open && activePanel === 'settings');
}
function togglePanel(name) {
const panel = document.getElementById('rightPanel');
if (name === null) { panel.classList.remove('active'); updateTopBarHighlight(); return; }
const wasActive = panel.classList.contains('active') && activePanel === name;
if (wasActive) { panel.classList.remove('active'); updateTopBarHighlight(); return; }
const group = PANEL_GROUPS[name];
const defaultTab = group ? group.default : name;
switchTab(defaultTab);
panel.classList.add('active');
updateTopBarHighlight();
}
function setActionType(type) {
actionType = type;
document.querySelectorAll('.action-buttons button').forEach(b => b.classList.toggle('active', b.dataset.type === type));
const input = document.getElementById('actionInput');
input.placeholder = {do:'What do you do?', say:'What do you say?', story:'Describe what happens...'}[type]||'';
input.focus();
}
function handleInputKey(e) { if (e.key==='Enter' && !e.shiftKey) { e.preventDefault(); sendAction(); } }
// ---------------------------------------------------------------------------
// Story actions & SSE streaming
// ---------------------------------------------------------------------------
// Lock/unlock the input controls while a response is generating.
function setStreaming(on) {
streaming = on;
document.getElementById('sendBtn').disabled = on;
document.querySelectorAll('.input-controls button').forEach(b => b.style.opacity = on ? '0.4' : '');
}
async function sendAction() {
if (streaming || !currentGame) return;
const input = document.getElementById('actionInput');
const text = input.value.trim();
if (!text) return;
input.value = '';
setStreaming(true);
currentGame.actions.push({type:actionType, text, timestamp:Date.now()/1000});
renderStory(); scrollToBottom();
await streamResponse('/api/action', {game_id:currentGame.id, action_type:actionType, text, model:document.getElementById('modelSelect').value});
triggerAutoCards();
}
async function sendContinue() {
if (streaming || !currentGame) return;
setStreaming(true);
await streamResponse('/api/action', {game_id:currentGame.id, action_type:'continue', text:'', model:document.getElementById('modelSelect').value});
triggerAutoCards();
}
async function retryAction() {
if (streaming || !currentGame) return;
setStreaming(true);
if (currentGame.actions.length && currentGame.actions[currentGame.actions.length-1].type==='continue') { currentGame.actions.pop(); renderStory(); }
await streamResponse('/api/action/retry', {game_id:currentGame.id, action_type:'continue', text:'', model:document.getElementById('modelSelect').value});
}
// POST to an SSE endpoint and stream the AI text into a new story block.
// Events: {chunk} appends text, {error} shows a message, {done} commits the
// action to local state. An empty failed block is removed entirely.
async function streamResponse(url, body) {
const area = document.getElementById('storyArea');
const block = document.createElement('div');
block.className = 'story-block ai';
block.innerHTML = '<span class="block-text"></span><span class="streaming-cursor"></span>';
area.appendChild(block); scrollToBottom();
const textEl = block.querySelector('.block-text');
let gotContent = false;
try {
const resp = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
if (!resp.ok) {
textEl.textContent = `[Server error: ${resp.status} ${resp.statusText}]`;
} else {
const reader = resp.body.getReader(); const decoder = new TextDecoder();
let fullText='', buffer='';
while (true) {
const {done, value} = await reader.read(); if (done) break;
buffer += decoder.decode(value, {stream:true});
const lines = buffer.split('\n'); buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try {
const data = JSON.parse(line.slice(6));
if (data.retry) { textEl.textContent = ''; fullText = ''; gotContent = false; showToast(data.retry); }
if (data.chunk) { fullText += data.chunk; textEl.textContent = fullText; scrollToBottom(); gotContent = true; }
if (data.error) { textEl.textContent = `[${data.error}]`; gotContent = true; }
if (data.done) currentGame.actions.push({type:'continue', text:fullText, timestamp:Date.now()/1000});
} catch(e){}
}
}
}
} catch(e) { textEl.textContent += `\n[Connection error: ${e.message}]`; }
block.querySelector('.streaming-cursor')?.remove();
if (!gotContent && !textEl.textContent) { block.remove(); }
else {
const editBtn = document.createElement('button');
editBtn.className = 'edit-btn'; editBtn.textContent = 'edit';
editBtn.onclick = () => showEditModal(currentGame.actions.length-1);
block.appendChild(editBtn);
}
setStreaming(false);
}
async function triggerAutoCards() {
if (!currentGame?.auto_cards?.enabled) return;
try {
const r = await fetch('/api/auto-cards/detect', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({game_id:currentGame.id, action_type:'', text:'', model:currentGame.model})});
const d = await r.json();
if (d.titles?.length > 0) {
await generateAllPreviews(d.titles);
}
} catch(e){}
}
async function runAutoCardsDetect() {
if (!currentGame) return;
showToast('Scanning...');
try {
const r = await fetch('/api/auto-cards/detect', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({game_id:currentGame.id, action_type:'', text:'', model:currentGame.model})});
const d = await r.json();
if (d.skipped) { showToast('Skipped: '+d.skipped); return; }
if (!d.titles?.length) { showToast('No new titles'); return; }
await generateAllPreviews(d.titles);
} catch(e) { showToast('Failed: '+e.message); }
}
// ---------------------------------------------------------------------------
// Auto-cards review flow: detect titles → generate all previews in parallel
// → present them one at a time for Accept / Regenerate / Reject
// ---------------------------------------------------------------------------
let pendingCards = []; // previews waiting for review
let pendingCardIndex = 0; // position in pendingCards
async function generateAllPreviews(titles) {
showToast(`Generating ${titles.length} card(s) in parallel...`);
const r = await fetch('/api/auto-cards/preview-batch', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({game_id:currentGame.id, titles, model:currentGame.model})});
const d = await r.json();
pendingCards = d.cards;
pendingCardIndex = 0;
showNextReview();
}
function showNextReview() {
if (pendingCardIndex >= pendingCards.length) {
closeModal('reviewCardModal');
showToast('All cards reviewed');
return;
}
const card = pendingCards[pendingCardIndex];
document.getElementById('rcTitle').value = card.title;
document.getElementById('rcKeys').value = card.keys;
document.getElementById('rcEntry').value = card.entry;
const label = card.is_update ? 'Update' : 'New';
document.getElementById('rcProgress').textContent = `${pendingCardIndex+1} / ${pendingCards.length} (${label})`;
showModal('reviewCardModal');
}
async function reviewAccept() {
const title = document.getElementById('rcTitle').value;
const keys = document.getElementById('rcKeys').value;
const entry = document.getElementById('rcEntry').value;
const card = pendingCards[pendingCardIndex];
if (card.is_update && card.existing_index >= 0) {
await fetch('/api/world-info/edit', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({game_id:currentGame.id, index:card.existing_index, title, keys, entry})});
} else {
await fetch('/api/world-info/add', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({game_id:currentGame.id, title, keys, entry})});
}
await loadGame(currentGame.id);
renderWorldInfo();
pendingCardIndex++;
showNextReview();
}
async function reviewReject() {
pendingCardIndex++;
showNextReview();
}
async function reviewRegen() {
const title = document.getElementById('rcTitle').value;
showToast(`Regenerating card for "${title}"...`);
const fd = new FormData(); fd.append('game_id',currentGame.id); fd.append('title',title); fd.append('model',currentGame.model);
const r = await fetch('/api/auto-cards/preview', {method:'POST', body:fd});
const card = await r.json();
pendingCards[pendingCardIndex] = card;
document.getElementById('rcTitle').value = card.title;
document.getElementById('rcKeys').value = card.keys;
document.getElementById('rcEntry').value = card.entry;
}
async function undoAction() {
if (streaming||!currentGame) return;
const r = await fetch('/api/action/undo', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({game_id:currentGame.id, action_type:'', text:''})});
currentGame.actions = (await r.json()).actions; renderStory();
}
function scrollToBottom() { const a = document.getElementById('storyArea'); a.scrollTop = a.scrollHeight; }
// ---------------------------------------------------------------------------
// Settings persistence (model, plot, sliders, auto-cards)
// ---------------------------------------------------------------------------
async function switchModel(model) {
if (!currentGame) return;
await fetch('/api/settings', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({game_id:currentGame.id, model})});
currentGame.model = model;
}
async function saveSettings() {
if (!currentGame) return;
const title = document.getElementById('settingsTitle').value;
const memory = document.getElementById('settingsMemory').value;
const authorsNote = document.getElementById('settingsAuthorsNote').value;
await fetch('/api/settings', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({game_id:currentGame.id, title, memory, authors_note:authorsNote})});
currentGame.title=title; currentGame.memory=memory; currentGame.authors_note=authorsNote;
document.getElementById('gameTitle').textContent = title; loadGames();
}
async function saveModelSettings() {
if (!currentGame) return;
const ms = {
temperature: parseFloat(document.getElementById('msTemp').value),
top_p: parseFloat(document.getElementById('msTopP').value),
top_k: parseInt(document.getElementById('msTopK').value),
repeat_penalty: parseFloat(document.getElementById('msRepeat').value),
max_tokens: parseInt(document.getElementById('msMaxTok').value),
context_window: parseInt(document.getElementById('msCtxWin').value),
history_actions: parseInt(document.getElementById('msHistory').value),
};
await fetch('/api/settings', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({game_id:currentGame.id, model_settings:ms})});
currentGame.model_settings = ms;
}
async function saveAutoCards() {
if (!currentGame) return;
const ac = {
enabled: document.getElementById('acEnabled').checked,
memory_updates: document.getElementById('acMemoryUpdates').checked,
bulleted_format: document.getElementById('acBulleted').checked,
exclude_allcaps: document.getElementById('acExcludeAllcaps').checked,
detect_from_player: document.getElementById('acDetectPlayer').checked,
cooldown_turns: parseInt(document.getElementById('acCooldown').value),
max_entry_length: parseInt(document.getElementById('acMaxLength').value),
memory_bank_length: parseInt(document.getElementById('acMemoryLength').value),
min_turns_for_detection: parseInt(document.getElementById('acMinTurns').value),
};
await fetch('/api/settings', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({game_id:currentGame.id, auto_cards:ac})});
currentGame.auto_cards = {...currentGame.auto_cards, ...ac};
}
async function exportCurrentGame() {
if (!currentGame) return;
const a = document.createElement('a');
a.href = `/api/game/${currentGame.id}/export`;
a.download = '';
document.body.appendChild(a);
a.click();
a.remove();
}
async function deleteCurrentGame() {
if (!currentGame||!confirm('Delete this adventure?')) return;
await fetch(`/api/game/${currentGame.id}`, {method:'DELETE'});
currentGame=null;
document.getElementById('welcomeScreen').style.display='flex';
document.getElementById('topBar').style.display='none';
document.getElementById('storyArea').style.display='none';
document.getElementById('inputArea').style.display='none';
togglePanel(null); loadGames();
}
// ---------------------------------------------------------------------------
// Modals, import/export, misc
// ---------------------------------------------------------------------------
function showModal(id) { document.getElementById(id).classList.add('active'); }
function closeModal(id) { document.getElementById(id).classList.remove('active'); }
function showEditModal(index) {
document.getElementById('editText').value = currentGame.actions[index].text;
document.getElementById('editIndex').value = index;
showModal('editModal');
}
function showEditCardModal(index) {
const wi = currentGame.world_info[index];
document.getElementById('ecTitle').value = wi.title || '';
document.getElementById('ecKeys').value = wi.keys || '';
document.getElementById('ecEntry').value = wi.entry || '';
document.getElementById('ecIndex').value = index;
showModal('editCardModal');
}
async function saveCardEdit() {
const index = parseInt(document.getElementById('ecIndex').value);
const title = document.getElementById('ecTitle').value;
const keys = document.getElementById('ecKeys').value;
const entry = document.getElementById('ecEntry').value;
await fetch('/api/world-info/edit', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({game_id:currentGame.id, index, title, keys, entry})});
currentGame.world_info[index] = {...currentGame.world_info[index], title, keys, entry};
renderWorldInfo(); closeModal('editCardModal');
}
async function saveEdit() {
const index = parseInt(document.getElementById('editIndex').value);
const text = document.getElementById('editText').value;
await fetch('/api/action/edit', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({game_id:currentGame.id, action_index:index, new_text:text})});
currentGame.actions[index].text = text; renderStory(); closeModal('editModal');
}
async function createGame() {
const model = document.getElementById('newGameModel').value;
const title = document.getElementById('newGameTitle').value || 'Untitled Adventure';
const prompt = document.getElementById('newGamePrompt').value;
const memory = document.getElementById('newGameMemory').value;
const authorsNote = document.getElementById('newGameAuthorsNote').value;
closeModal('newGameModal');
const r = await fetch('/api/games/new', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({model, title, prompt, memory, authors_note:authorsNote})});
currentGame = (await r.json()).game; await loadGame(currentGame.id);
}
async function importFile() {
const fi = document.getElementById('importFile');
if (!fi.files.length) return;
const fd = new FormData();
for (const f of fi.files) fd.append('files', f);
fd.append('model', models.length ? models[0].name : 'gemma3:12b');
closeModal('importModal');
const r = await fetch('/api/import/aidungeon', {method:'POST', body:fd});
const d = await r.json();
showToast(`Imported: ${d.action_count} actions, ${d.card_count} cards`);
await loadGame(d.game.id); loadGames();
}
async function addWorldInfo() {
if (!currentGame) return;
const title = document.getElementById('wiTitle').value;
const keys = document.getElementById('wiKeys').value;
const entry = document.getElementById('wiEntry').value;
if (!keys||!entry) return;
await fetch('/api/world-info/add', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({game_id:currentGame.id, title, keys, entry})});
currentGame.world_info.push({title, keys, entry}); renderWorldInfo(); closeModal('addWIModal');
document.getElementById('wiTitle').value=''; document.getElementById('wiKeys').value=''; document.getElementById('wiEntry').value='';
}
async function removeWorldInfo(index) {
if (!currentGame) return;
const fd = new FormData(); fd.append('game_id',currentGame.id); fd.append('index',index);
await fetch('/api/world-info/remove', {method:'POST', body:fd});
currentGame.world_info.splice(index,1); renderWorldInfo();
}
function showToast(msg) {
const t = document.getElementById('toast'); t.textContent=msg; t.classList.add('visible');
setTimeout(()=>t.classList.remove('visible'), 3000);
}
function esc(str) { const d=document.createElement('div'); d.textContent=str||''; return d.innerHTML; }
document.addEventListener('input', e => {
if (e.target.id==='actionInput') { e.target.style.height='auto'; e.target.style.height=Math.min(e.target.scrollHeight,200)+'px'; }
});
document.querySelectorAll('.modal-overlay').forEach(o => o.addEventListener('click', e => { if(e.target===o) o.classList.remove('active'); }));
init();
</script>
</body>
</html>
