v
Skip to content

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 ​

json
{
  "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:

tsx
export default function App() {
  return <Chat />;
}

Con burbujas de mensaje personalizadas:

tsx
import Bubble from "./bubble";

export default function App() {
  return <Chat renderBubble={Bubble} />;
}

Modo de aplicación completa:

tsx
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 ​

typescript
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 ​

typescript
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 ​

typescript
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 ​

typescript
saveCheckpoint(): Promise<void>;
loadCheckpoints(): Promise<void>;
restoreCheckpoint(checkpointId: string): Promise<void>;
deleteCheckpoint(checkpointId: string): Promise<void>;

Completados de IA ​

typescript
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 ​

typescript
setVariable(id: string, value: unknown, options?: {
  scope?: string;
  targetUserId?: string;
}): void;
executeAction(actionId: string): void;
injectContext(message: string, options?: { role?: "system" | "user" }): void;

Audio ​

typescript
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) ​

typescript
storage.get(key: string): Promise<string | null>;
storage.set(key: string, value: string): Promise<void>;
storage.remove(key: string): Promise<void>;

Controles de UI ​

typescript
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 ​

typescript
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:

tsx
// 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 necesario

Props de Chat ​

typescript
interface ChatProps {
  renderBubble?: (props: BubbleProps) => React.ReactNode;
  className?: string;
  children?: React.ReactNode;
}

BubbleProps ​

typescript
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 ​

typescript
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 ​

typescript
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) ​

js
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 window

Entorno Sandbox ​

React está disponible globalmente (no se necesita import). Usa React.useState, React.useEffect, etc.

Restricciones ​

  • Sin fetch / XMLHttpRequest
  • Sin localStorage / sessionStorage directos (usa la API storage.* en su lugar)
  • Sin manipulación de window.location (usa navigate())
  • 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 credenciales
  • localStorage / sessionStorage → con alcance al mundo, proxied al padre
  • navigator.clipboard.writeText() → proxied
  • window.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:

tsx
// 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 ​

tsx
// index.tsx
import StatusPanel from "./status-panel";
import MapView from "./map-view";

export default function App() {
  return (
    <>
      <StatusPanel />
      <Chat />
      <MapView />
    </>
  );
}