mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 12:41:23 +00:00
Telegram: add inline button model selection for /models and /model commands
This commit is contained in:
committed by
Ayaan Zaidi
parent
efb4a34be4
commit
16349b6e93
@@ -7,6 +7,7 @@ import {
|
||||
resolveInboundDebounceMs,
|
||||
} from "../auto-reply/inbound-debounce.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 { buildCommandsMessagePaginated } from "../auto-reply/status.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 { migrateTelegramGroupConfig } from "./group-migration.js";
|
||||
import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js";
|
||||
import {
|
||||
buildModelsKeyboard,
|
||||
buildProviderKeyboard,
|
||||
calculateTotalPages,
|
||||
getModelsPageSize,
|
||||
parseModelCallbackData,
|
||||
type ProviderInfo,
|
||||
} from "./model-buttons.js";
|
||||
import { buildInlineKeyboard } from "./send.js";
|
||||
|
||||
export const registerTelegramHandlers = ({
|
||||
@@ -404,6 +413,107 @@ export const registerTelegramHandlers = ({
|
||||
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 = {
|
||||
...callbackMessage,
|
||||
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