Files
IdeA/frontend/src/features/templates/TemplateEditor.tsx

360 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useMemo, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type {
AgentProfile,
McpToolCatalogue,
McpToolPolicy,
NetworkPolicy,
SystemPermissionSet,
Template,
} from "@/domain";
import {
mcpToolGroupsForCatalogue,
mcpToolLabel,
} from "@/features/permissions/mcpToolGroups";
import { Button, Input, SmallDropdown, cn } from "@/shared";
import { profileLabel } from "./templatePresentation";
export interface TemplateEditorSaveInput {
name: string;
content: string;
defaultProfileId: string;
defaultAgentName: string | null;
defaultContactable: boolean;
systemPermissions: SystemPermissionSet | null;
mcpToolPermissions: McpToolPolicy | null;
}
export interface TemplateEditorProps {
template?: Template | null;
profiles: AgentProfile[];
mcpCatalogue: McpToolCatalogue;
onSave: (input: TemplateEditorSaveInput) => Promise<void>;
onClose: () => void;
busy?: boolean;
}
type EditorTab = "edit" | "preview";
const NETWORK_OPTIONS: { value: "" | NetworkPolicy; label: string }[] = [
{ value: "", label: "Aucune permission système" },
{ value: "ask", label: "Réseau sur demande" },
{ value: "allow", label: "Réseau autorisé" },
{ value: "deny", label: "Réseau refusé" },
];
const TEMPLATE_CROSS_PROJECT_HELP =
"Les agents créés avec ce template reprendront cette permission.";
export function TemplateEditor({
template,
profiles,
mcpCatalogue,
onSave,
onClose,
busy = false,
}: TemplateEditorProps) {
const [name, setName] = useState(template?.name ?? "");
const [content, setContent] = useState(template?.contentMd ?? "");
const [defaultAgentName, setDefaultAgentName] = useState(
template?.defaultAgentName ?? "",
);
const [defaultProfileId, setDefaultProfileId] = useState(
template?.defaultProfileId ?? "",
);
const [network, setNetwork] = useState<"" | NetworkPolicy>(
template?.systemPermissions?.network ?? "",
);
const [defaultContactable, setDefaultContactable] = useState(
template?.defaultContactable ?? false,
);
const [allowedTools, setAllowedTools] = useState<string[]>(
template?.mcpToolPermissions?.allowedTools ?? [],
);
const [tab, setTab] = useState<EditorTab>("edit");
const toolGroups = useMemo(
() => mcpToolGroupsForCatalogue(mcpCatalogue),
[mcpCatalogue],
);
const allMcpTools = useMemo(
() => toolGroups.flatMap((group) => group.tools),
[toolGroups],
);
const allowed = useMemo(() => new Set(allowedTools), [allowedTools]);
const canSave = name.trim().length > 0 && !busy;
function toggleTool(tool: string) {
setAllowedTools((prev) =>
prev.includes(tool)
? prev.filter((candidate) => candidate !== tool)
: [...prev, tool],
);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!canSave) return;
await onSave({
name: name.trim(),
content,
defaultProfileId,
defaultAgentName: defaultAgentName.trim() || null,
defaultContactable,
systemPermissions: network ? { network } : null,
mcpToolPermissions:
allowedTools.length > 0 ? { allowedTools: [...allowedTools] } : null,
});
}
return (
<div
className="fixed inset-0 z-50 flex flex-col bg-canvas"
role="dialog"
aria-modal="true"
aria-label="template editor"
>
<div className="flex shrink-0 items-center justify-between border-b border-border bg-surface px-4 py-3">
<h2 className="text-sm font-semibold text-content">
{template ? `Edit template — ${template.name}` : "New template"}
</h2>
<Button
type="button"
variant="ghost"
aria-label="close template editor"
onClick={onClose}
disabled={busy}
>
x
</Button>
</div>
<form
onSubmit={(e) => void handleSubmit(e)}
className="flex min-h-0 flex-1 flex-col overflow-hidden"
>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto px-4 py-3">
<div className="flex min-w-0 flex-col gap-1">
<label htmlFor="te-name" className="text-xs font-medium text-muted">
Nom du template
</label>
<Input
id="te-name"
aria-label="template name"
placeholder="Template revue"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={busy}
/>
</div>
<section className="flex min-h-64 flex-col overflow-hidden rounded-md border border-border bg-raised">
<div className="flex shrink-0 items-center justify-between border-b border-border px-3">
<label
htmlFor="te-content"
className="py-2 text-xs font-medium text-muted"
>
Description
</label>
<div role="tablist" aria-label="template content mode" className="flex">
{(["edit", "preview"] as EditorTab[]).map((t) => (
<button
key={t}
type="button"
role="tab"
aria-label={t === "edit" ? "Edit" : "Preview"}
aria-selected={tab === t}
onClick={() => setTab(t)}
className={cn(
"px-3 py-2 text-xs font-medium transition-colors",
tab === t
? "border-b-2 border-primary text-content"
: "text-muted hover:text-content",
)}
>
{t === "edit" ? "Edit" : "Preview"}
</button>
))}
</div>
</div>
{tab === "edit" ? (
<textarea
id="te-content"
aria-label="template content"
value={content}
onChange={(e) => setContent(e.target.value)}
disabled={busy}
className={cn(
"min-h-60 flex-1 resize-none bg-raised px-3 py-2 font-mono text-sm text-content",
"outline-none placeholder:text-faint disabled:cursor-not-allowed disabled:opacity-50",
)}
placeholder="# Instructions de l'agent"
/>
) : (
<div
className={cn(
"min-h-60 flex-1 overflow-auto px-6 py-4",
"prose prose-sm prose-invert max-w-none",
"[&_h1]:text-content [&_h2]:text-content [&_h3]:text-content",
"[&_p]:text-content/90 [&_li]:text-content/90",
"[&_code]:rounded [&_code]:bg-canvas [&_code]:px-1 [&_code]:text-primary",
)}
>
{content.trim() ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
) : (
<p className="text-sm italic text-muted">Nothing to preview yet.</p>
)}
</div>
)}
</section>
<div className="grid gap-4 lg:grid-cols-2">
<div className="flex min-w-0 flex-col gap-1">
<label
htmlFor="te-default-agent"
className="text-xs font-medium text-muted"
>
Nom par défaut de l'agent
</label>
<Input
id="te-default-agent"
aria-label="template default agent name"
placeholder="Main"
value={defaultAgentName}
onChange={(e) => setDefaultAgentName(e.target.value)}
disabled={busy}
/>
</div>
<div className="flex min-w-0 flex-col gap-1">
<label htmlFor="te-profile" className="text-xs font-medium text-muted">
Modèle AI par défaut
</label>
{profiles.length > 0 ? (
<SmallDropdown
id="te-profile"
aria-label="template default profile"
value={defaultProfileId}
onChange={setDefaultProfileId}
disabled={busy}
className="w-full"
size="md"
options={[
{ value: "", label: "Aucun modèle par défaut" },
...profiles.map((p) => ({ value: p.id, label: profileLabel(p) })),
]}
/>
) : (
<Input
id="te-profile"
aria-label="template default profile"
placeholder="profile-id"
value={defaultProfileId}
onChange={(e) => setDefaultProfileId(e.target.value)}
disabled={busy}
/>
)}
</div>
<div className="flex min-w-0 flex-col gap-1">
<label htmlFor="te-system-network" className="text-xs font-medium text-muted">
Permissions système
</label>
<SmallDropdown
id="te-system-network"
aria-label="template system network permission"
value={network}
onChange={(value) => setNetwork(value as "" | NetworkPolicy)}
disabled={busy}
className="w-full"
size="md"
options={NETWORK_OPTIONS}
/>
</div>
<label className="flex min-w-0 items-start gap-2 rounded-md border border-border bg-raised px-3 py-2">
<input
type="checkbox"
aria-label="Contactable depuis d’autres projets"
checked={defaultContactable}
onChange={(e) => setDefaultContactable(e.target.checked)}
disabled={busy}
className="mt-0.5 h-4 w-4 shrink-0 accent-primary disabled:cursor-not-allowed disabled:opacity-50"
/>
<span className="flex min-w-0 flex-col gap-1">
<span className="text-sm font-medium text-content">
Contactable depuis d’autres projets
</span>
<span className="text-xs text-muted">
{TEMPLATE_CROSS_PROJECT_HELP}
</span>
</span>
</label>
</div>
<section className="flex flex-col gap-3 rounded-md border border-border bg-raised p-3">
<div className="flex items-center justify-between gap-3">
<h3 className="text-xs font-medium text-muted">
Permissions Tools MCP IdeA
</h3>
<span className="text-xs text-faint">{allowedTools.length} autorisé(s)</span>
</div>
{allMcpTools.length === 0 ? (
<p className="text-sm text-muted">Aucun tool MCP IdeA disponible.</p>
) : (
<>
{allowedTools.length === 0 && (
<p className="text-sm text-muted">
Aucun tool MCP IdeA autorisé.
</p>
)}
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{toolGroups.map((group) => (
<div key={group.id} className="flex min-w-0 flex-col gap-2">
<h4 className="text-xs font-semibold text-content">{group.label}</h4>
{group.tools.map((tool) => (
<label
key={tool}
className="flex min-w-0 items-center gap-2 text-sm text-content/90"
>
<input
type="checkbox"
aria-label={`template MCP tool ${tool}`}
checked={allowed.has(tool)}
onChange={() => toggleTool(tool)}
disabled={busy}
className="h-4 w-4 shrink-0 accent-primary"
/>
<span className="truncate">{mcpToolLabel(tool)}</span>
</label>
))}
</div>
))}
</div>
</>
)}
</section>
</div>
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-border px-4 py-3">
<Button type="button" variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button
type="submit"
variant="primary"
aria-label="Save template"
disabled={!canSave}
loading={busy}
>
Save
</Button>
</div>
</form>
</div>
);
}