@greatapps/greatagents-ui 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,342 @@
1
+ import { useState } from "react";
2
+ import { useCreateTool, useUpdateTool } from "../../hooks";
3
+ import type { Tool } from "../../types";
4
+ import type { GagentsHookConfig } from "../../hooks/types";
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ DialogFooter,
11
+ Button,
12
+ Input,
13
+ Textarea,
14
+ Label,
15
+ Select,
16
+ SelectContent,
17
+ SelectItem,
18
+ SelectTrigger,
19
+ SelectValue,
20
+ } from "@greatapps/greatauth-ui/ui";
21
+ import { Loader2 } from "lucide-react";
22
+ import { toast } from "sonner";
23
+
24
+ const TOOL_AUTH_TYPES = [
25
+ { value: "none", label: "Nenhuma" },
26
+ { value: "api_key", label: "API Key" },
27
+ { value: "oauth2", label: "OAuth 2.0" },
28
+ ] as const;
29
+
30
+ interface ToolFormDialogProps {
31
+ open: boolean;
32
+ onOpenChange: (open: boolean) => void;
33
+ tool?: Tool;
34
+ config: GagentsHookConfig;
35
+ }
36
+
37
+ interface FormState {
38
+ name: string;
39
+ slug: string;
40
+ type: string;
41
+ description: string;
42
+ functionDefinitions: string;
43
+ nameError: boolean;
44
+ slugError: boolean;
45
+ typeError: boolean;
46
+ jsonError: boolean;
47
+ }
48
+
49
+ function toolToFormState(tool?: Tool): FormState {
50
+ return {
51
+ name: tool?.name || "",
52
+ slug: tool?.slug || "",
53
+ type: tool?.type || "none",
54
+ description: tool?.description || "",
55
+ functionDefinitions: tool?.function_definitions
56
+ ? formatJson(tool.function_definitions)
57
+ : "",
58
+ nameError: false,
59
+ slugError: false,
60
+ typeError: false,
61
+ jsonError: false,
62
+ };
63
+ }
64
+
65
+ function formatJson(str: string): string {
66
+ try {
67
+ return JSON.stringify(JSON.parse(str), null, 2);
68
+ } catch {
69
+ return str;
70
+ }
71
+ }
72
+
73
+ function slugify(name: string): string {
74
+ return name
75
+ .toLowerCase()
76
+ .normalize("NFD")
77
+ .replace(/[\u0300-\u036f]/g, "")
78
+ .replace(/[^a-z0-9]+/g, "-")
79
+ .replace(/^-+|-+$/g, "");
80
+ }
81
+
82
+ function isValidJson(str: string): boolean {
83
+ if (!str.trim()) return true;
84
+ try {
85
+ JSON.parse(str);
86
+ return true;
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ export function ToolFormDialog({
93
+ open,
94
+ onOpenChange,
95
+ tool,
96
+ config,
97
+ }: ToolFormDialogProps) {
98
+ const isEditing = !!tool;
99
+ const createTool = useCreateTool(config);
100
+ const updateTool = useUpdateTool(config);
101
+ const [form, setForm] = useState<FormState>(() => toolToFormState(tool));
102
+ const [slugManuallyEdited, setSlugManuallyEdited] = useState(false);
103
+
104
+ // Reset form when dialog opens or tool changes
105
+ const [lastResetKey, setLastResetKey] = useState(
106
+ () => `${tool?.id}-${open}`,
107
+ );
108
+ const resetKey = `${tool?.id}-${open}`;
109
+ if (resetKey !== lastResetKey) {
110
+ setLastResetKey(resetKey);
111
+ setForm(toolToFormState(open ? tool : undefined));
112
+ setSlugManuallyEdited(false);
113
+ }
114
+
115
+ const isPending = createTool.isPending || updateTool.isPending;
116
+
117
+ async function handleSubmit(e: React.FormEvent) {
118
+ e.preventDefault();
119
+
120
+ let hasError = false;
121
+
122
+ if (!form.name.trim()) {
123
+ setForm((prev) => ({ ...prev, nameError: true }));
124
+ hasError = true;
125
+ }
126
+ const effectiveSlug = form.slug.trim() || slugify(form.name);
127
+ if (!effectiveSlug) {
128
+ setForm((prev) => ({ ...prev, slugError: true }));
129
+ hasError = true;
130
+ }
131
+ if (!form.type) {
132
+ setForm((prev) => ({ ...prev, typeError: true }));
133
+ hasError = true;
134
+ }
135
+ if (!isValidJson(form.functionDefinitions)) {
136
+ setForm((prev) => ({ ...prev, jsonError: true }));
137
+ hasError = true;
138
+ }
139
+
140
+ if (hasError) return;
141
+
142
+ const body: Record<string, unknown> = {
143
+ name: form.name.trim(),
144
+ slug: effectiveSlug,
145
+ type: form.type,
146
+ };
147
+
148
+ if (form.description.trim()) body.description = form.description.trim();
149
+ else body.description = "";
150
+
151
+ // JSONB critical rule: function_definitions must be sent as string
152
+ if (form.functionDefinitions.trim()) {
153
+ const parsed = JSON.parse(form.functionDefinitions.trim());
154
+ body.function_definitions = JSON.stringify(parsed);
155
+ } else {
156
+ body.function_definitions = "";
157
+ }
158
+
159
+ try {
160
+ if (isEditing) {
161
+ await updateTool.mutateAsync({ id: tool.id, body });
162
+ toast.success("Ferramenta atualizada");
163
+ } else {
164
+ await createTool.mutateAsync(
165
+ body as { name: string; slug: string; type: string; description?: string; function_definitions?: string },
166
+ );
167
+ toast.success("Ferramenta criada");
168
+ }
169
+ onOpenChange(false);
170
+ } catch (err) {
171
+ toast.error(
172
+ err instanceof Error
173
+ ? err.message
174
+ : isEditing ? "Erro ao atualizar ferramenta" : "Erro ao criar ferramenta",
175
+ );
176
+ }
177
+ }
178
+
179
+ return (
180
+ <Dialog open={open} onOpenChange={onOpenChange}>
181
+ <DialogContent className="sm:max-w-lg">
182
+ <DialogHeader>
183
+ <DialogTitle>
184
+ {isEditing ? "Editar Ferramenta" : "Nova Ferramenta"}
185
+ </DialogTitle>
186
+ </DialogHeader>
187
+ <form onSubmit={handleSubmit} className="space-y-4">
188
+ <div className="space-y-2">
189
+ <Label htmlFor="tool-name">Nome *</Label>
190
+ <Input
191
+ id="tool-name"
192
+ value={form.name}
193
+ onChange={(e) => {
194
+ const name = e.target.value;
195
+ setForm((prev) => ({
196
+ ...prev,
197
+ name,
198
+ nameError: name.trim() ? false : prev.nameError,
199
+ ...(!slugManuallyEdited && !isEditing
200
+ ? { slug: slugify(name), slugError: false }
201
+ : {}),
202
+ }));
203
+ }}
204
+ placeholder="Ex: Google Calendar"
205
+ disabled={isPending}
206
+ />
207
+ {form.nameError && (
208
+ <p className="text-sm text-destructive">Nome é obrigatório</p>
209
+ )}
210
+ </div>
211
+
212
+ <div className="space-y-2">
213
+ <Label htmlFor="tool-slug">Slug (identificador único) *</Label>
214
+ <Input
215
+ id="tool-slug"
216
+ value={form.slug}
217
+ onChange={(e) => {
218
+ setSlugManuallyEdited(true);
219
+ setForm((prev) => ({
220
+ ...prev,
221
+ slug: e.target.value,
222
+ slugError: e.target.value.trim() ? false : prev.slugError,
223
+ }));
224
+ }}
225
+ placeholder="Ex: google-calendar"
226
+ disabled={isPending}
227
+ />
228
+ <p className="text-xs text-muted-foreground">
229
+ Gerado automaticamente a partir do nome. Usado internamente para identificar a ferramenta.
230
+ </p>
231
+ {form.slugError && (
232
+ <p className="text-sm text-destructive">Slug é obrigatório</p>
233
+ )}
234
+ </div>
235
+
236
+ <div className="space-y-2">
237
+ <Label htmlFor="tool-type">Tipo de Autenticação *</Label>
238
+ <Select
239
+ value={form.type}
240
+ onValueChange={(value) => {
241
+ setForm((prev) => ({
242
+ ...prev,
243
+ type: value,
244
+ typeError: false,
245
+ }));
246
+ }}
247
+ disabled={isPending}
248
+ >
249
+ <SelectTrigger id="tool-type">
250
+ <SelectValue placeholder="Selecione o tipo" />
251
+ </SelectTrigger>
252
+ <SelectContent>
253
+ {TOOL_AUTH_TYPES.map((t) => (
254
+ <SelectItem key={t.value} value={t.value}>
255
+ {t.label}
256
+ </SelectItem>
257
+ ))}
258
+ </SelectContent>
259
+ </Select>
260
+ <p className="text-xs text-muted-foreground">
261
+ Define se a ferramenta requer credenciais para funcionar.
262
+ </p>
263
+ {form.typeError && (
264
+ <p className="text-sm text-destructive">Tipo é obrigatório</p>
265
+ )}
266
+ </div>
267
+
268
+ <div className="space-y-2">
269
+ <Label htmlFor="tool-description">Descrição</Label>
270
+ <Textarea
271
+ id="tool-description"
272
+ value={form.description}
273
+ onChange={(e) =>
274
+ setForm((prev) => ({ ...prev, description: e.target.value }))
275
+ }
276
+ placeholder="Descrição da ferramenta..."
277
+ rows={3}
278
+ disabled={isPending}
279
+ />
280
+ </div>
281
+
282
+ <div className="space-y-2">
283
+ <Label htmlFor="tool-function-defs">
284
+ Definições de Função (JSON)
285
+ </Label>
286
+ <Textarea
287
+ id="tool-function-defs"
288
+ value={form.functionDefinitions}
289
+ onChange={(e) => {
290
+ setForm((prev) => ({
291
+ ...prev,
292
+ functionDefinitions: e.target.value,
293
+ jsonError: false,
294
+ }));
295
+ }}
296
+ placeholder={`[
297
+ {
298
+ "type": "function",
299
+ "function": {
300
+ "name": "nome_da_funcao",
301
+ "description": "O que a função faz",
302
+ "parameters": {
303
+ "type": "object",
304
+ "properties": { ... },
305
+ "required": [...]
306
+ }
307
+ }
308
+ }
309
+ ]`}
310
+ rows={10}
311
+ className="font-mono text-sm"
312
+ disabled={isPending}
313
+ />
314
+ <p className="text-xs text-muted-foreground">
315
+ Array de definições no formato OpenAI Function Calling.
316
+ </p>
317
+ {form.jsonError && (
318
+ <p className="text-sm text-destructive">JSON inválido</p>
319
+ )}
320
+ </div>
321
+
322
+ <DialogFooter>
323
+ <Button
324
+ type="button"
325
+ variant="outline"
326
+ onClick={() => onOpenChange(false)}
327
+ disabled={isPending}
328
+ >
329
+ Cancelar
330
+ </Button>
331
+ <Button type="submit" disabled={isPending}>
332
+ {isPending ? (
333
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
334
+ ) : null}
335
+ {isEditing ? "Salvar" : "Criar"}
336
+ </Button>
337
+ </DialogFooter>
338
+ </form>
339
+ </DialogContent>
340
+ </Dialog>
341
+ );
342
+ }
@@ -0,0 +1,225 @@
1
+ import { useMemo, useState } from "react";
2
+ import type { ColumnDef } from "@tanstack/react-table";
3
+ import { useTools, useDeleteTool } from "../../hooks";
4
+ import type { Tool } from "../../types";
5
+ import type { GagentsHookConfig } from "../../hooks/types";
6
+ import { DataTable } from "@greatapps/greatauth-ui";
7
+ import {
8
+ Input,
9
+ Badge,
10
+ Tooltip,
11
+ TooltipTrigger,
12
+ TooltipContent,
13
+ AlertDialog,
14
+ AlertDialogAction,
15
+ AlertDialogCancel,
16
+ AlertDialogContent,
17
+ AlertDialogDescription,
18
+ AlertDialogFooter,
19
+ AlertDialogHeader,
20
+ AlertDialogTitle,
21
+ Button,
22
+ } from "@greatapps/greatauth-ui/ui";
23
+ import { Pencil, Trash2, Search } from "lucide-react";
24
+ import { format } from "date-fns";
25
+ import { ptBR } from "date-fns/locale";
26
+ import { toast } from "sonner";
27
+
28
+ interface ToolsTableProps {
29
+ onEdit: (tool: Tool) => void;
30
+ config: GagentsHookConfig;
31
+ }
32
+
33
+ function useColumns(
34
+ onEdit: (tool: Tool) => void,
35
+ onDelete: (id: number) => void,
36
+ ): ColumnDef<Tool>[] {
37
+ return [
38
+ {
39
+ accessorKey: "name",
40
+ header: "Nome",
41
+ cell: ({ row }) => (
42
+ <span className="font-medium">{row.original.name}</span>
43
+ ),
44
+ sortingFn: (rowA, rowB) =>
45
+ rowA.original.name
46
+ .toLowerCase()
47
+ .localeCompare(rowB.original.name.toLowerCase()),
48
+ },
49
+ {
50
+ accessorKey: "slug",
51
+ header: "Slug",
52
+ cell: ({ row }) => (
53
+ <span className="text-muted-foreground text-sm font-mono">
54
+ {row.original.slug || "\u2014"}
55
+ </span>
56
+ ),
57
+ },
58
+ {
59
+ accessorKey: "type",
60
+ header: "Tipo",
61
+ cell: ({ row }) => (
62
+ <Badge variant="secondary">{row.original.type}</Badge>
63
+ ),
64
+ },
65
+ {
66
+ accessorKey: "description",
67
+ header: "Descrição",
68
+ cell: ({ row }) => {
69
+ const desc = row.original.description;
70
+ if (!desc) return <span className="text-muted-foreground text-sm">{"\u2014"}</span>;
71
+ return (
72
+ <span className="text-muted-foreground text-sm">
73
+ {desc.length > 50 ? `${desc.slice(0, 50)}...` : desc}
74
+ </span>
75
+ );
76
+ },
77
+ },
78
+ {
79
+ accessorKey: "datetime_add",
80
+ header: "Criado em",
81
+ cell: ({ row }) => (
82
+ <span className="text-muted-foreground text-sm">
83
+ {format(new Date(row.original.datetime_add), "dd/MM/yyyy", {
84
+ locale: ptBR,
85
+ })}
86
+ </span>
87
+ ),
88
+ },
89
+ {
90
+ id: "actions",
91
+ size: 80,
92
+ enableSorting: false,
93
+ cell: ({ row }) => (
94
+ <div className="flex items-center gap-1">
95
+ <Tooltip>
96
+ <TooltipTrigger asChild>
97
+ <Button
98
+ variant="ghost"
99
+ size="icon"
100
+ className="h-8 w-8"
101
+ onClick={() => onEdit(row.original)}
102
+ >
103
+ <Pencil className="h-4 w-4" />
104
+ </Button>
105
+ </TooltipTrigger>
106
+ <TooltipContent>Editar</TooltipContent>
107
+ </Tooltip>
108
+ <Tooltip>
109
+ <TooltipTrigger asChild>
110
+ <Button
111
+ variant="ghost"
112
+ size="icon"
113
+ className="h-8 w-8 text-destructive hover:text-destructive"
114
+ onClick={() => onDelete(row.original.id)}
115
+ >
116
+ <Trash2 className="h-4 w-4" />
117
+ </Button>
118
+ </TooltipTrigger>
119
+ <TooltipContent>Excluir</TooltipContent>
120
+ </Tooltip>
121
+ </div>
122
+ ),
123
+ },
124
+ ];
125
+ }
126
+
127
+ export function ToolsTable({ onEdit, config }: ToolsTableProps) {
128
+ const [search, setSearch] = useState("");
129
+ const [page, setPage] = useState(1);
130
+
131
+ const queryParams = useMemo(() => {
132
+ const params: Record<string, string> = {
133
+ limit: "15",
134
+ page: String(page),
135
+ };
136
+ if (search) {
137
+ params.search = search;
138
+ }
139
+ return params;
140
+ }, [search, page]);
141
+
142
+ const { data, isLoading } = useTools(config, queryParams);
143
+ const deleteTool = useDeleteTool(config);
144
+ const [deleteId, setDeleteId] = useState<number | null>(null);
145
+
146
+ const tools = data?.data || [];
147
+ const total = data?.total || 0;
148
+
149
+ const columns = useColumns(
150
+ (tool) => onEdit(tool),
151
+ (id) => setDeleteId(id),
152
+ );
153
+
154
+ function handleDelete() {
155
+ if (!deleteId) return;
156
+ deleteTool.mutate(deleteId, {
157
+ onSuccess: () => {
158
+ toast.success("Ferramenta excluída");
159
+ setDeleteId(null);
160
+ },
161
+ onError: () => toast.error("Erro ao excluir ferramenta"),
162
+ });
163
+ }
164
+
165
+ function handleSearchChange(value: string) {
166
+ setSearch(value);
167
+ setPage(1);
168
+ }
169
+
170
+ return (
171
+ <>
172
+ <div className="flex items-center gap-3">
173
+ <div className="relative flex-1 max-w-md">
174
+ <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
175
+ <Input
176
+ placeholder="Buscar ferramentas..."
177
+ value={search}
178
+ onChange={(e) => handleSearchChange(e.target.value)}
179
+ className="pl-9"
180
+ />
181
+ </div>
182
+ </div>
183
+
184
+ <DataTable
185
+ columns={columns}
186
+ data={tools}
187
+ isLoading={isLoading}
188
+ emptyMessage="Nenhuma ferramenta encontrada"
189
+ total={total}
190
+ page={page}
191
+ onPageChange={setPage}
192
+ pageSize={15}
193
+ />
194
+
195
+ {/* Delete confirmation */}
196
+ <AlertDialog
197
+ open={!!deleteId}
198
+ onOpenChange={(open) => !open && setDeleteId(null)}
199
+ >
200
+ <AlertDialogContent>
201
+ <AlertDialogHeader>
202
+ <AlertDialogTitle>Excluir ferramenta?</AlertDialogTitle>
203
+ <AlertDialogDescription>
204
+ Esta ação não pode ser desfeita. A ferramenta será removida
205
+ permanentemente.
206
+ </AlertDialogDescription>
207
+ </AlertDialogHeader>
208
+ <AlertDialogFooter>
209
+ <AlertDialogCancel variant="outline" size="default">
210
+ Cancelar
211
+ </AlertDialogCancel>
212
+ <AlertDialogAction
213
+ variant="default"
214
+ size="default"
215
+ onClick={handleDelete}
216
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
217
+ >
218
+ Excluir
219
+ </AlertDialogAction>
220
+ </AlertDialogFooter>
221
+ </AlertDialogContent>
222
+ </AlertDialog>
223
+ </>
224
+ );
225
+ }