Files
wassname 39da73ce3c feat: improve model selection UX and fix queue/status behaviors
- Add search/filtering to the `/model` command with multi-word matching
- Finalize partial stream previews (e.g. thinking blocks) on turn abort instead of clearing them
- Dynamically format low-cost `$ value` metrics up to 5 decimal places in status outputs
- Update queue tests to expect text-turn plans for aborted turns with partial text
2026-04-23 12:42:35 +08:00

1001 lines
29 KiB
TypeScript

/**
* Telegram menu and inline-keyboard rendering helpers
* Owns model resolution, menu state, and inline UI text and reply-markup generation for status, model, and thinking controls
*/
import type { Model } from "@mariozechner/pi-ai";
export type ThinkingLevel =
| "off"
| "minimal"
| "low"
| "medium"
| "high"
| "xhigh";
export type TelegramModelScope = "all" | "scoped";
export interface ScopedTelegramModel {
model: Model<any>;
thinkingLevel?: ThinkingLevel;
}
export interface TelegramModelMenuState {
chatId: number;
messageId: number;
page: number;
scope: TelegramModelScope;
scopedModels: ScopedTelegramModel[];
allModels: ScopedTelegramModel[];
note?: string;
mode: "status" | "model" | "thinking";
}
export type TelegramReplyMarkup = {
inline_keyboard: Array<Array<{ text: string; callback_data: string }>>;
};
export interface TelegramMenuMessageRuntimeDeps {
editInteractiveMessage: (
chatId: number,
messageId: number,
text: string,
mode: "html" | "plain",
replyMarkup: TelegramReplyMarkup,
) => Promise<void>;
sendInteractiveMessage: (
chatId: number,
text: string,
mode: "html" | "plain",
replyMarkup: TelegramReplyMarkup,
) => Promise<number | undefined>;
}
export interface TelegramMenuEffectPort {
answerCallbackQuery: (
callbackQueryId: string,
text?: string,
) => Promise<void>;
updateModelMenuMessage: () => Promise<void>;
updateThinkingMenuMessage: () => Promise<void>;
updateStatusMessage: () => Promise<void>;
setModel: (model: Model<any>) => Promise<boolean>;
setCurrentModel: (model: Model<any>) => void;
setThinkingLevel: (level: ThinkingLevel) => void;
getCurrentThinkingLevel: () => ThinkingLevel;
setTraceVisible: (traceVisible: boolean) => void;
getTraceVisible: () => boolean;
stagePendingModelSwitch: (selection: ScopedTelegramModel) => void;
restartInterruptedTelegramTurn: (
selection: ScopedTelegramModel,
) => Promise<boolean> | boolean;
}
export type TelegramStatusMenuCallbackDeps = Pick<
TelegramMenuEffectPort,
| "updateModelMenuMessage"
| "updateThinkingMenuMessage"
| "updateStatusMessage"
| "setTraceVisible"
| "getTraceVisible"
| "answerCallbackQuery"
>;
export type TelegramThinkingMenuCallbackDeps = Pick<
TelegramMenuEffectPort,
"setThinkingLevel" | "getCurrentThinkingLevel" | "updateStatusMessage" | "answerCallbackQuery"
>;
export type TelegramModelMenuCallbackDeps = Pick<
TelegramMenuEffectPort,
| "updateModelMenuMessage"
| "updateStatusMessage"
| "answerCallbackQuery"
| "setModel"
| "setCurrentModel"
| "setThinkingLevel"
| "stagePendingModelSwitch"
| "restartInterruptedTelegramTurn"
>;
export interface TelegramMenuCallbackEntryDeps {
handleStatusAction: () => Promise<boolean>;
handleThinkingAction: () => Promise<boolean>;
handleModelAction: () => Promise<boolean>;
answerCallbackQuery: (
callbackQueryId: string,
text?: string,
) => Promise<void>;
}
export const THINKING_LEVELS: readonly ThinkingLevel[] = [
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
];
export const TELEGRAM_MODEL_PAGE_SIZE = 6;
export const MODEL_MENU_TITLE = "<b>Choose a model:</b>";
export interface BuildTelegramModelMenuStateParams {
chatId: number;
activeModel: Model<any> | undefined;
availableModels: Model<any>[];
configuredScopedModelPatterns: string[];
cliScopedModelPatterns?: string[];
filterQuery?: string;
}
export type TelegramMenuCallbackAction =
| { kind: "ignore" }
| { kind: "status"; action: "model" | "thinking" | "trace" }
| { kind: "thinking:set"; level: string }
| {
kind: "model";
action: "noop" | "scope" | "page" | "pick";
value?: string;
};
export type TelegramMenuMutationResult = "invalid" | "unchanged" | "changed";
export type TelegramMenuSelectionResult =
| { kind: "invalid" }
| { kind: "missing" }
| { kind: "selected"; selection: ScopedTelegramModel };
export interface TelegramModelMenuPage {
page: number;
pageCount: number;
start: number;
items: ScopedTelegramModel[];
}
export interface TelegramMenuRenderPayload {
nextMode: TelegramModelMenuState["mode"];
text: string;
mode: "html" | "plain";
replyMarkup: TelegramReplyMarkup;
}
export type TelegramModelCallbackPlan =
| { kind: "ignore" }
| { kind: "answer"; text?: string }
| { kind: "update-menu"; text?: string }
| {
kind: "refresh-status";
selection: ScopedTelegramModel;
callbackText: string;
shouldApplyThinkingLevel: boolean;
}
| {
kind: "switch-model";
selection: ScopedTelegramModel;
mode: "idle" | "restart-now" | "restart-after-tool";
callbackText: string;
};
export interface BuildTelegramModelCallbackPlanParams {
data: string | undefined;
state: TelegramModelMenuState;
activeModel: Model<any> | undefined;
currentThinkingLevel: ThinkingLevel;
isIdle: boolean;
canRestartBusyRun: boolean;
hasActiveToolExecutions: boolean;
}
export function modelsMatch(
a: Pick<Model<any>, "provider" | "id"> | undefined,
b: Pick<Model<any>, "provider" | "id"> | undefined,
): boolean {
return !!a && !!b && a.provider === b.provider && a.id === b.id;
}
export function getCanonicalModelId(
model: Pick<Model<any>, "provider" | "id">,
): string {
return `${model.provider}/${model.id}`;
}
export function isThinkingLevel(value: string): value is ThinkingLevel {
return THINKING_LEVELS.includes(value as ThinkingLevel);
}
function escapeRegex(text: string): string {
return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
}
function globMatches(text: string, pattern: string): boolean {
let regex = "^";
for (let i = 0; i < pattern.length; i++) {
const char = pattern[i];
if (char === "*") {
regex += ".*";
continue;
}
if (char === "?") {
regex += ".";
continue;
}
if (char === "[") {
const end = pattern.indexOf("]", i + 1);
if (end !== -1) {
const content = pattern.slice(i + 1, end);
regex += content.startsWith("!")
? `[^${content.slice(1)}]`
: `[${content}]`;
i = end;
continue;
}
}
regex += escapeRegex(char);
}
regex += "$";
return new RegExp(regex, "i").test(text);
}
function isAliasModelId(id: string): boolean {
if (id.endsWith("-latest")) return true;
return !/-\d{8}$/.test(id);
}
function findExactModelReferenceMatch(
modelReference: string,
availableModels: Model<any>[],
): Model<any> | undefined {
const trimmedReference = modelReference.trim();
if (!trimmedReference) return undefined;
const normalizedReference = trimmedReference.toLowerCase();
const canonicalMatches = availableModels.filter(
(model) => getCanonicalModelId(model).toLowerCase() === normalizedReference,
);
if (canonicalMatches.length === 1) return canonicalMatches[0];
if (canonicalMatches.length > 1) return undefined;
const slashIndex = trimmedReference.indexOf("/");
if (slashIndex !== -1) {
const provider = trimmedReference.substring(0, slashIndex).trim();
const modelId = trimmedReference.substring(slashIndex + 1).trim();
if (provider && modelId) {
const providerMatches = availableModels.filter(
(model) =>
model.provider.toLowerCase() === provider.toLowerCase() &&
model.id.toLowerCase() === modelId.toLowerCase(),
);
if (providerMatches.length === 1) return providerMatches[0];
if (providerMatches.length > 1) return undefined;
}
}
const idMatches = availableModels.filter(
(model) => model.id.toLowerCase() === normalizedReference,
);
return idMatches.length === 1 ? idMatches[0] : undefined;
}
function tryMatchScopedModel(
modelPattern: string,
availableModels: Model<any>[],
): Model<any> | undefined {
const exactMatch = findExactModelReferenceMatch(
modelPattern,
availableModels,
);
if (exactMatch) return exactMatch;
const matches = availableModels.filter(
(model) =>
model.id.toLowerCase().includes(modelPattern.toLowerCase()) ||
model.name?.toLowerCase().includes(modelPattern.toLowerCase()),
);
if (matches.length === 0) return undefined;
const aliases = matches.filter((model) => isAliasModelId(model.id));
const datedVersions = matches.filter((model) => !isAliasModelId(model.id));
if (aliases.length > 0) {
aliases.sort((a, b) => b.id.localeCompare(a.id));
return aliases[0];
}
datedVersions.sort((a, b) => b.id.localeCompare(a.id));
return datedVersions[0];
}
function parseScopedModelPattern(
pattern: string,
availableModels: Model<any>[],
): { model: Model<any> | undefined; thinkingLevel?: ThinkingLevel } {
const exactMatch = tryMatchScopedModel(pattern, availableModels);
if (exactMatch) {
return { model: exactMatch, thinkingLevel: undefined };
}
const lastColonIndex = pattern.lastIndexOf(":");
if (lastColonIndex === -1) {
return { model: undefined, thinkingLevel: undefined };
}
const prefix = pattern.substring(0, lastColonIndex);
const suffix = pattern.substring(lastColonIndex + 1);
if (isThinkingLevel(suffix)) {
const result = parseScopedModelPattern(prefix, availableModels);
if (result.model) {
return { model: result.model, thinkingLevel: suffix };
}
return result;
}
return parseScopedModelPattern(prefix, availableModels);
}
export function resolveScopedModelPatterns(
patterns: string[],
availableModels: Model<any>[],
): ScopedTelegramModel[] {
const resolved: ScopedTelegramModel[] = [];
const seen = new Set<string>();
for (const pattern of patterns) {
if (
pattern.includes("*") ||
pattern.includes("?") ||
pattern.includes("[")
) {
const colonIndex = pattern.lastIndexOf(":");
let globPattern = pattern;
let thinkingLevel: ThinkingLevel | undefined;
if (colonIndex !== -1) {
const suffix = pattern.substring(colonIndex + 1);
if (isThinkingLevel(suffix)) {
thinkingLevel = suffix;
globPattern = pattern.substring(0, colonIndex);
}
}
const matches = availableModels.filter(
(model) =>
globMatches(getCanonicalModelId(model), globPattern) ||
globMatches(model.id, globPattern),
);
for (const model of matches) {
const key = getCanonicalModelId(model);
if (seen.has(key)) continue;
seen.add(key);
resolved.push({ model, thinkingLevel });
}
continue;
}
const matched = parseScopedModelPattern(pattern, availableModels);
if (!matched.model) continue;
const key = getCanonicalModelId(matched.model);
if (seen.has(key)) continue;
seen.add(key);
resolved.push({
model: matched.model,
thinkingLevel: matched.thinkingLevel,
});
}
return resolved;
}
export function sortScopedModels(
models: ScopedTelegramModel[],
currentModel: Model<any> | undefined,
): ScopedTelegramModel[] {
const sorted = [...models];
sorted.sort((a, b) => {
const aIsCurrent = modelsMatch(a.model, currentModel);
const bIsCurrent = modelsMatch(b.model, currentModel);
if (aIsCurrent && !bIsCurrent) return -1;
if (!aIsCurrent && bIsCurrent) return 1;
const providerCompare = a.model.provider.localeCompare(b.model.provider);
if (providerCompare !== 0) return providerCompare;
return a.model.id.localeCompare(b.model.id);
});
return sorted;
}
function truncateTelegramButtonLabel(label: string, maxLength = 56): string {
return label.length <= maxLength
? label
: `${label.slice(0, maxLength - 1)}…`;
}
export function formatScopedModelButtonText(
entry: ScopedTelegramModel,
currentModel: Model<any> | undefined,
): string {
let label = `${modelsMatch(entry.model, currentModel) ? "✅ " : ""}${entry.model.id} [${entry.model.provider}]`;
if (entry.thinkingLevel) {
label += ` · ${entry.thinkingLevel}`;
}
return truncateTelegramButtonLabel(label);
}
export function formatStatusButtonLabel(label: string, value: string): string {
return truncateTelegramButtonLabel(`${label}: ${value}`, 64);
}
export function getModelMenuItems(
state: TelegramModelMenuState,
): ScopedTelegramModel[] {
return state.scope === "scoped" && state.scopedModels.length > 0
? state.scopedModels
: state.allModels;
}
export function buildTelegramModelMenuState(
params: BuildTelegramModelMenuStateParams,
): TelegramModelMenuState {
let filteredAvailableModels = params.availableModels;
if (params.filterQuery) {
const terms = params.filterQuery.toLowerCase().split(/\s+/).filter(Boolean);
filteredAvailableModels = filteredAvailableModels.filter((m) => {
const target = `${m.provider}/${m.id}`.toLowerCase();
return terms.every((t) => target.includes(t));
});
}
const allModels = sortScopedModels(
filteredAvailableModels.map((model) => ({ model })),
params.activeModel,
);
const scopedModels =
params.configuredScopedModelPatterns.length > 0
? sortScopedModels(
resolveScopedModelPatterns(
params.configuredScopedModelPatterns,
filteredAvailableModels,
),
params.activeModel,
)
: [];
let note: string | undefined;
if (
params.configuredScopedModelPatterns.length > 0 &&
scopedModels.length === 0 &&
!params.filterQuery
) {
note = params.cliScopedModelPatterns
? "No CLI scoped models matched the current auth configuration. Showing all available models."
: "No scoped models matched the current auth configuration. Showing all available models.";
} else if (params.filterQuery && filteredAvailableModels.length === 0) {
note = "No models matched your search query.";
}
return {
chatId: params.chatId,
messageId: 0,
page: 0,
scope: (scopedModels.length > 0 && !params.filterQuery) ? "scoped" : "all",
scopedModels,
allModels,
note,
mode: "status",
};
}
export function parseTelegramMenuCallbackAction(
data: string | undefined,
): TelegramMenuCallbackAction {
if (data === "status:model") return { kind: "status", action: "model" };
if (data === "status:thinking") {
return { kind: "status", action: "thinking" };
}
if (data === "status:trace") {
return { kind: "status", action: "trace" };
}
if (data?.startsWith("thinking:set:")) {
return {
kind: "thinking:set",
level: data.slice("thinking:set:".length),
};
}
if (data?.startsWith("model:")) {
const [, action, value] = data.split(":");
if (
action === "noop" ||
action === "scope" ||
action === "page" ||
action === "pick"
) {
return { kind: "model", action, value };
}
}
return { kind: "ignore" };
}
export function applyTelegramModelScopeSelection(
state: TelegramModelMenuState,
value: string | undefined,
): TelegramMenuMutationResult {
if (value !== "all" && value !== "scoped") return "invalid";
if (value === state.scope) return "unchanged";
state.scope = value;
state.page = 0;
return "changed";
}
export function applyTelegramModelPageSelection(
state: TelegramModelMenuState,
value: string | undefined,
): TelegramMenuMutationResult {
const page = Number(value);
if (!Number.isFinite(page)) return "invalid";
if (page === state.page) return "unchanged";
state.page = page;
return "changed";
}
export function getTelegramModelSelection(
state: TelegramModelMenuState,
value: string | undefined,
): TelegramMenuSelectionResult {
const index = Number(value);
if (!Number.isFinite(index)) return { kind: "invalid" };
const selection = getModelMenuItems(state)[index];
if (!selection) return { kind: "missing" };
return { kind: "selected", selection };
}
export function buildTelegramModelCallbackPlan(
params: BuildTelegramModelCallbackPlanParams,
): TelegramModelCallbackPlan {
const action = parseTelegramMenuCallbackAction(params.data);
if (action.kind !== "model") return { kind: "ignore" };
if (action.action === "noop") return { kind: "answer" };
if (action.action === "scope") {
const result = applyTelegramModelScopeSelection(params.state, action.value);
if (result === "invalid") {
return { kind: "answer", text: "Unknown model scope." };
}
if (result === "unchanged") {
return { kind: "answer" };
}
return {
kind: "update-menu",
text: params.state.scope === "scoped" ? "Scoped models" : "All models",
};
}
if (action.action === "page") {
const result = applyTelegramModelPageSelection(params.state, action.value);
if (result === "invalid") {
return { kind: "answer", text: "Invalid page." };
}
if (result === "unchanged") {
return { kind: "answer" };
}
return { kind: "update-menu" };
}
if (action.action !== "pick") {
return { kind: "answer" };
}
const selectionResult = getTelegramModelSelection(params.state, action.value);
if (selectionResult.kind === "invalid") {
return { kind: "answer", text: "Invalid model selection." };
}
if (selectionResult.kind === "missing") {
return { kind: "answer", text: "Selected model is no longer available." };
}
const selection = selectionResult.selection;
if (modelsMatch(selection.model, params.activeModel)) {
return {
kind: "refresh-status",
selection,
callbackText: `Model: ${selection.model.id}`,
shouldApplyThinkingLevel:
!!selection.thinkingLevel &&
selection.thinkingLevel !== params.currentThinkingLevel,
};
}
if (!params.isIdle) {
if (!params.canRestartBusyRun) {
return { kind: "answer", text: "Pi is busy. Send /stop first." };
}
return {
kind: "switch-model",
selection,
mode: params.hasActiveToolExecutions
? "restart-after-tool"
: "restart-now",
callbackText: params.hasActiveToolExecutions
? `Switched to ${selection.model.id}. Restarting after the current tool finishes…`
: `Switching to ${selection.model.id} and continuing…`,
};
}
return {
kind: "switch-model",
selection,
mode: "idle",
callbackText: `Switched to ${selection.model.id}`,
};
}
export async function handleTelegramMenuCallbackEntry(
callbackQueryId: string,
data: string | undefined,
state: TelegramModelMenuState | undefined,
deps: TelegramMenuCallbackEntryDeps,
): Promise<void> {
if (!data) {
await deps.answerCallbackQuery(callbackQueryId);
return;
}
if (!state) {
await deps.answerCallbackQuery(callbackQueryId, "Interactive message expired.");
return;
}
const handled =
(await deps.handleStatusAction()) ||
(await deps.handleThinkingAction()) ||
(await deps.handleModelAction());
if (!handled) {
await deps.answerCallbackQuery(callbackQueryId);
}
}
export async function handleTelegramModelMenuCallbackAction(
callbackQueryId: string,
params: BuildTelegramModelCallbackPlanParams,
deps: TelegramModelMenuCallbackDeps,
): Promise<boolean> {
const plan = buildTelegramModelCallbackPlan(params);
if (plan.kind === "ignore") return false;
if (plan.kind === "answer") {
await deps.answerCallbackQuery(callbackQueryId, plan.text);
return true;
}
if (plan.kind === "update-menu") {
await deps.updateModelMenuMessage();
await deps.answerCallbackQuery(callbackQueryId, plan.text);
return true;
}
if (plan.kind === "refresh-status") {
if (plan.shouldApplyThinkingLevel && plan.selection.thinkingLevel) {
deps.setThinkingLevel(plan.selection.thinkingLevel);
}
await deps.updateStatusMessage();
await deps.answerCallbackQuery(callbackQueryId, plan.callbackText);
return true;
}
const changed = await deps.setModel(plan.selection.model);
if (changed === false) {
await deps.answerCallbackQuery(callbackQueryId, "Model is not available.");
return true;
}
deps.setCurrentModel(plan.selection.model);
if (plan.selection.thinkingLevel) {
deps.setThinkingLevel(plan.selection.thinkingLevel);
}
await deps.updateStatusMessage();
if (plan.mode === "restart-after-tool") {
deps.stagePendingModelSwitch(plan.selection);
await deps.answerCallbackQuery(callbackQueryId, plan.callbackText);
return true;
}
if (plan.mode === "restart-now") {
const restarted = await deps.restartInterruptedTelegramTurn(plan.selection);
if (!restarted) {
await deps.answerCallbackQuery(
callbackQueryId,
"Pi is busy. Send /stop first.",
);
return true;
}
}
await deps.answerCallbackQuery(callbackQueryId, plan.callbackText);
return true;
}
export async function handleTelegramStatusMenuCallbackAction(
callbackQueryId: string,
data: string | undefined,
activeModel: Model<any> | undefined,
deps: TelegramStatusMenuCallbackDeps,
): Promise<boolean> {
const action = parseTelegramMenuCallbackAction(data);
if (action.kind === "status" && action.action === "model") {
await deps.updateModelMenuMessage();
await deps.answerCallbackQuery(callbackQueryId);
return true;
}
if (action.kind === "status" && action.action === "trace") {
const nextTraceVisible = !deps.getTraceVisible();
deps.setTraceVisible(nextTraceVisible);
await deps.updateStatusMessage();
await deps.answerCallbackQuery(
callbackQueryId,
`Trace: ${nextTraceVisible ? "on" : "off"}`,
);
return true;
}
if (!(action.kind === "status" && action.action === "thinking")) {
return false;
}
if (!activeModel?.reasoning) {
await deps.answerCallbackQuery(
callbackQueryId,
"This model has no reasoning controls.",
);
return true;
}
await deps.updateThinkingMenuMessage();
await deps.answerCallbackQuery(callbackQueryId);
return true;
}
export async function handleTelegramThinkingMenuCallbackAction(
callbackQueryId: string,
data: string | undefined,
activeModel: Model<any> | undefined,
deps: TelegramThinkingMenuCallbackDeps,
): Promise<boolean> {
const action = parseTelegramMenuCallbackAction(data);
if (action.kind !== "thinking:set") return false;
if (!isThinkingLevel(action.level)) {
await deps.answerCallbackQuery(callbackQueryId, "Invalid thinking level.");
return true;
}
if (!activeModel?.reasoning) {
await deps.answerCallbackQuery(
callbackQueryId,
"This model has no reasoning controls.",
);
return true;
}
deps.setThinkingLevel(action.level);
await deps.updateStatusMessage();
await deps.answerCallbackQuery(
callbackQueryId,
`Thinking: ${deps.getCurrentThinkingLevel()}`,
);
return true;
}
export function buildThinkingMenuText(
activeModel: Model<any> | undefined,
currentThinkingLevel: ThinkingLevel,
): string {
const lines = ["Choose a thinking level"];
if (activeModel) {
lines.push(`Model: ${getCanonicalModelId(activeModel)}`);
}
lines.push(`Current: ${currentThinkingLevel}`);
return lines.join("\n");
}
export function getTelegramModelMenuPage(
state: TelegramModelMenuState,
pageSize: number,
): TelegramModelMenuPage {
const items = getModelMenuItems(state);
const pageCount = Math.max(1, Math.ceil(items.length / pageSize));
const page = Math.max(0, Math.min(state.page, pageCount - 1));
const start = page * pageSize;
return {
page,
pageCount,
start,
items: items.slice(start, start + pageSize),
};
}
export function buildModelMenuReplyMarkup(
state: TelegramModelMenuState,
currentModel: Model<any> | undefined,
pageSize: number,
): TelegramReplyMarkup {
const menuPage = getTelegramModelMenuPage(state, pageSize);
const rows = menuPage.items.map((entry, index) => [
{
text: formatScopedModelButtonText(entry, currentModel),
callback_data: `model:pick:${menuPage.start + index}`,
},
]);
if (menuPage.pageCount > 1) {
const previousPage =
menuPage.page === 0 ? menuPage.pageCount - 1 : menuPage.page - 1;
const nextPage =
menuPage.page === menuPage.pageCount - 1 ? 0 : menuPage.page + 1;
rows.push([
{ text: "⬅️", callback_data: `model:page:${previousPage}` },
{
text: `${menuPage.page + 1}/${menuPage.pageCount}`,
callback_data: "model:noop",
},
{ text: "➡️", callback_data: `model:page:${nextPage}` },
]);
}
if (state.scopedModels.length > 0) {
rows.push([
{
text: state.scope === "scoped" ? "✅ Scoped" : "Scoped",
callback_data: "model:scope:scoped",
},
{
text: state.scope === "all" ? "✅ All" : "All",
callback_data: "model:scope:all",
},
]);
}
return { inline_keyboard: rows };
}
export function buildThinkingMenuReplyMarkup(
currentThinkingLevel: ThinkingLevel,
): TelegramReplyMarkup {
return {
inline_keyboard: THINKING_LEVELS.map((level) => [
{
text: level === currentThinkingLevel ? `✅ ${level}` : level,
callback_data: `thinking:set:${level}`,
},
]),
};
}
export function buildStatusReplyMarkup(
activeModel: Model<any> | undefined,
currentThinkingLevel: ThinkingLevel,
traceVisible: boolean,
): TelegramReplyMarkup {
const rows: Array<Array<{ text: string; callback_data: string }>> = [];
rows.push([
{
text: formatStatusButtonLabel(
"Model",
activeModel ? getCanonicalModelId(activeModel) : "unknown",
),
callback_data: "status:model",
},
]);
rows.push([
{
text: formatStatusButtonLabel("Trace", traceVisible ? "on" : "off"),
callback_data: "status:trace",
},
]);
if (activeModel?.reasoning) {
rows.push([
{
text: formatStatusButtonLabel("Thinking", currentThinkingLevel),
callback_data: "status:thinking",
},
]);
}
return { inline_keyboard: rows };
}
export function buildTelegramModelMenuRenderPayload(
state: TelegramModelMenuState,
activeModel: Model<any> | undefined,
): TelegramMenuRenderPayload {
return {
nextMode: "model",
text: MODEL_MENU_TITLE,
mode: "html",
replyMarkup: buildModelMenuReplyMarkup(
state,
activeModel,
TELEGRAM_MODEL_PAGE_SIZE,
),
};
}
export function buildTelegramThinkingMenuRenderPayload(
activeModel: Model<any> | undefined,
currentThinkingLevel: ThinkingLevel,
): TelegramMenuRenderPayload {
return {
nextMode: "thinking",
text: buildThinkingMenuText(activeModel, currentThinkingLevel),
mode: "plain",
replyMarkup: buildThinkingMenuReplyMarkup(currentThinkingLevel),
};
}
export function buildTelegramStatusMenuRenderPayload(
statusText: string,
activeModel: Model<any> | undefined,
currentThinkingLevel: ThinkingLevel,
traceVisible: boolean,
): TelegramMenuRenderPayload {
return {
nextMode: "status",
text: statusText,
mode: "html",
replyMarkup: buildStatusReplyMarkup(
activeModel,
currentThinkingLevel,
traceVisible,
),
};
}
export async function updateTelegramModelMenuMessage(
state: TelegramModelMenuState,
activeModel: Model<any> | undefined,
deps: TelegramMenuMessageRuntimeDeps,
): Promise<void> {
const payload = buildTelegramModelMenuRenderPayload(state, activeModel);
state.mode = payload.nextMode;
await deps.editInteractiveMessage(
state.chatId,
state.messageId,
payload.text,
payload.mode,
payload.replyMarkup,
);
}
export async function updateTelegramThinkingMenuMessage(
state: TelegramModelMenuState,
activeModel: Model<any> | undefined,
currentThinkingLevel: ThinkingLevel,
deps: TelegramMenuMessageRuntimeDeps,
): Promise<void> {
const payload = buildTelegramThinkingMenuRenderPayload(
activeModel,
currentThinkingLevel,
);
state.mode = payload.nextMode;
await deps.editInteractiveMessage(
state.chatId,
state.messageId,
payload.text,
payload.mode,
payload.replyMarkup,
);
}
export async function updateTelegramStatusMessage(
state: TelegramModelMenuState,
statusText: string,
activeModel: Model<any> | undefined,
currentThinkingLevel: ThinkingLevel,
traceVisible: boolean,
deps: TelegramMenuMessageRuntimeDeps,
): Promise<void> {
const payload = buildTelegramStatusMenuRenderPayload(
statusText,
activeModel,
currentThinkingLevel,
traceVisible,
);
state.mode = payload.nextMode;
await deps.editInteractiveMessage(
state.chatId,
state.messageId,
payload.text,
payload.mode,
payload.replyMarkup,
);
}
export async function sendTelegramStatusMessage(
state: TelegramModelMenuState,
statusText: string,
activeModel: Model<any> | undefined,
currentThinkingLevel: ThinkingLevel,
traceVisible: boolean,
deps: TelegramMenuMessageRuntimeDeps,
): Promise<number | undefined> {
const payload = buildTelegramStatusMenuRenderPayload(
statusText,
activeModel,
currentThinkingLevel,
traceVisible,
);
state.mode = payload.nextMode;
return deps.sendInteractiveMessage(
state.chatId,
payload.text,
payload.mode,
payload.replyMarkup,
);
}
export async function sendTelegramModelMenuMessage(
state: TelegramModelMenuState,
activeModel: Model<any> | undefined,
deps: TelegramMenuMessageRuntimeDeps,
): Promise<number | undefined> {
const payload = buildTelegramModelMenuRenderPayload(state, activeModel);
state.mode = payload.nextMode;
return deps.sendInteractiveMessage(
state.chatId,
payload.text,
payload.mode,
payload.replyMarkup,
);
}