mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-03 02:11:48 +00:00
425 lines
15 KiB
TypeScript
425 lines
15 KiB
TypeScript
import { useCallback, useEffect, 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 "@/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>
|
|
);
|
|
}
|