diff --git a/examples/reference/panes/HTML.ipynb b/examples/reference/panes/HTML.ipynb index 1bccb8372e..73862ca03f 100644 --- a/examples/reference/panes/HTML.ipynb +++ b/examples/reference/panes/HTML.ipynb @@ -22,6 +22,7 @@ "For details on other options for customizing the component see the [layout](../../how_to/layout/index.md) and [styling](../../how_to/styling/index.md) how-to guides.\n", "\n", "* **`disable_math`** (boolean, `default=True`): Whether to disable MathJax math rendering for strings escaped with `$$` delimiters.\n", + "* **`enable_streaming`** (boolean, `default=False`): Whether to enable streaming of text snippets. This will diff the `object` when it is updated and only send the trailing chunk that was added.\n", "* **`object`** (str or object): The string or object with ``_repr_html_`` method to display\n", "* **`sanitize_html`** (boolean, `default=False`): Whether to sanitize HTML sent to the frontend.\n", "* **`sanitize_hook`** (Callable, `default=bleach.clean`): Sanitization callback to apply if `sanitize_html=True`.\n", diff --git a/examples/reference/panes/Markdown.ipynb b/examples/reference/panes/Markdown.ipynb index 93eeeb7541..ef4f0055e1 100644 --- a/examples/reference/panes/Markdown.ipynb +++ b/examples/reference/panes/Markdown.ipynb @@ -25,6 +25,7 @@ "\n", "* **`dedent`** (bool): Whether to dedent common whitespace across all lines.\n", "* **`disable_math`** (boolean, `default=False`): Whether to disable MathJax math rendering for strings escaped with `$$` delimiters.\n", + "* **`enable_streaming`** (boolean, `default=False`): Whether to enable streaming of text snippets. This will diff the `object` when it is updated and only send the trailing chunk that was added.\n", "* **`extensions`** (list): A list of [Python-Markdown extensions](https://python-markdown.github.io/extensions/) to use (does not apply for 'markdown-it' and 'myst' renderers).\n", "* **`object`** (str or object): A string containing Markdown, or an object with a ``_repr_markdown_`` method.\n", "* **`plugins`** (function): A list of additional markdown-it-py plugins to apply.\n", diff --git a/panel/chat/icon.py b/panel/chat/icon.py index d7c2729323..0d0db1dadd 100644 --- a/panel/chat/icon.py +++ b/panel/chat/icon.py @@ -93,24 +93,33 @@ def _update_value(self, event): class ChatCopyIcon(ReactiveHTML): + """ + ChatCopyIcon copies the value to the clipboard when clicked. + To avoid sending the value to the frontend the value is only + synced after the icon is clicked. + """ + + css_classes = param.List(default=["copy-icon"], doc="The CSS classes of the widget.") fill = param.String(default="none", doc="The fill color of the icon.") - value = param.String(default=None, doc="The text to copy to the clipboard.") + value = param.String(default=None, doc="The text to copy to the clipboard.", precedence=-1) - css_classes = param.List(default=["copy-icon"], doc="The CSS classes of the widget.") + _synced = param.String(default=None, doc="The text to copy to the clipboard.") + + _request_sync = param.Integer(default=0) _template = """
- @@ -119,10 +128,21 @@ class ChatCopyIcon(ReactiveHTML):
""" - _scripts = {"copy_to_clipboard": """ - navigator.clipboard.writeText(`${data.value}`); - data.fill = "currentColor"; - setTimeout(() => data.fill = "none", 50); - """} + _scripts = { + "render": "copy_icon.setAttribute('fill', data.fill)", + "fill": "copy_icon.setAttribute('fill', data.fill)", + "request_value": """ + data._request_sync += 1; + data.fill = "currentColor"; + """, + "_synced": """ + navigator.clipboard.writeText(`${data._synced}`); + data.fill = "none"; + """ + } _stylesheets: ClassVar[list[str]] = [f"{CDN_DIST}css/chat_copy_icon.css"] + + @param.depends('_request_sync', watch=True) + def _sync(self): + self._synced = self.value diff --git a/panel/chat/message.py b/panel/chat/message.py index 26313d433b..11a8a8c9f9 100644 --- a/panel/chat/message.py +++ b/panel/chat/message.py @@ -487,7 +487,7 @@ def _create_panel(self, value, old=None): pass else: if isinstance(old, Markdown) and isinstance(value, str): - self._set_params(old, object=value) + self._set_params(old, enable_streaming=True, object=value) return old object_panel = _panel(value) diff --git a/panel/models/html.ts b/panel/models/html.ts index ec0d2b4800..fe549519e2 100644 --- a/panel/models/html.ts +++ b/panel/models/html.ts @@ -1,4 +1,4 @@ -import {ModelEvent} from "@bokehjs/core/bokeh_events" +import {ModelEvent, server_event} from "@bokehjs/core/bokeh_events" import type * as p from "@bokehjs/core/properties" import type {Attrs, Dict} from "@bokehjs/core/types" import {entries} from "@bokehjs/core/util/object" @@ -6,6 +6,25 @@ import {Markup} from "@bokehjs/models/widgets/markup" import {PanelMarkupView} from "./layout" import {serializeEvent} from "./event-to-object" +@server_event("html_stream") +export class HTMLStreamEvent extends ModelEvent { + constructor(readonly model: HTML, readonly patch: string, readonly start: number) { + super() + this.patch = patch + this.start = start + this.origin = model + } + + protected override get event_values(): Attrs { + return {model: this.origin, patch: this.patch, start: this.start} + } + + static override from_values(values: object) { + const {model, patch, start} = values as {model: HTML, patch: string, start: number} + return new HTMLStreamEvent(model, patch, start) + } +} + export class DOMEvent extends ModelEvent { constructor(readonly node: string, readonly data: unknown) { super() @@ -39,8 +58,33 @@ export function run_scripts(node: Element): void { } } +function throttle(func: Function, limit: number): any { + let lastFunc: number + let lastRan: number + + return function(...args: any) { + // @ts-ignore + const context = this + + if (!lastRan) { + func.apply(context, args) + lastRan = Date.now() + } else { + clearTimeout(lastFunc) + + lastFunc = setTimeout(function() { + if ((Date.now() - lastRan) >= limit) { + func.apply(context, args) + lastRan = Date.now() + } + }, limit - (Date.now() - lastRan)) + } + } +} + export class HTMLView extends PanelMarkupView { declare model: HTML + _buffer: string | null = null protected readonly _event_listeners: Map void>> = new Map() @@ -49,6 +93,7 @@ export class HTMLView extends PanelMarkupView { const {text, visible, events} = this.model.properties this.on_change(text, () => { + this._buffer = null const html = this.process_tex() this.set_html(html) }) @@ -61,6 +106,19 @@ export class HTMLView extends PanelMarkupView { this._remove_event_listeners() this._setup_event_listeners() }) + + const set_text = throttle(() => { + const text = this._buffer + this._buffer = null + this.model.setv({text}, {silent: true}) + const html = this.process_tex() + this.set_html(html) + }, 10) + this.model.on_event(HTMLStreamEvent, (event: HTMLStreamEvent) => { + const beginning = this._buffer == null ? this.model.text : this._buffer + this._buffer = beginning.slice(0, event.start)+event.patch + set_text() + }) } protected rerender() { diff --git a/panel/models/markup.py b/panel/models/markup.py index 69b0d40385..c0299c504c 100644 --- a/panel/models/markup.py +++ b/panel/models/markup.py @@ -1,12 +1,28 @@ """ Custom bokeh Markup models. """ +from typing import Any + from bokeh.core.properties import ( Bool, Dict, Either, Float, Int, List, Null, String, ) +from bokeh.events import ModelEvent from bokeh.models.widgets import Markup +class HTMLStreamEvent(ModelEvent): + + event_name = 'html_stream' + + def __init__(self, model, patch=None, start=None): + self.patch = patch + self.start = start + super().__init__(model=model) + + def event_values(self) -> dict[str, Any]: + return dict(super().event_values(), patch=self.patch, start=self.start) + + class HTML(Markup): """ A bokeh model to render HTML markup including embedded script tags. diff --git a/panel/pane/markup.py b/panel/pane/markup.py index ba5cddb7f3..583903a932 100644 --- a/panel/pane/markup.py +++ b/panel/pane/markup.py @@ -15,8 +15,8 @@ import param # type: ignore from ..io.resources import CDN_DIST -from ..models import HTML as _BkHTML, JSON as _BkJSON -from ..util import HTML_SANITIZER, escape +from ..models.markup import HTML as _BkHTML, JSON as _BkJSON, HTMLStreamEvent +from ..util import HTML_SANITIZER, escape, prefix_length from .base import ModelPane if TYPE_CHECKING: @@ -24,6 +24,7 @@ from bokeh.model import Model from pyviz_comms import Comm # type: ignore + class HTMLBasePane(ModelPane): """ Baseclass for Panes which render HTML inside a Bokeh Div. @@ -31,14 +32,30 @@ class HTMLBasePane(ModelPane): the supported options like style and sizing_mode. """ + enable_streaming = param.Boolean(default=False, doc=""" + Whether to enable streaming of text snippets. This is useful + when updating a string step by step, e.g. in a chat message.""") + _bokeh_model: ClassVar[Model] = _BkHTML - _rename: ClassVar[Mapping[str, str | None]] = {'object': 'text'} + _rename: ClassVar[Mapping[str, str | None]] = {'object': 'text', 'enable_streaming': None} _updates: ClassVar[bool] = True __abstract = True + def _update(self, ref: str, model: Model) -> None: + props = self._get_properties(model.document) + if self.enable_streaming and 'text' in props: + text = props['text'] + start = prefix_length(text, model.text) + model.run_scripts = False + patch = text[start:] + self._send_event(HTMLStreamEvent, patch=patch, start=start) + model._property_values['text'] = model.text[:start]+patch + del props['text'] + model.update(**props) + class HTML(HTMLBasePane): """ @@ -71,7 +88,7 @@ class HTML(HTMLBasePane): priority: ClassVar[float | bool | None] = None _rename: ClassVar[Mapping[str, str | None]] = { - 'sanitize_html': None, 'sanitize_hook': None + 'sanitize_html': None, 'sanitize_hook': None, 'stream': None } _rerender_params: ClassVar[list[str]] = [ diff --git a/panel/tests/ui/pane/test_markup.py b/panel/tests/ui/pane/test_markup.py index f2bae01304..6d246d76ee 100644 --- a/panel/tests/ui/pane/test_markup.py +++ b/panel/tests/ui/pane/test_markup.py @@ -82,12 +82,28 @@ def test_markdown_pane_visible_toggle(page): serve_component(page, md) - assert page.locator(".markdown").locator("div").text_content() == 'Initial\n' - assert not page.locator(".markdown").locator("div").is_visible() + expect(page.locator(".markdown").locator("div")).to_have_text('Initial\n') + expect(page.locator(".markdown").locator("div")).not_to_be_visible() md.visible = True - wait_until(lambda: page.locator(".markdown").locator("div").is_visible(), page) + expect(page.locator(".markdown").locator("div")).to_be_visible() + + +def test_markdown_pane_stream(page): + md = Markdown('Empty', enable_streaming=True) + + serve_component(page, md) + + expect(page.locator('.markdown')).to_have_text('Empty') + + md.object = '' + for i in range(1000): + md.object += str(i) + + assert md.object == ''.join(map(str, range(1000))) + expect(page.locator('.markdown')).to_have_text(md.object) + def test_html_model_no_stylesheet(page): # regression test for https://github.com/holoviz/holoviews/issues/5963 diff --git a/panel/util/__init__.py b/panel/util/__init__.py index 80cff9e211..0a074121cd 100644 --- a/panel/util/__init__.py +++ b/panel/util/__init__.py @@ -503,3 +503,21 @@ def safe_next(): if value is done: break yield value + + +def prefix_length(a: str, b: str) -> int: + """ + Searches for the length of overlap in the starting + characters of string b in a. Uses binary search + if b is not already a prefix of a. + """ + if a.startswith(b): + return len(b) + left, right = 0, min(len(a), len(b)) + while left < right: + mid = (left + right + 1) // 2 + if a.startswith(b[:mid]): + left = mid + else: + right = mid - 1 + return left