Render validated HTML, SVG, JSX, and TSX fences locally while preserving tool context and preventing explanatory JSON from triggering actions. Co-authored-by: Cursor <cursoragent@cursor.com>
425 lines
16 KiB
React
425 lines
16 KiB
React
import { useEffect, useState, useCallback, useRef } from "react";
|
|
import { API_BASE } from "./config";
|
|
|
|
export function Playbook() {
|
|
const [playbooks, setPlaybooks] = useState([]);
|
|
const [selectedId, setSelectedId] = useState(null);
|
|
const [form, setForm] = useState({ title: "", goal: "", instructions: "", tags: "" });
|
|
const [search, setSearch] = useState("");
|
|
const [message, setMessage] = useState("");
|
|
const dragItem = useRef(null);
|
|
const dragOverItem = useRef(null);
|
|
|
|
const normalizePlaybooks = (data) => {
|
|
if (!data) return [];
|
|
if (Array.isArray(data)) return data;
|
|
if (Array.isArray(data.playbooks)) return data.playbooks;
|
|
if (typeof data === "object") return Object.values(data);
|
|
return [];
|
|
};
|
|
|
|
const loadPlaybooks = useCallback(async () => {
|
|
try {
|
|
const res = await fetch(`${API_BASE}/playbooks`);
|
|
if (!res.ok) { setPlaybooks([]); return; }
|
|
const data = await res.json().catch(() => null);
|
|
setPlaybooks(normalizePlaybooks(data));
|
|
} catch {
|
|
setPlaybooks([]);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => { loadPlaybooks(); }, [loadPlaybooks]);
|
|
|
|
const safeString = (v) => (v == null ? "" : String(v));
|
|
|
|
const filteredPlaybooks = Array.isArray(playbooks)
|
|
? playbooks.filter(p => {
|
|
if (!search.trim()) return true;
|
|
const q = search.toLowerCase();
|
|
return (
|
|
safeString(p.title).toLowerCase().includes(q) ||
|
|
safeString(p.goal).toLowerCase().includes(q) ||
|
|
(Array.isArray(p.tags) ? p.tags : []).some(t => safeString(t).toLowerCase().includes(q))
|
|
);
|
|
})
|
|
: [];
|
|
|
|
const tryFetchWithTrailingSlash = async (url, opts) => {
|
|
let res = await fetch(url, opts);
|
|
if (res.status === 404 && !url.endsWith("/")) res = await fetch(url + "/", opts);
|
|
return res;
|
|
};
|
|
|
|
const selectPlaybook = async (playbookSummary) => {
|
|
const id = encodeURIComponent(String(playbookSummary.id));
|
|
setMessage("");
|
|
setSelectedId(playbookSummary.id);
|
|
try {
|
|
const res = await tryFetchWithTrailingSlash(`${API_BASE}/playbooks/${id}`, { method: "GET" });
|
|
const full = res.ok ? await res.json() : playbookSummary;
|
|
setForm({
|
|
title: full.title || "",
|
|
goal: full.goal || "",
|
|
instructions: full.instructions || "",
|
|
tags: (full.tags || []).join(", "),
|
|
tools: (full.tools || []).join(", "),
|
|
model: full.model || "",
|
|
});
|
|
} catch {
|
|
setForm({
|
|
title: playbookSummary.title || "",
|
|
goal: playbookSummary.goal || "",
|
|
instructions: playbookSummary.instructions || "",
|
|
tags: (playbookSummary.tags || []).join(", "),
|
|
tools: (playbookSummary.tools || []).join(", "),
|
|
model: playbookSummary.model || "",
|
|
});
|
|
}
|
|
};
|
|
|
|
const resetForm = () => {
|
|
setSelectedId(null);
|
|
setForm({ title: "", goal: "", instructions: "", tags: "", tools: "", model: "" });
|
|
setMessage("");
|
|
};
|
|
|
|
const savePlaybook = async () => {
|
|
if (!form.title || !form.goal || !form.instructions) {
|
|
setMessage("Please fill in title, goal, and instructions.");
|
|
return;
|
|
}
|
|
const payload = {
|
|
title: form.title,
|
|
goal: form.goal,
|
|
instructions: form.instructions,
|
|
tags: form.tags.split(",").map(t => t.trim()).filter(Boolean),
|
|
tools: (form.tools || "").split(",").map(t => t.trim()).filter(Boolean),
|
|
model: (form.model || "").trim(),
|
|
};
|
|
const isUpdate = Boolean(selectedId);
|
|
const safeId = isUpdate ? encodeURIComponent(String(selectedId)) : null;
|
|
const baseUrl = isUpdate ? `${API_BASE}/playbooks/${safeId}` : `${API_BASE}/playbooks`;
|
|
const commonOpts = { headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) };
|
|
|
|
try {
|
|
let res;
|
|
if (isUpdate) {
|
|
res = await tryFetchWithTrailingSlash(baseUrl, { ...commonOpts, method: "PATCH" });
|
|
if (res.status === 404 || res.status === 405) {
|
|
const getRes = await tryFetchWithTrailingSlash(baseUrl, { method: "GET" });
|
|
const existing = getRes.ok ? await getRes.json().catch(() => ({})) : {};
|
|
res = await tryFetchWithTrailingSlash(baseUrl, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ ...existing, ...payload }),
|
|
});
|
|
}
|
|
} else {
|
|
res = await tryFetchWithTrailingSlash(baseUrl, { ...commonOpts, method: "POST" });
|
|
}
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => "");
|
|
setMessage(`Save failed: ${text || res.statusText}`);
|
|
return;
|
|
}
|
|
setMessage(isUpdate ? "Playbook updated." : "Playbook created.");
|
|
resetForm();
|
|
loadPlaybooks();
|
|
} catch (err) {
|
|
setMessage(`Save error: ${err.message || err}`);
|
|
}
|
|
};
|
|
|
|
const deletePlaybook = async (id) => {
|
|
if (!window.confirm("Delete this playbook?")) return;
|
|
const safeId = encodeURIComponent(String(id));
|
|
try {
|
|
const res = await tryFetchWithTrailingSlash(`${API_BASE}/playbooks/${safeId}`, { method: "DELETE" });
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => "");
|
|
setMessage(`Delete failed: ${text || res.statusText}`);
|
|
return;
|
|
}
|
|
setMessage("Playbook deleted.");
|
|
if (selectedId === id) resetForm();
|
|
loadPlaybooks();
|
|
} catch (err) {
|
|
setMessage(`Delete error: ${err.message || err}`);
|
|
}
|
|
};
|
|
|
|
const onDragStart = (e, id) => {
|
|
dragItem.current = String(id);
|
|
e.dataTransfer.effectAllowed = "move";
|
|
};
|
|
|
|
const onDragEnter = (e, id) => {
|
|
dragOverItem.current = String(id);
|
|
e.preventDefault();
|
|
};
|
|
|
|
const onDragOver = (e) => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "move";
|
|
};
|
|
|
|
const onDrop = async () => {
|
|
if (!dragItem.current || dragItem.current === dragOverItem.current) return;
|
|
|
|
const currentOrder = filteredPlaybooks.map(p => String(p.id));
|
|
const fromIdx = currentOrder.indexOf(dragItem.current);
|
|
const toIdx = currentOrder.indexOf(dragOverItem.current);
|
|
if (fromIdx === -1 || toIdx === -1) return;
|
|
|
|
// Guard: only a playbook with "main" in its title can occupy the first slot
|
|
if (toIdx === 0 && fromIdx !== 0) {
|
|
const dragged = filteredPlaybooks.find(p => String(p.id) === dragItem.current);
|
|
if (!dragged || !dragged.title.toLowerCase().includes("main")) {
|
|
setMessage("Only a playbook with 'main' in its title can be placed first.");
|
|
dragItem.current = null;
|
|
dragOverItem.current = null;
|
|
return;
|
|
}
|
|
}
|
|
|
|
const newFilteredOrder = [...currentOrder];
|
|
newFilteredOrder.splice(fromIdx, 1);
|
|
newFilteredOrder.splice(toIdx, 0, dragItem.current);
|
|
|
|
// Optimistic UI update — reorder within filtered set, keep unfiltered playbooks intact
|
|
setPlaybooks(prev => {
|
|
const filteredSet = new Set(currentOrder);
|
|
const filteredMap = Object.fromEntries(prev.filter(p => filteredSet.has(String(p.id))).map(p => [String(p.id), p]));
|
|
const reorderedFiltered = newFilteredOrder.map(id => filteredMap[id]).filter(Boolean);
|
|
// Merge: place unfiltered items back in their original relative positions
|
|
const result = [];
|
|
let fi = 0;
|
|
for (const p of prev) {
|
|
if (filteredSet.has(String(p.id))) {
|
|
if (fi < reorderedFiltered.length) result.push(reorderedFiltered[fi++]);
|
|
} else {
|
|
result.push(p);
|
|
}
|
|
}
|
|
return result;
|
|
});
|
|
|
|
dragItem.current = null;
|
|
dragOverItem.current = null;
|
|
|
|
// Send the full playbook order to the backend (filtered reorder merged with all IDs)
|
|
try {
|
|
await fetch(`${API_BASE}/playbooks/reorder`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ ids: newFilteredOrder }),
|
|
});
|
|
} catch (err) {
|
|
console.error("Failed to persist order:", err);
|
|
loadPlaybooks();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div style={{ flexGrow: 1 }}>
|
|
<div style={{ display: "flex", gap: "1rem", alignItems: "stretch" }}>
|
|
|
|
{/* Sidebar */}
|
|
<aside style={{
|
|
width: "35%",
|
|
minWidth: "280px",
|
|
background: "#161616",
|
|
border: "1px solid #333",
|
|
borderRadius: "12px",
|
|
padding: "1rem"
|
|
}}>
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
|
|
<h3 style={{ margin: 0, fontSize: "1rem" }}>Playbooks</h3>
|
|
<button
|
|
onClick={resetForm}
|
|
style={{ padding: "0.35rem 0.75rem", background: "#007acc", color: "#fff", border: "none", borderRadius: "6px", cursor: "pointer" }}
|
|
>
|
|
New
|
|
</button>
|
|
</div>
|
|
|
|
<input
|
|
type="text"
|
|
placeholder="Search playbooks..."
|
|
value={search}
|
|
onChange={e => setSearch(e.target.value)}
|
|
style={{
|
|
width: "100%",
|
|
padding: "0.75rem",
|
|
background: "#222",
|
|
color: "#eee",
|
|
border: "1px solid #333",
|
|
borderRadius: "8px",
|
|
marginBottom: "1rem",
|
|
boxSizing: "border-box"
|
|
}}
|
|
/>
|
|
|
|
<div style={{ maxHeight: "calc(100vh - 240px)", overflowY: "auto" }}>
|
|
{filteredPlaybooks.length === 0 ? (
|
|
<p style={{ color: "#aaa" }}>No playbooks found.</p>
|
|
) : (
|
|
filteredPlaybooks.map((p, idx) => {
|
|
const isMain = idx === 0 && !search.trim();
|
|
return (
|
|
<div
|
|
key={safeString(p.id)}
|
|
draggable
|
|
onDragStart={e => onDragStart(e, p.id)}
|
|
onDragEnter={e => onDragEnter(e, p.id)}
|
|
onDragOver={onDragOver}
|
|
onDrop={onDrop}
|
|
onClick={() => selectPlaybook(p)}
|
|
style={{
|
|
padding: "0.75rem 1rem",
|
|
marginBottom: "0.5rem",
|
|
borderRadius: "10px",
|
|
cursor: "grab",
|
|
background: selectedId === p.id ? "#262626" : "#1b1b1b",
|
|
border: selectedId === p.id
|
|
? "1px solid #007acc"
|
|
: isMain
|
|
? "1px solid #2a4a2a"
|
|
: "1px solid #2a2a2a",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.75rem",
|
|
userSelect: "none",
|
|
}}
|
|
>
|
|
<span style={{ color: "#555", fontSize: "1rem", flexShrink: 0 }}>⠿</span>
|
|
<span style={{ color: "#eee", fontSize: "0.95rem", textAlign: "left", flexGrow: 1 }}>{p.title}</span>
|
|
{isMain && (
|
|
<span style={{
|
|
fontSize: "0.65rem",
|
|
fontWeight: "600",
|
|
color: "#4caf50",
|
|
background: "#1a2e1a",
|
|
border: "1px solid #2a4a2a",
|
|
borderRadius: "4px",
|
|
padding: "0.15rem 0.4rem",
|
|
flexShrink: 0,
|
|
letterSpacing: "0.05em",
|
|
}}>
|
|
MAIN
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Editor */}
|
|
<main style={{
|
|
flexGrow: 1,
|
|
background: "#161616",
|
|
border: "1px solid #333",
|
|
borderRadius: "12px",
|
|
padding: "1rem"
|
|
}}>
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: "1rem", marginBottom: "1rem" }}>
|
|
<div>
|
|
<h3 style={{ margin: 0, fontSize: "1rem" }}>{selectedId ? "Edit Playbook" : "Create Playbook"}</h3>
|
|
<p style={{ margin: "0.35rem 0 0", color: "#aaa", fontSize: "0.9rem" }}>
|
|
Select a playbook on the left, then edit here.
|
|
</p>
|
|
</div>
|
|
{selectedId && (
|
|
<button
|
|
onClick={() => deletePlaybook(selectedId)}
|
|
style={{ padding: "0.5rem 0.9rem", background: "#b32f2f", color: "#fff", border: "none", borderRadius: "8px", cursor: "pointer" }}
|
|
>
|
|
Delete
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ display: "grid", gap: "0.9rem" }}>
|
|
<input
|
|
type="text"
|
|
placeholder="Title"
|
|
value={form.title}
|
|
onChange={e => setForm(prev => ({ ...prev, title: e.target.value }))}
|
|
style={{ padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px" }}
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="Tags (comma separated)"
|
|
value={form.tags}
|
|
onChange={e => setForm(prev => ({ ...prev, tags: e.target.value }))}
|
|
style={{ padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px" }}
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="Playbook tools: search_memory, … (render_preview auto-attaches on visual asks)"
|
|
value={form.tools}
|
|
onChange={e => setForm(prev => ({ ...prev, tools: e.target.value }))}
|
|
style={{ padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px" }}
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="Model (blank = auto-select): e.g. mistral:latest"
|
|
value={form.model}
|
|
onChange={e => setForm(prev => ({ ...prev, model: e.target.value }))}
|
|
style={{ padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px" }}
|
|
/>
|
|
<textarea
|
|
rows={4}
|
|
placeholder="Goal"
|
|
value={form.goal}
|
|
onChange={e => setForm(prev => ({ ...prev, goal: e.target.value }))}
|
|
style={{ padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px" }}
|
|
/>
|
|
<textarea
|
|
rows={10}
|
|
placeholder="Instructions"
|
|
value={form.instructions}
|
|
onChange={e => setForm(prev => ({ ...prev, instructions: e.target.value }))}
|
|
onKeyDown={e => {
|
|
if (e.key === "Tab") {
|
|
e.preventDefault();
|
|
const ta = e.currentTarget;
|
|
const start = ta.selectionStart;
|
|
const end = ta.selectionEnd;
|
|
setForm(prev => {
|
|
const newVal = prev.instructions.slice(0, start) + "\t" + prev.instructions.slice(end);
|
|
setTimeout(() => { ta.selectionStart = ta.selectionEnd = start + 1; }, 0);
|
|
return { ...prev, instructions: newVal };
|
|
});
|
|
}
|
|
}}
|
|
style={{ padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px" }}
|
|
/>
|
|
</div>
|
|
|
|
<div style={{ marginTop: "1rem", display: "flex", gap: "0.75rem", flexWrap: "wrap" }}>
|
|
<button
|
|
onClick={savePlaybook}
|
|
style={{ padding: "0.85rem 1.2rem", background: "#007acc", color: "#fff", border: "none", borderRadius: "8px", cursor: "pointer" }}
|
|
>
|
|
{selectedId ? "Update Playbook" : "Create Playbook"}
|
|
</button>
|
|
<button
|
|
onClick={resetForm}
|
|
style={{ padding: "0.85rem 1.2rem", background: "#444", color: "#fff", border: "none", borderRadius: "8px", cursor: "pointer" }}
|
|
>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
|
|
{message && (
|
|
<div style={{ marginTop: "1rem", color: "#aaffaa" }}>{message}</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |