Files
GoClaw/server/gateway-proxy.ts
bboxwtf e228e7a655 fix(agents): provider pre-selection, magic-wand auto-fill, maxTokens from Ollama API
1. AgentDetailModal – fix provider not being pre-selected on edit open:
   - Add resolveProviderValue() that does exact → case-insensitive → partial
     match between stored provider string and connectedProviders list
   - Re-resolve provider in a second useEffect once providers load from API
   - Add safety-net SelectItem for stored value not found in providers list

2. AgentCreateModal – refactor Deploy Agent form:
   - Fix Provider + Model fields layout (grid-cols-2 with w-full truncate to
     prevent overflow/merging)
   - Add Wand2 'Auto-fill' button next to Agent Name field that calls
     agentCompiler.compile (existing LLM endpoint) with name+description as
     spec — fills role, model, temperature, systemPrompt automatically
   - Add Sparkles hint text explaining the magic wand functionality
   - Auto-select first provider/model when data loads
   - All fields use font-mono + proper label spacing

3. Both modals – MaxTokens auto-fill from Ollama API:
   - Add getOllamaModelInfo() in gateway-proxy.ts: calls Ollama /api/show,
     extracts {arch}.context_length from model_info, returns contextLength +
     parameterSize, family, quantization, capabilities
   - Add ollama.modelInfo tRPC query endpoint in routers.ts (input: modelId)
   - Both modals query trpc.ollama.modelInfo on model selection change
   - Auto-set maxTokens to context_length from API (262144 for kimi-k2.5 etc.)
   - Show 'max N from API' hint + clickable link to set full context window
   - Loading spinner while fetching model info
2026-03-21 19:41:15 +00:00

719 lines
21 KiB
TypeScript

/**
* GoClaw Gateway Proxy
*
* Forwards orchestrator/agent/tool requests from the Node.js tRPC server
* to the Go Gateway running on :18789.
*
* The Go Gateway handles:
* - LLM orchestration (tool-use loop)
* - Tool execution (shell, file, docker, http)
* - Agent listing from DB
* - Model listing from LLM provider
*
* When GATEWAY_URL is not set or the gateway is unreachable, all functions
* return a structured error so callers can fall back gracefully.
*/
const GATEWAY_BASE_URL = process.env.GATEWAY_URL ?? "http://localhost:18789";
const GATEWAY_TIMEOUT_MS = 180_000; // 3 min — LLM can be slow
const QUICK_TIMEOUT_MS = 5_000;
// ─── Types ────────────────────────────────────────────────────────────────────
export interface GatewayMessage {
role: "user" | "assistant" | "system";
content: string;
}
export interface GatewayToolCallStep {
tool: string;
args: Record<string, unknown>;
result: unknown; // required — matches ToolCallStep interface in Chat.tsx / Skills.tsx
error?: string;
success: boolean;
durationMs: number;
}
export interface GatewayChatResult {
success: boolean;
response: string;
toolCalls: GatewayToolCallStep[];
model?: string;
modelWarning?: string;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
error?: string;
}
export interface GatewayOrchestratorConfig {
id: number | null;
name: string;
model: string;
temperature: number;
maxTokens: number;
allowedTools: string[];
systemPromptPreview: string;
}
export interface GatewayToolDef {
name: string;
description: string;
parameters: Record<string, unknown>;
}
export interface GatewayToolResult {
success: boolean;
output?: unknown;
error?: string;
durationMs: number;
}
export interface GatewayHealthResult {
connected: boolean;
latencyMs: number;
llm?: { connected: boolean; latencyMs: number };
error?: string;
}
// ─── Health ───────────────────────────────────────────────────────────────────
/**
* Check if the Go Gateway is running and healthy.
* Also returns LLM provider health (Ollama / cloud API).
*/
export async function checkGatewayHealth(): Promise<GatewayHealthResult> {
const start = Date.now();
try {
const res = await fetch(`${GATEWAY_BASE_URL}/health`, {
signal: AbortSignal.timeout(QUICK_TIMEOUT_MS),
});
const latencyMs = Date.now() - start;
if (res.ok) {
const data = await res.json();
return {
connected: true,
latencyMs,
// Go Gateway may return "ollama" or "llm" key depending on version
llm: data.llm ?? data.ollama,
};
}
return { connected: false, latencyMs, error: `HTTP ${res.status}` };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return { connected: false, latencyMs: Date.now() - start, error: msg };
}
}
/**
* Returns true if the Go Gateway is reachable (fast check, no LLM ping).
*/
export async function isGatewayAvailable(): Promise<boolean> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/health`, {
signal: AbortSignal.timeout(2_000),
});
return res.ok;
} catch {
return false;
}
}
// ─── 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 ─────────────────────────────────────────────────────────────
/**
* Send a chat message to the Go Orchestrator.
* Includes full tool-use loop — the Go side handles all LLM ↔ tool iterations.
*/
export async function gatewayChat(
messages: GatewayMessage[],
model?: string,
maxIter?: number
): Promise<GatewayChatResult> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/orchestrator/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages, model, maxIter }),
signal: AbortSignal.timeout(GATEWAY_TIMEOUT_MS),
});
if (!res.ok) {
const text = await res.text();
return {
success: false,
response: "",
toolCalls: [],
error: `Gateway error (${res.status}): ${text}`,
};
}
return res.json();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return {
success: false,
response: "",
toolCalls: [],
error: `Gateway unreachable: ${msg}. Is the Go Gateway running on ${GATEWAY_BASE_URL}?`,
};
}
}
/**
* Get orchestrator config from Go Gateway (reads from DB).
*/
export async function getGatewayOrchestratorConfig(): Promise<GatewayOrchestratorConfig | null> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/orchestrator/config`, {
signal: AbortSignal.timeout(QUICK_TIMEOUT_MS),
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
// ─── Models ───────────────────────────────────────────────────────────────────
/**
* Get list of models from Go Gateway (proxied from LLM provider).
*/
export async function getGatewayModels(): Promise<{ data: { id: string }[] } | null> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/models`, {
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
// ─── Agents ───────────────────────────────────────────────────────────────────
/**
* Get list of agents from Go Gateway (reads from DB).
*/
export async function getGatewayAgents(): Promise<{ agents: unknown[]; count: number } | null> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/agents`, {
signal: AbortSignal.timeout(QUICK_TIMEOUT_MS),
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
/**
* Get a single agent by ID from Go Gateway.
*/
export async function getGatewayAgent(id: number): Promise<unknown | null> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/agents/${id}`, {
signal: AbortSignal.timeout(QUICK_TIMEOUT_MS),
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
// ─── Tools ────────────────────────────────────────────────────────────────────
/**
* Get list of available tools from Go Gateway.
*/
export async function getGatewayTools(): Promise<GatewayToolDef[] | null> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/tools`, {
signal: AbortSignal.timeout(QUICK_TIMEOUT_MS),
});
if (!res.ok) return null;
const data = await res.json();
// Go returns OpenAI format: { tools: [{type: "function", function: {name, description, parameters}}, ...], count: N }
if (!Array.isArray(data.tools)) return null;
return data.tools.map((t: { type?: string; function?: { name: string; description: string; parameters: Record<string, unknown> }; name?: string; description?: string; parameters?: Record<string, unknown> }) => {
// Handle OpenAI format: {type: "function", function: {name, ...}}
if (t.function) {
return {
name: t.function.name,
description: t.function.description,
parameters: t.function.parameters,
} as GatewayToolDef;
}
// Handle flat format: {name, description, parameters}
return t as GatewayToolDef;
});
} catch {
return null;
}
}
/**
* Execute a single tool via Go Gateway.
*/
export async function executeGatewayTool(
toolName: string,
args: Record<string, unknown>
): Promise<GatewayToolResult> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/tools/execute`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tool: toolName, args }),
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) {
const text = await res.text();
return {
success: false,
error: `Gateway error (${res.status}): ${text}`,
durationMs: 0,
};
}
return res.json();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return {
success: false,
error: `Gateway unreachable: ${msg}`,
durationMs: 0,
};
}
}
// ─── Nodes / Docker Swarm ─────────────────────────────────────────────────────
export interface GatewayNodeInfo {
id: string;
hostname: string;
role: string;
status: 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 GatewayContainerInfo {
id: string;
name: string;
image: string;
state: string;
status: string;
}
export interface GatewayNodesResult {
nodes: GatewayNodeInfo[];
count: number;
swarmActive: boolean;
managers?: number;
totalNodes?: number;
containers?: GatewayContainerInfo[];
fetchedAt: string;
}
export interface GatewayContainerStat {
id: string;
name: string;
cpuPct: number;
memUseMB: number;
memLimMB: number;
memPct: number;
}
export interface GatewayNodeStatsResult {
stats: GatewayContainerStat[];
count: number;
fetchedAt: string;
}
/**
* Get Docker Swarm nodes (or standalone Docker host) from Go Gateway.
*/
export async function getGatewayNodes(): Promise<GatewayNodesResult | null> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/nodes`, {
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
/**
* Get live container CPU/RAM stats from Go Gateway.
*/
export async function getGatewayNodeStats(): Promise<GatewayNodeStatsResult | null> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/nodes/stats`, {
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) return null;
return res.json();
} catch {
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>;
updatedAt: string;
ports: string[];
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;
return res.json();
} 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; }
}
/** 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;
}): 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; }
}