@inline-chat/hermes-agent-adapter 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -5
- package/package.json +8 -7
- package/plugin/inline/README.md +23 -0
- package/plugin/inline/adapter.py +334 -62
- package/plugin/inline/cli.py +1 -1
- package/plugin/inline/message_actions.py +78 -0
- package/plugin/inline/plugin.yaml +5 -1
- package/plugin/inline/sidecar/index.mjs +7283 -972
- package/plugin/inline/tools.py +82 -11
- package/plugin/inline/widgets.py +267 -0
package/plugin/inline/tools.py
CHANGED
|
@@ -12,6 +12,15 @@ from typing import Any, Dict, Iterable, Optional
|
|
|
12
12
|
|
|
13
13
|
from .message_actions import build_inline_agent_action_id
|
|
14
14
|
from .profile import hermes_home, inline_env, multiplex_active
|
|
15
|
+
from .widgets import (
|
|
16
|
+
MAX_MESSAGE_TEXT_UTF16_UNITS,
|
|
17
|
+
MAX_MESSAGE_TEXT_UTF8_BYTES,
|
|
18
|
+
WidgetError,
|
|
19
|
+
build_widget_markdown,
|
|
20
|
+
has_widget_markup,
|
|
21
|
+
scan_widgets,
|
|
22
|
+
utf16_len,
|
|
23
|
+
)
|
|
15
24
|
|
|
16
25
|
try:
|
|
17
26
|
from tools.registry import tool_error, tool_result
|
|
@@ -71,12 +80,25 @@ INLINE_PLATFORM_GUIDANCE = (
|
|
|
71
80
|
"do not treat thread ids as reply/quote message ids. Supported Markdown is "
|
|
72
81
|
"emphasis, inline or fenced code, links, headings, lists or checklists, "
|
|
73
82
|
"quotes, tables, separators, and HTTP(S) images. Do not rely on "
|
|
74
|
-
"strikethrough, footnotes, or arbitrary HTML; unsupported or ambiguous "
|
|
83
|
+
"strikethrough, footnotes, or arbitrary HTML outside widgets; unsupported or ambiguous "
|
|
75
84
|
"incomplete syntax remains visible text. Use ordinary Markdown "
|
|
76
85
|
"tables for tabular data, never fenced-code tables. For collapsible work, "
|
|
77
86
|
"use <details open>, then <summary>Title</summary>, body Markdown, and "
|
|
78
87
|
"</details>; put kind=\"progress\" on summary only while work is in progress. "
|
|
79
|
-
"Use <footer>Attribution or brief metadata</footer> for a short footer."
|
|
88
|
+
"Use <footer>Attribution or brief metadata</footer> for a short footer. "
|
|
89
|
+
"Widgets: one sandboxed HTML block per message, only when Markdown or tables can't show it "
|
|
90
|
+
"(charts, diagrams, mockups, comparison grids, small pickers); keep prose outside it. Prefer "
|
|
91
|
+
"the inline tool's send_widget action; in a reply, a block on its own lines works too: "
|
|
92
|
+
"<iframe title=\"Name\" id=\"name\" height=\"420\">HTML</iframe> (one widget per message; "
|
|
93
|
+
"height 80-2000 px, about the content height at 640px wide). The sandbox has no network "
|
|
94
|
+
"(no fetch, XHR, WebSocket or remote images; use data: URIs); scripts may be inline or from "
|
|
95
|
+
"cdn.jsdelivr.net, cdnjs.cloudflare.com, unpkg.com or esm.sh. Avoid vh units, position: fixed "
|
|
96
|
+
"and nested frames; fit 320-640px widths. Theme CSS variables: --background --foreground "
|
|
97
|
+
"--muted --muted-foreground --card --border --primary --primary-foreground --accent --success "
|
|
98
|
+
"--warning --destructive --radius --font-sans --font-mono; classes .card .grid .row .badge "
|
|
99
|
+
".btn .btn-primary .btn-ghost .table .muted .small. After a viewer's click, scripts can call "
|
|
100
|
+
"inline.copy(text), inline.sendPrompt(text), inline.action(name, payload) (reaches you as a "
|
|
101
|
+
"widget call turn), inline.openLink(url), and inline.setState(obj) with inline.state."
|
|
80
102
|
)
|
|
81
103
|
|
|
82
104
|
# Where each turn's triggering message physically lives, and who sent it. Hermes tells tools
|
|
@@ -129,7 +151,8 @@ _sidecars: Dict[str, Dict[str, Any]] = {}
|
|
|
129
151
|
_settings_owners: Dict[str, Any] = {}
|
|
130
152
|
|
|
131
153
|
_ACTION_MANIFEST = [
|
|
132
|
-
("send_message", "(chat_id?|user_id?, text, buttons?)", "Send a message, including optional agent-owned buttons."),
|
|
154
|
+
("send_message", "(chat_id?|user_id?, text, buttons?, reply_to_message_id?)", "Send a message, including optional agent-owned buttons."),
|
|
155
|
+
("send_widget", "(chat_id?|user_id?, title, html, widget_id?, height?, text?, reply_to_message_id?)", "Send one sandboxed HTML widget, with optional lead text above it."),
|
|
133
156
|
("get_chat", "(chat_id?)", "Get metadata for a chat or reply thread, including whether you follow it."),
|
|
134
157
|
("set_follow_mode", "(chat_id?, mode)", "Follow a thread to join its conversation, or unfollow it to answer only mentions and replies. Applies to you only; mode is following or unfollowed."),
|
|
135
158
|
("get_messages", "(chat_id?, message_ids)", "Fetch specific Inline messages by ID."),
|
|
@@ -238,6 +261,7 @@ def tool_context_prompt(
|
|
|
238
261
|
lines = [
|
|
239
262
|
"- The `inline` tool is available for Inline history, search, exact message lookup, reactions, pins, and explicit thread management.",
|
|
240
263
|
"- Return normal replies as text. Use inline send_message only to create a message with action buttons or to send into a different destination.",
|
|
264
|
+
"- For a chart, diagram, mockup or picker that Markdown can't show, use inline send_widget (then reply NO_REPLY unless more text is needed). A widget call from a viewer arrives as a turn naming widget_id, call_name and untrusted payload_json.",
|
|
241
265
|
"- An Inline action-button turn names its source message. A normal response replaces that message; omitted buttons clear the old buttons. Use edit_message with buttons to replace them explicitly.",
|
|
242
266
|
"- If edit_message already finalized the source action card during this turn, return NO_REPLY so the automatic final response does not overwrite that edit.",
|
|
243
267
|
]
|
|
@@ -270,6 +294,7 @@ INLINE_TOOL_SCHEMA = {
|
|
|
270
294
|
+ "\n\n"
|
|
271
295
|
"When chat_id is omitted, the tool uses the current Inline reply thread if present, otherwise the current chat. "
|
|
272
296
|
"Return normal assistant replies as text. Use send_message only for a button card or a different destination. "
|
|
297
|
+
"Use send_widget for one sandboxed HTML widget (see the Inline platform guidance); it is sent whole, and Inline's reason is returned if it rejects the widget. "
|
|
273
298
|
"To hand work to another bot, mention it in your normal reply; its answer comes back to you as a turn that doesn't need a response, and asking it again takes a new mention. "
|
|
274
299
|
"Agent-owned button presses return as ordinary turns, and the normal response replaces the source card while clearing omitted buttons. "
|
|
275
300
|
"Use create_thread on the current triggering message when someone asks to continue in a thread; your answer then goes into it, so don't also post it in the chat, and it starts with a short summary of the conversation so far. "
|
|
@@ -300,7 +325,11 @@ INLINE_TOOL_SCHEMA = {
|
|
|
300
325
|
"type": "string",
|
|
301
326
|
"description": "Parent message ID for create_thread. Defaults to the triggering message when available.",
|
|
302
327
|
},
|
|
303
|
-
"title": {"type": "string", "description": "Thread title. Required for create_chat, optional for create_thread, and the new name for update_chat."},
|
|
328
|
+
"title": {"type": "string", "description": "Thread title. Required for create_chat, optional for create_thread, and the new name for update_chat. For send_widget, the widget's title."},
|
|
329
|
+
"html": {"type": "string", "description": "send_widget: the widget's HTML body (no <iframe> wrapper, no </iframe>)."},
|
|
330
|
+
"widget_id": {"type": "string", "description": "send_widget: widget id, 1-64 of A-Z a-z 0-9 _ . : - (default widget). Widget calls name it."},
|
|
331
|
+
"height": {"type": "integer", "minimum": 80, "maximum": 2000, "description": "send_widget: frame height in CSS px, about the content height at 640px wide."},
|
|
332
|
+
"reply_to_message_id": {"type": "string", "description": "send_message/send_widget: message ID to reply to."},
|
|
304
333
|
"name": {"type": "string", "description": "Agent name for create_agent."},
|
|
305
334
|
"agent_id": {"type": "string", "description": "Globally unique Inline Agent ID for get_agent, update_agent, or delete_agent."},
|
|
306
335
|
"handle": {"type": "string", "description": "Optional Agent handle."},
|
|
@@ -320,7 +349,7 @@ INLINE_TOOL_SCHEMA = {
|
|
|
320
349
|
"description": "Whether create_chat is visible to the parent space. Defaults to false and requires space_id when true.",
|
|
321
350
|
},
|
|
322
351
|
"query": {"type": "string", "description": "Search query for search_messages."},
|
|
323
|
-
"text": {"type": "string", "description": "Message text for send_message or edit_message."},
|
|
352
|
+
"text": {"type": "string", "description": "Message text for send_message or edit_message; optional lead text above a send_widget widget."},
|
|
324
353
|
"buttons": {
|
|
325
354
|
"type": "array",
|
|
326
355
|
"maxItems": _MAX_ACTION_ROWS,
|
|
@@ -387,7 +416,13 @@ def _handle_inline_tool(args: Dict[str, Any], **_: Any) -> str:
|
|
|
387
416
|
if action == "create_thread":
|
|
388
417
|
return tool_result({"success": True, "action": action, "result": _create_thread(args)})
|
|
389
418
|
path, body = _request_for_action(action, args)
|
|
390
|
-
|
|
419
|
+
try:
|
|
420
|
+
response = _sidecar_call(path, body)
|
|
421
|
+
except InlineToolError as exc:
|
|
422
|
+
if action == "send_widget" and exc.error_kind == "bad_format":
|
|
423
|
+
# The server names the widget rule the message broke; the model can fix and resend.
|
|
424
|
+
raise InlineToolError(f"Inline rejected the widget: {exc}", exc.error_kind) from exc
|
|
425
|
+
raise
|
|
391
426
|
result = _compact_result(action, response.get("result") or {})
|
|
392
427
|
return tool_result({"success": True, "action": action, "result": result})
|
|
393
428
|
except InlineToolError as exc:
|
|
@@ -396,14 +431,37 @@ def _handle_inline_tool(args: Dict[str, Any], **_: Any) -> str:
|
|
|
396
431
|
|
|
397
432
|
def _request_for_action(action: str, args: Dict[str, Any]) -> tuple[str, Dict[str, Any]]:
|
|
398
433
|
if action == "send_message":
|
|
434
|
+
parse_markdown = _bool(args.get("parse_markdown"), True)
|
|
399
435
|
body = {
|
|
400
436
|
"target": _target(args),
|
|
401
|
-
"text":
|
|
402
|
-
"parseMarkdown":
|
|
437
|
+
"text": _message_text(args, parse_markdown),
|
|
438
|
+
"parseMarkdown": parse_markdown,
|
|
403
439
|
}
|
|
404
440
|
actions = _message_actions(args)
|
|
405
441
|
if actions is not None:
|
|
406
442
|
body["actions"] = actions
|
|
443
|
+
reply_to = _inline_id(args.get("reply_to_message_id"))
|
|
444
|
+
if reply_to:
|
|
445
|
+
body["replyToMsgId"] = reply_to
|
|
446
|
+
return "/send", body
|
|
447
|
+
|
|
448
|
+
if action == "send_widget":
|
|
449
|
+
if not _str(args.get("title")):
|
|
450
|
+
raise InlineToolError("title is required", "bad_format")
|
|
451
|
+
try:
|
|
452
|
+
text = build_widget_markdown(
|
|
453
|
+
html=str(args.get("html") or ""),
|
|
454
|
+
title=_str(args.get("title")),
|
|
455
|
+
widget_id=_str(args.get("widget_id")),
|
|
456
|
+
height=args.get("height"),
|
|
457
|
+
text=str(args.get("text") or ""),
|
|
458
|
+
)
|
|
459
|
+
except WidgetError as exc:
|
|
460
|
+
raise InlineToolError(str(exc), "bad_format") from exc
|
|
461
|
+
body = {"target": _target(args), "text": text, "parseMarkdown": True}
|
|
462
|
+
reply_to = _inline_id(args.get("reply_to_message_id"))
|
|
463
|
+
if reply_to:
|
|
464
|
+
body["replyToMsgId"] = reply_to
|
|
407
465
|
return "/send", body
|
|
408
466
|
|
|
409
467
|
if action == "get_chat":
|
|
@@ -437,11 +495,12 @@ def _request_for_action(action: str, args: Dict[str, Any]) -> tuple[str, Dict[st
|
|
|
437
495
|
return "/search", body
|
|
438
496
|
|
|
439
497
|
if action == "edit_message":
|
|
498
|
+
parse_markdown = _bool(args.get("parse_markdown"), True)
|
|
440
499
|
body = {
|
|
441
500
|
"target": _target(args),
|
|
442
501
|
"messageId": _required_id(args, "message_id"),
|
|
443
|
-
"text":
|
|
444
|
-
"parseMarkdown":
|
|
502
|
+
"text": _message_text(args, parse_markdown),
|
|
503
|
+
"parseMarkdown": parse_markdown,
|
|
445
504
|
}
|
|
446
505
|
actions = _message_actions(args)
|
|
447
506
|
if actions is not None:
|
|
@@ -895,7 +954,7 @@ def _message_actions(args: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
|
895
954
|
|
|
896
955
|
|
|
897
956
|
def _compact_result(action: str, result: Dict[str, Any]) -> Dict[str, Any]:
|
|
898
|
-
if action in {"send_message", "edit_message"}:
|
|
957
|
+
if action in {"send_message", "send_widget", "edit_message"}:
|
|
899
958
|
return {"messageId": _str(result.get("messageId"))}
|
|
900
959
|
if action == "get_chat":
|
|
901
960
|
return _compact_chat(result)
|
|
@@ -1166,6 +1225,18 @@ def _required_str(args: Dict[str, Any], key: str, *, max_chars: int) -> str:
|
|
|
1166
1225
|
return _truncate(value, max_chars)
|
|
1167
1226
|
|
|
1168
1227
|
|
|
1228
|
+
def _message_text(args: Dict[str, Any], parse_markdown: bool) -> str:
|
|
1229
|
+
"""Message text, shortened as before unless it holds a widget, which is sent whole or refused."""
|
|
1230
|
+
value = _str(args.get("text"))
|
|
1231
|
+
if not value:
|
|
1232
|
+
raise InlineToolError("text is required", "bad_format")
|
|
1233
|
+
if not parse_markdown or not has_widget_markup(value) or not scan_widgets(value).widgets:
|
|
1234
|
+
return _truncate(value, _MAX_TEXT_CHARS)
|
|
1235
|
+
if utf16_len(value) > MAX_MESSAGE_TEXT_UTF16_UNITS or len(value.encode("utf-8")) > MAX_MESSAGE_TEXT_UTF8_BYTES:
|
|
1236
|
+
raise InlineToolError("message with a widget exceeds Inline's 100,000 character limit", "bad_format")
|
|
1237
|
+
return value
|
|
1238
|
+
|
|
1239
|
+
|
|
1169
1240
|
def _required_id(args: Dict[str, Any], key: str) -> str:
|
|
1170
1241
|
value = _inline_id(args.get(key))
|
|
1171
1242
|
if not value:
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Inline message widgets: AFM ``<iframe>`` previews in bot Markdown.
|
|
2
|
+
|
|
3
|
+
The server turns one ``<iframe title=… id=… height=…>HTML</iframe>`` block in a
|
|
4
|
+
bot's Markdown into a sandboxed widget. These helpers mirror its parsing rules
|
|
5
|
+
so outbound text is never split inside a widget, and build the Markdown for the
|
|
6
|
+
``send_widget`` tool action.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import html as _html
|
|
11
|
+
import re
|
|
12
|
+
from typing import Any, Callable, List, NamedTuple, Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Server limits (server/src/modules/message/messageWidgets.ts, messageTextLimits.ts).
|
|
16
|
+
MAX_MESSAGE_TEXT_UTF16_UNITS = 100_000
|
|
17
|
+
MAX_MESSAGE_TEXT_UTF8_BYTES = 400_000
|
|
18
|
+
MAX_WIDGET_HTML_BYTES = 256 * 1024
|
|
19
|
+
MAX_WIDGET_TITLE_CHARS = 256
|
|
20
|
+
MIN_WIDGET_HEIGHT = 80
|
|
21
|
+
MAX_WIDGET_HEIGHT = 2000
|
|
22
|
+
|
|
23
|
+
_WIDGET_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,64}$")
|
|
24
|
+
# Same shapes as the server: the opener starts a line; the first closer ends the payload.
|
|
25
|
+
_OPENER_RE = re.compile(r"""<iframe(?=[\s>])((?:[^"'<>]|"[^"]*"|'[^']*')*)>""", re.IGNORECASE)
|
|
26
|
+
_CLOSER_RE = re.compile(r"</iframe\s*>", re.IGNORECASE)
|
|
27
|
+
_CLOSER_PREFIX_RE = re.compile(r"</iframe", re.IGNORECASE)
|
|
28
|
+
_OPENER_PREFIX_RE = re.compile(r"<iframe", re.IGNORECASE)
|
|
29
|
+
_OPENING_FENCE_RE = re.compile(r"( {0,3})(`{3,}|~{3,})(.*)")
|
|
30
|
+
_CLOSING_FENCE_RE = re.compile(r" {0,3}(`+|~+)[ \t]*")
|
|
31
|
+
_ATTRIBUTE_RE = re.compile(r"""([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?""")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class WidgetSpan(NamedTuple):
|
|
35
|
+
"""A complete widget block from its opener line start to the end of its closing line."""
|
|
36
|
+
start: int
|
|
37
|
+
end: int
|
|
38
|
+
title: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class WidgetScan(NamedTuple):
|
|
42
|
+
widgets: List[WidgetSpan]
|
|
43
|
+
# Offset of an opener whose closing tag has not arrived (a streaming preview), else None.
|
|
44
|
+
unclosed_at: Optional[int]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class WidgetError(ValueError):
|
|
48
|
+
"""A widget problem the model can fix."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def utf16_len(text: str) -> int:
|
|
52
|
+
return len(text.encode("utf-16-le")) // 2
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _read_line(text: str, start: int) -> tuple[int, int]:
|
|
56
|
+
"""(content_end, next_line_start) for the line starting at ``start``."""
|
|
57
|
+
newline = text.find("\n", start)
|
|
58
|
+
raw_end = newline if newline >= 0 else len(text)
|
|
59
|
+
content_end = raw_end - 1 if raw_end > start and text[raw_end - 1] == "\r" else raw_end
|
|
60
|
+
return content_end, (raw_end + 1 if raw_end < len(text) else len(text))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _opener_title(attributes: str) -> str:
|
|
64
|
+
for match in _ATTRIBUTE_RE.finditer(attributes):
|
|
65
|
+
if match.group(1).lower() != "title":
|
|
66
|
+
continue
|
|
67
|
+
value = next((group for group in match.groups()[1:] if group is not None), "")
|
|
68
|
+
return " ".join(_html.unescape(value).split())[:MAX_WIDGET_TITLE_CHARS]
|
|
69
|
+
return ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def scan_widgets(text: str) -> WidgetScan:
|
|
73
|
+
"""Find widget blocks as the server does: outside code fences, with the opener at a line start."""
|
|
74
|
+
widgets: List[WidgetSpan] = []
|
|
75
|
+
fence: Optional[tuple[str, int]] = None
|
|
76
|
+
cursor = 0
|
|
77
|
+
while cursor < len(text):
|
|
78
|
+
content_end, next_line = _read_line(text, cursor)
|
|
79
|
+
line = text[cursor:content_end]
|
|
80
|
+
if fence:
|
|
81
|
+
closing = _CLOSING_FENCE_RE.fullmatch(line)
|
|
82
|
+
if closing and closing.group(1)[0] == fence[0] and len(closing.group(1)) >= fence[1]:
|
|
83
|
+
fence = None
|
|
84
|
+
cursor = next_line
|
|
85
|
+
continue
|
|
86
|
+
opening = _OPENING_FENCE_RE.fullmatch(line)
|
|
87
|
+
if opening and not (opening.group(2)[0] == "`" and "`" in opening.group(3)):
|
|
88
|
+
fence = (opening.group(2)[0], len(opening.group(2)))
|
|
89
|
+
cursor = next_line
|
|
90
|
+
continue
|
|
91
|
+
opener = _OPENER_RE.match(line)
|
|
92
|
+
if not opener:
|
|
93
|
+
cursor = next_line
|
|
94
|
+
continue
|
|
95
|
+
closer = _CLOSER_RE.search(text, cursor + opener.end())
|
|
96
|
+
if not closer:
|
|
97
|
+
return WidgetScan(widgets, cursor)
|
|
98
|
+
closing_start = text.rfind("\n", 0, closer.start()) + 1
|
|
99
|
+
closing_end, after_closing = _read_line(text, closing_start)
|
|
100
|
+
widgets.append(WidgetSpan(cursor, closing_end, _opener_title(opener.group(1) or "")))
|
|
101
|
+
cursor = after_closing
|
|
102
|
+
return WidgetScan(widgets, None)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def has_widget_markup(text: str) -> bool:
|
|
106
|
+
"""Cheap pre-check before a full scan."""
|
|
107
|
+
return _OPENER_PREFIX_RE.search(text) is not None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def split_widget_aware(
|
|
111
|
+
text: str,
|
|
112
|
+
max_length: int,
|
|
113
|
+
split_plain: Callable[[str, int], List[str]],
|
|
114
|
+
len_fn: Callable[[str], int] = len,
|
|
115
|
+
) -> Optional[List[str]]:
|
|
116
|
+
"""Split ``text`` into messages without cutting a widget or putting two in one message.
|
|
117
|
+
|
|
118
|
+
Returns None when the text has no complete widget, so the caller keeps its plain splitter.
|
|
119
|
+
A widget block longer than ``max_length`` becomes its own message (the server allows
|
|
120
|
+
100,000 UTF-16 units); prose around widgets is split with ``split_plain``.
|
|
121
|
+
"""
|
|
122
|
+
if not has_widget_markup(text):
|
|
123
|
+
return None
|
|
124
|
+
scan = scan_widgets(text)
|
|
125
|
+
if not scan.widgets:
|
|
126
|
+
return None
|
|
127
|
+
if len(scan.widgets) == 1 and len_fn(text) <= max_length:
|
|
128
|
+
return [text]
|
|
129
|
+
|
|
130
|
+
segments: List[tuple[bool, str]] = []
|
|
131
|
+
cursor = 0
|
|
132
|
+
for widget in scan.widgets:
|
|
133
|
+
segments.append((False, text[cursor:widget.start]))
|
|
134
|
+
segments.append((True, text[widget.start:widget.end]))
|
|
135
|
+
cursor = widget.end
|
|
136
|
+
segments.append((False, text[cursor:]))
|
|
137
|
+
|
|
138
|
+
chunks: List[str] = []
|
|
139
|
+
current = ""
|
|
140
|
+
current_has_widget = False
|
|
141
|
+
|
|
142
|
+
def joined(part: str) -> str:
|
|
143
|
+
return f"{current}\n\n{part}" if current else part
|
|
144
|
+
|
|
145
|
+
def flush() -> None:
|
|
146
|
+
nonlocal current, current_has_widget
|
|
147
|
+
if current.strip():
|
|
148
|
+
chunks.append(current)
|
|
149
|
+
current = ""
|
|
150
|
+
current_has_widget = False
|
|
151
|
+
|
|
152
|
+
for is_widget, raw in segments:
|
|
153
|
+
part = raw.strip("\r\n")
|
|
154
|
+
if not part.strip():
|
|
155
|
+
continue
|
|
156
|
+
if is_widget:
|
|
157
|
+
if current and (current_has_widget or len_fn(joined(part)) > max_length):
|
|
158
|
+
flush()
|
|
159
|
+
current = joined(part)
|
|
160
|
+
current_has_widget = True
|
|
161
|
+
continue
|
|
162
|
+
if len_fn(joined(part)) <= max_length:
|
|
163
|
+
current = joined(part)
|
|
164
|
+
continue
|
|
165
|
+
flush()
|
|
166
|
+
pieces = [piece for piece in split_plain(part, max_length) if piece.strip()]
|
|
167
|
+
chunks.extend(pieces[:-1])
|
|
168
|
+
current = pieces[-1] if pieces else ""
|
|
169
|
+
flush()
|
|
170
|
+
return chunks or [text]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def streaming_preview(text: str) -> str:
|
|
174
|
+
"""Text safe to show while a reply streams: hide a widget whose closing tag hasn't arrived."""
|
|
175
|
+
if not has_widget_markup(text):
|
|
176
|
+
return text
|
|
177
|
+
scan = scan_widgets(text)
|
|
178
|
+
if scan.unclosed_at is None:
|
|
179
|
+
return text
|
|
180
|
+
return text[:scan.unclosed_at].rstrip() or "…"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def replace_widgets(text: str, replacement: Callable[[WidgetSpan], str]) -> str:
|
|
184
|
+
if not has_widget_markup(text):
|
|
185
|
+
return text
|
|
186
|
+
scan = scan_widgets(text)
|
|
187
|
+
out: List[str] = []
|
|
188
|
+
cursor = 0
|
|
189
|
+
for widget in scan.widgets:
|
|
190
|
+
out.append(text[cursor:widget.start])
|
|
191
|
+
out.append(replacement(widget))
|
|
192
|
+
cursor = widget.end
|
|
193
|
+
out.append(text[cursor:])
|
|
194
|
+
return "".join(out)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def without_widgets(text: str) -> str:
|
|
198
|
+
"""Replace each widget with a short notice, for a fallback after the server rejected one."""
|
|
199
|
+
def notice(widget: WidgetSpan) -> str:
|
|
200
|
+
return f'[Widget "{widget.title or "HTML preview"}" could not be displayed]'
|
|
201
|
+
|
|
202
|
+
replaced = replace_widgets(text, notice)
|
|
203
|
+
scan = scan_widgets(replaced)
|
|
204
|
+
if scan.unclosed_at is not None:
|
|
205
|
+
replaced = replaced[:scan.unclosed_at].rstrip() + "\n\n[Widget could not be displayed]"
|
|
206
|
+
return replaced
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _escape_attribute(value: str) -> str:
|
|
210
|
+
# The server decodes exactly " & < > ' ' in attribute values.
|
|
211
|
+
return value.replace("&", "&").replace('"', """).replace("<", "<").replace(">", ">")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _height(value: Any) -> Optional[int]:
|
|
215
|
+
if value is None or value == "":
|
|
216
|
+
return None
|
|
217
|
+
if isinstance(value, bool):
|
|
218
|
+
raise WidgetError("height must be a whole number of CSS pixels from 80 to 2000")
|
|
219
|
+
text = str(value).strip().lower().removesuffix("px")
|
|
220
|
+
if not text.isdigit():
|
|
221
|
+
raise WidgetError("height must be a whole number of CSS pixels from 80 to 2000")
|
|
222
|
+
height = int(text)
|
|
223
|
+
if height < MIN_WIDGET_HEIGHT or height > MAX_WIDGET_HEIGHT:
|
|
224
|
+
raise WidgetError("height must be from 80 to 2000 CSS pixels")
|
|
225
|
+
return height
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def build_widget_markdown(
|
|
229
|
+
*,
|
|
230
|
+
html: str,
|
|
231
|
+
title: Optional[str] = None,
|
|
232
|
+
widget_id: Optional[str] = None,
|
|
233
|
+
height: Any = None,
|
|
234
|
+
text: Optional[str] = None,
|
|
235
|
+
) -> str:
|
|
236
|
+
"""Markdown for one widget message: optional lead text, then the ``<iframe>`` block."""
|
|
237
|
+
body = str(html or "").strip("\r\n")
|
|
238
|
+
if not body.strip():
|
|
239
|
+
raise WidgetError("html is required")
|
|
240
|
+
if _CLOSER_PREFIX_RE.search(body):
|
|
241
|
+
raise WidgetError("html must not contain </iframe: it would end the widget early (nested iframes are not allowed)")
|
|
242
|
+
if len(body.encode("utf-8")) > MAX_WIDGET_HTML_BYTES:
|
|
243
|
+
raise WidgetError("html exceeds 256 KiB")
|
|
244
|
+
attributes: List[str] = []
|
|
245
|
+
clean_title = " ".join(str(title or "").split())
|
|
246
|
+
if len(clean_title) > MAX_WIDGET_TITLE_CHARS:
|
|
247
|
+
raise WidgetError(f"title is longer than {MAX_WIDGET_TITLE_CHARS} characters")
|
|
248
|
+
if clean_title:
|
|
249
|
+
attributes.append(f'title="{_escape_attribute(clean_title)}"')
|
|
250
|
+
clean_id = str(widget_id or "").strip()
|
|
251
|
+
if clean_id:
|
|
252
|
+
if not _WIDGET_ID_RE.fullmatch(clean_id):
|
|
253
|
+
raise WidgetError("widget_id must be 1-64 characters of letters, digits, _ . : -")
|
|
254
|
+
attributes.append(f'id="{clean_id}"')
|
|
255
|
+
parsed_height = _height(height)
|
|
256
|
+
if parsed_height is not None:
|
|
257
|
+
attributes.append(f'height="{parsed_height}"')
|
|
258
|
+
opener = "<iframe" + "".join(f" {attribute}" for attribute in attributes) + ">"
|
|
259
|
+
block = f"{opener}\n{body}\n</iframe>"
|
|
260
|
+
lead = str(text or "").strip()
|
|
261
|
+
markdown = f"{lead}\n\n{block}" if lead else block
|
|
262
|
+
scan = scan_widgets(markdown)
|
|
263
|
+
if len(scan.widgets) != 1 or scan.unclosed_at is not None or scan.widgets[0].end != len(markdown):
|
|
264
|
+
raise WidgetError("text must not contain another <iframe> or an unclosed code fence; a message holds one widget")
|
|
265
|
+
if utf16_len(markdown) > MAX_MESSAGE_TEXT_UTF16_UNITS or len(markdown.encode("utf-8")) > MAX_MESSAGE_TEXT_UTF8_BYTES:
|
|
266
|
+
raise WidgetError("message exceeds Inline's 100,000 character limit")
|
|
267
|
+
return markdown
|