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

390 lines
16 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 {
mcpToolGroupLabel,
mcpToolGroupDescription,
mcpToolGroupsForCatalogue,
mcpToolLabel,
} from "@/features/permissions/mcpToolGroups";
import { Button, Input, SmallDropdown, cn } from "@/shared";
import { useI18n } from "@/i18n";
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";
export function TemplateEditor({
template,
profiles,
mcpCatalogue,
onSave,
onClose,
busy = false,
}: TemplateEditorProps) {
const { tr } = useI18n();
const networkOptions: { value: "" | NetworkPolicy; label: string }[] = [
{ value: "", label: tr("Aucune permission système", "No system permission") },
{ value: "ask", label: tr("Réseau sur demande", "Ask for network") },
{ value: "allow", label: tr("Réseau autorisé", "Network allowed") },
{ value: "deny", label: tr("Réseau refusé", "Network denied") },
];
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 [mcpOverrideActive, setMcpOverrideActive] = useState(
template?.mcpToolPermissions != null,
);
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: mcpOverrideActive
? { allowedTools: [...allowedTools] }
: null,
});
}
return (
<div
className="fixed inset-0 z-50 flex flex-col bg-canvas"
role="dialog"
aria-modal="true"
aria-label={tr("Éditeur de modèle", "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 ? tr(`Modifier le modèle — ${template.name}`, `Edit template — ${template.name}`) : tr("Nouveau modèle", "New template")}
</h2>
<Button
type="button"
variant="ghost"
aria-label={tr("Fermer l’éditeur de modèle", "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">
{tr("Nom du modèle", "Template name")}
</label>
<Input
id="te-name"
aria-label={tr("Nom du modèle", "Template name")}
placeholder={tr("Modèle de revue", "Review template")}
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"
>
{tr("Description", "Description")}
</label>
<div role="tablist" aria-label={tr("Mode du contenu du modèle", "Template content mode")} className="flex">
{(["edit", "preview"] as EditorTab[]).map((t) => (
<button
key={t}
type="button"
role="tab"
aria-label={t === "edit" ? tr("Modifier", "Edit") : tr("Aperçu", "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" ? tr("Modifier", "Edit") : tr("Aperçu", "Preview")}
</button>
))}
</div>
</div>
{tab === "edit" ? (
<textarea
id="te-content"
aria-label={tr("Contenu du modèle", "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={tr("# Instructions de l’agent", "# Agent instructions")}
/>
) : (
<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">{tr("Rien à prévisualiser pour le moment.", "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"
>
{tr("Nom par défaut de l’agent", "Default agent name")}
</label>
<Input
id="te-default-agent"
aria-label={tr("Nom d’agent par défaut du modèle", "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">
{tr("Profil IA par défaut", "Default AI profile")}
</label>
{profiles.length > 0 ? (
<SmallDropdown
id="te-profile"
aria-label={tr("Profil par défaut du modèle", "Template default profile")}
value={defaultProfileId}
onChange={setDefaultProfileId}
disabled={busy}
className="w-full"
size="md"
options={[
{ value: "", label: tr("Aucun profil par défaut", "No default profile") },
...profiles.map((p) => ({ value: p.id, label: profileLabel(p) })),
]}
/>
) : (
<Input
id="te-profile"
aria-label={tr("Profil par défaut du modèle", "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">
{tr("Permissions système", "System permissions")}
</label>
<SmallDropdown
id="te-system-network"
aria-label={tr("Permission réseau système du modèle", "Template system network permission")}
value={network}
onChange={(value) => setNetwork(value as "" | NetworkPolicy)}
disabled={busy}
className="w-full"
size="md"
options={networkOptions}
/>
</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={tr("Contactable depuis d’autres projets", "Contactable from other projects")}
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">
{tr("Contactable depuis d’autres projets", "Contactable from other projects")}
</span>
<span className="text-xs text-muted">
{tr("Les agents créés avec ce modèle reprendront cette permission.", "Agents created from this template will inherit this permission.")}
</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">
{tr("Permissions des outils MCP IdeA", "IdeA MCP tool permissions")}
</h3>
<span className="text-xs text-faint">
{mcpOverrideActive
? `${allowedTools.length} ${tr("autorisé(s)", "allowed")}`
: tr("Héritage du projet", "Project inheritance")}
</span>
</div>
<label className="flex items-start gap-2 text-sm text-content">
<input
type="checkbox"
aria-label={tr("Hériter des outils MCP du projet", "Inherit project MCP tools")}
checked={!mcpOverrideActive}
onChange={(event) => setMcpOverrideActive(!event.target.checked)}
disabled={busy}
className="mt-0.5 h-4 w-4 shrink-0 accent-primary"
/>
<span className="flex flex-col gap-0.5">
<span>{tr("Hériter des outils MCP du projet", "Inherit project MCP tools")}</span>
<span className="text-xs text-muted">
{tr("Le modèle utilise les outils autorisés par le projet.", "The template uses the tools allowed by the project.")}
</span>
</span>
</label>
{allMcpTools.length === 0 ? (
<p className="text-sm text-muted">{tr("Aucun outil MCP IdeA disponible.", "No IdeA MCP tool available.")}</p>
) : (
<>
{mcpOverrideActive && allowedTools.length === 0 && (
<p className="text-sm text-muted">
{tr("Aucun outil MCP IdeA autorisé.", "No IdeA MCP tool allowed.")}
</p>
)}
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{toolGroups.map((group) => {
const description = mcpToolGroupDescription(group, tr);
return (
<div key={group.id} className="flex min-w-0 flex-col gap-2">
<div className="flex flex-col gap-0.5">
<h4 className="text-xs font-semibold text-content">{mcpToolGroupLabel(group, tr)}</h4>
{description && <p className="text-xs text-muted">{description}</p>}
</div>
{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={`${tr("Outil MCP du modèle", "Template MCP tool")} ${tool}`}
checked={allowed.has(tool)}
onChange={() => toggleTool(tool)}
disabled={busy || !mcpOverrideActive}
className="h-4 w-4 shrink-0 accent-primary"
/>
<span className="truncate">{mcpToolLabel(tool, tr)}</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}>
{tr("Annuler", "Cancel")}
</Button>
<Button
type="submit"
variant="primary"
aria-label={tr("Enregistrer le modèle", "Save template")}
disabled={!canSave}
loading={busy}
>
{tr("Enregistrer", "Save")}
</Button>
</div>
</form>
</div>
);
}