fuzzyworld

dm / commit

feat: add API key input and direct Anthropic streaming to frontend

c6a7ee63213ab96ffafef991f83617641e2c9242 · Pavel Safronov · 2026-05-27 00:05:47 -0700 · parents 3d32b96d442d

feat: add API key input and direct Anthropic streaming to frontend

Adds an API key field to the character creation screen (password input,
never stored beyond the session). On game start, the key is saved to
gameState and used in all fetch calls to api.anthropic.com.

Moves UPDATE_TOOL definition and buildSystemPrompt() into the browser
bundle. Implements the full streaming + tool-use loop client-side:
parses Anthropic SSE events, handles content_block_start/delta/stop,
detects tool_use stop_reason, sends tool_result, and continues
streaming — identical logic to the removed server.js.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 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();
   }