实验性项目 WorkFly 完全由 AI 生成,可能存在缺陷或错误,仅供内部测试,非用友官方产品,不代表公司立场。
开发者中心最佳实践

最佳实践:CURL 与红火台

两个真实 Manifest v2 包。CURL 讲最小闭环;红火台讲业务会话、只读查询、Surface、Skill 与 MCP。

这两个案例都是仓库内真实可构建的 Manifest v2 小程序(packages/miniapps/curlpackages/miniapps/catering),用作最佳实践拆解,而不是虚构协议。红火台自 1.4.0 起采用作者侧目录:capabilities/toolscapabilities/skillssurfaces/cards|widgets,由 @workfly/miniapp-kit 在打包时合并为 contributes;CURL 仍保持薄 manifest + 内联贡献,适合档 0 学习。

学习目标 先看
最小权限、内容带入、一次 wf.request、可选一张摘要卡 CURL(约等于 档 0 + 可选档 2 的卡)
业务登录、Hash 深链、只读 query、Skill、MCP、双账户口径 红火台档 1 + 档 2

不要从红火台完整清单照抄。先按 CURL 的尺度做出最小闭环,再只增加业务确实需要的能力。概念背景见 设计理念

先看差异

CURL 调试 红火台
完整页 单页 React Sandbox 多页面 React Sandbox + Hash 路由
权限 net.fetch net.fetchidentity.loginvault
数据来源 用户输入的任意请求 固定的红火台 HTTPS 服务
宿主入口 面板、Spotlight、深链、剪贴板、IM 内容 面板、Spotlight、深链
宿主 UI 请求摘要会话卡 余额卡、订单卡、首页 / Spotlight 动态磁贴
Agent 能力 2 个 Skill、2 个打开 Tool、6 个只读查询 Tool
外部调用 不投影业务查询 只读查询经 miniapp:read 投影到 MCP
适合学习 最小权限、内容带入、wf.request 鉴权、路由、数据边界、卡片与 Agent 协作

共同点只有三条:完整页始终运行在 Sandbox;能力全部由 Manifest 显式声明;组件只通过公开的 wf / host.wf 调宿主。

CURL:从一段命令到可复用请求

CURL 的用户路径很短:

  1. 用户从剪贴板、IM 文本或 Spotlight 把一段 CURL 带入应用;
  2. 应用解析方法、URL、Header 与 Body;
  3. 用户确认后调用 wf.request
  4. 结果留在应用内,必要时由 React UI Module 渲染一张请求摘要卡。

Manifest 只声明真实入口

{
  "id": "com.workfly.curl",
  "manifest": 2,
  "ui": {
    "standalone": {
      "enabled": true,
      "entry": "dist/index.html",
      "render": "sandbox",
      "layout": "full",
      "routing": {
        "mode": "hash",
        "defaultPath": "/",
        "routes": [{ "path": "/", "shareable": true }]
      }
    }
  },
  "launchers": ["miniprogram_panel", "spotlight", "deeplink"],
  "permissions": [{ "id": "net.fetch", "reason": "向你填写的接口发起请求并展示响应" }]
}

它不需要登录、Vault 或设备信息,因此不声明这些权限。clipboardHintscontentMatchers 只负责把命中的文本作为 query 带入同一个已声明路由,不在宿主里执行请求。

页面只依赖公开 SDK

const response = await wf.request({
  url: spec.url,
  method: spec.method,
  header: Object.fromEntries(spec.headers.map(({ key, value }) => [key, value])),
  data: spec.body,
  dataType: 'text'
})

开发态由 @workfly/vite-plugin 注入同名 wf 并代理网络请求;装入客户端后,调用自动切到真实宿主实现。业务组件不需要判断自己运行在哪种环境。

为什么卡片单独编译

CURL 的完整页仍是 Sandbox;只有会话里的 RequestCard 使用高信任 React UI Module。Vite 配置把 React runtime external 出去,把第三方依赖拆成相对 chunk,样式由宿主挂进 Shadow Root。

workfly({
  reactUiModules: { entries: { surfaces: 'src/UiSurfaces.tsx' } }
})

这条边界使完整应用可以继续选 React、Vue 或 Vanilla,而注册到宿主 Surface 的组件始终遵循稳定的 workfly-react@1 ABI。

红火台:从业务接口到 Agent 能力

红火台不是“把网页塞进小程序”。它把同一个业务域拆成四层:Sandbox 页面负责完整操作;声明式 query Tool 负责受限只读查询;React UI Module 负责宿主卡片与磁贴;Skill 负责教 Agent 如何分页、区分账户口径并解释结果。

先换自己的业务会话

页面通过 wf.login() 获取一次性 code,再交给红火台服务换取自己的 accessToken + tenantId。业务会话保存在当前 app id 隔离的 Vault,宿主 Token 不会进入小程序。

const { code } = await wf.login()
const session = await exchangeBusinessSession(code)
await wf.setStorage({ key: 'catering.business-session', data: session })

这套会话随后复用于余额、流水、订单、详情与评价接口。Cookie、抓包 Token 和真实身份数据都不写入 Manifest,也不进入示例代码。

路由就是可导航契约

"routing": {
  "mode": "hash",
  "defaultPath": "/balance",
  "routes": [
    { "path": "/balance", "shareable": true },
    { "path": "/accounts/:kind/:id", "shareable": false },
    { "path": "/orders", "shareable": true },
    { "path": "/orders/:orderId", "shareable": true }
  ]
}

Tool 打开路径、卡片主操作、应用内导航和分享链接都指向这份路由表。账户明细含内部账户 ID,因此明确设为不可分享;订单列表和详情可以被可靠定位。

查询 Tool 固定请求边界

订单列表由宿主执行声明式只读请求,而不是在后台运行第三方 JavaScript:

{
  "name": "miniapp_catering_orders_query",
  "parameters": [
    { "name": "statuses", "default": "0,1,2" },
    { "name": "current", "default": "1" },
    { "name": "size", "default": "10" }
  ],
  "query": {
    "readOnly": true,
    "sessionKey": "catering.business-session",
    "request": {
      "method": "POST",
      "url": "https://catering.yonyoucloud.com/catering/order/ctnorderinfo/v1/getUserOrderList",
      "header": {
        "Authorization": "Bearer {{session.accessToken}}",
        "TENANT-ID": "{{session.tenantId}}"
      }
    },
    "result": {
      "source": "data.records",
      "fields": ["id", "orderNo", "clientOrderStatus", "shopName", "payMoney", "createTime"],
      "meta": { "current": "data.current", "total": "data.total", "pages": "data.pages" },
      "maxItems": 20
    }
  },
  "expose": true
}

关键不是 POST 或 GET,而是 readOnly: true、固定 HTTPS 主机、明确业务成功条件、字段白名单、分页元数据与结果大小上限。外部 MCP 只拿到这份投影,不拿内部卡片、建议或业务会话。

同一结果服务三种消费者

  • 用户在会话里看resultCard 把当前页最近三笔订单交给 OrdersCard;完整列表由卡片主操作进入 /orders
  • Agent 理解上下文resultCard.summary 说明对象、口径、分页与实时性,避免模型只知道“展示了一张卡”。
  • 外部工具查询expose: true 把字段受限结果投影到 miniapp:read,不携带内部 UI。

卡片还可以贡献最多 4 条“下一步建议”。查询和分析使用 send;评价、提交、删除等有副作用的动作使用 fill,必须由用户确认后发送。

数据口径写进 Skill

红火台的个人账户和部门账户不能相加,也不能共用消费趋势刻度;订单与流水都是分页结果。Skill 明确要求 Agent 读取 current / pages / total,在分析前判断是否继续翻页。超出 JavaScript 安全整数范围的订单 ID 和账户 ID 全程保持字符串。

这类规则不适合藏在 UI 组件里。UI 负责显示,Tool 负责取数,Skill 负责解释与操作顺序,三者职责分开后才能同时服务会话和外部 AI。

按角色复用的清单

你在做「工具型」小程序(像 CURL)

  1. 权限能少则少:通常只要 net.fetch
  2. clipboardHints / contentMatchers 把内容带进已声明路由,不在宿主里执行业务。
  3. 页面只调公开 wf.*;开发态与安装态同一套 API。
  4. 若需要会话摘要卡,单独编译 UI Module,完整页仍可保持 Sandbox。
  5. 不声明用不到的 login / vault / MCP expose。

你在做「业务域」小程序(像红火台)

  1. wf.login → 自己的业务会话 → 当前 app Vault;禁止宿主 Token 进包。
  2. 路由表先于 Tool / 分享 / 卡片打开路径。
  3. query Tool:固定主机、readOnly、成功条件、字段白名单、分页 meta、体积上限。
  4. 同一查询服务三种消费者:用户卡、Agent summary、MCP 字段投影。
  5. 业务口径(不可相加的账户、分页未完不算全集、大整数 ID 字符串)写进 Skill,不埋在 CSS。
  6. Surface 只做摘要;复杂操作 openApp 回完整页。

两条共用

  1. 从 Sandbox 主线开始,没有嵌入需求就不要 UI Module。
  2. 权限按真实调用声明,写清 reason
  3. 用安装包做最终验收,不要只信 npm run dev

第三条路径 · 会话总结(IM + 宿主 AI)

与 CURL/红火台不同:不依赖你自己的 HTTPS 业务后端,而走宿主 IM 只读 + AI 补全

步骤 做法
入口 imEntries → 工具栏 popover
数据 wf.im.getHistoryim.chatContext
推理 wf.ai.complete + onChunkai.complete
样板 仓库 packages/miniapps/im-context-demo

适合「客户自写提示词、总结本群精彩片段」类需求;不要为此申请 ACP 或代发消息。→ 开发指南 · 阶梯旁路 · WF API

下一步