mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-02 12:13:05 +00:00
Headless/VPS users (dashboard-over-Tailscale, no comfortable SSH) could list/toggle/install skills and create/edit cron jobs, but not author a custom skill or link one to a cron job — the UI set WHEN a job runs, but not WHICH skill it uses. - Skills page: 'New skill' button + per-row edit pencil open a SKILL.md editor dialog (frontmatter + body, server-side validation via the same _create_skill/_edit_skill path as the agent's skill_manage tool). - New endpoints: GET /api/skills/content, POST /api/skills, PUT /api/skills/content — all profile-scoped via _profile_scope(), which now also retargets tools.skill_manager_tool's import-time SKILLS_DIR binding. - Cron page: skills multi-select in both create and edit modals (parity with hermes cron --skill / edit --add-skill); CronJobCreate gains a skills field; job cards show an attached-skills badge. update_job already accepted skills in updates. - Tests: 17 new endpoint tests (content read, create/edit validation + profile scoping + auth gate, cron skills round-trip).
215 lines
6.9 KiB
TypeScript
215 lines
6.9 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { api } from "@/lib/api";
|
|
import { Button } from "@nous-research/ui/ui/components/button";
|
|
import { Input } from "@nous-research/ui/ui/components/input";
|
|
import { Label } from "@nous-research/ui/ui/components/label";
|
|
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@nous-research/ui/ui/components/dialog";
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* SkillEditorDialog — create or edit a SKILL.md from the dashboard */
|
|
/* */
|
|
/* Headless/VPS users have no editor besides this: the only other way */
|
|
/* to author a custom skill is SSH + a terminal editor. Create mode */
|
|
/* posts a brand-new skill (name + optional category + SKILL.md); */
|
|
/* edit mode loads the existing SKILL.md raw text and rewrites it. */
|
|
/* Validation (frontmatter, name, size) happens server-side via the */
|
|
/* same path the agent's skill_manage tool uses, so errors come back */
|
|
/* as actionable messages rendered inline. */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
const CREATE_TEMPLATE = `---
|
|
name: my-skill
|
|
description: One-line description of when to use this skill.
|
|
---
|
|
|
|
# My Skill
|
|
|
|
Numbered steps, exact commands, and pitfalls go here.
|
|
`;
|
|
|
|
export interface SkillEditorDialogProps {
|
|
open: boolean;
|
|
/** Skill name to edit, or null for create mode. */
|
|
editName: string | null;
|
|
/** Profile to scope reads/writes to ("" = the dashboard's own profile). */
|
|
profile?: string;
|
|
onClose: () => void;
|
|
/** Called after a successful save so the page can refresh its list. */
|
|
onSaved: (name: string) => void;
|
|
}
|
|
|
|
export function SkillEditorDialog({
|
|
open,
|
|
editName,
|
|
profile,
|
|
onClose,
|
|
onSaved,
|
|
}: SkillEditorDialogProps) {
|
|
// The body is remounted via `key` every time the dialog opens or the
|
|
// target skill changes, so all form state initializes through useState
|
|
// initializers — no reset-on-open effect (react-hooks/set-state-in-effect).
|
|
return (
|
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
|
<DialogContent className="max-w-3xl">
|
|
{open && (
|
|
<EditorBody
|
|
key={editName ?? "__create__"}
|
|
editName={editName}
|
|
profile={profile}
|
|
onClose={onClose}
|
|
onSaved={onSaved}
|
|
/>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function EditorBody({
|
|
editName,
|
|
profile,
|
|
onClose,
|
|
onSaved,
|
|
}: Omit<SkillEditorDialogProps, "open">) {
|
|
const isEdit = editName !== null;
|
|
const [name, setName] = useState("");
|
|
const [category, setCategory] = useState("");
|
|
const [content, setContent] = useState(isEdit ? "" : CREATE_TEMPLATE);
|
|
const [loading, setLoading] = useState(isEdit);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!editName) return;
|
|
let cancelled = false;
|
|
api
|
|
.getSkillContent(editName, profile || undefined)
|
|
.then((res) => !cancelled && setContent(res.content))
|
|
.catch((e) => !cancelled && setError(String(e)))
|
|
.finally(() => !cancelled && setLoading(false));
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [editName, profile]);
|
|
|
|
const handleSave = async () => {
|
|
setError(null);
|
|
if (!isEdit && !name.trim()) {
|
|
setError("Skill name is required.");
|
|
return;
|
|
}
|
|
if (!content.trim()) {
|
|
setError("SKILL.md content is required.");
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
if (isEdit) {
|
|
await api.updateSkillContent(editName, content, profile || undefined);
|
|
onSaved(editName);
|
|
} else {
|
|
const trimmed = name.trim();
|
|
await api.createSkill(
|
|
{
|
|
name: trimmed,
|
|
content,
|
|
category: category.trim() || undefined,
|
|
},
|
|
profile || undefined,
|
|
);
|
|
onSaved(trimmed);
|
|
}
|
|
onClose();
|
|
} catch (e) {
|
|
setError(String(e));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{isEdit ? `Edit skill: ${editName}` : "New skill"}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{isEdit
|
|
? "Rewrite this skill's SKILL.md. Frontmatter (name, description) is validated on save."
|
|
: "Author a custom skill — YAML frontmatter plus markdown instructions. It becomes available to the agent and attachable to cron jobs."}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="grid gap-3">
|
|
{!isEdit && (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<div className="grid gap-1.5">
|
|
<Label htmlFor="skill-editor-name">Name</Label>
|
|
<Input
|
|
id="skill-editor-name"
|
|
autoFocus
|
|
placeholder="my-skill"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-1.5">
|
|
<Label htmlFor="skill-editor-category">Category (optional)</Label>
|
|
<Input
|
|
id="skill-editor-category"
|
|
placeholder="devops"
|
|
value={category}
|
|
onChange={(e) => setCategory(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid gap-1.5">
|
|
<Label htmlFor="skill-editor-content">SKILL.md</Label>
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-16">
|
|
<Spinner className="text-xl text-primary" />
|
|
</div>
|
|
) : (
|
|
<textarea
|
|
id="skill-editor-content"
|
|
spellCheck={false}
|
|
className="min-h-[320px] max-h-[55vh] w-full resize-y border border-border bg-background/40 px-3 py-2 font-mono text-xs leading-relaxed shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
|
|
value={content}
|
|
onChange={(e) => setContent(e.target.value)}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{error && (
|
|
<p className="whitespace-pre-wrap text-xs text-destructive">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
<div className="flex items-center justify-end gap-2">
|
|
<Button ghost size="sm" onClick={onClose} disabled={saving}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
className="uppercase"
|
|
onClick={handleSave}
|
|
disabled={saving || loading}
|
|
prefix={saving ? <Spinner /> : undefined}
|
|
>
|
|
{saving ? "Saving…" : isEdit ? "Save changes" : "Create skill"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|