File size: 7,085 Bytes
6aa76ed | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 | 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>
</>
);
}
|