Enhance MCP development features and introduce skills management
- Added configuration options for requiring human approval before applying LLM-generated MCP patches. - Updated Docker setup to include skills directory. - Integrated skills management into the backend, allowing for procedural guides and skill matching. - Refactored database initialization to apply Alembic migrations. - Enhanced task approval process to handle MCP patch applications with optional approval. - Introduced new schemas for skills and updated existing APIs to support skills functionality. This commit lays the groundwork for improved agent capabilities and better management of MCP development processes.
This commit is contained in:
199
frontend/app/skills/page.tsx
Normal file
199
frontend/app/skills/page.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import AppShell from "@/components/AppShell";
|
||||
import { PageHeader } from "@/components/ui";
|
||||
import { api, fetcher } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
type SkillSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
priority: number;
|
||||
rule_ids: string[];
|
||||
path_hint: string;
|
||||
};
|
||||
|
||||
type SkillsListResponse = {
|
||||
skills: SkillSummary[];
|
||||
path_hint: string;
|
||||
};
|
||||
|
||||
type SkillDetail = SkillSummary & {
|
||||
markdown: string;
|
||||
match: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export default function SkillsPage() {
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === "admin";
|
||||
const { data: listData } = useSWR<SkillsListResponse>("/api/skills", fetcher);
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const [markdown, setMarkdown] = useState<string | null>(null);
|
||||
const [previewTitle, setPreviewTitle] = useState("VoIP issue");
|
||||
const [previewIssue, setPreviewIssue] = useState("one-way audio on SIP calls from crew phones");
|
||||
const [previewRuleId, setPreviewRuleId] = useState("");
|
||||
const [preview, setPreview] = useState<{ matched: Record<string, unknown>[] } | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [msgKind, setMsgKind] = useState<"ok" | "err">("ok");
|
||||
|
||||
const skills = listData?.skills ?? [];
|
||||
const activeId = selectedId ?? skills[0]?.id ?? null;
|
||||
|
||||
const { data: skillData, mutate } = useSWR<SkillDetail>(
|
||||
activeId ? `/api/skills/${activeId}` : null,
|
||||
fetcher,
|
||||
);
|
||||
|
||||
const content = markdown ?? skillData?.markdown ?? "";
|
||||
|
||||
const selectSkill = (id: string) => {
|
||||
setSelectedId(id);
|
||||
setMarkdown(null);
|
||||
setMsg(null);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!activeId) return;
|
||||
setSaving(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
await api(`/api/skills/${activeId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ markdown: content }),
|
||||
});
|
||||
setMsgKind("ok");
|
||||
setMsg("Skill saved — next task run will use it.");
|
||||
setMarkdown(null);
|
||||
mutate();
|
||||
} catch (err: unknown) {
|
||||
setMsgKind("err");
|
||||
setMsg(err instanceof Error ? err.message : "Save failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runPreview = async () => {
|
||||
setPreview(null);
|
||||
try {
|
||||
const body: Record<string, string> = {
|
||||
title: previewTitle,
|
||||
issue: previewIssue,
|
||||
};
|
||||
if (previewRuleId.trim()) body.matched_rule_id = previewRuleId.trim();
|
||||
const res = await api<{ matched: Record<string, unknown>[] }>("/api/skills/preview", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
setPreview(res);
|
||||
} catch (err: unknown) {
|
||||
setMsgKind("err");
|
||||
setMsg(err instanceof Error ? err.message : "Preview failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<PageHeader
|
||||
title="Agent skills"
|
||||
subtitle="Procedural guides injected into triage and reasoning — complements troubleshooting rules"
|
||||
/>
|
||||
|
||||
<div className="mb-6 grid gap-4 lg:grid-cols-2">
|
||||
<div className="card">
|
||||
<h3 className="mb-2 text-sm font-semibold">Skill preview</h3>
|
||||
<p className="mb-3 text-xs text-muted">Test which skills match a task title + issue.</p>
|
||||
<input
|
||||
className="input mb-2 w-full"
|
||||
value={previewTitle}
|
||||
onChange={(e) => setPreviewTitle(e.target.value)}
|
||||
placeholder="Task title"
|
||||
/>
|
||||
<textarea
|
||||
className="input mb-2 min-h-[80px] w-full"
|
||||
value={previewIssue}
|
||||
onChange={(e) => setPreviewIssue(e.target.value)}
|
||||
placeholder="Issue description"
|
||||
/>
|
||||
<input
|
||||
className="input mb-2 w-full"
|
||||
value={previewRuleId}
|
||||
onChange={(e) => setPreviewRuleId(e.target.value)}
|
||||
placeholder="Optional matched rule id (e.g. one-way-audio)"
|
||||
/>
|
||||
<button type="button" className="btn-secondary" onClick={runPreview}>
|
||||
Preview match
|
||||
</button>
|
||||
{preview?.matched && preview.matched.length > 0 && (
|
||||
<pre className="mt-3 overflow-x-auto rounded-lg bg-panel2 p-3 text-xs">
|
||||
{JSON.stringify(preview.matched, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
{preview && preview.matched.length === 0 && (
|
||||
<p className="mt-3 text-sm text-muted">No skills matched.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 className="mb-2 text-sm font-semibold">Defined skills</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{skills.map((s) => (
|
||||
<li key={s.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectSkill(s.id)}
|
||||
className={`flex w-full justify-between gap-2 border-b border-edge/50 py-2 text-left transition ${
|
||||
activeId === s.id ? "text-accent2" : "hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<span>{s.name}</span>
|
||||
<span className="text-xs text-muted">priority {s.priority}</span>
|
||||
</button>
|
||||
{s.rule_ids.length > 0 && (
|
||||
<p className="pb-2 text-[11px] text-muted">Rules: {s.rule_ids.join(", ")}</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-3 text-xs text-muted">Directory: {listData?.path_hint}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeId && (
|
||||
<div className="card">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{skillData?.name ?? activeId}{" "}
|
||||
<span className="font-normal text-muted">({skillData?.path_hint})</span>
|
||||
</h3>
|
||||
{!isAdmin && <span className="text-xs text-muted">Read-only (admin can edit)</span>}
|
||||
</div>
|
||||
{skillData?.description && (
|
||||
<p className="mb-3 text-xs text-muted">{skillData.description}</p>
|
||||
)}
|
||||
<textarea
|
||||
className="input min-h-[420px] w-full font-mono text-xs leading-relaxed"
|
||||
value={content}
|
||||
onChange={(e) => isAdmin && setMarkdown(e.target.value)}
|
||||
readOnly={!isAdmin}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{msg && (
|
||||
<p className={`mt-2 text-sm ${msgKind === "ok" ? "text-good" : "text-bad"}`}>{msg}</p>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button type="button" className="btn-primary mt-4" disabled={saving} onClick={save}>
|
||||
{saving ? "Saving…" : "Save skill"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user