diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f81eb9a..b9ebdbef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ Chats can be made scene-aware, and each question carries a small snapshot of wha A new timeline dialog gives scene history a single home, replacing the old Restore from Backup dialog. Drag a slider across the scene's automatic version history to preview the message history at any revision, then fork that revision into a new save — the scene you are playing is never modified, and browsing the history changes nothing on disk. -Forking is the only action the timeline applies in this version. Rolling a scene back in place and opening a revision directly are disabled while they are being reworked, and will return in a future version. +Forking is the only action the timeline applies. Rolling a scene back in place and opening a revision directly are disabled in this version. ### Pi Bridge Client diff --git a/CHANGELOG.yaml b/CHANGELOG.yaml index e6c21cb7..9e386342 100644 --- a/CHANGELOG.yaml +++ b/CHANGELOG.yaml @@ -33,11 +33,12 @@ - "Scene Browser Landing Page: The Quick Load cards now pop out slightly on mouse hover, without shifting the cards around them." - "Prompt Finalization: UX polish for the post-processing actions editor — action fields lay out on a single balanced row with per-mode visibility (flags only show for exact and regex modes), each action card gained a compact header showing its mode and target plus tooltips on all controls, disabled actions dim their fields, an empty action list shows a hint instead of blank space." changes: - - "Timeline: Forking a revision to a new save is the only action the timeline applies in this release. Rolling a scene back in place and opening a revision directly are disabled while they are being reworked and will return in a future version — their websocket routes are gone, so no timeline action can write over an existing scene file. Browsing and previewing revisions are unaffected, and forking now refuses a save name that already exists instead of writing over it." + - "Timeline: Forking a revision to a new save is the only action the timeline applies in this release. Rolling a scene back in place and opening a revision directly are disabled in this version — their websocket routes are gone, so no timeline action can write over an existing scene file. Browsing and previewing revisions are unaffected, and forking now refuses a save name that already exists instead of writing over it." - "Dependencies: Refreshed the dependency lock across the board, including a setuptools bump to 83.0.0 which carries a fix for a source-distribution file-exclusion vulnerability (GHSA-h35f-9h28-mq5c). No dependency required code migrations." - "OpenRouter Client: The default model for newly created OpenRouter clients is now google/gemini-3.6-flash." - "OpenRouter Client: New OpenRouter clients now have reasoning enabled by default, with a budget of 2048 reasoning tokens, so the default model works out of the box. With reasoning off Talemate pre-fills the start of the response to steer it, and some providers — Google and Anthropic among them — reject requests that do that." fixes: + - "Scene Forking: Forking from a message now refuses a save name that is not a valid filename or that an existing save already uses, instead of writing over that save or outside the scene directory. The timeline fork already refused both." - "Prompt Finalization: The fuzzy match threshold slider's always-visible value bubble no longer overlaps the note above it." - "Frontend: A backend websocket URL configured with the host 0.0.0.0 now connects — the URL was used literally, which most browsers refuse, so the app stayed on 'backend not connected'. The host is resolved to the hostname the UI itself was loaded from." - "Help Chat: The warning shown while the Help agent has no configured client no longer stretches to fill the entire help panel — it renders as a compact notice above the chat." diff --git a/docs/user-guide/restoring-scenes.md b/docs/user-guide/restoring-scenes.md index 238e2be9..46940cd4 100644 --- a/docs/user-guide/restoring-scenes.md +++ b/docs/user-guide/restoring-scenes.md @@ -6,7 +6,7 @@ Talemate provides two ways to return a scene to a previous state: - **Restore from Restore Point** — reset to a specific save file you've designated as a baseline in the [scene settings](/talemate/user-guide/world-editor/scene/settings#restoration-settings) !!! warning "The timeline only forks in this version" - Rolling a scene back in place and opening a revision directly are both disabled while they are being reworked, and will return in a future version. Forking a revision into a new save is the one action the timeline applies — it writes a new save alongside the scene and leaves every existing save untouched. + Rolling a scene back in place and opening a revision directly are both disabled in this version. Forking a revision into a new save is the one action the timeline applies — it writes a new save alongside the scene and leaves every existing save untouched. ## The timeline diff --git a/src/talemate/agents/creator/assistant.py b/src/talemate/agents/creator/assistant.py index 36d87ab8..2f346a29 100644 --- a/src/talemate/agents/creator/assistant.py +++ b/src/talemate/agents/creator/assistant.py @@ -1,5 +1,6 @@ import json import re +from datetime import datetime, timezone from typing import TYPE_CHECKING, Tuple import traceback import uuid @@ -12,6 +13,7 @@ from talemate.emit import emit from talemate.instance import get_agent from talemate.prompts import Prompt from talemate.prompts.base import StripMode +from talemate.util.path import is_safe_relative_filename from talemate.util.response import extract_list from talemate.scene_message import CharacterMessage from talemate.world_state.templates import ( @@ -808,12 +810,44 @@ class AssistantMixin: This properly creates a new scene file without modifying the current scene, then signals the frontend to load the new scene. + + The fork always writes a save of its own: a name that is unsafe as a + filename, or that would land on an existing file, is refused rather + than escaping the save directory or overwriting a save. Without a + name it writes a timestamped one beside the scene, slugged so the + generated name cannot be the thing that gets refused. """ try: emit("status", "Preparing to fork scene...", status="busy") if not save_name: - save_name = self.scene.generate_name() + # new scenes before their first save and restored scenes have + # no filename + base = ( + os.path.splitext(self.scene.filename)[0] + if self.scene.filename + else self.scene.project_name + ) + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + save_name = f"{util.slugify(base) or 'scene'}_fork_{stamp}" + + fork_filename = f"{save_name}.json" + + if not is_safe_relative_filename(fork_filename, suffix=".json"): + emit( + "status", + f"'{save_name}' is not a valid save name", + status="error", + ) + return + + if os.path.exists(os.path.join(self.scene.save_dir, fork_filename)): + emit( + "status", + f"A save named '{save_name}' already exists — pick a different name", + status="error", + ) + return # Find the message to fork from message = self.scene.get_message(message_id) @@ -889,7 +923,7 @@ class AssistantMixin: scene_data["shared_context"] = "" # Write the fork file - fork_file_path = os.path.join(self.scene.save_dir, f"{save_name}.json") + fork_file_path = os.path.join(self.scene.save_dir, fork_filename) with open(fork_file_path, "w") as f: json.dump(scene_data, f, indent=2, cls=SceneEncoder) diff --git a/src/talemate/agents/help/docs-index.yaml b/src/talemate/agents/help/docs-index.yaml index 7a812f96..38c30898 100644 --- a/src/talemate/agents/help/docs-index.yaml +++ b/src/talemate/agents/help/docs-index.yaml @@ -998,8 +998,7 @@ history to preview any revision, then fork that revision into a new save; browsing and previewing write nothing to disk, and a save name an existing save already uses is refused) and restore from a designated restore point save file. Opened from the scene tools Save menu, a message's Fork button, or the scene card's three-dot menu. Forking is the timeline's - only action in this version — rolling a scene back in place and opening a revision directly are both disabled while they - are being reworked. + only action in this version — rolling a scene back in place and opening a revision directly are both disabled. - path: user-guide/saving.md title: Saving summary: How scene saving works, .json save files in the project directory, automatic changelog version history, restore diff --git a/talemate_frontend/src/components/SceneMessages.vue b/talemate_frontend/src/components/SceneMessages.vue index 514bd5ea..c35a5358 100644 --- a/talemate_frontend/src/components/SceneMessages.vue +++ b/talemate_frontend/src/components/SceneMessages.vue @@ -410,6 +410,7 @@ import { isVisualAgentReady, VIS_TYPE } from '@/constants/visual'; import { isKnownSceneCharacter } from '@/utils/entityActions'; import { parseCharacterMessage } from '@/utils/characterMessage.js'; import { primaryModifierLabel } from '@/utils/keyboardModifiers'; +import { base64ToObjectUrl } from '@/utils/objectUrl.js'; import { getMessageColor as resolveMessageColor, getMessageStyle as resolveMessageStyle, @@ -616,6 +617,7 @@ export default { // cache (the asset may still be in flight when the dialog opens) assetViewShow: false, assetViewAssetId: null, + sceneBackdropObjectUrl: null, } }, computed: { @@ -651,8 +653,9 @@ export default { sceneBackdropAssetId() { return this.sceneBackdrop?.assetId || null; }, - sceneBackdropSrc() { - return this.assetDataUrl(this.sceneBackdropAssetId); + sceneBackdropAsset() { + const assetId = this.sceneBackdropAssetId; + return assetId ? this.assetCache[assetId] || null : null; }, // Most recent message-attached scene background the scene-tools // "Immersive" chip could promote to a backdrop when none is set yet @@ -1336,6 +1339,26 @@ export default { return cached ? `data:${cached.mediaType};base64,${cached.base64}` : null; }, + requestSceneBackdropAsset() { + const assetId = this.sceneBackdropAssetId; + if (assetId && this.requestSceneAssets && !this.assetCache[assetId]) { + this.requestSceneAssets([assetId]); + } + }, + + /** + * Swap in the backdrop's object URL, revoking the one it replaces. + * The backdrop reaches the DOM as a CSS custom property, and chromium + * silently drops custom property values over 2 MiB — which a scene + * image as a base64 data URL almost always exceeds. + */ + setSceneBackdropObjectUrl(url) { + if (this.sceneBackdropObjectUrl) { + URL.revokeObjectURL(this.sceneBackdropObjectUrl); + } + this.sceneBackdropObjectUrl = url; + }, + showAssetMenu(event, context) { // Store the context (asset_id, asset_type, character, etc.) this.assetMenu.context = { ...context }; @@ -2170,14 +2193,29 @@ export default { }, deep: true, }, - sceneBackdropAssetId(assetId) { - if (assetId && this.requestSceneAssets && !this.assetCache[assetId]) { - this.requestSceneAssets([assetId]); - } + sceneBackdropAssetId: { + immediate: true, + handler() { + this.requestSceneBackdropAsset(); + }, + }, + // Scene load empties the asset cache without changing the backdrop id, + // so the id watcher alone would leave a reloaded scene without its + // backdrop image. + sceneBackdropAsset: { + immediate: true, + handler(asset) { + this.setSceneBackdropObjectUrl( + asset ? base64ToObjectUrl(asset.base64, asset.mediaType) : null + ); + if (!asset) { + this.requestSceneBackdropAsset(); + } + }, }, // The backdrop is painted by TalemateApp behind the whole scene // column, so hand the resolved image up - sceneBackdropSrc: { + sceneBackdropObjectUrl: { immediate: true, handler(src) { this.$emit('scene-backdrop', src); @@ -2201,6 +2239,7 @@ export default { clearTimeout(this._reapplyDebounceTimer); this._reapplyDebounceTimer = null; } + this.setSceneBackdropObjectUrl(null); }, } diff --git a/talemate_frontend/src/components/SceneTimeline.vue b/talemate_frontend/src/components/SceneTimeline.vue index 3faebef2..3cb2e4c1 100644 --- a/talemate_frontend/src/components/SceneTimeline.vue +++ b/talemate_frontend/src/components/SceneTimeline.vue @@ -85,7 +85,7 @@ - Rolling a scene back in place and opening a revision directly are disabled in this version while they are being reworked, and will return in a future version. Fork the revision into a new save instead — it is written alongside the scene and leaves every existing save untouched. + Rolling a scene back in place and opening a revision directly are disabled in this version. Fork the revision into a new save instead — it is written alongside the scene and leaves every existing save untouched. diff --git a/talemate_frontend/src/components/TalemateApp.vue b/talemate_frontend/src/components/TalemateApp.vue index b47872e1..8985983e 100644 --- a/talemate_frontend/src/components/TalemateApp.vue +++ b/talemate_frontend/src/components/TalemateApp.vue @@ -531,7 +531,7 @@ export default { data() { return { appearancePreview: null, // Preview config while editing settings (null = use saved config) - // data-url of the scene illustration acting as the scene backdrop + // object URL of the scene illustration acting as the scene backdrop // (scene.assets.backdrop), reported up by SceneMessages which owns // the asset cache sceneBackdropSrc: null, diff --git a/talemate_frontend/src/components/WhatsNew.vue b/talemate_frontend/src/components/WhatsNew.vue index ef2bf953..f8c8ab23 100644 --- a/talemate_frontend/src/components/WhatsNew.vue +++ b/talemate_frontend/src/components/WhatsNew.vue @@ -106,7 +106,7 @@ export default { }, { "title": "Timeline", - "description": "A new timeline dialog gives scene history a single home, replacing the old Restore from Backup dialog. Drag a slider across the scene's automatic version history to preview the message history at any revision, then fork that revision into a new save — the scene you are playing is never modified, and browsing the history changes nothing on disk.\n\nForking is the only action the timeline applies in this version. Rolling a scene back in place and opening a revision directly are disabled while they are being reworked, and will return in a future version." + "description": "A new timeline dialog gives scene history a single home, replacing the old Restore from Backup dialog. Drag a slider across the scene's automatic version history to preview the message history at any revision, then fork that revision into a new save — the scene you are playing is never modified, and browsing the history changes nothing on disk.\n\nForking is the only action the timeline applies. Rolling a scene back in place and opening a revision directly are disabled in this version." }, { "title": "Pi Bridge Client", diff --git a/talemate_frontend/src/utils/objectUrl.js b/talemate_frontend/src/utils/objectUrl.js new file mode 100644 index 00000000..fb9d4eb1 --- /dev/null +++ b/talemate_frontend/src/utils/objectUrl.js @@ -0,0 +1,14 @@ +/** + * Turn base64 asset data into an object URL. Callers own the returned URL and + * must revoke it. Asset data is fine as a data URL on an , but not in + * CSS: chromium silently drops custom property values over 2 MiB, which scene + * images exceed as base64. + */ +export function base64ToObjectUrl(base64, mediaType = 'image/png') { + const binary = window.atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return URL.createObjectURL(new Blob([bytes], { type: mediaType })); +} diff --git a/tests/test_creator_fork_scene.py b/tests/test_creator_fork_scene.py new file mode 100644 index 00000000..57bfb381 --- /dev/null +++ b/tests/test_creator_fork_scene.py @@ -0,0 +1,153 @@ +""" +Tests for the save-name guard on ``creator.fork_scene``. + +Forking from a message reaches this path with a user-typed name. Like the +timeline fork, it must refuse a name that is unsafe as a filename or that +would land on an existing save, rather than escaping the save directory or +overwriting a save with forked data. +""" + +import os +import re + +import pytest + +import talemate.instance as instance +from talemate.scene_message import NarratorMessage + +from conftest import MockClient, MockScene, bootstrap_engine + + +class _ForkScene(MockScene): + """Real ``Scene`` with ``save_dir`` pointed at a tmp dir. + + The parent exposes ``save_dir`` as a ``@property`` that joins the scenes + directory with the project name; shadowing it with a class-level plain + attribute lets the instance own it. + """ + + save_dir = None # type: ignore[assignment] + + def __init__(self, save_dir: str, **kwargs): + super().__init__(**kwargs) + self.save_dir = save_dir + + +@pytest.fixture +def creator(tmp_path): + bootstrap_engine() + scene = _ForkScene(save_dir=str(tmp_path)) + # distinct from the name, so a test can tell which one a generated save + # name was built from + scene.filename = "test_scene.json" + scene.name = "Project Scene" + scene.history.append(NarratorMessage("the scene begins")) + agent = instance.get_agent("creator") + agent.client = MockClient("test_client") + agent.scene = scene + return agent + + +@pytest.fixture +def emitted(monkeypatch): + """Capture the agent's status emissions as ``(typ, message, kwargs)``.""" + calls = [] + monkeypatch.setattr( + "talemate.agents.creator.assistant.emit", + lambda typ, message=None, **kwargs: calls.append((typ, message, kwargs)), + ) + return calls + + +def assert_refused(emitted, message): + assert ("status", message, {"status": "error"}) in emitted + + +@pytest.fixture +def message_id(creator): + return creator.scene.history[0].id + + +async def test_fork_writes_a_new_save(creator, message_id, tmp_path): + fork_path = await creator.fork_scene(message_id, save_name="forked") + + assert fork_path == os.path.join(str(tmp_path), "forked.json") + assert os.path.exists(fork_path) + + +@pytest.mark.parametrize("save_name", [None, ""]) +async def test_fork_without_a_name_writes_a_timestamped_save( + creator, message_id, tmp_path, save_name +): + fork_path = await creator.fork_scene(message_id, save_name=save_name) + + assert fork_path is not None + assert os.path.exists(fork_path) + assert re.fullmatch( + r"test-scene_fork_\d{8}T\d{6}Z\.json", os.path.basename(fork_path) + ) + + +async def test_fork_without_a_name_falls_back_to_the_project_name( + creator, message_id, tmp_path +): + """New scenes before their first save, and restored scenes, have no + filename.""" + creator.scene.filename = "" + + fork_path = await creator.fork_scene(message_id) + + assert fork_path is not None + assert os.path.exists(fork_path) + assert os.path.basename(fork_path).startswith("project-scene_fork_") + + +@pytest.mark.parametrize( + "scene_name,expected_base", + [ + ("Fate/Stay Night", "fate-stay-night"), + ("..", "scene"), + ], +) +async def test_fork_without_a_name_slugs_the_base( + creator, message_id, tmp_path, scene_name, expected_base +): + """A scene name the guard would refuse must not make the no-name fork + impossible — the caller supplied nothing, so there is nothing to correct.""" + creator.scene.filename = "" + creator.scene.name = scene_name + + fork_path = await creator.fork_scene(message_id) + + assert fork_path is not None + assert os.path.exists(fork_path) + assert re.fullmatch( + rf"{expected_base}_fork_\d{{8}}T\d{{6}}Z\.json", os.path.basename(fork_path) + ) + + +async def test_fork_refuses_a_name_that_escapes_the_save_dir( + creator, message_id, tmp_path, emitted +): + outside = tmp_path.parent / "escaped.json" + + assert await creator.fork_scene(message_id, save_name="../escaped") is None + assert not outside.exists() + assert_refused(emitted, "'../escaped' is not a valid save name") + + +@pytest.mark.parametrize("save_name", ["nested/fork", "..", "with\x00nul"]) +async def test_fork_refuses_unsafe_names(creator, message_id, tmp_path, save_name): + assert await creator.fork_scene(message_id, save_name=save_name) is None + assert list(tmp_path.iterdir()) == [] + + +async def test_fork_refuses_an_existing_save(creator, message_id, tmp_path, emitted): + existing = tmp_path / "taken.json" + existing.write_text("original") + + assert await creator.fork_scene(message_id, save_name="taken") is None + assert existing.read_text() == "original" + assert_refused( + emitted, "A save named 'taken' already exists — pick a different name" + )