Files
openclaw/extensions/discord/src/monitor/model-picker.ts
2026-03-16 21:16:32 -07:00

940 lines
27 KiB
TypeScript

import {
Button,
Container,
Row,
Separator,
StringSelectMenu,
TextDisplay,
type ComponentData,
type MessagePayloadObject,
type TopLevelComponents,
} from "@buape/carbon";
import type { APISelectMenuOption } from "discord-api-types/v10";
import { ButtonStyle } from "discord-api-types/v10";
import { normalizeProviderId } from "openclaw/plugin-sdk/agent-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
buildModelsProviderData,
type ModelsProviderData,
} from "openclaw/plugin-sdk/reply-runtime";
export const DISCORD_MODEL_PICKER_CUSTOM_ID_KEY = "mdlpk";
export const DISCORD_CUSTOM_ID_MAX_CHARS = 100;
// Discord component limits.
export const DISCORD_COMPONENT_MAX_ROWS = 5;
export const DISCORD_COMPONENT_MAX_BUTTONS_PER_ROW = 5;
export const DISCORD_COMPONENT_MAX_SELECT_OPTIONS = 25;
// Reserve one row for navigation/utility buttons when rendering providers.
export const DISCORD_MODEL_PICKER_PROVIDER_PAGE_SIZE =
DISCORD_COMPONENT_MAX_BUTTONS_PER_ROW * (DISCORD_COMPONENT_MAX_ROWS - 1);
// When providers fit in one page, we can use all button rows and hide nav controls.
export const DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX =
DISCORD_COMPONENT_MAX_BUTTONS_PER_ROW * DISCORD_COMPONENT_MAX_ROWS;
export const DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE = DISCORD_COMPONENT_MAX_SELECT_OPTIONS;
const DISCORD_PROVIDER_BUTTON_LABEL_MAX_CHARS = 18;
const COMMAND_CONTEXTS = ["model", "models"] as const;
const PICKER_ACTIONS = [
"open",
"provider",
"model",
"submit",
"quick",
"back",
"reset",
"cancel",
"recents",
] as const;
const PICKER_VIEWS = ["providers", "models", "recents"] as const;
export type DiscordModelPickerCommandContext = (typeof COMMAND_CONTEXTS)[number];
export type DiscordModelPickerAction = (typeof PICKER_ACTIONS)[number];
export type DiscordModelPickerView = (typeof PICKER_VIEWS)[number];
export type DiscordModelPickerState = {
command: DiscordModelPickerCommandContext;
action: DiscordModelPickerAction;
view: DiscordModelPickerView;
userId: string;
provider?: string;
page: number;
providerPage?: number;
modelIndex?: number;
recentSlot?: number;
};
export type DiscordModelPickerProviderItem = {
id: string;
count: number;
};
export type DiscordModelPickerPage<T> = {
items: T[];
page: number;
pageSize: number;
totalPages: number;
totalItems: number;
hasPrev: boolean;
hasNext: boolean;
};
export type DiscordModelPickerModelPage = DiscordModelPickerPage<string> & {
provider: string;
};
export type DiscordModelPickerLayout = "v2" | "classic";
type DiscordModelPickerButtonOptions = {
label: string;
customId: string;
style?: ButtonStyle;
disabled?: boolean;
};
type DiscordModelPickerCurrentModelRef = {
provider: string;
model: string;
};
type DiscordModelPickerRow = Row<Button> | Row<StringSelectMenu>;
type DiscordModelPickerRenderShellParams = {
layout: DiscordModelPickerLayout;
title: string;
detailLines: string[];
rows: DiscordModelPickerRow[];
footer?: string;
/** Text shown after the divider but before the interactive rows. */
preRowText?: string;
/** Extra rows appended after the main rows, preceded by a divider. */
trailingRows?: DiscordModelPickerRow[];
};
export type DiscordModelPickerRenderedView = {
layout: DiscordModelPickerLayout;
content?: string;
components: TopLevelComponents[];
};
export type DiscordModelPickerProviderViewParams = {
command: DiscordModelPickerCommandContext;
userId: string;
data: ModelsProviderData;
page?: number;
currentModel?: string;
layout?: DiscordModelPickerLayout;
};
export type DiscordModelPickerModelViewParams = {
command: DiscordModelPickerCommandContext;
userId: string;
data: ModelsProviderData;
provider: string;
page?: number;
providerPage?: number;
currentModel?: string;
pendingModel?: string;
pendingModelIndex?: number;
quickModels?: string[];
layout?: DiscordModelPickerLayout;
};
function encodeCustomIdValue(value: string): string {
return encodeURIComponent(value);
}
function decodeCustomIdValue(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function isValidCommandContext(value: string): value is DiscordModelPickerCommandContext {
return (COMMAND_CONTEXTS as readonly string[]).includes(value);
}
function isValidPickerAction(value: string): value is DiscordModelPickerAction {
return (PICKER_ACTIONS as readonly string[]).includes(value);
}
function isValidPickerView(value: string): value is DiscordModelPickerView {
return (PICKER_VIEWS as readonly string[]).includes(value);
}
function normalizePage(value: number | undefined): number {
const numeric = typeof value === "number" ? value : Number.NaN;
if (!Number.isFinite(numeric)) {
return 1;
}
return Math.max(1, Math.floor(numeric));
}
function parseRawPage(value: unknown): number {
if (typeof value === "number") {
return normalizePage(value);
}
if (typeof value === "string" && value.trim()) {
const parsed = Number.parseInt(value, 10);
if (Number.isFinite(parsed)) {
return normalizePage(parsed);
}
}
return 1;
}
function parseRawPositiveInt(value: unknown): number | undefined {
if (typeof value !== "string" && typeof value !== "number") {
return undefined;
}
const parsed = Number.parseInt(String(value), 10);
if (!Number.isFinite(parsed) || parsed < 1) {
return undefined;
}
return Math.floor(parsed);
}
function coerceString(value: unknown): string {
return typeof value === "string" || typeof value === "number" ? String(value) : "";
}
function clampPageSize(rawPageSize: number | undefined, max: number, fallback: number): number {
if (!Number.isFinite(rawPageSize)) {
return fallback;
}
return Math.min(max, Math.max(1, Math.floor(rawPageSize ?? fallback)));
}
function paginateItems<T>(params: {
items: T[];
page: number;
pageSize: number;
}): DiscordModelPickerPage<T> {
const totalItems = params.items.length;
const totalPages = Math.max(1, Math.ceil(totalItems / params.pageSize));
const page = Math.max(1, Math.min(params.page, totalPages));
const startIndex = (page - 1) * params.pageSize;
const endIndexExclusive = Math.min(totalItems, startIndex + params.pageSize);
return {
items: params.items.slice(startIndex, endIndexExclusive),
page,
pageSize: params.pageSize,
totalPages,
totalItems,
hasPrev: page > 1,
hasNext: page < totalPages,
};
}
function parseCurrentModelRef(raw?: string): DiscordModelPickerCurrentModelRef | null {
const trimmed = raw?.trim();
const match = trimmed?.match(/^([^/]+)\/(.+)$/u);
if (!match) {
return null;
}
const provider = normalizeProviderId(match[1]);
// Preserve the model suffix exactly as entered after "/" so select defaults
// continue to mirror the stored ref for Discord interactions.
const model = match[2];
if (!provider || !model) {
return null;
}
return { provider, model };
}
function formatCurrentModelLine(currentModel?: string): string {
const parsed = parseCurrentModelRef(currentModel);
if (!parsed) {
return "Current model: default";
}
return `Current model: ${parsed.provider}/${parsed.model}`;
}
function formatProviderButtonLabel(provider: string): string {
if (provider.length <= DISCORD_PROVIDER_BUTTON_LABEL_MAX_CHARS) {
return provider;
}
return `${provider.slice(0, DISCORD_PROVIDER_BUTTON_LABEL_MAX_CHARS - 1)}`;
}
function chunkProvidersForRows(
items: DiscordModelPickerProviderItem[],
): DiscordModelPickerProviderItem[][] {
if (items.length === 0) {
return [];
}
const rowCount = Math.max(1, Math.ceil(items.length / DISCORD_COMPONENT_MAX_BUTTONS_PER_ROW));
const minPerRow = Math.floor(items.length / rowCount);
const rowsWithExtraItem = items.length % rowCount;
const counts = Array.from({ length: rowCount }, (_, index) =>
index < rowCount - rowsWithExtraItem ? minPerRow : minPerRow + 1,
);
const rows: DiscordModelPickerProviderItem[][] = [];
let cursor = 0;
for (const count of counts) {
rows.push(items.slice(cursor, cursor + count));
cursor += count;
}
return rows;
}
function createModelPickerButton(params: DiscordModelPickerButtonOptions): Button {
class DiscordModelPickerButton extends Button {
label = params.label;
customId = params.customId;
style = params.style ?? ButtonStyle.Secondary;
disabled = params.disabled ?? false;
}
return new DiscordModelPickerButton();
}
function createModelSelect(params: {
customId: string;
options: APISelectMenuOption[];
placeholder?: string;
disabled?: boolean;
}): StringSelectMenu {
class DiscordModelPickerSelect extends StringSelectMenu {
customId = params.customId;
options = params.options;
minValues = 1;
maxValues = 1;
placeholder = params.placeholder;
disabled = params.disabled ?? false;
}
return new DiscordModelPickerSelect();
}
function buildRenderedShell(
params: DiscordModelPickerRenderShellParams,
): DiscordModelPickerRenderedView {
if (params.layout === "classic") {
const lines = [params.title, ...params.detailLines, "", params.footer].filter(Boolean);
return {
layout: "classic",
content: lines.join("\n"),
components: params.rows,
};
}
const containerComponents: Array<TextDisplay | Separator | DiscordModelPickerRow> = [
new TextDisplay(`## ${params.title}`),
];
if (params.detailLines.length > 0) {
containerComponents.push(new TextDisplay(params.detailLines.join("\n")));
}
containerComponents.push(new Separator({ divider: true, spacing: "small" }));
if (params.preRowText) {
containerComponents.push(new TextDisplay(params.preRowText));
}
containerComponents.push(...params.rows);
if (params.trailingRows && params.trailingRows.length > 0) {
containerComponents.push(new Separator({ divider: true, spacing: "small" }));
containerComponents.push(...params.trailingRows);
}
if (params.footer) {
containerComponents.push(new Separator({ divider: false, spacing: "small" }));
containerComponents.push(new TextDisplay(`-# ${params.footer}`));
}
const container = new Container(containerComponents);
return {
layout: "v2",
components: [container],
};
}
function buildProviderRows(params: {
command: DiscordModelPickerCommandContext;
userId: string;
page: DiscordModelPickerPage<DiscordModelPickerProviderItem>;
currentProvider?: string;
}): Row<Button>[] {
const rows = chunkProvidersForRows(params.page.items).map(
(providers) =>
new Row(
providers.map((provider) => {
const style =
provider.id === params.currentProvider ? ButtonStyle.Primary : ButtonStyle.Secondary;
return createModelPickerButton({
label: formatProviderButtonLabel(provider.id),
style,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "provider",
view: "models",
provider: provider.id,
page: params.page.page,
userId: params.userId,
}),
});
}),
),
);
return rows;
}
function buildModelRows(params: {
command: DiscordModelPickerCommandContext;
userId: string;
data: ModelsProviderData;
providerPage: number;
modelPage: DiscordModelPickerModelPage;
currentModel?: string;
pendingModel?: string;
pendingModelIndex?: number;
quickModels?: string[];
}): { rows: DiscordModelPickerRow[]; buttonRow: Row<Button> } {
const parsedCurrentModel = parseCurrentModelRef(params.currentModel);
const parsedPendingModel = parseCurrentModelRef(params.pendingModel);
const rows: DiscordModelPickerRow[] = [];
const hasQuickModels = (params.quickModels ?? []).length > 0;
const providerPage = getDiscordModelPickerProviderPage({
data: params.data,
page: params.providerPage,
});
const providerOptions: APISelectMenuOption[] = providerPage.items.map((provider) => ({
label: provider.id,
value: provider.id,
default: provider.id === params.modelPage.provider,
}));
rows.push(
new Row([
createModelSelect({
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "provider",
view: "models",
provider: params.modelPage.provider,
page: providerPage.page,
providerPage: providerPage.page,
userId: params.userId,
}),
options: providerOptions,
placeholder: "Select provider",
}),
]),
);
const selectedModelRef = parsedPendingModel ?? parsedCurrentModel;
const modelOptions: APISelectMenuOption[] = params.modelPage.items.map((model) => ({
label: model,
value: model,
default: selectedModelRef
? selectedModelRef.provider === params.modelPage.provider && selectedModelRef.model === model
: false,
}));
rows.push(
new Row([
createModelSelect({
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "model",
view: "models",
provider: params.modelPage.provider,
page: params.modelPage.page,
providerPage: providerPage.page,
userId: params.userId,
}),
options: modelOptions,
placeholder: `Select ${params.modelPage.provider} model`,
}),
]),
);
const resolvedDefault = params.data.resolvedDefault;
const shouldDisableReset =
Boolean(parsedCurrentModel) &&
parsedCurrentModel?.provider === resolvedDefault.provider &&
parsedCurrentModel?.model === resolvedDefault.model;
const hasPendingSelection =
Boolean(parsedPendingModel) &&
parsedPendingModel?.provider === params.modelPage.provider &&
typeof params.pendingModelIndex === "number" &&
params.pendingModelIndex > 0;
const buttonRowItems: Button[] = [
createModelPickerButton({
label: "Cancel",
style: ButtonStyle.Secondary,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "cancel",
view: "models",
provider: params.modelPage.provider,
page: params.modelPage.page,
providerPage: providerPage.page,
userId: params.userId,
}),
}),
createModelPickerButton({
label: "Reset to default",
style: ButtonStyle.Secondary,
disabled: shouldDisableReset,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "reset",
view: "models",
provider: params.modelPage.provider,
page: params.modelPage.page,
providerPage: providerPage.page,
userId: params.userId,
}),
}),
];
if (hasQuickModels) {
buttonRowItems.push(
createModelPickerButton({
label: "Recents",
style: ButtonStyle.Secondary,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "recents",
view: "recents",
provider: params.modelPage.provider,
page: params.modelPage.page,
providerPage: providerPage.page,
userId: params.userId,
}),
}),
);
}
buttonRowItems.push(
createModelPickerButton({
label: "Submit",
style: ButtonStyle.Primary,
disabled: !hasPendingSelection,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "submit",
view: "models",
provider: params.modelPage.provider,
page: params.modelPage.page,
providerPage: providerPage.page,
modelIndex: params.pendingModelIndex,
userId: params.userId,
}),
}),
);
return { rows, buttonRow: new Row(buttonRowItems) };
}
/**
* Source-of-truth data for Discord picker views. This intentionally reuses the
* same provider/model resolver used by text and Telegram model commands.
*/
export async function loadDiscordModelPickerData(
cfg: OpenClawConfig,
agentId?: string,
): Promise<ModelsProviderData> {
return buildModelsProviderData(cfg, agentId);
}
export function buildDiscordModelPickerCustomId(params: {
command: DiscordModelPickerCommandContext;
action: DiscordModelPickerAction;
view: DiscordModelPickerView;
userId: string;
provider?: string;
page?: number;
providerPage?: number;
modelIndex?: number;
recentSlot?: number;
}): string {
const userId = params.userId.trim();
if (!userId) {
throw new Error("Discord model picker custom_id requires userId");
}
const page = normalizePage(params.page);
const providerPage =
typeof params.providerPage === "number" && Number.isFinite(params.providerPage)
? Math.max(1, Math.floor(params.providerPage))
: undefined;
const normalizedProvider = params.provider ? normalizeProviderId(params.provider) : undefined;
const modelIndex =
typeof params.modelIndex === "number" && Number.isFinite(params.modelIndex)
? Math.max(1, Math.floor(params.modelIndex))
: undefined;
const recentSlot =
typeof params.recentSlot === "number" && Number.isFinite(params.recentSlot)
? Math.max(1, Math.floor(params.recentSlot))
: undefined;
const parts = [
`${DISCORD_MODEL_PICKER_CUSTOM_ID_KEY}:c=${encodeCustomIdValue(params.command)}`,
`a=${encodeCustomIdValue(params.action)}`,
`v=${encodeCustomIdValue(params.view)}`,
`u=${encodeCustomIdValue(userId)}`,
`g=${String(page)}`,
];
if (normalizedProvider) {
parts.push(`p=${encodeCustomIdValue(normalizedProvider)}`);
}
if (providerPage) {
parts.push(`pp=${String(providerPage)}`);
}
if (modelIndex) {
parts.push(`mi=${String(modelIndex)}`);
}
if (recentSlot) {
parts.push(`rs=${String(recentSlot)}`);
}
const customId = parts.join(";");
if (customId.length > DISCORD_CUSTOM_ID_MAX_CHARS) {
throw new Error(
`Discord model picker custom_id exceeds ${DISCORD_CUSTOM_ID_MAX_CHARS} chars (${customId.length})`,
);
}
return customId;
}
export function parseDiscordModelPickerCustomId(customId: string): DiscordModelPickerState | null {
const trimmed = customId.trim();
if (!trimmed.startsWith(`${DISCORD_MODEL_PICKER_CUSTOM_ID_KEY}:`)) {
return null;
}
const rawParts = trimmed.split(";");
const data: Record<string, string> = {};
for (const part of rawParts) {
const equalsIndex = part.indexOf("=");
if (equalsIndex <= 0) {
continue;
}
const rawKey = part.slice(0, equalsIndex);
const rawValue = part.slice(equalsIndex + 1);
const key = rawKey.includes(":") ? rawKey.split(":").slice(1).join(":") : rawKey;
if (!key) {
continue;
}
data[key] = rawValue;
}
return parseDiscordModelPickerData(data);
}
export function parseDiscordModelPickerData(data: ComponentData): DiscordModelPickerState | null {
if (!data || typeof data !== "object") {
return null;
}
const command = decodeCustomIdValue(coerceString(data.c ?? data.cmd));
const action = decodeCustomIdValue(coerceString(data.a ?? data.act));
const view = decodeCustomIdValue(coerceString(data.v ?? data.view));
const userId = decodeCustomIdValue(coerceString(data.u));
const providerRaw = decodeCustomIdValue(coerceString(data.p));
const page = parseRawPage(data.g ?? data.pg);
const providerPage = parseRawPositiveInt(data.pp);
const modelIndex = parseRawPositiveInt(data.mi);
const recentSlot = parseRawPositiveInt(data.rs);
if (!isValidCommandContext(command) || !isValidPickerAction(action) || !isValidPickerView(view)) {
return null;
}
const trimmedUserId = userId.trim();
if (!trimmedUserId) {
return null;
}
const provider = providerRaw ? normalizeProviderId(providerRaw) : undefined;
return {
command,
action,
view,
userId: trimmedUserId,
provider,
page,
...(typeof providerPage === "number" ? { providerPage } : {}),
...(typeof modelIndex === "number" ? { modelIndex } : {}),
...(typeof recentSlot === "number" ? { recentSlot } : {}),
};
}
export function buildDiscordModelPickerProviderItems(
data: ModelsProviderData,
): DiscordModelPickerProviderItem[] {
return data.providers.map((provider) => ({
id: provider,
count: data.byProvider.get(provider)?.size ?? 0,
}));
}
export function getDiscordModelPickerProviderPage(params: {
data: ModelsProviderData;
page?: number;
pageSize?: number;
}): DiscordModelPickerPage<DiscordModelPickerProviderItem> {
const items = buildDiscordModelPickerProviderItems(params.data);
const canFitSinglePage = items.length <= DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX;
const maxPageSize = canFitSinglePage
? DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX
: DISCORD_MODEL_PICKER_PROVIDER_PAGE_SIZE;
const pageSize = clampPageSize(params.pageSize, maxPageSize, maxPageSize);
return paginateItems({
items,
page: normalizePage(params.page),
pageSize,
});
}
export function getDiscordModelPickerModelPage(params: {
data: ModelsProviderData;
provider: string;
page?: number;
pageSize?: number;
}): DiscordModelPickerModelPage | null {
const provider = normalizeProviderId(params.provider);
const modelSet = params.data.byProvider.get(provider);
if (!modelSet) {
return null;
}
const pageSize = clampPageSize(
params.pageSize,
DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE,
DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE,
);
const models = [...modelSet].toSorted();
const page = paginateItems({
items: models,
page: normalizePage(params.page),
pageSize,
});
return {
...page,
provider,
};
}
export function renderDiscordModelPickerProvidersView(
params: DiscordModelPickerProviderViewParams,
): DiscordModelPickerRenderedView {
const page = getDiscordModelPickerProviderPage({ data: params.data, page: params.page });
const parsedCurrent = parseCurrentModelRef(params.currentModel);
const rows = buildProviderRows({
command: params.command,
userId: params.userId,
page,
currentProvider: parsedCurrent?.provider,
});
const detailLines = [
formatCurrentModelLine(params.currentModel),
`Select a provider (${page.totalItems} available).`,
];
return buildRenderedShell({
layout: params.layout ?? "v2",
title: "Model Picker",
detailLines,
rows,
footer: `All ${page.totalItems} providers shown`,
});
}
export function renderDiscordModelPickerModelsView(
params: DiscordModelPickerModelViewParams,
): DiscordModelPickerRenderedView {
const providerPage = normalizePage(params.providerPage);
const modelPage = getDiscordModelPickerModelPage({
data: params.data,
provider: params.provider,
page: params.page,
});
if (!modelPage) {
const rows: Row<Button>[] = [
new Row([
createModelPickerButton({
label: "Back",
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "back",
view: "providers",
page: providerPage,
userId: params.userId,
}),
}),
]),
];
return buildRenderedShell({
layout: params.layout ?? "v2",
title: "Model Picker",
detailLines: [
formatCurrentModelLine(params.currentModel),
`Provider not found: ${normalizeProviderId(params.provider)}`,
],
rows,
footer: "Choose a different provider.",
});
}
const { rows, buttonRow } = buildModelRows({
command: params.command,
userId: params.userId,
data: params.data,
providerPage,
modelPage,
currentModel: params.currentModel,
pendingModel: params.pendingModel,
pendingModelIndex: params.pendingModelIndex,
quickModels: params.quickModels,
});
const defaultModel = `${params.data.resolvedDefault.provider}/${params.data.resolvedDefault.model}`;
const pendingLine = params.pendingModel
? `Selected: ${params.pendingModel} (press Submit)`
: "Select a model, then press Submit.";
return buildRenderedShell({
layout: params.layout ?? "v2",
title: "Model Picker",
detailLines: [formatCurrentModelLine(params.currentModel), `Default: ${defaultModel}`],
preRowText: pendingLine,
rows,
trailingRows: [buttonRow],
});
}
export type DiscordModelPickerRecentsViewParams = {
command: DiscordModelPickerCommandContext;
userId: string;
data: ModelsProviderData;
quickModels: string[];
currentModel?: string;
provider?: string;
page?: number;
providerPage?: number;
layout?: DiscordModelPickerLayout;
};
function formatRecentsButtonLabel(modelRef: string, suffix?: string): string {
const maxLen = 80;
const label = suffix ? `${modelRef} ${suffix}` : modelRef;
if (label.length <= maxLen) {
return label;
}
const trimmed = suffix
? `${modelRef.slice(0, maxLen - suffix.length - 2)}${suffix}`
: `${modelRef.slice(0, maxLen - 1)}`;
return trimmed;
}
export function renderDiscordModelPickerRecentsView(
params: DiscordModelPickerRecentsViewParams,
): DiscordModelPickerRenderedView {
const defaultModelRef = `${params.data.resolvedDefault.provider}/${params.data.resolvedDefault.model}`;
const rows: DiscordModelPickerRow[] = [];
// Dedupe: filter recents that match the default model.
const dedupedQuickModels = params.quickModels.filter((modelRef) => modelRef !== defaultModelRef);
// Default model button — slot 1.
rows.push(
new Row([
createModelPickerButton({
label: formatRecentsButtonLabel(defaultModelRef, "(default)"),
style: ButtonStyle.Secondary,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "submit",
view: "recents",
recentSlot: 1,
provider: params.provider,
page: params.page,
providerPage: params.providerPage,
userId: params.userId,
}),
}),
]),
);
// Recent model buttons — slot 2+.
for (let i = 0; i < dedupedQuickModels.length; i++) {
const modelRef = dedupedQuickModels[i];
rows.push(
new Row([
createModelPickerButton({
label: formatRecentsButtonLabel(modelRef),
style: ButtonStyle.Secondary,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "submit",
view: "recents",
recentSlot: i + 2,
provider: params.provider,
page: params.page,
providerPage: params.providerPage,
userId: params.userId,
}),
}),
]),
);
}
// Back button after a divider (via trailingRows).
const backRow: Row<Button> = new Row([
createModelPickerButton({
label: "Back",
style: ButtonStyle.Secondary,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "back",
view: "models",
provider: params.provider,
page: params.page,
providerPage: params.providerPage,
userId: params.userId,
}),
}),
]);
return buildRenderedShell({
layout: params.layout ?? "v2",
title: "Recents",
detailLines: [
"Models you've previously selected appear here.",
formatCurrentModelLine(params.currentModel),
],
preRowText: "Tap a model to switch.",
rows,
trailingRows: [backRow],
});
}
export function toDiscordModelPickerMessagePayload(
view: DiscordModelPickerRenderedView,
): MessagePayloadObject {
if (view.layout === "classic") {
return {
content: view.content,
components: view.components,
};
}
return {
components: view.components,
};
}