KsADK
教程最佳实践

自定义 UI Agent 托管

这个教程讲清楚一件事:业务自带的前端 bundle,如何在一个托管 runtime 上跑起来——本地调试和云端部署共用同一套 bootstrap 契约,前端代码不写"是不是本地"的分支。

它不是"换个皮肤"。核心是 ksadk.server.app 把 UI 当成一个可解析的 spec(profile / path / url / bundle_path),在 FastAPI 里替换 StaticFiles 挂载点,并通过 GetAgentUiBootstrap 把这个 spec 透传给前端。hosted UI 和本地 Web UI 读同一个 action,所以同一个 bundle 在两端行为一致。

自定义 UI Agent 托管架构

它要解决什么问题

为什么不能直接让业务前端自己起一个静态服务器

业务前端要调 runtime API(/v1/responses/agentengine/api/v1/*、WebSocket),还要读 runtime 能力(checkpoint resume、attachments、workspace)。如果前端单独部署,会面临跨域、能力发现、路径硬编码三个问题。把 bundle 挂到 runtime 同源下,bootstrap 一次性告诉前端"我是谁、我能干什么、UI 在哪",前端只管渲染。

适用场景:

  • Agent 配套的业务前端不是 ksadk 内置聊天界面,而是带研究工作台、数据看板或多步表单的自研 UI。
  • 你希望本地 agentengine web 和托管部署后用同一个前端 bundle,不维护两套构建。
  • 前端需要消费 CustomUI / RuntimeCapabilities / CheckpointResumeCapability 等 bootstrap 字段做能力探测。

解析链路:UI spec 从哪来

_resolve_agent_ui_spec() 是入口。它合并四个来源,按优先级取值:

  1. agentengine.yaml 里的 ui_profile / ui_path / ui_url / ui_bundle_path
  2. .agentengine/state 持久化的 UI 状态(本地调试写过一次后缓存)。
  3. KSADK_UI_PROFILE / KSADK_UI_PATH / KSADK_UI_URL / KSADK_UI_BUNDLE_PATH 环境变量(serverless pod 注入)。
  4. research-ui/dist/index.html 自动探测——没有显式声明且该文件存在时,自动判为 custom profile。
ksadk/server/app.py
def _resolve_agent_ui_spec() -> dict[str, Any]:
    project_dir = _runner_project_dir()
    state = _ui_state_with_env_fallback(load_runtime_state(project_dir))
    framework = _current_framework()
    auto_custom_bundle_dir = _default_custom_ui_bundle_dir(project_dir)
    if (
        not state.get("ui_profile")
        and not state.get("ui_path")
        and not state.get("ui_url")
        and (auto_custom_bundle_dir / "index.html").exists()
    ):
        state["ui_profile"] = UI_PROFILE_CUSTOM
        state["ui_path"] = "/"
        state["ui_bundle_path"] = str(auto_custom_bundle_dir)

_ui_state_with_env_fallback 把环境变量叠到 state 上,pod 里没有 .agentengine/ 状态时仍能还原 UI 配置:

ksadk/server/app.py
env_fallbacks = {
    "ui_profile": os.environ.get("KSADK_UI_PROFILE"),
    "ui_path": os.environ.get("KSADK_UI_PATH"),
    "ui_url": os.environ.get("KSADK_UI_URL"),
    "ui_bundle_path": os.environ.get("KSADK_UI_BUNDLE_PATH"),
}

profile 确定后,resolve_ui_config() 做规范化:custom profile 默认 path 是 /,且 /chat 会被自动修正回 /——bundle 内不需要为内置聊天界面的旧路径打补丁。

ksadk/ui_config.py
_DEFAULT_PATH_BY_PROFILE = {
    UI_PROFILE_ADK: "/chat",
    UI_PROFILE_LANGCHAIN: "/chat",
    UI_PROFILE_OPENCLAW: "/chat",
    UI_PROFILE_HERMES: "/chat",
    UI_PROFILE_CUSTOM: "/",
}

if profile == UI_PROFILE_CUSTOM and path == "/chat":
    path = "/"

ui_path 必须以 / 开头

normalize_ui_path 会给没有前导 / 的 path 补一个 /。custom profile 下如果你写 ui_path: research,实际生效是 /research。建议直接写 //your-route,避免和 SPA 路由混淆。

静态路由:替换 StaticFiles 挂载

custom profile 下,runtime 不用内置 STATIC_DIR,而是把 catch-all 路由 /{requested_path:path} 指向 bundle 目录。_resolve_ui_static_response() 负责按 bundle 路径解析:

  • 请求落在 ui_path 本身 → 返回 index.html
  • 请求是 assets/* 或带文件后缀 → 返回对应静态文件,找不到才 404。
  • 其它路径(SPA 子路由) → 回退 index.html,让前端路由接管。
ksadk/server/app.py
def _resolve_ui_static_response(request_path: str) -> Optional[FileResponse]:
    spec = _resolve_agent_ui_spec()
    if not spec.get("enabled"):
        return None

    bundle_dir = Path(str(spec["bundle_path"]))
    index_file = Path(str(spec["index_path"]))
    path = _normalize_request_ui_path(request_path)

    if spec.get("source") == "custom":
        ui_path = _normalize_request_ui_path(str(spec.get("path") or "/")).rstrip("/") or "/"
        if path == ui_path or path == f"{ui_path}/":
            return FileResponse(index_file)
        if ui_path != "/" and not path.startswith(f"{ui_path}/"):
            return None
        relative = path[len(ui_path):].lstrip("/") if ui_path != "/" else path.lstrip("/")
        if not relative:
            return FileResponse(index_file)
        candidate = bundle_dir / relative
        if candidate.exists() and candidate.is_file():
            return FileResponse(candidate)
        if not _is_custom_ui_static_asset_path(relative):
            return FileResponse(index_file)
        return None

enabled 判定看 index.html 是否存在

spec 里 enabled = bundle_dir.exists() and index_file.exists()。bundle 目录在但 index.html 缺失,会被判为未启用,前端会落到内置 UI 或 404。生产部署一定要确认 ui_bundle_path 指向的目录里有 index.html

GetAgentUiBootstrap:透传 CustomUI

前端不直接读 agentengine.yaml,而是调 GetAgentUiBootstrap 拿到一份描述 runtime 自身的 JSON。custom UI 的关键字段在 CustomUI 块:

ksadk/server/app.py
"CustomUI": {
    "Enabled": bool(ui_spec.get("enabled")),
    "Profile": ui_spec.get("ui_profile") or ui_spec.get("profile"),
    "Path": ui_spec.get("ui_path") or ui_spec.get("path"),
    "Url": ui_spec.get("ui_url") or ui_spec.get("url"),
    "BundlePath": ui_spec.get("ui_bundle_path") or ui_spec.get("bundle_path"),
},

同一个 action 还带 Capabilities.RuntimeCapabilitiesCheckpointResumeCapabilitySharePermissionsApiFormatsStream。前端用这些字段决定是否渲染 checkpoint 恢复面板、是否启用流式、是否展示附件上传。

hosted 和 local 共用同一 action

这是"托管"的关键:hosted UI 和本地 Web UI 调的都是 /agentengine/api/v1/GetAgentUiBootstrap,字段契约一致。你的 bundle 不需要判断"我在哪跑",只读 CustomUICapabilities 即可。

开发自定义 UI bundle

  1. 创建 research-ui/ 目录

bundle 默认探测路径是项目根下的 research-ui/dist。所以前端工程放 research-ui/,构建产物输出到 research-ui/dist/

my-agent/
├── agentengine.yaml
├── agent.py
└── research-ui/
    ├── package.json
    └── (Next.js / Vite 工程)
  1. 配置静态导出到 dist
research-ui/package.json
{
  "scripts": {
    "build": "vite build"
  }
}
research-ui/vite.config.js
import { defineConfig } from "vite";

export default defineConfig({
  build: {
    outDir: "dist",
    emptyOutDir: true,
  },
});

Vite 默认输出 dist/index.html + dist/assets/*,正好命中 custom bundle 静态路由的判定。

research-ui/next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: "export",
  distDir: "dist",
  trailingSlash: true,
};

module.exports = nextConfig;

output: "export" 生成纯静态站点,distDir: "dist" 让产物落在 runtime 默认探测路径上。

  1. 接 runtime API

bundle 运行时同源调以下端点,不需要配 base URL:

  • POST /v1/responses — OpenAI 兼容 Responses API,流式对话主入口。
  • POST /agentengine/api/v1/RunAgent — AgentEngine 原生 action,带 Stream / Background
  • POST /agentengine/api/v1/GetAgentUiBootstrap — 启动时拉一次,读 CustomUICapabilities
  • POST /agentengine/api/v1/CancelRun / ResumeRun — 长任务取消与恢复。
  • GET /agentengine/api/v1/SubscribeRunEvents — 后台任务事件订阅(SSE)。
research-ui/src/bootstrap.ts
const res = await fetch("/agentengine/api/v1/GetAgentUiBootstrap", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ AgentId: null, SessionId: null }),
});
const { Data } = await res.json();
const { CustomUI, Capabilities, SharePermissions } = Data;

WebSocket 走同源相对路径

如果用 WebSocket(workspace 文件变更、终端),路径形如 /agentengine/api/v1/ws/{agent_id}/{file_path}。前端构造 WebSocket URL 时用 location.protocol 判断 wss/ws、location.host 拼 host,不要硬编码 host。

  1. 构建并验证产物
构建 bundle
cd research-ui
npm install
npm run build
ls dist/index.html

dist/index.html 存在,custom bundle 才会被判为 enabled

配置 agentengine.yaml

显式声明 custom profile,覆盖自动探测(推荐,避免依赖探测行为):

agentengine.yaml
name: my-custom-ui-agent
framework: adk
entry_point: agent.py
agent_variable: root_agent
ui_profile: custom
ui_path: /
ui_bundle_path: research-ui/dist

ui_bundle_path 支持相对路径

_resolve_agent_ui_spec 会把相对路径解析到 project_dir 下。写 research-ui/dist 即可,不需要写绝对路径。绝对路径在解析时直接用,不做 project_dir 拼接。

custom 下 ui_path 不要写 /chat

resolve_ui_config 会把 custom profile 下的 /chat 修正为 /。如果你确实想挂在子路由(例如 /research),写 ui_path: /research,前端 SPA 路由要能处理这个前缀。

本地调试

agentengine web 通过 _configure_custom_ui_env()agentengine.yaml 里的 UI 配置转成环境变量,再启动 runtime——这样本地和 serverless pod 走的是同一条 env fallback 链路:

ksadk/cli/cmd_web.py
def _configure_custom_ui_env(agent_path: Path) -> str:
    config = _load_agentengine_config(agent_path)
    if str(config.get("ui_profile") or "").strip().lower() != "custom":
        return "/"

    bundle_path = str(config.get("ui_bundle_path") or "").strip()
    bundle_dir = agent_path / bundle_path
    if not (bundle_dir / "index.html").is_file():
        return "/"

    ui_path = _normalize_ui_path(str(config.get("ui_path") or "/"))
    os.environ["KSADK_UI_PROFILE"] = "custom"
    os.environ["KSADK_UI_PATH"] = ui_path
    os.environ["KSADK_UI_BUNDLE_PATH"] = bundle_path
    return ui_path

即使不写 agentengine.yaml,只要 research-ui/dist/index.html 存在,runtime 也会自动探测并启用 custom bundle。

启动本地 Web UI
cd my-agent
agentengine web .

本地自动探测的条件

自动探测要求 ui_profileui_pathui_url 三个字段都未显式声明,且 research-ui/dist/index.html 存在。一旦你在 agentengine.yaml 写了 ui_profile: custom,以声明为准,探测逻辑被跳过。

部署到托管

serverless 部署通过 _inject_ui_runtime_env() 把解析后的 UI 配置注入 pod env——不打包本地 .agentengine/ 状态:

ksadk/deployment/providers/serverless.py
if profile:
    env_vars["KSADK_UI_PROFILE"] = str(profile)
if path:
    env_vars["KSADK_UI_PATH"] = str(path)
if url:
    env_vars["KSADK_UI_URL"] = str(url)
if bundle_path:
    env_vars["KSADK_UI_BUNDLE_PATH"] = str(bundle_path)

serverless provider 会读 agentengine.yamlui_bundle_path 并把 research-ui/dist 构建进镜像,运行时注入 KSADK_UI_* 四个环境变量。pod 内 ksadk.server.app 通过 _ui_state_with_env_fallback 还原配置。

部署
agentengine build .
agentengine deploy --provider serverless

确认 bundle 在镜像里:dist/index.html 必须随镜像一起打包,否则 enabled=false

Hermes runtime 走 TUI,不消费 custom UI bundle 的静态路由。如果你的 agent 同时要 hosted UI,把 bundle 构建产物交给前端工程单独托管,并在 agentengine.yamlui_url 指向前端地址——CustomUI.Url 会透传给 hosted UI 做跳转。

OpenClaw 与 Hermes 同属 TUI 框架(_NATIVE_TUI_FRAMEWORKS),custom bundle 静态路由不生效。需要 hosted UI 时同 Hermes,用 ui_url 声明外部前端地址。

Hermes / OpenClaw 不挂 custom bundle 静态路由

_resolve_agent_ui_spec 的 custom 分支只在 profile 解析为 custom 时触发。Hermes / OpenClaw 的 profile 是 hermes / openclaw,走内置 STATIC_DIR。这两类 runtime 要接自定义前端,用 ui_url 而不是 ui_bundle_path

验证

部署后调 GetAgentUiBootstrap,确认 CustomUI 块符合预期:

验证 bootstrap
curl -s -X POST https://<your-runtime>/agentengine/api/v1/GetAgentUiBootstrap \
  -H "Content-Type: application/json" \
  -d '{"AgentId": null, "SessionId": null}' | jq '.Data.CustomUI'

预期输出(serverless):

{
  "Enabled": true,
  "Profile": "custom",
  "Path": "/",
  "Url": null,
  "BundlePath": "/app/research-ui/dist"
}

Enabled: true 表示 bundle 目录和 index.html 都在;BundlePath 是 pod 内绝对路径。如果 Enabled: false,检查 KSADK_UI_BUNDLE_PATH 指向的目录在镜像里是否存在、index.html 是否随构建产物一起打包。

项目结构

agentengine.yaml
agent.py

后续阅读

本页导航