Skip to content

Commit

Permalink
Allow streaming chunks to HTML panes (#7125)
Browse files Browse the repository at this point in the history
  • Loading branch information
philippjfr authored Aug 12, 2024
1 parent f7d7f58 commit 9b198c8
Show file tree
Hide file tree
Showing 9 changed files with 166 additions and 19 deletions.
1 change: 1 addition & 0 deletions examples/reference/panes/HTML.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions examples/reference/panes/Markdown.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
40 changes: 30 additions & 10 deletions panel/chat/icon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
<div
type="button"
id="copy-button"
onclick="${script('copy_to_clipboard')}"
onclick="${script('request_value')}"
style="cursor: pointer; width: ${model.width}px; height: ${model.height}px;"
title="Copy message to clipboard"
>
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-copy" id="copy-icon"
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-copy" id="copy_icon"
width="${model.width}" height="${model.height}" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor" fill=${fill} stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z"></path>
Expand All @@ -119,10 +128,21 @@ class ChatCopyIcon(ReactiveHTML):
</div>
"""

_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
2 changes: 1 addition & 1 deletion panel/chat/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
60 changes: 59 additions & 1 deletion panel/models/html.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,30 @@
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"
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()
Expand Down Expand Up @@ -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<string, Map<string, (event: Event) => void>> = new Map()

Expand All @@ -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)
})
Expand All @@ -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() {
Expand Down
16 changes: 16 additions & 0 deletions panel/models/markup.py
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
25 changes: 21 additions & 4 deletions panel/pane/markup.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,30 +15,47 @@
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:
from bokeh.document import Document
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.
See the documentation for Bokeh Div for more detail about
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):
"""
Expand Down Expand Up @@ -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]] = [
Expand Down
22 changes: 19 additions & 3 deletions panel/tests/ui/pane/test_markup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions panel/util/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit 9b198c8

Please sign in to comment.