From 7fb8a6b5c535ab592e2a25878e59c28dc1407b9c Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Wed, 3 Jun 2026 10:37:36 -0400 Subject: [PATCH] feat(dashboard): enrich profiles dashboard and de-dupe channel env vars (#37872) * feat(desktop): enrich profiles dashboard and de-dupe channel env vars Add active-profile switching, role descriptions (manual + auto-generate via the auxiliary LLM), per-profile model selection, and gateway-running / distribution badges to the GUI Profiles page. New profile creation gains clone-all, optional description and model assignment. Hide messaging-platform credentials (channel_managed) from the Keys/Env page since the Channels page is the canonical surface for them, and relabel the trimmed "messaging" category as "Gateway". Co-authored-by: Cursor * fix(desktop): address review feedback on profiles/env changes - ProfilesPage: scope the action-menu outside-click handler to the menu's own container via a ref so opening one card's menu no longer leaves others open. - EnvPage: route the "Gateway" label and hint through i18n (t.common.gateway / gatewayHint) instead of hard-coded English, with an English fallback for untranslated locales. - web_server: only report description_auto=true when auto-generation actually succeeded. Co-authored-by: Cursor * fix(desktop): address second-round review on profiles - ProfilesPage: treat describe-auto success by null-checking the description and trust the response's description_auto flag instead of assuming true; disable the model-editor Save button unless the selected choice resolves to a real /api/model/options entry (avoids silent no-op saves). - tests: cover the new profile endpoints (active get/set + 404, description round-trip + 404, model round-trip + 400 validation, and describe-auto success/failure contracts). Co-authored-by: Cursor * fix(desktop): more profiles review fixes (toggles, races, tests) - ProfilesPage: use the canonical `active` returned by setActiveProfile; make the SOUL/description/model action-menu items toggle their editor closed when already open; guard description save/auto-describe against stale responses via an activeDescRequest ref so a late reply can't clobber a different open editor. - tests: assert /api/env channel_managed classification matches _channel_managed_env_keys(). Co-authored-by: Cursor --------- Co-authored-by: Cursor --- hermes_cli/web_server.py | 232 +++++- tests/hermes_cli/test_web_server.py | 139 ++++ web/src/i18n/en.ts | 35 + web/src/i18n/types.ts | 37 + web/src/lib/api.ts | 70 +- web/src/pages/EnvPage.tsx | 31 +- web/src/pages/ProfilesPage.tsx | 1126 +++++++++++++++++++++++---- 7 files changed, 1494 insertions(+), 176 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index a585c5ae2..b9b690769 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2044,6 +2044,7 @@ async def update_config(body: ConfigUpdate): @app.get("/api/env") async def get_env_vars(): env_on_disk = load_env() + channel_keys = _channel_managed_env_keys() result = {} for var_name, info in OPTIONAL_ENV_VARS.items(): value = env_on_disk.get(var_name) @@ -2056,6 +2057,10 @@ async def get_env_vars(): "is_password": info.get("password", False), "tools": info.get("tools", []), "advanced": info.get("advanced", False), + # True when this var is a messaging-platform credential owned by a + # Channels page card. The Keys/Env page uses this to hide it and + # avoid duplicating the (richer) Channels configuration UI. + "channel_managed": var_name in channel_keys, } return result @@ -2584,6 +2589,25 @@ def _messaging_platform_catalog() -> tuple[dict[str, Any], ...]: return tuple(entries) +def _channel_managed_env_keys() -> frozenset[str]: + """Env-var keys owned by a Channels page platform card. + + The Channels page is the canonical surface for configuring messaging + platform credentials (with connection status, test, enable toggle and + gateway restart). The Keys/Env page consults this set to hide those vars + so the same fields aren't duplicated in a plainer UI. Best-effort: if the + gateway catalog can't be built, nothing is flagged and Keys shows it all. + """ + try: + keys: set[str] = set() + for entry in _messaging_platform_catalog(): + keys.update(entry.get("env_vars", ())) + return frozenset(keys) + except Exception: + _log.debug("could not build channel-managed env key set", exc_info=True) + return frozenset() + + def _build_catalog_entry( platform_id: str, plugin_entry: Any | None = None ) -> dict[str, Any]: @@ -5935,7 +5959,11 @@ async def search_skills_hub(q: str = "", source: str = "all", limit: int = 20): class ProfileCreate(BaseModel): name: str clone_from_default: bool = False + clone_all: bool = False no_skills: bool = False + description: Optional[str] = None + provider: Optional[str] = None + model: Optional[str] = None class ProfileRename(BaseModel): @@ -5946,6 +5974,23 @@ class ProfileSoulUpdate(BaseModel): content: str +class ProfileActiveUpdate(BaseModel): + name: str + + +class ProfileDescriptionUpdate(BaseModel): + description: str = "" + + +class ProfileModelUpdate(BaseModel): + provider: str + model: str + + +class ProfileDescribeAuto(BaseModel): + overwrite: bool = False + + def _profile_attr(info, name: str, default: Any = None) -> Any: try: return getattr(info, name) @@ -5962,6 +6007,13 @@ def _profile_to_dict(info) -> Dict[str, Any]: "provider": _profile_attr(info, "provider"), "has_env": bool(_profile_attr(info, "has_env", False)), "skill_count": int(_profile_attr(info, "skill_count", 0) or 0), + "gateway_running": bool(_profile_attr(info, "gateway_running", False)), + "description": _profile_attr(info, "description", "") or "", + "description_auto": bool(_profile_attr(info, "description_auto", False)), + "distribution_name": _profile_attr(info, "distribution_name"), + "distribution_version": _profile_attr(info, "distribution_version"), + "distribution_source": _profile_attr(info, "distribution_source"), + "has_alias": _profile_attr(info, "alias_path") is not None, } @@ -5984,6 +6036,13 @@ def _fallback_profile_dicts(profiles_mod) -> List[Dict[str, Any]]: "provider": provider, "has_env": (default_home / ".env").exists(), "skill_count": _safe(lambda: profiles_mod._count_skills(default_home), 0), + "gateway_running": _safe(lambda: profiles_mod._check_gateway_running(default_home), False), + "description": _safe(lambda: profiles_mod.read_profile_meta(default_home).get("description", ""), ""), + "description_auto": _safe(lambda: profiles_mod.read_profile_meta(default_home).get("description_auto", False), False), + "distribution_name": None, + "distribution_version": None, + "distribution_source": None, + "has_alias": False, }) profiles_root = profiles_mod._get_profiles_root() @@ -6000,6 +6059,13 @@ def _fallback_profile_dicts(profiles_mod) -> List[Dict[str, Any]]: "provider": provider, "has_env": (entry / ".env").exists(), "skill_count": _safe(lambda entry=entry: profiles_mod._count_skills(entry), 0), + "gateway_running": _safe(lambda entry=entry: profiles_mod._check_gateway_running(entry), False), + "description": _safe(lambda entry=entry: profiles_mod.read_profile_meta(entry).get("description", ""), ""), + "description_auto": _safe(lambda entry=entry: profiles_mod.read_profile_meta(entry).get("description_auto", False), False), + "distribution_name": None, + "distribution_version": None, + "distribution_source": None, + "has_alias": False, }) return profiles @@ -6023,6 +6089,34 @@ def _profile_setup_command(name: str) -> str: return "hermes setup" if name == "default" else f"{name} setup" +def _write_profile_model(profile_dir: Path, provider: str, model: str) -> None: + """Write the main model assignment into a specific profile's config.yaml. + + Scopes ``load_config``/``save_config`` to ``profile_dir`` via the + context-local HERMES_HOME override so the write lands in the target + profile's config rather than the dashboard process's active profile. + Clears any stale ``base_url`` / ``context_length`` the same way + ``POST /api/model/set`` does, since the new model may differ. + """ + from hermes_constants import set_hermes_home_override, reset_hermes_home_override + + token = set_hermes_home_override(str(profile_dir)) + try: + cfg = load_config() + model_cfg = cfg.get("model", {}) + if not isinstance(model_cfg, dict): + model_cfg = {} + model_cfg["provider"] = provider + model_cfg["default"] = model + if model_cfg.get("base_url"): + model_cfg["base_url"] = "" + model_cfg.pop("context_length", None) + cfg["model"] = model_cfg + save_config(cfg) + finally: + reset_hermes_home_override(token) + + @app.get("/api/profiles") async def list_profiles_endpoint(): from hermes_cli import profiles as profiles_mod @@ -6036,19 +6130,22 @@ async def list_profiles_endpoint(): @app.post("/api/profiles") async def create_profile_endpoint(body: ProfileCreate): from hermes_cli import profiles as profiles_mod + clone = body.clone_from_default or body.clone_all try: path = profiles_mod.create_profile( name=body.name, - clone_from="default" if body.clone_from_default else None, - clone_config=body.clone_from_default, + clone_from="default" if clone else None, + clone_all=body.clone_all, + clone_config=body.clone_from_default and not body.clone_all, no_skills=body.no_skills, + description=body.description, ) # Match the CLI's profile-create flow: fresh named profiles get the # bundled skills installed. When cloning from default, create_profile() # has already copied the source profile's skills, including any # user-installed skills. When no_skills=True, create_profile() wrote # the opt-out marker and seed_profile_skills() will no-op. - if not body.clone_from_default: + if not clone: profiles_mod.seed_profile_skills(path, quiet=True) # Match the CLI's profile-create flow: named profiles should get a @@ -6061,7 +6158,63 @@ async def create_profile_endpoint(body: ProfileCreate): except Exception as e: _log.exception("POST /api/profiles failed") raise HTTPException(status_code=500, detail=str(e)) - return {"ok": True, "name": body.name, "path": str(path)} + + # Optional explicit model assignment for the new profile. Best-effort: + # the profile already exists, so a model-write hiccup must not 500 the + # whole create — the user can set the model later from the Models page + # or ` setup`. + provider = (body.provider or "").strip() + model = (body.model or "").strip() + model_set = False + if provider and model: + try: + _write_profile_model(path, provider, model) + model_set = True + except Exception: + _log.exception("Setting model for new profile %s failed", body.name) + + return {"ok": True, "name": body.name, "path": str(path), "model_set": model_set} + + +@app.get("/api/profiles/active") +async def get_active_profile_endpoint(): + """Return the sticky active profile and the profile this dashboard + process is currently running as. + + ``active`` is the sticky default written by ``hermes profile use`` — + the profile new CLI invocations pick up. ``current`` is the profile + the running dashboard/gateway is scoped to (derived from HERMES_HOME). + """ + from hermes_cli import profiles as profiles_mod + try: + active = profiles_mod.get_active_profile() or "default" + except Exception: + active = "default" + try: + current = profiles_mod.get_active_profile_name() or "default" + except Exception: + current = "default" + return {"active": active, "current": current} + + +@app.post("/api/profiles/active") +async def set_active_profile_endpoint(body: ProfileActiveUpdate): + """Set the sticky active profile (mirrors ``hermes profile use``). + + Note: this does not retarget the already-running dashboard process — + it changes which profile subsequent CLI commands and gateways use. + """ + from hermes_cli import profiles as profiles_mod + try: + profiles_mod.set_active_profile(body.name) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + _log.exception("POST /api/profiles/active failed") + raise HTTPException(status_code=500, detail=str(e)) + return {"ok": True, "active": profiles_mod.normalize_profile_name(body.name)} @app.get("/api/profiles/{name}/setup-command") @@ -6178,6 +6331,77 @@ async def update_profile_soul(name: str, body: ProfileSoulUpdate): return {"ok": True} +@app.put("/api/profiles/{name}/description") +async def update_profile_description_endpoint(name: str, body: ProfileDescriptionUpdate): + """Set or clear a profile's role description (kanban routing signal). + + Empty string clears the description. Non-empty stores it as a + user-authored description (``description_auto: false``) so the + auto-describer won't overwrite it on a sweep. + """ + from hermes_cli import profiles as profiles_mod + profile_dir = _resolve_profile_dir(name) + text = (body.description or "").strip() + try: + profiles_mod.write_profile_meta( + profile_dir, + description=text, + description_auto=False, + ) + except Exception as e: + _log.exception("PUT /api/profiles/%s/description failed", name) + raise HTTPException(status_code=500, detail=str(e)) + return {"ok": True, "description": text, "description_auto": False} + + +@app.put("/api/profiles/{name}/model") +async def update_profile_model_endpoint(name: str, body: ProfileModelUpdate): + """Set the main model (``model.default`` + ``model.provider``) for a + specific profile's config.yaml, without touching the dashboard's own + active profile. Mirrors ``POST /api/model/set`` (main scope) but scoped + to the named profile via the HERMES_HOME override. + """ + profile_dir = _resolve_profile_dir(name) + provider = (body.provider or "").strip() + model = (body.model or "").strip() + if not provider or not model: + raise HTTPException(status_code=400, detail="provider and model are required") + try: + _write_profile_model(profile_dir, provider, model) + except Exception as e: + _log.exception("PUT /api/profiles/%s/model failed", name) + raise HTTPException(status_code=500, detail=str(e)) + return {"ok": True, "provider": provider, "model": model} + + +@app.post("/api/profiles/{name}/describe-auto") +async def describe_profile_auto_endpoint(name: str, body: ProfileDescribeAuto): + """Auto-generate a profile's description via the auxiliary LLM + (``auxiliary.profile_describer``). Mirrors ``hermes profile describe + --auto``. + + A failed generation (no aux client, LLM error, …) is returned as + ``ok: false`` with a reason rather than an HTTP error so the UI can + surface it inline and let the operator fix config and retry. + """ + _resolve_profile_dir(name) + try: + from hermes_cli import profile_describer + outcome = profile_describer.describe_profile(name, overwrite=bool(body.overwrite)) + except Exception as e: + _log.exception("POST /api/profiles/%s/describe-auto failed", name) + raise HTTPException(status_code=500, detail=str(e)) + return { + "ok": bool(outcome.ok), + "reason": outcome.reason, + "description": outcome.description, + # Only a successful generation is an auto-authored description. A failed + # sweep leaves any existing description untouched, so don't claim it's + # auto-generated. + "description_auto": bool(outcome.ok), + } + + # --------------------------------------------------------------------------- # Skills & Tools endpoints # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 323f06c5a..747954633 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -706,6 +706,19 @@ class TestWebServerEndpoints: # Should contain known env var names assert any(k.endswith("_API_KEY") or k.endswith("_TOKEN") for k in data.keys()) + def test_get_env_vars_marks_channel_managed_keys(self): + from hermes_cli.web_server import _channel_managed_env_keys + + data = self.client.get("/api/env").json() + # Every entry carries the classification the Keys page relies on. + assert all("channel_managed" in info for info in data.values()) + + channel_keys = _channel_managed_env_keys() + # Messaging-platform credentials owned by the Channels page are flagged; + # everything else stays visible on the Keys page. + for key, info in data.items(): + assert info["channel_managed"] is (key in channel_keys) + def test_reveal_env_var(self, tmp_path): """POST /api/env/reveal should return the real unredacted value.""" from hermes_cli.config import save_env_value @@ -1498,6 +1511,132 @@ class TestNewEndpoints: resp = self.client.get("/api/profiles/nonexistent/soul") assert resp.status_code == 404 + # --- New profiles endpoints: active / description / model / describe-auto --- + + def test_profiles_active_defaults(self): + from hermes_constants import get_hermes_home + get_hermes_home().mkdir(parents=True, exist_ok=True) + + resp = self.client.get("/api/profiles/active") + assert resp.status_code == 200 + data = resp.json() + assert data["active"] == "default" + assert data["current"] == "default" + + def test_profiles_set_active_round_trip(self, monkeypatch): + import hermes_cli.profiles as profiles_mod + monkeypatch.setattr(profiles_mod, "create_wrapper_script", lambda name: None) + + self.client.post("/api/profiles", json={"name": "router"}) + + resp = self.client.post("/api/profiles/active", json={"name": "router"}) + assert resp.status_code == 200 + assert resp.json()["active"] == "router" + assert self.client.get("/api/profiles/active").json()["active"] == "router" + + def test_profiles_set_active_unknown_404(self): + resp = self.client.post("/api/profiles/active", json={"name": "ghost"}) + assert resp.status_code == 404 + + def test_profile_description_round_trip(self, monkeypatch): + import hermes_cli.profiles as profiles_mod + monkeypatch.setattr(profiles_mod, "create_wrapper_script", lambda name: None) + + self.client.post("/api/profiles", json={"name": "desc-prof"}) + + put = self.client.put( + "/api/profiles/desc-prof/description", + json={"description": "Handles code review"}, + ) + assert put.status_code == 200 + body = put.json() + assert body["description"] == "Handles code review" + assert body["description_auto"] is False + + profiles = {p["name"]: p for p in self.client.get("/api/profiles").json()["profiles"]} + assert profiles["desc-prof"]["description"] == "Handles code review" + assert profiles["desc-prof"]["description_auto"] is False + + def test_profile_description_unknown_404(self): + resp = self.client.put( + "/api/profiles/nope/description", json={"description": "x"} + ) + assert resp.status_code == 404 + + def test_profile_model_round_trip(self, monkeypatch): + from hermes_constants import get_hermes_home + import hermes_cli.profiles as profiles_mod + monkeypatch.setattr(profiles_mod, "create_wrapper_script", lambda name: None) + + self.client.post("/api/profiles", json={"name": "model-prof"}) + + resp = self.client.put( + "/api/profiles/model-prof/model", + json={"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}, + ) + assert resp.status_code == 200 + assert resp.json()["provider"] == "openrouter" + + import yaml + cfg_path = get_hermes_home() / "profiles" / "model-prof" / "config.yaml" + cfg = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + assert cfg["model"]["provider"] == "openrouter" + assert cfg["model"]["default"] == "anthropic/claude-sonnet-4.6" + + def test_profile_model_requires_provider_and_model(self, monkeypatch): + import hermes_cli.profiles as profiles_mod + monkeypatch.setattr(profiles_mod, "create_wrapper_script", lambda name: None) + + self.client.post("/api/profiles", json={"name": "model-prof2"}) + resp = self.client.put( + "/api/profiles/model-prof2/model", + json={"provider": "", "model": ""}, + ) + assert resp.status_code == 400 + + def test_profile_describe_auto_success(self, monkeypatch): + import hermes_cli.profiles as profiles_mod + monkeypatch.setattr(profiles_mod, "create_wrapper_script", lambda name: None) + + self.client.post("/api/profiles", json={"name": "auto-prof"}) + + from hermes_cli import profile_describer + monkeypatch.setattr( + profile_describer, + "describe_profile", + lambda name, overwrite=False: profile_describer.DescribeOutcome( + name, True, "described", description="Generated blurb" + ), + ) + + resp = self.client.post("/api/profiles/auto-prof/describe-auto", json={}) + assert resp.status_code == 200 + body = resp.json() + assert body["ok"] is True + assert body["description"] == "Generated blurb" + assert body["description_auto"] is True + + def test_profile_describe_auto_failure_is_not_auto(self, monkeypatch): + import hermes_cli.profiles as profiles_mod + monkeypatch.setattr(profiles_mod, "create_wrapper_script", lambda name: None) + + self.client.post("/api/profiles", json={"name": "auto-fail"}) + + from hermes_cli import profile_describer + monkeypatch.setattr( + profile_describer, + "describe_profile", + lambda name, overwrite=False: profile_describer.DescribeOutcome( + name, False, "no aux client", description=None + ), + ) + + resp = self.client.post("/api/profiles/auto-fail/describe-auto", json={}) + assert resp.status_code == 200 + body = resp.json() + assert body["ok"] is False + assert body["description_auto"] is False + def test_skills_list(self): resp = self.client.get("/api/skills") assert resp.status_code == 200 diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 423876f3d..315e55222 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -43,6 +43,9 @@ export const en: Translations = { expand: "Expand", general: "General", messaging: "Messaging", + gateway: "Gateway", + gatewayHint: + "Messaging platforms, the API server and webhooks are configured on the Channels page. These are gateway-wide settings (proxy/relay mode and the global allowlist).", pluginLoadFailed: "Could not load this plugin’s script. Check the Network tab (dashboard-plugins/…) and the server’s plugin path.", pluginNotRegistered: @@ -309,6 +312,38 @@ export const en: Translations = { created: "Created", deleted: "Deleted", renamed: "Renamed", + activeProfile: "Active profile", + activeBadge: "active", + setActive: "Set as active", + activeSet: "Active profile set", + gatewayRunning: "Gateway running", + gatewayStopped: "Gateway stopped", + gatewayRunningWarning: + "This profile's gateway is running — it will be stopped.", + aliasBadge: "alias", + description: "Description", + descriptionPlaceholder: + "What is this profile good at? Used to route kanban tasks by role.", + noDescription: "No description", + editDescription: "Edit description", + descriptionSaved: "Description saved", + reviewBadge: "review", + autoGenerate: "Auto-generate", + generating: "Generating…", + describeFailed: "Could not generate description", + distribution: "Distribution", + advancedOptions: "Advanced options", + cloneAll: "Clone everything (memories, sessions, skills, state)", + noSkillsOption: "Don't seed bundled skills", + descriptionOptional: "Description (optional)", + modelOptional: "Model (optional)", + modelInherit: "Inherit from clone / default", + modelLoading: "Loading models…", + modelNone: "No authenticated providers — set a key first", + editModel: "Change model", + modelSaved: "Model updated", + modelSelect: "Select a model", + actions: "Actions", }, pluginsPage: { diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index a35fcb898..26b14ff07 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -60,6 +60,10 @@ export interface Translations { expand: string; general: string; messaging: string; + // Optional: non-English locales fall back to the English literal in the + // component until translated, matching the enriched-profiles keys. + gateway?: string; + gatewayHint?: string; pluginLoadFailed: string; pluginNotRegistered: string; }; @@ -365,6 +369,39 @@ export interface Translations { created: string; deleted: string; renamed: string; + // Optional keys added for the enriched profiles experience. Non-English + // locales fall back to the English literal in the component until + // translated, so these are optional to avoid churning every locale file. + activeProfile?: string; + activeBadge?: string; + setActive?: string; + activeSet?: string; + gatewayRunning?: string; + gatewayStopped?: string; + gatewayRunningWarning?: string; + aliasBadge?: string; + description?: string; + descriptionPlaceholder?: string; + noDescription?: string; + editDescription?: string; + descriptionSaved?: string; + reviewBadge?: string; + autoGenerate?: string; + generating?: string; + describeFailed?: string; + distribution?: string; + advancedOptions?: string; + cloneAll?: string; + noSkillsOption?: string; + descriptionOptional?: string; + modelOptional?: string; + modelInherit?: string; + modelLoading?: string; + modelNone?: string; + editModel?: string; + modelSaved?: string; + modelSelect?: string; + actions?: string; }; // ── Skills page ── diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index a93827c55..4eb6291fd 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -361,15 +361,58 @@ export const api = { deleteCronJob: (id: string, profile = "default") => fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${encodeURIComponent(id)}?profile=${encodeURIComponent(profile)}`, { method: "DELETE" }), - // Profiles (minimal) + // Profiles getProfiles: () => fetchJSON<{ profiles: ProfileInfo[] }>("/api/profiles"), - createProfile: (body: { name: string; clone_from_default: boolean }) => - fetchJSON<{ ok: boolean; name: string; path: string }>("/api/profiles", { + getActiveProfile: () => + fetchJSON("/api/profiles/active"), + setActiveProfile: (name: string) => + fetchJSON<{ ok: boolean; active: string }>("/api/profiles/active", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }), + createProfile: (body: { + name: string; + clone_from_default: boolean; + clone_all?: boolean; + no_skills?: boolean; + description?: string; + provider?: string; + model?: string; + }) => + fetchJSON<{ ok: boolean; name: string; path: string; model_set?: boolean }>("/api/profiles", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }), + updateProfileDescription: (name: string, description: string) => + fetchJSON<{ ok: boolean; description: string; description_auto: boolean }>( + `/api/profiles/${encodeURIComponent(name)}/description`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ description }), + }, + ), + describeProfileAuto: (name: string, overwrite = true) => + fetchJSON( + `/api/profiles/${encodeURIComponent(name)}/describe-auto`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ overwrite }), + }, + ), + setProfileModel: (name: string, provider: string, model: string) => + fetchJSON<{ ok: boolean; provider: string; model: string }>( + `/api/profiles/${encodeURIComponent(name)}/model`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider, model }), + }, + ), renameProfile: (name: string, newName: string) => fetchJSON<{ ok: boolean; name: string; path: string }>( `/api/profiles/${encodeURIComponent(name)}`, @@ -1170,6 +1213,8 @@ export interface EnvVarInfo { is_password: boolean; tools: string[]; advanced: boolean; + /** True when this var is a messaging-platform credential owned by the Channels page. */ + channel_managed?: boolean; } export interface SessionMessage { @@ -1250,6 +1295,18 @@ export interface AnalyticsResponse { }; } +export interface ActiveProfileInfo { + active: string; + current: string; +} + +export interface ProfileDescribeAutoResult { + ok: boolean; + reason: string; + description: string | null; + description_auto: boolean; +} + export interface ProfileInfo { name: string; path: string; @@ -1258,6 +1315,13 @@ export interface ProfileInfo { provider: string | null; has_env: boolean; skill_count: number; + gateway_running: boolean; + description: string; + description_auto: boolean; + distribution_name: string | null; + distribution_version: string | null; + distribution_source: string | null; + has_alias: boolean; } export interface ModelsAnalyticsModelEntry { diff --git a/web/src/pages/EnvPage.tsx b/web/src/pages/EnvPage.tsx index 5be65d638..ded312655 100644 --- a/web/src/pages/EnvPage.tsx +++ b/web/src/pages/EnvPage.tsx @@ -513,12 +513,12 @@ export default function EnvPage() { const categories = ["tool", "messaging", "setting"]; const CATEGORY_LABELS: Record = { tool: "Tools", - messaging: "Messaging", + messaging: t.common.gateway ?? "Gateway", setting: "Settings", }; for (const cat of categories) { const hasEntries = Object.values(vars).some( - (info) => info.category === cat, + (info) => info.category === cat && !info.channel_managed, ); if (hasEntries) { items.push({ id: `section-${cat}`, label: CATEGORY_LABELS[cat] ?? cat }); @@ -526,7 +526,7 @@ export default function EnvPage() { } } return items; - }, [vars]); + }, [vars, t]); useLayoutEffect(() => { if (!vars) { @@ -681,21 +681,33 @@ export default function EnvPage() { })) .sort((a, b) => a.priority - b.priority); - // Non-provider categories — use translated labels + // Non-provider categories — use translated labels. Platform credentials + // (channel_managed) are configured on the Channels page, so the messaging + // category here is trimmed down to cross-cutting gateway / API / proxy + // settings and relabelled accordingly. const CATEGORY_META_LABELS: Record = { tool: t.app.nav.keys, - messaging: t.common.messaging, + messaging: t.common.gateway ?? "Gateway", setting: t.app.nav.config, }; + const CATEGORY_META_HINTS: Record = { + messaging: + t.common.gatewayHint ?? + "Messaging platforms, the API server and webhooks are configured on the Channels page. These are gateway-wide settings (proxy/relay mode and the global allowlist).", + }; const otherCategories = ["tool", "messaging", "setting"]; const nonProvider = otherCategories.map((cat) => { const entries = Object.entries(vars).filter( - ([, info]) => info.category === cat && (showAdvanced || !info.advanced), + ([, info]) => + info.category === cat && + !info.channel_managed && + (showAdvanced || !info.advanced), ); const setEntries = entries.filter(([, info]) => info.is_set); const unsetEntries = entries.filter(([, info]) => !info.is_set); return { label: CATEGORY_META_LABELS[cat] ?? cat, + hint: CATEGORY_META_HINTS[cat], icon: CATEGORY_META_ICONS[cat] ?? KeyRound, category: cat, setEntries, @@ -839,6 +851,7 @@ function EnvCategoryCard({ }: { section: { category: string; + hint?: string; icon: React.ComponentType<{ className?: string }>; label: string; setEntries: [string, EnvVarInfo][]; @@ -899,6 +912,12 @@ function EnvCategoryCard({ {section.setEntries.length} {t.common.of} {section.totalEntries}{" "} {t.common.configured} + + {section.hint && ( + + {section.hint} + + )} {hasContent && ( diff --git a/web/src/pages/ProfilesPage.tsx b/web/src/pages/ProfilesPage.tsx index 210a5da96..7afddfad1 100644 --- a/web/src/pages/ProfilesPage.tsx +++ b/web/src/pages/ProfilesPage.tsx @@ -2,12 +2,19 @@ import { useCallback, useEffect, useLayoutEffect, + useMemo, useRef, useState, } from "react"; import { + AlignLeft, + Check, ChevronDown, + Cpu, + MoreVertical, Pencil, + Package, + Sparkles, Terminal, Trash2, Users, @@ -16,7 +23,7 @@ import { import spinners from "unicode-animations"; import { H2 } from "@nous-research/ui/ui/components/typography/h2"; import { api } from "@/lib/api"; -import type { ProfileInfo } from "@/lib/api"; +import type { ActiveProfileInfo, ProfileInfo } from "@/lib/api"; import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog"; import { useToast } from "@nous-research/ui/hooks/use-toast"; import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete"; @@ -28,6 +35,10 @@ import { Button } from "@nous-research/ui/ui/components/button"; import { Input } from "@nous-research/ui/ui/components/input"; import { Label } from "@nous-research/ui/ui/components/label"; import { Checkbox } from "@nous-research/ui/ui/components/checkbox"; +import { + Select, + SelectOption, +} from "@nous-research/ui/ui/components/select"; import { useI18n } from "@/i18n"; import { usePageHeader } from "@/contexts/usePageHeader"; import { cn, themedBody } from "@/lib/utils"; @@ -65,18 +76,237 @@ function ProfilesLoadingSpinner() { ); } +/** + * Per-card "⋯" actions menu. Holds every action for the profile (set active, + * model, description, SOUL, copy command, rename, delete) so the card row stays + * a single button. Mirrors the hand-rolled dropdown pattern used by ModelsPage's + * "Use as" menu (button + absolute panel + outside-click close). + */ +function ProfileActionsMenu({ + isActive, + isDefault, + isEditingDesc, + isEditingModel, + isEditingSoul, + labels, + settingActive, + onCopyCommand, + onDelete, + onEditDescription, + onEditModel, + onEditSoul, + onRename, + onSetActive, +}: ProfileActionsMenuProps) { + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + const target = e.target as Node | null; + // Close only when the click lands outside *this* menu. Matching any + // `[data-profile-actions]` would treat another card's menu as "inside" + // and leave several menus open at once. + if (target && !containerRef.current?.contains(target)) setOpen(false); + }; + window.addEventListener("mousedown", onDown); + return () => window.removeEventListener("mousedown", onDown); + }, [open]); + + // Run the action, then collapse the menu. Toggle editors (model/description/ + // SOUL) expand the inline section below the card once the menu closes. + const run = (fn: () => void) => () => { + fn(); + setOpen(false); + }; + + const itemClass = + "flex w-full items-center gap-2.5 px-3 py-2 text-xs uppercase tracking-wider hover:bg-muted/50 disabled:opacity-40"; + + return ( +
+ + + {open && ( +
+ {!isActive && ( + + )} + + + + + + + + + + {!isDefault && ( + + )} + + {!isDefault && ( + + )} +
+ )} +
+ ); +} + export default function ProfilesPage() { const [profiles, setProfiles] = useState([]); + const [activeInfo, setActiveInfo] = useState(null); const [loading, setLoading] = useState(true); const { toast, showToast } = useToast(); const { t } = useI18n(); const { setEnd } = usePageHeader(); + // Locale strings with English fallbacks. The enriched keys are optional in + // the i18n type so untranslated locales don't break the build — they render + // the English literal until translated. + const L = useMemo(() => { + const p = t.profiles; + return { + activeProfile: p.activeProfile ?? "Active profile", + activeBadge: p.activeBadge ?? "active", + setActive: p.setActive ?? "Set as active", + activeSet: p.activeSet ?? "Active profile set", + gatewayRunning: p.gatewayRunning ?? "Gateway running", + gatewayStopped: p.gatewayStopped ?? "Gateway stopped", + gatewayRunningWarning: + p.gatewayRunningWarning ?? + "This profile's gateway is running — it will be stopped.", + aliasBadge: p.aliasBadge ?? "alias", + description: p.description ?? "Description", + descriptionPlaceholder: + p.descriptionPlaceholder ?? + "What is this profile good at? Used to route kanban tasks by role.", + noDescription: p.noDescription ?? "No description", + editDescription: p.editDescription ?? "Edit description", + descriptionSaved: p.descriptionSaved ?? "Description saved", + reviewBadge: p.reviewBadge ?? "review", + autoGenerate: p.autoGenerate ?? "Auto-generate", + generating: p.generating ?? "Generating…", + describeFailed: p.describeFailed ?? "Could not generate description", + distribution: p.distribution ?? "Distribution", + advancedOptions: p.advancedOptions ?? "Advanced options", + cloneAll: + p.cloneAll ?? "Clone everything (memories, sessions, skills, state)", + noSkillsOption: p.noSkillsOption ?? "Don't seed bundled skills", + descriptionOptional: p.descriptionOptional ?? "Description (optional)", + modelOptional: p.modelOptional ?? "Model (optional)", + modelInherit: p.modelInherit ?? "Inherit from clone / default", + modelLoading: p.modelLoading ?? "Loading models…", + modelNone: + p.modelNone ?? "No authenticated providers — set a key first", + editModel: p.editModel ?? "Change model", + modelSaved: p.modelSaved ?? "Model updated", + modelSelect: p.modelSelect ?? "Select a model", + actions: p.actions ?? "Actions", + }; + }, [t.profiles]); + // Create modal const [createModalOpen, setCreateModalOpen] = useState(false); const [newName, setNewName] = useState(""); const [cloneFromDefault, setCloneFromDefault] = useState(true); + const [cloneAll, setCloneAll] = useState(false); + const [noSkills, setNoSkills] = useState(false); + const [newDescription, setNewDescription] = useState(""); const [creating, setCreating] = useState(false); + // Model picker (lazy-loaded the first time a picker is opened). modelChoice + // is a "slug\u0000model" key, or "" to inherit from clone/default. + const [modelChoices, setModelChoices] = useState< + { provider: string; model: string; label: string }[] | null + >(null); + const modelChoicesLoading = useRef(false); + const [modelChoice, setModelChoice] = useState(""); const closeCreateModal = useCallback(() => setCreateModalOpen(false), []); const createModalRef = useModalBehavior({ open: createModalOpen, @@ -95,10 +325,56 @@ export default function ProfilesPage() { // newer state when the user switches profiles or closes the editor. const activeSoulRequest = useRef(null); - const load = useCallback(() => { + // Inline description editor state + const [editingDescFor, setEditingDescFor] = useState(null); + const [descText, setDescText] = useState(""); + const [descSaving, setDescSaving] = useState(false); + const [describing, setDescribing] = useState(false); + // Tracks the latest description request (save / auto-describe) so a late + // response can't overwrite state for a different, newly-opened editor. + const activeDescRequest = useRef(null); + + // Inline model editor state + const [editingModelFor, setEditingModelFor] = useState(null); + const [modelEditChoice, setModelEditChoice] = useState(""); + const [modelSaving, setModelSaving] = useState(false); + + // Per-profile "set active" in-flight name + const [settingActive, setSettingActive] = useState(null); + + const modelKey = (provider: string | null, model: string | null) => + provider && model ? `${provider}\u0000${model}` : ""; + + const loadModelChoices = useCallback(() => { + if (modelChoices !== null || modelChoicesLoading.current) return; + modelChoicesLoading.current = true; api - .getProfiles() - .then((res) => setProfiles(res.profiles)) + .getModelOptions() + .then((res) => { + const flat: { provider: string; model: string; label: string }[] = []; + for (const prov of res.providers ?? []) { + for (const m of prov.models ?? []) { + flat.push({ + provider: prov.slug, + model: m, + label: `${prov.name} · ${m}`, + }); + } + } + setModelChoices(flat); + }) + .catch(() => setModelChoices([])) + .finally(() => { + modelChoicesLoading.current = false; + }); + }, [modelChoices]); + + const load = useCallback(() => { + Promise.all([api.getProfiles(), api.getActiveProfile().catch(() => null)]) + .then(([res, active]) => { + setProfiles(res.profiles); + setActiveInfo(active); + }) .catch((e) => showToast(`${t.status.error}: ${e}`, "error")) .finally(() => setLoading(false)); }, [showToast, t.status.error]); @@ -107,6 +383,19 @@ export default function ProfilesPage() { load(); }, [load]); + // Lazily load the model picker the first time the create modal opens. + useEffect(() => { + if (createModalOpen) loadModelChoices(); + }, [createModalOpen, loadModelChoices]); + + const isActive = useCallback( + (p: ProfileInfo) => + activeInfo != null && + (activeInfo.active === p.name || + (activeInfo.active === "default" && p.is_default)), + [activeInfo], + ); + const handleCreate = async () => { const name = newName.trim(); if (!name) { @@ -119,9 +408,28 @@ export default function ProfilesPage() { } setCreating(true); try { - await api.createProfile({ name, clone_from_default: cloneFromDefault }); + const cloning = cloneAll || cloneFromDefault; + const picked = modelChoice + ? modelChoices?.find( + (c) => `${c.provider}\u0000${c.model}` === modelChoice, + ) + : undefined; + await api.createProfile({ + name, + clone_from_default: cloneAll ? false : cloneFromDefault, + clone_all: cloneAll, + no_skills: cloning ? false : noSkills, + description: newDescription.trim() || undefined, + provider: picked?.provider, + model: picked?.model, + }); showToast(`${t.profiles.created}: ${name}`, "success"); setNewName(""); + setNewDescription(""); + setNoSkills(false); + setCloneAll(false); + setCloneFromDefault(true); + setModelChoice(""); setCreateModalOpen(false); load(); } catch (e) { @@ -145,10 +453,7 @@ export default function ProfilesPage() { } try { await api.renameProfile(renamingFrom, target); - showToast( - `${t.profiles.renamed}: ${renamingFrom} → ${target}`, - "success", - ); + showToast(`${t.profiles.renamed}: ${renamingFrom} → ${target}`, "success"); setRenamingFrom(null); setRenameTo(""); load(); @@ -157,13 +462,42 @@ export default function ProfilesPage() { } }; + const handleSetActive = async (name: string) => { + setSettingActive(name); + try { + // The backend normalizes/validates the name; trust the canonical + // value it returns rather than the raw input. + const { active } = await api.setActiveProfile(name); + showToast(`${L.activeSet}: ${active}`, "success"); + setActiveInfo((prev) => + prev ? { ...prev, active } : { active, current: active }, + ); + } catch (e) { + showToast(`${t.status.error}: ${e}`, "error"); + } finally { + setSettingActive(null); + } + }; + + // Closes whichever editor dialog is open (model / description / SOUL). + const closeEditor = useCallback(() => { + activeSoulRequest.current = null; + activeDescRequest.current = null; + setEditingModelFor(null); + setEditingDescFor(null); + setEditingSoulFor(null); + }, []); + const openSoulEditor = useCallback( async (name: string) => { + // Re-selecting the action for the already-open editor collapses it, + // matching the chevron-down affordance in the actions menu. if (editingSoulFor === name) { - activeSoulRequest.current = null; - setEditingSoulFor(null); + closeEditor(); return; } + setEditingDescFor(null); + setEditingModelFor(null); setEditingSoulFor(name); setSoulText(""); activeSoulRequest.current = name; @@ -178,7 +512,7 @@ export default function ProfilesPage() { } } }, - [editingSoulFor, showToast, t.status.error], + [closeEditor, editingSoulFor, showToast, t.status.error], ); const handleSaveSoul = async (name: string) => { @@ -186,6 +520,8 @@ export default function ProfilesPage() { try { await api.updateProfileSoul(name, soulText); showToast(`${t.profiles.soulSaved}: ${name}`, "success"); + activeSoulRequest.current = null; + setEditingSoulFor(null); } catch (e) { showToast(`${t.status.error}: ${e}`, "error"); } finally { @@ -193,6 +529,140 @@ export default function ProfilesPage() { } }; + const openDescEditor = useCallback( + (p: ProfileInfo) => { + if (editingDescFor === p.name) { + closeEditor(); + return; + } + activeDescRequest.current = p.name; + setEditingSoulFor(null); + setEditingModelFor(null); + setEditingDescFor(p.name); + setDescText(p.description ?? ""); + }, + [closeEditor, editingDescFor], + ); + + const handleSaveDesc = async (name: string) => { + setDescSaving(true); + activeDescRequest.current = name; + try { + const res = await api.updateProfileDescription(name, descText); + // Profile-list state always reflects the persisted result, but only + // touch the open editor if it's still showing this profile. + setProfiles((prev) => + prev.map((p) => + p.name === name + ? { + ...p, + description: res.description, + description_auto: res.description_auto, + } + : p, + ), + ); + if (activeDescRequest.current === name) { + showToast(`${L.descriptionSaved}: ${name}`, "success"); + setEditingDescFor(null); + } + } catch (e) { + if (activeDescRequest.current === name) { + showToast(`${t.status.error}: ${e}`, "error"); + } + } finally { + setDescSaving(false); + } + }; + + const handleAutoDescribe = async (name: string) => { + setDescribing(true); + activeDescRequest.current = name; + try { + const res = await api.describeProfileAuto(name); + const current = activeDescRequest.current === name; + if (res.ok && res.description != null) { + if (current) setDescText(res.description); + setProfiles((prev) => + prev.map((p) => + p.name === name + ? { + ...p, + description: res.description ?? "", + description_auto: res.description_auto, + } + : p, + ), + ); + if (current) showToast(`${L.descriptionSaved}: ${name}`, "success"); + } else if (current) { + showToast(`${L.describeFailed}: ${res.reason}`, "error"); + } + } catch (e) { + if (activeDescRequest.current === name) { + showToast(`${t.status.error}: ${e}`, "error"); + } + } finally { + setDescribing(false); + } + }; + + const openModelEditor = useCallback( + (p: ProfileInfo) => { + if (editingModelFor === p.name) { + closeEditor(); + return; + } + setEditingSoulFor(null); + setEditingDescFor(null); + setEditingModelFor(p.name); + setModelEditChoice(modelKey(p.provider, p.model)); + loadModelChoices(); + }, + [closeEditor, editingModelFor, loadModelChoices], + ); + + const handleSaveModel = async (name: string) => { + const picked = modelEditChoice + ? modelChoices?.find( + (c) => `${c.provider}\u0000${c.model}` === modelEditChoice, + ) + : undefined; + if (!picked) return; + setModelSaving(true); + try { + await api.setProfileModel(name, picked.provider, picked.model); + showToast(`${L.modelSaved}: ${picked.model}`, "success"); + setProfiles((prev) => + prev.map((p) => + p.name === name + ? { ...p, model: picked.model, provider: picked.provider } + : p, + ), + ); + setEditingModelFor(null); + } catch (e) { + showToast(`${t.status.error}: ${e}`, "error"); + } finally { + setModelSaving(false); + } + }; + + // Exactly one editor is open at a time; derive which profile + kind so a + // single dialog can render the right body. + const editorName = editingModelFor ?? editingDescFor ?? editingSoulFor; + const editorKind: "model" | "desc" | "soul" | null = editingModelFor + ? "model" + : editingDescFor + ? "desc" + : editingSoulFor + ? "soul" + : null; + const editorModalRef = useModalBehavior({ + open: editorName != null, + onClose: closeEditor, + }); + const handleCopyTerminalCommand = async (name: string) => { let cmd: string; try { @@ -227,6 +697,16 @@ export default function ProfilesPage() { }); const pendingName = profileDelete.pendingId; + const pendingProfile = pendingName + ? profiles.find((p) => p.name === pendingName) + : undefined; + const deleteMessage = (() => { + if (!pendingName) return t.profiles.confirmDeleteMessage; + const base = t.profiles.confirmDeleteMessage.replace("{name}", pendingName); + return pendingProfile?.gateway_running + ? `${base}\n\n${L.gatewayRunningWarning}` + : base; + })(); // Put "Create" button in page header useLayoutEffect(() => { @@ -244,6 +724,8 @@ export default function ProfilesPage() { }; }, [setEnd, t.common.create, loading]); + const cloning = cloneAll || cloneFromDefault; + if (loading) { return (
@@ -287,7 +765,12 @@ export default function ProfilesPage() { aria-modal="true" aria-labelledby="create-profile-title" > -
+