-
Notifications
You must be signed in to change notification settings - Fork 0
feat: /model inline keyboard buttons #70
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,25 +3,34 @@ | |
| * | ||
| * 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"; | ||
| import { join } from "node:path"; | ||
| import { readFileSync, writeFileSync } from "node:fs"; | ||
|
|
||
| /** Known model aliases → Bedrock model IDs */ | ||
| const MODEL_ALIASES: Record<string, { provider: string; model: string; label: string }> = { | ||
| export const MODEL_ALIASES: Record<string, { provider: string; model: string; label: string }> = { | ||
| "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<string, any>): void { | |
| function getCurrentModel(settings: Record<string, any>): 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<Array<{ text: string; callback_data: string }>> } { | ||
| 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<void> { | ||
|
|
@@ -57,31 +80,61 @@ export async function handleModel(ctx: ModelCommandContext): Promise<void> { | |
|
|
||
| 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: <b>${current}</b>\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]; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The inline-keyboard path derives Useful? React with 👍 / 👎. |
||
| 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 <arg> and inline keyboard callback). | ||
| */ | ||
| export async function applyModelSelection( | ||
| target: string, | ||
| settings: Record<string, any> | null, | ||
| thread: any, | ||
| postWithFallback: (thread: any, text: string) => Promise<void>, | ||
| ): Promise<void> { | ||
| 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<void> { | |
| 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<void> { | ||
| 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}`); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Dropping versioned aliases (for example
sonnet-4.6,haiku-4.5,opus-4.6) regresses existing/model <alias>inputs: these now missMODEL_ALIASESand fall into the raw-ID branch inapplyModelSelection, which writes values likedefaultModel = "sonnet-4.6"even though Bedrock expects full IDs (for exampleus.anthropic...). Users who previously used these aliases will silently persist invalid model IDs.Useful? React with 👍 / 👎.