mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat: interactive model picker for Telegram and Discord (#5742)
/model with no args now shows an interactive UI on Telegram and Discord instead of a text list: Telegram: Inline keyboard buttons — two-step drill-down. Step 1: Provider buttons with model counts (e.g. 'OpenRouter (15)') Step 2: Model buttons within the selected provider Edits the same message in-place as the user navigates. Back/Cancel buttons for navigation. Discord: Embed + Select dropdown menus via discord.ui.View. Step 1: Provider dropdown with model counts Step 2: Model dropdown within the selected provider Back/Cancel buttons. Auth-gated to allowed users. Platforms without picker support (Slack, WhatsApp, Signal, etc.) fall back to the existing text list. /model <name> continues to work as a direct text switch on all platforms — the interactive picker is only for bare /model. Implementation: - TelegramAdapter.send_model_picker() + _handle_model_picker_callback() with compact callback_data (mp:/mm:/mb/mx, all within 64-byte limit) - DiscordAdapter.send_model_picker() + ModelPickerView (discord.ui.View) with Select menus (up to 25 options per dropdown) - GatewayRunner._handle_model_command() detects adapter capability via getattr(type(adapter), 'send_model_picker', None) (safe with mocks) and sends picker with async callback closure for the switch logic - Callback performs full switch: switch_model(), cached agent update, session override, pending model note — same as /model <name>
This commit is contained in:
parent
c7768137fa
commit
5a2cf280a3
3 changed files with 630 additions and 4 deletions
|
|
@ -2039,6 +2039,66 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
except Exception as e:
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def send_model_picker(
|
||||
self,
|
||||
chat_id: str,
|
||||
providers: list,
|
||||
current_model: str,
|
||||
current_provider: str,
|
||||
session_key: str,
|
||||
on_model_selected,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send an interactive select-menu model picker.
|
||||
|
||||
Two-step drill-down: provider dropdown → model dropdown.
|
||||
Uses Discord embeds + Select menus via ``ModelPickerView``.
|
||||
"""
|
||||
if not self._client or not DISCORD_AVAILABLE:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
try:
|
||||
# Resolve target channel (use thread_id if present)
|
||||
target_id = chat_id
|
||||
if metadata and metadata.get("thread_id"):
|
||||
target_id = metadata["thread_id"]
|
||||
|
||||
channel = self._client.get_channel(int(target_id))
|
||||
if not channel:
|
||||
channel = await self._client.fetch_channel(int(target_id))
|
||||
|
||||
try:
|
||||
from hermes_cli.providers import get_label
|
||||
provider_label = get_label(current_provider)
|
||||
except Exception:
|
||||
provider_label = current_provider
|
||||
|
||||
embed = discord.Embed(
|
||||
title="⚙ Model Configuration",
|
||||
description=(
|
||||
f"Current model: `{current_model or 'unknown'}`\n"
|
||||
f"Provider: {provider_label}\n\n"
|
||||
f"Select a provider:"
|
||||
),
|
||||
color=discord.Color.blue(),
|
||||
)
|
||||
|
||||
view = ModelPickerView(
|
||||
providers=providers,
|
||||
current_model=current_model,
|
||||
current_provider=current_provider,
|
||||
session_key=session_key,
|
||||
on_model_selected=on_model_selected,
|
||||
allowed_user_ids=self._allowed_user_ids,
|
||||
)
|
||||
|
||||
msg = await channel.send(embed=embed, view=view)
|
||||
return SendResult(success=True, message_id=str(msg.id))
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("[%s] send_model_picker failed: %s", self.name, e)
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
def _get_parent_channel_id(self, channel: Any) -> Optional[str]:
|
||||
"""Return the parent channel ID for a Discord thread-like channel, if present."""
|
||||
parent = getattr(channel, "parent", None)
|
||||
|
|
@ -2530,3 +2590,219 @@ if DISCORD_AVAILABLE:
|
|||
self.resolved = True
|
||||
for child in self.children:
|
||||
child.disabled = True
|
||||
|
||||
class ModelPickerView(discord.ui.View):
|
||||
"""Interactive select-menu view for model switching.
|
||||
|
||||
Two-step drill-down: provider dropdown → model dropdown.
|
||||
Edits the original message in-place as the user navigates.
|
||||
Times out after 2 minutes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
providers: list,
|
||||
current_model: str,
|
||||
current_provider: str,
|
||||
session_key: str,
|
||||
on_model_selected,
|
||||
allowed_user_ids: set,
|
||||
):
|
||||
super().__init__(timeout=120)
|
||||
self.providers = providers
|
||||
self.current_model = current_model
|
||||
self.current_provider = current_provider
|
||||
self.session_key = session_key
|
||||
self.on_model_selected = on_model_selected
|
||||
self.allowed_user_ids = allowed_user_ids
|
||||
self.resolved = False
|
||||
self._selected_provider: str = ""
|
||||
|
||||
self._build_provider_select()
|
||||
|
||||
def _check_auth(self, interaction: discord.Interaction) -> bool:
|
||||
if not self.allowed_user_ids:
|
||||
return True
|
||||
return str(interaction.user.id) in self.allowed_user_ids
|
||||
|
||||
def _build_provider_select(self):
|
||||
"""Build the provider dropdown menu."""
|
||||
self.clear_items()
|
||||
options = []
|
||||
for p in self.providers:
|
||||
count = p.get("total_models", len(p.get("models", [])))
|
||||
label = f"{p['name']} ({count} models)"
|
||||
desc = "current" if p.get("is_current") else None
|
||||
options.append(
|
||||
discord.SelectOption(
|
||||
label=label[:100],
|
||||
value=p["slug"],
|
||||
default=bool(p.get("is_current")),
|
||||
description=desc,
|
||||
)
|
||||
)
|
||||
if not options:
|
||||
return
|
||||
|
||||
select = discord.ui.Select(
|
||||
placeholder="Choose a provider...",
|
||||
options=options[:25],
|
||||
custom_id="model_provider_select",
|
||||
)
|
||||
select.callback = self._on_provider_selected
|
||||
self.add_item(select)
|
||||
|
||||
cancel_btn = discord.ui.Button(
|
||||
label="Cancel", style=discord.ButtonStyle.red, custom_id="model_cancel"
|
||||
)
|
||||
cancel_btn.callback = self._on_cancel
|
||||
self.add_item(cancel_btn)
|
||||
|
||||
def _build_model_select(self, provider_slug: str):
|
||||
"""Build the model dropdown for a specific provider."""
|
||||
self.clear_items()
|
||||
provider = next(
|
||||
(p for p in self.providers if p["slug"] == provider_slug), None
|
||||
)
|
||||
if not provider:
|
||||
return
|
||||
|
||||
models = provider.get("models", [])
|
||||
options = []
|
||||
for model_id in models[:25]:
|
||||
short = model_id.split("/")[-1] if "/" in model_id else model_id
|
||||
options.append(
|
||||
discord.SelectOption(
|
||||
label=short[:100],
|
||||
value=model_id[:100],
|
||||
)
|
||||
)
|
||||
if not options:
|
||||
return
|
||||
|
||||
select = discord.ui.Select(
|
||||
placeholder=f"Choose a model from {provider.get('name', provider_slug)}...",
|
||||
options=options,
|
||||
custom_id="model_model_select",
|
||||
)
|
||||
select.callback = self._on_model_selected
|
||||
self.add_item(select)
|
||||
|
||||
back_btn = discord.ui.Button(
|
||||
label="◀ Back", style=discord.ButtonStyle.grey, custom_id="model_back"
|
||||
)
|
||||
back_btn.callback = self._on_back
|
||||
self.add_item(back_btn)
|
||||
|
||||
cancel_btn = discord.ui.Button(
|
||||
label="Cancel", style=discord.ButtonStyle.red, custom_id="model_cancel2"
|
||||
)
|
||||
cancel_btn.callback = self._on_cancel
|
||||
self.add_item(cancel_btn)
|
||||
|
||||
async def _on_provider_selected(self, interaction: discord.Interaction):
|
||||
if not self._check_auth(interaction):
|
||||
await interaction.response.send_message(
|
||||
"You're not authorized~", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
provider_slug = interaction.data["values"][0]
|
||||
self._selected_provider = provider_slug
|
||||
provider = next(
|
||||
(p for p in self.providers if p["slug"] == provider_slug), None
|
||||
)
|
||||
pname = provider.get("name", provider_slug) if provider else provider_slug
|
||||
|
||||
self._build_model_select(provider_slug)
|
||||
|
||||
total = provider.get("total_models", 0) if provider else 0
|
||||
shown = min(len(provider.get("models", [])), 25) if provider else 0
|
||||
extra = f"\n*{total - shown} more available — type `/model <name>` directly*" if total > shown else ""
|
||||
|
||||
await interaction.response.edit_message(
|
||||
embed=discord.Embed(
|
||||
title="⚙ Model Configuration",
|
||||
description=f"Provider: **{pname}**\nSelect a model:{extra}",
|
||||
color=discord.Color.blue(),
|
||||
),
|
||||
view=self,
|
||||
)
|
||||
|
||||
async def _on_model_selected(self, interaction: discord.Interaction):
|
||||
if self.resolved:
|
||||
await interaction.response.send_message(
|
||||
"Already resolved~", ephemeral=True
|
||||
)
|
||||
return
|
||||
if not self._check_auth(interaction):
|
||||
await interaction.response.send_message(
|
||||
"You're not authorized~", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
self.resolved = True
|
||||
model_id = interaction.data["values"][0]
|
||||
|
||||
try:
|
||||
result_text = await self.on_model_selected(
|
||||
str(interaction.channel_id),
|
||||
model_id,
|
||||
self._selected_provider,
|
||||
)
|
||||
except Exception as exc:
|
||||
result_text = f"Error switching model: {exc}"
|
||||
|
||||
self.clear_items()
|
||||
await interaction.response.edit_message(
|
||||
embed=discord.Embed(
|
||||
title="⚙ Model Switched",
|
||||
description=result_text,
|
||||
color=discord.Color.green(),
|
||||
),
|
||||
view=self,
|
||||
)
|
||||
|
||||
async def _on_back(self, interaction: discord.Interaction):
|
||||
if not self._check_auth(interaction):
|
||||
await interaction.response.send_message(
|
||||
"You're not authorized~", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
self._build_provider_select()
|
||||
|
||||
try:
|
||||
from hermes_cli.providers import get_label
|
||||
provider_label = get_label(self.current_provider)
|
||||
except Exception:
|
||||
provider_label = self.current_provider
|
||||
|
||||
await interaction.response.edit_message(
|
||||
embed=discord.Embed(
|
||||
title="⚙ Model Configuration",
|
||||
description=(
|
||||
f"Current model: `{self.current_model or 'unknown'}`\n"
|
||||
f"Provider: {provider_label}\n\n"
|
||||
f"Select a provider:"
|
||||
),
|
||||
color=discord.Color.blue(),
|
||||
),
|
||||
view=self,
|
||||
)
|
||||
|
||||
async def _on_cancel(self, interaction: discord.Interaction):
|
||||
self.resolved = True
|
||||
self.clear_items()
|
||||
await interaction.response.edit_message(
|
||||
embed=discord.Embed(
|
||||
title="⚙ Model Configuration",
|
||||
description="Model selection cancelled.",
|
||||
color=discord.Color.greyple(),
|
||||
),
|
||||
view=self,
|
||||
)
|
||||
|
||||
async def on_timeout(self):
|
||||
self.resolved = True
|
||||
self.clear_items()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue