Eleven: a bot that upgrades to AI, plus AI/BOT helper badges (#133)
Split deterministic bots (BOT) from LLM agents (AI). Eleven runs a deterministic command floor (welcome + /help, /theme, /dark, /light, /profile) with no API key and upgrades to conversational AI when a key is present; its badge follows. Kind is self-declared by the sidecar (POST /api/bot-config); Announcer badges BOT. Adds an editable welcome message and a keyless (bot-only) sidecar mode.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
16 files changed,
+489
−33
agent/actions.mjs+5 −2agent/agent.mjs+11 −1agent/announcer.mjs+19 −0agent/commands.mjs+93 −0agent/config.mjs+6 −0agent/eleven.mjs+25 −4agent/runtime.mjs+54 −4bots_test.go+72 −0changelog/2026-07-04-ai-helpers-and-commands.md+1 −0db.go+58 −11deploy-agent.sh+9 −3main.go+46 −0package.json+1 −1test/commands.test.mjs+73 −0web/static/admin.js+6 −1web/static/chat.js+10 −6
diff --git a/agent/actions.mjs b/agent/actions.mjs| index 5496082..ceb5ca4 100644 |
| --- a/agent/actions.mjs |
| +++ b/agent/actions.mjs |
| @@ -6,7 +6,10 @@ |
| // small, harmless, and reversible. Dependency-free so tests can import it. |
| // Keep the theme list + descriptions in step with web/static/ui.js THEMES. |
| -const THEMES = ["litroom", "lagoon", "sunny", "plum", "linen", "classic"]; |
| +// Exported so the deterministic command floor (commands.mjs) validates against |
| +// the same lists the model tools do. |
| +export const THEMES = ["litroom", "lagoon", "sunny", "plum", "linen", "classic"]; |
| +export const APPEARANCES = ["light", "dark", "system"]; |
| export const CHIP_TOOLS = [ |
| { |
| @@ -54,7 +57,7 @@ export function actionsFromToolUse(content) { |
| if (b.type !== "tool_use") continue; |
| const input = b.input || {}; |
| if (b.name === "set_theme" && THEMES.includes(input.theme)) out.push({ type: "set_theme", theme: input.theme }); |
| - else if (b.name === "set_appearance" && ["light", "dark", "system"].includes(input.appearance)) |
| + else if (b.name === "set_appearance" && APPEARANCES.includes(input.appearance)) |
| out.push({ type: "set_appearance", appearance: input.appearance }); |
| else if (b.name === "open_theme_picker") out.push({ type: "open_theme_picker" }); |
| else if (b.name === "open_profile") out.push({ type: "open_profile" }); |
diff --git a/agent/agent.mjs b/agent/agent.mjs| index 18aad28..bba2970 100644 |
| --- a/agent/agent.mjs |
| +++ b/agent/agent.mjs |
| @@ -73,7 +73,17 @@ try { |
| process.exit(1); |
| } |
| if (elevenSession) { |
| - runBot({ name: ELEVEN_NAME, session: elevenSession, builtinPersona: ELEVEN_PERSONA, builtinDocs: ELEVEN_DOCS, tools: CHIP_TOOLS }); |
| + // Eleven runs a deterministic command floor (welcome + slash commands) that |
| + // works with no API key, and "upgrades" to conversational AI when one is set — |
| + // its badge (BOT vs AI) follows, self-declared on connect. See runtime.mjs. |
| + runBot({ |
| + name: ELEVEN_NAME, |
| + session: elevenSession, |
| + builtinPersona: ELEVEN_PERSONA, |
| + builtinDocs: ELEVEN_DOCS, |
| + tools: CHIP_TOOLS, |
| + commands: true, |
| + }); |
| startWelcomer(); |
| } |
diff --git a/agent/announcer.mjs b/agent/announcer.mjs| index c0d2a5f..aac8a19 100644 |
| --- a/agent/announcer.mjs |
| +++ b/agent/announcer.mjs |
| @@ -38,6 +38,24 @@ const POLL_MS = Number(process.env.LCHAT_ANNOUNCE_POLL_MS) || 5 * 60 * 1000; |
| const readJSON = (p) => (fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : null); |
| +// Announce our kind once so the UI badges us BOT — the announcer is deterministic |
| +// (it never calls a model; it only decrypts thread keys to *encrypt* what it |
| +// posts). Best-effort; retried on a later tick if it fails. |
| +let kindDeclared = false; |
| +async function declareBot(session) { |
| + if (kindDeclared) return; |
| + try { |
| + const r = await fetch(`${BASE}/api/bot-config`, { |
| + method: "POST", |
| + headers: { "content-type": "application/json" }, |
| + body: JSON.stringify({ token: session.token, kind: "bot" }), |
| + }); |
| + kindDeclared = r.ok; |
| + } catch (e) { |
| + log(`declareBot: ${e.message}`); |
| + } |
| +} |
| + |
| // Provision the announcer's own identity from LCHAT_ANNOUNCER_INVITE (same |
| // claim flow as provision.mjs, separate session file). Returns null — quietly |
| // disabled — when there's neither a session nor an invite. |
| @@ -70,6 +88,7 @@ async function loadOrProvision() { |
| export async function announceTick() { |
| const session = await loadOrProvision(); |
| if (!session) return false; |
| + await declareBot(session); |
| const api = (path) => |
| fetch(`${BASE}${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(session.token)}`).then((r) => |
| r.json() |
diff --git a/agent/commands.mjs b/agent/commands.mjs| new file mode 100644 |
| index 0000000..59ec686 |
| --- /dev/null |
| +++ b/agent/commands.mjs |
| @@ -0,0 +1,93 @@ |
| +// Eleven's deterministic command floor: slash-commands it answers WITHOUT the |
| +// model. This is the "bot" half of a helper that upgrades to "ai" — the welcome |
| +// and these commands work with no ANTHROPIC_API_KEY, and conversational replies |
| +// layer on top when a key is present (see runtime.mjs). A command maps text to |
| +// { text, actions? }: the SAME one-tap action chips the LLM can propose |
| +// (actions.mjs), just triggered deterministically, so "make it dark" (AI) and |
| +// "/dark" (no AI) reach the identical chip. Pure + dependency-light so it's |
| +// unit-testable in Node (test/commands.test.mjs). |
| +import { THEMES, APPEARANCES } from "./actions.mjs"; |
| + |
| +// Human-friendly theme names for the reply text (the chip itself carries the id). |
| +const THEME_LABELS = { |
| + litroom: "Lit Room (warm amber)", |
| + lagoon: "Lagoon (teal)", |
| + sunny: "Sunny (yellow)", |
| + plum: "Plum (purple)", |
| + linen: "Linen (soft neutral)", |
| + classic: "Classic (blue)", |
| +}; |
| + |
| +function themeCmd(args) { |
| + const name = (args || "").trim().toLowerCase(); |
| + if (THEMES.includes(name)) |
| + return { text: `Here you go — tap to switch to ${THEME_LABELS[name] || name}.`, actions: [{ type: "set_theme", theme: name }] }; |
| + // No/unknown theme → list the options and offer the picker. |
| + return { text: `Themes: ${THEMES.join(", ")}. Tap to browse, or try e.g. /theme lagoon.`, actions: [{ type: "open_theme_picker" }] }; |
| +} |
| + |
| +function appearanceCmd(mode) { |
| + return { text: `Here you go — tap to switch to ${mode} mode.`, actions: [{ type: "set_appearance", appearance: mode }] }; |
| +} |
| + |
| +// name -> { help, run(args, ctx) -> { text, actions? } }. `run` is pure: no I/O, |
| +// no model. `ctx.aiEnabled` only tweaks the /help wording (it never gates a chip). |
| +export const COMMANDS = { |
| + "/help": { help: "what I can do", run: (_args, ctx) => ({ text: helpText(ctx) }) }, |
| + "/theme": { help: "switch colour theme, e.g. /theme lagoon", run: themeCmd }, |
| + "/themes": { |
| + help: "browse the theme picker", |
| + run: () => ({ text: "Here you go — tap to browse themes.", actions: [{ type: "open_theme_picker" }] }), |
| + }, |
| + "/dark": { help: "switch to dark mode", run: () => appearanceCmd("dark") }, |
| + "/light": { help: "switch to light mode", run: () => appearanceCmd("light") }, |
| + "/profile": { |
| + help: "edit your screen name & photo", |
| + run: () => ({ text: "Here you go — tap to open your profile.", actions: [{ type: "open_profile" }] }), |
| + }, |
| +}; |
| + |
| +// A bullet list of the commands and what they do — shared by /help and Eleven's |
| +// own reference (eleven.mjs), so the LLM side knows the commands its bot side |
| +// answers and can point people at them. One source of truth, no drift. |
| +export function commandSummary() { |
| + return Object.entries(COMMANDS) |
| + .map(([name, c]) => `• ${name} — ${c.help}`) |
| + .join("\n"); |
| +} |
| + |
| +function helpText(ctx) { |
| + return [ |
| + "Here's what I can do right now:", |
| + commandSummary(), |
| + ctx && ctx.aiEnabled |
| + ? "\nOr just ask me anything in your own words — I'll do my best to help." |
| + : "\nConversational AI answers aren't enabled on this server, so I stick to the commands above.", |
| + ].join("\n"); |
| +} |
| + |
| +// parseCommand splits "/theme lagoon" into { name:"/theme", args:"lagoon" }, or |
| +// returns null when the text isn't a registered command (no leading "/", or the |
| +// first word isn't one we own — so "/unknown …" falls through to normal chat). |
| +export function parseCommand(text) { |
| + const t = (text || "").trim(); |
| + if (t[0] !== "/") return null; |
| + const sp = t.indexOf(" "); |
| + const name = (sp === -1 ? t : t.slice(0, sp)).toLowerCase(); |
| + if (!COMMANDS[name]) return null; |
| + return { name, args: sp === -1 ? "" : t.slice(sp + 1).trim() }; |
| +} |
| + |
| +// runCommand returns { text, actions } for a handled command, or null if the |
| +// text isn't one (the caller then falls back to the model, or degrades). |
| +export function runCommand(text, ctx = {}) { |
| + const cmd = parseCommand(text); |
| + if (!cmd) return null; |
| + const res = COMMANDS[cmd.name].run(cmd.args, ctx); |
| + return res || null; |
| +} |
| + |
| +// The registered command names, for typeahead / help surfaces later. |
| +export function commandNames() { |
| + return Object.keys(COMMANDS); |
| +} |
diff --git a/agent/config.mjs b/agent/config.mjs| index 78d6d08..5a4c2aa 100644 |
| --- a/agent/config.mjs |
| +++ b/agent/config.mjs |
| @@ -11,6 +11,12 @@ import { fileURLToPath } from "node:url"; |
| export const BASE = (process.env.LCHAT_BASE || "").replace(/\/$/, ""); |
| export const WS_BASE = BASE.replace(/^http/, "ws"); |
| +// Whether the model brain is available. With no key the sidecar still runs its |
| +// deterministic side (welcomes + slash commands), so a helper "upgrades" from |
| +// BOT to AI exactly when a key is present — the badge follows this (runtime.mjs |
| +// self-declares kind), and non-command messages degrade to a commands hint. |
| +export const AI_ENABLED = !!(process.env.ANTHROPIC_API_KEY || "").trim(); |
| + |
| export const AGENT_NAME = process.env.LCHAT_AGENT_NAME || "Claude"; |
| // The app's user-facing name (the product is called Eleven; lchat is the |
| // project/repo name). Bots refer to the app by this. |
diff --git a/agent/eleven.mjs b/agent/eleven.mjs| index 8c4a96d..1ac48d6 100644 |
| --- a/agent/eleven.mjs |
| +++ b/agent/eleven.mjs |
| @@ -17,8 +17,9 @@ |
| import fs from "node:fs"; |
| import { fileURLToPath } from "node:url"; |
| import { importKey, encrypt, decrypt, generateKey, wrapThreadKey, unwrapThreadKey } from "../web/static/crypto.js"; |
| -import { BASE, requireBase } from "./config.mjs"; |
| +import { BASE, requireBase, AI_ENABLED } from "./config.mjs"; |
| import { provision } from "./provision.mjs"; |
| +import { commandSummary } from "./commands.mjs"; |
| const log = (m) => { |
| try { |
| @@ -42,6 +43,9 @@ export const ELEVEN_PERSONA = |
| "light/dark mode, browse themes, or edit their profile, call the matching tool — it attaches a button " + |
| "they tap to apply (nothing happens without their tap, and only on their own device). Keep the " + |
| "accompanying text short, like: 'Here you go — tap to switch.' " + |
| + "You're not only an AI — you're also a bot: the very same actions are available as instant slash " + |
| + "commands (like /dark or /theme lagoon) that work even when AI features are switched off. When it's " + |
| + "natural, mention the quick command alongside the button so people learn the shortcut. " + |
| "If someone asks something unrelated to the app, you can chat — but gently note that Claude (the other " + |
| "helper) is the better companion for general questions."; |
| export const ELEVEN_DOCS = [ |
| @@ -55,6 +59,9 @@ export const ELEVEN_DOCS = [ |
| "Themes: the ⋮ menu has a Theme picker with light/dark and colour options — or people can just ask you to switch it (you offer a one-tap button).", |
| "Notifications: enable them from the ⋮ menu; you won't be notified for chats you have open.", |
| "The Announcer helper posts a note in groups it's added to whenever the app gets new features.", |
| + "You answer these slash commands as well as free-form questions — they run instantly and work even " + |
| + "when AI is off, so point people at the one that fits:\n" + |
| + commandSummary(), |
| ].join("\n"); |
| const readJSON = (p) => (fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : null); |
| @@ -71,13 +78,18 @@ export async function loadElevenSession() { |
| return session; |
| } |
| -const welcomeText = (name) => |
| +// The closing line adapts to whether Eleven has its model brain: with AI it |
| +// invites free-form questions; without, it points at the commands it can |
| +// actually run — so the welcome never promises what this instance can't do. |
| +const welcomeText = (name, aiEnabled) => |
| `Hi ${name || "there"}, welcome! 👋 I'm ${ELEVEN_NAME}, your guide to this app.\n\n` + |
| `A few things worth knowing:\n` + |
| `• Everything here is end-to-end encrypted — only the people in a chat can read it.\n` + |
| `• Tap ➕ to start a chat or a group. Helpers (like me) have their own section there.\n` + |
| `• Double-tap a message to react, swipe right to reply.\n\n` + |
| - `Ask me anything about how the app works — I'm happy to help!`; |
| + (aiEnabled |
| + ? `Ask me anything about how the app works — I'm happy to help!` |
| + : `Try /help to see what I can do.`); |
| // Member display names are encrypted under the instance key (the server is |
| // blind to them). Fetch + unwrap our copy; null if a holder hasn't granted it to |
| @@ -135,6 +147,14 @@ export async function welcomeTick() { |
| // re-run after a partial failure): don't create a duplicate, post into it. |
| const { threads = [] } = await get("/api/threads"); |
| + // Admin-tweakable welcome text (console → bot_configs.welcome). Falls back to |
| + // the shipped template; a literal "{name}" is replaced with the member's name. |
| + let welcomeTmpl = ""; |
| + try { |
| + const cfg = await get("/api/bot-config"); |
| + if (cfg && cfg.welcome) welcomeTmpl = cfg.welcome; |
| + } catch {} |
| + |
| for (const person of newcomers) { |
| try { |
| let dm = threads.find((t) => t.kind === "dm" && (t.members || []).includes(person.user_id)); |
| @@ -161,7 +181,8 @@ export async function welcomeTick() { |
| dm = { id: r.thread_id }; |
| } |
| const name = await memberName(person, instanceKey); |
| - const blob = await encrypt(key, { name: ELEVEN_NAME, text: welcomeText(name), ts: Date.now() }); |
| + const text = welcomeTmpl ? welcomeTmpl.replaceAll("{name}", name || "there") : welcomeText(name, AI_ENABLED); |
| + const blob = await encrypt(key, { name: ELEVEN_NAME, text, ts: Date.now() }); |
| const pr = await fetch(`${BASE}/api/messages?token=${encodeURIComponent(session.token)}`, { |
| method: "POST", |
| headers: { "content-type": "application/json" }, |
diff --git a/agent/runtime.mjs b/agent/runtime.mjs| index e6dd702..204e20e 100644 |
| --- a/agent/runtime.mjs |
| +++ b/agent/runtime.mjs |
| @@ -8,8 +8,9 @@ import fs from "node:fs"; |
| import Anthropic from "@anthropic-ai/sdk"; |
| import WebSocket from "ws"; |
| import { importKey, encrypt, decrypt, unwrapThreadKey } from "../web/static/crypto.js"; |
| -import { BASE, WS_BASE, MODEL, CLASSIFIER_MODEL, APP_NAME } from "./config.mjs"; |
| +import { BASE, WS_BASE, MODEL, CLASSIFIER_MODEL, APP_NAME, AI_ENABLED } from "./config.mjs"; |
| import { actionsFromToolUse } from "./actions.mjs"; |
| +import { runCommand } from "./commands.mjs"; |
| const log = (m) => { |
| try { |
| @@ -38,7 +39,7 @@ const DEFAULT_PERSONA = "Keep replies short, warm, and easy to read."; |
| // config can still override per instance. `tools` (Claude tool defs) let the |
| // bot propose one-tap action chips; tool_use blocks in a reply become the |
| // payload's `actions` (see actions.mjs — the client whitelists again). |
| -export function runBot({ name, session, builtinPersona = "", builtinDocs = "", tools = null }) { |
| +export function runBot({ name, session, builtinPersona = "", builtinDocs = "", tools = null, commands = false }) { |
| let persona = builtinPersona || process.env.LCHAT_SYSTEM || DEFAULT_PERSONA; |
| let model = MODEL; |
| let docs = builtinDocs; |
| @@ -280,12 +281,45 @@ export function runBot({ name, session, builtinPersona = "", builtinDocs = "", t |
| console.error(`[${name}] Claude error:`, e.message); |
| return; |
| } |
| + if (!text && !actions.length) return; |
| + await postReply(threadId, entry, text, actions); |
| + } |
| + |
| + // Encrypt + send a reply payload (text + optional one-tap chips). Shared by the |
| + // model path (replyInner) and the deterministic command path (processMsg). |
| + async function postReply(threadId, entry, text, actions = []) { |
| if (!text && actions.length) text = "Tap to apply:"; |
| if (!text) return; |
| const payload = { name, text, ts: Date.now(), ...(actions.length ? { actions } : {}) }; |
| send({ type: "msg", thread_id: threadId, blob: await encrypt(entry.key, payload) }); |
| } |
| + // Strip a leading "@Name" so a group command ("@Eleven /help") parses; a bare |
| + // command in a DM passes through unchanged. |
| + function stripMention(text) { |
| + return (text || "").replace(new RegExp(`^\\s*@${name}\\b[:,]?\\s*`, "i"), "").trim(); |
| + } |
| + |
| + // The reply when there's no model brain and the message wasn't a command. |
| + function degradeText() { |
| + return "I can help with a few quick commands — try /help. (Conversational AI answers aren't enabled on this server.)"; |
| + } |
| + |
| + // Tell the server what we are so it can badge us honestly: "ai" when the model |
| + // brain is available, else "bot" (deterministic). Re-declared on every connect, |
| + // so toggling the key (a redeploy) flips the badge with no admin action. |
| + async function declareKind() { |
| + try { |
| + await fetch(`${BASE}/api/bot-config`, { |
| + method: "POST", |
| + headers: { "content-type": "application/json" }, |
| + body: JSON.stringify({ token: session.token, kind: AI_ENABLED ? "ai" : "bot" }), |
| + }); |
| + } catch (e) { |
| + tag(`declareKind: ${e.message}`); |
| + } |
| + } |
| + |
| async function sendTyping(threadId, key, expiry = 15) { |
| try { |
| // Same shape chat.js emits (see hub.go's "typing" relay). expiry 0 is the |
| @@ -321,6 +355,7 @@ export function runBot({ name, session, builtinPersona = "", builtinDocs = "", t |
| await loadConfig(); |
| await loadAgents(); |
| await loadThreads(); |
| + await declareKind(); |
| await catchUp(); |
| for (const d of queue.splice(0)) ws.send(d); |
| }); |
| @@ -349,8 +384,23 @@ export function runBot({ name, session, builtinPersona = "", builtinDocs = "", t |
| if (agentNames.has(p.name)) return; // bots never talk to bots (see loadAgents) |
| const trigger = replyTrigger(entry.kind, p.text); |
| if (!trigger) return; |
| - if (trigger === "classify" && !(await isAddressed(threadId, entry.key))) return; |
| - await reply(threadId, entry); |
| + // The bare-name gate needs the model; with no AI there's nothing to classify. |
| + if (trigger === "classify" && (!AI_ENABLED || !(await isAddressed(threadId, entry.key)))) return; |
| + // Deterministic command floor first — works with or without a model brain. |
| + if (commands) { |
| + const res = runCommand(stripMention(p.text), { aiEnabled: AI_ENABLED, name }); |
| + if (res) { |
| + await postReply(threadId, entry, res.text, res.actions || []); |
| + return; |
| + } |
| + } |
| + // Conversational upgrade: only when the model brain is available. |
| + if (AI_ENABLED) { |
| + await reply(threadId, entry); |
| + return; |
| + } |
| + // No AI and not a command → a deterministic hint (command-capable bots only). |
| + if (commands) await postReply(threadId, entry, degradeText(), []); |
| } |
| async function onFrame(data) { |
diff --git a/bots_test.go b/bots_test.go| index bc7f832..ea3ab46 100644 |
| --- a/bots_test.go |
| +++ b/bots_test.go |
| @@ -3,6 +3,7 @@ package main |
| import ( |
| "encoding/json" |
| "net/http" |
| + "strings" |
| "testing" |
| ) |
| @@ -299,3 +300,74 @@ func TestAdminEditBotConfig(t *testing.T) { |
| t.Fatalf("sidecar view: ok=%v %+v", ok, bc) |
| } |
| } |
| + |
| +// A sidecar self-declares its identity's kind ("ai"/"bot") with its OWN device |
| +// token; it surfaces on the admin users list to drive the AI/BOT badge. The |
| +// endpoint accepts only an agent's own valid token and the two known kinds, and |
| +// kind never clobbers (nor is clobbered by) the admin config write — both are |
| +// narrow upserts. Welcome text round-trips to the sidecar-facing config. |
| +func TestBotKindDeclareAndWelcome(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + uid, _ := srv.db.createUser("Eleven") |
| + srv.db.createKeyInvite("inv-k", uid, "", testClaimHash, 0) |
| + tok, _, _ := srv.db.claimKeyInvite("inv-k", "Eleven", "", testClaimSecret) |
| + srv.db.setUserPublicKey(uid, "pub-eleven") |
| + srv.db.setUserAgent(uid) |
| + |
| + declare := func(token, kind string) int { |
| + r, _ := http.Post(ts.URL+"/api/bot-config", "application/json", |
| + strings.NewReader(`{"token":"`+token+`","kind":"`+kind+`"}`)) |
| + return r.StatusCode |
| + } |
| + kindOf := func(id string) string { |
| + users, _ := srv.db.listUsers() |
| + for _, u := range users { |
| + if u.ID == id { |
| + return u.Kind |
| + } |
| + } |
| + return "?" |
| + } |
| + |
| + // Default before any declare: an agent row reads as "ai". |
| + if k := kindOf(uid); k != "ai" { |
| + t.Fatalf("default agent kind: want ai, got %q", k) |
| + } |
| + // Declare bot (Eleven running commands-only) → badge flips to BOT. |
| + if s := declare(tok, "bot"); s != http.StatusOK || kindOf(uid) != "bot" { |
| + t.Fatalf("declare bot: status/kind = %d/%q", s, kindOf(uid)) |
| + } |
| + // Re-declare ai (its model brain came online) → badge flips back. |
| + if s := declare(tok, "ai"); s != http.StatusOK || kindOf(uid) != "ai" { |
| + t.Fatalf("re-declare ai: status/kind = %d/%q", s, kindOf(uid)) |
| + } |
| + |
| + // Guards: unknown kind, a non-agent's token, and a bogus token are all refused. |
| + if s := declare(tok, "watcher"); s != http.StatusBadRequest { |
| + t.Fatalf("bad kind: want 400, got %d", s) |
| + } |
| + person, _ := srv.db.createUser("Paul") |
| + srv.db.createKeyInvite("inv-p", person, "", testClaimHash, 0) |
| + ptok, _, _ := srv.db.claimKeyInvite("inv-p", "Paul", "", testClaimSecret) |
| + if s := declare(ptok, "bot"); s != http.StatusBadRequest { |
| + t.Fatalf("non-agent declare: want 400, got %d", s) |
| + } |
| + if s := declare("nope", "bot"); s != http.StatusForbidden { |
| + t.Fatalf("bogus token: want 403, got %d", s) |
| + } |
| + |
| + // kind and the admin config write don't clobber each other (narrow upserts), |
| + // and the admin-set welcome reaches the sidecar-facing config. |
| + declare(tok, "bot") |
| + if r := adminPost(t, ts.URL+"/api/admin/bots/"+uid+"/config", |
| + `{"system_prompt":"be brief","welcome":"Hi {name}, welcome aboard!"}`); r.StatusCode != http.StatusOK { |
| + t.Fatalf("admin edit: %d", r.StatusCode) |
| + } |
| + if k := kindOf(uid); k != "bot" { |
| + t.Fatalf("admin edit clobbered kind: got %q", k) |
| + } |
| + bc, ok, _ := srv.db.botConfig(uid) |
| + if !ok || bc.Welcome != "Hi {name}, welcome aboard!" { |
| + t.Fatalf("welcome round-trip: ok=%v %+v", ok, bc) |
| + } |
| +} |
diff --git a/changelog/2026-07-04-ai-helpers-and-commands.md b/changelog/2026-07-04-ai-helpers-and-commands.md| new file mode 100644 |
| index 0000000..b3dde8d |
| --- /dev/null |
| +++ b/changelog/2026-07-04-ai-helpers-and-commands.md |
| @@ -0,0 +1 @@ |
| +Helpers now show what they are: an **AI** badge when a helper is powered by AI (so it's clear that what you send it is answered by an AI service), or a **BOT** badge for a simple automated helper that stays on this server. And you can now ask Eleven for quick things with slash commands — try `/help`, `/theme lagoon`, `/dark`, or `/profile` — no need to spell it out in a sentence. |
diff --git a/db.go b/db.go| index 33f279b..2bcd061 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -248,6 +248,16 @@ CREATE TABLE IF NOT EXISTS bot_configs ( |
| // = only on an @mention (the default), 'name' = also when spoken to by |
| // bare name (the sidecar still gates that through a classifier). |
| "ALTER TABLE bot_configs ADD COLUMN listen TEXT NOT NULL DEFAULT ''", |
| + // Helper kind (docs/bots.md): 'ai' (LLM-backed — messages it sees are sent |
| + // to a model provider) vs 'bot' (deterministic — nothing leaves the server). |
| + // The sidecar SELF-DECLARES this on connect, so Eleven reads BOT while it |
| + // runs commands-only and AI once its model brain is live. Badge only; no |
| + // privilege. Default 'ai' so a pre-declare agent row reads as an assistant. |
| + "ALTER TABLE bot_configs ADD COLUMN kind TEXT NOT NULL DEFAULT 'ai'", |
| + // Admin-tweakable welcome DM text used by the guide (Eleven's welcomer). |
| + // '' = the shipped default; a literal '{name}' is replaced with the new |
| + // member's name. Operator copy, like the persona — never message content. |
| + "ALTER TABLE bot_configs ADD COLUMN welcome TEXT NOT NULL DEFAULT ''", |
| } { |
| if _, err := db.sql.Exec(c); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| return err |
| @@ -443,6 +453,8 @@ type User struct { |
| PendingDM bool `json:"pending_dm"` |
| // IsAgent marks a bot, so the console can badge it and offer "Edit bot". |
| IsAgent bool `json:"is_agent,omitempty"` |
| + // Kind is "ai" or "bot" for agents (drives the admin AI/BOT badge); "" for people. |
| + Kind string `json:"kind,omitempty"` |
| } |
| func (db *DB) createUser(label string) (string, error) { |
| @@ -463,7 +475,8 @@ func (db *DB) listUsers() ([]User, error) { |
| (SELECT COUNT(*) FROM key_invites k WHERE k.user_id = u.id |
| AND k.used_at IS NULL AND (k.expires_at IS NULL OR k.expires_at > ?)), |
| (SELECT COUNT(*) FROM key_invites k WHERE k.user_id = u.id |
| - AND k.thread_id IS NOT NULL AND k.used_at IS NULL) |
| + AND k.thread_id IS NOT NULL AND k.used_at IS NULL), |
| + COALESCE((SELECT kind FROM bot_configs b WHERE b.user_id = u.id), 'ai') |
| FROM users u ORDER BY u.created_at DESC`, now) |
| if err != nil { |
| return nil, err |
| @@ -474,7 +487,8 @@ func (db *DB) listUsers() ([]User, error) { |
| var u User |
| var joinedAt sql.NullInt64 |
| var pending, pendingDM, isAgent int |
| - if err := rows.Scan(&u.ID, &u.Label, &u.CreatedAt, &isAgent, &u.Devices, &joinedAt, &pending, &pendingDM); err != nil { |
| + var kind string |
| + if err := rows.Scan(&u.ID, &u.Label, &u.CreatedAt, &isAgent, &u.Devices, &joinedAt, &pending, &pendingDM, &kind); err != nil { |
| return nil, err |
| } |
| if joinedAt.Valid { |
| @@ -484,6 +498,9 @@ func (db *DB) listUsers() ([]User, error) { |
| u.Pending = pending > 0 |
| u.PendingDM = pendingDM > 0 |
| u.IsAgent = isAgent != 0 |
| + if u.IsAgent { |
| + u.Kind = kind |
| + } |
| out = append(out, u) |
| } |
| return out, rows.Err() |
| @@ -715,6 +732,9 @@ type RosterEntry struct { |
| // One-line admin-authored blurb for agents (the Helpers catalog entry — |
| // doubles as the consent hint). Always "" for people. |
| Description string `json:"description,omitempty"` |
| + // Kind is "ai" (LLM-backed) or "bot" (deterministic) for agents; "" for |
| + // people. Drives the AI/BOT badge — the honest "where do my words go?" cue. |
| + Kind string `json:"kind,omitempty"` |
| } |
| func (db *DB) setUserPublicKey(userID, pub string) error { |
| @@ -895,7 +915,31 @@ type BotConfig struct { |
| Model string `json:"model"` |
| SystemPrompt string `json:"system_prompt"` |
| Docs string `json:"docs"` |
| - Listen string `json:"listen"` // ''/'mention' = @mention only, 'name' = also bare name |
| + Listen string `json:"listen"` // ''/'mention' = @mention only, 'name' = also bare name |
| + Welcome string `json:"welcome"` // admin-tweakable welcome DM (the guide uses it); "" = shipped default |
| +} |
| + |
| +// setBotKind records an agent's kind ("ai" | "bot"), self-declared by the sidecar |
| +// on connect (LLM-backed vs deterministic). A narrow upsert: it touches only the |
| +// kind column, so it never clobbers admin-edited config — and setBotConfig never |
| +// clobbers kind. Order-independent. |
| +func (db *DB) setBotKind(userID, kind string) error { |
| + _, err := db.sql.Exec( |
| + `INSERT INTO bot_configs(user_id, kind, updated_at) VALUES(?, ?, ?) |
| + ON CONFLICT(user_id) DO UPDATE SET kind = excluded.kind, updated_at = excluded.updated_at`, |
| + userID, kind, time.Now().Unix()) |
| + return err |
| +} |
| + |
| +// setBotWelcome stores the admin-tweakable welcome DM text (same narrow-upsert |
| +// discipline as setBotKind, so it composes with setBotConfig without a signature |
| +// change or clobbering other fields). |
| +func (db *DB) setBotWelcome(userID, welcome string) error { |
| + _, err := db.sql.Exec( |
| + `INSERT INTO bot_configs(user_id, welcome, updated_at) VALUES(?, ?, ?) |
| + ON CONFLICT(user_id) DO UPDATE SET welcome = excluded.welcome, updated_at = excluded.updated_at`, |
| + userID, welcome, time.Now().Unix()) |
| + return err |
| } |
| func (db *DB) setBotConfig(userID, model, systemPrompt, docs, listen, description string, available bool) error { |
| @@ -915,8 +959,8 @@ func (db *DB) setBotConfig(userID, model, systemPrompt, docs, listen, descriptio |
| // botConfig returns an agent's config (ok=false if none has been set). |
| func (db *DB) botConfig(userID string) (BotConfig, bool, error) { |
| var c BotConfig |
| - err := db.sql.QueryRow("SELECT model, system_prompt, docs, listen FROM bot_configs WHERE user_id = ?", userID). |
| - Scan(&c.Model, &c.SystemPrompt, &c.Docs, &c.Listen) |
| + err := db.sql.QueryRow("SELECT model, system_prompt, docs, listen, COALESCE(welcome, '') FROM bot_configs WHERE user_id = ?", userID). |
| + Scan(&c.Model, &c.SystemPrompt, &c.Docs, &c.Listen, &c.Welcome) |
| if err == sql.ErrNoRows { |
| return c, false, nil |
| } |
| @@ -937,9 +981,9 @@ func (db *DB) fullBotConfig(userID string) (FullBotConfig, bool, error) { |
| var c FullBotConfig |
| var av int |
| err := db.sql.QueryRow( |
| - `SELECT model, system_prompt, docs, COALESCE(listen, ''), COALESCE(description, ''), COALESCE(available_to_members, 0) |
| + `SELECT model, system_prompt, docs, COALESCE(listen, ''), COALESCE(welcome, ''), COALESCE(description, ''), COALESCE(available_to_members, 0) |
| FROM bot_configs WHERE user_id = ?`, userID). |
| - Scan(&c.Model, &c.SystemPrompt, &c.Docs, &c.Listen, &c.Description, &av) |
| + Scan(&c.Model, &c.SystemPrompt, &c.Docs, &c.Listen, &c.Welcome, &c.Description, &av) |
| if err == sql.ErrNoRows { |
| return c, false, nil |
| } |
| @@ -961,7 +1005,7 @@ func (db *DB) setAvatar(userID, blobID string) error { |
| // they share a thread with). Kept for admin/back-end use and tests. |
| func (db *DB) roster() ([]RosterEntry, error) { |
| rows, err := db.sql.Query( |
| - `SELECT u.id, u.label, COALESCE(u.label_blob, ''), u.public_key, COALESCE(u.avatar_blob, ''), COALESCE(u.is_agent, 0), COALESCE(b.description, '') |
| + `SELECT u.id, u.label, COALESCE(u.label_blob, ''), u.public_key, COALESCE(u.avatar_blob, ''), COALESCE(u.is_agent, 0), COALESCE(b.description, ''), COALESCE(b.kind, 'ai') |
| FROM users u LEFT JOIN bot_configs b ON b.user_id = u.id |
| WHERE u.public_key IS NOT NULL AND u.public_key <> '' ORDER BY u.label`, |
| ) |
| @@ -984,7 +1028,7 @@ func (db *DB) roster() ([]RosterEntry, error) { |
| // connections-only; only vetted, clearly-badged helpers are instance-visible. |
| func (db *DB) connections(userID string) ([]RosterEntry, error) { |
| rows, err := db.sql.Query(` |
| - SELECT u.id, u.label, COALESCE(u.label_blob, ''), COALESCE(u.public_key, ''), COALESCE(u.avatar_blob, ''), COALESCE(u.is_agent, 0), COALESCE(b.description, '') |
| + SELECT u.id, u.label, COALESCE(u.label_blob, ''), COALESCE(u.public_key, ''), COALESCE(u.avatar_blob, ''), COALESCE(u.is_agent, 0), COALESCE(b.description, ''), COALESCE(b.kind, 'ai') |
| FROM users u LEFT JOIN bot_configs b ON b.user_id = u.id |
| WHERE u.id = ? |
| OR u.id IN ( |
| @@ -1007,11 +1051,14 @@ func scanRoster(rows *sql.Rows) ([]RosterEntry, error) { |
| for rows.Next() { |
| var e RosterEntry |
| var isAgent int |
| - if err := rows.Scan(&e.UserID, &e.Label, &e.LabelBlob, &e.PublicKey, &e.Avatar, &isAgent, &e.Description); err != nil { |
| + var kind string |
| + if err := rows.Scan(&e.UserID, &e.Label, &e.LabelBlob, &e.PublicKey, &e.Avatar, &isAgent, &e.Description, &kind); err != nil { |
| return nil, err |
| } |
| e.IsAgent = isAgent != 0 |
| - if !e.IsAgent { |
| + if e.IsAgent { |
| + e.Kind = kind // "ai" | "bot" — badge cue; only meaningful for agents |
| + } else { |
| e.Description = "" // description is Helpers-catalog metadata, agents only |
| } |
| out = append(out, e) |
diff --git a/deploy-agent.sh b/deploy-agent.sh| index 16ecd16..154fcd5 100755 |
| --- a/deploy-agent.sh |
| +++ b/deploy-agent.sh |
| @@ -4,18 +4,22 @@ |
| # git stays clean. |
| # |
| # Usage: |
| -# LCHAT_BASE=https://<host> ANTHROPIC_API_KEY=sk-ant-... [LCHAT_INVITE=<T>] \ |
| +# LCHAT_BASE=https://<host> [ANTHROPIC_API_KEY=sk-ant-...] [LCHAT_INVITE=<T>] \ |
| # ./deploy-agent.sh <instance> |
| # |
| # <instance> -> app "<instance>-bot", disk "<instance>-bot-data" |
| # |
| +# ANTHROPIC_API_KEY is OPTIONAL: omit it for a deterministic, bot-only sidecar — |
| +# Eleven still welcomes new members and answers slash commands, badges BOT (not |
| +# AI), and declines free-form questions until a key is set. With a key it |
| +# "upgrades" to conversational AI. (Claude/Announcer are unaffected either way.) |
| +# |
| # LCHAT_INVITE is only needed on the FIRST deploy (the sidecar self-provisions |
| # and persists its session to the /data disk; later deploys ignore it). |
| set -euo pipefail |
| INSTANCE="${1:?usage: deploy-agent.sh <instance>}" |
| : "${LCHAT_BASE:?set LCHAT_BASE (e.g. https://test-bots.luan.chat)}" |
| -: "${ANTHROPIC_API_KEY:?set ANTHROPIC_API_KEY}" |
| CLUSTER="${MIREN_CLUSTER:-luanchat}" |
| APP="${INSTANCE}-bot" |
| @@ -27,7 +31,9 @@ cp -R "$REPO"/. "$TMP"/ |
| rm -rf "$TMP/.git" |
| sed "s/__APP__/$APP/g" "$REPO/.miren/agent.app.toml" > "$TMP/.miren/app.toml" |
| -ENVARGS=(-e "LCHAT_BASE=$LCHAT_BASE" -e "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY") |
| +ENVARGS=(-e "LCHAT_BASE=$LCHAT_BASE") |
| +# Optional: omit for a deterministic, bot-only sidecar (see header). |
| +[ -n "${ANTHROPIC_API_KEY:-}" ] && ENVARGS+=(-e "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY") |
| [ -n "${LCHAT_INVITE:-}" ] && ENVARGS+=(-e "LCHAT_INVITE=$LCHAT_INVITE") |
| [ -n "${LCHAT_AGENT_NAME:-}" ] && ENVARGS+=(-e "LCHAT_AGENT_NAME=$LCHAT_AGENT_NAME") |
| [ -n "${LCHAT_MODEL:-}" ] && ENVARGS+=(-e "LCHAT_MODEL=$LCHAT_MODEL") |
diff --git a/main.go b/main.go| index da74d0a..50e8d76 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -153,6 +153,7 @@ func (s *Server) handler() http.Handler { |
| mux.HandleFunc("POST /api/pubkey", s.setPubkey) |
| mux.HandleFunc("GET /api/roster", s.getRoster) |
| mux.HandleFunc("GET /api/bot-config", s.getBotConfig) |
| + mux.HandleFunc("POST /api/bot-config", s.declareBotKind) |
| mux.HandleFunc("GET /api/me", s.getMe) |
| mux.HandleFunc("POST /api/device/signout", s.signout) |
| mux.HandleFunc("POST /api/apns/subscribe", s.apnsSubscribe) |
| @@ -1233,6 +1234,41 @@ func (s *Server) getBotConfig(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, c) |
| } |
| +// declareBotKind lets a sidecar self-report its identity's kind — "ai" (LLM-backed: |
| +// what it reads is sent to a model provider) or "bot" (deterministic: nothing |
| +// leaves the server). Authed by the bot's OWN device token, not the admin token: |
| +// it can only set its own kind, and only for an agent. This is why Eleven can |
| +// badge BOT when it runs commands-only and AI once its model brain is live — |
| +// the sidecar knows which, and the badge follows the truth. |
| +func (s *Server) declareBotKind(w http.ResponseWriter, r *http.Request) { |
| + var body struct { |
| + Token string `json:"token"` |
| + Kind string `json:"kind"` |
| + } |
| + if json.NewDecoder(r.Body).Decode(&body) != nil { |
| + httpError(w, http.StatusBadRequest, "bad request") |
| + return |
| + } |
| + if !s.tokenOK(body.Token) { |
| + httpError(w, http.StatusForbidden, "not a member") |
| + return |
| + } |
| + uid, ok := s.db.deviceTokenUser(body.Token) |
| + if !ok || !s.db.isAgent(uid) { |
| + httpError(w, http.StatusBadRequest, "not a bot") |
| + return |
| + } |
| + if body.Kind != "ai" && body.Kind != "bot" { |
| + httpError(w, http.StatusBadRequest, "bad kind") |
| + return |
| + } |
| + if err := s.db.setBotKind(uid, body.Kind); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| +} |
| + |
| func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) { |
| if !s.requireAdmin(w, r) { |
| return |
| @@ -1424,10 +1460,20 @@ func (s *Server) setAdminBotConfig(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusBadRequest, "bad listen") |
| return |
| } |
| + if len(body.Welcome) > 2000 { |
| + httpError(w, http.StatusBadRequest, "welcome too long") |
| + return |
| + } |
| if err := s.db.setBotConfig(id, body.Model, body.SystemPrompt, body.Docs, body.Listen, strings.TrimSpace(body.Description), body.Available); err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| } |
| + // Welcome text is a narrow upsert (see setBotWelcome) so it composes with the |
| + // config write above without widening setBotConfig's signature. |
| + if err := s.db.setBotWelcome(id, strings.TrimSpace(body.Welcome)); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| } |
diff --git a/package.json b/package.json| index b83afaa..71f0f0e 100644 |
| --- a/package.json |
| +++ b/package.json |
| @@ -3,6 +3,6 @@ |
| "private": true, |
| "type": "module", |
| "scripts": { |
| - "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/actions.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node home/test/crypto.test.mjs" |
| + "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node home/test/crypto.test.mjs" |
| } |
| } |
diff --git a/test/commands.test.mjs b/test/commands.test.mjs| new file mode 100644 |
| index 0000000..ddc90f9 |
| --- /dev/null |
| +++ b/test/commands.test.mjs |
| @@ -0,0 +1,73 @@ |
| +// Tests for Eleven's deterministic command floor. node test/commands.test.mjs |
| +import assert from "node:assert/strict"; |
| +import { parseCommand, runCommand, commandNames, commandSummary } from "../agent/commands.mjs"; |
| +import { sanitizeActions } from "../web/static/ui.js"; |
| + |
| +let failures = 0; |
| +async function test(name, fn) { |
| + try { |
| + await fn(); |
| + console.log(`PASS ${name}`); |
| + } catch (e) { |
| + failures++; |
| + console.log(`FAIL ${name} — ${e && e.message}`); |
| + } |
| +} |
| + |
| +await test("parseCommand recognises registered commands", () => { |
| + assert.deepEqual(parseCommand("/theme lagoon"), { name: "/theme", args: "lagoon" }); |
| + assert.deepEqual(parseCommand("/help"), { name: "/help", args: "" }); |
| + assert.deepEqual(parseCommand(" /Dark "), { name: "/dark", args: "" }); // trims + lowercases the verb |
| +}); |
| + |
| +await test("parseCommand ignores non-commands", () => { |
| + assert.equal(parseCommand("hello there"), null); |
| + assert.equal(parseCommand("/unknown thing"), null); // unowned verb → falls through to chat |
| + assert.equal(parseCommand(""), null); |
| + assert.equal(parseCommand("not /theme"), null); // a slash mid-sentence isn't a command |
| +}); |
| + |
| +await test("theme command emits a valid set_theme chip", () => { |
| + const res = runCommand("/theme plum"); |
| + assert.equal(res.actions[0].type, "set_theme"); |
| + assert.equal(res.actions[0].theme, "plum"); |
| +}); |
| + |
| +await test("unknown theme offers the picker instead of a bad chip", () => { |
| + const res = runCommand("/theme nope"); |
| + assert.equal(res.actions[0].type, "open_theme_picker"); |
| + assert.match(res.text, /lagoon/); // lists the real options |
| +}); |
| + |
| +await test("appearance + profile commands emit their chips", () => { |
| + assert.equal(runCommand("/dark").actions[0].appearance, "dark"); |
| + assert.equal(runCommand("/light").actions[0].appearance, "light"); |
| + assert.equal(runCommand("/profile").actions[0].type, "open_profile"); |
| +}); |
| + |
| +await test("every command's chips survive the client whitelist", () => { |
| + // The whole point: a deterministic chip is indistinguishable from a model one, |
| + // so sanitizeActions (the client's only render gate) must accept them all. |
| + for (const name of commandNames()) { |
| + const res = runCommand(name === "/theme" ? "/theme lagoon" : name); |
| + const actions = res.actions || []; |
| + assert.equal(sanitizeActions(actions).length, actions.length, `${name} produced an unrenderable chip`); |
| + } |
| +}); |
| + |
| +await test("help wording reflects whether AI is available", () => { |
| + assert.match(runCommand("/help", { aiEnabled: true }).text, /ask me anything/i); |
| + assert.match(runCommand("/help", { aiEnabled: false }).text, /aren't enabled/i); |
| +}); |
| + |
| +await test("runCommand returns null for non-commands", () => { |
| + assert.equal(runCommand("just chatting"), null); |
| +}); |
| + |
| +await test("commandSummary lists every command (fed to Eleven's instructions)", () => { |
| + const s = commandSummary(); |
| + for (const name of commandNames()) assert.match(s, new RegExp(name.replace("/", "\\/"))); |
| +}); |
| + |
| +console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| +process.exit(failures === 0 ? 0 : 1); |
diff --git a/web/static/admin.js b/web/static/admin.js| index ba76314..ba75105 100644 |
| --- a/web/static/admin.js |
| +++ b/web/static/admin.js |
| @@ -201,7 +201,8 @@ function renderUsers(users) { |
| label.textContent = u.is_agent ? u.label || "(bot)" : "Pending invite"; |
| if (u.is_agent) { |
| const tag = document.createElement("span"); |
| - tag.textContent = "BOT"; |
| + // AI = LLM-backed (what it sees goes to a model provider); BOT = deterministic. |
| + tag.textContent = u.kind === "ai" ? "AI" : "BOT"; |
| tag.className = "bot-tag"; |
| label.appendChild(tag); |
| } |
| @@ -317,6 +318,9 @@ async function showEditBotModal(u) { |
| const docs = field(document.createElement("textarea"), cfg.docs); |
| docs.rows = 3; |
| docs.placeholder = "Reference the bot should always know. Optional."; |
| + const welcome = field(document.createElement("textarea"), cfg.welcome); |
| + welcome.rows = 3; |
| + welcome.placeholder = "Welcome DM sent to new members (the guide sends this). Use {name} for their name. Blank = default."; |
| const listen = field(document.createElement("select"), ""); |
| for (const [v, t] of [ |
| ["", "Replies in groups only when @mentioned (default)"], |
| @@ -369,6 +373,7 @@ async function showEditBotModal(u) { |
| docs: docs.value.trim(), |
| listen: listen.value, |
| description: desc.value.trim(), |
| + welcome: welcome.value.trim(), |
| available_to_members: avail.checked, |
| }); |
| if (r.ok) { |
diff --git a/web/static/chat.js b/web/static/chat.js| index bb00baa..d97e4a4 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -622,10 +622,13 @@ function rosterLabel(userId) { |
| // you add it that it isn't a person. Self-contained styling (no CSS dependency). |
| // Accent by default (a discovery cue in pickers/lists); pass muted=true inline |
| // on messages, where it repeats on every reply and bright accent reads as noise. |
| -function agentTag(muted) { |
| +function agentTag(muted, kind) { |
| const t = document.createElement("span"); |
| - t.textContent = "BOT"; |
| - t.title = "This is an agent (bot), not a person"; |
| + const ai = kind === "ai"; |
| + t.textContent = ai ? "AI" : "BOT"; |
| + t.title = ai |
| + ? "An AI helper — what you send it is sent to an AI provider to answer" |
| + : "An automated helper (bot), not a person — it runs on this server"; |
| t.style.cssText = |
| "margin-left:6px;font-size:0.7em;font-weight:600;letter-spacing:0.03em;padding:1px 5px;border-radius:6px;vertical-align:middle;transform:translateY(-1px);" + |
| (muted ? "background:var(--border,#ddd);color:var(--muted,#666);" : "background:var(--accent,#888);color:#fff;"); |
| @@ -638,7 +641,7 @@ function pickerName(e) { |
| const nm = document.createElement("span"); |
| nm.textContent = e.label; |
| if (e.is_agent) { |
| - nm.appendChild(agentTag()); |
| + nm.appendChild(agentTag(false, e.kind)); |
| if (e.description) { |
| const d = document.createElement("span"); |
| d.className = "helper-desc"; |
| @@ -1880,7 +1883,8 @@ async function appendMessage(threadId, m, scroll = true, container = null, cache |
| // on every message, not just in the picker. Sender names aren't signed, so |
| // this is matched against the roster's agent labels (same trust level as |
| // the name shown beside it). |
| - if (roster.some((e) => e.is_agent && e.label === data.name)) nm.appendChild(agentTag(true)); |
| + const ag = roster.find((e) => e.is_agent && e.label === data.name); |
| + if (ag) nm.appendChild(agentTag(true, ag.kind)); |
| wrap.appendChild(nm); |
| } |
| // The bubble + its quote/image/preview + hover icons + exact time live in |
| @@ -4047,7 +4051,7 @@ function showMembers() { |
| lbl.textContent = me ? `${name} (you)` : name; |
| // Transparency: a bot in the room stays visibly a bot, not just in the picker. |
| const entry = roster.find((x) => x.user_id === uid); |
| - if (entry && entry.is_agent) lbl.appendChild(agentTag()); |
| + if (entry && entry.is_agent) lbl.appendChild(agentTag(false, entry.kind)); |
| main.appendChild(lbl); |
| if (!me) |
| main.onclick = async () => { |