diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 28af40b6..bdbf0390 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -7682,7 +7682,7 @@ def cmd_profile(args): if clone_all: print(f"Full copy from {source_label}.") else: - print(f"Cloned config, .env, SOUL.md from {source_label}.") + print(f"Cloned config, .env, SOUL.md, and skills from {source_label}.") # Auto-clone Honcho config for the new profile (only with --clone/--clone-all) if clone or clone_all: diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 794400ff..dd5fabce 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -11,7 +11,7 @@ zero migration needed. Usage:: hermes profile create coder # fresh profile + bundled skills - hermes profile create coder --clone # also copy config, .env, SOUL.md + hermes profile create coder --clone # also copy config, .env, SOUL.md, skills hermes profile create coder --clone-all # full copy of source profile coder chat # use via wrapper alias hermes -p coder chat # or via flag @@ -411,7 +411,8 @@ def create_profile( clone_all: If True, do a full copytree of the source (all state). clone_config: - If True, copy only config files (config.yaml, .env, SOUL.md). + If True, copy config files (config.yaml, .env, SOUL.md), installed + skills, and selected profile identity files from the source profile. no_alias: If True, skip wrapper script creation. @@ -469,6 +470,14 @@ def create_profile( if src.exists(): shutil.copy2(src, profile_dir / filename) + # Clone installed skills from the source profile. The dashboard's + # "clone from default" flow is expected to preserve both bundled + # and user-installed skills so the new profile immediately has the + # same agent capabilities as the source profile. + source_skills = source_dir / "skills" + if source_skills.is_dir(): + shutil.copytree(source_skills, profile_dir / "skills", dirs_exist_ok=True) + # Clone memory and other subdirectory files for relpath in _CLONE_SUBDIR_FILES: src = source_dir / relpath diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 8b63e13f..570a0a7a 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2344,6 +2344,254 @@ async def delete_cron_job(job_id: str): return {"ok": True} +# --------------------------------------------------------------------------- +# Profile management endpoints (minimal — list/create/rename/delete + SOUL.md) +# --------------------------------------------------------------------------- + + +class ProfileCreate(BaseModel): + name: str + clone_from_default: bool = False + + +class ProfileRename(BaseModel): + new_name: str + + +class ProfileSoulUpdate(BaseModel): + content: str + + +def _profile_attr(info, name: str, default: Any = None) -> Any: + try: + return getattr(info, name) + except Exception: + return default + + +def _profile_to_dict(info) -> Dict[str, Any]: + return { + "name": _profile_attr(info, "name", ""), + "path": str(_profile_attr(info, "path", "")), + "is_default": bool(_profile_attr(info, "is_default", False)), + "model": _profile_attr(info, "model"), + "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), + } + + +def _fallback_profile_dicts(profiles_mod) -> List[Dict[str, Any]]: + def _safe(callable_, default): + try: + return callable_() + except Exception: + return default + + profiles: List[Dict[str, Any]] = [] + default_home = profiles_mod._get_default_hermes_home() + if default_home.is_dir(): + model, provider = _safe(lambda: profiles_mod._read_config_model(default_home), (None, None)) + profiles.append({ + "name": "default", + "path": str(default_home), + "is_default": True, + "model": model, + "provider": provider, + "has_env": (default_home / ".env").exists(), + "skill_count": _safe(lambda: profiles_mod._count_skills(default_home), 0), + }) + + profiles_root = profiles_mod._get_profiles_root() + if profiles_root.is_dir(): + for entry in sorted(profiles_root.iterdir()): + if not entry.is_dir() or not profiles_mod._PROFILE_ID_RE.match(entry.name): + continue + model, provider = _safe(lambda entry=entry: profiles_mod._read_config_model(entry), (None, None)) + profiles.append({ + "name": entry.name, + "path": str(entry), + "is_default": False, + "model": model, + "provider": provider, + "has_env": (entry / ".env").exists(), + "skill_count": _safe(lambda entry=entry: profiles_mod._count_skills(entry), 0), + }) + + return profiles + + +def _resolve_profile_dir(name: str) -> Path: + """Validate ``name`` and resolve 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) + + +def _profile_setup_command(name: str) -> str: + """Return the shell command used to configure a profile in the CLI.""" + _resolve_profile_dir(name) + return "hermes setup" if name == "default" else f"{name} setup" + + +@app.get("/api/profiles") +async def list_profiles_endpoint(): + from hermes_cli import profiles as profiles_mod + try: + return {"profiles": [_profile_to_dict(p) for p in profiles_mod.list_profiles()]} + except Exception: + _log.exception("GET /api/profiles failed; falling back to profile directory scan") + return {"profiles": _fallback_profile_dicts(profiles_mod)} + + +@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="default" if body.clone_from_default else None, + clone_config=body.clone_from_default, + ) + # 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. + if not body.clone_from_default: + profiles_mod.seed_profile_skills(path, quiet=True) + + # Match the CLI's profile-create flow: named profiles should get a + # wrapper in ~/.local/bin when the alias is safe to create. + collision = profiles_mod.check_alias_collision(body.name) + if not collision: + profiles_mod.create_wrapper_script(body.name) + 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.get("/api/profiles/{name}/setup-command") +async def get_profile_setup_command(name: str): + return {"command": _profile_setup_command(name)} + + +@app.post("/api/profiles/{name}/open-terminal") +async def open_profile_terminal_endpoint(name: str): + try: + command = _profile_setup_command(name) + + if sys.platform.startswith("win"): + subprocess.Popen(["cmd.exe", "/c", "start", "", command]) + elif sys.platform == "darwin": + escaped = command.replace("\\", "\\\\").replace('"', '\\"') + applescript = ( + 'tell application "Terminal"\n' + "activate\n" + f'do script "{escaped}"\n' + "end tell" + ) + subprocess.Popen(["osascript", "-e", applescript]) + else: + terminal_commands = [ + ("x-terminal-emulator", ["x-terminal-emulator", "-e", "sh", "-lc", command]), + ("gnome-terminal", ["gnome-terminal", "--", "sh", "-lc", command]), + ("konsole", ["konsole", "-e", "sh", "-lc", command]), + ("xfce4-terminal", ["xfce4-terminal", "-e", f"sh -lc '{command}'"]), + ("mate-terminal", ["mate-terminal", "-e", f"sh -lc '{command}'"]), + ("lxterminal", ["lxterminal", "-e", f"sh -lc '{command}'"]), + ("tilix", ["tilix", "-e", "sh", "-lc", command]), + ("alacritty", ["alacritty", "-e", "sh", "-lc", command]), + ("kitty", ["kitty", "sh", "-lc", command]), + ("xterm", ["xterm", "-e", "sh", "-lc", command]), + ] + for executable, popen_args in terminal_commands: + if subprocess.call( + ["which", executable], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) == 0: + subprocess.Popen(popen_args) + break + else: + raise HTTPException( + status_code=400, + detail="No supported terminal emulator found", + ) + 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 HTTPException: + raise + except Exception as e: + _log.exception("POST /api/profiles/%s/open-terminal failed", name) + raise HTTPException(status_code=500, detail=str(e)) + return {"ok": True, "command": command} + + +@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 collects the user's confirmation in + its own dialog before this request, so we always pass ``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.get("/api/profiles/{name}/soul") +async def get_profile_soul(name: str): + soul_path = _resolve_profile_dir(name) / "SOUL.md" + if soul_path.exists(): + try: + return {"content": soul_path.read_text(encoding="utf-8"), "exists": True} + except OSError as e: + raise HTTPException(status_code=500, detail=f"Could not read SOUL.md: {e}") + return {"content": "", "exists": False} + + +@app.put("/api/profiles/{name}/soul") +async def update_profile_soul(name: str, body: ProfileSoulUpdate): + soul_path = _resolve_profile_dir(name) / "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} + + # --------------------------------------------------------------------------- # Skills & Tools endpoints # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_dashboard_browser_safe_imports.py b/tests/hermes_cli/test_dashboard_browser_safe_imports.py new file mode 100644 index 00000000..05f3a33b --- /dev/null +++ b/tests/hermes_cli/test_dashboard_browser_safe_imports.py @@ -0,0 +1,16 @@ +"""Static dashboard tests for browser-safe @nous-research/ui imports.""" +from pathlib import Path + + +WEB_SRC = Path(__file__).resolve().parents[2] / "web" / "src" + + +def test_dashboard_does_not_import_nous_ui_root_barrel(): + offenders = [] + for ext in ("*.tsx", "*.ts"): + for path in WEB_SRC.rglob(ext): + content = path.read_text(encoding="utf-8") + if 'from "@nous-research/ui"' in content or "from '@nous-research/ui'" in content: + offenders.append(str(path.relative_to(WEB_SRC))) + + assert offenders == [] diff --git a/tests/hermes_cli/test_dashboard_profiles_nav_label.py b/tests/hermes_cli/test_dashboard_profiles_nav_label.py new file mode 100644 index 00000000..583e62ee --- /dev/null +++ b/tests/hermes_cli/test_dashboard_profiles_nav_label.py @@ -0,0 +1,11 @@ +"""Static dashboard tests for the Profiles navigation copy.""" +from pathlib import Path + + +def test_profiles_nav_label_uses_short_multi_agents_copy(): + en_i18n = Path(__file__).resolve().parents[2] / "web" / "src" / "i18n" / "en.ts" + + content = en_i18n.read_text(encoding="utf-8") + + assert 'profiles: "profiles : multi agents"' in content + assert "Profiles: Running Multiple Agents" not in content diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index a285dca5..9177930f 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -149,6 +149,23 @@ class TestCreateProfile: assert (profile_dir / ".env").read_text() == "KEY=val" assert (profile_dir / "SOUL.md").read_text() == "Be helpful." + def test_clone_config_copies_source_skills(self, profile_env): + tmp_path = profile_env + default_home = tmp_path / ".hermes" + skill_dir = default_home / "skills" / "custom" / "installed-skill" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("---\nname: installed-skill\n---\n") + + profile_dir = create_profile("coder", clone_config=True, no_alias=True) + + assert ( + profile_dir + / "skills" + / "custom" + / "installed-skill" + / "SKILL.md" + ).read_text() == "---\nname: installed-skill\n---\n" + def test_clone_all_copies_entire_tree(self, profile_env): tmp_path = profile_env default_home = tmp_path / ".hermes" diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 0093dfb9..f2aed86d 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -591,6 +591,222 @@ class TestNewEndpoints: resp = self.client.get("/api/cron/jobs/nonexistent-id") assert resp.status_code == 404 + # --- Profiles --- + + def test_profiles_list_includes_default(self): + 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 + names = [p["name"] for p in resp.json()["profiles"]] + assert "default" in names + + def test_profiles_list_falls_back_when_profile_listing_fails(self, monkeypatch): + from hermes_constants import get_hermes_home + import hermes_cli.profiles as profiles_mod + + hermes_home = get_hermes_home() + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "config.yaml").write_text( + "model:\n provider: openrouter\n name: anthropic/claude-sonnet-4.6\n", + encoding="utf-8", + ) + named = hermes_home / "profiles" / "multi-agent" + named.mkdir(parents=True) + (named / ".env").write_text("EXAMPLE=1\n", encoding="utf-8") + (named / "skills" / "demo").mkdir(parents=True) + (named / "skills" / "demo" / "SKILL.md").write_text("---\nname: demo\n---\n", encoding="utf-8") + + monkeypatch.setattr( + profiles_mod, + "list_profiles", + lambda: (_ for _ in ()).throw(RuntimeError("boom")), + ) + + resp = self.client.get("/api/profiles") + + assert resp.status_code == 200 + profiles = {p["name"]: p for p in resp.json()["profiles"]} + assert profiles["default"]["is_default"] is True + assert profiles["default"]["provider"] == "openrouter" + assert profiles["multi-agent"]["has_env"] is True + assert profiles["multi-agent"]["skill_count"] == 1 + + def test_profiles_create_rename_delete_round_trip(self, monkeypatch): + # Stub gateway service teardown so the test doesn't shell out to + # launchctl/systemctl on the host. + import hermes_cli.profiles as profiles_mod + monkeypatch.setattr(profiles_mod, "_cleanup_gateway_service", lambda *a, **kw: None) + + created = self.client.post("/api/profiles", json={"name": "test-prof"}) + assert created.status_code == 200 + + renamed = self.client.patch( + "/api/profiles/test-prof", + json={"new_name": "test-prof-2"}, + ) + assert renamed.status_code == 200 + + names = [p["name"] for p in self.client.get("/api/profiles").json()["profiles"]] + assert "test-prof" not in names + assert "test-prof-2" in names + + deleted = self.client.delete("/api/profiles/test-prof-2") + assert deleted.status_code == 200 + names = [p["name"] for p in self.client.get("/api/profiles").json()["profiles"]] + assert "test-prof-2" not in names + + def test_profile_setup_command_uses_named_profile_wrapper(self): + from hermes_constants import get_hermes_home + + (get_hermes_home() / "profiles" / "coder").mkdir(parents=True) + + resp = self.client.get("/api/profiles/coder/setup-command") + + assert resp.status_code == 200 + assert resp.json()["command"] == "coder setup" + + def test_profile_setup_command_uses_hermes_for_default_profile(self): + from hermes_constants import get_hermes_home + + get_hermes_home().mkdir(parents=True, exist_ok=True) + + resp = self.client.get("/api/profiles/default/setup-command") + + assert resp.status_code == 200 + assert resp.json()["command"] == "hermes setup" + + def test_profiles_create_creates_wrapper_alias_when_safe(self, monkeypatch, tmp_path): + import hermes_cli.profiles as profiles_mod + + wrapper_dir = tmp_path / "bin" + wrapper_dir.mkdir() + monkeypatch.setattr(profiles_mod, "_get_wrapper_dir", lambda: wrapper_dir) + + resp = self.client.post( + "/api/profiles", + json={"name": "writer", "clone_from_default": False}, + ) + + assert resp.status_code == 200 + wrapper_path = wrapper_dir / "writer" + assert wrapper_path.exists() + assert wrapper_path.read_text() == '#!/bin/sh\nexec hermes -p writer "$@"\n' + + def test_profiles_create_with_clone_from_default_copies_default_skills(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) + default_skill = get_hermes_home() / "skills" / "custom" / "new-skill" + default_skill.mkdir(parents=True) + (default_skill / "SKILL.md").write_text("---\nname: new-skill\n---\n", encoding="utf-8") + + resp = self.client.post( + "/api/profiles", + json={"name": "cloned", "clone_from_default": True}, + ) + + assert resp.status_code == 200 + cloned_skill = get_hermes_home() / "profiles" / "cloned" / "skills" / "custom" / "new-skill" / "SKILL.md" + assert cloned_skill.exists() + profiles = {p["name"]: p for p in self.client.get("/api/profiles").json()["profiles"]} + assert profiles["cloned"]["skill_count"] == 1 + + def test_profiles_create_without_clone_seeds_bundled_skills(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) + + def fake_seed(profile_dir, quiet=False): + skill_dir = profile_dir / "skills" / "software-development" / "plan" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("---\nname: plan\n---\n", encoding="utf-8") + return {"copied": ["plan"]} + + monkeypatch.setattr(profiles_mod, "seed_profile_skills", fake_seed) + + resp = self.client.post( + "/api/profiles", + json={"name": "fresh", "clone_from_default": False}, + ) + + assert resp.status_code == 200 + seeded_skill = get_hermes_home() / "profiles" / "fresh" / "skills" / "software-development" / "plan" / "SKILL.md" + assert seeded_skill.exists() + profiles = {p["name"]: p for p in self.client.get("/api/profiles").json()["profiles"]} + assert profiles["fresh"]["skill_count"] == 1 + + def test_profile_open_terminal_uses_macos_terminal(self, monkeypatch): + from hermes_constants import get_hermes_home + import hermes_cli.web_server as web_server + + (get_hermes_home() / "profiles" / "coder").mkdir(parents=True) + calls = [] + monkeypatch.setattr(web_server.sys, "platform", "darwin") + monkeypatch.setattr(web_server.subprocess, "Popen", lambda args, **kwargs: calls.append(args)) + + resp = self.client.post("/api/profiles/coder/open-terminal") + + assert resp.status_code == 200 + assert calls + assert calls[0][0] == "osascript" + assert "coder setup" in " ".join(calls[0]) + + def test_profile_open_terminal_uses_windows_cmd(self, monkeypatch): + from hermes_constants import get_hermes_home + import hermes_cli.web_server as web_server + + (get_hermes_home() / "profiles" / "coder").mkdir(parents=True) + calls = [] + monkeypatch.setattr(web_server.sys, "platform", "win32") + monkeypatch.setattr(web_server.subprocess, "Popen", lambda args, **kwargs: calls.append(args)) + + resp = self.client.post("/api/profiles/coder/open-terminal") + + assert resp.status_code == 200 + assert calls + assert calls[0][:4] == ["cmd.exe", "/c", "start", ""] + assert calls[0][-1] == "coder setup" + + 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_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_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"}) + 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 soul"}, + ) + assert put.status_code == 200 + + got = self.client.get("/api/profiles/soul-prof/soul").json() + assert got["content"] == "# Edited 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 + 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 4be497ac..b03beef8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -38,17 +38,16 @@ import { Sparkles, Star, Terminal, + Users, Wrench, X, Zap, } from "lucide-react"; -import { - Button, - ListItem, - SelectionSwitcher, - Spinner, - Typography, -} from "@nous-research/ui"; +import { Button } from "@nous-research/ui/ui/components/button"; +import { ListItem } from "@nous-research/ui/ui/components/list-item"; +import { SelectionSwitcher } from "@nous-research/ui/ui/components/selection-switcher"; +import { Spinner } from "@nous-research/ui/ui/components/spinner"; +import { Typography } from "@/components/NouiTypography"; import { cn } from "@/lib/utils"; import { Backdrop } from "@/components/Backdrop"; import { SidebarFooter } from "@/components/SidebarFooter"; @@ -64,6 +63,7 @@ import LogsPage from "@/pages/LogsPage"; import AnalyticsPage from "@/pages/AnalyticsPage"; import ModelsPage from "@/pages/ModelsPage"; 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"; @@ -102,6 +102,7 @@ const BUILTIN_ROUTES_CORE: Record = { "/logs": LogsPage, "/cron": CronPage, "/skills": SkillsPage, + "/profiles": ProfilesPage, "/config": ConfigPage, "/env": EnvPage, "/docs": DocsPage, @@ -137,6 +138,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 }, { @@ -163,6 +165,7 @@ const ICON_MAP: Record> = { Globe, Database, Shield, + Users, Wrench, Zap, Heart, diff --git a/web/src/components/AutoField.tsx b/web/src/components/AutoField.tsx index 4b213be4..f7afd150 100644 --- a/web/src/components/AutoField.tsx +++ b/web/src/components/AutoField.tsx @@ -1,4 +1,5 @@ -import { Select, SelectOption, Switch } from "@nous-research/ui"; +import { Select, SelectOption } from "@nous-research/ui/ui/components/select"; +import { Switch } from "@nous-research/ui/ui/components/switch"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index 4436555b..1c923112 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -23,8 +23,8 @@ * terminal pane keeps working unimpaired. */ -import { Button } from "@nous-research/ui"; -import { Badge } from "@nous-research/ui"; +import { Button } from "@nous-research/ui/ui/components/button"; +import { Badge } from "@nous-research/ui/ui/components/badge"; import { Card } from "@/components/ui/card"; import { ModelPickerDialog } from "@/components/ModelPickerDialog"; diff --git a/web/src/components/LanguageSwitcher.tsx b/web/src/components/LanguageSwitcher.tsx index 24684bce..dc477021 100644 --- a/web/src/components/LanguageSwitcher.tsx +++ b/web/src/components/LanguageSwitcher.tsx @@ -1,4 +1,5 @@ -import { Button, Typography } from "@nous-research/ui"; +import { Button } from "@nous-research/ui/ui/components/button"; +import { Typography } from "@/components/NouiTypography"; import { useI18n } from "@/i18n/context"; /** diff --git a/web/src/components/ModelInfoCard.tsx b/web/src/components/ModelInfoCard.tsx index 39cf7bef..39410f3b 100644 --- a/web/src/components/ModelInfoCard.tsx +++ b/web/src/components/ModelInfoCard.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { Brain, Eye, Gauge, Lightbulb, Wrench } from "lucide-react"; -import { Spinner } from "@nous-research/ui"; +import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { api } from "@/lib/api"; import type { ModelInfoResponse } from "@/lib/api"; import { formatTokenCount } from "@/lib/format"; diff --git a/web/src/components/ModelPickerDialog.tsx b/web/src/components/ModelPickerDialog.tsx index 0cc195ec..d99ea09a 100644 --- a/web/src/components/ModelPickerDialog.tsx +++ b/web/src/components/ModelPickerDialog.tsx @@ -1,4 +1,6 @@ -import { Button, ListItem, Spinner } from "@nous-research/ui"; +import { Button } from "@nous-research/ui/ui/components/button"; +import { ListItem } from "@nous-research/ui/ui/components/list-item"; +import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { Input } from "@/components/ui/input"; import type { GatewayClient } from "@/lib/gatewayClient"; import { Check, Search, X } from "lucide-react"; diff --git a/web/src/components/NouiTypography.tsx b/web/src/components/NouiTypography.tsx new file mode 100644 index 00000000..eb26d75c --- /dev/null +++ b/web/src/components/NouiTypography.tsx @@ -0,0 +1,63 @@ +import { forwardRef, type ElementType, type HTMLAttributes, type ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +type TypographyProps = HTMLAttributes & { + as?: ElementType; + children?: ReactNode; + compressed?: boolean; + courier?: boolean; + expanded?: boolean; + mondwest?: boolean; + mono?: boolean; + sans?: boolean; + variant?: "sm" | "md" | "lg" | "xl"; +}; + +const variantClasses: Record, string> = { + sm: "leading-[1.4] text-[.9375rem] tracking-[0.1875rem]", + md: "text-[2.625rem] leading-[1] tracking-[0.0525rem]", + lg: "text-[2.625rem] leading-[1] tracking-[0.0525rem]", + xl: "text-[4.5rem] leading-[1] tracking-[0.135rem]", +}; + +export const Typography = forwardRef(function Typography( + { + as: Component = "span", + className, + compressed, + courier, + expanded, + mondwest, + mono, + sans, + variant, + ...props + }, + ref, +) { + const hasFontVariant = compressed || courier || expanded || mondwest || mono || sans; + + return ( + + ); +}); + +export const H2 = forwardRef>(function H2( + { className, variant = "lg", ...props }, + ref, +) { + return ; +}); diff --git a/web/src/components/OAuthLoginModal.tsx b/web/src/components/OAuthLoginModal.tsx index 8ac630ea..f4eb610c 100644 --- a/web/src/components/OAuthLoginModal.tsx +++ b/web/src/components/OAuthLoginModal.tsx @@ -1,6 +1,9 @@ import { useEffect, useRef, useState } from "react"; import { ExternalLink, X, Check } from "lucide-react"; -import { Button, CopyButton, H2, Spinner } from "@nous-research/ui"; +import { Button } from "@nous-research/ui/ui/components/button"; +import { CopyButton } from "@nous-research/ui/ui/components/command-block"; +import { Spinner } from "@nous-research/ui/ui/components/spinner"; +import { H2 } from "@/components/NouiTypography"; import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api"; import { Input } from "@/components/ui/input"; import { useI18n } from "@/i18n"; diff --git a/web/src/components/OAuthProvidersCard.tsx b/web/src/components/OAuthProvidersCard.tsx index 4ce4acae..6877207f 100644 --- a/web/src/components/OAuthProvidersCard.tsx +++ b/web/src/components/OAuthProvidersCard.tsx @@ -9,7 +9,9 @@ import { LogIn, } from "lucide-react"; import { api, type OAuthProvider } from "@/lib/api"; -import { Button, CopyButton, Spinner } from "@nous-research/ui"; +import { Button } from "@nous-research/ui/ui/components/button"; +import { CopyButton } from "@nous-research/ui/ui/components/command-block"; +import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { Card, CardContent, @@ -17,7 +19,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { Badge } from "@nous-research/ui"; +import { Badge } from "@nous-research/ui/ui/components/badge"; import { OAuthLoginModal } from "@/components/OAuthLoginModal"; import { useI18n } from "@/i18n"; diff --git a/web/src/components/PlatformsCard.tsx b/web/src/components/PlatformsCard.tsx index 842a513b..24cc668c 100644 --- a/web/src/components/PlatformsCard.tsx +++ b/web/src/components/PlatformsCard.tsx @@ -1,7 +1,7 @@ import { AlertTriangle, Radio, Wifi, WifiOff } from "lucide-react"; import type { PlatformStatus } from "@/lib/api"; import { isoTimeAgo } from "@/lib/utils"; -import { Badge } from "@nous-research/ui"; +import { Badge } from "@nous-research/ui/ui/components/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { useI18n } from "@/i18n"; diff --git a/web/src/components/SidebarFooter.tsx b/web/src/components/SidebarFooter.tsx index e163dd2c..c1810f10 100644 --- a/web/src/components/SidebarFooter.tsx +++ b/web/src/components/SidebarFooter.tsx @@ -1,4 +1,4 @@ -import { Typography } from "@nous-research/ui"; +import { Typography } from "@/components/NouiTypography"; import { useSidebarStatus } from "@/hooks/useSidebarStatus"; import { cn } from "@/lib/utils"; import { useI18n } from "@/i18n"; diff --git a/web/src/components/SlashPopover.tsx b/web/src/components/SlashPopover.tsx index eb4a3b63..418b0409 100644 --- a/web/src/components/SlashPopover.tsx +++ b/web/src/components/SlashPopover.tsx @@ -1,5 +1,5 @@ import type { GatewayClient } from "@/lib/gatewayClient"; -import { ListItem } from "@nous-research/ui"; +import { ListItem } from "@nous-research/ui/ui/components/list-item"; import { ChevronRight } from "lucide-react"; import { forwardRef, diff --git a/web/src/components/ThemeSwitcher.tsx b/web/src/components/ThemeSwitcher.tsx index 2d1cf353..4d50e611 100644 --- a/web/src/components/ThemeSwitcher.tsx +++ b/web/src/components/ThemeSwitcher.tsx @@ -1,6 +1,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Palette, Check } from "lucide-react"; -import { Button, ListItem, Typography } from "@nous-research/ui"; +import { Button } from "@nous-research/ui/ui/components/button"; +import { ListItem } from "@nous-research/ui/ui/components/list-item"; +import { Typography } from "@/components/NouiTypography"; import { BUILTIN_THEMES, useTheme } from "@/themes"; import { useI18n } from "@/i18n"; import { cn } from "@/lib/utils"; diff --git a/web/src/components/ToolCall.tsx b/web/src/components/ToolCall.tsx index 5cf25b37..8e465fa6 100644 --- a/web/src/components/ToolCall.tsx +++ b/web/src/components/ToolCall.tsx @@ -1,4 +1,4 @@ -import { ListItem } from "@nous-research/ui"; +import { ListItem } from "@nous-research/ui/ui/components/list-item"; import { AlertCircle, Check, diff --git a/web/src/components/ui/confirm-dialog.tsx b/web/src/components/ui/confirm-dialog.tsx index 38c2472c..e8529e2b 100644 --- a/web/src/components/ui/confirm-dialog.tsx +++ b/web/src/components/ui/confirm-dialog.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef } from "react"; import { createPortal } from "react-dom"; import { AlertTriangle } from "lucide-react"; -import { Button } from "@nous-research/ui"; +import { Button } from "@nous-research/ui/ui/components/button"; import { cn } from "@/lib/utils"; export function ConfirmDialog({ diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 9fed7cb8..1aaabd0f 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -75,6 +75,7 @@ export const en: Translations = { keys: "Keys", logs: "Logs", models: "Models", + profiles: "profiles : multi agents", sessions: "Sessions", skills: "Skills", }, @@ -223,6 +224,38 @@ export const en: Translations = { }, }, + profiles: { + newProfile: "New Profile", + name: "Name", + namePlaceholder: "e.g. coder, writer, etc.", + 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", + cloneFromDefault: "Clone config from default profile", + allProfiles: "Profiles", + noProfiles: "No profiles found.", + defaultBadge: "default", + hasEnv: "env", + model: "Model", + skills: "Skills", + rename: "Rename", + editSoul: "Edit SOUL.md", + soulSection: "SOUL.md (personality / system prompt)", + soulPlaceholder: "# How this agent should behave…", + saveSoul: "Save SOUL", + soulSaved: "SOUL.md saved", + openInTerminal: "Copy CLI command", + commandCopied: "Copied to clipboard", + copyFailed: "Could not copy", + 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", + }, + skills: { title: "Skills", searchPlaceholder: "Search skills and toolsets...", diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index 04c67cda..bb6266a2 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -75,6 +75,7 @@ export interface Translations { keys: string; logs: string; models: string; + profiles: string; sessions: string; skills: string; }; @@ -227,6 +228,37 @@ export interface Translations { }; }; + // ── Profiles page ── + profiles: { + newProfile: string; + name: string; + namePlaceholder: string; + nameRequired: string; + nameRule: string; + invalidName: string; + cloneFromDefault: string; + allProfiles: string; + noProfiles: string; + defaultBadge: string; + hasEnv: string; + model: string; + skills: string; + rename: string; + editSoul: string; + soulSection: string; + soulPlaceholder: string; + saveSoul: string; + soulSaved: string; + openInTerminal: string; + commandCopied: string; + copyFailed: string; + confirmDeleteTitle: string; + confirmDeleteMessage: string; + created: string; + deleted: string; + renamed: string; + }; + // ── Skills page ── skills: { title: string; diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index caec8c86..f7a7399a 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -74,6 +74,7 @@ export const zh: Translations = { keys: "密钥", logs: "日志", models: "模型", + profiles: "多Agent配置", sessions: "会话", skills: "技能", }, @@ -220,6 +221,38 @@ export const zh: Translations = { }, }, + profiles: { + newProfile: "新建多Agent配置", + name: "名称", + namePlaceholder: "例如:coder, writer 等", + nameRequired: "名称必填", + nameRule: + "仅允许小写字母、数字、下划线和短横线;首字符必须是字母或数字;最多 64 个字符。", + invalidName: "多Agent配置名称非法", + cloneFromDefault: "从默认多Agent配置克隆配置", + allProfiles: "多Agent配置列表", + noProfiles: "暂无多Agent配置。", + defaultBadge: "默认", + hasEnv: "已配置 env", + model: "模型", + skills: "技能", + rename: "重命名", + editSoul: "编辑 SOUL.md", + soulSection: "SOUL.md(人格 / 系统提示词)", + soulPlaceholder: "# 这个代理应当如何工作……", + saveSoul: "保存 SOUL", + soulSaved: "SOUL.md 已保存", + openInTerminal: "复制 CLI 命令", + commandCopied: "已复制到剪贴板", + copyFailed: "复制失败", + confirmDeleteTitle: "删除多Agent配置?", + confirmDeleteMessage: + "将永久删除多Agent配置 '{name}' — 包括配置、密钥、记忆、会话、技能、定时任务。此操作无法撤销。", + created: "已创建", + deleted: "已删除", + renamed: "已重命名", + }, + skills: { title: "技能", searchPlaceholder: "搜索技能和工具集...", diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index ef8e3ae1..10ed9acf 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -132,6 +132,47 @@ export const api = { deleteCronJob: (id: string) => fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}`, { method: "DELETE" }), + // Profiles (minimal) + getProfiles: () => + fetchJSON<{ profiles: ProfileInfo[] }>("/api/profiles"), + createProfile: (body: { name: string; clone_from_default: 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 }>( + `/api/profiles/${encodeURIComponent(name)}`, + { method: "DELETE" }, + ), + getProfileSetupCommand: (name: string) => + fetchJSON<{ command: string }>( + `/api/profiles/${encodeURIComponent(name)}/setup-command`, + ), + getProfileSoul: (name: string) => + fetchJSON<{ content: string; exists: boolean }>( + `/api/profiles/${encodeURIComponent(name)}/soul`, + ), + updateProfileSoul: (name: string, content: string) => + fetchJSON<{ ok: boolean }>( + `/api/profiles/${encodeURIComponent(name)}/soul`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }, + ), + // Skills & Toolsets getSkills: () => fetchJSON("/api/skills"), toggleSkill: (name: string, enabled: boolean) => @@ -380,6 +421,16 @@ export interface AnalyticsResponse { }; } +export interface ProfileInfo { + name: string; + path: string; + is_default: boolean; + model: string | null; + provider: string | null; + has_env: boolean; + skill_count: number; +} + export interface ModelsAnalyticsModelEntry { model: string; provider: string; diff --git a/web/src/pages/AnalyticsPage.tsx b/web/src/pages/AnalyticsPage.tsx index eb740d41..5eab4a7a 100644 --- a/web/src/pages/AnalyticsPage.tsx +++ b/web/src/pages/AnalyticsPage.tsx @@ -8,9 +8,11 @@ import type { AnalyticsSkillEntry, } from "@/lib/api"; import { timeAgo } from "@/lib/utils"; -import { Button, Spinner, Stats } from "@nous-research/ui"; +import { Button } from "@nous-research/ui/ui/components/button"; +import { Spinner } from "@nous-research/ui/ui/components/spinner"; +import { Stats } from "@nous-research/ui/ui/components/stats"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@nous-research/ui"; +import { Badge } from "@nous-research/ui/ui/components/badge"; import { usePageHeader } from "@/contexts/usePageHeader"; import { useI18n } from "@/i18n"; import { PluginSlot } from "@/plugins"; diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index a38116fa..085d1cfc 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -22,7 +22,8 @@ import { WebLinksAddon } from "@xterm/addon-web-links"; import { WebglAddon } from "@xterm/addon-webgl"; import { Terminal } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; -import { Button, Typography } from "@nous-research/ui"; +import { Button } from "@nous-research/ui/ui/components/button"; +import { Typography } from "@/components/NouiTypography"; import { cn } from "@/lib/utils"; import { Copy, PanelRight, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; diff --git a/web/src/pages/ConfigPage.tsx b/web/src/pages/ConfigPage.tsx index 3c392bd6..1a8be51e 100644 --- a/web/src/pages/ConfigPage.tsx +++ b/web/src/pages/ConfigPage.tsx @@ -33,10 +33,12 @@ import { getNestedValue, setNestedValue } from "@/lib/nested"; import { useToast } from "@/hooks/useToast"; import { Toast } from "@/components/Toast"; import { AutoField } from "@/components/AutoField"; -import { Button, ListItem, Spinner } from "@nous-research/ui"; +import { Button } from "@nous-research/ui/ui/components/button"; +import { ListItem } from "@nous-research/ui/ui/components/list-item"; +import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; -import { Badge } from "@nous-research/ui"; +import { Badge } from "@nous-research/ui/ui/components/badge"; import { useI18n } from "@/i18n"; import { usePageHeader } from "@/contexts/usePageHeader"; import { PluginSlot } from "@/plugins"; diff --git a/web/src/pages/CronPage.tsx b/web/src/pages/CronPage.tsx index 13c2f54a..90cc25ab 100644 --- a/web/src/pages/CronPage.tsx +++ b/web/src/pages/CronPage.tsx @@ -1,6 +1,10 @@ import { useCallback, useEffect, useState } from "react"; import { Clock, Pause, Play, Plus, Trash2, Zap } from "lucide-react"; -import { Badge, Button, H2, Select, SelectOption, Spinner } from "@nous-research/ui"; +import { Badge } from "@nous-research/ui/ui/components/badge"; +import { Button } from "@nous-research/ui/ui/components/button"; +import { Select, SelectOption } from "@nous-research/ui/ui/components/select"; +import { Spinner } from "@nous-research/ui/ui/components/spinner"; +import { H2 } from "@/components/NouiTypography"; import { api } from "@/lib/api"; import type { CronJob } from "@/lib/api"; import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog"; diff --git a/web/src/pages/EnvPage.tsx b/web/src/pages/EnvPage.tsx index a0aca5c5..9751ce37 100644 --- a/web/src/pages/EnvPage.tsx +++ b/web/src/pages/EnvPage.tsx @@ -21,7 +21,9 @@ import { Toast } from "@/components/Toast"; import { useConfirmDelete } from "@/hooks/useConfirmDelete"; import { useToast } from "@/hooks/useToast"; import { OAuthProvidersCard } from "@/components/OAuthProvidersCard"; -import { Button, ListItem, Spinner } from "@nous-research/ui"; +import { Button } from "@nous-research/ui/ui/components/button"; +import { ListItem } from "@nous-research/ui/ui/components/list-item"; +import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { Card, CardContent, @@ -29,7 +31,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { Badge } from "@nous-research/ui"; +import { Badge } from "@nous-research/ui/ui/components/badge"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useI18n } from "@/i18n"; diff --git a/web/src/pages/LogsPage.tsx b/web/src/pages/LogsPage.tsx index fc9e58e9..da9afe92 100644 --- a/web/src/pages/LogsPage.tsx +++ b/web/src/pages/LogsPage.tsx @@ -7,14 +7,11 @@ import { } from "react"; import { FileText, RefreshCw } from "lucide-react"; import { api } from "@/lib/api"; -import { - Badge, - Button, - FilterGroup, - Segmented, - Spinner, - Switch, -} from "@nous-research/ui"; +import { Badge } from "@nous-research/ui/ui/components/badge"; +import { Button } from "@nous-research/ui/ui/components/button"; +import { FilterGroup, Segmented } from "@nous-research/ui/ui/components/segmented"; +import { Spinner } from "@nous-research/ui/ui/components/spinner"; +import { Switch } from "@nous-research/ui/ui/components/switch"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Label } from "@/components/ui/label"; import { useI18n } from "@/i18n"; diff --git a/web/src/pages/ModelsPage.tsx b/web/src/pages/ModelsPage.tsx index 955ed0da..72b082f6 100644 --- a/web/src/pages/ModelsPage.tsx +++ b/web/src/pages/ModelsPage.tsx @@ -20,9 +20,11 @@ import type { } from "@/lib/api"; import { timeAgo } from "@/lib/utils"; import { formatTokenCount } from "@/lib/format"; -import { Button, Spinner, Stats } from "@nous-research/ui"; +import { Button } from "@nous-research/ui/ui/components/button"; +import { Spinner } from "@nous-research/ui/ui/components/spinner"; +import { Stats } from "@nous-research/ui/ui/components/stats"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@nous-research/ui"; +import { Badge } from "@nous-research/ui/ui/components/badge"; import { usePageHeader } from "@/contexts/usePageHeader"; import { useI18n } from "@/i18n"; import { PluginSlot } from "@/plugins"; diff --git a/web/src/pages/ProfilesPage.tsx b/web/src/pages/ProfilesPage.tsx new file mode 100644 index 00000000..e8dbfe07 --- /dev/null +++ b/web/src/pages/ProfilesPage.tsx @@ -0,0 +1,444 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { ChevronDown, Pencil, Plus, Terminal, Trash2, Users } from "lucide-react"; +import { H2 } from "@/components/NouiTypography"; +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 "@nous-research/ui/ui/components/badge"; +import { Button } from "@nous-research/ui/ui/components/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +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 POST. +const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/; + +export default function ProfilesPage() { + const [profiles, setProfiles] = useState([]); + const [loading, setLoading] = useState(true); + const { toast, showToast } = useToast(); + const { t } = useI18n(); + + // Create form + const [newName, setNewName] = useState(""); + const [cloneFromDefault, setCloneFromDefault] = useState(true); + const [creating, setCreating] = useState(false); + + // Inline rename state + const [renamingFrom, setRenamingFrom] = useState(null); + const [renameTo, setRenameTo] = useState(""); + + // Inline SOUL editor state + const [editingSoulFor, setEditingSoulFor] = useState(null); + const [soulText, setSoulText] = useState(""); + const [soulSaving, setSoulSaving] = useState(false); + // Tracks the latest SOUL request so out-of-order responses don't overwrite + // newer state when the user switches profiles or closes the editor. + const activeSoulRequest = useRef(null); + + const load = useCallback(() => { + api + .getProfiles() + .then((res) => setProfiles(res.profiles)) + .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 { + await api.createProfile({ name, clone_from_default: cloneFromDefault }); + showToast(`${t.profiles.created}: ${name}`, "success"); + setNewName(""); + load(); + } catch (e) { + showToast(`${t.status.error}: ${e}`, "error"); + } finally { + setCreating(false); + } + }; + + const handleRenameSubmit = async () => { + if (!renamingFrom) return; + const target = renameTo.trim(); + if (!target || target === renamingFrom) { + setRenamingFrom(null); + setRenameTo(""); + return; + } + if (!PROFILE_NAME_RE.test(target)) { + 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 openSoulEditor = useCallback( + async (name: string) => { + if (editingSoulFor === name) { + activeSoulRequest.current = null; + setEditingSoulFor(null); + return; + } + setEditingSoulFor(name); + setSoulText(""); + activeSoulRequest.current = name; + try { + const soul = await api.getProfileSoul(name); + if (activeSoulRequest.current === name) { + setSoulText(soul.content); + } + } catch (e) { + if (activeSoulRequest.current === name) { + showToast(`${t.status.error}: ${e}`, "error"); + } + } + }, + [editingSoulFor, 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 handleCopyTerminalCommand = async (name: string) => { + let cmd: string; + try { + const res = await api.getProfileSetupCommand(name); + cmd = res.command; + } catch (e) { + showToast(`${t.status.error}: ${e}`, "error"); + return; + } + try { + await navigator.clipboard.writeText(cmd); + showToast(`${t.profiles.commandCopied}: ${cmd}`, "success"); + } catch { + showToast(`${t.profiles.copyFailed}: ${cmd}`, "error"); + } + }; + + 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; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( + // Profile names, model slugs, and paths are case-sensitive; opt out of + // the app shell's global ``uppercase`` so they render as the user typed. + // Children that explicitly opt back in (Badges, etc.) keep their casing. +
+ + + + + {/* Create new profile */} + + + + + {t.profiles.newProfile} + + + +
+
+ + setNewName(e.target.value)} + aria-invalid={ + newName.trim() !== "" && + !PROFILE_NAME_RE.test(newName.trim()) + } + /> +

+ {t.profiles.nameRule} +

+
+ + + +
+ +
+
+
+
+ + {/* List */} +
+

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

+ + {profiles.length === 0 && ( + + + {t.profiles.noProfiles} + + + )} + + {profiles.map((p) => { + const isRenaming = renamingFrom === p.name; + const isEditingSoul = editingSoulFor === 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} + )} + {p.has_env && ( + {t.profiles.hasEnv} + )} +
+ {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 && ( + + )} + + )} +
+
+ + {isEditingSoul && ( +
+ +