mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
feat(gateway): inline choice pickers for /reasoning and /fast (Telegram, Discord, Matrix) (#65799)
Bare /reasoning and /fast now render a native one-tap picker on picker-capable platforms, with automatic fallback to the text status card everywhere else — parity with the /model picker UX. - gateway/slash_commands.py: generic send_choice_picker capability gate (detected on the adapter type, like send_model_picker); selection and typed arguments flow through one shared application path so they can never diverge; choices built from VALID_REASONING_EFFORTS so future levels appear automatically - telegram: flat inline-keyboard picker (cp:<idx> callbacks), authorized users only (same gate as approval buttons) - discord: ChoicePickerView select menu, auth mirrors ExecApprovalView, 2-minute timeout with expiry edit - matrix: reaction-based picker; reaction set extended to 12 slots to fit the full effort ladder + subcommands - locales: picker_title + choice labels in all 16 languages - docs: ADDING_A_PLATFORM.md capability table Closes #61110.
This commit is contained in:
parent
659d1123c4
commit
bd37ff9138
22 changed files with 970 additions and 104 deletions
|
|
@ -120,6 +120,7 @@ If your platform supports interactive button/menu messages, implement these for
|
|||
| `send_exec_approval(chat_id, command, session_key, description, ...)` | Render dangerous-command approval as Approve/Deny buttons. Inbound dispatch routes to `tools.approval.resolve_gateway_approval`. |
|
||||
| `send_slash_confirm(chat_id, title, message, session_key, confirm_id, ...)` | Render slash-command confirmations (e.g. `/reload-mcp`) as Once/Always/Cancel buttons. Inbound dispatch routes to `tools.slash_confirm.resolve`. |
|
||||
| `send_model_picker(...)` | Interactive `/model` picker. Used by Telegram and Discord. |
|
||||
| `send_choice_picker(...)` | Flat single-level picker for finite-choice commands (`/reasoning`, `/fast`). Implemented by Telegram (inline keyboard), Discord (select menu), and Matrix (reactions). Platforms without it fall back to the text status card automatically. |
|
||||
|
||||
See `gateway/platforms/telegram.py`, `discord.py`, and `whatsapp_cloud.py` for reference implementations. The button-callback id convention (`cl:<id>:<idx>`, `appr:<id>:<choice>`, `sc:<choice>:<id>`) is shared across adapters — match it so the gateway-side resolvers work without modification.
|
||||
|
||||
|
|
|
|||
|
|
@ -2704,7 +2704,154 @@ class GatewaySlashCommandsMixin:
|
|||
preview = prompt[:60] + ("..." if len(prompt) > 60 else "")
|
||||
return t("gateway.background.started", preview=preview, task_id=task_id)
|
||||
|
||||
async def _handle_reasoning_command(self, event: MessageEvent) -> str:
|
||||
def _save_gateway_config_key(self, key_path: str, value) -> bool:
|
||||
"""Save a dot-separated key to config.yaml (shared by /reasoning, /fast
|
||||
and their interactive pickers)."""
|
||||
import yaml
|
||||
from gateway.run import _hermes_home
|
||||
config_path = _hermes_home / "config.yaml"
|
||||
try:
|
||||
user_config = {}
|
||||
if config_path.exists():
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
user_config = yaml.safe_load(f) or {}
|
||||
keys = key_path.split(".")
|
||||
current = user_config
|
||||
for k in keys[:-1]:
|
||||
if k not in current or not isinstance(current[k], dict):
|
||||
current[k] = {}
|
||||
current = current[k]
|
||||
current[keys[-1]] = value
|
||||
atomic_config_write(config_path, user_config)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to save config key %s: %s", key_path, e)
|
||||
return False
|
||||
|
||||
def _apply_reasoning_selection(
|
||||
self,
|
||||
session_key: str,
|
||||
platform_key: str,
|
||||
value: str,
|
||||
persist_global: bool = False,
|
||||
) -> str:
|
||||
"""Apply a /reasoning argument (typed or picked) and return the reply.
|
||||
|
||||
Single application path shared by the typed `/reasoning <arg>` branch
|
||||
and the interactive choice picker, so both surfaces stay in lockstep
|
||||
with the canonical parser.
|
||||
"""
|
||||
from hermes_constants import parse_reasoning_effort
|
||||
|
||||
value = (value or "").strip().lower()
|
||||
|
||||
# Display toggle (per-platform)
|
||||
if value in {"show", "on"}:
|
||||
self._show_reasoning = True
|
||||
self._save_gateway_config_key(
|
||||
f"display.platforms.{platform_key}.show_reasoning", True
|
||||
)
|
||||
return t("gateway.reasoning.display_set_on", platform=platform_key)
|
||||
if value in {"hide", "off"}:
|
||||
self._show_reasoning = False
|
||||
self._save_gateway_config_key(
|
||||
f"display.platforms.{platform_key}.show_reasoning", False
|
||||
)
|
||||
return t("gateway.reasoning.display_set_off", platform=platform_key)
|
||||
|
||||
if value == "reset":
|
||||
if persist_global:
|
||||
return t("gateway.reasoning.reset_global_unsupported")
|
||||
self._set_session_reasoning_override(session_key, None)
|
||||
self._reasoning_config = self._load_reasoning_config()
|
||||
self._evict_cached_agent(session_key)
|
||||
return t("gateway.reasoning.reset_done")
|
||||
|
||||
parsed = parse_reasoning_effort(value)
|
||||
if parsed is None:
|
||||
return t("gateway.reasoning.unknown_arg", arg=value)
|
||||
|
||||
self._reasoning_config = parsed
|
||||
if persist_global:
|
||||
if self._save_gateway_config_key("agent.reasoning_effort", value):
|
||||
self._set_session_reasoning_override(session_key, None)
|
||||
self._evict_cached_agent(session_key)
|
||||
return t("gateway.reasoning.set_global", effort=value)
|
||||
self._set_session_reasoning_override(session_key, parsed)
|
||||
self._evict_cached_agent(session_key)
|
||||
return t("gateway.reasoning.set_global_save_failed", effort=value)
|
||||
|
||||
self._set_session_reasoning_override(session_key, parsed)
|
||||
self._evict_cached_agent(session_key)
|
||||
return t("gateway.reasoning.set_session", effort=value)
|
||||
|
||||
def _reasoning_picker_choices(self, current_effort: str) -> list:
|
||||
"""Build the choice list for the interactive /reasoning picker."""
|
||||
from hermes_constants import VALID_REASONING_EFFORTS
|
||||
|
||||
choices = [
|
||||
{
|
||||
"value": "none",
|
||||
"label": t("gateway.reasoning.choice_none"),
|
||||
"is_current": current_effort == "none",
|
||||
}
|
||||
]
|
||||
for level in VALID_REASONING_EFFORTS:
|
||||
choices.append(
|
||||
{
|
||||
"value": level,
|
||||
"label": level,
|
||||
"is_current": level == current_effort,
|
||||
}
|
||||
)
|
||||
choices.extend(
|
||||
[
|
||||
{"value": "reset", "label": t("gateway.reasoning.choice_reset"), "is_current": False},
|
||||
{"value": "show", "label": t("gateway.reasoning.choice_show"), "is_current": False},
|
||||
{"value": "hide", "label": t("gateway.reasoning.choice_hide"), "is_current": False},
|
||||
]
|
||||
)
|
||||
return choices
|
||||
|
||||
async def _try_send_choice_picker(
|
||||
self,
|
||||
event: MessageEvent,
|
||||
session_key: str,
|
||||
title: str,
|
||||
choices: list,
|
||||
on_choice_selected,
|
||||
) -> bool:
|
||||
"""Send an interactive choice picker when the platform supports it.
|
||||
|
||||
Mirrors the `/model` picker gate: the capability is detected on the
|
||||
adapter *type* (``send_choice_picker``), and a failed send falls back
|
||||
to the text path (returns False) instead of erroring the command.
|
||||
"""
|
||||
adapter = getattr(self, "_adapter_for_source")(event.source)
|
||||
has_picker = (
|
||||
adapter is not None
|
||||
and getattr(type(adapter), "send_choice_picker", None) is not None
|
||||
)
|
||||
if not has_picker:
|
||||
return False
|
||||
try:
|
||||
metadata = self._thread_metadata_for_source(
|
||||
event.source, self._reply_anchor_for_event(event)
|
||||
)
|
||||
result = await adapter.send_choice_picker(
|
||||
chat_id=event.source.chat_id,
|
||||
title=title,
|
||||
choices=choices,
|
||||
session_key=session_key,
|
||||
on_choice_selected=on_choice_selected,
|
||||
metadata=metadata,
|
||||
)
|
||||
return bool(getattr(result, "success", False))
|
||||
except Exception as e:
|
||||
logger.warning("send_choice_picker failed, falling back to text: %s", e)
|
||||
return False
|
||||
|
||||
async def _handle_reasoning_command(self, event: MessageEvent) -> Optional[str]:
|
||||
"""Handle /reasoning command — manage reasoning effort and display toggle.
|
||||
|
||||
Usage:
|
||||
|
|
@ -2715,13 +2862,10 @@ class GatewaySlashCommandsMixin:
|
|||
/reasoning show|on Show model reasoning in responses
|
||||
/reasoning hide|off Hide model reasoning from responses
|
||||
"""
|
||||
from gateway.run import _hermes_home, _platform_config_key
|
||||
from hermes_constants import parse_reasoning_effort
|
||||
import yaml
|
||||
from gateway.run import _platform_config_key
|
||||
|
||||
raw_args = event.get_command_args().strip()
|
||||
args, persist_global = self._parse_reasoning_command_args(raw_args)
|
||||
config_path = _hermes_home / "config.yaml"
|
||||
# Normalize the source (Telegram DM topic recovery) before deriving
|
||||
# the override key so storage matches the key the next message turn
|
||||
# reads — same fix as /model (#30479).
|
||||
|
|
@ -2739,35 +2883,18 @@ class GatewaySlashCommandsMixin:
|
|||
model=_session_model,
|
||||
)
|
||||
|
||||
def _save_config_key(key_path: str, value):
|
||||
"""Save a dot-separated key to config.yaml."""
|
||||
try:
|
||||
user_config = {}
|
||||
if config_path.exists():
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
user_config = yaml.safe_load(f) or {}
|
||||
keys = key_path.split(".")
|
||||
current = user_config
|
||||
for k in keys[:-1]:
|
||||
if k not in current or not isinstance(current[k], dict):
|
||||
current[k] = {}
|
||||
current = current[k]
|
||||
current[keys[-1]] = value
|
||||
atomic_config_write(config_path, user_config)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to save config key %s: %s", key_path, e)
|
||||
return False
|
||||
|
||||
if not raw_args:
|
||||
# Show current state
|
||||
rc = self._reasoning_config
|
||||
if rc is None:
|
||||
level = t("gateway.reasoning.level_default")
|
||||
current_effort = "medium"
|
||||
elif rc.get("enabled") is False:
|
||||
level = t("gateway.reasoning.level_disabled")
|
||||
current_effort = "none"
|
||||
else:
|
||||
level = rc.get("effort", "medium")
|
||||
current_effort = level
|
||||
display_state = (
|
||||
t("gateway.reasoning.display_on")
|
||||
if self._show_reasoning
|
||||
|
|
@ -2779,6 +2906,31 @@ class GatewaySlashCommandsMixin:
|
|||
if has_session_override
|
||||
else t("gateway.reasoning.scope_global")
|
||||
)
|
||||
|
||||
# Interactive picker on platforms that support it (parity with the
|
||||
# /model picker). Falls through to the text status card otherwise.
|
||||
_picker_platform_key = _platform_config_key(event.source.platform)
|
||||
|
||||
async def _on_reasoning_choice(_chat_id: str, value: str) -> str:
|
||||
return self._apply_reasoning_selection(
|
||||
session_key, _picker_platform_key, value
|
||||
)
|
||||
|
||||
picker_sent = await self._try_send_choice_picker(
|
||||
event,
|
||||
session_key,
|
||||
title=t(
|
||||
"gateway.reasoning.picker_title",
|
||||
level=level,
|
||||
scope=scope,
|
||||
display=display_state,
|
||||
),
|
||||
choices=self._reasoning_picker_choices(current_effort),
|
||||
on_choice_selected=_on_reasoning_choice,
|
||||
)
|
||||
if picker_sent:
|
||||
return None # Picker sent — adapter handles the response
|
||||
|
||||
return t(
|
||||
"gateway.reasoning.status",
|
||||
level=level,
|
||||
|
|
@ -2786,47 +2938,11 @@ class GatewaySlashCommandsMixin:
|
|||
display=display_state,
|
||||
)
|
||||
|
||||
# Display toggle (per-platform)
|
||||
# Typed argument path — same applier the picker uses.
|
||||
platform_key = _platform_config_key(event.source.platform)
|
||||
if args in {"show", "on"}:
|
||||
self._show_reasoning = True
|
||||
_save_config_key(f"display.platforms.{platform_key}.show_reasoning", True)
|
||||
return t("gateway.reasoning.display_set_on", platform=platform_key)
|
||||
|
||||
if args in {"hide", "off"}:
|
||||
self._show_reasoning = False
|
||||
_save_config_key(f"display.platforms.{platform_key}.show_reasoning", False)
|
||||
return t("gateway.reasoning.display_set_off", platform=platform_key)
|
||||
|
||||
# Effort level change
|
||||
effort = args.strip()
|
||||
if effort == "reset":
|
||||
if persist_global:
|
||||
return t("gateway.reasoning.reset_global_unsupported")
|
||||
self._set_session_reasoning_override(session_key, None)
|
||||
self._reasoning_config = self._load_reasoning_config()
|
||||
self._evict_cached_agent(session_key)
|
||||
return t("gateway.reasoning.reset_done")
|
||||
parsed = parse_reasoning_effort(effort)
|
||||
if parsed is None:
|
||||
return t(
|
||||
"gateway.reasoning.unknown_arg",
|
||||
arg=effort or raw_args.lower(),
|
||||
)
|
||||
|
||||
self._reasoning_config = parsed
|
||||
if persist_global:
|
||||
if _save_config_key("agent.reasoning_effort", effort):
|
||||
self._set_session_reasoning_override(session_key, None)
|
||||
self._evict_cached_agent(session_key)
|
||||
return t("gateway.reasoning.set_global", effort=effort)
|
||||
self._set_session_reasoning_override(session_key, parsed)
|
||||
self._evict_cached_agent(session_key)
|
||||
return t("gateway.reasoning.set_global_save_failed", effort=effort)
|
||||
|
||||
self._set_session_reasoning_override(session_key, parsed)
|
||||
self._evict_cached_agent(session_key)
|
||||
return t("gateway.reasoning.set_session", effort=effort)
|
||||
return self._apply_reasoning_selection(
|
||||
session_key, platform_key, args, persist_global=persist_global
|
||||
)
|
||||
|
||||
async def _handle_memory_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /memory — review pending memory writes + toggle the approval gate.
|
||||
|
|
@ -2932,14 +3048,12 @@ class GatewaySlashCommandsMixin:
|
|||
f"~/.hermes/pending/skills/{pending_id}.json)")
|
||||
return out
|
||||
|
||||
async def _handle_fast_command(self, event: MessageEvent) -> str:
|
||||
async def _handle_fast_command(self, event: MessageEvent) -> Optional[str]:
|
||||
"""Handle /fast — mirror the CLI Priority Processing toggle in gateway chats."""
|
||||
from gateway.run import _hermes_home, _load_gateway_config, _resolve_gateway_model
|
||||
import yaml
|
||||
from gateway.run import _load_gateway_config, _resolve_gateway_model
|
||||
from hermes_cli.models import model_supports_fast_mode
|
||||
|
||||
args = event.get_command_args().strip().lower()
|
||||
config_path = _hermes_home / "config.yaml"
|
||||
self._service_tier = self._load_service_tier()
|
||||
|
||||
user_config = _load_gateway_config()
|
||||
|
|
@ -2947,44 +3061,56 @@ class GatewaySlashCommandsMixin:
|
|||
if not model_supports_fast_mode(model):
|
||||
return t("gateway.fast.not_supported")
|
||||
|
||||
def _save_config_key(key_path: str, value):
|
||||
"""Save a dot-separated key to config.yaml."""
|
||||
try:
|
||||
user_config = {}
|
||||
if config_path.exists():
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
user_config = yaml.safe_load(f) or {}
|
||||
keys = key_path.split(".")
|
||||
current = user_config
|
||||
for k in keys[:-1]:
|
||||
if k not in current or not isinstance(current[k], dict):
|
||||
current[k] = {}
|
||||
current = current[k]
|
||||
current[keys[-1]] = value
|
||||
atomic_config_write(config_path, user_config)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to save config key %s: %s", key_path, e)
|
||||
return False
|
||||
def _apply_fast_selection(value: str) -> str:
|
||||
"""Apply a /fast argument (typed or picked) and return the reply."""
|
||||
if value in {"fast", "on"}:
|
||||
self._service_tier = "priority"
|
||||
saved_value = "fast"
|
||||
label = t("gateway.fast.label_fast")
|
||||
elif value in {"normal", "off"}:
|
||||
self._service_tier = None
|
||||
saved_value = "normal"
|
||||
label = t("gateway.fast.label_normal")
|
||||
else:
|
||||
return t("gateway.fast.unknown_arg", arg=value)
|
||||
if self._save_gateway_config_key("agent.service_tier", saved_value):
|
||||
return t("gateway.fast.saved", label=label)
|
||||
return t("gateway.fast.session_only", label=label)
|
||||
|
||||
if not args or args == "status":
|
||||
status = t("gateway.fast.status_fast") if self._service_tier == "priority" else t("gateway.fast.status_normal")
|
||||
is_fast = self._service_tier == "priority"
|
||||
status = t("gateway.fast.status_fast") if is_fast else t("gateway.fast.status_normal")
|
||||
|
||||
# Interactive picker on platforms that support it.
|
||||
session_key = self._session_key_for_source(event.source)
|
||||
|
||||
async def _on_fast_choice(_chat_id: str, value: str) -> str:
|
||||
return _apply_fast_selection(value)
|
||||
|
||||
picker_sent = await self._try_send_choice_picker(
|
||||
event,
|
||||
session_key,
|
||||
title=t("gateway.fast.picker_title", mode=status),
|
||||
choices=[
|
||||
{
|
||||
"value": "fast",
|
||||
"label": t("gateway.fast.choice_fast"),
|
||||
"is_current": is_fast,
|
||||
},
|
||||
{
|
||||
"value": "normal",
|
||||
"label": t("gateway.fast.choice_normal"),
|
||||
"is_current": not is_fast,
|
||||
},
|
||||
],
|
||||
on_choice_selected=_on_fast_choice,
|
||||
)
|
||||
if picker_sent:
|
||||
return None # Picker sent — adapter handles the response
|
||||
|
||||
return t("gateway.fast.status", mode=status)
|
||||
|
||||
if args in {"fast", "on"}:
|
||||
self._service_tier = "priority"
|
||||
saved_value = "fast"
|
||||
label = t("gateway.fast.label_fast")
|
||||
elif args in {"normal", "off"}:
|
||||
self._service_tier = None
|
||||
saved_value = "normal"
|
||||
label = t("gateway.fast.label_normal")
|
||||
else:
|
||||
return t("gateway.fast.unknown_arg", arg=args)
|
||||
|
||||
if _save_config_key("agent.service_tier", saved_value):
|
||||
return t("gateway.fast.saved", label=label)
|
||||
return t("gateway.fast.session_only", label=label)
|
||||
return _apply_fast_selection(args)
|
||||
|
||||
async def _handle_yolo_command(self, event: MessageEvent) -> Union[str, EphemeralReply]:
|
||||
"""Handle /yolo — toggle dangerous command approval bypass for this session only."""
|
||||
|
|
|
|||
|
|
@ -124,6 +124,9 @@ gateway:
|
|||
label_normal: "NORMAL"
|
||||
status_fast: "fast"
|
||||
status_normal: "normal"
|
||||
picker_title: "⚡ **Priority Processing**\\n\\nHuidige modus: `{mode}`\\n\\nKies \\'n opsie:"
|
||||
choice_fast: "fast — Priority Processing aan"
|
||||
choice_normal: "normal — standaardverwerking"
|
||||
|
||||
footer:
|
||||
status: "📎 Looptyd-voetstuk: **{state}**\nVelde: `{fields}`\nPlatform: `{platform}`"
|
||||
|
|
@ -194,6 +197,11 @@ gateway:
|
|||
reset_global_unsupported: "⚠️ `/reasoning reset --global` word nie ondersteun nie. Gebruik `/reasoning <level> --global` om die globale verstek te verander."
|
||||
reset_done: "🧠 ✓ Sessie-redenering-oorskryf verwyder; val terug op globale konfigurasie."
|
||||
unknown_arg: "⚠️ Onbekende argument: `{arg}`\n\n**Geldige vlakke:** none, minimal, low, medium, high, xhigh, max, ultra\n**Vertoon:** show, hide\n**Permanent:** voeg `--global` by om verby hierdie sessie te stoor"
|
||||
picker_title: "🧠 **Redenering-instellings**\\n\\n**Inspanning:** `{level}`\\n**Bereik:** {scope}\\n**Vertoon:** {display}\\n\\nKies \\'n opsie:"
|
||||
choice_none: "none — deaktiveer redenering"
|
||||
choice_reset: "reset — vee sessie-oorskrywing uit"
|
||||
choice_show: "wys redenering in antwoorde"
|
||||
choice_hide: "versteek redenering in antwoorde"
|
||||
set_global: "🧠 ✓ Redenering-inspanning gestel op `{effort}` (gestoor in konfigurasie)\n_(neem effek by die volgende boodskap)_"
|
||||
set_global_save_failed: "🧠 ✓ Redenering-inspanning gestel op `{effort}` (slegs sessie — konfigurasie-stoor het misluk)\n_(neem effek by die volgende boodskap)_"
|
||||
set_session: "🧠 ✓ Redenering-inspanning gestel op `{effort}` (slegs sessie — voeg `--global` by om permanent te stoor)\n_(neem effek by die volgende boodskap)_"
|
||||
|
|
|
|||
|
|
@ -124,6 +124,9 @@ gateway:
|
|||
label_normal: "NORMAL"
|
||||
status_fast: "fast"
|
||||
status_normal: "normal"
|
||||
picker_title: "⚡ **Priority Processing**\\n\\nAktueller Modus: `{mode}`\\n\\nOption wählen:"
|
||||
choice_fast: "fast — Priority Processing an"
|
||||
choice_normal: "normal — Standardverarbeitung"
|
||||
|
||||
footer:
|
||||
status: "📎 Laufzeit-Fußzeile: **{state}**\nFelder: `{fields}`\nPlattform: `{platform}`"
|
||||
|
|
@ -194,6 +197,11 @@ gateway:
|
|||
reset_global_unsupported: "⚠️ `/reasoning reset --global` wird nicht unterstützt. Verwenden Sie `/reasoning <level> --global`, um den globalen Standard zu ändern."
|
||||
reset_done: "🧠 ✓ Sitzungs-Reasoning-Override gelöscht; Rückfall auf globale Konfiguration."
|
||||
unknown_arg: "⚠️ Unbekanntes Argument: `{arg}`\n\n**Gültige Stärken:** none, minimal, low, medium, high, xhigh, max, ultra\n**Anzeige:** show, hide\n**Speichern:** `--global` hinzufügen, um über die Sitzung hinaus zu speichern"
|
||||
picker_title: "🧠 **Reasoning-Einstellungen**\\n\\n**Stärke:** `{level}`\\n**Geltungsbereich:** {scope}\\n**Anzeige:** {display}\\n\\nOption wählen:"
|
||||
choice_none: "none — Reasoning deaktivieren"
|
||||
choice_reset: "reset — Sitzungs-Override löschen"
|
||||
choice_show: "Reasoning in Antworten anzeigen"
|
||||
choice_hide: "Reasoning in Antworten ausblenden"
|
||||
set_global: "🧠 ✓ Reasoning-Stärke auf `{effort}` gesetzt (in Konfiguration gespeichert)\n_(wird mit der nächsten Nachricht wirksam)_"
|
||||
set_global_save_failed: "🧠 ✓ Reasoning-Stärke auf `{effort}` gesetzt (nur Sitzung — Konfiguration konnte nicht gespeichert werden)\n_(wird mit der nächsten Nachricht wirksam)_"
|
||||
set_session: "🧠 ✓ Reasoning-Stärke auf `{effort}` gesetzt (nur Sitzung — `--global` hinzufügen, um zu speichern)\n_(wird mit der nächsten Nachricht wirksam)_"
|
||||
|
|
|
|||
|
|
@ -139,6 +139,9 @@ gateway:
|
|||
label_normal: "NORMAL"
|
||||
status_fast: "fast"
|
||||
status_normal: "normal"
|
||||
picker_title: "⚡ **Priority Processing**\n\nCurrent mode: `{mode}`\n\nPick an option:"
|
||||
choice_fast: "fast — Priority Processing on"
|
||||
choice_normal: "normal — standard processing"
|
||||
|
||||
footer:
|
||||
status: "📎 Runtime footer: **{state}**\nFields: `{fields}`\nPlatform: `{platform}`"
|
||||
|
|
@ -209,6 +212,11 @@ gateway:
|
|||
reset_global_unsupported: "⚠️ `/reasoning reset --global` is not supported. Use `/reasoning <level> --global` to change the global default."
|
||||
reset_done: "🧠 ✓ Session reasoning override cleared; falling back to global config."
|
||||
unknown_arg: "⚠️ Unknown argument: `{arg}`\n\n**Valid levels:** none, minimal, low, medium, high, xhigh, max, ultra\n**Display:** show, hide\n**Persist:** add `--global` to save beyond this session"
|
||||
picker_title: "🧠 **Reasoning Settings**\n\n**Effort:** `{level}`\n**Scope:** {scope}\n**Display:** {display}\n\nPick an option:"
|
||||
choice_none: "none — disable reasoning"
|
||||
choice_reset: "reset — clear session override"
|
||||
choice_show: "show reasoning in replies"
|
||||
choice_hide: "hide reasoning from replies"
|
||||
set_global: "🧠 ✓ Reasoning effort set to `{effort}` (saved to config)\n_(takes effect on next message)_"
|
||||
set_global_save_failed: "🧠 ✓ Reasoning effort set to `{effort}` (session only — config save failed)\n_(takes effect on next message)_"
|
||||
set_session: "🧠 ✓ Reasoning effort set to `{effort}` (session only — add `--global` to persist)\n_(takes effect on next message)_"
|
||||
|
|
|
|||
|
|
@ -124,6 +124,9 @@ gateway:
|
|||
label_normal: "NORMAL"
|
||||
status_fast: "fast"
|
||||
status_normal: "normal"
|
||||
picker_title: "⚡ **Priority Processing**\\n\\nModo actual: `{mode}`\\n\\nElige una opción:"
|
||||
choice_fast: "fast — Priority Processing activado"
|
||||
choice_normal: "normal — procesamiento estándar"
|
||||
|
||||
footer:
|
||||
status: "📎 Pie de ejecución: **{state}**\nCampos: `{fields}`\nPlataforma: `{platform}`"
|
||||
|
|
@ -194,6 +197,11 @@ gateway:
|
|||
reset_global_unsupported: "⚠️ `/reasoning reset --global` no es compatible. Usa `/reasoning <level> --global` para cambiar el valor global por defecto."
|
||||
reset_done: "🧠 ✓ Anulación de razonamiento de la sesión borrada; volviendo a la configuración global."
|
||||
unknown_arg: "⚠️ Argumento desconocido: `{arg}`\n\n**Niveles válidos:** none, minimal, low, medium, high, xhigh, max, ultra\n**Visualización:** show, hide\n**Persistir:** añade `--global` para guardar más allá de esta sesión"
|
||||
picker_title: "🧠 **Ajustes de razonamiento**\\n\\n**Esfuerzo:** `{level}`\\n**Alcance:** {scope}\\n**Visualización:** {display}\\n\\nElige una opción:"
|
||||
choice_none: "none — desactivar razonamiento"
|
||||
choice_reset: "reset — borrar el ajuste de sesión"
|
||||
choice_show: "mostrar razonamiento en respuestas"
|
||||
choice_hide: "ocultar razonamiento en respuestas"
|
||||
set_global: "🧠 ✓ Esfuerzo de razonamiento ajustado a `{effort}` (guardado en la configuración)\n_(se aplica en el próximo mensaje)_"
|
||||
set_global_save_failed: "🧠 ✓ Esfuerzo de razonamiento ajustado a `{effort}` (solo en la sesión — error al guardar la configuración)\n_(se aplica en el próximo mensaje)_"
|
||||
set_session: "🧠 ✓ Esfuerzo de razonamiento ajustado a `{effort}` (solo en la sesión — añade `--global` para persistir)\n_(se aplica en el próximo mensaje)_"
|
||||
|
|
|
|||
|
|
@ -124,6 +124,9 @@ gateway:
|
|||
label_normal: "NORMAL"
|
||||
status_fast: "fast"
|
||||
status_normal: "normal"
|
||||
picker_title: "⚡ **Priority Processing**\\n\\nMode actuel : `{mode}`\\n\\nChoisissez une option :"
|
||||
choice_fast: "fast — Priority Processing activé"
|
||||
choice_normal: "normal — traitement standard"
|
||||
|
||||
footer:
|
||||
status: "📎 Pied de page d'exécution : **{state}**\nChamps : `{fields}`\nPlateforme : `{platform}`"
|
||||
|
|
@ -194,6 +197,11 @@ gateway:
|
|||
reset_global_unsupported: "⚠️ `/reasoning reset --global` n'est pas pris en charge. Utilisez `/reasoning <level> --global` pour modifier la valeur globale par défaut."
|
||||
reset_done: "🧠 ✓ Remplacement de raisonnement de la session effacé ; retour à la configuration globale."
|
||||
unknown_arg: "⚠️ Argument inconnu : `{arg}`\n\n**Niveaux valides :** none, minimal, low, medium, high, xhigh, max, ultra\n**Affichage :** show, hide\n**Persister :** ajoutez `--global` pour enregistrer au-delà de cette session"
|
||||
picker_title: "🧠 **Paramètres de raisonnement**\\n\\n**Effort :** `{level}`\\n**Portée :** {scope}\\n**Affichage :** {display}\\n\\nChoisissez une option :"
|
||||
choice_none: "none — désactiver le raisonnement"
|
||||
choice_reset: "reset — effacer le réglage de session"
|
||||
choice_show: "afficher le raisonnement dans les réponses"
|
||||
choice_hide: "masquer le raisonnement dans les réponses"
|
||||
set_global: "🧠 ✓ Effort de raisonnement défini sur `{effort}` (enregistré dans la configuration)\n_(prend effet au prochain message)_"
|
||||
set_global_save_failed: "🧠 ✓ Effort de raisonnement défini sur `{effort}` (session uniquement — échec de l'enregistrement de la configuration)\n_(prend effet au prochain message)_"
|
||||
set_session: "🧠 ✓ Effort de raisonnement défini sur `{effort}` (session uniquement — ajoutez `--global` pour persister)\n_(prend effet au prochain message)_"
|
||||
|
|
|
|||
|
|
@ -128,6 +128,9 @@ gateway:
|
|||
label_normal: "NORMAL"
|
||||
status_fast: "fast"
|
||||
status_normal: "normal"
|
||||
picker_title: "⚡ **Priority Processing**\\n\\nMód reatha: `{mode}`\\n\\nRoghnaigh rogha:"
|
||||
choice_fast: "fast — Priority Processing ar siúl"
|
||||
choice_normal: "normal — gnáthphróiseáil"
|
||||
|
||||
footer:
|
||||
status: "📎 Buntásc rite: **{state}**\nRéimsí: `{fields}`\nArdán: `{platform}`"
|
||||
|
|
@ -198,6 +201,11 @@ gateway:
|
|||
reset_global_unsupported: "⚠️ Ní thacaítear le `/reasoning reset --global`. Úsáid `/reasoning <level> --global` chun an réamhshocrú domhanda a athrú."
|
||||
reset_done: "🧠 ✓ Sárú réasúnaíochta seisiúin glanta; ag titim siar ar an gcumraíocht dhomhanda."
|
||||
unknown_arg: "⚠️ Argóint anaithnid: `{arg}`\n\n**Leibhéil bhailí:** none, minimal, low, medium, high, xhigh, max, ultra\n**Taispeáint:** show, hide\n**Coinnigh:** cuir `--global` leis chun sábháil thar an seisiún seo"
|
||||
picker_title: "🧠 **Socruithe Réasúnaíochta**\\n\\n**Iarracht:** `{level}`\\n**Scóip:** {scope}\\n**Taispeáint:** {display}\\n\\nRoghnaigh rogha:"
|
||||
choice_none: "none — díchumasaigh réasúnaíocht"
|
||||
choice_reset: "reset — glan sárú an tseisiúin"
|
||||
choice_show: "taispeáin réasúnaíocht sna freagraí"
|
||||
choice_hide: "folaigh réasúnaíocht ó na freagraí"
|
||||
set_global: "🧠 ✓ Iarracht réasúnaíochta socraithe go `{effort}` (sábháilte sa chumraíocht)\n_(éifeachtach ón gcéad teachtaireacht eile)_"
|
||||
set_global_save_failed: "🧠 ✓ Iarracht réasúnaíochta socraithe go `{effort}` (seisiún amháin — theip ar shábháil cumraíochta)\n_(éifeachtach ón gcéad teachtaireacht eile)_"
|
||||
set_session: "🧠 ✓ Iarracht réasúnaíochta socraithe go `{effort}` (seisiún amháin — cuir `--global` leis chun é a choinneáil)\n_(éifeachtach ón gcéad teachtaireacht eile)_"
|
||||
|
|
|
|||
|
|
@ -124,6 +124,9 @@ gateway:
|
|||
label_normal: "NORMAL"
|
||||
status_fast: "fast"
|
||||
status_normal: "normal"
|
||||
picker_title: "⚡ **Priority Processing**\\n\\nJelenlegi mód: `{mode}`\\n\\nVálassz egy opciót:"
|
||||
choice_fast: "fast — Priority Processing bekapcsolva"
|
||||
choice_normal: "normal — normál feldolgozás"
|
||||
|
||||
footer:
|
||||
status: "📎 Futási idejű lábléc: **{state}**\nMezők: `{fields}`\nPlatform: `{platform}`"
|
||||
|
|
@ -194,6 +197,11 @@ gateway:
|
|||
reset_global_unsupported: "⚠️ A `/reasoning reset --global` nem támogatott. Használd a `/reasoning <level> --global` parancsot a globális alapérték módosításához."
|
||||
reset_done: "🧠 ✓ A munkamenet gondolkodási felülbírálása törölve; visszaállás a globális konfigurációra."
|
||||
unknown_arg: "⚠️ Ismeretlen argumentum: `{arg}`\n\n**Érvényes szintek:** none, minimal, low, medium, high, xhigh, max, ultra\n**Megjelenítés:** show, hide\n**Megőrzés:** add hozzá a `--global` opciót a munkameneten túli mentéshez"
|
||||
picker_title: "🧠 **Gondolkodási beállítások**\\n\\n**Erőfeszítés:** `{level}`\\n**Hatókör:** {scope}\\n**Megjelenítés:** {display}\\n\\nVálassz egy opciót:"
|
||||
choice_none: "none — gondolkodás kikapcsolása"
|
||||
choice_reset: "reset — munkameneti felülbírálás törlése"
|
||||
choice_show: "gondolkodás megjelenítése a válaszokban"
|
||||
choice_hide: "gondolkodás elrejtése a válaszokból"
|
||||
set_global: "🧠 ✓ Gondolkodási erőfeszítés beállítva: `{effort}` (mentve a konfigurációba)\n_(a következő üzenettől lép életbe)_"
|
||||
set_global_save_failed: "🧠 ✓ Gondolkodási erőfeszítés beállítva: `{effort}` (csak ebben a munkamenetben — a konfiguráció mentése sikertelen)\n_(a következő üzenettől lép életbe)_"
|
||||
set_session: "🧠 ✓ Gondolkodási erőfeszítés beállítva: `{effort}` (csak ebben a munkamenetben — add hozzá a `--global` opciót a megőrzéshez)\n_(a következő üzenettől lép életbe)_"
|
||||
|
|
|
|||
|
|
@ -124,6 +124,9 @@ gateway:
|
|||
label_normal: "NORMAL"
|
||||
status_fast: "fast"
|
||||
status_normal: "normal"
|
||||
picker_title: "⚡ **Priority Processing**\\n\\nModalità attuale: `{mode}`\\n\\nScegli un\\'opzione:"
|
||||
choice_fast: "fast — Priority Processing attivo"
|
||||
choice_normal: "normal — elaborazione standard"
|
||||
|
||||
footer:
|
||||
status: "📎 Footer di runtime: **{state}**\nCampi: `{fields}`\nPiattaforma: `{platform}`"
|
||||
|
|
@ -194,6 +197,11 @@ gateway:
|
|||
reset_global_unsupported: "⚠️ `/reasoning reset --global` non è supportato. Usa `/reasoning <level> --global` per cambiare il valore predefinito globale."
|
||||
reset_done: "🧠 ✓ Override di reasoning della sessione cancellato; ripristino della configurazione globale."
|
||||
unknown_arg: "⚠️ Argomento sconosciuto: `{arg}`\n\n**Livelli validi:** none, minimal, low, medium, high, xhigh, max, ultra\n**Visualizzazione:** show, hide\n**Persistenza:** aggiungi `--global` per salvare oltre questa sessione"
|
||||
picker_title: "🧠 **Impostazioni di reasoning**\\n\\n**Sforzo:** `{level}`\\n**Ambito:** {scope}\\n**Visualizzazione:** {display}\\n\\nScegli un\\'opzione:"
|
||||
choice_none: "none — disattiva il reasoning"
|
||||
choice_reset: "reset — cancella l'override di sessione"
|
||||
choice_show: "mostra il reasoning nelle risposte"
|
||||
choice_hide: "nascondi il reasoning dalle risposte"
|
||||
set_global: "🧠 ✓ Sforzo di reasoning impostato su `{effort}` (salvato nella configurazione)\n_(verrà applicato al prossimo messaggio)_"
|
||||
set_global_save_failed: "🧠 ✓ Sforzo di reasoning impostato su `{effort}` (solo per questa sessione — salvataggio della configurazione non riuscito)\n_(verrà applicato al prossimo messaggio)_"
|
||||
set_session: "🧠 ✓ Sforzo di reasoning impostato su `{effort}` (solo per questa sessione — aggiungi `--global` per renderlo permanente)\n_(verrà applicato al prossimo messaggio)_"
|
||||
|
|
|
|||
|
|
@ -124,6 +124,9 @@ gateway:
|
|||
label_normal: "NORMAL"
|
||||
status_fast: "fast"
|
||||
status_normal: "normal"
|
||||
picker_title: "⚡ **Priority Processing**\\n\\n現在のモード: `{mode}`\\n\\nオプションを選択:"
|
||||
choice_fast: "fast — Priority Processing オン"
|
||||
choice_normal: "normal — 標準処理"
|
||||
|
||||
footer:
|
||||
status: "📎 ランタイムフッター: **{state}**\nフィールド: `{fields}`\nプラットフォーム: `{platform}`"
|
||||
|
|
@ -194,6 +197,11 @@ gateway:
|
|||
reset_global_unsupported: "⚠️ `/reasoning reset --global` はサポートされていません。グローバルのデフォルトを変更するには `/reasoning <level> --global` を使用してください。"
|
||||
reset_done: "🧠 ✓ セッションの推論オーバーライドをクリアしました。グローバル設定にフォールバックします。"
|
||||
unknown_arg: "⚠️ 不明な引数: `{arg}`\n\n**有効なレベル:** none, minimal, low, medium, high, xhigh, max, ultra\n**表示:** show, hide\n**永続化:** セッションを越えて保存するには `--global` を追加"
|
||||
picker_title: "🧠 **推論設定**\\n\\n**強度:** `{level}`\\n**スコープ:** {scope}\\n**表示:** {display}\\n\\nオプションを選択:"
|
||||
choice_none: "none — 推論を無効化"
|
||||
choice_reset: "reset — セッション上書きをクリア"
|
||||
choice_show: "返信に推論を表示"
|
||||
choice_hide: "返信の推論を非表示"
|
||||
set_global: "🧠 ✓ 推論強度を `{effort}` に設定しました (設定に保存)\n_(次のメッセージから有効)_"
|
||||
set_global_save_failed: "🧠 ✓ 推論強度を `{effort}` に設定しました (セッションのみ — 設定の保存に失敗)\n_(次のメッセージから有効)_"
|
||||
set_session: "🧠 ✓ 推論強度を `{effort}` に設定しました (セッションのみ — 永続化するには `--global` を追加)\n_(次のメッセージから有効)_"
|
||||
|
|
|
|||
|
|
@ -124,6 +124,9 @@ gateway:
|
|||
label_normal: "NORMAL"
|
||||
status_fast: "fast"
|
||||
status_normal: "normal"
|
||||
picker_title: "⚡ **Priority Processing**\\n\\n현재 모드: `{mode}`\\n\\n옵션을 선택하세요:"
|
||||
choice_fast: "fast — Priority Processing 켜기"
|
||||
choice_normal: "normal — 표준 처리"
|
||||
|
||||
footer:
|
||||
status: "📎 런타임 푸터: **{state}**\n필드: `{fields}`\n플랫폼: `{platform}`"
|
||||
|
|
@ -194,6 +197,11 @@ gateway:
|
|||
reset_global_unsupported: "⚠️ `/reasoning reset --global`은 지원되지 않습니다. 전역 기본값을 변경하려면 `/reasoning <level> --global`을 사용하세요."
|
||||
reset_done: "🧠 ✓ 세션 추론 재정의가 해제되었습니다. 전역 설정으로 돌아갑니다."
|
||||
unknown_arg: "⚠️ 알 수 없는 인수: `{arg}`\n\n**유효한 수준:** none, minimal, low, medium, high, xhigh, max, ultra\n**표시:** show, hide\n**영구화:** 이 세션을 넘어 저장하려면 `--global`을 추가하세요"
|
||||
picker_title: "🧠 **추론 설정**\\n\\n**노력:** `{level}`\\n**범위:** {scope}\\n**표시:** {display}\\n\\n옵션을 선택하세요:"
|
||||
choice_none: "none — 추론 비활성화"
|
||||
choice_reset: "reset — 세션 재정의 초기화"
|
||||
choice_show: "응답에 추론 표시"
|
||||
choice_hide: "응답에서 추론 숨기기"
|
||||
set_global: "🧠 ✓ 추론 노력이 `{effort}`(으)로 설정되었습니다 (설정에 저장됨)\n_(다음 메시지부터 적용됩니다)_"
|
||||
set_global_save_failed: "🧠 ✓ 추론 노력이 `{effort}`(으)로 설정되었습니다 (세션 한정 — 설정 저장 실패)\n_(다음 메시지부터 적용됩니다)_"
|
||||
set_session: "🧠 ✓ 추론 노력이 `{effort}`(으)로 설정되었습니다 (세션 한정 — 영구 저장하려면 `--global` 추가)\n_(다음 메시지부터 적용됩니다)_"
|
||||
|
|
|
|||
|
|
@ -124,6 +124,9 @@ gateway:
|
|||
label_normal: "NORMAL"
|
||||
status_fast: "fast"
|
||||
status_normal: "normal"
|
||||
picker_title: "⚡ **Priority Processing**\\n\\nModo atual: `{mode}`\\n\\nEscolha uma opção:"
|
||||
choice_fast: "fast — Priority Processing ativado"
|
||||
choice_normal: "normal — processamento padrão"
|
||||
|
||||
footer:
|
||||
status: "📎 Rodapé de execução: **{state}**\nCampos: `{fields}`\nPlataforma: `{platform}`"
|
||||
|
|
@ -194,6 +197,11 @@ gateway:
|
|||
reset_global_unsupported: "⚠️ `/reasoning reset --global` não é suportado. Usa `/reasoning <level> --global` para alterar o predefinido global."
|
||||
reset_done: "🧠 ✓ Substituição de raciocínio da sessão removida; a regressar à configuração global."
|
||||
unknown_arg: "⚠️ Argumento desconhecido: `{arg}`\n\n**Níveis válidos:** none, minimal, low, medium, high, xhigh, max, ultra\n**Visualização:** show, hide\n**Persistir:** adiciona `--global` para guardar para além desta sessão"
|
||||
picker_title: "🧠 **Definições de raciocínio**\\n\\n**Esforço:** `{level}`\\n**Âmbito:** {scope}\\n**Visualização:** {display}\\n\\nEscolha uma opção:"
|
||||
choice_none: "none — desativar raciocínio"
|
||||
choice_reset: "reset — limpar o override de sessão"
|
||||
choice_show: "mostrar raciocínio nas respostas"
|
||||
choice_hide: "ocultar raciocínio das respostas"
|
||||
set_global: "🧠 ✓ Esforço de raciocínio definido como `{effort}` (guardado na configuração)\n_(produz efeito na próxima mensagem)_"
|
||||
set_global_save_failed: "🧠 ✓ Esforço de raciocínio definido como `{effort}` (apenas sessão — falha ao guardar a configuração)\n_(produz efeito na próxima mensagem)_"
|
||||
set_session: "🧠 ✓ Esforço de raciocínio definido como `{effort}` (apenas sessão — adiciona `--global` para persistir)\n_(produz efeito na próxima mensagem)_"
|
||||
|
|
|
|||
|
|
@ -124,6 +124,9 @@ gateway:
|
|||
label_normal: "NORMAL"
|
||||
status_fast: "fast"
|
||||
status_normal: "normal"
|
||||
picker_title: "⚡ **Priority Processing**\\n\\nТекущий режим: `{mode}`\\n\\nВыберите вариант:"
|
||||
choice_fast: "fast — Priority Processing включён"
|
||||
choice_normal: "normal — стандартная обработка"
|
||||
|
||||
footer:
|
||||
status: "📎 Нижний колонтитул среды выполнения: **{state}**\nПоля: `{fields}`\nПлатформа: `{platform}`"
|
||||
|
|
@ -194,6 +197,11 @@ gateway:
|
|||
reset_global_unsupported: "⚠️ `/reasoning reset --global` не поддерживается. Используйте `/reasoning <level> --global`, чтобы изменить глобальное значение по умолчанию."
|
||||
reset_done: "🧠 ✓ Переопределение рассуждений для сеанса сброшено; возврат к глобальной конфигурации."
|
||||
unknown_arg: "⚠️ Неизвестный аргумент: `{arg}`\n\n**Допустимые уровни:** none, minimal, low, medium, high, xhigh, max, ultra\n**Отображение:** show, hide\n**Сохранение:** добавьте `--global`, чтобы сохранить за пределами этого сеанса"
|
||||
picker_title: "🧠 **Настройки рассуждений**\\n\\n**Усилия:** `{level}`\\n**Область:** {scope}\\n**Отображение:** {display}\\n\\nВыберите вариант:"
|
||||
choice_none: "none — отключить рассуждения"
|
||||
choice_reset: "reset — сбросить переопределение сессии"
|
||||
choice_show: "показывать рассуждения в ответах"
|
||||
choice_hide: "скрывать рассуждения в ответах"
|
||||
set_global: "🧠 ✓ Усилия рассуждений установлены на `{effort}` (сохранено в конфигурации)\n_(вступит в силу со следующего сообщения)_"
|
||||
set_global_save_failed: "🧠 ✓ Усилия рассуждений установлены на `{effort}` (только этот сеанс — не удалось сохранить конфигурацию)\n_(вступит в силу со следующего сообщения)_"
|
||||
set_session: "🧠 ✓ Усилия рассуждений установлены на `{effort}` (только этот сеанс — добавьте `--global`, чтобы сохранить)\n_(вступит в силу со следующего сообщения)_"
|
||||
|
|
|
|||
|
|
@ -124,6 +124,9 @@ gateway:
|
|||
label_normal: "NORMAL"
|
||||
status_fast: "fast"
|
||||
status_normal: "normal"
|
||||
picker_title: "⚡ **Priority Processing**\\n\\nMevcut mod: `{mode}`\\n\\nBir seçenek seçin:"
|
||||
choice_fast: "fast — Priority Processing açık"
|
||||
choice_normal: "normal — standart işleme"
|
||||
|
||||
footer:
|
||||
status: "📎 Çalışma zamanı altbilgisi: **{state}**\nAlanlar: `{fields}`\nPlatform: `{platform}`"
|
||||
|
|
@ -194,6 +197,11 @@ gateway:
|
|||
reset_global_unsupported: "⚠️ `/reasoning reset --global` desteklenmiyor. Genel varsayılanı değiştirmek için `/reasoning <level> --global` kullanın."
|
||||
reset_done: "🧠 ✓ Oturumun akıl yürütme geçersiz kılması temizlendi; genel yapılandırmaya geri dönülüyor."
|
||||
unknown_arg: "⚠️ Bilinmeyen argüman: `{arg}`\n\n**Geçerli seviyeler:** none, minimal, low, medium, high, xhigh, max, ultra\n**Görüntüleme:** show, hide\n**Kalıcı:** bu oturumun ötesinde kaydetmek için `--global` ekleyin"
|
||||
picker_title: "🧠 **Akıl Yürütme Ayarları**\\n\\n**Güç:** `{level}`\\n**Kapsam:** {scope}\\n**Görüntüleme:** {display}\\n\\nBir seçenek seçin:"
|
||||
choice_none: "none — akıl yürütmeyi kapat"
|
||||
choice_reset: "reset — oturum geçersiz kılmasını temizle"
|
||||
choice_show: "yanıtlarda akıl yürütmeyi göster"
|
||||
choice_hide: "yanıtlarda akıl yürütmeyi gizle"
|
||||
set_global: "🧠 ✓ Akıl yürütme gücü `{effort}` olarak ayarlandı (yapılandırmaya kaydedildi)\n_(sonraki mesajda etkili)_"
|
||||
set_global_save_failed: "🧠 ✓ Akıl yürütme gücü `{effort}` olarak ayarlandı (yalnızca bu oturum — yapılandırma kaydedilemedi)\n_(sonraki mesajda etkili)_"
|
||||
set_session: "🧠 ✓ Akıl yürütme gücü `{effort}` olarak ayarlandı (yalnızca bu oturum — kalıcı yapmak için `--global` ekleyin)\n_(sonraki mesajda etkili)_"
|
||||
|
|
|
|||
|
|
@ -124,6 +124,9 @@ gateway:
|
|||
label_normal: "NORMAL"
|
||||
status_fast: "fast"
|
||||
status_normal: "normal"
|
||||
picker_title: "⚡ **Priority Processing**\\n\\nПоточний режим: `{mode}`\\n\\nОберіть варіант:"
|
||||
choice_fast: "fast — Priority Processing увімкнено"
|
||||
choice_normal: "normal — стандартна обробка"
|
||||
|
||||
footer:
|
||||
status: "📎 Нижній колонтитул середовища: **{state}**\nПоля: `{fields}`\nПлатформа: `{platform}`"
|
||||
|
|
@ -194,6 +197,11 @@ gateway:
|
|||
reset_global_unsupported: "⚠️ `/reasoning reset --global` не підтримується. Використовуйте `/reasoning <level> --global`, щоб змінити глобальне значення за замовчуванням."
|
||||
reset_done: "🧠 ✓ Перевизначення мислення для сеансу скинуто; повернення до глобальної конфігурації."
|
||||
unknown_arg: "⚠️ Невідомий аргумент: `{arg}`\n\n**Дійсні рівні:** none, minimal, low, medium, high, xhigh, max, ultra\n**Показ:** show, hide\n**Зберегти:** додайте `--global`, щоб зберегти поза цим сеансом"
|
||||
picker_title: "🧠 **Налаштування мислення**\\n\\n**Зусилля:** `{level}`\\n**Область:** {scope}\\n**Показ:** {display}\\n\\nОберіть варіант:"
|
||||
choice_none: "none — вимкнути мислення"
|
||||
choice_reset: "reset — скинути перевизначення сесії"
|
||||
choice_show: "показувати мислення у відповідях"
|
||||
choice_hide: "приховувати мислення у відповідях"
|
||||
set_global: "🧠 ✓ Зусилля мислення встановлено на `{effort}` (збережено в конфігурації)\n_(набуде чинності з наступного повідомлення)_"
|
||||
set_global_save_failed: "🧠 ✓ Зусилля мислення встановлено на `{effort}` (лише цей сеанс — не вдалося зберегти конфігурацію)\n_(набуде чинності з наступного повідомлення)_"
|
||||
set_session: "🧠 ✓ Зусилля мислення встановлено на `{effort}` (лише цей сеанс — додайте `--global`, щоб зберегти)\n_(набуде чинності з наступного повідомлення)_"
|
||||
|
|
|
|||
|
|
@ -124,6 +124,9 @@ gateway:
|
|||
label_normal: "NORMAL"
|
||||
status_fast: "fast"
|
||||
status_normal: "normal"
|
||||
picker_title: "⚡ **Priority Processing**\\n\\n目前模式:`{mode}`\\n\\n請選擇:"
|
||||
choice_fast: "fast — 開啟 Priority Processing"
|
||||
choice_normal: "normal — 標準處理"
|
||||
|
||||
footer:
|
||||
status: "📎 執行階段頁尾:**{state}**\n欄位:`{fields}`\n平台:`{platform}`"
|
||||
|
|
@ -194,6 +197,11 @@ gateway:
|
|||
reset_global_unsupported: "⚠️ 不支援 `/reasoning reset --global`。請使用 `/reasoning <level> --global` 變更全域預設值。"
|
||||
reset_done: "🧠 ✓ 已清除本工作階段的推理覆寫;回退至全域設定。"
|
||||
unknown_arg: "⚠️ 未知參數:`{arg}`\n\n**有效級別:** none, minimal, low, medium, high, xhigh, max, ultra\n**顯示:** show, hide\n**持久化:** 加上 `--global` 可跨工作階段儲存"
|
||||
picker_title: "🧠 **推理設定**\\n\\n**強度:** `{level}`\\n**範圍:** {scope}\\n**顯示:** {display}\\n\\n請選擇:"
|
||||
choice_none: "none — 停用推理"
|
||||
choice_reset: "reset — 清除工作階段覆寫"
|
||||
choice_show: "在回覆中顯示推理"
|
||||
choice_hide: "在回覆中隱藏推理"
|
||||
set_global: "🧠 ✓ 推理強度已設定為 `{effort}`(已儲存到設定)\n_(下一則訊息生效)_"
|
||||
set_global_save_failed: "🧠 ✓ 推理強度已設定為 `{effort}`(僅本工作階段 — 設定儲存失敗)\n_(下一則訊息生效)_"
|
||||
set_session: "🧠 ✓ 推理強度已設定為 `{effort}`(僅本工作階段 — 加上 `--global` 可持久化)\n_(下一則訊息生效)_"
|
||||
|
|
|
|||
|
|
@ -124,6 +124,9 @@ gateway:
|
|||
label_normal: "NORMAL"
|
||||
status_fast: "fast"
|
||||
status_normal: "normal"
|
||||
picker_title: "⚡ **优先处理**\\n\\n当前模式:`{mode}`\\n\\n请选择:"
|
||||
choice_fast: "fast — 开启优先处理"
|
||||
choice_normal: "normal — 标准处理"
|
||||
|
||||
footer:
|
||||
status: "📎 运行时页脚:**{state}**\n字段:`{fields}`\n平台:`{platform}`"
|
||||
|
|
@ -194,6 +197,11 @@ gateway:
|
|||
reset_global_unsupported: "⚠️ 不支持 `/reasoning reset --global`。请使用 `/reasoning <level> --global` 修改全局默认值。"
|
||||
reset_done: "🧠 ✓ 已清除本会话的推理覆盖;回退到全局配置。"
|
||||
unknown_arg: "⚠️ 未知参数:`{arg}`\n\n**有效级别:** none, minimal, low, medium, high, xhigh, max, ultra\n**显示:** show, hide\n**持久化:** 添加 `--global` 以跨会话保存"
|
||||
picker_title: "🧠 **推理设置**\\n\\n**强度:** `{level}`\\n**作用域:** {scope}\\n**显示:** {display}\\n\\n请选择:"
|
||||
choice_none: "none — 关闭推理"
|
||||
choice_reset: "reset — 清除会话覆盖"
|
||||
choice_show: "在回复中显示推理"
|
||||
choice_hide: "在回复中隐藏推理"
|
||||
set_global: "🧠 ✓ 推理强度已设置为 `{effort}`(已保存到配置)\n_(下一条消息生效)_"
|
||||
set_global_save_failed: "🧠 ✓ 推理强度已设置为 `{effort}`(仅本会话 — 配置保存失败)\n_(下一条消息生效)_"
|
||||
set_session: "🧠 ✓ 推理强度已设置为 `{effort}`(仅本会话 — 添加 `--global` 以持久化)\n_(下一条消息生效)_"
|
||||
|
|
|
|||
|
|
@ -5961,6 +5961,54 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
logger.warning("[%s] send_model_picker failed: %s", self.name, e)
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def send_choice_picker(
|
||||
self,
|
||||
chat_id: str,
|
||||
title: str,
|
||||
choices: list,
|
||||
session_key: str,
|
||||
on_choice_selected,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send a flat select-menu choice picker (one selection → one value).
|
||||
|
||||
Generic single-level companion to ``send_model_picker`` used by
|
||||
`/reasoning`, `/fast`, and any future finite-choice command. Each
|
||||
choice dict: ``{"value": str, "label": str, "is_current": bool}``.
|
||||
"""
|
||||
if not self._client or not DISCORD_AVAILABLE:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
try:
|
||||
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))
|
||||
|
||||
embed = discord.Embed(
|
||||
title="⚙ " + (title.splitlines()[0] if title else "Choose an option"),
|
||||
description="\n".join(title.splitlines()[1:]) or None,
|
||||
color=discord.Color.blue(),
|
||||
)
|
||||
|
||||
view = ChoicePickerView(
|
||||
choices=choices,
|
||||
on_choice_selected=on_choice_selected,
|
||||
allowed_user_ids=self._allowed_user_ids,
|
||||
allowed_role_ids=self._allowed_role_ids,
|
||||
)
|
||||
|
||||
msg = await channel.send(embed=embed, view=view)
|
||||
view._message = msg # store for on_timeout expiration editing
|
||||
return SendResult(success=True, message_id=str(msg.id))
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("[%s] send_choice_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)
|
||||
|
|
@ -6850,7 +6898,7 @@ def _define_discord_view_classes() -> None:
|
|||
lazy install sets DISCORD_AVAILABLE=True but leaves the classes
|
||||
undefined, causing NameError on the first button interaction.
|
||||
"""
|
||||
global ExecApprovalView, SlashConfirmView, UpdatePromptView, ModelPickerView, ClarifyChoiceView
|
||||
global ExecApprovalView, SlashConfirmView, UpdatePromptView, ModelPickerView, ClarifyChoiceView, ChoicePickerView
|
||||
|
||||
class ExecApprovalView(discord.ui.View):
|
||||
"""
|
||||
|
|
@ -7547,6 +7595,97 @@ def _define_discord_view_classes() -> None:
|
|||
pass
|
||||
|
||||
|
||||
class ChoicePickerView(discord.ui.View):
|
||||
"""Flat select-menu view for finite-choice commands (/reasoning, /fast).
|
||||
|
||||
One dropdown, one selection, done — the generic single-level companion
|
||||
to ``ModelPickerView``. Auth gating mirrors ``ExecApprovalView``.
|
||||
Times out after 2 minutes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
choices: list,
|
||||
on_choice_selected,
|
||||
allowed_user_ids: set,
|
||||
allowed_role_ids: Optional[set] = None,
|
||||
):
|
||||
super().__init__(timeout=120)
|
||||
self.choices = list(choices)[:25] # Discord select cap
|
||||
self.on_choice_selected = on_choice_selected
|
||||
self.allowed_user_ids = allowed_user_ids
|
||||
self.allowed_role_ids = allowed_role_ids or set()
|
||||
self.resolved = False
|
||||
self._message = None
|
||||
|
||||
options = []
|
||||
for choice in self.choices:
|
||||
label = str(choice.get("label") or choice.get("value") or "")
|
||||
options.append(
|
||||
discord.SelectOption(
|
||||
label=_truncate_discord_component_text(
|
||||
label, _DISCORD_SELECT_FIELD_LIMIT
|
||||
),
|
||||
value=str(choice.get("value") or ""),
|
||||
description="current" if choice.get("is_current") else None,
|
||||
)
|
||||
)
|
||||
select = discord.ui.Select(
|
||||
placeholder="Choose an option...",
|
||||
options=options,
|
||||
)
|
||||
select.callback = self._on_select
|
||||
self.add_item(select)
|
||||
|
||||
def _check_auth(self, interaction: discord.Interaction) -> bool:
|
||||
return _component_check_auth(
|
||||
interaction, self.allowed_user_ids, self.allowed_role_ids,
|
||||
)
|
||||
|
||||
async def _on_select(self, interaction: discord.Interaction):
|
||||
if not self._check_auth(interaction):
|
||||
await interaction.response.send_message(
|
||||
"⛔ You are not authorized to change this setting.",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
if self.resolved:
|
||||
await interaction.response.defer()
|
||||
return
|
||||
self.resolved = True
|
||||
|
||||
value = interaction.data.get("values", [""])[0]
|
||||
try:
|
||||
result_text = await self.on_choice_selected(
|
||||
str(interaction.channel_id), value
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Choice picker selection failed: %s", exc)
|
||||
result_text = f"Error applying selection: {exc}"
|
||||
|
||||
embed = discord.Embed(
|
||||
description=result_text,
|
||||
color=discord.Color.green(),
|
||||
)
|
||||
self.clear_items()
|
||||
self.stop()
|
||||
await interaction.response.edit_message(embed=embed, view=self)
|
||||
|
||||
async def on_timeout(self):
|
||||
if self.resolved:
|
||||
return
|
||||
msg = self._message
|
||||
if msg is not None:
|
||||
try:
|
||||
embed = discord.Embed(
|
||||
description="⏱ Selection expired — no change made.",
|
||||
color=discord.Color.greyple(),
|
||||
)
|
||||
self.clear_items()
|
||||
await msg.edit(embed=embed, view=self)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
class ClarifyChoiceView(discord.ui.View):
|
||||
"""Interactive button view for the clarify tool's multiple-choice prompts.
|
||||
|
||||
|
|
|
|||
|
|
@ -337,6 +337,21 @@ class _MatrixModelPickerPrompt:
|
|||
bot_reaction_events: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _MatrixChoicePickerPrompt:
|
||||
"""Tracks a pending Matrix reaction-based choice picker (/reasoning, /fast)."""
|
||||
|
||||
chat_id: str
|
||||
message_id: str
|
||||
session_key: str
|
||||
choices: dict[str, str] # emoji -> value
|
||||
on_choice_selected: Any
|
||||
requester_user_id: str | None = None
|
||||
expires_at: float | None = None
|
||||
resolved: bool = False
|
||||
bot_reaction_events: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
# Matrix message size limit (4000 chars practical, spec has no hard limit
|
||||
# but clients render poorly above this).
|
||||
MAX_MESSAGE_LENGTH = 4000
|
||||
|
|
@ -386,6 +401,14 @@ _MATRIX_MODEL_PICKER_REACTIONS = (
|
|||
"\U0001f51f",
|
||||
)
|
||||
|
||||
# Choice pickers (/reasoning, /fast) can need more than 10 slots
|
||||
# (8 effort levels + none + reset/show/hide = 12), so extend the keycap
|
||||
# set with lettered squares.
|
||||
_MATRIX_CHOICE_PICKER_REACTIONS = _MATRIX_MODEL_PICKER_REACTIONS + (
|
||||
"\U0001f170\ufe0f", # 🅰️
|
||||
"\U0001f171\ufe0f", # 🅱️
|
||||
)
|
||||
|
||||
_MATRIX_CAPABILITIES: Dict[str, str] = {
|
||||
"text": "yes",
|
||||
"threads": "yes",
|
||||
|
|
@ -954,6 +977,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
|||
except ValueError:
|
||||
self._approval_timeout_seconds = 300
|
||||
self._model_picker_prompts_by_event: Dict[str, _MatrixModelPickerPrompt] = {}
|
||||
self._choice_picker_prompts_by_event: Dict[str, _MatrixChoicePickerPrompt] = {}
|
||||
allowed_users_raw = os.getenv("MATRIX_ALLOWED_USERS", "")
|
||||
self._allowed_user_ids: Set[str] = {
|
||||
u.strip() for u in allowed_users_raw.split(",") if u.strip()
|
||||
|
|
@ -2136,6 +2160,66 @@ class MatrixAdapter(BasePlatformAdapter):
|
|||
|
||||
return result
|
||||
|
||||
async def send_choice_picker(
|
||||
self,
|
||||
chat_id: str,
|
||||
title: str,
|
||||
choices: list,
|
||||
session_key: str,
|
||||
on_choice_selected,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send a Matrix reaction-based choice picker (/reasoning, /fast).
|
||||
|
||||
Generic single-level companion to ``send_model_picker``. Each choice
|
||||
dict: ``{"value": str, "label": str, "is_current": bool}``.
|
||||
"""
|
||||
if not self._client:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
emoji_choices: dict[str, str] = {}
|
||||
lines = [title, ""]
|
||||
for i, choice in enumerate(choices):
|
||||
if i >= len(_MATRIX_CHOICE_PICKER_REACTIONS):
|
||||
break
|
||||
emoji = _MATRIX_CHOICE_PICKER_REACTIONS[i]
|
||||
value = str(choice.get("value") or "")
|
||||
label = str(choice.get("label") or value)
|
||||
if choice.get("is_current"):
|
||||
label = f"{label} ← current"
|
||||
emoji_choices[emoji] = value
|
||||
lines.append(f"{emoji} {label}")
|
||||
|
||||
if not emoji_choices:
|
||||
return SendResult(success=False, error="No choices")
|
||||
|
||||
lines.append("")
|
||||
lines.append("React to choose.")
|
||||
result = await self.send(chat_id, "\n".join(lines), metadata=metadata)
|
||||
if not result.success or not result.message_id:
|
||||
return result
|
||||
|
||||
prompt = _MatrixChoicePickerPrompt(
|
||||
chat_id=chat_id,
|
||||
message_id=result.message_id,
|
||||
session_key=session_key,
|
||||
choices=emoji_choices,
|
||||
on_choice_selected=on_choice_selected,
|
||||
requester_user_id=str((metadata or {}).get("requester_user_id") or "") or None,
|
||||
expires_at=time.monotonic() + max(self._approval_timeout_seconds, 0),
|
||||
)
|
||||
self._choice_picker_prompts_by_event[result.message_id] = prompt
|
||||
|
||||
for emoji in emoji_choices:
|
||||
try:
|
||||
reaction_event_id = await self._send_reaction(chat_id, result.message_id, emoji)
|
||||
if reaction_event_id:
|
||||
prompt.bot_reaction_events[emoji] = str(reaction_event_id)
|
||||
except Exception as exc:
|
||||
logger.debug("Matrix: failed to add choice picker reaction %s: %s", emoji, exc)
|
||||
|
||||
return result
|
||||
|
||||
def format_message(self, content: str) -> str:
|
||||
"""Pass-through — Matrix supports standard Markdown natively."""
|
||||
# Strip image markdown; media is uploaded separately.
|
||||
|
|
@ -3328,6 +3412,40 @@ class MatrixAdapter(BasePlatformAdapter):
|
|||
)
|
||||
return
|
||||
|
||||
choice_prompt = self._choice_picker_prompts_by_event.get(reacts_to)
|
||||
if choice_prompt and not choice_prompt.resolved:
|
||||
if room_id != choice_prompt.chat_id:
|
||||
return
|
||||
if self._matrix_prompt_expired(choice_prompt):
|
||||
self._choice_picker_prompts_by_event.pop(reacts_to, None)
|
||||
return
|
||||
if not await self._validate_matrix_prompt_reactor(
|
||||
room_id, reacts_to, sender, choice_prompt, "choice picker"
|
||||
):
|
||||
return
|
||||
value = choice_prompt.choices.get(key)
|
||||
if value is None:
|
||||
await self._send_invalid_reaction_feedback(
|
||||
room_id,
|
||||
reacts_to,
|
||||
"That reaction is not one of the available choices.",
|
||||
)
|
||||
return
|
||||
choice_prompt.resolved = True
|
||||
self._choice_picker_prompts_by_event.pop(reacts_to, None)
|
||||
try:
|
||||
confirmation = await choice_prompt.on_choice_selected(room_id, value)
|
||||
if confirmation:
|
||||
await self.send(room_id, confirmation, reply_to=reacts_to)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to apply choice from Matrix reaction: %s", exc)
|
||||
await self.send(
|
||||
room_id,
|
||||
f"Failed to apply selection: {exc}",
|
||||
reply_to=reacts_to,
|
||||
)
|
||||
return
|
||||
|
||||
def _matrix_prompt_expired(self, prompt: Any) -> bool:
|
||||
expires_at = getattr(prompt, "expires_at", None)
|
||||
return expires_at is not None and time.monotonic() > float(expires_at)
|
||||
|
|
|
|||
|
|
@ -778,6 +778,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
)
|
||||
# Interactive model picker state per chat
|
||||
self._model_picker_state: Dict[str, dict] = {}
|
||||
self._choice_picker_state: Dict[str, dict] = {}
|
||||
# Approval button state: message_id → session_key
|
||||
self._approval_state: Dict[int, str] = {}
|
||||
# Slash-confirm button state: confirm_id → session_key (for /reload-mcp
|
||||
|
|
@ -5213,6 +5214,128 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
return SendResult(success=False, error=_redact_telegram_error_text(e))
|
||||
|
||||
_PROVIDER_PAGE_SIZE = 10
|
||||
|
||||
async def send_choice_picker(
|
||||
self,
|
||||
chat_id: str,
|
||||
title: str,
|
||||
choices: list,
|
||||
session_key: str,
|
||||
on_choice_selected,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send a flat inline-keyboard choice picker (one tap → one value).
|
||||
|
||||
Generic single-level companion to ``send_model_picker`` used by
|
||||
`/reasoning`, `/fast`, and any future finite-choice command. Each
|
||||
choice dict: ``{"value": str, "label": str, "is_current": bool}``.
|
||||
"""
|
||||
if not self._bot:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
try:
|
||||
buttons = []
|
||||
for i, choice in enumerate(choices):
|
||||
label = str(choice.get("label") or choice.get("value") or "")
|
||||
if choice.get("is_current"):
|
||||
label = f"✓ {label}"
|
||||
buttons.append(
|
||||
InlineKeyboardButton(label, callback_data=f"cp:{i}")
|
||||
)
|
||||
if not buttons:
|
||||
return SendResult(success=False, error="No choices")
|
||||
# Two buttons per row keeps labels readable on mobile.
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
[buttons[i:i + 2] for i in range(0, len(buttons), 2)]
|
||||
)
|
||||
|
||||
thread_id = metadata.get("thread_id") if metadata else None
|
||||
reply_to_id = self._reply_to_message_id_for_send(None, metadata, reply_to_mode=self._reply_to_mode)
|
||||
msg = await self._send_message_with_thread_fallback(
|
||||
chat_id=normalize_telegram_chat_id(chat_id),
|
||||
text=self.format_message(title),
|
||||
parse_mode=ParseMode.MARKDOWN_V2,
|
||||
reply_markup=keyboard,
|
||||
reply_to_message_id=reply_to_id,
|
||||
**self._thread_kwargs_for_send(
|
||||
chat_id,
|
||||
thread_id,
|
||||
metadata,
|
||||
reply_to_message_id=reply_to_id,
|
||||
reply_to_mode=self._reply_to_mode
|
||||
),
|
||||
**self._link_preview_kwargs(),
|
||||
)
|
||||
|
||||
self._choice_picker_state[str(chat_id)] = {
|
||||
"msg_id": msg.message_id,
|
||||
"choices": choices,
|
||||
"session_key": session_key,
|
||||
"on_choice_selected": on_choice_selected,
|
||||
}
|
||||
return SendResult(success=True, message_id=str(msg.message_id))
|
||||
except Exception as e:
|
||||
logger.warning("[%s] send_choice_picker failed: %s", self.name, _redact_telegram_error_text(e))
|
||||
return SendResult(success=False, error=_redact_telegram_error_text(e))
|
||||
|
||||
async def _handle_choice_picker_callback(
|
||||
self, query, data: str, chat_id: str
|
||||
) -> None:
|
||||
"""Handle choice picker button taps (cp:<index>)."""
|
||||
state = self._choice_picker_state.get(chat_id)
|
||||
if not state:
|
||||
await query.answer(text="Picker expired — run the command again.")
|
||||
return
|
||||
|
||||
# Same authorization gate as approval buttons: unauthorized users in a
|
||||
# shared group must not flip session/config state via someone else's
|
||||
# picker message.
|
||||
query_message = getattr(query, "message", None)
|
||||
query_chat = getattr(query_message, "chat", None)
|
||||
if not self._is_callback_user_authorized(
|
||||
str(getattr(query.from_user, "id", "")),
|
||||
chat_id=getattr(query_message, "chat_id", None),
|
||||
chat_type=str(getattr(query_chat, "type", None)) if getattr(query_chat, "type", None) is not None else None,
|
||||
thread_id=str(getattr(query_message, "message_thread_id", None)) if getattr(query_message, "message_thread_id", None) is not None else None,
|
||||
user_name=getattr(query.from_user, "first_name", None),
|
||||
):
|
||||
await query.answer(text="⛔ You are not authorized to change this setting.")
|
||||
return
|
||||
|
||||
try:
|
||||
idx = int(data[3:])
|
||||
choice = state["choices"][idx]
|
||||
except (ValueError, IndexError):
|
||||
await query.answer(text="Invalid selection.")
|
||||
return
|
||||
|
||||
callback = state.get("on_choice_selected")
|
||||
if not callback:
|
||||
await query.answer(text="Picker expired.")
|
||||
return
|
||||
|
||||
try:
|
||||
result_text = await callback(chat_id, str(choice.get("value") or ""))
|
||||
except Exception as exc:
|
||||
logger.error("Choice picker selection failed: %s", exc)
|
||||
result_text = f"Error applying selection: {exc}"
|
||||
|
||||
try:
|
||||
await query.edit_message_text(
|
||||
text=self.format_message(result_text),
|
||||
parse_mode=ParseMode.MARKDOWN_V2,
|
||||
reply_markup=None,
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
await query.edit_message_text(
|
||||
text=result_text, parse_mode=None, reply_markup=None,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await query.answer()
|
||||
self._choice_picker_state.pop(chat_id, None)
|
||||
|
||||
_MODEL_PAGE_SIZE = 8
|
||||
|
||||
def _build_provider_keyboard(self, providers: list, page: int = 0) -> tuple:
|
||||
|
|
@ -5709,6 +5832,13 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
await self._handle_model_picker_callback(query, data, chat_id)
|
||||
return
|
||||
|
||||
# --- Generic choice picker callbacks (/reasoning, /fast) ---
|
||||
if data.startswith("cp:"):
|
||||
chat_id = str(query.message.chat_id) if query.message else None
|
||||
if chat_id:
|
||||
await self._handle_choice_picker_callback(query, data, chat_id)
|
||||
return
|
||||
|
||||
# --- Gmail-triage callbacks (gt:verb:arg) ---
|
||||
if data.startswith("gt:"):
|
||||
await self._handle_gmail_triage_callback(
|
||||
|
|
|
|||
224
tests/gateway/test_choice_picker.py
Normal file
224
tests/gateway/test_choice_picker.py
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
"""Tests for the gateway interactive choice picker (/reasoning, /fast).
|
||||
|
||||
The picker mirrors the /model picker architecture: the gateway gates on the
|
||||
adapter *type* exposing ``send_choice_picker``, sends a flat choice list, and
|
||||
falls back to the text status card when the platform has no picker or the
|
||||
send fails. Selection flows through the same application path as the typed
|
||||
command, so picker and typed arguments can never diverge.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
import gateway.run as gateway_run
|
||||
from gateway.config import Platform
|
||||
from gateway.platforms.base import MessageEvent, SendResult
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
def _make_event(text="/reasoning", platform=Platform.TELEGRAM, user_id="12345", chat_id="67890"):
|
||||
source = SessionSource(
|
||||
platform=platform,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
user_name="testuser",
|
||||
)
|
||||
return MessageEvent(text=text, source=source)
|
||||
|
||||
|
||||
class _PickerAdapter:
|
||||
"""Adapter whose *type* exposes ``send_choice_picker`` (the gate the
|
||||
handler checks via ``getattr(type(adapter), 'send_choice_picker', None)``)."""
|
||||
|
||||
def __init__(self, success=True):
|
||||
self.calls = []
|
||||
self._success = success
|
||||
|
||||
async def send_choice_picker(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
return SendResult(success=self._success, message_id="m1")
|
||||
|
||||
|
||||
class _NoPickerAdapter:
|
||||
"""Adapter with no choice-picker capability."""
|
||||
|
||||
|
||||
def _make_runner(adapter=None):
|
||||
runner = object.__new__(gateway_run.GatewayRunner)
|
||||
runner.adapters = {}
|
||||
runner._ephemeral_system_prompt = ""
|
||||
runner._prefill_messages = []
|
||||
runner._reasoning_config = None
|
||||
runner._session_reasoning_overrides = {}
|
||||
runner._show_reasoning = False
|
||||
runner._provider_routing = {}
|
||||
runner._fallback_model = None
|
||||
runner._running_agents = {}
|
||||
runner.hooks = MagicMock()
|
||||
runner.hooks.emit = AsyncMock()
|
||||
runner.hooks.loaded_hooks = []
|
||||
runner._session_db = None
|
||||
runner._get_or_create_gateway_honcho = lambda session_key: (None, None)
|
||||
runner._adapter_for_source = lambda source: adapter
|
||||
runner._thread_metadata_for_source = lambda source, anchor=None: {}
|
||||
runner._reply_anchor_for_event = lambda event: None
|
||||
return runner
|
||||
|
||||
|
||||
class TestReasoningChoicePicker:
|
||||
@pytest.mark.asyncio
|
||||
async def test_bare_reasoning_sends_picker_when_adapter_supports_it(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
adapter = _PickerAdapter()
|
||||
runner = _make_runner(adapter)
|
||||
|
||||
result = await runner._handle_reasoning_command(_make_event("/reasoning"))
|
||||
|
||||
assert result is None # picker sent — adapter owns the response
|
||||
assert len(adapter.calls) == 1
|
||||
call = adapter.calls[0]
|
||||
values = [c["value"] for c in call["choices"]]
|
||||
# Full canonical ladder + none + subcommands, in order
|
||||
from hermes_constants import VALID_REASONING_EFFORTS
|
||||
assert values[0] == "none"
|
||||
assert values[1:1 + len(VALID_REASONING_EFFORTS)] == list(VALID_REASONING_EFFORTS)
|
||||
assert values[-3:] == ["reset", "show", "hide"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bare_reasoning_falls_back_to_text_without_picker(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
runner = _make_runner(_NoPickerAdapter())
|
||||
|
||||
result = await runner._handle_reasoning_command(_make_event("/reasoning"))
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "/reasoning" in result # text status card
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bare_reasoning_falls_back_to_text_when_picker_send_fails(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
adapter = _PickerAdapter(success=False)
|
||||
runner = _make_runner(adapter)
|
||||
|
||||
result = await runner._handle_reasoning_command(_make_event("/reasoning"))
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert len(adapter.calls) == 1 # attempted, then fell back
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typed_argument_never_sends_picker(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
adapter = _PickerAdapter()
|
||||
runner = _make_runner(adapter)
|
||||
|
||||
result = await runner._handle_reasoning_command(_make_event("/reasoning high"))
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert adapter.calls == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_picker_selection_applies_same_as_typed(self, tmp_path, monkeypatch):
|
||||
"""The picker's on_choice_selected must produce the identical state
|
||||
change as typing the argument (single application path)."""
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
adapter = _PickerAdapter()
|
||||
runner = _make_runner(adapter)
|
||||
event = _make_event("/reasoning")
|
||||
session_key = runner._session_key_for_source(event.source)
|
||||
|
||||
await runner._handle_reasoning_command(event)
|
||||
on_choice = adapter.calls[0]["on_choice_selected"]
|
||||
|
||||
reply = await on_choice(event.source.chat_id, "ultra")
|
||||
|
||||
assert "ultra" in reply
|
||||
override = runner._session_reasoning_overrides.get(session_key)
|
||||
assert override == {"enabled": True, "effort": "ultra"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_picker_selection_of_current_level_marks_is_current(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
yaml.safe_dump({"agent": {"reasoning_effort": "xhigh"}}), encoding="utf-8"
|
||||
)
|
||||
adapter = _PickerAdapter()
|
||||
runner = _make_runner(adapter)
|
||||
|
||||
await runner._handle_reasoning_command(_make_event("/reasoning"))
|
||||
|
||||
current = [c["value"] for c in adapter.calls[0]["choices"] if c.get("is_current")]
|
||||
assert current == ["xhigh"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_picker_show_choice_toggles_display(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
adapter = _PickerAdapter()
|
||||
runner = _make_runner(adapter)
|
||||
event = _make_event("/reasoning")
|
||||
|
||||
await runner._handle_reasoning_command(event)
|
||||
on_choice = adapter.calls[0]["on_choice_selected"]
|
||||
await on_choice(event.source.chat_id, "show")
|
||||
|
||||
assert runner._show_reasoning is True
|
||||
saved = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8"))
|
||||
assert saved["display"]["platforms"]["telegram"]["show_reasoning"] is True
|
||||
|
||||
|
||||
class TestFastChoicePicker:
|
||||
def _patch_fast_support(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {})
|
||||
monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda cfg: "gpt-5.6")
|
||||
import hermes_cli.models as models_mod
|
||||
monkeypatch.setattr(models_mod, "model_supports_fast_mode", lambda m: True)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bare_fast_sends_picker_when_adapter_supports_it(self, tmp_path, monkeypatch):
|
||||
self._patch_fast_support(monkeypatch, tmp_path)
|
||||
adapter = _PickerAdapter()
|
||||
runner = _make_runner(adapter)
|
||||
|
||||
result = await runner._handle_fast_command(_make_event("/fast"))
|
||||
|
||||
assert result is None
|
||||
values = [c["value"] for c in adapter.calls[0]["choices"]]
|
||||
assert values == ["fast", "normal"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fast_picker_selection_persists_service_tier(self, tmp_path, monkeypatch):
|
||||
self._patch_fast_support(monkeypatch, tmp_path)
|
||||
adapter = _PickerAdapter()
|
||||
runner = _make_runner(adapter)
|
||||
event = _make_event("/fast")
|
||||
|
||||
await runner._handle_fast_command(event)
|
||||
on_choice = adapter.calls[0]["on_choice_selected"]
|
||||
await on_choice(event.source.chat_id, "fast")
|
||||
|
||||
assert runner._service_tier == "priority"
|
||||
saved = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8"))
|
||||
assert saved["agent"]["service_tier"] == "fast"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bare_fast_falls_back_to_text_without_picker(self, tmp_path, monkeypatch):
|
||||
self._patch_fast_support(monkeypatch, tmp_path)
|
||||
runner = _make_runner(_NoPickerAdapter())
|
||||
|
||||
result = await runner._handle_fast_command(_make_event("/fast"))
|
||||
|
||||
assert isinstance(result, str)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typed_fast_argument_never_sends_picker(self, tmp_path, monkeypatch):
|
||||
self._patch_fast_support(monkeypatch, tmp_path)
|
||||
adapter = _PickerAdapter()
|
||||
runner = _make_runner(adapter)
|
||||
|
||||
result = await runner._handle_fast_command(_make_event("/fast normal"))
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert adapter.calls == []
|
||||
Loading…
Add table
Add a link
Reference in a new issue