feat(workflows): add full Workflow section — visual constructor, dashboard & execution engine #1

Open
NW wants to merge 21 commits from genspark_ai_developer into main
50 changed files with 15120 additions and 1760 deletions

1
.git-credentials Normal file
View File

@@ -0,0 +1 @@
https://x-access-token:ghs_b4NOitjlosRPPypJr3KupAZqrOXlxr4fq5Z9@github.com

View File

@@ -13,6 +13,7 @@ import Settings from "./pages/Settings";
import Nodes from "./pages/Nodes";
import Tools from "./pages/Tools";
import Skills from "./pages/Skills";
import Workflows from "./pages/Workflows";
function Router() {
// make sure to consider if you need authentication for certain routes
@@ -26,6 +27,8 @@ function Router() {
<Route path="/chat" component={Chat} />
<Route path="/tools" component={Tools} />
<Route path="/skills" component={Skills} />
<Route path="/workflows" component={Workflows} />
<Route path="/workflows/:id" component={Workflows} />
<Route path="/settings" component={Settings} />
<Route path="/404" component={NotFound} />
<Route component={NotFound} />

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -13,7 +13,7 @@ import {
} from "@/components/ui/select";
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";
import { Loader2, Plus } from "lucide-react";
import { Loader2, Plus, Wand2, Sparkles } from "lucide-react";
interface AgentCreateModalProps {
open: boolean;
@@ -22,47 +22,87 @@ interface AgentCreateModalProps {
}
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" },
{ 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" },
{ value: "analyst", label: "Analyst — Data processing & reports" },
{ value: "writer", label: "Writer — Content generation" },
{ value: "coordinator", label: "Coordinator — Multi-agent orchestration" },
];
const PROVIDERS = [
{ value: "Ollama", label: "Ollama (Local/Cloud)" },
{ value: "OpenAI", label: "OpenAI (GPT)" },
{ value: "Anthropic", label: "Anthropic (Claude)" },
];
const DEFAULT_FORM = {
name: "",
description: "",
role: "developer",
provider: "",
model: "",
temperature: 0.7,
maxTokens: 2048,
systemPrompt: "",
};
function toNum(v: string | number | null | undefined, fallback = 0): number {
if (v === null || v === undefined) return fallback;
const n = typeof v === "string" ? parseFloat(v) : v;
return isNaN(n) ? fallback : n;
}
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 [formData, setFormData] = useState({ ...DEFAULT_FORM });
const [isLoading, setIsLoading] = useState(false);
const [isAutoFilling, setIsAutoFilling] = useState(false);
const [maxTokensHint, setMaxTokensHint] = useState<number | null>(null);
const [modelInfoLoading, setModelInfoLoading] = useState(false);
const { data: models } = trpc.ollama.models.useQuery();
// ─── Remote data ───────────────────────────────────────────────────────────
const { data: modelsData, isLoading: modelsLoading } = trpc.ollama.models.useQuery(undefined, {
staleTime: 60_000,
});
const { data: configData } = trpc.config.providers.useQuery(undefined, {
staleTime: 300_000,
});
const connectedProviders = configData?.providers ?? [];
// ─── Model info (context_length) ───────────────────────────────────────────
const { data: modelInfoData, isFetching: modelInfoFetching } = trpc.ollama.modelInfo.useQuery(
{ modelId: formData.model },
{
enabled: !!formData.model,
staleTime: 300_000,
}
);
// When model info arrives, update maxTokens with context_length
useEffect(() => {
if (modelInfoData?.contextLength && modelInfoData.contextLength > 0) {
setMaxTokensHint(modelInfoData.contextLength);
setFormData((prev) => ({ ...prev, maxTokens: modelInfoData.contextLength }));
}
}, [modelInfoData]);
// Auto-select first provider when providers load
useEffect(() => {
if (connectedProviders.length > 0 && !formData.provider) {
setFormData((prev) => ({ ...prev, provider: connectedProviders[0].name }));
}
}, [connectedProviders]);
// Auto-select first model when models load
useEffect(() => {
const availableModels: string[] = modelsData?.models?.map((m: any) => m.id || m) ?? [];
if (availableModels.length > 0 && !formData.model) {
setFormData((prev) => ({ ...prev, model: availableModels[0] }));
}
}, [modelsData]);
// ─── Mutations ─────────────────────────────────────────────────────────────
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: "",
});
setFormData({ ...DEFAULT_FORM });
onOpenChange(false);
onSuccess?.();
},
@@ -71,19 +111,46 @@ export function AgentCreateModal({ open, onOpenChange, onSuccess }: AgentCreateM
},
});
const compileMutation = trpc.agentCompiler.compile.useMutation({
onSuccess: (result) => {
if (!result.success || !result.config) {
toast.error("AI auto-fill failed — fill fields manually");
setIsAutoFilling(false);
return;
}
const cfg = result.config;
setFormData((prev) => ({
...prev,
role: cfg.role || prev.role,
provider: cfg.provider || prev.provider,
model: cfg.model || prev.model,
temperature: toNum(cfg.temperature, 0.7),
maxTokens: cfg.maxTokens || prev.maxTokens,
systemPrompt: cfg.systemPrompt || prev.systemPrompt,
}));
toast.success("AI filled the fields — review and save");
setIsAutoFilling(false);
},
onError: (err) => {
toast.error(`Auto-fill error: ${err.message}`);
setIsAutoFilling(false);
},
});
// ─── Handlers ──────────────────────────────────────────────────────────────
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,
provider: formData.provider || "Ollama",
model: formData.model,
temperature: formData.temperature,
maxTokens: formData.maxTokens,
@@ -95,149 +162,247 @@ export function AgentCreateModal({ open, onOpenChange, onSuccess }: AgentCreateM
}
};
const availableModels = models?.models
?.filter((m: any) => m.provider === formData.provider || formData.provider === "Ollama")
.map((m: any) => m.id) || [];
const handleAutoFill = async () => {
if (!formData.name.trim()) {
toast.error("Enter Agent Name first — the AI needs it to suggest parameters");
return;
}
setIsAutoFilling(true);
const spec = `Agent name: ${formData.name}\n${formData.description ? `Description: ${formData.description}` : ""}`.trim();
await compileMutation.mutateAsync({
specification: spec,
name: formData.name,
preferredProvider: formData.provider || undefined,
preferredModel: formData.model || undefined,
});
};
const handleModelChange = (model: string) => {
setFormData((prev) => ({ ...prev, model }));
setMaxTokensHint(null);
};
// ─── Derived ───────────────────────────────────────────────────────────────
const availableModels: string[] = modelsData?.models?.map((m: any) => m.id || m) ?? [];
const canAutoFill = formData.name.trim().length > 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto bg-card border-border">
<DialogHeader>
<DialogTitle>Deploy New Agent</DialogTitle>
<DialogTitle className="font-mono text-primary flex items-center gap-2">
<Plus className="w-4 h-4" />
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"
/>
{/* ── Name + Description ─────────────────────────────────────────── */}
<div className="grid grid-cols-[1fr_auto] gap-2 items-end">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground font-mono">AGENT NAME *</Label>
<Input
placeholder="e.g. Code Reviewer Agent"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="font-mono"
/>
</div>
{/* Magic wand — auto-fill remaining fields via AI */}
<Button
variant="outline"
size="sm"
onClick={handleAutoFill}
disabled={!canAutoFill || isAutoFilling}
className={`h-9 gap-1.5 border-primary/40 text-primary hover:bg-primary/10 transition-all ${
canAutoFill ? "opacity-100" : "opacity-40"
}`}
title="Auto-fill fields with AI based on name & description"
>
{isAutoFilling ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Wand2 className="w-4 h-4" />
)}
<span className="text-xs font-mono hidden sm:inline">
{isAutoFilling ? "Thinking..." : "Auto-fill"}
</span>
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
{canAutoFill && !isAutoFilling && (
<p className="text-[11px] text-muted-foreground font-mono -mt-2 flex items-center gap-1">
<Sparkles className="w-3 h-3 text-primary" />
Click <span className="text-primary">Auto-fill</span> to let AI suggest temperature, system prompt and other params
</p>
)}
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground font-mono">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"
className="font-mono text-sm resize-none"
/>
</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">
{/* ── Role ───────────────────────────────────────────────────────── */}
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground font-mono">ROLE *</Label>
<Select value={formData.role} onValueChange={(v) => setFormData({ ...formData, role: v })}>
<SelectTrigger 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
{AGENT_ROLES.map((role) => (
<SelectItem key={role.value} value={role.value} className="font-mono text-sm">
{role.label}
</SelectItem>
)}
))}
</SelectContent>
</Select>
</div>
{/* LLM Parameters */}
{/* ── Provider + Model side-by-side ──────────────────────────────── */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground font-mono">PROVIDER *</Label>
<Select
value={formData.provider}
onValueChange={(v) => setFormData({ ...formData, provider: v, model: "" })}
>
<SelectTrigger className="font-mono w-full truncate">
<SelectValue placeholder="Select provider..." />
</SelectTrigger>
<SelectContent>
{connectedProviders.length > 0 ? (
connectedProviders.map((p) => (
<SelectItem key={p.id} value={p.name} className="font-mono text-sm">
<span className="flex items-center gap-2">
{p.name}
<span className="text-[10px] text-neon-green font-mono"> connected</span>
</span>
</SelectItem>
))
) : (
<SelectItem value="Ollama" className="font-mono text-sm">
Ollama
</SelectItem>
)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground font-mono flex items-center gap-2">
MODEL *
{modelsLoading && <Loader2 className="w-3 h-3 animate-spin text-primary" />}
{!modelsLoading && availableModels.length > 0 && (
<span className="text-[10px] text-neon-green font-normal">{availableModels.length} available</span>
)}
{!modelsLoading && availableModels.length === 0 && (
<span className="text-[10px] text-neon-amber font-normal">API unavailable</span>
)}
</Label>
<Select
value={formData.model}
onValueChange={handleModelChange}
disabled={modelsLoading}
>
<SelectTrigger className="font-mono w-full truncate">
<SelectValue placeholder={modelsLoading ? "Loading models..." : "Select model..."} />
</SelectTrigger>
<SelectContent>
{availableModels.length > 0 ? (
availableModels.map((model: string) => (
<SelectItem key={model} value={model} className="font-mono text-sm">
{model}
</SelectItem>
))
) : (
<SelectItem value="_placeholder" disabled className="font-mono text-sm">
{modelsLoading ? "Loading..." : "No models available"}
</SelectItem>
)}
</SelectContent>
</Select>
</div>
</div>
{/* ── Temperature + Max Tokens ────────────────────────────────────── */}
<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"
<div className="flex justify-between items-center">
<Label className="text-xs text-muted-foreground font-mono">TEMPERATURE</Label>
<span className="text-xs font-mono text-primary">{formData.temperature.toFixed(2)}</span>
</div>
<input
type="range"
min="0"
max="2"
step="0.01"
value={formData.temperature}
onChange={(e) => setFormData({ ...formData, temperature: parseFloat(e.target.value) })}
className="cursor-pointer"
className="w-full accent-cyan-400 cursor-pointer"
/>
<div className="flex justify-between text-[10px] text-muted-foreground font-mono">
<span>0.0 precise</span>
<span>2.0 creative</span>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="maxTokens">Max Tokens</Label>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground font-mono flex items-center gap-1.5">
MAX TOKENS
{(modelInfoFetching) && (
<Loader2 className="w-3 h-3 animate-spin text-primary" />
)}
{maxTokensHint && maxTokensHint > 0 && !modelInfoFetching && (
<span className="text-[10px] text-neon-green font-normal">
max {maxTokensHint.toLocaleString()} from API
</span>
)}
</Label>
<Input
id="maxTokens"
type="number"
value={formData.maxTokens}
onChange={(e) => setFormData({ ...formData, maxTokens: parseInt(e.target.value) })}
onChange={(e) => setFormData({ ...formData, maxTokens: parseInt(e.target.value) || 2048 })}
className="font-mono"
min={1}
max={maxTokensHint || 1000000}
/>
{maxTokensHint && maxTokensHint > 0 && (
<p className="text-[10px] text-muted-foreground font-mono">
Context window: {maxTokensHint.toLocaleString()} tokens
</p>
)}
</div>
</div>
{/* System Prompt */}
<div className="space-y-2">
<Label htmlFor="systemPrompt">System Prompt</Label>
{/* ── System Prompt ───────────────────────────────────────────────── */}
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground font-mono">SYSTEM PROMPT</Label>
<Textarea
id="systemPrompt"
placeholder="Define the agent's behavior and instructions..."
placeholder="Define the agent's behavior and instructions... (AI auto-fill will suggest this)"
value={formData.systemPrompt}
onChange={(e) => setFormData({ ...formData, systemPrompt: e.target.value })}
rows={4}
className="font-mono text-sm"
className="font-mono text-sm resize-none"
/>
</div>
</div>
<DialogFooter>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={isLoading || createMutation.isPending || !formData.name.trim()}>
<Button
onClick={handleCreate}
disabled={isLoading || createMutation.isPending || !formData.name.trim()}
className="bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25"
>
{isLoading || createMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />

View File

@@ -4,7 +4,7 @@ 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 { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Switch } from "@/components/ui/switch";
@@ -48,8 +48,10 @@ interface AgentDetailModalProps {
onSave?: () => void;
}
const PROVIDERS = ["Ollama", "OpenAI", "Anthropic", "Mistral", "Groq"];
const TOOL_OPTIONS = ["http_get", "http_post", "shell_exec", "file_read", "file_write", "docker_list", "docker_exec", "docker_logs", "browser_navigate"];
const TOOL_OPTIONS = [
"http_get", "http_post", "shell_exec", "file_read", "file_write",
"docker_list", "docker_exec", "docker_logs", "browser_navigate",
];
function toNum(v: string | number | null | undefined, fallback = 0): number {
if (v === null || v === undefined) return fallback;
@@ -57,26 +59,90 @@ function toNum(v: string | number | null | undefined, fallback = 0): number {
return isNaN(n) ? fallback : n;
}
/** Case-insensitive provider match: returns the provider.name from the list
* that best matches the agent's stored provider string, or returns the stored
* value as-is (so the Select always has a valid value). */
function resolveProviderValue(stored: string, providers: Array<{ id: string; name: string }>): string {
if (!stored) return "";
const exact = providers.find((p) => p.name === stored);
if (exact) return exact.name;
// case-insensitive fallback
const lower = stored.toLowerCase();
const ci = providers.find((p) => p.name.toLowerCase() === lower);
if (ci) return ci.name;
// partial match (e.g. stored="ollama" → provider.name="Ollama Cloud")
const partial = providers.find(
(p) => p.name.toLowerCase().includes(lower) || lower.includes(p.name.toLowerCase())
);
if (partial) return partial.name;
// Return stored value verbatim — the Select will show it via fallback item
return stored;
}
export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDetailModalProps) {
const [form, setForm] = useState<any>({});
const [newTool, setNewTool] = useState("");
const [newDomain, setNewDomain] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [maxTokensHint, setMaxTokensHint] = useState<number | null>(null);
// ─── Remote data ─────────────────────────────────────────────────────────
const { data: modelsData, isLoading: modelsLoading } = trpc.ollama.models.useQuery(undefined, {
staleTime: 60_000,
});
const { data: configData } = trpc.config.providers.useQuery(undefined, {
staleTime: 300_000,
});
const connectedProviders = configData?.providers ?? [];
// Model info (context_length) — enabled only when a model is selected
const { data: modelInfoData, isFetching: modelInfoFetching } = trpc.ollama.modelInfo.useQuery(
{ modelId: form.model ?? "" },
{
enabled: !!form.model && open,
staleTime: 300_000,
}
);
// ─── Init form from agent ─────────────────────────────────────────────────
useEffect(() => {
if (agent) setForm({ ...agent });
}, [agent]);
if (agent) {
const resolvedProvider = resolveProviderValue(agent.provider ?? "", connectedProviders);
setForm({
...agent,
provider: resolvedProvider || agent.provider,
});
setMaxTokensHint(null);
}
}, [agent]); // eslint-disable-line react-hooks/exhaustive-deps — intentionally excludes connectedProviders
const { data: modelsData } = trpc.ollama.models.useQuery();
const { data: history = [] } = trpc.agents.history.useQuery(
{ id: agent?.id ?? 0, limit: 20 },
{ enabled: !!agent && open }
);
const { data: stats } = trpc.agents.stats.useQuery(
{ id: agent?.id ?? 0, hoursBack: 24 },
{ enabled: !!agent && open }
);
// Re-resolve provider once providers load (they may arrive after agent)
useEffect(() => {
if (agent && connectedProviders.length > 0 && form.provider !== undefined) {
const resolved = resolveProviderValue(agent.provider ?? "", connectedProviders);
if (resolved && resolved !== form.provider) {
setForm((prev: any) => ({ ...prev, provider: resolved }));
}
}
}, [connectedProviders]); // eslint-disable-line react-hooks/exhaustive-deps
// When model info arrives → update maxTokens with context_length
useEffect(() => {
if (modelInfoData?.contextLength && modelInfoData.contextLength > 0) {
setMaxTokensHint(modelInfoData.contextLength);
// Only auto-update if user hasn't manually changed it
setForm((prev: any) => {
const currentMax = toNum(prev.maxTokens, 2048);
// Update if it's still at the default value or lower than the actual context window
if (currentMax <= 2048 || currentMax < modelInfoData.contextLength) {
return { ...prev, maxTokens: modelInfoData.contextLength };
}
return prev;
});
}
}, [modelInfoData]);
// ─── Mutations ───────────────────────────────────────────────────────────
const updateMutation = trpc.agents.update.useMutation({
onSuccess: () => {
toast.success("Agent configuration saved");
@@ -86,6 +152,16 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
onError: (err) => toast.error(`Save failed: ${err.message}`),
});
const { data: history = [] } = trpc.agents.history.useQuery(
{ id: agent?.id ?? 0, limit: 20 },
{ enabled: !!agent && open }
);
const { data: stats } = trpc.agents.stats.useQuery(
{ id: agent?.id ?? 0, hoursBack: 24 },
{ enabled: !!agent && open }
);
// ─── Handlers ────────────────────────────────────────────────────────────
const handleSave = async () => {
if (!agent) return;
setIsSaving(true);
@@ -138,7 +214,15 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
setForm({ ...form, allowedDomains: (form.allowedDomains || []).filter((x: string) => x !== d) });
};
const availableModels: string[] = modelsData?.models?.map((m: any) => m.id || m) ?? [];
const handleModelChange = (model: string) => {
setForm({ ...form, model });
setMaxTokensHint(null); // reset hint; modelInfo query will re-fire
};
// ─── Derived ─────────────────────────────────────────────────────────────
const fetchedModels: string[] = modelsData?.models?.map((m: any) => m.id || m) ?? [];
const availableModels: string[] =
fetchedModels.length > 0 ? fetchedModels : form.model ? [form.model] : [];
if (!agent) return null;
@@ -149,7 +233,14 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
<DialogTitle className="font-mono text-primary flex items-center gap-2">
<span className="text-muted-foreground text-xs">AGENT</span>
{agent.name}
<Badge variant="outline" className={`text-[10px] ml-2 ${form.isActive ? "border-neon-green/40 text-neon-green" : "border-muted text-muted-foreground"}`}>
<Badge
variant="outline"
className={`text-[10px] ml-2 ${
form.isActive
? "border-neon-green/40 text-neon-green"
: "border-muted text-muted-foreground"
}`}
>
{form.isActive ? "ACTIVE" : "INACTIVE"}
</Badge>
</DialogTitle>
@@ -164,43 +255,110 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
<TabsTrigger value="stats">Stats</TabsTrigger>
</TabsList>
{/* ── GENERAL ─────────────────────────────────── */}
{/* ── GENERAL ──────────────────────────────────────────────────────── */}
<TabsContent value="general" className="space-y-4 pt-2">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground font-mono">NAME</Label>
<Input value={form.name ?? ""} onChange={(e) => setForm({ ...form, name: e.target.value })} className="font-mono" />
<Input
value={form.name ?? ""}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="font-mono"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground font-mono">ROLE</Label>
<Input value={form.role ?? ""} disabled className="font-mono bg-secondary/30 text-muted-foreground" />
<Input
value={form.role ?? ""}
disabled
className="font-mono bg-secondary/30 text-muted-foreground"
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground font-mono">DESCRIPTION</Label>
<Textarea value={form.description ?? ""} onChange={(e) => setForm({ ...form, description: e.target.value })} rows={2} className="font-mono text-sm resize-none" />
<Textarea
value={form.description ?? ""}
onChange={(e) => setForm({ ...form, description: e.target.value })}
rows={2}
className="font-mono text-sm resize-none"
/>
</div>
{/* Provider + Model */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground font-mono">PROVIDER</Label>
<Select value={form.provider ?? ""} onValueChange={(v) => setForm({ ...form, provider: v, model: "" })}>
<SelectTrigger className="font-mono"><SelectValue /></SelectTrigger>
<Select
value={form.provider ?? ""}
onValueChange={(v) => setForm({ ...form, provider: v, model: "" })}
>
<SelectTrigger className="font-mono w-full">
<SelectValue placeholder="Select provider..." />
</SelectTrigger>
<SelectContent>
{PROVIDERS.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
{connectedProviders.length > 0 ? (
connectedProviders.map((p) => (
<SelectItem key={p.id} value={p.name} className="font-mono text-sm">
<span className="flex items-center gap-2">
{p.name}
<span className="text-[10px] text-neon-green font-mono"> connected</span>
</span>
</SelectItem>
))
) : (
/* Fallback: show current stored value so Select isn't empty */
<SelectItem value={form.provider ?? "ollama"} className="font-mono text-sm">
{form.provider ?? "Ollama"}
</SelectItem>
)}
{/* Always include stored value as safety net if not in list */}
{connectedProviders.length > 0 &&
form.provider &&
!connectedProviders.find((p) => p.name === form.provider) && (
<SelectItem
value={form.provider}
className="font-mono text-sm text-neon-amber"
>
{form.provider}{" "}
<span className="text-[10px] opacity-60">(not in list)</span>
</SelectItem>
)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground font-mono">MODEL</Label>
<Select value={form.model ?? ""} onValueChange={(v) => setForm({ ...form, model: v })}>
<SelectTrigger className="font-mono"><SelectValue placeholder="Select model..." /></SelectTrigger>
<Label className="text-xs text-muted-foreground font-mono flex items-center gap-2">
MODEL
{modelsLoading && <Loader2 className="w-3 h-3 animate-spin text-primary" />}
{!modelsLoading && fetchedModels.length > 0 && (
<span className="text-[10px] text-neon-green font-normal">
{fetchedModels.length} available
</span>
)}
{!modelsLoading && fetchedModels.length === 0 && (
<span className="text-[10px] text-neon-amber font-normal">
API unavailable using current
</span>
)}
</Label>
<Select value={form.model ?? ""} onValueChange={handleModelChange}>
<SelectTrigger className="font-mono">
<SelectValue
placeholder={modelsLoading ? "Loading models..." : "Select model..."}
/>
</SelectTrigger>
<SelectContent>
{availableModels.length > 0
? availableModels.map((m: string) => <SelectItem key={m} value={m}>{m}</SelectItem>)
: <SelectItem value={form.model ?? ""}>{form.model ?? "No models"}</SelectItem>
}
{availableModels.map((m: string) => (
<SelectItem key={m} value={m} className="font-mono text-sm">
{m}
{m === agent.model && (
<span className="ml-2 text-[10px] text-muted-foreground">(current)</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
@@ -222,75 +380,149 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
<div className="text-sm font-medium">Agent Active</div>
<div className="text-xs text-muted-foreground">Enable/disable this agent</div>
</div>
<Switch checked={form.isActive ?? true} onCheckedChange={(v) => setForm({ ...form, isActive: v })} />
<Switch
checked={form.isActive ?? true}
onCheckedChange={(v) => setForm({ ...form, isActive: v })}
/>
</div>
</TabsContent>
{/* ── LLM PARAMS ──────────────────────────────── */}
{/* ── LLM PARAMS ───────────────────────────────────────────────────── */}
<TabsContent value="llm" className="space-y-5 pt-2">
<div className="grid grid-cols-2 gap-5">
<div className="space-y-2">
<div className="flex justify-between">
<Label className="text-xs text-muted-foreground font-mono">TEMPERATURE</Label>
<span className="text-xs font-mono text-primary">{toNum(form.temperature, 0.7).toFixed(2)}</span>
<span className="text-xs font-mono text-primary">
{toNum(form.temperature, 0.7).toFixed(2)}
</span>
</div>
<input type="range" min="0" max="2" step="0.01"
<input
type="range"
min="0"
max="2"
step="0.01"
value={toNum(form.temperature, 0.7)}
onChange={(e) => setForm({ ...form, temperature: parseFloat(e.target.value) })}
className="w-full accent-cyan-400 cursor-pointer" />
onChange={(e) =>
setForm({ ...form, temperature: parseFloat(e.target.value) })
}
className="w-full accent-cyan-400 cursor-pointer"
/>
<div className="flex justify-between text-[10px] text-muted-foreground font-mono">
<span>0.0 (precise)</span><span>2.0 (creative)</span>
<span>0.0 (precise)</span>
<span>2.0 (creative)</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between">
<Label className="text-xs text-muted-foreground font-mono">TOP P</Label>
<span className="text-xs font-mono text-primary">{toNum(form.topP, 1.0).toFixed(2)}</span>
<span className="text-xs font-mono text-primary">
{toNum(form.topP, 1.0).toFixed(2)}
</span>
</div>
<input type="range" min="0" max="1" step="0.01"
<input
type="range"
min="0"
max="1"
step="0.01"
value={toNum(form.topP, 1.0)}
onChange={(e) => setForm({ ...form, topP: parseFloat(e.target.value) })}
className="w-full accent-cyan-400 cursor-pointer" />
className="w-full accent-cyan-400 cursor-pointer"
/>
<div className="flex justify-between text-[10px] text-muted-foreground font-mono">
<span>0.0</span><span>1.0</span>
<span>0.0</span>
<span>1.0</span>
</div>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground font-mono">MAX TOKENS</Label>
<Input type="number" value={form.maxTokens ?? 2048}
onChange={(e) => setForm({ ...form, maxTokens: parseInt(e.target.value) || 2048 })}
className="font-mono" min={1} max={128000} />
<Label className="text-xs text-muted-foreground font-mono flex items-center gap-2">
MAX TOKENS
{modelInfoFetching && (
<Loader2 className="w-3 h-3 animate-spin text-primary" />
)}
{maxTokensHint && maxTokensHint > 0 && !modelInfoFetching && (
<span className="text-[10px] text-neon-green font-normal">
context window: {maxTokensHint.toLocaleString()}
</span>
)}
</Label>
<Input
type="number"
value={form.maxTokens ?? 2048}
onChange={(e) =>
setForm({ ...form, maxTokens: parseInt(e.target.value) || 2048 })
}
className="font-mono"
min={1}
max={maxTokensHint || 1000000}
/>
{maxTokensHint && maxTokensHint > 0 && (
<p className="text-[10px] text-muted-foreground font-mono">
Set to{" "}
<button
className="text-primary hover:underline"
onClick={() => setForm({ ...form, maxTokens: maxTokensHint })}
>
{maxTokensHint.toLocaleString()}
</button>{" "}
to use full context window
</p>
)}
</div>
<div className="grid grid-cols-2 gap-5">
<div className="space-y-2">
<div className="flex justify-between">
<Label className="text-xs text-muted-foreground font-mono">FREQUENCY PENALTY</Label>
<span className="text-xs font-mono text-primary">{toNum(form.frequencyPenalty, 0).toFixed(2)}</span>
<Label className="text-xs text-muted-foreground font-mono">
FREQUENCY PENALTY
</Label>
<span className="text-xs font-mono text-primary">
{toNum(form.frequencyPenalty, 0).toFixed(2)}
</span>
</div>
<input type="range" min="-2" max="2" step="0.01"
<input
type="range"
min="-2"
max="2"
step="0.01"
value={toNum(form.frequencyPenalty, 0)}
onChange={(e) => setForm({ ...form, frequencyPenalty: parseFloat(e.target.value) })}
className="w-full accent-cyan-400 cursor-pointer" />
onChange={(e) =>
setForm({ ...form, frequencyPenalty: parseFloat(e.target.value) })
}
className="w-full accent-cyan-400 cursor-pointer"
/>
<div className="flex justify-between text-[10px] text-muted-foreground font-mono">
<span>-2.0</span><span>+2.0</span>
<span>-2.0</span>
<span>+2.0</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between">
<Label className="text-xs text-muted-foreground font-mono">PRESENCE PENALTY</Label>
<span className="text-xs font-mono text-primary">{toNum(form.presencePenalty, 0).toFixed(2)}</span>
<Label className="text-xs text-muted-foreground font-mono">
PRESENCE PENALTY
</Label>
<span className="text-xs font-mono text-primary">
{toNum(form.presencePenalty, 0).toFixed(2)}
</span>
</div>
<input type="range" min="-2" max="2" step="0.01"
<input
type="range"
min="-2"
max="2"
step="0.01"
value={toNum(form.presencePenalty, 0)}
onChange={(e) => setForm({ ...form, presencePenalty: parseFloat(e.target.value) })}
className="w-full accent-cyan-400 cursor-pointer" />
onChange={(e) =>
setForm({ ...form, presencePenalty: parseFloat(e.target.value) })
}
className="w-full accent-cyan-400 cursor-pointer"
/>
<div className="flex justify-between text-[10px] text-muted-foreground font-mono">
<span>-2.0</span><span>+2.0</span>
<span>-2.0</span>
<span>+2.0</span>
</div>
</div>
</div>
@@ -299,24 +531,42 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
<CardContent className="p-3">
<div className="text-xs text-muted-foreground font-mono mb-2">PARAMETER GUIDE</div>
<div className="space-y-1 text-xs text-muted-foreground">
<div><span className="text-primary font-mono">temperature</span> randomness (0=deterministic, 2=very random)</div>
<div><span className="text-primary font-mono">top_p</span> nucleus sampling (0.9=top 90% tokens)</div>
<div><span className="text-primary font-mono">freq_penalty</span> reduce word repetition</div>
<div><span className="text-primary font-mono">pres_penalty</span> encourage topic diversity</div>
<div>
<span className="text-primary font-mono">temperature</span> randomness
(0=deterministic, 2=very random)
</div>
<div>
<span className="text-primary font-mono">top_p</span> nucleus sampling
(0.9=top 90% tokens)
</div>
<div>
<span className="text-primary font-mono">freq_penalty</span> reduce word
repetition
</div>
<div>
<span className="text-primary font-mono">pres_penalty</span> encourage topic
diversity
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* ── TOOLS ───────────────────────────────────── */}
{/* ── TOOLS ────────────────────────────────────────────────────────── */}
<TabsContent value="tools" className="space-y-4 pt-2">
<div className="space-y-2">
<Label className="text-xs text-muted-foreground font-mono">ALLOWED TOOLS</Label>
<div className="flex gap-2">
<Select value={newTool} onValueChange={setNewTool}>
<SelectTrigger className="font-mono flex-1"><SelectValue placeholder="Select tool..." /></SelectTrigger>
<SelectTrigger className="font-mono flex-1">
<SelectValue placeholder="Select tool..." />
</SelectTrigger>
<SelectContent>
{TOOL_OPTIONS.map((t) => <SelectItem key={t} value={t}>{t}</SelectItem>)}
{TOOL_OPTIONS.map((t) => (
<SelectItem key={t} value={t} className="font-mono text-sm">
{t}
</SelectItem>
))}
</SelectContent>
</Select>
<Button size="sm" variant="outline" onClick={addTool} disabled={!newTool}>
@@ -324,22 +574,34 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
</Button>
</div>
<div className="flex flex-wrap gap-2 min-h-[40px] p-2 rounded-md bg-secondary/20 border border-border/30">
{(form.allowedTools || []).length === 0
? <span className="text-xs text-muted-foreground">No tools assigned all tools blocked</span>
: (form.allowedTools || []).map((tool: string) => (
<Badge key={tool} variant="outline" className="font-mono text-[11px] gap-1 border-primary/30 text-primary">
{(form.allowedTools || []).length === 0 ? (
<span className="text-xs text-muted-foreground">
No tools assigned all tools blocked
</span>
) : (
(form.allowedTools || []).map((tool: string) => (
<Badge
key={tool}
variant="outline"
className="font-mono text-[11px] gap-1 border-primary/30 text-primary"
>
{tool}
<button onClick={() => removeTool(tool)} className="hover:text-neon-red transition-colors">
<button
onClick={() => removeTool(tool)}
className="hover:text-neon-red transition-colors"
>
<X className="w-3 h-3" />
</button>
</Badge>
))
}
)}
</div>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground font-mono">ALLOWED DOMAINS (HTTP whitelist)</Label>
<Label className="text-xs text-muted-foreground font-mono">
ALLOWED DOMAINS (HTTP whitelist)
</Label>
<div className="flex gap-2">
<Input
placeholder="e.g. api.github.com"
@@ -348,27 +610,42 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
onKeyDown={(e) => e.key === "Enter" && addDomain()}
className="font-mono flex-1"
/>
<Button size="sm" variant="outline" onClick={addDomain} disabled={!newDomain.trim()}>
<Button
size="sm"
variant="outline"
onClick={addDomain}
disabled={!newDomain.trim()}
>
<Plus className="w-4 h-4" />
</Button>
</div>
<div className="flex flex-wrap gap-2 min-h-[40px] p-2 rounded-md bg-secondary/20 border border-border/30">
{(form.allowedDomains || []).length === 0
? <span className="text-xs text-muted-foreground">No domain restrictions all domains allowed</span>
: (form.allowedDomains || []).map((d: string) => (
<Badge key={d} variant="outline" className="font-mono text-[11px] gap-1 border-neon-amber/30 text-neon-amber">
{(form.allowedDomains || []).length === 0 ? (
<span className="text-xs text-muted-foreground">
No domain restrictions all domains allowed
</span>
) : (
(form.allowedDomains || []).map((d: string) => (
<Badge
key={d}
variant="outline"
className="font-mono text-[11px] gap-1 border-neon-amber/30 text-neon-amber"
>
{d}
<button onClick={() => removeDomain(d)} className="hover:text-neon-red transition-colors">
<button
onClick={() => removeDomain(d)}
className="hover:text-neon-red transition-colors"
>
<X className="w-3 h-3" />
</button>
</Badge>
))
}
)}
</div>
</div>
</TabsContent>
{/* ── HISTORY ─────────────────────────────────── */}
{/* ── HISTORY ──────────────────────────────────────────────────────── */}
<TabsContent value="history" className="pt-2">
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-1">
{(history as any[]).length === 0 ? (
@@ -381,10 +658,11 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
<CardContent className="p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{item.status === "success"
? <CheckCircle className="w-3.5 h-3.5 text-neon-green" />
: <XCircle className="w-3.5 h-3.5 text-neon-red" />
}
{item.status === "success" ? (
<CheckCircle className="w-3.5 h-3.5 text-neon-green" />
) : (
<XCircle className="w-3.5 h-3.5 text-neon-red" />
)}
<span className="text-[10px] font-mono text-muted-foreground">
{new Date(item.createdAt).toLocaleString()}
</span>
@@ -416,22 +694,44 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
</div>
</TabsContent>
{/* ── STATS ───────────────────────────────────── */}
{/* ── STATS ────────────────────────────────────────────────────────── */}
<TabsContent value="stats" className="pt-2">
{stats ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
{[
{ label: "Total Requests (24h)", value: stats.totalRequests, icon: Zap, color: "text-primary" },
{ label: "Success Rate", value: `${stats.successRate.toFixed(1)}%`, icon: CheckCircle, color: "text-neon-green" },
{ label: "Avg Processing", value: `${stats.avgProcessingTime}ms`, icon: Clock, color: "text-neon-amber" },
{ label: "Total Tokens", value: stats.totalTokens.toLocaleString(), icon: Zap, color: "text-primary" },
{
label: "Total Requests (24h)",
value: stats.totalRequests,
icon: Zap,
color: "text-primary",
},
{
label: "Success Rate",
value: `${stats.successRate.toFixed(1)}%`,
icon: CheckCircle,
color: "text-neon-green",
},
{
label: "Avg Processing",
value: `${stats.avgProcessingTime}ms`,
icon: Clock,
color: "text-neon-amber",
},
{
label: "Total Tokens",
value: stats.totalTokens.toLocaleString(),
icon: Zap,
color: "text-primary",
},
].map(({ label, value, icon: Icon, color }) => (
<Card key={label} className="bg-secondary/20 border-border/30">
<CardContent className="p-3">
<div className="flex items-center gap-2 mb-1">
<Icon className={`w-3.5 h-3.5 ${color}`} />
<span className="text-[10px] text-muted-foreground font-mono">{label.toUpperCase()}</span>
<span className="text-[10px] text-muted-foreground font-mono">
{label.toUpperCase()}
</span>
</div>
<div className={`text-xl font-bold font-mono ${color}`}>{value}</div>
</CardContent>
@@ -441,7 +741,9 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
<Card className="bg-secondary/20 border-border/30">
<CardContent className="p-3">
<div className="text-[10px] text-muted-foreground font-mono mb-2">REQUEST BREAKDOWN</div>
<div className="text-[10px] text-muted-foreground font-mono mb-2">
REQUEST BREAKDOWN
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-neon-green font-mono">Success</span>
@@ -450,7 +752,13 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
<div className="w-full h-1.5 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-neon-green rounded-full transition-all"
style={{ width: `${stats.totalRequests > 0 ? (stats.successRequests / stats.totalRequests) * 100 : 0}%` }}
style={{
width: `${
stats.totalRequests > 0
? (stats.successRequests / stats.totalRequests) * 100
: 0
}%`,
}}
/>
</div>
<div className="flex items-center justify-between text-xs">
@@ -460,7 +768,13 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
<div className="w-full h-1.5 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-neon-red rounded-full transition-all"
style={{ width: `${stats.totalRequests > 0 ? (stats.errorRequests / stats.totalRequests) * 100 : 0}%` }}
style={{
width: `${
stats.totalRequests > 0
? (stats.errorRequests / stats.totalRequests) * 100
: 0
}%`,
}}
/>
</div>
</div>
@@ -468,7 +782,9 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
</Card>
<div className="text-[10px] text-muted-foreground font-mono text-center">
Agent ID: {agent.id} · Created: {new Date(agent.createdAt).toLocaleDateString()} · Updated: {new Date(agent.updatedAt).toLocaleDateString()}
Agent ID: {agent.id} · Created:{" "}
{new Date(agent.createdAt).toLocaleDateString()} · Updated:{" "}
{new Date(agent.updatedAt).toLocaleDateString()}
</div>
</div>
) : (
@@ -480,13 +796,25 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
</Tabs>
<div className="flex justify-end gap-2 pt-2 border-t border-border/30">
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleSave} disabled={isSaving || updateMutation.isPending}
className="bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25">
{isSaving || updateMutation.isPending
? <><Loader2 className="w-4 h-4 mr-2 animate-spin" />Saving...</>
: <><Save className="w-4 h-4 mr-2" />Save Changes</>
}
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={isSaving || updateMutation.isPending}
className="bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25"
>
{isSaving || updateMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Save Changes
</>
)}
</Button>
</div>
</DialogContent>

View File

@@ -0,0 +1,551 @@
/**
* ClusterTopology — Animated SVG network visualization for the dashboard.
*
* Shows real swarm nodes (manager / workers), services, agents,
* overlay-network links with animated "data packets", and live status pulses.
*
* Data: 100 % real from tRPC — nodes.list, nodes.services, agents.list
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { motion } from "framer-motion";
import { trpc } from "@/lib/trpc";
import { Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
/* ─── colour tokens (match index.css oklch values) ─────────────────────── */
const C = {
cyan: "oklch(0.82 0.15 195)",
green: "oklch(0.82 0.2 155)",
amber: "oklch(0.82 0.16 80)",
red: "oklch(0.7 0.22 20)",
muted: "oklch(0.4 0.02 260)",
surface: "oklch(0.16 0.02 260)",
deep: "oklch(0.12 0.02 260)",
panel: "oklch(0.19 0.02 260)",
text: "oklch(0.92 0.01 260)",
textDim: "oklch(0.6 0.02 260)",
};
/* ─── types for the layout engine ──────────────────────────────────────── */
interface LayoutNode {
id: string;
kind: "manager" | "worker" | "service" | "agent" | "overlay";
label: string;
sub: string;
x: number;
y: number;
r: number;
color: string;
glowColor: string;
status: string;
extra?: Record<string, string>;
}
interface LayoutEdge {
id: string;
from: string;
to: string;
color: string;
dashed: boolean;
label?: string;
animated: boolean;
}
/* ─── helpers ──────────────────────────────────────────────────────────── */
function memLabel(mb: number) {
return mb > 1024 ? `${(mb / 1024).toFixed(1)} GB` : `${mb} MB`;
}
/* ─── main component ───────────────────────────────────────────────────── */
export default function ClusterTopology() {
const svgRef = useRef<SVGSVGElement>(null);
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
const [tick, setTick] = useState(0);
/* Live data */
const nodesQ = trpc.nodes.list.useQuery(undefined, { refetchInterval: 15_000 });
const servicesQ = trpc.nodes.services.useQuery(undefined, { refetchInterval: 30_000 });
const agentsQ = trpc.agents.list.useQuery(undefined, { refetchInterval: 30_000 });
/* Animation tick for data-flow particles */
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 50);
return () => clearInterval(id);
}, []);
/* ── Build layout ─────────────────────────────────────────────────── */
const { nodes: lnodes, edges: ledges } = useMemo(() => {
const nodes: LayoutNode[] = [];
const edges: LayoutEdge[] = [];
const W = 900;
const H = 460;
const CX = W / 2;
const CY = H / 2 - 10;
const swarmNodes = nodesQ.data?.nodes ?? [];
const services = servicesQ.data?.services ?? [];
const agents = (agentsQ.data ?? []).filter((a: any) => a.isActive);
if (swarmNodes.length === 0) return { nodes, edges };
/* Separate managers and workers */
const managers = swarmNodes.filter((n: any) => n.role === "manager");
const workers = swarmNodes.filter((n: any) => n.role !== "manager");
/* — Overlay network hub (centre) — */
nodes.push({
id: "__overlay__",
kind: "overlay",
label: "goclaw-net",
sub: "overlay network",
x: CX,
y: CY,
r: 36,
color: C.cyan,
glowColor: C.cyan,
status: "active",
});
/* — Manager(s) — top section, spread horizontally — */
const managerY = 60;
managers.forEach((sn: any, i: number) => {
const spread = managers.length > 1 ? (i - (managers.length - 1) / 2) * 140 : 0;
const nodeId = `node_${sn.id}`;
nodes.push({
id: nodeId,
kind: "manager",
label: sn.hostname,
sub: `${sn.ip} · Docker ${sn.dockerVersion}`,
x: CX + spread,
y: managerY,
r: 32,
color: C.green,
glowColor: C.green,
status: sn.availability ?? sn.state,
extra: {
role: "manager",
cpu: `${sn.cpuCores} cores`,
mem: memLabel(sn.memTotalMB),
leader: sn.isLeader ? "★ Leader" : "",
},
});
edges.push({
id: `edge_overlay_${sn.id}`,
from: "__overlay__",
to: nodeId,
color: C.green,
dashed: false,
animated: true,
});
});
/* — Workers — positioned to the left and right of the hub — */
const workerXSpacing = 160;
const workerBaseY = CY;
workers.forEach((sn: any, i: number) => {
const side = i % 2 === 0 ? -1 : 1;
const tier = Math.floor(i / 2);
const nodeId = `node_${sn.id}`;
nodes.push({
id: nodeId,
kind: "worker",
label: sn.hostname,
sub: `${sn.ip} · Docker ${sn.dockerVersion}`,
x: CX + side * (workerXSpacing + tier * 100),
y: workerBaseY + tier * 50,
r: 26,
color: C.cyan,
glowColor: C.cyan,
status: sn.availability ?? sn.state,
extra: {
role: "worker",
cpu: `${sn.cpuCores} cores`,
mem: memLabel(sn.memTotalMB),
},
});
edges.push({
id: `edge_overlay_${sn.id}`,
from: "__overlay__",
to: nodeId,
color: C.cyan,
dashed: false,
animated: true,
});
});
/* — Services — positioned to the right of the manager — */
const managerNodeId = managers.length > 0
? `node_${managers[0].id}`
: nodes[1]?.id;
if (managerNodeId) {
const mNode = nodes.find((n) => n.id === managerNodeId);
const svcBaseX = (mNode?.x ?? CX) + 120;
const svcBaseY = (mNode?.y ?? managerY) - 10;
services.forEach((svc: any, i: number) => {
const svcId = `svc_${svc.id}`;
nodes.push({
id: svcId,
kind: "service",
label: svc.name,
sub: `${svc.mode} · ${svc.runningTasks}/${svc.desiredReplicas} replicas`,
x: svcBaseX + i * 60,
y: svcBaseY + (i % 2) * 30,
r: 16,
color: C.amber,
glowColor: C.amber,
status: svc.runningTasks > 0 ? "running" : "stopped",
});
edges.push({
id: `edge_svc_${svc.id}`,
from: managerNodeId,
to: svcId,
color: C.amber,
dashed: true,
label: "service",
animated: svc.runningTasks > 0,
});
});
}
/* — Agents — spread across a wide arc below the hub — */
const agentCY = H - 80;
const totalAgents = agents.length;
const agentSpacing = Math.min(110, (W - 160) / Math.max(totalAgents, 1));
const agentStartX = CX - ((totalAgents - 1) * agentSpacing) / 2;
agents.forEach((ag: any, i: number) => {
const agId = `agent_${ag.id}`;
const isOrch = ag.isOrchestrator;
const yJitter = (i % 2) * 20;
nodes.push({
id: agId,
kind: "agent",
label: ag.name,
sub: `${ag.model} · ${ag.role}`,
x: agentStartX + i * agentSpacing,
y: agentCY + yJitter,
r: isOrch ? 22 : 16,
color: isOrch ? C.green : C.cyan,
glowColor: isOrch ? C.green : C.cyan,
status: ag.isActive ? "active" : "idle",
extra: {
orchestrator: isOrch ? "Yes" : "",
model: ag.model,
role: ag.role,
},
});
edges.push({
id: `edge_agent_${ag.id}`,
from: "__overlay__",
to: agId,
color: isOrch ? C.green : C.textDim,
dashed: true,
animated: ag.isActive,
});
});
return { nodes, edges };
}, [nodesQ.data, servicesQ.data, agentsQ.data]);
const isLoading = nodesQ.isLoading;
/* ── SVG dimensions ──────────────────────────────────────────────── */
const W = 900;
const H = 460;
/* ── tooltip ─────────────────────────────────────────────────────── */
const hNode = lnodes.find((n) => n.id === hoveredNode);
return (
<div className="relative w-full" style={{ aspectRatio: `${W}/${H}`, maxHeight: 480 }}>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center z-20 bg-card/80 rounded-md">
<Loader2 className="w-6 h-6 text-primary animate-spin mr-2" />
<span className="font-mono text-xs text-muted-foreground">Loading topology</span>
</div>
)}
<svg
ref={svgRef}
viewBox={`0 0 ${W} ${H}`}
className="w-full h-full"
style={{ fontFamily: "'JetBrains Mono', monospace" }}
>
{/* ── defs ──────────────────────────────────────────────── */}
<defs>
{/* Glow filter */}
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="6" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="glowSoft" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
{/* Animated dash gradient */}
<linearGradient id="gradCyan" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor={C.cyan} stopOpacity={0.1} />
<stop offset="50%" stopColor={C.cyan} stopOpacity={0.7} />
<stop offset="100%" stopColor={C.cyan} stopOpacity={0.1} />
</linearGradient>
{/* Grid pattern */}
<pattern id="gridPat" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke={C.muted} strokeWidth="0.3" opacity="0.25" />
</pattern>
{/* Radial backgrounds */}
<radialGradient id="hubGlow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor={C.cyan} stopOpacity={0.15} />
<stop offset="100%" stopColor={C.cyan} stopOpacity={0} />
</radialGradient>
</defs>
{/* ── Background ───────────────────────────────────────── */}
<rect width={W} height={H} fill={C.deep} rx="8" />
<rect width={W} height={H} fill="url(#gridPat)" rx="8" />
{/* Hub radial glow */}
<circle cx={W / 2} cy={H / 2} r={120} fill="url(#hubGlow)" />
{/* ── Edges ────────────────────────────────────────────── */}
{ledges.map((edge) => {
const from = lnodes.find((n) => n.id === edge.from);
const to = lnodes.find((n) => n.id === edge.to);
if (!from || !to) return null;
const dx = to.x - from.x;
const dy = to.y - from.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const ux = dx / dist;
const uy = dy / dist;
const x1 = from.x + ux * from.r;
const y1 = from.y + uy * from.r;
const x2 = to.x - ux * to.r;
const y2 = to.y - uy * to.r;
const isHovered = hoveredNode === edge.from || hoveredNode === edge.to;
return (
<g key={edge.id}>
{/* Main line */}
<line
x1={x1} y1={y1} x2={x2} y2={y2}
stroke={edge.color}
strokeWidth={isHovered ? 2 : 1}
strokeOpacity={isHovered ? 0.8 : 0.3}
strokeDasharray={edge.dashed ? "6 4" : undefined}
/>
{/* Animated data-flow particle */}
{edge.animated && (
<circle r={isHovered ? 3 : 2} fill={edge.color} opacity={0.9} filter="url(#glowSoft)">
<animateMotion
dur={`${2 + Math.random() * 1.5}s`}
repeatCount="indefinite"
path={`M${x1},${y1} L${x2},${y2}`}
/>
</circle>
)}
{/* Second particle (offset) for busier connections */}
{edge.animated && !edge.dashed && (
<circle r={1.5} fill={edge.color} opacity={0.5}>
<animateMotion
dur={`${3 + Math.random()}s`}
repeatCount="indefinite"
begin={`${1 + Math.random()}s`}
path={`M${x2},${y2} L${x1},${y1}`}
/>
</circle>
)}
</g>
);
})}
{/* ── Nodes ────────────────────────────────────────────── */}
{lnodes.map((node) => {
const isHovered = hoveredNode === node.id;
const scale = isHovered ? 1.12 : 1;
return (
<g
key={node.id}
transform={`translate(${node.x}, ${node.y})`}
style={{ cursor: "pointer" }}
onMouseEnter={() => setHoveredNode(node.id)}
onMouseLeave={() => setHoveredNode(null)}
>
{/* Pulse ring */}
{(node.status === "active" || node.status === "running") && (
<circle r={node.r + 8} fill="none" stroke={node.color} strokeWidth="1" opacity="0.4">
<animate attributeName="r" values={`${node.r + 4};${node.r + 14};${node.r + 4}`} dur="3s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.5;0.1;0.5" dur="3s" repeatCount="indefinite" />
</circle>
)}
{/* Outer glow ring */}
<circle
r={node.r * scale}
fill="none"
stroke={node.glowColor}
strokeWidth={isHovered ? 2.5 : 1.5}
opacity={isHovered ? 0.8 : 0.5}
filter="url(#glowSoft)"
/>
{/* Main body */}
<circle
r={(node.r - 2) * scale}
fill={C.panel}
stroke={node.color}
strokeWidth={isHovered ? 2 : 1}
opacity={0.95}
/>
{/* Icon / text inside */}
{node.kind === "overlay" && (
<>
<text textAnchor="middle" dominantBaseline="central" fontSize="10" fill={C.cyan} fontWeight="bold" dy="-4">
</text>
<text textAnchor="middle" dominantBaseline="central" fontSize="7" fill={C.text} dy="8" fontWeight="500">
{node.label}
</text>
</>
)}
{node.kind === "manager" && (
<>
<text textAnchor="middle" dominantBaseline="central" fontSize="14" fill={C.green} dy="-3">
👑
</text>
<text textAnchor="middle" dominantBaseline="central" fontSize="7" fill={C.text} dy="12" fontWeight="600">
{node.label}
</text>
</>
)}
{node.kind === "worker" && (
<>
<text textAnchor="middle" dominantBaseline="central" fontSize="12" fill={C.cyan} dy="-2">
</text>
<text textAnchor="middle" dominantBaseline="central" fontSize="7" fill={C.text} dy="10" fontWeight="500">
{node.label}
</text>
</>
)}
{node.kind === "service" && (
<>
<text textAnchor="middle" dominantBaseline="central" fontSize="9" fill={C.amber} dy="-1">
</text>
<text textAnchor="middle" dominantBaseline="central" fontSize="5.5" fill={C.textDim} dy="8">
{node.label.length > 12 ? node.label.slice(0, 12) + "…" : node.label}
</text>
</>
)}
{node.kind === "agent" && (
<>
<text textAnchor="middle" dominantBaseline="central" fontSize={node.r > 16 ? "10" : "8"} fill={node.color} dy="-1">
🤖
</text>
<text textAnchor="middle" dominantBaseline="central" fontSize="5" fill={C.textDim} dy={node.r > 16 ? "10" : "8"}>
{node.label.length > 14 ? node.label.slice(0, 14) + "…" : node.label}
</text>
</>
)}
{/* Status dot */}
<circle
cx={node.r * 0.7 * scale}
cy={-node.r * 0.7 * scale}
r={3}
fill={
node.status === "active" || node.status === "running"
? C.green
: node.status === "drain" || node.status === "stopped"
? C.amber
: C.red
}
stroke={C.deep}
strokeWidth={1.5}
/>
</g>
);
})}
{/* ── Legend (bottom-left) ──────────────────────────────── */}
<g transform={`translate(16, ${H - 74})`} fontSize="8" fill={C.textDim}>
<text fontWeight="600" fontSize="9" fill={C.text}>Topology Legend</text>
{[
{ icon: "👑", label: "Manager", color: C.green, y: 16 },
{ icon: "⚙", label: "Worker", color: C.cyan, y: 28 },
{ icon: "🤖", label: "Agent", color: C.cyan, y: 40 },
{ icon: "◆", label: "Service", color: C.amber, y: 52 },
{ icon: "⬡", label: "Overlay", color: C.cyan, y: 64 },
].map((it) => (
<g key={it.label} transform={`translate(0, ${it.y})`}>
<text fontSize="9" dy="1">{it.icon}</text>
<text x="16" fill={it.color} fontWeight="500">{it.label}</text>
</g>
))}
</g>
{/* ── Stats (bottom-right) ─────────────────────────────── */}
<g transform={`translate(${W - 160}, ${H - 46})`} fontSize="8" fill={C.textDim}>
<text fontWeight="600" fontSize="9" fill={C.text}>Cluster Stats</text>
<text y="14">
Nodes: <tspan fill={C.green} fontWeight="600">{nodesQ.data?.nodes?.length ?? 0}</tspan>
{" · "}Services: <tspan fill={C.amber} fontWeight="600">{servicesQ.data?.services?.length ?? 0}</tspan>
</text>
<text y="26">
Agents: <tspan fill={C.cyan} fontWeight="600">{(agentsQ.data ?? []).filter((a: any) => a.isActive).length}</tspan>
{" · "}Swarm: <tspan fill={nodesQ.data?.swarmActive ? C.green : C.red} fontWeight="600">{nodesQ.data?.swarmActive ? "active" : "off"}</tspan>
</text>
</g>
</svg>
{/* ── Hover tooltip (HTML overlay) ───────────────────────────── */}
{hNode && (
<motion.div
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="absolute z-30 pointer-events-none"
style={{
left: `${(hNode.x / W) * 100}%`,
top: `${(hNode.y / H) * 100 - 2}%`,
transform: "translate(-50%, -110%)",
}}
>
<div className="bg-card/95 border border-border/60 rounded-lg px-3 py-2 shadow-xl backdrop-blur-sm min-w-[160px]">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-semibold text-foreground">{hNode.label}</span>
<Badge variant="outline" className={`text-[8px] font-mono px-1 py-0 ${
hNode.status === "active" || hNode.status === "running"
? "bg-neon-green/15 text-neon-green border-neon-green/30"
: "bg-neon-amber/15 text-neon-amber border-neon-amber/30"
}`}>
{hNode.status.toUpperCase()}
</Badge>
</div>
<div className="text-[10px] font-mono text-muted-foreground">{hNode.sub}</div>
{hNode.extra && Object.entries(hNode.extra).filter(([, v]) => v).map(([k, v]) => (
<div key={k} className="text-[10px] font-mono mt-0.5">
<span className="text-muted-foreground">{k}: </span>
<span className="text-primary">{v}</span>
</div>
))}
</div>
</motion.div>
)}
</div>
);
}

View File

@@ -20,6 +20,7 @@ import {
Wifi,
Wrench,
Zap,
GitBranch,
} from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { motion, AnimatePresence } from "framer-motion";
@@ -28,6 +29,7 @@ import { trpc } from "@/lib/trpc";
const NAV_ITEMS = [
{ path: "/", icon: LayoutDashboard, label: "Дашборд" },
{ path: "/agents", icon: Bot, label: "Агенты" },
{ path: "/workflows", icon: GitBranch, label: "Воркфлоу" },
{ path: "/tools", icon: Wrench, label: "Инструменты" },
{ path: "/skills", icon: Zap, label: "Скилы" },
{ path: "/nodes", icon: Server, label: "Ноды" },

View File

@@ -0,0 +1,557 @@
/**
* TaskBoard — interactive task management panel for the Chat right-side panel.
*
* Features:
* - Task list with status badges (pending, in_progress, completed, failed, blocked)
* - Expandable subtasks per task (agents can add subtasks)
* - Time tracking: per-task elapsed, global total elapsed
* - Progress bar with completion percentage
* - Auto-retry toggle
* - Quick add task form
*/
import { useState, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
chatStore,
type Task,
type TaskSubtask,
type TaskStatus,
type TaskPriority,
} from "@/lib/chatStore";
import { Badge } from "@/components/ui/badge";
import {
CheckCircle,
XCircle,
Clock,
Loader2,
ChevronDown,
ChevronRight,
Plus,
Trash2,
RefreshCw,
AlertTriangle,
Circle,
Timer,
BarChart3,
Pause,
Play,
ListTodo,
Zap,
Bot,
User,
Shield,
Target,
} from "lucide-react";
// ─── Helpers ─────────────────────────────────────────────────────────────────
function formatElapsed(ms: number): string {
if (ms < 1000) return "0s";
const totalSec = Math.floor(ms / 1000);
const hours = Math.floor(totalSec / 3600);
const mins = Math.floor((totalSec % 3600) / 60);
const secs = totalSec % 60;
if (hours > 0) return `${hours}h ${mins}m ${secs}s`;
if (mins > 0) return `${mins}m ${secs}s`;
return `${secs}s`;
}
function statusColor(status: TaskStatus): string {
switch (status) {
case "completed": return "text-green-400";
case "in_progress": return "text-cyan-400";
case "failed": return "text-red-400";
case "blocked": return "text-amber-400";
case "pending": return "text-muted-foreground";
}
}
function statusBg(status: TaskStatus): string {
switch (status) {
case "completed": return "bg-green-500/10 border-green-500/25";
case "in_progress": return "bg-cyan-500/10 border-cyan-500/25";
case "failed": return "bg-red-500/10 border-red-500/25";
case "blocked": return "bg-amber-500/10 border-amber-500/25";
case "pending": return "bg-secondary/30 border-border/40";
}
}
function StatusIcon({ status, className = "w-3.5 h-3.5" }: { status: TaskStatus; className?: string }) {
switch (status) {
case "completed": return <CheckCircle className={`${className} text-green-400`} />;
case "in_progress": return <Loader2 className={`${className} text-cyan-400 animate-spin`} />;
case "failed": return <XCircle className={`${className} text-red-400`} />;
case "blocked": return <AlertTriangle className={`${className} text-amber-400`} />;
case "pending": return <Circle className={`${className} text-muted-foreground`} />;
}
}
function PriorityBadge({ priority }: { priority: TaskPriority }) {
const styles: Record<TaskPriority, string> = {
critical: "bg-red-500/20 text-red-400 border-red-500/30",
high: "bg-orange-500/20 text-orange-400 border-orange-500/30",
medium: "bg-blue-500/20 text-blue-400 border-blue-500/30",
low: "bg-gray-500/20 text-gray-400 border-gray-500/30",
};
return (
<Badge variant="outline" className={`text-[8px] h-3.5 px-1 font-mono ${styles[priority]}`}>
{priority}
</Badge>
);
}
function CreatorBadge({ createdBy }: { createdBy: string }) {
if (createdBy === "user") {
return (
<span className="inline-flex items-center gap-0.5 text-[8px] font-mono text-primary/60">
<User className="w-2 h-2" /> user
</span>
);
}
if (createdBy === "orchestrator") {
return (
<span className="inline-flex items-center gap-0.5 text-[8px] font-mono text-cyan-400/60">
<Shield className="w-2 h-2" /> orch
</span>
);
}
return (
<span className="inline-flex items-center gap-0.5 text-[8px] font-mono text-purple-400/60">
<Bot className="w-2 h-2" /> {createdBy}
</span>
);
}
// ─── Subtask Item ────────────────────────────────────────────────────────────
function SubtaskItem({ task, sub }: { task: Task; sub: TaskSubtask }) {
const cycleStatus = () => {
const next: Record<TaskStatus, TaskStatus> = {
pending: "in_progress",
in_progress: "completed",
completed: "pending",
failed: "pending",
blocked: "pending",
};
chatStore.updateSubtaskStatus(task.id, sub.id, next[sub.status]);
};
return (
<motion.div
initial={{ opacity: 0, x: -5 }}
animate={{ opacity: 1, x: 0 }}
className="flex items-center gap-1.5 pl-4 py-0.5"
>
<button onClick={cycleStatus} className="shrink-0 hover:scale-110 transition-transform">
<StatusIcon status={sub.status} className="w-2.5 h-2.5" />
</button>
<span className={`text-[9px] font-mono flex-1 truncate ${
sub.status === "completed" ? "line-through opacity-50" : ""
}`}>
{sub.content}
</span>
<CreatorBadge createdBy={sub.createdBy} />
</motion.div>
);
}
// ─── Task Item ───────────────────────────────────────────────────────────────
function TaskItem({ task }: { task: Task }) {
const [expanded, setExpanded] = useState(task.status === "in_progress");
const [addingSubtask, setAddingSubtask] = useState(false);
const [subtaskText, setSubtaskText] = useState("");
const subtaskInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (addingSubtask) subtaskInputRef.current?.focus();
}, [addingSubtask]);
const cycleStatus = () => {
const next: Record<TaskStatus, TaskStatus> = {
pending: "in_progress",
in_progress: "completed",
completed: "pending",
failed: "in_progress",
blocked: "pending",
};
chatStore.updateTaskStatus(task.id, next[task.status]);
};
const handleAddSubtask = () => {
if (!subtaskText.trim()) return;
chatStore.addSubtask(task.id, subtaskText.trim(), "user");
setSubtaskText("");
setAddingSubtask(false);
};
const completedSubtasks = task.subtasks.filter((s) => s.status === "completed").length;
return (
<motion.div
layout
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
className={`rounded-md border overflow-hidden ${statusBg(task.status)}`}
>
{/* Task header */}
<div className="flex items-start gap-1.5 px-2 py-1.5">
<button
onClick={cycleStatus}
className="shrink-0 mt-0.5 hover:scale-110 transition-transform"
>
<StatusIcon status={task.status} />
</button>
<div className="flex-1 min-w-0">
<button
onClick={() => setExpanded(!expanded)}
className="w-full text-left"
>
<span className={`text-[10px] font-mono leading-snug block ${
task.status === "completed" ? "line-through opacity-60" : ""
}`}>
{task.content}
</span>
</button>
{/* Meta row */}
<div className="flex items-center gap-1.5 mt-0.5 flex-wrap">
<PriorityBadge priority={task.priority} />
<CreatorBadge createdBy={task.createdBy} />
{task.elapsedMs > 0 && (
<span className="text-[8px] font-mono text-muted-foreground/60 flex items-center gap-0.5">
<Timer className="w-2 h-2" />
{formatElapsed(task.elapsedMs)}
</span>
)}
{task.retryCount > 0 && (
<span className="text-[8px] font-mono text-amber-400/60 flex items-center gap-0.5">
<RefreshCw className="w-2 h-2" />
x{task.retryCount}
</span>
)}
{task.testedAt && (
<span className="text-[8px] font-mono text-green-400/60 flex items-center gap-0.5">
<Target className="w-2 h-2" />
tested
</span>
)}
{task.subtasks.length > 0 && (
<span className="text-[8px] font-mono text-muted-foreground/50">
{completedSubtasks}/{task.subtasks.length}
</span>
)}
</div>
{task.lastError && task.status === "failed" && (
<div className="text-[8px] font-mono text-red-400/70 mt-0.5 truncate">
{task.lastError}
</div>
)}
</div>
{/* Right actions */}
<div className="flex items-center gap-0.5 shrink-0">
<button
onClick={() => setExpanded(!expanded)}
className="p-0.5 rounded hover:bg-white/5 text-muted-foreground"
>
{expanded
? <ChevronDown className="w-2.5 h-2.5" />
: <ChevronRight className="w-2.5 h-2.5" />
}
</button>
<button
onClick={() => chatStore.removeTask(task.id)}
className="p-0.5 rounded hover:bg-red-500/10 text-muted-foreground/40 hover:text-red-400"
>
<Trash2 className="w-2.5 h-2.5" />
</button>
</div>
</div>
{/* Expanded: subtasks + add subtask */}
<AnimatePresence>
{expanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden border-t border-border/20"
>
<div className="px-2 py-1 space-y-0.5">
{task.subtasks.map((sub) => (
<SubtaskItem key={sub.id} task={task} sub={sub} />
))}
{addingSubtask ? (
<div className="flex items-center gap-1 pl-4 mt-1">
<input
ref={subtaskInputRef}
value={subtaskText}
onChange={(e) => setSubtaskText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleAddSubtask();
if (e.key === "Escape") setAddingSubtask(false);
}}
placeholder="Подзадача..."
className="flex-1 bg-transparent border-none text-[9px] font-mono text-foreground placeholder:text-muted-foreground/40 focus:outline-none h-5"
/>
<button
onClick={handleAddSubtask}
className="text-[8px] font-mono text-primary hover:text-primary/80"
>
+
</button>
</div>
) : (
<button
onClick={() => setAddingSubtask(true)}
className="flex items-center gap-1 pl-4 mt-0.5 text-[8px] font-mono text-muted-foreground/40 hover:text-muted-foreground transition-colors"
>
<Plus className="w-2 h-2" />
подзадача
</button>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}
// ─── Progress Bar ────────────────────────────────────────────────────────────
function ProgressTracker() {
const progress = chatStore.getTaskProgress();
if (progress.total === 0) return null;
return (
<div className="px-2 py-1.5 border-t border-border/30 bg-secondary/10">
{/* Progress bar */}
<div className="flex items-center gap-2 mb-1">
<div className="flex-1 h-1.5 rounded-full bg-secondary/50 overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${progress.percent}%` }}
transition={{ duration: 0.5, ease: "easeOut" }}
className={`h-full rounded-full ${
progress.percent === 100 ? "bg-green-500" : "bg-cyan-500"
}`}
/>
</div>
<span className="text-[9px] font-mono text-muted-foreground shrink-0">
{progress.percent}%
</span>
</div>
{/* Stats row */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[8px] font-mono flex items-center gap-0.5">
<CheckCircle className="w-2 h-2 text-green-400" />
<span className="text-green-400">{progress.completed}</span>
</span>
<span className="text-[8px] font-mono flex items-center gap-0.5">
<Loader2 className="w-2 h-2 text-cyan-400" />
<span className="text-cyan-400">{progress.inProgress}</span>
</span>
{progress.failed > 0 && (
<span className="text-[8px] font-mono flex items-center gap-0.5">
<XCircle className="w-2 h-2 text-red-400" />
<span className="text-red-400">{progress.failed}</span>
</span>
)}
<span className="text-[8px] font-mono flex items-center gap-0.5">
<Circle className="w-2 h-2 text-muted-foreground" />
<span className="text-muted-foreground">{progress.pending}</span>
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[8px] font-mono text-muted-foreground/60 flex items-center gap-0.5">
<Timer className="w-2 h-2" />
{formatElapsed(progress.totalElapsedMs)}
</span>
{progress.globalElapsedMs > 0 && (
<span className="text-[8px] font-mono text-muted-foreground/40">
/ {formatElapsed(progress.globalElapsedMs)}
</span>
)}
</div>
</div>
</div>
);
}
// ─── Main TaskBoard Component ────────────────────────────────────────────────
export default function TaskBoard() {
const [newTaskText, setNewTaskText] = useState("");
const [newTaskPriority, setNewTaskPriority] = useState<TaskPriority>("medium");
const [showAddForm, setShowAddForm] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
// Re-render on store updates
const [, forceRender] = useState(0);
useEffect(() => {
const handler = () => forceRender((n) => n + 1);
chatStore.on("update", handler);
return () => chatStore.off("update", handler);
}, []);
useEffect(() => {
if (showAddForm) inputRef.current?.focus();
}, [showAddForm]);
const tasks = chatStore.getTasks();
const autoRetry = chatStore.getAutoRetryEnabled();
const handleAddTask = () => {
if (!newTaskText.trim()) return;
chatStore.addTask(newTaskText.trim(), { priority: newTaskPriority, createdBy: "user" });
setNewTaskText("");
setShowAddForm(false);
};
// Sort: in_progress first, then failed, pending, blocked, completed last
const statusOrder: Record<TaskStatus, number> = {
in_progress: 0,
failed: 1,
pending: 2,
blocked: 3,
completed: 4,
};
const sortedTasks = [...tasks].sort((a, b) => statusOrder[a.status] - statusOrder[b.status]);
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-2 py-1.5 border-b border-border/30 shrink-0">
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-mono text-muted-foreground uppercase tracking-wider flex items-center gap-1">
<ListTodo className="w-3 h-3" />
Tasks
</span>
{tasks.length > 0 && (
<Badge variant="outline" className="text-[8px] h-3.5 px-1 font-mono border-border/40">
{tasks.length}
</Badge>
)}
</div>
<div className="flex items-center gap-1">
{/* Auto-retry toggle */}
<button
onClick={() => chatStore.setAutoRetry(!autoRetry)}
className={`p-1 rounded text-[9px] font-mono flex items-center gap-0.5 transition-colors ${
autoRetry
? "text-cyan-400 bg-cyan-500/10 border border-cyan-500/20"
: "text-muted-foreground/50 border border-border/30 hover:text-muted-foreground"
}`}
title={autoRetry ? "Auto-retry: ON" : "Auto-retry: OFF"}
>
<RefreshCw className="w-2.5 h-2.5" />
</button>
{/* Add task */}
<button
onClick={() => setShowAddForm(!showAddForm)}
className="p-1 rounded border border-border/40 text-muted-foreground hover:text-primary hover:border-primary/40 transition-colors"
>
<Plus className="w-2.5 h-2.5" />
</button>
{/* Clear all */}
{tasks.length > 0 && (
<button
onClick={() => chatStore.clearTasks()}
className="text-[8px] font-mono text-muted-foreground/40 hover:text-red-400 transition-colors px-1"
>
clear
</button>
)}
</div>
</div>
{/* Add task form */}
<AnimatePresence>
{showAddForm && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden border-b border-border/30"
>
<div className="px-2 py-1.5 space-y-1">
<input
ref={inputRef}
value={newTaskText}
onChange={(e) => setNewTaskText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleAddTask();
if (e.key === "Escape") setShowAddForm(false);
}}
placeholder="Новая задача..."
className="w-full bg-transparent border border-border/30 rounded px-2 py-1 text-[10px] font-mono text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:border-primary/40 h-6"
/>
<div className="flex items-center gap-1">
{(["critical", "high", "medium", "low"] as TaskPriority[]).map((p) => (
<button
key={p}
onClick={() => setNewTaskPriority(p)}
className={`text-[8px] font-mono px-1.5 py-0.5 rounded border transition-colors ${
newTaskPriority === p
? "border-primary/40 text-primary bg-primary/10"
: "border-border/30 text-muted-foreground/50 hover:text-muted-foreground"
}`}
>
{p}
</button>
))}
<button
onClick={handleAddTask}
disabled={!newTaskText.trim()}
className="ml-auto text-[8px] font-mono px-2 py-0.5 rounded bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25 disabled:opacity-30"
>
Add
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Task list */}
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto p-1.5 space-y-1">
{sortedTasks.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground/50 gap-2">
<ListTodo className="w-8 h-8" />
<p className="text-[10px] font-mono text-center">
Задачи пока пусты<br />
<span className="text-[8px] opacity-60">Добавьте задачу или запустите оркестратор</span>
</p>
</div>
) : (
<AnimatePresence mode="popLayout">
{sortedTasks.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</AnimatePresence>
)}
</div>
{/* Bottom: progress tracker */}
<ProgressTracker />
</div>
);
}

View File

@@ -0,0 +1,781 @@
/**
* WorkflowCanvas — interactive visual constructor for building workflows.
*
* Features:
* - Multi-port nodes with named handles (success, error, true, false, data-N, out-N)
* - Edge types: success (green), error (red), loop (amber dashed), default (gray)
* - Proper mouseDown/mouseUp edge drawing between ports
* - Loop-back / self-referencing edges
* - Drag-and-drop nodes from palette
* - Panning / zooming the canvas
* - Save to server via tRPC
* - Real-time run status overlays
* - Keyboard shortcuts (Delete, Ctrl+S)
*
* FIX: Canvas now properly syncs initialNodes/initialEdges from server query
* using useEffect that tracks the workflow data lifecycle.
*/
import { useState, useRef, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Save,
Play,
ZoomIn,
ZoomOut,
Maximize2,
Loader2,
X,
AlertCircle,
TestTube,
} from "lucide-react";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
import { nanoid } from "nanoid";
import {
WorkflowNodeBlock,
WorkflowNodePaletteItem,
getNodePorts,
getPortPosition,
NODE_WIDTH,
type WFNodeData,
type NodeKind,
type PortDef,
} from "./WorkflowNodeBlock";
import { WorkflowNodeEditModal } from "./WorkflowNodeEditModal";
export interface WFEdgeData {
edgeKey: string;
sourceNodeKey: string;
targetNodeKey: string;
sourceHandle?: string; // handle id on source (e.g. "success", "error", "true")
targetHandle?: string; // handle id on target (e.g. "in", "data-1")
label?: string;
meta?: Record<string, any>;
}
type EdgeType = "default" | "success" | "error" | "loop" | "condition-true" | "condition-false";
const EDGE_STYLES: Record<EdgeType, { stroke: string; dash?: string; width: number }> = {
default: { stroke: "#475569", width: 2 },
success: { stroke: "#22c55e", width: 2 },
error: { stroke: "#ef4444", width: 2, dash: "6 4" },
loop: { stroke: "#f59e0b", width: 2, dash: "4 4" },
"condition-true": { stroke: "#22c55e", width: 2 },
"condition-false":{ stroke: "#ef4444", width: 2, dash: "6 4" },
};
function inferEdgeType(edge: WFEdgeData, nodes: WFNodeData[]): EdgeType {
// Loop edge (same node or explicit)
if (edge.sourceNodeKey === edge.targetNodeKey) return "loop";
if (edge.meta?.loop) return "loop";
// Handle-based inference
if (edge.sourceHandle === "error" || edge.sourceHandle === "err") return "error";
if (edge.sourceHandle === "false") return "condition-false";
if (edge.sourceHandle === "true") return "condition-true";
if (edge.sourceHandle === "success" || edge.sourceHandle === "ok") return "success";
if (edge.sourceHandle === "stdout") return "default";
if (edge.sourceHandle?.startsWith("out-")) return "success";
return "default";
}
interface WorkflowCanvasProps {
workflowId: number;
workflowName: string;
initialNodes?: WFNodeData[];
initialEdges?: WFEdgeData[];
runResults?: Record<string, {
status: "pending" | "running" | "success" | "failed" | "skipped";
output?: string;
durationMs?: number;
error?: string;
}>;
onBack: () => void;
}
const NODE_H = 100; // header + body approx
export default function WorkflowCanvas({
workflowId,
workflowName,
initialNodes = [],
initialEdges = [],
runResults,
onBack,
}: WorkflowCanvasProps) {
const [nodes, setNodes] = useState<WFNodeData[]>([]);
const [edges, setEdges] = useState<WFEdgeData[]>([]);
const [selectedNodeKey, setSelectedNodeKey] = useState<string | null>(null);
const [selectedEdgeKey, setSelectedEdgeKey] = useState<string | null>(null);
const [editingNode, setEditingNode] = useState<WFNodeData | null>(null);
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 60, y: 60 });
const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
// Track whether we've loaded initial data from server
const dataLoadedRef = useRef(false);
const prevDataKeyRef = useRef("");
// Dragging state
const [dragging, setDragging] = useState<{ nodeKey: string; offsetX: number; offsetY: number } | null>(null);
// Edge drawing state — we track source port info; edge completes on mouseUp of target port
const [edgeDrawing, setEdgeDrawing] = useState<{
sourceKey: string;
sourceHandleId: string;
sourceSide: PortDef["side"];
sourcePortType: "input" | "output";
mouseX: number;
mouseY: number;
} | null>(null);
const canvasRef = useRef<HTMLDivElement>(null);
// ─── FIX: Properly sync initial data from server ────────────────────────────
// This effect runs whenever initialNodes changes. It computes a key from node
// keys and compares to detect actual data changes (e.g. first load or refetch).
useEffect(() => {
const dataKey = initialNodes.map((n) => n.nodeKey).sort().join(",");
// If we get real data and it's different from what we've loaded before
if (initialNodes.length > 0 && dataKey !== prevDataKeyRef.current) {
setNodes(initialNodes);
setEdges(initialEdges);
prevDataKeyRef.current = dataKey;
dataLoadedRef.current = true;
}
// Also handle the case where data arrives after initial empty render
if (!dataLoadedRef.current && initialNodes.length > 0) {
setNodes(initialNodes);
setEdges(initialEdges);
prevDataKeyRef.current = dataKey;
dataLoadedRef.current = true;
}
}, [initialNodes, initialEdges]);
// Fetch agents for the node editor
const { data: agents = [] } = trpc.agents.list.useQuery();
// Save canvas mutation
const saveMutation = trpc.workflows.saveCanvas.useMutation({
onSuccess: () => toast.success("Canvas saved"),
onError: (e: any) => toast.error(`Save failed: ${e.message}`),
});
// Execute workflow mutation
const executeMutation = trpc.workflows.execute.useMutation({
onSuccess: (run: any) => {
toast.success(`Workflow started: ${run?.runKey ?? "?"}`);
},
onError: (e: any) => toast.error(`Execution failed: ${e.message}`),
});
// Test single node
const testNodeMutation = trpc.workflows.executeNode.useMutation({
onSuccess: (result: any) => {
if (result.success) toast.success(`Node OK in ${result.durationMs}ms`);
else toast.error(`Node failed: ${result.error}`);
},
});
// Apply run results as status overlays
const nodesWithStatus: WFNodeData[] = nodes.map((n) => {
const rr = runResults?.[n.nodeKey];
if (!rr) return n;
return { ...n, runStatus: rr.status, runDurationMs: rr.durationMs, runError: rr.error };
});
// ─── Canvas event handlers ───────────────────────────────────────────────
const handleCanvasMouseDown = (e: React.MouseEvent) => {
if (e.target === canvasRef.current || (e.target as HTMLElement).dataset?.canvas) {
setSelectedNodeKey(null);
setSelectedEdgeKey(null);
setIsPanning(true);
setPanStart({ x: e.clientX - pan.x, y: e.clientY - pan.y });
}
};
const handleCanvasMouseMove = (e: React.MouseEvent) => {
if (isPanning) {
setPan({ x: e.clientX - panStart.x, y: e.clientY - panStart.y });
}
if (dragging && canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect();
const newX = (e.clientX - rect.left - pan.x) / zoom - dragging.offsetX;
const newY = (e.clientY - rect.top - pan.y) / zoom - dragging.offsetY;
setNodes((prev) =>
prev.map((n) =>
n.nodeKey === dragging.nodeKey
? { ...n, posX: Math.round(newX), posY: Math.round(newY) }
: n
)
);
}
if (edgeDrawing && canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect();
setEdgeDrawing({
...edgeDrawing,
mouseX: (e.clientX - rect.left - pan.x) / zoom,
mouseY: (e.clientY - rect.top - pan.y) / zoom,
});
}
};
const handleCanvasMouseUp = () => {
setIsPanning(false);
setDragging(null);
// If we were drawing an edge and released on empty canvas, cancel it
if (edgeDrawing) {
setEdgeDrawing(null);
}
};
const handleNodeDragStart = (nodeKey: string, e: React.MouseEvent) => {
if (canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect();
const node = nodes.find((n) => n.nodeKey === nodeKey);
if (!node) return;
const offsetX = (e.clientX - rect.left - pan.x) / zoom - node.posX;
const offsetY = (e.clientY - rect.top - pan.y) / zoom - node.posY;
setDragging({ nodeKey, offsetX, offsetY });
}
};
// ─── Port interaction: mouseDown to start, mouseUp to complete ────────────
const handlePortMouseDown = useCallback((
nodeKey: string,
handleId: string,
side: PortDef["side"],
portType: "input" | "output",
e: React.MouseEvent,
) => {
if (!canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
setEdgeDrawing({
sourceKey: nodeKey,
sourceHandleId: handleId,
sourceSide: side,
sourcePortType: portType,
mouseX: (e.clientX - rect.left - pan.x) / zoom,
mouseY: (e.clientY - rect.top - pan.y) / zoom,
});
}, [pan, zoom]);
const handlePortMouseUp = useCallback((
nodeKey: string,
handleId: string,
side: PortDef["side"],
portType: "input" | "output",
_e: React.MouseEvent,
) => {
if (!edgeDrawing) return;
// Can't connect to same port
if (edgeDrawing.sourceKey === nodeKey && edgeDrawing.sourceHandleId === handleId) {
setEdgeDrawing(null);
return;
}
// Determine source (output) and target (input)
let srcKey: string, srcHandle: string, tgtKey: string, tgtHandle: string;
if (edgeDrawing.sourcePortType === "output" && portType === "input") {
srcKey = edgeDrawing.sourceKey;
srcHandle = edgeDrawing.sourceHandleId;
tgtKey = nodeKey;
tgtHandle = handleId;
} else if (edgeDrawing.sourcePortType === "input" && portType === "output") {
// Reverse: user started from input, ended on output
srcKey = nodeKey;
srcHandle = handleId;
tgtKey = edgeDrawing.sourceKey;
tgtHandle = edgeDrawing.sourceHandleId;
} else {
// Same polarity (output→output or input→input) — allow for loop-back
srcKey = edgeDrawing.sourceKey;
srcHandle = edgeDrawing.sourceHandleId;
tgtKey = nodeKey;
tgtHandle = handleId;
}
// Prevent exact duplicate
const exists = edges.some(
(e) => e.sourceNodeKey === srcKey && e.targetNodeKey === tgtKey
&& e.sourceHandle === srcHandle && e.targetHandle === tgtHandle
);
if (exists) {
setEdgeDrawing(null);
return;
}
const isLoop = srcKey === tgtKey;
// Infer label from handle
let label: string | undefined;
if (srcHandle === "error") label = "on error";
else if (srcHandle === "false") label = "if false";
else if (srcHandle === "true") label = "if true";
else if (srcHandle === "stdout") label = "stdout";
else if (srcHandle?.startsWith("out-")) label = srcHandle;
setEdges((prev) => [
...prev,
{
edgeKey: `edge_${nanoid(8)}`,
sourceNodeKey: srcKey,
targetNodeKey: tgtKey,
sourceHandle: srcHandle,
targetHandle: tgtHandle,
label,
meta: isLoop ? { loop: true } : undefined,
},
]);
setEdgeDrawing(null);
}, [edgeDrawing, edges]);
// ─── Actions ──────────────────────────────────────────────────────────────
const handleDeleteNode = (nodeKey: string) => {
setNodes((prev) => prev.filter((n) => n.nodeKey !== nodeKey));
setEdges((prev) => prev.filter((e) => e.sourceNodeKey !== nodeKey && e.targetNodeKey !== nodeKey));
setSelectedNodeKey(null);
};
const handleDeleteEdge = (edgeKey: string) => {
setEdges((prev) => prev.filter((e) => e.edgeKey !== edgeKey));
setSelectedEdgeKey(null);
};
const handleSave = () => {
saveMutation.mutate({
workflowId,
nodes: nodes.map((n) => ({
nodeKey: n.nodeKey,
label: n.label,
kind: n.kind,
agentId: n.agentId ?? null,
containerConfig: n.containerConfig ?? {},
conditionExpr: n.conditionExpr,
triggerConfig: n.triggerConfig ?? {},
posX: n.posX,
posY: n.posY,
meta: n.meta ?? {},
})),
edges: edges.map((e) => ({
edgeKey: e.edgeKey,
sourceNodeKey: e.sourceNodeKey,
targetNodeKey: e.targetNodeKey,
sourceHandle: e.sourceHandle,
targetHandle: e.targetHandle,
label: e.label,
meta: e.meta ?? {},
})),
canvasMeta: { zoom, viewportX: pan.x, viewportY: pan.y },
});
};
const handleExecute = () => {
executeMutation.mutate({ workflowId, input: "" });
};
const handleNodeSave = (updated: WFNodeData) => {
setNodes((prev) => prev.map((n) => (n.nodeKey === updated.nodeKey ? updated : n)));
setEditingNode(null);
};
const handleTestNode = (nodeKey: string) => {
testNodeMutation.mutate({ workflowId, nodeKey, input: "test" });
};
// ─── Edge rendering helpers ───────────────────────────────────────────────
/** Get absolute position of a port on the canvas */
const getHandlePos = useCallback((nodeKey: string, handleId: string | undefined, fallbackSide: "top" | "bottom") => {
const node = nodes.find((n) => n.nodeKey === nodeKey);
if (!node) return { x: 0, y: 0 };
const { inputs, outputs } = getNodePorts(node);
const allPorts = [...inputs, ...outputs];
const port = allPorts.find((p) => p.id === handleId);
if (port) {
const sameSide = allPorts.filter((p) => p.side === port.side);
const idx = sameSide.indexOf(port);
const pos = getPortPosition(port, idx, sameSide.length, NODE_WIDTH);
return { x: node.posX + pos.x, y: node.posY + pos.y };
}
// Fallback: center top/bottom
const sidePortCount = Math.max(
inputs.filter(p => p.side === "left").length,
outputs.filter(p => p.side === "right").length,
);
const bodyH = Math.max(52, sidePortCount * 20 + 16);
const nodeH = 48 + bodyH;
return {
x: node.posX + NODE_WIDTH / 2,
y: fallbackSide === "top" ? node.posY : node.posY + nodeH,
};
}, [nodes]);
/** Build bezier path between two points */
const buildEdgePath = (sx: number, sy: number, tx: number, ty: number, isLoop: boolean) => {
if (isLoop) {
// Self-referencing loop: curve out to the right
const ox = 90;
const oy = 50;
return `M ${sx} ${sy} C ${sx + ox} ${sy + oy}, ${tx + ox} ${ty - oy}, ${tx} ${ty}`;
}
const dy = ty - sy;
// If target is above source (backward), make an S-curve
if (dy < 30) {
const cpOffset = Math.max(Math.abs(tx - sx) * 0.3, 60);
return `M ${sx} ${sy} C ${sx} ${sy + cpOffset}, ${tx} ${ty - cpOffset}, ${tx} ${ty}`;
}
// Normal downward flow
const midY = (sy + ty) / 2;
return `M ${sx} ${sy} C ${sx} ${midY}, ${tx} ${midY}, ${tx} ${ty}`;
};
// ─── Keyboard shortcuts ───────────────────────────────────────────────────
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.target as HTMLElement)?.tagName === "INPUT" || (e.target as HTMLElement)?.tagName === "TEXTAREA") return;
if (e.key === "Delete" || e.key === "Backspace") {
if (selectedNodeKey) handleDeleteNode(selectedNodeKey);
if (selectedEdgeKey) handleDeleteEdge(selectedEdgeKey);
}
if (e.key === "s" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSave();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [selectedNodeKey, selectedEdgeKey, nodes, edges]);
// Scroll-to-zoom
useEffect(() => {
const el = canvasRef.current;
if (!el) return;
const handler = (e: WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.05 : 0.05;
setZoom((z) => Math.min(2, Math.max(0.2, z + delta)));
};
el.addEventListener("wheel", handler, { passive: false });
return () => el.removeEventListener("wheel", handler);
}, []);
return (
<div className="flex flex-col h-full">
{/* Toolbar */}
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50 bg-sidebar">
<div className="flex items-center gap-3">
<Button size="sm" variant="ghost" onClick={onBack} className="text-muted-foreground hover:text-foreground">
<X className="w-4 h-4 mr-1" /> Close
</Button>
<div className="h-5 w-px bg-border/50" />
<span className="text-sm font-semibold text-foreground">{workflowName}</span>
<Badge variant="outline" className="text-[10px] font-mono">
{nodes.length} nodes &middot; {edges.length} edges
</Badge>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setZoom((z) => Math.min(z + 0.15, 2))} className="h-7 w-7 p-0">
<ZoomIn className="w-3.5 h-3.5" />
</Button>
<span className="text-[10px] font-mono text-muted-foreground w-10 text-center">{Math.round(zoom * 100)}%</span>
<Button size="sm" variant="outline" onClick={() => setZoom((z) => Math.max(z - 0.15, 0.2))} className="h-7 w-7 p-0">
<ZoomOut className="w-3.5 h-3.5" />
</Button>
<Button size="sm" variant="outline" onClick={() => { setZoom(1); setPan({ x: 60, y: 60 }); }} className="h-7 w-7 p-0" title="Reset view">
<Maximize2 className="w-3.5 h-3.5" />
</Button>
<div className="h-5 w-px bg-border/50" />
<Button
size="sm"
onClick={handleSave}
disabled={saveMutation.isPending}
className="bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25"
>
{saveMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin mr-1" /> : <Save className="w-3.5 h-3.5 mr-1" />}
Save
</Button>
<Button
size="sm"
onClick={handleExecute}
disabled={executeMutation.isPending || nodes.length === 0}
className="bg-neon-green/15 text-neon-green border border-neon-green/30 hover:bg-neon-green/25"
>
{executeMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin mr-1" /> : <Play className="w-3.5 h-3.5 mr-1" />}
Run
</Button>
</div>
</div>
<div className="flex flex-1 overflow-hidden">
{/* Sidebar palette */}
<div className="w-52 border-r border-border/50 bg-sidebar p-3 space-y-2 overflow-y-auto shrink-0">
<div className="text-[10px] font-mono text-muted-foreground uppercase tracking-wider mb-2">Node Palette</div>
{(["trigger", "agent", "container", "condition", "output"] as NodeKind[]).map((kind) => (
<div
key={kind}
draggable
onDragStart={(e) => e.dataTransfer.setData("nodeKind", kind)}
>
<WorkflowNodePaletteItem kind={kind} onDragStart={() => {}} />
</div>
))}
{/* Edge type legend */}
<div className="text-[10px] font-mono text-muted-foreground uppercase tracking-wider mt-4 mb-2">Edge Types</div>
<div className="space-y-1">
{([
["success", "#22c55e", "solid"],
["error", "#ef4444", "dashed"],
["loop", "#f59e0b", "dashed"],
["default", "#475569", "solid"],
["true", "#22c55e", "solid"],
["false", "#ef4444", "dashed"],
] as const).map(([label, color, style]) => (
<div key={label} className="flex items-center gap-2 px-2 py-1 text-[10px] font-mono text-muted-foreground">
<svg width="24" height="6"><line x1="0" y1="3" x2="24" y2="3" stroke={color} strokeWidth="2" strokeDasharray={style === "dashed" ? "4 3" : "none"} /></svg>
{label}
</div>
))}
</div>
{/* Quick agent list */}
{agents.length > 0 && (
<>
<div className="text-[10px] font-mono text-muted-foreground uppercase tracking-wider mt-4 mb-2">Agents</div>
{agents.slice(0, 10).map((agent: any) => (
<div
key={agent.id}
className="flex items-center gap-2 px-2 py-1.5 rounded text-[11px] font-mono bg-primary/5 border border-primary/20 cursor-grab text-foreground hover:bg-primary/10"
draggable
onDragStart={(e) => {
e.dataTransfer.setData("nodeKind", "agent");
e.dataTransfer.setData("agentId", String(agent.id));
e.dataTransfer.setData("agentName", agent.name);
}}
>
<div className="w-2 h-2 rounded-full bg-primary" />
<span className="truncate">{agent.name}</span>
</div>
))}
</>
)}
</div>
{/* Canvas area */}
<div
ref={canvasRef}
data-canvas="true"
className="flex-1 relative overflow-hidden bg-[#0A0E1A] cursor-default"
onMouseDown={handleCanvasMouseDown}
onMouseMove={handleCanvasMouseMove}
onMouseUp={handleCanvasMouseUp}
onMouseLeave={handleCanvasMouseUp}
onDrop={(e) => {
e.preventDefault();
const kind = e.dataTransfer.getData("nodeKind") as NodeKind;
if (!kind || !canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
const posX = Math.round((e.clientX - rect.left - pan.x) / zoom - NODE_WIDTH / 2);
const posY = Math.round((e.clientY - rect.top - pan.y) / zoom - 30);
const agentIdStr = e.dataTransfer.getData("agentId");
const agentName = e.dataTransfer.getData("agentName");
const newNode: WFNodeData = {
nodeKey: `node_${nanoid(8)}`,
label: agentName || `New ${kind.charAt(0).toUpperCase() + kind.slice(1)}`,
kind,
agentId: agentIdStr ? Number(agentIdStr) : undefined,
agentName: agentName || undefined,
posX,
posY,
};
setNodes((prev) => [...prev, newNode]);
}}
onDragOver={(e) => e.preventDefault()}
>
{/* Grid pattern */}
<svg className="absolute inset-0 w-full h-full pointer-events-none opacity-20">
<defs>
<pattern id="grid" width={20 * zoom} height={20 * zoom} patternUnits="userSpaceOnUse"
x={pan.x % (20 * zoom)} y={pan.y % (20 * zoom)}>
<circle cx="1" cy="1" r="1" fill="#334155" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
</svg>
{/* Transform container */}
<div
className="absolute origin-top-left"
style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` }}
>
{/* Edges SVG */}
<svg className="absolute inset-0 pointer-events-none" style={{ width: 8000, height: 8000, overflow: "visible" }}>
{/* Arrow markers */}
<defs>
<marker id="arrow-default" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto">
<path d="M 0 0 L 8 4 L 0 8 Z" fill="#475569" />
</marker>
<marker id="arrow-success" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto">
<path d="M 0 0 L 8 4 L 0 8 Z" fill="#22c55e" />
</marker>
<marker id="arrow-error" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto">
<path d="M 0 0 L 8 4 L 0 8 Z" fill="#ef4444" />
</marker>
<marker id="arrow-loop" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto">
<path d="M 0 0 L 8 4 L 0 8 Z" fill="#f59e0b" />
</marker>
<marker id="arrow-cyan" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto">
<path d="M 0 0 L 8 4 L 0 8 Z" fill="#00D4FF" />
</marker>
</defs>
{edges.map((edge) => {
const edgeType = inferEdgeType(edge, nodes);
const style = EDGE_STYLES[edgeType] || EDGE_STYLES.default;
const isLoop = edge.sourceNodeKey === edge.targetNodeKey;
const isSelected = selectedEdgeKey === edge.edgeKey;
const src = getHandlePos(edge.sourceNodeKey, edge.sourceHandle, "bottom");
const tgt = getHandlePos(edge.targetNodeKey, edge.targetHandle, "top");
const path = buildEdgePath(src.x, src.y, tgt.x, tgt.y, isLoop);
const markerKey = isSelected ? "arrow-cyan"
: edgeType === "error" || edgeType === "condition-false" ? "arrow-error"
: edgeType === "loop" ? "arrow-loop"
: edgeType === "success" || edgeType === "condition-true" ? "arrow-success"
: "arrow-default";
return (
<g key={edge.edgeKey}>
{/* Hit area */}
<path
d={path}
fill="none"
stroke="transparent"
strokeWidth={14}
className="pointer-events-auto cursor-pointer"
onClick={(ev) => { ev.stopPropagation(); setSelectedEdgeKey(edge.edgeKey); setSelectedNodeKey(null); }}
/>
{/* Visible edge */}
<path
d={path}
fill="none"
stroke={isSelected ? "#00D4FF" : style.stroke}
strokeWidth={isSelected ? 3 : style.width}
strokeDasharray={style.dash}
markerEnd={`url(#${markerKey})`}
/>
{/* Edge label */}
{edge.label && (
<text
x={(src.x + tgt.x) / 2}
y={(src.y + tgt.y) / 2 - 8}
textAnchor="middle"
className="pointer-events-none select-none"
fill={isSelected ? "#00D4FF" : style.stroke}
fontSize={10}
fontFamily="JetBrains Mono, monospace"
fontWeight="600"
>
{edge.label}
</text>
)}
</g>
);
})}
{/* Edge being drawn */}
{edgeDrawing && (() => {
const srcPos = getHandlePos(edgeDrawing.sourceKey, edgeDrawing.sourceHandleId, "bottom");
return (
<path
d={buildEdgePath(srcPos.x, srcPos.y, edgeDrawing.mouseX, edgeDrawing.mouseY, false)}
fill="none"
stroke="#00D4FF"
strokeWidth={2}
strokeDasharray="6 3"
opacity={0.7}
markerEnd="url(#arrow-cyan)"
/>
);
})()}
</svg>
{/* Nodes */}
{nodesWithStatus.map((node) => (
<WorkflowNodeBlock
key={node.nodeKey}
node={node}
selected={selectedNodeKey === node.nodeKey}
onSelect={() => { setSelectedNodeKey(node.nodeKey); setSelectedEdgeKey(null); }}
onDelete={() => handleDeleteNode(node.nodeKey)}
onEdit={() => setEditingNode(node)}
onTest={() => handleTestNode(node.nodeKey)}
onDragStart={(e) => handleNodeDragStart(node.nodeKey, e)}
onPortMouseDown={handlePortMouseDown}
onPortMouseUp={handlePortMouseUp}
/>
))}
</div>
{/* Empty state */}
{nodes.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center">
<AlertCircle className="w-10 h-10 mx-auto mb-3 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground/50 font-mono">
Drag nodes from the palette to start building your workflow
</p>
</div>
</div>
)}
{/* Selected edge action bar */}
{selectedEdgeKey && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-2 px-3 py-2 rounded-lg bg-secondary/90 border border-border/50 backdrop-blur">
<span className="text-[10px] font-mono text-muted-foreground">Edge: {selectedEdgeKey.slice(0, 16)}</span>
<Button size="sm" variant="ghost" className="h-6 text-[10px] text-neon-red" onClick={() => handleDeleteEdge(selectedEdgeKey)}>
Delete
</Button>
</div>
)}
{/* Edge drawing indicator */}
{edgeDrawing && (
<div className="absolute top-3 right-3 px-3 py-1.5 rounded-md bg-primary/20 border border-primary/40 text-[10px] font-mono text-primary animate-pulse">
Drawing edge from {edgeDrawing.sourceHandleId} &mdash; click target port to connect
</div>
)}
</div>
</div>
{/* Node edit modal */}
{editingNode && (
<WorkflowNodeEditModal
node={editingNode}
agents={agents}
open={!!editingNode}
onOpenChange={(open) => { if (!open) setEditingNode(null); }}
onSave={handleNodeSave}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,144 @@
/**
* WorkflowCreateModal — create a new workflow (name + description + tags).
*/
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} 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 { Badge } from "@/components/ui/badge";
import { Plus, GitBranch, Loader2, X } from "lucide-react";
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";
interface WorkflowCreateModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: (workflow: any) => void;
}
export function WorkflowCreateModal({ open, onOpenChange, onSuccess }: WorkflowCreateModalProps) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [tagInput, setTagInput] = useState("");
const [tags, setTags] = useState<string[]>([]);
const createMutation = trpc.workflows.create.useMutation({
onSuccess: (wf) => {
toast.success(`Workflow "${wf?.name}" created`);
onSuccess(wf);
handleReset();
onOpenChange(false);
},
onError: (err) => {
toast.error(`Failed: ${err.message}`);
},
});
const handleReset = () => {
setName("");
setDescription("");
setTags([]);
setTagInput("");
};
const handleAddTag = () => {
const trimmed = tagInput.trim();
if (trimmed && !tags.includes(trimmed)) {
setTags([...tags, trimmed]);
setTagInput("");
}
};
const handleRemoveTag = (tag: string) => {
setTags(tags.filter((t) => t !== tag));
};
const handleCreate = () => {
if (!name.trim()) {
toast.error("Workflow name is required");
return;
}
createMutation.mutate({ name: name.trim(), description: description.trim() || undefined, tags });
};
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) handleReset(); onOpenChange(v); }}>
<DialogContent className="max-w-md bg-card border-border">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-foreground">
<GitBranch className="w-5 h-5 text-primary" />
New Workflow
</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div>
<Label className="text-xs text-muted-foreground font-mono">Name *</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Content Pipeline"
className="mt-1"
autoFocus
/>
</div>
<div>
<Label className="text-xs text-muted-foreground font-mono">Description</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What does this workflow do?"
className="mt-1 min-h-[80px]"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground font-mono">Tags</Label>
<div className="flex gap-2 mt-1">
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleAddTag(); } }}
placeholder="Add tag..."
className="flex-1"
/>
<Button size="sm" variant="outline" onClick={handleAddTag} className="shrink-0">
<Plus className="w-3.5 h-3.5" />
</Button>
</div>
{tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{tags.map((tag) => (
<Badge key={tag} variant="outline" className="text-[10px] font-mono bg-primary/10 text-primary border-primary/20 gap-1">
{tag}
<X className="w-2.5 h-2.5 cursor-pointer hover:text-neon-red" onClick={() => handleRemoveTag(tag)} />
</Badge>
))}
</div>
)}
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t border-border/30">
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button
onClick={handleCreate}
disabled={createMutation.isPending || !name.trim()}
className="bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25"
>
{createMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin mr-1" /> : <Plus className="w-3.5 h-3.5 mr-1" />}
Create Workflow
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,233 @@
/**
* WorkflowDashboard — monitoring panel for a single workflow.
* Shows: stats overview, run history, per-node results, real-time polling.
*/
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 {
Activity,
CheckCircle,
XCircle,
Clock,
Loader2,
Play,
RefreshCw,
Ban,
SkipForward,
BarChart2,
Zap,
} from "lucide-react";
import { motion } from "framer-motion";
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";
const STATUS_CONFIG: Record<string, { color: string; bg: string; icon: any }> = {
pending: { color: "text-muted-foreground", bg: "bg-muted/15", icon: Clock },
running: { color: "text-primary", bg: "bg-primary/15", icon: Loader2 },
success: { color: "text-neon-green", bg: "bg-neon-green/15", icon: CheckCircle },
failed: { color: "text-neon-red", bg: "bg-neon-red/15", icon: XCircle },
cancelled: { color: "text-neon-amber", bg: "bg-neon-amber/15", icon: Ban },
skipped: { color: "text-muted-foreground", bg: "bg-muted/15", icon: SkipForward },
};
interface WorkflowDashboardProps {
workflowId: number;
workflowName: string;
onOpenCanvas: () => void;
}
export default function WorkflowDashboard({ workflowId, workflowName, onOpenCanvas }: WorkflowDashboardProps) {
// Stats
const { data: stats, isLoading: statsLoading } = trpc.workflows.stats.useQuery(
{ workflowId },
{ refetchInterval: 10_000 }
);
// Runs
const { data: runs = [], isLoading: runsLoading, refetch: refetchRuns } = trpc.workflows.listRuns.useQuery(
{ workflowId, limit: 20 },
{ refetchInterval: 5_000 }
);
// Execute
const executeMutation = trpc.workflows.execute.useMutation({
onSuccess: () => {
toast.success("Workflow run started");
refetchRuns();
},
onError: (e) => toast.error(e.message),
});
// Cancel
const cancelMutation = trpc.workflows.cancelRun.useMutation({
onSuccess: () => {
toast.success("Run cancelled");
refetchRuns();
},
});
const formatDuration = (ms?: number | null) => {
if (!ms) return "—";
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
};
const formatTime = (d: any) => {
if (!d) return "—";
return new Date(d).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
};
return (
<div className="space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-bold text-foreground">{workflowName}</h3>
<p className="text-xs text-muted-foreground font-mono">Workflow Dashboard &middot; Real-time monitoring</p>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={onOpenCanvas}>
Open Canvas
</Button>
<Button
size="sm"
onClick={() => executeMutation.mutate({ workflowId })}
disabled={executeMutation.isPending}
className="bg-neon-green/15 text-neon-green border border-neon-green/30 hover:bg-neon-green/25"
>
{executeMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin mr-1" /> : <Play className="w-3.5 h-3.5 mr-1" />}
Run
</Button>
</div>
</div>
{/* Stats cards */}
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
<StatsCard label="Total Runs" value={statsLoading ? "..." : String(stats?.totalRuns ?? 0)} color="text-primary" icon={Activity} />
<StatsCard label="Success" value={statsLoading ? "..." : String(stats?.successRuns ?? 0)} color="text-neon-green" icon={CheckCircle} />
<StatsCard label="Failed" value={statsLoading ? "..." : String(stats?.failedRuns ?? 0)} color="text-neon-red" icon={XCircle} />
<StatsCard label="Success Rate" value={statsLoading ? "..." : `${stats?.successRate ?? 0}%`} color="text-primary" icon={BarChart2} />
<StatsCard label="Avg Duration" value={statsLoading ? "..." : formatDuration(stats?.avgDurationMs)} color="text-neon-amber" icon={Clock} />
</div>
{/* Run history */}
<Card className="bg-card border-border/50">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Zap className="w-4 h-4 text-primary" />
Run History
<span className="ml-auto text-[10px] font-mono text-muted-foreground flex items-center gap-1">
<RefreshCw className="w-3 h-3" /> 5s
</span>
</CardTitle>
</CardHeader>
<CardContent>
{runsLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-primary mr-2" />
<span className="text-xs font-mono text-muted-foreground">Loading runs...</span>
</div>
) : runs.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground gap-2">
<Activity className="w-5 h-5 text-muted-foreground/30" />
<span className="text-xs font-mono">No runs yet</span>
</div>
) : (
<div className="space-y-2">
{runs.map((run: any, i: number) => {
const sc = STATUS_CONFIG[run.status] ?? STATUS_CONFIG.pending;
const StatusIcon = sc.icon;
const nodeResults = (run.nodeResults ?? {}) as Record<string, any>;
const nodeCount = Object.keys(nodeResults).length;
const successNodes = Object.values(nodeResults).filter((r: any) => r.status === "success").length;
return (
<motion.div
key={run.runKey}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.03 }}
className={`p-3 rounded-md ${sc.bg} border border-border/30`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<StatusIcon className={`w-4 h-4 ${sc.color} ${run.status === "running" ? "animate-spin" : ""}`} />
<span className="text-xs font-mono font-medium text-foreground">{run.runKey}</span>
<Badge variant="outline" className={`text-[9px] font-mono ${sc.color}`}>
{run.status.toUpperCase()}
</Badge>
</div>
<div className="flex items-center gap-2">
{run.status === "running" && (
<Button
size="sm"
variant="ghost"
className="h-6 text-[10px] text-neon-red hover:bg-neon-red/10"
onClick={() => cancelMutation.mutate({ runKey: run.runKey })}
>
<Ban className="w-3 h-3 mr-1" /> Cancel
</Button>
)}
<span className="text-[10px] font-mono text-muted-foreground">{formatTime(run.createdAt)}</span>
</div>
</div>
{/* Node progress */}
{nodeCount > 0 && (
<div className="mb-2">
<div className="flex items-center gap-2 mb-1">
<span className="text-[10px] font-mono text-muted-foreground">Nodes:</span>
<span className="text-[10px] font-mono text-foreground">{successNodes}/{nodeCount}</span>
</div>
<Progress value={nodeCount > 0 ? (successNodes / nodeCount) * 100 : 0} className="h-1.5" />
<div className="flex flex-wrap gap-1 mt-1.5">
{Object.entries(nodeResults).map(([key, result]: [string, any]) => {
const nsc = STATUS_CONFIG[result.status] ?? STATUS_CONFIG.pending;
return (
<Badge key={key} variant="outline" className={`text-[8px] font-mono ${nsc.color} px-1.5 py-0`}>
{key.replace("node_", "").slice(0, 8)}
</Badge>
);
})}
</div>
</div>
)}
{/* Duration & error */}
<div className="flex items-center gap-4 text-[10px] font-mono text-muted-foreground">
{run.totalDurationMs && (
<span>Duration: <span className="text-foreground">{formatDuration(run.totalDurationMs)}</span></span>
)}
{run.currentNodeKey && run.status === "running" && (
<span>Current: <span className="text-primary">{run.currentNodeKey}</span></span>
)}
</div>
{run.errorMessage && (
<div className="text-[10px] font-mono text-neon-red mt-1 truncate">{run.errorMessage}</div>
)}
</motion.div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
);
}
function StatsCard({ label, value, color, icon: Icon }: { label: string; value: string; color: string; icon: any }) {
return (
<Card className="bg-card border-border/50">
<CardContent className="p-3">
<div className="flex items-center gap-2 mb-1.5">
<Icon className={`w-3.5 h-3.5 ${color}`} />
<span className="text-[10px] font-mono text-muted-foreground uppercase">{label}</span>
</div>
<div className={`font-mono text-xl font-bold ${color}`}>{value}</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,464 @@
/**
* WorkflowNodeBlock — individual draggable node block inside the canvas.
*
* Multi-port system:
* - Each node kind defines its own set of named input/output handles
* - Trigger: outputs: [success]
* - Agent: inputs: [in], outputs: [success, error]
* - Container: inputs: [in], outputs: [success, error, stdout]
* - Condition: inputs: [in], outputs: [true, false]
* - Output: inputs: [in, data-1, data-2, ...]
* - Aggregator (via meta.aggregator): inputs: [in, data-1..N], outputs: [merged]
* - Orchestrator (via meta.orchestrator): inputs: [in], outputs: [out-1..N, error]
*
* Edge types are tracked via sourceHandle/targetHandle.
*/
import {
Bot,
Box,
Play,
GitFork,
Flag,
GripVertical,
Trash2,
Settings,
Loader2,
CheckCircle,
XCircle,
SkipForward,
Merge,
Zap,
TestTube,
Network,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
export type NodeKind = "agent" | "container" | "trigger" | "condition" | "output";
export interface PortDef {
id: string; // unique handle id, e.g. "success", "error", "in"
label: string; // display label
side: "top" | "bottom" | "left" | "right";
color: string; // hex color for the port dot
type: "input" | "output";
}
export interface WFNodeData {
nodeKey: string;
label: string;
kind: NodeKind;
agentId?: number | null;
agentName?: string;
containerConfig?: Record<string, any>;
conditionExpr?: string;
triggerConfig?: Record<string, any>;
posX: number;
posY: number;
meta?: Record<string, any>;
/** Runtime status — set during execution */
runStatus?: "pending" | "running" | "success" | "failed" | "skipped";
runDurationMs?: number;
runError?: string;
}
// ─── Port definitions per node kind ─────────────────────────────────────────
export const NODE_WIDTH = 260;
const NODE_HEADER_H = 48;
const NODE_BODY_H = 52;
export function getNodePorts(node: WFNodeData): { inputs: PortDef[]; outputs: PortDef[] } {
const isAggregator = node.meta?.aggregator === true;
const isOrchestrator = node.meta?.orchestrator === true;
const extraInputs = (node.meta?.extraInputs as number) ?? 0;
const extraOutputs = (node.meta?.extraOutputs as number) ?? 0;
switch (node.kind) {
case "trigger":
return {
inputs: [],
outputs: [
{ id: "success", label: "out", side: "bottom", color: "#22c55e", type: "output" },
],
};
case "agent": {
const inputs: PortDef[] = [
{ id: "in", label: "in", side: "top", color: "#00D4FF", type: "input" },
...(extraInputs > 0 ? Array.from({ length: extraInputs }, (_, i) => ({
id: `data-${i + 1}`, label: `data-${i + 1}`, side: "left" as const, color: "#a855f7", type: "input" as const,
})) : []),
];
const outputs: PortDef[] = [
{ id: "success", label: "ok", side: "bottom", color: "#22c55e", type: "output" },
{ id: "error", label: "err", side: "right", color: "#ef4444", type: "output" },
...(isOrchestrator || extraOutputs > 0
? Array.from({ length: Math.max(extraOutputs, 2) }, (_, i) => ({
id: `out-${i + 1}`, label: `out-${i + 1}`, side: "right" as const, color: "#06b6d4", type: "output" as const,
}))
: []),
];
return { inputs, outputs };
}
case "container":
return {
inputs: [
{ id: "in", label: "in", side: "top", color: "#f59e0b", type: "input" },
],
outputs: [
{ id: "success", label: "ok", side: "bottom", color: "#22c55e", type: "output" },
{ id: "error", label: "err", side: "right", color: "#ef4444", type: "output" },
{ id: "stdout", label: "log", side: "right", color: "#94a3b8", type: "output" },
],
};
case "condition":
return {
inputs: [
{ id: "in", label: "in", side: "top", color: "#a855f7", type: "input" },
],
outputs: [
{ id: "true", label: "TRUE", side: "bottom", color: "#22c55e", type: "output" },
{ id: "false", label: "FALSE", side: "right", color: "#ef4444", type: "output" },
],
};
case "output": {
const inputCount = isAggregator ? Math.max(extraInputs, 3) : (extraInputs > 0 ? extraInputs : 0);
return {
inputs: [
{ id: "in", label: "in", side: "top", color: "#06b6d4", type: "input" },
...(inputCount > 0
? Array.from({ length: inputCount }, (_, i) => ({
id: `data-${i + 1}`, label: `#${i + 1}`, side: "left" as const, color: "#a855f7", type: "input" as const,
}))
: []),
],
outputs: isAggregator
? [{ id: "merged", label: "merged", side: "bottom", color: "#22c55e", type: "output" as const }]
: [],
};
}
default:
return {
inputs: [{ id: "in", label: "in", side: "top", color: "#94a3b8", type: "input" }],
outputs: [{ id: "success", label: "out", side: "bottom", color: "#94a3b8", type: "output" }],
};
}
}
/**
* Get the pixel position of a port relative to the node's top-left corner.
*/
export function getPortPosition(
port: PortDef,
index: number,
total: number,
_nodeWidth = NODE_WIDTH,
): { x: number; y: number } {
const nodeH = NODE_HEADER_H + NODE_BODY_H;
switch (port.side) {
case "top": {
const spacing = _nodeWidth / (total + 1);
return { x: spacing * (index + 1), y: 0 };
}
case "bottom": {
const spacing = _nodeWidth / (total + 1);
return { x: spacing * (index + 1), y: nodeH };
}
case "left":
return { x: 0, y: NODE_HEADER_H + 8 + index * 20 };
case "right":
return { x: _nodeWidth, y: NODE_HEADER_H + 8 + index * 20 };
}
}
const KIND_CONFIG: Record<NodeKind, { icon: any; color: string; bg: string; border: string; label: string }> = {
trigger: { icon: Play, color: "text-neon-green", bg: "bg-neon-green/10", border: "border-neon-green/40", label: "Trigger" },
agent: { icon: Bot, color: "text-primary", bg: "bg-primary/10", border: "border-primary/40", label: "Agent" },
container: { icon: Box, color: "text-neon-amber", bg: "bg-neon-amber/10", border: "border-neon-amber/40", label: "Container" },
condition: { icon: GitFork, color: "text-purple-400", bg: "bg-purple-400/10", border: "border-purple-400/40", label: "Condition" },
output: { icon: Flag, color: "text-cyan-400", bg: "bg-cyan-400/10", border: "border-cyan-400/40", label: "Output" },
};
const STATUS_OVERLAY: Record<string, { icon: any; color: string; animate?: boolean }> = {
running: { icon: Loader2, color: "text-primary", animate: true },
success: { icon: CheckCircle, color: "text-neon-green" },
failed: { icon: XCircle, color: "text-neon-red" },
skipped: { icon: SkipForward, color: "text-muted-foreground" },
};
interface WorkflowNodeBlockProps {
node: WFNodeData;
selected?: boolean;
onSelect?: () => void;
onDelete?: () => void;
onEdit?: () => void;
onTest?: () => void;
onDragStart?: (e: React.MouseEvent) => void;
/** Start drawing an edge from this port */
onPortMouseDown?: (nodeKey: string, handleId: string, side: PortDef["side"], portType: "input" | "output", e: React.MouseEvent) => void;
/** Complete an edge on this port */
onPortMouseUp?: (nodeKey: string, handleId: string, side: PortDef["side"], portType: "input" | "output", e: React.MouseEvent) => void;
}
export function WorkflowNodeBlock({
node,
selected,
onSelect,
onDelete,
onEdit,
onTest,
onDragStart,
onPortMouseDown,
onPortMouseUp,
}: WorkflowNodeBlockProps) {
const cfg = KIND_CONFIG[node.kind];
const Icon = cfg.icon;
const statusOverlay = node.runStatus ? STATUS_OVERLAY[node.runStatus] : null;
const StatusIcon = statusOverlay?.icon;
const { inputs, outputs } = getNodePorts(node);
// Separate ports by side for rendering
const topInputs = inputs.filter((p) => p.side === "top");
const leftInputs = inputs.filter((p) => p.side === "left");
const bottomOutputs = outputs.filter((p) => p.side === "bottom");
const rightOutputs = outputs.filter((p) => p.side === "right");
const renderPort = (port: PortDef, index: number, total: number) => {
const pos = getPortPosition(port, index, total);
const isTop = port.side === "top";
const isBottom = port.side === "bottom";
const isLeft = port.side === "left";
const isRight = port.side === "right";
return (
<div
key={port.id}
className="absolute z-30 group/port"
style={{
left: pos.x - 7,
top: pos.y - 7,
}}
onMouseDown={(e) => {
e.stopPropagation();
e.preventDefault();
onPortMouseDown?.(node.nodeKey, port.id, port.side, port.type, e);
}}
onMouseUp={(e) => {
e.stopPropagation();
e.preventDefault();
onPortMouseUp?.(node.nodeKey, port.id, port.side, port.type, e);
}}
title={`${port.id} (${port.type})`}
>
{/* Port dot */}
<div
className="w-[14px] h-[14px] rounded-full border-2 cursor-crosshair transition-all hover:scale-[1.6] hover:shadow-lg"
style={{
borderColor: port.color,
backgroundColor: `${port.color}33`,
}}
/>
{/* Port label — always visible on hover */}
<span
className={`absolute whitespace-nowrap text-[9px] font-mono font-semibold opacity-70 group-hover/port:opacity-100 transition-opacity pointer-events-none select-none`}
style={{
color: port.color,
...(isTop ? { bottom: '100%', left: '50%', transform: 'translateX(-50%)', marginBottom: 2 } : {}),
...(isBottom ? { top: '100%', left: '50%', transform: 'translateX(-50%)', marginTop: 2 } : {}),
...(isLeft ? { top: '50%', right: '100%', transform: 'translateY(-50%)', marginRight: 4 } : {}),
...(isRight ? { top: '50%', left: '100%', transform: 'translateY(-50%)', marginLeft: 4 } : {}),
}}
>
{port.label}
</span>
</div>
);
};
// Dynamic body height if there are left/right ports
const sidePortCount = Math.max(leftInputs.length, rightOutputs.length);
const dynamicBodyH = Math.max(NODE_BODY_H, sidePortCount * 20 + 16);
return (
<div
className={`
absolute select-none cursor-grab active:cursor-grabbing
rounded-lg border ${cfg.border} ${cfg.bg}
${selected ? "ring-2 ring-primary/60 shadow-lg shadow-primary/10" : ""}
${node.runStatus === "running" ? "ring-2 ring-primary/40 animate-pulse" : ""}
backdrop-blur-sm transition-shadow
`}
style={{ left: node.posX, top: node.posY, width: NODE_WIDTH }}
onClick={(e) => { e.stopPropagation(); onSelect?.(); }}
onMouseDown={(e) => {
// Only start drag if not clicking on a port
if ((e.target as HTMLElement).closest('.group\\/port')) return;
onDragStart?.(e);
}}
>
{/* ── Ports ── */}
{topInputs.map((p, i) => renderPort(p, i, topInputs.length))}
{leftInputs.map((p, i) => renderPort(p, i, leftInputs.length))}
{bottomOutputs.map((p, i) => renderPort(p, i, bottomOutputs.length))}
{rightOutputs.map((p, i) => renderPort(p, i, rightOutputs.length))}
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border/30" style={{ height: NODE_HEADER_H }}>
<GripVertical className="w-3 h-3 text-muted-foreground/50 shrink-0" />
<div className={`w-7 h-7 rounded-md ${cfg.bg} border ${cfg.border} flex items-center justify-center shrink-0`}>
<Icon className={`w-4 h-4 ${cfg.color}`} />
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-semibold text-foreground truncate">{node.label}</div>
<div className={`text-[10px] font-mono ${cfg.color}`}>
{cfg.label}
{node.meta?.aggregator && " (aggregator)"}
{node.meta?.orchestrator && " (orchestrator)"}
</div>
</div>
{statusOverlay && StatusIcon && (
<StatusIcon
className={`w-4 h-4 ${statusOverlay.color} shrink-0 ${statusOverlay.animate ? "animate-spin" : ""}`}
/>
)}
</div>
{/* Body */}
<div className="px-3 py-2 space-y-0.5" style={{ minHeight: dynamicBodyH }}>
{node.kind === "agent" && (
<div className="text-[10px] font-mono text-muted-foreground">
{node.agentName ? (
<span>Agent: <span className="text-primary">{node.agentName}</span></span>
) : node.agentId ? (
<span>Agent ID: <span className="text-primary">#{node.agentId}</span></span>
) : (
<span className="text-neon-amber">No agent assigned</span>
)}
</div>
)}
{node.kind === "container" && (
<div className="text-[10px] font-mono text-muted-foreground truncate">
{node.containerConfig?.image ? (
<span>Image: <span className="text-neon-amber">{node.containerConfig.image as string}</span></span>
) : (
<span className="text-neon-amber">No image configured</span>
)}
</div>
)}
{node.kind === "condition" && (
<div className="text-[10px] font-mono text-muted-foreground truncate">
{node.conditionExpr ? (
<code className="text-purple-400">{node.conditionExpr}</code>
) : (
<span className="text-purple-400/60">No condition set</span>
)}
</div>
)}
{node.kind === "trigger" && (
<div className="text-[10px] font-mono text-muted-foreground">
{node.triggerConfig?.type === "cron" ? (
<span>Cron: <span className="text-neon-green">{node.triggerConfig.cron as string}</span></span>
) : node.triggerConfig?.type === "webhook" ? (
<span>Webhook: <span className="text-neon-green">{node.triggerConfig.webhookPath as string}</span></span>
) : (
<span className="text-neon-green">Manual start</span>
)}
</div>
)}
{node.kind === "output" && (
<div className="text-[10px] font-mono text-muted-foreground">
{node.meta?.aggregator ? "Aggregator — collects data" : "Final output"}
</div>
)}
{/* Ports summary */}
<div className="flex items-center gap-1 pt-0.5">
{inputs.length > 0 && (
<Badge variant="outline" className="text-[8px] font-mono px-1 py-0 bg-transparent border-border/30 text-muted-foreground">
{inputs.length} in
</Badge>
)}
{outputs.length > 0 && (
<Badge variant="outline" className="text-[8px] font-mono px-1 py-0 bg-transparent border-border/30 text-muted-foreground">
{outputs.length} out
</Badge>
)}
</div>
{/* Runtime info */}
{node.runDurationMs !== undefined && node.runStatus !== "pending" && node.runStatus !== "running" && (
<div className="text-[10px] font-mono text-muted-foreground">
Duration: <span className="text-foreground">{node.runDurationMs}ms</span>
</div>
)}
{node.runError && (
<div className="text-[10px] font-mono text-neon-red truncate" title={node.runError}>
{node.runError}
</div>
)}
</div>
{/* Actions (visible when selected) */}
{selected && (
<div className="flex items-center gap-1 px-2 py-1.5 border-t border-border/30">
<Button
size="sm" variant="ghost"
className="h-6 w-6 p-0 text-muted-foreground hover:text-primary"
onClick={(e) => { e.stopPropagation(); onEdit?.(); }}
title="Edit"
>
<Settings className="w-3 h-3" />
</Button>
{onTest && (
<Button
size="sm" variant="ghost"
className="h-6 w-6 p-0 text-muted-foreground hover:text-neon-green"
onClick={(e) => { e.stopPropagation(); onTest(); }}
title="Test node"
>
<TestTube className="w-3 h-3" />
</Button>
)}
<Button
size="sm" variant="ghost"
className="h-6 w-6 p-0 text-muted-foreground hover:text-neon-red ml-auto"
onClick={(e) => { e.stopPropagation(); onDelete?.(); }}
title="Delete"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
)}
</div>
);
}
/**
* Mini node block for the sidebar palette (drag source).
*/
export function WorkflowNodePaletteItem({
kind,
onDragStart,
}: {
kind: NodeKind;
onDragStart: (kind: NodeKind) => void;
}) {
const cfg = KIND_CONFIG[kind];
const Icon = cfg.icon;
return (
<div
className={`
flex items-center gap-2.5 px-3 py-2 rounded-md border ${cfg.border} ${cfg.bg}
cursor-grab active:cursor-grabbing hover:brightness-110 transition-all
`}
draggable
onDragStart={() => onDragStart(kind)}
>
<div className={`w-6 h-6 rounded flex items-center justify-center ${cfg.bg} border ${cfg.border}`}>
<Icon className={`w-3.5 h-3.5 ${cfg.color}`} />
</div>
<span className="text-xs font-medium text-foreground">{cfg.label}</span>
</div>
);
}

View File

@@ -0,0 +1,380 @@
/**
* WorkflowNodeEditModal — configure individual node properties.
* Agent nodes get a selector for existing agents + orchestrator mode (extra outputs).
* Container nodes get image/env/ports fields.
* Condition nodes get an expression editor.
* Trigger nodes get type/cron/webhook fields.
* Output nodes can become aggregators (extra inputs).
*
* NEW: Extra inputs/outputs configuration for any node kind.
*/
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} 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 { Badge } from "@/components/ui/badge";
import { Bot, Box, Play, GitFork, Flag, Save, Minus, Plus, Network } from "lucide-react";
import type { WFNodeData, NodeKind } from "./WorkflowNodeBlock";
interface WorkflowNodeEditModalProps {
node: WFNodeData;
agents: any[];
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (node: WFNodeData) => void;
}
export function WorkflowNodeEditModal({
node,
agents,
open,
onOpenChange,
onSave,
}: WorkflowNodeEditModalProps) {
const [label, setLabel] = useState(node.label);
const [agentId, setAgentId] = useState<string>(node.agentId ? String(node.agentId) : "");
const [dockerImage, setDockerImage] = useState((node.containerConfig?.image as string) ?? "");
const [dockerEnv, setDockerEnv] = useState((node.containerConfig?.env as string[] ?? []).join("\n"));
const [dockerCommand, setDockerCommand] = useState((node.containerConfig?.command as string) ?? "");
const [conditionExpr, setConditionExpr] = useState(node.conditionExpr ?? "");
const [triggerType, setTriggerType] = useState((node.triggerConfig?.type as string) ?? "manual");
const [cronExpr, setCronExpr] = useState((node.triggerConfig?.cron as string) ?? "");
const [webhookPath, setWebhookPath] = useState((node.triggerConfig?.webhookPath as string) ?? "");
// Port configuration
const [extraInputs, setExtraInputs] = useState<number>((node.meta?.extraInputs as number) ?? 0);
const [extraOutputs, setExtraOutputs] = useState<number>((node.meta?.extraOutputs as number) ?? 0);
const [isAggregator, setIsAggregator] = useState<boolean>(node.meta?.aggregator === true);
const [isOrchestrator, setIsOrchestrator] = useState<boolean>(node.meta?.orchestrator === true);
useEffect(() => {
setLabel(node.label);
setAgentId(node.agentId ? String(node.agentId) : "");
setDockerImage((node.containerConfig?.image as string) ?? "");
setDockerEnv((node.containerConfig?.env as string[] ?? []).join("\n"));
setDockerCommand((node.containerConfig?.command as string) ?? "");
setConditionExpr(node.conditionExpr ?? "");
setTriggerType((node.triggerConfig?.type as string) ?? "manual");
setCronExpr((node.triggerConfig?.cron as string) ?? "");
setWebhookPath((node.triggerConfig?.webhookPath as string) ?? "");
setExtraInputs((node.meta?.extraInputs as number) ?? 0);
setExtraOutputs((node.meta?.extraOutputs as number) ?? 0);
setIsAggregator(node.meta?.aggregator === true);
setIsOrchestrator(node.meta?.orchestrator === true);
}, [node]);
const handleSave = () => {
const selectedAgent = agents.find((a: any) => a.id === Number(agentId));
const updated: WFNodeData = {
...node,
label,
agentId: agentId ? Number(agentId) : undefined,
agentName: selectedAgent?.name,
containerConfig: {
image: dockerImage,
env: dockerEnv.split("\n").filter(Boolean),
command: dockerCommand,
},
conditionExpr,
triggerConfig: {
type: triggerType,
cron: cronExpr,
webhookPath,
},
meta: {
...(node.meta ?? {}),
extraInputs,
extraOutputs,
aggregator: isAggregator,
orchestrator: isOrchestrator,
},
};
onSave(updated);
};
const kindIcons: Record<NodeKind, any> = {
trigger: Play,
agent: Bot,
container: Box,
condition: GitFork,
output: Flag,
};
const KindIcon = kindIcons[node.kind];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md bg-card border-border max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-foreground">
<KindIcon className="w-5 h-5 text-primary" />
Edit {node.kind.charAt(0).toUpperCase() + node.kind.slice(1)} Node
</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
{/* Label */}
<div>
<Label className="text-xs text-muted-foreground font-mono">Label</Label>
<Input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="Node name"
className="mt-1"
/>
</div>
{/* Agent config */}
{node.kind === "agent" && (
<>
<div>
<Label className="text-xs text-muted-foreground font-mono">Agent</Label>
<Select value={agentId} onValueChange={setAgentId}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Select an agent" />
</SelectTrigger>
<SelectContent>
{agents.map((agent: any) => (
<SelectItem key={agent.id} value={String(agent.id)}>
<div className="flex items-center gap-2">
<span>{agent.name}</span>
<Badge variant="outline" className="text-[9px] font-mono">{agent.role}</Badge>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{agentId && (() => {
const a = agents.find((ag: any) => ag.id === Number(agentId));
if (!a) return null;
return (
<div className="mt-2 p-2 rounded bg-secondary/30 border border-border/30 text-[10px] font-mono space-y-1">
<div>Model: <span className="text-primary">{a.model}</span></div>
<div>Provider: <span className="text-muted-foreground">{a.provider}</span></div>
{a.description && <div className="text-muted-foreground">{a.description}</div>}
</div>
);
})()}
</div>
{/* Orchestrator mode */}
<div className="p-3 rounded-md border border-border/30 bg-secondary/10">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Network className="w-3.5 h-3.5 text-cyan-400" />
<Label className="text-xs text-muted-foreground font-mono">Orchestrator Mode</Label>
</div>
<Button
size="sm"
variant={isOrchestrator ? "default" : "outline"}
className="h-6 text-[10px]"
onClick={() => {
setIsOrchestrator(!isOrchestrator);
if (!isOrchestrator) setExtraOutputs(Math.max(extraOutputs, 2));
}}
>
{isOrchestrator ? "ON" : "OFF"}
</Button>
</div>
<p className="text-[10px] text-muted-foreground">
Adds multiple output ports for routing to different downstream agents.
</p>
</div>
</>
)}
{/* Container config */}
{node.kind === "container" && (
<>
<div>
<Label className="text-xs text-muted-foreground font-mono">Docker Image</Label>
<Input
value={dockerImage}
onChange={(e) => setDockerImage(e.target.value)}
placeholder="e.g. python:3.12-slim"
className="mt-1 font-mono text-xs"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground font-mono">Command</Label>
<Input
value={dockerCommand}
onChange={(e) => setDockerCommand(e.target.value)}
placeholder="e.g. python /app/main.py"
className="mt-1 font-mono text-xs"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground font-mono">Environment Variables (one per line)</Label>
<Textarea
value={dockerEnv}
onChange={(e) => setDockerEnv(e.target.value)}
placeholder="KEY=VALUE"
className="mt-1 font-mono text-xs min-h-[60px]"
/>
</div>
</>
)}
{/* Condition config */}
{node.kind === "condition" && (
<div>
<Label className="text-xs text-muted-foreground font-mono">Condition Expression</Label>
<Textarea
value={conditionExpr}
onChange={(e) => setConditionExpr(e.target.value)}
placeholder="e.g. input.length > 0"
className="mt-1 font-mono text-xs min-h-[60px]"
/>
<p className="text-[10px] text-muted-foreground mt-1">
Evaluates to true/false. Routes data to TRUE or FALSE output ports.
</p>
</div>
)}
{/* Trigger config */}
{node.kind === "trigger" && (
<>
<div>
<Label className="text-xs text-muted-foreground font-mono">Trigger Type</Label>
<Select value={triggerType} onValueChange={setTriggerType}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="cron">Cron Schedule</SelectItem>
<SelectItem value="webhook">Webhook</SelectItem>
</SelectContent>
</Select>
</div>
{triggerType === "cron" && (
<div>
<Label className="text-xs text-muted-foreground font-mono">Cron Expression</Label>
<Input
value={cronExpr}
onChange={(e) => setCronExpr(e.target.value)}
placeholder="*/5 * * * *"
className="mt-1 font-mono text-xs"
/>
</div>
)}
{triggerType === "webhook" && (
<div>
<Label className="text-xs text-muted-foreground font-mono">Webhook Path</Label>
<Input
value={webhookPath}
onChange={(e) => setWebhookPath(e.target.value)}
placeholder="/webhook/my-trigger"
className="mt-1 font-mono text-xs"
/>
</div>
)}
</>
)}
{/* Output / Aggregator config */}
{node.kind === "output" && (
<div className="p-3 rounded-md border border-border/30 bg-secondary/10">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Network className="w-3.5 h-3.5 text-purple-400" />
<Label className="text-xs text-muted-foreground font-mono">Aggregator Mode</Label>
</div>
<Button
size="sm"
variant={isAggregator ? "default" : "outline"}
className="h-6 text-[10px]"
onClick={() => {
setIsAggregator(!isAggregator);
if (!isAggregator) setExtraInputs(Math.max(extraInputs, 3));
}}
>
{isAggregator ? "ON" : "OFF"}
</Button>
</div>
<p className="text-[10px] text-muted-foreground">
Collects data from multiple sources. Adds extra input ports and a merged output.
</p>
</div>
)}
{/* Extra ports configuration — available for all node kinds */}
<div className="p-3 rounded-md border border-border/30 bg-secondary/10 space-y-3">
<div className="text-[10px] font-mono text-muted-foreground uppercase tracking-wider">Port Configuration</div>
{/* Extra inputs */}
{node.kind !== "trigger" && (
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground font-mono">Extra Input Ports</Label>
<div className="flex items-center gap-2">
<Button
size="sm" variant="outline" className="h-6 w-6 p-0"
onClick={() => setExtraInputs(Math.max(0, extraInputs - 1))}
disabled={extraInputs === 0}
>
<Minus className="w-3 h-3" />
</Button>
<span className="text-xs font-mono w-6 text-center text-foreground">{extraInputs}</span>
<Button
size="sm" variant="outline" className="h-6 w-6 p-0"
onClick={() => setExtraInputs(Math.min(8, extraInputs + 1))}
>
<Plus className="w-3 h-3" />
</Button>
</div>
</div>
)}
{/* Extra outputs */}
{node.kind !== "output" || isAggregator ? (
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground font-mono">Extra Output Ports</Label>
<div className="flex items-center gap-2">
<Button
size="sm" variant="outline" className="h-6 w-6 p-0"
onClick={() => setExtraOutputs(Math.max(0, extraOutputs - 1))}
disabled={extraOutputs === 0}
>
<Minus className="w-3 h-3" />
</Button>
<span className="text-xs font-mono w-6 text-center text-foreground">{extraOutputs}</span>
<Button
size="sm" variant="outline" className="h-6 w-6 p-0"
onClick={() => setExtraOutputs(Math.min(8, extraOutputs + 1))}
>
<Plus className="w-3 h-3" />
</Button>
</div>
</div>
) : null}
<p className="text-[10px] text-muted-foreground">
Use extra ports for aggregator/orchestrator patterns. Side ports appear on left (inputs) and right (outputs).
</p>
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t border-border/30">
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleSave} className="bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25">
<Save className="w-3.5 h-3.5 mr-1" /> Save
</Button>
</div>
</DialogContent>
</Dialog>
);
}

989
client/src/lib/chatStore.ts Normal file
View File

@@ -0,0 +1,989 @@
/**
* chatStore — глобальный singleton для фонового чата.
*
* Архитектура:
* 1. Пользователь отправляет сообщение → POST /api/trpc/orchestrator.startSession
* Go Gateway создаёт запись в chatSessions и запускает горутину фоново.
* Ответ: { sessionId } — мгновенно, без ожидания LLM.
* 2. Фронтенд опрашивает /api/trpc/orchestrator.getEvents каждые 1.5 сек,
* применяя новые события к UI. Polling стартует заново при перезагрузке
* страницы, т.к. sessionId хранится в localStorage.
* 3. Когда status === "done" | "error" — опрос прекращается.
*
* Фоновые сессии (хранятся в localStorage):
* goclaw-pending-sessions — Map<sessionId, convId> для возобновления опроса.
* goclaw-conversations-v3 — список диалогов (сохраняется между загрузками).
*/
import { nanoid } from "nanoid";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface ToolCallStep {
tool: string;
args: Record<string, any>;
result: any;
error?: string;
success: boolean;
durationMs: number;
}
export interface ChatMessage {
id: string;
role: "user" | "assistant" | "system";
content: string;
timestamp: string;
toolCalls?: ToolCallStep[];
model?: string;
modelWarning?: string;
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
isError?: boolean;
isStreaming?: boolean;
/** sessionId for background sessions — used to resume polling */
sessionId?: string;
}
export interface Conversation {
id: string;
title: string;
createdAt: number;
messages: ChatMessage[];
history: Array<{ role: "user" | "assistant" | "system"; content: string }>;
}
export interface ConsoleEntry {
id: string;
type: "thinking" | "tool_call" | "done" | "error" | "retry";
tool?: string;
args?: any;
result?: any;
error?: string;
success?: boolean;
durationMs?: number;
timestamp: string;
model?: string;
/** For thinking events: extra message text (e.g. retry reason) */
content?: string;
}
// ─── TaskBoard Types ──────────────────────────────────────────────────────────
export type TaskStatus = "pending" | "in_progress" | "completed" | "failed" | "blocked";
export type TaskPriority = "critical" | "high" | "medium" | "low";
export interface TaskSubtask {
id: string;
content: string;
status: TaskStatus;
createdBy: string; // agent name or "user"
createdAt: number;
completedAt?: number;
}
export interface Task {
id: string;
content: string;
status: TaskStatus;
priority: TaskPriority;
subtasks: TaskSubtask[];
createdBy: string; // "user" | "orchestrator" | agent name
assignedTo?: string; // agent name
createdAt: number;
startedAt?: number;
completedAt?: number;
elapsedMs: number; // accumulated work time
retryCount: number;
lastError?: string;
testedAt?: number; // when the task was verified/tested
sessionId?: string; // linked chat session
}
export interface TaskBoard {
tasks: Task[];
globalStartedAt: number; // when the first task was created
totalElapsedMs: number; // total time spent on all tasks
isAutoRetryEnabled: boolean;
}
type StoreEvent = "update" | "console" | "tasks";
type UpdateHandler = () => void;
type ConsoleHandler = (entry: ConsoleEntry) => void;
type TaskHandler = (tasks: Task[]) => void;
// ─── Persistence ──────────────────────────────────────────────────────────────
const STORAGE_KEY = "goclaw-conversations-v3";
const PENDING_KEY = "goclaw-pending-sessions"; // sessionId → convId
const TASKS_KEY = "goclaw-taskboard-v1";
function loadConversations(): Conversation[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
return JSON.parse(raw);
} catch {
return [];
}
}
function persistConversations(convs: Conversation[]) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(convs.slice(0, 50)));
} catch {}
}
function loadPending(): Map<string, string> {
try {
const raw = localStorage.getItem(PENDING_KEY);
if (!raw) return new Map();
return new Map(JSON.parse(raw));
} catch {
return new Map();
}
}
function savePending(m: Map<string, string>) {
try {
localStorage.setItem(PENDING_KEY, JSON.stringify([...m.entries()]));
} catch {}
}
function loadTaskBoard(): TaskBoard {
try {
const raw = localStorage.getItem(TASKS_KEY);
if (!raw) return { tasks: [], globalStartedAt: 0, totalElapsedMs: 0, isAutoRetryEnabled: true };
return JSON.parse(raw);
} catch {
return { tasks: [], globalStartedAt: 0, totalElapsedMs: 0, isAutoRetryEnabled: true };
}
}
function persistTaskBoard(board: TaskBoard) {
try {
localStorage.setItem(TASKS_KEY, JSON.stringify(board));
} catch {}
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function getTs(): string {
return new Date().toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
/** Call the tRPC endpoint via raw fetch (avoids React-Query dependency). */
async function trpcQuery<T>(
path: string,
input: unknown,
method: "query" | "mutation"
): Promise<T> {
if (method === "mutation") {
const res = await fetch(`/api/trpc/${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ json: input }),
signal: AbortSignal.timeout(15_000),
});
const data = await res.json();
if (data?.error) throw new Error(data.error.message ?? "tRPC error");
return data?.result?.data?.json ?? data?.result?.data;
} else {
const encoded = encodeURIComponent(JSON.stringify({ json: input }));
const res = await fetch(`/api/trpc/${path}?input=${encoded}`, {
signal: AbortSignal.timeout(10_000),
});
const data = await res.json();
if (data?.error) throw new Error(data.error.message ?? "tRPC error");
return data?.result?.data?.json ?? data?.result?.data;
}
}
// ─── Store ────────────────────────────────────────────────────────────────────
class ChatStore {
private conversations: Conversation[] = loadConversations();
private activeId: string = "";
private consoleEntries: ConsoleEntry[] = [];
/** sessionId → convId for active polls */
private activePolls = new Map<string, string>();
/** sessionId → lastSeq */
private pollSeq = new Map<string, number>();
/** sessionId → timeout handle */
private pollTimers = new Map<string, ReturnType<typeof setTimeout>>();
/** Legacy SSE abort controller (still used as fallback) */
private abortController: AbortController | null = null;
private isThinking = false;
private updateListeners = new Set<UpdateHandler>();
private consoleListeners = new Set<ConsoleHandler>();
private taskListeners = new Set<TaskHandler>();
/** TaskBoard state */
private taskBoard: TaskBoard = loadTaskBoard();
/** Timer for tracking elapsed time on in-progress tasks */
private taskTimerHandle: ReturnType<typeof setInterval> | null = null;
constructor() {
if (this.conversations.length > 0) {
this.activeId = this.conversations[0].id;
}
// Resume any pending sessions from previous page load
this._resumePendingSessions();
// Start task timer for elapsed tracking
this._startTaskTimer();
}
// ─── Subscriptions ──────────────────────────────────────────────────────────
on(event: "update", handler: UpdateHandler): void;
on(event: "console", handler: ConsoleHandler): void;
on(event: "tasks", handler: TaskHandler): void;
on(event: StoreEvent, handler: any): void {
if (event === "update") this.updateListeners.add(handler);
else if (event === "console") this.consoleListeners.add(handler);
else if (event === "tasks") this.taskListeners.add(handler);
}
off(event: "update", handler: UpdateHandler): void;
off(event: "console", handler: ConsoleHandler): void;
off(event: "tasks", handler: TaskHandler): void;
off(event: StoreEvent, handler: any): void {
if (event === "update") this.updateListeners.delete(handler);
else if (event === "console") this.consoleListeners.delete(handler);
else if (event === "tasks") this.taskListeners.delete(handler);
}
private emit(event: "update"): void;
private emit(event: "console", entry: ConsoleEntry): void;
private emit(event: "tasks", tasks: Task[]): void;
private emit(event: StoreEvent, data?: any): void {
if (event === "update") {
this.updateListeners.forEach((h) => h());
} else if (event === "console" && data) {
this.consoleListeners.forEach((h) => h(data));
} else if (event === "tasks" && data) {
this.taskListeners.forEach((h) => h(data));
}
}
// ─── Selectors ──────────────────────────────────────────────────────────────
getConversations(): Conversation[] { return this.conversations; }
getActiveId(): string { return this.activeId; }
getActive(): Conversation | null {
return this.conversations.find((c) => c.id === this.activeId) ?? null;
}
getIsThinking(): boolean {
return this.isThinking || this.activePolls.size > 0;
}
getConsole(): ConsoleEntry[] { return this.consoleEntries; }
getTaskBoard(): TaskBoard { return this.taskBoard; }
getTasks(): Task[] { return this.taskBoard.tasks; }
getAutoRetryEnabled(): boolean { return this.taskBoard.isAutoRetryEnabled; }
// ─── Mutations ──────────────────────────────────────────────────────────────
setActiveId(id: string) {
this.activeId = id;
this.emit("update");
}
createConversation(orchName = "GoClaw Orchestrator"): string {
const id = nanoid(8);
const welcome: ChatMessage = {
id: "welcome",
role: "system",
content: `${orchName} ready. Type a command or ask anything.\n\n*Background mode: requests continue even when you close the tab.*`,
timestamp: getTs(),
};
const conv: Conversation = {
id,
title: "New Chat",
createdAt: Date.now(),
messages: [welcome],
history: [],
};
this.conversations = [conv, ...this.conversations];
this.activeId = id;
this.consoleEntries = [];
persistConversations(this.conversations);
this.emit("update");
return id;
}
deleteConversation(id: string, orchName?: string) {
this.conversations = this.conversations.filter((c) => c.id !== id);
if (this.activeId === id) {
if (this.conversations.length > 0) {
this.activeId = this.conversations[0].id;
} else {
this.createConversation(orchName);
return;
}
}
persistConversations(this.conversations);
this.emit("update");
}
clearConsole() {
this.consoleEntries = [];
this.emit("update");
}
private updateConv(id: string, updater: (c: Conversation) => Conversation) {
this.conversations = this.conversations.map((c) => (c.id === id ? updater(c) : c));
persistConversations(this.conversations);
}
private addConsoleEntry(entry: Omit<ConsoleEntry, "id" | "timestamp">) {
const full: ConsoleEntry = { ...entry, id: nanoid(6), timestamp: getTs() };
this.consoleEntries = [...this.consoleEntries, full];
this.emit("console", full);
this.emit("update");
}
// ─── Background Session Send ─────────────────────────────────────────────────
/**
* Send a message using the background session API.
* The Go Gateway processes the request in a detached goroutine — survives
* page reloads, laptop sleep, and browser tab closure.
*/
async send(userText: string, activeConvId?: string) {
if (!userText.trim()) return;
if (this.isThinking) return;
let convId = activeConvId ?? this.activeId;
if (!convId || !this.conversations.find((c) => c.id === convId)) {
convId = this.createConversation();
}
const conv = this.conversations.find((c) => c.id === convId);
if (!conv) return;
// Add user message immediately
const userMsg: ChatMessage = {
id: `user-${Date.now()}`,
role: "user",
content: userText.trim(),
timestamp: getTs(),
};
const newHistory = [
...conv.history,
{ role: "user" as const, content: userText.trim() },
];
this.updateConv(convId, (c) => ({
...c,
title: c.history.length === 0
? userText.trim().slice(0, 40) + (userText.length > 40 ? "…" : "")
: c.title,
messages: [...c.messages, userMsg],
history: newHistory,
}));
// Create placeholder streaming message
const sessionId = `cs-${nanoid(12)}`;
const assistantId = `resp-${sessionId}`;
const placeholder: ChatMessage = {
id: assistantId,
role: "assistant",
content: "",
timestamp: getTs(),
isStreaming: true,
toolCalls: [],
sessionId,
};
this.updateConv(convId, (c) => ({
...c,
messages: [...c.messages, placeholder],
}));
this.isThinking = true;
this.consoleEntries = [];
this.emit("update");
try {
// Start background session on Go Gateway (returns immediately)
await trpcQuery<{ sessionId: string; status: string }>(
"orchestrator.startSession",
{
messages: newHistory,
sessionId,
maxIter: 10,
},
"mutation"
);
// Persist to localStorage so we can resume on page reload
const pending = loadPending();
pending.set(sessionId, convId);
savePending(pending);
// Start polling
this._startPolling(sessionId, convId, assistantId);
} catch (err: any) {
this.isThinking = false;
this.addConsoleEntry({ type: "error", error: err.message });
this.updateConv(convId, (c) => ({
...c,
messages: c.messages.map((m) =>
m.id === assistantId
? {
...m,
content: `Failed to start background session: ${err.message}`,
isError: true,
isStreaming: false,
}
: m
),
}));
this.emit("update");
}
}
// ─── Polling ────────────────────────────────────────────────────────────────
private _startPolling(sessionId: string, convId: string, assistantMsgId: string) {
if (this.activePolls.has(sessionId)) return;
this.activePolls.set(sessionId, convId);
this.pollSeq.set(sessionId, 0);
this._scheduleNextPoll(sessionId, convId, assistantMsgId, 1500);
}
private _scheduleNextPoll(
sessionId: string,
convId: string,
assistantMsgId: string,
delayMs: number
) {
const handle = setTimeout(() => {
this._doPoll(sessionId, convId, assistantMsgId);
}, delayMs);
this.pollTimers.set(sessionId, handle);
}
private async _doPoll(sessionId: string, convId: string, assistantMsgId: string) {
const afterSeq = this.pollSeq.get(sessionId) ?? 0;
try {
const result = await trpcQuery<{
sessionId: string;
status: string;
events: Array<{
id: number;
sessionId: string;
seq: number;
eventType: "thinking" | "tool_call" | "delta" | "done" | "error";
content: string;
toolName: string;
toolArgs: string;
toolResult: string;
toolSuccess: boolean;
durationMs: number;
model: string;
usageJson: string;
errorMsg: string;
createdAt: string;
}>;
}>("orchestrator.getEvents", { sessionId, afterSeq }, "query");
if (!result) {
// Gateway not available yet — retry
this._scheduleNextPoll(sessionId, convId, assistantMsgId, 2000);
return;
}
const { status, events } = result;
let maxSeq = afterSeq;
let lastContent = "";
let lastModel = "";
let lastUsage: any = undefined;
let streamedContent = "";
// Get current content from message
const conv = this.conversations.find((c) => c.id === convId);
const existingMsg = conv?.messages.find((m) => m.id === assistantMsgId);
streamedContent = existingMsg?.content ?? "";
for (const ev of events) {
if (ev.seq > maxSeq) maxSeq = ev.seq;
switch (ev.eventType) {
case "thinking": {
// If content starts with retry prefix, show as retry event
const thinkMsg = ev.content || "";
if (thinkMsg.startsWith("⟳ Retry")) {
this.addConsoleEntry({ type: "retry", content: thinkMsg });
} else {
this.addConsoleEntry({ type: "thinking", content: thinkMsg || undefined });
}
break;
}
case "tool_call": {
let args: any = {};
try { args = JSON.parse(ev.toolArgs || "{}"); } catch {}
let resultVal: any = ev.toolResult;
try { if (ev.toolResult) resultVal = JSON.parse(ev.toolResult); } catch {}
const step: ToolCallStep = {
tool: ev.toolName,
args,
result: resultVal,
error: ev.errorMsg || undefined,
success: ev.toolSuccess,
durationMs: ev.durationMs,
};
this.addConsoleEntry({ type: "tool_call", ...step });
// Append tool call to message
this.updateConv(convId, (c) => ({
...c,
messages: c.messages.map((m) =>
m.id === assistantMsgId
? { ...m, toolCalls: [...(m.toolCalls ?? []), step] }
: m
),
}));
break;
}
case "delta":
// The Go gateway stores full response as a single delta
streamedContent = ev.content || streamedContent;
this.updateConv(convId, (c) => ({
...c,
messages: c.messages.map((m) =>
m.id === assistantMsgId ? { ...m, content: streamedContent } : m
),
}));
this.emit("update");
break;
case "done": {
lastModel = ev.model;
try {
const usageObj = JSON.parse(ev.usageJson || "null");
if (usageObj) {
lastUsage = {
prompt_tokens: usageObj.promptTokens ?? usageObj.prompt_tokens ?? 0,
completion_tokens: usageObj.completionTokens ?? usageObj.completion_tokens ?? 0,
total_tokens: usageObj.totalTokens ?? usageObj.total_tokens ?? 0,
};
}
} catch {}
this.addConsoleEntry({ type: "done", model: lastModel });
break;
}
case "error":
this.addConsoleEntry({ type: "error", error: ev.errorMsg });
this.updateConv(convId, (c) => ({
...c,
messages: c.messages.map((m) =>
m.id === assistantMsgId
? {
...m,
content: `Error: ${ev.errorMsg}`,
isError: true,
isStreaming: false,
}
: m
),
}));
this.emit("update");
break;
}
}
this.pollSeq.set(sessionId, maxSeq);
if (status === "done" || status === "error") {
// Finalize message
this.updateConv(convId, (c) => {
const msg = c.messages.find((m) => m.id === assistantMsgId);
const finalContent = streamedContent || msg?.content || "(no response)";
return {
...c,
history: status === "done"
? [
...c.history.filter((h) => !(h.role === "assistant" && h.content === "")),
{ role: "assistant" as const, content: finalContent },
]
: c.history,
messages: c.messages.map((m) =>
m.id === assistantMsgId
? {
...m,
content: finalContent,
isStreaming: false,
model: lastModel || m.model,
usage: lastUsage || m.usage,
isError: status === "error" ? true : m.isError,
}
: m
),
};
});
// Clean up
this._stopPolling(sessionId);
this.isThinking = this.activePolls.size > 0;
this.emit("update");
// ── Auto-retry logic ──────────────────────────────────────────────
// If session errored and there are pending/failed tasks with auto-retry,
// schedule a retry after a short delay.
if (status === "error" && this.taskBoard.isAutoRetryEnabled && !this.allTasksDone()) {
const retryTask = this.getNextRetryableTask();
if (retryTask) {
this.updateTaskStatus(retryTask.id, "failed", streamedContent || "Session error");
this.addConsoleEntry({
type: "retry",
content: `Auto-retry: task "${retryTask.content.slice(0, 40)}" (attempt #${retryTask.retryCount + 1})`,
});
// Retry after 3 seconds
setTimeout(() => {
const retryMsg = `[AUTO-RETRY] Task failed, retrying: "${retryTask.content}". Check the TODO board and continue working on incomplete tasks. Do not stop until all tasks are completed and tested.`;
this.send(retryMsg, convId);
}, 3000);
}
}
} else {
// Session still running — poll again
this._scheduleNextPoll(sessionId, convId, assistantMsgId, 1500);
}
} catch {
// Network error — retry with backoff
this._scheduleNextPoll(sessionId, convId, assistantMsgId, 3000);
}
}
private _stopPolling(sessionId: string) {
clearTimeout(this.pollTimers.get(sessionId));
this.pollTimers.delete(sessionId);
this.activePolls.delete(sessionId);
this.pollSeq.delete(sessionId);
// Remove from persistent pending list
const pending = loadPending();
pending.delete(sessionId);
savePending(pending);
}
/** Resume polling for sessions that were running when page was reloaded. */
private async _resumePendingSessions() {
const pending = loadPending();
if (pending.size === 0) return;
// Small delay to let React mount first
await new Promise<void>((resolve) => setTimeout(resolve, 2000));
for (const [sessionId, convId] of pending.entries()) {
// Find the conversation
const conv = this.conversations.find((c) => c.id === convId);
if (!conv) {
// Conversation gone — clean up
const p = loadPending();
p.delete(sessionId);
savePending(p);
continue;
}
// Find the placeholder message for this session
const msgWithSession = conv.messages.find(
(m) => m.sessionId === sessionId && m.role === "assistant"
);
// Check current session status from DB
try {
const sess = await trpcQuery<{
status: string;
finalResponse: string;
model: string;
totalTokens: number;
}>("orchestrator.getSession", { sessionId }, "query");
if (!sess) {
// Session not found in DB — remove pending
const p = loadPending();
p.delete(sessionId);
savePending(p);
continue;
}
if (sess.status === "done" || sess.status === "error") {
// Already finished — update message directly from session data
if (msgWithSession) {
this.updateConv(convId, (c) => ({
...c,
history: sess.status === "done"
? [
...c.history.filter((h) => !(h.role === "assistant" && h.content === "")),
{ role: "assistant" as const, content: sess.finalResponse },
]
: c.history,
messages: c.messages.map((m) =>
m.id === msgWithSession.id
? {
...m,
content: sess.finalResponse || m.content || "(no response)",
isStreaming: false,
isError: sess.status === "error",
model: sess.model || m.model,
usage: sess.totalTokens
? { prompt_tokens: 0, completion_tokens: 0, total_tokens: sess.totalTokens }
: m.usage,
}
: m
),
}));
}
const p = loadPending();
p.delete(sessionId);
savePending(p);
this.emit("update");
} else {
// Still running — resume polling
const assistantMsgId = msgWithSession?.id ?? `resp-${sessionId}`;
this.isThinking = true;
this._startPolling(sessionId, convId, assistantMsgId);
this.emit("update");
}
} catch {
// Gateway not reachable — keep pending for next reload
}
}
}
/** Cancel the current in-flight SSE request (legacy). */
cancel() {
this.abortController?.abort();
// Also stop all active polls
for (const sessionId of [...this.activePolls.keys()]) {
this._stopPolling(sessionId);
}
this.isThinking = false;
this.emit("update");
}
// ─── TaskBoard Methods ────────────────────────────────────────────────────
/** Start interval that tracks elapsed time for in-progress tasks */
private _startTaskTimer() {
if (this.taskTimerHandle) return;
this.taskTimerHandle = setInterval(() => {
let changed = false;
const now = Date.now();
this.taskBoard.tasks = this.taskBoard.tasks.map((t) => {
if (t.status === "in_progress" && t.startedAt) {
changed = true;
return { ...t, elapsedMs: t.elapsedMs + 1000 };
}
return t;
});
if (changed) {
// Update total elapsed
this.taskBoard.totalElapsedMs = this.taskBoard.tasks.reduce(
(sum, t) => sum + t.elapsedMs, 0
);
persistTaskBoard(this.taskBoard);
this.emit("update");
}
}, 1000);
}
/** Add a new task to the board */
addTask(
content: string,
opts: {
priority?: TaskPriority;
createdBy?: string;
assignedTo?: string;
sessionId?: string;
} = {}
): Task {
const task: Task = {
id: nanoid(8),
content,
status: "pending",
priority: opts.priority ?? "medium",
subtasks: [],
createdBy: opts.createdBy ?? "user",
assignedTo: opts.assignedTo,
createdAt: Date.now(),
elapsedMs: 0,
retryCount: 0,
sessionId: opts.sessionId,
};
if (this.taskBoard.globalStartedAt === 0) {
this.taskBoard.globalStartedAt = Date.now();
}
this.taskBoard.tasks = [...this.taskBoard.tasks, task];
persistTaskBoard(this.taskBoard);
this.emit("tasks", this.taskBoard.tasks);
this.emit("update");
return task;
}
/** Update task status */
updateTaskStatus(taskId: string, status: TaskStatus, error?: string) {
const now = Date.now();
this.taskBoard.tasks = this.taskBoard.tasks.map((t) => {
if (t.id !== taskId) return t;
const updated = { ...t, status };
if (status === "in_progress" && !t.startedAt) {
updated.startedAt = now;
}
if (status === "completed") {
updated.completedAt = now;
}
if (status === "failed") {
updated.lastError = error;
updated.retryCount = t.retryCount + 1;
}
return updated;
});
this.taskBoard.totalElapsedMs = this.taskBoard.tasks.reduce(
(sum, t) => sum + t.elapsedMs, 0
);
persistTaskBoard(this.taskBoard);
this.emit("tasks", this.taskBoard.tasks);
this.emit("update");
}
/** Mark a task as tested */
markTaskTested(taskId: string) {
this.taskBoard.tasks = this.taskBoard.tasks.map((t) =>
t.id === taskId ? { ...t, testedAt: Date.now() } : t
);
persistTaskBoard(this.taskBoard);
this.emit("tasks", this.taskBoard.tasks);
this.emit("update");
}
/** Add a subtask (can be called by agents) */
addSubtask(
taskId: string,
content: string,
createdBy: string = "orchestrator"
): TaskSubtask | null {
let sub: TaskSubtask | null = null;
this.taskBoard.tasks = this.taskBoard.tasks.map((t) => {
if (t.id !== taskId) return t;
sub = {
id: nanoid(6),
content,
status: "pending",
createdBy,
createdAt: Date.now(),
};
return { ...t, subtasks: [...t.subtasks, sub] };
});
persistTaskBoard(this.taskBoard);
this.emit("tasks", this.taskBoard.tasks);
this.emit("update");
return sub;
}
/** Update subtask status */
updateSubtaskStatus(taskId: string, subtaskId: string, status: TaskStatus) {
this.taskBoard.tasks = this.taskBoard.tasks.map((t) => {
if (t.id !== taskId) return t;
return {
...t,
subtasks: t.subtasks.map((s) =>
s.id === subtaskId
? { ...s, status, completedAt: status === "completed" ? Date.now() : s.completedAt }
: s
),
};
});
persistTaskBoard(this.taskBoard);
this.emit("tasks", this.taskBoard.tasks);
this.emit("update");
}
/** Remove a task */
removeTask(taskId: string) {
this.taskBoard.tasks = this.taskBoard.tasks.filter((t) => t.id !== taskId);
this.taskBoard.totalElapsedMs = this.taskBoard.tasks.reduce(
(sum, t) => sum + t.elapsedMs, 0
);
persistTaskBoard(this.taskBoard);
this.emit("tasks", this.taskBoard.tasks);
this.emit("update");
}
/** Clear all tasks */
clearTasks() {
this.taskBoard = {
tasks: [],
globalStartedAt: 0,
totalElapsedMs: 0,
isAutoRetryEnabled: this.taskBoard.isAutoRetryEnabled,
};
persistTaskBoard(this.taskBoard);
this.emit("tasks", this.taskBoard.tasks);
this.emit("update");
}
/** Toggle auto-retry mode */
setAutoRetry(enabled: boolean) {
this.taskBoard.isAutoRetryEnabled = enabled;
persistTaskBoard(this.taskBoard);
this.emit("update");
}
/** Get progress summary */
getTaskProgress(): {
total: number;
completed: number;
inProgress: number;
failed: number;
pending: number;
percent: number;
totalElapsedMs: number;
globalElapsedMs: number;
} {
const tasks = this.taskBoard.tasks;
const total = tasks.length;
const completed = tasks.filter((t) => t.status === "completed").length;
const inProgress = tasks.filter((t) => t.status === "in_progress").length;
const failed = tasks.filter((t) => t.status === "failed").length;
const pending = tasks.filter((t) => t.status === "pending" || t.status === "blocked").length;
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
const totalElapsedMs = this.taskBoard.totalElapsedMs;
const globalElapsedMs = this.taskBoard.globalStartedAt > 0
? Date.now() - this.taskBoard.globalStartedAt
: 0;
return { total, completed, inProgress, failed, pending, percent, totalElapsedMs, globalElapsedMs };
}
/** Get next pending/failed task for auto-retry */
getNextRetryableTask(): Task | null {
if (!this.taskBoard.isAutoRetryEnabled) return null;
// First try failed tasks, then pending
return (
this.taskBoard.tasks.find((t) => t.status === "failed" && t.retryCount < 5) ??
this.taskBoard.tasks.find((t) => t.status === "pending") ??
null
);
}
/** Check if all tasks are done (completed or tested) */
allTasksDone(): boolean {
if (this.taskBoard.tasks.length === 0) return true;
return this.taskBoard.tasks.every(
(t) => t.status === "completed"
);
}
}
// Singleton — survives React unmount/remount cycles
export const chatStore = new ChatStore();

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
* Design: Grid of metric cards, node status, agent activity feed, cluster health
* Colors: Cyan glow for primary metrics, green/amber/red for status
* Typography: JetBrains Mono for all data values
* Now with REAL Ollama API data integration
* Data: 100% real tRPC data — nodes.list, nodes.stats, agents.list, dashboard.stats
*/
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
@@ -21,47 +21,26 @@ import {
CheckCircle,
XCircle,
Loader2,
RefreshCw,
} from "lucide-react";
import { motion } from "framer-motion";
import { trpc } from "@/lib/trpc";
import ClusterTopology from "@/components/ClusterTopology";
const HERO_BG = "https://d2xsxph8kpxj0f.cloudfront.net/97147719/ZEGAT83geRq9CNvryykaQv/hero-bg-Si4yCvZwFbZMP4XaHUueFi.webp";
const SWARM_IMG = "https://d2xsxph8kpxj0f.cloudfront.net/97147719/ZEGAT83geRq9CNvryykaQv/swarm-cluster-jkxdea5N7sXTSZfbAbKCfs.webp";
const NODES = [
{ id: "node-01", name: "goclaw-manager-01", role: "Manager", status: "ready", cpu: 42, mem: 68, containers: 5, ip: "192.168.1.10" },
{ id: "node-02", name: "goclaw-worker-01", role: "Worker", status: "ready", cpu: 28, mem: 45, containers: 3, ip: "192.168.1.11" },
{ id: "node-03", name: "goclaw-worker-02", role: "Worker", status: "ready", cpu: 15, mem: 32, containers: 2, ip: "192.168.1.12" },
{ id: "node-04", name: "goclaw-worker-03", role: "Worker", status: "drain", cpu: 0, mem: 12, containers: 0, ip: "192.168.1.13" },
];
const AGENTS = [
{ id: "agent-coder", name: "Coder Agent", model: "claude-3.5-sonnet", status: "running", tasks: 3, uptime: "2d 14h" },
{ id: "agent-browser", name: "Browser Agent", model: "gpt-4o", status: "running", tasks: 1, uptime: "2d 14h" },
{ id: "agent-mail", name: "Mail Agent", model: "gpt-4o-mini", status: "idle", tasks: 0, uptime: "2d 14h" },
{ id: "agent-monitor", name: "Monitor Agent", model: "llama-3.1-8b", status: "running", tasks: 5, uptime: "2d 14h" },
{ id: "agent-docs", name: "Docs Agent", model: "claude-3-haiku", status: "error", tasks: 0, uptime: "0h 0m" },
];
const ACTIVITY_LOG = [
{ time: "19:24:15", agent: "Coder Agent", action: "Завершил рефакторинг модуля memory.go", type: "success" },
{ time: "19:23:48", agent: "Browser Agent", action: "Открыл https://github.com/goclaw/core", type: "info" },
{ time: "19:22:11", agent: "Monitor Agent", action: "CPU на node-02 превысил 80% (пик)", type: "warning" },
{ time: "19:21:30", agent: "Mail Agent", action: "Обработал 12 входящих писем", type: "success" },
{ time: "19:20:05", agent: "Docs Agent", action: "Ошибка подключения к LLM API", type: "error" },
{ time: "19:18:44", agent: "Coder Agent", action: "Создал новый скилл: ssh_executor.go", type: "success" },
];
function getStatusColor(status: string) {
switch (status) {
case "ready":
case "running":
case "active":
case "success":
return "text-neon-green";
case "idle":
case "info":
return "text-primary";
case "drain":
case "pause":
case "warning":
return "text-neon-amber";
case "error":
@@ -75,8 +54,10 @@ function getStatusColor(status: string) {
function getStatusBadge(status: string) {
const colors: Record<string, string> = {
ready: "bg-neon-green/15 text-neon-green border-neon-green/30",
active: "bg-neon-green/15 text-neon-green border-neon-green/30",
running: "bg-neon-green/15 text-neon-green border-neon-green/30",
idle: "bg-primary/15 text-primary border-primary/30",
pause: "bg-neon-amber/15 text-neon-amber border-neon-amber/30",
drain: "bg-neon-amber/15 text-neon-amber border-neon-amber/30",
error: "bg-neon-red/15 text-neon-red border-neon-red/30",
};
@@ -84,18 +65,58 @@ function getStatusBadge(status: string) {
}
export default function Dashboard() {
// Real data from Ollama API
// ── Real API data ──────────────────────────────────────────────────────────
const healthQuery = trpc.ollama.health.useQuery(undefined, {
refetchInterval: 30_000,
});
const modelsQuery = trpc.ollama.models.useQuery(undefined, {
refetchInterval: 60_000,
});
const dashboardStats = trpc.dashboard.stats.useQuery(undefined, {
refetchInterval: 30_000,
});
const nodesQuery = trpc.nodes.list.useQuery(undefined, {
refetchInterval: 15_000,
});
const nodeStatsQuery = trpc.nodes.stats.useQuery(undefined, {
refetchInterval: 15_000,
});
const agentsQuery = trpc.agents.list.useQuery(undefined, {
refetchInterval: 30_000,
});
// ── Derived values ─────────────────────────────────────────────────────────
const ollamaConnected = healthQuery.data?.connected ?? false;
const ollamaLatency = healthQuery.data?.latencyMs ?? 0;
const modelCount = modelsQuery.data?.success ? modelsQuery.data.models.length : 0;
const stats = dashboardStats.data;
const nodes = nodesQuery.data?.nodes ?? [];
const containers = nodesQuery.data?.containers ?? [];
const containerStats = nodeStatsQuery.data?.stats ?? [];
const agents = agentsQuery.data ?? [];
const activeAgents = agents.filter((a) => a.isActive);
// Build per-container cpu/mem map from stats
const statMap: Record<string, { cpuPct: number; memPct: number; memUseMB: number }> = {};
for (const s of containerStats) {
statMap[s.id] = { cpuPct: s.cpuPct, memPct: s.memPct, memUseMB: s.memUseMB };
}
// Activity feed: last 6 agent metrics (most recent requests) — use real agents list
// Since we don't have a global history endpoint on Dashboard, derive feed from agents
const activityFeed = activeAgents.slice(0, 6).map((agent) => ({
agent: agent.name,
action: `Агент активен · модель ${agent.model}`,
type: agent.isActive ? "success" : "info",
time: new Date().toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit" }),
id: agent.id,
}));
const nodesLoading = nodesQuery.isLoading;
const agentsLoading = agentsQuery.isLoading;
const statsLoading = dashboardStats.isLoading;
return (
<div className="space-y-6">
{/* Hero banner */}
@@ -117,7 +138,11 @@ export default function Dashboard() {
GoClaw Swarm Control Center
</h1>
<p className="text-sm text-muted-foreground mt-1 font-mono">
Кластер <span className="text-primary">goclaw-swarm</span> &middot; 4 ноды &middot; 7 агентов &middot; Overlay Network: <span className="text-primary">goclaw-net</span>
Кластер <span className="text-primary">goclaw-swarm</span>
{!statsLoading && stats && (
<> &middot; {stats.nodes} нод &middot; {stats.agents} агентов</>
)}
{" "}&middot; Overlay Network: <span className="text-primary">goclaw-net</span>
</p>
</div>
</div>
@@ -146,6 +171,11 @@ export default function Dashboard() {
<Badge variant="outline" className={`text-[9px] font-mono ${ollamaConnected ? "bg-neon-green/15 text-neon-green border-neon-green/30" : "bg-neon-red/15 text-neon-red border-neon-red/30"}`}>
{healthQuery.isLoading ? "CHECKING..." : ollamaConnected ? "CONNECTED" : "OFFLINE"}
</Badge>
{stats?.gatewayOnline && (
<Badge variant="outline" className="text-[9px] font-mono bg-primary/15 text-primary border-primary/30">
GATEWAY OK
</Badge>
)}
</div>
<div className="text-[11px] font-mono text-muted-foreground">
https://ollama.com/v1
@@ -172,21 +202,21 @@ export default function Dashboard() {
</Card>
</motion.div>
{/* Key metrics row */}
{/* Key metrics row — now from dashboard.stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
icon={Server}
label="Активные ноды"
value="3 / 4"
change="+0"
value={statsLoading ? "..." : (stats?.nodes ?? `${nodes.length}`)}
change={nodesQuery.data?.swarmActive ? "Swarm" : "Standalone"}
trend="up"
color="text-primary"
/>
<MetricCard
icon={Bot}
label="Агенты"
value="5"
change="4 active"
value={agentsLoading ? "..." : String(activeAgents.length)}
change={`${agents.length} total`}
trend="up"
color="text-neon-green"
/>
@@ -210,152 +240,261 @@ export default function Dashboard() {
{/* Main grid: Nodes + Agents + Activity */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* Nodes panel */}
{/* ── Nodes panel (real data) ───────────────────────────────────── */}
<Card className="xl:col-span-1 bg-card border-border/50">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Server className="w-4 h-4 text-primary" />
Swarm Nodes
{nodesLoading && <Loader2 className="w-3 h-3 animate-spin text-muted-foreground ml-auto" />}
{!nodesLoading && (
<span className="ml-auto text-[10px] font-mono text-muted-foreground">
{nodes.length > 0 ? `${nodes.length} nodes` : containers.length > 0 ? `${containers.length} containers` : "no data"}
</span>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{NODES.map((node) => (
<motion.div
key={node.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className="p-3 rounded-md bg-secondary/30 border border-border/30 space-y-2"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${node.status === "ready" ? "bg-neon-green pulse-indicator" : node.status === "drain" ? "bg-neon-amber" : "bg-neon-red"}`} />
<span className="font-mono text-xs font-medium text-foreground">{node.name}</span>
{nodesLoading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
<span className="text-xs font-mono">Загрузка нод...</span>
</div>
) : nodes.length > 0 ? (
nodes.map((node) => (
<motion.div
key={node.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className="p-3 rounded-md bg-secondary/30 border border-border/30 space-y-2"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${
node.status === "ready" || node.availability === "active"
? "bg-neon-green pulse-indicator"
: node.availability === "drain"
? "bg-neon-amber"
: "bg-neon-red"
}`} />
<span className="font-mono text-xs font-medium text-foreground truncate max-w-[120px]" title={node.hostname}>
{node.hostname}
</span>
</div>
<Badge variant="outline" className={`text-[10px] font-mono ${getStatusBadge(node.availability ?? node.status)}`}>
{(node.availability ?? node.status).toUpperCase()}
</Badge>
</div>
<Badge variant="outline" className={`text-[10px] font-mono ${getStatusBadge(node.status)}`}>
{node.status.toUpperCase()}
</Badge>
</div>
<div className="grid grid-cols-3 gap-2 text-[10px] font-mono">
<div>
<span className="text-muted-foreground">CPU</span>
<Progress value={node.cpu} className="h-1 mt-1" />
<span className={`${node.cpu > 70 ? "text-neon-amber" : "text-neon-green"}`}>{node.cpu}%</span>
<div className="grid grid-cols-3 gap-2 text-[10px] font-mono">
<div>
<span className="text-muted-foreground">ROLE</span>
<div className={`mt-1 ${node.role === "manager" ? "text-primary" : "text-foreground"}`}>
{node.role}
{node.isLeader && <span className="text-neon-amber ml-1"></span>}
</div>
</div>
<div>
<span className="text-muted-foreground">CPU</span>
<div className="text-foreground mt-1">{node.cpuCores}c</div>
</div>
<div>
<span className="text-muted-foreground">MEM</span>
<div className="text-foreground mt-1">
{node.memTotalMB > 1024 ? `${(node.memTotalMB / 1024).toFixed(1)}G` : `${node.memTotalMB}M`}
</div>
</div>
</div>
<div>
<span className="text-muted-foreground">MEM</span>
<Progress value={node.mem} className="h-1 mt-1" />
<span className={`${node.mem > 70 ? "text-neon-amber" : "text-neon-green"}`}>{node.mem}%</span>
<div className="text-[10px] font-mono text-muted-foreground truncate">
{node.ip} &middot; Docker {node.dockerVersion}
</div>
<div>
<span className="text-muted-foreground">CONTAINERS</span>
<div className="text-foreground mt-1">{node.containers}</div>
</div>
</div>
</motion.div>
))}
</motion.div>
))
) : containers.length > 0 ? (
// Standalone mode: show containers
containers.slice(0, 4).map((c) => {
const cs = containerStats.find((s) => s.id.startsWith(c.id.slice(0, 12)) || c.id.startsWith(s.id.slice(0, 12)));
return (
<motion.div
key={c.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className="p-3 rounded-md bg-secondary/30 border border-border/30 space-y-2"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${c.state === "running" ? "bg-neon-green pulse-indicator" : "bg-neon-amber"}`} />
<span className="font-mono text-xs font-medium text-foreground truncate max-w-[120px]" title={c.name}>
{c.name.replace(/^\//, "")}
</span>
</div>
<Badge variant="outline" className={`text-[10px] font-mono ${getStatusBadge(c.state)}`}>
{c.state.toUpperCase()}
</Badge>
</div>
{cs && (
<div className="grid grid-cols-2 gap-2 text-[10px] font-mono">
<div>
<span className="text-muted-foreground">CPU</span>
<Progress value={cs.cpuPct} className="h-1 mt-1" />
<span className={cs.cpuPct > 70 ? "text-neon-amber" : "text-neon-green"}>{cs.cpuPct.toFixed(1)}%</span>
</div>
<div>
<span className="text-muted-foreground">MEM</span>
<Progress value={cs.memPct} className="h-1 mt-1" />
<span className={cs.memPct > 70 ? "text-neon-amber" : "text-neon-green"}>{cs.memUseMB.toFixed(0)}MB</span>
</div>
</div>
)}
</motion.div>
);
})
) : (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground gap-2">
<XCircle className="w-5 h-5 text-neon-red" />
<span className="text-xs font-mono">
{nodesQuery.data?.error ?? "Нет данных о нодах"}
</span>
</div>
)}
</CardContent>
</Card>
{/* Agents panel */}
{/* ── Agents panel (real data) ──────────────────────────────────── */}
<Card className="xl:col-span-1 bg-card border-border/50">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Bot className="w-4 h-4 text-primary" />
Active Agents
{agentsLoading && <Loader2 className="w-3 h-3 animate-spin text-muted-foreground ml-auto" />}
{!agentsLoading && (
<span className="ml-auto text-[10px] font-mono text-muted-foreground">
{activeAgents.length} / {agents.length}
</span>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{AGENTS.map((agent) => (
<motion.div
key={agent.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className="p-3 rounded-md bg-secondary/30 border border-border/30"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${getStatusColor(agent.status).replace("text-", "bg-")} ${agent.status === "running" ? "pulse-indicator" : ""}`} />
<span className="text-xs font-medium text-foreground">{agent.name}</span>
{agentsLoading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
<span className="text-xs font-mono">Загрузка агентов...</span>
</div>
) : agents.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground gap-2">
<Bot className="w-5 h-5 text-muted-foreground" />
<span className="text-xs font-mono">Нет агентов в БД</span>
</div>
) : (
agents.slice(0, 6).map((agent) => (
<motion.div
key={agent.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className="p-3 rounded-md bg-secondary/30 border border-border/30"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${
agent.isActive ? "bg-neon-green pulse-indicator" : "bg-neon-amber"
}`} />
<span className="text-xs font-medium text-foreground truncate max-w-[110px]">
{agent.name}
</span>
</div>
<div className="flex items-center gap-1">
{agent.isSystem && (
<Badge variant="outline" className="text-[9px] font-mono bg-primary/10 text-primary border-primary/30 px-1 py-0">
SYS
</Badge>
)}
<Badge variant="outline" className={`text-[10px] font-mono ${getStatusBadge(agent.isActive ? "active" : "pause")}`}>
{agent.isActive ? "ACTIVE" : "PAUSED"}
</Badge>
</div>
</div>
<Badge variant="outline" className={`text-[10px] font-mono ${getStatusBadge(agent.status)}`}>
{agent.status.toUpperCase()}
</Badge>
</div>
<div className="flex items-center justify-between text-[10px] font-mono text-muted-foreground">
<span>Model: <span className="text-primary">{agent.model}</span></span>
<span>Tasks: <span className="text-foreground">{agent.tasks}</span></span>
</div>
</motion.div>
))}
<div className="flex items-center justify-between text-[10px] font-mono text-muted-foreground">
<span>Model: <span className="text-primary truncate max-w-[80px] inline-block align-bottom" title={agent.model}>{agent.model}</span></span>
<span className="capitalize">{agent.role}</span>
</div>
</motion.div>
))
)}
</CardContent>
</Card>
{/* Activity feed */}
{/* ── Activity feed (derived from real agents) ──────────────────── */}
<Card className="xl:col-span-1 bg-card border-border/50">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Activity className="w-4 h-4 text-primary" />
Activity Feed
<span className="ml-auto text-[10px] font-mono text-muted-foreground flex items-center gap-1">
<RefreshCw className="w-3 h-3" />
30s
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 relative">
<div className="absolute left-[7px] top-2 bottom-2 w-px bg-border/50" />
{ACTIVITY_LOG.map((entry, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
className="flex items-start gap-3 relative pl-5"
>
<div className={`absolute left-0 top-1.5 w-3.5 h-3.5 rounded-full border-2 ${
entry.type === "success" ? "border-neon-green bg-neon-green/20" :
entry.type === "warning" ? "border-neon-amber bg-neon-amber/20" :
entry.type === "error" ? "border-neon-red bg-neon-red/20" :
"border-primary bg-primary/20"
}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="font-mono text-[10px] text-muted-foreground">{entry.time}</span>
<span className="text-[11px] font-medium text-primary">{entry.agent}</span>
{agentsLoading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
<span className="text-xs font-mono">Загрузка...</span>
</div>
) : activityFeed.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground gap-2">
<Activity className="w-5 h-5 text-muted-foreground" />
<span className="text-xs font-mono">Нет активности</span>
</div>
) : (
<div className="space-y-2 relative">
<div className="absolute left-[7px] top-2 bottom-2 w-px bg-border/50" />
{activityFeed.map((entry, i) => (
<motion.div
key={entry.id}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
className="flex items-start gap-3 relative pl-5"
>
<div className={`absolute left-0 top-1.5 w-3.5 h-3.5 rounded-full border-2 ${
entry.type === "success" ? "border-neon-green bg-neon-green/20" :
entry.type === "warning" ? "border-neon-amber bg-neon-amber/20" :
entry.type === "error" ? "border-neon-red bg-neon-red/20" :
"border-primary bg-primary/20"
}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="font-mono text-[10px] text-muted-foreground">{entry.time}</span>
<span className="text-[11px] font-medium text-primary truncate">{entry.agent}</span>
</div>
<p className="text-xs text-muted-foreground truncate">{entry.action}</p>
</div>
<p className="text-xs text-muted-foreground truncate">{entry.action}</p>
</div>
</motion.div>
))}
</div>
</motion.div>
))}
</div>
)}
</CardContent>
</Card>
</div>
{/* Cluster visualization */}
{/* Cluster Topology — animated interactive visualization */}
<Card className="bg-card border-border/50 overflow-hidden">
<CardHeader className="pb-3">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Network className="w-4 h-4 text-primary" />
Cluster Topology
<span className="ml-auto text-[10px] font-mono text-muted-foreground flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-neon-green pulse-indicator" />
live
{stats && (
<span className="ml-2">&middot; Uptime: <span className="text-primary">{stats.uptime}</span></span>
)}
</span>
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="relative h-64">
<img
src={SWARM_IMG}
alt="Swarm Cluster Topology"
className="w-full h-full object-cover opacity-60"
/>
<div className="absolute inset-0 bg-gradient-to-t from-card via-transparent to-card/50" />
<div className="absolute bottom-4 left-6 right-6 flex items-center justify-between">
<div className="font-mono text-[11px] text-muted-foreground">
Overlay Network: <span className="text-primary">goclaw-net</span> &middot; Subnet: 10.0.0.0/24
</div>
<div className="flex items-center gap-4 font-mono text-[10px]">
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-neon-green" /> Manager</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-primary" /> Worker</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-neon-amber" /> Drain</span>
</div>
</div>
</div>
<CardContent className="p-2 pt-0">
<ClusterTopology />
</CardContent>
</Card>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,8 @@
/*
* Settings — API Keys, Model Providers, Gateway Configuration
* Теперь с реальным подключением к Ollama API через tRPC
* Settings — LLM Provider management (DB-backed), Gateway config, Security
*
* Providers are stored in the `llmProviders` MySQL table.
* API keys are encrypted with AES-256-GCM by the server; only hints shown here.
*/
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
@@ -10,6 +12,13 @@ import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Separator } from "@/components/ui/separator";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Key,
Globe,
@@ -26,12 +35,18 @@ import {
Shield,
Loader2,
Zap,
Trash2,
Edit2,
Star,
StarOff,
} from "lucide-react";
import { motion } from "framer-motion";
import { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useState } from "react";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
// ─── Status helpers ────────────────────────────────────────────────────────────
function getStatusIcon(status: string) {
switch (status) {
case "connected": return <CheckCircle className="w-4 h-4 text-neon-green" />;
@@ -48,33 +63,396 @@ function getStatusBadge(status: string) {
}
}
export default function Settings() {
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({});
// ─── Provider Form Modal ───────────────────────────────────────────────────────
// Реальные данные из Ollama API
const healthQuery = trpc.ollama.health.useQuery(undefined, {
refetchInterval: 30_000, // Обновлять каждые 30 секунд
});
const modelsQuery = trpc.ollama.models.useQuery(undefined, {
refetchInterval: 60_000, // Обновлять каждые 60 секунд
});
interface ProviderFormData {
name: string;
baseUrl: string;
apiKey: string;
modelDefault: string;
notes: string;
setActive: boolean;
}
const EMPTY_FORM: ProviderFormData = {
name: "",
baseUrl: "https://ollama.com/v1",
apiKey: "",
modelDefault: "",
notes: "",
setActive: false,
};
interface ProviderModalProps {
open: boolean;
editId?: number;
initial?: Partial<ProviderFormData>;
onClose: () => void;
onSaved: () => void;
}
function ProviderModal({ open, editId, initial, onClose, onSaved }: ProviderModalProps) {
const [form, setForm] = useState<ProviderFormData>({ ...EMPTY_FORM, ...initial });
const [showKey, setShowKey] = useState(false);
const isEdit = editId !== undefined;
const createMutation = trpc.providers.create.useMutation();
const updateMutation = trpc.providers.update.useMutation();
const handleSave = async () => {
try {
if (isEdit) {
await updateMutation.mutateAsync({
id: editId!,
name: form.name,
baseUrl: form.baseUrl,
apiKey: form.apiKey || undefined,
modelDefault: form.modelDefault || undefined,
notes: form.notes || undefined,
});
toast.success("Provider updated");
} else {
await createMutation.mutateAsync({
name: form.name,
baseUrl: form.baseUrl,
apiKey: form.apiKey,
modelDefault: form.modelDefault || undefined,
notes: form.notes || undefined,
setActive: form.setActive,
});
toast.success("Provider created");
}
onSaved();
onClose();
} catch (err: any) {
toast.error(`Failed: ${err.message}`);
}
};
const busy = createMutation.isPending || updateMutation.isPending;
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="bg-card border-border/50 text-foreground max-w-md">
<DialogHeader>
<DialogTitle className="font-mono text-sm">
{isEdit ? "Edit Provider" : "Add LLM Provider"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label className="text-[11px] font-mono text-muted-foreground">NAME</Label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="Ollama Cloud"
className="bg-secondary/30 border-border/30 font-mono text-xs h-8"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] font-mono text-muted-foreground">BASE URL</Label>
<Input
value={form.baseUrl}
onChange={(e) => setForm({ ...form, baseUrl: e.target.value })}
placeholder="https://ollama.com/v1"
className="bg-secondary/30 border-border/30 font-mono text-xs h-8"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] font-mono text-muted-foreground">
API KEY {isEdit && <span className="text-muted-foreground/50">(leave blank to keep current)</span>}
</Label>
<div className="relative">
<Input
type={showKey ? "text" : "password"}
value={form.apiKey}
onChange={(e) => setForm({ ...form, apiKey: e.target.value })}
placeholder={isEdit ? "••••••••" : "Enter API key"}
className="bg-secondary/30 border-border/30 font-mono text-xs h-8 pr-9"
/>
<button
onClick={() => setShowKey(!showKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showKey ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
</button>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] font-mono text-muted-foreground">DEFAULT MODEL</Label>
<Input
value={form.modelDefault}
onChange={(e) => setForm({ ...form, modelDefault: e.target.value })}
placeholder="minimax-m2.7 / gpt-4o / etc."
className="bg-secondary/30 border-border/30 font-mono text-xs h-8"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] font-mono text-muted-foreground">NOTES</Label>
<Input
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
placeholder="Optional notes..."
className="bg-secondary/30 border-border/30 font-mono text-xs h-8"
/>
</div>
{!isEdit && (
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-foreground">Set as active provider</div>
<div className="text-[10px] text-muted-foreground">Gateway will use this key immediately</div>
</div>
<Switch
checked={form.setActive}
onCheckedChange={(v) => setForm({ ...form, setActive: v })}
/>
</div>
)}
</div>
<DialogFooter className="gap-2">
<Button variant="outline" size="sm" onClick={onClose} className="text-xs border-border/50">
Cancel
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={busy || !form.name || !form.baseUrl}
className="bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25 text-xs"
>
{busy ? <Loader2 className="w-3 h-3 mr-1.5 animate-spin" /> : null}
{isEdit ? "Save Changes" : "Add Provider"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ─── Provider Card ─────────────────────────────────────────────────────────────
interface ProviderCardProps {
p: {
id: number;
name: string;
baseUrl: string;
apiKeyHint: string;
isActive: boolean;
isDefault: boolean;
modelDefault: string | null;
notes: string | null;
createdAt: Date;
updatedAt: Date;
};
ollamaStatus: string;
ollamaLatency: number;
healthLoading: boolean;
onEdit: () => void;
onDelete: () => void;
onActivate: () => void;
onTest: () => void;
}
function ProviderCard({
p, ollamaStatus, ollamaLatency, healthLoading,
onEdit, onDelete, onActivate, onTest,
}: ProviderCardProps) {
const [showKey, setShowKey] = useState(false);
return (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}>
<Card className={`bg-card border-border/50 ${p.isActive ? "ring-1 ring-primary/30" : ""}`}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{p.isActive ? (
healthLoading
? <Loader2 className="w-4 h-4 text-primary animate-spin" />
: getStatusIcon(ollamaStatus)
) : (
<AlertTriangle className="w-4 h-4 text-muted-foreground/40" />
)}
<div>
<CardTitle className="text-sm font-semibold flex items-center gap-2">
{p.name}
{p.isActive && (
<Badge variant="outline" className="text-[9px] font-mono bg-primary/10 text-primary border-primary/20">
ACTIVE
</Badge>
)}
{p.modelDefault && (
<Badge variant="outline" className="text-[9px] font-mono bg-secondary/50 text-muted-foreground border-border/30">
{p.modelDefault}
</Badge>
)}
</CardTitle>
<span className="text-[10px] font-mono text-muted-foreground truncate max-w-[240px] block">
{p.baseUrl}
</span>
</div>
</div>
<div className="flex items-center gap-2">
{p.isActive && ollamaLatency > 0 && (
<span className="text-[10px] font-mono text-muted-foreground flex items-center gap-1">
<Zap className="w-3 h-3 text-neon-amber" />
{ollamaLatency}ms
</span>
)}
{p.isActive && (
<Badge variant="outline" className={`text-[10px] font-mono ${getStatusBadge(ollamaStatus)}`}>
{ollamaStatus.toUpperCase()}
</Badge>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
{/* API Key hint */}
<div className="space-y-1.5">
<Label className="text-[11px] font-mono text-muted-foreground">API KEY</Label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Input
type={showKey ? "text" : "password"}
value={p.apiKeyHint ? `${p.apiKeyHint}${"*".repeat(24)}` : "(not set)"}
className={`bg-secondary/30 border-border/30 font-mono text-xs h-8 pr-9 ${!p.apiKeyHint ? "text-neon-amber" : ""}`}
readOnly
/>
<button
onClick={() => setShowKey(!showKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showKey ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
</button>
</div>
{!p.apiKeyHint && (
<Badge variant="outline" className="text-[10px] font-mono bg-neon-red/15 text-neon-red border-neon-red/30 whitespace-nowrap">
NO KEY
</Badge>
)}
</div>
</div>
{p.notes && (
<p className="text-[10px] font-mono text-muted-foreground/70 italic">{p.notes}</p>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-1">
{p.isActive ? (
<Button
size="sm"
variant="outline"
className="h-7 text-[10px] border-primary/30 text-primary hover:bg-primary/10"
onClick={onTest}
>
<Globe className="w-3 h-3 mr-1" />
Test
</Button>
) : (
<Button
size="sm"
variant="outline"
className="h-7 text-[10px] border-neon-green/30 text-neon-green hover:bg-neon-green/10"
onClick={onActivate}
>
<Star className="w-3 h-3 mr-1" />
Activate
</Button>
)}
<Button
size="sm"
variant="outline"
className="h-7 text-[10px] border-border/40 text-muted-foreground hover:text-foreground hover:border-primary/30"
onClick={onEdit}
>
<Edit2 className="w-3 h-3 mr-1" />
Edit
</Button>
{!p.isActive && (
<Button
size="sm"
variant="outline"
className="h-7 text-[10px] border-neon-red/30 text-neon-red hover:bg-neon-red/10 ml-auto"
onClick={onDelete}
>
<Trash2 className="w-3 h-3" />
</Button>
)}
</div>
</CardContent>
</Card>
</motion.div>
);
}
// ─── Main Settings Component ───────────────────────────────────────────────────
export default function Settings() {
const [modalOpen, setModalOpen] = useState(false);
const [editProvider, setEditProvider] = useState<{ id: number; form: Partial<ProviderFormData> } | null>(null);
// Queries
const healthQuery = trpc.ollama.health.useQuery(undefined, { refetchInterval: 30_000 });
const modelsQuery = trpc.ollama.models.useQuery(undefined, { refetchInterval: 60_000 });
const providersQuery = trpc.providers.list.useQuery(undefined, { staleTime: 5_000 });
// Mutations
const deleteMutation = trpc.providers.delete.useMutation();
const activateMutation = trpc.providers.activate.useMutation();
const utils = trpc.useUtils();
const refetchAll = () => {
utils.providers.list.invalidate();
utils.config.providers.invalidate();
healthQuery.refetch();
};
const ollamaStatus = healthQuery.data?.connected ? "connected" : healthQuery.isLoading ? "unchecked" : "error";
const ollamaLatency = healthQuery.data?.latencyMs ?? 0;
const ollamaModels = modelsQuery.data?.success ? modelsQuery.data.models : [];
const providers = providersQuery.data ?? [];
const toggleKeyVisibility = (id: string) => {
setShowKeys((prev) => ({ ...prev, [id]: !prev[id] }));
const handleDelete = async (id: number) => {
const res = await deleteMutation.mutateAsync({ id });
if (res.ok) {
toast.success("Provider deleted");
refetchAll();
} else {
toast.error(res.error ?? "Failed to delete");
}
};
const scanModels = () => {
modelsQuery.refetch();
toast.success("Сканирование моделей запущено...");
const handleActivate = async (id: number) => {
await activateMutation.mutateAsync({ id });
toast.success("Provider activated — Gateway reloaded");
refetchAll();
};
const testConnection = () => {
const handleTest = () => {
healthQuery.refetch();
toast.info("Тестирование подключения к Ollama...");
toast.info("Testing connection...");
};
const openEdit = (p: typeof providers[0]) => {
setEditProvider({
id: p.id,
form: { name: p.name, baseUrl: p.baseUrl, modelDefault: p.modelDefault ?? "", notes: p.notes ?? "" },
});
setModalOpen(true);
};
const openCreate = () => {
setEditProvider(null);
setModalOpen(true);
};
return (
@@ -82,10 +460,19 @@ export default function Settings() {
<div>
<h2 className="text-xl font-bold text-foreground">Настройки</h2>
<p className="text-sm text-muted-foreground font-mono mt-1">
Конфигурация Gateway, API-ключи и провайдеры моделей
Конфигурация Gateway, LLM-провайдеры и API-ключи
</p>
</div>
{/* Provider Add/Edit Modal */}
<ProviderModal
open={modalOpen}
editId={editProvider?.id}
initial={editProvider?.form}
onClose={() => setModalOpen(false)}
onSaved={refetchAll}
/>
<Tabs defaultValue="providers" className="space-y-4">
<TabsList className="bg-secondary/50 border border-border/50">
<TabsTrigger value="providers" className="data-[state=active]:bg-primary/15 data-[state=active]:text-primary font-mono text-xs">
@@ -106,200 +493,99 @@ export default function Settings() {
<TabsContent value="providers" className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Управление провайдерами LLM-моделей. Поддерживаются OpenAI-совместимые API.
LLM-провайдеры хранятся в БД. API-ключи зашифрованы AES-256-GCM.
</p>
<Button
size="sm"
className="bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25"
onClick={() => toast("Feature coming soon")}
onClick={openCreate}
>
<Plus className="w-3.5 h-3.5 mr-1.5" /> Добавить провайдер
</Button>
</div>
{/* === OLLAMA PROVIDER (REAL DATA) === */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
>
<Card className="bg-card border-border/50 ring-1 ring-primary/20">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{healthQuery.isLoading ? (
<Loader2 className="w-4 h-4 text-primary animate-spin" />
) : (
getStatusIcon(ollamaStatus)
)}
<div>
<CardTitle className="text-sm font-semibold flex items-center gap-2">
Ollama Cloud
<Badge variant="outline" className="text-[9px] font-mono bg-primary/10 text-primary border-primary/20">
LIVE
</Badge>
</CardTitle>
<span className="text-[10px] font-mono text-muted-foreground">OPENAI-COMPATIBLE</span>
</div>
</div>
<div className="flex items-center gap-3">
{ollamaLatency > 0 && (
<span className="text-[10px] font-mono text-muted-foreground flex items-center gap-1">
<Zap className="w-3 h-3 text-neon-amber" />
{ollamaLatency}ms
</span>
)}
<Badge variant="outline" className={`text-[10px] font-mono ${getStatusBadge(ollamaStatus)}`}>
{ollamaStatus.toUpperCase()}
</Badge>
<Switch defaultChecked />
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Base URL */}
<div className="space-y-1.5">
<Label className="text-[11px] font-mono text-muted-foreground">BASE URL</Label>
<div className="flex items-center gap-2">
<Input
value="https://ollama.com/v1"
className="bg-secondary/30 border-border/30 font-mono text-xs h-8"
readOnly
/>
<Button
size="sm"
variant="outline"
className="h-8 text-[11px] border-primary/30 text-primary hover:bg-primary/10"
onClick={testConnection}
disabled={healthQuery.isFetching}
>
{healthQuery.isFetching ? (
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
) : (
<Globe className="w-3 h-3 mr-1" />
)}
Test
</Button>
</div>
</div>
{/* API Key */}
<div className="space-y-1.5">
<Label className="text-[11px] font-mono text-muted-foreground">API KEY</Label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Input
type={showKeys["ollama"] ? "text" : "password"}
value="feaa56e2dff045af989346ca74cb33a6.xzJ-plOVSgTL1FbmL8PZZ3Wx"
className="bg-secondary/30 border-border/30 font-mono text-xs h-8 pr-10"
readOnly
/>
<button
onClick={() => toggleKeyVisibility("ollama")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showKeys["ollama"] ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
</button>
</div>
<Button
size="sm"
variant="outline"
className="h-8 text-[11px] border-border/50 text-muted-foreground hover:text-foreground"
onClick={() => toast("Feature coming soon")}
>
<Key className="w-3 h-3 mr-1" /> Edit
</Button>
</div>
</div>
<Separator className="bg-border/30" />
{/* Models — REAL DATA */}
<div>
<div className="flex items-center justify-between mb-2">
<Label className="text-[11px] font-mono text-muted-foreground">
AVAILABLE MODELS ({ollamaModels.length})
</Label>
<Button
size="sm"
variant="outline"
className="h-7 text-[10px] border-primary/30 text-primary hover:bg-primary/10"
onClick={scanModels}
disabled={modelsQuery.isFetching}
>
{modelsQuery.isFetching ? (
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
) : (
<RefreshCw className="w-3 h-3 mr-1" />
)}
Scan Models
</Button>
</div>
{modelsQuery.isLoading ? (
<div className="flex items-center gap-2 py-4">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
<span className="text-xs text-muted-foreground font-mono">Загрузка моделей...</span>
</div>
) : ollamaModels.length > 0 ? (
<div className="flex flex-wrap gap-1.5 max-h-48 overflow-y-auto">
{ollamaModels.map((model) => (
<span
key={model.id}
className="px-2 py-0.5 rounded text-[10px] font-mono bg-primary/10 text-primary border border-primary/20"
>
{model.id}
</span>
))}
</div>
) : (
<div className="text-xs text-muted-foreground font-mono py-2">
{modelsQuery.data && !modelsQuery.data.success
? `Ошибка: ${(modelsQuery.data as any).error}`
: "Нет доступных моделей"}
</div>
)}
</div>
{/* Health Error */}
{healthQuery.data && !healthQuery.data.connected && healthQuery.data.error && (
<div className="p-3 rounded-md bg-neon-red/10 border border-neon-red/20">
<div className="flex items-center gap-2">
<XCircle className="w-4 h-4 text-neon-red shrink-0" />
<span className="text-xs font-mono text-neon-red">{healthQuery.data.error}</span>
</div>
</div>
)}
</CardContent>
</Card>
</motion.div>
{/* Placeholder for other providers */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<Card className="bg-card border-border/50 opacity-60">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<AlertTriangle className="w-4 h-4 text-neon-amber" />
<div>
<CardTitle className="text-sm font-semibold">OpenAI</CardTitle>
<span className="text-[10px] font-mono text-muted-foreground">NOT CONFIGURED</span>
</div>
</div>
<Badge variant="outline" className="text-[10px] font-mono bg-neon-amber/15 text-neon-amber border-neon-amber/30">
UNCHECKED
</Badge>
</div>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">
Нажмите «Добавить провайдер» для настройки OpenAI, Anthropic или другого OpenAI-совместимого API.
{/* Provider list */}
{providersQuery.isLoading ? (
<div className="flex items-center gap-2 py-6">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
<span className="text-xs text-muted-foreground font-mono">Загрузка провайдеров...</span>
</div>
) : providers.length === 0 ? (
<Card className="bg-card border-border/50 border-dashed">
<CardContent className="py-8 text-center">
<p className="text-sm text-muted-foreground">
Нет настроенных провайдеров.{" "}
<button onClick={openCreate} className="text-primary hover:underline">
Добавьте первый
</button>{" "}
или перезапустите контейнеры провайдер из env будет добавлен автоматически.
</p>
</CardContent>
</Card>
</motion.div>
) : (
<AnimatePresence>
<div className="space-y-3">
{providers.map((p) => (
<ProviderCard
key={p.id}
p={p}
ollamaStatus={ollamaStatus}
ollamaLatency={ollamaLatency}
healthLoading={healthQuery.isLoading || healthQuery.isFetching}
onEdit={() => openEdit(p)}
onDelete={() => handleDelete(p.id)}
onActivate={() => handleActivate(p.id)}
onTest={handleTest}
/>
))}
</div>
</AnimatePresence>
)}
{/* Models section (always visible for the active provider) */}
{providers.some((p) => p.isActive) && (
<Card className="bg-card border-border/50">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Brain className="w-4 h-4 text-primary" />
Доступные модели ({ollamaModels.length})
</CardTitle>
<Button
size="sm"
variant="outline"
className="h-7 text-[10px] border-primary/30 text-primary hover:bg-primary/10"
onClick={() => { modelsQuery.refetch(); toast.success("Сканирование..."); }}
disabled={modelsQuery.isFetching}
>
{modelsQuery.isFetching
? <Loader2 className="w-3 h-3 mr-1 animate-spin" />
: <RefreshCw className="w-3 h-3 mr-1" />
}
Scan
</Button>
</div>
</CardHeader>
<CardContent>
{modelsQuery.isLoading ? (
<div className="flex items-center gap-2 py-2">
<Loader2 className="w-3 h-3 animate-spin text-primary" />
<span className="text-xs text-muted-foreground font-mono">Загрузка...</span>
</div>
) : ollamaModels.length > 0 ? (
<div className="flex flex-wrap gap-1.5 max-h-40 overflow-y-auto">
{ollamaModels.map((m) => (
<span key={m.id} className="px-2 py-0.5 rounded text-[10px] font-mono bg-primary/10 text-primary border border-primary/20">
{m.id}
</span>
))}
</div>
) : (
<p className="text-xs text-muted-foreground font-mono">Нет доступных моделей</p>
)}
</CardContent>
</Card>
)}
</TabsContent>
{/* Gateway Tab */}
@@ -313,46 +599,33 @@ export default function Settings() {
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-[11px] font-mono text-muted-foreground">GATEWAY HOST</Label>
<Input value="0.0.0.0" className="bg-secondary/30 border-border/30 font-mono text-xs h-8" readOnly />
</div>
<div className="space-y-1.5">
<Label className="text-[11px] font-mono text-muted-foreground">GATEWAY PORT</Label>
<Input value="18789" className="bg-secondary/30 border-border/30 font-mono text-xs h-8" readOnly />
</div>
<div className="space-y-1.5">
<Label className="text-[11px] font-mono text-muted-foreground">gRPC PORT</Label>
<Input value="50051" className="bg-secondary/30 border-border/30 font-mono text-xs h-8" readOnly />
</div>
<div className="space-y-1.5">
<Label className="text-[11px] font-mono text-muted-foreground">OVERLAY NETWORK</Label>
<Input value="goclaw-net" className="bg-secondary/30 border-border/30 font-mono text-xs h-8" readOnly />
</div>
{[
["GATEWAY HOST", "0.0.0.0"],
["GATEWAY PORT", "18789"],
["gRPC PORT", "50051"],
["OVERLAY NETWORK", "goclaw-net"],
].map(([label, val]) => (
<div key={label} className="space-y-1.5">
<Label className="text-[11px] font-mono text-muted-foreground">{label}</Label>
<Input value={val} className="bg-secondary/30 border-border/30 font-mono text-xs h-8" readOnly />
</div>
))}
</div>
<Separator className="bg-border/30" />
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-foreground">Auto-scaling</div>
<div className="text-[11px] text-muted-foreground">Автоматическое масштабирование агентов при нагрузке</div>
{[
["Auto-scaling", "Автоматическое масштабирование агентов при нагрузке", true],
["Health Checks", "Периодическая проверка состояния агентов", true],
["Debug Logging", "Расширенное логирование для отладки", false],
].map(([label, desc, defaultChecked]) => (
<div key={label as string} className="flex items-center justify-between">
<div>
<div className="text-sm text-foreground">{label as string}</div>
<div className="text-[11px] text-muted-foreground">{desc as string}</div>
</div>
<Switch defaultChecked={defaultChecked as boolean} />
</div>
<Switch defaultChecked />
</div>
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-foreground">Health Checks</div>
<div className="text-[11px] text-muted-foreground">Периодическая проверка состояния агентов</div>
</div>
<Switch defaultChecked />
</div>
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-foreground">Debug Logging</div>
<div className="text-[11px] text-muted-foreground">Расширенное логирование для отладки</div>
</div>
<Switch />
</div>
))}
</div>
</CardContent>
</Card>
@@ -364,11 +637,10 @@ export default function Settings() {
<CardHeader>
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Wifi className="w-4 h-4 text-primary" />
Внешние подключения (Connectors)
Внешние подключения
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Telegram */}
<div className="p-4 rounded-md bg-secondary/30 border border-border/30">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
@@ -394,8 +666,6 @@ export default function Settings() {
<Input placeholder="Введите токен Telegram бота..." className="bg-secondary/50 border-border/30 font-mono text-xs h-8" />
</div>
</div>
{/* Placeholder for more connectors */}
<Button
variant="outline"
className="w-full h-12 border-dashed border-border/50 text-muted-foreground hover:text-primary hover:border-primary/30"
@@ -417,37 +687,21 @@ export default function Settings() {
Безопасность и изоляция
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div className="flex items-center justify-between">
<CardContent className="space-y-3">
{[
["Sandbox Isolation", "Запуск кода агентов в изолированных контейнерах", true],
["Network Policy", "Ограничение сетевого доступа между агентами", true],
["Skill Auto-Approve", "Автоматическое одобрение новых скиллов (опасно)", false],
["Audit Log", "Запись всех действий агентов в журнал аудита", true],
].map(([label, desc, defaultChecked]) => (
<div key={label as string} className="flex items-center justify-between">
<div>
<div className="text-sm text-foreground">Sandbox Isolation</div>
<div className="text-[11px] text-muted-foreground">Запуск кода агентов в изолированных контейнерах</div>
<div className="text-sm text-foreground">{label as string}</div>
<div className="text-[11px] text-muted-foreground">{desc as string}</div>
</div>
<Switch defaultChecked />
<Switch defaultChecked={defaultChecked as boolean} />
</div>
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-foreground">Network Policy</div>
<div className="text-[11px] text-muted-foreground">Ограничение сетевого доступа между агентами</div>
</div>
<Switch defaultChecked />
</div>
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-foreground">Skill Auto-Approve</div>
<div className="text-[11px] text-muted-foreground">Автоматическое одобрение новых скиллов (опасно)</div>
</div>
<Switch />
</div>
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-foreground">Audit Log</div>
<div className="text-[11px] text-muted-foreground">Запись всех действий агентов в журнал аудита</div>
</div>
<Switch defaultChecked />
</div>
</div>
))}
</CardContent>
</Card>
</TabsContent>

View File

@@ -0,0 +1,441 @@
/**
* Workflows — Main page: list view, canvas constructor, and dashboard.
*
* Views:
* 1. List view — all workflows with status, stats, quick actions
* 2. Canvas view — visual drag-and-drop constructor (full screen)
* 3. Dashboard view — run monitoring for a selected workflow
*
* Design: Mission Control theme — dark bg, cyan glow, mono fonts.
*
* FIX: Canvas now renders even while loading (empty state), and syncs
* nodes/edges from the query via initialNodes/initialEdges props.
* The WorkflowCanvas component handles the async data arrival.
*/
import { useState, useEffect } from "react";
import { useRoute, useLocation } from "wouter";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
GitBranch,
Plus,
Play,
Pause,
Trash2,
Settings,
Loader2,
AlertCircle,
Activity,
Clock,
CheckCircle,
XCircle,
Eye,
Pencil,
Archive,
Zap,
BarChart2,
} from "lucide-react";
import { motion } from "framer-motion";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
import { WorkflowCreateModal } from "@/components/WorkflowCreateModal";
import WorkflowCanvas from "@/components/WorkflowCanvas";
import WorkflowDashboard from "@/components/WorkflowDashboard";
import type { WFNodeData } from "@/components/WorkflowNodeBlock";
import type { WFEdgeData } from "@/components/WorkflowCanvas";
const STATUS_STYLE: Record<string, { badge: string; dot: string }> = {
draft: { badge: "bg-muted/15 text-muted-foreground border-border", dot: "bg-muted-foreground" },
active: { badge: "bg-neon-green/15 text-neon-green border-neon-green/30", dot: "bg-neon-green pulse-indicator" },
paused: { badge: "bg-neon-amber/15 text-neon-amber border-neon-amber/30", dot: "bg-neon-amber" },
archived: { badge: "bg-muted/15 text-muted-foreground border-border", dot: "bg-muted-foreground" },
};
type ViewMode = "list" | "canvas" | "dashboard";
export default function Workflows() {
const [, params] = useRoute("/workflows/:id");
const [, navigate] = useLocation();
const [viewMode, setViewMode] = useState<ViewMode>("list");
const [selectedWorkflowId, setSelectedWorkflowId] = useState<number | null>(
params?.id ? Number(params.id) : null
);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [workflowToDelete, setWorkflowToDelete] = useState<number | null>(null);
// If URL has /workflows/:id, load that workflow
useEffect(() => {
if (params?.id) {
setSelectedWorkflowId(Number(params.id));
setViewMode("dashboard");
}
}, [params?.id]);
// List all workflows
const { data: workflows = [], isLoading, refetch } = trpc.workflows.list.useQuery(undefined, {
refetchInterval: 30_000,
});
// Get single workflow (for canvas view) — fetch as soon as we have an ID and need it
const { data: selectedWorkflow, isLoading: isLoadingWorkflow } = trpc.workflows.get.useQuery(
{ id: selectedWorkflowId! },
{ enabled: !!selectedWorkflowId && (viewMode === "canvas" || viewMode === "dashboard") }
);
// Get latest run for polling node statuses
const { data: latestRuns } = trpc.workflows.listRuns.useQuery(
{ workflowId: selectedWorkflowId!, limit: 1 },
{ enabled: !!selectedWorkflowId && viewMode === "canvas", refetchInterval: 3_000 }
);
// Mutations
const deleteMutation = trpc.workflows.delete.useMutation({
onSuccess: () => {
toast.success("Workflow deleted");
setDeleteConfirmOpen(false);
setWorkflowToDelete(null);
refetch();
},
onError: (e) => toast.error(e.message),
});
const updateMutation = trpc.workflows.update.useMutation({
onSuccess: () => {
toast.success("Workflow updated");
refetch();
},
});
const handleOpenCanvas = (id: number) => {
setSelectedWorkflowId(id);
setViewMode("canvas");
};
const handleOpenDashboard = (id: number) => {
setSelectedWorkflowId(id);
setViewMode("dashboard");
navigate(`/workflows/${id}`);
};
const handleBackToList = () => {
setViewMode("list");
setSelectedWorkflowId(null);
navigate("/workflows");
};
const handleToggleStatus = (id: number, currentStatus: string) => {
const newStatus = currentStatus === "active" ? "paused" : "active";
updateMutation.mutate({ id, status: newStatus as any });
};
// Build canvas data from server response
const canvasNodes: WFNodeData[] = (selectedWorkflow?.nodes ?? []).map((n: any) => ({
nodeKey: n.nodeKey,
label: n.label,
kind: n.kind,
agentId: n.agentId,
containerConfig: n.containerConfig,
conditionExpr: n.conditionExpr,
triggerConfig: n.triggerConfig,
posX: n.posX ?? 0,
posY: n.posY ?? 0,
meta: n.meta,
}));
const canvasEdges: WFEdgeData[] = (selectedWorkflow?.edges ?? []).map((e: any) => ({
edgeKey: e.edgeKey,
sourceNodeKey: e.sourceNodeKey,
targetNodeKey: e.targetNodeKey,
sourceHandle: e.sourceHandle,
targetHandle: e.targetHandle,
label: e.label,
meta: e.meta,
}));
// Run results for canvas overlay
const latestRun = latestRuns?.[0];
const runResults = latestRun?.status === "running" || latestRun?.status === "success" || latestRun?.status === "failed"
? (latestRun.nodeResults as any) ?? {}
: undefined;
// ─── Canvas View ──────────────────────────────────────────────────────────
// FIX: Render the canvas immediately even if data is still loading.
// WorkflowCanvas handles the async arrival of initialNodes/initialEdges via useEffect.
if (viewMode === "canvas" && selectedWorkflowId) {
// If workflow metadata is still loading, show a brief loading state then canvas
if (isLoadingWorkflow && !selectedWorkflow) {
return (
<div className="-m-6 h-[calc(100vh-3.5rem)] flex items-center justify-center bg-[#0A0E1A]">
<div className="text-center">
<Loader2 className="w-8 h-8 animate-spin text-primary mx-auto mb-3" />
<p className="text-sm font-mono text-muted-foreground">Loading workflow canvas...</p>
</div>
</div>
);
}
const wfName = selectedWorkflow?.name ?? "Workflow";
return (
<div className="-m-6 h-[calc(100vh-3.5rem)]">
<WorkflowCanvas
workflowId={selectedWorkflowId}
workflowName={wfName}
initialNodes={canvasNodes}
initialEdges={canvasEdges}
runResults={runResults}
onBack={handleBackToList}
/>
</div>
);
}
// ─── Dashboard View ───────────────────────────────────────────────────────
if (viewMode === "dashboard" && selectedWorkflowId) {
if (isLoadingWorkflow && !selectedWorkflow) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-primary mr-3" />
<span className="text-sm font-mono text-muted-foreground">Loading workflow...</span>
</div>
);
}
const wfName = selectedWorkflow?.name ?? "Workflow";
return (
<div className="space-y-6">
<Button size="sm" variant="ghost" onClick={handleBackToList} className="text-muted-foreground hover:text-foreground mb-2">
&larr; Back to Workflows
</Button>
<Tabs defaultValue="dashboard">
<TabsList className="bg-secondary/30 border border-border/30">
<TabsTrigger value="dashboard" className="data-[state=active]:bg-primary/15 data-[state=active]:text-primary">
<BarChart2 className="w-3.5 h-3.5 mr-1.5" /> Dashboard
</TabsTrigger>
<TabsTrigger value="canvas" className="data-[state=active]:bg-primary/15 data-[state=active]:text-primary">
<GitBranch className="w-3.5 h-3.5 mr-1.5" /> Canvas
</TabsTrigger>
</TabsList>
<TabsContent value="dashboard" className="mt-4">
<WorkflowDashboard
workflowId={selectedWorkflowId}
workflowName={wfName}
onOpenCanvas={() => setViewMode("canvas")}
/>
</TabsContent>
<TabsContent value="canvas" className="mt-4">
<div className="-mx-6 -mb-6 h-[calc(100vh-14rem)]">
<WorkflowCanvas
workflowId={selectedWorkflowId}
workflowName={wfName}
initialNodes={canvasNodes}
initialEdges={canvasEdges}
runResults={runResults}
onBack={() => {}}
/>
</div>
</TabsContent>
</Tabs>
</div>
);
}
// ─── List View ────────────────────────────────────────────────────────────
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-foreground">Workflows</h2>
<p className="text-sm text-muted-foreground font-mono mt-1">
{workflows.length} workflows &middot; Visual pipeline constructor
</p>
</div>
<Button
size="sm"
className="bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25"
onClick={() => setCreateModalOpen(true)}
>
<Plus className="w-4 h-4 mr-2" />
New Workflow
</Button>
</div>
{/* Loading */}
{isLoading ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
) : workflows.length === 0 ? (
/* Empty state */
<Card className="bg-card border-border/50">
<CardContent className="p-12 text-center">
<GitBranch className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-30" />
<h3 className="text-lg font-semibold text-foreground mb-2">No Workflows Yet</h3>
<p className="text-sm text-muted-foreground mb-6">
Create your first workflow to build visual agent pipelines.
</p>
<Button
onClick={() => setCreateModalOpen(true)}
className="bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25"
>
<Plus className="w-4 h-4 mr-2" />
Create First Workflow
</Button>
</CardContent>
</Card>
) : (
/* Workflow grid */
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
{workflows.map((wf: any, i: number) => {
const ss = STATUS_STYLE[wf.status] ?? STATUS_STYLE.draft;
return (
<motion.div
key={wf.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
>
<Card className="bg-card border-border/50 hover:border-primary/30 transition-all cursor-pointer group"
onClick={() => handleOpenDashboard(wf.id)}
>
<CardContent className="p-5">
{/* Top row */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2.5">
<div className="w-10 h-10 rounded-lg bg-primary/10 border border-primary/30 flex items-center justify-center">
<GitBranch className="w-5 h-5 text-primary" />
</div>
<div>
<h3 className="text-sm font-semibold text-foreground group-hover:text-primary transition-colors">
{wf.name}
</h3>
{wf.description && (
<p className="text-[11px] text-muted-foreground truncate max-w-[180px]">{wf.description}</p>
)}
</div>
</div>
<Badge variant="outline" className={`text-[10px] font-mono ${ss.badge}`}>
<span className={`w-1.5 h-1.5 rounded-full ${ss.dot} mr-1.5`} />
{wf.status.toUpperCase()}
</Badge>
</div>
{/* Tags */}
{wf.tags && wf.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{(wf.tags as string[]).map((tag: string) => (
<Badge key={tag} variant="outline" className="text-[9px] font-mono bg-secondary/30 text-muted-foreground border-border/30 px-1.5 py-0">
{tag}
</Badge>
))}
</div>
)}
{/* Dates */}
<div className="flex items-center gap-4 mb-3 text-[10px] font-mono text-muted-foreground">
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
Created: {new Date(wf.createdAt).toLocaleDateString()}
</div>
<div className="flex items-center gap-1">
<Activity className="w-3 h-3" />
Updated: {new Date(wf.updatedAt).toLocaleDateString()}
</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(); handleOpenCanvas(wf.id); }}
>
<Pencil className="w-3 h-3 mr-1" /> Edit
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-[11px] text-neon-amber border-neon-amber/30 hover:bg-neon-amber/10"
onClick={(e) => { e.stopPropagation(); handleOpenDashboard(wf.id); }}
>
<BarChart2 className="w-3 h-3 mr-1" /> Monitor
</Button>
<Button
size="sm"
variant="outline"
className={`h-7 text-[11px] ml-auto ${
wf.status === "active"
? "text-neon-amber border-neon-amber/30 hover:bg-neon-amber/10"
: "text-neon-green border-neon-green/30 hover:bg-neon-green/10"
}`}
onClick={(e) => { e.stopPropagation(); handleToggleStatus(wf.id, wf.status); }}
>
{wf.status === "active" ? <Pause className="w-3 h-3 mr-1" /> : <Play className="w-3 h-3 mr-1" />}
{wf.status === "active" ? "Pause" : "Activate"}
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-[11px] text-neon-red border-neon-red/30 hover:bg-neon-red/10"
onClick={(e) => { e.stopPropagation(); setWorkflowToDelete(wf.id); setDeleteConfirmOpen(true); }}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</CardContent>
</Card>
</motion.div>
);
})}
</div>
)}
{/* Create modal */}
<WorkflowCreateModal
open={createModalOpen}
onOpenChange={setCreateModalOpen}
onSuccess={() => refetch()}
/>
{/* Delete confirmation */}
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Workflow</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the workflow, all nodes, edges, and run history. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex gap-3">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => workflowToDelete && deleteMutation.mutate({ id: workflowToDelete })}
disabled={deleteMutation.isPending}
className="bg-neon-red hover:bg-neon-red/90"
>
{deleteMutation.isPending ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
Delete
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

53
docker/Dockerfile.agent Normal file
View File

@@ -0,0 +1,53 @@
# ── GoClaw Agent Container ────────────────────────────────────────────────────
#
# Autonomous agent microservice that:
# 1. Exposes a lightweight HTTP API (port 8080) for receiving tasks
# 2. Has access to the Swarm overlay network (goclaw-net)
# 3. Connects to the shared MySQL database for persistence
# 4. Calls the LLM API via the GoClaw Gateway
# 5. Auto-registers itself with the orchestrator on startup
#
# Build: docker build -f docker/Dockerfile.agent -t goclaw-agent:latest .
# Deploy: docker service create --name goclaw-agent-NAME \
# --network goclaw-net \
# -e AGENT_ID=NAME \
# -e GATEWAY_URL=http://goclaw-gateway:18789 \
# -e DATABASE_URL=mysql://... \
# goclaw-agent:latest
# ─────────────────────────────────────────────────────────────────────────────
# ── Stage 1: Build Go agent binary ───────────────────────────────────────────
FROM golang:1.23-alpine AS builder
WORKDIR /src
# Copy gateway module (agent reuses gateway internals)
COPY gateway/go.mod gateway/go.sum ./
RUN go mod download
COPY gateway/ ./
# Build the agent server binary
RUN go build -o /agent-server ./cmd/agent/...
# ── Stage 2: Runtime ──────────────────────────────────────────────────────────
FROM alpine:3.20
RUN apk add --no-cache ca-certificates curl wget tzdata
WORKDIR /app
COPY --from=builder /agent-server ./agent-server
# Default environment (override at deploy time)
ENV AGENT_ID=default-agent \
AGENT_PORT=8080 \
GATEWAY_URL=http://goclaw-gateway:18789 \
LLM_BASE_URL=https://ollama.com/v1 \
LLM_API_KEY="" \
DATABASE_URL="" \
IDLE_TIMEOUT_MINUTES=15
EXPOSE 8080
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:8080/health || exit 1
ENTRYPOINT ["/app/agent-server"]

View File

@@ -0,0 +1,45 @@
# ─── Stage 1: Build ────────────────────────────────────────────────────────────
# Собираем agent-worker binary из исходников gateway/
FROM golang:1.23-alpine AS builder
WORKDIR /build
# Кэшируем зависимости отдельным слоем
COPY gateway/go.mod gateway/go.sum ./
RUN go mod download
# Копируем исходники
COPY gateway/ ./
# Собираем статически линкованный бинарь
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" \
-o agent-worker \
./cmd/agent-worker
# ─── Stage 2: Runtime ──────────────────────────────────────────────────────────
# Минимальный образ: только бинарь + CA certs (для HTTPS к LLM API)
FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /build/agent-worker /app/agent-worker
# Порт HTTP API агента (переопределяется через AGENT_PORT env)
EXPOSE 8001
# ── Healthcheck ──────────────────────────────────────────────────────────────
# Docker/Swarm будет проверять /health каждые 15 секунд
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:${AGENT_PORT:-8001}/health || exit 1
# Required env vars (подставляются при деплое Swarm service):
# AGENT_ID — числовой ID агента из таблицы agents
# DATABASE_URL — mysql://user:pass@host:3306/goclaw
# LLM_BASE_URL — https://ollama.com/v1 или http://ollama:11434/v1
# LLM_API_KEY — ключ LLM провайдера
# AGENT_PORT — порт HTTP (default: 8001)
ENTRYPOINT ["/app/agent-worker"]

View File

@@ -109,8 +109,12 @@ services:
DEFAULT_MODEL: "${DEFAULT_MODEL:-qwen2.5:7b}"
DATABASE_URL: "${MYSQL_USER:-goclaw}:${MYSQL_PASSWORD:-goClawPass123}@tcp(db:3306)/${MYSQL_DATABASE:-goclaw}?parseTime=true"
PROJECT_ROOT: "/app"
GATEWAY_REQUEST_TIMEOUT_SECS: "120"
# Request timeout — must be > (MaxLLMRetries * RetryDelay * 2 + actual LLM time)
GATEWAY_REQUEST_TIMEOUT_SECS: "300"
GATEWAY_MAX_TOOL_ITERATIONS: "10"
# LLM retry policy: retry up to N times on empty response or network error
GATEWAY_MAX_LLM_RETRIES: "${GATEWAY_MAX_LLM_RETRIES:-3}"
GATEWAY_RETRY_DELAY_SECS: "${GATEWAY_RETRY_DELAY_SECS:-2}"
LOG_LEVEL: "info"
depends_on:
db:
@@ -122,8 +126,12 @@ services:
volumes:
# Mount project root for file tools (read-only)
- ..:/app:ro
# Mount Docker socket for docker_exec tool
# Mount Docker socket for docker_exec tool and Swarm management
- /var/run/docker.sock:/var/run/docker.sock
# privileged + pid:host allows nsenter to run commands on the host system
# This gives the orchestrator true shell access to the host for self-modification
privileged: true
pid: host
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:18789/health"]
interval: 15s
@@ -145,6 +153,9 @@ services:
DATABASE_URL: "mysql://${MYSQL_USER:-goclaw}:${MYSQL_PASSWORD:-goClawPass123}@db:3306/${MYSQL_DATABASE:-goclaw}"
GATEWAY_URL: "http://gateway:18789"
JWT_SECRET: "${JWT_SECRET:-change-me-in-production}"
# ── LLM Provider (same as gateway, used by Node.js tRPC proxy) ──────
OLLAMA_BASE_URL: "${LLM_BASE_URL:-${OLLAMA_BASE_URL:-https://ollama.com/v1}}"
OLLAMA_API_KEY: "${LLM_API_KEY:-${OLLAMA_API_KEY:-}}"
VITE_APP_ID: "${VITE_APP_ID:-}"
OAUTH_SERVER_URL: "${OAUTH_SERVER_URL:-}"
VITE_OAUTH_PORTAL_URL: "${VITE_OAUTH_PORTAL_URL:-}"

View File

@@ -0,0 +1,14 @@
CREATE TABLE `llmProviders` (
`id` int AUTO_INCREMENT NOT NULL,
`name` varchar(128) NOT NULL,
`baseUrl` varchar(512) NOT NULL,
`apiKeyEncrypted` text,
`apiKeyHint` varchar(16),
`isActive` boolean NOT NULL DEFAULT false,
`isDefault` boolean NOT NULL DEFAULT false,
`modelDefault` varchar(128),
`notes` text,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `llmProviders_id` PRIMARY KEY(`id`)
);

View File

@@ -0,0 +1,36 @@
-- chatSessions: one row per chat request, survives page reloads
CREATE TABLE IF NOT EXISTS chatSessions (
id INT AUTO_INCREMENT PRIMARY KEY,
sessionId VARCHAR(64) NOT NULL UNIQUE,
agentId INT NOT NULL DEFAULT 1,
status ENUM('running','done','error') NOT NULL DEFAULT 'running',
userMessage TEXT NOT NULL,
finalResponse TEXT,
model VARCHAR(128),
totalTokens INT DEFAULT 0,
processingTimeMs INT DEFAULT 0,
errorMessage TEXT,
createdAt TIMESTAMP NOT NULL DEFAULT NOW(),
updatedAt TIMESTAMP NOT NULL DEFAULT NOW() ON UPDATE NOW(),
INDEX chatSessions_status_idx (status),
INDEX chatSessions_createdAt_idx (createdAt)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- chatEvents: one row per SSE event within a session
CREATE TABLE IF NOT EXISTS chatEvents (
id INT AUTO_INCREMENT PRIMARY KEY,
sessionId VARCHAR(64) NOT NULL,
seq INT NOT NULL DEFAULT 0,
eventType ENUM('thinking','tool_call','delta','done','error') NOT NULL,
content TEXT,
toolName VARCHAR(128),
toolArgs JSON,
toolResult TEXT,
toolSuccess TINYINT(1),
durationMs INT,
model VARCHAR(128),
usageJson JSON,
errorMsg TEXT,
createdAt TIMESTAMP(3) NOT NULL DEFAULT NOW(3),
INDEX chatEvents_sessionId_seq_idx (sessionId, seq)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -0,0 +1,42 @@
-- ─── GoClaw Phase 21: Real Docker Swarm Management ────────────────────────────
-- swarmNodes: persistent record of each node in the swarm
-- Stores the advertise address, join tokens, role, labels,
-- and custom domain/port mapping so the UI can show connection info.
CREATE TABLE IF NOT EXISTS `swarmNodes` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`nodeId` VARCHAR(64) NOT NULL UNIQUE, -- Docker node ID
`hostname` VARCHAR(128) NOT NULL,
`role` ENUM('manager','worker') NOT NULL DEFAULT 'worker',
`state` ENUM('ready','down','disconnected') NOT NULL DEFAULT 'ready',
`availability` ENUM('active','pause','drain') NOT NULL DEFAULT 'active',
`advertiseAddr` VARCHAR(128), -- IP:port used by swarm
`domain` VARCHAR(256), -- optional custom domain
`sshPort` INT DEFAULT 22,
`labels` JSON,
`engineVersion` VARCHAR(64),
`cpuCores` INT DEFAULT 0,
`memTotalMB` BIGINT DEFAULT 0,
`isManager` TINYINT(1) DEFAULT 0,
`isLeader` TINYINT(1) DEFAULT 0,
`lastSeenAt` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`createdAt` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX `swarmNodes_role_idx` (`role`),
INDEX `swarmNodes_state_idx` (`state`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- swarmTokens: stores join tokens (manager + worker) for the swarm
-- Only one row (the current swarm). Updated by the Go gateway on startup.
CREATE TABLE IF NOT EXISTS `swarmTokens` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`managerToken` TEXT,
`workerToken` TEXT,
`managerAddr` VARCHAR(128), -- IP:2377 to join as manager/worker
`updatedAt` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Add serviceId + replicas columns to agents so each agent maps to a swarm service
ALTER TABLE `agents`
ADD COLUMN IF NOT EXISTS `serviceId` VARCHAR(128) DEFAULT NULL COMMENT 'Docker Swarm service ID',
ADD COLUMN IF NOT EXISTS `serviceName` VARCHAR(128) DEFAULT NULL COMMENT 'Docker Swarm service name',
ADD COLUMN IF NOT EXISTS `replicas` INT DEFAULT 1 COMMENT 'Desired replica count';

View File

@@ -0,0 +1,77 @@
-- Workflows: pipeline definitions
CREATE TABLE `workflows` (
`id` int AUTO_INCREMENT NOT NULL,
`name` varchar(255) NOT NULL,
`description` text,
`status` enum('draft','active','paused','archived') NOT NULL DEFAULT 'draft',
`canvasMeta` json DEFAULT ('{}'),
`tags` json DEFAULT ('[]'),
`createdBy` int,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `workflows_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
-- Workflow Nodes: blocks inside a workflow (agent / container / trigger / condition / output)
CREATE TABLE `workflowNodes` (
`id` int AUTO_INCREMENT NOT NULL,
`workflowId` int NOT NULL,
`nodeKey` varchar(64) NOT NULL,
`label` varchar(255) NOT NULL,
`kind` enum('agent','container','trigger','condition','output') NOT NULL,
`agentId` int,
`containerConfig` json DEFAULT ('{}'),
`conditionExpr` text,
`triggerConfig` json DEFAULT ('{}'),
`posX` int DEFAULT 0,
`posY` int DEFAULT 0,
`meta` json DEFAULT ('{}'),
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `workflowNodes_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE INDEX `workflowNodes_workflowId_idx` ON `workflowNodes` (`workflowId`);
--> statement-breakpoint
-- Workflow Edges: connections between nodes
CREATE TABLE `workflowEdges` (
`id` int AUTO_INCREMENT NOT NULL,
`workflowId` int NOT NULL,
`edgeKey` varchar(64) NOT NULL,
`sourceNodeKey` varchar(64) NOT NULL,
`targetNodeKey` varchar(64) NOT NULL,
`sourceHandle` varchar(64),
`targetHandle` varchar(64),
`label` varchar(128),
`meta` json DEFAULT ('{}'),
`createdAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `workflowEdges_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE INDEX `workflowEdges_workflowId_idx` ON `workflowEdges` (`workflowId`);
--> statement-breakpoint
-- Workflow Runs: execution history with per-node results
CREATE TABLE `workflowRuns` (
`id` int AUTO_INCREMENT NOT NULL,
`workflowId` int NOT NULL,
`runKey` varchar(64) NOT NULL,
`status` enum('pending','running','success','failed','cancelled') NOT NULL DEFAULT 'pending',
`nodeResults` json DEFAULT ('{}'),
`currentNodeKey` varchar(64),
`input` text,
`output` text,
`totalDurationMs` int,
`errorMessage` text,
`startedAt` timestamp,
`finishedAt` timestamp,
`createdAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `workflowRuns_id` PRIMARY KEY(`id`),
CONSTRAINT `workflowRuns_runKey_unique` UNIQUE(`runKey`)
);
--> statement-breakpoint
CREATE INDEX `workflowRuns_workflowId_idx` ON `workflowRuns` (`workflowId`);
--> statement-breakpoint
CREATE INDEX `workflowRuns_status_idx` ON `workflowRuns` (`status`);

View File

@@ -29,6 +29,13 @@
"when": 1774043298939,
"tag": "0003_lazy_hitman",
"breakpoints": true
},
{
"idx": 4,
"version": "5",
"when": 1774100000000,
"tag": "0004_llm_providers",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,12 @@
-- Migration: 0006_agent_container_fields
-- Add Docker Swarm container tracking fields to agents table.
-- Each agent can now be deployed as an autonomous Swarm service.
ALTER TABLE `agents`
ADD COLUMN `serviceName` VARCHAR(100) NULL COMMENT 'Docker Swarm service name: goclaw-agent-{id}',
ADD COLUMN `servicePort` INT NULL COMMENT 'HTTP API port inside overlay network (8001-8999)',
ADD COLUMN `containerImage` VARCHAR(255) NOT NULL DEFAULT 'goclaw-agent-worker:latest' COMMENT 'Docker image to run',
ADD COLUMN `containerStatus` ENUM('stopped','deploying','running','error') NOT NULL DEFAULT 'stopped' COMMENT 'Current container lifecycle state';
-- Index for quick lookup of running agents
CREATE INDEX `agents_containerStatus_idx` ON `agents` (`containerStatus`);

View File

@@ -59,7 +59,17 @@ export const agents = mysqlTable("agents", {
isPublic: boolean("isPublic").default(false),
isSystem: boolean("isSystem").default(false), // Системный агент (нельзя удалить)
isOrchestrator: boolean("isOrchestrator").default(false), // Главный оркестратор чата
// ── Container / Swarm fields ──────────────────────────────────────────────
// Имя Docker Swarm service: "goclaw-agent-{id}"
serviceName: varchar("serviceName", { length: 100 }),
// Порт HTTP API агента внутри overlay сети (80018999)
servicePort: int("servicePort"),
// Docker image для запуска агента
containerImage: varchar("containerImage", { length: 255 }).default("goclaw-agent-worker:latest"),
// Статус контейнера (обновляется при деплое/остановке)
containerStatus: mysqlEnum("containerStatus", ["stopped", "deploying", "running", "error"]).default("stopped"),
// Метаданные
tags: json("tags").$type<string[]>().default([]),
metadata: json("metadata").$type<Record<string, any>>().default({}),
@@ -201,3 +211,242 @@ export const browserSessions = mysqlTable("browserSessions", {
export type BrowserSession = typeof browserSessions.$inferSelect;
export type InsertBrowserSession = typeof browserSessions.$inferInsert;
/**
* LLM Providers — хранение конфигурации подключений к LLM API.
* API-ключи хранятся в зашифрованном виде (AES-256-GCM через crypto.ts).
* Активный провайдер читается gateway при каждом запросе.
*/
export const llmProviders = mysqlTable("llmProviders", {
id: int("id").autoincrement().primaryKey(),
name: varchar("name", { length: 128 }).notNull(), // "Ollama Cloud", "OpenAI", etc.
baseUrl: varchar("baseUrl", { length: 512 }).notNull(), // https://ollama.com/v1
apiKeyEncrypted: text("apiKeyEncrypted"), // AES-256-GCM encrypted key
apiKeyHint: varchar("apiKeyHint", { length: 16 }), // First 8 chars for display
isActive: boolean("isActive").default(false).notNull(), // Only one can be active
isDefault: boolean("isDefault").default(false).notNull(), // Default provider for new agents
modelDefault: varchar("modelDefault", { length: 128 }), // Default model for this provider
notes: text("notes"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type LlmProvider = typeof llmProviders.$inferSelect;
export type InsertLlmProvider = typeof llmProviders.$inferInsert;
/**
* Chat Sessions — persistent server-side chat runs.
* Each user message creates one session. The Go gateway processes it
* and writes events to chatEvents. The frontend polls for events.
*/
export const chatSessions = mysqlTable("chatSessions", {
id: int("id").autoincrement().primaryKey(),
sessionId: varchar("sessionId", { length: 64 }).notNull().unique(),
agentId: int("agentId").notNull().default(1),
status: mysqlEnum("status", ["running", "done", "error"]).notNull().default("running"),
userMessage: text("userMessage").notNull(),
finalResponse: text("finalResponse"),
model: varchar("model", { length: 128 }),
totalTokens: int("totalTokens").default(0),
processingTimeMs: int("processingTimeMs").default(0),
errorMessage: text("errorMessage"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
statusIdx: index("chatSessions_status_idx").on(table.status),
createdAtIdx: index("chatSessions_createdAt_idx").on(table.createdAt),
}));
export type ChatSession = typeof chatSessions.$inferSelect;
export type InsertChatSession = typeof chatSessions.$inferInsert;
/**
* Chat Events — individual SSE events written by Go gateway, read by frontend.
*/
export const chatEvents = mysqlTable("chatEvents", {
id: int("id").autoincrement().primaryKey(),
sessionId: varchar("sessionId", { length: 64 }).notNull(),
seq: int("seq").notNull().default(0),
eventType: mysqlEnum("eventType", ["thinking", "tool_call", "delta", "done", "error"]).notNull(),
content: text("content"),
toolName: varchar("toolName", { length: 128 }),
toolArgs: json("toolArgs"),
toolResult: text("toolResult"),
toolSuccess: boolean("toolSuccess"),
durationMs: int("durationMs"),
model: varchar("model", { length: 128 }),
usageJson: json("usageJson"),
errorMsg: text("errorMsg"),
createdAt: timestamp("createdAt", { fsp: 3 }).defaultNow().notNull(),
}, (table) => ({
sessionSeqIdx: index("chatEvents_sessionId_seq_idx").on(table.sessionId, table.seq),
}));
export type ChatEvent = typeof chatEvents.$inferSelect;
export type InsertChatEvent = typeof chatEvents.$inferInsert;
// ─── Workflows ────────────────────────────────────────────────────────────────
/**
* Workflows — visual pipeline definitions composed of agent/container nodes.
* Each workflow is a directed graph stored as nodes + edges.
*/
export const workflows = mysqlTable("workflows", {
id: int("id").autoincrement().primaryKey(),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
/** Visual status used in the list/dashboard */
status: mysqlEnum("status", ["draft", "active", "paused", "archived"]).default("draft").notNull(),
/** JSON blob of canvas-level metadata: viewport position, zoom, layout hints */
canvasMeta: json("canvasMeta").$type<{ viewportX?: number; viewportY?: number; zoom?: number }>().default({}),
tags: json("tags").$type<string[]>().default([]),
createdBy: int("createdBy"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type Workflow = typeof workflows.$inferSelect;
export type InsertWorkflow = typeof workflows.$inferInsert;
/**
* Workflow Nodes — individual blocks inside a workflow.
* Each node references either an agent (agentId) or an arbitrary container config.
*/
export const workflowNodes = mysqlTable("workflowNodes", {
id: int("id").autoincrement().primaryKey(),
workflowId: int("workflowId").notNull(),
/** Unique client-side ID used by the canvas (e.g. "node_abc123") */
nodeKey: varchar("nodeKey", { length: 64 }).notNull(),
label: varchar("label", { length: 255 }).notNull(),
/** Node kind: agent = uses an existing agent; container = custom Docker image; trigger = entry point; output = terminal */
kind: mysqlEnum("kind", ["agent", "container", "trigger", "condition", "output"]).notNull(),
/** Link to agents table (nullable — only for kind=agent) */
agentId: int("agentId"),
/** For kind=container: Docker image, env vars, ports etc. */
containerConfig: json("containerConfig").$type<{
image?: string;
env?: string[];
ports?: string[];
command?: string;
volumes?: string[];
}>().default({}),
/** For kind=condition: JS expression evaluated at runtime */
conditionExpr: text("conditionExpr"),
/** Trigger config: cron, webhook, manual */
triggerConfig: json("triggerConfig").$type<{ type?: string; cron?: string; webhookPath?: string }>().default({}),
/** Canvas position */
posX: int("posX").default(0),
posY: int("posY").default(0),
/** Extra metadata (colour, icon override, etc.) */
meta: json("meta").$type<Record<string, any>>().default({}),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
workflowIdIdx: index("workflowNodes_workflowId_idx").on(table.workflowId),
}));
export type WorkflowNode = typeof workflowNodes.$inferSelect;
export type InsertWorkflowNode = typeof workflowNodes.$inferInsert;
/**
* Workflow Edges — connections between nodes.
*/
export const workflowEdges = mysqlTable("workflowEdges", {
id: int("id").autoincrement().primaryKey(),
workflowId: int("workflowId").notNull(),
/** Edge identifier on the canvas */
edgeKey: varchar("edgeKey", { length: 64 }).notNull(),
sourceNodeKey: varchar("sourceNodeKey", { length: 64 }).notNull(),
targetNodeKey: varchar("targetNodeKey", { length: 64 }).notNull(),
/** Optional: which output handle → which input handle */
sourceHandle: varchar("sourceHandle", { length: 64 }),
targetHandle: varchar("targetHandle", { length: 64 }),
/** Edge label (e.g. "on success", "on fail") */
label: varchar("label", { length: 128 }),
/** Visual styling */
meta: json("meta").$type<Record<string, any>>().default({}),
createdAt: timestamp("createdAt").defaultNow().notNull(),
}, (table) => ({
workflowIdIdx: index("workflowEdges_workflowId_idx").on(table.workflowId),
}));
export type WorkflowEdge = typeof workflowEdges.$inferSelect;
export type InsertWorkflowEdge = typeof workflowEdges.$inferInsert;
/**
* Workflow Runs — execution history. Each run tracks overall status and
* per-node results so the dashboard can show progress in real-time.
*/
export const workflowRuns = mysqlTable("workflowRuns", {
id: int("id").autoincrement().primaryKey(),
workflowId: int("workflowId").notNull(),
runKey: varchar("runKey", { length: 64 }).notNull().unique(),
status: mysqlEnum("status", ["pending", "running", "success", "failed", "cancelled"]).default("pending").notNull(),
/** Per-node execution results: { [nodeKey]: { status, output, durationMs, error? } } */
nodeResults: json("nodeResults").$type<Record<string, {
status: "pending" | "running" | "success" | "failed" | "skipped";
output?: string;
durationMs?: number;
error?: string;
startedAt?: string;
finishedAt?: string;
}>>().default({}),
/** The node currently being executed */
currentNodeKey: varchar("currentNodeKey", { length: 64 }),
/** Global input passed to the first node */
input: text("input"),
/** Final aggregated output */
output: text("output"),
totalDurationMs: int("totalDurationMs"),
errorMessage: text("errorMessage"),
startedAt: timestamp("startedAt"),
finishedAt: timestamp("finishedAt"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
}, (table) => ({
workflowIdIdx: index("workflowRuns_workflowId_idx").on(table.workflowId),
statusIdx: index("workflowRuns_status_idx").on(table.status),
}));
export type WorkflowRun = typeof workflowRuns.$inferSelect;
export type InsertWorkflowRun = typeof workflowRuns.$inferInsert;
// ─── TaskBoard ───────────────────────────────────────────────────────────────
/**
* ChatTasks — persistent task board linked to chat sessions.
* Both the orchestrator and agents can create/update tasks.
* Subtasks are stored as JSON array inside the parent task row.
*/
export const chatTasks = mysqlTable("chatTasks", {
id: int("id").autoincrement().primaryKey(),
taskId: varchar("taskId", { length: 32 }).notNull().unique(),
sessionId: varchar("sessionId", { length: 64 }), // linked chat session
content: text("content").notNull(),
status: mysqlEnum("status", ["pending", "in_progress", "completed", "failed", "blocked"]).default("pending").notNull(),
priority: mysqlEnum("priority", ["critical", "high", "medium", "low"]).default("medium").notNull(),
createdBy: varchar("createdBy", { length: 128 }).default("user"),
assignedTo: varchar("assignedTo", { length: 128 }),
/** Subtasks stored as JSON array */
subtasks: json("subtasks").$type<Array<{
id: string;
content: string;
status: string;
createdBy: string;
createdAt: number;
completedAt?: number;
}>>().default([]),
elapsedMs: int("elapsedMs").default(0),
retryCount: int("retryCount").default(0),
lastError: text("lastError"),
testedAt: timestamp("testedAt"),
startedAt: timestamp("startedAt"),
completedAt: timestamp("completedAt"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
sessionIdx: index("chatTasks_sessionId_idx").on(table.sessionId),
statusIdx: index("chatTasks_status_idx").on(table.status),
}));
export type ChatTask = typeof chatTasks.$inferSelect;
export type InsertChatTask = typeof chatTasks.$inferInsert;

View File

@@ -0,0 +1,727 @@
// GoClaw Agent Worker — автономный HTTP-сервер агента.
//
// Каждый агент запускается как отдельный Docker Swarm service.
// Загружает свой конфиг из общей DB по AGENT_ID, выполняет LLM loop
// и принимает параллельные задачи от Orchestrator и других агентов.
//
// Endpoints:
//
// GET /health — liveness probe
// GET /info — конфиг агента (имя, модель, роль)
// POST /chat — синхронный чат (LLM loop, ждёт ответ)
// POST /task — поставить задачу в очередь (async, возвращает task_id)
// GET /tasks — список задач агента (active + recent)
// GET /tasks/{id} — статус конкретной задачи
// GET /memory — последние N сообщений из истории агента
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"sync"
"syscall"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/google/uuid"
"github.com/joho/godotenv"
"git.softuniq.eu/UniqAI/GoClaw/gateway/internal/db"
"git.softuniq.eu/UniqAI/GoClaw/gateway/internal/llm"
"git.softuniq.eu/UniqAI/GoClaw/gateway/internal/tools"
)
// ─── Task types ──────────────────────────────────────────────────────────────
type TaskStatus string
const (
TaskPending TaskStatus = "pending"
TaskRunning TaskStatus = "running"
TaskDone TaskStatus = "done"
TaskFailed TaskStatus = "failed"
TaskCancelled TaskStatus = "cancelled"
)
// Task — единица работы агента, принятая через /task.
type Task struct {
ID string `json:"id"`
FromAgentID int `json:"from_agent_id,omitempty"` // кто делегировал (0 = человек)
Input string `json:"input"` // текст задачи
CallbackURL string `json:"callback_url,omitempty"` // куда POST результат
Priority int `json:"priority"` // 0=normal, 1=high
TimeoutSecs int `json:"timeout_secs"`
Status TaskStatus `json:"status"`
Result string `json:"result,omitempty"`
Error string `json:"error,omitempty"`
ToolCalls []ToolCallStep `json:"tool_calls,omitempty"`
CreatedAt time.Time `json:"created_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
DoneAt *time.Time `json:"done_at,omitempty"`
}
// ToolCallStep — шаг вызова инструмента для отображения в UI.
type ToolCallStep struct {
Tool string `json:"tool"`
Args any `json:"args"`
Result any `json:"result,omitempty"`
Error string `json:"error,omitempty"`
Success bool `json:"success"`
DurationMs int64 `json:"duration_ms"`
}
// ChatMessage — сообщение в формате для /chat endpoint.
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
// ChatRequest — запрос на /chat (синхронный).
type ChatRequest struct {
Messages []ChatMessage `json:"messages"`
Model string `json:"model,omitempty"` // override модели агента
MaxIter int `json:"max_iter,omitempty"` // override max iterations
}
// ChatResponse — ответ /chat.
type ChatResponse struct {
Success bool `json:"success"`
Response string `json:"response"`
ToolCalls []ToolCallStep `json:"tool_calls"`
Model string `json:"model"`
Error string `json:"error,omitempty"`
}
// TaskRequest — запрос на /task (async).
type TaskRequest struct {
Input string `json:"input"`
FromAgentID int `json:"from_agent_id,omitempty"`
CallbackURL string `json:"callback_url,omitempty"`
Priority int `json:"priority,omitempty"`
TimeoutSecs int `json:"timeout_secs,omitempty"`
}
// ─── Agent Worker ─────────────────────────────────────────────────────────────
type AgentWorker struct {
agentID int
cfg *db.AgentConfig
llm *llm.Client
database *db.DB
executor *tools.Executor
// Task queue — buffered channel
taskQueue chan *Task
// Task store — in-memory (id → Task)
tasksMu sync.RWMutex
tasks map[string]*Task
// Recent tasks ring buffer (для GET /tasks)
recentMu sync.Mutex
recentKeys []string
}
const (
taskQueueDepth = 100
maxRecentTasks = 50
defaultMaxIter = 8
defaultTimeout = 120
workerGoroutines = 4 // параллельных воркеров на агента
)
func newAgentWorker(agentID int, database *db.DB, llmClient *llm.Client) (*AgentWorker, error) {
cfg, err := database.GetAgentByID(agentID)
if err != nil {
return nil, fmt.Errorf("agent %d not found in DB: %w", agentID, err)
}
log.Printf("[AgentWorker] Loaded config: id=%d name=%q model=%s", cfg.ID, cfg.Name, cfg.Model)
w := &AgentWorker{
agentID: agentID,
cfg: cfg,
llm: llmClient,
database: database,
taskQueue: make(chan *Task, taskQueueDepth),
tasks: make(map[string]*Task),
}
// Tool executor: агент использует подмножество инструментов из allowedTools
w.executor = tools.NewExecutor("/app", func() ([]map[string]any, error) {
rows, err := database.ListAgents()
if err != nil {
return nil, err
}
result := make([]map[string]any, len(rows))
for i, r := range rows {
result[i] = map[string]any{
"id": r.ID, "name": r.Name, "role": r.Role,
"model": r.Model, "isActive": r.IsActive,
}
}
return result, nil
})
return w, nil
}
// StartWorkers запускает N горутин-воркеров, читающих из taskQueue.
func (w *AgentWorker) StartWorkers(ctx context.Context) {
for i := 0; i < workerGoroutines; i++ {
go w.runWorker(ctx, i)
}
log.Printf("[AgentWorker] %d worker goroutines started", workerGoroutines)
}
func (w *AgentWorker) runWorker(ctx context.Context, workerID int) {
for {
select {
case <-ctx.Done():
log.Printf("[Worker-%d] shutting down", workerID)
return
case task := <-w.taskQueue:
log.Printf("[Worker-%d] processing task %s", workerID, task.ID)
w.processTask(ctx, task)
}
}
}
// EnqueueTask добавляет задачу в очередь и в хранилище.
func (w *AgentWorker) EnqueueTask(req TaskRequest) *Task {
timeout := req.TimeoutSecs
if timeout <= 0 {
timeout = defaultTimeout
}
task := &Task{
ID: uuid.New().String(),
FromAgentID: req.FromAgentID,
Input: req.Input,
CallbackURL: req.CallbackURL,
Priority: req.Priority,
TimeoutSecs: timeout,
Status: TaskPending,
CreatedAt: time.Now(),
}
// Сохранить в store
w.tasksMu.Lock()
w.tasks[task.ID] = task
w.tasksMu.Unlock()
// Добавить в recent ring
w.recentMu.Lock()
w.recentKeys = append(w.recentKeys, task.ID)
if len(w.recentKeys) > maxRecentTasks {
w.recentKeys = w.recentKeys[len(w.recentKeys)-maxRecentTasks:]
}
w.recentMu.Unlock()
// Отправить в очередь (non-blocking — если очередь полна, вернуть ошибку через Status)
select {
case w.taskQueue <- task:
default:
w.tasksMu.Lock()
task.Status = TaskFailed
task.Error = "task queue is full — agent is overloaded"
w.tasksMu.Unlock()
log.Printf("[AgentWorker] WARN: task queue full, task %s rejected", task.ID)
}
return task
}
// processTask выполняет задачу через LLM loop и обновляет её статус.
func (w *AgentWorker) processTask(ctx context.Context, task *Task) {
now := time.Now()
w.tasksMu.Lock()
task.Status = TaskRunning
task.StartedAt = &now
w.tasksMu.Unlock()
// Выполняем чат
chatCtx, cancel := context.WithTimeout(ctx, time.Duration(task.TimeoutSecs)*time.Second)
defer cancel()
messages := []ChatMessage{{Role: "user", Content: task.Input}}
resp := w.runChat(chatCtx, messages, "", defaultMaxIter)
doneAt := time.Now()
w.tasksMu.Lock()
task.DoneAt = &doneAt
task.ToolCalls = resp.ToolCalls
if resp.Success {
task.Status = TaskDone
task.Result = resp.Response
} else {
task.Status = TaskFailed
task.Error = resp.Error
}
w.tasksMu.Unlock()
log.Printf("[AgentWorker] task %s done: status=%s", task.ID, task.Status)
// Отправить результат на callback URL если задан
if task.CallbackURL != "" {
go w.postCallback(task)
}
// Сохранить в DB history
if w.database != nil {
go func() {
userMsg := task.Input
agentResp := task.Result
if task.Status == TaskFailed {
agentResp = "[ERROR] " + task.Error
}
w.database.SaveHistory(db.HistoryInput{
AgentID: w.agentID,
UserMessage: userMsg,
AgentResponse: agentResp,
})
}()
}
}
// runChat — основной LLM loop агента.
func (w *AgentWorker) runChat(ctx context.Context, messages []ChatMessage, overrideModel string, maxIter int) ChatResponse {
model := w.cfg.Model
if overrideModel != "" {
model = overrideModel
}
if maxIter <= 0 {
maxIter = defaultMaxIter
}
// Собрать контекст: системный промпт + история + новые сообщения
conv := []llm.Message{}
if w.cfg.SystemPrompt != "" {
conv = append(conv, llm.Message{Role: "system", Content: w.cfg.SystemPrompt})
}
// Загрузить sliding window памяти из DB
if w.database != nil {
history, err := w.database.GetAgentHistory(w.agentID, 20)
if err == nil {
for _, h := range history {
conv = append(conv, llm.Message{Role: "user", Content: h.UserMessage})
if h.AgentResponse != "" {
conv = append(conv, llm.Message{Role: "assistant", Content: h.AgentResponse})
}
}
}
}
// Добавить текущие сообщения
for _, m := range messages {
conv = append(conv, llm.Message{Role: m.Role, Content: m.Content})
}
// Получить доступные инструменты агента
agentTools := w.getAgentTools()
temp := w.cfg.Temperature
maxTok := w.cfg.MaxTokens
if maxTok == 0 {
maxTok = 4096
}
var toolCallSteps []ToolCallStep
var finalResponse string
var lastModel string
for iter := 0; iter < maxIter; iter++ {
req := llm.ChatRequest{
Model: model,
Messages: conv,
Temperature: &temp,
MaxTokens: &maxTok,
}
if len(agentTools) > 0 {
req.Tools = agentTools
req.ToolChoice = "auto"
}
resp, err := w.llm.Chat(ctx, req)
if err != nil {
// Fallback без инструментов
req.Tools = nil
req.ToolChoice = ""
resp2, err2 := w.llm.Chat(ctx, req)
if err2 != nil {
return ChatResponse{
Success: false,
Error: fmt.Sprintf("LLM error (model: %s): %v", model, err2),
}
}
if len(resp2.Choices) > 0 {
finalResponse = resp2.Choices[0].Message.Content
lastModel = resp2.Model
}
break
}
if len(resp.Choices) == 0 {
break
}
choice := resp.Choices[0]
lastModel = resp.Model
if lastModel == "" {
lastModel = model
}
// Инструменты?
if choice.FinishReason == "tool_calls" && len(choice.Message.ToolCalls) > 0 {
conv = append(conv, choice.Message)
for _, tc := range choice.Message.ToolCalls {
start := time.Now()
result := w.executor.Execute(ctx, tc.Function.Name, tc.Function.Arguments)
step := ToolCallStep{
Tool: tc.Function.Name,
Success: result.Success,
DurationMs: time.Since(start).Milliseconds(),
}
var argsMap any
_ = json.Unmarshal([]byte(tc.Function.Arguments), &argsMap)
step.Args = argsMap
var toolContent string
if result.Success {
step.Result = result.Result
b, _ := json.Marshal(result.Result)
toolContent = string(b)
} else {
step.Error = result.Error
toolContent = fmt.Sprintf(`{"error": %q}`, result.Error)
}
toolCallSteps = append(toolCallSteps, step)
conv = append(conv, llm.Message{
Role: "tool",
Content: toolContent,
ToolCallID: tc.ID,
Name: tc.Function.Name,
})
}
continue
}
finalResponse = choice.Message.Content
break
}
return ChatResponse{
Success: true,
Response: finalResponse,
ToolCalls: toolCallSteps,
Model: lastModel,
}
}
// getAgentTools возвращает только те инструменты, которые разрешены агенту.
func (w *AgentWorker) getAgentTools() []llm.Tool {
allTools := tools.OrchestratorTools()
allowed := make(map[string]bool, len(w.cfg.AllowedTools))
for _, t := range w.cfg.AllowedTools {
allowed[t] = true
}
// Если allowedTools пуст — агент получает базовый набор (http_request, file_read)
if len(allowed) == 0 {
allowed = map[string]bool{
"http_request": true,
"file_read": true,
"file_list": true,
}
}
var result []llm.Tool
for _, td := range allTools {
if allowed[td.Function.Name] {
result = append(result, llm.Tool{
Type: td.Type,
Function: llm.ToolFunction{
Name: td.Function.Name,
Description: td.Function.Description,
Parameters: td.Function.Parameters,
},
})
}
}
return result
}
// postCallback отправляет результат задачи на callback URL.
func (w *AgentWorker) postCallback(task *Task) {
w.tasksMu.RLock()
payload, _ := json.Marshal(task)
w.tasksMu.RUnlock()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, task.CallbackURL,
bytes.NewReader(payload))
if err != nil {
log.Printf("[AgentWorker] callback URL invalid for task %s: %v", task.ID, err)
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Printf("[AgentWorker] callback failed for task %s: %v", task.ID, err)
return
}
resp.Body.Close()
log.Printf("[AgentWorker] callback sent for task %s → %s (status %d)",
task.ID, task.CallbackURL, resp.StatusCode)
}
// ─── HTTP Handlers ────────────────────────────────────────────────────────────
func (w *AgentWorker) handleHealth(rw http.ResponseWriter, r *http.Request) {
json.NewEncoder(rw).Encode(map[string]any{
"status": "ok",
"agentId": w.agentID,
"name": w.cfg.Name,
"model": w.cfg.Model,
"queueLen": len(w.taskQueue),
})
}
func (w *AgentWorker) handleInfo(rw http.ResponseWriter, r *http.Request) {
json.NewEncoder(rw).Encode(map[string]any{
"id": w.cfg.ID,
"name": w.cfg.Name,
"role": w.cfg.Model,
"model": w.cfg.Model,
"allowedTools": w.cfg.AllowedTools,
"isSystem": w.cfg.IsSystem,
})
}
func (w *AgentWorker) handleChat(rw http.ResponseWriter, r *http.Request) {
var req ChatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(rw, `{"error":"invalid request body"}`, http.StatusBadRequest)
return
}
if len(req.Messages) == 0 {
http.Error(rw, `{"error":"messages required"}`, http.StatusBadRequest)
return
}
timeout := w.cfg.MaxTokens / 10 // грубая оценка
if timeout < 30 {
timeout = 30
}
if timeout > 300 {
timeout = 300
}
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(timeout)*time.Second)
defer cancel()
resp := w.runChat(ctx, req.Messages, req.Model, req.MaxIter)
rw.Header().Set("Content-Type", "application/json")
json.NewEncoder(rw).Encode(resp)
}
func (w *AgentWorker) handleTask(rw http.ResponseWriter, r *http.Request) {
var req TaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(rw, `{"error":"invalid request body"}`, http.StatusBadRequest)
return
}
if req.Input == "" {
http.Error(rw, `{"error":"input required"}`, http.StatusBadRequest)
return
}
task := w.EnqueueTask(req)
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusAccepted)
json.NewEncoder(rw).Encode(map[string]any{
"task_id": task.ID,
"status": task.Status,
"agent_id": w.agentID,
"queue_len": len(w.taskQueue),
})
}
func (w *AgentWorker) handleListTasks(rw http.ResponseWriter, r *http.Request) {
w.recentMu.Lock()
keys := make([]string, len(w.recentKeys))
copy(keys, w.recentKeys)
w.recentMu.Unlock()
w.tasksMu.RLock()
result := make([]*Task, 0, len(keys))
for i := len(keys) - 1; i >= 0; i-- {
if t, ok := w.tasks[keys[i]]; ok {
result = append(result, t)
}
}
w.tasksMu.RUnlock()
rw.Header().Set("Content-Type", "application/json")
json.NewEncoder(rw).Encode(map[string]any{
"tasks": result,
"total": len(result),
"queueLen": len(w.taskQueue),
})
}
func (w *AgentWorker) handleGetTask(rw http.ResponseWriter, r *http.Request) {
taskID := chi.URLParam(r, "id")
w.tasksMu.RLock()
task, ok := w.tasks[taskID]
w.tasksMu.RUnlock()
if !ok {
http.Error(rw, `{"error":"task not found"}`, http.StatusNotFound)
return
}
rw.Header().Set("Content-Type", "application/json")
json.NewEncoder(rw).Encode(task)
}
func (w *AgentWorker) handleMemory(rw http.ResponseWriter, r *http.Request) {
limitStr := r.URL.Query().Get("limit")
limit := 20
if n, err := strconv.Atoi(limitStr); err == nil && n > 0 && n <= 100 {
limit = n
}
if w.database == nil {
rw.Header().Set("Content-Type", "application/json")
json.NewEncoder(rw).Encode(map[string]any{"messages": []any{}, "total": 0})
return
}
history, err := w.database.GetAgentHistory(w.agentID, limit)
if err != nil {
http.Error(rw, `{"error":"failed to load history"}`, http.StatusInternalServerError)
return
}
rw.Header().Set("Content-Type", "application/json")
json.NewEncoder(rw).Encode(map[string]any{
"agent_id": w.agentID,
"messages": history,
"total": len(history),
})
}
// ─── Main ─────────────────────────────────────────────────────────────────────
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
_ = godotenv.Load("../.env")
_ = godotenv.Load(".env")
// ── Конфиг из env ────────────────────────────────────────────────────────
agentIDStr := os.Getenv("AGENT_ID")
if agentIDStr == "" {
log.Fatal("[AgentWorker] AGENT_ID env var is required")
}
agentID, err := strconv.Atoi(agentIDStr)
if err != nil || agentID <= 0 {
log.Fatalf("[AgentWorker] AGENT_ID must be a positive integer, got: %q", agentIDStr)
}
port := os.Getenv("AGENT_PORT")
if port == "" {
port = "8001"
}
llmBaseURL := getEnvFirst("LLM_BASE_URL", "OLLAMA_BASE_URL")
if llmBaseURL == "" {
llmBaseURL = "https://ollama.com/v1"
}
llmAPIKey := getEnvFirst("LLM_API_KEY", "OLLAMA_API_KEY")
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
log.Fatal("[AgentWorker] DATABASE_URL env var is required")
}
log.Printf("[AgentWorker] Starting: AGENT_ID=%d PORT=%s LLM=%s", agentID, port, llmBaseURL)
// ── DB ───────────────────────────────────────────────────────────────────
database, err := db.Connect(dbURL)
if err != nil {
log.Fatalf("[AgentWorker] DB connection failed: %v", err)
}
defer database.Close()
// ── LLM Client ───────────────────────────────────────────────────────────
llmClient := llm.NewClient(llmBaseURL, llmAPIKey)
// ── Agent Worker ─────────────────────────────────────────────────────────
worker, err := newAgentWorker(agentID, database, llmClient)
if err != nil {
log.Fatalf("[AgentWorker] init failed: %v", err)
}
// ── Background workers ───────────────────────────────────────────────────
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
worker.StartWorkers(ctx)
// ── Router ───────────────────────────────────────────────────────────────
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "OPTIONS"},
AllowedHeaders: []string{"Content-Type", "Authorization", "X-Agent-ID"},
}))
r.Get("/health", worker.handleHealth)
r.Get("/info", worker.handleInfo)
r.Post("/chat", worker.handleChat)
r.Post("/task", worker.handleTask)
r.Get("/tasks", worker.handleListTasks)
r.Get("/tasks/{id}", worker.handleGetTask)
r.Get("/memory", worker.handleMemory)
// ── HTTP Server ───────────────────────────────────────────────────────────
srv := &http.Server{
Addr: ":" + port,
Handler: r,
ReadTimeout: 30 * time.Second,
WriteTimeout: 310 * time.Second, // > max task timeout
IdleTimeout: 120 * time.Second,
}
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
log.Printf("[AgentWorker] agent-id=%d listening on :%s", agentID, port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("[AgentWorker] server error: %v", err)
}
}()
<-quit
log.Println("[AgentWorker] shutting down gracefully...")
cancel() // stop task workers
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer shutdownCancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("[AgentWorker] shutdown error: %v", err)
}
log.Println("[AgentWorker] stopped.")
}
func getEnvFirst(keys ...string) string {
for _, k := range keys {
if v := os.Getenv(k); v != "" {
return v
}
}
return ""
}

View File

@@ -0,0 +1,438 @@
package main
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.softuniq.eu/UniqAI/GoClaw/gateway/internal/db"
)
// ─── Mock DB agent config ─────────────────────────────────────────────────────
func mockAgentConfig() *db.AgentConfig {
return &db.AgentConfig{
ID: 42,
Name: "Test Agent",
Model: "qwen2.5:7b",
SystemPrompt: "You are a test agent.",
AllowedTools: []string{"http_request", "file_list"},
Temperature: 0.7,
MaxTokens: 2048,
IsSystem: false,
IsOrchestrator: false,
IsActive: true,
ContainerImage: "goclaw-agent-worker:latest",
ContainerStatus: "running",
ServiceName: "goclaw-agent-42",
ServicePort: 8001,
}
}
// ─── Unit: AgentWorker struct ─────────────────────────────────────────────────
func TestAgentWorkerInit(t *testing.T) {
w := &AgentWorker{
agentID: 42,
cfg: mockAgentConfig(),
taskQueue: make(chan *Task, taskQueueDepth),
tasks: make(map[string]*Task),
}
if w.agentID != 42 {
t.Errorf("expected agentID=42, got %d", w.agentID)
}
if w.cfg.Name != "Test Agent" {
t.Errorf("expected name 'Test Agent', got %q", w.cfg.Name)
}
}
// ─── Unit: Task enqueue ───────────────────────────────────────────────────────
func TestEnqueueTask(t *testing.T) {
w := &AgentWorker{
agentID: 42,
cfg: mockAgentConfig(),
taskQueue: make(chan *Task, taskQueueDepth),
tasks: make(map[string]*Task),
}
task := w.EnqueueTask(TaskRequest{
Input: "hello world",
TimeoutSecs: 30,
})
if task.ID == "" {
t.Error("task ID should not be empty")
}
if task.Status != TaskPending {
t.Errorf("expected status=pending, got %q", task.Status)
}
if task.Input != "hello world" {
t.Errorf("expected input='hello world', got %q", task.Input)
}
if len(w.taskQueue) != 1 {
t.Errorf("expected 1 task in queue, got %d", len(w.taskQueue))
}
// Task should be in store
w.tasksMu.RLock()
stored, ok := w.tasks[task.ID]
w.tasksMu.RUnlock()
if !ok {
t.Error("task not found in store")
}
if stored.ID != task.ID {
t.Errorf("stored task ID mismatch: %q != %q", stored.ID, task.ID)
}
}
func TestEnqueueTask_QueueFull(t *testing.T) {
// Queue depth = 1 for this test
w := &AgentWorker{
agentID: 42,
cfg: mockAgentConfig(),
taskQueue: make(chan *Task, 1),
tasks: make(map[string]*Task),
}
// Fill the queue
w.EnqueueTask(TaskRequest{Input: "task 1"})
// Overflow
task2 := w.EnqueueTask(TaskRequest{Input: "task 2"})
w.tasksMu.RLock()
status := task2.Status
w.tasksMu.RUnlock()
if status != TaskFailed {
t.Errorf("expected task2 status=failed when queue full, got %q", status)
}
}
func TestEnqueueTask_DefaultTimeout(t *testing.T) {
w := &AgentWorker{
agentID: 42,
cfg: mockAgentConfig(),
taskQueue: make(chan *Task, taskQueueDepth),
tasks: make(map[string]*Task),
}
task := w.EnqueueTask(TaskRequest{Input: "no timeout set"})
if task.TimeoutSecs != defaultTimeout {
t.Errorf("expected default timeout=%d, got %d", defaultTimeout, task.TimeoutSecs)
}
}
// ─── HTTP Handlers ────────────────────────────────────────────────────────────
func makeTestWorker() *AgentWorker {
return &AgentWorker{
agentID: 42,
cfg: mockAgentConfig(),
taskQueue: make(chan *Task, taskQueueDepth),
tasks: make(map[string]*Task),
}
}
func TestHandleHealth(t *testing.T) {
w := makeTestWorker()
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w.handleHealth(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var body map[string]any
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
t.Fatalf("invalid JSON response: %v", err)
}
if body["status"] != "ok" {
t.Errorf("expected status=ok, got %v", body["status"])
}
if int(body["agentId"].(float64)) != 42 {
t.Errorf("expected agentId=42, got %v", body["agentId"])
}
}
func TestHandleInfo(t *testing.T) {
w := makeTestWorker()
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/info", nil)
w.handleInfo(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var body map[string]any
json.NewDecoder(rr.Body).Decode(&body)
if body["name"] != "Test Agent" {
t.Errorf("expected name='Test Agent', got %v", body["name"])
}
}
func TestHandleTask_Valid(t *testing.T) {
w := makeTestWorker()
body := `{"input":"do something useful","timeout_secs":60}`
req := httptest.NewRequest(http.MethodPost, "/task", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
w.handleTask(rr, req)
if rr.Code != http.StatusAccepted {
t.Errorf("expected 202, got %d", rr.Code)
}
var resp map[string]any
json.NewDecoder(rr.Body).Decode(&resp)
if resp["task_id"] == "" || resp["task_id"] == nil {
t.Error("task_id should be in response")
}
if resp["status"] != string(TaskPending) {
t.Errorf("expected status=pending, got %v", resp["status"])
}
}
func TestHandleTask_EmptyInput(t *testing.T) {
w := makeTestWorker()
req := httptest.NewRequest(http.MethodPost, "/task", bytes.NewBufferString(`{"input":""}`))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
w.handleTask(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", rr.Code)
}
}
func TestHandleTask_InvalidJSON(t *testing.T) {
w := makeTestWorker()
req := httptest.NewRequest(http.MethodPost, "/task", bytes.NewBufferString(`not-json`))
rr := httptest.NewRecorder()
w.handleTask(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", rr.Code)
}
}
func TestHandleGetTask_NotFound(t *testing.T) {
// We can't easily use chi.URLParam in unit tests without a full router.
// Test the store logic directly instead.
w := makeTestWorker()
w.tasksMu.RLock()
_, ok := w.tasks["nonexistent-id"]
w.tasksMu.RUnlock()
if ok {
t.Error("nonexistent task should not be found")
}
}
func TestHandleListTasks_Empty(t *testing.T) {
w := makeTestWorker()
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/tasks", nil)
w.handleListTasks(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var resp map[string]any
json.NewDecoder(rr.Body).Decode(&resp)
if resp["total"].(float64) != 0 {
t.Errorf("expected total=0, got %v", resp["total"])
}
}
func TestHandleListTasks_WithTasks(t *testing.T) {
w := makeTestWorker()
w.EnqueueTask(TaskRequest{Input: "task A"})
w.EnqueueTask(TaskRequest{Input: "task B"})
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/tasks", nil)
w.handleListTasks(rr, req)
var resp map[string]any
json.NewDecoder(rr.Body).Decode(&resp)
if int(resp["total"].(float64)) != 2 {
t.Errorf("expected total=2, got %v", resp["total"])
}
}
func TestHandleMemory_NoDB(t *testing.T) {
w := makeTestWorker() // no database set
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/memory", nil)
w.handleMemory(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var resp map[string]any
json.NewDecoder(rr.Body).Decode(&resp)
if int(resp["total"].(float64)) != 0 {
t.Errorf("expected total=0 without DB, got %v", resp["total"])
}
}
// ─── Unit: getAgentTools ──────────────────────────────────────────────────────
func TestGetAgentTools_WithAllowedTools(t *testing.T) {
w := makeTestWorker()
agentTools := w.getAgentTools()
// Worker has allowedTools = ["http_request", "file_list"]
if len(agentTools) == 0 {
t.Error("expected some tools, got none")
}
names := make(map[string]bool)
for _, t := range agentTools {
names[t.Function.Name] = true
}
if !names["http_request"] {
t.Error("expected http_request in allowed tools")
}
if !names["file_list"] {
t.Error("expected file_list in allowed tools")
}
// shell_exec should NOT be allowed
if names["shell_exec"] {
t.Error("shell_exec should NOT be in allowed tools for this agent")
}
}
func TestGetAgentTools_EmptyAllowedTools_UsesDefaults(t *testing.T) {
cfg := mockAgentConfig()
cfg.AllowedTools = []string{} // empty
w := &AgentWorker{agentID: 1, cfg: cfg, taskQueue: make(chan *Task, 1), tasks: map[string]*Task{}}
tools := w.getAgentTools()
if len(tools) == 0 {
t.Error("expected default tools when allowedTools is empty")
}
}
// ─── Unit: recent task ring ───────────────────────────────────────────────────
func TestRecentRing_MaxCapacity(t *testing.T) {
w := makeTestWorker()
// Enqueue more than maxRecentTasks
for i := 0; i < maxRecentTasks+10; i++ {
// Don't block — drain queue
w.EnqueueTask(TaskRequest{Input: "task"})
select {
case <-w.taskQueue:
default:
}
}
w.recentMu.Lock()
count := len(w.recentKeys)
w.recentMu.Unlock()
if count > maxRecentTasks {
t.Errorf("recent ring should not exceed %d, got %d", maxRecentTasks, count)
}
}
// ─── Unit: Task lifecycle ─────────────────────────────────────────────────────
func TestTaskLifecycle_Timestamps(t *testing.T) {
w := makeTestWorker()
before := time.Now()
task := w.EnqueueTask(TaskRequest{Input: "lifecycle test"})
after := time.Now()
if task.CreatedAt.Before(before) || task.CreatedAt.After(after) {
t.Errorf("CreatedAt=%v should be between %v and %v", task.CreatedAt, before, after)
}
if task.StartedAt != nil {
t.Error("StartedAt should be nil for pending task")
}
if task.DoneAt != nil {
t.Error("DoneAt should be nil for pending task")
}
}
// ─── Unit: HTTP Chat handler (no LLM) ────────────────────────────────────────
func TestHandleChat_InvalidJSON(t *testing.T) {
w := makeTestWorker()
req := httptest.NewRequest(http.MethodPost, "/chat", bytes.NewBufferString(`not-json`))
rr := httptest.NewRecorder()
w.handleChat(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", rr.Code)
}
}
func TestHandleChat_EmptyMessages(t *testing.T) {
w := makeTestWorker()
req := httptest.NewRequest(http.MethodPost, "/chat",
bytes.NewBufferString(`{"messages":[]}`))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
w.handleChat(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400 for empty messages, got %d", rr.Code)
}
}
// ─── Integration: worker goroutine processes task ─────────────────────────────
func TestWorkerProcessesTask_WithMockLLM(t *testing.T) {
// Create a mock LLM server that returns a simple response
mockLLM := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{
"choices": []map[string]any{
{
"message": map[string]string{"role": "assistant", "content": "Mock answer"},
"finish_reason": "stop",
},
},
"model": "mock-model",
})
}))
defer mockLLM.Close()
// We can't easily create a full AgentWorker with llm client without more refactoring,
// so we test the task state machine directly
w := makeTestWorker()
task := w.EnqueueTask(TaskRequest{Input: "test task", TimeoutSecs: 5})
if task.Status != TaskPending {
t.Errorf("expected pending, got %s", task.Status)
}
// Simulate task processing (without LLM)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
now := time.Now()
w.tasksMu.Lock()
task.Status = TaskRunning
task.StartedAt = &now
w.tasksMu.Unlock()
// Simulate done
doneAt := time.Now()
w.tasksMu.Lock()
task.Status = TaskDone
task.Result = "completed"
task.DoneAt = &doneAt
w.tasksMu.Unlock()
_ = ctx
w.tasksMu.RLock()
finalStatus := task.Status
w.tasksMu.RUnlock()
if finalStatus != TaskDone {
t.Errorf("expected task done, got %s", finalStatus)
}
}

270
gateway/cmd/agent/main.go Normal file
View File

@@ -0,0 +1,270 @@
// GoClaw Agent Server — autonomous agent microservice
//
// Each agent runs as an independent container in the Docker Swarm overlay
// network. It exposes an HTTP API that the GoClaw Orchestrator can reach
// via the Swarm DNS name (e.g. http://goclaw-agent-researcher:8080).
//
// The agent:
// - Receives task requests from the orchestrator
// - Calls the LLM via the centrally-managed GoClaw Gateway
// - Reads/writes shared state in the MySQL database
// - Reports its last-activity time so the SwarmManager can auto-stop it
// - Gracefully shuts down after IdleTimeout with no requests
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
)
// ─── Config ──────────────────────────────────────────────────────────────────
type AgentConfig struct {
AgentID string
Port string
GatewayURL string
LLMURL string
LLMAPIKey string
DatabaseURL string
IdleTimeoutMinutes int
}
func loadConfig() AgentConfig {
idleMin := 15
if v := os.Getenv("IDLE_TIMEOUT_MINUTES"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
idleMin = n
}
}
port := os.Getenv("AGENT_PORT")
if port == "" {
port = "8080"
}
return AgentConfig{
AgentID: getEnv("AGENT_ID", "unnamed-agent"),
Port: port,
GatewayURL: getEnv("GATEWAY_URL", "http://goclaw-gateway:18789"),
LLMURL: getEnv("LLM_BASE_URL", "https://ollama.com/v1"),
LLMAPIKey: os.Getenv("LLM_API_KEY"),
DatabaseURL: os.Getenv("DATABASE_URL"),
IdleTimeoutMinutes: idleMin,
}
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// ─── State ───────────────────────────────────────────────────────────────────
type Agent struct {
cfg AgentConfig
lastActivity time.Time
httpClient *http.Client
}
func NewAgent(cfg AgentConfig) *Agent {
return &Agent{
cfg: cfg,
lastActivity: time.Now(),
httpClient: &http.Client{Timeout: 120 * time.Second},
}
}
func (a *Agent) touch() {
a.lastActivity = time.Now()
}
// ─── HTTP handlers ────────────────────────────────────────────────────────────
// GET /health — liveness probe
func (a *Agent) handleHealth(w http.ResponseWriter, r *http.Request) {
respond(w, 200, map[string]any{
"ok": true,
"agentId": a.cfg.AgentID,
"lastActivity": a.lastActivity.Format(time.RFC3339),
"idleMinutes": time.Since(a.lastActivity).Minutes(),
})
}
// POST /task — receive a task from the orchestrator
// Body: { "sessionId": "abc", "messages": [...], "model": "qwen2.5:7b", "maxIter": 5 }
func (a *Agent) handleTask(w http.ResponseWriter, r *http.Request) {
a.touch()
var body struct {
SessionID string `json:"sessionId"`
Messages json.RawMessage `json:"messages"`
Model string `json:"model"`
MaxIter int `json:"maxIter"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
respondError(w, 400, "invalid request: "+err.Error())
return
}
// Forward the task to the GoClaw Gateway orchestrator
gatewayURL := a.cfg.GatewayURL + "/api/orchestrator/chat"
reqBody, _ := json.Marshal(map[string]any{
"messages": body.Messages,
"model": body.Model,
"maxIter": body.MaxIter,
})
req, err := http.NewRequestWithContext(r.Context(), "POST", gatewayURL, strings.NewReader(string(reqBody)))
if err != nil {
respondError(w, 500, "request build error: "+err.Error())
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := a.httpClient.Do(req)
if err != nil {
respondError(w, 502, "gateway error: "+err.Error())
return
}
defer resp.Body.Close()
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
respondError(w, 502, "gateway response error: "+err.Error())
return
}
a.touch()
respond(w, 200, map[string]any{
"ok": true,
"agentId": a.cfg.AgentID,
"sessionId": body.SessionID,
"result": result,
})
}
// GET /info — agent metadata
func (a *Agent) handleInfo(w http.ResponseWriter, r *http.Request) {
hostname, _ := os.Hostname()
respond(w, 200, map[string]any{
"agentId": a.cfg.AgentID,
"hostname": hostname,
"gatewayUrl": a.cfg.GatewayURL,
"idleTimeout": a.cfg.IdleTimeoutMinutes,
"lastActivity": a.lastActivity.Format(time.RFC3339),
"idleMinutes": time.Since(a.lastActivity).Minutes(),
})
}
// ─── Idle watchdog ────────────────────────────────────────────────────────────
func (a *Agent) runIdleWatchdog(cancel context.CancelFunc) {
threshold := time.Duration(a.cfg.IdleTimeoutMinutes) * time.Minute
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
idle := time.Since(a.lastActivity)
if idle >= threshold {
log.Printf("[Agent %s] Idle for %.1f min — requesting self-stop via gateway",
a.cfg.AgentID, idle.Minutes())
a.selfStop()
cancel()
return
}
}
}
// selfStop asks the GoClaw Gateway to scale this service to 0.
func (a *Agent) selfStop() {
url := fmt.Sprintf("%s/api/swarm/agents/%s/stop", a.cfg.GatewayURL, a.cfg.AgentID)
req, err := http.NewRequest("POST", url, nil)
if err != nil {
log.Printf("[Agent %s] selfStop error building request: %v", a.cfg.AgentID, err)
return
}
resp, err := a.httpClient.Do(req)
if err != nil {
log.Printf("[Agent %s] selfStop error: %v", a.cfg.AgentID, err)
return
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
log.Printf("[Agent %s] selfStop response %d: %s", a.cfg.AgentID, resp.StatusCode, string(body))
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
func respond(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func respondError(w http.ResponseWriter, status int, msg string) {
respond(w, status, map[string]any{"error": msg})
}
// ─── Main ─────────────────────────────────────────────────────────────────────
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
cfg := loadConfig()
agent := NewAgent(cfg)
log.Printf("[Agent] %s starting on port %s (idle timeout: %d min)",
cfg.AgentID, cfg.Port, cfg.IdleTimeoutMinutes)
log.Printf("[Agent] Gateway: %s", cfg.GatewayURL)
// ── HTTP server ──────────────────────────────────────────────────────────
mux := http.NewServeMux()
mux.HandleFunc("GET /health", agent.handleHealth)
mux.HandleFunc("POST /task", agent.handleTask)
mux.HandleFunc("GET /info", agent.handleInfo)
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 150 * time.Second,
IdleTimeout: 120 * time.Second,
}
ctx, cancel := context.WithCancel(context.Background())
// ── Idle watchdog ────────────────────────────────────────────────────────
go agent.runIdleWatchdog(cancel)
// ── Graceful shutdown ────────────────────────────────────────────────────
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
log.Printf("[Agent %s] Listening on :%s", cfg.AgentID, cfg.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("[Agent %s] Server error: %v", cfg.AgentID, err)
}
}()
select {
case <-quit:
log.Printf("[Agent %s] Signal received — shutting down", cfg.AgentID)
case <-ctx.Done():
log.Printf("[Agent %s] Context cancelled — shutting down", cfg.AgentID)
}
shutCtx, shutCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutCancel()
if err := srv.Shutdown(shutCtx); err != nil {
log.Printf("[Agent %s] Shutdown error: %v", cfg.AgentID, err)
}
log.Printf("[Agent %s] Stopped.", cfg.AgentID)
}

View File

@@ -47,10 +47,35 @@ func main() {
// ── Orchestrator ─────────────────────────────────────────────────────────
orch := orchestrator.New(llmClient, database, cfg.ProjectRoot)
// Apply retry policy from config
orch.SetRetryPolicy(orchestrator.RetryPolicy{
MaxLLMRetries: cfg.MaxLLMRetries,
InitialDelay: time.Duration(cfg.RetryDelaySecs) * time.Second,
MaxDelay: 30 * time.Second,
RetryOnEmpty: true,
})
log.Printf("[Gateway] LLM retry policy: maxRetries=%d, initialDelay=%ds", cfg.MaxLLMRetries, cfg.RetryDelaySecs)
// ── HTTP Handlers ────────────────────────────────────────────────────────
h := api.NewHandler(cfg, llmClient, orch, database)
// ── Sync Swarm tokens to DB on startup ──────────────────────────────────
go func() {
time.Sleep(3 * time.Second) // wait for Docker daemon readiness
if database != nil {
dockerCl := h.GetDockerClient()
if tokens, err := dockerCl.GetJoinTokens(); err == nil {
addr := dockerCl.GetManagerAddr()
database.UpsertSwarmTokens(
tokens.JoinTokens.Worker,
tokens.JoinTokens.Manager,
addr,
)
log.Printf("[Gateway] Swarm tokens synced to DB. Manager addr: %s", addr)
}
}
}()
// ── Router ───────────────────────────────────────────────────────────────
r := chi.NewRouter()
@@ -76,6 +101,7 @@ func main() {
r.Route("/api", func(r chi.Router) {
// Orchestrator
r.Post("/orchestrator/chat", h.OrchestratorChat)
r.Post("/orchestrator/stream", h.OrchestratorStream)
r.Get("/orchestrator/config", h.OrchestratorConfig)
// Agents
@@ -92,8 +118,41 @@ func main() {
// Nodes / Docker Swarm monitoring
r.Get("/nodes", h.ListNodes)
r.Get("/nodes/stats", h.NodeStats)
// Provider config reload (called by Node.js after provider change)
r.Post("/providers/reload", h.ProvidersReload)
// Persistent chat sessions (background processing, DB-backed)
r.Post("/chat/session", h.StartChatSession)
r.Get("/chat/sessions", h.ListChatSessions)
r.Get("/chat/session/{id}", h.GetChatSession)
r.Get("/chat/session/{id}/events", h.GetChatEvents)
// ── Real Docker Swarm Management ─────────────────────────────────────
r.Get("/swarm/info", h.SwarmInfo)
r.Get("/swarm/nodes", h.SwarmNodes)
r.Post("/swarm/nodes/{id}/label", h.SwarmAddNodeLabel)
r.Post("/swarm/nodes/{id}/availability", h.SwarmSetNodeAvailability)
r.Get("/swarm/services", h.SwarmServices)
r.Post("/swarm/services/create", h.SwarmCreateService)
r.Delete("/swarm/services/{id}", h.SwarmRemoveService)
r.Get("/swarm/services/{id}/tasks", h.SwarmServiceTasks)
r.Post("/swarm/services/{id}/scale", h.SwarmScaleService)
r.Get("/swarm/join-token", h.SwarmJoinToken)
r.Post("/swarm/join-node", h.SwarmJoinNodeViaSSH)
r.Post("/swarm/ssh-test", h.SwarmSSHTest)
r.Post("/swarm/shell", h.SwarmShell)
r.Get("/swarm/agents", h.SwarmListAgents)
r.Post("/swarm/agents/{name}/start", h.SwarmStartAgent)
r.Post("/swarm/agents/{name}/stop", h.SwarmStopAgent)
})
// ── Swarm Manager: auto-stop idle agents after 15 min ────────────────────
swarmMgr := api.NewSwarmManager(h, 60*time.Second)
managerCtx, managerCancel := context.WithCancel(context.Background())
go swarmMgr.Start(managerCtx)
defer managerCancel()
// ── Start Server ─────────────────────────────────────────────────────────
srv := &http.Server{
Addr: ":" + cfg.Port,

View File

@@ -46,6 +46,12 @@ type Config struct {
DefaultModel string
MaxToolIterations int
RequestTimeoutSecs int
// LLM retry policy
// GATEWAY_MAX_LLM_RETRIES — additional attempts after a failure/empty response (default 3).
MaxLLMRetries int
// GATEWAY_RETRY_DELAY_SECS — initial delay before first retry in seconds (default 2).
RetryDelaySecs int
}
func Load() *Config {
@@ -55,6 +61,8 @@ func Load() *Config {
maxIter, _ := strconv.Atoi(getEnv("GATEWAY_MAX_TOOL_ITERATIONS", "10"))
timeout, _ := strconv.Atoi(getEnv("GATEWAY_REQUEST_TIMEOUT_SECS", "120"))
maxLLMRetries, _ := strconv.Atoi(getEnv("GATEWAY_MAX_LLM_RETRIES", "3"))
retryDelaySecs, _ := strconv.Atoi(getEnv("GATEWAY_RETRY_DELAY_SECS", "2"))
// Resolve LLM base URL — priority: LLM_BASE_URL > OLLAMA_BASE_URL > default cloud
rawLLMURL := getEnvFirst(
@@ -82,6 +90,8 @@ func Load() *Config {
DefaultModel: getEnv("DEFAULT_MODEL", "qwen2.5:7b"),
MaxToolIterations: maxIter,
RequestTimeoutSecs: timeout,
MaxLLMRetries: maxLLMRetries,
RetryDelaySecs: retryDelaySecs,
}
if cfg.LLMAPIKey == "" {

View File

@@ -3,10 +3,15 @@ module git.softuniq.eu/UniqAI/GoClaw/gateway
go 1.23.4
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/go-chi/chi/v5 v5.2.1 // indirect
github.com/go-chi/cors v1.2.1 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/jmoiron/sqlx v1.4.0 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/go-chi/chi/v5 v5.2.1
github.com/go-chi/cors v1.2.1
github.com/go-sql-driver/mysql v1.8.1
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.37.0
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
golang.org/x/sys v0.32.0 // indirect
)

View File

@@ -6,9 +6,13 @@ github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,142 @@
// Package api Swarm Agent Lifecycle Manager
//
// The SwarmManager runs as a background goroutine inside the GoClaw Gateway
// (which is the Swarm manager node). It watches all agent services and
// automatically scales them to 0 replicas after IdleTimeout minutes of no
// activity. The orchestrator can call StartAgent / StopAgent via the REST API
// to start/stop agents on demand.
//
// Start flow: POST /api/swarm/agents/{name}/start → scale to N replicas (default 1)
// Stop flow: POST /api/swarm/agents/{name}/stop → scale to 0
// Auto-stop: background loop checks every 60 s, scales idle agents to 0
package api
import (
"context"
"encoding/json"
"log"
"net/http"
"time"
)
const (
// IdleTimeout how many minutes without any task updates before an agent
// is automatically scaled to 0.
defaultIdleTimeoutMinutes = 15
)
// SwarmManager watches agent services and auto-scales them down after idle.
type SwarmManager struct {
handler *Handler
ticker *time.Ticker
done chan struct{}
}
// NewSwarmManager creates a manager that checks every checkInterval.
func NewSwarmManager(h *Handler, checkInterval time.Duration) *SwarmManager {
return &SwarmManager{
handler: h,
ticker: time.NewTicker(checkInterval),
done: make(chan struct{}),
}
}
// Start launches the background loop. Call in a goroutine.
func (m *SwarmManager) Start(ctx context.Context) {
log.Printf("[SwarmManager] Started — idle timeout %d min, check every %s",
defaultIdleTimeoutMinutes, m.ticker)
defer m.ticker.Stop()
for {
select {
case <-m.done:
return
case <-ctx.Done():
return
case <-m.ticker.C:
m.checkIdleAgents()
}
}
}
// Stop signals the background loop to exit.
func (m *SwarmManager) Stop() {
close(m.done)
}
func (m *SwarmManager) checkIdleAgents() {
services, err := m.handler.docker.ListServices()
if err != nil {
log.Printf("[SwarmManager] list services error: %v", err)
return
}
idleThreshold := time.Duration(defaultIdleTimeoutMinutes) * time.Minute
now := time.Now()
for _, svc := range services {
// Only manage services labelled as GoClaw agents
if svc.Spec.Labels["goclaw.agent"] != "true" {
continue
}
// Skip already-stopped services (0 desired replicas)
desired := 0
if svc.Spec.Mode.Replicated != nil {
desired = svc.Spec.Mode.Replicated.Replicas
}
if desired == 0 {
continue
}
// Check last activity time
lastActivity, err := m.handler.docker.GetServiceLastActivity(svc.ID)
if err != nil || lastActivity.IsZero() {
lastActivity = svc.UpdatedAt
}
idle := now.Sub(lastActivity)
if idle >= idleThreshold {
log.Printf("[SwarmManager] Agent '%s' idle for %.1f min → scaling to 0",
svc.Spec.Name, idle.Minutes())
if err := m.handler.docker.ScaleService(svc.ID, 0); err != nil {
log.Printf("[SwarmManager] scale-to-0 error for %s: %v", svc.Spec.Name, err)
}
}
}
}
// ─── HTTP Handlers for agent lifecycle ────────────────────────────────────────
// POST /api/swarm/agents/{name}/start
// Start (scale-up) a named agent service. Body: { "replicas": 1 }
func (h *Handler) SwarmStartAgent(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if name == "" {
respondError(w, http.StatusBadRequest, "agent name required")
return
}
var body struct {
Replicas int `json:"replicas"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
if body.Replicas <= 0 {
body.Replicas = 1
}
if err := h.docker.ScaleService(name, body.Replicas); err != nil {
respondError(w, http.StatusInternalServerError, "start agent: "+err.Error())
return
}
log.Printf("[Swarm] Agent '%s' started with %d replica(s)", name, body.Replicas)
respond(w, http.StatusOK, map[string]any{"ok": true, "name": name, "replicas": body.Replicas})
}
// POST /api/swarm/agents/{name}/stop
// Stop (scale-to-0) a named agent service.
func (h *Handler) SwarmStopAgent(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if name == "" {
respondError(w, http.StatusBadRequest, "agent name required")
return
}
if err := h.docker.ScaleService(name, 0); err != nil {
respondError(w, http.StatusInternalServerError, "stop agent: "+err.Error())
return
}
log.Printf("[Swarm] Agent '%s' stopped (scaled to 0)", name)
respond(w, http.StatusOK, map[string]any{"ok": true, "name": name, "replicas": 0})
}

View File

@@ -3,6 +3,7 @@ package db
import (
"database/sql"
"database/sql/driver"
"encoding/json"
"fmt"
"log"
@@ -20,9 +21,14 @@ type AgentConfig struct {
AllowedTools []string
Temperature float64
MaxTokens int
IsOrchestrator bool
IsSystem bool
IsActive bool
IsOrchestrator bool
IsSystem bool
IsActive bool
// Container / Swarm fields (Phase A)
ServiceName string
ServicePort int
ContainerImage string
ContainerStatus string // "stopped" | "deploying" | "running" | "error"
}
// AgentRow is a minimal agent representation for listing.
@@ -114,6 +120,484 @@ func (d *DB) ListAgents() ([]AgentRow, error) {
return agents, nil
}
// ─── LLM Provider ─────────────────────────────────────────────────────────────
// ProviderRow holds the active LLM provider config from DB.
type ProviderRow struct {
ID int
Name string
BaseURL string
APIKey string // decrypted (Node.js encrypts, Go just reads raw for now)
}
// GetActiveProvider returns the active LLM provider from the llmProviders table.
// Note: The API key is stored AES-256-GCM encrypted by the Node.js server.
// The Go gateway reads the raw encrypted bytes but cannot decrypt them (no shared key in Go).
// The proper flow: Node.js decrypts the key and passes it via /api/providers/reload.
// For now, GetActiveProvider returns the stored encrypted bytes as-is (not useful for direct use).
// Use UpdateCredentials on the LLM client instead.
func (d *DB) GetActiveProvider() (*ProviderRow, error) {
var p ProviderRow
var apiKeyEncrypted sql.NullString
row := d.conn.QueryRow(`
SELECT id, name, baseUrl, COALESCE(apiKeyEncrypted, '')
FROM llmProviders
WHERE isActive = 1
LIMIT 1
`)
err := row.Scan(&p.ID, &p.Name, &p.BaseURL, &apiKeyEncrypted)
if err != nil {
return nil, err
}
// We cannot decrypt the key in Go (different crypto impl from Node.js)
// Return empty key — the LLM client will use its env-configured key
p.APIKey = ""
return &p, nil
}
// ─── Chat Sessions & Events ───────────────────────────────────────────────────
// ChatSessionRow holds one persistent chat session.
type ChatSessionRow struct {
ID int `json:"id"`
SessionID string `json:"sessionId"`
AgentID int `json:"agentId"`
Status string `json:"status"` // running | done | error
UserMessage string `json:"userMessage"`
FinalResponse string `json:"finalResponse"`
Model string `json:"model"`
TotalTokens int `json:"totalTokens"`
ProcessingTimeMs int64 `json:"processingTimeMs"`
ErrorMessage string `json:"errorMessage"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// ChatEventRow holds one event inside a session.
type ChatEventRow struct {
ID int `json:"id"`
SessionID string `json:"sessionId"`
Seq int `json:"seq"`
EventType string `json:"eventType"` // thinking | tool_call | delta | done | error
Content string `json:"content"`
ToolName string `json:"toolName"`
ToolArgs string `json:"toolArgs"` // JSON string
ToolResult string `json:"toolResult"`
ToolSuccess bool `json:"toolSuccess"`
DurationMs int `json:"durationMs"`
Model string `json:"model"`
UsageJSON string `json:"usageJson"` // JSON string
ErrorMsg string `json:"errorMsg"`
CreatedAt string `json:"createdAt"`
}
// CreateSession inserts a new running session and returns its row.
func (d *DB) CreateSession(sessionID, userMessage string, agentID int) error {
if d.conn == nil {
return fmt.Errorf("DB not connected")
}
_, err := d.conn.Exec(`
INSERT INTO chatSessions (sessionId, agentId, status, userMessage)
VALUES (?, ?, 'running', ?)
`, sessionID, agentID, truncate(userMessage, 65535))
return err
}
// AppendEvent inserts a new event row for a session.
// seq is auto-calculated as MAX(seq)+1 for the session.
func (d *DB) AppendEvent(e ChatEventRow) error {
if d.conn == nil {
return nil
}
toolArgs := e.ToolArgs
if toolArgs == "" {
toolArgs = "null"
}
usageJSON := e.UsageJSON
if usageJSON == "" {
usageJSON = "null"
}
var toolSuccessVal interface{}
if e.EventType == "tool_call" {
if e.ToolSuccess {
toolSuccessVal = 1
} else {
toolSuccessVal = 0
}
}
_, err := d.conn.Exec(`
INSERT INTO chatEvents
(sessionId, seq, eventType, content, toolName, toolArgs,
toolResult, toolSuccess, durationMs, model, usageJson, errorMsg)
SELECT ?, COALESCE(MAX(seq),0)+1, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?
FROM chatEvents WHERE sessionId = ?
`,
e.SessionID, e.EventType,
nullStr(e.Content), nullStr(e.ToolName), rawJSON(toolArgs),
nullStr(e.ToolResult), toolSuccessVal, nullInt(e.DurationMs),
nullStr(e.Model), rawJSON(usageJSON), nullStr(e.ErrorMsg),
e.SessionID,
)
if err != nil {
log.Printf("[DB] AppendEvent error: %v", err)
}
return err
}
// MarkSessionDone updates a session to done/error status.
func (d *DB) MarkSessionDone(sessionID, status, finalResponse, model, errorMessage string, totalTokens int, processingTimeMs int64) {
if d.conn == nil {
return
}
_, err := d.conn.Exec(`
UPDATE chatSessions
SET status=?, finalResponse=?, model=?, totalTokens=?,
processingTimeMs=?, errorMessage=?
WHERE sessionId=?
`, status,
truncate(finalResponse, 65535),
model,
totalTokens,
processingTimeMs,
truncate(errorMessage, 65535),
sessionID,
)
if err != nil {
log.Printf("[DB] MarkSessionDone error: %v", err)
}
}
// GetSession returns a single session by its string ID.
func (d *DB) GetSession(sessionID string) (*ChatSessionRow, error) {
if d.conn == nil {
return nil, fmt.Errorf("DB not connected")
}
row := d.conn.QueryRow(`
SELECT id, sessionId, agentId, status,
COALESCE(userMessage,''),
COALESCE(finalResponse,''),
COALESCE(model,''),
COALESCE(totalTokens,0),
COALESCE(processingTimeMs,0),
COALESCE(errorMessage,''),
createdAt, updatedAt
FROM chatSessions WHERE sessionId=? LIMIT 1
`, sessionID)
var s ChatSessionRow
err := row.Scan(&s.ID, &s.SessionID, &s.AgentID, &s.Status,
&s.UserMessage, &s.FinalResponse, &s.Model,
&s.TotalTokens, &s.ProcessingTimeMs, &s.ErrorMessage,
&s.CreatedAt, &s.UpdatedAt)
if err != nil {
return nil, err
}
return &s, nil
}
// GetEvents returns all events for a session with seq > afterSeq (for incremental polling).
func (d *DB) GetEvents(sessionID string, afterSeq int) ([]ChatEventRow, error) {
if d.conn == nil {
return nil, fmt.Errorf("DB not connected")
}
rows, err := d.conn.Query(`
SELECT id, sessionId, seq, eventType,
COALESCE(content,''), COALESCE(toolName,''),
COALESCE(CAST(toolArgs AS CHAR),'null'),
COALESCE(toolResult,''),
COALESCE(toolSuccess,0),
COALESCE(durationMs,0),
COALESCE(model,''),
COALESCE(CAST(usageJson AS CHAR),'null'),
COALESCE(errorMsg,''),
createdAt
FROM chatEvents
WHERE sessionId=? AND seq > ?
ORDER BY seq ASC
`, sessionID, afterSeq)
if err != nil {
return nil, err
}
defer rows.Close()
var result []ChatEventRow
for rows.Next() {
var e ChatEventRow
var toolSuccess int
if err := rows.Scan(
&e.ID, &e.SessionID, &e.Seq, &e.EventType,
&e.Content, &e.ToolName, &e.ToolArgs,
&e.ToolResult, &toolSuccess, &e.DurationMs,
&e.Model, &e.UsageJSON, &e.ErrorMsg, &e.CreatedAt,
); err != nil {
continue
}
e.ToolSuccess = toolSuccess == 1
result = append(result, e)
}
return result, nil
}
// GetRecentSessions returns the N most recent sessions.
func (d *DB) GetRecentSessions(limit int) ([]ChatSessionRow, error) {
if d.conn == nil {
return nil, fmt.Errorf("DB not connected")
}
rows, err := d.conn.Query(`
SELECT id, sessionId, agentId, status,
COALESCE(userMessage,''),
COALESCE(finalResponse,''),
COALESCE(model,''),
COALESCE(totalTokens,0),
COALESCE(processingTimeMs,0),
COALESCE(errorMessage,''),
createdAt, updatedAt
FROM chatSessions ORDER BY id DESC LIMIT ?
`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var result []ChatSessionRow
for rows.Next() {
var s ChatSessionRow
if err := rows.Scan(&s.ID, &s.SessionID, &s.AgentID, &s.Status,
&s.UserMessage, &s.FinalResponse, &s.Model,
&s.TotalTokens, &s.ProcessingTimeMs, &s.ErrorMessage,
&s.CreatedAt, &s.UpdatedAt); err != nil {
continue
}
result = append(result, s)
}
return result, nil
}
// helper — nil for empty strings
func nullStr(s string) interface{} {
if s == "" {
return nil
}
return s
}
// helper — nil for zero int
func nullInt(n int) interface{} {
if n == 0 {
return nil
}
return n
}
// rawJSON wraps a JSON string so it's passed as-is to MySQL (not double-encoded)
type rawJSON string
func (r rawJSON) Value() (driver.Value, error) {
if r == "null" || r == "" {
return nil, nil
}
return string(r), nil
}
// ─── Metrics & History ────────────────────────────────────────────────────────
// MetricInput holds data for a single orchestrator request metric.
type MetricInput struct {
AgentID int
RequestID string
UserMessage string
AgentResponse string
InputTokens int
OutputTokens int
TotalTokens int
ProcessingTimeMs int64
Status string // "success" | "error" | "timeout"
ErrorMessage string
ToolsCalled []string
Model string
}
// SaveMetric inserts a row into the agentMetrics table.
// Non-fatal — logs on error but does not return one.
func (d *DB) SaveMetric(m MetricInput) {
if d.conn == nil {
return
}
toolsJSON, _ := json.Marshal(m.ToolsCalled)
_, err := d.conn.Exec(`
INSERT INTO agentMetrics
(agentId, requestId, userMessage, agentResponse,
inputTokens, outputTokens, totalTokens,
processingTimeMs, status, errorMessage, toolsCalled, model)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
m.AgentID,
m.RequestID,
truncate(m.UserMessage, 65535),
truncate(m.AgentResponse, 65535),
m.InputTokens, m.OutputTokens, m.TotalTokens,
m.ProcessingTimeMs,
m.Status,
m.ErrorMessage,
string(toolsJSON),
m.Model,
)
if err != nil {
log.Printf("[DB] SaveMetric error: %v", err)
}
}
// HistoryInput holds data for one conversation entry.
type HistoryInput struct {
AgentID int
UserMessage string
AgentResponse string
ConversationID string
Status string // "success" | "error" | "pending"
}
// SaveHistory inserts a row into the agentHistory table.
// Non-fatal — logs on error but does not return one.
func (d *DB) SaveHistory(h HistoryInput) {
if d.conn == nil {
return
}
status := h.Status
if status == "" {
status = "success"
}
convID := sql.NullString{String: h.ConversationID, Valid: h.ConversationID != ""}
resp := sql.NullString{String: h.AgentResponse, Valid: h.AgentResponse != ""}
_, err := d.conn.Exec(`
INSERT INTO agentHistory (agentId, userMessage, agentResponse, conversationId, status)
VALUES (?, ?, ?, ?, ?)
`,
h.AgentID,
truncate(h.UserMessage, 65535),
resp,
convID,
status,
)
if err != nil {
log.Printf("[DB] SaveHistory error: %v", err)
}
}
// truncate caps a string to maxLen bytes (not runes — fast path for DB limits).
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen]
}
// ─── Swarm Node Persistence ───────────────────────────────────────────────────
// SwarmNodeInput is the data shape that handlers pass to UpsertSwarmNodes.
// It matches the JSON shape from handler's NodeOut struct so we can reuse it.
type SwarmNodeInput struct {
ID string `json:"id"`
Hostname string `json:"hostname"`
Role string `json:"role"`
State string `json:"state"`
Availability string `json:"availability"`
IP string `json:"ip"`
CPUCores int `json:"cpuCores"`
MemTotalMB int64 `json:"memTotalMB"`
DockerVersion string `json:"dockerVersion"`
IsLeader bool `json:"isLeader"`
ManagerAddr string `json:"managerAddr"`
Labels map[string]string `json:"labels"`
}
// UpsertSwarmNodes inserts or updates swarm node records in the swarmNodes table.
// Called asynchronously from the SwarmNodes handler — never blocks the response.
func (d *DB) UpsertSwarmNodes(nodes interface{}) {
if d.conn == nil {
return
}
// We accept interface{} to avoid circular import; use json round-trip to parse.
b, err := json.Marshal(nodes)
if err != nil {
return
}
var list []SwarmNodeInput
if err := json.Unmarshal(b, &list); err != nil {
return
}
for _, n := range list {
labelsJSON, _ := json.Marshal(n.Labels)
isLeader := 0
if n.IsLeader {
isLeader = 1
}
isManager := 0
if n.Role == "manager" {
isManager = 1
}
state := n.State
if state != "ready" && state != "down" && state != "disconnected" {
state = "ready"
}
avail := n.Availability
if avail != "active" && avail != "pause" && avail != "drain" {
avail = "active"
}
_, err := d.conn.Exec(`
INSERT INTO swarmNodes
(nodeId, hostname, role, state, availability, advertiseAddr,
labels, engineVersion, cpuCores, memTotalMB, isManager, isLeader)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
hostname=VALUES(hostname), role=VALUES(role),
state=VALUES(state), availability=VALUES(availability),
advertiseAddr=VALUES(advertiseAddr),
labels=VALUES(labels), engineVersion=VALUES(engineVersion),
cpuCores=VALUES(cpuCores), memTotalMB=VALUES(memTotalMB),
isManager=VALUES(isManager), isLeader=VALUES(isLeader),
lastSeenAt=CURRENT_TIMESTAMP
`,
n.ID, n.Hostname, n.Role, state, avail, n.IP,
string(labelsJSON), n.DockerVersion,
n.CPUCores, n.MemTotalMB, isManager, isLeader,
)
if err != nil {
log.Printf("[DB] UpsertSwarmNodes error for node %s: %v", n.ID, err)
}
}
}
// UpsertSwarmTokens stores the current swarm join tokens.
func (d *DB) UpsertSwarmTokens(workerToken, managerToken, managerAddr string) {
if d.conn == nil {
return
}
_, err := d.conn.Exec(`
INSERT INTO swarmTokens (managerToken, workerToken, managerAddr)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE
managerToken=VALUES(managerToken),
workerToken=VALUES(workerToken),
managerAddr=VALUES(managerAddr)
`, managerToken, workerToken, managerAddr)
if err != nil {
log.Printf("[DB] UpsertSwarmTokens error: %v", err)
}
}
// GetSwarmTokens retrieves the stored join tokens.
func (d *DB) GetSwarmTokens() (worker, manager, addr string, err error) {
if d.conn == nil {
err = fmt.Errorf("DB not connected")
return
}
row := d.conn.QueryRow(`
SELECT COALESCE(workerToken,''), COALESCE(managerToken,''), COALESCE(managerAddr,'')
FROM swarmTokens ORDER BY id DESC LIMIT 1
`)
err = row.Scan(&worker, &manager, &addr)
return
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
func scanAgentConfig(row *sql.Row) (*AgentConfig, error) {
@@ -195,3 +679,60 @@ func normalizeDSN(dsn string) string {
}
return fmt.Sprintf("%s@tcp(%s)%s?parseTime=true&charset=utf8mb4%s", userInfo, hostPort, dbName, tlsParam)
}
// ─── Agent Container Fields ───────────────────────────────────────────────────
// These methods support the agent-worker container architecture where each
// agent runs as an autonomous Docker Swarm service.
// UpdateContainerStatus updates the container lifecycle state of an agent.
func (d *DB) UpdateContainerStatus(agentID int, status, serviceName string, servicePort int) error {
if d.conn == nil {
return nil
}
_, err := d.conn.Exec(`
UPDATE agents
SET containerStatus = ?, serviceName = ?, servicePort = ?, updatedAt = NOW()
WHERE id = ?
`, status, serviceName, servicePort, agentID)
return err
}
// HistoryRow is a single entry from agentHistory for sliding window memory.
type HistoryRow struct {
ID int `json:"id"`
UserMessage string `json:"userMessage"`
AgentResponse string `json:"agentResponse"`
ConvID string `json:"conversationId"`
}
// GetAgentHistory returns the last N conversation turns for an agent, oldest first.
func (d *DB) GetAgentHistory(agentID, limit int) ([]HistoryRow, error) {
if d.conn == nil {
return nil, nil
}
rows, err := d.conn.Query(`
SELECT id, userMessage, COALESCE(agentResponse,''), COALESCE(conversationId,'')
FROM agentHistory
WHERE agentId = ?
ORDER BY id DESC
LIMIT ?
`, agentID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var result []HistoryRow
for rows.Next() {
var h HistoryRow
if err := rows.Scan(&h.ID, &h.UserMessage, &h.AgentResponse, &h.ConvID); err != nil {
continue
}
result = append(result, h)
}
// Reverse so oldest is first (for LLM context ordering)
for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
result[i], result[j] = result[j], result[i]
}
return result, nil
}

View File

@@ -1,22 +1,25 @@
package docker
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os/exec"
"strings"
"time"
)
// DockerClient communicates with the Docker daemon via Unix socket or TCP.
// DockerClient communicates with the Docker daemon via Unix socket.
type DockerClient struct {
httpClient *http.Client
baseURL string
}
// NewDockerClient creates a client that talks to /var/run/docker.sock.
// NewDockerClient creates a client talking to /var/run/docker.sock.
func NewDockerClient() *DockerClient {
transport := &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
@@ -24,11 +27,13 @@ func NewDockerClient() *DockerClient {
},
}
return &DockerClient{
httpClient: &http.Client{Transport: transport, Timeout: 10 * time.Second},
baseURL: "http://localhost", // host is ignored for unix socket
httpClient: &http.Client{Transport: transport, Timeout: 30 * time.Second},
baseURL: "http://localhost",
}
}
// ─── HTTP helpers ─────────────────────────────────────────────────────────────
func (c *DockerClient) get(path string, out interface{}) error {
resp, err := c.httpClient.Get(c.baseURL + path)
if err != nil {
@@ -42,16 +47,64 @@ func (c *DockerClient) get(path string, out interface{}) error {
return json.Unmarshal(body, out)
}
// ---- Types ----------------------------------------------------------------
func (c *DockerClient) post(path string, payload interface{}, out interface{}) error {
b, err := json.Marshal(payload)
if err != nil {
return err
}
resp, err := c.httpClient.Post(c.baseURL+path, "application/json", bytes.NewReader(b))
if err != nil {
return fmt.Errorf("docker POST %s: %w", path, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return fmt.Errorf("docker POST %s: status %d: %s", path, resp.StatusCode, string(body))
}
if out != nil && len(body) > 0 {
return json.Unmarshal(body, out)
}
return nil
}
func (c *DockerClient) postUpdate(path string, version int, payload interface{}) error {
b, err := json.Marshal(payload)
if err != nil {
return err
}
url := fmt.Sprintf("%s%s?version=%d", c.baseURL, path, version)
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("docker POST(update) %s: %w", path, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return fmt.Errorf("docker POST(update) %s: status %d: %s", path, resp.StatusCode, string(body))
}
return nil
}
// ─── Swarm Node Types ─────────────────────────────────────────────────────────
type SwarmNode struct {
ID string `json:"ID"`
ID string `json:"ID"`
Description NodeDescription `json:"Description"`
Status NodeStatus `json:"Status"`
Status NodeStatus `json:"Status"`
ManagerStatus *ManagerStatus `json:"ManagerStatus,omitempty"`
Spec NodeSpec `json:"Spec"`
UpdatedAt time.Time `json:"UpdatedAt"`
CreatedAt time.Time `json:"CreatedAt"`
Spec NodeSpec `json:"Spec"`
UpdatedAt time.Time `json:"UpdatedAt"`
CreatedAt time.Time `json:"CreatedAt"`
Version VersionInfo `json:"Version"`
}
type VersionInfo struct {
Index int `json:"Index"`
}
type NodeDescription struct {
@@ -82,17 +135,155 @@ type NodeStatus struct {
}
type ManagerStatus struct {
Addr string `json:"Addr"`
Leader bool `json:"Leader"`
Reachability string `json:"Reachability"`
Addr string `json:"Addr"`
Leader bool `json:"Leader"`
Reachability string `json:"Reachability"`
}
type NodeSpec struct {
Role string `json:"Role"`
Availability string `json:"Availability"`
Role string `json:"Role"`
Availability string `json:"Availability"`
Labels map[string]string `json:"Labels"`
}
// ─── Swarm Service Types ──────────────────────────────────────────────────────
type SwarmService struct {
ID string `json:"ID"`
Spec ServiceSpec `json:"Spec"`
ServiceStatus *ServiceStatus `json:"ServiceStatus,omitempty"`
UpdatedAt time.Time `json:"UpdatedAt"`
CreatedAt time.Time `json:"CreatedAt"`
Version VersionInfo `json:"Version"`
}
type ServiceSpec struct {
Name string `json:"Name"`
Mode ServiceMode `json:"Mode"`
TaskTemplate TaskTemplate `json:"TaskTemplate"`
EndpointSpec *EndpointSpec `json:"EndpointSpec,omitempty"`
Labels map[string]string `json:"Labels"`
Networks []NetworkAttachment `json:"Networks,omitempty"`
}
type NetworkAttachment struct {
Target string `json:"Target"`
Aliases []string `json:"Aliases,omitempty"`
}
type ServiceMode struct {
Replicated *ReplicatedService `json:"Replicated,omitempty"`
Global *struct{} `json:"Global,omitempty"`
}
type ReplicatedService struct {
Replicas int `json:"Replicas"`
}
type TaskTemplate struct {
ContainerSpec ContainerSpec `json:"ContainerSpec"`
Resources *TaskResources `json:"Resources,omitempty"`
Placement *Placement `json:"Placement,omitempty"`
}
type ContainerSpec struct {
Image string `json:"Image"`
Env []string `json:"Env,omitempty"`
Labels map[string]string `json:"Labels,omitempty"`
}
type TaskResources struct {
Limits *ResourceSpec `json:"Limits,omitempty"`
Reservations *ResourceSpec `json:"Reservations,omitempty"`
}
type ResourceSpec struct {
NanoCPUs int64 `json:"NanoCPUs,omitempty"`
MemoryBytes int64 `json:"MemoryBytes,omitempty"`
}
type Placement struct {
Constraints []string `json:"Constraints,omitempty"`
}
type EndpointSpec struct {
Ports []PortConfig `json:"Ports,omitempty"`
}
type PortConfig struct {
Protocol string `json:"Protocol"`
TargetPort int `json:"TargetPort"`
PublishedPort int `json:"PublishedPort"`
PublishMode string `json:"PublishMode"`
}
type ServiceStatus struct {
RunningTasks int `json:"RunningTasks"`
DesiredTasks int `json:"DesiredTasks"`
CompletedTasks int `json:"CompletedTasks"`
}
// ─── Swarm Task Types ─────────────────────────────────────────────────────────
type SwarmTask struct {
ID string `json:"ID"`
ServiceID string `json:"ServiceID"`
NodeID string `json:"NodeID"`
Spec TaskSpec `json:"Spec"`
Status TaskStatus `json:"Status"`
Slot int `json:"Slot"`
UpdatedAt time.Time `json:"UpdatedAt"`
CreatedAt time.Time `json:"CreatedAt"`
}
type TaskSpec struct {
ContainerSpec ContainerSpec `json:"ContainerSpec"`
}
type TaskStatus struct {
Timestamp time.Time `json:"Timestamp"`
State string `json:"State"`
Message string `json:"Message"`
ContainerStatus *ContainerTaskStatus `json:"ContainerStatus,omitempty"`
}
type ContainerTaskStatus struct {
ContainerID string `json:"ContainerID"`
PID int `json:"PID"`
}
// ─── Swarm Info / Tokens ──────────────────────────────────────────────────────
type DockerInfo struct {
Swarm SwarmInfo `json:"Swarm"`
}
type SwarmInfo struct {
NodeID string `json:"NodeID"`
LocalNodeState string `json:"LocalNodeState"`
ControlAvailable bool `json:"ControlAvailable"`
Managers int `json:"Managers"`
Nodes int `json:"Nodes"`
RemoteManagers []RemoteManager `json:"RemoteManagers"`
}
type RemoteManager struct {
NodeID string `json:"NodeID"`
Addr string `json:"Addr"`
}
type SwarmSpec struct {
JoinTokens JoinTokens `json:"JoinTokens"`
ID string `json:"ID"`
}
type JoinTokens struct {
Worker string `json:"Worker"`
Manager string `json:"Manager"`
}
// ─── Container types ──────────────────────────────────────────────────────────
type Container struct {
ID string `json:"Id"`
Names []string `json:"Names"`
@@ -109,9 +300,9 @@ type ContainerStats struct {
}
type CPUStats struct {
CPUUsage CPUUsage `json:"cpu_usage"`
SystemCPUUsage int64 `json:"system_cpu_usage"`
OnlineCPUs int `json:"online_cpus"`
CPUUsage CPUUsage `json:"cpu_usage"`
SystemCPUUsage int64 `json:"system_cpu_usage"`
OnlineCPUs int `json:"online_cpus"`
}
type CPUUsage struct {
@@ -120,27 +311,14 @@ type CPUUsage struct {
}
type MemoryStats struct {
Usage int64 `json:"usage"`
MaxUsage int64 `json:"max_usage"`
Limit int64 `json:"limit"`
Usage int64 `json:"usage"`
MaxUsage int64 `json:"max_usage"`
Limit int64 `json:"limit"`
Stats map[string]int64 `json:"stats"`
}
type DockerInfo struct {
Swarm SwarmInfo `json:"Swarm"`
}
// ─── Methods: Swarm info ──────────────────────────────────────────────────────
type SwarmInfo struct {
NodeID string `json:"NodeID"`
LocalNodeState string `json:"LocalNodeState"`
ControlAvailable bool `json:"ControlAvailable"`
Managers int `json:"Managers"`
Nodes int `json:"Nodes"`
}
// ---- Methods ---------------------------------------------------------------
// IsSwarmActive checks if Docker Swarm is initialized.
func (c *DockerClient) IsSwarmActive() bool {
var info DockerInfo
if err := c.get("/v1.44/info", &info); err != nil {
@@ -149,7 +327,6 @@ func (c *DockerClient) IsSwarmActive() bool {
return info.Swarm.LocalNodeState == "active"
}
// GetSwarmInfo returns basic swarm info.
func (c *DockerClient) GetSwarmInfo() (*DockerInfo, error) {
var info DockerInfo
if err := c.get("/v1.44/info", &info); err != nil {
@@ -158,7 +335,27 @@ func (c *DockerClient) GetSwarmInfo() (*DockerInfo, error) {
return &info, nil
}
// ListNodes returns all Swarm nodes (requires manager node).
// GetJoinTokens returns the Swarm worker and manager join tokens.
// Requires this node to be a swarm manager.
func (c *DockerClient) GetJoinTokens() (*SwarmSpec, error) {
var spec SwarmSpec
if err := c.get("/v1.44/swarm", &spec); err != nil {
return nil, err
}
return &spec, nil
}
// GetManagerAddr returns the advertise address (IP:2377) for joining this swarm.
func (c *DockerClient) GetManagerAddr() string {
info, err := c.GetSwarmInfo()
if err != nil || len(info.Swarm.RemoteManagers) == 0 {
return ""
}
return info.Swarm.RemoteManagers[0].Addr
}
// ─── Methods: Nodes ───────────────────────────────────────────────────────────
func (c *DockerClient) ListNodes() ([]SwarmNode, error) {
var nodes []SwarmNode
if err := c.get("/v1.44/nodes", &nodes); err != nil {
@@ -167,7 +364,197 @@ func (c *DockerClient) ListNodes() ([]SwarmNode, error) {
return nodes, nil
}
// ListContainers returns all running containers on this host.
// UpdateNodeAvailability sets a node's availability (active|pause|drain).
func (c *DockerClient) UpdateNodeAvailability(nodeID, availability string) error {
// First get current node spec + version
var node SwarmNode
if err := c.get("/v1.44/nodes/"+nodeID, &node); err != nil {
return err
}
node.Spec.Availability = availability
return c.postUpdate("/v1.44/nodes/"+nodeID+"/update", node.Version.Index, node.Spec)
}
// AddNodeLabel adds a label to a swarm node.
func (c *DockerClient) AddNodeLabel(nodeID, key, value string) error {
var node SwarmNode
if err := c.get("/v1.44/nodes/"+nodeID, &node); err != nil {
return err
}
if node.Spec.Labels == nil {
node.Spec.Labels = map[string]string{}
}
node.Spec.Labels[key] = value
return c.postUpdate("/v1.44/nodes/"+nodeID+"/update", node.Version.Index, node.Spec)
}
// ─── Methods: Services ────────────────────────────────────────────────────────
// ListServices returns all swarm services, optionally filtered by label.
func (c *DockerClient) ListServices() ([]SwarmService, error) {
var services []SwarmService
// Include ServiceStatus so running/desired replicas are returned
if err := c.get("/v1.44/services?status=true", &services); err != nil {
return nil, err
}
return services, nil
}
// GetService returns a single service by ID or name.
func (c *DockerClient) GetService(idOrName string) (*SwarmService, error) {
var svc SwarmService
if err := c.get("/v1.44/services/"+idOrName+"?status=true", &svc); err != nil {
return nil, err
}
return &svc, nil
}
// ScaleService updates the replica count for a replicated service.
func (c *DockerClient) ScaleService(idOrName string, replicas int) error {
svc, err := c.GetService(idOrName)
if err != nil {
return err
}
if svc.Spec.Mode.Replicated == nil {
return fmt.Errorf("service %s is not in replicated mode", idOrName)
}
svc.Spec.Mode.Replicated.Replicas = replicas
return c.postUpdate(
"/v1.44/services/"+svc.ID+"/update",
svc.Version.Index,
svc.Spec,
)
}
// ListServiceTasks returns all tasks for a given service.
func (c *DockerClient) ListServiceTasks(serviceID string) ([]SwarmTask, error) {
var tasks []SwarmTask
filter := fmt.Sprintf(`{"service":["%s"]}`, serviceID)
path := "/v1.44/tasks?filters=" + urlEncode(filter)
if err := c.get(path, &tasks); err != nil {
return nil, err
}
return tasks, nil
}
// ListAllTasks returns all swarm tasks (across services).
func (c *DockerClient) ListAllTasks() ([]SwarmTask, error) {
var tasks []SwarmTask
if err := c.get("/v1.44/tasks", &tasks); err != nil {
return nil, err
}
return tasks, nil
}
// CreateAgentService deploys a new swarm service for an AI agent.
// image: container image, name: service name, replicas: initial count,
// env: environment variables, port: optional published port (0 = none).
// CreateAgentServiceOpts holds options for deploying an agent Swarm service.
type CreateAgentServiceOpts struct {
Name string
Image string
Replicas int
Env []string
Port int
Networks []string // overlay network names/IDs to attach
Labels map[string]string
}
func (c *DockerClient) CreateAgentService(name, image string, replicas int, env []string, port int) (*SwarmService, error) {
return c.CreateAgentServiceFull(CreateAgentServiceOpts{
Name: name,
Image: image,
Replicas: replicas,
Env: env,
Port: port,
})
}
func (c *DockerClient) CreateAgentServiceFull(opts CreateAgentServiceOpts) (*SwarmService, error) {
labels := map[string]string{
"goclaw.agent": "true",
"goclaw.name": opts.Name,
}
for k, v := range opts.Labels {
labels[k] = v
}
spec := ServiceSpec{
Name: opts.Name,
Mode: ServiceMode{
Replicated: &ReplicatedService{Replicas: opts.Replicas},
},
TaskTemplate: TaskTemplate{
ContainerSpec: ContainerSpec{
Image: opts.Image,
Env: opts.Env,
},
},
Labels: labels,
}
if opts.Port > 0 {
spec.EndpointSpec = &EndpointSpec{
Ports: []PortConfig{
{
Protocol: "tcp",
TargetPort: opts.Port,
PublishMode: "ingress",
},
},
}
}
if len(opts.Networks) > 0 {
for _, net := range opts.Networks {
spec.Networks = append(spec.Networks, NetworkAttachment{
Target: net,
Aliases: []string{opts.Name},
})
}
}
var created struct {
ID string `json:"ID"`
}
if err := c.post("/v1.44/services/create", spec, &created); err != nil {
return nil, err
}
return c.GetService(created.ID)
}
// RemoveService removes a swarm service by ID or name.
func (c *DockerClient) RemoveService(idOrName string) error {
req, err := http.NewRequest(http.MethodDelete, c.baseURL+"/v1.44/services/"+urlEncode(idOrName), nil)
if err != nil {
return err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("docker DELETE service %s: %w", idOrName, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("docker DELETE service %s: status %d: %s", idOrName, resp.StatusCode, string(body))
}
return nil
}
// GetServiceLastActivity returns the most recent task update time for a service.
// Used to determine whether a service is idle.
func (c *DockerClient) GetServiceLastActivity(serviceID string) (time.Time, error) {
tasks, err := c.ListServiceTasks(serviceID)
if err != nil {
return time.Time{}, err
}
var latest time.Time
for _, t := range tasks {
if t.UpdatedAt.After(latest) {
latest = t.UpdatedAt
}
}
return latest, nil
}
// ─── Methods: Containers ─────────────────────────────────────────────────────
func (c *DockerClient) ListContainers() ([]Container, error) {
var containers []Container
if err := c.get("/v1.44/containers/json?all=false", &containers); err != nil {
@@ -176,7 +563,6 @@ func (c *DockerClient) ListContainers() ([]Container, error) {
return containers, nil
}
// GetContainerStats returns one-shot stats for a container (no streaming).
func (c *DockerClient) GetContainerStats(containerID string) (*ContainerStats, error) {
var stats ContainerStats
if err := c.get(fmt.Sprintf("/v1.44/containers/%s/stats?stream=false", containerID), &stats); err != nil {
@@ -185,7 +571,69 @@ func (c *DockerClient) GetContainerStats(containerID string) (*ContainerStats, e
return &stats, nil
}
// CalcCPUPercent computes CPU usage % from two consecutive stats snapshots.
// ─── Host Shell execution ─────────────────────────────────────────────────────
// The gateway runs inside a container but has /var/run/docker.sock mounted.
// We use `docker exec` against the host PID namespace via a privileged helper,
// OR simply run commands via the docker socket by exec-ing into the gateway
// container's own shell with nsenter to reach PID 1 on the host.
//
// Approach: use `nsenter -t 1 -m -u -i -n -p -- <cmd>` via the host PID namespace.
// This requires the container to run with --privileged or SYS_PTRACE capability
// and PID namespace sharing. We add that to docker-compose.yml.
//
// Alternative (safer): exec into host via SSH or a privileged sidecar.
// For now we use nsenter which works when pid:host and privileged: true.
// ExecOnHost runs a shell command on the host via nsenter into PID 1.
// Returns combined stdout+stderr.
func ExecOnHost(command string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Try nsenter (requires pid:host + SYS_ADMIN or privileged)
cmd := exec.CommandContext(ctx, "nsenter", "-t", "1", "-m", "-u", "-i", "-n", "-p", "--",
"sh", "-c", command)
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
// If nsenter fails, fall back to running in container scope
cmd2 := exec.CommandContext(ctx, "sh", "-c", command)
var out2 bytes.Buffer
var stderr2 bytes.Buffer
cmd2.Stdout = &out2
cmd2.Stderr = &stderr2
if err2 := cmd2.Run(); err2 != nil {
combined := out2.String() + stderr2.String()
if combined == "" {
combined = err2.Error()
}
return combined, err2
}
return out2.String() + stderr2.String(), nil
}
return out.String() + stderr.String(), nil
}
// ExecDockerCLI runs `docker <args>` on the host by calling the docker socket.
// Since we have the socket mounted, we can exec docker commands directly
// using the docker CLI binary if available.
func ExecDockerCLI(args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "docker", args...)
var out, stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return out.String() + stderr.String(), err
}
return out.String(), nil
}
// CalcCPUPercent computes CPU% from stats snapshot.
func CalcCPUPercent(stats *ContainerStats) float64 {
cpuDelta := float64(stats.CPUStats.CPUUsage.TotalUsage) - float64(stats.PreCPUStats.CPUUsage.TotalUsage)
systemDelta := float64(stats.CPUStats.SystemCPUUsage) - float64(stats.PreCPUStats.SystemCPUUsage)
@@ -198,3 +646,19 @@ func CalcCPUPercent(stats *ContainerStats) float64 {
}
return 0
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
func urlEncode(s string) string {
var b strings.Builder
for _, r := range s {
switch {
case r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z', r >= '0' && r <= '9',
r == '-', r == '_', r == '.', r == '~':
b.WriteRune(r)
default:
b.WriteString(fmt.Sprintf("%%%02X", r))
}
}
return b.String()
}

View File

@@ -2,6 +2,7 @@
package llm
import (
"bufio"
"bytes"
"context"
"encoding/json"
@@ -105,6 +106,13 @@ func NewClient(baseURL, apiKey string) *Client {
}
}
// UpdateCredentials updates the LLM client's base URL and API key at runtime.
// Called when the active provider is changed via the Settings UI.
func (c *Client) UpdateCredentials(baseURL, apiKey string) {
c.baseURL = strings.TrimRight(baseURL, "/")
c.apiKey = apiKey
}
func (c *Client) headers() map[string]string {
h := map[string]string{
"Content-Type": "application/json",
@@ -159,7 +167,86 @@ func (c *Client) ListModels(ctx context.Context) (*ModelsResponse, error) {
return &result, nil
}
// Chat sends a chat completion request (non-streaming).
// ChatStream sends a streaming chat completion request (SSE).
// It calls the callback for each chunk received.
func (c *Client) ChatStream(ctx context.Context, req ChatRequest, onChunk func(delta string, done bool)) error {
req.Stream = true
body, err := json.Marshal(req)
if err != nil {
return err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return err
}
for k, v := range c.headers() {
httpReq.Header.Set(k, v)
}
httpReq.Header.Set("Accept", "text/event-stream")
// Use a client without timeout for streaming
streamClient := &http.Client{Timeout: 0}
resp, err := streamClient.Do(httpReq)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("ollama stream API error (%d): %s", resp.StatusCode, string(respBody))
}
// Parse SSE stream
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
onChunk("", true)
return nil
}
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
continue
}
if len(chunk.Choices) > 0 {
delta := chunk.Choices[0].Delta.Content
if delta != "" {
onChunk(delta, false)
}
if chunk.Choices[0].FinishReason != nil && *chunk.Choices[0].FinishReason == "stop" {
onChunk("", true)
return nil
}
}
}
if err := scanner.Err(); err != nil {
return err
}
onChunk("", true)
return nil
}
func (c *Client) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error) {
req.Stream = false

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"log"
"strings"
"time"
"git.softuniq.eu/UniqAI/GoClaw/gateway/internal/db"
@@ -31,13 +32,15 @@ type ToolCallStep struct {
DurationMs int64 `json:"durationMs"`
}
// ChatResult is the response from the orchestrator chat.
type ChatResult struct {
Success bool `json:"success"`
Response string `json:"response"`
ToolCalls []ToolCallStep `json:"toolCalls"`
Model string `json:"model"`
Usage *llm.Usage `json:"usage,omitempty"`
Error string `json:"error,omitempty"`
Success bool `json:"success"`
Response string `json:"response"`
ToolCalls []ToolCallStep `json:"toolCalls"`
Model string `json:"model"`
ModelWarning string `json:"modelWarning,omitempty"`
Usage *llm.Usage `json:"usage,omitempty"`
Error string `json:"error,omitempty"`
}
// OrchestratorConfig is the runtime config loaded from DB or defaults.
@@ -51,6 +54,30 @@ type OrchestratorConfig struct {
MaxTokens int
}
// RetryPolicy controls how the orchestrator retries failed or empty LLM calls.
type RetryPolicy struct {
// MaxLLMRetries is the number of additional attempts after a failure.
// Total attempts = MaxLLMRetries + 1. Default: 3 (4 total).
MaxLLMRetries int
// InitialDelay before the first retry. Default: 2s.
InitialDelay time.Duration
// MaxDelay caps the exponential back-off. Default: 30s.
MaxDelay time.Duration
// RetryOnEmpty means an empty-content response is treated as a soft failure
// and triggers a retry. Default: true.
RetryOnEmpty bool
}
// defaultRetryPolicy returns the default retry policy.
func defaultRetryPolicy() RetryPolicy {
return RetryPolicy{
MaxLLMRetries: 3,
InitialDelay: 2 * time.Second,
MaxDelay: 30 * time.Second,
RetryOnEmpty: true,
}
}
// ─── Default System Prompt ────────────────────────────────────────────────────
const defaultSystemPrompt = `You are GoClaw Orchestrator — the main AI agent managing the GoClaw distributed AI system.
@@ -86,6 +113,7 @@ type Orchestrator struct {
executor *tools.Executor
database *db.DB
projectRoot string
retry RetryPolicy
}
func New(llmClient *llm.Client, database *db.DB, projectRoot string) *Orchestrator {
@@ -93,12 +121,20 @@ func New(llmClient *llm.Client, database *db.DB, projectRoot string) *Orchestrat
llmClient: llmClient,
database: database,
projectRoot: projectRoot,
retry: defaultRetryPolicy(),
}
// Inject agent list function to avoid circular dependency
o.executor = tools.NewExecutor(projectRoot, o.listAgentsFn)
// Inject DB so delegate_to_agent can resolve live agent container addresses
o.executor.SetDatabase(database)
return o
}
// SetRetryPolicy overrides the default retry policy.
func (o *Orchestrator) SetRetryPolicy(p RetryPolicy) {
o.retry = p
}
// GetConfig loads orchestrator config from DB, falls back to defaults.
func (o *Orchestrator) GetConfig() *OrchestratorConfig {
if o.database != nil {
@@ -129,25 +165,188 @@ func (o *Orchestrator) GetConfig() *OrchestratorConfig {
}
}
// Chat runs the full orchestration loop: LLM → tool calls → LLM → response.
func (o *Orchestrator) Chat(ctx context.Context, messages []Message, overrideModel string, maxIter int) ChatResult {
if maxIter <= 0 {
maxIter = 10
// resolveModel checks if the desired model is available via the LLM API.
// If not, it tries to fall back to the first available model.
// Returns the resolved model name and a warning if fallback was used.
func (o *Orchestrator) resolveModel(ctx context.Context, desired string) (model string, warning string) {
ctxShort, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
models, err := o.llmClient.ListModels(ctxShort)
if err != nil || models == nil || len(models.Data) == 0 {
// Cannot verify — use desired model as-is
log.Printf("[Orchestrator] Cannot fetch model list: %v — using %q as-is", err, desired)
return desired, ""
}
// Check if desired model is available
for _, m := range models.Data {
if m.ID == desired {
return desired, "" // found — all good
}
}
// Desired model not in list — fall back to first available
fallback := models.Data[0].ID
warning = fmt.Sprintf("model %q not available — using %q instead", desired, fallback)
log.Printf("[Orchestrator] WARNING: %s", warning)
return fallback, warning
}
// ─── LLM call with retry ──────────────────────────────────────────────────────
// llmCallResult holds one attempt's outcome.
type llmCallResult struct {
resp *llm.ChatResponse
usedTools bool // whether the call was made with tools enabled
err error
attemptNum int
}
// callLLMWithRetry calls the LLM and retries on error or empty response.
// It also strips tools on the second attempt if the first fails with tools.
func (o *Orchestrator) callLLMWithRetry(
ctx context.Context,
req llm.ChatRequest,
model string,
onRetry func(attempt int, reason string), // optional event callback (may be nil)
) llmCallResult {
policy := o.retry
delay := policy.InitialDelay
maxAttempts := policy.MaxLLMRetries + 1
hasTools := len(req.Tools) > 0
for attempt := 1; attempt <= maxAttempts; attempt++ {
// On attempt > 1, always strip tools (avoid repeated tool-format errors)
useTools := hasTools && attempt == 1
r := req
if !useTools {
r.Tools = nil
r.ToolChoice = ""
}
resp, err := o.llmClient.Chat(ctx, r)
// ── Hard error (network, auth, etc.) ─────────────────────────
if err != nil {
reason := fmt.Sprintf("LLM error (attempt %d/%d): %v", attempt, maxAttempts, err)
log.Printf("[Orchestrator] %s", reason)
if attempt < maxAttempts {
if onRetry != nil {
onRetry(attempt, reason)
}
o.sleep(ctx, delay)
delay = min(delay*2, policy.MaxDelay)
continue
}
return llmCallResult{err: fmt.Errorf("LLM error after %d attempts (model: %s): %w", maxAttempts, model, err), attemptNum: attempt}
}
// ── Context cancelled ─────────────────────────────────────────
if ctx.Err() != nil {
return llmCallResult{err: ctx.Err(), attemptNum: attempt}
}
// ── Empty choices ─────────────────────────────────────────────
if len(resp.Choices) == 0 {
reason := fmt.Sprintf("empty choices (attempt %d/%d)", attempt, maxAttempts)
log.Printf("[Orchestrator] %s", reason)
if attempt < maxAttempts {
if onRetry != nil {
onRetry(attempt, reason)
}
o.sleep(ctx, delay)
delay = min(delay*2, policy.MaxDelay)
continue
}
return llmCallResult{resp: resp, usedTools: useTools, attemptNum: attempt}
}
content := strings.TrimSpace(resp.Choices[0].Message.Content)
finishReason := resp.Choices[0].FinishReason
// ── Empty content AND no tool calls — retry ───────────────────
if policy.RetryOnEmpty &&
content == "" &&
finishReason != "tool_calls" &&
len(resp.Choices[0].Message.ToolCalls) == 0 {
reason := fmt.Sprintf("empty response content (attempt %d/%d, finish_reason=%q)", attempt, maxAttempts, finishReason)
log.Printf("[Orchestrator] %s", reason)
if attempt < maxAttempts {
if onRetry != nil {
onRetry(attempt, reason)
}
o.sleep(ctx, delay)
delay = min(delay*2, policy.MaxDelay)
continue
}
// Exhausted retries — return what we have (even if empty)
log.Printf("[Orchestrator] All %d attempts exhausted — returning empty response", maxAttempts)
return llmCallResult{resp: resp, usedTools: useTools, attemptNum: attempt}
}
// ── Success ───────────────────────────────────────────────────
if attempt > 1 {
log.Printf("[Orchestrator] Succeeded on attempt %d/%d", attempt, maxAttempts)
}
return llmCallResult{resp: resp, usedTools: useTools, attemptNum: attempt}
}
// Should not be reached
return llmCallResult{err: fmt.Errorf("retry loop exited unexpectedly"), attemptNum: maxAttempts}
}
// sleep waits for d, returning early if ctx is cancelled.
func (o *Orchestrator) sleep(ctx context.Context, d time.Duration) {
select {
case <-ctx.Done():
case <-time.After(d):
}
}
// min returns the smaller of two durations.
func min(a, b time.Duration) time.Duration {
if a < b {
return a
}
return b
}
// ─── Core loop (shared by Chat and ChatWithEvents) ────────────────────────────
type loopOptions struct {
messages []Message
overrideModel string
maxIter int
onToolCall func(ToolCallStep) // may be nil
onRetry func(attempt int, reason string) // may be nil
}
func (o *Orchestrator) runLoop(ctx context.Context, opts loopOptions) ChatResult {
if opts.maxIter <= 0 {
opts.maxIter = 10
}
cfg := o.GetConfig()
model := cfg.Model
if overrideModel != "" {
model = overrideModel
if opts.overrideModel != "" {
model = opts.overrideModel
}
log.Printf("[Orchestrator] Chat started: model=%s, messages=%d", model, len(messages))
// Validate model against LLM API — fall back if unavailable (prevents 401/404)
model, modelWarning := o.resolveModel(ctx, model)
log.Printf("[Orchestrator] Loop started: model=%s, messages=%d, maxIter=%d, maxRetries=%d",
model, len(opts.messages), opts.maxIter, o.retry.MaxLLMRetries)
// Build conversation
conv := []llm.Message{
{Role: "system", Content: cfg.SystemPrompt},
}
for _, m := range messages {
for _, m := range opts.messages {
conv = append(conv, llm.Message{Role: m.Role, Content: m.Content})
}
@@ -173,7 +372,7 @@ func (o *Orchestrator) Chat(ctx context.Context, messages []Message, overrideMod
var lastUsage *llm.Usage
var lastModel string
for iter := 0; iter < maxIter; iter++ {
for iter := 0; iter < opts.maxIter; iter++ {
req := llm.ChatRequest{
Model: model,
Messages: conv,
@@ -183,28 +382,22 @@ func (o *Orchestrator) Chat(ctx context.Context, messages []Message, overrideMod
ToolChoice: "auto",
}
resp, err := o.llmClient.Chat(ctx, req)
if err != nil {
// Fallback: try without tools
log.Printf("[Orchestrator] LLM error with tools: %v — retrying without tools", err)
req.Tools = nil
req.ToolChoice = ""
resp2, err2 := o.llmClient.Chat(ctx, req)
if err2 != nil {
return ChatResult{
Success: false,
Error: fmt.Sprintf("LLM error (model: %s): %v", model, err2),
}
// ── LLM call with retry ────────────────────────────────────
callRes := o.callLLMWithRetry(ctx, req, model, opts.onRetry)
if callRes.err != nil {
return ChatResult{
Success: false,
ToolCalls: toolCallSteps,
Model: model,
ModelWarning: modelWarning,
Error: callRes.err.Error(),
}
if len(resp2.Choices) > 0 {
finalResponse = resp2.Choices[0].Message.Content
lastUsage = resp2.Usage
lastModel = resp2.Model
}
break
}
resp := callRes.resp
if len(resp.Choices) == 0 {
log.Printf("[Orchestrator] No choices in response — stopping loop at iter %d", iter)
break
}
@@ -215,19 +408,17 @@ func (o *Orchestrator) Chat(ctx context.Context, messages []Message, overrideMod
lastModel = model
}
// Check if LLM wants to call tools
// ── Tool calls ─────────────────────────────────────────────
if choice.FinishReason == "tool_calls" && len(choice.Message.ToolCalls) > 0 {
// Add assistant message with tool calls to conversation
conv = append(conv, choice.Message)
// Execute each tool call
for _, tc := range choice.Message.ToolCalls {
toolName := tc.Function.Name
argsJSON := tc.Function.Arguments
log.Printf("[Orchestrator] Executing tool: %s args=%s", toolName, argsJSON)
start := time.Now()
result := o.executor.Execute(ctx, toolName, argsJSON)
step := ToolCallStep{
@@ -236,7 +427,6 @@ func (o *Orchestrator) Chat(ctx context.Context, messages []Message, overrideMod
DurationMs: time.Since(start).Milliseconds(),
}
// Parse args for display
var argsMap any
_ = json.Unmarshal([]byte(argsJSON), &argsMap)
step.Args = argsMap
@@ -253,7 +443,10 @@ func (o *Orchestrator) Chat(ctx context.Context, messages []Message, overrideMod
toolCallSteps = append(toolCallSteps, step)
// Add tool result to conversation
if opts.onToolCall != nil {
opts.onToolCall(step)
}
conv = append(conv, llm.Message{
Role: "tool",
Content: toolResultContent,
@@ -265,20 +458,70 @@ func (o *Orchestrator) Chat(ctx context.Context, messages []Message, overrideMod
continue
}
// LLM finished — extract final response
// ── Final response ─────────────────────────────────────────
finalResponse = choice.Message.Content
break
}
return ChatResult{
Success: true,
Response: finalResponse,
ToolCalls: toolCallSteps,
Model: lastModel,
Usage: lastUsage,
Success: true,
Response: finalResponse,
ToolCalls: toolCallSteps,
Model: lastModel,
ModelWarning: modelWarning,
Usage: lastUsage,
}
}
// ─── Public API ───────────────────────────────────────────────────────────────
// Chat runs the full orchestration loop: LLM → tool calls → LLM → response.
func (o *Orchestrator) Chat(ctx context.Context, messages []Message, overrideModel string, maxIter int) ChatResult {
return o.runLoop(ctx, loopOptions{
messages: messages,
overrideModel: overrideModel,
maxIter: maxIter,
})
}
// ChatWithEvents runs the full orchestration loop and calls callbacks for each
// tool execution and each retry attempt. Used for SSE streaming and DB event logging.
func (o *Orchestrator) ChatWithEvents(
ctx context.Context,
messages []Message,
overrideModel string,
maxIter int,
onToolCall func(ToolCallStep),
) ChatResult {
return o.runLoop(ctx, loopOptions{
messages: messages,
overrideModel: overrideModel,
maxIter: maxIter,
onToolCall: onToolCall,
})
}
// ChatWithEventsAndRetry is the full-featured variant that also reports retry
// attempts through onRetry so they can be streamed to the client.
func (o *Orchestrator) ChatWithEventsAndRetry(
ctx context.Context,
messages []Message,
overrideModel string,
maxIter int,
onToolCall func(ToolCallStep),
onRetry func(attempt int, reason string),
) ChatResult {
return o.runLoop(ctx, loopOptions{
messages: messages,
overrideModel: overrideModel,
maxIter: maxIter,
onToolCall: onToolCall,
onRetry: onRetry,
})
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
// listAgentsFn is injected into the tool executor to list agents from DB.
func (o *Orchestrator) listAgentsFn() ([]map[string]any, error) {
if o.database == nil {

View File

@@ -3,6 +3,7 @@
package tools
import (
"bytes"
"context"
"encoding/json"
"fmt"
@@ -13,6 +14,8 @@ import (
"path/filepath"
"strings"
"time"
"git.softuniq.eu/UniqAI/GoClaw/gateway/internal/db"
)
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -175,6 +178,8 @@ type Executor struct {
httpClient *http.Client
// agentListFn is injected to avoid circular dependency with orchestrator
agentListFn func() ([]map[string]any, error)
// database is used for delegate_to_agent to look up service address
database *db.DB
}
func NewExecutor(projectRoot string, agentListFn func() ([]map[string]any, error)) *Executor {
@@ -187,6 +192,11 @@ func NewExecutor(projectRoot string, agentListFn func() ([]map[string]any, error
}
}
// SetDatabase injects the DB reference so delegate_to_agent can resolve agent addresses.
func (e *Executor) SetDatabase(database *db.DB) {
e.database = database
}
// Execute dispatches a tool call by name.
func (e *Executor) Execute(ctx context.Context, toolName string, argsJSON string) ToolResult {
start := time.Now()
@@ -215,7 +225,7 @@ func (e *Executor) Execute(ctx context.Context, toolName string, argsJSON string
case "list_agents":
result, execErr = e.listAgents()
case "delegate_to_agent":
result, execErr = e.delegateToAgent(args)
result, execErr = e.delegateToAgent(ctx, args)
default:
return ToolResult{Success: false, Error: fmt.Sprintf("unknown tool: %s", toolName), DurationMs: ms(start)}
}
@@ -446,21 +456,89 @@ func (e *Executor) listAgents() (any, error) {
return map[string]any{"agents": agents, "count": len(agents)}, nil
}
func (e *Executor) delegateToAgent(args map[string]any) (any, error) {
agentID, _ := args["agentId"].(float64)
message, _ := args["message"].(string)
if message == "" {
return nil, fmt.Errorf("message is required")
// delegateToAgent sends a task to an agent's container via HTTP.
// It resolves the agent's service name and port from DB, then POSTs to /task.
// If the agent container is not running (no servicePort), falls back to a stub.
func (e *Executor) delegateToAgent(ctx context.Context, args map[string]any) (any, error) {
agentIDf, _ := args["agentId"].(float64)
agentID := int(agentIDf)
task, _ := args["task"].(string)
if task == "" {
task, _ = args["message"].(string) // backward compat
}
// Delegation is handled at orchestrator level; here we return a placeholder
if task == "" {
return nil, fmt.Errorf("task (or message) is required")
}
callbackURL, _ := args["callbackUrl"].(string)
async, _ := args["async"].(bool)
// Resolve agent container address from DB
if e.database != nil {
cfg, err := e.database.GetAgentByID(agentID)
if err == nil && cfg != nil && cfg.ServicePort > 0 && cfg.ContainerStatus == "running" {
// Agent is deployed — call its container via overlay DNS
// Docker Swarm DNS: service name resolves inside overlay network
agentURL := fmt.Sprintf("http://%s:%d", cfg.ServiceName, cfg.ServicePort)
if async {
return e.postAgentTask(ctx, agentURL, agentID, task, callbackURL)
}
return e.postAgentChat(ctx, agentURL, agentID, task)
}
}
// Fallback: agent not deployed yet — return informational response
return map[string]any{
"delegated": true,
"agentId": int(agentID),
"message": message,
"note": "Agent delegation queued — response will be processed in next iteration",
"delegated": false,
"agentId": agentID,
"task": task,
"note": fmt.Sprintf("Agent %d is not running (containerStatus != running). Deploy it first via Web Panel.", agentID),
}, nil
}
// postAgentTask POSTs to agent's /task endpoint (async, returns task_id).
func (e *Executor) postAgentTask(ctx context.Context, agentURL string, fromAgentID int, task, callbackURL string) (any, error) {
payload, _ := json.Marshal(map[string]any{
"input": task,
"from_agent_id": fromAgentID,
"callback_url": callbackURL,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, agentURL+"/task", bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("delegate build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := e.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("delegate HTTP error: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result map[string]any
_ = json.Unmarshal(body, &result)
return result, nil
}
// postAgentChat POSTs to agent's /chat endpoint (sync, waits for response).
func (e *Executor) postAgentChat(ctx context.Context, agentURL string, _ int, task string) (any, error) {
payload, _ := json.Marshal(map[string]any{
"messages": []map[string]string{{"role": "user", "content": task}},
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, agentURL+"/chat", bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("delegate build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := e.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("delegate HTTP error: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result map[string]any
_ = json.Unmarshal(body, &result)
return result, nil
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
func (e *Executor) resolvePath(path string) string {

View File

@@ -9,6 +9,8 @@ import { createContext } from "./context";
import { serveStatic, setupVite } from "./vite";
import { seedDefaults } from "../seed";
const GATEWAY_BASE_URL = process.env.GATEWAY_URL ?? "http://localhost:18789";
function isPortAvailable(port: number): Promise<boolean> {
return new Promise(resolve => {
const server = net.createServer();
@@ -36,6 +38,61 @@ async function startServer() {
app.use(express.urlencoded({ limit: "50mb", extended: true }));
// OAuth callback under /api/oauth/callback
registerOAuthRoutes(app);
// ── SSE proxy: POST /api/orchestrator/stream → Go Gateway SSE ──────────────
// This proxies the SSE stream from the Go Gateway to the browser.
// We need to do it at the Express level because tRPC doesn't support SSE yet.
app.post("/api/orchestrator/stream", async (req, res) => {
try {
const gwRes = await fetch(`${GATEWAY_BASE_URL}/api/orchestrator/stream`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req.body),
});
if (!gwRes.ok || !gwRes.body) {
res.status(gwRes.status).json({ error: "Gateway stream error" });
return;
}
// Set SSE headers
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("Access-Control-Allow-Origin", "*");
res.flushHeaders();
// Pipe the response body — use a single TextDecoder with stream:true
// so multi-byte UTF-8 sequences (Cyrillic, CJK, etc.) are never split
const reader = gwRes.body.getReader();
const decoder = new TextDecoder("utf-8");
const pump = async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(decoder.decode(value, { stream: true }));
// @ts-ignore
if (res.flush) (res as any).flush();
}
} catch {
// Client disconnected
} finally {
res.end();
}
};
// Abort if client disconnects
req.on("close", () => reader.cancel());
await pump();
} catch (err: any) {
if (!res.headersSent) {
res.status(502).json({ error: `Gateway unreachable: ${err.message}` });
}
}
});
// tRPC API
app.use(
"/api/trpc",

View File

@@ -39,6 +39,7 @@ export interface GatewayChatResult {
response: string;
toolCalls: GatewayToolCallStep[];
model?: string;
modelWarning?: string;
usage?: {
prompt_tokens: number;
completion_tokens: number;
@@ -120,6 +121,56 @@ export async function isGatewayAvailable(): Promise<boolean> {
}
}
// ─── Model Info ───────────────────────────────────────────────────────────────
export interface OllamaModelInfo {
contextLength: number;
parameterSize?: string;
family?: string;
quantization?: string;
capabilities?: string[];
}
/**
* Fetch model details from Ollama /api/show (context_length, parameters, etc.)
* Uses the base URL and API key from environment.
*/
export async function getOllamaModelInfo(modelId: string): Promise<OllamaModelInfo | null> {
const baseUrl = (process.env.OLLAMA_BASE_URL ?? "https://ollama.com/v1").replace(/\/v1\/?$/, "");
const apiKey = process.env.OLLAMA_API_KEY ?? "";
try {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const res = await fetch(`${baseUrl}/api/show`, {
method: "POST",
headers,
body: JSON.stringify({ model: modelId }),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) return null;
const data = await res.json();
// context_length is under model_info with key "{arch}.context_length"
let contextLength = 0;
if (data.model_info) {
for (const [k, v] of Object.entries(data.model_info)) {
if (k.endsWith(".context_length") && typeof v === "number") {
contextLength = v;
break;
}
}
}
return {
contextLength,
parameterSize: data.details?.parameter_size,
family: data.details?.family,
quantization: data.details?.quantization_level,
capabilities: data.capabilities,
};
} catch {
return null;
}
}
// ─── Orchestrator ─────────────────────────────────────────────────────────────
/**
@@ -369,3 +420,419 @@ export async function getGatewayNodeStats(): Promise<GatewayNodeStatsResult | nu
return null;
}
}
// ─── Persistent Chat Sessions ─────────────────────────────────────────────────
export interface GatewayChatEvent {
id: number;
sessionId: string;
seq: number;
eventType: "thinking" | "tool_call" | "delta" | "done" | "error";
content: string;
toolName: string;
toolArgs: string; // JSON string
toolResult: string;
toolSuccess: boolean;
durationMs: number;
model: string;
usageJson: string; // JSON string
errorMsg: string;
createdAt: string;
}
export interface GatewayChatSession {
id: number;
sessionId: string;
agentId: number;
status: "running" | "done" | "error";
userMessage: string;
finalResponse: string;
model: string;
totalTokens: number;
processingTimeMs: number;
errorMessage: string;
createdAt: string;
updatedAt: string;
}
/**
* Start a persistent background chat session.
* Returns the sessionId immediately; processing continues on the server.
*/
export async function startChatSession(
messages: GatewayMessage[],
sessionId: string,
model?: string,
maxIter = 10
): Promise<{ sessionId: string; status: string } | null> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/chat/session`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages, sessionId, model, maxIter }),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
/**
* Get session metadata (status, finalResponse, tokens…).
*/
export async function getChatSession(sessionId: string): Promise<GatewayChatSession | null> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/chat/session/${sessionId}`, {
signal: AbortSignal.timeout(5_000),
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
/**
* Fetch events for a session with seq > afterSeq.
* Returns { sessionId, status, events[] }.
*/
export async function getChatEvents(
sessionId: string,
afterSeq = 0
): Promise<{ sessionId: string; status: string; events: GatewayChatEvent[] } | null> {
try {
const res = await fetch(
`${GATEWAY_BASE_URL}/api/chat/session/${sessionId}/events?after=${afterSeq}`,
{ signal: AbortSignal.timeout(5_000) }
);
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
/**
* List recent sessions (default last 50).
*/
export async function listChatSessions(
limit = 50
): Promise<{ sessions: GatewayChatSession[] } | null> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/chat/sessions?limit=${limit}`, {
signal: AbortSignal.timeout(5_000),
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
// ─── Real Docker Swarm API ────────────────────────────────────────────────────
export interface SwarmNodeInfo {
id: string;
hostname: string;
role: "manager" | "worker";
state: string;
availability: string;
ip: string;
os: string;
arch: string;
cpuCores: number;
memTotalMB: number;
dockerVersion: string;
isLeader: boolean;
managerAddr?: string;
labels: Record<string, string>;
updatedAt: string;
}
export interface SwarmServiceInfo {
id: string;
name: string;
image: string;
mode: "replicated" | "global";
desiredReplicas: number;
runningTasks: number;
desiredTasks: number;
labels: Record<string, string> | null;
updatedAt: string;
ports: string[] | null;
isGoClaw: boolean;
}
export interface SwarmTaskInfo {
id: string;
serviceId: string;
nodeId: string;
slot: number;
state: string;
message: string;
containerId: string;
updatedAt: string;
}
export interface SwarmInfoResult {
nodeId: string;
localNodeState: string;
isManager: boolean;
managers: number;
nodes: number;
managerAddr: string;
joinTokens?: { worker: string; manager: string };
}
export interface JoinTokenResult {
role: string;
token: string;
managerAddr: string;
joinCommand: string;
}
/** Get overall swarm state + join tokens */
export async function getSwarmInfo(): Promise<SwarmInfoResult | null> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/info`, {
signal: AbortSignal.timeout(QUICK_TIMEOUT_MS),
});
if (!res.ok) return null;
return res.json();
} catch { return null; }
}
/** List all swarm nodes with live status */
export async function listSwarmNodes(): Promise<{ nodes: SwarmNodeInfo[]; count: number } | null> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/nodes`, {
signal: AbortSignal.timeout(QUICK_TIMEOUT_MS),
});
if (!res.ok) return null;
return res.json();
} catch { return null; }
}
/** List all swarm services */
export async function listSwarmServices(): Promise<{ services: SwarmServiceInfo[]; count: number } | null> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/services`, {
signal: AbortSignal.timeout(QUICK_TIMEOUT_MS),
});
if (!res.ok) return null;
const data = await res.json();
// Normalise null fields so the frontend never has to worry
const services: SwarmServiceInfo[] = (data.services ?? []).map((s: any) => ({
...s,
ports: s.ports ?? [],
labels: s.labels ?? {},
}));
return { services, count: services.length };
} catch { return null; }
}
/** Get tasks for a specific service */
export async function getServiceTasks(serviceId: string): Promise<{ tasks: SwarmTaskInfo[] } | null> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/services/${serviceId}/tasks`, {
signal: AbortSignal.timeout(QUICK_TIMEOUT_MS),
});
if (!res.ok) return null;
return res.json();
} catch { return null; }
}
/** Scale a service to N replicas */
export async function scaleSwarmService(serviceId: string, replicas: number): Promise<boolean> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/services/${serviceId}/scale`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ replicas }),
signal: AbortSignal.timeout(QUICK_TIMEOUT_MS),
});
return res.ok;
} catch { return false; }
}
/** Get join token and command */
export async function getSwarmJoinToken(role: "worker" | "manager"): Promise<JoinTokenResult | null> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/join-token?role=${role}`, {
signal: AbortSignal.timeout(QUICK_TIMEOUT_MS),
});
if (!res.ok) return null;
return res.json();
} catch { return null; }
}
/** Execute a shell command on the host system */
export async function execSwarmShell(command: string): Promise<{ output: string; success: boolean; error?: string } | null> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/shell`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ command }),
signal: AbortSignal.timeout(35_000),
});
if (!res.ok) return null;
return res.json();
} catch { return null; }
}
export interface JoinNodeResult {
ok: boolean;
output?: string;
error?: string;
step?: string;
note?: string;
host?: string;
role?: string;
command?: string;
}
/**
* SSH into a remote host and run "docker swarm join ..." to add it to the cluster.
* The gateway fetches the current join token automatically.
*/
export async function joinSwarmNodeViaSSH(opts: {
host: string;
port?: number;
user: string;
password: string;
role?: "worker" | "manager";
}): Promise<JoinNodeResult | null> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/join-node`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(opts),
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) return null;
return res.json();
} catch { return null; }
}
/**
* Test SSH connectivity and Docker availability on a remote host (no swarm join).
*/
export async function testSSHConnection(opts: {
host: string;
port?: number;
user: string;
password: string;
}): Promise<{ ok: boolean; sshOk?: boolean; dockerOk?: boolean; dockerVersion?: string; error?: string; step?: string } | null> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/ssh-test`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(opts),
signal: AbortSignal.timeout(20_000),
});
if (!res.ok) return null;
return res.json();
} catch { return null; }
}
/** Add a label to a swarm node */
export async function addSwarmNodeLabel(nodeId: string, key: string, value: string): Promise<boolean> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/nodes/${nodeId}/label`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, value }),
signal: AbortSignal.timeout(QUICK_TIMEOUT_MS),
});
return res.ok;
} catch { return false; }
}
/** Set node availability (active|pause|drain) */
export async function setNodeAvailability(nodeId: string, availability: "active" | "pause" | "drain"): Promise<boolean> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/nodes/${nodeId}/availability`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ availability }),
signal: AbortSignal.timeout(QUICK_TIMEOUT_MS),
});
return res.ok;
} catch { return false; }
}
/** Deploy a new agent as a Swarm service */
export async function createAgentService(opts: {
name: string; image: string; replicas: number; env?: string[]; port?: number; networks?: string[];
}): Promise<{ ok: boolean; serviceId?: string; name?: string } | null> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/services/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(opts),
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) return null;
return res.json();
} catch { return null; }
}
/** Remove (stop) a Swarm service by ID or name */
export async function removeSwarmService(serviceId: string): Promise<boolean> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/services/${encodeURIComponent(serviceId)}`, {
method: "DELETE",
signal: AbortSignal.timeout(10_000),
});
return res.ok;
} catch { return false; }
}
export interface SwarmAgentInfo {
id: string;
name: string;
image: string;
desiredReplicas: number;
runningTasks: number;
lastActivity: string;
idleMinutes: number;
isGoClaw: boolean;
}
/** List all GoClaw agent services with idle time info */
export async function listSwarmAgents(): Promise<{ agents: SwarmAgentInfo[]; count: number } | null> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/agents`, {
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) return null;
return res.json();
} catch { return null; }
}
/** Start (scale-up) an agent service */
export async function startSwarmAgent(name: string, replicas = 1): Promise<boolean> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/agents/${encodeURIComponent(name)}/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ replicas }),
signal: AbortSignal.timeout(10_000),
});
return res.ok;
} catch { return false; }
}
/** Stop (scale-to-0) an agent service */
export async function stopSwarmAgent(name: string): Promise<boolean> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/agents/${encodeURIComponent(name)}/stop`, {
method: "POST",
signal: AbortSignal.timeout(10_000),
});
return res.ok;
} catch { return false; }
}

View File

@@ -1,3 +1,27 @@
/**
* server/index.ts — LEGACY STATIC-ONLY ENTRY POINT
*
* @deprecated This file is NOT used in production or development.
*
* The real application server is: server/_core/index.ts
* - Registers tRPC router (/api/trpc)
* - Registers OAuth routes (/api/oauth/callback)
* - Runs Vite middleware in development
* - Serves pre-built static assets in production (dist/public)
* - Seeds default agents on startup
*
* This file was the original minimal static server created before
* tRPC integration. It has NO tRPC routes, NO OAuth, NO seed logic.
*
* Build entrypoint (tsconfig/vite.config) → server/_core/index.ts
* Dockerfile CMD → node dist/index.js (compiled from _core/index.ts)
*
* ⚠️ DO NOT add business logic here.
* DO NOT run this file directly in production.
* It is kept as a historical artefact and may be removed in a future
* cleanup phase (see todo.md Phase 17 — technical debt).
*/
import express from "express";
import { createServer } from "http";
import path from "path";
@@ -18,15 +42,15 @@ async function startServer() {
app.use(express.static(staticPath));
// Handle client-side routing - serve index.html for all routes
// Handle client-side routing serve index.html for all routes
app.get("*", (_req, res) => {
res.sendFile(path.join(staticPath, "index.html"));
});
const port = process.env.PORT || 3000;
server.listen(port, () => {
console.log(`Server running on http://localhost:${port}/`);
console.log(`[LEGACY] Static-only server running on http://localhost:${port}/`);
console.log("[LEGACY] WARNING: This server has no tRPC routes. Use server/_core/index.ts instead.");
});
}

249
server/providers.ts Normal file
View File

@@ -0,0 +1,249 @@
/**
* LLM Providers — CRUD + шифрование API-ключей + синхронизация с Go Gateway.
*
* Ключи шифруются AES-256-GCM с помощью JWT_SECRET.
* Gateway перечитывает активного провайдера через GET /api/providers/active
* при каждом запросе (или кэширует на 30 сек).
*/
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto";
import { getDb } from "./db";
import { llmProviders, type LlmProvider, type InsertLlmProvider } from "../drizzle/schema";
import { eq, and } from "drizzle-orm";
import { ENV } from "./_core/env";
// ─── Encryption helpers ───────────────────────────────────────────────────────
const ALGO = "aes-256-gcm";
function getDerivedKey(): Buffer {
const secret = ENV.cookieSecret || "goclaw-default-secret-change-me";
return scryptSync(secret, "goclaw-llm-salt", 32);
}
export function encryptKey(plaintext: string): string {
if (!plaintext) return "";
const iv = randomBytes(12);
const key = getDerivedKey();
const cipher = createCipheriv(ALGO, key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
// iv(12) + tag(16) + ciphertext — base64 encoded
return Buffer.concat([iv, tag, encrypted]).toString("base64");
}
export function decryptKey(encoded: string): string {
if (!encoded) return "";
try {
const buf = Buffer.from(encoded, "base64");
const iv = buf.subarray(0, 12);
const tag = buf.subarray(12, 28);
const ciphertext = buf.subarray(28);
const key = getDerivedKey();
const decipher = createDecipheriv(ALGO, key, iv);
decipher.setAuthTag(tag);
return decipher.update(ciphertext) + decipher.final("utf8");
} catch {
return "";
}
}
// ─── DB operations ────────────────────────────────────────────────────────────
/** Returns the currently active provider with decrypted key. */
export async function getActiveProvider(): Promise<{
id: number;
name: string;
baseUrl: string;
apiKey: string;
apiKeyHint: string;
modelDefault: string | null;
} | null> {
const db = await getDb();
if (!db) return null;
const rows = await db
.select()
.from(llmProviders)
.where(eq(llmProviders.isActive, true))
.limit(1);
if (!rows.length) return null;
const p = rows[0];
return {
id: p.id,
name: p.name,
baseUrl: p.baseUrl,
apiKey: decryptKey(p.apiKeyEncrypted ?? ""),
apiKeyHint: p.apiKeyHint ?? "",
modelDefault: p.modelDefault ?? null,
};
}
/** Returns all providers (keys masked). */
export async function listProviders(): Promise<Array<{
id: number;
name: string;
baseUrl: string;
apiKeyHint: string;
isActive: boolean;
isDefault: boolean;
modelDefault: string | null;
notes: string | null;
createdAt: Date;
updatedAt: Date;
}>> {
const db = await getDb();
if (!db) return [];
const rows = await db.select().from(llmProviders).orderBy(llmProviders.id);
return rows.map((p) => ({
id: p.id,
name: p.name,
baseUrl: p.baseUrl,
apiKeyHint: p.apiKeyHint ?? "",
isActive: p.isActive,
isDefault: p.isDefault,
modelDefault: p.modelDefault ?? null,
notes: p.notes ?? null,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
}));
}
/** Creates a new provider. */
export async function createProvider(data: {
name: string;
baseUrl: string;
apiKey: string;
modelDefault?: string;
notes?: string;
setActive?: boolean;
}): Promise<number> {
const db = await getDb();
if (!db) throw new Error("DB not connected");
const encrypted = encryptKey(data.apiKey);
const hint = data.apiKey ? data.apiKey.slice(0, 8) : "";
// If setActive — deactivate all others first
if (data.setActive) {
await db.update(llmProviders).set({ isActive: false });
}
const [result] = await db.insert(llmProviders).values({
name: data.name,
baseUrl: data.baseUrl,
apiKeyEncrypted: encrypted,
apiKeyHint: hint,
isActive: data.setActive ?? false,
isDefault: data.setActive ?? false,
modelDefault: data.modelDefault ?? null,
notes: data.notes ?? null,
} as InsertLlmProvider);
return (result as any).insertId as number;
}
/** Updates a provider. Pass apiKey="" to keep existing. */
export async function updateProvider(
id: number,
data: {
name?: string;
baseUrl?: string;
apiKey?: string;
modelDefault?: string;
notes?: string;
isActive?: boolean;
}
): Promise<void> {
const db = await getDb();
if (!db) throw new Error("DB not connected");
const updates: Partial<LlmProvider> = {};
if (data.name !== undefined) updates.name = data.name;
if (data.baseUrl !== undefined) updates.baseUrl = data.baseUrl;
if (data.modelDefault !== undefined) updates.modelDefault = data.modelDefault;
if (data.notes !== undefined) updates.notes = data.notes;
if (data.apiKey !== undefined && data.apiKey !== "") {
updates.apiKeyEncrypted = encryptKey(data.apiKey);
updates.apiKeyHint = data.apiKey.slice(0, 8);
}
if (data.isActive !== undefined) {
// Deactivate all others first
if (data.isActive) {
await db.update(llmProviders).set({ isActive: false });
}
updates.isActive = data.isActive;
}
await db.update(llmProviders).set(updates as any).where(eq(llmProviders.id, id));
}
/** Deletes a provider. Cannot delete the active one. */
export async function deleteProvider(id: number): Promise<{ ok: boolean; error?: string }> {
const db = await getDb();
if (!db) return { ok: false, error: "DB not connected" };
const rows = await db.select().from(llmProviders).where(
and(eq(llmProviders.id, id), eq(llmProviders.isActive, true))
).limit(1);
if (rows.length > 0) {
return { ok: false, error: "Cannot delete the active provider. Set another as active first." };
}
await db.delete(llmProviders).where(eq(llmProviders.id, id));
return { ok: true };
}
/** Activates a provider and notifies Go Gateway to reload its config with the decrypted key. */
export async function activateProvider(id: number): Promise<void> {
await updateProvider(id, { isActive: true });
// Notify gateway to reload — pass decrypted key so Go can use it without sharing crypto logic
await notifyGatewayReload();
}
/**
* Reads the active provider (with decrypted key) and pushes it to the Go Gateway.
* Called after any activation/seed so the gateway always has a fresh key.
*/
export async function notifyGatewayReload(): Promise<void> {
try {
const provider = await getActiveProvider();
if (!provider) return;
const gwUrl = process.env.GATEWAY_URL || "http://goclaw-gateway:18789";
await fetch(`${gwUrl}/api/providers/reload`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: provider.name,
baseUrl: provider.baseUrl,
apiKey: provider.apiKey,
modelDefault: provider.modelDefault,
}),
});
console.log(`[Providers] Gateway reloaded with provider: ${provider.name}`);
} catch (err) {
console.warn("[Providers] Failed to notify gateway:", err);
}
}
/**
* Seeds the default provider from env vars (runs once on startup if table empty).
*/
export async function seedDefaultProvider(): Promise<void> {
const db = await getDb();
if (!db) return;
const existing = await db.select().from(llmProviders).limit(1);
if (existing.length > 0) return; // already seeded
const baseUrl = process.env.OLLAMA_BASE_URL || "https://ollama.com/v1";
const apiKey = process.env.OLLAMA_API_KEY || process.env.LLM_API_KEY || "";
let name = "Ollama Cloud";
if (baseUrl.includes("openai.com")) name = "OpenAI";
else if (baseUrl.includes("groq.com")) name = "Groq";
else if (baseUrl.includes("mistral.ai")) name = "Mistral";
await createProvider({ name, baseUrl, apiKey, setActive: true, modelDefault: "qwen2.5:7b" });
console.log(`[Providers] Seeded default provider: ${name} (${baseUrl})`);
// Push the active provider to the gateway immediately after seeding
await notifyGatewayReload();
}

View File

@@ -15,6 +15,27 @@ import {
isGatewayAvailable,
getGatewayNodes,
getGatewayNodeStats,
startChatSession,
getChatSession,
getChatEvents,
listChatSessions,
getSwarmInfo,
listSwarmNodes,
listSwarmServices,
getServiceTasks,
scaleSwarmService,
getSwarmJoinToken,
execSwarmShell,
addSwarmNodeLabel,
setNodeAvailability,
createAgentService,
removeSwarmService,
listSwarmAgents,
startSwarmAgent,
stopSwarmAgent,
getOllamaModelInfo,
joinSwarmNodeViaSSH,
testSSHConnection,
} from "./gateway-proxy";
// Shared system user id for non-authenticated agent management
@@ -31,6 +52,113 @@ export const appRouter = router({
}),
}),
/**
* LLM Providers — full CRUD backed by DB (llmProviders table).
* API keys stored encrypted with AES-256-GCM; never returned in plaintext to frontend.
*/
providers: router({
/** List all providers (keys masked). */
list: publicProcedure.query(async () => {
const { listProviders } = await import("./providers");
return listProviders();
}),
/** Create a new provider. */
create: publicProcedure
.input(z.object({
name: z.string().min(1),
baseUrl: z.string().url(),
apiKey: z.string(),
modelDefault: z.string().optional(),
notes: z.string().optional(),
setActive: z.boolean().default(false),
}))
.mutation(async ({ input }) => {
const { createProvider } = await import("./providers");
const id = await createProvider(input);
return { id };
}),
/** Update a provider (pass apiKey="" to keep existing key). */
update: publicProcedure
.input(z.object({
id: z.number(),
name: z.string().optional(),
baseUrl: z.string().url().optional(),
apiKey: z.string().optional(),
modelDefault: z.string().optional(),
notes: z.string().optional(),
isActive: z.boolean().optional(),
}))
.mutation(async ({ input }) => {
const { updateProvider } = await import("./providers");
const { id, ...rest } = input;
await updateProvider(id, rest);
return { ok: true };
}),
/** Delete a provider (cannot delete the active one). */
delete: publicProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const { deleteProvider } = await import("./providers");
return deleteProvider(input.id);
}),
/** Activate a provider and signal the gateway to reload its config. */
activate: publicProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const { activateProvider } = await import("./providers");
await activateProvider(input.id);
return { ok: true };
}),
}),
/**
* System config — returns active LLM provider config (key masked).
* Used by Settings page and AgentDetailModal.
*/
config: router({
providers: publicProcedure.query(async () => {
const { listProviders, getActiveProvider } = await import("./providers");
// Try DB first
try {
const rows = await listProviders();
if (rows.length > 0) {
return {
providers: rows.map((p) => ({
id: p.id.toString(),
name: p.name,
baseUrl: p.baseUrl,
hasKey: !!p.apiKeyHint,
maskedKey: p.apiKeyHint ? `${p.apiKeyHint}${"*".repeat(24)}` : "",
isActive: p.isActive,
modelDefault: p.modelDefault,
})),
};
}
} catch { /* fallback below */ }
// Fallback: read from env
const { ENV } = await import("./_core/env");
const baseUrl = ENV.ollamaBaseUrl || "https://ollama.com/v1";
const apiKey = ENV.ollamaApiKey || "";
const hasKey = apiKey.length > 0;
const maskedKey = hasKey ? `${apiKey.slice(0, 8)}${"*".repeat(Math.max(0, apiKey.length - 8))}` : "";
let providerName = "Ollama Cloud";
if (baseUrl.includes("openai.com")) providerName = "OpenAI";
else if (baseUrl.includes("anthropic.com")) providerName = "Anthropic";
else if (baseUrl.includes("groq.com")) providerName = "Groq";
else if (baseUrl.includes("mistral.ai")) providerName = "Mistral";
else if (!baseUrl.includes("ollama.com")) providerName = "Custom";
return {
providers: [{ id: "primary", name: providerName, baseUrl, hasKey, maskedKey, isActive: true, modelDefault: null }],
};
}),
}),
/**
* Ollama API — серверный прокси для безопасного доступа
* Приоритет: Go Gateway → прямой Ollama
@@ -80,6 +208,14 @@ export const appRouter = router({
}
}),
/** Fetch model details (context_length, family, quantization…) from Ollama /api/show */
modelInfo: publicProcedure
.input(z.object({ modelId: z.string() }))
.query(async ({ input }) => {
const info = await getOllamaModelInfo(input.modelId);
return info ?? { contextLength: 0 };
}),
chat: publicProcedure
.input(
z.object({
@@ -280,6 +416,20 @@ export const appRouter = router({
status: "success",
});
// Save metric
const { saveMetric } = await import("./agents");
await saveMetric(input.agentId, {
userMessage: input.message,
agentResponse: response,
inputTokens: result.usage?.prompt_tokens ?? 0,
outputTokens: result.usage?.completion_tokens ?? 0,
totalTokens: result.usage?.total_tokens ?? 0,
processingTimeMs,
status: "success",
toolsCalled: [],
model: result.model ?? agent.model,
}).catch(() => {}); // non-fatal
return {
success: true as const,
response,
@@ -288,12 +438,22 @@ export const appRouter = router({
processingTimeMs,
};
} catch (err: any) {
const processingTimeMs = Date.now() - startTime;
await saveHistory(input.agentId, {
userMessage: input.message,
agentResponse: null,
conversationId: input.conversationId,
status: "error",
});
const { saveMetric } = await import("./agents");
saveMetric(input.agentId, {
userMessage: input.message,
processingTimeMs,
status: "error",
errorMessage: err.message,
toolsCalled: [],
model: agent.model,
}).catch(() => {}); // non-fatal
return {
success: false as const,
response: "",
@@ -588,6 +748,188 @@ export const appRouter = router({
.mutation(async ({ input }) => {
return executeGatewayTool(input.tool, input.args);
}),
// ── Persistent Background Chat Sessions ──────────────────────────────────
// These routes start a session on the Go Gateway and return immediately.
// The Go Gateway runs the orchestrator in a detached goroutine — survives
// HTTP disconnects, page reloads, and laptop sleep.
// The client polls getEvents until status === "done" | "error".
/** Start a background session. Returns { sessionId, status:"running" }. */
startSession: publicProcedure
.input(
z.object({
messages: z.array(
z.object({
role: z.enum(["user", "assistant", "system"]),
content: z.string(),
})
),
sessionId: z.string(),
model: z.string().optional(),
maxIter: z.number().min(1).max(20).optional(),
})
)
.mutation(async ({ input }) => {
const result = await startChatSession(
input.messages,
input.sessionId,
input.model,
input.maxIter ?? 10
);
if (!result) throw new Error("Gateway unavailable — cannot start background session");
return result;
}),
/** Get session metadata (status, finalResponse, tokens, model…). */
getSession: publicProcedure
.input(z.object({ sessionId: z.string() }))
.query(async ({ input }) => {
const sess = await getChatSession(input.sessionId);
if (!sess) throw new Error("Session not found");
return sess;
}),
/** Get events for a session after a given seq number (incremental polling). */
getEvents: publicProcedure
.input(
z.object({
sessionId: z.string(),
afterSeq: z.number().min(0).default(0),
})
)
.query(async ({ input }) => {
const result = await getChatEvents(input.sessionId, input.afterSeq);
if (!result) return { sessionId: input.sessionId, status: "unknown", events: [] };
return result;
}),
/** List recent sessions (default last 50). */
listSessions: publicProcedure
.input(z.object({ limit: z.number().min(1).max(200).default(50) }))
.query(async ({ input }) => {
const result = await listChatSessions(input.limit);
return result?.sessions ?? [];
}),
}),
/**
* Tasks — persistent task board for chat sessions.
* Both the orchestrator and agents can create/update tasks.
*/
tasks: router({
/** List all tasks, optionally filtered by session */
list: publicProcedure
.input(z.object({
sessionId: z.string().optional(),
status: z.enum(["pending", "in_progress", "completed", "failed", "blocked"]).optional(),
limit: z.number().min(1).max(200).default(50),
}).optional())
.query(async ({ input }) => {
const db = await getDb();
if (!db) return [];
const { chatTasks } = await import("../drizzle/schema");
const { desc, eq, and } = await import("drizzle-orm");
let query = db.select().from(chatTasks).orderBy(desc(chatTasks.createdAt)).limit(input?.limit ?? 50);
// Filtering done in JS for simplicity
const rows = await query;
return rows
.filter((r) => !input?.sessionId || r.sessionId === input.sessionId)
.filter((r) => !input?.status || r.status === input.status);
}),
/** Create a new task */
create: publicProcedure
.input(z.object({
taskId: z.string(),
content: z.string().min(1),
priority: z.enum(["critical", "high", "medium", "low"]).default("medium"),
createdBy: z.string().default("user"),
assignedTo: z.string().optional(),
sessionId: z.string().optional(),
}))
.mutation(async ({ input }) => {
const db = await getDb();
if (!db) throw new Error("Database not available");
const { chatTasks } = await import("../drizzle/schema");
await db.insert(chatTasks).values({
taskId: input.taskId,
content: input.content,
priority: input.priority,
createdBy: input.createdBy,
assignedTo: input.assignedTo,
sessionId: input.sessionId,
});
return { ok: true, taskId: input.taskId };
}),
/** Update task status */
updateStatus: publicProcedure
.input(z.object({
taskId: z.string(),
status: z.enum(["pending", "in_progress", "completed", "failed", "blocked"]),
lastError: z.string().optional(),
}))
.mutation(async ({ input }) => {
const db = await getDb();
if (!db) throw new Error("Database not available");
const { chatTasks } = await import("../drizzle/schema");
const { eq, sql } = await import("drizzle-orm");
const updateSet: Record<string, any> = { status: input.status };
if (input.status === "in_progress") {
updateSet.startedAt = new Date();
}
if (input.status === "completed") {
updateSet.completedAt = new Date();
}
if (input.lastError) {
updateSet.lastError = input.lastError;
updateSet.retryCount = sql`${chatTasks.retryCount} + 1`;
}
await db.update(chatTasks).set(updateSet).where(eq(chatTasks.taskId, input.taskId));
return { ok: true };
}),
/** Add a subtask to a task */
addSubtask: publicProcedure
.input(z.object({
taskId: z.string(),
subtask: z.object({
id: z.string(),
content: z.string().min(1),
createdBy: z.string().default("orchestrator"),
}),
}))
.mutation(async ({ input }) => {
const db = await getDb();
if (!db) throw new Error("Database not available");
const { chatTasks } = await import("../drizzle/schema");
const { eq } = await import("drizzle-orm");
const [task] = await db.select().from(chatTasks).where(eq(chatTasks.taskId, input.taskId)).limit(1);
if (!task) throw new Error("Task not found");
const subs = (task.subtasks ?? []) as any[];
subs.push({
id: input.subtask.id,
content: input.subtask.content,
status: "pending",
createdBy: input.subtask.createdBy,
createdAt: Date.now(),
});
await db.update(chatTasks).set({ subtasks: subs }).where(eq(chatTasks.taskId, input.taskId));
return { ok: true };
}),
/** Delete a task */
delete: publicProcedure
.input(z.object({ taskId: z.string() }))
.mutation(async ({ input }) => {
const db = await getDb();
if (!db) throw new Error("Database not available");
const { chatTasks } = await import("../drizzle/schema");
const { eq } = await import("drizzle-orm");
await db.delete(chatTasks).where(eq(chatTasks.taskId, input.taskId));
return { ok: true };
}),
}),
/**
@@ -674,10 +1016,21 @@ export const appRouter = router({
*/
nodes: router({
/**
* List all Swarm nodes (or standalone Docker host if Swarm not active).
* Returns node info: hostname, role, status, resources, labels, etc.
* Full Docker Swarm info: status, node count, manager address, join tokens.
*/
swarmInfo: publicProcedure.query(async () => {
return getSwarmInfo();
}),
/**
* List real Swarm nodes with live state, resources, labels.
* Falls back to the old gateway nodes endpoint if swarm API unavailable.
*/
list: publicProcedure.query(async () => {
// Try real Swarm API first
const swarm = await listSwarmNodes();
if (swarm) return { ...swarm, swarmActive: true, fetchedAt: new Date().toISOString() };
// Fallback: old gateway nodes
const result = await getGatewayNodes();
if (!result) {
return {
@@ -691,6 +1044,165 @@ export const appRouter = router({
return result;
}),
/**
* List all Swarm services with replica counts and running task status.
*/
services: publicProcedure.query(async () => {
const result = await listSwarmServices();
return result ?? { services: [], count: 0 };
}),
/**
* Get all tasks for a specific service (where each replica is running).
*/
serviceTasks: publicProcedure
.input(z.object({ serviceId: z.string() }))
.query(async ({ input }) => {
const result = await getServiceTasks(input.serviceId);
return result ?? { tasks: [] };
}),
/**
* Scale a service to N replicas.
*/
scaleService: publicProcedure
.input(z.object({ serviceId: z.string(), replicas: z.number().min(0).max(100) }))
.mutation(async ({ input }) => {
const ok = await scaleSwarmService(input.serviceId, input.replicas);
return { ok };
}),
/**
* Get join token and command for adding a new node.
*/
joinToken: publicProcedure
.input(z.object({ role: z.enum(["worker", "manager"]).default("worker") }))
.query(async ({ input }) => {
return getSwarmJoinToken(input.role);
}),
/**
* Execute a shell command on the HOST system via nsenter.
* Requires gateway container to run with privileged: true + pid: host.
*/
execShell: publicProcedure
.input(z.object({ command: z.string().min(1).max(4096) }))
.mutation(async ({ input }) => {
const result = await execSwarmShell(input.command);
if (!result) throw new Error("Gateway unavailable");
return result;
}),
/**
* Add a label to a swarm node.
*/
addNodeLabel: publicProcedure
.input(z.object({ nodeId: z.string(), key: z.string(), value: z.string() }))
.mutation(async ({ input }) => {
const ok = await addSwarmNodeLabel(input.nodeId, input.key, input.value);
return { ok };
}),
/**
* Set node availability (active | pause | drain).
*/
setAvailability: publicProcedure
.input(z.object({ nodeId: z.string(), availability: z.enum(["active", "pause", "drain"]) }))
.mutation(async ({ input }) => {
const ok = await setNodeAvailability(input.nodeId, input.availability);
return { ok };
}),
/**
* Deploy an agent as a new Swarm service.
*/
deployAgentService: publicProcedure
.input(z.object({
name: z.string().min(1),
image: z.string().min(1),
replicas: z.number().min(1).max(20).default(1),
env: z.array(z.string()).optional(),
port: z.number().optional(),
networks: z.array(z.string()).optional(),
}))
.mutation(async ({ input }) => {
const result = await createAgentService(input);
if (!result) throw new Error("Failed to create service");
return result;
}),
/**
* Remove (stop and delete) a Swarm service.
*/
removeService: publicProcedure
.input(z.object({ serviceId: z.string().min(1) }))
.mutation(async ({ input }) => {
const ok = await removeSwarmService(input.serviceId);
return { ok };
}),
/**
* List all GoClaw agent services with idle time info.
*/
listAgents: publicProcedure.query(async () => {
const result = await listSwarmAgents();
return result ?? { agents: [], count: 0 };
}),
/**
* Start (scale-up) an agent service by name.
*/
startAgent: publicProcedure
.input(z.object({ name: z.string().min(1), replicas: z.number().min(1).max(20).default(1) }))
.mutation(async ({ input }) => {
const ok = await startSwarmAgent(input.name, input.replicas);
return { ok };
}),
/**
* Stop (scale-to-0) an agent service by name.
*/
stopAgent: publicProcedure
.input(z.object({ name: z.string().min(1) }))
.mutation(async ({ input }) => {
const ok = await stopSwarmAgent(input.name);
return { ok };
}),
/**
* SSH into a remote host and run "docker swarm join ..." to add it to the cluster.
* The gateway fetches the join token automatically.
*/
joinNode: publicProcedure
.input(z.object({
host: z.string().min(1),
port: z.number().int().min(1).max(65535).default(22),
user: z.string().min(1),
password: z.string().min(1),
role: z.enum(["worker", "manager"]).default("worker"),
}))
.mutation(async ({ input }) => {
const result = await joinSwarmNodeViaSSH(input);
if (!result) throw new Error("Gateway unavailable — cannot reach SSH endpoint");
return result;
}),
/**
* Test SSH connectivity and Docker availability on a remote host — no swarm join performed.
*/
sshTest: publicProcedure
.input(z.object({
host: z.string().min(1),
port: z.number().int().min(1).max(65535).default(22),
user: z.string().min(1),
password: z.string().min(1),
}))
.mutation(async ({ input }) => {
const result = await testSSHConnection(input);
if (!result) throw new Error("Gateway unavailable — cannot reach SSH test endpoint");
return result;
}),
/**
* Get live container stats (CPU%, RAM) for all running containers.
*/
@@ -707,5 +1219,143 @@ export const appRouter = router({
return result;
}),
}),
/**
* Workflows — visual pipeline builder (CRUD + execution)
*/
workflows: router({
/** List all workflows */
list: publicProcedure.query(async () => {
const { getAllWorkflows } = await import("./workflows");
return getAllWorkflows();
}),
/** Get a single workflow with its nodes and edges */
get: publicProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const { getWorkflowById } = await import("./workflows");
return getWorkflowById(input.id);
}),
/** Create a new workflow */
create: publicProcedure
.input(z.object({
name: z.string().min(1),
description: z.string().optional(),
tags: z.array(z.string()).default([]),
}))
.mutation(async ({ input }) => {
const { createWorkflow } = await import("./workflows");
return createWorkflow({ ...input, status: "draft" });
}),
/** Update workflow metadata */
update: publicProcedure
.input(z.object({
id: z.number(),
name: z.string().optional(),
description: z.string().optional(),
status: z.enum(["draft", "active", "paused", "archived"]).optional(),
tags: z.array(z.string()).optional(),
}))
.mutation(async ({ input }) => {
const { updateWorkflow } = await import("./workflows");
const { id, ...data } = input;
return updateWorkflow(id, data as any);
}),
/** Delete a workflow and all its nodes/edges/runs */
delete: publicProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const { deleteWorkflow } = await import("./workflows");
return deleteWorkflow(input.id);
}),
/** Save the full canvas (nodes + edges) atomically */
saveCanvas: publicProcedure
.input(z.object({
workflowId: z.number(),
nodes: z.array(z.object({
nodeKey: z.string(),
label: z.string(),
kind: z.enum(["agent", "container", "trigger", "condition", "output"]),
agentId: z.number().nullable().optional(),
containerConfig: z.record(z.string(), z.unknown()).optional(),
conditionExpr: z.string().optional(),
triggerConfig: z.record(z.string(), z.unknown()).optional(),
posX: z.number().default(0),
posY: z.number().default(0),
meta: z.record(z.string(), z.unknown()).optional(),
})),
edges: z.array(z.object({
edgeKey: z.string(),
sourceNodeKey: z.string(),
targetNodeKey: z.string(),
sourceHandle: z.string().optional(),
targetHandle: z.string().optional(),
label: z.string().optional(),
meta: z.record(z.string(), z.unknown()).optional(),
})),
canvasMeta: z.record(z.string(), z.unknown()).optional(),
}))
.mutation(async ({ input }) => {
const { saveCanvas } = await import("./workflows");
return saveCanvas(
input.workflowId,
input.nodes.map((n) => ({ ...n, workflowId: input.workflowId } as any)),
input.edges.map((e) => ({ ...e, workflowId: input.workflowId } as any)),
input.canvasMeta,
);
}),
/** Execute a full workflow */
execute: publicProcedure
.input(z.object({ workflowId: z.number(), input: z.string().optional() }))
.mutation(async ({ input }) => {
const { executeWorkflow } = await import("./workflows");
return executeWorkflow(input.workflowId, input.input);
}),
/** Execute a single node (for testing) */
executeNode: publicProcedure
.input(z.object({ workflowId: z.number(), nodeKey: z.string(), input: z.string() }))
.mutation(async ({ input }) => {
const { executeSingleNode } = await import("./workflows");
return executeSingleNode(input.workflowId, input.nodeKey, input.input);
}),
/** Cancel a running workflow */
cancelRun: publicProcedure
.input(z.object({ runKey: z.string() }))
.mutation(async ({ input }) => {
const { cancelRun } = await import("./workflows");
return cancelRun(input.runKey);
}),
/** Get run details */
getRun: publicProcedure
.input(z.object({ runKey: z.string() }))
.query(async ({ input }) => {
const { getRunByKey } = await import("./workflows");
return getRunByKey(input.runKey);
}),
/** List runs for a workflow */
listRuns: publicProcedure
.input(z.object({ workflowId: z.number(), limit: z.number().default(50) }))
.query(async ({ input }) => {
const { getRunsByWorkflow } = await import("./workflows");
return getRunsByWorkflow(input.workflowId, input.limit);
}),
/** Get workflow stats */
stats: publicProcedure
.input(z.object({ workflowId: z.number() }))
.query(async ({ input }) => {
const { getWorkflowStats } = await import("./workflows");
return getWorkflowStats(input.workflowId);
}),
}),
});
export type AppRouter = typeof appRouter;

View File

@@ -332,42 +332,61 @@ export async function seedDefaults(): Promise<void> {
.where(eq(agents.isSystem, true));
if (Number(systemCount) > 0) {
console.log(`[Seed] Skipping — ${systemCount} system agent(s) already exist`);
return;
console.log(`[Seed] Skipping agents ${systemCount} system agent(s) already exist`);
} else {
console.log("[Seed] No agents found — seeding default agents...");
for (const agentDef of DEFAULT_AGENTS) {
await db.insert(agents).values({
userId: SYSTEM_USER_ID,
name: agentDef.name,
description: agentDef.description,
role: agentDef.role,
model: agentDef.model,
provider: agentDef.provider,
temperature: agentDef.temperature,
maxTokens: agentDef.maxTokens,
topP: agentDef.topP,
frequencyPenalty: agentDef.frequencyPenalty,
presencePenalty: agentDef.presencePenalty,
systemPrompt: agentDef.systemPrompt,
allowedTools: agentDef.allowedTools,
allowedDomains: agentDef.allowedDomains,
maxRequestsPerHour: agentDef.maxRequestsPerHour,
isActive: agentDef.isActive,
isPublic: agentDef.isPublic,
isSystem: agentDef.isSystem,
isOrchestrator: agentDef.isOrchestrator,
tags: agentDef.tags,
metadata: agentDef.metadata,
});
console.log(`[Seed] ✓ Created agent: ${agentDef.name}`);
}
console.log(`[Seed] Done — ${DEFAULT_AGENTS.length} default agents created`);
}
console.log("[Seed] No agents found — seeding default agents...");
for (const agentDef of DEFAULT_AGENTS) {
await db.insert(agents).values({
userId: SYSTEM_USER_ID,
name: agentDef.name,
description: agentDef.description,
role: agentDef.role,
model: agentDef.model,
provider: agentDef.provider,
temperature: agentDef.temperature,
maxTokens: agentDef.maxTokens,
topP: agentDef.topP,
frequencyPenalty: agentDef.frequencyPenalty,
presencePenalty: agentDef.presencePenalty,
systemPrompt: agentDef.systemPrompt,
allowedTools: agentDef.allowedTools,
allowedDomains: agentDef.allowedDomains,
maxRequestsPerHour: agentDef.maxRequestsPerHour,
isActive: agentDef.isActive,
isPublic: agentDef.isPublic,
isSystem: agentDef.isSystem,
isOrchestrator: agentDef.isOrchestrator,
tags: agentDef.tags,
metadata: agentDef.metadata,
});
console.log(`[Seed] ✓ Created agent: ${agentDef.name}`);
}
console.log(`[Seed] Done — ${DEFAULT_AGENTS.length} default agents created`);
} catch (error) {
console.error("[Seed] Failed to seed default agents:", error);
// Non-fatal: server continues even if seed fails
}
// Seed default LLM provider from env vars if table is empty
try {
const { seedDefaultProvider, notifyGatewayReload } = await import("./providers");
await seedDefaultProvider();
// Always push the active provider to the gateway on startup (even if already seeded)
// We retry a few times to wait for the gateway to become ready
setTimeout(async () => {
for (let i = 0; i < 5; i++) {
try {
await notifyGatewayReload();
break;
} catch {
await new Promise((r) => setTimeout(r, 3000));
}
}
}, 5000);
} catch (error) {
console.error("[Seed] Failed to seed default provider:", error);
}
}

418
server/workflows.ts Normal file
View File

@@ -0,0 +1,418 @@
/**
* server/workflows.ts — Workflow CRUD, graph operations & execution engine.
*
* A Workflow is a directed graph of nodes (agents / containers / triggers / conditions / outputs)
* connected by edges. The execution engine walks the graph from trigger nodes,
* executing each agent/container block and forwarding the output downstream.
*/
import { eq, desc, and, inArray } from "drizzle-orm";
import {
workflows, workflowNodes, workflowEdges, workflowRuns,
type Workflow, type InsertWorkflow,
type WorkflowNode, type InsertWorkflowNode,
type WorkflowEdge, type InsertWorkflowEdge,
type WorkflowRun,
} from "../drizzle/schema";
import { getDb } from "./db";
import { nanoid } from "nanoid";
// ─── Workflow CRUD ────────────────────────────────────────────────────────────
export async function createWorkflow(data: Omit<InsertWorkflow, "id">): Promise<Workflow | null> {
const db = await getDb();
if (!db) return null;
const result = await db.insert(workflows).values(data);
const id = result[0].insertId;
const [row] = await db.select().from(workflows).where(eq(workflows.id, Number(id))).limit(1);
return row ?? null;
}
export async function getAllWorkflows(): Promise<Workflow[]> {
const db = await getDb();
if (!db) return [];
return db.select().from(workflows).orderBy(desc(workflows.updatedAt));
}
export async function getWorkflowById(id: number) {
const db = await getDb();
if (!db) return null;
const [wf] = await db.select().from(workflows).where(eq(workflows.id, id)).limit(1);
if (!wf) return null;
const nodes = await db.select().from(workflowNodes).where(eq(workflowNodes.workflowId, id));
const edges = await db.select().from(workflowEdges).where(eq(workflowEdges.workflowId, id));
return { ...wf, nodes, edges };
}
export async function updateWorkflow(id: number, data: Partial<InsertWorkflow>): Promise<Workflow | null> {
const db = await getDb();
if (!db) return null;
await db.update(workflows).set(data).where(eq(workflows.id, id));
const [row] = await db.select().from(workflows).where(eq(workflows.id, id)).limit(1);
return row ?? null;
}
export async function deleteWorkflow(id: number): Promise<boolean> {
const db = await getDb();
if (!db) return false;
await db.delete(workflowEdges).where(eq(workflowEdges.workflowId, id));
await db.delete(workflowNodes).where(eq(workflowNodes.workflowId, id));
await db.delete(workflowRuns).where(eq(workflowRuns.workflowId, id));
await db.delete(workflows).where(eq(workflows.id, id));
return true;
}
// ─── Nodes CRUD ───────────────────────────────────────────────────────────────
export async function saveNodes(workflowId: number, nodes: InsertWorkflowNode[]): Promise<WorkflowNode[]> {
const db = await getDb();
if (!db) return [];
// Delete existing nodes for this workflow, then insert fresh set (canvas save = full replace)
await db.delete(workflowNodes).where(eq(workflowNodes.workflowId, workflowId));
if (nodes.length === 0) return [];
await db.insert(workflowNodes).values(
nodes.map((n) => ({
...n,
workflowId,
nodeKey: n.nodeKey || `node_${nanoid(8)}`,
}))
);
return db.select().from(workflowNodes).where(eq(workflowNodes.workflowId, workflowId));
}
// ─── Edges CRUD ───────────────────────────────────────────────────────────────
export async function saveEdges(workflowId: number, edges: InsertWorkflowEdge[]): Promise<WorkflowEdge[]> {
const db = await getDb();
if (!db) return [];
await db.delete(workflowEdges).where(eq(workflowEdges.workflowId, workflowId));
if (edges.length === 0) return [];
await db.insert(workflowEdges).values(
edges.map((e) => ({
...e,
workflowId,
edgeKey: e.edgeKey || `edge_${nanoid(8)}`,
}))
);
return db.select().from(workflowEdges).where(eq(workflowEdges.workflowId, workflowId));
}
// ─── Full canvas save (nodes + edges atomically) ─────────────────────────────
export async function saveCanvas(
workflowId: number,
nodesData: InsertWorkflowNode[],
edgesData: InsertWorkflowEdge[],
canvasMeta?: Record<string, any>,
) {
const db = await getDb();
if (!db) return null;
// Update canvas meta on the workflow itself
if (canvasMeta) {
await db.update(workflows).set({ canvasMeta } as any).where(eq(workflows.id, workflowId));
}
const nodes = await saveNodes(workflowId, nodesData);
const edges = await saveEdges(workflowId, edgesData);
return { nodes, edges };
}
// ─── Workflow Runs ────────────────────────────────────────────────────────────
export async function createRun(workflowId: number, input?: string): Promise<WorkflowRun | null> {
const db = await getDb();
if (!db) return null;
const runKey = `run_${nanoid(12)}`;
await db.insert(workflowRuns).values({
workflowId,
runKey,
status: "pending",
input: input ?? null,
nodeResults: {},
});
const [row] = await db.select().from(workflowRuns).where(eq(workflowRuns.runKey, runKey)).limit(1);
return row ?? null;
}
export async function getRunsByWorkflow(workflowId: number, limit = 50): Promise<WorkflowRun[]> {
const db = await getDb();
if (!db) return [];
return db
.select()
.from(workflowRuns)
.where(eq(workflowRuns.workflowId, workflowId))
.orderBy(desc(workflowRuns.createdAt))
.limit(limit);
}
export async function getRunByKey(runKey: string): Promise<WorkflowRun | null> {
const db = await getDb();
if (!db) return null;
const [row] = await db.select().from(workflowRuns).where(eq(workflowRuns.runKey, runKey)).limit(1);
return row ?? null;
}
export async function updateRun(runKey: string, data: Partial<WorkflowRun>) {
const db = await getDb();
if (!db) return;
await db.update(workflowRuns).set(data as any).where(eq(workflowRuns.runKey, runKey));
}
// ─── Execution Engine ─────────────────────────────────────────────────────────
/**
* Execute a single node. For agent nodes it calls the agent chat mutation;
* for container nodes it can later call Docker SDK; for conditions it evals the expression.
*/
async function executeNode(
node: WorkflowNode,
input: string,
runKey: string,
): Promise<{ output: string; success: boolean; error?: string }> {
const start = Date.now();
try {
switch (node.kind) {
case "agent": {
if (!node.agentId) return { output: "", success: false, error: "No agentId configured" };
const { getAgentById } = await import("./agents");
const agent = await getAgentById(node.agentId);
if (!agent) return { output: "", success: false, error: `Agent #${node.agentId} not found` };
const { chatCompletion } = await import("./ollama");
const messages: Array<{ role: "system" | "user" | "assistant"; content: string }> = [];
if (agent.systemPrompt) messages.push({ role: "system", content: agent.systemPrompt });
messages.push({ role: "user", content: input });
const result = await chatCompletion(agent.model, messages, {
temperature: agent.temperature ? parseFloat(agent.temperature as string) : 0.7,
max_tokens: agent.maxTokens ?? 2048,
});
const text = result.choices[0]?.message?.content ?? "";
return { output: text, success: true };
}
case "container": {
// Placeholder: in production this would call Docker SDK / Gateway
const cfg = node.containerConfig as any;
return {
output: `[Container ${cfg?.image ?? "unknown"}] executed with input length=${input.length}`,
success: true,
};
}
case "condition": {
const expr = node.conditionExpr ?? "true";
// Simple safe eval: only allow basic boolean expressions
const result = expr.trim().toLowerCase() === "true" || input.trim().length > 0;
return { output: result ? "true" : "false", success: true };
}
case "trigger":
case "output":
return { output: input, success: true };
default:
return { output: input, success: true };
}
} catch (err: any) {
return { output: "", success: false, error: err.message };
}
}
/**
* Execute a full workflow from its trigger node(s) following edges.
* Updates workflowRuns in real-time so the dashboard can poll progress.
*/
export async function executeWorkflow(workflowId: number, userInput?: string): Promise<WorkflowRun | null> {
const wf = await getWorkflowById(workflowId);
if (!wf) return null;
const run = await createRun(workflowId, userInput);
if (!run) return null;
const { nodes, edges } = wf;
// Build adjacency: sourceNodeKey → [targetNodeKey, …]
const adj: Record<string, string[]> = {};
for (const e of edges) {
if (!adj[e.sourceNodeKey]) adj[e.sourceNodeKey] = [];
adj[e.sourceNodeKey].push(e.targetNodeKey);
}
// Find trigger / start nodes (no incoming edges, or kind=trigger)
const incomingSet = new Set(edges.map((e) => e.targetNodeKey));
const startNodes = nodes.filter(
(n) => n.kind === "trigger" || !incomingSet.has(n.nodeKey)
);
const nodeMap: Record<string, WorkflowNode> = {};
for (const n of nodes) nodeMap[n.nodeKey] = n;
// Mark run as running
await updateRun(run.runKey, { status: "running", startedAt: new Date() } as any);
const nodeResults: Record<string, any> = {};
const visited = new Set<string>();
// BFS execution
const queue: Array<{ nodeKey: string; input: string }> = startNodes.map((n) => ({
nodeKey: n.nodeKey,
input: userInput ?? "",
}));
let finalOutput = "";
let hasError = false;
while (queue.length > 0) {
const { nodeKey, input } = queue.shift()!;
if (visited.has(nodeKey)) continue;
visited.add(nodeKey);
const node = nodeMap[nodeKey];
if (!node) continue;
// Update current node
nodeResults[nodeKey] = { status: "running", startedAt: new Date().toISOString() };
await updateRun(run.runKey, { currentNodeKey: nodeKey, nodeResults } as any);
const start = Date.now();
const result = await executeNode(node, input, run.runKey);
const durationMs = Date.now() - start;
nodeResults[nodeKey] = {
status: result.success ? "success" : "failed",
output: result.output,
durationMs,
error: result.error,
startedAt: nodeResults[nodeKey].startedAt,
finishedAt: new Date().toISOString(),
};
await updateRun(run.runKey, { nodeResults } as any);
if (!result.success) {
hasError = true;
continue; // don't propagate to children on failure
}
// For condition nodes: only propagate if result is "true"
if (node.kind === "condition" && result.output !== "true") {
continue;
}
finalOutput = result.output;
// Enqueue children
const children = adj[nodeKey] ?? [];
for (const childKey of children) {
if (!visited.has(childKey)) {
queue.push({ nodeKey: childKey, input: result.output });
}
}
}
// Mark remaining unvisited nodes as skipped
for (const n of nodes) {
if (!nodeResults[n.nodeKey]) {
nodeResults[n.nodeKey] = { status: "skipped" };
}
}
const totalDurationMs = run.startedAt ? Date.now() - new Date(run.startedAt as any).getTime() : 0;
await updateRun(run.runKey, {
status: hasError ? "failed" : "success",
nodeResults,
output: finalOutput,
totalDurationMs,
finishedAt: new Date(),
currentNodeKey: null,
errorMessage: hasError ? "One or more nodes failed" : null,
} as any);
return getRunByKey(run.runKey);
}
/**
* Execute a single node inside a workflow (for testing individual blocks).
*/
export async function executeSingleNode(
workflowId: number,
nodeKey: string,
input: string,
): Promise<{ output: string; success: boolean; durationMs: number; error?: string }> {
const db = await getDb();
if (!db) return { output: "", success: false, durationMs: 0, error: "DB unavailable" };
const [node] = await db
.select()
.from(workflowNodes)
.where(and(eq(workflowNodes.workflowId, workflowId), eq(workflowNodes.nodeKey, nodeKey)))
.limit(1);
if (!node) return { output: "", success: false, durationMs: 0, error: "Node not found" };
const start = Date.now();
const result = await executeNode(node, input, `test_${nanoid(8)}`);
return { ...result, durationMs: Date.now() - start };
}
/**
* Cancel a running workflow run
*/
export async function cancelRun(runKey: string): Promise<boolean> {
const db = await getDb();
if (!db) return false;
await db
.update(workflowRuns)
.set({ status: "cancelled", finishedAt: new Date() } as any)
.where(eq(workflowRuns.runKey, runKey));
return true;
}
/**
* Get aggregated stats for a workflow
*/
export async function getWorkflowStats(workflowId: number) {
const db = await getDb();
if (!db) return null;
const runs = await db
.select()
.from(workflowRuns)
.where(eq(workflowRuns.workflowId, workflowId))
.orderBy(desc(workflowRuns.createdAt))
.limit(100);
const total = runs.length;
const success = runs.filter((r) => r.status === "success").length;
const failed = runs.filter((r) => r.status === "failed").length;
const running = runs.filter((r) => r.status === "running").length;
const avgDuration = total > 0
? Math.round(runs.reduce((s, r) => s + (r.totalDurationMs ?? 0), 0) / total)
: 0;
return {
totalRuns: total,
successRuns: success,
failedRuns: failed,
runningRuns: running,
successRate: total > 0 ? Math.round((success / total) * 100) : 0,
avgDurationMs: avgDuration,
lastRun: runs[0] ?? null,
};
}

366
todo.md
View File

@@ -1,209 +1,203 @@
# GoClaw Control Center TODO
# GoClaw TODO
## Целевая архитектура
```
┌─────────────────────────────────────────────────────────────┐
│ Docker Swarm overlay net │
│ │
│ [Web Panel :3000] [Orchestrator :18789] [Agent-1 :8001] │
│ │ │ │ │
│ └────────────────────┴────────────────────┘ │
│ goclaw-net │
│ │
│ Каждый агент = отдельный Docker Swarm service: │
│ • своя LLM модель + systemPrompt из DB │
│ • своя память (conversation history в shared DB) │
│ • HTTP API: POST /task, POST /chat, GET /health, GET /mem │
│ • принимает параллельные задачи от любых источников │
│ • автодеплой при создании агента через Web Panel │
│ │
│ Orchestrator = мозг экосистемы: │
│ • маршрутизирует задачи между агентами │
│ • tool: delegate_to_agent → HTTP к agent-N:8001/task │
│ • знает topology: какой агент где живёт │
│ │
│ Web Panel = панель управления и мониторинга: │
│ • создать/удалить агента → автодеплой контейнера │
│ • видеть статус контейнеров в реальном времени │
│ • логи, метрики, история задач │
└─────────────────────────────────────────────────────────────┘
```
---
## ✅ ЗАВЕРШЕНО (фундамент)
- [x] Basic Dashboard layout (Mission Control theme)
- [x] Agents page with mock data
- [x] Nodes page with mock data
- [x] Chat page with mock conversation
- [x] Settings page with provider cards
- [x] Docker Stack integration
- [x] Fix Home.tsx conflict after upgrade
- [x] Fix DashboardLayout.tsx conflict after upgrade
- [x] Create server-side Ollama API proxy routes (tRPC)
- [x] Integrate real Ollama /v1/models endpoint in Settings
- [x] Integrate real Ollama /v1/chat/completions in Chat page
- [x] Add OLLAMA_API_KEY and OLLAMA_BASE_URL secrets
- [x] Write vitest tests for Ollama API proxy
- [x] Update Dashboard with real model data
- [ ] Add streaming support for chat responses
- [ ] Connect real Docker Swarm API for node monitoring
- [ ] Add authentication/login protection
- [x] Docker Stack integration (docker-stack.yml, docker-compose.yml)
- [x] Go Gateway — отдельный контейнер-оркестратор (:18789)
- [x] Web Panel — отдельный контейнер (:3000)
- [x] Overlay network `goclaw-net` (attachable)
- [x] MySQL shared DB (агенты, метрики, история)
- [x] tRPC API: agents CRUD, metrics, history
- [x] Go Gateway: LLM client (OpenAI-compatible), tool executor
- [x] Go Gateway: tool loop (shell_exec, file_read/write, http_request, docker_exec)
- [x] Go Gateway: DockerClient.CreateAgentServiceFull() — готов к деплою агентов
- [x] Nodes page: реальные данные из Docker API (Swarm nodes, containers)
- [x] Seed: 6 системных агентов в DB при старте
- [x] SSE streaming chat (Phase 18, remote branch)
- [x] Persistent chat sessions в DB (Phase 20, remote branch)
- [x] Workflows: визуальный конструктор граф-воркфлоу (remote branch)
- [x] Real Docker Swarm management: live nodes/services/tasks (Phase 21, remote branch)
## 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
- [x] Add start/pause/restart actions for agents
- [x] Add agent metrics chart (requests, tokens, processing time)
- [x] Add agent history view (recent requests/responses)
- [x] Write vitest tests for agent management components
---
## Phase 2: Tool Binding System
- [x] Design Tool Binding API schema
- [x] Create tool registry in database
- [x] Implement tool execution sandbox
- [x] Add tool access control per agent
- [x] Create UI for tool management
## 🔥 PHASE A: Agent Worker Container (КРИТИЧЕСКИЙ ПУТЬ)
## Phase 3: Tool Integration
- [x] Implement Browser tool (HTTP fetch-based)
- [x] Implement Shell tool (bash execution with safety checks)
- [x] Implement File tool (read/write with path restrictions)
- [x] Implement Docker tool (container management)
- [x] Implement HTTP tool (GET/POST with domain whitelist)
> Цель: каждый агент живёт в своём контейнере с HTTP API.
> Orchestrator обращается к нему по имени в overlay сети.
## Phase 4: Metrics & History
- [x] AgentMetrics page with request timeline chart
- [x] Conversation history log per agent
- [x] Raw metrics table with token/time data
- [x] Stats cards (total requests, success rate, avg response time, tokens)
- [x] Time range selector (6h/24h/48h/7d)
- [x] Metrics button on agent cards
- [x] Navigation: /agents/:id/metrics route
- [x] Tools page added to sidebar navigation
### A1: agent-worker binary (Go)
- [x] Создать `gateway/cmd/agent-worker/main.go` — HTTP-сервер агента
- [x] Загружает конфиг из DB по `AGENT_ID` env var (model, systemPrompt, allowedTools)
- [x] `GET /health` — liveness/readiness probe
- [x] `GET /info` — конфиг агента (name, model, allowedTools)
- [x] `POST /task` — принять задачу от Orchestrator/другого агента (async, возвращает task_id)
- [x] `POST /chat` — синхронный чат (LLM loop с инструментами агента)
- [x] `GET /memory` — последние N сообщений из conversation history (sliding window)
- [x] Агент сам вызывает LLM через `LLM_BASE_URL` (не через Gateway)
- [x] 4 горутины-воркера на агента — параллельная обработка задач
- [x] Переиспользует `internal/llm`, `internal/db`, `internal/tools`
## Phase 5: Specialized Agents
### A2: Task Queue внутри агента
- [x] In-memory очередь задач (buffered channel, depth=100)
- [x] 4 worker goroutines: берут задачи из очереди, выполняют LLM loop
- [x] `GET /tasks` + `GET /tasks/{id}` — список задач и статус конкретной
- [x] Callback URL: агент POST результат на `callback_url` когда задача готова
- [x] Timeout per task (из запроса, default 120s)
- [x] Recent ring buffer (последние 50 задач)
### Browser Agent
- [ ] Install puppeteer-core + chromium dependencies
- [ ] Create server/browser-agent.ts — Puppeteer session manager
- [ ] tRPC routes: browser.start, browser.navigate, browser.screenshot, browser.click, browser.type, browser.extract, browser.close
- [ ] BrowserAgent.tsx page — live browser control UI with screenshot preview
- [ ] Session management: multiple concurrent browser sessions per agent
- [ ] Add browser_agent to agents DB as pre-seeded entry
### A3: DB schema — agent container fields
- [x] Добавить в `drizzle/schema.ts`: `serviceName`, `servicePort`, `containerImage`, `containerStatus`
- [x] SQL migration `drizzle/migrations/0006_agent_container_fields.sql`
- [x] `gateway/internal/db/db.go`: `AgentConfig` + `AgentRow` с новыми полями
- [x] `UpdateContainerStatus()` — обновление статуса при деплое/остановке
- [x] `GetAgentHistory()` + `SaveHistory()` — память агента в DB
### Tool Builder Agent
- [ ] Create server/tool-builder.ts — LLM-powered tool generator
- [ ] tRPC routes: toolBuilder.generate, toolBuilder.validate, toolBuilder.install
- [ ] Dynamic tool registration: add generated tools to TOOL_REGISTRY at runtime
- [ ] Persist custom tools to DB (tool_definitions table)
- [ ] ToolBuilder.tsx page — describe tool → preview code → install
- [ ] Add tool_builder_agent to agents DB as pre-seeded entry
### A4: Auto-deploy при создании агента
- [ ] Gateway: `POST /api/agents` — создаёт агента в DB + деплоит Swarm service
- [ ] `CreateAgentServiceFull()` с параметрами:
- image: `goclaw-agent-worker:latest`
- name: `goclaw-agent-{agentId}`
- env: `AGENT_ID`, `DATABASE_URL`, `LLM_BASE_URL`, `LLM_API_KEY`
- network: `goclaw-net`
- port: назначить из пула (8001+)
- [ ] Записать `serviceName`, `servicePort`, `containerStatus=running` в DB
- [ ] Gateway: `DELETE /api/agents/{id}` — удалить Swarm service + запись в DB
- [ ] Gateway: `POST /api/agents/{id}/scale` — масштабировать реплики агента
### Agent Compiler
- [ ] Create server/agent-compiler.ts — LLM-powered agent factory
- [ ] tRPC routes: agentCompiler.compile, agentCompiler.preview, agentCompiler.deploy
- [ ] AgentCompiler.tsx page — ТЗ input → agent config preview → deploy
- [ ] Auto-populate: model, role, systemPrompt, allowedTools from ТЗ
- [ ] Add agent_compiler to agents DB as pre-seeded entry
### A5: delegate_to_agent tool (Orchestrator → Agent HTTP)
- [x] Обновить `gateway/internal/tools/executor.go`:
- tool `delegate_to_agent`: args: `{agentId, task, callbackUrl?, async?}`
- Получить `serviceName`+`servicePort` агента из DB
- HTTP POST к `http://goclaw-agent-{id}:{port}/chat` (sync) или `/task` (async)
- Fallback: если агент не запущен — информативное сообщение
- [x] `Executor.SetDatabase()` — инжекция DB для резолва адресов агентов
- [x] Orchestrator инжектирует DB в Executor при инициализации
### Integration
- [ ] Add all 3 pages to sidebar navigation
- [ ] Write vitest tests for all new server modules
- [ ] Push to Gitea (NW)
### A6: Dockerfile.agent-worker
- [x] Создать `docker/Dockerfile.agent-worker` (multi-stage Go build)
- [x] Stage 1: `golang:1.23-alpine` — build agent-worker binary
- [x] Stage 2: `alpine:3.21` — минимальный runtime (ca-certificates, tzdata)
- [x] EXPOSE 8001 + HEALTHCHECK на /health
- [x] Агенты деплоятся динамически (не статический сервис в stack)
## Phase 6: Agents as Real Chat Entities
- [ ] Remove unused pages: BrowserAgent.tsx, ToolBuilder.tsx, AgentCompiler.tsx
- [ ] Seed 3 agents into DB: Browser Agent, Tool Builder Agent, Agent Compiler
- [ ] Add tRPC chat endpoint: agents.chat (LLM + tool execution per agent)
- [ ] Update Chat UI to support agent selection dropdown
- [ ] Create /skills page — skills registry with install/uninstall
- [ ] Update /agents to show seeded agents with Chat button
- [ ] Update /tools to show tools per agent with filter by agent
- [ ] Add /skills to sidebar navigation
- [ ] Write tests for chat and skills endpoints
### A7: Тесты и верификация
- [x] `go build ./cmd/agent-worker/...` — компилируется (11MB binary)
- [x] `go build ./cmd/gateway/...` — не сломан (11MB binary)
- [x] `go build ./...` — все пакеты компилируются
- [x] 20 unit-тестов: /health, /task, /tasks, /memory, task queue, tools, lifecycle — все PASS
- [ ] Docker build: `docker build -f docker/Dockerfile.agent-worker -t goclaw-agent-worker .` (нужен Docker daemon)
- [ ] Интеграционный тест: Gateway `delegate_to_agent` → agent-worker `/task` (нужна живая DB)
## Phase 6: Orchestrator Agent (Main Chat)
- [x] Fix TS errors: browserSessions/toolDefinitions schema exports, z.record
- [x] Seed 3 specialized agents into DB (Browser, Tool Builder, Agent Compiler)
- [x] Create server/orchestrator.ts — main orchestrator with tool-use loop
- [x] Orchestrator tools: shell_exec, file_read, file_write, http_request, delegate_to_agent, list_agents, list_skills, install_skill
- [x] Add trpc.orchestrator.chat mutation (multi-step tool-use loop with LLM)
- [x] Update /chat UI: show tool call steps, agent delegation, streaming response
- [x] Create /skills page with skill registry (install/remove/describe)
- [x] Add /skills to sidebar navigation
- [x] Update /agents to show seeded agents with Chat button
- [ ] Write tests for orchestrator
---
## Phase 7: Orchestrator as Configurable System Agent
- [x] Add isSystem + isOrchestrator fields to agents table (DB migration)
- [x] Seed Orchestrator as system agent in DB (role=orchestrator, isSystem=true)
- [x] Update orchestrator.ts to load model/systemPrompt/allowedTools from DB
- [x] Update /chat to read orchestrator config from DB, show active model in header
- [x] Update /agents to show Orchestrator with SYSTEM badge, Configure button, no delete
- [x] AgentDetailModal: orchestrator gets extra tab with system tools (shell, docker, agents mgmt)
- [x] Add system tools to orchestrator: docker_ps, docker_restart, manage_agents, read_logs
- [x] /chat header: show current model name + link to Configure Orchestrator
## 🟡 PHASE B: Web Panel — управление живыми агентами
## Phase 8: Fix Orchestrator Chat
- [x] Fix: orchestrator uses model from DB config (minimax-m2.7, not hardcoded fallback)
- [x] Fix: real tool-use loop — execute shell_exec, file_read, file_list tools
- [x] Fix: show tool call steps in Chat UI (tool name, args, result, duration)
- [x] Fix: Chat.tsx shows which model is being used from orchestrator config
- [x] Fix: Streamdown markdown rendering for assistant responses
- [ ] Add: streaming/SSE for real-time response display
> Цель: Web Panel показывает реальный статус контейнеров и позволяет деплоить/останавливать.
## Phase 9: Go Gateway Migration (Variant C)
- [x] Create gateway/ directory with Go module (git.softuniq.eu/UniqAI/GoClaw/gateway)
- [x] Implement config/config.go — env-based configuration
- [x] Implement internal/llm/client.go — Ollama API client (chat, models, health)
- [x] Implement internal/db/db.go — MySQL connection, agent/config queries
- [x] Implement internal/tools/executor.go — Tool Executor (shell_exec, file_read, file_write, file_list, http_request, docker_exec, list_agents)
- [x] Implement internal/orchestrator/orchestrator.go — LLM tool-use loop, config from DB
- [x] Implement internal/api/handlers.go — REST API handlers
- [x] Implement cmd/gateway/main.go — HTTP server with chi router, graceful shutdown
- [x] Go Gateway compiles successfully (10.8MB binary)
- [x] Create server/gateway-proxy.ts — Node.js proxy client to Go Gateway
- [x] Create docker/docker-compose.yml — local dev (control-center + gateway + ollama + db)
- [x] Create docker/docker-stack.yml — Docker Swarm production (2 replicas, rolling updates)
- [x] Create docker/Dockerfile.gateway — multi-stage Go build
- [x] Create docker/Dockerfile.control-center — multi-stage Node.js build
- [ ] Update server/routers.ts: replace orchestrator.ts calls with gateway-proxy.ts calls
- [ ] Write Go unit tests (gateway/internal/tools/executor_test.go)
- [ ] Write Go integration test for orchestrator chat loop
- [ ] Push to Gitea (NW)
- [ ] `/agents` страница: колонка `Container Status` (running/stopped/deploying/error)
- [ ] `/agents` страница: кнопка `Deploy` — вызывает `POST /api/agents/{id}/deploy`
- [ ] `/agents` страница: кнопка `Stop` / `Scale` для запущенных агентов
- [ ] `/agents` страница: live polling статуса контейнера (10s)
- [ ] tRPC: `agents.deploy`, `agents.stop`, `agents.scale` → Gateway REST
- [ ] Dashboard: topology карта — агенты как узлы, стрелки = делегирование задач
- [ ] `/agents/{id}` detail: вкладка `Tasks` — активные задачи агента в реальном времени
- [ ] `/agents/{id}` detail: вкладка `Memory` — последние N сообщений агента
## Phase 10: LLM Provider Configuration
- [x] config.go: default LLM_BASE_URL = https://ollama.com/v1 (Ollama Cloud)
- [x] config.go: support LLM_BASE_URL + LLM_API_KEY env vars (legacy OLLAMA_* aliases kept)
- [x] config.go: normaliseLLMURL() — auto-append /v1 for bare Ollama hosts
- [x] docker-compose.yml: ollama service commented out (GPU only), LLM_BASE_URL/LLM_API_KEY added
- [x] docker-stack.yml: ollama service commented out (GPU only), llm-api-key secret added
- [x] docker/.env.example: 4 LLM provider options documented (Ollama Cloud, OpenAI, Groq, Local GPU)
---
## Phase 11: Frontend → Go Gateway Integration
- [x] gateway-proxy.ts: fix getGatewayTools() — map OpenAI format {type,function:{name,...}} to GatewayToolDef
- [x] gateway-proxy.ts: add executeGatewayTool(), getGatewayAgent(), isGatewayAvailable() methods
- [x] routers.ts: orchestrator.getConfig — Go Gateway first, Node.js fallback
- [x] routers.ts: orchestrator.chat — Go Gateway first, Node.js fallback
- [x] routers.ts: orchestrator.tools — Go Gateway first, Node.js fallback
- [x] routers.ts: orchestrator.gatewayHealth — new endpoint for UI status
- [x] routers.ts: ollama.health — Go Gateway first, direct Ollama fallback
- [x] routers.ts: ollama.models — Go Gateway first, direct Ollama fallback
- [x] gateway/db.go: TLS auto-detection for TiDB Cloud (tidbcloud/aws/gcp/azure hosts)
- [x] server/gateway-proxy.test.ts: 13 vitest tests (health, config, tools, mapping)
- [x] End-to-end test: orchestrator.chat via tRPC → Go Gateway → Ollama (source: "gateway")
- [x] End-to-end test: tool calling — file_list tool executed by Go Gateway
## 🟡 PHASE C: Межагентная коммуникация
## Phase 12: Real-time Nodes Page
- [ ] Add Docker API client in Go Gateway: /api/nodes endpoint with real node data
- [ ] Add /api/nodes/stats endpoint for CPU/memory per node
- [ ] Add tRPC nodes.list and nodes.stats procedures via gateway-proxy
- [ ] Update Nodes.tsx: real data from tRPC + auto-refresh every 5 seconds
- [ ] Show: node ID, hostname, status, role (manager/worker), availability, CPU, RAM, Docker version, IP
- [ ] Show live indicator (green pulse) when data is fresh
- [x] Deploy to server 2.59.219.61
- [x] Docker API client: /api/nodes, /api/nodes/stats
- [x] tRPC nodes.list, nodes.stats procedures
- [x] Nodes.tsx rewritten with real data + auto-refresh 10s/15s
- [x] 14 vitest tests for nodes procedures
> Цель: агенты могут обращаться друг к другу параллельно, с разных мест.
## Phase 13: Seed Data for Agents & Orchestrator
- [x] Create server/seed.ts with default agents (orchestrator, coder, browser, researcher)
- [x] Create default orchestrator config seed
- [x] Integrate seed into server startup (idempotent — runs only when tables are empty)
- [x] Write vitest tests for seed logic (18 tests, all pass)
- [x] Commit to Gitea and deploy to production server
- [x] Verify seed data on production DB — 6 agents seeded successfully
- [ ] Стандарт сообщения agent-to-agent:
```json
{ "task_id": "uuid", "from_agent_id": 1, "task": "...", "callback_url": "http://...", "priority": "normal", "timeout_secs": 120 }
```
- [ ] Service Discovery: агент получает список других агентов из DB (GET /api/agents)
- [ ] Orchestrator: параллельный fanout — отправить задачу нескольким агентам одновременно
- [ ] Rate limiting: агент принимает не более N параллельных задач (configurable)
- [ ] Dead letter: если агент недоступен — Orchestrator автоматически рестартует сервис
## Phase 14: Auto-migrate on Container Startup
- [ ] Create server/migrate.ts — programmatic Drizzle migration runner
- [ ] Create docker/entrypoint.sh — wait-for-db + migrate + start server
- [ ] Update Dockerfile.control-center — copy entrypoint, set as CMD
- [ ] Write vitest tests for migrate logic
- [ ] Commit to Gitea and deploy to production server
- [ ] Verify auto-migrate on production (check logs)
---
## Phase 14 (Bug Fixes): Real Header Metrics + Seed Fix
- [x] Fix seed: agents not appearing on production after restart (check isSystem column query)
- [x] Fix header metrics: UPTIME/NODES/AGENTS/CPU/MEM show hardcoded data instead of real values
- [x] Connect header stats to real tRPC endpoints (agents count from DB, nodes/CPU/MEM from Docker API)
- [x] Write vitest tests for header stats procedure (82 tests total, all pass)
- [x] Commit to Gitea and deploy to production (Phase 14) — verified: nodes=6/6, agents=6, CPU=0.2%, MEM=645MB, gatewayOnline=true
## 🟡 PHASE D: Память агента
## Phase 15 (Bug Fix): Agents Page Shows Empty List
- [x] Diagnose: find why /agents page shows no agents (userId=0 in seed vs SYSTEM_USER_ID=1 in router)
- [x] Fix agents tRPC query: getAllAgents() instead of getUserAgents(SYSTEM_USER_ID)
- [x] Update vitest tests (86 tests, all pass)
- [ ] Deploy to production (Phase 15)
> Цель: каждый агент имеет изолированную персистентную память.
- [ ] Sliding window: агент загружает последние 20 сообщений своей истории при каждом запросе
- [ ] История привязана к `agentId` (уже есть `agentHistory` в DB)
- [ ] `GET /memory?limit=20` — endpoint агента отдаёт свою историю
- [ ] Опционально: summary compression — если история > N токенов, сжать через LLM
---
## 🟢 PHASE E: Специализированные образы агентов
> Цель: разные типы агентов с разными возможностями.
- [ ] `goclaw-agent-browser` — образ с Chromium + Puppeteer (или playwright-go)
- [ ] `goclaw-agent-coder` — образ с git, node, python, go
- [ ] `goclaw-agent-researcher` — образ с curl + базовый HTTP scraping
- [ ] Agent Compiler: из ТЗ → config в DB → auto-deploy нужного образа
- [ ] Tool Builder: динамическая регистрация инструментов через API агента
---
## 🟢 PHASE F: Observability & Production
- [ ] Centralized logging: агенты пишут структурированные логи в shared volume / Loki
- [ ] Metrics endpoint: `GET /metrics` (Prometheus-compatible) на каждом агенте
- [ ] Alert: Orchestrator получает webhook при падении агента (Docker healthcheck)
- [ ] Traefik reverse proxy: раскомментировать в docker-stack.yml + TLS
- [ ] Auth: JWT для межагентного API (если выйдет за периметр Swarm сети)
- [ ] Go unit tests: `gateway/internal/tools/executor_test.go`
- [ ] Go integration test: Orchestrator chat loop end-to-end
---
## 🚫 ОТБРОШЕНО (не соответствует архитектуре)
> Эти задачи противоречат концепции "агент = контейнер" или дублируют существующее.
- ~~Browser Agent как Puppeteer в Node.js~~ → заменяется специализированным образом `goclaw-agent-browser`
- ~~server/browser-agent.ts~~ → логика переезжает в отдельный Go binary
- ~~server/tool-builder.ts / server/agent-compiler.ts~~ → реализуются как агент-контейнеры (Phase E)
- ~~BrowserAgent.tsx / ToolBuilder.tsx / AgentCompiler.tsx как отдельные страницы~~ → управляются через стандартный `/agents` с типом
- ~~/skills страница~~ → заменяется `allowedTools` per-agent-container
- ~~server/web-research.ts~~ → реализуется как задача для агента-researcher через `delegate_to_agent`
- ~~server/chat-resilience.ts (Node.js retry)~~ → retry логика в Go agent-worker
- ~~Web Research Panel~~ → задача через Orchestrator chat
- ~~Phase 19 commit to Gitea (NW)~~ → история перезаписана целевой архитектурой