blob: fd558b3976f420b2ac72c62240644d3040b24b63 [file]
// Copyright 2026 The casbin Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import * as React from "react";
import i18next from "i18next";
import * as AgentBackend from "@/backend/AgentBackend";
import * as Setting from "@/Setting";
import type {BadgeVariant} from "@/components/ui/badge";
import {providerProtocol, servesResponsesApi} from "@/lib/providers";
import type {Agent, AgentRuntime, AgentSession, Provider} from "@/types";
/** How long a started app is given before its process is looked for again. */
const runtimeSettleMs = 2000;
/** Routing through the local proxy, which is what makes a switch hot. */
export const gatewayMode = "gateway";
export const directMode = "direct";
/** The monitor keys the two Codex front ends under one agent id. */
export function monitorAgentId(agentId: string) {
return agentId === "codex_vscode" || agentId === "codex-vscode" ? "codex-cli" : agentId;
}
/**
* agentKey identifies one installation. The same agent can be installed twice
* on a host - once per user account, or once per install method - so neither
* the agent id nor the path is unique on its own.
*/
export function agentKey(agent: Pick<Agent, "owner" | "path">) {
return `${agent.owner}:${agent.path}`;
}
/**
* The base URL an agent is pointed at to reach its own provider. One URL serves
* every wire format: an OpenAI client appends /chat/completions to it, Codex
* appends /responses, and an Anthropic one appends /v1/messages.
*/
export function agentProxyBaseUrl(agentId: string) {
return `${Setting.ServerUrl || window.location.origin}/v1/agents/${encodeURIComponent(agentId)}`;
}
/**
* A config file that carries the same setting for good, for the agents that
* have one. Empty when only the environment is known to work.
*/
export function agentSetupNoteKey(agentId: string) {
switch (agentId) {
case "claude-code":
return "agent:Claude Code config hint";
case "codex-cli":
return "agent:Codex CLI config hint";
default:
return "";
}
}
/**
* Mirrors the check in agentprovider's Codex writer: Codex speaks only the
* OpenAI Responses API, so it reaches a chat completions provider through
* Gateway, which translates, or not at all.
*/
export function agentNeedsResponsesApi(agentId: string) {
return agentId.startsWith("codex");
}
/**
* The wire format an agent's client speaks, as the server reports it. Empty for
* an agent Gateway does not know, which is then not filtered on.
*/
export function agentProtocol(agent: Agent) {
return agent.providerConfig?.protocol ?? "";
}
/**
* Whether an agent can be pointed at a provider serving this wire format.
* Through the gateway any provider will do: the proxy translates between the
* APIs. A direct binding writes the provider's own URL into the agent config,
* so there the two have to speak the same one.
*/
export function agentSpeaks(agent: Agent, protocol: string) {
if ((agent.mode || gatewayMode) !== directMode) {
return true;
}
const spoken = agentProtocol(agent);
return spoken === "" || spoken === protocol;
}
/**
* Whether this agent, routed the way it is now, can be pointed at this provider
* at all. Through the gateway anything goes, since the proxy translates; a
* direct binding hands the agent the provider's own URL, and there both the
* wire format and, for Codex, the Responses API have to line up.
*/
export function agentCanUse(agent: Agent, provider: Provider) {
if ((agent.mode || gatewayMode) !== directMode) {
return true;
}
if (agentNeedsResponsesApi(agent.agentId) && !servesResponsesApi(provider)) {
return false;
}
return agentSpeaks(agent, providerProtocol(provider.type));
}
/**
* The detail page route for one installation. The path is only spelled out when
* the same agent is installed more than once, which is what makes the id alone
* ambiguous; otherwise the URL stays short.
*/
export function agentDetailPath(agent: Agent, agents: Agent[] = []) {
const url = `/agents/${encodeURIComponent(agent.agentId)}`;
const duplicated = agents.filter(candidate => candidate.agentId === agent.agentId).length > 1;
return duplicated ? `${url}?path=${encodeURIComponent(agent.path)}` : url;
}
/**
* useAgents owns the local-installation scan and the monitoring toggle, which
* the dashboard, the list page and the detail page all need.
*/
export function useAgents(enabled = true) {
const [agents, setAgents] = React.useState<Agent[]>([]);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState("");
const [busyKey, setBusyKey] = React.useState("");
// The run state is keyed by installation and loaded on its own, so it can be
// refreshed after a start or a stop without scanning the disk again.
const [runtime, setRuntime] = React.useState<Record<string, AgentRuntime>>({});
const [runBusyKey, setRunBusyKey] = React.useState("");
// A scan inside a container reads the container's filesystem, not the host's,
// which is why it can come back empty on a machine full of agents.
const [inContainer, setInContainer] = React.useState(false);
// A scan is only "done" once the first response has landed, which is what
// tells an empty list apart from a list that has not arrived yet.
const [scanned, setScanned] = React.useState(false);
const scan = React.useCallback(
(forceRefresh = false) => {
if (!enabled) {
return;
}
setLoading(true);
setError("");
AgentBackend.getAgents(forceRefresh)
.then(res => {
if (res.status === "ok") {
setAgents(res.data ?? []);
setInContainer(res.data2 === true);
} else {
setError(res.msg || i18next.t("agent:Failed to scan agents"));
}
})
.catch(err => setError(err.message || String(err)))
.then(() => {
setLoading(false);
setScanned(true);
});
},
[enabled],
);
const loadRuntime = React.useCallback((forceRefresh = false) => {
if (!enabled) {
return;
}
AgentBackend.getAgentProcesses(forceRefresh)
.then(res => {
if (res.status === "ok") {
const next: Record<string, AgentRuntime> = {};
(res.data ?? []).forEach(item => {
next[agentKey(item)] = item;
});
setRuntime(next);
}
})
.catch(() => undefined);
}, [enabled]);
React.useEffect(() => {
scan();
loadRuntime();
}, [scan, loadRuntime]);
/**
* toggleRunning starts the agent or ends every process of it. A desktop app
* takes a moment to show up in the process table, so the run state is read
* again shortly after the call rather than only once.
*/
const toggleRunning = React.useCallback(
(agent: Agent, running: boolean) => {
const target = {agentId: agent.agentId, path: agent.path, owner: agent.owner};
setRunBusyKey(agentKey(agent));
(running ? AgentBackend.stopAgent(target) : AgentBackend.startAgent(target))
.then(res => {
if (res.status === "ok") {
Setting.showMessage(
"success",
`${i18next.t(running ? "agent:Agent stopped" : "agent:Agent started")}: ${agent.name}`,
);
setTimeout(() => loadRuntime(true), runtimeSettleMs);
} else {
Setting.showMessage(
"error",
res.msg ||
i18next.t(running ? "agent:Failed to stop the agent" : "agent:Failed to start the agent"),
);
}
loadRuntime(true);
})
.catch(err => Setting.showMessage("error", err.message || String(err)))
.then(() => setRunBusyKey(""));
},
[loadRuntime],
);
const togglePatch = React.useCallback(
(agent: Agent) => {
const target = {agentId: agent.agentId, path: agent.path, owner: agent.owner};
const patched = agent.patched;
setBusyKey(agentKey(agent));
(patched ? AgentBackend.unpatchAgent(target) : AgentBackend.patchAgent(target))
.then(res => {
if (res.status === "ok") {
const done = patched
? i18next.t("agent:Monitoring disabled")
: i18next.t("agent:Monitoring enabled");
const followup = res.data?.followup || agent.followup;
Setting.showMessage(
"success",
followup ? `${done}: ${agent.name}. ${followup}` : `${done}: ${agent.name}`,
);
scan();
} else {
Setting.showMessage("error", res.msg || i18next.t("agent:Failed to update agent patch"));
}
})
.catch(err => Setting.showMessage("error", err.message || String(err)))
.then(() => setBusyKey(""));
},
[scan],
);
/**
* setRouting saves where an agent's requests go. Only the changed part is
* passed in; the rest of the routing is carried over from the agent so a
* fallback list is not dropped by a provider change.
*/
const setRouting = React.useCallback(
(agent: Agent, routing: Partial<AgentBackend.AgentRouting>) => {
const next: AgentBackend.AgentRouting = {
provider: routing.provider ?? agent.provider,
fallbacks: routing.fallbacks ?? agent.fallbacks ?? [],
mode: routing.mode ?? agent.mode ?? gatewayMode,
};
setBusyKey(agentKey(agent));
AgentBackend.updateAgentRouting(agent.agentId, next)
.then(res => {
if (res.status === "ok") {
Setting.showMessage(
"success",
next.provider === ""
? i18next.t("agent:Provider cleared")
: `${i18next.t("agent:Provider saved")}: ${next.provider}`,
);
scan();
} else {
Setting.showMessage(
"error",
res.msg || i18next.t("agent:Failed to update agent provider"),
);
// The routing itself may already be stored: only writing the agent's
// own configuration file failed, and the page has to show that.
scan();
}
})
.catch(err => Setting.showMessage("error", err.message || String(err)))
.then(() => setBusyKey(""));
},
[scan],
);
/**
* activateProvider is the one click the home page is built around: it stores
* the routing and, for an agent whose configuration file Gateway knows how to
* write, writes it, so the agent is on the new provider with nothing left to
* press.
*/
const activateProvider = React.useCallback(
(agent: Agent, providerId: string) => {
const target = {agentId: agent.agentId, path: agent.path, owner: agent.owner};
const routing: AgentBackend.AgentRouting = {
provider: providerId,
fallbacks: [],
mode: agent.mode || gatewayMode,
};
const writes = providerId !== "" && (agent.providerConfig?.supported ?? false);
setBusyKey(agentKey(agent));
AgentBackend.updateAgentRouting(agent.agentId, routing)
.then(res => {
if (res.status !== "ok") {
throw new Error(res.msg || i18next.t("agent:Failed to update agent provider"));
}
return writes ? AgentBackend.applyAgentProvider(target) : res;
})
.then(res => {
if (res.status !== "ok") {
throw new Error(res.msg || i18next.t("agent:Failed to write the agent configuration"));
}
Setting.showMessage(
"success",
providerId === ""
? i18next.t("agent:Provider cleared")
: `${i18next.t("agent:Provider enabled")}: ${providerId}`,
);
})
.catch(err => Setting.showMessage("error", err.message || String(err)))
// The routing may be stored even when the file write failed, so the
// scan runs either way and the page shows what actually happened.
.then(() => {
scan();
setBusyKey("");
});
},
[scan],
);
/**
* writeProvider writes the bound provider into the agent's own config file, or
* puts back what the file held before Gateway first wrote it.
*/
const writeProvider = React.useCallback(
(agent: Agent, restore = false) => {
const target = {agentId: agent.agentId, path: agent.path, owner: agent.owner};
setBusyKey(agentKey(agent));
(restore
? AgentBackend.restoreAgentProvider(target)
: AgentBackend.applyAgentProvider(target))
.then(res => {
if (res.status === "ok") {
Setting.showMessage(
"success",
restore
? i18next.t("agent:Configuration restored")
: i18next.t("agent:Configuration written"),
);
scan();
} else {
Setting.showMessage(
"error",
res.msg || i18next.t("agent:Failed to write the agent configuration"),
);
}
})
.catch(err => Setting.showMessage("error", err.message || String(err)))
.then(() => setBusyKey(""));
},
[scan],
);
return {
agents,
loading,
error,
busyKey,
scanned,
inContainer,
runtime,
runBusyKey,
scan,
loadRuntime,
toggleRunning,
togglePatch,
setRouting,
activateProvider,
writeProvider,
};
}
/** The run state of one installation, before the first listing has landed. */
export function runtimeOf(runtime: Record<string, AgentRuntime>, agent: Agent) {
return runtime[agentKey(agent)];
}
/** What one agent has been up to, derived from its monitoring sessions. */
export interface AgentActivity {
sessionCount: number;
recordCount: number;
lastTime: string;
}
/**
* useAgentSessions loads the live session summaries. Each summary already
* carries its agent, record count and last activity, so the per-agent totals
* the dashboard shows are folded out of this one request rather than out of the
* far larger raw record list.
*/
export function useAgentSessions(enabled = true, agent = "", refreshMs = 0) {
const [sessions, setSessions] = React.useState<AgentSession[]>([]);
const [error, setError] = React.useState("");
const load = React.useCallback(() => {
if (!enabled) {
return;
}
AgentBackend.getAgentSessions(agent)
.then(res => {
if (res.status === "ok") {
setSessions(res.data ?? []);
setError("");
} else {
setError(res.msg || i18next.t("agent:Failed to get agent sessions"));
}
})
.catch(err => setError(err.message || String(err)));
}, [enabled, agent]);
React.useEffect(() => {
load();
if (!enabled || refreshMs <= 0) {
return undefined;
}
const interval = setInterval(load, refreshMs);
return () => clearInterval(interval);
}, [enabled, load, refreshMs]);
const activity = React.useMemo(() => {
const totals: Record<string, AgentActivity> = {};
sessions.forEach(session => {
const current = totals[session.agent] ?? {sessionCount: 0, recordCount: 0, lastTime: ""};
totals[session.agent] = {
sessionCount: current.sessionCount + 1,
recordCount: current.recordCount + session.recordCount,
lastTime:
current.lastTime === "" || session.lastTime > current.lastTime
? session.lastTime
: current.lastTime,
};
});
return totals;
}, [sessions]);
const recordCount = React.useMemo(
() => sessions.reduce((total, session) => total + session.recordCount, 0),
[sessions],
);
return {sessions, activity, recordCount, error, reload: load};
}
/** The activity of the installation, looked up under the id the monitor uses. */
export function activityOf(activity: Record<string, AgentActivity>, agent: Agent) {
return activity[monitorAgentId(agent.agentId)];
}
/** The badge colour every record table paints an outcome with. */
export function getOutcomeVariant(outcome: string | undefined): BadgeVariant {
return (
{
attempted: "info",
denied: "warning",
failure: "danger",
success: "success",
} as const
)[outcome as "attempted" | "denied" | "failure" | "success"] ?? "muted";
}