feat(dashboard): add profiles management page - #16058
Closed
VinceZcrikl wants to merge 19 commits into
Closed
Conversation
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>
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>
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
/profilespage in the web dashboard for end-to-end profile management — list, create (blank or cloned), rename, activate, export, import, delete — covering everything thehermes profile *CLI does/api/profiles*REST endpoints that wrap the existinghermes_cli.profilesfunctions; backend is a thin adapter, validation and state still live inprofiles.pyyes=Truesince the dashboard performs its own confirm dialog\$HERMES_HOME/exports/<name>-<ts>.tar.gzwhen nooutput_pathis provided; import takes a server-side archive pathenandzhScope notes
ProfileSwitcher.tsxand scopes the existing Sessions/Cron APIs by active profile. Reviewers can ship both PRs in either order; only the routes table inApp.tsxandweb/src/lib/api.tsneed a trivial rebase.Endpoints added
GET /api/profiles— list (returns{profiles, active})GET /api/profiles/activePOST /api/profiles— create (with optionalclone_from,clone_all,clone_config)PATCH /api/profiles/{name}— renameDELETE /api/profiles/{name}POST /api/profiles/{name}/activatePOST /api/profiles/{name}/export— body:{output_path?}, returns resolved pathPOST /api/profiles/import— body:{archive_path, name?}Test plan
pytest tests/hermes_cli/test_web_server.py -k profile— 11 new tests, all passcd web && npm run build— clean/profilesafterhermes dashboard, verify list/create/rename/activate/export/import/delete