diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index bf6de16dffdd3..ac69f680ee502 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -975,9 +975,16 @@ def rename_profile(old_name: str, new_name: str) -> Path: if new_dir.exists(): raise FileExistsError(f"Profile '{new_name}' already exists.") - # 1. Stop gateway if running + # 1. Tear down the launchd/systemd service unconditionally — gateway.pid + # may be stale (the file's pid is dead but launchd has KeepAlive=true + # and is silently respawning a fresh process under the old profile + # name). If we leave the plist installed and only check pid, that + # runaway process re-bootstraps the old directory after the rename + # and the user sees both names in the listing. _cleanup_gateway_service + # is internally guarded on plist/unit existence, so calling it on + # profiles without a service is a safe no-op. + _cleanup_gateway_service(old_name, old_dir) if _check_gateway_running(old_dir): - _cleanup_gateway_service(old_name, old_dir) _stop_gateway_process(old_dir) # 2. Rename directory diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 8c33a383e5f39..f702d1c0017c8 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2101,6 +2101,429 @@ async def delete_cron_job(job_id: str): return {"ok": True} +# --------------------------------------------------------------------------- +# Profile management endpoints +# --------------------------------------------------------------------------- + + +class ProfileCreate(BaseModel): + name: str + clone_from: Optional[str] = None + clone_all: bool = False + clone_config: bool = False + no_alias: bool = False + + +class ProfileRename(BaseModel): + new_name: str + + +class ProfileExport(BaseModel): + output_path: Optional[str] = None + + +class ProfileImport(BaseModel): + archive_path: str + name: Optional[str] = None + + +def _profile_to_dict(info) -> Dict[str, Any]: + """Serialise a ProfileInfo dataclass to a JSON-friendly dict.""" + return { + "name": info.name, + "path": str(info.path), + "is_default": info.is_default, + "gateway_running": info.gateway_running, + "model": info.model, + "provider": info.provider, + "has_env": info.has_env, + "skill_count": info.skill_count, + "alias_path": str(info.alias_path) if info.alias_path else None, + } + + +# Bot-credential env vars that lock to a single gateway at runtime — sharing +# the value across profiles guarantees only one gateway will succeed at +# starting that platform. Keep this aligned with the platform adapters' +# ``_acquire_platform_lock(...)`` calls in gateway/platforms/*.py. +_EXCLUSIVE_TOKEN_ENV_KEYS = ( + "WEIXIN_TOKEN", + "TELEGRAM_BOT_TOKEN", + "DISCORD_BOT_TOKEN", +) + + +def _read_env_subset(env_path: Path, keys: tuple) -> Dict[str, str]: + """Read a tiny subset of KEY=value pairs from a .env file. + + Tolerates surrounding quotes and ignores comments. Returns only the keys + we asked for and only when their value is non-empty. + """ + out: Dict[str, str] = {} + try: + text = env_path.read_text(encoding="utf-8", errors="replace") + except OSError: + return out + for raw in text.splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + if key not in keys: + continue + value = value.strip() + if (value.startswith('"') and value.endswith('"')) or ( + value.startswith("'") and value.endswith("'") + ): + value = value[1:-1] + if value: + out[key] = value + return out + + +def _detect_shared_tokens(profiles_list) -> Dict[str, List[Dict[str, Any]]]: + """Return a map ``profile_name -> [{key, with}]`` for each exclusive + credential env var that two or more profiles set to the same value. + + Trying to start two gateways with identical Weixin/Telegram/Discord + tokens always loses one of them to ``_acquire_platform_lock`` — surface + this on the dashboard before users hit it. + """ + from collections import defaultdict + value_to_profiles: Dict[tuple, List[str]] = defaultdict(list) + for info in profiles_list: + env_path = info.path / ".env" + if not env_path.exists(): + continue + env_values = _read_env_subset(env_path, _EXCLUSIVE_TOKEN_ENV_KEYS) + for key, value in env_values.items(): + value_to_profiles[(key, value)].append(info.name) + + result: Dict[str, List[Dict[str, Any]]] = defaultdict(list) + for (key, _value), names in value_to_profiles.items(): + if len(names) < 2: + continue + for name in names: + result[name].append( + {"key": key, "with": [n for n in names if n != name]} + ) + return dict(result) + + +@app.get("/api/profiles") +async def list_profiles_endpoint(): + from hermes_cli import profiles as profiles_mod + active = profiles_mod.get_active_profile() + raw = profiles_mod.list_profiles() + shared_map = _detect_shared_tokens(raw) + items = [] + for info in raw: + d = _profile_to_dict(info) + d["shared_tokens"] = shared_map.get(info.name, []) + items.append(d) + return {"profiles": items, "active": active} + + +@app.get("/api/profiles/active") +async def get_active_profile_endpoint(): + from hermes_cli import profiles as profiles_mod + return {"active": profiles_mod.get_active_profile()} + + +@app.post("/api/profiles") +async def create_profile_endpoint(body: ProfileCreate): + from hermes_cli import profiles as profiles_mod + try: + path = profiles_mod.create_profile( + name=body.name, + clone_from=body.clone_from, + clone_all=body.clone_all, + clone_config=body.clone_config, + no_alias=body.no_alias, + ) + except (ValueError, FileExistsError, FileNotFoundError) as e: + raise HTTPException(status_code=400, detail=str(e)) + 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)} + + +@app.patch("/api/profiles/{name}") +async def rename_profile_endpoint(name: str, body: ProfileRename): + from hermes_cli import profiles as profiles_mod + try: + path = profiles_mod.rename_profile(name, body.new_name) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except (ValueError, FileExistsError) as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + _log.exception("PATCH /api/profiles/%s failed", name) + raise HTTPException(status_code=500, detail=str(e)) + return {"ok": True, "name": body.new_name, "path": str(path)} + + +@app.delete("/api/profiles/{name}") +async def delete_profile_endpoint(name: str): + """Delete a profile. + + The dashboard performs the typed-name confirmation in the UI, so the + backend always passes ``yes=True`` to skip the CLI's interactive prompt. + """ + from hermes_cli import profiles as profiles_mod + try: + path = profiles_mod.delete_profile(name, yes=True) + 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("DELETE /api/profiles/%s failed", name) + raise HTTPException(status_code=500, detail=str(e)) + return {"ok": True, "path": str(path)} + + +@app.post("/api/profiles/{name}/activate") +async def activate_profile_endpoint(name: str): + from hermes_cli import profiles as profiles_mod + try: + profiles_mod.set_active_profile(name) + except (ValueError, FileNotFoundError) as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + _log.exception("POST /api/profiles/%s/activate failed", name) + raise HTTPException(status_code=500, detail=str(e)) + return {"ok": True, "active": name} + + +@app.post("/api/profiles/{name}/export") +async def export_profile_endpoint(name: str, body: ProfileExport): + """Export a profile to a tar.gz archive on the server filesystem. + + ``output_path`` may be omitted; the server then writes to + ``$HERMES_HOME/exports/-.tar.gz``. + """ + from hermes_cli import profiles as profiles_mod + if body.output_path: + output = Path(body.output_path).expanduser() + else: + ts = time.strftime("%Y%m%d-%H%M%S") + exports_dir = get_hermes_home() / "exports" + exports_dir.mkdir(parents=True, exist_ok=True) + output = exports_dir / f"{name}-{ts}.tar.gz" + try: + result = profiles_mod.export_profile(name, str(output)) + 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/%s/export failed", name) + raise HTTPException(status_code=500, detail=str(e)) + return {"ok": True, "path": str(result)} + + +@app.post("/api/profiles/import") +async def import_profile_endpoint(body: ProfileImport): + from hermes_cli import profiles as profiles_mod + archive = Path(body.archive_path).expanduser() + try: + path = profiles_mod.import_profile(str(archive), name=body.name) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except (ValueError, FileExistsError) as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + _log.exception("POST /api/profiles/import failed") + raise HTTPException(status_code=500, detail=str(e)) + return {"ok": True, "path": str(path), "name": path.name} + + +# --------------------------------------------------------------------------- +# Per-profile SOUL.md and model config (used by the Profiles dashboard page) +# --------------------------------------------------------------------------- + + +class ProfileSoulUpdate(BaseModel): + content: str + + +class ProfileModelUpdate(BaseModel): + model: Optional[str] = None + provider: Optional[str] = None + + +def _resolve_profile_dir(name: str) -> Path: + """Resolve a profile name to its directory or raise an HTTPException.""" + from hermes_cli import profiles as profiles_mod + try: + profiles_mod.validate_profile_name(name) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + if not profiles_mod.profile_exists(name): + raise HTTPException(status_code=404, detail=f"Profile '{name}' does not exist.") + return profiles_mod.get_profile_dir(name) + + +@app.get("/api/profiles/{name}/soul") +async def get_profile_soul(name: str): + profile_dir = _resolve_profile_dir(name) + soul_path = profile_dir / "SOUL.md" + if soul_path.exists(): + try: + content = soul_path.read_text(encoding="utf-8") + except OSError as e: + raise HTTPException(status_code=500, detail=f"Could not read SOUL.md: {e}") + return {"content": content, "exists": True, "path": str(soul_path)} + return {"content": "", "exists": False, "path": str(soul_path)} + + +@app.put("/api/profiles/{name}/soul") +async def update_profile_soul(name: str, body: ProfileSoulUpdate): + profile_dir = _resolve_profile_dir(name) + soul_path = profile_dir / "SOUL.md" + try: + soul_path.write_text(body.content, encoding="utf-8") + except OSError as e: + _log.exception("PUT /api/profiles/%s/soul failed", name) + raise HTTPException(status_code=500, detail=f"Could not write SOUL.md: {e}") + return {"ok": True, "path": str(soul_path)} + + +@app.get("/api/profiles/{name}/model") +async def get_profile_model(name: str): + from hermes_cli.profiles import _read_config_model + profile_dir = _resolve_profile_dir(name) + model, provider = _read_config_model(profile_dir) + return {"model": model, "provider": provider} + + +def _spawn_per_profile_gateway_restart(profile_dir: Path) -> subprocess.Popen: + """Spawn ``hermes gateway restart`` against a specific profile. + + Mirrors :func:`_spawn_hermes_action` but overrides ``HERMES_HOME`` so the + spawned ``hermes`` process operates on the target profile rather than the + one the dashboard itself is running under. Each profile keeps its own + ``logs/gateway-restart.log`` so concurrent restarts don't interleave. + """ + log_dir = profile_dir / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / "gateway-restart.log" + log_file = open(log_path, "ab", buffering=0) + log_file.write( + f"\n=== gateway-restart started {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n".encode() + ) + + cmd = [sys.executable, "-m", "hermes_cli.main", "gateway", "restart"] + # Strip exclusive bot-credential env vars from the inherited environment. + # The dashboard's own process loaded them from its HERMES_HOME's .env at + # boot, and ``{**os.environ}`` would otherwise shovel them into every + # spawned per-profile gateway — locking the target profile out of any + # platform whose token actually lives in the dashboard's profile. Let + # the child reload only what its own .env supplies. + env = { + k: v + for k, v in os.environ.items() + if k not in _EXCLUSIVE_TOKEN_ENV_KEYS + } + env["HERMES_HOME"] = str(profile_dir) + env["HERMES_NONINTERACTIVE"] = "1" + + popen_kwargs: Dict[str, Any] = { + "cwd": str(PROJECT_ROOT), + "stdin": subprocess.DEVNULL, + "stdout": log_file, + "stderr": subprocess.STDOUT, + "env": env, + } + if sys.platform == "win32": + popen_kwargs["creationflags"] = ( + subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined] + | getattr(subprocess, "DETACHED_PROCESS", 0) + ) + else: + popen_kwargs["start_new_session"] = True + + return subprocess.Popen(cmd, **popen_kwargs) + + +@app.post("/api/profiles/{name}/gateway/restart") +async def restart_profile_gateway(name: str): + """Restart the gateway for a specific profile. + + Spawns ``hermes gateway restart`` with ``HERMES_HOME`` pointing at the + target profile's directory, so the action operates on that profile + regardless of which profile the dashboard itself is running under. + """ + profile_dir = _resolve_profile_dir(name) + try: + proc = _spawn_per_profile_gateway_restart(profile_dir) + except Exception as exc: + _log.exception("Failed to restart gateway for profile %s", name) + raise HTTPException( + status_code=500, detail=f"Failed to restart gateway: {exc}" + ) + return {"ok": True, "pid": proc.pid, "name": name} + + +@app.put("/api/profiles/{name}/model") +async def update_profile_model(name: str, body: ProfileModelUpdate): + from utils import atomic_yaml_write + profile_dir = _resolve_profile_dir(name) + config_path = profile_dir / "config.yaml" + + if config_path.exists(): + try: + with open(config_path, encoding="utf-8") as f: + cfg = yaml.safe_load(f) or {} + except (yaml.YAMLError, OSError) as e: + raise HTTPException(status_code=500, detail=f"Could not read config.yaml: {e}") + else: + cfg = {} + if not isinstance(cfg, dict): + raise HTTPException(status_code=500, detail="config.yaml is not a mapping at the root") + + # Normalise legacy `model: ""` string form into dict form before mutating. + existing_model = cfg.get("model") + if isinstance(existing_model, str): + model_block: Dict[str, Any] = {"default": existing_model} + elif isinstance(existing_model, dict): + model_block = dict(existing_model) + else: + model_block = {} + + new_model = (body.model or "").strip() + new_provider = (body.provider or "").strip() + + if new_model: + model_block["default"] = new_model + # Drop the legacy ``model.model`` key if present so we don't keep two + # sources of truth in the same file. + model_block.pop("model", None) + else: + model_block.pop("default", None) + model_block.pop("model", None) + + if new_provider: + model_block["provider"] = new_provider + else: + model_block.pop("provider", None) + + if model_block: + cfg["model"] = model_block + else: + cfg.pop("model", None) + + try: + atomic_yaml_write(config_path, cfg) + except OSError as e: + _log.exception("PUT /api/profiles/%s/model failed", name) + raise HTTPException(status_code=500, detail=f"Could not write config.yaml: {e}") + return {"ok": True, "model": new_model or None, "provider": new_provider or None} + + # --------------------------------------------------------------------------- # Skills & Tools endpoints # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index e7b3b03305b94..8cb4f561f8294 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -585,6 +585,376 @@ def test_cron_job_not_found(self): resp = self.client.get("/api/cron/jobs/nonexistent-id") assert resp.status_code == 404 + # --- Profile management --- + + def test_profiles_list_includes_default(self, monkeypatch): + # Create the default home dir so list_profiles() reports it. + from hermes_constants import get_hermes_home + get_hermes_home().mkdir(parents=True, exist_ok=True) + + resp = self.client.get("/api/profiles") + assert resp.status_code == 200 + data = resp.json() + assert "profiles" in data + assert "active" in data + names = [p["name"] for p in data["profiles"]] + assert "default" in names + default_entry = next(p for p in data["profiles"] if p["name"] == "default") + assert default_entry["is_default"] is True + + def test_profiles_create_and_delete(self, monkeypatch): + # Stub gateway service cleanup — it shells out to launchctl/systemctl + # which we don't want to invoke from tests. + import hermes_cli.profiles as profiles_mod + monkeypatch.setattr(profiles_mod, "_cleanup_gateway_service", lambda *a, **kw: None) + + resp = self.client.post("/api/profiles", json={"name": "test-prof"}) + assert resp.status_code == 200 + body = resp.json() + assert body["ok"] is True + assert body["name"] == "test-prof" + + listing = self.client.get("/api/profiles").json() + assert "test-prof" in [p["name"] for p in listing["profiles"]] + + resp = self.client.delete("/api/profiles/test-prof") + assert resp.status_code == 200 + listing = self.client.get("/api/profiles").json() + assert "test-prof" not in [p["name"] for p in listing["profiles"]] + + def test_profiles_create_rejects_default_name(self): + resp = self.client.post("/api/profiles", json={"name": "default"}) + assert resp.status_code == 400 + + def test_profiles_create_rejects_invalid_name(self): + resp = self.client.post("/api/profiles", json={"name": "Has Spaces"}) + assert resp.status_code == 400 + + def test_profiles_create_rejects_duplicate(self, monkeypatch): + import hermes_cli.profiles as profiles_mod + monkeypatch.setattr(profiles_mod, "_cleanup_gateway_service", lambda *a, **kw: None) + + first = self.client.post("/api/profiles", json={"name": "dupe-prof"}) + assert first.status_code == 200 + second = self.client.post("/api/profiles", json={"name": "dupe-prof"}) + assert second.status_code == 400 + + # Cleanup so the temp dir leaves no residue between tests on this worker. + self.client.delete("/api/profiles/dupe-prof") + + def test_profiles_delete_default_forbidden(self): + resp = self.client.delete("/api/profiles/default") + assert resp.status_code == 400 + + def test_profiles_delete_not_found(self): + resp = self.client.delete("/api/profiles/does-not-exist") + assert resp.status_code == 404 + + def test_profiles_rename(self, monkeypatch): + import hermes_cli.profiles as profiles_mod + monkeypatch.setattr(profiles_mod, "_cleanup_gateway_service", lambda *a, **kw: None) + + self.client.post("/api/profiles", json={"name": "rename-src"}) + resp = self.client.patch( + "/api/profiles/rename-src", + json={"new_name": "rename-dst"}, + ) + assert resp.status_code == 200 + names = [p["name"] for p in self.client.get("/api/profiles").json()["profiles"]] + assert "rename-src" not in names + assert "rename-dst" in names + + self.client.delete("/api/profiles/rename-dst") + + def test_profiles_rename_not_found(self): + resp = self.client.patch( + "/api/profiles/missing", + json={"new_name": "whatever"}, + ) + assert resp.status_code == 404 + + def test_profiles_listing_flags_shared_exclusive_tokens(self, monkeypatch): + """Two profiles holding the same WEIXIN_TOKEN must be flagged as + sharing it; an unrelated profile must not be flagged.""" + import hermes_cli.profiles as profiles_mod + monkeypatch.setattr(profiles_mod, "_cleanup_gateway_service", lambda *a, **kw: None) + + # Three profiles: two with identical Weixin token, one with a unique + # Telegram token (which therefore shares with nobody). + for name in ("share-a", "share-b", "share-c"): + self.client.post("/api/profiles", json={"name": name}) + + a_dir = profiles_mod.get_profile_dir("share-a") + b_dir = profiles_mod.get_profile_dir("share-b") + c_dir = profiles_mod.get_profile_dir("share-c") + + (a_dir / ".env").write_text( + "WEIXIN_TOKEN=shared-weixin-12345\n", encoding="utf-8" + ) + (b_dir / ".env").write_text( + 'WEIXIN_TOKEN="shared-weixin-12345"\n' # quoted, must still match + "TELEGRAM_BOT_TOKEN=unique-tg\n", + encoding="utf-8", + ) + (c_dir / ".env").write_text( + "TELEGRAM_BOT_TOKEN=different-tg\n", encoding="utf-8" + ) + + listing = self.client.get("/api/profiles").json() + by_name = {p["name"]: p for p in listing["profiles"]} + + assert by_name["share-a"]["shared_tokens"] == [ + {"key": "WEIXIN_TOKEN", "with": ["share-b"]} + ] + assert by_name["share-b"]["shared_tokens"] == [ + {"key": "WEIXIN_TOKEN", "with": ["share-a"]} + ] + assert by_name["share-c"]["shared_tokens"] == [] + + for name in ("share-a", "share-b", "share-c"): + self.client.delete(f"/api/profiles/{name}") + + def test_profiles_rename_always_tears_down_service(self, monkeypatch): + """Regression: rename must call ``_cleanup_gateway_service`` even when + ``_check_gateway_running`` reports False. + + The pid file can lag behind the launchd state — KeepAlive=true plists + silently respawn the process after a crash and the new pid never + makes it back into ``gateway.pid``. If rename only tore down the + service when ``_check_gateway_running`` returned True, the runaway + launchd job would re-bootstrap the old profile name after the + directory got renamed away. + """ + import hermes_cli.profiles as profiles_mod + calls: list = [] + + def _fake_cleanup(name, profile_dir): + calls.append((name, str(profile_dir))) + + monkeypatch.setattr(profiles_mod, "_cleanup_gateway_service", _fake_cleanup) + # Force the (broken-in-the-wild) "no gateway running" state so we + # exercise the bug path the fix targets. + monkeypatch.setattr(profiles_mod, "_check_gateway_running", lambda *a, **kw: False) + monkeypatch.setattr(profiles_mod, "_stop_gateway_process", lambda *a, **kw: None) + + self.client.post("/api/profiles", json={"name": "stale-pid-prof"}) + + resp = self.client.patch( + "/api/profiles/stale-pid-prof", + json={"new_name": "stale-pid-prof-renamed"}, + ) + assert resp.status_code == 200 + + assert len(calls) == 1, ( + "rename_profile must call _cleanup_gateway_service once even when " + "the gateway is reported as not running" + ) + assert calls[0][0] == "stale-pid-prof" + + self.client.delete("/api/profiles/stale-pid-prof-renamed") + + def test_profiles_activate_and_get_active(self, monkeypatch): + import hermes_cli.profiles as profiles_mod + monkeypatch.setattr(profiles_mod, "_cleanup_gateway_service", lambda *a, **kw: None) + + self.client.post("/api/profiles", json={"name": "active-test"}) + resp = self.client.post("/api/profiles/active-test/activate") + assert resp.status_code == 200 + assert resp.json()["active"] == "active-test" + + active = self.client.get("/api/profiles/active").json() + assert active["active"] == "active-test" + + # Restore default before deletion so cleanup sees a sane state. + self.client.post("/api/profiles/default/activate") + self.client.delete("/api/profiles/active-test") + + def test_profiles_activate_unknown(self): + resp = self.client.post("/api/profiles/no-such-profile/activate") + assert resp.status_code == 400 + + def test_profile_soul_round_trip(self, monkeypatch): + import hermes_cli.profiles as profiles_mod + monkeypatch.setattr(profiles_mod, "_cleanup_gateway_service", lambda *a, **kw: None) + + self.client.post("/api/profiles", json={"name": "soul-prof"}) + + # Initial GET — profile created with seeded SOUL.md, so exists=True. + get1 = self.client.get("/api/profiles/soul-prof/soul") + assert get1.status_code == 200 + assert get1.json()["exists"] is True + + put = self.client.put( + "/api/profiles/soul-prof/soul", + json={"content": "# Edited\n\nThis is the new soul."}, + ) + assert put.status_code == 200 + + get2 = self.client.get("/api/profiles/soul-prof/soul") + assert get2.status_code == 200 + assert get2.json()["content"] == "# Edited\n\nThis is the new soul." + + self.client.delete("/api/profiles/soul-prof") + + def test_profile_soul_unknown_profile_404(self): + resp = self.client.get("/api/profiles/nonexistent/soul") + assert resp.status_code == 404 + resp = self.client.put( + "/api/profiles/nonexistent/soul", + json={"content": "x"}, + ) + assert resp.status_code == 404 + + def test_profile_model_round_trip(self, monkeypatch): + import hermes_cli.profiles as profiles_mod + monkeypatch.setattr(profiles_mod, "_cleanup_gateway_service", lambda *a, **kw: None) + + self.client.post("/api/profiles", json={"name": "model-prof"}) + + put = self.client.put( + "/api/profiles/model-prof/model", + json={"model": "test-org/test-model", "provider": "openrouter"}, + ) + assert put.status_code == 200 + + got = self.client.get("/api/profiles/model-prof/model").json() + assert got["model"] == "test-org/test-model" + assert got["provider"] == "openrouter" + + # Second write should overwrite, not append. + self.client.put( + "/api/profiles/model-prof/model", + json={"model": "another/model", "provider": None}, + ) + got2 = self.client.get("/api/profiles/model-prof/model").json() + assert got2["model"] == "another/model" + assert got2["provider"] is None + + # Empty model + empty provider clears the model block entirely. + self.client.put( + "/api/profiles/model-prof/model", + json={"model": "", "provider": ""}, + ) + got3 = self.client.get("/api/profiles/model-prof/model").json() + assert got3["model"] is None + assert got3["provider"] is None + + self.client.delete("/api/profiles/model-prof") + + def test_profile_gateway_restart_spawns_with_profile_home(self, monkeypatch): + """Per-profile restart must override HERMES_HOME for the spawned process.""" + import hermes_cli.profiles as profiles_mod + import hermes_cli.web_server as web_server + monkeypatch.setattr(profiles_mod, "_cleanup_gateway_service", lambda *a, **kw: None) + + self.client.post("/api/profiles", json={"name": "restart-prof"}) + + captured: dict = {} + + class _FakePopen: + def __init__(self, cmd, **kwargs): + captured["cmd"] = cmd + captured["env"] = kwargs.get("env", {}) + self.pid = 99999 + + monkeypatch.setattr(web_server.subprocess, "Popen", _FakePopen) + + resp = self.client.post("/api/profiles/restart-prof/gateway/restart") + assert resp.status_code == 200 + body = resp.json() + assert body["ok"] is True + assert body["pid"] == 99999 + assert body["name"] == "restart-prof" + + # The spawned process must have HERMES_HOME pointing at the profile dir, + # not the dashboard's own HERMES_HOME. + profile_dir = profiles_mod.get_profile_dir("restart-prof") + assert captured["env"]["HERMES_HOME"] == str(profile_dir) + assert captured["cmd"][-2:] == ["gateway", "restart"] + + self.client.delete("/api/profiles/restart-prof") + + def test_profile_gateway_restart_strips_exclusive_tokens_from_env(self, monkeypatch): + """Spawned per-profile gateway must not inherit Weixin/Telegram/Discord + tokens from the dashboard's own process env. + + The dashboard loads its HERMES_HOME's .env at boot, so its + ``os.environ`` carries that profile's bot credentials. Without + stripping, every spawned ``hermes gateway restart --profile X`` for + a *different* X would pick up those credentials and crash on the + platform-lock acquisition (or worse, hijack a token that legitimately + belongs to a different profile). + """ + import hermes_cli.profiles as profiles_mod + import hermes_cli.web_server as web_server + monkeypatch.setattr(profiles_mod, "_cleanup_gateway_service", lambda *a, **kw: None) + + # Simulate the dashboard process having default-profile tokens in + # its environment. + monkeypatch.setenv("WEIXIN_TOKEN", "leaked-weixin-token-from-default") + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "leaked-telegram-token") + monkeypatch.setenv("DISCORD_BOT_TOKEN", "leaked-discord-token") + monkeypatch.setenv("OPENROUTER_API_KEY", "should-pass-through-fine") + + captured: dict = {} + + class _FakePopen: + def __init__(self, cmd, **kwargs): + captured["env"] = kwargs.get("env", {}) + self.pid = 99999 + + monkeypatch.setattr(web_server.subprocess, "Popen", _FakePopen) + + self.client.post("/api/profiles", json={"name": "leak-test-prof"}) + resp = self.client.post("/api/profiles/leak-test-prof/gateway/restart") + assert resp.status_code == 200 + + env = captured["env"] + assert "WEIXIN_TOKEN" not in env + assert "TELEGRAM_BOT_TOKEN" not in env + assert "DISCORD_BOT_TOKEN" not in env + # Non-exclusive vars (LLM API keys, PATH, …) must still pass through + # so the spawned interpreter can find dependencies. + assert env.get("OPENROUTER_API_KEY") == "should-pass-through-fine" + assert env.get("HERMES_HOME") == str( + profiles_mod.get_profile_dir("leak-test-prof") + ) + + self.client.delete("/api/profiles/leak-test-prof") + + def test_profile_gateway_restart_unknown_404(self): + resp = self.client.post("/api/profiles/no-such-profile/gateway/restart") + assert resp.status_code == 404 + + def test_profile_model_normalises_legacy_string_form(self, monkeypatch): + # Some profiles still ship the legacy ``model: ""`` string form + # at the root of config.yaml. The PUT must rewrite it as a dict + # without losing other top-level keys (we use `terminal:` as a + # canary because it is a known DEFAULT_CONFIG section). + import hermes_cli.profiles as profiles_mod + monkeypatch.setattr(profiles_mod, "_cleanup_gateway_service", lambda *a, **kw: None) + + self.client.post("/api/profiles", json={"name": "legacy-model-prof"}) + config_path = profiles_mod.get_profile_dir("legacy-model-prof") / "config.yaml" + config_path.write_text( + "model: legacy-string-slug\nterminal:\n cwd: /tmp\n", + encoding="utf-8", + ) + + self.client.put( + "/api/profiles/legacy-model-prof/model", + json={"model": "shiny/new-slug", "provider": "nous"}, + ) + + import yaml as _yaml + with open(config_path, encoding="utf-8") as f: + cfg = _yaml.safe_load(f) + assert cfg["model"] == {"default": "shiny/new-slug", "provider": "nous"} + # Sibling sections must survive the rewrite. + assert cfg["terminal"] == {"cwd": "/tmp"} + + self.client.delete("/api/profiles/legacy-model-prof") + def test_skills_list(self): resp = self.client.get("/api/skills") assert resp.status_code == 200 diff --git a/web/src/App.tsx b/web/src/App.tsx index f4285a21b47b7..93b76b7554457 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -38,6 +38,7 @@ import { Sparkles, Star, Terminal, + Users, Wrench, X, Zap, @@ -57,6 +58,7 @@ import SessionsPage from "@/pages/SessionsPage"; import LogsPage from "@/pages/LogsPage"; import AnalyticsPage from "@/pages/AnalyticsPage"; import CronPage from "@/pages/CronPage"; +import ProfilesPage from "@/pages/ProfilesPage"; import SkillsPage from "@/pages/SkillsPage"; import ChatPage from "@/pages/ChatPage"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; @@ -86,6 +88,7 @@ const BUILTIN_ROUTES_CORE: Record = { "/logs": LogsPage, "/cron": CronPage, "/skills": SkillsPage, + "/profiles": ProfilesPage, "/config": ConfigPage, "/env": EnvPage, "/docs": DocsPage, @@ -107,6 +110,7 @@ const BUILTIN_NAV_REST: NavItem[] = [ { path: "/logs", labelKey: "logs", label: "Logs", icon: FileText }, { path: "/cron", labelKey: "cron", label: "Cron", icon: Clock }, { path: "/skills", labelKey: "skills", label: "Skills", icon: Package }, + { path: "/profiles", labelKey: "profiles", label: "Profiles", icon: Users }, { path: "/config", labelKey: "config", label: "Config", icon: Settings }, { path: "/env", labelKey: "keys", label: "Keys", icon: KeyRound }, { @@ -132,6 +136,7 @@ const ICON_MAP: Record> = { Globe, Database, Shield, + Users, Wrench, Zap, Heart, diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 5a50e1a289412..d1fb9a306ff30 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -74,6 +74,7 @@ export const en: Translations = { documentation: "Documentation", keys: "Keys", logs: "Logs", + profiles: "Profiles", sessions: "Sessions", skills: "Skills", }, @@ -210,6 +211,67 @@ export const en: Translations = { }, }, + profiles: { + newProfile: "New Profile", + name: "Name", + namePlaceholder: "e.g. work, side-project", + nameRequired: "Name is required", + nameRule: + "Lowercase letters, digits, _ and - only; must start with a letter or digit; up to 64 characters.", + invalidName: "Invalid profile name", + cloneSource: "Source profile", + cloneSourceBlank: "Blank profile", + copyMode: "Copy", + cloneModeConfig: "Config only", + cloneModeAll: "All state", + importTitle: "Import Profile", + importAction: "Import", + archivePath: "Archive path (server-side)", + archivePathPlaceholder: "/path/to/profile.tar.gz", + archivePathRequired: "Archive path is required", + importNameOptional: "Target name (optional)", + importNamePlaceholder: "Defaults to archive's top-level dir", + allProfiles: "Profiles", + noProfiles: "No profiles found.", + onlyDefaultHint: + "Only the default profile exists. Create one above to manage isolated agent state.", + defaultBadge: "default", + activeBadge: "active", + gatewayRunning: "running", + gatewayStopped: "stopped", + hasEnv: "env", + model: "Model", + skills: "Skills", + restartGateway: "Restart gateway", + gatewayRestarting: "Restarting gateway", + rename: "Rename", + exportAction: "Export", + editConfig: "Edit profile", + modelSection: "Model", + modelSlug: "Model", + modelSlugPlaceholder: "e.g. moonshotai/kimi-k2.6", + modelProvider: "Provider", + modelProviderPlaceholder: "e.g. nous, openrouter, openai", + saveModel: "Save model", + soulSection: "SOUL.md (personality / system prompt)", + soulPlaceholder: "# How this agent should behave…", + saveSoul: "Save SOUL", + confirmDeleteTitle: "Delete profile?", + confirmDeleteMessage: + "This permanently deletes profile '{name}' — config, keys, memories, sessions, skills, cron jobs. Cannot be undone.", + created: "Created", + deleted: "Deleted", + renamed: "Renamed", + exported: "Exported to", + imported: "Imported", + soulSaved: "SOUL.md saved", + modelSaved: "Model saved", + gatewayDidNotStart: "Gateway did not come up", + checkLog: "check logs/gateway-restart.log", + tokenConflict: "token conflict", + sharedWith: "shared with", + }, + skills: { title: "Skills", searchPlaceholder: "Search skills and toolsets...", diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index ab267933bb73c..a6686a1c57493 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -74,6 +74,7 @@ export interface Translations { documentation: string; keys: string; logs: string; + profiles: string; sessions: string; skills: string; }; @@ -213,6 +214,65 @@ export interface Translations { }; }; + // ── Profiles page ── + profiles: { + newProfile: string; + name: string; + namePlaceholder: string; + nameRequired: string; + nameRule: string; + invalidName: string; + cloneSource: string; + cloneSourceBlank: string; + copyMode: string; + cloneModeConfig: string; + cloneModeAll: string; + importTitle: string; + importAction: string; + archivePath: string; + archivePathPlaceholder: string; + archivePathRequired: string; + importNameOptional: string; + importNamePlaceholder: string; + allProfiles: string; + noProfiles: string; + onlyDefaultHint: string; + defaultBadge: string; + activeBadge: string; + gatewayRunning: string; + gatewayStopped: string; + hasEnv: string; + model: string; + skills: string; + restartGateway: string; + gatewayRestarting: string; + rename: string; + exportAction: string; + editConfig: string; + modelSection: string; + modelSlug: string; + modelSlugPlaceholder: string; + modelProvider: string; + modelProviderPlaceholder: string; + saveModel: string; + soulSection: string; + soulPlaceholder: string; + saveSoul: string; + confirmDeleteTitle: string; + confirmDeleteMessage: string; + created: string; + deleted: string; + renamed: string; + exported: string; + imported: string; + soulSaved: string; + modelSaved: string; + gatewayDidNotStart: string; + checkLog: string; + tokenConflict: string; + sharedWith: string; + }; + // ── Skills page ── skills: { title: string; diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index dc67cd8215c1c..3b0c423198a1b 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -73,6 +73,7 @@ export const zh: Translations = { documentation: "文档", keys: "密钥", logs: "日志", + profiles: "配置档", sessions: "会话", skills: "技能", }, @@ -207,6 +208,66 @@ export const zh: Translations = { }, }, + profiles: { + newProfile: "新建配置档", + name: "名称", + namePlaceholder: "例如:work、side-project", + nameRequired: "名称必填", + nameRule: + "仅允许小写字母、数字、下划线和短横线;首字符必须是字母或数字;最多 64 个字符。", + invalidName: "配置档名称非法", + cloneSource: "源配置档", + cloneSourceBlank: "空白配置档", + copyMode: "复制内容", + cloneModeConfig: "仅配置", + cloneModeAll: "全部状态", + importTitle: "导入配置档", + importAction: "导入", + archivePath: "归档路径(服务器端)", + archivePathPlaceholder: "/path/to/profile.tar.gz", + archivePathRequired: "归档路径必填", + importNameOptional: "目标名称(可选)", + importNamePlaceholder: "默认为归档顶层目录名", + allProfiles: "配置档列表", + noProfiles: "暂无配置档。", + onlyDefaultHint: "仅有默认配置档。在上方创建以隔离不同的代理状态。", + defaultBadge: "默认", + activeBadge: "活动", + gatewayRunning: "运行中", + gatewayStopped: "已停止", + hasEnv: "已配置 env", + model: "模型", + skills: "技能", + restartGateway: "重启网关", + gatewayRestarting: "正在重启网关", + rename: "重命名", + exportAction: "导出", + editConfig: "编辑配置档", + modelSection: "模型", + modelSlug: "模型", + modelSlugPlaceholder: "例如:moonshotai/kimi-k2.6", + modelProvider: "服务商", + modelProviderPlaceholder: "例如:nous、openrouter、openai", + saveModel: "保存模型", + soulSection: "SOUL.md(人格 / 系统提示词)", + soulPlaceholder: "# 这个代理应当如何工作……", + saveSoul: "保存 SOUL", + confirmDeleteTitle: "删除配置档?", + confirmDeleteMessage: + "将永久删除配置档 '{name}' — 包括配置、密钥、记忆、会话、技能、定时任务。此操作无法撤销。", + created: "已创建", + deleted: "已删除", + renamed: "已重命名", + exported: "已导出至", + imported: "已导入", + soulSaved: "SOUL.md 已保存", + modelSaved: "模型已保存", + gatewayDidNotStart: "网关未能启动", + checkLog: "查看 logs/gateway-restart.log", + tokenConflict: "token 冲突", + sharedWith: "与以下 profile 共享", + }, + skills: { title: "技能", searchPlaceholder: "搜索技能和工具集...", diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index b4790f267f397..16ea090d6afbc 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -18,6 +18,10 @@ function setSessionHeader(headers: Headers, token: string): void { } } +// Tracks whether we've already triggered a reload-to-refresh-session flow so +// repeated 401s in a single tab don't ping-pong reload forever. +let _sessionReloadTriggered = false; + export async function fetchJSON(url: string, init?: RequestInit): Promise { // Inject the session token into all /api/ requests. const headers = new Headers(init?.headers); @@ -27,6 +31,16 @@ export async function fetchJSON(url: string, init?: RequestInit): Promise } const res = await fetch(`${BASE}${url}`, { ...init, headers }); if (!res.ok) { + if (res.status === 401 && !_sessionReloadTriggered) { + // The dashboard server rotates _SESSION_TOKEN on every restart and + // injects it into the SPA HTML. If we hit 401, our cached token is + // almost certainly stale (the server got restarted while this tab + // stayed open). Reload once to pick up the freshly-injected token. + _sessionReloadTriggered = true; + window.location.reload(); + // Block this request indefinitely; the reload will tear it down. + return new Promise(() => {}); + } const text = await res.text().catch(() => res.statusText); throw new Error(`${res.status}: ${text}`); } @@ -122,6 +136,94 @@ export const api = { deleteCronJob: (id: string) => fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}`, { method: "DELETE" }), + // Profiles + getProfiles: () => fetchJSON("/api/profiles"), + getActiveProfile: () => + fetchJSON<{ active: string }>("/api/profiles/active"), + createProfile: (body: { + name: string; + clone_from?: string; + clone_all?: boolean; + clone_config?: boolean; + no_alias?: boolean; + }) => + fetchJSON<{ ok: boolean; name: string; path: string }>("/api/profiles", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + renameProfile: (name: string, newName: string) => + fetchJSON<{ ok: boolean; name: string; path: string }>( + `/api/profiles/${encodeURIComponent(name)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ new_name: newName }), + }, + ), + deleteProfile: (name: string) => + fetchJSON<{ ok: boolean; path: string }>( + `/api/profiles/${encodeURIComponent(name)}`, + { method: "DELETE" }, + ), + activateProfile: (name: string) => + fetchJSON<{ ok: boolean; active: string }>( + `/api/profiles/${encodeURIComponent(name)}/activate`, + { method: "POST" }, + ), + exportProfile: (name: string, outputPath?: string) => + fetchJSON<{ ok: boolean; path: string }>( + `/api/profiles/${encodeURIComponent(name)}/export`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ output_path: outputPath ?? null }), + }, + ), + importProfile: (archivePath: string, name?: string) => + fetchJSON<{ ok: boolean; path: string; name: string }>( + "/api/profiles/import", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ archive_path: archivePath, name: name ?? null }), + }, + ), + getProfileSoul: (name: string) => + fetchJSON<{ content: string; exists: boolean; path: string }>( + `/api/profiles/${encodeURIComponent(name)}/soul`, + ), + updateProfileSoul: (name: string, content: string) => + fetchJSON<{ ok: boolean; path: string }>( + `/api/profiles/${encodeURIComponent(name)}/soul`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }, + ), + getProfileModel: (name: string) => + fetchJSON<{ model: string | null; provider: string | null }>( + `/api/profiles/${encodeURIComponent(name)}/model`, + ), + updateProfileModel: ( + name: string, + body: { model: string | null; provider: string | null }, + ) => + fetchJSON<{ ok: boolean; model: string | null; provider: string | null }>( + `/api/profiles/${encodeURIComponent(name)}/model`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ), + restartProfileGateway: (name: string) => + fetchJSON<{ ok: boolean; pid: number; name: string }>( + `/api/profiles/${encodeURIComponent(name)}/gateway/restart`, + { method: "POST" }, + ), + // Skills & Toolsets getSkills: () => fetchJSON("/api/skills"), toggleSkill: (name: string, enabled: boolean) => @@ -370,6 +472,29 @@ export interface AnalyticsResponse { }; } +export interface ProfileSharedToken { + key: string; + with: string[]; +} + +export interface ProfileInfo { + name: string; + path: string; + is_default: boolean; + gateway_running: boolean; + model: string | null; + provider: string | null; + has_env: boolean; + skill_count: number; + alias_path: string | null; + shared_tokens: ProfileSharedToken[]; +} + +export interface ProfilesResponse { + profiles: ProfileInfo[]; + active: string; +} + export interface CronJob { id: string; name?: string; diff --git a/web/src/pages/ProfilesPage.tsx b/web/src/pages/ProfilesPage.tsx new file mode 100644 index 0000000000000..1475a8d7491fa --- /dev/null +++ b/web/src/pages/ProfilesPage.tsx @@ -0,0 +1,803 @@ +import { useCallback, useEffect, useState } from "react"; +import { + AlertTriangle, + ChevronDown, + ChevronRight, + Download, + Pencil, + Plus, + RotateCw, + Settings2, + Trash2, + Upload, + Users, +} from "lucide-react"; +import { H2 } from "@nous-research/ui"; +import { api } from "@/lib/api"; +import type { ProfileInfo } from "@/lib/api"; +import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog"; +import { useToast } from "@/hooks/useToast"; +import { useConfirmDelete } from "@/hooks/useConfirmDelete"; +import { Toast } from "@/components/Toast"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectOption } from "@/components/ui/select"; +import { Segmented } from "@/components/ui/segmented"; +import { useI18n } from "@/i18n"; + +// Mirrors hermes_cli/profiles.py::_PROFILE_ID_RE so we can reject obviously +// invalid names (uppercase, spaces, …) before round-tripping a doomed +// PATCH/POST request and burning a toast cycle. +const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/; + +// Friendly display names for env keys returned in shared_tokens — keep in +// sync with the `_EXCLUSIVE_TOKEN_ENV_KEYS` tuple in hermes_cli/web_server.py. +// These are user-facing brand names; deliberately not translated. +const TOKEN_PLATFORM_LABEL: Record = { + WEIXIN_TOKEN: "WeChat", + TELEGRAM_BOT_TOKEN: "Telegram", + DISCORD_BOT_TOKEN: "Discord", +}; + +export default function ProfilesPage() { + const [profiles, setProfiles] = useState([]); + const [active, setActive] = useState("default"); + const [loading, setLoading] = useState(true); + const { toast, showToast } = useToast(); + const { t } = useI18n(); + + // Create form. ``cloneSource === ""`` means a blank profile is created; any + // other value is the explicit source profile to clone from. ``copyMode`` + // is irrelevant (and hidden from the UI) when the source is blank. + const [newName, setNewName] = useState(""); + const [cloneSource, setCloneSource] = useState(""); + const [copyMode, setCopyMode] = useState<"config" | "all">("config"); + const [creating, setCreating] = useState(false); + + // Rename state + const [renamingFrom, setRenamingFrom] = useState(null); + const [renameTo, setRenameTo] = useState(""); + + // Import state + const [importPath, setImportPath] = useState(""); + const [importName, setImportName] = useState(""); + const [importing, setImporting] = useState(false); + + // Per-profile edit panel: which profile is expanded + cached form state. + const [editingName, setEditingName] = useState(null); + const [soulText, setSoulText] = useState(""); + const [soulSaving, setSoulSaving] = useState(false); + const [modelDraft, setModelDraft] = useState<{ model: string; provider: string }>({ + model: "", + provider: "", + }); + const [modelSaving, setModelSaving] = useState(false); + + const openEditor = useCallback( + async (name: string) => { + // Toggle off if clicking the already-open row. + if (editingName === name) { + setEditingName(null); + return; + } + setEditingName(name); + setSoulText(""); + setModelDraft({ model: "", provider: "" }); + try { + const [soul, model] = await Promise.all([ + api.getProfileSoul(name), + api.getProfileModel(name), + ]); + setSoulText(soul.content); + setModelDraft({ + model: model.model ?? "", + provider: model.provider ?? "", + }); + } catch (e) { + showToast(`${t.status.error}: ${e}`, "error"); + } + }, + [editingName, showToast, t.status.error], + ); + + const handleSaveSoul = async (name: string) => { + setSoulSaving(true); + try { + await api.updateProfileSoul(name, soulText); + showToast(`${t.profiles.soulSaved}: ${name}`, "success"); + } catch (e) { + showToast(`${t.status.error}: ${e}`, "error"); + } finally { + setSoulSaving(false); + } + }; + + const handleSaveModel = async (name: string) => { + setModelSaving(true); + try { + const res = await api.updateProfileModel(name, { + model: modelDraft.model.trim() || null, + provider: modelDraft.provider.trim() || null, + }); + showToast(`${t.profiles.modelSaved}: ${name}`, "success"); + // Reflect normalised values back so the row's badge / display refresh. + setModelDraft({ model: res.model ?? "", provider: res.provider ?? "" }); + load(); + } catch (e) { + showToast(`${t.status.error}: ${e}`, "error"); + } finally { + setModelSaving(false); + } + }; + + const load = useCallback(() => { + api + .getProfiles() + .then((res) => { + setProfiles(res.profiles); + setActive(res.active); + }) + .catch((e) => showToast(`${t.status.error}: ${e}`, "error")) + .finally(() => setLoading(false)); + }, [showToast, t.status.error]); + + useEffect(() => { + load(); + }, [load]); + + const handleCreate = async () => { + const name = newName.trim(); + if (!name) { + showToast(t.profiles.nameRequired, "error"); + return; + } + if (!PROFILE_NAME_RE.test(name)) { + showToast(`${t.profiles.invalidName}: ${t.profiles.nameRule}`, "error"); + return; + } + setCreating(true); + try { + const hasSource = cloneSource !== ""; + await api.createProfile({ + name, + clone_from: hasSource ? cloneSource : undefined, + clone_all: hasSource && copyMode === "all", + // Setting clone_config when clone_from is given is redundant — the + // backend already copies config files whenever a source is set — but + // we forward it for symmetry with the CLI flags. + clone_config: hasSource && copyMode === "config", + }); + showToast(`${t.profiles.created}: ${name}`, "success"); + setNewName(""); + setCloneSource(""); + setCopyMode("config"); + load(); + } catch (e) { + showToast(`${t.status.error}: ${e}`, "error"); + } finally { + setCreating(false); + } + }; + + // Profiles whose gateway-restart request is currently in flight. Drives the + // spinner + disabled state on the row's restart button so the user gets + // immediate visual feedback even when the toast is missed (the toast + // auto-dismisses after 3s, the spinner stays until the post-restart reload + // returns). + const [restartingNames, setRestartingNames] = useState>(new Set()); + + const handleRestartGateway = async (name: string) => { + setRestartingNames((s) => new Set(s).add(name)); + try { + await api.restartProfileGateway(name); + showToast(`${t.profiles.gatewayRestarting}: ${name}`, "success"); + } catch (e) { + showToast(`${t.status.error}: ${e}`, "error"); + setRestartingNames((s) => { + const next = new Set(s); + next.delete(name); + return next; + }); + return; + } + + // Poll the listing until the gateway shows running or we time out. + // Spawn returns immediately but the gateway process itself can take + // 5–15 s to write gateway.pid (clean shutdown + service registration + // + init), so a single 3 s reload routinely caught the in-between + // state and looked like the restart silently failed. + const POLL_INTERVAL = 2000; + const MAX_POLLS = 8; // ~16s ceiling + let polls = 0; + const clearSpinner = () => + setRestartingNames((s) => { + const next = new Set(s); + next.delete(name); + return next; + }); + + const poll = async () => { + polls += 1; + try { + const res = await api.getProfiles(); + setProfiles(res.profiles); + setActive(res.active); + const target = res.profiles.find((p) => p.name === name); + if (target?.gateway_running) { + clearSpinner(); + return; + } + } catch { + /* swallow — keep polling, spinner stays visible */ + } + if (polls >= MAX_POLLS) { + clearSpinner(); + // Spawn returned 200 but the gateway never reported running. Most + // commonly this is a startup conflict — Weixin/Telegram token in + // use by another profile, missing optional dependency, missing API + // keys, or a port collision. Point the user at the log instead of + // silently clearing the spinner so it doesn't look like nothing + // happened. + showToast( + `${t.profiles.gatewayDidNotStart}: ${name} — ${t.profiles.checkLog}`, + "error", + ); + return; + } + setTimeout(poll, POLL_INTERVAL); + }; + + setTimeout(poll, POLL_INTERVAL); + }; + + const handleRenameSubmit = async () => { + if (!renamingFrom) return; + const target = renameTo.trim(); + if (!target || target === renamingFrom) { + setRenamingFrom(null); + setRenameTo(""); + return; + } + if (!PROFILE_NAME_RE.test(target)) { + // Keep the inline editor open so the user can fix the name in place. + showToast(`${t.profiles.invalidName}: ${t.profiles.nameRule}`, "error"); + return; + } + try { + await api.renameProfile(renamingFrom, target); + showToast(`${t.profiles.renamed}: ${renamingFrom} → ${target}`, "success"); + setRenamingFrom(null); + setRenameTo(""); + load(); + } catch (e) { + showToast(`${t.status.error}: ${e}`, "error"); + } + }; + + const handleExport = async (name: string) => { + try { + const res = await api.exportProfile(name); + showToast(`${t.profiles.exported}: ${res.path}`, "success"); + } catch (e) { + showToast(`${t.status.error}: ${e}`, "error"); + } + }; + + const handleImport = async () => { + const path = importPath.trim(); + if (!path) { + showToast(t.profiles.archivePathRequired, "error"); + return; + } + setImporting(true); + try { + const res = await api.importProfile(path, importName.trim() || undefined); + showToast(`${t.profiles.imported}: ${res.name}`, "success"); + setImportPath(""); + setImportName(""); + load(); + } catch (e) { + showToast(`${t.status.error}: ${e}`, "error"); + } finally { + setImporting(false); + } + }; + + const profileDelete = useConfirmDelete({ + onDelete: useCallback( + async (name: string) => { + try { + await api.deleteProfile(name); + showToast(`${t.profiles.deleted}: ${name}`, "success"); + load(); + } catch (e) { + showToast(`${t.status.error}: ${e}`, "error"); + throw e; + } + }, + [load, showToast, t.profiles.deleted, t.status.error], + ), + }); + + const pendingName = profileDelete.pendingId; + const namedProfiles = profiles.filter((p) => !p.is_default); + + if (loading) { + return ( +
+
+
+ ); + } + + return ( + // The app shell sets ``uppercase`` on every page by default; override it + // here because profile names, model slugs, and paths are case-sensitive + // — rendering ``gf`` as ``GF`` falsely suggests uppercase is allowed. + // Children that explicitly opt into ``uppercase`` (Badges, the Segmented + // control, our small section headers) still apply it to themselves. +
+ + + + + + {/* Create new profile */} + + + + + {t.profiles.newProfile} + + + +
+
+ + setNewName(e.target.value)} + aria-invalid={ + newName.trim() !== "" && + !PROFILE_NAME_RE.test(newName.trim()) + } + /> +

+ {t.profiles.nameRule} +

+
+ +
+
+ + +
+ +
+ +
+
+ + {cloneSource !== "" && ( +
+ + + size="md" + value={copyMode} + onChange={setCopyMode} + options={[ + { value: "config", label: t.profiles.cloneModeConfig }, + { value: "all", label: t.profiles.cloneModeAll }, + ]} + /> +
+ )} +
+
+
+ + {/* Import profile UI is hidden for now — needs UX work to handle + server-side path entry and (eventually) browser file upload before + it's safe to surface. Backend endpoint /api/profiles/import is + still wired up, just no entry from this page. */} + {/* eslint-disable-next-line no-constant-binary-expression */} + {false && ( + + + + + {t.profiles.importTitle} + + + +
+
+ + setImportPath(e.target.value)} + /> +
+
+
+ + setImportName(e.target.value)} + /> +
+
+ +
+
+
+
+
+ )} + + {/* Profiles list */} +
+

+ + {t.profiles.allProfiles} ({profiles.length}) +

+ + {profiles.length === 0 && ( + + + {t.profiles.noProfiles} + + + )} + + {profiles.map((p) => { + const isActive = p.name === active; + const isRenaming = renamingFrom === p.name; + const isEditing = editingName === p.name; + return ( + + +
+
+ {isRenaming ? ( + setRenameTo(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleRenameSubmit(); + if (e.key === "Escape") setRenamingFrom(null); + }} + aria-invalid={ + renameTo.trim() !== "" && + renameTo.trim() !== p.name && + !PROFILE_NAME_RE.test(renameTo.trim()) + } + className="max-w-xs" + /> + ) : ( + + {p.name} + + )} + {p.is_default && ( + {t.profiles.defaultBadge} + )} + {isActive && ( + {t.profiles.activeBadge} + )} + + + {p.gateway_running + ? t.profiles.gatewayRunning + : t.profiles.gatewayStopped} + + {p.has_env && ( + {t.profiles.hasEnv} + )} + {p.shared_tokens.length > 0 && ( + + `${s.key} — ${t.profiles.sharedWith} ${s.with.join(", ")}`, + ) + .join("\n")} + > + + {`${t.profiles.tokenConflict} (${p.shared_tokens + .map((s) => TOKEN_PLATFORM_LABEL[s.key] ?? s.key) + .join(", ")})`} + + )} +
+ {isRenaming && + (() => { + const trimmed = renameTo.trim(); + const invalid = + trimmed !== "" && + trimmed !== p.name && + !PROFILE_NAME_RE.test(trimmed); + return ( +

+ {invalid + ? `${t.profiles.invalidName}: ${t.profiles.nameRule}` + : t.profiles.nameRule} +

+ ); + })()} +
+ {p.model && ( + + {t.profiles.model}: {p.model} + {p.provider ? ` (${p.provider})` : ""} + + )} + + {t.profiles.skills}: {p.skill_count} + + + {p.path} + +
+
+ +
+ {isRenaming ? ( + <> + + + + ) : ( + <> + + + + {!p.is_default && ( + + )} + {!p.is_default && ( + + )} + + )} +
+
+ + {isEditing && ( +
+
+ +
+
+ + + setModelDraft({ ...modelDraft, model: e.target.value }) + } + /> +
+
+ + + setModelDraft({ + ...modelDraft, + provider: e.target.value, + }) + } + /> +
+
+
+ +
+
+ +
+ +