feat: "let BotFights answer for me" — server-side AI bot (poll mode), + Latest Bouts short-viewport fix
CI / check (push) Failing after 6m8s
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:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user