Telegram: add inline button model selection for /models and /model commands
This commit is contained in:
committed by
Ayaan Zaidi
parent
efb4a34be4
commit
16349b6e93
@@ -10,10 +10,112 @@ import {
|
|||||||
resolveConfiguredModelRef,
|
resolveConfiguredModelRef,
|
||||||
resolveModelRefFromString,
|
resolveModelRefFromString,
|
||||||
} from "../../agents/model-selection.js";
|
} from "../../agents/model-selection.js";
|
||||||
|
import {
|
||||||
|
buildModelsKeyboard,
|
||||||
|
buildProviderKeyboard,
|
||||||
|
calculateTotalPages,
|
||||||
|
getModelsPageSize,
|
||||||
|
type ProviderInfo,
|
||||||
|
} from "../../telegram/model-buttons.js";
|
||||||
|
|
||||||
const PAGE_SIZE_DEFAULT = 20;
|
const PAGE_SIZE_DEFAULT = 20;
|
||||||
const PAGE_SIZE_MAX = 100;
|
const PAGE_SIZE_MAX = 100;
|
||||||
|
|
||||||
|
export type ModelsProviderData = {
|
||||||
|
byProvider: Map<string, Set<string>>;
|
||||||
|
providers: string[];
|
||||||
|
resolvedDefault: { provider: string; model: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build provider/model data from config and catalog.
|
||||||
|
* Exported for reuse by callback handlers.
|
||||||
|
*/
|
||||||
|
export async function buildModelsProviderData(cfg: OpenClawConfig): Promise<ModelsProviderData> {
|
||||||
|
const resolvedDefault = resolveConfiguredModelRef({
|
||||||
|
cfg,
|
||||||
|
defaultProvider: DEFAULT_PROVIDER,
|
||||||
|
defaultModel: DEFAULT_MODEL,
|
||||||
|
});
|
||||||
|
|
||||||
|
const catalog = await loadModelCatalog({ config: cfg });
|
||||||
|
const allowed = buildAllowedModelSet({
|
||||||
|
cfg,
|
||||||
|
catalog,
|
||||||
|
defaultProvider: resolvedDefault.provider,
|
||||||
|
defaultModel: resolvedDefault.model,
|
||||||
|
});
|
||||||
|
|
||||||
|
const aliasIndex = buildModelAliasIndex({
|
||||||
|
cfg,
|
||||||
|
defaultProvider: resolvedDefault.provider,
|
||||||
|
});
|
||||||
|
|
||||||
|
const byProvider = new Map<string, Set<string>>();
|
||||||
|
const add = (p: string, m: string) => {
|
||||||
|
const key = normalizeProviderId(p);
|
||||||
|
const set = byProvider.get(key) ?? new Set<string>();
|
||||||
|
set.add(m);
|
||||||
|
byProvider.set(key, set);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRawModelRef = (raw?: string) => {
|
||||||
|
const trimmed = raw?.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resolved = resolveModelRefFromString({
|
||||||
|
raw: trimmed,
|
||||||
|
defaultProvider: resolvedDefault.provider,
|
||||||
|
aliasIndex,
|
||||||
|
});
|
||||||
|
if (!resolved) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
add(resolved.ref.provider, resolved.ref.model);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addModelConfigEntries = () => {
|
||||||
|
const modelConfig = cfg.agents?.defaults?.model;
|
||||||
|
if (typeof modelConfig === "string") {
|
||||||
|
addRawModelRef(modelConfig);
|
||||||
|
} else if (modelConfig && typeof modelConfig === "object") {
|
||||||
|
addRawModelRef(modelConfig.primary);
|
||||||
|
for (const fallback of modelConfig.fallbacks ?? []) {
|
||||||
|
addRawModelRef(fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageConfig = cfg.agents?.defaults?.imageModel;
|
||||||
|
if (typeof imageConfig === "string") {
|
||||||
|
addRawModelRef(imageConfig);
|
||||||
|
} else if (imageConfig && typeof imageConfig === "object") {
|
||||||
|
addRawModelRef(imageConfig.primary);
|
||||||
|
for (const fallback of imageConfig.fallbacks ?? []) {
|
||||||
|
addRawModelRef(fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const entry of allowed.allowedCatalog) {
|
||||||
|
add(entry.provider, entry.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include config-only allowlist keys that aren't in the curated catalog.
|
||||||
|
for (const raw of Object.keys(cfg.agents?.defaults?.models ?? {})) {
|
||||||
|
addRawModelRef(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure configured defaults/fallbacks/image models show up even when the
|
||||||
|
// curated catalog doesn't know about them (custom providers, dev builds, etc.).
|
||||||
|
add(resolvedDefault.provider, resolvedDefault.model);
|
||||||
|
addModelConfigEntries();
|
||||||
|
|
||||||
|
const providers = [...byProvider.keys()].toSorted();
|
||||||
|
|
||||||
|
return { byProvider, providers, resolvedDefault };
|
||||||
|
}
|
||||||
|
|
||||||
function formatProviderLine(params: { provider: string; count: number }): string {
|
function formatProviderLine(params: { provider: string; count: number }): string {
|
||||||
return `- ${params.provider} (${params.count})`;
|
return `- ${params.provider} (${params.count})`;
|
||||||
}
|
}
|
||||||
@@ -78,6 +180,8 @@ function parseModelsArgs(raw: string): {
|
|||||||
export async function resolveModelsCommandReply(params: {
|
export async function resolveModelsCommandReply(params: {
|
||||||
cfg: OpenClawConfig;
|
cfg: OpenClawConfig;
|
||||||
commandBodyNormalized: string;
|
commandBodyNormalized: string;
|
||||||
|
surface?: string;
|
||||||
|
currentModel?: string;
|
||||||
}): Promise<ReplyPayload | null> {
|
}): Promise<ReplyPayload | null> {
|
||||||
const body = params.commandBodyNormalized.trim();
|
const body = params.commandBodyNormalized.trim();
|
||||||
if (!body.startsWith("/models")) {
|
if (!body.startsWith("/models")) {
|
||||||
@@ -87,88 +191,26 @@ export async function resolveModelsCommandReply(params: {
|
|||||||
const argText = body.replace(/^\/models\b/i, "").trim();
|
const argText = body.replace(/^\/models\b/i, "").trim();
|
||||||
const { provider, page, pageSize, all } = parseModelsArgs(argText);
|
const { provider, page, pageSize, all } = parseModelsArgs(argText);
|
||||||
|
|
||||||
const resolvedDefault = resolveConfiguredModelRef({
|
const { byProvider, providers } = await buildModelsProviderData(params.cfg);
|
||||||
cfg: params.cfg,
|
const isTelegram = params.surface === "telegram";
|
||||||
defaultProvider: DEFAULT_PROVIDER,
|
|
||||||
defaultModel: DEFAULT_MODEL,
|
|
||||||
});
|
|
||||||
|
|
||||||
const catalog = await loadModelCatalog({ config: params.cfg });
|
|
||||||
const allowed = buildAllowedModelSet({
|
|
||||||
cfg: params.cfg,
|
|
||||||
catalog,
|
|
||||||
defaultProvider: resolvedDefault.provider,
|
|
||||||
defaultModel: resolvedDefault.model,
|
|
||||||
});
|
|
||||||
|
|
||||||
const aliasIndex = buildModelAliasIndex({
|
|
||||||
cfg: params.cfg,
|
|
||||||
defaultProvider: resolvedDefault.provider,
|
|
||||||
});
|
|
||||||
|
|
||||||
const byProvider = new Map<string, Set<string>>();
|
|
||||||
const add = (p: string, m: string) => {
|
|
||||||
const key = normalizeProviderId(p);
|
|
||||||
const set = byProvider.get(key) ?? new Set<string>();
|
|
||||||
set.add(m);
|
|
||||||
byProvider.set(key, set);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addRawModelRef = (raw?: string) => {
|
|
||||||
const trimmed = raw?.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const resolved = resolveModelRefFromString({
|
|
||||||
raw: trimmed,
|
|
||||||
defaultProvider: resolvedDefault.provider,
|
|
||||||
aliasIndex,
|
|
||||||
});
|
|
||||||
if (!resolved) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
add(resolved.ref.provider, resolved.ref.model);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addModelConfigEntries = () => {
|
|
||||||
const modelConfig = params.cfg.agents?.defaults?.model;
|
|
||||||
if (typeof modelConfig === "string") {
|
|
||||||
addRawModelRef(modelConfig);
|
|
||||||
} else if (modelConfig && typeof modelConfig === "object") {
|
|
||||||
addRawModelRef(modelConfig.primary);
|
|
||||||
for (const fallback of modelConfig.fallbacks ?? []) {
|
|
||||||
addRawModelRef(fallback);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageConfig = params.cfg.agents?.defaults?.imageModel;
|
|
||||||
if (typeof imageConfig === "string") {
|
|
||||||
addRawModelRef(imageConfig);
|
|
||||||
} else if (imageConfig && typeof imageConfig === "object") {
|
|
||||||
addRawModelRef(imageConfig.primary);
|
|
||||||
for (const fallback of imageConfig.fallbacks ?? []) {
|
|
||||||
addRawModelRef(fallback);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const entry of allowed.allowedCatalog) {
|
|
||||||
add(entry.provider, entry.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Include config-only allowlist keys that aren't in the curated catalog.
|
|
||||||
for (const raw of Object.keys(params.cfg.agents?.defaults?.models ?? {})) {
|
|
||||||
addRawModelRef(raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure configured defaults/fallbacks/image models show up even when the
|
|
||||||
// curated catalog doesn't know about them (custom providers, dev builds, etc.).
|
|
||||||
add(resolvedDefault.provider, resolvedDefault.model);
|
|
||||||
addModelConfigEntries();
|
|
||||||
|
|
||||||
const providers = [...byProvider.keys()].toSorted();
|
|
||||||
|
|
||||||
|
// Provider list (no provider specified)
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
|
// For Telegram: show buttons if there are providers
|
||||||
|
if (isTelegram && providers.length > 0) {
|
||||||
|
const providerInfos: ProviderInfo[] = providers.map((p) => ({
|
||||||
|
id: p,
|
||||||
|
count: byProvider.get(p)?.size ?? 0,
|
||||||
|
}));
|
||||||
|
const buttons = buildProviderKeyboard(providerInfos);
|
||||||
|
const text = "Select a provider:";
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
channelData: { telegram: { buttons } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text fallback for non-Telegram surfaces
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
"Providers:",
|
"Providers:",
|
||||||
...providers.map((p) =>
|
...providers.map((p) =>
|
||||||
@@ -206,6 +248,29 @@ export async function resolveModelsCommandReply(params: {
|
|||||||
return { text: lines.join("\n") };
|
return { text: lines.join("\n") };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For Telegram: use button-based model list with inline keyboard pagination
|
||||||
|
if (isTelegram) {
|
||||||
|
const telegramPageSize = getModelsPageSize();
|
||||||
|
const totalPages = calculateTotalPages(total, telegramPageSize);
|
||||||
|
const safePage = Math.max(1, Math.min(page, totalPages));
|
||||||
|
|
||||||
|
const buttons = buildModelsKeyboard({
|
||||||
|
provider,
|
||||||
|
models,
|
||||||
|
currentModel: params.currentModel,
|
||||||
|
currentPage: safePage,
|
||||||
|
totalPages,
|
||||||
|
pageSize: telegramPageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = `Models (${provider}) — ${total} available`;
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
channelData: { telegram: { buttons } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text fallback for non-Telegram surfaces
|
||||||
const effectivePageSize = all ? total : pageSize;
|
const effectivePageSize = all ? total : pageSize;
|
||||||
const pageCount = effectivePageSize > 0 ? Math.ceil(total / effectivePageSize) : 1;
|
const pageCount = effectivePageSize > 0 ? Math.ceil(total / effectivePageSize) : 1;
|
||||||
const safePage = all ? 1 : Math.max(1, Math.min(page, pageCount));
|
const safePage = all ? 1 : Math.max(1, Math.min(page, pageCount));
|
||||||
@@ -251,6 +316,8 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
|
|||||||
const reply = await resolveModelsCommandReply({
|
const reply = await resolveModelsCommandReply({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
commandBodyNormalized: params.command.commandBodyNormalized,
|
commandBodyNormalized: params.command.commandBodyNormalized,
|
||||||
|
surface: params.ctx.Surface,
|
||||||
|
currentModel: params.model ? `${params.provider}/${params.model}` : undefined,
|
||||||
});
|
});
|
||||||
if (!reply) {
|
if (!reply) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ describe("/models command", () => {
|
|||||||
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
|
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
|
||||||
} as unknown as OpenClawConfig;
|
} as unknown as OpenClawConfig;
|
||||||
|
|
||||||
it.each(["telegram", "discord", "whatsapp"])("lists providers on %s", async (surface) => {
|
it.each(["discord", "whatsapp"])("lists providers on %s (text)", async (surface) => {
|
||||||
const params = buildParams("/models", cfg, { Provider: surface, Surface: surface });
|
const params = buildParams("/models", cfg, { Provider: surface, Surface: surface });
|
||||||
const result = await handleCommands(params);
|
const result = await handleCommands(params);
|
||||||
expect(result.shouldContinue).toBe(false);
|
expect(result.shouldContinue).toBe(false);
|
||||||
@@ -162,8 +162,20 @@ describe("/models command", () => {
|
|||||||
expect(result.reply?.text).toContain("Use: /models <provider>");
|
expect(result.reply?.text).toContain("Use: /models <provider>");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lists providers on telegram (buttons)", async () => {
|
||||||
|
const params = buildParams("/models", cfg, { Provider: "telegram", Surface: "telegram" });
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toBe("Select a provider:");
|
||||||
|
const buttons = (result.reply?.channelData as { telegram?: { buttons?: unknown[][] } })
|
||||||
|
?.telegram?.buttons;
|
||||||
|
expect(buttons).toBeDefined();
|
||||||
|
expect(buttons?.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("lists provider models with pagination hints", async () => {
|
it("lists provider models with pagination hints", async () => {
|
||||||
const params = buildParams("/models anthropic", cfg);
|
// Use discord surface for text-based output tests
|
||||||
|
const params = buildParams("/models anthropic", cfg, { Surface: "discord" });
|
||||||
const result = await handleCommands(params);
|
const result = await handleCommands(params);
|
||||||
expect(result.shouldContinue).toBe(false);
|
expect(result.shouldContinue).toBe(false);
|
||||||
expect(result.reply?.text).toContain("Models (anthropic)");
|
expect(result.reply?.text).toContain("Models (anthropic)");
|
||||||
@@ -174,7 +186,8 @@ describe("/models command", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("ignores page argument when all flag is present", async () => {
|
it("ignores page argument when all flag is present", async () => {
|
||||||
const params = buildParams("/models anthropic 3 all", cfg);
|
// Use discord surface for text-based output tests
|
||||||
|
const params = buildParams("/models anthropic 3 all", cfg, { Surface: "discord" });
|
||||||
const result = await handleCommands(params);
|
const result = await handleCommands(params);
|
||||||
expect(result.shouldContinue).toBe(false);
|
expect(result.shouldContinue).toBe(false);
|
||||||
expect(result.reply?.text).toContain("Models (anthropic)");
|
expect(result.reply?.text).toContain("Models (anthropic)");
|
||||||
@@ -184,7 +197,8 @@ describe("/models command", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("errors on out-of-range pages", async () => {
|
it("errors on out-of-range pages", async () => {
|
||||||
const params = buildParams("/models anthropic 4", cfg);
|
// Use discord surface for text-based output tests
|
||||||
|
const params = buildParams("/models anthropic 4", cfg, { Surface: "discord" });
|
||||||
const result = await handleCommands(params);
|
const result = await handleCommands(params);
|
||||||
expect(result.shouldContinue).toBe(false);
|
expect(result.shouldContinue).toBe(false);
|
||||||
expect(result.reply?.text).toContain("Page out of range");
|
expect(result.reply?.text).toContain("Page out of range");
|
||||||
@@ -213,11 +227,16 @@ describe("/models command", () => {
|
|||||||
},
|
},
|
||||||
} as unknown as OpenClawConfig;
|
} as unknown as OpenClawConfig;
|
||||||
|
|
||||||
const providerList = await handleCommands(buildParams("/models", customCfg));
|
// Use discord surface for text-based output tests
|
||||||
|
const providerList = await handleCommands(
|
||||||
|
buildParams("/models", customCfg, { Surface: "discord" }),
|
||||||
|
);
|
||||||
expect(providerList.reply?.text).toContain("localai");
|
expect(providerList.reply?.text).toContain("localai");
|
||||||
expect(providerList.reply?.text).toContain("visionpro");
|
expect(providerList.reply?.text).toContain("visionpro");
|
||||||
|
|
||||||
const result = await handleCommands(buildParams("/models localai", customCfg));
|
const result = await handleCommands(
|
||||||
|
buildParams("/models localai", customCfg, { Surface: "discord" }),
|
||||||
|
);
|
||||||
expect(result.shouldContinue).toBe(false);
|
expect(result.shouldContinue).toBe(false);
|
||||||
expect(result.reply?.text).toContain("Models (localai)");
|
expect(result.reply?.text).toContain("Models (localai)");
|
||||||
expect(result.reply?.text).toContain("localai/ultra-chat");
|
expect(result.reply?.text).toContain("localai/ultra-chat");
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export async function handleDirectiveOnly(params: {
|
|||||||
currentVerboseLevel?: VerboseLevel;
|
currentVerboseLevel?: VerboseLevel;
|
||||||
currentReasoningLevel?: ReasoningLevel;
|
currentReasoningLevel?: ReasoningLevel;
|
||||||
currentElevatedLevel?: ElevatedLevel;
|
currentElevatedLevel?: ElevatedLevel;
|
||||||
|
surface?: string;
|
||||||
}): Promise<ReplyPayload | undefined> {
|
}): Promise<ReplyPayload | undefined> {
|
||||||
const {
|
const {
|
||||||
directives,
|
directives,
|
||||||
@@ -132,6 +133,7 @@ export async function handleDirectiveOnly(params: {
|
|||||||
aliasIndex,
|
aliasIndex,
|
||||||
allowedModelCatalog,
|
allowedModelCatalog,
|
||||||
resetModelOverride,
|
resetModelOverride,
|
||||||
|
surface: params.surface,
|
||||||
});
|
});
|
||||||
if (modelInfo) {
|
if (modelInfo) {
|
||||||
return modelInfo;
|
return modelInfo;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
resolveConfiguredModelRef,
|
resolveConfiguredModelRef,
|
||||||
resolveModelRefFromString,
|
resolveModelRefFromString,
|
||||||
} from "../../agents/model-selection.js";
|
} from "../../agents/model-selection.js";
|
||||||
|
import { buildBrowseProvidersButton } from "../../telegram/model-buttons.js";
|
||||||
import { shortenHomePath } from "../../utils.js";
|
import { shortenHomePath } from "../../utils.js";
|
||||||
import { resolveModelsCommandReply } from "./commands-models.js";
|
import { resolveModelsCommandReply } from "./commands-models.js";
|
||||||
import {
|
import {
|
||||||
@@ -177,6 +178,7 @@ export async function maybeHandleModelDirectiveInfo(params: {
|
|||||||
aliasIndex: ModelAliasIndex;
|
aliasIndex: ModelAliasIndex;
|
||||||
allowedModelCatalog: Array<{ provider: string; id?: string; name?: string }>;
|
allowedModelCatalog: Array<{ provider: string; id?: string; name?: string }>;
|
||||||
resetModelOverride: boolean;
|
resetModelOverride: boolean;
|
||||||
|
surface?: string;
|
||||||
}): Promise<ReplyPayload | undefined> {
|
}): Promise<ReplyPayload | undefined> {
|
||||||
if (!params.directives.hasModelDirective) {
|
if (!params.directives.hasModelDirective) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -213,6 +215,22 @@ export async function maybeHandleModelDirectiveInfo(params: {
|
|||||||
|
|
||||||
if (wantsSummary) {
|
if (wantsSummary) {
|
||||||
const current = `${params.provider}/${params.model}`;
|
const current = `${params.provider}/${params.model}`;
|
||||||
|
const isTelegram = params.surface === "telegram";
|
||||||
|
|
||||||
|
if (isTelegram) {
|
||||||
|
const buttons = buildBrowseProvidersButton();
|
||||||
|
return {
|
||||||
|
text: [
|
||||||
|
`Current: ${current}`,
|
||||||
|
"",
|
||||||
|
"Tap below to browse models, or use:",
|
||||||
|
"/model <provider/model> to switch",
|
||||||
|
"/model status for details",
|
||||||
|
].join("\n"),
|
||||||
|
channelData: { telegram: { buttons } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: [
|
text: [
|
||||||
`Current: ${current}`,
|
`Current: ${current}`,
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ export async function applyInlineDirectiveOverrides(params: {
|
|||||||
currentVerboseLevel,
|
currentVerboseLevel,
|
||||||
currentReasoningLevel,
|
currentReasoningLevel,
|
||||||
currentElevatedLevel,
|
currentElevatedLevel,
|
||||||
|
surface: ctx.Surface,
|
||||||
});
|
});
|
||||||
let statusReply: ReplyPayload | undefined;
|
let statusReply: ReplyPayload | undefined;
|
||||||
if (directives.hasStatusDirective && allowTextCommands && command.isAuthorizedSender) {
|
if (directives.hasStatusDirective && allowTextCommands && command.isAuthorizedSender) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
resolveInboundDebounceMs,
|
resolveInboundDebounceMs,
|
||||||
} from "../auto-reply/inbound-debounce.js";
|
} from "../auto-reply/inbound-debounce.js";
|
||||||
import { buildCommandsPaginationKeyboard } from "../auto-reply/reply/commands-info.js";
|
import { buildCommandsPaginationKeyboard } from "../auto-reply/reply/commands-info.js";
|
||||||
|
import { buildModelsProviderData } from "../auto-reply/reply/commands-models.js";
|
||||||
import { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js";
|
import { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js";
|
||||||
import { buildCommandsMessagePaginated } from "../auto-reply/status.js";
|
import { buildCommandsMessagePaginated } from "../auto-reply/status.js";
|
||||||
import { resolveChannelConfigWrites } from "../channels/plugins/config-writes.js";
|
import { resolveChannelConfigWrites } from "../channels/plugins/config-writes.js";
|
||||||
@@ -22,6 +23,14 @@ import { resolveMedia } from "./bot/delivery.js";
|
|||||||
import { resolveTelegramForumThreadId } from "./bot/helpers.js";
|
import { resolveTelegramForumThreadId } from "./bot/helpers.js";
|
||||||
import { migrateTelegramGroupConfig } from "./group-migration.js";
|
import { migrateTelegramGroupConfig } from "./group-migration.js";
|
||||||
import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js";
|
import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js";
|
||||||
|
import {
|
||||||
|
buildModelsKeyboard,
|
||||||
|
buildProviderKeyboard,
|
||||||
|
calculateTotalPages,
|
||||||
|
getModelsPageSize,
|
||||||
|
parseModelCallbackData,
|
||||||
|
type ProviderInfo,
|
||||||
|
} from "./model-buttons.js";
|
||||||
import { buildInlineKeyboard } from "./send.js";
|
import { buildInlineKeyboard } from "./send.js";
|
||||||
|
|
||||||
export const registerTelegramHandlers = ({
|
export const registerTelegramHandlers = ({
|
||||||
@@ -404,6 +413,107 @@ export const registerTelegramHandlers = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Model selection callback handler (mdl_prov, mdl_list_*, mdl_sel_*, mdl_back)
|
||||||
|
const modelCallback = parseModelCallbackData(data);
|
||||||
|
if (modelCallback) {
|
||||||
|
const modelData = await buildModelsProviderData(cfg);
|
||||||
|
const { byProvider, providers } = modelData;
|
||||||
|
|
||||||
|
const editMessageWithButtons = async (
|
||||||
|
text: string,
|
||||||
|
buttons: ReturnType<typeof buildProviderKeyboard>,
|
||||||
|
) => {
|
||||||
|
const keyboard = buildInlineKeyboard(buttons);
|
||||||
|
try {
|
||||||
|
await bot.api.editMessageText(
|
||||||
|
callbackMessage.chat.id,
|
||||||
|
callbackMessage.message_id,
|
||||||
|
text,
|
||||||
|
keyboard ? { reply_markup: keyboard } : undefined,
|
||||||
|
);
|
||||||
|
} catch (editErr) {
|
||||||
|
const errStr = String(editErr);
|
||||||
|
if (!errStr.includes("message is not modified")) {
|
||||||
|
throw editErr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (modelCallback.type === "providers" || modelCallback.type === "back") {
|
||||||
|
if (providers.length === 0) {
|
||||||
|
await editMessageWithButtons("No providers available.", []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const providerInfos: ProviderInfo[] = providers.map((p) => ({
|
||||||
|
id: p,
|
||||||
|
count: byProvider.get(p)?.size ?? 0,
|
||||||
|
}));
|
||||||
|
const buttons = buildProviderKeyboard(providerInfos);
|
||||||
|
await editMessageWithButtons("Select a provider:", buttons);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modelCallback.type === "list") {
|
||||||
|
const { provider, page } = modelCallback;
|
||||||
|
const modelSet = byProvider.get(provider);
|
||||||
|
if (!modelSet || modelSet.size === 0) {
|
||||||
|
// Provider not found or no models - show providers list
|
||||||
|
const providerInfos: ProviderInfo[] = providers.map((p) => ({
|
||||||
|
id: p,
|
||||||
|
count: byProvider.get(p)?.size ?? 0,
|
||||||
|
}));
|
||||||
|
const buttons = buildProviderKeyboard(providerInfos);
|
||||||
|
await editMessageWithButtons(
|
||||||
|
`Unknown provider: ${provider}\n\nSelect a provider:`,
|
||||||
|
buttons,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const models = [...modelSet].toSorted();
|
||||||
|
const pageSize = getModelsPageSize();
|
||||||
|
const totalPages = calculateTotalPages(models.length, pageSize);
|
||||||
|
const safePage = Math.max(1, Math.min(page, totalPages));
|
||||||
|
|
||||||
|
const buttons = buildModelsKeyboard({
|
||||||
|
provider,
|
||||||
|
models,
|
||||||
|
currentPage: safePage,
|
||||||
|
totalPages,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
const text = `Models (${provider}) — ${models.length} available`;
|
||||||
|
await editMessageWithButtons(text, buttons);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modelCallback.type === "select") {
|
||||||
|
const { provider, model } = modelCallback;
|
||||||
|
// Process model selection as a synthetic message with /model command
|
||||||
|
const syntheticMessage: TelegramMessage = {
|
||||||
|
...callbackMessage,
|
||||||
|
from: callback.from,
|
||||||
|
text: `/model ${provider}/${model}`,
|
||||||
|
caption: undefined,
|
||||||
|
caption_entities: undefined,
|
||||||
|
entities: undefined,
|
||||||
|
};
|
||||||
|
const getFile =
|
||||||
|
typeof ctx.getFile === "function" ? ctx.getFile.bind(ctx) : async () => ({});
|
||||||
|
await processMessage(
|
||||||
|
{ message: syntheticMessage, me: ctx.me, getFile },
|
||||||
|
[],
|
||||||
|
storeAllowFrom,
|
||||||
|
{
|
||||||
|
forceWasMentioned: true,
|
||||||
|
messageIdOverride: callback.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const syntheticMessage: TelegramMessage = {
|
const syntheticMessage: TelegramMessage = {
|
||||||
...callbackMessage,
|
...callbackMessage,
|
||||||
from: callback.from,
|
from: callback.from,
|
||||||
|
|||||||
244
src/telegram/model-buttons.test.ts
Normal file
244
src/telegram/model-buttons.test.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildModelsKeyboard,
|
||||||
|
buildProviderKeyboard,
|
||||||
|
buildBrowseProvidersButton,
|
||||||
|
calculateTotalPages,
|
||||||
|
getModelsPageSize,
|
||||||
|
parseModelCallbackData,
|
||||||
|
type ProviderInfo,
|
||||||
|
} from "./model-buttons.js";
|
||||||
|
|
||||||
|
describe("parseModelCallbackData", () => {
|
||||||
|
it("parses mdl_prov callback", () => {
|
||||||
|
const result = parseModelCallbackData("mdl_prov");
|
||||||
|
expect(result).toEqual({ type: "providers" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses mdl_back callback", () => {
|
||||||
|
const result = parseModelCallbackData("mdl_back");
|
||||||
|
expect(result).toEqual({ type: "back" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses mdl_list callback with provider and page", () => {
|
||||||
|
const result = parseModelCallbackData("mdl_list_anthropic_2");
|
||||||
|
expect(result).toEqual({ type: "list", provider: "anthropic", page: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses mdl_list callback with hyphenated provider", () => {
|
||||||
|
const result = parseModelCallbackData("mdl_list_open-ai_1");
|
||||||
|
expect(result).toEqual({ type: "list", provider: "open-ai", page: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses mdl_sel callback with provider/model", () => {
|
||||||
|
const result = parseModelCallbackData("mdl_sel_anthropic/claude-sonnet-4-5");
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: "select",
|
||||||
|
provider: "anthropic",
|
||||||
|
model: "claude-sonnet-4-5",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses mdl_sel callback with nested model path", () => {
|
||||||
|
const result = parseModelCallbackData("mdl_sel_openai/gpt-4/turbo");
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: "select",
|
||||||
|
provider: "openai",
|
||||||
|
model: "gpt-4/turbo",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for non-model callback data", () => {
|
||||||
|
expect(parseModelCallbackData("commands_page_1")).toBeNull();
|
||||||
|
expect(parseModelCallbackData("other_callback")).toBeNull();
|
||||||
|
expect(parseModelCallbackData("")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for invalid mdl_ patterns", () => {
|
||||||
|
expect(parseModelCallbackData("mdl_invalid")).toBeNull();
|
||||||
|
expect(parseModelCallbackData("mdl_list_")).toBeNull();
|
||||||
|
expect(parseModelCallbackData("mdl_sel_noslash")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles whitespace in callback data", () => {
|
||||||
|
expect(parseModelCallbackData(" mdl_prov ")).toEqual({ type: "providers" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildProviderKeyboard", () => {
|
||||||
|
it("returns empty array for no providers", () => {
|
||||||
|
const result = buildProviderKeyboard([]);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds single provider as one row", () => {
|
||||||
|
const providers: ProviderInfo[] = [{ id: "anthropic", count: 5 }];
|
||||||
|
const result = buildProviderKeyboard(providers);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toHaveLength(1);
|
||||||
|
expect(result[0]?.[0]?.text).toBe("anthropic (5)");
|
||||||
|
expect(result[0]?.[0]?.callback_data).toBe("mdl_list_anthropic_1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds two providers per row", () => {
|
||||||
|
const providers: ProviderInfo[] = [
|
||||||
|
{ id: "anthropic", count: 5 },
|
||||||
|
{ id: "openai", count: 8 },
|
||||||
|
];
|
||||||
|
const result = buildProviderKeyboard(providers);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toHaveLength(2);
|
||||||
|
expect(result[0]?.[0]?.text).toBe("anthropic (5)");
|
||||||
|
expect(result[0]?.[1]?.text).toBe("openai (8)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps to next row after two providers", () => {
|
||||||
|
const providers: ProviderInfo[] = [
|
||||||
|
{ id: "anthropic", count: 5 },
|
||||||
|
{ id: "openai", count: 8 },
|
||||||
|
{ id: "google", count: 3 },
|
||||||
|
];
|
||||||
|
const result = buildProviderKeyboard(providers);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0]).toHaveLength(2);
|
||||||
|
expect(result[1]).toHaveLength(1);
|
||||||
|
expect(result[1]?.[0]?.text).toBe("google (3)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildModelsKeyboard", () => {
|
||||||
|
it("shows back button for empty models", () => {
|
||||||
|
const result = buildModelsKeyboard({
|
||||||
|
provider: "anthropic",
|
||||||
|
models: [],
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
});
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]?.[0]?.text).toBe("<< Back");
|
||||||
|
expect(result[0]?.[0]?.callback_data).toBe("mdl_back");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows models with one per row", () => {
|
||||||
|
const result = buildModelsKeyboard({
|
||||||
|
provider: "anthropic",
|
||||||
|
models: ["claude-sonnet-4", "claude-opus-4"],
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
});
|
||||||
|
// 2 model rows + back button
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
expect(result[0]?.[0]?.text).toBe("claude-sonnet-4");
|
||||||
|
expect(result[0]?.[0]?.callback_data).toBe("mdl_sel_anthropic/claude-sonnet-4");
|
||||||
|
expect(result[1]?.[0]?.text).toBe("claude-opus-4");
|
||||||
|
expect(result[2]?.[0]?.text).toBe("<< Back");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks current model with checkmark", () => {
|
||||||
|
const result = buildModelsKeyboard({
|
||||||
|
provider: "anthropic",
|
||||||
|
models: ["claude-sonnet-4", "claude-opus-4"],
|
||||||
|
currentModel: "anthropic/claude-sonnet-4",
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
});
|
||||||
|
expect(result[0]?.[0]?.text).toBe("claude-sonnet-4 ✓");
|
||||||
|
expect(result[1]?.[0]?.text).toBe("claude-opus-4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows pagination when multiple pages", () => {
|
||||||
|
const result = buildModelsKeyboard({
|
||||||
|
provider: "anthropic",
|
||||||
|
models: ["model1", "model2"],
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 3,
|
||||||
|
pageSize: 2,
|
||||||
|
});
|
||||||
|
// 2 model rows + pagination row + back button
|
||||||
|
expect(result).toHaveLength(4);
|
||||||
|
const paginationRow = result[2];
|
||||||
|
expect(paginationRow).toHaveLength(2); // no prev on first page
|
||||||
|
expect(paginationRow?.[0]?.text).toBe("1/3");
|
||||||
|
expect(paginationRow?.[1]?.text).toBe("Next ▶");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows prev and next on middle pages", () => {
|
||||||
|
// 6 models with pageSize 2 = 3 pages
|
||||||
|
const result = buildModelsKeyboard({
|
||||||
|
provider: "anthropic",
|
||||||
|
models: ["model1", "model2", "model3", "model4", "model5", "model6"],
|
||||||
|
currentPage: 2,
|
||||||
|
totalPages: 3,
|
||||||
|
pageSize: 2,
|
||||||
|
});
|
||||||
|
// 2 model rows + pagination row + back button
|
||||||
|
expect(result).toHaveLength(4);
|
||||||
|
const paginationRow = result[2];
|
||||||
|
expect(paginationRow).toHaveLength(3);
|
||||||
|
expect(paginationRow?.[0]?.text).toBe("◀ Prev");
|
||||||
|
expect(paginationRow?.[1]?.text).toBe("2/3");
|
||||||
|
expect(paginationRow?.[2]?.text).toBe("Next ▶");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows only prev on last page", () => {
|
||||||
|
// 6 models with pageSize 2 = 3 pages
|
||||||
|
const result = buildModelsKeyboard({
|
||||||
|
provider: "anthropic",
|
||||||
|
models: ["model1", "model2", "model3", "model4", "model5", "model6"],
|
||||||
|
currentPage: 3,
|
||||||
|
totalPages: 3,
|
||||||
|
pageSize: 2,
|
||||||
|
});
|
||||||
|
// 2 model rows + pagination row + back button
|
||||||
|
expect(result).toHaveLength(4);
|
||||||
|
const paginationRow = result[2];
|
||||||
|
expect(paginationRow).toHaveLength(2);
|
||||||
|
expect(paginationRow?.[0]?.text).toBe("◀ Prev");
|
||||||
|
expect(paginationRow?.[1]?.text).toBe("3/3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates long model IDs", () => {
|
||||||
|
const longModel = "this-is-a-very-long-model-name-that-exceeds-the-limit";
|
||||||
|
const result = buildModelsKeyboard({
|
||||||
|
provider: "anthropic",
|
||||||
|
models: [longModel],
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
});
|
||||||
|
const text = result[0]?.[0]?.text;
|
||||||
|
expect(text?.startsWith("…")).toBe(true);
|
||||||
|
expect(text?.length).toBeLessThanOrEqual(39); // 38 max + possible checkmark
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildBrowseProvidersButton", () => {
|
||||||
|
it("returns browse providers button", () => {
|
||||||
|
const result = buildBrowseProvidersButton();
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toHaveLength(1);
|
||||||
|
expect(result[0]?.[0]?.text).toBe("Browse providers");
|
||||||
|
expect(result[0]?.[0]?.callback_data).toBe("mdl_prov");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getModelsPageSize", () => {
|
||||||
|
it("returns default page size", () => {
|
||||||
|
expect(getModelsPageSize()).toBe(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("calculateTotalPages", () => {
|
||||||
|
it("calculates pages correctly", () => {
|
||||||
|
expect(calculateTotalPages(0)).toBe(0);
|
||||||
|
expect(calculateTotalPages(1)).toBe(1);
|
||||||
|
expect(calculateTotalPages(8)).toBe(1);
|
||||||
|
expect(calculateTotalPages(9)).toBe(2);
|
||||||
|
expect(calculateTotalPages(16)).toBe(2);
|
||||||
|
expect(calculateTotalPages(17)).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses custom page size", () => {
|
||||||
|
expect(calculateTotalPages(10, 5)).toBe(2);
|
||||||
|
expect(calculateTotalPages(11, 5)).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
210
src/telegram/model-buttons.ts
Normal file
210
src/telegram/model-buttons.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
/**
|
||||||
|
* Telegram inline button utilities for model selection.
|
||||||
|
*
|
||||||
|
* Callback data patterns (max 64 bytes for Telegram):
|
||||||
|
* - mdl_prov - show providers list
|
||||||
|
* - mdl_list_{prov}_{pg} - show models for provider (page N, 1-indexed)
|
||||||
|
* - mdl_sel_{provider/id} - select model
|
||||||
|
* - mdl_back - back to providers list
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ButtonRow = Array<{ text: string; callback_data: string }>;
|
||||||
|
|
||||||
|
export type ParsedModelCallback =
|
||||||
|
| { type: "providers" }
|
||||||
|
| { type: "list"; provider: string; page: number }
|
||||||
|
| { type: "select"; provider: string; model: string }
|
||||||
|
| { type: "back" };
|
||||||
|
|
||||||
|
export type ProviderInfo = {
|
||||||
|
id: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModelsKeyboardParams = {
|
||||||
|
provider: string;
|
||||||
|
models: string[];
|
||||||
|
currentModel?: string;
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
pageSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MODELS_PAGE_SIZE = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a model callback_data string into a structured object.
|
||||||
|
* Returns null if the data doesn't match a known pattern.
|
||||||
|
*/
|
||||||
|
export function parseModelCallbackData(data: string): ParsedModelCallback | null {
|
||||||
|
const trimmed = data.trim();
|
||||||
|
if (!trimmed.startsWith("mdl_")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed === "mdl_prov" || trimmed === "mdl_back") {
|
||||||
|
return { type: trimmed === "mdl_prov" ? "providers" : "back" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// mdl_list_{provider}_{page}
|
||||||
|
const listMatch = trimmed.match(/^mdl_list_([a-z0-9_-]+)_(\d+)$/i);
|
||||||
|
if (listMatch) {
|
||||||
|
const [, provider, pageStr] = listMatch;
|
||||||
|
const page = Number.parseInt(pageStr ?? "1", 10);
|
||||||
|
if (provider && Number.isFinite(page) && page >= 1) {
|
||||||
|
return { type: "list", provider, page };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mdl_sel_{provider/model}
|
||||||
|
const selMatch = trimmed.match(/^mdl_sel_(.+)$/);
|
||||||
|
if (selMatch) {
|
||||||
|
const modelRef = selMatch[1];
|
||||||
|
if (modelRef) {
|
||||||
|
const slashIndex = modelRef.indexOf("/");
|
||||||
|
if (slashIndex > 0 && slashIndex < modelRef.length - 1) {
|
||||||
|
return {
|
||||||
|
type: "select",
|
||||||
|
provider: modelRef.slice(0, slashIndex),
|
||||||
|
model: modelRef.slice(slashIndex + 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build provider selection keyboard with 2 providers per row.
|
||||||
|
*/
|
||||||
|
export function buildProviderKeyboard(providers: ProviderInfo[]): ButtonRow[] {
|
||||||
|
if (providers.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: ButtonRow[] = [];
|
||||||
|
let currentRow: ButtonRow = [];
|
||||||
|
|
||||||
|
for (const provider of providers) {
|
||||||
|
const button = {
|
||||||
|
text: `${provider.id} (${provider.count})`,
|
||||||
|
callback_data: `mdl_list_${provider.id}_1`,
|
||||||
|
};
|
||||||
|
|
||||||
|
currentRow.push(button);
|
||||||
|
|
||||||
|
if (currentRow.length === 2) {
|
||||||
|
rows.push(currentRow);
|
||||||
|
currentRow = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push any remaining button
|
||||||
|
if (currentRow.length > 0) {
|
||||||
|
rows.push(currentRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build model list keyboard with pagination and back button.
|
||||||
|
*/
|
||||||
|
export function buildModelsKeyboard(params: ModelsKeyboardParams): ButtonRow[] {
|
||||||
|
const { provider, models, currentModel, currentPage, totalPages } = params;
|
||||||
|
const pageSize = params.pageSize ?? MODELS_PAGE_SIZE;
|
||||||
|
|
||||||
|
if (models.length === 0) {
|
||||||
|
return [[{ text: "<< Back", callback_data: "mdl_back" }]];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: ButtonRow[] = [];
|
||||||
|
|
||||||
|
// Calculate page slice
|
||||||
|
const startIndex = (currentPage - 1) * pageSize;
|
||||||
|
const endIndex = Math.min(startIndex + pageSize, models.length);
|
||||||
|
const pageModels = models.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
// Model buttons - one per row
|
||||||
|
const currentModelId = currentModel?.includes("/")
|
||||||
|
? currentModel.split("/").slice(1).join("/")
|
||||||
|
: currentModel;
|
||||||
|
|
||||||
|
for (const model of pageModels) {
|
||||||
|
const isCurrentModel = model === currentModelId;
|
||||||
|
const displayText = truncateModelId(model, 38);
|
||||||
|
const text = isCurrentModel ? `${displayText} ✓` : displayText;
|
||||||
|
|
||||||
|
rows.push([
|
||||||
|
{
|
||||||
|
text,
|
||||||
|
callback_data: `mdl_sel_${provider}/${model}`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination row
|
||||||
|
if (totalPages > 1) {
|
||||||
|
const paginationRow: ButtonRow = [];
|
||||||
|
|
||||||
|
if (currentPage > 1) {
|
||||||
|
paginationRow.push({
|
||||||
|
text: "◀ Prev",
|
||||||
|
callback_data: `mdl_list_${provider}_${currentPage - 1}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
paginationRow.push({
|
||||||
|
text: `${currentPage}/${totalPages}`,
|
||||||
|
callback_data: `mdl_list_${provider}_${currentPage}`, // noop
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentPage < totalPages) {
|
||||||
|
paginationRow.push({
|
||||||
|
text: "Next ▶",
|
||||||
|
callback_data: `mdl_list_${provider}_${currentPage + 1}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push(paginationRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back button
|
||||||
|
rows.push([{ text: "<< Back", callback_data: "mdl_back" }]);
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build "Browse providers" button for /model summary.
|
||||||
|
*/
|
||||||
|
export function buildBrowseProvidersButton(): ButtonRow[] {
|
||||||
|
return [[{ text: "Browse providers", callback_data: "mdl_prov" }]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate model ID for display, preserving end if too long.
|
||||||
|
*/
|
||||||
|
function truncateModelId(modelId: string, maxLen: number): string {
|
||||||
|
if (modelId.length <= maxLen) {
|
||||||
|
return modelId;
|
||||||
|
}
|
||||||
|
// Show last part with ellipsis prefix
|
||||||
|
return `…${modelId.slice(-(maxLen - 1))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get page size for model list pagination.
|
||||||
|
*/
|
||||||
|
export function getModelsPageSize(): number {
|
||||||
|
return MODELS_PAGE_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate total pages for a model list.
|
||||||
|
*/
|
||||||
|
export function calculateTotalPages(totalModels: number, pageSize?: number): number {
|
||||||
|
const size = pageSize ?? MODELS_PAGE_SIZE;
|
||||||
|
return size > 0 ? Math.ceil(totalModels / size) : 1;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user