面板:本地面板、外部面板、免登登录链路
什么时候读:技能要带可交互面板(用户点按钮 / 填表单 / 看技能自己产出的数据),或要把一个 已有的外部网页整个嵌进平台当面板时。纯 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 公钥验签最稳。
目录
- 先决条件:面板是 handler 型的附加物
- 三种面板形态与入口声明
- 本地面板:文件、SKILL.md、schema、handler、ui.html、app.js
- 页面动作网关
/skills/invoke(放行规则) - 身份注入:页面驱动时 runtime_context 里有什么
- 本地面板红线
- 外部面板:一行
ui: <URL>起 - 外部面板登录链路(panel_token / JWT / 公钥)
- 给对接方的三件事
- 与其它 reference 的接缝
- 生成后自查
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.mdfrontmatter >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「技能不可执行」 |
| ③ action | input.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」。
由此对技能作者的硬要求:
- 带面板的技能必须在 schema 写
action.enum,否则任意动作名都能从页面打进来。 - enum 里每个写 / 删 / 结构类动作,handler 自己判身份:
actor_emp_no为空(未解析出身份)或不满足业务条件 → 拒绝;删除类再加「预演 → 确认」两段。 - 不想让页面触发、只给模型用的危险动作,没有网关级开关可用——拆成另一个不带面板的技能,
或要求一次性确认参数(如
确认=true+ 预演回显)。 - 别用
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_noactor_titleactor_department(_id/_path)actor_keyactor_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_id | Agent 的 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_role | 2026-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站点根路径;CSSurl()与 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 | 值 |
|---|---|
iss | PANEL_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. 给对接方的三件事(写进技能说明提醒使用者)
- 允许被 iframe 嵌(必做,否则白屏):移除
X-Frame-Options: DENY/SAMEORIGIN, CSP 改为Content-Security-Policy: frame-ancestors https://平台域名;。 - 接收 panel_token:读 URL 参数
panel_token,或监听 postMessage{type:'panel_auth'}(校验e.origin)。 - 验签:用平台 PEM 公钥 + 标准 JWT 库,
algorithms=["RS256"],校验aud(= 面板 URL 的 host[:port]) 与iss(默认myinc-platform);通过后换成对方 session。页面自适应 iframe 尺寸(宽高 100%)。
对接时向平台索取:公钥地址、iss。aud 不用约定——就是面板 URL 的 host[:port]。
10. 与其它 reference 的接缝
action.enum与动作归一 →references/input-schema.md- 面板读的
res.data.xxx= handler 返回的 dict;失败给error→references/output-envelope.md - 面板技能必是进程内 handler →
references/type-handler-python.md - 身份键全表 →
references/runtime-context.md
11. 生成后自查
本地面板:
- SKILL.md 有
ui: <相对路径.html>且文件存在;建议放ui/子目录。没想要面板的技能根目录里没有panel.html/ui.html/index.html。 - handler 是
async def execute(input_data, runtime_context),能处理 enum 里每一个动作。 - schema 写了
properties.action.enum,且面板调的每个动作都在其中。 - 写 / 删动作在 handler 内按
actor_emp_no/actor_is_admin/ 业务条件自鉴权;不依赖channel、不依赖网关。 - 身份只从
runtime_context取;不读 input 身份字段。 - ui.html 只用
src=/href=相对路径;CSSurl()、JS 相对 import 没有用到。 - app.js 完整 ready/init/invoke/invoke_result 握手,频道
skill_panel,ready必发。 - 无 localStorage/sessionStorage;无 fetch 直连后端。
- 入口目录树里没有敏感文件(静态资源免鉴权公开)。
- 动作返回字段名与面板
res.data.xxx读的对得上。
外部面板:
- SKILL.md 的
ui:是 http(s) URL;不生成本地面板文件。 - 技能说明提醒了第 9 节三件事,验签示例用 PEM 公钥、
aud= 面板 URL 的 host[:port]。
这几条已并入 references/checklist.md 的「面板」段。