mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
feat(dashboard): SKILL.md editor on Skills page + attach-skill selector in cron modals (#44231)
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).
This commit is contained in:
parent
f456f302df
commit
a09343cc96
6 changed files with 806 additions and 22 deletions
215
web/src/components/SkillEditorDialog.tsx
Normal file
215
web/src/components/SkillEditorDialog.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue