diff --git a/src/crates/assembly/core/builtin_skills/miniapp-dev/SKILL.md b/src/crates/assembly/core/builtin_skills/miniapp-dev/SKILL.md new file mode 100644 index 0000000000..1cc6dc30b5 --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/miniapp-dev/SKILL.md @@ -0,0 +1,251 @@ +--- +name: miniapp-dev +description: 'Generate and refine BitFun MiniApps. Use when the user wants a new MiniApp, wants an existing MiniApp redesigned or extended, or asks for a BitFun in-app tool. Typical triggers: "做一个小应用", "生成 MiniApp", "写个 BitFun 小工具", "创建 mini app".' +--- + +# BitFun MiniApp 生成指南 + +本技能用于**为用户生成、改造、完善一个 MiniApp**: + +- 做一个新的 BitFun 小应用 +- 修改某个 MiniApp 的交互、界面、能力、数据流 +- 把一个想法变成可运行的 MiniApp + +开始生成新的 MiniApp 前,先读 [`design-playbook.md`](design-playbook.md);运行时能力和宿主 API 细节再查 [`api-reference.md`](api-reference.md)。 + +## 目标 + +**交付一个能在 BitFun 里运行、风格合适、权限最小、结构清晰的 MiniApp**。 + +成功标准: + +- 用户的问题被这个 MiniApp 直接解决 +- 生成结果能在 MiniApp 场景里运行 +- 只申请必要权限 +- 不假设不存在的宿主 API +- 在 light/dark、zh/en 下都可用 + +## 先做什么 + +在写代码前,先完成这 4 件事: + +1. 明确用户目标 + 这个 MiniApp 是工具型、展示型,还是混合型?核心动作是什么? + +2. 找最近的参考 + 看 `references/examples/` 中最贴近任务形态的内置/示例 MiniApp 目录 + +3. 选运行模式 + 先判断是否真的需要 `worker.js` 和 `node.enabled = true`。 + +4. 定最小交付面 + 第一版只做最核心路径,不为了“看起来完整”堆功能。 + +## 生成流程 + +### 1. 先澄清,再实现 + +如果下面任一项不清楚,先问清楚,不要替用户脑补: + +- 解决什么问题 +- 谁使用 +- 要读写哪些路径 +- 要不要读工作区文件 +- 要执行哪些命令 +- 要不要执行命令 +- 要访问哪些域名 +- 要不要联网 +- 要不要持久化状态 +- 要不要多语言 +- 要不要 Tweaks 这类运行时可调变体 +- 有没有现成视觉参考 + +### 2. 优先复用现有 MiniApp 语言 + +不要从零发明一套 BitFun 风格。先从已有 MiniApp 中借鉴: + +- 布局密度 +- 圆角和间距 +- 卡片和面板结构 +- 主题变量使用方式 +- i18n 组织方式 + +默认优先做**工具型**设计:冷静、克制、信息密度高、操作路径短。 + +### 3. 优先选“无 Node 模式” + +如果需求只靠这些能力就能完成: + +- `app.fs.*` +- `app.shell.exec` +- `app.net.fetch` +- `app.os.info` +- `app.storage.*` + +那么优先使用: + +```json +{ + "permissions": { + "node": { "enabled": false } + } +} +``` + +只有在这些场景下才启用 `node.enabled = true`: + +- 需要自定义 `worker.js` 方法 +- 需要 npm 依赖 +- 需要较长链路或较复杂的后台逻辑 + +### 4. 用 `InitMiniApp` 创建骨架 + +创建后,围绕这些文件工作: + +- `index.html` +- `style.css` +- `ui.js` +- `worker.js`(只有需要时) +- `meta.json` + +默认做法: + +- `index.html` 只放清晰结构 +- `style.css` 先声明设计系统 +- `ui.js` 负责状态、渲染、事件、i18n +- `worker.js` 只承载真正需要后台执行的逻辑 + +### 5. 只使用真实存在的宿主能力 + +MiniApp 里可用的是 `window.app`。 + +默认可依赖的能力: + +- `app.fs.*` +- `app.shell.exec` +- `app.net.fetch` +- `app.os.info` +- `app.storage.get/set` +- `app.dialog.*` +- `app.clipboard.*` +- `app.ai.*` +- `app.theme` +- `app.locale` +- `app.onThemeChange` +- `app.onLocaleChange` +- `app.t(...)` +- `app.call(...)` 仅在 `node.enabled = true` 时 + +详细接口查: + +- [`api-reference.md`](api-reference.md) + +### 6. 不要假设这些 API 存在 + +默认**不要**写这些不存在的接口: + +- `app.bitfun.*` +- `app.workspace.*` +- `app.git.*` +- `app.session.*` +- `app.terminal.*` +- `app.browser.*` + +如果你需要 Git 能力,优先: + +```javascript +await app.shell.exec('git ...', { cwd: app.workspaceDir }) +``` + +如果你需要工作区数据,优先: + +```javascript +await app.fs.readFile(...) +``` + +### 7. 从第一版就带上 i18n 和 theme + +不要把多语言和主题适配留到最后。 + +至少做到: + +- `meta.json` 带 `i18n.locales` +- 静态文案可重渲染 +- 动态文案走 `app.t(...)` 或自有 `I18N` 表 +- 样式优先使用 `--bitfun-*` +- 测试 light/dark + zh/en + +### 8. 先做核心体验,不补假内容 + +如果缺素材、图标、真实数据: + +- 用明确占位 +- 用 fixture 数据 +- 用“待补”标记 + +不要: + +- 硬画劣质插画 +- 编造业务数据 +- 用装饰性内容填空白 + +## 硬约束 + +### 交互 + +- 首屏就要能理解用途 +- 主路径操作数尽量少 +- 点击区域至少 32px +- 正文不要小于 13px + +### 视觉 + +- 禁止默认蓝紫渐变 AI 风背景 +- 禁止 emoji 充当主图标 +- 禁止“每块一个风格” +- 禁止堆无意义 stats、sparkline、装饰 icon + +### 代码 + +- 不需要 `worker.js` 时不要启用 Node +- 不需要的权限不要申请 +- 不要把大量逻辑塞进 HTML +- `ui.js` 过长时主动拆成模块化结构 + +### 内容 + +- 不为填空白加内容 +- 每个 section 都要有明确用途 +- 不擅自扩 scope + +## 你应该参考什么 + +生成前优先阅读最贴近的一两个参考,而不是全看: + +- `references/examples/demo-git-graph/` +- `references/examples/demo-icon-design-system/` +- `references/examples/builtin-regex-playground/` +- `references/examples/builtin-coding-selfie/` +- `references/examples/builtin-gomoku/` +- `references/examples/builtin-daily-divination/` + +生成新的 MiniApp 时,默认先读: + +- [`design-playbook.md`](design-playbook.md) + +如果任务偏运行时调用,再看: + +- [`api-reference.md`](api-reference.md) + +## 交付前检查 + +交付前至少确认: + +- MiniApp 能运行 +- 主路径可操作 +- 权限是最小集 +- `node.enabled` 选择合理 +- 没有调用不存在的 `app.*` API +- i18n 至少覆盖 `zh-CN` / `en-US` +- light/dark 没有明显样式问题 +- 没有遗留 “TODO / 占位 / Lorem ipsum” diff --git a/src/crates/assembly/core/builtin_skills/miniapp-dev/api-reference.md b/src/crates/assembly/core/builtin_skills/miniapp-dev/api-reference.md new file mode 100644 index 0000000000..a2321a627b --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/miniapp-dev/api-reference.md @@ -0,0 +1,433 @@ +# MiniApp API 参考 + +此文档定义生成或修改 MiniApp 时可用的 API,供实现时直接参考。 + +> **实际全局对象为 `window.app`**(非 `window.__BITFUN__`),以下各节均基于 `window.app`。 + +## 能力边界 + +MiniApp **能且只能**用以下 API,没有任何"通用 BitFun 后端通道"。生成代码前请先确认你需要的能力在表内: + +- `app.fs.*` —— 文件系统(受 `permissions.fs.read/write` 限制) +- `app.shell.exec` —— 子进程命令行(受 `permissions.shell.allow` 命令名白名单限制) +- `app.net.fetch` —— HTTP 请求(受 `permissions.net.allow` 域名白名单限制) +- `app.os.info` —— 只读系统信息 +- `app.storage.get/set` —— 每应用独立 KV 存储 +- `app.ai.complete / chat / cancel / getModels` —— 复用宿主 AI(无需 API Key) +- `app.dialog.open/save/message` —— 文件对话框 +- `app.clipboard.readText/writeText` —— 剪贴板 +- `app.call('xxx', ...)` + `worker.js` —— 自定义 Node 后端(仅 `node.enabled = true` 时) +- `app.theme / locale / on*` —— 主题与 i18n + +MiniApp 不提供通用 BitFun 后端通道。不要假设这些接口存在: + +- `app.bitfun.*` +- `app.workspace.*` +- `app.git.*` +- `app.session.*` +- `app.terminal.*` +- `app.browser.*` + +需要相关能力时,优先这样做: + +1. 需要 Git 或其他命令行能力:用 `app.shell.exec`(如 git → 在 `permissions.shell.allow` 加 `"git"`,参考 `references/examples/builtin-coding-selfie/ui.js`); +2. 需要工作区文件:用 `app.fs.*`(把 `{workspace}` 加到 `permissions.fs.read`); +3. 需要未列出的宿主内部能力:当前不支持,不要自行模拟一套 `app.*` 接口。 + +## 标准 Node.js API(通过 require() shim) + +### fs/promises + +```javascript +const fs = require('fs/promises'); +``` + +| 方法 | 签名 | 说明 | +|------|------|------| +| `readFile` | `(path, opts?) → Promise` | opts: `{ encoding: 'utf-8' \| 'base64' }` | +| `writeFile` | `(path, data, opts?) → Promise` | opts: `{ encoding: 'utf-8' \| 'base64' }` | +| `appendFile` | `(path, data) → Promise` | | +| `readdir` | `(path, opts?) → Promise` | opts: `{ withFileTypes: boolean }` | +| `mkdir` | `(path, opts?) → Promise` | opts: `{ recursive: boolean }` | +| `rmdir` | `(path, opts?) → Promise` | opts: `{ recursive: boolean }` | +| `rm` | `(path, opts?) → Promise` | opts: `{ recursive: boolean, force: boolean }` | +| `stat` | `(path) → Promise` | Returns: `{ size, isFile, isDirectory, mtime, ctime }` | +| `lstat` | `(path) → Promise` | | +| `access` | `(path) → Promise` | throws if not accessible | +| `copyFile` | `(src, dst) → Promise` | | +| `rename` | `(oldPath, newPath) → Promise` | | +| `unlink` | `(path) → Promise` | | + +### path(纯 JS,零延迟) + +```javascript +const path = require('path'); +``` + +`join`, `resolve`, `dirname`, `basename`, `extname`, `parse`, `sep` + +### child_process + +```javascript +const { exec } = require('child_process'); +``` + +| 方法 | 签名 | 说明 | +|------|------|------| +| `exec` | `(cmd, opts?, callback?) → Promise \| void` | opts: `{ cwd, timeout }` | + +支持两种调用风格: +- **Promise 风格**:`const result = await exec(cmd, opts)` → 返回 `{ stdout, stderr, exit_code }` +- **Callback 风格**:`exec(cmd, opts, (err, stdout, stderr) => { ... })` → 无返回值 + +受 `permissions.shell.allow` 命令白名单限制。 + +### os(纯 JS) + +```javascript +const os = require('os'); +``` + +`platform()`, `homedir()`, `tmpdir()`, `cpus()`, `hostname()` + +### crypto + +```javascript +const crypto = require('crypto'); +``` + +映射 `window.crypto.subtle`,支持 `randomUUID()`。 + +## 标准浏览器 API + +MiniApp 运行在 iframe 中,完整支持: +- DOM、CSS(含 CSS 变量 `--bitfun-bg`, `--bitfun-text`, `--bitfun-accent` 等) +- Canvas 2D / WebGL +- Web Audio +- LocalStorage / SessionStorage(iframe 级隔离) +- `navigator.clipboard`(通过 `app.clipboard.*` 代理,绕过 sandbox 限制) + +## `window.app` — 全局 Runtime Adapter + +MiniApp 中所有与宿主通信的 API 均通过 `window.app` 暴露。 + +### 基本属性 + +```javascript +app.appId // string — 当前 MiniApp 的 ID +app.appDataDir // string — 应用数据目录绝对路径 +app.workspaceDir // string — 当前工作区路径 +app.theme // 'dark' | 'light' — 当前主题 +app.locale // string — 当前语言 ID(如 'zh-CN' / 'en-US'),随宿主切换更新 +app.platform // 'win32' | 'darwin' | 'linux' +app.mode // 'hosted' +``` + +### `app.fs.*` — 文件系统 + +需在 `permissions.fs` 中声明读写范围。 + +| 方法 | 签名 | 说明 | +|------|------|------| +| `readFile` | `(path, opts?) → Promise` | opts: `{ encoding: 'utf-8' \| 'base64' }` | +| `writeFile` | `(path, data, opts?) → Promise` | opts: `{ encoding: 'utf-8' \| 'base64' }` | +| `appendFile` | `(path, data) → Promise` | | +| `readdir` | `(path, opts?) → Promise` | opts: `{ withFileTypes: boolean }` | +| `mkdir` | `(path, opts?) → Promise` | opts: `{ recursive: boolean }` | +| `rm` | `(path, opts?) → Promise` | opts: `{ recursive: boolean, force: boolean }` | +| `stat` | `(path) → Promise` | `{ size, isFile, isDirectory, mtime, ctime }` | +| `copyFile` | `(src, dst) → Promise` | | +| `rename` | `(oldPath, newPath) → Promise` | | + +### `app.storage.*` — KV 持久化存储 + +无权限要求,数据存储在 `{appdata}/storage.json`。 + +```javascript +await app.storage.set('myKey', { foo: 'bar' }); +const value = await app.storage.get('myKey'); // { foo: 'bar' } +``` + +### `app.dialog.*` — 系统对话框 + +```javascript +const path = await app.dialog.open({ + title: '选择文件', + multiple: false, + filters: [{ name: 'SVG', extensions: ['svg'] }] +}); +const savePath = await app.dialog.save({ title: '保存', defaultPath: 'output.svg' }); +await app.dialog.message({ title: '提示', message: '操作成功' }); +``` + +### `app.shell.*` — Shell 命令执行 + +需在 `permissions.shell.allow` 中声明命令白名单。 + +```javascript +const result = await app.shell.exec('git log --oneline -10', { cwd: app.workspaceDir }); +``` + +### `app.net.*` — 网络请求(Worker 侧) + +需在 `permissions.net.allow` 中声明域名白名单。 + +```javascript +const data = await app.net.fetch('https://api.example.com/data', { method: 'GET' }); +``` + +### `app.os.*` — 系统信息 + +```javascript +const info = await app.os.info(); // { platform, homedir, tmpdir, ... } +``` + +### `app.call(method, params)` — 调用 Worker 方法 + +调用 `source/worker.js` 中导出的函数。 + +```javascript +const result = await app.call('myWorkerMethod', { key: 'value' }); +``` + +> **要求 `permissions.node.enabled = true`**。`node.enabled = false` 时只能调用框架原语(`app.fs.* / shell.* / net.* / os.* / storage.*`),调用任何自定义方法会得到明确的错误提示。 + +--- + +## `app.ai.*` — AI 接口(v2) + +直接复用宿主应用的 AI Client,无需配置 API Key。需在 `permissions.ai` 中声明。 + +### `app.ai.complete(prompt, opts?)` — 单次补全 + +返回完整文本,适合一次性生成场景。 + +```javascript +const result = await app.ai.complete('生成一个设置图标的 SVG,viewBox 24x24,线性风格', { + systemPrompt: '你是一个图标设计专家,只输出 SVG 代码,不含任何说明文字。', + model: 'fast', // 'primary' | 'fast' | 具体 model_id,默认 'primary' + maxTokens: 4096, + temperature: 0.7, +}); +console.log(result.text); // SVG 字符串 +console.log(result.usage); // { promptTokens, completionTokens, totalTokens } +``` + +### `app.ai.chat(messages, opts?)` — 流式对话 + +支持多轮对话和流式输出,适合交互式生成场景。 + +```javascript +const handle = await app.ai.chat( + [ + { role: 'user', content: '设计一个首页图标,圆角风格,24px 网格' } + ], + { + systemPrompt: '你是图标设计专家,生成符合设计规范的 SVG 代码。', + model: 'primary', + onChunk: ({ text, reasoningContent }) => { + // 实时更新预览 + if (text) appendToPreview(text); + }, + onDone: ({ fullText, usage }) => { + // 完成后处理完整结果 + const svg = extractSvg(fullText); + renderIcon(svg); + }, + onError: ({ message }) => { + console.error('AI error:', message); + }, + } +); + +// 取消流式请求 +cancelButton.onclick = () => handle.cancel(); + +// handle.streamId — 当前流的唯一 ID +``` + +### `app.ai.getModels()` — 查询可用模型 + +返回当前 MiniApp 权限范围内可用的模型列表(不含 API Key 等敏感信息)。 + +```javascript +const models = await app.ai.getModels(); +// [{ id: 'gpt4o', name: 'GPT-4o', provider: 'openai', isDefault: true }, ...] +``` + +### `app.ai.cancel(streamId)` — 取消流式请求 + +```javascript +await app.ai.cancel(handle.streamId); +``` + +### AI 权限声明 + +```json +{ + "permissions": { + "ai": { + "enabled": true, + "allowed_models": ["primary", "fast"], + "max_tokens_per_request": 8192, + "rate_limit_per_minute": 30 + } + } +} +``` + +- `allowed_models`:可用模型引用列表,支持 `"primary"`、`"fast"` 及具体 model_id;为空则允许所有模型 +- `max_tokens_per_request`:单次请求最大输出 token 数 +- `rate_limit_per_minute`:每分钟最大请求次数(按 app 计数) + +--- + +## `app.clipboard.*` — 剪贴板 + +通过宿主代理,绕过 iframe sandbox 的 clipboard 限制。 + +```javascript +await app.clipboard.writeText('Hello World'); +const text = await app.clipboard.readText(); +``` + +--- + +## 生命周期钩子 + +```javascript +app.onActivate(() => { /* Tab 变为活跃状态 */ }); +app.onDeactivate(() => { /* Tab 切走 */ }); +app.onThemeChange((payload) => { + // payload: { type: 'dark'|'light', vars: { '--bitfun-bg': '...', ... } } +}); +app.onLocaleChange((locale) => { + // locale: 新的语言 ID 字符串(如 'zh-CN' / 'en-US') +}); +``` + +## 国际化 i18n + +### `app.t(table, fallback)` — 多语言字符串挑选 + +```javascript +const label = app.t({ 'zh-CN': '保存', 'en-US': 'Save' }, 'Save'); +``` + +挑选顺序:`app.locale` → `'en-US'` → `'zh-CN'` → 表的第一个值 → `fallback`。适合在 JS 里就地写少量翻译。 + +更完整的做法(推荐): + +1. 在 `meta.json` 顶层加 `i18n.locales` 块翻译 `name` / `description` / `tags`,宿主 Gallery 自动按当前语言显示。 +2. 在 HTML 静态文案上加 `data-i18n="key"`(可选 `data-i18n-attr="aria-label"` 翻译属性)。 +3. 在 `ui.js` 中维护 `I18N` 字典,封装 `t(key)` 与 `applyStaticI18n()`,并 `app.onLocaleChange(...)` 时重新渲染动态内容。 +4. `app.storage` 持久化的字段保存语言无关的索引/键,避免存了翻译后字符串导致切换语言失效。 + +参考实现:`references/examples/builtin-gomoku/ui.js`、`references/examples/builtin-regex-playground/ui.js`。 + +## 自定义事件 + +```javascript +app.on('myEvent', (payload) => { /* 处理事件 */ }); +app.off('myEvent', handler); +``` + +--- + +## `app.dialog.*` — 系统对话框(详细) + +### `app.dialog.open` + +```javascript +const filePath = await app.dialog.open({ + title: '选择文件', + directory: false, // true 选目录 + multiple: false, // true 多选 + filters: [ + { name: 'Images', extensions: ['png', 'jpg', 'webp'] } + ] +}); +``` + +### `app.dialog.save` + +```javascript +const savePath = await app.dialog.save({ + title: '保存文件', + defaultPath: 'output.png', + filters: [ + { name: 'PNG', extensions: ['png'] } + ] +}); +``` + +## 权限声明格式 + +```json +{ + "permissions": { + "fs": { + "read": ["{workspace}", "{appdata}", "{user-selected}"], + "write": ["{appdata}", "{user-selected}"] + }, + "shell": { + "allow": ["git", "ffmpeg"] + }, + "net": { + "allow": ["api.example.com", "cdn.jsdelivr.net"] + }, + "ai": { + "enabled": true, + "allowed_models": ["primary", "fast"], + "max_tokens_per_request": 8192, + "rate_limit_per_minute": 30 + }, + "node": { + "enabled": true, + "timeout_ms": 30000 + } + } +} +``` + +### 无 Node 模式:`node.enabled = false` + +如果你的小应用只用 `app.fs.* / app.shell.* / app.net.fetch / app.os.info / app.storage.*`(即不需要在 `worker.js` 里自定义任何方法、也不需要安装 npm 依赖),把 `node.enabled` 设为 `false`: + +```json +{ + "permissions": { + "fs": { "read": ["{workspace}", "{appdata}"], "write": ["{appdata}"] }, + "shell": { "allow": ["git"] }, + "node": { "enabled": false } + } +} +``` + +宿主会把这些框架原语直接路由到 Rust `host_dispatch` 实现,完全不需要 Bun/Node 运行时;权限策略与 Worker 路径共用同一份 `resolve_policy`,行为完全等价。在这种模式下: + +- `app.shell.exec` / `app.fs.*` / `app.net.fetch` / `app.os.info` / `app.storage.get|set` —— 全部可用; +- `app.call('myCustomMethod', …)` —— **不可用**(宿主会显式报错),需要走完整的 Worker 路径请把 `node.enabled` 设回 `true` 并提供 `worker.js`。 + +推荐:所有"只是包一下 git/curl/系统命令"的开发者工具型小应用都使用此模式,避免 bundle 后宿主缺少 Bun/Node 时的运行时报错。 + +路径变量: +- `{appdata}` — `{user_data_dir}/miniapps/{app_id}/data/`,始终可读写 +- `{workspace}` — 当前打开的工作区路径 +- `{user-selected}` — 用户通过 app.dialog.open/save 选择的路径 +- `{home}` — 用户主目录(高风险) + +## CDN 依赖 + +通过 `source.dependencies` 声明,编译器自动注入 `