跳到主要内容

面板:本地面板、外部面板、免登登录链路

什么时候读:技能要带可交互面板(用户点按钮 / 填表单 / 看技能自己产出的数据),或要把一个 已有的外部网页整个嵌进平台当面板时。纯 handler、纯文档型、纯 knowledge 不用读。

本版按 backend/api/routers/skills.py 现行代码逐条核对(2026-10):invoke_skill / _declared_actions / _build_skill_runtime_ctx / get_skill_ui / get_skill_ui_asset / panel_public_key / _ui_entry_from_meta / _rewrite_ui_assets / _sign_panel_jwt。 与旧版的三处根本差别:① 网关不再校验 x-actions / invocable_from / structural,只按 schema 的 action.enum 挡未知动作;② 页面驱动的身份会解析出完整 actor_*(前提是带了所在会话的 session_id),不再「工号强制为空」;③ JWT 头里没有 kid,对方按 PEM 公钥验签最稳。

目录​

  1. 先决条件:面板是 handler 型的附加物
  2. 三种面板形态与入口声明
  3. 本地面板:文件、SKILL.md、schema、handler、ui.html、app.js
  4. 页面动作网关 /skills/invoke(放行规则)
  5. 身份注入:页面驱动时 runtime_context 里有什么
  6. 本地面板红线
  7. 外部面板:一行 ui: <URL> 起
  8. 外部面板登录链路(panel_token / JWT / 公钥)
  9. 给对接方的三件事
  10. 与其它 reference 的接缝
  11. 生成后自查

1. 先决条件:面板是 handler 型的附加物​

面板不是独立的 skill_type。 本地面板上的每次交互都经宿主调 POST /api/v1/skills/invoke, 网关再走 executor.execute 回调本技能 handler,所以带本地面板的技能必须是可执行的进程内 handler 型 (references/type-handler-python.md)。网关对 cfg.is_executable 为假的技能(skill_md / knowledge) 直接返回 40404「技能不可执行」。

外部面板(第 7 节)是例外:它整体是别人的网页,不依赖本技能的 handler。

action == "能力" 动作、x-actions 声明:当前网关都不读(_call_skill_capabilities、_action_meta 仍在 skills.py 里,但没有任何调用点)。写了无害,可作自述文档;不写也不影响页面调用。

2. 三种面板形态与入口声明​

形态ui: 写什么用在哪登录
本地面板技能目录内的 .html 相对路径,如 ui.html / ui/index.html展示技能自己的数据、轻交互无需,凭据在 handler 后端
外部面板(免登)https://x.com/panel整嵌外部系统,对方不需要平台身份平台没配私钥 → 不签 token,纯透传 URL
外部面板(带登录)https://x.com/panel + 对方接了 JWT整嵌外部系统且按平台用户免重复登录平台签 JWT,对方验签建 session(第 8 节)

入口声明怎么被读到(_ui_entry_from_meta):

  • 读取顺序:skill.toml > SKILL.md frontmatter > skill_meta.json / skill.json,命中即停。
  • 认的键:ui_entry / ui / panel / panel_html / panel_file(字符串),或 ui: 写成对象 {entry | file | html | path: ...}。
  • 值匹配 ^https?:// → 外部面板;否则当本地相对路径,必须是 .html / .htm、在技能目录内、不含 .., 不合法只记一条 warning 然后退回默认文件名。
  • 没声明也会有面板:技能目录(任一候选目录)里存在 panel.html / ui.html / index.html 之一, 平台就当它是面板入口。不想要面板的技能不要在根目录放这三个文件名。

优先本地面板:凭据留在后端、不受对方 CSP 限制、数据走统一 invoke 通道。只有「外部系统本身功能完整、 自带数据、想整个嵌进来」才用外部面板。

3. 本地面板​

3.1 文件组成​

技能目录/
SKILL.md # frontmatter 声明 ui: 入口
handler.py # async def execute(input_data, runtime_context) + 面板要调的动作
schema.json # properties.action.enum 列出全部合法动作(网关据此挡未知动作)
ui/ # 建议把面板放子目录:静态资源只从「入口文件所在目录」取,且免鉴权公开
index.html
app.js
style.css

模板见 templates/panel/(ui.html / app.js / style.css),复制改名填空。

3.2 SKILL.md 声明入口​

---
name: hello_panel
description: <什么时候调它>
source: builtin
category: general
trigger-type: explicit
cost-tier: low
ui: ui/index.html # ★ 本地 .html 相对路径 = 本地面板
---

3.3 schema.json:action.enum 就是页面动作白名单​

{
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["查询", "greet"], "description": "操作名,传中文原词"},
"name": {"type": "string", "description": "称呼"}
},
"required": ["action"]
}
  • 网关 _declared_actions 取 properties.action.enum:先看注册时的 input_schema,取不到再读技能目录 schema.json(每次现读,不缓存)。
  • 声明了 enum → 不在 enum 里的动作被拒(40404「未知动作「x」」)。
  • 没声明 enum → 网关纯透传任何 action。所以带面板的技能必须写 enum。
  • enum 同时也是模型侧的动作清单——页面能调的 = 模型能调的,网关不再区分来源。

3.4 handler.py​

async def execute(input_data: dict, runtime_context: dict) -> dict:
ctx = runtime_context or {}
action = (input_data.get("action") or "").strip()
if action == "greet":
return {"success": True, "greeting": f"你好,{(input_data.get('name') or '朋友').strip()}!"}
if action == "查询":
...
return {"success": False, "need_param": "action", "error_type": "recoverable",
"error": f"未知动作:{action or '(空)'}"}

动作型入参归一、无 action 拒绝、身份取 ctx 这些规矩照常,见 references/input-schema.md §6/§7。 写 / 删类动作必须在 handler 里自己鉴权(网关已不挡 structural),见第 4 节末。

3.5 ui.html —— 只用相对路径​

<!DOCTYPE html>
<html lang="zh"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="style.css">
<title>面板</title>
</head><body>
<div class="card">
<h1>面板标题</h1>
<input id="name" placeholder="输入" /><button id="go">执行</button>
<div id="out"></div>
</div>
<script src="app.js"></script>
</body></html>

get_skill_ui 用 _rewrite_ui_assets 把 HTML 里 src= / href= 属性的相对路径改写成 /api/v1/skills/<name>/ui/asset?path=...(带 role_id 时 ?role_id=...&path=...)。精确规则:

写法是否改写
src="app.js" / href="./css/a.css"改写(./ 去掉)
http(s)://…、//cdn…、data:、#锚点、以 / 开头的站点根路径不动
CSS 里的 url(bg.png)、JS 里的 fetch('x.json') / import './m.js'、srcset不改写——会以宿主 SPA 为基准解析,取不到
HTML 里已经出现过 asset_base 字符串整份不再改写(幂等判断)

所以:背景图写进 <img src> 或内联 data:;JS 不要相对 import 拆模块,合成一个文件;数据走 invoke。

静态资源端点 /ui/asset:

  • 免鉴权、Access-Control-Allow-Origin: *、no-store——任何人知道技能名就能取。
  • 只从入口文件所在目录往下找,.. 越界拒绝;后缀白名单: .html .htm .js .mjs .css .json .map .png .jpg .jpeg .gif .webp .svg .ico .woff .woff2 .ttf .otf .eot。
  • 按 reg.get(skill_name) 定位,不分角色范围。
  • 推论:入口放技能根目录时,根目录下的 schema.json 等 .json 文件也能被匿名读到。面板放 ui/ 子目录, 任何含配置/样例数据/密钥的文件都不要放在入口目录树里。
  • 没有面板的技能同样暴露:找不到入口文件时 _resolve_ui_asset 退回整个技能目录,所以任何技能目录里白名单后缀的文件 都能按技能名被匿名下载。所有技能(不只是面板技能)都不得在目录里放含密钥 / 内部数据的 .json / .js 等文件。

3.6 app.js —— 固定 postMessage 握手(宿主前端协议)​

频道名固定 skill_panel,ready 必发。握手四步(ready → 收 init → invoke → 收 invoke_result)照抄, 只改「点按钮调哪个动作、怎么显示结果」那一段。完整骨架见 templates/panel/app.js。

parent.postMessage({ channel: 'skill_panel', type: 'ready' }, '*'); // 1) 必发
// 2) 收 init(含 theme 主题变量、result 本轮工具输出,可直接渲染)
// 3) invoke:parent.postMessage({channel:'skill_panel', type:'invoke', reqId, action, args}, '*')
// 4) 收 invoke_result → 读 ok / data / error(data 即 /skills/invoke 返回的 output)

宿主收到 invoke 后调 POST /api/v1/skills/invoke,body: {skill_name, role_id, session_id, input: {action, ...args}};后端返回 {success, output, error},宿主把 output 作为 data 回投。 (postMessage 这一层在前端,不在后端 routers 里;以模板为准。)

4. 页面动作网关 /skills/invoke(放行规则)​

按代码顺序:

步骤条件结果
① 角色传了 role_id 但 Agent 不存在 / 不属于当前团队40404「角色不存在」/ 40403「无权操作其他团队的角色」
② 定位技能按角色可见范围 get_for_role,再退 reg.get;不可执行40404「技能不可执行」
③ actioninput.action 为空40001「缺少 action」
④ 白名单schema 声明了 action.enum 且 action 不在其中40404「未知动作「x」」
⑤ 剥身份删掉 input 里的 emp_no actor_emp_no actor_id is_admin actor_is_admin team_role actor_title actor_department actor_department_path org_id—
⑥ 执行executor.execute(...){success, output, error}

网关不再做的事(旧文档写过,现已删除):x-actions 未声明 → 40303、invocable_from 不含 page → 40303、 structural: true → 40303。代码注释原话:「越权/结构变更由技能自身 + 框架注入的 emp_no/actor_is_admin 判定, 网关不再维护 x-actions」。

由此对技能作者的硬要求:

  1. 带面板的技能必须在 schema 写 action.enum,否则任意动作名都能从页面打进来。
  2. enum 里每个写 / 删 / 结构类动作,handler 自己判身份: actor_emp_no 为空(未解析出身份)或不满足业务条件 → 拒绝;删除类再加「预演 → 确认」两段。
  3. 不想让页面触发、只给模型用的危险动作,没有网关级开关可用——拆成另一个不带面板的技能, 或要求一次性确认参数(如 确认=true + 预演回显)。
  4. 别用 channel 判断「是不是页面调的」:见第 5 节,它会被身份段覆盖。

5. 身份注入:页面驱动时 runtime_context 里有什么​

/invoke、/test、/options-query、/chain/test 都由 _build_skill_runtime_ctx 组装上下文, 随后 executor 再合并 db_factory / call_skill / provider 等(与对话线同一入口)。

身份解析规则:

  • 只认「可信会话」:session_id 对应的 ChatSession 存在,且其 user_id 等于当前登录用户;否则当没有会话。
  • 有可信会话 → actor_ctx.resolve_actor + build_ctx(与对话线同一份实现),产出完整 actor_*; 该会话 is_admin=True(管理员控制台会话)时,据 _build_skill_runtime_ctx 文档字符串由 build_ctx 按 ADMIN_SKIP_ACTOR_KEYS 清空身份键(actor_emp_no actor_title actor_department(_id/_path) actor_key actor_id 等为空;已按 actor_ctx.py 核对)。
  • 没有可信会话(宿主没传 session_id,网关回退成 chart_<hex>)→ actor_* 全为空值。
  • emp_no = 显式 emp_no(/test、/chain/test 可指定)否则 actor_emp_no。/invoke 恒以 emp_no="" 进入, 所以页面驱动下 emp_no 就是解析出的 actor_emp_no。
键页面驱动下的值
role_id宿主传的 role_id;没传则为空 → org_id 也拿不到
org_id按 role_id 从 Agent.org_id 反查;为空时不写这个键
team_idAgent 的 team_id,否则登录用户的 team_id;为空时不写这个键
user_id登录用户 ID
session_id宿主传的会话 ID,否则 chart_<hex>
actor_* / raw_input / attachments / triggered_at*第 2 节固定词表全集,恒存在;无可信会话时全为空值
channel先写 web_chart,但字典里身份段在其后展开,身份段带 channel 时会覆盖它(固定词表含 channel)。不可依赖
emp_no同 actor_emp_no(历史别名,新代码读 actor_emp_no)
db请求级会话(遗留,不要用;直连走 db_factory)
skill_configs / skill_args / 平铺配置由 skill_param_store.inject 按 role_id + 技能名注入
team_role2026-10 补丁后注入:与 Web 数据管理同口径(Team.owner_id == user_id → owner,否则 TeamMember.role,否则 member)。未打补丁的部署没有此键,datatable 按 member 处理
is_admin / chat_mode不注入

数据可见性:

  • row_scope="own" 的数据表按 actor_emp_no 过滤——带了可信会话且用户在 ChatUser 档案里有工号时可用; 否则 fail-closed(拒绝),这是正确行为。
  • 组织隔离靠 org_id;没传 role_id 时 datatable 会因 org_id 为空拒绝。

/test、/chain/test(管理员试运行):channel web_admin、session test_<hex> / chaintest_<hex>(无会话 → actor_* 空), 可在 body 里传 emp_no 指定「以谁的身份跑」,并有审计日志。

6. 本地面板红线​

  • 只用相对路径(src= / href=)引资源;不写 /api/...、/xxx 站点根路径;CSS url() 与 JS 相对 import 不会被改写。
  • 绝不 fetch/XHR 直接打后端;所有数据走 invoke 通道。
  • 不用 localStorage/sessionStorage(srcdoc iframe 源为 null,存储不可用);状态存 JS 变量。
  • 频道名固定 skill_panel;ready 必发。
  • schema 必须写 properties.action.enum;写 / 删动作在 handler 里自己鉴权,不指望网关拦。
  • 身份只从 runtime_context 取;不读 input 里的身份字段(网关也会剥掉);不靠 channel 判来源。
  • 面板放 ui/ 子目录;入口目录树里没有任何敏感文件(静态资源免鉴权公开)。
  • handler 动作返回的字段名,要跟面板 res.data.xxx 读的对得上。

7. 外部面板:一行起​

---
name: crm-dashboard
description: 打开 XX 系统的控制台
source: builtin
category: crm
trigger-type: explicit
cost-tier: zero
ui: https://crm.example.com/dashboard # ★ 写 http(s) URL = 外部面板
---

平台看到 ui: 是 URL,get_skill_ui 返回 {external_url, panel_token, panel_origin}: 配了私钥就签 JWT 并把 panel_token=<JWT> 拼进 external_url,同时给前端 panel_token / panel_origin 供 postMessage 下发。AI 不生成 ui.html/app.js/style.css。

外部面板能不能跑起来、能不能免登,取决于对方系统(第 9 节)。

8. 外部面板登录链路(panel_token / JWT / 公钥)​

8.1 端到端流程​

用户在平台点开外部面板(GET /api/v1/skills/<name>/ui?role_id=…,需登录)
→ 平台取登录用户 user_id / team_id 与查询参数 role_id
→ 平台用 RS256 私钥签一枚短时效 JWT(panel_token,默认 300 秒)
→ iframe 打开 external_url(已带 ?panel_token=…);前端另可 postMessage {type:'panel_auth', token} 到 panel_origin
→ 对方用平台公钥验签(RS256,校验 aud / iss / exp)
→ 验签通过 → 对方用 payload.sub 建立自己的 session → 用户免重复登录

平台侧配置(skills.py 顶部常量):PANEL_JWT_PRIVATE_KEY / PANEL_JWT_PUBLIC_KEY(PEM,\n 可写成字面量)、 PANEL_JWT_ISSUER(默认 myinc-platform)、PANEL_JWT_TTL(默认 300)。

8.2 panel_token 的 claims(_sign_panel_jwt)​

claim值
issPANEL_JWT_ISSUER,默认 myinc-platform
aud外部 URL 的 netloc(urlparse(url).netloc,即 host 或 host:port),不是自定义标识
sub平台登录用户 ID
iat / exp签发 / 过期(iat + TTL)
role_id / team_id查询参数里的角色 ID / 登录用户的团队 ID(可能为空串)

JWT header 只有 alg / typ,没有 kid。

8.3 token 怎么传到对方页面​

  • URL 参数:https://对方域/dashboard?panel_token=<JWT>(原 URL 已有 ? 时用 & 拼)。
  • postMessage:前端定向发 {type:'panel_auth', token} 到 panel_origin(= URL 的 scheme://netloc)。 对方 addEventListener('message', …) 收,且必须校验 e.origin 只认平台域。

8.4 几种登录态​

情形结果
平台没配 PANEL_JWT_PRIVATE_KEY不签 token,纯透传 URL = 免登面板
平台配了私钥 + 对方接了验签带登录免登
平台配了私钥,但对方没接用户在面板里重新登录对方系统
对方不允许被 iframe 嵌入(CSP / X-Frame-Options)白屏——对方的事,不是平台 bug

8.5 公钥与对方验签示例​

公钥端点(本包代码可证):GET https://平台域名/api/v1/skills/panel/public-key,返回 PEM 纯文本, 免鉴权、CORS *、缓存 1 小时;平台没配公钥时返回 50301。 (/.well-known/jwks.json 挂在 main.py,不在本次核对范围;因为 token 无 kid,JWKS 客户端按 kid 选钥会失败, 对接方一律按 PEM 验签。)

# Python: PyJWT
import jwt, requests
PEM = requests.get("https://平台域名/api/v1/skills/panel/public-key", timeout=5).text # 可缓存
payload = jwt.decode(token, PEM, algorithms=["RS256"],
audience="crm.example.com", # = 你的面板 URL 的 host[:port]
issuer="myinc-platform")
# 通过 → payload["sub"] 即平台用户,据此建立你自己的 session
// Node: jose
import { importSPKI, jwtVerify } from 'jose'
const pem = await (await fetch('https://平台域名/api/v1/skills/panel/public-key')).text()
const key = await importSPKI(pem, 'RS256')
const { payload } = await jwtVerify(token, key, { issuer: 'myinc-platform', audience: 'crm.example.com' })

验签通过后立刻换成对方自己的 session,别长期存这个 JWT。平台换密钥时对方需重新拉 PEM(建议按 1 小时缓存)。

平台侧怎么签 token、密钥怎么生成与轮换,属于平台维护范畴,不是技能作者要写的代码。 技能作者只需 ui: 写 URL,并把第 9 节转达给对接方。

9. 给对接方的三件事(写进技能说明提醒使用者)​

  1. 允许被 iframe 嵌(必做,否则白屏):移除 X-Frame-Options: DENY/SAMEORIGIN, CSP 改为 Content-Security-Policy: frame-ancestors https://平台域名;。
  2. 接收 panel_token:读 URL 参数 panel_token,或监听 postMessage {type:'panel_auth'}(校验 e.origin)。
  3. 验签:用平台 PEM 公钥 + 标准 JWT 库,algorithms=["RS256"],校验 aud(= 面板 URL 的 host[:port]) 与 iss(默认 myinc-platform);通过后换成对方 session。页面自适应 iframe 尺寸(宽高 100%)。

对接时向平台索取:公钥地址、iss。aud 不用约定——就是面板 URL 的 host[:port]。

10. 与其它 reference 的接缝​

11. 生成后自查​

本地面板:

  1. SKILL.md 有 ui: <相对路径.html> 且文件存在;建议放 ui/ 子目录。没想要面板的技能根目录里没有 panel.html / ui.html / index.html。
  2. handler 是 async def execute(input_data, runtime_context),能处理 enum 里每一个动作。
  3. schema 写了 properties.action.enum,且面板调的每个动作都在其中。
  4. 写 / 删动作在 handler 内按 actor_emp_no / actor_is_admin / 业务条件自鉴权;不依赖 channel、不依赖网关。
  5. 身份只从 runtime_context 取;不读 input 身份字段。
  6. ui.html 只用 src= / href= 相对路径;CSS url()、JS 相对 import 没有用到。
  7. app.js 完整 ready/init/invoke/invoke_result 握手,频道 skill_panel,ready 必发。
  8. 无 localStorage/sessionStorage;无 fetch 直连后端。
  9. 入口目录树里没有敏感文件(静态资源免鉴权公开)。
  10. 动作返回字段名与面板 res.data.xxx 读的对得上。

外部面板:

  1. SKILL.md 的 ui: 是 http(s) URL;不生成本地面板文件。
  2. 技能说明提醒了第 9 节三件事,验签示例用 PEM 公钥、aud = 面板 URL 的 host[:port]。

这几条已并入 references/checklist.md 的「面板」段。