Skip to content

feat(dashboard): add profiles management page - #16058

Closed
VinceZcrikl wants to merge 19 commits into
NousResearch:mainfrom
VinceZcrikl:feat/dashboard-profiles-page
Closed

feat(dashboard): add profiles management page#16058
VinceZcrikl wants to merge 19 commits into
NousResearch:mainfrom
VinceZcrikl:feat/dashboard-profiles-page

Conversation

@VinceZcrikl

Copy link
Copy Markdown

Summary

  • new /profiles page in the web dashboard for end-to-end profile management — list, create (blank or cloned), rename, activate, export, import, delete — covering everything the hermes profile * CLI does
  • new /api/profiles* REST endpoints that wrap the existing hermes_cli.profiles functions; backend is a thin adapter, validation and state still live in profiles.py
  • delete passes yes=True since the dashboard performs its own confirm dialog
  • export defaults to \$HERMES_HOME/exports/<name>-<ts>.tar.gz when no output_path is provided; import takes a server-side archive path
  • i18n strings added for en and zh

Scope notes

Endpoints added

  • GET /api/profiles — list (returns {profiles, active})
  • GET /api/profiles/active
  • POST /api/profiles — create (with optional clone_from, clone_all, clone_config)
  • PATCH /api/profiles/{name} — rename
  • DELETE /api/profiles/{name}
  • POST /api/profiles/{name}/activate
  • POST /api/profiles/{name}/export — body: {output_path?}, returns resolved path
  • POST /api/profiles/import — body: {archive_path, name?}

Test plan

  • pytest tests/hermes_cli/test_web_server.py -k profile — 11 new tests, all pass
  • cd web && npm run build — clean
  • manual: visit /profiles after hermes dashboard, verify list/create/rename/activate/export/import/delete

Adds a dedicated /profiles page to the web dashboard for managing isolated
Hermes profiles end-to-end (list, create, clone, rename, activate, export,
import, delete) and the matching `/api/profiles*` REST endpoints that wrap
the existing `hermes_cli.profiles` CRUD functions.

The endpoints are thin adapters: validation/state lives in profiles.py.
Delete passes `yes=True` since the dashboard performs its own confirm
dialog. Export defaults to `$HERMES_HOME/exports/<name>-<ts>.tar.gz` when
no `output_path` is supplied.

This PR intentionally does NOT add a profile *switcher* component (PR
NousResearch#13823 owns that) — the Profiles page is a self-contained CRUD surface
that complements rather than overlaps with NousResearch#13823 / NousResearch#9496.

Test plan:
- pytest tests/hermes_cli/test_web_server.py -k profile
- cd web && npm run build

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/cli CLI entry point, hermes_cli/, setup wizard labels Apr 26, 2026
VinceZcrikl and others added 18 commits April 26, 2026 09:45
Adds an inline edit panel (gear-icon button) on each row of the new
Profiles page so users can change model/provider and edit SOUL.md
without leaving the dashboard.

Backend:
- GET/PUT /api/profiles/{name}/soul — read/write SOUL.md
- GET/PUT /api/profiles/{name}/model — read/write model.default and
  model.provider in the profile's config.yaml
- model PUT normalises legacy `model: "<slug>"` string form into the
  dict form and preserves all other top-level config sections via
  atomic_yaml_write

Frontend: per-row collapsible panel with model fields + SOUL textarea.
Uses plain text inputs for the model slug (the existing
ModelPickerDialog is coupled to a running gateway, so it can't drive a
stopped profile's config without a wider refactor).

Test plan:
- pytest tests/hermes_cli/test_web_server.py -k profile  (15 tests pass)
- cd web && npm run build  (clean)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the per-row "Set active" button on the Profiles page with a
"Restart gateway" action. Active is a CLI-shell concept (which profile
plain `hermes ...` defaults to) — managing it from the dashboard is
indirect at best. The actually-useful per-profile action is restarting
that profile's gateway, which previously required dropping to a CLI.

Backend: POST /api/profiles/{name}/gateway/restart spawns
`hermes gateway restart` with HERMES_HOME overridden to the target
profile's directory, so it operates on that profile regardless of
which one the dashboard itself is running under. Each profile keeps
its own logs/gateway-restart.log.

The /api/profiles/{name}/activate endpoint is kept (the "active" badge
still surfaces it) but no longer has a UI trigger here.

Test plan:
- pytest tests/hermes_cli/test_web_server.py -k profile  (17 tests pass,
  including a new test that mocks subprocess.Popen and verifies the
  spawned command receives HERMES_HOME pointing at the target profile)
- cd web && npm run build  (clean)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small Profiles-page fixes:

Clone now leads with an explicit "Source profile" dropdown that lists
every existing profile with "Blank profile" as the default. Selecting a
real source reveals a Copy segmented control (Config only / All state).
The previous design had two parallel dropdowns whose interaction was
opaque — most notably you could pick a "Clone from" source while Copy
mode was "Don't clone", and nothing would actually be copied. Source-
first matches user mental model: pick what to clone, then choose how
much to copy.

The Import card is hidden behind `{false && (...)}`. Server-side path
entry is brittle UX and importing is rare enough that we'd rather punt
until we add real file upload. The /api/profiles/import endpoint and
the api.ts client method stay so external callers and a future PR can
restore the surface without touching the backend.

Test plan:
- cd web && npm run build  (clean)
- pytest tests/hermes_cli/test_web_server.py -k profile  (no new tests
  needed — backend is unchanged)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Source dropdown was only showing the static "Blank profile" entry —
none of the existing profiles appeared. Root cause: the Select
primitive's flattenChildren walks props.children to collect options but
doesn't recurse into bare arrays. Putting profiles.map(...) directly
inside <Select> handed it `[<Option/>, [<Option/>, <Option/>, ...]]`,
and the inner array got skipped because arrays have no `.props`.

Wrapping the map output in a Fragment routes the iteration through
props.children (which the function does recurse into).

Test plan:
- cd web && npm run build  (clean)
- manually: open dashboard /profiles, click Source dropdown, all
  existing profiles now appear alongside "Blank profile"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors hermes_cli/profiles.py::_PROFILE_ID_RE on the client so the
Create form and the inline rename input reject obviously invalid names
(uppercase, spaces, …) before round-tripping a 400. Without this the
server rejects the name and the toast carrying the regex hint can fly
by in three seconds before the user reads it — typical symptom is a
mysterious "PATCH /api/profiles/<name> 400" in the network tab with no
visible explanation.

Also surfaces the rule itself as helper text under the Create form's
Name input so users see the constraint while typing rather than after
submit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surface the same lowercase-only constraint hint that the Create form
shows underneath the inline rename input on each profile row. The hint
turns red and prefixes itself with "Invalid profile name:" the moment
the typed name fails the regex, so users see why their input is being
rejected before they hit Enter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dashboard's app shell sets ``text-transform: uppercase`` on the
root container as part of the design language. Most pages display
labels and headers, so the global rule looks fine. The Profiles page
displays case-sensitive identifiers — profile names (forced lowercase
by the backend regex), model slugs, and filesystem paths — and
rendering ``gf`` as ``GF`` actively contradicts the rule the user just
read in the name-rule hint.

Override ``normal-case`` on the page's outer container. Children that
explicitly opt into uppercase (Badges, Segmented options, the SOUL/
Model section headers) keep their styling because they set their own
text-transform.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
rename_profile gated _cleanup_gateway_service behind
_check_gateway_running, which reads gateway.pid. When launchd
KeepAlive=true respawns a crashed gateway, the new process pid never
makes it back into gateway.pid — so _check_gateway_running returns
False and the cleanup branch is skipped entirely.

Concrete failure mode this fixes:
1. profile X has launchd plist ai.hermes.gateway-X.plist with
   KeepAlive=true
2. its gateway crashed at some point; launchd respawned it; the new
   pid was never written to gateway.pid
3. user runs ``hermes profile rename X Y`` (or invokes the same path
   from the dashboard)
4. _check_gateway_running(X) reads stale pid → returns False
5. _cleanup_gateway_service is skipped — plist stays loaded
6. directory rename succeeds: ~/.hermes/profiles/X → .../Y
7. the runaway launchd job is still running with --profile X, finds
   its working dir gone, re-bootstraps a fresh skeleton .../X
8. listing now shows BOTH X (stub) and Y (real contents)

Fix: always run _cleanup_gateway_service first. It's already internally
guarded on plist/unit existence, so it's a safe no-op for profiles
that never had a service.

delete_profile already had the cleanup unconditional, so no change
there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous "gateway up" badge only rendered when the gateway was
running, so a stopped profile had no status indicator at all and the
list looked the same whether the gateway was offline or whether the
field had simply been omitted.

Replace the conditional badge with a two-state indicator that's always
present: success-variant green dot + "running" when up, outline +
muted dot + "stopped" when down. You can now scan the list and see at
a glance which profiles are alive vs idle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The outline-variant Badge defaults to text-muted-foreground which is
too dim against this theme's dark background — users reported the
"stopped" badge wasn't appearing on non-default profiles even though
it was in fact rendered.

Override to text-foreground/80 + border-foreground/30 for the stopped
state, and double the indicator dot size (1.5 → 2 px). The running
variant is unchanged (success badge already has its own emerald color
that's plenty visible).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The restart-gateway button fired POST /api/profiles/<name>/gateway/restart
and immediately returned to its idle state. The toast confirmation
faded after 3s and the gateway-status badge only refreshed on the
3s setTimeout, so users who blinked saw nothing happen at all and
clicked again — or thought the button was broken.

Track an in-flight set of profile names; while a restart is pending,
the button is disabled and its RotateCw icon spins. The spinner clears
when the deferred reload runs (or the request errors).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-shot reload

Restarting a profile's gateway from the dashboard previously refreshed
once at 3s and cleared the spinner. The CLI's ``hermes gateway
restart`` returns immediately from spawn but the actual gateway init
(clean shutdown of any old instance + service registration + writing
gateway.pid) routinely takes 5–15s. The 3s reload caught the gap, the
spinner cleared, and the row stayed on "stopped" — looking exactly
like the restart did nothing.

Replace the single timer with a 2s-interval poll for up to 16s. The
spinner stays visible until either the listing reports
gateway_running=true for the target profile or the cap is hit. State
flips visibly the moment the gateway comes up, no manual refresh
required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dashboard server generates a fresh ``_SESSION_TOKEN`` at every
process start and injects it into the SPA HTML. A tab opened against
the previous boot keeps the stale token in
``window.__HERMES_SESSION_TOKEN__`` and every API call comes back 401,
which the UI surfaces as a generic "Error: 401: Unauthorized" toast.
Hitting Restart Gateway from a tab open across a server restart
triggered this.

When fetchJSON sees a 401, trigger ``window.location.reload()`` once.
Reload pulls fresh HTML with the current token so the very next call
succeeds. The retry is gated on a module-level boolean to prevent
ping-pong reloads if the server is genuinely down or actually
rejecting credentials.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After 16s of polling, if the gateway never reported running, the UI
silently stopped the spinner and left the badge on "stopped" — so
legitimate startup failures (Weixin/Telegram token already in use by
another profile, missing optional deps, port collision) looked the
same as "I clicked but nothing happened."

Toast a clear failure when the poll times out, pointing the user at
the per-profile logs/gateway-restart.log where the real error message
lives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hermes platform adapters (Weixin/Telegram/Discord) acquire a process-
wide lock keyed by token value. When two profiles list the same
WEIXIN_TOKEN / TELEGRAM_BOT_TOKEN / DISCORD_BOT_TOKEN in their .env,
only one gateway will ever start that platform — the rest die with
"already in use" and the user finds out the hard way.

GET /api/profiles now scans every profile's .env for these exclusive
keys, buckets by (key, value), and returns shared_tokens=[{key, with}]
on each profile entry that collides with at least one other. The
Profiles page renders a destructive Badge with an AlertTriangle icon
and a tooltip listing the colliding profile names, so the conflict is
visible the moment the page loads instead of on next gateway restart.

This caught a real case on a maintainer's machine: 4 profiles all
shared one iLink Bot token, which is why every "Restart gateway"
attempt outside the active profile silently failed.

Test plan:
- pytest tests/hermes_cli/test_web_server.py -k profile  (19 tests
  pass; new test_profiles_listing_flags_shared_exclusive_tokens
  covers the round-trip including quoted env values)
- cd web && npm run build  (clean)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The aggregate "token shared" badge buried which platform was actually
in conflict — users had to hover the tooltip to find out whether they
needed to fix Weixin, Telegram, or Discord credentials.

Render one destructive badge per shared_tokens entry, labelled
"token conflict (WeChat)" / "token conflict (Telegram)" / etc. The
tooltip still lists the colliding profile names.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Render a single ⚠ badge per profile listing all conflicting platforms
inside the parens, e.g. "token conflict (WeChat, Telegram)", instead
of one badge per platform repeating "token conflict" each time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pawn env

The dashboard process loads its HERMES_HOME's .env at boot and pushes
those values into ``os.environ``. ``_spawn_per_profile_gateway_restart``
then forwarded the parent env wholesale via ``{**os.environ, ...}``,
so spawning a per-profile gateway with HERMES_HOME pointing at, say,
``profiles/master`` would still have ``WEIXIN_TOKEN`` set in its
environment — inherited from the dashboard's default-profile load.

Effects observed: ``master profile`` had no WEIXIN_TOKEN in its own
.env, but the spawned gateway saw the leaked value, attempted to
register with Weixin, and crashed on ``_acquire_platform_lock``
because the default-profile gateway was already holding that token.
The dashboard's shared-token detector reads .env files only, so it
correctly didn't flag master as conflicting — yet the user kept
seeing "Weixin bot token already in use" in master's gateway-status.

Filter the inherited env through ``_EXCLUSIVE_TOKEN_ENV_KEYS`` before
spawning. Non-exclusive vars (LLM API keys, PATH, …) still pass
through; bot credentials only come from the target profile's own .env
via Hermes's normal config load. New regression test asserts both the
strip and the pass-through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@VinceZcrikl

Copy link
Copy Markdown
Author

Superseded by #16324 — split into a minimal first cut. Will reopen the additional features (SOUL editor, model picker, gateway restart, shared-token detection, stale-pid rename fix) as targeted follow-up PRs after #16324 lands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard P3 Low — cosmetic, nice to have type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants