Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
VERSION = "0.261.036"
VERSION = "0.261.037"
IS_DEVELOPMENT = is_development_env_enabled()

SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax')
Expand Down
7 changes: 7 additions & 0 deletions application/single_app/functions_diagram_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,18 @@ def build_diagram_guidance_message():

Keep the source valid so it renders on the first attempt:
- Give every node a quoted label, for example `app["Simple Chat App Service"]`. Unquoted parentheses, braces, angle brackets, colons, `#`, and quotes inside a label break the parser.
- Never use `end`, `graph`, `class`, `style`, `subgraph`, or `click` as a node id: they are reserved words and the diagram will not parse. Write `end_state` or `graph_node` instead.
- Close every `subgraph` with a lowercase `end` on its own line. `End` and `END` are not accepted.
- Write one statement per line, and use `%%` for comments.
- Use `<br/>` inside a quoted label for a line break; do not use raw newlines.
- Do not use `click`, `style` with URLs, or any directive that links or navigates. They are stripped before rendering.
- Prefer one clear diagram over several near-duplicates, place it directly after the prose it illustrates, and add a short sentence introducing it.

Keep it readable. A diagram is a picture, not a transcript:
- Keep each node label to a short phrase, roughly a handful of words. Split detail across several connected nodes instead of writing one node with a dozen `<br/>` lines in it, which renders as a tall column of text nobody can take in.
- When the user pastes text or ASCII art to be turned into a diagram, translate the structure and summarise the detail. Do not carry placeholders such as `<random GUID>`, literal `{{}}`, or quoted fragments into labels; describe them in words, or leave them to the prose around the diagram.
- Aim for something that fits on a screen. Beyond roughly twenty nodes, split the answer into more than one diagram, each with its own heading.

A diagram is not always the right answer. When the content is narrative, numeric, or a simple list, prose, a table, or a chart is better. Base every node and edge on the source material or the user's own description, and never invent components, systems, or relationships to fill out a picture.

Use a diagram, not a generated image, for structural content such as flows, architectures, sequences, and relationships: Mermaid output stays selectable, accessible, and editable. Reserve image generation for illustrative or pictorial visuals. Use inline chart blocks, not Mermaid, when the answer is a plot of numeric or categorical data."""
Expand Down
82 changes: 75 additions & 7 deletions application/single_app/functions_message_visual_styles.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,14 @@
mermaid's theme configuration in a browser, so colours are reduced to `#rrggbb` and nothing
else is stored. Sizes are capped so a message document cannot be grown without bound by
repeated requests.

An entry also carries the height someone dragged the block to. That is stored and cleared
independently of the colours, because the two are separate choices: resetting a diagram's
colours should not silently snap it back to its automatic height, and resizing a diagram
should not stop it following the reader's default palette.
"""

import math
import re

# Fence languages a style may be saved against. Matches VISUAL_STYLE_KINDS in
Expand All @@ -44,6 +50,15 @@
# Total stored entries across every kind, which bounds the size of the stored map.
MAX_STORED_ENTRIES = 100

# Stored block height in pixels. Matches MIN_STAGE_HEIGHT and MAX_STAGE_HEIGHT in
# application/v2_ui/src/components/chat/DiagramStage.tsx.
MIN_BLOCK_HEIGHT = 140
MAX_BLOCK_HEIGHT = 2000

# Sentinel meaning "the caller said nothing about the height", which is different from the
# caller asking for the stored height to be removed.
UNSET = object()

HEX_COLOR_PATTERN = re.compile(r'^#[0-9a-fA-F]{6}$')

# Long enough for the 32-bit hex fingerprint the client sends, with room to spare.
Expand Down Expand Up @@ -94,6 +109,26 @@ def validate_source_hash(value):
return candidate


def validate_block_height(value):
"""Return a storable block height in pixels, or None to clear a stored one.

Clamped rather than rejected when out of range. The value comes from a drag, so a request
a few pixels past the limit is a reader holding the mouse down, not a client misbehaving,
and refusing it would lose a change they clearly meant to make.

Non-finite values are refused rather than clamped. ``json.loads`` accepts the bare
``Infinity`` and ``NaN`` tokens, and ``round(float('inf'))`` raises ``OverflowError``, which
would escape the caller's ``VisualStyleError`` handling and turn a bad request into a 500.
"""
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise VisualStyleError('Height must be a number')
if not math.isfinite(value):
raise VisualStyleError('Height must be a finite number')
return int(min(MAX_BLOCK_HEIGHT, max(MIN_BLOCK_HEIGHT, round(value))))


def sanitize_visual_style(value):
"""Return a storable style dict, rejecting anything that is not one.

Expand Down Expand Up @@ -160,30 +195,63 @@ def count_entries(styles):
return sum(len(entries) for entries in styles.values())


def apply_visual_style(message_doc, block_kind, block_index, style, source_hash=''):
"""Store, replace or remove one block's colours, returning the resulting map.
def apply_visual_style(
message_doc,
block_kind,
block_index,
style,
source_hash='',
height=UNSET,
):
"""Store, replace or remove one block's colours and height, returning the resulting map.

``style`` of None removes the entry, which is different from storing a style that happens
``style`` of None removes the colours, which is different from storing a style that happens
to equal the reader's current default: the default can change later, and a removed entry
should follow it.

``height`` left at ``UNSET`` keeps whatever is stored, so a colour change does not disturb a
size someone chose. ``None`` clears it. The entry itself only disappears once it holds
neither colours nor a height.
"""
kind = validate_block_kind(block_kind)
index = validate_block_index(block_index)
fingerprint = validate_source_hash(source_hash)
resolved_height = UNSET if height is UNSET else validate_block_height(height)

styles = read_visual_styles(message_doc)
entries = dict(styles.get(kind) or {})
existing = entries.get(str(index))
existing = existing if isinstance(existing, dict) else {}

# A stored entry whose fingerprint no longer matches describes different content, and the
# client already ignores it. Carrying its height forward would resurrect it and stamp it
# with the new fingerprint, making a size chosen for a block that no longer exists at this
# position authoritative for the one that does.
existing_hash = existing.get('source_hash')
if isinstance(existing_hash, str) and existing_hash and fingerprint and existing_hash != fingerprint:
existing = {}

if style is None:
entries.pop(str(index), None)
entry = {}
else:
sanitized = sanitize_visual_style(style)
entry = sanitize_visual_style(style)

if resolved_height is UNSET:
kept_height = existing.get('height')
if isinstance(kept_height, int) and not isinstance(kept_height, bool):
entry['height'] = kept_height
elif resolved_height is not None:
entry['height'] = resolved_height

if entry:
if fingerprint:
sanitized['source_hash'] = fingerprint
entry['source_hash'] = fingerprint
is_new = str(index) not in entries
if is_new and count_entries(styles) >= MAX_STORED_ENTRIES:
raise VisualStyleError('Too many styled blocks in this message')
entries[str(index)] = sanitized
entries[str(index)] = entry
else:
entries.pop(str(index), None)

if entries:
styles[kind] = entries
Expand Down
9 changes: 8 additions & 1 deletion application/single_app/route_backend_chats.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@
resolve_mask_display_name,
)
from functions_message_visual_styles import (
UNSET as VISUAL_STYLE_HEIGHT_UNSET,
VisualStyleError,
apply_visual_style,
)
Expand Down Expand Up @@ -24902,13 +24903,16 @@ def mask_message_api(message_id):
@login_required
@user_required
def set_message_visual_style_api(message_id):
"""Save or clear the colours chosen for one diagram or chart inside a message.
"""Save or clear the colours and height chosen for one diagram or chart in a message.

A reply can contain several styleable blocks, so the request identifies one of them by
its position among blocks of the same kind. Recolouring one block therefore leaves the
others untouched, which is the whole point of storing this per block rather than per
message.

``height`` is optional and independent of ``style``: omitting it keeps whatever size the
block was left at, so changing colours never resets a diagram someone resized.

Unlike the classic client's chart colour editor, nothing here rewrites the message
content: the payload the model produced stays exactly as it was written, and the
colours live beside it in metadata.
Expand Down Expand Up @@ -24956,6 +24960,9 @@ def set_message_visual_style_api(message_id):
data.get('block_index'),
data.get('style'),
data.get('source_hash') or '',
# A body that never mentions the height leaves the stored one alone; one
# that sends null is asking for it to be cleared.
data.get('height') if 'height' in data else VISUAL_STYLE_HEIGHT_UNSET,
)
except VisualStyleError as ex:
debug_print(f'[VISUAL_STYLE] Invalid request: {ex}')
Expand Down
Loading