dm / commit
feat: add API key input and direct Anthropic streaming to frontend
public/index.html | 192 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 150 insertions(+), 42 deletions(-) diff --git a/public/index.html b/public/index.html index 31261d6..4a249c4 100644 --- a/public/index.html +++ b/public/index.html @@ -743,6 +743,18 @@ body { <div class="creation-title"><img src="/dice.png" alt="" style="height:0.85em;vertical-align:middle;margin-right:0.2em;filter:invert(1) sepia(1) saturate(2) hue-rotate(5deg) brightness(0.9)"> Solo Dungeon Master</div> <div class="creation-sub">Powered by Claude — your story begins here</div> + <div class="creation-section"> + <div class="section-label">Anthropic API Key</div> + <div class="field-group"> + <label>API Key</label> + <input type="password" id="api-key-input" placeholder="sk-ant-api03-…" autocomplete="off" spellcheck="false"> + </div> + <p style="font-size:0.78rem;color:var(--text-dim);margin-top:8px;font-style:italic"> + Your key is sent directly to Anthropic from your browser — never stored or forwarded elsewhere. + Get one at <a href="https://console.anthropic.com/" target="_blank" rel="noopener" style="color:var(--gold-dim)">console.anthropic.com</a>. + </p> + </div> + <div class="creation-section"> <div class="section-label">Choose Your Race</div> <div class="selection-grid" id="race-grid"></div> @@ -901,6 +913,41 @@ const XP_THRESHOLDS = [300, 900, 2700, 6500, 14000, 23000, 34000, 48000, 64000, const DIE_TYPES = [4, 6, 8, 10, 12, 20, 100]; +const UPDATE_TOOL = { + name: 'update_character', + description: 'Update character stats based on narrative events. Call BEFORE writing narrative text.', + input_schema: { + type: 'object', + properties: { + hp_change: { type: 'number', description: 'HP delta. Negative=damage, positive=healing. 0 if unchanged.' }, + gold_change: { type: 'number', description: 'Gold delta. 0 if unchanged.' }, + xp_gain: { type: 'number', description: 'XP gained this turn. 0 if none.' }, + inventory_add: { type: 'array', items: { type: 'string' }, description: 'Items gained.' }, + inventory_remove: { type: 'array', items: { type: 'string' }, description: 'Items lost.' }, + reason: { type: 'string', description: 'Brief reason for the change.' }, + }, + required: ['hp_change', 'gold_change', 'xp_gain', 'inventory_add', 'inventory_remove', 'reason'], + }, +}; + +function buildSystemPrompt(character, theme) { + return `You are a masterful Dungeon Master running a solo D&D 5e adventure. + +CHARACTER: ${character.name} — Level ${character.level} ${character.race} ${character.class} +ADVENTURE THEME: ${theme || 'classic fantasy'} + +NARRATIVE RULES +• Write 2–4 vivid, immersive paragraphs per turn. +• Give NPCs distinct voices and motivations. +• Make choices matter — show consequences immediately. +• Run combat with suspense and tactical detail. +• End every turn with either 2–3 numbered options OR "What do you do?" + +STAT TRACKING — call update_character whenever HP changes, gold is gained/lost, XP is earned, or items change hands. Call the tool BEFORE writing your narrative text. + +TONE: Cinematic. Dangerous. Immersive. Legendary.`; +} + // ── STATE ───────────────────────────────────────────── let selectedRace = null; @@ -948,10 +995,14 @@ function checkBeginReady() { } function beginAdventure() { + const apiKey = document.getElementById('api-key-input').value.trim(); + if (!apiKey) { alert('Please enter your Anthropic API key.'); return; } + const name = document.getElementById('char-name').value.trim() || `The ${selectedClass.name}`; const theme = document.getElementById('adventure-theme').value.trim(); gameState = { + apiKey, character: { name, race: selectedRace.name, @@ -1234,56 +1285,113 @@ async function sendMessage(text, showAsPlayer = true) { let rawText = ''; try { - const resp = await fetch('/api/chat', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - messages: conversationHistory, - character: gameState.character, - theme: gameState.theme, - }), - }); - - if (!resp.ok) throw new Error(`Server error: ${resp.status}`); - - const reader = resp.body.getReader(); - const decoder = new TextDecoder(); - let buf = ''; + let apiMessages = [...conversationHistory]; + let statUpdate = null; let firstChunk = true; while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buf += decoder.decode(value, { stream: true }); - const lines = buf.split('\n'); - buf = lines.pop() ?? ''; - - for (const line of lines) { - if (!line.startsWith('data: ')) continue; - let data; - try { data = JSON.parse(line.slice(6)); } catch { continue; } - - if (data.type === 'text') { - if (firstChunk) { - // Remove typing indicator - dmEl.innerHTML = ''; - firstChunk = false; + const response = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': gameState.apiKey, + 'anthropic-version': '2023-06-01', + 'anthropic-beta': 'prompt-caching-2024-07-31', + 'anthropic-dangerous-allow-browser': 'true', + }, + body: JSON.stringify({ + model: 'claude-opus-4-7', + max_tokens: 1500, + stream: true, + system: [{ type: 'text', text: buildSystemPrompt(gameState.character, gameState.theme), cache_control: { type: 'ephemeral' } }], + tools: [UPDATE_TOOL], + messages: apiMessages, + }), + }); + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.error?.message || `API error ${response.status}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buf = ''; + let blocks = {}; + let stopReason = null; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buf += decoder.decode(value, { stream: true }); + const lines = buf.split('\n'); + buf = lines.pop() ?? ''; + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + let data; + try { data = JSON.parse(line.slice(6)); } catch { continue; } + + if (data.type === 'content_block_start') { + blocks[data.index] = { ...data.content_block, _buf: '' }; + } else if (data.type === 'content_block_delta') { + const block = blocks[data.index]; + if (!block) continue; + if (data.delta.type === 'text_delta') { + if (firstChunk) { dmEl.innerHTML = ''; firstChunk = false; } + block._buf += data.delta.text; + rawText += data.delta.text; + updateDMMessage(dmEl, rawText); + } else if (data.delta.type === 'input_json_delta') { + block._buf += data.delta.partial_json; + } + } else if (data.type === 'content_block_stop') { + const block = blocks[data.index]; + if (block?.type === 'tool_use') { + try { block.input = JSON.parse(block._buf || '{}'); } catch { block.input = {}; } + } + } else if (data.type === 'message_delta') { + stopReason = data.delta.stop_reason; } - rawText += data.chunk; - updateDMMessage(dmEl, rawText); - } else if (data.type === 'done') { - finalizeDMMessage(dmEl, rawText); - if (data.statUpdate) applyStatUpdate(data.statUpdate); - conversationHistory.push({ role: 'assistant', content: rawText }); - } else if (data.type === 'error') { - dmEl.textContent = `⚠️ Error: ${data.message}`; - conversationHistory.pop(); } } + + const allBlocks = Object.entries(blocks) + .sort(([a], [b]) => Number(a) - Number(b)) + .map(([, b]) => b); + + const toolBlocks = allBlocks.filter(b => b.type === 'tool_use'); + if (toolBlocks.length > 0) statUpdate = toolBlocks[toolBlocks.length - 1].input; + + if (stopReason !== 'tool_use' || toolBlocks.length === 0) break; + + const assistantContent = allBlocks.map(b => + b.type === 'text' + ? { type: 'text', text: b._buf } + : { type: 'tool_use', id: b.id, name: b.name, input: b.input } + ); + + apiMessages = [ + ...apiMessages, + { role: 'assistant', content: assistantContent }, + { + role: 'user', + content: toolBlocks.map(tb => ({ + type: 'tool_result', + tool_use_id: tb.id, + content: 'Character stats updated successfully.', + })), + }, + ]; } + + finalizeDMMessage(dmEl, rawText); + if (statUpdate) applyStatUpdate(statUpdate); + conversationHistory.push({ role: 'assistant', content: rawText }); + } catch (err) { - dmEl.textContent = `⚠️ Error: ${err.message}`; + dmEl.innerHTML = `<span style="color:#e74c3c">⚠️ ${err.message}</span>`; conversationHistory.pop(); }