* 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
@@ -11,6 +11,7 @@
|
||||
- "Client Auto Retry: Clients can now automatically retry on response issues before you are notified — separate 0–5 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."
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
!!! 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.
|
||||
|
||||
|
Before Width: | Height: | Size: 634 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 44 KiB |
BIN
docs/img/0.39.0/character-card-4.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
docs/img/0.39.0/getting-started-load-screen.png
Normal file
|
After Width: | Height: | Size: 628 KiB |
BIN
docs/img/0.39.0/model-testing-harness-load.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
docs/img/0.39.0/scene-browser-delete-project.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
docs/img/0.39.0/scene-browser-library.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
@@ -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):
|
||||
|
||||

|
||||

|
||||
|
||||
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).
|
||||
|
||||

|
||||
|
||||
The import dialog will automatically analyze the character card and display detected information.
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
The tests start automatically as soon as the scene finishes loading — there is no separate "run" button to press.
|
||||
|
||||
|
||||
73
docs/user-guide/scene-browser.md
Normal 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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

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

|
||||
|
||||
!!! 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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/,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
474
talemate_frontend/src/components/SceneBrowser.vue
Normal 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>
|
||||
502
talemate_frontend/src/components/SceneLanding.vue
Normal 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>
|
||||
@@ -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'] : []),
|
||||
|
||||
220
tests/test_delete_scene_project.py
Normal 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()
|
||||
@@ -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
@@ -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"
|
||||