mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-11 09:51:41 +00:00
fix: restore Discord model picker UX (#21458) (thanks @pejmanjohn)
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user