自定义 UI (rootComponent)
rootComponent 是一个 React/TSX 文件的虚拟文件系统,运行在沙箱化的 iframe 中。它提供世界的视觉层,拥有对游戏状态、聊天控制、会话管理、AI 补全和音频的完整访问权限。
rootComponent Schema
{
"rootComponent": {
"id": "uuid",
"name": "My World UI",
"entryFile": "index.tsx",
"files": {
"index.tsx": "export default function App() { ... }",
"bubble.tsx": "export default function Bubble({ content, role }) { ... }"
},
"updatedAt": "2024-01-01T00:00:00Z"
}
}入口文件
index.tsx 必须导出一个默认组件:
export default function App() {
return <Chat />;
}自定义消息气泡:
import Bubble from "./bubble";
export default function App() {
return <Chat renderBubble={Bubble} />;
}完整应用模式:
export default function App() {
var api = useYumina();
return (
<div style={ {display: "flex", flexDirection: "column", height: "100vh"} }>
<header>HP: {api.variables.health}</header>
<MessageList />
<MessageInput />
</div>
);
}useYumina() — 完整 API 参考
状态读取
interface SandboxedYuminaAPI {
// 游戏状态
variables: Record<string, unknown>;
globalVariables: Record<string, unknown>;
// 世界信息
worldName: string;
worldId: string;
sessionId: string;
// 用户身份
currentUser: { id: string; name?: string; image?: string | null } | null;
user: { name: string; avatar: string | null }; // 感知人设
// 聊天状态
messages: SandboxMessage[];
isStreaming: boolean;
streamingContent: string;
streamingReasoning: string;
pendingChoices: string[];
error: string | null;
readOnly: boolean;
// 知识库
entries: ReadonlyArray<SandboxEntry>;
getEntry(name: string): SandboxEntry | null;
// 会话
checkpoints: Array<{ id: string; name: string; messageCount: number; createdAt: string }>;
greetingContent: string | null;
mode: "session" | "guest-preview";
capabilities: {
canSendMessage: boolean;
canPersistSession: boolean;
canUseSessionApis: boolean;
requiresAuth: boolean;
};
// UI 状态
canvasMode: "chat" | "custom" | "fullscreen";
selectedModel: string;
userPlan: string;
preferredProvider: "official" | "private";
language: string;
// 音量不作为属性直接暴露 —— 用以下方法读取:
// getAudioVolume("bgm"): number
// getAudioVolume("sfx"): number
}聊天动作
type ChatImageInput = { type: "image"; mimeType: string; name: string; data: string };
sendMessage(text: string, attachments?: ChatImageInput[]): void;
editMessage(messageId: string, content: string): Promise<boolean>;
deleteMessage(messageId: string): Promise<boolean>;
regenerateMessage(messageId: string): void;
continueLastMessage(): void;
stopGeneration(): void;
restartChat(): void;
swipeMessage(messageId: string, direction: "left" | "right"): Promise<Record<string, unknown>>;
setComposerDraft(text: string): void;
clearPendingChoices(): void;会话管理
revertToMessage(messageId: string): Promise<void>;
branchFromMessage(messageId: string): Promise<string | null>;
getBranchContext(): Promise<BranchContext>;
createSession(worldId: string): Promise<string>;
deleteSession(sessionId: string): Promise<void>;
listSessions(worldId: string): Promise<Array<Record<string, unknown>>>;
navigate(path: string): void;存档点
saveCheckpoint(): Promise<void>;
loadCheckpoints(): Promise<void>;
restoreCheckpoint(checkpointId: string): Promise<void>;
deleteCheckpoint(checkpointId: string): Promise<void>;AI 补全
ai.complete(params: {
messages: Array<{
role: string;
content: string | Array<{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }>;
attachments?: ChatImageInput[];
}>;
onDelta?: (text: string) => void;
model?: string;
maxTokens?: number;
temperature?: number;
includeLorebook?: boolean | "all" | "matched";
}): Promise<string>;直接调用 LLM,支持可选的流式输出和知识库注入。适用于 NPC 生成器、动态描述、提示系统,或主聊天流程之外的任何 AI 逻辑。
两个 API 的用户消息都可以通过 attachments 传入图片。data 使用纯 base64(移除 data:...;base64, 前缀);sendMessage("", attachments) 可以只发图片。每次请求最多四张 PNG/JPEG/WebP/GIF,每张不超过 8 MB,合计不超过 16 MB。ai.complete 也接受按顺序排列的 text/image_url 内容;图片 URL 仅支持 data URL 或 Yumina 公开的 /cdn/key/ URL。仅渲染 <img> 或把图片地址写进普通文字,不会把图片传给模型。请选择支持图片输入的模型,处理失败并保留草稿。supportsImages 仅在能力已知时提供,缺少这个字段不代表支持读图。
游戏动作
setVariable(id: string, value: unknown, options?: {
scope?: string;
targetUserId?: string;
}): void;
executeAction(actionId: string): void;
injectContext(message: string, options?: { role?: "system" | "user" }): void;音频
playAudio(trackId: string, opts?: {
volume?: number;
fadeDuration?: number; // 秒
chainTo?: string;
maxDuration?: number; // 秒
duckBgm?: boolean;
loop?: boolean; // 覆盖该音轨的循环设置,仅对本次播放生效
}): void;
stopAudio(trackId?: string, fadeDuration?: number): void; // fadeDuration 单位为秒;会销毁音频元素
pauseAudio(trackId: string): void; // 原地暂停,保留播放位置
resumeAudio(trackId: string): void; // 继续一个被 pauseAudio 暂停的音轨
onAudioEnded(cb: (trackId: string) => void): () => void; // 非循环音轨播放结束时触发;返回取消订阅函数
setAudioVolume(type: "bgm" | "sfx", volume: number): void;
getAudioVolume(type: "bgm" | "sfx"): number;存储(世界级别,持久化)
storage.get(key: string): Promise<string | null>;
storage.set(key: string, value: string): Promise<void>;
storage.remove(key: string): Promise<void>;UI 控制
toggleImmersive(): void;
openPersonaManager(): void;
getPersonaProfile(): Promise<{ name: string; appearance: string; personality: string; backstory: string; entries?: Array<{ title: string; content: string }> } | null>;
openSupport(): Promise<{ opened: boolean; reason?: "self" | "signed-out" | "unavailable" }>;
fetchAsset(ref: string): Promise<{ ok: boolean; bytes?: ArrayBuffer; contentType?: string; error?: string }>;
switchGreeting(index: number): void;
copyToClipboard(text: string): void;
showToast(message: string, type?: "success" | "error" | "info"): void;
resolveAssetUrl(ref: string): string;
renderMarkdown(text: string): string;getPersonaProfile() 显式导入当前会话所选人设的副本,包含自定义条目,不含私人备注。选择不使用人设时返回 null,失败时 Promise 会拒绝。应在玩家主动选择导入时调用,让玩家检查结果,并按每轮游戏保存副本。已导入的副本需要再次导入才会更新;旧版宿主可能缺少 entries 或此方法。这里的人设条目与世界知识库 api.entries 不同。
模型选择
setModel(modelId: string): void;
getModels(): Promise<{
models: Array<{ id: string; name: string; provider: string; contextLength: number; supportsImages?: boolean }>;
pinnedModels: string[];
recentlyUsed: string[];
}>;
pinModel(modelId: string): void;
unpinModel(modelId: string): void;
setPreferredProvider(provider: "official" | "private"): Promise<{
ok: boolean;
provider?: string;
error?: string;
}>;内置组件
作为全局变量可用——无需导入。import 语句会在编译时被静默剥离,因此两种写法都可以,但组件会被自动注入到作用域中:
// 这些已经在作用域中——直接使用即可:
// Chat, MessageList, MessageInput, ChatCanvas,
// ModelPickerModal, ModelTrigger, useAssetFont, Icons
// import 语句无害(编译时被剥离)但不必要:
// import { Chat } from "yumina/Chat"; ← 可用但不需要Chat Props
interface ChatProps {
renderBubble?: (props: BubbleProps) => React.ReactNode;
className?: string;
children?: React.ReactNode;
}BubbleProps
interface BubbleProps {
contentHtml: string;
content: string;
rawContent: string;
role: "user" | "assistant" | "system";
messageIndex: number;
isStreaming: boolean;
stateSnapshot: Record<string, unknown> | null;
variables: Record<string, unknown>;
renderMarkdown: (text: string) => string;
}SandboxMessage
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: string; stateSnapshot?: Record<string, unknown> | null }>;
activeSwipeIndex?: number;
model?: string | null;
tokenCount?: number | null;
generationTimeMs?: number | null;
compacted?: boolean;
attachments?: Array<{ type: string; mimeType: string; name: string; url: string }> | null;
createdAt: string;
}SandboxEntry
interface SandboxEntry {
id: string;
name: string;
content: string;
keywords: string[];
position: number;
section: "system-presets" | "examples" | "chat-history" | "post-history";
enabled: boolean;
role: string;
tags?: string[];
}全局 API(非 React)
window.yumina // 与 useYumina() 相同的 API
window.yumina.onChange(cb) // 订阅状态变化,返回取消订阅函数
window.yumina.offChange(cb) // 取消订阅
// 同时在 window 上派发 "yumina:statechange" 事件沙箱环境
React 作为全局变量可用(无需导入)。使用 React.useState、React.useEffect 等。
限制
- 不能使用
fetch/XMLHttpRequest - 不能直接使用
localStorage/sessionStorage(请改用storage.*API) - 不能操作
window.location(请使用navigate()) - 不能访问
window.parent - 不能使用
eval/new Function - 不能访问 cookie
兼容性垫片
没有使用 SDK 的旧版世界代码仍然可以使用:
fetch('/api/*')→ 通过父窗口代理,带凭证localStorage/sessionStorage→ 世界级别作用域,代理到父窗口navigator.clipboard.writeText()→ 代理window.location→ 合成对象
样式
Tailwind CSS 在沙箱中完全可用——使用任何工具类(flex、gap-4、text-white、bg-[#1a1a2e] 等)。行内样式同样有效:
// Tailwind 类(推荐)
<div className="flex flex-col gap-4 p-4 bg-[#1a1a2e] text-[#e0e0e0] font-serif">
// 行内样式(也可以)
var style = {
background: "#1a1a2e",
color: "#e0e0e0",
fontFamily: '"Noto Serif SC", serif',
padding: "16px",
};多文件结构
// index.tsx
import StatusPanel from "./status-panel";
import MapView from "./map-view";
export default function App() {
return (
<>
<StatusPanel />
<Chat />
<MapView />
</>
);
}