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

View File

@@ -0,0 +1,31 @@
CREATE TABLE `browserSessions` (
`id` int AUTO_INCREMENT NOT NULL,
`sessionId` varchar(64) NOT NULL,
`agentId` int NOT NULL,
`currentUrl` text,
`title` text,
`status` enum('active','idle','closed','error') DEFAULT 'idle',
`screenshotUrl` text,
`lastActionAt` timestamp DEFAULT (now()),
`createdAt` timestamp NOT NULL DEFAULT (now()),
`closedAt` timestamp,
CONSTRAINT `browserSessions_id` PRIMARY KEY(`id`),
CONSTRAINT `browserSessions_sessionId_unique` UNIQUE(`sessionId`)
);
--> statement-breakpoint
CREATE TABLE `toolDefinitions` (
`id` int AUTO_INCREMENT NOT NULL,
`toolId` varchar(100) NOT NULL,
`name` varchar(255) NOT NULL,
`description` text NOT NULL,
`category` varchar(50) NOT NULL DEFAULT 'custom',
`dangerous` boolean DEFAULT false,
`parameters` json,
`implementation` text NOT NULL,
`isActive` boolean DEFAULT true,
`createdBy` int,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `toolDefinitions_id` PRIMARY KEY(`id`),
CONSTRAINT `toolDefinitions_toolId_unique` UNIQUE(`toolId`)
);

View File

@@ -0,0 +1,858 @@
{
"version": "5",
"dialect": "mysql",
"id": "c2d59f1f-0ab6-4daf-80f8-651cb95a4778",
"prevId": "81e64c5e-427c-49d1-bc11-a25918d54e4b",
"tables": {
"agentAccessControl": {
"name": "agentAccessControl",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"agentId": {
"name": "agentId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tool": {
"name": "tool",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"isAllowed": {
"name": "isAllowed",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": true
},
"maxExecutionsPerHour": {
"name": "maxExecutionsPerHour",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 100
},
"timeoutSeconds": {
"name": "timeoutSeconds",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 30
},
"allowedPatterns": {
"name": "allowedPatterns",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "('[]')"
},
"blockedPatterns": {
"name": "blockedPatterns",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "('[]')"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {
"agentAccessControl_agentId_tool_idx": {
"name": "agentAccessControl_agentId_tool_idx",
"columns": [
"agentId",
"tool"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"agentAccessControl_id": {
"name": "agentAccessControl_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"agentHistory": {
"name": "agentHistory",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"agentId": {
"name": "agentId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userMessage": {
"name": "userMessage",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"agentResponse": {
"name": "agentResponse",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"conversationId": {
"name": "conversationId",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"messageIndex": {
"name": "messageIndex",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "enum('pending','success','error')",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'pending'"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {
"agentHistory_agentId_idx": {
"name": "agentHistory_agentId_idx",
"columns": [
"agentId"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"agentHistory_id": {
"name": "agentHistory_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"agentMetrics": {
"name": "agentMetrics",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"agentId": {
"name": "agentId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"requestId": {
"name": "requestId",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userMessage": {
"name": "userMessage",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"agentResponse": {
"name": "agentResponse",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"inputTokens": {
"name": "inputTokens",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"outputTokens": {
"name": "outputTokens",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"totalTokens": {
"name": "totalTokens",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"processingTimeMs": {
"name": "processingTimeMs",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "enum('success','error','timeout','rate_limited')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"errorMessage": {
"name": "errorMessage",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"toolsCalled": {
"name": "toolsCalled",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "('[]')"
},
"model": {
"name": "model",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"temperature": {
"name": "temperature",
"type": "decimal(3,2)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {
"agentMetrics_agentId_idx": {
"name": "agentMetrics_agentId_idx",
"columns": [
"agentId"
],
"isUnique": false
},
"agentMetrics_createdAt_idx": {
"name": "agentMetrics_createdAt_idx",
"columns": [
"createdAt"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"agentMetrics_id": {
"name": "agentMetrics_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"agentMetrics_requestId_unique": {
"name": "agentMetrics_requestId_unique",
"columns": [
"requestId"
]
}
},
"checkConstraint": {}
},
"agents": {
"name": "agents",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"userId": {
"name": "userId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"model": {
"name": "model",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"temperature": {
"name": "temperature",
"type": "decimal(3,2)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'0.7'"
},
"maxTokens": {
"name": "maxTokens",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 2048
},
"topP": {
"name": "topP",
"type": "decimal(3,2)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'1.0'"
},
"frequencyPenalty": {
"name": "frequencyPenalty",
"type": "decimal(3,2)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'0.0'"
},
"presencePenalty": {
"name": "presencePenalty",
"type": "decimal(3,2)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'0.0'"
},
"systemPrompt": {
"name": "systemPrompt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"allowedTools": {
"name": "allowedTools",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "('[]')"
},
"allowedDomains": {
"name": "allowedDomains",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "('[]')"
},
"maxRequestsPerHour": {
"name": "maxRequestsPerHour",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 100
},
"isActive": {
"name": "isActive",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": true
},
"isPublic": {
"name": "isPublic",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"tags": {
"name": "tags",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "('[]')"
},
"metadata": {
"name": "metadata",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "('{}')"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {
"agents_userId_idx": {
"name": "agents_userId_idx",
"columns": [
"userId"
],
"isUnique": false
},
"agents_model_idx": {
"name": "agents_model_idx",
"columns": [
"model"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"agents_id": {
"name": "agents_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"browserSessions": {
"name": "browserSessions",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"sessionId": {
"name": "sessionId",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"agentId": {
"name": "agentId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"currentUrl": {
"name": "currentUrl",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "enum('active','idle','closed','error')",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'idle'"
},
"screenshotUrl": {
"name": "screenshotUrl",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lastActionAt": {
"name": "lastActionAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(now())"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"closedAt": {
"name": "closedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"browserSessions_id": {
"name": "browserSessions_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"browserSessions_sessionId_unique": {
"name": "browserSessions_sessionId_unique",
"columns": [
"sessionId"
]
}
},
"checkConstraint": {}
},
"toolDefinitions": {
"name": "toolDefinitions",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"toolId": {
"name": "toolId",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'custom'"
},
"dangerous": {
"name": "dangerous",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"parameters": {
"name": "parameters",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"implementation": {
"name": "implementation",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"isActive": {
"name": "isActive",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": true
},
"createdBy": {
"name": "createdBy",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"toolDefinitions_id": {
"name": "toolDefinitions_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"toolDefinitions_toolId_unique": {
"name": "toolDefinitions_toolId_unique",
"columns": [
"toolId"
]
}
},
"checkConstraint": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"openId": {
"name": "openId",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(320)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"loginMethod": {
"name": "loginMethod",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "enum('user','admin')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
},
"lastSignedIn": {
"name": "lastSignedIn",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"users_id": {
"name": "users_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"users_openId_unique": {
"name": "users_openId_unique",
"columns": [
"openId"
]
}
},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}

View File

@@ -15,6 +15,13 @@
"when": 1774038103243,
"tag": "0001_secret_guardian",
"breakpoints": true
},
{
"idx": 2,
"version": "5",
"when": 1774042457262,
"tag": "0002_tricky_saracen",
"breakpoints": true
}
]
}

View File

@@ -159,4 +159,43 @@ export const agentAccessControl = mysqlTable("agentAccessControl", {
}));
export type AgentAccessControl = typeof agentAccessControl.$inferSelect;
export type InsertAgentAccessControl = typeof agentAccessControl.$inferInsert;
export type InsertAgentAccessControl = typeof agentAccessControl.$inferInsert;
/**
* Tool Definitions — пользовательские инструменты, созданные Tool Builder Agent
*/
export const toolDefinitions = mysqlTable("toolDefinitions", {
id: int("id").autoincrement().primaryKey(),
toolId: varchar("toolId", { length: 100 }).notNull().unique(),
name: varchar("name", { length: 255 }).notNull(),
description: text("description").notNull(),
category: varchar("category", { length: 50 }).notNull().default("custom"),
dangerous: boolean("dangerous").default(false),
parameters: json("parameters").$type<Record<string, { type: string; description: string; required?: boolean }>>(),
implementation: text("implementation").notNull(), // JS код функции
isActive: boolean("isActive").default(true),
createdBy: int("createdBy"), // agentId или null
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type ToolDefinition = typeof toolDefinitions.$inferSelect;
export type InsertToolDefinition = typeof toolDefinitions.$inferInsert;
/**
* Browser Sessions — активные сессии браузера для Browser Agent
*/
export const browserSessions = mysqlTable("browserSessions", {
id: int("id").autoincrement().primaryKey(),
sessionId: varchar("sessionId", { length: 64 }).notNull().unique(),
agentId: int("agentId").notNull(),
currentUrl: text("currentUrl"),
title: text("title"),
status: mysqlEnum("status", ["active", "idle", "closed", "error"]).default("idle"),
screenshotUrl: text("screenshotUrl"), // S3 URL последнего скриншота
lastActionAt: timestamp("lastActionAt").defaultNow(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
closedAt: timestamp("closedAt"),
});
export type BrowserSession = typeof browserSessions.$inferSelect;
export type InsertBrowserSession = typeof browserSessions.$inferInsert;

View File

@@ -42,6 +42,7 @@
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@sparticuz/chromium": "^143.0.4",
"@tanstack/react-query": "^5.90.2",
"@trpc/client": "^11.6.0",
"@trpc/react-query": "^11.6.0",
@@ -63,6 +64,7 @@
"mysql2": "^3.15.0",
"nanoid": "^5.1.5",
"next-themes": "^0.4.6",
"puppeteer-core": "^24.40.0",
"react": "^19.2.1",
"react-day-picker": "^9.11.1",
"react-dom": "^19.2.1",
@@ -109,7 +111,8 @@
"wouter@3.7.1": "patches/wouter@3.7.1.patch"
},
"overrides": {
"tailwindcss>nanoid": "3.3.7"
"tailwindcss>nanoid": "3.3.7",
"zod": "4.1.12"
}
}
}

664
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
import mysql from 'mysql2/promise';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
async function main() {
const conn = await mysql.createConnection(process.env.DATABASE_URL);
try {
// Read the migration SQL
const sql = readFileSync(join(__dirname, '../drizzle/0001_secret_guardian.sql'), 'utf8');
// Split by statement-breakpoint and execute each statement
const statements = sql
.split('--> statement-breakpoint')
.map(s => s.trim())
.filter(s => s.length > 0);
console.log(`Executing ${statements.length} statements...`);
for (const stmt of statements) {
try {
await conn.query(stmt);
console.log('✓', stmt.slice(0, 60).replace(/\n/g, ' '));
} catch (e) {
if (e.code === 'ER_TABLE_EXISTS_ERROR' || e.message.includes('already exists')) {
console.log('⚠ Already exists (skipping):', stmt.slice(0, 60).replace(/\n/g, ' '));
} else {
console.error('✗ Error:', e.message, '\nSQL:', stmt.slice(0, 100));
}
}
}
// Mark migration as applied
try {
await conn.query(
'INSERT IGNORE INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)',
['0001_secret_guardian_hash', Date.now()]
);
console.log('✓ Migration marked as applied');
} catch (e) {
console.log('Migration tracking:', e.message);
}
// Verify tables
const [tables] = await conn.query('SHOW TABLES');
console.log('\nAll tables:', tables.map(t => Object.values(t)[0]));
} finally {
await conn.end();
}
}
main().catch(console.error);

133
scripts/create-tables.mjs Normal file
View File

@@ -0,0 +1,133 @@
import mysql from 'mysql2/promise';
async function main() {
const conn = await mysql.createConnection(process.env.DATABASE_URL);
const statements = [
// agentAccessControl
`CREATE TABLE IF NOT EXISTS \`agentAccessControl\` (
\`id\` int AUTO_INCREMENT NOT NULL,
\`agentId\` int NOT NULL,
\`tool\` varchar(50) NOT NULL,
\`isAllowed\` boolean DEFAULT true,
\`maxExecutionsPerHour\` int DEFAULT 100,
\`timeoutSeconds\` int DEFAULT 30,
\`allowedPatterns\` json,
\`blockedPatterns\` json,
\`createdAt\` timestamp NOT NULL DEFAULT (now()),
\`updatedAt\` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT \`agentAccessControl_id\` PRIMARY KEY(\`id\`)
)`,
// agentMetrics
`CREATE TABLE IF NOT EXISTS \`agentMetrics\` (
\`id\` int AUTO_INCREMENT NOT NULL,
\`agentId\` int NOT NULL,
\`requestId\` varchar(64) NOT NULL,
\`userMessage\` text,
\`agentResponse\` text,
\`inputTokens\` int DEFAULT 0,
\`outputTokens\` int DEFAULT 0,
\`totalTokens\` int DEFAULT 0,
\`processingTimeMs\` int NOT NULL,
\`status\` enum('success','error','timeout','rate_limited') NOT NULL,
\`errorMessage\` text,
\`toolsCalled\` json,
\`model\` varchar(100),
\`temperature\` decimal(3,2),
\`createdAt\` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT \`agentMetrics_id\` PRIMARY KEY(\`id\`),
CONSTRAINT \`agentMetrics_requestId_unique\` UNIQUE(\`requestId\`)
)`,
// agents
`CREATE TABLE IF NOT EXISTS \`agents\` (
\`id\` int AUTO_INCREMENT NOT NULL,
\`userId\` int NOT NULL,
\`name\` varchar(255) NOT NULL,
\`description\` text,
\`role\` varchar(100) NOT NULL,
\`model\` varchar(100) NOT NULL,
\`provider\` varchar(50) NOT NULL,
\`temperature\` decimal(3,2) DEFAULT '0.7',
\`maxTokens\` int DEFAULT 2048,
\`topP\` decimal(3,2) DEFAULT '1.0',
\`frequencyPenalty\` decimal(3,2) DEFAULT '0.0',
\`presencePenalty\` decimal(3,2) DEFAULT '0.0',
\`systemPrompt\` text,
\`allowedTools\` json,
\`allowedDomains\` json,
\`maxRequestsPerHour\` int DEFAULT 100,
\`isActive\` boolean DEFAULT true,
\`isPublic\` boolean DEFAULT false,
\`tags\` json,
\`metadata\` json,
\`createdAt\` timestamp NOT NULL DEFAULT (now()),
\`updatedAt\` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT \`agents_id\` PRIMARY KEY(\`id\`)
)`,
// toolDefinitions
`CREATE TABLE IF NOT EXISTS \`toolDefinitions\` (
\`id\` int AUTO_INCREMENT NOT NULL,
\`toolId\` varchar(100) NOT NULL,
\`name\` varchar(255) NOT NULL,
\`description\` text NOT NULL,
\`category\` varchar(50) NOT NULL DEFAULT 'custom',
\`dangerous\` boolean DEFAULT false,
\`parameters\` json,
\`implementation\` text NOT NULL,
\`isActive\` boolean DEFAULT true,
\`createdBy\` int,
\`createdAt\` timestamp NOT NULL DEFAULT (now()),
\`updatedAt\` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT \`toolDefinitions_id\` PRIMARY KEY(\`id\`),
CONSTRAINT \`toolDefinitions_toolId_unique\` UNIQUE(\`toolId\`)
)`,
// browserSessions
`CREATE TABLE IF NOT EXISTS \`browserSessions\` (
\`id\` int AUTO_INCREMENT NOT NULL,
\`sessionId\` varchar(64) NOT NULL,
\`agentId\` int NOT NULL,
\`currentUrl\` text,
\`title\` text,
\`status\` enum('active','idle','closed','error') DEFAULT 'idle',
\`screenshotUrl\` text,
\`lastActionAt\` timestamp DEFAULT (now()),
\`createdAt\` timestamp NOT NULL DEFAULT (now()),
\`closedAt\` timestamp,
CONSTRAINT \`browserSessions_id\` PRIMARY KEY(\`id\`),
CONSTRAINT \`browserSessions_sessionId_unique\` UNIQUE(\`sessionId\`)
)`,
// Indexes
`CREATE INDEX IF NOT EXISTS \`agentAccessControl_agentId_tool_idx\` ON \`agentAccessControl\` (\`agentId\`,\`tool\`)`,
`CREATE INDEX IF NOT EXISTS \`agentMetrics_agentId_idx\` ON \`agentMetrics\` (\`agentId\`)`,
`CREATE INDEX IF NOT EXISTS \`agentMetrics_createdAt_idx\` ON \`agentMetrics\` (\`createdAt\`)`,
`CREATE INDEX IF NOT EXISTS \`agents_userId_idx\` ON \`agents\` (\`userId\`)`,
`CREATE INDEX IF NOT EXISTS \`agents_model_idx\` ON \`agents\` (\`model\`)`,
`CREATE INDEX IF NOT EXISTS \`browserSessions_agentId_idx\` ON \`browserSessions\` (\`agentId\`)`,
];
for (const stmt of statements) {
try {
await conn.query(stmt);
const tableName = stmt.match(/TABLE.*?`(\w+)`/)?.[1] || stmt.match(/INDEX.*?ON `(\w+)`/)?.[1] || 'statement';
console.log('✓', tableName);
} catch (e) {
if (e.code === 'ER_DUP_KEYNAME' || e.message.includes('Duplicate key name')) {
console.log('⚠ Index already exists (ok)');
} else {
console.error('✗ Error:', e.message.slice(0, 120));
}
}
}
const [tables] = await conn.query('SHOW TABLES');
console.log('\n✅ All tables:', tables.map(t => Object.values(t)[0]).join(', '));
await conn.end();
}
main().catch(console.error);

251
scripts/seed-agents.mjs Normal file
View File

@@ -0,0 +1,251 @@
/**
* Seed script — creates 3 specialized agents in the database
* Run: node scripts/seed-agents.mjs
*/
import mysql from "mysql2/promise";
import * as dotenv from "dotenv";
dotenv.config();
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
console.error("DATABASE_URL not set");
process.exit(1);
}
// Parse mysql2 connection string
function parseDbUrl(url) {
const u = new URL(url);
return {
host: u.hostname,
port: parseInt(u.port || "3306"),
user: u.username,
password: u.password,
database: u.pathname.slice(1),
ssl: { rejectUnauthorized: false },
};
}
const AGENTS = [
{
name: "Browser Agent",
description:
"Specialized agent for web browsing, scraping, and internet research. Can navigate websites, extract content, fill forms, take screenshots, and gather information from any URL.",
role: "browser",
model: "qwen2.5:7b",
provider: "ollama",
temperature: "0.3",
maxTokens: 4096,
topP: "0.9",
frequencyPenalty: "0.0",
presencePenalty: "0.0",
systemPrompt: `You are Browser Agent — a specialized AI for web browsing and internet research.
Your capabilities:
- Navigate to any URL and extract content
- Search the web for information
- Scrape structured data from websites
- Fill out forms and interact with web pages
- Take screenshots for visual verification
- Follow links and traverse multi-page content
When given a task:
1. Identify the target URL(s) or search query
2. Use the http_request tool to fetch content
3. Parse and extract relevant information
4. Return structured, clean results
Always cite your sources with full URLs. If a page requires JavaScript, note this limitation.
Respond in the same language as the user's message.`,
allowedTools: JSON.stringify(["http_request", "web_search", "extract_text"]),
allowedDomains: JSON.stringify(["*"]),
maxRequestsPerHour: 100,
isActive: 1,
isPublic: 1,
tags: JSON.stringify(["browser", "web", "scraping", "research"]),
metadata: JSON.stringify({
agentType: "browser",
icon: "Globe",
color: "#00D4FF",
seeded: true,
}),
},
{
name: "Tool Builder",
description:
"Agent that creates new tools on demand. Describe what you need and it will generate, validate, and install a new tool into the GoClaw system automatically.",
role: "tool_builder",
model: "qwen2.5-coder:7b",
provider: "ollama",
temperature: "0.2",
maxTokens: 8192,
topP: "0.95",
frequencyPenalty: "0.0",
presencePenalty: "0.0",
systemPrompt: `You are Tool Builder — a specialized AI agent that creates new tools for the GoClaw system.
Your capabilities:
- Generate complete tool implementations based on descriptions
- Validate tool code for safety and correctness
- Install tools directly into the GoClaw tool registry
- Test tools with sample inputs
- Document tool parameters and usage
When asked to create a tool:
1. Understand the tool's purpose and required parameters
2. Generate a complete, working JavaScript implementation
3. Define the parameter schema (name, type, description, required)
4. Assess safety level (safe vs dangerous)
5. Provide usage examples
Tool format:
- Name: snake_case identifier
- Parameters: JSON schema with types
- Implementation: async JavaScript function
- Returns: structured JSON result
Always validate that generated code is safe and won't cause harm.
Respond in the same language as the user's message.`,
allowedTools: JSON.stringify(["execute_code", "validate_json", "install_tool"]),
allowedDomains: JSON.stringify([]),
maxRequestsPerHour: 50,
isActive: 1,
isPublic: 1,
tags: JSON.stringify(["tools", "code", "builder", "automation"]),
metadata: JSON.stringify({
agentType: "tool_builder",
icon: "Wrench",
color: "#FF6B35",
seeded: true,
}),
},
{
name: "Agent Compiler",
description:
"Compiles new AI agents from technical specifications (ТЗ). Provide a description of what the agent should do and it will generate the complete configuration and deploy it.",
role: "agent_compiler",
model: "qwen2.5:14b",
provider: "ollama",
temperature: "0.4",
maxTokens: 8192,
topP: "0.9",
frequencyPenalty: "0.1",
presencePenalty: "0.1",
systemPrompt: `You are Agent Compiler — a meta-agent that creates other AI agents from technical specifications.
Your capabilities:
- Analyze technical specifications (ТЗ) for new agents
- Determine optimal LLM model and parameters
- Generate comprehensive system prompts
- Select appropriate tools and permissions
- Deploy new agents to the GoClaw system
When given a specification:
1. Analyze the agent's purpose and required capabilities
2. Choose the best model (consider: reasoning needs, code generation, speed)
3. Set appropriate temperature (low for precise tasks, high for creative)
4. Write a detailed system prompt that defines the agent's behavior
5. Select tools the agent needs (browser, code execution, file access, etc.)
6. Set rate limits and domain restrictions
7. Generate descriptive tags
Output format: Always respond with a JSON configuration block followed by explanation.
Model selection guide:
- Code tasks: qwen2.5-coder:7b or deepseek-coder
- Research/analysis: qwen2.5:14b
- Fast responses: qwen2.5:7b
- Creative tasks: temperature 0.7-0.9
- Precise/factual: temperature 0.1-0.3
Respond in the same language as the user's message.`,
allowedTools: JSON.stringify(["deploy_agent", "list_tools", "validate_config"]),
allowedDomains: JSON.stringify([]),
maxRequestsPerHour: 30,
isActive: 1,
isPublic: 1,
tags: JSON.stringify(["compiler", "meta", "agent-factory", "automation"]),
metadata: JSON.stringify({
agentType: "agent_compiler",
icon: "Cpu",
color: "#A855F7",
seeded: true,
}),
},
];
async function seed() {
const conn = await mysql.createConnection(parseDbUrl(DATABASE_URL));
console.log("Connected to DB");
try {
// Check if agents already seeded
const [existing] = await conn.execute(
"SELECT id, name FROM agents WHERE JSON_CONTAINS(metadata, '\"seeded\"', '$.seeded') OR name IN (?, ?, ?)",
["Browser Agent", "Tool Builder", "Agent Compiler"]
);
if (existing.length > 0) {
console.log("Agents already seeded:");
existing.forEach((a) => console.log(` - [${a.id}] ${a.name}`));
console.log("Skipping seed. Use --force to re-seed.");
if (!process.argv.includes("--force")) {
await conn.end();
return;
}
// Delete existing seeded agents
const ids = existing.map((a) => a.id);
await conn.execute(`DELETE FROM agents WHERE id IN (${ids.join(",")})`);
console.log("Deleted existing seeded agents");
}
// Insert agents
for (const agent of AGENTS) {
const [result] = await conn.execute(
`INSERT INTO agents
(userId, name, description, role, model, provider, temperature, maxTokens, topP,
frequencyPenalty, presencePenalty, systemPrompt, allowedTools, allowedDomains,
maxRequestsPerHour, isActive, isPublic, tags, metadata, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())`,
[
1, // SYSTEM_USER_ID
agent.name,
agent.description,
agent.role,
agent.model,
agent.provider,
agent.temperature,
agent.maxTokens,
agent.topP,
agent.frequencyPenalty,
agent.presencePenalty,
agent.systemPrompt,
agent.allowedTools,
agent.allowedDomains,
agent.maxRequestsPerHour,
agent.isActive,
agent.isPublic,
agent.tags,
agent.metadata,
]
);
console.log(`✓ Created agent: ${agent.name} (id: ${result.insertId})`);
}
// Verify
const [agents] = await conn.execute(
"SELECT id, name, role, model, isActive FROM agents ORDER BY id"
);
console.log("\nAll agents in DB:");
agents.forEach((a) =>
console.log(` [${a.id}] ${a.name} | role: ${a.role} | model: ${a.model} | active: ${a.isActive}`)
);
} finally {
await conn.end();
}
}
seed().catch((err) => {
console.error("Seed failed:", err);
process.exit(1);
});

229
server/agent-compiler.ts Normal file
View File

@@ -0,0 +1,229 @@
/**
* Agent Compiler — компилирует новых AI-агентов по техническому заданию через LLM
* Автоматически определяет: модель, роль, системный промпт, инструменты, параметры LLM
*/
import { invokeLLM } from "./_core/llm";
import { getDb } from "./db";
import { agents } from "../drizzle/schema";
import { TOOL_REGISTRY } from "./tools";
export interface CompileAgentRequest {
/** Техническое задание — описание что должен делать агент */
specification: string;
/** Имя агента (если не указано — LLM выберет) */
name?: string;
/** Провайдер LLM (ollama, openai, anthropic) */
preferredProvider?: string;
/** Предпочитаемая модель */
preferredModel?: string;
/** ID пользователя-владельца */
userId: number;
}
export interface CompiledAgentConfig {
name: string;
description: string;
role: string;
model: string;
provider: string;
temperature: number;
maxTokens: number;
topP: number;
frequencyPenalty: number;
presencePenalty: number;
systemPrompt: string;
allowedTools: string[];
allowedDomains: string[];
maxRequestsPerHour: number;
tags: string[];
reasoning: string; // Объяснение почему такие параметры
}
export interface CompileAgentResult {
success: boolean;
config?: CompiledAgentConfig;
agentId?: number;
error?: string;
}
/**
* Компилирует конфигурацию агента по ТЗ через LLM
*/
export async function compileAgentConfig(request: CompileAgentRequest): Promise<CompileAgentResult> {
// Get available tools for context
const availableTools = TOOL_REGISTRY.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
dangerous: t.dangerous,
}));
const systemPrompt = `You are an expert AI agent architect. Your task is to analyze a technical specification (ТЗ) and generate the optimal configuration for an AI agent.
Available tools that can be assigned to the agent:
${JSON.stringify(availableTools, null, 2)}
Available providers and models:
- ollama: llama3.2, llama3.1, mistral, codellama, deepseek-coder, phi3
- openai: gpt-4o, gpt-4o-mini, gpt-3.5-turbo
- anthropic: claude-3-5-sonnet, claude-3-haiku
Guidelines for configuration:
- temperature: 0.1-0.3 for precise/analytical tasks, 0.5-0.7 for balanced, 0.8-1.0 for creative
- maxTokens: 512-1024 for simple tasks, 2048-4096 for complex, 8192 for very long outputs
- topP: 0.9-1.0 for most tasks, lower for more focused outputs
- systemPrompt: detailed, specific, includes examples if helpful
- allowedTools: only tools the agent actually needs
- allowedDomains: specific domains if web access needed, empty array if not needed
- role: one of "developer", "researcher", "analyst", "writer", "executor", "monitor", "coordinator"
Return ONLY valid JSON with this exact structure (no markdown, no extra text):
{
"name": "Agent Name",
"description": "Brief description of what this agent does",
"role": "developer|researcher|analyst|writer|executor|monitor|coordinator",
"model": "model-name",
"provider": "ollama|openai|anthropic",
"temperature": 0.7,
"maxTokens": 2048,
"topP": 1.0,
"frequencyPenalty": 0.0,
"presencePenalty": 0.0,
"systemPrompt": "Detailed system prompt for the agent...",
"allowedTools": ["tool_id1", "tool_id2"],
"allowedDomains": ["example.com"],
"maxRequestsPerHour": 100,
"tags": ["tag1", "tag2"],
"reasoning": "Explanation of why these parameters were chosen"
}`;
const userPrompt = `Technical Specification (ТЗ):
${request.specification}
${request.name ? `Preferred name: ${request.name}` : ""}
${request.preferredProvider ? `Preferred provider: ${request.preferredProvider}` : ""}
${request.preferredModel ? `Preferred model: ${request.preferredModel}` : ""}
Analyze this specification and generate the optimal agent configuration. Be specific and detailed in the systemPrompt.`;
try {
const response = await invokeLLM({
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
response_format: {
type: "json_schema",
json_schema: {
name: "agent_config",
strict: true,
schema: {
type: "object",
properties: {
name: { type: "string" },
description: { type: "string" },
role: { type: "string" },
model: { type: "string" },
provider: { type: "string" },
temperature: { type: "number" },
maxTokens: { type: "integer" },
topP: { type: "number" },
frequencyPenalty: { type: "number" },
presencePenalty: { type: "number" },
systemPrompt: { type: "string" },
allowedTools: { type: "array", items: { type: "string" } },
allowedDomains: { type: "array", items: { type: "string" } },
maxRequestsPerHour: { type: "integer" },
tags: { type: "array", items: { type: "string" } },
reasoning: { type: "string" },
},
required: [
"name", "description", "role", "model", "provider",
"temperature", "maxTokens", "topP", "frequencyPenalty", "presencePenalty",
"systemPrompt", "allowedTools", "allowedDomains", "maxRequestsPerHour",
"tags", "reasoning"
],
additionalProperties: false,
},
},
},
});
const content = response.choices[0].message.content;
const config = typeof content === "string" ? JSON.parse(content) : content;
// Validate that allowedTools exist in registry
const validToolIds = TOOL_REGISTRY.map(t => t.id);
config.allowedTools = config.allowedTools.filter((id: string) => validToolIds.includes(id));
return { success: true, config };
} catch (error: any) {
return {
success: false,
error: `Failed to compile agent: ${error.message}`,
};
}
}
/**
* Деплоит скомпилированного агента в БД
*/
export async function deployCompiledAgent(
config: CompiledAgentConfig,
userId: number
): Promise<{ success: boolean; agentId?: number; error?: string }> {
const db = await getDb();
if (!db) return { success: false, error: "Database not available" };
try {
const [result] = await db.insert(agents).values({
userId,
name: config.name,
description: config.description,
role: config.role,
model: config.model,
provider: config.provider,
temperature: String(config.temperature),
maxTokens: config.maxTokens,
topP: String(config.topP),
frequencyPenalty: String(config.frequencyPenalty),
presencePenalty: String(config.presencePenalty),
systemPrompt: config.systemPrompt,
allowedTools: config.allowedTools,
allowedDomains: config.allowedDomains,
maxRequestsPerHour: config.maxRequestsPerHour,
tags: config.tags,
metadata: { compiledFromSpec: true, reasoning: config.reasoning },
isActive: true,
isPublic: false,
});
return { success: true, agentId: (result as any).insertId };
} catch (error: any) {
return { success: false, error: error.message };
}
}
/**
* Полный цикл: компиляция + деплой
*/
export async function compileAndDeployAgent(
request: CompileAgentRequest
): Promise<CompileAgentResult> {
const compileResult = await compileAgentConfig(request);
if (!compileResult.success || !compileResult.config) {
return compileResult;
}
const deployResult = await deployCompiledAgent(compileResult.config, request.userId);
if (!deployResult.success) {
return { success: false, error: deployResult.error };
}
return {
success: true,
config: compileResult.config,
agentId: deployResult.agentId,
};
}

317
server/browser-agent.ts Normal file
View File

@@ -0,0 +1,317 @@
/**
* Browser Agent — управление браузером через Puppeteer
* Поддерживает: навигация, скриншоты, клики, ввод текста, извлечение данных
*/
import puppeteer, { Browser, Page } from "puppeteer-core";
import { randomUUID } from "crypto";
import { getDb } from "./db";
import { browserSessions } from "../drizzle/schema";
import { eq } from "drizzle-orm";
import { storagePut } from "./storage";
const CHROMIUM_PATH = process.env.CHROMIUM_PATH || "/usr/bin/chromium-browser";
// In-memory session store (browser instances)
const activeSessions = new Map<string, { browser: Browser; page: Page; agentId: number }>();
export interface BrowserAction {
type: "navigate" | "click" | "type" | "extract" | "screenshot" | "scroll" | "wait" | "evaluate" | "close";
params: Record<string, any>;
}
export interface BrowserResult {
success: boolean;
sessionId: string;
screenshotUrl?: string;
data?: any;
error?: string;
currentUrl?: string;
title?: string;
executionTimeMs: number;
}
/**
* Создаёт новую браузерную сессию для агента
*/
export async function createBrowserSession(agentId: number): Promise<{ sessionId: string; error?: string }> {
const sessionId = randomUUID().replace(/-/g, "").slice(0, 16);
try {
const browser = await puppeteer.launch({
executablePath: CHROMIUM_PATH,
headless: true,
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-accelerated-2d-canvas",
"--no-first-run",
"--no-zygote",
"--disable-gpu",
"--window-size=1280,800",
],
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.setUserAgent(
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
);
activeSessions.set(sessionId, { browser, page, agentId });
// Persist to DB
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.insert(browserSessions).values({
sessionId,
agentId,
status: "active",
currentUrl: "about:blank",
});
return { sessionId };
} catch (error: any) {
return { sessionId: "", error: error.message };
}
}
/**
* Выполняет действие в браузерной сессии
*/
export async function executeBrowserAction(
sessionId: string,
action: BrowserAction
): Promise<BrowserResult> {
const start = Date.now();
const session = activeSessions.get(sessionId);
if (!session) {
// Try to restore from DB info
return {
success: false,
sessionId,
error: "Session not found or expired. Create a new session.",
executionTimeMs: Date.now() - start,
};
}
const { page, agentId } = session;
try {
let data: any = null;
let screenshotUrl: string | undefined;
switch (action.type) {
case "navigate": {
const url = action.params.url as string;
await page.goto(url, {
waitUntil: action.params.waitUntil || "networkidle2",
timeout: action.params.timeout || 30000,
});
break;
}
case "click": {
const selector = action.params.selector as string;
await page.waitForSelector(selector, { timeout: 10000 });
await page.click(selector);
if (action.params.waitAfter) {
await new Promise(r => setTimeout(r, action.params.waitAfter));
}
break;
}
case "type": {
const selector = action.params.selector as string;
const text = action.params.text as string;
await page.waitForSelector(selector, { timeout: 10000 });
if (action.params.clear) {
await page.click(selector, { clickCount: 3 });
}
await page.type(selector, text, { delay: action.params.delay || 0 });
break;
}
case "extract": {
const extractType = action.params.extractType || "text";
if (extractType === "text") {
data = await page.evaluate(() => document.body.innerText);
} else if (extractType === "html") {
data = await page.evaluate(() => document.documentElement.outerHTML);
} else if (extractType === "selector") {
const selector = action.params.selector as string;
data = await page.evaluate((sel) => {
const elements = document.querySelectorAll(sel);
return Array.from(elements).map(el => ({
text: (el as HTMLElement).innerText,
html: el.innerHTML,
href: (el as HTMLAnchorElement).href || null,
src: (el as HTMLImageElement).src || null,
}));
}, selector);
} else if (extractType === "links") {
data = await page.evaluate(() => {
return Array.from(document.querySelectorAll("a[href]")).map(a => ({
text: (a as HTMLAnchorElement).innerText.trim(),
href: (a as HTMLAnchorElement).href,
}));
});
} else if (extractType === "tables") {
data = await page.evaluate(() => {
return Array.from(document.querySelectorAll("table")).map(table => {
const rows = Array.from(table.querySelectorAll("tr"));
return rows.map(row =>
Array.from(row.querySelectorAll("td, th")).map(cell => (cell as HTMLElement).innerText.trim())
);
});
});
}
break;
}
case "screenshot": {
const screenshotBuffer = await page.screenshot({
type: "png",
fullPage: action.params.fullPage || false,
});
// Upload to S3
const key = `browser-sessions/${sessionId}/${Date.now()}.png`;
const uploadResult = await storagePut(key, screenshotBuffer as Buffer, "image/png");
screenshotUrl = uploadResult.url;
data = { screenshotUrl };
break;
}
case "scroll": {
const scrollY = action.params.y || 500;
const scrollX = action.params.x || 0;
await page.evaluate((x, y) => window.scrollBy(x, y), scrollX, scrollY);
break;
}
case "wait": {
const waitFor = action.params.selector
? page.waitForSelector(action.params.selector, { timeout: action.params.timeout || 10000 })
: new Promise(r => setTimeout(r, action.params.ms || 1000));
await waitFor;
break;
}
case "evaluate": {
const code = action.params.code as string;
// Safety: only allow read-only operations
data = await page.evaluate(new Function(code) as any);
break;
}
case "close": {
await session.browser.close();
activeSessions.delete(sessionId);
const closeDb = await getDb();
if (closeDb) await closeDb.update(browserSessions)
.set({ status: "closed", closedAt: new Date() })
.where(eq(browserSessions.sessionId, sessionId));
return {
success: true,
sessionId,
data: { message: "Session closed" },
executionTimeMs: Date.now() - start,
};
}
}
// Take automatic screenshot after navigation/click/type
if (["navigate", "click", "type"].includes(action.type)) {
try {
const buf = await page.screenshot({ type: "png" });
const key = `browser-sessions/${sessionId}/${Date.now()}.png`;
const uploadResult = await storagePut(key, buf as Buffer, "image/png");
screenshotUrl = uploadResult.url;
} catch {
// Screenshot is optional
}
}
const currentUrl = page.url();
const title = await page.title().catch(() => "");
// Update DB
const dbInst = await getDb();
if (dbInst) await dbInst.update(browserSessions)
.set({
currentUrl,
title,
status: "active",
screenshotUrl: screenshotUrl || undefined,
lastActionAt: new Date(),
})
.where(eq(browserSessions.sessionId, sessionId));
return {
success: true,
sessionId,
screenshotUrl,
data,
currentUrl,
title,
executionTimeMs: Date.now() - start,
};
} catch (error: any) {
// Update DB with error
const errDb = await getDb();
if (errDb) await errDb.update(browserSessions)
.set({ status: "error" })
.where(eq(browserSessions.sessionId, sessionId))
.catch(() => {});
return {
success: false,
sessionId,
error: error.message,
currentUrl: page.url(),
executionTimeMs: Date.now() - start,
};
}
}
/**
* Получает список активных сессий агента
*/
export async function getAgentSessions(agentId: number) {
const db = await getDb();
if (!db) return [];
return db.select().from(browserSessions)
.where(eq(browserSessions.agentId, agentId));
}
/**
* Получает сессию по ID
*/
export async function getSession(sessionId: string) {
const db = await getDb();
if (!db) return null;
const rows = await db.select().from(browserSessions)
.where(eq(browserSessions.sessionId, sessionId));
return rows[0] || null;
}
/**
* Закрывает все сессии агента
*/
export async function closeAllAgentSessions(agentId: number) {
for (const [sid, session] of Array.from(activeSessions.entries())) {
if (session.agentId === agentId) {
await session.browser.close().catch(() => {});
activeSessions.delete(sid);
}
}
const db = await getDb();
if (!db) return;
await db.update(browserSessions)
.set({ status: "closed", closedAt: new Date() })
.where(eq(browserSessions.agentId, agentId));
}

569
server/orchestrator.ts Normal file
View File

@@ -0,0 +1,569 @@
/**
* GoClaw Orchestrator — Main AI Agent
*
* The orchestrator is the primary interface for the user. It has access to:
* - All specialized agents (Browser, Tool Builder, Agent Compiler, custom)
* - All tools (shell, file, HTTP, Docker, browser)
* - All skills (registered capabilities)
* - System management (create/install/run components)
*
* It uses a tool-use loop: LLM decides which tools to call, executes them,
* feeds results back to LLM, and repeats until a final answer is ready.
*/
import { exec } from "child_process";
import { promisify } from "util";
import { readFile, writeFile, readdir, stat, mkdir } from "fs/promises";
import { existsSync } from "fs";
import { join, dirname } from "path";
import { invokeLLM } from "./_core/llm";
import { chatCompletion } from "./ollama";
import { getDb } from "./db";
import { agents, agentHistory } from "../drizzle/schema";
import { eq } from "drizzle-orm";
const execAsync = promisify(exec);
// ─── Tool Definitions for LLM ────────────────────────────────────────────────
export const ORCHESTRATOR_TOOLS = [
{
type: "function" as const,
function: {
name: "shell_exec",
description:
"Execute a shell command on the server. Use for: running scripts, installing packages, git operations, checking system status, compiling code.",
parameters: {
type: "object",
properties: {
command: { type: "string", description: "Shell command to execute" },
cwd: { type: "string", description: "Working directory (default: project root)" },
timeout: { type: "number", description: "Timeout in ms (default: 30000)" },
},
required: ["command"],
additionalProperties: false,
},
},
},
{
type: "function" as const,
function: {
name: "file_read",
description: "Read a file from the filesystem. Returns file content as text.",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "Absolute or relative file path" },
encoding: { type: "string", description: "File encoding (default: utf-8)" },
},
required: ["path"],
additionalProperties: false,
},
},
},
{
type: "function" as const,
function: {
name: "file_write",
description: "Write content to a file. Creates directories if needed.",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "Absolute or relative file path" },
content: { type: "string", description: "Content to write" },
append: { type: "boolean", description: "Append to file instead of overwrite" },
},
required: ["path", "content"],
additionalProperties: false,
},
},
},
{
type: "function" as const,
function: {
name: "file_list",
description: "List files in a directory.",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "Directory path" },
recursive: { type: "boolean", description: "List recursively" },
},
required: ["path"],
additionalProperties: false,
},
},
},
{
type: "function" as const,
function: {
name: "http_request",
description: "Make an HTTP request to any URL. Supports GET, POST, PUT, DELETE.",
parameters: {
type: "object",
properties: {
url: { type: "string", description: "Target URL" },
method: { type: "string", description: "HTTP method (default: GET)" },
headers: { type: "object", description: "Request headers" },
body: { type: "string", description: "Request body (for POST/PUT)" },
},
required: ["url"],
additionalProperties: false,
},
},
},
{
type: "function" as const,
function: {
name: "delegate_to_agent",
description:
"Delegate a task to a specialized agent. Use for: web browsing (Browser Agent), creating tools (Tool Builder), compiling agents (Agent Compiler), or any other specialized agent.",
parameters: {
type: "object",
properties: {
agentId: { type: "number", description: "Agent ID to delegate to" },
message: { type: "string", description: "Task description for the agent" },
},
required: ["agentId", "message"],
additionalProperties: false,
},
},
},
{
type: "function" as const,
function: {
name: "list_agents",
description: "List all available specialized agents with their capabilities.",
parameters: {
type: "object",
properties: {},
additionalProperties: false,
},
},
},
{
type: "function" as const,
function: {
name: "list_skills",
description: "List all installed skills (capabilities) available to agents.",
parameters: {
type: "object",
properties: {},
additionalProperties: false,
},
},
},
{
type: "function" as const,
function: {
name: "install_skill",
description:
"Install a new skill into the system. A skill is a reusable capability module.",
parameters: {
type: "object",
properties: {
name: { type: "string", description: "Skill name (snake_case)" },
description: { type: "string", description: "What this skill does" },
code: { type: "string", description: "JavaScript implementation of the skill" },
category: { type: "string", description: "Skill category (web, data, ai, system, etc.)" },
},
required: ["name", "description", "code"],
additionalProperties: false,
},
},
},
{
type: "function" as const,
function: {
name: "docker_exec",
description: "Execute a Docker command (docker ps, docker logs, docker exec, etc.).",
parameters: {
type: "object",
properties: {
command: { type: "string", description: "Docker command (without 'docker' prefix)" },
},
required: ["command"],
additionalProperties: false,
},
},
},
];
// ─── Tool Execution ───────────────────────────────────────────────────────────
const PROJECT_ROOT = "/home/ubuntu/goclaw-control-center";
async function executeTool(
toolName: string,
args: Record<string, any>
): Promise<{ success: boolean; result?: any; error?: string }> {
try {
switch (toolName) {
case "shell_exec": {
const cwd = args.cwd || PROJECT_ROOT;
const timeout = args.timeout || 30000;
const { stdout, stderr } = await execAsync(args.command, { cwd, timeout });
return {
success: true,
result: { stdout: stdout.trim(), stderr: stderr.trim(), command: args.command },
};
}
case "file_read": {
const filePath = args.path.startsWith("/") ? args.path : join(PROJECT_ROOT, args.path);
if (!existsSync(filePath)) {
return { success: false, error: `File not found: ${filePath}` };
}
const content = await readFile(filePath, (args.encoding as BufferEncoding) || "utf-8");
const lines = (content as string).split("\n").length;
return { success: true, result: { path: filePath, content, lines } };
}
case "file_write": {
const filePath = args.path.startsWith("/") ? args.path : join(PROJECT_ROOT, args.path);
await mkdir(dirname(filePath), { recursive: true });
if (args.append) {
const existing = existsSync(filePath)
? await readFile(filePath, "utf-8")
: "";
await writeFile(filePath, existing + args.content, "utf-8");
} else {
await writeFile(filePath, args.content, "utf-8");
}
return { success: true, result: { path: filePath, written: args.content.length } };
}
case "file_list": {
const dirPath = args.path.startsWith("/") ? args.path : join(PROJECT_ROOT, args.path);
if (!existsSync(dirPath)) {
return { success: false, error: `Directory not found: ${dirPath}` };
}
const entries = await readdir(dirPath);
const details = await Promise.all(
entries.map(async (name) => {
const fullPath = join(dirPath, name);
const s = await stat(fullPath);
return { name, type: s.isDirectory() ? "dir" : "file", size: s.size };
})
);
return { success: true, result: { path: dirPath, entries: details } };
}
case "http_request": {
const method = (args.method || "GET").toUpperCase();
const response = await fetch(args.url, {
method,
headers: args.headers || {},
body: args.body || undefined,
signal: AbortSignal.timeout(15000),
});
const text = await response.text();
let body: any = text;
try {
body = JSON.parse(text);
} catch {
// keep as text
}
return {
success: true,
result: {
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body,
},
};
}
case "delegate_to_agent": {
const db = await getDb();
if (!db) return { success: false, error: "DB not available" };
const [agent] = await db
.select()
.from(agents)
.where(eq(agents.id, args.agentId))
.limit(1);
if (!agent) {
return { success: false, error: `Agent ${args.agentId} not found` };
}
const messages: Array<{ role: "system" | "user" | "assistant"; content: string }> = [];
if (agent.systemPrompt) {
messages.push({ role: "system", content: agent.systemPrompt });
}
messages.push({ role: "user", content: args.message });
const result = await chatCompletion(agent.model, messages, {
temperature: agent.temperature ? parseFloat(agent.temperature as string) : 0.7,
max_tokens: agent.maxTokens ?? 2048,
});
const response = result.choices[0]?.message?.content ?? "";
// Save to agent history
await db.insert(agentHistory).values({
agentId: agent.id,
userMessage: args.message,
agentResponse: response,
conversationId: `orchestrator-${Date.now()}`,
status: "success",
});
return {
success: true,
result: {
agentName: agent.name,
agentRole: agent.role,
response,
model: result.model,
usage: result.usage,
},
};
}
case "list_agents": {
const db = await getDb();
if (!db) return { success: false, error: "DB not available" };
const allAgents = await db
.select({
id: agents.id,
name: agents.name,
role: agents.role,
description: agents.description,
model: agents.model,
isActive: agents.isActive,
allowedTools: agents.allowedTools,
tags: agents.tags,
})
.from(agents)
.where(eq(agents.isActive, true));
return { success: true, result: { agents: allAgents, count: allAgents.length } };
}
case "list_skills": {
const skillsPath = join(PROJECT_ROOT, "server/skills");
if (!existsSync(skillsPath)) {
return { success: true, result: { skills: [], count: 0 } };
}
const entries = await readdir(skillsPath);
const skills = await Promise.all(
entries.map(async (name) => {
const metaPath = join(skillsPath, name, "skill.json");
if (existsSync(metaPath)) {
const meta = JSON.parse(await readFile(metaPath, "utf-8") as string);
return { name, ...meta };
}
return { name, description: "No metadata" };
})
);
return { success: true, result: { skills, count: skills.length } };
}
case "install_skill": {
const skillsPath = join(PROJECT_ROOT, "server/skills", args.name);
await mkdir(skillsPath, { recursive: true });
// Write skill implementation
await writeFile(join(skillsPath, "index.js"), args.code, "utf-8");
// Write skill metadata
const meta = {
name: args.name,
description: args.description,
category: args.category || "custom",
installedAt: new Date().toISOString(),
version: "1.0.0",
};
await writeFile(join(skillsPath, "skill.json"), JSON.stringify(meta, null, 2), "utf-8");
return { success: true, result: { installed: args.name, path: skillsPath } };
}
case "docker_exec": {
const { stdout, stderr } = await execAsync(`docker ${args.command}`, {
timeout: 15000,
});
return { success: true, result: { stdout: stdout.trim(), stderr: stderr.trim() } };
}
default:
return { success: false, error: `Unknown tool: ${toolName}` };
}
} catch (err: any) {
return { success: false, error: err.message || String(err) };
}
}
// ─── Orchestrator Chat ────────────────────────────────────────────────────────
export interface OrchestratorMessage {
role: "user" | "assistant" | "system";
content: string;
}
export interface ToolCallStep {
tool: string;
args: Record<string, any>;
result: any;
success: boolean;
durationMs: number;
}
export interface OrchestratorResult {
success: boolean;
response: string;
toolCalls: ToolCallStep[];
model?: string;
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
error?: string;
}
const ORCHESTRATOR_SYSTEM_PROMPT = `You are GoClaw Orchestrator — the main AI agent managing the GoClaw distributed AI system.
You have full access to:
1. **Specialized Agents**: Browser Agent (web browsing), Tool Builder (create tools), Agent Compiler (create agents)
2. **System Tools**: shell_exec (run commands), file_read/write (manage files), http_request (web requests), docker_exec (Docker management)
3. **Skills Registry**: list_skills (see capabilities), install_skill (add new capabilities)
Your responsibilities:
- Answer user questions directly when possible
- Delegate complex web tasks to Browser Agent
- Delegate tool creation to Tool Builder agent
- Delegate agent creation to Agent Compiler
- Execute shell commands to manage the system, install packages, run scripts
- Read and write files to modify the codebase
- Monitor Docker containers and services
Decision making:
- For simple questions: answer directly without tools
- For web research: use delegate_to_agent with Browser Agent (id: 1)
- For creating tools: use delegate_to_agent with Tool Builder (id: 2)
- For creating agents: use delegate_to_agent with Agent Compiler (id: 3)
- For system tasks: use shell_exec, file_read/write
- For Docker: use docker_exec
- Always use list_agents first if you're unsure which agent to delegate to
Response style:
- Be concise and actionable
- Show what tools you used and their results
- If a task requires multiple steps, execute them in sequence
- Respond in the same language as the user
You are running on a Linux server with Node.js, Docker, and full internet access.`;
export async function orchestratorChat(
messages: OrchestratorMessage[],
model: string = "qwen2.5:7b",
maxToolIterations: number = 10
): Promise<OrchestratorResult> {
const toolCalls: ToolCallStep[] = [];
// Build conversation with system prompt
const conversation: Array<{
role: "system" | "user" | "assistant" | "tool" | "function";
content: string | any;
tool_call_id?: string;
name?: string;
}> = [
{ role: "system", content: ORCHESTRATOR_SYSTEM_PROMPT },
...messages.map((m) => ({ role: m.role, content: m.content })),
];
let iterations = 0;
let finalResponse = "";
let lastUsage: any;
let lastModel: string = model;
while (iterations < maxToolIterations) {
iterations++;
// Call LLM with tools
let llmResult: any;
try {
llmResult = await invokeLLM({
messages: conversation as any,
tools: ORCHESTRATOR_TOOLS,
tool_choice: "auto",
});
} catch (err: any) {
// Fallback: try without tools if LLM doesn't support them
try {
const fallbackResult = await chatCompletion(model, conversation as any, {
temperature: 0.7,
max_tokens: 4096,
});
finalResponse = fallbackResult.choices[0]?.message?.content ?? "";
lastUsage = fallbackResult.usage;
lastModel = fallbackResult.model;
break;
} catch (fallbackErr: any) {
return {
success: false,
response: "",
toolCalls,
error: `LLM error: ${fallbackErr.message}`,
};
}
}
const choice = llmResult.choices?.[0];
if (!choice) break;
lastUsage = llmResult.usage;
lastModel = llmResult.model || model;
const message = choice.message;
// Check if LLM wants to call tools
if (choice.finish_reason === "tool_calls" && message.tool_calls?.length > 0) {
// Add assistant message with tool calls to conversation
conversation.push({
role: "assistant",
content: message.content || "",
...message,
});
// Execute each tool call
for (const tc of message.tool_calls) {
const toolName = tc.function?.name;
let toolArgs: Record<string, any> = {};
try {
toolArgs = JSON.parse(tc.function?.arguments || "{}");
} catch {
toolArgs = {};
}
const startTime = Date.now();
const toolResult = await executeTool(toolName, toolArgs);
const durationMs = Date.now() - startTime;
toolCalls.push({
tool: toolName,
args: toolArgs,
result: toolResult.result,
success: toolResult.success,
durationMs,
});
// Add tool result to conversation
conversation.push({
role: "tool",
content: JSON.stringify(
toolResult.success
? toolResult.result
: { error: toolResult.error }
),
tool_call_id: tc.id,
name: toolName,
});
}
// Continue loop — LLM will process tool results
continue;
}
// LLM finished — extract final response
finalResponse = message.content || "";
break;
}
return {
success: true,
response: finalResponse,
toolCalls,
model: lastModel,
usage: lastUsage,
};
}

View File

@@ -265,7 +265,7 @@ export const appRouter = router({
}),
}),
/**
/**
* Tools — управление инструментами агентов
*/
tools: router({
@@ -273,13 +273,12 @@ export const appRouter = router({
const { getAllTools } = await import("./tools");
return getAllTools();
}),
execute: publicProcedure
.input(
z.object({
agentId: z.number(),
tool: z.string(),
params: z.record(z.string(), z.any()),
params: z.record(z.string(), z.unknown()),
})
)
.mutation(async ({ input }) => {
@@ -287,6 +286,216 @@ export const appRouter = router({
return executeTool(input.agentId, input.tool, input.params);
}),
}),
});
/**
* Browser Agent — управление браузерными сессиями через Puppeteer
*/
browser: router({
createSession: publicProcedure
.input(z.object({ agentId: z.number() }))
.mutation(async ({ input }) => {
const { createBrowserSession } = await import("./browser-agent");
return createBrowserSession(input.agentId);
}),
execute: publicProcedure
.input(
z.object({
sessionId: z.string(),
action: z.object({
type: z.enum(["navigate", "click", "type", "extract", "screenshot", "scroll", "wait", "evaluate", "close"]),
params: z.record(z.string(), z.unknown()),
}),
})
)
.mutation(async ({ input }) => {
const { executeBrowserAction } = await import("./browser-agent");
return executeBrowserAction(input.sessionId, input.action as any);
}),
getSessions: publicProcedure
.input(z.object({ agentId: z.number() }))
.query(async ({ input }) => {
const { getAgentSessions } = await import("./browser-agent");
return getAgentSessions(input.agentId);
}),
closeSession: publicProcedure
.input(z.object({ sessionId: z.string() }))
.mutation(async ({ input }) => {
const { executeBrowserAction } = await import("./browser-agent");
return executeBrowserAction(input.sessionId, { type: "close", params: {} });
}),
closeAllSessions: publicProcedure
.input(z.object({ agentId: z.number() }))
.mutation(async ({ input }) => {
const { closeAllAgentSessions } = await import("./browser-agent");
await closeAllAgentSessions(input.agentId);
return { success: true };
}),
}),
/**
* Tool Builder — генерация и установка новых инструментов через LLM
*/
toolBuilder: router({
generate: publicProcedure
.input(
z.object({
name: z.string().min(1),
description: z.string().min(10),
category: z.string().optional(),
exampleInput: z.string().optional(),
exampleOutput: z.string().optional(),
dangerous: z.boolean().optional(),
})
)
.mutation(async ({ input }) => {
const { generateTool } = await import("./tool-builder");
return generateTool(input);
}),
install: publicProcedure
.input(
z.object({
toolId: z.string(),
name: z.string(),
description: z.string(),
category: z.string(),
dangerous: z.boolean(),
parameters: z.record(z.string(), z.object({
type: z.string(),
description: z.string(),
required: z.boolean().optional(),
})),
implementation: z.string(),
})
)
.mutation(async ({ input }) => {
const { installTool } = await import("./tool-builder");
return installTool(input);
}),
listCustom: publicProcedure.query(async () => {
const { getCustomTools } = await import("./tool-builder");
return getCustomTools();
}),
delete: publicProcedure
.input(z.object({ toolId: z.string() }))
.mutation(async ({ input }) => {
const { deleteTool } = await import("./tool-builder");
return deleteTool(input.toolId);
}),
test: publicProcedure
.input(
z.object({
toolId: z.string(),
params: z.record(z.string(), z.unknown()),
})
)
.mutation(async ({ input }) => {
const { testTool } = await import("./tool-builder");
return testTool(input.toolId, input.params);
}),
}),
/**
* Agent Compiler — компиляция агентов по ТЗ через LLM
*/
agentCompiler: router({
compile: publicProcedure
.input(
z.object({
specification: z.string().min(20),
name: z.string().optional(),
preferredProvider: z.string().optional(),
preferredModel: z.string().optional(),
})
)
.mutation(async ({ input }) => {
const { compileAgentConfig } = await import("./agent-compiler");
return compileAgentConfig({ ...input, userId: SYSTEM_USER_ID });
}),
deploy: publicProcedure
.input(
z.object({
config: z.object({
name: z.string(),
description: z.string(),
role: z.string(),
model: z.string(),
provider: z.string(),
temperature: z.number(),
maxTokens: z.number(),
topP: z.number(),
frequencyPenalty: z.number(),
presencePenalty: z.number(),
systemPrompt: z.string(),
allowedTools: z.array(z.string()),
allowedDomains: z.array(z.string()),
maxRequestsPerHour: z.number(),
tags: z.array(z.string()),
reasoning: z.string(),
}),
})
)
.mutation(async ({ input }) => {
const { deployCompiledAgent } = await import("./agent-compiler");
return deployCompiledAgent(input.config, SYSTEM_USER_ID);
}),
compileAndDeploy: publicProcedure
.input(
z.object({
specification: z.string().min(20),
name: z.string().optional(),
preferredProvider: z.string().optional(),
preferredModel: z.string().optional(),
})
)
.mutation(async ({ input }) => {
const { compileAndDeployAgent } = await import("./agent-compiler");
return compileAndDeployAgent({ ...input, userId: SYSTEM_USER_ID });
}),
}),
/**
* Orchestrator — main AI agent with tool-use loop
*/
orchestrator: router({
chat: publicProcedure
.input(
z.object({
messages: z.array(
z.object({
role: z.enum(["user", "assistant", "system"]),
content: z.string(),
})
),
model: z.string().optional(),
maxIterations: z.number().min(1).max(20).optional(),
})
)
.mutation(async ({ input }) => {
const { orchestratorChat } = await import("./orchestrator");
return orchestratorChat(
input.messages,
input.model ?? "qwen2.5:7b",
input.maxIterations ?? 10
);
}),
tools: publicProcedure.query(async () => {
const { ORCHESTRATOR_TOOLS } = await import("./orchestrator");
return ORCHESTRATOR_TOOLS.map((t) => ({
name: t.function.name,
description: t.function.description,
parameters: t.function.parameters,
}));
}),
}),
});
export type AppRouter = typeof appRouter;

282
server/tool-builder.ts Normal file
View File

@@ -0,0 +1,282 @@
/**
* Tool Builder Agent — генерирует новые инструменты через LLM и добавляет их в реестр
*/
import { invokeLLM } from "./_core/llm";
import { getDb } from "./db";
import { toolDefinitions } from "../drizzle/schema";
import { eq } from "drizzle-orm";
import { TOOL_REGISTRY, type ToolDefinition } from "./tools";
export interface GenerateToolRequest {
name: string;
description: string;
category?: string;
exampleInput?: string;
exampleOutput?: string;
dangerous?: boolean;
}
export interface GeneratedToolData {
toolId: string;
name: string;
description: string;
category: string;
dangerous: boolean;
parameters: Record<string, { type: string; description: string; required?: boolean }>;
implementation: string;
warnings?: string[];
}
export interface GenerateToolResult {
success: boolean;
tool?: GeneratedToolData;
error?: string;
}
/**
* Генерирует новый инструмент через LLM на основе описания
*/
export async function generateTool(request: GenerateToolRequest): Promise<GenerateToolResult> {
const systemPrompt = `You are an expert JavaScript developer specializing in creating tool functions for AI agents.
Your task is to generate a complete, working JavaScript tool implementation.
Rules:
1. The tool must be a single async function named "execute" that accepts a params object
2. Use only built-in Node.js modules (fs, path, crypto, http, https, url) or global fetch API
3. Always handle errors gracefully and return meaningful error messages
4. The function must return a JSON-serializable result object
5. Add input validation for all required parameters
6. Keep the implementation concise but complete
Return ONLY valid JSON with this exact structure:
{
"toolId": "snake_case_id",
"name": "Human Readable Name",
"description": "What this tool does",
"category": "web|data|file|system|ai|custom",
"dangerous": false,
"parameters": {
"paramName": {
"type": "string|number|boolean|array|object",
"description": "What this parameter does",
"required": true
}
},
"implementation": "async function execute(params) { /* your code here */ return { result: 'value' }; }"
}`;
const userPrompt = `Create a tool with the following specification:
Name: ${request.name}
Description: ${request.description}
Category: ${request.category || "custom"}
${request.exampleInput ? `Example Input: ${request.exampleInput}` : ""}
${request.exampleOutput ? `Example Output: ${request.exampleOutput}` : ""}
${request.dangerous ? "Note: This tool may perform dangerous operations, add appropriate safety checks." : ""}
Generate a complete, production-ready implementation.`;
try {
const response = await invokeLLM({
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
response_format: {
type: "json_schema",
json_schema: {
name: "tool_definition",
strict: true,
schema: {
type: "object",
properties: {
toolId: { type: "string" },
name: { type: "string" },
description: { type: "string" },
category: { type: "string" },
dangerous: { type: "boolean" },
parameters: { type: "object", additionalProperties: true },
implementation: { type: "string" },
},
required: ["toolId", "name", "description", "category", "dangerous", "parameters", "implementation"],
additionalProperties: false,
},
},
},
});
const content = response.choices[0].message.content;
const toolData = typeof content === "string" ? JSON.parse(content) : content;
// Validate the implementation is safe
const warnings: string[] = [];
const dangerousPatterns = ["child_process", "exec(", "spawn(", "eval(", "new Function("];
for (const pattern of dangerousPatterns) {
if (toolData.implementation.includes(pattern) && !request.dangerous) {
warnings.push(`Warning: Implementation contains potentially dangerous pattern: ${pattern}`);
}
}
return {
success: true,
tool: { ...toolData, warnings },
};
} catch (error: any) {
return {
success: false,
error: `Failed to generate tool: ${error.message}`,
};
}
}
/**
* Сохраняет инструмент в БД и регистрирует в реестре
*/
export async function installTool(
toolData: GeneratedToolData,
createdBy?: number
): Promise<{ success: boolean; toolId?: string; error?: string }> {
const db = await getDb();
if (!db) return { success: false, error: "Database not available" };
try {
// Save to DB
await db.insert(toolDefinitions).values({
toolId: toolData.toolId,
name: toolData.name,
description: toolData.description,
category: toolData.category,
dangerous: toolData.dangerous,
parameters: toolData.parameters,
implementation: toolData.implementation,
isActive: true,
createdBy: createdBy || null,
}).onDuplicateKeyUpdate({
set: {
name: toolData.name,
description: toolData.description,
implementation: toolData.implementation,
parameters: toolData.parameters,
updatedAt: new Date(),
},
});
// Register in runtime TOOL_REGISTRY
registerToolInRegistry(toolData);
return { success: true, toolId: toolData.toolId };
} catch (error: any) {
return { success: false, error: error.message };
}
}
/**
* Динамически регистрирует инструмент в TOOL_REGISTRY (массив)
*/
function registerToolInRegistry(toolData: GeneratedToolData): void {
try {
// Create the execute function from implementation string
const executeFunc = new Function("return " + toolData.implementation)() as (params: any) => Promise<any>;
const toolEntry: ToolDefinition = {
id: toolData.toolId,
name: toolData.name,
description: toolData.description,
category: toolData.category as ToolDefinition["category"],
dangerous: toolData.dangerous,
parameters: toolData.parameters,
execute: executeFunc,
};
// Remove existing entry if present, then add new one
const existingIdx = TOOL_REGISTRY.findIndex(t => t.id === toolData.toolId);
if (existingIdx >= 0) {
TOOL_REGISTRY.splice(existingIdx, 1, toolEntry);
} else {
TOOL_REGISTRY.push(toolEntry);
}
} catch (error: any) {
console.warn(`[ToolBuilder] Failed to register tool ${toolData.toolId} in runtime:`, error.message);
}
}
/**
* Загружает все кастомные инструменты из БД в реестр при старте
*/
export async function loadCustomToolsFromDb(): Promise<void> {
const db = await getDb();
if (!db) return;
try {
const tools = await db.select().from(toolDefinitions)
.where(eq(toolDefinitions.isActive, true));
for (const tool of tools) {
registerToolInRegistry({
toolId: tool.toolId,
name: tool.name,
description: tool.description,
category: tool.category,
dangerous: tool.dangerous || false,
parameters: (tool.parameters as any) || {},
implementation: tool.implementation,
});
}
console.log(`[ToolBuilder] Loaded ${tools.length} custom tools from DB`);
} catch (error: any) {
console.warn("[ToolBuilder] Failed to load custom tools:", error.message);
}
}
/**
* Получает все инструменты из БД
*/
export async function getCustomTools() {
const db = await getDb();
if (!db) return [];
return db.select().from(toolDefinitions);
}
/**
* Удаляет инструмент из БД и реестра
*/
export async function deleteTool(toolId: string): Promise<{ success: boolean; error?: string }> {
const db = await getDb();
if (!db) return { success: false, error: "Database not available" };
try {
await db.delete(toolDefinitions).where(eq(toolDefinitions.toolId, toolId));
// Remove from TOOL_REGISTRY
const idx = TOOL_REGISTRY.findIndex(t => t.id === toolId);
if (idx >= 0) TOOL_REGISTRY.splice(idx, 1);
return { success: true };
} catch (error: any) {
return { success: false, error: error.message };
}
}
/**
* Тестирует инструмент с заданными параметрами
*/
export async function testTool(
toolId: string,
params: Record<string, any>
): Promise<{ success: boolean; result?: any; error?: string; executionTimeMs: number }> {
const start = Date.now();
const tool = TOOL_REGISTRY.find(t => t.id === toolId);
if (!tool) {
return { success: false, error: `Tool '${toolId}' not found in registry`, executionTimeMs: 0 };
}
if (!tool.execute) {
return { success: false, error: `Tool '${toolId}' has no execute function (built-in tools use executeToolImpl)`, executionTimeMs: 0 };
}
try {
const result = await tool.execute(params);
return { success: true, result, executionTimeMs: Date.now() - start };
} catch (error: any) {
return { success: false, error: error.message, executionTimeMs: Date.now() - start };
}
}

View File

@@ -7,10 +7,12 @@ export interface ToolDefinition {
id: string;
name: string;
description: string;
category: "browser" | "shell" | "file" | "docker" | "http" | "system";
icon: string;
category: "browser" | "shell" | "file" | "docker" | "http" | "system" | "custom" | "web" | "data" | "ai";
icon?: string;
parameters: Record<string, { type: string; description: string; required?: boolean }>;
dangerous: boolean;
/** Optional custom execute function for dynamically registered tools */
execute?: (params: Record<string, any>) => Promise<any>;
}
/**

53
todo.md
View File

@@ -52,3 +52,56 @@
- [x] Metrics button on agent cards
- [x] Navigation: /agents/:id/metrics route
- [x] Tools page added to sidebar navigation
## Phase 5: Specialized Agents
### Browser Agent
- [ ] Install puppeteer-core + chromium dependencies
- [ ] Create server/browser-agent.ts — Puppeteer session manager
- [ ] tRPC routes: browser.start, browser.navigate, browser.screenshot, browser.click, browser.type, browser.extract, browser.close
- [ ] BrowserAgent.tsx page — live browser control UI with screenshot preview
- [ ] Session management: multiple concurrent browser sessions per agent
- [ ] Add browser_agent to agents DB as pre-seeded entry
### Tool Builder Agent
- [ ] Create server/tool-builder.ts — LLM-powered tool generator
- [ ] tRPC routes: toolBuilder.generate, toolBuilder.validate, toolBuilder.install
- [ ] Dynamic tool registration: add generated tools to TOOL_REGISTRY at runtime
- [ ] Persist custom tools to DB (tool_definitions table)
- [ ] ToolBuilder.tsx page — describe tool → preview code → install
- [ ] Add tool_builder_agent to agents DB as pre-seeded entry
### Agent Compiler
- [ ] Create server/agent-compiler.ts — LLM-powered agent factory
- [ ] tRPC routes: agentCompiler.compile, agentCompiler.preview, agentCompiler.deploy
- [ ] AgentCompiler.tsx page — ТЗ input → agent config preview → deploy
- [ ] Auto-populate: model, role, systemPrompt, allowedTools from ТЗ
- [ ] Add agent_compiler to agents DB as pre-seeded entry
### Integration
- [ ] Add all 3 pages to sidebar navigation
- [ ] Write vitest tests for all new server modules
- [ ] Push to Gitea (NW)
## Phase 6: Agents as Real Chat Entities
- [ ] Remove unused pages: BrowserAgent.tsx, ToolBuilder.tsx, AgentCompiler.tsx
- [ ] Seed 3 agents into DB: Browser Agent, Tool Builder Agent, Agent Compiler
- [ ] Add tRPC chat endpoint: agents.chat (LLM + tool execution per agent)
- [ ] Update Chat UI to support agent selection dropdown
- [ ] Create /skills page — skills registry with install/uninstall
- [ ] Update /agents to show seeded agents with Chat button
- [ ] Update /tools to show tools per agent with filter by agent
- [ ] Add /skills to sidebar navigation
- [ ] Write tests for chat and skills endpoints
## Phase 6: Orchestrator Agent (Main Chat)
- [x] Fix TS errors: browserSessions/toolDefinitions schema exports, z.record
- [x] Seed 3 specialized agents into DB (Browser, Tool Builder, Agent Compiler)
- [x] Create server/orchestrator.ts — main orchestrator with tool-use loop
- [x] Orchestrator tools: shell_exec, file_read, file_write, http_request, delegate_to_agent, list_agents, list_skills, install_skill
- [x] Add trpc.orchestrator.chat mutation (multi-step tool-use loop with LLM)
- [x] Update /chat UI: show tool call steps, agent delegation, streaming response
- [x] Create /skills page with skill registry (install/remove/describe)
- [x] Add /skills to sidebar navigation
- [x] Update /agents to show seeded agents with Chat button
- [ ] Write tests for orchestrator

View File

@@ -2,8 +2,7 @@
"include": ["client/src/**/*", "shared/**/*", "server/**/*"],
"exclude": ["node_modules", "build", "dist", "**/*.test.ts"],
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": "./node_modules/typescript/tsbuildinfo",
"incremental": false,
"noEmit": true,
"module": "ESNext",
"strict": true,