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 { CommandPalette } from "@/components/CommandPalette";
|
||||
import { KnowledgeCollectorInit } from "@/components/KnowledgeCollectorInit";
|
||||
import { OpenClawWidget } from "@/components/openclaw/OpenClawWidget";
|
||||
import NotFound from "@/pages/not-found";
|
||||
import AuthPage from "@/pages/auth-page";
|
||||
|
||||
|
|
@ -164,6 +165,7 @@ function App() {
|
|||
<KnowledgeCollectorInit />
|
||||
<Toaster />
|
||||
<CommandPalette />
|
||||
<OpenClawWidget />
|
||||
<Router />
|
||||
</TooltipProvider>
|
||||
</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