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