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-Hant">
<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: {
// 舊 Android 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——舊 Android 記憶體吃緊回收 GPU 後 context 永久失效 | 監聽 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(),Android 走容器下發的變數,二者取大;橫直向自動適配 */
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 || '呼叫失敗');
}