This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
The following commit(s) were added to refs/heads/master by this push:
new b9f2df9 feat(agent): route each agent through its own channel
b9f2df9 is described below
commit b9f2df94136edd2652e8841109b19ebce2efa057
Author: Yang Luo <[email protected]>
AuthorDate: Thu Aug 20 23:07:01 2026 +0800
feat(agent): route each agent through its own channel
---
agent/agent.go | 11 +++
controllers/agent.go | 39 ++++++++++
controllers/proxy.go | 62 +++++++++++++---
object/agent.go | 150 ++++++++++++++++++++++++++++++++++++++
object/agent_test.go | 113 ++++++++++++++++++++++++++++
object/ormer.go | 5 ++
routers/router.go | 2 +
web/src/backend/AgentBackend.ts | 4 +
web/src/lib/agents.ts | 33 ++++++++-
web/src/locales/en/data.json | 9 +++
web/src/locales/zh/data.json | 9 +++
web/src/pages/AgentDetailPage.tsx | 106 ++++++++++++++++++++++++++-
web/src/pages/AgentsPage.tsx | 13 ++++
web/src/types.ts | 2 +
14 files changed, 544 insertions(+), 14 deletions(-)
diff --git a/agent/agent.go b/agent/agent.go
index c8fca24..5bb83fb 100644
--- a/agent/agent.go
+++ b/agent/agent.go
@@ -24,3 +24,14 @@ type Installation struct {
InstallMethod string `json:"installMethod"`
Owner string `json:"owner"`
}
+
+// IsKnownAgentId reads the fingerprints rather than a host scan, so an agent
+// stays configurable while it is not installed.
+func IsKnownAgentId(id string) bool {
+ for i := range fingerprints {
+ if fingerprints[i].ID == id {
+ return true
+ }
+ }
+ return false
+}
diff --git a/controllers/agent.go b/controllers/agent.go
index 95e50a1..7954217 100644
--- a/controllers/agent.go
+++ b/controllers/agent.go
@@ -24,11 +24,16 @@ import (
"github.com/apache/casbin-gateway/agent"
"github.com/apache/casbin-gateway/agentmonitor"
"github.com/apache/casbin-gateway/agentpatch"
+ "github.com/apache/casbin-gateway/object"
)
type discoveredAgent struct {
agent.Installation
agentpatch.Status
+
+ // Channel is the "owner/name" id of the bound channel. Installations
are
+ // discovered per host and the binding is stored per agent id, so they
merge here.
+ Channel string `json:"channel"`
}
// GetAgents scans known installation locations and returns the AI agents
@@ -44,16 +49,50 @@ func (c *ApiController) GetAgents() {
return
}
+ channels, err := object.GetAgentChannels()
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
result := make([]*discoveredAgent, 0, len(installations))
for _, installation := range installations {
result = append(result, &discoveredAgent{
Installation: installation,
Status:
agentpatch.StatusOf(targetOf(installation)),
+ Channel: channels[installation.AgentId],
})
}
c.ResponseOk(result)
}
+// UpdateAgentChannel binds one agent to the channel its requests are forwarded
+// to, or unbinds it when the channel is empty. The binding is per agent id.
+func (c *ApiController) UpdateAgentChannel() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ var form struct {
+ AgentId string `json:"agentId"`
+ Channel string `json:"channel"`
+ }
+ if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ if !agent.IsKnownAgentId(form.AgentId) {
+ c.ResponseError("unknown agent: " + form.AgentId)
+ return
+ }
+
+ if err := object.SetAgentChannel(form.AgentId, form.Channel); err !=
nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ c.ResponseOk(form.Channel)
+}
+
// PatchAgent enables monitoring for one discovered installation.
func (c *ApiController) PatchAgent() {
if c.RequireAdmin() {
diff --git a/controllers/proxy.go b/controllers/proxy.go
index 45b9dc1..715ded7 100644
--- a/controllers/proxy.go
+++ b/controllers/proxy.go
@@ -100,15 +100,8 @@ var proxyClient = &http.Client{
// This endpoint does NOT require Casdoor authentication (auth deferred to
milestone 1.3).
func (c *ApiController) ChatCompletions() {
rawBody := c.Ctx.Input.RequestBody
-
- // Parse only model and stream; forward everything else as-is.
- var req chatCompletionsRequest
- if err := json.Unmarshal(rawBody, &req); err != nil {
- c.writeOpenAIError(http.StatusBadRequest,
"invalid_request_error", "invalid request body")
- return
- }
- if req.Model == "" {
- c.writeOpenAIError(http.StatusBadRequest,
"invalid_request_error", "model is required")
+ req, ok := c.readChatCompletionsRequest(rawBody)
+ if !ok {
return
}
@@ -124,6 +117,53 @@ func (c *ApiController) ChatCompletions() {
return
}
+ c.forwardChatCompletions(channels, rawBody, req.Stream, "model:
"+req.Model)
+}
+
+// AgentChatCompletions is the per-agent entry point of the same proxy: an
agent
+// pointed at ".../v1/agents/<agentId>" reaches the channel bound to it rather
+// than one chosen by model name.
+func (c *ApiController) AgentChatCompletions() {
+ agentId := c.Ctx.Input.Param(":agentId")
+ rawBody := c.Ctx.Input.RequestBody
+ req, ok := c.readChatCompletionsRequest(rawBody)
+ if !ok {
+ return
+ }
+
+ channel, err := object.GetChannelByAgent(agentId)
+ if err != nil {
+ if errors.Is(err, object.ErrAgentNoChannel) {
+ c.writeOpenAIError(http.StatusBadRequest,
"invalid_request_error", err.Error())
+ } else {
+ beego.Error("agent channel lookup failed:", err)
+ c.writeOpenAIError(http.StatusBadGateway,
"server_error", err.Error())
+ }
+ return
+ }
+
+ c.forwardChatCompletions([]*object.Channel{channel}, rawBody,
req.Stream, "agent: "+agentId)
+}
+
+// readChatCompletionsRequest parses only model and stream; everything else
+// (messages, temperature, ...) is forwarded as-is.
+func (c *ApiController) readChatCompletionsRequest(rawBody []byte)
(chatCompletionsRequest, bool) {
+ var req chatCompletionsRequest
+ if err := json.Unmarshal(rawBody, &req); err != nil {
+ c.writeOpenAIError(http.StatusBadRequest,
"invalid_request_error", "invalid request body")
+ return req, false
+ }
+ if req.Model == "" {
+ c.writeOpenAIError(http.StatusBadRequest,
"invalid_request_error", "model is required")
+ return req, false
+ }
+ return req, true
+}
+
+// forwardChatCompletions relays the request to the first channel that answers.
+// route describes how the channels were chosen, for the error a caller sees
+// when none of them can be used.
+func (c *ApiController) forwardChatCompletions(channels []*object.Channel,
rawBody []byte, stream bool, route string) {
// Drop the channels this proxy cannot talk to before forwarding, so
that
// the last usable channel is known and its response can be relayed
as-is.
usableChannels := []*object.Channel{}
@@ -137,7 +177,7 @@ func (c *ApiController) ChatCompletions() {
usableChannels = append(usableChannels, channel)
}
if len(usableChannels) == 0 {
- message := fmt.Sprintf("no usable channel for model: %s",
req.Model)
+ message := fmt.Sprintf("no usable channel for %s", route)
if skipReason != "" {
message = skipReason
}
@@ -155,7 +195,7 @@ func (c *ApiController) ChatCompletions() {
return
}
- status, message, written := c.forwardToChannel(channel,
rawBody, req.Stream, i == len(usableChannels)-1)
+ status, message, written := c.forwardToChannel(channel,
rawBody, stream, i == len(usableChannels)-1)
if written {
return
}
diff --git a/object/agent.go b/object/agent.go
new file mode 100644
index 0000000..d8d8e6b
--- /dev/null
+++ b/object/agent.go
@@ -0,0 +1,150 @@
+// 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.
+
+package object
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/apache/casbin-gateway/util"
+ "github.com/xorm-io/core"
+)
+
+// ErrAgentNoChannel lets the proxy answer with a client error, not a gateway
one.
+var ErrAgentNoChannel = errors.New("no channel is bound to this agent")
+
+// AgentOwner is the owner every agent row is stored under: an agent belongs to
+// the host, and its proxy endpoint is reached without a session.
+const AgentOwner = "admin"
+
+// Agent is the Gateway-side configuration of one kind of AI agent.
Installations
+// are discovered by scanning the host, so this table holds only what is
chosen.
+type Agent struct {
+ Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
+ Name string `xorm:"varchar(100) notnull pk" json:"name"`
+ CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
+ UpdatedTime string `xorm:"varchar(100)" json:"updatedTime"`
+
+ // Channel is the "owner/name" id of the bound channel, empty when
unbound.
+ Channel string `xorm:"varchar(200)" json:"channel"`
+}
+
+func (agent *Agent) GetId() string {
+ return fmt.Sprintf("%s/%s", agent.Owner, agent.Name)
+}
+
+func GetAgent(agentId string) (*Agent, error) {
+ agent := &Agent{Owner: AgentOwner, Name: agentId}
+ existed, err := ormer.Engine.Get(agent)
+ if err != nil {
+ return nil, err
+ }
+ if !existed {
+ return nil, nil
+ }
+ return agent, nil
+}
+
+// GetAgentChannels maps every configured agent id to its channel id.
+func GetAgentChannels() (map[string]string, error) {
+ agents := []*Agent{}
+ if err := ormer.Engine.Where("owner = ?", AgentOwner).Find(&agents);
err != nil {
+ return nil, err
+ }
+
+ channels := map[string]string{}
+ for _, agent := range agents {
+ if agent.Channel != "" {
+ channels[agent.Name] = agent.Channel
+ }
+ }
+ return channels, nil
+}
+
+// SetAgentChannel binds an agent to a channel, or unbinds it when channelId is
+// empty. The channel is resolved here so a typo fails at the form, not at
runtime.
+func SetAgentChannel(agentId string, channelId string) error {
+ if agentId == "" {
+ return errors.New("the agent id is empty")
+ }
+
+ if channelId != "" {
+ channel, err := getChannelById(channelId)
+ if err != nil {
+ return err
+ }
+ if channel == nil {
+ return fmt.Errorf("the channel does not exist: %s",
channelId)
+ }
+ }
+
+ stored, err := GetAgent(agentId)
+ if err != nil {
+ return err
+ }
+
+ now := util.GetCurrentTime()
+ if stored == nil {
+ _, err = ormer.Engine.Insert(&Agent{
+ Owner: AgentOwner,
+ Name: agentId,
+ CreatedTime: now,
+ UpdatedTime: now,
+ Channel: channelId,
+ })
+ return err
+ }
+
+ // Cols() is what writes an empty channel: xorm skips zero values
otherwise.
+ _, err = ormer.Engine.ID(core.PK{AgentOwner, agentId}).
+ Cols("channel", "updated_time").
+ Update(&Agent{Channel: channelId, UpdatedTime: now})
+ return err
+}
+
+// GetChannelByAgent resolves the channel one agent's requests go to. An
unusable
+// binding fails rather than falling back to model routing, so a request never
+// reaches a different upstream than the one configured.
+func GetChannelByAgent(agentId string) (*Channel, error) {
+ agent, err := GetAgent(agentId)
+ if err != nil {
+ return nil, err
+ }
+ if agent == nil || agent.Channel == "" {
+ return nil, fmt.Errorf("%w: %s", ErrAgentNoChannel, agentId)
+ }
+
+ channel, err := getChannelById(agent.Channel)
+ if err != nil {
+ return nil, err
+ }
+ if channel == nil {
+ return nil, fmt.Errorf("the channel bound to agent %s no longer
exists: %s", agentId, agent.Channel)
+ }
+ if channel.Status != "enabled" {
+ return nil, fmt.Errorf("the channel bound to agent %s is
disabled: %s", agentId, agent.Channel)
+ }
+ return channel, nil
+}
+
+// getChannelById is GetChannel() without its panic on a malformed id.
+func getChannelById(channelId string) (*Channel, error) {
+ tokens := strings.Split(channelId, "/")
+ if len(tokens) != 2 || tokens[0] == "" || tokens[1] == "" {
+ return nil, fmt.Errorf("invalid channel ID: %s", channelId)
+ }
+ return getChannel(tokens[0], tokens[1])
+}
diff --git a/object/agent_test.go b/object/agent_test.go
new file mode 100644
index 0000000..c40d091
--- /dev/null
+++ b/object/agent_test.go
@@ -0,0 +1,113 @@
+// 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.
+
+package object
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestSetAgentChannelBindsAndUnbinds(t *testing.T) {
+ initSqliteOrmer(t)
+
+ channel := newTestChannel("codex-upstream", "sk-test")
+ channel.BaseUrl = "https://api.openai.com/v1"
+ if _, err := AddChannel(channel); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := SetAgentChannel("codex", "admin/codex-upstream"); err != nil {
+ t.Fatal(err)
+ }
+ channels, err := GetAgentChannels()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if channels["codex"] != "admin/codex-upstream" {
+ t.Fatalf("expected the binding to be stored, got %q",
channels["codex"])
+ }
+
+ if err := SetAgentChannel("codex", ""); err != nil {
+ t.Fatal(err)
+ }
+ channels, err = GetAgentChannels()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, found := channels["codex"]; found {
+ t.Fatal("expected an empty channel to unbind the agent")
+ }
+}
+
+func TestSetAgentChannelRejectsAMissingChannel(t *testing.T) {
+ initSqliteOrmer(t)
+
+ if err := SetAgentChannel("codex", "admin/nope"); err == nil {
+ t.Fatal("expected binding to a channel that does not exist to
fail")
+ }
+ if err := SetAgentChannel("codex", "nope"); err == nil {
+ t.Fatal("expected a malformed channel id to fail")
+ }
+}
+
+func TestGetChannelByAgent(t *testing.T) {
+ initSqliteOrmer(t)
+
+ channel := newTestChannel("bound", "sk-test")
+ channel.BaseUrl = "https://api.openai.com/v1"
+ if _, err := AddChannel(channel); err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := GetChannelByAgent("codex"); !errors.Is(err,
ErrAgentNoChannel) {
+ t.Fatalf("expected ErrAgentNoChannel for an unbound agent, got
%v", err)
+ }
+
+ if err := SetAgentChannel("codex", "admin/bound"); err != nil {
+ t.Fatal(err)
+ }
+ resolved, err := GetChannelByAgent("codex")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resolved.ApiKey != "sk-test" {
+ t.Fatalf("expected the decrypted API key, got %q",
resolved.ApiKey)
+ }
+
+ if _, err := DeleteChannel(channel); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := GetChannelByAgent("codex"); err == nil {
+ t.Fatal("expected a binding pointing at a deleted channel to
fail")
+ }
+}
+
+func TestGetChannelByAgentRejectsADisabledChannel(t *testing.T) {
+ initSqliteOrmer(t)
+
+ channel := newTestChannel("off", "sk-test")
+ channel.BaseUrl = "https://api.openai.com/v1"
+ channel.Status = "disabled"
+ if _, err := AddChannel(channel); err != nil {
+ t.Fatal(err)
+ }
+ if err := SetAgentChannel("codex", "admin/off"); err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := GetChannelByAgent("codex"); err == nil {
+ t.Fatal("expected a disabled channel to be rejected")
+ }
+}
diff --git a/object/ormer.go b/object/ormer.go
index d04ad29..2d7a183 100644
--- a/object/ormer.go
+++ b/object/ormer.go
@@ -282,4 +282,9 @@ func (a *Ormer) createTable() {
if err != nil {
panic(err)
}
+
+ err = a.Engine.Sync2(new(Agent))
+ if err != nil {
+ panic(err)
+ }
}
diff --git a/routers/router.go b/routers/router.go
index ff6dd26..373fc71 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -66,6 +66,7 @@ func initAPI() {
beego.Router("/api/get-agents", &controllers.ApiController{},
"GET:GetAgents")
beego.Router("/api/patch-agent", &controllers.ApiController{},
"POST:PatchAgent")
beego.Router("/api/unpatch-agent", &controllers.ApiController{},
"POST:UnpatchAgent")
+ beego.Router("/api/update-agent-channel", &controllers.ApiController{},
"POST:UpdateAgentChannel")
beego.Router("/api/get-agent-records", &controllers.ApiController{},
"GET:GetAgentRecords")
beego.Router("/api/get-agent-sessions", &controllers.ApiController{},
"GET:GetAgentSessions")
beego.Router("/api/add-agent-record", &controllers.ApiController{},
"POST:AddAgentRecord")
@@ -95,4 +96,5 @@ func initAPI() {
// OpenAI-compatible chat completions endpoint for LLM gateway
milestone 1.2.
beego.Router("/v1/chat/completions", &controllers.ApiController{},
"POST:ChatCompletions")
+ beego.Router("/v1/agents/:agentId/chat/completions",
&controllers.ApiController{}, "POST:AgentChatCompletions")
}
diff --git a/web/src/backend/AgentBackend.ts b/web/src/backend/AgentBackend.ts
index 1d2c792..868b2c2 100644
--- a/web/src/backend/AgentBackend.ts
+++ b/web/src/backend/AgentBackend.ts
@@ -33,6 +33,10 @@ export function unpatchAgent(target: PatchTarget) {
return request<{followup?: string}>("/api/unpatch-agent", "POST", target);
}
+export function updateAgentChannel(agentId: string, channel: string) {
+ return request("/api/update-agent-channel", "POST", {agentId: agentId,
channel: channel});
+}
+
export function getAgentRecords(agent = "", eventType = "", outcome = "",
session = "", limit = 200) {
return request<AgentRecord[]>(
`/api/get-agent-records${query({
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index f8d411d..f64b435 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -33,6 +33,11 @@ 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 channel. */
+export function agentProxyBaseUrl(agentId: string) {
+ return `${Setting.ServerUrl ||
window.location.origin}/v1/agents/${encodeURIComponent(agentId)}`;
+}
+
/** The detail page route for one installation, disambiguated by its path. */
export function agentDetailPath(agent: Agent) {
return
`/agents/${encodeURIComponent(agent.agentId)}?path=${encodeURIComponent(agent.path)}`;
@@ -108,7 +113,33 @@ export function useAgents(enabled = true) {
[scan],
);
- return {agents, loading, error, busyKey, scanned, scan, togglePatch};
+ const setChannel = React.useCallback(
+ (agent: Agent, channel: string) => {
+ setBusyKey(agentKey(agent));
+ AgentBackend.updateAgentChannel(agent.agentId, channel)
+ .then(res => {
+ if (res.status === "ok") {
+ Setting.showMessage(
+ "success",
+ channel === ""
+ ? i18next.t("agent:Channel cleared")
+ : `${i18next.t("agent:Channel saved")}: ${channel}`,
+ );
+ scan();
+ } else {
+ Setting.showMessage(
+ "error",
+ res.msg || i18next.t("agent:Failed to update agent channel"),
+ );
+ }
+ })
+ .catch(err => Setting.showMessage("error", err.message || String(err)))
+ .then(() => setBusyKey(""));
+ },
+ [scan],
+ );
+
+ return {agents, loading, error, busyKey, scanned, scan, togglePatch,
setChannel};
}
/** What one agent has been up to, derived from its monitoring sessions. */
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index c449adc..f004d06 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -29,6 +29,13 @@
"All event types": "All event types",
"All outcomes": "All outcomes",
"Auto refresh": "Auto refresh",
+ "Base URL copied to clipboard": "Base URL copied to clipboard",
+ "Base URL hint": "Point this agent at the base URL below. Requests that
arrive here always go to the channel selected above, whatever model they name.",
+ "Channel": "Channel",
+ "Channel cleared": "Channel cleared",
+ "Channel hint": "With no channel selected, this agent still talks to its
own upstream and Gateway only watches it.",
+ "Channel saved": "Channel saved",
+ "Copy base URL": "Copy base URL",
"Copy path": "Copy path",
"Detail": "Detail",
"Details": "Details",
@@ -37,6 +44,7 @@
"Failed to get agent records": "Failed to get agent records",
"Failed to get agent sessions": "Failed to get agent sessions",
"Failed to scan agents": "Failed to scan agents",
+ "Failed to update agent channel": "Failed to update agent channel",
"Failed to update agent patch": "Failed to update agent patch",
"Filter": "Filter",
"First activity": "First activity",
@@ -54,6 +62,7 @@
"Monitoring, no activity yet": "Monitoring, no activity yet",
"No agent records yet - patch an agent to start collecting them": "No
activity yet - turn on monitoring for an agent to start collecting it",
"No agent sessions yet - patch an agent to start collecting them": "No
sessions yet - turn on monitoring for an agent to start collecting them",
+ "No channel": "No channel",
"No supported agents found": "No supported agents found",
"Not patched": "Not monitored",
"Not supported": "Not supported yet",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index b961f07..aa793aa 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -29,6 +29,13 @@
"All event types": "全部事件类型",
"All outcomes": "全部结果",
"Auto refresh": "自动刷新",
+ "Base URL copied to clipboard": "Base URL 已复制到剪贴板",
+ "Base URL hint": "把该 Agent 的 base URL
设成下面这个地址。到达这里的请求一律转发到上面选定的渠道,与请求里的模型名无关。",
+ "Channel": "渠道",
+ "Channel cleared": "已清除渠道",
+ "Channel hint": "未选择渠道时,该 Agent 仍直连自己的上游,网关只做观测。",
+ "Channel saved": "渠道已保存",
+ "Copy base URL": "复制 Base URL",
"Copy path": "复制路径",
"Detail": "详情",
"Details": "详情",
@@ -37,6 +44,7 @@
"Failed to get agent records": "获取 Agent 记录失败",
"Failed to get agent sessions": "获取 Agent 会话失败",
"Failed to scan agents": "探测 Agent 失败",
+ "Failed to update agent channel": "更新 Agent 渠道失败",
"Failed to update agent patch": "更新 Agent Patch 失败",
"Filter": "筛选",
"First activity": "首次活动",
@@ -54,6 +62,7 @@
"Monitoring, no activity yet": "监控中,暂无活动",
"No agent records yet - patch an agent to start collecting them":
"还没有活动记录,开启某个智能体的监控后才会开始收集",
"No agent sessions yet - patch an agent to start collecting them":
"还没有会话,开启某个智能体的监控后才会开始收集",
+ "No channel": "未设置渠道",
"No supported agents found": "没有发现本机安装的智能体",
"Not patched": "未开启",
"Not supported": "暂不支持",
diff --git a/web/src/pages/AgentDetailPage.tsx
b/web/src/pages/AgentDetailPage.tsx
index 15b27a7..2b7f6a7 100644
--- a/web/src/pages/AgentDetailPage.tsx
+++ b/web/src/pages/AgentDetailPage.tsx
@@ -19,6 +19,7 @@ import copy from "copy-to-clipboard";
import i18next from "i18next";
import * as AgentBackend from "@/backend/AgentBackend";
+import * as ChannelBackend from "@/backend/ChannelBackend";
import * as Setting from "@/Setting";
import {AgentIcon} from "@/components/AgentIcon";
import {DataTable, type Column} from "@/components/DataTable";
@@ -28,17 +29,28 @@ import {Badge} from "@/components/ui/badge";
import {Button} from "@/components/ui/button";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {ConfirmButton} from "@/components/ui/confirm-button";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
import {Spinner} from "@/components/ui/spinner";
import {Tooltip} from "@/components/ui/tooltip";
import {cn} from "@/lib/utils";
import {
agentKey,
+ agentProxyBaseUrl,
getOutcomeVariant,
monitorAgentId,
useAgents,
useAgentSessions,
} from "@/lib/agents";
-import type {Account, Agent, AgentRecord, AgentSession} from "@/types";
+import type {Account, Agent, AgentRecord, AgentSession, Channel} from
"@/types";
+
+/** Radix rejects an empty item value, so "unbound" needs a stand-in. */
+const noChannel = "-";
const tabs = ["Agent Sessions", "Agent Records"] as const;
type Tab = (typeof tabs)[number];
@@ -109,14 +121,84 @@ function MonitoringCard({
);
}
+/** Which upstream this agent's requests go to, and the base URL that sends
them there. */
+function ChannelCard({
+ agent,
+ channels,
+ busy,
+ onChange,
+}: {
+ agent: Agent;
+ channels: Channel[];
+ busy: boolean;
+ onChange: (channel: string) => void;
+}) {
+ const baseUrl = agentProxyBaseUrl(agent.agentId);
+
+ return (
+ <Card>
+ <CardHeader className="p-4 pb-2">
+ <CardTitle
className="text-base">{i18next.t("agent:Channel")}</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-3 p-4 pt-0">
+ <Select
+ value={agent.channel === "" ? noChannel : agent.channel}
+ disabled={busy}
+ onValueChange={value => onChange(value === noChannel ? "" : value)}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value={noChannel}>
+ <span className="text-muted-foreground">{i18next.t("agent:No
channel")}</span>
+ </SelectItem>
+ {channels.map(channel => (
+ <SelectItem key={`${channel.owner}/${channel.name}`}
value={`${channel.owner}/${channel.name}`}>
+ {channel.displayName || channel.name}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+
+ {agent.channel === "" ? (
+ <p className="text-sm
text-muted-foreground">{i18next.t("agent:Channel hint")}</p>
+ ) : (
+ <>
+ <p className="text-sm
text-muted-foreground">{i18next.t("agent:Base URL hint")}</p>
+ <div className="flex items-start gap-1">
+ <code className="min-w-0 flex-1 break-all
text-xs">{baseUrl}</code>
+ <Tooltip title={i18next.t("agent:Copy base URL")}>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-6 w-6 shrink-0"
+ aria-label={i18next.t("agent:Copy base URL")}
+ onClick={() => {
+ copy(baseUrl);
+ Setting.showMessage("success", i18next.t("agent:Base URL
copied to clipboard"));
+ }}
+ >
+ <Copy className="h-3.5 w-3.5" />
+ </Button>
+ </Tooltip>
+ </div>
+ </>
+ )}
+ </CardContent>
+ </Card>
+ );
+}
+
export default function AgentDetailPage({account}: {account: Account}) {
const params = useParams();
const [searchParams] = useSearchParams();
const isAdmin = Setting.isAdminUser(account);
- const {agents, error, busyKey, scanned, scan, togglePatch} =
useAgents(isAdmin);
+ const {agents, error, busyKey, scanned, scan, togglePatch, setChannel} =
useAgents(isAdmin);
const [tab, setTab] = React.useState<Tab>("Agent Sessions");
const [records, setRecords] = React.useState<AgentRecord[]>([]);
const [recordError, setRecordError] = React.useState("");
+ const [channels, setChannels] = React.useState<Channel[]>([]);
const agentId = params.agentId ?? "";
const path = searchParams.get("path") ?? "";
@@ -128,6 +210,19 @@ export default function AgentDetailPage({account}:
{account: Account}) {
candidate.agentId === agentId && (path === "" || candidate.path ===
path),
);
+ React.useEffect(() => {
+ if (!isAdmin) {
+ return;
+ }
+ ChannelBackend.getChannels(account.name)
+ .then(res => {
+ if (res.status === "ok") {
+ setChannels(res.data ?? []);
+ }
+ })
+ .catch(() => setChannels([]));
+ }, [isAdmin, account.name]);
+
const monitorId = agent ? monitorAgentId(agent.agentId) : "";
const watching = Boolean(agent?.patched);
const {sessions} = useAgentSessions(isAdmin && watching, monitorId, 5000);
@@ -293,6 +388,13 @@ export default function AgentDetailPage({account}:
{account: Account}) {
onToggle={() => togglePatch(agent)}
/>
+ <ChannelCard
+ agent={agent}
+ channels={channels}
+ busy={busyKey === agentKey(agent)}
+ onChange={value => setChannel(agent, value)}
+ />
+
<Card>
<CardHeader className="p-4 pb-2">
<CardTitle
className="text-base">{i18next.t("agent:Installation")}</CardTitle>
diff --git a/web/src/pages/AgentsPage.tsx b/web/src/pages/AgentsPage.tsx
index 36a0bf6..13964fe 100644
--- a/web/src/pages/AgentsPage.tsx
+++ b/web/src/pages/AgentsPage.tsx
@@ -76,6 +76,19 @@ export default function AgentsPage({account}: {account:
Account}) {
dataIndex: "path",
render: (value: string) => <code className="text-xs">{value}</code>,
},
+ {
+ title: i18next.t("agent:Channel"),
+ key: "channel",
+ dataIndex: "channel",
+ render: (value: string) =>
+ value ? (
+ <Link to={`/channels/${value}`} className="text-primary
hover:underline">
+ {value}
+ </Link>
+ ) : (
+ <span className="text-muted-foreground">{i18next.t("agent:No
channel")}</span>
+ ),
+ },
{
title: i18next.t("agent:Patch Status"),
key: "patched",
diff --git a/web/src/types.ts b/web/src/types.ts
index 16b7703..ac24790 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -173,6 +173,8 @@ export interface Agent {
detail?: string;
notice?: string;
followup?: string;
+ /** The "owner/name" id of the channel this agent's requests are sent to. */
+ channel: string;
}
export interface AgentRecord {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]