feat(openclaw): Fase 4 Sprint 2 - Frontend Widgets
- useAgentEmergence.ts: hook para Socket.IO + estado de sugestões - SkillSuggestion.tsx: modal com preview de skill (padrão, nome, descrição, automação) - OpenClawWidget.tsx: widget flutuante no canto inferior direito - App.tsx: integração do OpenClawWidget no layout global Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
dcaa94a422
commit
52311666aa
|
|
@ -9,6 +9,7 @@ import { SoeMotorProvider } from "@/contexts/SoeMotorContext";
|
||||||
import { ProtectedRoute } from "@/lib/protected-route";
|
import { ProtectedRoute } from "@/lib/protected-route";
|
||||||
import { CommandPalette } from "@/components/CommandPalette";
|
import { CommandPalette } from "@/components/CommandPalette";
|
||||||
import { KnowledgeCollectorInit } from "@/components/KnowledgeCollectorInit";
|
import { KnowledgeCollectorInit } from "@/components/KnowledgeCollectorInit";
|
||||||
|
import { OpenClawWidget } from "@/components/openclaw/OpenClawWidget";
|
||||||
import NotFound from "@/pages/not-found";
|
import NotFound from "@/pages/not-found";
|
||||||
import AuthPage from "@/pages/auth-page";
|
import AuthPage from "@/pages/auth-page";
|
||||||
|
|
||||||
|
|
@ -164,6 +165,7 @@ function App() {
|
||||||
<KnowledgeCollectorInit />
|
<KnowledgeCollectorInit />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
|
<OpenClawWidget />
|
||||||
<Router />
|
<Router />
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</SoeMotorProvider>
|
</SoeMotorProvider>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
/**
|
||||||
|
* OpenClawWidget.tsx
|
||||||
|
*
|
||||||
|
* Widget flutuante do OpenClaw — aparece quando há sugestões pendentes.
|
||||||
|
* Posicionado no canto inferior direito, fora das rotas.
|
||||||
|
* Abre o modal SkillSuggestion para confirmação.
|
||||||
|
*
|
||||||
|
* Fase 4: OpenClaw Sprint 2
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Sparkles, X, ChevronUp, ChevronDown } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { useAgentEmergence } from "@/hooks/useAgentEmergence";
|
||||||
|
import { SkillSuggestion } from "./SkillSuggestion";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
|
||||||
|
export function OpenClawWidget() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const {
|
||||||
|
suggestions,
|
||||||
|
activeSuggestion,
|
||||||
|
pendingCount,
|
||||||
|
confirmSkill,
|
||||||
|
rejectSkill,
|
||||||
|
openSuggestion,
|
||||||
|
closeSuggestion,
|
||||||
|
} = useAgentEmergence();
|
||||||
|
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
|
||||||
|
// Não renderiza se não há usuário, não há sugestões, ou foi dispensado
|
||||||
|
if (!user || pendingCount === 0 || dismissed) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Widget flutuante */}
|
||||||
|
<div
|
||||||
|
className="fixed bottom-6 right-6 z-50 flex flex-col items-end gap-2"
|
||||||
|
role="complementary"
|
||||||
|
aria-label="OpenClaw - Sugestões de Skills"
|
||||||
|
>
|
||||||
|
{/* Painel expandido */}
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="w-72 rounded-xl border bg-background shadow-lg overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2.5 border-b bg-violet-50 dark:bg-violet-900/20">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-violet-600 dark:text-violet-400" />
|
||||||
|
<span className="text-sm font-semibold text-violet-700 dark:text-violet-300">
|
||||||
|
OpenClaw
|
||||||
|
</span>
|
||||||
|
<Badge className="bg-violet-600 text-white text-xs px-1.5 py-0">
|
||||||
|
{pendingCount}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(true)}
|
||||||
|
className="p-0.5 rounded hover:bg-violet-100 dark:hover:bg-violet-900/40 text-muted-foreground"
|
||||||
|
aria-label="Minimizar"
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDismissed(true)}
|
||||||
|
className="p-0.5 rounded hover:bg-violet-100 dark:hover:bg-violet-900/40 text-muted-foreground"
|
||||||
|
aria-label="Fechar"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 space-y-2 max-h-64 overflow-y-auto">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Detectei padrões de uso que podem virar skills automáticas:
|
||||||
|
</p>
|
||||||
|
{suggestions.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => openSuggestion(s)}
|
||||||
|
className="w-full text-left rounded-lg border p-2.5 hover:bg-muted/50 transition-colors space-y-1"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span className="text-sm font-medium leading-tight line-clamp-1">
|
||||||
|
{s.suggested_skill_name}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="text-xs shrink-0">
|
||||||
|
{Math.round(s.confidence * 100)}%
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||||
|
{s.suggested_description}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Botão minimizado */}
|
||||||
|
{collapsed && (
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(false)}
|
||||||
|
className="flex items-center gap-2 rounded-full bg-violet-600 hover:bg-violet-700 text-white px-4 py-2.5 shadow-lg transition-colors"
|
||||||
|
aria-label="Expandir sugestões de skills"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">Skills sugeridas</span>
|
||||||
|
<Badge className="bg-white text-violet-700 text-xs px-1.5 py-0 ml-0.5">
|
||||||
|
{pendingCount}
|
||||||
|
</Badge>
|
||||||
|
<ChevronUp className="h-3.5 w-3.5 ml-0.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal de detalhes */}
|
||||||
|
{activeSuggestion && (
|
||||||
|
<SkillSuggestion
|
||||||
|
suggestion={activeSuggestion}
|
||||||
|
onConfirm={confirmSkill}
|
||||||
|
onReject={rejectSkill}
|
||||||
|
onClose={closeSuggestion}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
/**
|
||||||
|
* SkillSuggestion.tsx
|
||||||
|
*
|
||||||
|
* Modal com preview da skill sugerida pelo OpenClaw.
|
||||||
|
* Apresenta padrão detectado, nome, descrição e preview de automação.
|
||||||
|
* Usuário pode confirmar ou rejeitar.
|
||||||
|
*
|
||||||
|
* Fase 4: OpenClaw Sprint 2
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Sparkles, X, Check, Zap, BarChart2 } from "lucide-react";
|
||||||
|
import type { SkillSuggestionData } from "@/hooks/useAgentEmergence";
|
||||||
|
|
||||||
|
interface SkillSuggestionProps {
|
||||||
|
suggestion: SkillSuggestionData;
|
||||||
|
onConfirm: (id: string) => void;
|
||||||
|
onReject: (id: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkillSuggestion({ suggestion, onConfirm, onReject, onClose }: SkillSuggestionProps) {
|
||||||
|
const confidencePct = Math.round(suggestion.confidence * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={onClose}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-1.5 rounded-md bg-violet-100 dark:bg-violet-900/30">
|
||||||
|
<Sparkles className="h-4 w-4 text-violet-600 dark:text-violet-400" />
|
||||||
|
</div>
|
||||||
|
<DialogTitle className="text-base">Nova skill detectada</DialogTitle>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Padrão detectado */}
|
||||||
|
<div className="rounded-lg border bg-muted/40 p-3 text-sm space-y-1">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Padrão detectado</p>
|
||||||
|
<p className="font-medium">{suggestion.pattern.description || suggestion.pattern.action_type}</p>
|
||||||
|
<div className="flex items-center gap-3 text-muted-foreground text-xs mt-1">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<BarChart2 className="h-3 w-3" />
|
||||||
|
{suggestion.pattern.frequency}x detectado
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Zap className="h-3 w-3" />
|
||||||
|
Confiança: {confidencePct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skill sugerida */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-1">Nome da skill</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary" className="font-mono text-xs">
|
||||||
|
{suggestion.suggested_skill_name}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-1">Descrição</p>
|
||||||
|
<p className="text-sm">{suggestion.suggested_description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-1">O que será automatizado</p>
|
||||||
|
<div className="rounded-md border bg-muted/20 p-2.5 text-sm text-muted-foreground">
|
||||||
|
{suggestion.estimated_automation}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onReject(suggestion.id)}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
Rejeitar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onConfirm(suggestion.id)}
|
||||||
|
className="gap-1.5 bg-violet-600 hover:bg-violet-700"
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
Criar skill
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
/**
|
||||||
|
* useAgentEmergence.ts
|
||||||
|
*
|
||||||
|
* Hook para gerenciar sugestões de skills do OpenClaw.
|
||||||
|
* Escuta eventos Socket.IO e coordena confirmações/rejeições.
|
||||||
|
*
|
||||||
|
* Fase 4: OpenClaw Sprint 2
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useAuth } from "./use-auth";
|
||||||
|
|
||||||
|
export interface SkillSuggestionData {
|
||||||
|
id: string;
|
||||||
|
pattern: {
|
||||||
|
id: string;
|
||||||
|
action_type: string;
|
||||||
|
frequency: number;
|
||||||
|
confidence: number;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
suggested_skill_name: string;
|
||||||
|
suggested_description: string;
|
||||||
|
estimated_automation: string;
|
||||||
|
confidence: number;
|
||||||
|
created_at: string;
|
||||||
|
status: "pending" | "accepted" | "rejected";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseAgentEmergenceReturn {
|
||||||
|
suggestions: SkillSuggestionData[];
|
||||||
|
activeSuggestion: SkillSuggestionData | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
pendingCount: number;
|
||||||
|
confirmSkill: (suggestionId: string) => Promise<void>;
|
||||||
|
rejectSkill: (suggestionId: string) => Promise<void>;
|
||||||
|
openSuggestion: (suggestion: SkillSuggestionData) => void;
|
||||||
|
closeSuggestion: () => void;
|
||||||
|
dismissWidget: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgentEmergence(): UseAgentEmergenceReturn {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [suggestions, setSuggestions] = useState<SkillSuggestionData[]>([]);
|
||||||
|
const [activeSuggestion, setActiveSuggestion] = useState<SkillSuggestionData | null>(null);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
// Carrega sugestões pendentes do servidor
|
||||||
|
const fetchPendingSuggestions = useCallback(async () => {
|
||||||
|
if (!user) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/openclaw/suggestions");
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setSuggestions(data.suggestions || []);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silencioso — não bloqueia o usuário
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
// Escuta eventos Socket.IO via window (padrão da suite)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
fetchPendingSuggestions();
|
||||||
|
|
||||||
|
const handleOpenClawEvent = (event: CustomEvent) => {
|
||||||
|
const suggestion: SkillSuggestionData = event.detail;
|
||||||
|
setSuggestions((prev) => {
|
||||||
|
const exists = prev.find((s) => s.id === suggestion.id);
|
||||||
|
if (exists) return prev;
|
||||||
|
return [suggestion, ...prev];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("openclaw:suggestion" as any, handleOpenClawEvent);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("openclaw:suggestion" as any, handleOpenClawEvent);
|
||||||
|
};
|
||||||
|
}, [user, fetchPendingSuggestions]);
|
||||||
|
|
||||||
|
const confirmSkill = useCallback(async (suggestionId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/openclaw/confirm-skill", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ suggestion_id: suggestionId, action: "confirm" }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setSuggestions((prev) => prev.filter((s) => s.id !== suggestionId));
|
||||||
|
setActiveSuggestion(null);
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// erro tratado na UI via toast externo
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const rejectSkill = useCallback(async (suggestionId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/openclaw/confirm-skill", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ suggestion_id: suggestionId, action: "reject" }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setSuggestions((prev) => prev.filter((s) => s.id !== suggestionId));
|
||||||
|
setActiveSuggestion(null);
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silencioso
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openSuggestion = useCallback((suggestion: SkillSuggestionData) => {
|
||||||
|
setActiveSuggestion(suggestion);
|
||||||
|
setIsOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeSuggestion = useCallback(() => {
|
||||||
|
setActiveSuggestion(null);
|
||||||
|
setIsOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismissWidget = useCallback(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setActiveSuggestion(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
suggestions,
|
||||||
|
activeSuggestion,
|
||||||
|
isOpen,
|
||||||
|
pendingCount: suggestions.length,
|
||||||
|
confirmSkill,
|
||||||
|
rejectSkill,
|
||||||
|
openSuggestion,
|
||||||
|
closeSuggestion,
|
||||||
|
dismissWidget,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue