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:
Austin Pickett 2026-06-03 10:37:36 -04:00 committed by GitHub
parent 214b7e070f
commit 7fb8a6b5c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1494 additions and 176 deletions

View file

@ -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
# ---------------------------------------------------------------------------

View file

@ -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

View file

@ -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 plugins script. Check the Network tab (dashboard-plugins/…) and the servers 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: {

View file

@ -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 ──

View file

@ -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 {

View file

@ -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