feat(dashboard): add profiles management page
Copy profile dashboard changes onto a fresh branch under the vincez-hms-coder account. Includes: - Profiles dashboard route and sidebar entry - Profile lifecycle REST endpoints - SOUL.md read/write support - i18n labels and helper text updates - Targeted profile API tests Test plan: - pytest tests/hermes_cli/test_web_server.py -k profile -q - cd web && npm run build
This commit is contained in:
@@ -2100,6 +2100,126 @@ async def delete_cron_job(job_id: str):
|
|||||||
return {"ok": True}
|
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
|
# Skills & Tools endpoints
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -585,6 +585,77 @@ class TestNewEndpoints:
|
|||||||
resp = self.client.get("/api/cron/jobs/nonexistent-id")
|
resp = self.client.get("/api/cron/jobs/nonexistent-id")
|
||||||
assert resp.status_code == 404
|
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):
|
def test_skills_list(self):
|
||||||
resp = self.client.get("/api/skills")
|
resp = self.client.get("/api/skills")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
Star,
|
Star,
|
||||||
Terminal,
|
Terminal,
|
||||||
|
Users,
|
||||||
Wrench,
|
Wrench,
|
||||||
X,
|
X,
|
||||||
Zap,
|
Zap,
|
||||||
@@ -62,6 +63,7 @@ import SessionsPage from "@/pages/SessionsPage";
|
|||||||
import LogsPage from "@/pages/LogsPage";
|
import LogsPage from "@/pages/LogsPage";
|
||||||
import AnalyticsPage from "@/pages/AnalyticsPage";
|
import AnalyticsPage from "@/pages/AnalyticsPage";
|
||||||
import CronPage from "@/pages/CronPage";
|
import CronPage from "@/pages/CronPage";
|
||||||
|
import ProfilesPage from "@/pages/ProfilesPage";
|
||||||
import SkillsPage from "@/pages/SkillsPage";
|
import SkillsPage from "@/pages/SkillsPage";
|
||||||
import ChatPage from "@/pages/ChatPage";
|
import ChatPage from "@/pages/ChatPage";
|
||||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||||
@@ -99,6 +101,7 @@ const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
|
|||||||
"/logs": LogsPage,
|
"/logs": LogsPage,
|
||||||
"/cron": CronPage,
|
"/cron": CronPage,
|
||||||
"/skills": SkillsPage,
|
"/skills": SkillsPage,
|
||||||
|
"/profiles": ProfilesPage,
|
||||||
"/config": ConfigPage,
|
"/config": ConfigPage,
|
||||||
"/env": EnvPage,
|
"/env": EnvPage,
|
||||||
"/docs": DocsPage,
|
"/docs": DocsPage,
|
||||||
@@ -128,6 +131,7 @@ const BUILTIN_NAV_REST: NavItem[] = [
|
|||||||
{ path: "/logs", labelKey: "logs", label: "Logs", icon: FileText },
|
{ path: "/logs", labelKey: "logs", label: "Logs", icon: FileText },
|
||||||
{ path: "/cron", labelKey: "cron", label: "Cron", icon: Clock },
|
{ path: "/cron", labelKey: "cron", label: "Cron", icon: Clock },
|
||||||
{ path: "/skills", labelKey: "skills", label: "Skills", icon: Package },
|
{ 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: "/config", labelKey: "config", label: "Config", icon: Settings },
|
||||||
{ path: "/env", labelKey: "keys", label: "Keys", icon: KeyRound },
|
{ path: "/env", labelKey: "keys", label: "Keys", icon: KeyRound },
|
||||||
{
|
{
|
||||||
@@ -153,6 +157,7 @@ const ICON_MAP: Record<string, ComponentType<{ className?: string }>> = {
|
|||||||
Globe,
|
Globe,
|
||||||
Database,
|
Database,
|
||||||
Shield,
|
Shield,
|
||||||
|
Users,
|
||||||
Wrench,
|
Wrench,
|
||||||
Zap,
|
Zap,
|
||||||
Heart,
|
Heart,
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export const en: Translations = {
|
|||||||
documentation: "Documentation",
|
documentation: "Documentation",
|
||||||
keys: "Keys",
|
keys: "Keys",
|
||||||
logs: "Logs",
|
logs: "Logs",
|
||||||
|
profiles: "Profiles: Running Multiple Agents",
|
||||||
sessions: "Sessions",
|
sessions: "Sessions",
|
||||||
skills: "Skills",
|
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: {
|
skills: {
|
||||||
title: "Skills",
|
title: "Skills",
|
||||||
searchPlaceholder: "Search skills and toolsets...",
|
searchPlaceholder: "Search skills and toolsets...",
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export interface Translations {
|
|||||||
documentation: string;
|
documentation: string;
|
||||||
keys: string;
|
keys: string;
|
||||||
logs: string;
|
logs: string;
|
||||||
|
profiles: string;
|
||||||
sessions: string;
|
sessions: string;
|
||||||
skills: 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 page ──
|
||||||
skills: {
|
skills: {
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export const zh: Translations = {
|
|||||||
documentation: "文档",
|
documentation: "文档",
|
||||||
keys: "密钥",
|
keys: "密钥",
|
||||||
logs: "日志",
|
logs: "日志",
|
||||||
|
profiles: "多Agent配置",
|
||||||
sessions: "会话",
|
sessions: "会话",
|
||||||
skills: "技能",
|
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: {
|
skills: {
|
||||||
title: "技能",
|
title: "技能",
|
||||||
searchPlaceholder: "搜索技能和工具集...",
|
searchPlaceholder: "搜索技能和工具集...",
|
||||||
|
|||||||
@@ -122,6 +122,43 @@ export const api = {
|
|||||||
deleteCronJob: (id: string) =>
|
deleteCronJob: (id: string) =>
|
||||||
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}`, { method: "DELETE" }),
|
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
|
// Skills & Toolsets
|
||||||
getSkills: () => fetchJSON<SkillInfo[]>("/api/skills"),
|
getSkills: () => fetchJSON<SkillInfo[]>("/api/skills"),
|
||||||
toggleSkill: (name: string, enabled: boolean) =>
|
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 {
|
export interface CronJob {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|||||||
425
web/src/pages/ProfilesPage.tsx
Normal file
425
web/src/pages/ProfilesPage.tsx
Normal file
@@ -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<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);
|
||||||
|
|
||||||
|
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<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 variant="secondary">{t.profiles.defaultBadge}</Badge>
|
||||||
|
)}
|
||||||
|
{p.has_env && (
|
||||||
|
<Badge variant="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"
|
||||||
|
variant="default"
|
||||||
|
onClick={handleRenameSubmit}
|
||||||
|
>
|
||||||
|
{t.common.save}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setRenamingFrom(null)}
|
||||||
|
>
|
||||||
|
{t.common.cancel}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="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
|
||||||
|
variant="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
|
||||||
|
variant="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
|
||||||
|
variant="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 className="flex items-center gap-2 text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
|
{t.profiles.soulSection}
|
||||||
|
</Label>
|
||||||
|
<textarea
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user