fix: restore Discord model picker UX (#21458) (thanks @pejmanjohn)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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://<filename>`)
|
||||
|
||||
@@ -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 <ref>`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
193
src/discord/monitor/model-picker-preferences.ts
Normal file
193
src/discord/monitor/model-picker-preferences.ts
Normal file
@@ -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<string, ModelPickerPreferencesEntry>;
|
||||
};
|
||||
|
||||
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<string>();
|
||||
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<T>(
|
||||
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<void> {
|
||||
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<ModelPickerPreferencesStore> {
|
||||
const { value } = await readJsonFileWithFallback<ModelPickerPreferencesStore>(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<string>;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<string[]> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
626
src/discord/monitor/model-picker.test.ts
Normal file
626
src/discord/monitor/model-picker.test.ts
Normal file
@@ -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<string, string[]>): ModelsProviderData {
|
||||
const byProvider = new Map<string, Set<string>>();
|
||||
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<string, string[]> = {};
|
||||
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<string, string[]> = {};
|
||||
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<string, string[]> = {};
|
||||
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<string, string[]> = {};
|
||||
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<string, string[]> = {};
|
||||
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");
|
||||
});
|
||||
});
|
||||
937
src/discord/monitor/model-picker.ts
Normal file
937
src/discord/monitor/model-picker.ts
Normal file
@@ -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<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();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const slashIndex = trimmed.indexOf("/");
|
||||
if (slashIndex <= 0 || slashIndex >= trimmed.length - 1) {
|
||||
return null;
|
||||
}
|
||||
const provider = normalizeProviderId(trimmed.slice(0, slashIndex));
|
||||
const model = trimmed.slice(slashIndex + 1);
|
||||
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): Promise<ModelsProviderData> {
|
||||
return buildModelsProviderData(cfg);
|
||||
}
|
||||
|
||||
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}:cmd=${encodeCustomIdValue(params.command)}`,
|
||||
`act=${encodeCustomIdValue(params.action)}`,
|
||||
`view=${encodeCustomIdValue(params.view)}`,
|
||||
`u=${encodeCustomIdValue(userId)}`,
|
||||
`pg=${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.cmd));
|
||||
const action = decodeCustomIdValue(coerceString(data.act));
|
||||
const view = decodeCustomIdValue(coerceString(data.view));
|
||||
const userId = decodeCustomIdValue(coerceString(data.u));
|
||||
const providerRaw = decodeCustomIdValue(coerceString(data.p));
|
||||
const page = parseRawPage(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,
|
||||
};
|
||||
}
|
||||
378
src/discord/monitor/native-command.model-picker.test.ts
Normal file
378
src/discord/monitor/native-command.model-picker.test.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
import { ChannelType } from "discord-api-types/v10";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as commandRegistryModule from "../../auto-reply/commands-registry.js";
|
||||
import type {
|
||||
ChatCommandDefinition,
|
||||
CommandArgsParsing,
|
||||
} from "../../auto-reply/commands-registry.types.js";
|
||||
import type { ModelsProviderData } from "../../auto-reply/reply/commands-models.js";
|
||||
import * as dispatcherModule from "../../auto-reply/reply/provider-dispatcher.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import * as timeoutModule from "../../utils/with-timeout.js";
|
||||
import * as modelPickerPreferencesModule from "./model-picker-preferences.js";
|
||||
import * as modelPickerModule from "./model-picker.js";
|
||||
import {
|
||||
createDiscordModelPickerFallbackButton,
|
||||
createDiscordModelPickerFallbackSelect,
|
||||
} from "./native-command.js";
|
||||
|
||||
function createModelsProviderData(entries: Record<string, string[]>): ModelsProviderData {
|
||||
const byProvider = new Map<string, Set<string>>();
|
||||
for (const [provider, models] of Object.entries(entries)) {
|
||||
byProvider.set(provider, new Set(models));
|
||||
}
|
||||
const providers = Object.keys(entries).toSorted();
|
||||
return {
|
||||
byProvider,
|
||||
providers,
|
||||
resolvedDefault: {
|
||||
provider: providers[0] ?? "openai",
|
||||
model: entries[providers[0] ?? "openai"]?.[0] ?? "gpt-4o",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type ModelPickerContext = Parameters<typeof createDiscordModelPickerFallbackButton>[0];
|
||||
type PickerButton = ReturnType<typeof createDiscordModelPickerFallbackButton>;
|
||||
type PickerSelect = ReturnType<typeof createDiscordModelPickerFallbackSelect>;
|
||||
type PickerButtonInteraction = Parameters<PickerButton["run"]>[0];
|
||||
type PickerButtonData = Parameters<PickerButton["run"]>[1];
|
||||
type PickerSelectInteraction = Parameters<PickerSelect["run"]>[0];
|
||||
type PickerSelectData = Parameters<PickerSelect["run"]>[1];
|
||||
|
||||
type MockInteraction = {
|
||||
user: { id: string; username: string; globalName: string };
|
||||
channel: { type: ChannelType; id: string };
|
||||
guild: null;
|
||||
rawData: { id: string; member: { roles: string[] } };
|
||||
values?: string[];
|
||||
reply: ReturnType<typeof vi.fn>;
|
||||
followUp: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
acknowledge: ReturnType<typeof vi.fn>;
|
||||
client: object;
|
||||
};
|
||||
|
||||
function createModelPickerContext(): ModelPickerContext {
|
||||
const cfg = {
|
||||
channels: {
|
||||
discord: {
|
||||
dm: {
|
||||
enabled: true,
|
||||
policy: "open",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
return {
|
||||
cfg,
|
||||
discordConfig: cfg.channels?.discord ?? {},
|
||||
accountId: "default",
|
||||
sessionPrefix: "discord:slash",
|
||||
};
|
||||
}
|
||||
|
||||
function createInteraction(params?: { userId?: string; values?: string[] }): MockInteraction {
|
||||
const userId = params?.userId ?? "owner";
|
||||
return {
|
||||
user: {
|
||||
id: userId,
|
||||
username: "tester",
|
||||
globalName: "Tester",
|
||||
},
|
||||
channel: {
|
||||
type: ChannelType.DM,
|
||||
id: "dm-1",
|
||||
},
|
||||
guild: null,
|
||||
rawData: {
|
||||
id: "interaction-1",
|
||||
member: { roles: [] },
|
||||
},
|
||||
values: params?.values,
|
||||
reply: vi.fn().mockResolvedValue({ ok: true }),
|
||||
followUp: vi.fn().mockResolvedValue({ ok: true }),
|
||||
update: vi.fn().mockResolvedValue({ ok: true }),
|
||||
acknowledge: vi.fn().mockResolvedValue({ ok: true }),
|
||||
client: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("Discord model picker interactions", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("registers distinct fallback ids for button and select handlers", () => {
|
||||
const context = createModelPickerContext();
|
||||
const button = createDiscordModelPickerFallbackButton(context);
|
||||
const select = createDiscordModelPickerFallbackSelect(context);
|
||||
|
||||
expect(button.customId).not.toBe(select.customId);
|
||||
expect(button.customId.split(":")[0]).toBe(select.customId.split(":")[0]);
|
||||
});
|
||||
|
||||
it("ignores interactions from users other than the picker owner", async () => {
|
||||
const context = createModelPickerContext();
|
||||
const loadSpy = vi.spyOn(modelPickerModule, "loadDiscordModelPickerData");
|
||||
const button = createDiscordModelPickerFallbackButton(context);
|
||||
const interaction = createInteraction({ userId: "intruder" });
|
||||
|
||||
const data: PickerButtonData = {
|
||||
cmd: "model",
|
||||
act: "back",
|
||||
view: "providers",
|
||||
u: "owner",
|
||||
pg: "1",
|
||||
};
|
||||
|
||||
await button.run(interaction as unknown as PickerButtonInteraction, data);
|
||||
|
||||
expect(interaction.acknowledge).toHaveBeenCalledTimes(1);
|
||||
expect(interaction.update).not.toHaveBeenCalled();
|
||||
expect(loadSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires submit click before routing selected model through /model pipeline", async () => {
|
||||
const context = createModelPickerContext();
|
||||
const pickerData = createModelsProviderData({
|
||||
openai: ["gpt-4.1", "gpt-4o"],
|
||||
anthropic: ["claude-sonnet-4-5"],
|
||||
});
|
||||
const modelCommand: ChatCommandDefinition = {
|
||||
key: "model",
|
||||
nativeName: "model",
|
||||
description: "Switch model",
|
||||
textAliases: ["/model"],
|
||||
acceptsArgs: true,
|
||||
argsParsing: "none" as CommandArgsParsing,
|
||||
scope: "native",
|
||||
};
|
||||
|
||||
vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData);
|
||||
vi.spyOn(commandRegistryModule, "findCommandByNativeName").mockImplementation((name) =>
|
||||
name === "model" ? modelCommand : undefined,
|
||||
);
|
||||
vi.spyOn(commandRegistryModule, "listChatCommands").mockReturnValue([modelCommand]);
|
||||
vi.spyOn(commandRegistryModule, "resolveCommandArgMenu").mockReturnValue(null);
|
||||
|
||||
const dispatchSpy = vi
|
||||
.spyOn(dispatcherModule, "dispatchReplyWithDispatcher")
|
||||
.mockResolvedValue({} as never);
|
||||
|
||||
const select = createDiscordModelPickerFallbackSelect(context);
|
||||
const selectInteraction = createInteraction({
|
||||
userId: "owner",
|
||||
values: ["gpt-4o"],
|
||||
});
|
||||
|
||||
const selectData: PickerSelectData = {
|
||||
cmd: "model",
|
||||
act: "model",
|
||||
view: "models",
|
||||
u: "owner",
|
||||
p: "openai",
|
||||
pg: "1",
|
||||
};
|
||||
|
||||
await select.run(selectInteraction as unknown as PickerSelectInteraction, selectData);
|
||||
|
||||
expect(selectInteraction.update).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchSpy).not.toHaveBeenCalled();
|
||||
|
||||
const button = createDiscordModelPickerFallbackButton(context);
|
||||
const submitInteraction = createInteraction({ userId: "owner" });
|
||||
const submitData: PickerButtonData = {
|
||||
cmd: "model",
|
||||
act: "submit",
|
||||
view: "models",
|
||||
u: "owner",
|
||||
p: "openai",
|
||||
pg: "1",
|
||||
mi: "2",
|
||||
};
|
||||
|
||||
await button.run(submitInteraction as unknown as PickerButtonInteraction, submitData);
|
||||
|
||||
expect(submitInteraction.update).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
|
||||
ctx?: {
|
||||
CommandBody?: string;
|
||||
CommandArgs?: { values?: { model?: string } };
|
||||
CommandTargetSessionKey?: string;
|
||||
};
|
||||
};
|
||||
expect(dispatchCall.ctx?.CommandBody).toBe("/model openai/gpt-4o");
|
||||
expect(dispatchCall.ctx?.CommandArgs?.values?.model).toBe("openai/gpt-4o");
|
||||
expect(dispatchCall.ctx?.CommandTargetSessionKey).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows timeout status and skips recents write when apply is still processing", async () => {
|
||||
const context = createModelPickerContext();
|
||||
const pickerData = createModelsProviderData({
|
||||
openai: ["gpt-4.1", "gpt-4o"],
|
||||
anthropic: ["claude-sonnet-4-5"],
|
||||
});
|
||||
const modelCommand: ChatCommandDefinition = {
|
||||
key: "model",
|
||||
nativeName: "model",
|
||||
description: "Switch model",
|
||||
textAliases: ["/model"],
|
||||
acceptsArgs: true,
|
||||
argsParsing: "none" as CommandArgsParsing,
|
||||
scope: "native",
|
||||
};
|
||||
|
||||
vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData);
|
||||
vi.spyOn(commandRegistryModule, "findCommandByNativeName").mockImplementation((name) =>
|
||||
name === "model" ? modelCommand : undefined,
|
||||
);
|
||||
vi.spyOn(commandRegistryModule, "listChatCommands").mockReturnValue([modelCommand]);
|
||||
vi.spyOn(commandRegistryModule, "resolveCommandArgMenu").mockReturnValue(null);
|
||||
|
||||
const recordRecentSpy = vi
|
||||
.spyOn(modelPickerPreferencesModule, "recordDiscordModelPickerRecentModel")
|
||||
.mockResolvedValue();
|
||||
const dispatchSpy = vi
|
||||
.spyOn(dispatcherModule, "dispatchReplyWithDispatcher")
|
||||
.mockImplementation(() => new Promise(() => {}) as never);
|
||||
const withTimeoutSpy = vi
|
||||
.spyOn(timeoutModule, "withTimeout")
|
||||
.mockRejectedValue(new Error("timeout"));
|
||||
|
||||
const select = createDiscordModelPickerFallbackSelect(context);
|
||||
const selectInteraction = createInteraction({
|
||||
userId: "owner",
|
||||
values: ["gpt-4o"],
|
||||
});
|
||||
|
||||
const selectData: PickerSelectData = {
|
||||
cmd: "model",
|
||||
act: "model",
|
||||
view: "models",
|
||||
u: "owner",
|
||||
p: "openai",
|
||||
pg: "1",
|
||||
};
|
||||
|
||||
await select.run(selectInteraction as unknown as PickerSelectInteraction, selectData);
|
||||
|
||||
const button = createDiscordModelPickerFallbackButton(context);
|
||||
const submitInteraction = createInteraction({ userId: "owner" });
|
||||
const submitData: PickerButtonData = {
|
||||
cmd: "model",
|
||||
act: "submit",
|
||||
view: "models",
|
||||
u: "owner",
|
||||
p: "openai",
|
||||
pg: "1",
|
||||
mi: "2",
|
||||
};
|
||||
|
||||
await button.run(submitInteraction as unknown as PickerButtonInteraction, submitData);
|
||||
|
||||
expect(withTimeoutSpy).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(submitInteraction.followUp).toHaveBeenCalledTimes(1);
|
||||
const followUpPayload = submitInteraction.followUp.mock.calls[0]?.[0] as {
|
||||
components?: Array<{ components?: Array<{ content?: string }> }>;
|
||||
};
|
||||
const followUpText = JSON.stringify(followUpPayload);
|
||||
expect(followUpText).toContain("still processing");
|
||||
expect(recordRecentSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clicking Recents button renders recents view", async () => {
|
||||
const context = createModelPickerContext();
|
||||
const pickerData = createModelsProviderData({
|
||||
openai: ["gpt-4.1", "gpt-4o"],
|
||||
anthropic: ["claude-sonnet-4-5"],
|
||||
});
|
||||
|
||||
vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData);
|
||||
vi.spyOn(modelPickerPreferencesModule, "readDiscordModelPickerRecentModels").mockResolvedValue([
|
||||
"openai/gpt-4o",
|
||||
"anthropic/claude-sonnet-4-5",
|
||||
]);
|
||||
|
||||
const button = createDiscordModelPickerFallbackButton(context);
|
||||
const interaction = createInteraction({ userId: "owner" });
|
||||
|
||||
const data: PickerButtonData = {
|
||||
cmd: "model",
|
||||
act: "recents",
|
||||
view: "recents",
|
||||
u: "owner",
|
||||
p: "openai",
|
||||
pg: "1",
|
||||
};
|
||||
|
||||
await button.run(interaction as unknown as PickerButtonInteraction, data);
|
||||
|
||||
expect(interaction.update).toHaveBeenCalledTimes(1);
|
||||
const updatePayload = interaction.update.mock.calls[0]?.[0];
|
||||
expect(updatePayload).toBeDefined();
|
||||
expect(updatePayload.components).toBeDefined();
|
||||
});
|
||||
|
||||
it("clicking recents model button applies model through /model pipeline", async () => {
|
||||
const context = createModelPickerContext();
|
||||
const pickerData = createModelsProviderData({
|
||||
openai: ["gpt-4.1", "gpt-4o"],
|
||||
anthropic: ["claude-sonnet-4-5"],
|
||||
});
|
||||
const modelCommand: ChatCommandDefinition = {
|
||||
key: "model",
|
||||
nativeName: "model",
|
||||
description: "Switch model",
|
||||
textAliases: ["/model"],
|
||||
acceptsArgs: true,
|
||||
argsParsing: "none" as CommandArgsParsing,
|
||||
scope: "native",
|
||||
};
|
||||
|
||||
vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData);
|
||||
vi.spyOn(modelPickerPreferencesModule, "readDiscordModelPickerRecentModels").mockResolvedValue([
|
||||
"openai/gpt-4o",
|
||||
"anthropic/claude-sonnet-4-5",
|
||||
]);
|
||||
vi.spyOn(commandRegistryModule, "findCommandByNativeName").mockImplementation((name) =>
|
||||
name === "model" ? modelCommand : (undefined as never),
|
||||
);
|
||||
vi.spyOn(commandRegistryModule, "listChatCommands").mockReturnValue([modelCommand]);
|
||||
vi.spyOn(commandRegistryModule, "resolveCommandArgMenu").mockReturnValue(null);
|
||||
|
||||
const dispatchSpy = vi
|
||||
.spyOn(dispatcherModule, "dispatchReplyWithDispatcher")
|
||||
.mockResolvedValue({} as never);
|
||||
|
||||
const button = createDiscordModelPickerFallbackButton(context);
|
||||
const submitInteraction = createInteraction({ userId: "owner" });
|
||||
// rs=2 → first deduped recent (default is anthropic/claude-sonnet-4-5, so openai/gpt-4o remains)
|
||||
const submitData: PickerButtonData = {
|
||||
cmd: "model",
|
||||
act: "submit",
|
||||
view: "recents",
|
||||
u: "owner",
|
||||
pg: "1",
|
||||
rs: "2",
|
||||
};
|
||||
|
||||
await button.run(submitInteraction as unknown as PickerButtonInteraction, submitData);
|
||||
|
||||
expect(submitInteraction.update).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
|
||||
ctx?: {
|
||||
CommandBody?: string;
|
||||
CommandArgs?: { values?: { model?: string } };
|
||||
};
|
||||
};
|
||||
expect(dispatchCall.ctx?.CommandBody).toBe("/model openai/gpt-4o");
|
||||
expect(dispatchCall.ctx?.CommandArgs?.values?.model).toBe("openai/gpt-4o");
|
||||
});
|
||||
});
|
||||
@@ -2,12 +2,16 @@ import {
|
||||
Button,
|
||||
ChannelType,
|
||||
Command,
|
||||
Container,
|
||||
Row,
|
||||
StringSelectMenu,
|
||||
TextDisplay,
|
||||
type AutocompleteInteraction,
|
||||
type ButtonInteraction,
|
||||
type CommandInteraction,
|
||||
type CommandOptions,
|
||||
type ComponentData,
|
||||
type StringSelectMenuInteraction,
|
||||
} from "@buape/carbon";
|
||||
import { ApplicationCommandOptionType, ButtonStyle } from "discord-api-types/v10";
|
||||
import { resolveHumanDelayConfig } from "../../agents/identity.js";
|
||||
@@ -29,11 +33,14 @@ import {
|
||||
serializeCommandArgs,
|
||||
} from "../../auto-reply/commands-registry.js";
|
||||
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
|
||||
import { resolveStoredModelOverride } from "../../auto-reply/reply/model-selection.js";
|
||||
import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
|
||||
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
|
||||
import type { OpenClawConfig, loadConfig } from "../../config/config.js";
|
||||
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
|
||||
import { buildPairingReply } from "../../pairing/pairing-messages.js";
|
||||
import {
|
||||
@@ -43,6 +50,7 @@ import {
|
||||
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||
import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js";
|
||||
import { chunkItems } from "../../utils/chunk-items.js";
|
||||
import { withTimeout } from "../../utils/with-timeout.js";
|
||||
import { loadWebMedia } from "../../web/media.js";
|
||||
import { chunkDiscordTextWithMode } from "../chunk.js";
|
||||
import {
|
||||
@@ -56,6 +64,21 @@ import {
|
||||
resolveDiscordOwnerAllowFrom,
|
||||
} from "./allow-list.js";
|
||||
import { resolveDiscordChannelInfo } from "./message-utils.js";
|
||||
import {
|
||||
readDiscordModelPickerRecentModels,
|
||||
recordDiscordModelPickerRecentModel,
|
||||
type DiscordModelPickerPreferenceScope,
|
||||
} from "./model-picker-preferences.js";
|
||||
import {
|
||||
DISCORD_MODEL_PICKER_CUSTOM_ID_KEY,
|
||||
loadDiscordModelPickerData,
|
||||
parseDiscordModelPickerData,
|
||||
renderDiscordModelPickerModelsView,
|
||||
renderDiscordModelPickerProvidersView,
|
||||
renderDiscordModelPickerRecentsView,
|
||||
toDiscordModelPickerMessagePayload,
|
||||
type DiscordModelPickerCommandContext,
|
||||
} from "./model-picker.js";
|
||||
import { resolveDiscordSenderIdentity } from "./sender-identity.js";
|
||||
import { resolveDiscordThreadParentInfo } from "./threading.js";
|
||||
|
||||
@@ -196,7 +219,7 @@ async function safeDiscordInteractionCall<T>(
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
if (isDiscordUnknownInteraction(error)) {
|
||||
console.warn(`discord: ${label} skipped (interaction expired)`);
|
||||
logVerbose(`discord: ${label} skipped (interaction expired)`);
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
@@ -247,6 +270,634 @@ type DiscordCommandArgContext = {
|
||||
sessionPrefix: string;
|
||||
};
|
||||
|
||||
type DiscordModelPickerContext = DiscordCommandArgContext;
|
||||
|
||||
function resolveDiscordModelPickerCommandContext(
|
||||
command: ChatCommandDefinition,
|
||||
): DiscordModelPickerCommandContext | null {
|
||||
const normalized = (command.nativeName ?? command.key).trim().toLowerCase();
|
||||
if (normalized === "model" || normalized === "models") {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveCommandArgStringValue(args: CommandArgs | undefined, key: string): string {
|
||||
const value = args?.values?.[key];
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function shouldOpenDiscordModelPickerFromCommand(params: {
|
||||
command: ChatCommandDefinition;
|
||||
commandArgs?: CommandArgs;
|
||||
}): DiscordModelPickerCommandContext | null {
|
||||
const context = resolveDiscordModelPickerCommandContext(params.command);
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const serializedArgs = serializeCommandArgs(params.command, params.commandArgs)?.trim() ?? "";
|
||||
if (context === "model") {
|
||||
const modelValue = resolveCommandArgStringValue(params.commandArgs, "model");
|
||||
return !modelValue && !serializedArgs ? context : null;
|
||||
}
|
||||
|
||||
return serializedArgs ? null : context;
|
||||
}
|
||||
|
||||
function buildDiscordModelPickerCurrentModel(
|
||||
defaultProvider: string,
|
||||
defaultModel: string,
|
||||
): string {
|
||||
return `${defaultProvider}/${defaultModel}`;
|
||||
}
|
||||
|
||||
function buildDiscordModelPickerAllowedModelRefs(
|
||||
data: Awaited<ReturnType<typeof loadDiscordModelPickerData>>,
|
||||
): Set<string> {
|
||||
const out = new Set<string>();
|
||||
for (const provider of data.providers) {
|
||||
const models = data.byProvider.get(provider);
|
||||
if (!models) {
|
||||
continue;
|
||||
}
|
||||
for (const model of models) {
|
||||
out.add(`${provider}/${model}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolveDiscordModelPickerPreferenceScope(params: {
|
||||
interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction;
|
||||
accountId: string;
|
||||
userId: string;
|
||||
}): DiscordModelPickerPreferenceScope {
|
||||
return {
|
||||
accountId: params.accountId,
|
||||
guildId: params.interaction.guild?.id ?? undefined,
|
||||
userId: params.userId,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDiscordModelPickerNoticePayload(message: string): { components: Container[] } {
|
||||
return {
|
||||
components: [new Container([new TextDisplay(message)])],
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveDiscordModelPickerRoute(params: {
|
||||
interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction;
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
accountId: string;
|
||||
}) {
|
||||
const { interaction, cfg, accountId } = params;
|
||||
const channel = interaction.channel;
|
||||
const channelType = channel?.type;
|
||||
const isDirectMessage = channelType === ChannelType.DM;
|
||||
const isGroupDm = channelType === ChannelType.GroupDM;
|
||||
const isThreadChannel =
|
||||
channelType === ChannelType.PublicThread ||
|
||||
channelType === ChannelType.PrivateThread ||
|
||||
channelType === ChannelType.AnnouncementThread;
|
||||
const rawChannelId = channel?.id ?? "unknown";
|
||||
const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
|
||||
? interaction.rawData.member.roles.map((roleId: string) => String(roleId))
|
||||
: [];
|
||||
let threadParentId: string | undefined;
|
||||
if (interaction.guild && channel && isThreadChannel && rawChannelId) {
|
||||
const channelInfo = await resolveDiscordChannelInfo(interaction.client, rawChannelId);
|
||||
const parentInfo = await resolveDiscordThreadParentInfo({
|
||||
client: interaction.client,
|
||||
threadChannel: {
|
||||
id: rawChannelId,
|
||||
name: "name" in channel ? (channel.name as string | undefined) : undefined,
|
||||
parentId: "parentId" in channel ? (channel.parentId ?? undefined) : undefined,
|
||||
parent: undefined,
|
||||
},
|
||||
channelInfo,
|
||||
});
|
||||
threadParentId = parentInfo.id;
|
||||
}
|
||||
|
||||
return resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
accountId,
|
||||
guildId: interaction.guild?.id ?? undefined,
|
||||
memberRoleIds,
|
||||
peer: {
|
||||
kind: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel",
|
||||
id: isDirectMessage ? (interaction.user?.id ?? rawChannelId) : rawChannelId,
|
||||
},
|
||||
parentPeer: threadParentId ? { kind: "channel", id: threadParentId } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveDiscordModelPickerCurrentModel(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
route: ReturnType<typeof resolveAgentRoute>;
|
||||
data: Awaited<ReturnType<typeof loadDiscordModelPickerData>>;
|
||||
}): string {
|
||||
const fallback = buildDiscordModelPickerCurrentModel(
|
||||
params.data.resolvedDefault.provider,
|
||||
params.data.resolvedDefault.model,
|
||||
);
|
||||
try {
|
||||
const storePath = resolveStorePath(params.cfg.session?.store, {
|
||||
agentId: params.route.agentId,
|
||||
});
|
||||
const sessionStore = loadSessionStore(storePath, { skipCache: true });
|
||||
const sessionEntry = sessionStore[params.route.sessionKey];
|
||||
const override = resolveStoredModelOverride({
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: params.route.sessionKey,
|
||||
});
|
||||
if (!override?.model) {
|
||||
return fallback;
|
||||
}
|
||||
const provider = (override.provider || params.data.resolvedDefault.provider).trim();
|
||||
if (!provider) {
|
||||
return fallback;
|
||||
}
|
||||
return `${provider}/${override.model}`;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function replyWithDiscordModelPickerProviders(params: {
|
||||
interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction;
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
command: DiscordModelPickerCommandContext;
|
||||
userId: string;
|
||||
accountId: string;
|
||||
preferFollowUp: boolean;
|
||||
}) {
|
||||
const data = await loadDiscordModelPickerData(params.cfg);
|
||||
const route = await resolveDiscordModelPickerRoute({
|
||||
interaction: params.interaction,
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const currentModel = resolveDiscordModelPickerCurrentModel({
|
||||
cfg: params.cfg,
|
||||
route,
|
||||
data,
|
||||
});
|
||||
const quickModels = await readDiscordModelPickerRecentModels({
|
||||
scope: resolveDiscordModelPickerPreferenceScope({
|
||||
interaction: params.interaction,
|
||||
accountId: params.accountId,
|
||||
userId: params.userId,
|
||||
}),
|
||||
allowedModelRefs: buildDiscordModelPickerAllowedModelRefs(data),
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
const rendered = renderDiscordModelPickerModelsView({
|
||||
command: params.command,
|
||||
userId: params.userId,
|
||||
data,
|
||||
provider: splitDiscordModelRef(currentModel ?? "")?.provider ?? data.resolvedDefault.provider,
|
||||
page: 1,
|
||||
providerPage: 1,
|
||||
currentModel,
|
||||
quickModels,
|
||||
});
|
||||
const payload = {
|
||||
...toDiscordModelPickerMessagePayload(rendered),
|
||||
ephemeral: true,
|
||||
};
|
||||
|
||||
await safeDiscordInteractionCall("model picker reply", async () => {
|
||||
if (params.preferFollowUp) {
|
||||
await params.interaction.followUp(payload);
|
||||
return;
|
||||
}
|
||||
await params.interaction.reply(payload);
|
||||
});
|
||||
}
|
||||
|
||||
function resolveModelPickerSelectionValue(
|
||||
interaction: ButtonInteraction | StringSelectMenuInteraction,
|
||||
): string | null {
|
||||
const rawValues = (interaction as { values?: string[] }).values;
|
||||
if (!Array.isArray(rawValues) || rawValues.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const first = rawValues[0];
|
||||
if (typeof first !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = first.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function buildDiscordModelPickerSelectionCommand(params: {
|
||||
modelRef: string;
|
||||
}): { command: ChatCommandDefinition; args: CommandArgs; prompt: string } | null {
|
||||
const commandDefinition =
|
||||
findCommandByNativeName("model", "discord") ??
|
||||
listChatCommands().find((entry) => entry.key === "model");
|
||||
if (!commandDefinition) {
|
||||
return null;
|
||||
}
|
||||
const commandArgs: CommandArgs = {
|
||||
values: {
|
||||
model: params.modelRef,
|
||||
},
|
||||
raw: params.modelRef,
|
||||
};
|
||||
return {
|
||||
command: commandDefinition,
|
||||
args: commandArgs,
|
||||
prompt: buildCommandTextFromArgs(commandDefinition, commandArgs),
|
||||
};
|
||||
}
|
||||
|
||||
function listDiscordModelPickerProviderModels(
|
||||
data: Awaited<ReturnType<typeof loadDiscordModelPickerData>>,
|
||||
provider: string,
|
||||
): string[] {
|
||||
const modelSet = data.byProvider.get(provider);
|
||||
if (!modelSet) {
|
||||
return [];
|
||||
}
|
||||
return [...modelSet].toSorted();
|
||||
}
|
||||
|
||||
function resolveDiscordModelPickerModelIndex(params: {
|
||||
data: Awaited<ReturnType<typeof loadDiscordModelPickerData>>;
|
||||
provider: string;
|
||||
model: string;
|
||||
}): number | null {
|
||||
const models = listDiscordModelPickerProviderModels(params.data, params.provider);
|
||||
if (!models.length) {
|
||||
return null;
|
||||
}
|
||||
const index = models.indexOf(params.model);
|
||||
if (index < 0) {
|
||||
return null;
|
||||
}
|
||||
return index + 1;
|
||||
}
|
||||
|
||||
function resolveDiscordModelPickerModelByIndex(params: {
|
||||
data: Awaited<ReturnType<typeof loadDiscordModelPickerData>>;
|
||||
provider: string;
|
||||
modelIndex?: number;
|
||||
}): string | null {
|
||||
if (!params.modelIndex || params.modelIndex < 1) {
|
||||
return null;
|
||||
}
|
||||
const models = listDiscordModelPickerProviderModels(params.data, params.provider);
|
||||
if (!models.length) {
|
||||
return null;
|
||||
}
|
||||
return models[params.modelIndex - 1] ?? null;
|
||||
}
|
||||
|
||||
function splitDiscordModelRef(modelRef: string): { provider: string; model: string } | null {
|
||||
const trimmed = modelRef.trim();
|
||||
const slashIndex = trimmed.indexOf("/");
|
||||
if (slashIndex <= 0 || slashIndex >= trimmed.length - 1) {
|
||||
return null;
|
||||
}
|
||||
const provider = trimmed.slice(0, slashIndex).trim();
|
||||
const model = trimmed.slice(slashIndex + 1).trim();
|
||||
if (!provider || !model) {
|
||||
return null;
|
||||
}
|
||||
return { provider, model };
|
||||
}
|
||||
|
||||
async function handleDiscordModelPickerInteraction(
|
||||
interaction: ButtonInteraction | StringSelectMenuInteraction,
|
||||
data: ComponentData,
|
||||
ctx: DiscordModelPickerContext,
|
||||
) {
|
||||
const parsed = parseDiscordModelPickerData(data);
|
||||
if (!parsed) {
|
||||
await safeDiscordInteractionCall("model picker update", () =>
|
||||
interaction.update(
|
||||
buildDiscordModelPickerNoticePayload(
|
||||
"Sorry, that model picker interaction is no longer available.",
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.user?.id && interaction.user.id !== parsed.userId) {
|
||||
await safeDiscordInteractionCall("model picker ack", () => interaction.acknowledge());
|
||||
return;
|
||||
}
|
||||
|
||||
const pickerData = await loadDiscordModelPickerData(ctx.cfg);
|
||||
const route = await resolveDiscordModelPickerRoute({
|
||||
interaction,
|
||||
cfg: ctx.cfg,
|
||||
accountId: ctx.accountId,
|
||||
});
|
||||
const currentModelRef = resolveDiscordModelPickerCurrentModel({
|
||||
cfg: ctx.cfg,
|
||||
route,
|
||||
data: pickerData,
|
||||
});
|
||||
const allowedModelRefs = buildDiscordModelPickerAllowedModelRefs(pickerData);
|
||||
const preferenceScope = resolveDiscordModelPickerPreferenceScope({
|
||||
interaction,
|
||||
accountId: ctx.accountId,
|
||||
userId: parsed.userId,
|
||||
});
|
||||
const quickModels = await readDiscordModelPickerRecentModels({
|
||||
scope: preferenceScope,
|
||||
allowedModelRefs,
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
if (parsed.action === "recents") {
|
||||
const rendered = renderDiscordModelPickerRecentsView({
|
||||
command: parsed.command,
|
||||
userId: parsed.userId,
|
||||
data: pickerData,
|
||||
quickModels,
|
||||
currentModel: currentModelRef,
|
||||
provider: parsed.provider,
|
||||
page: parsed.page,
|
||||
providerPage: parsed.providerPage,
|
||||
});
|
||||
|
||||
await safeDiscordInteractionCall("model picker update", () =>
|
||||
interaction.update(toDiscordModelPickerMessagePayload(rendered)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.action === "back" && parsed.view === "providers") {
|
||||
const rendered = renderDiscordModelPickerProvidersView({
|
||||
command: parsed.command,
|
||||
userId: parsed.userId,
|
||||
data: pickerData,
|
||||
page: parsed.page,
|
||||
currentModel: currentModelRef,
|
||||
});
|
||||
|
||||
await safeDiscordInteractionCall("model picker update", () =>
|
||||
interaction.update(toDiscordModelPickerMessagePayload(rendered)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.action === "back" && parsed.view === "models") {
|
||||
const provider =
|
||||
parsed.provider ??
|
||||
splitDiscordModelRef(currentModelRef ?? "")?.provider ??
|
||||
pickerData.resolvedDefault.provider;
|
||||
|
||||
const rendered = renderDiscordModelPickerModelsView({
|
||||
command: parsed.command,
|
||||
userId: parsed.userId,
|
||||
data: pickerData,
|
||||
provider,
|
||||
page: parsed.page ?? 1,
|
||||
providerPage: parsed.providerPage ?? 1,
|
||||
currentModel: currentModelRef,
|
||||
quickModels,
|
||||
});
|
||||
|
||||
await safeDiscordInteractionCall("model picker update", () =>
|
||||
interaction.update(toDiscordModelPickerMessagePayload(rendered)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.action === "provider") {
|
||||
const selectedProvider = resolveModelPickerSelectionValue(interaction) ?? parsed.provider;
|
||||
if (!selectedProvider || !pickerData.byProvider.has(selectedProvider)) {
|
||||
await safeDiscordInteractionCall("model picker update", () =>
|
||||
interaction.update(
|
||||
buildDiscordModelPickerNoticePayload("Sorry, that provider isn't available anymore."),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const rendered = renderDiscordModelPickerModelsView({
|
||||
command: parsed.command,
|
||||
userId: parsed.userId,
|
||||
data: pickerData,
|
||||
provider: selectedProvider,
|
||||
page: 1,
|
||||
providerPage: parsed.providerPage ?? parsed.page,
|
||||
currentModel: currentModelRef,
|
||||
quickModels,
|
||||
});
|
||||
|
||||
await safeDiscordInteractionCall("model picker update", () =>
|
||||
interaction.update(toDiscordModelPickerMessagePayload(rendered)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.action === "model") {
|
||||
const selectedModel = resolveModelPickerSelectionValue(interaction);
|
||||
const provider = parsed.provider;
|
||||
if (!provider || !selectedModel) {
|
||||
await safeDiscordInteractionCall("model picker update", () =>
|
||||
interaction.update(
|
||||
buildDiscordModelPickerNoticePayload("Sorry, I couldn't read that model selection."),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const modelIndex = resolveDiscordModelPickerModelIndex({
|
||||
data: pickerData,
|
||||
provider,
|
||||
model: selectedModel,
|
||||
});
|
||||
if (!modelIndex) {
|
||||
await safeDiscordInteractionCall("model picker update", () =>
|
||||
interaction.update(
|
||||
buildDiscordModelPickerNoticePayload("Sorry, that model isn't available anymore."),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const modelRef = `${provider}/${selectedModel}`;
|
||||
const rendered = renderDiscordModelPickerModelsView({
|
||||
command: parsed.command,
|
||||
userId: parsed.userId,
|
||||
data: pickerData,
|
||||
provider,
|
||||
page: parsed.page,
|
||||
providerPage: parsed.providerPage ?? 1,
|
||||
currentModel: currentModelRef,
|
||||
pendingModel: modelRef,
|
||||
pendingModelIndex: modelIndex,
|
||||
quickModels,
|
||||
});
|
||||
|
||||
await safeDiscordInteractionCall("model picker update", () =>
|
||||
interaction.update(toDiscordModelPickerMessagePayload(rendered)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.action === "submit" || parsed.action === "reset" || parsed.action === "quick") {
|
||||
let modelRef: string | null = null;
|
||||
|
||||
if (parsed.action === "reset") {
|
||||
modelRef = `${pickerData.resolvedDefault.provider}/${pickerData.resolvedDefault.model}`;
|
||||
} else if (parsed.action === "quick") {
|
||||
const slot = parsed.recentSlot ?? 0;
|
||||
modelRef = slot >= 1 ? (quickModels[slot - 1] ?? null) : null;
|
||||
} else if (parsed.view === "recents") {
|
||||
const defaultModelRef = `${pickerData.resolvedDefault.provider}/${pickerData.resolvedDefault.model}`;
|
||||
const dedupedRecents = quickModels.filter((ref) => ref !== defaultModelRef);
|
||||
const slot = parsed.recentSlot ?? 0;
|
||||
if (slot === 1) {
|
||||
modelRef = defaultModelRef;
|
||||
} else if (slot >= 2) {
|
||||
modelRef = dedupedRecents[slot - 2] ?? null;
|
||||
}
|
||||
} else {
|
||||
const provider = parsed.provider;
|
||||
const selectedModel = resolveDiscordModelPickerModelByIndex({
|
||||
data: pickerData,
|
||||
provider: provider ?? "",
|
||||
modelIndex: parsed.modelIndex,
|
||||
});
|
||||
modelRef = provider && selectedModel ? `${provider}/${selectedModel}` : null;
|
||||
}
|
||||
const parsedModelRef = modelRef ? splitDiscordModelRef(modelRef) : null;
|
||||
if (
|
||||
!parsedModelRef ||
|
||||
!pickerData.byProvider.get(parsedModelRef.provider)?.has(parsedModelRef.model)
|
||||
) {
|
||||
await safeDiscordInteractionCall("model picker update", () =>
|
||||
interaction.update(
|
||||
buildDiscordModelPickerNoticePayload(
|
||||
"That selection expired. Please choose a model again.",
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedModelRef = `${parsedModelRef.provider}/${parsedModelRef.model}`;
|
||||
|
||||
const selectionCommand = buildDiscordModelPickerSelectionCommand({
|
||||
modelRef: resolvedModelRef,
|
||||
});
|
||||
if (!selectionCommand) {
|
||||
await safeDiscordInteractionCall("model picker update", () =>
|
||||
interaction.update(
|
||||
buildDiscordModelPickerNoticePayload("Sorry, /model is unavailable right now."),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateResult = await safeDiscordInteractionCall("model picker update", () =>
|
||||
interaction.update(
|
||||
buildDiscordModelPickerNoticePayload(`Applying model change to ${resolvedModelRef}...`),
|
||||
),
|
||||
);
|
||||
if (updateResult === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await withTimeout(
|
||||
dispatchDiscordCommandInteraction({
|
||||
interaction,
|
||||
prompt: selectionCommand.prompt,
|
||||
command: selectionCommand.command,
|
||||
commandArgs: selectionCommand.args,
|
||||
cfg: ctx.cfg,
|
||||
discordConfig: ctx.discordConfig,
|
||||
accountId: ctx.accountId,
|
||||
sessionPrefix: ctx.sessionPrefix,
|
||||
preferFollowUp: true,
|
||||
suppressReplies: true,
|
||||
}),
|
||||
12000,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "timeout") {
|
||||
await safeDiscordInteractionCall("model picker follow-up", () =>
|
||||
interaction.followUp({
|
||||
...buildDiscordModelPickerNoticePayload(
|
||||
`⏳ Model change to ${resolvedModelRef} is still processing. Check /status in a few seconds.`,
|
||||
),
|
||||
ephemeral: true,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await safeDiscordInteractionCall("model picker follow-up", () =>
|
||||
interaction.followUp({
|
||||
...buildDiscordModelPickerNoticePayload(
|
||||
`❌ Failed to apply ${resolvedModelRef}. Try /model ${resolvedModelRef} directly.`,
|
||||
),
|
||||
ephemeral: true,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveModelRef = resolveDiscordModelPickerCurrentModel({
|
||||
cfg: ctx.cfg,
|
||||
route,
|
||||
data: pickerData,
|
||||
});
|
||||
const persisted = effectiveModelRef === resolvedModelRef;
|
||||
|
||||
if (!persisted) {
|
||||
logVerbose(
|
||||
`discord: model picker override mismatch — expected ${resolvedModelRef} but read ${effectiveModelRef} from session key ${route.sessionKey}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (persisted) {
|
||||
await recordDiscordModelPickerRecentModel({
|
||||
scope: preferenceScope,
|
||||
modelRef: resolvedModelRef,
|
||||
limit: 5,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
await safeDiscordInteractionCall("model picker follow-up", () =>
|
||||
interaction.followUp({
|
||||
...buildDiscordModelPickerNoticePayload(
|
||||
persisted
|
||||
? `✅ Model set to ${resolvedModelRef}.`
|
||||
: `⚠️ Tried to set ${resolvedModelRef}, but current model is ${effectiveModelRef}.`,
|
||||
),
|
||||
ephemeral: true,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.action === "cancel") {
|
||||
const displayModel = currentModelRef ?? "default";
|
||||
await safeDiscordInteractionCall("model picker update", () =>
|
||||
interaction.update(buildDiscordModelPickerNoticePayload(`ℹ️ Model kept as ${displayModel}.`)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDiscordCommandArgInteraction(
|
||||
interaction: ButtonInteraction,
|
||||
data: ComponentData,
|
||||
@@ -278,13 +929,13 @@ async function handleDiscordCommandArgInteraction(
|
||||
);
|
||||
return;
|
||||
}
|
||||
const updated = await safeDiscordInteractionCall("command arg update", () =>
|
||||
const argUpdateResult = await safeDiscordInteractionCall("command arg update", () =>
|
||||
interaction.update({
|
||||
content: `✅ Selected ${parsed.value}.`,
|
||||
components: [],
|
||||
}),
|
||||
);
|
||||
if (!updated) {
|
||||
if (argUpdateResult === null) {
|
||||
return;
|
||||
}
|
||||
const commandArgs = createCommandArgsWithValue({
|
||||
@@ -364,6 +1015,46 @@ export function createDiscordCommandArgFallbackButton(params: DiscordCommandArgC
|
||||
return new DiscordCommandArgFallbackButton(params);
|
||||
}
|
||||
|
||||
class DiscordModelPickerFallbackButton extends Button {
|
||||
label = DISCORD_MODEL_PICKER_CUSTOM_ID_KEY;
|
||||
customId = `${DISCORD_MODEL_PICKER_CUSTOM_ID_KEY}:seed=btn`;
|
||||
private ctx: DiscordModelPickerContext;
|
||||
|
||||
constructor(ctx: DiscordModelPickerContext) {
|
||||
super();
|
||||
this.ctx = ctx;
|
||||
}
|
||||
|
||||
async run(interaction: ButtonInteraction, data: ComponentData) {
|
||||
await handleDiscordModelPickerInteraction(interaction, data, this.ctx);
|
||||
}
|
||||
}
|
||||
|
||||
class DiscordModelPickerFallbackSelect extends StringSelectMenu {
|
||||
customId = `${DISCORD_MODEL_PICKER_CUSTOM_ID_KEY}:seed=sel`;
|
||||
options = [];
|
||||
private ctx: DiscordModelPickerContext;
|
||||
|
||||
constructor(ctx: DiscordModelPickerContext) {
|
||||
super();
|
||||
this.ctx = ctx;
|
||||
}
|
||||
|
||||
async run(interaction: StringSelectMenuInteraction, data: ComponentData) {
|
||||
await handleDiscordModelPickerInteraction(interaction, data, this.ctx);
|
||||
}
|
||||
}
|
||||
|
||||
export function createDiscordModelPickerFallbackButton(params: DiscordModelPickerContext): Button {
|
||||
return new DiscordModelPickerFallbackButton(params);
|
||||
}
|
||||
|
||||
export function createDiscordModelPickerFallbackSelect(
|
||||
params: DiscordModelPickerContext,
|
||||
): StringSelectMenu {
|
||||
return new DiscordModelPickerFallbackSelect(params);
|
||||
}
|
||||
|
||||
function buildDiscordCommandArgMenu(params: {
|
||||
command: ChatCommandDefinition;
|
||||
menu: {
|
||||
@@ -479,7 +1170,7 @@ export function createDiscordNativeCommand(params: {
|
||||
}
|
||||
|
||||
async function dispatchDiscordCommandInteraction(params: {
|
||||
interaction: CommandInteraction | ButtonInteraction;
|
||||
interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction;
|
||||
prompt: string;
|
||||
command: ChatCommandDefinition;
|
||||
commandArgs?: CommandArgs;
|
||||
@@ -488,6 +1179,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
accountId: string;
|
||||
sessionPrefix: string;
|
||||
preferFollowUp: boolean;
|
||||
suppressReplies?: boolean;
|
||||
}) {
|
||||
const {
|
||||
interaction,
|
||||
@@ -499,6 +1191,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
accountId,
|
||||
sessionPrefix,
|
||||
preferFollowUp,
|
||||
suppressReplies,
|
||||
} = params;
|
||||
const respond = async (content: string, options?: { ephemeral?: boolean }) => {
|
||||
const payload = {
|
||||
@@ -719,6 +1412,22 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
const pickerCommandContext = shouldOpenDiscordModelPickerFromCommand({
|
||||
command,
|
||||
commandArgs,
|
||||
});
|
||||
if (pickerCommandContext) {
|
||||
await replyWithDiscordModelPickerProviders({
|
||||
interaction,
|
||||
cfg,
|
||||
command: pickerCommandContext,
|
||||
userId: user.id,
|
||||
accountId,
|
||||
preferFollowUp,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const isGuild = Boolean(interaction.guild);
|
||||
const channelId = rawChannelId || "unknown";
|
||||
const interactionId = interaction.rawData.id;
|
||||
@@ -813,6 +1522,9 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
...prefixOptions,
|
||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
||||
deliver: async (payload) => {
|
||||
if (suppressReplies) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deliverDiscordInteractionReply({
|
||||
interaction,
|
||||
@@ -827,7 +1539,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
});
|
||||
} catch (error) {
|
||||
if (isDiscordUnknownInteraction(error)) {
|
||||
console.warn("discord: interaction reply skipped (interaction expired)");
|
||||
logVerbose("discord: interaction reply skipped (interaction expired)");
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
@@ -850,7 +1562,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
}
|
||||
|
||||
async function deliverDiscordInteractionReply(params: {
|
||||
interaction: CommandInteraction | ButtonInteraction;
|
||||
interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction;
|
||||
payload: ReplyPayload;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
textLimit: number;
|
||||
|
||||
@@ -67,6 +67,8 @@ import {
|
||||
import { createDiscordMessageHandler } from "./message-handler.js";
|
||||
import {
|
||||
createDiscordCommandArgFallbackButton,
|
||||
createDiscordModelPickerFallbackButton,
|
||||
createDiscordModelPickerFallbackSelect,
|
||||
createDiscordNativeCommand,
|
||||
} from "./native-command.js";
|
||||
import { resolveDiscordPresenceUpdate } from "./presence.js";
|
||||
@@ -481,6 +483,18 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
accountId: account.accountId,
|
||||
sessionPrefix,
|
||||
}),
|
||||
createDiscordModelPickerFallbackButton({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
sessionPrefix,
|
||||
}),
|
||||
createDiscordModelPickerFallbackSelect({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
sessionPrefix,
|
||||
}),
|
||||
];
|
||||
const modals: Modal[] = [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user