feat: "let BotFights answer for me" — server-side AI bot (poll mode), + Latest Bouts short-viewport fix
CI / check (push) Failing after 6m8s

New feature, requested live during demo prep: an operator can paste their
own Anthropic or OpenAI API key and have the server itself answer fight
challenges for their poll-mode bot, instead of running an external script.

Storage (server/src/engine/ai-bot-config.ts): one 0600 JSON file per bot
under this app's own data volume — deliberately mirrors Archipelago's own
node-level pattern for the identical class of secret (system.settings.set
"claude_api_key" in core/archipelago/src/api/rpc/system/handlers.rs): never
returns the raw key on GET, only whether one is configured and which
provider. This is a human operator opting in for their own bot via the
app's own UI — a different trust boundary from the unified prompt's "never
ask an AI agent for its API key" rule (BOTFIGHTS.md), not a violation of it.

Execution (server/src/engine/orchestrator.ts): purely additive hook inside
the existing isPollingBot branch of getBotResponse(). waitForPollResponse()
(unchanged) registers the pending challenge synchronously before returning;
right after, answerWithAiIfConfigured() fires a fire-and-forget async LLM
call that races to call submitPollResponse() — the exact function an
external poller already calls — before that promise's own timeout. No AI
config = instant no-op. LLM error/timeout = the existing timeout path
handles it identically to a human forgetting to poll. No new failure mode,
no change to scoring/round timing for any other bot.

Adapter (server/src/engine/llm-adapter.ts): both providers, one call shape
each (Anthropic /v1/messages + x-api-key, OpenAI /v1/chat/completions +
Bearer), same competitive system prompt already documented in BOTFIGHTS.md
for operator-run bots.

API (server/src/routes/bots.ts): POST/GET/DELETE /api/bots/ai-config,
authenticated via the bot's own Authorization: Bot <id>:<secret> (same as
/api/fights/poll). Registered BEFORE GET /:name — same route-shadowing bug
class already found once in fights.ts's /poll route (09-05); a bare /:name
registered first would have swallowed /ai-config as a bot-name lookup.
Covered by a new test (ai-config.test.ts) that asserts the real contract
shape, not just a 200, specifically to catch that regression.

UI (frontend/src/pages/JoinBoutPage.vue): collapsible section in the
bot-setup step, poll mode only (webhook mode already assumes the operator
runs their own infra) — provider picker, password-type key input, links to
get a key from either provider, key cleared from page state immediately
after saving.

Explicitly deferred (not built here): "use this node's key" as an
alternative to pasting your own — the node already has one configured for
AIUI (found live, /opt/archipelago/claude-api-proxy.py), but wiring a
cross-container secret share from the archy orchestrator into this
container needs a manifest change and another catalog signing cycle, which
this session isn't improvising under demo time pressure.

Also: HomePage.vue "Latest Bouts" section hidden on short viewports
([@media(max-height:700px)]:hidden) — the hero layout is a vertically-
centered flex column with overflow-hidden and no scroll by design, so on a
short viewport (embedded node dashboard iframes, small kiosk screens) this
last/least-essential section was what silently clipped, reported live as
"it looks cut off on node screens often".

Fixed a regression-test violation this batch would have introduced
(BUG-F2: no silent .catch(() => {})) in the DocsPage.vue proxy-URL-resolve
fix from the previous commit — both catches now log a warning instead of
swallowing silently.

Verified: full server typecheck clean; orchestrator.test.ts (23) +
poll-responses.test.ts (10) unchanged and passing — the new hook doesn't
alter existing poll-mode behavior; new ai-config.test.ts (7) passing,
including the route-shadowing regression check; regression.test.ts (43,
including the newly-fixed BUG-F2) passing. lifecycle.test.ts/scoring.test.ts
perf-timing failures are pre-existing, documented, unrelated flakiness
under this shared machine's CPU load (see archy's 09-01 deferred-items.md
item 2) — not caused by this change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-07-31 08:06:02 -04:00
co-authored by Claude Fable 5
parent 2512265113
commit ca5b63468e
8 changed files with 549 additions and 6 deletions
+7 -2
View File
@@ -63,10 +63,15 @@ async function fetchPromptText(): Promise<string> {
return text
}
onMounted(() => { fetchPromptText().catch(() => {}) })
onMounted(() => {
fetchPromptText().catch(err => console.warn('[DocsPage] prompt prefetch failed:', err))
})
async function copyPromptUrl() {
await fetchPromptText().catch(() => {}) // best-effort resolve before copying
// Best-effort resolve before copying — promptUrl already has the
// same-origin fallback set at declaration, so a failure here just means
// the copied URL stays same-origin instead of the resolved arena origin.
await fetchPromptText().catch(err => console.warn('[DocsPage] prompt resolve failed:', err))
navigator.clipboard.writeText(promptUrl.value)
promptCopied.value = 'url'
setTimeout(() => { if (promptCopied.value === 'url') promptCopied.value = '' }, 2000)
+6 -2
View File
@@ -490,8 +490,12 @@ onUnmounted(() => {
</button>
</div>
<!-- Recent fights -->
<div v-if="recentFights.length > 0">
<!-- Recent fights hidden on short viewports (e.g. embedded node dashboard
iframes, small kiosk screens). The parent container is a vertically-
centered flex column with overflow-hidden and no scroll (by design,
for the hero layout), so on a short viewport this last/least-essential
section is what gets silently clipped rather than shown cut off. -->
<div v-if="recentFights.length > 0" class="[@media(max-height:700px)]:hidden">
<p class="font-pixel text-text-muted text-xs uppercase tracking-[0.3em] mb-3">
Latest Bouts
</p>
+116
View File
@@ -552,6 +552,57 @@ function modeHint() {
: 'You picked WEBHOOK — tell your AI to use "Option B: Webhook Bot" below. Needs a public URL.'
}
// --- "Let BotFights answer for me" — server-side AI bot, poll mode only ---
// (webhook mode already requires operator infra; this is specifically for
// the "I don't want to run any script at all" path.) Uses the bot's own
// Authorization: Bot <id>:<secret> credential — same auth every other
// bot-scoped endpoint in this app uses, not a nostr session.
const aiProvider = ref<'anthropic' | 'openai'>('anthropic')
const aiApiKey = ref('')
const aiConfigured = ref(false)
const aiSaving = ref(false)
const aiError = ref('')
const showAiSetup = ref(false)
async function saveAiConfig() {
if (!botId.value || !botSecret.value || !aiApiKey.value.trim()) return
aiSaving.value = true
aiError.value = ''
try {
const res = await fetch('/api/bots/ai-config', {
method: 'POST',
headers: {
'Authorization': `Bot ${botId.value}:${botSecret.value}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ provider: aiProvider.value, apiKey: aiApiKey.value.trim() }),
})
const data = await res.json()
if (!res.ok) {
aiError.value = data.error || 'Failed to save API key.'
return
}
aiConfigured.value = true
aiApiKey.value = '' // never keep the raw key in page state longer than needed
} catch {
aiError.value = 'Connection failed. Try again.'
} finally {
aiSaving.value = false
}
}
async function removeAiConfig() {
if (!botId.value || !botSecret.value) return
try {
await fetch('/api/bots/ai-config', {
method: 'DELETE',
headers: { 'Authorization': `Bot ${botId.value}:${botSecret.value}` },
})
} finally {
aiConfigured.value = false
}
}
async function toggleSetupContent() {
showSetupContent.value = !showSetupContent.value
if (showSetupContent.value && !setupContent.value) {
@@ -1084,6 +1135,71 @@ function handleSignOut() {
<pre v-else class="p-3 font-mono text-[10px] text-text-secondary leading-relaxed
overflow-x-auto max-h-80 overflow-y-auto whitespace-pre-wrap break-words select-all">{{ setupContent }}</pre>
</div>
<!-- "Let BotFights answer for me" poll mode only, no external
script/server needed. Not shown for webhook mode: that path
already assumes the operator is running their own infra. -->
<div v-if="connectionMode === 'polling'" class="mt-3 border border-border p-3">
<button
class="w-full text-left flex items-center justify-between"
@click="showAiSetup = !showAiSetup"
>
<span class="font-display font-bold text-[10px] tracking-wider text-neon-green">
🤖 DON'T WANT TO RUN A SCRIPT? LET BOTFIGHTS ANSWER FOR YOU
</span>
<span class="font-mono text-[9px] text-text-muted">{{ showAiSetup ? 'HIDE' : (aiConfigured ? 'ON' : 'SET UP') }}</span>
</button>
<div v-if="showAiSetup" class="mt-3 space-y-2">
<p class="font-mono text-[9px] text-text-muted leading-relaxed">
Paste your own Anthropic or OpenAI API key — this node answers challenges
for this bot automatically, no script or server of your own needed. The key
is stored only on this node (0600, never sent anywhere except the provider
you pick) and never shown again after saving.
<a href="https://console.anthropic.com" target="_blank" rel="noopener" class="text-neon-cyan underline">Get an Anthropic key</a>
or
<a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener" class="text-neon-cyan underline">an OpenAI key</a>.
</p>
<div v-if="aiConfigured" class="flex items-center justify-between p-2 border border-neon-green/30 bg-neon-green/5">
<span class="font-mono text-[10px] text-neon-green">✓ AI answering enabled ({{ aiProvider }})</span>
<button class="font-mono text-[9px] text-text-muted hover:text-neon-pink underline" @click="removeAiConfig">
Turn off
</button>
</div>
<template v-else>
<div class="flex gap-2">
<button
class="flex-1 py-1.5 border text-[9px] font-display font-bold tracking-wider"
:class="aiProvider === 'anthropic' ? 'border-neon-cyan/60 bg-neon-cyan/10 text-neon-cyan' : 'border-border text-text-muted'"
@click="aiProvider = 'anthropic'"
>ANTHROPIC</button>
<button
class="flex-1 py-1.5 border text-[9px] font-display font-bold tracking-wider"
:class="aiProvider === 'openai' ? 'border-neon-cyan/60 bg-neon-cyan/10 text-neon-cyan' : 'border-border text-text-muted'"
@click="aiProvider = 'openai'"
>OPENAI</button>
</div>
<input
v-model="aiApiKey"
type="password"
placeholder="Paste your API key"
autocomplete="off"
class="w-full px-3 py-2 bg-black/30 border border-border font-mono text-xs text-text-primary
placeholder-text-muted/50 focus:outline-none focus:border-neon-cyan/50"
/>
<p v-if="aiError" class="font-mono text-[9px] text-neon-pink">{{ aiError }}</p>
<button
class="w-full py-2 border-2 border-neon-green/50 text-neon-green font-display font-bold text-[10px]
tracking-wider hover:bg-neon-green/10 transition-all disabled:opacity-50"
:disabled="aiSaving || !aiApiKey.trim() || !botId || !botSecret"
@click="saveAiConfig"
>
{{ aiSaving ? 'SAVING...' : 'SAVE & ENABLE' }}
</button>
</template>
</div>
</div>
</div>
<div class="flex gap-2">