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
This commit is contained in:
veguAI
2026-07-20 00:19:52 +03:00
committed by GitHub
parent 856b4011fa
commit e3c1c231df
29 changed files with 1996 additions and 606 deletions

View File

@@ -11,6 +11,7 @@
- "Client Auto Retry: Clients can now automatically retry on response issues before you are notified — separate 05 retry sliders for empty responses, API rate limiting (HTTP 429, with progressively longer waits between attempts), and reasoning models that skip their reasoning tokens. A notification shows the retry progress with an abort option, and when retries run out (or a slider is at 0, the default) the usual generation error dialog appears."
- "New Node Graph Events: Node graphs can now hook into more of this release's features. `agent.visual.prompt_finalize.before` / `.after` fire around visual prompt finalization (mutable prompts, and the `.before` finalizer list can be modified — they fire even with the agent setting disabled, so a graph can act as its own finalizer). `agent.creator.dialogue_examples.before` / `.after` fire around character example dialogue generation (inject instructions, or rewrite the generated examples). `agent.help.chat.before` / `.after` fire around help chat responses. Scene asset mutations gained `asset_deleted`, `scene.backdrop_changed`, `scene.cover_image_changed` and `character.cover_image_changed` alongside the existing `asset_saved`. All are documented in the node editor's Events reference."
- "Simplified Character Card Import: The character card import dialog gained an AI Generation section with individual toggles for each generation step — content context, description rewrite, attribute extraction, dialogue instructions, example dialogue, and story intent — plus Full/Minimal preset buttons to flip them all at once. Disabled steps fall back to the card's original data (the description and example dialogue import verbatim), so a minimal import needs no text generation at all and completes in a fraction of the time."
- "Scene Browser Landing Page: The home screen was rebuilt into a full-page landing view, replacing the sidebar load panel. A file-tree Scene Library lists scene projects with cover thumbnails, save counts and per-save metadata, plus an asset and node-module summary row per expanded project; long save lists collapse behind a 'Show all' row and the list is filterable with click-to-load. Individual scene files and character cards can be deleted from the tree, and an entire scene project (saves, assets, nodes, history) can be deleted behind a type-the-project-name confirmation. Character cards display their card image and only actual card files are listed. Scene and character card import moved to a prominent drag-and-drop dropzone next to a Create-new-scene card, with the quick-load recent scene cards kept on top."
improvements:
- "Uniform Settings Framework: Agent settings and client settings now share one field-definition schema on the backend and one field renderer on the frontend. Client-specific settings gain the full widget set previously exclusive to agents (sliders, autocompletes, selects with rich choices, per-value notes) plus conditional visibility, and choice lists are delivered to the frontend in a single normalized shape."
- "MistralAI Client: Added a Concurrent Inference toggle so batch operations can dispatch multiple requests in parallel. Off by default."

View File

@@ -1,10 +1,10 @@
# Load a scenario
Once you've set up a client and assigned it to all the agents, you will be presented with the `Home` screen. From here, you can load talemate scenarios and upload character cards.
Once you've set up a client and assigned it to all the agents, you will be presented with the `Home` screen. From here, you can load talemate scenarios, browse the scene library, and import scenes or character cards — see [Home Screen & Scene Library](/talemate/user-guide/scene-browser) for a full tour.
To load the introductory `Infinity Quest` scenario, simply click on its entry in the `Quick Load` section.
To load the introductory `Infinity Quest` scenario, simply click on its entry in the `Quick load` section.
![Load infinity quest](/talemate/img/0.26.0/getting-started-load-screen.png)
![Load infinity quest](/talemate/img/0.39.0/getting-started-load-screen.png)
!!! info "First time may take a moment"
When you load the a scenario for the first time, Talemate will need to initialize the long term memory model. Which likely means a download. Just be patient and it will be ready soon.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 634 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View File

@@ -22,13 +22,15 @@ Character cards can be provided as:
## Starting an Import
Character card import is initiated from the **Load Scene** panel in the left sidebar on the home screen:
Character card import is initiated from the **Import** dropzone on the [home screen](/talemate/user-guide/scene-browser):
![Drag and Drop Upload Area](/talemate/img/0.34.0/character-card-4.png)
![Drag and Drop Upload Area](/talemate/img/0.39.0/character-card-4.png)
1. Drag and drop a character card file (image or JSON), or click to browse
1. Drag and drop a character card file (image or JSON) onto the dropzone, or click it to browse
2. If the file is detected as a character card, the **Character Card Import Options** dialog will automatically open
Cards already stored in your `scenes/characters/` directory can also be imported by clicking them in the **Character Cards** section of the [Scene Library](/talemate/user-guide/scene-browser#character-cards).
![Character Card Import Dialog](/talemate/img/0.34.0/character-card-1.png)
The import dialog will automatically analyze the character card and display detected information.

View File

@@ -17,11 +17,10 @@ Passing every test does not guarantee good creative output, but any failure is a
The harness is a normal Talemate scene and loads the same way as any other — see [Load a scenario](/talemate/getting-started/load-a-scene/) for the general flow.
1. From the **Home** screen, start typing `Model Testing Harness` into the **Search scenes** field.
2. Select the matching entry from the autocomplete.
3. Click **Load**.
1. From the **Home** screen, type `model` into the **Filter scenes** field of the [Scene Library](/talemate/user-guide/scene-browser).
2. Click the `model-testing-harness.json` save file in the **model-testing-harness** project.
![Searching for the Model Testing Harness from the Home screen](/talemate/img/0.37.0/model-testing-harness-load.png)
![Searching for the Model Testing Harness from the Home screen](/talemate/img/0.39.0/model-testing-harness-load.png)
The tests start automatically as soon as the scene finishes loading — there is no separate "run" button to press.

View File

@@ -0,0 +1,73 @@
# Home Screen & Scene Library
The :material-home: **Home** screen is Talemate's landing page. It is where you load, import, create, and manage your scenes.
![Home screen](/talemate/img/0.39.0/getting-started-load-screen.png)
It is made up of four areas:
- **Quick load** — cards for your most recently played scenes
- **Scene Library** — a file browser of every scene project on disk
- **Import** — drag-and-drop import for scenes and character cards
- **Create new scene** — start a blank scene
## Quick load
The **Quick load** row shows your most recently saved scenes as cards. Click a card to load that scene.
Each card has a three-dot menu (⋮) with additional actions:
- :material-history: **Timeline** — browse and restore the scene's version history, see [Restoring Scenes](/talemate/user-guide/restoring-scenes)
- **Remove from Quick Load** — remove the card without deleting any files
- **Delete** — delete the scene file itself
## Scene Library
The **Scene Library** lists every scene project found in your `scenes/` directory as an expandable tree. Each project row shows its cover image, the number of save files it contains, and when it was last saved.
![Scene library](/talemate/img/0.39.0/scene-browser-library.png)
Expanding a project reveals:
- An information row summarizing the project's contents — how many media assets and [node modules](/talemate/user-guide/node-editor/) it holds
- The project's save files, each with its scene name, last-modified date, and file size
Click a save file to load it.
!!! tip "Filtering"
The **Filter scenes** field narrows the tree to matching projects, save files, and character cards. Matching projects expand automatically so you can see the hits.
Projects with many save files show only the ten most recent — click **Show all N saves** to expand the rest.
### Character cards
Character card files stored in `scenes/characters/` are listed in their own **Character Cards** section at the bottom of the library. Image cards display a thumbnail of the card art.
Clicking a card starts a [character card import](/talemate/user-guide/character-card-import), letting you create a new scene from the card.
### Deleting scenes and projects
Each row in the library has a delete action:
- :material-file-remove-outline: on a **save file** or **character card** deletes that single file after a confirmation prompt.
- :material-folder-remove-outline: on a **project** deletes the entire scene project — all of its save files, assets, node modules, and version history.
Deleting a project is irreversible, so the confirmation dialog requires typing the project name before the delete button becomes available:
![Delete scene project dialog](/talemate/img/0.39.0/scene-browser-delete-project.png)
!!! warning
Deleting a scene project removes its whole directory from disk — see [Scene Directory Structure](/talemate/user-guide/scene-directory) for what that includes. There is no undo.
## Import
The **Import** dropzone accepts:
- **Talemate scenes** — `.json` save files, or `.zip` archives [exported from the world editor](/talemate/user-guide/world-editor/scene/export) (a complete scene including its assets and node modules)
- **Character cards** — `.png`, `.webp`, or `.json` card files
Drag a file onto the dropzone or click it to browse. Character cards open the [import options dialog](/talemate/user-guide/character-card-import); Talemate scene files load directly.
## Create new scene
**Create new scene** starts a blank scene. After choosing a name and optional writing style and director persona, the scene opens in the [world editor](/talemate/user-guide/world-editor/) where you can add characters and scene details.

View File

@@ -71,3 +71,5 @@ Understanding the directory structure is helpful for:
- **Manual backups** — you can copy the entire project directory to back up a scene and all its associated data
- **Sharing scenes** — the project directory contains everything needed to share a scene with others
- **Understanding saves** — all saves for a scene are grouped together in one directory, making it clear which files belong together
Scene projects and their save files can be browsed — and deleted — from the [Scene Library](/talemate/user-guide/scene-browser) on the home screen.

View File

@@ -377,9 +377,10 @@
indicator, and using {{ system_prompt }} to append to the default prompt.
- path: user-guide/character-card-import.md
title: Character Card Import
summary: How to import TavernAI-style character cards (Chara Card V0-V3, PNG/JPG/WebP images or JSON) from the Load Scene
panel to create a new scene. Covers the import dialog, character detection/manual selection, options for character book
entries, alternate greetings, shared context setup, writing style template, player character setup, and troubleshooting
summary: How to import TavernAI-style character cards (Chara Card V0-V3, PNG/JPG/WebP images or JSON) from the home screen's
Import dropzone or the Scene Library's Character Cards section to create a new scene. Covers the import dialog, character
detection/manual selection, options for character book entries, alternate greetings, shared context setup, writing style
template, player character setup, and troubleshooting
failed analysis.
- path: user-guide/clients/auto-retry.md
title: Auto Retry
@@ -745,6 +746,12 @@
summary: The scene tools toolbar and agent activity bar. Covers actor actions, director actions, narrator actions, advancing
time, world state actions, creative tools (e.g. taking characters in/out of the scene, introducing new characters with
optional example dialogue generation and guidance), the visualizer menu, and saving, with Ctrl/Cmd modifier click behavior.
- path: user-guide/scene-browser.md
title: Home Screen & Scene Library
summary: 'The home screen landing page: Quick load recent-scene cards, the Scene Library file tree (projects with cover
thumbnails, save files, asset/node-module counts, filtering, Show all saves), deleting save files, character cards, and
entire scene projects (type-name confirmation), the Import dropzone for scenes and character cards, and creating a new
blank scene.'
- path: user-guide/scene-directory.md
title: Scene Directory Structure
summary: Layout of a scene's project directory under scenes/, save .json files, and the assets/, changelog/, backups/, nodes/,

View File

@@ -1,6 +1,29 @@
import datetime
import fnmatch
import json
import os
from talemate.path import SCENES_DIR
# top-level directories inside scenes/ that are not scene projects
RESERVED_SCENES_DIRS = {"assets", "characters"}
# directories inside a scene project that never contain loadable saves
EXCLUDED_PROJECT_SUBDIRS = {"nodes", "changelog", "assets", "info"}
MEDIA_TYPES = {
"png": "image/png",
"webp": "image/webp",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"json": "application/json",
}
CHARACTER_CARD_SPECS = {"chara_card_v1", "chara_card_v2", "chara_card_v3"}
# scene save file metadata cache: path -> (mtime, scene_name, cover_asset_id)
_SCENE_META_CACHE: dict[str, tuple[float, str | None, str | None]] = {}
def list_scenes_directory(path: str = ".", list_images: bool = True) -> list:
"""
@@ -8,10 +31,8 @@ def list_scenes_directory(path: str = ".", list_images: bool = True) -> list:
:param directory: Directory to list scene files from.
:return: List of scene files in the given directory.
"""
current_dir = os.getcwd()
scenes = _list_files_and_directories(
os.path.join(current_dir, "scenes"), path, list_images=list_images
scenes_directory(), path, list_images=list_images
)
return scenes
@@ -57,3 +78,231 @@ def _list_files_and_directories(root: str, path: str, list_images: bool = True)
break
return items
def scenes_directory() -> str:
"""
Absolute path to the scenes directory.
"""
return str(SCENES_DIR)
def list_scenes_tree() -> dict:
"""
Structured listing of the scenes directory for the scene browser.
Returns a dict with:
- projects: scene project directories with their save files (newest first)
- characters: character card files in scenes/characters
"""
root = scenes_directory()
projects = []
characters = []
if not os.path.isdir(root):
return {"projects": [], "characters": []}
for entry in sorted(os.listdir(root)):
full_path = os.path.join(root, entry)
if not os.path.isdir(full_path):
continue
if entry == "characters":
characters = _list_character_cards(full_path)
continue
if entry in RESERVED_SCENES_DIRS:
continue
project = _scene_project_entry(full_path)
if project["files"]:
projects.append(project)
projects.sort(key=lambda project: project["modified"], reverse=True)
return {"projects": projects, "characters": characters}
def _file_entry(path: str, base_dir: str) -> dict:
file_stat = os.stat(path)
return {
"path": path,
"filename": os.path.basename(path),
"relpath": os.path.relpath(path, base_dir),
"size": file_stat.st_size,
"modified": datetime.datetime.fromtimestamp(file_stat.st_mtime).isoformat(),
}
def identify_character_card_spec(data) -> str | None:
"""
The character card spec of the given data, or None if it is not a
character card. Single source of truth for card detection - the loader's
identify_import_spec() builds on this, and the frontend mirrors it.
"""
if not isinstance(data, dict):
return None
spec = data.get("spec")
if spec in CHARACTER_CARD_SPECS:
return spec
if "first_mes" in data:
# original chara card didnt specify a spec
return "chara_card_v0"
if "first_mes" in (data.get("data") or {}):
# fallback for future chara card versions, which are supposed to be
# backwards compatible
return "chara_card_v3"
return None
def is_character_card_data(data) -> bool:
return identify_character_card_spec(data) is not None
def _list_character_cards(characters_dir: str) -> list:
# cards are stored flat in scenes/characters - subdirectories hold
# upload side artifacts (assets, changelog), never cards
cards = []
for filename in sorted(os.listdir(characters_dir)):
path = os.path.join(characters_dir, filename)
if not os.path.isfile(path):
continue
extension = filename.rsplit(".", 1)[-1].lower()
if extension not in ("png", "webp", "json"):
continue
# json files in the characters dir are only cards if they actually
# contain character card data (scene uploads land here too)
if extension == "json":
try:
with open(path) as file:
if not is_character_card_data(json.load(file)):
continue
except (OSError, ValueError):
continue
entry = _file_entry(path, characters_dir)
entry["media_type"] = MEDIA_TYPES[extension]
cards.append(entry)
cards.sort(key=lambda card: card["modified"], reverse=True)
return cards
def _load_asset_library(project_dir: str) -> dict:
library_path = os.path.join(project_dir, "assets", "library.json")
try:
with open(library_path) as file:
return json.load(file).get("assets", {})
except (OSError, ValueError):
return {}
def _resolve_cover_image(
project_dir: str, asset_id: str | None, library: dict
) -> dict | None:
if not asset_id:
return None
asset = library.get(asset_id)
if asset:
file_type = asset.get("file_type", "png")
return {
"id": asset_id,
"file_type": file_type,
"media_type": asset.get("media_type", "image/png"),
"path": os.path.join(project_dir, "assets", f"{asset_id}.{file_type}"),
}
# asset not in the library - probe the assets directory directly
for file_type in ("png", "webp", "jpg", "jpeg"):
asset_path = os.path.join(project_dir, "assets", f"{asset_id}.{file_type}")
if os.path.exists(asset_path):
return {
"id": asset_id,
"file_type": file_type,
"media_type": MEDIA_TYPES[file_type],
"path": asset_path,
}
return None
def _scene_file_meta(file_path: str) -> tuple[str | None, str | None]:
"""
Scene name and cover asset id for a save file, cached by mtime so large
projects don't re-parse every save on each tree request.
"""
mtime = os.stat(file_path).st_mtime
cached = _SCENE_META_CACHE.get(file_path)
if cached and cached[0] == mtime:
return cached[1], cached[2]
scene_name = None
cover_asset_id = None
try:
with open(file_path) as file:
scene_data = json.load(file)
if isinstance(scene_data, dict):
scene_name = scene_data.get("name")
cover_asset_id = (scene_data.get("assets") or {}).get("cover_image")
except (OSError, ValueError):
pass
_SCENE_META_CACHE[file_path] = (mtime, scene_name, cover_asset_id)
return scene_name, cover_asset_id
def _count_project_assets(project_dir: str) -> int:
assets_dir = os.path.join(project_dir, "assets")
try:
entries = os.listdir(assets_dir)
except OSError:
return 0
return sum(
1
for filename in entries
if filename != "library.json"
and os.path.isfile(os.path.join(assets_dir, filename))
)
def _count_project_nodes(project_dir: str) -> int:
count = 0
for _, _, filenames in os.walk(os.path.join(project_dir, "nodes")):
count += sum(1 for filename in filenames if filename.endswith(".json"))
return count
def _scene_project_entry(project_dir: str) -> dict:
library = _load_asset_library(project_dir)
files = []
for dirpath, dirnames, filenames in os.walk(project_dir):
dirnames[:] = [d for d in dirnames if d not in EXCLUDED_PROJECT_SUBDIRS]
for filename in filenames:
if not filename.endswith(".json"):
continue
file_path = os.path.join(dirpath, filename)
entry = _file_entry(file_path, project_dir)
scene_name, cover_asset_id = _scene_file_meta(file_path)
entry["scene_name"] = scene_name
entry["cover_image"] = _resolve_cover_image(
project_dir, cover_asset_id, library
)
files.append(entry)
files.sort(key=lambda entry: entry["modified"], reverse=True)
cover_image = next(
(entry["cover_image"] for entry in files if entry["cover_image"]), None
)
return {
"name": os.path.basename(project_dir),
"path": project_dir,
"modified": files[0]["modified"] if files else None,
"cover_image": cover_image,
"num_assets": _count_project_assets(project_dir),
"num_nodes": _count_project_nodes(project_dir),
"files": files,
}

View File

@@ -13,6 +13,7 @@ import talemate.instance as instance
from talemate import Character, Player
from talemate.character import activate_character
from talemate.exceptions import UnknownDataSpec
from talemate.files import identify_character_card_spec
from talemate.status import LoadingStatus
from talemate.config import get_config
from talemate.util import extract_metadata, select_best_texts_by_keyword, count_tokens
@@ -223,24 +224,9 @@ def identify_import_spec(data: dict) -> ImportSpec:
f"The file may not contain valid character card metadata."
)
if data.get("spec") == "chara_card_v3":
return ImportSpec.chara_card_v3
if data.get("spec") == "chara_card_v2":
return ImportSpec.chara_card_v2
if data.get("spec") == "chara_card_v1":
return ImportSpec.chara_card_v1
if "first_mes" in data:
# original chara card didnt specify a spec,
# if the first_mes key exists, we can assume it's a v0 chara card
return ImportSpec.chara_card_v0
if "first_mes" in data.get("data", {}):
# this can also serve as a fallback for future chara card versions
# as they are supposed to be backwards compatible
return ImportSpec.chara_card_v3
spec = identify_character_card_spec(data)
if spec is not None:
return ImportSpec(spec)
# TODO: probably should actually check for valid talemate scene data
return ImportSpec.talemate

View File

@@ -32,6 +32,7 @@ from talemate.agents.visual.schema import (
Resolution,
AssetAttachmentContext,
)
from talemate.files import MEDIA_TYPES
from talemate.path import SCENES_DIR
async_signals.register(
@@ -122,21 +123,11 @@ def get_media_type_from_extension(file_extension: str) -> str:
Raises:
ValueError: If the file extension is not supported
"""
# Normalize extension to lowercase and ensure it starts with a dot
ext = file_extension.lower()
if not ext.startswith("."):
ext = f".{ext}"
if ext == ".png":
return "image/png"
elif ext in [".jpg", ".jpeg"]:
return "image/jpeg"
elif ext == ".webp":
return "image/webp"
elif ext == ".json":
return "application/json"
else:
raise ValueError(f"Unsupported file extension: {ext}")
ext = file_extension.lower().lstrip(".")
media_type = MEDIA_TYPES.get(ext)
if media_type is None:
raise ValueError(f"Unsupported file extension: .{ext}")
return media_type
def get_media_type_from_file_path(file_path: str) -> str:

View File

@@ -198,6 +198,8 @@ async def websocket_endpoint(websocket):
query = data.get("query", "")
list_images = data.get("list_images", True)
handler.request_scenes_list(query, list_images)
elif action_type == "request_scenes_tree":
handler.request_scenes_tree()
elif action_type == "configure_clients":
await update_config({"clients": data.get("clients")})
await instance.instantiate_clients()

View File

@@ -1,6 +1,7 @@
import pydantic
import structlog
import os
import shutil
from talemate import VERSION
from talemate.changelog import delete_changelog_files, scene_ref_from_path
@@ -18,6 +19,7 @@ from talemate.config.schema import (
)
from talemate.config import get_config, Config, update_config
from talemate.emit import emit
from talemate.files import scenes_directory, RESERVED_SCENES_DIRS
from talemate.instance import emit_clients_status, get_client
from .websocket_plugin import Plugin
@@ -65,6 +67,10 @@ class DeleteScenePayload(pydantic.BaseModel):
path: str
class DeleteSceneProjectPayload(pydantic.BaseModel):
path: str
class SaveUnifiedAPIKeyPayload(pydantic.BaseModel):
config_path: str
api_key: str | None
@@ -446,6 +452,11 @@ class ConfigPlugin(Plugin):
async def handle_delete_scene(self, data):
payload = DeleteScenePayload(**data)
file_path = os.path.realpath(payload.path)
scenes_root = os.path.realpath(scenes_directory())
if not file_path.startswith(scenes_root + os.sep):
raise ValueError(f"Not a scene file: {payload.path}")
await self.handle_remove_scene_from_recents(data)
log.info("Deleting scene", path=payload.path)
@@ -488,6 +499,64 @@ class ConfigPlugin(Plugin):
{"type": "app_config", "data": config.model_dump(), "version": VERSION}
)
async def handle_delete_scene_project(self, data):
payload = DeleteSceneProjectPayload(**data)
project_path = os.path.realpath(payload.path)
scenes_root = os.path.realpath(scenes_directory())
project_name = os.path.basename(project_path)
# only direct children of the scenes directory are scene projects
if (
os.path.dirname(project_path) != scenes_root
or project_name in RESERVED_SCENES_DIRS
):
raise ValueError(f"Not a scene project directory: {payload.path}")
if not os.path.isdir(project_path):
raise ValueError(f"Scene project not found: {payload.path}")
prefix = project_path + os.sep
# the next save of a loaded scene would silently recreate a skeleton
# project with broken asset references, so refuse instead
active_scene = self.scene
if (
active_scene
and active_scene.full_path
and os.path.realpath(active_scene.full_path).startswith(prefix)
):
raise ValueError(
f"Cannot delete the project of the currently loaded scene: {project_name}"
)
log.info("Deleting scene project", path=project_path)
config: Config = get_config()
for recent_scene in list(config.recent_scenes.scenes):
if os.path.realpath(recent_scene.path).startswith(prefix):
config.recent_scenes.scenes.remove(recent_scene)
await config.set_dirty()
shutil.rmtree(project_path)
self.websocket_handler.queue_put(
{
"type": "config",
"action": "delete_scene_project_complete",
"data": {
"path": payload.path,
"name": project_name,
},
}
)
self.websocket_handler.queue_put(
{"type": "app_config", "data": config.model_dump(), "version": VERSION}
)
async def handle_set_agent_action_override(self, data):
payload = SetAgentActionOverridePayload(**data["data"])

View File

@@ -14,7 +14,7 @@ from talemate.config import get_config, Config
from talemate.context import ActiveScene
from talemate.emit import Emission, Receiver, abort_wait_for_input, emit
import talemate.emit.async_signals as async_signals
from talemate.files import list_scenes_directory
from talemate.files import list_scenes_directory, list_scenes_tree
from talemate.load import load_scene, SceneInitialization
from talemate.scene_assets import Asset, get_media_type_from_file_path, VIS_TYPE
from talemate.scene_message import versions_payload_for
@@ -649,6 +649,14 @@ class WebsocketHandler(SceneAssetsBatchingMixin, Receiver):
}
)
def request_scenes_tree(self):
self.queue_put(
{
"type": "scenes_tree",
"data": list_scenes_tree(),
}
)
def request_scene_history(self):
history = [archived_history for archived_history in self.scene.archived_history]

View File

@@ -1,45 +0,0 @@
<template>
<IntroRecentScenes :config="config" :scene-is-loading="sceneIsLoading" :scene-loading-available="sceneLoadingAvailable" @request-scene-load="requestSceneLoad"/>
<WhatsNew />
<v-btn variant="text" class="ml-2" href="https://vegu-ai.github.io/talemate/" target="_blank" rel="noopener">
<v-icon size="x-small" class="mr-1" color="primary">mdi-help-box-multiple-outline</v-icon>
Documentation
</v-btn>
</template>
<script>
import IntroRecentScenes from './IntroRecentScenes.vue';
import WhatsNew from './WhatsNew.vue';
export default {
name: 'IntroView',
components: {
IntroRecentScenes,
WhatsNew,
},
props: {
version: String,
sceneLoadingAvailable: Boolean,
sceneIsLoading: Boolean,
config: Object,
},
emits: ['request-scene-load'],
data() {
return {
changelog: [
"This screen was added",
"Item 2",
"Item 3",
"Item 4",
]
}
},
methods: {
requestSceneLoad(scene) {
this.$emit('request-scene-load', scene);
},
}
}
</script>

View File

@@ -1,485 +0,0 @@
<template>
<v-list density="compact">
<div v-if="isConnected() && sceneLoadingAvailable && appConfig !== null">
<v-list-subheader>
<v-icon class="mr-1" color="primary">mdi-folder</v-icon>
Load
</v-list-subheader>
<v-card variant="text">
<v-card-text>
<v-autocomplete :disabled="loading" v-model="sceneInput" :items="scenes"
label="Search scenes" outlined @update:search="updateSearchInput" item-title="label" item-value="path" :loading="sceneSearchLoading" messages="Load previously saved scenes.">
</v-autocomplete>
<v-btn class="mt-2" variant="tonal" block :disabled="loading" @click="loadScene('path')" append-icon="mdi-folder" color="primary">Load</v-btn>
</v-card-text>
</v-card>
<v-divider class="mt-3 mb-3"></v-divider>
<v-list-subheader>
<v-icon class="mr-1" color="primary">mdi-upload</v-icon>
Upload
</v-list-subheader>
<v-card variant="text">
<v-card-text>
<!-- File input for file upload -->
<div class="fixed-file-input-size">
<v-file-input
:disabled="loading"
prepend-icon=""
v-model="sceneFile"
@update:modelValue="loadScene('file')"
label="Drag and Drop file."
outlined accept="image/*,.json"
variant="solo-filled"
messages="Upload a talemate scene or a character card"
></v-file-input>
</div>
</v-card-text>
</v-card>
<v-divider class="mt-3 mb-3"></v-divider>
<!-- create new scene -->
<v-list-subheader>
<v-icon class="mr-1" color="primary">mdi-plus</v-icon>
Create new scene
</v-list-subheader>
<v-card variant="text">
<v-card-text>
<v-btn class="mt-2" variant="tonal" block :disabled="loading" @click="loadCreative" append-icon="mdi-plus" color="primary">Create</v-btn>
</v-card-text>
</v-card>
</div>
<CharacterCardImport ref="characterCardImportModal" :templates="worldStateTemplates"></CharacterCardImport>
<NewSceneSetupModal v-model="showNewSceneSetup" :templates="worldStateTemplates" @create="createNewScene" />
</v-list>
</template>
<script>
import CharacterCardImport from './CharacterCardImport.vue';
import NewSceneSetupModal from './NewSceneSetupModal.vue';
export default {
name: 'LoadScene',
components: {
CharacterCardImport,
NewSceneSetupModal,
},
props: {
sceneLoadingAvailable: Boolean,
worldStateTemplates: Object,
},
computed: {
cols () {
const { lg, sm } = this.$vuetify.display
return lg ? [2, 2]
: sm ? [4, 4]
: [6, 6]
},
},
data() {
return {
loading: false,
inputMethod: 'path',
sceneFile: [],
sceneInput: '',
scenes: [],
sceneSearchInput: null,
sceneSearchLoading: false,
sceneSaved: null,
expanded: true,
appConfig: null, // Store the app configuration
pendingLoadData: null, // Store pending load data while waiting for import options
pendingJsonPath: null, // Store JSON file path while checking if it's a character card
showNewSceneSetup: false,
pendingAssistWithSetup: false,
}
},
emits: {
loading: null,
'open-director': null,
},
inject: ['getWebsocket', 'registerMessageHandler', 'isConnected'],
methods: {
// Method to show the CharacterCardImport modal
async showCharacterCardImportModal(fileData = null, filePath = null, filename = null) {
const result = await this.$refs.characterCardImportModal.open(fileData, filePath, filename);
return result;
},
toggle() {
this.expanded = !this.expanded;
},
updateSearchInput(val) {
this.sceneSearchInput = val;
clearTimeout(this.searchTimeout); // Clear the previous timeout
this.searchTimeout = setTimeout(this.fetchScenes, 300); // Start a new timeout
},
fetchScenes() {
if (!this.sceneSearchInput)
return
this.sceneSearchLoading = true;
this.getWebsocket().send(JSON.stringify({ type: 'request_scenes_list', query: this.sceneSearchInput, list_images: true }));
},
loadCreative() {
if(this.sceneSaved === false) {
if(!confirm("The current scene is not saved. Are you sure you want to load a new scene?")) {
return;
}
}
this.showNewSceneSetup = true;
},
createNewScene(payload) {
this.loading = true;
this.pendingAssistWithSetup = payload.assistWithSetup;
this.getWebsocket().send(JSON.stringify({
type: 'load_scene',
file_path: "$NEW_SCENE$",
scene_initialization: {
project_name: payload.sceneName || null,
writing_style_template: payload.writingStyle || null,
agent_persona_templates: { director: payload.directorPersona || null },
},
}));
},
loadCanceled() {
console.log("Load canceled");
this.loading = false;
this.sceneFile = [];
},
// Helper function to detect if JSON content is a character card
isCharacterCardJson(jsonData) {
if (!jsonData || typeof jsonData !== 'object') {
return false;
}
// Check for character card spec versions
const spec = jsonData.spec;
if (spec === "chara_card_v1" || spec === "chara_card_v2" || spec === "chara_card_v3") {
return true;
}
// Check for v0 character card (has first_mes field)
if ("first_mes" in jsonData) {
return true;
}
// Check for v3+ character card (has first_mes in data object)
if (jsonData.data && typeof jsonData.data === 'object' && "first_mes" in jsonData.data) {
return true;
}
return false;
},
// Shared handler for processing JSON file content
async processJsonFile(jsonData, filePath, fileData, filename) {
try {
// Check if it's a character card
if (this.isCharacterCardJson(jsonData)) {
// Show character card import modal
const result = await this.showCharacterCardImportModal(fileData, filePath, filename);
if (!result || !result.confirmed) {
return false;
}
console.log("Loading character card from JSON file");
this.loading = true;
this.$emit("loading", true);
if (fileData && !filePath) {
// File upload - send as scene_data
this.getWebsocket().send(JSON.stringify({
type: 'load_scene',
scene_data: fileData,
filename: filename,
scene_initialization: {
character_card_import_options: result.options,
},
}));
} else {
// Path-based - send file_path
this.getWebsocket().send(JSON.stringify({
type: 'load_scene',
file_path: filePath,
scene_initialization: {
character_card_import_options: result.options,
},
}));
}
return true;
} else {
// It's a Talemate scene JSON, load it directly
console.log("Loading Talemate scene from JSON file");
this.loading = true;
this.$emit("loading", true);
if (fileData && !filePath) {
// File upload - send as scene_data
this.getWebsocket().send(JSON.stringify({
type: 'load_scene',
scene_data: fileData,
filename: filename,
}));
} else {
// Path-based - send file_path
this.getWebsocket().send(JSON.stringify({
type: 'load_scene',
file_path: filePath
}));
}
return true;
}
} catch (error) {
console.error("Error processing JSON file:", error);
alert("Error: Invalid JSON file. " + error.message);
return false;
}
},
async loadScene(inputMethod) {
if(this.sceneSaved === false) {
if(!confirm("The current scene is not saved. Are you sure you want to load a new scene?")) {
return;
}
}
if(inputMethod) {
this.inputMethod = inputMethod;
}
this.sceneSaved = null;
console.log("Loading scene", this.inputMethod, this.sceneFile, this.sceneInput)
if (this.inputMethod === 'file' && this.sceneFile) { // Just check if sceneFile exists
// File is now a direct File object, not an array
const file = this.sceneFile;
// if file is image, show character card import modal
if(file.type.startsWith("image/")) {
// Convert the uploaded file to base64 first
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = async () => {
// Show character card import options modal with file data
const result = await this.showCharacterCardImportModal(reader.result, null, file.name);
if (!result || !result.confirmed) {
this.sceneFile = null;
return;
}
console.log("Loading scene from file")
this.loading = true;
this.$emit("loading", true)
this.getWebsocket().send(JSON.stringify({
type: 'load_scene',
scene_data: reader.result,
filename: file.name,
scene_initialization: {
character_card_import_options: result.options,
},
}));
this.sceneFile = null; // Reset with null instead of empty array
};
} else if (file.type === "application/json" || file.name.endsWith(".json")) {
// JSON file - check if it's a character card
const reader = new FileReader();
reader.readAsText(file);
reader.onload = async () => {
try {
const jsonData = JSON.parse(reader.result);
// Convert to base64 for sending to backend
const base64Reader = new FileReader();
base64Reader.readAsDataURL(file);
base64Reader.onload = async () => {
const success = await this.processJsonFile(
jsonData,
null, // filePath (not used for uploads)
base64Reader.result,
file.name
);
if (!success) {
this.sceneFile = null;
} else {
this.sceneFile = null;
}
};
} catch (error) {
console.error("Error parsing JSON file:", error);
alert("Error: Invalid JSON file. " + error.message);
this.sceneFile = null;
}
};
} else {
this.loading = true;
// Convert the uploaded file to base64
const reader = new FileReader();
reader.readAsDataURL(file);
console.log("Loading scene from file")
reader.onload = () => {
this.$emit("loading", true)
this.getWebsocket().send(JSON.stringify({
type: 'load_scene',
scene_data: reader.result,
filename: file.name,
}));
this.sceneFile = null; // Reset with null instead of empty array
};
}
} else if (this.inputMethod === 'path' && this.sceneInput) {
// if path ends with .png/jpg/webp, show character card import modal
if(this.sceneInput.endsWith(".png") || this.sceneInput.endsWith(".jpg") || this.sceneInput.endsWith(".webp")) {
// Show character card import options modal with file path
const result = await this.showCharacterCardImportModal(null, this.sceneInput, null);
if (!result || !result.confirmed) {
this.sceneInput = '';
return;
}
this.loading = true;
this.$emit("loading", true)
this.getWebsocket().send(JSON.stringify({
type: 'load_scene',
file_path: this.sceneInput,
scene_initialization: {
character_card_import_options: result.options,
},
}));
this.sceneInput = '';
} else if (this.sceneInput.endsWith(".json")) {
// JSON file - check if it's a character card
// Store the path for when we get the file data
this.pendingJsonPath = this.sceneInput;
// Request file content to check if it's a character card
this.getWebsocket().send(JSON.stringify({
type: 'request_file_image_data',
file_path: this.sceneInput,
}));
// Don't clear sceneInput yet - wait for file data response
} else {
this.loading = true;
this.$emit("loading", true)
this.getWebsocket().send(JSON.stringify({ type: 'load_scene', file_path: this.sceneInput }));
this.sceneInput = '';
}
}
},
loadJsonSceneFromPath(path, reset = false, rev = null) {
this.loading = true;
this.$emit("loading", true)
const message = { type: 'load_scene', file_path: path, reset: reset };
if (rev !== null && rev !== undefined) {
message.rev = rev;
}
this.getWebsocket().send(JSON.stringify(message));
},
async handleMessage(data) {
// Handle app configuration
if (data.type === 'app_config') {
this.appConfig = data.data;
console.log("App config", this.appConfig);
}
// Scene loaded
if (data.type === "system") {
console.debug("system message", data);
if (data.id === 'scene.loaded') {
this.loading = false;
this.expanded = false;
if (this.pendingAssistWithSetup) {
this.pendingAssistWithSetup = false;
this.$emit('open-director');
}
} else if (data.id === 'scene.load_failure') {
this.loading = false;
this.expanded = false;
this.pendingAssistWithSetup = false;
}
}
// Handle scenes_list message type
if (data.type === 'scenes_list') {
this.scenes = data.data;
this.sceneSearchLoading = false;
return;
}
// Handle scene status
if (data.type == "scene_status") {
this.sceneSaved = data.data.saved;
return;
}
// Handle file_image_data - used for both images and JSON files
if (data.type === 'file_image_data') {
// Check if this is a pending JSON file check
if (this.pendingJsonPath && data.file_path === this.pendingJsonPath) {
const jsonPath = this.pendingJsonPath;
this.pendingJsonPath = null; // Clear pending path
if (data.error) {
console.error("Error loading JSON file:", data.error);
alert("Error loading file: " + data.error);
this.sceneInput = '';
return;
}
if (data.image_data && data.media_type === 'application/json') {
// Extract JSON text from base64 data URL
try {
// data.image_data is a data URL like "data:application/json;base64,..."
const base64Data = data.image_data.split(',')[1];
const jsonText = atob(base64Data);
const jsonData = JSON.parse(jsonText);
// Process JSON file (checks if character card and handles accordingly)
const success = await this.processJsonFile(
jsonData,
jsonPath,
data.image_data,
null // filename (not available for path-based)
);
if (success) {
this.sceneInput = '';
} else {
this.sceneInput = '';
}
} catch (error) {
console.error("Error parsing JSON file:", error);
alert("Error: Invalid JSON file. " + error.message);
this.sceneInput = '';
}
}
}
return;
}
},
},
created() {
this.registerMessageHandler(this.handleMessage);
},
mounted() {
}
}
</script>
<style>
.fixed-file-input-size div.v-input__details {
align-items: flex-start !important;
}
</style>

View File

@@ -0,0 +1,474 @@
<template>
<v-card variant="text">
<v-card-title class="d-flex align-center">
<v-icon size="x-small" class="mr-2" color="primary">mdi-file-tree</v-icon>
Scene Library
<v-spacer></v-spacer>
<v-tooltip text="Refresh" location="top">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon variant="text" size="small" :loading="loadingTree" @click="fetchTree">
<v-icon>mdi-refresh</v-icon>
</v-btn>
</template>
</v-tooltip>
</v-card-title>
<v-card-text>
<v-text-field
v-model="filter"
density="compact"
variant="underlined"
prepend-inner-icon="mdi-magnify"
label="Filter scenes"
clearable
hide-details
class="mb-2"
></v-text-field>
<v-alert v-if="tree != null && !hasContent" color="muted" variant="text" density="compact" icon="mdi-information-outline">
No saved scenes yet. Import a scene or character card, or create a new scene to get started.
</v-alert>
<v-skeleton-loader v-else-if="tree == null" type="list-item@4"></v-skeleton-loader>
<div v-else class="browser-scroll">
<v-treeview
:items="treeItems"
item-title="title"
item-value="id"
density="compact"
open-on-click
activatable
color="primary"
v-model:opened="opened"
:activated="activated"
@update:activated="onActivated"
>
<template #prepend="{ item }">
<v-avatar v-if="item.type === 'project' && coverImageSrc(item.coverImage)" size="28" rounded="sm">
<v-img :src="coverImageSrc(item.coverImage)" cover></v-img>
</v-avatar>
<v-icon v-else-if="item.type === 'project'" size="small" color="primary">mdi-folder-outline</v-icon>
<v-icon v-else-if="item.type === 'save'" size="small" color="highlight7">mdi-script-text-outline</v-icon>
<v-icon v-else-if="item.type === 'characters'" size="small" color="persona">mdi-account-box-multiple-outline</v-icon>
<v-avatar v-else-if="item.type === 'card' && fileImages[item.path]" size="28" rounded="sm">
<v-img :src="fileImages[item.path]" cover position="top"></v-img>
</v-avatar>
<v-icon v-else-if="item.type === 'card'" size="small" color="persona">{{ item.mediaType === 'application/json' ? 'mdi-code-json' : 'mdi-card-account-details-outline' }}</v-icon>
<v-icon v-else-if="item.type === 'more'" size="small" color="muted">mdi-dots-horizontal</v-icon>
<v-icon v-else-if="item.type === 'meta'" size="small" color="muted">mdi-information-outline</v-icon>
</template>
<template #title="{ item }">
<div v-if="item.type === 'meta'" class="browser-row text-caption text-grey">
<v-icon size="x-small" class="mr-1">mdi-image-multiple-outline</v-icon>
{{ item.numAssets }} {{ item.numAssets === 1 ? 'asset' : 'assets' }}
<span class="mx-2">·</span>
<v-icon size="x-small" class="mr-1">mdi-graph-outline</v-icon>
{{ item.numNodes }} {{ item.numNodes === 1 ? 'node module' : 'node modules' }}
</div>
<div v-else class="browser-row">
<span :class="item.type === 'project' ? 'text-primary' : ''">{{ item.title }}</span>
<span v-if="item.subtitle" class="text-caption text-grey ml-2">{{ item.subtitle }}</span>
</div>
</template>
<template #append="{ item }">
<span v-if="item.modified" class="text-caption text-grey-darken-1 mr-2 d-none d-sm-inline">{{ prettyDate(item.modified) }}</span>
<span v-if="item.size !== undefined" class="text-caption text-grey-darken-1 mr-2 d-none d-md-inline">{{ prettySize(item.size) }}</span>
<v-tooltip v-if="item.type === 'save' || item.type === 'card'" :text="item.type === 'save' ? 'Delete scene file' : 'Delete character card'" location="top">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon variant="text" size="x-small" color="delete" @click.stop="confirmDeleteFile(item)">
<v-icon>mdi-file-remove-outline</v-icon>
</v-btn>
</template>
</v-tooltip>
<v-tooltip v-else-if="item.type === 'project'" text="Delete scene project" location="top">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon variant="text" size="x-small" color="delete" @click.stop="openDeleteProjectDialog(item)">
<v-icon>mdi-folder-remove-outline</v-icon>
</v-btn>
</template>
</v-tooltip>
</template>
</v-treeview>
</div>
</v-card-text>
<ConfirmActionPrompt
ref="deleteFilePrompt"
actionLabel="Delete file"
description="Are you sure you want to delete {filename}?"
icon="mdi-file-remove-outline"
color="delete"
:max-width="400"
@confirm="(params) => deleteFile(params.path)"
></ConfirmActionPrompt>
<v-dialog v-model="deleteProjectDialog" max-width="500" @keydown.esc="deleteProjectDialog = false">
<v-card>
<v-card-title class="headline">
<v-icon class="mr-2" size="x-small" color="delete">mdi-folder-remove-outline</v-icon>
Delete scene project
</v-card-title>
<v-card-text>
<p>
This will permanently delete the
<span class="text-primary">{{ deleteProjectTarget?.title }}</span>
project - all of its saved scenes, assets and history. This cannot be undone.
</p>
<p class="mt-3 text-caption text-grey">Type the project name to confirm.</p>
<v-text-field
v-model="deleteProjectConfirmation"
density="compact"
variant="underlined"
:placeholder="deleteProjectTarget?.title"
autofocus
hide-details
@keydown.enter="deleteProjectConfirmed && deleteProject()"
></v-text-field>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="muted" variant="text" @click="deleteProjectDialog = false">Cancel</v-btn>
<v-btn color="delete" variant="text" :disabled="!deleteProjectConfirmed" prepend-icon="mdi-folder-remove-outline" @click="deleteProject">Delete project</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
</template>
<script>
import ConfirmActionPrompt from './ConfirmActionPrompt.vue';
const SAVE_DISPLAY_LIMIT = 10;
const CHARACTER_CARDS_ID = '$CHARACTER_CARDS$';
export default {
name: 'SceneBrowser',
components: {
ConfirmActionPrompt,
},
props: {
connected: Boolean,
sceneLoadingAvailable: Boolean,
visible: Boolean,
},
inject: ['getWebsocket', 'registerMessageHandler', 'isConnected'],
emits: ['load-save', 'load-card'],
data() {
return {
tree: null,
loadingTree: false,
filter: '',
opened: [],
activated: [],
fileImages: {},
requestedFileImages: [],
showAllSaves: [],
deleteProjectDialog: false,
deleteProjectTarget: null,
deleteProjectConfirmation: '',
}
},
computed: {
hasContent() {
return this.tree != null && (this.tree.projects.length > 0 || this.tree.characters.length > 0);
},
deleteProjectConfirmed() {
return this.deleteProjectTarget != null && this.deleteProjectConfirmation.trim() === this.deleteProjectTarget.title;
},
treeItems() {
if (!this.tree) {
return [];
}
const filter = (this.filter || '').toLowerCase();
const items = [];
for (const project of this.tree.projects) {
const projectMatches = project.name.toLowerCase().includes(filter);
let files = project.files.filter((file) => {
if (!filter || projectMatches) {
return true;
}
return file.filename.toLowerCase().includes(filter) ||
(file.scene_name || '').toLowerCase().includes(filter);
}).map((file) => ({
id: file.path,
type: 'save',
title: file.relpath,
subtitle: file.scene_name,
path: file.path,
filename: file.filename,
modified: file.modified,
size: file.size,
}));
if (filter && !projectMatches && files.length === 0) {
continue;
}
// cap long save lists behind a "show all" row (newest first,
// so the cap hides only older saves); filtering shows all matches
if (!filter && files.length > SAVE_DISPLAY_LIMIT && !this.showAllSaves.includes(project.path)) {
const total = files.length;
files = files.slice(0, SAVE_DISPLAY_LIMIT);
files.push({
id: `${project.path}/$MORE$`,
type: 'more',
title: `Show all ${total} saves`,
projectPath: project.path,
});
}
// lead the expanded project with a metadata row (hidden while
// filtering so search results stay clean)
if (!filter) {
files.unshift({
id: `${project.path}/$META$`,
type: 'meta',
numAssets: project.num_assets,
numNodes: project.num_nodes,
});
}
items.push({
id: project.path,
type: 'project',
title: project.name,
subtitle: `${project.files.length} ${project.files.length === 1 ? 'save' : 'saves'}`,
path: project.path,
modified: project.modified,
coverImage: project.cover_image,
children: files,
});
}
const cards = this.tree.characters.filter((card) => {
return !filter || card.filename.toLowerCase().includes(filter);
}).map((card) => ({
id: card.path,
type: 'card',
title: card.relpath,
path: card.path,
modified: card.modified,
size: card.size,
mediaType: card.media_type,
}));
if (cards.length > 0) {
items.push({
id: CHARACTER_CARDS_ID,
type: 'characters',
title: 'Character Cards',
subtitle: `${cards.length}`,
children: cards,
});
}
return items;
},
},
watch: {
connected(connected) {
if (connected) {
this.fetchTree();
}
},
visible(visible) {
if (visible && this.isConnected()) {
this.fetchTree();
}
},
filter(filter) {
// auto-expand groups while filtering so matches are visible
if (filter) {
this.opened = this.treeItems.map((item) => item.id);
}
},
opened(opened) {
if (opened.includes(CHARACTER_CARDS_ID)) {
this.requestCardImages();
}
},
},
methods: {
fetchTree() {
if (!this.isConnected()) {
return;
}
this.loadingTree = true;
this.getWebsocket().send(JSON.stringify({ type: 'request_scenes_tree' }));
},
onActivated(activated) {
// treat activation as a click-to-load; reset activation so the
// same entry can be clicked again
this.activated = [];
if (!activated.length) {
return;
}
const item = this.findItem(this.treeItems, activated[0]);
if (!item) {
return;
}
if (item.type === 'more') {
this.showAllSaves.push(item.projectPath);
return;
}
if (!this.sceneLoadingAvailable) {
return;
}
if (item.type === 'save') {
this.$emit('load-save', item.path);
} else if (item.type === 'card') {
this.$emit('load-card', item.path);
}
},
findItem(items, id) {
for (const item of items) {
if (item.id === id) {
return item;
}
if (item.children) {
const found = this.findItem(item.children, id);
if (found) {
return found;
}
}
}
return null;
},
confirmDeleteFile(item) {
this.$refs.deleteFilePrompt.initiateAction({ path: item.path, filename: item.filename });
},
deleteFile(path) {
this.getWebsocket().send(JSON.stringify({
type: 'config',
action: 'delete_scene',
path: path,
}));
},
openDeleteProjectDialog(item) {
this.deleteProjectTarget = item;
this.deleteProjectConfirmation = '';
this.deleteProjectDialog = true;
},
deleteProject() {
if (!this.deleteProjectConfirmed) {
return;
}
this.getWebsocket().send(JSON.stringify({
type: 'config',
action: 'delete_scene_project',
path: this.deleteProjectTarget.path,
}));
this.deleteProjectDialog = false;
this.deleteProjectTarget = null;
},
requestCoverImages() {
if (!this.tree) {
return;
}
for (const project of this.tree.projects) {
if (project.cover_image) {
this.requestFileImage(project.cover_image.path);
}
}
},
requestFileImage(path) {
if (!path || this.fileImages[path] || this.requestedFileImages.includes(path) || !this.isConnected()) {
return;
}
this.requestedFileImages.push(path);
this.getWebsocket().send(JSON.stringify({
type: 'request_file_image_data',
file_path: path,
}));
},
requestCardImages() {
if (!this.tree) {
return;
}
for (const card of this.tree.characters) {
if (card.media_type !== 'application/json') {
this.requestFileImage(card.path);
}
}
},
coverImageSrc(coverImage) {
return (coverImage && this.fileImages[coverImage.path]) || null;
},
prettyDate(date) {
return new Date(date).toLocaleString();
},
prettySize(size) {
if (size < 1024) {
return `${size} B`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
},
handleMessage(data) {
if (data.type === 'scenes_tree') {
this.tree = data.data;
this.loadingTree = false;
this.requestCoverImages();
if (this.opened.includes(CHARACTER_CARDS_ID)) {
this.requestCardImages();
}
return;
}
if (data.type === 'file_image_data') {
if (data.image_data && this.requestedFileImages.includes(data.file_path)) {
this.fileImages[data.file_path] = data.image_data;
}
return;
}
if (data.type === 'config' && (data.action === 'delete_scene_complete' || data.action === 'delete_scene_project_complete')) {
this.fetchTree();
}
},
},
created() {
this.registerMessageHandler(this.handleMessage);
},
mounted() {
if (this.isConnected()) {
this.fetchTree();
}
},
}
</script>
<style scoped>
.browser-row {
display: flex;
align-items: baseline;
min-width: 0;
}
.browser-scroll {
max-height: 65vh;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,502 @@
<template>
<div class="scene-landing">
<v-alert v-if="!connected" type="error" variant="tonal" density="compact" class="mb-4">
Not connected to Talemate backend. Make sure the backend process is running.
</v-alert>
<v-alert v-else-if="!sceneLoadingAvailable" type="warning" variant="tonal" density="compact" class="mb-4">
There are some outstanding configuration issues, please ensure that all enabled clients and agents are configured correctly.
</v-alert>
<IntroRecentScenes
:config="config"
:scene-is-loading="sceneIsLoading || loading"
:scene-loading-available="sceneLoadingAvailable"
@request-scene-load="requestSceneLoad"
/>
<v-row class="mt-1">
<v-col cols="12" md="7" lg="8">
<SceneBrowser
:connected="connected"
:scene-loading-available="sceneLoadingAvailable && !loading"
:visible="visible"
@load-save="requestSceneLoad"
@load-card="loadFromPath"
/>
</v-col>
<v-col cols="12" md="5" lg="4">
<v-card variant="text">
<v-card-title>
<v-icon size="x-small" class="mr-2" color="primary">mdi-tray-arrow-down</v-icon>
Import
</v-card-title>
<v-card-text>
<div
class="dropzone"
:class="{ 'dropzone-active': dragging, 'dropzone-disabled': !sceneLoadingAvailable || loading }"
@dragover.prevent="dragging = sceneLoadingAvailable && !loading"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop"
@click="openFilePicker"
>
<input
ref="fileInput"
type="file"
accept="image/*,.json,.zip"
class="d-none"
@change="onFilePicked"
/>
<v-icon size="48" color="primary" class="mb-2">mdi-tray-arrow-down</v-icon>
<div class="text-body-2">Drop a file here or click to browse</div>
<div class="text-caption text-grey mt-1">
Talemate scenes (.json, .zip) and character cards (.png, .webp, .json)
</div>
</div>
</v-card-text>
</v-card>
<v-card variant="text" class="mt-2">
<v-card-title>
<v-icon size="x-small" class="mr-2" color="primary">mdi-plus</v-icon>
Create new scene
</v-card-title>
<v-card-text>
<p class="text-caption text-grey mb-3">Start a blank scene and build it out in the world editor.</p>
<v-btn
variant="tonal"
block
color="primary"
:disabled="!sceneLoadingAvailable || loading"
append-icon="mdi-plus"
@click="startNewScene"
>Create</v-btn>
</v-card-text>
</v-card>
</v-col>
</v-row>
<div class="mt-4">
<WhatsNew />
<v-btn variant="text" class="ml-2" href="https://vegu-ai.github.io/talemate/" target="_blank" rel="noopener">
<v-icon size="x-small" class="mr-1" color="primary">mdi-help-box-multiple-outline</v-icon>
Documentation
</v-btn>
</div>
<CharacterCardImport ref="characterCardImportModal" :templates="worldStateTemplates"></CharacterCardImport>
<NewSceneSetupModal v-model="showNewSceneSetup" :templates="worldStateTemplates" @create="createNewScene" />
</div>
</template>
<script>
import CharacterCardImport from './CharacterCardImport.vue';
import IntroRecentScenes from './IntroRecentScenes.vue';
import NewSceneSetupModal from './NewSceneSetupModal.vue';
import SceneBrowser from './SceneBrowser.vue';
import WhatsNew from './WhatsNew.vue';
export default {
name: 'SceneLanding',
components: {
CharacterCardImport,
IntroRecentScenes,
NewSceneSetupModal,
SceneBrowser,
WhatsNew,
},
props: {
connected: Boolean,
sceneLoadingAvailable: Boolean,
sceneIsLoading: Boolean,
visible: Boolean,
config: Object,
worldStateTemplates: Object,
},
emits: ['loading', 'open-director', 'request-scene-load'],
inject: ['getWebsocket', 'registerMessageHandler', 'isConnected'],
data() {
return {
loading: false,
dragging: false,
sceneSaved: null,
pendingJsonPath: null,
showNewSceneSetup: false,
pendingAssistWithSetup: false,
}
},
methods: {
async showCharacterCardImportModal(fileData = null, filePath = null, filename = null) {
const result = await this.$refs.characterCardImportModal.open(fileData, filePath, filename);
return result;
},
confirmUnsaved() {
if (this.sceneSaved === false) {
return confirm("The current scene is not saved. Are you sure you want to load a new scene?");
}
return true;
},
requestSceneLoad(path) {
if (!this.confirmUnsaved()) {
return;
}
this.sceneSaved = null;
this.$emit('request-scene-load', path);
},
startNewScene() {
if (!this.confirmUnsaved()) {
return;
}
this.showNewSceneSetup = true;
},
createNewScene(payload) {
this.loading = true;
this.pendingAssistWithSetup = payload.assistWithSetup;
this.getWebsocket().send(JSON.stringify({
type: 'load_scene',
file_path: "$NEW_SCENE$",
scene_initialization: {
project_name: payload.sceneName || null,
writing_style_template: payload.writingStyle || null,
agent_persona_templates: { director: payload.directorPersona || null },
},
}));
},
// Helper function to detect if JSON content is a character card
isCharacterCardJson(jsonData) {
if (!jsonData || typeof jsonData !== 'object') {
return false;
}
// Check for character card spec versions
const spec = jsonData.spec;
if (spec === "chara_card_v1" || spec === "chara_card_v2" || spec === "chara_card_v3") {
return true;
}
// Check for v0 character card (has first_mes field)
if ("first_mes" in jsonData) {
return true;
}
// Check for v3+ character card (has first_mes in data object)
if (jsonData.data && typeof jsonData.data === 'object' && "first_mes" in jsonData.data) {
return true;
}
return false;
},
// Shared handler for processing JSON file content
async processJsonFile(jsonData, filePath, fileData, filename) {
try {
// Check if it's a character card
if (this.isCharacterCardJson(jsonData)) {
// Show character card import modal
const result = await this.showCharacterCardImportModal(fileData, filePath, filename);
if (!result || !result.confirmed) {
return false;
}
this.loading = true;
this.$emit("loading", true);
if (fileData && !filePath) {
// File upload - send as scene_data
this.getWebsocket().send(JSON.stringify({
type: 'load_scene',
scene_data: fileData,
filename: filename,
scene_initialization: {
character_card_import_options: result.options,
},
}));
} else {
// Path-based - send file_path
this.getWebsocket().send(JSON.stringify({
type: 'load_scene',
file_path: filePath,
scene_initialization: {
character_card_import_options: result.options,
},
}));
}
return true;
} else {
// It's a Talemate scene JSON, load it directly
this.loading = true;
this.$emit("loading", true);
if (fileData && !filePath) {
// File upload - send as scene_data
this.getWebsocket().send(JSON.stringify({
type: 'load_scene',
scene_data: fileData,
filename: filename,
}));
} else {
// Path-based - send file_path
this.getWebsocket().send(JSON.stringify({
type: 'load_scene',
file_path: filePath
}));
}
return true;
}
} catch (error) {
console.error("Error processing JSON file:", error);
alert("Error: Invalid JSON file. " + error.message);
return false;
}
},
openFilePicker() {
if (!this.sceneLoadingAvailable || this.loading) {
return;
}
this.$refs.fileInput.click();
},
onFilePicked(event) {
const file = event.target.files && event.target.files[0];
event.target.value = null;
if (file) {
this.loadFromFile(file);
}
},
onDrop(event) {
this.dragging = false;
if (!this.sceneLoadingAvailable || this.loading) {
return;
}
const file = event.dataTransfer.files && event.dataTransfer.files[0];
if (file) {
this.loadFromFile(file);
}
},
loadFromFile(file) {
if (!this.confirmUnsaved()) {
return;
}
this.sceneSaved = null;
// if file is image, show character card import modal
if (file.type.startsWith("image/")) {
// Convert the uploaded file to base64 first
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = async () => {
// Show character card import options modal with file data
const result = await this.showCharacterCardImportModal(reader.result, null, file.name);
if (!result || !result.confirmed) {
return;
}
this.loading = true;
this.$emit("loading", true)
this.getWebsocket().send(JSON.stringify({
type: 'load_scene',
scene_data: reader.result,
filename: file.name,
scene_initialization: {
character_card_import_options: result.options,
},
}));
};
} else if (file.type === "application/json" || file.name.endsWith(".json")) {
// JSON file - check if it's a character card
const reader = new FileReader();
reader.readAsText(file);
reader.onload = async () => {
try {
const jsonData = JSON.parse(reader.result);
// Convert to base64 for sending to backend
const base64Reader = new FileReader();
base64Reader.readAsDataURL(file);
base64Reader.onload = async () => {
await this.processJsonFile(
jsonData,
null, // filePath (not used for uploads)
base64Reader.result,
file.name
);
};
} catch (error) {
console.error("Error parsing JSON file:", error);
alert("Error: Invalid JSON file. " + error.message);
}
};
} else {
this.loading = true;
// Convert the uploaded file to base64
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
this.$emit("loading", true)
this.getWebsocket().send(JSON.stringify({
type: 'load_scene',
scene_data: reader.result,
filename: file.name,
}));
};
}
},
async loadFromPath(path) {
if (!this.confirmUnsaved()) {
return;
}
this.sceneSaved = null;
// if path ends with .png/jpg/webp, show character card import modal
if (path.endsWith(".png") || path.endsWith(".jpg") || path.endsWith(".webp")) {
// Show character card import options modal with file path
const result = await this.showCharacterCardImportModal(null, path, null);
if (!result || !result.confirmed) {
return;
}
this.loading = true;
this.$emit("loading", true)
this.getWebsocket().send(JSON.stringify({
type: 'load_scene',
file_path: path,
scene_initialization: {
character_card_import_options: result.options,
},
}));
} else if (path.endsWith(".json")) {
// JSON file - check if it's a character card
// Store the path for when we get the file data
this.pendingJsonPath = path;
// Request file content to check if it's a character card
this.getWebsocket().send(JSON.stringify({
type: 'request_file_image_data',
file_path: path,
}));
} else {
this.loading = true;
this.$emit("loading", true)
this.getWebsocket().send(JSON.stringify({ type: 'load_scene', file_path: path }));
}
},
loadJsonSceneFromPath(path, reset = false, rev = null) {
this.loading = true;
this.$emit("loading", true)
const message = { type: 'load_scene', file_path: path, reset: reset };
if (rev !== null && rev !== undefined) {
message.rev = rev;
}
this.getWebsocket().send(JSON.stringify(message));
},
async handleMessage(data) {
// Scene loaded
if (data.type === "system") {
if (data.id === 'scene.loaded') {
this.loading = false;
if (this.pendingAssistWithSetup) {
this.pendingAssistWithSetup = false;
this.$emit('open-director');
}
} else if (data.id === 'scene.load_failure') {
this.loading = false;
this.pendingAssistWithSetup = false;
}
return;
}
// Handle scene status
if (data.type == "scene_status") {
this.sceneSaved = data.data.saved;
return;
}
// Handle file_image_data - used to inspect JSON files loaded by path
if (data.type === 'file_image_data') {
// Check if this is a pending JSON file check
if (this.pendingJsonPath && data.file_path === this.pendingJsonPath) {
const jsonPath = this.pendingJsonPath;
this.pendingJsonPath = null; // Clear pending path
if (data.error) {
console.error("Error loading JSON file:", data.error);
alert("Error loading file: " + data.error);
return;
}
if (data.image_data && data.media_type === 'application/json') {
// Extract JSON text from base64 data URL
try {
// data.image_data is a data URL like "data:application/json;base64,..."
const base64Data = data.image_data.split(',')[1];
const jsonText = atob(base64Data);
const jsonData = JSON.parse(jsonText);
// Process JSON file (checks if character card and handles accordingly)
await this.processJsonFile(
jsonData,
jsonPath,
data.image_data,
null // filename (not available for path-based)
);
} catch (error) {
console.error("Error parsing JSON file:", error);
alert("Error: Invalid JSON file. " + error.message);
}
}
}
return;
}
},
},
created() {
this.registerMessageHandler(this.handleMessage);
},
}
</script>
<style scoped>
.scene-landing {
max-width: 1600px;
margin: 0 auto;
}
.dropzone {
border: 2px dashed rgba(179, 157, 219, 0.4);
border-radius: 8px;
padding: 32px 16px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background-color 0.2s;
}
.dropzone:hover,
.dropzone-active {
border-color: rgb(179, 157, 219);
background-color: rgba(179, 157, 219, 0.08);
}
.dropzone-disabled {
opacity: 0.5;
cursor: default;
}
.dropzone-disabled:hover {
border-color: rgba(179, 157, 219, 0.4);
background-color: transparent;
}
</style>

View File

@@ -52,7 +52,7 @@
<!-- app bar -->
<v-app-bar app density="compact">
<v-app-bar-nav-icon size="small" @click="toggleNavigation('game')">
<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>
@@ -116,7 +116,7 @@
<v-main style="height: 100%; display: flex; flex-direction: column;">
<!-- left side navigation drawer -->
<v-navigation-drawer v-model="sceneDrawer" app :width="leftDrawerWidth">
<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">
@@ -125,14 +125,6 @@
</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="home">
<LoadScene
ref="loadScene"
:scene-loading-available="ready && connected"
:world-state-templates="worldStateTemplates"
@loading="sceneStartedLoading"
@open-director="openDirectorWithChat" />
</v-tabs-window-item>
<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"/>
@@ -223,12 +215,16 @@
<v-tabs-window v-model="tab" style="height: 100%;">
<!-- HOME -->
<v-tabs-window-item :transition="false" :reverse-transition="false" value="home">
<IntroView
ref="introView"
@request-scene-load="(path) => { resetViews(); $refs.loadScene.loadJsonSceneFromPath(path); }"
:version="version"
<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 -->
@@ -399,7 +395,7 @@
ref="sceneTimeline"
:scene="scene"
:appearance-config="effectiveAppearanceConfig"
@load-at-revision="(target) => { resetViews(); $refs.loadScene.loadJsonSceneFromPath(target.path, false, target.rev); }"
@load-at-revision="(target) => { resetViews(); $refs.sceneLanding.loadJsonSceneFromPath(target.path, false, target.rev); }"
/>
<VersionMismatchAlert ref="versionMismatchAlert" />
<OnboardingWizard
@@ -415,7 +411,7 @@ import AIClient from './AIClient.vue';
import AIAgent from './AIAgent.vue';
import AgentActivityBar from './AgentActivityBar.vue';
import AgentActionOverrides from './AgentActionOverrides.vue';
import LoadScene from './LoadScene.vue';
import SceneLanding from './SceneLanding.vue';
import SceneTools from './SceneTools.vue';
import SceneMessages from './SceneMessages.vue';
import SceneMessageInput from './SceneMessageInput.vue';
@@ -435,7 +431,6 @@ import VisualLibrary from './VisualLibrary.vue';
import VoiceLibrary from './VoiceLibrary.vue';
import WorldStateManager from './WorldStateManager.vue';
import WorldStateManagerMenu from './WorldStateManagerMenu.vue';
import IntroView from './IntroView.vue';
import NodeEditor from './NodeEditor.vue';
import DirectorConsole from './DirectorConsole.vue';
import DirectorConsoleWidget from './DirectorConsoleWidget.vue';
@@ -459,7 +454,7 @@ export default {
AIAgent,
AgentActivityBar,
AgentActionOverrides,
LoadScene,
SceneLanding,
SceneTools,
SceneMessages,
SceneMessageInput,
@@ -469,7 +464,6 @@ export default {
DebugTools,
AudioQueue,
StatusNotification,
IntroView,
VisualLibrary,
WorldStateManager,
WorldStateManagerMenu,
@@ -1089,7 +1083,7 @@ export default {
} else if (data.id === 'load_scene_request') {
// Load the requested scene (e.g., after forking)
this.resetViews();
this.$refs.loadScene.loadJsonSceneFromPath(data.data.path);
this.$refs.sceneLanding.loadJsonSceneFromPath(data.data.path);
}
if(data.status == 'error') {
this.errorNotification = true;
@@ -1474,7 +1468,7 @@ export default {
return {
active_tab: this.tab,
open_drawers: [
...(this.sceneDrawer ? ['scene'] : []),
...(this.sceneDrawer && this.tab !== 'home' ? ['scene'] : []),
...(this.drawer ? ['clients_and_agents'] : []),
...(this.debugDrawer ? ['debug_tools'] : []),
...(this.directorConsoleDrawer ? ['director_console'] : []),

View File

@@ -0,0 +1,220 @@
"""
Tests for the ``delete_scene_project`` config websocket action.
The action removes an entire scene project directory (saves, assets, nodes,
changelog) and cleans up recent-scene entries pointing into it. It must
refuse anything that is not a direct child of the scenes directory.
"""
import json
import os
import pytest
import talemate.config.state as config_state
import talemate.emit.async_signals as async_signals
import talemate.files
from talemate.config import Config
from talemate.config.schema import RecentScene
from talemate.server.config import ConfigPlugin
@pytest.fixture(autouse=True)
def isolated_config_signals():
"""
set_dirty() broadcasts config.changed to whatever receivers other test
modules left connected (e.g. mock agents) - isolate the signal here.
"""
saved = {}
for name in ("config.changed", "config.changed.follow"):
signal = async_signals.get(name)
saved[signal] = signal.receivers
signal.receivers = []
yield
for signal, receivers in saved.items():
signal.receivers = receivers
class _MockWebsocketHandler:
def __init__(self):
self.messages: list[dict] = []
self.scene = None
def queue_put(self, data):
self.messages.append(data)
def by_action(self, action):
return [m for m in self.messages if m.get("action") == action]
@pytest.fixture
def scenes_root(tmp_path, monkeypatch):
root = tmp_path / "scenes"
root.mkdir()
monkeypatch.setattr(talemate.files, "SCENES_DIR", root)
return root
@pytest.fixture
def app_config(monkeypatch):
config = Config(recent_scenes={"scenes": []})
monkeypatch.setattr(config_state, "CONFIG", config)
return config
@pytest.fixture
def plugin():
return ConfigPlugin(_MockWebsocketHandler())
def _make_project(scenes_root, name="adventure"):
project = scenes_root / name
(project / "assets").mkdir(parents=True)
(project / "changelog").mkdir()
(project / "save.json").write_text(json.dumps({"name": name}))
(project / "assets" / "cover.png").write_bytes(b"png")
(project / "changelog" / "save.json.base.json").write_text("{}")
return project
@pytest.mark.asyncio
async def test_delete_scene_project(scenes_root, app_config, plugin):
project = _make_project(scenes_root)
other = _make_project(scenes_root, "other")
app_config.recent_scenes.scenes = [
RecentScene(
name="Adventure",
path=str(project / "save.json"),
filename="save.json",
date="2026-07-19T00:00:00",
),
RecentScene(
name="Other",
path=str(other / "save.json"),
filename="save.json",
date="2026-07-19T00:00:00",
),
]
await plugin.handle_delete_scene_project({"path": str(project)})
assert not project.exists()
assert other.exists()
# recents entry for the deleted project removed, the other kept
assert [scene.name for scene in app_config.recent_scenes.scenes] == ["Other"]
completed = plugin.websocket_handler.by_action("delete_scene_project_complete")
assert len(completed) == 1
assert completed[0]["data"]["name"] == "adventure"
# app_config broadcast follows
assert any(
message.get("type") == "app_config"
for message in plugin.websocket_handler.messages
)
@pytest.mark.asyncio
async def test_delete_rejects_paths_outside_scenes_dir(
tmp_path, scenes_root, app_config, plugin
):
outside = tmp_path / "not-scenes" / "project"
outside.mkdir(parents=True)
with pytest.raises(ValueError, match="Not a scene project"):
await plugin.handle_delete_scene_project({"path": str(outside)})
assert outside.exists()
@pytest.mark.asyncio
async def test_delete_rejects_scenes_root_itself(scenes_root, app_config, plugin):
with pytest.raises(ValueError, match="Not a scene project"):
await plugin.handle_delete_scene_project({"path": str(scenes_root)})
assert scenes_root.exists()
@pytest.mark.asyncio
async def test_delete_rejects_reserved_dirs(scenes_root, app_config, plugin):
for reserved in ("characters", "assets"):
(scenes_root / reserved).mkdir()
with pytest.raises(ValueError, match="Not a scene project"):
await plugin.handle_delete_scene_project(
{"path": str(scenes_root / reserved)}
)
assert (scenes_root / reserved).exists()
@pytest.mark.asyncio
async def test_delete_rejects_traversal(tmp_path, scenes_root, app_config, plugin):
victim = tmp_path / "victim"
victim.mkdir()
traversal = str(scenes_root / "project" / ".." / ".." / "victim")
with pytest.raises(ValueError, match="Not a scene project"):
await plugin.handle_delete_scene_project({"path": traversal})
assert victim.exists()
@pytest.mark.asyncio
async def test_delete_rejects_symlink_escape(tmp_path, scenes_root, app_config, plugin):
victim = tmp_path / "victim"
victim.mkdir()
link = scenes_root / "linked-project"
os.symlink(str(victim), str(link))
with pytest.raises(ValueError, match="Not a scene project"):
await plugin.handle_delete_scene_project({"path": str(link)})
assert victim.exists()
@pytest.mark.asyncio
async def test_delete_rejects_missing_project(scenes_root, app_config, plugin):
with pytest.raises(ValueError, match="not found"):
await plugin.handle_delete_scene_project(
{"path": str(scenes_root / "does-not-exist")}
)
@pytest.mark.asyncio
async def test_delete_rejects_active_scene_project(scenes_root, app_config, plugin):
project = _make_project(scenes_root)
class _SceneStub:
full_path = str(project / "save.json")
plugin.websocket_handler.scene = _SceneStub()
with pytest.raises(ValueError, match="currently loaded scene"):
await plugin.handle_delete_scene_project({"path": str(project)})
assert project.exists()
# a different loaded scene does not block the delete
other = _make_project(scenes_root, "other")
plugin.websocket_handler.scene = type(
"_OtherScene", (), {"full_path": str(other / "save.json")}
)()
await plugin.handle_delete_scene_project({"path": str(project)})
assert not project.exists()
@pytest.mark.asyncio
async def test_delete_scene_file_contained(tmp_path, scenes_root, app_config, plugin):
project = _make_project(scenes_root)
await plugin.handle_delete_scene({"path": str(project / "save.json")})
assert not (project / "save.json").exists()
outside = tmp_path / "outside.json"
outside.write_text("{}")
with pytest.raises(ValueError, match="Not a scene file"):
await plugin.handle_delete_scene({"path": str(outside)})
assert outside.exists()

View File

@@ -3,6 +3,7 @@
import os
import pytest
import talemate.files
from talemate.files import list_scenes_directory, _list_files_and_directories
@@ -127,9 +128,9 @@ class TestListFilesAndDirectories:
class TestListScenesDirectory:
def test_walks_scenes_subdir_of_cwd(self, scene_tree, monkeypatch):
# list_scenes_directory uses os.getcwd() / "scenes" as the root
monkeypatch.chdir(scene_tree["tmp"])
def test_walks_scenes_dir(self, scene_tree, monkeypatch):
# list_scenes_directory anchors on talemate.path.SCENES_DIR
monkeypatch.setattr(talemate.files, "SCENES_DIR", scene_tree["root"])
result = list_scenes_directory(list_images=True)
# The fake scenes tree has 4 matching files (2 JSONs + 2 images)
@@ -139,11 +140,11 @@ class TestListScenesDirectory:
assert scene_tree["paths"]["alice_in_wonderland/alice.json"] in result_set
def test_passes_list_images_flag_through(self, scene_tree, monkeypatch):
monkeypatch.chdir(scene_tree["tmp"])
monkeypatch.setattr(talemate.files, "SCENES_DIR", scene_tree["root"])
json_only = list_scenes_directory(list_images=False)
assert all(path.endswith(".json") for path in json_only)
def test_returns_empty_when_no_scenes_dir(self, tmp_path, monkeypatch):
monkeypatch.chdir(str(tmp_path))
monkeypatch.setattr(talemate.files, "SCENES_DIR", tmp_path / "missing")
# No scenes/ directory exists -> empty list (os.walk on missing path)
assert list_scenes_directory(list_images=True) == []

340
tests/test_scenes_tree.py Normal file
View File

@@ -0,0 +1,340 @@
"""
Tests for the structured scenes-tree listing used by the scene browser
landing page (``list_scenes_tree`` in ``talemate.files``).
"""
import json
import os
import pytest
import talemate.files
from talemate.files import list_scenes_tree
def _write_json(path, data):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as file:
json.dump(data, file)
def _touch(path, mtime):
os.utime(path, (mtime, mtime))
@pytest.fixture
def scenes_root(tmp_path, monkeypatch):
root = tmp_path / "scenes"
root.mkdir()
monkeypatch.setattr(talemate.files, "SCENES_DIR", root)
return root
COVER_ID = "cover-asset-id"
def _make_project(root, name, saves: dict, cover_in_library: bool = True):
"""
Create a scene project directory with the given saves.
``saves`` maps filename -> (mtime, scene_data)
"""
project = root / name
project.mkdir(exist_ok=True)
if cover_in_library:
_write_json(
str(project / "assets" / "library.json"),
{
"assets": {
COVER_ID: {
"id": COVER_ID,
"file_type": "webp",
"media_type": "image/webp",
}
}
},
)
for filename, (mtime, scene_data) in saves.items():
path = project / filename
_write_json(str(path), scene_data)
_touch(str(path), mtime)
return project
def test_empty_scenes_dir(scenes_root):
tree = list_scenes_tree()
assert tree == {"projects": [], "characters": []}
def test_missing_scenes_dir(tmp_path, monkeypatch):
monkeypatch.setattr(talemate.files, "SCENES_DIR", tmp_path / "does-not-exist")
tree = list_scenes_tree()
assert tree == {"projects": [], "characters": []}
def test_project_listing_with_metadata(scenes_root):
_make_project(
scenes_root,
"adventure",
{
"old-save.json": (
1000,
{"name": "Adventure", "assets": {"cover_image": COVER_ID}},
),
"new-save.json": (
2000,
{"name": "Adventure II", "assets": {"cover_image": COVER_ID}},
),
},
)
tree = list_scenes_tree()
assert len(tree["projects"]) == 1
project = tree["projects"][0]
assert project["name"] == "adventure"
assert project["path"] == str(scenes_root / "adventure")
# files sorted newest first
filenames = [entry["filename"] for entry in project["files"]]
assert filenames == ["new-save.json", "old-save.json"]
newest = project["files"][0]
assert newest["scene_name"] == "Adventure II"
assert newest["relpath"] == "new-save.json"
assert newest["size"] > 0
assert newest["modified"] == project["modified"]
# cover resolved through the asset library, path anchored at the
# project's assets dir
assert project["cover_image"] == {
"id": COVER_ID,
"file_type": "webp",
"media_type": "image/webp",
"path": str(scenes_root / "adventure" / "assets" / f"{COVER_ID}.webp"),
}
# library.json does not count as an asset
assert project["num_assets"] == 0
assert project["num_nodes"] == 0
def test_project_asset_and_node_counts(scenes_root):
project = _make_project(
scenes_root, "counted", {"save.json": (1000, {"name": "Counted"})}
)
(project / "assets" / "one.png").write_bytes(b"png")
(project / "assets" / "two.webp").write_bytes(b"webp")
_write_json(str(project / "nodes" / "scene-loop.json"), {})
_write_json(str(project / "nodes" / "sub" / "module.json"), {})
tree = list_scenes_tree()
counted = next(p for p in tree["projects"] if p["name"] == "counted")
assert counted["num_assets"] == 2
assert counted["num_nodes"] == 2
def test_projects_sorted_newest_first(scenes_root):
_make_project(scenes_root, "older", {"save.json": (1000, {"name": "Older"})})
_make_project(scenes_root, "newer", {"save.json": (2000, {"name": "Newer"})})
tree = list_scenes_tree()
assert [project["name"] for project in tree["projects"]] == ["newer", "older"]
def test_excluded_subdirs_and_empty_projects(scenes_root):
project = _make_project(
scenes_root, "adventure", {"save.json": (1000, {"name": "Adventure"})}
)
# json files in nodes/changelog/assets/info must not be listed
_write_json(str(project / "nodes" / "graph.json"), {})
_write_json(str(project / "changelog" / "save.json.base.json"), {})
_write_json(str(project / "assets" / "library.json"), {"assets": {}})
_write_json(str(project / "info" / "modules.json"), {})
# a project directory without any save files is not listed
(scenes_root / "empty-project").mkdir()
# a reserved top-level assets dir is not a project
_write_json(str(scenes_root / "assets" / "some.json"), {})
tree = list_scenes_tree()
assert [project["name"] for project in tree["projects"]] == ["adventure"]
assert [entry["filename"] for entry in tree["projects"][0]["files"]] == [
"save.json"
]
def test_unparseable_and_non_dict_saves_are_still_listed(scenes_root):
project = scenes_root / "broken"
project.mkdir()
invalid = project / "invalid.json"
invalid.write_text("{not valid json")
_touch(str(invalid), 1000)
non_dict = project / "list.json"
_write_json(str(non_dict), [1, 2, 3])
_touch(str(non_dict), 2000)
tree = list_scenes_tree()
files = tree["projects"][0]["files"]
assert [entry["filename"] for entry in files] == ["list.json", "invalid.json"]
for entry in files:
assert entry["scene_name"] is None
assert entry["cover_image"] is None
def test_cover_probe_fallback_without_library(scenes_root):
project = _make_project(
scenes_root,
"no-library",
{
"save.json": (
1000,
{"name": "No Library", "assets": {"cover_image": COVER_ID}},
)
},
cover_in_library=False,
)
# cover asset exists on disk but not in a library.json
asset_path = project / "assets" / f"{COVER_ID}.png"
os.makedirs(str(project / "assets"), exist_ok=True)
asset_path.write_bytes(b"png-bytes")
tree = list_scenes_tree()
assert tree["projects"][0]["cover_image"] == {
"id": COVER_ID,
"file_type": "png",
"media_type": "image/png",
"path": str(asset_path),
}
def test_cover_path_anchored_at_project_for_nested_saves(scenes_root):
# a project whose only save lives in a subdirectory still resolves the
# cover against the project-level assets dir
project = scenes_root / "nested"
_write_json(
str(project / "sub" / "save.json"),
{"name": "Nested", "assets": {"cover_image": COVER_ID}},
)
_write_json(
str(project / "assets" / "library.json"),
{
"assets": {
COVER_ID: {
"id": COVER_ID,
"file_type": "png",
"media_type": "image/png",
}
}
},
)
tree = list_scenes_tree()
cover = tree["projects"][0]["cover_image"]
assert cover["path"] == str(project / "assets" / f"{COVER_ID}.png")
def test_cover_unresolvable(scenes_root):
_make_project(
scenes_root,
"no-cover",
{
"save.json": (
1000,
{"name": "No Cover", "assets": {"cover_image": "missing-id"}},
)
},
cover_in_library=False,
)
tree = list_scenes_tree()
assert tree["projects"][0]["cover_image"] is None
def test_character_cards_listing(scenes_root):
characters = scenes_root / "characters"
characters.mkdir()
png = characters / "alice.png"
png.write_bytes(b"png")
_touch(str(png), 1000)
webp = characters / "bob.webp"
webp.write_bytes(b"webp")
_touch(str(webp), 3000)
card_json = characters / "carol.json"
_write_json(str(card_json), {"spec": "chara_card_v2"})
_touch(str(card_json), 2000)
v0_card = characters / "dave.json"
_write_json(str(v0_card), {"first_mes": "Hello"})
_touch(str(v0_card), 1500)
# scene uploads and other non-card json files land here too - not cards
scene_json = characters / "uploaded-scene.json"
_write_json(str(scene_json), {"name": "A Scene", "environment": "scene"})
broken_json = characters / "broken.json"
broken_json.write_text("{not json")
ignored = characters / "readme.txt"
ignored.write_text("not a card")
# upload side artifacts in subdirectories are never cards
_write_json(str(characters / "assets" / "library.json"), {"assets": {}})
nested_png = characters / "assets" / "cover.png"
nested_png.write_bytes(b"png")
tree = list_scenes_tree()
# characters dir is not listed as a project
assert tree["projects"] == []
cards = tree["characters"]
assert [card["filename"] for card in cards] == [
"bob.webp",
"carol.json",
"dave.json",
"alice.png",
]
assert cards[0]["media_type"] == "image/webp"
assert cards[1]["media_type"] == "application/json"
assert cards[3]["media_type"] == "image/png"
def test_scene_meta_cache_invalidates_on_mtime(scenes_root):
project = scenes_root / "cached"
project.mkdir()
save = project / "save.json"
_write_json(str(save), {"name": "First"})
_touch(str(save), 1000)
tree = list_scenes_tree()
assert tree["projects"][0]["files"][0]["scene_name"] == "First"
# same mtime -> cached name survives a content change
_write_json(str(save), {"name": "Second"})
_touch(str(save), 1000)
tree = list_scenes_tree()
assert tree["projects"][0]["files"][0]["scene_name"] == "First"
# new mtime -> cache invalidated
_touch(str(save), 2000)
tree = list_scenes_tree()
assert tree["projects"][0]["files"][0]["scene_name"] == "Second"