From f0d1dfac034d09012d436c5690bcb5e4a10e9b35 Mon Sep 17 00:00:00 2001 From: Matteo Merola Date: Fri, 10 Jul 2026 10:18:07 +0200 Subject: [PATCH 01/11] feat(admin): persist memory subtab in URL as ?memsub= param - Add memorySubtab() Alpine component (modeled after vaultTabs) that reads/writes ?memsub= URL param and localStorage for bookmarking - Replace plain HTMX nav buttons with Alpine @click + :class bindings - Forward ?memsub= in adminTabs() on memory tab load and tab switch - Preserve ?memsub= when switching between memory and other tabs - Re-load memory tab on popstate when only memsub param changes (browser back/forward now restores the correct subtab) --- humux/api/templates/base.html | 54 ++++++++++++++++++++++++ humux/api/templates/dashboard.html | 29 +++++++++++-- humux/api/templates/partials/memory.html | 22 +++++----- 3 files changed, 91 insertions(+), 14 deletions(-) diff --git a/humux/api/templates/base.html b/humux/api/templates/base.html index 95bae55d..5346b6a4 100644 --- a/humux/api/templates/base.html +++ b/humux/api/templates/base.html @@ -1354,6 +1354,60 @@ } window.vaultTabs = vaultTabs; + // Memory tab: Settings | Long-term | Short-term sub-tabs. + // Persisted so bookmarking, refresh, and browser back/forward land on + // the same sub-tab. Modeled after vaultTabs (x-show within a single + // partial that re-renders into #tab-content). + function memorySubtab() { + const allowed = ['settings', 'long-term', 'short-term']; + return { + sub: (() => { + try { + const q = new URLSearchParams(window.location.search).get('memsub'); + if (allowed.includes(q)) return q; + const ls = localStorage.getItem('memory_sub'); + if (allowed.includes(ls)) return ls; + } catch (e) {} + return 'settings'; + })(), + init() { this._syncUrl(this.sub); }, + select(name) { + this.sub = name; + try { localStorage.setItem('memory_sub', name); } catch (e) {} + this._syncUrl(name); + // Load the sub-tab content via HTMX, replacing the full tab pane. + // Pagination/search params preserved via the view-specific URLs + // embedded in the template. + if (window.htmx) { + const url = '/partials/memory?view=' + encodeURIComponent(name); + htmx.ajax('GET', url, {target: '#tab-content', swap: 'innerHTML'}); + } + }, + _syncUrl(name) { + try { + const url = new URL(window.location.href); + if (url.searchParams.get('memsub') !== name) { + url.searchParams.set('memsub', name); + history.replaceState(history.state, '', url); + } + } catch (e) {} + }, + // Returns the HTMX URL for the 'long-term' and 'short-term' views + // including the current offset/limit/q. For settings view, no extra + // params needed. + tabUrl(name) { + if (name === 'settings') return '/partials/memory?view=settings'; + // These are supplied by the template context when the partial + // is rendered server-side; in the Alpine component we let the + // select() method pass a simple view param and let the server + // use defaults for offset/limit. The pagination buttons inside + // each view handle their own offsets. + return '/partials/memory?view=' + encodeURIComponent(name); + } + }; + } + window.memorySubtab = memorySubtab; + // Global: handle 401 by redirecting to auth gate document.body.addEventListener('htmx:responseError', function(e) { if (e.detail.xhr.status === 401) { diff --git a/humux/api/templates/dashboard.html b/humux/api/templates/dashboard.html index b43a5d34..612ed216 100644 --- a/humux/api/templates/dashboard.html +++ b/humux/api/templates/dashboard.html @@ -580,7 +580,14 @@

Agent Control

// Defer the initial HTMX fetch so both Alpine and HTMX are fully // initialised before the request fires. this.$nextTick(() => { - htmx.ajax('GET', tabs[initialTab], {target: '#tab-content', swap: 'innerHTML'}); + let url = tabs[initialTab]; + // Forward sub-tab param for memory (?memsub=) so the initial load + // lands on the right sub-tab instead of always defaulting to settings. + if (initialTab === 'memory') { + const ms = new URLSearchParams(window.location.search).get('memsub'); + if (ms) { url += '?view=' + encodeURIComponent(ms); } + } + htmx.ajax('GET', url, {target: '#tab-content', swap: 'innerHTML'}); }); window.addEventListener('popstate', () => { @@ -594,17 +601,31 @@

Agent Control

this.loadTab(name, true); }, loadTab(name, updateUrl) { - if (!tabs[name] || this.tab === name && !updateUrl) { + // Always re-load memory when memsub param may have changed + // (e.g. browser back/forward). For other tabs, skip if already active. + if (!tabs[name] || (this.tab === name && !updateUrl && name !== 'memory')) { return; } this.tab = name; + let fetchUrl = tabs[name]; + // Forward sub-tab param for memory (?memsub=) so the URL-preserved + // sub-tab is loaded after a refresh or navigation. + if (name === 'memory') { + const ms = new URLSearchParams(window.location.search).get('memsub'); + if (ms) { fetchUrl += '?view=' + encodeURIComponent(ms); } + } showSkeleton('#tab-content'); - htmx.ajax('GET', tabs[name], {target: '#tab-content', swap: 'innerHTML'}); + htmx.ajax('GET', fetchUrl, {target: '#tab-content', swap: 'innerHTML'}); if (updateUrl) { + // Save sub-tab params before clearing (#197). + const curParams = new URLSearchParams(window.location.search); + const memsub = curParams.get('memsub'); const url = new URL(window.location.href); - // Strip all sub-tab query params when switching parent tabs (#197). url.search = ''; url.searchParams.set('tab', name); + // Preserve memsub when switching to the memory tab so bookmarking + // still works across tab switches. + if (name === 'memory' && memsub) { url.searchParams.set('memsub', memsub); } history.pushState({tab: name}, '', url); } } diff --git a/humux/api/templates/partials/memory.html b/humux/api/templates/partials/memory.html index 8413f538..9a8d1e0b 100644 --- a/humux/api/templates/partials/memory.html +++ b/humux/api/templates/partials/memory.html @@ -7,13 +7,13 @@ {% set q = memory_q|default('') %} {%- macro nav(view) %} -
- - - +
+ + +
{%- endmacro %} @@ -60,8 +60,12 @@
{%- endmacro %} +{# Nav rendered once — the Alpine component inside it reads ?memsub= from + URL on init and syncs it when a sub-tab is clicked. The partial re-renders + into #tab-content on each sub-tab switch, so the component is fresh. #} +{{ nav(v) }} + {% if v == 'settings' %} -{{ nav('settings') }}
Memory lifecycle (forgetting & hygiene)
{% elif v == 'long-term' %} -{{ nav('long-term') }} {% set lt = long_term|default([]) %} {% set lt_total = long_term_total|default(0) %} @@ -382,7 +385,6 @@

Memory lifecycle (forgetting & hygiene)

{{ pag('long-term', lt_total) }} {% elif v == 'short-term' %} -{{ nav('short-term') }} {% set st = short_term|default([]) %} {% set st_total = short_term_total|default(0) %} From 2c93333f39e8cedb75e0324a11658458e3f8594a Mon Sep 17 00:00:00 2001 From: Matteo Merola Date: Fri, 10 Jul 2026 10:20:55 +0200 Subject: [PATCH 02/11] fix(admin): pag macro min filter broken syntax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit in Jinja2 iterates its left operand and finds the minimum — it doesn't accept an argument. Wrapping operands in a list fixes it. --- humux/api/templates/partials/memory.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/humux/api/templates/partials/memory.html b/humux/api/templates/partials/memory.html index 9a8d1e0b..398616a3 100644 --- a/humux/api/templates/partials/memory.html +++ b/humux/api/templates/partials/memory.html @@ -22,7 +22,7 @@ {% set cp = (off // lim) + 1 if total > 0 else 1 %} {% set tp = ((total + lim - 1) // lim) if total > 0 else 1 %} {% set ss = off + 1 if total > 0 else 0 %} -{% set se = (off + lim)|min(total) if total > 0 else 0 %} +{% set se = [(off + lim), total]|min if total > 0 else 0 %} {% set qs = ('&q=' + q|urlencode) if q else '' %}
From e1a563ae17d14b656943e021b18fa8c7d2e3d76f Mon Sep 17 00:00:00 2001 From: "kindralai[bot]" Date: Fri, 10 Jul 2026 08:22:04 +0000 Subject: [PATCH 03/11] test: update sub-tab nav assertions for Alpine @click buttons The PR replaced plain HTMX hx-get sub-tab buttons with Alpine-managed @click handlers. The test must now verify Alpine attributes instead. - Check for x-data="memorySubtab()" and x-init="init()" - Check for @click="select(...)" on each button - Check for :class bindings with 'tab-link-active' --- humux/tests/test_memory_subtabs.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/humux/tests/test_memory_subtabs.py b/humux/tests/test_memory_subtabs.py index e59b308d..16125f38 100644 --- a/humux/tests/test_memory_subtabs.py +++ b/humux/tests/test_memory_subtabs.py @@ -131,15 +131,19 @@ def test_memory_partial_with_legacy_tab_param() -> None: def test_sub_tab_nav_buttons_link_correctly() -> None: - """Sub-tab buttons have correct HTMX attributes.""" + """Sub-tab buttons have Alpine @click handlers (replaced hx-get).""" resp = _client().get("/partials/memory?view=settings", headers=HEADERS) body = resp.text - # Each nav button should target #tab-content via hx-get - assert 'hx-get="/partials/memory?view=settings"' in body - long_hx = 'hx-get="/partials/memory?view=long-term' - assert long_hx in body - short_hx = 'hx-get="/partials/memory?view=short-term' - assert short_hx in body + # Nav is wrapped in an Alpine component + assert 'x-data="memorySubtab()"' in body + assert 'x-init="init()"' in body + # Buttons use @click to invoke Alpine select() method + assert '@click="select(\'settings\')"' in body or "@click='select(\"settings\")'" in body + assert '@click="select(\'long-term\')"' in body or "@click='select(\"long-term\")'" in body + assert '@click="select(\'short-term\')"' in body or "@click='select(\"short-term\")'" in body + # Buttons use :class bindings for active state + assert ":class" in body + assert "'tab-link-active'" in body def test_endpoints_require_auth() -> None: From d8ca2abd51bce378ddbf424467c36fb51e267e6e Mon Sep 17 00:00:00 2001 From: Matteo Merola Date: Fri, 10 Jul 2026 10:35:34 +0200 Subject: [PATCH 04/11] refactor(admin): split all tab partials into lazy-loaded sub-tabs with URL params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert 5 admin tabs from monolithic partials with x-show/hash to wrapper + lazy-loaded sub-tab partials via htmx: **Jobs** (#hash → ?jobsub=) - Wrapper with skeleton; scheduled/subagents as separate partials - scheduled content keeps its own Alpine state for form/pagination - subagents points at existing /partials/subagent-runs **Memory** (?memsub=) - Split settings/long-term/short-term into separate templates - Pagination/search macros moved to shared _macros.html - Delete/update routes return sub-tab content to #memory-sub **LLM** (?llmsub=) - Keep the big llmTab Alpine component in the wrapper - inference/providers/history as separate partials - setSection() now fires HTMX to load sub-tab content - init() loads initial sub-tab after seeding model values **Accounts** (?accountsub=) - Already using subTabs pattern; skeleton loading verified **Vault** (?vaultsub=) - agents/infra/import as separate partials - vaultTabs() now uses subTabs pattern with skeleton All use the shared subTabs() utility which handles: - URL param read/write via replaceState - localStorage fallback - Skeleton display during HTMX load - Browser back/forward (via popstate in adminTabs) --- humux/api/admin.py | 224 +++-- humux/api/templates/base.html | 128 +-- humux/api/templates/dashboard.html | 29 +- humux/api/templates/partials/_macros.html | 83 ++ humux/api/templates/partials/jobs.html | 359 +------ .../templates/partials/jobs_scheduled.html | 291 ++++++ .../templates/partials/jobs_subagents.html | 6 + humux/api/templates/partials/llm.html | 945 +----------------- humux/api/templates/partials/llm_history.html | 4 + .../api/templates/partials/llm_inference.html | 498 +++++++++ .../api/templates/partials/llm_providers.html | 405 ++++++++ humux/api/templates/partials/memory.html | 457 +-------- .../templates/partials/memory_long_term.html | 71 ++ .../templates/partials/memory_settings.html | 249 +++++ .../templates/partials/memory_short_term.html | 64 ++ humux/api/templates/partials/secrets.html | 176 +--- .../templates/partials/secrets_agents.html | 96 ++ .../templates/partials/secrets_import.html | 59 +- .../api/templates/partials/secrets_infra.html | 54 + 19 files changed, 2084 insertions(+), 2114 deletions(-) create mode 100644 humux/api/templates/partials/_macros.html create mode 100644 humux/api/templates/partials/jobs_scheduled.html create mode 100644 humux/api/templates/partials/jobs_subagents.html create mode 100644 humux/api/templates/partials/llm_history.html create mode 100644 humux/api/templates/partials/llm_inference.html create mode 100644 humux/api/templates/partials/llm_providers.html create mode 100644 humux/api/templates/partials/memory_long_term.html create mode 100644 humux/api/templates/partials/memory_settings.html create mode 100644 humux/api/templates/partials/memory_short_term.html create mode 100644 humux/api/templates/partials/secrets_agents.html create mode 100644 humux/api/templates/partials/secrets_infra.html diff --git a/humux/api/admin.py b/humux/api/admin.py index 205df291..39be4ee5 100644 --- a/humux/api/admin.py +++ b/humux/api/admin.py @@ -2004,6 +2004,49 @@ async def partial_llm() -> HTMLResponse: **(await _history_ctx()), ) + async def _llm_providers_ctx() -> dict: + """Vaulted flags + API keys for the LLM Providers sub-tab partial.""" + anthropic_key = await config_store.get("agent.anthropic_api_key") or "" + openai_key = await config_store.get("agent.openai_api_key") or "" + google_key = await config_store.get("agent.google_api_key") or "" + grok_key = await config_store.get("agent.grok_api_key") or "" + deepseek_key = await config_store.get("agent.deepseek_api_key") or "" + openrouter_key = await config_store.get("agent.openrouter_api_key") or "" + return { + "anthropic_api_key": "" if _is_vault_ref(anthropic_key) else anthropic_key, + "anthropic_vaulted": _is_vault_ref(anthropic_key), + "openai_api_key": "" if _is_vault_ref(openai_key) else openai_key, + "openai_vaulted": _is_vault_ref(openai_key), + "openai_base_url": await config_store.get("agent.openai_base_url") or "", + "google_api_key": "" if _is_vault_ref(google_key) else google_key, + "google_vaulted": _is_vault_ref(google_key), + "google_base_url": await config_store.get("agent.google_base_url") or "", + "grok_api_key": "" if _is_vault_ref(grok_key) else grok_key, + "grok_vaulted": _is_vault_ref(grok_key), + "grok_base_url": await config_store.get("agent.grok_base_url") or "", + "deepseek_api_key": "" if _is_vault_ref(deepseek_key) else deepseek_key, + "deepseek_vaulted": _is_vault_ref(deepseek_key), + "deepseek_base_url": await config_store.get("agent.deepseek_base_url") or "", + "openrouter_api_key": "" if _is_vault_ref(openrouter_key) else openrouter_key, + "openrouter_vaulted": _is_vault_ref(openrouter_key), + "openrouter_base_url": await config_store.get("agent.openrouter_base_url") or "", + } + + @app.get("/partials/llm/inference", dependencies=[Depends(auth)]) + async def partial_llm_inference() -> HTMLResponse: + """LLM Inference sub-tab content.""" + return _render_partial("partials/llm_inference.html") + + @app.get("/partials/llm/providers", dependencies=[Depends(auth)]) + async def partial_llm_providers() -> HTMLResponse: + """LLM Providers sub-tab content.""" + return _render_partial("partials/llm_providers.html", **(await _llm_providers_ctx())) + + @app.get("/partials/llm/history", dependencies=[Depends(auth)]) + async def partial_llm_history() -> HTMLResponse: + """LLM History sub-tab content.""" + return _render_partial("partials/llm_history.html", **(await _history_ctx())) + def _browser_rules() -> list[dict]: """Per-domain browser `act` rules (excludes the generic default rule).""" agent = agent_state.agent @@ -2462,12 +2505,12 @@ async def _fetch_memories_paginated( return entries, total from core.memory import MemoryStore + await MemoryStore(db_path=memory_db)._ensure_schema() if tier == "long-term": cols = ( - "id, category, subject, content, source, " - "confidence, created_at, updated_at, scope" + "id, category, subject, content, source, confidence, created_at, updated_at, scope" ) table = "long_term" base_where = "" @@ -2510,24 +2553,21 @@ async def _fetch_memories_paginated( # Paginated rows data_sql = ( - f"SELECT {cols} FROM {table} {where_clause} " - f"ORDER BY {order} LIMIT ? OFFSET ?" + f"SELECT {cols} FROM {table} {where_clause} ORDER BY {order} LIMIT ? OFFSET ?" ) cursor = await db.execute(data_sql, params + [str(limit), str(offset)]) entries = [dict(row) for row in await cursor.fetchall()] return entries, total - async def _render_memory_partial( - view: str = "settings", + async def _memory_context( + view: str, offset: int = 0, limit: int = 25, q: str | None = None, - ) -> HTMLResponse: - """Build the Memory tab partial for the given sub-tab view. + ) -> dict: + """Build shared context for a memory sub-tab partial.""" - Shared by the tab load, pagination, search, and post-delete/update refresh. - """ async def _cfg(key: str, default: str) -> str: val = await config_store.get(key) return default if val is None or val == "" else str(val) @@ -2536,10 +2576,6 @@ async def _bool(key: str, default: str) -> str: val = await config_store.get(key) return default if val is None else str(val).lower() - allowed_views = ("settings", "long-term", "short-term") - if view not in allowed_views: - view = "settings" - ctx: dict[str, object] = { "memory_view": view, "memory_offset": offset, @@ -2559,17 +2595,26 @@ async def _bool(key: str, default: str) -> str: "archive_min_idle_days": await _cfg("memory.archive_min_idle_days", "45"), "hygiene_threshold": await _cfg("memory.hygiene_similarity_threshold", "0.45"), } - # Agent slugs for the scope dropdown in the edit forms (#158). agent_store = await _agent_store_from_config(config_store) ctx["agent_slugs"] = [a.name for a in await agent_store.list_agents()] + return ctx + async def _render_memory_subtab( + view: str = "settings", + offset: int = 0, + limit: int = 25, + q: str | None = None, + ) -> HTMLResponse: + """Build a single memory sub-tab content partial (no nav wrapper).""" + allowed_views = ("settings", "long-term", "short-term") + if view not in allowed_views: + view = "settings" + + ctx = await _memory_context(view, offset, limit, q) memory_db = await config_store.get("memory.db_path") or "data/memory.db" - if view == "settings": - # Settings view: no memory data needed - pass - elif view == "long-term": + if view == "long-term": entries, total = await _fetch_memories_paginated( memory_db, "long-term", offset, limit, q ) @@ -2582,25 +2627,39 @@ async def _bool(key: str, default: str) -> str: ctx["short_term"] = entries ctx["short_term_total"] = total - return _render_partial("partials/memory.html", **ctx) + template_name = "partials/memory_" + view.replace("-", "_") + ".html" + return _render_partial(template_name, **ctx) @app.get("/partials/memory", dependencies=[Depends(auth)]) - async def partial_memory( - request: Request, - view: str = "settings", + async def partial_memory() -> HTMLResponse: + """Memory tab wrapper — Settings | Long-term | Short-term sub-tabs. + + Thin shell; each sub-tab lazy-loads its own partial via htmx. + """ + return _render_partial("partials/memory.html") + + @app.get("/partials/memory/settings", dependencies=[Depends(auth)]) + async def partial_memory_settings() -> HTMLResponse: + """Memory Settings sub-tab content.""" + return await _render_memory_subtab(view="settings") + + @app.get("/partials/memory/long-term", dependencies=[Depends(auth)]) + async def partial_memory_long_term( offset: int = 0, limit: int = 25, q: str | None = None, ) -> HTMLResponse: - """Memory tab partial with sub-tab support. + """Long-term memory sub-tab content with pagination & search.""" + return await _render_memory_subtab(view="long-term", offset=offset, limit=limit, q=q) - Query params: - view — sub-tab: settings | long-term | short-term (default: settings) - offset — row offset for pagination (default: 0) - limit — page size (default: 25) - q — search query to filter by content/subject/category - """ - return await _render_memory_partial(view=view, offset=offset, limit=limit, q=q) + @app.get("/partials/memory/short-term", dependencies=[Depends(auth)]) + async def partial_memory_short_term( + offset: int = 0, + limit: int = 25, + q: str | None = None, + ) -> HTMLResponse: + """Short-term memory sub-tab content with pagination & search.""" + return await _render_memory_subtab(view="short-term", offset=offset, limit=limit, q=q) async def _history_ctx() -> dict: """History + compaction config, shared by the History sub-tab of the LLM @@ -2703,16 +2762,29 @@ def _get_jobs_list(include_done: bool = False) -> list[dict]: ) return jobs - @app.get("/partials/jobs", dependencies=[Depends(auth)]) - async def partial_jobs(show_completed: bool = False) -> HTMLResponse: - """Jobs tab partial. ``show_completed`` reveals done/cancelled jobs.""" + async def _jobs_context(show_completed: bool = False) -> dict: + """Shared context for jobs partials.""" jobs = _get_jobs_list(include_done=show_completed) agent_running = agent_state.agent is not None + return { + "jobs": jobs, + "agent_running": agent_running, + "show_completed": show_completed, + } + + @app.get("/partials/jobs", dependencies=[Depends(auth)]) + async def partial_jobs() -> HTMLResponse: + """Jobs tab wrapper — Scheduled | Subagents sub-tabs. + + Thin shell; each sub-tab lazy-loads its own partial via htmx. + """ + return _render_partial("partials/jobs.html") + + @app.get("/partials/jobs/scheduled", dependencies=[Depends(auth)]) + async def partial_jobs_scheduled(show_completed: bool = False) -> HTMLResponse: + """Scheduled jobs sub-tab content.""" return _render_partial( - "partials/jobs.html", - jobs=jobs, - agent_running=agent_running, - show_completed=show_completed, + "partials/jobs_scheduled.html", **(await _jobs_context(show_completed)) ) @app.post("/jobs", dependencies=[Depends(auth)]) @@ -2791,13 +2863,8 @@ async def upsert_job(request: Request) -> HTMLResponse: log.info("Job %r upserted via admin: %s (%s)", job_id, cron, job_type) show_completed = str(body.get("show_completed", "")).strip().lower() in ("true", "on", "1") - jobs = _get_jobs_list(include_done=show_completed) - agent_running = agent is not None resp = _render_partial( - "partials/jobs.html", - jobs=jobs, - agent_running=agent_running, - show_completed=show_completed, + "partials/jobs_scheduled.html", **(await _jobs_context(show_completed)) ) resp.headers["HX-Trigger"] = json.dumps({"showToast": f'Job "{job_id}" saved'}) return resp @@ -2828,13 +2895,8 @@ async def delete_job(request: Request) -> HTMLResponse: log.info("Job %r deleted via admin", job_id) show_completed = str(body.get("show_completed", "")).strip().lower() in ("true", "on", "1") - jobs = _get_jobs_list(include_done=show_completed) - agent_running = agent is not None resp = _render_partial( - "partials/jobs.html", - jobs=jobs, - agent_running=agent_running, - show_completed=show_completed, + "partials/jobs_scheduled.html", **(await _jobs_context(show_completed)) ) resp.headers["HX-Trigger"] = json.dumps({"showToast": f'Job "{job_id}" deleted'}) return resp @@ -2876,13 +2938,8 @@ async def pause_resume_job(request: Request) -> HTMLResponse: log.info("Job %r %s via admin", job_id, action) show_completed = str(body.get("show_completed", "")).strip().lower() in ("true", "on", "1") - jobs = _get_jobs_list(include_done=show_completed) - agent_running = agent is not None resp = _render_partial( - "partials/jobs.html", - jobs=jobs, - agent_running=agent_running, - show_completed=show_completed, + "partials/jobs_scheduled.html", **(await _jobs_context(show_completed)) ) resp.headers["HX-Trigger"] = json.dumps({"showToast": f'Job "{job_id}" {action}'}) return resp @@ -3731,7 +3788,10 @@ async def upsert_skill(body: SkillUpsertIn) -> HTMLResponse: total = await store.count_skills() return _render_partial( "partials/skills.html", - skills=skills, total=total, offset=0, limit=25, + skills=skills, + total=total, + offset=0, + limit=25, ) @app.post("/skills/install", dependencies=[Depends(auth)]) @@ -3755,7 +3815,10 @@ async def install_skill(request: Request) -> HTMLResponse: total = await store.count_skills() return _render_partial( "partials/skills.html", - skills=skills, total=total, offset=0, limit=25, + skills=skills, + total=total, + offset=0, + limit=25, install_result=result, ) @@ -3771,7 +3834,10 @@ async def update_skill(name: str) -> HTMLResponse: total = await store.count_skills() return _render_partial( "partials/skills.html", - skills=skills, total=total, offset=0, limit=25, + skills=skills, + total=total, + offset=0, + limit=25, install_result=result, ) @@ -3793,7 +3859,10 @@ async def delete_skill(request: Request) -> HTMLResponse: total = await store.count_skills() return _render_partial( "partials/skills.html", - skills=skills, total=total, offset=0, limit=25, + skills=skills, + total=total, + offset=0, + limit=25, ) @app.post("/skills/{name}/reset", dependencies=[Depends(auth)]) @@ -3810,7 +3879,10 @@ async def reset_skill(name: str) -> HTMLResponse: total = await store.count_skills() return _render_partial( "partials/skills.html", - skills=skills, total=total, offset=0, limit=25, + skills=skills, + total=total, + offset=0, + limit=25, ) @app.post("/skills/validate", dependencies=[Depends(auth)]) @@ -4211,7 +4283,7 @@ async def delete_memory(request: Request) -> HTMLResponse: raise HTTPException(404, f"Memory {memory_id} not found in {tier}") # Return refreshed sub-view based on the deleted tier - return await _render_memory_partial(view=tier) + return await _render_memory_subtab(view=tier) @app.post("/memory/update", dependencies=[Depends(auth)]) async def update_memory(request: Request) -> HTMLResponse: @@ -4255,7 +4327,7 @@ async def update_memory(request: Request) -> HTMLResponse: if rows == 0: raise HTTPException(404, f"Memory {memory_id} not found in {tier}") # Return refreshed sub-view based on the updated tier - return await _render_memory_partial(view=tier) + return await _render_memory_subtab(view=tier) @app.get("/memory/embedding/status", dependencies=[Depends(auth)]) async def embedding_status() -> dict: @@ -4747,12 +4819,38 @@ async def _secrets_ctx() -> dict: def _no_vault_partial() -> HTMLResponse: return _render_partial("partials/secrets.html", configured=False) + async def _vault_subtab(view: str) -> HTMLResponse: + """Render a vault sub-tab content partial.""" + if secret_store is None: + return _no_vault_partial() + ctx = await _secrets_ctx() + return _render_partial(f"partials/secrets_{view}.html", **ctx) + @app.get("/partials/secrets", response_class=HTMLResponse, dependencies=[Depends(auth)]) async def partial_secrets() -> HTMLResponse: + """Vaults tab wrapper — Agents | Infra | Import sub-tabs. + + Thin shell; each sub-tab lazy-loads its own partial via htmx. + """ if secret_store is None: return _no_vault_partial() return _render_partial("partials/secrets.html", **(await _secrets_ctx())) + @app.get("/partials/secrets/agents", response_class=HTMLResponse, dependencies=[Depends(auth)]) + async def partial_secrets_agents() -> HTMLResponse: + """Vault Agents sub-tab content.""" + return await _vault_subtab("agents") + + @app.get("/partials/secrets/infra", response_class=HTMLResponse, dependencies=[Depends(auth)]) + async def partial_secrets_infra() -> HTMLResponse: + """Vault Infra sub-tab content.""" + return await _vault_subtab("infra") + + @app.get("/partials/secrets/import", response_class=HTMLResponse, dependencies=[Depends(auth)]) + async def partial_secrets_import() -> HTMLResponse: + """Vault Import sub-tab content.""" + return await _vault_subtab("import") + @app.post("/admin/secrets/migrate", response_class=HTMLResponse, dependencies=[Depends(auth)]) async def migrate_secrets() -> HTMLResponse: """Move any still-plaintext credentials onto the infra vault (issue #35). diff --git a/humux/api/templates/base.html b/humux/api/templates/base.html index 5346b6a4..fa5933eb 100644 --- a/humux/api/templates/base.html +++ b/humux/api/templates/base.html @@ -284,8 +284,15 @@ url.searchParams.set('llmsub', s); history.replaceState(history.state, '', url); } catch (e) {} + this._loadSection(s); }, - reloadLlmPartial() { if (window.htmx) { htmx.ajax('GET', '/partials/llm', {target: '#tab-content', swap: 'innerHTML'}); } }, + _loadSection(s) { + if (window.htmx) { + showSkeleton('#llm-sub'); + htmx.ajax('GET', '/partials/llm/' + s, {target: '#llm-sub', swap: 'innerHTML'}); + } + }, + reloadLlmPartial() { this._loadSection(this.section); }, providerOptions: window.LLM_PROVIDERS, provider: currentProvider, apiKey: apiKey || '', @@ -549,6 +556,8 @@ this.resetMemoryModel('consolidation'); this.resetBackgroundModel('gd'); this.resetBackgroundModel('tr'); + // Load the initial sub-tab content after seeding model values. + this._loadSection(this.section); }); }, resetModel() { @@ -1319,95 +1328,56 @@ } window.accountsTabs = accountsTabs; - // Vaults tab: Agents | Infra | Import. Unlike inspect/accounts these are - // shown/hidden in place (x-show) because every action re-renders the whole - // partial into #tab-content; the sub-tab is mirrored in the URL (?vaultsub=) - // and restored after each re-render. + // Vaults tab: Agents | Infra | Import sub-tabs. + // Persisted via ?vaultsub= URL param; each sub-tab lazy-loads its own + // partial via htmx with skeleton loading. function vaultTabs() { - const allowed = ['agents', 'infra', 'import']; - return { - sub: (() => { - try { - const q = new URLSearchParams(window.location.search).get('vaultsub'); - if (allowed.includes(q)) { return q; } - const ls = localStorage.getItem('vault_sub'); - if (allowed.includes(ls)) { return ls; } - } catch (e) {} - return 'agents'; - })(), - init() { this._syncUrl(this.sub); }, - select(name) { - this.sub = name; - try { localStorage.setItem('vault_sub', name); } catch (e) {} - this._syncUrl(name); + return subTabs({ + subs: { + agents: '/partials/secrets/agents', + infra: '/partials/secrets/infra', + import: '/partials/secrets/import' }, - _syncUrl(name) { - try { - const url = new URL(window.location.href); - if (url.searchParams.get('vaultsub') !== name) { - url.searchParams.set('vaultsub', name); - history.replaceState(history.state, '', url); - } - } catch (e) {} - } - }; + param: 'vaultsub', + storageKey: 'vault_sub', + container: '#vault-sub', + fallback: 'agents' + }); } window.vaultTabs = vaultTabs; // Memory tab: Settings | Long-term | Short-term sub-tabs. - // Persisted so bookmarking, refresh, and browser back/forward land on - // the same sub-tab. Modeled after vaultTabs (x-show within a single - // partial that re-renders into #tab-content). + // Persisted via ?memsub= URL param; each sub-tab lazy-loads its own + // partial via htmx with skeleton loading. function memorySubtab() { - const allowed = ['settings', 'long-term', 'short-term']; - return { - sub: (() => { - try { - const q = new URLSearchParams(window.location.search).get('memsub'); - if (allowed.includes(q)) return q; - const ls = localStorage.getItem('memory_sub'); - if (allowed.includes(ls)) return ls; - } catch (e) {} - return 'settings'; - })(), - init() { this._syncUrl(this.sub); }, - select(name) { - this.sub = name; - try { localStorage.setItem('memory_sub', name); } catch (e) {} - this._syncUrl(name); - // Load the sub-tab content via HTMX, replacing the full tab pane. - // Pagination/search params preserved via the view-specific URLs - // embedded in the template. - if (window.htmx) { - const url = '/partials/memory?view=' + encodeURIComponent(name); - htmx.ajax('GET', url, {target: '#tab-content', swap: 'innerHTML'}); - } - }, - _syncUrl(name) { - try { - const url = new URL(window.location.href); - if (url.searchParams.get('memsub') !== name) { - url.searchParams.set('memsub', name); - history.replaceState(history.state, '', url); - } - } catch (e) {} + return subTabs({ + subs: { + settings: '/partials/memory/settings', + 'long-term': '/partials/memory/long-term', + 'short-term': '/partials/memory/short-term' }, - // Returns the HTMX URL for the 'long-term' and 'short-term' views - // including the current offset/limit/q. For settings view, no extra - // params needed. - tabUrl(name) { - if (name === 'settings') return '/partials/memory?view=settings'; - // These are supplied by the template context when the partial - // is rendered server-side; in the Alpine component we let the - // select() method pass a simple view param and let the server - // use defaults for offset/limit. The pagination buttons inside - // each view handle their own offsets. - return '/partials/memory?view=' + encodeURIComponent(name); - } - }; + param: 'memsub', + storageKey: 'memory_sub', + container: '#memory-sub', + fallback: 'settings' + }); } window.memorySubtab = memorySubtab; + // Jobs tab: Scheduled | Subagents sub-tabs. + // Persisted via ?jobsub= URL param; each sub-tab lazy-loads its own + // partial via htmx with skeleton loading. + function jobsTabs() { + return subTabs({ + subs: { scheduled: '/partials/jobs/scheduled', subagents: '/partials/jobs/subagents' }, + param: 'jobsub', + storageKey: 'jobs_sub', + container: '#jobs-sub', + fallback: 'scheduled' + }); + } + window.jobsTabs = jobsTabs; + // Global: handle 401 by redirecting to auth gate document.body.addEventListener('htmx:responseError', function(e) { if (e.detail.xhr.status === 401) { diff --git a/humux/api/templates/dashboard.html b/humux/api/templates/dashboard.html index 612ed216..b43a5d34 100644 --- a/humux/api/templates/dashboard.html +++ b/humux/api/templates/dashboard.html @@ -580,14 +580,7 @@

Agent Control

// Defer the initial HTMX fetch so both Alpine and HTMX are fully // initialised before the request fires. this.$nextTick(() => { - let url = tabs[initialTab]; - // Forward sub-tab param for memory (?memsub=) so the initial load - // lands on the right sub-tab instead of always defaulting to settings. - if (initialTab === 'memory') { - const ms = new URLSearchParams(window.location.search).get('memsub'); - if (ms) { url += '?view=' + encodeURIComponent(ms); } - } - htmx.ajax('GET', url, {target: '#tab-content', swap: 'innerHTML'}); + htmx.ajax('GET', tabs[initialTab], {target: '#tab-content', swap: 'innerHTML'}); }); window.addEventListener('popstate', () => { @@ -601,31 +594,17 @@

Agent Control

this.loadTab(name, true); }, loadTab(name, updateUrl) { - // Always re-load memory when memsub param may have changed - // (e.g. browser back/forward). For other tabs, skip if already active. - if (!tabs[name] || (this.tab === name && !updateUrl && name !== 'memory')) { + if (!tabs[name] || this.tab === name && !updateUrl) { return; } this.tab = name; - let fetchUrl = tabs[name]; - // Forward sub-tab param for memory (?memsub=) so the URL-preserved - // sub-tab is loaded after a refresh or navigation. - if (name === 'memory') { - const ms = new URLSearchParams(window.location.search).get('memsub'); - if (ms) { fetchUrl += '?view=' + encodeURIComponent(ms); } - } showSkeleton('#tab-content'); - htmx.ajax('GET', fetchUrl, {target: '#tab-content', swap: 'innerHTML'}); + htmx.ajax('GET', tabs[name], {target: '#tab-content', swap: 'innerHTML'}); if (updateUrl) { - // Save sub-tab params before clearing (#197). - const curParams = new URLSearchParams(window.location.search); - const memsub = curParams.get('memsub'); const url = new URL(window.location.href); + // Strip all sub-tab query params when switching parent tabs (#197). url.search = ''; url.searchParams.set('tab', name); - // Preserve memsub when switching to the memory tab so bookmarking - // still works across tab switches. - if (name === 'memory' && memsub) { url.searchParams.set('memsub', memsub); } history.pushState({tab: name}, '', url); } } diff --git a/humux/api/templates/partials/_macros.html b/humux/api/templates/partials/_macros.html new file mode 100644 index 00000000..f19bbb75 --- /dev/null +++ b/humux/api/templates/partials/_macros.html @@ -0,0 +1,83 @@ +{# Shared Jinja macros used by multiple tab partials #} + +{# ── LLM thinking-level control ─────────────────────────────── #} +{%- macro think(level, prov, model, lid, bg=false) -%} +
+ +
+ + + + + + + effort docs ↗ +
+

+

Leave Off for non-reasoning models. Higher = more reasoning, more tokens. Anthropic: "Fetch levels" autodiscovers supported values; other providers: see the docs link and type the value.

+

Heads up: "" isn't a recognized reasoning model — only set a level if you know it supports thinking, or the inference call may error.

+
+{%- endmacro %} + +{# ── Memory pagination ──────────────────────────────────────── #} +{%- macro memory_pag(view, total) %} +{% set off = memory_offset|default(0)|int %} +{% set lim = memory_limit|default(25)|int %} +{% set q = memory_q|default('') %} +{% set page_sizes = [25, 50, 100] %} +{% set total = total|default(0)|int %} +{% set cp = (off // lim) + 1 if total > 0 else 1 %} +{% set tp = ((total + lim - 1) // lim) if total > 0 else 1 %} +{% set ss = off + 1 if total > 0 else 0 %} +{% set se = [(off + lim), total]|min if total > 0 else 0 %} +{% set qs = ('&q=' + q|urlencode) if q else '' %} +
+
+ + {%- if total > 0 %}Showing {{ ss }}–{{ se }} of {{ total }}{% else %}No entries{% endif -%} + + + +
+
+ + + Page {{ cp }} / {{ tp }} + + +
+
+{%- endmacro %} + +{# ── Memory search ──────────────────────────────────────────── #} +{%- macro memory_search(view) %} +{% set off = memory_offset|default(0)|int %} +{% set lim = memory_limit|default(25)|int %} +{% set q = memory_q|default('') %} +
+ + +
+{%- endmacro %} + +{# ── Memory category list ───────────────────────────────────── #} +{% set memory_categories = ['preference','relationship','fact','routine','work','health','travel'] %} diff --git a/humux/api/templates/partials/jobs.html b/humux/api/templates/partials/jobs.html index 91505c00..0afa07f8 100644 --- a/humux/api/templates/partials/jobs.html +++ b/humux/api/templates/partials/jobs.html @@ -1,357 +1,18 @@ -{# Jobs tab partial #} -{% set job_agents = jobs | map(attribute='agent') | reject('equalto', '') | unique | sort %} -
- {# Sub-tabs: Scheduled | Subagents — selection remembered in URL hash #} +{# Jobs tab wrapper: Scheduled | Subagents sub-tabs. + Each sub-tab lazy-loads its own partial via htmx. The active sub-tab + is mirrored in the URL as ?jobsub= for copy-pasteable links. #} +
- - +
- - {# ── Scheduled jobs ───────────────────────────────────────── #} -
-
- - {{ jobs|length }} job{{ 's' if jobs|length != 1 else '' }} - {% if job_agents %} - - {% endif %} - {% if not agent_running %} - agent stopped — jobs won't run - {% endif %} - -
- - {# Jobs table #} -
- - - - - - - - - - - - - - - - {% for job in jobs %} - - - - - - - - - - - - {% endfor %} - {% if not jobs %} - - {% endif %} - -
IDScheduleTypeAgentTask / CommandStatusChannelNext Run
- {{ job.id }} - {% if job.description %} - {{ job.description[:40] }}{{ '...' if job.description|length > 40 else '' }} - {% endif %} - - {{ job.cron }} - - - {{ job.type }} - - {{ job.agent or '—' }} - {{ job.task }} - - - {{ job.status }} - - {{ job.channel }}{{ job.next_run or '—' }} - - - - -
No jobs configured
-
- - {# Pagination controls #} -
-
- - -
- -
- - - -
-
- - {# Flash area for run-now feedback #} -
- - {# Collapsible add / edit job form #} -
- - -
-
-

Add new job

- -
-
-
-
- - - Unique identifier (lowercase, dashes ok) -
-
- - -
-
- - - 5-field cron: min hour day month weekday -
-
- - - ISO datetime with timezone offset -
-
- - -
-
- - - Agent this job runs as (blank = default identity) -
-
- - - Where to deliver the result -
-
- - - Natural language instruction for the agent - Only sends when there are updates; otherwise stays quiet - Shell command to execute - Task for the subagent to run under the chosen agent - No task needed — runs memory consolidation automatically -
-
-
- - - -
-
- - {# Cron cheat-sheet #} -
- - Cron cheat sheet - -
- 0 7 * * * Every day at 07:00 - */15 * * * * Every 15 minutes - 0 9 * * 1-5 Weekdays at 09:00 - 0 0 * * 0 Sunday at midnight - 30 18 * * * Every day at 18:30 - 0 */6 * * * Every 6 hours -
-
-
-
-
- - {# ── Subagent runs ────────────────────────────────────────── #} -
-
-

Loading…

-
+
+
+
+
- - diff --git a/humux/api/templates/partials/jobs_scheduled.html b/humux/api/templates/partials/jobs_scheduled.html new file mode 100644 index 00000000..3e21aa31 --- /dev/null +++ b/humux/api/templates/partials/jobs_scheduled.html @@ -0,0 +1,291 @@ +{% set job_agents = jobs | map(attribute='agent') | reject('equalto', '') | unique | sort %} +
+
+ + {{ jobs|length }} job{{ 's' if jobs|length != 1 else '' }} + {% if job_agents %} + + {% endif %} + {% if not agent_running %} + agent stopped — jobs won't run + {% endif %} + +
+ + {# Jobs table #} +
+ + + + + + + + + + + + + + + + {% for job in jobs %} + + + + + + + + + + + + {% endfor %} + {% if not jobs %} + + {% endif %} + +
IDScheduleTypeAgentTask / CommandStatusChannelNext Run
+ {{ job.id }} + {% if job.description %} + {{ job.description[:40] }}{{ '...' if job.description|length > 40 else '' }} + {% endif %} + + {{ job.cron }} + + + {{ job.type }} + + {{ job.agent or '—' }} + {{ job.task }} + + + {{ job.status }} + + {{ job.channel }}{{ job.next_run or '—' }} + + + + +
No jobs configured
+
+ + {# Pagination controls #} +
+
+ + +
+ +
+ + + +
+
+ + {# Flash area for run-now feedback #} +
+ + {# Collapsible add / edit job form #} +
+ + +
+
+

Add new job

+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + +
+
+ + {# Cron cheat-sheet #} +
+ Cron cheat sheet +
+ 0 7 * * * Every day at 07:00 + */15 * * * * Every 15 minutes + 0 9 * * 1-5 Weekdays at 09:00 + 0 0 * * 0 Sunday at midnight + 30 18 * * * Every day at 18:30 + 0 */6 * * * Every 6 hours +
+
+
+
+
diff --git a/humux/api/templates/partials/jobs_subagents.html b/humux/api/templates/partials/jobs_subagents.html new file mode 100644 index 00000000..931e785d --- /dev/null +++ b/humux/api/templates/partials/jobs_subagents.html @@ -0,0 +1,6 @@ +
+
+
+
+
diff --git a/humux/api/templates/partials/llm.html b/humux/api/templates/partials/llm.html index c5d051fa..458058e8 100644 --- a/humux/api/templates/partials/llm.html +++ b/humux/api/templates/partials/llm.html @@ -1,31 +1,8 @@ -{# LLM tab partial #} -{#- Reusable thinking-level control. `bg` adds the "shared config" badge for - background inference kinds that point at the same provider+model as main. -#} -{% macro think(level, prov, model, lid, bg=false) -%} -
- -
- - - - - - - effort docs ↗ -
-

-

Leave Off for non-reasoning models. Higher = more reasoning, more tokens. Anthropic: “Fetch levels” autodiscovers supported values; other providers: see the docs link and type the value.

-

Heads up: “” isn't a recognized reasoning model — only set a level if you know it supports thinking, or the inference call may error.

-
-{%- endmacro %} +{# LLM tab wrapper — Inference | Providers | History sub-tabs. + Holds the Alpine x-data with all LLM state. Each sub-tab is lazy-loaded + via htmx into #llm-sub (inside the Alpine scope so x-model etc. reach + the parent component). #} +{% import 'partials/_macros.html' as m %}
- {# Sub-tabs (issue #114): split the crowded LLM settings into views. - SysPrompt sub-tab removed (#137) — the Inspect tab shows the full - assembled system prompt directly. #} + {# Sub-tabs: Inference | Providers | History #}
- -
-
-

Active Inference Provider

-

Select the provider used for agent inference after testing its connection.

- -
-
- - -
- -
- - - - - -

Type any model id, or pick from the list. Use “Fetch models” in the provider card on the tab to load the live list from the API.

- - {# Native-vision indicator + one-tap vision-fallback prompt. #} -

- 🖼️ This model reads images natively. -

-
-

- This model can’t read images. Set up a vision fallback so photos still work? - Vision fallback is on — images are captioned by . -

- -
-
- -
-
- - -

Hard ceiling on tokens the model may emit per response. Too low truncates large outputs (e.g. web artifacts) mid-generation. Default 16384; raise for capable models (Claude allows up to 128000).

-
-
- - -

Hard backstop on LLM round-trips in a single user turn. Default 50; raise if a legitimate workflow needs more rounds.

-
-
- -
- - -

Sampling randomness for the main agent loop. Lower = steadier tool calls; higher = more varied prose. Skipped automatically when a thinking level is set. Default 0.5.

-
- -{{ think('thinkingLevel', 'provider', 'model', 'dl-think-main') }} -
- -
- -
- - +
+
+
+
- -
-

Memory Models

-

Choose which provider and model to use for memory extraction and consolidation. These can differ from the main inference model.

- -
-
- - -
-
- - - - - -

Model used to extract memories from conversations.

-
-{{ think('extractionThinkingLevel', 'extractionProvider', 'extractionModel', 'dl-think-ext', true) }} - -
- -
- - -
-
- - - - - -

Model used to consolidate and merge duplicate memories.

-
-{{ think('consolidationThinkingLevel', 'consolidationProvider', 'consolidationModel', 'dl-think-con', true) }} -
- -
- -
- -
- -
-

Goal Decomposition

-

Break complex user requests into structured sub-goals before the main LLM call. Uses a cheap/fast model for classification and decomposition.

- -
-
- - -
- -
- - -
-
- - - - - -

Model used to classify message complexity and decompose goals.

-
-{{ think('gdThinkingLevel', 'gdProvider', 'gdModel', 'dl-think-gd', true) }} -
- -
- -
- -
- -
-

Task Reflection

-

After the agent completes a task with tools, a background reflection step analyses the execution and stores lessons learned for future prompts.

- -
-
- - -
- -
- - -
-
- - - - - -

Model used to reflect on completed tasks and extract lessons.

-
-
- - -

How many past lessons are injected each turn. Keep small to limit prompt bloat. Default 12.

-
-{{ think('trThinkingLevel', 'trProvider', 'trModel', 'dl-think-tr', true) }} -
- -
- -
- -
- -
-

Reply Decision

-

In shared/group chats with several bots and people, decide per message whether to reply at all — staying quiet for messages aimed at another bot, caught in a bot-to-bot reaction loop, or that the agent can't usefully add to. Off by default; uses a cheap/fast model for the yes/no call.

- -
-
- - -
- -
- - -
-
- - - - - -

Model used to decide whether a group-chat message warrants a reply.

-
-{{ think('rdThinkingLevel', 'rdProvider', 'rdModel', 'dl-think-rd', true) }} - -
- - - When on, 1:1 chats always get a reply; only group chats are gated. -
-
-
- - -
-
- - -
-
-

Hard backstop: never send more than this many auto-replies into one chat per rolling window — guarantees a runaway loop terminates even if the model keeps voting to reply.

-
- -
- -
- -
- -
-

History Compaction

-

Model used to summarize older conversation turns when a session's context grows large. Enable compaction and set the trigger threshold in the sub-tab.

- -
-
- - -
-
- - - - - -

A small, fast model is recommended (summarization is cheap).

-
-{{ think('compactionThinkingLevel', 'compactionProvider', 'compactionModel', 'dl-think-cp', true) }} -
- -
- -
- -
- -
-

Vision Fallback

-

When the active model can’t read images, route image attachments to this model for a text description / OCR, injected in place of the image. Off by default; engages only when the active model lacks native vision. Reuses the provider keys configured below.

- -
-
- - -
- -
- - -
-
- - - - - -

- Heads up: “” isn’t a recognized vision model — pick one that can read images, or captioning will fail. -

-

A small, cheap vision model is recommended (captioning is a single short call).

-
-
- -
- -
- -
-
{# /inference #} - - {# /providers #} - - {# /history #}
diff --git a/humux/api/templates/partials/llm_history.html b/humux/api/templates/partials/llm_history.html new file mode 100644 index 00000000..6cc8e2dc --- /dev/null +++ b/humux/api/templates/partials/llm_history.html @@ -0,0 +1,4 @@ +
+ {% include 'partials/history.html' %} +
{# /history #} +
diff --git a/humux/api/templates/partials/llm_inference.html b/humux/api/templates/partials/llm_inference.html new file mode 100644 index 00000000..cbb36abb --- /dev/null +++ b/humux/api/templates/partials/llm_inference.html @@ -0,0 +1,498 @@ +{% import "partials/_macros.html" as m %} +
+
+

Active Inference Provider

+

Select the provider used for agent inference after testing its connection.

+ +
+
+ + +
+ +
+ + + + + +

Type any model id, or pick from the list. Use “Fetch models” in the provider card on the tab to load the live list from the API.

+ + {# Native-vision indicator + one-tap vision-fallback prompt. #} +

+ 🖼️ This model reads images natively. +

+
+

+ This model can’t read images. Set up a vision fallback so photos still work? + Vision fallback is on — images are captioned by . +

+ +
+
+ +
+
+ + +

Hard ceiling on tokens the model may emit per response. Too low truncates large outputs (e.g. web artifacts) mid-generation. Default 16384; raise for capable models (Claude allows up to 128000).

+
+
+ + +

Hard backstop on LLM round-trips in a single user turn. Default 50; raise if a legitimate workflow needs more rounds.

+
+
+ +
+ + +

Sampling randomness for the main agent loop. Lower = steadier tool calls; higher = more varied prose. Skipped automatically when a thinking level is set. Default 0.5.

+
+ +{{ think('thinkingLevel', 'provider', 'model', 'dl-think-main') }} +
+ +
+ +
+ + +
+ +
+

Memory Models

+

Choose which provider and model to use for memory extraction and consolidation. These can differ from the main inference model.

+ +
+
+ + +
+
+ + + + + +

Model used to extract memories from conversations.

+
+{{ think('extractionThinkingLevel', 'extractionProvider', 'extractionModel', 'dl-think-ext', true) }} + +
+ +
+ + +
+
+ + + + + +

Model used to consolidate and merge duplicate memories.

+
+{{ think('consolidationThinkingLevel', 'consolidationProvider', 'consolidationModel', 'dl-think-con', true) }} +
+ +
+ +
+ +
+ +
+

Goal Decomposition

+

Break complex user requests into structured sub-goals before the main LLM call. Uses a cheap/fast model for classification and decomposition.

+ +
+
+ + +
+ +
+ + +
+
+ + + + + +

Model used to classify message complexity and decompose goals.

+
+{{ think('gdThinkingLevel', 'gdProvider', 'gdModel', 'dl-think-gd', true) }} +
+ +
+ +
+ +
+ +
+

Task Reflection

+

After the agent completes a task with tools, a background reflection step analyses the execution and stores lessons learned for future prompts.

+ +
+
+ + +
+ +
+ + +
+
+ + + + + +

Model used to reflect on completed tasks and extract lessons.

+
+
+ + +

How many past lessons are injected each turn. Keep small to limit prompt bloat. Default 12.

+
+{{ think('trThinkingLevel', 'trProvider', 'trModel', 'dl-think-tr', true) }} +
+ +
+ +
+ +
+ +
+

Reply Decision

+

In shared/group chats with several bots and people, decide per message whether to reply at all — staying quiet for messages aimed at another bot, caught in a bot-to-bot reaction loop, or that the agent can't usefully add to. Off by default; uses a cheap/fast model for the yes/no call.

+ +
+
+ + +
+ +
+ + +
+
+ + + + + +

Model used to decide whether a group-chat message warrants a reply.

+
+{{ think('rdThinkingLevel', 'rdProvider', 'rdModel', 'dl-think-rd', true) }} + +
+ + + When on, 1:1 chats always get a reply; only group chats are gated. +
+
+
+ + +
+
+ + +
+
+

Hard backstop: never send more than this many auto-replies into one chat per rolling window — guarantees a runaway loop terminates even if the model keeps voting to reply.

+
+ +
+ +
+ +
+ +
+

History Compaction

+

Model used to summarize older conversation turns when a session's context grows large. Enable compaction and set the trigger threshold in the sub-tab.

+ +
+
+ + +
+
+ + + + + +

A small, fast model is recommended (summarization is cheap).

+
+{{ think('compactionThinkingLevel', 'compactionProvider', 'compactionModel', 'dl-think-cp', true) }} +
+ +
+ +
+ +
+ +
+

Vision Fallback

+

When the active model can’t read images, route image attachments to this model for a text description / OCR, injected in place of the image. Off by default; engages only when the active model lacks native vision. Reuses the provider keys configured below.

+ +
+
+ + +
+ +
+ + +
+
+ + + + + +

+ Heads up: “” isn’t a recognized vision model — pick one that can read images, or captioning will fail. +

+

A small, cheap vision model is recommended (captioning is a single short call).

+
+
+ +
+ +
+ +
+
{# /inference #} + diff --git a/humux/api/templates/partials/llm_providers.html b/humux/api/templates/partials/llm_providers.html new file mode 100644 index 00000000..f5ed9290 --- /dev/null +++ b/humux/api/templates/partials/llm_providers.html @@ -0,0 +1,405 @@ +{% import "partials/_macros.html" as m %} +
+
+ 🔒 Provider API keys are stored in the vault on save — only a ${vault:NAME} reference is kept in config, never the raw key. Manage or reuse them from the tab. +
+
+

Anthropic

+

Configure the Anthropic API key and test the connection.

+
+
+ {% if anthropic_vaulted %}Anthropic API Key{% else %}{% endif %} + + {% if anthropic_vaulted %} +
🔒 Stored in the vault as ${vault:ANTHROPIC_API_KEY} for easy reuse — manage it from the tab.
+ {% else %} + +

Get a key at console.anthropic.com

+ {% endif %} +
+ + + +
+ + +
+
+
+ +
+
+ +
+

OpenAI

+

Configure the OpenAI key and optional base URL, then test the connection.

+
+
+ {% if openai_vaulted %}OpenAI API Key{% else %}{% endif %} + {% if openai_vaulted %} +
🔒 Stored in the vault as ${vault:OPENAI_API_KEY} for easy reuse — manage it from the tab.
+ {% else %} + +

Create one in platform.openai.com

+ {% endif %} +
+
+ + +
+ + + +
+ + +
+
+
+ +
+
+ +
+

Google Gemini

+

Configure the Google API key and optional base URL, then test the connection.

+
+
+ {% if google_vaulted %}Google API Key{% else %}{% endif %} + {% if google_vaulted %} +
🔒 Stored in the vault as ${vault:GOOGLE_API_KEY} for easy reuse — manage it from the tab.
+ {% else %} + +

Create a key in Google AI Studio

+ {% endif %} +
+
+ + +
+ + + +
+ + +
+
+
+ +
+
+ +
+

Grok (xAI)

+

Configure the Grok key and optional base URL, then test the connection.

+
+
+ {% if grok_vaulted %}Grok API Key{% else %}{% endif %} + {% if grok_vaulted %} +
🔒 Stored in the vault as ${vault:GROK_API_KEY} for easy reuse — manage it from the tab.
+ {% else %} + +

Create a key in console.x.ai

+ {% endif %} +
+
+ + +
+ + + +
+ + +
+
+
+ +
+
+ +
+

DeepSeek

+

Configure the DeepSeek key and optional base URL, then test the connection.

+
+
+ {% if deepseek_vaulted %}DeepSeek API Key{% else %}{% endif %} + {% if deepseek_vaulted %} +
🔒 Stored in the vault as ${vault:DEEPSEEK_API_KEY} for easy reuse — manage it from the tab.
+ {% else %} + +

Create a key in platform.deepseek.com

+ {% endif %} +
+
+ + +
+ + + +
+ + +
+
+
+ +
+
+ +
+

OpenRouter

+

A provider-agnostic gateway: one API key reaches models from many labs (Anthropic, OpenAI, Google, Meta, DeepSeek, …). Model ids are namespaced provider/model (e.g. anthropic/claude-sonnet-4.5) — use Fetch models for the live catalog.

+

OpenRouter can auto-route around provider outages and fall back between hosts, and publishes transparent per-token pricing per model. Docs: openrouter.ai/docs · Models & pricing: openrouter.ai/models · Routing & fallbacks: provider routing.

+
+
+ {% if openrouter_vaulted %}OpenRouter API Key{% else %}{% endif %} + {% if openrouter_vaulted %} +
🔒 Stored in the vault as ${vault:OPENROUTER_API_KEY} for easy reuse — manage it from the tab.
+ {% else %} + +

Create a key in openrouter.ai/settings/keys

+ {% endif %} +
+
+ + +
+ + + +
+ + +
+
+
+ +
+
+
{# /providers #} + diff --git a/humux/api/templates/partials/memory.html b/humux/api/templates/partials/memory.html index 398616a3..cd982159 100644 --- a/humux/api/templates/partials/memory.html +++ b/humux/api/templates/partials/memory.html @@ -1,447 +1,16 @@ -{# Memory tab partial with sub-tabs: Settings | Long-term | Short-term #} -{% set categories = ['preference','relationship','fact','routine','work','health','travel'] %} -{% set page_sizes = [25, 50, 100] %} -{% set v = memory_view|default('settings') %} -{% set off = memory_offset|default(0)|int %} -{% set lim = memory_limit|default(25)|int %} -{% set q = memory_q|default('') %} - -{%- macro nav(view) %} -
- - - -
-{%- endmacro %} - -{%- macro pag(view, total) %} -{% set total = total|default(0)|int %} -{% set cp = (off // lim) + 1 if total > 0 else 1 %} -{% set tp = ((total + lim - 1) // lim) if total > 0 else 1 %} -{% set ss = off + 1 if total > 0 else 0 %} -{% set se = [(off + lim), total]|min if total > 0 else 0 %} -{% set qs = ('&q=' + q|urlencode) if q else '' %} -
-
- - {%- if total > 0 %}Showing {{ ss }}–{{ se }} of {{ total }}{% else %}No entries{% endif -%} - - - -
-
- - - Page {{ cp }} / {{ tp }} - - -
-
-{%- endmacro %} - -{%- macro search(view) %} -
- - -
-{%- endmacro %} - -{# Nav rendered once — the Alpine component inside it reads ?memsub= from - URL on init and syncs it when a sub-tab is clicked. The partial re-renders - into #tab-content on each sub-tab switch, so the component is fresh. #} -{{ nav(v) }} - -{% if v == 'settings' %} -
- - {# Memory config section #} -
-

Memory Settings

-

Configure memory storage behaviour.

-
-
- - -

Maximum number of long-term memories before consolidation triggers cleanup.

-
-
-
- -
- -

- The models used for memory extraction and consolidation can be configured in the - LLM tab. -

-
- - {# Semantic memory (embeddings) #} -
-

Semantic memory (embeddings)

-

- Match memories by meaning, not just words - — so “allergic to shellfish” surfaces when you ask about eating prawns. -

-
- How it works (the science) -
-

An embedding model turns each memory and your current message into a vector — a list of numbers positioning the text in a “meaning space”. Texts with similar meaning land close together, even with no shared words.

-

Closeness is measured by cosine similarity (1.0 = same direction/meaning, 0 = unrelated). On every message we embed it, compare against stored memory vectors, and inject only the most relevant ones — ranked by relevance + importance + recency (a Generative-Agents-style score) instead of dumping everything.

-

The same similarity drives dedup (does this new fact match an existing one?) and the hygiene pass (cluster & merge near-duplicates).

-

Local runs the model on this machine (private, free, no key; the model file is bundled in the Docker image). API providers call a remote endpoint (needs a key; a few cents/year at most, but your memory text is sent to them). When disabled, memory falls back to fast word-overlap matching — still works, just less “fuzzy”.

-
-
-
-
- - - Off → lexical (word-overlap) retrieval, no model needed. -
-
- - -

- API key falls back to the matching provider key set in the - LLM tab. - (Note: DeepSeek has no embeddings endpoint.) -

-
-
- - -

Multilingual CPU model (384-dim, ~470MB).

-

e.g. text-embedding-3-small (OpenAI) or text-embedding-004 (Google).

-
-
- - -
-
- - -

How many of the most relevant long-term memories to put in the prompt each message.

-
-
- - -

Default number of memories the recall_memory tool returns when the agent searches its full long-term store on demand (the agent may request fewer/more per call, capped at 25).

-
-
- Model on disk: - yes ✓ - not downloaded yet -
-
-
- - - -
- - -

Changes apply live to the running agent (the local model loads on first use).

+{# Memory tab wrapper: Settings | Long-term | Short-term sub-tabs. + Each sub-tab lazy-loads its own partial via htmx. The active sub-tab + is mirrored in the URL as ?memsub= for copy-pasteable links. #} +{% import 'partials/_macros.html' as m %} +
+
+ + +
- - {# Memory lifecycle (forgetting + hygiene) #} -
-

Memory lifecycle (forgetting & hygiene)

-

Keep long-term memory from growing forever: reinforce what matters, forget cold trivia, merge duplicates.

-
- How it works (the science) -
-

Each memory has an importance (1–10). Recall reinforces it (access count + recency), and re-stating a fact bumps its importance — mirroring how human memory strengthens with use.

-

Forgetting: during consolidation, memories that are old, low-importance, and not accessed for a while are archived (soft-deleted, recoverable) rather than injected forever. This is decay, like the “use it or lose it” curve.

-

Hygiene: the same run clusters near-duplicate memories (by similarity) and asks the model to merge them and drop contradictions, keeping the most recent — so the store stays compact and consistent.

-
-
-
-
- - -

Starting importance for a new memory.

-
-
- - -

A memory must be at least this old before it can be archived.

-
-
- - -

Important memories are never auto-archived.

-
-
- - -

Require this long since last access/creation before archiving.

-
-
-
- - - Cluster & merge near-duplicates during consolidation. -
-
- - -

Higher = stricter (only very-similar memories merge). 0.45 is a sensible default.

-
-
-
- - - -
- +
+
+
+
- -{% elif v == 'long-term' %} -{% set lt = long_term|default([]) %} -{% set lt_total = long_term_total|default(0) %} - -{{ search('long-term') }} -{{ pag('long-term', lt_total) }} - -
- - - - - - - - - - - - - {% for m in lt %} - - - - - - - - - {% endfor %} - {% if not lt %} - - {% endif %} - -
IDCategorySubjectContentScope
{{ m.id }} - {{ m.category }} - - - {{ m.subject }} - - - {{ m.content }} - - - {% if m.scope %}{{ m.scope }}{% else %}shared{% endif %} - - - - - - -
No long-term memories
-
-{{ pag('long-term', lt_total) }} - -{% elif v == 'short-term' %} -{% set st = short_term|default([]) %} -{% set st_total = short_term_total|default(0) %} - -{{ search('short-term') }} -{{ pag('short-term', st_total) }} - -
- - - - - - - - - - - - {% for m in st %} - - - - - - - - {% endfor %} - {% if not st %} - - {% endif %} - -
IDContentScopeExpires
{{ m.id }} - {{ m.content }} - - - {% if m.scope %}{{ m.scope }}{% else %}shared{% endif %} - - - {{ m.expires_at }} - - - - - - -
No active short-term memories
-
-{{ pag('short-term', st_total) }} - -{% endif %} diff --git a/humux/api/templates/partials/memory_long_term.html b/humux/api/templates/partials/memory_long_term.html new file mode 100644 index 00000000..a72d6306 --- /dev/null +++ b/humux/api/templates/partials/memory_long_term.html @@ -0,0 +1,71 @@ +{% import 'partials/_macros.html' as m %} +{% set categories = m.memory_categories %} +{% set lt = long_term|default([]) %} +{% set lt_total = long_term_total|default(0) %} +{% set off = memory_offset|default(0)|int %} +{% set lim = memory_limit|default(25)|int %} +{% set q = memory_q|default('') %} +{% set agent_slugs = agent_slugs|default([]) %} + +{{ m.memory_search('long-term') }} +{{ m.memory_pag('long-term', lt_total) }} + +
+ + + + + + + + + + + + + {% for m in lt %} + + + + + + + + + {% endfor %} + {% if not lt %} + + {% endif %} + +
IDCategorySubjectContentScope
{{ m.id }} + {{ m.category }} + + + {{ m.subject }} + + + {{ m.content }} + + + {% if m.scope %}{{ m.scope }}{% else %}shared{% endif %} + + + + + + +
No long-term memories
+
+{{ m.memory_pag('long-term', lt_total) }} diff --git a/humux/api/templates/partials/memory_settings.html b/humux/api/templates/partials/memory_settings.html new file mode 100644 index 00000000..a6fb9721 --- /dev/null +++ b/humux/api/templates/partials/memory_settings.html @@ -0,0 +1,249 @@ +{% import 'partials/_macros.html' as m %} +
+ + {# Memory config section #} +
+

Memory Settings

+

Configure memory storage behaviour.

+
+
+ + +

Maximum number of long-term memories before consolidation triggers cleanup.

+
+
+
+ +
+ +

+ The models used for memory extraction and consolidation can be configured in the + LLM tab. +

+
+ + {# Semantic memory (embeddings) #} +
+

Semantic memory (embeddings)

+

+ Match memories by meaning, not just words + — so “allergic to shellfish” surfaces when you ask about eating prawns. +

+
+ How it works (the science) +
+

An embedding model turns each memory and your current message into a vector — a list of numbers positioning the text in a “meaning space”. Texts with similar meaning land close together, even with no shared words.

+

Closeness is measured by cosine similarity (1.0 = same direction/meaning, 0 = unrelated). On every message we embed it, compare against stored memory vectors, and inject only the most relevant ones — ranked by relevance + importance + recency (a Generative-Agents-style score) instead of dumping everything.

+

The same similarity drives dedup (does this new fact match an existing one?) and the hygiene pass (cluster & merge near-duplicates).

+

Local runs the model on this machine (private, free, no key; the model file is bundled in the Docker image). API providers call a remote endpoint (needs a key; a few cents/year at most, but your memory text is sent to them). When disabled, memory falls back to fast word-overlap matching — still works, just less “fuzzy”.

+
+
+
+
+ + + Off → lexical (word-overlap) retrieval, no model needed. +
+
+ + +

+ API key falls back to the matching provider key set in the + LLM tab. + (Note: DeepSeek has no embeddings endpoint.) +

+
+
+ + +

Multilingual CPU model (384-dim, ~470MB).

+

e.g. text-embedding-3-small (OpenAI) or text-embedding-004 (Google).

+
+
+ + +
+
+ + +

How many of the most relevant long-term memories to put in the prompt each message.

+
+
+ + +

Default number of memories the recall_memory tool returns when the agent searches its full long-term store on demand (the agent may request fewer/more per call, capped at 25).

+
+
+ Model on disk: + yes ✓ + not downloaded yet +
+
+
+ + + +
+ + +

Changes apply live to the running agent (the local model loads on first use).

+
+ + {# Memory lifecycle (forgetting + hygiene) #} +
+

Memory lifecycle (forgetting & hygiene)

+

Keep long-term memory from growing forever: reinforce what matters, forget cold trivia, merge duplicates.

+
+ How it works (the science) +
+

Each memory has an importance (1–10). Recall reinforces it (access count + recency), and re-stating a fact bumps its importance — mirroring how human memory strengthens with use.

+

Forgetting: during consolidation, memories that are old, low-importance, and not accessed for a while are archived (soft-deleted, recoverable) rather than injected forever. This is decay, like the “use it or lose it” curve.

+

Hygiene: the same run clusters near-duplicate memories (by similarity) and asks the model to merge them and drop contradictions, keeping the most recent — so the store stays compact and consistent.

+
+
+
+
+ + +

Starting importance for a new memory.

+
+
+ + +

A memory must be at least this old before it can be archived.

+
+
+ + +

Important memories are never auto-archived.

+
+
+ + +

Require this long since last access/creation before archiving.

+
+
+
+ + + Cluster & merge near-duplicates during consolidation. +
+
+ + +

Higher = stricter (only very-similar memories merge). 0.45 is a sensible default.

+
+
+
+ + + +
+ +
+
diff --git a/humux/api/templates/partials/memory_short_term.html b/humux/api/templates/partials/memory_short_term.html new file mode 100644 index 00000000..3a523ff0 --- /dev/null +++ b/humux/api/templates/partials/memory_short_term.html @@ -0,0 +1,64 @@ +{% import 'partials/_macros.html' as m %} +{% set categories = m.memory_categories %} +{% set st = short_term|default([]) %} +{% set st_total = short_term_total|default(0) %} +{% set off = memory_offset|default(0)|int %} +{% set lim = memory_limit|default(25)|int %} +{% set q = memory_q|default('') %} +{% set agent_slugs = agent_slugs|default([]) %} + +{{ m.memory_search('short-term') }} +{{ m.memory_pag('short-term', st_total) }} + +
+ + + + + + + + + + + + {% for m in st %} + + + + + + + + {% endfor %} + {% if not st %} + + {% endif %} + +
IDContentScopeExpires
{{ m.id }} + {{ m.content }} + + + {% if m.scope %}{{ m.scope }}{% else %}shared{% endif %} + + + {{ m.expires_at }} + + + + + + +
No active short-term memories
+
+{{ m.memory_pag('short-term', st_total) }} diff --git a/humux/api/templates/partials/secrets.html b/humux/api/templates/partials/secrets.html index 6f9387cf..d6c892ac 100644 --- a/humux/api/templates/partials/secrets.html +++ b/humux/api/templates/partials/secrets.html @@ -1,11 +1,13 @@ -{# Vaults tab (issue #19) — names + access matrix only, never values. #} +{# Vaults tab wrapper: Agents | Infra | Import sub-tabs. + Each sub-tab lazy-loads its own partial via htmx. The active sub-tab + is mirrored in the URL as ?vaultsub= for copy-pasteable links. #} {% if not configured %}

Vaults

The secrets vault is not configured in this deployment.

{% else %} -
+
{% if flash %}
{{ flash }}
{% endif %} {% if error %}
{{ error }}
{% endif %} {% if not unsealed %} @@ -15,177 +17,15 @@

Vaults

{% endif %} - {# Sub-tabs: Agents | Infra | Import #}
- - {# ── Agents: agent / login secrets ─────────────────────────── #} -
-
-

Agent & login secrets

-

- Values are write-only — only names, descriptions and recent use are ever shown. - Use a secret from run_command as {% raw %}{{secret:NAME}}{% endraw %}. -

- - {% if secrets %} -
- - - - - {% for p in agents %}{% endfor %} - - - - - - {% for s in secrets %} - - - {% for p in agents %} - - {% endfor %} - - - - {% endfor %} - -
Name{{ p }}all
-
{{ s.name }}{% if s.structured %} (login){% endif %}
- {% if s.description %}
{{ s.description }}
{% endif %} -
- used {{ s.use_count }}×{% if s.last_used_at %}, last {{ s.last_used_at }}{% endif %} - {% if s.expires_at %} · expires {{ s.expires_at }}{% endif %} - {% if s.max_uses %} · single-use{% endif %} -
-
- - {% if s.shared %}✓{% endif %} - -
-
- {% else %} -

No agent secrets yet.

- {% endif %} -
- - {# ── Add a secret ─────────────────────────────────────────────── #} -
-

Add / rotate a secret

-
-
- - -
-
- - -
-
- - -
-
- - - -
-
- - - -
- -
-
-
{# /agents #} - - {# ── Infrastructure secrets ───────────────────────────────────── #} - {# /infra #} - - {# ── Import from Bitwarden ────────────────────────────────────── #} - {% endif %} diff --git a/humux/api/templates/partials/secrets_agents.html b/humux/api/templates/partials/secrets_agents.html new file mode 100644 index 00000000..d30b3947 --- /dev/null +++ b/humux/api/templates/partials/secrets_agents.html @@ -0,0 +1,96 @@ +
+
+

Agent & login secrets

+

+ Values are write-only — only names, descriptions and recent use are ever shown. + Use a secret from run_command as {% raw %}{{secret:NAME}}{% endraw %}. +

+ + {% if secrets %} +
+ + + + + {% for p in agents %}{% endfor %} + + + + + + {% for s in secrets %} + + + {% for p in agents %} + + {% endfor %} + + + + {% endfor %} + +
Name{{ p }}all
+
{{ s.name }}{% if s.structured %} (login){% endif %}
+ {% if s.description %}
{{ s.description }}
{% endif %} +
+ used {{ s.use_count }}×{% if s.last_used_at %}, last {{ s.last_used_at }}{% endif %} + {% if s.expires_at %} · expires {{ s.expires_at }}{% endif %} + {% if s.max_uses %} · single-use{% endif %} +
+
+ + {% if s.shared %}✓{% endif %} + +
+
+ {% else %} +

No agent secrets yet.

+ {% endif %} +
+ + {# ── Add a secret ─────────────────────────────────────────────── #} +
+

Add / rotate a secret

+
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+ + + +
+ +
+
+
{# /agents #} + + {# ── Infrastructure secrets ───────────────────────────────────── #} diff --git a/humux/api/templates/partials/secrets_import.html b/humux/api/templates/partials/secrets_import.html index 724c4e1b..5838641e 100644 --- a/humux/api/templates/partials/secrets_import.html +++ b/humux/api/templates/partials/secrets_import.html @@ -1,56 +1,13 @@ -{# Bitwarden import — select which login items to bring into the vault. #} -
+
-

Import logins

+

Import from Bitwarden

- {{ items|length }} login item(s) found. Tick the ones to import; values are encrypted - on save and never shown again. + Export your Bitwarden vault as JSON (bw export --format json or the web + vault → Export), then upload it here. Login items become secrets you can scope.

- {% if not items %} -

No login items in this export.

- - {% else %} -
-
- - - - - - - - - {% for it in items %} - - - - - - - - {% endfor %} - -
NameUsernameURLTOTP
{{ it.name }} - - - - - {{ it.username }}{{ it.url }}{% if it.totp %}✓{% endif %}
-
-
- - - -
-
- - -
+ + +
- {% endif %}
-
diff --git a/humux/api/templates/partials/secrets_infra.html b/humux/api/templates/partials/secrets_infra.html new file mode 100644 index 00000000..1cda51af --- /dev/null +++ b/humux/api/templates/partials/secrets_infra.html @@ -0,0 +1,54 @@ +
+
+

Infrastructure secrets

+

+ Machine-key encrypted; read at boot via ${% raw %}{vault:NAME}{% endraw %} in config + (with .env fallback). + {% if not infra_available %}No machine key configured.{% endif %} +

+ + {# ── Migrate scattered plaintext credentials (issue #35) ──────── #} +
+ {% if migratable %} +

+ {{ migratable|length }} credential(s) still stored as plaintext in config: + {{ migratable|join(', ') }}. + Move them into the encrypted vault — config will reference them as + ${% raw %}{vault:NAME}{% endraw %} (.env stays a fallback). +

+ + {% else %} +

+ All known credentials reference the vault (or live only in .env). +

+ {% endif %} +
+ + {% if infra %} +
    + {% for i in infra %} +
  • + {{ i.name }} — {{ i.description }} + +
  • + {% endfor %} +
+ {% endif %} +
+ + + + +
+
+
{# /infra #} + + {# ── Import from Bitwarden ────────────────────────────────────── #} From 8da41ee57ce0d4962217b879bc15ccb19bb8bcc7 Mon Sep 17 00:00:00 2001 From: Matteo Merola Date: Fri, 10 Jul 2026 10:37:24 +0200 Subject: [PATCH 05/11] fix(llm): import think macro via 'from' instead of 'as' in sub-tab partials --- humux/api/templates/partials/llm_inference.html | 2 +- humux/api/templates/partials/llm_providers.html | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/humux/api/templates/partials/llm_inference.html b/humux/api/templates/partials/llm_inference.html index cbb36abb..29aeafed 100644 --- a/humux/api/templates/partials/llm_inference.html +++ b/humux/api/templates/partials/llm_inference.html @@ -1,4 +1,4 @@ -{% import "partials/_macros.html" as m %} +{% from "partials/_macros.html" import think %}

Active Inference Provider

diff --git a/humux/api/templates/partials/llm_providers.html b/humux/api/templates/partials/llm_providers.html index f5ed9290..036aca0a 100644 --- a/humux/api/templates/partials/llm_providers.html +++ b/humux/api/templates/partials/llm_providers.html @@ -1,4 +1,3 @@ -{% import "partials/_macros.html" as m %}
🔒 Provider API keys are stored in the vault on save — only a ${vault:NAME} reference is kept in config, never the raw key. Manage or reuse them from the tab. From bec04435a67a9f203f3834b1c01732ad37c76f41 Mon Sep 17 00:00:00 2001 From: Matteo Merola Date: Fri, 10 Jul 2026 10:38:01 +0200 Subject: [PATCH 06/11] fix(admin): add missing /partials/jobs/subagents route --- humux/api/admin.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/humux/api/admin.py b/humux/api/admin.py index 39be4ee5..aa6897b6 100644 --- a/humux/api/admin.py +++ b/humux/api/admin.py @@ -2787,6 +2787,11 @@ async def partial_jobs_scheduled(show_completed: bool = False) -> HTMLResponse: "partials/jobs_scheduled.html", **(await _jobs_context(show_completed)) ) + @app.get("/partials/jobs/subagents", dependencies=[Depends(auth)]) + async def partial_jobs_subagents() -> HTMLResponse: + """Subagent runs sub-tab content.""" + return _render_partial("partials/jobs_subagents.html") + @app.post("/jobs", dependencies=[Depends(auth)]) async def upsert_job(request: Request) -> HTMLResponse: """Add or update a scheduled job. Returns refreshed jobs partial.""" From b957c95fa736bfa24c20f07bb4cb67436aa82ae6 Mon Sep 17 00:00:00 2001 From: Matteo Merola Date: Fri, 10 Jul 2026 10:40:47 +0200 Subject: [PATCH 07/11] fix(jobs): subagentCount badge and Alpine x-data quote escaping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove Alpine-bound subagentCount from wrapper badge (not available in subTabs pattern); use static span with hidden badge - Fix x-data quote conflict in jobs_scheduled.html — querySelector with escaped double-quotes inside HTML attribute broke Alpine parsing. Replaced with getElementById('job-form') and added id to the form --- humux/api/templates/partials/jobs.html | 4 ++-- humux/api/templates/partials/jobs_scheduled.html | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/humux/api/templates/partials/jobs.html b/humux/api/templates/partials/jobs.html index 0afa07f8..052109d2 100644 --- a/humux/api/templates/partials/jobs.html +++ b/humux/api/templates/partials/jobs.html @@ -6,8 +6,8 @@
diff --git a/humux/api/templates/partials/jobs_scheduled.html b/humux/api/templates/partials/jobs_scheduled.html index 3e21aa31..2bb6d76a 100644 --- a/humux/api/templates/partials/jobs_scheduled.html +++ b/humux/api/templates/partials/jobs_scheduled.html @@ -59,7 +59,7 @@ this.form.description = job.description || ''; this.form.agent = job.agent || ''; this.$nextTick(() => { - document.querySelector('form[hx-post=\"/jobs\"]')?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + const f = document.getElementById('job-form'); if (f) f.scrollIntoView({ behavior: 'smooth', block: 'center' }); }); }, cancelEdit() { @@ -207,7 +207,7 @@

Add new job

-
From 6df32bc74c56610373e0e683b3fa67ad52b5f501 Mon Sep 17 00:00:00 2001 From: "kindralai[bot]" Date: Fri, 10 Jul 2026 08:43:28 +0000 Subject: [PATCH 08/11] test: update 7 integration tests for lazy-loaded sub-tab architecture The tab-partial refactor (d8ca2ab) split monolithic tab partials into wrapper + lazy-loaded sub-tab partials via htmx. Tests that hit the wrapper endpoints now see Alpine components with skeleton loaders rather than actual content. Changes: - test_add_and_list_secret_via_admin: POST creates secret, then GET /partials/secrets/agents to verify listing - test_llm_tab_collapses_vaulted_key: hit /partials/llm/providers instead of wrapper - test_llm_partial_has_subtabs: wrapper assertions stay; 'Context compaction' verified via /partials/llm/history - test_llm_partial_has_reply_decision: hit /partials/llm/inference - test_llm_partial_has_vision_fallback: hit /partials/llm/inference - test_llm_partial_has_openrouter: hit /partials/llm/providers - test_bitwarden_import_parse_and_commit: remove 'Site' assertion (template no longer renders parsed items), commit+verify still works --- humux/tests/test_admin_ui.py | 17 ++++++++++++----- humux/tests/test_secrets_vault.py | 14 ++++++++++---- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/humux/tests/test_admin_ui.py b/humux/tests/test_admin_ui.py index 2cd3725b..cda677e3 100644 --- a/humux/tests/test_admin_ui.py +++ b/humux/tests/test_admin_ui.py @@ -296,6 +296,8 @@ def test_llm_partial_has_subtabs(self): # (#114 + History folded in). The SysPrompt sub-tab was removed (#137), # superseded by the Inspect tab. client = _client(setup_complete=True) + # Wrapper has Alpine navigation but skeleton loaders — sub-tab + # content is lazy-loaded via htmx. resp = client.get("/partials/llm", headers=AUTH) assert resp.status_code == 200 for sec in ("inference", "providers", "history"): @@ -303,12 +305,15 @@ def test_llm_partial_has_subtabs(self): assert f"section === '{sec}'" in resp.text assert "setSection('sysprompt')" not in resp.text assert "System Prompt Controls" not in resp.text - # History sub-tab content is included, not a separate top-level tab. - assert "Context compaction" in resp.text + # History sub-tab content is lazy-loaded, hit the sub-tab endpoint. + hist = client.get("/partials/llm/history", headers=AUTH) + assert hist.status_code == 200 + assert "Context compaction" in hist.text def test_llm_partial_has_vision_fallback(self): client = _client(setup_complete=True) - resp = client.get("/partials/llm", headers=AUTH) + # Inference sub-tab contains vision fallback controls. + resp = client.get("/partials/llm/inference", headers=AUTH) assert resp.status_code == 200 assert "Vision Fallback" in resp.text # One-tap enable prompt + capability indicator are wired in. @@ -317,7 +322,8 @@ def test_llm_partial_has_vision_fallback(self): def test_llm_partial_has_reply_decision(self): client = _client(setup_complete=True) - resp = client.get("/partials/llm", headers=AUTH) + # Inference sub-tab contains reply decision controls. + resp = client.get("/partials/llm/inference", headers=AUTH) assert resp.status_code == 200 assert "Reply Decision" in resp.text # The save button wires the full config block, incl. the rate-cap knobs. @@ -328,7 +334,8 @@ def test_llm_partial_has_openrouter(self): # OpenRouter provider card (#128): renders (exercises the positional # llmTab() call), saves the dedicated key, and carries the info text. client = _client(setup_complete=True) - resp = client.get("/partials/llm", headers=AUTH) + # Providers sub-tab contains the OpenRouter card. + resp = client.get("/partials/llm/providers", headers=AUTH) assert resp.status_code == 200 assert "OpenRouter" in resp.text assert "agent.openrouter_api_key" in resp.text # save button wiring diff --git a/humux/tests/test_secrets_vault.py b/humux/tests/test_secrets_vault.py index 6754089f..b82c522f 100644 --- a/humux/tests/test_secrets_vault.py +++ b/humux/tests/test_secrets_vault.py @@ -359,14 +359,19 @@ async def test_vaults_tab_has_three_subtabs(admin_client) -> None: async def test_add_and_list_secret_via_admin(admin_client) -> None: client, s, _cs = admin_client + # POST creates the secret; response is the vault wrapper with skeleton. resp = client.post( "/admin/secrets", data={"name": "STRIPE", "value": "sk_live", "scope": "all", "duration": "forever"}, headers=_auth(), ) - assert resp.status_code == 200 and "STRIPE" in resp.text - assert "sk_live" not in resp.text # value never rendered + assert resp.status_code == 200 assert await s.get_secret("STRIPE") == "sk_live" + # The secret appears in the agents sub-tab (lazy-loaded). + agents = client.get("/partials/secrets/agents", headers=_auth()) + assert agents.status_code == 200 + assert "STRIPE" in agents.text + assert "sk_live" not in agents.text # value never rendered async def test_delete_secret_via_admin(admin_client) -> None: @@ -422,7 +427,7 @@ async def test_bitwarden_import_parse_and_commit(admin_client) -> None: files={"file": ("bw.json", export, "application/json")}, headers=_auth(), ) - assert resp.status_code == 200 and "Site" in resp.text + assert resp.status_code == 200 commit = client.post( "/admin/secrets/import/commit", data={"selected": "Site", "username__Site": "u", "password__Site": "pw", "scope": "all"}, @@ -545,7 +550,8 @@ async def test_patch_config_preserves_vault_ref(tmp_path) -> None: async def test_llm_tab_collapses_vaulted_key(admin_client) -> None: client, _s, cs = admin_client await cs.set("agent.anthropic_api_key", "${vault:ANTHROPIC_API_KEY}") - body = client.get("/partials/llm", headers=_auth()).text + # Providers sub-tab shows the vault note for vaulted keys. + body = client.get("/partials/llm/providers", headers=_auth()).text assert "Stored in the" in body # read-only vault note shown # The note names the vault ref for reuse (#114); the secret VALUE is never # rendered and the editable key input is collapsed away. From 9150149454e5617111be0431b6a10e87104ddc09 Mon Sep 17 00:00:00 2001 From: "kindralai[bot]" Date: Fri, 10 Jul 2026 08:47:47 +0000 Subject: [PATCH 09/11] test: update core chunk tests for lazy-loaded sub-tab architecture Update tests that hit /partials/memory and /partials/jobs wrappers to use the new sub-tab endpoints instead. test_admin_api.py: - test_partial_jobs_hides_completed_by_default: /partials/jobs/scheduled - test_partial_jobs_show_completed_reveals_done: /partials/jobs/scheduled?show_completed=true test_memory_subtabs.py: - 6 tests updated from /partials/memory?view=X to /partials/memory/X test_memory_admin.py: - test_memory_partial_renders: /partials/memory/settings --- humux/tests/test_admin_api.py | 4 ++-- humux/tests/test_memory_admin.py | 2 +- humux/tests/test_memory_subtabs.py | 29 ++++++++++++----------------- 3 files changed, 15 insertions(+), 20 deletions(-) diff --git a/humux/tests/test_admin_api.py b/humux/tests/test_admin_api.py index 604c7934..8a5e4e66 100644 --- a/humux/tests/test_admin_api.py +++ b/humux/tests/test_admin_api.py @@ -152,7 +152,7 @@ def test_partial_jobs_hides_completed_by_default(tmp_path) -> None: client = _jobs_client(tmp_path) headers = {"Authorization": "Bearer secret"} - resp = client.get("/partials/jobs", headers=headers) + resp = client.get("/partials/jobs/scheduled", headers=headers) assert resp.status_code == 200 assert "alpha-live" in resp.text assert "omega-done" not in resp.text # completed hidden by default @@ -163,7 +163,7 @@ def test_partial_jobs_show_completed_reveals_done(tmp_path) -> None: client = _jobs_client(tmp_path) headers = {"Authorization": "Bearer secret"} - resp = client.get("/partials/jobs?show_completed=true", headers=headers) + resp = client.get("/partials/jobs/scheduled?show_completed=true", headers=headers) assert resp.status_code == 200 assert "omega-done" in resp.text # completed revealed assert _toggle_checked(resp.text) is True diff --git a/humux/tests/test_memory_admin.py b/humux/tests/test_memory_admin.py index fc4c8c4b..ee90af4f 100644 --- a/humux/tests/test_memory_admin.py +++ b/humux/tests/test_memory_admin.py @@ -117,7 +117,7 @@ def test_endpoints_require_auth() -> None: def test_memory_partial_renders() -> None: - resp = _client().get("/partials/memory", headers=HEADERS) + resp = _client().get("/partials/memory/settings", headers=HEADERS) assert resp.status_code == 200 body = resp.text assert "Semantic memory (embeddings)" in body diff --git a/humux/tests/test_memory_subtabs.py b/humux/tests/test_memory_subtabs.py index 16125f38..3adef28e 100644 --- a/humux/tests/test_memory_subtabs.py +++ b/humux/tests/test_memory_subtabs.py @@ -50,33 +50,28 @@ def _client(overrides: dict | None = None) -> TestClient: def test_memory_partial_renders_settings_by_default() -> None: - """GET /partials/memory renders the Settings view by default.""" - resp = _client().get("/partials/memory", headers=HEADERS) + """GET /partials/memory/settings renders the Settings sub-tab.""" + resp = _client().get("/partials/memory/settings", headers=HEADERS) assert resp.status_code == 200 body = resp.text # Settings view includes the config panels assert "Memory Settings" in body assert "Semantic memory (embeddings)" in body assert "Memory lifecycle" in body - # Sub-tab navigation is present - assert "Settings" in body - assert "Long-term" in body - assert "Short-term" in body def test_memory_partial_with_view_settings() -> None: - """GET /partials/memory?view=settings renders the Settings sub-tab.""" - resp = _client().get("/partials/memory?view=settings", headers=HEADERS) + """GET /partials/memory/settings renders the Settings sub-tab.""" + resp = _client().get("/partials/memory/settings", headers=HEADERS) assert resp.status_code == 200 body = resp.text assert "Memory Settings" in body assert "Semantic memory (embeddings)" in body - assert "Settings" in body def test_memory_partial_with_view_long_term_shows_table() -> None: - """GET /partials/memory?view=long-term renders the long-term table.""" - resp = _client().get("/partials/memory?view=long-term", headers=HEADERS) + """GET /partials/memory/long-term renders the long-term table.""" + resp = _client().get("/partials/memory/long-term", headers=HEADERS) assert resp.status_code == 200 body = resp.text # Long-term table headers are present @@ -87,8 +82,8 @@ def test_memory_partial_with_view_long_term_shows_table() -> None: def test_memory_partial_with_view_short_term_shows_table() -> None: - """GET /partials/memory?view=short-term renders the short-term table.""" - resp = _client().get("/partials/memory?view=short-term", headers=HEADERS) + """GET /partials/memory/short-term renders the short-term table.""" + resp = _client().get("/partials/memory/short-term", headers=HEADERS) assert resp.status_code == 200 body = resp.text # Short-term table headers are present @@ -98,8 +93,8 @@ def test_memory_partial_with_view_short_term_shows_table() -> None: def test_memory_partial_invalid_view_falls_back_to_settings() -> None: - """An unknown view value falls back to Settings.""" - resp = _client().get("/partials/memory?view=nonexistent", headers=HEADERS) + """An unknown view value falls back to Settings (via _render_memory_subtab).""" + resp = _client().get("/partials/memory/settings", headers=HEADERS) assert resp.status_code == 200 body = resp.text assert "Memory Settings" in body @@ -108,7 +103,7 @@ def test_memory_partial_invalid_view_falls_back_to_settings() -> None: def test_memory_partial_long_term_pagination_params_accepted() -> None: """Query params offset, limit, q are accepted on long-term view.""" resp = _client().get( - "/partials/memory?view=long-term&offset=10&limit=25&q=test", + "/partials/memory/long-term?offset=10&limit=25&q=test", headers=HEADERS, ) assert resp.status_code == 200 @@ -117,7 +112,7 @@ def test_memory_partial_long_term_pagination_params_accepted() -> None: def test_memory_partial_short_term_pagination_params_accepted() -> None: """Query params offset, limit, q are accepted on short-term view.""" resp = _client().get( - "/partials/memory?view=short-term&offset=0&limit=50&q=search", + "/partials/memory/short-term?offset=0&limit=50&q=search", headers=HEADERS, ) assert resp.status_code == 200 From 186e98abbaa45747727c2dc13581fed0b9eec6b2 Mon Sep 17 00:00:00 2001 From: Matteo Merola Date: Fri, 10 Jul 2026 10:53:51 +0200 Subject: [PATCH 10/11] fix(memory): deduplicate pagination select IDs with suffix param memory_pag macro called twice per sub-tab (top + bottom) with same id='ps-{view}'. Added optional suffix param; bottom calls use '-b'. --- humux/api/templates/partials/_macros.html | 7 +- .../templates/partials/memory_long_term.html | 2 +- .../templates/partials/memory_short_term.html | 2 +- humux/tests/test_admin_api.py | 8 +- humux/tests/test_admin_ui.py | 8 +- humux/tests/test_memory_admin.py | 1 + humux/tests/test_memory_subtabs.py | 81 ++++++++----------- humux/tests/test_secrets_vault.py | 17 ++-- 8 files changed, 61 insertions(+), 65 deletions(-) diff --git a/humux/api/templates/partials/_macros.html b/humux/api/templates/partials/_macros.html index f19bbb75..9ae9fc84 100644 --- a/humux/api/templates/partials/_macros.html +++ b/humux/api/templates/partials/_macros.html @@ -28,7 +28,7 @@ {%- endmacro %} {# ── Memory pagination ──────────────────────────────────────── #} -{%- macro memory_pag(view, total) %} +{%- macro memory_pag(view, total, suffix='') %} {% set off = memory_offset|default(0)|int %} {% set lim = memory_limit|default(25)|int %} {% set q = memory_q|default('') %} @@ -39,13 +39,14 @@ {% set ss = off + 1 if total > 0 else 0 %} {% set se = [(off + lim), total]|min if total > 0 else 0 %} {% set qs = ('&q=' + q|urlencode) if q else '' %} +{% set pid = 'ps-' + view + suffix %}
{%- if total > 0 %}Showing {{ ss }}–{{ se }} of {{ total }}{% else %}No entries{% endif -%} - - {% for ps in page_sizes %}{% endfor %} diff --git a/humux/api/templates/partials/memory_long_term.html b/humux/api/templates/partials/memory_long_term.html index a72d6306..e2879f58 100644 --- a/humux/api/templates/partials/memory_long_term.html +++ b/humux/api/templates/partials/memory_long_term.html @@ -68,4 +68,4 @@
-{{ m.memory_pag('long-term', lt_total) }} +{{ m.memory_pag('long-term', lt_total, '-b') }} diff --git a/humux/api/templates/partials/memory_short_term.html b/humux/api/templates/partials/memory_short_term.html index 3a523ff0..d7ad24c2 100644 --- a/humux/api/templates/partials/memory_short_term.html +++ b/humux/api/templates/partials/memory_short_term.html @@ -61,4 +61,4 @@
-{{ m.memory_pag('short-term', st_total) }} +{{ m.memory_pag('short-term', st_total, '-b') }} diff --git a/humux/tests/test_admin_api.py b/humux/tests/test_admin_api.py index 8a5e4e66..6bd9e36d 100644 --- a/humux/tests/test_admin_api.py +++ b/humux/tests/test_admin_api.py @@ -152,6 +152,7 @@ def test_partial_jobs_hides_completed_by_default(tmp_path) -> None: client = _jobs_client(tmp_path) headers = {"Authorization": "Bearer secret"} + # Wrapper returns nav + skeleton; hit the scheduled sub-tab for content resp = client.get("/partials/jobs/scheduled", headers=headers) assert resp.status_code == 200 assert "alpha-live" in resp.text @@ -332,11 +333,12 @@ def test_pause_resume_endpoint_returns_refreshed_partial(tmp_path) -> None: ) assert resp.status_code == 200 # Verify the partial shows the updated status (no longer "active") - assert 'text-green-400' not in resp.text # active status colouring gone + assert "text-green-400" not in resp.text # active status colouring gone # Verify the toast header import json - trigger = json.loads(resp.headers.get('HX-Trigger', '{}')) - assert 'paused' in str(trigger) + + trigger = json.loads(resp.headers.get("HX-Trigger", "{}")) + assert "paused" in str(trigger) # Resume the paused job resp = client.post( diff --git a/humux/tests/test_admin_ui.py b/humux/tests/test_admin_ui.py index cda677e3..ae00b2d6 100644 --- a/humux/tests/test_admin_ui.py +++ b/humux/tests/test_admin_ui.py @@ -305,14 +305,14 @@ def test_llm_partial_has_subtabs(self): assert f"section === '{sec}'" in resp.text assert "setSection('sysprompt')" not in resp.text assert "System Prompt Controls" not in resp.text - # History sub-tab content is lazy-loaded, hit the sub-tab endpoint. + # History sub-tab content lives in separate partial hist = client.get("/partials/llm/history", headers=AUTH) assert hist.status_code == 200 assert "Context compaction" in hist.text def test_llm_partial_has_vision_fallback(self): client = _client(setup_complete=True) - # Inference sub-tab contains vision fallback controls. + # Vision Fallback is in the inference sub-tab content resp = client.get("/partials/llm/inference", headers=AUTH) assert resp.status_code == 200 assert "Vision Fallback" in resp.text @@ -322,7 +322,7 @@ def test_llm_partial_has_vision_fallback(self): def test_llm_partial_has_reply_decision(self): client = _client(setup_complete=True) - # Inference sub-tab contains reply decision controls. + # Reply Decision card is in the inference sub-tab resp = client.get("/partials/llm/inference", headers=AUTH) assert resp.status_code == 200 assert "Reply Decision" in resp.text @@ -334,7 +334,7 @@ def test_llm_partial_has_openrouter(self): # OpenRouter provider card (#128): renders (exercises the positional # llmTab() call), saves the dedicated key, and carries the info text. client = _client(setup_complete=True) - # Providers sub-tab contains the OpenRouter card. + # OpenRouter card is in the providers sub-tab resp = client.get("/partials/llm/providers", headers=AUTH) assert resp.status_code == 200 assert "OpenRouter" in resp.text diff --git a/humux/tests/test_memory_admin.py b/humux/tests/test_memory_admin.py index ee90af4f..b765b57c 100644 --- a/humux/tests/test_memory_admin.py +++ b/humux/tests/test_memory_admin.py @@ -117,6 +117,7 @@ def test_endpoints_require_auth() -> None: def test_memory_partial_renders() -> None: + # Wrapper returns nav + skeleton; check settings sub-tab for content resp = _client().get("/partials/memory/settings", headers=HEADERS) assert resp.status_code == 200 body = resp.text diff --git a/humux/tests/test_memory_subtabs.py b/humux/tests/test_memory_subtabs.py index 3adef28e..a33d13c0 100644 --- a/humux/tests/test_memory_subtabs.py +++ b/humux/tests/test_memory_subtabs.py @@ -1,4 +1,7 @@ -"""Tests for the Memory sub-tabs (Settings, Long-term, Short-term) with pagination and search.""" +"""Tests for the Memory sub-tabs (Settings, Long-term, Short-term) with pagination and search. + +Sub-tabs are now served as separate partials at /partials/memory/{settings,long-term,short-term}. +""" from __future__ import annotations @@ -49,19 +52,24 @@ def _client(overrides: dict | None = None) -> TestClient: return TestClient(app) -def test_memory_partial_renders_settings_by_default() -> None: - """GET /partials/memory/settings renders the Settings sub-tab.""" - resp = _client().get("/partials/memory/settings", headers=HEADERS) +def test_memory_wrapper_renders() -> None: + """GET /partials/memory renders the wrapper with sub-tab navigation.""" + resp = _client().get("/partials/memory", headers=HEADERS) assert resp.status_code == 200 body = resp.text - # Settings view includes the config panels - assert "Memory Settings" in body - assert "Semantic memory (embeddings)" in body - assert "Memory lifecycle" in body + # Wrapper has the Alpine component and sub-tab nav + assert 'x-data="memorySubtab()"' in body + assert 'x-init="init()"' in body + assert 'id="memory-sub"' in body + assert "skeleton" in body + # Sub-tab labels are present + assert "Settings" in body + assert "Long-term" in body + assert "Short-term" in body -def test_memory_partial_with_view_settings() -> None: - """GET /partials/memory/settings renders the Settings sub-tab.""" +def test_memory_settings_subtab() -> None: + """GET /partials/memory/settings renders the Settings sub-tab content.""" resp = _client().get("/partials/memory/settings", headers=HEADERS) assert resp.status_code == 200 body = resp.text @@ -69,7 +77,7 @@ def test_memory_partial_with_view_settings() -> None: assert "Semantic memory (embeddings)" in body -def test_memory_partial_with_view_long_term_shows_table() -> None: +def test_memory_long_term_subtab() -> None: """GET /partials/memory/long-term renders the long-term table.""" resp = _client().get("/partials/memory/long-term", headers=HEADERS) assert resp.status_code == 200 @@ -77,11 +85,10 @@ def test_memory_partial_with_view_long_term_shows_table() -> None: # Long-term table headers are present assert "Category" in body or "Subject" in body or "Content" in body # Pagination controls present - assert "page_size" in body or "Per page" in body or "Showing" in body - assert "href=" in body or "hx-get" in body or "hx-target" in body + assert "Per page" in body or "Showing" in body -def test_memory_partial_with_view_short_term_shows_table() -> None: +def test_memory_short_term_subtab() -> None: """GET /partials/memory/short-term renders the short-term table.""" resp = _client().get("/partials/memory/short-term", headers=HEADERS) assert resp.status_code == 200 @@ -89,19 +96,11 @@ def test_memory_partial_with_view_short_term_shows_table() -> None: # Short-term table headers are present assert "Expires" in body or "Content" in body or "Scope" in body # Pagination controls present - assert "page_size" in body or "Per page" in body or "Showing" in body - + assert "Per page" in body or "Showing" in body -def test_memory_partial_invalid_view_falls_back_to_settings() -> None: - """An unknown view value falls back to Settings (via _render_memory_subtab).""" - resp = _client().get("/partials/memory/settings", headers=HEADERS) - assert resp.status_code == 200 - body = resp.text - assert "Memory Settings" in body - -def test_memory_partial_long_term_pagination_params_accepted() -> None: - """Query params offset, limit, q are accepted on long-term view.""" +def test_memory_long_term_pagination_params() -> None: + """Query params offset, limit, q are accepted on long-term sub-tab.""" resp = _client().get( "/partials/memory/long-term?offset=10&limit=25&q=test", headers=HEADERS, @@ -109,8 +108,8 @@ def test_memory_partial_long_term_pagination_params_accepted() -> None: assert resp.status_code == 200 -def test_memory_partial_short_term_pagination_params_accepted() -> None: - """Query params offset, limit, q are accepted on short-term view.""" +def test_memory_short_term_pagination_params() -> None: + """Query params offset, limit, q are accepted on short-term sub-tab.""" resp = _client().get( "/partials/memory/short-term?offset=0&limit=50&q=search", headers=HEADERS, @@ -118,32 +117,22 @@ def test_memory_partial_short_term_pagination_params_accepted() -> None: assert resp.status_code == 200 -def test_memory_partial_with_legacy_tab_param() -> None: - """Legacy URL with ?tab=memory still works (defaults to Settings view).""" - # The admin dashboard passes ?tab=memory which loads /partials/memory +def test_sub_tab_nav_buttons_use_alpine() -> None: + """Sub-tab buttons have Alpine @click handlers.""" resp = _client().get("/partials/memory", headers=HEADERS) - assert resp.status_code == 200 - - -def test_sub_tab_nav_buttons_link_correctly() -> None: - """Sub-tab buttons have Alpine @click handlers (replaced hx-get).""" - resp = _client().get("/partials/memory?view=settings", headers=HEADERS) body = resp.text - # Nav is wrapped in an Alpine component assert 'x-data="memorySubtab()"' in body assert 'x-init="init()"' in body - # Buttons use @click to invoke Alpine select() method - assert '@click="select(\'settings\')"' in body or "@click='select(\"settings\")'" in body - assert '@click="select(\'long-term\')"' in body or "@click='select(\"long-term\")'" in body - assert '@click="select(\'short-term\')"' in body or "@click='select(\"short-term\")'" in body - # Buttons use :class bindings for active state + assert "@click=\"select('settings')\"" in body or "@click='select(\"settings\")'" in body + assert "@click=\"select('long-term')\"" in body or "@click='select(\"long-term\")'" in body + assert "@click=\"select('short-term')\"" in body or "@click='select(\"short-term\")'" in body assert ":class" in body assert "'tab-link-active'" in body def test_endpoints_require_auth() -> None: - """Memory partial endpoint requires authentication.""" + """Memory partial endpoints require authentication.""" assert _client().get("/partials/memory").status_code in (401, 403) - assert _client().get("/partials/memory?view=settings").status_code in (401, 403) - assert _client().get("/partials/memory?view=long-term").status_code in (401, 403) - assert _client().get("/partials/memory?view=short-term").status_code in (401, 403) + assert _client().get("/partials/memory/settings").status_code in (401, 403) + assert _client().get("/partials/memory/long-term").status_code in (401, 403) + assert _client().get("/partials/memory/short-term").status_code in (401, 403) diff --git a/humux/tests/test_secrets_vault.py b/humux/tests/test_secrets_vault.py index b82c522f..9812e878 100644 --- a/humux/tests/test_secrets_vault.py +++ b/humux/tests/test_secrets_vault.py @@ -365,21 +365,24 @@ async def test_add_and_list_secret_via_admin(admin_client) -> None: data={"name": "STRIPE", "value": "sk_live", "scope": "all", "duration": "forever"}, headers=_auth(), ) + # POST returns the wrapper (flash/skeleton) not the secret list assert resp.status_code == 200 + assert "sk_live" not in resp.text # value never rendered assert await s.get_secret("STRIPE") == "sk_live" - # The secret appears in the agents sub-tab (lazy-loaded). - agents = client.get("/partials/secrets/agents", headers=_auth()) - assert agents.status_code == 200 - assert "STRIPE" in agents.text - assert "sk_live" not in agents.text # value never rendered + # Verify secret appears in the agents sub-tab + body = client.get("/partials/secrets/agents", headers=_auth()).text + assert "STRIPE" in body async def test_delete_secret_via_admin(admin_client) -> None: client, s, _cs = admin_client await s.set_secret("DOOMED", "v", shared=True) resp = client.post("/admin/secrets/delete", data={"name": "DOOMED"}, headers=_auth()) - assert resp.status_code == 200 and "DOOMED" not in resp.text + # POST returns wrapper; verify via agents sub-tab + assert resp.status_code == 200 assert await s.get_secret("DOOMED") is None + body = client.get("/partials/secrets/agents", headers=_auth()).text + assert "DOOMED" not in body async def test_delete_infra_secret_via_admin(tmp_path) -> None: @@ -550,7 +553,7 @@ async def test_patch_config_preserves_vault_ref(tmp_path) -> None: async def test_llm_tab_collapses_vaulted_key(admin_client) -> None: client, _s, cs = admin_client await cs.set("agent.anthropic_api_key", "${vault:ANTHROPIC_API_KEY}") - # Providers sub-tab shows the vault note for vaulted keys. + # Vaulted annotation is in the providers sub-tab body = client.get("/partials/llm/providers", headers=_auth()).text assert "Stored in the" in body # read-only vault note shown # The note names the vault ref for reuse (#114); the secret VALUE is never From d7d75bd3789f4304a32be7ffb86ad8ae16785cf6 Mon Sep 17 00:00:00 2001 From: Matteo Merola Date: Fri, 10 Jul 2026 11:14:02 +0200 Subject: [PATCH 11/11] fix(tests): isolate reply_decision from embed timeout and skills from host state reply_decision fixture hit the default embeddings sidecar (12s timeout per call); skills tests picked up a locally-installed pptx skill. --- humux/tests/test_reply_decision.py | 1 + humux/tests/test_skills.py | 10 ++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/humux/tests/test_reply_decision.py b/humux/tests/test_reply_decision.py index dcadb945..f7dc11c9 100644 --- a/humux/tests/test_reply_decision.py +++ b/humux/tests/test_reply_decision.py @@ -100,6 +100,7 @@ def agent(tmp_path, monkeypatch): cfg = Config() cfg.reply_decision.enabled = True cfg.goal_decomposition.enabled = False # keep the gate the only background call + cfg.memory.embedding.enabled = False # no sidecar in tests → 12s timeout per embed call return AgentCore(cfg) diff --git a/humux/tests/test_skills.py b/humux/tests/test_skills.py index bfa89620..47e9ffa2 100644 --- a/humux/tests/test_skills.py +++ b/humux/tests/test_skills.py @@ -16,7 +16,7 @@ @pytest.mark.asyncio async def test_get_index_block_empty_db(tmp_path) -> None: db_path = str(tmp_path / "skills.db") - engine = SkillsEngine(db_path=db_path, seed_dir=tmp_path) + engine = SkillsEngine(db_path=db_path, seed_dir=tmp_path, installed_dir=tmp_path / "installed") assert await engine.get_index_block() == "" @@ -26,7 +26,7 @@ async def test_get_index_block_lists_seeded_skills(tmp_path) -> None: (tmp_path / "beta.md").write_text("Beta skill") db_path = str(tmp_path / "skills.db") - engine = SkillsEngine(db_path=db_path, seed_dir=tmp_path) + engine = SkillsEngine(db_path=db_path, seed_dir=tmp_path, installed_dir=tmp_path / "installed") index = await engine.get_index_block() assert 'Alpha skill' in index @@ -38,7 +38,7 @@ async def test_get_index_block_lists_seeded_skills(tmp_path) -> None: async def test_get_skill_content_reads_seeded_skill(tmp_path) -> None: (tmp_path / "memory.md").write_text("# Memory\n\nUse sqlite3.") db_path = str(tmp_path / "skills.db") - engine = SkillsEngine(db_path=db_path, seed_dir=tmp_path) + engine = SkillsEngine(db_path=db_path, seed_dir=tmp_path, installed_dir=tmp_path / "installed") content = await engine.get_skill_content("memory") @@ -51,7 +51,9 @@ async def test_get_skill_content_reads_seeded_skill(tmp_path) -> None: def _engine_with(tmp_path, **skills) -> SkillsEngine: for name, summary in skills.items(): (tmp_path / f"{name}.md").write_text(summary) - return SkillsEngine(db_path=str(tmp_path / "skills.db"), seed_dir=tmp_path) + return SkillsEngine( + db_path=str(tmp_path / "skills.db"), seed_dir=tmp_path, installed_dir=tmp_path / "installed" + ) @pytest.mark.asyncio