Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow streaming chunks to HTML panes #7125

Merged
merged 8 commits into from
Aug 12, 2024
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
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
Loading