feat: add information messages to SceneAppearance and enhance DirectorMessage

- Introduced `information_messages` to `SceneAppearance` for system notices.
- Enhanced `DirectorMessage` with a new `subtype` property for categorization.
- Updated UX elements to support new `BuildNoticeElement` for display-only messages.
- Implemented color resolution for special message types, including information.
- Refactored message color handling in frontend components to utilize new utility functions.
- Added markdown rendering capabilities for plain text in UX elements.
- Improved styling and behavior for various message types in the frontend.
This commit is contained in:
vegu-ai-tools
2026-04-22 01:35:47 +03:00
parent d0a145e842
commit dfe2aedbb7
19 changed files with 1151 additions and 396 deletions

View File

@@ -710,28 +710,28 @@
"inputs": [],
"outputs": [
{
"id": "15ecf58a-8fed-4aac-be58-ae43827fcc79",
"id": "d505505c-f22f-4d6c-97d5-cfc05d9c41e4",
"name": "fn",
"optional": false,
"group": null,
"socket_type": "function"
},
{
"id": "30586a55-e9e1-4b8b-9554-826f69abc196",
"id": "3b9e471a-c4ba-43e9-ab2a-829f21fe3434",
"name": "name",
"optional": false,
"group": null,
"socket_type": "str"
},
{
"id": "87b6031d-5d53-4397-b38c-5d9ee70a18b3",
"id": "5ad79ca7-370e-442b-a670-bfd9bcb38958",
"name": "allow_multiple_calls",
"optional": false,
"group": null,
"socket_type": "bool"
},
{
"id": "da949bec-6c74-4503-87d2-d78ce63572f5",
"id": "712b4cac-5fe5-42a8-aa1d-fa3caa50ba12",
"name": "ai_callback",
"optional": false,
"group": null,

View File

@@ -192,12 +192,13 @@
"template": null,
"max_calls": 8,
"retries": 0,
"response_length": 4096
"response_length": 4096,
"max_concurrent": 3
},
"x": 1822,
"y": 1760,
"width": 228,
"height": 250,
"height": 274,
"collapsed": false,
"inherited": false,
"registry": "focal/Focal",
@@ -663,7 +664,7 @@
"x": 675,
"y": 496,
"width": 210,
"height": 193,
"height": 213,
"collapsed": false,
"inherited": false,
"registry": "prompt/Jinja2Format",
@@ -1007,12 +1008,13 @@
"id": "cfaec2dd-2c88-4087-8678-43cadd6c3226",
"properties": {
"name": "my_function",
"allow_multiple_calls": true
"allow_multiple_calls": true,
"allow_concurrent": false
},
"x": 420,
"y": 1370,
"width": 214,
"height": 102,
"height": 126,
"collapsed": false,
"inherited": false,
"registry": "focal/Callback",
@@ -1073,41 +1075,18 @@
"registry": "agents/director/chat/DirectorChatSubAction",
"base_type": "core/Node"
},
"d4071915-492a-41ac-b4f9-54e48a167c5c": {
"title": "[Update Context] Story Configuration",
"id": "d4071915-492a-41ac-b4f9-54e48a167c5c",
"properties": {
"group": "Update Context",
"action_id": "update_context.story_configuration",
"action_title": "Story Configuration",
"description_chat": "Make changes to the story / experience's configuration: Title, Description, Introduction, Content Classification, Story Intention, Scene Intention and Scene Type.\n\nManage Contextual Pins (add, update, remove pins of world entries).",
"description_scene_direction": "",
"instruction_examples": [
"Update the story intention to be more specific about the expected story's mature content."
],
"availability": "both",
"force_enabled": false
},
"x": 860,
"y": 1970,
"width": 317,
"height": 246,
"collapsed": false,
"inherited": false,
"registry": "agents/director/chat/DirectorChatSubAction",
"base_type": "core/Node"
},
"5e8fb08e-30c4-4c6a-97e0-8b4844598f4c": {
"title": "AI Function Callback",
"id": "5e8fb08e-30c4-4c6a-97e0-8b4844598f4c",
"properties": {
"name": "direct_context_change",
"allow_multiple_calls": true
"allow_multiple_calls": true,
"allow_concurrent": false
},
"x": 950,
"y": 2279,
"width": 214,
"height": 102,
"height": 126,
"collapsed": false,
"inherited": false,
"registry": "focal/Callback",
@@ -1166,12 +1145,13 @@
"id": "323899a1-ffba-4d63-af97-4a25f11bf00f",
"properties": {
"name": "my_function",
"allow_multiple_calls": true
"allow_multiple_calls": true,
"allow_concurrent": false
},
"x": 380,
"y": 2520,
"width": 214,
"height": 102,
"height": 126,
"collapsed": false,
"inherited": false,
"registry": "focal/Callback",
@@ -1182,12 +1162,13 @@
"id": "cdfcf3a5-ee6b-49f8-b1ad-078ba1100fd9",
"properties": {
"name": "my_function",
"allow_multiple_calls": true
"allow_multiple_calls": true,
"allow_concurrent": false
},
"x": 400,
"y": 2850,
"width": 214,
"height": 102,
"height": 126,
"collapsed": false,
"inherited": false,
"registry": "focal/Callback",
@@ -1228,12 +1209,13 @@
"id": "b90a792b-638e-45c3-9e33-c4951efbd76e",
"properties": {
"name": "my_function",
"allow_multiple_calls": true
"allow_multiple_calls": true,
"allow_concurrent": false
},
"x": 400,
"y": 1690,
"width": 214,
"height": 102,
"height": 126,
"collapsed": false,
"inherited": false,
"registry": "focal/Callback",
@@ -1244,12 +1226,13 @@
"id": "80265d56-f041-4b26-9468-2a0b38f886a2",
"properties": {
"name": "my_function",
"allow_multiple_calls": true
"allow_multiple_calls": true,
"allow_concurrent": false
},
"x": 410,
"y": 1980,
"width": 214,
"height": 102,
"height": 126,
"collapsed": false,
"inherited": false,
"registry": "focal/Callback",
@@ -1310,6 +1293,32 @@
"inherited": false,
"registry": "focal/Argument",
"base_type": "core/Node"
},
"d4071915-492a-41ac-b4f9-54e48a167c5c": {
"title": "[Update Context] Story Configuration",
"id": "d4071915-492a-41ac-b4f9-54e48a167c5c",
"properties": {
"group": "Update Context",
"action_id": "update_context.story_configuration",
"action_title": "Story Configuration",
"description_chat": "Make changes to the story / experience's configuration: Title, Description, Introduction, Content Classification, Story Intention, Scene Intention and Scene Type.\n\nManage Contextual Pins (add, update, remove pins of world entries).",
"description_scene_direction": "",
"instruction_examples": [
"Update the story intention to be more specific about the expected story's mature content.",
"Change the scene type to `roleplay`. Julienne should explore the marketplace to see if she finds anything interesting.",
"Keep the scene type `roleplay` but update the goal. Lara and Gustav are discussing how to traverse the puzzle room."
],
"availability": "both",
"force_enabled": false
},
"x": 860,
"y": 1970,
"width": 317,
"height": 246,
"collapsed": false,
"inherited": false,
"registry": "agents/director/chat/DirectorChatSubAction",
"base_type": "core/Node"
}
},
"edges": {
@@ -1497,9 +1506,6 @@
"328832ac-fdf5-49b7-9f22-23514f5a4f46.state": [
"e9460323-7f9c-484b-a993-e351e8fd2762.item1"
],
"d4071915-492a-41ac-b4f9-54e48a167c5c.state": [
"e9460323-7f9c-484b-a993-e351e8fd2762.item2"
],
"5e8fb08e-30c4-4c6a-97e0-8b4844598f4c.callback": [
"e9460323-7f9c-484b-a993-e351e8fd2762.item3"
],
@@ -1538,6 +1544,9 @@
],
"b6e0f644-176c-4503-8f5f-511700a77bac.value": [
"f16b6be4-f755-4999-ba41-64e630e4bb61.is_player"
],
"d4071915-492a-41ac-b4f9-54e48a167c5c.state": [
"e9460323-7f9c-484b-a993-e351e8fd2762.item2"
]
},
"groups": [
@@ -1628,28 +1637,28 @@
"inputs": [],
"outputs": [
{
"id": "e29e1b3b-6730-477b-9ac1-142f040f1534",
"id": "af480e2b-b458-43f8-b6d1-5b055bb465e3",
"name": "fn",
"optional": false,
"group": null,
"socket_type": "function"
},
{
"id": "0ab8ae79-5983-4f28-88eb-1ac093b1d670",
"id": "35f819dc-1a05-476f-bd58-dec8e6e6ef85",
"name": "name",
"optional": false,
"group": null,
"socket_type": "str"
},
{
"id": "dfba608b-296c-4ad1-a542-dcbeda406666",
"id": "4461c3b6-a329-4930-929b-0cecb0875a72",
"name": "allow_multiple_calls",
"optional": false,
"group": null,
"socket_type": "bool"
},
{
"id": "dd9c4401-57ea-4023-a5bc-d5b1112be388",
"id": "87ef9dea-d65f-4f58-9d44-fa54ee857d03",
"name": "ai_callback",
"optional": false,
"group": null,
@@ -1704,6 +1713,18 @@
"min": null,
"max": null,
"ephemeral": false
},
"allow_concurrent": {
"name": "allow_concurrent",
"description": "Whether the action can be called concurrently",
"type": "bool",
"default": false,
"choices": null,
"readonly": false,
"step": null,
"min": null,
"max": null,
"ephemeral": false
}
},
"style": {

View File

@@ -565,6 +565,7 @@ class SceneAppearance(pydantic.BaseModel):
context_investigation_messages: HidableHistoryMessageStyle = (
HidableHistoryMessageStyle()
)
information_messages: HistoryMessageStyle = HistoryMessageStyle()
quotes: MarkupMessageStyle = MarkupMessageStyle()
parentheses: MarkupMessageStyle = MarkupMessageStyle()
brackets: MarkupMessageStyle = MarkupMessageStyle()

View File

@@ -944,6 +944,21 @@ class DirectorMessage(Node):
description="Describes the director action",
type="str",
default="actor_instruction",
choices=[
"actor_instruction",
"user_direction",
],
)
subtype = PropertyField(
name="subtype",
description="The subtype of the director message, used for further categorization of the message",
type="str",
default=UNRESOLVED,
choices=[
"function_call",
"user_direction",
],
)
def __init__(self, title="Director Message", **kwargs):
@@ -955,18 +970,26 @@ class DirectorMessage(Node):
self.add_input("meta", socket_type="dict", optional=True)
self.add_input("character", socket_type="character", optional=True)
self.add_input("action", socket_type="str", optional=True)
self.add_input("subtype", socket_type="str", optional=True)
self.set_property("source", "ai")
self.set_property("action", "actor_instruction")
self.set_property("subtype", UNRESOLVED)
self.add_output("message", socket_type="message_object")
self.add_output("source", socket_type="str")
self.add_output("meta", socket_type="dict")
self.add_output("character", socket_type="character")
self.add_output("action", socket_type="str")
self.add_output("subtype", socket_type="str")
async def run(self, state: GraphState):
message = self.get_input_value("message")
source = self.get_input_value("source")
action = self.get_input_value("action")
meta = self.get_input_value("meta")
character: "Character" = self.get_input_value("character")
message = self.normalized_input_value("message")
source = self.normalized_input_value("source")
action = self.normalized_input_value("action")
meta = self.normalized_input_value("meta")
subtype = self.normalized_input_value("subtype")
character: "Character" = self.normalized_input_value("character")
extra = {}
@@ -974,13 +997,20 @@ class DirectorMessage(Node):
extra["meta"] = meta
message = scene_message.DirectorMessage(
message, source=source, action=action, **extra
message, source=source, action=action, subtype=subtype, **extra
)
if character and character is not UNRESOLVED:
if character is not None:
message.set_meta(character=character.name)
self.set_output_values({"message": message})
self.set_output_values({
"message": message,
"source": source,
"meta": meta,
"character": character,
"action": action,
"subtype": subtype,
})
@register("scene/message/UnpackMeta")

View File

@@ -21,6 +21,7 @@ from talemate.game.engine.nodes.registry import register
from talemate.game.engine.ux.schema import (
UXChoiceElement,
UXElement,
UXNoticeElement,
UXSelection,
UXTextInputElement,
)
@@ -30,6 +31,7 @@ from talemate.util.ux import normalize_choices
__all__ = [
"BuildChoiceElement",
"BuildTextInputElement",
"BuildNoticeElement",
"StyleElement",
"EmitElement",
]
@@ -133,7 +135,7 @@ class _BuildUxElementMixin:
def _parse_ux_element(raw: Any) -> UXElement:
if isinstance(raw, (UXChoiceElement, UXTextInputElement)):
if isinstance(raw, (UXChoiceElement, UXTextInputElement, UXNoticeElement)):
return raw
return pydantic.TypeAdapter(UXElement).validate_python(raw)
@@ -416,6 +418,77 @@ class BuildTextInputElement(_BuildUxElementMixin, Node):
)
@register("ux/BuildNoticeElement")
class BuildNoticeElement(_BuildUxElementMixin, Node):
"""
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:
- state: any
- id: str (optional)
- closable: bool (optional)
- timeout_seconds: int (optional)
- title: str (optional)
- body: str (optional)
Outputs:
- state: any
- ux_id: str
- ux_element: dict
"""
@pydantic.computed_field(description="Node style")
@property
def style(self) -> NodeStyle:
return NodeStyle(
node_color="#2d3a38",
title_color="#3a4d4a",
icon="F1B4F",
)
def __init__(self, title="Build Notice Element", **kwargs):
super().__init__(title=title, **kwargs)
class Fields:
closable = _UxBuildCommonFields.closable
timeout_seconds = _UxBuildCommonFields.timeout_seconds
element_title = _UxBuildCommonFields.element_title
element_body = _UxBuildCommonFields.element_body
def setup(self):
self._ux_build_setup_common()
self._ux_build_setup_tail_outputs()
async def run(self, state: GraphState):
state_value = self.get_input_value("state")
ux_id, closable, timeout_seconds, title, body = self._ux_build_resolve_common()
element = UXNoticeElement(
id=ux_id,
closable=closable,
timeout_seconds=timeout_seconds,
title=title,
body=body,
)
self.set_output_values(
{
"state": state_value,
"id": element.id,
"closable": element.closable,
"timeout_seconds": element.timeout_seconds,
"title": element.title or "",
"body": element.body or "",
"ux_id": element.id,
"ux_element": element.model_dump(),
}
)
@register("ux/StyleElement")
class StyleElement(Node):
"""
@@ -461,15 +534,23 @@ class StyleElement(Node):
type="str",
default="",
)
apply_scene_colors = PropertyField(
name="apply_scene_colors",
description="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.",
type="bool",
default=False,
)
def setup(self):
self.add_input("state")
self.add_input("ux_element", socket_type="ux_element")
self.add_input("tint", socket_type="str", optional=True)
self.add_input("icon", socket_type="str", optional=True)
self.add_input("apply_scene_colors", socket_type="bool", optional=True)
self.set_property("tint", "muted")
self.set_property("icon", "")
self.set_property("apply_scene_colors", False)
self.add_output("state", socket_type="any")
self.add_output("ux_id", socket_type="str")
@@ -494,12 +575,18 @@ class StyleElement(Node):
if self.get_input_socket("icon").source
else self.get_property("icon")
)
apply_scene_colors = (
self.normalized_input_value("apply_scene_colors")
if self.get_input_socket("apply_scene_colors").source
else self.get_property("apply_scene_colors")
)
color = (str(tint).strip() if tint is not None else "") or None
icon = (str(icon).strip() if icon is not None else "") or None
element.color = color
element.icon = icon
element.apply_scene_colors = bool(apply_scene_colors)
# Always sync to meta for backward compatibility
if element.color is None:
@@ -747,6 +834,9 @@ class EmitElement(Node):
cancelled = False
timed_out = False
# Kind dispatch — which await/selection machinery to run. This is not a
# "should we block" check (use element.blocking for that); it's which
# shape-specific protocol (choices list, text input) this kind speaks.
if element.kind in ("choice", "text_input"):
scene = active_scene.get()
selection, timed_out, aborted = await self.wait_for_interaction(

View File

@@ -6,10 +6,11 @@ import pydantic
__all__ = [
"UXElementBase",
"UXAwaitableElementBase",
"UXTimedElementBase",
"UXChoice",
"UXChoiceElement",
"UXTextInputElement",
"UXNoticeElement",
"UXElement",
"UXSelection",
"UXPresentPayload",
@@ -30,9 +31,21 @@ class UXElementBase(pydantic.BaseModel):
# waiting node should be resumed as cancelled.
closable: bool = True
# Whether the element blocks other scene input while present. Interactive
# elements (choice, text_input) block and are awaited by the backend;
# display-only elements (notice) do not and are fire-and-forget.
blocking: bool = True
title: str | None = None
body: str | None = None
# When true, the frontend renders `body` through the scene-message text
# parser, which paints its own per-category colors over the text
# (quotes/emphasis/parentheses/brackets). When false, body still renders
# markdown formatting but inherits the element's color. Authors flip this
# via the `ux/StyleElement` node.
apply_scene_colors: bool = False
# Presentation hints for the frontend (Vuetify-style).
# Kept optional for backward compatibility; frontend may also read these from `meta`.
icon: str | None = None
@@ -41,13 +54,14 @@ class UXElementBase(pydantic.BaseModel):
meta: dict[str, Any] = pydantic.Field(default_factory=dict)
class UXAwaitableElementBase(UXElementBase):
class UXTimedElementBase(UXElementBase):
"""
Base for UX elements that are awaitable and may timeout.
Base for UX elements that carry a timeout. For blocking elements the backend
enforces the timeout while awaiting the user; for non-blocking elements the
frontend uses it to drive a client-side auto-dismiss.
"""
# Timeout behavior for awaitable elements.
# 0 means no timeout (wait forever until user selects/cancels).
# 0 means no timeout.
timeout_seconds: int = 0
# When timeout_seconds > 0, the backend can stamp the start time so the frontend
@@ -66,14 +80,14 @@ class UXChoice(pydantic.BaseModel):
disabled: bool = False
class UXChoiceElement(UXAwaitableElementBase):
class UXChoiceElement(UXTimedElementBase):
kind: Literal["choice"] = "choice"
choices: list[UXChoice] = pydantic.Field(default_factory=list)
multi_select: bool = False
default: str | list[str] | None = None
class UXTextInputElement(UXAwaitableElementBase):
class UXTextInputElement(UXTimedElementBase):
kind: Literal["text_input"] = "text_input"
# Render as textarea when true, single-line input when false.
@@ -89,8 +103,19 @@ class UXTextInputElement(UXAwaitableElementBase):
trim: bool = True
class UXNoticeElement(UXTimedElementBase):
"""
Display-only UX element. Fire-and-forget: the backend emits it and moves on;
the frontend renders it until the user dismisses it (when closable) or the
client-side timeout expires.
"""
kind: Literal["notice"] = "notice"
blocking: bool = False
UXElement = Annotated[
Union[UXChoiceElement, UXTextInputElement],
Union[UXChoiceElement, UXTextInputElement, UXNoticeElement],
pydantic.Field(discriminator="kind"),
]

View File

@@ -302,10 +302,10 @@ class NarratorMessage(SceneMessage):
@dataclass
class DirectorMessage(SceneMessage):
action: str = "actor_instruction"
action: Literal["actor_instruction", "user_direction"] = "actor_instruction"
source: str = "ai"
typ = "director"
subtype: str | None = None
subtype: Literal["function_call", "user_direction"] | None = None
@property
def character_name(self) -> str:

View File

@@ -78,7 +78,14 @@ COLOR_MAP = {
COLOR_NAMES = sorted(list(COLOR_MAP.keys()))
COLORS = sorted(list(COLOR_MAP.values()))
SPECIAL_COLOR_NAMES = ["narrator", "actor", "director", "time", "context_investigation"]
SPECIAL_COLOR_NAMES = [
"narrator",
"actor",
"director",
"time",
"context_investigation",
"information",
]
ALL_COLOR_NAMES = SPECIAL_COLOR_NAMES + COLOR_NAMES

View File

@@ -94,6 +94,12 @@
<span v-html="renderedContextInvestigationPreview">
</span>
</div>
<div class="mt-3 d-flex align-center" :style="buildCssStyles('information_messages', config.information_messages)">
<v-icon class="mr-2" :color="getColor('information_messages', config.information_messages?.color)">mdi-information-outline</v-icon>
<span>
A heads-up or system notice for the player.
</span>
</div>
</div>
</v-card-text>
</v-card>
@@ -108,6 +114,7 @@
<script>
import { SceneTextParser } from '@/utils/sceneMessageRenderer';
import { DEFAULT_APPEARANCE_COLORS } from '@/utils/messageColors.js';
export default {
name: 'AppConfigAppearanceScene',
@@ -140,12 +147,12 @@ export default {
}
// Ensure new styling fields exist with defaults if missing
if (!sceneConfig.quotes) {
sceneConfig.quotes = { color: this.defaultColors.quotes, italic: false, bold: false, override_color: true };
sceneConfig.quotes = { color: DEFAULT_APPEARANCE_COLORS.quotes, italic: false, bold: false, override_color: true };
} else if (sceneConfig.quotes.override_color === undefined) {
sceneConfig.quotes.override_color = true;
}
if (!sceneConfig.parentheses) {
sceneConfig.parentheses = { color: this.defaultColors.parentheses, italic: true, bold: false, override_color: true, show: true };
sceneConfig.parentheses = { color: DEFAULT_APPEARANCE_COLORS.parentheses, italic: true, bold: false, override_color: true, show: true };
} else {
if (sceneConfig.parentheses.override_color === undefined) {
sceneConfig.parentheses.override_color = true;
@@ -155,7 +162,7 @@ export default {
}
}
if (!sceneConfig.brackets) {
sceneConfig.brackets = { color: this.defaultColors.brackets, italic: true, bold: false, override_color: true, show: true };
sceneConfig.brackets = { color: DEFAULT_APPEARANCE_COLORS.brackets, italic: true, bold: false, override_color: true, show: true };
} else {
if (sceneConfig.brackets.override_color === undefined) {
sceneConfig.brackets.override_color = true;
@@ -165,7 +172,7 @@ export default {
}
}
if (!sceneConfig.emphasis) {
sceneConfig.emphasis = { color: this.defaultColors.emphasis, italic: true, bold: false, override_color: true };
sceneConfig.emphasis = { color: DEFAULT_APPEARANCE_COLORS.emphasis, italic: true, bold: false, override_color: true };
} else if (sceneConfig.emphasis.override_color === undefined) {
sceneConfig.emphasis.override_color = true;
}
@@ -249,23 +256,13 @@ export default {
colorPicker: null,
color: "#000000",
colorPickerTarget: null,
defaultColors: {
"narrator_messages": "#A180AE",
"actor_messages": "#B39DDB",
"director_messages": "#FF5722",
"time_messages": "#FFECB3",
"context_investigation_messages": "#D5C0A1",
"quotes": "#FFFFFF",
"parentheses": "#DB9DC2",
"brackets": "#DC5D5D",
"emphasis": "#B39DDB",
},
typLabelMap: {
"narrator_messages": "Narrator Messages",
"actor_messages": "Actor Messages",
"director_messages": "Director Messages",
"time_messages": "Time Messages",
"context_investigation_messages": "Context Investigations",
"information_messages": "Information Messages",
"quotes": "Quotes",
"parentheses": "Parentheses",
"brackets": "Brackets",
@@ -280,6 +277,7 @@ export default {
"actor_messages": true,
"director_messages": true,
"context_investigation_messages": true,
"information_messages": true,
"quotes": true,
"parentheses": true,
"brackets": true,
@@ -292,6 +290,7 @@ export default {
"director_messages": true,
"time_messages": true,
"context_investigation_messages": true,
"information_messages": true,
"quotes": true,
"parentheses": true,
"brackets": true,
@@ -338,7 +337,11 @@ export default {
getColor(typ, color) {
// if color is None load the default color
if (color === null) {
return this.defaultColors[typ];
// Message-type keys use the `_messages` suffix in the config UI
// (e.g. "narrator_messages") but DEFAULT_APPEARANCE_COLORS is
// keyed by bare type name — strip the suffix on lookup.
const key = typ.endsWith('_messages') ? typ.slice(0, -'_messages'.length) : typ;
return DEFAULT_APPEARANCE_COLORS[key];
}
return color;
}

View File

@@ -1,5 +1,5 @@
<template>
<v-alert v-if="show" @mouseover="hovered=true" @mouseleave="hovered=false" @click="toggle()" class="clickable" variant="text" density="compact" :color="getMessageColor('context_investigation', null)">
<v-alert v-if="show" @mouseover="hovered=true" @mouseleave="hovered=false" @click="toggle()" class="clickable" variant="text" density="compact" :color="getMessageColor('context_investigation')">
<template v-slot:close>
<v-btn size="small" icon variant="text" class="close-button" @click="deleteMessage" :disabled="uxLocked">
<v-icon>mdi-close</v-icon>

View File

@@ -1,40 +1,40 @@
<template>
<div v-if="subtype === 'user_direction'">
<!-- player-authored direction (~ / ~~ input) -->
<v-alert class="clickable user-direction" variant="text" icon="mdi-account-voice" elevation="0" density="compact" color="deep-purple-lighten-2" @click:close="deleteMessage()" closable :disabled="uxLocked">
<span class="text-caption text-medium-emphasis mr-1">Player directs:</span>
<span class="director-text">{{ text }}</span>
<v-alert class="clickable user-direction mb-8 ml-8" variant="text" elevation="0" density="compact" :style="getMessageStyle('information')" @click:close="deleteMessage()" closable :disabled="uxLocked">
<div class="text-caption" :style="getMessageStyle('director')">Message to the director</div>
<div v-html="renderedText"></div>
</v-alert>
</div>
<div v-else-if="character">
<!-- actor instructions (character direction)-->
<div class="director-container" v-if="show && minimized" >
<v-chip closable :color="getMessageColor('director', null)" class="clickable" @click:close="deleteMessage()" :disabled="uxLocked">
<v-chip closable :color="getMessageColor('director')" class="clickable" @click:close="deleteMessage()" :disabled="uxLocked">
<v-icon class="mr-2">{{ icon }}</v-icon>
<span @click="toggle()">{{ character }}</span>
</v-chip>
</div>
<v-alert v-else-if="show" class="clickable" variant="text" type="info" :icon="icon" :style="getMessageStyle('director')" elevation="0" density="compact" @click:close="deleteMessage()" :color="getMessageColor('director', null)">
<v-alert v-else-if="show" class="clickable" variant="text" type="info" :icon="icon" :style="getMessageStyle('director')" elevation="0" density="compact" @click:close="deleteMessage()" :color="getMessageColor('director')">
<span v-if="direction_mode==='internal_monologue'">
<!-- internal monologue -->
<span :style="getMessageStyle('director')" class="text-decoration-underline" @click="toggle()">{{ character }}</span>
<span :style="getMessageStyle('director')" class="ml-1" @click="toggle()">thinks</span>
<span :style="getMessageStyle('director')" class="director-text ml-1" @click="toggle()">{{ text }}</span>
<span :style="getMessageStyle('director')" class="director-text ml-1" @click="toggle()" v-html="renderedTextInline"></span>
</span>
<span v-else>
<!-- director instructs -->
<span :style="getMessageStyle('director')" @click="toggle()">Director instructs</span>
<span :style="getMessageStyle('director')" class="ml-1 text-decoration-underline" @click="toggle()">{{ character }}</span>
<span :style="getMessageStyle('director')" class="director-text ml-1" @click="toggle()">{{ text }}</span>
<span :style="getMessageStyle('director')" class="director-text ml-1" @click="toggle()" v-html="renderedTextInline"></span>
</span>
</v-alert>
</div>
<div v-else-if="action">
<v-alert :color="getMessageColor('director', null)" variant="text" type="info" :icon="icon"
<v-alert :color="getMessageColor('director')" variant="text" type="info" :icon="icon"
elevation="0" density="compact" >
<div>{{ text }}</div>
<div v-html="renderedText"></div>
<div class="text-grey text-caption">{{ action }}</div>
</v-alert>
</div>
@@ -42,6 +42,8 @@
</template>
<script>
import { parseBlock, parseInline } from '@/utils/markdownRenderer.js';
export default {
data() {
return {
@@ -58,6 +60,12 @@ export default {
} else {
return 'mdi-bullhorn-outline';
}
},
renderedText() {
return parseBlock(this.text);
},
renderedTextInline() {
return parseInline(this.text);
}
},
props: ['text', 'message_id', 'character', 'direction_mode', 'action', 'subtype', 'uxLocked', 'isLastMessage'],
@@ -79,6 +87,10 @@ export default {
cursor: pointer;
}
.user-direction {
border-left: 3px solid rgb(var(--v-theme-mutedbg));
}
.director-container {
margin-left: 10px;
}

View File

@@ -376,6 +376,10 @@ import ConfirmActionPrompt from './ConfirmActionPrompt.vue';
import VisualAssetsMixin from './VisualAssetsMixin.js';
import { isVisualAgentReady } from '../constants/visual.js';
import { primaryModifierLabel } from '@/utils/keyboardModifiers';
import {
getMessageColor as resolveMessageColor,
getMessageStyle as resolveMessageStyle,
} from '@/utils/messageColors.js';
const MESSAGE_FLAGS = {
NONE: 0,
@@ -507,13 +511,6 @@ export default {
primaryModifierLabel,
messages: [],
selectedForkMessageId: null,
defaultColors: {
"narrator": "#B39DDB",
"character": "#FFFFFF",
"director": "#FF5722",
"time": "#B39DDB",
"context_investigation": "#FFE0B2",
},
// Track last effective asset ID per scope for "on_change" cadence
lastEffectiveAssetIdByScope: {},
// Debounce timer for reapplyMessageAssetCadence
@@ -642,12 +639,8 @@ export default {
},
methods: {
getMessageColor(typ,color) {
if(!this.appearanceConfig || !this.appearanceConfig.scene[`${typ}_messages`].color) {
return this.defaultColors[typ];
}
return color || this.appearanceConfig.scene[`${typ}_messages`].color;
getMessageColor(typ) {
return resolveMessageColor(this.appearanceConfig, typ);
},
getMessageTypeHidden(typ) {
@@ -664,16 +657,7 @@ export default {
},
getMessageStyle(typ) {
let styles = "";
let config = this.appearanceConfig.scene[`${typ}_messages`];
if (config.italic) {
styles += "font-style: italic;";
}
if (config.bold) {
styles += "font-weight: bold;";
}
styles += "color: " + this.getMessageColor(typ, config.color) + ";";
return styles;
return resolveMessageStyle(this.appearanceConfig, typ);
},
clear() {
@@ -1577,8 +1561,9 @@ export default {
type: 'ux',
element: el,
});
// Track UX interaction start
if (this.beginUxInteraction) {
// Track UX interaction start — only for blocking elements.
// Non-blocking (fire-and-forget) elements must not lock scene input.
if (this.beginUxInteraction && el.blocking !== false) {
this.beginUxInteraction(id);
}
return;

View File

@@ -12,21 +12,32 @@
<v-card variant="text" class="ux-element-card">
<v-card-title v-if="element?.title || alertIcon" class="d-flex align-center pa-0 mb-2">
<v-icon v-if="alertIcon" :color="alertColor" class="mr-2">{{ alertIcon }}</v-icon>
<span :class="`text-subtitle-1 font-weight-bold text-${alertColor}`">
<v-icon v-if="alertIcon" :color="resolvedColor || alertColor" class="mr-2">{{ alertIcon }}</v-icon>
<span :class="['text-subtitle-1', 'font-weight-bold', colorClass]" :style="colorStyle">
{{ element?.title || defaultTitle() }}
</span>
</v-card-title>
<v-card-text class="pa-0">
<div v-if="element?.body" class="text-body-2 mb-2 ux-element-body" v-html="renderedBody"></div>
<div
v-if="element?.body && applySceneColors"
class="text-body-2 mb-2 ux-element-body"
:style="colorStyle"
v-html="renderedBody"
></div>
<div
v-else-if="element?.body"
:class="['text-body-2', 'mb-2', 'ux-element-body-plain', colorClass]"
:style="colorStyle"
v-html="renderedPlainBody"
></div>
<div v-if="hasTimeoutTimer" class="mb-2">
<v-progress-linear
:model-value="timeoutProgressPct"
height="6"
rounded
:color="alertColor"
:color="resolvedColor || alertColor"
class="mb-1 ux-timeout-progress"
/>
</div>
@@ -36,7 +47,7 @@
:element="element"
:ux-locked="uxLocked"
:is-closable="isClosable"
:tint-color="alertColor"
:tint-color="resolvedColor || alertColor"
@close="$emit('close', $event)"
/>
@@ -45,11 +56,13 @@
:element="element"
:ux-locked="uxLocked"
:is-closable="isClosable"
:tint-color="alertColor"
:tint-color="resolvedColor || alertColor"
@close="$emit('close', $event)"
/>
<div v-else class="text-body-2">
<!-- 'notice' has no child component: the container chrome above (title, body, icon, timeout bar) is the full render. -->
<div v-else-if="!isKnownKind" class="text-body-2">
Unsupported UX element kind: {{ element?.kind }}
</div>
</v-card-text>
@@ -61,6 +74,8 @@
import UxElementChoice from "./UxElementChoice.vue";
import UxElementTextInput from "./UxElementTextInput.vue";
import { SceneTextParser } from "@/utils/sceneMessageRenderer.js";
import { isSpecialMessageColor } from "@/utils/messageColors.js";
import { parseBlock as parsePlainMarkdown } from "@/utils/markdownRenderer.js";
export default {
name: "UxElementMessage",
@@ -82,7 +97,10 @@ export default {
default: null,
},
},
inject: ["getWebsocket"],
inject: {
getWebsocket: { default: null },
getMessageColor: { default: null },
},
emits: ["close"],
data() {
return {
@@ -98,6 +116,22 @@ export default {
alertColor() {
return this.element?.color || this.element?.meta?.color || "muted";
},
resolvedColor() {
// Tints that name scene-message types (e.g. "director",
// "context_investigation") are resolved through getMessageColor so they
// honor the user's appearance config — same path DirectorMessage uses.
// Non-special tints (standard Vuetify palette names) return null and
// fall through to the `text-<color>` class.
if (!this.getMessageColor) return null;
if (!isSpecialMessageColor(this.alertColor)) return null;
return this.getMessageColor(this.alertColor) || null;
},
colorClass() {
return this.resolvedColor ? null : `text-${this.alertColor}`;
},
colorStyle() {
return this.resolvedColor ? { color: this.resolvedColor } : null;
},
alertIcon() {
const icon = this.element?.icon || this.element?.meta?.icon;
return icon ? icon : undefined;
@@ -142,19 +176,38 @@ export default {
parser() {
const sceneConfig = this.appearanceConfig?.scene || {};
const narratorStyles = sceneConfig.narrator_messages || {};
// When the element carries a resolved scene-message color (e.g. a
// "director" tint resolves to the configured director color), use it as
// the parser's default/paragraph color instead of narrator's. Markup
// categories configured with override_color:false cascade to this
// default; categories with their own color keep it.
const defaultStyles = this.resolvedColor
? { ...narratorStyles, color: this.resolvedColor }
: narratorStyles;
return new SceneTextParser({
quotes: sceneConfig.quotes,
emphasis: sceneConfig.emphasis || narratorStyles,
parentheses: sceneConfig.parentheses || narratorStyles,
brackets: sceneConfig.brackets || narratorStyles,
default: narratorStyles,
default: defaultStyles,
});
},
renderedBody() {
if (!this.element?.body) return "";
return this.parser.parse(this.element.body);
},
renderedPlainBody() {
if (!this.element?.body) return "";
return parsePlainMarkdown(this.element.body);
},
isKnownKind() {
return ["choice", "text_input", "notice"].includes(this.element?.kind);
},
applySceneColors() {
return this.element?.apply_scene_colors === true;
},
},
watch: {
hasTimeoutTimer: {
@@ -181,6 +234,7 @@ export default {
defaultTitle() {
if (this.element?.kind === "choice") return "Choose an option";
if (this.element?.kind === "text_input") return "Enter text";
if (this.element?.kind === "notice") return "Notice";
return "Interaction";
},
_startTimer() {
@@ -191,6 +245,11 @@ export default {
// stop ticking once timer reaches zero
if (this.hasTimeoutTimer && this.timeoutRemainingMs <= 0) {
this._stopTimer();
// Non-blocking elements are fire-and-forget on the backend, so the
// client owns auto-dismissal when the timer expires.
if (this.element?.blocking === false) {
this.$emit("close", this.element?.id);
}
return;
}
this._rafId = requestAnimationFrame(tick);
@@ -205,16 +264,20 @@ export default {
},
cancel() {
if (!this.isClosable) return;
const ws = this.getWebsocket();
if (ws && this.element?.id) {
ws.send(
JSON.stringify({
type: "ux",
action: "cancel",
ux_id: this.element.id,
kind: this.element?.kind,
})
);
// Only blocking elements have a backend waiter to cancel; for non-blocking
// elements, skip the ws round-trip to avoid leaving stale selection state.
if (this.element?.blocking !== false) {
const ws = this.getWebsocket();
if (ws && this.element?.id) {
ws.send(
JSON.stringify({
type: "ux",
action: "cancel",
ux_id: this.element.id,
kind: this.element?.kind,
})
);
}
}
this.$emit("close", this.element?.id);
},
@@ -237,5 +300,39 @@ export default {
.ux-element-body :deep(.scene-paragraph:last-child) {
margin-bottom: 0;
}
/* Plain (non-scene-parsed) body: vanilla markdown, inherits the element color
but rendered subtly dimmer than the bold title for visual differentiation. */
.ux-element-body-plain {
opacity: 0.82;
}
.ux-element-body-plain :deep(p) {
margin-bottom: 0.5em;
}
.ux-element-body-plain :deep(p:last-child) {
margin-bottom: 0;
}
/* Restore list markers — Vuetify's typography reset strips them inside alerts. */
.ux-element-body :deep(ul),
.ux-element-body-plain :deep(ul) {
list-style: disc;
padding-inline-start: 1.5em;
margin-bottom: 0.5em;
}
.ux-element-body :deep(ol),
.ux-element-body-plain :deep(ol) {
list-style: decimal;
padding-inline-start: 1.5em;
margin-bottom: 0.5em;
}
.ux-element-body :deep(li),
.ux-element-body-plain :deep(li) {
margin-bottom: 0.25em;
}
</style>

View File

@@ -1,10 +1,11 @@
<template>
<div>
<div class="text-grey-lighten-1">
<div v-if="!isMultiline">
<v-text-field
v-model="text"
density="compact"
variant="outlined"
:color="tintColor"
:placeholder="placeholder"
hide-details
@keydown.enter.prevent="submit"
@@ -16,6 +17,7 @@
v-model="text"
density="compact"
variant="outlined"
:color="tintColor"
:placeholder="placeholder"
:rows="rows"
hide-details

View File

@@ -30,6 +30,13 @@ export default createVuetify({
highlight5: colors.amber.lighten3,
highlight6: colors.orange.lighten3,
highlight7: colors.blue.lighten3,
subdued1: colors.indigo.darken2,
subdued2: colors.purple.darken2,
subdued3: colors.lightGreen.darken2,
subdued4: colors.red.darken2,
subdued5: colors.amber.darken2,
subdued6: colors.orange.darken2,
subdued7: colors.blue.darken2,
persona: colors.pink.lighten3,
dirty: colors.orange.lighten2,
instructions: colors.orange.lighten4,

View File

@@ -0,0 +1,16 @@
// Shared vanilla markdown renderer for scene-message components that render
// plain markdown (no scene-parser extensions, no color injection). Using a
// single instance keeps options consistent across components — callers that
// want scene-aware parsing should use SceneTextParser instead.
import { Marked } from "marked";
const renderer = new Marked({ breaks: true, gfm: true });
export function parseBlock(text) {
return renderer.parse(text ?? "");
}
export function parseInline(text) {
return renderer.parseInline(text ?? "");
}

View File

@@ -0,0 +1,56 @@
// Scene-message color resolution.
//
// Single source of truth for the set of configurable "special" message color
// types and how their effective color/style is resolved from the user's
// appearance config. Mirrors talemate.util.colors.SPECIAL_COLOR_NAMES on the
// backend — keep in sync.
export const SPECIAL_MESSAGE_COLORS = [
"narrator",
"actor",
"director",
"time",
"context_investigation",
"information",
];
// Canonical default colors used both as the resolution fallback (when the
// user has not configured a color) and as the reset target in the appearance
// config UI. Keyed by bare type name — both message types (narrator, actor,
// director, time, context_investigation, information) and markup types
// (quotes, parentheses, brackets, emphasis).
export const DEFAULT_APPEARANCE_COLORS = {
narrator: "#A180AE",
actor: "#B39DDB",
director: "#FF5722",
time: "#FFECB3",
context_investigation: "#D5C0A1",
information: "#BA8574",
quotes: "#FFFFFF",
parentheses: "#DB9DC2",
brackets: "#DC5D5D",
emphasis: "#B39DDB",
};
export function isSpecialMessageColor(name) {
return SPECIAL_MESSAGE_COLORS.includes(name);
}
// Resolve the effective color for a scene-message type against the user's
// appearance config. Returns the configured color when set, otherwise the
// canonical default.
export function getMessageColor(appearanceConfig, typ) {
const cfg = appearanceConfig?.scene?.[`${typ}_messages`];
return cfg?.color || DEFAULT_APPEARANCE_COLORS[typ];
}
// Build an inline CSS style string (color + optional italic/bold) for a
// scene-message type from the user's appearance config.
export function getMessageStyle(appearanceConfig, typ) {
const cfg = appearanceConfig?.scene?.[`${typ}_messages`] || {};
let styles = "";
if (cfg.italic) styles += "font-style: italic;";
if (cfg.bold) styles += "font-weight: bold;";
styles += `color: ${getMessageColor(appearanceConfig, typ)};`;
return styles;
}

View File

@@ -1,29 +1,30 @@
import { marked, Marked } from 'marked';
import { DEFAULT_APPEARANCE_COLORS } from './messageColors.js';
const DEFAULTS = {
quotes: {
color: "#FFFFFF",
color: DEFAULT_APPEARANCE_COLORS.quotes,
italic: false,
bold: false,
},
parentheses: {
color: "#DB9DC2",
color: DEFAULT_APPEARANCE_COLORS.parentheses,
italic: false,
bold: false,
},
brackets: {
color: "#DC5D5D",
color: DEFAULT_APPEARANCE_COLORS.brackets,
italic: false,
bold: true,
},
emphasis: {
color: "#B39DDB",
italic: false,
color: DEFAULT_APPEARANCE_COLORS.emphasis,
italic: false,
bold: false,
},
default: {
color: "#B39DDB",
italic: true,
color: DEFAULT_APPEARANCE_COLORS.emphasis,
italic: true,
bold: false,
},
prefix: {
@@ -33,19 +34,14 @@ const DEFAULTS = {
},
}
// Default colors for different message types when no color is specified
const MESSAGE_TYPE_DEFAULTS = {
context_investigation: "#D5C0A1",
}
export class SceneTextParser {
constructor(config = {}) {
// Determine default color:
// 1. Use config.defaultColor if explicitly provided
// 2. Use message type default if messageType is specified
// 2. Use message type default from DEFAULT_APPEARANCE_COLORS if messageType is specified
// 3. Otherwise use DEFAULTS.default.color
const defaultColor = config.defaultColor ??
(config.messageType && MESSAGE_TYPE_DEFAULTS[config.messageType]) ??
const defaultColor = config.defaultColor ??
(config.messageType && DEFAULT_APPEARANCE_COLORS[config.messageType]) ??
DEFAULTS.default.color;
// Get the default message color from config.default if available