Compare commits

...

13 Commits

Author SHA1 Message Date
Nathan Esquenazi
85557381ec docs: update CHANGELOG with v0.16.2 model list and base_url fixes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:24:52 -07:00
Nathan Esquenazi
4a1bf20b67 Merge pull request #9 from nesquena/fix/minimax-models-and-base-url
fix: update MiniMax/Z.AI model lists, pass base_url to AIAgent
2026-04-01 23:24:13 -07:00
Nathan Esquenazi
ceb091d6b1 fix: update MiniMax/Z.AI model lists, pass base_url to AIAgent
- Update _PROVIDER_MODELS['minimax'] from stale ABAB 6.5 models to
  current MiniMax-M2.7/M2.5/M2.1 lineup (matching hermes-agent upstream)
- Update _PROVIDER_MODELS['zai'] from GLM-4 to current GLM-5/4.7/4.5
  lineup (matching hermes-agent upstream)
- Extend resolve_model_provider() to also return base_url from config.yaml,
  so providers with custom endpoints (MiniMax, Z.AI) are routed correctly
- Pass base_url to AIAgent in both streaming and sync chat paths

Fixes #6

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:15:04 -07:00
Nathan Esquenazi
f9f56da484 docs: update CHANGELOG with v0.16.1 community fixes
Add entry for mobile responsive layout, reverse proxy auth support,
and model provider routing fixes contributed by @deboste.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:08:59 -07:00
Nathan Esquenazi
b0da4db0d2 Merge pull request #4 from deboste/fix/model-provider-routing
fix(api): resolve model provider from config to prevent misrouting
2026-04-01 22:57:06 -07:00
Nathan Esquenazi
d899115d26 Merge pull request #3 from deboste/fix/fetch-basic-auth-compat
fix(frontend): use URL origin for fetch/EventSource to support revers…
2026-04-01 22:57:02 -07:00
Nathan Esquenazi
241357595d refactor: extract resolve_model_provider helper, fix cross-provider routing
Replace duplicated inline provider resolution in routes.py and streaming.py
with a shared resolve_model_provider() helper in config.py.

Improvements over original:
- If model ID has a prefix matching any known direct-API provider
  (not just the config provider), strip it and route correctly.
  This handles edge cases like localStorage restoring a model from
  a different provider group.
- Single source of truth for the resolution logic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:56:34 -07:00
Nathan Esquenazi
1375ce0634 fix: add withCredentials to EventSource for reverse proxy auth
The original PR correctly used new URL(path, location.origin) to strip
credentials from fetch/EventSource URLs, and added credentials:'include'
to all fetch() calls. However, EventSource requires { withCredentials: true }
as a second constructor argument for cookies/auth headers to be forwarded.
Without this, SSE streaming breaks behind a reverse proxy with basic auth.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:53:50 -07:00
Nathan Esquenazi
0a39a64387 Merge pull request #5 from deboste/fix/mobile-responsive
Reviewed: CSS/HTML only, security audited, test suite passes (201/201 relevant tests). Clean mobile responsive fix.
2026-04-01 22:48:09 -07:00
deboste
f6e58ef2ad fix(css): mobile responsive layout and dvh viewport fix
- Use 100dvh with 100vh fallback to fix composer being cut off on
  mobile browsers where the address bar affects viewport height
- Add comprehensive @media(max-width:640px) rules: topbar wrapping,
  compact messages, full-width msg-body, smaller chips and buttons,
  responsive composer, approval cards, tool cards, settings modal
- Use font-size:16px on textarea to prevent iOS/Android auto-zoom
  on input focus (browsers zoom when font-size < 16px)
- Add .topbar-left class on title wrapper for responsive stacking
2026-03-31 15:00:50 +00:00
deboste
2864c2b691 fix(api): resolve model provider from config to prevent misrouting
When the model dropdown sends a prefixed ID like "anthropic/claude-xxx",
AIAgent interprets the provider/model format as an OpenRouter path and
routes through OpenRouter instead of the direct Anthropic API.

Fix: read the configured provider from config.yaml model section. If
the model ID starts with the configured provider name followed by "/",
strip that prefix and pass the provider explicitly to AIAgent. This
ensures direct API providers (Anthropic, OpenAI, etc.) are used when
configured, regardless of the model ID format from the dropdown.
2026-03-31 15:00:43 +00:00
deboste
96547f68a3 fix(frontend): use URL origin for fetch/EventSource to support reverse proxy auth
When Hermes WebUI runs behind a reverse proxy with HTTP basic auth
(e.g. Caddy basic_auth), browsers embed credentials in the page URL.
The Fetch API and EventSource reject requests constructed from URLs
that include credentials (per Fetch spec, all modern browsers).

Fix: construct all fetch() and EventSource URLs via
new URL(path, location.origin) which strips credentials from the
base URL. Add credentials:"include" to ensure auth headers are
forwarded on each request.
2026-03-31 15:00:38 +00:00
Nathan Esquenazi
a9ae0b0a83 Enhance README with Hermes Agent details
Updated README to include information about Hermes Agent and clarified the purpose of Hermes WebUI.
2026-03-31 00:34:50 -07:00
11 changed files with 143 additions and 22 deletions

View File

@@ -1,8 +1,44 @@
# Hermes Web UI -- Changelog
> Living document. Updated at the end of every sprint.
> Source: <repo>/
> Repository: https://github.com/<your-username>/hermes-webui
> Repository: https://github.com/nesquena/hermes-webui
---
## [v0.16.2] Model List Updates + base_url Passthrough
*April 1, 2026 | 247 tests*
### Bug Fixes
- **MiniMax model list updated.** Replaced stale ABAB 6.5 models with current
MiniMax-M2.7, M2.7-highspeed, M2.5, M2.5-highspeed, M2.1 lineup matching
hermes-agent upstream. (Fixes #6)
- **Z.AI/GLM model list updated.** Replaced GLM-4 series with current GLM-5,
GLM-5 Turbo, GLM-4.7, GLM-4.5, GLM-4.5 Flash lineup.
- **base_url passthrough to AIAgent.** `resolve_model_provider()` now reads
`base_url` from config.yaml and passes it to AIAgent, so providers with
custom endpoints (MiniMax, Z.AI, local LLMs) route to the correct API.
---
## [v0.16.1] Community Fixes -- Mobile + Auth + Provider Routing
*April 1, 2026 | 247 tests*
Community contributions from @deboste, reviewed and refined.
### Bug Fixes
- **Mobile responsive layout.** Comprehensive `@media(max-width:640px)` rules
for topbar, messages, composer, tool cards, approval cards, and settings modal.
Uses `100dvh` with `100vh` fallback to fix composer cutoff on mobile browsers.
Textarea `font-size:16px` prevents iOS/Android auto-zoom on focus.
- **Reverse proxy basic auth support.** All `fetch()` and `EventSource` URLs now
constructed via `new URL(path, location.origin)` to strip embedded credentials
per Fetch spec. `credentials:'include'` on fetch, `withCredentials:true` on
EventSource ensure auth headers are forwarded through reverse proxies.
- **Model provider routing.** New `resolve_model_provider()` helper in
`api/config.py` strips provider prefix from dropdown model IDs (e.g.
`anthropic/claude-sonnet-4.6``claude-sonnet-4.6`) and passes the correct
`provider` to AIAgent. Handles cross-provider selection by matching against
known direct-API providers.
---
@@ -405,4 +441,4 @@ Three-panel layout: sessions sidebar, chat area, workspace panel.
---
*Last updated: Sprint 14, March 31, 2026 | Tests: 224/224*
*Last updated: v0.16.2, April 1, 2026 | Tests: 247*

View File

@@ -1,7 +1,9 @@
# Hermes Web UI
A lightweight, dark-themed browser interface for Hermes.
Full parity with the CLI experience -- everything you can do from a terminal,
[Hermes Agent](https://hermes-agent.nousresearch.com/) is a sophisticated autonomous agent that lives on your server, accessed via a terminal or messaging apps, remembers what it learns, and gets more capable the longer it runs.
Hermes WebUI is a lightweight, dark-themed web app interface in your browser for [Hermes Agent](https://hermes-agent.nousresearch.com/).
Full parity with the CLI experience - everything you can do from a terminal,
you can do from this UI. No build step, no framework, no bundler. Just Python
and vanilla JS.
@@ -16,6 +18,8 @@ This gives you nearly **1:1 parity with Hermes CLI from a convenient web UI** wh
## Quick start
First, you need to install and configure [Hermes Agent](https://hermes-agent.nousresearch.com/). Once installed:
```bash
git clone https://github.com/nesquena/hermes-webui.git hermes-webui
cd hermes-webui

View File

@@ -295,9 +295,11 @@ _PROVIDER_MODELS = {
{'id': 'gemini-2.5-pro', 'label': 'Gemini 2.5 Pro (via Nous)'},
],
'zai': [
{'id': 'glm-4-plus', 'label': 'GLM-4 Plus'},
{'id': 'glm-4-air', 'label': 'GLM-4 Air'},
{'id': 'glm-z1-flash', 'label': 'GLM-Z1 Flash'},
{'id': 'glm-5', 'label': 'GLM-5'},
{'id': 'glm-5-turbo', 'label': 'GLM-5 Turbo'},
{'id': 'glm-4.7', 'label': 'GLM-4.7'},
{'id': 'glm-4.5', 'label': 'GLM-4.5'},
{'id': 'glm-4.5-flash', 'label': 'GLM-4.5 Flash'},
],
'kimi-coding': [
{'id': 'moonshot-v1-8k', 'label': 'Moonshot v1 8k'},
@@ -306,12 +308,51 @@ _PROVIDER_MODELS = {
{'id': 'kimi-latest', 'label': 'Kimi Latest'},
],
'minimax': [
{'id': 'abab6.5s-chat', 'label': 'MiniMax ABAB 6.5S'},
{'id': 'abab6.5g-chat', 'label': 'MiniMax ABAB 6.5G'},
{'id': 'MiniMax-M2.7', 'label': 'MiniMax M2.7'},
{'id': 'MiniMax-M2.7-highspeed', 'label': 'MiniMax M2.7 Highspeed'},
{'id': 'MiniMax-M2.5', 'label': 'MiniMax M2.5'},
{'id': 'MiniMax-M2.5-highspeed', 'label': 'MiniMax M2.5 Highspeed'},
{'id': 'MiniMax-M2.1', 'label': 'MiniMax M2.1'},
],
}
def resolve_model_provider(model_id: str):
"""Resolve bare model name, provider, and base_url for AIAgent.
Model IDs from the dropdown may include a provider prefix
(e.g. 'anthropic/claude-sonnet-4.6'). Direct-API providers expect
bare model names, while OpenRouter expects the full provider/model path.
Also reads base_url from config.yaml so providers with custom endpoints
(e.g. MiniMax, Z.AI) are routed correctly.
Returns (model, provider, base_url) where provider and base_url may be None.
"""
config_provider = None
config_base_url = None
model_cfg = cfg.get('model', {})
if isinstance(model_cfg, dict):
config_provider = model_cfg.get('provider')
config_base_url = model_cfg.get('base_url')
model_id = (model_id or '').strip()
if not model_id:
return model_id, config_provider, config_base_url
if '/' in model_id:
prefix, bare = model_id.split('/', 1)
# If prefix matches config provider, strip it
if config_provider and prefix == config_provider:
return bare, config_provider, config_base_url
# If prefix is a known direct-API provider, use it
# (base_url only applies when matching config provider)
if prefix in _PROVIDER_MODELS:
return bare, prefix, None
return model_id, config_provider, config_base_url
def get_available_models() -> dict:
"""
Return available models grouped by provider.

View File

@@ -629,7 +629,10 @@ def _handle_chat_sync(handler, body):
try:
from run_agent import AIAgent
with CHAT_LOCK:
agent = AIAgent(model=s.model, platform='cli', quiet_mode=True,
from api.config import resolve_model_provider
_model, _provider, _base_url = resolve_model_provider(s.model)
agent = AIAgent(model=_model, provider=_provider, base_url=_base_url,
platform='cli', quiet_mode=True,
enabled_toolsets=CLI_TOOLSETS, session_id=s.session_id)
workspace_ctx = f"[Workspace: {s.workspace}]\n"
workspace_system_msg = (

View File

@@ -13,6 +13,7 @@ from pathlib import Path
from api.config import (
STREAMS, STREAMS_LOCK, CANCEL_FLAGS, CLI_TOOLSETS,
_get_session_agent_lock, _set_thread_env, _clear_thread_env,
resolve_model_provider,
)
# Lazy import to avoid circular deps -- hermes-agent is on sys.path via api/config.py
@@ -99,8 +100,11 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
if AIAgent is None:
raise ImportError("AIAgent not available -- check that hermes-agent is on sys.path")
resolved_model, resolved_provider, resolved_base_url = resolve_model_provider(model)
agent = AIAgent(
model=model,
model=resolved_model,
provider=resolved_provider,
base_url=resolved_base_url,
platform='cli',
quiet_mode=True,
enabled_toolsets=CLI_TOOLSETS,

View File

@@ -2,7 +2,7 @@ async function cancelStream(){
const streamId = S.activeStreamId;
if(!streamId) return;
try{
await fetch(`/api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`);
await fetch(new URL(`/api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,location.origin).href,{credentials:'include'});
const btn=$('btnCancel');if(btn)btn.style.display='none';
setStatus('Cancelling…');
}catch(e){setStatus('Cancel failed: '+e.message);}

View File

@@ -143,7 +143,7 @@
</aside>
<main class="main">
<div class="topbar">
<div style="flex:1;min-width:0;overflow:hidden"><div class="topbar-title" id="topbarTitle">Hermes</div><div class="topbar-meta" id="topbarMeta">Start a new conversation</div></div>
<div class="topbar-left" style="flex:1;min-width:0;overflow:hidden"><div class="topbar-title" id="topbarTitle">Hermes</div><div class="topbar-meta" id="topbarMeta">Start a new conversation</div></div>
<div class="topbar-chips">
<div class="chip model" id="modelChip">GPT-5.4 Mini</div>
<div id="wsChipWrap" style="position:relative">

View File

@@ -169,7 +169,7 @@ async function send(){
const st=await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`);
if(st.active){
setStatus('Reconnected');
_wireSSE(new EventSource(`/api/chat/stream?stream_id=${encodeURIComponent(streamId)}`));
_wireSSE(new EventSource(new URL(`/api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,location.origin).href,{withCredentials:true}));
return;
}
}catch(_){}
@@ -214,7 +214,7 @@ async function send(){
if(!S.session||!INFLIGHT[S.session.session_id]){setBusy(false);setStatus('Error: Connection lost');}
}
_wireSSE(new EventSource(`/api/chat/stream?stream_id=${encodeURIComponent(streamId)}`));
_wireSSE(new EventSource(new URL(`/api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,location.origin).href,{withCredentials:true}));
}

View File

@@ -4,8 +4,8 @@
--text:#e8e8f0;--muted:#8888aa;--accent:#e94560;--blue:#7cb9ff;--gold:#c9a84c;--code-bg:#0d1117;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;font-size:14px;line-height:1.6;
}
body{background:var(--bg);color:var(--text);height:100vh;overflow:hidden;display:flex;}
.layout{display:flex;width:100%;height:100vh;}
body{background:var(--bg);color:var(--text);height:100vh;height:100dvh;overflow:hidden;display:flex;}
.layout{display:flex;width:100%;height:100vh;height:100dvh;}
.sidebar{width:300px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;flex-shrink:0;}
.sidebar-header{padding:16px 18px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;}
.logo{width:32px;height:32px;border-radius:9px;background:linear-gradient(145deg,#e8a030,var(--accent));display:flex;align-items:center;justify-content:center;font-weight:800;font-size:14px;color:#fff;flex-shrink:0;box-shadow:0 2px 8px rgba(233,69,96,.3);}
@@ -235,7 +235,39 @@
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:rgba(255,255,255,.1);border-radius:99px;transition:background .2s}
::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.22)}
@media(max-width:900px){.rightpanel{display:none}}@media(max-width:640px){.sidebar{display:none}}
@media(max-width:900px){.rightpanel{display:none}}
@media(max-width:640px){
.sidebar{display:none}
/* Topbar: stack title + chips vertically, allow wrapping */
.topbar{padding:8px 12px;gap:6px;flex-wrap:wrap;}
.topbar-left{min-width:0;flex:1 1 100%;}
.topbar-title{font-size:14px;}
.topbar-meta{font-size:10px;}
.topbar-chips{flex-wrap:wrap;gap:4px;}
.topbar-chips .chip,.topbar-chips .ws-chip,.topbar-chips button{font-size:11px!important;padding:3px 8px!important;}
/* Messages area */
.messages-inner{padding:12px 10px 20px;}
.msg-body{padding-left:0;max-width:100%;}
.msg-role{font-size:12px;}
/* Composer */
.composer-wrap{padding:8px 10px 12px;}
.composer-box{border-radius:12px;}
.composer-box textarea{font-size:16px;min-height:40px;}
.send-btn{padding:6px 14px;font-size:13px;}
/* Empty state */
.empty-state h2{font-size:18px;}
.empty-state p{font-size:13px;}
.suggestion-grid{max-width:100%;}
.suggestion-btn{font-size:12px;padding:8px 10px;}
/* Approval card */
.approval-card{padding:0 10px 8px;}
.approval-btns{gap:6px;}
.approval-btn{padding:5px 10px;font-size:11px;}
/* Tool cards */
.tool-card{margin-left:0;font-size:12px;}
/* Settings modal */
.settings-panel{width:95vw;max-width:95vw;}
}
/* ── Workspace dropdown (topbar) ── */
.ws-chip{user-select:none;}

View File

@@ -11,7 +11,7 @@ async function populateModelDropdown(){
const sel=$('modelSelect');
if(!sel) return;
try{
const data=await fetch('/api/models').then(r=>r.json());
const data=await fetch(new URL('/api/models',location.origin).href,{credentials:'include'}).then(r=>r.json());
if(!data.groups||!data.groups.length) return; // keep HTML defaults
// Clear existing options
sel.innerHTML='';
@@ -745,7 +745,7 @@ async function uploadPendingFiles(){
const f=S.pendingFiles[i];const fd=new FormData();
fd.append('session_id',S.session.session_id);fd.append('file',f,f.name);
try{
const res=await fetch('/api/upload',{method:'POST',body:fd});
const res=await fetch(new URL('/api/upload',location.origin).href,{method:'POST',credentials:'include',body:fd});
if(!res.ok){const err=await res.text();throw new Error(err);}
const data=await res.json();
if(data.error)throw new Error(data.error);

View File

@@ -1,5 +1,6 @@
async function api(path,opts={}){
const res=await fetch(path,{headers:{'Content-Type':'application/json'},...opts});
const url=new URL(path,location.origin);
const res=await fetch(url.href,{credentials:'include',headers:{'Content-Type':'application/json'},...opts});
if(!res.ok)throw new Error(await res.text());
const ct=res.headers.get('content-type')||'';
return ct.includes('application/json')?res.json():res.text();