From 096ba1d81066591b28bda479e32f7c998ede3c97 Mon Sep 17 00:00:00 2001 From: Loki FastStart Date: Sat, 9 May 2026 22:23:12 +0000 Subject: [PATCH] feat: /model shows inline keyboard buttons in Telegram When /model is used without arguments, shows clickable buttons for each available model (Opus 4.7, Opus 4.6, Sonnet 4.6, Haiku 4.5). Clicking a button immediately switches the model. Falls back to text list if inline keyboard fails. 393 tests green. --- src/gateway/gateway.ts | 7 ++- src/gateway/model-command.ts | 119 +++++++++++++++++++++++++++++------ 2 files changed, 107 insertions(+), 19 deletions(-) diff --git a/src/gateway/gateway.ts b/src/gateway/gateway.ts index 6acba75..c7502c8 100644 --- a/src/gateway/gateway.ts +++ b/src/gateway/gateway.ts @@ -23,7 +23,7 @@ import { isCommand as _isCmd, isCommandWithArgs as _isCmdArgs, resolveAgentThrea import { saveAttachments as _saveAttachments, type AttachmentResult } from "./attachments"; import { handleStreaming as _handleStream } from "./streaming"; import { handleNew, handleRestart, handleUpdate, handleCompact, handleStatus, handleStop, handleVerbose, handleDoctor, handleCrons, type CommandContext } from "./commands"; -import { handleModel } from "./model-command"; +import { handleModel, handleModelAction, MODEL_ACTION_ID } from "./model-command"; import { handleLater } from "./later-command"; import { TelegramAdapter } from "../transports"; import type { TransportAdapter } from "../transports"; @@ -327,6 +327,11 @@ export class Gateway { await handleOrAbort(thread, message); }); + // ── Handle inline keyboard callbacks ─── + this.chat.onAction(MODEL_ACTION_ID, async (event: any) => { + await handleModelAction({ value: event.value, thread: event.thread }); + }); + await this.chat.initialize(); const platforms = Object.keys(this.config.chat.adapters).join(", "); diff --git a/src/gateway/model-command.ts b/src/gateway/model-command.ts index f625248..58ec106 100644 --- a/src/gateway/model-command.ts +++ b/src/gateway/model-command.ts @@ -3,6 +3,9 @@ * * Allows switching the default AI model from Telegram. * Reads/writes ~/.pi/agent/settings.json (defaultProvider + defaultModel). + * + * When called without arguments, shows an inline keyboard with model buttons. + * When a button is clicked, the onAction handler applies the selection. */ import { homedir } from "node:os"; @@ -10,18 +13,24 @@ import { join } from "node:path"; import { readFileSync, writeFileSync } from "node:fs"; /** Known model aliases → Bedrock model IDs */ -const MODEL_ALIASES: Record = { +export const MODEL_ALIASES: Record = { "opus": { provider: "amazon-bedrock", model: "us.anthropic.claude-opus-4-6", label: "Claude Opus 4.6" }, - "opus-4.6": { provider: "amazon-bedrock", model: "us.anthropic.claude-opus-4-6", label: "Claude Opus 4.6" }, "opus-4.7": { provider: "amazon-bedrock", model: "us.anthropic.claude-opus-4-7", label: "Claude Opus 4.7" }, "sonnet": { provider: "amazon-bedrock", model: "us.anthropic.claude-sonnet-4-6", label: "Claude Sonnet 4.6" }, - "sonnet-4.6": { provider: "amazon-bedrock", model: "us.anthropic.claude-sonnet-4-6", label: "Claude Sonnet 4.6" }, "haiku": { provider: "amazon-bedrock", model: "us.anthropic.claude-haiku-4-5", label: "Claude Haiku 4.5" }, - "haiku-4.5": { provider: "amazon-bedrock", model: "us.anthropic.claude-haiku-4-5", label: "Claude Haiku 4.5" }, }; +/** Models shown in the inline keyboard (short aliases only) */ +const KEYBOARD_MODELS = ["opus-4.7", "opus", "sonnet", "haiku"] as const; + +/** Action ID for model selection callbacks */ +export const MODEL_ACTION_ID = "model_select"; + const SETTINGS_PATH = join(homedir(), ".pi", "agent", "settings.json"); +/** Callback data prefix used by @chat-adapter/telegram */ +const CALLBACK_PREFIX = "chat:"; + export interface ModelCommandContext { thread: any; text: string; @@ -43,11 +52,25 @@ function writeSettings(settings: Record): void { function getCurrentModel(settings: Record): string { const provider = settings.defaultProvider ?? "unknown"; const model = settings.defaultModel ?? "unknown"; - // Try to find a friendly label for (const [alias, info] of Object.entries(MODEL_ALIASES)) { - if (info.provider === provider && info.model === model) return `${info.label} (${alias})`; + if (info.provider === provider && info.model === model) return `${info.label}`; } - return `${provider}/${model}`; + return `${model}`; +} + +function encodeCallbackData(actionId: string, value: string): string { + return `${CALLBACK_PREFIX}${JSON.stringify({ a: actionId, v: value })}`; +} + +function buildInlineKeyboard(): { inline_keyboard: Array> } { + const rows = KEYBOARD_MODELS.map(alias => { + const info = MODEL_ALIASES[alias]; + return [{ + text: info.label, + callback_data: encodeCallbackData(MODEL_ACTION_ID, alias), + }]; + }); + return { inline_keyboard: rows }; } export async function handleModel(ctx: ModelCommandContext): Promise { @@ -57,31 +80,61 @@ export async function handleModel(ctx: ModelCommandContext): Promise { const settings = readSettings(); - // No argument: show current model + available options + // No argument: show inline keyboard if (!target) { const current = getCurrentModel(settings); - const aliases = Object.entries(MODEL_ALIASES) - .filter(([alias]) => !alias.includes(".")) // Show short aliases only - .map(([alias, info]) => ` \`${alias}\` → ${info.label}`) - .join("\n"); + const msgText = `🤖 Current model: ${current}\n\nSelect a model:`; + // Try to send with inline keyboard via telegramFetch + const adapter = thread?.adapter; + if (adapter?.telegramFetch) { + const chatId = thread?.platformThreadId?.split(":")?.[0] ?? thread?.id?.split(":")?.[0]; + if (chatId) { + try { + await adapter.telegramFetch("sendMessage", { + chat_id: chatId, + text: msgText, + parse_mode: "HTML", + reply_markup: buildInlineKeyboard(), + }); + return; + } catch (err) { + console.warn("[roundhouse] /model inline keyboard failed, falling back:", (err as Error).message); + } + } + } + + // Fallback: plain text + const aliases = KEYBOARD_MODELS.map(a => ` \`${a}\` → ${MODEL_ALIASES[a].label}`).join("\n"); await postWithFallback(thread, `🤖 *Current model:* ${current}\n\n*Available:*\n${aliases}\n\n_Usage:_ \`/model sonnet\``); return; } - // Resolve alias or use as raw model ID + // Resolve alias + await applyModelSelection(target, settings, thread, postWithFallback); +} + +/** + * Apply a model selection (used by both /model and inline keyboard callback). + */ +export async function applyModelSelection( + target: string, + settings: Record | null, + thread: any, + postWithFallback: (thread: any, text: string) => Promise, +): Promise { + if (!settings) settings = readSettings(); + const resolved = MODEL_ALIASES[target]; if (!resolved) { - // Check if it looks like a full model ID (contains a dot or slash) if (target.includes(".") || target.includes("/")) { - // Use as-is with current provider const provider = settings.defaultProvider ?? "amazon-bedrock"; settings.defaultModel = target; settings.defaultProvider = provider; writeSettings(settings); - await postWithFallback(thread, `✅ Model set to: \`${provider}/${target}\`\n\n⚠️ Restart needed: \`/restart\``); + await postWithFallback(thread, `✅ Model set to: \`${provider}/${target}\``); } else { - const aliases = Object.keys(MODEL_ALIASES).filter(a => !a.includes(".")).join(", "); + const aliases = Object.keys(MODEL_ALIASES).join(", "); await postWithFallback(thread, `❌ Unknown model: \`${target}\`\n\nAvailable: ${aliases}`); } return; @@ -91,6 +144,36 @@ export async function handleModel(ctx: ModelCommandContext): Promise { settings.defaultModel = resolved.model; writeSettings(settings); - await postWithFallback(thread, `✅ Model switched to: *${resolved.label}*\n\n⚠️ Takes effect on next agent turn (new sessions use new model).`); + await postWithFallback(thread, `✅ Switched to *${resolved.label}*`); console.log(`[roundhouse] /model: switched to ${resolved.provider}/${resolved.model}`); } + +/** + * Handle inline keyboard callback for model selection. + * Call this from chat.onAction(MODEL_ACTION_ID, ...). + */ +export async function handleModelAction(event: { + value?: string; + thread: any; +}): Promise { + const alias = event.value; + if (!alias) return; + + const settings = readSettings(); + const resolved = MODEL_ALIASES[alias]; + if (!resolved) return; + + settings.defaultProvider = resolved.provider; + settings.defaultModel = resolved.model; + writeSettings(settings); + + // Post confirmation to the thread + if (event.thread) { + try { + await event.thread.post({ markdown: `✅ Switched to *${resolved.label}*` }); + } catch { + try { await event.thread.post(`✅ Switched to ${resolved.label}`); } catch {} + } + } + console.log(`[roundhouse] /model (button): switched to ${resolved.provider}/${resolved.model}`); +}