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 f26b9fc  feat(agent): point out when a scan ran inside a container
f26b9fc is described below

commit f26b9fce79f1f86ccfe7ec78041cf3602ae7d499
Author: Yang Luo <[email protected]>
AuthorDate: Fri Aug 21 17:27:41 2026 +0800

    feat(agent): point out when a scan ran inside a container
---
 agent/container.go                   |  61 ++++++++++++++++++
 controllers/agent.go                 |   2 +-
 web/src/backend/AgentBackend.ts      |   3 +-
 web/src/lib/agents.ts                |  17 ++++-
 web/src/lib/channels.ts              |  58 ++++++++++++++++++
 web/src/locales/en/data.json         |  18 +++++-
 web/src/locales/zh/data.json         |  18 +++++-
 web/src/pages/AgentDashboardPage.tsx |  90 +++++++++++++++++++++++++--
 web/src/pages/AgentsPage.tsx         |  10 +--
 web/src/pages/ChannelEditPage.tsx    |  31 ++++------
 web/src/pages/ChannelListPage.tsx    | 116 ++++++++++++++++++++++++++---------
 11 files changed, 362 insertions(+), 62 deletions(-)

diff --git a/agent/container.go b/agent/container.go
new file mode 100644
index 0000000..1b57563
--- /dev/null
+++ b/agent/container.go
@@ -0,0 +1,61 @@
+// 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 agent
+
+import (
+       "os"
+       "strings"
+       "sync"
+)
+
+var (
+       containerOnce sync.Once
+       container     bool
+)
+
+// InContainer reports whether Gateway runs inside a container, where a scan
+// reads the container's own filesystem instead of the host's and therefore
+// finds none of the agents the user actually installed.
+func InContainer() bool {
+       containerOnce.Do(func() {
+               container = detectContainer()
+       })
+       return container
+}
+
+func detectContainer() bool {
+       // Set by our own compose file, and by Podman inside every container.
+       if os.Getenv("RUNNING_IN_DOCKER") == "true" || os.Getenv("container") 
!= "" {
+               return true
+       }
+
+       // Docker leaves the first file behind, Podman the second.
+       for _, path := range []string{"/.dockerenv", "/run/.containerenv"} {
+               if _, err := os.Stat(path); err == nil {
+                       return true
+               }
+       }
+
+       data, err := os.ReadFile("/proc/1/cgroup")
+       if err != nil {
+               return false
+       }
+       for _, marker := range []string{"docker", "libpod", "kubepods", 
"containerd"} {
+               if strings.Contains(string(data), marker) {
+                       return true
+               }
+       }
+       return false
+}
diff --git a/controllers/agent.go b/controllers/agent.go
index 3a3eead..56bafd2 100644
--- a/controllers/agent.go
+++ b/controllers/agent.go
@@ -81,7 +81,7 @@ func (c *ApiController) GetAgents() {
                }
                result = append(result, item)
        }
-       c.ResponseOk(result)
+       c.ResponseOk(result, agent.InContainer())
 }
 
 // UpdateAgentChannel binds one agent to the channel its requests are forwarded
diff --git a/web/src/backend/AgentBackend.ts b/web/src/backend/AgentBackend.ts
index 23f339e..178c6e2 100644
--- a/web/src/backend/AgentBackend.ts
+++ b/web/src/backend/AgentBackend.ts
@@ -21,8 +21,9 @@ export interface PatchTarget {
   owner: string;
 }
 
+/** `data2` tells whether the scan ran inside a container. */
 export function getAgents(forceRefresh = false) {
-  return request<Agent[]>(`/api/get-agents${forceRefresh ? "?refresh=true" : 
""}`);
+  return request<Agent[], boolean>(`/api/get-agents${forceRefresh ? 
"?refresh=true" : ""}`);
 }
 
 export function patchAgent(target: PatchTarget) {
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index 7604f45..db9a4cd 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -76,6 +76,9 @@ export function useAgents(enabled = true) {
   const [loading, setLoading] = React.useState(false);
   const [error, setError] = React.useState("");
   const [busyKey, setBusyKey] = 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);
@@ -92,6 +95,7 @@ export function useAgents(enabled = true) {
         .then(res => {
           if (res.status === "ok") {
             setAgents(res.data ?? []);
+            setInContainer(res.data2 === true);
           } else {
             setError(res.msg || i18next.t("agent:Failed to scan agents"));
           }
@@ -211,7 +215,18 @@ export function useAgents(enabled = true) {
     [scan],
   );
 
-  return {agents, loading, error, busyKey, scanned, scan, togglePatch, 
setRouting, writeProvider};
+  return {
+    agents,
+    loading,
+    error,
+    busyKey,
+    scanned,
+    inContainer,
+    scan,
+    togglePatch,
+    setRouting,
+    writeProvider,
+  };
 }
 
 /** What one agent has been up to, derived from its monitoring sessions. */
diff --git a/web/src/lib/channels.ts b/web/src/lib/channels.ts
index 3ad69ca..266c060 100644
--- a/web/src/lib/channels.ts
+++ b/web/src/lib/channels.ts
@@ -68,3 +68,61 @@ export function shellForPath(path: string): Shell {
 export function localShell(): Shell {
   return navigator.userAgent.includes("Windows") ? "PowerShell" : "bash";
 }
+
+/** A vendor the channel forms can fill themselves in from. */
+export interface ChannelPreset {
+  label: string;
+  type: string;
+  baseUrl: string;
+  models: string[];
+}
+
+export const channelPresets: ChannelPreset[] = [
+  {
+    label: "OpenAI",
+    type: "openai",
+    baseUrl: "https://api.openai.com/v1";,
+    models: ["gpt-5.5", "gpt-5", "gpt-5-mini", "o3", "o4-mini"],
+  },
+  {
+    label: "Anthropic",
+    type: "anthropic",
+    baseUrl: "https://api.anthropic.com";,
+    models: ["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5"],
+  },
+  {
+    label: "DeepSeek",
+    type: "custom",
+    baseUrl: "https://api.deepseek.com/v1";,
+    models: ["deepseek-chat", "deepseek-reasoner"],
+  },
+  {
+    label: "Moonshot",
+    type: "custom",
+    baseUrl: "https://api.moonshot.cn/v1";,
+    models: ["moonshot-v1-8k", "moonshot-v1-32k"],
+  },
+  {
+    label: "Qwen",
+    type: "custom",
+    baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1";,
+    models: ["qwen-max", "qwen-plus"],
+  },
+];
+
+/** The base URLs and models offered for a channel type, from the vendors of 
it. */
+export function baseUrlPresets(type: string) {
+  return channelPresets.filter(preset => preset.type === type).map(preset => 
preset.baseUrl);
+}
+
+export function modelPresets(type: string) {
+  return channelPresets.filter(preset => preset.type === type).flatMap(preset 
=> preset.models);
+}
+
+export function baseUrlPlaceholder(type: string) {
+  return channelProtocol(type) === "anthropic" ? "https://api.anthropic.com"; : 
"https://api.openai.com/v1";;
+}
+
+export function modelsPlaceholder(type: string) {
+  return channelProtocol(type) === "anthropic" ? "claude-opus-5, 
claude-sonnet-5" : "gpt-5, gpt-5-mini";
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 6938064..0b9c05a 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -18,6 +18,8 @@
     "Username": "Username"
   },
   "agent": {
+    "Add a channel": "Add a channel",
+    "Add a channel hint": "Channels → New Channel: pick a vendor, paste your 
API key, and list the models it serves.",
     "Agent": "Agent",
     "Agent Records": "Activity",
     "Agent Sessions": "Sessions",
@@ -31,6 +33,8 @@
     "All outcomes": "All outcomes",
     "Auto refresh": "Auto refresh",
     "Base URL hint": "Give the agent the settings below. Requests that arrive 
at this URL always go to the channel selected above, whatever model they name.",
+    "Bind the channel": "Bind the channel to an agent",
+    "Bind the channel hint": "Open an agent, pick the channel, and let Gateway 
write the agent's configuration for you.",
     "Bound channel cooling down": "The bound channel is out of rotation, 
requests go to the fallbacks",
     "Channel": "Channel",
     "Channel cleared": "Channel cleared",
@@ -97,6 +101,8 @@
     "Restore the configuration of {agent}?": "Restore the configuration of 
{agent}?",
     "Rewrite configuration": "Rewrite configuration",
     "Route through Gateway": "Route through Gateway",
+    "Running in a container": "Gateway is running in a container",
+    "Running in a container detail": "Agents are found by reading the home 
directories of the machine Gateway runs on, and in a container that is the 
container's own filesystem rather than the host's. Install Gateway on the host 
to see the agents installed there. Channels and the WAF proxy work either way.",
     "Scan": "Rescan",
     "Session": "Session",
     "Session title": "Session title",
@@ -109,6 +115,8 @@
     "Unknown": "Unknown",
     "Unpatch": "Turn off monitoring",
     "Usage": "Usage",
+    "Use your own API": "Use your own API",
+    "Use your own API hint": "Gateway can relay an agent's requests to your 
own vendor account, keeping the API key out of the agent and every prompt in 
LLM Records.",
     "User": "User",
     "Version": "Version",
     "View Records": "View Records",
@@ -200,6 +208,7 @@
   "channel": {
     "API Key": "API Key",
     "API Key hint": "The stored key is never sent back to the browser. Leave 
*** as it is to keep the current key, or clear the field to remove it.",
+    "API Key ownership hint": "Stored by Gateway and never handed to an 
agent.",
     "Base URL": "Base URL",
     "Base URL hint": "Requests will be sent to",
     "Channel": "Channel",
@@ -224,8 +233,12 @@
     "Healthy": "Healthy",
     "Model routing hint": "Requests that arrive here are routed by the model 
name they carry, to the enabled channel with the smallest priority value that 
serves it. To send one agent's requests to this channel whatever model they 
name, bind the channel on the agent's page instead.",
     "Models": "Models",
+    "Models hint": "The model names this channel serves, used to route 
requests that arrive by model name.",
     "New Channel": "New Channel",
+    "No channels yet": "No channels yet",
+    "No channels yet detail": "A channel is your own vendor account: a base 
URL, an API key, and the models it serves. Add one, then bind it to an agent to 
route that agent's requests through it.",
     "Not used yet": "Not used yet",
+    "Page description": "One endpoint in front of your model vendors. Gateway 
holds the API key, so the agents never have it.",
     "Priority": "Priority",
     "Priority hint": "Smaller number means higher priority, 0 is the highest; 
when multiple channels serve the same model, the one with the smaller value is 
used first.",
     "Recovering": "Recovering",
@@ -234,7 +247,9 @@
     "Test Connectivity": "Test Connectivity",
     "Type": "Type",
     "Usage": "Usage",
-    "Usage hint": "Point any client that speaks this channel's API at Gateway 
with these variables."
+    "Usage hint": "Point any client that speaks this channel's API at Gateway 
with these variables.",
+    "Vendor": "Vendor",
+    "Vendor hint": "Fills in the type, base URL and models. Everything stays 
editable."
   },
   "general": {
     "Action": "Action",
@@ -288,6 +303,7 @@
     "No data": "No data",
     "Nodes": "Nodes",
     "OK": "OK",
+    "Open": "Open",
     "Other": "Other",
     "Owner": "Owner",
     "Password": "Password",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 2b243d5..8e8faf2 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -18,6 +18,8 @@
     "Username": "用户名"
   },
   "agent": {
+    "Add a channel": "添加一个 Channel",
+    "Add a channel hint": "Channels → New Channel:选一个厂商,填上你的 API 
Key,列出它提供的模型。",
     "Agent": "智能体",
     "Agent Records": "活动记录",
     "Agent Sessions": "会话",
@@ -31,6 +33,8 @@
     "All outcomes": "全部结果",
     "Auto refresh": "自动刷新",
     "Base URL hint": "把下面的设置配给该 Agent。到达这个地址的请求一律转发到上面选定的渠道,与请求里的模型名无关。",
+    "Bind the channel": "把 Channel 绑定到 Agent",
+    "Bind the channel hint": "打开一个 Agent,选中该 Channel,Gateway 会替你写好它的配置。",
     "Bound channel cooling down": "绑定通道已暂时移出轮转,请求将走备用通道",
     "Channel": "渠道",
     "Channel cleared": "已清除渠道",
@@ -97,6 +101,8 @@
     "Restore the configuration of {agent}?": "还原 {agent} 的配置?",
     "Rewrite configuration": "重新写入配置",
     "Route through Gateway": "经由 Gateway 路由",
+    "Running in a container": "Gateway 正跑在容器里",
+    "Running in a container detail": "Agent 是靠读取 Gateway 
所在机器的用户主目录发现的,而在容器里那是容器自己的文件系统,不是宿主机的。把 Gateway 装到宿主机上才能看到装在那里的 Agent。Channel 和 
WAF 反向代理在容器里照常可用。",
     "Scan": "重新扫描",
     "Session": "会话",
     "Session title": "会话标题",
@@ -109,6 +115,8 @@
     "Unknown": "未知",
     "Unpatch": "关闭监控",
     "Usage": "用量",
+    "Use your own API": "使用你自己的 API",
+    "Use your own API hint": "Gateway 可以把 Agent 的请求转发到你自己的厂商账号,API Key 不会落到 
Agent 手里,每一次提示词都会进入 LLM Records。",
     "User": "用户",
     "Version": "版本",
     "View Records": "查看活动",
@@ -200,6 +208,7 @@
   "channel": {
     "API Key": "API 密钥",
     "API Key hint": "已保存的密钥不会返回给浏览器。保持 *** 不变即可沿用当前密钥,清空该字段则删除密钥。",
+    "API Key ownership hint": "由 Gateway 保存,不会交给任何 Agent。",
     "Base URL": "基础 URL",
     "Base URL hint": "实际请求将发往",
     "Channel": "渠道",
@@ -224,8 +233,12 @@
     "Healthy": "正常",
     "Model routing hint": "到达这里的请求按自己带的模型名路由,转发到支持该模型、且 priority 
值最小的已启用渠道。如果想让某个 Agent 的请求不管模型名一律走这个渠道,请到该 Agent 的页面上绑定。",
     "Models": "模型",
+    "Models hint": "这个 Channel 提供的模型名,按模型名路由的请求靠它匹配。",
     "New Channel": "新建渠道",
+    "No channels yet": "还没有 Channel",
+    "No channels yet detail": "一个 Channel 就是你自己的厂商账号:base URL、API 
Key,以及它提供的模型。先添加一个,再把它绑定到 Agent,该 Agent 的请求就会走它。",
     "Not used yet": "尚未使用",
+    "Page description": "挡在模型厂商前面的统一入口。API Key 由 Gateway 持有,Agent 拿不到。",
     "Priority": "优先级",
     "Priority hint": "数字越小优先级越高,0 为最高;同模型多渠道时优先使用数值小的渠道。",
     "Recovering": "恢复中",
@@ -234,7 +247,9 @@
     "Test Connectivity": "测试连接",
     "Type": "类型",
     "Usage": "使用方法",
-    "Usage hint": "任何说这个渠道所用 API 的客户端,配上下面的变量就能走网关。"
+    "Usage hint": "任何说这个渠道所用 API 的客户端,配上下面的变量就能走网关。",
+    "Vendor": "厂商",
+    "Vendor hint": "一键填好类型、base URL 和模型,填完还可以改。"
   },
   "general": {
     "Action": "操作",
@@ -288,6 +303,7 @@
     "No data": "暂无数据",
     "Nodes": "节点",
     "OK": "确定",
+    "Open": "打开",
     "Other": "其他",
     "Owner": "所有者",
     "Password": "密码",
diff --git a/web/src/pages/AgentDashboardPage.tsx 
b/web/src/pages/AgentDashboardPage.tsx
index e427dbd..1b3bb51 100644
--- a/web/src/pages/AgentDashboardPage.tsx
+++ b/web/src/pages/AgentDashboardPage.tsx
@@ -14,9 +14,19 @@
 
 import * as React from "react";
 import {Link} from "react-router-dom";
-import {Bot, ChevronRight, FileSearch, MessageSquare, RefreshCw, ShieldCheck} 
from "lucide-react";
+import {
+  Bot,
+  Check,
+  ChevronRight,
+  Container,
+  FileSearch,
+  MessageSquare,
+  RefreshCw,
+  ShieldCheck,
+} from "lucide-react";
 import i18next from "i18next";
 
+import * as ChannelBackend from "@/backend/ChannelBackend";
 import * as Setting from "@/Setting";
 import {AgentIcon} from "@/components/AgentIcon";
 import {ConfirmDialog} from "@/components/shared/confirm-dialog";
@@ -32,6 +42,7 @@ import {Card, CardContent, CardHeader} from 
"@/components/ui/card";
 import {Switch} from "@/components/ui/switch";
 import {SimpleTooltip} from "@/components/ui/tooltip";
 import {activityOf, agentDetailPath, agentKey, useAgents, useAgentSessions} 
from "@/lib/agents";
+import {cn} from "@/lib/utils";
 import type {Account, Agent} from "@/types";
 
 /** One labelled line inside an agent card. */
@@ -152,10 +163,70 @@ function AgentCard({
   );
 }
 
+/** The two steps between a fresh install and an agent running on your own 
key. */
+function SetupGuide({hasChannel, agents}: {hasChannel: boolean; agents: 
Agent[]}) {
+  const steps = [
+    {
+      done: hasChannel,
+      title: i18next.t("agent:Add a channel"),
+      hint: i18next.t("agent:Add a channel hint"),
+      to: "/channels",
+    },
+    {
+      done: agents.some(agent => agent.channel !== ""),
+      title: i18next.t("agent:Bind the channel"),
+      hint: i18next.t("agent:Bind the channel hint"),
+      // With nothing scanned there is no agent to open, only the list saying 
so.
+      to: agents.length > 0 ? agentDetailPath(agents[0]) : "/agents",
+    },
+  ];
+
+  return (
+    <Card className="gap-3 py-4">
+      <CardHeader className="flex flex-col gap-1 px-4">
+        <span className="font-semibold">{i18next.t("agent:Use your own 
API")}</span>
+        <span className="text-muted-foreground text-sm">{i18next.t("agent:Use 
your own API hint")}</span>
+      </CardHeader>
+      <CardContent className="flex flex-col gap-3 px-4">
+        {steps.map((step, index) => (
+          <div key={step.title} className="flex items-center gap-3">
+            <span
+              className={cn(
+                "flex size-6 shrink-0 items-center justify-center rounded-full 
text-xs",
+                step.done ? "bg-success/15 text-success" : "bg-muted 
text-muted-foreground",
+              )}
+            >
+              {step.done ? <Check className="size-3.5" /> : index + 1}
+            </span>
+            <div className="min-w-0 flex-1">
+              <p className="text-sm font-medium">{step.title}</p>
+              <p className="text-muted-foreground text-xs">{step.hint}</p>
+            </div>
+            <Button asChild variant="outline" size="sm">
+              <Link to={step.to}>{i18next.t("general:Open")}</Link>
+            </Button>
+          </div>
+        ))}
+      </CardContent>
+    </Card>
+  );
+}
+
 export default function AgentDashboardPage({account}: {account: Account}) {
   const isAdmin = Setting.isAdminUser(account);
-  const {agents, loading, error, busyKey, scanned, scan, togglePatch} = 
useAgents(isAdmin);
+  const {agents, loading, error, busyKey, scanned, inContainer, scan, 
togglePatch} = useAgents(isAdmin);
   const {activity, recordCount} = useAgentSessions(isAdmin, "", 5000);
+  // null until the channel count is known, so the guide never flashes.
+  const [hasChannel, setHasChannel] = React.useState<boolean | null>(null);
+
+  React.useEffect(() => {
+    if (!isAdmin) {
+      return;
+    }
+    ChannelBackend.getChannels(account.name, 1, 1)
+      .then(res => setHasChannel(res.status === "ok" && (res.data2 ?? 0) > 0))
+      .catch(() => setHasChannel(false));
+  }, [isAdmin, account.name]);
 
   if (!isAdmin) {
     return <UnauthorizedResult />;
@@ -163,6 +234,7 @@ export default function AgentDashboardPage({account}: 
{account: Account}) {
 
   const patchedCount = agents.filter(agent => agent.patched).length;
   const sessionCount = Object.values(activity).reduce((total, entry) => total 
+ entry.sessionCount, 0);
+  const setupDone = hasChannel === true && agents.some(agent => agent.channel 
!== "");
 
   return (
     <PageContainer>
@@ -186,14 +258,22 @@ export default function AgentDashboardPage({account}: 
{account: Account}) {
         <StatCard label={i18next.t("agent:Records")} value={recordCount} 
icon={FileSearch} />
       </div>
 
+      {scanned && hasChannel !== null && !setupDone ? (
+        <SetupGuide hasChannel={hasChannel} agents={agents} />
+      ) : null}
+
       {!scanned ? (
         <Loading tip={i18next.t("agent:Scan")} />
       ) : agents.length === 0 ? (
         <Card className="py-0">
           <EmptyState
-            icon={Bot}
-            title={i18next.t("agent:No supported agents found")}
-            description={i18next.t("agent:Install an AI agent on this machine, 
then scan again")}
+            icon={inContainer ? Container : Bot}
+            title={i18next.t(inContainer ? "agent:Running in a container" : 
"agent:No supported agents found")}
+            description={i18next.t(
+              inContainer
+                ? "agent:Running in a container detail"
+                : "agent:Install an AI agent on this machine, then scan again",
+            )}
             action={
               <Button variant="outline" onClick={() => scan(true)} 
loading={loading}>
                 <RefreshCw />
diff --git a/web/src/pages/AgentsPage.tsx b/web/src/pages/AgentsPage.tsx
index c29506f..2beaf25 100644
--- a/web/src/pages/AgentsPage.tsx
+++ b/web/src/pages/AgentsPage.tsx
@@ -13,7 +13,7 @@
 // limitations under the License.
 
 import {Link} from "react-router-dom";
-import {Bot, RefreshCw} from "lucide-react";
+import {Bot, Container, RefreshCw} from "lucide-react";
 import i18next from "i18next";
 
 import * as Setting from "@/Setting";
@@ -31,7 +31,7 @@ import type {Account, Agent} from "@/types";
 
 export default function AgentsPage({account}: {account: Account}) {
   const isAdmin = Setting.isAdminUser(account);
-  const {agents, loading, error, busyKey, scan, togglePatch} = 
useAgents(isAdmin);
+  const {agents, loading, error, busyKey, inContainer, scan, togglePatch} = 
useAgents(isAdmin);
 
   if (!isAdmin) {
     return <UnauthorizedResult />;
@@ -183,8 +183,10 @@ export default function AgentsPage({account}: {account: 
Account}) {
         loading={loading}
         pageSize={0}
         searchable
-        emptyIcon={Bot}
-        emptyText={i18next.t("agent:No supported agents found")}
+        emptyIcon={inContainer ? Container : Bot}
+        emptyText={i18next.t(
+          inContainer ? "agent:Running in a container detail" : "agent:No 
supported agents found",
+        )}
         toolbar={
           <Button variant="outline" size="sm" onClick={() => scan(true)} 
loading={loading}>
             <RefreshCw />
diff --git a/web/src/pages/ChannelEditPage.tsx 
b/web/src/pages/ChannelEditPage.tsx
index 423eef5..ce40799 100644
--- a/web/src/pages/ChannelEditPage.tsx
+++ b/web/src/pages/ChannelEditPage.tsx
@@ -31,22 +31,17 @@ import {MessageAlert} from "@/components/ui/alert";
 import {Button} from "@/components/ui/button";
 import {Input} from "@/components/ui/input";
 import {TagsInput} from "@/components/ui/tags-input";
-import {channelProtocol, gatewayBaseUrl, localShell} from "@/lib/channels";
+import {
+  baseUrlPlaceholder,
+  baseUrlPresets,
+  channelProtocol,
+  gatewayBaseUrl,
+  localShell,
+  modelPresets,
+  modelsPlaceholder,
+} from "@/lib/channels";
 import type {Channel, ChannelTestResult} from "@/types";
 
-// Hard-coded presets for milestone 1.1 (per channel type).
-const BASE_URL_PRESETS: Record<string, string[]> = {
-  openai: ["https://api.openai.com/v1";],
-  anthropic: ["https://api.anthropic.com";],
-  custom: ["https://oneapi.example.com";, "https://api.deepseek.com/v1";, 
"https://api.moonshot.cn/v1";],
-};
-
-const MODEL_PRESETS: Record<string, string[]> = {
-  openai: ["gpt-5.5", "gpt-5", "gpt-5-mini", "o3", "o4-mini"],
-  anthropic: ["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5"],
-  custom: ["deepseek-chat", "deepseek-reasoner", "moonshot-v1-8k", "qwen-max"],
-};
-
 // Mirrors object.BuildOpenAiUrl on the server.
 function buildOpenAiUrl(baseUrl: string, endpoint: string) {
   let url: URL;
@@ -249,8 +244,8 @@ export default function ChannelEditPage() {
           <SearchSelect
             allowCustomValue
             value={channel.baseUrl}
-            placeholder={channel.type === "anthropic" ? 
"https://api.anthropic.com"; : "https://api.openai.com/v1"}
-            options={(BASE_URL_PRESETS[channel.type] ?? []).map(url => 
({label: url, value: url}))}
+            placeholder={baseUrlPlaceholder(channel.type)}
+            options={baseUrlPresets(channel.type)}
             onChange={value => setField("baseUrl", value)}
           />
         </Field>
@@ -269,8 +264,8 @@ export default function ChannelEditPage() {
         <Field label={i18next.t("channel:Models")}>
           <TagsInput
             value={channel.models}
-            placeholder={channel.type === "anthropic" ? "claude-opus-5, 
claude-sonnet-5" : "gpt-5, gpt-5-mini"}
-            suggestions={MODEL_PRESETS[channel.type] ?? []}
+            placeholder={modelsPlaceholder(channel.type)}
+            suggestions={modelPresets(channel.type)}
             onChange={value => setField("models", value)}
           />
         </Field>
diff --git a/web/src/pages/ChannelListPage.tsx 
b/web/src/pages/ChannelListPage.tsx
index 80adc98..b1f8c63 100644
--- a/web/src/pages/ChannelListPage.tsx
+++ b/web/src/pages/ChannelListPage.tsx
@@ -21,16 +21,26 @@ import * as ChannelBackend from "@/backend/ChannelBackend";
 import * as Setting from "@/Setting";
 import {ConfirmDialog} from "@/components/shared/confirm-dialog";
 import {DataTable, type Column, type SortOrder} from 
"@/components/shared/data-table";
+import {EmptyState} from "@/components/shared/empty-state";
 import {Field, FormDialog} from "@/components/shared/form-dialog";
 import {CodeText} from "@/components/shared/misc";
 import {PageContainer, PageHeader} from "@/components/shared/page-header";
 import {PasswordInput} from "@/components/shared/password-input";
-import {SimpleSelect} from "@/components/shared/simple-select";
+import {SearchSelect, SimpleSelect} from "@/components/shared/simple-select";
 import {Badge} from "@/components/ui/badge";
 import {Button} from "@/components/ui/button";
+import {Card} from "@/components/ui/card";
 import {Input} from "@/components/ui/input";
 import {TagsInput} from "@/components/ui/tags-input";
 import {SimpleTooltip} from "@/components/ui/tooltip";
+import {
+  baseUrlPlaceholder,
+  baseUrlPresets,
+  channelPresets,
+  modelPresets,
+  modelsPlaceholder,
+  type ChannelPreset,
+} from "@/lib/channels";
 import type {Account, Channel, ChannelHealth} from "@/types";
 
 function newChannel(owner: string): Channel {
@@ -53,6 +63,7 @@ export default function ChannelListPage({account}: {account: 
Account}) {
   const [data, setData] = React.useState<Channel[]>([]);
   const [total, setTotal] = React.useState(0);
   const [loading, setLoading] = React.useState(false);
+  const [loaded, setLoaded] = React.useState(false);
   const [page, setPage] = React.useState(1);
   const [pageSize, setPageSize] = React.useState(10);
   const [sort, setSort] = React.useState<{field: string; order: SortOrder}>({
@@ -77,6 +88,7 @@ export default function ChannelListPage({account}: {account: 
Account}) {
       )
         .then(res => {
           setLoading(false);
+          setLoaded(true);
           if (res.status === "ok") {
             setData(res.data ?? []);
             setTotal(res.data2 ?? 0);
@@ -89,6 +101,7 @@ export default function ChannelListPage({account}: {account: 
Account}) {
         })
         .catch(error => {
           setLoading(false);
+          setLoaded(true);
           Setting.showMessage("error", `${i18next.t("channel:Failed to get 
channels")}: ${error}`);
         });
     },
@@ -124,6 +137,10 @@ export default function ChannelListPage({account}: 
{account: Account}) {
     setForm(prev => ({...prev, [key]: value}));
   };
 
+  const applyPreset = (preset: ChannelPreset) => {
+    setForm(prev => ({...prev, type: preset.type, baseUrl: preset.baseUrl, 
models: preset.models}));
+  };
+
   const addChannel = () => {
     const name = form.name.trim();
     if (name === "") {
@@ -324,6 +341,7 @@ export default function ChannelListPage({account}: 
{account: Account}) {
     <PageContainer>
       <PageHeader
         title={i18next.t("channel:Channels")}
+        description={i18next.t("channel:Page description")}
         actions={
           <Button onClick={openAddDialog}>
             <Plus />
@@ -332,29 +350,45 @@ export default function ChannelListPage({account}: 
{account: Account}) {
         }
       />
 
-      <DataTable
-        columns={columns}
-        dataSource={data}
-        // An admin sees channels across owners, where names may collide.
-        rowKey={record => `${record.owner}/${record.name}`}
-        loading={loading}
-        onSort={(field, order) => fetchChannels(1, pageSize, {field: field, 
order: order})}
-        serverPagination={{
-          page: page,
-          pageSize: pageSize,
-          total: total,
-          onChange: (nextPage, nextPageSize) => fetchChannels(nextPage, 
nextPageSize),
-        }}
-        title={i18next.t("channel:Channels")}
-        description={`${total} ${i18next.t("channel:Channels")}`}
-        emptyIcon={Plug}
-        toolbar={
-          <Button variant="outline" size="sm" onClick={() => fetchChannels()} 
loading={loading}>
-            <RefreshCw />
-            {i18next.t("general:Refresh")}
-          </Button>
-        }
-      />
+      {loaded && data.length === 0 ? (
+        <Card className="py-0">
+          <EmptyState
+            icon={Plug}
+            title={i18next.t("channel:No channels yet")}
+            description={i18next.t("channel:No channels yet detail")}
+            action={
+              <Button onClick={openAddDialog}>
+                <Plus />
+                {i18next.t("channel:New Channel")}
+              </Button>
+            }
+          />
+        </Card>
+      ) : (
+        <DataTable
+          columns={columns}
+          dataSource={data}
+          // An admin sees channels across owners, where names may collide.
+          rowKey={record => `${record.owner}/${record.name}`}
+          loading={loading}
+          onSort={(field, order) => fetchChannels(1, pageSize, {field: field, 
order: order})}
+          serverPagination={{
+            page: page,
+            pageSize: pageSize,
+            total: total,
+            onChange: (nextPage, nextPageSize) => fetchChannels(nextPage, 
nextPageSize),
+          }}
+          title={i18next.t("channel:Channels")}
+          description={`${total} ${i18next.t("channel:Channels")}`}
+          emptyIcon={Plug}
+          toolbar={
+            <Button variant="outline" size="sm" onClick={() => 
fetchChannels()} loading={loading}>
+              <RefreshCw />
+              {i18next.t("general:Refresh")}
+            </Button>
+          }
+        />
+      )}
 
       <FormDialog
         open={addOpen}
@@ -380,6 +414,21 @@ export default function ChannelListPage({account}: 
{account: Account}) {
             onChange={event => setFormField("displayName", event.target.value)}
           />
         </Field>
+        <Field label={i18next.t("channel:Vendor")} 
hint={i18next.t("channel:Vendor hint")}>
+          <div className="flex flex-wrap gap-2">
+            {channelPresets.map(preset => (
+              <Button
+                key={preset.label}
+                type="button"
+                size="sm"
+                variant={form.baseUrl === preset.baseUrl ? "default" : 
"outline"}
+                onClick={() => applyPreset(preset)}
+              >
+                {preset.label}
+              </Button>
+            ))}
+          </div>
+        </Field>
         <Field label={i18next.t("channel:Type")}>
           <SimpleSelect
             value={form.type}
@@ -392,14 +441,20 @@ export default function ChannelListPage({account}: 
{account: Account}) {
           />
         </Field>
         <Field label={i18next.t("channel:Base URL")} 
htmlFor="channel-base-url">
-          <Input
+          <SearchSelect
+            allowCustomValue
             id="channel-base-url"
             value={form.baseUrl}
-            placeholder={form.type === "anthropic" ? 
"https://api.anthropic.com"; : "https://api.openai.com/v1"}
-            onChange={event => setFormField("baseUrl", event.target.value)}
+            placeholder={baseUrlPlaceholder(form.type)}
+            options={baseUrlPresets(form.type)}
+            onChange={value => setFormField("baseUrl", value)}
           />
         </Field>
-        <Field label={i18next.t("channel:API Key")} htmlFor="channel-api-key">
+        <Field
+          label={i18next.t("channel:API Key")}
+          htmlFor="channel-api-key"
+          hint={i18next.t("channel:API Key ownership hint")}
+        >
           <PasswordInput
             id="channel-api-key"
             placeholder="sk-..."
@@ -407,10 +462,11 @@ export default function ChannelListPage({account}: 
{account: Account}) {
             onChange={event => setFormField("apiKey", event.target.value)}
           />
         </Field>
-        <Field label={i18next.t("channel:Models")}>
+        <Field label={i18next.t("channel:Models")} 
hint={i18next.t("channel:Models hint")}>
           <TagsInput
             value={form.models}
-            placeholder={form.type === "anthropic" ? "claude-opus-5, 
claude-sonnet-5" : "gpt-5, gpt-5-mini"}
+            placeholder={modelsPlaceholder(form.type)}
+            suggestions={modelPresets(form.type)}
             onChange={value => setFormField("models", value)}
           />
         </Field>


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to