From 69d619cf89b1a5ce556e2e36839a6d1a6129ddc8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:12:46 -0700 Subject: [PATCH 01/58] docs: add Hugging Face and Xiaomi MiMo to README provider list (#9406) * feat(skills): add fitness-nutrition skill to optional-skills Cherry-picked from PR #9177 by @haileymarshall. Adds a fitness and nutrition skill for gym-goers and health-conscious users: - Exercise search via wger API (690+ exercises, free, no auth) - Nutrition lookup via USDA FoodData Central (380K+ foods, DEMO_KEY fallback) - Offline body composition calculators (BMI, TDEE, 1RM, macros, body fat %) - Pure stdlib Python, no pip dependencies Changes from original PR: - Moved from skills/ to optional-skills/health/ (correct location) - Fixed BMR formula in FORMULAS.md (removed confusing -5+10, now just +5) - Fixed author attribution to match PR submitter - Marked USDA_API_KEY as optional (DEMO_KEY works without signup) Also adds optional env var support to the skill readiness checker: - New 'optional: true' field in required_environment_variables entries - Optional vars are preserved in metadata but don't block skill readiness - Optional vars skip the CLI capture prompt flow - Skills with only optional missing vars show as 'available' not 'setup_needed' * docs: add Hugging Face and Xiaomi MiMo to README provider list --------- Co-authored-by: haileymarshall --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ea0758c83..fdef1255f 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ **The self-improving AI agent built by [Nous Research](https://nousresearch.com).** It's the only agent with a built-in learning loop — it creates skills from experience, improves them during use, nudges itself to persist knowledge, searches its own past conversations, and builds a deepening model of who you are across sessions. Run it on a $5 VPS, a GPU cluster, or serverless infrastructure that costs nearly nothing when idle. It's not tied to your laptop — talk to it from Telegram while it works on a cloud VM. -Use any model you want — [Nous Portal](https://portal.nousresearch.com), [OpenRouter](https://openrouter.ai) (200+ models), [z.ai/GLM](https://z.ai), [Kimi/Moonshot](https://platform.moonshot.ai), [MiniMax](https://www.minimax.io), OpenAI, or your own endpoint. Switch with `hermes model` — no code changes, no lock-in. +Use any model you want — [Nous Portal](https://portal.nousresearch.com), [OpenRouter](https://openrouter.ai) (200+ models), [z.ai/GLM](https://z.ai), [Kimi/Moonshot](https://platform.moonshot.ai), [MiniMax](https://www.minimax.io), [Hugging Face](https://huggingface.co), [Xiaomi MiMo](https://platform.xiaomimimo.com), OpenAI, or your own endpoint. Switch with `hermes model` — no code changes, no lock-in. From e08590888a213885043fe530946744d23351614a Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:25:51 -0600 Subject: [PATCH 02/58] fix: honor interrupts during MCP tool waits --- tests/tools/test_mcp_tool.py | 73 ++++++++++++++++++++++++++++++++++++ tools/mcp_tool.py | 45 +++++++++++++++++++++- 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index 663895c0b..883bbe318 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -6,6 +6,8 @@ All tests use mocks -- no real MCP servers or subprocesses are started. import asyncio import json import os +import threading +import time from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch @@ -255,6 +257,77 @@ class TestToolHandler: finally: _servers.pop("test_srv", None) + def test_interrupted_call_returns_interrupted_error(self): + from tools.mcp_tool import _make_tool_handler, _servers + + mock_session = MagicMock() + server = _make_mock_server("test_srv", session=mock_session) + _servers["test_srv"] = server + + try: + handler = _make_tool_handler("test_srv", "greet", 120) + def _interrupting_run(coro, timeout=30): + coro.close() + raise InterruptedError("User sent a new message") + with patch( + "tools.mcp_tool._run_on_mcp_loop", + side_effect=_interrupting_run, + ): + result = json.loads(handler({})) + assert result == {"error": "MCP call interrupted: user sent a new message"} + finally: + _servers.pop("test_srv", None) + + +class TestRunOnMCPLoopInterrupts: + def test_interrupt_cancels_waiting_mcp_call(self): + import tools.mcp_tool as mcp_mod + from tools.interrupt import set_interrupt + + loop = asyncio.new_event_loop() + thread = threading.Thread(target=loop.run_forever, daemon=True) + thread.start() + + cancelled = threading.Event() + + async def _slow_call(): + try: + await asyncio.sleep(5) + return "done" + except asyncio.CancelledError: + cancelled.set() + raise + + old_loop = mcp_mod._mcp_loop + old_thread = mcp_mod._mcp_thread + mcp_mod._mcp_loop = loop + mcp_mod._mcp_thread = thread + + waiter_tid = threading.current_thread().ident + + def _interrupt_soon(): + time.sleep(0.2) + set_interrupt(True, waiter_tid) + + interrupter = threading.Thread(target=_interrupt_soon, daemon=True) + interrupter.start() + + try: + with pytest.raises(InterruptedError, match="User sent a new message"): + mcp_mod._run_on_mcp_loop(_slow_call(), timeout=2) + + deadline = time.time() + 2 + while time.time() < deadline and not cancelled.is_set(): + time.sleep(0.05) + assert cancelled.is_set() + finally: + set_interrupt(False, waiter_tid) + loop.call_soon_threadsafe(loop.stop) + thread.join(timeout=2) + loop.close() + mcp_mod._mcp_loop = old_loop + mcp_mod._mcp_thread = old_thread + # --------------------------------------------------------------------------- # Tool registration (discovery + register) diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index e953998cc..2356830c4 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -70,6 +70,7 @@ Thread safety: """ import asyncio +import concurrent.futures import inspect import json import logging @@ -1167,13 +1168,43 @@ def _ensure_mcp_loop(): def _run_on_mcp_loop(coro, timeout: float = 30): - """Schedule a coroutine on the MCP event loop and block until done.""" + """Schedule a coroutine on the MCP event loop and block until done. + + Poll in short intervals so the calling agent thread can honor user + interrupts while the MCP work is still running on the background loop. + """ + from tools.interrupt import is_interrupted + with _lock: loop = _mcp_loop if loop is None or not loop.is_running(): raise RuntimeError("MCP event loop is not running") future = asyncio.run_coroutine_threadsafe(coro, loop) - return future.result(timeout=timeout) + deadline = None if timeout is None else time.monotonic() + timeout + + while True: + if is_interrupted(): + future.cancel() + raise InterruptedError("User sent a new message") + + wait_timeout = 0.1 + if deadline is not None: + remaining = deadline - time.monotonic() + if remaining <= 0: + return future.result(timeout=0) + wait_timeout = min(wait_timeout, remaining) + + try: + return future.result(timeout=wait_timeout) + except concurrent.futures.TimeoutError: + continue + + +def _interrupted_call_result() -> str: + """Standardized JSON error for a user-interrupted MCP tool call.""" + return json.dumps({ + "error": "MCP call interrupted: user sent a new message" + }) # --------------------------------------------------------------------------- @@ -1299,6 +1330,8 @@ def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float): try: return _run_on_mcp_loop(_call(), timeout=tool_timeout) + except InterruptedError: + return _interrupted_call_result() except Exception as exc: logger.error( "MCP tool %s/%s call failed: %s", @@ -1342,6 +1375,8 @@ def _make_list_resources_handler(server_name: str, tool_timeout: float): try: return _run_on_mcp_loop(_call(), timeout=tool_timeout) + except InterruptedError: + return _interrupted_call_result() except Exception as exc: logger.error( "MCP %s/list_resources failed: %s", server_name, exc, @@ -1386,6 +1421,8 @@ def _make_read_resource_handler(server_name: str, tool_timeout: float): try: return _run_on_mcp_loop(_call(), timeout=tool_timeout) + except InterruptedError: + return _interrupted_call_result() except Exception as exc: logger.error( "MCP %s/read_resource failed: %s", server_name, exc, @@ -1433,6 +1470,8 @@ def _make_list_prompts_handler(server_name: str, tool_timeout: float): try: return _run_on_mcp_loop(_call(), timeout=tool_timeout) + except InterruptedError: + return _interrupted_call_result() except Exception as exc: logger.error( "MCP %s/list_prompts failed: %s", server_name, exc, @@ -1488,6 +1527,8 @@ def _make_get_prompt_handler(server_name: str, tool_timeout: float): try: return _run_on_mcp_loop(_call(), timeout=tool_timeout) + except InterruptedError: + return _interrupted_call_result() except Exception as exc: logger.error( "MCP %s/get_prompt failed: %s", server_name, exc, From 3de2b98503c14de1a9b23f3afafc778096c7802e Mon Sep 17 00:00:00 2001 From: Teknium Date: Mon, 13 Apr 2026 22:10:33 -0700 Subject: [PATCH 03/58] fix(streaming): filter blocks from gateway stream consumer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Models like MiniMax emit inline ... reasoning blocks in their content field. The CLI already suppresses these via a state machine in _stream_delta, but the gateway's GatewayStreamConsumer had no equivalent filtering — raw think blocks were streamed directly to Discord/Telegram/Slack. The fix adds a _filter_and_accumulate() method that mirrors the CLI's approach: a state machine tracks whether we're inside a reasoning block and silently discards the content. Includes the same block-boundary check (tag must appear at line start or after whitespace-only prefix) to avoid false positives when models mention in prose. Handles all tag variants: , , , , , . Also handles edge cases: - Tags split across streaming deltas (partial tag buffering) - Unclosed blocks (content suppressed until stream ends) - Multiple consecutive blocks - _flush_think_buffer on stream end for held-back partial tags Adds 22 unit tests + 1 integration test covering all scenarios. --- gateway/stream_consumer.py | 130 ++++++++++++++++- tests/gateway/test_stream_consumer.py | 199 ++++++++++++++++++++++++++ 2 files changed, 328 insertions(+), 1 deletion(-) diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py index 240084e9b..2107e62fd 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -64,6 +64,18 @@ class GatewayStreamConsumer: # progressive edits for the remainder of the stream. _MAX_FLOOD_STRIKES = 3 + # Reasoning/thinking tags that models emit inline in content. + # Must stay in sync with cli.py _OPEN_TAGS/_CLOSE_TAGS and + # run_agent.py _strip_think_blocks() tag variants. + _OPEN_THINK_TAGS = ( + "", "", "", + "", "", "", + ) + _CLOSE_THINK_TAGS = ( + "", "", "", + "", "", "", + ) + def __init__( self, adapter: Any, @@ -88,6 +100,10 @@ class GatewayStreamConsumer: self._current_edit_interval = self.cfg.edit_interval # Adaptive backoff self._final_response_sent = False + # Think-block filter state (mirrors CLI's _stream_delta tag suppression) + self._in_think_block = False + self._think_buffer = "" + @property def already_sent(self) -> bool: """True if at least one message was sent or edited during the run.""" @@ -132,6 +148,112 @@ class GatewayStreamConsumer: """Signal that the stream is complete.""" self._queue.put(_DONE) + # ── Think-block filtering ──────────────────────────────────────── + # Models like MiniMax emit inline ... blocks in their + # content. The CLI's _stream_delta suppresses these via a state + # machine; we do the same here so gateway users never see raw + # reasoning tags. The agent also strips them from the final + # response (run_agent.py _strip_think_blocks), but the stream + # consumer sends intermediate edits before that stripping happens. + + def _filter_and_accumulate(self, text: str) -> None: + """Add a text delta to the accumulated buffer, suppressing think blocks. + + Uses a state machine that tracks whether we are inside a + reasoning/thinking block. Text inside such blocks is silently + discarded. Partial tags at buffer boundaries are held back in + ``_think_buffer`` until enough characters arrive to decide. + """ + buf = self._think_buffer + text + self._think_buffer = "" + + while buf: + if self._in_think_block: + # Look for the earliest closing tag + best_idx = -1 + best_len = 0 + for tag in self._CLOSE_THINK_TAGS: + idx = buf.find(tag) + if idx != -1 and (best_idx == -1 or idx < best_idx): + best_idx = idx + best_len = len(tag) + + if best_len: + # Found closing tag — discard block, process remainder + self._in_think_block = False + buf = buf[best_idx + best_len:] + else: + # No closing tag yet — hold tail that could be a + # partial closing tag prefix, discard the rest. + max_tag = max(len(t) for t in self._CLOSE_THINK_TAGS) + self._think_buffer = buf[-max_tag:] if len(buf) > max_tag else buf + return + else: + # Look for earliest opening tag at a block boundary + # (start of text / preceded by newline + optional whitespace). + # This prevents false positives when models *mention* tags + # in prose (e.g. "the tag is used for…"). + best_idx = -1 + best_len = 0 + for tag in self._OPEN_THINK_TAGS: + search_start = 0 + while True: + idx = buf.find(tag, search_start) + if idx == -1: + break + # Block-boundary check (mirrors cli.py logic) + if idx == 0: + is_boundary = ( + not self._accumulated + or self._accumulated.endswith("\n") + ) + else: + preceding = buf[:idx] + last_nl = preceding.rfind("\n") + if last_nl == -1: + is_boundary = ( + (not self._accumulated + or self._accumulated.endswith("\n")) + and preceding.strip() == "" + ) + else: + is_boundary = preceding[last_nl + 1:].strip() == "" + + if is_boundary and (best_idx == -1 or idx < best_idx): + best_idx = idx + best_len = len(tag) + break # first boundary hit for this tag is enough + search_start = idx + 1 + + if best_len: + # Emit text before the tag, enter think block + self._accumulated += buf[:best_idx] + self._in_think_block = True + buf = buf[best_idx + best_len:] + else: + # No opening tag — check for a partial tag at the tail + held_back = 0 + for tag in self._OPEN_THINK_TAGS: + for i in range(1, len(tag)): + if buf.endswith(tag[:i]) and i > held_back: + held_back = i + if held_back: + self._accumulated += buf[:-held_back] + self._think_buffer = buf[-held_back:] + else: + self._accumulated += buf + return + + def _flush_think_buffer(self) -> None: + """Flush any held-back partial-tag buffer into accumulated text. + + Called when the stream ends (got_done) so that partial text that + was held back waiting for a possible opening tag is not lost. + """ + if self._think_buffer and not self._in_think_block: + self._accumulated += self._think_buffer + self._think_buffer = "" + async def run(self) -> None: """Async task that drains the queue and edits the platform message.""" # Platform message length limit — leave room for cursor + formatting @@ -156,10 +278,16 @@ class GatewayStreamConsumer: if isinstance(item, tuple) and len(item) == 2 and item[0] is _COMMENTARY: commentary_text = item[1] break - self._accumulated += item + self._filter_and_accumulate(item) except queue.Empty: break + # Flush any held-back partial-tag buffer on stream end + # so trailing text that was waiting for a potential open + # tag is not lost. + if got_done: + self._flush_think_buffer() + # Decide whether to flush an edit now = time.monotonic() elapsed = now - self._last_edit_time diff --git a/tests/gateway/test_stream_consumer.py b/tests/gateway/test_stream_consumer.py index d8a1be2d2..38e536d76 100644 --- a/tests/gateway/test_stream_consumer.py +++ b/tests/gateway/test_stream_consumer.py @@ -680,3 +680,202 @@ class TestCancelledConsumerSetsFlags: # Without a successful send, final_response_sent should stay False # so the normal gateway send path can deliver the response. assert consumer.final_response_sent is False + + +# ── Think-block filtering unit tests ───────────────────────────────────── + + +def _make_consumer() -> GatewayStreamConsumer: + """Create a bare consumer for unit-testing the filter (no adapter needed).""" + adapter = MagicMock() + return GatewayStreamConsumer(adapter, "chat_test") + + +class TestFilterAndAccumulate: + """Unit tests for _filter_and_accumulate think-block suppression.""" + + def test_plain_text_passes_through(self): + c = _make_consumer() + c._filter_and_accumulate("Hello world") + assert c._accumulated == "Hello world" + + def test_complete_think_block_stripped(self): + c = _make_consumer() + c._filter_and_accumulate("internal reasoningAnswer here") + assert c._accumulated == "Answer here" + + def test_think_block_in_middle(self): + c = _make_consumer() + c._filter_and_accumulate("Prefix\nreasoning\nSuffix") + assert c._accumulated == "Prefix\n\nSuffix" + + def test_think_block_split_across_deltas(self): + c = _make_consumer() + c._filter_and_accumulate("start of") + c._filter_and_accumulate(" reasoningvisible text") + assert c._accumulated == "visible text" + + def test_opening_tag_split_across_deltas(self): + c = _make_consumer() + c._filter_and_accumulate("hiddenshown") + assert c._accumulated == "shown" + + def test_closing_tag_split_across_deltas(self): + c = _make_consumer() + c._filter_and_accumulate("hiddenshown") + assert c._accumulated == "shown" + + def test_multiple_think_blocks(self): + c = _make_consumer() + # Consecutive blocks with no text between them — both stripped + c._filter_and_accumulate( + "block1block2visible" + ) + assert c._accumulated == "visible" + + def test_multiple_think_blocks_with_text_between(self): + """Think tag after non-whitespace is NOT a boundary (prose safety).""" + c = _make_consumer() + c._filter_and_accumulate( + "block1Ablock2B" + ) + # Second follows 'A' (not a block boundary) — treated as prose + assert "A" in c._accumulated + assert "B" in c._accumulated + + def test_thinking_tag_variant(self): + c = _make_consumer() + c._filter_and_accumulate("deep thoughtResult") + assert c._accumulated == "Result" + + def test_thought_tag_variant(self): + c = _make_consumer() + c._filter_and_accumulate("Gemma styleOutput") + assert c._accumulated == "Output" + + def test_reasoning_scratchpad_variant(self): + c = _make_consumer() + c._filter_and_accumulate( + "long planDone" + ) + assert c._accumulated == "Done" + + def test_case_insensitive_THINKING(self): + c = _make_consumer() + c._filter_and_accumulate("capsanswer") + assert c._accumulated == "answer" + + def test_prose_mention_not_stripped(self): + """ mentioned mid-line in prose should NOT trigger filtering.""" + c = _make_consumer() + c._filter_and_accumulate("The tag is used for reasoning") + assert "" in c._accumulated + assert "used for reasoning" in c._accumulated + + def test_prose_mention_after_text(self): + """ after non-whitespace on same line is not a block boundary.""" + c = _make_consumer() + c._filter_and_accumulate("Try using some content tags") + assert "" in c._accumulated + + def test_think_at_line_start_is_stripped(self): + """ at start of a new line IS a block boundary.""" + c = _make_consumer() + c._filter_and_accumulate("Previous line\nreasoningNext") + assert "Previous line\nNext" == c._accumulated + + def test_think_with_only_whitespace_before(self): + """ preceded by only whitespace on its line is a boundary.""" + c = _make_consumer() + c._filter_and_accumulate(" hiddenvisible") + # Leading whitespace before the tag is emitted, then block is stripped + assert c._accumulated == " visible" + + def test_flush_think_buffer_on_non_tag(self): + """Partial tag that turns out not to be a tag is flushed.""" + c = _make_consumer() + c._filter_and_accumulate("still thinking") + c._flush_think_buffer() + assert c._accumulated == "" + + def test_unclosed_think_block_suppresses(self): + """An unclosed suppresses all subsequent content.""" + c = _make_consumer() + c._filter_and_accumulate("Before\nreasoning that never ends...") + assert c._accumulated == "Before\n" + + def test_multiline_think_block(self): + c = _make_consumer() + c._filter_and_accumulate( + "\nLine 1\nLine 2\nLine 3\nFinal answer" + ) + assert c._accumulated == "Final answer" + + def test_segment_reset_preserves_think_state(self): + """_reset_segment_state should NOT clear think-block filter state.""" + c = _make_consumer() + c._filter_and_accumulate("start") + c._reset_segment_state() + # Still inside think block — subsequent text should be suppressed + c._filter_and_accumulate("still hiddenvisible") + assert c._accumulated == "visible" + + +class TestFilterAndAccumulateIntegration: + """Integration: verify think blocks don't leak through the full run() path.""" + + @pytest.mark.asyncio + async def test_think_block_not_sent_to_platform(self): + """Think blocks should be filtered before platform edit.""" + adapter = MagicMock() + adapter.send = AsyncMock( + return_value=SimpleNamespace(success=True, message_id="msg_1") + ) + adapter.edit_message = AsyncMock( + return_value=SimpleNamespace(success=True) + ) + adapter.MAX_MESSAGE_LENGTH = 4096 + + consumer = GatewayStreamConsumer( + adapter, + "chat_test", + StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5), + ) + + # Simulate streaming: think block then visible text + consumer.on_delta("deep reasoning here") + consumer.on_delta("The answer is 42.") + consumer.finish() + + task = asyncio.create_task(consumer.run()) + await asyncio.sleep(0.15) + + # The final text sent to the platform should NOT contain + all_calls = list(adapter.send.call_args_list) + list( + adapter.edit_message.call_args_list + ) + for call in all_calls: + args, kwargs = call + content = kwargs.get("content") or (args[0] if args else "") + assert "" not in content, f"Think tag leaked: {content}" + assert "deep reasoning" not in content + + try: + task.cancel() + await task + except asyncio.CancelledError: + pass From 110892ff69cea1da7da9598a13ae40f67f68d1b1 Mon Sep 17 00:00:00 2001 From: Teknium Date: Mon, 13 Apr 2026 22:30:44 -0700 Subject: [PATCH 04/58] docs: move Xiaomi MiMo up in README provider list --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fdef1255f..07a140419 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ **The self-improving AI agent built by [Nous Research](https://nousresearch.com).** It's the only agent with a built-in learning loop — it creates skills from experience, improves them during use, nudges itself to persist knowledge, searches its own past conversations, and builds a deepening model of who you are across sessions. Run it on a $5 VPS, a GPU cluster, or serverless infrastructure that costs nearly nothing when idle. It's not tied to your laptop — talk to it from Telegram while it works on a cloud VM. -Use any model you want — [Nous Portal](https://portal.nousresearch.com), [OpenRouter](https://openrouter.ai) (200+ models), [z.ai/GLM](https://z.ai), [Kimi/Moonshot](https://platform.moonshot.ai), [MiniMax](https://www.minimax.io), [Hugging Face](https://huggingface.co), [Xiaomi MiMo](https://platform.xiaomimimo.com), OpenAI, or your own endpoint. Switch with `hermes model` — no code changes, no lock-in. +Use any model you want — [Nous Portal](https://portal.nousresearch.com), [OpenRouter](https://openrouter.ai) (200+ models), [Xiaomi MiMo](https://platform.xiaomimimo.com), [z.ai/GLM](https://z.ai), [Kimi/Moonshot](https://platform.moonshot.ai), [MiniMax](https://www.minimax.io), [Hugging Face](https://huggingface.co), OpenAI, or your own endpoint. Switch with `hermes model` — no code changes, no lock-in.
A real terminal interfaceFull TUI with multiline editing, slash-command autocomplete, conversation history, interrupt-and-redirect, and streaming tool output.
From cdd44817f27e6ac330a1b6b3582f0969dfa689c3 Mon Sep 17 00:00:00 2001 From: Kenny Xie Date: Mon, 13 Apr 2026 13:37:05 -0700 Subject: [PATCH 05/58] fix(anthropic): send fast mode speed via extra_body --- agent/anthropic_adapter.py | 15 ++++++++------- tests/cli/test_fast_command.py | 19 ++++++++++++++++++- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 830c0f4de..b85f77a9d 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -1230,9 +1230,10 @@ def build_anthropic_kwargs( When *base_url* points to a third-party Anthropic-compatible endpoint, thinking block signatures are stripped (they are Anthropic-proprietary). - When *fast_mode* is True, adds ``speed: "fast"`` and the fast-mode beta - header for ~2.5x faster output throughput on Opus 4.6. Currently only - supported on native Anthropic endpoints (not third-party compatible ones). + When *fast_mode* is True, adds ``extra_body["speed"] = "fast"`` and the + fast-mode beta header for ~2.5x faster output throughput on Opus 4.6. + Currently only supported on native Anthropic endpoints (not third-party + compatible ones). """ system, anthropic_messages = convert_messages_to_anthropic(messages, base_url=base_url) anthropic_tools = convert_tools_to_anthropic(tools) if tools else [] @@ -1333,11 +1334,11 @@ def build_anthropic_kwargs( kwargs["max_tokens"] = max(effective_max_tokens, budget + 4096) # ── Fast mode (Opus 4.6 only) ──────────────────────────────────── - # Adds speed:"fast" + the fast-mode beta header for ~2.5x output speed. - # Only for native Anthropic endpoints — third-party providers would - # reject the unknown beta header and speed parameter. + # Adds extra_body.speed="fast" + the fast-mode beta header for ~2.5x + # output speed. Only for native Anthropic endpoints — third-party + # providers would reject the unknown beta header and speed parameter. if fast_mode and not _is_third_party_anthropic_endpoint(base_url): - kwargs["speed"] = "fast" + kwargs.setdefault("extra_body", {})["speed"] = "fast" # Build extra_headers with ALL applicable betas (the per-request # extra_headers override the client-level anthropic-beta header). betas = list(_common_betas_for_base_url(base_url)) diff --git a/tests/cli/test_fast_command.py b/tests/cli/test_fast_command.py index d39453c10..bc6c8e5fb 100644 --- a/tests/cli/test_fast_command.py +++ b/tests/cli/test_fast_command.py @@ -369,7 +369,8 @@ class TestAnthropicFastModeAdapter(unittest.TestCase): reasoning_config=None, fast_mode=True, ) - assert kwargs.get("speed") == "fast" + assert kwargs.get("extra_body", {}).get("speed") == "fast" + assert "speed" not in kwargs assert "extra_headers" in kwargs assert _FAST_MODE_BETA in kwargs["extra_headers"].get("anthropic-beta", "") @@ -384,6 +385,7 @@ class TestAnthropicFastModeAdapter(unittest.TestCase): reasoning_config=None, fast_mode=False, ) + assert kwargs.get("extra_body", {}).get("speed") is None assert "speed" not in kwargs assert "extra_headers" not in kwargs @@ -400,9 +402,24 @@ class TestAnthropicFastModeAdapter(unittest.TestCase): base_url="https://api.minimax.io/anthropic/v1", ) # Third-party endpoints should NOT get speed or fast-mode beta + assert kwargs.get("extra_body", {}).get("speed") is None assert "speed" not in kwargs assert "extra_headers" not in kwargs + def test_fast_mode_kwargs_are_safe_for_sdk_unpacking(self): + from agent.anthropic_adapter import build_anthropic_kwargs + + kwargs = build_anthropic_kwargs( + model="claude-opus-4-6", + messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}], + tools=None, + max_tokens=None, + reasoning_config=None, + fast_mode=True, + ) + assert "speed" not in kwargs + assert kwargs.get("extra_body", {}).get("speed") == "fast" + class TestConfigDefault(unittest.TestCase): def test_default_config_has_service_tier(self): From d6314318721cc8f3eba6e1a6138ccc03355764bc Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:41:00 -0700 Subject: [PATCH 06/58] feat: prompt for display name when adding custom providers (#9420) During custom endpoint setup, users are now asked for a display name with the auto-generated name as the default. Typing 'Ollama' or 'LM Studio' replaces the generic 'Local (localhost:11434)' in the provider menu. Extracts _auto_provider_name() for reuse and adds a name= parameter to _save_custom_provider() so the caller can pass through the user-chosen label. --- hermes_cli/main.py | 49 +++++++++++++++-------- tests/cli/test_cli_provider_resolution.py | 48 +++++++++++++++++++++- 2 files changed, 78 insertions(+), 19 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 2712a01ea..46a7e2c5f 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1618,6 +1618,10 @@ def _model_flow_custom(config): model_name = input("Model name (e.g. gpt-4, llama-3-70b): ").strip() context_length_str = input("Context length in tokens [leave blank for auto-detect]: ").strip() + + # Prompt for a display name — shown in the provider menu on future runs + default_name = _auto_provider_name(effective_url) + display_name = input(f"Display name [{default_name}]: ").strip() or default_name except (KeyboardInterrupt, EOFError): print("\nCancelled.") return @@ -1673,15 +1677,37 @@ def _model_flow_custom(config): print("Endpoint saved. Use `/model` in chat or `hermes model` to set a model.") # Auto-save to custom_providers so it appears in the menu next time - _save_custom_provider(effective_url, effective_key, model_name or "", context_length=context_length) + _save_custom_provider(effective_url, effective_key, model_name or "", + context_length=context_length, name=display_name) -def _save_custom_provider(base_url, api_key="", model="", context_length=None): +def _auto_provider_name(base_url: str) -> str: + """Generate a display name from a custom endpoint URL. + + Returns a human-friendly label like "Local (localhost:11434)" or + "RunPod (xyz.runpod.io)". Used as the default when prompting the + user for a display name during custom endpoint setup. + """ + import re + clean = base_url.replace("https://", "").replace("http://", "").rstrip("/") + clean = re.sub(r"/v1/?$", "", clean) + name = clean.split("/")[0] + if "localhost" in name or "127.0.0.1" in name: + name = f"Local ({name})" + elif "runpod" in name.lower(): + name = f"RunPod ({name})" + else: + name = name.capitalize() + return name + + +def _save_custom_provider(base_url, api_key="", model="", context_length=None, + name=None): """Save a custom endpoint to custom_providers in config.yaml. Deduplicates by base_url — if the URL already exists, updates the model name and context_length but doesn't add a duplicate entry. - Auto-generates a display name from the URL hostname. + Uses *name* when provided, otherwise auto-generates from the URL. """ from hermes_cli.config import load_config, save_config @@ -1709,20 +1735,9 @@ def _save_custom_provider(base_url, api_key="", model="", context_length=None): save_config(cfg) return # already saved, updated if needed - # Auto-generate a name from the URL - import re - clean = base_url.replace("https://", "").replace("http://", "").rstrip("/") - # Remove /v1 suffix for cleaner names - clean = re.sub(r"/v1/?$", "", clean) - # Use hostname:port as the name - name = clean.split("/")[0] - # Capitalize for readability - if "localhost" in name or "127.0.0.1" in name: - name = f"Local ({name})" - elif "runpod" in name.lower(): - name = f"RunPod ({name})" - else: - name = name.capitalize() + # Use provided name or auto-generate from URL + if not name: + name = _auto_provider_name(base_url) entry = {"name": name, "base_url": base_url} if api_key: diff --git a/tests/cli/test_cli_provider_resolution.py b/tests/cli/test_cli_provider_resolution.py index 353b3234e..9c5bf0cca 100644 --- a/tests/cli/test_cli_provider_resolution.py +++ b/tests/cli/test_cli_provider_resolution.py @@ -576,8 +576,9 @@ def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys): monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: None) # After the probe detects a single model ("llm"), the flow asks - # "Use this model? [Y/n]:" — confirm with Enter, then context length. - answers = iter(["http://localhost:8000", "local-key", "", ""]) + # "Use this model? [Y/n]:" — confirm with Enter, then context length, + # then display name. + answers = iter(["http://localhost:8000", "local-key", "", "", ""]) monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers)) monkeypatch.setattr("getpass.getpass", lambda _prompt="": next(answers)) @@ -641,3 +642,46 @@ def test_cmd_model_forwards_nous_login_tls_options(monkeypatch): "ca_bundle": "/tmp/local-ca.pem", "insecure": True, } + + +# --------------------------------------------------------------------------- +# _auto_provider_name — unit tests +# --------------------------------------------------------------------------- + +def test_auto_provider_name_localhost(): + from hermes_cli.main import _auto_provider_name + assert _auto_provider_name("http://localhost:11434/v1") == "Local (localhost:11434)" + assert _auto_provider_name("http://127.0.0.1:1234/v1") == "Local (127.0.0.1:1234)" + + +def test_auto_provider_name_runpod(): + from hermes_cli.main import _auto_provider_name + assert "RunPod" in _auto_provider_name("https://xyz.runpod.io/v1") + + +def test_auto_provider_name_remote(): + from hermes_cli.main import _auto_provider_name + result = _auto_provider_name("https://api.together.xyz/v1") + assert result == "Api.together.xyz" + + +def test_save_custom_provider_uses_provided_name(monkeypatch, tmp_path): + """When a display name is passed, it should appear in the saved entry.""" + import yaml + from hermes_cli.main import _save_custom_provider + + cfg_path = tmp_path / "config.yaml" + cfg_path.write_text(yaml.dump({})) + + monkeypatch.setattr( + "hermes_cli.config.load_config", lambda: yaml.safe_load(cfg_path.read_text()) or {}, + ) + saved = {} + def _save(cfg): + saved.update(cfg) + monkeypatch.setattr("hermes_cli.config.save_config", _save) + + _save_custom_provider("http://localhost:11434/v1", name="Ollama") + entries = saved.get("custom_providers", []) + assert len(entries) == 1 + assert entries[0]["name"] == "Ollama" From a91b9bb855e02b6b4fd662ef4bce1f87dee92e18 Mon Sep 17 00:00:00 2001 From: oluwadareab12 Date: Mon, 13 Apr 2026 22:58:51 -0700 Subject: [PATCH 07/58] =?UTF-8?q?feat(skills):=20add=20drug-discovery=20op?= =?UTF-8?q?tional=20skill=20=E2=80=94=20ChEMBL,=20PubChem,=20OpenFDA,=20AD?= =?UTF-8?q?MET=20analysis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pharmaceutical research skill covering bioactive compound search (ChEMBL), drug-likeness screening (Lipinski Ro5 + Veber via PubChem), drug-drug interaction lookups (OpenFDA), gene-disease associations (OpenTargets GraphQL), and ADMET reasoning guidance. All free public APIs, zero auth, stdlib-only Python. Includes helper scripts for batch Ro5 screening and target-to-compound pipelines. Moved to optional-skills/research/ (niche domain skill, not built-in). Fixed: authors→author frontmatter, removed unused jq prerequisite, bare except→except Exception. Co-authored-by: bennytimz Salvaged from PR #8695. --- .../research/drug-discovery/SKILL.md | 226 ++++++++++++++++++ .../references/ADMET_REFERENCE.md | 66 +++++ .../drug-discovery/scripts/chembl_target.py | 53 ++++ .../drug-discovery/scripts/ro5_screen.py | 44 ++++ 4 files changed, 389 insertions(+) create mode 100644 optional-skills/research/drug-discovery/SKILL.md create mode 100644 optional-skills/research/drug-discovery/references/ADMET_REFERENCE.md create mode 100644 optional-skills/research/drug-discovery/scripts/chembl_target.py create mode 100644 optional-skills/research/drug-discovery/scripts/ro5_screen.py diff --git a/optional-skills/research/drug-discovery/SKILL.md b/optional-skills/research/drug-discovery/SKILL.md new file mode 100644 index 000000000..dc3bd3e7b --- /dev/null +++ b/optional-skills/research/drug-discovery/SKILL.md @@ -0,0 +1,226 @@ +--- +name: drug-discovery +description: > + Pharmaceutical research assistant for drug discovery workflows. Search + bioactive compounds on ChEMBL, calculate drug-likeness (Lipinski Ro5, QED, + TPSA, synthetic accessibility), look up drug-drug interactions via + OpenFDA, interpret ADMET profiles, and assist with lead optimization. + Use for medicinal chemistry questions, molecule property analysis, clinical + pharmacology, and open-science drug research. +version: 1.0.0 +author: bennytimz +license: MIT +metadata: + hermes: + tags: [science, chemistry, pharmacology, research, health] +prerequisites: + commands: [curl, python3] +--- + +# Drug Discovery & Pharmaceutical Research + +You are an expert pharmaceutical scientist and medicinal chemist with deep +knowledge of drug discovery, cheminformatics, and clinical pharmacology. +Use this skill for all pharma/chemistry research tasks. + +## Core Workflows + +### 1 — Bioactive Compound Search (ChEMBL) + +Search ChEMBL (the world's largest open bioactivity database) for compounds +by target, activity, or molecule name. No API key required. + +```bash +# Search compounds by target name (e.g. "EGFR", "COX-2", "ACE") +TARGET="$1" +ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$TARGET") +curl -s "https://www.ebi.ac.uk/chembl/api/data/target/search?q=${ENCODED}&format=json" \ + | python3 -c " +import json,sys +data=json.load(sys.stdin) +targets=data.get('targets',[])[:5] +for t in targets: + print(f\"ChEMBL ID : {t.get('target_chembl_id')}\") + print(f\"Name : {t.get('pref_name')}\") + print(f\"Type : {t.get('target_type')}\") + print() +" +``` + +```bash +# Get bioactivity data for a ChEMBL target ID +TARGET_ID="$1" # e.g. CHEMBL203 +curl -s "https://www.ebi.ac.uk/chembl/api/data/activity?target_chembl_id=${TARGET_ID}&pchembl_value__gte=6&limit=10&format=json" \ + | python3 -c " +import json,sys +data=json.load(sys.stdin) +acts=data.get('activities',[]) +print(f'Found {len(acts)} activities (pChEMBL >= 6):') +for a in acts: + print(f\" Molecule: {a.get('molecule_chembl_id')} | {a.get('standard_type')}: {a.get('standard_value')} {a.get('standard_units')} | pChEMBL: {a.get('pchembl_value')}\") +" +``` + +```bash +# Look up a specific molecule by ChEMBL ID +MOL_ID="$1" # e.g. CHEMBL25 (aspirin) +curl -s "https://www.ebi.ac.uk/chembl/api/data/molecule/${MOL_ID}?format=json" \ + | python3 -c " +import json,sys +m=json.load(sys.stdin) +props=m.get('molecule_properties',{}) or {} +print(f\"Name : {m.get('pref_name','N/A')}\") +print(f\"SMILES : {m.get('molecule_structures',{}).get('canonical_smiles','N/A') if m.get('molecule_structures') else 'N/A'}\") +print(f\"MW : {props.get('full_mwt','N/A')} Da\") +print(f\"LogP : {props.get('alogp','N/A')}\") +print(f\"HBD : {props.get('hbd','N/A')}\") +print(f\"HBA : {props.get('hba','N/A')}\") +print(f\"TPSA : {props.get('psa','N/A')} Ų\") +print(f\"Ro5 violations: {props.get('num_ro5_violations','N/A')}\") +print(f\"QED : {props.get('qed_weighted','N/A')}\") +" +``` + +### 2 — Drug-Likeness Calculation (Lipinski Ro5 + Veber) + +Assess any molecule against established oral bioavailability rules using +PubChem's free property API — no RDKit install needed. + +```bash +COMPOUND="$1" +ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$COMPOUND") +curl -s "https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/${ENCODED}/property/MolecularWeight,XLogP,HBondDonorCount,HBondAcceptorCount,RotatableBondCount,TPSA,InChIKey/JSON" \ + | python3 -c " +import json,sys +data=json.load(sys.stdin) +props=data['PropertyTable']['Properties'][0] +mw = float(props.get('MolecularWeight', 0)) +logp = float(props.get('XLogP', 0)) +hbd = int(props.get('HBondDonorCount', 0)) +hba = int(props.get('HBondAcceptorCount', 0)) +rot = int(props.get('RotatableBondCount', 0)) +tpsa = float(props.get('TPSA', 0)) +print('=== Lipinski Rule of Five (Ro5) ===') +print(f' MW {mw:.1f} Da {\"✓\" if mw<=500 else \"✗ VIOLATION (>500)\"}') +print(f' LogP {logp:.2f} {\"✓\" if logp<=5 else \"✗ VIOLATION (>5)\"}') +print(f' HBD {hbd} {\"✓\" if hbd<=5 else \"✗ VIOLATION (>5)\"}') +print(f' HBA {hba} {\"✓\" if hba<=10 else \"✗ VIOLATION (>10)\"}') +viol = sum([mw>500, logp>5, hbd>5, hba>10]) +print(f' Violations: {viol}/4 {\"→ Likely orally bioavailable\" if viol<=1 else \"→ Poor oral bioavailability predicted\"}') +print() +print('=== Veber Oral Bioavailability Rules ===') +print(f' TPSA {tpsa:.1f} Ų {\"✓\" if tpsa<=140 else \"✗ VIOLATION (>140)\"}') +print(f' Rot. bonds {rot} {\"✓\" if rot<=10 else \"✗ VIOLATION (>10)\"}') +print(f' Both rules met: {\"Yes → good oral absorption predicted\" if tpsa<=140 and rot<=10 else \"No → reduced oral absorption\"}') +" +``` + +### 3 — Drug Interaction & Safety Lookup (OpenFDA) + +```bash +DRUG="$1" +ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$DRUG") +curl -s "https://api.fda.gov/drug/label.json?search=drug_interactions:\"${ENCODED}\"&limit=3" \ + | python3 -c " +import json,sys +data=json.load(sys.stdin) +results=data.get('results',[]) +if not results: + print('No interaction data found in FDA labels.') + sys.exit() +for r in results[:2]: + brand=r.get('openfda',{}).get('brand_name',['Unknown'])[0] + generic=r.get('openfda',{}).get('generic_name',['Unknown'])[0] + interactions=r.get('drug_interactions',['N/A'])[0] + print(f'--- {brand} ({generic}) ---') + print(interactions[:800]) + print() +" +``` + +```bash +DRUG="$1" +ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$DRUG") +curl -s "https://api.fda.gov/drug/event.json?search=patient.drug.medicinalproduct:\"${ENCODED}\"&count=patient.reaction.reactionmeddrapt.exact&limit=10" \ + | python3 -c " +import json,sys +data=json.load(sys.stdin) +results=data.get('results',[]) +if not results: + print('No adverse event data found.') + sys.exit() +print(f'Top adverse events reported:') +for r in results[:10]: + print(f\" {r['count']:>5}x {r['term']}\") +" +``` + +### 4 — PubChem Compound Search + +```bash +COMPOUND="$1" +ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$COMPOUND") +CID=$(curl -s "https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/${ENCODED}/cids/TXT" | head -1 | tr -d '[:space:]') +echo "PubChem CID: $CID" +curl -s "https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/${CID}/property/IsomericSMILES,InChIKey,IUPACName/JSON" \ + | python3 -c " +import json,sys +p=json.load(sys.stdin)['PropertyTable']['Properties'][0] +print(f\"IUPAC Name : {p.get('IUPACName','N/A')}\") +print(f\"SMILES : {p.get('IsomericSMILES','N/A')}\") +print(f\"InChIKey : {p.get('InChIKey','N/A')}\") +" +``` + +### 5 — Target & Disease Literature (OpenTargets) + +```bash +GENE="$1" +curl -s -X POST "https://api.platform.opentargets.org/api/v4/graphql" \ + -H "Content-Type: application/json" \ + -d "{\"query\":\"{ search(queryString: \\\"${GENE}\\\", entityNames: [\\\"target\\\"], page: {index: 0, size: 1}) { hits { id score object { ... on Target { id approvedSymbol approvedName associatedDiseases(page: {index: 0, size: 5}) { count rows { score disease { id name } } } } } } } }\"}" \ + | python3 -c " +import json,sys +data=json.load(sys.stdin) +hits=data.get('data',{}).get('search',{}).get('hits',[]) +if not hits: + print('Target not found.') + sys.exit() +obj=hits[0]['object'] +print(f\"Target: {obj.get('approvedSymbol')} — {obj.get('approvedName')}\") +assoc=obj.get('associatedDiseases',{}) +print(f\"Associated with {assoc.get('count',0)} diseases. Top associations:\") +for row in assoc.get('rows',[]): + print(f\" Score {row['score']:.3f} | {row['disease']['name']}\") +" +``` + +## Reasoning Guidelines + +When analysing drug-likeness or molecular properties, always: + +1. **State raw values first** — MW, LogP, HBD, HBA, TPSA, RotBonds +2. **Apply rule sets** — Ro5 (Lipinski), Veber, Ghose filter where relevant +3. **Flag liabilities** — metabolic hotspots, hERG risk, high TPSA for CNS penetration +4. **Suggest optimizations** — bioisosteric replacements, prodrug strategies, ring truncation +5. **Cite the source API** — ChEMBL, PubChem, OpenFDA, or OpenTargets + +For ADMET questions, reason through Absorption, Distribution, Metabolism, Excretion, Toxicity systematically. See references/ADMET_REFERENCE.md for detailed guidance. + +## Important Notes + +- All APIs are free, public, require no authentication +- ChEMBL rate limits: add sleep 1 between batch requests +- FDA data reflects reported adverse events, not necessarily causation +- Always recommend consulting a licensed pharmacist or physician for clinical decisions + +## Quick Reference + +| Task | API | Endpoint | +|------|-----|----------| +| Find target | ChEMBL | `/api/data/target/search?q=` | +| Get bioactivity | ChEMBL | `/api/data/activity?target_chembl_id=` | +| Molecule properties | PubChem | `/rest/pug/compound/name/{name}/property/` | +| Drug interactions | OpenFDA | `/drug/label.json?search=drug_interactions:` | +| Adverse events | OpenFDA | `/drug/event.json?search=...&count=reaction` | +| Gene-disease | OpenTargets | GraphQL POST `/api/v4/graphql` | diff --git a/optional-skills/research/drug-discovery/references/ADMET_REFERENCE.md b/optional-skills/research/drug-discovery/references/ADMET_REFERENCE.md new file mode 100644 index 000000000..92a5e9503 --- /dev/null +++ b/optional-skills/research/drug-discovery/references/ADMET_REFERENCE.md @@ -0,0 +1,66 @@ +# ADMET Reference Guide + +Comprehensive reference for Absorption, Distribution, Metabolism, Excretion, and Toxicity (ADMET) analysis in drug discovery. + +## Drug-Likeness Rule Sets + +### Lipinski's Rule of Five (Ro5) + +| Property | Threshold | +|----------|-----------| +| Molecular Weight (MW) | ≤ 500 Da | +| Lipophilicity (LogP) | ≤ 5 | +| H-Bond Donors (HBD) | ≤ 5 | +| H-Bond Acceptors (HBA) | ≤ 10 | + +Reference: Lipinski et al., Adv. Drug Deliv. Rev. 23, 3–25 (1997). + +### Veber's Oral Bioavailability Rules + +| Property | Threshold | +|----------|-----------| +| TPSA | ≤ 140 Ų | +| Rotatable Bonds | ≤ 10 | + +Reference: Veber et al., J. Med. Chem. 45, 2615–2623 (2002). + +### CNS Penetration (BBB) + +| Property | CNS-Optimal | +|----------|-------------| +| MW | ≤ 400 Da | +| LogP | 1–3 | +| TPSA | < 90 Ų | +| HBD | ≤ 3 | + +## CYP450 Metabolism + +| Isoform | % Drugs | Notable inhibitors | +|---------|---------|-------------------| +| CYP3A4 | ~50% | Grapefruit, ketoconazole | +| CYP2D6 | ~25% | Fluoxetine, paroxetine | +| CYP2C9 | ~15% | Fluconazole, amiodarone | +| CYP2C19 | ~10% | Omeprazole, fluoxetine | +| CYP1A2 | ~5% | Fluvoxamine, ciprofloxacin | + +## hERG Cardiac Toxicity Risk + +Structural alerts: basic nitrogen (pKa 7–9) + aromatic ring + hydrophobic moiety, LogP > 3.5 + basic amine. + +Mitigation: reduce basicity, introduce polar groups, break planarity. + +## Common Bioisosteric Replacements + +| Original | Bioisostere | Purpose | +|----------|-------------|---------| +| -COOH | -tetrazole, -SO₂NH₂ | Improve permeability | +| -OH (phenol) | -F, -CN | Reduce glucuronidation | +| Phenyl | Pyridine, thiophene | Reduce LogP | +| Ester | -CONHR | Reduce hydrolysis | + +## Key APIs + +- ChEMBL: https://www.ebi.ac.uk/chembl/api/data/ +- PubChem: https://pubchem.ncbi.nlm.nih.gov/rest/pug/ +- OpenFDA: https://api.fda.gov/drug/ +- OpenTargets GraphQL: https://api.platform.opentargets.org/api/v4/graphql diff --git a/optional-skills/research/drug-discovery/scripts/chembl_target.py b/optional-skills/research/drug-discovery/scripts/chembl_target.py new file mode 100644 index 000000000..1346b999a --- /dev/null +++ b/optional-skills/research/drug-discovery/scripts/chembl_target.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +""" +chembl_target.py — Search ChEMBL for a target and retrieve top active compounds. +Usage: python3 chembl_target.py "EGFR" --min-pchembl 7 --limit 20 +No external dependencies. +""" +import sys, json, time, argparse +import urllib.request, urllib.parse, urllib.error + +BASE = "https://www.ebi.ac.uk/chembl/api/data" + +def get(endpoint): + try: + req = urllib.request.Request(f"{BASE}{endpoint}", headers={"Accept":"application/json"}) + with urllib.request.urlopen(req, timeout=15) as r: + return json.loads(r.read()) + except Exception as e: + print(f"API error: {e}", file=sys.stderr); return None + +def main(): + parser = argparse.ArgumentParser(description="ChEMBL target → active compounds") + parser.add_argument("target") + parser.add_argument("--min-pchembl", type=float, default=6.0) + parser.add_argument("--limit", type=int, default=10) + args = parser.parse_args() + + enc = urllib.parse.quote(args.target) + data = get(f"/target/search?q={enc}&limit=5&format=json") + if not data or not data.get("targets"): + print("No targets found."); sys.exit(1) + + t = data["targets"][0] + tid = t.get("target_chembl_id","") + print(f"\nTarget: {t.get('pref_name')} ({tid})") + print(f"Type: {t.get('target_type')} | Organism: {t.get('organism','N/A')}") + print(f"\nFetching compounds with pChEMBL ≥ {args.min_pchembl}...\n") + + acts = get(f"/activity?target_chembl_id={tid}&pchembl_value__gte={args.min_pchembl}&assay_type=B&limit={args.limit}&order_by=-pchembl_value&format=json") + if not acts or not acts.get("activities"): + print("No activities found."); sys.exit(0) + + print(f"{'Molecule':<18} {'pChEMBL':>8} {'Type':<12} {'Value':<10} {'Units'}") + print("-"*65) + seen = set() + for a in acts["activities"]: + mid = a.get("molecule_chembl_id","N/A") + if mid in seen: continue + seen.add(mid) + print(f"{mid:<18} {str(a.get('pchembl_value','N/A')):>8} {str(a.get('standard_type','N/A')):<12} {str(a.get('standard_value','N/A')):<10} {a.get('standard_units','N/A')}") + time.sleep(0.1) + print(f"\nTotal: {len(seen)} unique molecules") + +if __name__ == "__main__": main() diff --git a/optional-skills/research/drug-discovery/scripts/ro5_screen.py b/optional-skills/research/drug-discovery/scripts/ro5_screen.py new file mode 100644 index 000000000..84e438fa1 --- /dev/null +++ b/optional-skills/research/drug-discovery/scripts/ro5_screen.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +""" +ro5_screen.py — Batch Lipinski Ro5 + Veber screening via PubChem API. +Usage: python3 ro5_screen.py aspirin ibuprofen paracetamol +No external dependencies beyond stdlib. +""" +import sys, json, time, argparse +import urllib.request, urllib.parse, urllib.error + +BASE = "https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name" +PROPS = "MolecularWeight,XLogP,HBondDonorCount,HBondAcceptorCount,RotatableBondCount,TPSA" + +def fetch(name): + url = f"{BASE}/{urllib.parse.quote(name)}/property/{PROPS}/JSON" + try: + with urllib.request.urlopen(url, timeout=10) as r: + return json.loads(r.read())["PropertyTable"]["Properties"][0] + except Exception: + return None + +def check(p): + mw,logp,hbd,hba,rot,tpsa = float(p.get("MolecularWeight",0)),float(p.get("XLogP",0)),int(p.get("HBondDonorCount",0)),int(p.get("HBondAcceptorCount",0)),int(p.get("RotatableBondCount",0)),float(p.get("TPSA",0)) + v = sum([mw>500,logp>5,hbd>5,hba>10]) + return dict(mw=mw,logp=logp,hbd=hbd,hba=hba,rot=rot,tpsa=tpsa,violations=v,ro5=v<=1,veber=tpsa<=140 and rot<=10,ok=v<=1 and tpsa<=140 and rot<=10) + +def report(name, r): + if not r: print(f"✗ {name:30s} — not found"); return + s = "✓ PASS" if r["ok"] else "✗ FAIL" + flags = (f" [Ro5 violations:{r['violations']}]" if not r["ro5"] else "") + (" [Veber fail]" if not r["veber"] else "") + print(f"{s} {name:28s} MW={r['mw']:.0f} LogP={r['logp']:.2f} HBD={r['hbd']} HBA={r['hba']} TPSA={r['tpsa']:.0f} RotB={r['rot']}{flags}") + +def main(): + compounds = sys.stdin.read().splitlines() if len(sys.argv)<2 or sys.argv[1]=="-" else sys.argv[1:] + print(f"\n{'Status':<8} {'Compound':<30} Properties\n" + "-"*85) + passed = 0 + for name in compounds: + props = fetch(name.strip()) + result = check(props) if props else None + report(name.strip(), result) + if result and result["ok"]: passed += 1 + time.sleep(0.3) + print(f"\nSummary: {passed}/{len(compounds)} passed Ro5 + Veber.\n") + +if __name__ == "__main__": main() From 35424f8fc1330f8202828a1bf5194fb54b5f3105 Mon Sep 17 00:00:00 2001 From: Teknium Date: Mon, 13 Apr 2026 22:59:23 -0700 Subject: [PATCH 08/58] chore: add bennytimz to AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 9aa1be79a..5cc938ca3 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -112,6 +112,7 @@ AUTHOR_MAP = { "dalvidjr2022@gmail.com": "Jr-kenny", "m@statecraft.systems": "mbierling", "balyan.sid@gmail.com": "balyansid", + "oluwadareab12@gmail.com": "bennytimz", # ── bulk addition: 75 emails resolved via API, PR salvage bodies, noreply # crossref, and GH contributor list matching (April 2026 audit) ── "1115117931@qq.com": "aaronagent", From 38ad158b6bd3ac4c2e68745f4f03916ece3b2305 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:09:39 -0700 Subject: [PATCH 09/58] fix: auto-correct close model name matches in /model validation (#9424) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(skills): add fitness-nutrition skill to optional-skills Cherry-picked from PR #9177 by @haileymarshall. Adds a fitness and nutrition skill for gym-goers and health-conscious users: - Exercise search via wger API (690+ exercises, free, no auth) - Nutrition lookup via USDA FoodData Central (380K+ foods, DEMO_KEY fallback) - Offline body composition calculators (BMI, TDEE, 1RM, macros, body fat %) - Pure stdlib Python, no pip dependencies Changes from original PR: - Moved from skills/ to optional-skills/health/ (correct location) - Fixed BMR formula in FORMULAS.md (removed confusing -5+10, now just +5) - Fixed author attribution to match PR submitter - Marked USDA_API_KEY as optional (DEMO_KEY works without signup) Also adds optional env var support to the skill readiness checker: - New 'optional: true' field in required_environment_variables entries - Optional vars are preserved in metadata but don't block skill readiness - Optional vars skip the CLI capture prompt flow - Skills with only optional missing vars show as 'available' not 'setup_needed' * fix: auto-correct close model name matches in /model validation When a user types a model name with a minor typo (e.g. gpt5.3-codex instead of gpt-5.3-codex), the validation now auto-corrects to the closest match instead of accepting the wrong name with a warning. Uses difflib get_close_matches with cutoff=0.9 to avoid false corrections (e.g. gpt-5.3 should not silently become gpt-5.4). Applied consistently across all three validation paths: codex provider, custom endpoints, and generic API-probed providers. The validate_requested_model() return dict gains an optional corrected_model key that switch_model() applies before building the result. Reported by Discord user — /model gpt5.3-codex was accepted with a warning but would fail at the API level. --------- Co-authored-by: haileymarshall --- hermes_cli/model_switch.py | 4 ++ hermes_cli/models.py | 33 ++++++++++++++ tests/hermes_cli/test_model_validation.py | 54 ++++++++++++++++++++++- 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index c777527f2..699bde23e 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -705,6 +705,10 @@ def switch_model( error_message=msg, ) + # Apply auto-correction if validation found a closer match + if validation.get("corrected_model"): + new_model = validation["corrected_model"] + # --- OpenCode api_mode override --- if target_provider in {"opencode-zen", "opencode-go", "opencode", "opencode-go"}: api_mode = opencode_model_api_mode(target_provider, new_model) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 483d4a309..852601229 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -1820,6 +1820,17 @@ def validate_requested_model( "message": None, } + # Auto-correct if the top match is very similar (e.g. typo) + auto = get_close_matches(requested_for_lookup, api_models, n=1, cutoff=0.9) + if auto: + return { + "accepted": True, + "persist": True, + "recognized": True, + "corrected_model": auto[0], + "message": f"Auto-corrected `{requested}` → `{auto[0]}`", + } + suggestions = get_close_matches(requested, api_models, n=3, cutoff=0.5) suggestion_text = "" if suggestions: @@ -1871,6 +1882,16 @@ def validate_requested_model( "recognized": True, "message": None, } + # Auto-correct if the top match is very similar (e.g. typo) + auto = get_close_matches(requested_for_lookup, codex_models, n=1, cutoff=0.9) + if auto: + return { + "accepted": True, + "persist": True, + "recognized": True, + "corrected_model": auto[0], + "message": f"Auto-corrected `{requested}` → `{auto[0]}`", + } suggestions = get_close_matches(requested_for_lookup, codex_models, n=3, cutoff=0.5) suggestion_text = "" if suggestions: @@ -1903,6 +1924,18 @@ def validate_requested_model( # the user may have access to models not shown in the public # listing (e.g. Z.AI Pro/Max plans can use glm-5 on coding # endpoints even though it's not in /models). Warn but allow. + + # Auto-correct if the top match is very similar (e.g. typo) + auto = get_close_matches(requested_for_lookup, api_models, n=1, cutoff=0.9) + if auto: + return { + "accepted": True, + "persist": True, + "recognized": True, + "corrected_model": auto[0], + "message": f"Auto-corrected `{requested}` → `{auto[0]}`", + } + suggestions = get_close_matches(requested, api_models, n=3, cutoff=0.5) suggestion_text = "" if suggestions: diff --git a/tests/hermes_cli/test_model_validation.py b/tests/hermes_cli/test_model_validation.py index af1d89ae8..5ed6b9d54 100644 --- a/tests/hermes_cli/test_model_validation.py +++ b/tests/hermes_cli/test_model_validation.py @@ -436,7 +436,22 @@ class TestValidateApiNotFound: def test_warning_includes_suggestions(self): result = _validate("anthropic/claude-opus-4.5") assert result["accepted"] is True - assert "Similar models" in result["message"] + # Close match auto-corrects; less similar inputs show suggestions + assert "Auto-corrected" in result["message"] or "Similar models" in result["message"] + + def test_auto_correction_returns_corrected_model(self): + """When a very close match exists, validate returns corrected_model.""" + result = _validate("anthropic/claude-opus-4.5") + assert result["accepted"] is True + assert result.get("corrected_model") == "anthropic/claude-opus-4.6" + assert result["recognized"] is True + + def test_dissimilar_model_shows_suggestions_not_autocorrect(self): + """Models too different for auto-correction still get suggestions.""" + result = _validate("anthropic/claude-nonexistent") + assert result["accepted"] is True + assert result.get("corrected_model") is None + assert "not found" in result["message"] # -- validate — API unreachable — accept and persist everything ---------------- @@ -486,3 +501,40 @@ class TestValidateApiFallback: assert result["persist"] is True assert "http://localhost:8000/v1/models" in result["message"] assert "http://localhost:8000/v1" in result["message"] + + +# -- validate — Codex auto-correction ------------------------------------------ + +class TestValidateCodexAutoCorrection: + """Auto-correction for typos on openai-codex provider.""" + + def test_missing_dash_auto_corrects(self): + """gpt5.3-codex (missing dash) auto-corrects to gpt-5.3-codex.""" + codex_models = ["gpt-5.4-mini", "gpt-5.4", "gpt-5.3-codex", + "gpt-5.2-codex", "gpt-5.1-codex-max"] + with patch("hermes_cli.models.provider_model_ids", return_value=codex_models): + result = validate_requested_model("gpt5.3-codex", "openai-codex") + assert result["accepted"] is True + assert result["recognized"] is True + assert result["corrected_model"] == "gpt-5.3-codex" + assert "Auto-corrected" in result["message"] + + def test_exact_match_no_correction(self): + """Exact model name does not trigger auto-correction.""" + codex_models = ["gpt-5.4-mini", "gpt-5.4", "gpt-5.3-codex"] + with patch("hermes_cli.models.provider_model_ids", return_value=codex_models): + result = validate_requested_model("gpt-5.3-codex", "openai-codex") + assert result["accepted"] is True + assert result["recognized"] is True + assert result.get("corrected_model") is None + assert result["message"] is None + + def test_very_different_name_falls_to_suggestions(self): + """Names too different for auto-correction get the suggestion list.""" + codex_models = ["gpt-5.4-mini", "gpt-5.4", "gpt-5.3-codex"] + with patch("hermes_cli.models.provider_model_ids", return_value=codex_models): + result = validate_requested_model("totally-wrong", "openai-codex") + assert result["accepted"] is True + assert result["recognized"] is False + assert result.get("corrected_model") is None + assert "not found" in result["message"] From 19199cd38d826ad146ee9e4a0cdfed78e347ff10 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:11:13 -0700 Subject: [PATCH 10/58] fix: clamp 'minimal' reasoning effort to 'low' on Responses API (#9429) GPT-5.4 supports none/low/medium/high/xhigh but not 'minimal'. Users may configure 'minimal' via OpenRouter conventions, which would cause a 400 on native OpenAI. Clamp to 'low' in the codex_responses path before sending. --- run_agent.py | 6 ++ .../test_run_agent_codex_responses.py | 63 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/run_agent.py b/run_agent.py index 592253464..626951b27 100644 --- a/run_agent.py +++ b/run_agent.py @@ -6143,6 +6143,12 @@ class AIAgent: elif self.reasoning_config.get("effort"): reasoning_effort = self.reasoning_config["effort"] + # Clamp effort levels not supported by the Responses API model. + # GPT-5.4 supports none/low/medium/high/xhigh but not "minimal". + # "minimal" is valid on OpenRouter and GPT-5 but fails on 5.2/5.4. + _effort_clamp = {"minimal": "low"} + reasoning_effort = _effort_clamp.get(reasoning_effort, reasoning_effort) + kwargs = { "model": self.model, "instructions": instructions, diff --git a/tests/run_agent/test_run_agent_codex_responses.py b/tests/run_agent/test_run_agent_codex_responses.py index 0fca9e4df..785d85886 100644 --- a/tests/run_agent/test_run_agent_codex_responses.py +++ b/tests/run_agent/test_run_agent_codex_responses.py @@ -287,6 +287,69 @@ def test_build_api_kwargs_codex(monkeypatch): assert "extra_body" not in kwargs +def test_build_api_kwargs_codex_clamps_minimal_effort(monkeypatch): + """'minimal' reasoning effort is clamped to 'low' on the Responses API. + + GPT-5.4 supports none/low/medium/high/xhigh but NOT 'minimal'. + Users may configure 'minimal' via OpenRouter conventions, so the Codex + Responses path must clamp it to the nearest supported level. + """ + _patch_agent_bootstrap(monkeypatch) + + agent = run_agent.AIAgent( + model="gpt-5-codex", + base_url="https://chatgpt.com/backend-api/codex", + api_key="codex-token", + quiet_mode=True, + max_iterations=4, + skip_context_files=True, + skip_memory=True, + reasoning_config={"enabled": True, "effort": "minimal"}, + ) + agent._cleanup_task_resources = lambda task_id: None + agent._persist_session = lambda messages, history=None: None + agent._save_trajectory = lambda messages, user_message, completed: None + agent._save_session_log = lambda messages: None + + kwargs = agent._build_api_kwargs( + [ + {"role": "system", "content": "You are Hermes."}, + {"role": "user", "content": "Ping"}, + ] + ) + + assert kwargs["reasoning"]["effort"] == "low" + + +def test_build_api_kwargs_codex_preserves_supported_efforts(monkeypatch): + """Effort levels natively supported by the Responses API pass through unchanged.""" + _patch_agent_bootstrap(monkeypatch) + + for effort in ("low", "medium", "high", "xhigh"): + agent = run_agent.AIAgent( + model="gpt-5-codex", + base_url="https://chatgpt.com/backend-api/codex", + api_key="codex-token", + quiet_mode=True, + max_iterations=4, + skip_context_files=True, + skip_memory=True, + reasoning_config={"enabled": True, "effort": effort}, + ) + agent._cleanup_task_resources = lambda task_id: None + agent._persist_session = lambda messages, history=None: None + agent._save_trajectory = lambda messages, user_message, completed: None + agent._save_session_log = lambda messages: None + + kwargs = agent._build_api_kwargs( + [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "hi"}, + ] + ) + assert kwargs["reasoning"]["effort"] == effort, f"{effort} should pass through unchanged" + + def test_build_api_kwargs_copilot_responses_omits_openai_only_fields(monkeypatch): agent = _build_copilot_agent(monkeypatch) kwargs = agent._build_api_kwargs([{"role": "user", "content": "hi"}]) From a2ea237db22580f6442dda07964f2bbb94b2fe0d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:19:13 -0700 Subject: [PATCH 11/58] =?UTF-8?q?feat:=20add=20internationalization=20(i18?= =?UTF-8?q?n)=20to=20web=20dashboard=20=E2=80=94=20English=20+=20Chinese?= =?UTF-8?q?=20(#9453)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a lightweight i18n system to the web dashboard with English (default) and Chinese language support. A language switcher with flag icons is placed in the header bar, allowing users to toggle between languages. The choice persists to localStorage. Implementation: - src/i18n/ — types, translation files (en.ts, zh.ts), React context + hook - LanguageSwitcher component shows the *other* language's flag as the toggle - I18nProvider wraps the app in main.tsx - All 8 pages + OAuth components updated to use t() translation calls - Zero new dependencies — pure React context + localStorage --- web/package-lock.json | 13 - web/src/App.tsx | 127 +++-- web/src/components/LanguageSwitcher.tsx | 27 + web/src/components/OAuthLoginModal.tsx | 82 +-- web/src/components/OAuthProvidersCard.tsx | 74 +-- web/src/i18n/context.tsx | 58 ++ web/src/i18n/en.ts | 275 +++++++++ web/src/i18n/index.ts | 2 + web/src/i18n/types.ts | 287 ++++++++++ web/src/i18n/zh.ts | 275 +++++++++ web/src/main.tsx | 6 +- web/src/pages/AnalyticsPage.tsx | 66 ++- web/src/pages/ConfigPage.tsx | 134 ++--- web/src/pages/CronPage.tsx | 78 +-- web/src/pages/EnvPage.tsx | 85 +-- web/src/pages/LogsPage.tsx | 276 ++++----- web/src/pages/SessionsPage.tsx | 60 +- web/src/pages/SkillsPage.tsx | 662 ++++++++++------------ web/src/pages/StatusPage.tsx | 105 ++-- 19 files changed, 1715 insertions(+), 977 deletions(-) create mode 100644 web/src/components/LanguageSwitcher.tsx create mode 100644 web/src/i18n/context.tsx create mode 100644 web/src/i18n/en.ts create mode 100644 web/src/i18n/index.ts create mode 100644 web/src/i18n/types.ts create mode 100644 web/src/i18n/zh.ts diff --git a/web/package-lock.json b/web/package-lock.json index 8299c8e49..71ca2c7a7 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -64,7 +64,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1639,7 +1638,6 @@ "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1650,7 +1648,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1710,7 +1707,6 @@ "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", @@ -1988,7 +1984,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2097,7 +2092,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2374,7 +2368,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3338,7 +3331,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3399,7 +3391,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3409,7 +3400,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3676,7 +3666,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3762,7 +3751,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -3884,7 +3872,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/src/App.tsx b/web/src/App.tsx index f2c72d5a6..3d2832ccb 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,4 @@ -import { Routes, Route, NavLink, Navigate } from "react-router-dom"; +import { useState, useEffect, useRef } from "react"; import { Activity, BarChart3, Clock, FileText, KeyRound, MessageSquare, Package, Settings } from "lucide-react"; import StatusPage from "@/pages/StatusPage"; import ConfigPage from "@/pages/ConfigPage"; @@ -8,89 +8,118 @@ import LogsPage from "@/pages/LogsPage"; import AnalyticsPage from "@/pages/AnalyticsPage"; import CronPage from "@/pages/CronPage"; import SkillsPage from "@/pages/SkillsPage"; +import { LanguageSwitcher } from "@/components/LanguageSwitcher"; +import { useI18n } from "@/i18n"; const NAV_ITEMS = [ - { path: "/", label: "Status", icon: Activity }, - { path: "/sessions", label: "Sessions", icon: MessageSquare }, - { path: "/analytics", label: "Analytics", icon: BarChart3 }, - { path: "/logs", label: "Logs", icon: FileText }, - { path: "/cron", label: "Cron", icon: Clock }, - { path: "/skills", label: "Skills", icon: Package }, - { path: "/config", label: "Config", icon: Settings }, - { path: "/env", label: "Keys", icon: KeyRound }, + { id: "status", labelKey: "status" as const, icon: Activity }, + { id: "sessions", labelKey: "sessions" as const, icon: MessageSquare }, + { id: "analytics", labelKey: "analytics" as const, icon: BarChart3 }, + { id: "logs", labelKey: "logs" as const, icon: FileText }, + { id: "cron", labelKey: "cron" as const, icon: Clock }, + { id: "skills", labelKey: "skills" as const, icon: Package }, + { id: "config", labelKey: "config" as const, icon: Settings }, + { id: "env", labelKey: "keys" as const, icon: KeyRound }, ] as const; +type PageId = (typeof NAV_ITEMS)[number]["id"]; + +const PAGE_COMPONENTS: Record = { + status: StatusPage, + sessions: SessionsPage, + analytics: AnalyticsPage, + logs: LogsPage, + cron: CronPage, + skills: SkillsPage, + config: ConfigPage, + env: EnvPage, +}; + export default function App() { + const [page, setPage] = useState("status"); + const [animKey, setAnimKey] = useState(0); + const initialRef = useRef(true); + const { t } = useI18n(); + + useEffect(() => { + // Skip the animation key bump on initial mount to avoid re-mounting + // the default page component (which causes duplicate API requests). + if (initialRef.current) { + initialRef.current = false; + return; + } + setAnimKey((k) => k + 1); + }, [page]); + + const PageComponent = PAGE_COMPONENTS[page]; + return (
+ {/* Global grain + warm glow (matches landing page) */}
-
+ {/* ---- Header with grid-border nav ---- */} +
+ {/* Brand — abbreviated on mobile */}
Hermes Agent
+ {/* Nav — icons only on mobile, icon+label on sm+ */} -
- - Web UI + {/* Right side: language switcher + version badge */} +
+ + + {t.app.webUi}
-
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - +
+
+ {/* ---- Footer ---- */}
- Hermes Agent + {t.app.footer.name} - Nous Research + {t.app.footer.org}
diff --git a/web/src/components/LanguageSwitcher.tsx b/web/src/components/LanguageSwitcher.tsx new file mode 100644 index 000000000..4cc945e96 --- /dev/null +++ b/web/src/components/LanguageSwitcher.tsx @@ -0,0 +1,27 @@ +import { useI18n } from "@/i18n/context"; + +/** + * Compact language toggle — shows a clickable flag that switches between + * English and Chinese. Persists choice to localStorage. + */ +export function LanguageSwitcher() { + const { locale, setLocale, t } = useI18n(); + + const toggle = () => setLocale(locale === "en" ? "zh" : "en"); + + return ( + + ); +} diff --git a/web/src/components/OAuthLoginModal.tsx b/web/src/components/OAuthLoginModal.tsx index 836ec4a1a..e0e756eca 100644 --- a/web/src/components/OAuthLoginModal.tsx +++ b/web/src/components/OAuthLoginModal.tsx @@ -3,29 +3,7 @@ import { ExternalLink, Copy, X, Check, Loader2 } from "lucide-react"; import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; - -/** - * OAuthLoginModal — drives the in-browser OAuth flow for a single provider. - * - * Two variants share the same modal shell: - * - * - PKCE (Anthropic): user opens the auth URL in a new tab, authorizes, - * pastes the resulting code back. We POST it to /submit which exchanges - * the (code + verifier) pair for tokens server-side. - * - * - Device code (Nous, OpenAI Codex): we display the verification URL - * and short user code; the backend polls the provider's token endpoint - * in a background thread; we poll /poll/{session_id} every 2s for status. - * - * Edge cases handled: - * - Popup blocker (we use plain anchor href + open in new tab; no popup - * window.open which is more likely to be blocked). - * - Modal dismissal mid-flight cancels the server-side session via DELETE. - * - Code expiry surfaces as a clear error state with retry button. - * - Polling continues to work if the user backgrounds the tab (setInterval - * keeps firing in modern browsers; we guard against polls firing after - * component unmount via an isMounted ref). - */ +import { useI18n } from "@/i18n"; interface Props { provider: OAuthProvider; @@ -45,6 +23,7 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props const [codeCopied, setCodeCopied] = useState(false); const isMounted = useRef(true); const pollTimer = useRef(null); + const { t } = useI18n(); // Initiate flow on mount useEffect(() => { @@ -57,10 +36,8 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props setSecondsLeft(resp.expires_in); setPhase(resp.flow === "device_code" ? "polling" : "awaiting_user"); if (resp.flow === "pkce") { - // Auto-open the auth URL in a new tab window.open(resp.auth_url, "_blank", "noopener,noreferrer"); } else { - // Device-code: open the verification URL automatically window.open(resp.verification_url, "_blank", "noopener,noreferrer"); } }) @@ -73,7 +50,6 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props isMounted.current = false; if (pollTimer.current !== null) window.clearInterval(pollTimer.current); }; - // We only want to start the flow once on mount. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -85,16 +61,15 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props if (!isMounted.current) return; setSecondsLeft((s) => { if (s !== null && s <= 1) { - // Session expired — transition to error state setPhase("error"); - setErrorMsg("Session expired. Click Retry to start a new login."); + setErrorMsg(t.oauth.sessionExpired); return 0; } return s !== null && s > 0 ? s - 1 : 0; }); }, 1000); return () => window.clearInterval(tick); - }, [secondsLeft, phase]); + }, [secondsLeft, phase, t]); // Device-code: poll backend every 2s useEffect(() => { @@ -115,7 +90,6 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props if (pollTimer.current !== null) window.clearInterval(pollTimer.current); } } catch (e) { - // 404 = session expired/cleaned up; treat as error if (!isMounted.current) return; setPhase("error"); setErrorMsg(`Polling failed: ${e}`); @@ -151,12 +125,11 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props }; const handleClose = async () => { - // Cancel server session if still in flight if (start && phase !== "approved" && phase !== "error") { try { await api.cancelOAuthSession(start.session_id); } catch { - // ignore — server-side TTL will clean it up anyway + // ignore } } onClose(); @@ -172,7 +145,6 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props } }; - // Backdrop click closes const handleBackdrop = (e: React.MouseEvent) => { if (e.target === e.currentTarget) handleClose(); }; @@ -197,18 +169,18 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props type="button" onClick={handleClose} className="absolute right-3 top-3 text-muted-foreground hover:text-foreground transition-colors" - aria-label="Close" + aria-label={t.common.close} >

- Connect {provider.name} + {t.oauth.connect} {provider.name}

{secondsLeft !== null && phase !== "approved" && phase !== "error" && (

- Session expires in {fmtTime(secondsLeft)} + {t.oauth.sessionExpires.replace("{time}", fmtTime(secondsLeft))}

)}
@@ -217,7 +189,7 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props {phase === "starting" && (
- Initiating login flow… + {t.oauth.initiatingLogin}
)} @@ -225,18 +197,15 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props {start?.flow === "pkce" && phase === "awaiting_user" && ( <>
    -
  1. - A new tab opened to claude.ai. Sign in - and click Authorize. -
  2. -
  3. Copy the authorization code shown after authorizing.
  4. -
  5. Paste it below and submit.
  6. +
  7. {t.oauth.pkceStep1}
  8. +
  9. {t.oauth.pkceStep2}
  10. +
  11. {t.oauth.pkceStep3}
setPkceCode(e.target.value)} - placeholder="Paste authorization code (with #state suffix is fine)" + placeholder={t.oauth.pasteCode} onKeyDown={(e) => e.key === "Enter" && handleSubmitPkceCode()} autoFocus /> @@ -248,10 +217,10 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1" > - Re-open auth page + {t.oauth.reOpenAuth}
@@ -262,7 +231,7 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props {phase === "submitting" && (
- Exchanging code for tokens… + {t.oauth.exchangingCode}
)} @@ -270,7 +239,7 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props {start?.flow === "device_code" && phase === "polling" && ( <>

- A new tab opened. Enter this code if prompted: + {t.oauth.enterCodePrompt}

@@ -296,11 +265,11 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1" > - Re-open verification page + {t.oauth.reOpenVerification}
- Waiting for you to authorize in the browser… + {t.oauth.waitingAuth}
)} @@ -309,7 +278,7 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props {phase === "approved" && (
- Connected! Closing… + {t.oauth.connectedClosing}
)} @@ -317,16 +286,15 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props {phase === "error" && ( <>
- {errorMsg || "Login failed."} + {errorMsg || t.oauth.loginFailed}
diff --git a/web/src/components/OAuthProvidersCard.tsx b/web/src/components/OAuthProvidersCard.tsx index 4449ac9b1..513afc00c 100644 --- a/web/src/components/OAuthProvidersCard.tsx +++ b/web/src/components/OAuthProvidersCard.tsx @@ -5,29 +5,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { OAuthLoginModal } from "@/components/OAuthLoginModal"; - -/** - * OAuthProvidersCard — surfaces every OAuth-capable LLM provider with its - * current connection status, a truncated token preview when connected, and - * action buttons (Copy CLI command for setup, Disconnect for cleanup). - * - * Phase 1 scope: read-only status + disconnect + copy-to-clipboard CLI - * command. Phase 2 will add in-browser PKCE / device-code flows so users - * never need to drop to a terminal. - */ +import { useI18n } from "@/i18n"; interface Props { onError?: (msg: string) => void; onSuccess?: (msg: string) => void; } -const FLOW_LABELS: Record = { - pkce: "Browser login (PKCE)", - device_code: "Device code", - external: "External CLI", -}; - -function formatExpiresAt(expiresAt: string | null | undefined): string | null { +function formatExpiresAt(expiresAt: string | null | undefined, expiresInTemplate: string): string | null { if (!expiresAt) return null; try { const dt = new Date(expiresAt); @@ -36,11 +21,11 @@ function formatExpiresAt(expiresAt: string | null | undefined): string | null { const diff = dt.getTime() - now; if (diff < 0) return "expired"; const mins = Math.floor(diff / 60_000); - if (mins < 60) return `expires in ${mins}m`; + if (mins < 60) return expiresInTemplate.replace("{time}", `${mins}m`); const hours = Math.floor(mins / 60); - if (hours < 24) return `expires in ${hours}h`; + if (hours < 24) return expiresInTemplate.replace("{time}", `${hours}h`); const days = Math.floor(hours / 24); - return `expires in ${days}d`; + return expiresInTemplate.replace("{time}", `${days}d`); } catch { return null; } @@ -51,10 +36,9 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) { const [loading, setLoading] = useState(true); const [busyId, setBusyId] = useState(null); const [copiedId, setCopiedId] = useState(null); - // Provider that the login modal is currently open for. null = modal closed. const [loginFor, setLoginFor] = useState(null); + const { t } = useI18n(); - // Use refs for callbacks to avoid re-creating refresh() when parent re-renders const onErrorRef = useRef(onError); onErrorRef.current = onError; @@ -83,16 +67,16 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) { }; const handleDisconnect = async (provider: OAuthProvider) => { - if (!confirm(`Disconnect ${provider.name}? You'll need to log in again to use this provider.`)) { + if (!confirm(`${t.oauth.disconnect} ${provider.name}?`)) { return; } setBusyId(provider.id); try { await api.disconnectOAuthProvider(provider.id); - onSuccess?.(`${provider.name} disconnected`); + onSuccess?.(`${provider.name} ${t.oauth.disconnect.toLowerCase()}ed`); refresh(); } catch (e) { - onError?.(`Disconnect failed: ${e}`); + onError?.(`${t.oauth.disconnect} failed: ${e}`); } finally { setBusyId(null); } @@ -107,7 +91,7 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
- Provider Logins (OAuth) + {t.oauth.providerLogins}
- {connectedCount} of {totalCount} OAuth providers connected. Login flows currently - run via the CLI; click Copy command and paste into a terminal to set up. + {t.oauth.description.replace("{connected}", String(connectedCount)).replace("{total}", String(totalCount))} @@ -133,12 +116,12 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) { )} {providers && providers.length === 0 && (

- No OAuth-capable providers detected. + {t.oauth.noProviders}

)}
{providers?.map((p) => { - const expiresLabel = formatExpiresAt(p.status.expires_at); + const expiresLabel = formatExpiresAt(p.status.expires_at, t.oauth.expiresIn); const isBusy = busyId === p.id; return (
{p.name} - {FLOW_LABELS[p.flow]} + {t.oauth.flowLabels[p.flow]} {p.status.logged_in && ( - Connected + {t.oauth.connected} )} {expiresLabel === "expired" && ( - Expired + {t.oauth.expired} )} {expiresLabel && expiresLabel !== "expired" && ( @@ -187,11 +170,11 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) { )} {!p.status.logged_in && ( - Not connected. Run{" "} - + {t.oauth.notConnected.split("{command}")[0]} + {p.cli_command} - {" "} - in a terminal. + + {t.oauth.notConnected.split("{command}")[1]} )} {p.status.error && ( @@ -222,10 +205,9 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) { size="sm" onClick={() => setLoginFor(p)} className="text-xs h-7" - title={`Start ${p.flow === "pkce" ? "browser" : "device code"} login`} > - Login + {t.oauth.login} )} {!p.status.logged_in && ( @@ -234,14 +216,14 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) { size="sm" onClick={() => handleCopy(p)} className="text-xs h-7" - title="Copy CLI command (for external / fallback)" + title={t.oauth.copyCliCommand} > {copiedId === p.id ? ( - <>Copied ✓ + <>{t.oauth.copied} ) : ( <> - CLI + {t.oauth.cli} )} @@ -259,13 +241,13 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) { ) : ( )} - Disconnect + {t.oauth.disconnect} )} {p.status.logged_in && p.flow === "external" && ( - Managed externally + {t.oauth.managedExternally} )}
@@ -279,7 +261,7 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) { provider={loginFor} onClose={() => { setLoginFor(null); - refresh(); // always refresh on close so token preview updates after login + refresh(); }} onSuccess={(msg) => onSuccess?.(msg)} onError={(msg) => onError?.(msg)} diff --git a/web/src/i18n/context.tsx b/web/src/i18n/context.tsx new file mode 100644 index 000000000..6fc6f6e56 --- /dev/null +++ b/web/src/i18n/context.tsx @@ -0,0 +1,58 @@ +import { createContext, useContext, useState, useCallback, type ReactNode } from "react"; +import type { Locale, Translations } from "./types"; +import { en } from "./en"; +import { zh } from "./zh"; + +const TRANSLATIONS: Record = { en, zh }; +const STORAGE_KEY = "hermes-locale"; + +function getInitialLocale(): Locale { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === "en" || stored === "zh") return stored; + } catch { + // SSR or privacy mode + } + return "en"; +} + +interface I18nContextValue { + locale: Locale; + setLocale: (l: Locale) => void; + t: Translations; +} + +const I18nContext = createContext({ + locale: "en", + setLocale: () => {}, + t: en, +}); + +export function I18nProvider({ children }: { children: ReactNode }) { + const [locale, setLocaleState] = useState(getInitialLocale); + + const setLocale = useCallback((l: Locale) => { + setLocaleState(l); + try { + localStorage.setItem(STORAGE_KEY, l); + } catch { + // ignore + } + }, []); + + const value: I18nContextValue = { + locale, + setLocale, + t: TRANSLATIONS[locale], + }; + + return ( + + {children} + + ); +} + +export function useI18n() { + return useContext(I18nContext); +} diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts new file mode 100644 index 000000000..8b387b463 --- /dev/null +++ b/web/src/i18n/en.ts @@ -0,0 +1,275 @@ +import type { Translations } from "./types"; + +export const en: Translations = { + common: { + save: "Save", + saving: "Saving...", + cancel: "Cancel", + close: "Close", + delete: "Delete", + refresh: "Refresh", + retry: "Retry", + search: "Search...", + loading: "Loading...", + create: "Create", + creating: "Creating...", + set: "Set", + replace: "Replace", + clear: "Clear", + live: "Live", + off: "Off", + enabled: "enabled", + disabled: "disabled", + active: "active", + inactive: "inactive", + unknown: "unknown", + untitled: "Untitled", + none: "None", + form: "Form", + noResults: "No results", + of: "of", + page: "Page", + msgs: "msgs", + tools: "tools", + match: "match", + other: "Other", + configured: "configured", + removed: "removed", + failedToToggle: "Failed to toggle", + failedToRemove: "Failed to remove", + failedToReveal: "Failed to reveal", + collapse: "Collapse", + expand: "Expand", + general: "General", + messaging: "Messaging", + }, + + app: { + brand: "Hermes Agent", + brandShort: "HA", + webUi: "Web UI", + footer: { + name: "Hermes Agent", + org: "Nous Research", + }, + nav: { + status: "Status", + sessions: "Sessions", + analytics: "Analytics", + logs: "Logs", + cron: "Cron", + skills: "Skills", + config: "Config", + keys: "Keys", + }, + }, + + status: { + agent: "Agent", + gateway: "Gateway", + activeSessions: "Active Sessions", + recentSessions: "Recent Sessions", + connectedPlatforms: "Connected Platforms", + running: "Running", + starting: "Starting", + failed: "Failed", + stopped: "Stopped", + connected: "Connected", + disconnected: "Disconnected", + error: "Error", + notRunning: "Not running", + startFailed: "Start failed", + pid: "PID", + noneRunning: "None", + gatewayFailedToStart: "Gateway failed to start", + lastUpdate: "Last update", + platformError: "error", + platformDisconnected: "disconnected", + }, + + sessions: { + title: "Sessions", + searchPlaceholder: "Search message content...", + noSessions: "No sessions yet", + noMatch: "No sessions match your search", + startConversation: "Start a conversation to see it here", + noMessages: "No messages", + untitledSession: "Untitled session", + deleteSession: "Delete session", + previousPage: "Previous page", + nextPage: "Next page", + roles: { + user: "User", + assistant: "Assistant", + system: "System", + tool: "Tool", + }, + }, + + analytics: { + period: "Period:", + totalTokens: "Total Tokens", + totalSessions: "Total Sessions", + apiCalls: "API Calls", + dailyTokenUsage: "Daily Token Usage", + dailyBreakdown: "Daily Breakdown", + perModelBreakdown: "Per-Model Breakdown", + input: "Input", + output: "Output", + total: "Total", + noUsageData: "No usage data for this period", + startSession: "Start a session to see analytics here", + date: "Date", + model: "Model", + tokens: "Tokens", + perDayAvg: "/day avg", + acrossModels: "across {count} models", + inOut: "{input} in / {output} out", + }, + + logs: { + title: "Logs", + autoRefresh: "Auto-refresh", + file: "File", + level: "Level", + component: "Component", + lines: "Lines", + noLogLines: "No log lines found", + }, + + cron: { + newJob: "New Cron Job", + nameOptional: "Name (optional)", + namePlaceholder: "e.g. Daily summary", + prompt: "Prompt", + promptPlaceholder: "What should the agent do on each run?", + schedule: "Schedule (cron expression)", + schedulePlaceholder: "0 9 * * *", + deliverTo: "Deliver to", + scheduledJobs: "Scheduled Jobs", + noJobs: "No cron jobs configured. Create one above.", + last: "Last", + next: "Next", + pause: "Pause", + resume: "Resume", + triggerNow: "Trigger now", + delivery: { + local: "Local", + telegram: "Telegram", + discord: "Discord", + slack: "Slack", + email: "Email", + }, + }, + + skills: { + title: "Skills", + searchPlaceholder: "Search skills and toolsets...", + enabledOf: "{enabled}/{total} enabled", + all: "All", + noSkills: "No skills found. Skills are loaded from ~/.hermes/skills/", + noSkillsMatch: "No skills match your search or filter.", + skillCount: "{count} skill{s}", + noDescription: "No description available.", + toolsets: "Toolsets", + noToolsetsMatch: "No toolsets match the search.", + setupNeeded: "Setup needed", + disabledForCli: "Disabled for CLI", + more: "+{count} more", + }, + + config: { + configPath: "~/.hermes/config.yaml", + exportConfig: "Export config as JSON", + importConfig: "Import config from JSON", + resetDefaults: "Reset to defaults", + rawYaml: "Raw YAML Configuration", + searchResults: "Search Results", + fields: "field{s}", + noFieldsMatch: 'No fields match "{query}"', + configSaved: "Configuration saved", + yamlConfigSaved: "YAML config saved", + failedToSave: "Failed to save", + failedToSaveYaml: "Failed to save YAML", + failedToLoadRaw: "Failed to load raw config", + configImported: "Config imported — review and save", + invalidJson: "Invalid JSON file", + categories: { + general: "General", + agent: "Agent", + terminal: "Terminal", + display: "Display", + delegation: "Delegation", + memory: "Memory", + compression: "Compression", + security: "Security", + browser: "Browser", + voice: "Voice", + tts: "Text-to-Speech", + stt: "Speech-to-Text", + logging: "Logging", + discord: "Discord", + auxiliary: "Auxiliary", + }, + }, + + env: { + description: "Manage API keys and secrets stored in", + changesNote: "Changes are saved to disk immediately. Active sessions pick up new keys automatically.", + hideAdvanced: "Hide Advanced", + showAdvanced: "Show Advanced", + llmProviders: "LLM Providers", + providersConfigured: "{configured} of {total} providers configured", + getKey: "Get key", + notConfigured: "{count} not configured", + notSet: "Not set", + keysCount: "{count} key{s}", + enterValue: "Enter value...", + replaceCurrentValue: "Replace current value ({preview})", + showValue: "Show real value", + hideValue: "Hide value", + }, + + oauth: { + title: "Provider Logins (OAuth)", + providerLogins: "Provider Logins (OAuth)", + description: "{connected} of {total} OAuth providers connected. Login flows currently run via the CLI; click Copy command and paste into a terminal to set up.", + connected: "Connected", + expired: "Expired", + notConnected: "Not connected. Run {command} in a terminal.", + runInTerminal: "in a terminal.", + noProviders: "No OAuth-capable providers detected.", + login: "Login", + disconnect: "Disconnect", + managedExternally: "Managed externally", + copied: "Copied ✓", + cli: "CLI", + copyCliCommand: "Copy CLI command (for external / fallback)", + connect: "Connect", + sessionExpires: "Session expires in {time}", + initiatingLogin: "Initiating login flow…", + exchangingCode: "Exchanging code for tokens…", + connectedClosing: "Connected! Closing…", + loginFailed: "Login failed.", + sessionExpired: "Session expired. Click Retry to start a new login.", + reOpenAuth: "Re-open auth page", + reOpenVerification: "Re-open verification page", + submitCode: "Submit code", + pasteCode: "Paste authorization code (with #state suffix is fine)", + waitingAuth: "Waiting for you to authorize in the browser…", + enterCodePrompt: "A new tab opened. Enter this code if prompted:", + pkceStep1: "A new tab opened to claude.ai. Sign in and click Authorize.", + pkceStep2: "Copy the authorization code shown after authorizing.", + pkceStep3: "Paste it below and submit.", + flowLabels: { + pkce: "Browser login (PKCE)", + device_code: "Device code", + external: "External CLI", + }, + expiresIn: "expires in {time}", + }, + + language: { + switchTo: "Switch to Chinese", + }, +}; diff --git a/web/src/i18n/index.ts b/web/src/i18n/index.ts new file mode 100644 index 000000000..7a9a9471e --- /dev/null +++ b/web/src/i18n/index.ts @@ -0,0 +1,2 @@ +export { I18nProvider, useI18n } from "./context"; +export type { Locale, Translations } from "./types"; diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts new file mode 100644 index 000000000..86b21c405 --- /dev/null +++ b/web/src/i18n/types.ts @@ -0,0 +1,287 @@ +export type Locale = "en" | "zh"; + +export interface Translations { + // ── Common ── + common: { + save: string; + saving: string; + cancel: string; + close: string; + delete: string; + refresh: string; + retry: string; + search: string; + loading: string; + create: string; + creating: string; + set: string; + replace: string; + clear: string; + live: string; + off: string; + enabled: string; + disabled: string; + active: string; + inactive: string; + unknown: string; + untitled: string; + none: string; + form: string; + noResults: string; + of: string; + page: string; + msgs: string; + tools: string; + match: string; + other: string; + configured: string; + removed: string; + failedToToggle: string; + failedToRemove: string; + failedToReveal: string; + collapse: string; + expand: string; + general: string; + messaging: string; + }; + + // ── App shell ── + app: { + brand: string; + brandShort: string; + webUi: string; + footer: { + name: string; + org: string; + }; + nav: { + status: string; + sessions: string; + analytics: string; + logs: string; + cron: string; + skills: string; + config: string; + keys: string; + }; + }; + + // ── Status page ── + status: { + agent: string; + gateway: string; + activeSessions: string; + recentSessions: string; + connectedPlatforms: string; + running: string; + starting: string; + failed: string; + stopped: string; + connected: string; + disconnected: string; + error: string; + notRunning: string; + startFailed: string; + pid: string; + noneRunning: string; + gatewayFailedToStart: string; + lastUpdate: string; + platformError: string; + platformDisconnected: string; + }; + + // ── Sessions page ── + sessions: { + title: string; + searchPlaceholder: string; + noSessions: string; + noMatch: string; + startConversation: string; + noMessages: string; + untitledSession: string; + deleteSession: string; + previousPage: string; + nextPage: string; + roles: { + user: string; + assistant: string; + system: string; + tool: string; + }; + }; + + // ── Analytics page ── + analytics: { + period: string; + totalTokens: string; + totalSessions: string; + apiCalls: string; + dailyTokenUsage: string; + dailyBreakdown: string; + perModelBreakdown: string; + input: string; + output: string; + total: string; + noUsageData: string; + startSession: string; + date: string; + model: string; + tokens: string; + perDayAvg: string; + acrossModels: string; + inOut: string; + }; + + // ── Logs page ── + logs: { + title: string; + autoRefresh: string; + file: string; + level: string; + component: string; + lines: string; + noLogLines: string; + }; + + // ── Cron page ── + cron: { + newJob: string; + nameOptional: string; + namePlaceholder: string; + prompt: string; + promptPlaceholder: string; + schedule: string; + schedulePlaceholder: string; + deliverTo: string; + scheduledJobs: string; + noJobs: string; + last: string; + next: string; + pause: string; + resume: string; + triggerNow: string; + delivery: { + local: string; + telegram: string; + discord: string; + slack: string; + email: string; + }; + }; + + // ── Skills page ── + skills: { + title: string; + searchPlaceholder: string; + enabledOf: string; + all: string; + noSkills: string; + noSkillsMatch: string; + skillCount: string; + noDescription: string; + toolsets: string; + noToolsetsMatch: string; + setupNeeded: string; + disabledForCli: string; + more: string; + }; + + // ── Config page ── + config: { + configPath: string; + exportConfig: string; + importConfig: string; + resetDefaults: string; + rawYaml: string; + searchResults: string; + fields: string; + noFieldsMatch: string; + configSaved: string; + yamlConfigSaved: string; + failedToSave: string; + failedToSaveYaml: string; + failedToLoadRaw: string; + configImported: string; + invalidJson: string; + categories: { + general: string; + agent: string; + terminal: string; + display: string; + delegation: string; + memory: string; + compression: string; + security: string; + browser: string; + voice: string; + tts: string; + stt: string; + logging: string; + discord: string; + auxiliary: string; + }; + }; + + // ── Env / Keys page ── + env: { + description: string; + changesNote: string; + hideAdvanced: string; + showAdvanced: string; + llmProviders: string; + providersConfigured: string; + getKey: string; + notConfigured: string; + notSet: string; + keysCount: string; + enterValue: string; + replaceCurrentValue: string; + showValue: string; + hideValue: string; + }; + + // ── OAuth ── + oauth: { + title: string; + providerLogins: string; + description: string; + connected: string; + expired: string; + notConnected: string; + runInTerminal: string; + noProviders: string; + login: string; + disconnect: string; + managedExternally: string; + copied: string; + cli: string; + copyCliCommand: string; + connect: string; + sessionExpires: string; + initiatingLogin: string; + exchangingCode: string; + connectedClosing: string; + loginFailed: string; + sessionExpired: string; + reOpenAuth: string; + reOpenVerification: string; + submitCode: string; + pasteCode: string; + waitingAuth: string; + enterCodePrompt: string; + pkceStep1: string; + pkceStep2: string; + pkceStep3: string; + flowLabels: { + pkce: string; + device_code: string; + external: string; + }; + expiresIn: string; + }; + + // ── Language switcher ── + language: { + switchTo: string; + }; +} diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts new file mode 100644 index 000000000..5138cae05 --- /dev/null +++ b/web/src/i18n/zh.ts @@ -0,0 +1,275 @@ +import type { Translations } from "./types"; + +export const zh: Translations = { + common: { + save: "保存", + saving: "保存中...", + cancel: "取消", + close: "关闭", + delete: "删除", + refresh: "刷新", + retry: "重试", + search: "搜索...", + loading: "加载中...", + create: "创建", + creating: "创建中...", + set: "设置", + replace: "替换", + clear: "清除", + live: "在线", + off: "离线", + enabled: "已启用", + disabled: "已禁用", + active: "活跃", + inactive: "未激活", + unknown: "未知", + untitled: "无标题", + none: "无", + form: "表单", + noResults: "无结果", + of: "/", + page: "页", + msgs: "消息", + tools: "工具", + match: "匹配", + other: "其他", + configured: "已配置", + removed: "已移除", + failedToToggle: "切换失败", + failedToRemove: "移除失败", + failedToReveal: "显示失败", + collapse: "折叠", + expand: "展开", + general: "通用", + messaging: "消息平台", + }, + + app: { + brand: "Hermes Agent", + brandShort: "HA", + webUi: "管理面板", + footer: { + name: "Hermes Agent", + org: "Nous Research", + }, + nav: { + status: "状态", + sessions: "会话", + analytics: "分析", + logs: "日志", + cron: "定时任务", + skills: "技能", + config: "配置", + keys: "密钥", + }, + }, + + status: { + agent: "代理", + gateway: "网关", + activeSessions: "活跃会话", + recentSessions: "最近会话", + connectedPlatforms: "已连接平台", + running: "运行中", + starting: "启动中", + failed: "失败", + stopped: "已停止", + connected: "已连接", + disconnected: "已断开", + error: "错误", + notRunning: "未运行", + startFailed: "启动失败", + pid: "进程", + noneRunning: "无", + gatewayFailedToStart: "网关启动失败", + lastUpdate: "最后更新", + platformError: "错误", + platformDisconnected: "已断开", + }, + + sessions: { + title: "会话", + searchPlaceholder: "搜索消息内容...", + noSessions: "暂无会话", + noMatch: "没有匹配的会话", + startConversation: "开始对话后将显示在此处", + noMessages: "暂无消息", + untitledSession: "无标题会话", + deleteSession: "删除会话", + previousPage: "上一页", + nextPage: "下一页", + roles: { + user: "用户", + assistant: "助手", + system: "系统", + tool: "工具", + }, + }, + + analytics: { + period: "时间范围:", + totalTokens: "总 Token 数", + totalSessions: "总会话数", + apiCalls: "API 调用", + dailyTokenUsage: "每日 Token 用量", + dailyBreakdown: "每日明细", + perModelBreakdown: "模型用量明细", + input: "输入", + output: "输出", + total: "总计", + noUsageData: "该时间段暂无使用数据", + startSession: "开始会话后将在此显示分析数据", + date: "日期", + model: "模型", + tokens: "Token", + perDayAvg: "/天 平均", + acrossModels: "共 {count} 个模型", + inOut: "输入 {input} / 输出 {output}", + }, + + logs: { + title: "日志", + autoRefresh: "自动刷新", + file: "文件", + level: "级别", + component: "组件", + lines: "行数", + noLogLines: "未找到日志记录", + }, + + cron: { + newJob: "新建定时任务", + nameOptional: "名称(可选)", + namePlaceholder: "例如:每日总结", + prompt: "提示词", + promptPlaceholder: "代理每次运行时应执行什么操作?", + schedule: "调度表达式(cron)", + schedulePlaceholder: "0 9 * * *", + deliverTo: "投递至", + scheduledJobs: "已调度任务", + noJobs: "暂无定时任务。在上方创建一个。", + last: "上次", + next: "下次", + pause: "暂停", + resume: "恢复", + triggerNow: "立即触发", + delivery: { + local: "本地", + telegram: "Telegram", + discord: "Discord", + slack: "Slack", + email: "邮件", + }, + }, + + skills: { + title: "技能", + searchPlaceholder: "搜索技能和工具集...", + enabledOf: "已启用 {enabled}/{total}", + all: "全部", + noSkills: "未找到技能。技能从 ~/.hermes/skills/ 加载", + noSkillsMatch: "没有匹配的技能。", + skillCount: "{count} 个技能", + noDescription: "暂无描述。", + toolsets: "工具集", + noToolsetsMatch: "没有匹配的工具集。", + setupNeeded: "需要配置", + disabledForCli: "CLI 已禁用", + more: "还有 {count} 个", + }, + + config: { + configPath: "~/.hermes/config.yaml", + exportConfig: "导出配置为 JSON", + importConfig: "从 JSON 导入配置", + resetDefaults: "恢复默认值", + rawYaml: "原始 YAML 配置", + searchResults: "搜索结果", + fields: "个字段", + noFieldsMatch: '没有匹配"{query}"的字段', + configSaved: "配置已保存", + yamlConfigSaved: "YAML 配置已保存", + failedToSave: "保存失败", + failedToSaveYaml: "YAML 保存失败", + failedToLoadRaw: "加载原始配置失败", + configImported: "配置已导入 — 请检查后保存", + invalidJson: "无效的 JSON 文件", + categories: { + general: "通用", + agent: "代理", + terminal: "终端", + display: "显示", + delegation: "委托", + memory: "记忆", + compression: "压缩", + security: "安全", + browser: "浏览器", + voice: "语音", + tts: "文字转语音", + stt: "语音转文字", + logging: "日志", + discord: "Discord", + auxiliary: "辅助", + }, + }, + + env: { + description: "管理存储在以下位置的 API 密钥和凭据", + changesNote: "更改会立即保存到磁盘。活跃会话将自动获取新密钥。", + hideAdvanced: "隐藏高级选项", + showAdvanced: "显示高级选项", + llmProviders: "LLM 提供商", + providersConfigured: "已配置 {configured}/{total} 个提供商", + getKey: "获取密钥", + notConfigured: "{count} 个未配置", + notSet: "未设置", + keysCount: "{count} 个密钥", + enterValue: "输入值...", + replaceCurrentValue: "替换当前值({preview})", + showValue: "显示实际值", + hideValue: "隐藏值", + }, + + oauth: { + title: "提供商登录(OAuth)", + providerLogins: "提供商登录(OAuth)", + description: "已连接 {connected}/{total} 个 OAuth 提供商。登录流程目前通过 CLI 运行;点击「复制命令」并粘贴到终端中进行设置。", + connected: "已连接", + expired: "已过期", + notConnected: "未连接。在终端中运行 {command}。", + runInTerminal: "在终端中。", + noProviders: "未检测到支持 OAuth 的提供商。", + login: "登录", + disconnect: "断开连接", + managedExternally: "外部管理", + copied: "已复制 ✓", + cli: "CLI", + copyCliCommand: "复制 CLI 命令(用于外部/备用方式)", + connect: "连接", + sessionExpires: "会话将在 {time} 后过期", + initiatingLogin: "正在启动登录流程…", + exchangingCode: "正在交换令牌…", + connectedClosing: "已连接!正在关闭…", + loginFailed: "登录失败。", + sessionExpired: "会话已过期。点击重试以开始新的登录。", + reOpenAuth: "重新打开授权页面", + reOpenVerification: "重新打开验证页面", + submitCode: "提交代码", + pasteCode: "粘贴授权代码(包含 #state 后缀也可以)", + waitingAuth: "等待您在浏览器中授权…", + enterCodePrompt: "已在新标签页中打开。如果需要,请输入以下代码:", + pkceStep1: "已在新标签页打开 claude.ai。请登录并点击「授权」。", + pkceStep2: "复制授权后显示的授权代码。", + pkceStep3: "将代码粘贴到下方并提交。", + flowLabels: { + pkce: "浏览器登录(PKCE)", + device_code: "设备代码", + external: "外部 CLI", + }, + expiresIn: "{time}后过期", + }, + + language: { + switchTo: "切换到英文", + }, +}; diff --git a/web/src/main.tsx b/web/src/main.tsx index df4d851c4..ede367cc3 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,10 +1,10 @@ import { createRoot } from "react-dom/client"; -import { BrowserRouter } from "react-router-dom"; import "./index.css"; import App from "./App"; +import { I18nProvider } from "./i18n"; createRoot(document.getElementById("root")!).render( - + - , + , ); diff --git a/web/src/pages/AnalyticsPage.tsx b/web/src/pages/AnalyticsPage.tsx index 3af5e2415..fe5bd75f4 100644 --- a/web/src/pages/AnalyticsPage.tsx +++ b/web/src/pages/AnalyticsPage.tsx @@ -1,5 +1,4 @@ import { useEffect, useState, useCallback } from "react"; -import { formatTokenCount } from "@/lib/format"; import { BarChart3, Cpu, @@ -10,6 +9,7 @@ import { api } from "@/lib/api"; import type { AnalyticsResponse, AnalyticsDailyEntry, AnalyticsModelEntry } from "@/lib/api"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import { useI18n } from "@/i18n"; const PERIODS = [ { label: "7d", days: 7 }, @@ -19,7 +19,11 @@ const PERIODS = [ const CHART_HEIGHT_PX = 160; -const formatTokens = formatTokenCount; +function formatTokens(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return String(n); +} function formatDate(day: string): string { try { @@ -56,6 +60,7 @@ function SummaryCard({ } function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) { + const { t } = useI18n(); if (daily.length === 0) return null; const maxTokens = Math.max(...daily.map((d) => d.input_tokens + d.output_tokens), 1); @@ -65,16 +70,16 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
- Daily Token Usage + {t.analytics.dailyTokenUsage}
-
- Input +
+ {t.analytics.input}
-
- Output +
+ {t.analytics.output}
@@ -92,11 +97,11 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) { > {/* Tooltip */}
-
+
{formatDate(d.day)}
-
Input: {formatTokens(d.input_tokens)}
-
Output: {formatTokens(d.output_tokens)}
-
Total: {formatTokens(total)}
+
{t.analytics.input}: {formatTokens(d.input_tokens)}
+
{t.analytics.output}: {formatTokens(d.output_tokens)}
+
{t.analytics.total}: {formatTokens(total)}
{/* Input bar */} @@ -127,6 +132,7 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) { } function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) { + const { t } = useI18n(); if (daily.length === 0) return null; const sorted = [...daily].reverse(); @@ -136,7 +142,7 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
- Daily Breakdown + {t.analytics.dailyBreakdown}
@@ -144,10 +150,10 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
A real terminal interfaceFull TUI with multiline editing, slash-command autocomplete, conversation history, interrupt-and-redirect, and streaming tool output.
- - - - + + + + @@ -174,6 +180,7 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) { } function ModelTable({ models }: { models: AnalyticsModelEntry[] }) { + const { t } = useI18n(); if (models.length === 0) return null; const sorted = [...models].sort( @@ -185,7 +192,7 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
- Per-Model Breakdown + {t.analytics.perModelBreakdown}
@@ -193,9 +200,9 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
DateSessionsInputOutput{t.analytics.date}{t.sessions.title}{t.analytics.input}{t.analytics.output}
- - - + + + @@ -225,6 +232,7 @@ export default function AnalyticsPage() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const { t } = useI18n(); const load = useCallback(() => { setLoading(true); @@ -244,7 +252,7 @@ export default function AnalyticsPage() {
{/* Period selector */}
- Period: + {t.analytics.period} {PERIODS.map((p) => (
@@ -310,8 +318,8 @@ export default function AnalyticsPage() {
-

No usage data for this period

-

Start a session to see analytics here

+

{t.analytics.noUsageData}

+

{t.analytics.startSession}

diff --git a/web/src/pages/ConfigPage.tsx b/web/src/pages/ConfigPage.tsx index 7cd6e4300..c447a46ab 100644 --- a/web/src/pages/ConfigPage.tsx +++ b/web/src/pages/ConfigPage.tsx @@ -1,76 +1,49 @@ import { useEffect, useRef, useState, useMemo } from "react"; import { - Bot, - ChevronRight, Code, - Ear, Download, - FileText, FormInput, - Globe, - Lock, - MessageSquare, - Mic, - Monitor, - Package, - Palette, RotateCcw, Save, - ScrollText, Search, - Settings, - Settings2, Upload, - Users, - Volume2, - Wrench, X, + ChevronRight, + Settings2, + FileText, } from "lucide-react"; -import type { ComponentType } from "react"; import { api } from "@/lib/api"; import { getNestedValue, setNestedValue } from "@/lib/nested"; import { useToast } from "@/hooks/useToast"; import { Toast } from "@/components/Toast"; import { AutoField } from "@/components/AutoField"; -import { ModelInfoCard } from "@/components/ModelInfoCard"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; +import { useI18n } from "@/i18n"; /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ -const CATEGORY_ICONS: Record> = { - general: Settings, - agent: Bot, - terminal: Monitor, - display: Palette, - delegation: Users, - memory: Package, - compression: Package, - security: Lock, - browser: Globe, - voice: Mic, - tts: Volume2, - stt: Ear, - logging: ScrollText, - discord: MessageSquare, - auxiliary: Wrench, +const CATEGORY_ICONS: Record = { + general: "⚙️", + agent: "🤖", + terminal: "💻", + display: "🎨", + delegation: "👥", + memory: "🧠", + compression: "📦", + security: "🔒", + browser: "🌐", + voice: "🎙️", + tts: "🔊", + stt: "👂", + logging: "📋", + discord: "💬", + auxiliary: "🔧", }; -const FallbackIcon = FileText; - -function prettyCategoryName(cat: string): string { - if (cat === "tts") return "Text-to-Speech"; - if (cat === "stt") return "Speech-to-Text"; - return cat.charAt(0).toUpperCase() + cat.slice(1); -} - -function CategoryIcon({ cat, className }: { cat: string; className?: string }) { - const Icon = CATEGORY_ICONS[cat] ?? FallbackIcon; - return ; -} /* ------------------------------------------------------------------ */ /* Component */ @@ -88,9 +61,15 @@ export default function ConfigPage() { const [yamlLoading, setYamlLoading] = useState(false); const [yamlSaving, setYamlSaving] = useState(false); const [activeCategory, setActiveCategory] = useState(""); - const [modelInfoRefreshKey, setModelInfoRefreshKey] = useState(0); const { toast, showToast } = useToast(); const fileInputRef = useRef(null); + const { t } = useI18n(); + + function prettyCategoryName(cat: string): string { + const key = cat as keyof typeof t.config.categories; + if (t.config.categories[key]) return t.config.categories[key]; + return cat.charAt(0).toUpperCase() + cat.slice(1); + } useEffect(() => { api.getConfig().then(setConfig).catch(() => {}); @@ -118,7 +97,7 @@ export default function ConfigPage() { api .getConfigRaw() .then((resp) => setYamlText(resp.yaml)) - .catch(() => showToast("Failed to load raw config", "error")) + .catch(() => showToast(t.config.failedToLoadRaw, "error")) .finally(() => setYamlLoading(false)); } }, [yamlMode]); @@ -175,10 +154,9 @@ export default function ConfigPage() { setSaving(true); try { await api.saveConfig(config); - showToast("Configuration saved", "success"); - setModelInfoRefreshKey((k) => k + 1); + showToast(t.config.configSaved, "success"); } catch (e) { - showToast(`Failed to save: ${e}`, "error"); + showToast(`${t.config.failedToSave}: ${e}`, "error"); } finally { setSaving(false); } @@ -188,11 +166,10 @@ export default function ConfigPage() { setYamlSaving(true); try { await api.saveConfigRaw(yamlText); - showToast("YAML config saved", "success"); - setModelInfoRefreshKey((k) => k + 1); + showToast(t.config.yamlConfigSaved, "success"); api.getConfig().then(setConfig).catch(() => {}); } catch (e) { - showToast(`Failed to save YAML: ${e}`, "error"); + showToast(`${t.config.failedToSaveYaml}: ${e}`, "error"); } finally { setYamlSaving(false); } @@ -221,9 +198,9 @@ export default function ConfigPage() { try { const imported = JSON.parse(reader.result as string); setConfig(imported); - showToast("Config imported — review and save", "success"); + showToast(t.config.configImported, "success"); } catch { - showToast("Invalid JSON file", "error"); + showToast(t.config.invalidJson, "error"); } }; reader.readAsText(file); @@ -242,7 +219,6 @@ export default function ConfigPage() { const renderFields = (fields: [string, Record][], showCategory = false) => { let lastSection = ""; let lastCat = ""; - const currentModel = config ? String(getNestedValue(config, "model") ?? "") : ""; return fields.map(([key, s]) => { const parts = key.split("."); const section = parts.length > 1 ? parts[0] : ""; @@ -256,7 +232,7 @@ export default function ConfigPage() {
{showCatBadge && (
- + {CATEGORY_ICONS[cat] || "📄"} {prettyCategoryName(cat)} @@ -279,12 +255,6 @@ export default function ConfigPage() { onChange={(v) => setConfig(setNestedValue(config, key, v))} />
- {/* Inject model info card right after the model field */} - {key === "model" && currentModel && ( -
- -
- )}
); }); @@ -298,19 +268,19 @@ export default function ConfigPage() {
- - ~/.hermes/config.yaml + + {t.config.configPath}
- - - @@ -325,7 +295,7 @@ export default function ConfigPage() { {yamlMode ? ( <> - Form + {t.common.form} ) : ( <> @@ -338,12 +308,12 @@ export default function ConfigPage() { {yamlMode ? ( ) : ( )}
@@ -355,7 +325,7 @@ export default function ConfigPage() { - Raw YAML Configuration + {t.config.rawYaml} @@ -384,7 +354,7 @@ export default function ConfigPage() { setSearchQuery(e.target.value)} /> @@ -411,13 +381,13 @@ export default function ConfigPage() { setSearchQuery(""); setActiveCategory(cat); }} - className={`group flex items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${ + className={`group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${ isActive ? "bg-primary/10 text-primary font-medium" : "text-muted-foreground hover:text-foreground hover:bg-muted/50" }`} > - + {CATEGORY_ICONS[cat] || "📄"} {prettyCategoryName(cat)} {categoryCounts[cat] || 0} @@ -441,17 +411,17 @@ export default function ConfigPage() {
- Search Results + {t.config.searchResults} - {searchMatchedFields.length} field{searchMatchedFields.length !== 1 ? "s" : ""} + {searchMatchedFields.length} {t.config.fields.replace("{s}", searchMatchedFields.length !== 1 ? "s" : "")}
{searchMatchedFields.length === 0 ? (

- No fields match "{searchQuery}" + {t.config.noFieldsMatch.replace("{query}", searchQuery)}

) : ( renderFields(searchMatchedFields, true) @@ -464,11 +434,11 @@ export default function ConfigPage() {
- + {CATEGORY_ICONS[activeCategory] || "📄"} {prettyCategoryName(activeCategory)} - {activeFields.length} field{activeFields.length !== 1 ? "s" : ""} + {activeFields.length} {t.config.fields.replace("{s}", activeFields.length !== 1 ? "s" : "")}
diff --git a/web/src/pages/CronPage.tsx b/web/src/pages/CronPage.tsx index 9c7f186ba..cfd1bc608 100644 --- a/web/src/pages/CronPage.tsx +++ b/web/src/pages/CronPage.tsx @@ -9,7 +9,8 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Select, SelectOption } from "@/components/ui/select"; +import { Select } from "@/components/ui/select"; +import { useI18n } from "@/i18n"; function formatTime(iso?: string | null): string { if (!iso) return "—"; @@ -29,6 +30,7 @@ export default function CronPage() { const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(true); const { toast, showToast } = useToast(); + const { t } = useI18n(); // New job form state const [prompt, setPrompt] = useState(""); @@ -41,7 +43,7 @@ export default function CronPage() { api .getCronJobs() .then(setJobs) - .catch(() => showToast("Failed to load cron jobs", "error")) + .catch(() => showToast(t.common.loading, "error")) .finally(() => setLoading(false)); }; @@ -51,7 +53,7 @@ export default function CronPage() { const handleCreate = async () => { if (!prompt.trim() || !schedule.trim()) { - showToast("Prompt and schedule are required", "error"); + showToast(`${t.cron.prompt} & ${t.cron.schedule} required`, "error"); return; } setCreating(true); @@ -62,14 +64,14 @@ export default function CronPage() { name: name.trim() || undefined, deliver, }); - showToast("Cron job created", "success"); + showToast(t.common.create + " ✓", "success"); setPrompt(""); setSchedule(""); setName(""); setDeliver("local"); loadJobs(); } catch (e) { - showToast(`Failed to create job: ${e}`, "error"); + showToast(`${t.config.failedToSave}: ${e}`, "error"); } finally { setCreating(false); } @@ -80,34 +82,34 @@ export default function CronPage() { const isPaused = job.state === "paused"; if (isPaused) { await api.resumeCronJob(job.id); - showToast(`Resumed "${job.name || job.prompt.slice(0, 30)}"`, "success"); + showToast(`${t.cron.resume}: "${job.name || job.prompt.slice(0, 30)}"`, "success"); } else { await api.pauseCronJob(job.id); - showToast(`Paused "${job.name || job.prompt.slice(0, 30)}"`, "success"); + showToast(`${t.cron.pause}: "${job.name || job.prompt.slice(0, 30)}"`, "success"); } loadJobs(); } catch (e) { - showToast(`Action failed: ${e}`, "error"); + showToast(`${t.status.error}: ${e}`, "error"); } }; const handleTrigger = async (job: CronJob) => { try { await api.triggerCronJob(job.id); - showToast(`Triggered "${job.name || job.prompt.slice(0, 30)}"`, "success"); + showToast(`${t.cron.triggerNow}: "${job.name || job.prompt.slice(0, 30)}"`, "success"); loadJobs(); } catch (e) { - showToast(`Trigger failed: ${e}`, "error"); + showToast(`${t.status.error}: ${e}`, "error"); } }; const handleDelete = async (job: CronJob) => { try { await api.deleteCronJob(job.id); - showToast(`Deleted "${job.name || job.prompt.slice(0, 30)}"`, "success"); + showToast(`${t.common.delete}: "${job.name || job.prompt.slice(0, 30)}"`, "success"); loadJobs(); } catch (e) { - showToast(`Delete failed: ${e}`, "error"); + showToast(`${t.status.error}: ${e}`, "error"); } }; @@ -128,27 +130,27 @@ export default function CronPage() { - New Cron Job + {t.cron.newJob}
- + setName(e.target.value)} />
- +
ModelSessionsTokens{t.analytics.model}{t.sessions.title}{t.analytics.tokens}