true message

This commit is contained in:
Manus
2026-03-20 17:34:20 -04:00
parent 86a1ee9062
commit c2fdfdbf72
21 changed files with 4587 additions and 254 deletions

View File

@@ -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} />

View File

@@ -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: "Настройки" },

View File

@@ -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
View 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>
);
}