diff --git a/CHANGELOG.md b/CHANGELOG.md index 68c7af413..f29e19d75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,7 +40,6 @@ Docs: https://docs.openclaw.ai - Security/Agents: restrict local MEDIA tool attachments to core tools and the OpenClaw temp root to prevent untrusted MCP tool file exfiltration. Thanks @NucleiAv and @thewilloftheshadow. - macOS/Build: default release packaging to `BUNDLE_ID=ai.openclaw.mac` in `scripts/package-mac-dist.sh`, so Sparkle feed URL is retained and auto-update no longer fails with an empty appcast feed. (#19750) thanks @loganprit. - Gateway/Pairing: clear persisted paired-device state when the gateway client closes with `device token mismatch` (`1008`) so reconnect flows can cleanly re-enter pairing. (#22071) Thanks @mbelinky. - - Signal/Outbound: preserve case for Base64 group IDs during outbound target normalization so cross-context routing and policy checks no longer break when group IDs include uppercase characters. (#5578) Thanks @heyhudson. - Providers/Copilot: drop persisted assistant `thinking` blocks for Claude models (while preserving turn structure/tool blocks) so follow-up requests no longer fail on invalid `thinkingSignature` payloads. (#19459) Thanks @jackheuberger. - Providers/Copilot: add `claude-sonnet-4.6` and `claude-sonnet-4.5` to the default GitHub Copilot model catalog and add coverage for model-list/definition helpers. (#20270, fixes #20091) Thanks @Clawborn. @@ -53,6 +52,7 @@ Docs: https://docs.openclaw.ai - WhatsApp/Cron/Heartbeat: enforce allowlisted routing for implicit scheduled/system delivery by merging pairing-store + configured `allowFrom` recipients, selecting authorized recipients when last-route context points to a non-allowlisted chat, and preventing heartbeat fan-out to recent unauthorized chats. - Heartbeat/Active hours: constrain active-hours `24` sentinel parsing to `24:00` in time validation so invalid values like `24:30` are rejected early. (#21410) thanks @adhitShet. - Heartbeat: treat `activeHours` windows with identical `start`/`end` times as zero-width (always outside the window) instead of always-active. (#21408) thanks @adhitShet. +- Discord: restore model picker back navigation when a provider is missing and document the Discord picker flow. (#21458) Thanks @pejmanjohn and @thewilloftheshadow. - Gateway/Pairing: tolerate legacy paired devices missing `roles`/`scopes` metadata in websocket upgrade checks and backfill metadata on reconnect. (#21447, fixes #21236) Thanks @joshavant. - Gateway/Pairing/CLI: align read-scope compatibility in pairing/device-token checks and add local `openclaw devices` fallback recovery for loopback `pairing required` deadlocks, with explicit fallback notice to unblock approval bootstrap flows. (#21616) Thanks @shakkernerd. - Auth/Onboarding: align OAuth profile-id config mapping with stored credential IDs for OpenAI Codex and Chutes flows, preventing `provider:default` mismatches when OAuth returns email-scoped credentials. (#12692) thanks @mudrii. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 28c4c8e14..0970a88c7 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -295,6 +295,8 @@ By default, components are single use. Set `components.reusable=true` to allow b To restrict who can click a button, set `allowedUsers` on that button (Discord user IDs, tags, or `*`). When configured, unmatched users receive an ephemeral denial. +The `/model` and `/models` slash commands open an interactive model picker with provider and model dropdowns plus a Submit step. The picker reply is ephemeral and only the invoking user can use it. + File attachments: - `file` blocks must point to an attachment reference (`attachment://`) diff --git a/docs/concepts/models.md b/docs/concepts/models.md index 3af853f5b..ee8f06ecb 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -104,6 +104,7 @@ You can switch models for the current session without restarting: Notes: - `/model` (and `/model list`) is a compact, numbered picker (model family + available providers). +- On Discord, `/model` and `/models` open an interactive picker with provider and model dropdowns plus a Submit step. - `/model <#>` selects from that picker. - `/model status` is the detailed view (auth candidates and, when configured, provider endpoint `baseUrl` + `api` mode). - Model refs are parsed by splitting on the **first** `/`. Use `provider/model` when typing `/model `. diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 38f80b53b..67f7a23e1 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -159,6 +159,7 @@ Examples: Notes: - `/model` and `/model list` show a compact, numbered picker (model family + available providers). +- On Discord, `/model` and `/models` open an interactive picker with provider and model dropdowns plus a Submit step. - `/model <#>` selects from that picker (and prefers the current provider when possible). - `/model status` shows the detailed view, including configured provider endpoint (`baseUrl`) and API mode (`api`) when available. diff --git a/src/discord/monitor/model-picker-preferences.ts b/src/discord/monitor/model-picker-preferences.ts new file mode 100644 index 000000000..14850475c --- /dev/null +++ b/src/discord/monitor/model-picker-preferences.ts @@ -0,0 +1,193 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { normalizeProviderId } from "../../agents/model-selection.js"; +import { resolveStateDir } from "../../config/paths.js"; +import { withFileLock } from "../../infra/file-lock.js"; +import { resolveRequiredHomeDir } from "../../infra/home-dir.js"; + +const MODEL_PICKER_PREFERENCES_LOCK_OPTIONS = { + retries: { + retries: 8, + factor: 2, + minTimeout: 50, + maxTimeout: 5_000, + randomize: true, + }, + stale: 15_000, +} as const; + +const DEFAULT_RECENT_LIMIT = 5; + +type ModelPickerPreferencesEntry = { + recent: string[]; + updatedAt: string; +}; + +type ModelPickerPreferencesStore = { + version: 1; + entries: Record; +}; + +export type DiscordModelPickerPreferenceScope = { + accountId?: string; + guildId?: string; + userId: string; +}; + +function resolvePreferencesStorePath(env: NodeJS.ProcessEnv = process.env): string { + const stateDir = resolveStateDir(env, () => resolveRequiredHomeDir(env, os.homedir)); + return path.join(stateDir, "discord", "model-picker-preferences.json"); +} + +function normalizeAccountId(value?: string): string { + const normalized = value?.trim().toLowerCase(); + return normalized || "default"; +} + +function normalizeId(value?: string): string { + return value?.trim() ?? ""; +} + +export function buildDiscordModelPickerPreferenceKey( + scope: DiscordModelPickerPreferenceScope, +): string | null { + const userId = normalizeId(scope.userId); + if (!userId) { + return null; + } + const accountId = normalizeAccountId(scope.accountId); + const guildId = normalizeId(scope.guildId); + if (guildId) { + return `discord:${accountId}:guild:${guildId}:user:${userId}`; + } + return `discord:${accountId}:dm:user:${userId}`; +} + +function normalizeModelRef(raw?: string): string | null { + const value = raw?.trim(); + if (!value) { + return null; + } + const slashIndex = value.indexOf("/"); + if (slashIndex <= 0 || slashIndex >= value.length - 1) { + return null; + } + const provider = normalizeProviderId(value.slice(0, slashIndex)); + const model = value.slice(slashIndex + 1).trim(); + if (!provider || !model) { + return null; + } + return `${provider}/${model}`; +} + +function sanitizeRecentModels(models: string[] | undefined, limit: number): string[] { + const deduped: string[] = []; + const seen = new Set(); + for (const item of models ?? []) { + const normalized = normalizeModelRef(item); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + deduped.push(normalized); + if (deduped.length >= limit) { + break; + } + } + return deduped; +} + +async function readJsonFileWithFallback( + filePath: string, + fallback: T, +): Promise<{ value: T; exists: boolean }> { + try { + const raw = await fs.promises.readFile(filePath, "utf-8"); + const parsed = JSON.parse(raw) as T; + return { value: parsed, exists: true }; + } catch (err) { + const code = (err as { code?: string }).code; + if (code === "ENOENT") { + return { value: fallback, exists: false }; + } + return { value: fallback, exists: false }; + } +} + +async function writeJsonFileAtomically(filePath: string, value: unknown): Promise { + const dir = path.dirname(filePath); + await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 }); + const tmp = path.join(dir, `${path.basename(filePath)}.${crypto.randomUUID()}.tmp`); + await fs.promises.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf-8"); + await fs.promises.chmod(tmp, 0o600); + await fs.promises.rename(tmp, filePath); +} + +async function readPreferencesStore(filePath: string): Promise { + const { value } = await readJsonFileWithFallback(filePath, { + version: 1, + entries: {}, + }); + if (!value || typeof value !== "object" || value.version !== 1) { + return { version: 1, entries: {} }; + } + return { + version: 1, + entries: value.entries && typeof value.entries === "object" ? value.entries : {}, + }; +} + +export async function readDiscordModelPickerRecentModels(params: { + scope: DiscordModelPickerPreferenceScope; + limit?: number; + allowedModelRefs?: Set; + env?: NodeJS.ProcessEnv; +}): Promise { + const key = buildDiscordModelPickerPreferenceKey(params.scope); + if (!key) { + return []; + } + const limit = Math.max(1, Math.min(params.limit ?? DEFAULT_RECENT_LIMIT, 10)); + const filePath = resolvePreferencesStorePath(params.env); + const store = await readPreferencesStore(filePath); + const entry = store.entries[key]; + const recent = sanitizeRecentModels(entry?.recent, limit); + if (!params.allowedModelRefs || params.allowedModelRefs.size === 0) { + return recent; + } + return recent.filter((modelRef) => params.allowedModelRefs?.has(modelRef)); +} + +export async function recordDiscordModelPickerRecentModel(params: { + scope: DiscordModelPickerPreferenceScope; + modelRef: string; + limit?: number; + env?: NodeJS.ProcessEnv; +}): Promise { + const key = buildDiscordModelPickerPreferenceKey(params.scope); + const normalizedModelRef = normalizeModelRef(params.modelRef); + if (!key || !normalizedModelRef) { + return; + } + + const limit = Math.max(1, Math.min(params.limit ?? DEFAULT_RECENT_LIMIT, 10)); + const filePath = resolvePreferencesStorePath(params.env); + + await withFileLock(filePath, MODEL_PICKER_PREFERENCES_LOCK_OPTIONS, async () => { + const store = await readPreferencesStore(filePath); + const existing = sanitizeRecentModels(store.entries[key]?.recent, limit); + const next = [ + normalizedModelRef, + ...existing.filter((entry) => entry !== normalizedModelRef), + ].slice(0, limit); + + store.entries[key] = { + recent: next, + updatedAt: new Date().toISOString(), + }; + + await writeJsonFileAtomically(filePath, store); + }); +} diff --git a/src/discord/monitor/model-picker.test.ts b/src/discord/monitor/model-picker.test.ts new file mode 100644 index 000000000..970382e43 --- /dev/null +++ b/src/discord/monitor/model-picker.test.ts @@ -0,0 +1,626 @@ +import { serializePayload } from "@buape/carbon"; +import { ComponentType } from "discord-api-types/v10"; +import { describe, expect, it, vi } from "vitest"; +import type { ModelsProviderData } from "../../auto-reply/reply/commands-models.js"; +import * as modelsCommandModule from "../../auto-reply/reply/commands-models.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { + DISCORD_CUSTOM_ID_MAX_CHARS, + DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE, + DISCORD_MODEL_PICKER_PROVIDER_PAGE_SIZE, + DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX, + buildDiscordModelPickerCustomId, + getDiscordModelPickerModelPage, + getDiscordModelPickerProviderPage, + loadDiscordModelPickerData, + parseDiscordModelPickerCustomId, + parseDiscordModelPickerData, + renderDiscordModelPickerModelsView, + renderDiscordModelPickerProvidersView, + renderDiscordModelPickerRecentsView, + toDiscordModelPickerMessagePayload, +} from "./model-picker.js"; + +function createModelsProviderData(entries: Record): ModelsProviderData { + const byProvider = new Map>(); + for (const [provider, models] of Object.entries(entries)) { + byProvider.set(provider, new Set(models)); + } + return { + byProvider, + providers: Object.keys(entries).toSorted(), + resolvedDefault: { + provider: Object.keys(entries)[0] ?? "openai", + model: entries[Object.keys(entries)[0]]?.[0] ?? "gpt-4o", + }, + }; +} + +type SerializedComponent = { + type: number; + custom_id?: string; + options?: Array<{ value: string; default?: boolean }>; + components?: SerializedComponent[]; +}; + +function extractContainerRows(components?: SerializedComponent[]): SerializedComponent[] { + const container = components?.find( + (component) => component.type === Number(ComponentType.Container), + ); + if (!container) { + return []; + } + return (container.components ?? []).filter( + (component) => component.type === Number(ComponentType.ActionRow), + ); +} + +describe("loadDiscordModelPickerData", () => { + it("reuses buildModelsProviderData as source of truth", async () => { + const expected = createModelsProviderData({ openai: ["gpt-4o"] }); + const spy = vi + .spyOn(modelsCommandModule, "buildModelsProviderData") + .mockResolvedValue(expected); + + const result = await loadDiscordModelPickerData({} as OpenClawConfig); + + expect(spy).toHaveBeenCalledTimes(1); + expect(result).toBe(expected); + }); +}); + +describe("Discord model picker custom_id", () => { + it("encodes and decodes command/provider/page/user context", () => { + const customId = buildDiscordModelPickerCustomId({ + command: "models", + action: "provider", + view: "models", + provider: "OpenAI", + page: 3, + userId: "1234567890", + }); + + const parsed = parseDiscordModelPickerCustomId(customId); + + expect(parsed).toEqual({ + command: "models", + action: "provider", + view: "models", + provider: "openai", + page: 3, + userId: "1234567890", + }); + }); + + it("parses component data payloads", () => { + const parsed = parseDiscordModelPickerData({ + cmd: "model", + act: "back", + view: "providers", + u: "42", + p: "anthropic", + pg: "2", + }); + + expect(parsed).toEqual({ + command: "model", + action: "back", + view: "providers", + userId: "42", + provider: "anthropic", + page: 2, + }); + }); + + it("parses optional submit model index", () => { + const parsed = parseDiscordModelPickerData({ + cmd: "models", + act: "submit", + view: "models", + u: "42", + p: "openai", + pg: "1", + mi: "7", + }); + + expect(parsed).toEqual({ + command: "models", + action: "submit", + view: "models", + userId: "42", + provider: "openai", + page: 1, + modelIndex: 7, + }); + }); + + it("rejects invalid command/action/view values", () => { + expect( + parseDiscordModelPickerData({ + cmd: "status", + act: "nav", + view: "providers", + u: "42", + }), + ).toBeNull(); + expect( + parseDiscordModelPickerData({ + cmd: "model", + act: "unknown", + view: "providers", + u: "42", + }), + ).toBeNull(); + expect( + parseDiscordModelPickerData({ + cmd: "model", + act: "nav", + view: "unknown", + u: "42", + }), + ).toBeNull(); + }); + + it("enforces Discord custom_id max length", () => { + const longProvider = `provider-${"x".repeat(DISCORD_CUSTOM_ID_MAX_CHARS)}`; + expect(() => + buildDiscordModelPickerCustomId({ + command: "model", + action: "provider", + view: "models", + provider: longProvider, + page: 1, + userId: "42", + }), + ).toThrow(/custom_id exceeds/i); + }); +}); + +describe("provider paging", () => { + it("keeps providers on a single page when count fits Discord button rows", () => { + const entries: Record = {}; + for (let i = 1; i <= DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX - 2; i += 1) { + entries[`provider-${String(i).padStart(2, "0")}`] = [`model-${i}`]; + } + const data = createModelsProviderData(entries); + + const page = getDiscordModelPickerProviderPage({ data, page: 1 }); + + expect(page.items).toHaveLength(DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX - 2); + expect(page.totalPages).toBe(1); + expect(page.pageSize).toBe(DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX); + expect(page.hasPrev).toBe(false); + expect(page.hasNext).toBe(false); + }); + + it("paginates providers when count exceeds one-page Discord button limits", () => { + const entries: Record = {}; + for (let i = 1; i <= DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX + 3; i += 1) { + entries[`provider-${String(i).padStart(2, "0")}`] = [`model-${i}`]; + } + const data = createModelsProviderData(entries); + + const page1 = getDiscordModelPickerProviderPage({ data, page: 1 }); + const lastPage = getDiscordModelPickerProviderPage({ data, page: 99 }); + + expect(page1.items).toHaveLength(DISCORD_MODEL_PICKER_PROVIDER_PAGE_SIZE); + expect(page1.totalPages).toBe(2); + expect(page1.hasNext).toBe(true); + + expect(lastPage.page).toBe(2); + expect(lastPage.items).toHaveLength(8); + expect(lastPage.hasPrev).toBe(true); + expect(lastPage.hasNext).toBe(false); + }); + + it("caps custom provider page size at Discord-safe max", () => { + const compactData = createModelsProviderData({ + anthropic: ["claude-sonnet-4-5"], + openai: ["gpt-4o"], + google: ["gemini-3-pro"], + }); + const compactPage = getDiscordModelPickerProviderPage({ + data: compactData, + page: 1, + pageSize: 999, + }); + expect(compactPage.pageSize).toBe(DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX); + + const pagedEntries: Record = {}; + for (let i = 1; i <= DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX + 1; i += 1) { + pagedEntries[`provider-${String(i).padStart(2, "0")}`] = [`model-${i}`]; + } + const pagedData = createModelsProviderData(pagedEntries); + const pagedPage = getDiscordModelPickerProviderPage({ + data: pagedData, + page: 1, + pageSize: 999, + }); + expect(pagedPage.pageSize).toBe(DISCORD_MODEL_PICKER_PROVIDER_PAGE_SIZE); + }); +}); + +describe("model paging", () => { + it("sorts models and paginates with Discord select-option constraints", () => { + const models = Array.from( + { length: DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE + 4 }, + (_, idx) => + `model-${String(DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE + 4 - idx).padStart(2, "0")}`, + ); + const data = createModelsProviderData({ openai: models }); + + const page1 = getDiscordModelPickerModelPage({ data, provider: "openai", page: 1 }); + const page2 = getDiscordModelPickerModelPage({ data, provider: "openai", page: 2 }); + + expect(page1).not.toBeNull(); + expect(page2).not.toBeNull(); + expect(page1?.items).toHaveLength(DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE); + expect(page1?.items[0]).toBe("model-01"); + expect(page1?.hasNext).toBe(true); + + expect(page2?.items).toHaveLength(4); + expect(page2?.page).toBe(2); + expect(page2?.hasPrev).toBe(true); + expect(page2?.hasNext).toBe(false); + }); + + it("returns null for unknown provider", () => { + const data = createModelsProviderData({ anthropic: ["claude-sonnet-4-5"] }); + const page = getDiscordModelPickerModelPage({ data, provider: "openai", page: 1 }); + expect(page).toBeNull(); + }); + + it("caps custom model page size at Discord select-option max", () => { + const data = createModelsProviderData({ openai: ["gpt-4o", "gpt-4.1"] }); + const page = getDiscordModelPickerModelPage({ data, provider: "openai", pageSize: 999 }); + expect(page?.pageSize).toBe(DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE); + }); +}); + +describe("Discord model picker rendering", () => { + it("renders provider view on one page when provider count is <= 25", () => { + const entries: Record = {}; + for (let i = 1; i <= 22; i += 1) { + entries[`provider-${String(i).padStart(2, "0")}`] = [`model-${i}`]; + } + entries["azure-openai-responses"] = ["gpt-4.1"]; + entries["vercel-ai-gateway"] = ["gpt-4o-mini"]; + const data = createModelsProviderData(entries); + + const rendered = renderDiscordModelPickerProvidersView({ + command: "models", + userId: "42", + data, + currentModel: "provider-01/model-1", + }); + + const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as { + content?: string; + components?: SerializedComponent[]; + }; + + expect(payload.content).toBeUndefined(); + expect(payload.components?.[0]?.type).toBe(ComponentType.Container); + + const rows = extractContainerRows(payload.components); + expect(rows.length).toBeGreaterThan(0); + + const rowProviderCounts = rows.map( + (row) => + (row.components ?? []).filter((component) => { + const parsed = parseDiscordModelPickerCustomId(component.custom_id ?? ""); + return parsed?.action === "provider"; + }).length, + ); + expect(rowProviderCounts).toEqual([4, 5, 5, 5, 5]); + + const allButtons = rows.flatMap((row) => row.components ?? []); + const providerButtons = allButtons.filter((component) => { + const parsed = parseDiscordModelPickerCustomId(component.custom_id ?? ""); + return parsed?.action === "provider"; + }); + expect(providerButtons).toHaveLength(Object.keys(entries).length); + expect(allButtons.some((component) => (component.custom_id ?? "").includes(":act=nav:"))).toBe( + false, + ); + }); + + it("does not render navigation buttons even when provider count exceeds one page", () => { + const entries: Record = {}; + for (let i = 1; i <= DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX + 4; i += 1) { + entries[`provider-${String(i).padStart(2, "0")}`] = [`model-${i}`]; + } + const data = createModelsProviderData(entries); + + const rendered = renderDiscordModelPickerProvidersView({ + command: "models", + userId: "42", + data, + currentModel: "provider-01/model-1", + }); + + const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as { + components?: SerializedComponent[]; + }; + + const rows = extractContainerRows(payload.components); + expect(rows.length).toBeGreaterThan(0); + + const allButtons = rows.flatMap((row) => row.components ?? []); + expect(allButtons.some((component) => (component.custom_id ?? "").includes(":act=nav:"))).toBe( + false, + ); + }); + + it("supports classic fallback rendering with content + action rows", () => { + const data = createModelsProviderData({ openai: ["gpt-4o"], anthropic: ["claude-sonnet-4-5"] }); + + const rendered = renderDiscordModelPickerProvidersView({ + command: "model", + userId: "99", + data, + layout: "classic", + }); + + const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as { + content?: string; + components?: SerializedComponent[]; + }; + + expect(payload.content).toContain("Model Picker"); + expect(payload.components?.[0]?.type).toBe(ComponentType.ActionRow); + }); + + it("renders model view with select menu and explicit submit button", () => { + const data = createModelsProviderData({ + openai: ["gpt-4.1", "gpt-4o", "o3"], + anthropic: ["claude-sonnet-4-5"], + }); + + const rendered = renderDiscordModelPickerModelsView({ + command: "models", + userId: "42", + data, + provider: "openai", + page: 1, + providerPage: 2, + currentModel: "openai/gpt-4o", + pendingModel: "openai/o3", + pendingModelIndex: 3, + }); + + const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as { + components?: SerializedComponent[]; + }; + + const rows = extractContainerRows(payload.components); + expect(rows).toHaveLength(3); + + const providerSelect = rows[0]?.components?.find( + (component) => component.type === Number(ComponentType.StringSelect), + ); + expect(providerSelect).toBeTruthy(); + expect(providerSelect?.options?.length).toBe(2); + expect(providerSelect?.options?.find((option) => option.value === "openai")?.default).toBe( + true, + ); + const parsedProviderState = parseDiscordModelPickerCustomId(providerSelect?.custom_id ?? ""); + expect(parsedProviderState?.action).toBe("provider"); + + const modelSelect = rows[1]?.components?.find( + (component) => component.type === Number(ComponentType.StringSelect), + ); + expect(modelSelect).toBeTruthy(); + expect(modelSelect?.options?.length).toBe(3); + expect(modelSelect?.options?.find((option) => option.value === "o3")?.default).toBe(true); + + const parsedModelSelectState = parseDiscordModelPickerCustomId(modelSelect?.custom_id ?? ""); + expect(parsedModelSelectState?.action).toBe("model"); + expect(parsedModelSelectState?.provider).toBe("openai"); + + const navButtons = rows[2]?.components ?? []; + expect(navButtons).toHaveLength(3); + + const cancelState = parseDiscordModelPickerCustomId(navButtons[0]?.custom_id ?? ""); + expect(cancelState?.action).toBe("cancel"); + + const resetState = parseDiscordModelPickerCustomId(navButtons[1]?.custom_id ?? ""); + expect(resetState?.action).toBe("reset"); + expect(resetState?.provider).toBe("openai"); + + const submitState = parseDiscordModelPickerCustomId(navButtons[2]?.custom_id ?? ""); + expect(submitState?.action).toBe("submit"); + expect(submitState?.provider).toBe("openai"); + expect(submitState?.modelIndex).toBe(3); + }); + + it("renders not-found model view with a back button", () => { + const data = createModelsProviderData({ openai: ["gpt-4o"] }); + + const rendered = renderDiscordModelPickerModelsView({ + command: "model", + userId: "42", + data, + provider: "does-not-exist", + providerPage: 3, + }); + + const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as { + components?: SerializedComponent[]; + }; + + const rows = extractContainerRows(payload.components); + expect(rows).toHaveLength(1); + + const backButton = rows[0]?.components?.[0]; + expect(backButton?.type).toBe(ComponentType.Button); + + const state = parseDiscordModelPickerCustomId(backButton?.custom_id ?? ""); + expect(state?.action).toBe("back"); + expect(state?.view).toBe("providers"); + expect(state?.page).toBe(3); + }); + + it("shows Recents button when quickModels are provided", () => { + const data = createModelsProviderData({ + openai: ["gpt-4.1", "gpt-4o"], + anthropic: ["claude-sonnet-4-5"], + }); + + const rendered = renderDiscordModelPickerModelsView({ + command: "model", + userId: "42", + data, + provider: "openai", + page: 1, + providerPage: 1, + currentModel: "openai/gpt-4o", + quickModels: ["openai/gpt-4o", "anthropic/claude-sonnet-4-5"], + }); + + const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as { + components?: SerializedComponent[]; + }; + + const rows = extractContainerRows(payload.components); + const buttonRow = rows[2]; + const buttons = buttonRow?.components ?? []; + expect(buttons).toHaveLength(4); + + const favoritesState = parseDiscordModelPickerCustomId(buttons[2]?.custom_id ?? ""); + expect(favoritesState?.action).toBe("recents"); + expect(favoritesState?.view).toBe("recents"); + }); + + it("omits Recents button when no quickModels", () => { + const data = createModelsProviderData({ + openai: ["gpt-4.1", "gpt-4o"], + }); + + const rendered = renderDiscordModelPickerModelsView({ + command: "model", + userId: "42", + data, + provider: "openai", + page: 1, + providerPage: 1, + currentModel: "openai/gpt-4o", + }); + + const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as { + components?: SerializedComponent[]; + }; + + const rows = extractContainerRows(payload.components); + const buttonRow = rows[2]; + const buttons = buttonRow?.components ?? []; + expect(buttons).toHaveLength(3); + + const allActions = buttons.map( + (b) => parseDiscordModelPickerCustomId(b?.custom_id ?? "")?.action, + ); + expect(allActions).not.toContain("recents"); + }); +}); + +describe("Discord model picker recents view", () => { + it("renders one button per model with back button after divider", () => { + const data = createModelsProviderData({ + openai: ["gpt-4.1", "gpt-4o"], + anthropic: ["claude-sonnet-4-5"], + }); + + // Default is openai/gpt-4.1 (first key in entries). + // Neither quickModel matches, so no deduping — 1 default + 2 recents + 1 back = 4 rows. + const rendered = renderDiscordModelPickerRecentsView({ + command: "model", + userId: "42", + data, + quickModels: ["openai/gpt-4o", "anthropic/claude-sonnet-4-5"], + currentModel: "openai/gpt-4o", + }); + + const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as { + components?: SerializedComponent[]; + }; + + const rows = extractContainerRows(payload.components); + expect(rows).toHaveLength(4); + + // First row: default model button (slot 1). + const defaultBtn = rows[0]?.components?.[0]; + expect(defaultBtn?.type).toBe(ComponentType.Button); + const defaultState = parseDiscordModelPickerCustomId(defaultBtn?.custom_id ?? ""); + expect(defaultState?.action).toBe("submit"); + expect(defaultState?.view).toBe("recents"); + expect(defaultState?.recentSlot).toBe(1); + + // Second row: first recent (slot 2). + const recentBtn1 = rows[1]?.components?.[0]; + const recentState1 = parseDiscordModelPickerCustomId(recentBtn1?.custom_id ?? ""); + expect(recentState1?.recentSlot).toBe(2); + + // Third row: second recent (slot 3). + const recentBtn2 = rows[2]?.components?.[0]; + const recentState2 = parseDiscordModelPickerCustomId(recentBtn2?.custom_id ?? ""); + expect(recentState2?.recentSlot).toBe(3); + + // Fourth row (after divider): Back button. + const backBtn = rows[3]?.components?.[0]; + const backState = parseDiscordModelPickerCustomId(backBtn?.custom_id ?? ""); + expect(backState?.action).toBe("back"); + expect(backState?.view).toBe("models"); + }); + + it("includes (default) suffix on default model button label", () => { + const data = createModelsProviderData({ + openai: ["gpt-4o"], + }); + + const rendered = renderDiscordModelPickerRecentsView({ + command: "model", + userId: "42", + data, + quickModels: ["openai/gpt-4o"], + currentModel: "openai/gpt-4o", + }); + + const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as { + components?: SerializedComponent[]; + }; + + const rows = extractContainerRows(payload.components); + const defaultBtn = rows[0]?.components?.[0] as { label?: string }; + expect(defaultBtn?.label).toContain("(default)"); + }); + + it("deduplicates recents that match the default model", () => { + const data = createModelsProviderData({ + openai: ["gpt-4o"], + anthropic: ["claude-sonnet-4-5"], + }); + // Default is openai/gpt-4o (first key). quickModels contains the default. + const rendered = renderDiscordModelPickerRecentsView({ + command: "model", + userId: "42", + data, + quickModels: ["openai/gpt-4o", "anthropic/claude-sonnet-4-5"], + currentModel: "openai/gpt-4o", + }); + + const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as { + components?: SerializedComponent[]; + }; + + const rows = extractContainerRows(payload.components); + // 1 default + 1 deduped recent + 1 back = 3 rows (openai/gpt-4o not shown twice) + expect(rows).toHaveLength(3); + + const defaultBtn = rows[0]?.components?.[0] as { label?: string }; + expect(defaultBtn?.label).toContain("openai/gpt-4o"); + expect(defaultBtn?.label).toContain("(default)"); + + const recentBtn = rows[1]?.components?.[0] as { label?: string }; + expect(recentBtn?.label).toContain("anthropic/claude-sonnet-4-5"); + }); +}); diff --git a/src/discord/monitor/model-picker.ts b/src/discord/monitor/model-picker.ts new file mode 100644 index 000000000..ad3654ae8 --- /dev/null +++ b/src/discord/monitor/model-picker.ts @@ -0,0 +1,937 @@ +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 "../../agents/model-selection.js"; +import { + buildModelsProviderData, + type ModelsProviderData, +} from "../../auto-reply/reply/commands-models.js"; +import type { OpenClawConfig } from "../../config/config.js"; + +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 = { + items: T[]; + page: number; + pageSize: number; + totalPages: number; + totalItems: number; + hasPrev: boolean; + hasNext: boolean; +}; + +export type DiscordModelPickerModelPage = DiscordModelPickerPage & { + 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