UI personalizada (rootComponent)
El rootComponent es un sistema de archivos virtual de archivos React/TSX que se ejecutan en un iframe sandbox. Proporciona la capa visual del mundo y tiene acceso completo al estado del juego, control de chat, gestión de sesión, completados de IA y audio.
Esquema rootComponent
{
"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"
}
}Carga de recursos
rootComponent.assetLoading es opcional. "automatic" (el valor predeterminado al omitirlo) conserva las sugerencias de precarga obtenidas del código, las entradas, el audio y los mensajes. Usa "on-demand" para una interfaz completa que cargue solo los recursos de la etapa actual: desactiva todas las sugerencias genéricas del mundo, incluidas las imágenes del saludo y de los mensajes y el audio. Las URL normales y api.fetchAsset() siguen funcionando. La interfaz debe gestionar la disponibilidad, los errores visibles, los reintentos y la cancelación o liberación de recursos al cambiar de etapa o sesión. Vuelve a "automatic" o elimina la propiedad para restaurar las sugerencias. La reproducción y el comportamiento predeterminado de otros mundos no cambian.
Punto de entrada
index.tsx debe exportar un componente por defecto:
export default function App() {
return <Chat />;
}Con burbujas de mensaje personalizadas:
import Bubble from "./bubble";
export default function App() {
return <Chat renderBubble={Bubble} />;
}Modo de aplicación completa:
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() — Referencia completa de la API
Lecturas de estado
interface SandboxedYuminaAPI {
// Estado del juego
variables: Record<string, unknown>;
globalVariables: Record<string, unknown>;
// Información del mundo
worldName: string;
worldId: string;
sessionId: string;
// Identidad del usuario
currentUser: { id: string; name?: string; image?: string | null } | null;
user: { name: string; avatar: string | null }; // Consciente de la persona
// Estado del chat
messages: SandboxMessage[];
isStreaming: boolean;
streamingContent: string;
streamingReasoning: string;
pendingChoices: string[];
error: string | null;
readOnly: boolean;
// Lorebook
entries: ReadonlyArray<SandboxEntry>;
getEntry(name: string): SandboxEntry | null;
// Sesión
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;
};
// Estado de UI
canvasMode: "chat" | "custom" | "fullscreen";
selectedModel: string;
userPlan: string;
preferredProvider: "official" | "private";
language: string;
// Los volúmenes de audio no se exponen como propiedades directas — léelos vía
// getAudioVolume("bgm"): number
// getAudioVolume("sfx"): number
}Acciones de Chat
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;Gestión de Sesión
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;Checkpoints
saveCheckpoint(): Promise<void>;
loadCheckpoints(): Promise<void>;
restoreCheckpoint(checkpointId: string): Promise<void>;
deleteCheckpoint(checkpointId: string): Promise<void>;Completados de IA
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>;Realiza llamadas LLM crudas con streaming opcional e inyección de lorebook. Úsalo para generadores de NPC, descripciones dinámicas, sistemas de pistas o cualquier lógica de IA fuera del flujo de chat principal.
Los mensajes de usuario pueden incluir imágenes mediante attachments en ambas API. Pasa base64 puro en data, sin el prefijo data:...;base64,; sendMessage("", attachments) envía imágenes sin texto. Se admiten hasta cuatro PNG/JPEG/WebP/GIF por solicitud, 8 MB por imagen y 16 MB en total. ai.complete también acepta partes text/image_url ordenadas; las URL de imagen deben ser data URL o URL públicas /cdn/key/ de Yumina. Mostrar un <img> o escribir su URL como texto no envía la imagen al modelo. Usa un modelo compatible, gestiona los errores y conserva el borrador. supportsImages solo aparece cuando se conoce la capacidad; su ausencia no confirma compatibilidad.
Acciones de Juego
setVariable(id: string, value: unknown, options?: {
scope?: string;
targetUserId?: string;
}): void;
executeAction(actionId: string): void;
injectContext(message: string, options?: { role?: "system" | "user" }): void;Audio
playAudio(trackId: string, opts?: {
volume?: number;
fadeDuration?: number; // segundos
chainTo?: string;
maxDuration?: number; // segundos
duckBgm?: boolean;
loop?: boolean; // sobrescribe el loop de la pista solo para esta reproducción
}): void;
stopAudio(trackId?: string, fadeDuration?: number): void; // fundido en segundos; destruye el elemento
pauseAudio(trackId: string): void; // pausa en el sitio, conserva la posición
resumeAudio(trackId: string): void; // reanuda una pista pausada con pauseAudio
onAudioEnded(cb: (trackId: string) => void): () => void; // se dispara cuando termina una pista no en bucle; devuelve cancelar suscripción
setAudioVolume(type: "bgm" | "sfx", volume: number): void;
getAudioVolume(type: "bgm" | "sfx"): number;Almacenamiento (con alcance al mundo, persistente)
storage.get(key: string): Promise<string | null>;
storage.set(key: string, value: string): Promise<void>;
storage.remove(key: string): Promise<void>;Controles de 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() importa explícitamente una copia de la persona elegida en la sesión, incluidas sus entradas personalizadas y sin notas privadas. Devuelve null para Sin persona y rechaza la promesa si falla. Llámalo cuando el jugador elija importar, permite revisar el resultado y guarda una copia por partida. Una copia ya importada solo cambia al importar de nuevo; los hosts antiguos pueden omitir entries o el método. Estas entradas de persona son distintas del lorebook del mundo, api.entries.
Selección de Modelo
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;
}>;Componentes integrados
Disponibles como globales — no se necesitan imports. Las declaraciones de import se eliminan silenciosamente en tiempo de compilación, así que ambos estilos funcionan, pero los componentes se inyectan automáticamente en el ámbito:
// Estos ya están en el ámbito — solo úsalos directamente:
// Chat, MessageList, MessageInput, ChatCanvas,
// ModelPickerModal, ModelTrigger, useAssetFont, Icons
// Las declaraciones de import son inocuas (se eliminan en compilación) pero innecesarias:
// import { Chat } from "yumina/Chat"; ← funciona pero no es necesarioProps de Chat
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 Global (No-React)
window.yumina // Misma API que useYumina()
window.yumina.onChange(cb) // Suscríbete a cambios de estado, retorna fn de desuscripción
window.yumina.offChange(cb) // Cancela suscripción
// También despacha el evento "yumina:statechange" en windowEntorno Sandbox
React está disponible globalmente (no se necesita import). Usa React.useState, React.useEffect, etc.
Restricciones
- Sin
fetch/XMLHttpRequest - Sin
localStorage/sessionStoragedirectos (usa la APIstorage.*en su lugar) - Sin manipulación de
window.location(usanavigate()) - Sin acceso a
window.parent - Sin
eval/new Function - Sin acceso a cookies
Shims de compatibilidad
El código de mundo antiguo sin el SDK aún puede usar:
fetch('/api/*')→ proxied a través del padre con credencialeslocalStorage/sessionStorage→ con alcance al mundo, proxied al padrenavigator.clipboard.writeText()→ proxiedwindow.location→ objeto sintético
Estilos
Tailwind CSS está completamente disponible en el sandbox — usa cualquier clase de utilidad (flex, gap-4, text-white, bg-[#1a1a2e], etc.). Los estilos inline también funcionan:
// Clases Tailwind (preferido)
<div className="flex flex-col gap-4 p-4 bg-[#1a1a2e] text-[#e0e0e0] font-serif">
// Estilos inline (también está bien)
var style = {
background: "#1a1a2e",
color: "#e0e0e0",
fontFamily: '"Noto Serif SC", serif',
padding: "16px",
};Estructura multi-archivo
// index.tsx
import StatusPanel from "./status-panel";
import MapView from "./map-view";
export default function App() {
return (
<>
<StatusPanel />
<Chat />
<MapView />
</>
);
}