@greatapps/greatagents-ui 0.1.0 → 0.2.1

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,406 @@
1
+ import { useState, useRef, useCallback, useEffect } from "react";
2
+ import type { Agent, PromptVersion, Objective, AgentTool, Tool } from "../../types";
3
+ import type { GagentsHookConfig } from "../../hooks/types";
4
+ import { usePromptVersions } from "../../hooks";
5
+ import { useUpdateAgent } from "../../hooks";
6
+ import { useAgentTools } from "../../hooks";
7
+ import { useObjectives } from "../../hooks";
8
+ import { useTools } from "../../hooks";
9
+ import { Button, Input, Skeleton, Badge } from "@greatapps/greatauth-ui/ui";
10
+ import { FileText, Loader2, ChevronDown, ChevronUp, RotateCcw } from "lucide-react";
11
+ import { toast } from "sonner";
12
+
13
+ interface AgentPromptEditorProps {
14
+ config: GagentsHookConfig;
15
+ agent: Agent;
16
+ }
17
+
18
+ function formatDate(dateStr: string): string {
19
+ const date = new Date(dateStr);
20
+ return date.toLocaleDateString("pt-BR", {
21
+ day: "2-digit",
22
+ month: "2-digit",
23
+ year: "numeric",
24
+ hour: "2-digit",
25
+ minute: "2-digit",
26
+ });
27
+ }
28
+
29
+ function computeDiff(
30
+ oldText: string,
31
+ newText: string,
32
+ ): { type: "added" | "removed" | "equal"; line: string }[] {
33
+ const oldLines = oldText.split("\n");
34
+ const newLines = newText.split("\n");
35
+ const result: { type: "added" | "removed" | "equal"; line: string }[] = [];
36
+ const maxLen = Math.max(oldLines.length, newLines.length);
37
+
38
+ for (let i = 0; i < maxLen; i++) {
39
+ const oldLine = i < oldLines.length ? oldLines[i] : undefined;
40
+ const newLine = i < newLines.length ? newLines[i] : undefined;
41
+
42
+ if (oldLine === newLine) {
43
+ result.push({ type: "equal", line: newLine! });
44
+ } else {
45
+ if (oldLine !== undefined) {
46
+ result.push({ type: "removed", line: oldLine });
47
+ }
48
+ if (newLine !== undefined) {
49
+ result.push({ type: "added", line: newLine });
50
+ }
51
+ }
52
+ }
53
+
54
+ return result;
55
+ }
56
+
57
+ function buildPreview(
58
+ promptText: string,
59
+ objectives: Objective[],
60
+ agentTools: AgentTool[],
61
+ allTools: Tool[],
62
+ ): string {
63
+ let preview = promptText;
64
+
65
+ const activeObjectives = objectives.filter((o) => o.active);
66
+ if (activeObjectives.length > 0) {
67
+ preview += "\n\n[SKILLS DISPONÍVEIS]\n";
68
+ for (const obj of activeObjectives) {
69
+ preview += `- ${obj.title}`;
70
+ if (obj.prompt) preview += `: ${obj.prompt}`;
71
+ preview += "\n";
72
+ }
73
+ }
74
+
75
+ const enabledAgentTools = agentTools.filter((at) => at.enabled);
76
+ if (enabledAgentTools.length > 0) {
77
+ const toolMap = new Map(allTools.map((t) => [t.id, t]));
78
+ preview += "\n[TOOLS DISPONÍVEIS]\n";
79
+ for (const at of enabledAgentTools) {
80
+ const tool = toolMap.get(at.id_tool);
81
+ const name = tool?.name || `Tool #${at.id_tool}`;
82
+ const desc = tool?.description ? `: ${tool.description}` : "";
83
+ preview += `- ${name}${desc}`;
84
+ if (at.custom_instructions) {
85
+ preview += `\n Instruções: ${at.custom_instructions}`;
86
+ }
87
+ preview += "\n";
88
+ }
89
+ }
90
+
91
+ return preview;
92
+ }
93
+
94
+ export function AgentPromptEditor({ config, agent }: AgentPromptEditorProps) {
95
+ const { data: versionsData, isLoading } = usePromptVersions(config, agent.id);
96
+ const updateAgent = useUpdateAgent(config);
97
+ const { data: objectivesData } = useObjectives(config, agent.id);
98
+ const { data: agentToolsData } = useAgentTools(config, agent.id);
99
+ const { data: toolsData } = useTools(config);
100
+
101
+ const [trackedAgentId, setTrackedAgentId] = useState(agent.id);
102
+ const [promptText, setPromptText] = useState(agent.prompt || "");
103
+ const [changeNotes, setChangeNotes] = useState("");
104
+ const [showPreview, setShowPreview] = useState(false);
105
+ const [compareVersionId, setCompareVersionId] = useState<number | null>(null);
106
+
107
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
108
+
109
+ // Reset prompt text when agent changes
110
+ if (trackedAgentId !== agent.id) {
111
+ setTrackedAgentId(agent.id);
112
+ setPromptText(agent.prompt || "");
113
+ setCompareVersionId(null);
114
+ }
115
+
116
+ // Auto-resize textarea
117
+ const autoResize = useCallback(() => {
118
+ const el = textareaRef.current;
119
+ if (!el) return;
120
+ el.style.height = "auto";
121
+ el.style.height = `${Math.max(300, el.scrollHeight)}px`;
122
+ }, []);
123
+
124
+ useEffect(() => {
125
+ autoResize();
126
+ }, [promptText, autoResize]);
127
+
128
+ // Tab key support
129
+ function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
130
+ if (e.key === "Tab") {
131
+ e.preventDefault();
132
+ const el = e.currentTarget;
133
+ const start = el.selectionStart;
134
+ const end = el.selectionEnd;
135
+ const value = el.value;
136
+ const newValue = value.substring(0, start) + " " + value.substring(end);
137
+ setPromptText(newValue);
138
+ // Restore cursor position after React re-render
139
+ requestAnimationFrame(() => {
140
+ el.selectionStart = el.selectionEnd = start + 2;
141
+ });
142
+ }
143
+ }
144
+
145
+ const versions = versionsData?.data || [];
146
+ const sortedVersions = [...versions].sort(
147
+ (a: PromptVersion, b: PromptVersion) =>
148
+ new Date(b.datetime_add).getTime() - new Date(a.datetime_add).getTime(),
149
+ );
150
+
151
+ const currentVersion = sortedVersions.length > 0 ? sortedVersions[0] : null;
152
+ const compareVersion = sortedVersions.find((v) => v.id === compareVersionId);
153
+
154
+ // Diff: always compare selected older version against current
155
+ const diffLines =
156
+ currentVersion && compareVersion && compareVersion.id !== currentVersion.id
157
+ ? computeDiff(compareVersion.prompt_content ?? "", currentVersion.prompt_content ?? "")
158
+ : null;
159
+
160
+ async function handleSave() {
161
+ const body: Record<string, unknown> = {
162
+ prompt: promptText.trim(),
163
+ };
164
+ if (changeNotes.trim()) {
165
+ body.change_notes = changeNotes.trim();
166
+ }
167
+
168
+ try {
169
+ await updateAgent.mutateAsync({ id: agent.id, body });
170
+ setChangeNotes("");
171
+ toast.success("Prompt salvo com sucesso");
172
+ } catch {
173
+ toast.error("Erro ao salvar prompt");
174
+ }
175
+ }
176
+
177
+ function handleRestore(version: PromptVersion) {
178
+ setPromptText(version.prompt_content ?? "");
179
+ setChangeNotes(`Restaurado da v${version.version_number}`);
180
+ toast.info("Prompt restaurado no editor. Clique em Salvar para confirmar.");
181
+ }
182
+
183
+ const charCount = promptText.length;
184
+ const tokenEstimate = Math.ceil(charCount / 4);
185
+
186
+ const objectives = objectivesData?.data || [];
187
+ const agentTools = agentToolsData?.data || [];
188
+ const allTools = toolsData?.data || [];
189
+ const previewText = buildPreview(promptText, objectives, agentTools, allTools);
190
+
191
+ if (isLoading) {
192
+ return (
193
+ <div className="space-y-3 p-4">
194
+ {Array.from({ length: 3 }).map((_, i) => (
195
+ <Skeleton key={i} className="h-14 w-full" />
196
+ ))}
197
+ </div>
198
+ );
199
+ }
200
+
201
+ return (
202
+ <div className="flex flex-col gap-4 p-4 lg:flex-row">
203
+ {/* Editor section */}
204
+ <div className="min-w-0 flex-1 space-y-4">
205
+ {/* Textarea */}
206
+ <div className="space-y-2">
207
+ <textarea
208
+ ref={textareaRef}
209
+ value={promptText}
210
+ onChange={(e) => setPromptText(e.target.value)}
211
+ onKeyDown={handleKeyDown}
212
+ placeholder="Escreva o prompt do sistema aqui..."
213
+ disabled={updateAgent.isPending}
214
+ className="w-full resize-none rounded-lg border bg-background p-3 font-mono text-sm leading-relaxed focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
215
+ style={{ minHeight: "300px" }}
216
+ />
217
+ <div className="flex items-center gap-3 text-xs text-muted-foreground">
218
+ <span>{charCount.toLocaleString("pt-BR")} caracteres</span>
219
+ <span>·</span>
220
+ <span>~{tokenEstimate.toLocaleString("pt-BR")} tokens</span>
221
+ </div>
222
+ </div>
223
+
224
+ {/* Save row */}
225
+ <div className="flex items-center gap-3">
226
+ <Input
227
+ value={changeNotes}
228
+ onChange={(e) => setChangeNotes(e.target.value)}
229
+ placeholder="O que mudou? (opcional)"
230
+ disabled={updateAgent.isPending}
231
+ className="flex-1"
232
+ onKeyDown={(e) => {
233
+ if (e.key === "Enter") {
234
+ e.preventDefault();
235
+ handleSave();
236
+ }
237
+ }}
238
+ />
239
+ <Button
240
+ onClick={handleSave}
241
+ disabled={updateAgent.isPending || !promptText.trim()}
242
+ >
243
+ {updateAgent.isPending && (
244
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
245
+ )}
246
+ Salvar
247
+ </Button>
248
+ </div>
249
+
250
+ {/* Preview section */}
251
+ <div className="rounded-lg border">
252
+ <button
253
+ type="button"
254
+ onClick={() => setShowPreview((prev) => !prev)}
255
+ className="flex w-full items-center justify-between px-4 py-3 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
256
+ >
257
+ <span>Preview do prompt final</span>
258
+ {showPreview ? (
259
+ <ChevronUp className="h-4 w-4" />
260
+ ) : (
261
+ <ChevronDown className="h-4 w-4" />
262
+ )}
263
+ </button>
264
+ {showPreview && (
265
+ <div className="border-t px-4 py-3">
266
+ <pre className="max-h-96 overflow-auto whitespace-pre-wrap font-mono text-sm leading-relaxed">
267
+ {previewText.split("\n").map((line, i) => {
268
+ const isSection =
269
+ line.startsWith("[SKILLS DISPONÍVEIS]") ||
270
+ line.startsWith("[TOOLS DISPONÍVEIS]");
271
+ return (
272
+ <span
273
+ key={i}
274
+ className={isSection ? "font-semibold text-muted-foreground" : ""}
275
+ >
276
+ {line}
277
+ {"\n"}
278
+ </span>
279
+ );
280
+ })}
281
+ </pre>
282
+ </div>
283
+ )}
284
+ </div>
285
+
286
+ {/* Diff panel (when comparing) */}
287
+ {diffLines && compareVersion && currentVersion && (
288
+ <div>
289
+ <div className="mb-2 flex items-center justify-between">
290
+ <h3 className="text-sm font-medium text-muted-foreground">
291
+ Diferenças: v{compareVersion.version_number} → v{currentVersion.version_number} (actual)
292
+ </h3>
293
+ <Button
294
+ variant="ghost"
295
+ size="sm"
296
+ onClick={() => setCompareVersionId(null)}
297
+ className="text-xs"
298
+ >
299
+ Fechar
300
+ </Button>
301
+ </div>
302
+ <div className="max-h-64 overflow-auto rounded-lg border font-mono text-sm">
303
+ {diffLines.map((line, i) => (
304
+ <div
305
+ key={i}
306
+ className={`whitespace-pre-wrap px-3 py-0.5 ${
307
+ line.type === "added"
308
+ ? "bg-green-500/10 text-green-700 dark:text-green-400"
309
+ : line.type === "removed"
310
+ ? "bg-red-500/10 text-red-700 dark:text-red-400"
311
+ : ""
312
+ }`}
313
+ >
314
+ <span className="mr-2 inline-block w-4 select-none text-muted-foreground">
315
+ {line.type === "added" ? "+" : line.type === "removed" ? "-" : " "}
316
+ </span>
317
+ {line.line || " "}
318
+ </div>
319
+ ))}
320
+ </div>
321
+ </div>
322
+ )}
323
+ </div>
324
+
325
+ {/* Timeline section (right) */}
326
+ <div className="w-full space-y-2 lg:w-80 lg:shrink-0">
327
+ <h3 className="text-sm font-medium text-muted-foreground">
328
+ Histórico de Versões
329
+ </h3>
330
+ {sortedVersions.length === 0 ? (
331
+ <div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center">
332
+ <FileText className="mb-2 h-8 w-8 text-muted-foreground" />
333
+ <p className="text-sm text-muted-foreground">
334
+ Nenhuma versão encontrada. Salve o prompt para criar a primeira versão.
335
+ </p>
336
+ </div>
337
+ ) : (
338
+ <div className="space-y-1">
339
+ {sortedVersions.map((version, idx) => {
340
+ const isCurrent = idx === 0;
341
+ const isComparing = version.id === compareVersionId;
342
+ return (
343
+ <div
344
+ key={version.id}
345
+ className={`rounded-lg border p-3 transition-colors ${
346
+ isCurrent
347
+ ? "border-primary bg-primary/5"
348
+ : isComparing
349
+ ? "border-muted-foreground/30 bg-muted/50"
350
+ : ""
351
+ }`}
352
+ >
353
+ <div className="flex items-center justify-between gap-2">
354
+ <span className="text-sm font-medium">
355
+ v{version.version_number}
356
+ </span>
357
+ {isCurrent && (
358
+ <Badge variant="default" className="text-[10px] px-1.5 py-0">
359
+ Actual
360
+ </Badge>
361
+ )}
362
+ </div>
363
+ <div className="mt-1 text-xs text-muted-foreground">
364
+ {formatDate(version.datetime_add)}
365
+ </div>
366
+ <div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
367
+ <span>{(version.prompt_content ?? "").length} chars</span>
368
+ <span>·</span>
369
+ <span className="truncate font-mono">
370
+ {(version.prompt_hash ?? "").slice(0, 8)}
371
+ </span>
372
+ </div>
373
+ {version.change_notes && (
374
+ <div className="mt-1.5 text-xs italic text-muted-foreground">
375
+ {version.change_notes}
376
+ </div>
377
+ )}
378
+ {!isCurrent && (
379
+ <div className="mt-2 flex items-center gap-3">
380
+ <button
381
+ type="button"
382
+ onClick={() => setCompareVersionId(isComparing ? null : version.id)}
383
+ className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
384
+ >
385
+ <FileText className="h-3 w-3" />
386
+ {isComparing ? "Ocultar diff" : "Comparar"}
387
+ </button>
388
+ <button
389
+ type="button"
390
+ onClick={() => handleRestore(version)}
391
+ className="flex items-center gap-1 text-xs text-primary hover:underline"
392
+ >
393
+ <RotateCcw className="h-3 w-3" />
394
+ Restaurar
395
+ </button>
396
+ </div>
397
+ )}
398
+ </div>
399
+ );
400
+ })}
401
+ </div>
402
+ )}
403
+ </div>
404
+ </div>
405
+ );
406
+ }
@@ -0,0 +1,64 @@
1
+ import type { Agent } from "../../types";
2
+ import type { GagentsHookConfig } from "../../hooks/types";
3
+ import { AgentToolsList } from "./agent-tools-list";
4
+ import { AgentObjectivesList } from "./agent-objectives-list";
5
+ import { AgentPromptEditor } from "./agent-prompt-editor";
6
+ import { AgentConversationsPanel } from "../conversations/agent-conversations-panel";
7
+ import {
8
+ Tabs,
9
+ TabsList,
10
+ TabsTrigger,
11
+ TabsContent,
12
+ } from "@greatapps/greatauth-ui/ui";
13
+ import { Wrench, Target, FileText, MessageCircle } from "lucide-react";
14
+
15
+ interface AgentTabsProps {
16
+ agent: Agent;
17
+ config: GagentsHookConfig;
18
+ renderChatLink?: (inboxId: number) => React.ReactNode;
19
+ }
20
+
21
+ export function AgentTabs({ agent, config, renderChatLink }: AgentTabsProps) {
22
+ return (
23
+ <Tabs defaultValue="prompt">
24
+ <TabsList>
25
+ <TabsTrigger value="prompt" className="flex items-center gap-1.5">
26
+ <FileText className="h-3.5 w-3.5" />
27
+ Prompt
28
+ </TabsTrigger>
29
+ <TabsTrigger value="objetivos" className="flex items-center gap-1.5">
30
+ <Target className="h-3.5 w-3.5" />
31
+ Objetivos
32
+ </TabsTrigger>
33
+ <TabsTrigger value="ferramentas" className="flex items-center gap-1.5">
34
+ <Wrench className="h-3.5 w-3.5" />
35
+ Ferramentas
36
+ </TabsTrigger>
37
+ <TabsTrigger value="conversas" className="flex items-center gap-1.5">
38
+ <MessageCircle className="h-3.5 w-3.5" />
39
+ Conversas
40
+ </TabsTrigger>
41
+ </TabsList>
42
+
43
+ <TabsContent value="prompt" className="mt-4">
44
+ <AgentPromptEditor agent={agent} config={config} />
45
+ </TabsContent>
46
+
47
+ <TabsContent value="objetivos" className="mt-4">
48
+ <AgentObjectivesList agent={agent} config={config} />
49
+ </TabsContent>
50
+
51
+ <TabsContent value="ferramentas" className="mt-4">
52
+ <AgentToolsList agent={agent} config={config} />
53
+ </TabsContent>
54
+
55
+ <TabsContent value="conversas" className="mt-4">
56
+ <AgentConversationsPanel
57
+ agent={agent}
58
+ config={config}
59
+ renderChatLink={renderChatLink}
60
+ />
61
+ </TabsContent>
62
+ </Tabs>
63
+ );
64
+ }