feat(workflows): add full Workflow section — visual constructor, dashboard & execution engine #1
1
.git-credentials
Normal file
1
.git-credentials
Normal file
@@ -0,0 +1 @@
|
||||
https://x-access-token:ghs_b4NOitjlosRPPypJr3KupAZqrOXlxr4fq5Z9@github.com
|
||||
@@ -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} />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
551
client/src/components/ClusterTopology.tsx
Normal file
551
client/src/components/ClusterTopology.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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: "Ноды" },
|
||||
|
||||
557
client/src/components/TaskBoard.tsx
Normal file
557
client/src/components/TaskBoard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
781
client/src/components/WorkflowCanvas.tsx
Normal file
781
client/src/components/WorkflowCanvas.tsx
Normal 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 · {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} — 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>
|
||||
);
|
||||
}
|
||||
144
client/src/components/WorkflowCreateModal.tsx
Normal file
144
client/src/components/WorkflowCreateModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
233
client/src/components/WorkflowDashboard.tsx
Normal file
233
client/src/components/WorkflowDashboard.tsx
Normal 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 · 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>
|
||||
);
|
||||
}
|
||||
464
client/src/components/WorkflowNodeBlock.tsx
Normal file
464
client/src/components/WorkflowNodeBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
380
client/src/components/WorkflowNodeEditModal.tsx
Normal file
380
client/src/components/WorkflowNodeEditModal.tsx
Normal 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
989
client/src/lib/chatStore.ts
Normal 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
@@ -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> · 4 ноды · 7 агентов · Overlay Network: <span className="text-primary">goclaw-net</span>
|
||||
Кластер <span className="text-primary">goclaw-swarm</span>
|
||||
{!statsLoading && stats && (
|
||||
<> · {stats.nodes} нод · {stats.agents} агентов</>
|
||||
)}
|
||||
{" "}· 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} · 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">· 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> · 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
@@ -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>
|
||||
|
||||
441
client/src/pages/Workflows.tsx
Normal file
441
client/src/pages/Workflows.tsx
Normal 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">
|
||||
← 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 · 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
53
docker/Dockerfile.agent
Normal 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"]
|
||||
45
docker/Dockerfile.agent-worker
Normal file
45
docker/Dockerfile.agent-worker
Normal 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"]
|
||||
@@ -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:-}"
|
||||
|
||||
14
drizzle/0004_llm_providers.sql
Normal file
14
drizzle/0004_llm_providers.sql
Normal 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`)
|
||||
);
|
||||
36
drizzle/0005_chat_sessions.sql
Normal file
36
drizzle/0005_chat_sessions.sql
Normal 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;
|
||||
42
drizzle/0006_swarm_nodes.sql
Normal file
42
drizzle/0006_swarm_nodes.sql
Normal 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';
|
||||
77
drizzle/0007_workflows.sql
Normal file
77
drizzle/0007_workflows.sql
Normal 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`);
|
||||
@@ -29,6 +29,13 @@
|
||||
"when": 1774043298939,
|
||||
"tag": "0003_lazy_hitman",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "5",
|
||||
"when": 1774100000000,
|
||||
"tag": "0004_llm_providers",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
12
drizzle/migrations/0006_agent_container_fields.sql
Normal file
12
drizzle/migrations/0006_agent_container_fields.sql
Normal 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`);
|
||||
@@ -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 сети (8001–8999)
|
||||
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;
|
||||
|
||||
727
gateway/cmd/agent-worker/main.go
Normal file
727
gateway/cmd/agent-worker/main.go
Normal 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 ""
|
||||
}
|
||||
438
gateway/cmd/agent-worker/main_test.go
Normal file
438
gateway/cmd/agent-worker/main_test.go
Normal 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
270
gateway/cmd/agent/main.go
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
142
gateway/internal/api/swarm_manager.go
Normal file
142
gateway/internal/api/swarm_manager.go
Normal 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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
249
server/providers.ts
Normal 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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
418
server/workflows.ts
Normal 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
366
todo.md
@@ -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)~~ → история перезаписана целевой архитектурой
|
||||
|
||||
Reference in New Issue
Block a user