true message
This commit is contained in:
23
.env.example
Normal file
23
.env.example
Normal 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
210
README.md
Normal 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.*
|
||||
91
client/src/components/AgentCreateModal.test.ts
Normal file
91
client/src/components/AgentCreateModal.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
257
client/src/components/AgentCreateModal.tsx
Normal file
257
client/src/components/AgentCreateModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
client/src/components/AgentDetailModal.test.ts
Normal file
89
client/src/components/AgentDetailModal.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
383
client/src/components/AgentDetailModal.tsx
Normal file
383
client/src/components/AgentDetailModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 · {agents.filter(a => a.status === "idle").length} idle · {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}% · 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>
|
||||
);
|
||||
}
|
||||
|
||||
322
docs/tool_binding_architecture.md
Normal file
322
docs/tool_binding_architecture.md
Normal 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-агентов.
|
||||
78
drizzle/0001_secret_guardian.sql
Normal file
78
drizzle/0001_secret_guardian.sql
Normal 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`);
|
||||
647
drizzle/meta/0001_snapshot.json
Normal file
647
drizzle/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
140
install.sh
Normal 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
63
server/agents.test.ts
Normal 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
241
server/agents.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
26
todo.md
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user