true message
This commit is contained in:
@@ -12,6 +12,7 @@ import Chat from "./pages/Chat";
|
||||
import Settings from "./pages/Settings";
|
||||
import Nodes from "./pages/Nodes";
|
||||
import Tools from "./pages/Tools";
|
||||
import Skills from "./pages/Skills";
|
||||
|
||||
function Router() {
|
||||
// make sure to consider if you need authentication for certain routes
|
||||
@@ -24,6 +25,7 @@ function Router() {
|
||||
<Route path="/nodes" component={Nodes} />
|
||||
<Route path="/chat" component={Chat} />
|
||||
<Route path="/tools" component={Tools} />
|
||||
<Route path="/skills" component={Skills} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
<Route path="/404" component={NotFound} />
|
||||
<Route component={NotFound} />
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
HardDrive,
|
||||
Wifi,
|
||||
Wrench,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
@@ -27,6 +28,7 @@ const NAV_ITEMS = [
|
||||
{ path: "/", icon: LayoutDashboard, label: "Дашборд" },
|
||||
{ path: "/agents", icon: Bot, label: "Агенты" },
|
||||
{ path: "/tools", icon: Wrench, label: "Инструменты" },
|
||||
{ path: "/skills", icon: Zap, label: "Скилы" },
|
||||
{ path: "/nodes", icon: Server, label: "Ноды" },
|
||||
{ path: "/chat", icon: MessageSquare, label: "Чат" },
|
||||
{ path: "/settings", icon: Settings, label: "Настройки" },
|
||||
|
||||
@@ -1,358 +1,458 @@
|
||||
/*
|
||||
* Chat — Terminal-style chat with GoClaw Orchestrator + Real Ollama LLM
|
||||
* Design: Terminal aesthetic, monospace font, typing animation, command history
|
||||
* Colors: Cyan for system, green for success, amber for warnings, white for user
|
||||
* Typography: JetBrains Mono exclusively
|
||||
*/
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Send, Terminal, Bot, User, AlertTriangle, CheckCircle, Info, Loader2, Brain } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Terminal,
|
||||
Send,
|
||||
Loader2,
|
||||
Bot,
|
||||
User,
|
||||
Wrench,
|
||||
Globe,
|
||||
Cpu,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
Zap,
|
||||
Code,
|
||||
FileText,
|
||||
Shell,
|
||||
Network,
|
||||
Database,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ToolCallStep {
|
||||
tool: string;
|
||||
args: Record<string, any>;
|
||||
result: any;
|
||||
success: boolean;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
type MessageRole = "user" | "assistant" | "system";
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: "user" | "system" | "agent";
|
||||
role: MessageRole;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
type?: "info" | "success" | "warning" | "error" | "command";
|
||||
agent?: string;
|
||||
toolCalls?: ToolCallStep[];
|
||||
model?: string;
|
||||
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
function getTs() {
|
||||
const now = new Date();
|
||||
return `${now.getHours().toString().padStart(2, "0")}:${now.getMinutes().toString().padStart(2, "0")}:${now.getSeconds().toString().padStart(2, "0")}`;
|
||||
// ─── Tool Icon Map ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ToolIcon({ tool }: { tool: string }) {
|
||||
const icons: Record<string, React.ReactNode> = {
|
||||
shell_exec: <Terminal className="w-3.5 h-3.5" />,
|
||||
file_read: <FileText className="w-3.5 h-3.5" />,
|
||||
file_write: <FileText className="w-3.5 h-3.5" />,
|
||||
file_list: <Database className="w-3.5 h-3.5" />,
|
||||
http_request: <Network className="w-3.5 h-3.5" />,
|
||||
delegate_to_agent: <Bot className="w-3.5 h-3.5" />,
|
||||
list_agents: <Bot className="w-3.5 h-3.5" />,
|
||||
list_skills: <Zap className="w-3.5 h-3.5" />,
|
||||
install_skill: <Zap className="w-3.5 h-3.5" />,
|
||||
docker_exec: <Code className="w-3.5 h-3.5" />,
|
||||
};
|
||||
return <span className="text-primary">{icons[tool] ?? <Wrench className="w-3.5 h-3.5" />}</span>;
|
||||
}
|
||||
|
||||
function getMessageIcon(msg: ChatMessage) {
|
||||
if (msg.role === "user") return <User className="w-3.5 h-3.5" />;
|
||||
if (msg.role === "agent") return <Bot className="w-3.5 h-3.5" />;
|
||||
switch (msg.type) {
|
||||
case "success": return <CheckCircle className="w-3.5 h-3.5 text-neon-green" />;
|
||||
case "warning": return <AlertTriangle className="w-3.5 h-3.5 text-neon-amber" />;
|
||||
case "error": return <AlertTriangle className="w-3.5 h-3.5 text-neon-red" />;
|
||||
default: return <Info className="w-3.5 h-3.5 text-primary" />;
|
||||
}
|
||||
function toolLabel(tool: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
shell_exec: "Shell",
|
||||
file_read: "Read File",
|
||||
file_write: "Write File",
|
||||
file_list: "List Dir",
|
||||
http_request: "HTTP",
|
||||
delegate_to_agent: "Delegate",
|
||||
list_agents: "List Agents",
|
||||
list_skills: "List Skills",
|
||||
install_skill: "Install Skill",
|
||||
docker_exec: "Docker",
|
||||
};
|
||||
return labels[tool] ?? tool;
|
||||
}
|
||||
|
||||
function getMessageColor(msg: ChatMessage) {
|
||||
if (msg.role === "user") return "text-foreground";
|
||||
switch (msg.type) {
|
||||
case "success": return "text-neon-green";
|
||||
case "warning": return "text-neon-amber";
|
||||
case "error": return "text-neon-red";
|
||||
default: return "text-primary";
|
||||
}
|
||||
// ─── Tool Call Card ───────────────────────────────────────────────────────────
|
||||
|
||||
function ToolCallCard({ step, index }: { step: ToolCallStep; index: number }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const argsSummary = () => {
|
||||
if (step.tool === "shell_exec") return step.args.command?.slice(0, 60);
|
||||
if (step.tool === "file_read" || step.tool === "file_write") return step.args.path;
|
||||
if (step.tool === "http_request") return `${step.args.method || "GET"} ${step.args.url?.slice(0, 50)}`;
|
||||
if (step.tool === "delegate_to_agent") return `Agent #${step.args.agentId}: ${step.args.message?.slice(0, 40)}`;
|
||||
if (step.tool === "docker_exec") return `docker ${step.args.command?.slice(0, 50)}`;
|
||||
return JSON.stringify(step.args).slice(0, 60);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -5 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className="border border-border/40 rounded-md bg-secondary/20 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-secondary/40 transition-colors"
|
||||
>
|
||||
<ToolIcon tool={step.tool} />
|
||||
<span className="text-xs font-mono text-primary font-medium">{toolLabel(step.tool)}</span>
|
||||
<span className="text-xs font-mono text-muted-foreground flex-1 truncate">{argsSummary()}</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-[10px] font-mono text-muted-foreground">{step.durationMs}ms</span>
|
||||
{step.success ? (
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="w-3.5 h-3.5 text-red-500" />
|
||||
)}
|
||||
{expanded ? (
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="px-3 pb-3 space-y-2 border-t border-border/30">
|
||||
<div>
|
||||
<p className="text-[10px] font-mono text-muted-foreground mt-2 mb-1">INPUT</p>
|
||||
<pre className="text-[10px] font-mono text-foreground/80 bg-background/50 rounded p-2 overflow-auto max-h-32 whitespace-pre-wrap">
|
||||
{JSON.stringify(step.args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-mono text-muted-foreground mb-1">OUTPUT</p>
|
||||
<pre className="text-[10px] font-mono text-foreground/80 bg-background/50 rounded p-2 overflow-auto max-h-48 whitespace-pre-wrap">
|
||||
{step.success
|
||||
? typeof step.result === "string"
|
||||
? step.result
|
||||
: JSON.stringify(step.result, null, 2)
|
||||
: `ERROR: ${step.result?.error ?? "Unknown error"}`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `You are GoClaw Gateway Orchestrator — an AI assistant managing a Docker Swarm cluster of specialized AI agents. You help the user monitor, control, and interact with their agent fleet. You respond concisely in a terminal-like style. You can discuss agent management, system monitoring, code development, and general tasks. Respond in the same language as the user's message.`;
|
||||
// ─── Message Bubble ───────────────────────────────────────────────────────────
|
||||
|
||||
function MessageBubble({ msg }: { msg: ChatMessage }) {
|
||||
const isUser = msg.role === "user";
|
||||
const isSystem = msg.role === "system";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`flex gap-3 ${isUser ? "flex-row-reverse" : "flex-row"}`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={`shrink-0 w-7 h-7 rounded-md flex items-center justify-center border ${
|
||||
isUser
|
||||
? "bg-primary/15 border-primary/30"
|
||||
: isSystem
|
||||
? "bg-yellow-500/10 border-yellow-500/30"
|
||||
: "bg-cyan-500/10 border-cyan-500/30"
|
||||
}`}
|
||||
>
|
||||
{isUser ? (
|
||||
<User className="w-3.5 h-3.5 text-primary" />
|
||||
) : isSystem ? (
|
||||
<Zap className="w-3.5 h-3.5 text-yellow-500" />
|
||||
) : (
|
||||
<Bot className="w-3.5 h-3.5 text-cyan-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={`flex-1 min-w-0 ${isUser ? "items-end" : "items-start"} flex flex-col gap-1`}>
|
||||
{/* Meta */}
|
||||
<div className={`flex items-center gap-2 ${isUser ? "flex-row-reverse" : ""}`}>
|
||||
<span className="text-[10px] font-mono text-muted-foreground">{msg.timestamp}</span>
|
||||
{msg.model && (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1.5 font-mono border-primary/30 text-primary">
|
||||
{msg.model}
|
||||
</Badge>
|
||||
)}
|
||||
{msg.usage && (
|
||||
<span className="text-[9px] font-mono text-muted-foreground/60">
|
||||
{msg.usage.total_tokens} tok
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tool calls */}
|
||||
{msg.toolCalls && msg.toolCalls.length > 0 && (
|
||||
<div className="w-full space-y-1 mb-1">
|
||||
<p className="text-[10px] font-mono text-muted-foreground flex items-center gap-1">
|
||||
<Wrench className="w-3 h-3" />
|
||||
{msg.toolCalls.length} tool call{msg.toolCalls.length > 1 ? "s" : ""}
|
||||
</p>
|
||||
{msg.toolCalls.map((step, i) => (
|
||||
<ToolCallCard key={i} step={step} index={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message text */}
|
||||
{msg.content && (
|
||||
<div
|
||||
className={`rounded-lg px-3 py-2 max-w-[85%] ${
|
||||
isUser
|
||||
? "bg-primary/15 border border-primary/20 text-foreground"
|
||||
: isSystem
|
||||
? "bg-yellow-500/10 border border-yellow-500/20 text-yellow-200"
|
||||
: msg.isError
|
||||
? "bg-red-500/10 border border-red-500/20 text-red-300"
|
||||
: "bg-secondary/50 border border-border/40 text-foreground"
|
||||
}`}
|
||||
>
|
||||
<pre className="font-mono text-xs whitespace-pre-wrap break-words">{msg.content}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Chat Component ──────────────────────────────────────────────────────
|
||||
|
||||
export default function Chat() {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([
|
||||
{
|
||||
id: "boot-1",
|
||||
id: "welcome",
|
||||
role: "system",
|
||||
content: "GoClaw Gateway v0.1.0 initialized. Connecting to Ollama Cloud API...",
|
||||
timestamp: getTs(),
|
||||
type: "info",
|
||||
content:
|
||||
"GoClaw Orchestrator ready.\nI have access to all agents, tools, and skills.\nType a command or ask anything.",
|
||||
timestamp: new Date().toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }),
|
||||
},
|
||||
]);
|
||||
const [conversationHistory, setConversationHistory] = useState<
|
||||
Array<{ role: "user" | "assistant" | "system"; content: string }>
|
||||
>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||
const [isThinking, setIsThinking] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Real data from Ollama API
|
||||
const modelsQuery = trpc.ollama.models.useQuery();
|
||||
const healthQuery = trpc.ollama.health.useQuery();
|
||||
const chatMutation = trpc.ollama.chat.useMutation();
|
||||
|
||||
// Conversation history for context
|
||||
const [conversationHistory, setConversationHistory] = useState<
|
||||
{ role: "system" | "user" | "assistant"; content: string }[]
|
||||
>([{ role: "system", content: SYSTEM_PROMPT }]);
|
||||
|
||||
// Auto-select first model when loaded
|
||||
useEffect(() => {
|
||||
if (modelsQuery.data?.success && modelsQuery.data.models.length > 0 && !selectedModel) {
|
||||
setSelectedModel(modelsQuery.data.models[0].id);
|
||||
}
|
||||
}, [modelsQuery.data, selectedModel]);
|
||||
|
||||
// Boot messages
|
||||
useEffect(() => {
|
||||
if (healthQuery.data) {
|
||||
const ts = getTs();
|
||||
if (healthQuery.data.connected) {
|
||||
setMessages((prev) => {
|
||||
if (prev.some((m) => m.id === "boot-health")) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: "boot-health",
|
||||
role: "system",
|
||||
content: `Ollama API connected (${healthQuery.data.latencyMs}ms latency). Ready for commands.`,
|
||||
timestamp: ts,
|
||||
type: "success",
|
||||
},
|
||||
];
|
||||
});
|
||||
} else {
|
||||
setMessages((prev) => {
|
||||
if (prev.some((m) => m.id === "boot-health")) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: "boot-health",
|
||||
role: "system",
|
||||
content: `Ollama API connection failed: ${healthQuery.data.error}`,
|
||||
timestamp: ts,
|
||||
type: "error",
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [healthQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (modelsQuery.data?.success && modelsQuery.data.models.length > 0) {
|
||||
const ts = getTs();
|
||||
setMessages((prev) => {
|
||||
if (prev.some((m) => m.id === "boot-models")) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: "boot-models",
|
||||
role: "system",
|
||||
content: `${modelsQuery.data.models.length} models available. Active model: ${modelsQuery.data.models[0].id}`,
|
||||
timestamp: ts,
|
||||
type: "info",
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
}, [modelsQuery.data]);
|
||||
const agentsQuery = trpc.agents.list.useQuery(undefined, { refetchInterval: 30000 });
|
||||
const orchestratorMutation = trpc.orchestrator.chat.useMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, isThinking]);
|
||||
}, [messages]);
|
||||
|
||||
const getTs = () =>
|
||||
new Date().toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!input.trim() || isThinking) return;
|
||||
if (!selectedModel) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `err-${Date.now()}`,
|
||||
role: "system",
|
||||
content: "No model selected. Please select a model from the dropdown above.",
|
||||
timestamp: getTs(),
|
||||
type: "error",
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const userContent = input.trim();
|
||||
const ts = getTs();
|
||||
|
||||
// Add user message
|
||||
const userMsg: ChatMessage = {
|
||||
id: `user-${Date.now()}`,
|
||||
role: "user",
|
||||
content: input,
|
||||
content: userContent,
|
||||
timestamp: ts,
|
||||
type: "command",
|
||||
};
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
|
||||
const newHistory = [...conversationHistory, { role: "user" as const, content: input }];
|
||||
const newHistory = [
|
||||
...conversationHistory,
|
||||
{ role: "user" as const, content: userContent },
|
||||
];
|
||||
setConversationHistory(newHistory);
|
||||
setInput("");
|
||||
setIsThinking(true);
|
||||
|
||||
// Add thinking indicator
|
||||
const thinkingId = `thinking-${Date.now()}`;
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: thinkingId,
|
||||
role: "system" as const,
|
||||
content: "Orchestrator is processing...",
|
||||
timestamp: getTs(),
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
const result = await chatMutation.mutateAsync({
|
||||
model: selectedModel,
|
||||
const result = await orchestratorMutation.mutateAsync({
|
||||
messages: newHistory,
|
||||
temperature: 0.7,
|
||||
max_tokens: 2048,
|
||||
model: "qwen2.5:7b",
|
||||
maxIterations: 10,
|
||||
});
|
||||
|
||||
// Remove thinking indicator
|
||||
setMessages((prev) => prev.filter((m) => m.id !== thinkingId));
|
||||
|
||||
const respTs = getTs();
|
||||
|
||||
if (result.success) {
|
||||
const assistantContent = result.response || "(empty response)";
|
||||
// Update conversation history
|
||||
setConversationHistory((prev) => [
|
||||
...prev,
|
||||
{ role: "assistant", content: assistantContent },
|
||||
{ role: "assistant" as const, content: result.response },
|
||||
]);
|
||||
|
||||
// Add assistant message with tool calls
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `resp-${Date.now()}`,
|
||||
role: "agent",
|
||||
content: assistantContent,
|
||||
role: "assistant" as const,
|
||||
content: result.response,
|
||||
timestamp: respTs,
|
||||
type: "info",
|
||||
agent: result.model || selectedModel,
|
||||
toolCalls: result.toolCalls,
|
||||
model: result.model,
|
||||
usage: result.usage,
|
||||
},
|
||||
]);
|
||||
if (result.usage) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `usage-${Date.now()}`,
|
||||
role: "system",
|
||||
content: `tokens: ${result.usage!.prompt_tokens} prompt + ${result.usage!.completion_tokens} completion = ${result.usage!.total_tokens} total`,
|
||||
timestamp: respTs,
|
||||
type: "info",
|
||||
},
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `err-${Date.now()}`,
|
||||
role: "system",
|
||||
content: `LLM Error: ${result.error}`,
|
||||
role: "assistant" as const,
|
||||
content: `Error: ${result.error || "Unknown error"}`,
|
||||
timestamp: respTs,
|
||||
type: "error",
|
||||
isError: true,
|
||||
},
|
||||
]);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setMessages((prev) => prev.filter((m) => m.id !== thinkingId));
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `err-${Date.now()}`,
|
||||
role: "system",
|
||||
role: "assistant" as const,
|
||||
content: `Network Error: ${err.message}`,
|
||||
timestamp: getTs(),
|
||||
type: "error",
|
||||
isError: true,
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setIsThinking(false);
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
}
|
||||
};
|
||||
|
||||
const models = modelsQuery.data?.success ? modelsQuery.data.models : [];
|
||||
const agents = agentsQuery.data ?? [];
|
||||
const activeAgents = agents.filter((a) => a.isActive);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-4">
|
||||
<div className="h-full flex flex-col gap-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-md bg-primary/15 border border-primary/30 flex items-center justify-center glow-cyan">
|
||||
<Terminal className="w-4 h-4 text-primary" />
|
||||
<div className="w-8 h-8 rounded-md bg-cyan-500/15 border border-cyan-500/30 flex items-center justify-center">
|
||||
<Bot className="w-4 h-4 text-cyan-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-foreground">Gateway Terminal</h2>
|
||||
<h2 className="text-lg font-bold text-foreground">GoClaw Orchestrator</h2>
|
||||
<p className="text-[11px] font-mono text-muted-foreground">
|
||||
Connected to <span className="text-primary">Ollama Cloud API</span>
|
||||
{healthQuery.data?.connected && (
|
||||
<span className="text-neon-green ml-2">({healthQuery.data.latencyMs}ms)</span>
|
||||
)}
|
||||
Main AI · {activeAgents.length} agents · {ORCHESTRATOR_TOOLS_COUNT} tools
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Model selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Brain className="w-3.5 h-3.5 text-primary" />
|
||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||
<SelectTrigger className="w-56 h-8 bg-secondary/50 border-border/50 font-mono text-xs">
|
||||
<SelectValue placeholder={modelsQuery.isLoading ? "Loading models..." : "Select model"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-popover border-border/50">
|
||||
{models.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id} className="font-mono text-xs">
|
||||
{m.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${healthQuery.data?.connected ? "bg-neon-green pulse-indicator" : "bg-neon-red"}`} />
|
||||
<span className={`text-[11px] font-mono ${healthQuery.data?.connected ? "text-neon-green" : "text-neon-red"}`}>
|
||||
{healthQuery.data?.connected ? "LIVE" : "OFFLINE"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Active agents badges */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap justify-end">
|
||||
{activeAgents.slice(0, 4).map((agent) => (
|
||||
<Badge
|
||||
key={agent.id}
|
||||
variant="outline"
|
||||
className="text-[9px] h-5 px-1.5 font-mono border-border/50 text-muted-foreground"
|
||||
>
|
||||
{agent.role === "browser" && <Globe className="w-2.5 h-2.5 mr-1 text-cyan-400" />}
|
||||
{agent.role === "tool_builder" && <Wrench className="w-2.5 h-2.5 mr-1 text-orange-400" />}
|
||||
{agent.role === "agent_compiler" && <Cpu className="w-2.5 h-2.5 mr-1 text-purple-400" />}
|
||||
{agent.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat area */}
|
||||
<Card className="flex-1 bg-card border-border/50 overflow-hidden relative scanline">
|
||||
<Card className="flex-1 bg-card border-border/50 overflow-hidden">
|
||||
<CardContent className="p-0 h-full flex flex-col">
|
||||
<div ref={scrollRef} className="flex-1 overflow-auto p-4 space-y-3">
|
||||
<AnimatePresence initial={false}>
|
||||
{messages.map((msg) => (
|
||||
<ScrollArea className="flex-1">
|
||||
<div ref={scrollRef} className="p-4 space-y-4">
|
||||
<AnimatePresence initial={false}>
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} msg={msg} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Thinking indicator */}
|
||||
{isThinking && (
|
||||
<motion.div
|
||||
key={msg.id}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex items-start gap-2"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex items-center gap-2 text-cyan-400 font-mono text-xs pl-10"
|
||||
>
|
||||
<div className={`mt-0.5 shrink-0 ${getMessageColor(msg)}`}>
|
||||
{getMessageIcon(msg)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="text-[10px] font-mono text-muted-foreground">{msg.timestamp}</span>
|
||||
{msg.agent && (
|
||||
<span className="text-[10px] font-mono text-primary">[{msg.agent}]</span>
|
||||
)}
|
||||
{msg.role === "user" && (
|
||||
<span className="text-[10px] font-mono text-neon-amber">[you]</span>
|
||||
)}
|
||||
</div>
|
||||
<pre className={`font-mono text-xs whitespace-pre-wrap break-words ${getMessageColor(msg)}`}>
|
||||
{msg.content}
|
||||
</pre>
|
||||
</div>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
<span className="text-muted-foreground">Orchestrator thinking...</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Thinking indicator */}
|
||||
{isThinking && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex items-center gap-2 text-primary font-mono text-xs"
|
||||
>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
<span className="text-muted-foreground">
|
||||
{selectedModel} is thinking...
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Terminal cursor */}
|
||||
{!isThinking && (
|
||||
<div className="flex items-center gap-1 text-primary font-mono text-xs">
|
||||
<span className="text-muted-foreground">$</span>
|
||||
<span className="w-2 h-4 bg-primary terminal-cursor" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input area */}
|
||||
<div className="border-t border-border/50 p-3 bg-secondary/20">
|
||||
<div className="border-t border-border/50 p-3 bg-secondary/10 shrink-0">
|
||||
{/* Quick commands */}
|
||||
<div className="flex items-center gap-1.5 mb-2 flex-wrap">
|
||||
{[
|
||||
"Список агентов",
|
||||
"Покажи файлы проекта",
|
||||
"Статус Docker",
|
||||
"Создай инструмент",
|
||||
"Скомпилируй агента",
|
||||
].map((cmd) => (
|
||||
<button
|
||||
key={cmd}
|
||||
onClick={() => setInput(cmd)}
|
||||
className="text-[10px] font-mono px-2 py-0.5 rounded border border-border/40 text-muted-foreground hover:text-foreground hover:border-primary/40 transition-colors bg-secondary/20"
|
||||
>
|
||||
{cmd}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-primary font-mono text-sm shrink-0">$</span>
|
||||
<span className="text-cyan-400 font-mono text-sm shrink-0">$</span>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && sendMessage()}
|
||||
placeholder={isThinking ? "Ожидание ответа..." : "Введите сообщение для оркестратора..."}
|
||||
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && sendMessage()}
|
||||
placeholder={isThinking ? "Ожидание ответа оркестратора..." : "Введите команду или вопрос..."}
|
||||
disabled={isThinking}
|
||||
className="bg-transparent border-none text-foreground font-mono text-sm placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0 h-8"
|
||||
/>
|
||||
@@ -360,7 +460,7 @@ export default function Chat() {
|
||||
size="sm"
|
||||
onClick={sendMessage}
|
||||
disabled={isThinking || !input.trim()}
|
||||
className="bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25 h-8 w-8 p-0"
|
||||
className="bg-cyan-500/15 text-cyan-400 border border-cyan-500/30 hover:bg-cyan-500/25 h-8 w-8 p-0 shrink-0"
|
||||
>
|
||||
{isThinking ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
@@ -375,3 +475,6 @@ export default function Chat() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Count of orchestrator tools (used in header)
|
||||
const ORCHESTRATOR_TOOLS_COUNT = 10;
|
||||
|
||||
526
client/src/pages/Skills.tsx
Normal file
526
client/src/pages/Skills.tsx
Normal file
@@ -0,0 +1,526 @@
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Zap,
|
||||
Plus,
|
||||
Trash2,
|
||||
Play,
|
||||
Globe,
|
||||
Code,
|
||||
Database,
|
||||
Brain,
|
||||
Settings,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
Package,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// ─── Built-in skills (always available) ──────────────────────────────────────
|
||||
|
||||
const BUILTIN_SKILLS = [
|
||||
{
|
||||
name: "web_search",
|
||||
description: "Search the web for information using HTTP requests",
|
||||
category: "web",
|
||||
version: "1.0.0",
|
||||
builtin: true,
|
||||
tags: ["search", "web", "research"],
|
||||
},
|
||||
{
|
||||
name: "text_extraction",
|
||||
description: "Extract clean text content from HTML pages",
|
||||
category: "web",
|
||||
version: "1.0.0",
|
||||
builtin: true,
|
||||
tags: ["html", "parsing", "text"],
|
||||
},
|
||||
{
|
||||
name: "json_transform",
|
||||
description: "Transform and filter JSON data structures",
|
||||
category: "data",
|
||||
version: "1.0.0",
|
||||
builtin: true,
|
||||
tags: ["json", "transform", "data"],
|
||||
},
|
||||
{
|
||||
name: "shell_runner",
|
||||
description: "Execute shell commands with safety checks",
|
||||
category: "system",
|
||||
version: "1.0.0",
|
||||
builtin: true,
|
||||
tags: ["shell", "system", "exec"],
|
||||
},
|
||||
{
|
||||
name: "file_manager",
|
||||
description: "Read, write, and manage files on the filesystem",
|
||||
category: "system",
|
||||
version: "1.0.0",
|
||||
builtin: true,
|
||||
tags: ["files", "filesystem", "io"],
|
||||
},
|
||||
{
|
||||
name: "docker_control",
|
||||
description: "Manage Docker containers and services",
|
||||
category: "system",
|
||||
version: "1.0.0",
|
||||
builtin: true,
|
||||
tags: ["docker", "containers", "devops"],
|
||||
},
|
||||
{
|
||||
name: "llm_invoke",
|
||||
description: "Call LLM models for text generation and analysis",
|
||||
category: "ai",
|
||||
version: "1.0.0",
|
||||
builtin: true,
|
||||
tags: ["llm", "ai", "generation"],
|
||||
},
|
||||
{
|
||||
name: "agent_delegate",
|
||||
description: "Delegate tasks to specialized agents",
|
||||
category: "ai",
|
||||
version: "1.0.0",
|
||||
builtin: true,
|
||||
tags: ["agents", "delegation", "orchestration"],
|
||||
},
|
||||
{
|
||||
name: "http_client",
|
||||
description: "Make HTTP requests to external APIs and services",
|
||||
category: "web",
|
||||
version: "1.0.0",
|
||||
builtin: true,
|
||||
tags: ["http", "api", "requests"],
|
||||
},
|
||||
{
|
||||
name: "code_executor",
|
||||
description: "Execute JavaScript/TypeScript code snippets safely",
|
||||
category: "system",
|
||||
version: "1.0.0",
|
||||
builtin: true,
|
||||
tags: ["code", "execution", "javascript"],
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Category config ──────────────────────────────────────────────────────────
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: "all", label: "All", icon: <Package className="w-3.5 h-3.5" /> },
|
||||
{ value: "web", label: "Web", icon: <Globe className="w-3.5 h-3.5" /> },
|
||||
{ value: "ai", label: "AI", icon: <Brain className="w-3.5 h-3.5" /> },
|
||||
{ value: "system", label: "System", icon: <Settings className="w-3.5 h-3.5" /> },
|
||||
{ value: "data", label: "Data", icon: <Database className="w-3.5 h-3.5" /> },
|
||||
{ value: "custom", label: "Custom", icon: <Code className="w-3.5 h-3.5" /> },
|
||||
];
|
||||
|
||||
function categoryColor(cat: string) {
|
||||
const colors: Record<string, string> = {
|
||||
web: "text-cyan-400 border-cyan-500/30 bg-cyan-500/10",
|
||||
ai: "text-purple-400 border-purple-500/30 bg-purple-500/10",
|
||||
system: "text-orange-400 border-orange-500/30 bg-orange-500/10",
|
||||
data: "text-green-400 border-green-500/30 bg-green-500/10",
|
||||
custom: "text-pink-400 border-pink-500/30 bg-pink-500/10",
|
||||
};
|
||||
return colors[cat] ?? "text-muted-foreground border-border/50 bg-secondary/30";
|
||||
}
|
||||
|
||||
// ─── Install Skill Dialog ─────────────────────────────────────────────────────
|
||||
|
||||
function InstallSkillDialog({ onInstalled }: { onInstalled: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [category, setCategory] = useState("custom");
|
||||
const [code, setCode] = useState(
|
||||
`// Skill implementation
|
||||
// Available: fetch, JSON, console
|
||||
// Must export: async function execute(params) { ... }
|
||||
|
||||
async function execute(params) {
|
||||
// Your implementation here
|
||||
return { success: true, result: "Hello from skill!" };
|
||||
}
|
||||
|
||||
module.exports = { execute };`
|
||||
);
|
||||
|
||||
const installMutation = trpc.orchestrator.chat.useMutation();
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!name.trim() || !description.trim() || !code.trim()) {
|
||||
toast.error("Fill all fields");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await installMutation.mutateAsync({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: `Install a new skill with these details:
|
||||
Name: ${name}
|
||||
Description: ${description}
|
||||
Category: ${category}
|
||||
Code:
|
||||
\`\`\`javascript
|
||||
${code}
|
||||
\`\`\`
|
||||
|
||||
Use the install_skill tool to install it.`,
|
||||
},
|
||||
],
|
||||
maxIterations: 3,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(`Skill "${name}" installed successfully`);
|
||||
setOpen(false);
|
||||
onInstalled();
|
||||
setName("");
|
||||
setDescription("");
|
||||
setCode("");
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(`Install failed: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" className="bg-primary/15 border border-primary/30 text-primary hover:bg-primary/25 h-8">
|
||||
<Plus className="w-3.5 h-3.5 mr-1.5" />
|
||||
Install Skill
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl bg-card border-border/50">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-mono text-foreground flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-primary" />
|
||||
Install New Skill
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-mono text-muted-foreground">Name (snake_case)</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value.replace(/\s+/g, "_").toLowerCase())}
|
||||
placeholder="my_skill"
|
||||
className="font-mono text-sm h-8 bg-secondary/30 border-border/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-mono text-muted-foreground">Category</Label>
|
||||
<Select value={category} onValueChange={setCategory}>
|
||||
<SelectTrigger className="h-8 bg-secondary/30 border-border/50 font-mono text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATEGORIES.filter((c) => c.value !== "all").map((c) => (
|
||||
<SelectItem key={c.value} value={c.value} className="font-mono text-xs">
|
||||
{c.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-mono text-muted-foreground">Description</Label>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What does this skill do?"
|
||||
className="font-mono text-sm h-8 bg-secondary/30 border-border/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-mono text-muted-foreground">Implementation (JavaScript)</Label>
|
||||
<Textarea
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
className="font-mono text-xs bg-secondary/30 border-border/50 min-h-[200px] resize-y"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setOpen(false)}
|
||||
className="h-8 font-mono text-xs"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleInstall}
|
||||
disabled={installMutation.isPending}
|
||||
className="h-8 font-mono text-xs bg-primary/15 border border-primary/30 text-primary hover:bg-primary/25"
|
||||
>
|
||||
{installMutation.isPending ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin mr-1.5" />
|
||||
) : (
|
||||
<Zap className="w-3.5 h-3.5 mr-1.5" />
|
||||
)}
|
||||
Install
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Skill Card ───────────────────────────────────────────────────────────────
|
||||
|
||||
function SkillCard({
|
||||
skill,
|
||||
onTest,
|
||||
}: {
|
||||
skill: (typeof BUILTIN_SKILLS)[0] & { builtin?: boolean; installedAt?: string };
|
||||
onTest: (name: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="border border-border/40 rounded-lg p-3 bg-card hover:border-primary/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-6 h-6 rounded bg-primary/10 border border-primary/20 flex items-center justify-center shrink-0">
|
||||
<Zap className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-mono font-semibold text-foreground truncate">{skill.name}</p>
|
||||
<p className="text-[10px] font-mono text-muted-foreground">v{skill.version}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{skill.builtin && (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1.5 font-mono border-green-500/30 text-green-400">
|
||||
built-in
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[9px] h-4 px-1.5 font-mono ${categoryColor(skill.category)}`}
|
||||
>
|
||||
{skill.category}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-muted-foreground mb-2 line-clamp-2">{skill.description}</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(skill.tags || []).slice(0, 3).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-[9px] font-mono px-1.5 py-0.5 rounded bg-secondary/50 text-muted-foreground border border-border/30"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onTest(skill.name)}
|
||||
className="h-6 px-2 text-[10px] font-mono text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<Play className="w-2.5 h-2.5 mr-1" />
|
||||
Test
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Skills Page ─────────────────────────────────────────────────────────
|
||||
|
||||
export default function Skills() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [activeCategory, setActiveCategory] = useState("all");
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
// Query installed skills via orchestrator
|
||||
const skillsQuery = trpc.orchestrator.chat.useMutation();
|
||||
const [installedSkills, setInstalledSkills] = useState<any[]>([]);
|
||||
|
||||
const loadInstalledSkills = async () => {
|
||||
try {
|
||||
const result = await skillsQuery.mutateAsync({
|
||||
messages: [{ role: "user", content: "List all installed skills using list_skills tool" }],
|
||||
maxIterations: 2,
|
||||
});
|
||||
if (result.success && result.toolCalls?.length > 0) {
|
||||
const listCall = result.toolCalls.find((tc: { tool: string; result: any }) => tc.tool === "list_skills");
|
||||
if (listCall?.result?.skills) {
|
||||
setInstalledSkills(listCall.result.skills);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
// Combine builtin + installed
|
||||
const allSkills = [
|
||||
...BUILTIN_SKILLS,
|
||||
...installedSkills.map((s) => ({
|
||||
...s,
|
||||
builtin: false,
|
||||
tags: s.tags || [],
|
||||
version: s.version || "1.0.0",
|
||||
})),
|
||||
];
|
||||
|
||||
// Filter
|
||||
const filtered = allSkills.filter((s) => {
|
||||
const matchSearch =
|
||||
!search ||
|
||||
s.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.description.toLowerCase().includes(search.toLowerCase());
|
||||
const matchCat = activeCategory === "all" || s.category === activeCategory;
|
||||
return matchSearch && matchCat;
|
||||
});
|
||||
|
||||
const handleTest = (name: string) => {
|
||||
toast(`Testing skill: ${name} — Feature coming soon`);
|
||||
};
|
||||
|
||||
const categoryCounts = CATEGORIES.map((c) => ({
|
||||
...c,
|
||||
count:
|
||||
c.value === "all"
|
||||
? allSkills.length
|
||||
: allSkills.filter((s) => s.category === c.value).length,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-md bg-primary/15 border border-primary/30 flex items-center justify-center">
|
||||
<Zap className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-foreground">Skills Registry</h2>
|
||||
<p className="text-[11px] font-mono text-muted-foreground">
|
||||
{allSkills.length} skills · {BUILTIN_SKILLS.length} built-in · {installedSkills.length} custom
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadInstalledSkills}
|
||||
disabled={skillsQuery.isPending}
|
||||
className="h-8 font-mono text-xs border-border/50"
|
||||
>
|
||||
{skillsQuery.isPending ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span className="ml-1.5">Scan</span>
|
||||
</Button>
|
||||
<InstallSkillDialog onInstalled={() => { setRefreshKey((k) => k + 1); loadInstalledSkills(); }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search + filters */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search skills..."
|
||||
className="pl-8 h-8 font-mono text-xs bg-secondary/30 border-border/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{categoryCounts.map((c) => (
|
||||
<button
|
||||
key={c.value}
|
||||
onClick={() => setActiveCategory(c.value)}
|
||||
className={`flex items-center gap-1 px-2.5 py-1 rounded-md text-[11px] font-mono transition-colors ${
|
||||
activeCategory === c.value
|
||||
? "bg-primary/15 border border-primary/30 text-primary"
|
||||
: "border border-border/40 text-muted-foreground hover:text-foreground hover:border-border/70"
|
||||
}`}
|
||||
>
|
||||
{c.icon}
|
||||
{c.label}
|
||||
<span className="text-[9px] opacity-60">({c.count})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-4 gap-3 shrink-0">
|
||||
{[
|
||||
{ label: "Total Skills", value: allSkills.length, color: "text-primary" },
|
||||
{ label: "Built-in", value: BUILTIN_SKILLS.length, color: "text-green-400" },
|
||||
{ label: "Custom", value: installedSkills.length, color: "text-orange-400" },
|
||||
{ label: "Categories", value: CATEGORIES.length - 1, color: "text-purple-400" },
|
||||
].map((stat) => (
|
||||
<Card key={stat.label} className="bg-card border-border/40">
|
||||
<CardContent className="p-3">
|
||||
<p className="text-[10px] font-mono text-muted-foreground">{stat.label}</p>
|
||||
<p className={`text-2xl font-bold font-mono ${stat.color}`}>{stat.value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Skills grid */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground">
|
||||
<Zap className="w-8 h-8 mb-3 opacity-30" />
|
||||
<p className="font-mono text-sm">No skills found</p>
|
||||
<p className="font-mono text-xs opacity-60 mt-1">Try a different search or category</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<AnimatePresence>
|
||||
{filtered.map((skill) => (
|
||||
<SkillCard key={skill.name} skill={skill} onTest={handleTest} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user