Merge 0cd7454392 into 28f8d00e9f
This commit is contained in:
commit
86bce233c3
@ -511,6 +511,20 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
defineChatCommand({
|
||||||
|
key: "model_channel",
|
||||||
|
nativeName: "model_channel",
|
||||||
|
description: "Set or clear the default model for this channel.",
|
||||||
|
textAlias: "/model-channel",
|
||||||
|
category: "options",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
name: "model",
|
||||||
|
description: "Model id (provider/model or id), or 'clear' to remove override",
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "models",
|
key: "models",
|
||||||
nativeName: "models",
|
nativeName: "models",
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import {
|
|||||||
import { handleAllowlistCommand } from "./commands-allowlist.js";
|
import { handleAllowlistCommand } from "./commands-allowlist.js";
|
||||||
import { handleApproveCommand } from "./commands-approve.js";
|
import { handleApproveCommand } from "./commands-approve.js";
|
||||||
import { handleSubagentsCommand } from "./commands-subagents.js";
|
import { handleSubagentsCommand } from "./commands-subagents.js";
|
||||||
import { handleModelsCommand } from "./commands-models.js";
|
import { handleModelsCommand, handleModelChannelCommand } from "./commands-models.js";
|
||||||
import { handleTtsCommands } from "./commands-tts.js";
|
import { handleTtsCommands } from "./commands-tts.js";
|
||||||
import {
|
import {
|
||||||
handleAbortTrigger,
|
handleAbortTrigger,
|
||||||
@ -53,6 +53,7 @@ const HANDLERS: CommandHandler[] = [
|
|||||||
handleConfigCommand,
|
handleConfigCommand,
|
||||||
handleDebugCommand,
|
handleDebugCommand,
|
||||||
handleModelsCommand,
|
handleModelsCommand,
|
||||||
|
handleModelChannelCommand,
|
||||||
handleStopCommand,
|
handleStopCommand,
|
||||||
handleCompactCommand,
|
handleCompactCommand,
|
||||||
handleAbortTrigger,
|
handleAbortTrigger,
|
||||||
|
|||||||
149
src/auto-reply/reply/commands-model-channel.ts
Normal file
149
src/auto-reply/reply/commands-model-channel.ts
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import { loadConfig, writeConfigFile } from "../../config/config.js";
|
||||||
|
import { resolveChannelConfigWrites } from "../../channels/plugins/config-writes.js";
|
||||||
|
import { resolveModelRefFromString, buildModelAliasIndex } from "../../agents/model-selection.js";
|
||||||
|
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js";
|
||||||
|
import type { MoltbotConfig } from "../../config/config.js";
|
||||||
|
import type { ReplyPayload } from "../types.js";
|
||||||
|
import type { CommandHandler } from "./commands-types.js";
|
||||||
|
|
||||||
|
export type ModelChannelCommandContext = {
|
||||||
|
cfg: MoltbotConfig;
|
||||||
|
commandBodyNormalized: string;
|
||||||
|
provider?: string;
|
||||||
|
surface?: string;
|
||||||
|
accountId?: string;
|
||||||
|
groupSpace?: string;
|
||||||
|
groupChannel?: string;
|
||||||
|
channelId?: string;
|
||||||
|
commandAuthorized?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function extractChannelId(ctx: ModelChannelCommandContext): string | null {
|
||||||
|
// Try to extract channel ID from groupChannel (e.g., "#channel-name" -> id not available)
|
||||||
|
// or from channelId if passed directly
|
||||||
|
if (ctx.channelId) return ctx.channelId;
|
||||||
|
|
||||||
|
// For Discord, the channel ID is typically in the "To" field as "channel:<id>"
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveModelChannelCommandReply(
|
||||||
|
ctx: ModelChannelCommandContext,
|
||||||
|
): Promise<ReplyPayload | null> {
|
||||||
|
const body = ctx.commandBodyNormalized.trim();
|
||||||
|
if (!body.startsWith("/model-channel") && !body.startsWith("/model_channel")) return null;
|
||||||
|
|
||||||
|
const surface = ctx.surface?.toLowerCase() ?? ctx.provider?.toLowerCase();
|
||||||
|
|
||||||
|
// Only supported for Discord guild channels
|
||||||
|
if (surface !== "discord") {
|
||||||
|
return {
|
||||||
|
text: "The /model-channel command is only available in Discord servers.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctx.groupSpace) {
|
||||||
|
return {
|
||||||
|
text: "The /model-channel command can only be used in server channels, not DMs.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if config writes are enabled
|
||||||
|
if (
|
||||||
|
!resolveChannelConfigWrites({ cfg: ctx.cfg, channelId: "discord", accountId: ctx.accountId })
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
text: "Config writes are disabled for this account. Enable `configWrites` in your Discord config to use this command.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check authorization
|
||||||
|
if (ctx.commandAuthorized === false) {
|
||||||
|
return {
|
||||||
|
text: "You are not authorized to use this command.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const argText = body.replace(/^\/model[-_]channel\b/i, "").trim();
|
||||||
|
const channelId = ctx.channelId;
|
||||||
|
const guildId = ctx.groupSpace;
|
||||||
|
|
||||||
|
if (!channelId) {
|
||||||
|
return {
|
||||||
|
text: "Could not determine the current channel. Please try again or specify the channel in your config directly.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle clear/reset
|
||||||
|
if (!argText || argText.toLowerCase() === "clear" || argText.toLowerCase() === "reset") {
|
||||||
|
const currentConfig = loadConfig();
|
||||||
|
const guildEntry = currentConfig.channels?.discord?.guilds?.[guildId];
|
||||||
|
const channelEntry = guildEntry?.channels?.[channelId];
|
||||||
|
|
||||||
|
if (!channelEntry?.model) {
|
||||||
|
return {
|
||||||
|
text: `No model override is set for this channel.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the model override
|
||||||
|
delete channelEntry.model;
|
||||||
|
|
||||||
|
// Clean up empty objects
|
||||||
|
if (Object.keys(channelEntry).length === 0 && guildEntry?.channels) {
|
||||||
|
delete guildEntry.channels[channelId];
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeConfigFile(currentConfig);
|
||||||
|
return {
|
||||||
|
text: `Cleared model override for this channel. Messages will now use the guild or agent default model.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the model reference
|
||||||
|
const aliasIndex = buildModelAliasIndex({
|
||||||
|
cfg: ctx.cfg,
|
||||||
|
defaultProvider: DEFAULT_PROVIDER,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = resolveModelRefFromString({
|
||||||
|
raw: argText,
|
||||||
|
defaultProvider: DEFAULT_PROVIDER,
|
||||||
|
aliasIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resolved) {
|
||||||
|
return {
|
||||||
|
text: `Unknown model "${argText}". Use /models to see available models.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelId = `${resolved.ref.provider}/${resolved.ref.model}`;
|
||||||
|
|
||||||
|
// Update the config
|
||||||
|
const currentConfig = loadConfig();
|
||||||
|
|
||||||
|
// Ensure the path exists
|
||||||
|
if (!currentConfig.channels) currentConfig.channels = {};
|
||||||
|
if (!currentConfig.channels.discord) currentConfig.channels.discord = {};
|
||||||
|
if (!currentConfig.channels.discord.guilds) currentConfig.channels.discord.guilds = {};
|
||||||
|
if (!currentConfig.channels.discord.guilds[guildId]) {
|
||||||
|
currentConfig.channels.discord.guilds[guildId] = {};
|
||||||
|
}
|
||||||
|
if (!currentConfig.channels.discord.guilds[guildId].channels) {
|
||||||
|
currentConfig.channels.discord.guilds[guildId].channels = {};
|
||||||
|
}
|
||||||
|
if (!currentConfig.channels.discord.guilds[guildId].channels[channelId]) {
|
||||||
|
currentConfig.channels.discord.guilds[guildId].channels[channelId] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the model
|
||||||
|
currentConfig.channels.discord.guilds[guildId].channels[channelId].model = modelId;
|
||||||
|
|
||||||
|
await writeConfigFile(currentConfig);
|
||||||
|
|
||||||
|
const displayModel = resolved.alias ? `${resolved.alias} (${modelId})` : modelId;
|
||||||
|
return {
|
||||||
|
text: `Set default model for this channel to **${displayModel}**. Use \`/model-channel clear\` to remove this override.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -241,3 +241,35 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
|
|||||||
if (!reply) return null;
|
if (!reply) return null;
|
||||||
return { reply, shouldContinue: false };
|
return { reply, shouldContinue: false };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const handleModelChannelCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||||
|
if (!allowTextCommands) return null;
|
||||||
|
|
||||||
|
const { resolveModelChannelCommandReply } = await import("./commands-model-channel.js");
|
||||||
|
const reply = await resolveModelChannelCommandReply({
|
||||||
|
cfg: params.cfg,
|
||||||
|
commandBodyNormalized: params.command.commandBodyNormalized,
|
||||||
|
provider: params.ctx.Provider,
|
||||||
|
surface: params.ctx.Surface,
|
||||||
|
accountId: params.ctx.AccountId,
|
||||||
|
groupSpace: params.ctx.GroupSpace,
|
||||||
|
groupChannel: params.ctx.GroupChannel,
|
||||||
|
channelId: extractChannelIdFromTo(params.ctx.To),
|
||||||
|
commandAuthorized: params.command.isAuthorizedSender,
|
||||||
|
});
|
||||||
|
if (!reply) return null;
|
||||||
|
return { reply, shouldContinue: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
function extractChannelIdFromTo(to?: string): string | undefined {
|
||||||
|
if (!to) return undefined;
|
||||||
|
// Handle "channel:<id>" format
|
||||||
|
if (to.startsWith("channel:")) {
|
||||||
|
return to.slice("channel:".length);
|
||||||
|
}
|
||||||
|
// Handle "discord:channel:<id>" format
|
||||||
|
if (to.startsWith("discord:channel:")) {
|
||||||
|
return to.slice("discord:channel:".length);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|||||||
@ -106,6 +106,8 @@ export async function resolveReplyDirectives(params: {
|
|||||||
typing: TypingController;
|
typing: TypingController;
|
||||||
opts?: GetReplyOptions;
|
opts?: GetReplyOptions;
|
||||||
skillFilter?: string[];
|
skillFilter?: string[];
|
||||||
|
/** Channel-level model override (fallback when no session override). */
|
||||||
|
channelModelOverride?: string;
|
||||||
}): Promise<ReplyDirectiveResult> {
|
}): Promise<ReplyDirectiveResult> {
|
||||||
const {
|
const {
|
||||||
ctx,
|
ctx,
|
||||||
@ -386,6 +388,7 @@ export async function resolveReplyDirectives(params: {
|
|||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
hasModelDirective: directives.hasModelDirective,
|
hasModelDirective: directives.hasModelDirective,
|
||||||
|
channelModelOverride: params.channelModelOverride ?? opts?.channelModelOverride,
|
||||||
});
|
});
|
||||||
provider = modelState.provider;
|
provider = modelState.provider;
|
||||||
model = modelState.model;
|
model = modelState.model;
|
||||||
|
|||||||
@ -166,6 +166,7 @@ export async function getReplyFromConfig(
|
|||||||
typing,
|
typing,
|
||||||
opts,
|
opts,
|
||||||
skillFilter: opts?.skillFilter,
|
skillFilter: opts?.skillFilter,
|
||||||
|
channelModelOverride: opts?.channelModelOverride,
|
||||||
});
|
});
|
||||||
if (directiveResult.kind === "reply") {
|
if (directiveResult.kind === "reply") {
|
||||||
return directiveResult.reply;
|
return directiveResult.reply;
|
||||||
|
|||||||
@ -231,6 +231,8 @@ export async function createModelSelectionState(params: {
|
|||||||
provider: string;
|
provider: string;
|
||||||
model: string;
|
model: string;
|
||||||
hasModelDirective: boolean;
|
hasModelDirective: boolean;
|
||||||
|
/** Channel-level model override (used as fallback when no session override). */
|
||||||
|
channelModelOverride?: string;
|
||||||
}): Promise<ModelSelectionState> {
|
}): Promise<ModelSelectionState> {
|
||||||
const {
|
const {
|
||||||
cfg,
|
cfg,
|
||||||
@ -242,6 +244,7 @@ export async function createModelSelectionState(params: {
|
|||||||
storePath,
|
storePath,
|
||||||
defaultProvider,
|
defaultProvider,
|
||||||
defaultModel,
|
defaultModel,
|
||||||
|
channelModelOverride,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
let provider = params.provider;
|
let provider = params.provider;
|
||||||
@ -310,6 +313,20 @@ export async function createModelSelectionState(params: {
|
|||||||
provider = candidateProvider;
|
provider = candidateProvider;
|
||||||
model = storedOverride.model;
|
model = storedOverride.model;
|
||||||
}
|
}
|
||||||
|
} else if (channelModelOverride?.trim()) {
|
||||||
|
// Apply channel-level model override as fallback when no session override exists.
|
||||||
|
const channelRef = resolveModelRefFromString({
|
||||||
|
raw: channelModelOverride.trim(),
|
||||||
|
defaultProvider,
|
||||||
|
aliasIndex: { byAlias: new Map(), byKey: new Map() },
|
||||||
|
});
|
||||||
|
if (channelRef) {
|
||||||
|
const key = modelKey(channelRef.ref.provider, channelRef.ref.model);
|
||||||
|
if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) {
|
||||||
|
provider = channelRef.ref.provider;
|
||||||
|
model = channelRef.ref.model;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sessionEntry && sessionStore && sessionKey && sessionEntry.authProfileOverride) {
|
if (sessionEntry && sessionStore && sessionKey && sessionEntry.authProfileOverride) {
|
||||||
|
|||||||
@ -39,6 +39,8 @@ export type GetReplyOptions = {
|
|||||||
skillFilter?: string[];
|
skillFilter?: string[];
|
||||||
/** Mutable ref to track if a reply was sent (for Slack "first" threading mode). */
|
/** Mutable ref to track if a reply was sent (for Slack "first" threading mode). */
|
||||||
hasRepliedRef?: { value: boolean };
|
hasRepliedRef?: { value: boolean };
|
||||||
|
/** Channel-level model override (fallback when no session override). */
|
||||||
|
channelModelOverride?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ReplyPayload = {
|
export type ReplyPayload = {
|
||||||
|
|||||||
@ -37,6 +37,8 @@ export type DiscordGuildChannelConfig = {
|
|||||||
users?: Array<string | number>;
|
users?: Array<string | number>;
|
||||||
/** Optional system prompt snippet for this channel. */
|
/** Optional system prompt snippet for this channel. */
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
|
/** Optional model override for this channel. */
|
||||||
|
model?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DiscordReactionNotificationMode = "off" | "own" | "all" | "allowlist";
|
export type DiscordReactionNotificationMode = "off" | "own" | "all" | "allowlist";
|
||||||
@ -51,6 +53,8 @@ export type DiscordGuildEntry = {
|
|||||||
reactionNotifications?: DiscordReactionNotificationMode;
|
reactionNotifications?: DiscordReactionNotificationMode;
|
||||||
users?: Array<string | number>;
|
users?: Array<string | number>;
|
||||||
channels?: Record<string, DiscordGuildChannelConfig>;
|
channels?: Record<string, DiscordGuildChannelConfig>;
|
||||||
|
/** Optional model override for this guild. */
|
||||||
|
model?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DiscordActionConfig = {
|
export type DiscordActionConfig = {
|
||||||
|
|||||||
@ -196,6 +196,7 @@ export const DiscordGuildChannelSchema = z
|
|||||||
users: z.array(z.union([z.string(), z.number()])).optional(),
|
users: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
systemPrompt: z.string().optional(),
|
systemPrompt: z.string().optional(),
|
||||||
autoThread: z.boolean().optional(),
|
autoThread: z.boolean().optional(),
|
||||||
|
model: z.string().optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@ -208,6 +209,7 @@ export const DiscordGuildSchema = z
|
|||||||
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
||||||
users: z.array(z.union([z.string(), z.number()])).optional(),
|
users: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
channels: z.record(z.string(), DiscordGuildChannelSchema.optional()).optional(),
|
channels: z.record(z.string(), DiscordGuildChannelSchema.optional()).optional(),
|
||||||
|
model: z.string().optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|||||||
@ -33,8 +33,10 @@ export type DiscordGuildEntryResolved = {
|
|||||||
users?: Array<string | number>;
|
users?: Array<string | number>;
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
autoThread?: boolean;
|
autoThread?: boolean;
|
||||||
|
model?: string;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
model?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DiscordChannelConfigResolved = {
|
export type DiscordChannelConfigResolved = {
|
||||||
@ -47,6 +49,7 @@ export type DiscordChannelConfigResolved = {
|
|||||||
autoThread?: boolean;
|
autoThread?: boolean;
|
||||||
matchKey?: string;
|
matchKey?: string;
|
||||||
matchSource?: ChannelMatchSource;
|
matchSource?: ChannelMatchSource;
|
||||||
|
model?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function normalizeDiscordAllowList(
|
export function normalizeDiscordAllowList(
|
||||||
@ -215,6 +218,7 @@ function resolveDiscordChannelConfigEntry(
|
|||||||
users: entry.users,
|
users: entry.users,
|
||||||
systemPrompt: entry.systemPrompt,
|
systemPrompt: entry.systemPrompt,
|
||||||
autoThread: entry.autoThread,
|
autoThread: entry.autoThread,
|
||||||
|
model: entry.model,
|
||||||
};
|
};
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -368,6 +368,9 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
|||||||
}).onReplyStart,
|
}).onReplyStart,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Resolve channel-level model override: channel config takes precedence over guild config.
|
||||||
|
const channelModelOverride = channelConfig?.model ?? guildInfo?.model;
|
||||||
|
|
||||||
const { queuedFinal, counts } = await dispatchInboundMessage({
|
const { queuedFinal, counts } = await dispatchInboundMessage({
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
cfg,
|
cfg,
|
||||||
@ -382,6 +385,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
|||||||
onModelSelected: (ctx) => {
|
onModelSelected: (ctx) => {
|
||||||
prefixContext.onModelSelected(ctx);
|
prefixContext.onModelSelected(ctx);
|
||||||
},
|
},
|
||||||
|
channelModelOverride,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
markDispatchIdle();
|
markDispatchIdle();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user