Merge pull request #16419 from vincez-hms-coder/feat/dashboard-profiles-hms-coder
feat(dashboard): add profiles management page
This commit is contained in:
commit
6bc5d72271
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
16
tests/hermes_cli/test_dashboard_browser_safe_imports.py
Normal file
16
tests/hermes_cli/test_dashboard_browser_safe_imports.py
Normal file
@ -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 == []
|
||||
11
tests/hermes_cli/test_dashboard_profiles_nav_label.py
Normal file
11
tests/hermes_cli/test_dashboard_profiles_nav_label.py
Normal file
@ -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
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<string, ComponentType> = {
|
||||
"/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<string, ComponentType<{ className?: string }>> = {
|
||||
Globe,
|
||||
Database,
|
||||
Shield,
|
||||
Users,
|
||||
Wrench,
|
||||
Zap,
|
||||
Heart,
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
63
web/src/components/NouiTypography.tsx
Normal file
63
web/src/components/NouiTypography.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { forwardRef, type ElementType, type HTMLAttributes, type ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TypographyProps = HTMLAttributes<HTMLElement> & {
|
||||
as?: ElementType;
|
||||
children?: ReactNode;
|
||||
compressed?: boolean;
|
||||
courier?: boolean;
|
||||
expanded?: boolean;
|
||||
mondwest?: boolean;
|
||||
mono?: boolean;
|
||||
sans?: boolean;
|
||||
variant?: "sm" | "md" | "lg" | "xl";
|
||||
};
|
||||
|
||||
const variantClasses: Record<NonNullable<TypographyProps["variant"]>, 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<HTMLElement, TypographyProps>(function Typography(
|
||||
{
|
||||
as: Component = "span",
|
||||
className,
|
||||
compressed,
|
||||
courier,
|
||||
expanded,
|
||||
mondwest,
|
||||
mono,
|
||||
sans,
|
||||
variant,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const hasFontVariant = compressed || courier || expanded || mondwest || mono || sans;
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={cn(
|
||||
compressed && "font-compressed",
|
||||
courier && "font-courier",
|
||||
expanded && "font-expanded",
|
||||
mondwest && "font-mondwest tracking-[0.1875rem]",
|
||||
mono && "font-mono",
|
||||
(!hasFontVariant || sans) && "font-sans",
|
||||
variant && variantClasses[variant],
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const H2 = forwardRef<HTMLHeadingElement, Omit<TypographyProps, "as">>(function H2(
|
||||
{ className, variant = "lg", ...props },
|
||||
ref,
|
||||
) {
|
||||
return <Typography as="h2" className={cn("font-bold", className)} variant={variant} ref={ref} {...props} />;
|
||||
});
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { ListItem } from "@nous-research/ui";
|
||||
import { ListItem } from "@nous-research/ui/ui/components/list-item";
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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...",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: "搜索技能和工具集...",
|
||||
|
||||
@ -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<SkillInfo[]>("/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;
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
444
web/src/pages/ProfilesPage.tsx
Normal file
444
web/src/pages/ProfilesPage.tsx
Normal file
@ -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<ProfileInfo[]>([]);
|
||||
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<string | null>(null);
|
||||
const [renameTo, setRenameTo] = useState("");
|
||||
|
||||
// Inline SOUL editor state
|
||||
const [editingSoulFor, setEditingSoulFor] = useState<string | null>(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<string | null>(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<string>({
|
||||
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 (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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.
|
||||
<div className="flex flex-col gap-6 normal-case">
|
||||
<Toast toast={toast} />
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={profileDelete.isOpen}
|
||||
onCancel={profileDelete.cancel}
|
||||
onConfirm={profileDelete.confirm}
|
||||
title={t.profiles.confirmDeleteTitle}
|
||||
description={
|
||||
pendingName
|
||||
? t.profiles.confirmDeleteMessage.replace("{name}", pendingName)
|
||||
: t.profiles.confirmDeleteMessage
|
||||
}
|
||||
loading={profileDelete.isDeleting}
|
||||
/>
|
||||
|
||||
{/* Create new profile */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t.profiles.newProfile}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="profile-name">{t.profiles.name}</Label>
|
||||
<Input
|
||||
id="profile-name"
|
||||
placeholder={t.profiles.namePlaceholder}
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
aria-invalid={
|
||||
newName.trim() !== "" &&
|
||||
!PROFILE_NAME_RE.test(newName.trim())
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t.profiles.nameRule}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cloneFromDefault}
|
||||
onChange={(e) => setCloneFromDefault(e.target.checked)}
|
||||
/>
|
||||
{t.profiles.cloneFromDefault}
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<Button onClick={handleCreate} disabled={creating}>
|
||||
<Plus className="h-3 w-3" />
|
||||
{creating ? t.common.creating : t.common.create}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<H2
|
||||
variant="sm"
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
{t.profiles.allProfiles} ({profiles.length})
|
||||
</H2>
|
||||
|
||||
{profiles.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t.profiles.noProfiles}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{profiles.map((p) => {
|
||||
const isRenaming = renamingFrom === p.name;
|
||||
const isEditingSoul = editingSoulFor === p.name;
|
||||
return (
|
||||
<Card key={p.name}>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
{isRenaming ? (
|
||||
<Input
|
||||
autoFocus
|
||||
value={renameTo}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
) : (
|
||||
<span className="font-medium text-sm truncate">
|
||||
{p.name}
|
||||
</span>
|
||||
)}
|
||||
{p.is_default && (
|
||||
<Badge tone="secondary">{t.profiles.defaultBadge}</Badge>
|
||||
)}
|
||||
{p.has_env && (
|
||||
<Badge tone="outline">{t.profiles.hasEnv}</Badge>
|
||||
)}
|
||||
</div>
|
||||
{isRenaming &&
|
||||
(() => {
|
||||
const trimmed = renameTo.trim();
|
||||
const invalid =
|
||||
trimmed !== "" &&
|
||||
trimmed !== p.name &&
|
||||
!PROFILE_NAME_RE.test(trimmed);
|
||||
return (
|
||||
<p
|
||||
className={
|
||||
"text-xs mb-1 " +
|
||||
(invalid
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground")
|
||||
}
|
||||
>
|
||||
{invalid
|
||||
? `${t.profiles.invalidName}: ${t.profiles.nameRule}`
|
||||
: t.profiles.nameRule}
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground flex-wrap">
|
||||
{p.model && (
|
||||
<span>
|
||||
{t.profiles.model}: {p.model}
|
||||
{p.provider ? ` (${p.provider})` : ""}
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
{t.profiles.skills}: {p.skill_count}
|
||||
</span>
|
||||
<span className="font-mono truncate max-w-[28rem]">
|
||||
{p.path}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{isRenaming ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleRenameSubmit}
|
||||
>
|
||||
{t.common.save}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
ghost
|
||||
onClick={() => setRenamingFrom(null)}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
title={t.profiles.editSoul}
|
||||
aria-label={t.profiles.editSoul}
|
||||
onClick={() => openSoulEditor(p.name)}
|
||||
>
|
||||
{isEditingSoul ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<span aria-hidden className="text-xs font-bold">
|
||||
S
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
title={t.profiles.openInTerminal}
|
||||
aria-label={t.profiles.openInTerminal}
|
||||
onClick={() => handleCopyTerminalCommand(p.name)}
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
</Button>
|
||||
{!p.is_default && (
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
title={t.profiles.rename}
|
||||
aria-label={t.profiles.rename}
|
||||
onClick={() => {
|
||||
setRenamingFrom(p.name);
|
||||
setRenameTo(p.name);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{!p.is_default && (
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
title={t.common.delete}
|
||||
aria-label={t.common.delete}
|
||||
onClick={() => profileDelete.requestDelete(p.name)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
{isEditingSoul && (
|
||||
<div className="border-t border-border px-4 pb-4 pt-3 flex flex-col gap-2">
|
||||
<Label
|
||||
htmlFor={`soul-editor-${p.name}`}
|
||||
className="flex items-center gap-2 text-xs uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
{t.profiles.soulSection}
|
||||
</Label>
|
||||
<textarea
|
||||
id={`soul-editor-${p.name}`}
|
||||
className="flex min-h-[180px] w-full border border-input bg-transparent px-3 py-2 text-sm font-mono shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder={t.profiles.soulPlaceholder}
|
||||
value={soulText}
|
||||
onChange={(e) => setSoulText(e.target.value)}
|
||||
/>
|
||||
<div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSaveSoul(p.name)}
|
||||
disabled={soulSaving}
|
||||
>
|
||||
{soulSaving ? t.common.saving : t.profiles.saveSoul}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -35,8 +35,10 @@ import { timeAgo } from "@/lib/utils";
|
||||
import { Markdown } from "@/components/Markdown";
|
||||
import { PlatformsCard } from "@/components/PlatformsCard";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import { Button, ListItem, Spinner } from "@nous-research/ui";
|
||||
import { Badge } 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 { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
||||
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
|
||||
|
||||
@ -20,7 +20,11 @@ import type { SkillInfo, ToolsetInfo } from "@/lib/api";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge, Button, ListItem, 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 { ListItem } from "@nous-research/ui/ui/components/list-item";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { Switch } from "@nous-research/ui/ui/components/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { Spinner } from "@nous-research/ui";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import {
|
||||
getPluginComponent,
|
||||
getPluginLoadError,
|
||||
|
||||
@ -19,12 +19,14 @@ import React, {
|
||||
} from "react";
|
||||
import { api, fetchJSON } from "@/lib/api";
|
||||
import { cn, timeAgo, isoTimeAgo } from "@/lib/utils";
|
||||
import { Badge, Button, Select, SelectOption } 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 { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@nous-research/ui";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@nous-research/ui/ui/components/tabs";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { registerSlot, PluginSlot } from "./slots";
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user