true message

This commit is contained in:
Manus
2026-03-20 16:39:29 -04:00
parent b18e6e244f
commit 159a89a156
17 changed files with 3054 additions and 191 deletions

23
.env.example Normal file
View File

@@ -0,0 +1,23 @@
# ─────────────────────────────────────────────
# GoClaw Control Center — Environment Variables
# Скопируйте этот файл в .env и заполните значения
# ─────────────────────────────────────────────
# Ollama API (обязательно для работы чата и списка моделей)
OLLAMA_BASE_URL=https://ollama.com/v1
OLLAMA_API_KEY=your_ollama_api_key_here
# База данных MySQL/TiDB
DATABASE_URL=mysql://goclaw:password@localhost:3306/goclaw
# JWT Secret — случайная строка для подписи сессионных токенов
# Сгенерировать: openssl rand -hex 32
JWT_SECRET=change_me_to_random_secret
# Telegram Bot (опционально)
TELEGRAM_BOT_TOKEN=
TELEGRAM_WEBHOOK_URL=
# GoClaw Gateway (опционально)
GATEWAY_URL=http://localhost:18789
GATEWAY_API_KEY=

210
README.md Normal file
View File

@@ -0,0 +1,210 @@
# GoClaw Control Center
> **Mission Control для вашего AI-агентского кластера.** Веб-интерфейс для мониторинга Docker Swarm, управления AI-агентами и общения с оркестратором через реальный LLM (Ollama Cloud API).
---
## Запуск одной командой
```bash
curl -fsSL https://git.softuniq.eu/UniqAI/GoClaw/raw/branch/main/install.sh | bash
```
Или вручную через Docker Compose:
```bash
git clone https://git.softuniq.eu/UniqAI/GoClaw.git && cd GoClaw && cp .env.example .env && docker compose up -d
```
После запуска откройте: **http://localhost:3000**
---
## Быстрый старт (без Docker)
```bash
# 1. Клонировать репозиторий
git clone https://git.softuniq.eu/UniqAI/GoClaw.git
cd GoClaw
# 2. Установить зависимости
pnpm install
# 3. Настроить переменные окружения
cp .env.example .env
# Отредактируйте .env — укажите OLLAMA_BASE_URL и OLLAMA_API_KEY
# 4. Запустить базу данных
pnpm db:push
# 5. Запустить dev-сервер
pnpm dev
```
---
## Архитектура
```
GoClaw Control Center
├── client/ # React 19 + Tailwind 4 + shadcn/ui (фронтенд)
│ └── src/
│ ├── pages/ # Dashboard, Agents, Nodes, Chat, Settings
│ └── components/ # DashboardLayout, UI-компоненты
├── server/ # Express 4 + tRPC 11 (бэкенд)
│ ├── ollama.ts # Прокси-клиент для Ollama API
│ ├── routers.ts # tRPC роуты (ollama.health, models, chat)
│ └── _core/ # Auth, DB, LLM, Storage хелперы
├── drizzle/ # Схема БД и миграции (MySQL/TiDB)
├── docker/ # Docker Stack для Swarm-деплоя
│ ├── docker-stack.yml # Полный стек: Gateway + Agents + Control Center
│ ├── Dockerfile.gateway # Образ основного шлюза GoClaw
│ ├── Dockerfile.agent # Образ AI-агента
│ └── Dockerfile.control-center # Образ веб-интерфейса (nginx)
└── docs/ # Документация и спецификации
├── swarm_architecture.md
└── goclaw_swarm_cloud_guide.md
```
---
## Возможности
| Раздел | Что умеет |
| :--- | :--- |
| **Dashboard** | Мониторинг Swarm-нод, статус агентов, лента активности, live-статус Ollama API |
| **Agents** | Просмотр всех AI-агентов с их ролями, моделями и текущими задачами |
| **Nodes** | Мониторинг Docker Swarm нод: CPU, RAM, контейнеры, статус |
| **Chat** | Терминальный чат с оркестратором через реальный LLM (34 модели Ollama) |
| **Settings** | Управление API-провайдерами, сканирование доступных моделей, настройки Telegram |
---
## Переменные окружения
Скопируйте `.env.example` в `.env` и заполните:
```env
# Ollama API (обязательно)
OLLAMA_BASE_URL=https://ollama.com/v1
OLLAMA_API_KEY=your_api_key_here
# База данных (обязательно для production)
DATABASE_URL=mysql://user:password@host:3306/goclaw
# JWT (сгенерируйте случайную строку)
JWT_SECRET=your_random_secret_here
# Telegram Bot (опционально)
TELEGRAM_BOT_TOKEN=your_bot_token
```
---
## Docker Swarm деплой
```bash
# Инициализировать Swarm (если ещё не сделано)
docker swarm init
# Задать секреты
export OLLAMA_API_KEY=your_key
export DATABASE_URL=mysql://...
export JWT_SECRET=your_secret
# Развернуть стек
docker stack deploy -c docker/docker-stack.yml goclaw
# Проверить статус
docker stack services goclaw
```
Добавление новой ноды в кластер:
```bash
# На manager-ноде получить токен
docker swarm join-token worker
# На новой ноде выполнить команду из вывода выше
docker swarm join --token SWMTKN-... manager-ip:2377
```
---
## Технологический стек
| Слой | Технологии |
| :--- | :--- |
| **Фронтенд** | React 19, Tailwind CSS 4, shadcn/ui, Framer Motion, tRPC Client |
| **Бэкенд** | Node.js, Express 4, tRPC 11, Drizzle ORM, Zod |
| **База данных** | MySQL / TiDB (через Drizzle) |
| **LLM** | Ollama Cloud API (OpenAI-совместимый, 34+ модели) |
| **Оркестрация** | Docker Swarm, Overlay Network |
| **Агенты (Go)** | gRPC, Docker SDK, Goroutines, Channels |
| **Тесты** | Vitest |
---
## Структура GoClaw Swarm
```
┌─────────────────────────────────┐
│ goclaw-net (Overlay Network) │
└─────────────────────────────────┘
┌─────────────────────┼─────────────────────┐
│ │ │
┌─────────▼──────┐ ┌──────────▼──────┐ ┌─────────▼──────┐
│ Control Center │ │ Gateway │ │ Agents │
│ (Web UI :3000) │ │ (Orchestrator) │ │ (Docker Svc) │
│ React + tRPC │ │ Go + gRPC │ │ Coder/Browser │
└────────────────┘ └─────────────────┘ │ Mail/Monitor │
│ └─────────────────┘
┌───────────▼──────────┐
│ Ollama Cloud API │
│ (34 LLM Models) │
└──────────────────────┘
```
---
## Разработка
```bash
# Запустить тесты
pnpm test
# Проверить типы
pnpm check
# Форматировать код
pnpm format
# Применить миграции БД
pnpm db:push
```
---
## Дорожная карта
- [x] Dashboard с мониторингом кластера
- [x] Реальная интеграция Ollama API (34 модели)
- [x] Терминальный чат с LLM
- [x] Docker Swarm Stack
- [x] gRPC API для агентов (Go)
- [ ] Стриминг ответов LLM в чате
- [ ] CRUD агентов через UI
- [ ] Подключение реального Docker API для нод
- [ ] Telegram-коннектор
- [ ] Система скиллов (Self-Evolution)
- [ ] Аутентификация и RBAC
---
## Лицензия
MIT — используйте свободно для личных и коммерческих проектов.
---
*Разработано в рамках проекта **GoClaw** — распределённой системы AI-агентов на Go + Docker Swarm.*

View File

@@ -0,0 +1,91 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AgentCreateModal } from "./AgentCreateModal";
// Mock trpc
vi.mock("@/lib/trpc", () => ({
trpc: {
ollama: {
models: {
useQuery: () => ({
data: {
models: [
{ id: "deepseek-v3.2", provider: "Ollama" },
{ id: "gpt-4", provider: "OpenAI" },
],
},
isLoading: false,
}),
},
},
agents: {
create: {
useMutation: () => ({
mutateAsync: vi.fn().mockResolvedValue({}),
isPending: false,
}),
},
},
},
}));
describe("AgentCreateModal", () => {
it("should render create modal when open is true", () => {
render(
<AgentCreateModal
open={true}
onOpenChange={() => {}}
/>
);
expect(screen.getByText(/Deploy New Agent/)).toBeInTheDocument();
});
it("should have required form fields", () => {
render(
<AgentCreateModal
open={true}
onOpenChange={() => {}}
/>
);
expect(screen.getByLabelText(/Agent Name/)).toBeInTheDocument();
expect(screen.getByLabelText(/Description/)).toBeInTheDocument();
expect(screen.getByLabelText(/Role/)).toBeInTheDocument();
expect(screen.getByLabelText(/Provider/)).toBeInTheDocument();
expect(screen.getByLabelText(/Model/)).toBeInTheDocument();
});
it("should have LLM parameter controls", () => {
render(
<AgentCreateModal
open={true}
onOpenChange={() => {}}
/>
);
expect(screen.getByLabelText(/Temperature/)).toBeInTheDocument();
expect(screen.getByLabelText(/Max Tokens/)).toBeInTheDocument();
expect(screen.getByLabelText(/System Prompt/)).toBeInTheDocument();
});
it("should disable create button when name is empty", () => {
render(
<AgentCreateModal
open={true}
onOpenChange={() => {}}
/>
);
const createButton = screen.getByRole("button", { name: /Create Agent/i });
expect(createButton).toBeDisabled();
});
it("should have cancel and create buttons", () => {
render(
<AgentCreateModal
open={true}
onOpenChange={() => {}}
/>
);
expect(screen.getByRole("button", { name: /Cancel/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Create Agent/i })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,257 @@
import { useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";
import { Loader2, Plus } from "lucide-react";
interface AgentCreateModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
}
const AGENT_ROLES = [
{ value: "developer", label: "Developer - Code generation & testing" },
{ value: "researcher", label: "Researcher - Data analysis & research" },
{ value: "executor", label: "Executor - Task automation" },
{ value: "monitor", label: "Monitor - System monitoring" },
];
const PROVIDERS = [
{ value: "Ollama", label: "Ollama (Local/Cloud)" },
{ value: "OpenAI", label: "OpenAI (GPT)" },
{ value: "Anthropic", label: "Anthropic (Claude)" },
];
export function AgentCreateModal({ open, onOpenChange, onSuccess }: AgentCreateModalProps) {
const [formData, setFormData] = useState({
name: "",
description: "",
role: "developer",
provider: "Ollama",
model: "deepseek-v3.2",
temperature: 0.7,
maxTokens: 2048,
systemPrompt: "",
});
const [isLoading, setIsLoading] = useState(false);
const { data: models } = trpc.ollama.models.useQuery();
const createMutation = trpc.agents.create.useMutation({
onSuccess: () => {
toast.success("Agent created successfully");
setFormData({
name: "",
description: "",
role: "developer",
provider: "Ollama",
model: "deepseek-v3.2",
temperature: 0.7,
maxTokens: 2048,
systemPrompt: "",
});
onOpenChange(false);
onSuccess?.();
},
onError: (error) => {
toast.error(`Failed to create agent: ${error.message}`);
},
});
const handleCreate = async () => {
if (!formData.name.trim()) {
toast.error("Agent name is required");
return;
}
setIsLoading(true);
try {
await createMutation.mutateAsync({
name: formData.name,
description: formData.description,
role: formData.role,
provider: formData.provider,
model: formData.model,
temperature: formData.temperature,
maxTokens: formData.maxTokens,
systemPrompt: formData.systemPrompt,
allowedTools: [],
});
} finally {
setIsLoading(false);
}
};
const availableModels = models?.models
?.filter((m: any) => m.provider === formData.provider || formData.provider === "Ollama")
.map((m: any) => m.id) || [];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Deploy New Agent</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Basic Info */}
<div className="space-y-2">
<Label htmlFor="name">Agent Name *</Label>
<Input
id="name"
placeholder="e.g., Code Reviewer Agent"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="font-mono"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="What is this agent responsible for?"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2}
className="font-mono text-sm"
/>
</div>
{/* Role & Provider */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="role">Role *</Label>
<Select value={formData.role} onValueChange={(value) => setFormData({ ...formData, role: value })}>
<SelectTrigger id="role" className="font-mono">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AGENT_ROLES.map((role) => (
<SelectItem key={role.value} value={role.value}>
{role.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="provider">Provider *</Label>
<Select value={formData.provider} onValueChange={(value) => setFormData({ ...formData, provider: value, model: "" })}>
<SelectTrigger id="provider" className="font-mono">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROVIDERS.map((provider) => (
<SelectItem key={provider.value} value={provider.value}>
{provider.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Model Selection */}
<div className="space-y-2">
<Label htmlFor="model">Model *</Label>
<Select value={formData.model} onValueChange={(value) => setFormData({ ...formData, model: value })}>
<SelectTrigger id="model" className="font-mono">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableModels.length > 0 ? (
availableModels.map((model: string) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))
) : (
<SelectItem value="default" disabled>
No models available
</SelectItem>
)}
</SelectContent>
</Select>
</div>
{/* LLM Parameters */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="temperature">
Temperature: {formData.temperature.toFixed(2)}
</Label>
<Input
id="temperature"
type="range"
min="0"
max="2"
step="0.01"
value={formData.temperature}
onChange={(e) => setFormData({ ...formData, temperature: parseFloat(e.target.value) })}
className="cursor-pointer"
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxTokens">Max Tokens</Label>
<Input
id="maxTokens"
type="number"
value={formData.maxTokens}
onChange={(e) => setFormData({ ...formData, maxTokens: parseInt(e.target.value) })}
className="font-mono"
/>
</div>
</div>
{/* System Prompt */}
<div className="space-y-2">
<Label htmlFor="systemPrompt">System Prompt</Label>
<Textarea
id="systemPrompt"
placeholder="Define the agent's behavior and instructions..."
value={formData.systemPrompt}
onChange={(e) => setFormData({ ...formData, systemPrompt: e.target.value })}
rows={4}
className="font-mono text-sm"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={isLoading || createMutation.isPending || !formData.name.trim()}>
{isLoading || createMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
Create Agent
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,89 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AgentDetailModal } from "./AgentDetailModal";
// Mock trpc
vi.mock("@/lib/trpc", () => ({
trpc: {
agents: {
update: {
useMutation: () => ({
mutateAsync: vi.fn().mockResolvedValue({}),
isPending: false,
}),
},
},
},
}));
describe("AgentDetailModal", () => {
const mockAgent = {
id: 1,
name: "Test Agent",
description: "Test Description",
role: "developer",
model: "gpt-4",
provider: "OpenAI",
temperature: "0.7",
maxTokens: 2048,
topP: "1.0",
frequencyPenalty: "0.0",
presencePenalty: "0.0",
systemPrompt: "You are a helpful assistant",
allowedTools: ["browser", "shell"],
allowedDomains: ["example.com"],
maxRequestsPerHour: 100,
isActive: true,
tags: ["test"],
createdAt: new Date(),
updatedAt: new Date(),
};
it("should not render when agent is null", () => {
const { container } = render(
<AgentDetailModal
agent={null}
open={true}
onOpenChange={() => {}}
/>
);
expect(container.firstChild).toBeNull();
});
it("should render modal when agent is provided and open is true", () => {
render(
<AgentDetailModal
agent={mockAgent}
open={true}
onOpenChange={() => {}}
/>
);
expect(screen.getByText(/Agent Configuration:/)).toBeInTheDocument();
});
it("should display agent name in title", () => {
render(
<AgentDetailModal
agent={mockAgent}
open={true}
onOpenChange={() => {}}
/>
);
expect(screen.getByText(/Test Agent/)).toBeInTheDocument();
});
it("should have tabs for different sections", () => {
render(
<AgentDetailModal
agent={mockAgent}
open={true}
onOpenChange={() => {}}
/>
);
expect(screen.getByRole("tab", { name: /General/i })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: /LLM/i })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: /Tools/i })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: /Info/i })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,383 @@
import { useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";
import { Loader2, Save, X } from "lucide-react";
interface Agent {
id: number;
name: string;
description: string | null;
role: string;
model: string;
provider: string;
temperature: string | number;
maxTokens: number;
topP: string | number;
frequencyPenalty: string | number;
presencePenalty: string | number;
systemPrompt: string | null;
allowedTools: string[];
allowedDomains: string[];
maxRequestsPerHour: number;
isActive: boolean;
tags: string[];
createdAt: Date;
updatedAt: Date;
}
interface AgentDetailModalProps {
agent: Agent | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSave?: () => void;
}
export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDetailModalProps) {
const [formData, setFormData] = useState<Partial<Agent>>(agent ? { ...agent } : {});
const [isLoading, setIsLoading] = useState(false);
const updateMutation = trpc.agents.update.useMutation({
onSuccess: () => {
toast.success("Agent configuration updated");
onOpenChange(false);
onSave?.();
},
onError: (error) => {
toast.error(`Failed to update agent: ${error.message}`);
},
});
const handleSave = async () => {
if (!agent) return;
setIsLoading(true);
try {
await updateMutation.mutateAsync({
id: agent.id,
name: formData.name || agent.name,
description: formData.description || undefined,
temperature: typeof formData.temperature === "string" ? parseFloat(formData.temperature) : (formData.temperature || 0.7),
maxTokens: formData.maxTokens || 2048,
systemPrompt: formData.systemPrompt || undefined,
isActive: formData.isActive !== undefined ? formData.isActive : true,
});
} finally {
setIsLoading(false);
}
};
if (!agent) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
<span>Agent Configuration: {agent.name}</span>
<Button
variant="ghost"
size="sm"
onClick={() => onOpenChange(false)}
className="h-6 w-6 p-0"
>
<X className="w-4 h-4" />
</Button>
</DialogTitle>
</DialogHeader>
<Tabs defaultValue="general" className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="llm">LLM</TabsTrigger>
<TabsTrigger value="tools">Tools</TabsTrigger>
<TabsTrigger value="info">Info</TabsTrigger>
</TabsList>
{/* General Tab */}
<TabsContent value="general" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Agent Name</Label>
<Input
id="name"
value={formData.name ?? ""}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="font-mono"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description ?? ""}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
className="font-mono text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Input
id="role"
value={formData.role || ""}
disabled
className="font-mono bg-muted"
/>
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<div className="flex items-center gap-2">
<Badge variant={formData.isActive ? "default" : "secondary"}>
{formData.isActive ? "Active" : "Inactive"}
</Badge>
<Button
size="sm"
variant="outline"
onClick={() => setFormData({ ...formData, isActive: !formData.isActive })}
>
Toggle
</Button>
</div>
</div>
</div>
</TabsContent>
{/* LLM Tab */}
<TabsContent value="llm" className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="model">Model</Label>
<Input
id="model"
value={formData.model || ""}
disabled
className="font-mono bg-muted"
/>
</div>
<div className="space-y-2">
<Label htmlFor="provider">Provider</Label>
<Input
id="provider"
value={formData.provider || ""}
disabled
className="font-mono bg-muted"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="temperature">
Temperature: {typeof formData.temperature === "string" ? parseFloat(formData.temperature).toFixed(2) : formData.temperature?.toFixed(2)}
</Label>
<Input
id="temperature"
type="range"
min="0"
max="2"
step="0.01"
value={typeof formData.temperature === "string" ? parseFloat(formData.temperature) : formData.temperature || 0.7}
onChange={(e) => setFormData({ ...formData, temperature: parseFloat(e.target.value) })}
className="cursor-pointer"
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxTokens">Max Tokens</Label>
<Input
id="maxTokens"
type="number"
value={formData.maxTokens || 2048}
onChange={(e) => setFormData({ ...formData, maxTokens: parseInt(e.target.value) })}
className="font-mono"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="topP">
Top P: {typeof formData.topP === "string" ? parseFloat(formData.topP).toFixed(2) : formData.topP?.toFixed(2)}
</Label>
<Input
id="topP"
type="range"
min="0"
max="1"
step="0.01"
value={typeof formData.topP === "string" ? parseFloat(formData.topP) : formData.topP || 1.0}
onChange={(e) => setFormData({ ...formData, topP: parseFloat(e.target.value) })}
className="cursor-pointer"
/>
</div>
<div className="space-y-2">
<Label htmlFor="frequencyPenalty">
Freq Penalty: {typeof formData.frequencyPenalty === "string" ? parseFloat(formData.frequencyPenalty).toFixed(2) : formData.frequencyPenalty?.toFixed(2)}
</Label>
<Input
id="frequencyPenalty"
type="range"
min="-2"
max="2"
step="0.01"
value={typeof formData.frequencyPenalty === "string" ? parseFloat(formData.frequencyPenalty) : formData.frequencyPenalty || 0}
onChange={(e) => setFormData({ ...formData, frequencyPenalty: parseFloat(e.target.value) })}
className="cursor-pointer"
/>
</div>
<div className="space-y-2">
<Label htmlFor="presencePenalty">
Pres Penalty: {typeof formData.presencePenalty === "string" ? parseFloat(formData.presencePenalty).toFixed(2) : formData.presencePenalty?.toFixed(2)}
</Label>
<Input
id="presencePenalty"
type="range"
min="-2"
max="2"
step="0.01"
value={typeof formData.presencePenalty === "string" ? parseFloat(formData.presencePenalty) : formData.presencePenalty || 0}
onChange={(e) => setFormData({ ...formData, presencePenalty: parseFloat(e.target.value) })}
className="cursor-pointer"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="systemPrompt">System Prompt</Label>
<Textarea
id="systemPrompt"
value={formData.systemPrompt || ""}
onChange={(e) => setFormData({ ...formData, systemPrompt: e.target.value })}
rows={5}
className="font-mono text-sm"
placeholder="Enter the system prompt that defines agent behavior..."
/>
</div>
</TabsContent>
{/* Tools Tab */}
<TabsContent value="tools" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-sm">Allowed Tools</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{formData.allowedTools && formData.allowedTools.length > 0 ? (
formData.allowedTools.map((tool) => (
<Badge key={tool} variant="secondary">
{tool}
</Badge>
))
) : (
<p className="text-sm text-muted-foreground">No tools assigned</p>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">Allowed Domains</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{formData.allowedDomains && formData.allowedDomains.length > 0 ? (
formData.allowedDomains.map((domain) => (
<Badge key={domain} variant="outline">
{domain}
</Badge>
))
) : (
<p className="text-sm text-muted-foreground">No domain restrictions</p>
)}
</div>
</CardContent>
</Card>
<div className="space-y-2">
<Label htmlFor="maxRequestsPerHour">Max Requests Per Hour</Label>
<Input
id="maxRequestsPerHour"
type="number"
value={formData.maxRequestsPerHour || 100}
disabled
className="font-mono bg-muted"
/>
</div>
</TabsContent>
{/* Info Tab */}
<TabsContent value="info" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-sm">Metadata</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm font-mono">
<div className="flex justify-between">
<span className="text-muted-foreground">Created:</span>
<span>{new Date(agent.createdAt).toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Updated:</span>
<span>{new Date(agent.updatedAt).toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Agent ID:</span>
<span>{agent.id}</span>
</div>
</div>
</CardContent>
</Card>
{formData.tags && formData.tags.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm">Tags</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{formData.tags.map((tag) => (
<Badge key={tag} variant="outline">
{tag}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isLoading || updateMutation.isPending}>
{isLoading || updateMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Save Changes
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -8,6 +8,15 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Bot,
Play,
@@ -24,95 +33,22 @@ import {
Mail,
FileText,
Eye,
Loader2,
AlertCircle,
} from "lucide-react";
import { motion } from "framer-motion";
import { useState } from "react";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
import { AgentDetailModal } from "@/components/AgentDetailModal";
import { AgentCreateModal } from "@/components/AgentCreateModal";
const AGENT_CARD_BG = "https://d2xsxph8kpxj0f.cloudfront.net/97147719/ZEGAT83geRq9CNvryykaQv/agent-card-bg-4pCQxardRUWZ77WDF6yvS3.webp";
const AGENTS_DATA = [
{
id: "agent-coder",
name: "Coder Agent",
description: "Пишет и тестирует Go-модули, рефакторит код, создаёт новые скиллы",
model: "claude-3.5-sonnet",
provider: "Anthropic",
status: "running",
icon: Terminal,
tasks_completed: 147,
tasks_active: 3,
cpu: 45,
mem: 512,
uptime: "2d 14h 23m",
skills: ["go_compiler", "git_ops", "test_runner", "code_review"],
node: "goclaw-worker-01",
},
{
id: "agent-browser",
name: "Browser Agent",
description: "Управляет Chromium, выполняет веб-скрапинг и автоматизацию",
model: "gpt-4o",
provider: "OpenAI",
status: "running",
icon: Globe,
tasks_completed: 89,
tasks_active: 1,
cpu: 62,
mem: 1024,
uptime: "2d 14h 23m",
skills: ["chromedp", "screenshot", "form_fill", "web_scrape"],
node: "goclaw-worker-01",
},
{
id: "agent-mail",
name: "Mail Agent",
description: "Мониторит почту, классифицирует письма, отправляет ответы",
model: "gpt-4o-mini",
provider: "OpenAI",
status: "idle",
icon: Mail,
tasks_completed: 234,
tasks_active: 0,
cpu: 2,
mem: 128,
uptime: "2d 14h 23m",
skills: ["imap_reader", "smtp_sender", "email_classifier"],
node: "goclaw-worker-02",
},
{
id: "agent-monitor",
name: "Monitor Agent",
description: "Следит за состоянием кластера, логами и метриками",
model: "llama-3.1-8b",
provider: "Ollama (local)",
status: "running",
icon: Eye,
tasks_completed: 1205,
tasks_active: 5,
cpu: 8,
mem: 256,
uptime: "2d 14h 23m",
skills: ["log_parser", "metric_collector", "alert_sender"],
node: "goclaw-manager-01",
},
{
id: "agent-docs",
name: "Docs Agent",
description: "Работает с документами: PDF, DOCX, создаёт отчёты",
model: "claude-3-haiku",
provider: "Anthropic",
status: "error",
icon: FileText,
tasks_completed: 56,
tasks_active: 0,
cpu: 0,
mem: 0,
uptime: "0h 0m",
skills: ["pdf_parser", "docx_writer", "summarizer"],
node: "goclaw-worker-02",
},
];
const ROLE_ICONS: Record<string, any> = {
developer: Terminal,
researcher: Brain,
executor: Zap,
monitor: Eye,
};
function getStatusConfig(status: string) {
switch (status) {
@@ -128,7 +64,61 @@ function getStatusConfig(status: string) {
}
export default function Agents() {
const [agents] = useState(AGENTS_DATA);
const [selectedAgent, setSelectedAgent] = useState<any>(null);
const [detailModalOpen, setDetailModalOpen] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [agentToDelete, setAgentToDelete] = useState<number | null>(null);
// Fetch agents from database
const { data: agents = [], isLoading, refetch } = trpc.agents.list.useQuery();
const deleteMutation = trpc.agents.delete.useMutation({
onSuccess: () => {
toast.success("Agent deleted successfully");
setDeleteConfirmOpen(false);
setAgentToDelete(null);
refetch();
},
onError: (error) => {
toast.error(`Failed to delete agent: ${error.message}`);
},
});
const handleDeleteClick = (agentId: number) => {
setAgentToDelete(agentId);
setDeleteConfirmOpen(true);
};
const handleConfirmDelete = async () => {
if (agentToDelete) {
await deleteMutation.mutateAsync({ id: agentToDelete });
}
};
const handleEditAgent = (agent: any) => {
setSelectedAgent(agent);
setDetailModalOpen(true);
};
const handleCreateSuccess = () => {
refetch();
};
const handleDetailSave = () => {
refetch();
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-2 text-primary" />
<p className="text-muted-foreground">Loading agents...</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
@@ -137,130 +127,197 @@ export default function Agents() {
<div>
<h2 className="text-xl font-bold text-foreground">Agent Fleet</h2>
<p className="text-sm text-muted-foreground font-mono mt-1">
{agents.filter(a => a.status === "running").length} running &middot; {agents.filter(a => a.status === "idle").length} idle &middot; {agents.filter(a => a.status === "error").length} error
{agents.length} total agents
</p>
</div>
<Button
size="sm"
className="bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25"
onClick={() => toast("Feature coming soon")}
onClick={() => setCreateModalOpen(true)}
>
<Plus className="w-4 h-4 mr-2" />
Deploy Agent
</Button>
</div>
{/* Agent cards grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{agents.map((agent, i) => {
const sc = getStatusConfig(agent.status);
const Icon = agent.icon;
return (
<motion.div
key={agent.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.08 }}
{/* Empty State */}
{agents.length === 0 ? (
<Card className="bg-card border-border/50">
<CardContent className="p-12 text-center">
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" />
<h3 className="text-lg font-semibold text-foreground mb-2">No Agents Deployed</h3>
<p className="text-sm text-muted-foreground mb-6">
Start by deploying your first AI agent to the cluster.
</p>
<Button
onClick={() => setCreateModalOpen(true)}
className="bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25"
>
<Card className={`bg-card border-border/50 hover:border-primary/30 transition-all ${sc.glow}`}>
<CardContent className="p-5">
{/* Top row */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-secondary/50 border border-border/50 flex items-center justify-center">
<Icon className={`w-5 h-5 ${sc.color}`} />
</div>
<div>
<h3 className="text-sm font-semibold text-foreground">{agent.name}</h3>
<p className="text-[11px] text-muted-foreground mt-0.5">{agent.description}</p>
</div>
</div>
<Badge variant="outline" className={`text-[10px] font-mono ${sc.badge}`}>
<span className={`w-1.5 h-1.5 rounded-full ${sc.bg} mr-1.5 ${agent.status === "running" ? "pulse-indicator" : ""}`} />
{agent.status.toUpperCase()}
</Badge>
</div>
<Plus className="w-4 h-4 mr-2" />
Deploy First Agent
</Button>
</CardContent>
</Card>
) : (
/* Agent cards grid */
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{agents.map((agent: any, i: number) => {
const sc = getStatusConfig(agent.status || "idle");
const Icon = ROLE_ICONS[agent.role] || Bot;
const temperature = typeof agent.temperature === "string" ? parseFloat(agent.temperature) : agent.temperature;
{/* Model & Node info */}
<div className="grid grid-cols-2 gap-3 mb-4">
<div className="p-2.5 rounded-md bg-secondary/30 border border-border/20">
<div className="flex items-center gap-1.5 mb-1">
<Brain className="w-3 h-3 text-primary" />
<span className="text-[10px] text-muted-foreground font-mono">MODEL</span>
return (
<motion.div
key={agent.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.08 }}
>
<Card className={`bg-card border-border/50 hover:border-primary/30 transition-all cursor-pointer ${sc.glow}`} onClick={() => handleEditAgent(agent)}>
<CardContent className="p-5">
{/* Top row */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-secondary/50 border border-border/50 flex items-center justify-center">
<Icon className={`w-5 h-5 ${sc.color}`} />
</div>
<div>
<h3 className="text-sm font-semibold text-foreground">{agent.name}</h3>
<p className="text-[11px] text-muted-foreground mt-0.5">{agent.description || "No description"}</p>
</div>
</div>
<div className="text-xs font-mono font-medium text-primary">{agent.model}</div>
<div className="text-[10px] font-mono text-muted-foreground">{agent.provider}</div>
<Badge variant="outline" className={`text-[10px] font-mono ${sc.badge}`}>
<span className={`w-1.5 h-1.5 rounded-full ${sc.bg} mr-1.5 ${agent.isActive ? "pulse-indicator" : ""}`} />
{agent.isActive ? "ACTIVE" : "INACTIVE"}
</Badge>
</div>
<div className="p-2.5 rounded-md bg-secondary/30 border border-border/20">
<div className="flex items-center gap-1.5 mb-1">
<Cpu className="w-3 h-3 text-primary" />
<span className="text-[10px] text-muted-foreground font-mono">NODE</span>
{/* Model & Node info */}
<div className="grid grid-cols-2 gap-3 mb-4">
<div className="p-2.5 rounded-md bg-secondary/30 border border-border/20">
<div className="flex items-center gap-1.5 mb-1">
<Brain className="w-3 h-3 text-primary" />
<span className="text-[10px] text-muted-foreground font-mono">MODEL</span>
</div>
<div className="text-xs font-mono font-medium text-primary">{agent.model}</div>
<div className="text-[10px] font-mono text-muted-foreground">{agent.provider}</div>
</div>
<div className="text-xs font-mono font-medium text-foreground">{agent.node}</div>
<div className="text-[10px] font-mono text-muted-foreground">
CPU: {agent.cpu}% &middot; MEM: {agent.mem}MB
<div className="p-2.5 rounded-md bg-secondary/30 border border-border/20">
<div className="flex items-center gap-1.5 mb-1">
<Cpu className="w-3 h-3 text-primary" />
<span className="text-[10px] text-muted-foreground font-mono">CONFIG</span>
</div>
<div className="text-xs font-mono font-medium text-foreground">T: {temperature.toFixed(2)}</div>
<div className="text-[10px] font-mono text-muted-foreground">Tokens: {agent.maxTokens}</div>
</div>
</div>
</div>
{/* Metrics row */}
<div className="flex items-center gap-4 mb-4 text-[10px] font-mono">
<div className="flex items-center gap-1">
<Zap className="w-3 h-3 text-neon-amber" />
<span className="text-muted-foreground">Tasks:</span>
<span className="text-foreground font-medium">{agent.tasks_completed}</span>
{/* Metrics row */}
<div className="flex items-center gap-4 mb-4 text-[10px] font-mono">
<div className="flex items-center gap-1">
<Zap className="w-3 h-3 text-neon-amber" />
<span className="text-muted-foreground">Role:</span>
<span className="text-foreground font-medium capitalize">{agent.role}</span>
</div>
<div className="flex items-center gap-1">
<Clock className="w-3 h-3 text-primary" />
<span className="text-muted-foreground">Created:</span>
<span className="text-foreground font-medium">{new Date(agent.createdAt).toLocaleDateString()}</span>
</div>
</div>
<div className="flex items-center gap-1">
<Clock className="w-3 h-3 text-primary" />
<span className="text-muted-foreground">Uptime:</span>
<span className="text-foreground font-medium">{agent.uptime}</span>
</div>
<div className="flex items-center gap-1">
<Play className="w-3 h-3 text-neon-green" />
<span className="text-muted-foreground">Active:</span>
<span className="text-neon-green font-medium">{agent.tasks_active}</span>
</div>
</div>
{/* Skills */}
<div className="mb-4">
<span className="text-[10px] text-muted-foreground font-mono block mb-1.5">SKILLS</span>
<div className="flex flex-wrap gap-1.5">
{agent.skills.map((skill) => (
<span
key={skill}
className="px-2 py-0.5 rounded text-[10px] font-mono bg-primary/10 text-primary border border-primary/20"
>
{skill}
</span>
))}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 pt-3 border-t border-border/30">
{agent.status === "running" ? (
<Button size="sm" variant="outline" className="h-7 text-[11px] text-neon-amber border-neon-amber/30 hover:bg-neon-amber/10" onClick={() => toast("Feature coming soon")}>
<Pause className="w-3 h-3 mr-1" /> Pause
</Button>
) : (
<Button size="sm" variant="outline" className="h-7 text-[11px] text-neon-green border-neon-green/30 hover:bg-neon-green/10" onClick={() => toast("Feature coming soon")}>
<Play className="w-3 h-3 mr-1" /> Start
</Button>
{/* Tools */}
{agent.allowedTools && agent.allowedTools.length > 0 && (
<div className="mb-4">
<span className="text-[10px] text-muted-foreground font-mono block mb-1.5">TOOLS</span>
<div className="flex flex-wrap gap-1.5">
{agent.allowedTools.map((tool: string) => (
<span
key={tool}
className="px-2 py-0.5 rounded text-[10px] font-mono bg-primary/10 text-primary border border-primary/20"
>
{tool}
</span>
))}
</div>
</div>
)}
<Button size="sm" variant="outline" className="h-7 text-[11px] text-primary border-primary/30 hover:bg-primary/10" onClick={() => toast("Feature coming soon")}>
<RotateCcw className="w-3 h-3 mr-1" /> Restart
</Button>
<Button size="sm" variant="outline" className="h-7 text-[11px] text-neon-red border-neon-red/30 hover:bg-neon-red/10 ml-auto" onClick={() => toast("Feature coming soon")}>
<Trash2 className="w-3 h-3 mr-1" /> Remove
</Button>
</div>
</CardContent>
</Card>
</motion.div>
);
})}
</div>
{/* Actions */}
<div className="flex items-center gap-2 pt-3 border-t border-border/30">
<Button
size="sm"
variant="outline"
className="h-7 text-[11px] text-primary border-primary/30 hover:bg-primary/10"
onClick={(e) => {
e.stopPropagation();
handleEditAgent(agent);
}}
>
Edit
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-[11px] text-neon-red border-neon-red/30 hover:bg-neon-red/10 ml-auto"
onClick={(e) => {
e.stopPropagation();
handleDeleteClick(agent.id);
}}
>
<Trash2 className="w-3 h-3 mr-1" /> Delete
</Button>
</div>
</CardContent>
</Card>
</motion.div>
);
})}
</div>
)}
{/* Modals */}
<AgentDetailModal
agent={selectedAgent}
open={detailModalOpen}
onOpenChange={setDetailModalOpen}
onSave={handleDetailSave}
/>
<AgentCreateModal
open={createModalOpen}
onOpenChange={setCreateModalOpen}
onSuccess={handleCreateSuccess}
/>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Agent</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this agent? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex gap-3">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
disabled={deleteMutation.isPending}
className="bg-neon-red hover:bg-neon-red/90"
>
{deleteMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Deleting...
</>
) : (
"Delete"
)}
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,322 @@
# GoClaw Tool Binding Architecture
> **Система предоставления LLM доступа к реальным инструментам через структурированный интерфейс Function Calling.**
---
## Проблема
Текущая архитектура Chat позволяет LLM только **генерировать текст**, но не **выполнять действия**. LLM не может:
- Открыть браузер и посетить сайт
- Выполнить команду в терминале
- Создать или отредактировать файл
- Вызвать Docker API для управления контейнерами
- Отправить HTTP-запрос
Это делает агента бесполезным для реальных задач.
---
## Решение: Tool Binding (Function Calling)
Архитектура состоит из **трёх слоёв**:
```
┌─────────────────────────────────────────────────────────┐
│ Layer 1: LLM Chat Interface (React) │
│ - Пользователь пишет команду │
│ - LLM получает список доступных инструментов │
│ - LLM генерирует JSON с вызовом инструмента │
└──────────────────────┬──────────────────────────────────┘
│ (JSON with tool_call)
┌──────────────────────▼──────────────────────────────────┐
│ Layer 2: Tool Binding Engine (Node.js/tRPC) │
│ - Парсит tool_call из LLM ответа │
│ - Валидирует параметры по JSON Schema │
│ - Маршрутизирует на Tool Executor │
│ - Возвращает результат LLM для следующего хода │
└──────────────────────┬──────────────────────────────────┘
│ (execute tool)
┌──────────────────────▼──────────────────────────────────┐
│ Layer 3: Tool Executor (Go/Node.js/Docker) │
│ - Browser Tool (Puppeteer/Playwright) │
│ - Shell Tool (exec, bash commands) │
│ - File Tool (read, write, delete) │
│ - Docker Tool (Docker SDK) │
│ - HTTP Tool (fetch, POST, etc) │
└─────────────────────────────────────────────────────────┘
```
---
## Доступные инструменты (Tools Registry)
| Инструмент | Функции | Параметры | Пример |
| :--- | :--- | :--- | :--- |
| **browser** | open, screenshot, click, type, wait | url, selector, text, timeout | `{"tool": "browser", "action": "open", "url": "https://..."}` |
| **shell** | exec, bash | command, timeout, cwd | `{"tool": "shell", "command": "ls -la /home"}` |
| **file** | read, write, delete, list | path, content, mode | `{"tool": "file", "action": "read", "path": "/etc/hosts"}` |
| **docker** | list_containers, inspect, exec, logs | container_id, command | `{"tool": "docker", "action": "list_containers"}` |
| **http** | GET, POST, PUT, DELETE | url, method, headers, body | `{"tool": "http", "method": "POST", "url": "..."}` |
---
## Формат Tool Call (Function Calling)
LLM генерирует JSON-ответ с вызовом инструмента:
```json
{
"type": "tool_call",
"tool": "browser",
"action": "open",
"params": {
"url": "https://github.com/UniqAI/GoClaw",
"timeout": 10000
},
"id": "call_123"
}
```
Или несколько инструментов в цепи:
```json
{
"type": "tool_use",
"tools": [
{
"tool": "shell",
"command": "docker ps --format json",
"id": "call_1"
},
{
"tool": "http",
"method": "POST",
"url": "http://gateway:18789/api/agents",
"body": {"name": "web-scraper", "model": "gpt-4o"},
"id": "call_2"
}
]
}
```
---
## System Prompt для LLM с Tool Binding
```
You are GoClaw Agent — an autonomous AI agent with access to real tools.
Available tools:
1. browser — Open URLs, take screenshots, interact with web pages
2. shell — Execute bash commands
3. file — Read/write/delete files
4. docker — Manage Docker containers
5. http — Make HTTP requests
When you need to perform an action, respond with a JSON tool_call:
{
"type": "tool_call",
"tool": "browser|shell|file|docker|http",
"action": "...",
"params": {...},
"id": "call_123"
}
After each tool execution, you'll receive the result and can make follow-up calls.
Always explain what you're doing and why.
```
---
## Архитектура Tool Executor (Go)
```go
// server/tools/executor.go
type ToolExecutor interface {
Execute(ctx context.Context, call ToolCall) (ToolResult, error)
}
type ToolCall struct {
Tool string `json:"tool"`
Action string `json:"action"`
Params map[string]interface{} `json:"params"`
ID string `json:"id"`
}
type ToolResult struct {
ID string `json:"id"`
Success bool `json:"success"`
Output interface{} `json:"output"`
Error string `json:"error,omitempty"`
Duration int64 `json:"duration_ms"`
}
// Реализации:
// - BrowserExecutor (Puppeteer/Playwright)
// - ShellExecutor (os/exec)
// - FileExecutor (os, ioutil)
// - DockerExecutor (docker/docker-go SDK)
// - HTTPExecutor (net/http)
```
---
## Интеграция в Chat (tRPC)
```typescript
// server/routers.ts
ollama: router({
chat: protectedProcedure
.input(z.object({
messages: z.array(MessageSchema),
model: z.string(),
tools: z.boolean().optional(),
}))
.mutation(async ({ input }) => {
// 1. Отправляем сообщение в LLM с описанием инструментов
const response = await chatCompletion(input.model, input.messages, {
tools: input.tools ? AVAILABLE_TOOLS : undefined,
});
// 2. Проверяем, есть ли tool_call в ответе
if (response.tool_call) {
// 3. Выполняем инструмент
const result = await toolExecutor.execute(response.tool_call);
// 4. Добавляем результат в контекст и делаем второй запрос к LLM
const finalResponse = await chatCompletion(input.model, [
...input.messages,
{ role: "assistant", content: JSON.stringify(response.tool_call) },
{ role: "tool", content: JSON.stringify(result) },
]);
return finalResponse;
}
return response;
}),
}),
```
---
## UI: Tools Manager
Новая страница в Control Center для управления инструментами:
```
┌─────────────────────────────────────────────┐
│ Tools Manager │
├─────────────────────────────────────────────┤
│ Available Tools: │
│ │
│ ✅ Browser Tool │
│ - Timeout: 30s │
│ - Max screenshots: 10 │
│ - Allowed domains: *.github.com, ... │
│ │
│ ✅ Shell Tool │
│ - Timeout: 60s │
│ - Allowed commands: ls, cat, grep, ... │
│ - Blocked commands: rm -rf, ... │
│ │
│ ✅ File Tool │
│ - Allowed paths: /home/goclaw, /tmp │
│ - Max file size: 10MB │
│ │
│ ✅ Docker Tool │
│ - Socket: /var/run/docker.sock │
│ - Allowed operations: list, inspect │
│ │
│ ✅ HTTP Tool │
│ - Timeout: 30s │
│ - Allowed hosts: *.api.example.com │
│ │
├─────────────────────────────────────────────┤
│ [Test Tool] [Edit] [Disable] │
└─────────────────────────────────────────────┘
```
---
## Безопасность и Изоляция
**Проблемы:**
- LLM может попытаться выполнить опасные команды (`rm -rf /`)
- Может получить доступ к чувствительным файлам
- Может перегрузить систему бесконечными запросами
**Решение:**
1. **Whitelist/Blacklist:** Каждый инструмент имеет список разрешённых/запрещённых операций
2. **Sandbox:** Shell команды выполняются в Docker контейнере с ограничениями ресурсов
3. **Timeout:** Все операции имеют таймаут (30-60 секунд)
4. **Rate Limiting:** Максимум 10 tool_call за одну сессию
5. **Logging:** Все вызовы инструментов логируются для аудита
---
## Дорожная карта реализации
- [ ] Спецификация Tool Call JSON Schema
- [ ] Реализация Tool Executor на Go
- [ ] Интеграция в tRPC роутер
- [ ] System Prompt с инструментами для LLM
- [ ] UI: Tools Manager страница
- [ ] Безопасность: Whitelist/Blacklist
- [ ] Тестирование: vitest + e2e
- [ ] Документация и примеры
---
## Примеры использования
### Пример 1: Открыть GitHub и скопировать README
```
User: "Открой https://github.com/UniqAI/GoClaw и скопируй содержимое README.md"
LLM:
1. tool_call: browser.open(url="https://github.com/UniqAI/GoClaw")
2. tool_call: browser.screenshot()
3. tool_call: browser.click(selector="a[href*=README]")
4. tool_call: file.write(path="/tmp/readme.md", content="...")
```
### Пример 2: Создать Docker контейнер и выполнить команду
```
User: "Создай контейнер с nginx и проверь статус"
LLM:
1. tool_call: docker.exec(command="docker run -d -p 80:80 nginx")
2. tool_call: shell.exec(command="curl http://localhost")
3. tool_call: docker.logs(container_id="...")
```
### Пример 3: Скачать файл и обработать его
```
User: "Скачай https://example.com/data.csv и посчитай количество строк"
LLM:
1. tool_call: http.GET(url="https://example.com/data.csv")
2. tool_call: file.write(path="/tmp/data.csv", content="...")
3. tool_call: shell.exec(command="wc -l /tmp/data.csv")
```
---
## Заключение
Tool Binding превращает GoClaw из **пассивного чат-бота** в **активного агента**, способного:
- 🌐 Браузить интернет
- 💻 Выполнять системные команды
- 📁 Управлять файлами
- 🐳 Управлять Docker контейнерами
- 🔗 Интегрироваться с внешними API
Это ключевой компонент для создания по-настоящему автономных AI-агентов.

View File

@@ -0,0 +1,78 @@
CREATE TABLE `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 DEFAULT ('[]'),
`blockedPatterns` json DEFAULT ('[]'),
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `agentAccessControl_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `agentHistory` (
`id` int AUTO_INCREMENT NOT NULL,
`agentId` int NOT NULL,
`userMessage` text NOT NULL,
`agentResponse` text,
`conversationId` varchar(64),
`messageIndex` int,
`status` enum('pending','success','error') DEFAULT 'pending',
`createdAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `agentHistory_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `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 DEFAULT ('[]'),
`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`)
);
--> statement-breakpoint
CREATE TABLE `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 DEFAULT ('[]'),
`allowedDomains` json DEFAULT ('[]'),
`maxRequestsPerHour` int DEFAULT 100,
`isActive` boolean DEFAULT true,
`isPublic` boolean DEFAULT false,
`tags` json DEFAULT ('[]'),
`metadata` json DEFAULT ('{}'),
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `agents_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE INDEX `agentAccessControl_agentId_tool_idx` ON `agentAccessControl` (`agentId`,`tool`);--> statement-breakpoint
CREATE INDEX `agentHistory_agentId_idx` ON `agentHistory` (`agentId`);--> statement-breakpoint
CREATE INDEX `agentMetrics_agentId_idx` ON `agentMetrics` (`agentId`);--> statement-breakpoint
CREATE INDEX `agentMetrics_createdAt_idx` ON `agentMetrics` (`createdAt`);--> statement-breakpoint
CREATE INDEX `agents_userId_idx` ON `agents` (`userId`);--> statement-breakpoint
CREATE INDEX `agents_model_idx` ON `agents` (`model`);

View File

@@ -0,0 +1,647 @@
{
"version": "5",
"dialect": "mysql",
"id": "81e64c5e-427c-49d1-bc11-a25918d54e4b",
"prevId": "dc689f95-4069-4f14-ab7c-53cb1cc15760",
"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": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"openId": {
"name": "openId",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(320)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"loginMethod": {
"name": "loginMethod",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "enum('user','admin')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
},
"lastSignedIn": {
"name": "lastSignedIn",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"users_id": {
"name": "users_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"users_openId_unique": {
"name": "users_openId_unique",
"columns": [
"openId"
]
}
},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}

View File

@@ -8,6 +8,13 @@
"when": 1774036428800,
"tag": "0000_sudden_blue_shield",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1774038103243,
"tag": "0001_secret_guardian",
"breakpoints": true
}
]
}

View File

@@ -1,4 +1,4 @@
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar } from "drizzle-orm/mysql-core";
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, decimal, json, boolean, index } from "drizzle-orm/mysql-core";
/**
* Core user table backing auth flow.
@@ -25,4 +25,138 @@ export const users = mysqlTable("users", {
export type User = typeof users.$inferSelect;
export type InsertUser = typeof users.$inferInsert;
// TODO: Add your tables here
/**
* Agents — конфигурация и управление AI-агентами
*/
export const agents = mysqlTable("agents", {
id: int("id").autoincrement().primaryKey(),
userId: int("userId").notNull(), // Владелец агента
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
role: varchar("role", { length: 100 }).notNull(), // "developer", "researcher", "executor"
// Модель LLM
model: varchar("model", { length: 100 }).notNull(),
provider: varchar("provider", { length: 50 }).notNull(),
// Параметры LLM
temperature: decimal("temperature", { precision: 3, scale: 2 }).default("0.7"),
maxTokens: int("maxTokens").default(2048),
topP: decimal("topP", { precision: 3, scale: 2 }).default("1.0"),
frequencyPenalty: decimal("frequencyPenalty", { precision: 3, scale: 2 }).default("0.0"),
presencePenalty: decimal("presencePenalty", { precision: 3, scale: 2 }).default("0.0"),
// System Prompt
systemPrompt: text("systemPrompt"),
// Доступы и разрешения
allowedTools: json("allowedTools").$type<string[]>().default([]),
allowedDomains: json("allowedDomains").$type<string[]>().default([]),
maxRequestsPerHour: int("maxRequestsPerHour").default(100),
// Статус
isActive: boolean("isActive").default(true),
isPublic: boolean("isPublic").default(false),
// Метаданные
tags: json("tags").$type<string[]>().default([]),
metadata: json("metadata").$type<Record<string, any>>().default({}),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
userIdIdx: index("agents_userId_idx").on(table.userId),
modelIdx: index("agents_model_idx").on(table.model),
}));
export type Agent = typeof agents.$inferSelect;
export type InsertAgent = typeof agents.$inferInsert;
/**
* Agent Metrics — метрики производительности агентов
*/
export const agentMetrics = mysqlTable("agentMetrics", {
id: int("id").autoincrement().primaryKey(),
agentId: int("agentId").notNull(),
// Информация о запросе
requestId: varchar("requestId", { length: 64 }).notNull().unique(),
userMessage: text("userMessage"),
agentResponse: text("agentResponse"),
// Токены
inputTokens: int("inputTokens").default(0),
outputTokens: int("outputTokens").default(0),
totalTokens: int("totalTokens").default(0),
// Время обработки
processingTimeMs: int("processingTimeMs").notNull(),
// Статус
status: mysqlEnum("status", ["success", "error", "timeout", "rate_limited"]).notNull(),
errorMessage: text("errorMessage"),
// Инструменты
toolsCalled: json("toolsCalled").$type<string[]>().default([]),
// Модель
model: varchar("model", { length: 100 }),
temperature: decimal("temperature", { precision: 3, scale: 2 }),
createdAt: timestamp("createdAt").defaultNow().notNull(),
}, (table) => ({
agentIdIdx: index("agentMetrics_agentId_idx").on(table.agentId),
createdAtIdx: index("agentMetrics_createdAt_idx").on(table.createdAt),
}));
export type AgentMetric = typeof agentMetrics.$inferSelect;
export type InsertAgentMetric = typeof agentMetrics.$inferInsert;
/**
* Agent History — полная история запросов
*/
export const agentHistory = mysqlTable("agentHistory", {
id: int("id").autoincrement().primaryKey(),
agentId: int("agentId").notNull(),
userMessage: text("userMessage").notNull(),
agentResponse: text("agentResponse"),
conversationId: varchar("conversationId", { length: 64 }),
messageIndex: int("messageIndex"),
status: mysqlEnum("status", ["pending", "success", "error"]).default("pending"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
}, (table) => ({
agentIdIdx: index("agentHistory_agentId_idx").on(table.agentId),
}));
export type AgentHistory = typeof agentHistory.$inferSelect;
export type InsertAgentHistory = typeof agentHistory.$inferInsert;
/**
* Agent Access Control — управление доступами
*/
export const agentAccessControl = mysqlTable("agentAccessControl", {
id: int("id").autoincrement().primaryKey(),
agentId: int("agentId").notNull(),
tool: varchar("tool", { length: 50 }).notNull(),
isAllowed: boolean("isAllowed").default(true),
maxExecutionsPerHour: int("maxExecutionsPerHour").default(100),
timeoutSeconds: int("timeoutSeconds").default(30),
allowedPatterns: json("allowedPatterns").$type<string[]>().default([]),
blockedPatterns: json("blockedPatterns").$type<string[]>().default([]),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
agentIdToolIdx: index("agentAccessControl_agentId_tool_idx").on(table.agentId, table.tool),
}));
export type AgentAccessControl = typeof agentAccessControl.$inferSelect;
export type InsertAgentAccessControl = typeof agentAccessControl.$inferInsert;

140
install.sh Normal file
View File

@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# GoClaw Control Center — однострочная установка
# Использование: curl -fsSL https://git.softuniq.eu/UniqAI/GoClaw/raw/branch/main/install.sh | bash
set -euo pipefail
REPO_URL="https://git.softuniq.eu/UniqAI/GoClaw.git"
INSTALL_DIR="${GOCLAW_DIR:-$HOME/goclaw}"
COMPOSE_FILE="docker/docker-stack.yml"
# Цвета
RED='\033[0;31m'
GREEN='\033[0;32m'
CYAN='\033[0;36m'
YELLOW='\033[1;33m'
NC='\033[0m'
banner() {
echo -e "${CYAN}"
echo " ██████╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗"
echo " ██╔════╝ ██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║"
echo " ██║ ███╗██║ ██║██║ ██║ ███████║██║ █╗ ██║"
echo " ██║ ██║██║ ██║██║ ██║ ██╔══██║██║███╗██║"
echo " ╚██████╔╝╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝"
echo " ╚═════╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝ "
echo -e "${NC}"
echo -e "${CYAN} GoClaw Control Center — AI Agent Orchestration Platform${NC}"
echo -e "${CYAN} https://git.softuniq.eu/UniqAI/GoClaw${NC}"
echo ""
}
info() { echo -e "${CYAN}[INFO]${NC} $*"; }
success() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
check_deps() {
info "Проверка зависимостей..."
for cmd in git docker; do
if ! command -v "$cmd" &>/dev/null; then
error "Не найден '$cmd'. Установите его и повторите."
fi
done
# Проверяем docker compose (v2) или docker-compose (v1)
if docker compose version &>/dev/null 2>&1; then
COMPOSE_CMD="docker compose"
elif command -v docker-compose &>/dev/null; then
COMPOSE_CMD="docker-compose"
else
error "Не найден 'docker compose' или 'docker-compose'. Установите Docker Compose."
fi
success "Все зависимости найдены (compose: $COMPOSE_CMD)"
}
clone_or_update() {
if [ -d "$INSTALL_DIR/.git" ]; then
info "Обновление существующей установки в $INSTALL_DIR..."
git -C "$INSTALL_DIR" pull --ff-only
success "Репозиторий обновлён"
else
info "Клонирование репозитория в $INSTALL_DIR..."
git clone "$REPO_URL" "$INSTALL_DIR"
success "Репозиторий клонирован"
fi
}
setup_env() {
local env_file="$INSTALL_DIR/.env"
if [ ! -f "$env_file" ]; then
info "Создание .env из шаблона..."
cp "$INSTALL_DIR/.env.example" "$env_file"
# Генерация случайного JWT_SECRET
local jwt_secret
jwt_secret=$(openssl rand -hex 32 2>/dev/null || cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 64)
sed -i "s|JWT_SECRET=.*|JWT_SECRET=${jwt_secret}|" "$env_file"
warn "Файл .env создан. Укажите OLLAMA_API_KEY:"
warn " nano $env_file"
echo ""
read -rp " Введите OLLAMA_API_KEY (или Enter для пропуска): " api_key
if [ -n "$api_key" ]; then
sed -i "s|OLLAMA_API_KEY=.*|OLLAMA_API_KEY=${api_key}|" "$env_file"
success "OLLAMA_API_KEY сохранён"
else
warn "OLLAMA_API_KEY не задан — чат и список моделей будут недоступны"
fi
else
success ".env уже существует, пропускаем"
fi
}
start_services() {
info "Запуск GoClaw Control Center..."
cd "$INSTALL_DIR"
# Проверяем наличие docker-compose.yml (для простого запуска без Swarm)
if [ -f "docker-compose.yml" ]; then
$COMPOSE_CMD up -d
elif [ -f "$COMPOSE_FILE" ]; then
# Swarm mode
if ! docker info 2>/dev/null | grep -q "Swarm: active"; then
info "Инициализация Docker Swarm..."
docker swarm init --advertise-addr "$(hostname -I | awk '{print $1}')" 2>/dev/null || true
fi
docker stack deploy -c "$COMPOSE_FILE" goclaw
else
error "Не найден docker-compose.yml или $COMPOSE_FILE"
fi
}
print_success() {
echo ""
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${GREEN} GoClaw Control Center успешно запущен!${NC}"
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
local ip
ip=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost")
echo -e " ${CYAN}Web UI:${NC} http://${ip}:3000"
echo -e " ${CYAN}Gateway API:${NC} http://${ip}:18789"
echo -e " ${CYAN}Конфиг:${NC} $INSTALL_DIR/.env"
echo ""
echo -e " Управление:"
echo -e " ${YELLOW}Остановить:${NC} cd $INSTALL_DIR && docker compose down"
echo -e " ${YELLOW}Логи:${NC} cd $INSTALL_DIR && docker compose logs -f"
echo -e " ${YELLOW}Обновить:${NC} cd $INSTALL_DIR && git pull && docker compose up -d --build"
echo ""
}
main() {
banner
check_deps
clone_or_update
setup_env
start_services
print_success
}
main "$@"

63
server/agents.test.ts Normal file
View File

@@ -0,0 +1,63 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import * as agentsModule from "./agents";
// Mock getDb
vi.mock("./db", () => ({
getDb: vi.fn(async () => null),
}));
describe("Agents Module", () => {
describe("createAgent", () => {
it("should return null when database is unavailable", async () => {
const result = await agentsModule.createAgent(1, {
name: "Test Agent",
role: "developer",
model: "gpt-4o",
provider: "openai",
});
expect(result).toBeNull();
});
});
describe("getAgentById", () => {
it("should return null when database is unavailable", async () => {
const result = await agentsModule.getAgentById(1);
expect(result).toBeNull();
});
});
describe("getUserAgents", () => {
it("should return empty array when database is unavailable", async () => {
const result = await agentsModule.getUserAgents(1);
expect(result).toEqual([]);
});
});
describe("getAgentStats", () => {
it("should return null when database is unavailable", async () => {
const result = await agentsModule.getAgentStats(1);
expect(result).toBeNull();
});
});
describe("getAgentMetrics", () => {
it("should return empty array when database is unavailable", async () => {
const result = await agentsModule.getAgentMetrics(1);
expect(result).toEqual([]);
});
});
describe("getAgentHistory", () => {
it("should return empty array when database is unavailable", async () => {
const result = await agentsModule.getAgentHistory(1);
expect(result).toEqual([]);
});
});
describe("getAgentAccessControl", () => {
it("should return empty array when database is unavailable", async () => {
const result = await agentsModule.getAgentAccessControl(1);
expect(result).toEqual([]);
});
});
});

241
server/agents.ts Normal file
View File

@@ -0,0 +1,241 @@
import { eq, and, desc, gte } from "drizzle-orm";
import { agents, agentMetrics, agentHistory, agentAccessControl, type Agent, type InsertAgent, type AgentMetric, type InsertAgentMetric } from "../drizzle/schema";
import { getDb } from "./db";
import { nanoid } from "nanoid";
/**
* Создать нового агента
*/
export async function createAgent(userId: number, data: InsertAgent): Promise<Agent | null> {
const db = await getDb();
if (!db) return null;
try {
const result = await db.insert(agents).values({
...data,
userId,
});
const agentId = result[0].insertId;
const created = await db.select().from(agents).where(eq(agents.id, Number(agentId))).limit(1);
return created[0] || null;
} catch (error) {
console.error("[DB] Failed to create agent:", error);
throw error;
}
}
/**
* Получить агента по ID
*/
export async function getAgentById(agentId: number): Promise<Agent | null> {
const db = await getDb();
if (!db) return null;
try {
const result = await db.select().from(agents).where(eq(agents.id, agentId)).limit(1);
return result[0] || null;
} catch (error) {
console.error("[DB] Failed to get agent:", error);
return null;
}
}
/**
* Получить все агенты пользователя
*/
export async function getUserAgents(userId: number): Promise<Agent[]> {
const db = await getDb();
if (!db) return [];
try {
return await db.select().from(agents).where(eq(agents.userId, userId));
} catch (error) {
console.error("[DB] Failed to get user agents:", error);
return [];
}
}
/**
* Обновить конфигурацию агента
*/
export async function updateAgent(agentId: number, updates: Partial<InsertAgent>): Promise<Agent | null> {
const db = await getDb();
if (!db) return null;
try {
await db.update(agents).set(updates).where(eq(agents.id, agentId));
return getAgentById(agentId);
} catch (error) {
console.error("[DB] Failed to update agent:", error);
throw error;
}
}
/**
* Удалить агента
*/
export async function deleteAgent(agentId: number): Promise<boolean> {
const db = await getDb();
if (!db) return false;
try {
await db.delete(agents).where(eq(agents.id, agentId));
return true;
} catch (error) {
console.error("[DB] Failed to delete agent:", error);
return false;
}
}
/**
* Сохранить метрику запроса
*/
export async function saveMetric(agentId: number, data: Omit<InsertAgentMetric, "agentId">): Promise<AgentMetric | null> {
const db = await getDb();
if (!db) return null;
try {
const requestId = nanoid();
const result = await db.insert(agentMetrics).values({
...data,
agentId,
requestId,
});
const metricId = result[0].insertId;
const created = await db.select().from(agentMetrics).where(eq(agentMetrics.id, Number(metricId))).limit(1);
return created[0] || null;
} catch (error) {
console.error("[DB] Failed to save metric:", error);
throw error;
}
}
/**
* Получить метрики агента за последние N часов
*/
export async function getAgentMetrics(agentId: number, hoursBack: number = 24): Promise<AgentMetric[]> {
const db = await getDb();
if (!db) return [];
try {
const since = new Date(Date.now() - hoursBack * 60 * 60 * 1000);
return await db
.select()
.from(agentMetrics)
.where(and(eq(agentMetrics.agentId, agentId), gte(agentMetrics.createdAt, since)))
.orderBy(desc(agentMetrics.createdAt));
} catch (error) {
console.error("[DB] Failed to get agent metrics:", error);
return [];
}
}
/**
* Получить статистику агента
*/
export async function getAgentStats(agentId: number, hoursBack: number = 24) {
const db = await getDb();
if (!db) return null;
try {
const metrics = await getAgentMetrics(agentId, hoursBack);
const totalRequests = metrics.length;
const successRequests = metrics.filter((m) => m.status === "success").length;
const errorRequests = metrics.filter((m) => m.status === "error").length;
const avgProcessingTime = metrics.length > 0 ? metrics.reduce((sum, m) => sum + m.processingTimeMs, 0) / metrics.length : 0;
const totalTokens = metrics.reduce((sum, m) => sum + (m.totalTokens || 0), 0);
const avgTokensPerRequest = metrics.length > 0 ? totalTokens / metrics.length : 0;
return {
totalRequests,
successRequests,
errorRequests,
successRate: totalRequests > 0 ? (successRequests / totalRequests) * 100 : 0,
avgProcessingTime: Math.round(avgProcessingTime),
totalTokens,
avgTokensPerRequest: Math.round(avgTokensPerRequest),
period: `${hoursBack}h`,
};
} catch (error) {
console.error("[DB] Failed to get agent stats:", error);
return null;
}
}
/**
* Получить историю запросов агента
*/
export async function getAgentHistory(agentId: number, limit: number = 50) {
const db = await getDb();
if (!db) return [];
try {
return await db
.select()
.from(agentHistory)
.where(eq(agentHistory.agentId, agentId))
.orderBy(desc(agentHistory.createdAt))
.limit(limit);
} catch (error) {
console.error("[DB] Failed to get agent history:", error);
return [];
}
}
/**
* Получить управление доступами для агента
*/
export async function getAgentAccessControl(agentId: number) {
const db = await getDb();
if (!db) return [];
try {
return await db.select().from(agentAccessControl).where(eq(agentAccessControl.agentId, agentId));
} catch (error) {
console.error("[DB] Failed to get agent access control:", error);
return [];
}
}
/**
* Обновить управление доступами для инструмента
*/
export async function updateToolAccess(agentId: number, tool: string, updates: Partial<typeof agentAccessControl.$inferInsert>) {
const db = await getDb();
if (!db) return null;
try {
const existing = await db
.select()
.from(agentAccessControl)
.where(and(eq(agentAccessControl.agentId, agentId), eq(agentAccessControl.tool, tool)))
.limit(1);
if (existing.length > 0) {
await db
.update(agentAccessControl)
.set(updates)
.where(and(eq(agentAccessControl.agentId, agentId), eq(agentAccessControl.tool, tool)));
} else {
await db.insert(agentAccessControl).values({
agentId,
tool,
...updates,
});
}
const result = await db
.select()
.from(agentAccessControl)
.where(and(eq(agentAccessControl.agentId, agentId), eq(agentAccessControl.tool, tool)))
.limit(1);
return result[0] || null;
} catch (error) {
console.error("[DB] Failed to update tool access:", error);
throw error;
}
}

View File

@@ -2,7 +2,7 @@ import { COOKIE_NAME } from "@shared/const";
import { z } from "zod";
import { getSessionCookieOptions } from "./_core/cookies";
import { systemRouter } from "./_core/systemRouter";
import { publicProcedure, router } from "./_core/trpc";
import { publicProcedure, router, protectedProcedure } from "./_core/trpc";
import { checkOllamaHealth, listModels, chatCompletion } from "./ollama";
export const appRouter = router({
@@ -78,6 +78,103 @@ export const appRouter = router({
}
}),
}),
/**
* Agents — управление AI-агентами
*/
agents: router({
list: protectedProcedure.query(async ({ ctx }) => {
const { getUserAgents } = await import("./agents");
return getUserAgents(ctx.user.id);
}),
get: protectedProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
const { getAgentById } = await import("./agents");
return getAgentById(input.id);
}),
create: protectedProcedure
.input(
z.object({
name: z.string().min(1),
description: z.string().optional(),
role: z.string(),
model: z.string(),
provider: z.string(),
temperature: z.number().min(0).max(2).default(0.7),
maxTokens: z.number().default(2048),
systemPrompt: z.string().optional(),
allowedTools: z.array(z.string()).default([]),
})
)
.mutation(async ({ ctx, input }) => {
const { createAgent } = await import("./agents");
return createAgent(ctx.user.id, {
...input,
temperature: input.temperature.toString(),
} as any);
}),
update: protectedProcedure
.input(
z.object({
id: z.number(),
name: z.string().optional(),
description: z.string().optional(),
temperature: z.number().optional(),
maxTokens: z.number().optional(),
systemPrompt: z.string().optional(),
isActive: z.boolean().optional(),
})
)
.mutation(async ({ input }) => {
const { updateAgent } = await import("./agents");
const { id, temperature, ...updates } = input;
const finalUpdates = temperature !== undefined ? { ...updates, temperature: temperature.toString() } : updates;
return updateAgent(id, finalUpdates as any);
}),
delete: protectedProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
const { deleteAgent } = await import("./agents");
return deleteAgent(input.id);
}),
stats: protectedProcedure.input(z.object({ id: z.number(), hoursBack: z.number().default(24) })).query(async ({ input }) => {
const { getAgentStats } = await import("./agents");
return getAgentStats(input.id, input.hoursBack);
}),
metrics: protectedProcedure.input(z.object({ id: z.number(), hoursBack: z.number().default(24) })).query(async ({ input }) => {
const { getAgentMetrics } = await import("./agents");
return getAgentMetrics(input.id, input.hoursBack);
}),
history: protectedProcedure.input(z.object({ id: z.number(), limit: z.number().default(50) })).query(async ({ input }) => {
const { getAgentHistory } = await import("./agents");
return getAgentHistory(input.id, input.limit);
}),
accessControl: protectedProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
const { getAgentAccessControl } = await import("./agents");
return getAgentAccessControl(input.id);
}),
updateToolAccess: protectedProcedure
.input(
z.object({
agentId: z.number(),
tool: z.string(),
isAllowed: z.boolean(),
maxExecutionsPerHour: z.number().optional(),
timeoutSeconds: z.number().optional(),
})
)
.mutation(async ({ input }) => {
const { updateToolAccess } = await import("./agents");
const { agentId, ...updates } = input;
return updateToolAccess(agentId, input.tool, updates);
}),
}),
});
export type AppRouter = typeof appRouter;

26
todo.md
View File

@@ -15,6 +15,30 @@
- [x] Write vitest tests for Ollama API proxy
- [x] Update Dashboard with real model data
- [ ] Add streaming support for chat responses
- [ ] Add agent CRUD (create/edit/delete agents via UI)
- [ ] Connect real Docker Swarm API for node monitoring
- [ ] Add authentication/login protection
## Phase 1: Agent Management UI
- [x] Connect Agents page to trpc.agents.list (load real agents from DB)
- [x] Create AgentDetailModal component for viewing agent config
- [x] Create AgentCreateModal component with form validation
- [x] Implement agent update mutation (model, temperature, maxTokens, systemPrompt)
- [x] Implement agent delete mutation with confirmation
- [ ] Add start/pause/restart actions for agents
- [ ] Add agent metrics chart (requests, tokens, processing time)
- [ ] Add agent history view (recent requests/responses)
- [x] Write vitest tests for agent management components
## Phase 2: Tool Binding System
- [ ] Design Tool Binding API schema
- [ ] Create tool registry in database
- [ ] Implement tool execution sandbox
- [ ] Add tool access control per agent
- [ ] Create UI for tool management
## Phase 3: Tool Integration
- [ ] Implement Browser tool (Chromedp wrapper)
- [ ] Implement Shell tool (bash execution with sandbox)
- [ ] Implement File tool (read/write with path restrictions)
- [ ] Implement Docker tool (container management)
- [ ] Implement HTTP tool (GET/POST with domain whitelist)