Files
talemate/talemate_frontend/src/components/TalemateApp.vue
veguAI e3c1c231df Scene browser landing page (#109) (#112)
* Scene browser landing page: full-page home view with scenes tree, project/file/card deletion, prominent import (#109)

* review: anchor scenes dir on talemate.path.SCENES_DIR, share character card spec detection with loader

* docs: scene browser landing page - new Home Screen & Scene Library page, refresh load/import/harness docs and screenshots, update help docs index

* review: refuse deleting the active scene's project, root-anchored cover asset paths, share media-type map, dedupe delete button template

* import dropzone: accept and document .zip scene archives
2026-07-20 00:19:52 +03:00

1893 lines
71 KiB
Vue

<template>
<v-app>
<!-- system bar -->
<v-system-bar>
<v-icon icon="mdi-network-outline"></v-icon>
<span v-for="name in sortedClientNames" :key="name">
<v-fade-transition>
<v-chip v-if="clientStatus[name].recentlyActive" label size="x-small" class="mr-1" variant="text">
<template v-slot:prepend>
<v-progress-circular v-if="clientStatus[name].busy" indeterminate="disable-shrink" :color="(clientStatus[name].busy_bg ? 'secondary' : 'primary')" size="11"></v-progress-circular>
<v-icon v-else color="muted" size="11">mdi-circle-outline</v-icon>
</template>
<span class="ml-1">{{ clientStatus[name].label }}</span>
</v-chip>
</v-fade-transition>
</span>
<v-icon icon="mdi-transit-connection-variant" class="mr-1"></v-icon>
<span v-for="name in sortedAgentNames" :key="name">
<v-fade-transition>
<v-chip v-if="agentStatus[name].recentlyActive" label size="x-small" class="mr-1" variant="text">
<template v-slot:prepend>
<v-progress-circular v-if="agentStatus[name].busy" indeterminate="disable-shrink" :color="(agentStatus[name].busy_bg ? 'secondary' : 'primary')" size="11"></v-progress-circular>
<v-icon v-else color="muted" size="11">mdi-circle-outline</v-icon>
</template>
<span class="ml-1">{{ agentStatus[name].label }}</span>
</v-chip>
</v-fade-transition>
</span>
<v-divider vertical class="ml-1"></v-divider>
<v-chip variant="text" prepend-icon="mdi-progress-helper" v-if="connecting" color="info" size="x-small" class="mr-1" label>connecting</v-chip>
<v-chip variant="text" prepend-icon="mdi-checkbox-blank-circle" v-else-if="connected" color="success" size="x-small" class="mr-1" label>connected</v-chip>
<v-chip variant="text" prepend-icon="mdi-progress-close" v-else color="error" size="x-small" class="mr-1" label>disconnected</v-chip>
<v-divider class="mr-1" vertical></v-divider>
<v-spacer></v-spacer>
<AudioQueue ref="audioQueue" @message-audio-played="onMessageAudioPlayed" />
<v-divider class="ml-2 mr-2" vertical></v-divider>
<span v-if="version !== null" class="text-grey text-caption">v{{ version }}</span>
<span v-if="!ready">
<v-icon icon="mdi-application-cog"></v-icon>
<span class="ml-1">Configuration required</span>
</span>
</v-system-bar>
<!-- app bar -->
<v-app-bar app density="compact">
<v-app-bar-nav-icon v-if="tab !== 'home'" size="small" @click="toggleNavigation('game')">
<v-tooltip activator="parent" location="top">Toggle sidebar</v-tooltip>
<v-icon v-if="sceneDrawer">mdi-arrow-collapse-left</v-icon>
<v-icon v-else>mdi-arrow-collapse-right</v-icon>
</v-app-bar-nav-icon>
<v-app-bar-nav-icon v-if="sceneActive && scene.environment === 'scene'" @click="setEnvCreative(); tab = 'main'" color="highlight6" icon>
<v-tooltip activator="parent" location="top">Change to node editor</v-tooltip>
<v-icon>mdi-chart-timeline-variant-shimmer</v-icon>
</v-app-bar-nav-icon>
<v-app-bar-nav-icon v-else-if="sceneActive && scene.environment === 'creative'" @click="requestNodeEditorExit" color="highlight4" icon>
<v-tooltip activator="parent" location="top">Exit node editor</v-tooltip>
<v-icon>mdi-exit-to-app</v-icon>
</v-app-bar-nav-icon>
<v-tabs v-model="tab" color="primary">
<v-tab v-for="tab in availableTabs" :key="tab" :value="tab.value" @click.stop="tab.click">
<v-icon class="mr-1">{{ tab.icon() }}</v-icon>
{{ tab.title() }}
<v-icon v-if="tab.alert && tab.alert()" size="x-small" color="warning" class="ml-1">mdi-alert</v-icon>
</v-tab>
</v-tabs>
<v-spacer></v-spacer>
<DirectorConsoleWidget :scene-active="sceneActive" @open-director-console="toggleNavigation('directorConsole')" />
<VoiceLibrary :scene-active="sceneActive" :scene="scene" :app-busy="busy" :app-ready="ready" v-if="agentStatus.tts?.available"/>
<VisualLibrary ref="visualLibrary" :scene-active="sceneActive" :scene="scene" :app-busy="busy" :app-ready="ready" :agent-status="agentStatus" :world-state-templates="worldStateTemplates"/>
<v-tooltip text="Help" location="top">
<template v-slot:activator="{ props }">
<v-app-bar-nav-icon @click="toggleNavigation('help')" v-bind="props"><v-icon>mdi-help-circle-outline</v-icon></v-app-bar-nav-icon>
</template>
</v-tooltip>
<v-tooltip text="Debug Tools" location="top">
<template v-slot:activator="{ props }">
<v-app-bar-nav-icon @click="toggleNavigation('debug')" v-bind="props"><v-icon>mdi-bug</v-icon></v-app-bar-nav-icon>
</template>
</v-tooltip>
<v-tooltip text="Settings" location="top">
<template v-slot:activator="{ props }">
<v-app-bar-nav-icon @click="openAppConfig()" v-bind="props"><v-icon>mdi-cog</v-icon></v-app-bar-nav-icon>
</template>
</v-tooltip>
<v-tooltip text="Clients / Agents" location="top">
<template v-slot:activator="{ props }">
<v-app-bar-nav-icon @click="toggleNavigation('settings')" v-if="!ready" v-bind="props" color="red-darken-1"><v-icon>mdi-application-cog</v-icon></v-app-bar-nav-icon>
<v-app-bar-nav-icon @click="toggleNavigation('settings')" v-else v-bind="props"><v-icon>mdi-application-cog</v-icon></v-app-bar-nav-icon>
</template>
</v-tooltip>
</v-app-bar>
<!-- removed creative mode toolbar; controls moved into NodeEditor toolbar -->
<v-main style="height: 100%; display: flex; flex-direction: column;">
<!-- left side navigation drawer -->
<v-navigation-drawer :model-value="sceneDrawer && tab !== 'home'" @update:model-value="sceneDrawer = $event" app :width="leftDrawerWidth">
<v-alert v-if="!connected" type="error" variant="tonal">
Not connected to Talemate backend
<p class="text-body-2" color="white">
Make sure the backend process is running.
</p>
</v-alert>
<v-alert type="warning" variant="tonal" v-if="!ready && connected">There are some outstanding configuration issues, please ensure that all enabled agents are configured correctly.</v-alert>
<v-tabs-window v-model="tab">
<v-tabs-window-item :transition="false" :reverse-transition="false" value="main">
<CoverImage v-if="sceneActive" ref="coverImage" type="scene" :target="scene" />
<WorldState v-if="sceneActive" ref="worldState" :busy="busy" @passive-characters="(characters) => { passiveCharacters = characters }" @open-world-state-manager="onOpenWorldStateManager"/>
</v-tabs-window-item>
<v-tabs-window-item :transition="false" :reverse-transition="false" value="world">
<WorldStateManagerMenu v-if="sceneActive"
ref="worldStateManagerMenu"
:scene="scene"
:worldStateTemplates="worldStateTemplates"
:app-busy="busy"
:app-ready="ready"
@world-state-manager-navigate="onOpenWorldStateManager"
/>
</v-tabs-window-item>
<v-tabs-window-item :transition="false" :reverse-transition="false" value="package_manager">
<PackageManagerMenu v-if="sceneActive"
ref="packageManagerMenu"
:scene="scene"
:app-busy="busy"
:app-ready="ready"
/>
</v-tabs-window-item>
<v-tabs-window-item :transition="false" :reverse-transition="false" value="templates">
<TemplatesMenu
ref="templatesMenu"
:templates="worldStateTemplates"
:selected-groups="templatesSelectedGroups"
:selected="templatesSelected"
@navigate-template="onNavigateTemplate"
@update:selectedGroups="templatesSelectedGroups = $event"
@update:selected="templatesSelected = $event"
/>
</v-tabs-window-item>
<v-tabs-window-item :transition="false" :reverse-transition="false" value="prompts">
<PromptsMenu
ref="promptsMenu"
:prompts="promptsViewPrompts"
:recent-templates="recentTemplates"
v-model:active-tab="promptsMainTab"
@navigate-template="onNavigatePromptTemplate"
@open-prompt="onOpenPrompt"
@clear-prompts="onClearPrompts"
/>
</v-tabs-window-item>
</v-tabs-window>
</v-navigation-drawer>
<!-- right side navigation drawer -->
<v-navigation-drawer v-model="drawer" app location="right" width="350" disable-resize-watcher>
<v-alert v-if="!connected" type="error" variant="tonal">
Not connected to Talemate backend
<p class="text-body-2" color="white">
Make sure the backend process is running.
</p>
</v-alert>
<v-alert v-else-if="!ready" type="warning" variant="tonal">
There are some outstanding configuration issues, please ensure that all enabled agents are configured correctly.
</v-alert>
<v-list>
<AIClient ref="aiClient" @save="saveClients" @error="uxErrorHandler" @clients-updated="saveClients" @client-assigned="saveAgents" @open-app-config="openAppConfig" :immutable-config="appConfig" :app-config="appConfig"></AIClient>
<v-divider></v-divider>
<AIAgent ref="aiAgent" @agents-updated="saveAgents" :agentState="agentState" :templates="worldStateTemplates" :app-config="appConfig" :scene="scene"></AIAgent>
<!-- More sections can be added here -->
</v-list>
</v-navigation-drawer>
<!-- director console navigation drawer -->
<v-navigation-drawer v-model="directorConsoleDrawer" app location="right" :width="directorConsoleWidth" disable-resize-watcher>
<DirectorConsole :scene="scene" v-if="sceneActive" :app-busy="busy" :app-ready="ready" :open="directorConsoleDrawer" />
</v-navigation-drawer>
<!-- help chat navigation drawer -->
<v-navigation-drawer v-model="helpDrawer" app location="right" :width="directorConsoleWidth" disable-resize-watcher>
<HelpChat v-if="helpDrawer && connected" :scene-active="sceneActive" :agent-status="agentStatus" :ux-snapshot="buildUxSnapshot" />
</v-navigation-drawer>
<!-- debug tools navigation drawer -->
<v-navigation-drawer v-model="debugDrawer" app location="right" width="400" disable-resize-watcher>
<v-list>
<v-list-subheader class="text-uppercase"><v-icon>mdi-bug</v-icon> Debug Tools</v-list-subheader>
<DebugTools ref="debugTools" :scene="scene" :prompts="promptsViewPrompts" :app-config="appConfig" @clear-prompts="clearPrompts"></DebugTools>
</v-list>
</v-navigation-drawer>
<v-container :class="(sceneActive ? '' : 'backdrop')" fluid style="height: 100%;">
<v-tabs-window v-model="tab" style="height: 100%;">
<!-- HOME -->
<v-tabs-window-item :transition="false" :reverse-transition="false" value="home">
<SceneLanding
ref="sceneLanding"
@request-scene-load="(path) => { resetViews(); $refs.sceneLanding.loadJsonSceneFromPath(path); }"
@loading="sceneStartedLoading"
@open-director="openDirectorWithChat"
:connected="connected"
:scene-loading-available="ready && connected"
:scene-is-loading="loading"
:visible="tab === 'home'"
:world-state-templates="worldStateTemplates"
:config="appConfig" />
</v-tabs-window-item>
<!-- SCENE -->
<v-tabs-window-item :transition="false" :reverse-transition="false" value="main" style="height: 100%;">
<v-row no-gutters class="position-relative">
<v-col ref="nodeEditorContainer" v-resize="onNodeEditorContainerResize" :xl="creativeMode ? (showSceneView ? 8 : 12) : 0" :cols="creativeMode ? (showSceneView ? 6 : 12) : 0" :class="{ 'd-none': !creativeMode }" class="position-relative">
<NodeEditor
:scene="scene"
:busy="busy"
:app-config="appConfig"
:templates="worldStateTemplates"
:is-visible="creativeMode"
:scene-view-visible="showSceneView"
@toggle-scene-view="toggleSceneView"
ref="nodeEditor"
v-if="sceneActive && scene.environment === 'creative'"
>
</NodeEditor>
</v-col>
<v-col :cols="creativeMode ? (showSceneView ? 6 : 0) : 12" :xl="creativeMode ? (showSceneView ? 4 : 12) : 12" :class="{ 'pl-2': true, 'd-none': creativeMode && !showSceneView, 'scene-backdrop-active': !!sceneBackdropSrc }" :style="sceneBackdropStyle">
<div class="scene-column" style="display: flex; flex-direction: column; height: 100%">
<div class="scene-container">
<div v-if="sceneActive && scene.environment === 'creative' && !scene.data.intro">
<v-alert color="muted" class="mb-2" variant="text">
<v-alert-title>New Scene</v-alert-title>
You're editing a new scene. Select the <v-btn @click="onOpenWorldStateManager('scene')" variant="text" size="small" color="primary" prepend-icon="mdi-earth-box">World Editor</v-btn> to add characters and scene details. You are currently operating in the creative environment. Once you have added at least one player character you may switch back and forth between creative and gameplay mode at any point using the <v-icon color="primary" size="small">mdi-gamepad-square</v-icon> button at the bottom.
<p class="mt-4">
You can still use the world editor while in gameplay mode as well.
</p>
</v-alert>
</div>
<div v-show="showSceneView" class="scene-view">
<SceneMessages
ref="sceneMessages"
:appearance-config="effectiveAppearanceConfig"
:ux-locked="uxLocked"
:app-busy="busy"
:agent-status="agentStatus"
:audio-played-for-message-id="audioPlayedForMessageId"
:scene="scene"
@cancel-audio-queue="onCancelAudioQueue"
@configure-entity-highlights="onConfigureEntityHighlights"
@scene-backdrop="sceneBackdropSrc = $event"
@scene-backdrop-candidate="sceneBackdropCandidate = $event"
/>
</div>
<div ref="sceneToolsContainer" class="scene-controls" :class="{ 'scene-controls--locked': uxInteractionActive }">
<AgentActivityBar v-if="appConfig?.game?.general?.show_agent_activity_bar !== false" :agent-status="agentStatus" />
<SceneTools
@open-world-state-manager="onOpenWorldStateManager"
@open-agent-messages="onOpenAgentMessages"
@cancel-audio-queue="onCancelAudioQueue"
:messageInput="messageInput"
:agent-status="agentStatus"
:app-busy="busy"
:app-ready="ready"
:worldStateTemplates="worldStateTemplates"
:playerCharacterName="getPlayerCharacterName()"
:passiveCharacters="passiveCharacters"
:inactiveCharacters="inactiveCharacters"
:scene="scene"
:activeCharacters="activeCharacters"
:visual-agent-ready="visualAgentReady"
:scene-backdrop-candidate="sceneBackdropCandidate"
:audioPlayedForMessageId="audioPlayedForMessageId" />
<SceneMessageInput
ref="sceneMessageInput"
v-model="messageInput"
v-model:act-as="actAs"
:busy="busy"
:ready="ready"
:ux-interaction-active="uxInteractionActive"
:input-disabled="isInputDisabled()"
:waiting-for-input="waitingForInput"
:input-request-info="inputRequestInfo"
:scene-active="sceneActive"
:scene-environment="scene.environment"
:character-colors="scene.data?.character_colors"
:player-character-name="getPlayerCharacterName()"
:active-characters="activeCharacters"
:direction-available="directionAvailable"
@send="onInputSend"
@autocomplete-start="onAutocompleteStart"
@autocomplete-end="onAutocompleteEnd" />
</div>
</div>
</div>
</v-col>
</v-row>
</v-tabs-window-item>
<!-- WORLD -->
<v-tabs-window-item :transition="false" :reverse-transition="false" value="world">
<WorldStateManager
:world-state-templates="worldStateTemplates"
:scene="scene"
:agent-status="agentStatus"
:app-config="appConfig"
:app-busy="busy"
:app-ready="ready"
:visible="tab === 'world'"
:visual-agent-ready="visualAgentReady"
:image-edit-available="imageEditAvailable"
:image-create-available="imageCreateAvailable"
@navigate-r="onWorldStateManagerNavigateR"
@selected-character="onWorldStateManagerSelectedCharacter"
ref="worldStateManager" />
</v-tabs-window-item>
<!-- MODULES -->
<v-tabs-window-item :transition="false" :reverse-transition="false" value="package_manager">
<PackageManager :visible="tab === 'package_manager'" :scene="scene" :app-busy="busy" :app-ready="ready" />
</v-tabs-window-item>
<!-- TEMPLATES -->
<v-tabs-window-item :transition="false" :reverse-transition="false" value="templates">
<Templates
:immutable-templates="worldStateTemplates"
:scene-active="sceneActive"
ref="templates"
@selection-changed="onTemplatesSelectionChanged" />
</v-tabs-window-item>
<!-- PROMPTS -->
<v-tabs-window-item :transition="false" :reverse-transition="false" value="prompts">
<PromptsView :visible="tab === 'prompts'" :prompts="prompts" :agent-status="agentStatus" :app-config="appConfig" ref="promptsView" v-model:main-tab="promptsMainTab" @clear-prompts="onClearPrompts" />
</v-tabs-window-item>
</v-tabs-window>
</v-container>
</v-main>
<AppConfig ref="appConfig" :agentStatus="agentStatus" :sceneActive="sceneActive" :clientStatus="clientStatus" @appearance-preview="onAppearancePreview" @appearance-preview-clear="onAppearancePreviewClear" />
<AgentActionOverrides ref="agentActionOverrides" :app-config="appConfig" :agent-status="agentStatus" />
<v-dialog v-model="worldEditorUnavailable" max-width="420">
<v-card>
<v-card-title>
<v-icon start color="warning">mdi-earth-box-off</v-icon>
World editor unavailable
</v-card-title>
<v-card-text>
The world editor requires a loaded scene. Load or create a scene first.
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" variant="text" @click="worldEditorUnavailable = false">OK</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar v-model="errorNotification" color="red-darken-1" :timeout="3000">
{{ errorMessage }}
</v-snackbar>
</v-app>
<StatusNotification />
<RateLimitAlert ref="rateLimitAlert" />
<AutoRetryAlert ref="autoRetryAlert" />
<GenerationErrorDialog ref="generationErrorDialog" />
<SceneTimeline
ref="sceneTimeline"
:scene="scene"
:appearance-config="effectiveAppearanceConfig"
@load-at-revision="(target) => { resetViews(); $refs.sceneLanding.loadJsonSceneFromPath(target.path, false, target.rev); }"
/>
<VersionMismatchAlert ref="versionMismatchAlert" />
<OnboardingWizard
v-if="connected && appConfig && appConfig.clients"
:clients="Object.values(appConfig.clients || {})"
:agents="Object.values(agentStatus || {})"
@open-client-modal="(preset) => $refs.aiClient.openModal(preset)"
/>
</template>
<script>
import AIClient from './AIClient.vue';
import AIAgent from './AIAgent.vue';
import AgentActivityBar from './AgentActivityBar.vue';
import AgentActionOverrides from './AgentActionOverrides.vue';
import SceneLanding from './SceneLanding.vue';
import SceneTools from './SceneTools.vue';
import SceneMessages from './SceneMessages.vue';
import SceneMessageInput from './SceneMessageInput.vue';
import WorldState from './WorldState.vue';
import CoverImage from './CoverImage.vue';
import AppConfig from './AppConfig.vue';
import DebugTools from './DebugTools.vue';
import AudioQueue from './AudioQueue.vue';
import StatusNotification from './StatusNotification.vue';
import RateLimitAlert from './RateLimitAlert.vue';
import AutoRetryAlert from './AutoRetryAlert.vue';
import GenerationErrorDialog from './GenerationErrorDialog.vue';
import SceneTimeline from './SceneTimeline.vue';
import VersionMismatchAlert from './VersionMismatchAlert.vue';
import { versionsMatch } from '@/constants/version';
import VisualLibrary from './VisualLibrary.vue';
import VoiceLibrary from './VoiceLibrary.vue';
import WorldStateManager from './WorldStateManager.vue';
import WorldStateManagerMenu from './WorldStateManagerMenu.vue';
import NodeEditor from './NodeEditor.vue';
import DirectorConsole from './DirectorConsole.vue';
import DirectorConsoleWidget from './DirectorConsoleWidget.vue';
import HelpChat from './HelpChat.vue';
import PackageManager from './PackageManager.vue';
import PackageManagerMenu from './PackageManagerMenu.vue';
import Templates from './Templates.vue';
import TemplatesMenu from './TemplatesMenu.vue';
import OnboardingWizard from './OnboardingWizard.vue';
import PromptsView from './prompts/PromptsView.vue';
import PromptsMenu from './prompts/PromptsMenu.vue';
// import debounce
import { debounce } from 'lodash';
import { isVisualAgentReady, isImageEditAvailable, isImageCreateAvailable } from '@/constants/visual';
import { createSceneAssetsRequester } from './VisualAssetsMixin.js';
import AutocompleteMixin from './AutocompleteMixin.js';
export default {
components: {
AIClient,
AIAgent,
AgentActivityBar,
AgentActionOverrides,
SceneLanding,
SceneTools,
SceneMessages,
SceneMessageInput,
WorldState,
CoverImage,
AppConfig,
DebugTools,
AudioQueue,
StatusNotification,
VisualLibrary,
WorldStateManager,
WorldStateManagerMenu,
NodeEditor,
DirectorConsole,
RateLimitAlert,
AutoRetryAlert,
GenerationErrorDialog,
SceneTimeline,
VersionMismatchAlert,
DirectorConsoleWidget,
HelpChat,
PackageManager,
PackageManagerMenu,
VoiceLibrary,
Templates,
TemplatesMenu,
OnboardingWizard,
PromptsView,
PromptsMenu,
},
name: 'TalemateApp',
mixins: [AutocompleteMixin],
data() {
return {
appearancePreview: null, // Preview config while editing settings (null = use saved config)
// data-url of the scene illustration acting as the scene backdrop
// (scene.assets.backdrop), reported up by SceneMessages which owns
// the asset cache
sceneBackdropSrc: null,
// asset id of the most recent message-attached background image the
// scene-tools "Immersive" chip promotes when no backdrop is set
sceneBackdropCandidate: null,
tab: 'home',
tabs: [
{
title: () => {
return this.scene.title || 'Untitled Scenario';
},
condition: () => { return this.sceneActive },
icon: () => { return 'mdi-script' },
click: () => {
// on next tick, scroll to the bottom of the message list
this.$nextTick(() => {
this.$refs.sceneMessageInput?.scrollIntoView(false);
});
},
value: 'main'
},
{
title: () => { return 'World Editor' },
condition: () => { return this.sceneActive },
icon: () => { return 'mdi-earth-box' },
click: () => {
this.$nextTick(() => {
this.onOpenWorldStateManager();
});
},
value: 'world'
},
{
title: () => { return 'Mods' },
condition: () => { return this.sceneActive },
icon: () => { return 'mdi-package-variant' },
click: () => {
this.$nextTick(() => {
this.onOpenPackageManager();
});
},
value: 'package_manager'
},
{
title: () => { return 'Templates' },
condition: () => { return true },
icon: () => { return 'mdi-cube-scan' },
click: () => {
// Templates tab clicked
},
value: 'templates'
},
{
title: () => { return 'Prompts' },
condition: () => { return true },
icon: () => { return 'mdi-file-code-outline' },
alert: () => { return this.promptsOutdatedCount > 0 },
click: () => {
// Prompts tab clicked
},
value: 'prompts'
},
{
title: () => { return 'Home' },
condition: () => { return true },
icon: () => { return 'mdi-home' },
click: () => {
// on next tick, scroll to the top
this.$nextTick(() => {
window.scrollTo(0, 0);
});
},
value: 'home'
},
],
version: null,
loading: false,
favicon: null,
sceneActive: false,
drawer: false,
sceneDrawer: true,
debugDrawer: false,
directorConsoleDrawer: false,
helpDrawer: false,
worldEditorUnavailable: false,
websocket: null,
inputDisabled: false,
waitingForInput: false,
inputRequestInfo: null,
connectTimeout: null,
connected: false,
connecting: false,
reconnect: true,
errorMessage: null,
errorNotification: false,
notificatioonBusy: false,
ready: false,
messageInput: '',
reconnectInterval: 3000,
passiveCharacters: [],
inactiveCharacters: [],
activeCharacters: [],
messageHandlers: [],
scene: {},
actAs: null,
appConfig: {},
worldStateTemplates: {},
// busy status of agents
agentStatus: {},
// scene state of agents
agentState: {},
clientStatus: {},
// timestamps for last agent and client updates
// received from the backend
lastAgentUpdate: null,
lastClientUpdate: null,
busy: false,
visualBusyTimer: null,
audioPlayedForMessageId: undefined,
showSceneView: true,
templatesSelectedGroups: [],
templatesSelected: null,
mainTabScrollPosition: null,
// Scene assets requester (initialized when websocket is available)
_sceneAssetsRequester: null,
// Track active UX interaction IDs (for disabling scene controls during UX interactions)
activeUxInteractionIds: [],
// Prompt capture state (moved from PromptsView for always-on capture)
prompts: [],
promptTotal: 0,
maxPrompts: 50,
// Recent templates (pushed from backend)
recentTemplates: [],
// Synced tab state between PromptsMenu and PromptsView
promptsMainTab: 'prompts',
// Count of outdated prompt template overrides
promptsOutdatedCount: 0,
// Flag to ensure new-scene navigation only happens once per scene load
newSceneNavigationPending: false,
}
},
watch:{
availableTabs(tabs) {
// check if tab still exists
// in tabs
// if not select first tab
if(!tabs.find(tab => tab.value == this.tab)) {
// When no scene is loaded, prefer 'home' tab
const homeTab = tabs.find(tab => tab.value === 'home');
this.tab = homeTab ? homeTab.value : tabs[0].value;
}
},
tab: {
handler(newTab, oldTab) {
// Save scroll position when leaving main tab
if(oldTab === 'main' && this.sceneActive) {
this.mainTabScrollPosition = window.scrollY;
}
// Restore scroll position when entering main tab
if(newTab === 'main') {
this.$nextTick(() => {
debounce(this.onNodeEditorContainerResize, 250)();
if(this.sceneActive) {
setTimeout(() => {
if(this.mainTabScrollPosition !== null) {
// Restore saved scroll position
window.scrollTo({ top: this.mainTabScrollPosition, behavior: 'smooth' });
} else if(this.$refs.sceneMessageInput) {
// No saved position, scroll input field into view
this.$refs.sceneMessageInput.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, 100);
}
});
}
}
},
creativeMode: {
handler(newVal, oldVal) {
if (newVal && !oldVal) {
// Switching to creative mode - ensure proper resize
this.$nextTick(() => {
setTimeout(() => {
debounce(this.onNodeEditorContainerResize, 50)();
}, 100);
});
}
}
},
sceneDrawer() {
// debounce onNodeEditorContainerResize
// to prevent resizing the node editor
// too often
debounce(this.onNodeEditorContainerResize, 250)();
},
directorConsoleDrawer() {
// debounce onNodeEditorContainerResize
// to prevent resizing the node editor
// too often
debounce(this.onNodeEditorContainerResize, 250)();
},
drawer() {
// debounce onNodeEditorContainerResize
// to prevent resizing the node editor
// too often
debounce(this.onNodeEditorContainerResize, 250)();
},
debugDrawer() {
debounce(this.onNodeEditorContainerResize, 250)();
},
agentStatus: {
// check if any of the agent's is busy in a blocking manner
// this means agentStatus[agent].busy is true and agentStatus[agent].busy_bg is false
handler: function() {
let actuallyBusy = false;
for(let agent in this.agentStatus) {
if(this.agentStatus[agent].busy && !this.agentStatus[agent].busy_bg) {
actuallyBusy = true;
break;
}
}
if(actuallyBusy) {
// Immediately show busy
this.busy = true;
if(this.visualBusyTimer) {
clearTimeout(this.visualBusyTimer);
this.visualBusyTimer = null;
}
} else {
// Delay clearing busy to prevent flicker
if(!this.visualBusyTimer) {
this.visualBusyTimer = setTimeout(() => {
this.busy = false;
this.visualBusyTimer = null;
}, 800); // 800ms delay prevents most flicker
}
}
},
deep: true,
},
},
computed: {
sceneBackdropStyle() {
if (!this.sceneBackdropSrc) {
return {};
}
return { '--scene-backdrop-image': `url(${this.sceneBackdropSrc})` };
},
creativeMode() {
return this.tab === 'main' && this.sceneActive && this.scene.environment === 'creative';
},
leftDrawerWidth() {
// Wider drawer for prompts tab to match debug tools drawer
return this.tab === 'prompts' ? 400 : 300;
},
promptsViewPrompts() {
// Return the prompts captured at TalemateApp level (always available)
return this.prompts;
},
availableTabs() {
return this.tabs.filter(tab => tab.condition());
},
sortedAgentNames() {
// sort by label
return Object.keys(this.agentStatus).sort((a, b) => {
return this.agentStatus[a].label.localeCompare(this.agentStatus[b].label);
});
},
sortedClientNames() {
// sort by label
return Object.keys(this.clientStatus).sort((a, b) => {
return this.clientStatus[a].label.localeCompare(this.clientStatus[b].label);
});
},
uxLocked() {
// no scene loaded, not locked
if(!this.sceneActive) {
return false;
}
// if loading, ux is locked
if(this.loading) {
return true;
}
// if not waiting for input then ux is locked
if(!this.waitingForInput) {
return true;
}
return false;
},
effectiveAppearanceConfig() {
// Use preview if available, otherwise fall back to saved config
return this.appearancePreview ?? (this.appConfig ? this.appConfig.appearance : {});
},
directorConsoleWidth() {
// based on the screen width, set the width of the director console
const screenWidth = window.innerWidth;
if(screenWidth <= 1920) {
return 400;
} else if(screenWidth <= 2560) {
return 600;
} else {
return 800;
}
},
visualAgentReady() {
return isVisualAgentReady(this.agentStatus);
},
imageEditAvailable() {
return isImageEditAvailable(this.agentStatus);
},
imageCreateAvailable() {
return isImageCreateAvailable(this.agentStatus);
},
uxInteractionActive() {
return this.activeUxInteractionIds.length > 0;
},
// True when scene direction is usable — either the director agent has it
// enabled, or the currently-loaded scene forces it on via the intent
// state override (`direction.always_on`). Mirrors the backend's
// `direction_enabled_with_override` property.
directionAvailable() {
const agentEnabled = this.agentStatus?.director?.actions?.scene_direction?.enabled || false;
const sceneForced = this.scene?.data?.direction_always_on || false;
return agentEnabled || sceneForced;
},
},
mounted() {
this.connect();
this.favicon = document.querySelector('link[rel="icon"]');
},
beforeUnmount() {
// Cleanup scene assets requester (flushes any pending requests)
if (this._sceneAssetsRequester) {
this._sceneAssetsRequester.cleanup({ flush: true });
}
// Close the WebSocket connection when the component is destroyed
if (this.websocket) {
this.reconnect = false;
this.websocket.close();
}
// Drop the asset-load rescroll listener if its window is still open
if (this._assetLoadScrollHandler) {
document.removeEventListener('load', this._assetLoadScrollHandler, true);
this._assetLoadScrollHandler = null;
}
clearTimeout(this._assetLoadScrollTimer);
},
provide() {
return {
getWebsocket: () => this.websocket,
registerMessageHandler: this.registerMessageHandler,
unregisterMessageHandler: this.unregisterMessageHandler,
isInputDisabled: () => this.isInputDisabled(),
setInputDisabled: (disabled) => this.inputDisabled = disabled,
isWaitingForInput: () => this.waitingForInput,
setWaitingForInput: (waiting) => this.waitingForInput = waiting,
isConnected: () => this.connected,
scene: () => this.scene,
getClients: () => this.getClients(),
getAgents: () => this.getAgents(),
openAgentSettings: this.openAgentSettings,
requestSceneAssets: (asset_ids) => this.requestSceneAssets(asset_ids),
requestAssets: (assets) => this.requestAssets(assets),
creativeEditor: () => this.$refs.creativeEditor,
setEnvCreative: () => this.setEnvCreative(),
setEnvScene: () => this.setEnvScene(),
requestAppConfig: () => this.requestAppConfig(),
appConfig: () => this.appConfig,
openAppConfig: this.openAppConfig,
openAgentActionOverrides: () => this.$refs.agentActionOverrides?.open(),
openSceneTimeline: (options) => this.$refs.sceneTimeline.open(options),
configurationRequired: () => this.configurationRequired(),
getTrackedCharacterState: (name, question) => this.$refs.worldState.trackedCharacterState(name, question),
getTrackedCharacterStates: (name) => this.$refs.worldState.trackedCharacterStates(name),
getTrackedWorldState: (question) => this.$refs.worldState.trackedWorldState(question),
getTrackedWorldStates: () => this.$refs.worldState.trackedWorldStates(),
getPlayerCharacterName: () => this.getPlayerCharacterName(),
requestRegenerateLastMessage: () => this.$refs.sceneMessages?.requestRegenerateLastMessage(),
getActAsCharacterName: () => this.actAs || this.getPlayerCharacterName(),
formatWorldStateTemplateString: (templateString, chracterName) => this.formatWorldStateTemplateString(templateString, chracterName),
openVisualLibraryWithAsset: (assetId, initialTab = 'info') => {
if (this.$refs.visualLibrary && typeof this.$refs.visualLibrary.openWithAsset === 'function') {
this.$refs.visualLibrary.openWithAsset(assetId, initialTab);
}
},
addToVisualLibraryPendingQueue: (items) => {
if (this.$refs.visualLibrary && typeof this.$refs.visualLibrary.addToPendingQueue === 'function') {
this.$refs.visualLibrary.addToPendingQueue(items);
}
},
autocompleteRequest: (partialInput, callback, focus_element, delay) => this.autocompleteRequest(partialInput, callback, focus_element, delay),
autocompleteInfoMessage: (active) => this.autocompleteInfoMessage(active),
toLabel: (value) => this.toLabel(value),
openWorldStateManager: this.onOpenWorldStateManager,
requestTemplates: () => this.requestWorldStateTemplates(),
beginUxInteraction: (uxId) => this.beginUxInteraction(uxId),
endUxInteraction: (uxId) => this.endUxInteraction(uxId),
clearUxInteractions: () => this.clearUxInteractions(),
openDebugTools: (tabValue) => {
this.toggleNavigation('debug', true);
this.$nextTick(() => {
if (this.$refs.debugTools && typeof this.$refs.debugTools.selectTab === 'function') {
this.$refs.debugTools.selectTab(tabValue);
}
});
},
callAgentTool: (actionName, args) => this.callAgentTool(actionName, args),
openDirectorConsole: () => this.toggleNavigation('directorConsole', true),
helpChatOpen: () => this.helpDrawer,
openHelpChat: () => this.toggleNavigation('help', true),
navigateToLLMTemplates: () => {
this.tab = 'prompts';
this.$nextTick(() => {
this.promptsMainTab = 'llm-templates';
});
},
};
},
methods: {
isNewScene(sceneObj) {
try {
const data = sceneObj && sceneObj.data ? sceneObj.data : {};
if (data.immutable_save) return false;
return !data.filename;
} catch(e) {
console.error('Error in isNewScene()', e);
return false;
}
},
toggleSceneView(payload) {
const wasVisible = this.showSceneView;
this.showSceneView = !this.showSceneView;
// If shift-click while hiding the scene view, close all drawers
if (wasVisible && payload && payload.shiftKey) {
this.sceneDrawer = false;
this.drawer = false;
this.directorConsoleDrawer = false;
this.debugDrawer = false;
}
this.$nextTick(() => {
debounce(this.onNodeEditorContainerResize, 250)();
});
},
setBusy() {
this.busy = true;
},
setIdle() {
this.busy = false;
},
onNodeEditorContainerResize() {
this.$nextTick(() => {
if(!this.$refs.nodeEditorContainer) {
return;
}
let width = this.$refs.nodeEditorContainer.$el.clientWidth;
if(this.$refs.nodeEditor) {
this.$refs.nodeEditor.resize(width);
}
});
},
connect() {
if (this.connected || this.connecting) {
return;
}
this.connecting = true;
let currentUrl = new URL(window.location.href);
let envWebsocketUrl = import.meta.env.VITE_TALEMATE_BACKEND_WEBSOCKET_URL;
// Check if the env value is a valid URL (not a placeholder like ${VITE_...})
let isValidUrl = envWebsocketUrl && envWebsocketUrl.startsWith('ws');
if (envWebsocketUrl && !isValidUrl) {
console.log("VITE_TALEMATE_BACKEND_WEBSOCKET_URL is set but not a valid WebSocket URL:", envWebsocketUrl);
}
let websocketUrl = isValidUrl ? envWebsocketUrl : `ws://${currentUrl.hostname}:5050/ws`;
console.log("urls", { websocketUrl, currentUrl }, {env : import.meta.env});
this.websocket = new WebSocket(websocketUrl);
console.log("Websocket connecting ...")
this.websocket.onmessage = this.handleMessage;
// Initialize scene assets requester with a sendFn that always uses current websocket
this._sceneAssetsRequester = createSceneAssetsRequester((message) => {
if (this.websocket) {
this.websocket.send(JSON.stringify(message));
}
});
this.websocket.onopen = () => {
console.log('WebSocket connection established');
this.connected = true;
this.connecting = false;
this.requestAppConfig();
this.requestWorldStateTemplates();
this.requestPromptOutdatedCheck();
};
this.websocket.onclose = (event) => {
console.log('WebSocket connection closed', event);
this.connected = false;
this.connecting = false;
this.sceneActive = false;
this.scene = {};
this.loading = false;
if (this.reconnect) {
// Wait for the configured reconnectInterval before trying again to reduce rapid retry loops
setTimeout(() => {
this.connect();
}, this.reconnectInterval);
}
};
this.websocket.onerror = (error) => {
console.log('WebSocket error', error);
// Close the WebSocket connection when an error occurs
this.websocket.close();
this.setNavigation('settings');
};
},
registerMessageHandler(handler) {
this.messageHandlers.push(handler);
},
unregisterMessageHandler(handler) {
this.messageHandlers = this.messageHandlers.filter(h => h !== handler);
},
handleMessage(event) {
const data = JSON.parse(event.data);
this.messageHandlers.forEach(handler => handler(data));
// Handle prompt_sent messages (capture prompts for PromptsMenu)
if (data.type === 'prompt_sent') {
this.handlePromptSent(data.data);
return;
}
// Handle recent_templates push updates
if (data.type === 'prompts' && data.action === 'recent_templates') {
this.recentTemplates = data.data?.templates || [];
return;
}
// Track outdated prompt template overrides
if (data.type === 'prompts' && data.action === 'list_templates') {
const templates = data.data?.templates || [];
this.promptsOutdatedCount = templates.filter(t => t.is_outdated).length;
}
// Scene loaded
if (data.type === "system") {
if (data.id === 'scene.loaded') {
this.loading = false;
this.sceneActive = true;
this.actAs = null;
this.showSceneView = true;
this.mainTabScrollPosition = null; // Reset scroll position memory for new scene
this.newSceneNavigationPending = true; // Allow one-time new-scene navigation on next scene_status
this.clearUxInteractions(); // Clear any active UX interactions when loading a new scene
this.clearPrompts(); // Clear prompts when loading a new scene
this.$refs.generationErrorDialog.closeAll(); // Backend cancelled the outgoing scene's pending generations
this.requestAppConfig();
this.requestWorldStateTemplates();
this.requestPromptOutdatedCheck();
this.$nextTick(() => {
this.tab = 'main';
debounce(this.onNodeEditorContainerResize, 500)();
});
} else if(data.id === 'scene.load_failure') {
this.loading = false;
this.sceneActive = false;
this.actAs = null;
this.scene = {};
this.clearUxInteractions(); // Clear any active UX interactions on load failure
this.$refs.generationErrorDialog.closeAll(); // Backend cancelled the outgoing scene's pending generations
} else if (data.id === 'load_scene_request') {
// Load the requested scene (e.g., after forking)
this.resetViews();
this.$refs.sceneLanding.loadJsonSceneFromPath(data.data.path);
}
if(data.status == 'error') {
this.errorNotification = true;
this.errorMessage = data.message;
}
}
if(data.type == 'status') {
this.notificatioonBusy = (data.status == 'busy');
}
if(data.type === 'agent_status') {
this.setAgentStatus(data);
}
if(data.type === 'client_status') {
this.setClientStatus(data);
}
if(data.type === 'rate_limited') {
this.$refs.rateLimitAlert.open(data.data.client, data.data.reset_time, data.data.rate_limit);
return;
}
if(data.type === 'rate_limit_reset') {
this.$refs.rateLimitAlert.close();
return;
}
if(data.type === 'auto_retry') {
this.$refs.autoRetryAlert.open(data.data);
return;
}
if(data.type === 'auto_retry_done') {
this.$refs.autoRetryAlert.close(data.data.generation_id);
return;
}
if(data.type === 'generation_error') {
// retries exhausted - the dialog supersedes the auto-retry snackbar
this.$refs.autoRetryAlert.close(data.data.generation_id, true);
this.$refs.generationErrorDialog.open(data.data);
return;
}
if (data.type == "scene_status") {
this.scene = {
name: data.name,
title: data.data.title,
environment: data.data.environment,
scene_time: data.data.scene_time,
saved: data.data.saved,
player_character_name: data.data.player_character_name,
data: {...data.data},
}
this.sceneActive = true;
this.inactiveCharacters = data.data.inactive_characters;
// data.data.characters is a list of all active characters in the scene
// collect character.name into list of active characters
this.activeCharacters = data.data.characters.map((character) => character.name);
this.agentState = data.data.agent_state;
this.syncActAs();
if(this.scene.environment === 'scene') {
// always show scene view in scene mode
this.showSceneView = true;
}
// Navigate to world editor scene outline tab for new scenes (once per scene load)
if (this.newSceneNavigationPending && this.isNewScene(this.scene)) {
this.newSceneNavigationPending = false;
this.onOpenWorldStateManager('scene', 'outline');
}
return;
}
if (data.type == "client_status" || data.type == "agent_status") {
this.ready = !this.configurationRequired();
if (!this.ready) {
this.setNavigation('settings');
}
return;
}
if (data.type === 'app_config') {
this.appConfig = data.data;
if(data.version) {
this.version = data.version;
if (!versionsMatch(data.version)) {
this.$refs.versionMismatchAlert.open(data.version);
}
}
return;
}
if (this.handleAutocompleteMessage(data)) {
return;
}
if (data.type === 'request_input') {
this.waitingForInput = true;
this.inputRequestInfo = data;
if (data.data && data.data["input_type"] == "select") {
// If the input_type is 'choice', send the data to SceneMessages
this.$refs.sceneMessages.handleChoiceInput(data);
} else {
// Enable the input field when a request_input message comes in
this.inputDisabled = false;
this.$nextTick(() => {
if (this.$refs.sceneMessageInput)
// Highlight the user text input element when a request_input message comes in
this.$refs.sceneMessageInput.focus();
});
}
}
if (data.type === 'processing_input') {
// Disable the input field when a processing_input message comes in
this.inputDisabled = true;
this.inputRequestInfo = null;
this.waitingForInput = false;
} else if (data.type === "character" || data.type === "system") {
this.scrollInputIntoView();
} else if(data.type == 'world_state_manager') {
if(data.action == 'templates') {
this.worldStateTemplates = data.data;
console.debug("WorldStateTemplates", this.worldStateTemplates);
}
}
},
/**
* Updates the agentStatus object with the latest agent status data
*
* This keeps track of busy and ready status of agents
*
* Called when agent_status messages are received from the backend
*
* @param {Object} data - agent_status message data
*/
setAgentStatus(data) {
this.lastAgentUpdate = new Date().getTime();
// was the agent recently busy?
const recentlyActiveDuration = 5000;
const lastActive = this.agentStatus[data.name] ? this.agentStatus[data.name].lastActive : null;
const busy = data.status === 'busy_bg' || data.status === 'busy';
const wasBusy = !busy && this.agentStatus[data.name] && this.agentStatus[data.name].busy;
const recentlyActive = busy || wasBusy || (this.lastAgentUpdate - (lastActive || 0)) < recentlyActiveDuration;
const recentlyActiveTimeout = this.agentStatus[data.name] ? this.agentStatus[data.name].recentlyActiveTimeout : null;
if(recentlyActiveTimeout) {
clearTimeout(recentlyActiveTimeout);
}
this.agentStatus[data.name] = {
name: data.name,
status: data.status,
busy: busy,
busy_bg: data.status === 'busy_bg',
available: data.status === 'idle' || data.status === 'busy' || data.status === 'busy_bg',
ready: data.status === 'idle',
lastActive: (wasBusy || busy ? this.lastClientUpdate : lastActive),
label: data.message,
// active - has the agent been active in the last 5 seconds?
recentlyActive: recentlyActive,
details: data.client,
meta: data.meta,
actions: data.data.actions,
scene_overrides: data.data.scene_overrides,
}
if(recentlyActive && !busy) {
this.agentStatus[data.name].recentlyActiveTimeout = setTimeout(() => {
this.agentStatus[data.name].recentlyActive = false;
}, recentlyActiveDuration);
}
// if any agents are busy show the browser's native loading spinner in the tab
//
// favicon.ico vs favicon-loading.ico
if(Object.values(this.agentStatus).find(agent => agent.busy)) {
if(this.favicon) {
this.favicon.href = '/favicon-loading.ico';
}
} else {
if(this.favicon) {
this.favicon.href = '/favicon.ico';
}
}
},
/**
* Updates the clientStatus object with the latest client status data
*
* This keeps track of busy and ready status of clients
*
* Called when client_status messages are received from the backend
*
* @param {Object} data - client_status message data
*/
setClientStatus(data) {
this.lastClientUpdate = new Date().getTime();
const recentlyActiveDuration = 15000;
const lastActive = this.clientStatus[data.name] ? this.clientStatus[data.name].lastActive : null;
const busy = data.status === 'busy';
const wasBusy = !busy && this.clientStatus[data.name] && this.clientStatus[data.name].busy;
const recentlyActive = busy || wasBusy || (this.lastClientUpdate - (lastActive || 0)) < recentlyActiveDuration;
const recentlyActiveTimeout = this.clientStatus[data.name] ? this.clientStatus[data.name].recentlyActiveTimeout : null;
if(recentlyActiveTimeout) {
clearTimeout(recentlyActiveTimeout);
}
this.clientStatus[data.name] = {
status: data.status,
busy: busy,
busy_bg: data.status === 'busy_bg',
available: data.status === 'idle' || data.status === 'busy' || data.status === 'busy_bg',
ready: data.status === 'idle',
label: data.name,
name: data.name,
lastActive: (wasBusy || busy ? this.lastClientUpdate : lastActive),
recentlyActive: recentlyActive,
supports_embeddings: data.data.supports_embeddings,
embeddings_status: data.data.embeddings_status,
}
if(recentlyActive && !busy) {
this.clientStatus[data.name].recentlyActiveTimeout = setTimeout(() => {
this.clientStatus[data.name].recentlyActive = false;
}, recentlyActiveDuration);
}
},
onInputSend({ text, actAs }) {
this.websocket.send(JSON.stringify({ type: 'interact', text, act_as: actAs }));
this.inputDisabled = true;
this.waitingForInput = false;
},
scrollInputIntoView() {
const scroll = () => this.$refs.sceneMessageInput?.scrollIntoView(false);
this.$nextTick(scroll);
// Images (character portraits, scene art) load asynchronously and can
// push the input off-screen after our initial scroll. Listen for image
// load events in a short window and re-scroll when they fire. Capture
// phase is required because `load` doesn't bubble.
if (!this._assetLoadScrollHandler) {
this._assetLoadScrollHandler = (e) => {
if (e.target && e.target.tagName === 'IMG') scroll();
};
document.addEventListener('load', this._assetLoadScrollHandler, true);
}
clearTimeout(this._assetLoadScrollTimer);
this._assetLoadScrollTimer = setTimeout(() => {
if (this._assetLoadScrollHandler) {
document.removeEventListener('load', this._assetLoadScrollHandler, true);
this._assetLoadScrollHandler = null;
}
}, 3000);
},
requestWorldStateTemplates() {
this.websocket.send(JSON.stringify({
type: 'world_state_manager',
action: 'get_templates'
}));
},
requestPromptOutdatedCheck() {
this.websocket.send(JSON.stringify({
type: 'prompts',
action: 'list_templates'
}));
},
syncActAs() {
// sets the appropriate actAs
// acting as narrator, narrator is always valid, do nothing
if(this.actAs == "$narrator") {
return;
}
// acting as a character, check if the character is still valid
if(this.actAs && this.activeCharacters.includes(this.actAs)) {
return;
}
// if actAs is null and we have characters, just leave it as null (which will default it to the player character)
if(!this.actAs && this.activeCharacters.length > 0) {
return;
}
// at this point we need a change of actAs so cycle to next option
this.$refs.sceneMessageInput?.cycleActAs();
},
requestAppConfig() {
this.websocket.send(JSON.stringify({ type: 'request_app_config' }));
},
saveClients(clients) {
console.log("saveClients", clients)
const saveData = {}
for(let client of clients) {
saveData[client.name] = {
...client,
}
}
this.websocket.send(JSON.stringify({ type: 'configure_clients', clients: saveData }));
},
saveAgents(agents) {
const saveData = {}
for(let agent of agents) {
console.log("agent", agent)
const requiresLLM = agent.data?.requires_llm_client || false;
let client;
if(requiresLLM) {
if(agent.client?.client) {
client = agent.client?.client?.value;
} else {
client = agent.client || null;
}
} else {
client = null;
}
saveData[agent.name] = {
enabled: agent.enabled,
actions: agent.actions,
client: client,
}
}
console.log("saveAgents",{ saveData, agents })
this.websocket.send(JSON.stringify({ type: 'configure_agents', agents: saveData }));
},
requestSceneAssets(asset_ids) {
if (this._sceneAssetsRequester) {
this._sceneAssetsRequester.request(asset_ids);
}
},
requestAssets(assets) {
this.websocket.send(JSON.stringify({ type: 'request_assets', assets: assets }));
},
setNavigation(navigation) {
if (navigation == "game")
this.sceneDrawer = true;
else if (navigation == "settings")
this.drawer = true;
},
toggleNavigation(navigation, open) {
if (navigation == "game")
this.sceneDrawer = open || !this.sceneDrawer;
else if (navigation == "settings")
this.drawer = open || !this.drawer;
else if (navigation == "debug")
this.debugDrawer = open || !this.debugDrawer;
else if (navigation == "directorConsole")
this.directorConsoleDrawer = open || !this.directorConsoleDrawer;
else if (navigation == "help")
this.helpDrawer = open || !this.helpDrawer;
},
buildUxSnapshot() {
// sent with help chat messages so the help agent knows what the user is looking at
return {
active_tab: this.tab,
open_drawers: [
...(this.sceneDrawer && this.tab !== 'home' ? ['scene'] : []),
...(this.drawer ? ['clients_and_agents'] : []),
...(this.debugDrawer ? ['debug_tools'] : []),
...(this.directorConsoleDrawer ? ['director_console'] : []),
],
scene_active: this.sceneActive,
scene_environment: this.sceneActive ? this.scene?.environment : null,
client_settings_modal: this.$refs.aiClient?.uxSnapshot() || null,
agent_settings_modal: this.$refs.aiAgent?.uxSnapshot() || null,
app_settings_modal: this.$refs.appConfig?.uxSnapshot() || null,
app_ready: this.ready,
waiting_for_input: this.waitingForInput,
};
},
openDirectorWithChat() {
this.toggleNavigation('directorConsole', true);
this.websocket.send(JSON.stringify({ type: 'director', action: 'chat_create' }));
},
returnToStartScreen() {
this.tab = 'home';
},
getClients() {
if (!this.$refs.aiClient) {
return [];
}
return this.$refs.aiClient.state.clients;
},
getAgents() {
if (!this.$refs.aiAgent) {
return [];
}
return this.$refs.aiAgent.state.agents;
},
activeClientName() {
if (!this.$refs.aiClient) {
return null;
}
let client = this.$refs.aiClient.getActive();
if (client) {
return client.name;
}
return null;
},
activeAgentName() {
if (!this.$refs.aiAgent) {
return null;
}
let agent = this.$refs.aiAgent.getActive();
if (agent) {
return agent.label;
}
return null;
},
openAgentSettings(agentName, section) {
this.$refs.aiAgent.openSettings(agentName, section);
},
callAgentTool(actionName, args) {
const dispatch = {
openAppConfig: (...a) => this.openAppConfig(...a),
openAgentSettings: (...a) => this.openAgentSettings(...a),
};
const fn = dispatch[actionName];
if (fn) {
fn(...(args || []));
} else {
console.warn('Unknown agent tool action:', actionName);
}
},
configurationRequired() {
if (!this.$refs.aiClient || this.connecting || (!this.connecting && !this.connected)) {
return false;
}
return this.$refs.aiAgent.configurationRequired();
},
onOpenWorldStateManager(tab, sub1, sub2, sub3) {
// If trying to open templates, redirect to templates tab instead
// (available without a scene)
if (tab === 'templates') {
this.onNavigateTemplate(sub1);
return;
}
if (!this.sceneActive) {
this.worldEditorUnavailable = true;
return;
}
this.tab = 'world';
this.$nextTick(() => {
this.$refs.worldStateManager.show(tab, sub1, sub2, sub3);
});
},
onOpenAgentMessages(agent_name) {
this.$nextTick(() => {
this.$refs.aiAgent.openMessages(agent_name);
});
},
onOpenPackageManager() {
this.tab = 'package_manager';
},
onNavigateTemplate(templateIndex) {
this.tab = 'templates';
this.$nextTick(() => {
if(this.$refs.templates && templateIndex) {
// If templateIndex is a template type (like 'agent_persona'), we can't directly select it
// but we can navigate to templates tab - filtering could be added later if needed
// For now, if it's not a valid template index format, just navigate to templates
this.$refs.templates.selectTemplate(templateIndex);
}
});
},
onNavigatePromptTemplate(template) {
this.$nextTick(() => {
if (this.$refs.promptsView) {
this.$refs.promptsView.navigateToTemplate(template.uid, template.source_group);
}
});
},
onOpenPrompt(prompt) {
this.$nextTick(() => {
if (this.$refs.promptsView) {
this.$refs.promptsView.handleOpenPrompt(prompt);
}
});
},
onClearPrompts() {
this.clearPrompts();
},
onTemplatesSelectionChanged(selection) {
this.templatesSelectedGroups = selection.selectedGroups || [];
this.templatesSelected = selection.selected || null;
},
onWorldStateManagerNavigateR(tab, meta) {
this.$nextTick(() => {
if(this.$refs.worldStateManagerMenu)
this.$refs.worldStateManagerMenu.update(tab, meta);
});
},
onWorldStateManagerSelectedCharacter(character) {
this.$nextTick(() => {
if(this.$refs.worldStateManagerMenu)
this.$refs.worldStateManagerMenu.setCharacter(character)
});
},
onAppearancePreview(previewConfig) {
// Store preview config for live preview while editing
this.appearancePreview = previewConfig;
},
onAppearancePreviewClear() {
// Clear preview when settings dialog closes/cancels/saves
this.appearancePreview = null;
},
onConfigureEntityHighlights() {
// Triggered from the "Configure highlights" affordance in the entity
// tooltip — opens the app config dialog at the scene appearance page.
this.openAppConfig('appearance', 'scene');
},
openAppConfig(tab, page, item=null) {
this.$refs.appConfig.show(tab, page, item);
},
uxErrorHandler(error) {
this.errorNotification = true;
this.errorMessage = error;
},
sceneStartedLoading() {
this.loading = true;
this.sceneActive = false;
this.mainTabScrollPosition = null; // Reset scroll position memory when starting to load a new scene
if(this.$refs.sceneMessages)
this.$refs.sceneMessages.clear();
},
getPlayerCharacterName() {
if (!this.scene || !this.scene.player_character_name) {
return null;
}
return this.scene.player_character_name;
},
isInputDisabled() {
// Lock interaction while a foreground agent is busy. We deliberately
// check agent status (this.busy already excludes busy_bg) rather than
// client status: a background agent operation — e.g. the world state
// snapshot — keeps the shared client busy but must not lock the input.
if (this.busy) {
return true;
}
return this.inputDisabled || this.notificatioonBusy;
},
formatWorldStateTemplateString(templateString, chracterName) {
let playerCharacterName = this.getPlayerCharacterName();
// replace {character_name} and {player_name}
if (playerCharacterName) {
templateString = templateString.replace(/{character_name}/g, chracterName);
templateString = templateString.replace(/{player_name}/g, playerCharacterName);
} else {
templateString = templateString.replace(/{character_name}/g, chracterName);
templateString = templateString.replace(/{player_name}/g, chracterName);
}
return templateString;
},
resetViews() {
if(this.$refs.worldStateManager)
this.$refs.worldStateManager.reset()
},
toLabel(value) {
return value.replace(/[_-]/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
},
setEnvCreative() {
this.websocket.send(JSON.stringify({ type: 'assistant', action: 'set_environment', environment: 'creative' }));
},
setEnvScene() {
this.websocket.send(JSON.stringify({ type: 'assistant', action: 'set_environment', environment: 'scene' }));
},
onMessageAudioPlayed(messageId) {
this.audioPlayedForMessageId = messageId;
},
onCancelAudioQueue() {
this.audioPlayedForMessageId = undefined;
if(this.$refs.audioQueue) {
this.$refs.audioQueue.stopAndClear();
}
},
requestNodeEditorExit() {
this.$refs?.nodeEditor?.requestExitCreative();
},
beginUxInteraction(uxId) {
if (uxId && !this.activeUxInteractionIds.includes(uxId)) {
this.activeUxInteractionIds.push(uxId);
}
},
endUxInteraction(uxId) {
if (uxId) {
const index = this.activeUxInteractionIds.indexOf(uxId);
if (index > -1) {
this.activeUxInteractionIds.splice(index, 1);
}
}
},
clearUxInteractions() {
this.activeUxInteractionIds = [];
},
// Compute character-level prefix match ratio between two strings
computePrefixMatchRatio(a, b) {
const len = Math.min(a.length, b.length);
let i = 0;
while (i < len && a[i] === b[i]) i++;
return a.length > 0 ? i / a.length : 0;
},
// Handle prompt_sent messages (capture prompts for PromptsMenu)
handlePromptSent(data) {
// agent_name here is the verbose display name (from agent_stack); the
// canonical type+action come straight from the server now.
let agent = null;
let agentName = null;
let agentAction = data.agent_action || null;
if (data.agent_stack && data.agent_stack.length > 0) {
agent = data.agent_stack[data.agent_stack.length - 1];
agentName = agent.split('.')[0];
}
// Compute prefix cache ratio against previous prompt with same template_uid + client_name
let prefixCacheRatio = null;
if (data.template_uid && data.client_name && data.prompt) {
const previous = this.prompts.find(
p => p.template_uid === data.template_uid && p.client_name === data.client_name
);
if (previous && previous.prompt) {
prefixCacheRatio = this.computePrefixMatchRatio(data.prompt, previous.prompt);
}
}
this.prompts.unshift({
prompt: data.prompt,
response: data.response,
kind: data.kind,
response_tokens: data.response_tokens,
prompt_tokens: data.prompt_tokens,
agent_stack: data.agent_stack,
agent: agent,
agent_name: agentName,
agent_type: data.agent_type || null,
agent_action: agentAction,
client_name: data.client_name,
client_type: data.client_type,
time: parseInt(data.time),
num: this.promptTotal++,
generation_parameters: data.generation_parameters,
inference_preset: data.inference_preset,
original_generation_parameters: JSON.parse(JSON.stringify(data.generation_parameters)),
original_prompt: data.prompt,
original_response: data.response,
reasoning: data.reasoning,
template_uid: data.template_uid,
prefix_cache_ratio: prefixCacheRatio,
});
// Truncate if exceeds max
while (this.prompts.length > this.maxPrompts) {
this.prompts.pop();
}
},
// Clear prompts
clearPrompts() {
this.prompts = [];
this.promptTotal = 0;
}
}
}
</script>
<style scoped>
.backdrop {
background-image: url('/src/assets/logo-13.1-backdrop.png');
background-repeat: no-repeat;
background-position: center;
background-size: 512px 512px;
}
.backdrop-active {
background-image: url('/src/assets/logo-13.1-backdrop.png');
background-repeat: no-repeat;
background-attachment: fixed;
background-position: center;
background-size: 512px 512px;
}
.logo {
background-image: url('/src/assets/logo-13.1-transparent.png');
background-repeat: no-repeat;
background-position: center;
background-size: fit;
background-color: transparent;
}
.scene-container {
flex-shrink: 0;
max-width: 1600px;
margin: 0 auto;
width: 100%;
overflow-x: auto;
}
/* Scene backdrop: the scene's backdrop image (scene.assets.backdrop) fills
the whole scene column (messages, tools and input); SceneMessages gives
each message a translucent panel for legibility. */
.scene-backdrop-active {
background-image: var(--scene-backdrop-image);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
/* fixed attachment sizes/positions the image against the viewport, not
the scene column - otherwise a long message list stretches the column
and `cover` scales the image to the full scrollback height */
background-attachment: fixed;
}
/* the message input, control chips (auto save, auto progress, agent
activity, ...) and the input's send/autocomplete buttons get solid
backgrounds so they don't fight the backdrop (they are otherwise
transparent/tonal) */
.scene-backdrop-active .scene-controls :deep(.v-field),
.scene-backdrop-active .scene-controls :deep(.v-chip),
.scene-backdrop-active .scene-controls :deep(.scene-message-input-root .v-btn) {
background-color: rgb(var(--v-theme-surface));
}
/* with a backdrop, stretch the scene column to at least the visible
main-area height so a short scene doesn't crop the image; the message
list grows and the tools/input hug the bottom of the screen.
--v-layout-top is set by vuetify on v-main (app bar offset); the 32px
accounts for the v-container's vertical padding. */
.scene-backdrop-active .scene-column {
min-height: calc(100dvh - var(--v-layout-top, 64px) - 32px);
}
.scene-backdrop-active .scene-container {
flex-grow: 1;
display: flex;
flex-direction: column;
}
.scene-backdrop-active .scene-view {
flex-grow: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.scene-controls--locked {
pointer-events: none;
opacity: 0.6;
}
</style>
<style>
/* dialogs opt into this while the help chat drawer is open - shifts them
left so the drawer stays visible and usable next to them */
.v-overlay__content.yield-to-help-drawer {
margin-right: auto;
margin-left: 24px;
}
</style>