Skip to content

API 参考 ​

卡的界面代码能用到的所有东西:沙盒全局变量、组件、useYumina() 上的每个字段和方法、类型定义,以及被屏蔽的浏览器 API 的替代办法。

界面代码在 面板 → 前端代码 里。想先有个整体了解,请先读 自定义 UI 指南;这一页是用来查签名的。


沙盒全局变量 ​

下面这些名字 在界面代码的任何地方都能直接用,不需要 import:

名字类别是什么
React模块完整的 React(useState、useEffect、useRef、useMemo、useCallback、useLayoutEffect、Fragment……)
useYuminahook平台 SDK,见 useYumina() SDK
useAssetFonthook从资源库加载自定义字体,见 useAssetFont()
Icons对象所有 Lucide 图标,都是组件:<Icons.Heart />、<Icons.Sword />。完整目录:https://lucide.dev/icons
Chat组件完整的聊天积木,见 <Chat>
MessageList组件只有消息、没有输入框,见 <MessageList>
MessageInput组件只有输入栏,见 <MessageInput>
ChatCanvas组件<Chat /> 的旧别名,见 <ChatCanvas>
ModelTrigger、ModelPickerModal组件聊天里的模型按钮和模型选择器,见 模型选择器组件
SessionMemoryModal组件聊天里的会话记忆对话框,见 模型选择器组件
LoreSlot、LoreButton、LorePanel、LoreGroup、LoreSwitch组件只在开着的时候才把一条设定发给 AI 的控件,见 设定控件
exports、module对象CJS 风格的导出兜底,一般不用管

不要 import React 或上面任何一个名字,它们是沙盒注入的。编译时 import 语句会被去掉,所以 import { useState } from "react" 之后 useState 是 undefined。请用 React.useState。

你自己的文件可以 import:多文件的界面代码用 ES module 语法,import StatBar from "./stat-bar"。扩展名 .tsx、.ts、.jsx、.js 可以省略。


useYumina() SDK ​

在组件函数里调用:

tsx
function MyWorld() {
  const api = useYumina()
  // api.variables, api.sendMessage(...), ...
}

完整的接口,按用途分组:

状态读取(同步) ​

读最新的游戏状态。下面任何一项变了,组件都会重新渲染。

字段类型含义
variablesRecord<string, unknown>这一局的游戏变量,以变量的 ID 为键。变量的 显示名 也能当读取的键(即使 ID 是 UUID,api.variables.hunger 也能找到名叫 hunger 的变量)。例:{ health: 80, gold: 150 }。见 ID 和显示名
globalVariablesRecord<string, unknown>游玩时和 variables 的值一样。同样可以用 ID 或名字查
worldNamestring当前这张卡的名字
worldCoverstring | null卡的封面图,绝对 URL,没设时为 null。会自动跟着封面的修改更新,做角色头像、页头、启动画面时直接用它,不用再把封面当素材重新上传一遍
background{ id, url, blur, dim, opacity, position } | null宿主画在默认聊天后面的那张图,已经解析好(url 是最终地址,blur 单位 px,dim 和 opacity 为 0–1,position 为 "center" | "top" | "bottom")。卡没有背景图时为 null。全屏界面可以画同样的背景,也可以不管它
worldIdstring当前这张卡的 UUID
sessionIdstring当前这一局的 UUID
currentUser{ id, name?, image? } | null原始账号:id、显示名、账号头像。未登录时为 null。用于 账号层面 的界面,比如「查看主页」。世界里的 角色扮演 显示请用 user
user{ name: string; avatar: string | null }玩家扮演的身份,和 宏一样在人设和账号之间取值。玩家用着某个人设时,user.name 是人设名,user.avatar 是人设头像;否则退回账号的。世界内的聊天气泡、角色卡和资料面板用这个
mode"session" | "guest-preview""session" 是真正的一局。"guest-preview" 是未登录时在 hub 的预览,会改状态的操作什么都不做,并让宿主弹出登录提示
capabilities{ canSendMessage, canPersistSession, canUseSessionApis, requiresAuth }当前 mode 允许做什么。读它来禁用那些按了也没用的按钮(比如访客预览里的发送按钮),或者显示一个「登录后继续」的提示
languagestring宿主当前的界面语言代码("en"、"zh"……)。用它在卡里挑翻译,不用依赖宿主的 i18next 实例
messagesArray<Record<string, unknown>>消息记录(已加载的那一段;见 hasEarlierMessages),见 SandboxMessage
hasEarlierMessagesboolean服务器上还有更早的消息没加载。调用 loadEarlierMessages() 去取
isLoadingEarlierboolean正在取更早一页时为 true
isStreamingbooleanAI 正在生成回复时为 true
streamingContentstringAI 正在流式输出的文字(更新很频繁)
streamingReasoningstringAI 正在输出的「思考」/推理文字(只有推理模型有)
pendingChoicesstring[]规则发出的选项按钮文字
errorstring | null当前的错误信息(API 失败、生成出错),没有则为 null
errorCodestring | nullerror 对应的机器代码(比如 "CONTENT_FILTER");不知道时为 null
sendFailureNoncenumber每次发送彻底失败都会加一,包括只弹了提示、没设 error 的失败。盯着它,就能把玩家的文字放回你自己的输入框
composerSendKey"enter" | "mod-enter"玩家的「按 Enter 发送」设置。"enter":Enter 发送,Shift+Enter 换行。"mod-enter":Ctrl/⌘+Enter 发送,Enter 换行。自己做的输入框应该跟着它
readOnlyboolean在看别人的会话时为 true,<Chat /> 会自动隐藏输入框。宿主发来第一份状态之前一直是 true
checkpointsArray<Checkpoint>已存的存档点,见 Checkpoint
greetingContentstring | null根据卡的设定算出来的开场文字(<Chat /> 用它做空状态的内容)
canvasMode"chat" | "custom" | "fullscreen"当前的画面模式
speakerBubblesboolean | undefined这张卡在 回复处理 里打开了 按说话人分成多个气泡
selectedModelstring当前选中的 AI 模型 ID
userPlanstring用户的订阅套餐("free"、"go"、"plus"、"pro"、"ultra"、"internal")
preferredProvider"official" | "private"官方 API 还是用户自己的密钥
balancenumber | null玩家的蘑菇币余额,不知道时为 null
entriesReadonlyArray<SandboxEntry>卡里的设定,只含启用的,按 position 排好序。见 设定查询 和 SandboxEntry
worldbooksReadonlyArray<Worldbook>卡里的情境(id、name、description?、enabled?、activation……)。SandboxEntry.worldbookId 指向这些 ID
loreUiBindingsReadonlyArray<LoreUiBinding>每个 <LoreSlot> id 发的是哪条设定。见 设定控件

ID 和显示名 ​

每个变量都有两个标签:

  • ID:游戏状态实际存储用的键。新变量的 ID 是一串随机 UUID,比如 a8c5a685-1317-40b2-8f11-baccd39f9042,直到你改掉它。 请用这张卡实际的 ID;导入的或比较旧的卡可能保留着大小写混合的 ID。
  • 显示名:你给它起的名字(hunger、Love meter、zombieKills)。

api.variables 以 ID 为键,同时也认显示名:

jsx
api.variables.hunger        // 不管 "hunger" 是 ID 还是显示名都能读到
api.variables["Love meter"] // 带空格的显示名也行
api.setVariable("hunger", 50)  // 写到那个变量的 ID 上

两者都存在时 ID 优先。如果一个变量的 ID 是 hp,另一个变量的显示名也是 hp, api.variables.hp 读到的是 ID 为 hp 的那个。 如果好几个变量显示名相同,以最后定义的那个为准,和引擎按名字查找的规则一致。 最好让名字不重复,免得混淆。

Object.keys(api.variables) 返回的仍然是存储用的键,不包含额外的显示名别名。 遍历这个对象不会因为别名多出重复的项。

游戏动作 ​

方法作用
sendMessage(text, attachments?)以玩家身份发一条消息,触发 AI 回复。attachments 是可选的图片数组,来自 pickChatImage()。即发即忘
pickChatImage()打开宿主的图片选择器,选一张聊天附件。返回 { type: "image", mimeType, name, data }(data 是 base64),取消或不可用时返回 null。把结果传给 sendMessage(text, [image])
setVariable(id, value, options?)设置一个变量。id 是变量的 ID 或显示名。即发即忘:值会立刻出现在 api.variables 里,在后台保存。options({ scope?, targetUserId? })为兼容旧卡而保留,宿主会忽略它
patchVariables(values)一次写入好几个变量:values 是 { variableId: value, ... },以 ID 为键(这里不认显示名)。返回 Promise<void>,服务器确认写入后 resolve,失败时 reject(只读视图、会话已变、服务器出错)。下一步要依赖这个值已经存好时用它
executeAction(actionId)让触发条件是 玩家点了某个按钮、并且 按钮 名为这个值(比如 "attack")的行为生效。即发即忘,不能传值;要传值用 executeActionAndWait
await executeActionAndWait(actionId, params?)同上,但会等结果,并且能传值进去。params 是一个普通对象,比如 { price: 5, item: "Mushroom ale" };行为在条件值、效果值或提示里用 {param.price} 读取(行为 · 按钮传值)。值只能是文字、数字或 true/false;最多 30 个,名字最长 60 个字符,文字最长 500 个字符。宿主状态、运行记录、通知和音频效果都应用完后,resolve 为 { applied: true, variables, firedIds }。以下情况会 reject:失败、超时、不可用的视图(访客预览、只读、回放)、空的或不是字符串的 ID、过期的会话。variables 是服务器确认过的快照;更新的乐观写入仍然留在 api.variables 里
callAi(ai, input?)按名字或 ID 运行卡里的某个 AI,通常是一个带 回答格式 的 界面型 AI。input 是你想交给它的任何东西(一段字符串或一个小对象)。resolve 为 { text, fields, fallback }:text 是它说的话,fields 是它填的值,fallback 为 true 表示它失败了、用了备用台词。跑不了时(这里没生效、在冷却、正忙、或者这个视图不能叫 AI)resolve 为 null。它的话和字段去哪,在它的 回答格式 里设;选了 进故事 的话,它那句话也会加进聊天
onAiOutput(channel?, cb)监听发到 界面频道 的话:来自 说在哪 设成 界面频道 的 AI,或者设成 界面频道 的 回复处理 规则。cb({ channel, id, name, text, fields })。传一个频道名就只听那个频道,只传 cb 就全听。对回复处理规则来说,id 是 "reply",name 是规则名,fields 是 {}。返回一个取消订阅的函数
setScene(scene, options?)告诉 AI 屏幕上现在显示的是什么。scene 是一小段文字或一个小对象({ room: "kitchen", onCamera: false });null 清掉。之后的每一次 AI 调用(回复、重新生成、幕后的 AI)都会把它当作单独的一块拿到;它不会被保存。options.events 是 [{ name, when? }]:AI 可以通过写 [event: name] 来触发的事件
onStoryEvent(cb)监听剧情事件:AI 写了 [event: name],或者设成 一个剧情事件 的 回复处理 规则。cb({ id, name })。只在实际进行的一局里触发。返回一个取消订阅的函数
switchGreeting(index)把第一条消息换成第 index 个开场白(0 = 第一个开场白)。返回 Promise<void>,切换生效后 resolve。切换会把变量重置成那个开场白的初始值,所以你自己的变量要在 await 它 之后 再设
clearPendingChoices()不选任何一个,直接关掉待选的选项按钮
setComposerDraft(text)把 text 放进聊天输入框并聚焦。不会发送。 想让玩家先看一眼、改一改再点发送时用(比如一个给出开场白的 NPC 互动按钮)。只在沙盒内部生效,不经过宿主,所以只能配合自带的 <MessageInput> / <Chat> 组件用
tsx
// 一个商店按钮:带上这件商品的价格,运行 "buy" 行为
async function buy() {
  try {
    await api.executeActionAndWait("buy", { item: "Mushroom ale", price: 5 })
  } catch (e) {
    api.showToast("现在买不了", "error")
  }
}

// 一个算命按钮:调用一个界面型 AI
async function askFortune() {
  const answer = await api.callAi("Fortune Mushroom", { question: "Will I find the key?" })
  if (answer) setFortune(answer.fields.Fortune)
}

聊天控制 ​

默认聊天栏能做的事都开放出来了,你的自定义界面也能做。

方法作用
editMessage(messageId, content, options?)编辑一条已有的消息。返回 Promise<boolean>,成功为 true。options.swipeIndex 写的是开始编辑时所在的那个 swipe;保存时如果当前已经是另一个 swipe,会拒绝保存(false)
deleteMessage(messageId)删除一条消息。返回 Promise<boolean>
regenerateMessage(messageId)让 AI 重新生成这条回复(即发即忘)
continueLastMessage()接着最后一条 AI 消息继续生成(即发即忘)
stopGeneration()中断当前的流式输出(即发即忘)
restartChat()清空所有消息,重置状态,重新开始
swipeMessage(messageId, "left" | "right")在一条消息的几个 AI 备选回复(swipe)之间切换。返回 Promise<Record<string, unknown>>
loadEarlierMessages()取上一页历史记录,加到 messages 前面。返回 Promise<boolean>

会话与分支 ​

方法作用
revertToMessage(messageId)把对话回退到 messageId 之前。返回 Promise<void>
branchFromMessage(messageId)从这条消息分出一个新会话(复制到这条为止、包括这条在内的消息,以及状态快照)。返回 Promise<string | null>:新会话的 ID,失败时为 null(正在流式输出、多人房间、消息不存在都会失败)
getBranchContext()取当前的分支切片(自己、父分支、兄弟分支、子分支)。返回 Promise<BranchContext>。每次调用都重新取,客户端不缓存。见 BranchContext
createSession(worldId)为一个世界开一局新的。返回 Promise<string>,是新会话的 ID
deleteSession(sessionId)删除一个会话。返回 Promise<void>
listSessions(worldId)列出一个世界的所有会话。返回 Promise<Array<Record<string, unknown>>>

存档点 ​

存档点是当前会话里一个有名字的快照,可以回退到它。

方法作用
saveCheckpoint()把当前会话状态存成一个新的存档点。返回 Promise<void>(之后 checkpoints 字段会被推回来)
loadCheckpoints()让宿主刷新 checkpoints 数组。返回 Promise<void>
restoreCheckpoint(checkpointId)把会话恢复到某个存档点。返回 Promise<void>
deleteCheckpoint(checkpointId)删除一个存档点。返回 Promise<void>

音频 ​

trackId 是音轨的 音轨 ID:在 面板 → 音频 里点这条音轨,用 复制 ID。音轨改名不会改它。

方法作用
playAudio(trackId, opts?)播放卡里的一条音轨。opts:{ volume?, fadeDuration?, chainTo?, maxDuration?, duckBgm?, loop? },时长单位是 秒;chainTo 指定接着放的 trackId;duckBgm 在播放时压低 BGM;loop 只对这一次播放覆盖音轨的循环设置
stopAudio(trackId?, fadeDuration?)停掉一条音轨(不传 trackId 就全停)。fadeDuration 单位秒。会 销毁 这个元素;想从同一位置接着放,用 pauseAudio
pauseAudio(trackId)原地暂停一条音轨,保留播放位置
resumeAudio(trackId)继续播放用 pauseAudio 暂停的音轨
onAudioEnded(cb)订阅「一条不循环的音轨放完了」,cb(trackId)。返回一个取消订阅的函数。用来做自定义播放列表的自动下一首
setAudioVolume(type, volume)type 是 "bgm" 或 "sfx",volume 为 0–1
getAudioVolume(type)同步返回当前音量(0–1)

语音朗读(TTS) ​

通过平台的语音流程(经 OpenRouter 调用 Fish Audio)把文字念出来。按字数向玩家计费;同样的文字和音色再放一次,命中缓存,不收费。同一时间只放一个声音。文字里的 [whisper] / [excited] 这类行内标签可以控制语气。

语音朗读需要玩家 自己打开:在 设置 › 显示 / 语言 里开,或者在你的卡请求时点「打开」(见下文)。玩家打开之前,ttsState.enabled 是 false,tts.speak / tts.preview 会直接 resolve 为 { ok: false, reason: "disabled" },不合成也不扣费,tts.setPrefs 也什么都不改。enabled 为 false 时,把你的语音界面藏起来。

方法 / 字段作用
tts.speak(opts)opts:{ messageId?, text?, key?, voice?, waitForEnd? }。传 messageId 就念一条聊天消息(遵守玩家的朗读模式设置),传 text 就念卡里任意一句话(key 给它一个稳定的播放标识)。voice 可以用一个 fish.audio 市场的 id(32 位十六进制)覆盖玩家选的音色,这样卡可以给自己的角色配音。设了 waitForEnd: true 时,成功表示所有音频片段都真的放完了;被取消、播放被拦、媒体出错和超时都返回 ok: false。不设的话,成功只表示已经排进播放队列。返回 Promise<{ok, reason?}>,reason 可能是 "disabled"(玩家没打开朗读)、"unavailable"(预览/回放)、"insufficient"(蘑菇币不够)、"rate-limited"、"empty"、"timeout",或者一条错误
tts.stop()停止当前的朗读(同时取消正在等的合成和自动跟读队列)
tts.onPlaybackFrame(cb)一句话播放时,订阅实测的输出帧(每秒约 15 帧)。用帧上的 key 对上你开始播放的那句。返回一个取消订阅的函数;组件卸载时记得取消
tts.setPrefs(prefs)更新玩家的朗读偏好:{ enabled?, autoPlay?, mode?, voice?, voicePool?, volume? }(mode 是 "full" 或 "dialogue";volume 为 0–100;voicePool 是 AI 分配角色时可用的音色 id 列表,[] = 全部;voice 设成只有一个音色的池子,"" = 全部)。会保存到账号;新值通过 ttsState 传回来。朗读关着时,从玩家点击里发出 enabled: true,Yumina 会弹出自己的确认框问玩家(朗读会消耗 mushies);玩家点了打开才会打开,然后 ttsState.enabled 变成 true。玩家拒绝后一分钟内不会再问;不是从点击里发出的请求什么都不做。其他字段只有朗读打开后才有效。enabled: false 会把它关掉
tts.preview(voice)播放一个音色的短样本("" = 自动音色)。第一次听的时候和普通合成一样计费,之后对所有人都走缓存。返回 Promise<{ok, reason?}>
ttsState只读:{ available, enabled, voice, voicePool, mode, autoPlay, volume, playback }。在真正的一局之外(访客预览、回放)available 为 false,这时把所有语音界面都藏起来;enabled 是玩家是否打开了朗读(在设置里打开之前都是 false)。playback 是 { key, status: "loading" | "playing" | "blocked", progress?, level? } 或 null(progress 0–1,约 2Hz),playing 跟着实际播放走。waitForEnd 的对话里可能带一个真实的输出 level(0–1,约 15Hz);不支持时没有这个字段。自定义界面可以据此为每一句画说话人的状态和进度环

语音输入(按住说话) ​

让玩家说话,而不是打字。录音由平台来做(卡完全碰不到麦克风),再把识别出的文字交回来。对玩家免费。默认的聊天输入框已经有麦克风了;用这些可以做你自己的说话按钮,比如狼人杀的发言台。

方法 / 字段作用
voice.prepare(opts?)检查浏览器的麦克风权限,然后释放麦克风,不录音也不转写。要自动检测静音就设 { requireLevels: true };如果分析器跑不起来,返回 { ok: false, reason: "levels-unavailable" }。在要花钱的语音之前调用,免得麦克风被拦时对话卡住。用 voice.cancel() 取消。
voice.record(opts?)开始录音,在你的说话按钮按下时调用。要自动检测静音的话也要设 opts.requireLevels: true;普通的按住说话可以不设。opts.onLevel(level) 拿到实时的输入音量(0–1,每秒约 15 次),可以画波形。opts.hint 写玩家会说到的人名和自造词,用逗号隔开("牧濑红莉栖, 冈部伦太郎, Amadeus"),转出来的字就会写对。在 voice.stop() 之后 resolve 为 { ok: true, text },或者 { ok: false, reason },reason 可能是 "cancelled"、"too-short"(点了一下而不是按住)、"denied"(麦克风被拦)、"empty"、"rate-limited"、"unavailable"(预览/回放)、"timeout" 或 "error"。会暂停正在进行的语音朗读,免得把 AI 的声音录进去
voice.stop()结束这段录音并转写(说话按钮松开)
voice.cancel()扔掉这段录音
voice.setPrefs(prefs)更新玩家的语音输入偏好:{ enabled?, mode?, key? }。mode 是 ""(跟随卡的设置)、"confirm" 或 "auto";key 是要按住的 KeyboardEvent.code
voiceInputState只读:{ available, enabled, mode, cardMode, playerMode, key }。mode 是玩家松开后想要的处理方式:"auto"(直接按说的发出去)或 "confirm"(让玩家先看一眼)。除非玩家自己改了,否则就是你这张卡的 settings.voiceInputMode。key 是玩家按住说话用的 KeyboardEvent.code

在 卡的设置 → 声音 → 玩家语音输入 里给你的卡设默认值:先确认 对应 "confirm",直接发送 对应 "auto"。在世界 JSON 里是 settings.voiceInputMode: "auto" | "confirm"。

做法:会说话的陪伴卡 ​

一张会开口跟玩家说话的卡需要这些(Amadeus 卡就是这么做的):

  1. 通话前先检查声音。 ttsState.available && !ttsState.enabled 时,显示一个「打开她的声音」按钮,在点击里调用 api.tts.setPrefs({ enabled: true })。Yumina 会问玩家;玩家点了打开,ttsState.enabled 变成 true,按钮就可以收起来。如果还是关着,告诉玩家开关在哪:设置 › 显示 / 语言 › 语音朗读。
  2. 测试按钮。 「听一下她」:tts.speak({ text, voice, key, waitForEnd: true }),把结果显示出来。「测试麦克风」:voice.record({ requireLevels: true, onLevel }) 录几秒、显示音量条,再 voice.cancel();不转文字,也不收费。
  3. 听从麦克风开关。 只在 voiceInputState.available && voiceInputState.enabled 时提供麦克风;voice.setPrefs({ enabled }) 可以开关它。
  4. 换模型。 一个调用 api.openModelPicker() 的按钮,让玩家换一个更快的模型;api.selectedModel 是玩家现在用的模型。
  5. 小窗。 电脑上的 Chrome 和 Edge(hostWindowState.canFloat):在点击里调用 api.hostWindow.float({ width, height }),卡会搬进一个浮在所有窗口上面的小窗,并在那里重新加载,hostWindowState.floating 为 true;api.hostWindow.dock() 把它放回页面。其他地方(手机、Safari):把角色画进一个 canvas,把 canvas.captureStream() 放进一个静音的 <video playsinline>,在点击里调用 video.requestPictureInPicture()。视频要在点击之前就准备好:还没出第一帧的视频会被拒绝。iPhone 和 iPad(上面所有浏览器底层都是 Safari)不能把 canvas 视频流放进画中画:给它们一个提前录好的视频文件(一段待机循环,再加一段说话,角色说话时跳过去),在点击里用 video.webkitSetPresentationMode("picture-in-picture") 打开。手机让页面休眠以后,只有视频文件会继续播。
  6. 写明支持的浏览器(卡里和简介里都写)。声音和麦克风:电脑和手机上的 Chrome、Edge、Safari、Firefox;在聊天、社交 App 里打开的网页通常拿不到麦克风。浮动小窗:电脑上的 Chrome 或 Edge。画中画:安卓 Chrome、iPhone Safari。

持续实时语音(OpenAI Realtime) ​

api.realtimeVoice 在一个可编辑的已保存会话里提供持续的麦克风对话。先调 getConfig() 确认实际能不能用、由谁出钱;这个对象存在不代表有权限。宿主在采集之前会请玩家明确同意使用麦克风。费用可以来自玩家保存的 OpenAI 密钥、开启的测试赞助,或明确开启的 Yumina 余额。用余额时,同意对话框会说明服务器的临时预留和按实际用量的扣费。麦克风音频和世界上下文会发给 OpenAI;转写可能被存进故事里。不会请求摄像头。持久回执和刷新后的恢复见原生余额语音契约(英文)。

方法约定
realtimeVoice.getConfig()无参数,不花钱也不采集。余额通话返回 transport: "server-ws-v1"、funding: "balance"、服务器的 reservationCredits 和 finishAcknowledged: true;旧传输返回 false。被禁用的视图返回不可用。
realtimeVoice.finish()无参数。余额通话先在本地静音,再把这次尝试的持久最终记录取完,然后才返回 finished;pending、incomplete、unavailable 和原子的 not-started 是各自不同的结果。pending 没有最终游标。旧传输返回 unsupported,stop 的行为不变。
realtimeVoice.prepare({ avatar? }?)在玩家点击时、做任何慢操作(组装上下文、保存)之前调用。resolve 为 { intent };把这个 intent 传给 start。这个视图里不能用语音时会 reject
realtimeVoice.start({ instructions, intent?, context?, voice?, avatar?, tools?, interruptionMode? })resolve 为 { status: "connected" };instructions 为 1–12,000 个字符。context 接受和 updateContext 一样的格式。voice 是 "marin"(默认)或 "cedar"。使用 gpt-realtime-2.1、中等的语义 VAD 和由宿主管理的回复。interruptionMode 是 "automatic"(默认)或 "manual";无效的模式会在采集前 reject。tools 是 [](不带检查工具)或 ["request_inspection_focus"];不传就保持默认。可选的 avatar: true 需要宿主配置好的测试 LiveAvatar 服务,并会等它的媒体连接建立;不可用时会明确 reject。把 reject 的结果告诉玩家。
realtimeVoice.interrupt()余额通话立刻清掉当前输出,并发送实际播放到的位置,持续采集不中断。旧传输保留原来清空输入、抢轮次的行为。保持通话连接。
realtimeVoice.stop()立即关闭宿主的采集和播放,有人出钱的通话还会请求服务器挂断。本地静音不等于已确认的结束。
realtimeVoice.setMuted({ input, output })分别静音采集和播放。助手说话时不会把麦克风静音。
realtimeVoice.updateContext(context)更新亲眼所见的场景上下文:一段字符串,或者 { state: Record<string, string>, events: Array<{ id: string, text: string }> }。展开后的文字不能超过 4,000 个字符。
realtimeVoice.updateInstructions(text)旧传输会在安静的轮次边界替换最初的指引(1–12,000 个字符)。余额通话的指引是固定的,调用只会发出一个不致命的「不支持」提示。余额通话的 updateContext 会把亲眼所见的更新攒起来,并发出带修订号的 pending/configuring/applied 确认。
realtimeVoice.reactToScene(notice)把一个已经发生的公开场景事件作为观察发过去,不执行它的动作。见下面的有界通知。绝不要带上私密的笔记/页面文字。
realtimeVoice.cancelSceneReaction(id?)按 ID 移除一个排队中或正在播放的通知。不传 ID 的话只取消还没处理的追问,保留实际事件的播报。不会停止通话,也不会清掉麦克风输入。
realtimeVoice.resolveTool(callId, { accepted, focus?, reason? })由世界来校验这个有界的检查请求再决定是否接受;服务商不执行代码,也不应用状态效果。
realtimeVoice.setSpatial({ x, z, yaw, sourceX, sourceZ })更新空间播放的位置姿态。
realtimeVoice.onEvent(callback)订阅事件;返回一个取消订阅的函数。

头像模式会把 AI 的音频经由服务器选定的 LiveAvatar 转发,只播放同步返回的音频。它不会请求摄像头,也不会把麦克风音频直接发给 LiveAvatar。宿主会额外发出 video-status 事件(connecting、live、stopped)和带 ImageBitmap 的 video-frame 事件。在事件监听里把每一帧 bitmap 同步地 画到你自己的 canvas 上;沙盒随后会立刻关闭它并确认已送达。不要留着 bitmap,不要把帧放进 React state,也不要保存它们。收到 video-status: stopped 时换回一张静态图。会话令牌、打断、空间播放、帧的背压和清理都由宿主负责;卡不能自己指定任意的头像凭证或接口地址。头像的播放状态跟着返回的流走,所以 AI 生成结束了,屏幕上的这一句也不会提前结束。

想让角色把每一句说完、除非玩家主动按下某个控件才打断时,用手动打断:

tsx
// 在开始通话的点击处理函数里:
const { intent } = await api.realtimeVoice.prepare();
await api.realtimeVoice.start({ intent, instructions: "Your character direction.", interruptionMode: "manual" });
// 在玩家的打断按钮/按键处理函数里:
api.realtimeVoice.interrupt();

手动模式下,麦克风在两句之间一直开着。在生成未完成、头像音频未播、正在发声或其回声尾音期间开始或持续的输入,不会进入用户转写事件和原生对话历史,即使它最终的语音识别结果晚到、或者和角色说的话不一样。预期内被保护的输入不会发出「说话重叠」的提示。原生的删除确认仍然会挡住之后的回复。明确的打断要等输入缓冲区清空确认之后才接收新的采集;在那个边界之后再说话。在新一轮输出开始前就已经说完的输入,按原来的方式结算。静音、暂停和断开对采集/播放的控制照旧。这个选项改的是接收输入的规则,不改麦克风硬件、VAD、音色或声学模型。

事件包括 status(connecting、connected、stopped、error)、transcript(role、稳定的 id、累积的 text、final),以及有界的检查 tool 请求。input 用转写 ID 加上 speaking、transcribing 或 discarded;收到 discarded 时清掉待处理的发言状态,免得转写失败后一条提交永远等着。activity 是 listening、thinking 或 speaking;speaking 跟着实际的输出缓冲播放走,而不是生成何时结束。input-level 是实测的麦克风音量(0–1),level 是实测的输出音量(0–1)。给每个参与者用对应的音量表。input-hint 带一条给玩家的简短提示,capture-settings 报告浏览器实际用了哪种回声消除。

玩家离开、隐藏标签页、失去权限、主动停止,或者通话满五分钟时,通话结束。赞助的测试还额外要求服务器挂断、每个账号同时只能有一个通话,以及滚动 24 小时内每位玩家最多发起 12 次、全局最多 60 次。这个 API 和 api.voice 的按住说话、api.tts 的朗读是分开的。

音频输出会在同意的那次点击里解锁,并在采集麦克风之前检查。恢复失败、一直处于暂停状态,或者两秒解锁超时,都会让 start reject 并释放资源。这只确认浏览器的输出已就绪;硬件音量仍由玩家控制。

场景通知有 id(1–160 个字母、数字、下划线、冒号或减号)和 kind,并且只能带下面这些额外字段:

Kind额外字段
return、seen、overdue、writing、caught、knock、power-restored、clearance无
bulletintext:非空的广播文字,最多 600 个字符
power-cutseconds:3 到 12 之间的有限数字;观察者暂时看不见
begin-inspectionfocus:desk、door 或 bed;line:非空的公开宣告,最多 240 个字符
searchfocus:desk、door 或 bed
follow-upevidenceId:之前一条公开的住户陈述 ID;line:一个非空的问题,最多 240 个字符

多余的字段会被拒绝。通知按连接去重,每十秒最多接受八个事件,最多 128 个 ID。输出静音时不会再播新的通知;正在说的话和待处理的回复按原来的顺序。广播文字会作为公开广播播出;其它事件会得到针对那个已发生事件的简短回应。原生的 start 仍然最多允许 12,000 个字符的指引,updateContext 最多 4,000 个字符。

start({ instructions, context }) 接受同样的 context 格式。场景会变化时,用结构化的快照:稳定的 state 键描述当前的公开事实;不可变的事件 ID 标识记录下来的观察。某个事实变了,就覆盖它的旧值。快照里没有某个事件,意思是它没放进历史预算,不是它没发生过。修正过的或新的证词不要复用 ID。只放这个角色被允许知道的信息;这个 API 不会帮你过滤私密页面或没被看到的行动。

宿主在连接/重连时发送完整快照。在宿主开启的 GPT-Live 路径上,之后的快照只会在安静的播放边界发送变了的事实和之前没送达的事件 ID。待处理的事件在状态合并时不会丢,改了摘录也不会重放旧事件。旧式的字符串和 Realtime 路径仍然整个替换上下文。上限:16 个 state 字段,每个快照 64 个事件,展开后 4,000 个字符,启动指引加上上下文分隔符和文字合计 12,000 个字符。键和 ID 是传输用的元数据,不会被念出来。如果产生的事件超出了待处理事件或连接历史的容量,通话会明确地结束,而不是悄悄丢掉事件。

SDK 只转发通知的 ID 和 kind。没有 unperson-room 变量的现有卡,可以继续发固定的、不带文字的 return、seen、overdue、writing 和 caught 通知。这些是卡报告的观察;宿主并不判定它们在故事里是不是真的。对 Unperson room 的 schema,宿主会从已确认保存的状态里补全每一个支持的事件;给 SDK 传文字并不等于允许你编造证据。追问必须引用同一次尝试里之前收到的住户发言。房间时间过了 45 秒、阶段/尝试变了,或者收到了更新的发言,追问就失效。新的麦克风发言也会让所有未处理的追问立刻失效。其它的房间 schema 需要一个宿主适配器,才能支持广播、检查和关联证据的追问这类带丰富引用的通知。

UI / 导航 ​

方法作用
toggleImmersive()切换沉浸(全屏)模式
exitFullscreen()退出全屏,只退不进。游戏菜单里的「退出全屏」
isFullscreen()Promise<boolean>:游玩页现在是不是全屏。菜单打开时问一次,决定那一项写「退出全屏」还是「全屏」
backToYumina()离开这张卡回到 Yumina,和游玩页的返回按钮一样(先退出全屏)。游戏菜单里的「返回 Yumina」。编辑器预览里不起作用
openPersonaManager()打开玩家的人设管理器,不离开世界就能切换/新建/编辑人设(和输入框「+」菜单打开的是同一个面板)。游玩时切换会改掉这一局保存的人设,从下一条消息开始生效
getPersonaProfile()在玩家选择导入时,读取这一局选中的人设。返回 Promise<{ name, appearance, personality, backstory, entries? } | null>(entries 是 [{ title, content }]);没选人设时为 null;失败会 reject。私密笔记永远不会包含在内。如果这个身份在整局里必须固定不变,就在你自己的数据里存一份副本
openModelPicker(options?)打开聊天的模型选择器。如果是给你自己的 ai.complete 调用选模型,传 { purpose: "side-completion" };这时只能用于聊天的试用模型不会列出来
openSessionManager()打开会话/分支管理器(切换、改名、删除分支)
sharePlaythrough()打开分享对话框,把这一局发布成一个分享的游玩记录
openSupport()打开「支持创作者」对话框(打赏美元或送蘑菇币),记在正在玩的这个世界名下。给全屏自定义界面的卡用,因为它自己的画面盖住了游玩页顶部的爱心按钮。返回 Promise<{opened, reason}>,reason 可能是 "self"(你就是创作者)、"signed-out" 或 "unavailable"(编辑器预览)。await 它并把原因显示出来:第一个按这个按钮的,就是在测试自己卡的创作者,什么都不发生看起来就像坏了
openCreditTopUp()打开蘑菇币充值弹窗
fetchAsset(ref)把你的一个素材读成原始字节。参数是素材 id("@asset:<uuid>" 或光秃秃的 uuid),不能是 URL。返回 Promise<{ok, bytes?, contentType?, error?}>;error 可能是 "bad-ref"、"http-404"、"too-large"(上限 32MB)、"unavailable",或者一条网络错误信息。沙盒自己什么都取不了,浏览器只给图片和媒体开了特权加载通道(没有 <model> 标签),所以 .glb、大的 JSON 表或者精灵图集只能这样进来。碰 bytes 之前先检查 ok。在编辑器预览里也能用
copyToClipboard(text)复制到剪贴板(替代 navigator.clipboard.writeText)
navigate(path)让宿主跳转到一个路径,比如 "/app/hub"(替代 window.location = ...)
showToast(message, type?)在宿主界面弹一个提示。type:"success"、"error"、"info"(默认)

Esc 键和游戏菜单。 全屏时 Esc 交给卡,玩家要按住 Esc 才退出全屏(浏览器会提示)。按住时连续发出的 Esc 不会传到卡里。所有占满屏幕的卡都守同一套规矩,玩家学一次就会:

  1. Esc 只用来打开和关闭游戏菜单(暂停 / 设置),不做别的。 抽屉、输入框、对话框用自己的关闭按钮,不用 Esc。菜单里按 Esc 先从子页面退回菜单,再关掉菜单。处理了的 Esc 要调用 preventDefault()。
  2. 菜单最后固定两项: 「退出全屏」(exitFullscreen();isFullscreen() 为 false 时改成「全屏」→ toggleImmersive())和「返回 Yumina」(backToYumina();卡自己存进度的话先存)。
  3. 没有菜单的卡不要碰 Esc:没人处理的 Esc 会直接退出全屏。

浏览器本地存储(按世界) ​

localStorage 的替代品。按 worldId 隔离,世界之间读不到彼此的键。这是存在设备本地的偏好/缓存:不会在设备之间同步,也不会进存档点、分支或分享的快照。浏览器的 sessionStorage 别名用的也是这个本地 API;它和下面的 api.sessionStorage 不是一回事。

方法作用
storage.get(key)读取。返回 Promise<string | null>
storage.set(key, value)写入(只能是字符串)。返回 Promise<void>
storage.remove(key)删除。返回 Promise<void>

要存复杂数据?存取时用 JSON.stringify / JSON.parse。

云端 JSON 存储(按会话) ​

api.sessionStorage 用来存属于这一局、要跟着它跨设备走的小 JSON 记录。这些值和给 AI 的游戏变量是分开的。所有方法都返回一个包含 { value, version, exists } 的 promise。从没写过的键返回 { value: null, version: 0, exists: false };删掉的键会保留一个版本号,所以要看 exists,别以为 null 就是不存在。

方法作用
sessionStorage.get(key)读取当前记录,在分享的回放里读的是冻结的记录
sessionStorage.set(key, value, { expectedVersion })只有当前版本对得上时才保存这个 JSON 值;options 必填
sessionStorage.remove(key, { expectedVersion })删除这个值,留下一个带版本的墓碑;options 必填
tsx
const previous = await api.sessionStorage.get("journal");
const saved = await api.sessionStorage.set(
  "journal", { notes: ["Reached the village"] },
  { expectedVersion: previous.version }
);
// 留着 saved.version,下次修改(包括 remove())要用。

遇到 SESSION_STORAGE_CONFLICT 时,保住玩家的草稿,重新读取记录,把改动合并好再提交。不要拿旧值盲目重试。收到成功的响应之后,再显示「已保存」。要遵守 api.readOnly;分享的回放不能写。读写都需要一局真正的游戏或一个有权限的分享回放,编辑器和访客预览都不行。

键由 1–128 个 ASCII 字母、数字、_、.、: 或 - 组成,以字母或数字开头。上限:每个值序列化后的 UTF-8 JSON 最多 32 KiB,每局当前值合计 256 KiB,包括墓碑在内最多 128 个键,每局值的历史最多 16 MiB,所有者的所有会话加起来每小时最多 1,000 次修改。记录要控制大小,这里不是文件仓库。

会改动 JSON 的存档点恢复,也受这些容量和频率限制。超过 16 MiB 历史上限或其它配额返回 HTTP 413;超过每小时修改次数返回 429。整个恢复会回滚,包括媒体和剧情状态,不会只恢复一半。已有的历史会保留。值没有变化时,恢复不会重复写历史,即使到了这些 JSON 上限也能继续。

JSON 和媒体引用会一起被存进存档点、同账号的分支和分享的快照。恢复存档点时两者都会恢复。私密的进行中会话数据只有它的所有者能访问;你是这个世界的创作者也不能因此访问。分享的快照遵循现有的可见范围、世界发布状态、隐藏/审核状态和内容分级规则。在这些规则下,分享者和卡的创作者是快照的特权读者,包括隐藏的、未发布的或敏感的快照。其他人要通过对应的检查;不公开列出的分享凭链接可以访问。别在会被分享的记录里存机密。任意的浏览器存储键不会被自动迁移过来。

玩家图片(按会话) ​

api.media 用来存玩家的私密图片。创作者的素材仍然用 @asset:。服务器配置了 S3 存储时,所有世界都能上传,除非运营方暂停了新上传。

方法作用
media.list(offset?)返回 { items, hasMore, uploadsEnabled };hasMore 为 true 时继续加载下一页
media.pick({ entryId?, metadata? }?)打开选择器并上传;resolve 为 { mediaId, entryId },取消时为 null
media.upload(file, { entryId?, filename?, metadata?, uploadId? }?)上传一个 Blob/File;返回 { mediaId, entryId }。同一个请求重试时复用 uploadId
media.remove(entryId, version)用这一项的版本号,解除它和当前会话的关联;返回 { removed }

列出的每一项包含 { id, entryId, filename, metadata, version, sizeBytes, url, thumbnailUrl, deleted };id 是媒体 ID。在 JSON 里存 entryId/mediaId,永远不要存带签名的显示 URL 或 base64 字节。重新打开时、以及在 URL 五分钟过期之前,用 list() 刷新 URL。已删除的项 URL 可能为 null。解除和某个会话的关联,并不会永久删除文件;只要还有别的引用,占用的容量也不会释放。

静态的 JPEG/PNG/WebP 输入最大 16 MiB,解码后最多 4,000 万像素。服务器会生成最长边 2048 像素的 WebP 显示图和缩略图;这不是原文件的备份。图片占用账号共享的素材容量。存储和迁移的做法见 玩家图片的配方。

设定查询 ​

在界面里只读地访问卡的设定和情境。拼一个旁支 LLM 的提示词时用来查看或挑选设定、做一个游戏内的日志查看器,或者接一个调试面板,都用得上。

字段 / 方法作用
entriesReadonlyArray<SandboxEntry>:所有启用的设定,已按 position 排好序。见 SandboxEntry
getEntry(name)按 完整的名字(区分大小写)找一条设定。返回 SandboxEntry | null。在 localhost 上找不到时会打一次警告,列出可用的名字,改了设定名却忘了改卡的时候很方便
worldbooks卡里的情境。用 entry.worldbookId 对上 worldbook.id,就能按情境给设定分组

大多数情况下不用直接碰这些:给 ai.complete() 传 includeLorebook: "matched",服务器会帮你组装设定(见下文)。需要精确控制时再用 entries / getEntry,比如 「这个 NPC 只知道打了 tavern 标签的设定」。

调用卡里的 AI 和原始补全 ​

在界面里让模型说话有两种办法:

  • api.callAi(name, input) 运行卡里的某个 AI,它在画布上设好:模型、它看得到什么、回答格式 的字段、重试、冷却和备用台词都在它的设置里,答案在交给你之前已经检查过。如果这次调用是游戏的一部分(算命师、讨价还价的店主、按按钮就回话的 NPC),优先用这个。见 游戏动作。
  • 下面的 api.ai.complete(...) 是原始的模型调用:整个提示词你在代码里自己写,什么都不帮你检查。

原始 AI 补全 ​

在主聊天流程 之外 调用 LLM。用来做「侧边面板里的 NPC 内心独白」「AI 生成的物品描述」「卡里的手机聊天」之类的东西。不会 写进消息记录,不会触发状态更新,也不会消耗开场白。

tsx
const api = useYumina()
const text = await api.ai.complete({
  messages: [
    { role: "system", content: "You are a surly merchant." },
    { role: "user", content: "Price me an iron sword." },
  ],
  onDelta: (chunk) => setStreaming((s) => s + chunk),  // 可选,逐 token 回调
  model: "anthropic/claude-sonnet-4.6",                 // 可选,默认用 selectedModel
  maxTokens: 500,                                       // 可选,默认 2048,最大 8192
  temperature: 0.7,                                     // 可选
  includeLorebook: "matched",                           // 可选,见下文
  reasoningEffort: "medium",                            // 可选:none | minimal | low | medium | high,推理模型想多深
})

返回 Promise<string>,是完整的回复。调用失败时(限流、请求无效、服务商出错、没有会话),promise 仍然会 resolve,值是 "[Error: <message>]" 这样的字符串;用之前先检查这个前缀。只有客户端 120 秒超时后才会 reject。在访客预览里 resolve 为 ""。

对能读图的模型,消息的 content 也可以是由 { type: "text", text } 和 { type: "image_url", image_url: { url } } 组成的数组,或者消息里带上 attachments(来自 pickChatImage() 的图片)。

需要机器可读的回复时,传 responseFormat: { type: "json_object" }, 并在消息里明确要求输出 JSON。SDK 会把这个选项转给服务商。 支持与否取决于所选的服务商/模型;它并不会强制你的游戏 schema。 解析结果,并在应用之前校验每一个动作。 不传这个选项就是普通的文本补全。其它格式会在推理之前被拒绝,返回 HTTP 400。

context: "session":共享叙事上下文 ​

选择使用已认证会话当前的人设、启用的叙事提示词, 以及玩家当前的生成设置:

tsx
const result = await api.ai.complete({
  context: "session",
  includeLorebook: "matched",
  responseFormat: { type: "json_object" },
  messages: [
    { role: "system", content: 'Return JSON with only {"actions":[],"line":""}.' },
    { role: "user", content: sceneObservation },
  ],
})

人设的选择跟着账号走,除非这一局被锁定了,包括明确锁定为不用人设。 人设公开的描述字段在服务器上解析;私密的人设笔记永远不会包含进来。 叙事提示词遵循和普通聊天一样的启用文件夹、所选模型和账号资格规则。

在会话上下文里,不传 includeLorebook 就等于 "matched";false 不带世界设定, 但保留人设和用户提示词。true/"all" 跳过关键词筛选,但仍然遵守状态条件、 开着的情境和界面上的设定绑定。匹配用的是 传入的所有用户消息, 所以最后一条修正指令不会把原本的场景冲掉。原生的宏使用已保存的会话状态和 当前人设;「最后一条消息」类的宏指的是传入的历史。系统段、示例对话、 深度注入的设定和后历史的角色,都保持它们在这段历史周围的原生位置。 调用方的系统消息放在这些叙事段之后,JSON 模式还会在最后加一条指令, 保护所要求的对象格式。在卡里继续校验返回的 schema。

worldbookIds(仅限会话上下文)把原生设定限制在列出的情境, 加上不属于任何情境的设定。不传就是正常的选择;[] 表示只要情境之外的设定。 ID 必须存在;启用、激活和设定条件仍然生效。不带 context: "session" 却传了 worldbookIds,返回 HTTP 400。

当前的玩家设置由宿主提供;卡不能自己传一个任意的偏好对象。 调用时明确写的 maxTokens/temperature 优先,其次是玩家设置、世界设置, 最后是下面的原始默认值。8192 的输出 token 上限和 0–2 的温度范围仍然适用。 支持的采样、推理和流式偏好,按普通聊天的转发方式处理(包括针对特定模型的重复惩罚)。 无效的会话设置返回 HTTP 400。

只使用传入的这次旁支调用的历史:已保存的聊天、摘要和待处理的聊天效果都不会被带进来。 maxContext 和世界设定预算限制的是可选的匹配设定;它们不会截断必需的设定、 预设或调用方的格式约定,也不保证完整的提示词能装进每个服务商的窗口。 返回的文字不会自动应用效果,也不会保存成消息。不传 context, 就保持下面描述的原有原始行为。

可选的结构化输出 ​

传 responseFormat 来要求 JSON 输出。不传就是原有的文本行为,或者用 { type: "json_object" } 走原有的、各服务商自己的 JSON 模式。严格的 schema 输出目前需要 OpenRouter 连接(官方或自带密钥):

tsx
const text = await api.ai.complete({
  messages: [{ role: "user", content: "Choose an action: wait." }],
  responseFormat: {
    type: "json_schema",
    json_schema: {
      name: "decision_v1",
      strict: true,
      schema: {
        type: "object",
        properties: { action: { type: "string", enum: ["wait"] } },
        required: ["action"],
        additionalProperties: false,
      },
    },
  },
})

外层只接受示例里这些字段。schema 名必须匹配 /^[A-Za-z0-9_-]{1,64}$/,strict 必须是 true,schema 的根必须是 type: "object" 的对象。序列化后的完整格式最多 16,384 个 UTF-8 字节,遍历深度 16(格式根为深度 0),最多访问 2,048 个值。用普通的 JSON 值;循环引用、访问器和序列化钩子都会被拒绝。schema 的字符计入原有的 50,000 字符内容上限和提示词费用估算。

其它服务商适配器会拒绝 json_schema,返回 HTTP 400 和代码 UNSUPPORTED_RESPONSE_FORMAT:“This provider does not support JSON Schema side completions.” OpenRouter 会发送完整的 schema,并要求接口支持所请求的参数,同时保留官方的路由偏好和自带密钥账号的路由。接口返回的 schema 相关错误会以 "[Error: …]" 的结果返回,绝不会变成空的成功或者降级成更弱的格式。检查它,并准备合适的兜底。

schema 约束的是回复的结构,不代表允许它做什么。在应用任何状态变化之前,解析并校验返回的内容、当前状态、允许的动作和证据。服务商特有的 schema 关键字由上游检查。

限制与费用 ​

限制值来源
每次调用最多消息数50服务器拒绝,HTTP 400
内容总量上限所有消息合计 50,000 个字符服务器拒绝,HTTP 400
maxTokens 默认值2048原始默认值;会话上下文先继承设置
maxTokens 上限8192更大的值会被悄悄截到上限
temperature 范围0–2,默认 1.0超出范围的值会被截断
默认模型玩家的 selectedModel;model 和 selectedModel 都没设时用 anthropic/claude-sonnet-4.6
频率限制旁支调用专用的池子:每分钟 100 次旁支调用,和主聊天的每分钟额度互不影响超出时返回 HTTP 429 + RATE_LIMITED 代码和 Retry-After 头
额度和主聊天一样按 token 计费。自带密钥的用户不扣服务器的额度,但仍要付自己服务商的钱记录的接口为 "side-completion"
鉴权会话必须属于当前玩家,否则调用失败,返回 HTTP 404

ai.decide:选择题 ​

await api.ai.decide({ state, questions }) 让模型给每个问题挑一个选项,返回的是概率而不是文字。state 是一个描述当前情况的小 JSON 对象。questions 把问题名映射到 { type: "choice", instructions, criteria },其中 criteria 把每个选项标签映射到一段「什么时候选它」的描述:

tsx
const { answers } = await api.ai.decide({
  state: { suspect: "Vex", lastLine: playerText },
  questions: {
    lying: {
      type: "choice",
      instructions: "Is the player lying to the guard?",
      criteria: { yes: "The statement contradicts what they did", no: "The statement fits what they did" },
    },
  },
})
// answers.lying → { choice: "yes", probabilities: { yes: 0.8, no: 0.2 }, confidence: 0.8 }

每次调用最多 8 个问题,每个问题最多 64 个选项,选项标签最长 128 个字符,总共约 32,000 个字符。它用的是服务器的判断模型;官方使用由平台付费,OpenRouter 自带密钥可能会记在玩家的服务商账上。它不会改动故事或状态。失败会 reject(有 15 秒的时限);不要自己补一个缺失的答案。需要一局真正的游戏。

ai.context:组装旁支角色的指引 ​

await api.ai.context({ actor: "voice" | "director", model?, recentMessages? }) 为当前可编辑的已保存会话返回 { instructions, receipt }。它组装当前生效的世界指引和适用的用户预设,不生成文字、不产生模型调用费用,也不改状态。要明确传入实际要用的目标模型:实时语音模型和文字模型可能对上不同的设定条件。这个方法不会拿当前选的聊天模型来代替。

recentMessages 只能放这个角色亲眼看到的公开记录 { role: "user" | "assistant", content }:最多 24 条,每条 4,000 个字符,合计 12,000 个字符。不会带入普通的聊天历史或账号的人设资料。 用的是卡里虚构的玩家名;私密变量不能展开进指引。角色标签决定设定归谁;共享的、生效中的文风/系统预设两个角色都适用。正常的设定激活、设定匹配和宏仍然生效。普通的 ai.complete 默认值不变。

receipt 列出包含和省略的设定 ID、省略的原因、适用的用户提示词 ID,以及模型、人设/历史策略和指引的字符数。里面没有设定的内容。指引最多 9,000 个字符;超出的部分会 reject,不会悄悄截断。捕获失败,并把之前确认过的指引作为「已过期」继续显示,直到刷新成功。对旧传输的通话,把成功拿到的新指引传给 realtimeVoice.updateInstructions;余额通话的最初指引是固定的。光用 updateContext 不会替换最初的指引。

includeLorebook:自动注入世界设定 ​

不带 context: "session" 时,旁支调用会跳过主聊天的提示词组装。传入 includeLorebook,服务器会在前面加一条用世界设定拼成的系统消息:

值行为
不传 / false不注入(默认)。用于翻译、摘要、分类这类不需要世界背景的任务
true / "all"注入所有启用的、非开场白的设定,按 position 排序。结果可预测,token 花得多
"matched"用主聊天同一套关键词匹配,对 messages 里 最后一条用户消息 去匹配。alwaysSend 的设定总是包含;关键词触发的设定只在相关时才加进来。角色扮演类的旁支调用推荐用这个

不带它的话,角色扮演类的旁支调用只知道你的消息告诉它的东西,角色的口吻就会和主聊天越走越远。用 "matched",和某个 NPC 的手机聊天就能看到和主聊天一样的世界设定和角色资料。

tsx
// 一段不跑偏的手机聊天
api.ai.complete({
  messages: [
    { role: "system", content: "Stay strictly in character as Balder. Reply in one or two short lines." },
    ...history,
    { role: "user", content: userText },
  ],
  includeLorebook: "matched",  // 服务器会拉取 Balder 的资料和相关的世界设定
})

如果需要更细的控制,比如按名字注入某一条设定,或者只要带某个标签的设定,就不要用 includeLorebook,自己遍历 api.entries 拼系统消息:

tsx
const tavernLore = api.entries
  .filter((e) => e.tags?.includes("tavern"))
  .map((e) => `【${e.name}】\n${e.content}`)
  .join("\n\n")

api.ai.complete({
  messages: [
    { role: "system", content: `You are the tavern keeper.\n\n${tavernLore}` },
    { role: "user", content: userText },
  ],
})

"matched" 模式的注意事项:它只扫最后一条用户消息里的关键词(不扫整段历史),而且依赖游戏变量的条件设定在旁支调用里不会触发(匹配器看到的是一个空的状态占位)。比起省 token 更在意准确的话,用 true 强制全部包含。

上下文注入 ​

往 下一次 主聊天的 AI 回合里注入一条 一次性 的上下文消息。用过一次就丢掉;不会 产生可见的聊天消息。用来告诉主 AI 一些它该知道、但玩家不该看到成一个聊天气泡的东西:一条手机消息、幕后 NPC 的对话、周围环境的变化。

tsx
api.injectContext("You just received a cryptic text: 'Tonight, 9pm, usual place.'", { role: "system" })
// 玩家发下一条消息时,主 AI 会把这句话当作一条系统消息看到。

options:{ role?: "system" \| "user" }(默认 "system")。

如果某件事在成立期间 AI 每次 调用都该看到(玩家在哪个房间、屏幕上显示什么),改用 setScene。

模型选择器 ​

字段 / 方法作用
selectedModel当前模型 ID
userPlan用户的套餐档位
preferredProvider"official" 或 "private"
setPreferredProvider(provider)在 "official" 和 "private" 之间切换。返回 Promise<{ ok, provider?, error? }>
setModel(modelId)换模型(即发即忘)
getModels(provider?)返回 Promise<{ models, pinnedModels, recentlyUsed }>,其中 models 是 Array<{ id, name, provider, contextLength, supportsImages? }>。传 "private" 拿玩家自己密钥上的模型
pinModel(modelId) / unpinModel(modelId)置顶 / 取消置顶一个模型。返回 Promise<{ pinnedModels, accepted }>;列表满了、置顶被拒绝时 accepted 为 false
modelFallback选的模型用不了、宿主提供了替代时,是要显示的提示;否则为 null。<Chat> 和 <MessageInput> 已经处理好了

这个 API 还带着内置聊天给自己的面板用的字段和方法:混合模型(mixMode、modelPool、setMixMode、addToPool、removeFromPool、setPoolWeight、togglePoolLock)、玩家机器上的本地模型(localBridge、reconnectLocalBridge、resolveModelFallback、cancelModelFallback)、玩家的提示词(playerPrompts、togglePlayerPrompt)、会话记忆和摘要(memorySummaryEnabled、getSessionMemory、getSessionSummary 以及相关的设置方法)、Lore Shift(getLiveCanon 等),以及状态守卫(getStateGuardSettings、setStateGuardSettings)。它们的格式跟着内置界面走,这里不展开。

素材 ​

方法作用
resolveAssetUrl(ref)把 @asset:<id> 引用转成它的 /cdn/<id> URL。纯字符串转换,不发网络请求。HTTP/HTTPS 的 URL 原样返回。<img src="@asset:…">,以及行内样式和 <style> 标签里的 @asset: 都会自动解析;需要拿到字符串形式的 URL 时再调用它

Markdown ​

方法作用
renderMarkdown(text)把 markdown 转成 安全的 HTML(转义 HTML 实体、去掉危险标签、保留格式)。在自定义气泡里把结果交给 dangerouslySetInnerHTML,见下面的例子
tsx
<div dangerouslySetInnerHTML={{ __html: api.renderMarkdown(msg.rawContent) }} />

组件 ​

<Chat> ​

平台完整的聊天体验。不传任何 props 就是默认的聊天。

包括:消息列表、自动滚动、流式光标、swipe 控件、消息操作(编辑/删除/重新生成)、输入栏、选项按钮、模型选择器、只读模式、开场白占位。

tsx
<Chat renderBubble={(msg) => <MyBubble {...msg} />} />

Props ​

Prop类型说明
renderBubble?(props: BubbleProps) => ReactNode自定义每个消息气泡的样子。不传就用默认的 markdown 渲染
className?string加在最外层容器上的额外 CSS 类
children?ReactNode渲染在消息列表 上方 的内容(比如一个固定的 HUD 顶栏)
design?、fontMap?对象来自 玩家界面 编辑器的消息样子(消息样子 和它的细调)。只在没有 renderBubble 时使用。可视化编辑器会填好这些,你一般不用管

BubbleProps ​

你的 renderBubble 回调收到的 msg 对象:

字段类型含义
contentHtmlstring预渲染好的安全 HTML(markdown 已经转换)。通常直接交给 dangerouslySetInnerHTML
contentstring去掉了 [var: op value] 指令的 markdown 文字。用它做你自己的正则匹配,或者截一段交给 renderMarkdown
rawContentstring渲染之前的原始 markdown 文字(包含指令文字)
role"user" | "assistant" | "system"消息来源
messageIndexnumber在列表里的位置(0 = 第一条,通常是开场白)
isStreamingboolean这条消息正在流式输出时为 true
stateSnapshotRecord<string, unknown> | null生成这条消息时的游戏状态(想知道「那时候的 HP/地点是多少」时有用)
variablesRecord<string, unknown>当前(最新)的游戏变量
renderMarkdown(text) => string辅助函数:把任意 markdown 文字转成安全的 HTML

<MessageList> ​

只有消息流(带滚动、流式光标、swipe 控件)。没有 输入栏。

tsx
<MessageList />

不接受 renderBubble。要自定义气泡,用 <Chat renderBubble={...} />,或者干脆不用 <MessageList>,直接读 api.messages(视觉小说的做法)。

<MessageInput> ​

只有输入栏(带模型选择器、选项按钮、继续/重开菜单、流式状态)。

tsx
<MessageInput />

api.readOnly 为 true 时自动隐藏。

<ChatCanvas> ​

<Chat /> 的别名,留着是为了让旧卡还能用。新代码请用 <Chat />。

模型选择器组件 ​

组件作用
<ModelTrigger onClick={...} />聊天输入框里的模型按钮:当前模型名和玩家的余额。Props:onClick(必填)、model?、provider?、showBalance?(默认 true)、compactBalance?、className?
<ModelPickerModal open={...} onClose={...} />模型选择器。在一局真正的游戏里只传 open 和 onClose 时,打开的就是聊天用的那个选择器(也可以调用 api.openModelPicker())。传 onSelectModel(modelId)、title 或 subtitle,就可以把它用在你自己的选择上,而不是选聊天模型
<SessionMemoryModal open={...} onClose={...} />聊天的会话记忆对话框。需要会话记忆扩展;显示按钮之前先检查 api.memorySummaryEnabled

没有 <MessageInput> 的全屏界面,除非你自己加一个,否则没有模型按钮:

tsx
var [pickerOpen, setPickerOpen] = React.useState(false)
// ...
<ModelTrigger onClick={function () { setPickerOpen(true) }} />
<ModelPickerModal open={pickerOpen} onClose={function () { setPickerOpen(false) }} />

设定控件 ​

这些控件只在界面里的某个开关开着的时候,才把一条设定发给 AI。玩家永远看不到设定的文字。

组件作用
<LoreSlot id="slot-id" />什么都不渲染。它挂载着的时候,绑定到 slot-id 的设定会发给 AI;卸载后就不发了。有条件地挂载它({open && <LoreSlot id="x" />})
<LoreButton slotId label icon? description? variant? className? />一个开关某个槽位的按钮。variant:"chip"(小胶囊,默认)或 "card"(整行宽,带开关和 description)
<LoreSwitch slotId onLabel? offLabel? onIcon? offIcon? className? />一个槽位的两个按钮:一个打开,一个关闭。标签默认是中文(开启 / 关闭),可以传你自己的
<LoreGroup slots={[{ id, label, icon?, description? }]} variant? className? />一排选项,同一时间最多只有一个槽位开着。再点一次当前选中的就关掉
<LorePanel title? slots? defaultOpen? className?>…</LorePanel>一个可折叠的面板,用来放设定按钮。slots 列出要计入「开了几个」小标的槽位 id。title 默认是中文(知识库),可以传你自己的

LoreButton、LoreSwitch 和 LoreGroup 会把状态记在一个名叫 __lore_<slotId> 的变量里,所以选择在刷新、存档点和分支之后都还在。

在代码里放一个控件,就会为它的槽位 id 建一条对应的设定;它列在设定页(面板 → 设定)的 前端控制的词条 下面。在那里写 AI 要读的内容。你还可以在绑定上叠加变量条件:这样就要控件开着 并且 条件成立才发。api.loreUiBindings 列出所有绑定。


行为代码(ctx) ​

一条 行为 的效果里可以放一段 代码。行为触发时,它在卡的沙盒里、对着实时的 API 运行,而且只在玩家开着游戏的时候跑。代码体是一个 async 函数,所以能用 await。它拿到一个对象 ctx:

字段 / 方法作用
ctx.vars当前值的一份副本,以变量 ID 为键
ctx.get(name)一个值,按显示名或 ID 取
ctx.set(name, value)写一个值
ctx.add(name, n)给一个数加上 n
ctx.push(name, item)往一个列表里加一项
ctx.toast(text)给玩家弹一个提示
ctx.say(text)以玩家身份发一句话
ctx.callAi(ai, input?)调用卡里的某个 AI;resolve 的结果和 api.callAi 一样
ctx.random(a, b)从 a 到 b 的一个整数,包含两端
ctx.event{ reactionId }:触发的是哪条行为

代码运行期间的写入会先攒着,跑完后一起保存(通过 patchVariables)。如果代码抛了错,错误会打到浏览器控制台,出错之前写的值照样会保存。

js
// 从牌堆里抽两张牌
const deck = [...(ctx.get("deck") || [])]
const hand = []
for (let i = 0; i < 2 && deck.length; i++) {
  hand.push(deck.splice(ctx.random(0, deck.length - 1), 1)[0])
}
ctx.set("deck", deck)
ctx.set("hand", hand)
ctx.toast("You drew " + hand.join(" and "))

useAssetFont() ​

把上传的字体素材加载成一个 @font-face,返回一个可以直接放进 CSS font-family 的字符串。

tsx
const fontFamily = useAssetFont("@asset:my-font-id", {
  family: "Cinzel",
  fallback: "serif",
})
return <div style={{ fontFamily }}>Ancient runes</div>

签名 ​

ts
useAssetFont(
  assetRef: string | null | undefined,
  options?: AssetFontOptions
): string

字体是异步加载的。加载期间这个 hook 返回 options.fallback(默认 "serif");加载好后会触发一次重新渲染,返回完整的 family 字符串(带一个后缀,避免重名)。

AssetFontOptions ​

字段类型说明
family?string字体族名。不传就从文件名或 assetRef 推断
fallback?string加载期间显示的备用字体。默认 "serif"
filename?string | null原始文件名,用来猜格式
mimeType?string | nullMIME 类型,用来猜格式
format?"opentype" | "truetype" | "woff" | "woff2" | null明确指定格式
weight?string | numberfont-weight
style?stringfont-style(比如 "italic")
stretch?stringfont-stretch
display?FontDisplayfont-display(默认 "swap")

类型 ​

SandboxMessage ​

api.messages 里每一项的结构:

ts
interface SandboxMessage {
  id: string
  sessionId: string
  role: "user" | "assistant" | "system"
  content: string
  status?: "complete" | "streaming" | "failed"
  errorMessage?: string | null
  stateChanges?: Record<string, unknown> | null   // 这条消息带来的变量更新差异
  stateSnapshot?: Record<string, unknown> | null  // 生成这条消息时的完整状态
  swipes?: Array<{ content?, rawContent?, createdAt, model?, tokenCount?, refusal? }>
                                                  // AI 的备选回复;只有当前那一个保留文字
  activeSwipeIndex?: number
  model?: string | null
  tokenCount?: number | null
  generationTimeMs?: number | null
  creditCost?: number | null
  compacted?: boolean                   // 藏在「更早的消息」区域里
  attachments?: Array<{ type, mimeType, name, url }> | null
  createdAt: string                     // ISO-8601
}

在沙盒里,swipe 不带自己的 stateSnapshot,而且当前以外的 swipe 没有 content 和 rawContent。

Checkpoint ​

ts
interface Checkpoint {
  id: string
  name: string
  messageCount: number
  createdAt: string   // ISO-8601
}

SandboxEntry ​

一条只读的设定,通过 api.entries 和 api.getEntry() 拿到:

ts
interface SandboxEntry {
  id: string
  name: string
  content: string
  keywords: string[]
  position: number
  section: "system-presets" | "examples" | "chat-history" | "post-history"
  enabled: boolean
  role: string                            // "system" | "character" | "lore" | 等等
  tags?: string[]
  worldbookId?: string                    // 它所属的情境;对应 api.worldbooks 的 ID
  folderId?: string                       // 你把它放进的文件夹(如果有)
  audience?: "ai" | "player" | "both"
  conditions?: Condition[]                // 变量条件(如果它是条件设定)
  conditionLogic?: "all" | "any"
  portrait?: string | null                // 角色立绘,绝对 URL
  portraitVideo?: { idle: string | null; speaking: string | null } | null
}

这是引擎内部 WorldEntry 的精简版,只保留卡在拼提示词和显示时需要的字段。运行时已经过滤掉禁用的设定,并按 position 排好序,卡里不用自己再做。

BranchContext ​

ts
interface BranchNode {
  id: string
  name: string | null
  parentSessionId: string | null
  branchedFromMessageId: string | null
  messageCount: number
  updatedAt: string   // ISO-8601
  createdAt: string   // ISO-8601
}

interface BranchContext {
  current: BranchNode          // 你所在的会话
  parent: BranchNode | null    // 你分出来的那个分支,在根上时为 null
  siblings: BranchNode[]       // 从同一个父分支分出来的其它分支,按时间从早到晚
  children: BranchNode[]       // 从 `current` 分出来的分支,按时间从早到晚
}

被屏蔽的浏览器 API ​

你的代码跑在一个跨源的 sandbox="allow-scripts" iframe 里,没有 allow-same-origin,内容安全策略里是 connect-src 'none'。这意味着:

  • 访问不到宿主应用的 cookie / localStorage
  • 不能自己发网络请求(fetch、XMLHttpRequest、WebSocket)
  • 不能直接操作 window.parent

有些浏览器 API 被沙盒替换掉了,这样旧代码不会崩:

替换后的行为 ​

你写的实际发生什么
fetch('/api/...')宿主不处理它:promise resolve 成一个不带任何数据的空响应。请用下面的 SDK 方法
fetch('/cdn/...')交给浏览器,被浏览器拦下(connect-src 'none')。要读素材的字节,用 api.fetchAsset()
fetch('任何其它 URL')拒绝(抛错)
localStorage.getItem/setItem/removeItem/clear写入会经由宿主按世界保存,但 getItem 只能看到页面加载以后写的值。要读已保存的值,用 api.storage
sessionStorage.*同上
navigator.clipboard.writeText()等同于 api.copyToClipboard()
navigator.clipboard.readText() / read() / write()拒绝(抛错)
window.location.pathname / href / assign / replace一个模拟的对象;pathname 永远是 /app/chat/{sessionId};赋值、调用 assign / replace 会触发跳转
window.location.reload()转成重新加载这一局
window.__yuminaToggleImmersive()等同于 api.toggleImmersive()

改用 SDK ​

别这样写这样写
fetch('/api/sessions', { method: 'POST' })api.createSession(worldId)
fetch('/api/sessions/' + sid, { method: 'DELETE' })api.deleteSession(sid)
localStorage.getItem("k")await api.storage.get("k")
window.location = "/app/hub"api.navigate("/app/hub")
navigator.clipboard.writeText(t)api.copyToClipboard(t)

可以直接用的浏览器 API ​

不碰网络、也不碰宿主源的东西,和在普通浏览器里一样能用,不需要 SDK 包装:

API在卡里的常见用途
<input type="file"> + FileReader读取玩家选的文件。要保留玩家的图片,用 api.media 上传,见 配方:玩家上传的图片
URL.createObjectURL / revokeObjectURL给一个 Blob 生成临时的内存 URL(比如保存前预览)
<canvas> + getContext("2d") + toDataURL / toBlob上传前缩放、裁剪或合成图片
<img>、<audio>、<video>显示 https: URL、@asset:... 引用、data:/blob: URL
<iframe>嵌入托管在 *.pages.dev、*.vercel.app、*.netlify.app 或 *.github.io 上的页面
IntersectionObserver、ResizeObserver、matchMedia、requestAnimationFrame标准的布局 / 动画基础功能
crypto.randomUUID、crypto.subtle给客户端状态做哈希和生成 ID
WebAudio(AudioContext)轻量的声音合成或分析
指针锁定第一人称操控(requestPointerLock)

一览:整个 API ​

一张表,扫一眼就行。

useYumina()
├── 状态读取
│   ├── variables, globalVariables
│   ├── worldName, worldId, worldCover, background, sessionId
│   ├── currentUser(账号), user(跟着人设走)
│   ├── mode, capabilities, language
│   ├── messages, hasEarlierMessages, isLoadingEarlier
│   ├── isStreaming, streamingContent, streamingReasoning
│   ├── pendingChoices, error, errorCode, sendFailureNonce, composerSendKey
│   ├── readOnly, greetingContent, canvasMode, speakerBubbles
│   ├── checkpoints
│   ├── entries, worldbooks, loreUiBindings
│   └── selectedModel, userPlan, preferredProvider, balance
├── 游戏动作
│   ├── sendMessage(text, attachments?)
│   ├── pickChatImage() → Promise<image | null>
│   ├── setVariable(id, value)
│   ├── patchVariables(values) → Promise<void>
│   ├── executeAction(actionId)
│   ├── executeActionAndWait(actionId, params?) → Promise<{applied, variables, firedIds}>
│   ├── callAi(ai, input?) → Promise<{text, fields, fallback} | null>
│   ├── onAiOutput(channel?, cb) → unsubscribe
│   ├── setScene(scene, { events? })
│   ├── onStoryEvent(cb) → unsubscribe
│   ├── switchGreeting(index) → Promise<void>
│   ├── clearPendingChoices()
│   └── setComposerDraft(text)              // 预填,不发送
├── 聊天控制
│   ├── editMessage(id, content, options?) → Promise<boolean>
│   ├── deleteMessage(id) → Promise<boolean>
│   ├── regenerateMessage(id)
│   ├── continueLastMessage()
│   ├── stopGeneration()
│   ├── restartChat()
│   ├── swipeMessage(id, direction) → Promise
│   └── loadEarlierMessages() → Promise<boolean>
├── 会话 / 分支
│   ├── revertToMessage(id) → Promise<void>
│   ├── branchFromMessage(id) → Promise<string | null>
│   ├── getBranchContext() → Promise<BranchContext>
│   ├── createSession(worldId) → Promise<string>
│   ├── deleteSession(id) → Promise<void>
│   └── listSessions(worldId) → Promise<Array>
├── 存档点
│   ├── saveCheckpoint() → Promise<void>
│   ├── loadCheckpoints() → Promise<void>
│   ├── restoreCheckpoint(id) → Promise<void>
│   └── deleteCheckpoint(id) → Promise<void>
├── 音频
│   ├── playAudio(trackId, opts?)
│   ├── stopAudio(trackId?, fadeDuration?)
│   ├── pauseAudio(trackId)
│   ├── resumeAudio(trackId)
│   ├── onAudioEnded(cb) → unsubscribe
│   ├── setAudioVolume(type, volume)
│   └── getAudioVolume(type) → number
├── 语音
│   ├── tts.speak / stop / onPlaybackFrame / setPrefs / preview, ttsState
│   ├── voice.prepare / record / stop / cancel / setPrefs, voiceInputState
│   └── realtimeVoice.getConfig / finish / prepare / start / interrupt / stop / setMuted / updateContext /
│       updateInstructions / reactToScene / cancelSceneReaction / resolveTool /
│       setSpatial / onEvent
├── UI / 导航
│   ├── toggleImmersive(), exitFullscreen(), isFullscreen(), backToYumina()
│   ├── openPersonaManager(), getPersonaProfile() → Promise
│   ├── openModelPicker(options?), openSessionManager(), sharePlaythrough()
│   ├── openSupport() → Promise<{opened, reason}>, openCreditTopUp()
│   ├── fetchAsset(ref) → Promise<{ok, bytes?, contentType?, error?}>
│   ├── copyToClipboard(text)
│   ├── navigate(path)
│   └── showToast(message, type?)
├── 存储
│   ├── storage.get(key) → Promise<string | null>
│   ├── storage.set(key, value) → Promise<void>
│   └── storage.remove(key) → Promise<void>
├── 云端会话 JSON
│   ├── sessionStorage.get(key) → Promise<{value, version, exists}>
│   ├── sessionStorage.set(key, value, {expectedVersion}) → Promise<{value, version, exists}>
│   └── sessionStorage.remove(key, {expectedVersion}) → Promise<{value, version, exists}>
├── 会话图片
│   ├── media.list(offset?) → Promise<{items, hasMore, uploadsEnabled}>
│   ├── media.pick(options?) → Promise<{mediaId, entryId} | null>
│   ├── media.upload(file, options?) → Promise<{mediaId, entryId}>
│   └── media.remove(entryId, version) → Promise<{removed}>
├── 设定
│   ├── entries (ReadonlyArray<SandboxEntry>)  // 按 position 排序,只含启用的
│   ├── worldbooks
│   └── getEntry(name) → SandboxEntry | null
├── AI
│   ├── ai.complete({ messages, onDelta?, model?, maxTokens?, temperature?, context?, worldbookIds?, includeLorebook?, responseFormat? }) → Promise<string>
│   │    // includeLorebook: true | "all" | "matched",自动注入世界设定
│   ├── ai.decide({ state, questions }) → Promise<{ answers }>
│   └── ai.context({ actor, model?, recentMessages? }) → Promise<{ instructions, receipt }>
├── 上下文注入
│   └── injectContext(message, { role? })
├── 模型选择器
│   ├── setModel(modelId), setPreferredProvider(provider) → Promise
│   ├── getModels(provider?) → Promise<{ models, pinnedModels, recentlyUsed }>
│   ├── pinModel(id), unpinModel(id) → Promise<{ pinnedModels, accepted }>
│   └── modelFallback
├── 素材
│   └── resolveAssetUrl(ref) → string
└── Markdown
    └── renderMarkdown(text) → string   // 安全的 HTML

沙盒全局变量(不用 import)
├── React
├── useYumina, useAssetFont
├── Icons  (所有 Lucide 图标)
├── Chat, MessageList, MessageInput, ChatCanvas(Chat 的别名)
├── ModelTrigger, ModelPickerModal, SessionMemoryModal
├── LoreSlot, LoreButton, LoreSwitch, LoreGroup, LorePanel
└── Tailwind 工具类(CSS 层面)

行为代码(ctx)
└── vars, get, set, add, push, toast, say, callAi, random, event

被屏蔽 / 被替换
├── fetch(...) → 被屏蔽;用 SDK 方法 / fetchAsset
├── localStorage / sessionStorage → 只能写穿;用 api.storage 读
├── window.location → 模拟对象 + navigate
└── navigator.clipboard → copyToClipboard

可以直接用的浏览器 API
├── <input type="file"> + FileReader      // 然后 api.media.upload
├── <canvas>, URL.createObjectURL          // 图片处理
├── IntersectionObserver, ResizeObserver, matchMedia, rAF
├── crypto.randomUUID, crypto.subtle
└── WebAudio (AudioContext)

接下来:自定义 UI 指南 里有完整的例子,配方 里有可以直接拿来改的完整搭建。