@khorsheed/dsh-inline-html-render 0.1.13
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/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.en.md +79 -0
- package/README.md +79 -0
- package/cordis.patch.yml +8 -0
- package/lib/client.js +421 -0
- package/lib/index.js +88 -0
- package/lib/invariant.js +25 -0
- package/lib/types/client/bridge.d.ts +28 -0
- package/lib/types/client/bridge.js +110 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/index.js +60 -0
- package/lib/types/client/renderer.d.ts +52 -0
- package/lib/types/client/renderer.js +220 -0
- package/lib/types/client/srcdoc.d.ts +30 -0
- package/lib/types/client/srcdoc.js +88 -0
- package/lib/types/index.d.ts +32 -0
- package/lib/types/index.js +90 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +23 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +60 -0
- package/skills/3d-artifact/SKILL.md +52 -0
- package/skills/inline-html-card/SKILL.md +97 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-plugins contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.en.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# @khorsheed/dsh-inline-html-render
|
|
2
|
+
|
|
3
|
+
[English](README.en.md) | 中文
|
|
4
|
+
|
|
5
|
+
A plugin that lets the agent render **interactive HTML cards** directly in the conversation. When the agent writes a ` ```dsh-card ```` fenced block, the plugin swaps that region in the middle of the message flow for a **sandboxed iframe** running the authored HTML — genuinely inline and interactive, not ASCII art or a prose description.
|
|
6
|
+
|
|
7
|
+
```dsh-card
|
|
8
|
+
<div style="font:14px system-ui;padding:16px;background:#1e1e1e;border-radius:10px;color:#eee">
|
|
9
|
+
<div style="font-weight:600;margin-bottom:8px">design token reference</div>
|
|
10
|
+
<div style="display:flex;gap:8px">
|
|
11
|
+
<span style="background:#4f8cff;color:#fff;padding:4px 10px;border-radius:6px">primary</span>
|
|
12
|
+
<span style="background:#2ea043;color:#fff;padding:4px 10px;border-radius:6px">success</span>
|
|
13
|
+
<span style="background:#d29922;color:#fff;padding:4px 10px;border-radius:6px">warning</span>
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
In the session, that is not a code block — it is a rendered card.
|
|
19
|
+
|
|
20
|
+
## Features
|
|
21
|
+
|
|
22
|
+
- **Inline in the middle of the paragraph** — the card sits exactly where the agent wrote it, flowing naturally with the surrounding text.
|
|
23
|
+
- **Truly interactive** — the card runs in a `sandbox="allow-scripts"` iframe and supports arbitrary DOM/CSS/JS interaction (hover, click, tab switching, inline SVG/canvas animation).
|
|
24
|
+
- **Zero-invasive to official code** — it only mutates the DOM the official client has **already rendered** (inserts an iframe beside the rendered `.md-code-block` and hides that block); no official source is touched. Without this plugin the ` ```dsh-card ```` block simply degrades to a normal code block, never crashing.
|
|
25
|
+
- **Strict sandbox** — a CSP is injected (`connect-src 'none'`), so the card's scripts cannot reach the network or the host. Only three controlled `window.dshBridge.openLink / copy / download` capabilities are exposed.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
Not in the default web bundle; install and mount with one command (self-mounting via `dsh.bundle`):
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
dsh plugin --profile <p> add @khorsheed/dsh-inline-html-render # install
|
|
33
|
+
dsh plugin --profile <p> remove @khorsheed/dsh-inline-html-render # uninstall
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Authoring for the agent
|
|
37
|
+
|
|
38
|
+
The host half registers the two HTML-authoring protocols, `inline-html-card` and `3d-artifact` (both with provider `inline-html-render`). Whenever the user wants a **visible result** ("show me", "preview", "make me a card", "compare these visual options", a clickable widget) rather than a paragraph of text, the agent picks up this protocol. Core constraints (see the skills):
|
|
39
|
+
|
|
40
|
+
- Exactly one fenced block whose info string is exactly `dsh-card`; use **three** backticks ( ``` ), never four — four backticks or a nested fence make the parser read it as an outer block and the card never renders.
|
|
41
|
+
- Self-contained content: inline CSS/JS, `data:` URIs for images, zero external network.
|
|
42
|
+
- Never reach the host — use `window.dshBridge` for opening links / copying / downloading.
|
|
43
|
+
|
|
44
|
+
## Compatibility
|
|
45
|
+
|
|
46
|
+
- npm release line (`@deepseek-ai/dsh@0.1.2-rc.1`): ✅ full — this plugin consumes only standard DOM + the client runtime `ClientContext`; the `.md-code-block` wrapper, its banner info-string display, and the `data-streaming` attribute that `ui-conversation` renders are unchanged on 0.1.2-rc.1; minHost moves up to 0.1.2-rc.1 — older hosts stay on the previous release line.
|
|
47
|
+
- source line (deepseek-harness master): ✅ (verifiedHost: 0.1.2-rc.1)
|
|
48
|
+
|
|
49
|
+
## Known limitations
|
|
50
|
+
|
|
51
|
+
- **Depends on official rendered DOM details** — the renderer locates blocks via the `.md-code-block` wrapper and the leading `dsh-card` text in its banner (this release's official CodeBlock renders a hashed banner wrap with no stable `.infostring` class; also compatible with an explicit `.infostring` element), and aligns streaming completion via `AssistantMarkdown`'s `data-streaming`. This is the repo-accepted last-resort DOM-anchor pattern (same shape as message-tools' dom-hider). If the official client changes the wrapper class, the banner text shape, or the info-string placement, the MutationObserver fallback path must be re-verified.
|
|
52
|
+
- **No network in the sandbox** — card scripts cannot `fetch`/load external resources (even whitelisted CDNs are not allowed; stricter than file-preview's Tier1). For network-bearing visuals use file-preview's HTML render path instead.
|
|
53
|
+
- **Auto-height iframe** — the iframe is sized to its content height; rare dynamically-growing layouts may need a manual `data-dsh-card` re-mount.
|
|
54
|
+
|
|
55
|
+
## Implementation
|
|
56
|
+
|
|
57
|
+
<details>
|
|
58
|
+
<summary>Internal structure (expand)</summary>
|
|
59
|
+
|
|
60
|
+
- `src/index.ts` (node half) — registers the `inline-html-card` and `3d-artifact` skills (`skills` service optional; stays pending when absent).
|
|
61
|
+
- `src/client/` (browser half, `/plugins/inline-html-render/client.js`):
|
|
62
|
+
|
|
63
|
+
| Module | Responsibility |
|
|
64
|
+
| --- | --- |
|
|
65
|
+
| `renderer.ts` | A `MutationObserver` watches the DOM; for each `.md-code-block` whose info string is `dsh-card` (an explicit `.infostring` or the banner's leading text), reads the `<pre>` text (the raw HTML), inserts an iframe beside the block, hides the block; never mounts while streaming (`data-streaming` present), re-scans once it settles. |
|
|
66
|
+
| `srcdoc.ts` | Wraps the card HTML into a full document carrying the strict CSP + `dshBridge` (`srcdoc`). |
|
|
67
|
+
| `bridge.ts` | Host-side validation of iframe messages (source window + whitelist + arg shape), answering `openLink/copy/download`. |
|
|
68
|
+
|
|
69
|
+
**Why a sibling instead of replacing the node**: React owns the markdown tree, so a bare `removeChild` would make React's reconciliation fight the DOM (potentially throwing or resurrecting the block). So this plugin never removes a React node — it inserts the iframe as a sibling **after** the block, hides the block with `style.display='none'` (a style React does not manage), and re-scans with a MutationObserver on every change. When React replaces a node (rebuilding it on content change), the new block is caught and hidden again.
|
|
70
|
+
|
|
71
|
+
**Streaming alignment**: while a turn runs, `AssistantMarkdown` sets `data-streaming` on its root and the block content is partial and re-rendered. The plugin only mounts when there is no `[data-streaming]` ancestor, so a card never renders on a half-streamed body. When the turn ends, React re-renders, removes `data-streaming`, and rebuilds the block; the observer swaps then.
|
|
72
|
+
|
|
73
|
+
**For plugin authors** — the card protocol is pull-based (a skill), not pushed to every session. Just write self-contained HTML inside ` ```dsh-card ````.
|
|
74
|
+
|
|
75
|
+
</details>
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT
|
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# @khorsheed/dsh-inline-html-render
|
|
2
|
+
|
|
3
|
+
[English](README.en.md) | 中文
|
|
4
|
+
|
|
5
|
+
让 agent 直接在对话正文里渲染**可交互的 HTML 卡片**的插件:agent 写一个 ` ```dsh-card ```` fenced block,插件把这一段在消息流中间替换成**沙箱 iframe** 运行其 HTML——真正的内联、可交互,而不是 ASCII 图或一句描述。
|
|
6
|
+
|
|
7
|
+
```dsh-card
|
|
8
|
+
<div style="font:14px system-ui;padding:16px;background:#1e1e1e;border-radius:10px;color:#eee">
|
|
9
|
+
<div style="font-weight:600;margin-bottom:8px">设计 token 对照</div>
|
|
10
|
+
<div style="display:flex;gap:8px">
|
|
11
|
+
<span style="background:#4f8cff;color:#fff;padding:4px 10px;border-radius:6px">primary</span>
|
|
12
|
+
<span style="background:#2ea043;color:#fff;padding:4px 10px;border-radius:6px">success</span>
|
|
13
|
+
<span style="background:#d29922;color:#fff;padding:4px 10px;border-radius:6px">warning</span>
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
上面这段在会话里不是一个代码块,而是一张渲染出来的卡片。
|
|
19
|
+
|
|
20
|
+
## 特性
|
|
21
|
+
|
|
22
|
+
- **内联在段落中间**——卡片就长在 agent 写它的位置,与文字自然衔接。
|
|
23
|
+
- **真正可交互**——卡片在 `sandbox="allow-scripts"` 的 iframe 里,支持任意 DOM/CSS/JS 交互(hover、点击、tab 切换、内联 SVG/canvas 动图)。
|
|
24
|
+
- **零侵入官方**——只改写官方**已经渲染出来的 DOM**(在渲染后的 `.md-code-block` 旁插入 iframe 并隐藏原块),不改一行官方源码。没有本插件时,` ```dsh-card ```` 退化成普通代码块,零崩溃。
|
|
25
|
+
- **严格沙箱**——注入 CSP(`connect-src 'none'`),卡片脚本无法联网、无法访问宿主。仅开放 `window.dshBridge.openLink / copy / download` 三个受控能力。
|
|
26
|
+
|
|
27
|
+
## 安装
|
|
28
|
+
|
|
29
|
+
不在默认 web bundle 中;一条命令完成安装并挂载(通过 `dsh.bundle` 自挂载):
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
dsh plugin --profile <p> add @khorsheed/dsh-inline-html-render # 安装
|
|
33
|
+
dsh plugin --profile <p> remove @khorsheed/dsh-inline-html-render # 卸载
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## 给 agent 的写法
|
|
37
|
+
|
|
38
|
+
host 半边注册了同属 HTML 作者协议的 `inline-html-card` 与 `3d-artifact` 两个 skill(provider 均为 `inline-html-render`)。只要用户想要**看得见的结果**(比如 "show me / 预览 / 做个卡片看看 / 对比几个视觉效果 / 想要个能点击的小组件",而不是读一段文字),agent 就会套用这个协议。核心约束(详见 skill):
|
|
39
|
+
|
|
40
|
+
- 恰好一个 fenced block,info string 严格为 `dsh-card`;**用三个反引号 ```,不要用四个反引号嵌套**(嵌套会被解析成外层代码块,卡片不渲染)。
|
|
41
|
+
- 内容自包含:CSS/JS 内联,图片用 `data:` URI,零外部网络。
|
|
42
|
+
- 不要访问宿主——用 `window.dshBridge` 做外链/复制/下载。
|
|
43
|
+
|
|
44
|
+
## Compatibility
|
|
45
|
+
|
|
46
|
+
- npm 发布线(`@deepseek-ai/dsh@0.1.2-rc.1`):✅ 完整——本插件只消费标准 DOM + 客户端运行时 `ClientContext`,`ui-conversation` 渲染出的 `.md-code-block` / banner infostring 显示位 / `data-streaming` 结构在 0.1.2-rc.1 上无变化;minHost 前移至 0.1.2-rc.1,旧宿主请停留在旧发布线。
|
|
47
|
+
- 源码线(deepseek-harness master):✅(verifiedHost: 0.1.2-rc.1)
|
|
48
|
+
|
|
49
|
+
## 已知限制
|
|
50
|
+
|
|
51
|
+
- **依赖官方渲染 DOM 细节**——渲染器靠 `.md-code-block` 包裹及其 banner 里 info string 的**前导文本** `dsh-card`(本版本官方 CodeBlock 用 hash banner 包裹,没有稳定的 `.infostring` 类;also 兼容显式 `.infostring` 的元素)和 `AssistantMarkdown` 的 `data-streaming` 定位并对齐流式结束。这是仓库接受的 last-resort DOM-anchor 模式(与 message-tools 的 dom-hider 同型)。官方若改动 wrapper 类名、banner 文本形状或 info string 位置,需要用 MutationObserver 的降级路径复核。
|
|
52
|
+
- **沙箱无网络**——卡片脚本不能 fetch/外链资源(白名单 CDN 也不放行,比 file-preview 的 Tier1 更严)。需要联网的画面请用 file-preview 的 HTML 渲染链路,而非本插件。
|
|
53
|
+
- **iframe 高度自适应**——卡片渲染后按其内容高度自动撑开;极少数动态撑高的场景可能需手动触发 `data-dsh-card` 重新挂载。
|
|
54
|
+
|
|
55
|
+
## 实现原理
|
|
56
|
+
|
|
57
|
+
<details>
|
|
58
|
+
<summary>内部结构(点击展开)</summary>
|
|
59
|
+
|
|
60
|
+
- `src/index.ts`(node 半边)——注册 `inline-html-card` 与 `3d-artifact` skill(`skills` 服务可选,缺失时保持 pending)。
|
|
61
|
+
- `src/client/`(浏览器半边,`/plugins/inline-html-render/client.js`):
|
|
62
|
+
|
|
63
|
+
| 模块 | 职责 |
|
|
64
|
+
| --- | --- |
|
|
65
|
+
| `renderer.ts` | `MutationObserver` 监控 DOM;对每个 info string 为 `dsh-card` 的 `.md-code-block`(显式 `.infostring` 或 banner 前导文本),读取 `<pre>` 文本(原始 HTML),在块旁插入 iframe、隐藏原块;流式未结束(`data-streaming` 存在)时不挂载,结束后重扫。 |
|
|
66
|
+
| `srcdoc.ts` | 把卡片 HTML 包成带严格 CSP + `dshBridge` 的完整文档(`srcdoc`)。 |
|
|
67
|
+
| `bridge.ts` | 宿主侧校验 iframe 消息(来源窗口 + 白名单 + 参数形状),响应 `openLink/copy/download`。 |
|
|
68
|
+
|
|
69
|
+
**为什么插 sibling 而不是替换节点**:React 拥有 markdown 树,直接 `removeChild` 会让 React 的 reconciliation 与 DOM 打架(可能抛错或让原块复活)。所以本插件**从不删 React 的节点**——把 iframe 作为兄弟节点插入该块之后,再把块 `style.display='none'`(一个 React 不管的样式),并用 MutationObserver 在每次变更后重扫。React 替换节点时(内容变化重建节点),新的块同样被捕获、再次隐藏。
|
|
70
|
+
|
|
71
|
+
**流式对齐**:回合运行时,`AssistantMarkdown` 在根上设 `data-streaming`,块内容是部分且会被重渲染。插件只在无 `data-streaming` 祖先时挂载,杜绝在半截内容上渲染。回合结束 React 重渲染时会移除 `data-streaming` 并重建块,observer 此时才真正替换。
|
|
72
|
+
|
|
73
|
+
**给插件作者**——卡片协议是拉取式(skill),不推给每个会话。你只需照 ` ```dsh-card ```` 写自包含 HTML。
|
|
74
|
+
|
|
75
|
+
</details>
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# inline-html-render bundle patch: inserts the plugin row into the composing
|
|
2
|
+
# profile. The client half is discovered through the package.json dsh.client
|
|
3
|
+
# block; this row activates the host half (the inline-html-card skill
|
|
4
|
+
# registration). The `name` value must stay quoted — `@` is a YAML-reserved
|
|
5
|
+
# indicator and an unquoted scalar fails the parse at boot.
|
|
6
|
+
- insert:
|
|
7
|
+
- id: inline-html-render
|
|
8
|
+
name: '@khorsheed/dsh-inline-html-render'
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "@khorsheed/dsh-inline-html-render",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
//#region src/client/srcdoc.ts
|
|
8
|
+
/**
|
|
9
|
+
* Sandboxed srcDoc builder for inline HTML cards.
|
|
10
|
+
*
|
|
11
|
+
* The parent web shell has no Content-Security-Policy of its own and an
|
|
12
|
+
* iframe's `srcdoc` is not served as an HTTP response, so CSP inheritance
|
|
13
|
+
* cannot be relied on — the policy must be embedded IN the document. This
|
|
14
|
+
* helper guarantees exactly that for both shapes of input:
|
|
15
|
+
*
|
|
16
|
+
* - a bare fragment (no `<html>`/`<head>`) is wrapped into a full document
|
|
17
|
+
* whose head carries the CSP and the capability bridge;
|
|
18
|
+
* - a full document keeps its own structure and gets the CSP meta injected
|
|
19
|
+
* right after its `<head …>` tag (skipped when it already declares one,
|
|
20
|
+
* e.g. a skill-compliant output that embeds the same policy).
|
|
21
|
+
*
|
|
22
|
+
* The sandbox is `allow-scripts`, NEVER `allow-same-origin` — the opaque
|
|
23
|
+
* origin is what keeps the frame's scripts from touching the host, and the
|
|
24
|
+
* CSP is the network/capability boundary. This is deliberately stricter than
|
|
25
|
+
* file-preview's Tier1 (no whitelisted CDNs): an inline card should never
|
|
26
|
+
* reach the network at all.
|
|
27
|
+
* @module @khorsheed/dsh-inline-html-render
|
|
28
|
+
*/
|
|
29
|
+
/** Strict sandbox CSP: no network, no navigation, scripts only inline. */
|
|
30
|
+
const CARD_CSP = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none'; worker-src blob:; object-src 'none'; base-uri 'none'; form-action 'none'";
|
|
31
|
+
/** The capability bridge client, injected into the document. Exposes a tiny
|
|
32
|
+
* `window.dshBridge` (openLink / copy / download) backed by postMessage; the
|
|
33
|
+
* host validates every call. It also reports the document's content height on
|
|
34
|
+
* load (and on resize) so the parent can size the cross-origin iframe — the
|
|
35
|
+
* parent cannot read `contentDocument` on an opaque-origin frame, so the height
|
|
36
|
+
* must be posted from inside. No network and no host access exist outside
|
|
37
|
+
* these messages. */
|
|
38
|
+
const BRIDGE_SCRIPT = `<script id="dsh-inline-bridge">
|
|
39
|
+
(function () {
|
|
40
|
+
var seq = 0
|
|
41
|
+
function call (fn, args, target) {
|
|
42
|
+
return new Promise(function (resolve, reject) {
|
|
43
|
+
var id = 'dshb' + (++seq)
|
|
44
|
+
function on (e) {
|
|
45
|
+
if (e.data && e.data.id === id) {
|
|
46
|
+
removeEventListener('message', on)
|
|
47
|
+
if (e.data.ok) resolve(e.data.result)
|
|
48
|
+
else reject(new Error(e.data.error || 'bridge call failed'))
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
addEventListener('message', on)
|
|
52
|
+
parent.postMessage({ id: id, fn: fn, args: args }, '*')
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
window.dshBridge = {
|
|
56
|
+
openLink: function (url) { return call('openLink', [url]) },
|
|
57
|
+
copy: function (text) { return call('copy', [text]) },
|
|
58
|
+
download: function (name, dataUrl) { return call('download', [name, dataUrl]) }
|
|
59
|
+
}
|
|
60
|
+
function report () {
|
|
61
|
+
// documentElement.scrollHeight reflects the viewport height, so a short
|
|
62
|
+
// card leaves a blank gap below. document.body.scrollHeight is the actual
|
|
63
|
+
// content height the card occupies. Use it, with documentElement only as a
|
|
64
|
+
// fallback for a body that holds no laid-out content of its own.
|
|
65
|
+
var bodyH = document.body ? document.body.scrollHeight : 0
|
|
66
|
+
var h = bodyH
|
|
67
|
+
if (h <= 0 && document.documentElement) h = document.documentElement.scrollHeight || 0
|
|
68
|
+
if (h > 0) parent.postMessage({ fn: 'resize', args: [h] }, '*')
|
|
69
|
+
}
|
|
70
|
+
if (document.readyState === 'complete') report()
|
|
71
|
+
else addEventListener('load', report)
|
|
72
|
+
addEventListener('resize', report)
|
|
73
|
+
})()
|
|
74
|
+
<\/script>`;
|
|
75
|
+
/** The CSP meta tag. */
|
|
76
|
+
const CSP_META = `<meta http-equiv="Content-Security-Policy" content="${CARD_CSP}">`;
|
|
77
|
+
const HEAD_OPEN = /<head[^>]*>/i;
|
|
78
|
+
const HAS_CSP = /http-equiv=["']Content-Security-Policy/i;
|
|
79
|
+
/**
|
|
80
|
+
* Wrap or annotate an untrusted HTML document so it runs inside the sandbox CSP.
|
|
81
|
+
* @param content - the card HTML, as authored inside the fenced block.
|
|
82
|
+
* @returns a complete document carrying the strict CSP and the bridge.
|
|
83
|
+
*/
|
|
84
|
+
function buildCardSrcDoc(content) {
|
|
85
|
+
const headExtras = BRIDGE_SCRIPT;
|
|
86
|
+
const head = HEAD_OPEN.exec(content);
|
|
87
|
+
if (head === null) return `<!doctype html><html lang="zh"><head><meta charset="utf-8">${CSP_META}${headExtras}</head><body>${content}</body></html>`;
|
|
88
|
+
const headInjection = (HAS_CSP.test(content) ? "" : CSP_META) + headExtras;
|
|
89
|
+
return content.slice(0, head.index + head[0].length) + headInjection + content.slice(head.index + head[0].length);
|
|
90
|
+
}
|
|
91
|
+
//#endregion
|
|
92
|
+
//#region src/client/bridge.ts
|
|
93
|
+
/** The default link route: a new window, https only enforced by the caller. */
|
|
94
|
+
function openLinkInWindow(url) {
|
|
95
|
+
window.open(url, "_blank", "noopener,noreferrer");
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Each handler runs with validated args and may throw; errors become an error reply.
|
|
99
|
+
* @param capabilities - the install's wired capabilities (openLink falls back to a new window).
|
|
100
|
+
*/
|
|
101
|
+
function handlersFor(capabilities) {
|
|
102
|
+
return {
|
|
103
|
+
openLink: (args) => {
|
|
104
|
+
const url = String(args[0] ?? "");
|
|
105
|
+
if (!/^https:/i.test(url)) throw new Error(`openLink: only https: targets are allowed (got ${url.slice(0, 40)})`);
|
|
106
|
+
(capabilities.openLink ?? openLinkInWindow)(url);
|
|
107
|
+
},
|
|
108
|
+
copy: async (args) => {
|
|
109
|
+
const text = String(args[0] ?? "");
|
|
110
|
+
if (text.length > 1e6) throw new Error("copy: text too large");
|
|
111
|
+
try {
|
|
112
|
+
await navigator.clipboard.writeText(text);
|
|
113
|
+
} catch {
|
|
114
|
+
fallbackCopy(text);
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
download: (args) => {
|
|
118
|
+
const name = String(args[0] ?? "download");
|
|
119
|
+
const dataUrl = String(args[1] ?? "");
|
|
120
|
+
if (!/^data:/i.test(dataUrl)) throw new Error("download: only data: URLs are allowed");
|
|
121
|
+
const anchor = document.createElement("a");
|
|
122
|
+
anchor.href = dataUrl;
|
|
123
|
+
anchor.download = name;
|
|
124
|
+
anchor.click();
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** execCommand copy fallback for contexts without the async clipboard API. */
|
|
129
|
+
function fallbackCopy(text) {
|
|
130
|
+
const area = document.createElement("textarea");
|
|
131
|
+
area.value = text;
|
|
132
|
+
area.setAttribute("readonly", "");
|
|
133
|
+
area.style.position = "fixed";
|
|
134
|
+
area.style.opacity = "0";
|
|
135
|
+
document.body.appendChild(area);
|
|
136
|
+
area.select();
|
|
137
|
+
try {
|
|
138
|
+
document.execCommand("copy");
|
|
139
|
+
} finally {
|
|
140
|
+
area.remove();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function reply(frame, message) {
|
|
144
|
+
frame.contentWindow?.postMessage(message, "*");
|
|
145
|
+
}
|
|
146
|
+
/** Cap on the reported height, guarding against a runaway or adversarial card. */
|
|
147
|
+
const MAX_FRAME_HEIGHT = 2e4;
|
|
148
|
+
/**
|
|
149
|
+
* Listen for bridge/height messages from one controlled iframe.
|
|
150
|
+
* @param frame - the card iframe; only its window's messages are honored.
|
|
151
|
+
* @param capabilities - wired host capabilities; absence keeps browser defaults.
|
|
152
|
+
* @returns a disposer removing the listener.
|
|
153
|
+
*/
|
|
154
|
+
function attachBridge(frame, capabilities = {}) {
|
|
155
|
+
const handlers = handlersFor(capabilities);
|
|
156
|
+
const onMessage = (event) => {
|
|
157
|
+
if (event.source !== frame.contentWindow) return;
|
|
158
|
+
const data = event.data;
|
|
159
|
+
if (data === null || typeof data !== "object") return;
|
|
160
|
+
const id = data.id;
|
|
161
|
+
const fn = data.fn;
|
|
162
|
+
if (fn === "resize") {
|
|
163
|
+
const args = Array.isArray(data.args) ? data.args : [];
|
|
164
|
+
const height = Number(args[0] ?? 0);
|
|
165
|
+
if (Number.isFinite(height) && height > 0) frame.style.height = `${Math.min(Math.ceil(height) + 1, MAX_FRAME_HEIGHT)}px`;
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (typeof id !== "string" || typeof fn !== "string") return;
|
|
169
|
+
const handler = handlers[fn];
|
|
170
|
+
if (handler === void 0) {
|
|
171
|
+
reply(frame, {
|
|
172
|
+
id,
|
|
173
|
+
ok: false,
|
|
174
|
+
error: `unknown bridge function: ${fn}`
|
|
175
|
+
});
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const args = Array.isArray(data.args) ? data.args : [];
|
|
179
|
+
Promise.resolve().then(() => handler(args)).then(() => reply(frame, {
|
|
180
|
+
id,
|
|
181
|
+
ok: true,
|
|
182
|
+
result: void 0
|
|
183
|
+
}), (error) => reply(frame, {
|
|
184
|
+
id,
|
|
185
|
+
ok: false,
|
|
186
|
+
error: error instanceof Error ? error.message : String(error)
|
|
187
|
+
}));
|
|
188
|
+
};
|
|
189
|
+
addEventListener("message", onMessage);
|
|
190
|
+
return () => removeEventListener("message", onMessage);
|
|
191
|
+
}
|
|
192
|
+
//#endregion
|
|
193
|
+
//#region src/client/renderer.ts
|
|
194
|
+
/**
|
|
195
|
+
* Inline card renderer: finds the rendered `.md-code-block` whose infostring is
|
|
196
|
+
* `dsh-card` and replaces its visual slot with a sandboxed iframe running the
|
|
197
|
+
* authored HTML, inline in the conversation flow.
|
|
198
|
+
*
|
|
199
|
+
* React owns the markdown tree, so this plugin NEVER removes a node React
|
|
200
|
+
* created — that would make React's reconciliation fight the DOM and could
|
|
201
|
+
* throw or resurrect the block. Instead it inserts the iframe as a SIBLING
|
|
202
|
+
* right after the block and hides the block (`display:none`, a style React
|
|
203
|
+
* does not manage), then re-applies on any mutation. Because React replaces
|
|
204
|
+
* nodes (it does not reuse them across a re-render of changed content), each
|
|
205
|
+
* replacement is detected fresh and re-hidden; the data marker on the *block*
|
|
206
|
+
* keeps one block from being processed twice in a single pass.
|
|
207
|
+
*
|
|
208
|
+
* Streaming gate: while a turn is running, `AssistantMarkdown` sets
|
|
209
|
+
* `data-streaming` on its root and the block content is partial and will be
|
|
210
|
+
* re-rendered. We only swap settled blocks (no `[data-streaming]` ancestor),
|
|
211
|
+
* so a card never mounts on a half-streamed body. On settle, React re-renders
|
|
212
|
+
* the markdown, which removes `data-streaming` and re-creates the block; the
|
|
213
|
+
* observer sees that and swaps then.
|
|
214
|
+
*
|
|
215
|
+
* @module @khorsheed/dsh-inline-html-render
|
|
216
|
+
*/
|
|
217
|
+
/** Info string that marks a fence as an inline card. */
|
|
218
|
+
const CARD_INFO_STRING = "dsh-card";
|
|
219
|
+
/** Marker set on the block once it has been swapped, making a pass idempotent. */
|
|
220
|
+
const PROCESSED_ATTR = "data-dsh-card-processed";
|
|
221
|
+
/** The injected iframe's class, for cleanup. */
|
|
222
|
+
const FRAME_CLASS = "dsh-inline-card-frame";
|
|
223
|
+
/** Attribute used to find the bridge-ready frame and its disposer. */
|
|
224
|
+
const BRIDGE_ATTR = "data-dsh-bridge";
|
|
225
|
+
/** True when one `.md-code-block` was authored with the `dsh-card` info string.
|
|
226
|
+
*
|
|
227
|
+
* The official `CodeBlock` renders the fence info string in the banner, but the
|
|
228
|
+
* DOM shape is not a stable contract: some builds render a dedicated
|
|
229
|
+
* `.infostring` element, while this release flattens the banner wrap
|
|
230
|
+
* (`_bannerWrap_…`) whose leading text is `<info>复制` (info string first, then
|
|
231
|
+
* the localized copy label), with no `.infostring`. So we match on the block's
|
|
232
|
+
* leading text: the info string is always rendered before the code body, and a
|
|
233
|
+
* normal code block cannot begin with `dsh-card` unless it was authored that
|
|
234
|
+
* way. Prefer the explicit `.infostring` element when present, then fall back
|
|
235
|
+
* to the text prefix.
|
|
236
|
+
*/
|
|
237
|
+
function isCardInfoBlock(block) {
|
|
238
|
+
const explicit = block.querySelector(".infostring");
|
|
239
|
+
if (explicit !== null) return explicit.textContent?.trim() === CARD_INFO_STRING;
|
|
240
|
+
return block.textContent.trimStart().startsWith(CARD_INFO_STRING);
|
|
241
|
+
}
|
|
242
|
+
/** Find every rendered `dsh-card` block under `root`. */
|
|
243
|
+
function findCardBlocks(root) {
|
|
244
|
+
const out = [];
|
|
245
|
+
for (const node of root.querySelectorAll(".md-code-block")) if (isCardInfoBlock(node)) out.push(node);
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
/** True when the block (or an ancestor) is inside a still-streaming message. */
|
|
249
|
+
function isStreaming(node) {
|
|
250
|
+
return node.closest("[data-streaming]") !== null;
|
|
251
|
+
}
|
|
252
|
+
/** Extract the authored HTML from a card block's `<pre>` text body. */
|
|
253
|
+
function readCardHtml(block) {
|
|
254
|
+
return (block.querySelector("pre")?.textContent ?? "").replace(/\n$/, "");
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Render one card block: read the HTML, build a sandboxed srcDoc, insert the
|
|
258
|
+
* iframe after the block, hide the block, and attach the capability bridge.
|
|
259
|
+
* @param block - the rendered `dsh-card` block.
|
|
260
|
+
* @param capabilities - wired host capabilities handed to the bridge.
|
|
261
|
+
* @returns a {@link MountedCard} or null when the block had no content.
|
|
262
|
+
*/
|
|
263
|
+
function mountCard(block, capabilities) {
|
|
264
|
+
const html = readCardHtml(block);
|
|
265
|
+
if (html.trim() === "") return null;
|
|
266
|
+
const frame = document.createElement("iframe");
|
|
267
|
+
frame.className = FRAME_CLASS;
|
|
268
|
+
frame.setAttribute("sandbox", "allow-scripts");
|
|
269
|
+
frame.setAttribute("title", "inline card");
|
|
270
|
+
frame.srcdoc = buildCardSrcDoc(html);
|
|
271
|
+
frame.style.display = "block";
|
|
272
|
+
frame.style.width = "100%";
|
|
273
|
+
frame.style.height = "auto";
|
|
274
|
+
frame.style.border = "none";
|
|
275
|
+
block.parentNode?.insertBefore(frame, block.nextSibling);
|
|
276
|
+
block.style.display = "none";
|
|
277
|
+
block.setAttribute(PROCESSED_ATTR, "");
|
|
278
|
+
const disposeBridge = attachBridge(frame, capabilities);
|
|
279
|
+
frame.setAttribute(BRIDGE_ATTR, "");
|
|
280
|
+
const dispose = () => {
|
|
281
|
+
disposeBridge();
|
|
282
|
+
frame.remove();
|
|
283
|
+
block.removeAttribute(PROCESSED_ATTR);
|
|
284
|
+
};
|
|
285
|
+
return {
|
|
286
|
+
block,
|
|
287
|
+
frame,
|
|
288
|
+
dispose
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Drop frames whose owning block is gone. React may replace a processed block
|
|
293
|
+
* with a fresh node (a re-render of the settled markdown); the old block's
|
|
294
|
+
* frame then has no matching block before it and would sit orphaned. A frame
|
|
295
|
+
* is owned by the immediately-preceding `.md-code-block` carrying the
|
|
296
|
+
* processed marker that paired with it — so a frame not preceded by exactly
|
|
297
|
+
* that is stale. Reactive re-scanning keeps the two in lockstep.
|
|
298
|
+
*/
|
|
299
|
+
function collectCardFrames(root) {
|
|
300
|
+
return [...root.querySelectorAll(`.${FRAME_CLASS}`)];
|
|
301
|
+
}
|
|
302
|
+
/** Remove every card frame whose predecessor block is no longer its partner. */
|
|
303
|
+
function dropOrphanFrames(root, mounted) {
|
|
304
|
+
for (const frame of collectCardFrames(root)) {
|
|
305
|
+
const prev = frame.previousElementSibling;
|
|
306
|
+
if (prev instanceof HTMLElement && prev.classList.contains("md-code-block") && prev.hasAttribute(PROCESSED_ATTR)) continue;
|
|
307
|
+
frame.remove();
|
|
308
|
+
for (const card of mounted) if (card.frame === frame) {
|
|
309
|
+
mounted.delete(card);
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/** Reconcile: swap every unprocessed, settled `dsh-card` block under `root`, then drop orphans. */
|
|
315
|
+
function reconcile(root, capabilities = {}) {
|
|
316
|
+
const mounted = [];
|
|
317
|
+
for (const block of findCardBlocks(root)) {
|
|
318
|
+
if (block.hasAttribute(PROCESSED_ATTR)) continue;
|
|
319
|
+
if (isStreaming(block)) continue;
|
|
320
|
+
const card = mountCard(block, capabilities);
|
|
321
|
+
if (card !== null) mounted.push(card);
|
|
322
|
+
}
|
|
323
|
+
return mounted;
|
|
324
|
+
}
|
|
325
|
+
const CHAT_ROOT = "[data-conversation-scroll]";
|
|
326
|
+
/**
|
|
327
|
+
* Install the inline-card renderer over the document.
|
|
328
|
+
* @param capabilities - wired host capabilities handed to every card's bridge.
|
|
329
|
+
* @returns a {@link CardRenderer} whose `dispose` tears everything down.
|
|
330
|
+
*/
|
|
331
|
+
function installCardRenderer(capabilities = {}) {
|
|
332
|
+
const nextFrame = typeof requestAnimationFrame === "function" ? requestAnimationFrame : (cb) => {
|
|
333
|
+
setTimeout(cb, 0);
|
|
334
|
+
};
|
|
335
|
+
const cancelFrame = typeof cancelAnimationFrame === "function" ? cancelAnimationFrame : (id) => {
|
|
336
|
+
clearTimeout(id);
|
|
337
|
+
};
|
|
338
|
+
const mounted = /* @__PURE__ */ new Set();
|
|
339
|
+
const run = () => {
|
|
340
|
+
const root = document.querySelector(CHAT_ROOT) ?? document.body;
|
|
341
|
+
for (const card of reconcile(root, capabilities)) mounted.add(card);
|
|
342
|
+
dropOrphanFrames(root, mounted);
|
|
343
|
+
};
|
|
344
|
+
let raf = 0;
|
|
345
|
+
const schedule = () => {
|
|
346
|
+
if (raf !== 0) return;
|
|
347
|
+
raf = nextFrame(() => {
|
|
348
|
+
raf = 0;
|
|
349
|
+
run();
|
|
350
|
+
});
|
|
351
|
+
};
|
|
352
|
+
const observer = new MutationObserver(schedule);
|
|
353
|
+
observer.observe(document.body, {
|
|
354
|
+
childList: true,
|
|
355
|
+
subtree: true,
|
|
356
|
+
attributes: true,
|
|
357
|
+
attributeFilter: ["data-streaming"]
|
|
358
|
+
});
|
|
359
|
+
schedule();
|
|
360
|
+
return {
|
|
361
|
+
run,
|
|
362
|
+
dispose: () => {
|
|
363
|
+
if (raf !== 0) {
|
|
364
|
+
cancelFrame(raf);
|
|
365
|
+
raf = 0;
|
|
366
|
+
}
|
|
367
|
+
observer.disconnect();
|
|
368
|
+
for (const card of mounted) card.dispose();
|
|
369
|
+
mounted.clear();
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
//#endregion
|
|
374
|
+
//#region src/client/index.ts
|
|
375
|
+
/** The client half requires no service: every host capability is probed, never injected. */
|
|
376
|
+
const inject = [];
|
|
377
|
+
/**
|
|
378
|
+
* The card-link route: host 0.1.6-alpha.2's ui-sidebar-browser registers the
|
|
379
|
+
* `browser` right-Sidebar tab kind, so a card link opens as a Sidebar tab
|
|
380
|
+
* there; any older host or missing piece (service, type, or a registry race
|
|
381
|
+
* on open) falls back to a new window. Probed per open, so a hot-added tab
|
|
382
|
+
* type counts and a hot-removed one just falls back.
|
|
383
|
+
* @param ctx - client root context.
|
|
384
|
+
* @returns the bridge's openLink capability.
|
|
385
|
+
*/
|
|
386
|
+
function createLinkOpener(ctx) {
|
|
387
|
+
return (url) => {
|
|
388
|
+
const tabs = ctx.get("sidebarRightTabs");
|
|
389
|
+
const sidebarRight = ctx.get("sidebarRight");
|
|
390
|
+
if (tabs?.get("browser") !== void 0 && sidebarRight !== void 0) try {
|
|
391
|
+
sidebarRight.openTab("browser", { params: { url } });
|
|
392
|
+
return;
|
|
393
|
+
} catch {}
|
|
394
|
+
window.open(url, "_blank", "noopener,noreferrer");
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Plugin body: install the card renderer for the browser lifetime.
|
|
399
|
+
* @param ctx - client root context (probed for the optional Sidebar Browser route).
|
|
400
|
+
* @returns a disposer tearing the renderer down.
|
|
401
|
+
*/
|
|
402
|
+
function apply(ctx) {
|
|
403
|
+
let renderer;
|
|
404
|
+
const install = () => {
|
|
405
|
+
renderer = installCardRenderer({ openLink: createLinkOpener(ctx) });
|
|
406
|
+
};
|
|
407
|
+
install();
|
|
408
|
+
return () => {
|
|
409
|
+
renderer?.dispose();
|
|
410
|
+
renderer = void 0;
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
//#endregion
|
|
414
|
+
exports.apply = apply;
|
|
415
|
+
exports.createLinkOpener = createLinkOpener;
|
|
416
|
+
exports.inject = inject;
|
|
417
|
+
return module.exports;
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
//# sourceMappingURL=client.js.map
|