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]

Reply via email to