390 lines
16 KiB
TypeScript
390 lines
16 KiB
TypeScript
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>
|
||
);
|
||
}
|