mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
214b7e070f
commit
7fb8a6b5c5
7 changed files with 1494 additions and 176 deletions
|
|
@ -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 `<profile> 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
|
||||
<name> --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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 ──
|
||||
|
|
|
|||
|
|
@ -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<ActiveProfileInfo>("/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<ProfileDescribeAutoResult>(
|
||||
`/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 {
|
||||
|
|
|
|||
|
|
@ -513,12 +513,12 @@ export default function EnvPage() {
|
|||
const categories = ["tool", "messaging", "setting"];
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
tool: t.app.nav.keys,
|
||||
messaging: t.common.messaging,
|
||||
messaging: t.common.gateway ?? "Gateway",
|
||||
setting: t.app.nav.config,
|
||||
};
|
||||
const CATEGORY_META_HINTS: Record<string, string | undefined> = {
|
||||
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}
|
||||
</CardDescription>
|
||||
|
||||
{section.hint && (
|
||||
<CardDescription className="text-text-tertiary">
|
||||
{section.hint}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{hasContent && (
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue