diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index b91edc16d1..8f52835d78 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2100,6 +2100,126 @@ 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_to_dict(info) -> Dict[str, Any]: + return { + "name": info.name, + "path": str(info.path), + "is_default": info.is_default, + "model": info.model, + "provider": info.provider, + "has_env": info.has_env, + "skill_count": info.skill_count, + } + + +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) + + +@app.get("/api/profiles") +async def list_profiles_endpoint(): + from hermes_cli import profiles as profiles_mod + return {"profiles": [_profile_to_dict(p) for p in profiles_mod.list_profiles()]} + + +@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, + ) + except (ValueError, FileExistsError, FileNotFoundError) as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + _log.exception("POST /api/profiles failed") + raise HTTPException(status_code=500, detail=str(e)) + return {"ok": True, "name": body.name, "path": str(path)} + + +@app.patch("/api/profiles/{name}") +async def rename_profile_endpoint(name: str, body: ProfileRename): + from hermes_cli import profiles as profiles_mod + try: + path = profiles_mod.rename_profile(name, body.new_name) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except (ValueError, FileExistsError) as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + _log.exception("PATCH /api/profiles/%s failed", name) + raise HTTPException(status_code=500, detail=str(e)) + return {"ok": True, "name": body.new_name, "path": str(path)} + + +@app.delete("/api/profiles/{name}") +async def delete_profile_endpoint(name: str): + """Delete a profile. The dashboard 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_web_server.py b/tests/hermes_cli/test_web_server.py index e7b3b03305..b090c5f23d 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -585,6 +585,77 @@ 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_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_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 3acb886d93..835d3e268c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -37,6 +37,7 @@ import { Sparkles, Star, Terminal, + Users, Wrench, X, Zap, @@ -62,6 +63,7 @@ import SessionsPage from "@/pages/SessionsPage"; import LogsPage from "@/pages/LogsPage"; import AnalyticsPage from "@/pages/AnalyticsPage"; import CronPage from "@/pages/CronPage"; +import ProfilesPage from "@/pages/ProfilesPage"; import SkillsPage from "@/pages/SkillsPage"; import ChatPage from "@/pages/ChatPage"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; @@ -99,6 +101,7 @@ const BUILTIN_ROUTES_CORE: Record = { "/logs": LogsPage, "/cron": CronPage, "/skills": SkillsPage, + "/profiles": ProfilesPage, "/config": ConfigPage, "/env": EnvPage, "/docs": DocsPage, @@ -128,6 +131,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 }, { @@ -153,6 +157,7 @@ const ICON_MAP: Record> = { Globe, Database, Shield, + Users, Wrench, Zap, Heart, diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index bf8b34356a..f3974c1ee2 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -74,6 +74,7 @@ export const en: Translations = { documentation: "Documentation", keys: "Keys", logs: "Logs", + profiles: "Profiles: Running Multiple Agents", sessions: "Sessions", skills: "Skills", }, @@ -210,6 +211,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 718115e975..0da93a722e 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -74,6 +74,7 @@ export interface Translations { documentation: string; keys: string; logs: string; + profiles: string; sessions: string; skills: string; }; @@ -213,6 +214,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 ff8f3a2798..8c3753e4d4 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -73,6 +73,7 @@ export const zh: Translations = { documentation: "文档", keys: "密钥", logs: "日志", + profiles: "多Agent配置", sessions: "会话", skills: "技能", }, @@ -207,6 +208,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 b4790f267f..5b1fa9fb24 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -122,6 +122,43 @@ 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" }, + ), + 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) => @@ -370,6 +407,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 CronJob { id: string; name?: string; diff --git a/web/src/pages/ProfilesPage.tsx b/web/src/pages/ProfilesPage.tsx new file mode 100644 index 0000000000..e55f99977f --- /dev/null +++ b/web/src/pages/ProfilesPage.tsx @@ -0,0 +1,425 @@ +import { useCallback, useEffect, useState } from "react"; +import { ChevronDown, Pencil, Plus, Terminal, Trash2, Users } from "lucide-react"; +import { H2 } from "@nous-research/ui"; +import { api } from "@/lib/api"; +import type { ProfileInfo } from "@/lib/api"; +import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog"; +import { useToast } from "@/hooks/useToast"; +import { useConfirmDelete } from "@/hooks/useConfirmDelete"; +import { Toast } from "@/components/Toast"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { 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); + + 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) { + setEditingSoulFor(null); + return; + } + setEditingSoulFor(name); + setSoulText(""); + try { + const soul = await api.getProfileSoul(name); + setSoulText(soul.content); + } catch (e) { + 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) => { + const cmd = `hermes -p ${name}`; + 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 && ( +
+ +