mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 08:36:44 +00:00
245 lines
8.7 KiB
TypeScript
245 lines
8.7 KiB
TypeScript
"use client";
|
|
|
|
import { createFileRoute, useNavigate, Link, useRouter } from "@tanstack/react-router";
|
|
import { useState, lazy, Suspense, useCallback, useMemo } from "react";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { ArrowLeft, Eye, FileText, Loader2, Play, Save } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import ReactMarkdown from "react-markdown";
|
|
import remarkGfm from "remark-gfm";
|
|
import { supabase } from "@/integrations/supabase/client";
|
|
import { useAuth } from "@/hooks/use-auth";
|
|
import { useAgentInstance } from "@/hooks/use-agent-instance";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { SkillTestPanel } from "@/components/mika/skills/SkillTestPanel";
|
|
|
|
// Lazy-load CodeMirror to reduce initial bundle
|
|
const CodeMirrorEditor = lazy(() => import("@/components/mika/skills/SkillMarkdownEditor"));
|
|
|
|
export const Route = createFileRoute("/painel/skills/preview")({
|
|
component: SkillPreviewPage,
|
|
});
|
|
|
|
function SkillPreviewPage() {
|
|
const router = useRouter();
|
|
const navigate = useNavigate();
|
|
const { user } = useAuth();
|
|
const agent = useAgentInstance();
|
|
const qc = useQueryClient();
|
|
|
|
// State passed from /painel/skills/nova
|
|
const routerState = (router.state.location.state ?? {}) as {
|
|
markdown_content?: string;
|
|
form_inputs?: Record<string, unknown>;
|
|
};
|
|
|
|
const [markdown, setMarkdown] = useState(routerState.markdown_content ?? "");
|
|
const formInputs = useMemo(() => routerState.form_inputs ?? {}, [routerState.form_inputs]);
|
|
const [testOpen, setTestOpen] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [publishing, setPublishing] = useState(false);
|
|
|
|
const agentId = agent.data?.id;
|
|
|
|
const hasContent = markdown.trim().length > 0;
|
|
|
|
// Helper: parse Supabase error codes
|
|
const handleSupabaseError = useCallback((error: { code?: string; message?: string }) => {
|
|
if (error.code === "P0001") {
|
|
toast.error("Você precisa de uma assinatura ativa para criar skills.", {
|
|
action: { label: "Ver planos", onClick: () => navigate({ to: "/", hash: "planos" }) },
|
|
});
|
|
return true;
|
|
}
|
|
if (error.code === "P0002") {
|
|
toast.error("Você atingiu o limite de skills do seu plano. Faça upgrade ou arquive uma skill existente.");
|
|
return true;
|
|
}
|
|
if (error.code === "23505") {
|
|
toast.error("Você já tem uma skill com esse nome. Escolha outro.");
|
|
return true;
|
|
}
|
|
return false;
|
|
}, [navigate]);
|
|
|
|
const createSkill = useCallback(async (publish: boolean) => {
|
|
if (!user || !agentId) return;
|
|
if (markdown.length > 50000) {
|
|
toast.error("O conteúdo excede 50.000 caracteres. Reduza antes de salvar.");
|
|
return;
|
|
}
|
|
|
|
const setter = publish ? setPublishing : setSaving;
|
|
setter(true);
|
|
|
|
try {
|
|
const fi = formInputs as Record<string, unknown>;
|
|
// 1. Create skill
|
|
const { data: skill, error: skillErr } = await supabase
|
|
.from("skills")
|
|
.insert({
|
|
user_id: user.id,
|
|
agent_instance_id: agentId,
|
|
name: (fi.name as string) || "Skill sem nome",
|
|
description: (fi.description as string) || "",
|
|
trigger_keywords: (fi.trigger_keywords as string) || "",
|
|
status: "draft",
|
|
})
|
|
.select("id")
|
|
.single();
|
|
|
|
if (skillErr) {
|
|
if (!handleSupabaseError(skillErr as { code?: string })) {
|
|
toast.error(skillErr.message || "Erro ao criar skill");
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 2. Create version
|
|
const { data: ver, error: verErr } = await supabase
|
|
.from("skill_versions")
|
|
.insert({
|
|
skill_id: skill.id,
|
|
version_number: 1,
|
|
markdown_content: markdown,
|
|
form_inputs: formInputs as Record<string, unknown>,
|
|
is_live: false,
|
|
created_by: user.id,
|
|
})
|
|
.select("id")
|
|
.single();
|
|
|
|
if (verErr) {
|
|
toast.error(verErr.message || "Erro ao salvar versão");
|
|
return;
|
|
}
|
|
|
|
// 3. Optionally publish
|
|
if (publish) {
|
|
const { data: pubData, error: pubErr } = await supabase.functions.invoke(
|
|
"publish-skill-version",
|
|
{ body: { skill_version_id: ver.id } },
|
|
);
|
|
if (pubErr) {
|
|
toast.error("Skill salva, mas falha ao publicar: " + pubErr.message);
|
|
} else {
|
|
toast.success("Skill publicada com sucesso!");
|
|
}
|
|
} else {
|
|
toast.success("Rascunho salvo!");
|
|
}
|
|
|
|
qc.invalidateQueries({ queryKey: ["skills"] });
|
|
qc.invalidateQueries({ queryKey: ["user-limits"] });
|
|
navigate({ to: "/painel/skills/$id", params: { id: skill.id } });
|
|
} catch {
|
|
toast.error("Erro inesperado");
|
|
} finally {
|
|
setter(false);
|
|
}
|
|
}, [user, agentId, markdown, formInputs, handleSupabaseError, navigate, qc]);
|
|
|
|
if (!routerState.markdown_content) {
|
|
return (
|
|
<div className="text-center py-20">
|
|
<p className="text-muted-foreground">Nenhum conteúdo para pré-visualizar.</p>
|
|
<Button asChild variant="outline" className="mt-4">
|
|
<Link to="/painel/skills/nova">Voltar ao formulário</Link>
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<header className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<Button variant="ghost" size="icon" asChild>
|
|
<Link to="/painel/skills/nova">
|
|
<ArrowLeft className="h-5 w-5" />
|
|
</Link>
|
|
</Button>
|
|
<h1 className="text-xl font-bold">Pré-visualização</h1>
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<Button variant="outline" onClick={() => setTestOpen(true)} disabled={!hasContent}>
|
|
<Play className="h-4 w-4 mr-1" /> Testar antes
|
|
</Button>
|
|
<Button variant="outline" onClick={() => createSkill(false)} disabled={saving || publishing || !hasContent}>
|
|
{saving ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Save className="h-4 w-4 mr-1" />}
|
|
Salvar rascunho
|
|
</Button>
|
|
<Button
|
|
onClick={() => createSkill(true)}
|
|
disabled={saving || publishing || !hasContent}
|
|
className="bg-primary hover:bg-primary-dark text-primary-foreground"
|
|
>
|
|
{publishing ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <FileText className="h-4 w-4 mr-1" />}
|
|
Publicar agora
|
|
</Button>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Desktop: split | Mobile: tabs */}
|
|
<div className="hidden lg:grid lg:grid-cols-2 gap-4 min-h-[60vh]">
|
|
<div className="rounded-xl border border-border bg-card overflow-hidden">
|
|
<Suspense fallback={<EditorSkeleton />}>
|
|
<CodeMirrorEditor value={markdown} onChange={setMarkdown} />
|
|
</Suspense>
|
|
</div>
|
|
<MarkdownPreview content={markdown} />
|
|
</div>
|
|
|
|
<div className="lg:hidden">
|
|
<Tabs defaultValue="preview">
|
|
<TabsList className="w-full">
|
|
<TabsTrigger value="editor" className="flex-1">
|
|
<FileText className="h-4 w-4 mr-1" /> Editor
|
|
</TabsTrigger>
|
|
<TabsTrigger value="preview" className="flex-1">
|
|
<Eye className="h-4 w-4 mr-1" /> Preview
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
<TabsContent value="editor" className="mt-4 min-h-[50vh]">
|
|
<div className="rounded-xl border border-border bg-card overflow-hidden">
|
|
<Suspense fallback={<EditorSkeleton />}>
|
|
<CodeMirrorEditor value={markdown} onChange={setMarkdown} />
|
|
</Suspense>
|
|
</div>
|
|
</TabsContent>
|
|
<TabsContent value="preview" className="mt-4">
|
|
<MarkdownPreview content={markdown} />
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
|
|
{testOpen && (
|
|
<SkillTestPanel
|
|
open={testOpen}
|
|
onOpenChange={setTestOpen}
|
|
skillName={(formInputs as Record<string, string>).name || "Skill"}
|
|
skillVersionId="preview"
|
|
triggerKeywords={(formInputs as Record<string, string>).trigger_keywords}
|
|
stateless={{ markdown_content: markdown }}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MarkdownPreview({ content }: { content: string }) {
|
|
return (
|
|
<div className="rounded-xl border border-border bg-card p-6 overflow-auto max-h-[80vh] prose prose-sm dark:prose-invert max-w-none">
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EditorSkeleton() {
|
|
return (
|
|
<div className="flex items-center justify-center h-60">
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
);
|
|
}
|