MiniApp 是运行在 Tokomi 故事容器内的 Web 应用:一个根目录含 index.html 的静态包,通过容器注入的 window.Shine 访问 AI、存储、角色与剧情能力。本文档面向前端开发者,假定你熟悉 HTML / JavaScript 与常见构建工具。
用一个最小 MiniApp 跑通「调用 AI → 持久化结果 → 上架」的完整链路。
SDK 由容器在页面加载时注入,window.Shine 直接可用;不要自行引入 shine-app.js。单文件 HTML 没有转译环节,避免 ?. / ?? 等 Chromium 80+ 语法。
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Hello Tokomi</title>
<style>
body {
margin: 0; font-family: system-ui; background: #111; color: #eee;
/* 全屏运行:顶部安全区取 iOS env() 与容器变量的较大值 */
padding: max(env(safe-area-inset-top, 0px), var(--shine-safe-area-top, 0px)) 16px 16px;
}
button { min-height: 44px; padding: 0 16px; }
</style>
</head>
<body>
<button id="ask">问 AI</button>
<p id="out"></p>
<script>
var btn = document.getElementById('ask');
var out = document.getElementById('out');
btn.addEventListener('click', async function () {
btn.disabled = true; // 防连点重复扣费
try {
var reply = await Shine.ai.chat({ // 付费能力,按标准价扣星辰
system: '你是一个简洁的助理。',
message: '用一句话介绍 Tokomi。'
});
out.textContent = reply;
await Shine.storage.set('last_reply', reply); // 云端 KV,按用户隔离
} catch (e) {
await Shine.ui.toast(e.message || '调用失败'); // 余额不足等由平台兜底提示
} finally {
btn.disabled = false;
}
});
</script>
</body>
</html>新建应用并粘贴代码,右侧模拟器实时预览,可切换「商店 / 挂故事」两种启动模式。预览中的付费调用真实扣星辰。
填写名称、分类、图标与截图;在「星辰消费申报」中勾选用到的付费能力与触发频率。平台据此在用户进入前统一弹窗告知,审核通过后自动上架。
一份 Agent Skill(英文),装进 Claude Code / Cursor / Codex 等编码助手后,它写 Tokomi MiniApp 时会自动遵守平台硬性规则:SDK 由容器注入、启动模式分支、星辰计费与一次一图、旧 WebView 兼容基线、审核红线;并附完整 SDK 参考供其按需查阅。
references/ 下的文件由本页文档数据生成,与页面内容始终一致。
# Claude Code unzip shine-miniapp-skill.zip -d .claude/skills/ # Cursor unzip shine-miniapp-skill.zip -d .cursor/skills/ # Codex / other agents: unzip anywhere and reference SKILL.md from AGENTS.md
单文件 HTML 可直接在工作台编辑并实时预览;多文件工程建议本地 Vite 开发,发版时 npm run build 产出 dist/,打成根目录含 index.html 的 ZIP 上传工作台。
// vite.config.ts
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig(() => {
const apiTarget = process.env.VITE_API_BASE || 'http://localhost:8000';
return {
// 产物用相对路径,兼容 MiniApp 容器 file:// / 子目录加载(否则 /assets 绝对路径 404 白屏)
base: './',
plugins: [react()],
build: {
// 旧安卓 WebView(vivo 等出厂 Chromium <80)不认 ?. / ??,默认 esnext 产物整包 SyntaxError 黑屏。
// 用真实浏览器 target 让 esbuild 把新语法转译掉;cssTarget 会自动跟随它,
// 免得 CSS 压缩器把 top/right/bottom/left 四边合并回旧机不认的 inset 简写。
target: ['chrome61'],
},
server: {
port: 3000,
host: '0.0.0.0',
proxy: {
// Shine SDK 与 MiniApp API 都走同源,避免 CORS
'/api': { target: apiTarget, changeOrigin: true },
'/static': { target: apiTarget, changeOrigin: true },
},
},
};
});// src/lib/shine-dev.ts — 推荐抽一层,业务代码只调这里
const DEV_APP_ID = '00000000-0000-0000-0000-000000000000';
function installDevLaunchOptions() {
const sessionId = import.meta.env.VITE_SHINE_SESSION_ID;
const characterId = import.meta.env.VITE_SHINE_CHARACTER_ID;
if (!window.__shine_launch_options && sessionId) {
window.__shine_launch_options = { sessionId, characterId };
}
}
async function ensureSdk() {
if (window.Shine) return;
await new Promise<void>((resolve, reject) => {
const s = document.createElement('script');
s.src = '/static/sdk/v1/shine-app.js';
s.onload = () => resolve();
s.onerror = () => reject(new Error('Shine SDK 加载失败'));
document.head.appendChild(s);
});
}
let inited = false;
export async function initShine() {
installDevLaunchOptions();
await ensureSdk();
if (inited || !window.Shine?.init) return;
window.Shine.init({
appId: import.meta.env.VITE_SHINE_APP_ID || DEV_APP_ID,
token: import.meta.env.VITE_SHINE_TOKEN || '',
baseUrl: '', // 同源,走 Vite proxy
});
inited = true;
}// src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import { initShine } from './lib/shine-dev';
void initShine();
createRoot(document.getElementById('root')!).render(
<StrictMode><App /></StrictMode>,
);登录后此处可一键生成带 token 与调试会话的 .env.local。变量:VITE_API_BASE、VITE_SHINE_APP_ID、VITE_SHINE_TOKEN、VITE_SHINE_SESSION_ID、VITE_SHINE_CHARACTER_ID。
| 层级 | 环境配置 | SDK / AI | 故事能力 | 原生 |
|---|---|---|---|---|
| ① 纯 UI | npm run dev,不配 token | 示例封装层降级 / mock(见下方降级策略) | 无 session → 商店模式 | 不可用 |
| ② SDK 联调 | 配 VITE_SHINE_TOKEN + 后端启动 | ai / storage / user 走真实 HTTP | 仍无通讯录 / actor | 不可用 |
| ③ 故事联调 | 再加 VITE_SHINE_SESSION_ID / CHARACTER_ID | 同上 | contact / actor / wallet 可用 | 不可用 |
| ④ 发版验收 | npm run build → ZIP → 工作台上传 | 与线上一致 | 工作台可选挂故事预览 | 仅真机容器 |
能力可用性由启动方式决定。用 Shine.context.getLaunchOptions() 同步判断:返回 null 为商店模式,返回 { sessionId, characterId? } 为挂故事启动。
| 启动方式 | getLaunchOptions() | 能力范围 |
|---|---|---|
| 应用商店直接打开 | getLaunchOptions() → null | ai.* / storage / user / ui(部分)/ tts(voiceId 模式,可做旁白)可用;character / contact / actor / wallet 不可用或返回空(getLaunchOptions() 本身任何模式都可调,此时返回 null) |
| 挂故事会话启动 | { sessionId, characterId? } | 全部能力可用(含 actor.speak / actor.notify、scene.inject、wallet、通讯录) |
| 工作台预览 | 可选「商店 / 挂故事」模拟 | 与线上一致;计费能力真实扣星辰 |
上架表单「启动模式」选「故事启动」时,平台会在进入前让用户选一条故事,sessionId 必有;选「兼容」则必须处理 null。
const launch = Shine.context.getLaunchOptions();
if (!launch) {
// 商店模式:隐藏依赖会话的入口(actor / contact / wallet / scene)
}付费能力按平台标准价从用户星辰余额扣除。消耗告知与余额不足提示由平台统一负责——App 内不需要、也不应重复实现。
| 接口 | 计费 |
|---|---|
Shine.ai.chat | 按标准价计费;容器不逐次提示 |
Shine.ai.image | 按标准价计费;平台逐张弹授权框,用户拒绝时 reject(user_denied)且不扣费 |
Shine.actor.speak | 按所选模型标准价计费 |
Shine.tts.speak / synthesize | 未命中缓存按字数计费(每 50 字一档);命中缓存免费 |
Shine.actor.notify | 单向投递免费;triggerReply: true 按 NPC 回复计费 |
Shine.wechat.sendCard | triggerReply 默认 true,按 NPC 回复计费 |
| 其余能力 | storage / ui / device / context / character / contact / scene / credits / wallet 均免费 |
MiniApp 运行在各厂商 Android WebView 中,部分系统 WebView 停留在 Chromium 66–79。桌面浏览器通过不代表真机通过。下表为高频且后果严重的约束;React / Vite 工程可靠 build.target 转译语法,但 API 缺失与 CSS 特性仍需人工规避。
| 主题 | 避免 | 改用 |
|---|---|---|
| 构建目标(React/Vite 工程) | vite.config 不设 build.target(默认 esnext,把 ?. / ?? 原样出包,<80 机型整包 SyntaxError) | build: { target: ['chrome61'] }(真实浏览器 target,语法自动转译、cssTarget 跟随),并设 base:'./';单文件 HTML 无转译,直接别写 ?. / ?? |
| JS 新 API/语法 | 未转译就用 .at()、replaceAll、structuredClone、Object.hasOwn、??=/||=/&&=、findLast、Promise.any、Promise.allSettled、matchAll、Object.fromEntries、模块顶层 await;单文件 HTML 还含 ?. / ??(需 Chrome 80,vivo 66~79 会挂) | arr[arr.length-1]、split().join()、JSON.parse(JSON.stringify())、hasOwnProperty.call;??=/||=/&&= 展开成 if 判断后赋值(单文件 HTML 里连 ?? / ?. 本身也不能用);allSettled/matchAll/fromEntries 属 API 缺失,转译补不了,只能改写或 polyfill |
| 颜色与透明度 | color-mix()/oklch()/lab()/@layer;Tailwind v4 浏览器 CDN(@tailwindcss/browser@4) | 颜色用 rgb()/rgba()/#hex;Tailwind 用 Play CDN cdn.tailwindcss.com(v3) |
| Tailwind 透明度 | 非 5 倍数的透明度修饰符(bg-white/8、text-white/12) | 只用 5 的倍数(/10、/15、/20…),或 bg-[rgba(...)] |
| 宽高比 | aspect-ratio / Tailwind aspect-[3/4] | padding-bottom 撑比例:容器 position:relative;width:100%;padding-bottom:133%(3:4 即高/宽=4/3),子元素 absolute 四边 0 |
| flex 间距 | flex 容器用 gap-* | margin / Tailwind space-x-*、space-y-*(grid 可继续用 gap) |
| 绝对定位 inset | inset 简写 / Tailwind inset-0 | 分写 top/right/bottom/left 四个值 |
| 图片圆角 | 只靠父容器 overflow:hidden + border-radius 裁 <img> | 给 <img> 自身加 border-radius(Tailwind rounded-full) |
| 3D 翻转 | transform-style:preserve-3d + backface-visibility 双面翻牌 | 单层 rotateY 翻入 + opacity 淡入淡出 |
| 毛玻璃 | backdrop-filter / Tailwind backdrop-blur | 深色半透明底 + 渐变 + 边框模拟;遮罩用 rgba(0,0,0,.7) |
| 视口单位 | dvh / svh / lvh | 100% + flex 撑开,或 vh |
| 其他新 CSS | :has()、:is()、:where()、accent-color、content-visibility、text-wrap:balance、subgrid | 改用受支持的等价写法或去掉 |
| 图片格式 | .avif | WebP / PNG / JPG |
| 资源路径 | 站点绝对路径 /assets/x.png | 相对路径 ./assets/x.png;Vite 设 base:'./' |
| 内层滚动 | 内层滚动容器不加触摸滚动属性 | 滚动容器加 -webkit-overflow-scrolling:touch; touch-action:pan-y; |
| 弱机性能 | 大量 repeat:Infinity 无限动画、过多背景粒子、backdrop-filter | 常驻背景粒子 ≤10 且只用 opacity;能静态就静态 |
| Canvas / WebGL(含 3D 才需看) | canvas 不处理 webglcontextlost——旧安卓内存吃紧回收 GPU 后上下文永久失效 | 监听 webglcontextlost(e.preventDefault() + 暂停渲染循环)与 webglcontextrestored(重建纹理/缓冲 + 恢复渲染);three.js 用 WebGLRenderer 对应事件;加载失败给静态兜底 |
MiniApp 可以把结果写回当前故事:落库线下小剧场、让 NPC 知情、或直接让角色发言。以下接口均依赖挂故事启动(launch 含 sessionId)。
| 目标 | 接口 | 计费 |
|---|---|---|
| 在线下时间线显示旁白,并触发「新剧情」提示 | scene.inject({ visible }) | 免费 |
| 让 NPC 知情但不显示气泡(导演备注 / 伏笔 / 小游戏结果) | scene.inject({ hidden }) | 免费 |
| 以自定义发件人向某 NPC 或玩家本人投递一条消息 | actor.notify() | 单向免费;triggerReply 按 NPC 回复计费 |
| 让角色立刻按人设回一段话 | actor.speak() | 按模型标准价 |
| 把卡片发进与某联系人的微聊私聊 | wechat.sendCard() | triggerReply 默认 true,消耗星辰 |
notify 不返回台词,只让收件人「知情」并自行行动;需要即时回复用 speak。nav.openScene() 仅跳转不带内容,携带内容触发新剧情请用 scene.inject()。
以下为审核必查项,不满足将被驳回。
上架表单勾选用到的付费能力与触发频率。平台据此在进入前统一弹窗;申报不实直接驳回。
流程内的自动调用(如每回合结算)允许并需申报;定时器 / 轮询 / 挂机循环让用户不操作也持续扣费,一律驳回。
调用进行中禁用按钮或加 loading;批量或高价调用前用 Shine.ui.confirm 二次确认。
Shine.ai.image 由平台逐张弹授权框,禁止循环 / 批量 / 自动连续出图;不要再弹自己的确认框。
不要自建开屏消耗告知或充值引导;余额不足由平台提示,App 只需 catch 失败并恢复界面。
AI 生成的文字 / 图片不得伪装成真人、权威机构或事实。付费入口建议标注「消耗星辰」,不得把付费操作伪装成免费功能。
涉黄 / 诱导未成年人 / 露骨暴力直接驳回,严重者封禁账号;写进 ai.chat / actor.speak 的 system prompt 同样受平台风控约束。
可直接运行的参考实现。单文件示例可载入工作台编辑;工程示例在工作台 IDE 内实时编译预览,也可下载 ZIP 本地开发。
一个 index.html 串起全部能力:AI 对话 / 生图、本地存储、UI 组件、导航与状态栏、故事上下文(context / character / contact)、角色发言、原生媒体、钱包付款 / 退款——逐个板块可点。带 ⭐ 的调用会消耗星辰。零依赖、复制即用,最适合快速试能力或当起步骨架。
与简易版同样的能力清单,但拆成 React + Vite 多文件工程:每个能力一个 section 组件,配统一的 Card / Output / useAction 设计,板块清晰、样式精致。点「查看示例」即在工作台 IDE 实时编译预览,删掉用不到的 section 文件即可当项目模板。
用 React + Vite 搭的完整狼人杀工程,接入 Shine SDK 实现 AI 玩家发言、用户信息与云存档。点「查看示例」即可在工作台 IDE 里看真实多文件源码、浏览器内实时编译预览;也可下载 ZIP 本地 npm 开发,build 出 dist 后发版。
纯前端单文件五子棋:落子、胜负判定、悔棋与重开,零依赖、复制即玩。适合学习把一个完整小游戏塞进一个 index.html,再接 Shine SDK 加排行榜 / 分享等玩法。
官方横屏小游戏示例:纯 Canvas 弹球,拖左侧球拍对战 AI,先得 7 分获胜。重点演示横屏三件套——ui.setOrientation('landscape') 锁横屏、ui.setNavigationBar + ui.setFullscreen 全屏沉浸、以及用 max(env(), var(--shine-safe-area-*)) 适配横屏左右刘海安全区。零依赖、复制即玩。
React + Vite 的「一起看小说」阅读器:书架、沉浸阅读、夜间模式,再用 Shine SDK 接入 AI 书伴——边读边和角色聊剧情、给出陪读点评。点「查看示例」在工作台 IDE 实时编译预览带 motion 动效的完整多文件工程。
每个方法标注参数、返回值、计费与运行环境约束。实现以 static/sdk/v1/shine-app.src.js 为准;调用较新的方法前先 Shine.canIUse() 探测。
原生容器(Flutter WebView)会在加载页面时自动注入 SDK 并写入启动参数,通常无需手动 init。浏览器 / 工作台预览需手动引入并 init。
Shine.init({ appId, token?, baseUrl? })| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
appId | string (UUID) | 是 | MiniApp 的 appId;工作台预览可用 00000000-0000-0000-0000-000000000000 |
token | string | 否 | 创作者登录后的 Bearer token;浏览器直连 API 时必填 |
baseUrl | string | 否 | API 根地址,如 https://api.shineapp.cn;末尾不要带 / |
void<!-- 仅本地调试 / 工作台预览需要;上架后平台自动注入,可删掉 -->
<script src="https://api.shineapp.cn/static/sdk/v1/shine-app.js"></script>
<script>
Shine.init({
appId: '<你的 appId>',
token: '<登录 token>',
baseUrl: 'https://api.shineapp.cn',
});
</script>与命名空间平级的探测能力。SDK 是渲染时实时内联的(永远最新),但用户手上的 App 可能是旧包——新方法在旧容器里会跨桥失败,用 canIUse 做降级。
Shine.canIUse(schema)sync探测当前容器是否支持某个 SDK 能力。
| 参数 | 类型 | 说明 |
|---|---|---|
schema | string | 能力名,与文档方法口径一致,如 'device.vibrate' / 'tts.listVoices' / 'actor.notify'* |
boolean — 同步返回
if (Shine.canIUse('device.vibrate')) {
await Shine.device.vibrate({ style: 'light' });
}
if (!Shine.canIUse('tts.listVoices')) {
// 旧版 App:隐藏旁白音色选择,走静默文案
}通用 AI 能力。system / message 由创作者完全控制,平台只负责模型调用与计费。
Shine.ai.chat(options)async发起一次 AI 对话,返回模型回复文本。
| 参数 | 类型 | 说明 |
|---|---|---|
system | string | 创作者自定义 system prompt,最长 5000 字* |
message | string | 本轮用户输入,最长 2000 字* |
context | "story" | "actor" | "director" | null | 可选上下文模式提示(当前版本主要由创作者自行拼进 system) · 默认 null |
stream | boolean | 是否流式返回 · 默认 false |
onChunk | (chunk) => void | 流式回调,每次收到一段增量文本;提供时自动开启 stream。需要全文请自行累加 chunk(原生容器只传增量,不传全文参数) |
Promise<string> — AI 回复正文
const reply = await Shine.ai.chat({
system: '你是塔罗占卜师,语气神秘但友善。',
message: '帮我解读这三张牌:愚者、恋人、命运之轮',
});Shine.ai.image(promptOrOptions)async根据文本描述生成图片。
| 参数 | 类型 | 说明 |
|---|---|---|
prompt | string | 图片描述,最长 1000 字。也可传 { prompt: string }* |
Promise<string> — 图片 OSS 公网 URL
const url = await Shine.ai.image('水彩风格,一只橘猫坐在窗台上');
// <img src={url} />云端 KV 存储,按用户隔离(A 用户的数据 B 用户看不到),写入后不会自动过期、除非主动删除。key 最长 255 字符;value 支持 string / number / boolean / object / array。两种作用域:Shine.storage.*(用户级,跨该用户所有会话共享)与 Shine.storage.session.*(会话级,每条剧情各一份存档,详见下方 session.* 条目),两套方法名完全一致。配额按「用户 + App」跨作用域合计。
Shine.storage.get(key)async读取指定 key 的值。
| 参数 | 类型 | 说明 |
|---|---|---|
key | string | 存储键名* |
Promise<any | null> — 未设置过返回 null
const save = await Shine.storage.get('game_save');
if (save) board = JSON.parse(save);Shine.storage.set(key, value)async写入或覆盖一个 key。
| 参数 | 类型 | 说明 |
|---|---|---|
key | string | 存储键名* |
value | any | 任意 JSON 可序列化值* |
Promise<void>
await Shine.storage.set('coins', 128);
await Shine.storage.set('inventory', { sword: 1, potion: 3 });Shine.storage.remove(key)async删除指定 key。
| 参数 | 类型 | 说明 |
|---|---|---|
key | string | 要删除的键名* |
Promise<void>
Shine.storage.list(opts?)async枚举当前 user+app 下的 key(不含 value),cursor 分页。
| 参数 | 类型 | 说明 |
|---|---|---|
prefix | string | 只列指定前缀的 key |
cursor | string | 上一页最后一个 key |
limit | number | 每页数量,最大 200 · 默认 100 |
Promise<{ keys: string[]; nextCursor: string | null; hasMore: boolean }>const page = await Shine.storage.list({ prefix: 'save_' });
for (const k of page.keys) console.log(k);Shine.storage.mget(keys)async批量读取多个 key(最多 50 个)。
| 参数 | 类型 | 说明 |
|---|---|---|
keys | string[] | 要读取的 key 列表* |
Promise<Record<string, any | null>>
const vals = await Shine.storage.mget(['coins', 'inventory']); console.log(vals.coins, vals.inventory);
Shine.storage.getInfo()async查询 key 数量、已用空间与配额上限。
Promise<{ keyCount: number; currentSizeBytes: number; limitKeys: number; limitSizeBytes: number }>Shine.storage.clear(opts?)async批量删除 key;可选 prefix。平台不弹确认,请自行 ui.confirm。
| 参数 | 类型 | 说明 |
|---|---|---|
prefix | string | 只删指定前缀 |
Promise<{ status: 'ok'; deletedCount: number }>Shine.storage.session.get / set / remove / list / mget / clear / getInfoasync会话级存储命名空间:方法与 Shine.storage.* 完全一致,但按「当前会话」隔离——同一用户在不同剧情/会话打开同一 App,各有一份独立存档,互不串档。
与对应的 Shine.storage.* 方法一致
// 用户级:跨剧情共享(设置、收藏)
await Shine.storage.set('settings', { sound: true });
// 会话级:每条剧情各一份存档(进度 / 养成 / 小游戏)
await Shine.storage.session.set('progress', { chapter: 3, hp: 80 });
const p = await Shine.storage.session.get('progress');当前用户的公开信息。「当前用户」随启动方式而变(见 getInfo)。
Shine.user.getInfo()async获取当前用户信息。带故事启动(launch 有 sessionId)时返回玩家的剧内化身(角色面具 / user_role),与 actor.speak 注入的 user_identity 同源;商店模式返回真实账号。返回结构不变,无需区分场景。
Promise<{ id: string; nickname: string; avatar: string | null }>// 带故事时 nickname 即玩家在剧情里的名字(面具),与角色看到的一致
const me = await Shine.user.getInfo();
document.getElementById('name').textContent = me.nickname;原生容器内的系统级 UI;浏览器环境降级为 confirm / prompt / 简易 toast。
Shine.ui.toast(message)async顶部短暂提示。
| 参数 | 类型 | 说明 |
|---|---|---|
message | string | 提示文案* |
Promise<void>
Shine.ui.confirm(message)async确认对话框。
| 参数 | 类型 | 说明 |
|---|---|---|
message | string | 确认文案* |
Promise<boolean> — 用户点确定 true,取消 false
Shine.ui.prompt(message, defaultValue?)async单行输入框。
| 参数 | 类型 | 说明 |
|---|---|---|
message | string | 输入提示* |
defaultValue | string | 默认值 · 默认 "" |
Promise<string | null> — 取消返回 null
Shine.ui.showActionSheet(options)async底部选项菜单。
| 参数 | 类型 | 说明 |
|---|---|---|
options | string[] | 选项文案数组* |
Promise<number> — 选中项下标;取消返回 -1
Shine.ui.setNavigationBar(opts)async控制容器顶部导航栏。
| 参数 | 类型 | 说明 |
|---|---|---|
visible | boolean | 是否显示导航栏;默认不显示(全屏),返回等操作由右上角原生胶囊承担 |
title | string | 标题文字 |
backgroundColor | string | 背景色,如 #111827 / transparent |
Promise<void>
await Shine.ui.setNavigationBar({ visible: false });
/* 全屏页根容器:iOS 走 env(),安卓走容器下发的变量,二者取大;横竖屏自动适配 */
body {
padding:
max(env(safe-area-inset-top,0px), var(--shine-safe-area-top,0px))
max(env(safe-area-inset-right,0px), var(--shine-safe-area-right,0px))
max(env(safe-area-inset-bottom,0px), var(--shine-safe-area-bottom,0px))
max(env(safe-area-inset-left,0px), var(--shine-safe-area-left,0px));
}Shine.ui.getCapsuleRect()sync取原生胶囊在页面里的位置,自绘顶栏时据此避让。
{ visible, top, right, bottom, left, width, height } — 单位 CSS px,同步返回/* 纯 CSS:自绘顶栏右侧给胶囊让位 */
.app-header {
padding-top: max(env(safe-area-inset-top,0px), var(--shine-safe-area-top,0px));
padding-right: calc(var(--shine-capsule-width,0px) + var(--shine-capsule-right,0px) + 8px);
}
/* JS:需要精确坐标时 */
const cap = Shine.ui.getCapsuleRect();
if (cap.visible) toolbar.style.maxWidth = cap.left - 12 + 'px';
window.addEventListener('shine:capsulechange', relayout);Shine.ui.setStatusBar({ style })async设置状态栏文字颜色。
| 参数 | 类型 | 说明 |
|---|---|---|
style | "light" | "dark" | light=白字(深色背景);dark=黑字* |
Promise<void>
Shine.ui.setOrientation(orientation)async锁定或恢复屏幕方向。
| 参数 | 类型 | 说明 |
|---|---|---|
orientation | "portrait" | "landscape" | "auto" | 屏幕方向* |
Promise<void>
Shine.ui.setFullscreen(enabled)async全屏沉浸:隐藏/恢复系统状态栏。
| 参数 | 类型 | 说明 |
|---|---|---|
enabled | boolean | true=隐藏状态栏进入全屏;false=恢复* |
Promise<void>
// 适合全屏游戏 / 看图 / 视频等无干扰场景
await Shine.ui.setNavigationBar({ visible: false });
await Shine.ui.setFullscreen(true);
// 退出该页面 / 需要恢复时
await Shine.ui.setFullscreen(false);容器与设备信息、剪贴板、触感反馈。这些能力 WebView 里做不稳或根本没有,必须由原生容器提供。
Shine.device.getSystemInfo()async取平台、主端版本、深浅色主题、屏幕尺寸与安全区。
Promise<{ platform: 'ios'|'android'|'other'; appVersion: string; theme: 'light'|'dark'; locale: string; screen: { width, height, pixelRatio }; safeArea: { top, right, bottom, left } }>const info = await Shine.device.getSystemInfo(); document.documentElement.dataset.theme = info.theme;
Shine.device.setClipboard({ text })async写入系统剪贴板。
| 参数 | 类型 | 说明 |
|---|---|---|
text | string | 要复制的文本;也可直接传字符串* |
Promise<{ ok: boolean }>await Shine.device.setClipboard({ text: inviteCode });
await Shine.ui.toast('已复制邀请码');Shine.device.getClipboard()async读取系统剪贴板文本。
Promise<{ text: string }>const { text } = await Shine.device.getClipboard();
if (text) input.value = text.trim();Shine.device.vibrate({ style? })async短震动触感反馈(游戏连击、按钮按下等)。
| 参数 | 类型 | 说明 |
|---|---|---|
style | 'light' | 'medium' | 'heavy' | 'selection' | 触感强度;selection 适合选择器轻点 · 默认 'light' |
Promise<{ ok: boolean }>if (Shine.canIUse('device.vibrate')) {
await Shine.device.vibrate({ style: combo > 1 ? 'medium' : 'light' });
}启动上下文。同步方法,不发起网络请求。
Shine.context.getLaunchOptions()sync读取容器启动 MiniApp 时透传的参数。
{ sessionId?: string; characterId?: string } | null
商店直接打开 → null;挂故事启动 → 含 sessionIdconst launch = Shine.context.getLaunchOptions();
if (!launch) {
// 商店模式:展示友好空状态
return;
}
console.log(launch.sessionId, launch.characterId);挂载故事的公开元信息。不返回 world_scenario / personality 等内部 prompt 字段。
Shine.character.getDetail(characterId?)async拉取所挂故事的公开详情。
| 参数 | 类型 | 说明 |
|---|---|---|
characterId | string (UUID) | 不传则用 launch.characterId |
Promise<{ id, name, brief, avatar_url, cover_url }>const story = await Shine.character.getDetail(); titleEl.textContent = story.name;
当前会话通讯录:故事 actor + 用户自建 NPC。
Shine.contact.list()async拉取通讯录列表。
Promise<Array<{ id: string; name: string; avatar_url: string | null; is_custom_npc: boolean }>>const contacts = await Shine.contact.list(); const partner = contacts[0];
让故事里的角色 / NPC 按人设发言。后端 miniapp_prompt_manager 自动拼装世界观、人设、记忆与历史。
Shine.actor.speak(opts)async让指定 actor 生成一段发言。
| 参数 | 类型 | 说明 |
|---|---|---|
actorId | string | contact.list() 返回的 id(UUID 或 NPC:xxx)* |
message | string | 本轮指令 / 用户输入,最长 2000 字* |
extraSystem | string | 额外 system,如游戏规则、当前局势,最长 5000 字 |
includeWorldScenario | boolean | 注入故事卡片 / 章节 / 情节设定 · 默认 true |
includeActorPersona | boolean | 注入 actor 身份与人设;关掉则退化为匿名 NPC · 默认 true |
includeActorMemory | boolean | 注入该 actor 的关系 / 经历记忆;仅 persona=true 时生效 · 默认 true |
includeOtherActors | boolean | 注入其他 actor 卡司列表;狼人杀等信息隔离场景关掉 · 默认 true |
includeChatHistory | boolean | 注入小手机交错聊天记录;游戏类一般保持 false · 默认 false |
customHistory | Array<{role:'user'|'assistant', content:string}> | App 自维护对话历史,最多 50 条,每条 ≤ 4000 字 · 默认 [] |
Promise<string> — actor 发言正文
const reply = await Shine.actor.speak({
actorId: partner.id,
message: '你觉得这段剧情接下来会怎样?',
extraSystem: '你们正在共读第三章,保持角色口吻。',
includeWorldScenario: true,
includeActorPersona: true,
customHistory: [
{ role: 'user', content: '上一回合我们讨论了什么' },
{ role: 'assistant', content: '你提到了主角的抉择…' },
],
});Shine.actor.notify(opts)async以某个发件人身份给某收件人投递一条(默认单向)消息——告密 / 搭讪 / 通知 / 警告 / 让角色主动私信你。
| 参数 | 类型 | 说明 |
|---|---|---|
actorId | string | 收件人 actor id(来自 contact.list);传 'user' 投给玩家本人(可见进其收件箱)* |
message | string | 投递的消息正文,最长 2000 字* |
fromActorId | string | 用已存在 actor 当发件人(如让某角色主动私信);传了则忽略下面三个 from* 字段 |
fromName | string | 发件人显示名(匿名场景传「未知号码」「匿名线人」) · 默认 本 App 名 |
fromAvatar | string | 发件人头像 URL |
fromPersona | string | 发件人人设;不传则套「来自本 App、因这条消息联系上目标的联系人」中性模板 |
triggerReply | boolean | 是否让收件人(NPC)立刻就此消息反应(需开启 NPC 互聊深度;投给 user 时无效) · 默认 false |
Promise<{ status: 'ok' | 'blocked'; delivered: boolean }>const contacts = await Shine.contact.list();
const cop = contacts.find(c => c.name === '老张警官');
// ① 暗网匿名告密给某 NPC:自定义发件人身份,用户看不到这条私聊
await Shine.actor.notify({
actorId: cop.id,
message: '城东仓库今晚有交易,速去。',
fromName: '未知号码',
fromPersona: '神秘线人,身份不明,说话简短,绝不透露自己是谁。',
});
// ② 投递给玩家本人:消息可见地进你自己的微聊收件箱
await Shine.actor.notify({
actorId: 'user',
message: '【系统】你收到一封匿名威胁信……',
fromName: '匿名信',
});
// ③ 让某个已存在角色主动私信你(actor 找 user)
await Shine.actor.notify({
actorId: 'user',
fromActorId: cop.id,
message: '是我,老张。有件事必须现在跟你说。',
});把文字读出来。音色两条路:voiceId 用平台音色目录里的音色(旁白、系统播报、商店直接打开的应用都走这条,不需要故事和角色);actorId 用故事里那个角色自己的音色(voice_config,未配置则按性别回退默认音色)。原生容器交给原生播放器(just_audio)边下边播流式 mp3,首声更快、更稳;播放期间会自动压低背景音乐、念完抬回。复用平台 MiniMax TTS,相同文字+音色命中缓存不重复扣费。
Shine.tts.listVoices({ gender?, language? })async取平台音色目录,拿到 id 即可直接朗读——不需要故事,也不需要角色。
| 参数 | 类型 | 说明 |
|---|---|---|
gender | "male" | "female" | 只要某个性别的音色 |
language | string | 语言码 zh/yue/en/ja/ko;'all'=全语言;不传=中文语系 |
Promise<Array<{ id: string; name: string; gender: string; tags: string[]; languages: string[]; previewUrl: string | null }>>const voices = await Shine.tts.listVoices({ gender: 'female' });
narratorVoiceId = voices[0].id; // 让用户在设置里挑一个存下来
await Shine.tts.speak({ voiceId: narratorVoiceId, text: '夜色渐深,你推开了那扇门。' });Shine.tts.speak({ voiceId | actorId, text, speed?, pitch?, languageBoost? })async合成并播放(同一时刻只播一条,新的会打断旧的);Promise 在播放结束时 resolve。
| 参数 | 类型 | 说明 |
|---|---|---|
voiceId | string | 平台音色 id(见 listVoices);与 actorId 二选一,给了就不看 actorId |
actorId | string | contact.list() 返回的 id,与 actor.speak 同一个;用角色自己的音色 |
text | string | 要朗读的文本,最长 10000 字;标签 / 旁白会在合成前清洗* |
speed | number | 语速 0.5~2.0,不传沿用音色自身设置 |
pitch | number | 音高 -12~12,不传沿用音色自身设置 |
languageBoost | string | 语言增强 · 默认 "Chinese" |
Promise<{ audioUrl: string; durationMs: number; usageCharacters: number; cached: boolean; cost: number }>// 旁白:不需要故事,也不需要角色
await Shine.tts.speak({ voiceId: narratorVoiceId, text: '第三天清晨,雨停了。', speed: 0.9 });
// 角色台词:用他本人的音色
const reply = await Shine.actor.speak({ actorId: him.id, message: '该你走了' });
await Shine.tts.speak({ actorId: him.id, text: reply });Shine.tts.synthesize({ voiceId | actorId, text, speed?, pitch?, languageBoost? })async只解析出可播放 URL、不播放,便于自定义播放或预取。
| 参数 | 类型 | 说明 |
|---|---|---|
voiceId | string | 平台音色 id;与 actorId 二选一 |
actorId | string | contact.id,用角色自己的音色 |
text | string | 要朗读的文本,最长 10000 字* |
speed | number | 语速 0.5~2.0 |
pitch | number | 音高 -12~12 |
languageBoost | string | 语言增强 · 默认 "Chinese" |
Promise<{ audioUrl: string; durationMs: number; usageCharacters: number; cached: boolean; cost: number }>const { audioUrl } = await Shine.tts.synthesize({ voiceId: narratorVoiceId, text: '稳住,看清局面。' });
new Audio(audioUrl).play();Shine.tts.stop()sync立即停止当前由 speak 播放的语音(若有)。
void
背景音乐(BGM)。原生容器交给原生播放器:无需用户手势即可起播,且 Shine.tts.speak 播放时会自动压低本 BGM、念完抬回(ducking 自动)。浏览器预览用 <audio> 兜底(可能需首个手势才能起播)。
Shine.audio.playBgm({ url, loop? })async循环播放背景音乐(同一时刻只有一条,再次调用切换)。
| 参数 | 类型 | 说明 |
|---|---|---|
url | string | 音频地址(mp3 等);也可直接传字符串当 url* |
loop | boolean | 是否循环 · 默认 true |
Promise<boolean>
Shine.audio.playBgm({ url: 'https://.../bgm.mp3' });Shine.audio.stopBgm()async停止背景音乐。
Promise<boolean>
原生设备能力。仅 Flutter 容器内可用,浏览器会 reject。
Shine.media.pickImage(opts?)async从相册选图或拍照。
| 参数 | 类型 | 说明 |
|---|---|---|
source | "album" | "camera" | "both" | both 时弹底部菜单让用户选择 · 默认 "both" |
Promise<string | null> — OSS 公网 URL;用户取消返回 null
Shine.media.recordVoiceText(opts?)async按住说话录音,返回语音识别文本。
| 参数 | 类型 | 说明 |
|---|---|---|
maxDuration | number | 最大录音秒数,硬上限 180 · 默认 60 |
Promise<string | null> — 识别文字;取消或无内容返回 null
容器可见性事件(对齐小程序 onShow/onHide)。
Shine.lifecycle.onShow(callback)syncMiniApp 变为可见时触发。
| 参数 | 类型 | 说明 |
|---|---|---|
callback | () => void | 回调* |
void
Shine.lifecycle.onHide(callback)syncMiniApp 变为不可见时触发。
| 参数 | 类型 | 说明 |
|---|---|---|
callback | () => void | 回调* |
void
Shine.lifecycle.off(event, callback?)sync取消监听。
| 参数 | 类型 | 说明 |
|---|---|---|
event | "show" | "hide" | 事件名* |
callback | () => void | 不传则清空该事件全部监听 |
void
线下小剧场:把小应用产出的内容落库进当前线下会话、触发一段「新剧情」。落库 + 后端发通知,不跳转——用户会自动收到「新剧情」提示后自行进入、主动续写。需从挂故事的会话启动 MiniApp。
Shine.scene.inject(opts)async把内容落库进当前线下会话并触发「新剧情」通知。
| 参数 | 类型 | 说明 |
|---|---|---|
visible | string | 可展示内容:作为旁白/场景描写显示在线下时间线,并触发新剧情通知 |
hidden | string | 可隐藏内容:进 prompt 让 NPC 知情,但不显示气泡(导演备注/伏笔/小游戏结果) |
orderId | string | 幂等键;同一 orderId 只落库一次(防连点 / 重试) |
Promise<{ status: string; injectedVisible: boolean; injectedHidden: boolean; idempotent: boolean }>// 小游戏赢了钥匙 → 落库新剧情,用户会收到「新剧情」提示后自行进线下续写
await Shine.scene.inject({
visible: '你攥着刚赢来的铜钥匙,推开了那扇尘封的木门。',
hidden: '[导演]玩家在小游戏里赢得了地窖钥匙,NPC 暂不知情,可制造悬念。',
orderId: 'scene-' + Date.now(),
});平台星辰只读查询(不扣费)。规范:凡用到付费能力(ai.chat / ai.image / actor.speak / tts.speak)的 App,在上架表单如实填写「星辰消费申报」即可——用户进入前由平台统一弹告知窗(自动带实时单价),App 内不要再写开屏告知弹窗(见「AI 使用规范」,审核必查)。
Shine.credits.getBalance()async读取当前用户可消费星辰余额。
Promise<{ total: number; free: number; membership: number; permanent: number }>const bal = await Shine.credits.getBalance();
if (bal.total < 2) await Shine.ui.toast('星辰不足');Shine.credits.getCost({ feature })async查询单次能力消耗的星辰预估。
| 参数 | 类型 | 说明 |
|---|---|---|
feature | "ai.chat" | "ai.image" | "actor.speak" | 能力标识* |
Promise<{ feature: string; cost: number; currency: 'credits' }>微聊剧情虚拟钱包(单位「分」),与平台星辰无关。需挂故事启动。
Shine.wallet.getBalance()async读取当前用户在本会话的微聊钱包余额。
Promise<{ balanceCents: number; balanceYuan: number }>Shine.wallet.pay(opts)async扣款(先弹支付确认框)。
| 参数 | 类型 | 说明 |
|---|---|---|
orderId | string | 幂等键(≤36 字符),同一笔交易复用同一值,重试不重复扣。注意别拼 UUID 进去(会超长被后端拒绝)* |
amountCents | number | 金额(分),正整数* |
note | string | 用途说明,写入账单 |
Promise<{ status: 'ok' | 'cancelled'; charged?: boolean; balanceCents: number; balanceYuan: number }>// orderId 是本 App 内该用户下的幂等键,≤36 字符,短横线拼编号即可
const res = await Shine.wallet.pay({
orderId: 'shop-sword-001',
amountCents: 990,
note: '购买铁剑',
});
if (res.status === 'cancelled') return;Shine.wallet.refund(opts)async退回指定 orderId 已扣款项。
| 参数 | 类型 | 说明 |
|---|---|---|
orderId | string | 要退款的那笔 orderId(≤36 字符)* |
Promise<{ status: string; refundedCents: number; balanceCents: number; balanceYuan: number }>Shine.wallet.credit(opts)async给用户微聊钱包入账(虚拟钱包「充值 / 发奖励」,不弹确认框)。
| 参数 | 类型 | 说明 |
|---|---|---|
orderId | string | 幂等键(≤36 字符),同一笔入账复用同一值,重试不重复加* |
amountCents | number | 金额(分),正整数* |
note | string | 用途说明,写入账单 |
Promise<{ status: 'ok'; credited: boolean; balanceCents: number; balanceYuan: number }>微聊:把卡片发进当前会话私聊。复用平台现成 link 卡片渲染,需挂故事启动。
Shine.wechat.sendCard(opts)async以「用户」身份把一张卡片发到与某联系人的微聊私聊里。
| 参数 | 类型 | 说明 |
|---|---|---|
to | string | 收卡联系人 actor id(来自 contact.list)* |
title | string | 卡片标题* |
desc | string | 卡片正文 / 摘要 |
subtitle | string | 副标题 |
imageUrl | string | 缩略图 URL |
footer | string | 底栏来源标签,缺省用 App 名 |
triggerReply | boolean | 发完是否触发对方 NPC 回复(默认 true,消耗星辰) |
Promise<{ status: 'ok' | 'blocked'; sent: boolean }>const contacts = await Shine.contact.list();
await Shine.wechat.sendCard({
to: contacts[0].id,
title: '今日运势:大吉',
desc: '宜出行、宜表白,忌熬夜。',
footer: '运势小程序',
});所有 async 方法失败时 reject(Error),原生容器与 HTTP 直连均把后端 detail 转成可读 message。
计费类接口(ai.chat / ai.image / actor.speak / tts.speak)余额不足或用户取消授权时抛错;故事能力(actor / contact / wallet / scene)在无 sessionId 时直接 reject;media.* 在浏览器环境 reject。
try {
const reply = await Shine.actor.speak({ actorId, message: '你好' });
} catch (e) {
await Shine.ui.toast(e.message || '调用失败');
}