diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..00b0f8da --- /dev/null +++ b/.dockerignore @@ -0,0 +1,36 @@ +# Build context excludes. Every path the Dockerfile COPYs from the context must +# stay out of this list: talemate_frontend/, src/, docs/, scenes/, templates/, +# tts/, chroma*, pyproject.toml, uv.lock, frontend_wsgi.py, config.example.yaml +# and docker-entrypoint.sh. + +.git +.github +tests + +# docs/ is NOT excluded. The help agent reads TALEMATE_ROOT/docs at runtime, so +# the backend-build stage copies it and keeps the markdown. + +# A host node_modules lands on top of the one the image installed. pnpm then +# reads the host's own paths out of it, decides the dependencies are stale, and +# reinstalls mid-build - which aborts when no TTY can confirm the purge. +**/node_modules +talemate_frontend/dist + +.venv +talemate_env +embedded_python +embedded_node +**/__pycache__ +**/*.pyc +*.egg-info +.pytest_cache +.ruff_cache +.coverage + +# Runtime state and credentials. docker-compose mounts these as volumes, so an +# image layer never needs them. +config.yaml +secrets +pi +logs +chats diff --git a/.gitignore b/.gitignore index 85e2b11d..777e5b7b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,9 +8,11 @@ talemate_env embedded_python/ embedded_node/ +chats/ chroma config.yaml secrets/ +pi/ logs/ .cursor .claude diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d0d6ffd..88e05556 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,170 @@ _Auto-generated. Do not edit by hand._ +## 0.39.0 + +### Help Agent + +A new Help agent answers questions about Talemate itself — settings, agents, clients, the world editor and more — grounded in the bundled documentation, which it can search and read before answering. Opened via the help icon in the app bar, it works with or without a loaded scene, keeps multiple persistent chats, and runs in the background so it never blocks the main Talemate loop. + +Chats can be made scene-aware, and each question carries a small snapshot of what you are looking at in the interface. When asked, the help agent can also read your configuration and change agent and application settings, recording exactly what changed — it never exposes API keys and cannot change scene content. + +### Timeline + +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. Rolling a scene back in place and opening a revision directly are disabled in this version. + +### Pi Bridge Client + +A new Pi Bridge client type drives generations through the pi coding agent's headless RPC mode instead of a direct LLM API. pi owns provider authentication and model resolution — including custom providers and models defined in pi's own configuration — so any model pi can reach becomes usable in Talemate. + +Provider and model are free-form fields with suggestions pulled from pi's model catalog, reasoning maps to pi's thinking levels with the thinking output captured for the reasoning display, and concurrent inference runs an isolated pi instance per request. Sampling parameters stay on the pi side by design. + +### Application Settings + +The application settings move out of their modal into an always-available Settings tab alongside the World Editor and Templates. The nested tabs-within-tabs layout is replaced by a single sidebar grouped by topic — Game, Appearance, Connections, Presets and Storytelling — and a settings search finds any setting by name, including individual API-key providers, and jumps straight to it. All API keys now live on one scrollable page instead of ten per-provider pages. + +Edits are tracked against the stored configuration: an unsaved-changes indicator with Save and Discard appears in the toolbar and on the Settings tab, unsaved edits survive switching to other tabs, and configuration changed elsewhere while you are editing is flagged instead of silently overwritten. Existing deep links, such as the Set API key action on a misconfigured client, land on the correct page as before. + +### Scene Library + +The home screen is rebuilt as 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, along with an asset and node-module summary for each expanded project. Long save lists collapse behind a Show all row, the list is filterable, and a click loads the save. + +Individual scene files and character cards can be deleted from the tree, and an entire scene project — saves, assets, nodes and history — can be deleted behind a type-the-name confirmation. Scene and character card import now happens in a prominent drag-and-drop dropzone next to a Create new scene card, with the quick-load recent scene cards kept on top. + +### Fast Character Creation + +The Creator agent gains a Character Creation section with a Fast mode that generates a character in one request instead of one prompt per aspect. A Consolidate multi-select picks which aspects — name, description, attributes, dialogue instructions, example dialogue — the single prompt covers; unselected aspects keep their individual prompts, and selected attribute templates fold in as per-attribute instructions instead of a prompt each. A token budget slider sizes the shared response, and a Fill in misses toggle re-runs the individual request for anything the consolidated response missed. + +Every AI-assisted character creation path routes through this flow, and the consolidated prompt applies the scene's writing style template when one is configured. The split per-aspect flow remains the default for older or smaller models, and Fast mode is off by default. + +### Scene Backdrop + +Any scene illustration can now be set as the scene backdrop — an image that fills the whole scene view behind the messages instead of rendering inline. The backdrop belongs to the scene and is saved with it, so it survives reloads and history edits. Message text sits on translucent panels with a drop shadow for legibility — panel opacity and the text shadow are configurable. + +An optional Auto Backdrop setting promotes newly generated scene backgrounds and illustrations automatically, and an Immersive quick-toggle chip in the scene tools turns the backdrop on and off without forgetting the chosen image. + +### Scene Visual Manager + +World Editor → Scene gains a Visuals tab mirroring the character visual manager: browse, upload, generate (single or batch, new or as variations of a reference image), and delete the scene's background illustrations, scene illustrations and scene cards, set the scene cover image, and set or fully unset the scene backdrop (the Scene Card sub-tab is portrait and offers no set-backdrop action). A Prompt Finalization sub-tab edits the Visualizer agent's per-scene prompt finalization overrides. + +### Visual Prompt Finalization + +The Visualizer agent gains a Prompt Finalization settings tab defining post-processing actions — exact, fuzzy, or regex match and replace, or a free-form AI instruction — that rewrite image prompts right before they are sent to the image generation backend. Actions can target positive and/or negative prompts, be restricted to specific visual types, and be overridden per scene; characters can define their own actions that run after the agent's. + +Reusable action sets are managed as a new Visual prompt finalizer template type, including a shipped Ideogram JSON preset, and a new FinalizePrompt node exposes the step to custom node graphs. + +### Improvements + +**Clients & Generation** + +- Client settings now offer the same widget set as agent settings — sliders, autocompletes, rich selects with notes, and conditional visibility. +- MistralAI: added a Concurrent Inference toggle so batch operations can dispatch multiple requests in parallel. Off by default. +- Text-Generation-WebUI: a new API handles prompt template toggle lets the server apply the model's own chat template; response pre-filling and reasoning capture keep working. Off by default. +- llama.cpp: the same API handles prompt template toggle renders prompts through the model's built-in chat template, with reasoning respecting the client's setting. Off by default. +- Clients can automatically retry empty responses, rate limiting, and skipped reasoning — separate 0–5 retry sliders per issue, with a progress notification and abort. Off by default. +- OpenRouter: a new Parameters tab toggles individual sampler parameters — disabled ones are omitted from the request entirely, for providers that reject parameters they don't support. +- Pi Bridge: the Docker image ships with pi preinstalled and mounts pi's configuration directory, so the client works in Docker out of the box. +- A new Environment Variables settings page stores named values encrypted at rest and passes them to Talemate's pi instances, so pi's configuration can reference secrets by name. + +**Scene & UI** + +- AI-assisted character creation gains a Generate example dialogue option, with a guidance field to steer tone, speech patterns, and quirks. Off by default. +- The two Add tracked state actions in the scene tools are now a single Track state action — pick the world or a character in the modal. +- Character card import gains per-step AI generation toggles with Full and Minimal presets — disabled steps use the card's original data, so a minimal import needs no text generation. +- Per-scene agent overrides now cover the enable toggle of every agent action, not just the settings underneath it. +- Agent quick-toggle chips show and toggle the value the loaded scene is running on — its override when one is active, the global setting otherwise. +- The Quick Load recent-scene cards are smaller and scale with the viewport, so a full row no longer pushes the Scene Library down. +- Quick Load cards and Scene Library saves share one context menu — Timeline, Remove from Quick Load, and Delete. + +**Node Editor** + +- New `agent.visual.prompt_finalize.before` / `.after` events fire around visual prompt finalization — prompts are editable, and `.before` can modify the finalizer list — even when the agent setting is off. +- New `agent.creator.dialogue_examples.before` / `.after` events wrap example-dialogue generation — inject instructions or rewrite the generated examples. +- New `agent.help.chat.before` / `.after` events fire around help chat responses. +- Scene assets gain `asset_deleted`, `scene.backdrop_changed`, `scene.cover_image_changed`, and `character.cover_image_changed` events alongside the existing `asset_saved`. +- All new events are documented in the node editor's Events reference. +- A new Generate Character node generates character data following the creator agent's Character Creation settings; wire its outputs into Persist Character to add the character to the scene. +- The Create Character module now builds through that node, gaining `generate_dialogue_instructions` (on) and `generate_example_dialogue` (off) inputs; the simulation suite enables both. + +**Documentation & Help** + +- The documentation gained a complete node reference covering 400+ nodes, listing each node's registry path, sockets and properties. +- The help agent searches and reads the node reference, so questions about what a node does return grounded answers. +- The help chat looks documentation pages up on demand instead of carrying the whole index in every message, so it works with small context windows. + +### Bug Fixes + +**Agents & Settings** + +- Closing an agent's settings dialog without changing anything no longer re-saves its settings, which could silently revert changes made elsewhere while the dialog was open. +- The help agent refuses to change settings for an agent whose dialog is open, asking to close it first. +- Agent settings in Scene mode now honor each setting's visibility conditions, so gated settings and empty sections no longer show up as scene overrides. +- Pocket TTS: pasting a Hugging Face token now enables the gated voice-cloning download without a restart, and the agent warns while cloning is unavailable. +- Director chat: images generated from the chat are now inserted into the conversation that requested them. +- Agent sections that are always on by design — Summarization, Conversation and Narrator Generation, Character Portraits — are no longer switched off in the background by a stale configuration value. +- Agent settings added in a new release now arrive in the state they ship with instead of switched off; settings you switched off yourself stay off. + +**Backends** + +- OpenRouter: a failed model and provider list fetch at startup no longer sticks until restart — later config saves and status refreshes retry it. +- OpenRouter: setting the API key for the first time during initial setup now triggers the provider fetch. + +**Character Creation & Import** + +- Character card import: a failed or cancelled description or example dialogue generation now keeps the card's own text. +- Character card import: cancelling an import now aborts it instead of letting it run to completion. +- Character card import: attribute extraction now sees the character's description, so imported attributes reflect it. +- A description typed into the world editor's character creator is no longer discarded when AI generation is enabled. +- The director's Limit character attributes setting now applies to character card import, in both the split and Fast flows. +- The director's attribute limit now delivers the number it promises — the character's own name no longer costs one of the allowed slots. +- Fast character creation no longer asks for the name as an attribute, so it is neither duplicated nor counted against the limit. +- Example dialogue no longer keeps typographic quotes — they are converted to straight quotes on import, manual entry, and generation, so lines render as speech. + +**Clients** + +- A single failed status check no longer briefly flaps a connected client to 'Could not connect' — disconnection now requires consecutive failures. +- The Context Length input now appears in the edit dialog for every client type, including OpenRouter and the remote API clients. +- The context and reasoning sliders in the client list no longer jitter back to previous values after dragging. +- Quickly editing two clients back to back no longer drops the first client's pending change. + +**Generation Errors** + +- Simultaneous generation failures are now queued and answered one after another, so earlier generations resume and their results are no longer lost. +- Pending error dialogs are cancelled cleanly when the scene is unloaded or the frontend disconnects. + +**Help & Documentation** + +- Documentation lookups now rank the page a question is about above pages that only mention the topic in passing. +- Help and director chat answers render headings, lists, quotes and paragraphs with proper spacing and indentation instead of one solid block. + +**Node Editor** + +- The Apply Style node now resolves real style template ids and applies the full template — use Apply Styles for configuration-driven styles. +- The Build Prompt node's `memory_prompt` and the Generate Response node's `action_type` input sockets take effect again, with wired values winning over the property. +- The Clean Up Narration node and the Stop node's StageExit choice no longer error when they run. +- The Compress Context ID Part node emits the original part on `uncompressed`, and As Bool reports an uncastable value properly. +- Registry typos fixed for `CleanUpCharacterMessage` and the shipped `instructGamestateUpdates` module; saved graphs keep loading via legacy aliases. +- Socket fixes across Generate Response, State Counter, Generate Scene Types, Apply Style(s), State Reinforcement, Focal and Spices. +- Property fixes — declared defaults match the effective ones on Context History and As Number, Set Scene Phase defaults to `roleplay`, and Update Message Assets drops an unused property. + +**Scenes** + +- Exporting a scene with Reset Progress checked no longer wipes the loaded scene's message history and world state — the reset applies only to the exported file. +- Deleting the save file of the currently loaded scene is refused with an error instead of being recreated by the next save. +- Applying a scene type template now actually registers the scene type on the scene, so the picked type is no longer lost. + +**Timeline** + +- Previewing a revision no longer fails on scenes whose stored version history diverged from its starting snapshot — affected entries are repaired during reconstruction. + +**Windows** + +- The `system_time` template function no longer errors on Windows. +- Pi Bridge: generations no longer hang at the very end, and cancelling one no longer leaks a background process. +- Pi Bridge: the client now launches pi correctly, and a failed launch names the executable that could not be run. + ## 0.38.0 ### Per-Scene Agent Overrides diff --git a/CHANGELOG.yaml b/CHANGELOG.yaml index c5d638dc..ae8e77e1 100644 --- a/CHANGELOG.yaml +++ b/CHANGELOG.yaml @@ -1,4 +1,92 @@ -0.38.0.dev: +0.39.0.dev: + features: + - "Node Reference: The documentation gained a complete node glossary covering every node available in the node editor - 400+ nodes across the engine, agent integrations and shipped node modules - organized into categorized reference pages listing each node's registry path, input/output sockets and properties. The Help Agent can search and read these pages, so asking it what a node does or how to use it now returns grounded answers. The pages are rendered from the live node registry by a new `glossary` subcommand on the node tools CLI (`python -m talemate.game.engine.nodes.tools glossary --write`), with a `--check` mode that detects when the committed reference has drifted from the code." + - "Pi Bridge Client: New client type that drives generations through the pi coding agent's headless RPC mode instead of a direct LLM API. pi owns provider authentication (environment API keys, pi's auth file including subscription auth) and model resolution — including custom providers and models defined in pi's models.json — so any model pi can reach becomes usable in Talemate. Provider and model are free-form fields with suggestions pulled from pi's model catalog, reasoning maps to pi's thinking levels with thinking output captured for the reasoning display, and concurrent inference runs one isolated pi instance per request. Sampling parameters stay on the pi side by design." + - "Environment Variable Store: Application settings gained an Environment Variables page for named values that are encrypted at rest and passed as environment variables to processes Talemate spawns — currently the Pi Bridge client's pi instances, so pi's models.json can reference secrets as $NAME instead of keeping plaintext keys in environment files." + - "Timeline: A new timeline dialog provides a single home for a scene's version history. Drag a slider across the scene's automatic version history to preview the message history at any revision, then fork that revision to a new save — the scene you are playing is never modified, and browsing the history changes nothing on disk. Opened from the scene tools Save menu, from the Fork button on any changelog-backed message (which positions the timeline at that message's revision), or from the Quick Load card menu on the main screen, where it replaces the old Restore from Backup dialog. Forking is the only action the timeline applies in this release, see below." + - "Character Creation Example Dialogue: AI-assisted character creation (World Editor → Characters → Create and the scene tools creative menu) gained a 'Generate example dialogue' option that generates a few example dialogue lines for the new character, with an optional guidance field to steer how the examples are written (tone, speech patterns, quirks). Off by default." + - "Scene Visual Manager: World Editor → Scene gained a Visuals tab mirroring the character visual manager, with Background Illustration, Scene Illustration and Scene Card sub-tabs to browse, upload, generate (new or as a variation of a reference image, single or batch), view, and delete scene images — plus per-image actions to set the scene cover image and set or unset the scene backdrop (the Scene Card sub-tab is portrait and offers no Set as Scene Backdrop action). A Prompt Finalization sub-tab edits the Visualizer agent's per-scene Prompt Finalization overrides directly from the world editor, creating the scene's agent-settings file with the default name when none is linked yet. The scene backdrop can now also be fully unset (not just toggled off), keeping the image in the scene assets." + - "Help Agent: A new Help agent provides an interactive help chat that answers questions about Talemate itself — settings, agents, clients, the world editor and more — grounded in the bundled documentation, which it can search and read before answering. Opened via the help icon in the app bar, it works with or without a loaded scene, supports multiple persistent multi-turn chats, and runs in the background so it never blocks the main Talemate loop. A per-chat Scene Aware toggle lets a conversation see (or stay unaware of) the currently loaded scene, and each question carries a small snapshot of what you are looking at in the interface so contextual questions like 'what does this setting do?' can be answered. The most recent answer can be regenerated via a button right on the message, matching the director chat. Beyond answering questions, the help agent can read your actual configuration — any agent's settings, application settings, and a read-only view of your clients (API keys are never exposed) — and, when asked, change agent settings (globally or as a per-scene override) and application settings directly from the chat, recording exactly what changed. It cannot change scene content — scene changes remain the director chat's job." + - "Scene Backdrop: Any scene illustration can now be set as the scene backdrop — an image that fills the whole scene view behind the messages instead of rendering inline. The backdrop belongs to the scene and is saved with it, so it survives reloads and history edits. Set it via 'Set as scene backdrop' on any illustration's image menu, the 'Set backdrop' button in the Visual Library, or enable 'Auto Backdrop' per visual type (Settings → Appearance → Message Visuals) to have newly generated Scene Backgrounds ('Visualize Scene (Background)') and/or Scene Illustrations ('Visualize Moment') promoted automatically. An 'Immersive' quick-toggle chip in the scene tools turns the backdrop on and off without forgetting the chosen image. Message text sits on translucent panels with a drop shadow for legibility — panel opacity and the text shadow are configurable — and a small marker icon shows which message's image is the current backdrop (click it for the image menu, which stays reachable via an Illustration chip on the message hover toolbar)." + - "Visual Prompt Finalization: The Visualizer agent gained a Prompt Finalization settings tab defining post-processing actions (exact, fuzzy or regex match and replace, or an AI instruction) that rewrite image prompts right before they are sent to the image generation backend. Actions can target positive and/or negative prompts, be restricted to specific visual types, and be overridden per scene. Characters can define their own actions under World Editor → Characters → Visuals → Prompt Finalization, which run after the agent's. Reusable action sets are managed as a new 'Visual prompt finalizer' template type, including a shipped Ideogram JSON preset that converts the positive prompt into an Ideogram 4.0 structured JSON prompt. Prompt-only generation output is finalized as well, and a new FinalizePrompt node exposes the step to custom node graphs." + - "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 - not emitted when a Fast-mode consolidated one-shot produces the examples itself). `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 imports verbatim, example dialogue as-is with typographic quotes converted), 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." + - "Fast Character Creation: The Creator agent's settings gained a Character Creation section with a Fast mode that consolidates AI character generation into a single prompt instead of one prompt per aspect, covering the selected aspects in a single request. A Consolidate multi-select picks which aspects (name, description, attributes, dialogue instructions, example dialogue) the one-shot covers - unselected aspects keep their individual prompts - an 'Attribute templates' entry in the same list folds selected attribute world-state templates into the one-shot as per-attribute instructions instead of one prompt per template, a One-shot token budget slider (1024-8192, default 4096) controls the shared response budget, and a Fill in misses toggle re-runs an aspect's individual request when the consolidated response missed it, while a completely unparseable response is always a hard error. The consolidated prompt also applies the scene's writing style template when one is configured. All AI-assisted character creation paths (world editor, scene tools character introduction, character card import and node graphs) route through this single flow, with the split per-aspect flow remaining the default for older or smaller models. Off by default." + - "Generate Character Node: Node graphs gained a Generate Character node (`agents/creator/GenerateCharacter`) that generates character data (name, description, attributes, dialogue instructions and/or example dialogue) following the creator agent's Character Creation settings. Which aspects to generate can be wired in, alongside the character name, guiding instructions, an existing description and generation options. The node only generates data - wire its outputs into the director Persist Character node, which now accepts pre-generated dialogue instructions and example dialogue, to add the character to the scene without generating twice. The `Create Character` module, which now builds its character through this node, gained matching `generate_dialogue_instructions` (default on - existing graphs calling the module now generate dialogue instructions, an extra prompt when Fast mode is off) and `generate_example_dialogue` (default off) inputs; the simulation suite enables both, so its add-character action now also generates example dialogue." + improvements: + - "Per-Scene Agent Overrides: Every agent action that can be switched off globally can now also be switched off for a single scene. Previously only the Visualizer's Prompt Finalization could — a scene could retune how Auto Narration, Revision, Scene Analysis or Long Term Memory behave, but not turn any of them off. The quick-toggle chips in the agent panel follow the same rule: a chip shows the value the loaded scene is actually running on and toggles exactly that value — the scene's override when one is active (marked with a small scene icon, the global value shown in the chip's tooltip), the global setting otherwise." + - "Application Settings Redesign: The application settings moved out of their cramped modal into the main view as an always-available Settings tab, matching the World Editor and Templates. The nested tabs-within-tabs layout is replaced by a single sidebar navigation grouped by topic (Game, Appearance, Connections, Presets, Storytelling), with a settings search that finds any setting by name — including individual API-key providers — and jumps straight to it, highlighting the target. All API keys now live on one scrollable page instead of ten separate per-provider pages. Edits are tracked against the stored configuration: an Unsaved-changes indicator with Save and Discard appears in the toolbar, unsaved edits survive switching to other tabs, and configuration changed elsewhere (another window, the help agent) while you are editing is flagged instead of silently overwriting or being overwritten. Existing deep links — for example the 'Set API key' action on a misconfigured client — land on the correct page as before." + - "Application Settings: The Settings tab in the top navigation now shows a warning badge with a tooltip while there are unsaved changes, so the state stays visible after switching away. The settings toolbar is also capped to the content width and groups Save / Discard next to the page title on the left instead of the far viewport edge." + - "Application Settings: Switching away from the Settings tab while there are unsaved changes now asks what to do with them — Ignore keeps them pending, Save writes them, Discard Changes reverts them — since the warning badge alone was too easy to miss." + - "Help Agent: The documentation index is no longer embedded in the chat prompt - the prompt now carries only a compact section overview (~15k fewer tokens per message), and a new find_docs lookup locates the right documentation pages on demand, so the help chat works with small context windows typical of local models." + - "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." + - "Text-Generation-WebUI Client: Added an 'API handles prompt template' toggle. When enabled, requests go to the chat/completions API and text-generation-webui applies the model's prompt template; response pre-filling (coercion) keeps working via the API's continue_ mechanism, and reasoning-model thinking is captured automatically without needing a reasoning pattern. Off by default." + - "llama.cpp Client: Added an 'API handles prompt template' toggle. When enabled, prompts are rendered through the server's /apply-template endpoint using the model's built-in chat template; response pre-filling (coercion) and reasoning keep working, and thinking-capable templates are asked not to think when reasoning is disabled. Off by default." + - "Track State: The World State scene tool menu's two separate 'Add tracked world state' and 'Add tracked character state' actions are now a single 'Track state' action. The modal itself lets you choose the target — the world or a specific character — before describing what to track, and the context attachment default and 'Require character active' option adapt to the choice." + - "OpenRouter Client: Added a Parameters config tab with individual toggles for `temperature`, `top_p`, `top_k`, `min_p`, `frequency_penalty`, `presence_penalty`, and `repetition_penalty`. Toggling a parameter off omits it from the request entirely, for model providers that hard-error on parameters they don't support for the selected model — some, for example, only accept a `frequency_penalty` of exactly 0 for certain models. All default to on." + - "Pi Bridge Client: The Docker image now ships with pi preinstalled, and pi's configuration directory (models.json, auth.json) is mounted from ./pi next to the compose file, so the client works in Docker out of the box." + - "Scene Browser Landing Page: The Quick Load recent-scene cards are smaller and scale with the viewport width, so a full row of recents no longer pushes the Scene Library far down the page." + - "Scene Browser Landing Page: The Quick Load card menu trigger moved below each card, so it no longer crowds the card title. Scene save files in the Scene Library gained the same context menu — Timeline, Remove from Quick Load (shown when the save is in Quick Load), and Delete — replacing the bare delete button." + - "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 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: + - "Docker: `docker compose up` now requires an NVIDIA GPU and the NVIDIA Container Toolkit and exposes all host NVIDIA GPUs to Talemate, restoring CUDA detection for PyTorch. Hosts without an NVIDIA GPU must use `docker compose -f docker-compose.cpu.yml up`; the standalone manual-build command remains a complete CPU configuration." + - "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." + - "Character Card Import: A failed description or example dialogue generation no longer wipes the card's own text. Both steps clear the field before regenerating it so the card's version doesn't bias the rewrite, but a generation that errored out or came back empty left the character with nothing at all — an empty description then also blinded the attribute extraction that runs right after it. The card's original description and example dialogue are now restored whenever generation fails, returns nothing, or is cancelled, matching what the import already does when those generation steps are switched off." + - "Character Card Import: A failed attribute extraction no longer overwrites the card's attributes. A generation that errored out or came back empty was still written to the character — as a sheet containing nothing but the character's own name — which dropped whatever attributes the card supplied (its gender, for example) and shadowed the character description in every prompt that renders the character sheet. An extraction that produces nothing now keeps the card's attributes, matching what the import already does when the attribute step is switched off." + - "Character Card Import: The director's 'Limit character attributes' setting now applies to character card import. The setting governs every other character creation path the director manages, but import ignored it entirely — importing a card with a limit of 5 configured still produced an unbounded sheet, with nothing indicating why. Both import paths (the per-aspect flow and the Creator's Fast one-shot) now generate at most the configured number of attributes. Attributes the card itself supplies are untouched when attribute extraction is switched off." + - "Pocket TTS: Pasting a Hugging Face token into the agent settings now enables the gated voice-cloning model download without restarting Talemate — previously a generation attempted before the token was set cached the fallback model without voice cloning, so following the in-app 'paste token and try again' instructions kept failing until a restart. While voice cloning is unavailable the agent card now also shows a warning." + - "Prompt Templates: The `system_time` template function crashed with 'Invalid format string' on Windows — it used strftime codes only available on Linux/macOS. The time is now formatted platform-independently with identical output." + - "Director Chat: Images created through the director chat's image generation action never appeared in the chat — the generation completed and the image was saved to the scene's assets, but the 'Image Generated' message was inserted into a newly created orphan chat instead of the conversation that requested it. The message is now inserted into the initiating chat." + - "Node Editor: The Build Prompt node's `memory_prompt` input socket and the Generate Response node's `action_type` input socket were silently ignored — only the node property ever took effect, so a graph computing either value dynamically and wiring it in had no effect. Non-empty wired values now win over the property (the property still applies when the socket is unconnected or resolves empty). Note: graphs that already had something connected to these sockets will start honoring the connection." + - "Scene Library: Deleting the save file of the currently loaded scene is now refused with an error - previously the file (and its version history) was deleted but silently recreated by the next save or autosave, making the delete appear to not work. Load a different scene first." + - "Pi Bridge Client: On Windows every generation hung at the very end — the response streamed in fully (visible as tokens-per-second slowly decaying to zero) but was never delivered, leaving the client busy until cancelled. pi is spawned through npm's pi.cmd shim there, and terminating that shim after the response orphaned the underlying node process, which kept the stdio pipes open and blocked the cleanup forever. The client now asks pi to shut down by closing its stdin (pi exits cleanly on stdin EOF on all platforms), force-kills the whole process tree if it doesn't exit within a grace period — so cancelling a generation no longer leaks a node process that keeps the provider request running — and no longer lets cleanup block response delivery." + - "Pi Bridge Client: On Windows the client could not launch pi at all — the startup model fetch logged an unhelpful '[WinError 2] The system cannot find the file specified' and generations failed the same way, because pi installed via npm is a pi.cmd shim that Windows only executes by its full path. The pi executable is now resolved to its full path before spawning, and if a launch still fails the error names the executable that could not be run. A missing pi install is also no longer reported at startup — it only surfaces on a configured Pi Bridge client." + - "Client Status: A single failed status check (e.g. a brief connection timeout to a busy local KoboldCpp) no longer flaps a connected client to 'Could not connect' for a few seconds — the client is only marked disconnected after consecutive status failures." + - "Agent Settings Dialog: Closing the dialog without changing anything no longer re-saves its settings — previously the untouched dialog pushed the copy it was opened with back to the backend, silently reverting any setting changed elsewhere while it was open (such as by the help agent). The help agent likewise refuses to change settings of an agent whose dialog is currently open, asking to close it first." + - "OpenRouter Client: A failed model/provider list fetch at startup no longer sticks until the server is restarted — later config saves and client status refreshes now retry it. Setting the API key for the first time during initial setup also correctly triggers the provider fetch." + - "Generation Error Dialog: When several generations failed at the same time (e.g. an API throttle hitting both a summarization and a background task), only the most recent error could be answered — the earlier generation was never resumed and its result (such as a scene summary) was silently lost. Error dialogs are now queued and answered one after another, and pending dialogs are cancelled cleanly when the scene is unloaded or the frontend disconnects." + - "Timeline: Previewing a revision could fail with an 'unhashable type' error when the scene's changelog contained deltas that diverged from the base snapshot — reconstruction now repairs the affected message history (and other list data) instead of crashing the timeline." + - "Scene Export: Exporting a scene with 'Reset Progress' checked wiped the loaded scene's message history and world state and could break subsequent saves — the reset now applies only to the exported file, leaving the current session untouched." + - "Client Editor: The Context Length input is now shown for every client type — previously clients without a self-hosted API URL (e.g. OpenRouter and the remote API clients) were missing it from the edit dialog, leaving the client-list slider as the only way to change their context token budget." + - "Node Editor: The Apply Style node crashed on any real style template id — it fed the id string into a code path expecting a visual type enum, so only the literal 'UNSPECIFIED' survived. The node now resolves the given template id directly and applies the full template. Note: passing 'UNSPECIFIED' as the template id no longer applies the configured art style — use the Apply Styles node for configuration-driven styles." + - "Client Sliders: Adjusting the context or reasoning token budget sliders in the client list no longer jitters back and forth between values — stale client status echoes from the backend could snap the slider back to a previous value shortly after dragging. Saving a client also no longer triggers redundant status round-trips to the inference API, and quickly editing two clients back to back no longer drops the first client's pending change." + - "Help & Director Chat: Long answers no longer read as one solid block of text. Headings, paragraphs, lists, quotes and horizontal rules had no spacing of their own, and lists had lost the indent their markers live in — so bullets were invisible and numbered steps showed a bare '.' with the number clipped off. All of it now renders with proper structure and spacing." + - "Node Editor: The Clean Up Narration node crashed with an AttributeError whenever it ran, and the raise/Stop node errored with 'Unknown exception' when its StageExit choice was selected." + - "Node Editor: The Compress Context ID Part node emitted the compressed value on its `uncompressed` output instead of the original part, and the As Bool node's error path itself crashed with a TypeError when a value could not be cast to a boolean." + - "Node Editor: Fixed two registry typos — `agents/editor/CleanUoCharacterMessage` is now `agents/editor/CleanUpCharacterMessage`, and the shipped director module `agernts/director/chat/instructGamestateUpdates` is now `agents/director/chat/instructGamestateUpdates`. Saved graphs referencing the old names keep loading via legacy aliases." + - "Node Editor: Socket fixes — Generate Response and State Counter no longer show a duplicate output socket; Generate Scene Types gained a `scene_types` output, Apply Styles and Apply Style gained a `state` passthrough output, and State Reinforcement's declared `reinforcement` output is now actually set. The Focal node's `template` input is correctly typed as `str`, and the Generation Options `spices` input and Spices node output are both typed `spices` so they can be wired directly." + - "Node Editor: Property metadata fixes — declared property defaults now match effective defaults on Context History and As Number; Set Scene Phase now actually defaults to the `roleplay` scene type (its effective default was an empty string, which made a freshly placed node error with 'Invalid scene type' until configured); the unused `asset_type` property was removed from Update Message Assets; the Sum node's `numbers` property is now properly typed in the editor UI." + - "Scene Types: Applying a scene type template never actually registered the scene type on the scene's intent state — the template wrote to a non-existent attribute, so template-picked scene types were silently lost." + - "Character Creation: A description typed into the world editor's character creator was silently discarded when AI generation was enabled, leaving the new character with no description. Provided descriptions are now kept." + - "Agent Settings Dialog: Scene mode now honors each setting's visibility conditions. Conditional settings - such as the Creator agent's Fast Character Creation fields, which only apply while Fast mode is on - no longer show up as scene overrides while their gate is off, and a section (along with its tab) whose settings are all currently hidden is dropped from Scene mode instead of rendering empty. Conditions are evaluated against the scene's effective values, so overriding the gating setting for a scene reveals the settings it gates. A setting that already carries an override stays visible even while its gate is off, so it can still be seen in the override count and cleared." + - "Character Card Import: Cancelling an import now actually aborts it — the split-mode per-aspect extractions, content-context determination, and story-intent generation each swallowed the cancellation as a warning, so the import completed anyway (and a cancel during story-intent generation leaked into the next unrelated generation)." + - "Character Card Import: Attribute extraction no longer runs blind in the default (non-Fast) import mode — the character being profiled was missing from its own extraction prompt, so attributes were generated from the greeting and character book alone, without the character's description. Imported attributes now reflect the description." + - "Character Creation: The director's 'Limit character attributes' setting now delivers the number of attributes it promises. The character's own name is written into the generated character sheet as a `Name` line, and it was counted against the limit — so every value delivered one attribute fewer than configured, and a limit of 1 produced no attributes at all. The name no longer costs a slot, in generated sheets as well as in sheets supplied to the Persist Character node." + - "Fast Character Creation: The one-shot generation prompt no longer repeats the character's name as an attribute — the name is already generated as its own aspect, so a `Name` entry in the character sheet was duplication, and under a configured attribute limit it consumed one of the allowed lines. This applies at every value of the director's 'Limit character attributes' setting, including the default of 0: Fast mode no longer asks for a `Name` attribute, and one written anyway does not cost a slot." + - "Agent Settings: An agent setting that has no on/off switch is no longer left switched off by a stored configuration. Sections like the Summarizer's Summarization, the Conversation and Narrator Generation sections or the World State's Character Portraits are always on by design, so the settings screen shows their options without an Enable checkbox — but a `false` recorded in the configuration file was still applied on startup, switching the section off in the background while its options stayed on display, with no control anywhere to turn it back on. The declaration now wins: such a setting is always loaded as on, and a `false` left in the configuration file — or in a scene's per-scene overrides — is ignored. A node graph that tries to toggle one now reports an error instead of appearing to succeed." + - "Agent Settings: Newly added agent settings no longer arrive switched off on an existing installation. Any setting that did not yet exist when the configuration file was last written was loaded as off rather than in the state it ships with, and then recorded as off — so a feature added in a release could be silently inactive for everyone upgrading into it while a fresh installation got it working. It is also what wrote the stale `false` the fix above corrects. Settings absent from the stored configuration now keep the state they ship with; a setting you switched off yourself stays off." + - "Example Dialogue: Character example dialogue no longer keeps typographic quotes (“ ” „ ‘ ’ ‚). Talemate delimits spoken words with the straight quote, so an example line carrying fancy quotes rendered as narration instead of dialogue and was mis-chunked by dialogue parsing and text-to-speech. Typographic quotes are now replaced with their plain equivalents wherever example dialogue is written outside a generation — character card import, manual entry and edits in the world editor, examples supplied directly to the Persist Character node, and the creator's example dialogue generation. Generated text was already normalized at the client level, and now also covers the German-style low-9 opening quotes, so a `„…“` pair no longer collapses into a single unbalanced straight quote. Characters already saved with fancy quotes keep them until the affected line is saved again." + - "Help Agent: Documentation lookups now find the page that actually answers the question. Every word counted the same, so asking about 'koboldcpp settings' returned five different agent settings pages and no KoboldCpp page at all — the common word decided the match and the rest were ties broken alphabetically. Distinctive words now count for far more than ubiquitous ones. Page length also buys far less rank: a longer description of a page used to be a strictly better one, every extra word another free chance to match, so describing a page more thoroughly made it surface for topics it only mentions in passing. Those matches are now diluted by how much the description covers, so a page that mentions a topic in passing no longer outranks the page about it." + - "Story Introduction: Setting the introduction through the director or through the Set Introduction node now updates the scene view and the World Editor right away instead of only after a scene reload." + - "Drawer Widths: The director console and help chat drawers no longer stay locked to the window size measured when the page was loaded — they now follow the current window size." + - "Dependencies: Excluded nltk 3.10.1 from the accepted version range — that release ships a CWD-import security hook that breaks Talemate's standard install layout (virtualenv inside the project directory), crashing startup on any install not using the committed lock. The lock now resolves nltk 3.10.2, where the hook was removed upstream." + - "Docker: Loading a scene no longer fails with a Memory Agent database error. The image installed an FFmpeg build that had moved past the versions torchcodec supports, so the embedding model could not be imported. The FFmpeg version is now pinned, and a mismatch fails the image build instead of shipping." + - "Docker: Building the image from a working copy that had already run the frontend outside Docker no longer fails at `pnpm build` with `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`. The repository had no `.dockerignore`, so the build context carried the host's `talemate_frontend/node_modules` into the image on top of the one the build had just installed; pnpm read the host's own paths out of it, judged the dependencies stale, and tried to reinstall them mid-build, which needs a terminal to confirm. A `.dockerignore` now keeps host build output, virtual environments, caches, `config.yaml` and `secrets/` out of the build context, and the frontend stage runs pnpm in CI mode. The corepack and uv versions the image installs are pinned as well, so neither can drift into the next broken build." + - "Docker: The Help agent can now read the documentation in the container. The image never carried the bundled `docs/` directory the agent reads at runtime, so in Docker it answered without any grounding in the manual - it could neither search nor open a page. The image now ships the documentation markdown (the screenshots stay out, the agent only ever reads the text)." + +0.38.0: features: - "Event Module Auto-Register: Event Modules gained an `auto_register` toggle so a module subscribes to its event as soon as it's registered with the scene, without having to be placed in the scene loop graph." - "Per-Scene Agent Overrides: Agent configuration can now be overridden per scene without changing the global config. The Agent Modal gains a Global / Scene mode switch for toggling and editing overrides; choose, swap, or opt out of the override file under World Editor → Scene → Settings." diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 64d9a13a..56642505 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,6 +59,28 @@ Ensure all tests pass by running: uv run pytest tests/ -p no:warnings ``` +The test dependencies live in the `dev` extra, which is not installed by +default — install it once with either of: +```bash +uv sync --extra dev +uv pip install -e ".[dev]" +``` +Plain `uv sync` does not just skip the extra, it *removes* it: run against a +working environment it uninstalls `pytest`, `pytest-xdist` and `pytest-asyncio`, +breaking the command above. + +The suite runs distributed across your CPU cores by default (via `pytest-xdist`). +If your environment predates that — pytest installed, `pytest-xdist` not — pytest +exits with `error: unrecognized arguments: -n --dist worksteal`; install the `dev` +extra as above. + +Workers discard `-s` / `--capture=no` **stdout** entirely (stderr, including +`logging`, still comes through but out of order), and failures are reordered. +Pass `-n0` to run serially while debugging a specific test: +```bash +uv run pytest tests/ -n0 -s -x +``` + ## Questions? If you're unsure whether your contribution would be welcome, please open an issue to discuss it first. This saves everyone time and ensures alignment with the project's direction. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 7b9c8829..ab8cd89f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,7 @@ +# The backend-build stage creates the virtual environment and the final stage +# runs it, so both must install the same uv. +ARG UV_VERSION=0.12.5 + # Stage 1: Frontend build FROM node:22-slim AS frontend-build @@ -5,7 +9,11 @@ WORKDIR /app # Enable pnpm via corepack (version pinned by package.json "packageManager"). ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 -RUN npm install -g corepack@latest && corepack enable +# pnpm asks before it purges a modules directory, and aborts when no TTY can +# answer. https://pnpm.io/settings#confirmmodulespurge +ENV CI=true +ARG COREPACK_VERSION=0.35.0 +RUN npm install -g corepack@${COREPACK_VERSION} && corepack enable # Copy frontend manifest, lockfile and pnpm settings COPY talemate_frontend/package.json talemate_frontend/pnpm-lock.yaml talemate_frontend/pnpm-workspace.yaml ./ @@ -31,7 +39,8 @@ RUN apt-get update && apt-get install -y \ && rm -rf /var/lib/apt/lists/* # Install uv -RUN pip install uv +ARG UV_VERSION +RUN pip install uv==${UV_VERSION} # Copy installation files COPY pyproject.toml uv.lock /app/ @@ -42,6 +51,13 @@ COPY ./src /app/src # Create virtual environment and install dependencies (includes CUDA support via pyproject.toml) RUN uv sync +# The help agent reads the bundled documentation from TALEMATE_ROOT/docs, and it +# reads nothing but markdown. Dropping the rest here keeps 36 MB of screenshots +# out of the final image instead of deleting them in a layer that still ships. +COPY docs /app/docs +RUN find /app/docs -type f ! -name "*.md" -delete && \ + find /app/docs -depth -type d -empty -delete + # Stage 3: Final image FROM python:3.11-slim @@ -53,32 +69,56 @@ RUN apt-get update && apt-get install -y \ tar \ xz-utils \ gettext-base \ + libstdc++6 \ && rm -rf /var/lib/apt/lists/* # Install uv in the final stage -RUN pip install uv +ARG UV_VERSION +RUN pip install uv==${UV_VERSION} + +# Node.js runtime for the pi coding agent (Pi Bridge client), reused from the +# frontend build stage so the final image needs no extra apt source +COPY --from=frontend-build /usr/local/bin/node /usr/local/bin/node +COPY --from=frontend-build /usr/local/lib/node_modules /usr/local/lib/node_modules +RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \ + ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx + +# Install pi for the Pi Bridge client (override the version via build arg) +ARG PI_VERSION=0.80.10 +RUN npm install -g @earendil-works/pi-coding-agent@${PI_VERSION} && \ + npm cache clean --force && \ + pi --version + +# pi config dir (models.json, auth.json) - mounted as a volume in docker-compose +# so it is host-editable and survives container recreation +ENV PI_CODING_AGENT_DIR=/app/pi +RUN mkdir -p /app/pi # Copy virtual environment from backend-build stage COPY --from=backend-build /app/.venv /app/.venv -# Download and install FFmpeg 8.0 with shared libraries into .venv (matching Windows installer approach) -# Using BtbN FFmpeg builds which provide shared libraries - verified to work -# Note: We tried using jrottenberg/ffmpeg:8.0-ubuntu image but copying libraries from it didn't work properly, -# so we use the direct download approach which is more reliable and matches the Windows installer +# FFmpeg shared libraries for torchcodec, which loads FFmpeg 4 to 8 only. +# The BtbN master asset now ships FFmpeg 9, so this tracks the 8.1 release branch. +ARG FFMPEG_BUILD=ffmpeg-n8.1-latest-linux64-gpl-shared-8.1 RUN cd /tmp && \ - wget -q https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl-shared.tar.xz -O ffmpeg.tar.xz && \ + wget -q https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/${FFMPEG_BUILD}.tar.xz -O ffmpeg.tar.xz && \ tar -xf ffmpeg.tar.xz && \ - cp -a ffmpeg-master-latest-linux64-gpl-shared/bin/* /app/.venv/bin/ && \ - cp -a ffmpeg-master-latest-linux64-gpl-shared/lib/* /app/.venv/lib/ && \ - rm -rf ffmpeg-master-latest-linux64-gpl-shared ffmpeg.tar.xz && \ + cp -a ${FFMPEG_BUILD}/bin/* /app/.venv/bin/ && \ + cp -a ${FFMPEG_BUILD}/lib/* /app/.venv/lib/ && \ + rm -rf ${FFMPEG_BUILD} ffmpeg.tar.xz && \ LD_LIBRARY_PATH=/app/.venv/lib /app/.venv/bin/ffmpeg -version | head -n 1 # Set LD_LIBRARY_PATH so torchcodec can find ffmpeg libraries at runtime ENV LD_LIBRARY_PATH=/app/.venv/lib:${LD_LIBRARY_PATH} +# Fail the build, instead of scene loading at runtime, when the two no longer match. +RUN /app/.venv/bin/python -B -c "import torchcodec.decoders" + # Copy Python source code COPY --from=backend-build /app/src /app/src +COPY --from=backend-build /app/docs /app/docs + # Copy Node.js build artifacts from frontend-build stage COPY --from=frontend-build /app/dist /app/talemate_frontend/dist diff --git a/docker-compose.cpu.yml b/docker-compose.cpu.yml new file mode 100644 index 00000000..d224e21f --- /dev/null +++ b/docker-compose.cpu.yml @@ -0,0 +1,20 @@ +services: + talemate: + image: ghcr.io/vegu-ai/talemate:latest + ports: + - "${TALEMATE_FRONTEND_PORT:-8082}:${TALEMATE_FRONTEND_PORT:-8082}" + - "${TALEMATE_BACKEND_PORT:-5050}:${TALEMATE_BACKEND_PORT:-5050}" + volumes: + - ./config.yaml:/app/config.yaml + - ./secrets:/app/secrets + - ./pi:/app/pi + - ./scenes:/app/scenes + - ./templates:/app/templates + - ./chroma:/app/chroma + - ./tts:/app/tts + environment: + - PYTHONUNBUFFERED=1 + - PYTHONPATH=/app/src:$PYTHONPATH + - VITE_TALEMATE_BACKEND_WEBSOCKET_URL=${VITE_TALEMATE_BACKEND_WEBSOCKET_URL:-} + - TALEMATE_FRONTEND_PORT=${TALEMATE_FRONTEND_PORT:-8082} + - TALEMATE_BACKEND_PORT=${TALEMATE_BACKEND_PORT:-5050} diff --git a/docker-compose.manual.yml b/docker-compose.manual.yml index cddf1d41..4fc778bb 100644 --- a/docker-compose.manual.yml +++ b/docker-compose.manual.yml @@ -1,23 +1,18 @@ -version: '3.8' - services: talemate: + extends: + file: docker-compose.cpu.yml + service: talemate + # Uncomment this block to enable NVIDIA GPU access for standalone manual builds. + # deploy: + # resources: + # reservations: + # devices: + # - driver: nvidia + # count: all + # capabilities: [gpu] + image: talemate:local + pull_policy: build build: context: . dockerfile: Dockerfile - ports: - - "${TALEMATE_FRONTEND_PORT:-8082}:${TALEMATE_FRONTEND_PORT:-8082}" - - "${TALEMATE_BACKEND_PORT:-5050}:${TALEMATE_BACKEND_PORT:-5050}" - volumes: - - ./config.yaml:/app/config.yaml - - ./secrets:/app/secrets - - ./scenes:/app/scenes - - ./templates:/app/templates - - ./chroma:/app/chroma - - ./tts:/app/tts - environment: - - PYTHONUNBUFFERED=1 - - PYTHONPATH=/app/src:$PYTHONPATH - - VITE_TALEMATE_BACKEND_WEBSOCKET_URL=${VITE_TALEMATE_BACKEND_WEBSOCKET_URL:-} - - TALEMATE_FRONTEND_PORT=${TALEMATE_FRONTEND_PORT:-8082} - - TALEMATE_BACKEND_PORT=${TALEMATE_BACKEND_PORT:-5050} diff --git a/docker-compose.yml b/docker-compose.yml index cb11bc75..6bd0a3ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,21 +1,12 @@ -version: '3.8' - services: talemate: - image: ghcr.io/vegu-ai/talemate:latest - ports: - - "${TALEMATE_FRONTEND_PORT:-8082}:${TALEMATE_FRONTEND_PORT:-8082}" - - "${TALEMATE_BACKEND_PORT:-5050}:${TALEMATE_BACKEND_PORT:-5050}" - volumes: - - ./config.yaml:/app/config.yaml - - ./secrets:/app/secrets - - ./scenes:/app/scenes - - ./templates:/app/templates - - ./chroma:/app/chroma - - ./tts:/app/tts - environment: - - PYTHONUNBUFFERED=1 - - PYTHONPATH=/app/src:$PYTHONPATH - - VITE_TALEMATE_BACKEND_WEBSOCKET_URL=${VITE_TALEMATE_BACKEND_WEBSOCKET_URL:-} - - TALEMATE_FRONTEND_PORT=${TALEMATE_FRONTEND_PORT:-8082} - - TALEMATE_BACKEND_PORT=${TALEMATE_BACKEND_PORT:-5050} + extends: + file: docker-compose.cpu.yml + service: talemate + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] diff --git a/docs/dev/nodes-glossary.md b/docs/dev/nodes-glossary.md new file mode 100644 index 00000000..80f94fe0 --- /dev/null +++ b/docs/dev/nodes-glossary.md @@ -0,0 +1,24 @@ +# Node reference glossary + +The [node reference](/talemate/user-guide/node-editor/reference/nodes/) in the user guide — one page per node category, listing every node's registry path, input/output sockets and properties — is not written by hand. It is rendered from the live node registry by the `glossary` subcommand of the node tools CLI. + +## Regenerating the pages + +After adding, removing or changing nodes (or their docstrings, sockets or properties), regenerate the reference: + +```bash +uv run python -m talemate.game.engine.nodes.tools glossary --write +``` + +This rewrites the generated portion of every page under `docs/user-guide/node-editor/reference/nodes/`. Anything above the `` marker in a page is a hand-editable intro and is preserved across regenerations — everything below the marker is overwritten. + +## Checking for drift + +```bash +uv run python -m talemate.game.engine.nodes.tools glossary --check +``` + +compares the committed pages against a fresh render and exits non-zero when they have drifted from the code (missing pages, stale pages, or generated content that no longer matches). Run it before shipping node changes to catch a forgotten `--write`. + +!!! note + Loading the full node registry takes a while — both commands import every node module before rendering. diff --git a/docs/getting-started/advanced/change-host-and-port.md b/docs/getting-started/advanced/change-host-and-port.md index b4c62c8d..025b6674 100644 --- a/docs/getting-started/advanced/change-host-and-port.md +++ b/docs/getting-started/advanced/change-host-and-port.md @@ -177,6 +177,8 @@ start_custom.bat For Docker deployments, you can configure the frontend port, backend port, and the WebSocket URL at container startup without rebuilding the image. +The commands below use the default CUDA configuration and require an NVIDIA GPU and the NVIDIA Container Toolkit. On a host without an NVIDIA GPU, add `-f docker-compose.cpu.yml` to every command; for example, `TALEMATE_FRONTEND_PORT=9090 docker compose -f docker-compose.cpu.yml up`. + ### Changing the frontend port Set `TALEMATE_FRONTEND_PORT` before running `docker compose up`: @@ -223,4 +225,7 @@ The WebSocket URL is determined in this order: 1. **Runtime environment variable** (`VITE_TALEMATE_BACKEND_WEBSOCKET_URL` at container start) 2. **Auto-detection** (`ws://:5050/ws`) -This means you can use a single Docker image across different environments (staging, production) by simply changing the environment variable. \ No newline at end of file +This means you can use a single Docker image across different environments (staging, production) by simply changing the environment variable. + +!!! info "`0.0.0.0` in the WebSocket URL" + `0.0.0.0` is a bind address, not an address a browser can connect to. If the environment variable's host is `0.0.0.0`, the frontend replaces it with the hostname the page was loaded from — the configured port and path are kept. So `ws://0.0.0.0:6060/ws` behaves like auto-detection for the host, but on port `6060`. diff --git a/docs/getting-started/advanced/environment-variables.md b/docs/getting-started/advanced/environment-variables.md index 37ae3800..8e2f8638 100644 --- a/docs/getting-started/advanced/environment-variables.md +++ b/docs/getting-started/advanced/environment-variables.md @@ -31,6 +31,7 @@ This variable is read by Vite at build time and re-read at container start in th |----------|---------|---------| | `TALEMATE_DEBUG` | _unset_ | Set to `1` to enable `DEBUG`-level logging and write errors to a separate error log file. See [Debug logging](debug-logging.md). | | `TALEMATE_LOG_PROMPTS` | _unset_ | Set to any non-empty value to write full prompt + response data to `logs/prompt_log.jsonl`. See [Prompt logging](prompt-logging.md). | +| `TALEMATE_PI_BRIDGE_TRACE` | _unset_ | Set to `1` to log the [Pi Bridge client's](../../user-guide/clients/types/pi-bridge.md) pi event stream as it is consumed (per-event sizes and types plus a heartbeat warning while the stream is silent). Diagnostic aid for generations that stream but never finish. | `start-backend.sh` and `start-backend.bat` set `TALEMATE_DEBUG=1` automatically; the production `start.sh` / `start.bat` do not. @@ -47,7 +48,7 @@ See [API key encryption](../../user-guide/api-key-encryption.md) for the full ke ## Docker Compose passthroughs -The values below are not consumed by Talemate's Python code directly — they're consumed by `docker-compose.yml` so that the same variable controls both the published host port and the value passed into the container as `TALEMATE_BACKEND_PORT` / `TALEMATE_FRONTEND_PORT`: +The values below are not consumed by Talemate's Python code directly — they're consumed by the default and CPU-only Compose configurations so that the same variable controls both the published host port and the value passed into the container as `TALEMATE_BACKEND_PORT` / `TALEMATE_FRONTEND_PORT`: - `TALEMATE_BACKEND_PORT` - `TALEMATE_FRONTEND_PORT` diff --git a/docs/getting-started/connect-a-client.md b/docs/getting-started/connect-a-client.md index be5772df..c34c1d69 100644 --- a/docs/getting-started/connect-a-client.md +++ b/docs/getting-started/connect-a-client.md @@ -3,9 +3,7 @@ !!! note "First time setup?" If this is your first time launching Talemate, the [Setup Wizard](setup-wizard.md) will guide you through adding your first client and configuring essential settings. This page covers manual client configuration for adding additional clients or if you skipped the wizard. -Once Talemate is up and running and you are connected, you will see a notification in the corner instructing you to configured a client. - -![no clients](/talemate/img/0.26.0/no-clients.png) +Once Talemate is up and running and you are connected, the Home screen shows a warning at the top while no client is configured, reminding you that all enabled clients and agents need to be configured before you can load scenes. Talemate uses client(s) to connect to local or remote AI text generation APIs like koboldcpp, text-generation-webui or OpenAI. @@ -22,7 +20,7 @@ On the right hand side click the **:material-plus-box: ADD CLIENT** button. The client configuration window will appear. Here you can choose the type of client you want to add. -![connect a client add client modal](/talemate/img/0.30.0/connect-a-client-add-client-modal.png) +![connect a client add client modal](/talemate/img/0.39.0/connect-a-client-add-client-modal.png) ## Choose an API / Client Type @@ -43,6 +41,7 @@ Select the API you want to use and click through to follow the instructions to c - [Groq](/talemate/user-guide/clients/types/groq/) - [Google Gemini](/talemate/user-guide/clients/types/google/) - [OpenRouter](/talemate/user-guide/clients/types/openrouter/) +- [Pi Bridge](/talemate/user-guide/clients/types/pi-bridge/) — any provider reachable through a local [pi coding agent](https://github.com/earendil-works/pi) install ##### Local APIs diff --git a/docs/getting-started/installation/docker.md b/docs/getting-started/installation/docker.md index 9da6d33a..653c76da 100644 --- a/docs/getting-started/installation/docker.md +++ b/docs/getting-started/installation/docker.md @@ -5,11 +5,35 @@ 1. copy config file 1. linux: `cp config.example.yaml config.yaml` 1. windows: `copy config.example.yaml config.yaml` (or just copy the file and rename it via the file explorer) -1. `docker compose up` +1. Start Talemate: + 1. NVIDIA GPU host with the NVIDIA Container Toolkit installed: `docker compose up` + 1. Host without an NVIDIA GPU: `docker compose -f docker-compose.cpu.yml up` 1. Navigate your browser to http://localhost:8082 +The default Compose configuration requires an NVIDIA GPU and the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html). It reserves all NVIDIA GPUs and does not fall back to CPU execution. + +On a host without an NVIDIA GPU, you must use: + +```bash +docker compose -f docker-compose.cpu.yml up +``` + !!! info "Pre-built Images" - The default setup uses pre-built images from GitHub Container Registry that include CUDA support by default. To manually build the container instead, use `docker compose -f docker-compose.manual.yml up --build`. + The default setup uses a pre-built image from GitHub Container Registry. To build it locally with CUDA enabled, use `docker compose -f docker-compose.yml -f docker-compose.manual.yml up --build`. For a local CPU-only build, use the standalone command `docker compose -f docker-compose.manual.yml up --build`; combining `docker-compose.cpu.yml` and `docker-compose.manual.yml` is also supported. + +## Verify CUDA access + +With the container running, verify that Docker exposed the GPU and that PyTorch can use it: + +```bash +docker compose exec talemate nvidia-smi +docker compose exec talemate /app/.venv/bin/python -B -c "import torch; print(torch.__version__, torch.version.cuda, torch.cuda.is_available())" +``` + +The final value from the Python command should be `True`. See [Common issues](troubleshoot.md#cuda-is-not-available-in-a-running-container) if either command fails. !!! note When connecting local APIs running on the hostmachine (e.g. text-generation-webui), you need to use `host.docker.internal` as the hostname. + +!!! info "Pi Bridge" + The image ships with the [pi coding agent](../../user-guide/clients/types/pi-bridge.md) preinstalled for the Pi Bridge client. pi's configuration (`models.json`, `auth.json`) lives in the `./pi` directory next to the compose file. diff --git a/docs/getting-started/installation/troubleshoot.md b/docs/getting-started/installation/troubleshoot.md index 1f55672e..5253a7c1 100644 --- a/docs/getting-started/installation/troubleshoot.md +++ b/docs/getting-started/installation/troubleshoot.md @@ -8,6 +8,43 @@ ## Docker +### Docker cannot start with the NVIDIA device request + +The default configuration requires both an NVIDIA GPU and a working NVIDIA Container Toolkit. If either is missing, Docker reports an error similar to: + +```text +could not select device driver "nvidia" with capabilities: [[gpu]] +``` + +On a host without an NVIDIA GPU, start the CPU-only configuration instead: + +```bash +docker compose -f docker-compose.cpu.yml up +``` + +If the host has an NVIDIA GPU, confirm `nvidia-smi` works on the host, then install or repair the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html). Verify the Toolkit before retrying Talemate: + +```bash +docker run --rm --gpus all ubuntu nvidia-smi +``` + +### CUDA is not available in a running container + +If the Toolkit probe succeeds and Talemate starts, check the running container: + +```bash +docker compose exec talemate nvidia-smi +docker compose exec talemate /app/.venv/bin/python -B -c "import torch; print(torch.__version__, torch.version.cuda, torch.cuda.is_available())" +``` + +If `nvidia-smi` works inside Talemate but the Python command reports `False`, update the host NVIDIA driver to one compatible with the image's locked CUDA 12.8 build, then recreate the container. + +To run without CUDA instead, use: + +```bash +docker compose -f docker-compose.cpu.yml up +``` + ### Docker has created `config.yaml` directory If you do not copy the example config to `config.yaml` before running `docker compose up` docker will create a `config` directory in the root of the project. This will cause the backend to fail to start. @@ -66,4 +103,4 @@ location /ws { proxy_set_header Connection "upgrade"; proxy_set_header Host $host; } -``` \ No newline at end of file +``` diff --git a/docs/getting-started/load-a-scene.md b/docs/getting-started/load-a-scene.md index fae19d85..e978e5ba 100644 --- a/docs/getting-started/load-a-scene.md +++ b/docs/getting-started/load-a-scene.md @@ -1,10 +1,10 @@ # Load a scenario -Once you've set up a client and assigned it to all the agents, you will be presented with the `Home` screen. From here, you can load talemate scenarios and upload character cards. +Once you've set up a client and assigned it to all the agents, you will be presented with the `Home` screen. From here, you can load talemate scenarios, browse the scene library, and import scenes or character cards — see [Home Screen & Scene Library](/talemate/user-guide/scene-browser) for a full tour. -To load the introductory `Infinity Quest` scenario, simply click on its entry in the `Quick Load` section. +To load the introductory `Infinity Quest` scenario, simply click on its entry in the `Quick load` section. -![Load infinity quest](/talemate/img/0.26.0/getting-started-load-screen.png) +![Load infinity quest](/talemate/img/0.39.0/getting-started-load-screen.png) !!! info "First time may take a moment" When you load the a scenario for the first time, Talemate will need to initialize the long term memory model. Which likely means a download. Just be patient and it will be ready soon. diff --git a/docs/getting-started/setup-wizard.md b/docs/getting-started/setup-wizard.md index 1882a6f4..318240f1 100644 --- a/docs/getting-started/setup-wizard.md +++ b/docs/getting-started/setup-wizard.md @@ -40,7 +40,7 @@ Select your preferred client from the dropdown and click **Add Client** to open Once you complete the client configuration, the wizard will automatically advance to the next step. !!! info "Reasoning Models" - If you are using a reasoning model (like DeepSeek R1 or GLM), you will need to enable reasoning in the client settings after setup. For OpenRouter users, the default model selected during the wizard (Gemini 3 Flash) has reasoning enabled automatically, but if you switch to a different reasoning-capable model, you'll need to enable it manually in the client settings. + If you are using a reasoning model (like DeepSeek R1 or GLM), you will need to enable reasoning in the client settings after setup. For OpenRouter users, new clients have reasoning enabled by default with a budget of 2048 reasoning tokens, so the default model selected during the wizard (Gemini 3.6 Flash) and other reasoning models work out of the box. If you switch to a model that does not reason, you can turn reasoning off in the client settings. ## Step 3: Configure Long-term Memory diff --git a/docs/img/0.26.0/anthropic-settings.png b/docs/img/0.26.0/anthropic-settings.png deleted file mode 100644 index f0e5775e..00000000 Binary files a/docs/img/0.26.0/anthropic-settings.png and /dev/null differ diff --git a/docs/img/0.26.0/client-anthropic.png b/docs/img/0.26.0/client-anthropic.png deleted file mode 100644 index 7839f481..00000000 Binary files a/docs/img/0.26.0/client-anthropic.png and /dev/null differ diff --git a/docs/img/0.26.0/client-cohere.png b/docs/img/0.26.0/client-cohere.png deleted file mode 100644 index ed87fd80..00000000 Binary files a/docs/img/0.26.0/client-cohere.png and /dev/null differ diff --git a/docs/img/0.26.0/client-google.png b/docs/img/0.26.0/client-google.png deleted file mode 100644 index ffa012f5..00000000 Binary files a/docs/img/0.26.0/client-google.png and /dev/null differ diff --git a/docs/img/0.26.0/client-groq.png b/docs/img/0.26.0/client-groq.png deleted file mode 100644 index 718b9fc9..00000000 Binary files a/docs/img/0.26.0/client-groq.png and /dev/null differ diff --git a/docs/img/0.26.0/client-mistral.png b/docs/img/0.26.0/client-mistral.png deleted file mode 100644 index 6c611cdb..00000000 Binary files a/docs/img/0.26.0/client-mistral.png and /dev/null differ diff --git a/docs/img/0.26.0/client-openai.png b/docs/img/0.26.0/client-openai.png deleted file mode 100644 index 2c2cd291..00000000 Binary files a/docs/img/0.26.0/client-openai.png and /dev/null differ diff --git a/docs/img/0.26.0/cohere-settings.png b/docs/img/0.26.0/cohere-settings.png deleted file mode 100644 index 129d123a..00000000 Binary files a/docs/img/0.26.0/cohere-settings.png and /dev/null differ diff --git a/docs/img/0.26.0/elevenlabs-settings.png b/docs/img/0.26.0/elevenlabs-settings.png deleted file mode 100644 index d07c2ee9..00000000 Binary files a/docs/img/0.26.0/elevenlabs-settings.png and /dev/null differ diff --git a/docs/img/0.26.0/getting-started-load-screen.png b/docs/img/0.26.0/getting-started-load-screen.png deleted file mode 100644 index 3e5f2881..00000000 Binary files a/docs/img/0.26.0/getting-started-load-screen.png and /dev/null differ diff --git a/docs/img/0.26.0/google-settings.png b/docs/img/0.26.0/google-settings.png deleted file mode 100644 index 7ddbf6d7..00000000 Binary files a/docs/img/0.26.0/google-settings.png and /dev/null differ diff --git a/docs/img/0.26.0/groq-settings.png b/docs/img/0.26.0/groq-settings.png deleted file mode 100644 index 068b488d..00000000 Binary files a/docs/img/0.26.0/groq-settings.png and /dev/null differ diff --git a/docs/img/0.26.0/mistral-settings.png b/docs/img/0.26.0/mistral-settings.png deleted file mode 100644 index fa5e6089..00000000 Binary files a/docs/img/0.26.0/mistral-settings.png and /dev/null differ diff --git a/docs/img/0.26.0/no-clients.png b/docs/img/0.26.0/no-clients.png deleted file mode 100644 index 942822cc..00000000 Binary files a/docs/img/0.26.0/no-clients.png and /dev/null differ diff --git a/docs/img/0.26.0/open-settings.png b/docs/img/0.26.0/open-settings.png deleted file mode 100644 index ce4ff74a..00000000 Binary files a/docs/img/0.26.0/open-settings.png and /dev/null differ diff --git a/docs/img/0.26.0/openai-settings.png b/docs/img/0.26.0/openai-settings.png deleted file mode 100644 index 4c2ebb26..00000000 Binary files a/docs/img/0.26.0/openai-settings.png and /dev/null differ diff --git a/docs/img/0.26.0/runpod-api-key-setting.png b/docs/img/0.26.0/runpod-api-key-setting.png deleted file mode 100644 index b2f96057..00000000 Binary files a/docs/img/0.26.0/runpod-api-key-setting.png and /dev/null differ diff --git a/docs/img/0.26.0/scene-save.png b/docs/img/0.26.0/scene-save.png deleted file mode 100644 index a14e9a82..00000000 Binary files a/docs/img/0.26.0/scene-save.png and /dev/null differ diff --git a/docs/img/0.26.0/scene-tool-world-state-actions.png b/docs/img/0.26.0/scene-tool-world-state-actions.png deleted file mode 100644 index f39db897..00000000 Binary files a/docs/img/0.26.0/scene-tool-world-state-actions.png and /dev/null differ diff --git a/docs/img/0.26.0/scene-tool-world-state-applied.png b/docs/img/0.26.0/scene-tool-world-state-applied.png deleted file mode 100644 index def30b35..00000000 Binary files a/docs/img/0.26.0/scene-tool-world-state-applied.png and /dev/null differ diff --git a/docs/img/0.26.0/world-editor-create-player-character-1.png b/docs/img/0.26.0/world-editor-create-player-character-1.png deleted file mode 100644 index a65de20a..00000000 Binary files a/docs/img/0.26.0/world-editor-create-player-character-1.png and /dev/null differ diff --git a/docs/img/0.26.0/world-editor-create-player-character-2.png b/docs/img/0.26.0/world-editor-create-player-character-2.png deleted file mode 100644 index 92acb7f9..00000000 Binary files a/docs/img/0.26.0/world-editor-create-player-character-2.png and /dev/null differ diff --git a/docs/img/0.27.0/embedding-settings-edit.png b/docs/img/0.27.0/embedding-settings-edit.png deleted file mode 100644 index 3055d006..00000000 Binary files a/docs/img/0.27.0/embedding-settings-edit.png and /dev/null differ diff --git a/docs/img/0.27.0/embedding-settings-new-1.png b/docs/img/0.27.0/embedding-settings-new-1.png deleted file mode 100644 index b15a069f..00000000 Binary files a/docs/img/0.27.0/embedding-settings-new-1.png and /dev/null differ diff --git a/docs/img/0.29.0/app-settings-game-default-character.png b/docs/img/0.29.0/app-settings-game-default-character.png deleted file mode 100644 index 12c0ad88..00000000 Binary files a/docs/img/0.29.0/app-settings-game-default-character.png and /dev/null differ diff --git a/docs/img/0.29.0/app-settings-game-general.png b/docs/img/0.29.0/app-settings-game-general.png deleted file mode 100644 index 58b268b2..00000000 Binary files a/docs/img/0.29.0/app-settings-game-general.png and /dev/null differ diff --git a/docs/img/0.29.0/app-settings-presets-embeddings.png b/docs/img/0.29.0/app-settings-presets-embeddings.png deleted file mode 100644 index f4356e7d..00000000 Binary files a/docs/img/0.29.0/app-settings-presets-embeddings.png and /dev/null differ diff --git a/docs/img/0.29.0/app-settings-presets-inference.png b/docs/img/0.29.0/app-settings-presets-inference.png deleted file mode 100644 index 24ca742e..00000000 Binary files a/docs/img/0.29.0/app-settings-presets-inference.png and /dev/null differ diff --git a/docs/img/0.29.0/app-settings-presets-system-prompts.png b/docs/img/0.29.0/app-settings-presets-system-prompts.png deleted file mode 100644 index 44cb6b77..00000000 Binary files a/docs/img/0.29.0/app-settings-presets-system-prompts.png and /dev/null differ diff --git a/docs/img/0.30.0/connect-a-client-add-client-modal.png b/docs/img/0.30.0/connect-a-client-add-client-modal.png deleted file mode 100644 index e5483f67..00000000 Binary files a/docs/img/0.30.0/connect-a-client-add-client-modal.png and /dev/null differ diff --git a/docs/img/0.30.0/inference-presets-custom-group-1.png b/docs/img/0.30.0/inference-presets-custom-group-1.png deleted file mode 100644 index 7b0f23b2..00000000 Binary files a/docs/img/0.30.0/inference-presets-custom-group-1.png and /dev/null differ diff --git a/docs/img/0.30.0/inference-presets-custom-group-2.png b/docs/img/0.30.0/inference-presets-custom-group-2.png deleted file mode 100644 index 9a7aeb4b..00000000 Binary files a/docs/img/0.30.0/inference-presets-custom-group-2.png and /dev/null differ diff --git a/docs/img/0.30.0/scene-tool-intro-char-1.png b/docs/img/0.30.0/scene-tool-intro-char-1.png deleted file mode 100644 index 38972ac3..00000000 Binary files a/docs/img/0.30.0/scene-tool-intro-char-1.png and /dev/null differ diff --git a/docs/img/0.31.0/client-openrouter.png b/docs/img/0.31.0/client-openrouter.png deleted file mode 100644 index 1deb3a8a..00000000 Binary files a/docs/img/0.31.0/client-openrouter.png and /dev/null differ diff --git a/docs/img/0.31.0/openrouter-settings.png b/docs/img/0.31.0/openrouter-settings.png deleted file mode 100644 index 120a1ab7..00000000 Binary files a/docs/img/0.31.0/openrouter-settings.png and /dev/null differ diff --git a/docs/img/0.33.0/restore-from-backup-dlg.png b/docs/img/0.33.0/restore-from-backup-dlg.png deleted file mode 100644 index b3f23652..00000000 Binary files a/docs/img/0.33.0/restore-from-backup-dlg.png and /dev/null differ diff --git a/docs/img/0.33.0/restore-from-backup.png b/docs/img/0.33.0/restore-from-backup.png deleted file mode 100644 index b8047deb..00000000 Binary files a/docs/img/0.33.0/restore-from-backup.png and /dev/null differ diff --git a/docs/img/0.34.0/character-card-2.png b/docs/img/0.34.0/character-card-2.png deleted file mode 100644 index 124ba3bc..00000000 Binary files a/docs/img/0.34.0/character-card-2.png and /dev/null differ diff --git a/docs/img/0.34.0/character-card-4.png b/docs/img/0.34.0/character-card-4.png deleted file mode 100644 index bfca5512..00000000 Binary files a/docs/img/0.34.0/character-card-4.png and /dev/null differ diff --git a/docs/img/0.35.0/app-settings-appearance-visuals.png b/docs/img/0.35.0/app-settings-appearance-visuals.png deleted file mode 100644 index b0a88c9d..00000000 Binary files a/docs/img/0.35.0/app-settings-appearance-visuals.png and /dev/null differ diff --git a/docs/img/0.35.0/client-deepseek.png b/docs/img/0.35.0/client-deepseek.png deleted file mode 100644 index ac95eb70..00000000 Binary files a/docs/img/0.35.0/client-deepseek.png and /dev/null differ diff --git a/docs/img/0.37.0/model-testing-harness-load.png b/docs/img/0.37.0/model-testing-harness-load.png deleted file mode 100644 index 23f1b81e..00000000 Binary files a/docs/img/0.37.0/model-testing-harness-load.png and /dev/null differ diff --git a/docs/img/0.38.0/app-settings-appearance-messages.png b/docs/img/0.38.0/app-settings-appearance-messages.png deleted file mode 100644 index 5b09cfd8..00000000 Binary files a/docs/img/0.38.0/app-settings-appearance-messages.png and /dev/null differ diff --git a/docs/img/0.38.0/app-settings-application.png b/docs/img/0.38.0/app-settings-application.png deleted file mode 100644 index 419a8516..00000000 Binary files a/docs/img/0.38.0/app-settings-application.png and /dev/null differ diff --git a/docs/img/0.38.0/app-settings-creator-perspective-presets.png b/docs/img/0.38.0/app-settings-creator-perspective-presets.png deleted file mode 100644 index 4e3982e6..00000000 Binary files a/docs/img/0.38.0/app-settings-creator-perspective-presets.png and /dev/null differ diff --git a/docs/img/0.38.0/inference-presets-1.png b/docs/img/0.38.0/inference-presets-1.png deleted file mode 100644 index 7eb472ce..00000000 Binary files a/docs/img/0.38.0/inference-presets-1.png and /dev/null differ diff --git a/docs/img/0.39.0/api-keys-anthropic.png b/docs/img/0.39.0/api-keys-anthropic.png new file mode 100644 index 00000000..962d6448 Binary files /dev/null and b/docs/img/0.39.0/api-keys-anthropic.png differ diff --git a/docs/img/0.39.0/api-keys-cohere.png b/docs/img/0.39.0/api-keys-cohere.png new file mode 100644 index 00000000..cce21a20 Binary files /dev/null and b/docs/img/0.39.0/api-keys-cohere.png differ diff --git a/docs/img/0.39.0/api-keys-elevenlabs.png b/docs/img/0.39.0/api-keys-elevenlabs.png new file mode 100644 index 00000000..a275cb3d Binary files /dev/null and b/docs/img/0.39.0/api-keys-elevenlabs.png differ diff --git a/docs/img/0.39.0/api-keys-google.png b/docs/img/0.39.0/api-keys-google.png new file mode 100644 index 00000000..7529c3f1 Binary files /dev/null and b/docs/img/0.39.0/api-keys-google.png differ diff --git a/docs/img/0.39.0/api-keys-groq.png b/docs/img/0.39.0/api-keys-groq.png new file mode 100644 index 00000000..9e5f136c Binary files /dev/null and b/docs/img/0.39.0/api-keys-groq.png differ diff --git a/docs/img/0.39.0/api-keys-mistral.png b/docs/img/0.39.0/api-keys-mistral.png new file mode 100644 index 00000000..762ddd87 Binary files /dev/null and b/docs/img/0.39.0/api-keys-mistral.png differ diff --git a/docs/img/0.39.0/api-keys-openai.png b/docs/img/0.39.0/api-keys-openai.png new file mode 100644 index 00000000..be54d057 Binary files /dev/null and b/docs/img/0.39.0/api-keys-openai.png differ diff --git a/docs/img/0.39.0/api-keys-openrouter.png b/docs/img/0.39.0/api-keys-openrouter.png new file mode 100644 index 00000000..2764b2fc Binary files /dev/null and b/docs/img/0.39.0/api-keys-openrouter.png differ diff --git a/docs/img/0.39.0/app-settings-api-keys.png b/docs/img/0.39.0/app-settings-api-keys.png new file mode 100644 index 00000000..37efaba8 Binary files /dev/null and b/docs/img/0.39.0/app-settings-api-keys.png differ diff --git a/docs/img/0.39.0/app-settings-appearance-messages.png b/docs/img/0.39.0/app-settings-appearance-messages.png new file mode 100644 index 00000000..60b959b6 Binary files /dev/null and b/docs/img/0.39.0/app-settings-appearance-messages.png differ diff --git a/docs/img/0.39.0/app-settings-appearance-visuals.png b/docs/img/0.39.0/app-settings-appearance-visuals.png new file mode 100644 index 00000000..a3202174 Binary files /dev/null and b/docs/img/0.39.0/app-settings-appearance-visuals.png differ diff --git a/docs/img/0.39.0/app-settings-creator-content-classification.png b/docs/img/0.39.0/app-settings-creator-content-classification.png new file mode 100644 index 00000000..1fa33bb6 Binary files /dev/null and b/docs/img/0.39.0/app-settings-creator-content-classification.png differ diff --git a/docs/img/0.39.0/app-settings-creator-perspective-presets.png b/docs/img/0.39.0/app-settings-creator-perspective-presets.png new file mode 100644 index 00000000..d42b9690 Binary files /dev/null and b/docs/img/0.39.0/app-settings-creator-perspective-presets.png differ diff --git a/docs/img/0.39.0/app-settings-env-variables.png b/docs/img/0.39.0/app-settings-env-variables.png new file mode 100644 index 00000000..3a9a7c95 Binary files /dev/null and b/docs/img/0.39.0/app-settings-env-variables.png differ diff --git a/docs/img/0.39.0/app-settings-gameplay.png b/docs/img/0.39.0/app-settings-gameplay.png new file mode 100644 index 00000000..9a9b5a54 Binary files /dev/null and b/docs/img/0.39.0/app-settings-gameplay.png differ diff --git a/docs/img/0.39.0/app-settings-player-character.png b/docs/img/0.39.0/app-settings-player-character.png new file mode 100644 index 00000000..b8a8e87f Binary files /dev/null and b/docs/img/0.39.0/app-settings-player-character.png differ diff --git a/docs/img/0.39.0/app-settings-presets-embeddings.png b/docs/img/0.39.0/app-settings-presets-embeddings.png new file mode 100644 index 00000000..5586b5ae Binary files /dev/null and b/docs/img/0.39.0/app-settings-presets-embeddings.png differ diff --git a/docs/img/0.39.0/app-settings-presets-inference.png b/docs/img/0.39.0/app-settings-presets-inference.png new file mode 100644 index 00000000..0dcac11e Binary files /dev/null and b/docs/img/0.39.0/app-settings-presets-inference.png differ diff --git a/docs/img/0.39.0/app-settings-presets-system-prompts.png b/docs/img/0.39.0/app-settings-presets-system-prompts.png new file mode 100644 index 00000000..e14ede69 Binary files /dev/null and b/docs/img/0.39.0/app-settings-presets-system-prompts.png differ diff --git a/docs/img/0.39.0/app-settings-search.png b/docs/img/0.39.0/app-settings-search.png new file mode 100644 index 00000000..06a871ea Binary files /dev/null and b/docs/img/0.39.0/app-settings-search.png differ diff --git a/docs/img/0.39.0/app-settings-unsaved-badge.png b/docs/img/0.39.0/app-settings-unsaved-badge.png new file mode 100644 index 00000000..75afcfaa Binary files /dev/null and b/docs/img/0.39.0/app-settings-unsaved-badge.png differ diff --git a/docs/img/0.39.0/character-card-2.png b/docs/img/0.39.0/character-card-2.png new file mode 100644 index 00000000..2c20d4c0 Binary files /dev/null and b/docs/img/0.39.0/character-card-2.png differ diff --git a/docs/img/0.39.0/character-card-4.png b/docs/img/0.39.0/character-card-4.png new file mode 100644 index 00000000..545bbae8 Binary files /dev/null and b/docs/img/0.39.0/character-card-4.png differ diff --git a/docs/img/0.39.0/client-anthropic.png b/docs/img/0.39.0/client-anthropic.png new file mode 100644 index 00000000..0c4ec0b2 Binary files /dev/null and b/docs/img/0.39.0/client-anthropic.png differ diff --git a/docs/img/0.39.0/client-cohere.png b/docs/img/0.39.0/client-cohere.png new file mode 100644 index 00000000..21b7aef9 Binary files /dev/null and b/docs/img/0.39.0/client-cohere.png differ diff --git a/docs/img/0.39.0/client-deepseek.png b/docs/img/0.39.0/client-deepseek.png new file mode 100644 index 00000000..36439347 Binary files /dev/null and b/docs/img/0.39.0/client-deepseek.png differ diff --git a/docs/img/0.39.0/client-google.png b/docs/img/0.39.0/client-google.png new file mode 100644 index 00000000..ed3affcf Binary files /dev/null and b/docs/img/0.39.0/client-google.png differ diff --git a/docs/img/0.39.0/client-groq.png b/docs/img/0.39.0/client-groq.png new file mode 100644 index 00000000..853ae7d2 Binary files /dev/null and b/docs/img/0.39.0/client-groq.png differ diff --git a/docs/img/0.39.0/client-mistral.png b/docs/img/0.39.0/client-mistral.png new file mode 100644 index 00000000..772af3ea Binary files /dev/null and b/docs/img/0.39.0/client-mistral.png differ diff --git a/docs/img/0.39.0/client-openai.png b/docs/img/0.39.0/client-openai.png new file mode 100644 index 00000000..cb1ccafc Binary files /dev/null and b/docs/img/0.39.0/client-openai.png differ diff --git a/docs/img/0.39.0/client-openrouter.png b/docs/img/0.39.0/client-openrouter.png new file mode 100644 index 00000000..b2f412d8 Binary files /dev/null and b/docs/img/0.39.0/client-openrouter.png differ diff --git a/docs/img/0.39.0/connect-a-client-add-client-modal.png b/docs/img/0.39.0/connect-a-client-add-client-modal.png new file mode 100644 index 00000000..0ebc4058 Binary files /dev/null and b/docs/img/0.39.0/connect-a-client-add-client-modal.png differ diff --git a/docs/img/0.39.0/embedding-settings-edit.png b/docs/img/0.39.0/embedding-settings-edit.png new file mode 100644 index 00000000..b95d1f3c Binary files /dev/null and b/docs/img/0.39.0/embedding-settings-edit.png differ diff --git a/docs/img/0.39.0/embedding-settings-new-1.png b/docs/img/0.39.0/embedding-settings-new-1.png new file mode 100644 index 00000000..457fabf4 Binary files /dev/null and b/docs/img/0.39.0/embedding-settings-new-1.png differ diff --git a/docs/img/0.39.0/getting-started-load-screen.png b/docs/img/0.39.0/getting-started-load-screen.png new file mode 100644 index 00000000..20395be8 Binary files /dev/null and b/docs/img/0.39.0/getting-started-load-screen.png differ diff --git a/docs/img/0.39.0/help-agent-chat.png b/docs/img/0.39.0/help-agent-chat.png new file mode 100644 index 00000000..0eec0d21 Binary files /dev/null and b/docs/img/0.39.0/help-agent-chat.png differ diff --git a/docs/img/0.39.0/help-agent-settings.png b/docs/img/0.39.0/help-agent-settings.png new file mode 100644 index 00000000..1628a92d Binary files /dev/null and b/docs/img/0.39.0/help-agent-settings.png differ diff --git a/docs/img/0.39.0/inference-presets-1.png b/docs/img/0.39.0/inference-presets-1.png new file mode 100644 index 00000000..3197f3ce Binary files /dev/null and b/docs/img/0.39.0/inference-presets-1.png differ diff --git a/docs/img/0.39.0/inference-presets-custom-group-1.png b/docs/img/0.39.0/inference-presets-custom-group-1.png new file mode 100644 index 00000000..84e6312f Binary files /dev/null and b/docs/img/0.39.0/inference-presets-custom-group-1.png differ diff --git a/docs/img/0.39.0/inference-presets-custom-group-2.png b/docs/img/0.39.0/inference-presets-custom-group-2.png new file mode 100644 index 00000000..828eb4ca Binary files /dev/null and b/docs/img/0.39.0/inference-presets-custom-group-2.png differ diff --git a/docs/img/0.39.0/model-testing-harness-load.png b/docs/img/0.39.0/model-testing-harness-load.png new file mode 100644 index 00000000..61db053a Binary files /dev/null and b/docs/img/0.39.0/model-testing-harness-load.png differ diff --git a/docs/img/0.39.0/scene-backdrop-active.png b/docs/img/0.39.0/scene-backdrop-active.png new file mode 100644 index 00000000..0de3ce11 Binary files /dev/null and b/docs/img/0.39.0/scene-backdrop-active.png differ diff --git a/docs/img/0.39.0/scene-browser-delete-project.png b/docs/img/0.39.0/scene-browser-delete-project.png new file mode 100644 index 00000000..2d34f6fb Binary files /dev/null and b/docs/img/0.39.0/scene-browser-delete-project.png differ diff --git a/docs/img/0.39.0/scene-browser-library.png b/docs/img/0.39.0/scene-browser-library.png new file mode 100644 index 00000000..e14544ea Binary files /dev/null and b/docs/img/0.39.0/scene-browser-library.png differ diff --git a/docs/img/0.39.0/scene-save.png b/docs/img/0.39.0/scene-save.png new file mode 100644 index 00000000..b34f7674 Binary files /dev/null and b/docs/img/0.39.0/scene-save.png differ diff --git a/docs/img/0.39.0/scene-tool-intro-char-1.png b/docs/img/0.39.0/scene-tool-intro-char-1.png new file mode 100644 index 00000000..5e0a5330 Binary files /dev/null and b/docs/img/0.39.0/scene-tool-intro-char-1.png differ diff --git a/docs/img/0.39.0/scene-tool-intro-char-example-dialogue.png b/docs/img/0.39.0/scene-tool-intro-char-example-dialogue.png new file mode 100644 index 00000000..87ce5f7a Binary files /dev/null and b/docs/img/0.39.0/scene-tool-intro-char-example-dialogue.png differ diff --git a/docs/img/0.39.0/scene-tool-track-state-modal-character.png b/docs/img/0.39.0/scene-tool-track-state-modal-character.png new file mode 100644 index 00000000..8e3520ce Binary files /dev/null and b/docs/img/0.39.0/scene-tool-track-state-modal-character.png differ diff --git a/docs/img/0.39.0/scene-tool-track-state-modal.png b/docs/img/0.39.0/scene-tool-track-state-modal.png new file mode 100644 index 00000000..979a7f84 Binary files /dev/null and b/docs/img/0.39.0/scene-tool-track-state-modal.png differ diff --git a/docs/img/0.39.0/scene-tool-world-state-actions.png b/docs/img/0.39.0/scene-tool-world-state-actions.png new file mode 100644 index 00000000..01384d7a Binary files /dev/null and b/docs/img/0.39.0/scene-tool-world-state-actions.png differ diff --git a/docs/img/0.39.0/scene-tool-world-state-submenu.png b/docs/img/0.39.0/scene-tool-world-state-submenu.png new file mode 100644 index 00000000..8a511afa Binary files /dev/null and b/docs/img/0.39.0/scene-tool-world-state-submenu.png differ diff --git a/docs/img/0.39.0/scene-tools-quick-settings.png b/docs/img/0.39.0/scene-tools-quick-settings.png new file mode 100644 index 00000000..71d93420 Binary files /dev/null and b/docs/img/0.39.0/scene-tools-quick-settings.png differ diff --git a/docs/img/0.39.0/timeline-dialog.png b/docs/img/0.39.0/timeline-dialog.png new file mode 100644 index 00000000..53e21c90 Binary files /dev/null and b/docs/img/0.39.0/timeline-dialog.png differ diff --git a/docs/img/0.39.0/visualizer-prompt-finalization.png b/docs/img/0.39.0/visualizer-prompt-finalization.png new file mode 100644 index 00000000..4507b0b5 Binary files /dev/null and b/docs/img/0.39.0/visualizer-prompt-finalization.png differ diff --git a/docs/img/0.39.0/world-editor-create-character-example-dialogue.png b/docs/img/0.39.0/world-editor-create-character-example-dialogue.png new file mode 100644 index 00000000..e7be45c6 Binary files /dev/null and b/docs/img/0.39.0/world-editor-create-character-example-dialogue.png differ diff --git a/docs/img/0.39.0/world-editor-create-player-character-1.png b/docs/img/0.39.0/world-editor-create-player-character-1.png new file mode 100644 index 00000000..fd04a140 Binary files /dev/null and b/docs/img/0.39.0/world-editor-create-player-character-1.png differ diff --git a/docs/img/0.39.0/world-editor-create-player-character-2.png b/docs/img/0.39.0/world-editor-create-player-character-2.png new file mode 100644 index 00000000..9b007cbe Binary files /dev/null and b/docs/img/0.39.0/world-editor-create-player-character-2.png differ diff --git a/docs/img/0.39.0/world-editor-scene-visuals-overview.png b/docs/img/0.39.0/world-editor-scene-visuals-overview.png new file mode 100644 index 00000000..3271acb6 Binary files /dev/null and b/docs/img/0.39.0/world-editor-scene-visuals-overview.png differ diff --git a/docs/user-guide/agents/creator/settings.md b/docs/user-guide/agents/creator/settings.md index 52aab58a..29b62587 100644 --- a/docs/user-guide/agents/creator/settings.md +++ b/docs/user-guide/agents/creator/settings.md @@ -4,6 +4,67 @@ Open by clicking the **Creator** agent in the agent list. ![Creator agent item](/talemate/img/0.30.0/creator-agent-item.png) +## Character Creation + +These settings control how AI-assisted character creation generates characters — +this applies everywhere characters are created with AI assistance: the world +editor, the scene tools character introduction and character card import. +The `Generate Character` node follows these settings as well. + +##### Fast Character Generation + +When enabled, character generation is consolidated into a **single prompt** +instead of one prompt per aspect. This is much faster (one request instead +of several), but note that less detail in the individual aspects is a +possible failure mode of this approach, and the model needs a large context +window and reliable structured output. Keep this off for older or smaller +models. + +Off by default. Can also be flipped quickly via the quick-toggle chip. + +When the scene has a writing style template configured, the consolidated +prompt applies it. + +##### Consolidate + +Visible when **Fast Character Generation** is enabled. Selects which +aspects the consolidated prompt generates: *Name*, *Description*, +*Attributes*, *Dialogue instructions* and/or *Example dialogue*. Aspects not +selected are still generated with their individual prompts. + +The additional *Attribute templates* entry folds attribute world-state +templates selected during character creation into the consolidated prompt as +per-attribute instructions, instead of running one prompt per template. It +requires *Attributes* to be selected as well — otherwise the templates apply +per-template as before. `{character_name}` placeholders in the template text +are formatted with the character's name, or "the character" while the name +is still being determined. + +Defaults to everything (all aspects and *Attribute templates*). + +Note that when the one-shot produces the example dialogue itself, the +`agent.creator.dialogue_examples` node-graph events are not emitted (they +fire on the split flow and on fill-in-misses regeneration only). + +##### One-shot token budget + +Visible when **Fast Character Generation** is enabled. The maximum response +tokens for the consolidated prompt (1024–8192, default 4096). All +consolidated aspects share this budget — if the response is truncated, +later sections come out missing and are regenerated individually when +**Fill in misses** is on. + +##### Fill in misses + +Visible when **Fast Character Generation** is enabled. When the +consolidated response misses an aspect entirely, that aspect's individual +request is run to fill it in. When disabled, missed aspects are left empty. + +A completely unparseable consolidated response is always a hard error, +regardless of this setting. + +On by default. + ## Long Term Memory --8<-- "docs/snippets/tips.md:agent_long_term_memory_settings" diff --git a/docs/user-guide/agents/director/settings.md b/docs/user-guide/agents/director/settings.md index ae03199e..5c95a801 100644 --- a/docs/user-guide/agents/director/settings.md +++ b/docs/user-guide/agents/director/settings.md @@ -159,13 +159,15 @@ The Character Management settings control how the director handles character cre ##### Limit character attributes -Controls the maximum number of attributes that will be generated when creating or updating character sheets. This applies when the director creates new characters or when character sheets are generated through templates. +Controls the maximum number of attributes that will be generated when creating or updating character sheets. This applies when the director creates new characters, when character sheets are generated through templates, and when a character sheet is generated during [character card import](../../character-card-import.md). - **0** (default): No limit - attributes are generated without restriction -- **1-40**: Limits the character sheet to this many attributes +- **1-40**: Generates at most this many attributes (the character's own name is not counted) When a limit is set, the AI is instructed to generate no more than the specified number of attributes, and any excess attributes are trimmed during processing. +If the sheet carries the character's own name as a `Name` attribute, it does not count towards the limit - it is scaffolding rather than a generated trait. + This setting is useful when you want to keep character sheets concise, or when working with characters that might otherwise generate an excessive number of attributes. ### Persisting Characters diff --git a/docs/user-guide/agents/help/index.md b/docs/user-guide/agents/help/index.md new file mode 100644 index 00000000..55928c3b --- /dev/null +++ b/docs/user-guide/agents/help/index.md @@ -0,0 +1,58 @@ +# Overview + +The help agent provides an interactive help chat that answers questions about Talemate itself - settings, agents, clients, the world editor, the node editor and everything else the application offers. + +It grounds its answers in the documentation that ships with Talemate: it knows the documentation's sections and can look up relevant pages by topic, search the documentation, read whole pages, or read individual sections before answering. + +Open the help chat via the :material-help-circle-outline: icon in the app bar. It is available at any time - no scene needs to be loaded. + +![Help chat answering a question about dialogue styling](/talemate/img/0.39.0/help-agent-chat.png) + +## What it can and cannot do + +- It answers questions and points you to the relevant documentation pages. +- Its answers can include links that navigate you inside the application - to an agent's settings, a world editor tab, or the director console. +- It can read your configuration and, when you ask it to, change settings for you - see [Reading and changing settings](#reading-and-changing-settings) below. +- It **cannot** change anything in your scene itself - its story, characters or world state. If you want to make changes to a scene through chat, use the [director's chat](/talemate/user-guide/agents/director/chat) instead. For questions about your scene's content the director chat is also the better place - the help agent only sees a shallow snapshot of the scene, the director has the full context. + +## Reading and changing settings + +The help agent can see what you currently have configured, so instead of answering from the documentation alone it can tell you what a setting is *set to* and what your options are. It can look at: + +- Any agent's settings - every action and setting with its current value, valid choices, and any per-scene overrides. +- Application settings - the game (general), appearance, creator, inference/embeddings presets, and prompt template group sections of your config. +- Your configured clients - type, model, context length and status. + +When you ask it to, it can also **change** settings: + +- Agent settings, either globally or as a [per-scene override](/talemate/user-guide/agents/scene-overrides) for the currently loaded scene - it can also remove a scene override so the setting falls back to the global value again. +- Application settings in the game, appearance, and creator sections. + +So things like "switch the conversation format to Narrative", "lower the editor revision to only run on dialogue", "turn off auto save" or "override the visualizer style for this scene only" can be done right from the chat. Changes are applied immediately, the chat records exactly what changed (old and new value), and any open settings dialogs refresh live. + +Some things are intentionally out of reach: + +- API keys and passwords are never shown to the help agent and it cannot change them - key management stays in the application settings (Connections → API Keys). +- Clients are read-only - client changes (model, context length, etc.) must be made in the client settings. +- Complex settings (tables, weight maps, template pickers) are read-only in chat and must be edited in the settings interface directly. + +!!! note "Open settings dialogs" + If the settings dialog of the agent you want to change is currently open, the help agent will ask you to close it first. The dialog holds its own copy of the settings - it would not show the change, and closing it could overwrite it. + + Application settings work differently: having the Settings tab open is fine, and applied changes show up in it live. Only when the settings view has **unsaved edits** will the help agent ask you to save or discard them before it changes an application setting. + +## Chats + +Help conversations are multi-turn and you can keep several of them - use the chat selector at the top of the help drawer to switch, create, or delete chats. Chats persist across restarts (they are stored in `chats/help.json` in your Talemate directory, independent of any scene). + +The most recent answer can be regenerated via the refresh button next to the message. + +## Scene awareness + +Each chat has a **Scene Aware** toggle. When enabled and a scene is loaded, the help agent can see the scene title, its characters, and recent scene progress, so you can ask scene-specific questions. When disabled the conversation is completely unaware of your scene. + +The help agent also receives a small snapshot of what you are currently looking at in the interface (active tab, open panels, the settings view's current page, and any open client or agent settings dialog including its selected tab), so questions like "what does this setting do?" can be answered in context. + +## Non-blocking + +Help chat generation runs in the background and does not block the main Talemate loop - you can keep playing a scene while a help response is being generated. If your connected client does not support concurrent requests, the requests will naturally queue up against each other. diff --git a/docs/user-guide/agents/help/settings.md b/docs/user-guide/agents/help/settings.md new file mode 100644 index 00000000..4224a564 --- /dev/null +++ b/docs/user-guide/agents/help/settings.md @@ -0,0 +1,19 @@ +# Settings + +![Help agent settings](/talemate/img/0.39.0/help-agent-settings.png) + +##### Response token budget + +Maximum response length for help responses. + +##### Tool call rounds + +How many rounds of tool calls (documentation lookups, settings reads and updates) the help agent may perform before it must answer. Each round lets the agent use its tools and then continue with the results in context. + +##### Tool calls per round + +Maximum tool calls per round. When the connected client supports concurrent inference, the read-only calls in a round execute concurrently; settings changes always run one at a time. + +##### Custom instructions + +Extra instructions added to every help chat prompt. diff --git a/docs/user-guide/agents/index.md b/docs/user-guide/agents/index.md index a0ec90ae..92f56560 100644 --- a/docs/user-guide/agents/index.md +++ b/docs/user-guide/agents/index.md @@ -18,6 +18,10 @@ Will eventually become a Game Master type agent. Right now used for some very ru Will try to correct markup and optionally revise text to fix repetition and unwanted prose. +### Help + +Interactive help chat that answers questions about Talemate itself, grounded in the bundled documentation and your actual configuration - it can also change settings for you on request. Available from the app bar at any time, with or without a scene. + ### Long-term Memory Attempts to select and add relevant information to the current context window. diff --git a/docs/user-guide/agents/memory/embeddings.md b/docs/user-guide/agents/memory/embeddings.md index c1168dba..79e265f5 100644 --- a/docs/user-guide/agents/memory/embeddings.md +++ b/docs/user-guide/agents/memory/embeddings.md @@ -2,9 +2,7 @@ You can manage your available embeddings through the application settings. -![Open settings](/talemate/img/0.26.0/open-settings.png) - -In the settings dialogue go to **:material-tune: Presets** and then **:material-cube-unfolded: Embeddings**. +Open the **:material-cog: Settings** tab in the top navigation and select **:material-cube-unfolded: Embeddings** under the **Presets** group. !!! warning "INSTRUCTOR Embeddings Removed (0.35.0)" INSTRUCTOR embeddings are no longer supported. If you were using INSTRUCTOR embeddings, your configuration has been automatically reset to use the default embedding model (all-MiniLM-L6-v2). @@ -41,7 +39,7 @@ You can add new embeddings by clicking the **:material-plus: Add new** button. Select the embedding type and then enter the model name. When using sentence-transformer, make sure the modelname matches the name of the model repository on Huggingface, so for example `Alibaba-NLP/gte-base-en-v1.5`. -![Add new embedding](/talemate/img/0.27.0/embedding-settings-new-1.png) +![Add new embedding](/talemate/img/0.39.0/embedding-settings-new-1.png) !!! warning "New embeddings require a download" When you add a new embedding model and use it for the first time in the Memory agent, Talemate will download the model from Huggingface. This can take a while, depending on the size of the model and your internet connection. @@ -50,7 +48,7 @@ Select the embedding type and then enter the model name. When using sentence-tra ## Editing an Embedding -![Edit embedding](/talemate/img/0.27.0/embedding-settings-edit.png) +![Edit embedding](/talemate/img/0.39.0/embedding-settings-edit.png) Select the existing embedding from the left side bar and you may change the following properties: diff --git a/docs/user-guide/agents/scene-overrides.md b/docs/user-guide/agents/scene-overrides.md index a4774ea1..397a81c5 100644 --- a/docs/user-guide/agents/scene-overrides.md +++ b/docs/user-guide/agents/scene-overrides.md @@ -33,14 +33,33 @@ In **Scene** mode, each overridable setting has a small link icon next to it: When you activate an override, the field starts out seeded with the current global value, so you always begin from a known state. -Some agents also let you override whether an entire action is **enabled** for the scene, using the same link icon next to the action's enable checkbox. +Any action that can be switched off globally can also be switched off for the scene alone: its **enable** checkbox carries the same link icon as the individual settings. So a scene can turn off Auto Narration, Revision, Scene Analysis, Long Term Memory and so on without touching your global configuration. -In Scene mode, the modal only shows the settings (and tabs) that actually support overriding, to keep the view focused. If an agent has no overridable settings at all, the Global / Scene switch won't appear. +Some sections are always on by design — for example the World State's Character Portraits, the Summarizer's Scene History, and the Conversation and Narrator Generation sections. They have no enable checkbox and cannot be switched off globally or per scene; a `false` left for one in the configuration file (or in a scene's overrides) is ignored. + +In Scene mode, the modal only shows the settings (and tabs) that actually support overriding, to keep the view focused. If an agent has no currently overridable settings at all, the Global / Scene switch won't appear. + +Some settings only apply while another setting is switched on — for example the Creator agent's Fast-mode fields (Consolidate, One-shot token budget, Fill in misses) only matter while **Fast Character Generation** is on. Those settings appear in Scene mode only while their gating setting allows it, and the gate is read as this scene sees it: the scene's override if there is one, otherwise the global value. So overriding the gate for a scene reveals the settings it gates, and a section (or tab) whose settings are all currently hidden drops out of Scene mode entirely. + +One exception keeps things recoverable: a setting you have **already** overridden stays visible even when its gate is off, so you can always see it in the count and clear it. Its stored value still applies whenever the gate is on again. ### Saving Overrides are saved automatically when you close the agent modal, the same way global settings are. If the scene doesn't yet have an overrides file (see below), you'll be asked to name one the first time you save an override. +## Quick-toggle chips + +Some actions and settings are also exposed as quick-toggle chips directly on the agent in the agent panel, so they can be flipped mid-play without opening the modal. + +A chip always shows the value the loaded scene is actually running on — the scene override when one is active, your global value otherwise — and clicking it changes exactly the value it is showing: + +- **No override active** — the chip toggles the global setting, as it always has. +- **Override active** — the chip carries a small :material-movie-open-cog-outline: marker and toggles the scene's override only. Your global setting is left alone; hover the chip to see what it currently is. + +Unlike the modal, a chip writes immediately — there is nothing to close and no save step. + +To go back to following the global value, clear the override in the agent modal's **Scene** mode. + ## The overrides file Per-scene overrides are stored in a JSON file inside an `agent-settings/` folder in the scene's project directory. The default file name is `agent-settings.json`. diff --git a/docs/user-guide/agents/visualizer/backends/google.md b/docs/user-guide/agents/visualizer/backends/google.md index 15c4c40a..b5236c43 100644 --- a/docs/user-guide/agents/visualizer/backends/google.md +++ b/docs/user-guide/agents/visualizer/backends/google.md @@ -15,7 +15,7 @@ Before configuring the Google backend, you need to obtain a Google API key: Then configure it in Talemate: -1. Open Talemate Settings → Application → Google +1. Open Talemate Settings → Connections → API Keys → Google 2. Paste your Google API key in the "Google API Key" field 3. Save your changes diff --git a/docs/user-guide/agents/visualizer/backends/openai.md b/docs/user-guide/agents/visualizer/backends/openai.md index 3eb5b8ec..8cc22dcb 100644 --- a/docs/user-guide/agents/visualizer/backends/openai.md +++ b/docs/user-guide/agents/visualizer/backends/openai.md @@ -15,7 +15,7 @@ Before configuring the OpenAI backend, you need to obtain an OpenAI API key: Then configure it in Talemate: -1. Open Talemate Settings → Application → OpenAI API +1. Open Talemate Settings → Connections → API Keys → OpenAI 2. Paste your OpenAI API key in the "OpenAI API Key" field 3. Save your changes diff --git a/docs/user-guide/agents/visualizer/backends/openrouter.md b/docs/user-guide/agents/visualizer/backends/openrouter.md index 49e41f89..f841ae97 100644 --- a/docs/user-guide/agents/visualizer/backends/openrouter.md +++ b/docs/user-guide/agents/visualizer/backends/openrouter.md @@ -15,7 +15,7 @@ Before configuring the OpenRouter backend, you need to obtain an OpenRouter API Then configure it in Talemate: -1. Open Talemate Settings → Application → OpenRouter API +1. Open Talemate Settings → Connections → API Keys → OpenRouter 2. Paste your OpenRouter API key in the "OpenRouter API Key" field 3. Save your changes diff --git a/docs/user-guide/agents/visualizer/settings.md b/docs/user-guide/agents/visualizer/settings.md index cda8729f..428e5ba8 100644 --- a/docs/user-guide/agents/visualizer/settings.md +++ b/docs/user-guide/agents/visualizer/settings.md @@ -2,7 +2,7 @@ ![This image displays a dark user interface header labeled "Visualizer," accented with a chromatic aberration effect and a green status dot. Below the title, there are two badges: one labeled "Google" with a monitor icon, and a warning badge featuring a triangle alert symbol that reads "No backend configured."](/talemate/img/0.34.0/visual-agent-general-1.png) -The Visualizer agent settings are organized into three main sections: **General**, **Prompt Generation**, and **Styles**. Additionally, each backend may have its own configuration options, including [resolution presets](#resolution-presets) for local image generation backends. +The Visualizer agent settings are organized into four main sections: **General**, **Prompt Generation**, **Styles**, and **Prompt Finalization**. Additionally, each backend may have its own configuration options, including [resolution presets](#resolution-presets) for local image generation backends. ![A dark-mode settings interface for a 'Visualizer' tool displaying the 'General' configuration tab. It features dropdown menus showing 'Google' selected as the client with no backends currently configured, alongside a slider for image generation timeout and checkboxes for automatic setup options.](/talemate/img/0.34.0/visual-agent-general-2.png) @@ -133,6 +133,69 @@ Each style template can include: These styles are applied automatically when generating images based on the visual type you select. +## Prompt Finalization + +![Visualizer prompt finalization settings](/talemate/img/0.39.0/visualizer-prompt-finalization.png) + +The Prompt Finalization tab defines a chain of post-processing actions applied to the final prompt strings right before they are sent to the image generation backend. Use it to clean up recurring unwanted keywords, enforce specific phrasing, or transform the whole prompt with an AI instruction. The same finalization is applied to `prompt only` generation output, and custom node graphs can invoke it through the `Finalize Prompt` node. + +All settings in this section can be overridden per scene through the Agent Modal's scene mode. + +### Presets + +The **Preset** picker below the actions table inserts the actions of a **Visual prompt finalizer** template into the table as editable copies. You can insert as many presets as you like; inserted rows behave like any other action — reorder, edit, or remove them freely. Because rows are copied, later changes to the template do not affect actions that were already inserted. + +Presets are reusable sets of post-processing actions managed in the Templates manager — Talemate ships an **Ideogram JSON** preset that converts the positive prompt into an Ideogram 4.0 structured JSON prompt. + +### Post-processing Actions + +A table of actions, executed from top to bottom. Each row has: + +- **Enabled**: Toggle the action without deleting it. +- **Mode**: How the action transforms the prompt: + - **Exact match**: Substring search and replace. Case insensitive unless the *Case sensitive* flag is set. + - **Fuzzy match**: Compares each comma-separated prompt segment against the match string using fuzzy similarity (always case insensitive) and replaces the whole segment when it scores at or above the [fuzzy match threshold](#fuzzy-match-threshold). + - **Regex**: Regular expression search and replace with group passthrough (`\1`, `\g`). Case insensitive unless the *Case sensitive* flag is set; the *Dot all* and *Multiline* flags map to the corresponding regex flags. + - **AI**: Sends the prompt to the AI together with the instruction from the *Instruct* field and replaces the prompt with the result. +- **Match**: The search string (exact, fuzzy) or pattern (regex). Hidden for AI, which only uses the instruction. +- **Replace / Instruct**: The replacement text, or — in AI mode, where the field is labeled *Instruct* — the instruction for AI processing. Leaving the replacement empty removes the match (fuzzy mode removes the whole matching segment). +- **Flags** (exact and regex modes only): Case sensitivity and regex behavior flags. +- **Target**: Whether the action applies to the positive prompt (default), the negative prompt, or both. +- **Types**: Restrict the action to specific visual types (character portrait, scene background, ...). Empty applies to all. + +!!! warning "AI actions add queries" + Each enabled AI action adds one extra AI query per targeted prompt — an action targeting *Both* adds two queries when a negative prompt is set. + +!!! info "Character-level actions" + Characters can define their own post-processing actions under World Editor → Characters → Visuals → Prompt Finalization. These run after the agent's actions whenever the generated image involves that character. + +#### Regex group references + +In regex mode, parentheses in the match pattern capture text you can reuse in the replacement: + +- `\1`, `\2`, ... — numbered groups, in order of opening parenthesis +- `\g` — a named group, defined in the pattern as `(?P...)` +- `\g<0>` — the entire matched text + +| Match | Replace | Effect | +|---|---|---| +| `(\d+)mm lens` | `85mm lens (was \1mm)` | Reuses the captured number | +| `(red\|blue\|green) hair` | `dark \1 hair` | Keeps the matched color, prefixes it | +| `(?P\w+) lighting` | `dramatic \g lighting` | Named group reference | +| `\b(\w+), \1\b` | `\1` | Deduplicates an immediately repeated segment | + +Every non-overlapping occurrence of the pattern is replaced, not just the first. A capture group preserves the casing that was actually in the prompt, so with the default case-insensitive matching there is no need to spell out casing variants. + +Some notes: + +- Use `\g<1>` instead of `\1` when the reference is immediately followed by a digit (`\g<1>0` — `\10` would be read as group 10). +- Backslash escapes in the replacement are interpreted, so a literal backslash needs doubling (`\\`). +- An invalid pattern or group reference never breaks generation — the action is skipped and the prompt passes through unchanged. + +### Fuzzy Match Threshold + +The similarity score (0-100) a prompt segment must reach to be considered a fuzzy match. The default is 85; lower values match more loosely. + ## Resolution Presets Local image generation backends (ComfyUI, SD.Next, and AUTOMATIC1111) include a resolution preset picker that lets you quickly select appropriate image dimensions for your generated images. This feature appears in each backend's configuration section. diff --git a/docs/user-guide/agents/visualizer/visual-library.md b/docs/user-guide/agents/visualizer/visual-library.md index bd1666fc..075695a9 100644 --- a/docs/user-guide/agents/visualizer/visual-library.md +++ b/docs/user-guide/agents/visualizer/visual-library.md @@ -198,6 +198,7 @@ From the Scene Assets view, you can: - **Delete**: Permanently remove an asset from your scene - **Analyze**: Use AI to analyze the image and extract information - **Set Cover**: Set the asset as a cover image for the scene or a character +- **Set Backdrop**: Set the asset as the [scene backdrop](/talemate/user-guide/inline-visuals/#scene-backdrop), rendered behind the scene text - **Use as Reference**: Open the generation dialog with this asset as a reference - **Iterate**: Create a new image based on this asset diff --git a/docs/user-guide/agents/voice/pocket-tts.md b/docs/user-guide/agents/voice/pocket-tts.md index 4965d881..d5d138d9 100644 --- a/docs/user-guide/agents/voice/pocket-tts.md +++ b/docs/user-guide/agents/voice/pocket-tts.md @@ -158,8 +158,8 @@ If the model fails to download: - Check your internet connection - Verify you have accepted the terms on [Hugging Face](https://huggingface.co/kyutai/pocket-tts) -- Make sure your [HuggingFace Token](#huggingface-token) is set correctly -- Try restarting Talemate +- Make sure your [HuggingFace Token](#huggingface-token) is set correctly, then generate again — no restart is needed; the download is retried on the next generation +- While the voice-cloning model is unavailable, the Voice agent card shows a "Voice cloning unavailable" warning ### Voice Cloning Not Working diff --git a/docs/user-guide/apis/anthropic.md b/docs/user-guide/apis/anthropic.md index dbb70acb..50366284 100644 --- a/docs/user-guide/apis/anthropic.md +++ b/docs/user-guide/apis/anthropic.md @@ -4,10 +4,8 @@ Talemate can use Anthropic for text generation. You need to have an API key for Anthropic and set it in the application config. You can manage your Anthropic API keys at [https://console.anthropic.com/settings/keys](https://console.anthropic.com/settings/keys) -Once you have your key open the Talemate settings and click the `APPLICATION` tab and then the `ANTHROPIC` category. Here you can add your Anthropic API key. +Once you have your key open the Talemate settings (the **Settings** tab in the top navigation), go to **Connections → API Keys** and scroll to (or search for) **Anthropic**. Here you can add your Anthropic API key. -![Open settings](/talemate/img/0.26.0/open-settings.png) - -![Set Anthropic Api Key](/talemate/img/0.26.0/anthropic-settings.png) +![Set Anthropic API key](/talemate/img/0.39.0/api-keys-anthropic.png) Then click `Save` to save your changes. \ No newline at end of file diff --git a/docs/user-guide/apis/cohere.md b/docs/user-guide/apis/cohere.md index 3e740a0d..cb81fb47 100644 --- a/docs/user-guide/apis/cohere.md +++ b/docs/user-guide/apis/cohere.md @@ -4,10 +4,8 @@ Talemate can use Cohere for text generation. You need to have an API key for Cohere and set it in the application config. You can manage your Cohere API keys at [https://dashboard.cohere.com/api-keys](https://dashboard.cohere.com/api-keys) -Once you have your key open the Talemate settings and click the `APPLICATION` tab and then the `COHERE` category. Here you can add your Cohere API key. +Once you have your key open the Talemate settings (the **Settings** tab in the top navigation), go to **Connections → API Keys** and scroll to (or search for) **Cohere**. Here you can add your Cohere API key. -![Open settings](/talemate/img/0.26.0/open-settings.png) - -![Set Cohere Api Key](/talemate/img/0.26.0/cohere-settings.png) +![Set Cohere API key](/talemate/img/0.39.0/api-keys-cohere.png) Then click `Save` to save your changes. \ No newline at end of file diff --git a/docs/user-guide/apis/elevenlabs.md b/docs/user-guide/apis/elevenlabs.md index 82be9444..60eec5d1 100644 --- a/docs/user-guide/apis/elevenlabs.md +++ b/docs/user-guide/apis/elevenlabs.md @@ -3,11 +3,9 @@ To use ElevenLabs TTS with Talemate, follow these steps: 1. Visit [ElevenLabs](https://elevenlabs.com) and create an account if you don't already have one. 2. Click on your profile in the upper right corner of the Eleven Labs website to access your API key. -In Talemate open the settings and click the `APPLICATION` tab and then the `ELEVENLABS` category. Here you can add your ElevenLabs API key. +In Talemate open the settings and go to **Connections → API Keys** and scroll to (or search for) **ElevenLabs**. Here you can add your ElevenLabs API key. -![Open settings](/talemate/img/0.26.0/open-settings.png) - -![Runpod settings](/talemate/img/0.26.0/elevenlabs-settings.png) +![Set ElevenLabs API key](/talemate/img/0.39.0/api-keys-elevenlabs.png) Click `Save` to save your changes. diff --git a/docs/user-guide/apis/google.md b/docs/user-guide/apis/google.md index a6891b55..aef0a2af 100644 --- a/docs/user-guide/apis/google.md +++ b/docs/user-guide/apis/google.md @@ -6,9 +6,9 @@ Please follow their [instructions for setup](https://cloud.google.com/vertex-ai/ Once you have downloaded the credentials, copy the JSON file into the talemate directory. You can rename it to something that's easier to remember, like `creds.json`. -![Open settings](/talemate/img/0.26.0/open-settings.png) +In the Talemate settings (the **Settings** tab in the top navigation), go to **Connections → API Keys** and scroll to (or search for) **Google** to fill in the credentials path and location. -![Set Google creds](/talemate/img/0.26.0/google-settings.png) +![Set Google creds](/talemate/img/0.39.0/api-keys-google.png) Then click `Save` to save your changes. diff --git a/docs/user-guide/apis/groq.md b/docs/user-guide/apis/groq.md index 52277a65..d1a7691e 100644 --- a/docs/user-guide/apis/groq.md +++ b/docs/user-guide/apis/groq.md @@ -4,10 +4,8 @@ Talemate can use Groq for text generation. You need to have an API key for Groq and set it in the application config. You can manage your Groq API keys at [https://console.groq.com/keys](https://console.groq.com/keys) -Once you have your key open the Talemate settings and click the `APPLICATION` tab and then the `GROQ` category. Here you can add your Groq API key. +Once you have your key open the Talemate settings (the **Settings** tab in the top navigation), go to **Connections → API Keys** and scroll to (or search for) **groq**. Here you can add your Groq API key. -![Open settings](/talemate/img/0.26.0/open-settings.png) - -![Set Groq Api Key](/talemate/img/0.26.0/groq-settings.png) +![Set groq API key](/talemate/img/0.39.0/api-keys-groq.png) Then click `Save` to save your changes. \ No newline at end of file diff --git a/docs/user-guide/apis/mistral.md b/docs/user-guide/apis/mistral.md index efeff6ac..37424b0b 100644 --- a/docs/user-guide/apis/mistral.md +++ b/docs/user-guide/apis/mistral.md @@ -4,10 +4,8 @@ Talemate can use Mistral.ai for text generation. You need to have an API key for Mistral.ai and set it in the application config. You can manage your Mistral.ai API keys at [https://console.mistral.ai/api-keys/](https://console.mistral.ai/api-keys/) -Once you have your key open the Talemate settings and click the `APPLICATION` tab and then the `MISTRAL.AI` category. Here you can add your Mistral.ai API key. +Once you have your key open the Talemate settings (the **Settings** tab in the top navigation), go to **Connections → API Keys** and scroll to (or search for) **mistral.ai**. Here you can add your Mistral.ai API key. -![Open settings](/talemate/img/0.26.0/open-settings.png) - -![Set Mistral.ai Api Key](/talemate/img/0.26.0/mistral-settings.png) +![Set mistral.ai API key](/talemate/img/0.39.0/api-keys-mistral.png) Then click `Save` to save your changes. \ No newline at end of file diff --git a/docs/user-guide/apis/openai.md b/docs/user-guide/apis/openai.md index 37ef2c62..8eb6c975 100644 --- a/docs/user-guide/apis/openai.md +++ b/docs/user-guide/apis/openai.md @@ -4,10 +4,8 @@ Talemate can use OpenAI for text, image and audio generation. You need to have an API key for OpenAI and set it in the application config. You can manage your OpenAI API keys at [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys) -Once you have your key open the Talemate settings and click the `APPLICATION` tab and then the `OPENAI` category. Here you can add your OpenAI API key. +Once you have your key open the Talemate settings (the **Settings** tab in the top navigation), go to **Connections → API Keys** and scroll to (or search for) **OpenAI**. Here you can add your OpenAI API key. -![Open settings](/talemate/img/0.26.0/open-settings.png) - -![Set OpenAI Api Key](/talemate/img/0.26.0/openai-settings.png) +![Set OpenAI API key](/talemate/img/0.39.0/api-keys-openai.png) Then click `Save` to save your changes. \ No newline at end of file diff --git a/docs/user-guide/apis/openrouter.md b/docs/user-guide/apis/openrouter.md index c09141ca..72535164 100644 --- a/docs/user-guide/apis/openrouter.md +++ b/docs/user-guide/apis/openrouter.md @@ -4,8 +4,8 @@ Talemate can use any model accessible through OpenRouter. You need an OpenRouter API key and must set it in the application config. You can create and manage keys in your OpenRouter dashboard at [https://openrouter.ai/keys](https://openrouter.ai/keys). -Once you have generated a key open the Talemate settings, switch to the `APPLICATION` tab and then select the `OPENROUTER API` category. Paste your key in the **API Key** field. +Once you have generated a key open the Talemate settings (the **Settings** tab in the top navigation), go to **Connections → API Keys** and scroll to (or search for) **OpenRouter**. Paste your key in the **API Key** field. -![OpenRouter API settings](/talemate/img/0.31.0/openrouter-settings.png) +![Set OpenRouter API key](/talemate/img/0.39.0/api-keys-openrouter.png) Finally click **Save** to store the credentials. \ No newline at end of file diff --git a/docs/user-guide/app-settings/appearance.md b/docs/user-guide/app-settings/appearance.md index d64eb839..a81a21e9 100644 --- a/docs/user-guide/app-settings/appearance.md +++ b/docs/user-guide/app-settings/appearance.md @@ -1,12 +1,14 @@ # :material-palette-outline: Appearance -The Appearance settings let you customize how messages and visuals are displayed in your scene. These settings are divided into two tabs: **Messages** and **Visuals**. +The Appearance settings let you customize how messages and visuals are displayed in your scene. The **Appearance** group in the settings sidebar holds two pages: **Messages** and **Visuals**. + +While you edit appearance settings, the scene view previews your changes live; they only become permanent when you save. ## :material-script: Messages -![App settings - Appearance - Messages](/talemate/img/0.38.0/app-settings-appearance-messages.png) +![App settings - Appearance - Messages](/talemate/img/0.39.0/app-settings-appearance-messages.png) -The Messages tab gives you control over how text appears in the scene feed. You can customize the styling for different message types and special text formatting. +The Messages page gives you control over how text appears in the scene feed. You can customize the styling for different message types and special text formatting. ### Message Types @@ -73,38 +75,39 @@ To change a color: ## :material-image-outline: Visuals -![App settings - Appearance - Visuals](/talemate/img/0.35.0/app-settings-appearance-visuals.png) +![App settings - Appearance - Visuals](/talemate/img/0.39.0/app-settings-appearance-visuals.png) -The Visuals tab controls how images appear alongside messages in your scene feed. This includes character portraits, scene illustrations, and character cards. +The Visuals page controls how images appear alongside messages in your scene feed. This includes character portraits, cards, scene illustrations, and scene backgrounds. -### Asset Types +### Visual Types -Configure display settings for each type of visual asset: +Configure display settings for each type of visual: -| Asset Type | Description | +| Visual Type | Description | |-----------|-------------| -| **Avatar** | Character portraits that appear next to character messages | +| **Portrait** | Character portraits that appear next to character messages | | **Card** | Character or scene cards that can be attached to messages | -| **Scene Illustration** | Scene illustrations and backgrounds attached to messages | +| **Scene Illustration** | Images of the current moment ("Visualize Moment") attached to messages | +| **Scene Background** | Purely environmental images ("Visualize Scene (Background)") | ### Display Settings -For each asset type, you can configure: +For each visual type, you can configure: -#### Cadence +#### Render Cadence -Controls when the asset is displayed: +Controls when the visual is displayed: -- **Always** - Show the asset on every message that has one (default) -- **On Change** - Only show when the asset differs from the previous message of the same type -- **Never** - Hide this type of asset entirely +- **Always** - Show the visual on every message that has one (default) +- **On Change** - Only show when the visual differs from the previous message of the same type (portraits only) +- **Never** - Hide this type of visual entirely -!!! tip "Using On Change for Avatars" - Setting avatar cadence to **On Change** can reduce visual clutter in long conversations. The character's portrait will only appear when it changes, such as when the character's expression or appearance shifts during the scene. +!!! tip "Using On Change for Portraits" + Setting portrait cadence to **On Change** can reduce visual clutter in long conversations. The character's portrait will only appear when it changes, such as when the character's expression or appearance shifts during the scene. -#### Size +#### Display Size -Controls how large the asset appears: +Controls how large the visual appears: - **Small** - Compact display, good for keeping focus on the text - **Medium** - Balanced size (default) @@ -113,6 +116,19 @@ Controls how large the asset appears: !!! note "Scene Illustration Positioning" When set to **Big**, scene illustrations display above the message text rather than inline. This gives them more prominence and works well for important scene moments. -### Auto-attach Assets +#### Auto Backdrop + +Available for **Scene Illustration** and **Scene Background**. When enabled, newly generated images of that type automatically become the [scene backdrop](/talemate/user-guide/inline-visuals/#scene-backdrop) — rendered behind the scene text instead of only appearing inline. Off by default for both types. + +Any illustration can also be set as the backdrop manually via its image menu, and the scene tools **Immersive** chip toggles the backdrop on and off. + +### Backdrop Legibility + +When a backdrop is active, message text sits on translucent panels so it stays readable over the image. Two settings control this: + +- **Backdrop message panel opacity** - How opaque the panels behind the message text are. Defaults to `0.8`; lower values let more of the backdrop show through. +- **Backdrop message text shadow** - Renders a subtle drop shadow behind the message text for extra contrast. On by default. + +### Auto-attach Visuals When enabled (the default), newly generated visuals are automatically attached to the relevant messages in your scene. Disable this if you prefer to manually manage which images appear with which messages. diff --git a/docs/user-guide/app-settings/application.md b/docs/user-guide/app-settings/application.md index 77a20e18..e3041860 100644 --- a/docs/user-guide/app-settings/application.md +++ b/docs/user-guide/app-settings/application.md @@ -1,12 +1,16 @@ -# :material-application-outline: Application +# :material-connection: Connections -![App settings - Application](/talemate/img/0.38.0/app-settings-application.png) +The **Connections** group of the application settings holds credentials for third party services and the environment variable store. Open it from the **:material-cog: Settings** tab in the top navigation. -Configure various API keys for integration with external services. (OpenAI, Anthropic, etc.) +## :material-key-variant: API Keys -Each external service has its own page in the sidebar. Select a service, paste your key or token, and save. +![App settings - API Keys](/talemate/img/0.39.0/app-settings-api-keys.png) -## HuggingFace Token +Configure API keys for integration with external services. (OpenAI, Anthropic, etc.) + +All services live on a single page — scroll to the service, paste your key or token, and save. You can also type a service name into the sidebar's **Search settings** field to jump directly to its entry. + +### HuggingFace Token !!! info "Added in 0.38.0" @@ -14,11 +18,23 @@ Some features download model weights from [Hugging Face](https://huggingface.co/ To download gated weights, add a HuggingFace access token: -1. Open the **HuggingFace** page under Application settings. +1. Find the **HuggingFace** entry on the API Keys page. 2. Create a token at [https://huggingface.co/settings/tokens](https://huggingface.co/settings/tokens). A read token is sufficient. 3. Paste it into the **HuggingFace Token** field and save. The token is shared across Talemate. Once set here, any feature that needs to download gated weights (such as the [Pocket TTS](../agents/voice/pocket-tts.md) agent) will use it automatically. You can also set the same token directly in the Pocket TTS agent config — both fields point at the same setting. !!! note "Accepting model terms" - A token only proves who you are. For gated models you still need to visit the model's page on Hugging Face and accept its terms once with the same account before the download will succeed. \ No newline at end of file + A token only proves who you are. For gated models you still need to visit the model's page on Hugging Face and accept its terms once with the same account before the download will succeed. + +## :material-variable: Environment Variables + +!!! info "Added in 0.39.0" + +![App settings - Environment Variables](/talemate/img/0.39.0/app-settings-env-variables.png) + +Named values that are passed as environment variables to processes Talemate spawns — currently the [Pi Bridge](../clients/types/pi-bridge.md) client, where pi's `models.json` can reference them as `$NAME`. + +Values are encrypted at rest in Talemate's configuration file. See [API key encryption](../api-key-encryption.md) for how encryption keys are managed. + +To add a variable, enter a name (letters, digits and underscores; must not start with a digit) and a value in the bottom row and press ++enter++ or the plus button. Existing variables can have their value edited in place or be removed with the delete button; remember to save. diff --git a/docs/user-guide/app-settings/creator.md b/docs/user-guide/app-settings/creator.md index efb19b41..c61a91bf 100644 --- a/docs/user-guide/app-settings/creator.md +++ b/docs/user-guide/app-settings/creator.md @@ -1,21 +1,27 @@ -# :material-cube-scan: Creator +# :material-feather: Storytelling -The **Creator** tab in the application settings holds shared building blocks that are offered as picker options elsewhere in the UI. Open it from **Settings** (the cogwheel in the top navigation) and switch to the **Creator** tab. +The **Storytelling** group in the application settings holds shared building blocks that are offered as picker options elsewhere in the UI. Open the **:material-cog: Settings** tab in the top navigation and find the **Storytelling** group in the sidebar. -Two sub-tabs live here: +Two pages live here: - **Content Classification** — the list of content classification strings offered when you set up a scene. - **Perspective Presets** — the list of narrative perspective / tense strings offered in the scene outline. Both lists are global to your Talemate installation. Editing them does not change any existing scene — your scenes keep whatever value they were saved with. The lists only affect what appears as suggestions in the pickers next time you edit a field. +## Content Classification + +![Storytelling settings with the Content Classification page selected](/talemate/img/0.39.0/app-settings-creator-content-classification.png) + +Available content classification choices when generating characters or scenarios. Add and remove entries the same way as perspective presets below. + ## Perspective Presets !!! info "Added in 0.38.0" The presets you add here show up in every **Perspective and tense** combobox in the scene outline (default plus the three per-speaker overrides). They are convenience suggestions, not constraints — you can always type a custom value into any of the four fields if no preset fits. -![Creator settings with the Perspective Presets sub-tab selected](/talemate/img/0.38.0/app-settings-creator-perspective-presets.png) +![Storytelling settings with the Perspective Presets page selected](/talemate/img/0.39.0/app-settings-creator-perspective-presets.png) ### Adding a preset diff --git a/docs/user-guide/app-settings/game.md b/docs/user-guide/app-settings/game.md index 21e58ae4..f87ad328 100644 --- a/docs/user-guide/app-settings/game.md +++ b/docs/user-guide/app-settings/game.md @@ -1,7 +1,20 @@ -# Game +# :material-gamepad-square: Game + +Application settings open in the main view via the **:material-cog: Settings** tab in the top navigation (or the cogwheel icon on the right side of the top bar). Navigation lives in the left sidebar, grouped by topic, with a **search field** at the top that finds any setting by name and jumps straight to it. + +Changes are collected as you edit — an **Unsaved changes** indicator with **Save** and **Discard** actions appears in the toolbar whenever your edits differ from the stored configuration. Nothing is applied until you save. Switching to another main tab while edits are pending opens a **You have unsaved settings** prompt with three actions: **Ignore** leaves the tab with your edits still pending, **Save** saves them and then leaves, and **Discard Changes** reverts them and then leaves. Moving between pages inside Settings never prompts. Ignored edits survive switching to another main tab and back, and while they are pending the **Settings** tab in the top navigation shows a warning badge, so the state stays visible even after you switch to another tab. + +If the configuration is changed elsewhere while you have unsaved edits — from another window, or by the [help agent](/talemate/user-guide/agents/help/) — a **Changed outside this view — saving overwrites** warning appears in the toolbar instead of your edits being silently overwritten (or silently overwriting the other change). **Discard** loads the latest stored configuration; **Save** overwrites the outside change with your edits. + +![Settings tab showing the unsaved-changes badge](/talemate/img/0.39.0/app-settings-unsaved-badge.png) + +![App settings - search](/talemate/img/0.39.0/app-settings-search.png) + +The **Game** group holds general game behavior and the default player character. + ## :material-cog: General -![App settings - Game - General](/talemate/img/0.29.0/app-settings-game-general.png) +![App settings - Game - General](/talemate/img/0.39.0/app-settings-gameplay.png) ##### Auto save @@ -23,10 +36,18 @@ When enabled, a horizontal bar appears above the scene tools showing which agent This setting is enabled by default. -## :material-human-edit: Default character +##### Release GPU cache on scene load -![App settings - Game - Default Character](/talemate/img/0.29.0/app-settings-game-default-character.png) +When enabled, idle GPU memory reserved by local CUDA embeddings or TTS is handed back to the driver when switching scenes, so it doesn't pile up and eventually leave too little VRAM to load another scene. Disable only if you have VRAM to spare and would rather keep it reserved. + +## :material-human-edit: Player Character + +![App settings - Player Character](/talemate/img/0.39.0/app-settings-player-character.png) Lets you manage a basic default character. -This is only relevant when loading scenes that do not come with a default character. (e.g., mostly from other application exports, like ST character cards.) \ No newline at end of file +This character is used when a scene is loaded that does not define a player character. Mostly relevant when you load character-cards that aren't in the talemate scene format (e.g., ST character cards). + +##### Add default character to blank talemate scenes + +When creating a new scene, add the default player character to the scene. diff --git a/docs/user-guide/app-settings/presets.md b/docs/user-guide/app-settings/presets.md index 3e308510..c8a48819 100644 --- a/docs/user-guide/app-settings/presets.md +++ b/docs/user-guide/app-settings/presets.md @@ -1,6 +1,6 @@ # :material-tune: Presets -Change inference parameters, embedding parameters and global system prompt overrides. +Change inference parameters, embedding parameters and global system prompt overrides. The **Presets** group in the settings sidebar holds three pages: **Inference**, **Embeddings** and **System Prompts**. ## :material-matrix: Inference @@ -9,7 +9,7 @@ Change inference parameters, embedding parameters and global system prompt overr This document will NOT explain what each setting does. -![App settings - Application](/talemate/img/0.29.0/app-settings-presets-inference.png) +![App settings - Presets - Inference](/talemate/img/0.39.0/app-settings-presets-inference.png) If you're familiar with editing inference parameters from other similar applications, be aware that there is a significant difference in how TaleMate handles these settings. @@ -27,7 +27,7 @@ These are all experimental and will probably change / get merged in the future. ## :material-cube-unfolded: Embeddings -![App settings - Application](/talemate/img/0.29.0/app-settings-presets-embeddings.png) +![App settings - Presets - Embeddings](/talemate/img/0.39.0/app-settings-presets-embeddings.png) Allows you to add, remove and manage various embedding models for the memory agent to use via chromadb. @@ -35,7 +35,7 @@ Allows you to add, remove and manage various embedding models for the memory age ## :material-text-box: System Prompts -![App settings - Application](/talemate/img/0.29.0/app-settings-presets-system-prompts.png) +![App settings - Presets - System Prompts](/talemate/img/0.39.0/app-settings-presets-system-prompts.png) This panel lets you override the global system prompts for the entire application for each prompt kind (Conversation, Narration, Creation, and so on). Per-client overrides live on the **System Prompts** tab of each client's [configuration dialog](../clients/client-configuration.md). diff --git a/docs/user-guide/app-settings/system-prompts.md b/docs/user-guide/app-settings/system-prompts.md index af65f5be..58ddfadd 100644 --- a/docs/user-guide/app-settings/system-prompts.md +++ b/docs/user-guide/app-settings/system-prompts.md @@ -18,9 +18,9 @@ If a level is blank it falls through to the next one. An empty textarea counts a ### App-level overrides -Open **Settings** (the cogwheel in the top navigation), then go to the **Presets** tab and select **System Prompts**. +Open the **:material-cog: Settings** tab in the top navigation, then select **System Prompts** under the **Presets** group in the sidebar. -![App-wide System Prompt presets with the override list on the left](/talemate/img/0.29.0/app-settings-presets-system-prompts.png) +![App-wide System Prompt presets with the override list on the left](/talemate/img/0.39.0/app-settings-presets-system-prompts.png) App-level overrides apply to every client unless that client has its own override for the same prompt kind. diff --git a/docs/user-guide/character-card-import.md b/docs/user-guide/character-card-import.md index ba2a3ce0..b0f7507c 100644 --- a/docs/user-guide/character-card-import.md +++ b/docs/user-guide/character-card-import.md @@ -22,13 +22,15 @@ Character cards can be provided as: ## Starting an Import -Character card import is initiated from the **Load Scene** panel in the left sidebar on the home screen: +Character card import is initiated from the **Import** dropzone on the [home screen](/talemate/user-guide/scene-browser): -![Drag and Drop Upload Area](/talemate/img/0.34.0/character-card-4.png) +![Drag and Drop Upload Area](/talemate/img/0.39.0/character-card-4.png) -1. Drag and drop a character card file (image or JSON), or click to browse +1. Drag and drop a character card file (image or JSON) onto the dropzone, or click it to browse 2. If the file is detected as a character card, the **Character Card Import Options** dialog will automatically open +Cards already stored in your `scenes/characters/` directory can also be imported by clicking them in the **Character Cards** section of the [Scene Library](/talemate/user-guide/scene-browser#character-cards). + ![Character Card Import Dialog](/talemate/img/0.34.0/character-card-1.png) The import dialog will automatically analyze the character card and display detected information. @@ -61,7 +63,7 @@ The middle panel allows you to select which characters from the card should be i Talemate uses the [Director Agent](/talemate/user-guide/agents/director) to analyze greeting texts and automatically detect multiple characters present in the character card. All detected characters are selected by default. -![Character Selection and Import Options](/talemate/img/0.34.0/character-card-2.png) +![Character Selection and Import Options](/talemate/img/0.39.0/character-card-2.png) - Use checkboxes to select/deselect characters @@ -103,6 +105,33 @@ The right panel contains various import configuration options: !!! info "Default Behavior" This option defaults to `true` if alternate greetings are detected, as shared context is particularly useful when working with multiple episodes. +### AI Generation + +By default, importing a character card runs several AI generation steps that enrich the imported scene. Each of these can be disabled individually to speed up the import — disabled steps fall back to the card's original data. The individual toggles are collapsed by default — click the **AI Generation** section header to expand them. + +- **Determine Content Context**: AI determines the content context (genre and tone) for the scene. + +- **Generate Description**: AI rewrites the character description. When disabled, the card's original description is kept as-is. + +- **Extract Attributes**: AI extracts a structured attribute sheet (age, appearance, personality, etc.) from the card. The Director's [**Limit character attributes**](/talemate/user-guide/agents/director/settings/#limit-character-attributes) setting caps how many attributes this generates. + +- **Generate Dialogue Instructions**: AI generates acting instructions that guide how the character speaks and behaves. + +- **Generate Example Dialogue**: AI generates example dialogue for the character. When disabled, the card's original example dialogue is imported as-is, with typographic quotes converted to straight quotes. + +- **Generate Story Intent**: AI generates the overall story intent for the scene. When the Director's auto-direct is enabled, this also covers scene type generation and scene intent setup. + +!!! info "Failed generations keep the card's data" + If the description rewrite, attribute extraction or example dialogue generation + fails or comes back empty, the card's own version is kept and the import carries on + with the remaining steps. With the Creator agent's [**Fast Character Generation**](/talemate/user-guide/agents/creator/settings) + enabled, a consolidated response that cannot be parsed at all still aborts the import. + +Use the **Full** / **Minimal** preset buttons to toggle all AI generation steps at once. **Minimal** also disables episode title generation, reducing the import to the bare card data (name, description, greetings, example dialogue) — the fastest possible import. + +!!! tip "Simplified Import" + If your backend is slow or you just want to get playing quickly, choose the **Minimal** preset. You can always generate attributes, dialogue instructions and other details later from the [Character Editor](/talemate/user-guide/world-editor/characters). + ### Writing Style Template Select an optional writing style template to apply to the scene. This affects how the AI generates narrative text and dialogue. @@ -149,9 +178,9 @@ Import an existing player character from another Talemate scene. - Character name, description, and greeting text -- Character attributes (determined by AI analysis) +- Character attributes (determined by AI analysis, if enabled) -- Dialogue examples (generated from character card data) +- Dialogue examples (generated from character card data, or imported as-is if generation is disabled, with typographic quotes converted to straight quotes) - Character color (automatically assigned unique colors) diff --git a/docs/user-guide/clients/auto-retry.md b/docs/user-guide/clients/auto-retry.md new file mode 100644 index 00000000..c5b075ec --- /dev/null +++ b/docs/user-guide/clients/auto-retry.md @@ -0,0 +1,16 @@ +# Auto Retry + +By default, when a generation runs into a response issue — an empty response, an API rate limit, or a reasoning model that skipped its reasoning tokens — Talemate notifies you immediately with a dialog offering to retry, ignore, or cancel the generation. + +You can instead configure a client to quietly retry a number of times on its own before you are notified. Each response issue has its own slider (0 to 5 retries, default 0 = notify immediately): + +- **Empty Response** — the model returned an empty response. Retries fire immediately. +- **Rate Limited** — the API responded with HTTP 429. Retries wait progressively longer between attempts (2s, 4s, 8s, 16s, capped at 30s). Other API errors are not affected and still notify you immediately. +- **Missing Reasoning** (labeled **Auto Retry** on the Reasoning tab) — a reasoning model's response did not contain the expected reasoning pattern. Retries fire immediately. See [Reasoning](reasoning.md). + +The **Empty Response** and **Rate Limited** sliders are on the **Advanced** tab of the [client configuration](client-configuration.md) dialog. The **Missing Reasoning** slider is on the **Reasoning** tab, next to the **Pattern Not Found Behavior** setting (it only appears when that setting is **Fail** — with **Ignore**, a missing pattern is never treated as an error). + +While automatic retries are running, a notification at the top of the screen shows which client is retrying, why, and the attempt count. You can abort the generation from there at any time. If all automatic retries are exhausted, the usual generation error dialog appears. + +!!! note "Rate limit responses vs. the Rate Limit slider" + The **Rate Limited** retry slider reacts to the *API* telling Talemate to slow down (HTTP 429). This is separate from the [Rate Limit](rate-limiting.md) slider, which is Talemate's own client-side cap on requests per minute. diff --git a/docs/user-guide/clients/client-configuration.md b/docs/user-guide/clients/client-configuration.md index e43ed5b0..3f078462 100644 --- a/docs/user-guide/clients/client-configuration.md +++ b/docs/user-guide/clients/client-configuration.md @@ -19,11 +19,11 @@ The tabs that appear depend on the client type. The core set is: |---|---| | **General** | Client type, name, API URL / key, model, context length, prompt template (for local clients). | | **Coercion** | Prefill text used to enforce compliance. Only shown for clients that can be coerced. | -| **Advanced** | Inference Presets, Structured Data Format, [Section Format](section-format.md), [Response Length Enforcement](response-length.md), Prompt Caching, and [Rate Limit](rate-limiting.md). | +| **Advanced** | Inference Presets, Structured Data Format, [Section Format](section-format.md), [Response Length Enforcement](response-length.md), Prompt Caching, [Rate Limit](rate-limiting.md), and [Auto Retry](auto-retry.md). | | **Reasoning** | [Reasoning model support](reasoning.md) settings. | | **System Prompts** | Per-client [system prompt overrides](../app-settings/system-prompts.md). | -Some client types add extra tabs (for example the **Endpoint Override** tab on remote clients, or the **Concurrency** tab on clients that support concurrent requests). +Some client types add extra tabs (for example the **Endpoint Override** tab on remote clients, the **Concurrency** tab on clients that support concurrent requests, or the **Parameters** tab on the [OpenRouter client](types/openrouter.md)). ### Advanced tab @@ -37,6 +37,7 @@ The Advanced tab contains settings that you usually only need to touch once per - **Response Length Enforcement** — how the response length is communicated to the model. See [Response Length Enforcement](response-length.md). - **Optimize for Prompt Caching** — moves volatile context after the scene history to improve cache hit rates. See [Volatile Context Placement](../prompts/volatile-context-placement.md). - **Rate Limit** — caps requests per minute. See [Rate Limiting](rate-limiting.md). +- **Auto Retry** — automatic retries on empty or rate-limited responses before you are notified. See [Auto Retry](auto-retry.md). From the General tab you can also jump straight to Advanced with the :material-cog-outline: **Advanced Options** button underneath the basic fields. diff --git a/docs/user-guide/clients/concurrent-requests.md b/docs/user-guide/clients/concurrent-requests.md index 66ff94d7..acd3047e 100644 --- a/docs/user-guide/clients/concurrent-requests.md +++ b/docs/user-guide/clients/concurrent-requests.md @@ -4,9 +4,16 @@ Concurrent requests is an experimental feature that allows certain LLM clients t ## What It Does -When enabled, operations that require multiple LLM queries (such as generating image prompts) will execute those queries in parallel instead of sequentially. This can significantly reduce the total time needed for these batch operations. +When enabled, operations that require multiple LLM queries will execute those queries in parallel instead of sequentially. This can significantly reduce the total time needed for these batch operations. -**Currently, this feature is only used for visual prompt generation** (creating prompts for image generation). It is not applied to regular conversation or narration tasks. +Operations that take advantage of this include: + +- Visual/image prompt generation +- Function-calling batches, where an AI response requests several tool calls at once (for example director chat query actions or help agent documentation lookups) +- Multi-query world state updates +- World state snapshots, which only run as true background tasks when the client can handle a concurrent request + +Regular conversation and narration tasks are single generations and are unaffected. ## Supported Clients @@ -15,7 +22,9 @@ Concurrent requests are available for the following hosted API clients: - [Anthropic](/talemate/user-guide/clients/types/anthropic/) - [OpenAI](/talemate/user-guide/clients/types/openai/) - [Google Gemini](/talemate/user-guide/clients/types/google/) +- [MistralAI](/talemate/user-guide/clients/types/mistral/) - [OpenRouter](/talemate/user-guide/clients/types/openrouter/) +- [Pi Bridge](/talemate/user-guide/clients/types/pi-bridge/) — each concurrent request runs its own pi instance It is also available for the local [llama.cpp](/talemate/user-guide/clients/types/llamacpp/) client. For llama.cpp, your `llama-server` must be started with enough parallel slots to handle more than one request at a time; otherwise the extra requests are simply queued and you will not see a speed-up. @@ -40,8 +49,8 @@ You can also enable this feature through the client's settings dialog under the Consider enabling concurrent requests if: -- You frequently use the visual/image generation features -- You want to reduce wait times during image prompt generation +- You frequently use the visual/image generation features, the director or help chats, or tracked world states +- You want to reduce wait times during batch operations - You are not experiencing rate limit issues with the API You can safely leave this disabled if: diff --git a/docs/user-guide/clients/model-testing-harness.md b/docs/user-guide/clients/model-testing-harness.md index 3624959a..27345693 100644 --- a/docs/user-guide/clients/model-testing-harness.md +++ b/docs/user-guide/clients/model-testing-harness.md @@ -17,11 +17,10 @@ Passing every test does not guarantee good creative output, but any failure is a The harness is a normal Talemate scene and loads the same way as any other — see [Load a scenario](/talemate/getting-started/load-a-scene/) for the general flow. -1. From the **Home** screen, start typing `Model Testing Harness` into the **Search scenes** field. -2. Select the matching entry from the autocomplete. -3. Click **Load**. +1. From the **Home** screen, type `model` into the **Filter scenes** field of the [Scene Library](/talemate/user-guide/scene-browser). +2. Click the `model-testing-harness.json` save file in the **model-testing-harness** project. -![Searching for the Model Testing Harness from the Home screen](/talemate/img/0.37.0/model-testing-harness-load.png) +![Searching for the Model Testing Harness from the Home screen](/talemate/img/0.39.0/model-testing-harness-load.png) The tests start automatically as soon as the scene finishes loading — there is no separate "run" button to press. diff --git a/docs/user-guide/clients/presets.md b/docs/user-guide/clients/presets.md index 9f133ffb..5d45b5c8 100644 --- a/docs/user-guide/clients/presets.md +++ b/docs/user-guide/clients/presets.md @@ -6,11 +6,9 @@ If you wish to alter the inference parameters sent with the generation requests for text-generation you can do in the settings interface. -![open settings](/talemate/img/0.26.0/open-settings.png) +Open the **Settings** tab in the top navigation, then select :material-matrix: **Inference** under the :material-tune: **Presets** group in the sidebar. -Navigate to the :material-tune: **Presets** tab then select the :material-matrix: **Inference** tab. - -![selected preset](/talemate/img/0.38.0/inference-presets-1.png) +![selected preset](/talemate/img/0.39.0/inference-presets-1.png) !!! warning Not all clients support all parameters, and generally it is assumed that the client implementation handles the parameters in a sane way, especially if values are passed for all of them. All presets are used and will be selected depending on the action the agent is performing. If you don't know what these mean, it is recommended to leave them as they are. @@ -139,7 +137,7 @@ Because this overwrites the values of all the other presets in the group, you wi The **Reset** action restores the currently selected preset back to its default values. As of version 0.38.0 this action also asks for confirmation before resetting, so an accidental click no longer discards your changes. -Both actions only change the values in the editor. Remember to save the settings with the **Save** button at the bottom of the dialog for them to take effect. +Both actions only change the values in the editor. Remember to save the settings with the **Save** button in the settings toolbar for them to take effect. ## Preset Groups @@ -147,12 +145,12 @@ Initially there is a `Default` group in which the presets are edited, but if you To add a new group, type the title in to the **New Group Name** field in the upper right and press `Enter`. -![new group](/talemate/img/0.30.0/inference-presets-custom-group-1.png) +![new group](/talemate/img/0.39.0/inference-presets-custom-group-1.png) The new group will be added and automatically selected for editing. -![new group](/talemate/img/0.30.0/inference-presets-custom-group-2.png) +![new group](/talemate/img/0.39.0/inference-presets-custom-group-2.png) Once you have adjusted the presets to your liking you can save the group by clicking the :material-content-save: **Save** button. diff --git a/docs/user-guide/clients/rate-limiting.md b/docs/user-guide/clients/rate-limiting.md index 292e7863..18e71776 100644 --- a/docs/user-guide/clients/rate-limiting.md +++ b/docs/user-guide/clients/rate-limiting.md @@ -2,6 +2,8 @@ You can rate limit a client to N requests per minute. The slider is on the **Advanced** tab of the [client configuration](client-configuration.md) dialog. +This is Talemate's own client-side cap. If instead the *API* is rate limiting you (HTTP 429 responses), see the **Rate Limited** slider under [Auto Retry](auto-retry.md). + ![Rate limit](/talemate/img/0.30.0/client-ratelimit.png) Once the limit is hit you will get a popup notification. diff --git a/docs/user-guide/clients/reasoning.md b/docs/user-guide/clients/reasoning.md index dc781d27..0b1f90d2 100644 --- a/docs/user-guide/clients/reasoning.md +++ b/docs/user-guide/clients/reasoning.md @@ -66,6 +66,8 @@ When the configured reasoning pattern is not found in a response, you can contro - **Fail** (default) - Raises an error, causing the request to fail. Use this when you expect the model to always include reasoning tokens and want to be alerted if it doesn't. - **Ignore** - Returns the response as-is without stripping anything. Use this when the model may sometimes respond without reasoning tokens (e.g., for simple queries). +When set to **Fail**, the **Auto Retry** slider next to it lets the client automatically retry a number of times before you are notified. See [Auto Retry](auto-retry.md). + ## Forcing Reasoning Off for Specific Actions The **Enable Reasoning** checkbox is a global setting for a client — when it's on, every prompt that client handles uses reasoning. Sometimes that isn't what you want. A reasoning model might do an excellent job writing dialogue but waste time (and tokens) "thinking" before simple, mechanical tasks like summarization or world-state updates. diff --git a/docs/user-guide/clients/types/.pages b/docs/user-guide/clients/types/.pages index c415e92a..ee857097 100644 --- a/docs/user-guide/clients/types/.pages +++ b/docs/user-guide/clients/types/.pages @@ -11,6 +11,7 @@ nav: - OpenAI Compatible: openai-compatible.md - Ollama: ollama.md - OpenRouter: openrouter.md + - Pi Bridge: pi-bridge.md - TabbyAPI: tabbyapi.md - Text-Generation-WebUI: text-generation-webui.md - ... \ No newline at end of file diff --git a/docs/user-guide/clients/types/anthropic.md b/docs/user-guide/clients/types/anthropic.md index 81fb88d3..b55bbcd9 100644 --- a/docs/user-guide/clients/types/anthropic.md +++ b/docs/user-guide/clients/types/anthropic.md @@ -2,7 +2,7 @@ If you want to add an Anthropic client, change the `Client Type` to `Anthropic`. -![Client Anthropic](/talemate/img/0.26.0/client-anthropic.png) +![Client Anthropic](/talemate/img/0.39.0/client-anthropic.png) Click `Save` to add the client. @@ -12,11 +12,11 @@ The client should appear in the clients list. If you haven't setup Anthropic bef ![Client anthropic no api key](/talemate/img/0.26.0/client-anthropic-no-api-key.png) -Click the `SET API KEY` button. This will open the api settings window where you can add your Anthropic API key. +Click the `SET API KEY` button. This will open the application settings on the API Keys page where you can add your Anthropic API key. For additional instructions on obtaining and setting your Anthropic API key, see [Anthropic API instructions](/talemate/user-guide/apis/anthropic/). -![Anthropic settings](/talemate/img/0.26.0/anthropic-settings.png) +![Anthropic settings](/talemate/img/0.39.0/api-keys-anthropic.png) Click `Save` and after a moment the client should have a green dot next to it, indicating that it is ready to go. @@ -36,4 +36,8 @@ Which model to use. Currently defaults to `claudr-3.5-sonnet`. !!! note "Talemate lags behind Anthropic" When Anthropic adds a new model, it may take a Talemate update to add it to the list of available models. However, you can always manually enter any model name in the model field if you know the exact model identifier. + +##### Context Length + +Maximum context length (in tokens) for generations sent to Anthropic. If you are not sure, leave the default value. --8<-- "docs/snippets/common.md:client-response-length" diff --git a/docs/user-guide/clients/types/cohere.md b/docs/user-guide/clients/types/cohere.md index 5934aada..14c9cf44 100644 --- a/docs/user-guide/clients/types/cohere.md +++ b/docs/user-guide/clients/types/cohere.md @@ -2,7 +2,7 @@ If you want to add an Cohere client, change the `Client Type` to `Cohere`. -![Client Cohere](/talemate/img/0.26.0/client-cohere.png) +![Client Cohere](/talemate/img/0.39.0/client-cohere.png) Click `Save` to add the client. @@ -12,11 +12,11 @@ The client should appear in the clients list. If you haven't setup Cohere before ![Client cohere no api key](/talemate/img/0.26.0/client-cohere-no-api-key.png) -Click the `SET API KEY` button. This will open the api settings window where you can add your Cohere API key. +Click the `SET API KEY` button. This will open the application settings on the API Keys page where you can add your Cohere API key. For additional instructions on obtaining and setting your Cohere API key, see [Cohere API instructions](/talemate/user-guide/apis/cohere/). -![Cohere settings](/talemate/img/0.26.0/cohere-settings.png) +![Cohere settings](/talemate/img/0.39.0/api-keys-cohere.png) Click `Save` and after a moment the client should have a green dot next to it, indicating that it is ready to go. @@ -36,4 +36,8 @@ Which model to use. Currently defaults to `command-r-plus`. !!! note "Talemate lags behind Cohere" When Cohere adds a new model, it may take a Talemate update to add it to the list of available models. However, you can always manually enter any model name in the model field if you know the exact model identifier. + +##### Context Length + +Maximum context length (in tokens) for generations sent to Cohere. If you are not sure, leave the default value. --8<-- "docs/snippets/common.md:client-response-length" diff --git a/docs/user-guide/clients/types/deepseek.md b/docs/user-guide/clients/types/deepseek.md index f2f0ee54..9ebc967a 100644 --- a/docs/user-guide/clients/types/deepseek.md +++ b/docs/user-guide/clients/types/deepseek.md @@ -2,7 +2,7 @@ If you want to add a DeepSeek client, change the `Client Type` to `DeepSeek`. -![Client DeepSeek](/talemate/img/0.35.0/client-deepseek.png) +![Client DeepSeek](/talemate/img/0.39.0/client-deepseek.png) Click `Save` to add the client. @@ -12,7 +12,7 @@ The client should appear in the clients list. If you haven't set up DeepSeek bef ![Client deepseek no api key](/talemate/img/0.35.0/client-deepseek-no-api-key.png) -Click the `SET API KEY` button. This will open the API settings window where you can add your DeepSeek API key. +Click the `SET API KEY` button. This will open the application settings on the API Keys page where you can add your DeepSeek API key. You can obtain an API key from the [DeepSeek Platform](https://platform.deepseek.com/). @@ -40,6 +40,10 @@ Which model to use. Available models include: !!! note "Talemate lags behind DeepSeek" When DeepSeek adds a new model, it may take a Talemate update to add it to the list of available models. However, you can always manually enter any model name in the model field if you know the exact model identifier. +##### Context Length + +Maximum context length (in tokens) for generations sent to DeepSeek. If you are not sure, leave the default value. + ##### Using deepseek-reasoner The `deepseek-reasoner` model is a reasoning model that performs internal thinking before producing the final answer. diff --git a/docs/user-guide/clients/types/google.md b/docs/user-guide/clients/types/google.md index 46762025..b3cbd360 100644 --- a/docs/user-guide/clients/types/google.md +++ b/docs/user-guide/clients/types/google.md @@ -2,7 +2,7 @@ If you want to add an Google client, change the `Client Type` to `Google`. -![Client Google](/talemate/img/0.26.0/client-google.png) +![Client Google](/talemate/img/0.39.0/client-google.png) Click `Save` to add the client. @@ -12,11 +12,11 @@ The client should appear in the clients list. If you haven't setup Google before ![Client google no api key](/talemate/img/0.26.0/client-google-creds-missing.png) -Click the `SETUP GOOGLE API CREDENTIALS` button. This will open the api settings window where you can add your Google API credentials. +Click the `SETUP GOOGLE API CREDENTIALS` button. This will open the application settings on the API Keys page where you can add your Google API credentials. For additional instructions on obtaining and setting your Google API credentials, see [Google API instructions](/talemate/user-guide/apis/google/). -![Google settings](/talemate/img/0.26.0/google-settings.png) +![Google settings](/talemate/img/0.39.0/api-keys-google.png) Click `Save` and after a moment the client should have a green dot next to it, indicating that it is ready to go. @@ -37,6 +37,9 @@ Which model to use. Currently defaults to `gemini-1.0`. !!! note "Talemate lags behind Google" When Google adds a new model, it may take a Talemate update to add it to the list of available models. However, you can always manually enter any model name in the model field if you know the exact model identifier. +##### Context Length + +Maximum context length (in tokens) for generations sent to Google. If you are not sure, leave the default value. ##### Disable Safety Settings diff --git a/docs/user-guide/clients/types/groq.md b/docs/user-guide/clients/types/groq.md index 20baf90e..1df00d8c 100644 --- a/docs/user-guide/clients/types/groq.md +++ b/docs/user-guide/clients/types/groq.md @@ -2,7 +2,7 @@ If you want to add an Groq client, change the `Client Type` to `Groq`. -![Client Groq](/talemate/img/0.26.0/client-groq.png) +![Client Groq](/talemate/img/0.39.0/client-groq.png) Click `Save` to add the client. @@ -12,11 +12,11 @@ The client should appear in the clients list. If you haven't setup Groq before, ![Client groq no api key](/talemate/img/0.26.0/client-groq-no-api-key.png) -Click the `SET API KEY` button. This will open the api settings window where you can add your Groq API key. +Click the `SET API KEY` button. This will open the application settings on the API Keys page where you can add your Groq API key. For additional instructions on obtaining and setting your Groq API key, see [Groq API instructions](/talemate/user-guide/apis/groq/). -![Groq settings](/talemate/img/0.26.0/groq-settings.png) +![Groq settings](/talemate/img/0.39.0/api-keys-groq.png) Click `Save` and after a moment the client should have a green dot next to it, indicating that it is ready to go. @@ -36,4 +36,8 @@ Which model to use. Currently defaults to `llama3-70b-8192`. !!! note "Talemate lags behind Groq" When Groq adds a new model, it may take a Talemate update to add it to the list of available models. However, you can always manually enter any model name in the model field if you know the exact model identifier. + +##### Context Length + +Maximum context length (in tokens) for generations sent to Groq. If you are not sure, leave the default value. --8<-- "docs/snippets/common.md:client-response-length" diff --git a/docs/user-guide/clients/types/llamacpp.md b/docs/user-guide/clients/types/llamacpp.md index 5d6b76aa..96639815 100644 --- a/docs/user-guide/clients/types/llamacpp.md +++ b/docs/user-guide/clients/types/llamacpp.md @@ -37,6 +37,14 @@ The llama.cpp server (`llama-server`) defaults to port 8080, so unless you chang If your llama.cpp server is configured to require authentication, you can set the API key here. Most local setups do not require this. +##### API handles prompt template + +When enabled, Talemate uses the server's `/apply-template` endpoint to render prompts through the model's built-in chat template instead of applying a prompt template itself. Coercion (pre-filling the beginning of the response) keeps working, since the server leaves the assistant turn open for continuation. + +This is **off by default**. It is a trade-off: keep it disabled for full control of the prompt template in Talemate, or enable it to trust the model's built-in chat template on the server — useful when you don't know which prompt template to pick. + +Reasoning models work as usual — enable Reasoning on the client and the think block is separated out by the configured reasoning pattern. When Reasoning is disabled, Talemate asks thinking-capable templates (Qwen, GLM, ...) not to open a think block via `enable_thinking`. + ##### Context Length The number of tokens to use as context when generating text. Defaults to `8192`. diff --git a/docs/user-guide/clients/types/mistral.md b/docs/user-guide/clients/types/mistral.md index c8d3ce94..6bdd0f08 100644 --- a/docs/user-guide/clients/types/mistral.md +++ b/docs/user-guide/clients/types/mistral.md @@ -2,7 +2,7 @@ If you want to add an Mistral.ai client, change the `Client Type` to `Mistral.ai`. -![Client Mistral.ai](/talemate/img/0.26.0/client-mistral.png) +![Client Mistral.ai](/talemate/img/0.39.0/client-mistral.png) Click `Save` to add the client. @@ -12,11 +12,11 @@ The client should appear in the clients list. If you haven't setup Mistral.ai be ![Client mistral no api key](/talemate/img/0.26.0/client-mistral-no-api-key.png) -Click the `SET API KEY` button. This will open the api settings window where you can add your Mistral.ai API key. +Click the `SET API KEY` button. This will open the application settings on the API Keys page where you can add your Mistral.ai API key. For additional instructions on obtaining and setting your Mistral.ai API key, see [Mistral.ai API instructions](/talemate/user-guide/apis/mistral/). -![Mistral.ai settings](/talemate/img/0.26.0/mistral-settings.png) +![Mistral.ai settings](/talemate/img/0.39.0/api-keys-mistral.png) Click `Save` and after a moment the client should have a green dot next to it, indicating that it is ready to go. @@ -36,4 +36,14 @@ Which model to use. Currently defaults to `mixtral-8x22`. !!! note "Talemate lags behind Mistral.ai" When Mistral.ai adds a new model, it may take a Talemate update to add it to the list of available models. However, you can always manually enter any model name in the model field if you know the exact model identifier. + +##### Context Length + +Maximum context length (in tokens) for generations sent to Mistral.ai. If you are not sure, leave the default value. + +##### Concurrent Inference + +Found under the **Concurrency** tab in the client settings. When enabled, batch operations that need several queries (currently visual prompt generation for image generation) can send multiple requests to the Mistral.ai API in parallel instead of one at a time, which can speed those operations up. + +This is **off by default**. Whether concurrent requests actually complete in parallel depends on your Mistral.ai account's rate limits. See the [Concurrent Requests](/talemate/user-guide/clients/concurrent-requests/) page for more detail. --8<-- "docs/snippets/common.md:client-response-length" diff --git a/docs/user-guide/clients/types/openai.md b/docs/user-guide/clients/types/openai.md index 39c7acd9..dc1ef850 100644 --- a/docs/user-guide/clients/types/openai.md +++ b/docs/user-guide/clients/types/openai.md @@ -2,7 +2,7 @@ If you want to add an OpenAI client, change the `Client Type` to `OpenAI`. -![Client OpenAI](/talemate/img/0.26.0/client-openai.png) +![Client OpenAI](/talemate/img/0.39.0/client-openai.png) Click `Save` to add the client. @@ -12,11 +12,11 @@ The client should appear in the clients list. If you haven't setup OpenAI before ![Client openai no api key](/talemate/img/0.26.0/client-openai-no-api-key.png) -Click the `SET API KEY` button. This will open the api settings window where you can add your OpenAI API key. +Click the `SET API KEY` button. This will open the application settings on the API Keys page where you can add your OpenAI API key. For additional instructions on obtaining and setting your OpenAI API key, see [OpenAI API instructions](/talemate/user-guide/apis/openai/). -![OpenAI settings](/talemate/img/0.26.0/openai-settings.png) +![OpenAI settings](/talemate/img/0.39.0/api-keys-openai.png) Click `Save` and after a moment the client should have a green dot next to it, indicating that it is ready to go. @@ -37,6 +37,10 @@ Which model to use. Currently defaults to `gpt-4o`. !!! note "Talemate lags behind OpenAI" When OpenAI adds a new model, it may take a Talemate update to add it to the list of available models. However, you can always manually enter any model name in the model field if you know the exact model identifier. +##### Context Length + +Maximum context length (in tokens) for generations sent to OpenAI. If you are not sure, leave the default value. + ##### Reasoning models (o1, o3, gpt-5) !!! important "Enable reasoning and allocate tokens" diff --git a/docs/user-guide/clients/types/openrouter.md b/docs/user-guide/clients/types/openrouter.md index cdf815d8..dd395c5d 100644 --- a/docs/user-guide/clients/types/openrouter.md +++ b/docs/user-guide/clients/types/openrouter.md @@ -2,7 +2,7 @@ If you want to add an OpenRouter client, change the `Client Type` to `OpenRouter`. -![Client OpenRouter](/talemate/img/0.31.0/client-openrouter.png) +![Client OpenRouter](/talemate/img/0.39.0/client-openrouter.png) Click `Save` to add the client. @@ -12,11 +12,11 @@ The client should appear in the clients list. If you haven't set up OpenRouter b ![Client openrouter no api key](/talemate/img/0.31.0/client-openrouter-no-api-key.png) -Click the `SET API KEY` button. This will open the API settings window where you can add your OpenRouter API key. +Click the `SET API KEY` button. This will open the application settings on the API Keys page where you can add your OpenRouter API key. For additional instructions on obtaining and setting your OpenRouter API key, see [OpenRouter API instructions](/talemate/user-guide/apis/openrouter/). -![OpenRouter settings](/talemate/img/0.31.0/openrouter-settings.png) +![OpenRouter settings](/talemate/img/0.39.0/api-keys-openrouter.png) Click `Save` and after a moment the client should have a red dot next to it, saying that there is no model loaded. @@ -40,10 +40,26 @@ A unique name for the client that makes sense to you. Choose any model available via your OpenRouter account. Talemate dynamically fetches the list of models associated with your API key so new models will show up automatically. -##### Max token length +##### Context Length Maximum context length (in tokens) that OpenRouter should consider. If you are not sure leave the default value. !!! note "Available models are fetched automatically" Talemate fetches the list of available OpenRouter models when you save the configuration (if a valid API key is present). If you add or remove models to your account later, simply click **Save** in the application settings again to refresh the list. + +### Parameters + +The **Parameters** tab in the client settings lets you control which sampler parameters are sent with each request. Some model providers on OpenRouter reject requests that include parameters they don't support for the selected model, returning an error instead of a generation. If you run into this, you can turn the offending parameter off so it is left out of the request entirely. + +Each parameter has its own toggle: + +- **Send temperature** +- **Send top_p** +- **Send top_k** +- **Send min_p** +- **Send frequency_penalty** +- **Send presence_penalty** +- **Send repetition_penalty** + +All of them are enabled by default. Turning a toggle off omits that parameter from the request completely (it is not sent as a zero value). Leave them enabled unless the provider serving your model errors when it receives one of them — for example, some providers only accept a `frequency_penalty` or `presence_penalty` of exactly `0` for certain models. --8<-- "docs/snippets/common.md:client-response-length" diff --git a/docs/user-guide/clients/types/pi-bridge.md b/docs/user-guide/clients/types/pi-bridge.md new file mode 100644 index 00000000..af8faf31 --- /dev/null +++ b/docs/user-guide/clients/types/pi-bridge.md @@ -0,0 +1,95 @@ +# Pi Bridge Client + +The Pi Bridge client drives generations through the [pi coding agent](https://github.com/earendil-works/pi) instead of talking to an LLM API directly. pi handles provider authentication and model resolution, so any model pi can reach — hosted APIs, subscription auth, or custom providers you define in pi's `models.json` — becomes usable in Talemate. + +Talemate runs pi in its headless RPC mode as a pure bridge: pi's coding tools, extensions, skills and project context discovery are all disabled, and each generation runs in an isolated pi process. + +## Requirements + +The `pi` binary must be installed and on the `PATH` of the machine running the Talemate backend: + +```bash +npm install -g @earendil-works/pi-coding-agent +``` + +If the client shows a `pi binary not found` error, install pi and re-save the client. + +!!! note "Docker" + Talemate's Docker image ships with pi preinstalled — see [Docker](#docker) below. + +## Authentication + +pi resolves credentials on its own — Talemate does not manage API keys for this client. Depending on the provider, pi uses: + +- Environment variables (e.g. `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`) +- Keys and OAuth tokens stored in pi's `~/.pi/agent/auth.json` (set up via `/login` in interactive pi, including subscription auth such as Claude Pro/Max or ChatGPT) +- Custom providers defined in `~/.pi/agent/models.json` + +As a convenience, an OpenRouter API key configured in Talemate's application settings is passed to pi as `OPENROUTER_API_KEY` automatically (an entry of the same name in the environment variable store takes precedence). + +### Environment variable store + +Instead of keeping API keys for `models.json` providers in plaintext environment files, store them in Talemate: `Settings → Connections → Environment Variables`. Values are encrypted at rest in Talemate's configuration and passed as environment variables to every pi process the client spawns, so `models.json` can reference them: + +```json +{ + "providers": { + "kimi": { + "baseUrl": "https://api.moonshot.ai/v1", + "api": "openai-completions", + "apiKey": "$KIMI_API_KEY", + "models": [{ "id": "kimi-k2.5" }] + } + } +} +``` + +Here `$KIMI_API_KEY` resolves from a variable named `KIMI_API_KEY` in the store. pi hides providers whose variables do not resolve — after adding a variable, save the settings once and the provider appears in the client's catalog. + +## Settings + +##### Client Name + +A unique name for the client that makes sense to you. + +##### Provider + +The pi provider to route generations through (default `openrouter`). Suggestions are read from pi's model catalog, which includes custom providers from `models.json`. + +!!! note "Catalog refreshes on save" + The catalog is read when Talemate starts and again whenever settings are saved. If you add a provider or model to pi (e.g. a new `models.json` entry) while Talemate is running, save the client or application settings once and reopen the client settings to see it. pi only lists providers whose credentials resolve — a provider whose `apiKey` references an unset environment variable stays hidden. + +##### Model + +Free-form model id, with suggestions from pi's catalog for the selected provider. Model ids that are not in the catalog are passed through to the provider as-is, so newly released or custom models work without waiting for a catalog update. + +##### Context Length + +Maximum context length (in tokens) to send with a generation request. If you are not sure leave the default value. + +##### Thinking Level + +When reasoning is enabled for the client, this thinking level is passed to pi (`minimal` through `max`). Availability of the higher levels depends on the selected model. The model's thinking output is captured and shown in Talemate's reasoning display. + +!!! note "The thinking budget is enforced by the provider, not Talemate" + pi translates the level into the provider's thinking budget/effort parameter, but some providers treat it as advisory — a reasoning-heavy model may think well past a `minimal` budget on complex prompts. If a model spends too long thinking regardless of the level, disable Reasoning for the client instead, which requests no thinking at all. + +##### Concurrent Inference + +When enabled, batch operations may dispatch multiple generations in parallel — each request runs its own pi instance, so requests never share state. + +!!! note "Sampling parameters are owned by pi" + Unlike API clients, the Pi Bridge does not send sampler parameters (temperature, penalties, token caps) with requests — pi and the provider decide those. Talemate's inference presets do not apply to this client, and response length is controlled via instructions rather than a hard token cap. + +## Docker + +Talemate's Docker image ships with pi preinstalled, so the client works without any extra setup. pi's configuration directory is mounted from `./pi` next to the compose file: + +- `./pi/models.json` — custom providers and models, editable on the host; combine with the [environment variable store](#environment-variable-store) to keep provider keys encrypted instead of in an env file +- `./pi/auth.json` — pi's stored credentials + +The directory persists across container recreations. Inside the container it is exposed via the `PI_CODING_AGENT_DIR` environment variable (`/app/pi`); when building the image manually, the pi version can be overridden with the `PI_VERSION` build argument. + +## Troubleshooting + +If generations misbehave at the stream level (tokens arrive but the response never finishes, or nothing arrives at all), set `TALEMATE_PI_BRIDGE_TRACE=1` before starting the backend to log the pi event stream as it is consumed — see [Environment variables](../../../getting-started/advanced/environment-variables.md#logging-debugging). diff --git a/docs/user-guide/clients/types/text-generation-webui.md b/docs/user-guide/clients/types/text-generation-webui.md index 9f424934..b933bbd4 100644 --- a/docs/user-guide/clients/types/text-generation-webui.md +++ b/docs/user-guide/clients/types/text-generation-webui.md @@ -33,6 +33,14 @@ The URL of your Text-Generation-WebUI instance, without any path. For example, ` If the Text-Generation-WebUI instance requires an API key, you can set it here. +##### API handles prompt template (chat/completions) + +This will cause requests to go to the `chat/completions` API instead and Text-Generation-WebUI will be in control of the prompt template. Coercion (pre-filling the beginning of the response) keeps working through the API's `continue_` mechanism. + +This is **off by default**. It is a trade-off: keep it disabled for full control of the prompt template in Talemate, or enable it to trust that the template configured on the remote end is correct — useful when you don't know which prompt template to pick. While it is enabled, Talemate's own prompt template selection for the client has no effect. + +With reasoning models, Text-Generation-WebUI separates the thinking from the response on its end, so no reasoning pattern is needed in this mode — the reasoning is captured automatically. Note that the model spends tokens on thinking either way, so make sure response budgets account for it. + ##### Context Length The number of tokens to use as context when generating text. Defaults to `8192`. diff --git a/docs/user-guide/howto/create-a-new-scene/create-empty-scene.md b/docs/user-guide/howto/create-a-new-scene/create-empty-scene.md index ce438ff0..4d60bfbc 100644 --- a/docs/user-guide/howto/create-a-new-scene/create-empty-scene.md +++ b/docs/user-guide/howto/create-a-new-scene/create-empty-scene.md @@ -1,9 +1,11 @@ # Create an empty scene -To start a new, empty scene, go to the :material-home: **Home** screen and click on the :material-plus: **Create** button in the left sidebar. +To start a new, empty scene, go to the :material-home: **Home** screen and click the **Create new scene** card — it sits in the right-hand column of the [scene browser](/talemate/user-guide/scene-browser/), below the Import dropzone. ![Create empty scene](/talemate/img/0.26.0/create-new-scene.png) +A **New scene setup** dialog opens where you give the scene a name and can optionally pick a writing style, a director persona, and whether the Director should assist with story setup. For this walkthrough just enter a name and click :material-check-circle-outline: **Continue**. + After a moment of loading you will be taken to the new scene and `creative` mode will be enabled. In `creative` mode normal scene progression is suspended and input in the scene view will be for command execution only. (covered elsewhere, ignore for now) ## Create the outline for the scene diff --git a/docs/user-guide/howto/create-a-new-scene/create-npc.md b/docs/user-guide/howto/create-a-new-scene/create-npc.md index 92e6c0a2..ce9c2b31 100644 --- a/docs/user-guide/howto/create-a-new-scene/create-npc.md +++ b/docs/user-guide/howto/create-a-new-scene/create-npc.md @@ -19,7 +19,7 @@ Then in the `AI Generation Instructions` field type in something like !!! tip Make sure you include intructions for everything that is important for the character, LLMs are not great at generating something interesting by themselves, they will often go down tropes and cliches. So be specific and detailed, but not long winded. -Then leave everything else as is and click the **Create Character** button. +Then leave everything else as is and click the **Create Character** button. (Since 0.39.0 the form also offers a **Generate example dialogue** option with its own guidance field — it is off by default and can stay off for this walkthrough; see [Creating a character](/talemate/user-guide/world-editor/characters/create/) for details.) ![world-editor-create-npc-1](/talemate/img/0.26.0/world-editor-create-npc-1.png) diff --git a/docs/user-guide/howto/create-a-new-scene/create-player-character.md b/docs/user-guide/howto/create-a-new-scene/create-player-character.md index de57756f..e783cfaa 100644 --- a/docs/user-guide/howto/create-a-new-scene/create-player-character.md +++ b/docs/user-guide/howto/create-a-new-scene/create-player-character.md @@ -12,7 +12,7 @@ Find the :material-account-plus: **Create Character** button and click on it. A `New character` entry will appear in the list. -![world-editor-create-player-character-1](/talemate/img/0.26.0/world-editor-create-player-character-1.png) +![world-editor-create-player-character-1](/talemate/img/0.39.0/world-editor-create-player-character-1.png) ## Generate the player character @@ -28,7 +28,7 @@ You can leave the `Description` field empty, as the AI will generate a descripti Toggle on the **Controlled by Player** switch. -![world-editor-create-player-character-2](/talemate/img/0.26.0/world-editor-create-player-character-2.png) +![world-editor-create-player-character-2](/talemate/img/0.39.0/world-editor-create-player-character-2.png) Finally click the **Create Character** button. diff --git a/docs/user-guide/inline-visuals.md b/docs/user-guide/inline-visuals.md index b85b5f9f..39b926a7 100644 --- a/docs/user-guide/inline-visuals.md +++ b/docs/user-guide/inline-visuals.md @@ -34,7 +34,7 @@ From this menu you can: - **Visualize Moment (Illustration)**: Generate an image of the current moment in the story !!! note "Per-character submenus (0.37.0)" - As of version 0.37.0, each character in the scene is grouped into its own submenu instead of appearing as separate top-level entries. Hover a character's name to open their submenu and pick **Card** or **Portrait**. See [Scene Tools — Visualizer](scenario-tools.md#material-image-frame-visualizer) for the full menu reference. + As of version 0.37.0, each character in the scene is grouped into its own submenu instead of appearing as separate top-level entries. Hover a character's name to open their submenu and pick **Card** or **Portrait**. See [Scene Tools — Visualizer](scenario-tools.md#visualizer) for the full menu reference. !!! tip "Keyboard Modifiers" - Hold **ALT** to generate only the prompt without creating an image @@ -100,6 +100,7 @@ Click on any inline visual to open a context menu with options: - **Regenerate and Delete**: Replace the current image with a new one (Alt+click shortcut) - **Edit Illustration**: Regenerate with custom editing instructions - **Select illustration**: Choose from existing scene illustrations +- **Set as scene backdrop**: Render this image behind the scene text — see [Scene Backdrop](#scene-backdrop) - **Clear Image**: Remove the image from this message (keeps the image in your library) - **Delete Image**: Permanently remove the image @@ -121,11 +122,36 @@ For character portraits (avatars), you have additional options: - **Generate new portrait**: Create a new portrait based on the current scene context - **Select portrait**: Choose from existing portraits for this character +## Scene Backdrop + +Any scene illustration can be promoted to the **scene backdrop** — instead of rendering inline, the image fills the whole scene view behind the messages. Message text sits on translucent panels with a drop shadow to stay legible; both the panel opacity and the text shadow are [configurable](app-settings/appearance.md#backdrop-legibility). + +![Scene backdrop active](/talemate/img/0.39.0/scene-backdrop-active.png) + +The backdrop belongs to the scene and is saved with it, so it survives reloads and history edits. + +### Setting a backdrop + +There are several ways to set the backdrop: + +- **From a message** — click an inline scene illustration and choose **Set as scene backdrop** from its image menu +- **From the Visual Library** — select an asset in the Scene Assets tab and click **Set backdrop** +- **From the world editor** — use the per-image actions in the [Scene Visual Manager](world-editor/scene/visuals.md) (World Editor → Scene → Visuals), which is also the place to fully **unset** the backdrop (the image stays in the scene assets) +- **Automatically** — enable **Auto Backdrop** for Scene Illustrations and/or Scene Backgrounds in the [Appearance Settings](app-settings/appearance.md#visuals); newly generated images of that type then become the backdrop as they arrive + +### The Immersive toggle + +Once a backdrop is set (or a generated background is available to become one), an **:material-image-area: Immersive** chip appears in the scene tools [quick settings](scenario-tools.md#quick-settings). It toggles the backdrop rendering on and off without forgetting the chosen image. + +### Finding the backdrop's message + +A small :material-image-area: marker icon appears next to the message whose illustration is the current backdrop. Click it to open that image's menu. Because the illustration no longer renders inline while it is the backdrop, the image menu also stays reachable via an **Illustration** chip on the message's hover toolbar. + ## Configuring Display Settings You can customize how inline visuals appear through the [Appearance Settings](app-settings/appearance.md#visuals). -![Appearance settings - Visuals tab](/talemate/img/0.35.0/app-settings-appearance-visuals.png) +![Appearance settings - Visuals tab](/talemate/img/0.39.0/app-settings-appearance-visuals.png) ### Visual Types @@ -133,7 +159,8 @@ Configure settings independently for each type of visual: - **Portrait**: Character face images - **Card**: Character or scene cards -- **Scene Illustration**: Full scene images +- **Scene Illustration**: Images of the current moment ("Visualize Moment") +- **Scene Background**: Purely environmental images ("Visualize Scene (Background)") ### Render Cadence @@ -158,6 +185,10 @@ Control how large visuals appear: | **Medium** | Balanced size (default) | | **Big** | Large display; scene illustrations appear above messages | +### Auto Backdrop + +Scene Illustrations and Scene Backgrounds additionally have an **Auto Backdrop** checkbox. When enabled, newly generated images of that type automatically become the [scene backdrop](#scene-backdrop) instead of only rendering inline. + ### Auto-attach Visuals The **Auto-attach visuals** toggle controls whether newly generated images are automatically attached to messages. When disabled, images go to the Visual Library but don't appear in the scene feed unless you manually attach them. diff --git a/docs/user-guide/integrations/runpod.md b/docs/user-guide/integrations/runpod.md index f6275a41..7f97fd1d 100644 --- a/docs/user-guide/integrations/runpod.md +++ b/docs/user-guide/integrations/runpod.md @@ -1,6 +1,8 @@ !!! note These instructions have not been updated in a while and RunPod has changed somewhat. I will update this as soon as I can. The general idea should still be the same. + The RunPod settings category described below no longer exists in the current settings interface. + RunPod allows you to quickly set up and run text-generation-webui instances on powerful GPUs, remotely. If you want to run the significantly larger models (like 70B parameters) with reasonable speeds, this is probably the best way to do it. ### Get a RunPod API key and add it to the talemate config @@ -9,11 +11,7 @@ You can manage your RunPod api keys at [https://www.runpod.io/console/user/setti Once you have your key you can open the settings in talemate. -![Open settings](/talemate/img/0.26.0/open-settings.png) - -Then click the `APPLICATION` tab and then the `RUNPOD` category. Here you can add your RunPod API key. - -![Runpod settings](/talemate/img/0.26.0/runpod-api-key-setting.png) +Older versions provided a `RUNPOD` category in the application settings to add your RunPod API key. Setting the runpod api key requires a restart of the backend, so make sure to save your changes and restart the backend. diff --git a/docs/user-guide/node-editor/core-concepts/prompt_building.md b/docs/user-guide/node-editor/core-concepts/prompt_building.md index 9d2673c8..0c45be03 100644 --- a/docs/user-guide/node-editor/core-concepts/prompt_building.md +++ b/docs/user-guide/node-editor/core-concepts/prompt_building.md @@ -86,7 +86,7 @@ A fully assembled prompt with: - **`response_length`** (default: `0`): Expected length of the response - **`technical`** (default: `false`): Include technical context (IDs, typing information) - **`dedupe_enabled`** (default: `true`): Forces line-level deduplication on or off for the rendered prompt, overriding the client-level **Deduplicate Prompts** toggle. See [Prompt Deduplication](../../prompts/deduplication.md) — most users should set this to `false`. -- **`memory_prompt`** (default: `""`): Semantic query string for memory retrieval. Provide this to guide what memories are retrieved when `include_memory_context` is enabled +- **`memory_prompt`** (default: `""`): Semantic query string for memory retrieval. Provide this to guide what memories are retrieved when `include_memory_context` is enabled. A non-empty value wired into the `memory_prompt` input socket takes precedence over this property — the property applies when the socket is unconnected or resolves empty. (The same rule applies to the Generate Response node's `action_type` input and property.) - **`prefill_prompt`** (default: `""`): Text to prefill the response - **`return_prefill_prompt`** (default: `false`): Return the prefill with response diff --git a/docs/user-guide/node-editor/index.md b/docs/user-guide/node-editor/index.md index a5c8fe01..8f5dffc6 100644 --- a/docs/user-guide/node-editor/index.md +++ b/docs/user-guide/node-editor/index.md @@ -4,4 +4,10 @@ The node editor is introduced with version `0.30.0` and is a major new feature i It allows the code-less creation of more complex scenes and re-usable modules. +## Reference + +- [Node reference](reference/nodes/index.md) - every available node, organized by category, with registry paths, sockets and properties. +- [Event reference](reference/events.md) - the events node graphs can listen for. +- [Template functions](reference/template_functions.md) - functions available in prompt templates. + --8<-- "docs/snippets/common.md:documentation-is-a-work-in-progress" \ No newline at end of file diff --git a/docs/user-guide/node-editor/reference/events.md b/docs/user-guide/node-editor/reference/events.md index 3b87d792..842789eb 100644 --- a/docs/user-guide/node-editor/reference/events.md +++ b/docs/user-guide/node-editor/reference/events.md @@ -36,10 +36,14 @@ List of currently supported events. | [`agent.creator.contextual_generate.after`](#agentcreatorcontextual_generateafter) | Creator Agent | | [`agent.creator.autocomplete.before`](#agentcreatorautocompletebefore) | Creator Agent | | [`agent.creator.autocomplete.after`](#agentcreatorautocompleteafter) | Creator Agent | +| [`agent.creator.dialogue_examples.before`](#agentcreatordialogue_examplesbefore) | Creator Agent | +| [`agent.creator.dialogue_examples.after`](#agentcreatordialogue_examplesafter) | Creator Agent | | [`agent.editor.revision-analysis.before`](#agenteditorrevision-analysisbefore) | Editor Agent | | [`agent.editor.revision-analysis.after`](#agenteditorrevision-analysisafter) | Editor Agent | | [`agent.editor.revision-revise.before`](#agenteditorrevision-revisebefore) | Editor Agent | | [`agent.editor.revision-revise.after`](#agenteditorrevision-reviseafter) | Editor Agent | +| [`agent.help.chat.before`](#agenthelpchatbefore) | Help Agent | +| [`agent.help.chat.after`](#agenthelpchatafter) | Help Agent | | [`agent.narrator.before_generate`](#agentnarratorbefore_generate) | Narrator Agent | | [`agent.narrator.inject_instructions`](#agentnarratorinject_instructions) | Narrator Agent | | [`agent.narrator.generated`](#agentnarratorgenerated) | Narrator Agent | @@ -69,6 +73,13 @@ List of currently supported events. | [`agent.tts.generate.after`](#agentttsgenerateafter) | TTS Agent | | [`agent.visual.generation.before_generate`](#agentvisualgenerationbefore_generate) | Visual Agent | | [`agent.visual.generation.after_generate`](#agentvisualgenerationafter_generate) | Visual Agent | +| [`agent.visual.prompt_finalize.before`](#agentvisualprompt_finalizebefore) | Visual Agent | +| [`agent.visual.prompt_finalize.after`](#agentvisualprompt_finalizeafter) | Visual Agent | +| [`asset_saved`](#asset_saved) | Scene Assets | +| [`asset_deleted`](#asset_deleted) | Scene Assets | +| [`scene.backdrop_changed`](#scenebackdrop_changed) | Scene Assets | +| [`scene.cover_image_changed`](#scenecover_image_changed) | Scene Assets | +| [`character.cover_image_changed`](#charactercover_image_changed) | Scene Assets | ## Game Loop @@ -395,6 +406,42 @@ Handlers can edit `response` in-place to clean up or transform the text (the Edi | `template_vars` | `dict` | Variables that were fed into the prompt – **mutable** | | `dynamic_instructions` | `list[DynamicInstruction]` | **Mutable.** Push additional `DynamicInstruction` objects to influence generation | +### agent.creator.dialogue_examples.before + +Emitted **before** the Creator agent generates example dialogue for a character (AI-assisted character creation, character card import). + +Not emitted when **Fast Character Generation** is on and the consolidated +one-shot produces the examples itself — the events fire on the split flow +and on fill-in-misses regeneration only. + +!!! payload "Payload" + + | Field | Type | Notes | + |-------|------|-------| + | `agent` | `CreatorAgent` | The agent instance | + | `character` | `Character` | The character the dialogue examples are for | + | `text` | `str` | Source text containing dialogue examples / character information | + | `instructions` | `str` | User-provided guidance for the dialogue examples | + | `dynamic_instructions` | `list[DynamicInstruction]` | **Mutable.** Push additional `DynamicInstruction` objects to influence generation | + +### agent.creator.dialogue_examples.after + +Emitted **after** the dialogue examples have been generated but **before** they are returned to the caller (and applied to the character). +Handlers can mutate `dialogue_examples` to add, remove or rewrite examples. + +Like the `before` event, not emitted when the consolidated one-shot +produces the examples (see above). + +!!! payload "Payload" + + | Field | Type | Notes | + |-------|------|-------| + | `agent` | `CreatorAgent` | The agent instance | + | `character` | `Character` | The character the dialogue examples are for | + | `text` | `str` | Source text containing dialogue examples / character information | + | `instructions` | `str` | User-provided guidance for the dialogue examples | + | `dialogue_examples` | `list[str]` | **Mutable.** The generated examples, formatted as `Character Name: ...` | + ## Editor Agent Events ### agent.editor.revision-revise.before @@ -471,6 +518,37 @@ notification hook for observers of the rewrite flow. | `agent` | `EditorAgent` | The agent instance | | `template_vars` | `dict` | Same vars used for the prompt | +## Help Agent Events + +!!! note + The help chat runs in the background and also works without a loaded scene. When no scene loop is running there are no connected listeners, so scene node graphs will only observe help chats that happen while their scene is loaded. + +### agent.help.chat.before + +Emitted when the Help agent starts generating a response to a help chat, before any documentation lookups run. + +!!! payload "Payload" + + | Field | Type | Notes | + |-------|------|-------| + | `agent` | `HelpAgent` | The agent instance | + | `chat_id` | `str` | The help chat's id | + | `chat` | `HelpChat` | The chat, including all messages so far | + +--- + +### agent.help.chat.after + +Emitted after the Help agent has finished generating a response (all documentation lookup rounds completed and the answer appended to the chat). + +!!! payload "Payload" + + | Field | Type | Notes | + |-------|------|-------| + | `agent` | `HelpAgent` | The agent instance | + | `chat_id` | `str` | The help chat's id | + | `chat` | `HelpChat` | The chat, now including the generated answer | + ## Narrator Agent Events ### agent.narrator.before_generate @@ -886,4 +964,105 @@ Fires after a generation request has completed and the resulting image has been |-------|------|-------| | `agent` | `VisualAgent` | The agent instance | | `request` | `GenerationRequest` | The original request | - | `response` | `GenerationResponse` | The completed response, including base64 image data | \ No newline at end of file + | `response` | `GenerationResponse` | The completed response, including base64 image data | + +--- + +### agent.visual.prompt_finalize.before + +Emitted when prompt finalization runs — right before an image generation prompt is sent to the backend, and when the `agents/visual/FinalizePrompt` node is executed. Fires **before** the configured finalizer actions are applied, and fires even when the Prompt Finalization agent setting is disabled, so listeners can act as their own finalization mechanism. + +Handlers can mutate the prompt strings and the finalizer list. + +!!! payload "Payload" + + | Field | Type | Notes | + |-------|------|-------| + | `agent` | `VisualAgent` | The agent instance | + | `positive_prompt` | `str \| None` | **Mutable.** The positive prompt before finalization | + | `negative_prompt` | `str \| None` | **Mutable.** The negative prompt before finalization | + | `vis_type` | `VIS_TYPE` | The visual type being generated (e.g. `CHARACTER_PORTRAIT`) | + | `character_name` | `str \| None` | The targeted character, if any | + | `finalizers` | `list[PromptFinalizer]` | **Mutable.** The finalizer actions about to be applied (agent's first, then character's); empty when finalization is disabled | + +--- + +### agent.visual.prompt_finalize.after + +Fires after all finalizer actions have been applied, right before the finalized prompts are returned (and sent to the image generation backend). The same emission instance from `.before` is reused. Handlers can mutate the prompt strings for a final rewrite. + +!!! payload "Payload" + + | Field | Type | Notes | + |-------|------|-------| + | `agent` | `VisualAgent` | The agent instance | + | `positive_prompt` | `str \| None` | **Mutable.** The finalized positive prompt | + | `negative_prompt` | `str \| None` | **Mutable.** The finalized negative prompt | + | `vis_type` | `VIS_TYPE` | The visual type being generated | + | `character_name` | `str \| None` | The targeted character, if any | + | `finalizers` | `list[PromptFinalizer]` | The finalizer actions that were applied | + +## Scene Asset Events + +### asset_saved + +Emitted when an asset is added to the scene's asset library (uploaded, imported, or auto-saved after image generation). + +!!! payload "Payload" + + | Field | Type | Notes | + |-------|------|-------| + | `asset` | `Asset` | The saved asset | + | `new_asset` | `bool` | `True` when the asset was newly created, `False` when it already existed | + | `asset_attachment_context` | `AssetAttachmentContext` | How the asset wants to be attached (message attachment, cover image, avatar, ...) | + +--- + +### asset_deleted + +Emitted when an asset is removed from the scene's asset library. + +!!! payload "Payload" + + | Field | Type | Notes | + |-------|------|-------| + | `asset` | `Asset` | The deleted asset | + +--- + +### scene.backdrop_changed + +Emitted when the scene backdrop state actually changes — a backdrop asset is set, the backdrop is toggled on or off, or the backdrop is cleared (including when the current backdrop asset is deleted). Updates that leave the state unchanged do not fire. + +!!! payload "Payload" + + | Field | Type | Notes | + |-------|------|-------| + | `backdrop` | `str \| None` | The backdrop asset id after the update (`None` when cleared) | + | `enabled` | `bool` | Whether the backdrop currently renders | + +--- + +### scene.cover_image_changed + +Emitted when the scene cover image is set. + +!!! payload "Payload" + + | Field | Type | Notes | + |-------|------|-------| + | `asset` | `Asset` | The new cover image asset | + | `character_name` | `None` | Always `None` for the scene cover | + +--- + +### character.cover_image_changed + +Emitted when a character's cover image is set. + +!!! payload "Payload" + + | Field | Type | Notes | + |-------|------|-------| + | `asset` | `Asset` | The new cover image asset | + | `character_name` | `str` | The character the cover image belongs to | \ No newline at end of file diff --git a/docs/user-guide/node-editor/reference/nodes/.pages b/docs/user-guide/node-editor/reference/nodes/.pages new file mode 100644 index 00000000..47c54746 --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/.pages @@ -0,0 +1,5 @@ +title: Node Reference +order_by: title +nav: + - index.md + - ... diff --git a/docs/user-guide/node-editor/reference/nodes/agents-conversation.md b/docs/user-guide/node-editor/reference/nodes/agents-conversation.md new file mode 100644 index 00000000..302ed197 --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/agents-conversation.md @@ -0,0 +1,55 @@ +# Conversation Agent Nodes + +Generate character dialogue through the conversation agent. + + + + +_2 nodes._ + +| Node | Registry path | +| --- | --- | +| [Generate Conversation](#generate-conversation) | `agents/conversation/Generate` | +| [Conversation Settings](#conversation-settings) | `agents/conversation/Settings` | + +## Generate Conversation + +`agents/conversation/Generate` + +Generates dialogue for a character via the conversation agent. + +Calls the conversation agent's converse action for the given character +and returns the first generated message. The message is not added to +the scene history by this node. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `character` | `character` | The character to generate dialogue for | +| `instruction` | `str` | (optional) Optional instruction to guide the generation | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `generated` | `str` | The generated dialogue text | +| `message` | `message_object` | The generated CharacterMessage object | +| `character` | `character` | The character input, passed through | +| `instruction` | `str` | The instruction input, passed through | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `trigger_conversation_generated` | `bool` | `True` | trigger_conversation_generated | + +## Conversation Settings + +`agents/conversation/Settings` + +Base node to render conversation agent settings. + +Every setting of the conversation agent is exposed as an output socket named after the setting - see the agent's documentation under [Agents](../../../agents/index.md) for what each setting does. diff --git a/docs/user-guide/node-editor/reference/nodes/agents-creator.md b/docs/user-guide/node-editor/reference/nodes/agents-creator.md new file mode 100644 index 00000000..6355bedf --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/agents-creator.md @@ -0,0 +1,315 @@ +# Creator Agent Nodes + +Content creation through the creator agent: contextual generation, character creation and per-character derivations (name, description, dialogue instructions) plus thematic lists. + +!!! note "Create Character module changes in 0.39.0" + The [Create Character](#create-character) module now builds its character through the [Generate Character](#generate-character) node, so it follows the Creator agent's [Character Creation settings](/talemate/user-guide/agents/creator/settings/) (including Fast mode). It also gained two inputs: + + - `generate_dialogue_instructions` defaults **on** — existing graphs calling the module now generate dialogue instructions for the new character, which is an extra prompt whenever Fast mode is off (or not consolidating that aspect). + - `generate_example_dialogue` defaults **off**. + + + + +_9 nodes._ + +| Node | Registry path | +| --- | --- | +| [Contextual Generate](#contextual-generate) | `agents/creator/ContextualGenerate` | +| [Create Character](#create-character) | `agents/creator/CreateCharacter` | +| [Determine Character Description](#determine-character-description) | `agents/creator/DetermineCharacterDescription` | +| [Determine Character Dialogue Instructions](#determine-character-dialogue-instructions) | `agents/creator/DetermineCharacterDialogueInstructions` | +| [Determine Character Name](#determine-character-name) | `agents/creator/DetermineCharacterName` | +| [Determine Content Context](#determine-content-context) | `agents/creator/DetermineContentContext` | +| [Generate Character](#generate-character) | `agents/creator/GenerateCharacter` | +| [Generate Thematic List](#generate-thematic-list) | `agents/creator/GenerateThematicList` | +| [Creator Settings](#creator-settings) | `agents/creator/Settings` | + +## Contextual Generate + +`agents/creator/ContextualGenerate` + +Generates text based on the given context. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The current state of the graph | +| `context_type` | `str` | (optional) The type of context to use in generating the text | +| `context_name` | `str` | (optional) The name of the context to use in generating the text | +| `instructions` | `str` | (optional) The instructions to use in generating the text | +| `length` | `int` | (optional) The length of the text to generate | +| `character` | `character,str` | (optional) The character to generate the text for | +| `original` | `str` | (optional) The original text to use in generating the text | +| `partial` | `str` | (optional) The partial text to use in generating the text | +| `uid` | `str` | (optional) The uid to use in generating the text | +| `generation_options` | `generation_options` | (optional) The generation options to use in generating the text | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The updated state of the graph | +| `text` | `str` | The generated text | +| `character` | `character` | The character object resolved from the character input | +| `context_type` | `str` | The context type, passed through | +| `context_name` | `str` | The context name, passed through | +| `instructions` | `str` | The instructions, passed through | +| `original` | `str` | The original text, passed through | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `context_type` | `str` | `"general"` | The type of context to use in generating the text. Choices: `character attribute`, `character detail`, `character dialogue`, `scene intro`, `scene intent`, `scene phase intent`, `scene type description`, `scene type instructions`, `general`, `list`, `scene`, `static history`, …. | +| `context_name` | `str` | `unset` | The name of the context to use in generating the text | +| `instructions` | `str` | `unset` | The instructions to use in generating the text | +| `length` | `int` | `100` | The length of the text to generate | +| `character` | `str` | `unset` | The character to generate the text for | +| `uid` | `str` | `unset` | The uid to use in generating the text | +| `context_aware` | `bool` | `True` | Whether to use the context in generating the text | +| `history_aware` | `bool` | `True` | Whether to use the history in generating the text | + +## Create Character + +`agents/creator/CreateCharacter` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/creator/modules/create-character.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `instructions` | `str` | | +| `character_name` | `str` | (optional) | +| `description` | `str` | (optional) | +| `generate` | `bool` | (optional) | +| `generate_attributes` | `bool` | (optional) | +| `generate_dialogue_instructions` | `bool` | (optional) | +| `generate_example_dialogue` | `bool` | (optional) | +| `is_active` | `bool` | (optional) | +| `generation_options` | `generation_options` | (optional) | +| `is_player` | `bool` | (optional) | +| `assign_voice` | `bool` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `` | | +| `character` | `character` | | +| `actor` | `actor` | | + +## Determine Character Description + +`agents/creator/DetermineCharacterDescription` + +Determines the description for a character. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The current state of the graph | +| `character` | `character` | The character to determine the description for | +| `extra_context` | `str` | (optional) Extra context to use in determining the description | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `description` | `str` | The determined description (empty if the model produced nothing) | + +## Determine Character Dialogue Instructions + +`agents/creator/DetermineCharacterDialogueInstructions` + +Determines dialogue (acting) instructions for a character, via the +creator agent. The result is returned but not stored on the character +by this node. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The current state of the graph | +| `character` | `character` | The character to determine dialogue instructions for | +| `instructions` | `str` | Additional instructions to guide the generation (optional) | +| `update_existing` | `bool` | Whether to base the result on the character's existing dialogue instructions (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `character` | `character` | The character, passed through | +| `dialogue_instructions` | `str` | The determined dialogue instructions | +| `original` | `str` | The character's dialogue instructions before generation | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `instructions` | `text` | `""` | Any additional instructions to use in determining the dialogue instructions | +| `update_existing` | `bool` | `False` | Whether to update the existing dialogue instructions | + +## Determine Character Name + +`agents/creator/DetermineCharacterName` + +Determines (or clarifies) a character name from an existing or descriptive name. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The current state of the graph | +| `character_name` | `str` | (optional) The current or descriptive character name | +| `allowed_names` | `list` | (optional) Optional list of allowed names to select from | +| `is_group` | `bool` | (optional) Whether the name describes a group of characters | +| `instructions` | `str` | (optional) Additional instructions to guide name generation | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The current state of the graph (pass-through) | +| `character_name` | `str` | The determined name | +| `original` | `str` | The original character_name input | +| `allowed_names` | `list` | The allowed_names input (pass-through) | +| `is_group` | `bool` | The is_group input (pass-through) | +| `instructions` | `str` | The instructions input (pass-through) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `character_name` | `str` | `""` | The current or descriptive character name | +| `allowed_names` | `list` | `[]` | Optional list of allowed names to select from | +| `is_group` | `bool` | `False` | Whether the name describes a group of characters | +| `instructions` | `text` | `""` | Additional instructions to guide name generation | + +## Determine Content Context + +`agents/creator/DetermineContentContext` + +Determines a fitting content context label (genre / style descriptor) +for the given description, via the creator agent. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The current state of the graph | +| `description` | `str` | (optional) The description to determine the content context for (required at runtime despite the optional socket) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `content_context` | `str` | The determined content context | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `description` | `str` | `unset` | Description of the context | + +## Generate Character + +`agents/creator/GenerateCharacter` + +Generates character data through the creator agent's character +generation pipeline, following the agent's "Character Creation" +settings. + +With "Fast Character Generation" enabled, the aspects listed in the +agent's "Consolidate" setting are generated with a single +consolidated prompt (response budget from the "One-shot token budget" +setting; aspects the response misses are handled according to the +"Fill in misses" setting - individual follow-up requests or left +empty; a completely unparseable response raises an error). Remaining +aspects - or all of them, with Fast mode disabled - are generated +individually. + +This node only generates data - it does not add the character to the +scene. Wire the outputs into an `agents/director/PersistCharacter` node +to persist the character (disable `determine_name` on that node when +wiring a generated name, and wire `description` through as well, so +nothing is generated twice). + +Generating example dialogue requires a name: provide `character_name` +or enable `generate_name` - the node errors otherwise. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The current state of the graph | +| `character_name` | `str` | The current or descriptive character name (optional) | +| `instructions` | `str` | Guiding instructions / content for the creation (optional) | +| `description` | `str` | An existing description to use as context (optional) | +| `generation_options` | `generation_options` | Spice / writing style to shape the description (optional) | +| `generate_name` | `bool` | Whether to generate a name (optional) | +| `generate_description` | `bool` | Whether to generate a description (optional) | +| `generate_attributes` | `bool` | Whether to generate attributes (optional) | +| `generate_dialogue_instructions` | `bool` | Whether to generate dialogue instructions (optional) | +| `generate_example_dialogue` | `bool` | Whether to generate example dialogue lines (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `character_name` | `str` | The determined (or input) character name | +| `description` | `str` | The generated (or input) description | +| `attributes` | `dict` | The generated attributes (empty if not generated) | +| `dialogue_instructions` | `str` | The generated dialogue instructions (empty if not generated) | +| `example_dialogue` | `list` | The generated example dialogue lines (empty if not generated) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `generate_name` | `bool` | `False` | Determine a fitting character name | +| `generate_description` | `bool` | `True` | Generate the character description | +| `generate_attributes` | `bool` | `True` | Generate the character sheet (attributes) | +| `generate_dialogue_instructions` | `bool` | `True` | Generate dialogue (acting) instructions | +| `generate_example_dialogue` | `bool` | `False` | Generate example dialogue lines | + +## Generate Thematic List + +`agents/creator/GenerateThematicList` + +Generates a list of thematic items based on the instructions. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `instructions` | `str` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `list` | `list` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `instructions` | `str` | `unset` | The instructions to use in generating the list | +| `iterations` | `int` | `1` | The number of iterations to use in generating the list - 1 iteration will generate 20 items. Range: 1 – 10. | + +## Creator Settings + +`agents/creator/Settings` + +Base node to render creator agent settings. + +Every setting of the creator agent is exposed as an output socket named after the setting - see the agent's documentation under [Agents](../../../agents/index.md) for what each setting does. diff --git a/docs/user-guide/node-editor/reference/nodes/agents-director-auto.md b/docs/user-guide/node-editor/reference/nodes/agents-director-auto.md new file mode 100644 index 00000000..a19bf9da --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/agents-director-auto.md @@ -0,0 +1,151 @@ +# Director Auto-Direction Nodes + +The director's automatic direction loop: pick candidates, determine scene intent, decide when an instruction is due and generate the auto-direction itself. + + + + +_6 nodes._ + +| Node | Registry path | +| --- | --- | +| [Auto Direction](#auto-direction) | `agents/director/auto-direct/AutoDirection` | +| [Auto Direct Candidates [DEPRECATED]](#auto-direct-candidates-deprecated) | `agents/director/auto-direct/Candidates` | +| [Determine Scene Intent](#determine-scene-intent) | `agents/director/auto-direct/DetermineSceneIntent` | +| [Generate Auto Direction](#generate-auto-direction) | `agents/director/auto-direct/generateAutoDirection` | +| [Generate Scene Types](#generate-scene-types) | `agents/director/auto-direct/GenerateSceneTypes` | +| [Is Due For Instruction [DEPRECATED]](#is-due-for-instruction-deprecated) | `agents/director/auto-direct/IsDueForInstruction` | + +## Auto Direction + +`agents/director/auto-direct/AutoDirection` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/director/modules/auto-direction.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `` | | + +## Auto Direct Candidates [DEPRECATED] + +`agents/director/auto-direct/Candidates` + +Returns a list of characters that are valid for doing the +next action, based on the director's auto-direct settings and +the recent scene history. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `characters` | `list` | | + +## Determine Scene Intent + +`agents/director/auto-direct/DetermineSceneIntent` + +Has the director determine the current scene intent and set it on the +scene's intent state (the director may also decide to leave the +current intent unchanged). + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `scene_phase` | `scene_intent/scene_phase` | The scene's phase after the determination (None if the director decided not to set one) | + +## Generate Auto Direction + +`agents/director/auto-direct/generateAutoDirection` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/director/modules/generate-auto-direction.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `characters` | `list` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `actor_direction` | `any` | | +| `narrator_direction` | `any` | | + +## Generate Scene Types + +`agents/director/auto-direct/GenerateSceneTypes` + +Has the director generate scene types (or pick them from the scene +type templates) and register them on the scene's intent state. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `instructions` | `str` | Instructions guiding the scene type generation (optional) | +| `max_scene_types` | `int` | The maximum number of scene types to generate (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `scene_types` | `list` | The generated scene types | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `instructions` | `text` | `""` | The instructions for the scene types | +| `max_scene_types` | `int` | `1` | The maximum number of scene types to generate | + +## Is Due For Instruction [DEPRECATED] + +`agents/director/auto-direct/IsDueForInstruction` + +Checks if the actor is due for instruction based on the auto-direct settings. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `actor_name` | `str` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `is_due` | `bool` | | +| `actor_name` | `str` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `actor_name` | `str` | `""` | The name of the actor to check instruction timing for | diff --git a/docs/user-guide/node-editor/reference/nodes/agents-director-chat-actions.md b/docs/user-guide/node-editor/reference/nodes/agents-director-chat-actions.md new file mode 100644 index 00000000..92837122 --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/agents-director-chat-actions.md @@ -0,0 +1,264 @@ +# Director Chat Actions + +The actions the director chat AI can take. Each module defines one callable action — its name, description and instructions (shown in the properties below) are what the director AI sees when deciding what to do. + + + + +_10 nodes._ + +| Node | Registry path | +| --- | --- | +| [Director Action Create Image](#director-action-create-image) | `agents/director/chat/directorActionCreateImage` | +| [Director Action Direct Scene](#director-action-direct-scene) | `agents/director/chat/directorActionDirectScene` | +| [Director Action Direct Story Arc](#director-action-direct-story-arc) | `agents/director/chat/directorActionDirectStoryArc` | +| [Director Action Gameplay](#director-action-gameplay) | `agents/director/chat/directorActionGameplay` | +| [Director Action Prompt User](#director-action-prompt-user) | `agents/director/chat/directorActionPromptUser` | +| [Director Action Query](#director-action-query) | `agents/director/chat/directorActionQuery` | +| [Director Action Summary](#director-action-summary) | `agents/director/chat/directorActionSummary` | +| [Director Action Update Context](#director-action-update-context) | `agents/director/chat/directorActionUpdateContext` | +| [Director Action Update Gamestate](#director-action-update-gamestate) | `agents/director/chat/directorActionUpdateGamestate` | +| [Director Action Yield To User](#director-action-yield-to-user) | `agents/director/chat/directorActionYieldToUser` | + +## Director Action Create Image + +`agents/director/chat/directorActionCreateImage` + +Node module (base type `agents/director/DirectorChatAction`) defined in `src/talemate/agents/director/modules/director-action-create-image.json`. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `fn` | `function` | | +| `name` | `str` | | +| `allow_multiple_calls` | `bool` | | +| `ai_callback` | `focal/callback` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `"create_image"` | The name of the action | +| `description` | `text` | `""` | The description of the action | +| `example_json` | `text` | `"[]"` | An example JSON payload for the action | +| `instructions` | `text` | `"Instruct the artist to generate an image for you. Be detailed and specific as the artist's contextual understanding may be more limited than yours."` | The instructions for the action | +| `allow_concurrent` | `bool` | `False` | Whether the action can be called concurrently | + +## Director Action Direct Scene + +`agents/director/chat/directorActionDirectScene` + +Node module (base type `agents/director/DirectorChatAction`) defined in `src/talemate/agents/director/modules/director-action-direct-scene.json`. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `fn` | `function` | | +| `name` | `str` | | +| `allow_multiple_calls` | `bool` | | +| `ai_callback` | `focal/callback` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `"direct_scene"` | The name of the action | +| `description` | `text` | `"Instruct the writer to produce the next segment of narration or dialogue. IMPORTANT: Do NOT provide verbatim narration or dialogue. Just instruct the writer w…` | The description of the action | +| `instructions` | `text` | `"Instruct the writer to progress the scene either through narration or writing the next character segment. IMPORTANT: When the instructions include direction f…` | The instructions for the action | +| `example_json` | `text` | `"[]"` | An example JSON payload for the action | +| `allow_concurrent` | `bool` | `False` | Whether the action can be called concurrently | + +## Director Action Direct Story Arc + +`agents/director/chat/directorActionDirectStoryArc` + +Node module (base type `agents/director/DirectorChatAction`) defined in `src/talemate/agents/director/modules/director-action-direct-story-arc.json`. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `fn` | `function` | | +| `name` | `str` | | +| `allow_multiple_calls` | `bool` | | +| `ai_callback` | `focal/callback` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `"direct_story_arc"` | The name of the action | +| `description` | `text` | `"Generate a complete story arc from the active plan's beats. All beats are expanded into prose in chunks and pushed to scene history automatically. Use this ins…` | The description of the action | +| `instructions` | `text` | `""` | The instructions for the action | +| `example_json` | `text` | `"[]"` | An example JSON payload for the action | +| `allow_concurrent` | `bool` | `False` | Whether the action can be called concurrently | + +## Director Action Gameplay + +`agents/director/chat/directorActionGameplay` + +Node module (base type `agents/director/DirectorChatAction`) defined in `src/talemate/agents/director/modules/director-action-gameplay.json`. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `fn` | `function` | | +| `name` | `str` | | +| `allow_multiple_calls` | `bool` | | +| `ai_callback` | `focal/callback` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `"gameplay"` | The name of the action | +| `description` | `text` | `"Available gameplay utilities that can help you solve game mechanics."` | The description of the action | +| `instructions` | `text` | `"Pass a clear natural-language description of what you want to do."` | The instructions for the action | +| `example_json` | `text` | `"[]"` | An example JSON payload for the action | +| `allow_concurrent` | `bool` | `False` | Whether the action can be called concurrently | + +## Director Action Prompt User + +`agents/director/chat/directorActionPromptUser` + +Node module (base type `agents/director/DirectorChatAction`) defined in `src/talemate/agents/director/modules/director-action-prompt-user.json`. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `fn` | `function` | | +| `name` | `str` | | +| `allow_multiple_calls` | `bool` | | +| `ai_callback` | `focal/callback` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `"ooc"` | The name of the action | +| `description` | `text` | `"Interface for out of character communication with the user while remaining within the context of the scene."` | The description of the action | +| `instructions` | `text` | `""` | The instructions for the action | +| `example_json` | `text` | `None` | An example JSON payload for the action | +| `allow_concurrent` | `bool` | `False` | Whether the action can be called concurrently | + +## Director Action Query + +`agents/director/chat/directorActionQuery` + +Node module (base type `agents/director/DirectorChatAction`) defined in `src/talemate/agents/director/modules/director-action-query.json`. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `fn` | `function` | | +| `name` | `str` | | +| `allow_multiple_calls` | `bool` | | +| `ai_callback` | `focal/callback` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `"query"` | The name of the action | +| `description` | `text` | `"Your main way to learn information. Avoid generic queries and try to be specific. The following topics are available for investigation:"` | The description of the action | +| `instructions` | `text` | `"Instruct another agent to retrieve information about the scene, world and its characters. When looking to retrieve character specific information for a specifi…` | The instructions for the action | +| `example_json` | `text` | `"[]"` | An example JSON payload for the action | +| `allow_concurrent` | `bool` | `True` | Whether the action can be called concurrently | + +## Director Action Summary + +`agents/director/chat/directorActionSummary` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/director/modules/director-action-summary.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `calls` | `list` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `summary` | `str` | | + +## Director Action Update Context + +`agents/director/chat/directorActionUpdateContext` + +Node module (base type `agents/director/DirectorChatAction`) defined in `src/talemate/agents/director/modules/director-action-update-context.json`. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `fn` | `function` | | +| `name` | `str` | | +| `allow_multiple_calls` | `bool` | | +| `ai_callback` | `focal/callback` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `"update_context"` | The name of the action | +| `description` | `text` | `"Instruct another agent to make changes to the story context. IMPORTANT: - Changes to existing context: you MUST ALWAYS provide the specific Context ID of the…` | The description of the action | +| `instructions` | `text` | `"Instruct another agent to make changes to the World, Characters, History or Story configuration. Changes can mean updates, creations or removals."` | The instructions for the action | +| `example_json` | `text` | `"[]"` | An example JSON payload for the action | +| `allow_concurrent` | `bool` | `False` | Whether the action can be called concurrently | + +## Director Action Update Gamestate + +`agents/director/chat/directorActionUpdateGamestate` + +Node module (base type `agents/director/DirectorChatAction`) defined in `src/talemate/agents/director/modules/director-action-update-gamestate.json`. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `fn` | `function` | | +| `name` | `str` | | +| `allow_multiple_calls` | `bool` | | +| `ai_callback` | `focal/callback` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `"update_gamestate"` | The name of the action | +| `description` | `text` | `"Instruct another agent to make updates to any game state variables. You should query for relevant game state variables BEFORE using this. If you're changing e…` | The description of the action | +| `instructions` | `text` | `"Make updates to the game state variables."` | The instructions for the action | +| `example_json` | `text` | `"[]"` | An example JSON payload for the action | +| `allow_concurrent` | `bool` | `False` | Whether the action can be called concurrently | + +## Director Action Yield To User + +`agents/director/chat/directorActionYieldToUser` + +Node module (base type `agents/director/DirectorChatAction`) defined in `src/talemate/agents/director/modules/director-action-yield-to-user.json`. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `fn` | `function` | | +| `name` | `str` | | +| `allow_multiple_calls` | `bool` | | +| `ai_callback` | `focal/callback` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `"yield_to_user"` | The name of the action | +| `description` | `text` | `"Yield the turn to the user. You do not need to pass any instructions to this action."` | The description of the action | +| `instructions` | `text` | `"Yield the turn to the user"` | The instructions for the action | +| `example_json` | `text` | `"[]"` | An example JSON payload for the action | +| `allow_concurrent` | `bool` | `False` | Whether the action can be called concurrently | diff --git a/docs/user-guide/node-editor/reference/nodes/agents-director-chat-instructors.md b/docs/user-guide/node-editor/reference/nodes/agents-director-chat-instructors.md new file mode 100644 index 00000000..2c56edc1 --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/agents-director-chat-instructors.md @@ -0,0 +1,248 @@ +# Director Chat Instructors + +Instructor modules the director chat uses to apply changes: instruct characters and the narrator, and update world, history, gamestate, story and character configuration. + + + + +_10 nodes._ + +| Node | Registry path | +| --- | --- | +| [Instruct Character](#instruct-character) | `agents/director/chat/instructCharacter` | +| [Instruct Character Changes](#instruct-character-changes) | `agents/director/chat/instructCharacterChanges` | +| [Instruct Character Config Updates](#instruct-character-config-updates) | `agents/director/chat/instructCharacterConfigUpdates` | +| [Instruct Character Creation](#instruct-character-creation) | `agents/director/chat/instructCharacterCreation` | +| [Instruct Character Updates](#instruct-character-updates) | `agents/director/chat/instructCharacterUpdates` | +| [Instruct Gamestate Updates](#instruct-gamestate-updates) | `agents/director/chat/instructGamestateUpdates` | +| [Instruct History Updates](#instruct-history-updates) | `agents/director/chat/instructHistoryUpdates` | +| [Instruct Narrator](#instruct-narrator) | `agents/director/chat/instructNarrator` | +| [Instruct Story Config Updates](#instruct-story-config-updates) | `agents/director/chat/instructStoryConfigUpdates` | +| [Instruct World Updates](#instruct-world-updates) | `agents/director/chat/instructWorldUpdates` | + +## Instruct Character + +`agents/director/chat/instructCharacter` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/director/modules/instruct-character.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `character` | `character` | | +| `instructions` | `str` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `instructions` | `str` | | +| `character` | `character` | | +| `summary` | `str` | | +| `summary` | `str` | | + +## Instruct Character Changes + +`agents/director/chat/instructCharacterChanges` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/director/modules/instruct-character-changes.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `instructions` | `str` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `instructions` | `str` | | +| `summary` | `str` | | + +## Instruct Character Config Updates + +`agents/director/chat/instructCharacterConfigUpdates` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/director/modules/instruct-character-config-updates.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `character` | `character` | | +| `instructions` | `str` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `character` | `character` | | +| `instructions` | `str` | | +| `summary` | `str` | | + +## Instruct Character Creation + +`agents/director/chat/instructCharacterCreation` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/director/modules/instruct-character-creation.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `instructions` | `str` | | +| `active` | `bool` | | +| `narrate_instructions` | `str` | | +| `is_player` | `bool` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `instructions` | `str` | | +| `active` | `bool` | | +| `narrate_instructions` | `str` | | +| `is_player` | `bool` | | +| `character` | `character` | | +| `summary` | `str` | | + +## Instruct Character Updates + +`agents/director/chat/instructCharacterUpdates` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/director/modules/instruct-character-updates.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `character` | `character` | | +| `instructions` | `str` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `character` | `character` | | +| `instructions` | `str` | | +| `summary` | `str` | | + +## Instruct Gamestate Updates + +`agents/director/chat/instructGamestateUpdates` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/director/modules/instruct-gamestate-updates.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `instructions` | `str` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `instructions` | `str` | | +| `updates` | `dict` | | + +## Instruct History Updates + +`agents/director/chat/instructHistoryUpdates` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/director/modules/instruct-history-updates.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `instructions` | `str` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `instructions` | `str` | | +| `summary` | `str` | | + +## Instruct Narrator + +`agents/director/chat/instructNarrator` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/director/modules/instruct-narrator.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `instructions` | `str` | | +| `narration_type` | `str` | | +| `character` | `character` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `instructions` | `str` | | +| `narration_type` | `str` | | +| `character` | `character` | | +| `summary` | `str` | | + +## Instruct Story Config Updates + +`agents/director/chat/instructStoryConfigUpdates` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/director/modules/instruct-story-config-updates.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `instructions` | `str` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `summary` | `str` | | + +## Instruct World Updates + +`agents/director/chat/instructWorldUpdates` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/director/modules/instruct-world-updates.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `instructions` | `str` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `instructions` | `str` | | +| `summary` | `str` | | diff --git a/docs/user-guide/node-editor/reference/nodes/agents-director-chat.md b/docs/user-guide/node-editor/reference/nodes/agents-director-chat.md new file mode 100644 index 00000000..c9d4b1fd --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/agents-director-chat.md @@ -0,0 +1,355 @@ +# Director Chat Nodes + +Plumbing for the director chat: build the chat context, define action arguments, confirm actions and run sub-actions, plus the context/query modules the chat uses to look things up. + + + + +_13 nodes._ + +| Node | Registry path | +| --- | --- | +| [Director Action Argument](#director-action-argument) | `agents/director/chat/ActionArgument` | +| [Director Action Confirm](#director-action-confirm) | `agents/director/chat/ActionConfirm` | +| [Agent Report Issue](#agent-report-issue) | `agents/director/chat/agentReportIssue` | +| [Character Context](#character-context) | `agents/director/chat/characterContext` | +| [Character Names Context](#character-names-context) | `agents/director/chat/characterNamesContext` | +| [Direct Context Update](#direct-context-update) | `agents/director/chat/directContextUpdate` | +| [Director Agent Retrieve Context](#director-agent-retrieve-context) | `agents/director/chat/directorAgentRetrieveContext` | +| [Director Chat Sub Action](#director-chat-sub-action) | `agents/director/chat/DirectorChatSubAction` | +| [Get Director Chat Context](#get-director-chat-context) | `agents/director/chat/GetDirectorChatContext` | +| [Query Game State](#query-game-state) | `agents/director/chat/queryGameState` | +| [Query Scene Direction](#query-scene-direction) | `agents/director/chat/querySceneDirection` | +| [Query World Information](#query-world-information) | `agents/director/chat/queryWorldInformation` | +| [Toggle Character](#toggle-character) | `agents/director/chat/toggleCharacter` | + +## Director Action Argument + +`agents/director/chat/ActionArgument` + +Declares an argument for a director chat action. Place it inside a +DirectorChatAction graph to define one named, typed argument the +director can pass when invoking the action; the instructions describe +the argument to the director in the action prompt. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `value` | `any` | The argument's value (available while the action executes) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `unset` | The name of the argument | +| `typ` | `str` | `"str"` | The type of the argument. Choices: `str`, `int`, `float`, `bool`, `list`, `any`. | +| `instructions` | `text` | `""` | The instructions for the argument | + +## Director Action Confirm + +`agents/director/chat/ActionConfirm` + +Asks the user to confirm a director action before it proceeds. Only +active when the current director chat context requires confirmation +for write actions - otherwise the state passes straight through as +accepted. When active, the node emits a confirmation request to the +frontend and blocks until the user responds, the scene ends, or the +configured timeout is reached (timeouts and scene shutdown count as +rejections). + +On acceptance the state is passed to the `accepted` output; on +rejection it is passed to the `rejected` output instead - unless +raise_on_reject is set (the default), in which case an ActionRejected +error is raised and aborts the action. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state to gate on user confirmation | +| `name` | `str` | The name of the action to confirm (optional) | +| `description` | `str` | The description of the action to confirm (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `accepted` | `any` | The state input, set when the action was confirmed | +| `rejected` | `any` | The state input, set when the action was rejected | +| `rejected_message` | `str` | A message describing the rejection (only set on rejection) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `""` | The name of the action | +| `description` | `text` | `""` | The description of the action | +| `raise_on_reject` | `bool` | `True` | Whether to raise an error if the action is rejected | + +## Agent Report Issue + +`agents/director/chat/agentReportIssue` + +Node module (base type `core/functions/Function`) defined in `src/talemate/agents/director/modules/agent-report-issue.json`. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `fn` | `function` | | +| `name` | `str` | | +| `allow_multiple_calls` | `bool` | | +| `ai_callback` | `focal/callback` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `"report_problem"` | The name of the function | +| `allow_multiple_calls` | `bool` | `True` | Function can be called multiple times during AI Function Calling | + +## Character Context + +`agents/director/chat/characterContext` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/director/modules/character-context.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `character` | `character` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `character` | `character` | | +| `character_context` | `list` | | + +## Character Names Context + +`agents/director/chat/characterNamesContext` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/director/modules/character-names-context.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `header` | `str` | (optional) | +| `character_status` | `any` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `dynamic_instruction` | `dynamic_instruction` | | + +## Direct Context Update + +`agents/director/chat/directContextUpdate` + +Node module (base type `core/functions/Function`) defined in `src/talemate/agents/director/modules/direct-context-update.json`. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `fn` | `function` | | +| `name` | `str` | | +| `allow_multiple_calls` | `bool` | | +| `ai_callback` | `focal/callback` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `"direct_context_update"` | The name of the function | +| `allow_multiple_calls` | `bool` | `True` | Function can be called multiple times during AI Function Calling | + +## Director Agent Retrieve Context + +`agents/director/chat/directorAgentRetrieveContext` + +Node module (base type `agents/director/DirectorChatAction`) defined in `src/talemate/agents/director/modules/director-agent-retrieve-context.json`. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `fn` | `function` | | +| `name` | `str` | | +| `allow_multiple_calls` | `bool` | | +| `ai_callback` | `focal/callback` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `"retrieve_context"` | The name of the action | +| `description` | `text` | `"Instruct another agent to retrieve the complete content of a specific source context ID for you. Only use this if you have obtained such context ids. They ar…` | The description of the action | +| `instructions` | `text` | `"Use an exact source context ID to retrieve the content of the source. Source context IDs are always formatted like this: `{context_type}:{context_id}`"` | The instructions for the action | +| `example_json` | `text` | `"[ { "context_id": "context_type:context_id" } ]"` | An example JSON payload for the action | +| `allow_concurrent` | `bool` | `False` | Whether the action can be called concurrently | + +## Director Chat Sub Action + +`agents/director/chat/DirectorChatSubAction` + +Declares a sub-action inside a DirectorChatAction graph. Its properties +describe one concrete operation the director can perform and are extracted +statically (without running the graph) to advertise the sub-action in chat +and scene direction prompts and in the enable/disable UI. At runtime the +node acts as a gate: it evaluates the optional condition function and the +gating rules (availability vs. current mode, force_enabled, per-scene +disabled list) and only passes state through when the sub-action is +enabled - otherwise the state output stays unresolved and the downstream +branch is skipped. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | (optional) State to pass through when the sub-action is enabled | +| `condition` | `function` | (optional) Optional function; if it returns falsy the sub-action is neither advertised nor executed | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through when the sub-action is enabled; stays unresolved when the sub-action is gated off | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `group` | `str` | `""` | The group of the action | +| `action_id` | `str` | `""` | The id of the action | +| `action_title` | `str` | `""` | The title of the action | +| `description_chat` | `text` | `""` | The description of the action in chat | +| `description_scene_direction` | `text` | `""` | The description of the action in scene direction | +| `instruction_examples` | `list` | `[]` | The examples of the action instructions | +| `availability` | `str` | `"both"` | Which modes this action is available in. Choices: `both`, `chat`, `scene_direction`. | +| `force_enabled` | `bool` | `False` | If true, prevents users from disabling this action | + +## Get Director Chat Context + +`agents/director/chat/GetDirectorChatContext` + +Exposes the active DirectorChatContext as a dict for graph consumption. + +Use this when a graph needs to read flags off the current director chat +(e.g. close_arc, chat_id, plan_id) without reaching into Python context. +The `context` output is a plain dict that can be wired into template_vars +or inspected directly; fields are added as the DirectorChatContext grows +without requiring node socket changes. + +When no chat context is active, `has_context` is False and `context` is +an empty dict. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `has_context` | `bool` | | +| `context` | `dict` | | + +## Query Game State + +`agents/director/chat/queryGameState` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/director/modules/query-game-state.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `query` | `str` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `query` | `str` | | +| `answer` | `str` | | +| `variables` | `str` | | +| `formatted` | `str` | | + +## Query Scene Direction + +`agents/director/chat/querySceneDirection` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/director/modules/query-scene-direction.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `query` | `str` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `query` | `str` | | +| `answer` | `str` | | +| `formatted` | `str` | | + +## Query World Information + +`agents/director/chat/queryWorldInformation` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/director/modules/query-world-information.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `query` | `str` | | +| `character` | `character` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `answer` | `str` | | +| `sources` | `str` | | +| `formatted` | `str` | | + +## Toggle Character + +`agents/director/chat/toggleCharacter` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/director/modules/toggle-character.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `character` | `character` | | +| `status` | `str` | | +| `narrate_instructions` | `str` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `character` | `character` | | +| `narrate_instructions` | `str` | | diff --git a/docs/user-guide/node-editor/reference/nodes/agents-director-plan.md b/docs/user-guide/node-editor/reference/nodes/agents-director-plan.md new file mode 100644 index 00000000..8494cb8e --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/agents-director-plan.md @@ -0,0 +1,372 @@ +# Director Planning Nodes + +Director plans: create plans and tasks, advance and complete them, and read plan state from graphs. + + + + +_13 nodes._ + +| Node | Registry path | +| --- | --- | +| [Complete Task](#complete-task) | `agents/director/plan/CompleteTask` | +| [Create Outline](#create-outline) | `agents/director/plan/CreateOutline` | +| [Create Plan](#create-plan) | `agents/director/plan/CreatePlan` | +| [Critique Outline](#critique-outline) | `agents/director/plan/critiqueOutline` | +| [Delete Plan](#delete-plan) | `agents/director/plan/DeletePlan` | +| [Edit Task](#edit-task) | `agents/director/plan/EditTask` | +| [Estimate Words](#estimate-words) | `agents/director/plan/EstimateWords` | +| [Expand Story Arc](#expand-story-arc) | `agents/director/plan/ExpandStoryArc` | +| [Get Active Chat Plan ID](#get-active-chat-plan-id) | `agents/director/plan/GetActiveChatPlanId` | +| [Get Active Plan](#get-active-plan) | `agents/director/plan/GetActivePlan` | +| [Insert Task](#insert-task) | `agents/director/plan/InsertTask` | +| [Manage Plan](#manage-plan) | `agents/director/plan/managePlan` | +| [Remove Task](#remove-task) | `agents/director/plan/RemoveTask` | + +## Complete Task + +`agents/director/plan/CompleteTask` + +Marks a task as completed in a plan. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `task_id` | `str` | | +| `plan_id` | `str` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `result` | `str` | | + +## Create Outline + +`agents/director/plan/CreateOutline` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/director/modules/create-outline.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `instructions` | `str` | | +| `beat_count` | `int` | | +| `critique` | `bool` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `instructions` | `str` | | +| `beat_count` | `int` | | +| `critique` | `bool` | | +| `plan_id` | `str` | | +| `summary` | `str` | | + +## Create Plan + +`agents/director/plan/CreatePlan` + +Creates a plan with tasks and saves it to agent state. + +Accepts a list of task dicts — automatically validates each as Beat +(if beat-specific fields are present) or generic Task. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `instructions` | `str` | | +| `tasks` | `list` | | +| `meta` | `dict` | (optional) | +| `status` | `str` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `plan_id` | `str` | | +| `result` | `str` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `instructions` | `text` | `""` | Instructions for the plan | +| `status` | `str` | `"ready"` | Initial plan status. Choices: `planning`, `ready`, `executing`, `completed`, `cancelled`. | + +## Critique Outline + +`agents/director/plan/critiqueOutline` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/director/modules/critique-outline.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `outline_instructions` | `str` | | +| `outline` | `list` | | +| `perspective` | `str` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `outline_instructions` | `str` | | +| `orig_outline` | `list` | | +| `perspective` | `str` | | +| `new_outline` | `list` | | +| `outline` | `list` | | +| `changes` | `str` | | + +## Delete Plan + +`agents/director/plan/DeletePlan` + +Deletes the active plan. + +Also unlinks the plan from the active director chat. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `result` | `str` | | + +## Edit Task + +`agents/director/plan/EditTask` + +Patch-updates a task's fields in the active plan. + +Accepts a dict of field updates — only provided fields are changed. +The 'id' and 'order' fields are protected and cannot be changed. +Blocked if the plan is completed. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `task_id` | `str` | | +| `updates` | `dict` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `result` | `str` | | + +## Estimate Words + +`agents/director/plan/EstimateWords` + +Estimates total word output for a given number of beats based on +narrator and conversation agent token settings. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `beat_count` | `int` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `beat_count` | `int` | | +| `estimated_words` | `int` | | +| `estimated_reading_minutes` | `float` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `beat_count` | `int` | `8` | Number of beats to estimate for | + +## Expand Story Arc + +`agents/director/plan/ExpandStoryArc` + +Expands a plan's beats into full prose and pushes them to scene history. + +Delegates to the director's PlanMixin.expand_beats() which handles +chunking, template calls, revision, emission, and task completion. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `plan_id` | `str` | | +| `beats` | `list` | | +| `perspective` | `str` | | +| `director_notes` | `str` | (optional) | +| `chunk_size` | `int` | (optional) | +| `expand_critique` | `bool` | (optional) | +| `close_arc` | `bool` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `result` | `str` | | +| `word_count` | `int` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `chunk_size` | `int` | `5` | Number of beats per expansion chunk. Range: 3 – 12. | + +## Get Active Chat Plan ID + +`agents/director/plan/GetActiveChatPlanId` + +Returns the plan_id linked to the currently active director chat, +or None if no chat is active or no plan is linked. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `plan_id` | `str` | | +| `has_plan` | `bool` | | + +## Get Active Plan + +`agents/director/plan/GetActivePlan` + +Returns the plan linked to the currently active director chat. + +When no chat is active or no plan is linked, has_plan is False and the +plan output is empty. The perspective falls back to the scene's default +perspective (or "Third person, past tense.") when the plan doesn't +specify one. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `plan_id` | `str` | The plan's ID (empty string if no plan) | +| `plan` | `any` | The full plan object (or None) | +| `beats` | `list` | The plan's Beat tasks only (excludes generic tasks) | +| `perspective` | `str` | The plan's perspective, with fallback as described above | +| `close_arc` | `bool` | The plan's close_arc meta flag | +| `has_plan` | `bool` | Whether a plan was found | + +## Insert Task + +`agents/director/plan/InsertTask` + +Inserts a new task into the active plan at a given position. + +Position can be "start", "end", or an existing task ID (inserts after that task). +The task dict is auto-validated as Beat or Task based on fields present. +Blocked if the plan is completed. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `task` | `dict` | | +| `position` | `str` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `result` | `str` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `position` | `str` | `"end"` | Where to insert: 'start', 'end', or a task ID to insert after | + +## Manage Plan + +`agents/director/plan/managePlan` + +Node module (base type `agents/director/DirectorChatAction`) defined in `src/talemate/agents/director/modules/manage-plan.json`. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `fn` | `function` | | +| `name` | `str` | | +| `allow_multiple_calls` | `bool` | | +| `ai_callback` | `focal/callback` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `"manage_plan"` | The name of the action | +| `description` | `text` | `"Allows you set up, track and update your current set of tasks. Use this to setup general plans or plans for creating multiple beats of content over a specific …` | The description of the action | +| `instructions` | `text` | `"Manage your task plans. Pass plain text instructions describing what you want to do. To create a scene outline: describe the scene and specify the beat count.…` | The instructions for the action | +| `example_json` | `text` | `"[{"instructions": "Create a 6-beat scene outline: A tense encounter in the library where two strangers discover they're searching for the same book."}, {"instr…` | An example JSON payload for the action | +| `allow_concurrent` | `bool` | `False` | Whether the action can be called concurrently | + +## Remove Task + +`agents/director/plan/RemoveTask` + +Removes a task from the active plan by task ID. + +Renumbers remaining tasks to keep contiguous ordering. +Blocked if the plan is completed. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `task_id` | `str` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `result` | `str` | | diff --git a/docs/user-guide/node-editor/reference/nodes/agents-director.md b/docs/user-guide/node-editor/reference/nodes/agents-director.md new file mode 100644 index 00000000..a92a652c --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/agents-director.md @@ -0,0 +1,406 @@ +# Director Agent Nodes + +Director operations: scene direction, character persistence, voice assignment, chat message insertion, dice rolls, actor/narrator direction functions and the director's websocket handler modules. + + + + +_15 nodes._ + +| Node | Registry path | +| --- | --- | +| [Assign Voice](#assign-voice) | `agents/director/AssignVoice` | +| [On Persist Character Generate Visual](#on-persist-character-generate-visual) | `agents/director/events/onPersistCharacterGenerateVisual` | +| [Direct Actor](#direct-actor) | `agents/director/fn/directActor` | +| [Direct Narrator](#direct-narrator) | `agents/director/fn/directNarrator` | +| [Yield To Actor](#yield-to-actor) | `agents/director/fn/yieldToActor` | +| [Yield To User](#yield-to-user) | `agents/director/fn/yieldToUser` | +| [Roll Dice](#roll-dice) | `agents/director/gameplay/rollDice` | +| [Insert Chat Message](#insert-chat-message) | `agents/director/InsertChatMessage` | +| [Instruct Avatar Generation](#instruct-avatar-generation) | `agents/director/instructAvatarGeneration` | +| [Log Director Action](#log-director-action) | `agents/director/LogAction` | +| [Persist Character](#persist-character) | `agents/director/PersistCharacter` | +| [Scene Direction](#scene-direction) | `agents/director/SceneDirection` | +| [Director Settings](#director-settings) | `agents/director/Settings` | +| [Wsh Activate Character](#wsh-activate-character) | `agents/director/wshActivateCharacter` | +| [Wsh Dectivate Character](#wsh-dectivate-character) | `agents/director/wshDectivateCharacter` | + +## Assign Voice + +`agents/director/AssignVoice` + +Has the director automatically pick and assign a fitting TTS voice to +a character, choosing from the voices available through the ready TTS +APIs (global and scene voice libraries). Does nothing when automatic +voice assignment is disabled in the director settings or when no +voices are available. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `character` | `character` | The character to assign a voice to | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `character` | `character` | The character, passed through | +| `voice` | `tts/voice` | The character's voice after assignment (may be unset if assignment was skipped) | + +## On Persist Character Generate Visual + +`agents/director/events/onPersistCharacterGenerateVisual` + +Node module (base type `core/Event`) defined in `src/talemate/agents/director/modules/on-persist-character-generate-visual.json`. + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `event_name` | `str` | `"agent.director.character_management.after_persist_character"` | Event to listen for | +| `auto_register` | `bool` | `False` | Self-register from the node registry instead of requiring placement in the scene loop graph | + +## Direct Actor + +`agents/director/fn/directActor` + +Node module (base type `core/functions/Function`) defined in `src/talemate/agents/director/modules/direct-actor.json`. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `fn` | `function` | | +| `name` | `str` | | +| `allow_multiple_calls` | `bool` | | +| `ai_callback` | `focal/callback` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `"direct_actor"` | The name of the function | +| `allow_multiple_calls` | `bool` | `False` | Function can be called multiple times during AI Function Calling | + +## Direct Narrator + +`agents/director/fn/directNarrator` + +Node module (base type `core/functions/Function`) defined in `src/talemate/agents/director/modules/direct-narrator.json`. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `fn` | `function` | | +| `name` | `str` | | +| `allow_multiple_calls` | `bool` | | +| `ai_callback` | `focal/callback` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `"direct_narrator"` | The name of the function | +| `allow_multiple_calls` | `bool` | `False` | Function can be called multiple times during AI Function Calling | + +## Yield To Actor + +`agents/director/fn/yieldToActor` + +Node module (base type `core/functions/Function`) defined in `src/talemate/agents/director/modules/yield-to-actor.json`. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `fn` | `function` | | +| `name` | `str` | | +| `allow_multiple_calls` | `bool` | | +| `ai_callback` | `focal/callback` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `"yield_to_user"` | The name of the function | +| `allow_multiple_calls` | `bool` | `False` | Function can be called multiple times during AI Function Calling | + +## Yield To User + +`agents/director/fn/yieldToUser` + +Node module (base type `core/functions/Function`) defined in `src/talemate/agents/director/modules/yield-to-user.json`. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `fn` | `function` | | +| `name` | `str` | | +| `allow_multiple_calls` | `bool` | | +| `ai_callback` | `focal/callback` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `"yield_to_user"` | The name of the function | +| `allow_multiple_calls` | `bool` | `False` | Function can be called multiple times during AI Function Calling | + +## Roll Dice + +`agents/director/gameplay/rollDice` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/director/modules/roll-dice.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `min` | `int` | | +| `max` | `int` | | +| `reason` | `str` | | +| `emit_system_message` | `bool` | (optional) | +| `modifier` | `int` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `min` | `int` | | +| `max` | `int` | | +| `reason` | `str` | | +| `emit_system_message` | `bool` | | +| `result` | `int` | | +| `formatted_result` | `str` | | + +## Insert Chat Message + +`agents/director/InsertChatMessage` + +Inserts a message into the director chat. +Can optionally display an asset from scene_assets by providing an asset_id. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `message` | `str` | | +| `source` | `str` | (optional) | +| `asset_id` | `str` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `chat_message` | `director/chat_message` | | +| `message` | `str` | | +| `source` | `str` | | +| `asset_id` | `str` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `message` | `text` | `""` | The message to insert | +| `source` | `str` | `"user"` | The source of the message. Choices: `user`, `director`. | +| `asset_id` | `str` | `""` | Optional asset ID from scene_assets library to display | + +## Instruct Avatar Generation + +`agents/director/instructAvatarGeneration` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/director/modules/instruct-avatar-generation.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `character` | `character` | | +| `set_current_avatar` | `bool` | (optional) | +| `set_default_avatar` | `bool` | (optional) | +| `asset_ctx` | `asset_attachment_context` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `character` | `character` | | +| `set_current_avatar` | `bool` | | +| `set_default_avatar` | `bool` | | +| `asset_ctx` | `asset_attachment_context` | | + +## Log Director Action + +`agents/director/LogAction` + +Logs a director action by pushing a DirectorMessage to the scene +history and emitting it to the UI. When console_only is true the +message is flagged as hidden so it is not shown in the scene, but +still appears in the debug console. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `action` | `str` | The name of the action to log | +| `action_description` | `str` | The description of the action | +| `console_only` | `bool` | Whether to hide the message from the scene (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `action` | `str` | `""` | The action to log | +| `action_description` | `str` | `""` | The description of the action | +| `console_only` | `bool` | `False` | Whether to log the action to the console only | + +## Persist Character + +`agents/director/PersistCharacter` + +Persists a character that currently only exists as part of the given context +as a real character that can actively participate in the scene. + +Pre-generated `description`, `dialogue_instructions` and `example_dialogue` +inputs (e.g. from an `agents/creator/GenerateCharacter` node) are used +as-is instead of being generated again. When wiring a generated name into +`character_name`, disable the `determine_name` property so the name is +not determined twice. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `character_name` | `str` | (optional) | +| `context` | `str` | (optional) | +| `description` | `str` | (optional) | +| `attributes` | `dict,str` | (optional) | +| `dialogue_instructions` | `str` | (optional) | +| `example_dialogue` | `list` | (optional) | +| `is_player` | `bool` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `character` | `character` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `determine_name` | `bool` | `True` | Whether to determine the name of the character | +| `is_player` | `bool` | `False` | Whether the character is a player | + +## Scene Direction + +`agents/director/SceneDirection` + +Executes an autonomous scene direction turn (game master mode). + +The director analyzes the scene and takes multiple sequential actions +until satisfied or hitting the max actions limit, without user interaction. + +Skips execution (leaving all outputs unresolved) when scene direction +is disabled and always_on is not set, or on the first turn of the +scene unless run_immediately is set. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | Required state to trigger execution | +| `max_actions` | `number` | (optional) Optional override for max actions per turn (0 = agent default) | +| `is_first_turn` | `bool` | (optional) Optional flag marking this as the scene's first turn | +| `always_on` | `bool` | (optional) Optional override to always execute (ignores agent enabled config) | +| `run_immediately` | `bool` | (optional) Optional flag to run even on the first turn | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The input state passed through | +| `actions_taken` | `list` | List of actions executed during the turn | +| `yield_to_user` | `bool` | Whether the director wants to yield to the player | +| `action_count` | `number` | Number of actions taken | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `max_actions` | `int` | `0` | Maximum actions per turn (0 = use agent default). Range: 0 – 20. | +| `always_on` | `bool` | `False` | Override agent config and always execute scene direction (ignores enabled setting) | +| `run_immediately` | `bool` | `False` | Run immediately (do not yield first turn) | +| `is_first_turn` | `bool` | `False` | Is this the first turn of the scene? | + +## Director Settings + +`agents/director/Settings` + +Base node to render director agent settings. + +Every setting of the director agent is exposed as an output socket named after the setting - see the agent's documentation under [Agents](../../../agents/index.md) for what each setting does. + +## Wsh Activate Character + +`agents/director/wshActivateCharacter` + +Node module (base type `agents/AgentWebsocketHandler`) defined in `src/talemate/agents/director/modules/wsh-activate-character.json`. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `fn` | `function` | | +| `name` | `str` | | +| `allow_multiple_calls` | `bool` | | +| `ai_callback` | `focal/callback` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `"activate_character"` | The name of the handler | +| `agent` | `str` | `"director"` | The agent to register the handler on. Choices are generated at runtime. | + +## Wsh Dectivate Character + +`agents/director/wshDectivateCharacter` + +Node module (base type `agents/AgentWebsocketHandler`) defined in `src/talemate/agents/director/modules/wsh-dectivate-character.json`. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `fn` | `function` | | +| `name` | `str` | | +| `allow_multiple_calls` | `bool` | | +| `ai_callback` | `focal/callback` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `"deactivate_character"` | The name of the handler | +| `agent` | `str` | `"director"` | The agent to register the handler on. Choices are generated at runtime. | diff --git a/docs/user-guide/node-editor/reference/nodes/agents-editor.md b/docs/user-guide/node-editor/reference/nodes/agents-editor.md new file mode 100644 index 00000000..46d4fc88 --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/agents-editor.md @@ -0,0 +1,107 @@ +# Editor Agent Nodes + +Clean up user input, narration and character messages through the editor agent's revision passes. + + + + +_4 nodes._ + +| Node | Registry path | +| --- | --- | +| [Clean Up Character Message](#clean-up-character-message) | `agents/editor/CleanUpCharacterMessage` | +| [Clean Up Narration](#clean-up-narration) | `agents/editor/CleanUpNarration` | +| [Clean Up User Input](#clean-up-user-input) | `agents/editor/CleanUpUserInput` | +| [Editor Settings](#editor-settings) | `agents/editor/Settings` | + +## Clean Up Character Message + +`agents/editor/CleanUpCharacterMessage` + +Cleans up a character's dialogue text via the editor agent: fixes +exposition formatting (per the editor's formatting settings), cleans +up stray dialogue from other characters, strips partial sentences and +balances quotation marks. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `text` | `str` | The character message text to clean up | +| `character` | `character` | The character the message belongs to | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `cleaned_character_message` | `str` | The cleaned up character message | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `force` | `bool` | `False` | Force clean up | + +## Clean Up Narration + +`agents/editor/CleanUpNarration` + +Cleans up narration text via the editor agent, stripping partial +sentences and fixing exposition formatting according to the editor's +formatting settings (skipped unless the narrator fix-exposition +setting is enabled or force is set). + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `narration` | `str` | The narration text to clean up | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `cleaned_narration` | `str` | The cleaned up narration | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `force` | `bool` | `False` | Force clean up | + +## Clean Up User Input + +`agents/editor/CleanUpUserInput` + +Cleans up user input via the editor agent, fixing exposition +formatting (quotes for speech, asterisks for narration) according to +the editor's formatting settings. Input starting with a command prefix +(!, @, /) is never edited, and cleanup is skipped entirely when the +editor's fix-user-input setting is disabled - unless force is set. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `user_input` | `str` | The user input text to clean up | +| `as_narration` | `bool` | Whether to treat the input as narration instead of speech | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `cleaned_user_input` | `str` | The cleaned up user input | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `force` | `bool` | `False` | Force clean up | + +## Editor Settings + +`agents/editor/Settings` + +Base node to render editor agent settings. + +Every setting of the editor agent is exposed as an output socket named after the setting - see the agent's documentation under [Agents](../../../agents/index.md) for what each setting does. diff --git a/docs/user-guide/node-editor/reference/nodes/agents-memory.md b/docs/user-guide/node-editor/reference/nodes/agents-memory.md new file mode 100644 index 00000000..227e96db --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/agents-memory.md @@ -0,0 +1,72 @@ +# Memory Agent Nodes + +Query the long-term memory database (RAG) and unpack the returned memory documents. + + + + +_2 nodes._ + +| Node | Registry path | +| --- | --- | +| [Query Context DB](#query-context-db) | `agents/memory/QueryContextDB` | +| [Unpack Memory Document](#unpack-memory-document) | `agents/memory/UnpackMemoryDocument` | + +## Query Context DB + +`agents/memory/QueryContextDB` + +Queries the memory agent's context database with one or more queries +and collects the matching documents, up to a total token budget. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `queries` | `list,str` | (optional) The queries to run (list, or a single query string) | +| `meta_filters` | `dict` | Metadata filters to constrain the results (optional) | +| `max_tokens` | `int` | The maximum total tokens of results to return (optional) | +| `fn_filter` | `function` | Function that receives a result and returns whether to keep it (optional) | +| `fn_formatter` | `function` | Function that receives a result and returns its formatted replacement (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `results` | `list` | The list of matching documents | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `queries` | `list` | `[]` | The queries to use to query the context database | +| `meta_filters` | `dict` | `{}` | The meta filters to apply to the results | +| `max_tokens` | `int` | `1024` | The maximum number of tokens to return | +| `limit` | `int` | `10` | The number of N best results to consider per query | +| `iterate` | `int` | `3` | The number of results to return per query | + +## Unpack Memory Document + +`agents/memory/UnpackMemoryDocument` + +Unpacks a memory document into its individual fields. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `document` | `memory/document` | The memory document to unpack | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `document` | `memory/document` | The memory document, passed through | +| `meta` | `dict` | The document's metadata dict | +| `id` | `str` | The document's ID in the memory database | +| `raw` | `dict` | The document's raw content | +| `as_text` | `str` | The document rendered as text | +| `as_dict` | `dict` | The document as a dict | +| `context_id` | `context_id` | The document's context ID | diff --git a/docs/user-guide/node-editor/reference/nodes/agents-narrator.md b/docs/user-guide/node-editor/reference/nodes/agents-narrator.md new file mode 100644 index 00000000..9fc2bd16 --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/agents-narrator.md @@ -0,0 +1,354 @@ +# Narrator Agent Nodes + +Generate narration through the narrator agent: scene progress, environment, queries, character entries/exits and time passage. + + + + +_12 nodes._ + +| Node | Registry path | +| --- | --- | +| [Auto Narration](#auto-narration) | `agents/narrator/autoNarration` | +| [Generate After Dialog Narration](#generate-after-dialog-narration) | `agents/narrator/GenerateAfterDialogNarration` | +| [Generate Character Entry Narration](#generate-character-entry-narration) | `agents/narrator/GenerateCharacterEntryNarration` | +| [Generate Character Exit Narration](#generate-character-exit-narration) | `agents/narrator/GenerateCharacterExitNarration` | +| [Generate Character Narration](#generate-character-narration) | `agents/narrator/GenerateCharacterNarration` | +| [Generate Environment Narration](#generate-environment-narration) | `agents/narrator/GenerateEnvironmentNarration` | +| [Generate Progress Narration](#generate-progress-narration) | `agents/narrator/GenerateProgress` | +| [Generate Query Narration](#generate-query-narration) | `agents/narrator/GenerateQueryNarration` | +| [Generate Scene Narration](#generate-scene-narration) | `agents/narrator/GenerateSceneNarration` | +| [Generate Time Narration](#generate-time-narration) | `agents/narrator/GenerateTimeNarration` | +| [Narrator Settings](#narrator-settings) | `agents/narrator/Settings` | +| [Unpack Source](#unpack-source) | `agents/narrator/UnpackSource` | + +## Auto Narration + +`agents/narrator/autoNarration` + +Node module (base type `core/Event`) defined in `src/talemate/agents/narrator/modules/auto-narration.json`. + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `event_name` | `str` | `"game_loop_actor_iter"` | Event to listen for | +| `auto_register` | `bool` | `False` | Self-register from the node registry instead of requiring placement in the scene loop graph | + +## Generate After Dialog Narration + +`agents/narrator/GenerateAfterDialogNarration` + +Generates narration reacting to the most recent line of dialogue, from +the perspective of the given character, via the narrator agent's +narrate_after_dialogue action. + +The generated message is not added to the scene history by this node. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `narrative_direction` | `str` | (optional) Optional direction the narrative should take | +| `response_length` | `int` | (optional) Optional response length in tokens (0 for default) | +| `character` | `character` | The character whose dialogue the narration follows | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `generated` | `str` | The generated narration text | +| `message` | `message_object` | The generated NarratorMessage object | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `response_length` | `int` | `0` | Response length (0 for default) | +| `narrative_direction` | `str` | `""` | Narrative directions | + +## Generate Character Entry Narration + +`agents/narrator/GenerateCharacterEntryNarration` + +Generates narration for a character entering the scene, via the +narrator agent's narrate_character_entry action. The node does not +activate the character or add the message to the scene history. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `narrative_direction` | `str` | (optional) Optional direction the narrative should take | +| `response_length` | `int` | (optional) Optional response length in tokens (0 for default) | +| `character` | `character` | The character entering the scene | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `generated` | `str` | The generated narration text | +| `message` | `message_object` | The generated NarratorMessage object | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `response_length` | `int` | `0` | Response length (0 for default) | +| `narrative_direction` | `str` | `""` | Narrative directions | + +## Generate Character Exit Narration + +`agents/narrator/GenerateCharacterExitNarration` + +Generates narration for a character exiting the scene, via the +narrator agent's narrate_character_exit action. The node does not +deactivate the character or add the message to the scene history. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `narrative_direction` | `str` | (optional) Optional direction the narrative should take | +| `response_length` | `int` | (optional) Optional response length in tokens (0 for default) | +| `character` | `character` | The character exiting the scene | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `generated` | `str` | The generated narration text | +| `message` | `message_object` | The generated NarratorMessage object | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `response_length` | `int` | `0` | Response length (0 for default) | +| `narrative_direction` | `str` | `""` | Narrative directions | + +## Generate Character Narration + +`agents/narrator/GenerateCharacterNarration` + +Generates narration describing a specific character, via the narrator +agent's narrate_character action. + +The generated message is not added to the scene history by this node. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `narrative_direction` | `str` | (optional) Optional direction the narrative should take | +| `response_length` | `int` | (optional) Optional response length in tokens (0 for default) | +| `character` | `character` | The character to narrate | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `generated` | `str` | The generated narration text | +| `message` | `message_object` | The generated NarratorMessage object | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `response_length` | `int` | `0` | Response length (0 for default) | +| `narrative_direction` | `str` | `""` | Narrative directions | + +## Generate Environment Narration + +`agents/narrator/GenerateEnvironmentNarration` + +Generates narration describing the current environment, via the +narrator agent's narrate_environment action (which narrates from the +player character's perspective). + +The generated message is not added to the scene history by this node. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `narrative_direction` | `str` | (optional) Optional direction the narrative should take | +| `response_length` | `int` | (optional) Optional response length in tokens (0 for default) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `generated` | `str` | The generated narration text | +| `message` | `message_object` | The generated NarratorMessage object | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `response_length` | `int` | `0` | Response length (0 for default) | +| `narrative_direction` | `str` | `""` | Narrative directions | + +## Generate Progress Narration + +`agents/narrator/GenerateProgress` + +Generates narration that moves the story forward, via the narrator +agent's progress_story action. If no narrative direction is given the +narrator will attempt to subtly move the story forward on its own. + +The generated message is not added to the scene history by this node. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `narrative_direction` | `str` | (optional) Optional direction the narrative should take | +| `response_length` | `int` | (optional) Optional response length in tokens (0 for default) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `generated` | `str` | The generated narration text | +| `message` | `message_object` | The generated NarratorMessage object | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `response_length` | `int` | `0` | Response length (0 for default) | +| `narrative_direction` | `str` | `""` | Narrative directions | + +## Generate Query Narration + +`agents/narrator/GenerateQueryNarration` + +Generates narration answering a specific question about the scene, via +the narrator agent's narrate_query action. + +The generated message is not added to the scene history by this node. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `response_length` | `int` | (optional) Optional response length in tokens (0 for default) | +| `query` | `str` | The question to answer through narration | +| `extra_context` | `str` | (optional) Optional additional context to inform the answer | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `generated` | `str` | The generated narration text | +| `message` | `message_object` | The generated NarratorMessage object | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `response_length` | `int` | `0` | Response length (0 for default) | +| `narrative_direction` | `str` | `""` | Narrative directions | + +## Generate Scene Narration + +`agents/narrator/GenerateSceneNarration` + +Generates narration describing the current scene, via the narrator +agent's narrate_scene action. + +The generated message is not added to the scene history by this node. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `narrative_direction` | `str` | (optional) Optional direction the narrative should take | +| `response_length` | `int` | (optional) Optional response length in tokens (0 for default) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `generated` | `str` | The generated narration text | +| `message` | `message_object` | The generated NarratorMessage object | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `response_length` | `int` | `0` | Response length (0 for default) | +| `narrative_direction` | `str` | `""` | Narrative directions | + +## Generate Time Narration + +`agents/narrator/GenerateTimeNarration` + +Generates narration for a passage of time, via the narrator agent's +narrate_time_passage action. The ISO 8601 duration is converted to a +human readable string before being handed to the narrator. + +The generated message is not added to the scene history by this node, +nor does the node advance the scene time. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `narrative_direction` | `str` | (optional) Optional direction the narrative should take | +| `response_length` | `int` | (optional) Optional response length in tokens (0 for default) | +| `duration` | `str` | The time passed as an ISO 8601 duration (e.g. "PT30M") | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `generated` | `str` | The generated narration text | +| `message` | `message_object` | The generated NarratorMessage object | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `response_length` | `int` | `0` | Response length (0 for default) | +| `duration` | `str` | `"P0T1S"` | duration | +| `narrative_direction` | `str` | `""` | Narrative directions | + +## Narrator Settings + +`agents/narrator/Settings` + +Settings for the narrator agent + +Every setting of the narrator agent is exposed as an output socket named after the setting - see the agent's documentation under [Agents](../../../agents/index.md) for what each setting does. + +## Unpack Source + +`agents/narrator/UnpackSource` + +DEPRECATED - narration messages no longer encode their action in a +source string. This node always outputs an empty action name and an +empty arguments dict. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `source` | `str` | The narration message source string | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `action_name` | `str` | Always an empty string | +| `arguments` | `dict` | Always an empty dict | diff --git a/docs/user-guide/node-editor/reference/nodes/agents-summarizer.md b/docs/user-guide/node-editor/reference/nodes/agents-summarizer.md new file mode 100644 index 00000000..402eed7f --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/agents-summarizer.md @@ -0,0 +1,103 @@ +# Summarizer Agent Nodes + +Summarization through the summarizer agent: scene progression summaries and tag generation. + + + + +_5 nodes._ + +| Node | Registry path | +| --- | --- | +| [Generate Tags](#generate-tags) | `agents/summarize/generateTags` | +| [On Message Set Avatar](#on-message-set-avatar) | `agents/summarizer/onMessageSetAvatar` | +| [Summarizer Settings](#summarizer-settings) | `agents/summarizer/Settings` | +| [Summarize Scene Progression](#summarize-scene-progression) | `agents/summarizer/summarizeSceneProgression` | +| [Wsh Summarize Scene Progress](#wsh-summarize-scene-progress) | `agents/summarizer/websocket/wshSummarizeSceneProgress` | + +## Generate Tags + +`agents/summarize/generateTags` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/summarize/modules/generate-tags.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `text` | `str` | | +| `instructions` | `str` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `text` | `str` | | +| `instructions` | `str` | | +| `tags` | `list` | | +| `tags_str` | `str` | | + +## On Message Set Avatar + +`agents/summarizer/onMessageSetAvatar` + +Node module (base type `core/Event`) defined in `src/talemate/agents/world_state/modules/on-message-set-avatar.json`. + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `event_name` | `str` | `"agent.conversation.generated"` | Event to listen for | +| `auto_register` | `bool` | `False` | Self-register from the node registry instead of requiring placement in the scene loop graph | + +## Summarizer Settings + +`agents/summarizer/Settings` + +Base node to render summarizer agent settings. + +Every setting of the summarizer agent is exposed as an output socket named after the setting - see the agent's documentation under [Agents](../../../agents/index.md) for what each setting does. + +## Summarize Scene Progression + +`agents/summarizer/summarizeSceneProgression` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/summarize/modules/summarize-scene-progression.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `summary` | `str` | | +| `analysis` | `str` | | + +## Wsh Summarize Scene Progress + +`agents/summarizer/websocket/wshSummarizeSceneProgress` + +Node module (base type `agents/AgentWebsocketHandler`) defined in `src/talemate/agents/summarize/modules/wsh-summarize-scene-progress.json`. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `fn` | `function` | | +| `name` | `str` | | +| `allow_multiple_calls` | `bool` | | +| `ai_callback` | `focal/callback` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `"summarize_scene_progress"` | The name of the handler | +| `agent` | `str` | `"summarizer"` | The agent to register the handler on. Choices are generated at runtime. | diff --git a/docs/user-guide/node-editor/reference/nodes/agents-tts.md b/docs/user-guide/node-editor/reference/nodes/agents-tts.md new file mode 100644 index 00000000..da7cb1b2 --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/agents-tts.md @@ -0,0 +1,116 @@ +# Voice Agent Nodes + +Text-to-speech through the voice agent: resolve voices (including the narrator's), unpack voice data and generate speech. + + + + +_5 nodes._ + +| Node | Registry path | +| --- | --- | +| [Generate TTS](#generate-tts) | `agents/tts/Generate` | +| [Get Narrator Voice](#get-narrator-voice) | `agents/tts/GetNarratorVoice` | +| [Get Voice](#get-voice) | `agents/tts/GetVoice` | +| [TTS Agent Settings](#tts-agent-settings) | `agents/tts/Settings` | +| [Unpack Voice](#unpack-voice) | `agents/tts/UnpackVoice` | + +## Generate TTS + +`agents/tts/Generate` + +Generates speech audio for the given text via the TTS agent and queues +it for playback in the frontend. Either a voice or a character must be +provided; when a character is given its assigned voice is used, while +an explicit voice input overrides it. Does nothing when the TTS agent +is disabled or not ready. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `text` | `text` | (optional) The text to speak (required at runtime despite the optional socket) | +| `voice` | `tts/voice` | The voice to use (optional) | +| `character` | `character` | The character to speak as (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `text` | `text` | `unset` | The text to generate | + +## Get Narrator Voice + +`agents/tts/GetNarratorVoice` + +Gets the narrator voice from the TTS agent. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `voice` | `tts/voice` | | + +## Get Voice + +`agents/tts/GetVoice` + +Looks up a voice in the TTS agent's voice library by voice ID. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `voice_id` | `str` | (optional) The ID of the voice to get (required at runtime despite the optional socket) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `voice` | `tts/voice` | The voice object (None if no voice with that ID exists) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `voice_id` | `str` | `unset` | The ID of the voice to get | + +## TTS Agent Settings + +`agents/tts/Settings` + +Base node to render TTS agent settings. + +Every setting of the tts agent is exposed as an output socket named after the setting - see the agent's documentation under [Agents](../../../agents/index.md) for what each setting does. + +## Unpack Voice + +`agents/tts/UnpackVoice` + +Unpacks a voice object into its individual fields. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `voice` | `tts/voice` | The voice to unpack | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `voice` | `tts/voice` | The voice, passed through | +| `label` | `str` | The voice's display label | +| `provider` | `str` | The TTS provider (API) the voice belongs to | +| `provider_id` | `str` | The voice's ID at the provider | +| `provider_model` | `str` | The provider model the voice uses | +| `tags` | `list` | The voice's tags | +| `parameters` | `dict` | The voice's generation parameters | +| `is_scene_asset` | `bool` | Whether the voice is stored as a scene asset | diff --git a/docs/user-guide/node-editor/reference/nodes/agents-visual-generation.md b/docs/user-guide/node-editor/reference/nodes/agents-visual-generation.md new file mode 100644 index 00000000..b67f3fca --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/agents-visual-generation.md @@ -0,0 +1,226 @@ +# Visualizer Agent Generation Nodes + +Run visual generation: build generation requests, select the backend, generate images and analyze them. Prompts come from the [prompt nodes](agents-visual.md). + + + + +_7 nodes._ + +| Node | Registry path | +| --- | --- | +| [Analyze Images](#analyze-images) | `agents/visual/AnalyzeImages` | +| [Backend Status](#backend-status) | `agents/visual/BackendStatus` | +| [Generate Image](#generate-image) | `agents/visual/GenerateImage` | +| [Visual Generation Request](#visual-generation-request) | `agents/visual/GenerationRequest` | +| [Select Backend](#select-backend) | `agents/visual/SelectBackend` | +| [Unpack Visual Generation Request](#unpack-visual-generation-request) | `agents/visual/UnpackGenerationRequest` | +| [Unpack Visual Generation Response](#unpack-visual-generation-response) | `agents/visual/UnpackGenerationResponse` | + +## Analyze Images + +`agents/visual/AnalyzeImages` + +Analyzes images in batches using asyncio.Semaphore to limit concurrent requests. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | graph state (required) | +| `asset_ids` | `list` | list of asset IDs to analyze (required) | +| `missing_only` | `bool` | (optional) only analyze assets without existing analysis (optional, default True) | +| `prompt` | `str` | (optional) analysis prompt to use (optional, default "Describe this image in detail.") | +| `save` | `bool` | (optional) whether to save analysis to asset meta (optional, default True) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | graph state (passed through) | +| `asset_ids` | `list` | original list of asset IDs (passed through) | +| `missing_only` | `bool` | missing_only flag (passed through) | +| `prompt` | `str` | analysis prompt used (passed through) | +| `save` | `bool` | save flag (passed through) | +| `analyzed_ids` | `list` | list of successfully analyzed asset IDs | +| `skipped_ids` | `list` | list of skipped asset IDs (missing assets or already analyzed) | +| `failed_ids` | `list` | list of asset IDs that failed to analyze | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `missing_only` | `bool` | `True` | Only analyze assets that don't have an existing analysis | +| `prompt` | `text` | `"Describe this image in detail. (3 paragraphs max.)"` | The prompt to use for image analysis | +| `save` | `bool` | `True` | Whether to save the analysis to asset meta | + +## Backend Status + +`agents/visual/BackendStatus` + +Reports the capabilities of the visual agent's currently configured +backends. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `can_generate_images` | `bool` | Whether an image generation backend is available | +| `can_edit_images` | `bool` | Whether an image editing backend is available | +| `max_references` | `int` | Maximum number of reference images the image-edit backend supports (0 if none is available) | + +## Generate Image + +`agents/visual/GenerateImage` + +Generates an image by submitting a generation request to the visual +agent, which routes it to the appropriate backend. The request's +callback (if any) is invoked with the response, and depending on the +request's asset attachment context the resulting image may be saved +to the scene's assets. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `generation_request` | `visual/generation_request` | The generation request to execute | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `generation_request` | `visual/generation_request` | The generation request, passed through | +| `generation_response` | `visual/generation_response` | The generation response containing the image | + +## Visual Generation Request + +`agents/visual/GenerationRequest` + +Creates a generation request for image generation. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `prompt` | `visual/prompt` | visual prompt object (required) | +| `vis_type` | `str` | type of visual to generate (optional) | +| `gen_type` | `str` | type of generation (TEXT_TO_IMAGE, etc.) (optional) | +| `format` | `str` | image format/aspect ratio (optional) | +| `instructions` | `str` | additional instructions for generation (optional) | +| `character_name` | `str` | name of character for character-specific generation (optional) | +| `reference_assets` | `list` | list of reference asset IDs (optional) | +| `callback` | `function` | callback function to run after generation (optional) | +| `extra_config` | `dict` | additional configuration dict (optional) | +| `asset_attachment_context` | `asset_attachment_context` | controls automatic asset attachment behavior (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `generation_request` | `visual/generation_request` | the created generation request object | +| `prompt` | `visual/prompt` | the visual prompt (passed through) | +| `vis_type` | `str` | visual type (passed through) | +| `format` | `str` | format type (passed through) | +| `character_name` | `str` | character name (passed through) | +| `reference_assets` | `list` | reference assets list (passed through) | +| `gen_type` | `str` | generation type (passed through) | +| `extra_config` | `dict` | extra config dict (passed through) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `vis_type` | `str` | `"UNSPECIFIED"` | The type of visual to generate. Choices: `UNSPECIFIED`, `CHARACTER_CARD`, `CHARACTER_PORTRAIT`, `SCENE_CARD`, `SCENE_BACKGROUND`, `SCENE_ILLUSTRATION`, `OBJECT_ILLUSTRATION`. | +| `gen_type` | `str` | `"TEXT_TO_IMAGE"` | The type of generation to perform. Choices: `TEXT_TO_IMAGE`, `IMAGE_EDIT`, `UPLOAD`. | +| `format` | `str` | `"LANDSCAPE"` | The format of the visual to generate. Choices: `LANDSCAPE`, `PORTRAIT`, `SQUARE`. | +| `character_name` | `str` | `""` | The name of the character to generate | +| `instructions` | `text` | `""` | The instructions for the generation request | +| `extra_config` | `dict` | `{}` | The extra configuration for the generation request | + +## Select Backend + +`agents/visual/SelectBackend` + +Determines which visual backend and generation type to use for a request. +Selects the image-edit backend (gen_type IMAGE_EDIT) when reference assets +are provided and image editing is available, or when image generation is +unavailable but editing is; otherwise selects the image generation backend +(gen_type TEXT_TO_IMAGE). Also resolves the prompt type the selected +backend expects and the image format implied by the visual type. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | Graph state | +| `vis_type` | `str` | (optional) The type of visual to generate | +| `reference_assets` | `list` | (optional) List of reference asset IDs; when set, steers selection toward the image-edit backend | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | Graph state (passed through) | +| `backend_name` | `str` | Name of the selected backend (empty if none is available) | +| `gen_type` | `str` | The selected generation type (TEXT_TO_IMAGE or IMAGE_EDIT) | +| `vis_type` | `str` | The visual type (passed through) | +| `prompt_type` | `str` | Prompt type the selected backend expects (falls back to the agent's fallback prompt type when no backend is available) | +| `format` | `str` | Image format derived from the visual type (e.g. PORTRAIT) | +| `reference_assets` | `list` | The reference assets list (passed through) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `vis_type` | `str` | `"UNSPECIFIED"` | The type of visual to generate. Choices: `UNSPECIFIED`, `CHARACTER_CARD`, `CHARACTER_PORTRAIT`, `SCENE_CARD`, `SCENE_BACKGROUND`, `SCENE_ILLUSTRATION`, `OBJECT_ILLUSTRATION`. | + +## Unpack Visual Generation Request + +`agents/visual/UnpackGenerationRequest` + +Unpacks a visual generation request into its individual fields. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `generation_request` | `visual/generation_request` | The generation request to unpack | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `generation_request` | `visual/generation_request` | The generation request, passed through | +| `prompt` | `str` | The positive prompt string | +| `vis_type` | `str` | The visual type | +| `format` | `str` | The image format | +| `character_name` | `str` | The character name | +| `reference_assets` | `list` | The list of reference asset IDs | +| `gen_type` | `str` | The generation type | +| `extra_config` | `dict` | The extra configuration dict | +| `asset_attachment_context` | `asset_attachment_context` | The asset attachment context | + +## Unpack Visual Generation Response + +`agents/visual/UnpackGenerationResponse` + +Unpacks a visual generation response into its individual fields. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `generation_response` | `visual/generation_response` | The generation response to unpack | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `generation_response` | `visual/generation_response` | The generation response, passed through | +| `base64` | `str` | The generated image as base64 encoded data | +| `image_data` | `str` | The generated image as a data URI | +| `id` | `str` | The generation's ID | +| `backend_name` | `str` | The name of the backend that generated the image | +| `request` | `visual/generation_request` | The generation request that produced this response | diff --git a/docs/user-guide/node-editor/reference/nodes/agents-visual-modules.md b/docs/user-guide/node-editor/reference/nodes/agents-visual-modules.md new file mode 100644 index 00000000..94fca51f --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/agents-visual-modules.md @@ -0,0 +1,279 @@ +# Visualizer Agent Modules + +Ready-made visual generation flows built on the visualizer [prompt](agents-visual.md) and [generation](agents-visual-generation.md) nodes: generate visual assets, avatars, portraits, image edits and regenerations, plus the visualizer's websocket handlers. + + + + +_10 nodes._ + +| Node | Registry path | +| --- | --- | +| [Determine Visual References](#determine-visual-references) | `agents/visual/determineVisualReferences` | +| [Generate Avatar](#generate-avatar) | `agents/visual/generateAvatar` | +| [Generate Character Portrait Legacy](#generate-character-portrait-legacy) | `agents/visual/GenerateCharacterPortrait` | +| [Generate Image Edit](#generate-image-edit) | `agents/visual/generateImageEdit` | +| [Generate Visual Asset](#generate-visual-asset) | `agents/visual/generateVisualAsset` | +| [Regenerate Visual Asset](#regenerate-visual-asset) | `agents/visual/regenerateVisualAsset` | +| [Set Cover Image From Generation](#set-cover-image-from-generation) | `agents/visual/setCoverImageFromGeneration` | +| [Wsh Generate Visual Asset Tags](#wsh-generate-visual-asset-tags) | `agents/visual/wshGenerateVisualAssetTags` | +| [Wsh Regenerate Visual Asset](#wsh-regenerate-visual-asset) | `agents/visual/wshRegenerateVisualAsset` | +| [Wsh Visualize](#wsh-visualize) | `agents/visual/wshVisualize` | + +## Determine Visual References + +`agents/visual/determineVisualReferences` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/visual/modules/determine-visual-references.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `prompt` | `str` | | +| `vis_type` | `str` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `prompt` | `str` | | +| `references` | `list` | | +| `vis_type` | `str` | | +| `asset_ids` | `list` | | + +## Generate Avatar + +`agents/visual/generateAvatar` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/visual/modules/generate-avatar.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `character` | `character` | | +| `instructions` | `str` | | +| `set_current_avatar` | `bool` | | +| `set_default_avatar` | `bool` | | +| `tags` | `list` | (optional) | +| `asset_name` | `str` | (optional) | +| `asset_ctx` | `asset_attachment_context` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `character` | `character` | | +| `instructions` | `str` | | +| `set_current_avatar` | `bool` | | +| `set_default_avatar` | `bool` | | +| `tags` | `list` | | +| `asset_name` | `str` | | +| `asset_ctx` | `asset_attachment_context` | | +| `generation_request` | `visual/generation_request` | | + +## Generate Character Portrait Legacy + +`agents/visual/GenerateCharacterPortrait` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/visual/modules/generate-character-portrait-legacy.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `character` | `any` | | +| `instructions` | `str` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `character` | `any` | | +| `instructions` | `str` | | + +## Generate Image Edit + +`agents/visual/generateImageEdit` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/visual/modules/generate-image-edit.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `instructions` | `str` | | +| `asset_id` | `str` | | +| `callback` | `function` | (optional) | +| `asset_ctx` | `asset_attachment_context` | (optional) | +| `vis_type` | `str` | (optional) | +| `format` | `str` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `instructions` | `str` | | +| `asset_id` | `str` | | +| `asset_ctx` | `asset_attachment_context` | | +| `vis_type` | `str` | | +| `format` | `str` | | +| `generation_request` | `visual/generation_request` | | +| `generation_response` | `visual/generation_response` | | + +## Generate Visual Asset + +`agents/visual/generateVisualAsset` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/visual/modules/generate-visual-asset.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `character` | `character` | (optional) | +| `vis_type` | `str` | | +| `instructions` | `str` | (optional) | +| `prompt_only` | `bool` | (optional) | +| `callback` | `function` | (optional) | +| `asset_ctx` | `asset_attachment_context` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `character` | `character` | | +| `vis_type` | `str` | | +| `instructions` | `str` | | +| `prompt_only` | `bool` | | +| `callback` | `function` | | +| `asset_ctx` | `asset_attachment_context` | | +| `prompt` | `str` | | +| `visual_prompt` | `visual/prompt` | | +| `generation_request` | `visual/generation_request` | | + +## Regenerate Visual Asset + +`agents/visual/regenerateVisualAsset` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/visual/modules/regenerate-visual-asset.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `asset_id` | `str` | | +| `asset_ctx` | `asset_attachment_context` | (optional) | +| `instructions` | `str` | (optional) | +| `callback` | `function` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `asset_id` | `str` | | +| `asset_ctx` | `asset_attachment_context` | | +| `instructions` | `str` | | +| `callback` | `function` | | + +## Set Cover Image From Generation + +`agents/visual/setCoverImageFromGeneration` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/visual/modules/set-cover-image-from-generation.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `generation_response` | `visual/generation_response` | | +| `reference_for` | `list` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `generation_response` | `visual/generation_response` | | +| `asset_id` | `str` | | +| `reference_for` | `list` | | + +## Wsh Generate Visual Asset Tags + +`agents/visual/wshGenerateVisualAssetTags` + +Node module (base type `agents/AgentWebsocketHandler`) defined in `src/talemate/agents/visual/modules/wsh-generate-visual-asset-tags.json`. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `fn` | `function` | | +| `name` | `str` | | +| `allow_multiple_calls` | `bool` | | +| `ai_callback` | `focal/callback` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `agent` | `str` | `"visual"` | The agent to register the handler on. Choices are generated at runtime. | +| `name` | `str` | `"generate_tags"` | The name of the handler | + +## Wsh Regenerate Visual Asset + +`agents/visual/wshRegenerateVisualAsset` + +Node module (base type `agents/AgentWebsocketHandler`) defined in `src/talemate/agents/visual/modules/wsh-regenerate-visual-asset.json`. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `fn` | `function` | | +| `name` | `str` | | +| `allow_multiple_calls` | `bool` | | +| `ai_callback` | `focal/callback` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `"revisualize"` | The name of the handler | +| `agent` | `str` | `"visual"` | The agent to register the handler on. Choices are generated at runtime. | + +## Wsh Visualize + +`agents/visual/wshVisualize` + +Node module (base type `agents/AgentWebsocketHandler`) defined in `src/talemate/agents/visual/modules/wsh-visualize.json`. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `fn` | `function` | | +| `name` | `str` | | +| `allow_multiple_calls` | `bool` | | +| `ai_callback` | `focal/callback` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `agent` | `str` | `"visual"` | The agent to register the handler on. Choices are generated at runtime. | +| `name` | `str` | `"visualize"` | The name of the handler | diff --git a/docs/user-guide/node-editor/reference/nodes/agents-visual.md b/docs/user-guide/node-editor/reference/nodes/agents-visual.md new file mode 100644 index 00000000..2d94d989 --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/agents-visual.md @@ -0,0 +1,239 @@ +# Visualizer Agent Prompt Nodes + +Build visual prompts: assemble prompt parts, apply styles, finalize prompts and unpack them into compiled positive/negative prompt strings. Generation itself happens through the [generation nodes](agents-visual-generation.md), and ready-made flows live in [Visualizer Agent Modules](agents-visual-modules.md). + +!!! note "Apply Style behavior change in 0.39.0" + [Apply Style](#apply-style) now resolves its `template_id` directly to a style template. Passing the literal `UNSPECIFIED` as the template id no longer applies the configured art style — it applies nothing. For configuration-driven styles (the active art style, plus the subject style for a `vis_type`), use [Apply Styles](#apply-styles) instead. + + + + +_8 nodes._ + +| Node | Registry path | +| --- | --- | +| [Apply Style](#apply-style) | `agents/visual/ApplyStyle` | +| [Apply Styles](#apply-styles) | `agents/visual/ApplyStyles` | +| [Visual Enum Values](#visual-enum-values) | `agents/visual/EnumValues` | +| [Finalize Prompt](#finalize-prompt) | `agents/visual/FinalizePrompt` | +| [Visual Prompt](#visual-prompt) | `agents/visual/Prompt` | +| [Visual Prompt Part](#visual-prompt-part) | `agents/visual/PromptPart` | +| [Visual Settings](#visual-settings) | `agents/visual/Settings` | +| [Unpack Visual Prompt](#unpack-visual-prompt) | `agents/visual/UnpackPrompt` | + +## Apply Style + +`agents/visual/ApplyStyle` + +Applies a specific style template (by template id) to a visual prompt, +inserting it at the front of the prompt's part list and modifying the +prompt in place. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `prompt` | `visual/prompt` | The visual prompt to apply the style to | +| `template_id` | `str` | The id of the style template to apply | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state, passed through | +| `prompt` | `visual/prompt` | The prompt with the style applied | +| `template_id` | `str` | The template id, passed through | +| `prompt_part` | `visual/prompt_part` | The prompt part created from the style template (None if the template was not found) | + +## Apply Styles + +`agents/visual/ApplyStyles` + +Applies the configured style templates to a visual prompt: the active +art style plus the subject style matching the given vis_type. Matching +styles are inserted at the front of the prompt's part list, modifying +the prompt in place. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `prompt` | `visual/prompt` | The visual prompt to apply styles to | +| `vis_type` | `str` | The type of visual to apply styles for (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state, passed through | +| `prompt` | `visual/prompt` | The prompt with the styles applied | +| `vis_type` | `str` | The vis_type, passed through | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `vis_type` | `str` | `"UNSPECIFIED"` | The type of visual to apply styles to. Choices: `UNSPECIFIED`, `CHARACTER_CARD`, `CHARACTER_PORTRAIT`, `SCENE_CARD`, `SCENE_BACKGROUND`, `SCENE_ILLUSTRATION`, `OBJECT_ILLUSTRATION`. | + +## Visual Enum Values + +`agents/visual/EnumValues` + +Returns the possible values of one of the visual agent's enums +(VIS_TYPE, GEN_TYPE, FORMAT_TYPE or PROMPT_TYPE), selected via the +enum property. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `values` | `list` | The list of the enum's values | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `enum` | `str` | `"VIS_TYPE"` | The enum to get the values of. Choices: `VIS_TYPE`, `GEN_TYPE`, `FORMAT_TYPE`, `PROMPT_TYPE`. | + +## Finalize Prompt + +`agents/visual/FinalizePrompt` + +Applies the visualizer's prompt finalization (post-processing actions) +to a positive / negative prompt string pair. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | graph state (required — wire through any gating switch so the node is skipped when its output would be discarded) | +| `positive_prompt` | `str` | positive prompt string (required) | +| `negative_prompt` | `str` | negative prompt string (optional) | +| `vis_type` | `str` | type of visual the prompts are for (optional) | +| `character_name` | `str` | character the prompts involve, enables character level finalizers (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | graph state (passed through) | +| `positive_prompt` | `str` | finalized positive prompt | +| `negative_prompt` | `str` | finalized negative prompt | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `vis_type` | `str` | `"UNSPECIFIED"` | The type of visual the prompts are for. Choices: `UNSPECIFIED`, `CHARACTER_CARD`, `CHARACTER_PORTRAIT`, `SCENE_CARD`, `SCENE_BACKGROUND`, `SCENE_ILLUSTRATION`, `OBJECT_ILLUSTRATION`. | + +## Visual Prompt + +`agents/visual/Prompt` + +Creates a visual prompt from a list of prompt parts. The prompt_type +controls how the parts are compiled into the final positive / negative +prompt strings (comma-separated keywords or descriptive prose). + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `prompt_type` | `str` | (optional) The type of prompt to create (KEYWORDS or DESCRIPTIVE) | +| `parts` | `list` | (optional) List of visual prompt parts to combine | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `prompt` | `visual/prompt` | The created visual prompt | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `prompt_type` | `str` | `"KEYWORDS"` | The type of prompt to create. Choices: `KEYWORDS`, `DESCRIPTIVE`. | + +## Visual Prompt Part + +`agents/visual/PromptPart` + +Creates a visual prompt part from instructions, keyword lists and +descriptive text. Prompt parts are combined into a visual prompt (via the +Prompt node), which compiles the final positive / negative prompts from +them. Keyword inputs given as a single string are split on ", ", and +positive keywords prefixed with "no " (e.g. "no glasses") are treated as +implied negative keywords. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `instructions` | `str` | (optional) Free-form instructions for the prompt part | +| `positive_keywords_raw` | `list,str` | (optional) Positive keywords as a list or comma-separated string | +| `negative_keywords_raw` | `list,str` | (optional) Negative keywords as a list or comma-separated string | +| `positive_descriptive` | `str` | (optional) Descriptive (prose) positive prompt text | +| `negative_descriptive` | `str` | (optional) Descriptive (prose) negative prompt text | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `prompt_part` | `visual/prompt_part` | The created visual prompt part | +| `instructions` | `str` | The instructions (passed through) | +| `positive_keywords_raw` | `list` | Raw positive keywords, including any "no ..." entries | +| `negative_keywords_raw` | `list` | Raw negative keywords (passed through) | +| `implied_negative_keywords` | `list` | Keywords derived from "no ..." positive keywords | +| `positive_keywords` | `list` | Positive keywords with "no ..." entries removed | +| `negative_keywords` | `list` | Raw negative keywords plus the implied negative keywords | +| `positive_descriptive` | `str` | Descriptive positive text (passed through) | +| `negative_descriptive` | `str` | Descriptive negative text (passed through) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `instructions` | `text` | `""` | The instructions for the prompt part | +| `positive_keywords_raw` | `list` | `[]` | The positive keywords for the prompt part | +| `negative_keywords_raw` | `list` | `[]` | The negative keywords for the prompt part | +| `positive_descriptive` | `text` | `""` | The positive descriptive for the prompt part | +| `negative_descriptive` | `text` | `""` | The negative descriptive for the prompt part | + +## Visual Settings + +`agents/visual/Settings` + +Base node to render visual agent settings. + +Every setting of the visual agent is exposed as an output socket named after the setting - see the agent's documentation under [Agents](../../../agents/index.md) for what each setting does. + +## Unpack Visual Prompt + +`agents/visual/UnpackPrompt` + +Unpacks a visual prompt into its parts and the compiled prompt strings. +Use this to access the final positive / negative prompts (built according +to the prompt's prompt_type) or the keyword-only / descriptive-only +variants regardless of the prompt type. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `prompt` | `visual/prompt` | The visual prompt to unpack | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `prompt` | `visual/prompt` | The visual prompt (passed through) | +| `parts` | `list` | The prompt's list of prompt parts | +| `prompt_type` | `str` | The prompt type (KEYWORDS or DESCRIPTIVE) | +| `instructions` | `str` | Combined instructions from all parts | +| `positive_prompt` | `str` | Positive prompt compiled according to prompt_type | +| `negative_prompt` | `str` | Negative prompt compiled according to prompt_type | +| `positive_prompt_keywords` | `str` | Positive prompt compiled as comma-separated keywords | +| `negative_prompt_keywords` | `str` | Negative prompt compiled as comma-separated keywords | +| `positive_prompt_descriptive` | `str` | Positive prompt compiled as descriptive text | +| `negative_prompt_descriptive` | `str` | Negative prompt compiled as descriptive text | diff --git a/docs/user-guide/node-editor/reference/nodes/agents-world-state.md b/docs/user-guide/node-editor/reference/nodes/agents-world-state.md new file mode 100644 index 00000000..56d4aa13 --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/agents-world-state.md @@ -0,0 +1,345 @@ +# World State Agent Nodes + +World state operations through the world state agent: advance time, run character progression, evaluate queries, extract character sheets and emit the world state to the frontend. + + + + +_13 nodes._ + +| Node | Registry path | +| --- | --- | +| [Advance Time](#advance-time) | `agents/world_state/AdvanceTime` | +| [Character Progression](#character-progression) | `agents/world_state/CharacterProgression` | +| [Deactivate Character](#deactivate-character) | `agents/world_state/DeactivateCharacter` | +| [Determine Character Avatar](#determine-character-avatar) | `agents/world_state/determineCharacterAvatar` | +| [Determine Character Presence](#determine-character-presence) | `agents/world_state/DetermineCharacterPresence` | +| [Emit World State](#emit-world-state) | `agents/world_state/EmitWorldState` | +| [Evaluate Query](#evaluate-query) | `agents/world_state/EvaluateQuery` | +| [Extract Character Sheet](#extract-character-sheet) | `agents/world_state/ExtractCharacterSheet` | +| [Request World State](#request-world-state) | `agents/world_state/RequestWorldState` | +| [Worldstate Settings](#worldstate-settings) | `agents/world_state/Settings` | +| [State Reinforcement](#state-reinforcement) | `agents/world_state/StateReinforcement` | +| [Wsh Advance Time](#wsh-advance-time) | `agents/world_state/wshAdvanceTime` | +| [Wsh Determine Avatar](#wsh-determine-avatar) | `agents/world_state/wshDetermineAvatar` | + +## Advance Time + +`agents/world_state/AdvanceTime` + +Advances the scene time by the given ISO 8601 duration. Pushes a +TimePassageMessage to the scene history, emits it to the UI and fires +the time passage signal (which other agents may react to, e.g. to +narrate the time passage using the narration instructions). + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `duration` | `str` | The duration to advance as an ISO 8601 duration (e.g. "PT1H") | +| `narration_instructions` | `str` | Instructions for narrating the time passage (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `duration` | `str` | The duration of the emitted message | +| `narration_instructions` | `str` | The narration instructions, passed through | +| `message` | `message` | The TimePassageMessage that was added to the history | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `narration_instructions` | `str` | `""` | The instructions for the narration | +| `duration` | `str` | `"P0T1S"` | The duration to advance | + +## Character Progression + +`agents/world_state/CharacterProgression` + +Determines character development (new, updated or removed attributes, +description updates) via the world_state agent and processes the +resulting proposals. With as_suggestions enabled the proposals are +added to the world state manager as suggestions for the user to +review; otherwise they are applied to the character directly. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `character` | `character` | The character to progress | +| `instructions` | `str` | (optional) Instructions guiding the development (required at runtime despite the optional socket) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `calls` | `list` | The list of proposal calls that were generated | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `as_suggestions` | `bool` | `False` | Whether to return the result as suggestions | +| `instructions` | `text` | `""` | Instructions for the character progression | + +## Deactivate Character + +`agents/world_state/DeactivateCharacter` + +Deactivates a character from the world state. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The current state of the graph | +| `character` | `character` | The character to deactivate | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The updated state | + +## Determine Character Avatar + +`agents/world_state/determineCharacterAvatar` + +Node module (base type `core/Graph`) defined in `src/talemate/agents/world_state/modules/determine-character-avatar.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `character` | `character` | | +| `content` | `str` | | +| `force_regenerate` | `bool` | (optional) | +| `force_determine` | `bool` | (optional) | +| `asset_ctx` | `asset_attachment_context` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `character` | `character` | | +| `content` | `str` | | +| `force_regenerate` | `bool` | | +| `asset_id` | `str` | | +| `force_determine` | `bool` | | +| `asset_ctx` | `asset_attachment_context` | | + +## Determine Character Presence + +`agents/world_state/DetermineCharacterPresence` + +Determines whether a character is present (and active) or leaving the current scene. + +Mode is controlled via the `check` property: +- present: calls world_state.is_character_present(character.name) +- leaving: calls world_state.is_character_leaving(character.name) + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The current graph state | +| `character` | `character` | The character to check | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `yes` | `bool` | True if the condition is met | +| `no` | `bool` | True if the condition is not met | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `check` | `str` | `"present"` | Which condition to check. Choices: `present`, `leaving`. | + +## Emit World State + +`agents/world_state/EmitWorldState` + +Re-emits the scene's current world state to the frontend so the world +state UI refreshes. Does not regenerate or update the world state. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | + +## Evaluate Query + +`agents/world_state/EvaluateQuery` + +Evaluates a query on the world state. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The current state of the graph | +| `query` | `str` | The query to evaluate | +| `context` | `str` | The context to evaluate the query in | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The current state | +| `result` | `bool` | The result of the query | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `query` | `str` | `unset` | The query to evaluate | + +## Extract Character Sheet + +`agents/world_state/ExtractCharacterSheet` + +Attempts to extract an attribute based character sheet +from a given context for a specific character. + +Additionally alteration instructions can be given to +modify the character's existing sheet. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The current state of the graph | +| `character_name` | `str` | The name of the character to extract the sheet for | +| `context` | `str` | The context to extract the sheet from | +| `alteration_instructions` | `str` | (optional) Instructions to alter the character's sheet | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `character_sheet` | `dict` | The extracted character sheet (dict, empty if the model produced nothing) | + +## Request World State + +`agents/world_state/RequestWorldState` + +Requests the current world state. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The current state of the graph | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The current state | +| `world_state` | `dict` | The current world state | + +## Worldstate Settings + +`agents/world_state/Settings` + +Base node to render world_state agent settings. + +Every setting of the world_state agent is exposed as an output socket named after the setting - see the agent's documentation under [Agents](../../../agents/index.md) for what each setting does. + +## State Reinforcement + +`agents/world_state/StateReinforcement` + +Sets up (or updates) a tracked state reinforcement for a character or +the world in general. Adds the reinforcement to the scene's world +state, then immediately runs an update, producing a reinforcement +message. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The current state of the graph | +| `query_or_detail` | `str` | The question or detail to track | +| `character` | `character` | (optional) The character to track the state for (optional; when omitted the state is tracked for the world in general) | +| `instructions` | `str` | Additional instructions for the reinforcement (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | graph state | +| `message` | `message` | state reinforcement message | +| `reinforcement` | `world_state/reinforcement` | the reinforcement that was added or updated | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `query_or_detail` | `str` | `""` | Query or detail to reinforce | +| `instructions` | `text` | `""` | Instructions for the reinforcement | +| `interval` | `int` | `10` | Interval for reinforcement. Min: 1. | +| `insert_method` | `str` | `"sequential"` | Method to insert reinforcement. Choices: `sequential`, `conversation-context`, `all-context`, `never`. | +| `reset` | `bool` | `False` | If the state should be reset | + +## Wsh Advance Time + +`agents/world_state/wshAdvanceTime` + +Node module (base type `agents/AgentWebsocketHandler`) defined in `src/talemate/agents/world_state/modules/wsh-advance-time.json`. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `fn` | `function` | | +| `name` | `str` | | +| `allow_multiple_calls` | `bool` | | +| `ai_callback` | `focal/callback` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `"advance_time"` | The name of the handler | +| `agent` | `str` | `"world_state"` | The agent to register the handler on. Choices are generated at runtime. | + +## Wsh Determine Avatar + +`agents/world_state/wshDetermineAvatar` + +Node module (base type `agents/AgentWebsocketHandler`) defined in `src/talemate/agents/world_state/modules/wsh-determine-avatar.json`. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `fn` | `function` | | +| `name` | `str` | | +| `allow_multiple_calls` | `bool` | | +| `ai_callback` | `focal/callback` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `"determine_avatar"` | The name of the handler | +| `agent` | `str` | `"world_state"` | The agent to register the handler on. Choices are generated at runtime. | diff --git a/docs/user-guide/node-editor/reference/nodes/agents.md b/docs/user-guide/node-editor/reference/nodes/agents.md new file mode 100644 index 00000000..ccf75998 --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/agents.md @@ -0,0 +1,318 @@ +# Agent Nodes + +Generic agent interaction available for every agent: fetch an agent instance, call agent functions (optionally conditionally), and read or count agent state variables. + + + + +_9 nodes._ + +| Node | Registry path | +| --- | --- | +| [Call Agent Function](#call-agent-function) | `agents/CallAgentFunction` | +| [Call Agent Function (Conditional)](#call-agent-function-conditional) | `agents/CallAgentFunctionConditional` | +| [Counter Agent State](#counter-agent-state) | `agents/CounterAgentState` | +| [Dynamic Instruction](#dynamic-instruction) | `agents/DynamicInstruction` | +| [Get Agent](#get-agent) | `agents/GetAgent` | +| [Get Agent State](#get-agent-state) | `agents/GetAgentState` | +| [Set Agent State](#set-agent-state) | `agents/SetAgentState` | +| [Toggle Agent Action](#toggle-agent-action) | `agents/ToggleAgentAction` | +| [Unset Agent State](#unset-agent-state) | `agents/UnsetAgentState` | + +## Call Agent Function + +`agents/CallAgentFunction` + +Call a function on an agent and return its result. + +The agent can be given as an agent instance or by name. The function is +looked up on the agent by name and called with the given arguments as +keyword arguments (coroutine functions are awaited). + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `agent` | `str,agent` | The agent (instance or name) to call the function on | +| `function_name` | `str` | The name of the function to call on the agent | +| `arguments` | `dict` | Dict of keyword arguments to pass to the function | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `result` | `any` | The return value of the function call | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `agent` | `str` | `""` | The agent to call the function on. Choices are generated at runtime. | +| `function_name` | `str` | `""` | The name of the function to call on the agent | +| `arguments` | `dict` | `{}` | The arguments to pass to the function | + +## Call Agent Function (Conditional) + +`agents/CallAgentFunctionConditional` + +Call a function on an agent and return its result. + +Provides a required `state` input causing the node to only run when a state is provided + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `agent` | `str,agent` | The agent (instance or name) to call the function on | +| `function_name` | `str` | The name of the function to call on the agent | +| `arguments` | `dict` | Dict of keyword arguments to pass to the function | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `result` | `any` | The return value of the function call | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `agent` | `str` | `""` | The agent to call the function on. Choices are generated at runtime. | +| `function_name` | `str` | `""` | The name of the function to call on the agent | +| `arguments` | `dict` | `{}` | The arguments to pass to the function | + +## Counter Agent State + +`agents/CounterAgentState` + +Increment a numeric variable in an agent's state and return the new value. + +Provides a required `state` input causing the node to only run when a state is provided + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `name` | `str` | (optional) The name of the counter variable | +| `reset` | `bool` | If true, the value will be reset to 0 (optional) | +| `reset_cap` | `number` | If set, the counter resets to 0 once it reaches this value (optional) | +| `agent` | `str,agent` | The agent (instance or name) whose state holds the counter | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `name` | `str` | The name that was used | +| `value` | `any` | The new value | +| `scope` | `str` | The scope that was used | +| `reset_cap` | `number` | The reset cap that was used | +| `reset` | `bool` | Whether the counter was reset | +| `new_cycle` | `bool` | True if the counter was at 0 before this run | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `unset` | The name of the variable to manipulate | +| `scope` | `str` | `"local"` | Which scope to manipulate. Choices: `scene`, `context`. | +| `increment` | `int` | `1` | increment | +| `reset` | `bool` | `False` | reset | +| `reset_cap` | `int` | `0` | reset_cap | +| `agent` | `str` | `unset` | The agent to manipulate the state on. Choices are generated at runtime. | + +## Dynamic Instruction + +`agents/DynamicInstruction` + +Create a dynamic instruction object to use for instruction injection +in event handlers. + +A header is required at runtime (raises an error if missing). List content +is joined with newlines. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `header` | `str` | (optional) The header (title) of the dynamic instruction | +| `content` | `str,list` | (optional) The content of the dynamic instruction (string or list of strings) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `dynamic_instruction` | `dynamic_instruction` | The dynamic instruction object | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `header` | `str` | `unset` | The header of the dynamic instruction | +| `content` | `text` | `unset` | The content of the dynamic instruction | + +## Get Agent + +`agents/GetAgent` + +Get an agent instance by name. + +Does nothing if no agent name is set; raises an error if the agent cannot +be found. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `agent` | `agent` | The agent instance | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `agent_name` | `str` | `""` | The name of the agent to get. Choices are generated at runtime. | + +## Get Agent State + +`agents/GetAgentState` + +Get a variable from an agent's state. + +The `scene` scope reads agent state stored with the scene, the `context` +scope reads the agent's context state. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `name` | `str` | (optional) the name of the variable to get | +| `default` | `any` | value to return if the variable is not set (optional) | +| `agent` | `str,agent` | the agent (instance or name) to read the state from | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `name` | `str` | the name that was retrieved | +| `value` | `any` | the value that was retrieved | +| `scope` | `str` | the scope that was retrieved | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `unset` | The name of the variable to manipulate | +| `scope` | `str` | `"local"` | Which scope to manipulate. Choices: `scene`, `context`. | +| `agent` | `str` | `unset` | The agent to manipulate the state on. Choices are generated at runtime. | + +## Set Agent State + +`agents/SetAgentState` + +Set a variable in an agent's state. + +The `scene` scope writes agent state stored with the scene, the `context` +scope writes to the agent's context state. + +Provides a required `state` input causing the node to only run when a state is provided + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `name` | `str` | (optional) the name of the variable to set | +| `value` | `any` | the value to set | +| `agent` | `str,agent` | the agent (instance or name) to set the state on | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `name` | `str` | the name that was set | +| `value` | `any` | the value that was set | +| `scope` | `str` | the scope that was used | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `unset` | The name of the variable to manipulate | +| `scope` | `str` | `"local"` | Which scope to manipulate. Choices: `scene`, `context`. | +| `agent` | `str` | `unset` | The agent to manipulate the state on. Choices are generated at runtime. | + +## Toggle Agent Action + +`agents/ToggleAgentAction` + +Allows disabling or enabling an agent action that can be disabled + +Raises an error if the agent or the action cannot be found, or if the +action is one that cannot be disabled — those are always enabled and +are not togglable from a graph. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `agent` | `str,agent` | (optional) The agent (instance or name) to toggle the action on | +| `action_name` | `str` | (optional) The name of the action to toggle | +| `enabled` | `bool` | (optional) Whether to enable or disable the action | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `agent` | `agent` | The resolved agent instance | +| `action_name` | `str` | The action name, passed through | +| `enabled` | `bool` | The action's effective enabled state after the write — when a scene override is active it takes the write, so this reflects the override rather than the agent's global setting | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `agent` | `str` | `""` | The agent to toggle the action on. Choices are generated at runtime. | +| `action_name` | `str` | `""` | The name of the action to toggle | +| `enabled` | `bool` | `True` | Whether to enable or disable the action | + +## Unset Agent State + +`agents/UnsetAgentState` + +Unset a variable in an agent's state. + +The `scene` scope removes agent state stored with the scene, the `context` +scope removes from the agent's context state. + +Provides a required `state` input causing the node to only run when a state is provided + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `name` | `str` | (optional) the name of the variable to unset | +| `agent` | `str,agent` | the agent (instance or name) to unset the state on | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `name` | `str` | the name that was unset | +| `value` | `any` | the value that was unset | +| `scope` | `str` | the scope that was used | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `unset` | The name of the variable to manipulate | +| `scope` | `str` | `"local"` | Which scope to manipulate. Choices: `scene`, `context`. | +| `agent` | `str` | `unset` | The agent to manipulate the state on. Choices are generated at runtime. | diff --git a/docs/user-guide/node-editor/reference/nodes/assets-meta.md b/docs/user-guide/node-editor/reference/nodes/assets-meta.md new file mode 100644 index 00000000..1b546e27 --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/assets-meta.md @@ -0,0 +1,288 @@ +# Asset Meta And Selection Nodes + +Asset metadata and selection: search assets, build selection contexts, and construct attachment contexts for passing assets into generation requests. + + + + +_6 nodes._ + +| Node | Registry path | +| --- | --- | +| [Make Asset Attachment Context](#make-asset-attachment-context) | `assets/MakeAssetAttachmentContext` | +| [Make Asset Meta](#make-asset-meta) | `assets/MakeAssetMeta` | +| [Search Assets](#search-assets) | `assets/SearchAssets` | +| [Select Assets](#select-assets) | `assets/SelectAssets` | +| [Unpack Asset Meta](#unpack-asset-meta) | `assets/UnpackAssetMeta` | +| [Unpack Asset Selection Context](#unpack-asset-selection-context) | `assets/UnpackAssetSelectionContext` | + +## Make Asset Attachment Context + +`assets/MakeAssetAttachmentContext` + +Create an asset attachment context object. + +This controls how assets are automatically attached to messages when created. + +Note: +- When message_ids is empty, assets will be attached to the most recent appropriate message +- When message_ids is provided, assets will only be attached to those specific messages +- Use allow_override=true to replace existing message assets, false to skip messages that already have assets +- Use delete_old=true to delete the old asset when replacing (requires allow_override=true) + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `asset_name` | `str` | (optional) name to save the asset under | +| `tags` | `list` | (optional) list of tags to add to the asset | +| `allow_auto_attach` | `bool` | (optional) whether to allow automatic attachment of assets to messages | +| `allow_override` | `bool` | (optional) whether to allow overriding existing message assets | +| `delete_old` | `bool` | (optional) whether to delete the old asset when replacing a message's asset | +| `message_ids` | `list` | (optional) list of specific message IDs to attach to (empty = auto-detect last message) | +| `scene_cover` | `bool` | (optional) whether to set the scene cover image | +| `character_cover` | `bool` | (optional) whether to set the character cover image | +| `override_scene_cover` | `bool` | (optional) whether to override an existing scene cover image (requires scene_cover=true) | +| `override_character_cover` | `bool` | (optional) whether to override an existing character cover image (requires character_cover=true) | +| `default_avatar` | `bool` | (optional) whether to set the default avatar image | +| `current_avatar` | `bool` | (optional) whether to set the current avatar image | +| `override_default_avatar` | `bool` | (optional) whether to override the default avatar image (requires default_avatar=true) | +| `override_current_avatar` | `bool` | (optional) whether to override the current avatar image (requires current_avatar=true) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `context` | `asset_attachment_context` | the asset attachment context object | +| `asset_name` | `str` | the asset name (passed through) | +| `tags` | `list` | list of tags (passed through) | +| `allow_auto_attach` | `bool` | whether to allow automatic attachment (passed through) | +| `allow_override` | `bool` | whether to allow overriding existing assets (passed through) | +| `delete_old` | `bool` | whether to delete old assets (passed through) | +| `message_ids` | `list` | list of message IDs (passed through) | +| `scene_cover` | `bool` | scene cover flag (passed through) | +| `character_cover` | `bool` | character cover flag (passed through) | +| `override_scene_cover` | `bool` | scene cover override flag (passed through) | +| `override_character_cover` | `bool` | character cover override flag (passed through) | +| `default_avatar` | `bool` | default avatar flag (passed through) | +| `current_avatar` | `bool` | current avatar flag (passed through) | +| `override_default_avatar` | `bool` | default avatar override flag (passed through) | +| `override_current_avatar` | `bool` | current avatar override flag (passed through) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `asset_name` | `str` | `""` | Name of the asset to save | +| `tags` | `list` | `[]` | List of tags to add to the asset | +| `allow_auto_attach` | `bool` | `False` | Allow automatic attachment of assets to messages | +| `allow_override` | `bool` | `False` | Allow overriding existing message assets | +| `delete_old` | `bool` | `False` | Delete old asset when replacing (requires allow_override=true) | +| `message_ids` | `list` | `[]` | List of message IDs to attach to (empty = auto-detect) | +| `scene_cover` | `bool` | `False` | Whether to set the scene cover image | +| `character_cover` | `bool` | `False` | Whether to set the character cover image | +| `override_scene_cover` | `bool` | `False` | Whether to override the scene cover image (requires scene_cover=true) | +| `override_character_cover` | `bool` | `False` | Whether to override the character cover image (requires character_cover=true) | +| `default_avatar` | `bool` | `False` | Whether to set the default avatar image | +| `current_avatar` | `bool` | `False` | Whether to set the current avatar image | +| `override_default_avatar` | `bool` | `False` | Whether to override the default avatar image (requires default_avatar=true) | +| `override_current_avatar` | `bool` | `False` | Whether to override the current avatar image (requires current_avatar=true) | + +## Make Asset Meta + +`assets/MakeAssetMeta` + +Create an asset metadata object. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `name` | `str` | (optional) asset name | +| `vis_type` | `any` | (optional) visual type | +| `gen_type` | `any` | (optional) generation type | +| `character_name` | `str` | (optional) character name | +| `prompt` | `str` | (optional) prompt text | +| `negative_prompt` | `str` | (optional) negative prompt text | +| `sampler_settings` | `any` | (optional) sampler settings object | +| `reference_assets` | `list` | (optional) list of reference asset IDs | +| `tags` | `list` | (optional) list of tags | +| `analysis` | `str` | (optional) analysis prompt text | +| `reference` | `list` | (optional) list of vis_types that this asset may be used as a reference for | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `meta` | `asset_meta` | the asset metadata object | +| `name` | `str` | asset name (passed through) | +| `vis_type` | `any` | visual type (passed through) | +| `gen_type` | `any` | generation type (passed through) | +| `format` | `any` | recommended format based on vis_type (PORTRAIT, LANDSCAPE, or SQUARE) | +| `character_name` | `str` | character name (passed through) | +| `prompt` | `str` | prompt text (passed through) | +| `negative_prompt` | `str` | negative prompt text (passed through) | +| `sampler_settings` | `any` | sampler settings object (passed through) | +| `reference_assets` | `list` | list of reference asset IDs (passed through) | +| `tags` | `list` | list of tags (passed through) | +| `analysis` | `str` | analysis prompt text (passed through) | +| `reference` | `list` | list of vis_types that this asset may be used as a reference for (passed through) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `""` | Asset name | +| `vis_type` | `str` | `""` | Visual type. Choices: `UNSPECIFIED`, `CHARACTER_CARD`, `CHARACTER_PORTRAIT`, `SCENE_CARD`, `SCENE_BACKGROUND`, `SCENE_ILLUSTRATION`, `OBJECT_ILLUSTRATION`. | +| `gen_type` | `str` | `""` | Generation type. Choices: `TEXT_TO_IMAGE`, `IMAGE_EDIT`, `UPLOAD`. | +| `character_name` | `str` | `""` | Character name | +| `prompt` | `text` | `""` | Prompt text | +| `negative_prompt` | `text` | `""` | Negative prompt text | +| `reference_assets` | `list` | `[]` | List of reference asset IDs | +| `tags` | `list` | `[]` | List of tags | +| `analysis` | `text` | `""` | Analysis prompt | +| `reference` | `list` | `[]` | List of vis_types that this asset may be used as a reference for | + +## Search Assets + +`assets/SearchAssets` + +Search assets by vis_type, character_name, tags, or reference vis_types. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `vis_type` | `any` | (optional) visual type to filter by (can be a single value or a list of values) | +| `character_name` | `str` | (optional) character name to filter by (case insensitive) | +| `tags` | `list` | (optional) list of tags to filter by (case insensitive) | +| `reference_vis_types` | `list` | (optional) list of vis_types to filter by. Only return assets that have at least one matching vis_type in their reference list | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `asset_ids` | `list` | list of matching asset IDs | +| `asset_count` | `int` | number of matching assets | +| `vis_type` | `any` | visual type filter (passed through) | +| `character_name` | `str` | character name filter (passed through) | +| `tags` | `list` | tags filter (passed through) | +| `reference_vis_types` | `list` | reference vis_types filter (passed through) | +| `tag_match_mode` | `str` | tag match mode (passed through) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `vis_type` | `str` | `""` | Visual type to filter by. Choices: `UNSPECIFIED`, `CHARACTER_CARD`, `CHARACTER_PORTRAIT`, `SCENE_CARD`, `SCENE_BACKGROUND`, `SCENE_ILLUSTRATION`, `OBJECT_ILLUSTRATION`, `ANY`. | +| `character_name` | `str` | `""` | Character name to filter by (case insensitive) | +| `tags` | `list` | `[]` | List of tags to filter by (case insensitive) | +| `reference_vis_types` | `list` | `[]` | List of vis_types to filter by. Only return assets that have at least one matching vis_type in their reference list | +| `tag_match_mode` | `str` | `"all"` | How to match tags: 'all' (must have all), 'any' (must have at least one), 'none' (must not have any). Choices: `all`, `any`, `none`. | + +## Select Assets + +`assets/SelectAssets` + +Select assets from a list of asset IDs by filtering on vis_type and/or reference_vis_types. + +This node is designed to be chained with other SelectAssets nodes to implement +priority-based selection. + +Modes: +- noop: Once a selection is made, subsequent nodes pass through the previous selection +- prioritize: All nodes contribute, results sorted by priority (earlier selections first) + +Example usage (noop mode - fallback): +1. SearchAssets (CHARACTER_PORTRAIT, CHARACTER_CARD) -> asset_ids +2. SelectAssets (vis_types=CHARACTER_PORTRAIT) -> tries to select portraits first +3. SelectAssets (vis_types=CHARACTER_CARD) -> falls back to cards if no portraits + +Example usage (prioritize mode - sorting): +1. SearchAssets (CHARACTER_PORTRAIT, CHARACTER_CARD) -> asset_ids +2. SelectAssets (mode=prioritize, vis_types=CHARACTER_PORTRAIT) -> portraits first +3. SelectAssets (vis_types=CHARACTER_CARD) -> cards added after portraits + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `asset_ids` | `list` | list of asset IDs to filter from (only used on first node in chain) | +| `selection_context` | `asset_selection_context` | optional context from previous SelectAssets node | +| `vis_types` | `list` | (optional) list of vis_types to match (asset must match any) | +| `reference_vis_types` | `list` | (optional) list of reference vis_types to match (asset must have any in its reference list) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `asset_ids` | `list` | list of selected asset IDs | +| `asset_count` | `int` | number of selected assets | +| `selection_context` | `asset_selection_context` | updated context to pass to next SelectAssets node (includes original_asset_ids) | +| `selected` | `bool` | whether this node made a selection | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `mode` | `str` | `"noop"` | Selection mode: 'noop' (stop after first match) or 'prioritize' (accumulate and sort by priority). Choices: `noop`, `prioritize`. | +| `vis_types` | `list` | `[]` | List of vis_types to match (asset must match any) | +| `reference_vis_types` | `list` | `[]` | List of reference vis_types to match (asset must have any in its reference list) | + +## Unpack Asset Meta + +`assets/UnpackAssetMeta` + +Unpack an asset metadata object into its properties. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `meta` | `asset_meta` | the asset metadata object | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `meta` | `asset_meta` | the asset metadata object (passed through) | +| `name` | `str` | asset name | +| `vis_type` | `any` | visual type | +| `gen_type` | `any` | generation type | +| `character_name` | `str` | character name | +| `prompt` | `str` | prompt text | +| `negative_prompt` | `str` | negative prompt text | +| `format` | `any` | format type | +| `width` | `int` | image width | +| `height` | `int` | image height | +| `sampler_settings` | `any` | sampler settings object | +| `reference_assets` | `list` | list of reference asset IDs | +| `tags` | `list` | list of tags | +| `analysis` | `str` | analysis prompt text | +| `reference` | `list` | list of vis_types that this asset may be used as a reference for | + +## Unpack Asset Selection Context + +`assets/UnpackAssetSelectionContext` + +Unpack an asset selection context into its properties. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `selection_context` | `asset_selection_context` | the asset selection context object | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `selection_context` | `asset_selection_context` | the context object (passed through) | +| `mode` | `str` | selection mode ("noop" or "prioritize") | +| `selected` | `bool` | whether any selection has been made | +| `asset_ids` | `list` | list of selected asset IDs | +| `asset_count` | `int` | number of selected assets | +| `original_asset_ids` | `list` | list of original asset IDs passed to first SelectAssets node | +| `assets` | `list` | list of asset objects (only computed if connected) | +| `first` | `asset` | first asset object (only set if asset_ids is not empty) | +| `last` | `asset` | last asset object (only set if asset_ids is not empty) | diff --git a/docs/user-guide/node-editor/reference/nodes/assets.md b/docs/user-guide/node-editor/reference/nodes/assets.md new file mode 100644 index 00000000..4ec27775 --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/assets.md @@ -0,0 +1,279 @@ +# Asset Nodes + +Manage scene assets (images and other files): add, fetch, list, remove and update them, and set cover and avatar images. + + + + +_9 nodes._ + +| Node | Registry path | +| --- | --- | +| [Add Asset](#add-asset) | `assets/AddAsset` | +| [Asset Exists](#asset-exists) | `assets/AssetExists` | +| [Get Asset](#get-asset) | `assets/GetAsset` | +| [Get Assets](#get-assets) | `assets/GetAssets` | +| [List Assets](#list-assets) | `assets/ListAssets` | +| [Remove Asset](#remove-asset) | `assets/RemoveAsset` | +| [Set Avatar Image](#set-avatar-image) | `assets/SetAvatarImage` | +| [Set Cover Image](#set-cover-image) | `assets/SetCoverImage` | +| [Update Message Assets](#update-message-assets) | `assets/UpdateMessageAsset` | + +## Add Asset + +`assets/AddAsset` + +Add an asset to the scene from image data (base64 data URL). + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | graph state (required) | +| `image_data` | `str` | (optional) base64 data URL (e.g., "data:image/png;base64,...") | +| `meta` | `asset_meta` | asset metadata (optional) | +| `asset_attachment_context` | `asset_attachment_context` | controls automatic asset attachment behavior (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | graph state | +| `asset` | `asset` | the created asset object | +| `asset_id` | `str` | the asset ID | +| `image_data` | `str` | the image data (passed through) | +| `meta` | `asset_meta` | the asset metadata (passed through) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `image_data` | `str` | `""` | Base64 data URL (e.g., 'data:image/png;base64,...') | + +## Asset Exists + +`assets/AssetExists` + +Check if an asset exists by ID. + +Routes to 'yes' output if the asset exists, 'no' output if it doesn't. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `asset_id` | `str` | (optional) the asset ID to check | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `yes` | `any` | activated if asset exists (contains asset_id) | +| `no` | `any` | activated if asset does not exist (contains asset_id) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `asset_id` | `str` | `""` | The asset ID to check | +| `return_bool` | `bool` | `False` | If True, return True instead of asset_id | +| `allow_partial` | `bool` | `False` | If True, match assets whose ID starts with the given asset_id | + +## Get Asset + +`assets/GetAsset` + +Get an asset by ID. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `asset_id` | `str` | (optional) the asset ID | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `asset` | `asset` | the asset object | +| `asset_id` | `str` | the asset ID (passed through) | +| `file_type` | `str` | the file type | +| `media_type` | `str` | the media type | +| `meta` | `asset_meta` | the asset metadata | +| `asset_bytes` | `any` | the asset bytes (only set if connected) | +| `base64_data` | `str` | the asset bytes as base64 (only set if connected) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `asset_id` | `str` | `""` | The asset ID | + +## Get Assets + +`assets/GetAssets` + +Get multiple assets by their IDs. + +Asset IDs that don't exist are skipped rather than raising an error. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `asset_ids` | `list` | (optional) list of asset IDs | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `assets` | `list` | list of asset objects | +| `asset_ids` | `list` | list of asset IDs that were found (missing assets are skipped) | +| `asset_count` | `int` | number of assets retrieved | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `asset_ids` | `list` | `[]` | List of asset IDs | + +## List Assets + +`assets/ListAssets` + +List all asset IDs in the scene. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `asset_ids` | `list` | list of asset IDs | +| `asset_count` | `int` | number of assets | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `reference_only` | `bool` | `False` | If True, only return reference assets | + +## Remove Asset + +`assets/RemoveAsset` + +Remove an asset from the scene. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | graph state (required) | +| `asset_id` | `str` | (optional) the asset ID to remove | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | graph state | +| `asset_id` | `str` | the asset ID (passed through) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `asset_id` | `str` | `""` | The asset ID to remove | + +## Set Avatar Image + +`assets/SetAvatarImage` + +Set the avatar image for a character (default or current). + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | graph state (required) | +| `asset_id` | `str` | the asset ID to set as avatar | +| `character` | `character` | the character to set the avatar for | +| `avatar_type` | `str` | (optional) "default" or "current" (defaults to "default") | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | graph state | +| `asset_id` | `str` | the asset ID (passed through) | +| `character` | `character` | the character (passed through) | +| `avatar_type` | `str` | the avatar type (passed through) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `avatar_type` | `str` | `"default"` | Type of avatar to set: 'default' or 'current'. Choices: `default`, `current`. | + +## Set Cover Image + +`assets/SetCoverImage` + +Set the cover image for the scene and/or a character from an existing scene asset. + +If `set_on_scene` is true the asset becomes the scene cover image; if a +character is provided the asset becomes that character's cover image. The +override flags control whether an existing cover image is replaced. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `asset_id` | `str` | The id of the asset to use as the cover image | +| `set_on_scene` | `bool` | Whether to set the cover image on the scene (optional) | +| `override_scene` | `bool` | Whether to override an existing scene cover image (optional) | +| `override_character` | `bool` | Whether to override an existing character cover image (optional) | +| `character` | `character` | The character to set the cover image for (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `asset_id` | `str` | The asset id, passed through | +| `set_on_scene` | `bool` | The set_on_scene input, passed through | +| `override_scene` | `bool` | The override_scene input, passed through | +| `override_character` | `bool` | The override_character input, passed through | +| `character` | `character` | The character input, passed through | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `asset_id` | `str` | `""` | asset_id | +| `set_on_scene` | `bool` | `False` | Whether to set the cover image on the scene | +| `override_scene` | `bool` | `False` | Whether to override the scene cover image | +| `override_character` | `bool` | `False` | Whether to override the character cover image | + +## Update Message Assets + +`assets/UpdateMessageAsset` + +Update the asset_id of messages in the scene history + +Messages that aren't found are skipped rather than raising an error. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | graph state (required) | +| `message_ids` | `list` | (optional) List of message IDs to update | +| `asset_id` | `str` | The new asset ID | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | graph state | +| `messages` | `list` | List of updated message objects | +| `message_ids` | `list` | List of message IDs (passthrough) | +| `asset_id` | `str` | The asset ID (passthrough) | diff --git a/docs/user-guide/node-editor/reference/nodes/context-id.md b/docs/user-guide/node-editor/reference/nodes/context-id.md new file mode 100644 index 00000000..16768a46 --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/context-id.md @@ -0,0 +1,340 @@ +# Context ID Nodes + +Context IDs address pieces of scene context (character attributes, world entries, etc.) with a uniform string format so they can be read, written, pinned and rendered generically. + + + + +_11 nodes._ + +| Node | Registry path | +| --- | --- | +| [Character Context IDs](#character-context-ids) | `context_id/CharacterContextIDs` | +| [Compress Context ID Part](#compress-context-id-part) | `context_id/CompressContextIDPart` | +| [Context ID Get Value](#context-id-get-value) | `context_id/ContextIDGetValue` | +| [Context ID Meta Entries](#context-id-meta-entries) | `context_id/ContextIDMetaEntries` | +| [Context ID Set Value](#context-id-set-value) | `context_id/ContextIDSetValue` | +| [Is Pin Active](#is-pin-active) | `context_id/IsPinActive` | +| [Path to Context ID](#path-to-context-id) | `context_id/PathToContextID` | +| [Remove Pin](#remove-pin) | `context_id/RemovePin` | +| [Render Context IDs](#render-context-ids) | `context_id/RenderContextIDs` | +| [Scan Context IDs](#scan-context-ids) | `context_id/ScanContextIDs` | +| [Set Pin](#set-pin) | `context_id/SetPin` | + +## Character Context IDs + +`context_id/CharacterContextIDs` + +Unpack a character into context ID items for its various context entries +(attributes, details, description, acting instructions and example dialogue). + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `character` | `character` | The character to get context ID items for | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `character` | `character` | The character input, passed through | +| `attributes` | `list` | List of context ID items for the character's attributes | +| `details` | `list` | List of context ID items for the character's details | +| `description` | `context_id_item` | Context ID item for the character's description | +| `acting_instructions` | `context_id_item` | Context ID item for the character's acting instructions | +| `example_dialogue` | `list` | List of context ID items for the character's example dialogue | + +## Compress Context ID Part + +`context_id/CompressContextIDPart` + +Compress a context ID part name into the short hash identifier used inside +context IDs (truncated SHA256). + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `part` | `str` | The part name to compress | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `uncompressed` | `str` | The original part, passed through | +| `compressed` | `str` | The compressed part | + +## Context ID Get Value + +`context_id/ContextIDGetValue` + +Get the value of a context ID + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `context_id_item` | `context_id_item` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `context_id_item` | `context_id_item` | | +| `value` | `any` | | + +## Context ID Meta Entries + +`context_id/ContextIDMetaEntries` + +Get all defined context ID meta entries for the active scene, sorted by +context ID. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `meta_entries` | `list` | List of context ID meta entries | +| `context_id_types` | `list` | List of unique context ID types found in the entries | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `filter_creative` | `bool` | `False` | Filter creative context ID meta entries | + +## Context ID Set Value + +`context_id/ContextIDSetValue` + +Set the value of a context ID entry in the active scene. + +The target entry can be given either as a context ID item or as a path +string (one of the two must be set). Raises an error if the entry cannot +be resolved. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `context_id_item` | `context_id_item` | The context ID item to set the value for (optional) | +| `path` | `str` | The context ID path to set the value for (optional) | +| `value` | `any` | The value to set | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `context_id_item` | `context_id_item` | The context ID item that was set | +| `path` | `str` | The path input, passed through | +| `value` | `any` | The value that was set | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `path` | `str` | `""` | The path to set the value for | + +## Is Pin Active + +`context_id/IsPinActive` + +Check whether the world state pin for a context entry is currently active. + +The target entry can be given either as a context ID item or as a path +string (one of the two must be set). + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `context_id_item` | `context_id_item` | The context ID item to check (optional) | +| `path` | `str` | The context ID path to check (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `context_id_item` | `context_id_item` | The context ID item that was checked | +| `path` | `str` | The path input, passed through | +| `active` | `bool` | Whether the pin is active | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `path` | `str` | `""` | The path to the context ID item | + +## Path to Context ID + +`context_id/PathToContextID` + +Resolve a context ID path string into a context ID item and retrieve its +current value from the active scene. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `path` | `str` | The context ID path to resolve | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `context_id` | `context_id` | The resolved context ID (None if the path could not be resolved) | +| `context_id_item` | `context_id_item` | The resolved context ID item (None if the path could not be resolved) | +| `human_id` | `str` | Human readable identifier of the resolved item | +| `as_dict` | `dict` | The resolved item as a dictionary (empty if not resolved) | +| `name` | `str` | The name of the resolved item | +| `value` | `any` | The current value stored at the context ID | +| `exists` | `bool` | Whether the path resolved to an existing context ID item | +| `context_type` | `str` | The context type of the resolved item | +| `path` | `str` | The path input, passed through | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `path` | `str` | `""` | The path to convert to a context ID | + +## Remove Pin + +`context_id/RemovePin` + +Remove the world state pin for a context entry and reload the scene's +active pins. + +The target entry can be given either as a context ID item or as a path +string (one of the two must be set). + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `context_id_item` | `context_id_item` | The context ID item to unpin (optional) | +| `path` | `str` | The context ID path to unpin (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `context_id_item` | `context_id_item` | The context ID item that was unpinned | +| `path` | `str` | The path input, passed through | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `path` | `str` | `""` | The path to the context ID item | + +## Render Context IDs + +`context_id/RenderContextIDs` + +Render a list of context ID items to prompt-ready text using the +`common.context_id_items` prompt template. + +A single item may be passed instead of a list and will be wrapped +automatically. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `items` | `list,context_id_item` | List of context ID items to render (a single item is also accepted) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `items` | `list` | The list of items that was rendered | +| `rendered` | `str` | The rendered text | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `display_mode` | `str` | `"compact"` | Display mode. Choices: `compact`, `subsection`, `normal`. | + +## Scan Context IDs + +`context_id/ScanContextIDs` + +Scan text for context ID references, resolve them against the active scene +and return the results in various formats. + +Resolved items are rendered through the `common.context_id_items` prompt +template and also wrapped in a dynamic instruction that can be injected +into agent prompts. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `text` | `str` | The text to scan for context IDs | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `dynamic_instruction` | `dynamic_instruction` | Dynamic instruction containing the rendered context items | +| `rendered` | `str` | The rendered text of the resolved items | +| `context_id_items` | `list` | List of resolved context ID items | +| `unresolved` | `list` | List of context ID references that could not be resolved | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `header` | `str` | `"Relevant Context"` | The header of the dynamic instruction | +| `display_mode` | `str` | `"compact"` | The display mode of the dynamic instruction. Choices: `compact`, `subsection`, `normal`. | + +## Set Pin + +`context_id/SetPin` + +Create or update a world state pin for a context entry, pinning it into the +AI context. The target entry can be given either as a context ID item or as +a path (one of the two must be set). + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `context_id_item` | `context_id_item` | The context ID item to pin (optional) | +| `path` | `str` | The context ID path to pin (optional) | +| `condition` | `str` | AI-evaluated condition that determines whether the pin is active (optional) | +| `condition_state` | `bool` | The current evaluation state of the condition (optional) | +| `active` | `bool` | Whether the pin is active (optional) | +| `decay` | `int` | Number of cycles the pin remains active once set, 0 for no decay (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `context_id_item` | `context_id_item` | The context ID item that was pinned | +| `path` | `str` | The path input, passed through | +| `condition` | `str` | The condition that was set | +| `condition_state` | `bool` | The condition state that was set | +| `active` | `bool` | The active state that was set | +| `decay` | `int` | The decay that was set | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `path` | `str` | `""` | The path to the context ID item | +| `condition` | `str` | `""` | The condition to set the pin | +| `condition_state` | `bool` | `False` | The condition state to set the pin | +| `active` | `bool` | `False` | The active state to set the pin | +| `decay` | `int` | `0` | Number of cycles the pin remains active once set | diff --git a/docs/user-guide/node-editor/reference/nodes/core-functions.md b/docs/user-guide/node-editor/reference/nodes/core-functions.md new file mode 100644 index 00000000..ae69cdc1 --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/core-functions.md @@ -0,0 +1,289 @@ +# Function Nodes + +Define and call reusable functions inside node graphs. A `core/functions/Function` module defines the body, **Define Function** / **Get Function** / **Call Function** wire it up, and **Call For Each** maps a function over a list. **Error Handler** and **Unpack Exception** deal with failures raised inside a call. See [Functions](../../core-concepts/functions.md) for the full concept walkthrough. + + + + +_11 nodes._ + +| Node | Registry path | +| --- | --- | +| [Error Handler](#error-handler) | `core/ErrorHandler` | +| [Argument](#argument) | `core/functions/Argument` | +| [Breakpoint](#breakpoint) | `core/functions/Breakpoint` | +| [Call For Each](#call-for-each) | `core/functions/CallForEach` | +| [Call Function](#call-function) | `core/functions/CallFunction` | +| [Define Function](#define-function) | `core/functions/DefineFunction` | +| [Get Function](#get-function) | `core/functions/GetFunction` | +| [Return](#return) | `core/functions/Return` | +| [Scoped API Function](#scoped-api-function) | `core/functions/ScopedAPIFunction` | +| [Unpack Exception](#unpack-exception) | `core/functions/UnpackException` | +| [Run Module](#run-module) | `core/RunModule` | + +## Error Handler + +`core/ErrorHandler` + +A node that will catch unhandled errors in the graph and allow for +custom error handling + +This is an isolated node. When an unhandled error occurs, the +supplied function is called with a single `exc` argument holding an +exception wrapper (see UnpackException). If the function returns a +truthy value the error is considered handled. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `fn` | `function` | The function to call when an error occurs | + +## Argument + +`core/functions/Argument` + +Represents an argument to a function. + +During function execution the value passed for this argument is cast +to the declared type and emitted from the `value` output. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `value` | `any` | The value of the argument (during function execution) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `unset` | The name of the argument | +| `typ` | `str` | `"str"` | The type of the argument. Choices: `str`, `int`, `float`, `bool`, `list`, `any`. | + +## Breakpoint + +`core/functions/Breakpoint` + +Pauses graph execution at this point and notifies the node editor, +allowing the current state to be inspected. Execution resumes when +the breakpoint is released from the editor. + +Breakpoints only trigger in the creative (node editor) environment - +during normal gameplay the node simply passes the state through. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state to pass through | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `active` | `bool` | `True` | Whether the breakpoint is active | + +## Call For Each + +`core/functions/CallForEach` + +Calls the supplied function on each item in the input list + +The item is passed to the function as an argument + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state of the graph | +| `fn` | `function` | The function to call | +| `items` | `list,dict` | The list of items to iterate over | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state of the graph | +| `results` | `list` | The results of the function calls | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `copy_items` | `bool` | `False` | Whether to copy the items list | +| `argument_name` | `str` | `"item"` | The name of the argument to pass to the function | + +## Call Function + +`core/functions/CallFunction` + +Takes a function wrapper input and a dict property to define arguments +to pass to the function then calls the function + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `fn` | `function` | The function to call | +| `args` | `any` | (optional) The arguments to pass to the function | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `result` | `any` | The result of the function call | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `args` | `dict` | `{}` | The arguments to pass to the function | + +## Define Function + +`core/functions/DefineFunction` + +Defines a function from the connected nodes, which can be retrieved +elsewhere in the graph via the GetFunction node. + +This is an isolated node that never runs during normal graph +execution. The node connected to `nodes` becomes the endpoint of the +function - when the function is called, the nodes leading into the +endpoint are executed. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `nodes` | `any` | The nodes to convert into a function | +| `name` | `str` | The name of the function | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `unset` | The name of the function | + +## Get Function + +`core/functions/GetFunction` + +Retrieves a function from the graph + +This has no inputs and will return the function wrapper for the +function defined by the DefineFunction node. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `fn` | `function` | The function wrapper | +| `name` | `str` | The name of the function | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `unset` | The name of the function | + +## Return + +`core/functions/Return` + +Represents the return value of a function. + +When this node runs with a resolved input value, it sets the +function's return value and stops execution of the function graph. +If the input value is unresolved, execution continues normally. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `value` | `any` | The value to return | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `value` | `any` | The value to return | + +## Scoped API Function + +`core/functions/ScopedAPIFunction` + +Executes python code inside the quarantined scoped environment. + +The code has access to the `arguments` dict, a `result` dict to +populate, and the `TM` scoped API object. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `agent` | `agent` | The agent whose client the scoped context runs against | +| `arguments` | `dict` | (optional) Arguments made available to the executed code | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `result` | `any` | The result dict populated by the executed code | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `code` | `text` | `unset` | The code to execute | + +## Unpack Exception + +`core/functions/UnpackException` + +Unpacks an exception wrapper (as received by an ErrorHandler +function) into its name and message. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `exc` | `exception` | The exception wrapper to unpack | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `name` | `any` | The exception class name | +| `message` | `any` | The exception message | + +## Run Module + +`core/RunModule` + +Provides a way to run a node module from memory + +The module runs in an isolated state; running a module from within +itself raises an error. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `module` | `any` | The module (Graph instance) to run | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `done` | `bool` | True if module was executed successfully | +| `failed` | `str` | Error message if module execution failed | +| `cancelled` | `bool` | True if module execution was cancelled | diff --git a/docs/user-guide/node-editor/reference/nodes/core-logic.md b/docs/user-guide/node-editor/reference/nodes/core-logic.md new file mode 100644 index 00000000..a33056d6 --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/core-logic.md @@ -0,0 +1,343 @@ +# Logic Nodes + +Branching and comparison: switches route execution based on a value, routers pick one of several paths, and the boolean nodes combine conditions. See [Switches](../../core-concepts/switches.md) for how conditional flow works in graphs. + + + + +_12 nodes._ + +| Node | Registry path | +| --- | --- | +| [AND Router](#and-router) | `core/ANDRouter` | +| [Apply Default](#apply-default) | `core/ApplyDefault` | +| [As Bool](#as-bool) | `core/AsBool` | +| [Case](#case) | `core/Case` | +| [Case Router](#case-router) | `core/CaseRouter` | +| [Coallesce](#coallesce) | `core/Coallesce` | +| [Invert](#invert) | `core/Invert` | +| [Make Bool](#make-bool) | `core/MakeBool` | +| [OR Router](#or-router) | `core/ORRouter` | +| [RSwitch](#rswitch) | `core/RSwitch` | +| [RSwitch Advanced](#rswitch-advanced) | `core/RSwitchAdvanced` | +| [Switch](#switch) | `core/Switch` | + +## AND Router + +`core/ANDRouter` + +Route a value based on AND logic where all of a - d are truthy (if connected) + +Truthy values are considered as True, False and None are considered as False + +If a value is provided, it will be returned if the result is True +If no value is provided, True will be returned on the output activated through the result, the other output will be deactivated + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `a` | `bool` | flag A | +| `b` | `bool` | flag B | +| `c` | `bool` | flag C | +| `d` | `bool` | flag D | +| `value` | `any` | value to route to the activated output (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `yes` | `any` | if the result is True | +| `no` | `any` | if the result is False | + +## Apply Default + +`core/ApplyDefault` + +Applies a default value if the input value is UNRESOLVED + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `value` | `any` | (optional) value to apply the default to | +| `default` | `any` | the default value to apply | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `value` | `any` | the value with the default applied | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `apply_on_none` | `bool` | `False` | If True, the default will be applied if the value is None | +| `apply_on_unresolved` | `bool` | `True` | If True, the default will be applied if the value is UNRESOLVED | + +## As Bool + +`core/AsBool` + +Converts a value to a boolean + +This specfically handles UNRESOLVED by casting it to a default value + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `value` | `any` | (optional) value to convert | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `value` | `bool` | boolean value | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `default` | `bool` | `False` | The default value to return if the value is UNRESOLVED | + +## Case + +`core/Case` + +Route a value based on attribute value check (exact match) +like a switch / case statement. + +When no attribute_name is set, the value is cast to a string before +comparison, so case values should be given as strings. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `value` | `any` | value to check | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `a` | `any` | if the value matches case A | +| `b` | `any` | if the value matches case B | +| `c` | `any` | if the value matches case C | +| `d` | `any` | if the value matches case D | +| `none` | `any` | if the value matches no case | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `attribute_name` | `str` | `""` | The attribute name to check for the value | +| `case_a` | `str` | `""` | The value to compare to for case A | +| `case_b` | `str` | `""` | The value to compare to for case B | +| `case_c` | `str` | `""` | The value to compare to for case C | +| `case_d` | `str` | `""` | The value to compare to for case D | + +## Case Router + +`core/CaseRouter` + +Route one of several input values based on a check value match, +like a switch / case statement with a single output. + +The check value (or its attribute) is cast to a string before comparison, +so case values should be given as strings. The input of the first matching +case is routed to the value output; if no case matches, the default input +is routed instead. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `check` | `any` | value to check | +| `a` | `any` | (optional) value to route for case A | +| `b` | `any` | (optional) value to route for case B | +| `c` | `any` | (optional) value to route for case C | +| `d` | `any` | (optional) value to route for case D | +| `default` | `any` | (optional) value to route if no match | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `value` | `any` | the value of the matching case or default | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `attribute_name` | `str` | `""` | The attribute name to check for the check value | +| `case_a` | `str` | `""` | The value to compare to for case A | +| `case_b` | `str` | `""` | The value to compare to for case B | +| `case_c` | `str` | `""` | The value to compare to for case C | +| `case_d` | `str` | `""` | The value to compare to for case D | + +## Coallesce + +`core/Coallesce` + +Takes a list of values and returns the first truthy value + +A value is considered truthy unless it is None, False, or UNRESOLVED +(0 and empty strings count as truthy). If no input qualifies, the +output is UNRESOLVED. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `a` | `any` | (optional) value A | +| `b` | `any` | (optional) value B | +| `c` | `any` | (optional) value C | +| `d` | `any` | (optional) value D | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `value` | `any` | the first truthy value | + +## Invert + +`core/Invert` + +Takes a boolean input and inverts it + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `value` | `bool` | boolean value | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `value` | `bool` | inverted boolean value | + +## Make Bool + +`core/MakeBool` + +Creates a boolean value + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `value` | `bool` | boolean value | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `value` | `bool` | `True` | The boolean value | + +## OR Router + +`core/ORRouter` + +Route a value based on OR logic where any of a - d is truthy (if connected) + +Truthy values are considered as True, False and None are considered as False + +If a value is provided, it will be returned if the result is True +If no value is provided, True will be returned on the output activated through the result, the other output will be deactivated + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `a` | `bool` | flag A | +| `b` | `bool` | flag B | +| `c` | `bool` | flag C | +| `d` | `bool` | flag D | +| `value` | `any` | value to route to the activated output (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `yes` | `any` | if the result is True | +| `no` | `any` | if the result is False | + +## RSwitch + +`core/RSwitch` + +Checks if the check value is truthy + +If the check value is truthy, the yes input is routed to the output, otherwise the no input is routed to the output + +A value is considered truthy unless it is None, False, or UNRESOLVED (0 and empty strings count as truthy) + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `check` | `any` | (optional) value to check | +| `yes` | `any` | (optional) value to return if the check value is truthy | +| `no` | `any` | (optional) value to return if the check value is not truthy | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `value` | `any` | the value to return | + +## RSwitch Advanced + +`core/RSwitchAdvanced` + +Checks if the check value is truthy + +If the check value is truthy, the yes input is routed to the yes output, otherwise the no input is routed to the no output (the other output stays UNRESOLVED) + +A value is considered truthy unless it is None, False, or UNRESOLVED (0 and empty strings count as truthy) + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `check` | `any` | (optional) value to check | +| `yes` | `any` | (optional) value to return if the check value is truthy | +| `no` | `any` | (optional) value to return if the check value is not truthy | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `yes` | `any` | the value to return if the check value is truthy | +| `no` | `any` | the value to return if the check value is not truthy | + +## Switch + +`core/Switch` + +Checks if the input value is not None or False + +If the value is truthy, the yes output is activated, otherwise the no output is activated + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `value` | `any` | value to check | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `yes` | `any` | if the value is truthy | +| `no` | `any` | if the value is not truthy | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `pass_through` | `bool` | `True` | If True, the value will be passed through to the output, otherwise True will be passed through | diff --git a/docs/user-guide/node-editor/reference/nodes/core.md b/docs/user-guide/node-editor/reference/nodes/core.md new file mode 100644 index 00000000..74b1a48c --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/core.md @@ -0,0 +1,179 @@ +# Core Nodes + +Structural plumbing every module graph is built from. **Input**, **Output** and **Module Property** define a module's interface — the sockets and properties it exposes when used as a node inside another graph (see [Modularity](../../core-concepts/modularity.md)). **Stage** controls execution order between disconnected branches (see [Staging](../../core-concepts/staging.md)), **Route** and **Watch** pass values through (Watch also surfaces them in the debug tools), and **Null** is an explicit no-op. + + + + +_7 nodes._ + +| Node | Registry path | +| --- | --- | +| [Input Socket](#input-socket) | `core/Input` | +| [Module Property](#module-property) | `core/ModuleProperty` | +| [Null](#null) | `core/Null` | +| [Output Socket](#output-socket) | `core/Output` | +| [Route](#route) | `core/Route` | +| [Stage](#stage) | `core/Stage` | +| [Watch](#watch) | `core/Watch` | + +## Input Socket + +`core/Input` + +Defines an input socket for the containing node module (graph). + +When the module is used as a node inside another graph, each Input node +becomes an input socket on the module node, and the value passed into that +socket is emitted from this node's `value` output inside the module. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `value` | `any` | The value received through the module's input socket | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `input_type` | `str` | `"any"` | Input Type. Choices: `actor`, `agent`, `any`, `bool`, `character`, `character_context`, `client`, `context_id`, `context_id_item`, `dict`, `event`, `float`, …. | +| `input_name` | `str` | `"state"` | Input Name | +| `input_optional` | `bool` | `False` | Input Optional | +| `input_group` | `str` | `""` | Input Group | +| `num` | `int` | `0` | Number | + +## Module Property + +`core/ModuleProperty` + +A node that can be placed to define a property of a Graph + +When the graph is used as a node module inside another graph, each +ModuleProperty node becomes a property on the module node. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `name` | `any` | The name of the property | +| `value` | `any` | The value of the property | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `property_name` | `str` | `""` | Property Name | +| `property_type` | `str` | `""` | Property Type. Choices: `str`, `bool`, `int`, `float`, `text`. | +| `default` | `any` | `unset` | Default Value | +| `choices` | `list` | `unset` | Choices | +| `description` | `str` | `""` | Description | +| `num` | `int` | `0` | Number. Min: 0. | + +## Null + +`core/Null` + +A node that returns None + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `value` | `any` | | + +## Output Socket + +`core/Output` + +Defines an output socket for the containing node module (graph). + +When the module is used as a node inside another graph, each Output node +becomes an output socket on the module node, and the value connected to this +node's `value` input is exposed through that socket. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `value` | `any` | (optional) The value to expose through the module's output socket | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `output_type` | `str` | `"any"` | Output Type. Choices: `actor`, `agent`, `any`, `bool`, `character`, `character_context`, `client`, `context_id`, `context_id_item`, `dict`, `event`, `float`, …. | +| `output_name` | `str` | `"state"` | Output Name | +| `num` | `int` | `0` | Number | + +## Route + +`core/Route` + +Simply passes the value of the input to the output + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `value` | `any` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `value` | `any` | | + +## Stage + +`core/Stage` + +A node that can be connected in or out and +defines a stage level for the nodes connected to it + +This stage level can be used to control the order +of execution of nodes in the graph, the lowest stage +will be executed first. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | (optional) Any value to pass through. If not connected, defaults to True | +| `state_b` | `any` | (optional) Any value to pass through. | +| `state_c` | `any` | (optional) Any value to pass through. | +| `state_d` | `any` | (optional) Any value to pass through. | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The value of the input state or True if corresponding input is not connected | +| `state_b` | `any` | The value of the input state_b | +| `state_c` | `any` | The value of the input state_c | +| `state_d` | `any` | The value of the input state_d | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `stage` | `int` | `0` | Stage. Min: 0. | + +## Watch + +`core/Watch` + +Passes the input value through unchanged and logs it for inspection +when the graph is running in the node editor (creative mode). + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `value` | `any` | The value to watch | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `value` | `any` | The value, passed through | diff --git a/docs/user-guide/node-editor/reference/nodes/data-collections.md b/docs/user-guide/node-editor/reference/nodes/data-collections.md new file mode 100644 index 00000000..f3a0c610 --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/data-collections.md @@ -0,0 +1,404 @@ +# Dict And List Nodes + +Build and manipulate dictionaries and lists. The **Dict Collector** and **List Collector** aggregate any number of inputs through dynamic sockets — see [Collector Nodes](../../core-concepts/collector_nodes.md) for how key inference works with **Make Key-Value Pair**. + + + + +_14 nodes._ + +| Node | Registry path | +| --- | --- | +| [Combine Lists](#combine-lists) | `data/CombineLists` | +| [Dict Collector](#dict-collector) | `data/DictCollector` | +| [Dict Get](#dict-get) | `data/DictGet` | +| [Dict Get (Path)](#dict-get-path) | `data/DictGetPath` | +| [Dict To Key-Value Pairs](#dict-to-key-value-pairs) | `data/DictKeyValuePairs` | +| [Dict Pop](#dict-pop) | `data/DictPop` | +| [Dict Set](#dict-set) | `data/DictSet` | +| [Dict Update](#dict-update) | `data/DictUpdate` | +| [List Append](#list-append) | `data/ListAppend` | +| [List Collector](#list-collector) | `data/ListCollector` | +| [List Remove](#list-remove) | `data/ListRemove` | +| [Make Dict](#make-dict) | `data/MakeDict` | +| [Make Key-Value Pair](#make-key-value-pair) | `data/MakeKeyValuePair` | +| [Make List](#make-list) | `data/MakeList` | + +## Combine Lists + +`data/CombineLists` + +Combines multiple lists into a single list + +Each connected dynamic input must be a list; their items are appended +to the result in socket order. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `list` | `list` | (optional) Base list to extend (optional - a new list is created if not provided) | + +This node supports **dynamic inputs** - additional input sockets can be added in the editor as needed, and connected values are collected with key inference (see [Collector Nodes](../../core-concepts/collector_nodes.md)). + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `list` | `list` | The combined list | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `create_copy` | `bool` | `True` | Create a copy of the list | + +## Dict Collector + +`data/DictCollector` + +Collects key-value pairs into a dictionary with dynamic inputs. +New item sockets appear as connections are made. + +For each connected item the key is inferred: if the value is a +(key, value) tuple (e.g., from Make Key-Value Pair) that key is used; +otherwise the key is derived from the source socket - for sockets named +`value`, the source node's `name`, `key` or `attribute` input is used, +falling back to the source socket name. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `dict` | `dict` | (optional) Base dictionary to collect into (optional - a new dictionary is created if not provided) | + +This node supports **dynamic inputs** - additional input sockets can be added in the editor as needed, and connected values are collected with key inference (see [Collector Nodes](../../core-concepts/collector_nodes.md)). + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `dict` | `dict` | The collected dictionary | + +## Dict Get + +`data/DictGet` + +Retrieves a value from a dictionary + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `dict` | `dict` | Dictionary | +| `key` | `str` | Key | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `value` | `any` | The value for the key, or None if the key is not present | +| `key` | `str` | The key input, passed through | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `key` | `str` | `unset` | Key | + +## Dict Get (Path) + +`data/DictGetPath` + +Retrieves a value from a nested dict (or list-of-dicts) using a dotted path. + +Supports: +- Nested dict keys: "modes.generate_arc.close_arc" +- List indices: "items.0.name" +- Missing paths return the `default` input (or None if unset) +- Any non-traversable segment short-circuits to `default` + +Downstream DynamicSocketNodeBase collectors (DictCollector, UpdateObject) +auto-infer the collector key from the last segment of `path` via the +`key` property set during run(). E.g. `modes.generate_arc.close_arc` +yields a collector key of `close_arc`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `dict` | `dict` | Dictionary (or any traversable structure) | +| `path` | `str` | (optional) Dotted path string | +| `default` | `any` | Fallback value when the path does not fully resolve (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `value` | `any` | The resolved value, or `default` if the path does not resolve | +| `found` | `bool` | True if the full path was resolved, False if default was used | +| `path` | `str` | The path that was attempted (echoed for graph readability) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `path` | `str` | `""` | Dotted path to the value (e.g. 'modes.generate_arc.close_arc') | + +## Dict To Key-Value Pairs + +`data/DictKeyValuePairs` + +Creates a list of key-value pairs from a dictionary + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `dict` | `dict` | Dictionary to convert | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `dict` | `dict` | The dict input, passed through | +| `kvs` | `list` | List of (key, value) tuples | + +## Dict Pop + +`data/DictPop` + +Pops a value from a dictionary + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `dict` | `dict` | Dictionary | +| `key` | `str` | Key | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `dict` | `dict` | The dictionary with the key removed | +| `value` | `any` | The popped value, or None if the key was not present | +| `key` | `str` | The key input, passed through | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `key` | `str` | `unset` | Key | + +## Dict Set + +`data/DictSet` + +Set a value in a dictionary + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `dict` | `dict` | (optional) Dictionary - if not provided, a new dictionary will be created | +| `key` | `str` | (optional) Key | +| `value` | `any` | Value | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `dict` | `dict` | Dictionary | +| `key` | `str` | Key | +| `value` | `any` | Value | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `key` | `str` | `unset` | Key | + +## Dict Update + +`data/DictUpdate` + +Updates a dictionary from a list of other dictionaries + +Each dictionary in the list is applied in order. By default this is a +shallow dict.update(); enable merge to deep-merge nested dictionaries +instead of replacing them. By default the target dictionary is modified +in place; enable create_copy to leave the input untouched. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `dict` | `dict` | The target dictionary to update | +| `dicts` | `list` | List of dictionaries to apply to the target | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `dict` | `dict` | The updated dictionary | +| `dicts` | `list` | The dicts input, passed through | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `create_copy` | `bool` | `False` | Create a copy of the dictionary | +| `merge` | `bool` | `False` | Perform a deep merge | + +## List Append + +`data/ListAppend` + +Appends an item to a list + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `list` | `list` | (optional) List to append to (optional - a new list is created if not provided) | +| `item` | `any` | Item to append | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `list` | `list` | The list with the item appended | +| `item` | `any` | The item input, passed through | + +## List Collector + +`data/ListCollector` + +Collects items into a list with dynamic inputs. +New item sockets appear as connections are made, and each connected +value is appended to the list in socket order. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `list` | `list` | (optional) Base list to append to (optional - a new list is created if not provided) | + +This node supports **dynamic inputs** - additional input sockets can be added in the editor as needed, and connected values are collected with key inference (see [Collector Nodes](../../core-concepts/collector_nodes.md)). + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `list` | `list` | The collected list | + +## List Remove + +`data/ListRemove` + +Removes an item from a list + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `list` | `list` | List | +| `item` | `any` | Item | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `list` | `list` | List | +| `item` | `any` | Item | +| `removed` | `bool` | True if item was removed, False if not | + +## Make Dict + +`data/MakeDict` + +Creates a new dictionary, optionally initialized from the data property + +The data property is deep-copied on every execution, so downstream +mutation of the dictionary does not alter the property. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | (optional) Graph state | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `dict` | `dict` | The new dictionary | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `data` | `dict` | `{}` | Data | + +## Make Key-Value Pair + +`data/MakeKeyValuePair` + +Creates a key-value pair tuple from separate key and value inputs. +Outputs a tuple (key, value) that can be connected to DictCollector. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `key` | `str` | The key (optional) | +| `value` | `any` | The value (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `kv` | `key/value` | The (key, value) tuple | +| `key` | `str` | The key input, passed through | +| `value` | `any` | The value input, passed through | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `key` | `str` | `""` | Key | +| `value` | `any` | `""` | Value | + +## Make List + +`data/MakeList` + +Creates a new list, optionally initialized from the items property + +The items property is deep-copied on every execution, so downstream +mutation of the list does not alter the property. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | (optional) Graph state | +| `item_type` | `str` | Declared type of the items in the list (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `list` | `list` | The new list | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `item_type` | `str` | `"any"` | Type of items in the list. Choices are generated at runtime. | +| `items` | `list` | `[]` | Initial items in the list | diff --git a/docs/user-guide/node-editor/reference/nodes/data-number.md b/docs/user-guide/node-editor/reference/nodes/data-number.md new file mode 100644 index 00000000..a6ed170a --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/data-number.md @@ -0,0 +1,278 @@ +# Number Nodes + +Numeric values: conversion, arithmetic, comparison, clamping, averaging and random numbers. + + + + +_9 nodes._ + +| Node | Registry path | +| --- | --- | +| [As Number](#as-number) | `data/number/AsNumber` | +| [Average](#average) | `data/number/Average` | +| [Basic Arithmetic](#basic-arithmetic) | `data/number/BasicArithmetic` | +| [Clamp](#clamp) | `data/number/Clamp` | +| [Compare](#compare) | `data/number/Compare` | +| [Make Number](#make-number) | `data/number/Make` | +| [Min Max](#min-max) | `data/number/MinMax` | +| [Random](#random) | `data/number/Random` | +| [Sum](#sum) | `data/number/Sum` | + +## As Number + +`data/number/AsNumber` + +Converts a value to a number + +Converts a value to a number, handling both string and numeric inputs. +A value that cannot be converted raises an error. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `value` | `any` | The value to convert to a number | +| `default` | `any` | Fallback value used when ``value`` is unresolved or None | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `value` | `int,float` | The converted number value | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `number_type` | `str` | `"int"` | Type of number to create. Choices: `int`, `float`. | + +## Average + +`data/number/Average` + +Calculates average of a list of numbers + +Calculates one of three types of average (mean, median, or mode) +from a list of numeric values. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `numbers` | `list` | List of numbers to calculate average from | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `result` | `int,float` | The calculated average value (mode yields None when there is no unique mode) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `method` | `str` | `"mean"` | Type of average to calculate. Choices: `mean`, `median`, `mode`. | + +## Basic Arithmetic + +`data/number/BasicArithmetic` + +Performs basic arithmetic operations + +Performs one of the following operations on two input values: add, subtract, +multiply, divide, power, or modulo. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `a` | `int,float` | First operand (number) | +| `b` | `int,float` | Second operand (number) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `result` | `int,float` | Result of the arithmetic operation (division or modulo by zero raises an error) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `operation` | `str` | `"add"` | Arithmetic operation to perform. Choices: `add`, `subtract`, `multiply`, `divide`, `power`, `modulo`. | +| `a` | `number` | `0` | First operand | +| `b` | `number` | `0` | Second operand | + +## Clamp + +`data/number/Clamp` + +Constrains a number within a specified range + +Takes a value and ensures it falls within a specific minimum and maximum range. +If the value is below the minimum, it returns the minimum. If it's above the +maximum, it returns the maximum. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `value` | `int,float` | The number to constrain | +| `min` | `int,float` | Minimum allowed value | +| `max` | `int,float` | Maximum allowed value | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `result` | `int,float` | The value constrained to the specified range | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `value` | `number` | `0` | The number to constrain | +| `min` | `number` | `0` | Minimum allowed value | +| `max` | `number` | `1` | Maximum allowed value | + +## Compare + +`data/number/Compare` + +Compares two numbers + +Performs comparison operations between two numeric values with optional tolerance +for floating point comparisons. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `a` | `int,float` | First value to compare (number) | +| `b` | `int,float` | Second value to compare (number) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `result` | `bool` | Boolean result of the comparison | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `operation` | `str` | `"equals"` | Comparison operation to perform. Choices: `equals`, `not_equals`, `greater_than`, `less_than`, `greater_equal`, `less_equal`. | +| `tolerance` | `float` | `0.0001` | Tolerance for floating point comparison | +| `a` | `number` | `0` | First value to compare | +| `b` | `number` | `0` | Second value to compare | + +## Make Number + +`data/number/Make` + +Creates a number with a specified value + +Creates either an integer or floating point number with the specified value. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `value` | `any` | The created number value | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `value` | `number` | `0` | The numeric value to create | +| `number_type` | `str` | `"float"` | Type of number to create. Choices: `int`, `float`. | + +## Min Max + +`data/number/MinMax` + +Finds minimum or maximum in a list of numbers + +Takes a list of numbers and finds either the minimum or maximum value, +returning both the value and its index in the list. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `numbers` | `list` | List of numbers to analyze | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `result` | `int,float` | The minimum or maximum value | +| `index` | `int` | The index position of the minimum or maximum value in the list | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `operation` | `str` | `"min"` | Operation to perform. Choices: `min`, `max`. | + +## Random + +`data/number/Random` + +Generates random numbers + +Generates random numbers using various distributions (uniform, integer, normal) +or selects a random item from a list of choices. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `min` | `int,float` | Minimum value for uniform/integer distribution (optional) | +| `max` | `int,float` | Maximum value for uniform/integer distribution (optional) | +| `mean` | `int,float` | Mean value for normal distribution (optional) | +| `std_dev` | `int,float` | Standard deviation for normal distribution (optional) | +| `choices` | `list` | List to select a random item from (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `result` | `int,float` | The generated random number or selected item | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `method` | `str` | `"uniform"` | Type of random number to generate. Choices: `uniform`, `integer`, `normal`, `choice`. | +| `min` | `float` | `0.0` | Minimum value for uniform/integer distribution | +| `max` | `float` | `1.0` | Maximum value for uniform/integer distribution | +| `mean` | `float` | `0.0` | Mean value for normal distribution | +| `std_dev` | `float` | `1.0` | Standard deviation for normal distribution | + +## Sum + +`data/number/Sum` + +Sums a list of numbers + +Calculates the sum of all values in a list of numbers. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `numbers` | `list` | List of numbers to sum | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `result` | `int,float` | The sum of all numbers in the list | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `numbers` | `list` | `[]` | List of numbers to sum | diff --git a/docs/user-guide/node-editor/reference/nodes/data-string.md b/docs/user-guide/node-editor/reference/nodes/data-string.md new file mode 100644 index 00000000..f2097da7 --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/data-string.md @@ -0,0 +1,466 @@ +# String Nodes + +String manipulation: formatting, case, join/split, replace, trim, substrings and excerpts. The **Advanced Format** (`{name}` placeholders) and **Jinja2 Format** (`{{ name }}` templates) nodes build strings from dynamic inputs — connect other nodes into their input slots and each connected value becomes a template variable (see [Collector Nodes](../../core-concepts/collector_nodes.md#advanced-format) for the key inference rules). + + + + +_16 nodes._ + +| Node | Registry path | +| --- | --- | +| [Advanced Format](#advanced-format) | `data/string/AdvancedFormat` | +| [As String](#as-string) | `data/string/AsString` | +| [Case](#case) | `data/string/Case` | +| [Condensed](#condensed) | `data/string/Condensed` | +| [Excerpt](#excerpt) | `data/string/Excerpt` | +| [Extract](#extract) | `data/string/Extract` | +| [Format](#format) | `data/string/Format` | +| [Join](#join) | `data/string/Join` | +| [Make String](#make-string) | `data/string/Make` | +| [Make Text](#make-text) | `data/string/MakeText` | +| [Replace](#replace) | `data/string/Replace` | +| [Split](#split) | `data/string/Split` | +| [String Check](#string-check) | `data/string/StringCheck` | +| [Substring](#substring) | `data/string/Substring` | +| [Trim](#trim) | `data/string/Trim` | +| [Jinja2 Format](#jinja2-format) | `prompt/Jinja2Format` | + +## Advanced Format + +`data/string/AdvancedFormat` + +Python-style string formatting with dynamic inputs. Uses Python's +.format() syntax - reference variables with single curly braces +({name}); for jinja2 templates ({{ name }}) use the Jinja2 Format node. + +Behaves like Format but supports dynamic inputs similar to DictCollector. +Dynamic inputs can be: +- a tuple (key, value) +- a scalar value, in which case the key is derived from the source +socket/node using a best-effort heuristic. + +Dynamic inputs: item{i} + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `template` | `str` | A format string with placeholders (e.g., "Hello, {name}") | +| `variables` | `dict` | (optional) Optional base dictionary to merge into (dynamic inputs extend/override these) | + +This node supports **dynamic inputs** - additional input sockets can be added in the editor as needed, and connected values are collected with key inference (see [Collector Nodes](../../core-concepts/collector_nodes.md)). + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `result` | `str` | The formatted string | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `template` | `text` | `""` | A format string with placeholders (e.g., "Hello, {name}") | + +## As String + +`data/string/AsString` + +Converts a value to a string + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `value` | `any` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `value` | `str` | | + +## Case + +`data/string/Case` + +Changes string case (upper, lower, title, capitalize) + +Converts a string to a different case format, such as uppercase, lowercase, +title case, or capitalized. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `string` | `str` | The string to transform | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `result` | `str` | The transformed string | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `operation` | `str` | `"lower"` | Case operation to perform. Choices: `upper`, `lower`, `title`, `capitalize`. | + +## Condensed + +`data/string/Condensed` + +Condenses a string by removing line breaks and extra spaces. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `string` | `str` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `result` | `str` | | + +## Excerpt + +`data/string/Excerpt` + +Returns an excerpt of a string based on length + +Takes the first `length` characters of the string. If the string was +truncated and add_ellipsis is enabled, "..." is appended. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `string` | `str` | The string to excerpt | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `result` | `str` | The excerpt | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `length` | `int` | `100` | The length of the excerpt | +| `add_ellipsis` | `bool` | `True` | Whether to add an ellipsis to the end of the excerpt | + +## Extract + +`data/string/Extract` + +Extracts a portion of a string using a left and right anchor + +Finds the first valid block between anchors (no nested left_anchor inside). +Falls back to everything after the last left_anchor if no complete block is +found. If the left_anchor does not occur at all, the result is an empty +string. + +Examples: +- "nestedvalue" -> "value" (first clean block) +- "value ... other" -> "value" (first valid block) +- "no closing tag" -> "no closing tag" (fallback) + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `string` | `str` | The string to extract from | +| `left_anchor` | `str` | (optional) The left anchor | +| `right_anchor` | `str` | (optional) The right anchor | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `result` | `str` | The extracted substring | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `left_anchor` | `str` | `""` | The left anchor | +| `right_anchor` | `str` | `""` | The right anchor | +| `trim` | `bool` | `True` | Whether to trim the result | + +## Format + +`data/string/Format` + +Python-style string formatting with variables + +Formats a template string by replacing placeholders with values from a variables dictionary, +using Python's format() string method. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `template` | `str` | A format string with placeholders (e.g., "Hello, {name}") | +| `variables` | `dict` | Dictionary of variable names and values to insert | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `result` | `str` | The formatted string | + +## Join + +`data/string/Join` + +Joins a list of strings with a delimiter + +Combines a list of strings into a single string with a specified delimiter between each element. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `strings` | `list` | List of strings to join | +| `delimiter` | `str` | (optional) Character(s) to insert between each string (optional; a literal "\n" is treated as a newline) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `result` | `str` | The joined string | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `delimiter` | `str` | `" "` | Character(s) to insert between each string | + +## Make String + +`data/string/Make` + +Creates a string + +Creates a string with the specified value. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `value` | `str` | The created string value | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `value` | `str` | `""` | The string value to create | + +## Make Text + +`data/string/MakeText` + +Same as make string but will be rendered with a multiline text editor + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `value` | `str` | The created string value | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `value` | `text` | `""` | The string value to create | + +## Replace + +`data/string/Replace` + +Replaces occurrences of a substring with another + +Searches for all occurrences of a substring and replaces them with a new string. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `string` | `str` | The original string | +| `old` | `str` | Substring to find and replace | +| `new` | `str` | Replacement string | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `result` | `str` | The string after replacements | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `old` | `str` | `""` | Substring to find and replace | +| `new` | `str` | `""` | Replacement string | +| `count` | `int` | `-1` | Maximum number of replacements to make (-1 for all occurrences) | + +## Split + +`data/string/Split` + +Splits a string into a list based on a delimiter + +Divides a string into multiple parts using a specified delimiter. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `string` | `str` | The string to split | +| `delimiter` | `str` | (optional) Character(s) to use as the split point (optional; a literal "\n" is treated as a newline) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `parts` | `list` | List of string parts after splitting | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `delimiter` | `str` | `" "` | Character(s) to use as the split point | +| `max_splits` | `int` | `-1` | Maximum number of splits to perform (-1 for all possible splits) | + +## String Check + +`data/string/StringCheck` + +Checks if a string starts with, ends with, or contains a substring + +Tests whether a string starts with, ends with, contains, or exactly equals a substring, +with optional case sensitivity. An empty (or unset) string always yields False, +regardless of mode. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `string` | `str` | The string to check | +| `substring` | `str` | The substring to look for | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `result` | `bool` | Boolean result of the check | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `substring` | `str` | `""` | Default substring to check for | +| `mode` | `str` | `"contains"` | Check operation to perform. Choices: `startswith`, `endswith`, `contains`, `exact`. | +| `case_sensitive` | `bool` | `True` | Whether the check should be case-sensitive | + +## Substring + +`data/string/Substring` + +Extracts a portion of a string using indices + +Extracts a substring from the original string using start and end indices. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `string` | `str` | The source string | +| `start` | `int` | Starting index (optional) | +| `end` | `int` | Ending index (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `result` | `str` | The extracted substring | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `start` | `int` | `0` | Starting index. Min: 0. | +| `end` | `int` | `None` | Ending index. Min: 0. | + +## Trim + +`data/string/Trim` + +Removes characters from start/end of string + +Removes specified characters from the beginning, end, or both ends of a string. +By default, it removes whitespace if no specific characters are provided. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `string` | `str` | The string to trim | +| `chars` | `str` | (optional) Character(s) to remove (optional, defaults to whitespace; a literal "\n" is treated as a newline) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `result` | `str` | The trimmed string | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `mode` | `str` | `"both"` | Trim mode. Choices: `left`, `right`, `both`. | +| `chars` | `str` | `None` | Character(s) to remove | + +## Jinja2 Format + +`prompt/Jinja2Format` + +Renders a jinja2 template string using Prompt's template environment, +providing access to all Prompt globals, filters, and template features. +Reference variables with double curly braces: {{ name }}. + +Variables come from the connected inputs: every dynamic input becomes a +template variable. Connect any node output to a dynamic input slot and +the variable name is inferred from the source node's name / key / +attribute (falling back to the socket name), or connect a (key, value) +tuple from Make Key-Value Pair to name it explicitly. The optional +variables dict is merged in first; dynamic inputs extend/override it. + +Dynamic inputs: item{i} - each connected value becomes a template variable + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `template` | `str` | The jinja2 template string to render | +| `variables` | `dict` | (optional) Optional base dictionary of template variables (dynamic inputs extend/override these) | +| `scope` | `str` | (optional) Optional agent scope (e.g., "director") for template includes | + +This node supports **dynamic inputs** - additional input sockets can be added in the editor as needed, and connected values are collected with key inference (see [Collector Nodes](../../core-concepts/collector_nodes.md)). + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `result` | `str` | The rendered string | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `template` | `text` | `""` | A jinja2 template string (e.g., "Hello, {{ name }}") | diff --git a/docs/user-guide/node-editor/reference/nodes/data.md b/docs/user-guide/node-editor/reference/nodes/data.md new file mode 100644 index 00000000..5eb811c3 --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/data.md @@ -0,0 +1,332 @@ +# Data Nodes + +General-purpose value handling: get/set on arbitrary objects, conditionals, sorting and selection, UUID generation, JSON parsing/serialization and length capping. + + + + +_11 nodes._ + +| Node | Registry path | +| --- | --- | +| [Cap Length](#cap-length) | `data/CapLength` | +| [Contains](#contains) | `data/Contains` | +| [Get](#get) | `data/Get` | +| [JSON](#json) | `data/JSON` | +| [Length](#length) | `data/Length` | +| [Select Item](#select-item) | `data/SelectItem` | +| [Set](#set) | `data/Set` | +| [Set Conditional](#set-conditional) | `data/SetConditional` | +| [Sort](#sort) | `data/Sort` | +| [Update Object](#update-object) | `data/UpdateObject` | +| [UUID](#uuid) | `data/UUID` | + +## Cap Length + +`data/CapLength` + +Applies a maximum length to an iterable (string or list), removing items from the specified side + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `iterable` | `str, list` | Iterable (string or list) to cap | +| `max_length` | `int` | Maximum length to cap the iterable to (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `capped` | `str, list` | Capped iterable (same type as input) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `max_length` | `int` | `100` | Maximum length to cap the iterable to | +| `side` | `str` | `"right"` | Side to pop values from. Choices: `left`, `right`. | + +## Contains + +`data/Contains` + +Checks if a value is in a list or dictionary + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `object` | `any` | Object (list, dict, etc.) - if a generator is provided, it will be converted to a list | +| `value` | `any` | Value | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `contains` | `bool` | True if value is in object, False otherwise | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `value` | `any` | `unset` | Value | + +## Get + +`data/Get` + +Get a value from an object using getattr + +Dictionaries are read by key (missing keys yield None). Lists, tuples +and sets are read by index - the attribute must be an integer, and an +out-of-range index yields UNRESOLVED. Any other object is read via +getattr, yielding None if the attribute does not exist. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `object` | `any` | Object to read from | +| `attribute` | `str` | Attribute name, dict key, or index | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `value` | `any` | The retrieved value | +| `attribute` | `str` | The attribute input, passed through | +| `object` | `any` | The object input, passed through | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `attribute` | `str` | `unset` | Attribute | + +## JSON + +`data/JSON` + +Node that converts a JSON string to a Python object + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `json` | `str` | JSON string | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `data` | `dict,list` | Python object (dict or list) | + +## Length + +`data/Length` + +Gets the length of an iterable + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `object` | `any` | Object (list, dict, etc.) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `length` | `int` | Length of the object (number of items) | + +## Select Item + +`data/SelectItem` + +Node that takes in a list of items and selects one based on the selection function + +- random: picks a random item +- cycle: picks the next item on each execution, wrapping around +- sorted_cycle: like cycle, but iterates the items in sorted order +- direct: picks the item at the index property + +The cycle position is stored in the graph state per node, so it persists +across executions within a run. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `items` | `list` | List of items | +| `except` | `any` | (optional) Item (or list of items) to exclude from selection | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `selected_item` | `any` | Selected item | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `index` | `int` | `0` | index | +| `cycle_index` | `int` | `0` | cycle index. Read-only. | +| `selection_function` | `str` | `"cycle"` | Selection function. Choices: `random`, `cycle`, `sorted_cycle`, `direct`. | + +## Set + +`data/Set` + +Set a value on an object using setattr + +Dictionaries are written by key. Lists are written by index - the +attribute must be an integer. Any other object is written via setattr. +The object is modified in place. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `object` | `any` | Object to modify | +| `attribute` | `str` | Attribute name, dict key, or list index | +| `value` | `any` | Value to set | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `object` | `any` | The modified object | +| `attribute` | `str` | The attribute input, passed through | +| `value` | `any` | The value input, passed through | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `attribute` | `str` | `unset` | Attribute | + +## Set Conditional + +`data/SetConditional` + +Same as Set, but with a state passthrough so it can be placed in a +conditional execution chain + +Dictionaries are written by key, lists by integer index, any other +object via setattr. The object is modified in place. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `object` | `any` | Object to modify | +| `attribute` | `str` | Attribute name, dict key, or list index | +| `value` | `any` | Value to set | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `object` | `any` | The modified object | +| `attribute` | `str` | The attribute input, passed through | +| `value` | `any` | The value input, passed through | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `attribute` | `str` | `unset` | Attribute | + +## Sort + +`data/Sort` + +Sorts a list of items + +If sort_keys is provided, items are sorted by the named attributes +(read via getattr) in order. A sort_keys string input is parsed as JSON. +Without sort_keys, items are sorted by their natural order. The input +list is not modified; a sorted copy is returned. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `items` | `list` | List of items to sort | +| `sort_keys` | `str, list` | Attribute name(s) to sort by - list, or JSON string (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `sorted_items` | `list` | Sorted list of items | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `reverse` | `bool` | `False` | Reverse sort | +| `sort_keys` | `list` | `unset` | Sort keys | + +## Update Object + +`data/UpdateObject` + +Updates an object (dict or attribute-based) with values collected from +dynamic inputs. New item sockets appear as connections are made. + +For each connected item the key to update is inferred: if the value is a +(key, value) tuple that key is used; otherwise the key is derived from the +source socket - for sockets named `value`, the source node's `name`, `key` +or `attribute` input is used, falling back to the source socket name. Dicts +are updated by key, other objects via setattr. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `object` | `any` | The object to update | + +This node supports **dynamic inputs** - additional input sockets can be added in the editor as needed, and connected values are collected with key inference (see [Collector Nodes](../../core-concepts/collector_nodes.md)). + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `object` | `any` | The updated object | + +## UUID + +`data/UUID` + +Generates a UUID string + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | (optional) The graph state | +| `max_length` | `int` | (optional) Maximum number of characters to return | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `uuid` | `str` | A UUID string (e.g., "550e8400-e29b-41d4-a716-446655440000") | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `max_length` | `int` | `36` | Maximum number of characters to return | diff --git a/docs/user-guide/node-editor/reference/nodes/event.md b/docs/user-guide/node-editor/reference/nodes/event.md new file mode 100644 index 00000000..b1868f90 --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/event.md @@ -0,0 +1,196 @@ +# Event Nodes + +Emit messages and status updates from graphs, and react to scene events with the **Event** listener node. See [Events](../../core-concepts/events.md) for the event system and the [Events reference](../events.md) for every event a graph can listen to. + + + + +_7 nodes._ + +| Node | Registry path | +| --- | --- | +| [Emit Agent Message](#emit-agent-message) | `event/EmitAgentMessage` | +| [Emit Scene Status](#emit-scene-status) | `event/EmitSceneStatus` | +| [Emit Status](#emit-status) | `event/EmitStatus` | +| [Emit Status (Conditional)](#emit-status-conditional) | `event/EmitStatusConditional` | +| [Emit System Message](#emit-system-message) | `event/EmitSystemMessage` | +| [Emit World Editor Sync](#emit-world-editor-sync) | `event/EmitWorldEditorSync` | +| [Event](#event) | `event/Event` | + +## Emit Agent Message + +`event/EmitAgentMessage` + +Emits an agent message to the UX + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `message` | `str` | (optional) The message text to emit | +| `agent` | `agent,str` | (optional) The agent (agent object or agent name) | +| `header` | `str` | (optional) The header of the message | +| `message_color` | `str` | (optional) The color of the message | +| `meta` | `dict` | (optional) The meta data of the message | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `emitted` | `bool` | Whether the message was emitted (True) or not (False) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `message` | `str` | `""` | The message text to emit | +| `agent` | `str` | `""` | The agent the message is attributed to. Choices are generated at runtime. | +| `header` | `str` | `""` | The header of the message | +| `message_color` | `str` | `"grey"` | The color of the message. Choices are generated at runtime. | +| `meta` | `dict` | `{}` | The meta data of the message | + +## Emit Scene Status + +`event/EmitSceneStatus` + +Emits the scene status object to the UX + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | + +## Emit Status + +`event/EmitStatus` + +Emits a status message + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `message` | `str` | (optional) The message text to emit | +| `status` | `str` | (optional) The status of the message | +| `as_scene_message` | `bool` | Whether to emit the message as a scene message (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `emitted` | `bool` | Whether the message was emitted (True) or not (False) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `message` | `str` | `""` | The message text to emit | +| `status` | `str` | `"info"` | The status of the message. Choices are generated at runtime. | +| `as_scene_message` | `bool` | `False` | Whether to emit the message as a scene message | + +## Emit Status (Conditional) + +`event/EmitStatusConditional` + +Emits a status message if a condition is met + +Unlike EmitStatus, the `state` input is required, so the message is +only emitted when the state input actually receives a value - +connect it through a conditional branch to gate the emission. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `message` | `str` | (optional) The message text to emit | +| `status` | `str` | (optional) The status of the message | +| `as_scene_message` | `bool` | Whether to emit the message as a scene message (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `emitted` | `bool` | Whether the message was emitted (True) or not (False) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `message` | `str` | `""` | The message text to emit | +| `status` | `str` | `"info"` | The status of the message. Choices are generated at runtime. | +| `as_scene_message` | `bool` | `False` | Whether to emit the message as a scene message | + +## Emit System Message + +`event/EmitSystemMessage` + +Emits a system message + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `message_title` | `str` | The title of the message (optional) | +| `message` | `str` | (optional) The message text to emit | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `message_title` | `str` | `""` | The title of the message | +| `message` | `text` | `""` | The message text to emit | +| `font_color` | `str` | `"grey"` | The color of the message. Choices are generated at runtime. | +| `icon` | `str` | `"mdi-information"` | The icon of the message | +| `display` | `str` | `"text"` | Whether to display the message. Choices are generated at runtime. | +| `as_markdown` | `bool` | `False` | Whether to render the message as markdown | + +## Emit World Editor Sync + +`event/EmitWorldEditorSync` + +Sends a world editor sync message which on the UX side +will cause the world editor to sync its state with the server + +This is useful when the world editor needs to be updated with the latest state. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | + +## Event + +`event/Event` + +Returns the current event object when inside a Listen node module. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `event` | `event` | The current event object | diff --git a/docs/user-guide/node-editor/reference/nodes/focal.md b/docs/user-guide/node-editor/reference/nodes/focal.md new file mode 100644 index 00000000..73f6cea8 --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/focal.md @@ -0,0 +1,221 @@ +# AI Function Nodes (Focal) + +Talemate's AI function-calling layer ("focal"). Define callable functions with typed arguments, hand them to an agent prompt, then unpack the calls the AI made and collect their results. Used by flows like the director chat actions. + + + + +_7 nodes._ + +| Node | Registry path | +| --- | --- | +| [AI Function Argument](#ai-function-argument) | `focal/Argument` | +| [AI Function Callback](#ai-function-callback) | `focal/Callback` | +| [Collect AI Function Call Results](#collect-ai-function-call-results) | `focal/CollectResults` | +| [AI Function Calling](#ai-function-calling) | `focal/Focal` | +| [AI Function Callback Metadata](#ai-function-callback-metadata) | `focal/Metadata` | +| [Process AI Function Call](#process-ai-function-call) | `focal/ProcessCall` | +| [Unpack AI Function Call](#unpack-ai-function-call) | `focal/UnpackCall` | + +## AI Function Argument + +`focal/Argument` + +Represents an argument to an AI function. + +Extends the function argument with instructions that tell the AI how +to fill the argument during AI function calling. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `value` | `any` | The value of the argument (during function execution) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `unset` | The name of the argument | +| `typ` | `str` | `"str"` | The type of the argument. Choices: `str`, `int`, `float`, `bool`, `list`, `any`. | +| `instructions` | `text` | `""` | The instructions for the argument | + +## AI Function Callback + +`focal/Callback` + +Defines an AI function callback for use with the FOCAL system. + +Arguments, instructions and examples are collected from the focal +Argument and Metadata nodes inside the supplied function graph. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `fn` | `function` | The function to call (Returned from an GetFunction node) | +| `name` | `str` | (optional) The name of the callback (optional, overrides the property) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `callback` | `focal/callback` | The focal.Callback instance | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `"my_function"` | The name of the callback | +| `allow_multiple_calls` | `bool` | `False` | Whether the function can be called multiple times | +| `allow_concurrent` | `bool` | `False` | Whether calls to this function can run concurrently (requires client concurrent inference support) | + +## Collect AI Function Call Results + +`focal/CollectResults` + +Collects the results of a list of calls + +If a name is provided, only results from calls with that name are +collected. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `calls` | `list` | The list of calls (focal.Call instances) | +| `name` | `str` | Only collect results from calls with this name (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `calls` | `list` | The calls input, passed through | +| `results` | `list` | The list of collected call results | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `unset` | The name of the call to collect results from | + +## AI Function Calling + +`focal/Focal` + +Main node for calling AI functions using the FOCAL system. + +Either a template or a prompt must be provided. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The current graph state | +| `template` | `str` | (optional) The prompt template name; This template will be used to generate the prompt that facilitates the AI function call(s) | +| `prompt` | `prompt` | (optional) A Prompt instance to use instead of a template | +| `callbacks` | `list` | A list of focal.Callback instances that define the functions to call | +| `agent` | `agent` | The agent to use for the AI function call | +| `template_vars` | `dict` | (optional) A dictionary of variables to use in the template | +| `max_calls` | `int` | (optional) The maximum number of calls to make | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The current graph state | +| `calls` | `list` | The list of calls made (focal.Call instances) | +| `call_payloads` | `list` | The payload dictionaries of the calls made | +| `response` | `str` | The raw response from the processed prompt | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `template` | `str` | `unset` | The prompt template | +| `max_calls` | `int` | `1` | The maximum number of calls to make. Range: 1 – 10. | +| `retries` | `int` | `0` | The number of retries to make. Range: 0 – 10. | +| `response_length` | `int` | `1024` | The maximum length of the response. Range: 1 – 8192. | +| `max_concurrent` | `int` | `3` | Maximum number of concurrent callback executions. Range: 1 – 10. | + +## AI Function Callback Metadata + +`focal/Metadata` + +Represents metadata within a callback in the focal system. + +Allowing to specify instructions and examples for the callback. + +The node does no work when executed - it is discovered inside the +function graph when an AI function callback is built, and its +instructions and examples are attached to the callback. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | Connects the node into the function graph so it can be discovered | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | Not populated at runtime | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `instructions` | `text` | `""` | The instructions for the callback | +| `examples` | `list` | `[]` | The examples for the callback | + +## Process AI Function Call + +`focal/ProcessCall` + +Process the AI function call result. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `calls` | `list` | The list of calls made (focal.Call instances) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `name` | `str` | The name of the call | +| `arguments` | `dict` | The arguments of the call | +| `result` | `any` | The result of the call | +| `uid` | `str` | The UID of the call | +| `called` | `bool` | Whether the call was made (if this is False, likely something went wrong) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `unset` | The name of the call to process | + +## Unpack AI Function Call + +`focal/UnpackCall` + +Unpacks a focal.Call instance + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `call` | `focal/call` | The focal.Call instance to unpack | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `name` | `str` | The name of the call | +| `arguments` | `dict` | The arguments of the call | +| `result` | `any` | The result of the call | +| `uid` | `str` | The UID of the call | +| `called` | `bool` | Whether the call was made | +| `error` | `str` | The error message if the call failed | diff --git a/docs/user-guide/node-editor/reference/nodes/index.md b/docs/user-guide/node-editor/reference/nodes/index.md new file mode 100644 index 00000000..ba670422 --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/index.md @@ -0,0 +1,61 @@ +# Node Reference + +Complete reference of every node available in the node editor, grouped by category. + +Every node has a **registry path** (like `scene/GetCharacter`) that identifies it uniquely — it is what you see when searching for a node in the editor (double-click the canvas) and what node module JSON files store. The first path segment groups related nodes and maps to the pages below. Each entry documents the node's input and output sockets and its properties. + +If you are new to the node editor, start with the [core concepts](../../core-concepts/functions.md) section — the reference pages assume you know how graphs, sockets and properties work. + + + + +Every node available in the node editor, grouped by category. Each page lists the node's registry path, inputs, outputs and properties. + +| Page | Nodes | Description | +| --- | --- | --- | +| [Agent Nodes](agents.md) | 9 | Generic agent interaction: get an agent, call agent functions, read and count agent state variables. | +| [AI Function Nodes (Focal)](focal.md) | 7 | AI function calling: define callable functions and arguments, unpack calls and collect results. | +| [Asset Meta And Selection Nodes](assets-meta.md) | 6 | Asset metadata, search, selection and attachment context for passing assets to generation. | +| [Asset Nodes](assets.md) | 9 | Scene asset CRUD: add, get, list, remove and update assets, cover and avatar images. | +| [Context ID Nodes](context-id.md) | 11 | Context IDs: build, convert, compress, read and write context-addressable values and pins. | +| [Conversation Agent Nodes](agents-conversation.md) | 2 | Generate character dialogue through the conversation agent. | +| [Core Nodes](core.md) | 7 | Graph plumbing: module Input/Output/Property, Route, Watch, Null and Stage. | +| [Creator Agent Nodes](agents-creator.md) | 9 | Contextual content generation: character attributes, dialogue instructions, content context and thematic lists. | +| [Data Nodes](data.md) | 11 | General value handling: get/set, conditionals, sorting, selection, UUIDs, JSON, object updates and length/capping helpers. | +| [Dict And List Nodes](data-collections.md) | 14 | Dictionaries and lists: make, get, set, update, pop, append, combine and collect. | +| [Director Agent Nodes](agents-director.md) | 15 | Director operations: scene direction, character persistence, voice assignment, dice rolls, narrator/actor direction functions and websocket handlers. | +| [Director Auto-Direction Nodes](agents-director-auto.md) | 6 | Automatic scene direction: candidates, scene intent, instruction cadence and auto-direction generation. | +| [Director Chat Actions](agents-director-chat-actions.md) | 10 | The director chat's action modules: direct the scene, update context/gamestate/story arc, create images, query, prompt or yield to the user. | +| [Director Chat Instructors](agents-director-chat-instructors.md) | 10 | The director chat's instructor modules: instruct characters, the narrator, world/history/story updates and character creation. | +| [Director Chat Nodes](agents-director-chat.md) | 13 | Director chat plumbing: chat context, action arguments, confirmation and sub-actions, plus context/query modules. | +| [Director Planning Nodes](agents-director-plan.md) | 13 | Director plans: create, update, advance and complete plans and their tasks. | +| [Editor Agent Nodes](agents-editor.md) | 4 | Clean up user input, narration and character messages through the editor agent. | +| [Event Nodes](event.md) | 7 | Emit agent/system/status messages and listen to scene events. | +| [Flow Exception Nodes](raise.md) | 3 | Stop graph execution or raise errors: Stop, input value errors and acted-as-character signals. | +| [Function Nodes](core-functions.md) | 11 | Callable node modules: define, get and call functions, for-each calls, returns, error handlers, breakpoints and scoped API functions. | +| [Input Nodes](input.md) | 2 | Process and wait for player input in the scene loop. | +| [Logic Nodes](core-logic.md) | 12 | Branching and comparison: switches, routers, boolean logic and conditional routing. | +| [Memory Agent Nodes](agents-memory.md) | 2 | Query the long-term memory context database and unpack memory documents. | +| [Narrator Agent Nodes](agents-narrator.md) | 12 | Generate narration messages: progress, scene, environment, query, character entry/exit and time narration. | +| [Number Nodes](data-number.md) | 9 | Numeric values: conversion, arithmetic, comparison and random numbers. | +| [Packaging Nodes](util-packaging.md) | 4 | Node module packaging: define, install and configure scene packages. | +| [Prompt Nodes](prompt.md) | 7 | Build prompts and control generation: prompt construction, defaults and inference settings. | +| [Response Parsing Nodes](response.md) | 9 | Parse LLM responses: extractors for anchors, lists, movie-script dialogue and structured data. | +| [Scene Character Nodes](scene-characters.md) | 18 | Characters in the scene: get, make, activate/deactivate, remove and read/write attributes, details and descriptions. | +| [Scene History Nodes](scene-history.md) | 11 | Scene message history: query, summarize and manage archive entries and character activity. | +| [Scene Intent Nodes](scene-intent.md) | 10 | Scene intention: read and set the scene intent, scene phase and scene types. | +| [Scene Message Nodes](scene-messages.md) | 6 | Scene messages: create, version, hide and manipulate messages in the scene log. | +| [Scene Nodes](scene.md) | 18 | Scene lifecycle and metadata: title, description, introduction, content classification, scene loop and storyline modules. | +| [State Nodes](state.md) | 14 | Graph state variables: get/set/unset across scopes (local, parent, shared, scene loop, game), counters and path variants. | +| [String Nodes](data-string.md) | 16 | Strings: formatting, templates, joining, splitting, cleanup and excerpts. | +| [Summarizer Agent Nodes](agents-summarizer.md) | 5 | Summarization: scene progression summaries, tag generation and message avatar hooks. | +| [Utility Nodes](util.md) | 5 | Miscellaneous helpers: counters, diffs, list extraction, ISO date durations and module styling. | +| [UX Nodes](ux.md) | 5 | User-facing interface elements: choice elements, status displays and interactive prompts. | +| [Validation Nodes](validation.md) | 7 | Validate values: characters, asset IDs, context IDs, containment and set/unset checks. | +| [Visualizer Agent Generation Nodes](agents-visual-generation.md) | 7 | Visual generation: generation requests, image generation, backend selection/status and image analysis. | +| [Visualizer Agent Modules](agents-visual-modules.md) | 10 | Ready-made visual generation flows: generate visual assets, avatars, portraits, image edits and regenerations. | +| [Visualizer Agent Prompt Nodes](agents-visual.md) | 8 | Visual prompt building: prompt parts, styles, finalization and unpacking. | +| [Voice Agent Nodes](agents-tts.md) | 5 | Text-to-speech: get and unpack voices and generate speech through the voice agent. | +| [Websocket Nodes](websocket.md) | 4 | Websocket plumbing: routers, responses and operation done/failed signals. | +| [World Entry Nodes](scene-world-state.md) | 8 | World entries (get, save, unpack, remove) and generation options: spices and writing styles. | +| [World State Agent Nodes](agents-world-state.md) | 13 | World state operations: time advancement, character progression, world state emission and extraction. | diff --git a/docs/user-guide/node-editor/reference/nodes/input.md b/docs/user-guide/node-editor/reference/nodes/input.md new file mode 100644 index 00000000..7b8427dc --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/input.md @@ -0,0 +1,83 @@ +# Input Nodes + +Receive and process player input inside the scene loop. + + + + +_2 nodes._ + +| Node | Registry path | +| --- | --- | +| [Process Input](#process-input) | `input/ProcessInput` | +| [Get Input](#get-input) | `input/WaitForInput` | + +## Process Input + +`input/ProcessInput` + +Node module (base type `core/Graph`) defined in `src/talemate/game/engine/nodes/modules/scene/process-input.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `text` | `str` | | +| `interaction_state` | `interaction_state` | | +| `character` | `character` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `text` | `str` | | +| `interaction_state` | `interaction_state` | | +| `cleaned_input` | `str` | | + +## Get Input + +`input/WaitForInput` + +Get input from the user to interact with the scene. + +This node will wait for the user to input a message, and then return the message +for processing. + +Properties + +- allow_commands: Allow commands to be executed, using the ! prefix + +Abort Conditions: + +The chain of nodes connected to the abort_condition socket will be executed +on each iteration of the input loop. If the chain resolves to a boolean value, +the input loop will be aborted. + +You can use this to check for conditions that should abort the input loop. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The current graph state | +| `player_character` | `character` | (optional) The player character | +| `reason` | `str` | (optional) The reason for the input | +| `prefix` | `str` | (optional) The prefix for the input message (similar to a cli prompt) | +| `abort_condition` | `any` | (optional) A condition to abort the input loop | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `input` | `str` | The input message | +| `interaction_state` | `interaction_state` | The interaction state | +| `character` | `character` | The character object | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `reason` | `str` | `"talk"` | The reason for the input | +| `prefix` | `str` | `""` | The prefix for the input message (similar to a cli prompt) | +| `allow_commands` | `bool` | `True` | Allow commands to be executed, using the ! prefix | diff --git a/docs/user-guide/node-editor/reference/nodes/prompt.md b/docs/user-guide/node-editor/reference/nodes/prompt.md new file mode 100644 index 00000000..9bd5b956 --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/prompt.md @@ -0,0 +1,249 @@ +# Prompt Nodes + +Build prompts and run generation from graphs: load and render [prompt templates](../../core-concepts/prompt-templates.md), assemble prompts dynamically (see [Prompt Building](../../core-concepts/prompt_building.md)), and generate a response with a client. + + + + +_7 nodes._ + +| Node | Registry path | +| --- | --- | +| [Build Prompt](#build-prompt) | `prompt/BuildPrompt` | +| [Clean Response](#clean-response) | `prompt/CleanResponse` | +| [Generate Response](#generate-response) | `prompt/GenerateResponse` | +| [Load Template](#load-template) | `prompt/LoadTemplate` | +| [Prompt From Template](#prompt-from-template) | `prompt/PromptFromTemplate` | +| [Render Prompt](#render-prompt) | `prompt/RenderPrompt` | +| [Template Variables](#template-variables) | `prompt/TemplateVariables` | + +## Build Prompt + +`prompt/BuildPrompt` + +Builds a prompt from a jinja2 template, exposing common context building +blocks (scene, memory, extra context, dynamic instructions etc.) as +template variables that can be toggled through the node's properties. + +Loads the template `{scope}.{template_file}` and renders it against the +active scene using the agent's client for token budgeting. Outputs both the +Prompt object and the rendered prompt text. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `agent` | `agent` | The agent whose client is used for rendering and token budgeting | +| `instructions` | `str` | Task instructions made available to the template (optional) | +| `dynamic_context` | `list` | List of DynamicInstruction objects injected as extra context (optional) | +| `dynamic_instructions` | `list` | List of DynamicInstruction objects injected as instructions (optional) | +| `memory_prompt` | `str` | Semantic query / retrieval prompt for the memory context (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `state` | The state input, passed through | +| `agent` | `agent` | The agent input, passed through | +| `prompt` | `prompt` | The built Prompt object | +| `rendered` | `str` | The rendered prompt text | +| `response_length` | `int` | The configured response length, passed through | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `template_file` | `str` | `"base"` | The template file to use | +| `scope` | `str` | `"common"` | The scope of the template | +| `instructions` | `text` | `""` | The instructions to include in the prompt | +| `reserved_tokens` | `int` | `312` | The number of tokens to reserve to account for any overhead. Range: 16 – 1024. | +| `limit_max_tokens` | `int` | `0` | Limit the maximum number of tokens used for the prompt (0 = client context limit). Min: 0. | +| `include_scene_intent` | `bool` | `True` | Include the scene intent | +| `include_extra_context` | `bool` | `True` | Include the extra context (pins, reinforcements, content classification) | +| `include_memory_context` | `bool` | `True` | Include the memory context | +| `include_scene_context` | `bool` | `True` | Include the scene context | +| `include_character_context` | `bool` | `False` | Include the active character context | +| `include_gamestate_context` | `bool` | `False` | Include the game state context | +| `memory_prompt` | `str` | `""` | Semantic query / retrieval prompt for memory | +| `prefill_prompt` | `str` | `""` | Prefill the prompt with a response | +| `return_prefill_prompt` | `bool` | `False` | Return the prefill prompt with the response | +| `dedupe_enabled` | `bool` | `True` | Enable deduplication | +| `response_length` | `int` | `0` | The length of the response | +| `technical` | `bool` | `False` | Include the technical context where applicable (ids, typing etc.) | + +## Clean Response + +`prompt/CleanResponse` + +Cleans a response + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `response` | `str` | The response to clean | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `cleaned` | `str` | The cleaned response | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `partial_sentences` | `bool` | `True` | partial_sentences | +| `strip_partial_sentences` | `bool` | `True` | Strip partial sentences from the response | + +## Generate Response + +`prompt/GenerateResponse` + +Sends a prompt to the agent and generates a response + +Retries up to `attempts` times on an empty response. When a +response_spec is provided its extractors are applied to the +response; otherwise, if the template produced a data structure, +that is used as the extracted value. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `agent` | `agent` | The agent to send the prompt to | +| `prompt` | `prompt` | The prompt to send to the agent | +| `action_type` | `str` | Classification of the generated response (optional) | +| `response_length` | `int` | The maximum length of the response (optional) | +| `response_spec` | `response/spec` | (optional) Optional ResponseSpec for extracting structured data from response | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `state` | The state input, passed through | +| `agent` | `agent` | The agent that generated the response | +| `prompt` | `prompt` | The Prompt object, passed through | +| `response` | `str` | The response from the agent | +| `data_obj` | `dict,list` | The data structure of the response (when data_output is enabled) | +| `captured_context` | `str` | Context captured by the template during rendering | +| `rendered_prompt` | `str` | The rendered prompt | +| `response_spec` | `response/spec` | Pass-through of the input response spec | +| `extracted` | `dict` | Dictionary of extracted values (when response_spec provided) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `data_output` | `bool` | `False` | Output the response as a data structure | +| `data_multiple` | `bool` | `False` | Allow multiple data structures in the response | +| `response_length` | `int` | `256` | The maximum length of the response | +| `action_type` | `str` | `"scene_direction"` | Classification of the generated response. Choices: `analyze`, `conversation`, `create`, `edit`, `narrate`, `scene_direction`, `summarize`, `visualize`, `world_state`. | +| `attempts` | `int` | `1` | The number of attempts (retry on empty response) | + +## Load Template + +`prompt/LoadTemplate` + +Loads the raw unrendered jinja2 template content based on scope and name + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `name` | `str` | (optional) The template name (without .jinja2 extension) | +| `scope` | `str` | (optional) The template scope (optional, defaults to property) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `template_content` | `str` | The raw unrendered template content as a string | +| `scope` | `str` | The resolved template scope, passed through | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `scope` | `str` | `"scene"` | The template scope. Choices are generated at runtime. | +| `name` | `str` | `""` | The template name to load (without .jinja2 extension) | + +## Prompt From Template + +`prompt/PromptFromTemplate` + +Loads a talemate template prompt + +Either a template file or raw template text must be provided (but +not both). + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `template_file` | `str` | The template file to load (optional) | +| `template_text` | `str` | Raw template text to use instead of a file (optional) | +| `variables` | `dict` | The variables to use in the template (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `prompt` | `prompt` | The Prompt instance | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `scope` | `str` | `"scene"` | The template scope. Choices are generated at runtime. | +| `template_file` | `str` | `""` | The template to load | +| `template_text` | `text` | `""` | The template text to use | +| `dedupe` | `bool` | `True` | Enable prompt deduplication | + +## Render Prompt + +`prompt/RenderPrompt` + +Renders a prompt + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `prompt` | `prompt` | The prompt to render | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `rendered` | `str` | The rendered prompt | + +## Template Variables + +`prompt/TemplateVariables` + +Helper node that returns a pre defined set of common +template variables + +Variables: + +- scene: The current scene +- scene_title: The scene title (falls back to the scene name) +- max_tokens: The maximum number of tokens in the response +- agent: The relevant agent + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `agent` | `agent` | The relevant agent | +| `merge_with` | `dict` | A dictionary of variables to merge with the pre defined variables (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `variables` | `dict` | A dictionary of variables | +| `agent` | `agent` | The relevant agent | diff --git a/docs/user-guide/node-editor/reference/nodes/raise.md b/docs/user-guide/node-editor/reference/nodes/raise.md new file mode 100644 index 00000000..076c8350 --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/raise.md @@ -0,0 +1,89 @@ +# Flow Exception Nodes + +Stop or redirect graph execution by raising exceptions: **Stop** ends the current run, **Input Value Error** reports invalid socket input, and **Acted As Character** signals that a character turn happened. See [Error Handling](../../core-concepts/error-handling.md). + + + + +_3 nodes._ + +| Node | Registry path | +| --- | --- | +| [Acted As Character](#acted-as-character) | `raise/ActedAsCharacter` | +| [Input Value Error](#input-value-error) | `raise/InputValueError` | +| [Stop](#stop) | `raise/Stop` | + +## Acted As Character + +`raise/ActedAsCharacter` + +Raises an ActedAsCharacter exception. + +This is used to communicate to the main scene loop that the user +has performed an action as a specific character. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The current graph state | +| `character_name` | `str` | The name of the character the user acted as | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | + +## Input Value Error + +`raise/InputValueError` + +Raises an InputValueError exception. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The current state | +| `message` | `str` | The message to raise the exception with | +| `field` | `str` | The field that caused the error | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `message` | `str` | `""` | The message to raise the exception with | +| `field` | `str` | `""` | The field that caused the error | + +## Stop + +`raise/Stop` + +Raises the specified node / scene loop exception +to stop execution of the current graph + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The current state | +| `exception` | `any` | (optional) The exception to raise | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The current state | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `exception` | `str` | `"StopGraphExecution"` | Exception. Choices: `StopGraphExecution`, `StopModule`, `LoopBreak`, `LoopContinue`, `LoopExit`, `ExitScene`, `RestartSceneLoop`, `ResetScene`, `StageExit`. | diff --git a/docs/user-guide/node-editor/reference/nodes/response.md b/docs/user-guide/node-editor/reference/nodes/response.md new file mode 100644 index 00000000..5ed599bb --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/response.md @@ -0,0 +1,293 @@ +# Response Parsing Nodes + +Extract structured data from LLM responses. A **Response Spec** describes the expected format; extractors pull out anchored sections, code blocks, lists or regex matches from the raw response text. + + + + +_9 nodes._ + +| Node | Registry path | +| --- | --- | +| [After Anchor Extractor](#after-anchor-extractor) | `response/AfterAnchorExtractor` | +| [Anchor Extractor](#anchor-extractor) | `response/AnchorExtractor` | +| [AsIs Extractor](#asis-extractor) | `response/AsIsExtractor` | +| [Code Block Extractor](#code-block-extractor) | `response/CodeBlockExtractor` | +| [Complex Anchor Extractor](#complex-anchor-extractor) | `response/ComplexAnchorExtractor` | +| [Complex Code Block Extractor](#complex-code-block-extractor) | `response/ComplexCodeBlockExtractor` | +| [Regex Extractor](#regex-extractor) | `response/RegexExtractor` | +| [Response Spec](#response-spec) | `response/ResponseSpec` | +| [Strip Prefix Extractor](#strip-prefix-extractor) | `response/StripPrefixExtractor` | + +## After Anchor Extractor + +`response/AfterAnchorExtractor` + +Creates an AfterAnchorExtractor that extracts everything after a start marker. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `name` | `str` | (optional) Optional override for name property | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `name` | `str` | The extractor name (pass-through) | +| `value` | `response/extractor` | The AfterAnchorExtractor instance | +| `spec` | `response/spec` | A ResponseSpec containing just this extractor | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `""` | Extractor name (key for dict collector) | +| `start` | `str` | `""` | Start marker to search for | +| `stop_at` | `str` | `""` | Optional end marker (empty = no stop) | +| `fallback_to_full` | `bool` | `False` | Return full response if start marker not found | +| `trim` | `bool` | `True` | Trim whitespace from result | + +## Anchor Extractor + +`response/AnchorExtractor` + +Creates an AnchorExtractor that extracts content between anchor tags. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `name` | `str` | (optional) Optional override for name property | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `name` | `str` | The extractor name (pass-through) | +| `value` | `response/extractor` | The AnchorExtractor instance | +| `spec` | `response/spec` | A ResponseSpec containing just this extractor | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `""` | Extractor name (key for dict collector) | +| `left` | `str` | `""` | Left anchor tag (e.g., ) | +| `right` | `str` | `""` | Right anchor tag (e.g., ) | +| `fallback_to_full` | `bool` | `False` | Return full response if anchors not found | +| `trim` | `bool` | `True` | Trim whitespace from result | + +## AsIs Extractor + +`response/AsIsExtractor` + +Creates an AsIsExtractor that returns the entire response as-is. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `name` | `str` | (optional) Optional override for name property | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `name` | `str` | The extractor name (pass-through) | +| `value` | `response/extractor` | The AsIsExtractor instance | +| `spec` | `response/spec` | A ResponseSpec containing just this extractor | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `""` | Extractor name (key for dict collector) | +| `trim` | `bool` | `True` | Trim whitespace from result | + +## Code Block Extractor + +`response/CodeBlockExtractor` + +Creates a CodeBlockExtractor that extracts code blocks from tagged sections. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `name` | `str` | (optional) Optional override for name property | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `name` | `str` | The extractor name (pass-through) | +| `value` | `response/extractor` | The CodeBlockExtractor instance | +| `spec` | `response/spec` | A ResponseSpec containing just this extractor | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `""` | Extractor name (key for dict collector) | +| `left` | `str` | `""` | Left anchor tag (e.g., ) | +| `right` | `str` | `""` | Right anchor tag (e.g., ) | +| `validate_structured` | `bool` | `True` | Validate content as JSON/YAML for no-fence fallback | +| `fallback_to_full` | `bool` | `False` | Return full response if anchors not found | +| `trim` | `bool` | `True` | Trim whitespace from result | + +## Complex Anchor Extractor + +`response/ComplexAnchorExtractor` + +Creates a ComplexAnchorExtractor with nesting awareness. + +Tracks multiple tags and only extracts target blocks when at root level. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `name` | `str` | (optional) Optional override for name property | +| `tracked_tags` | `list` | (optional) Optional override for tracked_tags property | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `name` | `str` | The extractor name (pass-through) | +| `value` | `response/extractor` | The ComplexAnchorExtractor instance | +| `spec` | `response/spec` | A ResponseSpec containing just this extractor | +| `tracked_tags` | `list` | The tracked tags list (pass-through) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `""` | Extractor name (key for dict collector) | +| `left` | `str` | `""` | Left anchor tag (e.g., ) | +| `right` | `str` | `""` | Right anchor tag (e.g., ) | +| `tracked_tags` | `list` | `[]` | Tags to track for nesting awareness | +| `fallback_to_full` | `bool` | `False` | Return full response if anchors not found | +| `trim` | `bool` | `True` | Trim whitespace from result | + +## Complex Code Block Extractor + +`response/ComplexCodeBlockExtractor` + +Creates a ComplexCodeBlockExtractor with nesting awareness for code blocks. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `name` | `str` | (optional) Optional override for name property | +| `tracked_tags` | `list` | (optional) Optional override for tracked_tags property | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `name` | `str` | The extractor name (pass-through) | +| `value` | `response/extractor` | The ComplexCodeBlockExtractor instance | +| `spec` | `response/spec` | A ResponseSpec containing just this extractor | +| `tracked_tags` | `list` | The tracked tags list (pass-through) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `""` | Extractor name (key for dict collector) | +| `left` | `str` | `""` | Left anchor tag (e.g., ) | +| `right` | `str` | `""` | Right anchor tag (e.g., ) | +| `tracked_tags` | `list` | `[]` | Tags to track for nesting awareness | +| `validate_structured` | `bool` | `True` | Validate content as JSON/YAML for no-fence fallback | +| `fallback_to_full` | `bool` | `False` | Return full response if anchors not found | +| `trim` | `bool` | `True` | Trim whitespace from result | + +## Regex Extractor + +`response/RegexExtractor` + +Creates a RegexExtractor that extracts content using regex patterns. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `name` | `str` | (optional) Optional override for name property | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `name` | `str` | The extractor name (pass-through) | +| `value` | `response/extractor` | The RegexExtractor instance | +| `spec` | `response/spec` | A ResponseSpec containing just this extractor | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `""` | Extractor name (key for dict collector) | +| `pattern` | `str` | `""` | Regex pattern with capture group | +| `case_insensitive` | `bool` | `False` | Ignore case when matching | +| `group` | `int` | `1` | Capture group to extract | +| `all_matches` | `bool` | `False` | Return list of all matches | +| `trim` | `bool` | `True` | Trim whitespace from result | + +## Response Spec + +`response/ResponseSpec` + +Creates a ResponseSpec from a dictionary of extractors. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `extractors` | `dict` | Dictionary of name -> Extractor (from DictCollector) | +| `required` | `list` | (optional) Optional override for required property | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `spec` | `response/spec` | The ResponseSpec instance | +| `extractors` | `dict` | Pass-through of the extractors dict | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `required` | `list` | `[]` | List of required field names | + +## Strip Prefix Extractor + +`response/StripPrefixExtractor` + +Creates a StripPrefixExtractor that strips patterns using regex substitution. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `name` | `str` | (optional) Optional override for name property | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `name` | `str` | The extractor name (pass-through) | +| `value` | `response/extractor` | The StripPrefixExtractor instance | +| `spec` | `response/spec` | A ResponseSpec containing just this extractor | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `""` | Extractor name (key for dict collector) | +| `pattern` | `str` | `""` | Regex pattern to strip | +| `replacement` | `str` | `""` | Replacement string | +| `trim` | `bool` | `True` | Trim whitespace from result | diff --git a/docs/user-guide/node-editor/reference/nodes/scene-characters.md b/docs/user-guide/node-editor/reference/nodes/scene-characters.md new file mode 100644 index 00000000..23ef4e65 --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/scene-characters.md @@ -0,0 +1,459 @@ +# Scene Character Nodes + +Everything about characters in the scene: fetch them (by name or player status), create and persist new ones, activate/deactivate them for the current scene, and read or write their attributes, details and descriptions. + + + + +_18 nodes._ + +| Node | Registry path | +| --- | --- | +| [Activate Character](#activate-character) | `scene/ActivateCharacter` | +| [Deactivate Character](#deactivate-character) | `scene/DeactivateCharacter` | +| [Get Character](#get-character) | `scene/GetCharacter` | +| [Get Character Attribute](#get-character-attribute) | `scene/GetCharacterAttribute` | +| [Get Character Description](#get-character-description) | `scene/GetCharacterDescription` | +| [Get Character Detail](#get-character-detail) | `scene/GetCharacterDetail` | +| [Get Player Character](#get-player-character) | `scene/GetPlayerCharacter` | +| [Is Active Character](#is-active-character) | `scene/IsActiveCharacter` | +| [Is Player Character](#is-player-character) | `scene/IsPlayerCharacter` | +| [List Characters](#list-characters) | `scene/ListCharacters` | +| [Make Character](#make-character) | `scene/MakeCharacter` | +| [Remove All Characters](#remove-all-characters) | `scene/RemoveAllCharacters` | +| [Remove Character](#remove-character) | `scene/RemoveCharacter` | +| [Set Character Attribute](#set-character-attribute) | `scene/SetCharacterAttribute` | +| [Set Character Description](#set-character-description) | `scene/SetCharacterDescription` | +| [Set Character Detail](#set-character-detail) | `scene/SetCharacterDetail` | +| [Unpack Character](#unpack-character) | `scene/UnpackCharacter` | +| [Update Character Data](#update-character-data) | `scene/UpdateCharacterData` | + +## Activate Character + +`scene/ActivateCharacter` + +Activate a character + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `character` | `character` | The character to activate | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `character` | `character` | The activated character | + +## Deactivate Character + +`scene/DeactivateCharacter` + +Deactivate a character + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `character` | `character` | The character to deactivate | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `character` | `character` | The deactivated character | + +## Get Character + +`scene/GetCharacter` + +Returns a character object from the scene by name + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `character_name` | `str` | The name of the character | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `character` | `character` | The character object | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `partial` | `bool` | `False` | Whether to match on partial name | + +## Get Character Attribute + +`scene/GetCharacterAttribute` + +Get a base attribute from a character + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `character` | `character` | The character object | +| `name` | `str` | (optional) The name of the attribute | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `character` | `character` | The character object, passed through | +| `name` | `str` | The attribute name, passed through | +| `value` | `str` | The attribute value (None if the attribute does not exist) | +| `context_id` | `context_id` | The context ID of the attribute (None if the attribute does not exist) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `""` | The name of the attribute | + +## Get Character Description + +`scene/GetCharacterDescription` + +Get the description of a character + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `character` | `character` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `character` | `character` | | +| `description` | `str` | | + +## Get Character Detail + +`scene/GetCharacterDetail` + +Get a detail from a character + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `character` | `character` | The character object | +| `name` | `str` | (optional) The name of the detail | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `character` | `character` | The character object, passed through | +| `name` | `str` | The detail name, passed through | +| `detail` | `str` | The detail value (None if the detail does not exist) | +| `context_id` | `context_id` | The context ID of the detail (None if the detail does not exist) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `""` | name | +| `detail` | `str` | `""` | The name of the detail | + +## Get Player Character + +`scene/GetPlayerCharacter` + +Get the main player character from the scene + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `character` | `character` | The player character | + +## Is Active Character + +`scene/IsActiveCharacter` + +Returns whether a character is active + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `character` | `character` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `active` | `bool` | | + +## Is Player Character + +`scene/IsPlayerCharacter` + +Returns whether a character is the player character + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `character` | `character` | The character object | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `yes` | `bool` | True if the character is the player character | +| `no` | `bool` | True if the character is not the player character | +| `character` | `character` | The character object | + +## List Characters + +`scene/ListCharacters` + +Returns a list of characters in the scene, filtered by status + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `character_status` | `str` | (optional) Which characters to include - "active", "inactive" or "all" | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `characters` | `list` | The list of matching character objects | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `character_status` | `str` | `"all"` | Which characters to list (active, inactive or all). Choices: `active`, `inactive`, `all`. | + +## Make Character + +`scene/MakeCharacter` + +Make a character + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `name` | `str` | The name of the character | +| `description` | `text` | (optional) The description of the character | +| `color` | `color` | (optional) The color of the character name | +| `base_attributes` | `dict` | (optional) The base attributes of the character | +| `is_player` | `bool` | (optional) Whether the character is the player character | +| `add_to_scene` | `bool` | (optional) Whether to add the character to the scene | +| `is_active` | `bool` | (optional) Whether the character is active | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `character` | `character` | The character object | +| `actor` | `actor` | The actor object | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `unset` | The name of the character | +| `description` | `text` | `""` | The description of the character | +| `color` | `color` | `unset` | The color of the character name | +| `base_attributes` | `dict` | `unset` | The base attributes of the character | +| `is_player` | `bool` | `False` | Whether the character is the player character | +| `add_to_scene` | `bool` | `True` | Whether to add the character to the scene | +| `is_active` | `bool` | `True` | Whether the character is active | + +## Remove All Characters + +`scene/RemoveAllCharacters` + +Remove all active characters from the scene (inactive characters are +not affected) + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | + +## Remove Character + +`scene/RemoveCharacter` + +Remove a character from the scene + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `character` | `character` | The character to remove | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | + +## Set Character Attribute + +`scene/SetCharacterAttribute` + +Set an attribute on a character + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `character` | `character` | | +| `name` | `str` | (optional) | +| `value` | `str` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `character` | `character` | | +| `name` | `str` | | +| `value` | `str` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `""` | The name of the attribute | +| `value` | `str` | `unset` | The value of the attribute | + +## Set Character Description + +`scene/SetCharacterDescription` + +Set the description of a character + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `character` | `character` | | +| `description` | `str` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `character` | `character` | | +| `description` | `str` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `description` | `text` | `""` | The description of the character | + +## Set Character Detail + +`scene/SetCharacterDetail` + +Set the details of a character + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `character` | `character` | | +| `name` | `str` | (optional) | +| `value` | `str` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `character` | `character` | | +| `name` | `str` | | +| `value` | `str` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `""` | The name of the detail | +| `value` | `text` | `unset` | The content of the detail | + +## Unpack Character + +`scene/UnpackCharacter` + +Unpack a character into its individual fields + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `character` | `character` | The character object | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `name` | `str` | The name of the character | +| `is_player` | `bool` | Whether the character is the player character | +| `description` | `str` | The character description | +| `base_attributes` | `dict` | The character base attributes dictionary | +| `details` | `dict` | The character details dictionary | +| `color` | `str` | The character name color | +| `actor` | `actor` | The actor instance tied to the character | + +## Update Character Data + +`scene/UpdateCharacterData` + +Update the data of a character + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `character` | `character` | The character object | +| `base_attributes` | `dict` | (optional) The base attributes dictionary | +| `details` | `dict` | (optional) The details dictionary | +| `description` | `str` | (optional) The description string | +| `name` | `str` | (optional) A new name for the character (renames the character) | +| `color` | `str` | (optional) The color of the character name | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `character` | `character` | The updated character object | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `description` | `str` | `unset` | The character description | +| `name` | `str` | `unset` | The character name | +| `color` | `str` | `unset` | The color of the character name | diff --git a/docs/user-guide/node-editor/reference/nodes/scene-history.md b/docs/user-guide/node-editor/reference/nodes/scene-history.md new file mode 100644 index 00000000..5efb0cc9 --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/scene-history.md @@ -0,0 +1,308 @@ +# Scene History Nodes + +Work with the scene's message history and its summarized archive: query messages by type or id, push/pop messages, and manage static archive entries (pre-authored history that survives summarization). + + + + +_11 nodes._ + +| Node | Registry path | +| --- | --- | +| [Character Activity](#character-activity) | `scene/history/ActiveCharacterActivity` | +| [Context History](#context-history) | `scene/history/ContextHistory` | +| [Create Static Archive Entry](#create-static-archive-entry) | `scene/history/CreateStaticArchiveEntry` | +| [Get Message By ID](#get-message-by-id) | `scene/history/GetMessageById` | +| [Scene Has History](#scene-has-history) | `scene/history/HasHistory` | +| [Last Message of Type](#last-message-of-type) | `scene/history/LastMessageOfType` | +| [Pop History](#pop-history) | `scene/history/Pop` | +| [Push History](#push-history) | `scene/history/Push` | +| [Remove Static Archive Entry](#remove-static-archive-entry) | `scene/history/RemoveStaticArchiveEntry` | +| [Static Archive Entries](#static-archive-entries) | `scene/history/StaticArchiveEntries` | +| [Unpack Archive Entry](#unpack-archive-entry) | `scene/history/UnpackArchiveEntry` | + +## Character Activity + +`scene/history/ActiveCharacterActivity` + +Returns a list of all active characters sorted by which were last active + +The most recently active character is first in the list. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `none_have_acted` | `bool` | whether no characters have acted | +| `characters` | `list` | list of characters | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `since_time_passage` | `bool` | `False` | Only include characters that have acted since the last time passage message | + +## Context History + +`scene/history/ContextHistory` + +Compiles history for inclusion in a prompt context. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `budget` | `int` | (optional) The budget for the history (number of tokens, defaults to 8192) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `messages` | `list` | list of messages | +| `compiled` | `str` | compiled message | +| `characters` | `list` | list of characters that have actively participated in the scene | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `budget` | `int` | `8192` | The budget for the history (number of tokens, defaults to 8192). Min: 0. | +| `keep_director_messages` | `bool` | `False` | Whether to keep director messages | +| `keep_investigation_messages` | `bool` | `False` | Whether to keep investigation messages | +| `keep_reinforcement_messages` | `bool` | `False` | Whether to keep reinforcement messages | +| `show_hidden` | `bool` | `False` | Whether to show hidden messages | +| `min_dialogue_length` | `int` | `5` | The minimum length of dialogue to keep, this will ensure that there are always N dialogue messages in the history regardless of whether they are covered by summarization. Min: 0. | +| `label_chapters` | `bool` | `False` | Whether to label chapters in the summarized history | + +## Create Static Archive Entry + +`scene/history/CreateStaticArchiveEntry` + +Create a static (manually written) archive entry in the scene's base history, +dated a given amount of time before the current scene time. + +The time amount and unit are converted to an ISO 8601 duration offset. The +entry must predate the summarized history. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `time_unit` | `str` | The unit of time for the offset (minute, hour, day, week, month, year) | +| `time_amount` | `int` | The amount of time for the offset | +| `text` | `str` | The text of the entry | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `entry` | `history/archive_entry` | The created archive entry | +| `offset` | `str` | The ISO 8601 duration offset that was applied | +| `context_id` | `context_id` | Context ID for the created entry | +| `time_unit` | `str` | The time unit input, passed through | +| `time_amount` | `int` | The time amount input, passed through | +| `text` | `str` | The text input, passed through | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `time_unit` | `str` | `"day"` | The unit of time. Choices: `minute`, `hour`, `day`, `week`, `month`, `year`. | +| `time_amount` | `int` | `1` | The amount of time | +| `text` | `str` | `""` | The text of the entry | + +## Get Message By ID + +`scene/history/GetMessageById` + +Get a message from the scene history by its ID + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `message_id` | `int` | The ID of the message to get | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `message` | `message_object` | The message object (None if not found, or no value if stop_on_missing is true) | +| `message_id` | `int` | The ID of the message (passthrough) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `stop_on_missing` | `bool` | `True` | If true, deactivate the message output wire when message is not found | + +## Scene Has History + +`scene/history/HasHistory` + +Check if the scene has history + +Looks for a character, narrator or context_investigation message within +the most recent 100 history entries. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `has_history` | `bool` | True if such a message was found | + +## Last Message of Type + +`scene/history/LastMessageOfType` + +Get the last message of a certain type from the history with +some basic filtering. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `message_type` | `str,list` | The type of message to get (or a list of types) | +| `filters` | `dict` | (optional) filter the messages by property values | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `message` | `message_object` | The message object | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `message_type` | `str` | `unset` | The type of message to get (or a list of types). Choices: `scene`, `character`, `narrator`, `director`, `time`, `reinforcement`, `context_investigation`. | +| `max_iterations` | `int` | `100` | The maximum number of iterations to go back | +| `stop_on_time_passage` | `bool` | `False` | Stop when a time passage message is encountered | +| `filters` | `dict` | `{}` | Filter the messages by property values | + +## Pop History + +`scene/history/Pop` + +Pop a message from the scene history + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `message` | `message_object` | The message to pop | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `message` | `message_object` | The message object | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `emit_removal` | `bool` | `True` | Emit the removal of the message | + +## Push History + +`scene/history/Push` + +Push a message to the scene history at the lowest (e.g., dialogue) layer + +This will emit the message to the screen as part of the ongoing scene + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `message` | `message_object` | The message to push | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `message` | `message_object` | The message object | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `emit_message` | `bool` | `True` | Emit the message to the screen | + +## Remove Static Archive Entry + +`scene/history/RemoveStaticArchiveEntry` + +Remove a static archive entry + +The entry can be specified either directly or via a context ID item - +at least one of the two is required. Raises an error if the resolved +entry is not a static history entry. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `entry` | `history/archive_entry` | The archive entry to remove | +| `context_id_item` | `context_id_item` | A context ID item referencing a static history entry | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `entry` | `history/archive_entry` | The removed entry | +| `context_id_item` | `context_id_item` | The context ID item input, passed through | + +## Static Archive Entries + +`scene/history/StaticArchiveEntries` + +Get the static scene history entries + +Static entries are manually written archive entries that predate the +summarized history. Collection stops at the first summarized +(dynamic) entry. Entries are annotated with a human readable time +relative to the current scene time. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `entries` | `list` | The list of static archive entries | + +## Unpack Archive Entry + +`scene/history/UnpackArchiveEntry` + +Unpack an archive (summarized history) entry into its individual fields and +build a context ID for it. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `entry` | `history/archive_entry` | The archive entry to unpack | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `entry` | `history/archive_entry` | The entry, passed through | +| `id` | `str` | The id of the entry | +| `text` | `str` | The text of the entry | +| `index` | `int` | The index of the entry within its history layer | +| `layer` | `int` | The history layer the entry belongs to | +| `start` | `int` | Index of the first source entry covered by the entry (None for static entries) | +| `end` | `int` | Index of the last source entry covered by the entry (None for static entries) | +| `ts_start` | `str` | Starting timestamp of the covered range (ISO 8601 duration) | +| `ts_end` | `str` | Ending timestamp of the covered range (ISO 8601 duration) | +| `ts` | `str` | Timestamp of the entry (ISO 8601 duration) | +| `time` | `str` | Human readable time relative to the current scene time | +| `time_start` | `str` | Human readable starting time of the covered range | +| `time_end` | `str` | Human readable ending time of the covered range | +| `context_id` | `context_id` | Context ID for the entry (static or dynamic depending on the entry type) | diff --git a/docs/user-guide/node-editor/reference/nodes/scene-intent.md b/docs/user-guide/node-editor/reference/nodes/scene-intent.md new file mode 100644 index 00000000..071fa51a --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/scene-intent.md @@ -0,0 +1,321 @@ +# Scene Intent Nodes + +Read and change the scene's intention state: the overall scene intent, the active scene phase and the scene types that define how different phases play. + + + + +_10 nodes._ + +| Node | Registry path | +| --- | --- | +| [Get Scene Intent](#get-scene-intent) | `scene/intention/GetSceneIntent` | +| [Get Scene Type](#get-scene-type) | `scene/intention/GetSceneType` | +| [Get Scene Types](#get-scene-types) | `scene/intention/GetSceneTypes` | +| [Is Scene Phase Active](#is-scene-phase-active) | `scene/intention/IsScenePhaseActive` | +| [Make Scene Type](#make-scene-type) | `scene/intention/MakeSceneType` | +| [Remove Scene Type](#remove-scene-type) | `scene/intention/RemoveSceneType` | +| [Set Scene Intent](#set-scene-intent) | `scene/intention/SetSceneIntent` | +| [Set Scene Phase](#set-scene-phase) | `scene/intention/SetScenePhase` | +| [Unpack Scene Phase](#unpack-scene-phase) | `scene/intention/UnpackScenePhase` | +| [Unpack Scene Type](#unpack-scene-type) | `scene/intention/UnpackSceneType` | + +## Get Scene Intent + +`scene/intention/GetSceneIntent` + +Returns the intent state. + +The phase, scene_type and start outputs are only set when a scene phase +is currently active. + +- intent (str) - the overall intent +- phase (scene_intent/scene_phase) - the current phase +- scene_type (scene_intent/scene_type) - the current scene type +- start (int) - the message id where this intent started +- direction_always_on (bool) - whether scene direction is always on +- direction_run_immediately (bool) - whether scene direction runs immediately +- direction_instructions (str) - the current direction instructions + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `intent` | `str` | | +| `phase` | `scene_intent/scene_phase` | | +| `scene_type` | `scene_intent/scene_type` | | +| `start` | `int` | | +| `direction_always_on` | `bool` | | +| `direction_run_immediately` | `bool` | | +| `direction_instructions` | `str` | | + +## Get Scene Type + +`scene/intention/GetSceneType` + +Get a scene type object. + +Raises an error if the scene type is not found. + +- scene_type_id (str) - scene type ID + +- scene_type (scene_intent/scene_type) - the scene type object + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `scene_type_id` | `str` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `scene_type` | `scene_intent/scene_type` | | + +## Get Scene Types + +`scene/intention/GetSceneTypes` + +Get a list of available scene types. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `scene_types` | `list` | | +| `scene_type_ids` | `list` | | + +## Is Scene Phase Active + +`scene/intention/IsScenePhaseActive` + +Checks if a specified scene phase type is currently active. + +Routes to yes output if the current scene phase type matches the specified scene type id, +otherwise routes to no output. + +- scene_type_id (str) - the scene type id to check for (optional, can be set as property) + +- scene_type_id (str) - the scene type id to check for + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `scene_type_id` | `str` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `yes` | `any` | if the current scene phase type matches the specified scene type id | +| `no` | `any` | if the current scene phase type does not match | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `scene_type_id` | `str` | `""` | Scene type ID to check for | + +## Make Scene Type + +`scene/intention/MakeSceneType` + +Create a new scene type object. + +If auto_append is enabled (the default), the new scene type is also added +to the scene's available scene types and the intent state is emitted to +the UI. + +- scene_type_id (str) - scene type ID +- name (str) - scene type name +- description (text) - scene type description +- instructions (text) - scene type instructions + +- auto_append (bool) - automatically add the scene type to the scene's scene types + +- scene_type (scene_intent/scene_type) - the new scene type object + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `scene_type_id` | `str` | (optional) | +| `name` | `str` | (optional) | +| `description` | `text` | (optional) | +| `instructions` | `text` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `scene_type` | `scene_intent/scene_type` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `scene_type_id` | `str` | `unset` | Scene type ID | +| `name` | `str` | `""` | Scene type name | +| `description` | `text` | `""` | Scene type description | +| `instructions` | `text` | `""` | Scene type instructions | +| `auto_append` | `bool` | `True` | Automatically append this scene type to scene_types dict | + +## Remove Scene Type + +`scene/intention/RemoveSceneType` + +Remove a scene type object. + +Unknown scene type IDs are silently ignored. Emits the updated intent +state to the UI. + +- state - graph state +- scene_type_id (str) - scene type ID + +- state - the scene's intent state object + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `scene_type_id` | `str` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | + +## Set Scene Intent + +`scene/intention/SetSceneIntent` + +Updates the overall intent and emits the updated intent state to the UI. + +- state - graph state +- intent (str) - the overall intent + +- state - the scene's intent state object +- intent (str) - the overall intent + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `intent` | `str` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `intent` | `str` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `intent` | `str` | `""` | Overall story / experience intent | + +## Set Scene Phase + +`scene/intention/SetScenePhase` + +Set a new scene phase. + +- state - graph state +- scene_type (str) - the type of scene (scene type id) +- intent (str) - the phase intent + +- state - the scene's intent state object +- phase (scene_intent/scene_phase) - the new phase +- scene_type (scene_intent/scene_type) - the scene type of the new phase (object) +- intent (str) - the phase intent, passed through + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `scene_type` | `str` | (optional) | +| `intent` | `str` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `phase` | `scene_intent/scene_phase` | | +| `scene_type` | `scene_intent/scene_type` | | +| `intent` | `str` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `scene_type` | `str` | `"roleplay"` | Type of scene | +| `intent` | `str` | `""` | Phase intent | + +## Unpack Scene Phase + +`scene/intention/UnpackScenePhase` + +Unpack a scene phase into its intent and scene type fields. + +- phase (scene_intent/scene_phase) - the phase to unpack + +- intent (str) - the phase intent +- scene_type (str) - the scene type id of the phase +- scene_type_instructions (str) - the scene type instructions +- scene_type_description (str) - the scene type description +- scene_type_name (str) - the scene type name +- scene_type_id (str) - the scene type id + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `phase` | `scene_intent/scene_phase` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `intent` | `str` | | +| `scene_type` | `str` | | +| `scene_type_instructions` | `str` | | +| `scene_type_description` | `str` | | +| `scene_type_name` | `str` | | +| `scene_type_id` | `str` | | + +## Unpack Scene Type + +`scene/intention/UnpackSceneType` + +Unpack a scene type object. + +- scene_type (scene_intent/scene_type) - the scene type object + +- scene_type_id (str) - scene type ID +- name (str) - scene type name +- description (text) - scene type description +- instructions (text) - scene type instructions + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `scene_type` | `scene_intent/scene_type` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `scene_type_id` | `str` | | +| `name` | `str` | | +| `description` | `str` | | +| `instructions` | `str` | | diff --git a/docs/user-guide/node-editor/reference/nodes/scene-messages.md b/docs/user-guide/node-editor/reference/nodes/scene-messages.md new file mode 100644 index 00000000..549fc85f --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/scene-messages.md @@ -0,0 +1,208 @@ +# Scene Message Nodes + +Create and manipulate messages in the scene log: character, narrator and director messages, message versions, context visibility and message metadata. + + + + +_6 nodes._ + +| Node | Registry path | +| --- | --- | +| [Add Message Version](#add-message-version) | `scene/message/AddMessageVersion` | +| [Character Message](#character-message) | `scene/message/CharacterMessage` | +| [Director Message](#director-message) | `scene/message/DirectorMessage` | +| [Narrator Message](#narrator-message) | `scene/message/NarratorMessage` | +| [Toggle Message Context Visibility](#toggle-message-context-visibility) | `scene/message/ToggleMessageContextVisibility` | +| [Unpack Message Meta](#unpack-message-meta) | `scene/message/UnpackMeta` | + +## Add Message Version + +`scene/message/AddMessageVersion` + +Append a new version to a message's revision stack and make it the +active canonical. + +Typical use: hook ``game_loop_new_message`` (or ``push_history``), +rewrite the message body, and have the prior canonical preserved on +the stack so the user can navigate back to it via the revision +arrows in chat. The prior version stays at its existing index with +its own source/reason intact — this node only appends and shifts +the active pointer. + +Only valid for message types the revision UI can walk: +``CharacterMessage``, ``NarratorMessage``, +``ContextInvestigationMessage`` (i.e. anything with +``_supports_versions = True``). Anything else raises +``InputValueError`` — filter the message type before reaching this +node. + +For ``CharacterMessage``, ``new_text`` is auto-prefixed with +``"Name: "`` if missing, matching the convention in +``scene/message/CharacterMessage``. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `message` | `message_object` | The SceneMessage to append a version to (required) | +| `new_text` | `str` | The new canonical text (required) | +| `source` | `str` | (optional) Version source label (optional, overrides property) | +| `reason` | `str` | Free-form annotation rendered alongside the source badge (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `message` | `message_object` | The same SceneMessage instance (post-append) | +| `new_text` | `str` | Passthrough of new_text | +| `source` | `str` | Passthrough of source actually used | +| `reason` | `str` | Passthrough of reason actually used | +| `version` | `any` | The MessageVersion instance that was appended | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `source` | `str` | `"custom"` | The source label attached to the new version entry. Choices: `original`, `revision`, `regenerate`, `continue`, `custom`. | +| `reason` | `str` | `""` | Free-form annotation rendered alongside the source badge in the revision navigator | + +## Character Message + +`scene/message/CharacterMessage` + +Creates a character message from a character and a message + +The message text is automatically prefixed with the character's name +("Name: ...") if it isn't already. The character's current avatar (if +set) is captured on the message at creation time. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `character` | `character` | The character object | +| `message` | `str` | The message to send | +| `source` | `str` | (optional) The source of the message - player or ai, so whether the message is result of user input or AI generated | +| `from_choice` | `str` | (optional) For player messages this indicates that the message was generated from a choice selection, for ai sourced messages this indicates the instruction that was followed | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `message` | `message_object` | The message object (this is a scene_message.CharacterMessage instance) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `source` | `str` | `"player"` | The source of the message. Choices: `player`, `ai`. | + +## Director Message + +`scene/message/DirectorMessage` + +Creates a director message + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `message` | `str` | The message to send | +| `source` | `str` | (optional) The source of the message - player or ai, so whether the message is result of user input or AI generated | +| `meta` | `dict` | (optional) A dictionary of meta information to attach to the message. Can hold the character name that the message is related to. | +| `character` | `character` | (optional) The character object that the message is related to | +| `action` | `str` | (optional) Describes the director action | +| `subtype` | `str` | (optional) The subtype of the director message, used for further categorization | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `message` | `message_object` | The message object (this is a scene_message.DirectorMessage instance) | +| `source` | `str` | The source input, passed through | +| `meta` | `dict` | The meta input, passed through | +| `character` | `character` | The character input, passed through | +| `action` | `str` | The action input, passed through | +| `subtype` | `str` | The subtype input, passed through | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `source` | `str` | `"ai"` | The source of the message. Choices: `player`, `ai`. | +| `action` | `str` | `"actor_instruction"` | Describes the director action. Choices: `actor_instruction`, `user_direction`. | +| `subtype` | `str` | `unset` | The subtype of the director message, used for further categorization of the message. Choices: `function_call`, `user_direction`. | + +## Narrator Message + +`scene/message/NarratorMessage` + +Creates a narrator message + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `message` | `str` | The message to send | +| `source` | `str` | (optional) The source of the message - player or ai, so whether the message is result of user input or AI generated | +| `meta` | `dict` | (optional) A dictionary of meta information to attach to the message. This will generally be arguments and function name that was called on the narrator agent to generate the message and will be used when regenerating the message. | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `message` | `message_object` | The message object (this is a scene_message.NarratorMessage instance) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `source` | `str` | `"ai"` | The source of the message. Choices: `player`, `ai`. | + +## Toggle Message Context Visibility + +`scene/message/ToggleMessageContextVisibility` + +Hide or show a message. Hidden messages are not displayed to the AI. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `message` | `message_object` | The message object | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `message` | `message_object` | The message object | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `hidden` | `bool` | `False` | Whether the message is hidden | + +## Unpack Message Meta + +`scene/message/UnpackMeta` + +Unpacks a message meta dictionary +into arguments + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `meta` | `dict` | The meta dictionary | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `agent_name` | `str` | The agent name | +| `function_name` | `str` | The function name | +| `arguments` | `dict` | The arguments dictionary | diff --git a/docs/user-guide/node-editor/reference/nodes/scene-world-state.md b/docs/user-guide/node-editor/reference/nodes/scene-world-state.md new file mode 100644 index 00000000..b44b600c --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/scene-world-state.md @@ -0,0 +1,221 @@ +# World Entry Nodes + +Read and write the scene's world entries (the world information shown in the world state panel) and build generation options — writing styles and spices — that steer content generation. + + + + +_8 nodes._ + +| Node | Registry path | +| --- | --- | +| [Get World Entries](#get-world-entries) | `scene/worldstate/GetWorldEntries` | +| [Get World Entry](#get-world-entry) | `scene/worldstate/GetWorldEntry` | +| [Remove World Entry](#remove-world-entry) | `scene/worldstate/RemoveWorldEntry` | +| [Save World Entry](#save-world-entry) | `scene/worldstate/SaveWorldEntry` | +| [Generation Options](#generation-options) | `scene/worldstate/templates/GenerationOptions` | +| [Spices](#spices) | `scene/worldstate/templates/Spices` | +| [Writing Style](#writing-style) | `scene/worldstate/templates/WritingStyle` | +| [Unpack World Entry](#unpack-world-entry) | `scene/worldstate/UnpackWorldEntry` | + +## Get World Entries + +`scene/worldstate/GetWorldEntries` + +Gets world entries, optionally filtered by a list of ids + +Ids are matched case-insensitively. If no ids are given, all world +entries are returned. If raise_on_missing is enabled, an error is +raised when any requested id is not found. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `ids` | `list` | (optional) The ids of the world entries to get | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `world_entries` | `dict` | Dictionary of world entries keyed by entry id | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `ids` | `list` | `unset` | The ids of the world entries | +| `raise_on_missing` | `bool` | `False` | Whether to raise an error if a world entry is missing | + +## Get World Entry + +`scene/worldstate/GetWorldEntry` + +Gets a world entry by its id + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `entry_id` | `str` | The id of the world entry | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `world_entry` | `world_entry` | The world entry object | +| `entry_id` | `str` | The id of the world entry, passed through | +| `text` | `str` | The text of the world entry | +| `shared` | `bool` | Whether the entry is shared | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `entry_id` | `str` | `unset` | The id of the world entry | + +## Remove World Entry + +`scene/worldstate/RemoveWorldEntry` + +Removes a world entry + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `entry_id` | `str` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `entry_id` | `str` | | + +## Save World Entry + +`scene/worldstate/SaveWorldEntry` + +Saves the world entry + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `entry_id` | `str` | (optional) The id of the world entry | +| `text` | `str` | (optional) The text of the world entry | +| `meta` | `dict` | (optional) The meta of the world entry | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `entry_id` | `str` | The id of the world entry | +| `text` | `str` | The text of the world entry | +| `meta` | `dict` | The meta of the world entry | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `entry_id` | `str` | `unset` | The id of the world entry | +| `text` | `text` | `unset` | The text of the world entry | +| `meta` | `dict` | `{}` | The meta of the world entry | +| `create_pin` | `bool` | `False` | Whether to create a pin for the entry | + +## Generation Options + +`scene/worldstate/templates/GenerationOptions` + +Node that returns a GenerationOptions object + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `spices` | `spices` | (optional) The spices to apply to the generation options (input-only) | +| `spice_level` | `number` | (optional) The spice level to apply to the generation options | +| `writing_style` | `writing_style` | (optional) The writing style to apply to the generation options | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `generation_options` | `generation_options` | The generation options | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `spice_level` | `number` | `0.0` | The spice level to apply to the generation options. Range: 0.0 – 1.0. | +| `writing_style` | `writing_style` | `""` | The writing style to apply to the generation options | + +## Spices + +`scene/worldstate/templates/Spices` + +Node that returns a Spices object + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `spice_values` | `list` | (optional) list of strings | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `spices` | `spices` | The Spices object built from the spice values | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `spice_values` | `list` | `[]` | The list of spices | + +## Writing Style + +`scene/worldstate/templates/WritingStyle` + +Node that returns a WritingStyle object + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `instructions` | `str` | (optional) Writing style instructions | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `writing_style` | `writing_style` | The writing style to apply to the generation options | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `instructions` | `text` | `""` | Writing style instructions | + +## Unpack World Entry + +`scene/worldstate/UnpackWorldEntry` + +Unpacks a world entry into its individual fields + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `world_entry` | `world_entry` | The world entry object | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `entry_id` | `str` | The id of the world entry | +| `text` | `str` | The text of the world entry | +| `meta` | `dict` | The meta of the world entry | diff --git a/docs/user-guide/node-editor/reference/nodes/scene.md b/docs/user-guide/node-editor/reference/nodes/scene.md new file mode 100644 index 00000000..e4f53397 --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/scene.md @@ -0,0 +1,377 @@ +# Scene Nodes + +Scene lifecycle and metadata. The **Scene Loop** module is the heart of a playable scene — it processes turns, input and events (see the [Command Module](../../core-concepts/command_module.md) and [Events](../../core-concepts/events.md) concepts). The Get/Set pairs manage the scene's title, description, introduction and content classification, and the storyline modules drive dynamic storylines. + + + + +_18 nodes._ + +| Node | Registry path | +| --- | --- | +| [Creative Loop](#creative-loop) | `scene/CreativeLoop` | +| [Dynamic Storyline](#dynamic-storyline) | `scene/dynamicStoryline` | +| [Game Loop Actor Iteration](#game-loop-actor-iteration) | `scene/event/trigger/GameLoopActorIter` | +| [Generate Storyline](#generate-storyline) | `scene/generateStoryline` | +| [Get Content Classification](#get-content-classification) | `scene/GetContentClassification` | +| [Get Story Description](#get-story-description) | `scene/GetDescription` | +| [Get Story Introduction](#get-story-introduction) | `scene/GetIntroduction` | +| [Get Scene Loop State](#get-scene-loop-state) | `scene/GetSceneLoopState` | +| [Get Scene State](#get-scene-state) | `scene/GetSceneState` | +| [Get Story Title](#get-story-title) | `scene/GetTitle` | +| [Restore Scene](#restore-scene) | `scene/Restore` | +| [Scene Loop](#scene-loop) | `scene/SceneLoop` | +| [Select Actor For Turn](#select-actor-for-turn) | `scene/SelectActorForTurn` | +| [Set Content Classification](#set-content-classification) | `scene/SetContentClassification` | +| [Set Story Description](#set-story-description) | `scene/SetDescription` | +| [Set Introduction](#set-introduction) | `scene/SetIntroduction` | +| [Set Story Title](#set-story-title) | `scene/SetTitle` | +| [Unpack Interaction State](#unpack-interaction-state) | `scene/UnpackInteractionState` | + +## Creative Loop + +`scene/CreativeLoop` + +Node module (base type `scene/SceneLoop`) defined in `src/talemate/game/engine/nodes/modules/scene/creative-loop.json`. + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `trigger_game_loop` | `bool` | `False` | Trigger the game loop event | + +## Dynamic Storyline + +`scene/dynamicStoryline` + +Node module (base type `core/Event`) defined in `src/talemate/game/engine/nodes/modules/scene/dynamic-storyline.json`. + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `event_name` | `str` | `"scene_loop_init"` | Event to listen for | +| `auto_register` | `bool` | `False` | Self-register from the node registry instead of requiring placement in the scene loop graph | + +## Game Loop Actor Iteration + +`scene/event/trigger/GameLoopActorIter` + +Trigger the game loop actor iteration event. + +In a most basic setup you will trigger this everytime an actor has had a turn. + +After the event is sent, a follow-up character iteration event is also +fired: game_loop_player_character_iter for player characters, or +game_loop_ai_character_iter for AI characters. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `trigger` | `any` | Trigger input to activate the node | +| `actor` | `actor` | The actor that has had a turn | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `event` | `event` | The event object that was sent | + +## Generate Storyline + +`scene/generateStoryline` + +Node module (base type `core/Graph`) defined in `src/talemate/game/engine/nodes/modules/scene/generate-storyline.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `topic` | `str` | | +| `analysis_instructions` | `str` | (optional) | +| `analysis_enabled` | `bool` | (optional) | +| `state` | `any` | | +| `intro_length` | `int` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `text` | `str` | | +| `theme` | `str` | | + +## Get Content Classification + +`scene/GetContentClassification` + +Get the content classification text for the scene + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `content_classification` | `str` | | + +## Get Story Description + +`scene/GetDescription` + +Get the description text for the scene + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `description` | `str` | | + +## Get Story Introduction + +`scene/GetIntroduction` + +Get the introduction text for the scene + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `introduction` | `str` | | + +## Get Scene Loop State + +`scene/GetSceneLoopState` + +Returns the current scene loop states + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `dict` | The current node state, this is the state of the graph currently being processed | +| `parent` | `dict` | The parent node state, this is the state of the graph that contains the current graph | +| `shared` | `dict` | The shared state, this is the state shared between all graphs | + +## Get Scene State + +`scene/GetSceneState` + +Gets some basic information about the scene + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `characters` | `list` | A list of characters in the scene | +| `active` | `bool` | Whether the scene is active | +| `auto_save` | `bool` | Whether auto save is enabled | +| `auto_progress` | `bool` | Whether auto progress is enabled | +| `scene` | `scene` | The scene instance | + +## Get Story Title + +`scene/GetTitle` + +Get the title text for the scene + +Falls back to the scene name if no title is set. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `title` | `str` | The scene title (or scene name, or empty string) | + +## Restore Scene + +`scene/Restore` + +Restore the scene to its restore point + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | + +## Scene Loop + +`scene/SceneLoop` + +The main scene loop node + +It will loop through the scene graph until the loop is broken. + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `trigger_game_loop` | `bool` | `True` | Trigger the game loop event | + +## Select Actor For Turn + +`scene/SelectActorForTurn` + +Node module (base type `core/Graph`) defined in `src/talemate/game/engine/nodes/modules/scene/select-actor-for-turn.json`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `character` | `character` | | +| `player` | `bool` | | +| `ai` | `bool` | | + +## Set Content Classification + +`scene/SetContentClassification` + +Set the content classification text for the scene + +Raises an error if the text is longer than max_length characters. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `content_classification` | `str` | The content classification text | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `content_classification` | `str` | The content classification text | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `content_classification` | `str` | `""` | The content classification text | +| `max_length` | `int` | `75` | The maximum length of the content classification text (characters, NOT tokens) | + +## Set Story Description + +`scene/SetDescription` + +Set the description text for the scene + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `description` | `str` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `description` | `str` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `description` | `text` | `""` | The description text | + +## Set Introduction + +`scene/SetIntroduction` + +Set the introduction text for the scene + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `introduction` | `str` | The introduction text | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `introduction` | `text` | `unset` | The introduction text | +| `emit_history` | `bool` | `True` | Whether to re-emit the entire history of the scene | + +## Set Story Title + +`scene/SetTitle` + +Set the title text for the scene + +Raises an error if the new title is empty. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `new_title` | `str` | The new title text | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `new_title` | `str` | The new title text | +| `old_title` | `str` | The previous title (or scene name if no title was set) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `new_title` | `str` | `""` | new_title | +| `stroy_title` | `str` | `""` | The title text | + +## Unpack Interaction State + +`scene/UnpackInteractionState` + +Will take an interaction state and unpack it into the individual fields + +Inputs +- interaction_state `interaction_state` + +Outputs +- act_as `str` +- from_choice `str` +- input `str` +- reset_requested `bool` + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `interaction_state` | `interaction_state` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `act_as` | `str` | | +| `from_choice` | `str` | | +| `input` | `str` | | +| `reset_requested` | `bool` | | diff --git a/docs/user-guide/node-editor/reference/nodes/state.md b/docs/user-guide/node-editor/reference/nodes/state.md new file mode 100644 index 00000000..fd8d6036 --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/state.md @@ -0,0 +1,450 @@ +# State Nodes + +Read and write named variables in the graph state. Every variable lives in a **scope**: `local` (this module run), `parent`, `shared` (all modules in the graph), `scene loop` (reset each loop iteration) or `game` (persisted with the scene save). See [States](../../core-concepts/states.md) for scope semantics. The `...Path` variants address nested values with dotted paths, and the counter nodes increment numeric state in place. + + + + +_14 nodes._ + +| Node | Registry path | +| --- | --- | +| [Counter State (Conditional)](#counter-state-conditional) | `state/ConditionalCounterState` | +| [Set State (Conditional)](#set-state-conditional) | `state/ConditionalSetState` | +| [Unset State (Conditional)](#unset-state-conditional) | `state/ConditionalUnsetState` | +| [State Counter](#state-counter) | `state/CounterState` | +| [State Counter (Path)](#state-counter-path) | `state/CounterStatePath` | +| [Game State](#game-state) | `state/Gamestate` | +| [Get State](#get-state) | `state/GetState` | +| [Get State (Path)](#get-state-path) | `state/GetStatePath` | +| [Has State](#has-state) | `state/HasState` | +| [Has State (Path)](#has-state-path) | `state/HasStatePath` | +| [Set State](#set-state) | `state/SetState` | +| [Set State (Path)](#set-state-path) | `state/SetStatePath` | +| [Unset State](#unset-state) | `state/UnsetState` | +| [Unset State (Path)](#unset-state-path) | `state/UnsetStatePath` | + +## Counter State (Conditional) + +`state/ConditionalCounterState` + +Counter node that increments a numeric value in the state and returns the new value. + +Provides a required `state` input causing the node to only run when a state is provided + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `name` | `str` | (optional) | +| `reset` | `bool` | (optional) | +| `reset_cap` | `number` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `name` | `str` | | +| `value` | `any` | | +| `scope` | `str` | | +| `reset_cap` | `number` | | +| `reset` | `bool` | | +| `new_cycle` | `bool` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `unset` | The name of the variable to manipulate | +| `scope` | `str` | `"local"` | Which scope to manipulate. Choices: `local`, `parent`, `shared`, `scene loop`, `game`. | +| `increment` | `number` | `1` | The amount to increment the value by. Min: 1. | +| `reset` | `bool` | `False` | If true, the value will be reset to 0 | +| `reset_cap` | `number` | `0` | If set (> 0), the counter resets to 0 once the value reaches this cap | + +## Set State (Conditional) + +`state/ConditionalSetState` + +Set a variable in the graph state + +Provides a required `state` input causing the node to only run when a state is provided + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `name` | `str` | (optional) | +| `value` | `any` | | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `name` | `str` | | +| `value` | `any` | | +| `scope` | `str` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `unset` | The name of the variable to manipulate | +| `scope` | `str` | `"local"` | Which scope to manipulate. Choices: `local`, `parent`, `shared`, `scene loop`, `game`. | + +## Unset State (Conditional) + +`state/ConditionalUnsetState` + +Unset a variable in the graph state + +Provides a required `state` input causing the node to only run when a state is provided + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `name` | `str` | (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | | +| `name` | `str` | | +| `value` | `any` | | +| `scope` | `str` | | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `unset` | The name of the variable to manipulate | +| `scope` | `str` | `"local"` | Which scope to manipulate. Choices: `local`, `parent`, `shared`, `scene loop`, `game`. | + +## State Counter + +`state/CounterState` + +Counter node that increments a numeric value in the state and returns the new value. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | required state input (conditional execution) | +| `name` | `str` | (optional) The key to the value to increment | +| `reset` | `bool` | (optional) If true, the value will be reset to 0 | +| `reset_cap` | `number` | (optional) If set, the counter resets to 0 once it reaches this value | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | the state that was passed in | +| `name` | `str` | The key that was used | +| `value` | `any` | The new value | +| `scope` | `str` | The scope that was used | +| `reset_cap` | `number` | The reset cap that was used | +| `reset` | `bool` | Whether the counter was reset | +| `new_cycle` | `bool` | True if the counter was at 0 before this run | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `unset` | The name of the variable to manipulate | +| `scope` | `str` | `"local"` | Which scope to manipulate. Choices: `local`, `parent`, `shared`, `scene loop`, `game`. | +| `increment` | `number` | `1` | The amount to increment the value by. Min: 1. | +| `reset` | `bool` | `False` | If true, the value will be reset to 0 | +| `reset_cap` | `number` | `0` | If set (> 0), the counter resets to 0 once the value reaches this cap | + +## State Counter (Path) + +`state/CounterStatePath` + +Counter node that increments a numeric value in the state using a path (e.g., 'a/b/c'). + +Creates intermediate dictionaries as needed (mkdir -p semantics). +Mutating operation - conditional by default with required state input. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | required state input (conditional execution) | +| `name` | `str` | (optional) The path key to the value to increment (e.g., 'a/b/c') | +| `reset` | `bool` | (optional) If true, the value will be reset to 0 | +| `reset_cap` | `number` | (optional) If set, the value will be reset to 0 when it reaches this value | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | the state that was passed in | +| `name` | `str` | The key that was used | +| `value` | `any` | The new value | +| `scope` | `str` | The scope that was used | +| `reset_cap` | `number` | The reset cap value | +| `reset` | `bool` | Whether reset occurred | +| `new_cycle` | `bool` | Whether this is a new cycle (value was 0) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `unset` | The name of the variable to manipulate | +| `scope` | `str` | `"local"` | Which scope to manipulate. Choices: `local`, `parent`, `shared`, `scene loop`, `game`. | +| `increment` | `number` | `1` | The amount to increment the value by. Min: 1. | +| `reset` | `bool` | `False` | If true, the value will be reset to 0 | +| `reset_cap` | `number` | `0` | If set (> 0), the counter resets to 0 once the value reaches this cap | + +## Game State + +`state/Gamestate` + +Get the game state variables of the active scene. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `variables` | `dict` | Dict of the scene's game state variables | + +## Get State + +`state/GetState` + +Get a variable from the graph state + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `name` | `str` | (optional) the name to get | +| `default` | `any` | value to return if the variable is not set (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `name` | `str` | the name that was retrieved | +| `value` | `any` | the value that was retrieved | +| `scope` | `str` | the scope that was retrieved | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `unset` | The name of the variable to manipulate | +| `scope` | `str` | `"local"` | Which scope to manipulate. Choices: `local`, `parent`, `shared`, `scene loop`, `game`. | + +## Get State (Path) + +`state/GetStatePath` + +Get a variable from the graph state using a path (e.g., 'a/b/c'). + +Does not create missing containers - returns default if path doesn't exist. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `name` | `str` | (optional) the path name to get (e.g., 'a/b/c') | +| `default` | `any` | (optional) default value if path doesn't exist | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `name` | `str` | the name that was retrieved | +| `value` | `any` | the value that was retrieved | +| `scope` | `str` | the scope that was retrieved | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `unset` | The name of the variable to manipulate | +| `scope` | `str` | `"local"` | Which scope to manipulate. Choices: `local`, `parent`, `shared`, `scene loop`, `game`. | + +## Has State + +`state/HasState` + +Check if a variable exists in the graph state + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `name` | `str` | (optional) the name to check | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `name` | `str` | the name that was checked | +| `value` | `any` | | +| `scope` | `str` | the scope that was checked | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `unset` | The name of the variable to manipulate | +| `scope` | `str` | `"local"` | Which scope to manipulate. Choices: `local`, `parent`, `shared`, `scene loop`, `game`. | + +## Has State (Path) + +`state/HasStatePath` + +Check if a variable exists in the graph state using a path (e.g., 'a/b/c'). + +Does not create missing containers - returns False if path doesn't exist. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `name` | `str` | (optional) the path name to check (e.g., 'a/b/c') | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `name` | `str` | the name that was checked | +| `value` | `any` | | +| `scope` | `str` | the scope that was checked | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `unset` | The name of the variable to manipulate | +| `scope` | `str` | `"local"` | Which scope to manipulate. Choices: `local`, `parent`, `shared`, `scene loop`, `game`. | + +## Set State + +`state/SetState` + +Set a variable in the graph state + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `name` | `str` | (optional) the name to set | +| `value` | `any` | the value to set | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `name` | `str` | the name that was set | +| `value` | `any` | the value that was set | +| `scope` | `str` | the scope that was set | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `unset` | The name of the variable to manipulate | +| `scope` | `str` | `"local"` | Which scope to manipulate. Choices: `local`, `parent`, `shared`, `scene loop`, `game`. | + +## Set State (Path) + +`state/SetStatePath` + +Set a variable in the graph state using a path (e.g., 'a/b/c'). + +Creates intermediate dictionaries as needed (mkdir -p semantics). +Mutating operation - conditional by default with required state input. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | required state input (conditional execution) | +| `name` | `str` | (optional) the path name to set (e.g., 'a/b/c') | +| `value` | `any` | the value to set | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | the state that was passed in | +| `name` | `str` | the name that was set | +| `value` | `any` | the value that was set | +| `scope` | `str` | the scope that was set | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `unset` | The name of the variable to manipulate | +| `scope` | `str` | `"local"` | Which scope to manipulate. Choices: `local`, `parent`, `shared`, `scene loop`, `game`. | + +## Unset State + +`state/UnsetState` + +Unset a variable in the graph state + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `name` | `str` | (optional) the name to unset | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `name` | `str` | the name that was unset | +| `value` | `any` | the value that was unset | +| `scope` | `str` | the scope that was unset | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `unset` | The name of the variable to manipulate | +| `scope` | `str` | `"local"` | Which scope to manipulate. Choices: `local`, `parent`, `shared`, `scene loop`, `game`. | + +## Unset State (Path) + +`state/UnsetStatePath` + +Unset a variable in the graph state using a path (e.g., 'a/b/c'). + +Does not create missing containers - returns None if path doesn't exist. +Mutating operation - conditional by default with required state input. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | required state input (conditional execution) | +| `name` | `str` | (optional) the path name to unset (e.g., 'a/b/c') | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | the state that was passed in | +| `name` | `str` | the name that was unset | +| `value` | `any` | the value that was unset | +| `scope` | `str` | the scope that was unset | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | `unset` | The name of the variable to manipulate | +| `scope` | `str` | `"local"` | Which scope to manipulate. Choices: `local`, `parent`, `shared`, `scene loop`, `game`. | diff --git a/docs/user-guide/node-editor/reference/nodes/util-packaging.md b/docs/user-guide/node-editor/reference/nodes/util-packaging.md new file mode 100644 index 00000000..f8fdd38d --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/util-packaging.md @@ -0,0 +1,86 @@ +# Packaging Nodes + +Package node modules for reuse and installation into scenes — see [Package](../../core-concepts/package.md) for the packaging workflow. + + + + +_4 nodes._ + +| Node | Registry path | +| --- | --- | +| [Dynamic Storyline Package](#dynamic-storyline-package) | `package/talemate/DynamicStoryline` | +| [Install Node Module](#install-node-module) | `util/packaging/InstallNodeModule` | +| [Package](#package) | `util/packaging/Package` | +| [Promote Config](#promote-config) | `util/packaging/PromoteConfig` | + +## Dynamic Storyline Package + +`package/talemate/DynamicStoryline` + +Node module (base type `util/packaging/Package`) defined in `src/talemate/game/engine/nodes/modules/scene/dynamic-storyline-package.json`. + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `package_name` | `str` | `"Dynamic Storyline"` | The name of the node module | +| `author` | `str` | `"Talemate"` | The author of the node module | +| `description` | `str` | `"Generate a random story premise at the beginning of the scene."` | The description of the node module | +| `installable` | `bool` | `True` | Whether the node module is installable to the scene | +| `restart_scene_loop` | `bool` | `True` | Whether the scene loop should be restarted after the package is installed | + +## Install Node Module + +`util/packaging/InstallNodeModule` + +Marks a node module for installation when placed inside a Package graph. + +When the package is installed to a scene, each InstallNodeModule node causes +the referenced node module to be instantiated and added to the scene loop. + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `node_registry` | `str` | `unset` | The registry path of the node module to package | + +## Package + +`util/packaging/Package` + +Graph that defines an installable package of node modules for easy scene +installation. + +Place InstallNodeModule nodes inside it to mark node modules for +installation, and PromoteConfig nodes to expose module properties as +package configuration. + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `package_name` | `str` | `""` | The name of the node module | +| `author` | `str` | `""` | The author of the node module | +| `description` | `str` | `""` | The description of the node module | +| `installable` | `bool` | `True` | Whether the node module is installable to the scene | +| `restart_scene_loop` | `bool` | `False` | Whether the scene loop should be restarted after the package is installed | + +## Promote Config + +`util/packaging/PromoteConfig` + +Promotes a single module property to be configurable through the scene once the package is installed. + +Place inside a Package graph alongside the InstallNodeModule node whose +module property should be exposed. + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `node_registry` | `str` | `unset` | The registry path of the node module to package | +| `property_name` | `str` | `unset` | Property Name | +| `exposed_property_name` | `str` | `unset` | Exposed Property Name | +| `required` | `bool` | `False` | Whether the property is required | +| `label` | `str` | `""` | Label | diff --git a/docs/user-guide/node-editor/reference/nodes/util.md b/docs/user-guide/node-editor/reference/nodes/util.md new file mode 100644 index 00000000..2fadd4ff --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/util.md @@ -0,0 +1,141 @@ +# Utility Nodes + +Miscellaneous helpers: counters, string diffs, list extraction from text, ISO date durations and the **Module Style** node that sets a module's canvas appearance. + + + + +_5 nodes._ + +| Node | Registry path | +| --- | --- | +| [Counter](#counter) | `util/Counter` | +| [Diff](#diff) | `util/Diff` | +| [Extract List](#extract-list) | `util/ExtractList` | +| [ISO Date Duration](#iso-date-duration) | `util/IsoDateDuration` | +| [Module Style](#module-style) | `util/ModuleStyle` | + +## Counter + +`util/Counter` + +Counter node that increments a numeric value inside a +dict and returns the new value. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `dict` | `dict` | The dict containing the value to increment | +| `key` | `str` | (optional) The key to the value to increment | +| `reset` | `bool` | (optional) If true, the value will be reset to 0 | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `value` | `any` | The new value | +| `dict` | `dict` | The dict with the new value | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `increment` | `number` | `1` | The amount to increment the value by. Min: 1. | +| `key` | `str` | `"counter"` | The key to the value to increment | +| `reset` | `bool` | `False` | If true, the value will be reset to 0 | + +## Diff + +`util/Diff` + +Diff node that returns the diff between two strings. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `a` | `str` | The original string | +| `b` | `str` | The changed string | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `diff_plain` | `str` | The diff as plain text | +| `diff_html` | `str` | The diff as inline HTML markup | +| `a` | `str` | The a input, passed through | +| `b` | `str` | The b input, passed through | + +## Extract List + +`util/ExtractList` + +Extracts a list from a string. + +Parses a numbered ("1.") or bulleted ("*" / "-") list out of text, such +as an LLM response, returning the items as a list of strings. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `string` | `str` | The text to extract the list from | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `string` | `str` | The string input, passed through | +| `list` | `list` | The extracted list items | +| `is_empty` | `bool` | True if no list items were found | + +## ISO Date Duration + +`util/IsoDateDuration` + +IsoDateDuration node that allows constructing ISO 8601 duration strings. + +Combines an amount and a unit into an ISO 8601 duration +(e.g., 3 + "day" -> "P3D"). + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `unit` | `str` | The unit of the duration (year, month, week, day, hour, minute, second) | +| `amount` | `number` | The amount of the duration | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `unit` | `str` | The unit input, passed through | +| `amount` | `number` | The amount input, passed through | +| `duration` | `str` | The ISO 8601 duration string | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `unit` | `str` | `"day"` | The unit of the duration. Choices: `year`, `month`, `week`, `day`, `hour`, `minute`, `second`. | +| `amount` | `number` | `1` | The amount of the duration. Min: 1. | + +## Module Style + +`util/ModuleStyle` + +An isolated node that defines the visual style of the graph it is +placed in, when that graph is used as a node module inside another +graph. + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `title_color` | `color` | `unset` | Title Color | +| `node_color` | `color` | `unset` | Node Color | +| `auto_title` | `str` | `unset` | Auto Title | +| `icon` | `str` | `unset` | Icon (Material Icon Codepoint) | diff --git a/docs/user-guide/node-editor/reference/nodes/ux.md b/docs/user-guide/node-editor/reference/nodes/ux.md new file mode 100644 index 00000000..1ba7bbb7 --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/ux.md @@ -0,0 +1,233 @@ +# UX Nodes + +Build interactive user-facing elements from graphs: choice menus, notices and text inputs, emitted to the interface and styled with **Style Element**. + + + + +_5 nodes._ + +| Node | Registry path | +| --- | --- | +| [Build Choice Element](#build-choice-element) | `ux/BuildChoiceElement` | +| [Build Notice Element](#build-notice-element) | `ux/BuildNoticeElement` | +| [Build Text Input Element](#build-text-input-element) | `ux/BuildTextInputElement` | +| [Emit UX Element](#emit-ux-element) | `ux/EmitElement` | +| [Style UX Element](#style-ux-element) | `ux/StyleElement` | + +## Build Choice Element + +`ux/BuildChoiceElement` + +Builds a choice UX element payload. + +A random id is generated when none is given. Raises an error if choices is +empty. Emit the resulting element with `ux/EmitElement`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | any | +| `id` | `str` | (optional) str (optional; auto-generated when not set) | +| `closable` | `bool` | bool (optional) | +| `timeout_seconds` | `int` | (optional) int (optional; 0 = no timeout) | +| `title` | `str` | str (optional) | +| `body` | `str` | str (optional) | +| `choices` | `list` | list (required) | +| `multi_select` | `bool` | bool (optional) | +| `default` | `any` | str\|list[str] (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | any | +| `id` | `str` | the element id (resolved) | +| `closable` | `bool` | bool (resolved) | +| `timeout_seconds` | `int` | int (resolved) | +| `title` | `str` | str (resolved) | +| `body` | `str` | str (resolved) | +| `choices` | `list` | list (passed through) | +| `multi_select` | `bool` | bool (resolved) | +| `default` | `any` | the resolved default selection | +| `ux_id` | `str` | str | +| `ux_element` | `ux_element` | dict | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `closable` | `bool` | `True` | Whether the user can dismiss the element (dismissal cancels waiting) | +| `timeout_seconds` | `int` | `0` | Optional timeout for the interaction (0 = no timeout). Min: 0. | +| `element_title` | `str` | `""` | Title for the element | +| `element_body` | `text` | `""` | Body/description for the element | +| `multi_select` | `bool` | `False` | Allow selecting multiple choices | +| `default` | `any` | `None` | Default selection (string or list of strings) | + +## Build Notice Element + +`ux/BuildNoticeElement` + +Builds a notice (display-only) UX element payload. + +Fire-and-forget: EmitElement emits and returns immediately. The frontend +renders the notice until the user dismisses it (when closable) or the +client-side timeout expires. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | any | +| `id` | `str` | (optional) str (optional; auto-generated when not set) | +| `closable` | `bool` | bool (optional) | +| `timeout_seconds` | `int` | int (optional) | +| `title` | `str` | str (optional) | +| `body` | `str` | str (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | any | +| `id` | `str` | the element id (resolved) | +| `closable` | `bool` | bool (resolved) | +| `timeout_seconds` | `int` | int (resolved) | +| `title` | `str` | str (resolved) | +| `body` | `str` | str (resolved) | +| `ux_id` | `str` | str | +| `ux_element` | `ux_element` | dict | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `closable` | `bool` | `True` | Whether the user can dismiss the element (dismissal cancels waiting) | +| `timeout_seconds` | `int` | `0` | Optional timeout for the interaction (0 = no timeout). Min: 0. | +| `element_title` | `str` | `""` | Title for the element | +| `element_body` | `text` | `""` | Body/description for the element | + +## Build Text Input Element + +`ux/BuildTextInputElement` + +Builds a text input UX element payload. + +A random id is generated when none is given. Emit the resulting element +with `ux/EmitElement`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | any | +| `id` | `str` | (optional) str (optional; auto-generated when not set) | +| `closable` | `bool` | bool (optional) | +| `timeout_seconds` | `int` | (optional) int (optional; 0 = no timeout) | +| `title` | `str` | str (optional) | +| `body` | `str` | str (optional) | +| `multiline` | `bool` | bool (optional) | +| `rows` | `int` | int (optional) | +| `placeholder` | `str` | str (optional) | +| `default` | `str` | str (optional) | +| `trim` | `bool` | bool (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | any | +| `id` | `str` | the element id (resolved) | +| `closable` | `bool` | bool (resolved) | +| `timeout_seconds` | `int` | int (resolved) | +| `title` | `str` | str (resolved) | +| `body` | `str` | str (resolved) | +| `multiline` | `bool` | bool (resolved) | +| `rows` | `int` | int (resolved) | +| `placeholder` | `str` | str (resolved) | +| `default` | `str` | str (resolved) | +| `trim` | `bool` | bool (resolved) | +| `ux_id` | `str` | str | +| `ux_element` | `ux_element` | dict | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `closable` | `bool` | `True` | Whether the user can dismiss the element (dismissal cancels waiting) | +| `timeout_seconds` | `int` | `0` | Optional timeout for the interaction (0 = no timeout). Min: 0. | +| `element_title` | `str` | `""` | Title for the element | +| `element_body` | `text` | `""` | Body/description for the element | +| `multiline` | `bool` | `False` | Render as textarea (true) or single-line input (false) | +| `rows` | `int` | `0` | Textarea rows (only used when multiline). Min: 0. | +| `placeholder` | `str` | `""` | Input placeholder text | +| `default` | `str` | `""` | Default input value | +| `trim` | `bool` | `True` | Whether to trim input before submission | + +## Emit UX Element + +`ux/EmitElement` + +Emits a UX element to the frontend (websocket passthrough). + +Choice and text input elements are awaitable by design: emitting one will +wait for the user to submit/cancel (with optional element-defined timeout), +then close the UX element and return the captured interaction values. +A timeout is reported as both `timed_out` and `cancelled`. Notice elements +are fire-and-forget and return immediately. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | any | +| `ux_element` | `ux_element` | dict | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | any | +| `ux_id` | `str` | str | +| `ux_element` | `ux_element` | dict | +| `value` | `any` | any | +| `values` | `any` | dict | +| `cancelled` | `bool` | bool | +| `timed_out` | `bool` | bool | + +## Style UX Element + +`ux/StyleElement` + +Pass-through node for styling UX elements. + +Intended usage: connect between `ux/Build*Element` nodes and `ux/EmitElement`. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | any | +| `ux_element` | `ux_element` | dict | +| `tint` | `str` | str (optional) - Vuetify color name | +| `icon` | `str` | str (optional) - mdi-* icon name | +| `apply_scene_colors` | `bool` | bool (optional) - render body through the scene-message parser for per-category colors | +| `compact` | `bool` | bool (optional) - render the element in a condensed layout | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | any | +| `ux_id` | `str` | str | +| `ux_element` | `ux_element` | dict | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `tint` | `str` | `"muted"` | Color tint (Vuetify color name). Choices are generated at runtime. | +| `icon` | `str` | `""` | Material Design Icon name (e.g., 'mdi-help-circle-outline', 'mdi-information'). Leave blank for no icon. | +| `apply_scene_colors` | `bool` | `False` | When true, the body text is rendered through the scene-message parser and gets its per-category colors (quotes/emphasis/parentheses/brackets). When false, body renders with markdown formatting but inherits the element's color. | +| `compact` | `bool` | `False` | When true, the frontend renders the element in a condensed layout: smaller icon and text, and the title leads inline into the body rather than sitting on its own row. | diff --git a/docs/user-guide/node-editor/reference/nodes/validation.md b/docs/user-guide/node-editor/reference/nodes/validation.md new file mode 100644 index 00000000..dea0b36f --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/validation.md @@ -0,0 +1,226 @@ +# Validation Nodes + +Validate values inside graphs — set/unset checks, containment, characters, asset IDs and context ID strings/items — producing pass/fail results you can branch on. + + + + +_7 nodes._ + +| Node | Registry path | +| --- | --- | +| [Validate Asset ID](#validate-asset-id) | `validation/ValidateAssetID` | +| [Validate Character](#validate-character) | `validation/ValidateCharacter` | +| [Validate Context ID Item](#validate-context-id-item) | `validation/ValidateContextIDItem` | +| [Validate Context ID String](#validate-context-id-string) | `validation/ValidateContextIDString` | +| [Validate Value Contained](#validate-value-contained) | `validation/ValidateValueContained` | +| [Validate Value Is Not Set](#validate-value-is-not-set) | `validation/ValidateValueIsNotSet` | +| [Validate Value Is Set](#validate-value-is-set) | `validation/ValidateValueIsSet` | + +## Validate Asset ID + +`validation/ValidateAssetID` + +Validate that a value is the ID of an existing scene asset, raising an +error if it isn't. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `value` | `any` | The asset ID to validate | +| `error_message` | `str` | Custom error message, `{value}` is replaced with the value (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `value` | `any` | The validated asset ID, passed through | +| `asset` | `asset` | The asset object | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `error_message` | `str` | `""` | The error message to raise. Use {value} to reference the value that is not set. | + +## Validate Character + +`validation/ValidateCharacter` + +Validate that a value is the name of a character in the scene, raising an +error if it isn't. + +Optionally restrict to active or inactive characters, or create a +placeholder character when the name doesn't exist. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `value` | `any` | The character name to validate | +| `error_message` | `str` | Custom error message, `{value}` is replaced with the value (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `value` | `any` | The validated character name, passed through | +| `character` | `character` | The character object (or placeholder) | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `error_message` | `str` | `""` | The error message to raise. Use {value} to reference the value that is not set. | +| `character_status` | `str` | `"all"` | The status of the character. Choices: `active`, `inactive`, `all`. | +| `create_placeholder` | `bool` | `False` | Whether to create a placeholder character if the character does not exist | + +## Validate Context ID Item + +`validation/ValidateContextIDItem` + +Validate that a value is a context ID string that resolves to an existing +context ID item, raising an error if it doesn't. + +Surrounding backticks and whitespace are stripped before validation. On +success the resolved item and its details are provided as outputs. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `value` | `any` | The context ID string to validate and resolve | +| `error_message` | `str` | Custom error message, `{value}` is replaced with the value (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `value` | `any` | The cleaned, validated context ID string | +| `context_id` | `context_id` | The context ID of the resolved item | +| `context_id_item` | `context_id_item` | The resolved context ID item | +| `context_type` | `str` | The context type of the resolved item | +| `context_value` | `any` | The current value stored at the context ID | +| `name` | `str` | The name of the resolved item | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `error_message` | `str` | `""` | The error message to raise. Use {value} to reference the value that is not set. | + +## Validate Context ID String + +`validation/ValidateContextIDString` + +Validate that a value is a valid context ID string, raising an error if it +isn't. + +Surrounding backticks and whitespace are stripped before validation, and +the cleaned string is passed through on success. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `value` | `any` | The context ID string to validate | +| `error_message` | `str` | Custom error message, `{value}` is replaced with the value (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `value` | `any` | The cleaned, validated context ID string | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `error_message` | `str` | `""` | The error message to raise. Use {value} to reference the value that is not set. | + +## Validate Value Contained + +`validation/ValidateValueContained` + +Validate that a value is contained in a list or dictionary, raising an +error if it isn't. + +For dictionaries, containment is checked against the keys. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `value` | `any` | The value to validate | +| `error_message` | `str` | Custom error message, `{value}` is replaced with the value (optional) | +| `list` | `list,dict` | The list or dictionary to check containment against | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `value` | `any` | The validated value, passed through | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `error_message` | `str` | `""` | The error message to raise. Use {value} to reference the value that is not set. | + +## Validate Value Is Not Set + +`validation/ValidateValueIsNotSet` + +Validate that a value is NOT set, raising an error if it is. + +A value counts as unset when it is null, unresolved or a blank string. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `value` | `any` | The value to validate | +| `error_message` | `str` | Custom error message, `{value}` is replaced with the value (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `value` | `any` | The (unset) value, passed through | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `error_message` | `str` | `""` | The error message to raise. Use {value} to reference the value that is not set. | + +## Validate Value Is Set + +`validation/ValidateValueIsSet` + +Validate that a value is set, raising an error if it isn't. + +A value counts as unset when it is null, unresolved or - when +`blank_string_is_unset` is true - a blank string. Other falsy values +(0, false) count as set. On success the value is passed through. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `value` | `any` | The value to validate | +| `error_message` | `str` | Custom error message, `{value}` is replaced with the value (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `value` | `any` | The validated value, passed through | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `error_message` | `str` | `""` | The error message to raise. Use {value} to reference the value that is not set. | +| `blank_string_is_unset` | `bool` | `True` | If true, a blank string will be considered unset | diff --git a/docs/user-guide/node-editor/reference/nodes/websocket.md b/docs/user-guide/node-editor/reference/nodes/websocket.md new file mode 100644 index 00000000..20dcaf21 --- /dev/null +++ b/docs/user-guide/node-editor/reference/nodes/websocket.md @@ -0,0 +1,140 @@ +# Websocket Nodes + +Plumbing for module-defined websocket handlers: route requests, queue responses and signal operation success or failure to the frontend. + + + + +_4 nodes._ + +| Node | Registry path | +| --- | --- | +| [Get Websocket Router](#get-websocket-router) | `websocket/GetWebsocketRouter` | +| [Websocket Operation Done](#websocket-operation-done) | `websocket/signals/OperationDone` | +| [Websocket Operation Failed](#websocket-operation-failed) | `websocket/signals/OperationFailed` | +| [Websocket Response](#websocket-response) | `websocket/WebsocketResponse` | + +## Get Websocket Router + +`websocket/GetWebsocketRouter` + +Get a websocket router (plugin) by its route name from the active +websocket handler. + +Raises an error if no plugin is registered for the given route. + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `router` | `str` | The route name, passed through | +| `websocket_router` | `websocket_router` | The websocket router (plugin) instance | +| `websocket_handler` | `websocket_handler` | The active websocket handler | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `router` | `str` | `""` | The router to get the websocket plugin for. Choices are generated at runtime. | + +## Websocket Operation Done + +`websocket/signals/OperationDone` + +Signal to the frontend that a websocket operation has completed successfully. + +Sends an `operation_done` message through the websocket router, optionally +emits a status message and - unless `signal_only` is set - triggers an auto +save of the scene (or marks the scene as unsaved when auto save is off or +disallowed). + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `websocket_router` | `websocket_router` | The websocket router (plugin) to signal through | +| `signal_only` | `bool` | If true, only send the signal and skip the save handling (optional) | +| `allow_auto_save` | `bool` | Whether to allow the scene to auto save after the operation (optional) | +| `emit_status_message` | `str` | Status message to display in the frontend (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `websocket_router` | `websocket_router` | The websocket router, passed through | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `signal_only` | `bool` | `False` | Whether to signal only or emit a status | +| `allow_auto_save` | `bool` | `True` | Whether to allow auto save | +| `emit_status_message` | `str` | `""` | The status message to emit | + +## Websocket Operation Failed + +`websocket/signals/OperationFailed` + +Signal to the frontend that a websocket operation has failed. + +Sends an `operation_done` message carrying the error through the websocket +router and optionally emits an error status message in the frontend. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `websocket_router` | `websocket_router` | The websocket router (plugin) to signal through | +| `message` | `str` | The error message (optional) | +| `emit_status` | `bool` | Whether to also emit an error status message (optional) | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `websocket_router` | `websocket_router` | The websocket router, passed through | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `message` | `str` | `""` | The message to emit | +| `emit_status` | `bool` | `True` | Whether to emit a status | + +## Websocket Response + +`websocket/WebsocketResponse` + +Queue a message to be sent to the frontend through the websocket. + +The message is sent with the router's name as its type, the given action +name, and the data dict merged into the message payload. + +**Inputs** + +| Input | Type | Description | +| --- | --- | --- | +| `state` | `any` | The graph state | +| `websocket_router` | `websocket_router` | The websocket router (plugin) to send the message through | +| `action` | `str` | The action name of the message | +| `data` | `dict` | Dict of additional payload fields merged into the message | + +**Outputs** + +| Output | Type | Description | +| --- | --- | --- | +| `state` | `any` | The state input, passed through | +| `websocket_router` | `websocket_router` | The websocket router, passed through | +| `action` | `str` | The action input, passed through | +| `data` | `dict` | The data input, passed through | + +**Properties** + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `action` | `str` | `""` | The action to send to the websocket | +| `data` | `dict` | `{}` | The data to send to the websocket | diff --git a/docs/user-guide/restoring-scenes.md b/docs/user-guide/restoring-scenes.md index 90f3642e..46940cd4 100644 --- a/docs/user-guide/restoring-scenes.md +++ b/docs/user-guide/restoring-scenes.md @@ -1,49 +1,40 @@ -# Restoring Scenes from Backups +# Restoring Scenes Talemate provides two ways to return a scene to a previous state: -- **Restore from Backup** — use the automatic version history to go back to any previous revision (covered on this page) +- **Timeline** — use the automatic version history to browse every previous revision and fork from any point (covered on this page) - **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) -Both methods create a new, unsaved scene — your existing files are never modified. +!!! warning "The timeline only forks in this version" + 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. -## Restore from backup +## The timeline -Every time you save, Talemate records the changes as a delta in the scene's [changelog directory](/talemate/user-guide/scene-directory#changelog). This version history lets you restore a scene to any previous revision. +Every time you save, Talemate records the changes as a delta in the scene's [changelog directory](/talemate/user-guide/scene-directory#changelog). The timeline lets you browse this version history: drag the slider to any revision to preview the scene's messages as they were at that point, then fork that revision into a new save. Browsing and previewing change nothing on disk. -### How to restore +![Timeline dialog](/talemate/img/0.39.0/timeline-dialog.png) -1. From the main screen, locate your scene in the **Quick load** section -2. Click the three-dot menu (⋮) on the scene card -3. Select **Restore from Backup** +### Opening the timeline -![Restore menu option](/talemate/img/0.33.0/restore-from-backup.png) +The timeline can be opened from four places: -### Restore options +- **While playing a scene** — click the :material-content-save: **Save** button in the [Scene tools](/talemate/user-guide/scenario-tools) toolbar and select :material-history: **Timeline** +- **From a message** — click the :material-source-fork: **Fork** button underneath any message; the timeline opens positioned at that message's revision +- **From the main screen** — click the three-dot menu (⋯) beneath a scene card in the **Quick load** section and select :material-history: **Timeline** +- **From the Scene Library** — click the three-dot menu (⋮) on any save file row and select :material-history: **Timeline** -![Restore menu option](/talemate/img/0.33.0/restore-from-backup-dlg.png) +### Forking a revision -The backup restore dialog provides several restoration options: +Once you've found the point you want to return to, click **Fork to new save**. It creates a new save file in the same project directory from the selected revision, and asks for a name. The scene you are playing is not modified, and a name an existing save already uses is refused rather than written over. -- **Restore Earliest** - Returns the scene to its initial state (revision 0) -- **Restore Latest** - Restores the most recent saved state -- **Filter by date/time** - Find and restore to a specific point in time - -When you use the date/time filter, Talemate will show the closest available revision to your selected time. - -### Important notes +!!! info + Forking never destroys your version history. Every revision is kept, so you can open the timeline again and scrub to any other point. !!! warning - Restoring creates a **new, unsaved scene** from the selected revision. Your original scene file will **not** be modified. This allows you to safely explore previous states without losing your current progress. - -After restoration: - -- The restored scene opens as a new, unsaved scene -- You must manually save it to preserve the restored state -- The original scene file remains unchanged in its current state + Scenes connected to a shared world context are disconnected from it when forked, since shared context cannot be reconstructed to a specific revision. ## Restore from restore point If you've configured a restore point in the [scene settings](/talemate/user-guide/world-editor/scene/settings#restoration-settings), you can reset the scene to that baseline state using the **:material-backup-restore: Restore Scene** button. This is useful for scenes you want to replay from a fixed starting point, such as testing a scene during development. -Unlike backup restoration, which uses the automatic version history, restore points use a specific save file you've chosen as the baseline. See the [restoration settings documentation](/talemate/user-guide/world-editor/scene/settings#restoration-settings) for setup details. +Unlike the timeline, which uses the automatic version history, restore points use a specific save file you've chosen as the baseline. See the [restoration settings documentation](/talemate/user-guide/world-editor/scene/settings#restoration-settings) for setup details. diff --git a/docs/user-guide/saving.md b/docs/user-guide/saving.md index fe4b51ec..2391b654 100644 --- a/docs/user-guide/saving.md +++ b/docs/user-guide/saving.md @@ -12,7 +12,7 @@ Saves can also serve as **restore points**. By designating one save file as the To save while looking at the scene, click the :material-content-save: **Save** button on the right of the [Scene tools](/talemate/user-guide/scenario-tools) toolbar. -![Scene save](/talemate/img/0.26.0/scene-save.png) +![Scene save](/talemate/img/0.39.0/scene-save.png) ## Saving from the world editor @@ -47,4 +47,4 @@ These additional save files can be set as [restore points](/talemate/user-guide/ You can create a new copy of a scene from any message in the scene by clicking the :material-source-fork: **Fork** button underneath the message. -All progress after the target message will be removed and a new scene will be created with the previous messages. \ No newline at end of file +This opens the [timeline](/talemate/user-guide/restoring-scenes) positioned at that message's revision, where you can preview the scene state and fork it to a new save. \ No newline at end of file diff --git a/docs/user-guide/scenario-tools.md b/docs/user-guide/scenario-tools.md index 7b5852a9..5e399558 100644 --- a/docs/user-guide/scenario-tools.md +++ b/docs/user-guide/scenario-tools.md @@ -20,6 +20,17 @@ This feature provides visibility into what's happening behind the scenes without The agent activity bar can be toggled on or off in [Settings > Game > General](/talemate/user-guide/app-settings/game#show-agent-activity-bar). It is enabled by default. +## Quick Settings + +![Quick settings chips](/talemate/img/0.39.0/scene-tools-quick-settings.png) + +Above the scene tools sits a row of small toggle chips. Click a chip to toggle it — a green check marks an active toggle. + +- **:material-content-save: Auto Save** — Automatically save after each game loop. Requires the scene to have been saved manually once; until then the chip shows a warning explaining why it is unavailable. +- **:material-robot: Auto Progress** — The AI automatically progresses the scene after your turn. +- **:material-image-area: Immersive** — Render the [scene backdrop](/talemate/user-guide/inline-visuals/#scene-backdrop) behind the scene text. This chip only appears when the scene has a backdrop image set (or a generated background is available to become one); toggling it off remembers the chosen image, so you can switch back at any time. +- **:material-cog: Settings** — Not a toggle: opens a shortcut menu to commonly used [application settings](/talemate/user-guide/app-settings/) (Game, Appearance) and the settings of individual agents (Conversation, Narrator, Creator, Director, Visualizer, Text to Speech). + ## Tool Bar @@ -191,37 +202,51 @@ By default the [:material-script-text: Narrator Agent](/talemate/user-guide/agen ### :material-earth: World State Actions -##### Automatic State Updates +The :material-earth: world state menu lets you set up and manage tracked states, apply favorited state templates, and generate or edit world context — all without leaving the scene. -Allows you to quickly set up tracked character and world states. +![World State Actions](/talemate/img/0.39.0/scene-tool-world-state-actions.png) !!! info "What is a tracked state?" --8<-- "docs/user-guide/tracking-a-state.md:what-is-a-tracked-state" -Please refer to the [World State](/talemate/user-guide/world-state) section for more information on how set up custom states to track. + Please refer to the [World State](/talemate/user-guide/world-state) section for more information on how to set up custom states to track. + +#### :material-cube-scan: Track state + +Opens a dialog to quickly set up a new tracked state — a shortcut for doing the same thing in the world editor. Choose whether to track the **World** or a specific **character**, describe what to track, then set how often it updates and how it attaches to context. + +![Track State dialog](/talemate/img/0.39.0/scene-tool-track-state-modal.png) + +When a character is the target, an extra **Require character active** option appears so the state only progresses while that character is in the scene. The default context attachment also adapts to the target — `Passive` for the world, `Sequential` for a character. + +![Track State dialog for a character](/talemate/img/0.39.0/scene-tool-track-state-modal-character.png) + +See [Context Attachment Method](/talemate/user-guide/tracking-a-state/#context-attachment-method) for what each attachment option does. Once created, the state can be viewed and managed in the World State Manager. + +#### Quick-apply favorite states -Any favorited state will be shown in the :material-earth: world state context menu. *Your list may be different than the one shown here, depending on what you have favorited.* +Any state template you have favorited appears under its target in the world state menu — the player character, each NPC, and **World** each get a submenu of the favorites relevant to them. *Your list may be different than the one shown here, depending on what you have favorited.* -![World State Actions](/talemate/img/0.26.0/scene-tool-world-state-actions.png) +![Favorite states submenu](/talemate/img/0.39.0/scene-tool-world-state-submenu.png) -Clicking on any item in `Autoamtic State Updates` will generate the current state and keep it tracked until it is removed. - -A tracked state will have a checkmark next to it. - -![World State Tracked](/talemate/img/0.26.0/scene-tool-world-state-applied.png) +Clicking a favorite generates the current state and keeps it tracked until it is removed. A state that is already tracked is marked with a checkmark; clicking it opens the state in the World State Manager instead of creating a duplicate. -#### :material-book-open-page-variant: Open the world state manager +#### :material-auto-fix: Generate world context -Will open the world state template editor, where you can view and edit your available world states templates. +Generates a new world entry from the current scene context — useful for locations, lore, backstory, and other world details. -#### :material-refresh: Update the world state +#### :material-book-open-page-variant: World context editor / Character context editor -Will cause a regeneration of the world state. +Open the World State Manager to view and edit world entries and character context, including any tracked states you have set up. + +#### :material-refresh: Update world snapshot + +Regenerates the world state snapshot shown in the left :material-earth: `World` panel. Hold ++ctrl++ (or ++cmd++) while clicking to wipe the snapshot and start fresh. !!! info "Does not run state re-inforcement" - Currently, this will not re-inforce the state of the world or characters, it will only update the world state context that is displayed in the left panel under the :material-earth: `World` section. + This only refreshes the world state snapshot displayed in the left panel under the :material-earth: `World` section; it does not re-inforce the tracked states of the world or characters. ### :material-puzzle-edit: Creative Tools @@ -257,7 +282,7 @@ If the current narration and scene progress has not yet indicated the character Allows you to quickly generate a new, interactive character to add to the scene. -![Introduce new character 1](/talemate/img/0.30.0/scene-tool-intro-char-1.png) +![Introduce new character 1](/talemate/img/0.39.0/scene-tool-intro-char-1.png) ##### Determine Name @@ -284,6 +309,16 @@ Write direction on how you want the character to enter the scene. !!! note "A character already interacting in the scene will not have their entrance narrated" If the narrator has already established the character interacting with your characters this "entry narration" will be skipped. The check for this 100% based on an AI prompt so may not always work correctly. +##### Generate example dialogue + +If selected, the AI will generate a few example dialogue lines for the character, showcasing how they speak and act. + +When checked, an **Example dialogue guidance** field appears where you can optionally steer how the examples are written — tone, speech patterns, quirks (e.g. "Speaks in short sentences, dry humor"). Leave it blank to let the AI infer the voice from the character's description and attributes. + +![Introduce new character example dialogue](/talemate/img/0.39.0/scene-tool-intro-char-example-dialogue.png) + +The generated examples can be reviewed and edited later under the character's [Actor management](/talemate/user-guide/world-editor/characters/actor) tab in the world editor. + ##### Templates Control attribute generation using your existing [World State Templates](/talemate/user-guide/templates). @@ -372,4 +407,4 @@ Generated images can be viewed and managed in the [Visual Library](/talemate/use ### :material-content-save: Saving -Context menu that will provide you with `Save` and `Save As` options. \ No newline at end of file +Context menu that will provide you with `Save` and `Save As` options, restoration actions — `Restore` to the configured [restore point](/talemate/user-guide/world-editor/scene/settings#restoration-settings) and the [`Timeline`](/talemate/user-guide/restoring-scenes) for previewing or forking from any revision — and `Reset Scene State`. \ No newline at end of file diff --git a/docs/user-guide/scene-browser.md b/docs/user-guide/scene-browser.md new file mode 100644 index 00000000..626e4f0c --- /dev/null +++ b/docs/user-guide/scene-browser.md @@ -0,0 +1,77 @@ +# Home Screen & Scene Library + +The :material-home: **Home** screen is Talemate's landing page. It is where you load, import, create, and manage your scenes. + +![Home screen](/talemate/img/0.39.0/getting-started-load-screen.png) + +It is made up of four areas: + +- **Quick load** — cards for your most recently played scenes +- **Scene Library** — a file browser of every scene project on disk +- **Import** — drag-and-drop import for scenes and character cards +- **Create new scene** — start a blank scene + +## Quick load + +The **Quick load** row shows your most recently saved scenes as cards. Click a card to load that scene. + +Each card has a three-dot menu (⋯) beneath it with additional actions: + +- :material-history: **Timeline** — browse and fork from 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 (refused for the currently loaded scene — load a different scene first) + +## Scene Library + +The **Scene Library** lists every scene project found in your `scenes/` directory as an expandable tree. Each project row shows its cover image, the number of save files it contains, and when it was last saved. + +![Scene library](/talemate/img/0.39.0/scene-browser-library.png) + +Expanding a project reveals: + +- An information row summarizing the project's contents — how many media assets and [node modules](/talemate/user-guide/node-editor/) it holds +- The project's save files, each with its scene name, last-modified date, and file size + +Click a save file to load it. Each save file row also has a three-dot menu (⋮) with the same actions as the quick-load cards — **Timeline**, **Remove from Quick Load** (only shown when the save is in Quick Load), and **Delete**. + +!!! 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: + +- **Delete** in a **save file**'s three-dot menu (⋮) deletes that single file after a confirmation prompt. +- :material-file-remove-outline: on a **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. + +!!! note + The save file or project of the **currently loaded** scene cannot be deleted — the next save would silently recreate it. Load a different scene first. + +Deleting a project is irreversible, so the confirmation dialog requires typing the project name before the delete button becomes available: + +![Delete scene project dialog](/talemate/img/0.39.0/scene-browser-delete-project.png) + +!!! warning + Deleting a scene project removes its whole directory from disk — see [Scene Directory Structure](/talemate/user-guide/scene-directory) for what that includes. There is no undo. + +## Import + +The **Import** dropzone accepts: + +- **Talemate scenes** — `.json` save files, or `.zip` archives [exported from the world editor](/talemate/user-guide/world-editor/scene/export) (a complete scene including its assets and node modules) +- **Character cards** — `.png`, `.webp`, or `.json` card files + +Drag a file onto the dropzone or click it to browse. Character cards open the [import options dialog](/talemate/user-guide/character-card-import); Talemate scene files load directly. + +## Create new scene + +**Create new scene** starts a blank scene. After choosing a name and optional writing style and director persona, the scene opens in the [world editor](/talemate/user-guide/world-editor/) where you can add characters and scene details. diff --git a/docs/user-guide/scene-directory.md b/docs/user-guide/scene-directory.md index ccebc99e..eeb3dfa4 100644 --- a/docs/user-guide/scene-directory.md +++ b/docs/user-guide/scene-directory.md @@ -33,7 +33,7 @@ Stores media assets associated with the scene, such as generated images and TTS ### `changelog/` -Contains the automatic version history that powers the [Restore from Backup](/talemate/user-guide/restoring-scenes) feature. Every time you save, Talemate records the changes as a delta, allowing you to restore the scene to any previous revision. +Contains the automatic version history that powers the [Timeline](/talemate/user-guide/restoring-scenes) feature. Every time you save, Talemate records the changes as a delta, allowing you to preview any previous revision and fork it into a new save. This directory includes: @@ -43,6 +43,10 @@ This directory includes: You generally don't need to interact with these files directly. +### `backups/` + +Holds automatic safety copies of the scene file. Each backup is a timestamped copy of the scene file as it was before the operation that created it. + ### `nodes/` Stores [node editor](/talemate/user-guide/node-editor/) graphs for the scene, such as the scene loop and creative loop. @@ -67,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. diff --git a/docs/user-guide/templates/index.md b/docs/user-guide/templates/index.md index d7e7eb05..c3f6e0f8 100644 --- a/docs/user-guide/templates/index.md +++ b/docs/user-guide/templates/index.md @@ -26,4 +26,13 @@ Writing style templates are used to define a writing style that can be applied t State reinforcement templates are used to quickly (or even automatically) setup common attribues and states you want to track for characters or the world itself. They revolve around a question, statement or attribute name that you want to track for a character. The AI will use this template to generate content that matches the query, based on the current progression of the scene. ### :material-movie-open: Scene Type -Scene type templates are used to define different types of scenes that can be played in your game. Each scene type has different rules and constraints that guide the generation and flow of the scene. \ No newline at end of file +Scene type templates are used to define different types of scenes that can be played in your game. Each scene type has different rules and constraints that guide the generation and flow of the scene. + +### :material-palette: Visual Style +Visual style templates define how image generation prompts are constructed — positive and negative prompt prefixes and suffixes, and the prompting type (keywords vs. descriptive). Select a style in the [visualizer agent's Styles settings](/talemate/user-guide/agents/visualizer/settings/#styles). + +### :material-auto-fix: Visual Prompt Finalizer +Visual prompt finalizer templates define a reusable set of post-processing actions (exact, fuzzy or regex match and replace, or an AI instruction) applied to image generation prompts. See [Visual Prompt Finalizer](/talemate/user-guide/templates/visual-finalizer). + +### :material-account-voice: Agent Persona +Agent persona templates define how an agent should present and behave in prompts — tone, perspective, style. Assign a persona per agent in the [Scene Settings](/talemate/user-guide/world-editor/scene/settings). (Currently used by the director.) \ No newline at end of file diff --git a/docs/user-guide/templates/visual-finalizer.md b/docs/user-guide/templates/visual-finalizer.md new file mode 100644 index 00000000..42b40016 --- /dev/null +++ b/docs/user-guide/templates/visual-finalizer.md @@ -0,0 +1,42 @@ +# Visual Prompt Finalizer + +Visual prompt finalizer templates define a reusable set of post-processing actions that rewrite image generation prompts right before they are sent to the image generation backend — exact, fuzzy or regex match and replace, or an AI instruction applied to the whole prompt. + +They exist so you can maintain a set of actions once and insert it wherever it is needed, instead of recreating the same rows in the [visualizer agent's Prompt Finalization settings](/talemate/user-guide/agents/visualizer/settings/#prompt-finalization) for every configuration. + +!!! info "Shipped preset: Ideogram JSON" + Talemate ships an **Ideogram JSON** finalizer template that converts the positive prompt into an Ideogram 4.0 structured JSON prompt via an AI action. It appears in the preset picker out of the box. + +## Creating a visual prompt finalizer + +--8<-- "docs/snippets/common.md:world-editor-create-group" + +Next enter a template name and select **Visual prompt finalizer** as the template type. + +### Prompt finalizer name + +The name / label of the template. This is the name displayed in the list of templates and in the preset picker. + +### Template description + +A longer description of the template. What does this set of actions do, and when should it be used? + +### Favorite + +Mark the template as a favorite to make it easier to find in the list of templates. Favorited templates are shown at the top of the list. + +### Actions + +The table of post-processing actions, executed from top to bottom. The rows work exactly like the ones in the visualizer agent settings — see [Post-processing Actions](/talemate/user-guide/agents/visualizer/settings/#post-processing-actions) for a description of the available modes, flags, targets, and visual type restrictions. + +## Using a visual prompt finalizer + +Open the visualizer agent settings and switch to the **Prompt Finalization** tab. The **Preset** picker below the actions table inserts the template's actions into the table as editable copies. + +Because the rows are copied, you can reorder, edit, or remove them freely after inserting — and later changes to the template do not affect actions that were already inserted. + +## Editing and deleting + +To edit an existing template, select it from the list of templates in the left sidebar and adjust the fields — updates are applied automatically. + +To delete a template, select it and click the **:material-close-box-outline: Remove Template** button. diff --git a/docs/user-guide/world-editor/characters/actor.md b/docs/user-guide/world-editor/characters/actor.md index 44a7fe51..c1210cf7 100644 --- a/docs/user-guide/world-editor/characters/actor.md +++ b/docs/user-guide/world-editor/characters/actor.md @@ -23,6 +23,9 @@ You can press the **:material-auto-fix: Generate** button on top of the instruct It can also be useful to provide examples of how the character should speak. Examples are provided one line at a time. +!!! note "Use straight quotes for spoken words" + Talemate delimits spoken words with the straight quote `"`. Typographic quotes (`“ ” „ ‘ ’ ‚`) in an example line are converted to their plain equivalents when the line is saved — a line that kept fancy quotes would be treated as narration and mis-chunked by dialogue parsing and text-to-speech. Characters saved before 0.39.0 keep their fancy quotes until the affected line is saved again. + #### Example > `*Ramunakz sighs deeply, a sound like wind whispering through a graveyard.* "Time, like your fleeting life, flows ever onward. Struggle only prolongs the inevitable."` diff --git a/docs/user-guide/world-editor/characters/create.md b/docs/user-guide/world-editor/characters/create.md index 683c1a0b..20df4bfe 100644 --- a/docs/user-guide/world-editor/characters/create.md +++ b/docs/user-guide/world-editor/characters/create.md @@ -6,7 +6,7 @@ Then click on the :material-account-plus: **Create Character** button on the lef A `New character` entry will appear in the list. -![world-editor-create-player-character-1](/talemate/img/0.26.0/world-editor-create-player-character-1.png) +![world-editor-create-player-character-1](/talemate/img/0.39.0/world-editor-create-player-character-1.png) ### Enable AI Generation @@ -14,6 +14,9 @@ If this is toggled on the character description and some attributes will automat If the name is left blank, it will also be generated based on the instructions. +!!! tip "Fast mode" + AI-assisted character creation routes through the Creator agent. Its [Character Creation settings](/talemate/user-guide/agents/creator/settings/) offer a **Fast Character Generation** mode that consolidates the generation into a single prompt instead of one prompt per aspect. + ### AI Generation Instructions Here you can provide instructions for the AI to generate the character. This can include the character's appearance, personality, and other details. @@ -33,6 +36,16 @@ The short to medium length description of the character. This will be generated If this is toggled on, the AI will generate some attributes for the character based on the instructions you provide. +### Generate Example Dialogue + +If this is toggled on (it is off by default), the AI will generate a few example dialogue lines for the character, showcasing how they speak and act. + +When enabled, an **Example dialogue guidance** field appears where you can optionally steer how the examples are written — tone, speech patterns, quirks (e.g. "Speaks in short sentences, dry humor"). Leave it blank to let the AI infer the voice from the character's description and attributes. + +![world-editor-create-character-example-dialogue](/talemate/img/0.39.0/world-editor-create-character-example-dialogue.png) + +The generated examples can be reviewed and edited after creation under the character's [Actor management](/talemate/user-guide/world-editor/characters/actor) tab. + ### Controlled by Player If this is toggled on, the character will be flagged as the main player character. This is used to determine who the player is controlling in the scene. diff --git a/docs/user-guide/world-editor/characters/visuals.md b/docs/user-guide/world-editor/characters/visuals.md index 9468515d..b9590723 100644 --- a/docs/user-guide/world-editor/characters/visuals.md +++ b/docs/user-guide/world-editor/characters/visuals.md @@ -8,11 +8,12 @@ To access the Visuals tab, open the :material-earth-box: **World Editor** and na ## Overview -The Visuals tab is organized into three sub-tabs: +The Visuals tab is organized into four sub-tabs: - **:material-image-frame: Cover Image** - Manage full character reference images - **:material-account-circle: Portrait** - Manage character portraits used in dialogue - **:material-format-list-bulleted-type: Rules** - Define permanent visual traits for image generation +- **:material-auto-fix: Prompt Finalization** - Post-process image prompts involving this character ## Cover Image @@ -173,6 +174,18 @@ Visual rules are included in every image generation prompt for this character, e Visual rules are saved automatically when you click outside the text field. +## Prompt Finalization + +The Prompt Finalization tab holds a table of post-processing actions that rewrite the image generation prompt whenever this character is the subject of the image. They run right before the prompt is sent to the image generation backend, after the visualizer agent's own [prompt finalization actions](/talemate/user-guide/agents/visualizer/settings/#prompt-finalization) — so character-specific fixes always get the last word. + +Each action works exactly like the agent-level ones: exact, fuzzy, or regex match and replace, or an AI instruction applied to the whole prompt. See the [visualizer agent documentation](/talemate/user-guide/agents/visualizer/settings/#prompt-finalization) for a description of the available modes and options. + +**Example uses:** + +- Replace a keyword the prompt generator keeps getting wrong for this character ("red hair" → "auburn hair") +- Remove a recurring unwanted keyword from prompts about this character +- Enforce phrasing that your image model responds to better for this character + ## Prerequisites To use the image generation features in the Visuals tab, you need to have the Visual Agent properly configured: diff --git a/docs/user-guide/world-editor/scene/.pages b/docs/user-guide/world-editor/scene/.pages index 8a29b5bd..bde6616b 100644 --- a/docs/user-guide/world-editor/scene/.pages +++ b/docs/user-guide/world-editor/scene/.pages @@ -4,4 +4,5 @@ nav: - Game State: game-state.md - Settings: settings.md - Shared World: shared-context.md + - Visuals: visuals.md - Export: export.md diff --git a/docs/user-guide/world-editor/scene/export.md b/docs/user-guide/world-editor/scene/export.md index a457904e..3ffe27b5 100644 --- a/docs/user-guide/world-editor/scene/export.md +++ b/docs/user-guide/world-editor/scene/export.md @@ -2,15 +2,16 @@ This tab allows you to export the scene. -Currently only talemate format is supported, but we also want to support character card exports, to some extent. - -Exported scenes can be lupoaded into Talemate from the :material-home: Home screen. +Exported scenes can be loaded into Talemate from the :material-home: Home screen. ![World editor scene export 1](/talemate/img/0.26.0/world-editor-scene-export-1.png) ### Export format -The export format is currently set to `Talemate scene`, which is the only format supported at the moment. +Two formats are available: + +- `Talemate Scene (JSON only)` - just the scene file itself. +- `Complete Scene Package (ZIP)` - the scene file plus its assets, nodes, info, templates and per-scene agent settings. ### Export name @@ -18,4 +19,4 @@ The file name (Without extension) that the exported scene will be saved as. ### Reset progress -If checked, the progress of the scene will be reset. Clearing messages, choices and other stateful data. \ No newline at end of file +If checked, the exported scene will have its progress reset - clearing messages, choices and other stateful data. Your current session is not affected. \ No newline at end of file diff --git a/docs/user-guide/world-editor/scene/outline.md b/docs/user-guide/world-editor/scene/outline.md index 5258d853..d3e800c8 100644 --- a/docs/user-guide/world-editor/scene/outline.md +++ b/docs/user-guide/world-editor/scene/outline.md @@ -23,7 +23,7 @@ The `Content Classification` is used to set the tone and expectation of the gene You can type in a value or pick something from the list. !!! note - What is available in the list is controlled in the :material-cog: application settings under the `Creator` tab. + What is available in the list is controlled in the :material-cog: application settings under the **Storytelling** group. ##### Example @@ -69,7 +69,7 @@ Each of the four fields is a combobox. You can: - **Pick a preset** from the dropdown. - **Type a custom value** — the field accepts any free-form text and your custom entry is saved with the scene whether or not it matches a preset. -The dropdown list is shared by all four fields and is managed in **Settings → Creator → Perspective Presets**. See [Creator settings](../../app-settings/creator.md#perspective-presets) for how to add, remove, or rename presets. +The dropdown list is shared by all four fields and is managed in **Settings → Storytelling → Perspective Presets**. See [Storytelling settings](../../app-settings/creator.md#perspective-presets) for how to add, remove, or rename presets. #### The `{player_name}` placeholder @@ -107,7 +107,7 @@ This table lists the presets that ship with Talemate, what each one means in pla | `Second person, present tense.` | The narrator addresses an unnamed "you" — classic interactive-fiction voice. Present tense. | *"You walk up to the porch. The door looks older than the rest of the house."* | | `Second person, present tense. Talking to {player_name}.` | Same as above, but the narrator explicitly knows the player character's name and may use it. | *"You walk up to the porch, Annabelle. The door looks older than the rest of the house."* | -You are not limited to these — type anything into the field and it will be passed to the AI verbatim. The presets are just shortcuts for the most common configurations. See [Creator settings → Perspective Presets](../../app-settings/creator.md#perspective-presets) for how to add your own. +You are not limited to these — type anything into the field and it will be passed to the AI verbatim. The presets are just shortcuts for the most common configurations. See [Storytelling settings → Perspective Presets](../../app-settings/creator.md#perspective-presets) for how to add your own. #### Common patterns diff --git a/docs/user-guide/world-editor/scene/visuals.md b/docs/user-guide/world-editor/scene/visuals.md new file mode 100644 index 00000000..605d0ea6 --- /dev/null +++ b/docs/user-guide/world-editor/scene/visuals.md @@ -0,0 +1,49 @@ +# Scene Visuals + +The **Visuals** tab in the Scene Editor provides a dedicated interface for managing the scene's own imagery — background illustrations, scene illustrations, scene cards, the scene cover image, and the [scene backdrop](/talemate/user-guide/inline-visuals/#scene-backdrop) — mirroring the [character visual manager](/talemate/user-guide/world-editor/characters/visuals). + +To access it, open the :material-earth-box: **World Editor**, navigate to the :material-script: **Scene** tab, and click the :material-image-multiple-outline: **Visuals** tab. + +![Scene editor Visuals tab, Scene Card sub-tab](/talemate/img/0.39.0/world-editor-scene-visuals-overview.png) + +## Overview + +The Visuals tab is organized into four sub-tabs: + +- **:material-image-area: Background Illustration** - Purely environmental images of the scene ("Visualize Scene (Background)") +- **:material-image-filter-hdr: Scene Illustration** - Images of specific moments in the story ("Visualize Moment") +- **:material-image-frame: Scene Card** - Portrait images representing the story as a whole ("Visualize Scene (Card)"), the ones you pick the scene cover image from +- **:material-auto-fix: Prompt Finalization** - Edit the Visualizer agent's per-scene prompt finalization overrides + +## Background Illustration, Scene Illustration & Scene Card + +The three image sub-tabs work the same way and differ only in the visual type they manage. Each shows a grid of the scene's images of that type — badges mark the image currently used as the scene **Cover** and the current **Backdrop**. Background illustrations and scene illustrations are landscape images, scene cards are portrait ones. + +When a backdrop is set, a banner above the grid names the backdrop image, offers a **Render backdrop** switch (the same toggle as the scene tools **Immersive** chip), and an **Unset backdrop** button that removes the backdrop entirely while keeping the image in the scene assets. The backdrop is meant for the landscape types, so the Scene Card sub-tab shows neither the banner nor the **Set as Scene Backdrop** action. + +### Adding images + +1. **Drag and Drop**: Drop an image file onto the upload card +2. **Generate Variation**: Create a variation of an existing scene image — modify time of day, weather, mood, or details via an image-editing prompt (e.g. "make it night time", "add rain"). Batch prompts let you generate several variations in one go. +3. **Generate New**: Create a completely new image; the visual agent builds a prompt from the current scene state and your instructions + +Generation requires a ready [Visualizer Agent](/talemate/user-guide/agents/visualizer); Generate Variation additionally needs a backend with image-editing support and at least one existing image to use as reference. + +### Image actions + +Click an image to access its menu: + +- **Set as Scene Cover Image**: Make this the scene's cover image (shown on the scene card in the scene directory) +- **Set as Scene Backdrop** / **Unset Scene Backdrop**: Make this image the [scene backdrop](/talemate/user-guide/inline-visuals/#scene-backdrop), rendered behind the scene text — or unset it (the image stays in the scene assets). Setting is offered on the two illustration sub-tabs; unsetting is offered wherever the current backdrop image appears +- **View Image**: Open a larger preview +- **Open in Visual Library**: View and edit the image in the full [Visual Library](/talemate/user-guide/agents/visualizer/visual-library) +- **Delete**: Permanently remove the image from the scene assets + +## Prompt Finalization + +This sub-tab edits the Visualizer agent's **per-scene overrides** for [Prompt Finalization](/talemate/user-guide/agents/visualizer/settings/#prompt-finalization) without leaving the world editor — the same overrides you would otherwise manage through the Agent Modal's scene mode. + +Click the :material-link-variant-off: icon next to a field to activate an override for the scene, then edit and **Save**. Overrides are stored in the scene's agent-settings file; if the scene doesn't have one linked yet, it is created with the default name on first save. + +!!! note + The scene must have been saved to disk at least once, and must not have opted out of per-scene agent settings (see [Scene Settings](/talemate/user-guide/world-editor/scene/settings)) for overrides to be editable here. diff --git a/install.sh b/install.sh old mode 100644 new mode 100755 diff --git a/pyproject.toml b/pyproject.toml index cedb5270..63354fa1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "talemate" -version = "0.38.0" +version = "0.39.0" description = "AI-backed roleplay and narrative tools" authors = [{name = "VeguAITools"}] license = {text = "GNU Affero General Public License v3.0"} @@ -38,7 +38,7 @@ dependencies = [ "isodate>=0.6.1", "thefuzz>=0.20.0", "tiktoken>=0.5.1", - "nltk>=3.8.1", + "nltk>=3.8.1,!=3.10.1", "huggingface-hub>=0.20.2", "RestrictedPython>7.1", "numpy>=2", @@ -62,7 +62,9 @@ dependencies = [ # "chatterbox-tts==0.1.2", # tts_webui_chatterbox_tts is much faster without seeminly any quality loss "tts_webui_chatterbox_tts==0.4.4", - "torchcodec>=0.9.0", + # 0.14.0 wheels link against the CUDA 13 runtime (libnvrtc.so.13); our torch + # comes from the cu128 (CUDA 12.8) index, so cap below 0.14 to stay compatible. + "torchcodec>=0.9.0,<0.14", # kokoro TTS "kokoro>=0.9.4", "soundfile>=0.13.1", @@ -78,6 +80,7 @@ dev = [ "pytest>=6.2", "pytest-asyncio>=0.25.3", "pytest-cov>=4.0", + "pytest-xdist>=3.8", "mypy>=0.910", "mkdocs-material>=9.5.27", "mkdocs-awesome-pages-plugin>=2.9.2", @@ -125,9 +128,9 @@ line_length = 88 [tool.uv] exclude-newer = "1 week" -# safetensors 0.8.0 (uploaded 2026-06-09) is needed by diffusers 0.38.0, which fixes -# CVE-2026-45804 / CVE-2026-44513 / CVE-2026-44827; remove once it ages past the 1-week window -exclude-newer-package = { safetensors = "2026-06-10T00:00:00Z" } +# nltk 3.10.2 (uploaded 2026-08-05) removes the CWD-import hook that 3.10.1 shipped; +# remove once it ages past the 1-week window +exclude-newer-package = { nltk = "2026-08-06T00:00:00Z" } override-dependencies = [ # chatterbox wants torch 2.6.0, but is confirmed working with 2.7.1 "torchaudio>=2.7.1", @@ -135,6 +138,11 @@ override-dependencies = [ # numba needs numpy < 2.3 "numpy>=2,<2.3", "pydantic>=2.11", + # torch 2.11 cu128 pins setuptools<82 (setuptools 82 removed pkg_resources); + # nothing we ship imports pkg_resources and 83.0.0 carries the MANIFEST.in + # unicode-normalization security fix, so override the pin. Confirmed working + # with torch 2.11.0+cu128 (full suite + app boot). + "setuptools>=83", ] [tool.uv.sources] @@ -153,6 +161,13 @@ explicit = true [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" +# Distribute across cores by default. The worker count comes from +# `pytest_xdist_auto_num_workers` in tests/conftest.py, which respects CPU +# affinity and any cgroup quota, and falls back to in-process execution on a +# single CPU. `-n0` runs serially, which is what you want for `-s` / +# `--capture=no` or readable output. (`--pdb` needs no flag — xdist disables +# distribution for it by itself.) +addopts = "-n auto --dist worksteal" [tool.coverage.run] source = ["src/talemate"] diff --git a/scenes/simulation-suite-v2/nodes/fn-sim-suite-add-character.json b/scenes/simulation-suite-v2/nodes/fn-sim-suite-add-character.json index b39ca03a..dc13033f 100644 --- a/scenes/simulation-suite-v2/nodes/fn-sim-suite-add-character.json +++ b/scenes/simulation-suite-v2/nodes/fn-sim-suite-add-character.json @@ -215,7 +215,7 @@ "as_scene_message": true }, "x": 31, - "y": 2217, + "y": 1840, "width": 319, "height": 151, "collapsed": false, @@ -230,7 +230,7 @@ "stage": 5 }, "x": 411, - "y": 2267, + "y": 1890, "width": 210, "height": 118, "collapsed": true, @@ -315,22 +315,6 @@ "registry": "agents/world_state/StateReinforcement", "base_type": "core/Node" }, - "1aa94d91-7040-4fcd-9ca6-8a161e98a3fd": { - "title": "Director Message", - "id": "1aa94d91-7040-4fcd-9ca6-8a161e98a3fd", - "properties": { - "source": "ai", - "action": "actor_instruction" - }, - "x": 844, - "y": 902, - "width": 210, - "height": 162, - "collapsed": false, - "inherited": false, - "registry": "scene/message/DirectorMessage", - "base_type": "core/Node" - }, "132e5d44-542b-455c-8e7d-23defde39b80": { "title": "Push History", "id": "132e5d44-542b-455c-8e7d-23defde39b80", @@ -453,52 +437,6 @@ "registry": "core/Stage", "base_type": "core/Node" }, - "a2457116-35cb-4571-ba1a-cbf63851544e": { - "title": "GET created_character", - "id": "a2457116-35cb-4571-ba1a-cbf63851544e", - "properties": { - "name": "created_character", - "scope": "local" - }, - "x": 39, - "y": 1860, - "width": 210, - "height": 122, - "collapsed": false, - "inherited": false, - "registry": "state/GetState", - "base_type": "core/Node" - }, - "ab17bcda-58a1-4b82-bb98-460b14ede0c8": { - "title": "SET obj.dialogue_instructions", - "id": "ab17bcda-58a1-4b82-bb98-460b14ede0c8", - "properties": { - "attribute": "dialogue_instructions" - }, - "x": 799, - "y": 1880, - "width": 244, - "height": 98, - "collapsed": false, - "inherited": false, - "registry": "data/Set", - "base_type": "core/Node" - }, - "161d2a52-575a-4edb-8a92-f7e9bf08c368": { - "title": "Stage 4", - "id": "161d2a52-575a-4edb-8a92-f7e9bf08c368", - "properties": { - "stage": 4 - }, - "x": 1080, - "y": 1950, - "width": 210, - "height": 118, - "collapsed": true, - "inherited": false, - "registry": "core/Stage", - "base_type": "core/Node" - }, "5041f12d-507f-4f5d-a26f-048625974602": { "title": "Make Text", "id": "5041f12d-507f-4f5d-a26f-048625974602", @@ -520,36 +458,21 @@ "properties": {}, "x": 580, "y": 550, - "width": 245, - "height": 206, + "width": 338, + "height": 246, "collapsed": false, "inherited": false, "registry": "agents/creator/CreateCharacter", "base_type": "core/Graph" }, - "8a050403-5c69-46f7-abe2-f65db4553942": { - "title": "TRUE", - "id": "8a050403-5c69-46f7-abe2-f65db4553942", - "properties": { - "value": true - }, - "x": 400, - "y": 720, - "width": 210, - "height": 58, - "collapsed": true, - "inherited": false, - "registry": "core/MakeBool", - "base_type": "core/Node" - }, "a748570f-e8ce-4281-84db-abe75decc5ad": { "title": "Visual Settings", "id": "a748570f-e8ce-4281-84db-abe75decc5ad", "properties": {}, - "x": 60, - "y": 2480, + "x": 63, + "y": 2105, "width": 405, - "height": 1846, + "height": 2386, "collapsed": true, "inherited": false, "registry": "agents/visual/Settings", @@ -561,8 +484,8 @@ "properties": { "stage": 6 }, - "x": 990, - "y": 2640, + "x": 993, + "y": 2265, "width": 210, "height": 118, "collapsed": true, @@ -576,8 +499,8 @@ "properties": { "instructions": null }, - "x": 650, - "y": 2580, + "x": 653, + "y": 2205, "width": 261, "height": 112, "collapsed": false, @@ -592,8 +515,8 @@ "name": "created_character", "scope": "local" }, - "x": 40, - "y": 2720, + "x": 43, + "y": 2345, "width": 210, "height": 122, "collapsed": false, @@ -605,8 +528,8 @@ "title": "RSwitch", "id": "71e63a09-153c-4fb3-9274-a63129aaf045", "properties": {}, - "x": 450, - "y": 2590, + "x": 453, + "y": 2215, "width": 140, "height": 66, "collapsed": false, @@ -618,8 +541,8 @@ "title": "Backend Status", "id": "2e7d01c6-2699-42ba-8ea5-9f7c9e3421b5", "properties": {}, - "x": 60, - "y": 2580, + "x": 63, + "y": 2205, "width": 170, "height": 66, "collapsed": false, @@ -631,8 +554,8 @@ "title": "AND Router", "id": "d629347c-57f7-4681-9c90-9b8a196e0e74", "properties": {}, - "x": 280, - "y": 2510, + "x": 283, + "y": 2135, "width": 140, "height": 106, "collapsed": false, @@ -640,20 +563,49 @@ "registry": "core/ANDRouter", "base_type": "core/Node" }, - "a9f17ddc-fc8f-4257-8e32-45ac111fd50d": { - "title": "Determine Character Dialogue Instructions", - "id": "a9f17ddc-fc8f-4257-8e32-45ac111fd50d", + "1aa94d91-7040-4fcd-9ca6-8a161e98a3fd": { + "title": "Director Message", + "id": "1aa94d91-7040-4fcd-9ca6-8a161e98a3fd", "properties": { - "instructions": null, - "update_existing": false + "source": "ai", + "action": "actor_instruction", + "subtype": "" }, - "x": 350, - "y": 1960, - "width": 344, - "height": 142, + "x": 840, + "y": 900, + "width": 210, + "height": 206, "collapsed": false, "inherited": false, - "registry": "agents/creator/DetermineCharacterDialogueInstructions", + "registry": "scene/message/DirectorMessage", + "base_type": "core/Node" + }, + "73929369-2611-45a6-aeeb-7c8c88e2636b": { + "title": "Null", + "id": "73929369-2611-45a6-aeeb-7c8c88e2636b", + "properties": {}, + "x": 620, + "y": 1120, + "width": 140, + "height": 26, + "collapsed": false, + "inherited": false, + "registry": "core/Null", + "base_type": "core/Node" + }, + "8a050403-5c69-46f7-abe2-f65db4553942": { + "title": "TRUE", + "id": "8a050403-5c69-46f7-abe2-f65db4553942", + "properties": { + "value": true + }, + "x": 420, + "y": 720, + "width": 210, + "height": 58, + "collapsed": true, + "inherited": false, + "registry": "core/MakeBool", "base_type": "core/Node" } }, @@ -709,10 +661,6 @@ "76c8118f-bc77-4e4c-8ea3-ca04302edcff.state": [ "4bd273e6-8898-42b0-b381-660e2be2dfda.state" ], - "1aa94d91-7040-4fcd-9ca6-8a161e98a3fd.message": [ - "132e5d44-542b-455c-8e7d-23defde39b80.message", - "365a4706-9af5-40c5-a5da-cd0604ce9363.object" - ], "365a4706-9af5-40c5-a5da-cd0604ce9363.value": [ "41c0c2cd-d39b-4528-a5fe-459094393ba3.item" ], @@ -722,20 +670,12 @@ "72943c1c-d2a1-40b4-bb28-8bcc5f02aa5c.character_name" ], "5102b824-63dc-429a-9e91-c1fb57246741.value": [ - "1aa94d91-7040-4fcd-9ca6-8a161e98a3fd.action", - "72943c1c-d2a1-40b4-bb28-8bcc5f02aa5c.instructions" + "72943c1c-d2a1-40b4-bb28-8bcc5f02aa5c.instructions", + "1aa94d91-7040-4fcd-9ca6-8a161e98a3fd.action" ], "fd4cd318-121b-47de-84a0-b1ab62c5601b.value": [ "41c0c2cd-d39b-4528-a5fe-459094393ba3.list" ], - "a2457116-35cb-4571-ba1a-cbf63851544e.value": [ - "a9f17ddc-fc8f-4257-8e32-45ac111fd50d.state", - "a9f17ddc-fc8f-4257-8e32-45ac111fd50d.character", - "ab17bcda-58a1-4b82-bb98-460b14ede0c8.object" - ], - "ab17bcda-58a1-4b82-bb98-460b14ede0c8.value": [ - "161d2a52-575a-4edb-8a92-f7e9bf08c368.state" - ], "5041f12d-507f-4f5d-a26f-048625974602.value": [ "b50e23d4-b456-4385-b80e-c2b6884c7855.template" ], @@ -745,12 +685,6 @@ "72943c1c-d2a1-40b4-bb28-8bcc5f02aa5c.character": [ "970f12d0-330e-41b3-b025-9a53bcf2fc6f.value" ], - "8a050403-5c69-46f7-abe2-f65db4553942.value": [ - "72943c1c-d2a1-40b4-bb28-8bcc5f02aa5c.generate", - "72943c1c-d2a1-40b4-bb28-8bcc5f02aa5c.generate_attributes", - "72943c1c-d2a1-40b4-bb28-8bcc5f02aa5c.is_active", - "72943c1c-d2a1-40b4-bb28-8bcc5f02aa5c.assign_voice" - ], "a748570f-e8ce-4281-84db-abe75decc5ad.agent_enabled": [ "d629347c-57f7-4681-9c90-9b8a196e0e74.a" ], @@ -770,8 +704,20 @@ "d629347c-57f7-4681-9c90-9b8a196e0e74.yes": [ "71e63a09-153c-4fb3-9274-a63129aaf045.check" ], - "a9f17ddc-fc8f-4257-8e32-45ac111fd50d.dialogue_instructions": [ - "ab17bcda-58a1-4b82-bb98-460b14ede0c8.value" + "1aa94d91-7040-4fcd-9ca6-8a161e98a3fd.message": [ + "132e5d44-542b-455c-8e7d-23defde39b80.message", + "365a4706-9af5-40c5-a5da-cd0604ce9363.object" + ], + "73929369-2611-45a6-aeeb-7c8c88e2636b.value": [ + "1aa94d91-7040-4fcd-9ca6-8a161e98a3fd.subtype" + ], + "8a050403-5c69-46f7-abe2-f65db4553942.value": [ + "72943c1c-d2a1-40b4-bb28-8bcc5f02aa5c.generate", + "72943c1c-d2a1-40b4-bb28-8bcc5f02aa5c.generate_attributes", + "72943c1c-d2a1-40b4-bb28-8bcc5f02aa5c.is_active", + "72943c1c-d2a1-40b4-bb28-8bcc5f02aa5c.assign_voice", + "72943c1c-d2a1-40b4-bb28-8bcc5f02aa5c.generate_dialogue_instructions", + "72943c1c-d2a1-40b4-bb28-8bcc5f02aa5c.generate_example_dialogue" ] }, "groups": [ @@ -798,7 +744,7 @@ { "title": "Stage 5 - Emit status", "x": 4, - "y": 2129, + "y": 1752, "width": 541, "height": 267, "color": "#3f789e", @@ -807,8 +753,8 @@ }, { "title": "Stage 6 - Visualize", - "x": 5, - "y": 2401, + "x": 8, + "y": 2026, "width": 1110, "height": 500, "color": "#a1309b", @@ -824,16 +770,6 @@ "color": "#3f789e", "font_size": 24, "inherited": false - }, - { - "title": "Stage 4 - Generate acting instructions", - "x": 3, - "y": 1751, - "width": 1218, - "height": 376, - "color": "#3f789e", - "font_size": 24, - "inherited": false } ], "comments": [], @@ -842,14 +778,60 @@ "inputs": [], "outputs": [ { - "id": "17baefca-1fa5-4a58-a0d3-6a5a50795395", + "id": "030f2db6-b362-479e-ac6a-56198035a214", "name": "fn", "optional": false, "group": null, "socket_type": "function" + }, + { + "id": "fa600cb9-24c4-4be7-9c56-20dc0067eef3", + "name": "name", + "optional": false, + "group": null, + "socket_type": "str" + }, + { + "id": "c85fb2eb-b371-461a-a96a-fb0489cced85", + "name": "allow_multiple_calls", + "optional": false, + "group": null, + "socket_type": "bool" + }, + { + "id": "72a60805-ce60-4e1b-8905-a2aafee8c2d0", + "name": "ai_callback", + "optional": false, + "group": null, + "socket_type": "focal/callback" } ], - "module_properties": {}, + "module_properties": { + "name": { + "name": "name", + "description": "The name of the function", + "type": "str", + "default": "", + "choices": null, + "readonly": false, + "step": null, + "min": null, + "max": null, + "ephemeral": false + }, + "allow_multiple_calls": { + "name": "allow_multiple_calls", + "description": "Function can be called multiple times during AI Function Calling", + "type": "bool", + "default": false, + "choices": null, + "readonly": false, + "step": null, + "min": null, + "max": null, + "ephemeral": false + } + }, "style": { "title_color": "#573a2e", "node_color": "#392f2c", diff --git a/src/talemate/agents/__init__.py b/src/talemate/agents/__init__.py index bdedd6fe..222e856c 100644 --- a/src/talemate/agents/__init__.py +++ b/src/talemate/agents/__init__.py @@ -3,6 +3,7 @@ from .conversation import ConversationAgent # noqa: F401 from .creator import CreatorAgent # noqa: F401 from .director import DirectorAgent # noqa: F401 from .editor import EditorAgent # noqa: F401 +from .help import HelpAgent # noqa: F401 from .memory import ChromaDBMemoryAgent, MemoryAgent # noqa: F401 from .narrator import NarratorAgent # noqa: F401 from .registry import AGENT_CLASSES, get_agent_class, register # noqa: F401 diff --git a/src/talemate/agents/base.py b/src/talemate/agents/base.py index c0a1f7da..b50d8dbf 100644 --- a/src/talemate/agents/base.py +++ b/src/talemate/agents/base.py @@ -24,7 +24,8 @@ from talemate.agents.context import ActiveAgent, active_agent from talemate.emit import emit from talemate.events import GameLoopStartEvent from talemate.context import active_scene -from talemate.ux.schema import Action, Column, Note +from talemate.ux.schema import Action, Condition, Note +from talemate.ux.schema import Field as UxField from talemate.config import get_config, Config import talemate.config.schema as config_schema from talemate.client.context import ( @@ -53,53 +54,21 @@ __all__ = [ log = structlog.get_logger("talemate.agents.base") -class AgentActionConditional(pydantic.BaseModel): - attribute: str - value: int | float | str | bool | list[int | float | str | bool] | None = None +# Backwards-compat aliases — the shared UX schema now owns these shapes. +AgentActionConditional = Condition +AgentActionNote = Note -class AgentActionNote(Note): - pass +class AgentActionConfig(UxField): + """ + Agent setting field — extends the uniform UX field definition + (talemate.ux.schema.Field) with agent-specific behavior. + """ - -class AgentActionConfig(pydantic.BaseModel): - type: Literal[ - "autocomplete", - "blob", - "bool", - "flags", - "number", - "text", - "vector2", - "weights", - "wstemplate", - "password", - "unified_api_key", - ] - label: str - description: str = "" - value: int | float | str | bool | list | dict | None = None - default_value: int | float | str | bool | None = None - max: int | float | None = None - min: int | float | None = None - step: int | float | None = None - graduations: list[dict[str, int | float]] | None = None - scope: str = "global" - choices: ( - list[dict[str, str | int | float | bool | list[int | float | bool]]] | None - ) = None - note: AgentActionNote | None = None - expensive: bool = False + scope: Literal["global", "scene"] = "global" quick_toggle: bool = False - condition: AgentActionConditional | None = None title: str | None = None value_migration: Callable | None = pydantic.Field(default=None, exclude=True) - columns: list[Column] | None = None - - note_on_value: dict[str | int | float | bool, AgentActionNote] = pydantic.Field( - default_factory=dict - ) - save_on_change: bool = False scene_overridable: bool = True wstemplate_type: ( @@ -110,6 +79,7 @@ class AgentActionConfig(pydantic.BaseModel): "spices", "writing_style", "visual_style", + "visual_finalizer", "agent_persona", "scene_type", ] @@ -117,25 +87,6 @@ class AgentActionConfig(pydantic.BaseModel): ) = None wstemplate_filter: dict[str, str] | None = None - @pydantic.field_validator("note", mode="before") - @classmethod - def validate_note(cls, v): - if isinstance(v, str): - return AgentActionNote(text=v) - return v - - @pydantic.model_validator(mode="after") - def ensure_note_is_object(self): - if isinstance(self.note, str): - self.note = AgentActionNote(text=self.note) - return self - - @pydantic.field_serializer("note") - def serialize_note(self, v): - if isinstance(v, str): - return AgentActionNote(text=v) - return v - model_config = ConfigDict(arbitrary_types_allowed=True) @@ -164,7 +115,25 @@ class AgentAction(pydantic.BaseModel): # Only meaningful on actions that are themselves dynamic registries. dynamic_registry_component: str | None = None - enabled_scene_overridable: bool = False + # None means "follow can_be_disabled" — every action whose enable flag can be + # toggled at all is overridable per scene unless it opts out explicitly. + enabled_scene_overridable: bool | None = None + + @pydantic.model_validator(mode="after") + def _disabled_requires_can_be_disabled(self): + # An action nothing can turn on that ships turned off is unreachable: + # the global UI renders no Enable checkbox without can_be_disabled, + # and `resolve_enabled` reports it on regardless. Rejecting the + # combination at construction keeps the declaration and the resolver + # from disagreeing — including for dynamically synthesized children, + # which the shipped-action sweep in the tests cannot see. + if not self.enabled and not self.can_be_disabled: + raise ValueError( + f"AgentAction {self.label!r}: enabled=False requires " + "can_be_disabled=True — an action that cannot be disabled " + "always resolves as enabled" + ) + return self @pydantic.model_validator(mode="after") def _enabled_scene_overridable_requires_can_be_disabled(self): @@ -172,6 +141,10 @@ class AgentAction(pydantic.BaseModel): # flag is itself togglable. Without can_be_disabled the global UI # never exposes an Enable checkbox, so a scene-level override has # nothing to override. + if self.enabled_scene_overridable is None: + self.enabled_scene_overridable = self.can_be_disabled + return self + if self.enabled_scene_overridable and not self.can_be_disabled: raise ValueError( f"AgentAction {self.label!r}: enabled_scene_overridable=True " @@ -219,12 +192,20 @@ def optimize_prompt_caching_action() -> AgentAction: ) +AgentDetailColor = Literal[ + "grey", "primary", "muted", "orange", "success", "warning", "error" +] + + class AgentDetail(pydantic.BaseModel): value: str | None = None description: str | None = None icon: str | None = None - color: str = "grey" + color: AgentDetailColor = "grey" hidden: bool = False + # surface this detail even when the subsystem emitting it is not in use + # (e.g. a TTS api no voice currently points at) + surface_when_unused: bool = False class DynamicInstruction(pydantic.BaseModel): @@ -913,10 +894,18 @@ class Agent(ABC): def resolve_enabled(self, action_key: str) -> bool: """Return the effective enabled flag for a container action.""" + action = self.actions[action_key] + + # The declaration wins: an action without can_be_disabled has no + # Enable control anywhere in the UI, so an override that turns it off + # (hand-edited or written by an older version) could never be undone. + if not action.can_be_disabled: + return True + return bool( self._resolve( lambda o: o.get_enabled(self.agent_type, action_key), - lambda: self.actions[action_key].enabled, + lambda: action.enabled, ) ) @@ -948,6 +937,18 @@ class Agent(ABC): Note: this updates an *existing* override; it does not create a new one. """ + # Same rule as resolve_enabled / apply_config: the declaration wins. + # Writing the flag here would otherwise persist a value the resolver + # ignores, leaving the stored config disagreeing with what runs. + if not self.actions[action_key].can_be_disabled: + log.warning( + "write_enabled refused: action cannot be disabled", + agent=self.agent_type, + action=action_key, + requested=enabled, + ) + return + self._route_write( lambda o: o.get_enabled(self.agent_type, action_key) is not UNSET, lambda o: o.set_enabled(self.agent_type, action_key, enabled), @@ -984,9 +985,20 @@ class Agent(ABC): if not kwargs.get("actions"): continue - action.enabled = ( - kwargs.get("actions", {}).get(action_key, {}).get("enabled", False) - ) + if not action.can_be_disabled: + # See resolve_enabled: nothing in the UI can turn these back + # on, so a saved `enabled: false` (written by an older version + # or hand-edited) is discarded rather than honored. + action.enabled = True + else: + # Falling back to the action's current value rather than False + # keeps an action the saved config predates in the state it + # ships with, instead of silently disabling it on first load. + action.enabled = ( + kwargs.get("actions", {}) + .get(action_key, {}) + .get("enabled", action.enabled) + ) if not action.config: continue @@ -1025,7 +1037,7 @@ class Agent(ABC): config_key: config_schema.AgentActionConfig( value=config_obj.value ) - for config_key, config_obj in action.config.items() + for config_key, config_obj in (action.config or {}).items() if config_obj.type != "unified_api_key" }, ) diff --git a/src/talemate/agents/chat_title.py b/src/talemate/agents/chat_title.py new file mode 100644 index 00000000..bb5a9fe0 --- /dev/null +++ b/src/talemate/agents/chat_title.py @@ -0,0 +1,51 @@ +""" +Shared chat title generation used by the director and help chats. +""" + +from typing import TYPE_CHECKING + +from talemate.prompts import Prompt +from talemate.prompts.response import AnchorExtractor, ResponseSpec + +if TYPE_CHECKING: + from talemate.client.base import ClientBase + +__all__ = [ + "CHAT_TITLE_SPEC", + "generate_chat_title", +] + +CHAT_TITLE_SPEC = ResponseSpec( + extractors={ + "title": AnchorExtractor(left="", right=""), + }, + required=[], +) + +MAX_TITLE_LENGTH = 60 + + +async def generate_chat_title( + client: "ClientBase", chat_excerpt: str, participant: str +) -> str | None: + """ + Generate a short title for a chat, based on an excerpt of its messages. + `participant` describes the non-user side of the conversation for the + prompt (e.g. "an AI director"). + """ + response, extracted = await Prompt.request( + "common.chat-title", + client, + "create_92", + vars={"chat_excerpt": chat_excerpt, "participant": participant}, + response_spec=CHAT_TITLE_SPEC, + ) + + title = extracted.get("title") or (response.strip() if response else None) + if not title: + return None + + title = title.strip().strip("\"'") + if len(title) > MAX_TITLE_LENGTH: + title = title[: MAX_TITLE_LENGTH - 3] + "..." + return title diff --git a/src/talemate/agents/conversation/nodes.py b/src/talemate/agents/conversation/nodes.py index 4596c51c..694f5351 100644 --- a/src/talemate/agents/conversation/nodes.py +++ b/src/talemate/agents/conversation/nodes.py @@ -27,7 +27,30 @@ class ConversationSettings(AgentSettingsNode): @register("agents/conversation/Generate") class GenerateConversation(AgentNode): """ - Generate a conversation between two characters + Generates dialogue for a character via the conversation agent. + + Calls the conversation agent's converse action for the given character + and returns the first generated message. The message is not added to + the scene history by this node. + + Inputs: + + - state: The graph state + - character: The character to generate dialogue for + - instruction: Optional instruction to guide the generation + + Properties: + + - trigger_conversation_generated: Whether to emit the conversation + generation signals (allowing e.g. editor cleanup hooks to run) + + Outputs: + + - state: The state input, passed through + - generated: The generated dialogue text + - message: The generated CharacterMessage object + - character: The character input, passed through + - instruction: The instruction input, passed through """ _agent_name: ClassVar[str] = "conversation" diff --git a/src/talemate/agents/creator/__init__.py b/src/talemate/agents/creator/__init__.py index 8669ac16..677f6552 100644 --- a/src/talemate/agents/creator/__init__.py +++ b/src/talemate/agents/creator/__init__.py @@ -36,6 +36,7 @@ class CreatorAgent( actions = {"prompt_caching": optimize_prompt_caching_action()} MemoryRAGMixin.add_actions(actions) AssistantMixin.add_actions(actions) + CharacterCreatorMixin.add_actions(actions) return actions def __init__( diff --git a/src/talemate/agents/creator/assistant.py b/src/talemate/agents/creator/assistant.py index 5e52cb63..2f346a29 100644 --- a/src/talemate/agents/creator/assistant.py +++ b/src/talemate/agents/creator/assistant.py @@ -1,6 +1,6 @@ import json import re -import random +from datetime import datetime, timezone from typing import TYPE_CHECKING, Tuple import traceback import uuid @@ -13,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 ( @@ -21,7 +22,7 @@ from talemate.world_state.templates import ( Spices, WritingStyle, ) -from talemate.changelog import write_reconstructed_scene +from talemate.changelog import fork_scene_at_revision from talemate.save import SceneEncoder import os from talemate.agents.base import ( @@ -116,26 +117,15 @@ class ContentGenerationContext(pydantic.BaseModel): @property def spice(self) -> str: - spice_level = self.generation_options.spice_level - if self.template and not getattr(self.template, "supports_spice", False): # template supplied that doesn't support spice return "" - if spice_level == 0: - # no spice - return "" + spice = self.generation_options.render_spice(self.scene, self.character) - if not self.generation_options.spices: - # no spices + if not spice: return "" - # randomly determine if we should add spice (0.0 - 1.0) - if random.random() > spice_level: - return "" - - spice = self.generation_options.spices.render(self.scene, self.character) - log.debug( "spice_applied", spice=spice, @@ -163,11 +153,7 @@ class ContentGenerationContext(pydantic.BaseModel): # template supplied that doesn't support style return "" - if not self.generation_options.writing_style: - # no writing style - return "" - - return self.generation_options.writing_style.render(self.scene, self.character) + return self.generation_options.render_writing_style(self.scene, self.character) def set_state(self, key: str, value: str | int | float | bool): self.state[key] = value @@ -824,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) @@ -848,14 +866,8 @@ class AssistantMixin: emit("status", "Creating reconstructive fork...", status="busy") # Create fork file with reconstructed scene data (shared_context will be disconnected) - fork_file_path = await write_reconstructed_scene( - self.scene, - message.rev, - f"{save_name}.json", - overrides={ - "immutable_save": False, - "memory_id": str(uuid.uuid4())[:10], - }, + fork_file_path = await fork_scene_at_revision( + self.scene, message.rev, save_name ) log.info( @@ -911,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/creator/character.py b/src/talemate/agents/creator/character.py index 1a420bf7..4ed3e88f 100644 --- a/src/talemate/agents/creator/character.py +++ b/src/talemate/agents/creator/character.py @@ -1,24 +1,394 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import Callable, Literal, get_args +import pydantic import structlog +from typing_extensions import TypedDict -from talemate.agents.base import set_processing +import talemate.emit.async_signals as async_signals + +# circular-import-safe at runtime only (talemate.instance imports +# talemate.agents); same pattern as agents/director/character_management.py +import talemate.instance as instance +from talemate.agents.base import ( + AgentAction, + AgentActionConfig, + AgentEmission, + DynamicInstruction, + set_processing, +) +from talemate.character import Character +from talemate.exceptions import LLMAccuracyError from talemate.game import focal from talemate.prompts import Prompt +from talemate.prompts.response import ResponseSpec, StrictAnchorExtractor +from talemate.util.data import parse_attribute_lines +from talemate.util.strings import replace_smart_quotes +from talemate.ux.schema import Condition +from talemate.world_state.templates.content import GenerationOptions from .response_specs import NAME_SPEC -if TYPE_CHECKING: - from talemate.tale_mate import Character - log = structlog.get_logger("talemate.agents.creator.character") DEFAULT_CONTENT_CONTEXT = "a fun and engaging adventure aimed at an adult audience." +CharacterGenerationAspect = Literal[ + "name", + "description", + "attributes", + "dialogue_instructions", + "example_dialogue", +] + +# Aspects that can be generated for a character, in canonical generation +# order (the Literal definition order above). The consolidated ("Fast") +# one-shot can cover any subset of these. +CHARACTER_GENERATION_ASPECTS: list[CharacterGenerationAspect] = list( + get_args(CharacterGenerationAspect) +) + +# extra (non-aspect) entry of the Consolidate flags: fold selected attribute +# world-state templates into the one-shot instead of one prompt per template +CONSOLIDATE_TEMPLATES_FLAG = "attribute_templates" + +# the Consolidate setting's value shape: aspects plus the fold flag +ConsolidateFlag = CharacterGenerationAspect | Literal["attribute_templates"] + +# tokens reserved on top of the response budget when sizing the scene +# history for the consolidated one-shot (covers the fixed prompt sections) +ONE_SHOT_CONTEXT_OVERHEAD_TOKENS = 256 + +# lower bound for the scene history budget - keeps tiny-context clients +# from ending up with a negative reservation +ONE_SHOT_MIN_HISTORY_TOKENS = 512 + +# aspect -> fill method name for generate_character_aspects' individual +# fills (totality pinned by test) +_ASPECT_FILL_METHODS: dict[CharacterGenerationAspect, str] = { + "name": "_fill_aspect_name", + "description": "_fill_aspect_description", + "attributes": "_fill_aspect_attributes", + "dialogue_instructions": "_fill_aspect_dialogue_instructions", + "example_dialogue": "_fill_aspect_example_dialogue", +} + + +def one_shot_history_budget(max_tokens: int, response_budget: int) -> int: + """Scene history budget for the consolidated one-shot: the client's + context minus the response budget and overhead, floored so + small-context clients don't get a negative reservation.""" + return max( + ONE_SHOT_MIN_HISTORY_TOKENS, + max_tokens - response_budget - ONE_SHOT_CONTEXT_OVERHEAD_TOKENS, + ) + + +def one_shot_effective_response_budget(configured_budget: int, max_tokens: int) -> int: + """Response budget for the consolidated one-shot, clamped so that + history + overhead + response fit the client's context (guaranteed for + contexts >= 1792 = 512 + 256 + 1024; below that the response/history + floors take precedence - no practical Fast-mode client is that small).""" + return min( + configured_budget, + max( + 1024, + max_tokens - ONE_SHOT_MIN_HISTORY_TOKENS - ONE_SHOT_CONTEXT_OVERHEAD_TOKENS, + ), + ) + + +class AttributeInstructionSpec(TypedDict): + """Per-attribute generation instructions, folded into the attributes + section of consolidated generation (from attribute world-state + templates).""" + + attribute: str + instructions: str + + +def format_attribute_instructions(specs: list[AttributeInstructionSpec]) -> str: + """Render an attribute instruction spec list for prompt inclusion - + single formatting shared by the one-shot template var and the + fill-misses fallback.""" + return "\n".join( + f"- {spec['attribute']}: {spec['instructions']}" + if spec["instructions"] + else f"- {spec['attribute']}" + for spec in specs + ) + + +async_signals.register( + "agent.creator.dialogue_examples.before", + "agent.creator.dialogue_examples.after", +) + + +class DialogueExamplesEmission(AgentEmission): + character: Character + text: str = "" + instructions: str = "" + dynamic_instructions: list[DynamicInstruction] = pydantic.Field( + default_factory=list + ) + dialogue_examples: list[str] = pydantic.Field(default_factory=list) + + +class CharacterGenerationResult(pydantic.BaseModel): + """ + Result of (consolidated or hybrid) character generation. Each field + corresponds to one character generation aspect and is None when that + aspect was not generated (or was missed and not filled in). + """ + + name: str | None = None + description: str | None = None + attributes: dict[str, str] | None = None + dialogue_instructions: str | None = None + example_dialogue: list[str] | None = None + + def extracted_aspects(self) -> set[str]: + """Aspects that hold a non-empty value.""" + found = set() + if self.name: + found.add("name") + if self.description: + found.add("description") + if self.attributes: + found.add("attributes") + if self.dialogue_instructions: + found.add("dialogue_instructions") + if self.example_dialogue: + found.add("example_dialogue") + return found + + +class CharacterGenerationRequest(pydantic.BaseModel): + """Request parameters for generate_character_aspects. + + `unified` / `consolidate` / `fill_misses` default to the creator agent's + "Character Creation" settings when not given. + + Attributes: + aspects: Requested aspects (subset of CHARACTER_GENERATION_ASPECTS) + name: Current or descriptive character name + content: Guiding content / instructions for the creation + description: Existing description (if any) + character: Existing character for context (e.g. card import). + Used read-only - a copy is made for internal bookkeeping. + unified: Override for the Fast mode setting + consolidate: Override for the consolidated aspects setting (aspect + names; may include the 'attribute_templates' fold flag, like the + raw setting value) + fill_misses: Override for the fill-in-misses setting + instructions: Extra instructions for dialogue instructions + example_dialogue_instructions: Guidance for example dialogue + max_examples: Maximum number of example dialogue lines + max_attributes: Maximum number of attributes + dynamic_instructions: Extra dynamic instructions for the prompts + content_role: How `content` is passed to individual description + generation (split mode or one-shot fallback): "information" + layers it on top of the scene context, "text" replaces the + scene context with it + generation_options: Caller-selected spice / writing style, applied to + the description (the aspect whose prose they shape) and, when the + consolidated prompt is used, to the one-shot that produces it + attribute_instructions: Per-attribute generation instructions + (from attribute world-state templates) to fold into the + attributes section of the one-shot (and of its individual + fallback) + augment_attributes: Augmentation instruction to fold into the + attributes section of the one-shot (add attributes beyond the + instructed ones) + on_aspect_start: Optional callback invoked with each aspect right + before its individual fill runs (progress reporting) + """ + + model_config = pydantic.ConfigDict(extra="forbid") + + aspects: list[CharacterGenerationAspect] + name: str = "" + content: str = "" + description: str = "" + character: Character | None = None + unified: bool | None = None + consolidate: list[ConsolidateFlag] | None = None + fill_misses: bool | None = None + instructions: str = "" + example_dialogue_instructions: str = "" + max_examples: int = 5 + max_attributes: int | None = None + dynamic_instructions: list | None = None + generation_options: GenerationOptions | None = None + content_role: Literal["information", "text"] = "information" + attribute_instructions: list[AttributeInstructionSpec] | None = None + augment_attributes: str = "" + on_aspect_start: Callable[[CharacterGenerationAspect], None] | None = None + + @property + def effective_name(self) -> str: + """The request's name, falling back to the context character's.""" + return self.name or (self.character.name if self.character else "") + + +def _is_other_speaker(speaker: str) -> bool: + """Whether a `Word Word:` speaker prefix looks like another character's + name (2-3 capitalized words, digits allowed for the likes of "Agent 47"). + + Single-word prefixes ("Warning:", "Narrator:") deliberately don't + qualify: they are the dominant false-positive class (plausible dialogue + content) and a relabeled line is the lesser evil there. + """ + words = speaker.strip().split() + return ( + 2 <= len(words) <= 3 + and len(speaker) <= 40 + and all(word[:1].isupper() or word[:1].isdigit() for word in words) + ) + + +def _normalize_example_dialogue_lines( + lines: list[str], + example_name: str, + other_known_names: list[str] | None = None, + max_examples: int = 5, +) -> list[str]: + """Normalize example dialogue lines to `example_name: ` entries. + + - lines whose speaker prefix is a known name (the final name, or a raw / + descriptive name the model may have echoed) are re-prefixed with the + final name + - bare `Name:` / name-only lines (no dialogue content) are dropped + - lines with an unrecognized name-shaped speaker prefix (another + character's line) are dropped + - anything else is prefixed with the final name + - typographic quotes are replaced with their ascii equivalents + """ + known_prefixes = { + p.lower() for p in [example_name, *(other_known_names or [])] if p and p.strip() + } + normalized = [] + for line in lines: + line = replace_smart_quotes(line).strip() + if not line: + continue + speaker, colon, rest = line.partition(":") + if colon and not rest.strip(): + continue + if not colon and line.lower() in known_prefixes: + continue + if speaker.strip().lower() in known_prefixes: + normalized.append(f"{example_name}: {rest.strip()}") + elif colon and _is_other_speaker(speaker): + # unrecognized name-shaped speaker prefix - another character's + # line, not an example for this character + log.debug("normalize_example_dialogue_lines: dropping line", line=line) + continue + else: + normalized.append(f"{example_name}: {line}") + return normalized[:max_examples] + class CharacterCreatorMixin: + @classmethod + def add_actions(cls, actions: dict[str, AgentAction]): + actions["character_creation"] = AgentAction( + enabled=True, + container=True, + can_be_disabled=False, + label="Character Creation", + icon="mdi-account-plus", + description="Configure how AI-assisted character creation generates characters.", + config={ + "fast": AgentActionConfig( + type="bool", + label="Fast Character Generation", + description=( + "Consolidate character generation into a single prompt " + "instead of one prompt per aspect. This is much faster " + "(one request instead of several), but note that less " + "detail in the individual aspects is a possible failure " + "mode of this approach, and the model needs a large " + "context window and reliable structured output. Keep " + "this off for older or smaller models." + ), + value=False, + title="Generation Mode", + quick_toggle=True, + ), + "consolidate": AgentActionConfig( + type="flags", + label="Consolidate", + description=( + "Aspects generated by the single consolidated prompt " + "when Fast Character Generation is enabled. Aspects not " + "selected are still " + "generated with their individual prompts. 'Attribute " + "templates' additionally folds selected attribute " + "world-state templates into the consolidated prompt " + "instead of one prompt per template - their " + "instructions are formatted with the character's name " + "(or 'the character' when it is still being " + "determined). Requires Attributes to be selected as " + "well." + ), + value=list(CHARACTER_GENERATION_ASPECTS) + + [CONSOLIDATE_TEMPLATES_FLAG], + choices=[ + { + "label": aspect.replace("_", " ").capitalize(), + "value": aspect, + } + for aspect in CHARACTER_GENERATION_ASPECTS + ] + + [ + { + "label": "Attribute templates", + "value": CONSOLIDATE_TEMPLATES_FLAG, + } + ], + condition=Condition( + attribute="character_creation.config.fast", value=True + ), + ), + "one_shot_token_budget": AgentActionConfig( + type="number", + label="One-shot token budget", + description=( + "Maximum response tokens for the consolidated prompt. " + "All consolidated aspects share this budget - if the " + "response is truncated, later sections come out missing " + "(and are regenerated individually when Fill in misses " + "is on)." + ), + value=4096, + min=1024, + max=8192, + step=1024, + condition=Condition( + attribute="character_creation.config.fast", value=True + ), + ), + "fill_misses": AgentActionConfig( + type="bool", + label="Fill in misses", + description=( + "When the consolidated response misses an aspect " + "entirely, run that aspect's individual request to fill " + "it in. When disabled, missed aspects are left empty. " + "A completely unparseable response is always a hard " + "error." + ), + value=True, + condition=Condition( + attribute="character_creation.config.fast", value=True + ), + ), + }, + ) + @set_processing async def determine_content_context_for_character( self, @@ -114,7 +484,10 @@ class CharacterCreatorMixin: instructions: str = "", information: str = "", dynamic_instructions: list = None, - ): + generation_options: GenerationOptions | None = None, + ) -> str: + """The generated description, or an empty string when the model + produced nothing.""" vars_dict = { "character": character, "scene": self.scene, @@ -124,6 +497,10 @@ class CharacterCreatorMixin: "information": information, } + vars_dict.update( + self._generation_option_vars(generation_options, character.name) + ) + if dynamic_instructions: vars_dict["dynamic_instructions"] = dynamic_instructions @@ -133,7 +510,16 @@ class CharacterCreatorMixin: "create", vars=vars_dict, ) - return extracted["response"].strip() + + description = extracted["response"].strip() + + # the template primes the response with the character's name, so a + # generation that produced nothing comes back as the bare name - + # report it as empty rather than as a one-word description + if description == character.name.strip(): + return "" + + return description @set_processing async def determine_character_goals( @@ -166,6 +552,7 @@ class CharacterCreatorMixin: self, character: Character, text: str = "", + instructions: str = "", dynamic_instructions: list = None, max_examples: int = 5, ) -> list[str]: @@ -174,6 +561,7 @@ class CharacterCreatorMixin: Args: character: The character to extract dialogue examples for text: Text containing dialogue examples and relevant character information + instructions: Optional user-provided guidance for the dialogue examples dynamic_instructions: Optional dynamic instructions for context max_examples: Maximum number of dialogue examples to generate (default: 5) @@ -182,6 +570,16 @@ class CharacterCreatorMixin: """ dialogue_examples = [] + emission = DialogueExamplesEmission( + agent=self, + character=character, + text=text, + instructions=instructions, + dynamic_instructions=list(dynamic_instructions or []), + ) + await async_signals.get("agent.creator.dialogue_examples.before").send(emission) + dynamic_instructions = emission.dynamic_instructions + async def add_dialogue_example(example: str) -> str: """Add a dialogue example for the character.""" # Ensure example starts with character name if not already present @@ -211,6 +609,7 @@ class CharacterCreatorMixin: character=character, scene=self.scene, text=text, + instructions=instructions, max_examples=max_examples, existing_examples=character.example_dialogue[:3] if character.example_dialogue @@ -225,11 +624,460 @@ class CharacterCreatorMixin: "creator.determine-character-dialogue-examples", ) + emission.dialogue_examples = dialogue_examples + await async_signals.get("agent.creator.dialogue_examples.after").send(emission) + log.debug( "determine_character_dialogue_examples", character=character.name, - count=len(dialogue_examples), - examples=dialogue_examples, + count=len(emission.dialogue_examples), + examples=emission.dialogue_examples, ) - return dialogue_examples + return emission.dialogue_examples + + # character creation config helpers + + @property + def cc_fast(self) -> bool: + """Fast mode: consolidate character generation into a single prompt.""" + return bool(self.resolve_config("character_creation", "fast")) + + @property + def cc_consolidate(self) -> list[ConsolidateFlag]: + """Aspects the consolidated one-shot covers when Fast mode is on.""" + return list(self.resolve_config("character_creation", "consolidate") or []) + + @property + def cc_fill_misses(self) -> bool: + """Whether to fill aspects the consolidated response missed with their + individual requests.""" + return bool(self.resolve_config("character_creation", "fill_misses")) + + @property + def cc_one_shot_token_budget(self) -> int: + """Response token budget for the consolidated one-shot prompt.""" + return max( + 1024, + min( + 8192, + int( + self.resolve_config("character_creation", "one_shot_token_budget") + or 4096 + ), + ), + ) + + @property + def cc_consolidate_templates(self) -> bool: + """Whether attribute world-state templates are folded into the + consolidated one-shot (as instructions) instead of one prompt per + template.""" + return CONSOLIDATE_TEMPLATES_FLAG in self.cc_consolidate + + def _generation_option_vars( + self, + generation_options: GenerationOptions | None, + character_name: str | None = None, + ) -> dict: + """Rendered spice / writing style prompt vars for the caller's + generation options. + + Mirrors contextual generation: an option-supplied writing style wins + over the scene's, and the scene's applies when the caller did not pick + one. + """ + options = generation_options or GenerationOptions() + if not options.writing_style: + options = options.model_copy( + update={"writing_style": self.scene.writing_style} + ) + + return { + "spice": options.render_spice(self.scene, character_name), + "writing_style": options.render_writing_style(self.scene, character_name), + } + + # consolidated character generation + + @set_processing + async def generate_character_unified( + self, + request: CharacterGenerationRequest, + ) -> CharacterGenerationResult: + """ + Generate multiple character aspects with a single consolidated prompt + ("Fast" mode). + + Args: + request: The generation request (see CharacterGenerationRequest). + Only the fields the consolidated prompt consumes are read. + + Returns: + CharacterGenerationResult with whatever aspects could be extracted. + Callers decide how to handle missed aspects — a result with no + aspects extracted at all means the response was unprocessable. + """ + aspects = request.aspects + response_spec = ResponseSpec( + extractors={ + aspect: StrictAnchorExtractor( + left=f"<{aspect.upper()}>", right=f"" + ) + for aspect in aspects + }, + required=[], + ) + + response_budget = one_shot_effective_response_budget( + self.cc_one_shot_token_budget, self.client.max_token_length + ) + if response_budget < self.cc_one_shot_token_budget: + log.warning( + "generate_character_unified: configured one-shot token budget " + "does not fit the client context - clamping", + configured=self.cc_one_shot_token_budget, + effective=response_budget, + max_token_length=self.client.max_token_length, + ) + + response, extracted = await Prompt.request( + "creator.generate-character", + self.client, + f"create_{response_budget}", + vars={ + "scene": self.scene, + "max_tokens": self.client.max_token_length, + "aspects": aspects, + "character_name": request.effective_name, + "content": request.content, + "description": request.description, + "character": request.character, + "dynamic_instructions": request.dynamic_instructions or [], + "max_examples": request.max_examples, + "max_attributes": request.max_attributes, + "example_dialogue_instructions": request.example_dialogue_instructions, + "attribute_instructions_text": format_attribute_instructions( + request.attribute_instructions + ) + if request.attribute_instructions + else "", + "augment_attributes": request.augment_attributes, + "history_budget": one_shot_history_budget( + self.client.max_token_length, response_budget + ), + **self._generation_option_vars( + request.generation_options, request.effective_name + ), + }, + response_spec=response_spec, + ) + + log.debug( + "generate_character_unified", + aspects=aspects, + extracted={k: bool(v) for k, v in extracted.items()}, + ) + + return self._parse_unified_character_response( + extracted, + aspects, + fallback_name=request.effective_name, + max_examples=request.max_examples, + max_attributes=request.max_attributes, + ) + + def _parse_unified_character_response( + self, + extracted: dict, + aspects: list[CharacterGenerationAspect], + fallback_name: str = "", + max_examples: int = 5, + max_attributes: int | None = None, + ) -> CharacterGenerationResult: + """Normalize the extracted sections into a CharacterGenerationResult. + + Aspects whose section could not be extracted (missing or unclosed + tags - see StrictAnchorExtractor) are left empty for the caller's + fill-misses policy. + """ + result = CharacterGenerationResult() + + if "name" in aspects: + raw_name = (extracted.get("name") or "").strip().strip('"').strip() + if raw_name: + result.name = raw_name.strip(".").strip() + + if "description" in aspects: + raw_description = (extracted.get("description") or "").strip() + if raw_description: + result.description = raw_description + + if "attributes" in aspects: + raw_attributes = (extracted.get("attributes") or "").strip() + if raw_attributes: + attributes = parse_attribute_lines( + raw_attributes, max_attributes=max_attributes + ) + if attributes: + result.attributes = attributes + + if "dialogue_instructions" in aspects: + raw_instructions = ( + (extracted.get("dialogue_instructions") or "") + .strip() + .strip('"') + .strip() + ) + if raw_instructions: + result.dialogue_instructions = raw_instructions + + if "example_dialogue" in aspects: + raw_examples = (extracted.get("example_dialogue") or "").strip() + example_name = result.name or fallback_name + if raw_examples and example_name: + lines = [ + line.strip() for line in raw_examples.split("\n") if line.strip() + ] + normalized = _normalize_example_dialogue_lines( + lines, + example_name, + other_known_names=[fallback_name], + max_examples=max_examples, + ) + if normalized: + result.example_dialogue = normalized + + return result + + @set_processing + async def generate_character_aspects( + self, + request: CharacterGenerationRequest, + ) -> CharacterGenerationResult: + """ + Orchestrates character aspect generation. + + When `unified` (Fast mode) is on, all requested aspects that are part + of `consolidate` are generated with a single consolidated prompt; the + remaining aspects are generated with their individual requests. + Aspects the consolidated response missed entirely are retried with + their individual requests when `fill_misses` is on, otherwise they + are left empty (with a warning). A consolidated response from which + none of the requested aspects could be extracted is a hard error + (LLMAccuracyError) regardless of `fill_misses`. + + Args: + request: The generation request (see CharacterGenerationRequest). + + Returns: + CharacterGenerationResult with all requested aspects filled in + (unless missed with fill_misses disabled). + """ + unified = request.unified if request.unified is not None else self.cc_fast + consolidate = ( + request.consolidate + if request.consolidate is not None + else self.cc_consolidate + ) + fill_misses = ( + request.fill_misses + if request.fill_misses is not None + else self.cc_fill_misses + ) + + one_shot = [a for a in request.aspects if unified and a in consolidate] + + result = CharacterGenerationResult() + + # name the one-shot's example dialogue normalization fell back to + # (relevant when the real name is only determined afterwards) + one_shot_fallback_name = request.effective_name + one_shot_example_name = None + + if one_shot: + result = await self.generate_character_unified( + request.model_copy(update={"aspects": one_shot}) + ) + if not result.extracted_aspects(): + raise LLMAccuracyError( + "creator.generate-character - none of the requested aspects " + f"({', '.join(one_shot)}) could be extracted from the " + "consolidated response. The model did not follow the " + "structured response format - choose a different model or " + "disable Fast Character Generation.", + model_name=self.client.model_name if self.client else "unknown", + ) + # the name the one-shot's example dialogue was actually + # normalized against + one_shot_example_name = result.name or one_shot_fallback_name + + # transient character carrying everything generated so far, so + # individual generations build on the consolidated results (and on + # each other) even before a real character exists in the scene. A + # provided character is detached via model_dump (actor/agent are + # exclude=True and not deep-copyable), so scene-bound characters are + # safe to pass and callers never see mutation. + working_character = ( + Character(**request.character.model_dump()) + if request.character + else Character( + name=result.name or request.name or "the character", + description=result.description or request.description or "", + ) + ) + if result.name: + working_character.name = result.name + if result.description: + working_character.description = result.description + if result.attributes: + working_character.base_attributes.update(result.attributes) + + for aspect in request.aspects: + if aspect in result.extracted_aspects(): + continue + + missed = aspect in one_shot + if missed and not fill_misses: + log.warning( + "generate_character_aspects: aspect missed by consolidated " + "response and fill_misses is disabled - leaving empty", + aspect=aspect, + ) + continue + + if missed: + log.info( + "generate_character_aspects: filling in missed aspect with " + "individual request", + aspect=aspect, + ) + + if request.on_aspect_start: + request.on_aspect_start(aspect) + + fill = getattr(self, _ASPECT_FILL_METHODS[aspect]) + await fill(request, result, working_character) + + # the one-shot normalized example dialogue against an earlier name + # (raw fallback or missed name section) - redo the prefixes once the + # real name is known + if ( + result.example_dialogue + and result.name + and "example_dialogue" in one_shot + and one_shot_example_name + and result.name != one_shot_example_name + ): + result.example_dialogue = _normalize_example_dialogue_lines( + result.example_dialogue, + result.name, + other_known_names=[one_shot_example_name, one_shot_fallback_name], + max_examples=request.max_examples, + ) + + log.debug( + "generate_character_aspects", + requested=request.aspects, + one_shot=one_shot, + extracted=sorted(result.extracted_aspects()), + ) + + return result + + # per-aspect fill functions - name, description and attributes chain + # onto the working character so later aspects build on them + + async def _fill_aspect_name( + self, + request: CharacterGenerationRequest, + result: CharacterGenerationResult, + working_character: Character, + ) -> None: + result.name = await self.determine_character_name( + request.name, instructions=request.content + ) + working_character.name = result.name + + async def _fill_aspect_description( + self, + request: CharacterGenerationRequest, + result: CharacterGenerationResult, + working_character: Character, + ) -> None: + result.description = await self.determine_character_description( + working_character, + text=request.content if request.content_role == "text" else "", + information=request.content + if request.content_role == "information" + else "", + dynamic_instructions=request.dynamic_instructions, + generation_options=request.generation_options, + ) + working_character.description = result.description + + async def _fill_aspect_attributes( + self, + request: CharacterGenerationRequest, + result: CharacterGenerationResult, + working_character: Character, + ) -> None: + world_state = instance.get_agent("world_state") + fallback_instructions = list(request.dynamic_instructions or []) + if request.attribute_instructions or request.augment_attributes: + fallback_instruction_content = ( + format_attribute_instructions(request.attribute_instructions) + if request.attribute_instructions + else "" + ) + if request.augment_attributes: + # passing augmentation_instructions would flip + # extract-character-sheet.jinja2 into its augment branch, + # which renders character.sheet from the transient working + # character - the augment text must ride in the dynamic + # instruction instead + fallback_instruction_content += ( + f"\nAdditionally: {request.augment_attributes}" + ) + fallback_instructions.append( + DynamicInstruction( + title="ATTRIBUTE INSTRUCTIONS", + content=fallback_instruction_content.strip(), + ) + ) + result.attributes = await world_state.extract_character_sheet( + name=working_character.name, + text=request.content, + dynamic_instructions=fallback_instructions, + max_attributes=request.max_attributes, + character=working_character, + ) + working_character.base_attributes.update(result.attributes) + + async def _fill_aspect_dialogue_instructions( + self, + request: CharacterGenerationRequest, + result: CharacterGenerationResult, + working_character: Character, + ) -> None: + result.dialogue_instructions = ( + await self.determine_character_dialogue_instructions( + working_character, + instructions=request.instructions, + information=request.content, + ) + ) + + async def _fill_aspect_example_dialogue( + self, + request: CharacterGenerationRequest, + result: CharacterGenerationResult, + working_character: Character, + ) -> None: + result.example_dialogue = await self.determine_character_dialogue_examples( + working_character, + text=request.content, + instructions=request.example_dialogue_instructions, + dynamic_instructions=request.dynamic_instructions, + max_examples=request.max_examples, + ) diff --git a/src/talemate/agents/creator/modules/create-character.json b/src/talemate/agents/creator/modules/create-character.json index 0c8254d6..3b27664c 100644 --- a/src/talemate/agents/creator/modules/create-character.json +++ b/src/talemate/agents/creator/modules/create-character.json @@ -20,8 +20,8 @@ "input_group": "", "num": 3 }, - "x": 39, - "y": 236, + "x": 38, + "y": -243, "width": 210, "height": 154, "collapsed": false, @@ -39,8 +39,8 @@ "input_group": "", "num": 1 }, - "x": 38, - "y": -164, + "x": 37, + "y": -643, "width": 210, "height": 154, "collapsed": false, @@ -58,8 +58,8 @@ "input_group": "", "num": 0 }, - "x": 38, - "y": -364, + "x": 37, + "y": -843, "width": 210, "height": 154, "collapsed": false, @@ -75,8 +75,8 @@ "output_name": "state", "num": 0 }, - "x": 288, - "y": -334, + "x": 287, + "y": -813, "width": 210, "height": 106, "collapsed": true, @@ -88,8 +88,8 @@ "title": "description", "id": "553125be-2c2b-4404-98b5-d6333a4f9655", "properties": {}, - "x": 348, - "y": 236, + "x": 347, + "y": -243, "width": 140, "height": 26, "collapsed": false, @@ -101,8 +101,8 @@ "title": "character_name", "id": "207e357e-5e83-4d40-a331-d0041b9dfa49", "properties": {}, - "x": 348, - "y": 36, + "x": 347, + "y": -443, "width": 140, "height": 26, "collapsed": false, @@ -114,8 +114,8 @@ "title": "instructions", "id": "bad7d2ed-f6fa-452b-8b47-d753ae7a45e0", "properties": {}, - "x": 348, - "y": -164, + "x": 347, + "y": -643, "width": 140, "height": 26, "collapsed": false, @@ -130,8 +130,8 @@ "name": "instructions", "scope": "local" }, - "x": 598, - "y": -164, + "x": 597, + "y": -643, "width": 210, "height": 122, "collapsed": false, @@ -146,8 +146,8 @@ "name": "character_name", "scope": "local" }, - "x": 598, - "y": 36, + "x": 597, + "y": -443, "width": 210, "height": 122, "collapsed": false, @@ -162,8 +162,8 @@ "name": "description", "scope": "local" }, - "x": 598, - "y": 236, + "x": 597, + "y": -243, "width": 210, "height": 122, "collapsed": false, @@ -175,8 +175,8 @@ "title": "generate_attributes", "id": "1765a51c-82ac-4d96-9c60-d0adc7faaa68", "properties": {}, - "x": 498, - "y": 436, + "x": 497, + "y": -43, "width": 171, "height": 26, "collapsed": false, @@ -191,8 +191,8 @@ "name": "generate_attributes", "scope": "local" }, - "x": 798, - "y": 435, + "x": 797, + "y": -44, "width": 244, "height": 122, "collapsed": false, @@ -204,8 +204,8 @@ "title": "is_active", "id": "f3dc37df-1748-4235-b575-60e55b8bec73", "properties": {}, - "x": 498, - "y": 635, + "x": 497, + "y": 156, "width": 171, "height": 26, "collapsed": false, @@ -219,8 +219,8 @@ "properties": { "stage": 0 }, - "x": 1208, - "y": 95, + "x": 1207, + "y": -384, "width": 210, "height": 118, "collapsed": true, @@ -237,8 +237,8 @@ "auto_title": null, "icon": "F1719" }, - "x": 1178, - "y": -415, + "x": 1177, + "y": -894, "width": 210, "height": 130, "collapsed": false, @@ -252,8 +252,8 @@ "properties": { "default": true }, - "x": 298, - "y": 465, + "x": 297, + "y": -14, "width": 210, "height": 58, "collapsed": true, @@ -267,8 +267,8 @@ "properties": { "default": false }, - "x": 298, - "y": 665, + "x": 297, + "y": 186, "width": 210, "height": 58, "collapsed": true, @@ -286,8 +286,8 @@ "input_group": "", "num": 2 }, - "x": 38, - "y": 35, + "x": 37, + "y": -444, "width": 210, "height": 154, "collapsed": false, @@ -302,8 +302,8 @@ "name": "is_active", "scope": "local" }, - "x": 798, - "y": 635, + "x": 797, + "y": 156, "width": 244, "height": 122, "collapsed": false, @@ -321,46 +321,8 @@ "input_group": "", "num": 5 }, - "x": 38, - "y": 435, - "width": 237, - "height": 154, - "collapsed": false, - "inherited": false, - "registry": "core/Input", - "base_type": "core/Node" - }, - "0d7d1898-f122-4803-8a87-5be7a6bd7b56": { - "title": "IN is_active", - "id": "0d7d1898-f122-4803-8a87-5be7a6bd7b56", - "properties": { - "input_type": "bool", - "input_name": "is_active", - "input_optional": true, - "input_group": "", - "num": 7 - }, - "x": 38, - "y": 635, - "width": 210, - "height": 154, - "collapsed": false, - "inherited": false, - "registry": "core/Input", - "base_type": "core/Node" - }, - "b8adab3f-3cd1-4406-ad3f-db4c80a910f7": { - "title": "IN generate", - "id": "b8adab3f-3cd1-4406-ad3f-db4c80a910f7", - "properties": { - "input_type": "bool", - "input_name": "generate", - "input_optional": true, - "input_group": "", - "num": 4 - }, - "x": 48, - "y": 1055, + "x": 37, + "y": -44, "width": 237, "height": 154, "collapsed": false, @@ -374,8 +336,8 @@ "properties": { "default": true }, - "x": 318, - "y": 1085, + "x": 317, + "y": 606, "width": 210, "height": 58, "collapsed": true, @@ -387,8 +349,8 @@ "title": "generate", "id": "6d387c67-6b32-4435-b984-4760f0f1f8d2", "properties": {}, - "x": 498, - "y": 1065, + "x": 497, + "y": 586, "width": 171, "height": 26, "collapsed": false, @@ -403,8 +365,8 @@ "name": "generate", "scope": "local" }, - "x": 798, - "y": 1045, + "x": 797, + "y": 566, "width": 244, "height": 122, "collapsed": false, @@ -419,8 +381,8 @@ "name": "generate", "scope": "local" }, - "x": 39, - "y": 1829, + "x": 37, + "y": 1790, "width": 210, "height": 122, "collapsed": false, @@ -435,8 +397,8 @@ "name": "character_name", "scope": "local" }, - "x": 39, - "y": 2009, + "x": 37, + "y": 1970, "width": 210, "height": 122, "collapsed": false, @@ -444,25 +406,6 @@ "registry": "state/GetState", "base_type": "core/Node" }, - "6cbc9260-f13b-42a4-baa6-f9b2506ea9e7": { - "title": "IN generation_options", - "id": "6cbc9260-f13b-42a4-baa6-f9b2506ea9e7", - "properties": { - "input_type": "generation_options", - "input_name": "generation_options", - "input_optional": true, - "input_group": "", - "num": 6 - }, - "x": 38, - "y": 835, - "width": 252, - "height": 154, - "collapsed": false, - "inherited": false, - "registry": "core/Input", - "base_type": "core/Node" - }, "3cb38945-4f46-4200-b1ff-db2a69d66e07": { "title": "Apply Default", "id": "3cb38945-4f46-4200-b1ff-db2a69d66e07", @@ -470,8 +413,8 @@ "apply_on_none": true, "apply_on_unresolved": true }, - "x": 518, - "y": 865, + "x": 517, + "y": 386, "width": 210, "height": 102, "collapsed": true, @@ -479,30 +422,14 @@ "registry": "core/ApplyDefault", "base_type": "core/Node" }, - "c16ff9e6-efc1-4eb1-a998-df70e8dd488d": { - "title": "GET local.character_name", - "id": "c16ff9e6-efc1-4eb1-a998-df70e8dd488d", - "properties": { - "name": "character_name", - "scope": "local" - }, - "x": 31, - "y": 2461, - "width": 210, - "height": 122, - "collapsed": false, - "inherited": false, - "registry": "state/GetState", - "base_type": "core/Node" - }, "9d9d9bf9-6c1f-4732-a1f6-7f0eb8362981": { "title": "NOT", "id": "9d9d9bf9-6c1f-4732-a1f6-7f0eb8362981", "properties": { "pass_through": false }, - "x": 330, - "y": 2060, + "x": 328, + "y": 2021, "width": 210, "height": 78, "collapsed": true, @@ -516,8 +443,8 @@ "properties": { "pass_through": false }, - "x": 330, - "y": 1880, + "x": 328, + "y": 1841, "width": 210, "height": 78, "collapsed": true, @@ -532,8 +459,8 @@ "message": "You need to specify a name for the character, or enable `generate`.", "field": "character_name" }, - "x": 760, - "y": 1920, + "x": 758, + "y": 1881, "width": 287, "height": 122, "collapsed": false, @@ -545,8 +472,8 @@ "title": "AND Router", "id": "18759191-ed28-4794-bd01-59b3f15ac249", "properties": {}, - "x": 570, - "y": 1970, + "x": 568, + "y": 1931, "width": 140, "height": 106, "collapsed": true, @@ -554,426 +481,6 @@ "registry": "core/ANDRouter", "base_type": "core/Node" }, - "5c482249-9a89-4844-b541-d04204327ef1": { - "title": "GET local.generate", - "id": "5c482249-9a89-4844-b541-d04204327ef1", - "properties": { - "name": "generate", - "scope": "local" - }, - "x": 31, - "y": 2261, - "width": 210, - "height": 122, - "collapsed": false, - "inherited": false, - "registry": "state/GetState", - "base_type": "core/Node" - }, - "ba559ec0-59ba-4f0f-b20b-2f184b8fa98a": { - "title": "NOT", - "id": "ba559ec0-59ba-4f0f-b20b-2f184b8fa98a", - "properties": { - "pass_through": false - }, - "x": 291, - "y": 2471, - "width": 210, - "height": 78, - "collapsed": false, - "inherited": false, - "registry": "core/Switch", - "base_type": "core/Node" - }, - "cdab077e-6e60-4d56-a603-e579e37afefc": { - "title": "Switch", - "id": "cdab077e-6e60-4d56-a603-e579e37afefc", - "properties": { - "pass_through": true - }, - "x": 291, - "y": 2311, - "width": 210, - "height": 78, - "collapsed": false, - "inherited": false, - "registry": "core/Switch", - "base_type": "core/Node" - }, - "12e6c82e-f36b-4a34-86ba-e7e6bda33925": { - "title": "AND Router", - "id": "12e6c82e-f36b-4a34-86ba-e7e6bda33925", - "properties": {}, - "x": 581, - "y": 2391, - "width": 140, - "height": 106, - "collapsed": false, - "inherited": false, - "registry": "core/ANDRouter", - "base_type": "core/Node" - }, - "4c66411b-eca7-4d94-9157-1a1c034ee850": { - "title": "GET local.instructions", - "id": "4c66411b-eca7-4d94-9157-1a1c034ee850", - "properties": { - "name": "instructions", - "scope": "local" - }, - "x": 31, - "y": 2661, - "width": 210, - "height": 122, - "collapsed": false, - "inherited": false, - "registry": "state/GetState", - "base_type": "core/Node" - }, - "e9988ef9-4286-470e-8bdc-1559b37cb92d": { - "title": "Dict Set", - "id": "e9988ef9-4286-470e-8bdc-1559b37cb92d", - "properties": { - "key": "instructions" - }, - "x": 341, - "y": 2781, - "width": 210, - "height": 98, - "collapsed": false, - "inherited": false, - "registry": "data/DictSet", - "base_type": "core/Node" - }, - "9abc678f-9b1b-4a79-ab05-e6fa068c7b8f": { - "title": "Format", - "id": "9abc678f-9b1b-4a79-ab05-e6fa068c7b8f", - "properties": {}, - "x": 711, - "y": 2611, - "width": 140, - "height": 46, - "collapsed": false, - "inherited": false, - "registry": "data/string/Format", - "base_type": "core/Node" - }, - "6343ba50-0ac4-4eff-8769-4a34199f225b": { - "title": "SET local.character_name", - "id": "6343ba50-0ac4-4eff-8769-4a34199f225b", - "properties": { - "name": "character_name", - "scope": "local" - }, - "x": 1371, - "y": 2481, - "width": 210, - "height": 122, - "collapsed": false, - "inherited": false, - "registry": "state/SetState", - "base_type": "core/Node" - }, - "977e9dd8-c8ab-483e-806b-ab1a71dedaff": { - "title": "Generated character_name", - "id": "977e9dd8-c8ab-483e-806b-ab1a71dedaff", - "properties": {}, - "x": 1631, - "y": 2501, - "width": 222, - "height": 29, - "collapsed": false, - "inherited": false, - "registry": "core/Watch", - "base_type": "core/Node" - }, - "1146d1cf-5ef1-4868-9804-77e7fa27c64f": { - "title": "NOT", - "id": "1146d1cf-5ef1-4868-9804-77e7fa27c64f", - "properties": { - "pass_through": false - }, - "x": 298, - "y": 3235, - "width": 210, - "height": 78, - "collapsed": false, - "inherited": false, - "registry": "core/Switch", - "base_type": "core/Node" - }, - "7e5fa4f6-0d43-400d-b45a-e79d4b639b2a": { - "title": "Switch", - "id": "7e5fa4f6-0d43-400d-b45a-e79d4b639b2a", - "properties": { - "pass_through": true - }, - "x": 299, - "y": 3071, - "width": 210, - "height": 78, - "collapsed": false, - "inherited": false, - "registry": "core/Switch", - "base_type": "core/Node" - }, - "38daf429-e97e-469b-9639-1bc5f71adfab": { - "title": "GET local.generate", - "id": "38daf429-e97e-469b-9639-1bc5f71adfab", - "properties": { - "name": "generate", - "scope": "local" - }, - "x": 38, - "y": 3025, - "width": 210, - "height": 122, - "collapsed": false, - "inherited": false, - "registry": "state/GetState", - "base_type": "core/Node" - }, - "8b0dc913-54c9-4d80-a6f9-a0838a32bdd5": { - "title": "GET local.description", - "id": "8b0dc913-54c9-4d80-a6f9-a0838a32bdd5", - "properties": { - "name": "description", - "scope": "local" - }, - "x": 39, - "y": 3231, - "width": 210, - "height": 122, - "collapsed": false, - "inherited": false, - "registry": "state/GetState", - "base_type": "core/Node" - }, - "94c0ff89-d383-422a-a9be-56d7d3dfeb6e": { - "title": "Generated description", - "id": "94c0ff89-d383-422a-a9be-56d7d3dfeb6e", - "properties": {}, - "x": 1617, - "y": 3303, - "width": 222, - "height": 29, - "collapsed": false, - "inherited": false, - "registry": "core/Watch", - "base_type": "core/Node" - }, - "d41f0d98-14d5-49dd-8e57-7812fb9fee94": { - "title": "SET local.description", - "id": "d41f0d98-14d5-49dd-8e57-7812fb9fee94", - "properties": { - "name": "description", - "scope": "local" - }, - "x": 1357, - "y": 3283, - "width": 210, - "height": 122, - "collapsed": false, - "inherited": false, - "registry": "state/SetState", - "base_type": "core/Node" - }, - "18aebea3-92e2-40c6-a369-9d75d6c2f8ad": { - "title": "AND Router", - "id": "18aebea3-92e2-40c6-a369-9d75d6c2f8ad", - "properties": {}, - "x": 620, - "y": 3100, - "width": 140, - "height": 106, - "collapsed": false, - "inherited": false, - "registry": "core/ANDRouter", - "base_type": "core/Node" - }, - "f7744a7b-ab5b-42e2-842c-6dde999cf9cc": { - "title": "GET local.instructions", - "id": "f7744a7b-ab5b-42e2-842c-6dde999cf9cc", - "properties": { - "name": "instructions", - "scope": "local" - }, - "x": 620, - "y": 3250, - "width": 249, - "height": 122, - "collapsed": false, - "inherited": false, - "registry": "state/GetState", - "base_type": "core/Node" - }, - "2b3f32d4-b0cc-4920-b48d-884b0a0043a0": { - "title": "GET local.character_name", - "id": "2b3f32d4-b0cc-4920-b48d-884b0a0043a0", - "properties": { - "name": "character_name", - "scope": "local" - }, - "x": 620, - "y": 3420, - "width": 253, - "height": 123, - "collapsed": false, - "inherited": false, - "registry": "state/GetState", - "base_type": "core/Node" - }, - "7b0ebe89-2d3f-4193-9088-957f72da9dd0": { - "title": "GET local.generation_options", - "id": "7b0ebe89-2d3f-4193-9088-957f72da9dd0", - "properties": { - "name": "generation_options", - "scope": "local" - }, - "x": 620, - "y": 3590, - "width": 251, - "height": 122, - "collapsed": false, - "inherited": false, - "registry": "state/GetState", - "base_type": "core/Node" - }, - "c665fe70-5c5e-42d7-aabe-4a1ff439991d": { - "title": "GET local.generate", - "id": "c665fe70-5c5e-42d7-aabe-4a1ff439991d", - "properties": { - "name": "generate", - "scope": "local" - }, - "x": 50, - "y": 3880, - "width": 210, - "height": 122, - "collapsed": false, - "inherited": false, - "registry": "state/GetState", - "base_type": "core/Node" - }, - "b3209093-d6cd-43b8-8b63-9f3266793dd6": { - "title": "GET local.generate", - "id": "b3209093-d6cd-43b8-8b63-9f3266793dd6", - "properties": { - "name": "generate_attributes", - "scope": "local" - }, - "x": 50, - "y": 4060, - "width": 210, - "height": 122, - "collapsed": false, - "inherited": false, - "registry": "state/GetState", - "base_type": "core/Node" - }, - "84c3e51f-a5ee-4712-8a5e-6e7862209720": { - "title": "Switch", - "id": "84c3e51f-a5ee-4712-8a5e-6e7862209720", - "properties": { - "pass_through": true - }, - "x": 310, - "y": 3910, - "width": 210, - "height": 78, - "collapsed": false, - "inherited": false, - "registry": "core/Switch", - "base_type": "core/Node" - }, - "4b9ad3a5-a48a-4bb0-ab1d-821662657ee9": { - "title": "Switch", - "id": "4b9ad3a5-a48a-4bb0-ab1d-821662657ee9", - "properties": { - "pass_through": true - }, - "x": 310, - "y": 4080, - "width": 210, - "height": 78, - "collapsed": false, - "inherited": false, - "registry": "core/Switch", - "base_type": "core/Node" - }, - "7a8cf3ed-64f3-4529-825c-991afd135df2": { - "title": "AND Router", - "id": "7a8cf3ed-64f3-4529-825c-991afd135df2", - "properties": {}, - "x": 620, - "y": 3980, - "width": 140, - "height": 106, - "collapsed": false, - "inherited": false, - "registry": "core/ANDRouter", - "base_type": "core/Node" - }, - "49baf79b-9489-46ae-8304-c51e446efd2a": { - "title": "GET local.character_name", - "id": "49baf79b-9489-46ae-8304-c51e446efd2a", - "properties": { - "name": "character_name", - "scope": "local" - }, - "x": 50, - "y": 4240, - "width": 253, - "height": 123, - "collapsed": false, - "inherited": false, - "registry": "state/GetState", - "base_type": "core/Node" - }, - "799953d7-a321-4873-a19d-62a803c91b68": { - "title": "GET local.description", - "id": "799953d7-a321-4873-a19d-62a803c91b68", - "properties": { - "name": "description", - "scope": "local" - }, - "x": 50, - "y": 4420, - "width": 240, - "height": 122, - "collapsed": false, - "inherited": false, - "registry": "state/GetState", - "base_type": "core/Node" - }, - "5f355c6f-8b73-4a56-98fe-804584ca021d": { - "title": "Extract Character Sheet", - "id": "5f355c6f-8b73-4a56-98fe-804584ca021d", - "properties": {}, - "x": 840, - "y": 4080, - "width": 329, - "height": 86, - "collapsed": false, - "inherited": false, - "registry": "agents/world_state/ExtractCharacterSheet", - "base_type": "core/Node" - }, - "fc9c01c5-16dc-4e37-ae5a-2e7c49d717b8": { - "title": "SET local.base_attributes", - "id": "fc9c01c5-16dc-4e37-ae5a-2e7c49d717b8", - "properties": { - "name": "base_attributes", - "scope": "local" - }, - "x": 1200, - "y": 4060, - "width": 210, - "height": 122, - "collapsed": false, - "inherited": false, - "registry": "state/SetState", - "base_type": "core/Node" - }, "7c86e49a-a745-4de7-86f8-ecdfe686e594": { "title": "GET local.character_name", "id": "7c86e49a-a745-4de7-86f8-ecdfe686e594", @@ -982,7 +489,7 @@ "scope": "local" }, "x": 41, - "y": 4678, + "y": 3773, "width": 253, "height": 123, "collapsed": false, @@ -998,7 +505,7 @@ "scope": "local" }, "x": 41, - "y": 4858, + "y": 3953, "width": 240, "height": 122, "collapsed": false, @@ -1013,57 +520,22 @@ "data": {} }, "x": 39, - "y": 5038, + "y": 4133, "width": 210, - "height": 58, + "height": 78, "collapsed": true, "inherited": false, "registry": "data/MakeDict", "base_type": "core/Node" }, - "34139ec6-8089-4a29-a42f-3d06a9387f8e": { - "title": "GET local.base_attributes", - "id": "34139ec6-8089-4a29-a42f-3d06a9387f8e", - "properties": { - "name": "base_attributes", - "scope": "local" - }, - "x": 37, - "y": 5068, - "width": 240, - "height": 122, - "collapsed": false, - "inherited": false, - "registry": "state/GetState", - "base_type": "core/Node" - }, - "0b2f17c9-6182-425c-a26f-fdbc78ed3b1d": { - "title": "IN is_player", - "id": "0b2f17c9-6182-425c-a26f-fdbc78ed3b1d", - "properties": { - "input_type": "bool", - "input_name": "is_player", - "input_optional": true, - "input_group": "", - "num": 8 - }, - "x": 49, - "y": 1275, - "width": 210, - "height": 154, - "collapsed": false, - "inherited": false, - "registry": "core/Input", - "base_type": "core/Node" - }, "aa3c1a11-bdff-44a0-bc7b-a2a04eebccc4": { "title": "As Bool", "id": "aa3c1a11-bdff-44a0-bc7b-a2a04eebccc4", "properties": { "default": false }, - "x": 329, - "y": 1315, + "x": 328, + "y": 836, "width": 210, "height": 58, "collapsed": true, @@ -1075,8 +547,8 @@ "title": "is_player", "id": "8acfe789-fbb5-4e29-8fd8-2217b987c086", "properties": {}, - "x": 499, - "y": 1295, + "x": 498, + "y": 816, "width": 171, "height": 26, "collapsed": false, @@ -1091,8 +563,8 @@ "name": "is_player", "scope": "local" }, - "x": 799, - "y": 1255, + "x": 798, + "y": 776, "width": 244, "height": 122, "collapsed": false, @@ -1108,7 +580,7 @@ "scope": "local" }, "x": 36, - "y": 5249, + "y": 4344, "width": 240, "height": 122, "collapsed": false, @@ -1124,7 +596,7 @@ "scope": "local" }, "x": 36, - "y": 5439, + "y": 4534, "width": 240, "height": 122, "collapsed": false, @@ -1139,7 +611,7 @@ "value": true }, "x": 415, - "y": 5299, + "y": 4394, "width": 210, "height": 58, "collapsed": true, @@ -1147,27 +619,6 @@ "registry": "core/MakeBool", "base_type": "core/Node" }, - "6bed1dc2-9753-4850-a7e4-33893b20593d": { - "title": "Make Character", - "id": "6bed1dc2-9753-4850-a7e4-33893b20593d", - "properties": { - "name": null, - "description": "", - "color": null, - "base_attributes": null, - "is_player": false, - "add_to_scene": true, - "is_active": true - }, - "x": 705, - "y": 5089, - "width": 212, - "height": 322, - "collapsed": false, - "inherited": false, - "registry": "scene/MakeCharacter", - "base_type": "core/Node" - }, "836f1ff3-d017-437f-9eff-a28214d3b7e2": { "title": "SET local.character", "id": "836f1ff3-d017-437f-9eff-a28214d3b7e2", @@ -1175,8 +626,8 @@ "name": "character", "scope": "local" }, - "x": 1025, - "y": 4989, + "x": 1010, + "y": 4084, "width": 210, "height": 122, "collapsed": false, @@ -1189,7 +640,7 @@ "id": "902a31db-63f8-494f-97a8-9910407f2e97", "properties": {}, "x": 1285, - "y": 5009, + "y": 4104, "width": 184, "height": 30, "collapsed": false, @@ -1206,7 +657,7 @@ "num": 0 }, "x": 332, - "y": 6178, + "y": 5272, "width": 210, "height": 106, "collapsed": false, @@ -1222,7 +673,7 @@ "scope": "local" }, "x": 52, - "y": 6168, + "y": 5262, "width": 210, "height": 122, "collapsed": false, @@ -1239,7 +690,7 @@ "num": 0 }, "x": 332, - "y": 6388, + "y": 5482, "width": 210, "height": 106, "collapsed": false, @@ -1255,7 +706,7 @@ "scope": "local" }, "x": 52, - "y": 6388, + "y": 5482, "width": 210, "height": 122, "collapsed": false, @@ -1269,8 +720,8 @@ "properties": { "stage": 0 }, - "x": 1320, - "y": 889, + "x": 1319, + "y": 410, "width": 210, "height": 118, "collapsed": true, @@ -1284,53 +735,8 @@ "properties": { "stage": 1 }, - "x": 1110, - "y": 1950, - "width": 210, - "height": 118, - "collapsed": true, - "inherited": false, - "registry": "core/Stage", - "base_type": "core/Node" - }, - "1409e867-ad0b-4e79-95d8-d69aaaac7a78": { - "title": "Stage 2", - "id": "1409e867-ad0b-4e79-95d8-d69aaaac7a78", - "properties": { - "stage": 2 - }, - "x": 1900, - "y": 2530, - "width": 210, - "height": 118, - "collapsed": true, - "inherited": false, - "registry": "core/Stage", - "base_type": "core/Node" - }, - "1b180fa5-5d97-4d46-bdd3-eaf19084177d": { - "title": "Stage 3", - "id": "1b180fa5-5d97-4d46-bdd3-eaf19084177d", - "properties": { - "stage": 3 - }, - "x": 1900, - "y": 3330, - "width": 210, - "height": 118, - "collapsed": true, - "inherited": false, - "registry": "core/Stage", - "base_type": "core/Node" - }, - "f298b3a5-0ef6-4034-832c-658a2beabdf4": { - "title": "Stage 4", - "id": "f298b3a5-0ef6-4034-832c-658a2beabdf4", - "properties": { - "stage": 4 - }, - "x": 1440, - "y": 4110, + "x": 1108, + "y": 1911, "width": 210, "height": 118, "collapsed": true, @@ -1345,7 +751,7 @@ "stage": 5 }, "x": 1560, - "y": 5130, + "y": 4225, "width": 210, "height": 118, "collapsed": true, @@ -1360,8 +766,8 @@ "spice_level": 0, "writing_style": null }, - "x": 320, - "y": 929, + "x": 319, + "y": 450, "width": 270, "height": 122, "collapsed": true, @@ -1376,8 +782,8 @@ "name": "generation_options", "scope": "local" }, - "x": 790, - "y": 839, + "x": 789, + "y": 360, "width": 244, "height": 122, "collapsed": false, @@ -1392,8 +798,8 @@ "name": "actor", "scope": "local" }, - "x": 1030, - "y": 5190, + "x": 1010, + "y": 4285, "width": 210, "height": 122, "collapsed": false, @@ -1401,34 +807,12 @@ "registry": "state/SetState", "base_type": "core/Node" }, - "4acb67ea-68ee-43ae-a8c6-98a2b0e0f053": { - "title": "Contextual Generate", - "id": "4acb67ea-68ee-43ae-a8c6-98a2b0e0f053", - "properties": { - "context_type": "character detail", - "context_name": "description", - "instructions": null, - "length": 372, - "character": "the character", - "uid": null, - "context_aware": true, - "history_aware": true - }, - "x": 1019, - "y": 3241, - "width": 304, - "height": 406, - "collapsed": false, - "inherited": false, - "registry": "agents/creator/ContextualGenerate", - "base_type": "core/Node" - }, "d61de1ad-6f2a-447f-918a-dce7e76ea3a1": { "title": "assign_voice", "id": "d61de1ad-6f2a-447f-918a-dce7e76ea3a1", "properties": {}, - "x": 509, - "y": 1544, + "x": 498, + "y": 1065, "width": 171, "height": 26, "collapsed": false, @@ -1443,8 +827,8 @@ "name": "assign_voice", "scope": "local" }, - "x": 810, - "y": 1506, + "x": 809, + "y": 1027, "width": 244, "height": 122, "collapsed": false, @@ -1452,40 +836,6 @@ "registry": "state/SetState", "base_type": "core/Node" }, - "6aa5c32a-8dfb-48a9-96ec-5ad9ed6aa5d1": { - "title": "Stage 0", - "id": "6aa5c32a-8dfb-48a9-96ec-5ad9ed6aa5d1", - "properties": { - "stage": 0 - }, - "x": 1170, - "y": 1546, - "width": 210, - "height": 118, - "collapsed": true, - "inherited": false, - "registry": "core/Stage", - "base_type": "core/Node" - }, - "9ac9b12d-1b97-4f42-92d8-0d4f883ffb2f": { - "title": "IN assign_voice", - "id": "9ac9b12d-1b97-4f42-92d8-0d4f883ffb2f", - "properties": { - "input_type": "bool", - "input_name": "assign_voice", - "input_optional": true, - "input_group": "", - "num": 9 - }, - "x": 60, - "y": 1527, - "width": 210, - "height": 154, - "collapsed": false, - "inherited": false, - "registry": "core/Input", - "base_type": "core/Node" - }, "de11206d-13db-44a5-befd-de559fb68d09": { "title": "GET local.assign_voice", "id": "de11206d-13db-44a5-befd-de559fb68d09", @@ -1494,7 +844,7 @@ "scope": "local" }, "x": 25, - "y": 5720, + "y": 4815, "width": 240, "height": 122, "collapsed": false, @@ -1509,7 +859,7 @@ "pass_through": true }, "x": 355, - "y": 5740, + "y": 4835, "width": 210, "height": 78, "collapsed": false, @@ -1525,7 +875,7 @@ "scope": "local" }, "x": 25, - "y": 5930, + "y": 5025, "width": 210, "height": 122, "collapsed": false, @@ -1538,7 +888,7 @@ "id": "47b2b492-4178-4254-b468-3877a5341f66", "properties": {}, "x": 665, - "y": 5830, + "y": 4925, "width": 161, "height": 66, "collapsed": false, @@ -1553,7 +903,7 @@ "stage": 6 }, "x": 885, - "y": 5860, + "y": 4955, "width": 210, "height": 118, "collapsed": true, @@ -1567,8 +917,8 @@ "properties": { "default": true }, - "x": 330, - "y": 1560, + "x": 329, + "y": 1081, "width": 210, "height": 58, "collapsed": true, @@ -1576,37 +926,626 @@ "registry": "core/AsBool", "base_type": "core/Node" }, - "391f49ed-ade2-4bb3-8e34-8ed4bc850f6c": { - "title": "Make Text", - "id": "391f49ed-ade2-4bb3-8e34-8ed4bc850f6c", + "26e7a052-66b2-4566-a906-841e1a564075": { + "title": "IS", + "id": "26e7a052-66b2-4566-a906-841e1a564075", "properties": { - "value": "You are creating a simple character name, without titles for the following character: {instructions}\n\nOnly respond with the character's name, nothing else." + "pass_through": true }, - "x": 301, - "y": 2621, - "width": 273, - "height": 58, - "collapsed": false, + "x": 298, + "y": 2261, + "width": 210, + "height": 78, + "collapsed": true, "inherited": false, - "registry": "data/string/MakeText", + "registry": "core/Switch", "base_type": "core/Node" }, - "625fbe42-59ec-4494-a26e-c5464f43bc41": { - "title": "Determine Character Name", - "id": "625fbe42-59ec-4494-a26e-c5464f43bc41", + "ca6f6330-6fa2-40a9-a3cc-93cbe98af45c": { + "title": "GET local.instructions", + "id": "ca6f6330-6fa2-40a9-a3cc-93cbe98af45c", "properties": { - "character_name": "", - "allowed_names": [], - "is_group": false, - "instructions": "" + "name": "instructions", + "scope": "local" }, - "x": 980, - "y": 2430, - "width": 245, - "height": 230, + "x": 25, + "y": 2391, + "width": 210, + "height": 122, "collapsed": false, "inherited": false, - "registry": "agents/creator/DetermineCharacterName", + "registry": "state/GetState", + "base_type": "core/Node" + }, + "0d49348a-887a-4613-ba32-eacfe7d2e883": { + "title": "GET local.generate", + "id": "0d49348a-887a-4613-ba32-eacfe7d2e883", + "properties": { + "name": "generate", + "scope": "local" + }, + "x": 25, + "y": 2211, + "width": 210, + "height": 122, + "collapsed": false, + "inherited": false, + "registry": "state/GetState", + "base_type": "core/Node" + }, + "e20ced99-ed5c-4df5-bce7-c2905835b583": { + "title": "NOT", + "id": "e20ced99-ed5c-4df5-bce7-c2905835b583", + "properties": { + "pass_through": false + }, + "x": 310, + "y": 2979, + "width": 210, + "height": 78, + "collapsed": true, + "inherited": false, + "registry": "core/Switch", + "base_type": "core/Node" + }, + "28e76ef6-1c68-4783-ae8d-3fa3e869c7d8": { + "title": "NOT", + "id": "28e76ef6-1c68-4783-ae8d-3fa3e869c7d8", + "properties": { + "pass_through": false + }, + "x": 310, + "y": 2801, + "width": 210, + "height": 78, + "collapsed": true, + "inherited": false, + "registry": "core/Switch", + "base_type": "core/Node" + }, + "698e0a5a-551d-4767-8e3a-87a8463b74be": { + "title": "GET local.generate_attributes", + "id": "698e0a5a-551d-4767-8e3a-87a8463b74be", + "properties": { + "name": "generate_attributes", + "scope": "local" + }, + "x": 25, + "y": 3141, + "width": 244, + "height": 122, + "collapsed": false, + "inherited": false, + "registry": "state/GetState", + "base_type": "core/Node" + }, + "4999a6bc-4a88-4dd8-90ea-b3c266fe9a5c": { + "title": "GET local.description", + "id": "4999a6bc-4a88-4dd8-90ea-b3c266fe9a5c", + "properties": { + "name": "description", + "scope": "local" + }, + "x": 25, + "y": 2951, + "width": 210, + "height": 122, + "collapsed": false, + "inherited": false, + "registry": "state/GetState", + "base_type": "core/Node" + }, + "12e31840-e862-409c-8e40-e05cb3f9f432": { + "title": "SET local.character_name", + "id": "12e31840-e862-409c-8e40-e05cb3f9f432", + "properties": { + "name": "character_name", + "scope": "local" + }, + "x": 1280, + "y": 2446, + "width": 210, + "height": 122, + "collapsed": false, + "inherited": false, + "registry": "state/SetState", + "base_type": "core/Node" + }, + "3d1008e2-d2f9-4cd4-adc3-3bced9a979ca": { + "title": "SET local.description", + "id": "3d1008e2-d2f9-4cd4-adc3-3bced9a979ca", + "properties": { + "name": "description", + "scope": "local" + }, + "x": 1280, + "y": 2636, + "width": 210, + "height": 122, + "collapsed": false, + "inherited": false, + "registry": "state/SetState", + "base_type": "core/Node" + }, + "7dc4fd07-3221-4fe6-8cbe-5b0a91379300": { + "title": "SET local.attributes", + "id": "7dc4fd07-3221-4fe6-8cbe-5b0a91379300", + "properties": { + "name": "attributes", + "scope": "local" + }, + "x": 1280, + "y": 2826, + "width": 210, + "height": 122, + "collapsed": false, + "inherited": false, + "registry": "state/SetState", + "base_type": "core/Node" + }, + "34139ec6-8089-4a29-a42f-3d06a9387f8e": { + "title": "GET local.attributes", + "id": "34139ec6-8089-4a29-a42f-3d06a9387f8e", + "properties": { + "name": "attributes", + "scope": "local" + }, + "x": 40, + "y": 4165, + "width": 240, + "height": 122, + "collapsed": false, + "inherited": false, + "registry": "state/GetState", + "base_type": "core/Node" + }, + "adf58774-bed7-4fd4-8203-713b7b2ea6cd": { + "title": "GET local.dialogue_instructions", + "id": "adf58774-bed7-4fd4-8203-713b7b2ea6cd", + "properties": { + "name": "dialogue_instructions", + "scope": "local" + }, + "x": 1010, + "y": 3775, + "width": 260, + "height": 122, + "collapsed": true, + "inherited": false, + "registry": "state/GetState", + "base_type": "core/Node" + }, + "331324b5-8c8b-4c63-95bc-ce6c57265d31": { + "title": "SET obj.dialogue_instructions", + "id": "331324b5-8c8b-4c63-95bc-ce6c57265d31", + "properties": { + "attribute": "dialogue_instructions" + }, + "x": 1010, + "y": 3805, + "width": 269, + "height": 115, + "collapsed": true, + "inherited": false, + "registry": "data/Set", + "base_type": "core/Node" + }, + "6bed1dc2-9753-4850-a7e4-33893b20593d": { + "title": "Make Character", + "id": "6bed1dc2-9753-4850-a7e4-33893b20593d", + "properties": { + "name": null, + "description": "", + "color": null, + "base_attributes": null, + "is_player": false, + "add_to_scene": true, + "is_active": true + }, + "x": 705, + "y": 4184, + "width": 212, + "height": 322, + "collapsed": false, + "inherited": false, + "registry": "scene/MakeCharacter", + "base_type": "core/Node" + }, + "b206d7f4-7ecc-4aa1-a9b0-b34682eae813": { + "title": "GET local.example_dialogue", + "id": "b206d7f4-7ecc-4aa1-a9b0-b34682eae813", + "properties": { + "name": "example_dialogue", + "scope": "local" + }, + "x": 1010, + "y": 3865, + "width": 218, + "height": 122, + "collapsed": true, + "inherited": false, + "registry": "state/GetState", + "base_type": "core/Node" + }, + "b4f6b977-54a3-4d28-9d0b-98c95e43051b": { + "title": "SET obj.example_dialogue", + "id": "b4f6b977-54a3-4d28-9d0b-98c95e43051b", + "properties": { + "attribute": "example_dialogue" + }, + "x": 1010, + "y": 3895, + "width": 210, + "height": 98, + "collapsed": true, + "inherited": false, + "registry": "data/Set", + "base_type": "core/Node" + }, + "7ef6441d-c230-4e2d-a4f3-99616e4843f6": { + "title": "SET local.dialogue_instructions", + "id": "7ef6441d-c230-4e2d-a4f3-99616e4843f6", + "properties": { + "name": "dialogue_instructions", + "scope": "local" + }, + "x": 1280, + "y": 3016, + "width": 260, + "height": 122, + "collapsed": false, + "inherited": false, + "registry": "state/SetState", + "base_type": "core/Node" + }, + "89a068c5-2a36-4292-a32a-7f2c3648320f": { + "title": "SET local.example_dialogue", + "id": "89a068c5-2a36-4292-a32a-7f2c3648320f", + "properties": { + "name": "example_dialogue", + "scope": "local" + }, + "x": 1293, + "y": 3209, + "width": 218, + "height": 122, + "collapsed": false, + "inherited": false, + "registry": "state/SetState", + "base_type": "core/Node" + }, + "c0e70bc5-75c2-4145-b65c-a866bebed641": { + "title": "GET local.generation_options", + "id": "c0e70bc5-75c2-4145-b65c-a866bebed641", + "properties": { + "name": "generation_options", + "scope": "local" + }, + "x": 25, + "y": 2569, + "width": 252, + "height": 122, + "collapsed": false, + "inherited": false, + "registry": "state/GetState", + "base_type": "core/Node" + }, + "34e9ba42-7c82-4c52-8f2c-2fd5370d1fcf": { + "title": "GET local.character_name", + "id": "34e9ba42-7c82-4c52-8f2c-2fd5370d1fcf", + "properties": { + "name": "character_name", + "scope": "local" + }, + "x": 25, + "y": 2749, + "width": 210, + "height": 122, + "collapsed": false, + "inherited": false, + "registry": "state/GetState", + "base_type": "core/Node" + }, + "95d2ff56-8568-42f9-ab54-54b83b262f4e": { + "title": "SET local.generate_dialogue_instructions", + "id": "95d2ff56-8568-42f9-ab54-54b83b262f4e", + "properties": { + "name": "generate_dialogue_instructions", + "scope": "local" + }, + "x": 809, + "y": 1288, + "width": 336, + "height": 122, + "collapsed": false, + "inherited": false, + "registry": "state/SetState", + "base_type": "core/Node" + }, + "bf1be608-be81-488e-8bdf-1f6f8430c918": { + "title": "SET local.generate_example_dialogue", + "id": "bf1be608-be81-488e-8bdf-1f6f8430c918", + "properties": { + "name": "generate_example_dialogue", + "scope": "local" + }, + "x": 809, + "y": 1498, + "width": 294, + "height": 122, + "collapsed": false, + "inherited": false, + "registry": "state/SetState", + "base_type": "core/Node" + }, + "6aa5c32a-8dfb-48a9-96ec-5ad9ed6aa5d1": { + "title": "Stage 0", + "id": "6aa5c32a-8dfb-48a9-96ec-5ad9ed6aa5d1", + "properties": { + "stage": 0 + }, + "x": 1369, + "y": 1249, + "width": 210, + "height": 118, + "collapsed": false, + "inherited": false, + "registry": "core/Stage", + "base_type": "core/Node" + }, + "a7f4f8b9-c422-4b0c-a8ba-d2afa918e721": { + "title": "As Bool", + "id": "a7f4f8b9-c422-4b0c-a8ba-d2afa918e721", + "properties": { + "default": true + }, + "x": 360, + "y": 1310, + "width": 210, + "height": 58, + "collapsed": true, + "inherited": false, + "registry": "core/AsBool", + "base_type": "core/Node" + }, + "e97eeeeb-6dce-480d-8ca6-f3c5b409d2ef": { + "title": "generate_dialogue_instructions", + "id": "e97eeeeb-6dce-480d-8ca6-f3c5b409d2ef", + "properties": {}, + "x": 498, + "y": 1290, + "width": 252, + "height": 26, + "collapsed": false, + "inherited": false, + "registry": "core/Watch", + "base_type": "core/Node" + }, + "018c3dbf-8445-409a-880f-a786896bb341": { + "title": "generate_example_dialogue", + "id": "018c3dbf-8445-409a-880f-a786896bb341", + "properties": {}, + "x": 498, + "y": 1500, + "width": 210, + "height": 26, + "collapsed": false, + "inherited": false, + "registry": "core/Watch", + "base_type": "core/Node" + }, + "7cf4d469-99f4-4ab7-bc37-9e9f1883c96b": { + "title": "GET local.generate_dialogue_instructions", + "id": "7cf4d469-99f4-4ab7-bc37-9e9f1883c96b", + "properties": { + "name": "generate_dialogue_instructions", + "scope": "local" + }, + "x": 25, + "y": 3330, + "width": 336, + "height": 122, + "collapsed": false, + "inherited": false, + "registry": "state/GetState", + "base_type": "core/Node" + }, + "d1914df2-f082-4f10-ab3c-9793bff3a953": { + "title": "GET local.generate_example_dialogue", + "id": "d1914df2-f082-4f10-ab3c-9793bff3a953", + "properties": { + "name": "generate_example_dialogue", + "scope": "local" + }, + "x": 25, + "y": 3520, + "width": 294, + "height": 122, + "collapsed": false, + "inherited": false, + "registry": "state/GetState", + "base_type": "core/Node" + }, + "8833b822-27a2-4cd5-b093-dfb54ee9af45": { + "title": "Generate Character", + "id": "8833b822-27a2-4cd5-b093-dfb54ee9af45", + "properties": { + "generate_name": false, + "generate_description": true, + "generate_attributes": true, + "generate_dialogue_instructions": true, + "generate_example_dialogue": false + }, + "x": 650, + "y": 2730, + "width": 438, + "height": 334, + "collapsed": false, + "inherited": false, + "registry": "agents/creator/GenerateCharacter", + "base_type": "core/Node" + }, + "1409e867-ad0b-4e79-95d8-d69aaaac7a78": { + "title": "Stage 2", + "id": "1409e867-ad0b-4e79-95d8-d69aaaac7a78", + "properties": { + "stage": 2 + }, + "x": 1800, + "y": 2780, + "width": 210, + "height": 118, + "collapsed": false, + "inherited": false, + "registry": "core/Stage", + "base_type": "core/Node" + }, + "6ea14928-0afe-46ec-b416-2b5d5e33010b": { + "title": "As Bool", + "id": "6ea14928-0afe-46ec-b416-2b5d5e33010b", + "properties": { + "default": false + }, + "x": 370, + "y": 1510, + "width": 210, + "height": 58, + "collapsed": true, + "inherited": false, + "registry": "core/AsBool", + "base_type": "core/Node" + }, + "86b90606-a1f4-4670-ab4b-004078272585": { + "title": "IN generate_dialogue_instructions", + "id": "86b90606-a1f4-4670-ab4b-004078272585", + "properties": { + "input_type": "bool", + "input_name": "generate_dialogue_instructions", + "input_optional": true, + "input_group": "", + "num": 6 + }, + "x": 48, + "y": 1276, + "width": 277, + "height": 154, + "collapsed": false, + "inherited": false, + "registry": "core/Input", + "base_type": "core/Node" + }, + "140e01a5-f7a5-4a60-b124-f7194bc5ff99": { + "title": "IN generate_example_dialogue", + "id": "140e01a5-f7a5-4a60-b124-f7194bc5ff99", + "properties": { + "input_type": "bool", + "input_name": "generate_example_dialogue", + "input_optional": true, + "input_group": "", + "num": 7 + }, + "x": 48, + "y": 1486, + "width": 277, + "height": 154, + "collapsed": false, + "inherited": false, + "registry": "core/Input", + "base_type": "core/Node" + }, + "0d7d1898-f122-4803-8a87-5be7a6bd7b56": { + "title": "IN is_active", + "id": "0d7d1898-f122-4803-8a87-5be7a6bd7b56", + "properties": { + "input_type": "bool", + "input_name": "is_active", + "input_optional": true, + "input_group": "", + "num": 8 + }, + "x": 37, + "y": 156, + "width": 210, + "height": 154, + "collapsed": false, + "inherited": false, + "registry": "core/Input", + "base_type": "core/Node" + }, + "6cbc9260-f13b-42a4-baa6-f9b2506ea9e7": { + "title": "IN generation_options", + "id": "6cbc9260-f13b-42a4-baa6-f9b2506ea9e7", + "properties": { + "input_type": "generation_options", + "input_name": "generation_options", + "input_optional": true, + "input_group": "", + "num": 9 + }, + "x": 37, + "y": 356, + "width": 252, + "height": 154, + "collapsed": false, + "inherited": false, + "registry": "core/Input", + "base_type": "core/Node" + }, + "b8adab3f-3cd1-4406-ad3f-db4c80a910f7": { + "title": "IN generate", + "id": "b8adab3f-3cd1-4406-ad3f-db4c80a910f7", + "properties": { + "input_type": "bool", + "input_name": "generate", + "input_optional": true, + "input_group": "", + "num": 4 + }, + "x": 47, + "y": 576, + "width": 237, + "height": 154, + "collapsed": false, + "inherited": false, + "registry": "core/Input", + "base_type": "core/Node" + }, + "0b2f17c9-6182-425c-a26f-fdbc78ed3b1d": { + "title": "IN is_player", + "id": "0b2f17c9-6182-425c-a26f-fdbc78ed3b1d", + "properties": { + "input_type": "bool", + "input_name": "is_player", + "input_optional": true, + "input_group": "", + "num": 10 + }, + "x": 48, + "y": 796, + "width": 210, + "height": 154, + "collapsed": false, + "inherited": false, + "registry": "core/Input", + "base_type": "core/Node" + }, + "9ac9b12d-1b97-4f42-92d8-0d4f883ffb2f": { + "title": "IN assign_voice", + "id": "9ac9b12d-1b97-4f42-92d8-0d4f883ffb2f", + "properties": { + "input_type": "bool", + "input_name": "assign_voice", + "input_optional": true, + "input_group": "", + "num": 11 + }, + "x": 48, + "y": 1049, + "width": 210, + "height": 154, + "collapsed": false, + "inherited": false, + "registry": "core/Input", "base_type": "core/Node" } }, @@ -1662,12 +1601,6 @@ "11d72894-fcb2-4265-b51f-a2d6295358a4.value": [ "d7f99152-807e-4670-9acb-a2b93db4648d.value" ], - "0d7d1898-f122-4803-8a87-5be7a6bd7b56.value": [ - "4bea833c-4ae6-4437-bfd1-bfebc0075256.value" - ], - "b8adab3f-3cd1-4406-ad3f-db4c80a910f7.value": [ - "9b2aeb2e-ca56-4594-a84f-48448ce568e5.value" - ], "9b2aeb2e-ca56-4594-a84f-48448ce568e5.value": [ "6d387c67-6b32-4435-b984-4760f0f1f8d2.value" ], @@ -1683,15 +1616,9 @@ "4ef21cb0-a50d-41c7-b1a0-f627a5accc3d.value": [ "9d9d9bf9-6c1f-4732-a1f6-7f0eb8362981.value" ], - "6cbc9260-f13b-42a4-baa6-f9b2506ea9e7.value": [ - "3cb38945-4f46-4200-b1ff-db2a69d66e07.value" - ], "3cb38945-4f46-4200-b1ff-db2a69d66e07.value": [ "3b178a92-4c44-4568-851f-9466f39cee8e.value" ], - "c16ff9e6-efc1-4eb1-a998-df70e8dd488d.value": [ - "ba559ec0-59ba-4f0f-b20b-2f184b8fa98a.value" - ], "9d9d9bf9-6c1f-4732-a1f6-7f0eb8362981.no": [ "18759191-ed28-4794-bd01-59b3f15ac249.b" ], @@ -1704,90 +1631,6 @@ "18759191-ed28-4794-bd01-59b3f15ac249.yes": [ "73dedcf0-dcba-447a-a0ac-159739a389a9.state" ], - "5c482249-9a89-4844-b541-d04204327ef1.value": [ - "cdab077e-6e60-4d56-a603-e579e37afefc.value" - ], - "ba559ec0-59ba-4f0f-b20b-2f184b8fa98a.no": [ - "12e6c82e-f36b-4a34-86ba-e7e6bda33925.b" - ], - "cdab077e-6e60-4d56-a603-e579e37afefc.yes": [ - "12e6c82e-f36b-4a34-86ba-e7e6bda33925.a" - ], - "12e6c82e-f36b-4a34-86ba-e7e6bda33925.yes": [ - "625fbe42-59ec-4494-a26e-c5464f43bc41.state" - ], - "4c66411b-eca7-4d94-9157-1a1c034ee850.value": [ - "e9988ef9-4286-470e-8bdc-1559b37cb92d.value" - ], - "e9988ef9-4286-470e-8bdc-1559b37cb92d.dict": [ - "9abc678f-9b1b-4a79-ab05-e6fa068c7b8f.variables" - ], - "9abc678f-9b1b-4a79-ab05-e6fa068c7b8f.result": [ - "625fbe42-59ec-4494-a26e-c5464f43bc41.instructions" - ], - "6343ba50-0ac4-4eff-8769-4a34199f225b.value": [ - "977e9dd8-c8ab-483e-806b-ab1a71dedaff.value" - ], - "977e9dd8-c8ab-483e-806b-ab1a71dedaff.value": [ - "1409e867-ad0b-4e79-95d8-d69aaaac7a78.state" - ], - "1146d1cf-5ef1-4868-9804-77e7fa27c64f.no": [ - "18aebea3-92e2-40c6-a369-9d75d6c2f8ad.b" - ], - "7e5fa4f6-0d43-400d-b45a-e79d4b639b2a.yes": [ - "18aebea3-92e2-40c6-a369-9d75d6c2f8ad.a" - ], - "38daf429-e97e-469b-9639-1bc5f71adfab.value": [ - "7e5fa4f6-0d43-400d-b45a-e79d4b639b2a.value" - ], - "8b0dc913-54c9-4d80-a6f9-a0838a32bdd5.value": [ - "1146d1cf-5ef1-4868-9804-77e7fa27c64f.value" - ], - "94c0ff89-d383-422a-a9be-56d7d3dfeb6e.value": [ - "1b180fa5-5d97-4d46-bdd3-eaf19084177d.state" - ], - "d41f0d98-14d5-49dd-8e57-7812fb9fee94.value": [ - "94c0ff89-d383-422a-a9be-56d7d3dfeb6e.value" - ], - "18aebea3-92e2-40c6-a369-9d75d6c2f8ad.yes": [ - "4acb67ea-68ee-43ae-a8c6-98a2b0e0f053.state" - ], - "f7744a7b-ab5b-42e2-842c-6dde999cf9cc.value": [ - "4acb67ea-68ee-43ae-a8c6-98a2b0e0f053.instructions" - ], - "2b3f32d4-b0cc-4920-b48d-884b0a0043a0.value": [ - "4acb67ea-68ee-43ae-a8c6-98a2b0e0f053.character" - ], - "7b0ebe89-2d3f-4193-9088-957f72da9dd0.value": [ - "4acb67ea-68ee-43ae-a8c6-98a2b0e0f053.generation_options" - ], - "c665fe70-5c5e-42d7-aabe-4a1ff439991d.value": [ - "84c3e51f-a5ee-4712-8a5e-6e7862209720.value" - ], - "b3209093-d6cd-43b8-8b63-9f3266793dd6.value": [ - "4b9ad3a5-a48a-4bb0-ab1d-821662657ee9.value" - ], - "84c3e51f-a5ee-4712-8a5e-6e7862209720.yes": [ - "7a8cf3ed-64f3-4529-825c-991afd135df2.a" - ], - "4b9ad3a5-a48a-4bb0-ab1d-821662657ee9.yes": [ - "7a8cf3ed-64f3-4529-825c-991afd135df2.b" - ], - "7a8cf3ed-64f3-4529-825c-991afd135df2.yes": [ - "5f355c6f-8b73-4a56-98fe-804584ca021d.state" - ], - "49baf79b-9489-46ae-8304-c51e446efd2a.value": [ - "5f355c6f-8b73-4a56-98fe-804584ca021d.character_name" - ], - "799953d7-a321-4873-a19d-62a803c91b68.value": [ - "5f355c6f-8b73-4a56-98fe-804584ca021d.context" - ], - "5f355c6f-8b73-4a56-98fe-804584ca021d.character_sheet": [ - "fc9c01c5-16dc-4e37-ae5a-2e7c49d717b8.value" - ], - "fc9c01c5-16dc-4e37-ae5a-2e7c49d717b8.value": [ - "f298b3a5-0ef6-4034-832c-658a2beabdf4.state" - ], "7c86e49a-a745-4de7-86f8-ecdfe686e594.value": [ "6bed1dc2-9753-4850-a7e4-33893b20593d.name" ], @@ -1797,12 +1640,6 @@ "4bddb047-9381-46f4-a178-18435f4dcee8.dict": [ "34139ec6-8089-4a29-a42f-3d06a9387f8e.default" ], - "34139ec6-8089-4a29-a42f-3d06a9387f8e.value": [ - "6bed1dc2-9753-4850-a7e4-33893b20593d.base_attributes" - ], - "0b2f17c9-6182-425c-a26f-fdbc78ed3b1d.value": [ - "aa3c1a11-bdff-44a0-bc7b-a2a04eebccc4.value" - ], "aa3c1a11-bdff-44a0-bc7b-a2a04eebccc4.value": [ "8acfe789-fbb5-4e29-8fd8-2217b987c086.value" ], @@ -1821,12 +1658,6 @@ "b068a4d8-1295-462b-b798-78414becb57a.value": [ "6bed1dc2-9753-4850-a7e4-33893b20593d.add_to_scene" ], - "6bed1dc2-9753-4850-a7e4-33893b20593d.character": [ - "836f1ff3-d017-437f-9eff-a28214d3b7e2.value" - ], - "6bed1dc2-9753-4850-a7e4-33893b20593d.actor": [ - "9f875159-9e90-41cc-84fe-d1b10061a885.value" - ], "836f1ff3-d017-437f-9eff-a28214d3b7e2.value": [ "902a31db-63f8-494f-97a8-9910407f2e97.value" ], @@ -1848,18 +1679,12 @@ "9f875159-9e90-41cc-84fe-d1b10061a885.value": [ "f779b15c-6114-4afb-822e-d5dd696cbe4e.state_b" ], - "4acb67ea-68ee-43ae-a8c6-98a2b0e0f053.text": [ - "d41f0d98-14d5-49dd-8e57-7812fb9fee94.value" - ], "d61de1ad-6f2a-447f-918a-dce7e76ea3a1.value": [ "3d655827-b66b-4355-910d-96097e7f2f13.value" ], "3d655827-b66b-4355-910d-96097e7f2f13.value": [ "6aa5c32a-8dfb-48a9-96ec-5ad9ed6aa5d1.state" ], - "9ac9b12d-1b97-4f42-92d8-0d4f883ffb2f.value": [ - "f5e5ec03-cc12-4a45-aa15-6ca3e5e4bc85.value" - ], "de11206d-13db-44a5-befd-de559fb68d09.value": [ "97b196a3-e7a6-4cfa-905e-686a744890b7.value" ], @@ -1875,28 +1700,140 @@ "f5e5ec03-cc12-4a45-aa15-6ca3e5e4bc85.value": [ "d61de1ad-6f2a-447f-918a-dce7e76ea3a1.value" ], - "391f49ed-ade2-4bb3-8e34-8ed4bc850f6c.value": [ - "9abc678f-9b1b-4a79-ab05-e6fa068c7b8f.template" + "26e7a052-66b2-4566-a906-841e1a564075.yes": [ + "8833b822-27a2-4cd5-b093-dfb54ee9af45.state" ], - "625fbe42-59ec-4494-a26e-c5464f43bc41.character_name": [ - "6343ba50-0ac4-4eff-8769-4a34199f225b.value" + "ca6f6330-6fa2-40a9-a3cc-93cbe98af45c.value": [ + "8833b822-27a2-4cd5-b093-dfb54ee9af45.instructions" + ], + "0d49348a-887a-4613-ba32-eacfe7d2e883.value": [ + "26e7a052-66b2-4566-a906-841e1a564075.value" + ], + "e20ced99-ed5c-4df5-bce7-c2905835b583.no": [ + "8833b822-27a2-4cd5-b093-dfb54ee9af45.generate_description" + ], + "28e76ef6-1c68-4783-ae8d-3fa3e869c7d8.no": [ + "8833b822-27a2-4cd5-b093-dfb54ee9af45.generate_name" + ], + "698e0a5a-551d-4767-8e3a-87a8463b74be.value": [ + "8833b822-27a2-4cd5-b093-dfb54ee9af45.generate_attributes" + ], + "4999a6bc-4a88-4dd8-90ea-b3c266fe9a5c.value": [ + "e20ced99-ed5c-4df5-bce7-c2905835b583.value", + "8833b822-27a2-4cd5-b093-dfb54ee9af45.description" + ], + "12e31840-e862-409c-8e40-e05cb3f9f432.value": [ + "1409e867-ad0b-4e79-95d8-d69aaaac7a78.state" + ], + "3d1008e2-d2f9-4cd4-adc3-3bced9a979ca.value": [ + "1409e867-ad0b-4e79-95d8-d69aaaac7a78.state_b" + ], + "7dc4fd07-3221-4fe6-8cbe-5b0a91379300.value": [ + "1409e867-ad0b-4e79-95d8-d69aaaac7a78.state_c" + ], + "34139ec6-8089-4a29-a42f-3d06a9387f8e.value": [ + "6bed1dc2-9753-4850-a7e4-33893b20593d.base_attributes" + ], + "adf58774-bed7-4fd4-8203-713b7b2ea6cd.value": [ + "331324b5-8c8b-4c63-95bc-ce6c57265d31.value" + ], + "6bed1dc2-9753-4850-a7e4-33893b20593d.character": [ + "836f1ff3-d017-437f-9eff-a28214d3b7e2.value", + "331324b5-8c8b-4c63-95bc-ce6c57265d31.object", + "b4f6b977-54a3-4d28-9d0b-98c95e43051b.object" + ], + "6bed1dc2-9753-4850-a7e4-33893b20593d.actor": [ + "9f875159-9e90-41cc-84fe-d1b10061a885.value" + ], + "b206d7f4-7ecc-4aa1-a9b0-b34682eae813.value": [ + "b4f6b977-54a3-4d28-9d0b-98c95e43051b.value" + ], + "7ef6441d-c230-4e2d-a4f3-99616e4843f6.value": [ + "1409e867-ad0b-4e79-95d8-d69aaaac7a78.state_d" + ], + "c0e70bc5-75c2-4145-b65c-a866bebed641.value": [ + "8833b822-27a2-4cd5-b093-dfb54ee9af45.generation_options" + ], + "34e9ba42-7c82-4c52-8f2c-2fd5370d1fcf.value": [ + "28e76ef6-1c68-4783-ae8d-3fa3e869c7d8.value", + "8833b822-27a2-4cd5-b093-dfb54ee9af45.character_name" + ], + "95d2ff56-8568-42f9-ab54-54b83b262f4e.value": [ + "6aa5c32a-8dfb-48a9-96ec-5ad9ed6aa5d1.state_b" + ], + "bf1be608-be81-488e-8bdf-1f6f8430c918.value": [ + "6aa5c32a-8dfb-48a9-96ec-5ad9ed6aa5d1.state_c" + ], + "a7f4f8b9-c422-4b0c-a8ba-d2afa918e721.value": [ + "e97eeeeb-6dce-480d-8ca6-f3c5b409d2ef.value" + ], + "e97eeeeb-6dce-480d-8ca6-f3c5b409d2ef.value": [ + "95d2ff56-8568-42f9-ab54-54b83b262f4e.value" + ], + "018c3dbf-8445-409a-880f-a786896bb341.value": [ + "bf1be608-be81-488e-8bdf-1f6f8430c918.value" + ], + "7cf4d469-99f4-4ab7-bc37-9e9f1883c96b.value": [ + "8833b822-27a2-4cd5-b093-dfb54ee9af45.generate_dialogue_instructions" + ], + "d1914df2-f082-4f10-ab3c-9793bff3a953.value": [ + "8833b822-27a2-4cd5-b093-dfb54ee9af45.generate_example_dialogue" + ], + "8833b822-27a2-4cd5-b093-dfb54ee9af45.character_name": [ + "12e31840-e862-409c-8e40-e05cb3f9f432.value" + ], + "8833b822-27a2-4cd5-b093-dfb54ee9af45.description": [ + "3d1008e2-d2f9-4cd4-adc3-3bced9a979ca.value" + ], + "8833b822-27a2-4cd5-b093-dfb54ee9af45.attributes": [ + "7dc4fd07-3221-4fe6-8cbe-5b0a91379300.value" + ], + "8833b822-27a2-4cd5-b093-dfb54ee9af45.dialogue_instructions": [ + "7ef6441d-c230-4e2d-a4f3-99616e4843f6.value" + ], + "8833b822-27a2-4cd5-b093-dfb54ee9af45.example_dialogue": [ + "89a068c5-2a36-4292-a32a-7f2c3648320f.value" + ], + "6ea14928-0afe-46ec-b416-2b5d5e33010b.value": [ + "018c3dbf-8445-409a-880f-a786896bb341.value" + ], + "86b90606-a1f4-4670-ab4b-004078272585.value": [ + "a7f4f8b9-c422-4b0c-a8ba-d2afa918e721.value" + ], + "140e01a5-f7a5-4a60-b124-f7194bc5ff99.value": [ + "6ea14928-0afe-46ec-b416-2b5d5e33010b.value" + ], + "0d7d1898-f122-4803-8a87-5be7a6bd7b56.value": [ + "4bea833c-4ae6-4437-bfd1-bfebc0075256.value" + ], + "6cbc9260-f13b-42a4-baa6-f9b2506ea9e7.value": [ + "3cb38945-4f46-4200-b1ff-db2a69d66e07.value" + ], + "b8adab3f-3cd1-4406-ad3f-db4c80a910f7.value": [ + "9b2aeb2e-ca56-4594-a84f-48448ce568e5.value" + ], + "0b2f17c9-6182-425c-a26f-fdbc78ed3b1d.value": [ + "aa3c1a11-bdff-44a0-bc7b-a2a04eebccc4.value" + ], + "9ac9b12d-1b97-4f42-92d8-0d4f883ffb2f.value": [ + "f5e5ec03-cc12-4a45-aa15-6ca3e5e4bc85.value" ] }, "groups": [ { "title": "Process Arguments - Stage 0", - "x": 1, - "y": -490, - "width": 1432, - "height": 2216, + "x": 0, + "y": -968, + "width": 1659, + "height": 2659, "color": "#3f789e", "font_size": 24, "inherited": false }, { "title": "Validation - Stage 1", - "x": 2, - "y": 1734, + "x": 0, + "y": 1694, "width": 1443, "height": 434, "color": "#b58b2a", @@ -1905,30 +1842,10 @@ }, { "title": "Generate character name - Stage 2", - "x": 2, - "y": 2173, - "width": 2039, - "height": 741, - "color": "#8AA", - "font_size": 24, - "inherited": false - }, - { - "title": "Generate description - Stage 3", - "x": 1, - "y": 2918, - "width": 2040, - "height": 829, - "color": "#8AA", - "font_size": 24, - "inherited": false - }, - { - "title": "Generate attributes - Stage 4", - "x": 1, - "y": 3752, - "width": 1583, - "height": 817, + "x": 0, + "y": 2131, + "width": 2101, + "height": 1535, "color": "#8AA", "font_size": 24, "inherited": false @@ -1936,7 +1853,7 @@ { "title": "Make Character - Stage 5", "x": 0, - "y": 4576, + "y": 3669, "width": 1691, "height": 1061, "color": "#3f789e", @@ -1946,7 +1863,7 @@ { "title": "Outputs", "x": 0, - "y": 6080, + "y": 5173, "width": 595, "height": 472, "color": "#8A8", @@ -1956,7 +1873,7 @@ { "title": "Assign Voice - Stage 6", "x": 0, - "y": 5640, + "y": 4733, "width": 1120, "height": 437, "color": "#3f789e", diff --git a/src/talemate/agents/creator/nodes.py b/src/talemate/agents/creator/nodes.py index d1d6b488..c83394c6 100644 --- a/src/talemate/agents/creator/nodes.py +++ b/src/talemate/agents/creator/nodes.py @@ -9,6 +9,7 @@ from talemate.game.engine.nodes.core import ( ) from talemate.game.engine.nodes.registry import register from talemate.game.engine.nodes.agent import AgentSettingsNode, AgentNode +from talemate.agents.creator.character import CharacterGenerationRequest log = structlog.get_logger("talemate.game.engine.nodes.agents.creator") @@ -28,7 +29,18 @@ class CreatorSettings(AgentSettingsNode): @register("agents/creator/DetermineContentContext") class DetermineContentContext(AgentNode): """ - Determines the context for the content creation. + Determines a fitting content context label (genre / style descriptor) + for the given description, via the creator agent. + + Inputs: + + - state: The current state of the graph + - description: The description to determine the content context for + (required at runtime despite the optional socket) + + Outputs: + + - content_context: The determined content context """ _agent_name: ClassVar[str] = "creator" @@ -68,11 +80,11 @@ class DetermineCharacterDescription(AgentNode): - state: The current state of the graph - character: The character to determine the description for - - extra_context: Extra context to use in determining the + - extra_context: Extra context to use in determining the description Outputs: - - description: The determined description + - description: The determined description (empty if the model produced nothing) """ _agent_name: ClassVar[str] = "creator" @@ -204,7 +216,24 @@ class DetermineCharacterName(AgentNode): @register("agents/creator/DetermineCharacterDialogueInstructions") class DetermineCharacterDialogueInstructions(AgentNode): """ - Determines the dialogue instructions for a character. + Determines dialogue (acting) instructions for a character, via the + creator agent. The result is returned but not stored on the character + by this node. + + Inputs: + + - state: The current state of the graph + - character: The character to determine dialogue instructions for + - instructions: Additional instructions to guide the generation (optional) + - update_existing: Whether to base the result on the character's + existing dialogue instructions (optional) + + Outputs: + + - state: The state input, passed through + - character: The character, passed through + - dialogue_instructions: The determined dialogue instructions + - original: The character's dialogue instructions before generation """ _agent_name: ClassVar[str] = "creator" @@ -260,6 +289,187 @@ class DetermineCharacterDialogueInstructions(AgentNode): ) +@register("agents/creator/GenerateCharacter") +class GenerateCharacter(AgentNode): + """ + Generates character data through the creator agent's character + generation pipeline, following the agent's "Character Creation" + settings. + + With "Fast Character Generation" enabled, the aspects listed in the + agent's "Consolidate" setting are generated with a single + consolidated prompt (response budget from the "One-shot token budget" + setting; aspects the response misses are handled according to the + "Fill in misses" setting - individual follow-up requests or left + empty; a completely unparseable response raises an error). Remaining + aspects - or all of them, with Fast mode disabled - are generated + individually. + + This node only generates data - it does not add the character to the + scene. Wire the outputs into an `agents/director/PersistCharacter` node + to persist the character (disable `determine_name` on that node when + wiring a generated name, and wire `description` through as well, so + nothing is generated twice). + + Generating example dialogue requires a name: provide `character_name` + or enable `generate_name` - the node errors otherwise. + + Inputs: + + - state: The current state of the graph + - character_name: The current or descriptive character name (optional) + - instructions: Guiding instructions / content for the creation (optional) + - description: An existing description to use as context (optional) + - generation_options: Spice / writing style to shape the description (optional) + - generate_name: Whether to generate a name (optional) + - generate_description: Whether to generate a description (optional) + - generate_attributes: Whether to generate attributes (optional) + - generate_dialogue_instructions: Whether to generate dialogue instructions (optional) + - generate_example_dialogue: Whether to generate example dialogue lines (optional) + + Properties: + + - generate_name: Determine a fitting name + - generate_description: Generate the character description + - generate_attributes: Generate the character sheet (attributes) + - generate_dialogue_instructions: Generate dialogue (acting) instructions + - generate_example_dialogue: Generate example dialogue lines + + Outputs: + + - state: The state input, passed through + - character_name: The determined (or input) character name + - description: The generated (or input) description + - attributes: The generated attributes (empty if not generated) + - dialogue_instructions: The generated dialogue instructions (empty if not generated) + - example_dialogue: The generated example dialogue lines (empty if not generated) + """ + + _agent_name: ClassVar[str] = "creator" + + class Fields: + generate_name = PropertyField( + name="generate_name", + description="Determine a fitting character name", + type="bool", + default=False, + ) + generate_description = PropertyField( + name="generate_description", + description="Generate the character description", + type="bool", + default=True, + ) + generate_attributes = PropertyField( + name="generate_attributes", + description="Generate the character sheet (attributes)", + type="bool", + default=True, + ) + generate_dialogue_instructions = PropertyField( + name="generate_dialogue_instructions", + description="Generate dialogue (acting) instructions", + type="bool", + default=True, + ) + generate_example_dialogue = PropertyField( + name="generate_example_dialogue", + description="Generate example dialogue lines", + type="bool", + default=False, + ) + + def __init__(self, title="Generate Character", **kwargs): + super().__init__(title=title, **kwargs) + + def setup(self): + self.add_input("state") + self.add_input("character_name", socket_type="str", optional=True) + self.add_input("instructions", socket_type="str", optional=True) + self.add_input("description", socket_type="str", optional=True) + self.add_input( + "generation_options", socket_type="generation_options", optional=True + ) + self.add_input("generate_name", socket_type="bool", optional=True) + self.add_input("generate_description", socket_type="bool", optional=True) + self.add_input("generate_attributes", socket_type="bool", optional=True) + self.add_input( + "generate_dialogue_instructions", socket_type="bool", optional=True + ) + self.add_input("generate_example_dialogue", socket_type="bool", optional=True) + + self.set_property("generate_name", False) + self.set_property("generate_description", True) + self.set_property("generate_attributes", True) + self.set_property("generate_dialogue_instructions", True) + self.set_property("generate_example_dialogue", False) + + self.add_output("state", socket_type="any") + self.add_output("character_name", socket_type="str") + self.add_output("description", socket_type="str") + self.add_output("attributes", socket_type="dict") + self.add_output("dialogue_instructions", socket_type="str") + self.add_output("example_dialogue", socket_type="list") + + async def run(self, state: GraphState): + character_name = self.normalized_input_value("character_name") or "" + instructions = self.normalized_input_value("instructions") or "" + description = self.normalized_input_value("description") or "" + generation_options = self.normalized_input_value("generation_options") + + aspects = [] + if self.normalized_input_value("generate_name"): + aspects.append("name") + if self.normalized_input_value("generate_description"): + aspects.append("description") + if self.normalized_input_value("generate_attributes"): + aspects.append("attributes") + if self.normalized_input_value("generate_dialogue_instructions"): + aspects.append("dialogue_instructions") + if self.normalized_input_value("generate_example_dialogue"): + aspects.append("example_dialogue") + + if not aspects: + raise InputValueError( + self, "generate_description", "No aspects selected for generation" + ) + + if ( + "example_dialogue" in aspects + and "name" not in aspects + and not character_name + ): + raise InputValueError( + self, + "character_name", + "Example dialogue generation needs a name - provide " + "character_name or enable generate_name", + ) + + result = await self.agent.generate_character_aspects( + CharacterGenerationRequest( + aspects=aspects, + name=character_name, + content=instructions, + description=description, + generation_options=generation_options, + ) + ) + + self.set_output_values( + { + "state": state, + "character_name": result.name or character_name, + # an aspect that wasn't generated passes its input through, so + # downstream state writes don't blank a supplied value + "description": result.description or description, + "attributes": result.attributes or {}, + "dialogue_instructions": result.dialogue_instructions or "", + "example_dialogue": result.example_dialogue or [], + } + ) + + @register("agents/creator/ContextualGenerate") class ContextualGenerate(AgentNode): """ @@ -293,6 +503,11 @@ class ContextualGenerate(AgentNode): - state: The updated state of the graph - text: The generated text + - character: The character object resolved from the character input + - context_type: The context type, passed through + - context_name: The context name, passed through + - instructions: The instructions, passed through + - original: The original text, passed through """ _agent_name: ClassVar[str] = "creator" diff --git a/src/talemate/agents/creator/response_specs.py b/src/talemate/agents/creator/response_specs.py index 10b5b336..06c08199 100644 --- a/src/talemate/agents/creator/response_specs.py +++ b/src/talemate/agents/creator/response_specs.py @@ -15,6 +15,7 @@ __all__ = [ "COMPLETION_SPEC", ] + # For ... extraction (used in generate_title) TITLE_SPEC = ResponseSpec( extractors={ diff --git a/src/talemate/agents/director/action_core/utils.py b/src/talemate/agents/director/action_core/utils.py index 04cda88c..d6d90e01 100644 --- a/src/talemate/agents/director/action_core/utils.py +++ b/src/talemate/agents/director/action_core/utils.py @@ -319,43 +319,25 @@ def reverse_trim_history( budget_tokens: int, ) -> list[Any]: """ - Reverse-trim history to fit within a token budget. - Walk from the end, include items until token count exceeds budget. - Returns items in chronological order. + Reverse-trim history to fit within a token budget, counting director + message types (action results, user interactions) by their real payload. """ + + def _count_item_tokens(message: Any) -> int: + if message.type == "action_result": + name_text = message.name or "" + instr_text = message.instructions or "" + try: + result_text = json.dumps(message.result, default=str) + except Exception: + result_text = str(message.result) + return util.count_tokens("\n".join([name_text, instr_text, result_text])) + elif message.type == "user_interaction": + return util.count_tokens(message.user_input or "") + return util.count_tokens(message.message or "") + try: - if not history or budget_tokens <= 0: - return [] - - def _count_item_tokens(message: Any) -> int: - if message.type == "action_result": - name_text = message.name or "" - instr_text = message.instructions or "" - try: - result_text = json.dumps(message.result, default=str) - except Exception: - result_text = str(message.result) - return util.count_tokens( - "\n".join([name_text, instr_text, result_text]) - ) - elif message.type == "user_interaction": - return util.count_tokens(message.user_input or "") - return util.count_tokens(message.message or "") - - selected_indices: list[int] = [] - total_tokens = 0 - - for i in range(len(history) - 1, -1, -1): - t = _count_item_tokens(history[i]) - if total_tokens + t <= budget_tokens: - selected_indices.append(i) - total_tokens += t - else: - break - - if not selected_indices: - return [] - return [history[i] for i in reversed(selected_indices)] + return util.reverse_trim_history(history, budget_tokens, _count_item_tokens) except Exception as e: log.error("action_core.reverse_trim_history.error", error=e) return [history[-1]] if history else [] diff --git a/src/talemate/agents/director/auto_direct.py b/src/talemate/agents/director/auto_direct.py index 1a376add..22a2b4db 100644 --- a/src/talemate/agents/director/auto_direct.py +++ b/src/talemate/agents/director/auto_direct.py @@ -317,28 +317,32 @@ class AutoDirectMixin: self, instructions: str, max_scene_types: int = 1, - ): + ) -> list[SceneType]: world_state_manager: WorldStateManager = self.scene.world_state_manager scene_type_templates: TypedCollection = await world_state_manager.get_templates( types=["scene_type"] ) - async def add_from_template(id: str) -> SceneType: + generated: list[SceneType] = [] + + async def add_from_template(id: str) -> SceneType | None: template: TemplateSceneType | None = scene_type_templates.find_by_name(id) if not template: log.warning( "auto_direct_generate_scene_types: Template not found.", name=id ) return None - return template.apply_to_scene(self.scene) + scene_type = template.apply_to_scene(self.scene) + generated.append(scene_type) + return scene_type async def generate_scene_type( id: str = None, name: str = None, description: str = None, instructions: str = None, - ) -> SceneType: + ) -> SceneType | None: if not id or not name: return None @@ -351,6 +355,8 @@ class AutoDirectMixin: self.scene.intent_state.scene_types[id] = scene_type + generated.append(scene_type) + return scene_type focal_handler = focal.Focal( @@ -386,3 +392,5 @@ class AutoDirectMixin: await focal_handler.request( "director.generate-scene-types", ) + + return generated diff --git a/src/talemate/agents/director/auto_direct_nodes.py b/src/talemate/agents/director/auto_direct_nodes.py index bb26cf8d..3b67ae1e 100644 --- a/src/talemate/agents/director/auto_direct_nodes.py +++ b/src/talemate/agents/director/auto_direct_nodes.py @@ -33,7 +33,19 @@ class AutoDirectCandidates(AgentNode): @register("agents/director/auto-direct/DetermineSceneIntent") class DetermineSceneIntent(AgentNode): """ - Determines the scene intent based on the current scene state. + Has the director determine the current scene intent and set it on the + scene's intent state (the director may also decide to leave the + current intent unchanged). + + Inputs: + + - state: The graph state + + Outputs: + + - state: The state input, passed through + - scene_phase: The scene's phase after the determination (None if the + director decided not to set one) """ _agent_name: ClassVar[str] = "director" @@ -55,7 +67,19 @@ class DetermineSceneIntent(AgentNode): @register("agents/director/auto-direct/GenerateSceneTypes") class GenerateSceneTypes(AgentNode): """ - Generates scene types based on the current scene state. + Has the director generate scene types (or pick them from the scene + type templates) and register them on the scene's intent state. + + Inputs: + + - state: The graph state + - instructions: Instructions guiding the scene type generation (optional) + - max_scene_types: The maximum number of scene types to generate (optional) + + Outputs: + + - state: The state input, passed through + - scene_types: The generated scene types """ _agent_name: ClassVar[str] = "director" @@ -85,6 +109,7 @@ class GenerateSceneTypes(AgentNode): self.set_property("instructions", "") self.set_property("max_scene_types", 1) self.add_output("state") + self.add_output("scene_types", socket_type="list") async def run(self, state: GraphState): instructions = self.normalized_input_value("instructions") diff --git a/src/talemate/agents/director/character_management.py b/src/talemate/agents/director/character_management.py index 7fcdf7bc..50b0280a 100644 --- a/src/talemate/agents/director/character_management.py +++ b/src/talemate/agents/director/character_management.py @@ -1,11 +1,18 @@ from typing import TYPE_CHECKING import traceback +import pydantic import structlog import talemate.instance as instance import talemate.agents.tts.voice_library as voice_library from talemate.agents.tts.schema import Voice -from talemate.util import random_color, chunk_items_by_tokens, remove_substring_names -from talemate.character import set_voice, activate_character +from talemate.util import ( + random_color, + chunk_items_by_tokens, + remove_substring_names, + replace_smart_quotes, +) +from talemate.util.data import trim_attributes +from talemate.character import Character, set_voice, activate_character from talemate.status import LoadingStatus from talemate.exceptions import GenerationCancelled from talemate.agents.base import ( @@ -14,10 +21,19 @@ from talemate.agents.base import ( set_processing, AgentEmission, ) +from talemate.agents.creator.character import ( + CharacterGenerationAspect, + CharacterGenerationRequest, + CharacterGenerationResult, +) import talemate.game.focal as focal from talemate.client.context import ClientContext import talemate.emit.async_signals as async_signals +if TYPE_CHECKING: + from talemate import Scene + from talemate.agents.tts import TTSAgent + async_signals.register( "agent.director.character_management.before_persist_character", "agent.director.character_management.after_persist_character", @@ -29,19 +45,139 @@ __all__ = [ log = structlog.get_logger() -if TYPE_CHECKING: - from talemate import Character, Scene - from talemate.agents.tts import TTSAgent +PERSIST_CHARACTER_EXAMPLE_DIALOGUE_COUNT = 3 + +NAME_REQUIRED_MESSAGE = ( + "A character name is required - none was provided or determined." +) + +# per-aspect loading status messages for split-mode generation +SPLIT_ASPECT_LOADING_MESSAGES: dict[CharacterGenerationAspect, str] = { + "name": "Determining character name", + "attributes": "Generating character sheet", + "description": "Generating character description", + "dialogue_instructions": "Generating acting instructions", + "example_dialogue": "Generating example dialogue", +} + + +def _requested_aspects( + request: "PersistCharacterRequest", + *, + description: str, + dialogue_instructions: str, + example_dialogue: list[str] | None, + any_attribute_templates: bool, + include_name: bool = False, + attributes_first: bool = False, +) -> list[CharacterGenerationAspect]: + """Aspects still needing generation - shared by the fast and split + paths. The name aspect only exists before the character is created + (fast mode); split mode puts the sheet first so the later prompts + render it.""" + aspects = [] + if include_name and request.determine_name: + aspects.append("name") + if not description: + aspects.append("description") + if ( + request.generate_attributes + and not request.attributes + and not any_attribute_templates + ): + aspects.append("attributes") + if not dialogue_instructions: + aspects.append("dialogue_instructions") + if request.generate_example_dialogue and not example_dialogue: + aspects.append("example_dialogue") + if attributes_first and "attributes" in aspects: + aspects.insert(0, aspects.pop(aspects.index("attributes"))) + return aspects + + +def _merge_generated( + generated: CharacterGenerationResult, + *, + description: str, + dialogue_instructions: str, + example_dialogue: list[str] | None, +) -> tuple[str, str, list[str] | None]: + """Fold generated aspects into the caller-supplied values (generated + wins) - shared by the fast and split paths.""" + description = generated.description or description + if generated.dialogue_instructions: + dialogue_instructions = generated.dialogue_instructions + if generated.example_dialogue: + example_dialogue = generated.example_dialogue + return description, dialogue_instructions, example_dialogue + + +class _FastPreparation(pydantic.BaseModel): + """Outcome of the Fast-mode pre-generation step of persist_character.""" + + name: str + description: str + dialogue_instructions: str + example_dialogue: list[str] | None + generated: CharacterGenerationResult | None + collected_templates: dict | None + + +class _SplitPreparation(pydantic.BaseModel): + """Outcome of the split-mode aspect-generation step of + persist_character.""" + + description: str + dialogue_instructions: str + example_dialogue: list[str] | None + generated: CharacterGenerationResult | None class PersistCharacterEmission(AgentEmission): - character: "Character" + character: Character class VoiceCandidate(Voice): used: bool = False +class PersistCharacterRequest(pydantic.BaseModel): + """Request parameters for persist_character. + + Attributes: + generate: Master switch for AI generation. When False, no LLM + calls are made at all: the character is created with the given + name (required), description and attributes. Voice assignment + and entry narration are skipped as well (both are AI features). + dialogue_instructions: Pre-generated dialogue instructions (e.g. + from the creator GenerateCharacter node) - skips the + corresponding generation step. + example_dialogue: Pre-generated example dialogue - skips the + corresponding generation step. + """ + + model_config = pydantic.ConfigDict(extra="forbid") + + name: str + content: str | None = None + attributes: str | None = None + determine_name: bool = True + templates: list[str] | None = None + active: bool = True + narrate_entry: bool = False + narrate_entry_direction: str = "" + augment_attributes: str = "" + generate_attributes: bool = True + description: str = "" + assign_voice: bool = True + is_player: bool = False + generate_example_dialogue: bool = False + example_dialogue_instructions: str = "" + generate: bool = True + dialogue_instructions: str = "" + example_dialogue: list[str] | None = None + + class CharacterManagementMixin: """ Director agent mixin that provides functionality for automatically guiding @@ -75,7 +211,7 @@ class CharacterManagementMixin: "max_attributes": AgentActionConfig( type="number", label="Limit character attributes", - description="Maximum number of attributes to generate for character sheets. Set to 0 for unlimited (default).", + description="Maximum number of attributes to generate for character sheets, not counting the character's name. Set to 0 for unlimited (default).", value=0, min=0, max=40, @@ -118,7 +254,7 @@ class CharacterManagementMixin: @set_processing async def persist_characters_from_worldstate( self, exclude: list[str] = None - ) -> list["Character"]: + ) -> list[Character]: created_characters = [] for character_name in self.scene.world_state.characters.keys(): @@ -128,7 +264,9 @@ class CharacterManagementMixin: if character_name in self.scene.character_names: continue - character = await self.persist_character(name=character_name) + character = await self.persist_character( + PersistCharacterRequest(name=character_name) + ) created_characters.append(character) @@ -136,46 +274,283 @@ class CharacterManagementMixin: return created_characters - @set_processing - async def persist_character( + async def _prepare_fast_generation( self, - name: str, - content: str = None, - attributes: str = None, - determine_name: bool = True, - templates: list[str] = None, - active: bool = True, - narrate_entry: bool = False, - narrate_entry_direction: str = "", - augment_attributes: str = "", - generate_attributes: bool = True, - description: str = "", - assign_voice: bool = True, - is_player: bool = False, - ) -> "Character": + request: PersistCharacterRequest, + *, + max_attrs: int | None, + loading_status: LoadingStatus, + ) -> _FastPreparation: + """Fast (consolidated) generation for persist_character. + + Runs before the character exists - the determined name is needed to + create it. Also pre-collects generation templates so attribute + templates can be folded into the attributes aspect (fold on) or + suppress it (fold off). + """ + creator = instance.get_agent("creator") + scene: "Scene" = self.scene + + name = request.name + description = request.description + dialogue_instructions = request.dialogue_instructions + example_dialogue = request.example_dialogue + content = request.content or "" + + collected_templates = None + any_attribute_templates = False + if request.templates: + collected_templates = ( + scene.world_state_manager.template_collection.collect_all( + request.templates + ) + ) + any_attribute_templates = any( + template.template_type == "character_attribute" + for template in collected_templates.values() + ) + + # whether the attributes aspect can be consolidated at all (the + # template fold below decides whether attribute templates suppress + # it or feed it their instructions) + can_one_shot_attributes = request.generate_attributes and not request.attributes + + # fold the attribute templates' instructions into the one-shot + # instead of one prompt per template - only when the attributes + # aspect can actually be consolidated; otherwise the templates apply + # per-template as before. placeholders in the template text are + # formatted with the input name (or "the character" while the name + # is still being determined) + attribute_instructions = None + if ( + any_attribute_templates + and creator.cc_consolidate_templates + and "attributes" in creator.cc_consolidate + and can_one_shot_attributes + ): + any_attribute_templates = False + format_name = "the character" if request.determine_name else name + attribute_instructions = [ + { + "attribute": template.formatted("attribute", scene, format_name), + "instructions": template.formatted( + "instructions", scene, format_name + ) + or "", + } + for template in collected_templates.values() + if template.template_type == "character_attribute" + ] + collected_templates = { + uid: template + for uid, template in collected_templates.items() + if template.template_type != "character_attribute" + } + + fast_aspects = _requested_aspects( + request, + description=description, + dialogue_instructions=dialogue_instructions, + example_dialogue=example_dialogue, + any_attribute_templates=any_attribute_templates, + include_name=True, + ) + + generated = None + if fast_aspects: + loading_status("Generating character") + generated = await creator.generate_character_aspects( + CharacterGenerationRequest( + aspects=fast_aspects, + name=name, + content=content, + description=description, + example_dialogue_instructions=request.example_dialogue_instructions, + max_examples=PERSIST_CHARACTER_EXAMPLE_DIALOGUE_COUNT, + max_attributes=max_attrs, + attribute_instructions=attribute_instructions, + augment_attributes=request.augment_attributes + if attribute_instructions + else "", + ) + ) + if request.determine_name and generated.name: + name = generated.name + log.debug("persist_character", adjusted_name=name) + description, dialogue_instructions, example_dialogue = _merge_generated( + generated, + description=description, + dialogue_instructions=dialogue_instructions, + example_dialogue=example_dialogue, + ) + + # a name is existential - if the one-shot missed it, try the + # individual request before the caller's name guard rejects it + if request.determine_name and not name: + loading_status(SPLIT_ASPECT_LOADING_MESSAGES["name"]) + name = await creator.determine_character_name(name, instructions=content) + log.debug("persist_character", adjusted_name=name) + + return _FastPreparation( + name=name, + description=description, + dialogue_instructions=dialogue_instructions, + example_dialogue=example_dialogue, + generated=generated, + collected_templates=collected_templates, + ) + + async def _determine_split_name( + self, + request: PersistCharacterRequest, + *, + loading_status: LoadingStatus, + ) -> str: + """Split mode: determine the name with its individual request - runs + before the character exists, the name is needed to create it.""" + creator = instance.get_agent("creator") + loading_status(SPLIT_ASPECT_LOADING_MESSAGES["name"]) + result = await creator.generate_character_aspects( + CharacterGenerationRequest( + aspects=["name"], + name=request.name, + content=request.content or "", + unified=False, + ) + ) + log.debug("persist_character", adjusted_name=result.name) + return result.name + + async def _prepare_split_generation( + self, + request: PersistCharacterRequest, + *, + character: Character, + any_attribute_templates: bool, + max_attrs: int | None, + loading_status: LoadingStatus, + ) -> _SplitPreparation: + """Split (per-aspect) generation for persist_character - runs after + the character exists and its sheet was resolved.""" + creator = instance.get_agent("creator") + + description = request.description + dialogue_instructions = request.dialogue_instructions + example_dialogue = request.example_dialogue + + aspects = _requested_aspects( + request, + description=description, + dialogue_instructions=dialogue_instructions, + example_dialogue=example_dialogue, + any_attribute_templates=any_attribute_templates, + attributes_first=True, + ) + + generated = None + if aspects: + generated = await creator.generate_character_aspects( + CharacterGenerationRequest( + aspects=aspects, + name=character.name, + content=request.content or "", + character=character, + unified=False, + example_dialogue_instructions=request.example_dialogue_instructions, + max_examples=PERSIST_CHARACTER_EXAMPLE_DIALOGUE_COUNT, + max_attributes=max_attrs, + on_aspect_start=lambda aspect: loading_status( + SPLIT_ASPECT_LOADING_MESSAGES[aspect] + ), + ) + ) + description, dialogue_instructions, example_dialogue = _merge_generated( + generated, + description=description, + dialogue_instructions=dialogue_instructions, + example_dialogue=example_dialogue, + ) + + return _SplitPreparation( + description=description, + dialogue_instructions=dialogue_instructions, + example_dialogue=example_dialogue, + generated=generated, + ) + + @set_processing + async def persist_character(self, request: PersistCharacterRequest) -> Character: + """ + Persist a character into the scene - the single backend process all + character creation paths route through. + + When the creator agent's "Character Creation -> Fast Character + Generation" setting is enabled, all applicable generation steps are + consolidated into a single prompt (see + creator.generate_character_aspects); otherwise each aspect is + generated with its individual request via the same orchestrator + (unified=False). + + Args: + request: The creation request (see PersistCharacterRequest). + """ world_state = instance.get_agent("world_state") creator = instance.get_agent("creator") narrator = instance.get_agent("narrator") memory = instance.get_agent("memory") scene: "Scene" = self.scene - any_attribute_templates = False loading_status = LoadingStatus(max_steps=None, cancellable=True) # Start of character creation - log.debug("persist_character", name=name) + log.debug("persist_character", name=request.name, generate=request.generate) - # Determine the character's name (or clarify if it's already set) - if determine_name: - loading_status("Determining character name") - name = await creator.determine_character_name(name, instructions=content) - log.debug("persist_character", adjusted_name=name) + # a name is existential - fail before any LLM call when no name was + # provided and none will be determined + if not request.name and not (request.generate and request.determine_name): + raise ValueError(NAME_REQUIRED_MESSAGE) + + fast = request.generate and creator.cc_fast + max_attrs = self.cm_max_attributes if self.cm_max_attributes > 0 else None + + name = request.name + description = request.description + dialogue_instructions = request.dialogue_instructions + example_dialogue = request.example_dialogue + + generated = None + collected_templates = None + if fast: + preparation = await self._prepare_fast_generation( + request, + max_attrs=max_attrs, + loading_status=loading_status, + ) + name = preparation.name + description = preparation.description + dialogue_instructions = preparation.dialogue_instructions + example_dialogue = preparation.example_dialogue + generated = preparation.generated + collected_templates = preparation.collected_templates + elif request.generate and request.determine_name: + name = await self._determine_split_name( + request, loading_status=loading_status + ) + + # a name is existential - regardless of mode and flag combination + if not name: + raise ValueError(NAME_REQUIRED_MESSAGE) if name in self.scene.all_character_names: raise ValueError(f'Name "{name}" already exists.') # Create the blank character - character: "Character" = self.scene.Character(name=name, is_player=is_player) + character: Character = self.scene.Character( + name=name, is_player=request.is_player + ) + + if description: + character.description = description emission = PersistCharacterEmission( agent=self, @@ -188,7 +563,7 @@ class CharacterManagementMixin: # Add the character to the scene character.color = random_color() - if is_player: + if request.is_player: actor = self.scene.Player( character=character, agent=instance.get_agent("conversation") ) @@ -200,106 +575,85 @@ class CharacterManagementMixin: await self.scene.add_actor(actor) try: - # Apply any character generation templates - if templates: - loading_status("Applying character generation templates") - templates = scene.world_state_manager.template_collection.collect_all( - templates - ) - log.debug("persist_character", applying_templates=templates) - await scene.world_state_manager.apply_templates( - templates.values(), - character_name=character.name, - information=content, - ) + any_attribute_templates = await self._apply_generation_templates( + character, + request, + collected_templates=collected_templates, + max_attrs=max_attrs, + loading_status=loading_status, + ) - # if any of the templates are attribute templates, then we no longer need to - # generate a character sheet - any_attribute_templates = any( - template.template_type == "character_attribute" - for template in templates.values() - ) - log.debug( - "persist_character", any_attribute_templates=any_attribute_templates - ) - - if ( - any_attribute_templates - and augment_attributes - and generate_attributes - ): - log.debug( - "persist_character", augmenting_attributes=augment_attributes - ) - loading_status("Augmenting character attributes") - max_attrs = ( - self.cm_max_attributes if self.cm_max_attributes > 0 else None - ) - additional_attributes = await world_state.extract_character_sheet( - name=name, - text=content, - augmentation_instructions=augment_attributes, - max_attributes=max_attrs, - ) - character.base_attributes.update(additional_attributes) - - # Generate a character sheet if there are no attribute templates - if not any_attribute_templates and generate_attributes: - loading_status("Generating character sheet") - log.debug("persist_character", extracting_character_sheet=True) - max_attrs = ( - self.cm_max_attributes if self.cm_max_attributes > 0 else None - ) - if not attributes: - attributes = await world_state.extract_character_sheet( - name=name, text=content, max_attributes=max_attrs - ) - else: - attributes = world_state._parse_character_sheet( - attributes, max_attributes=max_attrs - ) - - log.debug("persist_character", attributes=attributes) - character.base_attributes = attributes - - # Enforce max_attributes limit on final base_attributes if configured + # caller-provided attributes are resolved onto the character + # before aspect generation - the downstream prompts render the + # sheet if ( - self.cm_max_attributes > 0 - and len(character.base_attributes) > self.cm_max_attributes + not any_attribute_templates + and request.generate_attributes + and request.attributes ): - # Keep only the first N attributes (preserving insertion order) - limited_attrs = dict( - list(character.base_attributes.items())[: self.cm_max_attributes] + character.base_attributes = world_state._parse_character_sheet( + request.attributes, max_attributes=max_attrs ) - log.debug( - "persist_character", - limiting_attributes=True, - original_count=len(character.base_attributes), - limited_count=len(limited_attrs), - ) - character.base_attributes = limited_attrs - # Generate a description for the character - if not description: - loading_status("Generating character description") - description = await creator.determine_character_description( - character, information=content + # Enforce max_attributes limit on base_attributes if configured - + # before aspect generation, so the downstream prompts render the + # truncated sheet. Same budget rule as the generated sheet: the + # character's own name does not cost a slot. + if max_attrs: + limited_attrs = trim_attributes( + character.base_attributes, max_attributes=max_attrs ) + if len(limited_attrs) < len(character.base_attributes): + log.debug( + "persist_character", + limiting_attributes=True, + original_count=len(character.base_attributes), + limited_count=len(limited_attrs), + ) + character.base_attributes = limited_attrs + + if not fast and request.generate: + split = await self._prepare_split_generation( + request, + character=character, + any_attribute_templates=any_attribute_templates, + max_attrs=max_attrs, + loading_status=loading_status, + ) + description = split.description + dialogue_instructions = split.dialogue_instructions + example_dialogue = split.example_dialogue + generated = split.generated + + self._apply_base_attributes( + character, + request, + generated=generated, + any_attribute_templates=any_attribute_templates, + ) + + if description: character.description = description log.debug("persist_character", description=description) - # Generate a dialogue instructions for the character - loading_status("Generating acting instructions") - dialogue_instructions = ( - await creator.determine_character_dialogue_instructions( - character, information=content + if dialogue_instructions: + character.dialogue_instructions = dialogue_instructions + log.debug( + "persist_character", dialogue_instructions=dialogue_instructions ) - ) - character.dialogue_instructions = dialogue_instructions - log.debug("persist_character", dialogue_instructions=dialogue_instructions) + + if example_dialogue: + # generated examples arrive already normalized from the + # creator; pre-supplied ones (node graph literals, ws payload) + # skip generation, so this is where they get cleaned + example_dialogue = [ + replace_smart_quotes(example) for example in example_dialogue + ] + character.example_dialogue = example_dialogue + log.debug("persist_character", example_dialogue=example_dialogue) # Narrate the character's entry if the option is selected - if active and narrate_entry: + if request.generate and request.active and request.narrate_entry: loading_status("Narrating character entry") is_present = await world_state.is_character_present(name) if not is_present: @@ -307,14 +661,18 @@ class CharacterManagementMixin: "narrate_character_entry", emit_message=True, character=character, - narrative_direction=narrate_entry_direction, + narrative_direction=request.narrate_entry_direction, ) - if assign_voice: + if request.generate and request.assign_voice: await self.assign_voice_to_character(character) + # done() no-ops at step 0 - every mode needs one step to report + # a terminal status + loading_status("Adding character to scene") + # Deactivate the character if not active - if active: + if request.active: await activate_character(scene, character) # Commit the character's details to long term memory @@ -339,10 +697,89 @@ class CharacterManagementMixin: await scene.remove_actor(actor) log.error("Error persisting character", error=traceback.format_exc()) + async def _apply_generation_templates( + self, + character: Character, + request: PersistCharacterRequest, + *, + collected_templates: dict | None, + max_attrs: int | None, + loading_status: LoadingStatus, + ) -> bool: + """Apply the request's character generation templates (AI generation + only - template values are LLM-generated, so manual mode skips them), + augmenting the sheet from the content when instructed. + + Returns whether any attribute templates were applied - those suppress + character sheet generation. Fast mode's template fold removes the + folded templates from the collection beforehand, so the same + computation holds for both modes. + """ + if not (request.templates and request.generate): + return False + + world_state = instance.get_agent("world_state") + scene: "Scene" = self.scene + + loading_status("Applying character generation templates") + if collected_templates is None: + collected_templates = ( + scene.world_state_manager.template_collection.collect_all( + request.templates + ) + ) + log.debug("persist_character", applying_templates=collected_templates) + await scene.world_state_manager.apply_templates( + collected_templates.values(), + character_name=character.name, + information=request.content, + ) + + # if any of the templates are attribute templates, then we no longer + # need to generate a character sheet + any_attribute_templates = any( + template.template_type == "character_attribute" + for template in collected_templates.values() + ) + log.debug("persist_character", any_attribute_templates=any_attribute_templates) + + if ( + any_attribute_templates + and request.augment_attributes + and request.generate_attributes + ): + log.debug( + "persist_character", augmenting_attributes=request.augment_attributes + ) + loading_status("Augmenting character attributes") + additional_attributes = await world_state.extract_character_sheet( + name=character.name, + text=request.content, + augmentation_instructions=request.augment_attributes, + max_attributes=max_attrs, + ) + character.base_attributes.update(additional_attributes) + + return any_attribute_templates + + def _apply_base_attributes( + self, + character: Character, + request: PersistCharacterRequest, + *, + generated: CharacterGenerationResult | None, + any_attribute_templates: bool, + ) -> None: + """Apply the orchestrator's generated attributes (fast one-shot or + split-mode individual request).""" + if any_attribute_templates or not request.generate_attributes: + return + + if generated and generated.attributes: + character.base_attributes = generated.attributes + @set_processing - async def assign_voice_to_character( - self, character: "Character" - ) -> list[focal.Call]: + async def assign_voice_to_character(self, character: Character) -> list[focal.Call]: tts_agent: "TTSAgent" = instance.get_agent("tts") if not self.cm_should_assign_voice: log.debug("assign_voice_to_character", skip=True, reason="not enabled") diff --git a/src/talemate/agents/director/chat/mixin.py b/src/talemate/agents/director/chat/mixin.py index d75b007e..e77c3029 100644 --- a/src/talemate/agents/director/chat/mixin.py +++ b/src/talemate/agents/director/chat/mixin.py @@ -13,11 +13,10 @@ from talemate.agents.director.action_core.exceptions import ( UnknownAction, ) -from talemate.prompts import Prompt +from talemate.agents.chat_title import generate_chat_title from talemate.agents.director.plan.util import get_plan, cleanup_orphaned_plans from .context import director_chat_context -from .response_specs import CHAT_TITLE_SPEC from .schema import ( DirectorChat, DirectorChatMessage, @@ -832,23 +831,8 @@ class DirectorChatMixin: chat_excerpt = "\n".join(excerpt_parts) - response, extracted = await Prompt.request( - "director.chat-title", - self.client, - "create_92", - vars={"chat_excerpt": chat_excerpt}, - response_spec=CHAT_TITLE_SPEC, - ) - - title = extracted.get("title") - if not title: - title = response.strip() if response else None - + title = await generate_chat_title(self.client, chat_excerpt, "an AI director") if title: - # Clean up and truncate - title = title.strip().strip("\"'") - if len(title) > 60: - title = title[:57] + "..." self.chat_update_title(chat_id, title) return title diff --git a/src/talemate/agents/director/chat/nodes.py b/src/talemate/agents/director/chat/nodes.py index a3b7862b..5cbc45aa 100644 --- a/src/talemate/agents/director/chat/nodes.py +++ b/src/talemate/agents/director/chat/nodes.py @@ -94,6 +94,44 @@ class DirectorChatAction(Function): @register("agents/director/chat/DirectorChatSubAction") class DirectorChatSubAction(Node): + """ + Declares a sub-action inside a DirectorChatAction graph. Its properties + describe one concrete operation the director can perform and are extracted + statically (without running the graph) to advertise the sub-action in chat + and scene direction prompts and in the enable/disable UI. At runtime the + node acts as a gate: it evaluates the optional condition function and the + gating rules (availability vs. current mode, force_enabled, per-scene + disabled list) and only passes state through when the sub-action is + enabled - otherwise the state output stays unresolved and the downstream + branch is skipped. + + Inputs: + + - state: State to pass through when the sub-action is enabled + - condition: Optional function; if it returns falsy the sub-action is + neither advertised nor executed + + Outputs: + + - state: The state input, passed through when the sub-action is enabled; + stays unresolved when the sub-action is gated off + + Properties: + + - group: Group label used to organize sub-actions in prompts and the UI + - action_title: Human readable title of the sub-action + - action_id: Unique id of the sub-action (required); used for gating and + the per-scene disabled list + - instruction_examples: Example instructions shown to the director to + illustrate how to invoke the sub-action + - description_chat: Description shown to the director in chat mode + - description_scene_direction: Description shown to the director in scene + direction mode (each description falls back to the other when unset) + - availability: Which modes the sub-action is available in (both, chat or + scene_direction) + - force_enabled: If true, users cannot disable this sub-action + """ + class Fields: group = PropertyField( name="group", @@ -253,7 +291,20 @@ class DirectorChatSubAction(Node): @register("agents/director/chat/ActionArgument") class DirectorChatActionArgument(FocalArgument): """ - A argument is a node that can be used as an argument to a director chat action + Declares an argument for a director chat action. Place it inside a + DirectorChatAction graph to define one named, typed argument the + director can pass when invoking the action; the instructions describe + the argument to the director in the action prompt. + + Properties: + + - name: The name of the argument + - typ: The type of the argument (str, int, float, bool, list, any) + - instructions: Description of the argument shown to the director + + Outputs: + + - value: The argument's value (available while the action executes) """ @pydantic.computed_field(description="Node style") @@ -273,9 +324,35 @@ class DirectorChatActionArgument(FocalArgument): @register("agents/director/chat/ActionConfirm") class DirectorChatActionConfirm(Node): """ - If the is a chat context active that requires confirmation for write - actions, this node will block the further execution of the node graph - and send a signal to the frontend to collect the confirmation from the user (or reject the action) + Asks the user to confirm a director action before it proceeds. Only + active when the current director chat context requires confirmation + for write actions - otherwise the state passes straight through as + accepted. When active, the node emits a confirmation request to the + frontend and blocks until the user responds, the scene ends, or the + configured timeout is reached (timeouts and scene shutdown count as + rejections). + + On acceptance the state is passed to the `accepted` output; on + rejection it is passed to the `rejected` output instead - unless + raise_on_reject is set (the default), in which case an ActionRejected + error is raised and aborts the action. + + Inputs: + + - state: The state to gate on user confirmation + - name: The name of the action to confirm (optional) + - description: The description of the action to confirm (optional) + + Properties: + + - raise_on_reject: Whether to raise an error if the action is rejected + + Outputs: + + - accepted: The state input, set when the action was confirmed + - rejected: The state input, set when the action was rejected + - rejected_message: A message describing the rejection (only set on + rejection) """ class Fields: @@ -455,8 +532,15 @@ class InsertChatMessage(AgentNode): if not scene.assets.validate_asset_id(asset_id): raise ValueError(f"Asset not found: {asset_id}") - # Get or create chat - chat = self.agent.chat_create() + # Resolve the target chat: prefer the chat that initiated the + # current action (director chat context), fall back to the active + # chat and only create a new one as a last resort. + chat = None + context = director_chat_context.get() + if context and context.chat_id: + chat = self.agent.chat_get(context.chat_id) + if not chat: + chat = self.agent.chat_get_or_create_active() message = DirectorChatMessage( message=message_content, diff --git a/src/talemate/agents/director/chat/response_specs.py b/src/talemate/agents/director/chat/response_specs.py deleted file mode 100644 index dbbb099c..00000000 --- a/src/talemate/agents/director/chat/response_specs.py +++ /dev/null @@ -1,19 +0,0 @@ -""" -Response specifications for the director chat agent. -""" - -from talemate.prompts.response import ( - AnchorExtractor, - ResponseSpec, -) - -__all__ = [ - "CHAT_TITLE_SPEC", -] - -CHAT_TITLE_SPEC = ResponseSpec( - extractors={ - "title": AnchorExtractor(left="", right=""), - }, - required=[], -) diff --git a/src/talemate/agents/director/modules/director-action-update-gamestate.json b/src/talemate/agents/director/modules/director-action-update-gamestate.json index b662038e..73dc9e17 100644 --- a/src/talemate/agents/director/modules/director-action-update-gamestate.json +++ b/src/talemate/agents/director/modules/director-action-update-gamestate.json @@ -105,7 +105,7 @@ "height": 66, "collapsed": false, "inherited": false, - "registry": "agernts/director/chat/instructGamestateUpdates", + "registry": "agents/director/chat/instructGamestateUpdates", "base_type": "core/Graph" }, "64136cbd-4b3b-4586-9122-4fae36374af8": { diff --git a/src/talemate/agents/director/modules/instruct-gamestate-updates.json b/src/talemate/agents/director/modules/instruct-gamestate-updates.json index 75631c0a..20064d96 100644 --- a/src/talemate/agents/director/modules/instruct-gamestate-updates.json +++ b/src/talemate/agents/director/modules/instruct-gamestate-updates.json @@ -8,7 +8,7 @@ "height": 100, "collapsed": false, "inherited": false, - "registry": "agernts/director/chat/instructGamestateUpdates", + "registry": "agents/director/chat/instructGamestateUpdates", "nodes": { "0404af9c-3a1e-4535-b8bc-5e8257f95d20": { "title": "true", diff --git a/src/talemate/agents/director/nodes.py b/src/talemate/agents/director/nodes.py index 4d6289ac..b72e9bcb 100644 --- a/src/talemate/agents/director/nodes.py +++ b/src/talemate/agents/director/nodes.py @@ -12,6 +12,7 @@ from talemate.character import Character import talemate.agents.director.chat.nodes # noqa: F401 import talemate.agents.director.scene_direction.nodes # noqa: F401 import talemate.agents.director.plan.nodes # noqa: F401 +from talemate.agents.director.character_management import PersistCharacterRequest TYPE_CHOICES.extend( [ @@ -39,6 +40,12 @@ class PersistCharacter(AgentNode): """ Persists a character that currently only exists as part of the given context as a real character that can actively participate in the scene. + + Pre-generated `description`, `dialogue_instructions` and `example_dialogue` + inputs (e.g. from an `agents/creator/GenerateCharacter` node) are used + as-is instead of being generated again. When wiring a generated name into + `character_name`, disable the `determine_name` property so the name is + not determined twice. """ _agent_name: ClassVar[str] = "director" @@ -64,7 +71,10 @@ class PersistCharacter(AgentNode): self.add_input("state") self.add_input("character_name", socket_type="str", optional=True) self.add_input("context", socket_type="str", optional=True) + self.add_input("description", socket_type="str", optional=True) self.add_input("attributes", socket_type="dict,str", optional=True) + self.add_input("dialogue_instructions", socket_type="str", optional=True) + self.add_input("example_dialogue", socket_type="list", optional=True) self.add_input("is_player", socket_type="bool", optional=True) self.set_property("determine_name", True) @@ -75,18 +85,26 @@ class PersistCharacter(AgentNode): async def run(self, state: GraphState): character_name = self.normalized_input_value("character_name") context = self.normalized_input_value("context") + description = self.normalized_input_value("description") attributes = self.normalized_input_value("attributes") + dialogue_instructions = self.normalized_input_value("dialogue_instructions") + example_dialogue = self.normalized_input_value("example_dialogue") determine_name = self.normalized_input_value("determine_name") is_player = self.normalized_input_value("is_player") character = await self.agent.persist_character( - name=character_name, - content=context, - attributes="\n".join([f"{k}: {v}" for k, v in attributes.items()]) - if attributes - else None, - determine_name=determine_name, - is_player=is_player, + PersistCharacterRequest( + name=character_name or "", + content=context, + description=description or "", + attributes="\n".join([f"{k}: {v}" for k, v in attributes.items()]) + if attributes + else None, + determine_name=determine_name, + is_player=is_player, + dialogue_instructions=dialogue_instructions or "", + example_dialogue=example_dialogue or None, + ) ) self.set_output_values({"state": state, "character": character}) @@ -95,7 +113,23 @@ class PersistCharacter(AgentNode): @register("agents/director/AssignVoice") class AssignVoice(AgentNode): """ - Assigns a voice to a character. + Has the director automatically pick and assign a fitting TTS voice to + a character, choosing from the voices available through the ready TTS + APIs (global and scene voice libraries). Does nothing when automatic + voice assignment is disabled in the director settings or when no + voices are available. + + Inputs: + + - state: The graph state + - character: The character to assign a voice to + + Outputs: + + - state: The state input, passed through + - character: The character, passed through + - voice: The character's voice after assignment (may be unset if + assignment was skipped) """ _agent_name: ClassVar[str] = "director" @@ -124,7 +158,21 @@ class AssignVoice(AgentNode): @register("agents/director/LogAction") class LogAction(AgentNode): """ - Logs an action to the console. + Logs a director action by pushing a DirectorMessage to the scene + history and emitting it to the UI. When console_only is true the + message is flagged as hidden so it is not shown in the scene, but + still appears in the debug console. + + Inputs: + + - state: The graph state + - action: The name of the action to log + - action_description: The description of the action + - console_only: Whether to hide the message from the scene (optional) + + Outputs: + + - state: The state input, passed through """ _agent_name: ClassVar[str] = "director" diff --git a/src/talemate/agents/director/plan/nodes.py b/src/talemate/agents/director/plan/nodes.py index fc1af299..0f691c8e 100644 --- a/src/talemate/agents/director/plan/nodes.py +++ b/src/talemate/agents/director/plan/nodes.py @@ -262,7 +262,25 @@ class GetActiveChatPlanId(Node): class GetActivePlan(Node): """ Returns the plan linked to the currently active director chat. - Outputs the full plan object, plan ID, perspective, and beat list. + + When no chat is active or no plan is linked, has_plan is False and the + plan output is empty. The perspective falls back to the scene's default + perspective (or "Third person, past tense.") when the plan doesn't + specify one. + + Inputs: + + - state: The graph state + + Outputs: + + - state: The state input, passed through + - plan_id: The plan's ID (empty string if no plan) + - plan: The full plan object (or None) + - beats: The plan's Beat tasks only (excludes generic tasks) + - perspective: The plan's perspective, with fallback as described above + - close_arc: The plan's close_arc meta flag + - has_plan: Whether a plan was found """ def __init__(self, title="Get Active Plan", **kwargs): diff --git a/src/talemate/agents/director/scene_direction/nodes.py b/src/talemate/agents/director/scene_direction/nodes.py index 96d86467..9f045720 100644 --- a/src/talemate/agents/director/scene_direction/nodes.py +++ b/src/talemate/agents/director/scene_direction/nodes.py @@ -23,10 +23,16 @@ class SceneDirection(AgentNode): The director analyzes the scene and takes multiple sequential actions until satisfied or hitting the max actions limit, without user interaction. + Skips execution (leaving all outputs unresolved) when scene direction + is disabled and always_on is not set, or on the first turn of the + scene unless run_immediately is set. + Inputs: - state: Required state to trigger execution - - max_actions: Optional override for max actions per turn + - max_actions: Optional override for max actions per turn (0 = agent default) + - is_first_turn: Optional flag marking this as the scene's first turn - always_on: Optional override to always execute (ignores agent enabled config) + - run_immediately: Optional flag to run even on the first turn Outputs: - state: The input state passed through diff --git a/src/talemate/agents/director/websocket_handler.py b/src/talemate/agents/director/websocket_handler.py index a5df2e0b..bef45e36 100644 --- a/src/talemate/agents/director/websocket_handler.py +++ b/src/talemate/agents/director/websocket_handler.py @@ -15,6 +15,7 @@ from talemate.context import interaction, handle_generation_cancelled from talemate.status import set_loading from talemate.exceptions import GenerationCancelled from .scene_direction.schema import UserInteractionMessage +from .character_management import PersistCharacterRequest if TYPE_CHECKING: from talemate.tale_mate import Scene @@ -55,6 +56,14 @@ class PersistCharacterPayload(pydantic.BaseModel): is_player: bool = False + generate_example_dialogue: bool = False + example_dialogue_instructions: str = "" + + generate: bool = True + + dialogue_instructions: str = "" + example_dialogue: list[str] | None = None + class AssignVoiceToCharacterPayload(pydantic.BaseModel): character_name: str @@ -140,9 +149,16 @@ class DirectorWebsocketHandler(DirectorChatWebsocketMixin, Plugin): async def handle_persist_character(self, data: dict): payload = PersistCharacterPayload(**data) + if not payload.generate: + # manual creation - mirror the activation rule of the retired + # world_state_manager.create_character path + payload.active = payload.is_player or not self.scene.has_active_npcs + # add as asyncio task task = asyncio.create_task( - self.director.persist_character(**payload.model_dump()) + self.director.persist_character( + PersistCharacterRequest(**payload.model_dump()) + ) ) async def handle_task_done(task): diff --git a/src/talemate/agents/editor/nodes.py b/src/talemate/agents/editor/nodes.py index efa08387..f6ac0c94 100644 --- a/src/talemate/agents/editor/nodes.py +++ b/src/talemate/agents/editor/nodes.py @@ -28,7 +28,24 @@ class EditorSettings(AgentSettingsNode): @register("agents/editor/CleanUpUserInput") class CleanUpUserInput(AgentNode): """ - Cleans up user input. + Cleans up user input via the editor agent, fixing exposition + formatting (quotes for speech, asterisks for narration) according to + the editor's formatting settings. Input starting with a command prefix + (!, @, /) is never edited, and cleanup is skipped entirely when the + editor's fix-user-input setting is disabled - unless force is set. + + Inputs: + + - user_input: The user input text to clean up + - as_narration: Whether to treat the input as narration instead of speech + + Properties: + + - force: Clean up even when the editor's fix-user-input setting is disabled + + Outputs: + + - cleaned_user_input: The cleaned up user input """ _agent_name: ClassVar[str] = "editor" @@ -70,7 +87,22 @@ class CleanUpUserInput(AgentNode): @register("agents/editor/CleanUpNarration") class CleanUpNarration(AgentNode): """ - Cleans up narration. + Cleans up narration text via the editor agent, stripping partial + sentences and fixing exposition formatting according to the editor's + formatting settings (skipped unless the narrator fix-exposition + setting is enabled or force is set). + + Inputs: + + - narration: The narration text to clean up + + Properties: + + - force: Clean up even when the editor's fix-exposition setting is disabled + + Outputs: + + - cleaned_narration: The cleaned up narration """ _agent_name: ClassVar[str] = "editor" @@ -95,7 +127,7 @@ class CleanUpNarration(AgentNode): editor: "EditorAgent" = self.agent narration = self.get_input_value("narration") force = self.get_property("force") - cleaned_narration = await editor.cleanup_narration(narration, force=force) + cleaned_narration = await editor.clean_up_narration(narration, force=force) self.set_output_values( { "cleaned_narration": cleaned_narration, @@ -103,10 +135,27 @@ class CleanUpNarration(AgentNode): ) -@register("agents/editor/CleanUoCharacterMessage") +@register("agents/editor/CleanUpCharacterMessage") class CleanUpCharacterMessage(AgentNode): """ - Cleans up character message. + Cleans up a character's dialogue text via the editor agent: fixes + exposition formatting (per the editor's formatting settings), cleans + up stray dialogue from other characters, strips partial sentences and + balances quotation marks. + + Inputs: + + - text: The character message text to clean up + - character: The character the message belongs to + + Properties: + + - force: Fix exposition even when the editor's fix-exposition setting + is disabled or the character is a player character + + Outputs: + + - cleaned_character_message: The cleaned up character message """ _agent_name: ClassVar[str] = "editor" diff --git a/src/talemate/agents/help/__init__.py b/src/talemate/agents/help/__init__.py new file mode 100644 index 00000000..a05464f2 --- /dev/null +++ b/src/talemate/agents/help/__init__.py @@ -0,0 +1,48 @@ +import structlog + +from talemate.agents.base import Agent, AgentAction +from talemate.agents.registry import register +from talemate.client import ClientBase + +from .chat import HelpChatMixin +from .websocket_handler import HelpWebsocketHandler + +__all__ = ["HelpAgent"] + +log = structlog.get_logger("talemate.agents.help") + + +@register() +class HelpAgent(HelpChatMixin, Agent): + """ + Help agent + + Answers user questions about Talemate itself by consulting the bundled + documentation. Available regardless of whether a scene is loaded and + never modifies scene state - scene changes are the director's job. + """ + + agent_type = "help" + verbose_name = "Help" + essential = False + websocket_handler = HelpWebsocketHandler + + @classmethod + def init_actions(cls) -> dict[str, AgentAction]: + actions = {} + HelpChatMixin.add_actions(actions) + return actions + + def __init__(self, client: ClientBase | None = None, **kwargs): + self.client = client + self.scene = None + self.is_enabled = True + self.actions = HelpAgent.init_actions() + + @property + def enabled(self): + return self.is_enabled + + @property + def has_toggle(self): + return True diff --git a/src/talemate/agents/help/chat.py b/src/talemate/agents/help/chat.py new file mode 100644 index 00000000..9caf3471 --- /dev/null +++ b/src/talemate/agents/help/chat.py @@ -0,0 +1,587 @@ +from typing import Awaitable, Callable + +import structlog + +import talemate.emit.async_signals as async_signals +import talemate.game.focal as focal +import talemate.instance as instance +import talemate.util as util +from talemate.agents.base import ( + AgentAction, + AgentActionConfig, + AgentEmission, + set_processing, +) +from talemate.agents.chat_title import generate_chat_title +from talemate.client.context import ClientContext +from talemate.game.focal.util import strip_call_blocks + +from . import docs +from . import settings +from .schema import ( + HelpChat, + HelpChatDocResultMessage, + HelpChatListEntry, + HelpChatMessage, + HelpChatStore, +) +from .storage import load_store, save_store + +__all__ = ["HelpChatMixin", "HelpChatEmission"] + +log = structlog.get_logger("talemate.agents.help.chat") + +INITIAL_MESSAGE = ( + "Hi! I can help you with anything Talemate - settings, agents, clients, " + "the world editor and more. I can also look up your current settings and " + "change them for you if you ask. What would you like to know?" +) + +OnUpdate = Callable[ + [str, list[HelpChatMessage | HelpChatDocResultMessage]], Awaitable[None] +] +OnDone = Callable[[str], Awaitable[None]] +OnTitleGenerated = Callable[[str, str], Awaitable[None]] + +async_signals.register( + "agent.help.chat.before", + "agent.help.chat.after", +) + + +class HelpChatEmission(AgentEmission): + chat_id: str + chat: HelpChat | None = None + + +class HelpChatMixin: + """ + Agent mixin providing multi-turn help chats backed by the bundled + documentation via focal function calling. + """ + + @classmethod + def add_actions(cls, actions: dict[str, AgentAction]): + actions["chat"] = AgentAction( + enabled=True, + container=True, + can_be_disabled=False, + label="Help Chat", + icon="mdi-help-circle", + description="Interactive help chat that answers questions about Talemate using the bundled documentation.", + config={ + "response_length": AgentActionConfig( + type="number", + label="Response token budget", + description="Maximum response length for help responses.", + value=1024, + step=256, + min=512, + max=4096, + ), + "doc_lookup_iterations": AgentActionConfig( + type="number", + label="Tool call rounds", + description="How many rounds of tool calls (documentation lookups, settings reads and updates) the help agent may perform before it must answer.", + value=3, + step=1, + min=1, + max=10, + ), + "max_doc_lookups": AgentActionConfig( + type="number", + label="Tool calls per round", + description="Maximum tool calls per round. Read-only calls in a round run concurrently when the client supports concurrent inference.", + value=5, + step=1, + min=1, + max=16, + ), + "custom_instructions": AgentActionConfig( + type="blob", + label="Custom instructions", + description="Custom instructions to add to the help chat.", + value="", + ), + }, + ) + + # === Config property helpers === + + @property + def chat_response_length(self) -> int: + return int(self.resolve_config("chat", "response_length")) + + @property + def chat_doc_lookup_iterations(self) -> int: + return int(self.resolve_config("chat", "doc_lookup_iterations")) + + @property + def chat_max_doc_lookups(self) -> int: + return int(self.resolve_config("chat", "max_doc_lookups")) + + @property + def chat_custom_instructions(self) -> str: + return self.resolve_config("chat", "custom_instructions") + + # === Store access === + + @property + def chat_store(self) -> HelpChatStore: + if not hasattr(self, "_chat_store"): + self._chat_store = load_store() + return self._chat_store + + def _chat_persist(self): + save_store(self.chat_store) + + # === Chat CRUD === + + def chat_list(self) -> list[HelpChatListEntry]: + """Return chat entries sorted by created_at descending (most recent first).""" + entries = [ + HelpChatListEntry(id=chat.id, title=chat.title, created_at=chat.created_at) + for chat in self.chat_store.chats.values() + ] + entries.sort(key=lambda e: e.created_at, reverse=True) + return entries + + def chat_get(self, chat_id: str) -> HelpChat | None: + return self.chat_store.chats.get(chat_id) + + def chat_get_last_active_id(self) -> str | None: + return self.chat_store.last_active_chat_id + + def chat_set_last_active_id(self, chat_id: str | None): + self.chat_store.last_active_chat_id = chat_id + self._chat_persist() + + def chat_create(self) -> HelpChat: + chat = HelpChat( + messages=[HelpChatMessage(message=INITIAL_MESSAGE, source="help")], + scene_aware=self.scene_loaded, + ) + self.chat_store.chats[chat.id] = chat + self.chat_store.last_active_chat_id = chat.id + self._chat_persist() + return chat + + def chat_delete(self, chat_id: str) -> bool: + if chat_id not in self.chat_store.chats: + return False + del self.chat_store.chats[chat_id] + if self.chat_store.last_active_chat_id == chat_id: + remaining = self.chat_list() + self.chat_store.last_active_chat_id = remaining[0].id if remaining else None + self._chat_persist() + return True + + def chat_clear(self, chat_id: str) -> bool: + chat = self.chat_get(chat_id) + if not chat: + return False + chat.messages = [HelpChatMessage(message=INITIAL_MESSAGE, source="help")] + self._chat_persist() + return True + + def chat_get_or_create_active(self) -> HelpChat: + last_id = self.chat_store.last_active_chat_id + if last_id: + chat = self.chat_get(last_id) + if chat: + return chat + entries = self.chat_list() + if entries: + chat = self.chat_get(entries[0].id) + if chat: + self.chat_set_last_active_id(chat.id) + return chat + return self.chat_create() + + def chat_update_title(self, chat_id: str, title: str) -> bool: + chat = self.chat_get(chat_id) + if not chat: + return False + chat.title = title + self._chat_persist() + return True + + def chat_update_scene_aware(self, chat_id: str, scene_aware: bool) -> bool: + chat = self.chat_get(chat_id) + if not chat: + return False + chat.scene_aware = scene_aware + self._chat_persist() + return True + + async def chat_append_message( + self, + chat_id: str, + message: HelpChatMessage | HelpChatDocResultMessage, + on_update: OnUpdate | None = None, + ) -> HelpChat | None: + chat = self.chat_get(chat_id) + if not chat: + return None + chat.messages.append(message) + self._chat_persist() + if on_update: + await on_update(chat_id, [message]) + return chat + + # === Context helpers === + + @property + def scene_loaded(self) -> bool: + return bool(self.scene and self.scene.name) + + def _chat_scene_context(self, chat: HelpChat) -> dict | None: + """Build scene context for the prompt when the chat is scene aware.""" + if not chat.scene_aware or not self.scene_loaded: + return None + try: + return { + "title": self.scene.title or self.scene.name, + "characters": self.scene.character_names, + "snapshot": self.scene.snapshot(lines=10), + } + except Exception as e: + log.error("help.chat.scene_context.error", error=e) + return None + + def _chat_doc_callbacks(self) -> list[focal.Callback]: + async def find_docs(query: str): + return docs.find_docs(query) + + async def search_docs(query: str): + return docs.search_docs(query) + + async def read_doc(path: str): + return docs.read_doc(path) + + async def read_doc_section(path: str, section: str): + return docs.read_doc_section(path, section) + + return [ + focal.Callback( + name="find_docs", + arguments=[focal.Argument(name="query", type="str")], + fn=find_docs, + concurrent=True, + ), + focal.Callback( + name="search_docs", + arguments=[focal.Argument(name="query", type="str")], + fn=search_docs, + concurrent=True, + ), + focal.Callback( + name="read_doc", + arguments=[focal.Argument(name="path", type="str")], + fn=read_doc, + concurrent=True, + ), + focal.Callback( + name="read_doc_section", + arguments=[ + focal.Argument(name="path", type="str"), + focal.Argument(name="section", type="str"), + ], + fn=read_doc_section, + concurrent=True, + ), + ] + + def _chat_settings_callbacks( + self, ux_snapshot: dict | None = None + ) -> list[focal.Callback]: + # writes targeting the agent whose settings dialog is open would be + # invisible in (and overwritable by) that dialog - refuse them. + # best-effort: the snapshot is from message-send time, so a dialog + # opened mid-generation is not seen + open_agent_modal = ((ux_snapshot or {}).get("agent_settings_modal") or {}).get( + "agent" + ) + # the settings view lives in the main viewport now — merely having it + # open is fine (incoming config pushes are handled cleanly); only + # unsaved edits there conflict with a config write + app_settings_dirty = bool( + ((ux_snapshot or {}).get("app_settings_modal") or {}).get("dirty") + ) + + async def read_agent_settings(agent: str): + return settings.read_agent_settings(agent) + + async def update_agent_setting( + agent: str, action: str, setting: str, value, scope: str = "global" + ): + return await settings.update_agent_setting( + agent, action, setting, value, scope, open_agent_modal=open_agent_modal + ) + + async def clear_agent_setting_scene_override( + agent: str, action: str, setting: str + ): + return await settings.clear_agent_setting_scene_override( + agent, action, setting, open_agent_modal=open_agent_modal + ) + + async def read_app_config(section: str): + return settings.read_app_config(section) + + async def update_app_config(path: str, value): + return await settings.update_app_config( + path, value, app_settings_dirty=app_settings_dirty + ) + + async def read_clients(): + return settings.read_clients() + + # reads may run concurrently; writes stay sequential so config + # mutations never race each other + return [ + focal.Callback( + name="read_agent_settings", + arguments=[focal.Argument(name="agent", type="str")], + fn=read_agent_settings, + concurrent=True, + ), + focal.Callback( + name="update_agent_setting", + arguments=[ + focal.Argument(name="agent", type="str"), + focal.Argument(name="action", type="str"), + focal.Argument(name="setting", type="str"), + focal.Argument( + name="value", type="str | int | float | bool | list" + ), + focal.Argument(name="scope", type="str"), + ], + fn=update_agent_setting, + ), + focal.Callback( + name="clear_agent_setting_scene_override", + arguments=[ + focal.Argument(name="agent", type="str"), + focal.Argument(name="action", type="str"), + focal.Argument(name="setting", type="str"), + ], + fn=clear_agent_setting_scene_override, + ), + focal.Callback( + name="read_app_config", + arguments=[focal.Argument(name="section", type="str")], + fn=read_app_config, + concurrent=True, + ), + focal.Callback( + name="update_app_config", + arguments=[ + focal.Argument(name="path", type="str"), + focal.Argument( + name="value", type="str | int | float | bool | list" + ), + ], + fn=update_app_config, + ), + focal.Callback( + name="read_clients", + arguments=[], + fn=read_clients, + concurrent=True, + ), + ] + + # === Generation === + + async def chat_generate_next( + self, + chat_id: str, + ux_snapshot: dict | None = None, + on_update: OnUpdate | None = None, + on_done: OnDone | None = None, + on_title_generated: OnTitleGenerated | None = None, + ) -> HelpChat | None: + """ + Generate the next help response. Documentation lookups append their + results to the chat and trigger a follow-up round so the response can + incorporate them, up to the configured number of rounds. + """ + chat = self.chat_get(chat_id) + if not chat: + return None + + await async_signals.get("agent.help.chat.before").send( + HelpChatEmission(agent=self, chat_id=chat_id, chat=chat) + ) + + rounds = 0 + + # help chats must work without a loaded scene, so lift the + # client's active-scene requirement for the whole generation + with ClientContext(requires_active_scene=False): + while True: + scene_context = self._chat_scene_context(chat) + + focal_handler = focal.Focal( + self.client, + callbacks=self._chat_doc_callbacks() + + self._chat_settings_callbacks(ux_snapshot), + max_calls=self.chat_max_doc_lookups, + max_concurrent=self.chat_max_doc_lookups, + retries=0, + response_length=self.chat_response_length, + scene=self.scene, + history=chat.messages, + history_trim=util.reverse_trim_history, + docs_sections=docs.docs_section_overview(), + docs_available=docs.docs_available(), + docs_site_url=docs.DOCS_SITE_URL, + agent_types=sorted(instance.AGENTS.keys()), + ux_snapshot=ux_snapshot, + scene_context=scene_context, + custom_instructions=self.chat_custom_instructions, + final_round=rounds >= self.chat_doc_lookup_iterations, + ) + + response = await focal_handler.request("help.chat") + + visible = strip_call_blocks(response, focal_handler.state.schema_format) + if visible: + await self.chat_append_message( + chat_id, + HelpChatMessage(message=visible, source="help"), + on_update=on_update, + ) + + calls = focal_handler.state.calls + for call in calls: + result = ( + call.result + if call.called + else f"Tool call failed: {call.error or 'unknown error'}" + ) + await self.chat_append_message( + chat_id, + HelpChatDocResultMessage( + name=call.name, + arguments=call.arguments, + result=result, + ), + on_update=on_update, + ) + + rounds += 1 + + if not calls or rounds > self.chat_doc_lookup_iterations: + if calls: + log.warning( + "help.chat.lookup_rounds_exhausted", chat_id=chat_id + ) + break + + chat = self.chat_get(chat_id) + if chat and not chat.title and self._chat_has_enough_for_title(chat): + try: + title = await self.chat_generate_title(chat_id) + if title and on_title_generated: + await on_title_generated(chat_id, title) + except Exception: + pass # title generation is best-effort + + await async_signals.get("agent.help.chat.after").send( + HelpChatEmission(agent=self, chat_id=chat_id, chat=self.chat_get(chat_id)) + ) + + # only on success - on failure the tracked task's error handler emits + # the chat_done carrying the error, and a success-shaped chat_done + # would trigger a history re-sync that wipes the error message + if on_done: + try: + await on_done(chat_id) + except Exception as e: + log.error("help.chat.on_done.error", error=e) + + return self.chat_get(chat_id) + + @set_processing + async def chat_send( + self, + chat_id: str, + message: str, + ux_snapshot: dict | None = None, + on_update: OnUpdate | None = None, + on_done: OnDone | None = None, + on_title_generated: OnTitleGenerated | None = None, + ) -> HelpChat | None: + """Append a user message and generate a help response.""" + await self.chat_append_message( + chat_id, HelpChatMessage(message=message, source="user") + ) + return await self.chat_generate_next( + chat_id, + ux_snapshot=ux_snapshot, + on_update=on_update, + on_done=on_done, + on_title_generated=on_title_generated, + ) + + @set_processing + async def chat_regenerate_last( + self, + chat_id: str, + ux_snapshot: dict | None = None, + on_update: OnUpdate | None = None, + on_done: OnDone | None = None, + on_title_generated: OnTitleGenerated | None = None, + ) -> HelpChat | None: + """Remove the most recent help text message and generate a new one.""" + chat = self.chat_get(chat_id) + if not chat or not chat.messages: + return chat + + for i in range(len(chat.messages) - 1, -1, -1): + message = chat.messages[i] + if message.type == "text" and message.source == "help": + del chat.messages[i] + self._chat_persist() + break + else: + return chat + + return await self.chat_generate_next( + chat_id, + ux_snapshot=ux_snapshot, + on_update=on_update, + on_done=on_done, + on_title_generated=on_title_generated, + ) + + # === Title generation === + + def _chat_has_enough_for_title(self, chat: HelpChat) -> bool: + has_user = any(m.type == "text" and m.source == "user" for m in chat.messages) + has_help = any( + m.type == "text" and m.source == "help" and m.message != INITIAL_MESSAGE + for m in chat.messages + ) + return has_user and has_help + + async def chat_generate_title(self, chat_id: str) -> str | None: + chat = self.chat_get(chat_id) + if not chat: + return None + + excerpt_parts = [] + for message in chat.messages[:6]: + if message.type != "text": + continue + excerpt_parts.append(f"{message.source}: {message.message}") + + title = await generate_chat_title( + self.client, "\n".join(excerpt_parts), "the Talemate help assistant" + ) + if title: + self.chat_update_title(chat_id, title) + + return title diff --git a/src/talemate/agents/help/docs-index.yaml b/src/talemate/agents/help/docs-index.yaml new file mode 100644 index 00000000..de5cfb90 --- /dev/null +++ b/src/talemate/agents/help/docs-index.yaml @@ -0,0 +1,1214 @@ +# Machine-readable index of the Talemate documentation (docs/). +# Consumed by the help agent to route questions to the right document. +# Generated file - regenerate with the docs-index claude skill in the work repo. +- path: dev/howto/add-a-worldstate-template-type.md + title: Adding a new world-state template + summary: 'Developer walkthrough for adding a new world-state template type: define the pydantic schema in src/talemate/world_state/templates, + build a WorldStateManagerTemplate* Vue component, and register it in WorldStateManagerTemplates.vue with conditional rendering, + icon/color, and help message.' +- path: dev/index.md + title: Coming soon + summary: Placeholder landing page for the developer documentation section; contains no content yet beyond a coming-soon + notice. +- path: dev/templates.md + title: Template Overrides + summary: 'Older developer guide to Jinja2 prompt templates: section tag structure, overriding defaults via ./templates/prompts/{agent}/, + creator agent character-creation template naming (character-attributes/details/example-dialogue), extending templates + with Jinja2 inheritance, and a list of template functions like set_prepared_response, query_scene, and query_memory.' +- path: dev/third-party-reference.md + title: Third Party API docs + summary: 'Link collection to external chat-completion API references for backends Talemate supports: Anthropic, Cohere, + Google AI, Groq, KoboldCpp, LMStudio, Mistral, OpenAI, TabbyAPI, and Text-Generation-WebUI.' +- path: getting-started/advanced/change-host-and-port.md + title: Changing host and port + summary: Changing the backend (localhost:5050) and frontend (localhost:8082) bind addresses via TALEMATE_BACKEND_HOST/PORT + and TALEMATE_FRONTEND_HOST/PORT or the --host/--port CLI flags, rebuilding the frontend with VITE_TALEMATE_BACKEND_WEBSOCKET_URL, + Docker Compose runtime overrides with CUDA/CPU file selection, and the 0.37.0 rename notes. +- path: getting-started/advanced/debug-logging.md + title: Debug Logging + summary: Enable verbose DEBUG-level logging with TALEMATE_DEBUG=1 on Linux and Windows; errors then also go to a rotating + logs/errors.log file. Includes how to disable it again. +- path: getting-started/advanced/environment-variables.md + title: Environment variables + summary: 'Reference table of all environment variables Talemate reads at startup: backend/frontend host and port, VITE_TALEMATE_BACKEND_WEBSOCKET_URL, + TALEMATE_DEBUG, TALEMATE_LOG_PROMPTS, API-key encryption vars (TALEMATE_DISABLE_KEYRING, TALEMATE_ENCRYPTION_KEY_DIR), + and Docker Compose passthroughs.' +- path: getting-started/advanced/prompt-logging.md + title: Prompt Logging + summary: Enable TALEMATE_LOG_PROMPTS=1 to write every prompt and model response to logs/prompt_log.jsonl; documents the + full record schema (kind, tokens, client, agent_stack, generation_parameters, template_uid, etc.) and jq recipes for inspecting + the log. +- path: getting-started/connect-a-client.md + title: Connect a client + summary: 'Manually add an AI client after (or instead of) the setup wizard: the ADD CLIENT button, choosing among remote + APIs (OpenAI, Anthropic, Mistral, Cohere, DeepSeek, Groq, Google, OpenRouter) and local APIs (KoboldCpp, llama.cpp, Ollama, + Text-Generation-WebUI, LMStudio, TabbyAPI), and assigning the client to all agents.' +- path: getting-started/installation/docker.md + title: Docker + summary: 'Docker installation and GPU/CPU launch paths: clone and copy config.yaml, then use docker compose up for NVIDIA + CUDA or docker compose -f docker-compose.cpu.yml up on CPU-only hosts. Covers local builds with docker-compose.manual.yml, + CUDA verification with nvidia-smi/PyTorch, host.docker.internal, and Pi Bridge config.' +- path: getting-started/installation/linux.md + title: Linux + summary: 'Linux install: python and uv prerequisites, running install.sh (which downloads portable Node.js 22 and offers + CUDA pytorch) and start.sh, plus manual setup of the venv, backend (uv run src/talemate/server/run.py runserver), and + frontend (corepack pnpm serve).' +- path: getting-started/installation/troubleshoot.md + title: Common issues + summary: Troubleshooting Windows install paths, Docker CUDA startup failures, and reverse-proxy WebSockets. Shows the CPU + opt-out command, NVIDIA Container Toolkit smoke test, in-container nvidia-smi/PyTorch checks, the config.yaml mount pitfall, + VITE WebSocket URL, and nginx upgrade headers. +- path: getting-started/installation/windows.md + title: Windows + summary: 'Windows install: download the release ZIP and double-click start.bat, which auto-downloads portable Python 3 and + Node.js 22 and builds the web client; open http://localhost:8082. Also documents the maintenance scripts install.bat, + install-cuda.bat, and update.bat.' +- path: getting-started/load-a-scene.md + title: Load a scenario + summary: Load the introductory Infinity Quest scenario from the Home screen's Quick Load, send a first interaction (quotes + for speech, asterisks for actions), and get a quick tour of the scenario tools, agent activity bar, and world state panel. +- path: getting-started/setup-wizard.md + title: Setup Wizard + summary: 'First-launch setup wizard walkthrough: choose self-hosted vs hosted API provider, add your first client (OpenRouter + or KoboldCpp defaults), configure long-term memory embeddings (Alibaba-NLP/gte-base-en-v1.5 vs standard model, CUDA vs + CPU), optionally enable the Visual Agent for Google/OpenRouter, and how to skip or re-access these settings later.' +- path: index.md + title: Talemate Documentation + summary: Root landing page describing Talemate as AI roleplay with strong narration and world/game state tracking, with + links to installation (Windows, Linux, Docker) and first steps (setup wizard, connect a client, load a scene, interact). +- path: user-guide/agents/conversation/index.md + title: Overview + summary: Introduces the Conversation (Actor) agent, which generates character-driven dialogue and actions and requires a + text-generation client to be configured and assigned. +- path: user-guide/agents/conversation/settings.md + title: Settings + summary: Conversation agent settings; client selection, Auto Break Repetition (deprecated), dialogue Format (Narrative, + Screenplay, Chat, AI Aware), Generation Length, Jiggle randomness, Task/Actor Instructions and instruction offset, plus + Content toggles for Use Scene Intent and Use Writing Style. +- path: user-guide/agents/creator/autocomplete.md + title: Autocomplete + summary: How to use AI autocomplete (ctrl+Enter/cmd+Enter) to continue partially typed text, steer it with trailing curly-brace + hints, use the Redo/Undo chip, and which fields support it (main input, message edits, character attributes/details/description, + scene intro). +- path: user-guide/agents/creator/index.md + title: Overview + summary: Introduces the Creator agent used for creative content generation tasks, with its Autocomplete feature for continuing + text you have started typing. +- path: user-guide/agents/creator/settings.md + title: Settings + summary: Creator agent settings; Character Creation (Fast consolidated single-prompt generation, Consolidate aspect selection + including the Attribute templates fold, One-shot token budget, Fill in misses), Dialogue Suggestion Length and Narrative + Suggestion Length (max tokens for autocomplete), Enable Hints toggle for curly-brace autocomplete hints, and long-term memory + options. +- path: user-guide/agents/director/chat.md + title: Director Chat + summary: The director console chat for querying and modifying a loaded scene in natural language; covers chat modes (Normal, + Decisive, No Spoilers), write-action confirmation and its timeout, enabling/disabling and locked actions, director personas, + and multiple concurrent chats with auto-generated titles. +- path: user-guide/agents/director/index.md + title: Overview + summary: Overview of the Director agent as a game master; lists its features - Autonomous Scene Direction, Director Chat, + Director Planning, Dynamic Actions (clickable player choices), and Guide Scene. +- path: user-guide/agents/director/planning.md + title: Director Planning + summary: Director Planning (0.37.0); autonomous task lists during chat vs the Generate Long Progress dialog for multi-beat + scene arcs, including turns, dialogue ratio, Expand vs Turn-by-turn execution, Close the arc, outline/expansion critique, + the Plan banner statuses, Arc Generation agent settings, and troubleshooting. +- path: user-guide/agents/director/scene-direction.md + title: Autonomous Scene Direction + summary: Enabling the director to progress scenes automatically; requires story/scene intentions, covers the '#' and '##' + player-input prefixes for steering, manual turn triggering, turn balance, prompting the user for input, general/context + settings, the Direction tab and timeline, custom director action nodes, and troubleshooting. +- path: user-guide/agents/director/settings.md + title: Settings + summary: Director agent settings; direct scene/actors and direction stickiness, Dynamic Actions (chance, number, instructions), + Guide Scene (guide actors/narrator, guidance length), Scene Direction options, Character Management (attribute limit, + auto voice assignment, generate visuals), and Director Chat settings (token budget, iteration limit, context ratio, confirm + timeout, custom instructions). +- path: user-guide/agents/editor/index.md + title: Overview + summary: Introduces the experimental Editor agent, which post-processes generated content to add detail to dialogue and + fix exposition markers. +- path: user-guide/agents/editor/revisions.md + title: Revisions + summary: How editor revisions (0.30.0) detect and fix repetition and unwanted prose; automatic revision status indicators + and Agent Messages dialog, automatic revision targets, and manually triggering a revision on the latest message via the + Revise chip with version history preserved. +- path: user-guide/agents/editor/settings.md + title: Settings + summary: Editor agent settings; fix exposition options, add detail, and the Revision section - enable/automatic revision, + revision targets, methods (Dedupe, Unslop, Targeted Rewrite), repetition detection (fuzzy vs semantic similarity), thresholds, + range, repetition handling, and unwanted prose detection. +- path: user-guide/agents/help/index.md + title: Overview + summary: 'Help agent overview: the interactive help chat opened from the app bar help icon, grounded in the bundled documentation; + multi-turn persistent chats, the per-chat Scene Aware toggle, UX snapshot context, reading and changing settings from + the chat (agent settings globally or per scene, app config; API keys and clients are off limits), and why it cannot change + scene content (defer to director chat).' +- path: user-guide/agents/help/settings.md + title: Settings + summary: 'Help agent settings: response token budget, tool call rounds, tool calls per round, and custom instructions.' +- path: user-guide/agents/index.md + title: Overview + summary: Top-level overview of all Talemate agents (Conversation, Creator, Director, Editor, Long-term Memory, Narrator, + Summarizer, Visualizer, Voice, World State) with one-line descriptions and a pointer to per-scene agent overrides. +- path: user-guide/agents/memory/embeddings.md + title: Embeddings + summary: Managing embedding models via application settings Presets; pre-configured options (all-MiniLM-L6-v2, gte-base-en-v1.5, + OpenAI text-embedding-3-small), adding custom sentence-transformer models, and editing properties like trust remote code, + device, distance, distance mod, and distance function. Notes INSTRUCTOR removal in 0.35.0. +- path: user-guide/agents/memory/index.md + title: Overview + summary: Introduces the Memory agent, which manages long-term memory via embeddings using ChromaDB as the backend. +- path: user-guide/agents/memory/koboldcpp.md + title: KoboldCpp Embeddings + summary: Using an embeddings model loaded in KoboldCpp (--embeddingsmodel flag); Talemate auto-detects it and switches the + Memory agent to the Client API embedding, plus how to revert to a local embedding. +- path: user-guide/agents/memory/settings.md + title: Settings + summary: Memory agent settings; selecting the embedding to use and the device (cpu/cuda), including the 0.37.0 behavior + where device changes no longer require a restart. +- path: user-guide/agents/memory/testing.md + title: Testing Embeddings + summary: How to test embedding accuracy by inspecting memory requests in the debug tools Memory tab, reading result distances + (green/yellow/red), and when to tweak embedding settings. +- path: user-guide/agents/narrator/auto-narration.md + title: Auto Narration + summary: Auto Narration (0.37.0, replaces Narrate after Dialogue); a probability-gated dispatcher that fires Progress Story, + Narrate Scene, or Narrate Environment after actor turns. Covers the decision gates, Chance setting, auto-rebalancing action + weight sliders, the Disable-during-scene-direction option, and debug-logging troubleshooting. +- path: user-guide/agents/narrator/index.md + title: Overview + summary: Introduces the Narrator agent for narrative text generation; explains scene intention awareness (story intention, + scene type, phase intention), writing style templates, and links to Auto Narration. +- path: user-guide/agents/narrator/settings.md + title: Settings + summary: Narrator agent settings; client, generation override instructions, per-narration-type generation lengths (progress + story, scene, query, character, time passage, after dialogue, entry/exit), Use Scene Intent and Use Writing Style content + toggles, time passage narration prompting, and Auto Narration chance/weights. +- path: user-guide/agents/scene-overrides.md + title: Per-Scene Agent Overrides + summary: Overriding selected agent settings for a single scene via the Global/Scene switch in the agent modal; sparse per-field + overrides with link icons, overriding whether an action is enabled at all, how quick-toggle chips show and write the + scene's effective value, the agent-settings.json overrides file stored per project, and choosing, swapping, or opting + out of an overrides file in scene settings. +- path: user-guide/agents/summarizer/index.md + title: Overview + summary: Introduces the Summarizer agent; dialogue summarization, layered history (multi-level summaries), scene analysis + shared with other agents, and experimental context investigation into history layers. +- path: user-guide/agents/summarizer/settings.md + title: Settings + summary: Summarizer settings; token threshold and summarization method, custom instructions, layered history (thresholds, + max layers, chunking, analyzation), scene analysis (length, deep analysis, investigation cap, caching), and Scene Context + History budget controls (max budget, best fit mode, dialogue/summary ratios, summary boundary). +- path: user-guide/agents/visualizer/backends/a1111.md + title: AUTOMATIC1111 + summary: Configuring the deprecated AUTOMATIC1111 text-to-image backend (API URL, steps, sampling method, CFG scale, resolutions); + its limitations (no image editing, auth, or model selection), sampler guidance, and the recommendation to migrate to SD.Next. +- path: user-guide/agents/visualizer/backends/comfyui.md + title: ComfyUI + summary: Setting up ComfyUI as a text-to-image and image-editing backend; port/listen flags, workflow and model selection, + and building custom workflows with required Talemate node titles (Positive/Negative Prompt, Resolution, Load Model, Reference + 1-3), Lightning LoRA, API export, and placing workflows in templates/comfyui-workflows. +- path: user-guide/agents/visualizer/backends/google.md + title: Google + summary: Configuring the Google (Gemini) visual backend for text-to-image, image editing (up to 3 reference images), and + image analysis; getting an AI Studio API key and choosing models like gemini-2.5-flash-image, gemini-3-pro-image-preview, + and vision text models for analysis. +- path: user-guide/agents/visualizer/backends/openai-compatible-vision.md + title: OpenAI Compatible (Vision) + summary: Using any OpenAI-compatible chat endpoint with vision support (llama.cpp, Ollama, vLLM) as the image analysis backend; + base URL, optional API key, model name, and troubleshooting connection or model issues. +- path: user-guide/agents/visualizer/backends/openai.md + title: OpenAI + summary: Configuring the OpenAI visual backend; DALL-E 3 and gpt-image-1/mini for text-to-image, GPT-Image models for editing + (single reference, org verification may be required), gpt-4.1-mini/gpt-4o-mini for analysis, and per-model resolutions. +- path: user-guide/agents/visualizer/backends/openrouter.md + title: OpenRouter + summary: Configuring OpenRouter as a visual backend for text-to-image, editing, and analysis; API key setup, unfiltered + model lists (verify capability yourself), Max References, and provider filtering with only-use/ignore lists. +- path: user-guide/agents/visualizer/backends/sdnext.md + title: SD.Next + summary: Configuring SD.Next for text-to-image and img2img image editing; API URL, authentication methods (none, basic, + bearer API key incl. ArliAI), steps/sampler/CFG, dynamic model and sampler lists, resolutions, and remote access security + notes. +- path: user-guide/agents/visualizer/backends/talemate-client.md + title: Talemate Client (Vision) + summary: Reusing an existing vision-enabled Talemate LLM client (KoboldCpp, llama.cpp, text-generation-webui) as the image + analysis backend; enabling the Vision toggle on the client, selecting it in the Visualizer, advantages over a separate + endpoint, and troubleshooting. +- path: user-guide/agents/visualizer/index.md + title: Overview + summary: Overview of the Visualizer agent for image generation; the three operations (text-to-image, image editing, image + analysis), supported backends per operation, style templates, Visual Library and inline visuals, scenario-tool shortcuts + (Visualize Scene/Character/Moment), and generation via Director Chat. +- path: user-guide/agents/visualizer/settings.md + title: Settings + summary: Visualizer settings; separate backends per operation, generation timeout, automatic setup/generation, prompt generation + options (fallback prompt type, max length, automatic reference analysis, editing prompt revision), style templates per + visual type, Prompt Finalization post-processing actions (exact/fuzzy/regex/AI) with presets, and resolution presets for + local backends. +- path: user-guide/agents/visualizer/visual-library.md + title: Visual Library + summary: The Visual Library for managing generated images; Prompt vs Instruct generation modes, visual types (character + card/portrait, scene card/background/illustration), regenerating, iterating with reference images, saving to shared scene + assets, uploading, reference configuration and tags, cover crop, and AI image analysis. +- path: user-guide/agents/voice/chatterbox.md + title: Chatterbox + summary: Chatterbox local voice cloning backend; device/model/chunk-size settings, voice requirements (5-15s clean .wav), + creating a voice in the Voice Library, and per-voice parameters like exaggeration level and CFG/pace. +- path: user-guide/agents/voice/elevenlabs.md + title: ElevenLabs + summary: ElevenLabs TTS backend; API key setup, model selection, subscription voice-slot limits warning, copying voice IDs + from the ElevenLabs voice lab, and adding those voices in Talemate's Voice Library. +- path: user-guide/agents/voice/f5tts.md + title: F5-TTS + summary: F5-TTS local voice cloning backend; device, model variants (F5TTS_v1_Base etc.), NFE steps, chunk size, exclamation-mark + replacement, reference audio and reference text requirements for creating voices, plus speed and CFG strength parameters. +- path: user-guide/agents/voice/google.md + title: Google Gemini-TTS + summary: Google Gemini-TTS backend; requires a Google Cloud API key and lets you select from available Google TTS models. +- path: user-guide/agents/voice/index.md + title: Overview + summary: Overview of the Voice (TTS) agent; per-character voice assignment, speaker separation, voice library, supported + local APIs (Kokoro, F5-TTS, Chatterbox, Pocket TTS) and remote APIs (ElevenLabs, Google, OpenAI, OpenAI Compatible), enabling + the agent, and character voice fallback behavior. +- path: user-guide/agents/voice/kokoro.md + title: Kokoro + summary: Kokoro TTS backend with predefined voice models; creating mixed voices via the Mixer (2-4 base voices with weights + summing to 1.0) and saving them for character or narrator use. +- path: user-guide/agents/voice/openai-compatible.md + title: OpenAI Compatible + summary: Registering multiple OpenAI-compatible TTS backends (0.37.0 registry); per-backend base URL, API key, model, voices + endpoint, and chunk size, auto-fetching voice lists (KoboldCpp, Speaches, openedai-speech paths), manual voice entry, + auto-managed backends, and troubleshooting. +- path: user-guide/agents/voice/openai.md + title: OpenAI + summary: OpenAI TTS backend; requires an API key and offers model selection between GPT-4o Mini TTS, TTS-1, and TTS-1 HD. +- path: user-guide/agents/voice/pocket-tts.md + title: Pocket TTS + summary: Pocket TTS, Kyutai's CPU-based voice-cloning model; HuggingFace token for gated downloads, language/model selection, + temperature and decode parameters, int8 quantization, chunk size, v1-to-v2 migration, built-in voices, custom .wav or + hf:// catalog voices, and troubleshooting. +- path: user-guide/agents/voice/settings.md + title: Settings + summary: Voice agent settings; enabling multiple TTS APIs, narrator voice selection, speaker separation modes (none, simple, + mixed, AI assisted), auto-generate toggles per message type, and the 0.37.0 Automatic Setup that registers OpenAI Compatible + backends for clients with TTS models. +- path: user-guide/agents/voice/troubleshooting.md + title: TTS Troubleshooting + summary: Fixing common TTS problems, chiefly FFmpeg-not-found/libtorchcodec errors for Chatterbox and F5-TTS; the install-ffmpeg.bat + script on Windows, package-manager install on Linux/macOS, and verifying the installation. +- path: user-guide/agents/voice/voice-library.md + title: Voice Library + summary: The Voice Library for managing voices across all TTS providers; global vs scene scope tabs, API status colors, + adding voices per provider, automatic (Director) and manual character voice assignment, and free-form tagging, filtering, + and search. +- path: user-guide/agents/world-state/index.md + title: Overview + summary: Introduces the World State agent, which generates world state snapshots (location, time, character states) and + reinforces tracked states, and can propose character progression changes as the story advances. +- path: user-guide/agents/world-state/settings.md + title: Settings + summary: World State agent settings; snapshot update frequency and moment size, durable snapshot with max items and stale-entry + eviction, pin to context, state reinforcement and conditional context pin updates, character progression checks and suggestions, + and character portrait auto-selection/generation. +- path: user-guide/api-key-encryption.md + title: API Key Encryption + summary: 'Explains how Talemate (0.36.0+) automatically encrypts API keys in config.yaml with Fernet (ENC: prefix), where + the encryption key is stored (OS keyring vs file-based fallback, TALEMATE_ENCRYPTION_KEY env var), automatic migration + of plaintext keys, and recovery from lost/corrupted keys and Docker deployment considerations.' +- path: user-guide/apis/anthropic.md + title: Anthropic API Setup + summary: Set the Anthropic API key for text generation in Settings under Connections, API Keys (scroll or search for Anthropic); + includes where to create keys on console.anthropic.com. +- path: user-guide/apis/cohere.md + title: Cohere API Setup + summary: Set the Cohere API key for text generation in Settings under Connections, API Keys (scroll or search for Cohere); + includes where to create keys on dashboard.cohere.com. +- path: user-guide/apis/elevenlabs.md + title: ElevenLabs + summary: Set the ElevenLabs API key for text-to-speech in Settings under Connections, API Keys (scroll or search for ElevenLabs), + then configure the Voice agent to use it. +- path: user-guide/apis/google.md + title: Google Cloud Setup + summary: 'Set up Google Cloud for Gemini via Vertex AI: create a project and service account, download the credentials JSON + into the talemate directory, and configure the credentials path and Google Cloud location in Settings under Connections, API Keys (Google entry).' +- path: user-guide/apis/groq.md + title: Groq API Setup + summary: Set the Groq API key for text generation in Settings under Connections, API Keys (scroll or search for groq); includes + where to create keys on console.groq.com. +- path: user-guide/apis/mistral.md + title: Mistral.ai API Setup + summary: Set the Mistral.ai API key for text generation in Settings under Connections, API Keys (scroll or search for mistral.ai); + includes where to create keys on console.mistral.ai. +- path: user-guide/apis/openai.md + title: OpenAI API Setup + summary: Set the OpenAI API key for text, image, and audio generation in Settings under Connections, API Keys (scroll or search + for OpenAI); includes where to create keys on platform.openai.com. +- path: user-guide/apis/openrouter.md + title: OpenRouter API Setup + summary: Set the OpenRouter API key (from openrouter.ai/keys) in Settings under Connections, API Keys (scroll or search for + OpenRouter), to use any model available through OpenRouter. +- path: user-guide/app-settings/appearance.md + title: Appearance + summary: 'Appearance settings for the scene feed: per-message-type styling (narrator, actor, director, time, context investigations) + with italic/bold/color/visibility, text markup styling for quotes, parentheses, brackets, emphasis, and entity highlights, + live preview, and Visuals-page controls for avatar/card/scene-illustration cadence (Always/On Change/Never), size, and + auto-attach.' +- path: user-guide/app-settings/application.md + title: Connections + summary: The Connections group of the application settings, holding the single API Keys page where keys for external services + (OpenAI, Anthropic, etc.) are stored - including the HuggingFace token (added 0.38.0) needed to download gated model weights + such as the Pocket TTS voice-cloning model - and the Environment Variables store (added 0.39.0) for encrypted named values + passed to spawned processes like the Pi Bridge client. +- path: user-guide/app-settings/creator.md + title: Storytelling + summary: 'Storytelling settings group (formerly Creator) holding global picker lists: Content Classification strings for scene setup and Perspective + Presets (added 0.38.0) for the scene outline''s perspective/tense comboboxes, including the {player_name} placeholder + substitution and how to add or remove presets.' +- path: user-guide/app-settings/game.md + title: Game + summary: 'Overview of the redesigned settings view (0.39.0: main-view Settings tab with grouped sidebar navigation, settings + search, and explicit Save/Discard with unsaved-change tracking, including the unsaved-settings prompt shown when leaving + the Settings tab with pending edits - Ignore/Save/Discard Changes - and the warning badge on the tab) plus the Game + group: auto save on game-loop completion, auto progress to the next character, max backscroll message count, the agent + activity bar toggle, releasing GPU cache on scene load, and managing a default player character for imported scenes + (e.g. ST character cards) that lack one.' +- path: user-guide/app-settings/presets.md + title: Presets + summary: Presets settings group covering inference parameter presets (Analytical, Conversation, and other per-action presets + used by agents), embedding model management for the memory agent via chromadb, and the global system prompt override panel. +- path: user-guide/app-settings/system-prompts.md + title: System Prompt Overrides + summary: Override the system prompt per prompt kind (Conversation, Narration, Creation, Direction, Analysis, Editing, World + State, Summarization) at app level or per client, with resolution order, Normal vs Uncensored variants, the pencil override + 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 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, the AI Generation section (per-step generation toggles with card-data fallback, Full/Minimal + presets, and the director's attribute limit capping extracted attributes), 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 + summary: 'Per-client sliders (0-5, default 0 = notify immediately) for automatically retrying response issues before the + generation error dialog appears: empty responses, API rate limiting (HTTP 429, with growing backoff between attempts), + and reasoning models that skip their reasoning tokens. Covers the retry notification with its abort option and where each + slider lives (Advanced tab; Reasoning tab for missing reasoning).' +- path: user-guide/clients/client-configuration.md + title: Client Configuration + summary: 'Tour of the per-client settings dialog and its tabs (General, Coercion, Advanced, Reasoning, System Prompts): + where to find Inference Presets, Structured Data Format, Section Format, Response Length Enforcement, Prompt Caching, + Rate Limit, and Auto Retry, plus the Simple View toggle for quick setup.' +- path: user-guide/clients/concurrent-requests.md + title: Concurrent Requests (Experimental) + summary: Experimental feature that lets certain clients (Anthropic, OpenAI, Google, MistralAI, OpenRouter, Pi Bridge, llama.cpp) + run multiple LLM requests in parallel — used by visual/image prompt generation, function-calling batches (director chat + query actions, help agent doc lookups), multi-query world state updates, and background world state snapshots. Covers + how to enable it via the client-list toggle or Concurrency tab and rate-limit caveats. +- path: user-guide/clients/endpoint-override.md + title: Endpoint Override + summary: 'How to point a remote client at a custom API endpoint such as a LiteLLM proxy gateway: the Endpoint Override tab, + Base URL and separate API Key fields, and the warning to never send your real provider API key to untrusted proxies.' +- path: user-guide/clients/hibernate.md + title: Hibernation + summary: How to hibernate (temporarily disable) a client via the power button in the client list to declutter the UI without + deleting it, and how to re-enable it. +- path: user-guide/clients/index.md + title: Overview + summary: Introduction to Talemate clients that connect agents to local or remote text-generation APIs, why using multiple + clients for different tasks is encouraged, and an index of setup guides for all supported remote (OpenAI, Anthropic, Mistral, + Cohere, Groq, Google) and local (KoboldCpp, llama.cpp, Text-Generation-WebUI, LMStudio) client types. +- path: user-guide/clients/model-testing-harness.md + title: Model Testing Harness + summary: 'Bundled scene (0.37.0+) that runs six minimum-viability tests against the clients assigned to your agents: instruction + following, JSON/YAML data responses, conversation and narrative generation, FOCAL function calling, and multi-step problem + solving. Explains how to load it, read pass/fail results, and diagnose which client setting is broken.' +- path: user-guide/clients/presets.md + title: Inference Configuration + summary: 'Editing inference presets (Analytical, Conversation, Creative, Deterministic, Scene Direction, Summarization, + etc.) in App Settings: parameters like temperature, top-p/k, min-p, penalties, and advanced samplers (XTC, DRY, Smoothing, + Adaptive-P), per-client parameter support, the Apply-to-all and Reset actions, and creating custom preset groups assigned + per client.' +- path: user-guide/clients/prompt-templates.md + title: Prompt Templates + summary: 'Why the correct prompt template is vital for local clients (KoboldCpp, llama.cpp, Text-Generation-WebUI, LMStudio, + TabbyAPI): the yellow-triangle warning for unset templates, automatic detection via HuggingFace, manual selection, and + where user templates are stored.' +- path: user-guide/clients/rate-limiting.md + title: Rate Limiting + summary: 'Capping a client to N requests per minute via the slider on the Advanced tab, and what happens when the limit + is hit: a popup where you can wait for the countdown or abort the generation.' +- path: user-guide/clients/reasoning.md + title: Reasoning Model Support + summary: 'Enabling reasoning (thinking) models per client: the Reasoning tab, Reasoning Tokens allocation and how it behaves + on APIs without native support, regex patterns to strip thinking from responses, Pattern Not Found behavior, and per-action + overrides to force reasoning off for individual agent actions via the prompt detail view.' +- path: user-guide/clients/recommended-models.md + title: Recommended Local Models + summary: Talemate no longer maintains a model list; points to external benchmarks, advises 7B minimum (unquantized for 7B/8B + if possible), and suggests validating candidates with the Model Testing Harness. +- path: user-guide/clients/response-length.md + title: Response Length Enforcement + summary: 'The Response Length Enforcement setting on the Advanced tab: four modes combining token-budget capping (max_tokens) + and human-readable length instructions appended to prompts, including the token-to-paragraph instruction table and the + not-recommended Uncapped mode.' +- path: user-guide/clients/section-format.md + title: Section Format + summary: 'Per-client setting (0.37.0+) that controls how prompt sections like Characters/Scene/Task are delimited: Markdown + headings versus paired uppercase XML tags, where to change it on the Advanced tab, and the sidebar tag shown for non-default + values.' +- path: user-guide/clients/template-locking.md + title: Template Locking + summary: Using the Lock Template checkbox to prevent a client's prompt template from auto-updating when the model changes + — useful for pinning reasoning vs non-reasoning template variants — with step-by-step lock and unlock instructions. +- path: user-guide/clients/types/anthropic.md + title: Anthropic Client + summary: 'Adding an Anthropic client: setting the client type, entering the Anthropic API key via SET API KEY, and the Client + Name and Model settings (any model id can be typed manually if the list lags behind).' +- path: user-guide/clients/types/cohere.md + title: Cohere Client + summary: 'Adding a Cohere client: setting the client type, entering the Cohere API key, and the Client Name and Model settings + (defaults to command-r-plus; manual model names accepted).' +- path: user-guide/clients/types/deepseek.md + title: DeepSeek Client + summary: Adding a DeepSeek client and API key, choosing between deepseek-chat and deepseek-reasoner, and the requirement + to enable Reasoning with sufficient Reasoning Tokens (512-1024+) when using deepseek-reasoner to avoid empty responses. +- path: user-guide/clients/types/google.md + title: Google Client + summary: 'Adding a Google Gemini client: setting up Google Cloud API credentials, Client Name and Model settings, and the + Disable Safety Settings option to turn off Google''s harmful-content response validation.' +- path: user-guide/clients/types/groq.md + title: Groq Client + summary: 'Adding a Groq client: setting the client type, entering the Groq API key, and the Client Name and Model settings + (defaults to llama3-70b-8192; manual model names accepted).' +- path: user-guide/clients/types/koboldcpp.md + title: KoboldCpp Client + summary: 'Connecting to a KoboldCpp instance: API URL (United API by default, append /v1 for OpenAI mode), API key, context + length, the full table of United-API sampler parameters (XTC, DRY, Smoothing, Adaptive-P, etc.), automatic Visual agent + and TTS/Voice agent auto-setup when Kobold loads image or TTS models, and connection troubleshooting.' +- path: user-guide/clients/types/llamacpp.md + title: llama.cpp Client + summary: 'Connecting to a llama-server instance: API URL (default port 8080), optional API key, the ''API handles prompt + template'' option (renders prompts via the server''s /apply-template endpoint using the model''s built-in chat template; + response pre-filling keeps working), context length, and the Concurrent Inference toggle (requires parallel slots on the + server). Includes could-not-connect troubleshooting and a sample llama-server launch command.' +- path: user-guide/clients/types/lmstudio.md + title: LMStudio Client + summary: 'Connecting to an LMStudio instance: API URL (default port 1234), API key, and context length settings, plus the + gotcha that the server must be started inside the LMStudio app for connections to work.' +- path: user-guide/clients/types/mistral.md + title: Mistral.ai Client + summary: 'Adding a Mistral.ai client: setting the client type, entering the Mistral.ai API key, and the Client Name and + Model settings (defaults to mixtral-8x22; manual model names accepted).' +- path: user-guide/clients/types/ollama.md + title: Ollama Client + summary: 'Connecting to an Ollama server (default http://localhost:11434): selecting a model from the auto-fetched list, + the ''API handles prompt template'' option (recommended off), the ''Allow thinking'' option for thinking-capable models, + and troubleshooting offline/no-model states.' +- path: user-guide/clients/types/openai-compatible.md + title: OpenAI Compatible Client + summary: 'Using the generic OpenAI-compatible client for services without official Talemate support, with DeepInfra as the + worked example: API URL/key, manual model name entry, the ''API handles prompt template'' option, and the Parameters tab + toggles to omit temperature/top_p/presence_penalty for services that reject them.' +- path: user-guide/clients/types/openai.md + title: OpenAI Client + summary: 'Adding an OpenAI client and API key, the Model setting (defaults to gpt-4o), and required setup for o1/o3/gpt-5 + reasoning models: enable Reasoning and allocate 512-1024+ Reasoning Tokens, since empty responses usually mean the reasoning + budget was exhausted.' +- path: user-guide/clients/types/openrouter.md + title: OpenRouter Client + summary: 'Adding an OpenRouter client: entering the API key, picking a model from the list dynamically fetched from your + account (refresh by re-saving), the Max token length (context) setting, and the Parameters tab with per-parameter send + toggles for providers that reject unsupported sampler parameters.' +- path: user-guide/clients/types/pi-bridge.md + title: Pi Bridge Client + summary: 'Driving generations through the pi coding agent''s headless RPC mode: pi binary requirement, pi-managed authentication + (env keys, auth.json incl. subscription auth, custom models.json providers), the encrypted environment variable store + for models.json secrets ($NAME references), free-form provider/model fields with catalog suggestions, thinking level mapping, + per-request pi instances for concurrency, why sampler parameters/presets do not apply, and Docker usage (pi preinstalled, + ./pi config directory).' +- path: user-guide/clients/types/tabbyapi.md + title: TabbyAPI Client + summary: 'Connecting to a TabbyAPI instance: API URL (e.g. http://localhost:5000/v1), API key, context length, the ''API + handles prompt template'' option (recommended off), and could-not-connect troubleshooting.' +- path: user-guide/clients/types/text-generation-webui.md + title: Text-Generation-WebUI Client + summary: 'Connecting to a Text-Generation-WebUI (ooba) instance: API URL (default port 5000), API key, the ''API handles + prompt template'' option (routes requests to chat/completions so the backend applies the prompt template; response pre-filling + keeps working via continue_), and context length, plus troubleshooting for bad generations (wrong prompt template), ''No + model loaded'', and connection failures.' +- path: user-guide/debug-tools.md + title: Debug Tools + summary: The Debug Tools panel (bug icon in the toolbar) with its Prompts tab (inspect all AI prompts sent, force reasoning + off per action), Memory tab (memory retrieval requests), and Vars tab. Explains game state variables, setting up watched + paths in the Game State editor, and editing scene state. +- path: user-guide/howto/create-a-new-scene/create-empty-scene.md + title: Create an empty scene + summary: Tutorial step 1 of creating a basic scene, start a new empty scene from the Home screen Create button, fill in + the scene outline (title, content classification, description, introduction) in the world editor, and save it with a project + name. +- path: user-guide/howto/create-a-new-scene/create-npc.md + title: Add an AI controlled character + summary: Tutorial step 3 of creating a basic scene, create an AI-controlled character (NPC) via Create Character with AI + generation instructions, refine the generated attributes/description, and save the scene. +- path: user-guide/howto/create-a-new-scene/create-player-character.md + title: Add the player character + summary: Tutorial step 2 of creating a basic scene, add a player character using the Create Character button with AI generation + instructions and the Controlled by Player toggle, then save the scene. +- path: user-guide/howto/create-a-new-scene/test.md + title: Test your scene + summary: Tutorial step 4 of creating a basic scene, exit the World Editor, switch from Creative mode to Play mode via the + gamepad button, and verify you control the player character and the AI character responds. +- path: user-guide/howto/infinity-quest-dynamic/1-create-the-scene-project.md + title: 1 - Scene Project + summary: Node editor crash course part 1, create the Infinity Quest Dynamic scene project, fill in title/description/content + classification, import the Kaira and Elmer characters from the original Infinity Quest scene, and finalize the setup. +- path: user-guide/howto/infinity-quest-dynamic/2-initial-nodes.md + title: 2 - Initial Nodes + summary: Node editor crash course part 2, extend a custom Scene Loop from the default Talemate loop, create an event module + hooked into scene initialization, test it, and make the dynamic introduction generation run only once on first load. +- path: user-guide/howto/infinity-quest-dynamic/3-improvements.md + title: 3 - Improvements + summary: Node editor crash course part 3, replace narrator generation with the dedicated Contextual Generate node for the + scene introduction, add a reset mechanism to make testing easier, and generate a random thematic list to reduce AI bias + in premises. +- path: user-guide/howto/infinity-quest-dynamic/4-get-organized.md + title: 4 - Get organized + summary: Node editor crash course part 4, organize the graph by selecting a node chain and extracting it into a separate + submodule via the Create Module button, plus visual polish like styling/groups for agent actions. +- path: user-guide/howto/infinity-quest-dynamic/5-agentic-workflow.md + title: 5 - Agentic Flow + summary: Node editor crash course part 5, build a multi-agent, multi-stage workflow, stage 1 generates a random theme, stage + 2 has the Summarizer agent analyze it, stage 3 uses the analysis to inform the Creator agent's introduction generation. + Covers stages, and reorganizing module inputs/outputs. +- path: user-guide/howto/infinity-quest-dynamic/6-reusable-module.md + title: 6 - Reusable Module + summary: Node editor crash course part 6, turn the scene-specific premise generator into a reusable module, rename/re-register + modules, expose a reset toggle and content-control inputs, add an analysis on/off switch, propagate settings, and package + it as a Talemate module usable in any scene. +- path: user-guide/howto/infinity-quest-dynamic/7-polish.md + title: 7 - Polish + summary: Node editor crash course part 7 (final), polish touches for shipping the Dynamic Story module, status indication, + node comments, error handling, generating a story intention, and defaulting to the scene_loop_init event. +- path: user-guide/howto/infinity-quest-dynamic/index.md + title: Building a Dynamic Story Generator with the Node Editor + summary: Overview and table of contents for the 7-part node editor crash course that ports Infinity Quest to a dynamic story + generator, teaching node workflows, AI agent usage (Creator, Summarizer), stages, and building reusable modules. +- path: user-guide/howto/use-dynamic-story-module/index.md + title: Use the dynamic story module + summary: Tutorial for installing and configuring the built-in Dynamic Storyline module (0.30+) from the world editor Mods + tab to randomize the scene introduction of any scene, including keeping a locked foundation save file to restart new stories + from. +- path: user-guide/inline-visuals.md + title: Inline Visuals + summary: The inline visuals feature (0.35.0+) that displays generated images directly in the scene feed. Covers generating + via scene tools, director chat, autonomous direction and context investigation, quick actions and portrait management, + and display settings, visual types (scene illustration/background/card, character card/portrait), render cadence, display + size, and auto-attach. +- path: user-guide/integrations/runpod.md + title: RunPod + summary: 'Run text-generation-webui on rented RunPod GPUs: add the RunPod API key (the settings category for it no longer exists in current versions; backend restart + was required), deploy a pod with the ''RunPod TheBloke LLMs'' template named to include ''thebloke llms'' or ''textgen'' so + Talemate auto-discovers it, and load models via the pod''s web UI. Notes the instructions are somewhat outdated.' +- path: user-guide/interacting.md + title: Interacting with the scene + summary: How to play a scene, dialogue input conventions (quotes for speech, asterisks for actions), linebreaks, acting + as another character or the narrator via tab (act-as), quick actions, player direction with +- path: user-guide/node-editor/core-concepts/collector_nodes.md + title: Collector Nodes + summary: 'Dict Collector and List Collector nodes that aggregate multiple inputs via dynamic sockets: key inference rules, + the Make Key-Value Pair node, adding/removing input slots, nested collection, and the Advanced Format / Jinja2 Format + nodes for turning collected data into formatted strings.' +- path: user-guide/node-editor/core-concepts/command_module.md + title: Command Modules + summary: 'Registering scene commands invoked with !name:arg0;arg1 syntax: creating a Command module, setting its name property, + wiring Argument nodes to processing logic, and a full !instruct_actor example. Command modules load automatically with + the scene.' +- path: user-guide/node-editor/core-concepts/error-handling.md + title: Error Handling + summary: Catching exceptions in node graphs with the Error Handler node (attach a handler function that receives an exc + argument) and Unpack Exception (extract name/message for display or branching), with an example that logs errors to scene + history. +- path: user-guide/node-editor/core-concepts/events.md + title: Events + summary: 'Creating Event modules that run when a specific event is emitted: accessing the event payload via the Event node, + activating by adding to the scene loop with an event_name, or via auto_register on the module properties. Includes a worked + example hooking the director''s generate-choices prompt to inject a dynamic instruction.' +- path: user-guide/node-editor/core-concepts/functions.md + title: Functions + summary: 'Building callable functions from node graphs: inline functions (Argument, Return, Define Function, Get Function, + Call Function, Call For Each nodes), self-contained Function modules, and exposing functions to the LLM via FOCAL AI function + calling (AI Function Callback, AI Function Calling, Process AI Function Call) with a complete Jinja2 prompt example.' +- path: user-guide/node-editor/core-concepts/modularity.md + title: Modularity + summary: The difference between Python nodes and code-less node modules, module core types (Module, Command, Event, Function, + Scene Loop), the module load-order priority (scene > templates/modules/ > built-in) that lets you override built-ins, + and promoting scene modules to shared modules with copy vs move. +- path: user-guide/node-editor/core-concepts/module-inheritance.md + title: Module Inheritance + summary: 'Extending an existing node module via ''Extend current'': inherited nodes are live references so base-module changes + propagate to extensions; you can add but not modify base logic. Explains the extends property and when to copy instead + of extend.' +- path: user-guide/node-editor/core-concepts/package.md + title: Installable Packages + summary: 'Packaging node modules so users can install them from the Mods list without the node editor: creating a Package + module, its properties (name, author, description, restart-scene-loop flag), the Install Node Module and Promote Config + nodes, and deploying to templates/modules/.' +- path: user-guide/node-editor/core-concepts/prompt-templates.md + title: Prompt Templates + summary: 'Using Jinja2 prompt templates in graphs: the Prompt from Template node (template_file vs template_text, variables, + scope, dedupe), Template Variables and Generate Response nodes, template scope/file storage locations, prompting for JSON/YAML + data structures with data_response, and two full worked examples.' +- path: user-guide/node-editor/core-concepts/prompt_building.md + title: Prompt Building + summary: 'The Build Prompt node as a high-level alternative to manual templates: automatic context assembly with include_* + toggles (scene intent, memory/RAG, scene, character, gamestate context), dynamic instructions, token-budget management + via reserved_tokens, and how it maps onto the common/base.jinja2 template.' +- path: user-guide/node-editor/core-concepts/staging.md + title: Staging + summary: 'Using core/Stage nodes to split long node chains into ordered execution steps: stage priority numbers (negative + allowed), pass-through state sockets, how unstaged nodes run last, sharing data between stages with Set/Get State, and + the X-key shortcut to drop a Stage node while dragging a connection.' +- path: user-guide/node-editor/core-concepts/states.md + title: State Management + summary: The state nodes (SetState, GetState, UnsetState, HasState, CounterState and conditional variants) and their scopes + — local, parent, shared, scene loop, and game (saved with the scene) — plus agent-specific state nodes with scene and + context scopes, and guidance on choosing scope lifetimes. +- path: user-guide/node-editor/core-concepts/switches.md + title: Switches and conditional routing + summary: 'Conditional routing nodes: Switch (truthy yes/no branching), RSwitch and RSwitchAdvanced (value selection), ORRouter/ANDRouter + for combining flags, Case for multi-way exact-match branching, plus Invert and Coallesce helpers, with input/output payload + tables and practical patterns.' +- path: user-guide/node-editor/core-concepts/user-interface.md + title: Using the node editor + summary: 'Node editor UI walkthrough: opening it from creative mode, the Module Library (listing groups, filtering, creating/deleting + modules, registry paths with $N), the toolbar (debug menu, scene/module test runs, breakpoints), canvas operations (adding, + connecting, selecting, copying, aligning nodes), quick W/S/X shortcuts for Watch/SetState/Stage nodes, groups, and comments.' +- path: user-guide/node-editor/index.md + title: Introduction + summary: Brief introduction to the node editor (added in 0.30.0), which enables code-less creation of complex scenes and + reusable modules. +- path: user-guide/node-editor/reference/events.md + title: Events + summary: 'Reference list of all supported events with payload tables: game loop and scene loop lifecycle events, regenerate + events, per-agent events (conversation, creator, editor, help, narrator, director, world state, summarization, TTS, visual + generation and prompt finalization), and scene asset events (asset saved/deleted, backdrop and cover image changes), including + which payload fields are mutable for injecting instructions or rewriting responses.' +- path: user-guide/node-editor/reference/nodes/agents-conversation.md + title: Conversation Agent Nodes + summary: 'Node reference. Generate character dialogue through the conversation agent. Nodes: Generate Conversation, Conversation + Settings.' +- path: user-guide/node-editor/reference/nodes/agents-creator.md + title: Creator Agent Nodes + summary: 'Node reference. Content creation through the creator agent: contextual generation, character creation (including + the Generate Character node, which generates name, description, attributes, dialogue instructions and example dialogue + following the creator agent''s Character Creation settings) and per-character derivations, plus thematic lists. Nodes: Contextual Generate, Create Character, + Generate Character, Determine Character Description, Determine Character Dialogue Instructions, Determine Character Name, + Determine Content Context, and more.' +- path: user-guide/node-editor/reference/nodes/agents-director-auto.md + title: Director Auto-Direction Nodes + summary: 'Node reference. The director''s automatic direction loop: pick candidates, determine scene intent, decide when + an instruction is due and generate the auto-direction itself. Nodes: Auto Direction, Auto Direct Candidates [DEPRECATED], + Determine Scene Intent, Generate Auto Direction, Generate Scene Types, Is Due For Instruction [DEPRECATED].' +- path: user-guide/node-editor/reference/nodes/agents-director-chat-actions.md + title: Director Chat Actions + summary: 'Node reference. The actions the director chat AI can take. Each module defines one callable action — its name, + description and instructions (shown in the properties below) are what the director AI sees when deciding what to do. Nodes: + Director Action Create Image, Director Action Direct Scene, Director Action Direct Story Arc, Director Action Gameplay, + Director Action Prompt User, and more.' +- path: user-guide/node-editor/reference/nodes/agents-director-chat-instructors.md + title: Director Chat Instructors + summary: 'Node reference. Instructor modules the director chat uses to apply changes: instruct characters and the narrator, + and update world, history, gamestate, story and character configuration. Nodes: Instruct Character, Instruct Character + Changes, Instruct Character Config Updates, Instruct Character Creation, Instruct Character Updates, Instruct History + Updates, Instruct Narrator, and more.' +- path: user-guide/node-editor/reference/nodes/agents-director-chat.md + title: Director Chat Nodes + summary: 'Node reference. Plumbing for the director chat: build the chat context, define action arguments, confirm actions + and run sub-actions, plus the context/query modules the chat uses to look things up. Nodes: Director Action Argument, + Director Action Confirm, Agent Report Issue, Character Context, Character Names Context, Direct Context Update, Director + Agent Retrieve Context, and more.' +- path: user-guide/node-editor/reference/nodes/agents-director-plan.md + title: Director Planning Nodes + summary: 'Node reference. Director plans: create plans and tasks, advance and complete them, and read plan state from graphs. + Nodes: Complete Task, Create Outline, Create Plan, Critique Outline, Delete Plan, Edit Task, Estimate Words, Expand Story + Arc, Get Active Chat Plan ID, Get Active Plan, Insert Task, Manage Plan, Remove Task.' +- path: user-guide/node-editor/reference/nodes/agents-director.md + title: Director Agent Nodes + summary: 'Node reference. Director operations: scene direction, character persistence (Persist Character accepts pre-generated + description, dialogue instructions and example dialogue, e.g. from the creator Generate Character node), voice assignment, + chat message insertion, dice rolls, actor/narrator direction functions and the director''s websocket handler modules. Nodes: + Assign Voice, On Persist Character Generate Visual, Direct Actor, Direct Narrator, Yield To Actor, Yield To User, Roll + Dice, Insert Chat Message, and more.' +- path: user-guide/node-editor/reference/nodes/agents-editor.md + title: Editor Agent Nodes + summary: 'Node reference. Clean up user input, narration and character messages through the editor agent''s revision passes. + Nodes: Clean Up Character Message, Clean Up Narration, Clean Up User Input, Editor Settings.' +- path: user-guide/node-editor/reference/nodes/agents-memory.md + title: Memory Agent Nodes + summary: 'Node reference. Query the long-term memory database (RAG) and unpack the returned memory documents. Nodes: Query + Context DB, Unpack Memory Document.' +- path: user-guide/node-editor/reference/nodes/agents-narrator.md + title: Narrator Agent Nodes + summary: 'Node reference. Generate narration through the narrator agent: scene progress, environment, queries, character + entries/exits and time passage. Nodes: Auto Narration, Generate After Dialog Narration, Generate Character Entry Narration, + Generate Character Exit Narration, Generate Character Narration, Generate Environment Narration, Generate Progress Narration, + Generate Query Narration, and more.' +- path: user-guide/node-editor/reference/nodes/agents-summarizer.md + title: Summarizer Agent Nodes + summary: 'Node reference. Summarization through the summarizer agent: scene progression summaries and tag generation. Nodes: + Generate Tags, On Message Set Avatar, Summarizer Settings, Summarize Scene Progression, Wsh Summarize Scene Progress.' +- path: user-guide/node-editor/reference/nodes/agents-tts.md + title: Voice Agent Nodes + summary: 'Node reference. Text-to-speech through the voice agent: resolve voices (including the narrator''s), unpack voice + data and generate speech. Nodes: Generate TTS, Get Narrator Voice, Get Voice, TTS Agent Settings, Unpack Voice.' +- path: user-guide/node-editor/reference/nodes/agents-visual-generation.md + title: Visualizer Agent Generation Nodes + summary: 'Node reference. Run visual generation: build generation requests, select the backend, generate images and analyze + them. Prompts come from the prompt nodes. Nodes: Analyze Images, Backend Status, Generate Image, Visual Generation Request, + Select Backend, Unpack Visual Generation Request, Unpack Visual Generation Response.' +- path: user-guide/node-editor/reference/nodes/agents-visual-modules.md + title: Visualizer Agent Modules + summary: 'Node reference. Ready-made visual generation flows built on the visualizer prompt and generation nodes: generate + visual assets, avatars, portraits, image edits and regenerations, plus the visualizer''s websocket handlers. Nodes: Determine + Visual References, Generate Avatar, Generate Character Portrait Legacy, Generate Image Edit, Generate Visual Asset, Regenerate + Visual Asset, and more.' +- path: user-guide/node-editor/reference/nodes/agents-visual.md + title: Visualizer Agent Prompt Nodes + summary: 'Node reference. Build visual prompts: assemble prompt parts, apply styles, finalize prompts and unpack them into + compiled positive/negative prompt strings. Generation itself happens through the generation nodes, and ready-made flows + live in Visualizer Agent Modules. Nodes: Apply Style, Apply Styles, Visual Enum Values, Finalize Prompt, Visual Prompt, + Visual Prompt Part, Visual Settings, Unpack Visual Prompt, and more.' +- path: user-guide/node-editor/reference/nodes/agents-world-state.md + title: World State Agent Nodes + summary: 'Node reference. World state operations through the world state agent: advance time, run character progression, + evaluate queries, extract character sheets and emit the world state to the frontend. Nodes: Advance Time, Character Progression, + Deactivate Character, Determine Character Avatar, Determine Character Presence, Emit World State, Evaluate Query, Extract + Character Sheet, and more.' +- path: user-guide/node-editor/reference/nodes/agents.md + title: Agent Nodes + summary: 'Node reference. Generic agent interaction available for every agent: fetch an agent instance, call agent functions + (optionally conditionally), and read or count agent state variables. Nodes: Call Agent Function, Call Agent Function (Conditional), + Counter Agent State, Dynamic Instruction, Get Agent, Get Agent State, Set Agent State, Toggle Agent Action, Unset Agent + State.' +- path: user-guide/node-editor/reference/nodes/assets-meta.md + title: Asset Meta And Selection Nodes + summary: 'Node reference. Asset metadata and selection: search assets, build selection contexts, and construct attachment + contexts for passing assets into generation requests. Nodes: Make Asset Attachment Context, Make Asset Meta, Search Assets, + Select Assets, Unpack Asset Meta, Unpack Asset Selection Context.' +- path: user-guide/node-editor/reference/nodes/assets.md + title: Asset Nodes + summary: 'Node reference. Manage scene assets (images and other files): add, fetch, list, remove and update them, and set + cover and avatar images. Nodes: Add Asset, Asset Exists, Get Asset, Get Assets, List Assets, Remove Asset, Set Avatar + Image, Set Cover Image, Update Message Assets.' +- path: user-guide/node-editor/reference/nodes/context-id.md + title: Context ID Nodes + summary: 'Node reference. Context IDs address pieces of scene context (character attributes, world entries, etc.) with a + uniform string format so they can be read, written, pinned and rendered generically. Nodes: Character Context IDs, Compress + Context ID Part, Context ID Get Value, Context ID Meta Entries, Context ID Set Value, Is Pin Active, Path to Context ID, + Remove Pin, Render Context IDs, and more.' +- path: user-guide/node-editor/reference/nodes/core-functions.md + title: Function Nodes + summary: 'Node reference. Define and call reusable functions inside node graphs. A `core/functions/Function` module defines + the body, **Define Function** / **Get Function** / **Call Function** wire it up, and **Call For Each** maps a function + over a list. **Error Handler** and **Unpack Exception** deal with failures raised inside a call. See Functions for the + full concept walkthrough. Nodes: Error Handler, Argument, Breakpoint, Call For Each, Call Function, Define Function, Get + Function, Return, Scoped API Function, and more.' +- path: user-guide/node-editor/reference/nodes/core-logic.md + title: Logic Nodes + summary: 'Node reference. Branching and comparison: switches route execution based on a value, routers pick one of several + paths, and the boolean nodes combine conditions. See Switches for how conditional flow works in graphs. Nodes: AND Router, + Apply Default, As Bool, Case, Case Router, Coallesce, Invert, Make Bool, OR Router, RSwitch, RSwitch Advanced, Switch.' +- path: user-guide/node-editor/reference/nodes/core.md + title: Core Nodes + summary: 'Node reference. Structural plumbing every module graph is built from. **Input**, **Output** and **Module Property** + define a module''s interface — the sockets and properties it exposes when used as a node inside another graph (see Modularity). + **Stage** controls execution order between disconnected branches (see Staging), **Route** and **Watch** pass values through + (Watch also surfaces them in the debug tools), and **Null** is an explicit no-op. Nodes: Input Socket, Module Property, + Null, Output Socket, Route, Stage, Watch, and more.' +- path: user-guide/node-editor/reference/nodes/data-collections.md + title: Dict And List Nodes + summary: 'Node reference. Build and manipulate dictionaries and lists. The **Dict Collector** and **List Collector** aggregate + any number of inputs through dynamic sockets — see Collector Nodes for how key inference works with **Make Key-Value Pair**. + Nodes: Combine Lists, Dict Collector, Dict Get, Dict Get (Path), Dict To Key-Value Pairs, Dict Pop, Dict Set, Dict Update, + List Append, and more.' +- path: user-guide/node-editor/reference/nodes/data-number.md + title: Number Nodes + summary: 'Node reference. Numeric values: conversion, arithmetic, comparison, clamping, averaging and random numbers. Nodes: + As Number, Average, Basic Arithmetic, Clamp, Compare, Make Number, Min Max, Random, Sum.' +- path: user-guide/node-editor/reference/nodes/data-string.md + title: String Nodes + summary: 'Node reference. String manipulation: formatting, case, join/split, replace, trim, substrings and excerpts. The + **Advanced Format** (`{name}` placeholders) and **Jinja2 Format** (`{{ name }}` templates) nodes build strings from dynamic + inputs — connect other nodes into their input slots and each connected value becomes a template variable (see Collector + Nodes for the key inference rules). Nodes: Advanced Format, As String, Case, Condensed, Excerpt, Extract, Format, Join, + Make String, Make Text, Replace, Split, String Check, and more.' +- path: user-guide/node-editor/reference/nodes/data.md + title: Data Nodes + summary: 'Node reference. General-purpose value handling: get/set on arbitrary objects, conditionals, sorting and selection, + UUID generation, JSON parsing/serialization and length capping. Nodes: Cap Length, Contains, Get, JSON, Length, Select + Item, Set, Set Conditional, Sort, Update Object, UUID.' +- path: user-guide/node-editor/reference/nodes/event.md + title: Event Nodes + summary: 'Node reference. Emit messages and status updates from graphs, and react to scene events with the **Event** listener + node. See Events for the event system and the Events reference for every event a graph can listen to. Nodes: Emit Agent + Message, Emit Scene Status, Emit Status, Emit Status (Conditional), Emit System Message, Emit World Editor Sync, Event.' +- path: user-guide/node-editor/reference/nodes/focal.md + title: AI Function Nodes (Focal) + summary: 'Node reference. Talemate''s AI function-calling layer ("focal"). Define callable functions with typed arguments, + hand them to an agent prompt, then unpack the calls the AI made and collect their results. Used by flows like the director + chat actions. Nodes: AI Function Argument, AI Function Callback, Collect AI Function Call Results, AI Function Calling, + AI Function Callback Metadata, and more.' +- path: user-guide/node-editor/reference/nodes/index.md + title: Node Reference + summary: Index of the node editor's complete node reference - every node category page with node counts. Start here to find + which page documents a specific node. +- path: user-guide/node-editor/reference/nodes/input.md + title: Input Nodes + summary: 'Node reference. Receive and process player input inside the scene loop. Nodes: Process Input, Get Input.' +- path: user-guide/node-editor/reference/nodes/prompt.md + title: Prompt Nodes + summary: 'Node reference. Build prompts and run generation from graphs: load and render prompt templates, assemble prompts + dynamically (see Prompt Building), and generate a response with a client. Nodes: Build Prompt, Clean Response, Generate + Response, Load Template, Prompt From Template, Render Prompt, Template Variables.' +- path: user-guide/node-editor/reference/nodes/raise.md + title: Flow Exception Nodes + summary: 'Node reference. Stop or redirect graph execution by raising exceptions: **Stop** ends the current run, **Input + Value Error** reports invalid socket input, and **Acted As Character** signals that a character turn happened. See Error + Handling. Nodes: Acted As Character, Input Value Error, Stop.' +- path: user-guide/node-editor/reference/nodes/response.md + title: Response Parsing Nodes + summary: 'Node reference. Extract structured data from LLM responses. A **Response Spec** describes the expected format; + extractors pull out anchored sections, code blocks, lists or regex matches from the raw response text. Nodes: After Anchor + Extractor, Anchor Extractor, AsIs Extractor, Code Block Extractor, Complex Anchor Extractor, Complex Code Block Extractor, + Regex Extractor, Response Spec, and more.' +- path: user-guide/node-editor/reference/nodes/scene-characters.md + title: Scene Character Nodes + summary: 'Node reference. Everything about characters in the scene: fetch them (by name or player status), create and persist + new ones, activate/deactivate them for the current scene, and read or write their attributes, details and descriptions. + Nodes: Activate Character, Deactivate Character, Get Character, Get Character Attribute, Get Character Description, Get + Character Detail, and more.' +- path: user-guide/node-editor/reference/nodes/scene-history.md + title: Scene History Nodes + summary: 'Node reference. Work with the scene''s message history and its summarized archive: query messages by type or id, + push/pop messages, and manage static archive entries (pre-authored history that survives summarization). Nodes: Character + Activity, Context History, Create Static Archive Entry, Get Message By ID, Scene Has History, Last Message of Type, Pop + History, Push History, and more.' +- path: user-guide/node-editor/reference/nodes/scene-intent.md + title: Scene Intent Nodes + summary: 'Node reference. Read and change the scene''s intention state: the overall scene intent, the active scene phase + and the scene types that define how different phases play. Nodes: Get Scene Intent, Get Scene Type, Get Scene Types, Is + Scene Phase Active, Make Scene Type, Remove Scene Type, Set Scene Intent, Set Scene Phase, Unpack Scene Phase, Unpack + Scene Type.' +- path: user-guide/node-editor/reference/nodes/scene-messages.md + title: Scene Message Nodes + summary: 'Node reference. Create and manipulate messages in the scene log: character, narrator and director messages, message + versions, context visibility and message metadata. Nodes: Add Message Version, Character Message, Director Message, Narrator + Message, Toggle Message Context Visibility, Unpack Message Meta.' +- path: user-guide/node-editor/reference/nodes/scene-world-state.md + title: World Entry Nodes + summary: 'Node reference. Read and write the scene''s world entries (the world information shown in the world state panel) + and build generation options — writing styles and spices — that steer content generation. Nodes: Get World Entries, Get + World Entry, Remove World Entry, Save World Entry, Generation Options, Spices, Writing Style, Unpack World Entry.' +- path: user-guide/node-editor/reference/nodes/scene.md + title: Scene Nodes + summary: 'Node reference. Scene lifecycle and metadata. The **Scene Loop** module is the heart of a playable scene — it + processes turns, input and events (see the Command Module and Events concepts). The Get/Set pairs manage the scene''s + title, description, introduction and content classification, and the storyline modules drive dynamic storylines. Nodes: + Creative Loop, Dynamic Storyline, Game Loop Actor Iteration, Generate Storyline, Get Content Classification, Get Story + Description, and more.' +- path: user-guide/node-editor/reference/nodes/state.md + title: State Nodes + summary: 'Node reference. Read and write named variables in the graph state. Every variable lives in a **scope**: `local` + (this module run), `parent`, `shared` (all modules in the graph), `scene loop` (reset each loop iteration) or `game` (persisted + with the scene save). See States for scope semantics. The `...Path` variants address nested values with dotted paths, + and the counter nodes increment numeric state in place. Nodes: Counter State (Conditional), Set State (Conditional), Unset + State (Conditional), State Counter, State Counter (Path), Game State, Get State, and more.' +- path: user-guide/node-editor/reference/nodes/util-packaging.md + title: Packaging Nodes + summary: 'Node reference. Package node modules for reuse and installation into scenes — see Package for the packaging workflow. + Nodes: Dynamic Storyline Package, Install Node Module, Package, Promote Config.' +- path: user-guide/node-editor/reference/nodes/util.md + title: Utility Nodes + summary: 'Node reference. Miscellaneous helpers: counters, string diffs, list extraction from text, ISO date durations and + the **Module Style** node that sets a module''s canvas appearance. Nodes: Counter, Diff, Extract List, ISO Date Duration, + Module Style.' +- path: user-guide/node-editor/reference/nodes/ux.md + title: UX Nodes + summary: 'Node reference. Build interactive user-facing elements from graphs: choice menus, notices and text inputs, emitted + to the interface and styled with **Style Element**. Nodes: Build Choice Element, Build Notice Element, Build Text Input + Element, Emit UX Element, Style UX Element.' +- path: user-guide/node-editor/reference/nodes/validation.md + title: Validation Nodes + summary: 'Node reference. Validate values inside graphs — set/unset checks, containment, characters, asset IDs and context + ID strings/items — producing pass/fail results you can branch on. Nodes: Validate Asset ID, Validate Character, Validate + Context ID Item, Validate Context ID String, Validate Value Contained, Validate Value Is Not Set, Validate Value Is Set.' +- path: user-guide/node-editor/reference/nodes/websocket.md + title: Websocket Nodes + summary: 'Node reference. Plumbing for module-defined websocket handlers: route requests, queue responses and signal operation + success or failure to the frontend. Nodes: Get Websocket Router, Websocket Operation Done, Websocket Operation Failed, + Websocket Response.' +- path: user-guide/node-editor/reference/template_functions.md + title: Template Functions + summary: 'Reference for functions available inside prompt templates: response scaffolding (set_prepared_response, set_data_response, + disable_dedupe), scene/memory/text queries (query_scene, query_memory, query_text, query_text_eval, instruct_text), agent + access (agent_action, agent_config), utilities (time_diff, text_to_chunks, to_int, to_str, len, max, min, join, count_tokens, + data_format_type, llm_can_be_coerced, config), and the condensed filter.' +- path: user-guide/node-editor/reference/useful-template-patterns.md + title: Template Pattern Cheat Sheet + summary: 'Copy-paste Jinja2 patterns for prompt templates: scene context with token budgets, memory retrieval via rag_build, + character context with conditional detail, including reusable templates (scene-context, memory-context, character-context), + budget-based conditionals, and response scaffolding with bot_token, set_prepared_response, and set_data_response.' +- path: user-guide/prompts/context-history-review.md + title: Scene Context History Review + summary: 'The Scene Context tab of the Prompt Manager visualizes how scene history is assembled into AI context, with color-coded + sections (layered history, archived summaries, dialogue), per-section token counts, and tunable settings: best fit mode + with min/max dialogue, manual dialogue/summary detail ratios, enforce boundary, and max token budget.' +- path: user-guide/prompts/deduplication.md + title: Prompt Deduplication + summary: Line-level fuzzy dedupe pass (removes >32-char lines with >=95% similarity) toggled per client on the Advanced + tab; recommended off because it breaks prompt caching and can mangle structured content. Covers opting out per template + with disable_dedupe() or the Prompt from Template node's dedupe property. +- path: user-guide/prompts/index.md + title: Prompt Manager + summary: 'Central UI for the Jinja2 prompt templates driving all AI interactions: template groups (default/user/scene/custom) + with priority-based override resolution, the Active tab''s resolved template tree with outdated-override indicators, template + editing, response extraction directives (AnchorExtractor, RegexExtractor, etc.), and the Prompts tab for inspecting sent + prompts with a Test Changes re-send button. Includes 0.36.0 template restructuring upgrade notes.' +- path: user-guide/prompts/llm-prompt-templates.md + title: LLM Prompt Templates + summary: 'Manage base chat-format templates (ChatML, Llama3, Mistral, etc.) that wrap prompts for local LLM inference: built-in + vs user templates in templates/llm-prompt/std/, creating and copying templates, pasting GGUF/llama.cpp chat templates + with supported variables (messages, enable_thinking, thinking_budget), and assigning a template to a client.' +- path: user-guide/prompts/redefining-response-extraction.md + title: Redefining Response Extraction + summary: Advanced guide to customizing how content is extracted from LLM responses using template extractor functions (set_anchor_extractor, + set_as_is_extractor, set_after_anchor_extractor), with a worked example separating from tags, plus + fallback_to_full, tracked_tags for nested tags, and mod_response_length for extending the response token budget. +- path: user-guide/prompts/volatile-context-placement.md + title: Volatile Context Placement + summary: The Optimize for Prompt Caching client setting (Advanced tab) moves volatile content (RAG memory, dynamic notes) + after scene history so provider prompt caches stay valid, cutting latency and cost on Anthropic/OpenAI/Google; includes + per-agent Auto/On/Off overrides and guidance on when not to enable it (local or weaker models). +- path: user-guide/restoring-scenes.md + title: Restoring Scenes + summary: Two ways to return a scene to a previous state, the timeline (drag a slider across the automatic changelog version + 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. +- path: user-guide/saving.md + title: Saving + summary: How scene saving works, .json save files in the project directory, automatic changelog version history, restore + points, saving from the scene or world editor, auto save (and why the toggle can be locked), Save As, and forking a copy + of a scene from a message via the timeline. +- path: user-guide/scenario-tools.md + title: Scene Tools + 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/, + templates/, shared-context/, and info/ subdirectories, and why the structure matters for backups and restores. +- path: user-guide/scene-state-reset.md + title: Reset Scene State + summary: The Reset Scene State dialog for selectively clearing scene-internal state, context DB (memory/RAG reimport), history, + intent state, agent states, and reinforcements, without touching character definitions or world entries. Useful for stale + cached data or restarting progression. +- path: user-guide/templates/attribute.md + title: Character Attribute + summary: 'Create and use Character Attribute templates for AI-generating character traits in the World Editor: attribute + name, generation priority, additional instructions with {character_name}/{player_name} variables, spice and writing-style + support flags, favorites, and applying the template in the Character Editor''s Attributes section.' +- path: user-guide/templates/detail.md + title: Character Detail + summary: 'Create and use Character Detail templates for AI-generating longer-form character backstory answers: question/statement + field (questions work best), additional instructions with {character_name}/{player_name} variables, spice and writing-style + support flags, and applying the template in the Character Editor''s Details section.' +- path: user-guide/templates/groups.md + title: Groups + summary: All world-state templates live in groups; this page covers creating a group (name, author, description) via the + Create Group button in the Templates sidebar and deleting a group. +- path: user-guide/templates/index.md + title: Templates + summary: 'Overview of the Templates tab and the six world-state template types: Character Attribute, Character Detail, Spices + (randomization), Writing Style, State Reinforcement (tracked states), and Scene Type. Templates are organized into groups + and shared across all games.' +- path: user-guide/templates/scene_type.md + title: Scene Types + summary: Create Scene Type templates whose instructions guide the director's style and pacing for a scene; currently an + early WIP, usable only via import in the World Editor's Scene Direction section. +- path: user-guide/templates/spice.md + title: Spice Collection + summary: 'Create Spice Collection templates that inject a random {spice} instruction into attribute/detail generation: writing + the additional-instructions pattern, adding or AI-generating spice values, and a worked example of applying a collection + with the Generation Settings spice button and raising the application chance (ctrl-click) to 100%.' +- path: user-guide/templates/state.md + title: State Reinforcement + summary: 'Create State Reinforcement templates to track character or world states: question/attribute name, state type (all + characters, player, NPCs, world), context attachment method, re-evaluation interval in turns, additional instructions, + and auto-create for new characters; generated from the Character Editor''s States tab.' +- path: user-guide/templates/visual-finalizer.md + title: Visual Prompt Finalizer + summary: 'Visual prompt finalizer templates: reusable sets of image-prompt post-processing actions (exact/fuzzy/regex replace + or AI instruction) insertable into the visualizer agent''s Prompt Finalization table via the Preset picker; includes the + shipped Ideogram JSON preset and create/edit/delete steps.' +- path: user-guide/templates/writing-style.md + title: Writing Styles + summary: Create Writing Style templates that apply tone/style instructions to generated content, and configure phrase detection + (0.30.0+) for the Editor agent's revision actions using regex or semantic-similarity matching against unwanted phrases, + including the requirement to set the writing style in Scene Settings. +- path: user-guide/time-passage.md + title: Time Passage Management + summary: Inserting time passage markers via the Advance Time scene tool (preset durations or custom) or between messages + in the scene view, editing and deleting them in the scene view or World State Manager, and how time passages affect the + AI's temporal context. +- path: user-guide/tracking-a-state.md + title: Tracked states + summary: Introduction to tracked states, periodic reinforcements of character or world facts kept in AI context. Explains + what they are, how to reach the character editor and world editor state managers, inspecting state values, quickly applying + states via templates, and the context attachment method. +- path: user-guide/visual-library.md + title: Visual Library + summary: The Visual Library dialog for generating and managing images, prompt/instruct modes, visual types, reference images, + iterating and regenerating, the image queue, saving to scene assets, uploads, reference configuration, cover crop, and + AI image analysis with tag generation. +- path: user-guide/voice-library.md + title: Voice Library + summary: The Voice Library for managing TTS voices across all providers, global vs scene scope tabs, API status, creating + and managing voices, automatic and manual character voice assignment, and organizing voices with tags, filtering, and + search. +- path: user-guide/world-editor/characters/actor.md + title: Actor management + summary: The Actor tab for AI characters, define dialogue instructions (acting/speaking directions like to a stage actor) + and line-by-line dialogue examples to shape a character's speech style, with AI generate support for instructions. +- path: user-guide/world-editor/characters/attributes.md + title: Attributes + summary: Managing character attributes (short snapshot traits) in the character editor, adding via the New attribute input, + editing, removing, and generating attributes in bulk using attribute templates. +- path: user-guide/world-editor/characters/create.md + title: Character creation + summary: Creating a new character with the Create Character button, the AI generation toggle and instructions field, name, + description, generate-attributes option, the generate-example-dialogue option with its optional guidance field, and the + Controlled by Player switch. +- path: user-guide/world-editor/characters/deactivate.md + title: Activate and Deactivate Characters + summary: Temporarily removing a character from dialogue generation with the Deactivate button and bringing them back with + Activate, instant, without narration, versus doing it through scene tools which narrates the exit/entrance. +- path: user-guide/world-editor/characters/delete.md + title: Delete character + summary: Permanently deleting a character from a scene via the Delete button in the character editor, with a type-the-name + confirmation step. +- path: user-guide/world-editor/characters/description.md + title: Description + summary: Editing a character's description (defining characteristics, personality, background, motivations) in the Description + tab, changes apply automatically as you type, with AI generate/autocomplete support. +- path: user-guide/world-editor/characters/details.md + title: Details + summary: Managing character details (freeform background/history entries, names can be titles or questions), adding, editing, + removing, setting up a tracked state from a detail, pinning a detail to context, and generating details from templates. +- path: user-guide/world-editor/characters/folders.md + title: Folders + summary: Organizing the character list sidebar into collapsible folders (0.37+), assigning via the folder input in the character + editor, renaming, expanding/collapsing, member count chips, and how folder assignments sync across scenes. +- path: user-guide/world-editor/characters/import.md + title: Import character + summary: Importing a character from another existing Talemate scene using the Import character button in the Characters + tab, pick source scene, pick character, import. +- path: user-guide/world-editor/characters/index.md + title: Character Editor + summary: Overview of the Characters tab in the World Editor and a character's components, attributes, details, description, + tracked states, actor management (dialogue instructions/examples), and visuals, plus links to activation, player control, + and folders. +- path: user-guide/world-editor/characters/player.md + title: Make or Unmark a Player Character + summary: Switching which character is player-controlled at any time, the Make Player Character button (auto-demotes the + previous player to AI, auto-activates deactivated characters) and unmarking so all characters are AI-controlled. +- path: user-guide/world-editor/characters/states.md + title: Tracked states + summary: Setting up tracked state reinforcements for a character in the States tab, naming the state (questions work best), + update frequency in turns, context attachment method, additional instructions, and refreshing or resetting a state. +- path: user-guide/world-editor/characters/visuals.md + title: Character Visuals + summary: The character Visuals tab with its four sub-tabs, cover images (CHARACTER_CARD reference images), portraits (types, + management, automatic emotion-based selection, missing-tags warning), rules for permanent visual traits, and prompt finalization, + plus generation prerequisites. +- path: user-guide/world-editor/context-db.md + title: Context DB + summary: Browsing and searching the scene's semantic memory database (Memory Agent) in the world editor, embedding-based + search with the Search Strictness slider (0.1-2.0), manually adding entries, and a reset tool to rebuild the database. +- path: user-guide/world-editor/generation-settings.md + title: Generation Settings + summary: World editor AI content generation controls, ctrl+click Generate for an instructions dialog (seed material that + must be reflected), alt+click to rewrite existing content, generation length with per-context-type memory, and applying + writing style and spice templates. +- path: user-guide/world-editor/history/index.md + title: History + summary: The scene History manager, static archived entries (manually dated pre-scene backstory), summary entries auto-generated + by the Summarizer agent, and layered history. Covers editing, deleting, regenerating, inspecting sources, compression + statistics, Regenerate everything, and reset. +- path: user-guide/world-editor/history/layered-history.md + title: Layered History + summary: Explains layered history compression, base layer 0 (static plus summary entries) is re-summarized into increasingly + compressed layers 1+, creating a detail gradient so old events stay in AI context. Covers compression statistics, configuration, + management, and how layers appear in context. +- path: user-guide/world-editor/pins.md + title: Pins + summary: Pinning context entries so the AI always includes them in generation, creating pins from the Context editor or + from character details, toggling pins active/inactive, and conditional automatic pinning via AI prompt conditions or game + state conditions. +- path: user-guide/world-editor/scene/direction.md + title: Direction + summary: The scene Direction tab, set director instructions (story-specific guidance for director chat and auto-direction), + the overall story intention, and the current scene phase/intention, both required for the director's Autonomous Scene + Direction to be available. +- path: user-guide/world-editor/scene/export.md + title: Export + summary: Exporting a scene from the world editor, Talemate scene format (currently the only format), choosing the export + file name, and the reset-progress option that clears messages, choices, and stateful data for a clean distributable copy. +- path: user-guide/world-editor/scene/game-state.md + title: Game State + summary: The Game State tab for viewing and editing persistent game state variables used by node modules, a raw JSON editor + (invalid JSON is rejected) and a Watched Variables manager for adding/removing watch paths shown in Debug Tools. +- path: user-guide/world-editor/scene/outline.md + title: Outline + summary: The scene Outline tab, edit the scene's title, content classification (tone/expectation of generated content), + perspective and tense, description, and the introduction text shown when a user first enters the scene. +- path: user-guide/world-editor/scene/settings.md + title: Settings + summary: Scene-level settings, selecting a writing style template, locking the save file to prevent overwriting, the experimental + tag, restoration settings (designate a save as restore point and reset via Restore Scene), and the agent settings file. +- path: user-guide/world-editor/scene/shared-context.md + title: Shared World & Episodes + summary: The Shared World panel, create shared context .json files to share characters, world entries, and history across + scenes in a project, link scenes, bulk-share elements, and manage episodes (alternative storylines) including creating + new scenes from an episode. +- path: user-guide/world-editor/scene/visuals.md + title: Scene Visuals + summary: 'World Editor Scene Visuals tab: manage background illustrations, scene illustrations and scene cards (upload, + Generate New, Generate Variation with batch prompts), set the scene cover image, set/unset the scene backdrop with the + Render backdrop switch, and edit the visualizer agent''s per-scene Prompt Finalization overrides.' +- path: user-guide/world-editor/suggestions.md + title: Suggestions + summary: The Suggestions view where agent-proposed world changes (currently character changes only) accumulate for review, + plus manually requesting proposals via the Suggest Changes button in the character editor. +- path: user-guide/world-editor/world/states.md + title: Tracked states + summary: Setting up world-level tracked state reinforcements via the New State Reinforcement button in the World editor, + question or state description, optional pre-specified value, update frequency in turns, context attachment method, additional + instructions, and resetting the reinforcement. +- path: user-guide/world-state.md + title: World State + summary: The world state snapshot panel, characters, objects, and tracked states summarized by the World State agent, clickable + entity highlights in recent scene messages, manual and automatic snapshot refreshing, how the snapshot carries forward, + wiping it, and pinning the snapshot to the scene. diff --git a/src/talemate/agents/help/docs.py b/src/talemate/agents/help/docs.py new file mode 100644 index 00000000..64f9873c --- /dev/null +++ b/src/talemate/agents/help/docs.py @@ -0,0 +1,487 @@ +""" +Documentation access tools for the help agent. + +Exposes the bundled markdown documentation (docs/ in the talemate root) to the +LLM through four focal callbacks: look up pages by topic (find_docs, a +keyword-scored match over the generated index, weighted by token rarity and +diluted by how many distinct tokens a summary has), full-text search, read a +full document, and read a single section of a document. The generated index +(docs-index.yaml, shipped next to this module) provides path/title/summary for +every page; only a compact section overview of it is injected into the prompt. +""" + +import math +import re +from pathlib import Path +from typing import NamedTuple + +import structlog +import yaml + +from talemate.path import TALEMATE_ROOT + +__all__ = [ + "DOCS_DIR", + "DOCS_SITE_URL", + "doc_url", + "docs_available", + "load_docs_index", + "docs_section_overview", + "find_docs", + "search_docs", + "read_doc", + "read_doc_section", +] + +log = structlog.get_logger("talemate.agents.help.docs") + +DOCS_DIR = TALEMATE_ROOT / "docs" +DOCS_INDEX_FILE = Path(__file__).parent / "docs-index.yaml" + +# published manual (mkdocs site_url) - doc references shown to the user link here +DOCS_SITE_URL = "https://vegu-ai.github.io/talemate/" + +MAX_SEARCH_RESULTS = 40 +MAX_RESULTS_PER_FILE = 5 +MAX_DOC_CHARS = 15000 + +# Short content descriptions for the section overview injected into the +# LLM prompt (the per-page index stays OUT of the prompt - pages are +# located at runtime via find_docs). Prefixes not listed fall back to the +# prefix itself. +SECTION_DESCRIPTIONS = { + "getting-started": "Installation (Windows/Linux/Docker), first launch, connecting a client, loading the first scene", + "user-guide": "Top-level guides: interacting with scenes, saving/restoring, world state, tracking states, character import, scene tools, debug tools, visual and voice library", + "user-guide/agents": "Every agent's settings and behavior: conversation, creator, director, editor, help, memory, narrator, summarizer, visualizer, voice, world state", + "user-guide/apis": "Third-party inference API key setup (OpenAI, Anthropic, Google, OpenRouter, ...)", + "user-guide/app-settings": "Application settings dialog pages", + "user-guide/clients": "LLM client types, setup and per-client options", + "user-guide/howto": "Tutorials and walkthroughs, including the multi-part dynamic scenario crash course", + "user-guide/integrations": "Third-party integrations", + "user-guide/node-editor": "Node editor: core concepts (graphs, states, functions, events, modules, packaging) and the complete node reference documenting every node's registry path, sockets and properties", + "user-guide/prompts": "Prompt template overrides", + "user-guide/templates": "World-state templates (attributes, details, spices, writing styles, ...)", + "user-guide/world-editor": "World editor: scene, characters, world entries, context DB, history, pins, suggestions", + "dev": "Developer documentation: agent/client internals, templates, third-party reference", +} + +# tokens too generic to contribute to find_docs scoring +_FIND_STOPWORDS = { + "the", + "a", + "an", + "and", + "or", + "to", + "of", + "in", + "on", + "for", + "with", + "how", + "do", + "does", + "what", + "where", + "when", + "is", + "are", + "my", + "i", + "use", + "using", + "it", + "can", + "you", +} + +FIND_DOCS_LIMIT = 5 + +_index_cache: list[dict] | None = None + + +class _IndexedEntry(NamedTuple): + """An index entry with its fields tokenized for scoring.""" + + entry: dict + title_tokens: set[str] + path_tokens: set[str] + summary_tokens: set[str] + title_phrase: str + path_phrase: str + summary_phrase: str + + +class _ScoringIndex(NamedTuple): + """The index prepared for scoring, with its corpus-wide statistics.""" + + entries: list[_IndexedEntry] + idf: dict[str, float] + average_summary_tokens: float + + +_scoring_cache: tuple[list[dict], _ScoringIndex] | None = None + + +def docs_available() -> bool: + return DOCS_DIR.is_dir() + + +def load_docs_index() -> list[dict]: + """Load the generated documentation index (path, title, summary per page).""" + global _index_cache + if _index_cache is not None: + return _index_cache + try: + _index_cache = yaml.safe_load(DOCS_INDEX_FILE.read_text(encoding="utf-8")) + except Exception as e: + log.error("help.docs.index.load.error", file=str(DOCS_INDEX_FILE), error=e) + _index_cache = [] + return _index_cache + + +def docs_section_overview() -> list[dict]: + """ + The documentation grouped into sections, for the LLM prompt: one entry + per section with its path prefix, page count and a content description. + """ + counts: dict[str, int] = {} + for entry in load_docs_index(): + parts = entry["path"].split("/") + if len(parts) < 2: + continue + if parts[0] == "user-guide" and len(parts) > 2: + prefix = "/".join(parts[:2]) + else: + prefix = parts[0] + counts[prefix] = counts.get(prefix, 0) + 1 + return [ + { + "prefix": prefix, + "count": count, + "description": SECTION_DESCRIPTIONS.get(prefix, prefix), + } + for prefix, count in sorted(counts.items()) + ] + + +def _stem(token: str) -> str: + """ + Naive morphological fold: plurals ("node" matches "nodes", but not + e.g. "class") and -ing forms ("track" matches "tracking"). Applied to + query and index tokens alike, so folded forms stay consistent. + """ + if len(token) > 3 and token.endswith("s") and not token.endswith("ss"): + token = token[:-1] + if len(token) > 5 and token.endswith("ing"): + token = token[:-3] + return token + + +def _token_seq(text: str) -> list[str]: + """ + Normalize text into an ordered word-token sequence: camelCase split + (so registry paths like scene/GetCharacter match the spaced page + titles), lowercased, split on non-alphanumerics, plural-folded. + """ + decamel = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", " ", text) + return [_stem(t) for t in re.split(r"[^a-z0-9]+", decamel.lower()) if t] + + +def _token_set(text: str) -> set[str]: + """ + Word tokens for matching: both the camelCase-split tokens and the raw + ones, so "koboldcpp" matches "KoboldCpp" as well as "Kobold Cpp". + """ + raw = {_stem(t) for t in re.split(r"[^a-z0-9]+", text.lower()) if t} + return raw | set(_token_seq(text)) + + +def _phrase_text(text: str) -> str: + """The token sequence as a padded string, so phrases match on word boundaries.""" + return f" {' '.join(_token_seq(text))} " + + +def _scoring_index() -> _ScoringIndex: + """ + The loaded index prepared for scoring: per-entry token sets and phrase + strings, the inverse document frequency of every token, and the average + number of distinct summary tokens. Rebuilt whenever a different index is + loaded. + """ + global _scoring_cache + index = load_docs_index() + if _scoring_cache is not None and _scoring_cache[0] is index: + return _scoring_cache[1] + + entries = [ + _IndexedEntry( + entry=entry, + title_tokens=_token_set(entry["title"]), + path_tokens=_token_set(entry["path"]), + summary_tokens=_token_set(entry["summary"]), + title_phrase=_phrase_text(entry["title"]), + path_phrase=_phrase_text(entry["path"]), + summary_phrase=_phrase_text(entry["summary"]), + ) + for entry in index + ] + + document_frequency: dict[str, int] = {} + for indexed in entries: + for token in ( + indexed.title_tokens | indexed.path_tokens | indexed.summary_tokens + ): + document_frequency[token] = document_frequency.get(token, 0) + 1 + + total = len(entries) + scoring = _ScoringIndex( + entries=entries, + # BM25's idf: a token in every page is worth almost nothing, a token + # in one page is worth several times an average one + idf={ + token: math.log(1 + (total - count + 0.5) / (count + 0.5)) + for token, count in document_frequency.items() + }, + # floored at one token so an index whose summaries all tokenize to + # nothing cannot divide by zero + average_summary_tokens=max( + 1.0, sum(len(indexed.summary_tokens) for indexed in entries) / max(total, 1) + ), + ) + _scoring_cache = (index, scoring) + return scoring + + +def find_docs(query: str, limit: int = FIND_DOCS_LIMIT) -> list[dict] | str: + """ + Look up documentation pages by topic. + + Keyword-scored match over the full index (path, title, summary) - + deterministic, no LLM involved. Tokens match on word boundaries (a + query token "set" does not match "settings") and are weighted by how + rare they are across the index, so a distinctive term ("koboldcpp") + counts for far more than a ubiquitous one ("context"). Token hits in a + summary are diluted by the whole of its length; the phrase bonus is + diluted only once a summary runs past twice the typical length, and an + ordinary-length summary keeps it in full - deliberately, since an exact + phrase in a summary of normal length is a real signal, and taxing it + demotes pages that are long because they genuinely cover a lot. So a + summary well past typical cannot buy rank with a passing mention; one + just over it still can. Returns the best matches with their path, title, + summary and manual URL. + """ + query_seq = _token_seq(query or "") + if not query_seq: + return "Empty query." + stopwords = _FIND_STOPWORDS | {_stem(w) for w in _FIND_STOPWORDS} + tokens = {t for t in _token_set(query or "") if len(t) > 1 and t not in stopwords} + # word-boundary phrase, only meaningful for multi-word queries + phrase = f" {' '.join(query_seq)} " if len(query_seq) > 1 else None + + scoring = _scoring_index() + scored: list[tuple[float, dict]] = [] + for indexed in scoring.entries: + # a longer-than-average summary dilutes its own matches; a shorter + # one is not rewarded for its brevity + dilution = max( + 1.0, len(indexed.summary_tokens) / scoring.average_summary_tokens + ) + # a phrase hit is a single event, not one chance per token, so it is + # diluted by how far the summary runs PAST typical length rather than + # by the whole of it: an ordinary summary pays nothing, and only one + # that has bought enough text for a passing mention to be likely does. + # This leaves a deadband below 2x that the generator's summary cap + # keeps most entries inside - the cap is the primary control here and + # this is the backstop against a pathological entry, not a full fix + phrase_dilution = max(1.0, dilution - 1.0) + score = 0.0 + for token in tokens: + weight = scoring.idf.get(token, 0.0) + if token in indexed.title_tokens: + score += 3 * weight + if token in indexed.path_tokens: + score += 2 * weight + if token in indexed.summary_tokens: + score += weight / dilution + if phrase: + if phrase in indexed.title_phrase: + score += 5 + elif phrase in indexed.path_phrase: + score += 3 + elif phrase in indexed.summary_phrase: + # titles and paths are not length-variable, summaries are: an + # undiluted bonus here is rank a long summary buys outright + score += 3 / phrase_dilution + if score > 0: + scored.append((score, indexed.entry)) + scored.sort(key=lambda item: (-item[0], item[1]["path"])) + if not scored: + return ( + f"No documentation pages match '{query}'. Try different terms, " + f"or search_docs for a full-text search." + ) + return [ + { + "path": entry["path"], + "title": entry["title"], + "summary": entry["summary"], + "url": doc_url(entry["path"]), + } + for _, entry in scored[:limit] + ] + + +def doc_url(path: str) -> str: + """ + Map a documentation path to its URL on the published manual + (mkdocs directory URLs: `a/b.md` -> `a/b/`, `a/index.md` -> `a/`). + """ + path = path.removesuffix(".md") + if path.endswith("index"): + path = path[: -len("index")] + path = path.strip("/") + return f"{DOCS_SITE_URL}{path}/" if path else DOCS_SITE_URL + + +def _resolve_doc_path(path: str) -> Path | None: + """Resolve a relative documentation path, refusing anything outside DOCS_DIR.""" + try: + resolved = (DOCS_DIR / path).resolve() + resolved.relative_to(DOCS_DIR.resolve()) + except (ValueError, OSError): + return None + if resolved.suffix.lower() != ".md" or not resolved.is_file(): + return None + return resolved + + +def _strip_markdown_noise(content: str) -> str: + """Remove image references - they carry no information for the LLM.""" + return re.sub(r"!\[[^\]]*\]\([^)]*\)", "", content) + + +def _truncate(content: str, limit: int = MAX_DOC_CHARS) -> str: + if len(content) <= limit: + return content + return ( + content[:limit] + + f"\n\n[... truncated at {limit} characters - read a specific section for more]" + ) + + +def search_docs(query: str) -> list[dict] | str: + """ + Case-insensitive search across all documentation pages. + + The query is treated as a regular expression, falling back to a literal + match when it does not compile. Returns matches as + {path, line, text} dicts. + """ + if not docs_available(): + return "Documentation directory is not available in this installation." + + try: + pattern = re.compile(query, re.IGNORECASE) + except re.error: + pattern = re.compile(re.escape(query), re.IGNORECASE) + + results: list[dict] = [] + + for md_file in sorted(DOCS_DIR.rglob("*.md")): + try: + lines = md_file.read_text(encoding="utf-8").splitlines() + except OSError: + continue + per_file = 0 + for line_number, line in enumerate(lines, start=1): + if not pattern.search(line): + continue + results.append( + { + "path": str(md_file.relative_to(DOCS_DIR)), + "line": line_number, + "text": line.strip()[:300], + } + ) + per_file += 1 + if per_file >= MAX_RESULTS_PER_FILE: + break + if len(results) >= MAX_SEARCH_RESULTS: + results = results[:MAX_SEARCH_RESULTS] + break + + if not results: + return f"No matches for '{query}'. Try a broader or different term." + return results + + +def read_doc(path: str) -> dict | str: + """Read a full documentation page. Path is relative to the docs root.""" + if not docs_available(): + return "Documentation directory is not available in this installation." + + resolved = _resolve_doc_path(path) + if not resolved: + return f"Document '{path}' does not exist. Use paths from the documentation index or search results." + + content = _strip_markdown_noise(resolved.read_text(encoding="utf-8")) + return { + "path": path, + "url": doc_url(path), + "content": _truncate(content), + } + + +def read_doc_section(path: str, section: str) -> dict | str: + """ + Read a single section of a documentation page. + + The section is matched against markdown headings (case-insensitive, + substring). Returns the heading's content including any subsections. + """ + if not docs_available(): + return "Documentation directory is not available in this installation." + + resolved = _resolve_doc_path(path) + if not resolved: + return f"Document '{path}' does not exist. Use paths from the documentation index or search results." + + lines = resolved.read_text(encoding="utf-8").splitlines() + heading_re = re.compile(r"^(#{1,6})\s+(.*)$") + + needle = section.strip().lower().lstrip("#").strip() + start: int | None = None + level = 0 + headings: list[str] = [] + + for i, line in enumerate(lines): + match = heading_re.match(line) + if not match: + continue + headings.append(match.group(2).strip()) + if start is None and needle in match.group(2).strip().lower(): + start = i + level = len(match.group(1)) + + if start is None: + return { + "path": path, + "error": f"No heading matching '{section}' found.", + "available_sections": headings, + } + + end = len(lines) + for i in range(start + 1, len(lines)): + match = heading_re.match(lines[i]) + if match and len(match.group(1)) <= level: + end = i + break + + content = _strip_markdown_noise("\n".join(lines[start:end]).strip()) + return { + "path": path, + "url": doc_url(path), + "section": lines[start].lstrip("#").strip(), + "content": _truncate(content), + } diff --git a/src/talemate/agents/help/schema.py b/src/talemate/agents/help/schema.py new file mode 100644 index 00000000..888efd5e --- /dev/null +++ b/src/talemate/agents/help/schema.py @@ -0,0 +1,78 @@ +import time +import uuid +from typing import Any, Literal + +import pydantic + +__all__ = [ + "HelpChat", + "HelpChatMessage", + "HelpChatDocResultMessage", + "HelpChatListEntry", + "HelpChatStore", +] + + +class HelpChatMessage(pydantic.BaseModel): + """ + A conversational message in a help chat. + """ + + message: str = "" + source: Literal["help", "user"] + type: Literal["text"] = "text" + id: str = pydantic.Field(default_factory=lambda: str(uuid.uuid4())) + + def __str__(self) -> str: + return self.message + + +class HelpChatDocResultMessage(pydantic.BaseModel): + """ + The result of a tool call made by the help agent (documentation lookups, + settings reads and updates). The type stays "doc_result" for backward + compatibility with persisted chats. + """ + + name: str + arguments: dict[str, Any] = pydantic.Field(default_factory=dict) + result: Any = None + type: Literal["doc_result"] = "doc_result" + id: str = pydantic.Field(default_factory=lambda: str(uuid.uuid4())) + + def __str__(self) -> str: + return f"{self.name}({self.arguments}) -> {self.result}" + + +class HelpChat(pydantic.BaseModel): + """ + A multi-turn conversation with the help agent. + """ + + messages: list[HelpChatMessage | HelpChatDocResultMessage] = pydantic.Field( + default_factory=list + ) + id: str = pydantic.Field(default_factory=lambda: str(uuid.uuid4())[:10]) + title: str | None = None + scene_aware: bool = False + created_at: float = pydantic.Field(default_factory=time.time) + + +class HelpChatListEntry(pydantic.BaseModel): + """ + Lightweight chat entry for listing chats without full message history. + """ + + id: str + title: str | None = None + created_at: float = 0.0 + + +class HelpChatStore(pydantic.BaseModel): + """ + On-disk store for help chats. Help chats are application level (not tied + to a scene), so they persist to their own file rather than scene state. + """ + + chats: dict[str, HelpChat] = pydantic.Field(default_factory=dict) + last_active_chat_id: str | None = None diff --git a/src/talemate/agents/help/settings.py b/src/talemate/agents/help/settings.py new file mode 100644 index 00000000..2801fc6a --- /dev/null +++ b/src/talemate/agents/help/settings.py @@ -0,0 +1,705 @@ +""" +Settings access tools for the help agent. + +Exposes Talemate's configuration to the LLM through focal callbacks: reading +and updating agent settings (globally or as per-scene overrides), reading and +updating application config sections, and a read-only view of the configured +clients. Secret values (API keys) are never exposed and cannot be written. + +Write operations mirror the frontend save paths: agent settings persist via +``Agent.save_config`` + ``commit_config`` and re-emit the agent status; app +config writes re-validate the whole section through its pydantic model and +fire ``config.changed`` so the frontend receives a fresh ``app_config`` +payload. +""" + +from typing import TYPE_CHECKING, Any, Literal + +import pydantic +import structlog + +import talemate.instance as instance +from talemate.agents.base import DYNAMIC_CHILDREN_FIELD +from talemate.client.registry import get_client_class +from talemate.config import commit_config, get_config +from talemate.scene_agent_settings import ( + DEFAULT_SETTINGS_FILENAME, + SceneAgentSettings, + UNSET, + agent_settings_dir, +) +from talemate.util.encryption import SENSITIVE_FIELD_NAMES + +if TYPE_CHECKING: + from talemate.agents.base import Agent, AgentAction, AgentActionConfig + +__all__ = [ + "read_agent_settings", + "update_agent_setting", + "clear_agent_setting_scene_override", + "read_app_config", + "update_app_config", + "read_clients", +] + +log = structlog.get_logger("talemate.agents.help.settings") + +REDACTED = "(hidden)" + +# field types whose values must never be shown to (or written by) the LLM +SECRET_FIELD_TYPES = ("password", "unified_api_key") + +# field types the update tool can write - everything else (tables, weights, +# vectors, template pickers, ...) is UI-only +WRITABLE_FIELD_TYPES = ("bool", "number", "text", "autocomplete", "blob", "flags") + +READABLE_CONFIG_SECTIONS = ("game", "appearance", "creator", "presets", "prompts") +WRITABLE_CONFIG_SECTIONS = ("game", "appearance", "creator") + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + + +def _get_agent(agent_type: str) -> "Agent | None": + return instance.AGENTS.get(agent_type) + + +def _unknown_agent(agent_type: str) -> str: + return ( + f"Unknown agent '{agent_type}'. " + f"Valid agents: {', '.join(sorted(instance.AGENTS.keys()))}" + ) + + +def _is_secret_field(field: "AgentActionConfig") -> bool: + return field.type in SECRET_FIELD_TYPES + + +def _redact_sensitive(data: Any) -> Any: + """Recursively replace values of sensitive keys (api_key etc.).""" + if isinstance(data, dict): + return { + key: ( + (REDACTED if value else None) + if key in SENSITIVE_FIELD_NAMES + else _redact_sensitive(value) + ) + for key, value in data.items() + } + if isinstance(data, list): + return [_redact_sensitive(item) for item in data] + return data + + +def _coerce_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str) and value.strip().lower() in ( + "true", + "false", + "yes", + "no", + "on", + "off", + ): + return value.strip().lower() in ("true", "yes", "on") + if isinstance(value, (int, float)) and value in (0, 1): + return bool(value) + raise ValueError(f"Expected a boolean (true/false), got {value!r}.") + + +def _resolve_choice(field: "AgentActionConfig", value: Any) -> Any: + """Validate ``value`` against the field's choices, returning the canonical + choice value. Raises ValueError when it matches neither a value nor a label.""" + valid_values = [choice["value"] for choice in field.choices] + if value in valid_values: + return value + # the LLM may have supplied the choice label instead of the value + for choice in field.choices: + if str(choice["label"]).strip().lower() == str(value).strip().lower(): + return choice["value"] + raise ValueError(f"Invalid choice {value!r}. Valid values: {valid_values}") + + +def _coerce_value(field: "AgentActionConfig", value: Any) -> Any: + """Coerce a raw LLM-supplied value to the field's type. + + Raises ValueError with an LLM-readable message when the value cannot be + coerced or fails choice / range validation. + """ + if field.type == "bool": + coerced = _coerce_bool(value) + elif field.type == "number": + try: + number = float(value) + except (TypeError, ValueError): + raise ValueError(f"Expected a number, got {value!r}.") + coerced = int(number) if number == int(number) else number + if field.min is not None and coerced < field.min: + raise ValueError(f"Value {coerced} is below the minimum of {field.min}.") + if field.max is not None and coerced > field.max: + raise ValueError(f"Value {coerced} is above the maximum of {field.max}.") + elif field.type == "flags": + # flags fields hold a list of selected choice values + if not isinstance(value, list): + raise ValueError( + f"This setting holds a list of values - pass the FULL new list, got {value!r}." + ) + return [_resolve_choice(field, item) for item in value] + else: + coerced = value if isinstance(value, str) else str(value) + + if field.choices: + return _resolve_choice(field, coerced) + + return coerced + + +def _field_payload( + agent: "Agent", action_key: str, config_key: str, field: "AgentActionConfig" +) -> dict: + """LLM-facing description of a single agent setting field.""" + payload: dict[str, Any] = { + "label": field.label, + "type": field.type, + "value": REDACTED if _is_secret_field(field) else field.value, + } + if field.description: + payload["description"] = field.description + if field.choices: + payload["choices"] = [ + {"label": choice["label"], "value": choice["value"]} + for choice in field.choices + ] + if field.min is not None: + payload["min"] = field.min + if field.max is not None: + payload["max"] = field.max + if field.type not in WRITABLE_FIELD_TYPES or _is_secret_field(field): + payload["read_only"] = "can only be changed through the settings dialog" + if not field.scene_overridable: + payload["scene_overridable"] = False + + overrides = agent.scene_overrides() + if overrides is not None and not _is_secret_field(field): + override_value = overrides.get_value(agent.agent_type, action_key, config_key) + if override_value is not UNSET: + payload["scene_override"] = override_value + + return payload + + +def _action_payload(agent: "Agent", action_key: str, action: "AgentAction") -> dict: + payload: dict[str, Any] = { + "label": action.label, + "enabled": action.enabled, + } + if action.description: + payload["description"] = action.description + if action.can_be_disabled: + payload["can_be_disabled"] = True + + overrides = agent.scene_overrides() + if overrides is not None: + enabled_override = overrides.get_enabled(agent.agent_type, action_key) + if enabled_override is not UNSET: + payload["enabled_scene_override"] = enabled_override + + if action.config: + payload["settings"] = { + config_key: _field_payload(agent, action_key, config_key, field) + for config_key, field in action.config.items() + if config_key != DYNAMIC_CHILDREN_FIELD + } + return payload + + +# --------------------------------------------------------------------------- +# agent settings +# --------------------------------------------------------------------------- + + +def read_agent_settings(agent_type: str) -> dict | str: + """Return the full settings state of one agent.""" + agent = _get_agent(agent_type) + if not agent: + return _unknown_agent(agent_type) + + payload: dict[str, Any] = { + "agent": agent.agent_type, + "label": agent.verbose_name, + "enabled": agent.enabled, + } + if getattr(agent, "client", None): + payload["client"] = agent.client.name + + actions = getattr(agent, "actions", None) or {} + payload["actions"] = { + action_key: _action_payload(agent, action_key, action) + for action_key, action in actions.items() + } + + overrides = agent.scene_overrides() + if overrides is not None: + payload["note"] = ( + "This scene has an agent-settings override file linked " + f"({overrides.filename}). Fields with a 'scene_override' value are " + "currently masked by it; the effective value is the override." + ) + + return payload + + +def _resolve_setting( + agent_type: str, action_key: str, setting_key: str +) -> "tuple[Agent, AgentAction, AgentActionConfig | None] | str": + """Resolve agent/action/setting, returning an error string on failure. + + ``setting_key`` may be ``"enabled"`` to target the action's enabled flag, + in which case the returned field is None. + """ + agent = _get_agent(agent_type) + if not agent: + return _unknown_agent(agent_type) + + actions = getattr(agent, "actions", None) or {} + action = actions.get(action_key) + if not action: + return ( + f"Agent '{agent_type}' has no action '{action_key}'. " + f"Valid actions: {', '.join(sorted(actions.keys()))}" + ) + + if setting_key == "enabled": + return agent, action, None + + field = (action.config or {}).get(setting_key) + if not field or setting_key == DYNAMIC_CHILDREN_FIELD: + valid = sorted(k for k in (action.config or {}) if k != DYNAMIC_CHILDREN_FIELD) + return ( + f"Action '{action_key}' has no setting '{setting_key}'. " + f"Valid settings: {', '.join(valid) or '(none)'}" + ) + + return agent, action, field + + +def _open_modal_conflict(agent_type: str, open_agent_modal: str | None) -> str | None: + """Refusal message when the target agent's settings dialog is open. + + The dialog holds its own copy of the agent's settings: it will not show + an external change, and closing it with pending edits writes the stale + copy back - silently reverting anything changed while it was open. + """ + if not open_agent_modal or open_agent_modal != agent_type: + return None + return ( + f"The settings dialog for the '{agent_type}' agent is currently open in " + "the interface - it holds its own copy of the settings, so it would not " + "show this change and could overwrite it when it closes. Ask the user to " + "close the dialog first, then apply the change again." + ) + + +def _scene_for_overrides(agent: "Agent") -> "tuple[Any, str | None]": + """Return (scene, error) for scene-override writes on ``agent``.""" + scene = getattr(agent, "scene", None) + if scene is None or not getattr(scene, "name", None): + return ( + None, + "No scene is currently loaded - scene overrides need a loaded scene.", + ) + if not getattr(scene, "filename", None) or not getattr(scene, "project_name", None): + return ( + None, + "The scene has not been saved yet - save it before adding scene overrides.", + ) + if getattr(scene, "_agent_settings_opted_out", False): + return None, ( + "This scene has explicitly opted out of agent-settings overrides. " + "The user can change that under World Editor > Scene > Settings." + ) + return scene, None + + +def _ensure_scene_overlay(scene) -> SceneAgentSettings: + """Return the scene's overlay, creating the default-named one if missing. + + Unlike the AgentModal flow, no scene save is forced to persist the link: + a file with the default name is auto-linked on every scene load + (scene_agent_settings.resolve_link_on_load). + """ + if scene.agent_overrides is None: + target_path = agent_settings_dir(scene.save_dir) / DEFAULT_SETTINGS_FILENAME + scene.agent_overrides = SceneAgentSettings(filepath=target_path) + scene.agent_settings_file = DEFAULT_SETTINGS_FILENAME + scene._agent_settings_opted_out = False + return scene.agent_overrides + + +async def _persist_agent_config(agent: "Agent") -> None: + # same sequence as the frontend-driven save paths + # (server.agent_config._persist_and_broadcast) + await agent.save_config() + await commit_config() + await agent.emit_status() + + +async def update_agent_setting( + agent_type: str, + action_key: str, + setting_key: str, + value: Any, + scope: Literal["global", "scene"] = "global", + open_agent_modal: str | None = None, +) -> dict | str: + """Update one agent setting - the global value or a per-scene override. + + ``setting_key`` may be ``"enabled"`` to toggle the action itself. + ``open_agent_modal`` names the agent whose settings dialog is open in the + frontend (from the UX snapshot) - writes targeting it are refused. + """ + if scope not in ("global", "scene"): + return f"Invalid scope '{scope}'. Use 'global' or 'scene'." + + resolved = _resolve_setting(agent_type, action_key, setting_key) + if isinstance(resolved, str): + return resolved + agent, action, field = resolved + + conflict = _open_modal_conflict(agent_type, open_agent_modal) + if conflict: + return conflict + + result: dict[str, Any] = { + "applied": True, + "agent": agent_type, + "action": action_key, + "setting": setting_key, + "scope": scope, + } + + if field is None: + try: + coerced = _coerce_bool(value) + except ValueError as exc: + return str(exc) + + if scope == "global": + if not action.can_be_disabled: + return f"Action '{action_key}' of agent '{agent_type}' is always enabled and cannot be toggled." + result["previous_value"] = action.enabled + action.enabled = coerced + await _persist_agent_config(agent) + else: + if not action.enabled_scene_overridable: + return f"The enabled flag of action '{action_key}' cannot be overridden per scene." + scene, error = _scene_for_overrides(agent) + if error: + return error + overlay = _ensure_scene_overlay(scene) + previous = overlay.get_enabled(agent_type, action_key) + result["previous_value"] = None if previous is UNSET else previous + overlay.set_enabled(agent_type, action_key, coerced) + await overlay.write_to_file() + await agent.emit_status() + + result["new_value"] = coerced + return result + + # regular config field + if _is_secret_field(field): + return ( + f"Setting '{setting_key}' holds an API key or password and can only be " + "changed through the settings dialog." + ) + if field.type not in WRITABLE_FIELD_TYPES: + return ( + f"Setting '{setting_key}' ({field.type}) is too complex to change here - " + "the user should edit it in the agent's settings dialog." + ) + + try: + coerced = _coerce_value(field, value) + except ValueError as exc: + return str(exc) + + if scope == "global": + result["previous_value"] = field.value + field.value = coerced + await _persist_agent_config(agent) + + overrides = agent.scene_overrides() + if ( + overrides is not None + and overrides.get_value(agent_type, action_key, setting_key) is not UNSET + ): + result["warning"] = ( + "A scene override is active for this setting and masks the " + "global value in the current scene." + ) + else: + if not field.scene_overridable: + return f"Setting '{setting_key}' cannot be overridden per scene." + scene, error = _scene_for_overrides(agent) + if error: + return error + overlay = _ensure_scene_overlay(scene) + previous = overlay.get_value(agent_type, action_key, setting_key) + result["previous_value"] = None if previous is UNSET else previous + overlay.set_value(agent_type, action_key, setting_key, coerced) + await overlay.write_to_file() + await agent.emit_status() + + result["new_value"] = coerced + return result + + +async def clear_agent_setting_scene_override( + agent_type: str, + action_key: str, + setting_key: str, + open_agent_modal: str | None = None, +) -> dict | str: + """Remove a per-scene override so the setting falls back to its global value.""" + resolved = _resolve_setting(agent_type, action_key, setting_key) + if isinstance(resolved, str): + return resolved + agent, _action, field = resolved + + conflict = _open_modal_conflict(agent_type, open_agent_modal) + if conflict: + return conflict + + overrides = agent.scene_overrides() + if overrides is None: + return "No scene overrides are linked to the current scene." + + if field is None: + previous = overrides.get_enabled(agent_type, action_key) + if previous is UNSET: + return f"No scene override is set for the enabled flag of '{action_key}'." + overrides.clear_enabled(agent_type, action_key) + else: + previous = overrides.get_value(agent_type, action_key, setting_key) + if previous is UNSET: + return f"No scene override is set for '{action_key}.{setting_key}'." + overrides.clear_value(agent_type, action_key, setting_key) + + await overrides.write_to_file() + await agent.emit_status() + + return { + "cleared": True, + "agent": agent_type, + "action": action_key, + "setting": setting_key, + "removed_override_value": previous, + } + + +# --------------------------------------------------------------------------- +# app config +# --------------------------------------------------------------------------- + + +def read_app_config(section: str) -> dict | str: + """Return one application config section (secrets redacted).""" + section = (section or "").strip().lower() + if section not in READABLE_CONFIG_SECTIONS: + return ( + f"Unknown or unavailable config section '{section}'. " + f"Readable sections: {', '.join(READABLE_CONFIG_SECTIONS)}. " + "Use read_agent_settings for agents and read_clients for clients." + ) + + config = get_config() + data = getattr(config, section).model_dump() + + if section == "presets": + # the *_defaults entries mirror the shipped defaults and double the + # payload without adding information + data.pop("inference_defaults", None) + data.pop("embeddings_defaults", None) + + return { + "section": section, + "writable": section in WRITABLE_CONFIG_SECTIONS, + "values": _redact_sensitive(data), + } + + +def _walk_to_leaf(data: dict, keys: list[str]) -> "tuple[dict, str] | str": + """Walk ``data`` down ``keys``, returning (parent_container, leaf_key).""" + container = data + for i, key in enumerate(keys[:-1]): + if not isinstance(container, dict) or key not in container: + return f"Path segment '{'.'.join(keys[: i + 1])}' does not exist." + container = container[key] + leaf = keys[-1] + if not isinstance(container, dict) or leaf not in container: + return f"Setting '{'.'.join(keys)}' does not exist." + return container, leaf + + +def _coerce_config_leaf(current: Any, value: Any, path: str) -> Any: + """Coerce ``value`` to the type of the existing leaf value.""" + if isinstance(current, bool): + try: + return _coerce_bool(value) + except ValueError: + raise ValueError(f"'{path}' expects a boolean (true/false), got {value!r}.") + if isinstance(current, (int, float)): + try: + number = float(value) + except (TypeError, ValueError): + raise ValueError(f"'{path}' expects a number, got {value!r}.") + return ( + int(number) + if isinstance(current, int) and number == int(number) + else number + ) + if isinstance(current, list): + if not isinstance(value, list): + raise ValueError( + f"'{path}' is a list and must be replaced with a full list value." + ) + return value + if isinstance(current, dict): + raise ValueError( + f"'{path}' is a nested section, not a setting. Target one of its settings instead." + ) + return value if isinstance(value, str) or value is None else str(value) + + +async def update_app_config( + path: str, value: Any, app_settings_dirty: bool = False +) -> dict | str: + """Update one application config setting by dotted path. + + The whole section is re-validated through its pydantic model, so invalid + values are rejected with the validation error. ``app_settings_dirty`` + flags that the settings view in the frontend has unsaved edits - writes + are refused then, since the user's pending changes and the agent's write + would conflict (whichever saves last would overwrite the other). A merely + open settings view is fine: the frontend handles the config push cleanly. + """ + if app_settings_dirty: + return ( + "The application settings view has unsaved edits - applying this " + "change now would conflict with them. Ask the user to save or " + "discard their settings changes first, then apply the change again." + ) + + keys = [key for key in (path or "").strip().split(".") if key] + if len(keys) < 2: + return ( + "Provide a dotted path with at least a section and a setting, " + "e.g. 'game.general.auto_save'." + ) + + section = keys[0].lower() + if section not in WRITABLE_CONFIG_SECTIONS: + return ( + f"Config section '{section}' is not writable here. " + f"Writable sections: {', '.join(WRITABLE_CONFIG_SECTIONS)}." + ) + if any(key in SENSITIVE_FIELD_NAMES for key in keys): + return "API keys and passwords cannot be read or changed through the help chat." + + config = get_config() + section_model = getattr(config, section) + data = section_model.model_dump() + + walked = _walk_to_leaf(data, keys[1:]) + if isinstance(walked, str): + return walked + container, leaf = walked + + previous = container[leaf] + try: + container[leaf] = _coerce_config_leaf(previous, value, path) + except ValueError as exc: + return str(exc) + + try: + validated = type(section_model).model_validate(data) + except pydantic.ValidationError as exc: + first = exc.errors()[0] + return f"Invalid value for '{path}': {first.get('msg', 'validation failed')}" + + setattr(config, section, validated) + await config.set_dirty() + await commit_config() + + return { + "applied": True, + "path": path, + "previous_value": previous, + "new_value": container[leaf], + } + + +# --------------------------------------------------------------------------- +# clients +# --------------------------------------------------------------------------- + + +def _unified_api_key_state(config, client_type: str) -> str | None: + """Key-state string for clients whose API key lives at an app-level + unified config path (openai.api_key, openrouter.api_key, ...) instead of + on the client entry.""" + client_cls = get_client_class(client_type) + path = ( + getattr(client_cls.Meta(), "unified_api_key_config_path", None) + if client_cls + else None + ) + if not path: + return None + value = config + for part in path.split("."): + # `not value` (rather than `is None`) also catches a key cleared to "" + # in Application Settings, which clients treat as unset; intermediate + # config sections are always-truthy pydantic models, so only the leaf + # key value is affected + value = getattr(value, part, None) + if not value: + return None + return f"set (unified API key from application settings: {path})" + + +def read_clients() -> list[dict] | str: + """Read-only summary of the configured LLM clients (API keys redacted).""" + config = get_config() + if not config.clients: + return "No clients are configured yet." + + payload = [] + for name, client_config in config.clients.items(): + entry = { + "name": name, + "type": client_config.type, + "model": client_config.model, + "enabled": client_config.enabled, + "max_token_length": client_config.max_token_length, + "reason_enabled": client_config.reason_enabled, + } + if client_config.preset_group: + entry["preset_group"] = client_config.preset_group + if client_config.api_key: + entry["api_key"] = "set" + else: + entry["api_key"] = ( + _unified_api_key_state(config, client_config.type) or "not set" + ) + + client_instance = instance.CLIENTS.get(name) + if client_instance is not None: + entry["status"] = client_instance.current_status + + payload.append(entry) + + return payload diff --git a/src/talemate/agents/help/storage.py b/src/talemate/agents/help/storage.py new file mode 100644 index 00000000..44dc2289 --- /dev/null +++ b/src/talemate/agents/help/storage.py @@ -0,0 +1,48 @@ +""" +File based persistence for help chats. + +Help chats are application level rather than scene level, so unlike director +chats (which live in scene agent state and persist through scene saves) they +are stored in their own JSON file under the talemate root. +""" + +import os + +import structlog + +from talemate.path import TALEMATE_ROOT + +from .schema import HelpChatStore + +__all__ = [ + "HELP_CHATS_FILE", + "load_store", + "save_store", +] + +log = structlog.get_logger("talemate.agents.help.storage") + +HELP_CHATS_DIR = TALEMATE_ROOT / "chats" +HELP_CHATS_FILE = HELP_CHATS_DIR / "help.json" + + +def load_store() -> HelpChatStore: + """Load the help chat store from disk, returning an empty store on any failure.""" + if not HELP_CHATS_FILE.exists(): + return HelpChatStore() + try: + return HelpChatStore.model_validate_json( + HELP_CHATS_FILE.read_text(encoding="utf-8") + ) + except Exception as e: + log.error("help.storage.load.error", file=str(HELP_CHATS_FILE), error=e) + return HelpChatStore() + + +def save_store(store: HelpChatStore): + # atomic write - a crash mid-write must not truncate the store, since + # load_store falls back to an empty store (losing all chats) + HELP_CHATS_DIR.mkdir(parents=True, exist_ok=True) + tmp_file = HELP_CHATS_FILE.with_suffix(".json.tmp") + tmp_file.write_text(store.model_dump_json(indent=2), encoding="utf-8") + os.replace(tmp_file, HELP_CHATS_FILE) diff --git a/src/talemate/agents/help/websocket_handler.py b/src/talemate/agents/help/websocket_handler.py new file mode 100644 index 00000000..e4a048d2 --- /dev/null +++ b/src/talemate/agents/help/websocket_handler.py @@ -0,0 +1,256 @@ +import pydantic +import structlog + +import talemate.util as util +from talemate.instance import get_agent +from talemate.server.websocket_plugin import Plugin + +from .schema import HelpChat + +log = structlog.get_logger("talemate.server.help") + + +class ChatSelectPayload(pydantic.BaseModel): + chat_id: str + + +class ChatHistoryPayload(pydantic.BaseModel): + chat_id: str + + +class ChatSendPayload(pydantic.BaseModel): + chat_id: str + message: str + ux_snapshot: dict | None = None + + +class ChatRegeneratePayload(pydantic.BaseModel): + chat_id: str + ux_snapshot: dict | None = None + + +class ChatClearPayload(pydantic.BaseModel): + chat_id: str + + +class ChatDeletePayload(pydantic.BaseModel): + chat_id: str + + +class ChatUpdateSceneAwarePayload(pydantic.BaseModel): + chat_id: str + scene_aware: bool + + +class HelpWebsocketHandler(Plugin): + """ + Websocket handler for the help agent chat. + + Generation runs as a tracked background task on the agent so it never + blocks the websocket receive loop or the main talemate loop. + """ + + router = "help" + + @property + def help_agent(self): + return get_agent("help") + + def _chat_history_payload(self, chat: HelpChat, **overrides) -> dict: + payload = { + "type": "help", + "action": "chat_history", + "chat_id": chat.id, + "messages": [message.model_dump() for message in chat.messages], + "title": chat.title, + "scene_aware": chat.scene_aware, + "token_total": sum( + util.count_tokens(str(message)) for message in chat.messages + ), + } + payload.update(overrides) + return payload + + def _chat_list_payload(self) -> list[dict]: + return [entry.model_dump() for entry in self.help_agent.chat_list()] + + def _make_generation_callbacks(self): + async def _on_update(chat_id, new_messages): + try: + self.websocket_handler.queue_put( + { + "type": "help", + "action": "chat_append", + "chat_id": chat_id, + "messages": [message.model_dump() for message in new_messages], + } + ) + except Exception as e: + log.error("help.chat.websocket.on_update.error", error=e) + + async def _on_done(chat_id): + try: + self.websocket_handler.queue_put( + { + "type": "help", + "action": "chat_done", + "chat_id": chat_id, + } + ) + except Exception as e: + log.error("help.chat.websocket.on_done.error", error=e) + + async def _on_title_generated(chat_id, title): + try: + self.websocket_handler.queue_put( + { + "type": "help", + "action": "chat_title_updated", + "chat_id": chat_id, + "title": title, + } + ) + except Exception as e: + log.error("help.chat.websocket.on_title_generated.error", error=e) + + return _on_update, _on_done, _on_title_generated + + def _make_error_handler(self, chat_id: str): + async def _on_error(exc): + self.websocket_handler.queue_put( + { + "type": "help", + "action": "chat_done", + "chat_id": chat_id, + "error": str(exc), + } + ) + + return _on_error + + async def handle_chat_list(self, data: dict): + self.websocket_handler.queue_put( + { + "type": "help", + "action": "chat_list", + "chat_list": self._chat_list_payload(), + "last_active_chat_id": self.help_agent.chat_get_last_active_id(), + } + ) + + async def handle_chat_create(self, data: dict): + chat = self.help_agent.chat_create() + self.websocket_handler.queue_put( + { + "type": "help", + "action": "chat_created", + "chat_id": chat.id, + "chat_list": self._chat_list_payload(), + } + ) + self.websocket_handler.queue_put(self._chat_history_payload(chat)) + + async def handle_chat_select(self, data: dict): + payload = ChatSelectPayload(**data) + chat = self.help_agent.chat_get(payload.chat_id) + if not chat: + return + self.help_agent.chat_set_last_active_id(payload.chat_id) + self.websocket_handler.queue_put(self._chat_history_payload(chat)) + + async def handle_chat_history(self, data: dict): + payload = ChatHistoryPayload(**data) + chat = self.help_agent.chat_get(payload.chat_id) + if not chat: + return + self.websocket_handler.queue_put(self._chat_history_payload(chat)) + + async def handle_chat_send(self, data: dict): + payload = ChatSendPayload(**data) + agent = self.help_agent + + _on_update, _on_done, _on_title_generated = self._make_generation_callbacks() + + task = await agent.run_tracked_task( + f"help_chat_{payload.chat_id}", + lambda: agent.chat_send( + payload.chat_id, + payload.message, + ux_snapshot=payload.ux_snapshot, + on_update=_on_update, + on_done=_on_done, + on_title_generated=_on_title_generated, + ), + background=True, + error_handler=self._make_error_handler(payload.chat_id), + ) + if task is None: + self._reject_busy_chat(payload.chat_id, "send") + + async def handle_chat_regenerate(self, data: dict): + payload = ChatRegeneratePayload(**data) + agent = self.help_agent + + _on_update, _on_done, _on_title_generated = self._make_generation_callbacks() + + task = await agent.run_tracked_task( + f"help_chat_{payload.chat_id}", + lambda: agent.chat_regenerate_last( + payload.chat_id, + ux_snapshot=payload.ux_snapshot, + on_update=_on_update, + on_done=_on_done, + on_title_generated=_on_title_generated, + ), + background=True, + error_handler=self._make_error_handler(payload.chat_id), + ) + if task is None: + self._reject_busy_chat(payload.chat_id, "regenerate") + + def _reject_busy_chat(self, chat_id: str, action: str): + """ + A generation is already in flight for this chat (single-flight skip), + so the request was dropped - tell the frontend, otherwise the + optimistic user message vanishes without explanation on the next + history sync. + """ + log.warning( + "help.chat.websocket.already_generating", chat_id=chat_id, action=action + ) + self.websocket_handler.queue_put( + { + "type": "help", + "action": "chat_done", + "chat_id": chat_id, + "error": "Still generating a response in this chat - wait for it to finish, then send again.", + } + ) + + async def handle_chat_clear(self, data: dict): + payload = ChatClearPayload(**data) + if not self.help_agent.chat_clear(payload.chat_id): + return + chat = self.help_agent.chat_get(payload.chat_id) + self.websocket_handler.queue_put(self._chat_history_payload(chat)) + + async def handle_chat_delete(self, data: dict): + payload = ChatDeletePayload(**data) + if not self.help_agent.chat_delete(payload.chat_id): + return + + active_chat = self.help_agent.chat_get_or_create_active() + self.websocket_handler.queue_put( + { + "type": "help", + "action": "chat_deleted", + "chat_id": payload.chat_id, + "chat_list": self._chat_list_payload(), + "active_chat_id": active_chat.id, + } + ) + self.websocket_handler.queue_put(self._chat_history_payload(active_chat)) + + async def handle_chat_update_scene_aware(self, data: dict): + payload = ChatUpdateSceneAwarePayload(**data) + self.help_agent.chat_update_scene_aware(payload.chat_id, payload.scene_aware) diff --git a/src/talemate/agents/memory/nodes.py b/src/talemate/agents/memory/nodes.py index 82de93ee..f7d0b12d 100644 --- a/src/talemate/agents/memory/nodes.py +++ b/src/talemate/agents/memory/nodes.py @@ -25,18 +25,29 @@ TYPE_CHOICES.extend( @register("agents/memory/QueryContextDB") class QueryContextDB(AgentNode): """ - Node that queries the context database + Queries the memory agent's context database with one or more queries + and collects the matching documents, up to a total token budget. - async def multi_query( - self, - queries: list[str], - iterate: int = 1, - max_tokens: int = 1000, - filter: Callable = lambda x: True, - formatter: Callable = lambda x: x, - limit: int = 10, - **where, - ): + Inputs: + + - state: The graph state + - queries: The queries to run (list, or a single query string) + - meta_filters: Metadata filters to constrain the results (optional) + - max_tokens: The maximum total tokens of results to return (optional) + - fn_filter: Function that receives a result and returns whether to + keep it (optional) + - fn_formatter: Function that receives a result and returns its + formatted replacement (optional) + + Properties: + + - iterate: The number of results to accept per query + - limit: The number of N best results to consider per query + + Outputs: + + - state: The state input, passed through + - results: The list of matching documents """ _agent_name: ClassVar[str] = "memory" @@ -153,7 +164,21 @@ class QueryContextDB(AgentNode): @register("agents/memory/UnpackMemoryDocument") class UnpackMemoryDocument(Node): """ - Unpacks a memory document + Unpacks a memory document into its individual fields. + + Inputs: + + - document: The memory document to unpack + + Outputs: + + - document: The memory document, passed through + - meta: The document's metadata dict + - id: The document's ID in the memory database + - raw: The document's raw content + - as_text: The document rendered as text + - as_dict: The document as a dict + - context_id: The document's context ID """ def __init__(self, title="Unpack Memory Document", **kwargs): diff --git a/src/talemate/agents/narrator/nodes.py b/src/talemate/agents/narrator/nodes.py index 25da84d6..91094752 100644 --- a/src/talemate/agents/narrator/nodes.py +++ b/src/talemate/agents/narrator/nodes.py @@ -91,7 +91,22 @@ class GenerateNarrationBase(AgentNode): @register("agents/narrator/GenerateProgress") class GenerateProgressNarration(GenerateNarrationBase): """ - Generate a progress narration message + Generates narration that moves the story forward, via the narrator + agent's progress_story action. If no narrative direction is given the + narrator will attempt to subtly move the story forward on its own. + + The generated message is not added to the scene history by this node. + + Inputs: + + - state: The graph state + - narrative_direction: Optional direction the narrative should take + - response_length: Optional response length in tokens (0 for default) + + Outputs: + + - generated: The generated narration text + - message: The generated NarratorMessage object """ _action_name: ClassVar[str] = "progress_story" @@ -101,7 +116,21 @@ class GenerateProgressNarration(GenerateNarrationBase): @register("agents/narrator/GenerateSceneNarration") class GenerateSceneNarration(GenerateNarrationBase): """ - Generate a scene narration message + Generates narration describing the current scene, via the narrator + agent's narrate_scene action. + + The generated message is not added to the scene history by this node. + + Inputs: + + - state: The graph state + - narrative_direction: Optional direction the narrative should take + - response_length: Optional response length in tokens (0 for default) + + Outputs: + + - generated: The generated narration text + - message: The generated NarratorMessage object """ _action_name: ClassVar[str] = "narrate_scene" @@ -111,7 +140,23 @@ class GenerateSceneNarration(GenerateNarrationBase): @register("agents/narrator/GenerateAfterDialogNarration") class GenerateAfterDialogNarration(GenerateNarrationBase): """ - Generate an after dialog narration message + Generates narration reacting to the most recent line of dialogue, from + the perspective of the given character, via the narrator agent's + narrate_after_dialogue action. + + The generated message is not added to the scene history by this node. + + Inputs: + + - state: The graph state + - narrative_direction: Optional direction the narrative should take + - response_length: Optional response length in tokens (0 for default) + - character: The character whose dialogue the narration follows + + Outputs: + + - generated: The generated narration text + - message: The generated NarratorMessage object """ _action_name: ClassVar[str] = "narrate_after_dialogue" @@ -125,7 +170,22 @@ class GenerateAfterDialogNarration(GenerateNarrationBase): @register("agents/narrator/GenerateEnvironmentNarration") class GenerateEnvironmentNarration(GenerateNarrationBase): """ - Generate an environment narration message + Generates narration describing the current environment, via the + narrator agent's narrate_environment action (which narrates from the + player character's perspective). + + The generated message is not added to the scene history by this node. + + Inputs: + + - state: The graph state + - narrative_direction: Optional direction the narrative should take + - response_length: Optional response length in tokens (0 for default) + + Outputs: + + - generated: The generated narration text + - message: The generated NarratorMessage object """ _action_name: ClassVar[str] = "narrate_environment" @@ -135,7 +195,22 @@ class GenerateEnvironmentNarration(GenerateNarrationBase): @register("agents/narrator/GenerateQueryNarration") class GenerateQueryNarration(GenerateNarrationBase): """ - Generate a query narration message + Generates narration answering a specific question about the scene, via + the narrator agent's narrate_query action. + + The generated message is not added to the scene history by this node. + + Inputs: + + - state: The graph state + - response_length: Optional response length in tokens (0 for default) + - query: The question to answer through narration + - extra_context: Optional additional context to inform the answer + + Outputs: + + - generated: The generated narration text + - message: The generated NarratorMessage object """ _action_name: ClassVar[str] = "narrate_query" @@ -151,7 +226,22 @@ class GenerateQueryNarration(GenerateNarrationBase): @register("agents/narrator/GenerateCharacterNarration") class GenerateCharacterNarration(GenerateNarrationBase): """ - Generate a character narration message + Generates narration describing a specific character, via the narrator + agent's narrate_character action. + + The generated message is not added to the scene history by this node. + + Inputs: + + - state: The graph state + - narrative_direction: Optional direction the narrative should take + - response_length: Optional response length in tokens (0 for default) + - character: The character to narrate + + Outputs: + + - generated: The generated narration text + - message: The generated NarratorMessage object """ _action_name: ClassVar[str] = "narrate_character" @@ -165,7 +255,24 @@ class GenerateCharacterNarration(GenerateNarrationBase): @register("agents/narrator/GenerateTimeNarration") class GenerateTimeNarration(GenerateNarrationBase): """ - Generate a time narration message + Generates narration for a passage of time, via the narrator agent's + narrate_time_passage action. The ISO 8601 duration is converted to a + human readable string before being handed to the narrator. + + The generated message is not added to the scene history by this node, + nor does the node advance the scene time. + + Inputs: + + - state: The graph state + - narrative_direction: Optional direction the narrative should take + - response_length: Optional response length in tokens (0 for default) + - duration: The time passed as an ISO 8601 duration (e.g. "PT30M") + + Outputs: + + - generated: The generated narration text + - message: The generated NarratorMessage object """ _action_name: ClassVar[str] = "narrate_time_passage" @@ -187,7 +294,21 @@ class GenerateTimeNarration(GenerateNarrationBase): @register("agents/narrator/GenerateCharacterEntryNarration") class GenerateCharacterEntryNarration(GenerateNarrationBase): """ - Generate a character entry narration message + Generates narration for a character entering the scene, via the + narrator agent's narrate_character_entry action. The node does not + activate the character or add the message to the scene history. + + Inputs: + + - state: The graph state + - narrative_direction: Optional direction the narrative should take + - response_length: Optional response length in tokens (0 for default) + - character: The character entering the scene + + Outputs: + + - generated: The generated narration text + - message: The generated NarratorMessage object """ _action_name: ClassVar[str] = "narrate_character_entry" @@ -201,7 +322,21 @@ class GenerateCharacterEntryNarration(GenerateNarrationBase): @register("agents/narrator/GenerateCharacterExitNarration") class GenerateCharacterExitNarration(GenerateNarrationBase): """ - Generate a character exit narration message + Generates narration for a character exiting the scene, via the + narrator agent's narrate_character_exit action. The node does not + deactivate the character or add the message to the scene history. + + Inputs: + + - state: The graph state + - narrative_direction: Optional direction the narrative should take + - response_length: Optional response length in tokens (0 for default) + - character: The character exiting the scene + + Outputs: + + - generated: The generated narration text + - message: The generated NarratorMessage object """ _action_name: ClassVar[str] = "narrate_character_exit" @@ -215,9 +350,18 @@ class GenerateCharacterExitNarration(GenerateNarrationBase): @register("agents/narrator/UnpackSource") class UnpackSource(AgentNode): """ - Unpacks a narration message source string - into action name and arguments - DEPRECATED + DEPRECATED - narration messages no longer encode their action in a + source string. This node always outputs an empty action name and an + empty arguments dict. + + Inputs: + + - source: The narration message source string + + Outputs: + + - action_name: Always an empty string + - arguments: Always an empty dict """ _agent_name: ClassVar[str] = "narrator" diff --git a/src/talemate/agents/tts/__init__.py b/src/talemate/agents/tts/__init__.py index 284c0da3..9794480b 100644 --- a/src/talemate/agents/tts/__init__.py +++ b/src/talemate/agents/tts/__init__.py @@ -616,6 +616,23 @@ class TTSAgent( api_details = self.api_attr(api, "agent_details", None) if api_details: details.update(api_details) + + # Unused-but-enabled APIs surface only details flagged + # surface_when_unused; everything else (including error details) is + # already surfaced by the api status in the voice UI. + for api in self.apis: + if api in used_apis: + continue + api_details = self.api_attr(api, "agent_details", None) + if not api_details: + continue + details.update( + { + key: detail + for key, detail in api_details.items() + if detail.get("surface_when_unused") + } + ) return details @property diff --git a/src/talemate/agents/tts/chatterbox.py b/src/talemate/agents/tts/chatterbox.py index 45bf94ea..53c71275 100644 --- a/src/talemate/agents/tts/chatterbox.py +++ b/src/talemate/agents/tts/chatterbox.py @@ -7,24 +7,13 @@ import structlog import pydantic from pydantic import ConfigDict -import torch - - -# Lazy imports for heavy dependencies -def _import_heavy_deps(): - global ta, ChatterboxTTS - import torchaudio as ta - from chatterbox.tts import ChatterboxTTS - - -CUDA_AVAILABLE = torch.cuda.is_available() - from talemate.agents.base import ( AgentAction, AgentActionConfig, AgentDetail, ) from talemate.ux.schema import Field +from talemate.util.gpu import cuda_available from .schema import Voice, Chunk, GenerationContext, VoiceProvider, INFO_CHUNK_SIZE from .voice_library import add_default_voices @@ -33,6 +22,15 @@ from .util import voice_is_talemate_asset log = structlog.get_logger("talemate.agents.tts.chatterbox") + +def _import_heavy_deps(): + # torchaudio and the chatterbox model stack are only needed once a + # generation actually runs. + global ta, ChatterboxTTS + import torchaudio as ta + from chatterbox.tts import ChatterboxTTS + + add_default_voices( [ Voice( @@ -162,7 +160,7 @@ class ChatterboxMixin: config={ "device": AgentActionConfig( type="text", - value="cuda" if CUDA_AVAILABLE else "cpu", + value="cuda" if cuda_available() else "cpu", label="Device", choices=[ {"value": "cpu", "label": "CPU"}, diff --git a/src/talemate/agents/tts/f5tts.py b/src/talemate/agents/tts/f5tts.py index 5121ee16..22e878f0 100644 --- a/src/talemate/agents/tts/f5tts.py +++ b/src/talemate/agents/tts/f5tts.py @@ -8,23 +8,13 @@ import pydantic from pydantic import ConfigDict import re -import torch - - -# Lazy imports for heavy dependencies -def _import_heavy_deps(): - global F5TTS - from f5_tts.api import F5TTS - - -CUDA_AVAILABLE = torch.cuda.is_available() - from talemate.agents.base import ( AgentAction, AgentActionConfig, AgentDetail, ) from talemate.ux.schema import Field +from talemate.util.gpu import cuda_available from .schema import Voice, Chunk, GenerationContext, VoiceProvider, INFO_CHUNK_SIZE from .voice_library import add_default_voices @@ -33,6 +23,13 @@ from .util import voice_is_talemate_asset log = structlog.get_logger("talemate.agents.tts.f5tts") + +def _import_heavy_deps(): + # The f5_tts model stack is only needed once a generation actually runs. + global F5TTS + from f5_tts.api import F5TTS + + REF_TEXT = "You awaken aboard your ship, the Starlight Nomad. A soft hum resonates throughout the vessel indicating its systems are online." add_default_voices( @@ -202,7 +199,7 @@ class F5TTSMixin: config={ "device": AgentActionConfig( type="text", - value="cuda" if CUDA_AVAILABLE else "cpu", + value="cuda" if cuda_available() else "cpu", label="Device", choices=[ {"value": "cpu", "label": "CPU"}, diff --git a/src/talemate/agents/tts/kokoro.py b/src/talemate/agents/tts/kokoro.py index 81d3aa4e..c8c49081 100644 --- a/src/talemate/agents/tts/kokoro.py +++ b/src/talemate/agents/tts/kokoro.py @@ -9,12 +9,6 @@ from pydantic import ConfigDict import traceback from pathlib import Path - -import torch -import soundfile as sf -from kokoro import KPipeline - - from talemate.agents.base import ( AgentAction, AgentActionConfig, @@ -32,6 +26,16 @@ from .voice_library import add_default_voices log = structlog.get_logger("talemate.agents.tts.kokoro") + +def _import_heavy_deps(): + # kokoro pulls in torch and costs ~4.3s / ~843MB, so it is loaded on first + # generation rather than at import. + global torch, sf, KPipeline + import torch + import soundfile as sf + from kokoro import KPipeline + + CUSTOM_VOICE_STORAGE = ( Path(__file__).parent.parent.parent.parent.parent / "tts" / "voice" / "kokoro" ) @@ -207,6 +211,7 @@ class KokoroMixin: pass def _kokoro_mix(self, mixer: VoiceMixer) -> "torch.Tensor": + _import_heavy_deps() pipeline = KPipeline(lang_code="a") packs = [ @@ -230,6 +235,7 @@ class KokoroMixin: async def kokoro_test_mix(self, mixer: VoiceMixer): """Test a mixed voice by generating a sample.""" + _import_heavy_deps() mixed_voice_tensor = self._kokoro_mix(mixer) loop = asyncio.get_event_loop() @@ -257,6 +263,7 @@ class KokoroMixin: async def kokoro_save_mix(self, voice_id: str, mixer: VoiceMixer) -> Path: """Save a voice tensor to disk.""" + _import_heavy_deps() # Ensure the directory exists CUSTOM_VOICE_STORAGE.mkdir(parents=True, exist_ok=True) @@ -273,6 +280,7 @@ class KokoroMixin: file_path: str, ) -> None: """Generate audio from text using the given voice.""" + _import_heavy_deps() try: generator = pipeline(text, voice=voice) for i, (gs, ps, audio) in enumerate(generator): @@ -295,7 +303,7 @@ class KokoroMixin: log.debug( "kokoro - reinitializing tts instance", ) - # Lazy import heavy dependencies only when needed + _import_heavy_deps() self.kokoro_instance = KokoroInstance( # a= American English diff --git a/src/talemate/agents/tts/nodes.py b/src/talemate/agents/tts/nodes.py index 51c152f2..6a4d0fed 100644 --- a/src/talemate/agents/tts/nodes.py +++ b/src/talemate/agents/tts/nodes.py @@ -34,7 +34,16 @@ class TTSAgentSettings(AgentSettingsNode): @register("agents/tts/GetVoice") class GetVoice(AgentNode): """ - Gets a voice from the TTS agent. + Looks up a voice in the TTS agent's voice library by voice ID. + + Inputs: + + - voice_id: The ID of the voice to get (required at runtime despite + the optional socket) + + Outputs: + + - voice: The voice object (None if no voice with that ID exists) """ _agent_name: ClassVar[str] = "tts" @@ -91,7 +100,22 @@ class GetNarratorVoice(AgentNode): @register("agents/tts/UnpackVoice") class UnpackVoice(AgentNode): """ - Unpacks a voice from the TTS agent. + Unpacks a voice object into its individual fields. + + Inputs: + + - voice: The voice to unpack + + Outputs: + + - voice: The voice, passed through + - label: The voice's display label + - provider: The TTS provider (API) the voice belongs to + - provider_id: The voice's ID at the provider + - provider_model: The provider model the voice uses + - tags: The voice's tags + - parameters: The voice's generation parameters + - is_scene_asset: Whether the voice is stored as a scene asset """ _agent_name: ClassVar[str] = "tts" @@ -124,7 +148,22 @@ class UnpackVoice(AgentNode): @register("agents/tts/Generate") class Generate(AgentNode): """ - Generates a voice from the TTS agent. + Generates speech audio for the given text via the TTS agent and queues + it for playback in the frontend. Either a voice or a character must be + provided; when a character is given its assigned voice is used, while + an explicit voice input overrides it. Does nothing when the TTS agent + is disabled or not ready. + + Inputs: + + - state: The graph state + - text: The text to speak (required at runtime despite the optional socket) + - voice: The voice to use (optional) + - character: The character to speak as (optional) + + Outputs: + + - state: The state input, passed through """ _agent_name: ClassVar[str] = "tts" diff --git a/src/talemate/agents/tts/pocket_tts.py b/src/talemate/agents/tts/pocket_tts.py index 0d6126a9..c4c3905d 100644 --- a/src/talemate/agents/tts/pocket_tts.py +++ b/src/talemate/agents/tts/pocket_tts.py @@ -8,7 +8,7 @@ from pathlib import Path import numpy as np import pydantic import structlog -import torch +from huggingface_hub import get_token from pydantic import ConfigDict from talemate.agents.base import AgentAction, AgentActionConfig, AgentDetail from talemate.ux.schema import Field @@ -22,8 +22,9 @@ log = structlog.get_logger("talemate.agents.tts.pocket_tts") def _import_heavy_deps(): - global sf, TTSModel + global sf, torch, TTSModel import soundfile as sf + import torch from pocket_tts import TTSModel @@ -175,6 +176,12 @@ class PocketTTSInstance(pydantic.BaseModel): class PocketTTSMixin: + # last token this mixin exported to HF_TOKEN, and the env value that export + # displaced, so a cleared config token restores the environment instead of + # destroying a user-provided value + _pocket_tts_env_token: str | None = None + _pocket_tts_displaced_env_token: str | None = None + @classmethod def add_actions(cls, actions: dict[str, AgentAction]): actions["_config"].config["apis"].choices.append( @@ -339,10 +346,52 @@ class PocketTTSMixin: return self.config.huggingface.api_key def _pocket_tts_apply_hf_token(self): - """Export the configured HF token as HF_TOKEN so huggingface_hub can fetch gated weights; falls back to any existing env var.""" + """Export the configured HF token as HF_TOKEN so huggingface_hub can fetch gated weights. + + When the config token is cleared again, HF_TOKEN is restored to the + value the export displaced (or unset if there was none). An env value + changed out-of-band is never overwritten by the clear, and the most + recently observed external value wins as the restore target. + """ token = self.pocket_tts_hf_token if token: + current = os.environ.get("HF_TOKEN") + if current != self._pocket_tts_env_token: + self._pocket_tts_displaced_env_token = current os.environ["HF_TOKEN"] = token + self._pocket_tts_env_token = token + elif self._pocket_tts_env_token: + if os.environ.get("HF_TOKEN") == self._pocket_tts_env_token: + if self._pocket_tts_displaced_env_token is not None: + os.environ["HF_TOKEN"] = self._pocket_tts_displaced_env_token + else: + del os.environ["HF_TOKEN"] + # whether restored or changed out-of-band, the export is gone - + # drop the bookkeeping so a later external value equal to the old + # export is not mistaken for Talemate's own + self._pocket_tts_env_token = None + self._pocket_tts_displaced_env_token = None + + def _pocket_tts_should_reload(self, instance: PocketTTSInstance | None) -> bool: + if instance is None: + return True + return ( + instance.model_language != self.pocket_tts_language + or instance.temp != self.pocket_tts_temp + or instance.lsd_decode_steps != self.pocket_tts_lsd_decode_steps + or instance.noise_clamp != self.pocket_tts_noise_clamp + or instance.eos_threshold != self.pocket_tts_eos_threshold + or instance.quantize != self.pocket_tts_quantize + # pocket_tts silently falls back to the public non-voice-cloning + # weights when the gated download fails, so while a token is + # available the cached fallback model must be discarded to retry + # the gated download. get_token() mirrors the credential + # resolution the download itself uses (env var, hf auth token file). + or ( + bool(self.pocket_tts_hf_token or get_token()) + and not instance.model.has_voice_cloning + ) + ) @property def pocket_tts_configured(self) -> bool: @@ -382,6 +431,19 @@ class PocketTTSMixin: description="Cached Pocket TTS voice states", ).model_dump() + if not instance.model.has_voice_cloning: + details["pocket_tts_voice_cloning"] = AgentDetail( + icon="mdi-alert", + value="Voice cloning unavailable", + description=( + "The gated Pocket TTS voice-cloning model could not be downloaded. " + "Accept the model terms at https://huggingface.co/kyutai/pocket-tts, " + "set your HuggingFace Token in the Pocket TTS settings, then generate again." + ), + color="warning", + surface_when_unused=True, + ).model_dump() + return details def pocket_tts_delete_voice(self, voice: Voice): @@ -420,17 +482,7 @@ class PocketTTSMixin: instance: PocketTTSInstance | None = getattr(self, "pocket_tts_instance", None) - reload_model = ( - instance is None - or instance.model_language != self.pocket_tts_language - or instance.temp != self.pocket_tts_temp - or instance.lsd_decode_steps != self.pocket_tts_lsd_decode_steps - or instance.noise_clamp != self.pocket_tts_noise_clamp - or instance.eos_threshold != self.pocket_tts_eos_threshold - or instance.quantize != self.pocket_tts_quantize - ) - - if reload_model: + if self._pocket_tts_should_reload(instance): # Voice clone embeddings are tied to the underlying language model, so # they survive sampling-param changes (temp, eos_threshold, quantize, ...) # but must be discarded when the language itself changes. diff --git a/src/talemate/agents/visual/agent.py b/src/talemate/agents/visual/agent.py index 6fdbcc27..1f470d6a 100644 --- a/src/talemate/agents/visual/agent.py +++ b/src/talemate/agents/visual/agent.py @@ -22,6 +22,7 @@ from .schema import ReadyCheckResult, BackendStatusType, PROMPT_TYPE import talemate.agents.visual.nodes # noqa: F401 from .style import StyleMixin +from .finalize import PromptFinalizationMixin from .generation import GenerationMixin from .analyze import AnalysisMixin from .backends.comfyui import ComfyUIMixin @@ -53,6 +54,7 @@ log = structlog.get_logger("talemate.agents.visual") @register() class VisualAgent( StyleMixin, + PromptFinalizationMixin, GenerationMixin, AnalysisMixin, ComfyUIMixin, @@ -202,6 +204,9 @@ class VisualAgent( ), } + # added first so the Prompt Finalization tab lands right after + # Prompt Generation (tabs render in action insertion order) + PromptFinalizationMixin.add_actions(actions) ComfyUIMixin.add_actions(actions) Automatic1111Mixin.add_actions(actions) SDNextMixin.add_actions(actions) @@ -304,6 +309,10 @@ class VisualAgent( meta["current_art_style"] = art_style_name meta["current_art_style_source"] = self._get_current_art_style_source() + # resolved (scene overrides included) — lets the frontend warn when + # character-level finalizers are edited while the feature is off + meta["prompt_finalization_enabled"] = self.prompt_finalization_enabled + return meta @property diff --git a/src/talemate/agents/visual/backends/__init__.py b/src/talemate/agents/visual/backends/__init__.py index e62fe225..f9fb0d96 100644 --- a/src/talemate/agents/visual/backends/__init__.py +++ b/src/talemate/agents/visual/backends/__init__.py @@ -5,6 +5,7 @@ import random from typing import ClassVar import time from talemate.instance import get_agent +from talemate.agents.base import AgentDetailColor from talemate.agents.visual.schema import ( BackendBase, BackendStatus, @@ -50,7 +51,7 @@ class Backend(BackendBase): return None @property - def status_color(self) -> str: + def status_color(self) -> AgentDetailColor: if self.status.type == BackendStatusType.ERROR: return "error" elif self.status.type == BackendStatusType.DISCONNECTED: diff --git a/src/talemate/agents/visual/finalize.py b/src/talemate/agents/visual/finalize.py new file mode 100644 index 00000000..b9323943 --- /dev/null +++ b/src/talemate/agents/visual/finalize.py @@ -0,0 +1,459 @@ +import re + +import pydantic +import structlog +from thefuzz import fuzz + +from .schema import ( + FINALIZER_MODE, + VIS_TYPE, + GenerationRequest, + PromptFinalizer, +) + +import talemate.emit.async_signals as async_signals +from talemate.agents.base import ( + AgentAction, + AgentActionConfig, + AgentActionNote, + AgentEmission, +) +from talemate.prompts import Prompt +from talemate.ux.schema import Column, Condition, DynamicLabel, DynamicSpan + +__all__ = [ + "PromptFinalizationMixin", + "PromptFinalizeEmission", + "apply_exact", + "apply_fuzzy", + "apply_regex", + "cleanup_prompt", + "validate_finalizers", +] + +log = structlog.get_logger("talemate.agents.visual.finalize") + +DEFAULT_FUZZY_THRESHOLD = 85 + +AI_RESPONSE_TAG = re.compile( + r"(.*?)", re.DOTALL | re.IGNORECASE +) + +async_signals.register( + "agent.visual.prompt_finalize.before", + "agent.visual.prompt_finalize.after", +) + + +class PromptFinalizeEmission(AgentEmission): + positive_prompt: str | None = None + negative_prompt: str | None = None + vis_type: VIS_TYPE = VIS_TYPE.UNSPECIFIED + character_name: str | None = None + finalizers: list[PromptFinalizer] = pydantic.Field(default_factory=list) + + +def cleanup_prompt(text: str) -> str: + """ + Tidy a prompt string after a removal: collapse segments left empty + ("a, , b" -> "a, b"), squash duplicate spaces and strip dangling + commas from the edges. + """ + text = re.sub(r",(\s*,)+", ",", text) + text = re.sub(r"\s+,", ",", text) + text = re.sub(r"[ \t]{2,}", " ", text) + return re.sub(r"^[,\s]+|[,\s]+$", "", text) + + +def apply_exact(text: str, match: str, replace: str, case_sensitive: bool) -> str: + """Substring search and replace; an empty replacement removes the match.""" + if not match: + return text + pattern = re.compile(re.escape(match), 0 if case_sensitive else re.IGNORECASE) + if not pattern.search(text): + return text + result = pattern.sub(lambda _: replace, text) + return cleanup_prompt(result) if not replace.strip() else result + + +def apply_fuzzy(text: str, match: str, replace: str, threshold: int) -> str: + """ + Fuzzy search and replace on comma-separated prompt segments (always + case insensitive). A segment scoring at or above the threshold is + replaced whole; an empty replacement removes it. + """ + if not match.strip(): + return text + + changed = False + segments: list[str] = [] + + for segment in text.split(","): + segment = segment.strip() + if segment and fuzz.ratio(segment.lower(), match.strip().lower()) >= threshold: + changed = True + if replace.strip(): + segments.append(replace.strip()) + elif segment: + segments.append(segment) + + if not changed: + return text + return ", ".join(segments) + + +def apply_regex(text: str, pattern: str, replace: str, flags: list[str]) -> str: + """ + Regex search and replace with group passthrough (``\\1`` / ``\\g``). + Invalid patterns or group references leave the text unchanged. + """ + if not pattern: + return text + + re_flags = 0 if "case_sensitive" in flags else re.IGNORECASE + if "dot_all" in flags: + re_flags |= re.DOTALL + if "multiline" in flags: + re_flags |= re.MULTILINE + + try: + result = re.sub(pattern, replace, text, flags=re_flags) + except re.error as exc: + log.warning( + "prompt_finalization.invalid_regex", pattern=pattern, error=str(exc) + ) + return text + + return cleanup_prompt(result) if not replace.strip() else result + + +def validate_finalizers(rows: list) -> list[PromptFinalizer]: + """Coerce raw config/table rows into PromptFinalizer models, skipping invalid rows.""" + finalizers = [] + for row in rows or []: + if isinstance(row, PromptFinalizer): + finalizers.append(row) + continue + try: + finalizers.append(PromptFinalizer(**row)) + except (pydantic.ValidationError, TypeError) as exc: + log.warning("prompt_finalization.invalid_row", row=row, error=str(exc)) + return finalizers + + +def finalizer_table_columns() -> list[Column]: + return [ + Column( + name="enabled", + type="bool", + label="", + default_value=True, + rail=True, + disables_row=True, + ), + Column( + name="mode", + type="text", + label="Mode", + default_value=FINALIZER_MODE.EXACT.value, + span=3, + choices=[ + {"label": "Exact match", "value": FINALIZER_MODE.EXACT.value}, + {"label": "Fuzzy match", "value": FINALIZER_MODE.FUZZY.value}, + {"label": "Regex", "value": FINALIZER_MODE.REGEX.value}, + {"label": "AI", "value": FINALIZER_MODE.AI.value}, + ], + ), + Column( + name="target", + type="text", + label="Target", + default_value="POSITIVE", + span=3, + choices=[ + {"label": "Positive", "value": "POSITIVE"}, + {"label": "Negative", "value": "NEGATIVE"}, + {"label": "Both", "value": "BOTH"}, + ], + ), + Column( + name="flags", + type="flags", + label="Flags", + span=3, + choices=[ + {"label": "Case sensitive", "value": "case_sensitive"}, + {"label": "Dot all (regex)", "value": "dot_all"}, + {"label": "Multiline (regex)", "value": "multiline"}, + ], + # fuzzy matching is always case insensitive and AI mode has no + # flags — the stored value is ignored for those modes + condition=Condition( + attribute="mode", + value=[FINALIZER_MODE.EXACT.value, FINALIZER_MODE.REGEX.value], + ), + ), + Column( + name="vis_types", + type="flags", + label="Types", + description="Visual types this action applies to. Empty applies to all.", + span=3, + # take over the row when the flags column is hidden + dynamic_span=DynamicSpan( + attribute="mode", + spans={FINALIZER_MODE.FUZZY.value: 6, FINALIZER_MODE.AI.value: 6}, + ), + choices=[ + { + "label": vis_type.value.replace("_", " ").title(), + "value": vis_type.value, + } + for vis_type in VIS_TYPE + ], + ), + Column( + name="match", + type="blob", + label="Match", + description="Search string (exact, fuzzy) or pattern (regex).", + span=12, + rows=1, + auto_grow=True, + condition=Condition( + attribute="mode", + value=[ + FINALIZER_MODE.EXACT.value, + FINALIZER_MODE.FUZZY.value, + FINALIZER_MODE.REGEX.value, + ], + ), + ), + Column( + name="replace", + type="blob", + label="Replace", + dynamic_label=DynamicLabel( + attribute="mode", + labels={FINALIZER_MODE.AI.value: "Instruct"}, + ), + description="Replacement text, or the instruction for AI processing. An empty replacement removes the match.", + span=12, + rows=1, + max_rows=15, + auto_grow=True, + ), + ] + + +class PromptFinalizationMixin: + """ + Visualizer agent mixin that applies user configured post-processing + actions to the final prompt strings before image generation. + """ + + @classmethod + def add_actions(cls, actions: dict[str, AgentAction]): + actions["_prompt_finalization"] = AgentAction( + enabled=True, + container=True, + can_be_disabled=True, + label="Prompt Finalization", + icon="mdi-auto-fix", + description="Post-processing of image generation prompts. Actions run in order, right before the prompt is sent to the image generation backend.", + config={ + "finalizers": AgentActionConfig( + type="table", + value=[], + # tags the table for the frontend's preset insert control, + # which appends editable copies of a template's actions + wstemplate_type="visual_finalizer", + label="Post-processing actions", + description="Actions are applied in order. Character specific actions (World Editor -> Character -> Visuals) run after these.", + columns=finalizer_table_columns(), + note=AgentActionNote( + title="AI processing", + text="AI actions send an extra AI query per targeted prompt (an action targeting Both adds two when a negative prompt is set), using the instruction in the Instruct field.", + icon="mdi-creation", + ), + ), + "fuzzy_threshold": AgentActionConfig( + type="number", + value=DEFAULT_FUZZY_THRESHOLD, + min=50, + max=100, + step=5, + label="Fuzzy match threshold", + description="Similarity (0-100) a prompt segment must reach to be considered a fuzzy match.", + ), + }, + ) + return actions + + # config + + @property + def prompt_finalization_enabled(self) -> bool: + return self.resolve_enabled("_prompt_finalization") + + @property + def prompt_finalization_fuzzy_threshold(self) -> int: + value = self.resolve_config("_prompt_finalization", "fuzzy_threshold") + return int(value) if value else DEFAULT_FUZZY_THRESHOLD + + # helpers + + def gather_prompt_finalizers( + self, character_name: str | None = None + ) -> list[PromptFinalizer]: + """ + Collect finalizers in execution order: the agent's actions first + (scene overrides already resolved), then character specific actions + when a character is targeted. + """ + finalizers: list[PromptFinalizer] = [] + + finalizers.extend( + validate_finalizers( + self.resolve_config("_prompt_finalization", "finalizers") + ) + ) + + scene = getattr(self, "scene", None) + if scene and character_name: + character = scene.get_character(character_name) + if character and character.visual_finalizers: + finalizers.extend(validate_finalizers(character.visual_finalizers)) + + return finalizers + + # actions + + async def apply_prompt_finalizer( + self, + finalizer: PromptFinalizer, + text: str, + vis_type: VIS_TYPE, + character_name: str | None = None, + ) -> str: + if finalizer.mode == FINALIZER_MODE.EXACT: + return apply_exact( + text, finalizer.match, finalizer.replace, finalizer.case_sensitive + ) + if finalizer.mode == FINALIZER_MODE.FUZZY: + return apply_fuzzy( + text, + finalizer.match, + finalizer.replace, + self.prompt_finalization_fuzzy_threshold, + ) + if finalizer.mode == FINALIZER_MODE.REGEX: + return apply_regex( + text, finalizer.match, finalizer.replace, finalizer.flags + ) + if finalizer.mode == FINALIZER_MODE.AI: + return await self.apply_ai_finalizer( + text, finalizer, vis_type, character_name + ) + return text + + async def apply_ai_finalizer( + self, + text: str, + finalizer: PromptFinalizer, + vis_type: VIS_TYPE, + character_name: str | None = None, + ) -> str: + instruction = finalizer.replace.strip() + if not self.client or not text.strip() or not instruction: + return text + + response, _ = await Prompt.request( + "visual.finalize-prompt", + self.client, + "visualize_2048", + vars={ + "scene": getattr(self, "scene", None), + "prompt_text": text, + "instruction": instruction, + "character_name": character_name, + "vis_type": vis_type.value, + "max_tokens": self.client.max_token_length, + }, + ) + + tagged = AI_RESPONSE_TAG.search(response or "") + finalized = tagged.group(1).strip() if tagged else (response or "").strip() + return finalized or text + + async def finalize_prompts( + self, + positive: str | None, + negative: str | None, + vis_type: VIS_TYPE, + character_name: str | None = None, + ) -> tuple[str | None, str | None]: + """ + Apply all configured prompt finalizers to a positive / negative + prompt string pair and return the finalized pair. + + The `agent.visual.prompt_finalize.before` / `.after` signals fire + even when prompt finalization is disabled, so listeners can act as + their own finalization mechanism. Handlers may mutate the prompt + strings and (on `.before`) the finalizer list. + """ + finalizers = ( + self.gather_prompt_finalizers(character_name) + if self.prompt_finalization_enabled + else [] + ) + + emission = PromptFinalizeEmission( + agent=self, + positive_prompt=positive, + negative_prompt=negative, + vis_type=vis_type, + character_name=character_name, + finalizers=finalizers, + ) + await async_signals.get("agent.visual.prompt_finalize.before").send(emission) + + positive = emission.positive_prompt + negative = emission.negative_prompt + + for finalizer in emission.finalizers: + if positive and finalizer.applies_to(vis_type, True): + positive = await self.apply_prompt_finalizer( + finalizer, positive, vis_type, character_name + ) + if negative and finalizer.applies_to(vis_type, False): + negative = await self.apply_prompt_finalizer( + finalizer, negative, vis_type, character_name + ) + + emission.positive_prompt = positive + emission.negative_prompt = negative + await async_signals.get("agent.visual.prompt_finalize.after").send(emission) + + if emission.finalizers: + log.debug( + "prompt_finalization", + vis_type=vis_type, + character_name=character_name, + finalizers=len(emission.finalizers), + prompt=emission.positive_prompt, + negative_prompt=emission.negative_prompt, + ) + return emission.positive_prompt, emission.negative_prompt + + async def finalize_prompt_request(self, request: GenerationRequest): + """ + Apply all configured prompt finalizers to the request's prompt + strings, in place. + """ + request.prompt, request.negative_prompt = await self.finalize_prompts( + request.prompt, + request.negative_prompt, + request.vis_type, + request.character_name, + ) diff --git a/src/talemate/agents/visual/generation.py b/src/talemate/agents/visual/generation.py index 5d76d25a..01c21ec8 100644 --- a/src/talemate/agents/visual/generation.py +++ b/src/talemate/agents/visual/generation.py @@ -130,6 +130,8 @@ class GenerationMixin: @set_processing async def generate(self, request: GenerationRequest) -> GenerationResponse: + await self.finalize_prompt_request(request) + response = GenerationResponse( request=request, id=request.id, diff --git a/src/talemate/agents/visual/modules/generate-visual-asset.json b/src/talemate/agents/visual/modules/generate-visual-asset.json index 80aec0b6..d43ea0cc 100644 --- a/src/talemate/agents/visual/modules/generate-visual-asset.json +++ b/src/talemate/agents/visual/modules/generate-visual-asset.json @@ -86,35 +86,19 @@ "registry": "core/Output", "base_type": "core/Node" }, - "995eac0a-6e61-4979-944c-90ec78ab7a69": { - "title": "OUT vis_type", - "id": "995eac0a-6e61-4979-944c-90ec78ab7a69", - "properties": { - "output_type": "character", - "output_name": "vis_type", - "num": 2 - }, - "x": 993, - "y": -2512, - "width": 210, - "height": 106, - "collapsed": false, - "inherited": false, - "registry": "core/Output", - "base_type": "core/Node" - }, "fe0f0758-cd54-4e81-bff4-d264bb9c2cd6": { "title": "Prompt From Template", "id": "fe0f0758-cd54-4e81-bff4-d264bb9c2cd6", "properties": { "scope": "visual", "template_file": "generate-image", - "template_text": "" + "template_text": "", + "dedupe": true }, "x": 1500, "y": -478, "width": 272, - "height": 147, + "height": 170, "collapsed": false, "inherited": false, "registry": "prompt/PromptFromTemplate", @@ -1452,7 +1436,7 @@ "x": 1351, "y": -180, "width": 405, - "height": 2186, + "height": 2406, "collapsed": true, "inherited": false, "registry": "agents/visual/Settings", @@ -1465,7 +1449,7 @@ "x": 1006, "y": 1834, "width": 405, - "height": 2186, + "height": 2406, "collapsed": true, "inherited": false, "registry": "agents/visual/Settings", @@ -1491,7 +1475,7 @@ "x": 11, "y": 1435, "width": 405, - "height": 2186, + "height": 2406, "collapsed": true, "inherited": false, "registry": "agents/visual/Settings", @@ -1575,7 +1559,7 @@ "x": 11, "y": 1813, "width": 405, - "height": 2186, + "height": 2406, "collapsed": true, "inherited": false, "registry": "agents/visual/Settings", @@ -2255,6 +2239,23 @@ "inherited": false, "registry": "data/Get", "base_type": "core/Node" + }, + "995eac0a-6e61-4979-944c-90ec78ab7a69": { + "title": "OUT vis_type", + "id": "995eac0a-6e61-4979-944c-90ec78ab7a69", + "properties": { + "output_type": "str", + "output_name": "vis_type", + "num": 2 + }, + "x": 993, + "y": -2512, + "width": 210, + "height": 106, + "collapsed": false, + "inherited": false, + "registry": "core/Output", + "base_type": "core/Node" } }, "edges": { @@ -2638,8 +2639,8 @@ "7e20a46e-aa81-4270-bb89-dd75d2f8b9de.value" ], "38690310-cba0-477d-b8a2-3ee04bcce7c0.extracted": [ - "eb3e8386-17f5-409b-b3be-eda1c0a08ae3.object", - "1b332607-0114-454d-a93a-336b672fade5.object" + "1b332607-0114-454d-a93a-336b672fade5.object", + "eb3e8386-17f5-409b-b3be-eda1c0a08ae3.object" ], "1b332607-0114-454d-a93a-336b672fade5.value": [ "5f475f9b-3cf4-4ff6-974a-305b3529ad7e.value" @@ -2666,8 +2667,8 @@ "967e857f-b1f2-4bac-a71c-ff0530997eb9.state" ], "c06c1a51-79d7-4600-896d-3134735409ce.value": [ - "86c4e2af-ec07-4c59-a60a-133a1155e079.object", - "967e857f-b1f2-4bac-a71c-ff0530997eb9.value" + "967e857f-b1f2-4bac-a71c-ff0530997eb9.value", + "86c4e2af-ec07-4c59-a60a-133a1155e079.object" ] }, "groups": [ diff --git a/src/talemate/agents/visual/modules/wsh-visualize.json b/src/talemate/agents/visual/modules/wsh-visualize.json index f4f4a4ec..3673e38b 100644 --- a/src/talemate/agents/visual/modules/wsh-visualize.json +++ b/src/talemate/agents/visual/modules/wsh-visualize.json @@ -19,8 +19,8 @@ "properties": { "attribute": "prompt_only" }, - "x": 397, - "y": 592, + "x": 352, + "y": 1600, "width": 210, "height": 98, "collapsed": false, @@ -34,8 +34,8 @@ "properties": { "attribute": "instructions" }, - "x": 397, - "y": 419, + "x": 352, + "y": 1427, "width": 210, "height": 98, "collapsed": false, @@ -47,8 +47,8 @@ "title": "Coallesce", "id": "006732ed-c87f-43db-8ec7-4043748466eb", "properties": {}, - "x": 2707, - "y": 299, + "x": 3009, + "y": 1307, "width": 140, "height": 86, "collapsed": false, @@ -63,8 +63,8 @@ "name": "websocket_router", "typ": "any" }, - "x": 2707, - "y": 469, + "x": 3009, + "y": 1477, "width": 210, "height": 82, "collapsed": false, @@ -78,8 +78,8 @@ "properties": { "attribute": "character_name" }, - "x": 397, - "y": 74, + "x": 352, + "y": 1082, "width": 210, "height": 98, "collapsed": false, @@ -93,8 +93,8 @@ "properties": { "pass_through": true }, - "x": 687, - "y": 84, + "x": 642, + "y": 1092, "width": 210, "height": 78, "collapsed": false, @@ -106,8 +106,8 @@ "title": "data", "id": "cf4618c8-f944-4973-9681-778708dba637", "properties": {}, - "x": 213, - "y": 174, + "x": 168, + "y": 1182, "width": 140, "height": 26, "collapsed": false, @@ -115,47 +115,14 @@ "registry": "core/Watch", "base_type": "core/Node" }, - "4b63c4d2-cb0d-4362-8fb7-702aa2d1cb5f": { - "title": "Emit System Message", - "id": "4b63c4d2-cb0d-4362-8fb7-702aa2d1cb5f", - "properties": { - "message_title": "Visualize", - "message": "", - "font_color": "amber-darken-3", - "icon": "mdi-image-text", - "display": "tonal", - "as_markdown": true - }, - "x": 2397, - "y": 429, - "width": 240, - "height": 218, - "collapsed": false, - "inherited": false, - "registry": "event/EmitSystemMessage", - "base_type": "core/Node" - }, - "cfb303df-5fd2-4bb0-8b19-be24b0ed5893": { - "title": "Unpack Visual Prompt", - "id": "cfb303df-5fd2-4bb0-8b19-be24b0ed5893", - "properties": {}, - "x": 1800, - "y": 519, - "width": 287, - "height": 206, - "collapsed": false, - "inherited": false, - "registry": "agents/visual/UnpackPrompt", - "base_type": "core/Node" - }, "bdd13471-9bd3-46a7-83f9-5b97039a0463": { "title": "IS", "id": "bdd13471-9bd3-46a7-83f9-5b97039a0463", "properties": { "pass_through": true }, - "x": 1841, - "y": 447, + "x": 1796, + "y": 1455, "width": 210, "height": 78, "collapsed": true, @@ -163,79 +130,12 @@ "registry": "core/Switch", "base_type": "core/Node" }, - "7cb57057-5971-437b-a9ff-de9b8ef22209": { - "title": "Websocket Operation Done", - "id": "7cb57057-5971-437b-a9ff-de9b8ef22209", - "properties": { - "signal_only": false, - "allow_auto_save": true, - "emit_status_message": "" - }, - "x": 3001, - "y": 337, - "width": 304, - "height": 186, - "collapsed": false, - "inherited": false, - "registry": "websocket/signals/OperationDone", - "base_type": "core/Node" - }, - "d47daf63-4f06-4bf8-85ae-724724ea8fc3": { - "title": "Format Body", - "id": "d47daf63-4f06-4bf8-85ae-724724ea8fc3", - "properties": { - "template": "### Positive\n{positive_prompt}\n\n### Negative\n{negative_prompt}" - }, - "x": 2161, - "y": 527, - "width": 210, - "height": 153, - "collapsed": true, - "inherited": false, - "registry": "data/string/AdvancedFormat", - "dynamic_inputs": [ - { - "name": "item0", - "type": "*" - }, - { - "name": "item1", - "type": "*" - } - ], - "base_type": "core/DynamicSocketNodeBase" - }, - "9bdcb864-1e35-4fd7-baa7-876e9bdf0dc9": { - "title": "Format Title", - "id": "9bdcb864-1e35-4fd7-baa7-876e9bdf0dc9", - "properties": { - "template": "Visualize{% if character %} {{ character.name }}{% else %} scene{% endif %}" - }, - "x": 2141, - "y": 337, - "width": 210, - "height": 153, - "collapsed": false, - "inherited": false, - "registry": "prompt/Jinja2Format", - "dynamic_inputs": [ - { - "name": "item0", - "type": "*" - }, - { - "name": "item1", - "type": "*" - } - ], - "base_type": "core/DynamicSocketNodeBase" - }, "77635548-9427-4b05-a24b-6c228e286e81": { "title": "RSwitch", "id": "77635548-9427-4b05-a24b-6c228e286e81", "properties": {}, - "x": 1971, - "y": 167, + "x": 1926, + "y": 1175, "width": 140, "height": 66, "collapsed": false, @@ -243,19 +143,6 @@ "registry": "core/RSwitch", "base_type": "core/Node" }, - "d2bde569-e394-46b3-9d44-78791d423ce2": { - "title": "Generate Visual Asset", - "id": "d2bde569-e394-46b3-9d44-78791d423ce2", - "properties": {}, - "x": 1478, - "y": 289, - "width": 262, - "height": 226, - "collapsed": false, - "inherited": false, - "registry": "agents/visual/generateVisualAsset", - "base_type": "core/Graph" - }, "34180c70-80a8-46a1-99c4-5b13e1b04df2": { "title": "KV character", "id": "34180c70-80a8-46a1-99c4-5b13e1b04df2", @@ -263,8 +150,8 @@ "key": "character", "value": "" }, - "x": 1981, - "y": 267, + "x": 1936, + "y": 1275, "width": 210, "height": 122, "collapsed": true, @@ -276,8 +163,8 @@ "title": "Null", "id": "57b3f4e2-1f2b-491c-bd07-40116221b6e0", "properties": {}, - "x": 1881, - "y": 267, + "x": 1836, + "y": 1275, "width": 140, "height": 26, "collapsed": true, @@ -291,8 +178,8 @@ "properties": { "attribute": "save_asset" }, - "x": 397, - "y": 749, + "x": 352, + "y": 1757, "width": 210, "height": 98, "collapsed": false, @@ -306,8 +193,8 @@ "properties": { "default": false }, - "x": 687, - "y": 629, + "x": 642, + "y": 1637, "width": 210, "height": 58, "collapsed": true, @@ -321,8 +208,8 @@ "properties": { "attribute": "vis_type" }, - "x": 397, - "y": 243, + "x": 352, + "y": 1251, "width": 210, "height": 98, "collapsed": false, @@ -336,8 +223,8 @@ "properties": { "error_message": "Invalid value for `vis_type`." }, - "x": 687, - "y": 279, + "x": 642, + "y": 1287, "width": 210, "height": 98, "collapsed": true, @@ -360,8 +247,8 @@ "OBJECT_ILLUSTRATION" ] }, - "x": 687, - "y": 309, + "x": 642, + "y": 1317, "width": 210, "height": 102, "collapsed": true, @@ -369,31 +256,14 @@ "registry": "data/MakeList", "base_type": "core/Node" }, - "04e1ce7f-b180-429d-b8a3-23c407c017e5": { - "title": "Validate Character", - "id": "04e1ce7f-b180-429d-b8a3-23c407c017e5", - "properties": { - "error_message": "", - "character_status": "all", - "create_placeholder": false - }, - "x": 957, - "y": 119, - "width": 210, - "height": 126, - "collapsed": true, - "inherited": false, - "registry": "validation/ValidateCharacter", - "base_type": "core/Node" - }, "f300ec60-b911-4aa9-a4b9-868031a57192": { "title": "As Bool", "id": "f300ec60-b911-4aa9-a4b9-868031a57192", "properties": { "default": false }, - "x": 699, - "y": 1090, + "x": 654, + "y": 2098, "width": 210, "height": 58, "collapsed": true, @@ -407,8 +277,8 @@ "properties": { "default": false }, - "x": 699, - "y": 950, + "x": 654, + "y": 1958, "width": 210, "height": 58, "collapsed": true, @@ -422,8 +292,8 @@ "properties": { "attribute": "asset_allow_auto_attach" }, - "x": 397, - "y": 910, + "x": 352, + "y": 1918, "width": 260, "height": 98, "collapsed": false, @@ -437,8 +307,8 @@ "properties": { "attribute": "asset_allow_override" }, - "x": 397, - "y": 1080, + "x": 352, + "y": 2088, "width": 235, "height": 98, "collapsed": false, @@ -452,8 +322,8 @@ "properties": { "value": true }, - "x": 1473, - "y": 251, + "x": 1428, + "y": 1259, "width": 210, "height": 58, "collapsed": true, @@ -467,8 +337,8 @@ "properties": { "attribute": "set_cover_image" }, - "x": 397, - "y": 1240, + "x": 352, + "y": 2248, "width": 210, "height": 98, "collapsed": false, @@ -482,8 +352,8 @@ "properties": { "attribute": "override_scene_cover" }, - "x": 397, - "y": 1390, + "x": 352, + "y": 2398, "width": 235, "height": 98, "collapsed": false, @@ -497,8 +367,8 @@ "properties": { "default": false }, - "x": 699, - "y": 1270, + "x": 654, + "y": 2278, "width": 210, "height": 58, "collapsed": true, @@ -512,8 +382,8 @@ "properties": { "default": false }, - "x": 699, - "y": 1420, + "x": 654, + "y": 2428, "width": 210, "height": 58, "collapsed": true, @@ -527,8 +397,8 @@ "properties": { "default": false }, - "x": 699, - "y": 1580, + "x": 654, + "y": 2588, "width": 210, "height": 58, "collapsed": true, @@ -542,8 +412,8 @@ "properties": { "attribute": "override_character_cover" }, - "x": 397, - "y": 1550, + "x": 352, + "y": 2558, "width": 269, "height": 98, "collapsed": false, @@ -558,8 +428,8 @@ "name": "data", "typ": "any" }, - "x": 29, - "y": 281, + "x": -16, + "y": 1289, "width": 210, "height": 82, "collapsed": false, @@ -573,8 +443,8 @@ "properties": { "attribute": "message_ids" }, - "x": 397, - "y": 1700, + "x": 352, + "y": 2708, "width": 210, "height": 98, "collapsed": false, @@ -589,8 +459,8 @@ "item_type": "any", "items": [] }, - "x": 397, - "y": 1820, + "x": 726, + "y": 2444, "width": 210, "height": 102, "collapsed": true, @@ -602,9 +472,9 @@ "title": "Coallesce message_ids", "id": "d9a3c5e4-3b66-4f12-9c40-2e3f7a8b3456", "properties": {}, - "x": 699, - "y": 1730, - "width": 140, + "x": 1028, + "y": 2354, + "width": 176, "height": 86, "collapsed": false, "inherited": false, @@ -616,6 +486,7 @@ "id": "5527d9cb-6ffd-461e-bb08-0f915e06da18", "properties": { "asset_name": "", + "tags": [], "allow_auto_attach": true, "allow_override": true, "delete_old": false, @@ -624,6 +495,10 @@ "character_cover": false, "override_scene_cover": false, "override_character_cover": false, + "default_avatar": false, + "current_avatar": false, + "override_default_avatar": false, + "override_current_avatar": false, "scene_cover_image": false, "character_cover_image": false, "override_scene_cover_image": false, @@ -631,14 +506,189 @@ "set_cover_images": "none", "allow_cover_image_override": false }, - "x": 1028, - "y": 1240, + "x": 983, + "y": 2248, "width": 514, - "height": 430, + "height": 650, "collapsed": true, "inherited": false, "registry": "assets/MakeAssetAttachmentContext", "base_type": "core/Node" + }, + "7cb57057-5971-437b-a9ff-de9b8ef22209": { + "title": "Websocket Operation Done", + "id": "7cb57057-5971-437b-a9ff-de9b8ef22209", + "properties": { + "signal_only": false, + "allow_auto_save": true, + "emit_status_message": "" + }, + "x": 3301, + "y": 1343, + "width": 304, + "height": 186, + "collapsed": false, + "inherited": false, + "registry": "websocket/signals/OperationDone", + "base_type": "core/Node" + }, + "4b63c4d2-cb0d-4362-8fb7-702aa2d1cb5f": { + "title": "Emit System Message", + "id": "4b63c4d2-cb0d-4362-8fb7-702aa2d1cb5f", + "properties": { + "message_title": "Visualize", + "message": "", + "font_color": "amber-darken-3", + "icon": "mdi-image-text", + "display": "tonal", + "as_markdown": true + }, + "x": 2671, + "y": 1373, + "width": 240, + "height": 218, + "collapsed": false, + "inherited": false, + "registry": "event/EmitSystemMessage", + "base_type": "core/Node" + }, + "9bdcb864-1e35-4fd7-baa7-876e9bdf0dc9": { + "title": "Format Title", + "id": "9bdcb864-1e35-4fd7-baa7-876e9bdf0dc9", + "properties": { + "template": "Visualize{% if character %} {{ character.name }}{% else %} scene{% endif %}" + }, + "x": 2111, + "y": 1463, + "width": 210, + "height": 173, + "collapsed": false, + "inherited": false, + "registry": "prompt/Jinja2Format", + "dynamic_inputs": [ + { + "name": "item0", + "type": "*" + }, + { + "name": "item1", + "type": "*" + } + ], + "base_type": "core/DynamicSocketNodeBase" + }, + "d47daf63-4f06-4bf8-85ae-724724ea8fc3": { + "title": "Format Body", + "id": "d47daf63-4f06-4bf8-85ae-724724ea8fc3", + "properties": { + "template": "### Positive\n{positive_prompt}\n\n### Negative\n{negative_prompt}" + }, + "x": 2391, + "y": 1713, + "width": 210, + "height": 153, + "collapsed": false, + "inherited": false, + "registry": "data/string/AdvancedFormat", + "dynamic_inputs": [ + { + "name": "item0", + "type": "*" + }, + { + "name": "item1", + "type": "*" + } + ], + "base_type": "core/DynamicSocketNodeBase" + }, + "cfb303df-5fd2-4bb0-8b19-be24b0ed5893": { + "title": "Unpack Visual Prompt", + "id": "cfb303df-5fd2-4bb0-8b19-be24b0ed5893", + "properties": {}, + "x": 1741, + "y": 1713, + "width": 287, + "height": 206, + "collapsed": false, + "inherited": false, + "registry": "agents/visual/UnpackPrompt", + "base_type": "core/Node" + }, + "9f83c651-d8bc-4d75-8da2-1103febee25a": { + "title": "Finalize Prompt", + "id": "9f83c651-d8bc-4d75-8da2-1103febee25a", + "properties": { + "vis_type": "UNSPECIFIED" + }, + "x": 2081, + "y": 1713, + "width": 262, + "height": 138, + "collapsed": false, + "inherited": false, + "registry": "agents/visual/FinalizePrompt", + "base_type": "core/Node" + }, + "d2bde569-e394-46b3-9d44-78791d423ce2": { + "title": "Generate Visual Asset", + "id": "d2bde569-e394-46b3-9d44-78791d423ce2", + "properties": {}, + "x": 1433, + "y": 1297, + "width": 262, + "height": 226, + "collapsed": false, + "inherited": false, + "registry": "agents/visual/generateVisualAsset", + "base_type": "core/Graph" + }, + "b480707b-8da6-4658-900b-0811b2a9b905": { + "title": "GET character.name", + "id": "b480707b-8da6-4658-900b-0811b2a9b905", + "properties": { + "attribute": "name" + }, + "x": 1852, + "y": 1993, + "width": 210, + "height": 98, + "collapsed": true, + "inherited": false, + "registry": "data/Get", + "base_type": "core/Node" + }, + "04e1ce7f-b180-429d-b8a3-23c407c017e5": { + "title": "Validate Character", + "id": "04e1ce7f-b180-429d-b8a3-23c407c017e5", + "properties": { + "error_message": "", + "character_status": "all", + "create_placeholder": false + }, + "x": 900, + "y": 1130, + "width": 210, + "height": 126, + "collapsed": true, + "inherited": false, + "registry": "validation/ValidateCharacter", + "base_type": "core/Node" + }, + "6ca5cf1d-16f2-4dea-826e-f78c2ad0d268": { + "title": "IS", + "id": "6ca5cf1d-16f2-4dea-826e-f78c2ad0d268", + "properties": { + "pass_through": true + }, + "x": 1750, + "y": 1990, + "width": 210, + "height": 78, + "collapsed": true, + "inherited": false, + "registry": "core/Switch", + "base_type": "core/Node" } }, "edges": { @@ -663,43 +713,13 @@ "cf4618c8-f944-4973-9681-778708dba637.value": [ "f4d484b8-ecce-4af1-b569-c9b984e428af.object" ], - "4b63c4d2-cb0d-4362-8fb7-702aa2d1cb5f.state": [ - "006732ed-c87f-43db-8ec7-4043748466eb.b" - ], - "cfb303df-5fd2-4bb0-8b19-be24b0ed5893.positive_prompt": [ - "d47daf63-4f06-4bf8-85ae-724724ea8fc3.item0" - ], - "cfb303df-5fd2-4bb0-8b19-be24b0ed5893.negative_prompt": [ - "d47daf63-4f06-4bf8-85ae-724724ea8fc3.item1" - ], "bdd13471-9bd3-46a7-83f9-5b97039a0463.yes": [ - "4b63c4d2-cb0d-4362-8fb7-702aa2d1cb5f.state" - ], - "d47daf63-4f06-4bf8-85ae-724724ea8fc3.result": [ - "4b63c4d2-cb0d-4362-8fb7-702aa2d1cb5f.message" - ], - "9bdcb864-1e35-4fd7-baa7-876e9bdf0dc9.result": [ - "4b63c4d2-cb0d-4362-8fb7-702aa2d1cb5f.message_title" + "4b63c4d2-cb0d-4362-8fb7-702aa2d1cb5f.state", + "9f83c651-d8bc-4d75-8da2-1103febee25a.state" ], "77635548-9427-4b05-a24b-6c228e286e81.value": [ "34180c70-80a8-46a1-99c4-5b13e1b04df2.value" ], - "d2bde569-e394-46b3-9d44-78791d423ce2.state": [ - "006732ed-c87f-43db-8ec7-4043748466eb.a" - ], - "d2bde569-e394-46b3-9d44-78791d423ce2.character": [ - "77635548-9427-4b05-a24b-6c228e286e81.check", - "77635548-9427-4b05-a24b-6c228e286e81.yes" - ], - "d2bde569-e394-46b3-9d44-78791d423ce2.prompt_only": [ - "bdd13471-9bd3-46a7-83f9-5b97039a0463.value" - ], - "d2bde569-e394-46b3-9d44-78791d423ce2.visual_prompt": [ - "cfb303df-5fd2-4bb0-8b19-be24b0ed5893.prompt" - ], - "d2bde569-e394-46b3-9d44-78791d423ce2.generation_request": [ - "9bdcb864-1e35-4fd7-baa7-876e9bdf0dc9.item0" - ], "34180c70-80a8-46a1-99c4-5b13e1b04df2.kv": [ "9bdcb864-1e35-4fd7-baa7-876e9bdf0dc9.item1" ], @@ -718,9 +738,6 @@ "3001b0db-42b4-4a10-b586-e29317d69c02.list": [ "7b514d6c-d237-4abd-aac0-201e9d8c41f1.list" ], - "04e1ce7f-b180-429d-b8a3-23c407c017e5.character": [ - "d2bde569-e394-46b3-9d44-78791d423ce2.character" - ], "f300ec60-b911-4aa9-a4b9-868031a57192.value": [ "5527d9cb-6ffd-461e-bb08-0f915e06da18.allow_override" ], @@ -768,9 +785,6 @@ "7dd0877a-67ca-4989-bab0-88b17764979d.object", "b7e1a3c2-1f44-4d90-9a2e-7c6d5e0f1234.object" ], - "5527d9cb-6ffd-461e-bb08-0f915e06da18.context": [ - "d2bde569-e394-46b3-9d44-78791d423ce2.asset_ctx" - ], "b7e1a3c2-1f44-4d90-9a2e-7c6d5e0f1234.value": [ "d9a3c5e4-3b66-4f12-9c40-2e3f7a8b3456.a" ], @@ -779,15 +793,68 @@ ], "d9a3c5e4-3b66-4f12-9c40-2e3f7a8b3456.value": [ "5527d9cb-6ffd-461e-bb08-0f915e06da18.message_ids" + ], + "5527d9cb-6ffd-461e-bb08-0f915e06da18.context": [ + "d2bde569-e394-46b3-9d44-78791d423ce2.asset_ctx" + ], + "4b63c4d2-cb0d-4362-8fb7-702aa2d1cb5f.state": [ + "006732ed-c87f-43db-8ec7-4043748466eb.b" + ], + "9bdcb864-1e35-4fd7-baa7-876e9bdf0dc9.result": [ + "4b63c4d2-cb0d-4362-8fb7-702aa2d1cb5f.message_title" + ], + "d47daf63-4f06-4bf8-85ae-724724ea8fc3.result": [ + "4b63c4d2-cb0d-4362-8fb7-702aa2d1cb5f.message" + ], + "cfb303df-5fd2-4bb0-8b19-be24b0ed5893.positive_prompt": [ + "9f83c651-d8bc-4d75-8da2-1103febee25a.positive_prompt" + ], + "cfb303df-5fd2-4bb0-8b19-be24b0ed5893.negative_prompt": [ + "9f83c651-d8bc-4d75-8da2-1103febee25a.negative_prompt" + ], + "9f83c651-d8bc-4d75-8da2-1103febee25a.positive_prompt": [ + "d47daf63-4f06-4bf8-85ae-724724ea8fc3.item0" + ], + "9f83c651-d8bc-4d75-8da2-1103febee25a.negative_prompt": [ + "d47daf63-4f06-4bf8-85ae-724724ea8fc3.item1" + ], + "d2bde569-e394-46b3-9d44-78791d423ce2.state": [ + "006732ed-c87f-43db-8ec7-4043748466eb.a" + ], + "d2bde569-e394-46b3-9d44-78791d423ce2.character": [ + "77635548-9427-4b05-a24b-6c228e286e81.check", + "77635548-9427-4b05-a24b-6c228e286e81.yes" + ], + "d2bde569-e394-46b3-9d44-78791d423ce2.vis_type": [ + "9f83c651-d8bc-4d75-8da2-1103febee25a.vis_type" + ], + "d2bde569-e394-46b3-9d44-78791d423ce2.prompt_only": [ + "bdd13471-9bd3-46a7-83f9-5b97039a0463.value" + ], + "d2bde569-e394-46b3-9d44-78791d423ce2.visual_prompt": [ + "cfb303df-5fd2-4bb0-8b19-be24b0ed5893.prompt" + ], + "d2bde569-e394-46b3-9d44-78791d423ce2.generation_request": [ + "9bdcb864-1e35-4fd7-baa7-876e9bdf0dc9.item0" + ], + "b480707b-8da6-4658-900b-0811b2a9b905.value": [ + "9f83c651-d8bc-4d75-8da2-1103febee25a.character_name" + ], + "04e1ce7f-b180-429d-b8a3-23c407c017e5.character": [ + "d2bde569-e394-46b3-9d44-78791d423ce2.character", + "6ca5cf1d-16f2-4dea-826e-f78c2ad0d268.value" + ], + "6ca5cf1d-16f2-4dea-826e-f78c2ad0d268.yes": [ + "b480707b-8da6-4658-900b-0811b2a9b905.object" ] }, "groups": [ { "title": "Process", - "x": 4, - "y": -6, - "width": 3326, - "height": 1679, + "x": -41, + "y": 1003, + "width": 3879, + "height": 1868, "color": "#3f789e", "font_size": 24, "inherited": false @@ -799,28 +866,28 @@ "inputs": [], "outputs": [ { - "id": "7ceb9ea0-03a9-49f8-811d-a57798a313b8", + "id": "6f8db74b-33fd-45f1-a2d4-7fc20645ebef", "name": "fn", "optional": false, "group": null, "socket_type": "function" }, { - "id": "6bf31fc7-0b68-4a94-8f9d-24bc04785e6c", + "id": "828896e4-444c-4593-a691-314a7f12814b", "name": "name", "optional": false, "group": null, "socket_type": "str" }, { - "id": "9b4752a1-245b-4bae-aa6e-66918381eb76", + "id": "6478377f-57f3-4813-9f32-b884701bb3e5", "name": "allow_multiple_calls", "optional": false, "group": null, "socket_type": "bool" }, { - "id": "ae47008e-ea06-4e6e-bd98-13f5b568d657", + "id": "3af0c812-a0bd-4221-98f4-35d0255ffdf3", "name": "ai_callback", "optional": false, "group": null, @@ -860,4 +927,4 @@ "auto_title": null, "counterpart": null } -} \ No newline at end of file +} diff --git a/src/talemate/agents/visual/nodes.py b/src/talemate/agents/visual/nodes.py index a1e86109..885188fa 100644 --- a/src/talemate/agents/visual/nodes.py +++ b/src/talemate/agents/visual/nodes.py @@ -73,7 +73,17 @@ class VisualSettings(AgentSettingsNode): @register("agents/visual/EnumValues") class EnumValues(Node): """ - Returns the values of an enum + Returns the possible values of one of the visual agent's enums + (VIS_TYPE, GEN_TYPE, FORMAT_TYPE or PROMPT_TYPE), selected via the + enum property. + + Properties: + + - enum: The enum to get the values of + + Outputs: + + - values: The list of the enum's values """ class Fields: @@ -110,7 +120,15 @@ class EnumValues(Node): @register("agents/visual/BackendStatus") class BackendStatus(AgentNode): """ - Checks the status of the backend + Reports the capabilities of the visual agent's currently configured + backends. + + Outputs: + + - can_generate_images: Whether an image generation backend is available + - can_edit_images: Whether an image editing backend is available + - max_references: Maximum number of reference images the image-edit + backend supports (0 if none is available) """ _agent_name: ClassVar[str] = "visual" @@ -143,7 +161,32 @@ class BackendStatus(AgentNode): @register("agents/visual/PromptPart") class PromptPart(Node): """ - Creates a visual prompt part instance + Creates a visual prompt part from instructions, keyword lists and + descriptive text. Prompt parts are combined into a visual prompt (via the + Prompt node), which compiles the final positive / negative prompts from + them. Keyword inputs given as a single string are split on ", ", and + positive keywords prefixed with "no " (e.g. "no glasses") are treated as + implied negative keywords. + + Inputs: + + - instructions: Free-form instructions for the prompt part + - positive_keywords_raw: Positive keywords as a list or comma-separated string + - negative_keywords_raw: Negative keywords as a list or comma-separated string + - positive_descriptive: Descriptive (prose) positive prompt text + - negative_descriptive: Descriptive (prose) negative prompt text + + Outputs: + + - prompt_part: The created visual prompt part + - instructions: The instructions (passed through) + - positive_keywords_raw: Raw positive keywords, including any "no ..." entries + - negative_keywords_raw: Raw negative keywords (passed through) + - implied_negative_keywords: Keywords derived from "no ..." positive keywords + - positive_keywords: Positive keywords with "no ..." entries removed + - negative_keywords: Raw negative keywords plus the implied negative keywords + - positive_descriptive: Descriptive positive text (passed through) + - negative_descriptive: Descriptive negative text (passed through) """ class Fields: @@ -245,7 +288,18 @@ class PromptPart(Node): @register("agents/visual/Prompt") class Prompt(Node): """ - Creates a visual prompt instance + Creates a visual prompt from a list of prompt parts. The prompt_type + controls how the parts are compiled into the final positive / negative + prompt strings (comma-separated keywords or descriptive prose). + + Inputs: + + - prompt_type: The type of prompt to create (KEYWORDS or DESCRIPTIVE) + - parts: List of visual prompt parts to combine + + Outputs: + + - prompt: The created visual prompt """ class Fields: @@ -278,7 +332,27 @@ class Prompt(Node): @register("agents/visual/UnpackPrompt") class UnpackPrompt(Node): """ - Unpacks a visual prompt into a list of prompt parts + Unpacks a visual prompt into its parts and the compiled prompt strings. + Use this to access the final positive / negative prompts (built according + to the prompt's prompt_type) or the keyword-only / descriptive-only + variants regardless of the prompt type. + + Inputs: + + - prompt: The visual prompt to unpack + + Outputs: + + - prompt: The visual prompt (passed through) + - parts: The prompt's list of prompt parts + - prompt_type: The prompt type (KEYWORDS or DESCRIPTIVE) + - instructions: Combined instructions from all parts + - positive_prompt: Positive prompt compiled according to prompt_type + - negative_prompt: Negative prompt compiled according to prompt_type + - positive_prompt_keywords: Positive prompt compiled as comma-separated keywords + - negative_prompt_keywords: Negative prompt compiled as comma-separated keywords + - positive_prompt_descriptive: Positive prompt compiled as descriptive text + - negative_prompt_descriptive: Negative prompt compiled as descriptive text """ def __init__(self, title="Unpack Visual Prompt", **kwargs): @@ -319,7 +393,22 @@ class UnpackPrompt(Node): @register("agents/visual/ApplyStyles") class ApplyStyles(AgentNode): """ - Applies configured styles to a visual prompt + Applies the configured style templates to a visual prompt: the active + art style plus the subject style matching the given vis_type. Matching + styles are inserted at the front of the prompt's part list, modifying + the prompt in place. + + Inputs: + + - state: The graph state + - prompt: The visual prompt to apply styles to + - vis_type: The type of visual to apply styles for (optional) + + Outputs: + + - state: The graph state, passed through + - prompt: The prompt with the styles applied + - vis_type: The vis_type, passed through """ _agent_name: ClassVar[str] = "visual" @@ -341,6 +430,7 @@ class ApplyStyles(AgentNode): self.add_input("prompt", socket_type="visual/prompt") self.add_input("vis_type", socket_type="str", optional=True) self.set_property("vis_type", "UNSPECIFIED") + self.add_output("state") self.add_output("prompt", socket_type="visual/prompt") self.add_output("vis_type", socket_type="str") @@ -357,10 +447,97 @@ class ApplyStyles(AgentNode): ) +@register("agents/visual/FinalizePrompt") +class FinalizePrompt(AgentNode): + """ + Applies the visualizer's prompt finalization (post-processing actions) + to a positive / negative prompt string pair. + + Inputs: + - state: graph state (required — wire through any gating switch so the + node is skipped when its output would be discarded) + - positive_prompt: positive prompt string (required) + - negative_prompt: negative prompt string (optional) + - vis_type: type of visual the prompts are for (optional) + - character_name: character the prompts involve, enables character + level finalizers (optional) + + Outputs: + - state: graph state (passed through) + - positive_prompt: finalized positive prompt + - negative_prompt: finalized negative prompt + """ + + _agent_name: ClassVar[str] = "visual" + + class Fields: + vis_type = PropertyField( + name="vis_type", + type="str", + description="The type of visual the prompts are for", + default="UNSPECIFIED", + choices=VIS_TYPE.choice_values(), + ) + + def __init__(self, title="Finalize Prompt", **kwargs): + super().__init__(title=title, **kwargs) + + def setup(self): + # state is required so an upstream gate (e.g. a prompt_only switch) + # can deactivate this node — finalization may issue AI queries, so + # it must not run eagerly on paths whose result is discarded + self.add_input("state") + self.add_input("positive_prompt", socket_type="str") + self.add_input("negative_prompt", socket_type="str", optional=True) + self.add_input("vis_type", socket_type="str", optional=True) + self.add_input("character_name", socket_type="str", optional=True) + self.set_property("vis_type", "UNSPECIFIED") + self.add_output("state") + self.add_output("positive_prompt", socket_type="str") + self.add_output("negative_prompt", socket_type="str") + + async def run(self, state: GraphState): + positive = self.normalized_input_value("positive_prompt") + negative = self.normalized_input_value("negative_prompt") + vis_type = self.normalized_input_value("vis_type") or "UNSPECIFIED" + character_name = self.normalized_input_value("character_name") + + positive, negative = await self.agent.finalize_prompts( + positive, + negative, + VIS_TYPE(vis_type), + character_name or None, + ) + + self.set_output_values( + { + "state": self.get_input_value("state"), + "positive_prompt": positive, + "negative_prompt": negative, + } + ) + + @register("agents/visual/ApplyStyle") class ApplyStyle(AgentNode): """ - Applies a style to a visual prompt + Applies a specific style template (by template id) to a visual prompt, + inserting it at the front of the prompt's part list and modifying the + prompt in place. + + Inputs: + + - state: The graph state + - prompt: The visual prompt to apply the style to + - template_id: The id of the style template to apply + + Outputs: + + - state: The graph state, passed through + - prompt: The prompt with the style applied + - template_id: The template id, passed through + - prompt_part: The prompt part created from the style template (None + if the template was not found) """ _agent_name: ClassVar[str] = "visual" @@ -372,6 +549,7 @@ class ApplyStyle(AgentNode): self.add_input("state") self.add_input("prompt", socket_type="visual/prompt") self.add_input("template_id", socket_type="str") + self.add_output("state") self.add_output("prompt", socket_type="visual/prompt") self.add_output("template_id", socket_type="str") self.add_output("prompt_part", socket_type="visual/prompt_part") @@ -395,7 +573,30 @@ class ApplyStyle(AgentNode): @register("agents/visual/SelectBackend") class SelectBackend(AgentNode): """ - Selects a backend based on the generation type + Determines which visual backend and generation type to use for a request. + Selects the image-edit backend (gen_type IMAGE_EDIT) when reference assets + are provided and image editing is available, or when image generation is + unavailable but editing is; otherwise selects the image generation backend + (gen_type TEXT_TO_IMAGE). Also resolves the prompt type the selected + backend expects and the image format implied by the visual type. + + Inputs: + + - state: Graph state + - vis_type: The type of visual to generate + - reference_assets: List of reference asset IDs; when set, steers + selection toward the image-edit backend + + Outputs: + + - state: Graph state (passed through) + - backend_name: Name of the selected backend (empty if none is available) + - gen_type: The selected generation type (TEXT_TO_IMAGE or IMAGE_EDIT) + - vis_type: The visual type (passed through) + - prompt_type: Prompt type the selected backend expects (falls back to + the agent's fallback prompt type when no backend is available) + - format: Image format derived from the visual type (e.g. PORTRAIT) + - reference_assets: The reference assets list (passed through) """ _agent_name: ClassVar[str] = "visual" @@ -478,7 +679,6 @@ class GenerationRequestNode(AgentNode): - character_name: name of character for character-specific generation (optional) - reference_assets: list of reference asset IDs (optional) - callback: callback function to run after generation (optional) - - save_asset: whether to save the generated asset to scene (optional) - extra_config: additional configuration dict (optional) - asset_attachment_context: controls automatic asset attachment behavior (optional) @@ -490,7 +690,6 @@ class GenerationRequestNode(AgentNode): - character_name: character name (passed through) - reference_assets: reference assets list (passed through) - gen_type: generation type (passed through) - - save_asset: save asset flag (passed through) - extra_config: extra config dict (passed through) """ @@ -623,7 +822,22 @@ class GenerationRequestNode(AgentNode): @register("agents/visual/GenerateImage") class GenerateImage(AgentNode): """ - Generates an image + Generates an image by submitting a generation request to the visual + agent, which routes it to the appropriate backend. The request's + callback (if any) is invoked with the response, and depending on the + request's asset attachment context the resulting image may be saved + to the scene's assets. + + Inputs: + + - state: The graph state + - generation_request: The generation request to execute + + Outputs: + + - state: The state input, passed through + - generation_request: The generation request, passed through + - generation_response: The generation response containing the image """ _agent_name: ClassVar[str] = "visual" @@ -655,7 +869,23 @@ class GenerateImage(AgentNode): @register("agents/visual/UnpackGenerationRequest") class UnpackGenerationRequest(AgentNode): """ - Unpacks a generation request + Unpacks a visual generation request into its individual fields. + + Inputs: + + - generation_request: The generation request to unpack + + Outputs: + + - generation_request: The generation request, passed through + - prompt: The positive prompt string + - vis_type: The visual type + - format: The image format + - character_name: The character name + - reference_assets: The list of reference asset IDs + - gen_type: The generation type + - extra_config: The extra configuration dict + - asset_attachment_context: The asset attachment context """ _agent_name: ClassVar[str] = "visual" @@ -697,7 +927,20 @@ class UnpackGenerationRequest(AgentNode): @register("agents/visual/UnpackGenerationResponse") class UnpackGenerationResponse(AgentNode): """ - Unpacks a generation response + Unpacks a visual generation response into its individual fields. + + Inputs: + + - generation_response: The generation response to unpack + + Outputs: + + - generation_response: The generation response, passed through + - base64: The generated image as base64 encoded data + - image_data: The generated image as a data URI + - id: The generation's ID + - backend_name: The name of the backend that generated the image + - request: The generation request that produced this response """ _agent_name: ClassVar[str] = "visual" diff --git a/src/talemate/agents/visual/schema.py b/src/talemate/agents/visual/schema.py index fa4adc0e..4090fa5e 100644 --- a/src/talemate/agents/visual/schema.py +++ b/src/talemate/agents/visual/schema.py @@ -1,4 +1,4 @@ -from typing import Any, ClassVar, TYPE_CHECKING, Callable +from typing import Any, ClassVar, Literal, TYPE_CHECKING, Callable import pydantic import enum import base64 @@ -20,6 +20,7 @@ __all__ = [ "BackendStatusType", "VisualPrompt", "VisualPromptPart", + "PromptFinalizer", "SamplerSettings", "Resolution", "RESOLUTION_MAP", @@ -27,6 +28,8 @@ __all__ = [ "GEN_TYPE", "PROMPT_TYPE", "FORMAT_TYPE", + "FINALIZER_MODE", + "FINALIZER_TARGET", "VIS_TYPE_TO_FORMAT", "ENUM_TYPES", ] @@ -80,6 +83,65 @@ class FORMAT_TYPE(ChoiceMixin, enum.StrEnum): SQUARE = "SQUARE" +class FINALIZER_MODE(ChoiceMixin, enum.StrEnum): + EXACT = "EXACT" + FUZZY = "FUZZY" + REGEX = "REGEX" + AI = "AI" + + +class FINALIZER_TARGET(ChoiceMixin, enum.StrEnum): + POSITIVE = "POSITIVE" + NEGATIVE = "NEGATIVE" + BOTH = "BOTH" + + +class PromptFinalizer(pydantic.BaseModel): + """ + A single prompt post-processing action, applied to the finalized + prompt string right before it is sent to image generation. + """ + + # enum fields are stored as plain values (validate_default so enum + # defaults are unwrapped too) so the model survives yaml.dump / + # safe_load round-trips in world state template groups + model_config = pydantic.ConfigDict(use_enum_values=True, validate_default=True) + + enabled: bool = True + mode: FINALIZER_MODE = FINALIZER_MODE.EXACT + # search string (EXACT, FUZZY) or pattern (REGEX); unused for AI + match: str = "" + # replacement text (EXACT, FUZZY, REGEX) or instruction (AI); + # an empty replacement removes the match + replace: str = "" + flags: list[Literal["case_sensitive", "dot_all", "multiline"]] = pydantic.Field( + default_factory=list + ) + target: FINALIZER_TARGET = FINALIZER_TARGET.POSITIVE + # empty applies to all visual types + vis_types: list[str] = pydantic.Field(default_factory=list) + + @pydantic.field_validator("vis_types") + @classmethod + def validate_vis_types(cls, values: list[str]) -> list[str]: + # use_enum_values does not unwrap enums inside lists, so coerce + # to plain values here to keep the model yaml-safe + return [VIS_TYPE(value).value for value in values] + + @property + def case_sensitive(self) -> bool: + return "case_sensitive" in self.flags + + def applies_to(self, vis_type: "VIS_TYPE", positive: bool) -> bool: + if not self.enabled: + return False + if self.vis_types and vis_type.value not in self.vis_types: + return False + if positive: + return self.target != FINALIZER_TARGET.NEGATIVE + return self.target != FINALIZER_TARGET.POSITIVE + + class BackendStatusType(enum.Enum): OK = "ok" ERROR = "error" diff --git a/src/talemate/agents/visual/style.py b/src/talemate/agents/visual/style.py index 217ed8d4..5a68df87 100644 --- a/src/talemate/agents/visual/style.py +++ b/src/talemate/agents/visual/style.py @@ -14,6 +14,18 @@ __all__ = [ log = structlog.get_logger("talemate.agents.visual.style") +def _part_from_style(template: VisualStyle) -> VisualPromptPart: + """Build a VisualPromptPart from a style template, mapping the + template's keyword lists onto the part's raw keyword fields.""" + return VisualPromptPart( + positive_keywords_raw=template.positive_keywords, + negative_keywords_raw=template.negative_keywords, + positive_descriptive=template.positive_descriptive, + negative_descriptive=template.negative_descriptive, + instructions=template.instructions, + ) + + class StyleMixin: @classmethod def add_actions(cls, actions: dict[str, AgentAction]): @@ -121,11 +133,7 @@ class StyleMixin: if not template_id: return None - try: - group_uid, template_uid = template_id.split("__") - except ValueError: - return None - return templates.find_template(group_uid, template_uid) + return templates.find_template_by_id(template_id) def _get_current_art_style_name(self) -> str | None: """Get the name of the currently active art style template""" @@ -150,11 +158,26 @@ class StyleMixin: def apply_style( self, prompt: VisualPrompt, template_id: str ) -> VisualPromptPart | None: - template = self.style_template(template_id) - part = None - if template: - part = VisualPromptPart(**template.model_dump()) - prompt.parts.insert(0, part) + """Apply a specific style template (by `group_uid__template_uid`) to + the prompt, inserting it at the front of the part list. + + Returns the created part, or None if the template id is malformed or + the template was not found (prompt left unchanged). + """ + scene = getattr(self, "scene", None) + if not scene or not template_id: + return None + + manager: WorldStateManager = scene.world_state_manager + templates: Collection = manager.template_collection + + template = templates.find_template_by_id(template_id) + if not isinstance(template, VisualStyle): + # unknown id, or a valid id pointing at a non-visual template + return None + + part = _part_from_style(template) + prompt.parts.insert(0, part) return part def apply_styles(self, prompt: VisualPrompt, vis_type: VIS_TYPE) -> VisualPrompt: @@ -170,27 +193,9 @@ class StyleMixin: ) if template_subject_style: - prompt.parts.insert( - 0, - VisualPromptPart( - positive_keywords_raw=template_subject_style.positive_keywords, - negative_keywords_raw=template_subject_style.negative_keywords, - positive_descriptive=template_subject_style.positive_descriptive, - negative_descriptive=template_subject_style.negative_descriptive, - instructions=template_subject_style.instructions, - ), - ) + prompt.parts.insert(0, _part_from_style(template_subject_style)) if template_art_style: - prompt.parts.insert( - 0, - VisualPromptPart( - positive_keywords_raw=template_art_style.positive_keywords, - negative_keywords_raw=template_art_style.negative_keywords, - positive_descriptive=template_art_style.positive_descriptive, - negative_descriptive=template_art_style.negative_descriptive, - instructions=template_art_style.instructions, - ), - ) + prompt.parts.insert(0, _part_from_style(template_art_style)) return prompt diff --git a/src/talemate/agents/world_state/__init__.py b/src/talemate/agents/world_state/__init__.py index a99b7f89..074d1dc6 100644 --- a/src/talemate/agents/world_state/__init__.py +++ b/src/talemate/agents/world_state/__init__.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import isodate import structlog @@ -12,6 +14,7 @@ from talemate.prompts import Prompt from talemate.scene_message import TimePassageMessage from talemate.util.response import extract_list +from talemate.util.data import parse_attribute_lines from talemate.agents.base import ( Agent, @@ -34,6 +37,9 @@ from .pin_conditions import WorldStatePinConditionsMixin from .websocket_handler import WorldStateWebsocketHandler import talemate.agents.world_state.nodes +if TYPE_CHECKING: + from talemate.character import Character + log = structlog.get_logger("talemate.agents.world_state") talemate.emit.async_signals.register("agent.world_state.time") @@ -337,20 +343,7 @@ class WorldStateAgent( def _parse_character_sheet( self, response, max_attributes: int | None = None ) -> dict[str, str]: - data = {} - for line in response.split("\n"): - if not line.strip(): - continue - if ":" not in line: - break - name, value = line.split(":", 1) - data[name.strip()] = value.strip() - - # Enforce max_attributes limit if set - if max_attributes and max_attributes > 0 and len(data) >= max_attributes: - break - - return data + return parse_attribute_lines(response, max_attributes=max_attributes) @set_processing async def extract_character_sheet( @@ -361,9 +354,19 @@ class WorldStateAgent( augmentation_instructions: str = None, dynamic_instructions: list[DynamicInstruction] = [], max_attributes: int | None = None, + character: Character | None = None, ) -> dict[str, str]: """ Attempts to extract a character sheet from the given text. + + Returns an empty dict when the generation produced no attribute + content. + + character: Explicit context character for the prompt - for callers + whose character is not in the scene yet (pre-creation flows). + Defaults to the scene lookup. When given, it renders as the + prompt's character context (a same-named scene character is + skipped), so the profiled character appears exactly once. """ response, extracted = await Prompt.request( @@ -375,7 +378,8 @@ class WorldStateAgent( "max_tokens": self.client.max_token_length, "text": text, "name": name, - "character": self.scene.get_character(name), + "character": character or self.scene.get_character(name), + "context_character": character, "alteration_instructions": alteration_instructions or "", "augmentation_instructions": augmentation_instructions or "", "dynamic_instructions": dynamic_instructions, @@ -388,10 +392,18 @@ class WorldStateAgent( # # break as soon as a non-empty line is found that doesn't contain a : - return self._parse_character_sheet( + sheet = self._parse_character_sheet( extracted["response"], max_attributes=max_attributes ) + # the template primes the response with "Name: \nAge:", so a + # generation that produced nothing is primed back up into a sheet + # carrying just the character's own name - report that as empty + if not any(value for key, value in sheet.items() if key.lower() != "name"): + return {} + + return sheet + @set_processing async def summarize_and_pin(self, message_id: int, num_messages: int = 3) -> str: """ diff --git a/src/talemate/agents/world_state/nodes.py b/src/talemate/agents/world_state/nodes.py index 7edd81fd..c7f4a40d 100644 --- a/src/talemate/agents/world_state/nodes.py +++ b/src/talemate/agents/world_state/nodes.py @@ -55,7 +55,7 @@ class ExtractCharacterSheet(AgentNode): Outputs: - - character_sheet: The extracted character sheet (dict) + - character_sheet: The extracted character sheet (dict, empty if the model produced nothing) """ _agent_name: ClassVar[str] = "world_state" @@ -88,22 +88,30 @@ class ExtractCharacterSheet(AgentNode): @register("agents/world_state/StateReinforcement") class StateReinforcement(AgentNode): """ - Reinforces the a tracked state of a character or the world in general. + Sets up (or updates) a tracked state reinforcement for a character or + the world in general. Adds the reinforcement to the scene's world + state, then immediately runs an update, producing a reinforcement + message. Inputs: - state: The current state of the graph - - query_or_detail: The query or instruction to reinforce - - character: The character to reinforce the state for (optional) + - query_or_detail: The question or detail to track + - character: The character to track the state for (optional; when + omitted the state is tracked for the world in general) + - instructions: Additional instructions for the reinforcement (optional) - Properties + Properties: - - reset: If the state should be reset + - interval: How many rounds pass between automatic re-evaluations + - insert_method: How the reinforcement is inserted into the context + - reset: If the tracked state should be reset Outputs: - state: graph state - message: state reinforcement message + - reinforcement: the reinforcement that was added or updated """ _agent_name: ClassVar[str] = "world_state" @@ -175,7 +183,7 @@ class StateReinforcement(AgentNode): instructions = self.normalized_input_value("instructions") insert_method = self.get_property("insert_method") - await scene.world_state.add_reinforcement( + reinforcement = await scene.world_state.add_reinforcement( question=query_or_detail, character=character.name if character else None, instructions=instructions, @@ -187,7 +195,9 @@ class StateReinforcement(AgentNode): question=query_or_detail, character=character, reset=reset ) - self.set_output_values({"state": state, "message": message}) + self.set_output_values( + {"state": state, "message": message, "reinforcement": reinforcement} + ) @register("agents/world_state/DeactivateCharacter") @@ -376,7 +386,16 @@ class RequestWorldState(AgentNode): @register("agents/world_state/EmitWorldState") class EmitWorldState(AgentNode): """ - Emits the current world state. + Re-emits the scene's current world state to the frontend so the world + state UI refreshes. Does not regenerate or update the world state. + + Inputs: + + - state: The graph state + + Outputs: + + - state: The state input, passed through """ _agent_name: ClassVar[str] = "world_state" @@ -399,7 +418,28 @@ class EmitWorldState(AgentNode): @register("agents/world_state/CharacterProgression") class CharacterProgression(AgentNode): """ - Character progression + Determines character development (new, updated or removed attributes, + description updates) via the world_state agent and processes the + resulting proposals. With as_suggestions enabled the proposals are + added to the world state manager as suggestions for the user to + review; otherwise they are applied to the character directly. + + Inputs: + + - state: The graph state + - character: The character to progress + - instructions: Instructions guiding the development (required at + runtime despite the optional socket) + + Properties: + + - as_suggestions: Whether to store proposals as suggestions instead of + applying them directly + + Outputs: + + - state: The state input, passed through + - calls: The list of proposal calls that were generated """ _agent_name: ClassVar[str] = "world_state" @@ -452,7 +492,24 @@ class CharacterProgression(AgentNode): @register("agents/world_state/AdvanceTime") class AdvanceTime(AgentNode): """ - Advances the time of the world state. + Advances the scene time by the given ISO 8601 duration. Pushes a + TimePassageMessage to the scene history, emits it to the UI and fires + the time passage signal (which other agents may react to, e.g. to + narrate the time passage using the narration instructions). + + Inputs: + + - state: The graph state + - duration: The duration to advance as an ISO 8601 duration (e.g. "PT1H") + - narration_instructions: Instructions for narrating the time passage + (optional) + + Outputs: + + - state: The state input, passed through + - duration: The duration of the emitted message + - narration_instructions: The narration instructions, passed through + - message: The TimePassageMessage that was added to the history """ _agent_name: ClassVar[str] = "world_state" # diff --git a/src/talemate/changelog.py b/src/talemate/changelog.py index 2ed14073..7525e294 100644 --- a/src/talemate/changelog.py +++ b/src/talemate/changelog.py @@ -79,6 +79,7 @@ import deepdiff from datetime import datetime, timezone import shutil import glob +import uuid from talemate.save import SceneEncoder from talemate.path import SCENES_DIR @@ -118,6 +119,18 @@ EXCLUDE_FROM_DELTAS_REGEX = [ # re.compile(r"root\['some_array'\]\[\d+\]\['volatile_field'\]"), ] +# Scene-data fields that must always be lists. Delta application with +# force=True can leave an int-keyed dict in their place when an iterable op +# targets a path whose parent is missing (baseline divergence) — deepdiff +# creates ``{index: item}`` instead of a list. +_LIST_FIELDS = ( + "history", + "archived_history", + "layered_history", + "active_characters", + "game_state_watch_paths", +) + # Helper minimal scene reference compatible with this module's helpers class _SceneRef: @@ -125,9 +138,22 @@ class _SceneRef: self.filename = filename self.save_dir = save_dir self.changelog_dir = os.path.join(save_dir, "changelog") + self.backups_dir = os.path.join(save_dir, "backups") self.serialize = data +def scene_ref_from_path(scene_path: str) -> _SceneRef: + """ + Build a minimal scene reference for changelog operations on a scene that + is not currently loaded, from the path to its scene file. + """ + return _SceneRef( + filename=os.path.basename(scene_path), + save_dir=os.path.dirname(scene_path), + data={}, + ) + + async def save_changelog(scene: "Scene"): """ Initialize the changelog system for a scene by creating base and latest snapshot files. @@ -671,6 +697,61 @@ def _get_overall_latest_revision(scene: "Scene") -> int: return latest_rev +def _coerce_forced_list(value) -> tuple[list | None, bool]: + """Convert a force-mode artifact — an int-keyed dict where a list is + expected — back into a list ordered by index. + + Returns ``(converted_list, True)`` when the value was such an artifact, + ``(None, False)`` otherwise. Dicts with any non-index key are left alone. + """ + if not isinstance(value, dict): + return None, False + + indexed: list[tuple[int, object]] = [] + for key, item in value.items(): + if isinstance(key, bool): + return None, False + if isinstance(key, int): + indexed.append((key, item)) + elif isinstance(key, str) and re.fullmatch(r"-?\d+", key): + # int keys become strings when the data round-trips through JSON + indexed.append((int(key), item)) + else: + return None, False + + indexed.sort(key=lambda pair: pair[0]) + return [item for _, item in indexed], True + + +def _repair_forced_list_fields(data: dict) -> int: + """Restore list fields that force-mode delta application turned into + int-keyed dicts (see ``_LIST_FIELDS``), including the nested layers of + ``layered_history``. + + Returns the number of fields repaired. + """ + repaired: list[str] = [] + + for field in _LIST_FIELDS: + converted, was_forced = _coerce_forced_list(data.get(field)) + if was_forced: + data[field] = converted + repaired.append(field) + + layered = data.get("layered_history") + if isinstance(layered, list): + for i, layer in enumerate(layered): + converted, was_forced = _coerce_forced_list(layer) + if was_forced: + layered[i] = converted + repaired.append(f"layered_history[{i}]") + + if repaired: + log.warning("repaired_forced_list_fields", fields=repaired) + + return len(repaired) + + def _repair_history(data: dict) -> int: """Backfill required SceneMessage fields on bare-fragment history entries. @@ -715,6 +796,7 @@ async def reconstruct_cleanup(data: dict) -> dict: ) data["shared_context"] = "" + _repair_forced_list_fields(data) _repair_history(data) return data @@ -926,6 +1008,55 @@ def delete_changelog_files(scene: "Scene") -> dict: return {"deleted": deleted, "dir_removed": None} +def create_pre_rollback_backup(scene: "Scene") -> str | None: + """ + Copy the scene's current file to a timestamped backup in its backups + directory before a rollback overwrites or replaces its state. + + Returns: + str | None: Path to the backup file, or None if the scene file does + not exist or the copy failed. + """ + current_path = os.path.join(scene.save_dir, scene.filename) + if not os.path.exists(current_path): + return None + + os.makedirs(scene.backups_dir, exist_ok=True) + ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + base_name = os.path.splitext(scene.filename)[0] + backup_name = f"{base_name}_pre_rollback_{ts}.json" + backup_path = os.path.join(scene.backups_dir, backup_name) + try: + shutil.copy2(current_path, backup_path) + log.debug("rollback_backup_created", backup_path=backup_path) + except Exception as e: + log.error("rollback_backup_failed", error=e, path=current_path) + return None + return backup_path + + +async def fork_scene_at_revision(scene: "Scene", to_rev: int, save_name: str) -> str: + """ + Create a new scene file forked from the given revision. + + Writes the reconstructed scene state to ``.json`` in the + scene's save directory, cleared for independent use (mutable, fresh + memory id). + + Returns: + str: Path to the written fork file + """ + return await write_reconstructed_scene( + scene, + to_rev, + f"{save_name}.json", + overrides={ + "immutable_save": False, + "memory_id": str(uuid.uuid4())[:10], + }, + ) + + async def rollback_scene_to_revision( scene: "Scene", to_rev: int, create_backup: bool = True ) -> str: @@ -957,17 +1088,8 @@ async def rollback_scene_to_revision( current_path = os.path.join(scene.save_dir, scene.filename) backup_path = None - if create_backup and os.path.exists(current_path): - os.makedirs(scene.backups_dir, exist_ok=True) - ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") - base_name = os.path.splitext(scene.filename)[0] - backup_name = f"{base_name}_pre_rollback_{ts}.json" - backup_path = os.path.join(scene.backups_dir, backup_name) - try: - shutil.copy2(current_path, backup_path) - log.debug("rollback_backup_created", backup_path=backup_path) - except Exception as e: - log.error("rollback_backup_failed", error=e, path=current_path) + if create_backup: + backup_path = create_pre_rollback_backup(scene) reconstructed = await reconstruct_scene_data(scene, to_rev=to_rev) with open(current_path, "w") as f: @@ -1002,8 +1124,15 @@ class InMemoryChangelog: async def __aenter__(self): """Initialize the in-memory changelog context.""" - # Store the current state as our baseline - self.last_state = _serialize_scene_plain(self.scene) + # Baseline on the last committed snapshot when one exists, so deltas + # always transform committed state -> new state and the delta chain + # stays consistent with reconstructions. Baselining on the live scene + # state instead would silently drop any mutations made since the last + # commit (e.g. load-time message meta stamps) from the chain, which + # breaks delta value verification during reconstruction. + self.last_state = _load_latest_scene_data(self.scene) + if self.last_state is None: + self.last_state = _serialize_scene_plain(self.scene) self.initial_state = ( self.last_state.copy() ) # Keep initial state for base snapshots diff --git a/src/talemate/character.py b/src/talemate/character.py index f0743ef8..40986ff2 100644 --- a/src/talemate/character.py +++ b/src/talemate/character.py @@ -11,6 +11,7 @@ import talemate.instance as instance import talemate.scene_message as scene_message import talemate.agents.base as agent_base from talemate.agents.tts.schema import Voice +from talemate.agents.visual.schema import PromptFinalizer as VisualPromptFinalizer import talemate.emit.async_signals as async_signals from talemate.game.engine.context_id.character import ( CharacterContext, @@ -46,6 +47,9 @@ class Character(pydantic.BaseModel): avatar: str | None = None # default avatar (used as fallback for messages) current_avatar: str | None = None # current avatar (used to set message.asset_id) visual_rules: str | None = None + visual_finalizers: list[VisualPromptFinalizer] = pydantic.Field( + default_factory=list + ) voice: Voice | None = None # shared context @@ -348,7 +352,7 @@ class Character(pydantic.BaseModel): """ Append a new example dialogue line. """ - text = (example or "").strip() + text = util.replace_smart_quotes(example or "").strip() if not text: return self.example_dialogue.append(text) @@ -359,7 +363,7 @@ class Character(pydantic.BaseModel): """ if index < 0 or index >= len(self.example_dialogue): return - value = (text or "").strip() + value = util.replace_smart_quotes(text or "").strip() if not value: # empty string behaves like delete await self.remove_example_dialogue(index) diff --git a/src/talemate/client/__init__.py b/src/talemate/client/__init__.py index a82ab64a..e4f9c17c 100644 --- a/src/talemate/client/__init__.py +++ b/src/talemate/client/__init__.py @@ -12,6 +12,7 @@ from talemate.client.ollama import OllamaClient # noqa: F401 from talemate.client.openai import OpenAIClient # noqa: F401 from talemate.client.openrouter import OpenRouterClient # noqa: F401 from talemate.client.openai_compat import OpenAICompatibleClient # noqa: F401 +from talemate.client.pi_bridge import PiBridgeClient # noqa: F401 from talemate.client.registry import CLIENT_CLASSES, get_client_class, register # noqa: F401 from talemate.client.tabbyapi import TabbyAPIClient # noqa: F401 from talemate.client.textgenwebui import TextGeneratorWebuiClient # noqa: F401 diff --git a/src/talemate/client/anthropic.py b/src/talemate/client/anthropic.py index dea65dfd..27338f1b 100644 --- a/src/talemate/client/anthropic.py +++ b/src/talemate/client/anthropic.py @@ -116,7 +116,7 @@ class AnthropicClient(ConcurrentInferenceMixin, EndpointOverrideMixin, ClientBas extra_fields: dict[str, ExtraField] = { "thinking_mode": ExtraField( name="thinking_mode", - type="select", + type="text", label="Thinking Mode", choices=["budget", "adaptive"], description="'budget' uses fixed token budget (legacy), 'adaptive' lets the model decide when to think. Adaptive is recommended for Opus 4.6+ and required for Opus 4.7+ (budget mode is ignored on those models).", @@ -130,7 +130,7 @@ class AnthropicClient(ConcurrentInferenceMixin, EndpointOverrideMixin, ClientBas ), "effort_level": ExtraField( name="effort_level", - type="select", + type="text", label="Effort Level", choices=["low", "medium", "high", "xhigh", "max"], description="Controls thinking depth and cost trade-off. Higher effort = better quality but more cost/latency. Only applies with adaptive thinking mode. The 'xhigh' option (between high and max) is supported on Opus 4.7+.", diff --git a/src/talemate/client/api_handles.py b/src/talemate/client/api_handles.py new file mode 100644 index 00000000..fc717a33 --- /dev/null +++ b/src/talemate/client/api_handles.py @@ -0,0 +1,52 @@ +""" +Shared pieces for clients that expose the `api_handles_prompt_template` +config flag, which routes generation through the remote API's own +prompt-template rendering (chat-style) instead of Talemate's local +prompt template. +""" + +import pydantic + +from .base import ExtraField + +__all__ = [ + "ApiHandlesPromptTemplateConfig", + "ApiHandlesPromptTemplateMixin", + "api_handles_prompt_template_extra_fields", +] + + +class ApiHandlesPromptTemplateConfig(pydantic.BaseModel): + api_handles_prompt_template: bool = False + + +def api_handles_prompt_template_extra_fields( + description: str, + label: str = "API handles prompt template (chat/completions)", +) -> dict[str, ExtraField]: + return { + "api_handles_prompt_template": ExtraField( + name="api_handles_prompt_template", + type="bool", + label=label, + required=False, + description=description, + ), + } + + +class ApiHandlesPromptTemplateMixin: + """ + Provides the `api_handles_prompt_template` flag property and bypasses + local prompt-template rendering when the API applies the model's own + template. Mix in before ClientBase. + """ + + @property + def api_handles_prompt_template(self) -> bool: + return self.client_config.api_handles_prompt_template + + def prompt_template(self, system_message: str, prompt: str): + if self.api_handles_prompt_template: + return prompt + return super().prompt_template(system_message, prompt) diff --git a/src/talemate/client/base.py b/src/talemate/client/base.py index 602a8a28..b806de42 100644 --- a/src/talemate/client/base.py +++ b/src/talemate/client/base.py @@ -110,6 +110,9 @@ HTTP_ERROR_MESSAGES = { EMPTY_RESPONSE_MESSAGE = "The model returned an empty response. This can happen due to content filtering, server issues, or a reasoning budget that is too low." +# cap (seconds) for the exponential backoff between automatic rate-limit retries +AUTO_RETRY_MAX_BACKOFF = 30 + def get_error_message(status_code: int | None) -> str: """Get a human-friendly error message for an HTTP status code.""" @@ -126,16 +129,40 @@ def get_error_message(status_code: int | None) -> str: # Maps request_id -> asyncio.Future that resolves to the user's choice. _generation_error_futures: dict[str, asyncio.Future] = {} +GenerationErrorAction = Literal["retry", "cancel", "ignore"] -def resolve_generation_error(request_id: str, action: str): +AutoRetryIssue = Literal["empty_response", "rate_limit", "missing_reasoning"] + +AUTO_RETRY_ISSUE_LABELS: dict[AutoRetryIssue, str] = { + "empty_response": "Empty response", + "rate_limit": "Rate limited", + "missing_reasoning": "Missing reasoning tokens", +} + + +def resolve_generation_error(request_id: str, action: GenerationErrorAction): """Called from the websocket handler when the user responds to a generation error dialog.""" future = _generation_error_futures.get(request_id) if future and not future.done(): future.set_result(action) +def resolve_all_generation_errors(action: GenerationErrorAction): + """Resolve every pending generation error with the given action. + + Used when no user response can arrive anymore (frontend disconnect, + scene unload) so that coroutines waiting on an error dialog don't hang + forever. + """ + for request_id in list(_generation_error_futures): + resolve_generation_error(request_id, action) + + class CommonDefaults(pydantic.BaseModel): rate_limit: int | None = None + retry_empty_response: int = 0 + retry_rate_limit: int = 0 + retry_missing_reasoning: int = 0 data_format: Literal["yaml", "json"] | None = None section_format: Literal["markdown", "xml"] | None = None preset_group: str | None = None @@ -154,22 +181,18 @@ class Defaults(CommonDefaults, pydantic.BaseModel): lock_template: bool = False -class FieldGroup(pydantic.BaseModel): - name: str - label: str - description: str - icon: str = "mdi-cog" +# Backwards-compat alias — the shared UX schema now owns this shape. +FieldGroup = ux_schema.FieldGroup -class ExtraField(pydantic.BaseModel): - name: str - type: str - label: str - required: bool - description: str - group: FieldGroup | None = None - note: ux_schema.Note | None = None - choices: list[str | int | float | bool] | None = None +class ExtraField(ux_schema.Field): + """ + Client setting field — the uniform UX field definition + (talemate.ux.schema.Field), rendered by the frontend through the same + shared component as agent settings. + """ + + pass class ReasoningDisplay(pydantic.BaseModel): @@ -295,6 +318,7 @@ class ClientBase: client_type = "base" request_information: RequestInformation | None = None status_request_timeout: int = 2 + status_failure_threshold: int = 2 rate_limit_counter: CounterRateLimiter = None class Meta(pydantic.BaseModel): @@ -322,6 +346,9 @@ class ClientBase: self.name = name or self.client_type self.remote_model_name = None self.auto_determine_prompt_template_attempt = None + self._status_failures = 0 + self._auto_retry_aborts: set[str] = set() + self._auto_retry_live_ids: set[str] = set() self.log = structlog.get_logger(f"client.{self.client_type}") def __str__(self): @@ -373,6 +400,18 @@ class ClientBase: def rate_limit(self) -> int | None: return self.client_config.rate_limit + @property + def retry_empty_response(self) -> int: + return self.client_config.retry_empty_response + + @property + def retry_rate_limit(self) -> int: + return self.client_config.retry_rate_limit + + @property + def retry_missing_reasoning(self) -> int: + return self.client_config.retry_missing_reasoning + @property def data_format(self) -> Literal["yaml", "json"]: return self.client_config.data_format @@ -821,6 +860,34 @@ class ClientBase: return prompt, coercion return prompt, None + def chat_messages_for_coercion( + self, prompt: str, kind: str + ) -> tuple[list[dict], str | None]: + """ + Assembles chat messages for clients that let the API handle the + prompt template: [system, user] plus an assistant pre-fill message + when the prompt carries a coercion marker. + + Returns the messages and the stripped coercion prompt (None when + the prompt has no coercion). Transport-specific handling of the + pre-fill (e.g. TabbyAPI's `"prefix": True`, text-generation-webui's + `continue_`) stays at the call site. + """ + prompt, coercion_prompt = self.split_prompt_for_coercion(prompt) + if coercion_prompt: + coercion_prompt = coercion_prompt.strip() + + messages = [ + {"role": "system", "content": self.get_system_message(kind)}, + {"role": "user", "content": prompt.strip()}, + ] + + if coercion_prompt: + self.log.debug("Adding coercion pre-fill", coercion_prompt=coercion_prompt) + messages.append({"role": "assistant", "content": coercion_prompt}) + + return messages, coercion_prompt + def rate_limit_update(self): """ Updates the rate limit counter for the client. @@ -1041,6 +1108,9 @@ class ClientBase: "can_be_coerced": self.can_be_coerced, "preset_group": self.preset_group or "", "rate_limit": self.rate_limit, + "retry_empty_response": self.retry_empty_response, + "retry_rate_limit": self.retry_rate_limit, + "retry_missing_reasoning": self.retry_missing_reasoning, "data_format": self.data_format, "section_format": self.section_format, "manual_model_choices": getattr(self.Meta(), "manual_model_choices", []), @@ -1105,6 +1175,13 @@ class ClientBase: except IndexError: return None + def _tolerate_status_failure(self) -> bool: + """Track a failed status check; True while the failure should be + tolerated (established connection, threshold not yet reached) so a + single dropped status check doesn't flap the client to error.""" + self._status_failures += 1 + return self.connected and self._status_failures < self.status_failure_threshold + async def status(self): """ Send a request to the API to retrieve the loaded AI model name. @@ -1126,12 +1203,22 @@ class ClientBase: self.log.debug( "client status error", e=traceback.format_exc(), client=self.name ) + if self._tolerate_status_failure(): + self.log.warning( + "client status error (tolerated)", + e=e, + client=self.name, + failures=self._status_failures, + ) + self.emit_status() + return self.log.warning("client status error", e=e, client=self.name) self.remote_model_name = None self.connected = False self.emit_status() return + self._status_failures = 0 self.connected = True self.emit_status() @@ -1281,12 +1368,13 @@ class ClientBase: pass async def _prompt_generation_error( - self, error_message: str, status_code: int | None = None - ) -> str: + self, + error_message: str, + status_code: int | None = None, + generation_id: str | None = None, + ) -> GenerationErrorAction: """ Emit a generation error to the frontend and wait for the user's choice. - - Returns one of: "retry", "cancel", "ignore" """ request_id = str(uuid.uuid4()) loop = asyncio.get_event_loop() @@ -1304,62 +1392,273 @@ class ClientBase: "model": self.model_name, "status_code": status_code, "error_message": error_message, + "generation_id": generation_id, }, ) - return await future + action = await future + # an explicit dialog choice supersedes an abort clicked before + # the dialog appeared - discard the stale latch so it can't + # override a dialog-retry later in this sequence + if generation_id: + self._auto_retry_aborts.discard(generation_id) + return action finally: _generation_error_futures.pop(request_id, None) + def _emit_auto_retry( + self, + issue: AutoRetryIssue, + attempt: int, + total: int, + generation_id: str, + wait: float = 0, + ): + """ + Notify the frontend that an automatic retry is in progress. + """ + message = AUTO_RETRY_ISSUE_LABELS[issue] + emit( + "auto_retry", + message=message, + websocket_passthrough=True, + data={ + "client": self.name, + "issue": issue, + "message": message, + "attempt": attempt, + "total": total, + "wait": wait, + "generation_id": generation_id, + }, + ) + + def _emit_auto_retry_done(self, generation_id: str): + emit( + "auto_retry_done", + message="", + websocket_passthrough=True, + data={"client": self.name, "generation_id": generation_id}, + ) + + def request_auto_retry_abort(self, generation_id: str): + """ + Latches an abort click from the auto-retry notification, keyed by the + retry sequence it was aimed at - concurrent generations on one client + (FOCAL concurrent callbacks, background chats) must not consume each + other's aborts. Kept on the client because scene.cancel_requested is + unconditionally reset by any plugin-routed websocket action + (Plugin.handle), which would silently drop an abort that lands + mid-attempt or mid-backoff. + """ + if generation_id not in self._auto_retry_live_ids: + # the sequence already ended - a late abort must not latch an + # id nothing will ever observe or clean up + return + self._auto_retry_aborts.add(generation_id) + + def _auto_retry_cancelled(self, generation_id: str) -> bool: + """ + Scene-free generations (help chat, background flows) run with + requires_active_scene unset, so _poll_interrupt never observes an + abort click - the auto-retry machinery checks between attempts + instead. Consumes the abort latch like Scene.continue_actions() so a + latched abort doesn't cancel a later generation's retries. + """ + cancelled = False + if generation_id in self._auto_retry_aborts: + self._auto_retry_aborts.discard(generation_id) + cancelled = True + scene = active_scene.get() + if scene and scene.cancel_requested: + # an active scene's flag is observed non-consumingly by every + # concurrent generation's _poll_interrupt and reset by the + # GenerationCancelled handlers - consuming it here would steal + # the stop from them. Only the inactive placeholder scene + # (scene-free flows) has no other reset path. + if not scene.active: + scene.cancel_requested = False + cancelled = True + return cancelled + + async def _auto_retry_backoff_wait(self, delay: float, generation_id: str) -> bool: + """ + Sleep for `delay` seconds before an automatic rate-limit retry, + aborting early when the generation is cancelled. + + Scene-free generations (e.g. the help chat) run with an inactive + placeholder scene in context, so an inactive scene only counts as a + cancellation when the scene was active at the start of the wait. + + Returns False if aborted. + """ + scene = active_scene.get() + scene_was_active = bool(scene and scene.active) + remaining = delay + while remaining > 0: + if self._auto_retry_cancelled(generation_id): + return False + if scene and scene_was_active and not scene.active: + return False + await asyncio.sleep(min(1, remaining)) + remaining -= 1 + return True + + async def _auto_retry_or_prompt( + self, + issue: AutoRetryIssue, + error_message: str, + auto_retries: dict[str, int], + limit: int, + generation_id: str, + status_code: int | None = None, + ) -> GenerationErrorAction: + """ + Decide how to proceed after a retryable response issue: auto-retry + while the configured budget allows (rate-limit retries back off + exponentially), then fall through to the user's retry/cancel/ignore + dialog. + + Returns "retry" or "ignore". Raises GenerationCancelled when the + generation was aborted or the user cancelled. + """ + if auto_retries[issue] < limit: + if self._auto_retry_cancelled(generation_id): + raise GenerationCancelled("Generation cancelled") + auto_retries[issue] += 1 + wait = ( + min(2 ** auto_retries[issue], AUTO_RETRY_MAX_BACKOFF) + if issue == "rate_limit" + else 0 + ) + self._emit_auto_retry( + issue, auto_retries[issue], limit, generation_id, wait=wait + ) + if wait and not await self._auto_retry_backoff_wait(wait, generation_id): + raise GenerationCancelled("Generation cancelled") + return "retry" + + action = await self._prompt_generation_error( + error_message, status_code=status_code, generation_id=generation_id + ) + if action == "cancel": + raise GenerationCancelled("Generation cancelled by user") + return action + async def _generate_with_error_handling( - self, finalized_prompt: str, prompt_param: dict, kind: str + self, finalized_prompt: str, prompt_param: dict, kind: str, generation_id: str ) -> str: """ Wraps _cancelable_generate in a retry loop. On API errors or empty responses, prompts the user with retry/cancel/ignore options. + Rate limit (429) errors and empty responses are automatically retried + up to the client's configured counts before the user is prompted. + Returns the generation response string. """ - while True: - self.new_request() + auto_retries = {"rate_limit": 0, "empty_response": 0} + try: + while True: + self.new_request() - try: - response = await self._cancelable_generate( - finalized_prompt, prompt_param, kind - ) - except GenerationCancelled: - raise - except Exception as e: - self.log.error("generation error", e=traceback.format_exc()) - status_code = self._extract_status_code(e) - error_message = get_error_message(status_code) - action = await self._prompt_generation_error( - error_message, status_code=status_code - ) - if action == "retry": - continue - elif action == "cancel": - raise GenerationCancelled("Generation cancelled by user") - else: + try: + response = await self._cancelable_generate( + finalized_prompt, prompt_param, kind + ) + except GenerationCancelled: + raise + except Exception as e: + self.log.error("generation error", e=traceback.format_exc()) + status_code = self._extract_status_code(e) + # exceptions may carry a user-presentable message that is more + # accurate than the generic per-status text (e.g. clients whose + # transport reports errors without HTTP status codes) + error_message = getattr( + e, "user_message", None + ) or get_error_message(status_code) + action = await self._auto_retry_or_prompt( + "rate_limit", + error_message, + auto_retries, + self.retry_rate_limit if status_code == 429 else 0, + generation_id, + status_code=status_code, + ) + if action == "retry": + continue # ignore - proceed with empty response return "" - if isinstance(response, GenerationCancelled): - raise response + if isinstance(response, GenerationCancelled): + raise response - # Check for empty response - if not response or not response.strip(): - self.log.warning("empty response from generation") - action = await self._prompt_generation_error( - EMPTY_RESPONSE_MESSAGE, status_code=None + if not response or not response.strip(): + self.log.warning("empty response from generation") + action = await self._auto_retry_or_prompt( + "empty_response", + EMPTY_RESPONSE_MESSAGE, + auto_retries, + self.retry_empty_response, + generation_id, + ) + if action == "retry": + continue + # ignore - proceed with empty response + + return response + finally: + if any(auto_retries.values()): + self._emit_auto_retry_done(generation_id) + + async def _generate_with_reasoning_handling( + self, finalized_prompt: str, prompt_param: dict, kind: str + ) -> str: + """ + Wraps _generate_with_error_handling in a retry loop for responses + missing the expected reasoning pattern. Automatically retries up to + the client's configured count before prompting the user with + retry/cancel/ignore options. + + Stores the stripped reasoning on self._reasoning_response and returns + the generation response string. + """ + # identifies this retry sequence in auto_retry emissions and abort + # requests - concurrent generations on one client each get their own + generation_id = str(uuid.uuid4()) + self._auto_retry_live_ids.add(generation_id) + auto_retries = {"missing_reasoning": 0} + try: + while True: + response = await self._generate_with_error_handling( + finalized_prompt, prompt_param, kind, generation_id ) - if action == "retry": - continue - elif action == "cancel": - raise GenerationCancelled("Generation cancelled by user") - # else: ignore - proceed with empty response - return response + try: + response, reasoning_response = self.strip_reasoning(response) + except ReasoningResponseError as e: + action = await self._auto_retry_or_prompt( + "missing_reasoning", + str(e), + auto_retries, + self.retry_missing_reasoning, + generation_id, + ) + if action == "retry": + continue + # ignore - proceed with raw response + reasoning_response = None + + if reasoning_response: + self._reasoning_response = reasoning_response + return response + finally: + # an abort latched but never observed (e.g. the aborted attempt + # succeeded) must not linger once its sequence is over + self._auto_retry_live_ids.discard(generation_id) + self._auto_retry_aborts.discard(generation_id) + if auto_retries["missing_reasoning"]: + self._emit_auto_retry_done(generation_id) def _extract_status_code(self, exception: Exception) -> int | None: """ @@ -1673,28 +1972,9 @@ class ClientBase: "\n<|RESPONSE_LENGTH_INSTRUCTIONS|>", "" ) - while True: - response = await self._generate_with_error_handling( - finalized_prompt, prompt_param, kind - ) - - try: - response, reasoning_response = self.strip_reasoning(response) - except ReasoningResponseError as e: - action = await self._prompt_generation_error( - str(e), status_code=None - ) - if action == "retry": - continue - elif action == "cancel": - raise GenerationCancelled("Generation cancelled by user") - else: - # ignore - proceed with raw response - reasoning_response = None - - if reasoning_response: - self._reasoning_response = reasoning_response - break + response = await self._generate_with_reasoning_handling( + finalized_prompt, prompt_param, kind + ) if coercion_prompt: response = self.process_response_for_indirect_coercion( @@ -1704,12 +1984,7 @@ class ClientBase: self.end_request() if REPLACE_SMART_QUOTES: - response = ( - response.replace("“", '"') - .replace("”", '"') - .replace("‘", "'") - .replace("’", "'") - ) + response = util.replace_smart_quotes(response) time_end = time.time() diff --git a/src/talemate/client/llamacpp.py b/src/talemate/client/llamacpp.py index fbf6af80..203c3724 100644 --- a/src/talemate/client/llamacpp.py +++ b/src/talemate/client/llamacpp.py @@ -1,10 +1,14 @@ import json import pydantic -import structlog import httpx from openai import AsyncOpenAI +from talemate.client.api_handles import ( + ApiHandlesPromptTemplateConfig, + ApiHandlesPromptTemplateMixin, + api_handles_prompt_template_extra_fields, +) from talemate.client.base import ( STOPPING_STRINGS, ClientBase, @@ -21,21 +25,28 @@ from talemate.client.vision import VisionConfig, vision_extra_fields, OpenAIVisi from talemate.config.schema import Client as BaseClientConfig from talemate.exceptions import GenerationProcessingError -log = structlog.get_logger("talemate.client.llamacpp") +APPLY_TEMPLATE_TIMEOUT = 30 -class Defaults(CommonDefaults, pydantic.BaseModel): +class Defaults(CommonDefaults, ApiHandlesPromptTemplateConfig): # llama.cpp `llama-server` defaults to port 8080 (see ggml-org/llama.cpp README) api_url: str = "http://localhost:8080" max_token_length: int = 8192 -class ClientConfig(ConcurrentInference, VisionConfig, BaseClientConfig): +class ClientConfig( + ConcurrentInference, ApiHandlesPromptTemplateConfig, VisionConfig, BaseClientConfig +): pass @register() -class LlamaCppClient(ConcurrentInferenceMixin, OpenAIVisionMixin, ClientBase): +class LlamaCppClient( + ApiHandlesPromptTemplateMixin, + ConcurrentInferenceMixin, + OpenAIVisionMixin, + ClientBase, +): """ Client for ggml-org/llama.cpp `llama-server`. @@ -59,6 +70,10 @@ class LlamaCppClient(ConcurrentInferenceMixin, OpenAIVisionMixin, ClientBase): self_hosted: bool = True extra_fields: dict = pydantic.Field( default_factory=lambda: { + **api_handles_prompt_template_extra_fields( + label="API handles prompt template", + description="The prompt template is rendered by llama.cpp using the model's built-in chat template, and the prompt template selection below is ignored. Response pre-filling keeps working. Keep this disabled for full control of the prompt template in Talemate; enable it to trust that the template on the remote end is correct.", + ), **vision_extra_fields(), **concurrent_inference_extra_fields(), } @@ -117,10 +132,13 @@ class LlamaCppClient(ConcurrentInferenceMixin, OpenAIVisionMixin, ClientBase): headers["Authorization"] = f"Bearer {self.api_key}" return headers + def _base_url(self) -> str: + return self.api_url.strip().rstrip("/") + def _base_url_v1(self) -> str | None: if not self.api_url: return None - base = self.api_url.strip().rstrip("/") + base = self._base_url() if base.endswith("/v1"): return base return base + "/v1" @@ -148,6 +166,34 @@ class LlamaCppClient(ConcurrentInferenceMixin, OpenAIVisionMixin, ClientBase): return model_name + async def apply_remote_template(self, prompt: str, kind: str) -> str: + """ + Renders the prompt through llama.cpp's POST /apply-template endpoint, + which applies the model's built-in chat template server-side and + leaves the final assistant turn open when a prefill (coercion) + message is included. + """ + + messages, _ = self.chat_messages_for_coercion(prompt, kind) + + payload = {"messages": messages} + if not self.reason_enabled: + # best-effort: ask thinking-capable templates (Qwen, GLM, ...) + # not to open a think block when reasoning is disabled + payload["chat_template_kwargs"] = {"enable_thinking": False} + + async with httpx.AsyncClient(timeout=APPLY_TEMPLATE_TIMEOUT) as http: + response = await http.post( + f"{self._base_url()}/apply-template", + json=payload, + headers=self.request_headers, + ) + if response.status_code >= 400: + raise GenerationProcessingError( + f"llama.cpp API error ({response.status_code}) while applying prompt template" + ) + return response.json()["prompt"] + async def generate(self, prompt: str, parameters: dict, kind: str): """ Generate text using llama.cpp's POST /completion endpoint in streaming @@ -155,6 +201,9 @@ class LlamaCppClient(ConcurrentInferenceMixin, OpenAIVisionMixin, ClientBase): `update_request_tokens`. """ + if self.api_handles_prompt_template: + prompt = await self.apply_remote_template(prompt, kind) + self.log.debug( "generate", prompt=prompt[:128] + " ...", @@ -180,8 +229,7 @@ class LlamaCppClient(ConcurrentInferenceMixin, OpenAIVisionMixin, ClientBase): try: response = "" - base = self.api_url.strip().rstrip("/") - url = f"{base}/completion" + url = f"{self._base_url()}/completion" async with httpx.AsyncClient(timeout=None) as http: async with http.stream( diff --git a/src/talemate/client/mistral.py b/src/talemate/client/mistral.py index e246b891..8e2baf81 100644 --- a/src/talemate/client/mistral.py +++ b/src/talemate/client/mistral.py @@ -14,6 +14,9 @@ from talemate.client.remote import ( EndpointOverride, EndpointOverrideMixin, endpoint_override_extra_fields, + ConcurrentInferenceMixin, + ConcurrentInference, + concurrent_inference_extra_fields, ) from talemate.config.schema import Client as BaseClientConfig from talemate.emit import emit @@ -41,12 +44,12 @@ class Defaults(EndpointOverride, CommonDefaults, pydantic.BaseModel): model: str = "open-mixtral-8x22b" -class ClientConfig(EndpointOverride, BaseClientConfig): +class ClientConfig(ConcurrentInference, EndpointOverride, BaseClientConfig): pass @register() -class MistralAIClient(EndpointOverrideMixin, ClientBase): +class MistralAIClient(ConcurrentInferenceMixin, EndpointOverrideMixin, ClientBase): """ OpenAI client for generating text. """ @@ -65,6 +68,7 @@ class MistralAIClient(EndpointOverrideMixin, ClientBase): requires_prompt_template: bool = False defaults: Defaults = Defaults() extra_fields: dict[str, ExtraField] = endpoint_override_extra_fields() + extra_fields.update(concurrent_inference_extra_fields()) unified_api_key_config_path: str = "mistralai.api_key" @property diff --git a/src/talemate/client/ollama.py b/src/talemate/client/ollama.py index f629a7a8..51776e9d 100644 --- a/src/talemate/client/ollama.py +++ b/src/talemate/client/ollama.py @@ -3,6 +3,11 @@ import httpx import ollama import time +from talemate.client.api_handles import ( + ApiHandlesPromptTemplateConfig, + ApiHandlesPromptTemplateMixin, + api_handles_prompt_template_extra_fields, +) from talemate.client.base import ( STOPPING_STRINGS, ClientBase, @@ -19,18 +24,17 @@ log = structlog.get_logger("talemate.client.ollama") FETCH_MODELS_INTERVAL = 15 -class OllamaClientDefaults(CommonDefaults): +class OllamaClientDefaults(CommonDefaults, ApiHandlesPromptTemplateConfig): api_url: str = "http://localhost:11434" # Default Ollama URL model: str = "" # Allow empty default, will fetch from Ollama - api_handles_prompt_template: bool = False -class ClientConfig(BaseClientConfig): - api_handles_prompt_template: bool = False +class ClientConfig(ApiHandlesPromptTemplateConfig, BaseClientConfig): + pass @register() -class OllamaClient(ClientBase): +class OllamaClient(ApiHandlesPromptTemplateMixin, ClientBase): """ Ollama client for generating text using locally hosted models. """ @@ -48,15 +52,10 @@ class OllamaClient(ClientBase): manual_model_choices: list[str] = [] # Will be overridden by finalize_status defaults: OllamaClientDefaults = OllamaClientDefaults() self_hosted: bool = True - extra_fields: dict[str, ExtraField] = { - "api_handles_prompt_template": ExtraField( - name="api_handles_prompt_template", - type="bool", - label="API handles prompt template", - required=False, - description="Let Ollama handle the prompt template. Only do this if you don't know which prompt template to use. Letting talemate handle the prompt template will generally lead to improved responses.", - ), - } + extra_fields: dict[str, ExtraField] = api_handles_prompt_template_extra_fields( + label="API handles prompt template", + description="Let Ollama handle the prompt template. Only do this if you don't know which prompt template to use. Letting talemate handle the prompt template will generally lead to improved responses.", + ) @property def supported_parameters(self): @@ -97,10 +96,6 @@ class OllamaClient(ClientBase): """ return not self.api_handles_prompt_template and not self.reason_enabled - @property - def api_handles_prompt_template(self) -> bool: - return self.client_config.api_handles_prompt_template - async def status(self): """ Send a request to the API to retrieve the loaded AI model name. @@ -128,6 +123,14 @@ class OllamaClient(ClientBase): # if the server is running, fetch the available models await self.fetch_available_models() except Exception as e: + if self._tolerate_status_failure(): + log.warning( + "Failed to fetch models from Ollama (tolerated)", + error=str(e), + failures=self._status_failures, + ) + self.emit_status() + return log.error("Failed to fetch models from Ollama", error=str(e)) self.connected = False self.emit_status() @@ -161,11 +164,6 @@ class OllamaClient(ClientBase): async def get_model_name(self): return self.model - def prompt_template(self, system_message: str, prompt: str): - if not self.api_handles_prompt_template: - return super().prompt_template(system_message, prompt) - return prompt - def tune_prompt_parameters(self, parameters: dict, kind: str): """ Tune parameters for Ollama's generate endpoint. diff --git a/src/talemate/client/openai_compat.py b/src/talemate/client/openai_compat.py index 717bc3bf..4cc21d1b 100644 --- a/src/talemate/client/openai_compat.py +++ b/src/talemate/client/openai_compat.py @@ -1,15 +1,21 @@ import random -import pydantic -import structlog from openai import AsyncOpenAI -from talemate.client.base import ClientBase, ExtraField, FieldGroup +from talemate.client.api_handles import ( + ApiHandlesPromptTemplateConfig, + ApiHandlesPromptTemplateMixin, + api_handles_prompt_template_extra_fields, +) +from talemate.client.base import ClientBase, ExtraField from talemate.client.registry import register +from talemate.client.toggleable_parameters import ( + ToggleableParametersMixin, + toggleable_parameters_config, + toggleable_parameters_extra_fields, +) from talemate.config.schema import Client as BaseClientConfig -log = structlog.get_logger("talemate.client.openai_compat") - EXPERIMENTAL_DESCRIPTION = """Use this client if you want to connect to a service implementing an OpenAI-compatible API. Success is going to depend on the level of compatibility. Use the actual OpenAI client if you want to connect to OpenAI's API.""" # Sampler parameters whose inclusion in the request payload is user-toggleable. @@ -17,58 +23,32 @@ EXPERIMENTAL_DESCRIPTION = """Use this client if you want to connect to a servic # so they need to be omitted entirely (not just zeroed out). TOGGLEABLE_PARAMETERS = ("temperature", "top_p", "presence_penalty") - -PARAMETERS_FIELD_GROUP = FieldGroup( - name="parameters", - label="Parameters", - description=( - "Toggle individual sampler parameters. When a parameter is disabled it is " - "omitted from the request payload entirely. Useful for APIs that hard-error " - "when receiving parameters they don't support for the selected model." - ), - icon="mdi-tune-vertical", -) +ToggleableParametersConfig = toggleable_parameters_config(TOGGLEABLE_PARAMETERS) -def _send_parameter_field(param: str) -> ExtraField: - return ExtraField( - name=f"send_{param}", - type="bool", - label=f"Send {param}", - required=False, - description=( - f"When enabled, `{param}` is included in the request. Disable for " - f"models/APIs that reject it." - ), - group=PARAMETERS_FIELD_GROUP, - ) - - -class Defaults(pydantic.BaseModel): +class Defaults(ToggleableParametersConfig, ApiHandlesPromptTemplateConfig): api_url: str = "http://localhost:5000" api_key: str = "" max_token_length: int = 8192 model: str = "" - api_handles_prompt_template: bool = False double_coercion: str = None rate_limit: int | None = None - send_temperature: bool = True - send_top_p: bool = True - send_presence_penalty: bool = True -class ClientConfig(BaseClientConfig): - api_handles_prompt_template: bool = False - send_temperature: bool = True - send_top_p: bool = True - send_presence_penalty: bool = True +class ClientConfig( + ToggleableParametersConfig, ApiHandlesPromptTemplateConfig, BaseClientConfig +): + pass @register() -class OpenAICompatibleClient(ClientBase): +class OpenAICompatibleClient( + ApiHandlesPromptTemplateMixin, ToggleableParametersMixin, ClientBase +): client_type = "openai_compat" conversation_retries = 0 config_cls = ClientConfig + toggleable_parameters = TOGGLEABLE_PARAMETERS class Meta(ClientBase.Meta): title: str = "OpenAI Compatible API" @@ -79,32 +59,12 @@ class OpenAICompatibleClient(ClientBase): defaults: Defaults = Defaults() self_hosted: bool | None = None extra_fields: dict[str, ExtraField] = { - "api_handles_prompt_template": ExtraField( - name="api_handles_prompt_template", - type="bool", - label="API handles prompt template (chat/completions)", - required=False, + **api_handles_prompt_template_extra_fields( description="The API handles the prompt template, meaning your choice in the UI for the prompt template below will be ignored. This is not recommended and should only be used if the API does not support the `completions` andpoint or you don't know which prompt template to use.", ), - **{f"send_{p}": _send_parameter_field(p) for p in TOGGLEABLE_PARAMETERS}, + **toggleable_parameters_extra_fields(TOGGLEABLE_PARAMETERS), } - @property - def api_handles_prompt_template(self) -> bool: - return self.client_config.api_handles_prompt_template - - @property - def send_temperature(self) -> bool: - return self.client_config.send_temperature - - @property - def send_top_p(self) -> bool: - return self.client_config.send_top_p - - @property - def send_presence_penalty(self) -> bool: - return self.client_config.send_presence_penalty - @property def experimental(self): return EXPERIMENTAL_DESCRIPTION @@ -117,19 +77,6 @@ class OpenAICompatibleClient(ClientBase): """ return not self.reason_enabled - @property - def supported_parameters(self): - params = ["max_tokens"] - for param in TOGGLEABLE_PARAMETERS: - if getattr(self.client_config, f"send_{param}"): - params.append(param) - return params - - def prompt_template(self, system_message: str, prompt: str): - if not self.api_handles_prompt_template: - return super().prompt_template(system_message, prompt) - return prompt - async def get_model_name(self): return self.model @@ -149,25 +96,11 @@ class OpenAICompatibleClient(ClientBase): parameters=parameters, ) - if self.can_be_coerced: - prompt, coercion_prompt = self.split_prompt_for_coercion(prompt) - else: - coercion_prompt = None - - messages = [ - {"role": "system", "content": self.get_system_message(kind)}, - {"role": "user", "content": prompt.strip()}, - ] + messages, coercion_prompt = self.chat_messages_for_coercion(prompt, kind) if coercion_prompt: - log.debug("Adding coercion pre-fill", coercion_prompt=coercion_prompt) - messages.append( - { - "role": "assistant", - "content": coercion_prompt.strip(), - "prefix": True, - } - ) + # continue the pre-fill via the (non-standard) prefix flag + messages[-1]["prefix"] = True response = await client.chat.completions.create( model=self.model_name, diff --git a/src/talemate/client/openrouter.py b/src/talemate/client/openrouter.py index 99f5bb27..6febe8b4 100644 --- a/src/talemate/client/openrouter.py +++ b/src/talemate/client/openrouter.py @@ -3,6 +3,7 @@ import structlog import httpx import asyncio import json +import time from talemate.client.base import ( ClientBase, @@ -20,6 +21,11 @@ from talemate.config.schema import Client as BaseClientConfig from talemate.config import get_config from talemate.client.registry import register +from talemate.client.toggleable_parameters import ( + ToggleableParametersMixin, + toggleable_parameters_config, + toggleable_parameters_extra_fields, +) from talemate.emit import emit from talemate.emit.signals import handlers import talemate.emit.async_signals as async_signals @@ -46,17 +52,43 @@ AVAILABLE_MODELS = [] # Available providers will be populated dynamically from OpenRouter API once a valid API key is set AVAILABLE_PROVIDERS = [] -DEFAULT_MODEL = "google/gemini-3-flash-preview" +DEFAULT_MODEL = "google/gemini-3.6-flash" + +# Sampler parameters whose inclusion in the request payload is user-toggleable. +# Some OpenRouter providers hard-error when these are sent for certain models +# (e.g. Moonshot rejects non-zero penalties for kimi models), so they need to +# be omitted entirely (not just zeroed out). +TOGGLEABLE_PARAMETERS = ( + "temperature", + "top_p", + "top_k", + "min_p", + "frequency_penalty", + "presence_penalty", + "repetition_penalty", +) + +ToggleableParametersConfig = toggleable_parameters_config(TOGGLEABLE_PARAMETERS) + MODELS_FETCHED = False PROVIDERS_FETCHED = False _MODELS_LOCK = asyncio.Lock() _PROVIDERS_LOCK = asyncio.Lock() +# Failed fetches stay unlatched so the retry triggers (config saves, client +# status refreshes) can recover; the cooldown keeps them from hammering the +# API while it is unreachable. +FETCH_RETRY_COOLDOWN = 30.0 +# None = never attempted; time.monotonic() can be < FETCH_RETRY_COOLDOWN on a +# freshly-booted host, so a 0.0 sentinel would wrongly cool down the first try. +_models_last_attempt: float | None = None +_providers_last_attempt: float | None = None + async def fetch_available_models(api_key: str = None): """Fetch available models from OpenRouter API""" - global AVAILABLE_MODELS, DEFAULT_MODEL, MODELS_FETCHED + global AVAILABLE_MODELS, MODELS_FETCHED, _models_last_attempt if MODELS_FETCHED: return AVAILABLE_MODELS @@ -65,6 +97,14 @@ async def fetch_available_models(api_key: str = None): if MODELS_FETCHED: return AVAILABLE_MODELS + now = time.monotonic() + if ( + _models_last_attempt is not None + and now - _models_last_attempt < FETCH_RETRY_COOLDOWN + ): + return AVAILABLE_MODELS + _models_last_attempt = now + try: log.debug("Fetching models from OpenRouter") async with httpx.AsyncClient() as client: @@ -79,6 +119,7 @@ async def fetch_available_models(api_key: str = None): if model_id: models.append(model_id) AVAILABLE_MODELS = sorted(models) + MODELS_FETCHED = True log.debug(f"Fetched {len(AVAILABLE_MODELS)} models from OpenRouter") else: log.warning( @@ -87,13 +128,12 @@ async def fetch_available_models(api_key: str = None): except Exception as e: log.error(f"Error fetching models from OpenRouter: {e}") - MODELS_FETCHED = True return AVAILABLE_MODELS async def fetch_available_providers(api_key: str = None): """Fetch available providers from OpenRouter API""" - global AVAILABLE_PROVIDERS, PROVIDERS_FETCHED + global AVAILABLE_PROVIDERS, PROVIDERS_FETCHED, _providers_last_attempt if PROVIDERS_FETCHED: return AVAILABLE_PROVIDERS @@ -106,10 +146,19 @@ async def fetch_available_providers(api_key: str = None): api_key = get_config().openrouter.api_key if not api_key: + # No key yet (e.g. initial setup) — stay unlatched so the fetch + # reruns once a key is saved. log.warning("No OpenRouter API key available, cannot fetch providers") - PROVIDERS_FETCHED = True return AVAILABLE_PROVIDERS + now = time.monotonic() + if ( + _providers_last_attempt is not None + and now - _providers_last_attempt < FETCH_RETRY_COOLDOWN + ): + return AVAILABLE_PROVIDERS + _providers_last_attempt = now + try: log.debug("Fetching providers from OpenRouter") async with httpx.AsyncClient() as client: @@ -126,6 +175,7 @@ async def fetch_available_providers(api_key: str = None): if provider_name: providers.append(provider_name) AVAILABLE_PROVIDERS = sorted(providers) + PROVIDERS_FETCHED = True log.info( f"Fetched {len(AVAILABLE_PROVIDERS)} providers from OpenRouter" ) @@ -136,7 +186,6 @@ async def fetch_available_providers(api_key: str = None): except Exception as e: log.error(f"Error fetching providers from OpenRouter: {e}") - PROVIDERS_FETCHED = True return AVAILABLE_PROVIDERS @@ -158,14 +207,19 @@ handlers["talemate_started"].connect(on_talemate_started) async_signals.get("config.saved").connect(on_config_saved) -class Defaults(CommonDefaults, pydantic.BaseModel): +class Defaults(ToggleableParametersConfig, CommonDefaults, pydantic.BaseModel): max_token_length: int = 16384 model: str = DEFAULT_MODEL + # Reasoning on by default: with it off the client is coercible, and several + # providers (Google, Anthropic) reject the coercion prefill outright since + # the request then ends on a model turn. + reason_enabled: bool = True + reason_tokens: int = 2048 provider_only: list[str] = pydantic.Field(default_factory=list) provider_ignore: list[str] = pydantic.Field(default_factory=list) -class ClientConfig(ConcurrentInference, BaseClientConfig): +class ClientConfig(ToggleableParametersConfig, ConcurrentInference, BaseClientConfig): provider_only: list[str] = pydantic.Field(default_factory=list) provider_ignore: list[str] = pydantic.Field(default_factory=list) @@ -198,7 +252,7 @@ def cache_control_for_model(model_name: str) -> dict | None: @register() -class OpenRouterClient(ConcurrentInferenceMixin, ClientBase): +class OpenRouterClient(ConcurrentInferenceMixin, ToggleableParametersMixin, ClientBase): """ OpenRouter client for generating text using various models. """ @@ -208,6 +262,7 @@ class OpenRouterClient(ConcurrentInferenceMixin, ClientBase): # TODO: make this configurable? decensor_enabled = False config_cls = ClientConfig + toggleable_parameters = TOGGLEABLE_PARAMETERS class Meta(ClientBase.Meta): name_prefix: str = "OpenRouter" @@ -243,6 +298,7 @@ class OpenRouterClient(ConcurrentInferenceMixin, ClientBase): required=False, ), } + fields.update(toggleable_parameters_extra_fields(TOGGLEABLE_PARAMETERS)) fields.update(concurrent_inference_extra_fields()) return fields @@ -250,10 +306,6 @@ class OpenRouterClient(ConcurrentInferenceMixin, ClientBase): default_factory=_build_extra_fields ) - def __init__(self, **kwargs): - self._models_fetched = False - super().__init__(**kwargs) - @property def provider_only(self) -> list[str]: return self.client_config.provider_only @@ -278,19 +330,6 @@ class OpenRouterClient(ConcurrentInferenceMixin, ClientBase): def min_reason_tokens(self) -> int: return MIN_THINKING_TOKENS - @property - def supported_parameters(self): - return [ - "temperature", - "top_p", - "top_k", - "min_p", - "frequency_penalty", - "presence_penalty", - "repetition_penalty", - "max_tokens", - ] - def emit_status(self, processing: bool = None): error_action = None error_message = None @@ -336,13 +375,12 @@ class OpenRouterClient(ConcurrentInferenceMixin, ClientBase): ) async def status(self): - # Fetch models and providers if we have an API key and haven't fetched yet - if not self._models_fetched: - self._models_fetched = True - # Update the Meta class with new model choices - self.Meta.manual_model_choices = AVAILABLE_MODELS + # Retry fetches that haven't succeeded yet (e.g. the initial fetch + # failed, or ran before an API key was configured) — the fetchers + # latch on success and rate-limit retries internally. + if not MODELS_FETCHED: + await fetch_available_models(self.openrouter_api_key) - # Fetch providers if not already fetched if not PROVIDERS_FETCHED and self.openrouter_api_key: await fetch_available_providers(self.openrouter_api_key) diff --git a/src/talemate/client/pi_bridge.py b/src/talemate/client/pi_bridge.py new file mode 100644 index 00000000..cd272ceb --- /dev/null +++ b/src/talemate/client/pi_bridge.py @@ -0,0 +1,763 @@ +import asyncio +import json +import os +import re +import shutil +import sys +import tempfile +import time +from typing import Literal + +import pydantic +import structlog + +from talemate.client.base import ( + ClientBase, + CommonDefaults, + ExtraField, + FieldGroup, + ReasoningDisplay, +) +from talemate.client.registry import register +from talemate.client.remote import ( + ConcurrentInference, + ConcurrentInferenceMixin, + concurrent_inference_extra_fields, +) +from talemate.config.schema import Client as BaseClientConfig +from talemate.config import get_config +from talemate.emit import emit +from talemate.emit.signals import handlers +import talemate.emit.async_signals as async_signals + +__all__ = [ + "PiBridgeClient", +] + +log = structlog.get_logger("talemate.client.pi_bridge") + +PI_BINARY = "pi" + +# Set TALEMATE_PI_BRIDGE_TRACE=1 to log the pi event stream as it is consumed +# (per-event sizes/types plus a silence heartbeat). Diagnostic aid for stream +# stalls that only reproduce inside talemate (issue #126). +PI_BRIDGE_TRACE = os.environ.get("TALEMATE_PI_BRIDGE_TRACE", "") == "1" + +# pi event lines carry entire messages (message_end includes the full +# accumulated thinking block, agent_end the whole conversation), which +# easily exceeds asyncio's 64KB default StreamReader line limit and would +# fail readline() with a LimitOverrunError mid-generation. +PI_STDOUT_LIMIT = 2**26 + +DEFAULT_PROVIDER = "openrouter" + +# how long pi gets to exit on its own after stdin closes before the process +# tree is force-killed +PI_SHUTDOWN_GRACE = 3.0 + +# cap on waiting for the stderr drain during cleanup, in case something +# still holds the pipe open +PI_STDERR_DRAIN_TIMEOUT = 5.0 + +THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] + +# provider -> [model ids], populated from `pi --list-models` (which includes +# custom providers/models defined in pi's models.json) +AVAILABLE_MODELS: dict[str, list[str]] = {} + +MODELS_FETCHED = False + +_MODELS_LOCK = asyncio.Lock() + +# Failed fetches stay unlatched so the retry triggers (client status +# refreshes) can recover; the cooldown keeps them from re-spawning pi +# while it keeps failing. +FETCH_RETRY_COOLDOWN = 30.0 +_models_last_attempt: float | None = None + + +async def kill_pi_process_tree(proc: asyncio.subprocess.Process) -> None: + """Force-kill pi and any children it spawned. + + On Windows the spawned process is npm's pi.cmd shim (cmd.exe); killing it + directly orphans the node process underneath, which keeps the stdio pipes + open forever (issue #126). taskkill /T takes down the whole tree. + """ + try: + if sys.platform == "win32": + try: + killer = await asyncio.create_subprocess_exec( + "taskkill", + "/F", + "/T", + "/PID", + str(proc.pid), + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await killer.wait() + except OSError as e: + # degrade loudly - the proc.kill() below only kills the + # shim, so a failed taskkill means the node tree survives + log.warning("taskkill failed", pid=proc.pid, error=str(e)) + finally: + # runs even if cancellation lands on the taskkill awaits above + try: + proc.kill() + except ProcessLookupError: + pass + except OSError as e: + log.warning("failed to kill pi process", pid=proc.pid, error=str(e)) + + +async def terminate_pi_process(proc: asyncio.subprocess.Process) -> None: + """End the pi subprocess without orphaning grandchildren. + + Closing stdin asks pi's RPC mode to shut down (it exits on stdin EOF), + which ends the actual node process even when spawned through the pi.cmd + shim on Windows; the tree kill is the fallback for a process that will + not exit on its own. + """ + if proc.returncode is None: + proc.stdin.close() + # the kill fallbacks in the except branches below are shielded so a + # further cancellation cannot interrupt them mid-way - the detached + # kill still runs to completion (at the cost of the trailing reap + # warning never firing on that path) + try: + await asyncio.wait_for(proc.wait(), timeout=PI_SHUTDOWN_GRACE) + except asyncio.TimeoutError: + await asyncio.shield(kill_pi_process_tree(proc)) + except asyncio.CancelledError: + # a repeat cancellation while parked in the grace wait must not + # skip the kill, or the pi/node tree leaks with a live request + await asyncio.shield(kill_pi_process_tree(proc)) + raise + try: + await asyncio.wait_for(proc.wait(), timeout=PI_SHUTDOWN_GRACE) + except asyncio.TimeoutError: + log.warning("pi process did not exit after kill", pid=proc.pid) + + +def resolve_pi_binary() -> str | None: + """Absolute path to the pi executable, or None when pi is not installed. + + Spawns must use the resolved path: on Windows shutil.which finds npm's + pi.cmd shim via PATHEXT, but CreateProcess only looks for pi.exe when + given the bare name — spawning "pi" fails with WinError 2 even though + the availability check passed. + """ + return shutil.which(PI_BINARY) + + +def pi_subprocess_env() -> dict: + """Environment for pi subprocesses: talemate-managed values layered over + the process environment. The configured openrouter API key doubles as + OPENROUTER_API_KEY (so a key set up in talemate works without container + env setup), and the config env variable store is applied last so explicit + entries always win. Used for generations and catalog fetches alike, so + models.json providers gated on $VAR references resolve consistently. + """ + config = get_config() + env = os.environ.copy() + if config.openrouter.api_key: + env["OPENROUTER_API_KEY"] = config.openrouter.api_key + env.update(config.env) + return env + + +def models_for_provider(provider: str) -> list[str]: + return AVAILABLE_MODELS.get(provider, []) + + +def available_providers() -> list[str]: + return sorted(AVAILABLE_MODELS.keys()) + + +async def fetch_available_models(): + """Fetch the model catalog from pi via `pi --list-models`. + + Runs offline (no catalog refresh) so it stays fast and deterministic; + custom providers/models from pi's models.json are included. + """ + global AVAILABLE_MODELS, MODELS_FETCHED, _models_last_attempt + + if MODELS_FETCHED: + return AVAILABLE_MODELS + + async with _MODELS_LOCK: + if MODELS_FETCHED: + return AVAILABLE_MODELS + + now = time.monotonic() + if ( + _models_last_attempt is not None + and now - _models_last_attempt < FETCH_RETRY_COOLDOWN + ): + return AVAILABLE_MODELS + _models_last_attempt = now + + pi_path = resolve_pi_binary() + if not pi_path: + # pi is optional — stay quiet until the user actually sets up a + # pi_bridge client, whose status reports the missing binary + log.debug("pi binary not found, skipping model fetch") + return AVAILABLE_MODELS + + try: + proc = await asyncio.create_subprocess_exec( + pi_path, + "--list-models", + "--offline", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=pi_subprocess_env(), + ) + stdout, stderr = await proc.communicate() + if proc.returncode != 0: + log.error( + "pi --list-models failed", + returncode=proc.returncode, + stderr=stderr.decode(errors="replace")[:500], + ) + return AVAILABLE_MODELS + + AVAILABLE_MODELS = parse_model_list(stdout.decode(errors="replace")) + MODELS_FETCHED = True + log.debug( + "fetched models from pi", + providers=len(AVAILABLE_MODELS), + models=sum(len(models) for models in AVAILABLE_MODELS.values()), + ) + except FileNotFoundError as e: + # WinError 2 / ENOENT don't say which file — name the executable + log.error( + "error fetching models from pi - could not execute the pi binary", + path=pi_path, + error=str(e), + ) + except Exception as e: + log.error("error fetching models from pi", path=pi_path, error=str(e)) + + return AVAILABLE_MODELS + + +def parse_model_list(output: str) -> dict[str, list[str]]: + """Parse `pi --list-models` table output into {provider: [model ids]}. + + The first line is a header; data rows are whitespace-separated with + provider and model id in the first two columns. + """ + models: dict[str, list[str]] = {} + lines = output.splitlines() + for line in lines[1:]: + parts = line.split() + if len(parts) < 2: + continue + provider, model_id = parts[0], parts[1] + models.setdefault(provider, []).append(model_id) + return {provider: sorted(ids) for provider, ids in models.items()} + + +def on_talemate_started(event): + """Spawn a background task to fetch the pi model catalog.""" + loop = asyncio.get_event_loop() + loop.create_task(fetch_available_models()) + + +async def on_config_saved(config): + """Refresh the catalog so pi-side changes (a new models.json provider, + newly configured credentials) show up after saving settings, without + restarting the server.""" + global MODELS_FETCHED, _models_last_attempt + MODELS_FETCHED = False + _models_last_attempt = None + await fetch_available_models() + + +handlers["talemate_started"].connect(on_talemate_started) +async_signals.get("config.saved").connect(on_config_saved) + + +class PiBridgeTrace: + """Per-generation stream statistics logger for TALEMATE_PI_BRIDGE_TRACE.""" + + HEARTBEAT_INTERVAL = 5.0 + # log every Nth delta event; every one would flood the log at + # generation speed while the interesting ones are the non-deltas + DELTA_LOG_EVERY = 25 + + def __init__(self, logger): + self.log = logger + self.started = time.monotonic() + self.lines = 0 + self.bytes = 0 + self.deltas = 0 + self.last_type = "(none)" + self.last_line_at = self.started + + def elapsed(self) -> float: + return round(time.monotonic() - self.started, 2) + + def line(self, raw_line: bytes, event_type: str): + self.lines += 1 + self.bytes += len(raw_line) + self.last_line_at = time.monotonic() + is_delta = event_type.startswith("message_update") + if is_delta: + self.deltas += 1 + if not is_delta or self.deltas % self.DELTA_LOG_EVERY == 1: + self.log.info( + "pi_bridge trace: event", + elapsed=self.elapsed(), + line=self.lines, + size=len(raw_line), + type=event_type, + total_bytes=self.bytes, + deltas=self.deltas, + ) + self.last_type = event_type + + async def heartbeat(self, proc): + while True: + await asyncio.sleep(self.HEARTBEAT_INTERVAL) + quiet = time.monotonic() - self.last_line_at + if quiet < self.HEARTBEAT_INTERVAL: + continue + self.log.warning( + "pi_bridge trace: no output", + elapsed=self.elapsed(), + quiet_seconds=round(quiet, 1), + lines=self.lines, + total_bytes=self.bytes, + deltas=self.deltas, + last_event=self.last_type, + pi_alive=proc.returncode is None, + ) + + +REASONING_FIELD_GROUP = FieldGroup( + name="reasoning", + label="Reasoning", + description="", + icon="mdi-brain", +) + + +class Defaults(CommonDefaults, pydantic.BaseModel): + max_token_length: int = 16384 + model: str = "" + provider: str = DEFAULT_PROVIDER + effort_level: str = "medium" + + +class ClientConfig(ConcurrentInference, BaseClientConfig): + provider: str = DEFAULT_PROVIDER + effort_level: Literal["minimal", "low", "medium", "high", "xhigh", "max"] = "medium" + + +class PiBridgeError(Exception): + """Raised when the pi subprocess fails or reports a generation error. + + Provider API errors reported by pi are prefixed with the HTTP status + (e.g. "400: ..."); the parsed code feeds the standard generation error + dialog. Most pi failures carry no status code at all (missing key, + process exit, retry exhaustion), so the message itself is exposed as + `user_message` for the dialog — the status-code fallback text would + misreport these as empty responses. + """ + + def __init__(self, message: str, status_code: int | None = None): + super().__init__(message) + self.status_code = status_code + self.user_message = message + + +def parse_error_status_code(error_message: str) -> int | None: + status_match = re.match(r"(\d{3}):", error_message) + return int(status_match.group(1)) if status_match else None + + +@register() +class PiBridgeClient(ConcurrentInferenceMixin, ClientBase): + """ + Client that drives generations through the `pi` coding harness via its + RPC mode. pi owns provider auth, model resolution and sampling + parameters; talemate acts as a pure prompt/response bridge. + """ + + client_type = "pi_bridge" + conversation_retries = 0 + decensor_enabled = False + config_cls = ClientConfig + + class Meta(ClientBase.Meta): + name_prefix: str = "Pi Bridge" + title: str = "Pi Bridge" + manual_model: bool = True + manual_model_choices: list[str] = pydantic.Field( + default_factory=lambda: models_for_provider(DEFAULT_PROVIDER) + ) + requires_prompt_template: bool = False + defaults: Defaults = Defaults() + + @staticmethod + def _build_extra_fields(): + """Build extra_fields dynamically so provider choices reflect the + current pi model catalog.""" + fields = { + "provider": ExtraField( + name="provider", + type="autocomplete" if available_providers() else "text", + label="Provider", + choices=available_providers() or None, + description=( + "The pi provider to route generations through. pi handles " + "authentication (environment API keys, pi's auth.json, " + "subscription auth) and model resolution, including custom " + "providers defined in pi's models.json. Variables from " + "Settings → Application → Environment Variables are passed " + "to pi, so models.json can reference them as $NAME." + ), + required=False, + ), + "effort_level": ExtraField( + name="effort_level", + type="text", + label="Thinking Level", + choices=THINKING_LEVELS, + description=( + "Thinking level passed to pi (--thinking) when reasoning is " + "enabled. Availability of the higher levels depends on the " + "selected model." + ), + group=REASONING_FIELD_GROUP, + required=False, + ), + } + fields.update(concurrent_inference_extra_fields()) + return fields + + extra_fields: dict[str, ExtraField] = pydantic.Field( + default_factory=_build_extra_fields + ) + + @property + def provider(self) -> str: + return self.client_config.provider or DEFAULT_PROVIDER + + @property + def effort_level(self) -> str: + return self.client_config.effort_level + + @property + def can_be_coerced(self) -> bool: + # pi's RPC prompt is a single user message; coercion happens + # indirectly via instructions appended by the base class. + return False + + @property + def requires_reasoning_pattern(self) -> bool: + # pi returns thinking blocks separately from response text + return False + + @property + def supported_parameters(self): + # Sampling parameters are owned by pi / the provider; talemate + # deliberately forwards none of them. + return [] + + @property + def pi_available(self) -> bool: + return resolve_pi_binary() is not None + + @property + def reasoning_display(self) -> ReasoningDisplay | None: + if not self.reason_enabled_configured: + return None + return ReasoningDisplay( + indicator_value=self.effort_level, + indicator_tooltip="Thinking level", + show_token_slider=False, + show_effort_selector=True, + effort_level=self.effort_level, + effort_choices=THINKING_LEVELS, + ) + + def emit_status(self, processing: bool = None): + error_message = None + if processing is not None: + self.processing = processing + + if not self.pi_available: + status = "error" + error_message = "pi binary not found - install the pi coding agent" + elif not self.model_name: + status = "error" + error_message = "No model loaded" + else: + status = "busy" if self.processing else "idle" + + self.current_status = status + + data = { + "error_action": None, + "meta": self.Meta().model_dump(), + "enabled": self.enabled, + "error_message": error_message, + } + data.update(self._common_status_data()) + data["manual_model_choices"] = models_for_provider(self.provider) + + emit( + "client_status", + message=self.client_type, + id=self.name, + details=self.model_name, + status=status if self.enabled else "disabled", + data=data, + ) + + async def status(self): + if not MODELS_FETCHED: + await fetch_available_models() + self.emit_status() + + def _build_command( + self, kind: str, pi_path: str, system_prompt_path: str + ) -> list[str]: + """Assemble the pi RPC invocation for a single generation. + + The system prompt travels as a file (pi reads --system-prompt from a + path when one exists) rather than inline: on Windows the resolved pi + is npm's pi.cmd, which CreateProcess runs through cmd.exe, and cmd + re-parses the argv — an embedded newline would end the command and + the rest of the prompt would be interpreted as one. + """ + thinking = self.effort_level if self.reason_enabled else "off" + return [ + pi_path, + "--mode", + "rpc", + "--no-session", + "--provider", + self.provider, + "--model", + self.model_name, + "--thinking", + thinking, + "--no-tools", + "--no-extensions", + "--no-skills", + "--no-context-files", + "--no-prompt-templates", + "--system-prompt", + system_prompt_path, + ] + + async def generate(self, prompt: str, parameters: dict, kind: str): + """ + Generates text by spawning a pi RPC subprocess for this request. + """ + + pi_path = resolve_pi_binary() + if not pi_path: + raise PiBridgeError("pi binary not found") + + self.log.debug( + "generate", + prompt=prompt[:128] + " ...", + model=self.model_name, + provider=self.provider, + ) + + with tempfile.TemporaryDirectory(prefix="talemate-pi-") as prompt_dir: + system_prompt_path = os.path.join(prompt_dir, "system-prompt.txt") + with open(system_prompt_path, "w", encoding="utf-8") as f: + f.write(self.get_system_message(kind)) + + argv = self._build_command(kind, pi_path, system_prompt_path) + try: + proc = await asyncio.create_subprocess_exec( + *argv, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=pi_subprocess_env(), + limit=PI_STDOUT_LIMIT, + ) + except FileNotFoundError as e: + raise PiBridgeError( + f"could not execute the pi binary: {pi_path}" + ) from e + + # drain stderr continuously so a chatty pi process can never fill + # the OS pipe buffer and deadlock against our stdout readline loop; + # the collected output feeds the unexpected-exit error message. + # drained incrementally into a buffer so whatever pi wrote is + # available even when the drain has to be abandoned mid-read. + stderr_buf = bytearray() + + async def drain_stderr(): + while True: + chunk = await proc.stderr.read(4096) + if not chunk: + return + stderr_buf.extend(chunk) + + stderr_task = asyncio.create_task(drain_stderr()) + + trace = None + heartbeat_task = None + if PI_BRIDGE_TRACE: + trace = PiBridgeTrace(self.log) + heartbeat_task = asyncio.create_task(trace.heartbeat(proc)) + self.log.info( + "pi_bridge trace: spawned", + argv=argv, + prompt_bytes=len(prompt), + pid=proc.pid, + ) + + try: + rpc_command = {"id": "generate", "type": "prompt", "message": prompt} + proc.stdin.write((json.dumps(rpc_command) + "\n").encode("utf-8")) + await proc.stdin.drain() + if trace: + self.log.info( + "pi_bridge trace: prompt command sent", + elapsed=trace.elapsed(), + ) + + response_text, reasoning_text = await self._consume_events( + proc, stderr_task, stderr_buf, trace=trace + ) + + self._reasoning_response = reasoning_text or None + + self.log.debug( + "generated response", + response=response_text[:128] + " ..." + if len(response_text) > 128 + else response_text, + reasoning_length=len(reasoning_text), + ) + + return response_text + finally: + if heartbeat_task: + heartbeat_task.cancel() + try: + await terminate_pi_process(proc) + # the exit above closes the pipe, so the drain finishes; + # bounded in case something still holds stderr open. + # skipped when the unexpected-exit path already consumed + # it - if that drain timed out the task is CANCELLED, and + # re-awaiting it would raise CancelledError over the + # in-flight PiBridgeError + if not stderr_task.done(): + await asyncio.wait_for( + stderr_task, timeout=PI_STDERR_DRAIN_TIMEOUT + ) + except asyncio.TimeoutError: + pass + finally: + # cancellation out of terminate_pi_process must not + # abandon a pending drain (task-destroyed warnings) + if not stderr_task.done(): + stderr_task.cancel() + + async def _consume_events( + self, + proc: asyncio.subprocess.Process, + stderr_task: asyncio.Task, + stderr_buf: bytearray, + trace: PiBridgeTrace | None = None, + ) -> tuple[str, str]: + """Read the pi RPC event stream until the agent settles. + + Returns the accumulated assistant text and thinking content. + """ + response_text = "" + reasoning_text = "" + + while True: + raw_line = await proc.stdout.readline() + if not raw_line: + # bounded: an orphaned grandchild can hold stderr open; the + # buffer still carries whatever pi wrote before dying + try: + await asyncio.wait_for(stderr_task, timeout=PI_STDERR_DRAIN_TIMEOUT) + except asyncio.TimeoutError: + pass + stderr = bytes(stderr_buf).decode(errors="replace") + raise PiBridgeError( + f"pi process exited unexpectedly: {stderr[:500].strip() or 'no error output'}" + ) + + line = raw_line.decode("utf-8").strip() + if not line: + continue + + try: + event = json.loads(line) + except json.JSONDecodeError: + # pi may print non-protocol noise on stdout during startup + continue + + event_type = event.get("type") + + if trace: + sub = event.get("assistantMessageEvent", {}).get("type", "") + trace.line( + raw_line, + f"{event_type}/{sub}" if sub else str(event_type), + ) + + if event_type == "response": + if not event.get("success", True): + raise PiBridgeError( + f"pi rejected the prompt: {event.get('error', 'unknown error')}" + ) + continue + + if event_type == "message_update": + delta = event.get("assistantMessageEvent", {}) + if delta.get("type") in ("text_delta", "thinking_delta"): + self.update_request_tokens( + self.count_tokens(delta.get("delta", "")) + ) + continue + + if event_type == "message_end": + message = event.get("message", {}) + if message.get("role") != "assistant": + continue + + if message.get("stopReason") in ("error", "aborted"): + error_message = message.get("errorMessage") or "generation failed" + raise PiBridgeError( + f"pi generation error: {error_message}", + status_code=parse_error_status_code(error_message), + ) + + for block in message.get("content", []): + if block.get("type") == "text": + response_text += block.get("text", "") + elif block.get("type") == "thinking": + reasoning_text += block.get("thinking", "") + + usage = message.get("usage", {}) + if usage.get("input"): + self._returned_prompt_tokens = usage["input"] + if usage.get("output"): + self._returned_response_tokens = usage["output"] + continue + + if event_type == "auto_retry_end" and not event.get("success", True): + raise PiBridgeError( + f"pi generation failed: {event.get('finalError', 'max retries exceeded')}" + ) + + if event_type == "agent_settled": + return response_text, reasoning_text diff --git a/src/talemate/client/tabbyapi.py b/src/talemate/client/tabbyapi.py index 20df0d85..506db6c0 100644 --- a/src/talemate/client/tabbyapi.py +++ b/src/talemate/client/tabbyapi.py @@ -1,33 +1,33 @@ import random import json import httpx -import pydantic -import structlog +from talemate.client.api_handles import ( + ApiHandlesPromptTemplateConfig, + ApiHandlesPromptTemplateMixin, + api_handles_prompt_template_extra_fields, +) from talemate.client.base import ClientBase, ExtraField, CommonDefaults from talemate.client.registry import register from talemate.client.utils import urljoin from talemate.config.schema import Client as BaseClientConfig -log = structlog.get_logger("talemate.client.tabbyapi") - EXPERIMENTAL_DESCRIPTION = """Use this client to use all of TabbyAPI's features. Note on EXL3 models: They seem to be very sensitive to `presence_penalty`, `frequency_penalty` and `repetition_penalty_range`. If you're getting gibberish output, try creating a new inference parameter group and turn those off or way down.""" -class Defaults(CommonDefaults, pydantic.BaseModel): +class Defaults(CommonDefaults, ApiHandlesPromptTemplateConfig): api_url: str = "http://localhost:5000/v1" api_key: str = "" max_token_length: int = 8192 model: str = "" - api_handles_prompt_template: bool = False double_coercion: str = None -class ClientConfig(BaseClientConfig): - api_handles_prompt_template: bool = False +class ClientConfig(ApiHandlesPromptTemplateConfig, BaseClientConfig): + pass @register() -class TabbyAPIClient(ClientBase): +class TabbyAPIClient(ApiHandlesPromptTemplateMixin, ClientBase): client_type = "tabbyapi" conversation_retries = 0 config_cls = ClientConfig @@ -41,19 +41,9 @@ class TabbyAPIClient(ClientBase): manual_model: bool = False defaults: Defaults = Defaults() self_hosted: bool = True - extra_fields: dict[str, ExtraField] = { - "api_handles_prompt_template": ExtraField( - name="api_handles_prompt_template", - type="bool", - label="API handles prompt template (chat/completions)", - required=False, - description="The API handles the prompt template, meaning your choice in the UI for the prompt template below will be ignored. This is not recommended and should only be used if the API does not support the `completions` endpoint or you don't know which prompt template to use.", - ) - } - - @property - def api_handles_prompt_template(self) -> bool: - return self.client_config.api_handles_prompt_template + extra_fields: dict[str, ExtraField] = api_handles_prompt_template_extra_fields( + description="The API handles the prompt template, meaning your choice in the UI for the prompt template below will be ignored. This is not recommended and should only be used if the API does not support the `completions` endpoint or you don't know which prompt template to use.", + ) @property def experimental(self): @@ -87,11 +77,6 @@ class TabbyAPIClient(ClientBase): "temperature", ] - def prompt_template(self, system_message: str, prompt: str): - if not self.api_handles_prompt_template: - return super().prompt_template(system_message, prompt) - return prompt - async def get_model_name(self): url = urljoin(self.api_url, "model") headers = { @@ -124,25 +109,11 @@ class TabbyAPIClient(ClientBase): parameters=parameters, ) - if self.can_be_coerced: - prompt, coercion_prompt = self.split_prompt_for_coercion(prompt) - else: - coercion_prompt = None - - messages = [ - {"role": "system", "content": self.get_system_message(kind)}, - {"role": "user", "content": prompt.strip()}, - ] + messages, coercion_prompt = self.chat_messages_for_coercion(prompt, kind) if coercion_prompt: - log.debug("Adding coercion pre-fill", coercion_prompt=coercion_prompt) - messages.append( - { - "role": "assistant", - "content": coercion_prompt.strip(), - "prefix": True, - } - ) + # TabbyAPI continues the pre-fill via its prefix flag + messages[-1]["prefix"] = True payload = { "model": self.model_name, diff --git a/src/talemate/client/textgenwebui.py b/src/talemate/client/textgenwebui.py index 6c7cf04c..b8cc7051 100644 --- a/src/talemate/client/textgenwebui.py +++ b/src/talemate/client/textgenwebui.py @@ -9,6 +9,11 @@ import pydantic import structlog from openai import AsyncOpenAI +from talemate.client.api_handles import ( + ApiHandlesPromptTemplateConfig, + ApiHandlesPromptTemplateMixin, + api_handles_prompt_template_extra_fields, +) from talemate.client.base import STOPPING_STRINGS, ClientBase, Defaults from talemate.client.registry import register from talemate.client.vision import VisionConfig, vision_extra_fields, OpenAIVisionMixin @@ -17,16 +22,18 @@ from talemate.config.schema import Client as BaseClientConfig log = structlog.get_logger("talemate.client.textgenwebui") -class TextGeneratorWebuiClientDefaults(Defaults): +class TextGeneratorWebuiClientDefaults(Defaults, ApiHandlesPromptTemplateConfig): api_key: str = "" -class ClientConfig(VisionConfig, BaseClientConfig): +class ClientConfig(ApiHandlesPromptTemplateConfig, VisionConfig, BaseClientConfig): pass @register() -class TextGeneratorWebuiClient(OpenAIVisionMixin, ClientBase): +class TextGeneratorWebuiClient( + ApiHandlesPromptTemplateMixin, OpenAIVisionMixin, ClientBase +): auto_determine_prompt_template: bool = True remote_model_locked: bool = True finalizers: list[str] = [ @@ -44,9 +51,20 @@ class TextGeneratorWebuiClient(OpenAIVisionMixin, ClientBase): defaults: TextGeneratorWebuiClientDefaults = TextGeneratorWebuiClientDefaults() self_hosted: bool = True extra_fields: dict = pydantic.Field( - default_factory=lambda: vision_extra_fields() + default_factory=lambda: { + **api_handles_prompt_template_extra_fields( + description="Requests go to the chat/completions API and text-generation-webui applies the model's prompt template, and the prompt template selection below is ignored. Response pre-filling keeps working. Keep this disabled for full control of the prompt template in Talemate; enable it to trust that the template on the remote end is correct.", + ), + **vision_extra_fields(), + } ) + @property + def requires_reasoning_pattern(self) -> bool: + # in chat mode the API separates reasoning into reasoning_content + # deltas, which are captured during streaming + return not self.api_handles_prompt_template + def make_client(self) -> AsyncOpenAI: api_key = self.api_key or "sk-1234" base = self.api_url.rstrip("/") @@ -181,10 +199,70 @@ class TextGeneratorWebuiClient(OpenAIVisionMixin, ClientBase): async def generate(self, prompt: str, parameters: dict, kind: str): loop = asyncio.get_event_loop() + if self.api_handles_prompt_template: + # assemble on the event loop - the executor thread cannot see the + # active_scene contextvar, which would drop persona instructions + messages, coercion_prompt = self.chat_messages_for_coercion(prompt, kind) + return await loop.run_in_executor( + None, self._generate_chat, messages, coercion_prompt, parameters + ) return await loop.run_in_executor( None, self._generate, prompt, parameters, kind ) + def _generate_chat( + self, messages: list[dict], coercion_prompt: str | None, parameters: dict + ): + """ + Generates text via the chat/completions endpoint, letting + text-generation-webui apply the model's prompt template. Coercion is + passed as a partial assistant message that the API continues via its + `continue_` parameter. + """ + if coercion_prompt: + parameters["continue_"] = True + + parameters["mode"] = "instruct" + parameters["messages"] = messages + parameters["stream"] = True + + response = "" + reasoning_response = "" + stream_response = requests.post( + f"{self.api_url}/v1/chat/completions", + json=parameters, + timeout=None, + headers=self.request_headers, + stream=True, + ) + stream_response.raise_for_status() + + sse = sseclient.SSEClient(stream_response) + + for event in sse.events(): + if event.data == "[DONE]": + break + payload = json.loads(event.data) + delta = payload["choices"][0]["delta"] + reasoning_chunk = delta.get("reasoning_content") or "" + if reasoning_chunk: + reasoning_response += reasoning_chunk + self.update_request_tokens(self.count_tokens(reasoning_chunk)) + chunk = delta.get("content") or "" + response += chunk + self.update_request_tokens(self.count_tokens(chunk)) + + if reasoning_response: + self._reasoning_response = reasoning_response + + # the API echoes the coercion pre-fill back at the start of the + # response - strip it so coerced responses match the completions + # endpoint behavior (continuation only) + if coercion_prompt and response.startswith(coercion_prompt): + response = response[len(coercion_prompt) :] + + return response + def _generate(self, prompt: str, parameters: dict, kind: str): """ Generates text from the given prompt and parameters. diff --git a/src/talemate/client/toggleable_parameters.py b/src/talemate/client/toggleable_parameters.py new file mode 100644 index 00000000..562f5bcc --- /dev/null +++ b/src/talemate/client/toggleable_parameters.py @@ -0,0 +1,98 @@ +""" +Shared pieces for clients that expose per-parameter `send_*` toggles for +sampler parameters. Some APIs hard-error when they receive a parameter +they don't support for the selected model, so a disabled parameter needs +to be omitted from the request payload entirely (not just zeroed out). +""" + +import pydantic + +from .base import ExtraField, FieldGroup + +__all__ = [ + "PARAMETERS_FIELD_GROUP", + "ToggleableParametersMixin", + "toggleable_parameters_config", + "toggleable_parameters_extra_fields", +] + + +PARAMETERS_FIELD_GROUP = FieldGroup( + name="parameters", + label="Parameters", + description=( + "Toggle individual sampler parameters. When a parameter is disabled it is " + "omitted from the request payload entirely. Useful for APIs that hard-error " + "when receiving parameters they don't support for the selected model." + ), + icon="mdi-tune-vertical", +) + + +def _send_parameter_field(param: str) -> ExtraField: + return ExtraField( + name=f"send_{param}", + type="bool", + label=f"Send {param}", + required=False, + description=( + f"When enabled, `{param}` is included in the request. Disable for " + f"models/APIs that reject it." + ), + group=PARAMETERS_FIELD_GROUP, + ) + + +def toggleable_parameters_extra_fields( + params: tuple[str, ...], +) -> dict[str, ExtraField]: + return {f"send_{p}": _send_parameter_field(p) for p in params} + + +def toggleable_parameters_config(params: tuple[str, ...]) -> type[pydantic.BaseModel]: + """ + Build a pydantic model with a `send_: bool = True` field per + parameter, for use as a base of a client's Defaults / ClientConfig. + """ + return pydantic.create_model( + "ToggleableParametersConfig", + **{f"send_{p}": (bool, True) for p in params}, + ) + + +class ToggleableParametersMixin: + """ + Provides `supported_parameters` filtered by the `send_*` config flags, + plus client-level `send_` accessor properties — the client status + payload reads extra-field values off the client instance + (ClientBase._common_status_data / populate_extra_fields), and the frontend + falls back to the Meta defaults for missing values, so without these the + toggles would render as enabled and be reverted on the next save. + + Set `toggleable_parameters` on the client class; mix in before ClientBase. + """ + + toggleable_parameters: tuple[str, ...] = () + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + for param in cls.toggleable_parameters: + field_name = f"send_{param}" + if not hasattr(cls, field_name): + setattr( + cls, + field_name, + property( + lambda self, _field_name=field_name: getattr( + self.client_config, _field_name + ) + ), + ) + + @property + def supported_parameters(self): + return [ + param + for param in self.toggleable_parameters + if getattr(self.client_config, f"send_{param}") + ] + ["max_tokens"] diff --git a/src/talemate/config/schema.py b/src/talemate/config/schema.py index 38ec038d..20d797bf 100644 --- a/src/talemate/config/schema.py +++ b/src/talemate/config/schema.py @@ -1,5 +1,6 @@ import datetime import os +import re from typing import TYPE_CHECKING, Any, ClassVar, Dict, Optional, TypeVar, Union, Literal import pydantic @@ -24,6 +25,11 @@ async_signals.register( "config.changed.follow", ) +MESSAGE_ASSET_KINDS = ("avatar", "card", "scene_illustration", "scene_background") + +# mirrors the frontend rule for the environment variable store +ENV_VARIABLE_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + class Client(pydantic.BaseModel): """ @@ -45,6 +51,12 @@ class Client(pydantic.BaseModel): # max requests per minute rate_limit: Union[int, None] = None + # automatic retries before the user is notified of a response issue + # (0 = notify immediately) + retry_empty_response: int = 0 + retry_rate_limit: int = 0 + retry_missing_reasoning: int = 0 + # expected data structure format in responses data_format: Literal["json", "yaml"] | None = None @@ -126,6 +138,16 @@ class Client(pydantic.BaseModel): return False return v + # clamp rather than reject so an out-of-range hand-edited config.yaml + # doesn't fail to load - an unbounded value here means an unbounded + # automatic retry loop against a (usually paid) API + @pydantic.field_validator( + "retry_empty_response", "retry_rate_limit", "retry_missing_reasoning" + ) + @classmethod + def clamp_retry_counts(cls, v: int) -> int: + return max(0, min(5, v)) + # Generic choice metadata for fields that should render as in the # frontend. Keyed by field name; values use {"label": ..., "value": ...} format. FIELD_CHOICES: ClassVar[dict[str, list[dict[str, str]]]] = { @@ -594,6 +616,17 @@ class MarkupMessageStyle(HistoryMessageStyle): class MessageAssetCadenceConfig(pydantic.BaseModel): cadence: Literal["always", "never", "on_change"] = "always" size: Literal["small", "medium", "big"] = "medium" + # automatically promote newly generated assets of this kind to the scene + # backdrop (only meaningful for scene_illustration and scene_background) + auto_backdrop: bool = False + + +def default_message_asset_config(kind: str) -> MessageAssetCadenceConfig: + # scene backgrounds are environmental full-frame images — full width + # by default + if kind == "scene_background": + return MessageAssetCadenceConfig(size="big") + return MessageAssetCadenceConfig() class SceneAppearance(pydantic.BaseModel): @@ -610,16 +643,32 @@ class SceneAppearance(pydantic.BaseModel): brackets: MarkupMessageStyle = MarkupMessageStyle() emphasis: MarkupMessageStyle = MarkupMessageStyle() entities: MarkupMessageStyle = MarkupMessageStyle() + # scene_background and scene_illustration assets share the message-level + # asset_type "scene_illustration"; separate entries keep their display + # individually configurable message_assets: Dict[str, MessageAssetCadenceConfig] = pydantic.Field( default_factory=lambda: { - "avatar": MessageAssetCadenceConfig(), - "card": MessageAssetCadenceConfig(), - "scene_illustration": MessageAssetCadenceConfig(), + kind: default_message_asset_config(kind) for kind in MESSAGE_ASSET_KINDS } ) auto_attach_assets: bool = True + # opacity of the message text panels rendered over the scene backdrop + backdrop_panel_opacity: float = pydantic.Field(default=0.8, ge=0.0, le=1.0) + # drop shadow on message text over the scene backdrop + backdrop_text_shadow: bool = True + + @pydantic.field_validator("message_assets", mode="after") + @classmethod + def ensure_default_message_asset_entries(cls, value): + # configs saved before an entry existed (e.g. scene_background) come + # in without it — fill the gaps so consumers can rely on all keys + for key in MESSAGE_ASSET_KINDS: + if key not in value: + value[key] = default_message_asset_config(key) + return value + class Appearance(pydantic.BaseModel): scene: SceneAppearance = SceneAppearance() @@ -679,6 +728,22 @@ class Config(pydantic.BaseModel): huggingface: HuggingFaceConfig = HuggingFaceConfig() + # named environment variables passed to subprocess-based integrations + # (currently the pi bridge client); every value is encrypted at rest + env: Dict[str, str] = pydantic.Field(default_factory=dict) + + @pydantic.field_validator("env") + @classmethod + def drop_invalid_env_variable_names(cls, value: Dict[str, str]) -> Dict[str, str]: + # an invalid name (e.g. containing '=' from a hand-edited config) + # would make subprocess spawns fail far from the cause; drop instead + # of raising so a bad entry cannot brick config loading + invalid = [name for name in value if not ENV_VARIABLE_NAME_PATTERN.match(name)] + for name in invalid: + log.warning("dropping invalid env variable name", name=name) + del value[name] + return value + recent_scenes: RecentScenes = RecentScenes() presets: Presets = Presets() diff --git a/src/talemate/emit/signals.py b/src/talemate/emit/signals.py index e9f66835..ea7c9a0c 100644 --- a/src/talemate/emit/signals.py +++ b/src/talemate/emit/signals.py @@ -21,6 +21,8 @@ ReceiveInput = signal("receive_input") ClientStatus = signal("client_status") RateLimited = signal("rate_limited") RateLimitReset = signal("rate_limit_reset") +AutoRetry = signal("auto_retry") +AutoRetryDone = signal("auto_retry_done") GenerationError = signal("generation_error") GenerationErrorResponse = signal("generation_error_response") RequestClientStatus = signal("request_client_status") @@ -79,6 +81,8 @@ handlers = { "client_status": ClientStatus, "rate_limited": RateLimited, "rate_limit_reset": RateLimitReset, + "auto_retry": AutoRetry, + "auto_retry_done": AutoRetryDone, "generation_error": GenerationError, "generation_error_response": GenerationErrorResponse, "request_client_status": RequestClientStatus, diff --git a/src/talemate/export.py b/src/talemate/export.py index a88d7439..d04335b7 100644 --- a/src/talemate/export.py +++ b/src/talemate/export.py @@ -14,6 +14,7 @@ from typing import Union import pydantic import structlog +import talemate.save as save from talemate.scene_agent_settings import AGENT_SETTINGS_DIRNAME from talemate.tale_mate import Scene @@ -25,6 +26,7 @@ __all__ = [ "export", "export_talemate", "export_talemate_complete", + "scene_export_json", ] @@ -48,6 +50,33 @@ class ExportOptions(pydantic.BaseModel): include_agent_settings: bool = True +def scene_export_json(scene: Scene, options: ExportOptions) -> str: + """ + Serialize the scene for export. + + When reset_progress is set, the reset is applied to the serialized copy + only — the live scene must never be mutated by an export. + Mirrors Scene.reset() / WorldState.reset(), plus layered history. + """ + data = scene.serialize + + if options.reset_progress: + data["history"] = [] + data["archived_history"] = [ + ah for ah in data["archived_history"] if ah.get("end") is None + ] + data["layered_history"] = [] + data["world_state"].update( + characters={}, + items={}, + places={}, + location=None, + anchor_message_ids=[], + ) + + return save.scene_data_dumps(data) + + async def export(scene: Scene, options: ExportOptions) -> Union[str, bytes]: """ Export a scene @@ -65,14 +94,9 @@ async def export_talemate(scene: Scene, options: ExportOptions) -> str: """ Export a scene in talemate format (JSON only, legacy format) """ - # Reset progress - if options.reset_progress: - scene.reset() - - # Export scene # json string - scene_json = scene.json + scene_json = scene_export_json(scene, options) # encode base64 scene_base64 = base64.b64encode(scene_json.encode()).decode() @@ -84,10 +108,6 @@ async def export_talemate_complete(scene: Scene, options: ExportOptions) -> byte """ Export a complete scene in ZIP format including all assets, nodes, info, and templates """ - # Reset progress - if options.reset_progress: - scene.reset() - log.info( "Starting complete scene export", scene_name=scene.name, @@ -101,7 +121,7 @@ async def export_talemate_complete(scene: Scene, options: ExportOptions) -> byte # Export main scene JSON scene_json_path = temp_path / "scene.json" with open(scene_json_path, "w", encoding="utf-8") as f: - f.write(scene.json) + f.write(scene_export_json(scene, options)) log.debug("Exported scene JSON", path=scene_json_path) diff --git a/src/talemate/files.py b/src/talemate/files.py index 6efbe9fd..4cd8794b 100644 --- a/src/talemate/files.py +++ b/src/talemate/files.py @@ -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, + } diff --git a/src/talemate/game/engine/api/agents/creator.py b/src/talemate/game/engine/api/agents/creator.py index 22d22076..7508bd63 100644 --- a/src/talemate/game/engine/api/agents/creator.py +++ b/src/talemate/game/engine/api/agents/creator.py @@ -144,7 +144,8 @@ def create(scene: "Scene") -> "ScopedAPI": Returns: - - str - The generated description + - str - The generated description, or an empty string when the model + produced nothing Raises: diff --git a/src/talemate/game/engine/api/agents/director.py b/src/talemate/game/engine/api/agents/director.py index 17e011af..232a2a43 100644 --- a/src/talemate/game/engine/api/agents/director.py +++ b/src/talemate/game/engine/api/agents/director.py @@ -47,11 +47,19 @@ def create(scene: "Scene") -> "ScopedAPI": director = get_agent("director") + # lazy: this module loads during talemate.agents.base init, long + # before the director package can be imported + from talemate.agents.director.character_management import ( + PersistCharacterRequest, + ) + character = run_async( director.persist_character( - name=validated.character_name, - content=validated.content, - determine_name=validated.determine_name, + PersistCharacterRequest( + name=validated.character_name, + content=validated.content, + determine_name=validated.determine_name, + ) ) ) diff --git a/src/talemate/game/engine/api/agents/world_state.py b/src/talemate/game/engine/api/agents/world_state.py index bac7abd9..783e9b8c 100644 --- a/src/talemate/game/engine/api/agents/world_state.py +++ b/src/talemate/game/engine/api/agents/world_state.py @@ -166,7 +166,9 @@ def create(scene: "Scene") -> "ScopedAPI": Returns: - - dict - The extracted character sheet where each key is an attribute name and the value is the attribute value + - dict - The extracted character sheet where each key is an attribute + name and the value is the attribute value, or an empty dict when the + model produced nothing """ class Arguments(pydantic.BaseModel): diff --git a/src/talemate/game/engine/context_id/story_configuration.py b/src/talemate/game/engine/context_id/story_configuration.py index 755a32ca..9325c9c8 100644 --- a/src/talemate/game/engine/context_id/story_configuration.py +++ b/src/talemate/game/engine/context_id/story_configuration.py @@ -329,12 +329,14 @@ class StoryConfigurationContextItem(ContextIDItem): from talemate.scene.schema import ScenePhase intent_changed = False + intro_changed = False if self.context_type == "title": scene.title = value or "" elif self.context_type == "description": scene.description = value or "" elif self.context_type == "introduction": scene.set_intro(value or "") + intro_changed = True elif self.context_type == "content_classification": scene.context = value or "" elif self.context_type == "story_intention": @@ -373,6 +375,10 @@ class StoryConfigurationContextItem(ContextIDItem): if intent_changed: scene.emit_scene_intent() + if intro_changed: + scene.emit_status() + await scene.emit_history() + class SceneTypeListItem(pydantic.BaseModel): id: str diff --git a/src/talemate/game/engine/nodes/agent.py b/src/talemate/game/engine/nodes/agent.py index 5bd83216..6b4c11dc 100644 --- a/src/talemate/game/engine/nodes/agent.py +++ b/src/talemate/game/engine/nodes/agent.py @@ -152,19 +152,27 @@ class AgentSettingsNode(Node): @register("agents/ToggleAgentAction") class ToggleAgentAction(Node): """ - Allows disabling or enabling an agent action + Allows disabling or enabling an agent action that can be disabled + + Raises an error if the agent or the action cannot be found, or if the + action is one that cannot be disabled — those are always enabled and + are not togglable from a graph. Inputs: - - agent: str,agent - - action_name: str - - enabled: bool + - state: The graph state + - agent: The agent (instance or name) to toggle the action on + - action_name: The name of the action to toggle + - enabled: Whether to enable or disable the action Outputs: - - agent: agent - - action_name: str - - enabled: bool + - state: The state input, passed through + - agent: The resolved agent instance + - action_name: The action name, passed through + - enabled: The action's effective enabled state after the write — when a + scene override is active it takes the write, so this reflects the + override rather than the agent's global setting """ class Fields: @@ -201,6 +209,7 @@ class ToggleAgentAction(Node): self.set_property("action_name", "") self.set_property("enabled", True) + self.add_output("state") self.add_output("agent", socket_type="agent") self.add_output("action_name", socket_type="str") self.add_output("enabled", socket_type="bool") @@ -224,20 +233,53 @@ class ToggleAgentAction(Node): raise InputValueError( self, "action_name", - f"Could not find action {action_name} in agent {agent}", + f"Could not find action {action_name} in agent {agent.agent_type}", + ) + + if not action.can_be_disabled: + # The write would be refused, so say so rather than pass the graph + # through as if the action had been toggled. Matches how this node + # already reports an unknown agent or action. + raise InputValueError( + self, + "action_name", + f"Action {action_name} on agent {agent.agent_type} is always " + "enabled and cannot be toggled", ) agent.write_enabled(action_name, enabled) self.set_output_values( - {"agent": agent, "action_name": action_name, "enabled": enabled} + { + "state": self.get_input_value("state"), + "agent": agent, + "action_name": action_name, + # Read back through the resolver rather than echoing the input: + # when a scene override is active it takes the write, so this + # reflects the override rather than the agent's global setting. + "enabled": agent.resolve_enabled(action_name), + } ) @register("agents/CallAgentFunction") class CallAgentFunction(Node): """ - Call an agent function + Call a function on an agent and return its result. + + The agent can be given as an agent instance or by name. The function is + looked up on the agent by name and called with the given arguments as + keyword arguments (coroutine functions are awaited). + + Inputs: + + - agent: The agent (instance or name) to call the function on + - function_name: The name of the function to call on the agent + - arguments: Dict of keyword arguments to pass to the function + + Outputs: + + - result: The return value of the function call """ class Fields: @@ -319,7 +361,21 @@ class CallAgentFunction(Node): @register("agents/CallAgentFunctionConditional") class CallAgentFunctionConditional(CallAgentFunction): """ - Call an agent function with state + Call a function on an agent and return its result. + + Provides a required `state` input causing the node to only run when a state is provided + + Inputs: + + - state: The graph state + - agent: The agent (instance or name) to call the function on + - function_name: The name of the function to call on the agent + - arguments: Dict of keyword arguments to pass to the function + + Outputs: + + - state: The state input, passed through + - result: The return value of the function call """ def __init__(self, title="Call Agent Function (Conditional)", **kwargs): @@ -327,6 +383,7 @@ class CallAgentFunctionConditional(CallAgentFunction): def setup(self): self.add_input("state") + self.add_output("state") super().setup() async def run(self, state: GraphState): @@ -337,7 +394,18 @@ class CallAgentFunctionConditional(CallAgentFunction): @register("agents/GetAgent") class GetAgent(Node): """ - Get an agent instance + Get an agent instance by name. + + Does nothing if no agent name is set; raises an error if the agent cannot + be found. + + Properties: + + - agent_name: The name of the agent to get + + Outputs: + + - agent: The agent instance """ class Fields: @@ -345,7 +413,7 @@ class GetAgent(Node): name="agent_name", type="str", default="", - description="The name of the agent to get the client for", + description="The name of the agent to get", choices=[], generate_choices=lambda: get_agent_types(), ) @@ -439,9 +507,30 @@ class AgentStateManipulation(StateManipulation): @register("agents/SetAgentState") class SetAgentState(AgentStateManipulation, ConditionalSetState): """ - Set an agent state variable + Set a variable in an agent's state. + + The `scene` scope writes agent state stored with the scene, the `context` + scope writes to the agent's context state. Provides a required `state` input causing the node to only run when a state is provided + + Inputs: + + - state: The graph state + - name: the name of the variable to set + - value: the value to set + - agent: the agent (instance or name) to set the state on + + Properties: + + - scope: which scope to write the variable to (scene or context) + + Outputs: + + - state: The state input, passed through + - name: the name that was set + - value: the value that was set + - scope: the scope that was used """ @pydantic.computed_field(description="Node style") @@ -460,7 +549,26 @@ class SetAgentState(AgentStateManipulation, ConditionalSetState): @register("agents/GetAgentState") class GetAgentState(AgentStateManipulation, GetState): """ - Get an agent state variable + Get a variable from an agent's state. + + The `scene` scope reads agent state stored with the scene, the `context` + scope reads the agent's context state. + + Inputs: + + - name: the name of the variable to get + - default: value to return if the variable is not set (optional) + - agent: the agent (instance or name) to read the state from + + Properties: + + - scope: which scope to read the variable from (scene or context) + + Outputs: + + - name: the name that was retrieved + - value: the value that was retrieved + - scope: the scope that was retrieved """ @pydantic.computed_field(description="Node style") @@ -479,9 +587,29 @@ class GetAgentState(AgentStateManipulation, GetState): @register("agents/UnsetAgentState") class UnsetAgentState(AgentStateManipulation, ConditionalUnsetState): """ - Unset an agent state variable + Unset a variable in an agent's state. + + The `scene` scope removes agent state stored with the scene, the `context` + scope removes from the agent's context state. Provides a required `state` input causing the node to only run when a state is provided + + Inputs: + + - state: The graph state + - name: the name of the variable to unset + - agent: the agent (instance or name) to unset the state on + + Properties: + + - scope: which scope to remove the variable from (scene or context) + + Outputs: + + - state: The state input, passed through + - name: the name that was unset + - value: the value that was unset + - scope: the scope that was used """ @pydantic.computed_field(description="Node style") @@ -511,7 +639,32 @@ class HasAgentState(AgentStateManipulation, HasState): @register("agents/CounterAgentState") class CounterAgentState(AgentStateManipulation, ConditionalCounterState): """ - Increment or decrement an agent state variable + Increment a numeric variable in an agent's state and return the new value. + + Provides a required `state` input causing the node to only run when a state is provided + + Inputs: + + - state: The graph state + - name: The name of the counter variable + - reset: If true, the value will be reset to 0 (optional) + - reset_cap: If set, the counter resets to 0 once it reaches this value (optional) + - agent: The agent (instance or name) whose state holds the counter + + Properties: + + - increment: The amount to increment the value by + - scope: Which scope holds the counter (scene or context) + + Outputs: + + - state: The state input, passed through + - name: The name that was used + - value: The new value + - scope: The scope that was used + - reset: Whether the counter was reset + - reset_cap: The reset cap that was used + - new_cycle: True if the counter was at 0 before this run """ @pydantic.computed_field(description="Node style") @@ -530,8 +683,20 @@ class CounterAgentState(AgentStateManipulation, ConditionalCounterState): @register("agents/DynamicInstruction") class DynamicInstruction(Node): """ - Dynamic instruction object to use for instruction injection - in event handlers + Create a dynamic instruction object to use for instruction injection + in event handlers. + + A header is required at runtime (raises an error if missing). List content + is joined with newlines. + + Inputs: + + - header: The header (title) of the dynamic instruction + - content: The content of the dynamic instruction (string or list of strings) + + Outputs: + + - dynamic_instruction: The dynamic instruction object """ class Fields: diff --git a/src/talemate/game/engine/nodes/api.py b/src/talemate/game/engine/nodes/api.py index 5a8aa8a0..b7126ee9 100644 --- a/src/talemate/game/engine/nodes/api.py +++ b/src/talemate/game/engine/nodes/api.py @@ -21,6 +21,24 @@ log = structlog.get_logger("talemate.game.engine.nodes.core.api") class ScopedAPIFunction(Node): """ Executes python code inside the quarantined scoped environment. + + The code has access to the `arguments` dict, a `result` dict to + populate, and the `TM` scoped API object. + + Inputs: + + - state: The graph state + - agent: The agent whose client the scoped context runs against + - arguments: Arguments made available to the executed code + + Properties: + + - code: The python code to execute + + Outputs: + + - state: The state input, passed through + - result: The result dict populated by the executed code """ class Fields: @@ -49,6 +67,7 @@ class ScopedAPIFunction(Node): self.set_property("code", UNRESOLVED) + self.add_output("state") self.add_output("result") async def run(self, state: GraphState): @@ -80,4 +99,9 @@ class ScopedAPIFunction(Node): with OpenScopedContext(scene, agent.client): _module() - self.set_output_values({"result": result}) + self.set_output_values( + { + "state": self.get_input_value("state"), + "result": result, + } + ) diff --git a/src/talemate/game/engine/nodes/assets.py b/src/talemate/game/engine/nodes/assets.py index d1871dc4..69bcb534 100644 --- a/src/talemate/game/engine/nodes/assets.py +++ b/src/talemate/game/engine/nodes/assets.py @@ -249,12 +249,14 @@ class GetAssets(Node): """ Get multiple assets by their IDs. + Asset IDs that don't exist are skipped rather than raising an error. + Inputs: - asset_ids: list of asset IDs Outputs: - assets: list of asset objects - - asset_ids: list of asset IDs (passed through) + - asset_ids: list of asset IDs that were found (missing assets are skipped) - asset_count: number of assets retrieved """ @@ -532,6 +534,8 @@ class SearchAssets(Node): - character_name: character name to filter by (case insensitive) - tags: list of tags to filter by (case insensitive) - reference_vis_types: list of vis_types to filter by. Only return assets that have at least one matching vis_type in their reference list + + Properties: - tag_match_mode: how to match tags - "all" (must have all), "any" (must have at least one), "none" (must not have any) Outputs: @@ -723,6 +727,7 @@ class MakeAssetMeta(Node): - sampler_settings: sampler settings object - reference_assets: list of reference asset IDs - tags: list of tags + - analysis: analysis prompt text - reference: list of vis_types that this asset may be used as a reference for Outputs: @@ -737,6 +742,7 @@ class MakeAssetMeta(Node): - sampler_settings: sampler settings object (passed through) - reference_assets: list of reference asset IDs (passed through) - tags: list of tags (passed through) + - analysis: analysis prompt text (passed through) - reference: list of vis_types that this asset may be used as a reference for (passed through) """ @@ -984,6 +990,7 @@ class UnpackAssetMeta(Node): - sampler_settings: sampler settings object - reference_assets: list of reference asset IDs - tags: list of tags + - analysis: analysis prompt text - reference: list of vis_types that this asset may be used as a reference for """ @@ -1054,17 +1061,37 @@ class MakeAssetAttachmentContext(Node): This controls how assets are automatically attached to messages when created. Inputs: + - asset_name: name to save the asset under + - tags: list of tags to add to the asset - allow_auto_attach: whether to allow automatic attachment of assets to messages - allow_override: whether to allow overriding existing message assets - delete_old: whether to delete the old asset when replacing a message's asset - message_ids: list of specific message IDs to attach to (empty = auto-detect last message) + - scene_cover: whether to set the scene cover image + - character_cover: whether to set the character cover image + - override_scene_cover: whether to override an existing scene cover image (requires scene_cover=true) + - override_character_cover: whether to override an existing character cover image (requires character_cover=true) + - default_avatar: whether to set the default avatar image + - current_avatar: whether to set the current avatar image + - override_default_avatar: whether to override the default avatar image (requires default_avatar=true) + - override_current_avatar: whether to override the current avatar image (requires current_avatar=true) Outputs: - context: the asset attachment context object + - asset_name: the asset name (passed through) + - tags: list of tags (passed through) - allow_auto_attach: whether to allow automatic attachment (passed through) - allow_override: whether to allow overriding existing assets (passed through) - delete_old: whether to delete old assets (passed through) - message_ids: list of message IDs (passed through) + - scene_cover: scene cover flag (passed through) + - character_cover: character cover flag (passed through) + - override_scene_cover: scene cover override flag (passed through) + - override_character_cover: character cover override flag (passed through) + - default_avatar: default avatar flag (passed through) + - current_avatar: current avatar flag (passed through) + - override_default_avatar: default avatar override flag (passed through) + - override_current_avatar: current avatar override flag (passed through) Note: - When message_ids is empty, assets will be attached to the most recent appropriate message @@ -1277,7 +1304,29 @@ class MakeAssetAttachmentContext(Node): @register("assets/SetCoverImage") class SetCoverImage(Node): """ - Set the cover image for a scene or character. + Set the cover image for the scene and/or a character from an existing scene asset. + + If `set_on_scene` is true the asset becomes the scene cover image; if a + character is provided the asset becomes that character's cover image. The + override flags control whether an existing cover image is replaced. + + Inputs: + + - state: The graph state + - asset_id: The id of the asset to use as the cover image + - set_on_scene: Whether to set the cover image on the scene (optional) + - override_scene: Whether to override an existing scene cover image (optional) + - override_character: Whether to override an existing character cover image (optional) + - character: The character to set the cover image for (optional) + + Outputs: + + - state: The state input, passed through + - asset_id: The asset id, passed through + - set_on_scene: The set_on_scene input, passed through + - override_scene: The override_scene input, passed through + - override_character: The override_character input, passed through + - character: The character input, passed through """ @pydantic.computed_field(description="Node style") @@ -1458,18 +1507,15 @@ class SetAvatarImage(Node): @register("assets/UpdateMessageAsset") class UpdateMessageAsset(Node): """ - Update the asset_id and asset_type of messages in the scene history + Update the asset_id of messages in the scene history + + Messages that aren't found are skipped rather than raising an error. Inputs: - state: graph state (required) - message_ids: List of message IDs to update - asset_id: The new asset ID - - asset_type: The type of asset (e.g., "avatar") - - Properties: - - - asset_type: The type of asset (predefined choices) Outputs: @@ -1477,7 +1523,6 @@ class UpdateMessageAsset(Node): - messages: List of updated message objects - message_ids: List of message IDs (passthrough) - asset_id: The asset ID (passthrough) - - asset_type: The asset type (passthrough) """ @pydantic.computed_field(description="Node style") @@ -1489,15 +1534,6 @@ class UpdateMessageAsset(Node): icon="F0287", ) - class Fields: - asset_type = PropertyField( - name="asset_type", - description="The type of asset", - type="str", - default="avatar", - choices=["avatar", "card", "scene_illustration", "__keep__"], - ) - def __init__(self, title="Update Message Assets", **kwargs): super().__init__(title=title, **kwargs) @@ -1506,8 +1542,6 @@ class UpdateMessageAsset(Node): self.add_input("message_ids", socket_type="list", optional=True) self.add_input("asset_id", socket_type="str") - self.set_property("asset_type", "avatar") - self.add_output("state") self.add_output("messages", socket_type="list") self.add_output("message_ids", socket_type="list") diff --git a/src/talemate/game/engine/nodes/context_id.py b/src/talemate/game/engine/nodes/context_id.py index 3940418c..3e951eb3 100644 --- a/src/talemate/game/engine/nodes/context_id.py +++ b/src/talemate/game/engine/nodes/context_id.py @@ -31,7 +31,24 @@ log = structlog.get_logger("talemate.game.engine.nodes.context_id") @register("context_id/RenderContextIDs") class RenderContextIDs(Node): """ - Render a list of context ID items + Render a list of context ID items to prompt-ready text using the + `common.context_id_items` prompt template. + + A single item may be passed instead of a list and will be wrapped + automatically. + + Inputs: + + - items: List of context ID items to render (a single item is also accepted) + + Properties: + + - display_mode: How to format the rendered items (compact, subsection, or normal) + + Outputs: + + - items: The list of items that was rendered + - rendered: The rendered text """ class Fields: @@ -72,7 +89,21 @@ class RenderContextIDs(Node): @register("context_id/CharacterContextIDs") class CharacterContext(Node): """ - A context ID for a character + Unpack a character into context ID items for its various context entries + (attributes, details, description, acting instructions and example dialogue). + + Inputs: + + - character: The character to get context ID items for + + Outputs: + + - character: The character input, passed through + - attributes: List of context ID items for the character's attributes + - details: List of context ID items for the character's details + - description: Context ID item for the character's description + - acting_instructions: Context ID item for the character's acting instructions + - example_dialogue: List of context ID items for the character's example dialogue """ def __init__(self, title="Character Context IDs", **kwargs): @@ -109,7 +140,28 @@ class CharacterContext(Node): @register("context_id/ScanContextIDs") class ScanContextIDs(Node): """ - Scan text for context IDs and return them in various formats + Scan text for context ID references, resolve them against the active scene + and return the results in various formats. + + Resolved items are rendered through the `common.context_id_items` prompt + template and also wrapped in a dynamic instruction that can be injected + into agent prompts. + + Inputs: + + - text: The text to scan for context IDs + + Properties: + + - header: The header (title) of the generated dynamic instruction + - display_mode: How to format the rendered items (compact, subsection, or normal) + + Outputs: + + - dynamic_instruction: Dynamic instruction containing the rendered context items + - rendered: The rendered text of the resolved items + - context_id_items: List of resolved context ID items + - unresolved: List of context ID references that could not be resolved """ class Fields: @@ -170,7 +222,17 @@ class ScanContextIDs(Node): @register("context_id/CompressContextIDPart") class CompressContextIDPart(Node): """ - Compress a context ID part + Compress a context ID part name into the short hash identifier used inside + context IDs (truncated SHA256). + + Inputs: + + - part: The part name to compress + + Outputs: + + - uncompressed: The original part, passed through + - compressed: The compressed part """ def __init__(self, title="Compress Context ID Part", **kwargs): @@ -183,14 +245,31 @@ class CompressContextIDPart(Node): async def run(self, state: GraphState): part = self.require_input("part") - part = compress_name(part) - self.set_output_values({"uncompressed": part, "compressed": part}) + compressed = compress_name(part) + self.set_output_values({"uncompressed": part, "compressed": compressed}) @register("context_id/PathToContextID") class PathToContextID(Node): """ - Convert a path to a context ID + Resolve a context ID path string into a context ID item and retrieve its + current value from the active scene. + + Inputs: + + - path: The context ID path to resolve + + Outputs: + + - context_id: The resolved context ID (None if the path could not be resolved) + - context_id_item: The resolved context ID item (None if the path could not be resolved) + - human_id: Human readable identifier of the resolved item + - as_dict: The resolved item as a dictionary (empty if not resolved) + - name: The name of the resolved item + - value: The current value stored at the context ID + - exists: Whether the path resolved to an existing context ID item + - context_type: The context type of the resolved item + - path: The path input, passed through """ class Fields: @@ -256,7 +335,17 @@ class PathToContextID(Node): @register("context_id/ContextIDMetaEntries") class ContextIDMetaEntries(Node): """ - Get all defined context ID meta entries + Get all defined context ID meta entries for the active scene, sorted by + context ID. + + Properties: + + - filter_creative: If true, only include meta entries flagged as creative + + Outputs: + + - meta_entries: List of context ID meta entries + - context_id_types: List of unique context ID types found in the entries """ class Fields: @@ -379,7 +468,25 @@ class ContextIDActionBase(Node): @register("context_id/ContextIDSetValue") class ContextIDSetValue(ContextIDActionBase): """ - Set the value of a context ID + Set the value of a context ID entry in the active scene. + + The target entry can be given either as a context ID item or as a path + string (one of the two must be set). Raises an error if the entry cannot + be resolved. + + Inputs: + + - state: The graph state + - context_id_item: The context ID item to set the value for (optional) + - path: The context ID path to set the value for (optional) + - value: The value to set + + Outputs: + + - state: The state input, passed through + - context_id_item: The context ID item that was set + - path: The path input, passed through + - value: The value that was set """ class Fields: @@ -427,7 +534,29 @@ class ContextIDSetValue(ContextIDActionBase): @register("context_id/SetPin") class SetPin(ContextIDActionBase): """ - Create or update a pin + Create or update a world state pin for a context entry, pinning it into the + AI context. The target entry can be given either as a context ID item or as + a path (one of the two must be set). + + Inputs: + + - state: The graph state + - context_id_item: The context ID item to pin (optional) + - path: The context ID path to pin (optional) + - condition: AI-evaluated condition that determines whether the pin is active (optional) + - condition_state: The current evaluation state of the condition (optional) + - active: Whether the pin is active (optional) + - decay: Number of cycles the pin remains active once set, 0 for no decay (optional) + + Outputs: + + - state: The state input, passed through + - context_id_item: The context ID item that was pinned + - path: The path input, passed through + - condition: The condition that was set + - condition_state: The condition state that was set + - active: The active state that was set + - decay: The decay that was set """ class Fields(ContextIDActionBase.Fields): @@ -512,7 +641,23 @@ class SetPin(ContextIDActionBase): @register("context_id/RemovePin") class RemovePin(ContextIDActionBase): """ - Remove a pin + Remove the world state pin for a context entry and reload the scene's + active pins. + + The target entry can be given either as a context ID item or as a path + string (one of the two must be set). + + Inputs: + + - state: The graph state + - context_id_item: The context ID item to unpin (optional) + - path: The context ID path to unpin (optional) + + Outputs: + + - state: The state input, passed through + - context_id_item: The context ID item that was unpinned + - path: The path input, passed through """ def __init__(self, title="Remove Pin", **kwargs): @@ -535,7 +680,23 @@ class RemovePin(ContextIDActionBase): @register("context_id/IsPinActive") class IsPinActive(ContextIDActionBase): """ - Check if a pin is active + Check whether the world state pin for a context entry is currently active. + + The target entry can be given either as a context ID item or as a path + string (one of the two must be set). + + Inputs: + + - state: The graph state + - context_id_item: The context ID item to check (optional) + - path: The context ID path to check (optional) + + Outputs: + + - state: The state input, passed through + - context_id_item: The context ID item that was checked + - path: The path input, passed through + - active: Whether the pin is active """ def __init__(self, title="Is Pin Active", **kwargs): diff --git a/src/talemate/game/engine/nodes/core/__init__.py b/src/talemate/game/engine/nodes/core/__init__.py index 500a958d..2ccac0fd 100644 --- a/src/talemate/game/engine/nodes/core/__init__.py +++ b/src/talemate/game/engine/nodes/core/__init__.py @@ -967,6 +967,26 @@ class Router(Node): @register("core/Input") class Input(Node): + """ + Defines an input socket for the containing node module (graph). + + When the module is used as a node inside another graph, each Input node + becomes an input socket on the module node, and the value passed into that + socket is emitted from this node's `value` output inside the module. + + Properties: + + - input_type: The socket type of the module input socket + - input_name: The name of the module input socket + - input_optional: Whether the module input socket is optional + - input_group: Socket group name - of sockets sharing a group, only one needs to be connected (optional) + - num: Sort order of the socket on the module node + + Outputs: + + - value: The value received through the module's input socket + """ + class Fields: input_type = PropertyField( name="input_type", @@ -1023,6 +1043,24 @@ class Input(Node): @register("core/Output") class Output(Node): + """ + Defines an output socket for the containing node module (graph). + + When the module is used as a node inside another graph, each Output node + becomes an output socket on the module node, and the value connected to this + node's `value` input is exposed through that socket. + + Inputs: + + - value: The value to expose through the module's output socket + + Properties: + + - output_type: The socket type of the module output socket + - output_name: The name of the module output socket + - num: Sort order of the socket on the module node + """ + class Fields: output_type = PropertyField( name="output_type", @@ -1069,15 +1107,17 @@ class ModuleProperty(Node): """ A node that can be placed to define a property of a Graph + When the graph is used as a node module inside another graph, each + ModuleProperty node becomes a property on the module node. + Properties: - property_name: The name of the property - - proeprty_type: The type of the property + - property_type: The type of the property - default: The default value of the property - choices: The choices of the property - - readonly: Whether the property is readonly - - ephemeral: Whether the property is ephemeral - - required: Whether the property is required + - description: The description of the property + - num: Sort order of the property on the module node Outputs: @@ -1202,7 +1242,16 @@ class Route(Node): @register("core/Watch") class Watch(Node): """ - Outputs the value of the input socket + Passes the input value through unchanged and logs it for inspection + when the graph is running in the node editor (creative mode). + + Inputs: + + - value: The value to watch + + Outputs: + + - value: The value, passed through """ @pydantic.computed_field(description="Node style") @@ -1377,7 +1426,16 @@ class Comment(pydantic.BaseModel): @register("util/ModuleStyle") class ModuleStyle(Node): """ - An isolated node that will define the Graph's style + An isolated node that defines the visual style of the graph it is + placed in, when that graph is used as a node module inside another + graph. + + Properties: + + - title_color: The title bar color of the module node + - node_color: The body color of the module node + - auto_title: Title template for the module node (can reference property values, e.g. {property_name}) + - icon: The icon of the module node (Material Design icon codepoint) """ _isolated: ClassVar[bool] = True @@ -2077,6 +2135,8 @@ class Graph(NodeBase): node_state, node, state, error=traceback.format_exc() ) except StageExit: + if emit_state: + await self.node_state_pop(node_state, node, state) break except Exception as exc: if emit_state: diff --git a/src/talemate/game/engine/nodes/data.py b/src/talemate/game/engine/nodes/data.py index 0d8d3de8..00e1eab1 100644 --- a/src/talemate/game/engine/nodes/data.py +++ b/src/talemate/game/engine/nodes/data.py @@ -25,11 +25,16 @@ class Sort(Node): """ Sorts a list of items + If sort_keys is provided, items are sorted by the named attributes + (read via getattr) in order. A sort_keys string input is parsed as JSON. + Without sort_keys, items are sorted by their natural order. The input + list is not modified; a sorted copy is returned. + Inputs: + - state: The graph state - items: List of items to sort - - sort_keys: List of keys to sort by - - reverse: Reverse sort + - sort_keys: Attribute name(s) to sort by - list, or JSON string (optional) Properties: @@ -38,6 +43,7 @@ class Sort(Node): Outputs: + - state: The state input, passed through - sorted_items: Sorted list of items """ @@ -67,6 +73,7 @@ class Sort(Node): self.set_property("reverse", False) self.set_property("sort_keys", UNRESOLVED) + self.add_output("state") self.add_output("sorted_items", socket_type="list") async def run(self, state: GraphState): @@ -91,7 +98,12 @@ class Sort(Node): else: new_items.sort(reverse=reverse) - self.set_output_values({"sorted_items": new_items}) + self.set_output_values( + { + "state": self.get_input_value("state"), + "sorted_items": new_items, + } + ) @register("data/JSON") @@ -196,8 +208,8 @@ class DictGet(Node): Outputs: - - value: Value - + - value: The value for the key, or None if the key is not present + - key: The key input, passed through """ class Fields: @@ -360,9 +372,9 @@ class DictPop(Node): Outputs: - - dict: Dictionary - - value: Value - - key: Key + - dict: The dictionary with the key removed + - value: The popped value, or None if the key was not present + - key: The key input, passed through """ class Fields: @@ -459,6 +471,28 @@ class DictSet(Node): class DictUpdate(Node): """ Updates a dictionary from a list of other dictionaries + + Each dictionary in the list is applied in order. By default this is a + shallow dict.update(); enable merge to deep-merge nested dictionaries + instead of replacing them. By default the target dictionary is modified + in place; enable create_copy to leave the input untouched. + + Inputs: + + - state: The graph state + - dict: The target dictionary to update + - dicts: List of dictionaries to apply to the target + + Properties: + + - create_copy: Update a copy of the target instead of modifying it in place + - merge: Perform a deep merge instead of a shallow update + + Outputs: + + - state: The state input, passed through + - dict: The updated dictionary + - dicts: The dicts input, passed through """ class Fields: @@ -531,7 +565,10 @@ class DictUpdate(Node): @register("data/MakeDict") class MakeDict(Node): """ - Creates a new empty dictionary + Creates a new dictionary, optionally initialized from the data property + + The data property is deep-copied on every execution, so downstream + mutation of the dictionary does not alter the property. Inputs: @@ -539,11 +576,12 @@ class MakeDict(Node): Properties: - - data: Data to initialize the dictionary with + - data: Data to initialize the dictionary with (empty dict by default) Outputs: - - dict: Dictionary + - state: The state input, passed through + - dict: The new dictionary """ class Fields: @@ -562,6 +600,7 @@ class MakeDict(Node): self.set_property("data", {}) + self.add_output("state") self.add_output("dict", socket_type="dict") async def run(self, state: GraphState): @@ -569,7 +608,12 @@ class MakeDict(Node): # node's static `data` property across executions. new_dict = copy.deepcopy(self.get_property("data")) - self.set_output_values({"dict": new_dict}) + self.set_output_values( + { + "state": self.get_input_value("state"), + "dict": new_dict, + } + ) @register("data/Get") @@ -577,22 +621,25 @@ class Get(Node): """ Get a value from an object using getattr - Can be used on dictionaries as well. + Dictionaries are read by key (missing keys yield None). Lists, tuples + and sets are read by index - the attribute must be an integer, and an + out-of-range index yields UNRESOLVED. Any other object is read via + getattr, yielding None if the attribute does not exist. Inputs: - - object: Object - - attribute: Attribute + - object: Object to read from + - attribute: Attribute name, dict key, or index Properties: - - attribute: Attribute + - attribute: Attribute name, dict key, or index Outputs: - - value: Value - - attribute: Attribute - - object: Object + - value: The retrieved value + - attribute: The attribute input, passed through + - object: The object input, passed through """ @pydantic.computed_field(description="Node style") @@ -655,23 +702,25 @@ class Set(Node): """ Set a value on an object using setattr - Can be used on dictionaries as well. + Dictionaries are written by key. Lists are written by index - the + attribute must be an integer. Any other object is written via setattr. + The object is modified in place. Inputs: - - object: Object - - attribute: Attribute - - value: Value + - object: Object to modify + - attribute: Attribute name, dict key, or list index + - value: Value to set Properties: - - attribute: Attribute + - attribute: Attribute name, dict key, or list index Outputs: - - object: Object - - attribute: Attribute - - value: Value + - object: The modified object + - attribute: The attribute input, passed through + - value: The value input, passed through """ @pydantic.computed_field(description="Node style") @@ -731,9 +780,29 @@ class Set(Node): @register("data/SetConditional") class SetConditional(Set): """ - Set a value on an object using setattr + Same as Set, but with a state passthrough so it can be placed in a + conditional execution chain - Can be used on dictionaries as well. + Dictionaries are written by key, lists by integer index, any other + object via setattr. The object is modified in place. + + Inputs: + + - state: The graph state + - object: Object to modify + - attribute: Attribute name, dict key, or list index + - value: Value to set + + Properties: + + - attribute: Attribute name, dict key, or list index + + Outputs: + + - state: The state input, passed through + - object: The modified object + - attribute: The attribute input, passed through + - value: The value input, passed through """ def __init__(self, title="Set Conditional", **kwargs): @@ -752,15 +821,25 @@ class SetConditional(Set): @register("data/MakeList") class MakeList(Node): """ - Creates a new empty list + Creates a new list, optionally initialized from the items property + + The items property is deep-copied on every execution, so downstream + mutation of the list does not alter the property. Inputs: - state: Graph state + - item_type: Declared type of the items in the list (optional) + + Properties: + + - item_type: Declared type of the items in the list + - items: Initial items in the list (empty by default) Outputs: - - list: List + - state: The state input, passed through + - list: The new list """ class Fields: @@ -789,6 +868,7 @@ class MakeList(Node): self.set_property("item_type", "any") self.set_property("items", []) + self.add_output("state") self.add_output("list", socket_type="list") async def run(self, state: GraphState): @@ -803,7 +883,12 @@ class MakeList(Node): # node's static `items` property across executions. new_list = copy.deepcopy(self.get_property("items")) - self.set_output_values({"list": new_list}) + self.set_output_values( + { + "state": self.get_input_value("state"), + "list": new_list, + } + ) @register("data/ListAppend") @@ -813,13 +898,13 @@ class ListAppend(Node): Inputs: - - list: List - - item: Item + - list: List to append to (optional - a new list is created if not provided) + - item: Item to append Outputs: - - list: List - - item: Item + - list: The list with the item appended + - item: The item input, passed through """ def __init__(self, title="List Append", **kwargs): @@ -946,8 +1031,9 @@ class CapLength(Node): Inputs: + - state: The graph state - iterable: Iterable (string or list) to cap - - max_length: Maximum length to cap the iterable to + - max_length: Maximum length to cap the iterable to (optional) Properties: @@ -956,6 +1042,7 @@ class CapLength(Node): Outputs: + - state: The state input, passed through - capped: Capped iterable (same type as input) """ @@ -1030,18 +1117,22 @@ class SelectItem(Node): """ Node that takes in a list of items and selects one based on the selection function - - random - - cycle - - sorted_cycle + - random: picks a random item + - cycle: picks the next item on each execution, wrapping around + - sorted_cycle: like cycle, but iterates the items in sorted order + - direct: picks the item at the index property + + The cycle position is stored in the graph state per node, so it persists + across executions within a run. Inputs: - items: List of items - - except: Item to exclude from selection + - except: Item (or list of items) to exclude from selection Properties: - - index: Index of item to select + - index: Index of item to select (used by the direct selection function) - selection_function: Selection function - cycle_index: Cycle index (ephemeral, read-only) @@ -1150,7 +1241,23 @@ class SelectItem(Node): class DictCollector(DynamicSocketNodeBase): """ Collects key-value pairs into a dictionary with dynamic inputs. - Connect tuple outputs like (key, value) to the dynamic input slots. + New item sockets appear as connections are made. + + For each connected item the key is inferred: if the value is a + (key, value) tuple (e.g., from Make Key-Value Pair) that key is used; + otherwise the key is derived from the source socket - for sockets named + `value`, the source node's `name`, `key` or `attribute` input is used, + falling back to the source socket name. + + Inputs: + + - dict: Base dictionary to collect into (optional - a new dictionary + is created if not provided) + - item{i}: Dynamic inputs holding the values to collect + + Outputs: + + - dict: The collected dictionary """ dynamic_input_label: str = "item{i}" @@ -1200,7 +1307,18 @@ class DictCollector(DynamicSocketNodeBase): class ListCollector(DynamicSocketNodeBase): """ Collects items into a list with dynamic inputs. - Connect tuple outputs like (key, value) to the dynamic input slots. + New item sockets appear as connections are made, and each connected + value is appended to the list in socket order. + + Inputs: + + - list: Base list to append to (optional - a new list is created if + not provided) + - item{i}: Dynamic inputs holding the values to collect + + Outputs: + + - list: The collected list """ dynamic_input_label: str = "item{i}" @@ -1241,7 +1359,25 @@ class ListCollector(DynamicSocketNodeBase): @register("data/CombineLists") class CombineList(DynamicSocketNodeBase): """ - Combines a list of lists into a single list + Combines multiple lists into a single list + + Each connected dynamic input must be a list; their items are appended + to the result in socket order. + + Inputs: + + - list: Base list to extend (optional - a new list is created if not + provided) + - list{i}: Dynamic inputs holding the lists to combine + + Properties: + + - create_copy: Extend a copy of the base list instead of modifying it + in place + + Outputs: + + - list: The combined list """ dynamic_input_label: str = "list{i}" @@ -1288,6 +1424,15 @@ class CombineList(DynamicSocketNodeBase): class DictKeyValuePairs(Node): """ Creates a list of key-value pairs from a dictionary + + Inputs: + + - dict: Dictionary to convert + + Outputs: + + - dict: The dict input, passed through + - kvs: List of (key, value) tuples """ def __init__(self, title="Dict To Key-Value Pairs", **kwargs): @@ -1310,6 +1455,22 @@ class MakeKeyValuePair(Node): """ Creates a key-value pair tuple from separate key and value inputs. Outputs a tuple (key, value) that can be connected to DictCollector. + + Inputs: + + - key: The key (optional) + - value: The value (optional) + + Properties: + + - key: The key + - value: The value + + Outputs: + + - kv: The (key, value) tuple + - key: The key input, passed through + - value: The value input, passed through """ class Fields: @@ -1361,12 +1522,18 @@ class UUID(Node): """ Generates a UUID string + Inputs: + + - state: The graph state + - max_length: Maximum number of characters to return + Properties: - max_length: Maximum number of characters to return (optional, if not set returns full UUID) Outputs: + - state: The state input, passed through - uuid: A UUID string (e.g., "550e8400-e29b-41d4-a716-446655440000") """ @@ -1385,6 +1552,7 @@ class UUID(Node): self.add_input("state", optional=True) self.add_input("max_length", socket_type="int", optional=True) self.set_property("max_length", 36) + self.add_output("state") self.add_output("uuid", socket_type="str") async def run(self, state: GraphState): @@ -1394,13 +1562,36 @@ class UUID(Node): if max_length > 0: uuid_string = uuid_string[:max_length] - self.set_output_values({"uuid": uuid_string}) + self.set_output_values( + { + "state": self.get_input_value("state"), + "uuid": uuid_string, + } + ) @register("data/UpdateObject") class UpdateObject(DynamicSocketNodeBase): """ - Updates an object with dynamic inputs. + Updates an object (dict or attribute-based) with values collected from + dynamic inputs. New item sockets appear as connections are made. + + For each connected item the key to update is inferred: if the value is a + (key, value) tuple that key is used; otherwise the key is derived from the + source socket - for sockets named `value`, the source node's `name`, `key` + or `attribute` input is used, falling back to the source socket name. Dicts + are updated by key, other objects via setattr. + + Inputs: + + - state: The graph state + - object: The object to update + - item{i}: Dynamic inputs holding the values to set on the object + + Outputs: + + - state: The state input, passed through + - object: The updated object """ dynamic_input_label: str = "item{i}" diff --git a/src/talemate/game/engine/nodes/event.py b/src/talemate/game/engine/nodes/event.py index 875b27b6..f1159ef0 100644 --- a/src/talemate/game/engine/nodes/event.py +++ b/src/talemate/game/engine/nodes/event.py @@ -293,8 +293,17 @@ class EmitSystemMessage(EmitStatus): Inputs: - state: The graph state + - message_title: The title of the message (optional) - message: The message text to emit + Properties: + + - message_title: The title of the message + - message: The message text to emit + - font_color: The color of the message + - icon: The icon of the message + - display: The display style of the message (text, tonal or flat) + - as_markdown: Whether to render the message as markdown Outputs: @@ -387,6 +396,10 @@ class EmitStatusConditional(EmitStatus): """ Emits a status message if a condition is met + Unlike EmitStatus, the `state` input is required, so the message is + only emitted when the state input actually receives a value - + connect it through a conditional branch to gate the emission. + Inputs: - state: The graph state @@ -429,7 +442,7 @@ class EmitSceneStatus(Node): Outputs: - - state: The scene status object + - state: The state input, passed through """ def __init__(self, title="Emit Scene Status", **kwargs): @@ -480,40 +493,21 @@ class EmitWorldEditorSync(Node): @register("event/EmitAgentMessage") class EmitAgentMessage(Node): """ - Emits an agent message - - EXAMPLE - emit("agent_message", - message=message, - data={ - "uuid": str(uuid.uuid4()), - "agent": "editor", - "header": "Removed repetition", - "color": "highlight4", - }, - meta={ - "action": "revision_dedupe", - "similarity": dedupe['similarity'], - "threshold": self.revision_repetition_threshold, - "range": self.revision_repetition_range, - }, - websocket_passthrough=True - ) - + Emits an agent message to the UX Inputs: - state: The graph state - message: The message text to emit - - agent: The agent + - agent: The agent (agent object or agent name) - header: The header of the message - - color: The color of the message + - message_color: The color of the message - meta: The meta data of the message Outputs: + - state: The state input, passed through - emitted: Whether the message was emitted (True) or not (False) - """ class Fields: @@ -528,7 +522,7 @@ class EmitAgentMessage(Node): name="agent", type="str", default="", - description="The name of the agent to get the client for", + description="The agent the message is attributed to", choices=[], generate_choices=lambda: get_agent_types(), ) @@ -573,6 +567,7 @@ class EmitAgentMessage(Node): self.set_property("message_color", "grey") self.set_property("meta", {}) + self.add_output("state") self.add_output("emitted", socket_type="bool") async def run(self, state: GraphState): @@ -604,6 +599,7 @@ class EmitAgentMessage(Node): self.set_output_values( { + "state": self.get_input_value("state"), "emitted": True, } ) diff --git a/src/talemate/game/engine/nodes/focal.py b/src/talemate/game/engine/nodes/focal.py index 89441946..2b94fb9b 100644 --- a/src/talemate/game/engine/nodes/focal.py +++ b/src/talemate/game/engine/nodes/focal.py @@ -43,6 +43,19 @@ SOCKET_TYPES.extend( class FocalArgument(FunctionArgument): """ Represents an argument to an AI function. + + Extends the function argument with instructions that tell the AI how + to fill the argument during AI function calling. + + Properties: + + - typ: The type of the argument + - name: The name of the argument + - instructions: The instructions for the argument + + Outputs: + + - value: The value of the argument (during function execution) """ class Fields(FunctionArgument.Fields): @@ -66,9 +79,12 @@ class Focal(Node): """ Main node for calling AI functions using the FOCAL system. + Either a template or a prompt must be provided. + Inputs: - state: The current graph state - template: The prompt template name; This template will be used to generate the prompt that facilitates the AI function call(s) + - prompt: A Prompt instance to use instead of a template - callbacks: A list of focal.Callback instances that define the functions to call - agent: The agent to use for the AI function call - template_vars: A dictionary of variables to use in the template @@ -77,11 +93,14 @@ class Focal(Node): Properties: - template: The prompt template name - max_calls: The maximum number of calls to make + - retries: The number of retries to make - response_length: The maximum length of the response + - max_concurrent: Maximum number of concurrent callback executions Outputs: - state: The current graph state - - calls: The list of calls made + - calls: The list of calls made (focal.Call instances) + - call_payloads: The payload dictionaries of the calls made - response: The raw response from the processed prompt """ @@ -138,7 +157,7 @@ class Focal(Node): def setup(self): self.add_input("state") - self.add_input("template", socket_typoe="str", optional=True) + self.add_input("template", socket_type="str", optional=True) self.add_input("prompt", socket_type="prompt", optional=True) self.add_input("callbacks", socket_type="list") self.add_input("agent", socket_type="agent") @@ -240,6 +259,20 @@ class Metadata(Node): Represents metadata within a callback in the focal system. Allowing to specify instructions and examples for the callback. + + The node does no work when executed - it is discovered inside the + function graph when an AI function callback is built, and its + instructions and examples are attached to the callback. + + Inputs: + - state: Connects the node into the function graph so it can be discovered + + Properties: + - instructions: The instructions for the callback + - examples: The examples for the callback + + Outputs: + - state: Not populated at runtime """ class Fields: @@ -275,11 +308,17 @@ class Callback(Node): """ Defines an AI function callback for use with the FOCAL system. + Arguments, instructions and examples are collected from the focal + Argument and Metadata nodes inside the supplied function graph. + Inputs: - fn: The function to call (Returned from an GetFunction node) + - name: The name of the callback (optional, overrides the property) Properties: - name: The name of the callback as the AI will see it + - allow_multiple_calls: Whether the function can be called multiple times + - allow_concurrent: Whether calls to this function can run concurrently Outputs: - callback: The focal.Callback instance @@ -373,6 +412,19 @@ class Callback(Node): class UnpackCall(Node): """ Unpacks a focal.Call instance + + Inputs: + + - call: The focal.Call instance to unpack + + Outputs: + + - name: The name of the call + - arguments: The arguments of the call + - result: The result of the call + - uid: The UID of the call + - called: Whether the call was made + - error: The error message if the call failed """ def __init__(self, title="Unpack AI Function Call", **kwargs): @@ -467,6 +519,23 @@ class ProcessCall(Node): class CollectResults(Node): """ Collects the results of a list of calls + + If a name is provided, only results from calls with that name are + collected. + + Inputs: + + - calls: The list of calls (focal.Call instances) + - name: Only collect results from calls with this name (optional) + + Properties: + + - name: Only collect results from calls with this name + + Outputs: + + - calls: The calls input, passed through + - results: The list of collected call results """ class Fields: diff --git a/src/talemate/game/engine/nodes/history.py b/src/talemate/game/engine/nodes/history.py index cecdd381..0bef18ff 100644 --- a/src/talemate/game/engine/nodes/history.py +++ b/src/talemate/game/engine/nodes/history.py @@ -40,9 +40,9 @@ class PushHistory(Node): """ Push a message to the scene history at the lowest (e.g., dialogue) layer - This will emit the message to the the sreen as part of the ongoing scene + This will emit the message to the screen as part of the ongoing scene - Inputs: + Inputs: - message: The message to push @@ -166,6 +166,13 @@ class PopHistory(Node): class HasHistory(Node): """ Check if the scene has history + + Looks for a character, narrator or context_investigation message within + the most recent 100 history entries. + + Outputs: + + - has_history: True if such a message was found """ def __init__(self, title="Scene Has History", **kwargs): @@ -286,7 +293,29 @@ class LastMessageOfType(Node): @register("scene/history/UnpackArchiveEntry") class UnpackArchiveEntry(Node): """ - Unpack an archive entry + Unpack an archive (summarized history) entry into its individual fields and + build a context ID for it. + + Inputs: + + - entry: The archive entry to unpack + + Outputs: + + - entry: The entry, passed through + - id: The id of the entry + - text: The text of the entry + - index: The index of the entry within its history layer + - layer: The history layer the entry belongs to + - start: Index of the first source entry covered by the entry (None for static entries) + - end: Index of the last source entry covered by the entry (None for static entries) + - ts_start: Starting timestamp of the covered range (ISO 8601 duration) + - ts_end: Ending timestamp of the covered range (ISO 8601 duration) + - ts: Timestamp of the entry (ISO 8601 duration) + - time: Human readable time relative to the current scene time + - time_start: Human readable starting time of the covered range + - time_end: Human readable ending time of the covered range + - context_id: Context ID for the entry (static or dynamic depending on the entry type) """ def __init__(self, title="Unpack Archive Entry", **kwargs): @@ -326,6 +355,15 @@ class UnpackArchiveEntry(Node): class StaticArchiveEntries(Node): """ Get the static scene history entries + + Static entries are manually written archive entries that predate the + summarized history. Collection stops at the first summarized + (dynamic) entry. Entries are annotated with a human readable time + relative to the current scene time. + + Outputs: + + - entries: The list of static archive entries """ def __init__(self, title="Static Archive Entries", **kwargs): @@ -355,7 +393,28 @@ class StaticArchiveEntries(Node): @register("scene/history/CreateStaticArchiveEntry") class CreateStaticArchiveEntry(Node): """ - Create a static archive entry + Create a static (manually written) archive entry in the scene's base history, + dated a given amount of time before the current scene time. + + The time amount and unit are converted to an ISO 8601 duration offset. The + entry must predate the summarized history. + + Inputs: + + - state: The graph state + - time_unit: The unit of time for the offset (minute, hour, day, week, month, year) + - time_amount: The amount of time for the offset + - text: The text of the entry + + Outputs: + + - state: The state input, passed through + - entry: The created archive entry + - offset: The ISO 8601 duration offset that was applied + - context_id: Context ID for the created entry + - time_unit: The time unit input, passed through + - time_amount: The time amount input, passed through + - text: The text input, passed through """ class Fields: @@ -430,6 +489,22 @@ class CreateStaticArchiveEntry(Node): class RemoveStaticArchiveEntry(Node): """ Remove a static archive entry + + The entry can be specified either directly or via a context ID item - + at least one of the two is required. Raises an error if the resolved + entry is not a static history entry. + + Inputs: + + - state: The graph state + - entry: The archive entry to remove + - context_id_item: A context ID item referencing a static history entry + + Outputs: + + - state: The graph state + - entry: The removed entry + - context_id_item: The context ID item input, passed through """ def __init__(self, title="Remove Static Archive Entry", **kwargs): @@ -487,9 +562,9 @@ class ContextHistory(Node): Properties: - - keep_direcctor_messages: Whether to keep director messages + - keep_director_messages: Whether to keep director messages - keep_investigation_messages: Whether to keep investigation messages - - keep_reinforcment_messages: Whether to keep reinforcement messages + - keep_reinforcement_messages: Whether to keep reinforcement messages - show_hidden: Whether to show hidden messages - min_dialogue_length: The minimum length of dialogue to keep, this will ensure that there are always N dialogue messages in the history regardless of whether they are covered by summarization. (default 5) - label_chapters: Whether to label chapters in the summarized history @@ -522,14 +597,14 @@ class ContextHistory(Node): name="keep_investigation_messages", description="Whether to keep investigation messages", type="bool", - default=True, + default=False, ) keep_reinforcement_messages = PropertyField( name="keep_reinforcement_messages", description="Whether to keep reinforcement messages", type="bool", - default=True, + default=False, ) show_hidden = PropertyField( diff --git a/src/talemate/game/engine/nodes/logic.py b/src/talemate/game/engine/nodes/logic.py index c7146712..2aca9d4d 100644 --- a/src/talemate/game/engine/nodes/logic.py +++ b/src/talemate/game/engine/nodes/logic.py @@ -143,6 +143,7 @@ class ORRouter(LogicalRouter): - b: flag B - c: flag C - d: flag D + - value: value to route to the activated output (optional) Outputs: @@ -172,6 +173,7 @@ class ANDRouter(LogicalRouter): - b: flag B - c: flag C - d: flag D + - value: value to route to the activated output (optional) Outputs: @@ -282,9 +284,11 @@ class Switch(Node): @register("core/RSwitch") class RSwitch(Node): """ - Checks if the a value is truthy + Checks if the check value is truthy - If the value is truthy, the yes input is routed to the output, otherwise the no input is routed to the output + If the check value is truthy, the yes input is routed to the output, otherwise the no input is routed to the output + + A value is considered truthy unless it is None, False, or UNRESOLVED (0 and empty strings count as truthy) Inputs: @@ -328,9 +332,11 @@ class RSwitch(Node): @register("core/RSwitchAdvanced") class RSwitchAdvanced(Node): """ - Checks if the a value is truthy + Checks if the check value is truthy - If the value is truthy, the yes input is routed to yes output and the no output is deactivated, otherwise the no input is routed to the no output and the yes output is deactivated + If the check value is truthy, the yes input is routed to the yes output, otherwise the no input is routed to the no output (the other output stays UNRESOLVED) + + A value is considered truthy unless it is None, False, or UNRESOLVED (0 and empty strings count as truthy) Inputs: @@ -384,6 +390,9 @@ class Case(Node): Route a value based on attribute value check (exact match) like a switch / case statement. + When no attribute_name is set, the value is cast to a string before + comparison, so case values should be given as strings. + Inputs: - value: value to check @@ -402,6 +411,7 @@ class Case(Node): - b: if the value matches case B - c: if the value matches case C - d: if the value matches case D + - none: if the value matches no case """ class Fields: @@ -494,8 +504,13 @@ class Case(Node): @register("core/CaseRouter") class CaseRouter(Node): """ - Route specific input values based on a check value match. - Only the matching input is routed to its corresponding output, others are deactivated. + Route one of several input values based on a check value match, + like a switch / case statement with a single output. + + The check value (or its attribute) is cast to a string before comparison, + so case values should be given as strings. The input of the first matching + case is routed to the value output; if no case matches, the default input + is routed instead. Inputs: @@ -627,7 +642,11 @@ class CaseRouter(Node): @register("core/Coallesce") class Coallesce(Node): """ - Takes a list of values and returns the first non-UNRESOLVED value + Takes a list of values and returns the first truthy value + + A value is considered truthy unless it is None, False, or UNRESOLVED + (0 and empty strings count as truthy). If no input qualifies, the + output is UNRESOLVED. Inputs: @@ -638,7 +657,7 @@ class Coallesce(Node): Outputs: - - value: the first non-UNRESOLVED value + - value: the first truthy value """ def __init__(self, title="Coallesce", **kwargs): @@ -756,7 +775,9 @@ class AsBool(Node): try: value = bool(value) except Exception as e: - raise InputValueError(f"Failed to convert value to bool: {e}") + raise InputValueError( + self, "value", f"Failed to convert value to bool: {e}" + ) self.set_output_values({"value": value}) diff --git a/src/talemate/game/engine/nodes/number.py b/src/talemate/game/engine/nodes/number.py index a5aa2caf..15e1891b 100644 --- a/src/talemate/game/engine/nodes/number.py +++ b/src/talemate/game/engine/nodes/number.py @@ -82,12 +82,17 @@ class AsNumber(NumberNode): """Converts a value to a number Converts a value to a number, handling both string and numeric inputs. + A value that cannot be converted raises an error. Inputs: - value: The value to convert to a number - default: Fallback value used when ``value`` is unresolved or None + Properties: + + - number_type: Whether to convert to an "int" or a "float" (defaults to int) + Outputs: - value: The converted number value @@ -98,7 +103,7 @@ class AsNumber(NumberNode): name="number_type", description="Type of number to create", type="str", - default="float", + default="int", choices=["int", "float"], ) @@ -135,10 +140,12 @@ class BasicArithmetic(NumberNode): Properties: - operation: Arithmetic operation to perform (add, subtract, multiply, divide, power, modulo) + - a: Default first operand when not connected + - b: Default second operand when not connected Outputs: - - result: Result of the arithmetic operation + - result: Result of the arithmetic operation (division or modulo by zero raises an error) """ class Fields: @@ -151,10 +158,10 @@ class BasicArithmetic(NumberNode): ) a = PropertyField( - name="a", description="First value to compare", type="number", default=0 + name="a", description="First operand", type="number", default=0 ) b = PropertyField( - name="b", description="Second value to compare", type="number", default=0 + name="b", description="Second operand", type="number", default=0 ) def setup(self): @@ -215,6 +222,8 @@ class Compare(NumberNode): - operation: Comparison operation to perform (equals, not_equals, greater_than, less_than, greater_equal, less_equal) - tolerance: Tolerance for floating point equality comparison + - a: Default first value when not connected + - b: Default second value when not connected Outputs: @@ -356,6 +365,14 @@ class Sum(NumberNode): - result: The sum of all numbers in the list """ + class Fields: + numbers = PropertyField( + name="numbers", + description="List of numbers to sum", + type="list", + default=[], + ) + def setup(self): self.add_input("numbers", socket_type="list") self.add_output("result", socket_type="int,float") @@ -389,7 +406,7 @@ class Average(NumberNode): Outputs: - - result: The calculated average value + - result: The calculated average value (mode yields None when there is no unique mode) """ class Fields: diff --git a/src/talemate/game/engine/nodes/packaging.py b/src/talemate/game/engine/nodes/packaging.py index c82f5ecc..98ff59e7 100644 --- a/src/talemate/game/engine/nodes/packaging.py +++ b/src/talemate/game/engine/nodes/packaging.py @@ -457,9 +457,20 @@ async def initialize_package( @register("util/packaging/Package", as_base_type=True) class Package(Graph): """ - Configure node that helps managing node module packaging setup for easy scene installation. + Graph that defines an installable package of node modules for easy scene + installation. - This graph expects node module packaging instructions via various packaging nodes. + Place InstallNodeModule nodes inside it to mark node modules for + installation, and PromoteConfig nodes to expose module properties as + package configuration. + + Properties: + + - package_name: The name of the package + - author: The author of the package + - description: The description of the package + - installable: Whether the package is installable to a scene + - restart_scene_loop: Whether the scene loop should be restarted after the package is installed """ _export_definition: ClassVar[bool] = False @@ -513,6 +524,17 @@ class Package(Graph): @register("util/packaging/InstallNodeModule") class InstallNodeModule(Node): + """ + Marks a node module for installation when placed inside a Package graph. + + When the package is installed to a scene, each InstallNodeModule node causes + the referenced node module to be instantiated and added to the scene loop. + + Properties: + + - node_registry: The registry path of the node module to install + """ + class Fields: node_registry = PropertyField( name="node_registry", @@ -541,6 +563,17 @@ class InstallNodeModule(Node): class PromoteConfig(Node): """ Promotes a single module property to be configurable through the scene once the package is installed. + + Place inside a Package graph alongside the InstallNodeModule node whose + module property should be exposed. + + Properties: + + - node_registry: The registry path of the node module the property belongs to + - property_name: The name of the module property to promote + - exposed_property_name: The name the property is exposed as on the package + - label: Human readable label shown in the package configuration UI + - required: Whether the property must be set before the package is considered configured """ class Fields: diff --git a/src/talemate/game/engine/nodes/prompt.py b/src/talemate/game/engine/nodes/prompt.py index 3578d5a0..2d80d807 100644 --- a/src/talemate/game/engine/nodes/prompt.py +++ b/src/talemate/game/engine/nodes/prompt.py @@ -39,14 +39,21 @@ class PromptFromTemplate(Node): """ Loads a talemate template prompt + Either a template file or raw template text must be provided (but + not both). + Inputs: - - template_file: The template file to load + - template_file: The template file to load (optional) + - template_text: Raw template text to use instead of a file (optional) - variables: The variables to use in the template (optional) Properties: - scope: the template scope (choices of agents or scene) + - template_file: The template file to load + - template_text: The template text to use + - dedupe: Enable prompt deduplication Outputs: @@ -154,6 +161,7 @@ class LoadTemplate(Node): Outputs: - template_content: The raw unrendered template content as a string + - scope: The resolved template scope, passed through """ @pydantic.computed_field(description="Node style") @@ -273,7 +281,30 @@ class RenderPrompt(Node): @register("prompt/BuildPrompt") class BuildPrompt(Node): """ - Builds a prompt based on needs and dynamic instructions + Builds a prompt from a jinja2 template, exposing common context building + blocks (scene, memory, extra context, dynamic instructions etc.) as + template variables that can be toggled through the node's properties. + + Loads the template `{scope}.{template_file}` and renders it against the + active scene using the agent's client for token budgeting. Outputs both the + Prompt object and the rendered prompt text. + + Inputs: + + - state: The graph state + - agent: The agent whose client is used for rendering and token budgeting + - instructions: Task instructions made available to the template (optional) + - dynamic_context: List of DynamicInstruction objects injected as extra context (optional) + - dynamic_instructions: List of DynamicInstruction objects injected as instructions (optional) + - memory_prompt: Semantic query / retrieval prompt for the memory context (optional) + + Outputs: + + - state: The state input, passed through + - agent: The agent input, passed through + - prompt: The built Prompt object + - rendered: The rendered prompt text + - response_length: The configured response length, passed through """ class Fields: @@ -438,7 +469,9 @@ class BuildPrompt(Node): include_scene_context: bool = self.get_property("include_scene_context") include_character_context: bool = self.get_property("include_character_context") include_gamestate_context: bool = self.get_property("include_gamestate_context") - memory_prompt: str = self.get_property("memory_prompt") + memory_prompt: str = self.normalized_input_value( + "memory_prompt" + ) or self.get_property("memory_prompt") prefill_prompt: str = self.get_property("prefill_prompt") return_prefill_prompt: bool = self.get_property("return_prefill_prompt") dedupe_enabled: bool = self.get_property("dedupe_enabled") @@ -497,7 +530,9 @@ class TemplateVariables(Node): Variables: - scene: The current scene + - scene_title: The scene title (falls back to the scene name) - max_tokens: The maximum number of tokens in the response + - agent: The relevant agent Inputs: @@ -544,26 +579,39 @@ class GenerateResponse(Node): """ Sends a prompt to the agent and generates a response + Retries up to `attempts` times on an empty response. When a + response_spec is provided its extractors are applied to the + response; otherwise, if the template produced a data structure, + that is used as the extracted value. + Inputs: + - state: The graph state - agent: The agent to send the prompt to - prompt: The prompt to send to the agent + - action_type: Classification of the generated response (optional) + - response_length: The maximum length of the response (optional) - response_spec: Optional ResponseSpec for extracting structured data from response - Properties + Properties: - data_output: Output the response as data structure - - attempts: The number of attempts to attempt (on empty response) + - data_multiple: Allow multiple data structures in the response + - attempts: The number of attempts (retry on empty response) + - response_length: The maximum length of the response + - action_type: Classification of the generated response Outputs: - - response: The response from the agent - - data_obj: The data structure of the response - - rendered_prompt: The rendered prompt + - state: The state input, passed through - agent: The agent that generated the response + - prompt: The Prompt object, passed through + - response: The response from the agent + - data_obj: The data structure of the response (when data_output is enabled) + - captured_context: Context captured by the template during rendering + - rendered_prompt: The rendered prompt - response_spec: Pass-through of the input response spec - extracted: Dictionary of extracted values (when response_spec provided) - """ @pydantic.computed_field(description="Node style") @@ -648,7 +696,6 @@ class GenerateResponse(Node): self.add_output("data_obj", socket_type="dict,list") self.add_output("captured_context", socket_type="str") self.add_output("rendered_prompt", socket_type="str") - self.add_output("agent", socket_type="agent") self.add_output("response_spec", socket_type="response/spec") self.add_output("extracted", socket_type="dict") @@ -656,7 +703,9 @@ class GenerateResponse(Node): scene: "Scene" = active_scene.get() agent: Agent = self.require_input("agent") prompt: Prompt = self.require_input("prompt") - action_type = self.get_property("action_type") + action_type = self.normalized_input_value("action_type") or self.get_property( + "action_type" + ) response_length = self.require_number_input("response_length", types=(int,)) data_output = self.get_property("data_output") data_multiple = self.get_property("data_multiple") diff --git a/src/talemate/game/engine/nodes/raise_errors.py b/src/talemate/game/engine/nodes/raise_errors.py index a535a800..1a90e80b 100644 --- a/src/talemate/game/engine/nodes/raise_errors.py +++ b/src/talemate/game/engine/nodes/raise_errors.py @@ -9,6 +9,7 @@ from talemate.game.engine.nodes.core import ( PropertyField, InputValueError, NodeStyle, + StageExit, StopGraphExecution, StopModule, LoopBreak, @@ -37,6 +38,10 @@ class ActedAsCharacter(Node): - state: The current graph state - character_name: The name of the character the user acted as + + Outputs: + + - state: The state input, passed through """ def __init__(self, title="Acted As Character", **kwargs): @@ -46,16 +51,22 @@ class ActedAsCharacter(Node): self.add_input("state") self.add_input("character_name", socket_type="str") + self.add_output("state") + async def run(self, state: GraphState): character_name = self.get_input_value("character_name") + # this will never be reached, but it's here to make sure + # that Stage nodes can be connected to this node + self.set_output_values({"state": self.get_input_value("state")}) + raise exceptions.ActedAsCharacter(character_name) @register("raise/Stop") class Stop(Node): """ - Raises the sepcified node / scene loop exception + Raises the specified node / scene loop exception to stop execution of the current graph Inputs: @@ -130,6 +141,8 @@ class Stop(Node): raise exceptions.RestartSceneLoop() elif exception == "ResetScene": raise exceptions.ResetScene() + elif exception == "StageExit": + raise StageExit() else: raise InputValueError(self, "exception", f"Unknown exception: {exception}") @@ -144,6 +157,10 @@ class InputValueErrorNode(Node): - state: The current state - field: The field that caused the error - message: The message to raise the exception with + + Outputs: + + - state: The state input, passed through """ @pydantic.computed_field(description="Node style") diff --git a/src/talemate/game/engine/nodes/registry.py b/src/talemate/game/engine/nodes/registry.py index 26e5c30f..b1ed0291 100644 --- a/src/talemate/game/engine/nodes/registry.py +++ b/src/talemate/game/engine/nodes/registry.py @@ -19,6 +19,7 @@ __all__ = [ "register", "get_node", "NODES", + "NODE_ALIASES", "export_node_definitions", "import_node_definitions", "import_node_definition", @@ -34,6 +35,14 @@ log = structlog.get_logger("talemate.game.engine.nodes.registry") NODES = {} +# Maps legacy registry names to their current equivalents so saved graphs +# referencing a renamed node keep loading. Old names resolve via get_node() +# but are not exported or searchable. +NODE_ALIASES = { + "agents/editor/CleanUoCharacterMessage": "agents/editor/CleanUpCharacterMessage", + "agernts/director/chat/instructGamestateUpdates": "agents/director/chat/instructGamestateUpdates", +} + INITIAL_IMPORT_DONE = False @@ -75,6 +84,13 @@ def get_node(name): SCENE_NODES = getattr(scene, "_NODE_DEFINITIONS", {}) + # scene-local definitions may still be registered under a legacy name - + # they must keep priority over the shipped node the alias points to + if name in SCENE_NODES: + return SCENE_NODES[name] + + name = NODE_ALIASES.get(name, name) + if name in SCENE_NODES: return SCENE_NODES[name] diff --git a/src/talemate/game/engine/nodes/response.py b/src/talemate/game/engine/nodes/response.py index 2e7c1c93..dfd82c2e 100644 --- a/src/talemate/game/engine/nodes/response.py +++ b/src/talemate/game/engine/nodes/response.py @@ -84,9 +84,13 @@ class AsIsExtractor(ExtractorNodeBase): - name: Key name for DictCollector - trim: Whether to trim whitespace (default: True) + Inputs: + - name: Optional override for name property + Outputs: - name: The extractor name (pass-through) - - extractor: The AsIsExtractor instance + - value: The AsIsExtractor instance + - spec: A ResponseSpec containing just this extractor """ class Fields: @@ -134,9 +138,13 @@ class AnchorExtractor(ExtractorNodeBase): - fallback_to_full: Return full response if anchors not found - trim: Whether to trim whitespace + Inputs: + - name: Optional override for name property + Outputs: - name: The extractor name (pass-through) - - extractor: The AnchorExtractor instance + - value: The AnchorExtractor instance + - spec: A ResponseSpec containing just this extractor """ class Fields: @@ -218,7 +226,8 @@ class ComplexAnchorExtractor(ExtractorNodeBase): Outputs: - name: The extractor name (pass-through) - tracked_tags: The tracked tags list (pass-through) - - extractor: The ComplexAnchorExtractor instance + - value: The ComplexAnchorExtractor instance + - spec: A ResponseSpec containing just this extractor """ class Fields: @@ -320,9 +329,13 @@ class AfterAnchorExtractor(ExtractorNodeBase): - fallback_to_full: Return full response if start marker not found - trim: Whether to trim whitespace + Inputs: + - name: Optional override for name property + Outputs: - name: The extractor name (pass-through) - - extractor: The AfterAnchorExtractor instance + - value: The AfterAnchorExtractor instance + - spec: A ResponseSpec containing just this extractor """ class Fields: @@ -396,9 +409,13 @@ class RegexExtractor(ExtractorNodeBase): - all_matches: Return list of all matches instead of first - trim: Whether to trim whitespace + Inputs: + - name: Optional override for name property + Outputs: - name: The extractor name (pass-through) - - extractor: The RegexExtractor instance + - value: The RegexExtractor instance + - spec: A ResponseSpec containing just this extractor """ class Fields: @@ -478,9 +495,13 @@ class StripPrefixExtractor(ExtractorNodeBase): - replacement: Replacement string (default: "") - trim: Whether to trim whitespace + Inputs: + - name: Optional override for name property + Outputs: - name: The extractor name (pass-through) - - extractor: The StripPrefixExtractor instance + - value: The StripPrefixExtractor instance + - spec: A ResponseSpec containing just this extractor """ class Fields: @@ -545,9 +566,13 @@ class CodeBlockExtractor(ExtractorNodeBase): - fallback_to_full: Return full response if anchors not found - trim: Whether to trim whitespace + Inputs: + - name: Optional override for name property + Outputs: - name: The extractor name (pass-through) - - extractor: The CodeBlockExtractor instance + - value: The CodeBlockExtractor instance + - spec: A ResponseSpec containing just this extractor """ class Fields: @@ -636,7 +661,8 @@ class ComplexCodeBlockExtractor(ExtractorNodeBase): Outputs: - name: The extractor name (pass-through) - tracked_tags: The tracked tags list (pass-through) - - extractor: The ComplexCodeBlockExtractor instance + - value: The ComplexCodeBlockExtractor instance + - spec: A ResponseSpec containing just this extractor """ class Fields: diff --git a/src/talemate/game/engine/nodes/run.py b/src/talemate/game/engine/nodes/run.py index e7450611..8ed3ff54 100644 --- a/src/talemate/game/engine/nodes/run.py +++ b/src/talemate/game/engine/nodes/run.py @@ -203,10 +203,13 @@ class FunctionArgument(Node): """ Represents an argument to a function. + During function execution the value passed for this argument is cast + to the declared type and emitted from the `value` output. + Properties: - - type (str): The type of the argument - - name (str): The name of the argument + - typ: The type of the argument + - name: The name of the argument Outputs: @@ -305,6 +308,10 @@ class FunctionReturn(Node): """ Represents the return value of a function. + When this node runs with a resolved input value, it sets the + function's return value and stops execution of the function graph. + If the input value is unresolved, execution continues normally. + Inputs: - value: The value to return @@ -339,13 +346,21 @@ class FunctionReturn(Node): @register("core/functions/DefineFunction") class DefineFunction(Node): """ - Does not define any outputs and is considered an isolated node. + Defines a function from the connected nodes, which can be retrieved + elsewhere in the graph via the GetFunction node. - The correspinding GetFunction node will be used to retrieve the function object. + This is an isolated node that never runs during normal graph + execution. The node connected to `nodes` becomes the endpoint of the + function - when the function is called, the nodes leading into the + endpoint are executed. Inputs: - nodes: The nodes to convert into a function + - name: The name of the function + + Properties: + - name: The name of the function """ @@ -415,6 +430,7 @@ class GetFunction(Node): Outputs: - fn: The function wrapper + - name: The name of the function """ class Fields: @@ -728,10 +744,15 @@ class RunModule(Node): """ Provides a way to run a node module from memory + The module runs in an isolated state; running a module from within + itself raises an error. + Inputs: - - module (optional) + + - module: The module (Graph instance) to run Outputs: + - done: True if module was executed successfully - failed: Error message if module execution failed - cancelled: True if module execution was cancelled @@ -814,7 +835,24 @@ class RunModule(Node): @register("core/functions/Breakpoint") class Breakpoint(Node): """ - A node that will pause execution of the graph and allow for inspection + Pauses graph execution at this point and notifies the node editor, + allowing the current state to be inspected. Execution resumes when + the breakpoint is released from the editor. + + Breakpoints only trigger in the creative (node editor) environment - + during normal gameplay the node simply passes the state through. + + Inputs: + + - state: The state to pass through + + Properties: + + - active: Whether the breakpoint is active + + Outputs: + + - state: The state input, passed through """ class Fields: @@ -887,6 +925,11 @@ class ErrorHandler(Node): A node that will catch unhandled errors in the graph and allow for custom error handling + This is an isolated node. When an unhandled error occurs, the + supplied function is called with a single `exc` argument holding an + exception wrapper (see UnpackException). If the function returns a + truthy value the error is considered handled. + Inputs: - fn: The function to call when an error occurs @@ -940,7 +983,17 @@ class ErrorHandler(Node): @register("core/functions/UnpackException") class UnpackException(Node): """ - Unpacks an ExceptionWrapper instance into an description and message + Unpacks an exception wrapper (as received by an ErrorHandler + function) into its name and message. + + Inputs: + + - exc: The exception wrapper to unpack + + Outputs: + + - name: The exception class name + - message: The exception message """ def __init__(self, title="Unpack Exception", **kwargs): diff --git a/src/talemate/game/engine/nodes/scene.py b/src/talemate/game/engine/nodes/scene.py index e4a197c6..5d49e3fd 100644 --- a/src/talemate/game/engine/nodes/scene.py +++ b/src/talemate/game/engine/nodes/scene.py @@ -65,7 +65,6 @@ class GetSceneState(Node): - characters: A list of characters in the scene - active: Whether the scene is active - auto_save: Whether auto save is enabled - - auto_backup: Whether auto backup is enabled - auto_progress: Whether auto progress is enabled - scene: The scene instance """ @@ -289,13 +288,25 @@ class GetCharacter(Node): @register("scene/ListCharacters") class ListCharacters(Node): """ - Returns a list of all characters in the scene + Returns a list of characters in the scene, filtered by status + + Inputs: + + - character_status: Which characters to include - "active", "inactive" or "all" + + Properties: + + - character_status: Which characters to include - "active", "inactive" or "all" + + Outputs: + + - characters: The list of matching character objects """ class Fields: character_status = PropertyField( name="character_status", - description="The status of the character", + description="Which characters to list (active, inactive or all)", type="str", default="all", choices=["active", "inactive", "all"], @@ -420,6 +431,7 @@ class UpdateCharacterData(Node): - base_attributes: The base attributes dictionary - details: The details dictionary - description: The description string + - name: A new name for the character (renames the character) - color: The color of the character name Outputs: @@ -489,7 +501,23 @@ class UpdateCharacterData(Node): @register("scene/GetCharacterAttribute") class GetCharacterAttribute(Node): """ - Get an attribute from a character + Get a base attribute from a character + + Inputs: + + - character: The character object + - name: The name of the attribute + + Properties: + + - name: The name of the attribute + + Outputs: + + - character: The character object, passed through + - name: The attribute name, passed through + - value: The attribute value (None if the attribute does not exist) + - context_id: The context ID of the attribute (None if the attribute does not exist) """ class Fields: @@ -584,7 +612,23 @@ class SetCharacterAttribute(Node): @register("scene/GetCharacterDetail") class GetCharacterDetail(Node): """ - Get the details of a character + Get a detail from a character + + Inputs: + + - character: The character object + - name: The name of the detail + + Properties: + + - name: The name of the detail + + Outputs: + + - character: The character object, passed through + - name: The detail name, passed through + - detail: The detail value (None if the detail does not exist) + - context_id: The context ID of the detail (None if the detail does not exist) """ class Fields: @@ -799,6 +843,7 @@ class WaitForInput(Node): Outputs: + - state: The state input, passed through - input: The input message - interaction_state: The interaction state - character: The character object @@ -846,6 +891,7 @@ class WaitForInput(Node): self.set_property("prefix", "") self.set_property("allow_commands", True) + self.add_output("state") self.add_output("input", socket_type="str") self.add_output("interaction_state", socket_type="interaction_state") self.add_output("character", socket_type="character") @@ -970,6 +1016,7 @@ class WaitForInput(Node): self.set_output_values( { + "state": self.get_input_value("state"), "input": text_message, "interaction_state": interaction_state, "character": player_character, @@ -1081,9 +1128,18 @@ class TriggerGameLoopActorIter(Trigger): In a most basic setup you will trigger this everytime an actor has had a turn. + After the event is sent, a follow-up character iteration event is also + fired: game_loop_player_character_iter for player characters, or + game_loop_ai_character_iter for AI characters. + Inputs: + - trigger: Trigger input to activate the node - actor: The actor that has had a turn + + Outputs: + + - event: The event object that was sent """ class Fields: @@ -1239,7 +1295,8 @@ class DeactivateCharacter(Node): @register("scene/RemoveAllCharacters") class RemoveAllCharacters(Node): """ - Remove all characters from the scene + Remove all active characters from the scene (inactive characters are + not affected) Inputs: @@ -1334,7 +1391,7 @@ class GetSceneLoopState(Node): @register("scene/Restore") class RestoreScene(Node): """ - Restore the scene to its resore point + Restore the scene to its restore point Inputs: @@ -1363,6 +1420,12 @@ class RestoreScene(Node): class GetTitle(Node): """ Get the title text for the scene + + Falls back to the scene name if no title is set. + + Outputs: + + - title: The scene title (or scene name, or empty string) """ def __init__(self, title="Get Story Title", **kwargs): @@ -1380,6 +1443,19 @@ class GetTitle(Node): class SetTitle(Node): """ Set the title text for the scene + + Raises an error if the new title is empty. + + Inputs: + + - state: The graph state + - new_title: The new title text + + Outputs: + + - state: The graph state + - new_title: The new title text + - old_title: The previous title (or scene name if no title was set) """ class Fields: @@ -1486,6 +1562,23 @@ class GetContentClassification(Node): class SetContentClassification(Node): """ Set the content classification text for the scene + + Raises an error if the text is longer than max_length characters. + + Inputs: + + - state: The graph state + - content_classification: The content classification text + + Properties: + + - content_classification: The content classification text + - max_length: The maximum length of the text (characters, NOT tokens) + + Outputs: + + - state: The graph state + - content_classification: The content classification text """ class Fields: @@ -1599,6 +1692,7 @@ class SetIntroduction(Node): emit_history = self.get_input_value("emit_history") scene.set_intro(introduction) + scene.emit_status() if emit_history: await scene.emit_history() diff --git a/src/talemate/game/engine/nodes/scene_intent.py b/src/talemate/game/engine/nodes/scene_intent.py index da80a5dc..6016190e 100644 --- a/src/talemate/game/engine/nodes/scene_intent.py +++ b/src/talemate/game/engine/nodes/scene_intent.py @@ -42,13 +42,18 @@ class GetSceneIntent(Node): """ Returns the intent state. + The phase, scene_type and start outputs are only set when a scene phase + is currently active. + Outputs: - intent (str) - the overall intent - phase (scene_intent/scene_phase) - the current phase - - instructions (str) - the current instructions - scene_type (scene_intent/scene_type) - the current scene type - start (int) - the message id where this intent started + - direction_always_on (bool) - whether scene direction is always on + - direction_run_immediately (bool) - whether scene direction runs immediately + - direction_instructions (str) - the current direction instructions """ def __init__(self, title="Get Scene Intent", **kwargs): @@ -90,7 +95,7 @@ class GetSceneIntent(Node): @register("scene/intention/SetSceneIntent") class SetSceneIntent(Node): """ - Updates the overall intent. + Updates the overall intent and emits the updated intent state to the UI. Inputs: @@ -99,7 +104,7 @@ class SetSceneIntent(Node): Outputs: - - state - graph state + - state - the scene's intent state object - intent (str) - the overall intent """ @@ -152,9 +157,10 @@ class SetScenePhase(Node): Outputs: - - state - graph state + - state - the scene's intent state object - phase (scene_intent/scene_phase) - the new phase - scene_type (scene_intent/scene_type) - the scene type of the new phase (object) + - intent (str) - the phase intent, passed through """ class Fields: @@ -179,7 +185,7 @@ class SetScenePhase(Node): self.add_input("scene_type", socket_type="str", optional=True) self.add_input("intent", socket_type="str", optional=True) - self.set_property("scene_type", "") + self.set_property("scene_type", "roleplay") self.set_property("intent", "") self.add_output("state") @@ -209,18 +215,20 @@ class SetScenePhase(Node): @register("scene/intention/UnpackScenePhase") class UnpackScenePhase(Node): """ + Unpack a scene phase into its intent and scene type fields. + Inputs: - - phhase (scene_intent/scene_phase) + - phase (scene_intent/scene_phase) - the phase to unpack - Outputs + Outputs: - - intent - - scene_type - - scene_type_instructions - - scene_type_description - - scene_type_name - - scene_type_id + - intent (str) - the phase intent + - scene_type (str) - the scene type id of the phase + - scene_type_instructions (str) - the scene type instructions + - scene_type_description (str) - the scene type description + - scene_type_name (str) - the scene type name + - scene_type_id (str) - the scene type id """ def __init__(self, title="Unpack Scene Phase", **kwargs): @@ -259,13 +267,21 @@ class MakeSceneType(Node): """ Create a new scene type object. + If auto_append is enabled (the default), the new scene type is also added + to the scene's available scene types and the intent state is emitted to + the UI. + Inputs: - - id (str) - scene type ID + - scene_type_id (str) - scene type ID - name (str) - scene type name - description (text) - scene type description - instructions (text) - scene type instructions + Properties: + + - auto_append (bool) - automatically add the scene type to the scene's scene types + Outputs: - scene_type (scene_intent/scene_type) - the new scene type object @@ -370,9 +386,11 @@ class GetSceneType(Node): """ Get a scene type object. + Raises an error if the scene type is not found. + Inputs: - - id (str) - scene type ID + - scene_type_id (str) - scene type ID Outputs: @@ -410,7 +428,7 @@ class UnpackSceneType(Node): Outputs: - - id (str) - scene type ID + - scene_type_id (str) - scene type ID - name (str) - scene type name - description (text) - scene type description - instructions (text) - scene type instructions @@ -445,14 +463,17 @@ class RemoveSceneType(Node): """ Remove a scene type object. + Unknown scene type IDs are silently ignored. Emits the updated intent + state to the UI. + Inputs: - state - graph state - - id (str) - scene type ID + - scene_type_id (str) - scene type ID Outputs: - - state - graph state + - state - the scene's intent state object """ def __init__(self, title="Remove Scene Type", **kwargs): diff --git a/src/talemate/game/engine/nodes/scene_messages.py b/src/talemate/game/engine/nodes/scene_messages.py index 5c5117a6..671ab349 100644 --- a/src/talemate/game/engine/nodes/scene_messages.py +++ b/src/talemate/game/engine/nodes/scene_messages.py @@ -19,6 +19,10 @@ class CharacterMessage(Node): """ Creates a character message from a character and a message + The message text is automatically prefixed with the character's name + ("Name: ...") if it isn't already. The character's current avatar (if + set) is captured on the message at creation time. + Inputs: - character: The character object @@ -156,15 +160,23 @@ class DirectorMessage(Node): - source: The source of the message - player or ai, so whether the message is result of user input or AI generated - meta: A dictionary of meta information to attach to the message. Can hold the character name that the message is related to. - character: The character object that the message is related to + - action: Describes the director action + - subtype: The subtype of the director message, used for further categorization Properties: - source: The source of the message - action: Describes the director action + - subtype: The subtype of the director message, used for further categorization Outputs: - message: The message object (this is a scene_message.DirectorMessage instance) + - source: The source input, passed through + - meta: The meta input, passed through + - character: The character input, passed through + - action: The action input, passed through + - subtype: The subtype input, passed through """ class Fields: @@ -319,6 +331,7 @@ class AddMessageVersion(Node): Inputs: + - state: The graph state - message: The SceneMessage to append a version to (required) - new_text: The new canonical text (required) - source: Version source label (optional, overrides property) @@ -331,6 +344,7 @@ class AddMessageVersion(Node): Outputs: + - state: The state input, passed through - message: The same SceneMessage instance (post-append) - new_text: Passthrough of new_text - source: Passthrough of source actually used diff --git a/src/talemate/game/engine/nodes/state.py b/src/talemate/game/engine/nodes/state.py index 77cc360b..f15ea208 100644 --- a/src/talemate/game/engine/nodes/state.py +++ b/src/talemate/game/engine/nodes/state.py @@ -97,6 +97,9 @@ class SetState(StateManipulation): - name: the name to set - value: the value to set + + Properties: + - scope: which scope to set the variable in Outputs: @@ -149,6 +152,10 @@ class GetState(StateManipulation): Inputs: - name: the name to get + - default: value to return if the variable is not set (optional) + + Properties: + - scope: which scope to get the variable from Outputs: @@ -202,6 +209,9 @@ class UnsetState(StateManipulation): Inputs: - name: the name to unset + + Properties: + - scope: which scope to unset the variable in Outputs: @@ -242,6 +252,9 @@ class HasState(StateManipulation): Inputs: - name: the name to check + + Properties: + - scope: which scope to check the variable in Outputs: @@ -271,20 +284,23 @@ class CounterState(StateManipulation): Counter node that increments a numeric value in the state and returns the new value. Inputs: + - state: required state input (conditional execution) - name: The key to the value to increment - - scope: Which scope to use for the counter - reset: If true, the value will be reset to 0 + - reset_cap: If set, the counter resets to 0 once it reaches this value Properties: - increment: The amount to increment the value by - - name: The key to the value to increment - scope: Which scope to use for the counter - - reset: If true, the value will be reset to 0 Outputs: + - state: the state that was passed in - value: The new value - name: The key that was used - scope: The scope that was used + - reset_cap: The reset cap that was used + - reset: Whether the counter was reset + - new_cycle: True if the counter was at 0 before this run """ class Fields(StateManipulation.Fields): @@ -308,7 +324,7 @@ class CounterState(StateManipulation): name="reset_cap", type="number", default=0, - description="If set, the value will be reset to this value when it exceeds it", + description="If set (> 0), the counter resets to 0 once the value reaches this cap", ) @pydantic.computed_field(description="Node style") @@ -334,7 +350,6 @@ class CounterState(StateManipulation): self.set_property("reset", False) self.set_property("reset_cap", 0) - self.add_output("value") self.add_output("reset_cap", socket_type="number") self.add_output("reset", socket_type="bool") self.add_output("new_cycle", socket_type="bool") @@ -460,6 +475,8 @@ class SetStatePath(SetState): - state: required state input (conditional execution) - name: the path name to set (e.g., 'a/b/c') - value: the value to set + + Properties: - scope: which scope to set the variable in Outputs: @@ -531,9 +548,11 @@ class GetStatePath(GetState): Inputs: - name: the path name to get (e.g., 'a/b/c') - - scope: which scope to get the variable from - default: default value if path doesn't exist + Properties: + - scope: which scope to get the variable from + Outputs: - value: the value that was retrieved - name: the name that was retrieved @@ -604,6 +623,8 @@ class UnsetStatePath(UnsetState): Inputs: - state: required state input (conditional execution) - name: the path name to unset (e.g., 'a/b/c') + + Properties: - scope: which scope to unset the variable in Outputs: @@ -677,6 +698,8 @@ class HasStatePath(HasState): Inputs: - name: the path name to check (e.g., 'a/b/c') + + Properties: - scope: which scope to check the variable in Outputs: @@ -724,12 +747,12 @@ class CounterStatePath(CounterState): Inputs: - state: required state input (conditional execution) - name: The path key to the value to increment (e.g., 'a/b/c') - - scope: Which scope to use for the counter - reset: If true, the value will be reset to 0 - - reset_cap: If set, the value will be reset to 0 when it exceeds this value + - reset_cap: If set, the value will be reset to 0 when it reaches this value Properties: - increment: The amount to increment the value by + - scope: Which scope to use for the counter Outputs: - state: the state that was passed in @@ -823,7 +846,11 @@ class CounterStatePath(CounterState): @register("state/Gamestate") class UnpackGameState(Node): """ - Get and unpack the game state + Get the game state variables of the active scene. + + Outputs: + + - variables: Dict of the scene's game state variables """ def __init__(self, title="Game State", **kwargs): diff --git a/src/talemate/game/engine/nodes/string.py b/src/talemate/game/engine/nodes/string.py index 563725d7..b1d7891d 100644 --- a/src/talemate/game/engine/nodes/string.py +++ b/src/talemate/game/engine/nodes/string.py @@ -96,7 +96,7 @@ class Split(Node): Inputs: - string: The string to split - - delimiter: Character(s) to use as the split point (optional) + - delimiter: Character(s) to use as the split point (optional; a literal "\\n" is treated as a newline) Properties: @@ -152,7 +152,7 @@ class Join(Node): Inputs: - strings: List of strings to join - - delimiter: Character(s) to insert between each string (optional) + - delimiter: Character(s) to insert between each string (optional; a literal "\\n" is treated as a newline) Properties: @@ -208,6 +208,8 @@ class Replace(Node): Properties: - count: Maximum number of replacements to make (-1 for all occurrences) + - old: Default substring to find when not provided via input + - new: Default replacement string when not provided via input Outputs: @@ -290,7 +292,9 @@ class Format(Node): @register("data/string/AdvancedFormat") class AdvancedFormat(DynamicSocketNodeBase): """ - Python-style string formatting with dynamic inputs. + Python-style string formatting with dynamic inputs. Uses Python's + .format() syntax - reference variables with single curly braces + ({name}); for jinja2 templates ({{ name }}) use the Jinja2 Format node. Behaves like Format but supports dynamic inputs similar to DictCollector. Dynamic inputs can be: @@ -372,17 +376,37 @@ class AdvancedFormat(DynamicSocketNodeBase): @register("prompt/Jinja2Format") class Jinja2Format(AdvancedFormat): """ - Formats a string using jinja2 with Prompt's template environment + Renders a jinja2 template string using Prompt's template environment, + providing access to all Prompt globals, filters, and template features. + Reference variables with double curly braces: {{ name }}. - Uses a Prompt instance to render templates, providing access to all - Prompt globals, filters, and template features. + Variables come from the connected inputs: every dynamic input becomes a + template variable. Connect any node output to a dynamic input slot and + the variable name is inferred from the source node's name / key / + attribute (falling back to the socket name), or connect a (key, value) + tuple from Make Key-Value Pair to name it explicitly. The optional + variables dict is merged in first; dynamic inputs extend/override it. Inputs: - - template: The template string to render - - variables: Dictionary of variables for the template + - template: The jinja2 template string to render + - variables: Optional base dictionary of template variables + (dynamic inputs extend/override these) - scope: Optional agent scope (e.g., "director") for template includes + + Dynamic inputs: item{i} - each connected value becomes a template variable + + Outputs: + - result: The rendered string """ + class Fields: + template = PropertyField( + name="template", + description='A jinja2 template string (e.g., "Hello, {{ name }}")', + type="text", + default="", + ) + def __init__(self, title="Jinja2 Format", **kwargs): super().__init__(title=title, **kwargs) @@ -466,7 +490,7 @@ class Trim(Node): Inputs: - string: The string to trim - - chars: Character(s) to remove (optional, defaults to whitespace) + - chars: Character(s) to remove (optional, defaults to whitespace; a literal "\\n" is treated as a newline) Properties: @@ -583,7 +607,9 @@ class Extract(Node): Extracts a portion of a string using a left and right anchor Finds the first valid block between anchors (no nested left_anchor inside). - Falls back to everything after the left_anchor if no complete block is found. + Falls back to everything after the last left_anchor if no complete block is + found. If the left_anchor does not occur at all, the result is an empty + string. Examples: - "nestedvalue" -> "value" (first clean block) @@ -670,7 +696,8 @@ class StringCheck(Node): """Checks if a string starts with, ends with, or contains a substring Tests whether a string starts with, ends with, contains, or exactly equals a substring, - with optional case sensitivity. + with optional case sensitivity. An empty (or unset) string always yields False, + regardless of mode. Inputs: @@ -750,7 +777,23 @@ class StringCheck(Node): @register("data/string/Excerpt") class Excerpt(Node): """ - Returns a excerpt of a string based on length. + Returns an excerpt of a string based on length + + Takes the first `length` characters of the string. If the string was + truncated and add_ellipsis is enabled, "..." is appended. + + Inputs: + + - string: The string to excerpt + + Properties: + + - length: The length of the excerpt + - add_ellipsis: Whether to add an ellipsis when the string is truncated + + Outputs: + + - result: The excerpt """ class Fields: diff --git a/src/talemate/game/engine/nodes/tools/cli.py b/src/talemate/game/engine/nodes/tools/cli.py index b2e5cdbd..fccc1c87 100644 --- a/src/talemate/game/engine/nodes/tools/cli.py +++ b/src/talemate/game/engine/nodes/tools/cli.py @@ -12,6 +12,7 @@ import argparse import io import json import sys +from pathlib import Path from typing import Any, Callable, TextIO import pydantic @@ -232,7 +233,10 @@ def _emit( def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="python -m talemate.game.engine.nodes.tools", - description="Read-only static analysis for Talemate node graph JSON files.", + description=( + "Read-only static analysis for Talemate node graph JSON files, " + "plus the node glossary docs generator." + ), ) sub = parser.add_subparsers(dest="cmd", required=True) @@ -285,6 +289,24 @@ def build_parser() -> argparse.ArgumentParser: ) add_graph_arg(p_check) + p_glossary = sub.add_parser( + "glossary", + help="Generate the node glossary docs pages from the live registry", + ) + glossary_mode = p_glossary.add_mutually_exclusive_group(required=True) + glossary_mode.add_argument( + "--write", action="store_true", help="Render and write all glossary pages" + ) + glossary_mode.add_argument( + "--check", + action="store_true", + help="Verify the committed pages match a fresh render (exit 1 on drift)", + ) + p_glossary.add_argument( + "--docs-root", + help="Override the output directory (default: docs/user-guide/node-editor/reference/nodes)", + ) + return parser @@ -294,6 +316,14 @@ def _run(args: argparse.Namespace, out_stream: TextIO, err_stream: TextIO) -> in # commands and surfaces loader errors before we start analyzing. analysis.ensure_registry_loaded() + if args.cmd == "glossary": + from . import glossary + + docs_root = Path(args.docs_root) if args.docs_root else None + if args.check: + return glossary.check_pages(docs_root) + return glossary.write_pages(docs_root) + try: graph = load_graph(args.graph) except GraphLoadError as exc: diff --git a/src/talemate/game/engine/nodes/tools/glossary.py b/src/talemate/game/engine/nodes/tools/glossary.py new file mode 100644 index 00000000..df0b9c6e --- /dev/null +++ b/src/talemate/game/engine/nodes/tools/glossary.py @@ -0,0 +1,914 @@ +""" +Node glossary generator. + +Renders a markdown reference page set covering every registered node type +(Python node classes and dynamically imported JSON module graphs) into +``docs/user-guide/node-editor/reference/nodes/``. + +Each page covers one category of nodes (see ``PREFIX_MAP`` / +``MODULE_MAP``). Everything above the ``GENERATED_MARKER`` line in a page +is hand-written and preserved across regenerations; everything below it is +owned by this generator and rewritten on every ``--write`` run. + +Node metadata is read the same way ``writer.py`` reads it: by +instantiating each registered class once. ``PropertyField`` attributes are +accessed directly instead of via ``model_dump()`` so user-supplied +``generate_choices`` callables (which may touch runtime-only state) are +never invoked. +""" + +from __future__ import annotations + +import dataclasses +import re +from pathlib import Path + +import structlog + +from talemate.path import TALEMATE_ROOT, relative_to_root + +log = structlog.get_logger("talemate.game.engine.nodes.tools.glossary") + +__all__ = [ + "DEFAULT_DOCS_ROOT", + "GENERATED_MARKER", + "collect_node_docs", + "render_pages", + "write_pages", + "check_pages", +] + +DEFAULT_DOCS_ROOT = ( + TALEMATE_ROOT / "docs" / "user-guide" / "node-editor" / "reference" / "nodes" +) + +GENERATED_MARKER = ( + "" +) + +# Soft budget per page: the help agent's read_doc truncates at 15000 +# characters, so a page that exceeds this renders a warning. +PAGE_SIZE_BUDGET = 14000 + +# Property default values longer than this are truncated in the tables. +MAX_DEFAULT_LENGTH = 160 + +# Registry prefix (longest match wins, matched against the registry path +# minus the node name) -> page slug. Prefixes not listed fall back to +# MODULE_MAP, then to their first path segment. +PREFIX_MAP: dict[str, str] = { + # engine + "assets": "assets", + "context_id": "context-id", + "data": "data", + "data/number": "data-number", + "data/string": "data-string", + "event": "event", + "focal": "focal", + "input": "input", + "package/talemate": "util-packaging", + "prompt": "prompt", + "raise": "raise", + "response": "response", + "scene/event/trigger": "scene", + "scene/history": "scene-history", + "scene/intention": "scene-intent", + "scene/message": "scene-messages", + "scene/worldstate": "scene-world-state", + "state": "state", + "util": "util", + "util/packaging": "util-packaging", + "ux": "ux", + "validation": "validation", + "websocket": "websocket", + "websocket/signals": "websocket", + # agents + "agents": "agents", + "agents/conversation": "agents-conversation", + "agents/creator": "agents-creator", + "agents/director": "agents-director", + "agents/director/auto-direct": "agents-director-auto", + "agents/director/events": "agents-director", + "agents/director/fn": "agents-director", + "agents/director/gameplay": "agents-director", + "agents/director/chat": "agents-director-chat", + "agents/director/plan": "agents-director-plan", + "agents/editor": "agents-editor", + "agents/memory": "agents-memory", + "agents/narrator": "agents-narrator", + "agents/summarize": "agents-summarizer", + "agents/summarizer": "agents-summarizer", + "agents/tts": "agents-tts", + "agents/visual": "agents-visual", + "agents/world_state": "agents-world-state", +} + +# For flat registry prefixes shared by several source modules (the flat +# ``core/*`` space is populated by core, logic and run), the defining +# python module (last dotted segment) refines the page. Never consulted +# for dynamically imported JSON modules - their defining module is always +# ``core``. +MODULE_MAP: dict[str, str] = { + "core": "core", + "logic": "core-logic", + "run": "core-functions", + "api": "core-functions", + "scene": "scene", +} + +# Explicit per-registry overrides, applied before everything else. +NODE_OVERRIDES: dict[str, str] = { + # asset meta / selection-context nodes are heavyweight - they get + # their own page separate from the asset CRUD nodes + "assets/MakeAssetAttachmentContext": "assets-meta", + "assets/MakeAssetMeta": "assets-meta", + "assets/SearchAssets": "assets-meta", + "assets/SelectAssets": "assets-meta", + "assets/UnpackAssetMeta": "assets-meta", + "assets/UnpackAssetSelectionContext": "assets-meta", + # pairs with the Dict Collector + "data/MakeKeyValuePair": "data-collections", + # registered under prompt/ but users look for it next to Advanced + # Format on the string page - the two share their dynamic-input + # variable semantics + "prompt/Jinja2Format": "data-string", + # visual agent: generation/backend nodes split off the prompt-building + # page + "agents/visual/AnalyzeImages": "agents-visual-generation", + "agents/visual/BackendStatus": "agents-visual-generation", + "agents/visual/GenerateImage": "agents-visual-generation", + "agents/visual/GenerationRequest": "agents-visual-generation", + "agents/visual/SelectBackend": "agents-visual-generation", + "agents/visual/UnpackGenerationRequest": "agents-visual-generation", + "agents/visual/UnpackGenerationResponse": "agents-visual-generation", +} + +# slug -> (page title, one-line description used in the index table and as +# the default intro for newly created pages). +PAGE_TITLES: dict[str, tuple[str, str]] = { + "index": ( + "Node Reference", + "Complete reference of every node available in the node editor.", + ), + "agents": ( + "Agent Nodes", + "Generic agent interaction: get an agent, call agent functions, read and count agent state variables.", + ), + "agents-conversation": ( + "Conversation Agent Nodes", + "Generate character dialogue through the conversation agent.", + ), + "agents-creator": ( + "Creator Agent Nodes", + "Contextual content generation: character attributes, dialogue instructions, content context and thematic lists.", + ), + "agents-director": ( + "Director Agent Nodes", + "Director operations: scene direction, character persistence, voice assignment, dice rolls, narrator/actor direction functions and websocket handlers.", + ), + "agents-director-auto": ( + "Director Auto-Direction Nodes", + "Automatic scene direction: candidates, scene intent, instruction cadence and auto-direction generation.", + ), + "agents-director-chat": ( + "Director Chat Nodes", + "Director chat plumbing: chat context, action arguments, confirmation and sub-actions, plus context/query modules.", + ), + "agents-director-chat-actions": ( + "Director Chat Actions", + "The director chat's action modules: direct the scene, update context/gamestate/story arc, create images, query, prompt or yield to the user.", + ), + "agents-director-chat-instructors": ( + "Director Chat Instructors", + "The director chat's instructor modules: instruct characters, the narrator, world/history/story updates and character creation.", + ), + "agents-director-plan": ( + "Director Planning Nodes", + "Director plans: create, update, advance and complete plans and their tasks.", + ), + "agents-editor": ( + "Editor Agent Nodes", + "Clean up user input, narration and character messages through the editor agent.", + ), + "agents-memory": ( + "Memory Agent Nodes", + "Query the long-term memory context database and unpack memory documents.", + ), + "agents-narrator": ( + "Narrator Agent Nodes", + "Generate narration messages: progress, scene, environment, query, character entry/exit and time narration.", + ), + "agents-summarizer": ( + "Summarizer Agent Nodes", + "Summarization: scene progression summaries, tag generation and message avatar hooks.", + ), + "agents-tts": ( + "Voice Agent Nodes", + "Text-to-speech: get and unpack voices and generate speech through the voice agent.", + ), + "agents-visual": ( + "Visualizer Agent Prompt Nodes", + "Visual prompt building: prompt parts, styles, finalization and unpacking.", + ), + "agents-visual-generation": ( + "Visualizer Agent Generation Nodes", + "Visual generation: generation requests, image generation, backend selection/status and image analysis.", + ), + "agents-visual-modules": ( + "Visualizer Agent Modules", + "Ready-made visual generation flows: generate visual assets, avatars, portraits, image edits and regenerations.", + ), + "agents-world-state": ( + "World State Agent Nodes", + "World state operations: time advancement, character progression, world state emission and extraction.", + ), + "assets": ( + "Asset Nodes", + "Scene asset CRUD: add, get, list, remove and update assets, cover and avatar images.", + ), + "assets-meta": ( + "Asset Meta And Selection Nodes", + "Asset metadata, search, selection and attachment context for passing assets to generation.", + ), + "context-id": ( + "Context ID Nodes", + "Context IDs: build, convert, compress, read and write context-addressable values and pins.", + ), + "core": ( + "Core Nodes", + "Graph plumbing: module Input/Output/Property, Route, Watch, Null and Stage.", + ), + "core-functions": ( + "Function Nodes", + "Callable node modules: define, get and call functions, for-each calls, returns, error handlers, breakpoints and scoped API functions.", + ), + "core-logic": ( + "Logic Nodes", + "Branching and comparison: switches, routers, boolean logic and conditional routing.", + ), + "data": ( + "Data Nodes", + "General value handling: get/set, conditionals, sorting, selection, UUIDs, JSON, object updates and length/capping helpers.", + ), + "data-collections": ( + "Dict And List Nodes", + "Dictionaries and lists: make, get, set, update, pop, append, combine and collect.", + ), + "data-number": ( + "Number Nodes", + "Numeric values: conversion, arithmetic, comparison and random numbers.", + ), + "data-string": ( + "String Nodes", + "Strings: formatting, templates, joining, splitting, cleanup and excerpts.", + ), + "event": ( + "Event Nodes", + "Emit agent/system/status messages and listen to scene events.", + ), + "focal": ( + "AI Function Nodes (Focal)", + "AI function calling: define callable functions and arguments, unpack calls and collect results.", + ), + "input": ( + "Input Nodes", + "Process and wait for player input in the scene loop.", + ), + "prompt": ( + "Prompt Nodes", + "Build prompts and control generation: prompt construction, defaults and inference settings.", + ), + "raise": ( + "Flow Exception Nodes", + "Stop graph execution or raise errors: Stop, input value errors and acted-as-character signals.", + ), + "response": ( + "Response Parsing Nodes", + "Parse LLM responses: extractors for anchors, lists, movie-script dialogue and structured data.", + ), + "scene": ( + "Scene Nodes", + "Scene lifecycle and metadata: title, description, introduction, content classification, scene loop and storyline modules.", + ), + "scene-characters": ( + "Scene Character Nodes", + "Characters in the scene: get, make, activate/deactivate, remove and read/write attributes, details and descriptions.", + ), + "scene-history": ( + "Scene History Nodes", + "Scene message history: query, summarize and manage archive entries and character activity.", + ), + "scene-intent": ( + "Scene Intent Nodes", + "Scene intention: read and set the scene intent, scene phase and scene types.", + ), + "scene-messages": ( + "Scene Message Nodes", + "Scene messages: create, version, hide and manipulate messages in the scene log.", + ), + "scene-world-state": ( + "World Entry Nodes", + "World entries (get, save, unpack, remove) and generation options: spices and writing styles.", + ), + "state": ( + "State Nodes", + "Graph state variables: get/set/unset across scopes (local, parent, shared, scene loop, game), counters and path variants.", + ), + "util": ( + "Utility Nodes", + "Miscellaneous helpers: counters, diffs, list extraction, ISO date durations and module styling.", + ), + "util-packaging": ( + "Packaging Nodes", + "Node module packaging: define, install and configure scene packages.", + ), + "ux": ( + "UX Nodes", + "User-facing interface elements: choice elements, status displays and interactive prompts.", + ), + "validation": ( + "Validation Nodes", + "Validate values: characters, asset IDs, context IDs, containment and set/unset checks.", + ), + "websocket": ( + "Websocket Nodes", + "Websocket plumbing: routers, responses and operation done/failed signals.", + ), +} + + +@dataclasses.dataclass +class SocketDoc: + name: str + type: str + optional: bool = False + description: str = "" + + +@dataclasses.dataclass +class PropertyDoc: + name: str + type: str + default: str + description: str + choices: list | None = None + dynamic_choices: bool = False + min: float | int | None = None + max: float | int | None = None + step: float | int | None = None + readonly: bool = False + + +@dataclasses.dataclass +class NodeDoc: + registry: str + title: str + base_type: str + module: str + prose: str = "" + inputs: list[SocketDoc] = dataclasses.field(default_factory=list) + outputs: list[SocketDoc] = dataclasses.field(default_factory=list) + properties: list[PropertyDoc] = dataclasses.field(default_factory=list) + module_path: str = "" + dynamic_imported: bool = False + supports_dynamic_inputs: bool = False + error: str = "" + + +_SECTION_RE = re.compile(r"(Inputs?|Outputs?|Properties):\s*$") +_BULLET_RE = re.compile(r"[-*]\s+`?([\w{}\[\]. ]+)`?\s*(?:\(([^)]*)\))?:\s*(.*)") + +_SECTION_ALIASES = { + "input": "inputs", + "inputs": "inputs", + "output": "outputs", + "outputs": "outputs", + "properties": "properties", +} + + +def _parse_docstring(docstring: str) -> tuple[str, dict[str, dict[str, str]]]: + """ + Split a node docstring into prose and per-socket/property descriptions. + + Recognizes the ``Inputs:`` / ``Outputs:`` / ``Properties:`` bullet + sections used across the node modules (``- name: description``). + Everything else stays in the prose. Returns ``(prose, sections)`` + where ``sections`` maps section name -> {item name -> description}. + """ + + prose_lines: list[str] = [] + sections: dict[str, dict[str, str]] = {} + current: dict[str, str] | None = None + current_item: str | None = None + + for raw in (docstring or "").splitlines(): + line = raw.strip() + + header = _SECTION_RE.fullmatch(line) + if header: + current = sections.setdefault(_SECTION_ALIASES[header.group(1).lower()], {}) + current_item = None + continue + + if current is None: + prose_lines.append(line) + continue + + if not line: + current_item = None + continue + + bullet = _BULLET_RE.fullmatch(line) + if bullet: + current_item = bullet.group(1).strip() + desc = bullet.group(3).strip() + qualifier = (bullet.group(2) or "").strip() + if qualifier and qualifier.lower() not in ("optional",): + desc = f"({qualifier}) {desc}" if desc else f"({qualifier})" + current[current_item] = desc + continue + + if current_item: + # continuation line of the previous bullet + current[current_item] = f"{current[current_item]} {line}".strip() + else: + # section ended with untagged text - back to prose + current = None + prose_lines.append(line) + + prose = "\n".join(prose_lines) + # collapse 3+ consecutive newlines and trim + prose = re.sub(r"\n{3,}", "\n\n", prose).strip() + return prose, sections + + +def _fmt_value(value) -> str: + from talemate.game.engine.nodes.core import UNRESOLVED + + if value is UNRESOLVED: + return "unset" + if value is None: + return "None" + if isinstance(value, str): + formatted = f'"{value}"' + else: + formatted = repr(value) + if len(formatted) > MAX_DEFAULT_LENGTH: + formatted = formatted[:MAX_DEFAULT_LENGTH] + "…" + return formatted + + +def _socket_type(socket_type) -> str: + if isinstance(socket_type, (list, tuple)): + return ", ".join(str(t) for t in socket_type) + return str(socket_type) + + +def collect_node_docs() -> list[NodeDoc]: + """Instantiate every registered node class and collect its metadata.""" + + from .analysis import ensure_registry_loaded + + ensure_registry_loaded() + + from talemate.game.engine.nodes.core.dynamic import DynamicSocketNodeBase + from talemate.game.engine.nodes.registry import NODES + + docs: list[NodeDoc] = [] + + for registry, cls in sorted(NODES.items()): + dynamic_imported = bool(getattr(cls, "__dynamic_imported__", False)) + prose, sections = _parse_docstring(cls.__doc__ or "") + + module_path = str(getattr(cls, "_module_path", "") or "") + if module_path: + try: + module_path = str(relative_to_root(module_path)) + except ValueError: + pass + + doc = NodeDoc( + registry=registry, + title=registry.split("/")[-1], + base_type=getattr(cls, "_base_type", "") or "", + module=cls.__module__, + prose=prose, + module_path=module_path, + dynamic_imported=dynamic_imported, + supports_dynamic_inputs=issubclass(cls, DynamicSocketNodeBase), + ) + + try: + inst = cls() + except Exception as exc: + doc.error = f"{type(exc).__name__}: {exc}" + log.warning( + "glossary: could not instantiate node", + registry=registry, + error=doc.error, + ) + docs.append(doc) + continue + + doc.title = inst.title + + input_descs = sections.get("inputs", {}) + output_descs = sections.get("outputs", {}) + property_descs = sections.get("properties", {}) + + doc.inputs = [ + SocketDoc( + name=s.name, + type=_socket_type(s.socket_type), + optional=s.optional, + description=input_descs.get(s.name, ""), + ) + for s in inst.inputs + ] + doc.outputs = [ + SocketDoc( + name=s.name, + type=_socket_type(s.socket_type), + description=output_descs.get(s.name, ""), + ) + for s in inst.outputs + ] + + try: + field_definitions = inst.field_definitions + except Exception as exc: + field_definitions = {} + log.warning( + "glossary: could not read field definitions", + registry=registry, + error=str(exc), + ) + + for name, field in field_definitions.items(): + doc.properties.append( + PropertyDoc( + name=name, + type=field.type, + default=_fmt_value(inst.properties.get(name, field.default)), + description=field.description or property_descs.get(name, ""), + choices=field.choices, + dynamic_choices=field.generate_choices is not None, + min=field.min, + max=field.max, + step=field.step, + readonly=field.readonly, + ) + ) + + docs.append(doc) + + return docs + + +def _page_slug(doc: NodeDoc) -> str: + override = NODE_OVERRIDES.get(doc.registry) + if override: + return override + + parts = doc.registry.split("/") + name = parts[-1] + dirname = "/".join(parts[:-1]) + + # the flat scene/* space is large; character manipulation nodes get + # their own page + if dirname == "scene" and "Character" in name: + return "scene-characters" + + # dict/list container nodes split off the general data page + if dirname == "data" and ("Dict" in name or "List" in name): + return "data-collections" + + # director chat is the largest module family - split its action and + # instructor modules off the chat plumbing page + if parts[0] == "agents" and dirname.endswith("director/chat"): + if name.startswith("directorAction"): + return "agents-director-chat-actions" + if name.startswith("instruct"): + return "agents-director-chat-instructors" + + # visual agent: JSON module graphs get their own page next to the + # python nodes + if dirname == "agents/visual" and doc.dynamic_imported: + return "agents-visual-modules" + + probe = dirname + while probe: + if probe in PREFIX_MAP: + return PREFIX_MAP[probe] + probe = probe.rsplit("/", 1)[0] if "/" in probe else "" + + if not doc.dynamic_imported: + module_tail = doc.module.rsplit(".", 1)[-1] + if module_tail in MODULE_MAP: + return MODULE_MAP[module_tail] + + return parts[0] + + +def _page_title(slug: str) -> tuple[str, str]: + if slug in PAGE_TITLES: + return PAGE_TITLES[slug] + title = slug.replace("-", " ").title() + " Nodes" + return title, f"Reference for the `{slug}` node category." + + +def _escape_cell(text: str) -> str: + return (text or "").replace("|", "\\|").replace("\n", " ").strip() + + +def _render_property_extras(prop: PropertyDoc) -> str: + extras = [] + if prop.choices: + shown = [f"`{c}`" for c in prop.choices[:12]] + if len(prop.choices) > 12: + shown.append("…") + extras.append(f"Choices: {', '.join(shown)}.") + elif prop.dynamic_choices: + extras.append("Choices are generated at runtime.") + if prop.min is not None and prop.max is not None: + extras.append(f"Range: {prop.min} – {prop.max}.") + elif prop.min is not None: + extras.append(f"Min: {prop.min}.") + elif prop.max is not None: + extras.append(f"Max: {prop.max}.") + if prop.readonly: + extras.append("Read-only.") + return " ".join(extras) + + +def _render_node(doc: NodeDoc, heading: str) -> str: + out: list[str] = [f"## {heading}", ""] + out.append(f"`{doc.registry}`") + out.append("") + + if doc.error: + out.append( + f"_Metadata for this node could not be fully resolved " + f"({_escape_cell(doc.error)})._" + ) + out.append("") + + if doc.dynamic_imported: + origin = f" defined in `{doc.module_path}`" if doc.module_path else "" + out.append(f"Node module (base type `{doc.base_type}`){origin}.") + out.append("") + + if doc.prose: + out.append(doc.prose) + out.append("") + + if doc.inputs: + out.append("**Inputs**") + out.append("") + out.append("| Input | Type | Description |") + out.append("| --- | --- | --- |") + for socket in doc.inputs: + desc = socket.description + if socket.optional and "(optional)" not in desc.lower(): + desc = f"(optional) {desc}".strip() + out.append( + f"| `{socket.name}` | `{_escape_cell(socket.type)}` " + f"| {_escape_cell(desc)} |" + ) + out.append("") + + if doc.supports_dynamic_inputs: + out.append( + "This node supports **dynamic inputs** - additional input " + "sockets can be added in the editor as needed, and connected " + "values are collected with key inference (see " + "[Collector Nodes](../../core-concepts/collector_nodes.md))." + ) + out.append("") + + # agent Settings nodes mirror the agent's entire settings schema as + # output sockets/properties; the per-agent documentation already + # covers every field, so a pointer replaces the huge tables + settings_node = doc.registry.startswith("agents/") and doc.registry.endswith( + "/Settings" + ) + + if settings_node: + agent_name = doc.registry.split("/")[1] + out.append( + f"Every setting of the {agent_name} agent is exposed as an " + f"output socket named after the setting - see the agent's " + f"documentation under [Agents](../../../agents/index.md) for " + f"what each setting does." + ) + out.append("") + elif doc.outputs: + out.append("**Outputs**") + out.append("") + out.append("| Output | Type | Description |") + out.append("| --- | --- | --- |") + for socket in doc.outputs: + out.append( + f"| `{socket.name}` | `{_escape_cell(socket.type)}` " + f"| {_escape_cell(socket.description)} |" + ) + out.append("") + + if doc.properties and not settings_node: + out.append("**Properties**") + out.append("") + out.append("| Property | Type | Default | Description |") + out.append("| --- | --- | --- | --- |") + for prop in doc.properties: + description = _escape_cell(prop.description) + extras = _render_property_extras(prop) + if description and extras and not description.endswith((".", "!", "?")): + description += "." + desc = " ".join(part for part in (description, extras) if part) + out.append( + f"| `{prop.name}` | `{_escape_cell(prop.type)}` " + f"| `{_escape_cell(prop.default)}` | {desc} |" + ) + out.append("") + + return "\n".join(out).rstrip() + "\n" + + +def render_pages(docs: list[NodeDoc]) -> dict[str, str]: + """ + Render the generated portion of every glossary page. + + Returns a mapping of page slug -> generated markdown (the content that + belongs below ``GENERATED_MARKER``). Includes the ``index`` page. + """ + + by_page: dict[str, list[NodeDoc]] = {} + for doc in docs: + by_page.setdefault(_page_slug(doc), []).append(doc) + + pages: dict[str, str] = {} + + for slug, page_docs in sorted(by_page.items()): + page_docs.sort(key=lambda d: (d.registry.lower())) + + # disambiguate duplicate titles within a page so headings stay + # unique anchors + title_counts: dict[str, int] = {} + for doc in page_docs: + title_counts[doc.title] = title_counts.get(doc.title, 0) + 1 + headings = { + doc.registry: ( + doc.title + if title_counts[doc.title] == 1 + else f"{doc.title} (`{doc.registry}`)" + ) + for doc in page_docs + } + + out: list[str] = ["", ""] + out.append(f"_{len(page_docs)} nodes._") + out.append("") + out.append("| Node | Registry path |") + out.append("| --- | --- |") + for doc in page_docs: + anchor = re.sub(r"[^\w\s-]", "", headings[doc.registry].lower()) + anchor = re.sub(r"[-\s]+", "-", anchor.strip()) + out.append(f"| [{doc.title}](#{anchor}) | `{doc.registry}` |") + out.append("") + for doc in page_docs: + out.append(_render_node(doc, headings[doc.registry])) + + pages[slug] = "\n".join(out).rstrip() + "\n" + + # index page: category overview table + out = ["", ""] + out.append( + "Every node available in the node editor, grouped by category. " + "Each page lists the node's registry path, inputs, outputs and " + "properties." + ) + out.append("") + out.append("| Page | Nodes | Description |") + out.append("| --- | --- | --- |") + # sort by page title, not slug - slug order fronts all agents-* pages + for slug, page_docs in sorted( + by_page.items(), key=lambda item: _page_title(item[0])[0].lower() + ): + title, description = _page_title(slug) + out.append( + f"| [{title}]({slug}.md) | {len(page_docs)} | {_escape_cell(description)} |" + ) + out.append("") + pages["index"] = "\n".join(out).rstrip() + "\n" + + return pages + + +def _split_existing(content: str) -> tuple[str, str] | None: + """Split page content into (intro, generated) at the marker line.""" + + if GENERATED_MARKER not in content: + return None + intro, _, generated = content.partition(GENERATED_MARKER) + return intro, generated + + +def _default_intro(slug: str) -> str: + title, description = _page_title(slug) + return f"# {title}\n\n{description}\n\n" + + +def _page_path(docs_root: Path, slug: str) -> Path: + return docs_root / f"{slug}.md" + + +def write_pages(docs_root: Path | None = None) -> int: + """Render and write all glossary pages. Returns an exit code.""" + + docs_root = docs_root or DEFAULT_DOCS_ROOT + docs_root.mkdir(parents=True, exist_ok=True) + + docs = collect_node_docs() + pages = render_pages(docs) + + for slug, generated in pages.items(): + path = _page_path(docs_root, slug) + intro = _default_intro(slug) + if path.exists(): + split = _split_existing(path.read_text(encoding="utf-8")) + if split is not None: + intro = split[0] + else: + log.warning( + "glossary: page has no marker, restoring default intro", + page=str(path), + ) + content = f"{intro}{GENERATED_MARKER}\n{generated}" + path.write_text(content, encoding="utf-8") + size = len(content) + if size > PAGE_SIZE_BUDGET: + log.warning( + "glossary: page exceeds size budget", + page=f"{slug}.md", + size=size, + budget=PAGE_SIZE_BUDGET, + ) + + for stale in sorted(docs_root.glob("*.md")): + if stale.stem not in pages: + log.warning("glossary: stale page not in render set", page=str(stale)) + + print(f"glossary: wrote {len(pages)} pages to {docs_root}") + return 0 + + +def check_pages(docs_root: Path | None = None) -> int: + """ + Verify the committed glossary pages match a fresh render. + + Returns 0 when in sync, 1 when pages are missing, stale or drifted. + """ + + docs_root = docs_root or DEFAULT_DOCS_ROOT + + docs = collect_node_docs() + pages = render_pages(docs) + + problems: list[str] = [] + + for slug, generated in sorted(pages.items()): + path = _page_path(docs_root, slug) + if not path.exists(): + problems.append(f"missing page: {path.name}") + continue + content = path.read_text(encoding="utf-8") + split = _split_existing(content) + if split is None: + problems.append(f"missing generated marker: {path.name}") + continue + if split[1] != f"\n{generated}": + problems.append(f"drifted page: {path.name}") + if len(content) > PAGE_SIZE_BUDGET: + print( + f"glossary: warning: {path.name} exceeds size budget " + f"({len(content)} > {PAGE_SIZE_BUDGET})" + ) + + if docs_root.exists(): + for stale in sorted(docs_root.glob("*.md")): + if stale.stem not in pages: + problems.append(f"stale page: {stale.name}") + + if problems: + for problem in problems: + print(f"glossary: {problem}") + print( + "glossary: out of sync - run " + "`python -m talemate.game.engine.nodes.tools glossary --write`" + ) + return 1 + + print(f"glossary: {len(pages)} pages in sync") + return 0 diff --git a/src/talemate/game/engine/nodes/util.py b/src/talemate/game/engine/nodes/util.py index 89b60328..b0f0da92 100644 --- a/src/talemate/game/engine/nodes/util.py +++ b/src/talemate/game/engine/nodes/util.py @@ -30,6 +30,7 @@ class Counter(Node): - reset: If true, the value will be reset to 0 Outputs: + - state: The state input, passed through - value: The new value - dict: The dict with the new value """ @@ -71,6 +72,7 @@ class Counter(Node): self.set_property("key", "counter") self.set_property("reset", False) + self.add_output("state") self.add_output("value") self.add_output("dict", socket_type="dict") @@ -88,13 +90,31 @@ class Counter(Node): else: dict_[key] = dict_.get(key, 0) + increment - self.set_output_values({"value": dict_[key], "dict": dict_}) + self.set_output_values( + { + "state": self.get_input_value("state"), + "value": dict_[key], + "dict": dict_, + } + ) @register("util/Diff") class Diff(Node): """ Diff node that returns the diff between two strings. + + Inputs: + + - a: The original string + - b: The changed string + + Outputs: + + - diff_plain: The diff as plain text + - diff_html: The diff as inline HTML markup + - a: The a input, passed through + - b: The b input, passed through """ def __init__(self, title="Diff", **kwargs): @@ -122,6 +142,19 @@ class Diff(Node): class ExtractList(Node): """ Extracts a list from a string. + + Parses a numbered ("1.") or bulleted ("*" / "-") list out of text, such + as an LLM response, returning the items as a list of strings. + + Inputs: + + - string: The text to extract the list from + + Outputs: + + - string: The string input, passed through + - list: The extracted list items + - is_empty: True if no list items were found """ def __init__(self, title="Extract List", **kwargs): @@ -143,7 +176,26 @@ class ExtractList(Node): @register("util/IsoDateDuration") class IsoDateDuration(Node): """ - IsoDateDuration node that allows constructing ISO 8601 interval strings. + IsoDateDuration node that allows constructing ISO 8601 duration strings. + + Combines an amount and a unit into an ISO 8601 duration + (e.g., 3 + "day" -> "P3D"). + + Inputs: + + - unit: The unit of the duration (year, month, week, day, hour, minute, second) + - amount: The amount of the duration + + Properties: + + - unit: The unit of the duration + - amount: The amount of the duration + + Outputs: + + - unit: The unit input, passed through + - amount: The amount input, passed through + - duration: The ISO 8601 duration string """ class Fields: diff --git a/src/talemate/game/engine/nodes/ux.py b/src/talemate/game/engine/nodes/ux.py index 0371b9a8..3d82acad 100644 --- a/src/talemate/game/engine/nodes/ux.py +++ b/src/talemate/game/engine/nodes/ux.py @@ -145,9 +145,14 @@ class BuildChoiceElement(_BuildUxElementMixin, Node): """ Builds a choice UX element payload. + A random id is generated when none is given. Raises an error if choices is + empty. Emit the resulting element with `ux/EmitElement`. + Inputs: - state: any - - id: str (optional) + - id: str (optional; auto-generated when not set) + - closable: bool (optional) + - timeout_seconds: int (optional; 0 = no timeout) - title: str (optional) - body: str (optional) - choices: list (required) @@ -156,6 +161,14 @@ class BuildChoiceElement(_BuildUxElementMixin, Node): Outputs: - state: any + - id: the element id (resolved) + - closable: bool (resolved) + - timeout_seconds: int (resolved) + - title: str (resolved) + - body: str (resolved) + - choices: list (passed through) + - multi_select: bool (resolved) + - default: the resolved default selection - ux_id: str - ux_element: dict """ @@ -256,9 +269,14 @@ class BuildTextInputElement(_BuildUxElementMixin, Node): """ Builds a text input UX element payload. + A random id is generated when none is given. Emit the resulting element + with `ux/EmitElement`. + Inputs: - state: any - - id: str (optional) + - id: str (optional; auto-generated when not set) + - closable: bool (optional) + - timeout_seconds: int (optional; 0 = no timeout) - title: str (optional) - body: str (optional) - multiline: bool (optional) @@ -269,6 +287,16 @@ class BuildTextInputElement(_BuildUxElementMixin, Node): Outputs: - state: any + - id: the element id (resolved) + - closable: bool (resolved) + - timeout_seconds: int (resolved) + - title: str (resolved) + - body: str (resolved) + - multiline: bool (resolved) + - rows: int (resolved) + - placeholder: str (resolved) + - default: str (resolved) + - trim: bool (resolved) - ux_id: str - ux_element: dict """ @@ -429,7 +457,7 @@ class BuildNoticeElement(_BuildUxElementMixin, Node): Inputs: - state: any - - id: str (optional) + - id: str (optional; auto-generated when not set) - closable: bool (optional) - timeout_seconds: int (optional) - title: str (optional) @@ -437,6 +465,11 @@ class BuildNoticeElement(_BuildUxElementMixin, Node): Outputs: - state: any + - id: the element id (resolved) + - closable: bool (resolved) + - timeout_seconds: int (resolved) + - title: str (resolved) + - body: str (resolved) - ux_id: str - ux_element: dict """ @@ -501,6 +534,8 @@ class StyleElement(Node): - ux_element: dict - tint: str (optional) - Vuetify color name - icon: str (optional) - mdi-* icon name + - apply_scene_colors: bool (optional) - render body through the scene-message parser for per-category colors + - compact: bool (optional) - render the element in a condensed layout Outputs: - state: any @@ -611,9 +646,11 @@ class EmitElement(Node): """ Emits a UX element to the frontend (websocket passthrough). - Choice elements are awaitable by design: emitting a choice element will wait - for the user to select/cancel (with optional element-defined timeout), + Choice and text input elements are awaitable by design: emitting one will + wait for the user to submit/cancel (with optional element-defined timeout), then close the UX element and return the captured interaction values. + A timeout is reported as both `timed_out` and `cancelled`. Notice elements + are fire-and-forget and return immediately. Inputs: - state: any diff --git a/src/talemate/game/engine/nodes/validation.py b/src/talemate/game/engine/nodes/validation.py index ad6b8a8d..bae9697c 100644 --- a/src/talemate/game/engine/nodes/validation.py +++ b/src/talemate/game/engine/nodes/validation.py @@ -72,9 +72,24 @@ class ValidateNode(Node): @register("validation/ValidateValueIsSet") class ValidateValueIsSet(ValidateNode): """ - Validate the truthyness of a value + Validate that a value is set, raising an error if it isn't. - '', null are considered false + A value counts as unset when it is null, unresolved or - when + `blank_string_is_unset` is true - a blank string. Other falsy values + (0, false) count as set. On success the value is passed through. + + Inputs: + + - value: The value to validate + - error_message: Custom error message, `{value}` is replaced with the value (optional) + + Properties: + + - blank_string_is_unset: If true, a blank string is considered unset + + Outputs: + + - value: The validated value, passed through """ class Fields(ValidateNode.Fields): @@ -107,7 +122,18 @@ class ValidateValueIsSet(ValidateNode): @register("validation/ValidateValueIsNotSet") class ValidateValueIsNotSet(ValidateNode): """ - Validate the value is not set + Validate that a value is NOT set, raising an error if it is. + + A value counts as unset when it is null, unresolved or a blank string. + + Inputs: + + - value: The value to validate + - error_message: Custom error message, `{value}` is replaced with the value (optional) + + Outputs: + + - value: The (unset) value, passed through """ def __init__(self, title="Validate Value Is Not Set", **kwargs): @@ -128,7 +154,20 @@ class ValidateValueIsNotSet(ValidateNode): @register("validation/ValidateValueContained") class ValidateValueContained(ValidateNode): """ - Validate the value is contained in a list or dictionary + Validate that a value is contained in a list or dictionary, raising an + error if it isn't. + + For dictionaries, containment is checked against the keys. + + Inputs: + + - value: The value to validate + - error_message: Custom error message, `{value}` is replaced with the value (optional) + - list: The list or dictionary to check containment against + + Outputs: + + - value: The validated value, passed through """ def __init__(self, title="Validate Value Contained", **kwargs): @@ -150,7 +189,20 @@ class ValidateValueContained(ValidateNode): @register("validation/ValidateContextIDString") class ValidateContextIDString(ValidateNode): """ - Validate the value is a context ID string + Validate that a value is a valid context ID string, raising an error if it + isn't. + + Surrounding backticks and whitespace are stripped before validation, and + the cleaned string is passed through on success. + + Inputs: + + - value: The context ID string to validate + - error_message: Custom error message, `{value}` is replaced with the value (optional) + + Outputs: + + - value: The cleaned, validated context ID string """ def __init__(self, title="Validate Context ID String", **kwargs): @@ -178,7 +230,25 @@ class ValidateContextIDString(ValidateNode): @register("validation/ValidateContextIDItem") class ValidateContextIDItem(ValidateNode): """ - Validate the value is a context ID item + Validate that a value is a context ID string that resolves to an existing + context ID item, raising an error if it doesn't. + + Surrounding backticks and whitespace are stripped before validation. On + success the resolved item and its details are provided as outputs. + + Inputs: + + - value: The context ID string to validate and resolve + - error_message: Custom error message, `{value}` is replaced with the value (optional) + + Outputs: + + - value: The cleaned, validated context ID string + - context_id: The context ID of the resolved item + - context_id_item: The resolved context ID item + - context_type: The context type of the resolved item + - context_value: The current value stored at the context ID + - name: The name of the resolved item """ def __init__(self, title="Validate Context ID Item", **kwargs): @@ -231,7 +301,26 @@ class ValidateContextIDItem(ValidateNode): @register("validation/ValidateCharacter") class ValidateCharacter(ValidateNode): """ - Validate the value is a character + Validate that a value is the name of a character in the scene, raising an + error if it isn't. + + Optionally restrict to active or inactive characters, or create a + placeholder character when the name doesn't exist. + + Inputs: + + - value: The character name to validate + - error_message: Custom error message, `{value}` is replaced with the value (optional) + + Properties: + + - character_status: Which characters are allowed (active, inactive, or all) + - create_placeholder: Whether to create a placeholder character if the character does not exist + + Outputs: + + - value: The validated character name, passed through + - character: The character object (or placeholder) """ class Fields(ValidateNode.Fields): @@ -300,7 +389,18 @@ class ValidateCharacter(ValidateNode): @register("validation/ValidateAssetID") class ValidateAssetID(ValidateNode): """ - Validate the value is a asset ID + Validate that a value is the ID of an existing scene asset, raising an + error if it isn't. + + Inputs: + + - value: The asset ID to validate + - error_message: Custom error message, `{value}` is replaced with the value (optional) + + Outputs: + + - value: The validated asset ID, passed through + - asset: The asset object """ def __init__(self, title="Validate Asset ID", **kwargs): diff --git a/src/talemate/game/engine/nodes/websocket.py b/src/talemate/game/engine/nodes/websocket.py index 0b8910cf..037ffb4b 100644 --- a/src/talemate/game/engine/nodes/websocket.py +++ b/src/talemate/game/engine/nodes/websocket.py @@ -63,7 +63,25 @@ class WebsocketBase(Node): @register("websocket/signals/OperationDone") class OperationDone(WebsocketBase): """ - A node that signals that an operation has been done + Signal to the frontend that a websocket operation has completed successfully. + + Sends an `operation_done` message through the websocket router, optionally + emits a status message and - unless `signal_only` is set - triggers an auto + save of the scene (or marks the scene as unsaved when auto save is off or + disallowed). + + Inputs: + + - state: The graph state + - websocket_router: The websocket router (plugin) to signal through + - signal_only: If true, only send the signal and skip the save handling (optional) + - allow_auto_save: Whether to allow the scene to auto save after the operation (optional) + - emit_status_message: Status message to display in the frontend (optional) + + Outputs: + + - state: The state input, passed through + - websocket_router: The websocket router, passed through """ class Fields: @@ -116,7 +134,22 @@ class OperationDone(WebsocketBase): @register("websocket/signals/OperationFailed") class OperationFailed(WebsocketBase): """ - A node that signals that an operation has failed + Signal to the frontend that a websocket operation has failed. + + Sends an `operation_done` message carrying the error through the websocket + router and optionally emits an error status message in the frontend. + + Inputs: + + - state: The graph state + - websocket_router: The websocket router (plugin) to signal through + - message: The error message (optional) + - emit_status: Whether to also emit an error status message (optional) + + Outputs: + + - state: The state input, passed through + - websocket_router: The websocket router, passed through """ class Fields: @@ -158,7 +191,24 @@ class OperationFailed(WebsocketBase): @register("websocket/WebsocketResponse") class QueueResponse(WebsocketBase): """ - A node that queues a response to be sent to the websocket + Queue a message to be sent to the frontend through the websocket. + + The message is sent with the router's name as its type, the given action + name, and the data dict merged into the message payload. + + Inputs: + + - state: The graph state + - websocket_router: The websocket router (plugin) to send the message through + - action: The action name of the message + - data: Dict of additional payload fields merged into the message + + Outputs: + + - state: The state input, passed through + - websocket_router: The websocket router, passed through + - action: The action input, passed through + - data: The data input, passed through """ class Fields: @@ -213,7 +263,20 @@ class QueueResponse(WebsocketBase): @register("websocket/GetWebsocketRouter") class GetWebsocketRouter(Node): """ - A node that gets a websocket router + Get a websocket router (plugin) by its route name from the active + websocket handler. + + Raises an error if no plugin is registered for the given route. + + Properties: + + - router: The route name to get the websocket plugin for + + Outputs: + + - router: The route name, passed through + - websocket_router: The websocket router (plugin) instance + - websocket_handler: The active websocket handler """ class Fields: @@ -251,7 +314,6 @@ class GetWebsocketRouter(Node): websocket_handler = active_websocket_handler() self.set_output_values( { - "state": state, "websocket_router": websocket_router, "websocket_handler": websocket_handler, "router": router, diff --git a/src/talemate/game/engine/nodes/world_state.py b/src/talemate/game/engine/nodes/world_state.py index 36927db9..7dd56eb3 100644 --- a/src/talemate/game/engine/nodes/world_state.py +++ b/src/talemate/game/engine/nodes/world_state.py @@ -117,7 +117,22 @@ class SaveWorldEntry(WorldStateManagerNode): @register("scene/worldstate/GetWorldEntry") class GetWorldEntry(WorldStateManagerNode): """ - Gets a world entry + Gets a world entry by its id + + Inputs: + + - entry_id: The id of the world entry + + Properties: + + - entry_id: The id of the world entry + + Outputs: + + - world_entry: The world entry object + - entry_id: The id of the world entry, passed through + - text: The text of the world entry + - shared: Whether the entry is shared """ class Fields: @@ -156,7 +171,24 @@ class GetWorldEntry(WorldStateManagerNode): @register("scene/worldstate/GetWorldEntries") class GetWorldEntries(WorldStateManagerNode): """ - Gets all world entries + Gets world entries, optionally filtered by a list of ids + + Ids are matched case-insensitively. If no ids are given, all world + entries are returned. If raise_on_missing is enabled, an error is + raised when any requested id is not found. + + Inputs: + + - ids: The ids of the world entries to get + + Properties: + + - ids: The ids of the world entries to get + - raise_on_missing: Whether to raise an error if a world entry is missing + + Outputs: + + - world_entries: Dictionary of world entries keyed by entry id """ class Fields: @@ -213,7 +245,17 @@ class GetWorldEntries(WorldStateManagerNode): @register("scene/worldstate/UnpackWorldEntry") class UnpackWorldEntry(Node): """ - Unpacks a world entry + Unpacks a world entry into its individual fields + + Inputs: + + - world_entry: The world entry object + + Outputs: + + - entry_id: The id of the world entry + - text: The text of the world entry + - meta: The meta of the world entry """ def __init__(self, title="Unpack World Entry", **kwargs): @@ -272,7 +314,7 @@ class Spices(Node): Outputs: - - spices: list of strings + - spices: The Spices object built from the spice values """ class Fields: @@ -291,7 +333,7 @@ class Spices(Node): self.set_property("spice_values", []) - self.add_output("spices", socket_type="list") + self.add_output("spices", socket_type="spices") async def run(self, state: GraphState): spice_values = self.get_input_value("spice_values") @@ -348,7 +390,7 @@ class GenerationOptions(Node): Inputs: - - spices: The spices to apply to the generation options + - spices: The spices to apply to the generation options (input-only) - spice_level: The spice level to apply to the generation options - writing_style: The writing style to apply to the generation options @@ -363,13 +405,6 @@ class GenerationOptions(Node): """ class Fields: - spices = PropertyField( - name="spices", - description="The spices to apply to the generation options", - type="spices", - default=UNRESOLVED, - ) - spice_level = PropertyField( name="spice_level", description="The spice level to apply to the generation options", @@ -391,7 +426,7 @@ class GenerationOptions(Node): super().__init__(title=title, **kwargs) def setup(self): - self.add_input("spices", socket_type="generation_options", optional=True) + self.add_input("spices", socket_type="spices", optional=True) self.add_input("spice_level", socket_type="number", optional=True) self.add_input("writing_style", socket_type="writing_style", optional=True) diff --git a/src/talemate/game/focal/util.py b/src/talemate/game/focal/util.py new file mode 100644 index 00000000..d3884caf --- /dev/null +++ b/src/talemate/game/focal/util.py @@ -0,0 +1,46 @@ +import json +from typing import Literal + +import yaml + +__all__ = [ + "strip_call_blocks", +] + + +def strip_call_blocks(response: str, schema_format: Literal["json", "yaml"]) -> str: + """ + Remove fenced code blocks that hold focal function calls from the visible + response text, leaving any other fenced blocks (code examples etc.) intact. + """ + parts = response.split("```") + kept: list[str] = [] + for i, part in enumerate(parts): + if i % 2 == 0: + kept.append(part) + continue + block = part.strip() + if block.startswith(schema_format): + block = block[len(schema_format) :].strip() + if _is_call_payload(block, schema_format): + continue + kept.append(f"```{part}```") + return "".join(kept).strip() + + +def _is_call_payload(block: str, schema_format: Literal["json", "yaml"]) -> bool: + try: + if schema_format == "yaml": + data = yaml.safe_load(block) + else: + data = json.loads(block) + except Exception: + return False + if isinstance(data, dict): + data = [data] + if not isinstance(data, list) or not data: + return False + return all( + isinstance(item, dict) and ("function" in item or "name" in item) + for item in data + ) diff --git a/src/talemate/instance.py b/src/talemate/instance.py index 69084828..7e63deb4 100644 --- a/src/talemate/instance.py +++ b/src/talemate/instance.py @@ -19,6 +19,10 @@ log = structlog.get_logger("talemate") AGENTS = {} CLIENTS = {} +# in-flight client.status() tasks keyed by client name - lets overlapping +# status sweeps share one remote round-trip per client instead of stacking them +CLIENT_STATUS_TASKS = {} + def get_agent(typ: str): agent = AGENTS.get(typ) @@ -34,6 +38,7 @@ async def destroy_client(name: str): if client: await client.destroy() del CLIENTS[name] + CLIENT_STATUS_TASKS.pop(name, None) def get_client(name: str): @@ -86,10 +91,14 @@ async def emit_clients_status(wait_for_status: bool = False): """ # log.debug("emit", type="client status") tasks = [] - for client in list(CLIENTS.values()): - if client: + for name, client in list(CLIENTS.items()): + if not client: + continue + task = CLIENT_STATUS_TASKS.get(name) + if not task or task.done(): task = asyncio.create_task(client.status()) - tasks.append(task) + CLIENT_STATUS_TASKS[name] = task + tasks.append(task) if wait_for_status: await asyncio.gather(*tasks) @@ -197,6 +206,7 @@ async def instantiate_agents(): async def instantiate_clients(): config: Config = get_config() + created = False for name, client_config in config.clients.items(): if name in CLIENTS: continue @@ -205,8 +215,10 @@ async def instantiate_clients(): **client_config.model_dump() ) CLIENTS[name] = client + created = True - await emit_clients_status() + if created: + await emit_clients_status() async def configure_agents(): diff --git a/src/talemate/load/__init__.py b/src/talemate/load/__init__.py index 772471fe..041b986b 100644 --- a/src/talemate/load/__init__.py +++ b/src/talemate/load/__init__.py @@ -373,8 +373,11 @@ async def load_scene_from_data( scene.layered_history = [] scene.intent_state.reset() - scene.assets.cover_image = scene_data.get("assets", {}).get("cover_image", None) - scene.assets.load_assets(scene_data.get("assets", {}).get("assets", {})) + asset_data = scene_data.get("assets", {}) + scene.assets.cover_image = asset_data.get("cover_image", None) + scene.assets.backdrop = asset_data.get("backdrop", None) + scene.assets.backdrop_enabled = asset_data.get("backdrop_enabled", True) + scene.assets.load_assets(asset_data.get("assets", {})) # Clean up cover images and message avatars that reference non-existent assets scene.assets.cleanup_cover_images() diff --git a/src/talemate/load/character_card.py b/src/talemate/load/character_card.py index 658e5f4f..90920fe9 100644 --- a/src/talemate/load/character_card.py +++ b/src/talemate/load/character_card.py @@ -11,11 +11,18 @@ import structlog import talemate.instance as instance from talemate import Character, Player +from talemate.agents.creator.character import CharacterGenerationRequest from talemate.character import activate_character -from talemate.exceptions import UnknownDataSpec +from talemate.exceptions import GenerationCancelled, LLMAccuracyError, 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 +from talemate.util import ( + extract_metadata, + select_best_texts_by_keyword, + count_tokens, + replace_smart_quotes, +) from talemate.util.colors import unique_random_colors from talemate.agents.base import DynamicInstruction from talemate.game.engine.nodes.registry import import_scene_node_definitions @@ -152,6 +159,15 @@ class CharacterCardImportOptions(pydantic.BaseModel): use_asset_as_reference: bool = True selected_character_names: list[str] = pydantic.Field(default_factory=list) + # AI generation toggles - disabling one skips the corresponding LLM step + # during import, falling back to the raw character card data + determine_content_context: bool = True + extract_description: bool = True + extract_attributes: bool = True + extract_dialogue_instructions: bool = True + extract_dialogue_examples: bool = True + generate_story_intent: bool = True + # Player character options (mutually exclusive) player_character_template: PlayerCharacterTemplate | None = None player_character_existing: str | None = None # detected character name @@ -214,65 +230,65 @@ 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 def _setup_loading_status( + import_options: "CharacterCardImportOptions", num_characters: int = 1, has_character_book: bool = False, num_episodes: int = 0, - generate_episode_titles: bool = False, ) -> LoadingStatus: """Set up and return loading status tracker. Args: + import_options: Import options determining which generation steps run num_characters: Number of characters being imported has_character_book: Whether character book entries will be loaded num_episodes: Number of episodes that will be added - generate_episode_titles: Whether episode titles will be generated """ director = instance.get_agent("director") + creator = instance.get_agent("creator") # Base steps: # 1. Loading character card # 2. Initializing long-term memory # 3. Loading character book entries (conditional) - # 4. Determine character context (first character only) - # 5. Determine description (per character) - # 6. Determine character attributes (per character) - # 7. Determine dialogue examples (per character) - # 8. Generating story intent - # 9. Generating scene types (if auto_direct) - # 10. Setting scene intent (if auto_direct) - # 11. Generating episode titles (one per episode if enabled) - loading_steps = 4 # Base: card, memory, context, story intent + # 4. Determine character context (first character only, if enabled) + # 5. Determine description (per character, if enabled) + # 6. Determine character attributes (per character, if enabled) + # 7. Determine dialogue instructions (per character, if enabled) + # 8. Determine dialogue examples (per character, if enabled) + # 9. Generating story intent (if enabled) + # 10. Generating scene types (if auto_direct and story intent enabled) + # 11. Setting scene intent (if auto_direct and story intent enabled) + # 12. Generating episode titles (one per episode if enabled) + loading_steps = 2 # Base: card, memory + if import_options.determine_content_context: + loading_steps += 1 if has_character_book: loading_steps += 1 - loading_steps += ( - num_characters * 3 - ) # Description + attributes + dialogue examples per character - if director.auto_direct_enabled: - loading_steps += 2 - if generate_episode_titles and num_episodes > 0: + per_character_steps = sum( + [ + import_options.extract_description, + import_options.extract_attributes, + import_options.extract_dialogue_instructions, + import_options.extract_dialogue_examples, + ] + ) + if creator.cc_fast and per_character_steps > 0: + # fast mode consolidates the enabled extractions into one step + per_character_steps = 1 + loading_steps += num_characters * per_character_steps + if import_options.generate_story_intent: + loading_steps += 1 + if director.auto_direct_enabled: + loading_steps += 2 + if import_options.generate_episode_titles and num_episodes > 0: loading_steps += num_episodes loading_status = LoadingStatus(loading_steps, cancellable=True) loading_status("Loading character card...") @@ -448,6 +464,8 @@ async def analyze_character_card(file_path: str) -> CharacterCardAnalysis: detected_character_names = await director.detect_characters_from_texts( texts=all_texts ) + except GenerationCancelled: + raise except Exception as e: log.warning("Failed to detect characters from texts", error=str(e)) @@ -537,6 +555,8 @@ async def _determine_character_context( character ) log.debug("content_context", content_context=scene.context) + except GenerationCancelled: + raise except Exception as e: log.error("determine_content_context_for_character", error=e) @@ -630,6 +650,9 @@ async def _determine_character_description( """Determine and set character description.""" loading_status(f"Determine description for {character.name}...") + # keep the card's description so a generation miss can restore it + original_description = character.description + try: creator = instance.get_agent("creator") dynamic_instructions = relevant_info.to_dynamic_instructions(scenario=False) @@ -642,12 +665,20 @@ async def _determine_character_description( text=relevant_info.scenario.content if relevant_info.scenario else "", dynamic_instructions=dynamic_instructions, ) + + if not character.description: + character.description = original_description + log.debug( "character_description", character=character.name, description=character.description, ) + except GenerationCancelled: + character.description = original_description + raise except Exception as e: + character.description = original_description log.warning("determine_character_description", error=e) @@ -655,21 +686,26 @@ async def _determine_character_attributes( character, loading_status: LoadingStatus, relevant_info: RelevantCharacterInfo, + max_attrs: int | None = None, ) -> None: - """Determine and set character attributes and dialogue instructions.""" + """Determine and set character attributes.""" loading_status("Determine character attributes...") try: world_state = instance.get_agent("world_state") - creator = instance.get_agent("creator") - character.base_attributes = await world_state.extract_character_sheet( + generated = await world_state.extract_character_sheet( name=character.name, + # the imported character has no actor yet - without the explicit + # context character the prompt's active-character loop misses it + character=character, dynamic_instructions=relevant_info.to_dynamic_instructions(scenario=False), + max_attributes=max_attrs, ) - character.dialogue_instructions = ( - await creator.determine_character_dialogue_instructions(character) - ) + # an extraction that produced nothing must not replace the card's + # own attributes + if generated: + character.base_attributes = generated # any values that are lists should be converted to strings joined by , for k, v in character.base_attributes.items(): @@ -677,10 +713,30 @@ async def _determine_character_attributes( character.base_attributes[k] = ",".join(v) log.debug("base_attributes parsed", base_attributes=character.base_attributes) + except GenerationCancelled: + raise except Exception as e: log.warning("determine_character_attributes", error=e) +async def _determine_character_dialogue_instructions( + character, + loading_status: LoadingStatus, +) -> None: + """Determine and set character dialogue instructions.""" + loading_status(f"Determine dialogue instructions for {character.name}...") + + try: + creator = instance.get_agent("creator") + character.dialogue_instructions = ( + await creator.determine_character_dialogue_instructions(character) + ) + except GenerationCancelled: + raise + except Exception as e: + log.warning("determine_character_dialogue_instructions", error=e) + + async def _determine_character_dialogue_examples( character, loading_status: LoadingStatus, @@ -699,6 +755,9 @@ async def _determine_character_dialogue_examples( """ loading_status(f"Determine dialogue examples for {character.name}...") + # keep the card's examples so a generation miss can restore them + original_example_dialogue = list(character.example_dialogue) + try: creator = instance.get_agent("creator") @@ -708,6 +767,9 @@ async def _determine_character_dialogue_examples( original_dialogue_examples_text if original_dialogue_examples_text else "" ) + # needs to be empty here, so the card's examples don't bias the rewrite + character.example_dialogue = [] + character.example_dialogue = ( await creator.determine_character_dialogue_examples( character, @@ -719,13 +781,20 @@ async def _determine_character_dialogue_examples( ) ) + if not character.example_dialogue: + character.example_dialogue = original_example_dialogue + log.debug( "determine_character_dialogue_examples", character=character.name, count=len(character.example_dialogue), examples=character.example_dialogue, ) + except GenerationCancelled: + character.example_dialogue = original_example_dialogue + raise except Exception as e: + character.example_dialogue = original_example_dialogue log.warning("determine_character_dialogue_examples", error=e) @@ -914,6 +983,8 @@ async def _generate_story_intent(scene, loading_status: LoadingStatus) -> None: ) loading_status("Setting scene intent...") await director.auto_direct_set_scene_intent(require=True) + except GenerationCancelled: + raise except Exception as e: log.error("generate story intent", error=e) @@ -1232,6 +1303,118 @@ def _create_characters_from_names( return characters +async def _process_character_fast( + character: Character, + loading_status: LoadingStatus, + relevant_info: RelevantCharacterInfo, + original_dialogue_examples_text: str, + import_options: CharacterCardImportOptions, + max_examples: int = 5, + max_attrs: int | None = None, +) -> None: + """Fast mode: route the enabled extraction aspects through the creator + agent's consolidated generation (one prompt instead of one per aspect). + + Which aspects actually go into the consolidated one-shot is decided by + the creator's "Character Creation" settings; the rest are generated with + their individual requests by the orchestrator. A completely unparseable + consolidated response is a hard error (per the Fast mode contract); any + other failure warns and keeps the card's original data, mirroring the + split-path helpers. + + Args: + character: The character to generate aspects for + loading_status: Loading status tracker for progress updates + relevant_info: Relevant character information + original_dialogue_examples_text: Original dialogue examples text from character card + import_options: Import options + max_examples: Maximum number of dialogue examples to generate (default: 5) + max_attrs: Maximum number of attributes to generate (None for unlimited) + """ + creator = instance.get_agent("creator") + + aspects = [] + if import_options.extract_description: + aspects.append("description") + if import_options.extract_attributes: + aspects.append("attributes") + if import_options.extract_dialogue_instructions: + aspects.append("dialogue_instructions") + if import_options.extract_dialogue_examples: + aspects.append("example_dialogue") + + if not aspects: + return + + loading_status(f"Generating {character.name}...") + + content = relevant_info.scenario.content if relevant_info.scenario else "" + if import_options.extract_dialogue_examples and original_dialogue_examples_text: + content = ( + f"{content}\n\n{original_dialogue_examples_text}" + if content + else original_dialogue_examples_text + ) + + # keep the card's examples so a generation miss can restore them + original_example_dialogue = list(character.example_dialogue) + if import_options.extract_dialogue_examples: + character.example_dialogue = [] + + try: + result = await creator.generate_character_aspects( + CharacterGenerationRequest( + aspects=aspects, + name=character.name, + content=content, + character=character, + dynamic_instructions=relevant_info.to_dynamic_instructions( + scenario=False + ), + max_examples=max_examples, + max_attributes=max_attrs, + content_role="text", + ) + ) + except LLMAccuracyError: + # the unparseable-response hard error contract + character.example_dialogue = original_example_dialogue + raise + except GenerationCancelled: + character.example_dialogue = original_example_dialogue + raise + except Exception as e: + character.example_dialogue = original_example_dialogue + log.warning("process_character_fast", error=e) + return + + if import_options.extract_description and result.description: + character.description = result.description + log.debug( + "character_description", + character=character.name, + description=character.description, + ) + + if import_options.extract_attributes and result.attributes: + character.base_attributes = result.attributes + log.debug("base_attributes parsed", base_attributes=character.base_attributes) + + if import_options.extract_dialogue_instructions and result.dialogue_instructions: + character.dialogue_instructions = result.dialogue_instructions + + if import_options.extract_dialogue_examples: + if result.example_dialogue: + character.example_dialogue = result.example_dialogue + log.debug( + "determine_character_dialogue_examples", + character=character.name, + count=len(character.example_dialogue), + ) + else: + character.example_dialogue = original_example_dialogue + + async def _process_characters_for_import( scene, characters: list[Character], @@ -1251,6 +1434,10 @@ async def _process_characters_for_import( import_options: Import options """ director = instance.get_agent("director") + creator = instance.get_agent("creator") + + # 0 means unlimited + max_attrs = director.cm_max_attributes or None for character in characters: # Add character to character_data without activating @@ -1269,23 +1456,43 @@ async def _process_characters_for_import( character, character.description, all_greeting_texts, scene ) - await _determine_character_description( - character, - loading_status, - relevant_info=relevant_info, - ) + if creator.cc_fast: + await _process_character_fast( + character, + loading_status, + relevant_info=relevant_info, + original_dialogue_examples_text=original_dialogue_examples_text, + import_options=import_options, + max_attrs=max_attrs, + ) + else: + if import_options.extract_description: + await _determine_character_description( + character, + loading_status, + relevant_info=relevant_info, + ) - await _determine_character_attributes( - character, loading_status, relevant_info=relevant_info - ) + if import_options.extract_attributes: + await _determine_character_attributes( + character, + loading_status, + relevant_info=relevant_info, + max_attrs=max_attrs, + ) - character.example_dialogue = [] - await _determine_character_dialogue_examples( - character, - loading_status, - relevant_info=relevant_info, - original_dialogue_examples_text=original_dialogue_examples_text, - ) + if import_options.extract_dialogue_instructions: + await _determine_character_dialogue_instructions( + character, loading_status + ) + + if import_options.extract_dialogue_examples: + await _determine_character_dialogue_examples( + character, + loading_status, + relevant_info=relevant_info, + original_dialogue_examples_text=original_dialogue_examples_text, + ) if character.is_player: await activate_character(scene, character) @@ -1356,7 +1563,8 @@ async def _finalize_character_card_import( use_asset_as_reference=import_options.use_asset_as_reference, ) - await _generate_story_intent(scene, loading_status) + if import_options.generate_story_intent: + await _generate_story_intent(scene, loading_status) await _process_pending_asset_transfers(scene, import_options) if import_options.setup_shared_context: @@ -1429,10 +1637,10 @@ async def load_scene_from_character_card( if import_options.import_alternate_greetings and alternate_greetings: num_episodes = len(alternate_greetings) loading_status = _setup_loading_status( + import_options, num_characters=len(characters), has_character_book=has_character_book, num_episodes=num_episodes, - generate_episode_titles=import_options.generate_episode_titles, ) scene_name = _extract_scene_name_from_spec(raw_data_or_metadata) @@ -1453,7 +1661,8 @@ async def load_scene_from_character_card( import_character_book_meta=import_options.import_character_book_meta, ) - await _determine_character_context(scene, first_character, loading_status) + if import_options.determine_content_context: + await _determine_character_context(scene, first_character, loading_status) # Handle Player Character Existing Option @@ -1586,8 +1795,10 @@ def character_from_chara_data(data: dict) -> Character: if "color" in data: character.color = data["color"] if "mes_example" in data: - new_line_match = "\r\n" if "\r\n" in data["mes_example"] else "\n" - for message in data["mes_example"].split(""): + # cards are hand-written; typographic quotes are common in them + mes_example = replace_smart_quotes(data["mes_example"]) + new_line_match = "\r\n" if "\r\n" in mes_example else "\n" + for message in mes_example.split(""): if message.strip(new_line_match): character.example_dialogue.extend( [m for m in message.split(new_line_match) if m] diff --git a/src/talemate/prompts/base.py b/src/talemate/prompts/base.py index baeaf25a..c3c28e51 100644 --- a/src/talemate/prompts/base.py +++ b/src/talemate/prompts/base.py @@ -16,7 +16,7 @@ import re import uuid from contextvars import ContextVar from datetime import datetime -from typing import Any +from typing import Any, Literal from enum import Enum import jinja2 @@ -1067,7 +1067,9 @@ class Prompt(pydantic.BaseModel): return "" return iso8601_diff_to_human(iso8601_time, scene.ts) - def system_time(self, format: str = "full") -> str: + def system_time( + self, format: Literal["full", "date", "time", "iso", "datetime"] = "full" + ) -> str: """ Returns the current system time in a clear, LLM-friendly format. @@ -1084,18 +1086,21 @@ class Prompt(pydantic.BaseModel): """ now = datetime.now() - if format == "full": - return now.strftime("%A, %B %-d, %Y at %-I:%M %p") - elif format == "date": - return now.strftime("%B %-d, %Y") + # %-d / %-I are glibc-only and raise ValueError on Windows, so the + # unpadded day and 12-hour clock are built from datetime attributes. + hour12 = now.hour % 12 or 12 + time_str = f"{hour12}:{now:%M} {now:%p}" + + if format == "date": + return f"{now:%B} {now.day}, {now.year}" elif format == "time": - return now.strftime("%-I:%M %p") + return time_str elif format == "iso": return now.strftime("%Y-%m-%dT%H:%M:%S") elif format == "datetime": return now.strftime("%Y-%m-%d %H:%M:%S") else: - return now.strftime("%A, %B %-d, %Y at %-I:%M %p") + return f"{now:%A}, {now:%B} {now.day}, {now.year} at {time_str}" def text_to_chunks(self, text: str, chunk_size: int = 512) -> list[str]: """ diff --git a/src/talemate/prompts/response.py b/src/talemate/prompts/response.py index 1ecbbb8f..dfcef023 100644 --- a/src/talemate/prompts/response.py +++ b/src/talemate/prompts/response.py @@ -28,6 +28,7 @@ __all__ = [ "Extractor", "AnchorExtractorBase", "AnchorExtractor", + "StrictAnchorExtractor", "ComplexAnchorExtractor", "AsIsExtractor", "AfterAnchorExtractor", @@ -245,6 +246,27 @@ class AnchorExtractor(AnchorExtractorBase): return None +class StrictAnchorExtractor(AnchorExtractor): + """Anchor extraction that only accepts complete ... blocks. + + The base AnchorExtractor falls back to open-ended (`...` to end of + text), close-ended, and `fallback_to_full` extraction; this subclass + accepts none of them. Callers that require reliable structured output + deliberately treat those as failures: a model that does not reliably + close its tags is not viable for multi-section responses, and accepting + bleeding or truncated sections would mask that. Missing sections read + as "not extracted" for the caller's fallback policy. + """ + + def extract(self, text: str) -> str | None: + if not text: + return None + blocks = self._find_root_level_blocks(text) + if blocks: + return self._apply_trim(blocks[-1]) + return None + + class ComplexAnchorExtractor(AnchorExtractorBase): """ Extract content between anchor tags with nesting awareness (case-insensitive). diff --git a/src/talemate/prompts/templates/director/chat-title.jinja2 b/src/talemate/prompts/templates/common/chat-title.jinja2 similarity index 77% rename from src/talemate/prompts/templates/director/chat-title.jinja2 rename to src/talemate/prompts/templates/common/chat-title.jinja2 index 46fec9ee..9f544ab4 100644 --- a/src/talemate/prompts/templates/director/chat-title.jinja2 +++ b/src/talemate/prompts/templates/common/chat-title.jinja2 @@ -6,7 +6,7 @@ Extractors: {{ chat_excerpt }} <|CLOSE_SECTION|> <|SECTION:TASK|> -Generate a very short title (3-8 words) for this conversation between a user and an AI director. The title should capture the main topic or purpose of the discussion. +Generate a very short title (3-8 words) for this conversation between a user and {{ participant }}. The title should capture the main topic or purpose of the discussion. Provide your title in a ... section. diff --git a/src/talemate/prompts/templates/common/generation-options-instructions.jinja2 b/src/talemate/prompts/templates/common/generation-options-instructions.jinja2 new file mode 100644 index 00000000..b6284f8b --- /dev/null +++ b/src/talemate/prompts/templates/common/generation-options-instructions.jinja2 @@ -0,0 +1,10 @@ +{% if writing_style %} +<|SECTION:WRITING STYLE|> +{{ writing_style }} +<|CLOSE_SECTION|> +{% endif %} +{%- if spice %} +<|SECTION:SPICE|> +{{ spice }} +<|CLOSE_SECTION|> +{% endif %} \ No newline at end of file diff --git a/src/talemate/prompts/templates/creator/determine-character-description.jinja2 b/src/talemate/prompts/templates/creator/determine-character-description.jinja2 index 03eb6293..c49b8776 100644 --- a/src/talemate/prompts/templates/creator/determine-character-description.jinja2 +++ b/src/talemate/prompts/templates/creator/determine-character-description.jinja2 @@ -47,6 +47,7 @@ The description must be an overview of the character in broad strokes, not a con {{ content_text }} {{ character_context }} {{ task_instructions }} +{%- include "generation-options-instructions.jinja2" %} {% endset %} {# COMBINED TEMPLATE #} diff --git a/src/talemate/prompts/templates/creator/determine-character-dialogue-examples.jinja2 b/src/talemate/prompts/templates/creator/determine-character-dialogue-examples.jinja2 index 782efb6f..0c546d07 100644 --- a/src/talemate/prompts/templates/creator/determine-character-dialogue-examples.jinja2 +++ b/src/talemate/prompts/templates/creator/determine-character-dialogue-examples.jinja2 @@ -34,6 +34,11 @@ Each dialogue example should be formatted as: You are limited to {{ focal.max_calls }} {% if focal.max_calls == 1 %}dialogue example{% else %}dialogue examples{% endif %}, indicate that you understand this limitation by clearly stating how many examples you are allowed to add. +{% if instructions %} +**User-provided guidance for the dialogue examples:** +{{ instructions }} +{% endif -%} + {% if existing_examples %} **Existing dialogue examples (for reference only):** {% for example in existing_examples -%} diff --git a/src/talemate/prompts/templates/creator/generate-character.jinja2 b/src/talemate/prompts/templates/creator/generate-character.jinja2 new file mode 100644 index 00000000..8c489691 --- /dev/null +++ b/src/talemate/prompts/templates/creator/generate-character.jinja2 @@ -0,0 +1,125 @@ +{#- +Extractors (dynamic — one per requested aspect, strict: complete tag pairs +only, a model that does not close its tags is not viable for consolidated +generation): + - name: StrictAnchorExtractor("", "") + - description: StrictAnchorExtractor("", "") + - attributes: StrictAnchorExtractor("", "") + - dialogue_instructions: StrictAnchorExtractor("", "") + - example_dialogue: StrictAnchorExtractor("", "") +-#} +{# DYNAMIC INSTRUCTIONS #} +{% set dynamic_instructions_text %} +{% include "dynamic-instructions.jinja2" %} +{% endset %} + +{# CONTENT TEXT #} +{% set content_text %} +<|SECTION:CONTENT|> +{% if content -%} +{{ content }} +{% else -%} +{% set scene_context_history = scene.context_history(budget=history_budget, min_dialogue=25, sections=False, keep_director=True) -%} +{% if scene.num_history_entries < 25 %}{{ scene.description }}{% endif -%} +{% for scene_context in scene_context_history -%} +{{ scene_context }} +{% endfor %} +{% endif %} +<|CLOSE_SECTION|> +{% endset %} + +{# CHARACTER CONTEXT #} +{% set character_context %} +{% if character or description -%} +<|SECTION:CHARACTER|> +{% if character -%} +{{ character.sheet }} +{% endif -%} +{% if description and (not character or description != character.description) -%} +{{ description }} +{% endif -%} +<|CLOSE_SECTION|> +{% endif %} +{% endset %} + +{# TASK INSTRUCTIONS #} +{% set task_instructions %} +<|SECTION:TASK|> +You are creating the character `{{ character_name }}` for {{ scene.context }}. + +Generate each of the following aspects for {{ character_name }}. + +{% if "name" in aspects -%} +NAME: The character's name. If `{{ character_name }}` is already a distinct name, repeat it. If it is currently a description, give the character a distinct name. If we don't know the character's actual name, you must decide one. The name MUST fit the context of the scenario and the scene. The name only, nothing else. +{% endif -%} +{% if "description" in aspects -%} +DESCRIPTION: The character description: an overview of the character in broad strokes — who they are and what defines them — not a continuation of any current narrative. +{% endif -%} +{% if "attributes" in aspects -%} +ATTRIBUTES: A character sheet of attributes for {{ character_name }}. You are omniscient and can describe the character in detail; you are a creative writer and may fill any gaps in the profile with your own ideas. Expand on interesting details. Format MUST be one attribute per line, with a colon after the attribute name. Do not include the character's name as an attribute.{% if max_attributes and max_attributes > 0 %} At most {{ max_attributes }} attributes (lines).{% endif %} + +Example: +Age: early 30s +Appearance: +Occupation: +<...> +{% if attribute_instructions_text %} +The following attributes MUST be present, each generated according to its instructions: +{{ attribute_instructions_text }} +{% endif %} +{% if augment_attributes %} +Additionally: {{ augment_attributes }} +{% endif %} +{% endif -%} +{% if "dialogue_instructions" in aspects -%} +DIALOGUE INSTRUCTIONS: Dialogue (acting) instructions for the actor playing {{ character_name }}. By default, all actors are instructed to use an informal and colloquial register with a conversational tone - natural, spontaneous, with a sense of immediacy. You may override this default where the character calls for it. Use simple language and structure the instructions in two short paragraphs: +1. Default register — how {{ character_name }} normally speaks: vocabulary, formality, sentence structure, verbal habits, how they address others. +2. Under pressure — how their speech changes when stressed, scared, angry, or pushed past their limit. Their composure must crack in a specific, visible way — shorter sentences, different word choices, breaking their own verbal patterns. NOT simply "more of the same". +{% endif -%} +{% if "example_dialogue" in aspects -%} +EXAMPLE DIALOGUE: Up to {{ max_examples }} example dialogue lines that showcase how {{ character_name }} speaks and acts, one per line. Spoken words must be enclosed in quotation marks, and each line should include actions or mannerisms that showcase the character's personality. Cover DIFFERENT emotional registers - not just the character's default mode: include at least one example where their normal speech patterns break or shift under stress or strong emotion. Each line is formatted as `: "spoken dialogue" ... actions or narrative ...`{% if "name" in aspects %} (using the name from the NAME section){% endif %}. +{% if example_dialogue_instructions %} +User-provided guidance for the dialogue examples: +{{ example_dialogue_instructions }} +{% endif -%} +{% endif -%} + +{{ character_name }} MUST feel like a believable character in the context of the scene, and relatable to the audience. +<|CLOSE_SECTION|> +<|SECTION:RESPONSE FORMAT|> +Respond with each aspect inside its own tag pair — an opening AND a closing tag — in exactly this order and format: + +{% if "name" in aspects -%} + +{% endif -%} +{% if "description" in aspects -%} + +{% endif -%} +{% if "attributes" in aspects -%} + +{% endif -%} +{% if "dialogue_instructions" in aspects -%} + +{% endif -%} +{% if "example_dialogue" in aspects -%} + +{% endif -%} +Every tag MUST be closed. Do not write anything outside the tag pairs. +<|CLOSE_SECTION|> +{% endset %} + +{# COMMON INSTRUCTIONS #} +{% set common_instructions %} +{% include "generation-options-instructions.jinja2" %} +{% endset %} + +{% set required_instructions %} +{{ content_text }} +{{ character_context }} +{{ task_instructions }} +{{ common_instructions }} +{% endset %} + +{# COMBINED TEMPLATE #} +{{ limit_tokens(dynamic_instructions_text, max_tokens-count_tokens(required_instructions)) }} +{{ required_instructions }} diff --git a/src/talemate/prompts/templates/help/chat.jinja2 b/src/talemate/prompts/templates/help/chat.jinja2 new file mode 100644 index 00000000..2f14a3ef --- /dev/null +++ b/src/talemate/prompts/templates/help/chat.jinja2 @@ -0,0 +1,190 @@ +{#- +Extractors: + - response: AsIsExtractor() +-#} +{% set rendered_context -%} +<|SECTION:Context|> +This is a help chat between you (the Talemate Help Assistant) and the user. + +Talemate is a local application for LLM-backed storytelling and roleplaying. The user is currently running Talemate and is asking questions about how to use it - its interface, settings, agents, clients, world editor, node editor, templates and everything else the application offers. + +CRITICAL RULES: +- You explain, guide and manage settings. Using the provided functions you can read the user's configuration and change Talemate application and agent settings for them. You CANNOT change anything in a scene itself (its story, characters or world state). If the user wants something changed in their scene, point them to the [Director chat](talemate://director-console), which is built for that. +- Only change a setting when the user explicitly asked for a change, and change exactly what they asked. Read the relevant settings first so you target the correct setting and a valid value - NEVER guess setting keys. After changing something, briefly confirm what changed. +- If you announce that you are about to look something up or change something, that SAME response MUST contain the function call. NEVER end a response with an announced but unperformed action. +- NEVER claim a change was applied before its function result appears in the chat. After emitting a write call, stop writing - you will be prompted again with the result and can confirm the outcome then. +- NEVER reveal or modify API keys or passwords. They are redacted from everything you see; key management belongs in the settings dialog. +- If the user asks about the CONTENT of their loaded scene - its story, characters, or what happened - rather than about using Talemate, answer what you can but let them know the [Director chat](talemate://director-console) is better suited for scene-specific questions: it has the full scene context, while you only see a small snapshot at best. +- Ground every answer in the bundled documentation and the user's actual configuration. Use the provided functions to look things up whenever you are not certain. NEVER invent settings, buttons or features. +- When your answer is based on a documentation page, add a markdown link to that page on the online manual at {{ docs_site_url }} - documentation lookup results include the exact `url` to use. To construct one yourself: drop the `.md` suffix (a trailing `index` maps to its directory) and append a `/`. Example: `user-guide/agents/editor/settings.md` becomes {{ docs_site_url }}user-guide/agents/editor/settings/ +- ONLY link to {{ docs_site_url }} - never link documentation anywhere else. +- Answer conversationally in markdown, concise and to the point. +<|CLOSE_SECTION|> +<|SECTION:In-App Navigation Links|> +You can give the user markdown links that, when clicked, navigate them inside the Talemate interface. Use them whenever you point the user at a specific place in the UI: + +- `[label](talemate://agent-settings/)` opens that agent's settings dialog. Valid agents: {{ join(agent_types, ", ") }}. You may append `/` only if you know the exact settings tab key. +- `[label](talemate://world-editor/)` opens the world editor on a tab. Valid tabs: scene, characters, world, contextdb, history, pins, suggestions. A character can be deep-linked: `talemate://world-editor/characters/`. Only offer world editor links while a scene is loaded. +- `[label](talemate://director-console)` opens the director console with its chat. + +These links are for navigation only - documentation references must still link to the online manual as instructed above. +<|CLOSE_SECTION|> +{% if docs_available %} +<|SECTION:Documentation Sections|> +The bundled documentation, grouped by section as `path prefix | pages | contents`: + +{% for s in docs_sections %}{{ s.prefix }} | {{ s.count }} pages | {{ s.description }} +{% endfor %} +Individual pages are NOT listed here - locate them with the find_docs function before reading them. +<|CLOSE_SECTION|> +{% endif %} +{% if ux_snapshot %} +<|SECTION:User's Current View|> +A snapshot of what the user is currently looking at in the Talemate interface. Use it to resolve ambiguous questions like "what does this setting do?": + +{{ yaml(ux_snapshot) }} +<|CLOSE_SECTION|> +{% endif %} +{% if scene_context %} +<|SECTION:Loaded Scene|> +The user has made this chat scene-aware, so you may reference their currently loaded scene when answering: + +Title: {{ scene_context.title }} +Characters: {{ join(scene_context.characters, ", ") }} +{% if scene_context.snapshot %} +Recent scene progress: + +{{ scene_context.snapshot }} +{% endif %} +<|CLOSE_SECTION|> +{% endif %} +{% if custom_instructions %} +<|SECTION:Additional Instructions|> +{{ custom_instructions }} +<|CLOSE_SECTION|> +{% endif %} +{%- endset %} +{% set tools_text -%} +{% if not final_round %} +{{ focal.render_instructions() }} +{% if docs_available %} + +{{ focal.callbacks.find_docs.render( + "Look up documentation pages by topic - returns the best-matching pages with path, title, summary and manual URL. This is the primary way to find the right page; call it before read_doc unless you already know the exact path.", + query="What you are looking for - a feature, setting, node title or task.", + examples=[ + {"query": "Jinja2 Format node"}, + {"query": "assign a voice to a character"}, + ] +) }} + +{{ focal.callbacks.search_docs.render( + "Full-text search across all documentation pages for a term or regular expression. Returns matching lines with their path and line number. Use when find_docs does not surface the right page or you need to find an exact string.", + query="Term or regular expression to search for.", + examples=[ + {"query": "revision method"}, + ] +) }} + +{{ focal.callbacks.read_doc.render( + "Read a full documentation page. Use a path from find_docs or search results.", + path="Path of the documentation page, relative to the documentation root.", + examples=[ + {"path": "user-guide/agents/editor/settings.md"}, + ] +) }} + +{{ focal.callbacks.read_doc_section.render( + "Read a single section of a documentation page. Prefer this over read_doc for long pages when you know which section you need.", + path="Path of the documentation page, relative to the documentation root.", + section="Heading of the section to read (matched case-insensitively).", + examples=[ + {"path": "user-guide/agents/editor/settings.md", "section": "Revision"}, + ] +) }} +{% endif %} + +{{ focal.callbacks.read_agent_settings.render( + "Read the full settings of one agent - every action and setting with labels, descriptions, current values, valid choices and any per-scene overrides. ALWAYS use this before changing an agent setting.", + agent="Agent type, one of the valid agents listed in the navigation links section.", + examples=[ + {"agent": "conversation"}, + ] +) }} + +{{ focal.callbacks.update_agent_setting.render( + "Change one agent setting. Use setting 'enabled' to toggle the action itself on or off. Scope 'global' changes the value for all scenes; scope 'scene' writes a per-scene override for the currently loaded scene.", + agent="Agent type.", + action="Action key as returned by read_agent_settings.", + setting="Setting key within the action, or 'enabled' to toggle the action.", + value="The new value. Must be valid for the setting's type and choices. For flags settings pass the FULL new list of values.", + scope="'global' or 'scene'.", + examples=[ + {"agent": "conversation", "action": "generation_override", "setting": "length", "value": 192, "scope": "global"}, + ] +) }} + +{{ focal.callbacks.clear_agent_setting_scene_override.render( + "Remove a per-scene override so the setting falls back to its global value. Use setting 'enabled' to clear an enabled-flag override.", + agent="Agent type.", + action="Action key.", + setting="Setting key, or 'enabled'.", + examples=[ + {"agent": "conversation", "action": "generation_override", "setting": "length"}, + ] +) }} + +{{ focal.callbacks.read_app_config.render( + "Read an application config section. Available sections: game, appearance, creator, presets, prompts.", + section="Section name.", + examples=[ + {"section": "game"}, + ] +) }} + +{{ focal.callbacks.update_app_config.render( + "Change one application config setting by dotted path. Writable sections: game, appearance, creator. Read the section first so you target an existing path.", + path="Dotted path to the setting, starting with the section.", + value="The new value. Lists must be passed as a full replacement list.", + examples=[ + {"path": "game.general.auto_save", "value": false}, + ] +) }} + +{{ focal.callbacks.read_clients.render( + "List the configured LLM clients - type, model, context length, status. API keys are never included.", + examples=[ + {}, + ] +) }} +{% endif %} +{%- endset %} +{% set instructions_text -%} +<|SECTION:Task|> +Respond to the user's latest message. + +{% if not final_round -%} +If you need information from the documentation or the user's configuration to answer accurately - or the user asked you to change a setting - call the functions; their results will be appended to the chat and you will be prompted again to continue. Once you have what you need (or if the question needs no lookup), just write your answer WITHOUT calling any functions. +{%- else -%} +Write your answer now using the information already present in this chat. Do NOT call any functions. +{%- endif %} +<|CLOSE_SECTION|> +{%- endset %} +{% set reserved_tokens = count_tokens(rendered_context + tools_text + instructions_text) + 512 %} +{% set trimmed_history = history_trim(history, max_tokens - reserved_tokens) %} +{{ rendered_context }} +{{ tools_text }} +<|SECTION:Chat History - THIS IS WHAT YOU RESPOND TO|> +{% for m in trimmed_history %} +{% if m.type == 'doc_result' %} +[#{{ loop.index }}] [tool call] `{{ m.name }}` {{ json(m.arguments) }} +Result: {{ json(m.result) }} +--- +{% else %} +[#{{ loop.index }}] [{{ m.source }}] {{ m.message }} +--- +{% endif %} +{% endfor %} +<|CLOSE_SECTION|> +{{ instructions_text }} +{{ bot_token }} \ No newline at end of file diff --git a/src/talemate/prompts/templates/visual/finalize-prompt.jinja2 b/src/talemate/prompts/templates/visual/finalize-prompt.jinja2 new file mode 100644 index 00000000..d8a25624 --- /dev/null +++ b/src/talemate/prompts/templates/visual/finalize-prompt.jinja2 @@ -0,0 +1,15 @@ +<|SECTION:PROMPT|> +{{ prompt_text }} +<|CLOSE_SECTION|> + +<|SECTION:TASK|> +The PROMPT above is an image generation prompt{% if vis_type and vis_type != "UNSPECIFIED" %} for a {{ vis_type.replace("_", " ").lower() }}{% endif %}{% if character_name %} involving the character "{{ character_name }}"{% endif %}. + +Rewrite the PROMPT by applying the following instruction to it: + +{{ instruction }} + +Apply the instruction faithfully. Unless the instruction says otherwise, preserve the meaning, subjects and composition of the original prompt - only transform it as instructed. Do not add commentary or explanations. + +Output the rewritten prompt between tags. +<|CLOSE_SECTION|> diff --git a/src/talemate/prompts/templates/world_state/extract-character-sheet.jinja2 b/src/talemate/prompts/templates/world_state/extract-character-sheet.jinja2 index e7f777ce..2aea29f3 100644 --- a/src/talemate/prompts/templates/world_state/extract-character-sheet.jinja2 +++ b/src/talemate/prompts/templates/world_state/extract-character-sheet.jinja2 @@ -24,7 +24,9 @@ Override example: {# CHARACTER CONTEXT #} {% set character_context %} -{% with skip_sheet=True %}{% include "character-context.jinja2" %}{% endwith %} +{% with skip_sheet=True, + skip_characters=[context_character.name] if context_character else [], + mentioned_characters=[context_character] if context_character else [] %}{% include "character-context.jinja2" %}{% endwith %} {% endset %} {# COMMON INSTRUCTIONS #} @@ -43,7 +45,7 @@ Appearance: Your response MUST be a character sheet with multiple attributes. Format MUST be one attribute per line, with a colon after the attribute name. {% if max_attributes and max_attributes > 0 %} -You MUST output at most {{ max_attributes }} attributes (lines) total. +You MUST output at most {{ max_attributes }} attributes (lines) in addition to the Name line. {% endif %} {% endset %} diff --git a/src/talemate/save.py b/src/talemate/save.py index ce3cc3e8..d41233ba 100644 --- a/src/talemate/save.py +++ b/src/talemate/save.py @@ -34,6 +34,11 @@ class SceneEncoder(json.JSONEncoder): return super().default(obj) +def scene_data_dumps(data: dict) -> str: + """Canonical JSON encoding for serialized scene data (saves and exports).""" + return json.dumps(data, indent=2, cls=SceneEncoder) + + async def save_node_module( scene: "Scene", graph: "Graph", filename: str = None, set_as_main: bool = False ) -> str: diff --git a/src/talemate/scene_assets.py b/src/talemate/scene_assets.py index 5f83b0c5..bf0d0078 100644 --- a/src/talemate/scene_assets.py +++ b/src/talemate/scene_assets.py @@ -32,14 +32,24 @@ from talemate.agents.visual.schema import ( Resolution, AssetAttachmentContext, ) +from talemate.files import MEDIA_TYPES from talemate.path import SCENES_DIR -async_signals.register("asset_saved") +async_signals.register( + "asset_saved", + "asset_deleted", + "scene.backdrop_changed", + "scene.cover_image_changed", + "character.cover_image_changed", +) __all__ = [ "Asset", "AssetTransfer", "AssetSavedPayload", + "AssetDeletedPayload", + "SceneBackdropChangedPayload", + "CoverImageChangedPayload", "SceneAssets", "AssetMeta", "AssetSelectionContext", @@ -63,6 +73,13 @@ VIS_TYPE_TO_ASSET_TYPE = { VIS_TYPE.UNSPECIFIED: None, } +# vis_types eligible for the scene backdrop, mapped to their +# appearance.scene.message_assets config entry +VIS_TYPE_TO_MESSAGE_ASSET_KIND = { + VIS_TYPE.SCENE_BACKGROUND: "scene_background", + VIS_TYPE.SCENE_ILLUSTRATION: "scene_illustration", +} + def validate_image_data_url(image_data: str) -> None: """ @@ -106,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: @@ -337,6 +344,30 @@ class AssetSavedPayload(pydantic.BaseModel): ) +class AssetDeletedPayload(pydantic.BaseModel): + """Payload for the asset_deleted signal.""" + + asset: Asset + + +class SceneBackdropChangedPayload(pydantic.BaseModel): + """Payload for the scene.backdrop_changed signal.""" + + backdrop: str | None + enabled: bool + + +class CoverImageChangedPayload(pydantic.BaseModel): + """ + Payload for the scene.cover_image_changed and + character.cover_image_changed signals. character_name is only set for + the character signal. + """ + + asset: Asset + character_name: str | None = None + + async def _handle_asset_saved(payload: AssetSavedPayload): """ Module-level handler for the asset_saved signal. @@ -373,6 +404,16 @@ async def _handle_asset_saved(payload: AssetSavedPayload): message_ids=asset_attachment_context.message_ids, ) + # scene backdrop auto-promotion — a newly generated scene illustration / + # background becomes the backdrop when its kind's appearance config opts + # in; backdrop_enabled is left untouched so an explicit "Immersive" off + # isn't overridden + + if payload.new_asset: + kind = VIS_TYPE_TO_MESSAGE_ASSET_KIND.get(asset.meta.vis_type) + if kind and config.appearance.scene.message_assets[kind].auto_backdrop: + await scene.assets.set_scene_backdrop(asset_id=asset.id) + # cover image (scene and character) if asset_attachment_context.scene_cover: @@ -434,6 +475,17 @@ class SceneAssets: self.scene = scene self._assets_cache = None self.cover_image = None + # scene backdrop: which asset renders behind the scene text, and + # whether it currently renders at all + self.backdrop: str | None = None + self.backdrop_enabled: bool = True + + def _fire_signal(self, name: str, payload: pydantic.BaseModel): + """Fire an async signal from a synchronous context.""" + try: + asyncio.create_task(async_signals.get(name).send(payload)) + except Exception as e: + log.error(f"Failed to fire {name} signal", error=str(e)) def _signal_asset_saved( self, @@ -448,16 +500,13 @@ class SceneAssets: asset: The asset that was saved new_asset: True if this is a newly created asset, False if it already existed """ - try: - payload = AssetSavedPayload( - asset=asset, - new_asset=new_asset, - ) - if asset_attachment_context: - payload.asset_attachment_context = asset_attachment_context - asyncio.create_task(async_signals.get("asset_saved").send(payload)) - except Exception as e: - log.error("Failed to fire asset_saved signal", error=str(e)) + payload = AssetSavedPayload( + asset=asset, + new_asset=new_asset, + ) + if asset_attachment_context: + payload.asset_attachment_context = asset_attachment_context + self._fire_signal("asset_saved", payload) @property def asset_directory(self) -> str: @@ -586,12 +635,16 @@ class SceneAssets: def dict(self, *args, **kwargs): return { "cover_image": self.cover_image, + "backdrop": self.backdrop, + "backdrop_enabled": self.backdrop_enabled, "assets": {asset.id: asset.model_dump() for asset in self.assets.values()}, } def scene_info(self) -> dict: return { "cover_image": self.cover_image, + "backdrop": self.backdrop, + "backdrop_enabled": self.backdrop_enabled, } def load_assets(self, assets_dict: dict): @@ -942,13 +995,29 @@ class SceneAssets: def cleanup_cover_images(self) -> bool: """ - Checks character cover images and the scene cover image and if they - no longer exist as assets, unsets them. + Checks character cover images, the scene cover image and the scene + backdrop and if they no longer exist as assets, unsets them. - Returns True if any cover images were cleaned up, False otherwise. + Returns True if anything was cleaned up, False otherwise. """ cleaned = False + # Check scene backdrop + if self.backdrop and not self.validate_asset_id(self.backdrop): + log.debug( + "Cleaning up scene backdrop", + asset_id=self.backdrop, + ) + self.backdrop = None + cleaned = True + + self._fire_signal( + "scene.backdrop_changed", + SceneBackdropChangedPayload( + backdrop=None, enabled=self.backdrop_enabled + ), + ) + # Check scene cover image if self.cover_image and not self.validate_asset_id(self.cover_image): log.debug( @@ -1125,6 +1194,8 @@ class SceneAssets: self.cleanup_character_avatars() self.cleanup_message_avatars() + self._fire_signal("asset_deleted", AssetDeletedPayload(asset=asset)) + self.scene.emit_status() def search_assets( @@ -1630,8 +1701,52 @@ class SceneAssets: }, ) + await async_signals.get("scene.cover_image_changed").send( + CoverImageChangedPayload(asset=asset) + ) + return asset_id + async def set_scene_backdrop( + self, + asset_id: str | None = None, + enabled: bool | None = None, + clear: bool = False, + ) -> str | None: + """ + Updates the scene backdrop. + + Either argument may be omitted to leave that aspect untouched: + asset_id selects which asset renders behind the scene text, + enabled toggles whether it renders at all. + + clear removes the backdrop asset entirely (asset_id is ignored). + """ + log.debug("set_scene_backdrop", asset_id=asset_id, enabled=enabled, clear=clear) + prior = (self.backdrop, self.backdrop_enabled) + if clear: + self.backdrop = None + elif asset_id is not None: + if not self.validate_asset_id(asset_id): + log.error("Invalid asset id", asset_id=asset_id) + return None + self.backdrop = asset_id + if enabled is not None: + self.backdrop_enabled = enabled + + if (self.backdrop, self.backdrop_enabled) != prior: + await async_signals.get("scene.backdrop_changed").send( + SceneBackdropChangedPayload( + backdrop=self.backdrop, enabled=self.backdrop_enabled + ) + ) + + # scene status carries the backdrop state to the frontend + if self.scene.active: + self.scene.emit_status() + + return self.backdrop + async def set_character_cover_image_from_bytes( self, character: "Character", bytes: bytes, override: bool = False ) -> str: @@ -1694,6 +1809,10 @@ class SceneAssets: }, ) + await async_signals.get("character.cover_image_changed").send( + CoverImageChangedPayload(asset=asset, character_name=character.name) + ) + return asset_id diff --git a/src/talemate/server/agent_config.py b/src/talemate/server/agent_config.py index 2cc26192..a6a658e8 100644 --- a/src/talemate/server/agent_config.py +++ b/src/talemate/server/agent_config.py @@ -57,10 +57,12 @@ class AgentConfigPlugin(Plugin): async def _persist_and_broadcast(self, agent) -> None: # NOTE: this path runs alongside the bulk ``configure_agents`` save - # path. Both write to the same ``Config.agents[agent_type]`` slot. - # We rely on the websocket dispatcher serializing inbound messages so - # there's no concurrent register-vs-configure overlap on the same - # agent. If that assumption changes, add a lock. + # path AND the help agent's settings tools (background tasks, NOT + # serialized by the websocket dispatcher). Every writer replaces the + # ``Config.agents[agent_type]`` slot in one synchronous event-loop + # step, so interleaving can't tear the entry — the exposure is + # whole-entry last-writer-wins, which the AgentModal's unchanged-save + # skip keeps rare. If entry building ever gains awaits, add a lock. await agent.save_config() await commit_config() await agent.emit_status() @@ -157,6 +159,18 @@ class AgentConfigPlugin(Plugin): # Per-scene agent overrides # ------------------------------------------------------------------ + async def _refuse_scene_overrides(self, agent, message: str) -> None: + """Reject an override save and put the frontend back on the truth. + + Callers (the AgentModal, the agent panel's quick-toggle chips) update + their local copy of the overlay optimistically, so a refusal that only + raised an error toast would leave a value on screen that was never + stored. Re-emitting agent status ships the unchanged overlay back. + """ + if agent: + await agent.emit_status() + await self.signal_operation_failed(message) + async def handle_save_scene_overrides(self, data: dict): """Persist a sparse per-agent override set into the scene's agent-settings JSON file. @@ -176,8 +190,8 @@ class AgentConfigPlugin(Plugin): scene = getattr(agent, "scene", None) if scene is None: - await self.signal_operation_failed( - f"Agent '{agent_type}' is not connected to a scene" + await self._refuse_scene_overrides( + agent, f"Agent '{agent_type}' is not connected to a scene" ) return @@ -190,34 +204,70 @@ class AgentConfigPlugin(Plugin): project_name=getattr(scene, "project_name", None), filename=getattr(scene, "filename", None), ) - await self.signal_operation_failed( - "Save the scene first before adding per-scene agent overrides." + # A scene can hold a live overlay and still have no filename — a + # restore re-links the overlay from `project_name` and then clears + # the filename — so this is not always a "not saved yet" scene. + await self._refuse_scene_overrides( + agent, + "This scene has no save file, so its agent overrides cannot be " + "changed. Save the scene first.", ) return # Defense against path traversal — filename arrives over the wire # and must stay inside ``save_dir``. if filename is not None and not is_safe_settings_filename(filename): - await self.signal_operation_failed( - f"Invalid agent-settings filename: {filename!r}" + await self._refuse_scene_overrides( + agent, f"Invalid agent-settings filename: {filename!r}" ) return + # Everything below mutates in-memory state that agents resolve their + # settings from; snapshot it so a failed write can be undone instead of + # leaving the running scene on values that never reached disk. + previous_overlay = scene.agent_overrides + previous_settings_file = scene.agent_settings_file + # Linking clears the opt-out; leaving it cleared after a failed write + # would silently re-arm the load-time auto-link for a scene the user + # opted out of. + previous_opted_out = scene._agent_settings_opted_out + previous_slice = ( + previous_overlay.agents.get(agent_type) if previous_overlay else None + ) + + def rollback() -> None: + scene.agent_overrides = previous_overlay + scene.agent_settings_file = previous_settings_file + scene._agent_settings_opted_out = previous_opted_out + if previous_overlay is None: + return + if previous_slice is None: + previous_overlay.agents.pop(agent_type, None) + else: + previous_overlay.agents[agent_type] = previous_slice + # Resolve target file. If the scene already has an overlay we write # into it; otherwise the caller MUST supply a filename so we know - # what to create. + # what to create. Linking a file is the only case that changes the + # scene itself (it stores `agent_settings_file`), and so the only one + # that needs the scene persisted afterwards. + linked_file = False if scene.agent_overrides is None: if not filename: - await self.signal_operation_failed( - "No agent-settings file linked; filename is required" + await self._refuse_scene_overrides( + agent, "No agent-settings file linked; filename is required" ) return target_path = agent_settings_dir(scene.save_dir) / filename scene.agent_overrides = SceneAgentSettings(filepath=target_path) scene.agent_settings_file = filename scene._agent_settings_opted_out = False + linked_file = True elif filename and filename != scene.agent_overrides.filename: - # Caller wants to switch which file we write to. Move the in-memory + # Caller wants to switch which file we write to. No current sender + # reaches this — they only attach a filename while the scene has + # none linked, and the world editor switches files through + # ``apply_scene_settings_link`` instead. Move the in-memory # overlay's filepath; existing file (if any) at the new name is # treated as a fresh start — we overwrite below. target_path = agent_settings_dir(scene.save_dir) / filename @@ -226,6 +276,7 @@ class AgentConfigPlugin(Plugin): agents=scene.agent_overrides.agents, ) scene.agent_settings_file = filename + linked_file = True scene.agent_overrides.replace_agent_overrides(agent_type, override or {}) try: @@ -237,15 +288,18 @@ class AgentConfigPlugin(Plugin): filepath=str(scene.agent_overrides.filepath), error=str(exc), ) - await self.signal_operation_failed( - f"Failed to write agent-settings file: {exc.strerror or exc}" + rollback() + await self._refuse_scene_overrides( + agent, f"Failed to write agent-settings file: {exc.strerror or exc}" ) return # Re-emit agent + scene status so the frontend re-renders with the # newly-resolved values and the updated linked-file display. await agent.emit_status() - # signal_operation_done triggers a scene auto-save when enabled, which - # persists the `agent_settings_file` reference in the scene JSON so - # the link survives reload. - await self.signal_operation_done() + # Overrides live in their own file, so the scene itself only needs + # persisting when this save linked one: the auto-save writes the + # `agent_settings_file` reference so the link survives reload. Repeat + # saves (modal re-saves, quick-toggle chip flips) skip it — otherwise + # every chip click would rewrite the scene JSON or mark it unsaved. + await self.signal_operation_done(signal_only=not linked_file) diff --git a/src/talemate/server/api.py b/src/talemate/server/api.py index 38f0b307..a4f4dd6a 100644 --- a/src/talemate/server/api.py +++ b/src/talemate/server/api.py @@ -8,7 +8,10 @@ import websockets import talemate.instance as instance from talemate import VERSION -from talemate.client.base import resolve_generation_error +from talemate.client.base import ( + resolve_all_generation_errors, + resolve_generation_error, +) from talemate.config import get_config, Config, commit_config, update_config from talemate.client.system_prompts import RENDER_CACHE as SYSTEM_PROMPTS_CACHE from talemate.server.websocket_server import WebsocketHandler @@ -73,6 +76,9 @@ async def websocket_endpoint(websocket): send_messages_task.cancel() send_status_task.cancel() test_connection_task.cancel() + # no user response can arrive anymore - terminate any coroutine still + # waiting on a generation error dialog + resolve_all_generation_errors("cancel") handler.disconnect() if handler.scene: handler.scene.active = False @@ -130,11 +136,14 @@ async def websocket_endpoint(websocket): handler.scene.continue_scene = False scene_task.cancel() + # pending error dialogs belong to the outgoing scene's + # generations - cancel them so their coroutines don't hang + resolve_all_generation_errors("cancel") + file_path = data.get("file_path") scene_data = data.get("scene_data") filename = data.get("filename") reset = data.get("reset", False) - rev = data.get("rev") scene_initialization = data.get("scene_initialization") await message_queue.put( @@ -172,7 +181,6 @@ async def websocket_endpoint(websocket): file_path, reset=reset, callback=scene_loading_done, - rev=rev, scene_initialization=scene_initialization, ) ) @@ -188,6 +196,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() @@ -230,6 +240,21 @@ async def websocket_endpoint(websocket): if handler.scene.loading: scene_task.cancel() scene_task = None + elif action_type == "auto_retry_abort": + client_name = data.get("client") + generation_id = data.get("generation_id") + log.info( + "auto_retry_abort", + client=client_name, + generation_id=generation_id, + ) + if generation_id: + try: + instance.get_client(client_name).request_auto_retry_abort( + generation_id + ) + except KeyError: + pass elif action_type == "request_app_config": log.info("request_app_config") diff --git a/src/talemate/server/config.py b/src/talemate/server/config.py index 86dee04a..dd54df18 100644 --- a/src/talemate/server/config.py +++ b/src/talemate/server/config.py @@ -1,11 +1,10 @@ import pydantic import structlog import os -from datetime import datetime -from pathlib import Path +import shutil from talemate import VERSION -from talemate.changelog import list_revision_entries, delete_changelog_files +from talemate.changelog import delete_changelog_files, scene_ref_from_path from talemate.client.model_prompts import model_prompt, PromptSpec from talemate.client.registry import CLIENT_CLASSES from talemate.client.base import ( @@ -20,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 @@ -67,9 +67,8 @@ class DeleteScenePayload(pydantic.BaseModel): path: str -class GetBackupFilesPayload(pydantic.BaseModel): - scene_path: str - filter_date: str | None = None +class DeleteSceneProjectPayload(pydantic.BaseModel): + path: str class SaveUnifiedAPIKeyPayload(pydantic.BaseModel): @@ -450,9 +449,27 @@ class ConfigPlugin(Plugin): {"type": "app_config", "data": config.model_dump(), "version": VERSION} ) + def _active_scene_realpath(self) -> str | None: + scene = self.scene + if scene and scene.full_path: + return os.path.realpath(scene.full_path) + return None + 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}") + + # the next save of the loaded scene would silently recreate the file + # (with its changelog history gone), so refuse instead + if self._active_scene_realpath() == file_path: + raise ValueError( + "Cannot delete the save file of the currently loaded scene - load a different scene first" + ) + await self.handle_remove_scene_from_recents(data) log.info("Deleting scene", path=payload.path) @@ -465,19 +482,7 @@ class ConfigPlugin(Plugin): # remove associated changelog files (base, latest, and segmented changelog files) try: - # Construct a proper scene reference from the deleted file path - scene_dir = os.path.dirname(payload.path) - scene_filename = os.path.basename(payload.path) - scene_ref = type( - "Scene", - (), - { - "save_dir": scene_dir, - "filename": scene_filename, - "changelog_dir": os.path.join(scene_dir, "changelog"), - }, - )() - + scene_ref = scene_ref_from_path(payload.path) result = delete_changelog_files(scene_ref) log.info( "Deleted scene changelog artifacts", @@ -507,6 +512,60 @@ 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_path = self._active_scene_realpath() + if active_scene_path and active_scene_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"]) @@ -557,108 +616,3 @@ class ConfigPlugin(Plugin): self.websocket_handler.queue_put( {"type": "app_config", "data": config.model_dump(), "version": VERSION} ) - - async def handle_get_backup_files(self, data): - """Get the most appropriate revision for the scene.""" - payload = GetBackupFilesPayload(**data) - try: - # we dont actually have the scene loaded at this point so we need - # to scaffold a temporary scene object that has the necessary paths - scene_dir = os.path.dirname(payload.scene_path) - scene_filename = os.path.basename(payload.scene_path) - scene = type( - "Scene", - (), - { - "save_dir": scene_dir, - "filename": scene_filename, - "name": "temp", - "changelog_dir": os.path.join(scene_dir, "changelog"), - }, - )() - - # Get base and latest snapshot file info - changelog_dir = Path(scene.changelog_dir) - base_path = changelog_dir / f"{scene.filename}.base.json" - latest_path = changelog_dir / f"{scene.filename}.latest.json" - - base_mtime = base_path.stat().st_mtime if base_path.exists() else None - latest_mtime = latest_path.stat().st_mtime if latest_path.exists() else None - - files = [] - if payload.filter_date: - # Find the revision closest to the filter date (before or after) - # Only show specific revision when filtering by date - - filter_ts = int( - datetime.fromisoformat( - payload.filter_date.replace("Z", "+00:00") - ).timestamp() - ) - - entries = list_revision_entries(scene) - candidate = None - best_distance = None - for entry in entries: - distance = abs(entry["ts"] - filter_ts) - if ( - best_distance is None - or distance < best_distance - or (distance == best_distance and candidate) - ): - candidate = entry - best_distance = distance - - if candidate: - files.append( - { - "name": f"rev_{candidate['rev']}", - "path": payload.scene_path, - "timestamp": candidate["ts"], - "size": 0, - "rev": candidate["rev"], - } - ) - - # Always include base and latest snapshots as restore options - entries = list_revision_entries(scene) - if base_mtime: - files.append( - { - "name": "base", - "path": payload.scene_path, - "timestamp": int(base_mtime), - "size": 0, - "rev": 0, - "is_base": True, - } - ) - - if latest_mtime: - latest_rev = entries[0]["rev"] if entries else 0 - files.append( - { - "name": "latest", - "path": payload.scene_path, - "timestamp": int(latest_mtime), - "size": 0, - "rev": latest_rev, - "is_latest": True, - } - ) - - self.websocket_handler.queue_put( - {"type": "backup", "action": "backup_files", "files": files} - ) - except Exception as e: - log.error( - "Failed to list revisions", scene_path=payload.scene_path, error=e - ) - self.websocket_handler.queue_put( - { - "type": "backup", - "action": "backup_files", - "files": [], - "error": str(e), - } - ) diff --git a/src/talemate/server/scene_assets.py b/src/talemate/server/scene_assets.py index 53219cf1..c20d8dcb 100644 --- a/src/talemate/server/scene_assets.py +++ b/src/talemate/server/scene_assets.py @@ -46,6 +46,12 @@ class SetSceneCoverImagePayload(pydantic.BaseModel): asset_id: str +class SetSceneBackdropPayload(pydantic.BaseModel): + asset_id: str | None = None + enabled: bool | None = None + clear: bool = False + + class SetCharacterCoverImagePayload(pydantic.BaseModel): asset_id: str character_name: str @@ -219,6 +225,32 @@ class SceneAssetsPlugin(Plugin): log.error("set_scene_cover_image_failed", error=e) await self.signal_operation_failed(f"Failed to set scene cover image: {e}") + async def handle_set_scene_backdrop(self, data: dict): + payload = SetSceneBackdropPayload(**data) + + try: + if payload.asset_id is not None and not self.scene.assets.validate_asset_id( + payload.asset_id + ): + await self.signal_operation_failed("Invalid asset_id") + return + + await self.scene.assets.set_scene_backdrop( + asset_id=payload.asset_id, + enabled=payload.enabled, + clear=payload.clear, + ) + + # Request the asset for frontend + if payload.asset_id: + self.websocket_handler.request_scene_assets([payload.asset_id]) + + await self.scene.attempt_auto_save() + await self.signal_operation_done() + except Exception as e: + log.error("set_scene_backdrop_failed", error=e) + await self.signal_operation_failed(f"Failed to set scene backdrop: {e}") + async def handle_set_character_cover_image(self, data: dict): payload = SetCharacterCoverImagePayload(**data) asset_id = payload.asset_id diff --git a/src/talemate/server/timeline.py b/src/talemate/server/timeline.py new file mode 100644 index 00000000..2cbb31c3 --- /dev/null +++ b/src/talemate/server/timeline.py @@ -0,0 +1,168 @@ +""" +Websocket plugin for the scene timeline UX. + +Lets the frontend browse a scene's changelog revisions, preview the message +history at any revision, and fork from one: + +- ``list_revisions``: timeline points — every changelog revision with its + timestamp, plus the base snapshot (revision 0). +- ``preview``: reconstructs the scene at a revision and returns the tail of + its message history for display. +- ``fork``: writes a new scene file forked from a revision and asks the + frontend to load it. + +In-place rollback is not exposed: it is being reworked, so the route is gone. +``fork`` refuses a save name that already exists, so no timeline action can +overwrite an existing scene file. + +All actions also work without a loaded scene by passing ``scene_path`` (used +from the load screen), operating on a lightweight scene scaffold. +""" + +import os + +import pydantic +import structlog + +from talemate.changelog import ( + fork_scene_at_revision, + list_revision_entries, + reconstruct_scene_data, + scene_ref_from_path, +) +from talemate.server.websocket_plugin import Plugin +from talemate.util.path import is_safe_relative_filename + +log = structlog.get_logger("talemate.server.timeline") + +__all__ = ["TimelinePlugin"] + +MAX_PREVIEW_MESSAGES = 100 + + +class ListRevisionsPayload(pydantic.BaseModel): + scene_path: str | None = None + + +class PreviewPayload(pydantic.BaseModel): + rev: int + scene_path: str | None = None + max_messages: int = 20 + + +class ForkPayload(pydantic.BaseModel): + rev: int + save_name: str + scene_path: str | None = None + + +class TimelinePlugin(Plugin): + router = "timeline" + + def _target_scene(self, scene_path: str | None): + """ + Resolve the scene object timeline actions operate on: a scaffold for + the given path (load-screen mode), or the active scene. + """ + if scene_path: + return scene_ref_from_path(scene_path) + + if not self.scene or not self.scene.filename: + raise ValueError("No saved scene available for timeline operations") + + return self.scene + + async def handle_list_revisions(self, data: dict): + """ + Send the scene's timeline points: base snapshot (revision 0) followed + by all changelog revisions in ascending order. + """ + payload = ListRevisionsPayload(**data) + scene = self._target_scene(payload.scene_path) + + revisions = sorted(list_revision_entries(scene), key=lambda x: x["rev"]) + + base_path = os.path.join(scene.changelog_dir, f"{scene.filename}.base.json") + if os.path.exists(base_path): + revisions.insert( + 0, {"rev": 0, "ts": int(os.path.getmtime(base_path)), "is_base": True} + ) + + self.websocket_handler.queue_put( + { + "type": self.router, + "action": "revisions", + "data": { + "scene_path": payload.scene_path, + "revisions": revisions, + }, + } + ) + + async def handle_preview(self, data: dict): + """ + Reconstruct the scene at a revision and send the tail of its message + history for the preview panel. + """ + payload = PreviewPayload(**data) + scene = self._target_scene(payload.scene_path) + + max_messages = max(1, min(payload.max_messages, MAX_PREVIEW_MESSAGES)) + reconstructed = await reconstruct_scene_data(scene, to_rev=payload.rev) + + history = reconstructed.get("history") or [] + messages = history[-max_messages:] + + self.websocket_handler.queue_put( + { + "type": self.router, + "action": "preview", + "data": { + "scene_path": payload.scene_path, + "rev": payload.rev, + "messages": messages, + "total_messages": len(history), + "intro": reconstructed.get("intro"), + }, + } + ) + + async def handle_fork(self, data: dict): + """ + Create a new scene file forked from a revision and ask the frontend + to load it. + + The fork always writes a save of its own: a name that would land on an + existing file is refused rather than overwriting it with older, + reconstructed data. + """ + payload = ForkPayload(**data) + scene = self._target_scene(payload.scene_path) + + fork_filename = f"{payload.save_name}.json" + + if not is_safe_relative_filename(fork_filename, suffix=".json"): + await self.signal_operation_failed( + f"'{payload.save_name}' is not a valid save name" + ) + return + + if os.path.exists(os.path.join(scene.save_dir, fork_filename)): + await self.signal_operation_failed( + f"A save named '{payload.save_name}' already exists — pick a different name" + ) + return + + fork_path = await fork_scene_at_revision(scene, payload.rev, payload.save_name) + + log.info("timeline_fork", rev=payload.rev, path=fork_path) + + await self.signal_operation_done(signal_only=True) + + self.websocket_handler.queue_put( + { + "type": "system", + "id": "load_scene_request", + "data": {"path": fork_path}, + } + ) diff --git a/src/talemate/server/websocket_server.py b/src/talemate/server/websocket_server.py index cedc303e..5490eb67 100644 --- a/src/talemate/server/websocket_server.py +++ b/src/talemate/server/websocket_server.py @@ -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 @@ -27,6 +27,7 @@ from talemate.server import ( quick_settings, scene_message as scene_message_plugin, time_passage, + timeline, ux, world_state_manager, node_editor, @@ -83,6 +84,7 @@ class WebsocketHandler(SceneAssetsBatchingMixin, Receiver): scene_message_plugin.SceneMessagePlugin.router: scene_message_plugin.SceneMessagePlugin( self ), + timeline.TimelinePlugin.router: timeline.TimelinePlugin(self), } # unconveniently named function, this `connect` method is called @@ -148,7 +150,6 @@ class WebsocketHandler(SceneAssetsBatchingMixin, Receiver): reset=False, callback=None, file_name=None, - rev: int | None = None, scene_initialization: dict | None = None, ): try: @@ -171,34 +172,14 @@ class WebsocketHandler(SceneAssetsBatchingMixin, Receiver): try: # Use input path directly scene_path = path_or_data - add_to_recent = rev is None scene = await load_scene( scene, scene_path, reset=reset, - add_to_recent=add_to_recent, scene_initialization=SceneInitialization(**scene_initialization) if scene_initialization else None, ) - # If a revision is requested, reconstruct and load it - if rev is not None: - from talemate.changelog import write_reconstructed_scene - - temp_name = f"{scene.filename.replace('.json', '')}-{str(uuid.uuid4())[:10]}.json" - temp_path = await write_reconstructed_scene( - scene, to_rev=rev, output_filename=temp_name - ) - scene = self.init_scene() - scene.active = True - scene._memory_never_persisted = True - scene = await load_scene( - scene, - temp_path, - add_to_recent=False, - ) - scene.filename = "" - os.remove(temp_path) except Exception as e: self.scene = self.init_scene() return await self.load_scene_failure(e) @@ -647,6 +628,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] diff --git a/src/talemate/server/world_state_manager/__init__.py b/src/talemate/server/world_state_manager/__init__.py index ca89d3f3..5f3a99ea 100644 --- a/src/talemate/server/world_state_manager/__init__.py +++ b/src/talemate/server/world_state_manager/__init__.py @@ -159,16 +159,6 @@ class SelectiveCharacterPayload(pydantic.BaseModel): name: str -class CreateCharacterPayload(pydantic.BaseModel): - generate: bool = True - instructions: str | None = None - name: str | None = None - description: str | None = None - is_player: bool = False - generate_attributes: bool = False - generation_options: world_state_templates.GenerationOptions | None = None - - class SceneOutlinePayload(pydantic.BaseModel): title: str description: str | None = None @@ -1069,42 +1059,6 @@ class WorldStateManagerPlugin( await self.signal_operation_done() self.scene.emit_status() - async def handle_create_character(self, data): - payload = CreateCharacterPayload(**data) - - try: - character = await self.world_state_manager.create_character( - generate=payload.generate, - instructions=payload.instructions, - name=payload.name, - description=payload.description, - is_player=payload.is_player, - generate_attributes=payload.generate_attributes, - generation_options=payload.generation_options, - active=payload.is_player or not self.scene.has_active_npcs, - ) - except Exception as e: - log.error("Error creating character", error=e) - await self.signal_operation_failed("Error creating character") - return - - self.websocket_handler.queue_put( - { - "type": "world_state_manager", - "action": "character_created", - "data": { - "name": character.name, - "description": character.description, - }, - } - ) - - await self.handle_get_character_list({}) - await self.handle_get_character_details({"name": character.name}) - await self.signal_operation_done() - - self.scene.emit_status() - async def handle_update_scene_outline(self, data): payload = SceneOutlinePayload(**data) diff --git a/src/talemate/server/world_state_manager/character.py b/src/talemate/server/world_state_manager/character.py index b4cc6854..195a2d46 100644 --- a/src/talemate/server/world_state_manager/character.py +++ b/src/talemate/server/world_state_manager/character.py @@ -26,6 +26,13 @@ class UpdateCharacterVisualRulesPayload(pydantic.BaseModel): visual_rules: str | None = None +class UpdateCharacterVisualFinalizersPayload(pydantic.BaseModel): + """Payload for updating a character's visual prompt finalizers.""" + + name: str + visual_finalizers: list[dict] = pydantic.Field(default_factory=list) + + class UpdateCharacterSharedPayload(pydantic.BaseModel): """Payload for updating a character shared.""" @@ -146,6 +153,34 @@ class CharacterMixin: await self.signal_operation_done() self.scene.emit_status() + async def handle_update_character_visual_finalizers(self, data: dict): + """Update a character's visual prompt finalizers.""" + try: + payload = UpdateCharacterVisualFinalizersPayload(**data) + except pydantic.ValidationError as e: + log.error("Invalid payload for update_character_visual_finalizers", error=e) + await self.signal_operation_failed(str(e)) + return + + try: + await self.world_state_manager.update_character_visual_finalizers( + payload.name, payload.visual_finalizers + ) + except Exception as e: + log.error( + "Failed to update character visual finalizers", + character=payload.name, + error=e, + ) + await self.signal_operation_failed( + "Failed to update character visual finalizers" + ) + return + + await self.handle_get_character_details({"name": payload.name}) + await self.signal_operation_done() + self.scene.emit_status() + async def handle_update_character_shared(self, data: dict): """Update a character shared. If enabling shared and no shared context is configured, ensure one exists following selection rules. diff --git a/src/talemate/server/world_state_manager/episodes.py b/src/talemate/server/world_state_manager/episodes.py index fc13b209..23f4dc09 100644 --- a/src/talemate/server/world_state_manager/episodes.py +++ b/src/talemate/server/world_state_manager/episodes.py @@ -58,6 +58,7 @@ class EpisodesMixin: await self.signal_operation_done() self.scene.emit_status() + await self.scene.emit_history() async def handle_add_episode(self, data): """Add a new episode.""" diff --git a/src/talemate/tale_mate.py b/src/talemate/tale_mate.py index 429f58b0..e38bd98e 100644 --- a/src/talemate/tale_mate.py +++ b/src/talemate/tale_mate.py @@ -1,5 +1,4 @@ import asyncio -import json import os import re import traceback @@ -464,14 +463,9 @@ class Scene(Emitter): if not self.writing_style_template: return None - try: - group_uid, template_uid = self.writing_style_template.split("__", 1) - # Ensure template collection is initialized via manager - return self.world_state_manager.template_collection.find_template( - group_uid, template_uid - ) - except ValueError: - return None + return self.world_state_manager.template_collection.find_template_by_id( + self.writing_style_template + ) def agent_persona(self, agent_name: str): """ @@ -481,14 +475,7 @@ class Scene(Emitter): uid = (self.agent_persona_templates or {}).get(agent_name) if not uid: return None - try: - group_uid, template_uid = uid.split("__", 1) - except ValueError: - return None - # Ensure template collection is initialized via manager - return self.world_state_manager.template_collection.find_template( - group_uid, template_uid - ) + return self.world_state_manager.template_collection.find_template_by_id(uid) @property def agent_persona_names(self) -> dict[str, str]: @@ -1929,7 +1916,7 @@ class Scene(Emitter): emit("status", status="success", message="Saved scene") with open(filepath, "w") as f: - json.dump(scene_data, f, indent=2, cls=save.SceneEncoder) + f.write(save.scene_data_dumps(scene_data)) self.saved = True @@ -1963,7 +1950,7 @@ class Scene(Emitter): serialized["memory_id"] = str(uuid.uuid4())[:10] filepath = os.path.join(self.save_dir, filename) with open(filepath, "w") as f: - json.dump(serialized, f, indent=2, cls=save.SceneEncoder) + f.write(save.scene_data_dumps(serialized)) async def add_to_recent_scenes(self): log.debug("add_to_recent_scenes", filename=self.filename) @@ -2020,6 +2007,8 @@ class Scene(Emitter): ah for ah in self.archived_history if ah.get("end") is None ] + self.layered_history = [] + self.world_state.reset() self.filename = "" @@ -2055,19 +2044,21 @@ class Scene(Emitter): ) restore_from = self.restore_from + use_changelog = ( + from_rev is not None or from_date is not None or to_date is not None + ) - if not self.restore_from: - self.log.error("No save file specified to restore from.") + if not self.restore_from and not use_changelog: + self.log.warning("restore: no save file specified to restore from") return - self.reset() - self.active_characters = [] - await self.remove_all_actors() - from talemate.load import load_scene - # If a changelog rev/date-range is provided, reconstruct first - if from_rev is not None or from_date is not None or to_date is not None: + # If a changelog rev/date-range is provided, reconstruct first. + # Reconstruction must happen before reset() since reset() clears + # `filename`, which the changelog paths are resolved from. + temp_path = None + if use_changelog: from talemate.changelog import reconstruct_scene_data target_rev = from_rev @@ -2076,17 +2067,21 @@ class Scene(Emitter): temp_name = f"{os.path.splitext(self.filename or 'scene.json')[0]}-restored.json" temp_path = os.path.join(self.save_dir, temp_name) with open(temp_path, "w") as f: - json.dump(reconstructed, f, indent=2, cls=save.SceneEncoder) - await load_scene( - self, - temp_path, - get_agent("conversation").client, - ) + f.write(save.scene_data_dumps(reconstructed)) + + self.reset() + self.active_characters = [] + await self.remove_all_actors() + + if use_changelog: + try: + await load_scene(self, temp_path, add_to_recent=False) + finally: + os.remove(temp_path) else: await load_scene( self, os.path.join(self.save_dir, self.restore_from), - get_agent("conversation").client, ) if not self.restore_from: self.restore_from = restore_from @@ -2108,6 +2103,7 @@ class Scene(Emitter): except Exception as e: self.log.error("restore", error=e, traceback=traceback.format_exc()) + raise def sync_restore(self, *args, **kwargs): loop = asyncio.get_event_loop() @@ -2161,7 +2157,7 @@ class Scene(Emitter): @property def json(self): - return json.dumps(self.serialize, indent=2, cls=save.SceneEncoder) + return save.scene_data_dumps(self.serialize) def interrupt(self): self.cancel_requested = True diff --git a/src/talemate/util/__init__.py b/src/talemate/util/__init__.py index 6e812011..52eda63b 100644 --- a/src/talemate/util/__init__.py +++ b/src/talemate/util/__init__.py @@ -40,6 +40,36 @@ def count_tokens(source): return t +def reverse_trim_history( + history: list, budget_tokens: int, count_fn: Callable | None = None +) -> list: + """ + Reverse-trim history to fit within a token budget: walk from the end, + include items until the budget is exceeded, return them in chronological + order. The most recent item is always kept, even when it alone exceeds + the budget. + + `count_fn` computes the token count per item; defaults to + `count_tokens(str(item))`. + """ + if not history: + return [] + if budget_tokens <= 0: + return history[-1:] + if count_fn is None: + count_fn = lambda item: count_tokens(str(item)) # noqa: E731 + + selected: list = [] + total = 0 + for item in reversed(history): + tokens = count_fn(item) + if total + tokens > budget_tokens and selected: + break + selected.insert(0, item) + total += tokens + return selected + + def limit_tokens(text: str, limit: int) -> str: """ separate by linebreaks and pop off chunks until the total number of tokens is less than or equal to the limit. diff --git a/src/talemate/util/data.py b/src/talemate/util/data.py index bae129f7..dd2c31b8 100644 --- a/src/talemate/util/data.py +++ b/src/talemate/util/data.py @@ -17,10 +17,15 @@ __all__ = [ "DataParsingError", "fix_yaml_colon_in_strings", "fix_faulty_yaml", + "parse_attribute_lines", + "trim_attributes", ] log = structlog.get_logger("talemate.util.dedupe") +# the character's own name is prompt scaffold, not a generated attribute +UNBUDGETED_ATTRIBUTES = frozenset({"name"}) + if TYPE_CHECKING: from talemate.client.base import ClientBase @@ -52,6 +57,56 @@ class DataParsingError(Exception): super().__init__(self.message) +def trim_attributes( + attributes: dict[str, str], max_attributes: int | None = None +) -> dict[str, str]: + """Trim an attributes dict to `max_attributes` attributes, preserving + insertion order. A falsy or negative limit means no limit. + + Attributes in `UNBUDGETED_ATTRIBUTES` are kept without costing a slot, + wherever they sit in the sheet. + """ + if not max_attributes or max_attributes <= 0: + return dict(attributes) + + trimmed = {} + budgeted = 0 + for name, value in attributes.items(): + if name.strip().lower() in UNBUDGETED_ATTRIBUTES: + trimmed[name] = value + continue + + if budgeted >= max_attributes: + continue + + trimmed[name] = value + budgeted += 1 + + return trimmed + + +def parse_attribute_lines( + text: str, max_attributes: int | None = None +) -> dict[str, str]: + """Parse `Name: value` lines into an attributes dict. + + The shared character-sheet attribute format: one attribute per line, + with a colon after the attribute name. Parsing stops at the first + non-empty line without a colon. The character's own name does not cost + a slot of `max_attributes`. + """ + data = {} + for line in text.split("\n"): + if not line.strip(): + continue + if ":" not in line: + break + name, value = line.split(":", 1) + data[name.strip()] = value.strip() + + return trim_attributes(data, max_attributes) + + def fix_faulty_json(data: str) -> str: # Fix missing commas data = re.sub(r"}\s*{", "},{", data) diff --git a/src/talemate/util/encryption.py b/src/talemate/util/encryption.py index 46abf84a..fcbd4d75 100644 --- a/src/talemate/util/encryption.py +++ b/src/talemate/util/encryption.py @@ -29,8 +29,14 @@ log = structlog.get_logger("talemate.util.encryption") # Prefix that marks encrypted values in YAML ENC_PREFIX = "ENC:" -# Field names to encrypt/decrypt when walking config dicts -_SENSITIVE_FIELD_NAMES = frozenset({"api_key", "override_api_key"}) +# Field names holding secrets - encrypted on disk and redacted anywhere +# config data is exposed (e.g. the help agent's settings tools) +SENSITIVE_FIELD_NAMES = frozenset({"api_key", "override_api_key"}) + +# Top-level config sections that are name -> value maps where every value +# is sensitive regardless of the key name (e.g. the environment variable +# store passed to pi subprocesses) +SENSITIVE_MAP_SECTIONS = ("env",) # Keyring identifiers for OS credential storage _KEYRING_SERVICE = "talemate" @@ -326,12 +332,12 @@ def decrypt_value(stored: str) -> str | None: def _walk_and_transform(node, transform_fn): """ Recursively walk a nested dict/list structure. For any dict key in - _SENSITIVE_FIELD_NAMES whose value is a str, apply transform_fn + SENSITIVE_FIELD_NAMES whose value is a str, apply transform_fn and replace the value in-place. """ if isinstance(node, dict): for key, value in node.items(): - if key in _SENSITIVE_FIELD_NAMES: + if key in SENSITIVE_FIELD_NAMES: if isinstance(value, str): node[key] = transform_fn(value) elif ( @@ -351,13 +357,39 @@ def _walk_and_transform(node, transform_fn): _walk_and_transform(item, transform_fn) +def _transform_map_sections(data: dict, transform_fn): + """ + Transform every string value of the top-level name -> value map sections + listed in SENSITIVE_MAP_SECTIONS. Entries whose transform returns None + (unrecoverable decrypt after key loss) are dropped. + """ + for section in SENSITIVE_MAP_SECTIONS: + mapping = data.get(section) + if not isinstance(mapping, dict): + continue + for name in list(mapping.keys()): + value = mapping[name] + if not isinstance(value, str): + continue + transformed = transform_fn(value) + if transformed is None: + del mapping[name] + else: + mapping[name] = transformed + + def decrypt_sensitive_values(data: dict) -> dict: """ Recursively walk a config dict and decrypt any values for keys named - 'api_key' or 'override_api_key' that are strings. + 'api_key' or 'override_api_key' that are strings, plus every value of + the sensitive map sections (e.g. the 'env' variable store). Modifies and returns the dict in-place. """ + # map sections first: once decrypted, the recursive walk passes their + # values through untouched even when a name collides with a sensitive + # field name + _transform_map_sections(data, decrypt_value) _walk_and_transform(data, decrypt_value) return data @@ -365,9 +397,11 @@ def decrypt_sensitive_values(data: dict) -> dict: def encrypt_sensitive_values(data: dict) -> dict: """ Recursively walk a config dict and encrypt any values for keys named - 'api_key' or 'override_api_key' that are non-empty strings. + 'api_key' or 'override_api_key' that are non-empty strings, plus every + value of the sensitive map sections (e.g. the 'env' variable store). Modifies and returns the dict in-place. """ + _transform_map_sections(data, encrypt_value) _walk_and_transform(data, encrypt_value) return data diff --git a/src/talemate/util/gpu.py b/src/talemate/util/gpu.py index 93476c5a..50e0b443 100644 --- a/src/talemate/util/gpu.py +++ b/src/talemate/util/gpu.py @@ -5,7 +5,25 @@ Kept framework-agnostic at the import boundary: torch is imported lazily so the rest of the application keeps working on installs without a CUDA-enabled torch. """ -__all__ = ["release_cuda_cache"] +import functools + +__all__ = ["cuda_available", "release_cuda_cache"] + + +@functools.cache +def cuda_available() -> bool: + """ + Whether torch reports a usable CUDA device. + + Importing torch costs ~1.2s and ~470MB, hence the deferred import. False + when torch is absent or unusable, matching the rest of this module. + """ + try: + import torch + except ImportError: + return False + + return torch.cuda.is_available() def release_cuda_cache() -> bool: @@ -22,13 +40,10 @@ def release_cuda_cache() -> bool: Returns True if a CUDA cache flush was performed, False otherwise (no torch, or no CUDA device). """ - try: - import torch - except ImportError: + if not cuda_available(): return False - if not torch.cuda.is_available(): - return False + import torch torch.cuda.empty_cache() return True diff --git a/src/talemate/util/strings.py b/src/talemate/util/strings.py index e9fca69d..881ae142 100644 --- a/src/talemate/util/strings.py +++ b/src/talemate/util/strings.py @@ -2,8 +2,30 @@ __all__ = [ "normalize_name", + "replace_smart_quotes", ] +# Typographic quotes mapped to their ascii equivalents. Talemate uses the +# straight `"` as the dialogue delimiter throughout (scene message rendering, +# dialogue/exposition separation, tts markup), so smart quotes have to be +# normalized away before text is stored or displayed. +# +# Both marks of a pair have to be mapped together: mapping only one of them +# leaves a single unbalanced straight quote behind, which the dialogue parsers +# then pair with whatever follows - worse than leaving the pair alone. The +# low-9 marks are the opening quotes of the German-style `„…“` / `‚…‘` +# convention, so they belong with the marks that close them. +SMART_QUOTE_TRANSLATION = str.maketrans( + { + "“": '"', # left double quotation mark + "”": '"', # right double quotation mark + "„": '"', # double low-9 quotation mark + "‘": "'", # left single quotation mark + "’": "'", # right single quotation mark + "‚": "'", # single low-9 quotation mark + } +) + def normalize_name(raw: str | None, max_length: int) -> str | None: """ @@ -25,3 +47,16 @@ def normalize_name(raw: str | None, max_length: int) -> str | None: if not trimmed: return None return trimmed[:max_length] + + +def replace_smart_quotes(text: str) -> str: + """ + Replace typographic quotes with their ascii equivalents. + + Args: + text: The input string. + + Returns: + The string with smart quotes replaced. + """ + return text.translate(SMART_QUOTE_TRANSLATION) diff --git a/src/talemate/ux/schema.py b/src/talemate/ux/schema.py index 4f430f71..85321e53 100644 --- a/src/talemate/ux/schema.py +++ b/src/talemate/ux/schema.py @@ -1,11 +1,36 @@ +from typing import Literal + import pydantic __all__ = [ + "Action", "Note", + "Condition", + "DynamicLabel", + "DynamicSpan", + "FieldGroup", + "FieldType", "Field", "Column", ] +# Widget types understood by the shared frontend field renderer +# (talemate_frontend/src/components/UxField.vue). +FieldType = Literal[ + "autocomplete", + "blob", + "bool", + "flags", + "number", + "table", + "text", + "vector2", + "weights", + "wstemplate", + "password", + "unified_api_key", +] + class Action(pydantic.BaseModel): action_name: str @@ -23,21 +48,164 @@ class Note(pydantic.BaseModel): actions: list[Action] = pydantic.Field(default_factory=list) -class Field(pydantic.BaseModel): +class Condition(pydantic.BaseModel): + """ + Conditional visibility for a field (or a container of fields): the + frontend only renders the item when the referenced attribute holds the + given value (or one of the given values when `value` is a list). + + How `attribute` is resolved depends on the context the field is rendered + in — agent settings resolve it against the agent's action config values, + client settings resolve it against the client's field values. + """ + + attribute: str + value: int | float | str | bool | list[int | float | str | bool] | None = None + + +class FieldGroup(pydantic.BaseModel): + """ + Groups related fields together — the frontend renders one section/tab per + group. + """ + name: str label: str - type: str - value: int | float | str | bool | list | None = None - choices: list[dict[str, str | int | float | bool]] = pydantic.Field( - default_factory=list - ) - max: int | float | None = None - min: int | float | None = None - step: int | float | None = None + description: str = "" + icon: str = "mdi-cog" + + +class Field(pydantic.BaseModel): + """ + Uniform UX field definition. + + This is the shared schema for user-configurable settings rendered by the + frontend — agent action configs (talemate.agents.base.AgentActionConfig) + and client extra fields (talemate.client.base.ExtraField) both extend it. + The frontend renders any of these through the shared UxField component. + """ + + # Field identifier. Optional because some containers (e.g. agent action + # config dicts) key their fields externally. + name: str = "" + type: FieldType + label: str description: str = "" + value: int | float | str | bool | list | dict | None = None + default_value: int | float | str | bool | None = None + + # number widgets + min: int | float | None = None + max: int | float | None = None + step: int | float | None = None + graduations: list[dict[str, int | float]] | None = None + + # blob widgets — initial textarea height and whether it grows with + # content (up to max_rows when set) + rows: int | None = None + max_rows: int | None = None + auto_grow: bool = False + + # choice widgets — always a list of {"label": ..., "value": ...} dicts; + # scalar shorthand entries are normalized by the validator below. + choices: ( + list[dict[str, str | int | float | bool | list[int | float | bool]]] | None + ) = None + + # table widgets + columns: list["Column"] | None = None + + note: Note | None = None + note_on_value: dict[str | int | float | bool, Note] = pydantic.Field( + default_factory=dict + ) + + condition: Condition | None = None + group: FieldGroup | None = None + required: bool = False + # marks settings that can cause many additional prompts when enabled + expensive: bool = False + # value changes should be saved immediately rather than on dialog save + save_on_change: bool = False + + @pydantic.field_validator("choices", mode="before") + @classmethod + def normalize_choices(cls, v): + if v is None: + return v + return [ + choice + if isinstance(choice, dict) + else {"label": str(choice), "value": choice} + for choice in v + ] + + @pydantic.field_validator("note", mode="before") + @classmethod + def coerce_note(cls, v): + if isinstance(v, str): + return Note(text=v) + return v + + @pydantic.field_validator("note_on_value", mode="before") + @classmethod + def coerce_note_on_value(cls, v): + if isinstance(v, dict): + return { + key: Note(text=note) if isinstance(note, str) else note + for key, note in v.items() + } + return v + + # notes can also be assigned as plain strings after construction + # (assignment bypasses validation), so coerce again at dump time + @pydantic.field_serializer("note") + def serialize_note(self, v): + if isinstance(v, str): + return Note(text=v) + return v + + +class DynamicLabel(pydantic.BaseModel): + """ + Per-row label override for a table column: when the row's value for + `attribute` (a sibling column) matches a key in `labels`, that label is + shown instead of the column's static label. + """ + + attribute: str + labels: dict[str, str] + + +class DynamicSpan(pydantic.BaseModel): + """ + Per-row span override for a table column: when the row's value for + `attribute` (a sibling column) matches a key in `spans`, that span is + used instead of the column's static span. + """ + + attribute: str + spans: dict[str, int] class Column(Field): - pass + # grid width (out of 12) in the table widget's stacked row layout; + # the frontend falls back to a per-type default when unset + span: int | None = None + + # render in the row's control rail (with the move/delete buttons) + # instead of the fields grid — bool columns only + rail: bool = False + + # marks a rail bool column as the row's enable toggle — the table widget + # dims the row's fields while it is off + disables_row: bool = False + + dynamic_label: DynamicLabel | None = None + dynamic_span: DynamicSpan | None = None + + +# resolve the "Column" forward reference in Field.columns +Field.model_rebuild() diff --git a/src/talemate/version.py b/src/talemate/version.py index f5508f0b..24084cb5 100644 --- a/src/talemate/version.py +++ b/src/talemate/version.py @@ -1,3 +1,3 @@ __all__ = ["VERSION"] -VERSION = "0.38.0" +VERSION = "0.39.0" diff --git a/src/talemate/world_state/manager.py b/src/talemate/world_state/manager.py index 7ca2da2f..1cc27c89 100644 --- a/src/talemate/world_state/manager.py +++ b/src/talemate/world_state/manager.py @@ -17,6 +17,7 @@ from talemate.character import ( ) from talemate.instance import get_agent from talemate.emit import emit +from talemate.util import replace_smart_quotes from talemate.world_state import ( ContextPin, ManualContext, @@ -26,11 +27,12 @@ from talemate.world_state import ( from talemate.game.schema import ConditionGroup, condition_groups_match from talemate.game.engine.context_id.base import ContextIDItem from talemate.agents.tts.schema import Voice +from talemate.agents.visual.schema import PromptFinalizer from talemate.game.engine.context_id import ContextID from talemate.scene.schema import ScenePerspectives if TYPE_CHECKING: - from talemate.tale_mate import Character, Scene + from talemate.tale_mate import Scene log = structlog.get_logger("talemate.server.world_state_manager") @@ -74,6 +76,7 @@ class CharacterDetails(pydantic.BaseModel): avatar: Union[str, None] = None # default avatar current_avatar: Union[str, None] = None # current avatar visual_rules: Union[str, None] = None + visual_finalizers: list[PromptFinalizer] = pydantic.Field(default_factory=list) color: Union[str, None] = None voice: Union[Voice, None] = None shared: bool = False @@ -205,6 +208,7 @@ class WorldStateManager: avatar=character.avatar, current_avatar=character.current_avatar, visual_rules=character.visual_rules, + visual_finalizers=character.visual_finalizers, color=character.color, voice=character.voice, shared=character.shared, @@ -453,6 +457,25 @@ class WorldStateManager: character.visual_rules = visual_rules or None character.memory_dirty = True + async def update_character_visual_finalizers( + self, character_name: str, visual_finalizers: list[dict] + ): + """ + Updates the visual prompt finalizers for a character. + + Arguments: + character_name: The name of the character to be updated. + visual_finalizers: The new list of finalizer rows for the character. + """ + character = self.scene.get_character(character_name) + if not character: + log.error("character not found", character_name=character_name) + return + + character.visual_finalizers = [ + PromptFinalizer(**row) for row in visual_finalizers or [] + ] + async def update_character_actor( self, character_name: str, @@ -480,8 +503,10 @@ class WorldStateManager: if example_dialogue: for idx, example in enumerate(example_dialogue): + example = replace_smart_quotes(example) if not example.startswith(f"{character_name}:"): - example_dialogue[idx] = f"{character_name}: {example}" + example = f"{character_name}: {example}" + example_dialogue[idx] = example character.example_dialogue = example_dialogue @@ -1010,105 +1035,6 @@ class WorldStateManager: """ await set_character_is_player(self.scene, character_name, is_player) - async def create_character( - self, - generate: bool = True, - instructions: str = None, - name: str = None, - is_player: bool = False, - description: str = "", - active: bool = False, - generate_attributes: bool = True, - generation_options: world_state_templates.GenerationOptions | None = None, - ) -> "Character": - """ - Creates a new character in the scene. - - DEPRECATED: Use the director agent's persist_character method instead. - - Arguments: - generate: Whether to generate name and description if they are not specified; defaults to True. - instructions: Optional instructions for the character creation. - name: Optional name for the new character. - is_player: Whether the new character is a player character; defaults to False. - description: Optional description for the new character. - - Returns: - The name of the newly created character. - """ - - if not name and not generate: - raise ValueError("You need to specify a name for the character.") - - creator = get_agent("creator") - world_state = get_agent("world_state") - - if not generation_options: - generation_options = world_state_templates.GenerationOptions() - - if not name and generate: - tries = 2 - while not name and tries > 0: - name = await creator.contextual_generate_from_args( - context="character attribute:name", - instructions=f"You are creating: {instructions if instructions else 'A new character'}. Only respond with the character's name.", - length=25, - uid="wsm.create_character", - character="the character", - ) - tries -= 1 - - if not name: - raise ValueError("Failed to generate a name for the character.") - - if name in self.scene.all_character_names: - raise ValueError(f'Name "{name}" already exists.') - - if not description and generate: - description = await creator.contextual_generate_from_args( - context="character detail:description", - instructions=instructions, - length=100, - uid="wsm.create_character", - character=name, - **generation_options.model_dump(), - ) - - # create character instance - character: "Character" = self.scene.Character( - name=name, - description=description, - base_attributes={}, - is_player=is_player, - ) - - # set random color for their name - character.set_color() - - if is_player: - ActorCls = self.scene.Player - else: - ActorCls = self.scene.Actor - - actor = ActorCls(character, get_agent("conversation")) - - await self.scene.add_actor(actor) - - try: - if generate_attributes: - base_attributes = await world_state.extract_character_sheet( - name=name, text=description - ) - character.update(base_attributes=base_attributes) - - if active: - await activate_character(self.scene, name) - except Exception as e: - await self.scene.remove_actor(actor) - raise e - - return character - async def update_scene_outline( self, title: str, diff --git a/src/talemate/world_state/templates/base.py b/src/talemate/world_state/templates/base.py index 83771cf4..fd34ea55 100644 --- a/src/talemate/world_state/templates/base.py +++ b/src/talemate/world_state/templates/base.py @@ -105,7 +105,24 @@ class Template(pydantic.BaseModel): **vars, ) - return value.format(**kwargs) + # user-authored template text may contain literal braces (JSON + # examples, {unknown} placeholders) - render it raw rather than + # crash the generation that includes it. AttributeError/TypeError + # cover compound fields ({character_name.first}, {player_name[0]}): + # once the first component resolves, str.format raises those instead + # of KeyError - and player_name is None in scenes without a player + try: + return value.format(**kwargs) + except (KeyError, IndexError, ValueError, AttributeError, TypeError) as e: + log.warning( + "template.formatted: instructions contain braces that are " + "not valid placeholders - using the raw text", + template=self.name, + template_type=self.template_type, + prop_name=prop_name, + error=str(e), + ) + return value TemplateType = TypeVar("TemplateType", bound=Template) @@ -436,6 +453,19 @@ class Collection(pydantic.BaseModel): return group.find(template_uid) return None + def find_template_by_id(self, template_id: str) -> Template | None: + """Resolve a `group_uid__template_uid` id to a template. + + Returns None for empty/malformed ids or when no template matches. + """ + if not template_id: + return None + try: + group_uid, template_uid = template_id.split("__", 1) + except ValueError: + return None + return self.find_template(group_uid, template_uid) + def remove(self, group: Group, save: bool = True): existing = self.find(group.uid) if existing is None: diff --git a/src/talemate/world_state/templates/content.py b/src/talemate/world_state/templates/content.py index 33750689..695c5a0a 100644 --- a/src/talemate/world_state/templates/content.py +++ b/src/talemate/world_state/templates/content.py @@ -44,3 +44,25 @@ class GenerationOptions(pydantic.BaseModel): spices: Spices | None = None spice_level: float = 0.0 writing_style: WritingStyle | None = None + + def render_spice(self, scene: "Scene", character_name: str | None = None) -> str: + """The rendered spice instruction, or an empty string when spice does + not apply - no spices, a zero spice level, or the per-generation roll + against the spice level came up short.""" + if not self.spices or self.spice_level <= 0: + return "" + + if random.random() > self.spice_level: + return "" + + return self.spices.render(scene, character_name) or "" + + def render_writing_style( + self, scene: "Scene", character_name: str | None = None + ) -> str: + """The rendered writing style instructions, or an empty string when no + writing style is set.""" + if not self.writing_style: + return "" + + return self.writing_style.render(scene, character_name) or "" diff --git a/src/talemate/world_state/templates/scene.py b/src/talemate/world_state/templates/scene.py index 1f4a2b46..47d1c781 100644 --- a/src/talemate/world_state/templates/scene.py +++ b/src/talemate/world_state/templates/scene.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING from talemate.world_state.templates.base import Template, register if TYPE_CHECKING: + from talemate.scene.schema import SceneType as IntentSceneType from talemate.tale_mate import Scene __all__ = ["SceneType"] @@ -33,15 +34,21 @@ class SceneType(Template): "instructions": self.instructions, } - def apply_to_scene(self, scene: "Scene") -> dict: + def apply_to_scene(self, scene: "Scene") -> "IntentSceneType": """ - Apply this template to create a scene type in the scene + Apply this template to create a scene type in the scene, + registering it on the scene's intent state. - Returns the created scene type dict + Returns the created scene type """ - scene_type = self.to_scene_type_dict() + # deferred: any talemate.* import runs talemate/__init__ -> tale_mate, + # which imports world_state.templates - a top-level import here + # re-enters this package mid-init + from talemate.scene.schema import SceneType as IntentSceneType - if scene and hasattr(scene, "scene_intent") and scene.scene_intent: - scene.scene_intent.scene_types[scene_type["id"]] = scene_type + scene_type = IntentSceneType(**self.to_scene_type_dict()) + + if scene and getattr(scene, "intent_state", None): + scene.intent_state.scene_types[scene_type.id] = scene_type return scene_type diff --git a/src/talemate/world_state/templates/visual.py b/src/talemate/world_state/templates/visual.py index f33ce2f1..53633e3b 100644 --- a/src/talemate/world_state/templates/visual.py +++ b/src/talemate/world_state/templates/visual.py @@ -1,12 +1,13 @@ from typing import TYPE_CHECKING import pydantic +from talemate.agents.visual.schema import PromptFinalizer from talemate.world_state.templates.base import Template, register if TYPE_CHECKING: from talemate.tale_mate import Scene -__all__ = ["VisualStyle"] +__all__ = ["VisualStyle", "VisualFinalizer"] @register("visual_style") @@ -50,3 +51,18 @@ class VisualStyle(Template): ), "visual_type": self.visual_type, } + + +@register("visual_finalizer") +class VisualFinalizer(Template): + """ + A reusable set of visual prompt post-processing actions, applied to + image generation prompts by the visualizer agent's prompt + finalization step. + """ + + description: str | None = None + + finalizers: list[PromptFinalizer] = pydantic.Field(default_factory=list) + + template_type: str = "visual_finalizer" diff --git a/talemate_frontend/package.json b/talemate_frontend/package.json index 7a6e7aa5..1402463f 100644 --- a/talemate_frontend/package.json +++ b/talemate_frontend/package.json @@ -1,8 +1,8 @@ { "name": "talemate_frontend", - "version": "0.38.0", + "version": "0.39.0", "private": true, - "packageManager": "pnpm@11.4.0+sha512.f0febc7e37552ab485494a914241b338e0b3580b93d54ce31f00933015880863129038a1b4ae4e414a0ee63ac35bf21197e990172c4a68256450b5636310968f", + "packageManager": "pnpm@11.15.1+sha512.81350b07e53c9538a02f1f2303b4290fa2d7be04e56e2a970c4cc4b417dc761de196edabd49d55c7dc9580db81007c44143e4e3d7e462b3000d23c255122d065", "type": "module", "scripts": { "serve": "vite", diff --git a/talemate_frontend/src/components/AIAgent.vue b/talemate_frontend/src/components/AIAgent.vue index 776792c2..702e07d4 100644 --- a/talemate_frontend/src/components/AIAgent.vue +++ b/talemate_frontend/src/components/AIAgent.vue @@ -89,34 +89,49 @@ @@ -130,7 +145,14 @@ import AgentModal from './AgentModal.vue'; import { isPrimaryModifier } from '@/utils/keyboardModifiers'; import AgentMessages from './AgentMessages.vue'; -import { countSceneOverrides } from '@/constants/sceneAgentSettings'; +import { + configOverrideActive, + countSceneOverrides, + effectiveActionEnabled, + effectiveConfigValue, + enabledOverrideActive, + setActionOverrideSlice, +} from '@/constants/sceneAgentSettings'; export default { components: { @@ -206,6 +228,17 @@ export default { }; }, methods: { + uxSnapshot() { + // what the open agent modal shows, for the help agent's UX snapshot + if(!this.state.dialog) return null; + const tab = this.$refs.modal?.tab || null; + return { + agent: this.state.currentAgent?.name || null, + agent_label: this.state.currentAgent?.label || null, + tab: tab === '_config' ? 'general' : tab, + }; + }, + sceneOverrideCount(agent) { return countSceneOverrides(agent?.data?.scene_overrides); }, @@ -281,16 +314,83 @@ export default { return false; }, + // ------------------------------------------------------------------ + // Quick-toggle chips — scene-aware reads and writes + // + // A chip must show what the loaded scene actually runs on: the scene + // override when one is active, the global value otherwise. Its click + // writes to whichever of the two it is showing, so the chip never + // changes a value other than the one displayed. + // ------------------------------------------------------------------ + + sceneOverlay(agent) { + return agent.data.scene_overrides || {}; + }, + actionEnabled(agent, action_name) { + return effectiveActionEnabled(agent.actions, this.sceneOverlay(agent), action_name); + }, + isEnabledOverridden(agent, action_name) { + return enabledOverrideActive(this.sceneOverlay(agent), action_name); + }, + configValue(agent, action_name, config_name) { + return effectiveConfigValue( + agent.actions, + this.sceneOverlay(agent), + `${action_name}.config.${config_name}`, + ); + }, + isConfigOverridden(agent, action_name, config_name) { + return configOverrideActive(this.sceneOverlay(agent), action_name, config_name); + }, + actionChipTooltip(agent, action_name, action) { + if (this.isEnabledOverridden(agent, action_name)) { + return `Scene override — toggles ${action.label} for this scene only (global: ${action.enabled ? 'on' : 'off'})`; + } + return `Toggles ${action.label} globally`; + }, + configChipTooltip(agent, action_name, config_name, config) { + if (this.isConfigOverridden(agent, action_name, config_name)) { + // `config` is the global entry the template iterates, so its + // value is the one the override masks. + return `Scene override — toggles ${config.label} for this scene only (global: ${config.value ? 'on' : 'off'})`; + } + return `Toggles ${config.label} globally`; + }, + /** + * Persist a changed per-action override slice. The server replaces the + * whole per-agent slice, so the full overlay goes over the wire. + */ + saveSceneOverrideSlice(agent, action_name, slice) { + const overlay = { + actions: setActionOverrideSlice(this.sceneOverlay(agent).actions || {}, action_name, slice), + }; + // Keep the local copy in sync so the chip updates immediately + // instead of waiting for the agent status echo. + agent.data.scene_overrides = overlay; + this.getWebsocket().send(JSON.stringify({ + type: 'agent_config', + action: 'save_scene_overrides', + agent_type: agent.name, + override: overlay, + })); + }, toggleAction(agent, action_name, action) { + if (this.isEnabledOverridden(agent, action_name)) { + const slice = JSON.parse(JSON.stringify(this.sceneOverlay(agent).actions[action_name])); + slice.enabled = !slice.enabled; + this.saveSceneOverrideSlice(agent, action_name, slice); + return; + } + // Toggle the action's enabled state action.enabled = !action.enabled; - + // Update the agent's actions agent.actions[action_name].enabled = action.enabled; - + // Save the agent to persist the changes this.saveAgent(agent); - + // Send update to server this.getWebsocket().send(JSON.stringify({ type: 'agent_action', @@ -300,15 +400,22 @@ export default { })); }, toggleSubConfig(agent, action_name, config_name, config) { + if (this.isConfigOverridden(agent, action_name, config_name)) { + const slice = JSON.parse(JSON.stringify(this.sceneOverlay(agent).actions[action_name])); + slice.config[config_name] = { value: !slice.config[config_name].value }; + this.saveSceneOverrideSlice(agent, action_name, slice); + return; + } + // Toggle the config value (assuming it's a boolean) config.value = !config.value; - + // Update the agent's config agent.actions[action_name].config[config_name].value = config.value; - + // Save the agent to persist the changes this.saveAgent(agent); - + // Send update to server using the same type as action toggles this.getWebsocket().send(JSON.stringify({ type: 'agent_action', diff --git a/talemate_frontend/src/components/AIClient.vue b/talemate_frontend/src/components/AIClient.vue index c9fb0771..b608bf62 100644 --- a/talemate_frontend/src/components/AIClient.vue +++ b/talemate_frontend/src/components/AIClient.vue @@ -173,7 +173,7 @@ - + @@ -187,7 +187,7 @@ - + @@ -257,14 +257,15 @@ - You have no LLM clients configured. Add one. @@ -277,6 +278,32 @@ import ClientModal from './ClientModal.vue'; import AIClientRequestInformation from './AIClientRequestInformation.vue'; import GraduatedSlider from './GraduatedSlider.vue'; +// user-editable fields compared against client_status echoes to confirm a save +const SAVE_ECHO_FIELDS = [ + 'max_token_length', + 'api_url', + 'reason_tokens', + 'effort_level', + 'preset_group', + 'reason_enabled', + 'vision_enabled', + 'concurrent_inference_enabled', + 'rate_limit', + 'retry_empty_response', + 'retry_rate_limit', + 'retry_missing_reasoning', + 'data_format', + 'section_format', + 'double_coercion', +]; + +// echoed at the top level of the client_status message rather than in data +const TOP_LEVEL_ECHO_FIELDS = ['max_token_length', 'api_url']; + +// how long after a save the backend stays non-authoritative while we wait for +// the confirming echo; a status arriving later than this is applied as-is +const SAVE_ECHO_GRACE_MS = 3000; + export default { props: { immutableConfig: Object, @@ -289,7 +316,8 @@ export default { }, data() { return { - saveDelayTimeout: null, + saveDelayTimeouts: {}, + pendingSaves: {}, clientStatusCheck: null, hideDisabled: true, clientImmutable: {}, @@ -373,6 +401,17 @@ export default { ], methods: { + uxSnapshot() { + // what the open client modal shows, for the help agent's UX snapshot + if(!this.state.dialog) return null; + const modal = this.$refs.modal; + return { + client_name: modal?.client?.name || this.state.currentClient?.name || null, + client_type: modal?.client?.type || this.state.currentClient?.type || null, + tab: modal?.tab || null, + }; + }, + callErrorAction(client, action) { if(action.action_name === 'openAppConfig') { this.$emit('open-app-config', ...action.arguments); @@ -418,12 +457,12 @@ export default { saveClientDelayed(client) { client.dirty = true; - if (this.saveDelayTimeout) { - clearTimeout(this.saveDelayTimeout); + if (this.saveDelayTimeouts[client.name]) { + clearTimeout(this.saveDelayTimeouts[client.name]); } - this.saveDelayTimeout = setTimeout(() => { + this.saveDelayTimeouts[client.name] = setTimeout(() => { + delete this.saveDelayTimeouts[client.name]; this.saveClient(client); - client.dirty = false; }, 500); }, @@ -434,9 +473,46 @@ export default { } else { this.state.clients[index] = client; } + // stay dirty until the backend echoes these values back (or the grace + // window expires) so stale client_status messages can't rubberband the UI + client.dirty = true; + const snapshot = {}; + for (const field of SAVE_ECHO_FIELDS) { + snapshot[field] = client[field]; + } + this.pendingSaves[client.name] = { sentAt: Date.now(), snapshot }; this.state.dialog = false; // Close the dialog after saving the client this.$emit('clients-updated', this.state.clients); }, + + // Decides whether an incoming client_status may be applied to a client + // with local edits. Confirmed (echo matches what we sent) and grace-expired + // saves clear the dirty flag; anything else is a stale echo and is ignored. + echoResolvesDirtyState(client, data) { + if (this.saveDelayTimeouts[client.name]) { + // still debouncing local edits + return false; + } + + const pending = this.pendingSaves[client.name]; + const echoed = (field) => + TOP_LEVEL_ECHO_FIELDS.includes(field) ? data[field] : data.data[field]; + const normalize = (v) => (v === undefined || v === null || v === '') ? null : v; + const confirmed = !pending || SAVE_ECHO_FIELDS.every((field) => { + // fields the client type doesn't use are not part of the contract + if (pending.snapshot[field] === undefined) return true; + return normalize(pending.snapshot[field]) === normalize(echoed(field)); + }); + const graceExpired = pending && (Date.now() - pending.sentAt) > SAVE_ECHO_GRACE_MS; + + if (confirmed || graceExpired) { + delete this.pendingSaves[client.name]; + client.dirty = false; + return true; + } + + return false; + }, editClient(index) { this.state.currentClient = { ...this.state.clients[index] }; this.state.formTitle = 'Edit AI Client'; @@ -444,7 +520,13 @@ export default { }, deleteClient(index) { if (window.confirm('Are you sure you want to delete this client?')) { - this.clientImmutable[this.state.clients[index].name] = new Date().getTime(); + const name = this.state.clients[index].name; + this.clientImmutable[name] = new Date().getTime(); + if (this.saveDelayTimeouts[name]) { + clearTimeout(this.saveDelayTimeouts[name]); + delete this.saveDelayTimeouts[name]; + } + delete this.pendingSaves[name]; this.state.clients.splice(index, 1); this.$emit('clients-updated', this.state.clients); } @@ -543,7 +625,12 @@ export default { // Find the client with the given name const client = this.state.clients.find(client => client.name === data.name); - if (client && !client.dirty) { + if (client && client.dirty && !this.echoResolvesDirtyState(client, data)) { + // local edits pending or save not yet echoed back - ignore stale status + return; + } + + if (client) { // Update the model name of the client client.model_name = data.model_name; client.error_message = data.data.error_message; @@ -568,6 +655,9 @@ export default { client.double_coercion = data.data.double_coercion; client.manual_model_choices = data.data.manual_model_choices; client.rate_limit = data.data.rate_limit; + client.retry_empty_response = data.data.retry_empty_response; + client.retry_rate_limit = data.data.retry_rate_limit; + client.retry_missing_reasoning = data.data.retry_missing_reasoning; client.data_format = data.data.data_format; client.section_format = data.data.section_format; client.data = data.data; @@ -605,6 +695,9 @@ export default { double_coercion: data.data.double_coercion, manual_model_choices: data.data.manual_model_choices, rate_limit: data.data.rate_limit, + retry_empty_response: data.data.retry_empty_response, + retry_rate_limit: data.data.retry_rate_limit, + retry_missing_reasoning: data.data.retry_missing_reasoning, data_format: data.data.data_format, section_format: data.data.section_format, data: data.data, diff --git a/talemate_frontend/src/components/AgentGlobalSettings.vue b/talemate_frontend/src/components/AgentGlobalSettings.vue index 6ddddff1..67ba32a4 100644 --- a/talemate_frontend/src/components/AgentGlobalSettings.vue +++ b/talemate_frontend/src/components/AgentGlobalSettings.vue @@ -46,8 +46,8 @@ - import { getProperty } from 'dot-prop'; -import AgentSettingField from './AgentSettingField.vue'; +import { conditionMet } from '@/utils/uxConditions'; +import UxField from './UxField.vue'; // Renders one AgentAction in Global mode. Scene mode lives in // [[AgentSceneSettings.vue]]. The per-field widget rendering is owned by -// [[AgentSettingField.vue]] and shared with the scene-mode renderer. +// [[UxField.vue]] and shared with the scene-mode renderer. export default { components: { - AgentSettingField, + UxField, }, props: { // Live mutable agent (deep-cloned in AgentModal). We mutate action via @@ -106,18 +107,13 @@ export default { // action is rendered. if (typeof this.agent.client !== 'object') return true; const value = getProperty(this.agent.actions, action.condition.attribute + ".value"); - if (Array.isArray(action.condition.value)) { - return action.condition.value.some(v => v == value); - } - return value == action.condition.value; + return conditionMet(action.condition, value); }, testConfigConditional(config) { - if (config.condition == null) return true; - const value = getProperty(this.agent.actions, config.condition.attribute + ".value"); - if (Array.isArray(config.condition.value)) { - return config.condition.value.some(v => v == value); - } - return value == config.condition.value; + const value = config.condition + ? getProperty(this.agent.actions, config.condition.attribute + ".value") + : null; + return conditionMet(config.condition, value); }, }, }; diff --git a/talemate_frontend/src/components/AgentModal.vue b/talemate_frontend/src/components/AgentModal.vue index aef3ff01..8fae4007 100644 --- a/talemate_frontend/src/components/AgentModal.vue +++ b/talemate_frontend/src/components/AgentModal.vue @@ -3,6 +3,11 @@ :model-value="localDialog" @update:model-value="onDialogModelUpdate" max-width="1200px" + :scrim="!helpChatIsOpen" + :retain-focus="!helpChatIsOpen" + :persistent="helpChatIsOpen" + :no-click-animation="helpChatIsOpen" + :content-class="helpChatIsOpen ? 'yield-to-help-drawer' : ''" > @@ -35,9 +40,17 @@ - + + + mdi-help-circle-outline + Ask the help agent about these settings + + + mdi-close + Close + @@ -106,6 +119,8 @@ :action-schema="agent.data.actions[key]" :app-config="appConfig" :templates="templates" + :agent-actions="agent.actions" + :overlay="sceneOverrides" :overrides="sceneOverrides.actions[key] || {}" @update:overrides="(v) => updateActionOverrides(key, v)" @change="dirtyScene = true" @@ -145,6 +160,7 @@ \ No newline at end of file diff --git a/talemate_frontend/src/components/AppConfigAppearance.vue b/talemate_frontend/src/components/AppConfigAppearance.vue deleted file mode 100644 index 0fa02895..00000000 --- a/talemate_frontend/src/components/AppConfigAppearance.vue +++ /dev/null @@ -1,80 +0,0 @@ - - - \ No newline at end of file diff --git a/talemate_frontend/src/components/AppConfigAppearanceAssets.vue b/talemate_frontend/src/components/AppConfigAppearanceAssets.vue index 37ca35f1..ce225e35 100644 --- a/talemate_frontend/src/components/AppConfigAppearanceAssets.vue +++ b/talemate_frontend/src/components/AppConfigAppearanceAssets.vue @@ -1,13 +1,5 @@ diff --git a/talemate_frontend/src/components/AppConfigPresets.vue b/talemate_frontend/src/components/AppConfigPresets.vue deleted file mode 100644 index eff053bc..00000000 --- a/talemate_frontend/src/components/AppConfigPresets.vue +++ /dev/null @@ -1,97 +0,0 @@ - - \ No newline at end of file diff --git a/talemate_frontend/src/components/AppConfigPresetsSystemPrompts.vue b/talemate_frontend/src/components/AppConfigPresetsSystemPrompts.vue index 40c37193..d13967da 100644 --- a/talemate_frontend/src/components/AppConfigPresetsSystemPrompts.vue +++ b/talemate_frontend/src/components/AppConfigPresetsSystemPrompts.vue @@ -33,7 +33,7 @@ rows="10" auto-grow clearable - @update:model-value="dropIfEmpty(selectedKey);" + @update:model-value="dropIfEmpty(selectedKey); $emit('update', {system_prompts: config})" @blur="$emit('update', {system_prompts: config})" :label="labelFromValue(selected[0], tab === 'decensor')" > @@ -50,14 +50,7 @@ - -

- App wide override for the various system prompts based on action type. -

-

- These will be used when there are no client specific overrides configured in the client. -

-
+ Select a system prompt to edit

@@ -67,7 +60,7 @@ These system prompts will only be used by this client.

- You can specify global overrides in the mdi-cog Settings window. + You can specify global overrides in the mdi-cog Settings tab under Presets → System Prompts.

diff --git a/talemate_frontend/src/components/AppSettings.vue b/talemate_frontend/src/components/AppSettings.vue new file mode 100644 index 00000000..a46d8b19 --- /dev/null +++ b/talemate_frontend/src/components/AppSettings.vue @@ -0,0 +1,410 @@ + + + + + + + diff --git a/talemate_frontend/src/components/AppSettingsApiKeys.vue b/talemate_frontend/src/components/AppSettingsApiKeys.vue new file mode 100644 index 00000000..54d9de35 --- /dev/null +++ b/talemate_frontend/src/components/AppSettingsApiKeys.vue @@ -0,0 +1,79 @@ + + + diff --git a/talemate_frontend/src/components/AppSettingsEnvVariables.vue b/talemate_frontend/src/components/AppSettingsEnvVariables.vue new file mode 100644 index 00000000..257b6e98 --- /dev/null +++ b/talemate_frontend/src/components/AppSettingsEnvVariables.vue @@ -0,0 +1,87 @@ + + + diff --git a/talemate_frontend/src/components/AppSettingsGameplay.vue b/talemate_frontend/src/components/AppSettingsGameplay.vue new file mode 100644 index 00000000..c80b040e --- /dev/null +++ b/talemate_frontend/src/components/AppSettingsGameplay.vue @@ -0,0 +1,36 @@ + + + diff --git a/talemate_frontend/src/components/AppSettingsMenu.vue b/talemate_frontend/src/components/AppSettingsMenu.vue new file mode 100644 index 00000000..e9aca4b3 --- /dev/null +++ b/talemate_frontend/src/components/AppSettingsMenu.vue @@ -0,0 +1,85 @@ + + + diff --git a/talemate_frontend/src/components/AppSettingsPageHeader.vue b/talemate_frontend/src/components/AppSettingsPageHeader.vue new file mode 100644 index 00000000..95541077 --- /dev/null +++ b/talemate_frontend/src/components/AppSettingsPageHeader.vue @@ -0,0 +1,19 @@ + + + diff --git a/talemate_frontend/src/components/AppSettingsPlayerCharacter.vue b/talemate_frontend/src/components/AppSettingsPlayerCharacter.vue new file mode 100644 index 00000000..bf7a3682 --- /dev/null +++ b/talemate_frontend/src/components/AppSettingsPlayerCharacter.vue @@ -0,0 +1,32 @@ + + + diff --git a/talemate_frontend/src/components/AppSettingsStringList.vue b/talemate_frontend/src/components/AppSettingsStringList.vue new file mode 100644 index 00000000..0e6edc03 --- /dev/null +++ b/talemate_frontend/src/components/AppSettingsStringList.vue @@ -0,0 +1,45 @@ + + + diff --git a/talemate_frontend/src/components/AutoRetryAlert.vue b/talemate_frontend/src/components/AutoRetryAlert.vue new file mode 100644 index 00000000..4db6c4c7 --- /dev/null +++ b/talemate_frontend/src/components/AutoRetryAlert.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/talemate_frontend/src/components/CharacterCardImport.vue b/talemate_frontend/src/components/CharacterCardImport.vue index ec1fc65a..bcae2ec6 100644 --- a/talemate_frontend/src/components/CharacterCardImport.vue +++ b/talemate_frontend/src/components/CharacterCardImport.vue @@ -258,6 +258,121 @@
+
+ + mdi-creation + AI Generation + {{ showGenerationOptions ? 'mdi-chevron-up' : 'mdi-chevron-down' }} + + + + + + + + + +
+ +
+
+ Disabled steps fall back to the card's original data, significantly speeding up the import. +
+ + + + + + + + + + + + + + + + + + +
+
+ diff --git a/talemate_frontend/src/components/CharacterMessage.vue b/talemate_frontend/src/components/CharacterMessage.vue index 50b5b35d..c7468a91 100644 --- a/talemate_frontend/src/components/CharacterMessage.vue +++ b/talemate_frontend/src/components/CharacterMessage.vue @@ -19,7 +19,7 @@