mirror of
https://github.com/vegu-ai/talemate.git
synced 2026-08-29 10:08:58 +02:00
* fix: render the scene backdrop through an object URL (issue #313) * docs: state the disabled timeline actions as disabled in this version, no return promise * fix: refuse unsafe and colliding save names when forking from a message * fix: give fork_scene a real default save name instead of a missing method * fix: slug the generated fork name so the guard cannot refuse it; pin the refusal messages
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
</v-alert>
|
||||
|
||||
<v-alert color="muted" icon="mdi-history" density="compact" variant="tonal" class="text-caption mt-3">
|
||||
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.
|
||||
</v-alert>
|
||||
</template>
|
||||
</v-card-text>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
14
talemate_frontend/src/utils/objectUrl.js
Normal file
14
talemate_frontend/src/utils/objectUrl.js
Normal file
@@ -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 <img>, 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 }));
|
||||
}
|
||||
153
tests/test_creator_fork_scene.py
Normal file
153
tests/test_creator_fork_scene.py
Normal file
@@ -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"
|
||||
)
|
||||
Reference in New Issue
Block a user