| 1 | // Eleven's deterministic command floor: slash-commands it answers WITHOUT the |
| 2 | // model. This is the "bot" half of a helper that upgrades to "ai" — the welcome |
| 3 | // and these commands work with no ANTHROPIC_API_KEY, and conversational replies |
| 4 | // layer on top when a key is present (see runtime.mjs). A command maps text to |
| 5 | // { text, actions? }: the SAME one-tap action chips the LLM can propose |
| 6 | // (actions.mjs), just triggered deterministically, so "make it dark" (AI) and |
| 7 | // "/dark" (no AI) reach the identical chip. Pure + dependency-light so it's |
| 8 | // unit-testable in Node (test/commands.test.mjs). |
| 9 | import { THEMES, APPEARANCES } from "./actions.mjs"; |
| 10 | import { BOT_COMMANDS } from "../web/static/bot-commands.js"; |
| 11 | |
| 12 | // Human-friendly theme names for the reply text (the chip itself carries the id). |
| 13 | const THEME_LABELS = { |
| 14 | litroom: "Lit Room (warm amber)", |
| 15 | lagoon: "Lagoon (teal)", |
| 16 | sunny: "Sunny (yellow)", |
| 17 | plum: "Plum (purple)", |
| 18 | linen: "Linen (soft neutral)", |
| 19 | classic: "Classic (blue)", |
| 20 | }; |
| 21 | |
| 22 | function themeCmd(args) { |
| 23 | const name = (args || "").trim().toLowerCase(); |
| 24 | if (THEMES.includes(name)) |
| 25 | return { text: `Here you go — tap to switch to ${THEME_LABELS[name] || name}.`, actions: [{ type: "set_theme", theme: name }] }; |
| 26 | // No/unknown theme → list the options and offer the picker. |
| 27 | return { text: `Themes: ${THEMES.join(", ")}. Tap to browse, or try e.g. /theme lagoon.`, actions: [{ type: "open_theme_picker" }] }; |
| 28 | } |
| 29 | |
| 30 | function appearanceCmd(mode) { |
| 31 | return { text: `Here you go — tap to switch to ${mode} mode.`, actions: [{ type: "set_appearance", appearance: mode }] }; |
| 32 | } |
| 33 | |
| 34 | // The deterministic run(args, ctx) -> { text, actions? } for each command. Pure: |
| 35 | // no I/O, no model. `ctx.aiEnabled` only tweaks the /help wording (never gates a |
| 36 | // chip). Keyed by the shared BOT_COMMANDS name list (web/static/bot-commands.js), |
| 37 | // which the client also registers — so bot + client can't drift. |
| 38 | const RUN = { |
| 39 | "/help": (_args, ctx) => ({ text: helpText(ctx) }), |
| 40 | "/theme": themeCmd, |
| 41 | "/themes": () => ({ text: "Here you go — tap to browse themes.", actions: [{ type: "open_theme_picker" }] }), |
| 42 | "/dark": () => appearanceCmd("dark"), |
| 43 | "/light": () => appearanceCmd("light"), |
| 44 | "/profile": () => ({ text: "Here you go — tap to open your profile.", actions: [{ type: "open_profile" }] }), |
| 45 | }; |
| 46 | |
| 47 | // name -> { help, run } — help from the shared list, run from above. |
| 48 | export const COMMANDS = Object.fromEntries( |
| 49 | Object.entries(BOT_COMMANDS).map(([name, help]) => [name, { help, run: RUN[name] }]), |
| 50 | ); |
| 51 | |
| 52 | // A bullet list of the commands and what they do — shared by /help and Eleven's |
| 53 | // own reference (eleven.mjs), so the LLM side knows the commands its bot side |
| 54 | // answers and can point people at them. One source of truth, no drift. |
| 55 | export function commandSummary() { |
| 56 | return Object.entries(COMMANDS) |
| 57 | .map(([name, c]) => `• ${name} — ${c.help}`) |
| 58 | .join("\n"); |
| 59 | } |
| 60 | |
| 61 | function helpText(ctx) { |
| 62 | return [ |
| 63 | "Here's what I can do right now:", |
| 64 | commandSummary(), |
| 65 | ctx && ctx.aiEnabled |
| 66 | ? "\nOr just ask me anything in your own words — I'll do my best to help." |
| 67 | : "\nConversational AI answers aren't enabled on this server, so I stick to the commands above.", |
| 68 | ].join("\n"); |
| 69 | } |
| 70 | |
| 71 | // parseCommand splits "/theme lagoon" into { name:"/theme", args:"lagoon" }, or |
| 72 | // returns null when the text isn't a registered command (no leading "/", or the |
| 73 | // first word isn't one we own — so "/unknown …" falls through to normal chat). |
| 74 | export function parseCommand(text) { |
| 75 | const t = (text || "").trim(); |
| 76 | if (t[0] !== "/") return null; |
| 77 | const sp = t.indexOf(" "); |
| 78 | const name = (sp === -1 ? t : t.slice(0, sp)).toLowerCase(); |
| 79 | if (!COMMANDS[name]) return null; |
| 80 | return { name, args: sp === -1 ? "" : t.slice(sp + 1).trim() }; |
| 81 | } |
| 82 | |
| 83 | // runCommand returns { text, actions } for a handled command, or null if the |
| 84 | // text isn't one (the caller then falls back to the model, or degrades). |
| 85 | export function runCommand(text, ctx = {}) { |
| 86 | const cmd = parseCommand(text); |
| 87 | if (!cmd) return null; |
| 88 | const res = COMMANDS[cmd.name].run(cmd.args, ctx); |
| 89 | return res || null; |
| 90 | } |
| 91 | |
| 92 | // The registered command names, for typeahead / help surfaces later. |
| 93 | export function commandNames() { |
| 94 | return Object.keys(COMMANDS); |
| 95 | } |
| 96 | |