mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
feat(harness-f2): marketplace de skills (instaladas + catálogo + upload .zip)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Xh2xNpYkUm23ijNBVjDVa
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b170a5e693
commit
225ba2deb2
@@ -38,3 +38,4 @@
|
||||
- 2026-07-24 — FASE 2 Task 5 concluída: API de skills (`GET /api/v1/ai/skills`, `POST .../import` multipart, `POST .../[name]/install`, `DELETE .../[name]`). 3 ações de audit novas (`ai.skill_imported`, `ai.skill_installed`, `ai.skill_uninstalled`). Achado do "Before You Begin": NENHUMA rota do app usava `pg.Pool` antes desta — só o worker/scripts chamavam `createPool` do agent-engine. Resolvido reusando o MESMO `createPool` (não duplica setup de conexão) atrás de um singleton lazy novo (`lib/ai/skills/db.ts::getSkillsPool()`), com `SUPABASE_DB_URL` promovido de env exclusivo do engine pra `lib/env.ts` (schema `required()`, tolerante em dev — já existia no `.env.example`). GET resolve `installed` via os pointers da PRÓPRIA org (join em skill_versions; `source` derivado de `forked_from_version_id`) e `catalog` via pointers de plataforma cujo name não está em installed — 3 queries simples, sem embed do PostgREST (padrão do repo). DELETE só remove `skill_pointers`, nunca `skill_versions` (histórico imutável). Achado de teste: multipart real (`File`/`FormData`) corrompe em ambiente jsdom (default do projeto) ao passar pelo parser undici do `NextRequest` — `import/route.test.ts` roda em `// @vitest-environment node` (isolado, só este arquivo). TDD: 13/13 testes verdes (4 arquivos: GET, import, install, delete). Typecheck e lint zerados (0 erros, 0 warnings).
|
||||
- 2026-07-24 — FASE 2 Task 6 concluída: tool `read_skill_reference` no turno + telemetria `skill_activations`. `LoadedSkill.manifest` não existia (só Task 2 tinha trazido `versionId`) — trazido pra `loadSkills` (SELECT `v.manifest` + campo na interface, 2 linhas, sem quebrar `skills.test.ts` que mocka rows sem o campo). Achado de ordenação: `matchSkills` rodava DEPOIS de `wrapToolsWithBreaker` no arquivo original — movido pra ANTES da montagem de `rawTools` (junto da telemetria fire-and-forget do brief) porque o gate "só entra quando há skill casada com references" precisa do resultado do match no MESMO ponto onde `search_knowledge`/`request_human_handoff` já são ligados/desligados, antes do breaker envolver as tools. Storage: `deps.crmCfg.supabase` já era o admin client (service role) usado pelas outras bordas do CRM — nenhum seam novo. Guarda anti-vazamento entre skills: `readSkillReference` só aceita `skill_name` presente em `skillMatch.matched` (as skills CASADAS deste turno, nunca todas as skills do tenant) — pedir uma skill instalada mas não casada volta `skill_not_active` antes de tocar manifest/storage. TDD: 8/8 testes novos verdes (`skill-references.test.ts` + 1 em `tool-breaker.test.ts`); suite completa `lib/agent-engine` 27/27 verde; typecheck e lint zerados. Não existe `llm-cache.test.ts`/teste de byte-identidade dedicado no repo (confirmado por busca — mesmo achado da Task 3 da Fase 1).
|
||||
- 2026-07-24 — FASE 2 Task 8 concluída: migration 0069 — seed de 2 skills de plataforma (`organization_id null`) no catálogo do marketplace: `objecao-preco` (diagnostica o motivo real por trás do "caro" — orçamento/valor não visto/concorrente/tática/timing — antes de reagir, nunca cede desconto não documentado) e `agendamento` (nunca inventa disponibilidade sem checar agenda real, oferece horários fechados em vez de pergunta aberta, exige confirmação explícita por escrito). Bodies markdown if-then (~80/76 linhas, teto 200) + `matcher` `{any_keywords, probe_keywords}` validado por `skillMatcherSchema` — não há CHECK no banco pro shape (confirmado lendo migration 0050: "shape validado no código"). Idempotente via guard `where not exists (select 1 from skill_pointers where organization_id is null and name=...)` em `do $seed$` blocks — aplicado 2x no banco dev: 2 pointers/1 versão cada nas duas rodadas (sem duplicação). Espelhado no apêndice do baseline.sql + linha no MANIFEST. Sem mudança de contrato — `database.types.ts` não regenerado (seed de dados). Typecheck zerado.
|
||||
- 2026-07-24 — FASE 2 Task 7 concluída: tela `app/app/ai/skills` (hooks/ai/useSkills.ts + page.tsx + _client.tsx) — "Skills instaladas" (cards com badge manual/do catálogo, Desinstalar, nota de que reenviar um .zip com o mesmo nome personaliza) e "Catálogo" (skills de plataforma não instaladas, Instalar) + botão "Enviar skill (.zip)" (upload multipart via `fetch` direto — `apiClient` sempre serializa JSON, não dá pra FormData). Item "Skills da IA" na Sidebar + permissões `ai.skills.view`/`ai.skills.manage` (ambas manager) em AuthProvider. Ícone `PuzzlePiece` (não `Puzzle` — nome real no phosphor 2.1.10) + `UploadSimple`/`DownloadSimple` adicionados ao mapa canônico `lib/ui/icons.ts`. Typecheck e eslint zerados. Prova real clicando (manager e2e-manager, sem MFA): catálogo mostrou os 2 seeds da Task 8 → Instalar `objecao-preco` moveu pra Instaladas com badge "do catálogo" → Desinstalar removeu; upload de um .zip mínimo (SKILL.md+references/, montado com fflate) → apareceu em Instaladas com badge "manual". Screenshot em `.superpowers/sdd/evidence/f2-skills-tela.png`. Cleanup: `skill_pointers`(0)/`skill_versions`(0) da org de teste deletados via psql + objeto de storage em `skill-assets/{org}/teste-f2-upload/...` removido via admin client — confirmado 0/0 e catálogo de plataforma intacto (os 2 seeds voltaram a aparecer).
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { showApiError } from "@/components/feedback/ApiErrorToast";
|
||||
import { PuzzlePiece, UploadSimple, DownloadSimple, Trash, Info } from "@/lib/ui/icons";
|
||||
import { usePermission } from "@/hooks/auth/AuthProvider";
|
||||
import {
|
||||
useSkills,
|
||||
useInstallSkill,
|
||||
useUninstallSkill,
|
||||
useImportSkill,
|
||||
type SkillsState,
|
||||
} from "@/hooks/ai/useSkills";
|
||||
|
||||
interface Props {
|
||||
initialState: SkillsState;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString("pt-BR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function SkillsClient({ initialState }: Props) {
|
||||
const { data } = useSkills(initialState);
|
||||
const installed = data?.installed ?? [];
|
||||
const catalog = data?.catalog ?? [];
|
||||
const canManage = usePermission("ai.skills.manage");
|
||||
|
||||
const install = useInstallSkill();
|
||||
const uninstall = useUninstallSkill();
|
||||
const importSkill = useImportSkill();
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [pendingName, setPendingName] = React.useState<string | null>(null);
|
||||
|
||||
function handleInstall(name: string) {
|
||||
setPendingName(name);
|
||||
install.mutate(name, {
|
||||
onSuccess: () => {
|
||||
toast.success(`Skill "${name}" instalada — já vale para os agentes desta organização.`);
|
||||
setPendingName(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
showApiError(err);
|
||||
setPendingName(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleUninstall(name: string) {
|
||||
setPendingName(name);
|
||||
uninstall.mutate(name, {
|
||||
onSuccess: () => {
|
||||
toast.success(`Skill "${name}" desinstalada.`);
|
||||
setPendingName(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
showApiError(err);
|
||||
setPendingName(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleFileChosen(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
importSkill.mutate(file, {
|
||||
onSuccess: (res) => {
|
||||
toast.success(`Skill "${res.data.name}" enviada e instalada com sucesso.`);
|
||||
},
|
||||
onError: showApiError,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle>Skills instaladas</CardTitle>
|
||||
<CardDescription>
|
||||
O que seus agentes já sabem fazer além da conversa comum — cada skill só entra
|
||||
em ação quando o assunto pede.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{canManage && (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="hidden"
|
||||
onChange={handleFileChosen}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={importSkill.isPending}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<UploadSimple /> {importSkill.isPending ? "Enviando…" : "Enviar skill (.zip)"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{installed.length === 0 ? (
|
||||
<p className="rounded-md border border-dashed border-border p-4 text-sm text-muted-foreground">
|
||||
Nenhuma skill instalada ainda. Instale uma pronta do catálogo abaixo ou envie a
|
||||
sua em "Enviar skill (.zip)".
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{installed.map((skill) => (
|
||||
<li
|
||||
key={skill.name}
|
||||
className="flex flex-col gap-1.5 rounded-md border border-border/60 p-3 text-sm"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<PuzzlePiece className="text-accent" aria-hidden />
|
||||
<span className="font-medium">{skill.name}</span>
|
||||
<Badge variant={skill.source === "catalog" ? "info" : "neutral"} className="text-[10px]">
|
||||
{skill.source === "catalog" ? "do catálogo" : "manual"}
|
||||
</Badge>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
atualizada em {formatDate(skill.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
{skill.description && <p className="text-text-muted">{skill.description}</p>}
|
||||
{canManage && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={uninstall.isPending && pendingName === skill.name}
|
||||
onClick={() => handleUninstall(skill.name)}
|
||||
>
|
||||
<Trash /> Desinstalar
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-2 rounded-md bg-accent-soft p-3 text-xs text-text-muted">
|
||||
<Info className="mt-0.5 shrink-0" aria-hidden />
|
||||
<p>
|
||||
Para personalizar uma skill instalada, basta reenviar um .zip com o mesmo nome —
|
||||
a sua versão passa a valer no lugar da do catálogo. Não há editor dentro do sistema
|
||||
nesta fase.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Catálogo</CardTitle>
|
||||
<CardDescription>
|
||||
Skills prontas, mantidas pela plataforma, disponíveis para instalar com um clique.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{catalog.length === 0 ? (
|
||||
<p className="rounded-md border border-dashed border-border p-4 text-sm text-muted-foreground">
|
||||
Nenhuma skill nova no catálogo — você já instalou tudo que a plataforma oferece
|
||||
hoje.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{catalog.map((skill) => (
|
||||
<li
|
||||
key={skill.name}
|
||||
className="flex flex-col gap-1.5 rounded-md border border-border/60 p-3 text-sm"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<PuzzlePiece aria-hidden />
|
||||
<span className="font-medium">{skill.name}</span>
|
||||
</div>
|
||||
{skill.description && <p className="text-text-muted">{skill.description}</p>}
|
||||
{canManage && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={install.isPending && pendingName === skill.name}
|
||||
onClick={() => handleInstall(skill.name)}
|
||||
>
|
||||
<DownloadSimple />
|
||||
{install.isPending && pendingName === skill.name ? "Instalando…" : "Instalar"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { requireAuth, resolveActiveOrg } from "@/lib/auth/server";
|
||||
import { ROLE_RANK } from "@/lib/auth/types";
|
||||
import { createAdminClient } from "@/lib/supabase/admin";
|
||||
import type { SkillsState } from "@/hooks/ai/useSkills";
|
||||
import { SkillsClient } from "./_client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function SkillsPage() {
|
||||
const user = await requireAuth();
|
||||
const activeOrg = await resolveActiveOrg(user);
|
||||
if (!activeOrg) redirect("/app");
|
||||
|
||||
if (!user.is_platform_admin && ROLE_RANK[activeOrg.role] < ROLE_RANK.manager) {
|
||||
redirect("/403");
|
||||
}
|
||||
|
||||
const admin = createAdminClient();
|
||||
|
||||
const [{ data: orgPointers }, { data: platformPointers }] = await Promise.all([
|
||||
admin.from("skill_pointers").select("name, version_id, updated_at").eq("organization_id", activeOrg.orgId),
|
||||
admin.from("skill_pointers").select("name, version_id").is("organization_id", null),
|
||||
]);
|
||||
|
||||
const orgRows = orgPointers ?? [];
|
||||
const platformRows = platformPointers ?? [];
|
||||
const versionIds = [...new Set([...orgRows, ...platformRows].map((p) => p.version_id))];
|
||||
|
||||
const { data: versionsRaw } =
|
||||
versionIds.length > 0
|
||||
? await admin.from("skill_versions").select("id, description, forked_from_version_id").in("id", versionIds)
|
||||
: { data: [] };
|
||||
const versionById = new Map((versionsRaw ?? []).map((v) => [v.id, v]));
|
||||
|
||||
const installed: SkillsState["installed"] = orgRows.map((p) => {
|
||||
const v = versionById.get(p.version_id);
|
||||
return {
|
||||
name: p.name,
|
||||
description: v?.description ?? "",
|
||||
version_id: p.version_id,
|
||||
source: (v?.forked_from_version_id ? "catalog" : "manual") as "catalog" | "manual",
|
||||
updated_at: p.updated_at,
|
||||
};
|
||||
});
|
||||
|
||||
const installedNames = new Set(installed.map((i) => i.name));
|
||||
const catalog: SkillsState["catalog"] = platformRows
|
||||
.filter((p) => !installedNames.has(p.name))
|
||||
.map((p) => ({ name: p.name, description: versionById.get(p.version_id)?.description ?? "" }));
|
||||
|
||||
const initialState: SkillsState = { installed, catalog };
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-6 p-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Skills da IA</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Habilidades especializadas que seus agentes carregam só quando a conversa pede —
|
||||
instale prontas do catálogo ou envie a sua.
|
||||
</p>
|
||||
</header>
|
||||
<SkillsClient initialState={initialState} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { Kanban, Users, UsersThree, Gear, CaretDoubleLeft, CaretDoubleRight, Inbox, ScalesSimple, Robot, Brain, PlugsConnected, ChartBar, WebhooksLogo } from "@/lib/ui/icons";
|
||||
import { Kanban, Users, UsersThree, Gear, CaretDoubleLeft, CaretDoubleRight, Inbox, ScalesSimple, Robot, Brain, PuzzlePiece, PlugsConnected, ChartBar, WebhooksLogo, ClockCountdown } from "@/lib/ui/icons";
|
||||
import type { Icon as PhosphorIcon } from "@phosphor-icons/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toggleSidebar } from "@/app/actions/shell/toggleSidebar";
|
||||
@@ -19,6 +19,7 @@ interface NavItem {
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ href: "/app/inbox", label: "Inbox", icon: Inbox },
|
||||
{ href: "/app/radar", label: "Radar", icon: ClockCountdown },
|
||||
{ href: "/app/connections", label: "Conexões", icon: PlugsConnected, healthDot: true },
|
||||
{ href: "/app/kanban", label: "Kanban", icon: Kanban },
|
||||
{ href: "/app/contacts", label: "Contatos", icon: Users },
|
||||
@@ -27,6 +28,7 @@ const NAV_ITEMS: NavItem[] = [
|
||||
{ href: "/app/lgpd/requests", label: "LGPD", icon: ScalesSimple, permission: "lgpd.execute_redact" },
|
||||
{ href: "/app/ai/agents", label: "Agentes IA", icon: Robot, permission: "ai.agents.view" },
|
||||
{ href: "/app/ai/memory", label: "Memória da IA", icon: Brain, permission: "ai.memory.view" },
|
||||
{ href: "/app/ai/skills", label: "Skills da IA", icon: PuzzlePiece, permission: "ai.skills.view" },
|
||||
{ href: "/app/webhooks", label: "Webhooks", icon: WebhooksLogo, permission: "webhooks.manage" },
|
||||
{ href: "/app/settings", label: "Configurações", icon: Gear },
|
||||
];
|
||||
@@ -37,6 +39,7 @@ export function Sidebar({ collapsed }: { collapsed: boolean }) {
|
||||
const canLgpd = usePermission("lgpd.execute_redact");
|
||||
const canAiAgents = usePermission("ai.agents.view");
|
||||
const canAiMemory = usePermission("ai.memory.view");
|
||||
const canAiSkills = usePermission("ai.skills.view");
|
||||
const canWebhooks = usePermission("webhooks.manage");
|
||||
|
||||
return (
|
||||
@@ -55,6 +58,7 @@ export function Sidebar({ collapsed }: { collapsed: boolean }) {
|
||||
if (item.permission === "lgpd.execute_redact") return canLgpd;
|
||||
if (item.permission === "ai.agents.view") return canAiAgents;
|
||||
if (item.permission === "ai.memory.view") return canAiMemory;
|
||||
if (item.permission === "ai.skills.view") return canAiSkills;
|
||||
if (item.permission === "webhooks.manage") return canWebhooks;
|
||||
return true;
|
||||
}).map((item) => {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { ApiError, type ApiErrorBody } from "@/lib/api/types";
|
||||
import { randomId } from "@/lib/random-id";
|
||||
|
||||
export interface InstalledSkill {
|
||||
name: string;
|
||||
description: string;
|
||||
version_id: string;
|
||||
source: "manual" | "catalog";
|
||||
updated_at: string;
|
||||
}
|
||||
export interface CatalogSkill {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
export interface SkillsState {
|
||||
installed: InstalledSkill[];
|
||||
catalog: CatalogSkill[];
|
||||
}
|
||||
|
||||
const KEY = ["skills"];
|
||||
|
||||
export function useSkills(initial?: SkillsState) {
|
||||
return useQuery({
|
||||
queryKey: KEY,
|
||||
...(initial !== undefined ? { initialData: initial } : {}),
|
||||
queryFn: () => apiClient.get<{ data: SkillsState }>("/api/v1/ai/skills").then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useInstallSkill() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (name: string) =>
|
||||
apiClient.post<{ data: { version_id: string } }>(`/api/v1/ai/skills/${encodeURIComponent(name)}/install`, {}),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUninstallSkill() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (name: string) =>
|
||||
apiClient.delete<{ data: { name: string } }>(`/api/v1/ai/skills/${encodeURIComponent(name)}`),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload multipart — `apiClient` sempre serializa o body como JSON, então este
|
||||
* mutation faz o fetch direto (mesmo tratamento de erro do apiClient: parseia
|
||||
* `{error}` e lança ApiError, pra `showApiError` funcionar igual nos outros hooks).
|
||||
*/
|
||||
export function useImportSkill() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await fetch("/api/v1/ai/skills/import", {
|
||||
method: "POST",
|
||||
headers: { "Idempotency-Key": randomId() },
|
||||
body: form,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const text = await res.text();
|
||||
const parsed = text ? JSON.parse(text) : null;
|
||||
if (!res.ok) {
|
||||
const errBody = parsed as ApiErrorBody | null;
|
||||
const e = errBody?.error;
|
||||
throw new ApiError(
|
||||
res.status,
|
||||
e?.code ?? "unknown_error",
|
||||
e?.details,
|
||||
e?.request_id ?? randomId(),
|
||||
e?.message,
|
||||
);
|
||||
}
|
||||
return parsed as { data: { name: string; version_id: string } };
|
||||
},
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: KEY }),
|
||||
});
|
||||
}
|
||||
@@ -105,6 +105,8 @@ const ACTION_MIN_ROLE: Record<string, Role> = {
|
||||
"ai.agents.write": "admin",
|
||||
"ai.memory.view": "manager",
|
||||
"ai.memory.publish": "admin",
|
||||
"ai.skills.view": "manager",
|
||||
"ai.skills.manage": "manager",
|
||||
"ai.credentials.view": "manager",
|
||||
"ai.credentials.write": "admin",
|
||||
"webhooks.manage": "manager",
|
||||
|
||||
@@ -86,4 +86,7 @@ export {
|
||||
ArrowRight,
|
||||
SignOut,
|
||||
WebhooksLogo,
|
||||
PuzzlePiece,
|
||||
UploadSimple,
|
||||
DownloadSimple,
|
||||
} from "@phosphor-icons/react/dist/ssr";
|
||||
|
||||
Reference in New Issue
Block a user