Skip to content
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## [0.8.3] - 2026-08-14
### 修复
- **DOM 通道在异形宿主上静默不渲染(issue #6)**:DOM 通道的围栏发现此前依赖单一表面契约——选择器只认 `.md-code-block`,语言标签只认 banner 里的**叶子 `div`**。部分 DSH 0.1.0-rc.6 部署(deepsuite 风格渲染栈)把围栏渲染成 `.code-block` / `.code-block-small`,标签是 `span`,正文还可能被 content div 包裹 → 插件完全找不到围栏:保持代码块、控制台零报错(与报告完全一致)。修复为**多表面发现**:
- 选择器并集 `.md-code-block, .code-block, .code-block-small`(最外层去重,修饰类子元素不会双计);
- **结构兜底**:任何 banner 叶子元素文本恰为 `dsh-ui`(div/span 均可、且必须在 `<pre>` 正文之外——代码体里出现 `dsh-ui` 字面量不得误判)且含 `<pre>` 的元素都会被识别为围栏表面,未知类名的宿主照样渲染;
- **漂移诊断**:结构兜底命中未知类名时一次性 `console.warn`(`[dsh-genui]` 前缀,每次安装一条),「静默失败」不再可能无迹可查;
- 已知类名表面保留完整流式能力(按内容流式接管 + 落定标签复核);未知类名表面在标签出现(落定)后立即渲染,不丢内容。
### 测试
- 268 → 275(+7 多表面回归:`.code-block` 接管(span 标签 + 包裹正文)/`.code-block-small` 接管/未知类名结构兜底 + 漂移告警恰好一次/代码体含 `dsh-ui` 字面量不误判/嵌套修饰类只接管最外层/同行两个异形围栏各自渲染且身份不折叠/异形表面流式接管与落定复核);370 全绿

## [0.8.2] - 2026-08-14
### 修复
- **页面刷新后面板 dock 冻结(issue #4)**:宿主 anchor key 格式为 `<kindlen>:<kind><id>`(assistant step 的 id 是 `<turn>:<step>`,如 `14:assistant-step3:0`)。DOM 通道 `anchorSeqOf` 旧实现取 key 里**第一个数字 = kind 名称长度常量**(所有 assistant step 都是同一个值)→ 面板 store 的持久化重放屏障(刷新后 replayBarrier = 持久化 maxSeenSeq = 该常量)拒绝一切新 panel 围栏:dock 停在旧快照、`[genui-action]` 还活着、控制台零日志;清 `localStorage['dsh.genui.panel']` 恢复但刷新复发(与报告完全一致)。修复:`anchorSeqOf` 改从 assistant-step key 解析 `<turn>:<step>`,seq = `turn*1000+step`(随消息顺序严格单调,刷新后新消息必然大于持久化屏障);非 assistant 行 / 无锚点(Safari)行保留文档序兜底。同类隐患一并修复:`/panel` 本地覆盖的 localBarrier 同样依赖该 seq,此前也会冻结后续更新
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ https://github.com/user-attachments/assets/f5db33ec-7471-4d4a-a85b-79c9962ab4ef
The plugin ships **two rendering channels** and picks one automatically at startup — no dependency on a specific host version:

- **Registry channel**: when the host exposes the `fence-registry` extension point (newer dsh builds), fences register through the host's streaming render pipeline and behave seamlessly with the host;
- **DOM channel**: when the host lacks that extension point (including stock DSH and older builds), the plugin observes the session DOM and mounts its own render tree. Since 0.7.2 it **supports streaming rendering**: components appear as the model writes them — the first finished component shows up immediately, no need to wait for the whole reply.
- **DOM channel**: when the host lacks that extension point (including stock DSH and older builds), the plugin observes the session DOM and mounts its own render tree. Since 0.7.2 it **supports streaming rendering**: components appear as the model writes them — the first finished component shows up immediately, no need to wait for the whole reply. Since 0.8.3 fence discovery is **multi-surface**: it matches the stock `md-code-block` surface, the deepsuite-style `.code-block` / `.code-block-small` surfaces some host builds render instead, and — as a structural backstop — any element whose banner labels it `dsh-ui` and contains a `<pre>` body. If your dsh build renders fences with a different class name, they still render (and a one-time console warning tells you the host DOM drifted).

Whichever channel is active, components, interactions, panels, and persistence behave identically.

Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ https://github.com/user-attachments/assets/f5db33ec-7471-4d4a-a85b-79c9962ab4ef
本插件自带**两套渲染通道**,启动时自动选择,不依赖特定宿主版本:

- **Registry 通道**:宿主提供 `fence-registry` 扩展点(新版 dsh 构建)时,围栏经宿主流式渲染管线注册,行为与宿主无缝;
- **DOM 通道**:宿主没有该扩展点(包括原版 DSH 与旧版构建)时,插件观察会话 DOM 自行挂载渲染树。自 0.7.2 起**支持流式渲染**:模型写到哪渲染到哪,首个完成的组件立即出现,不用等整段回复写完。
- **DOM 通道**:宿主没有该扩展点(包括原版 DSH 与旧版构建)时,插件观察会话 DOM 自行挂载渲染树。自 0.7.2 起**支持流式渲染**:模型写到哪渲染到哪,首个完成的组件立即出现,不用等整段回复写完。自 0.8.3 起围栏发现**多表面兼容**:同时匹配标准 `md-code-block` 表面、部分宿主构建使用的 deepsuite 风格 `.code-block` / `.code-block-small` 表面,并以「label+`<pre>`」结构兜底——任何 banner 标注 `dsh-ui` 且含 `<pre>` 正文的元素都能被识别。即使你的 dsh 构建用了别的类名,围栏照常渲染(控制台会有一条一次性提示说明宿主 DOM 发生漂移)。

无论走哪条通道,组件、交互、面板、持久化行为完全一致。

Expand Down
6 changes: 3 additions & 3 deletions lib/client.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@omdsh-dev/dsh-genui",
"description": "GenUI for DeepSeek Harness: interactive UI components rendered inline in assistant replies via the ```dsh-ui fence — layout, charts, plots, forms, quizzes, mermaid, 3D scenes, and an action event loop back to the model. Ships the fence-teaching host plugin, the browser renderer (client half), and the genui skill.",
"version": "0.8.2",
"version": "0.8.3",
"type": "module",
"main": "lib/index.js",
"types": "lib/types/plugin/index.d.ts",
Expand Down
118 changes: 96 additions & 22 deletions src/client/dom-fence.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@
* finds blocks labelled `dsh-ui`, parses the raw fence body and mounts the
* plugin's own React tree next to the (hidden) stock block:
*
* Fence discovery is **multi-surface** (issue #6): besides `md-code-block`,
* the channel also matches the deepsuite-style surfaces some host builds
* render instead (`.code-block` / `.code-block-small`), and — as the
* structural backstop — ANY element whose banner labels it `dsh-ui` and
* which contains a `<pre>` body. The only invariants are the language label
* (a leaf element with the exact text `dsh-ui`, outside the code body) and
* the `<pre>`, so a host DOM drift degrades to a rendered fence, never a
* silently skipped one:
*
* - **Streaming takeover**: the channel takes over a dsh-ui block as soon as
* ONE finished component parses (the partial parser), and re-renders the
* root as the body grows — the UI assembles top-down while the reply
Expand Down Expand Up @@ -42,8 +51,13 @@ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { GenuiActionContext, type GenuiActionHandler } from './action-context.ts'
import { renderResolvedFenceNode, type GenuiFenceContext } from './fence-render.tsx'

/** The stock code-block surface every markdown fence renders through. */
const CODE_BLOCK = '.md-code-block'
/** Fence surfaces the channel can take over, newest host first: the shared
* CodeBlock surface every rc.6+ markdown fence renders through
* (`.md-code-block`) and the deepsuite-style surfaces some host builds
* render instead (`.code-block` / `.code-block-small`). Surfaces with an
* unlisted class are still found by the structural backstop (label + `<pre>`),
* so this list is an optimization, not a hard contract. */
const CODE_BLOCK_SELECTORS = '.md-code-block, .code-block, .code-block-small'
/** Marker attribute set on blocks this channel has taken over. */
const PROCESSED = 'data-genui-rendered'
/** The settled marker on AssistantMarkdown (absent = settled). */
Expand All @@ -54,6 +68,11 @@ const CONTAINER_CLASS = 'genui-dom-fence'
* belt-and-braces pass (history loads, missed attribute batches). */
const SWEEP_MS = 1000

/** Max ancestors walked from a `<pre>` to its fence surface root (banner +
* pre holder). Most hosts put the pre directly under the surface; some wrap
* it in a content div. */
const SURFACE_HOPS = 4

interface Mount {
root: Root
container: HTMLElement
Expand All @@ -66,21 +85,33 @@ function isTextNode(node: Node): node is Text {
return node.nodeType === Node.TEXT_NODE
}

/** The banner's language label: a childless div whose text is exactly the lang. */
/** The banner's language label: a leaf element whose text is exactly the
* lang. CodeBlock renders the label as a childless div; deepsuite-style
* surfaces use a span; the ONLY structural invariants across hosts are "a
* leaf element holds exactly the lang text" and "it lives outside the code
* body" — a fence whose code literally contains the text `dsh-ui` must not
* self-identify through its body. */
function infostringOf(block: Element): string | null {
// The label div holds nothing but text; the banner wrapper concatenates
// label + copy-button text, so only the leaf div matches exactly.
for (const div of block.querySelectorAll('div')) {
if (div.childElementCount === 0 && div.textContent === 'dsh-ui') return 'dsh-ui'
const pre = block.querySelector('pre')
for (const el of block.querySelectorAll('*')) {
if (el.childElementCount !== 0) continue
if (el.textContent !== 'dsh-ui') continue
if (pre !== null && pre.contains(el)) continue
return 'dsh-ui'
}
return null
}

/** The banner label's raw text (empty while streaming — the host renders the
* language label only once the reply settles). */
* language label only once the reply settles). Returns the first leaf
* outside the code body (banners always lead with the language), so a
* span-label host reads identically to the div-label host. */
function labelTextOf(block: Element): string {
for (const div of block.querySelectorAll('div')) {
if (div.childElementCount === 0) return div.textContent ?? ''
const pre = block.querySelector('pre')
for (const el of block.querySelectorAll('*')) {
if (el.childElementCount !== 0) continue
if (pre !== null && pre.contains(el)) continue
return el.textContent ?? ''
}
return ''
}
Expand All @@ -102,6 +133,57 @@ function isSettled(block: Element): boolean {
return block.closest(STREAMING) === null
}

/** Walk up from a `<pre>` to its fence surface root — the ancestor that
* carries the banner label AND the pre. Returns null when no ancestor within
* `SURFACE_HOPS` (or the scope boundary) labels itself `dsh-ui`. */
function surfaceOf(pre: HTMLElement, scope: ParentNode = document): HTMLElement | null {
let el: HTMLElement | null = pre.parentElement
for (let hops = 0; el !== null && el !== scope && hops < SURFACE_HOPS; hops += 1, el = el.parentElement) {
if (infostringOf(el) === 'dsh-ui') return el
}
return null
}

/**
* Every dsh-ui fence surface under `scope`, outer-most first, deduped.
* Known surface classes first (cheap, ordered), then a structural sweep —
* every `<pre>` whose banner labels it `dsh-ui` — so a host with an
* unlisted surface shape still renders. The label + `<pre>` gates make the
* structural pass false-positive-free: a random code surface without the
* exact `dsh-ui` label is never taken over.
*/
function findFenceCandidates(scope: ParentNode = document): HTMLElement[] {
const seen = new Set<HTMLElement>()
const out: HTMLElement[] = []
for (const el of scope.querySelectorAll<HTMLElement>(CODE_BLOCK_SELECTORS)) {
// Modifier classes can sit inside a surface (e.g. a `code-block-small`
// child of `code-block`): only the outermost matching element is a
// candidate, so a fence is never double-counted or taken over twice.
if (el.parentElement !== null && el.parentElement.closest(CODE_BLOCK_SELECTORS) !== null) continue
if (seen.has(el)) continue
out.push(el)
seen.add(el)
}
for (const pre of scope.querySelectorAll<HTMLElement>('pre')) {
const surface = surfaceOf(pre, scope)
if (surface === null || seen.has(surface)) continue
// Host DOM drift diagnostic: the fence renders (structural backstop),
// but the surface class is unknown to this build — warn once per
// renderer install so future drift is never silent again.
if (!driftWarned) {
driftWarned = true
console.warn('[dsh-genui] 围栏表面类名未被已知选择器命中(宿主 DOM 漂移),已按 label+pre 结构识别 dsh-ui 围栏')
}
out.push(surface)
seen.add(surface)
}
return out
}

/** One-time-per-install drift diagnostic flag (reset per install, so tests
* and hot re-installs each get a fresh warning budget). */
let driftWarned = false

/**
* The owning conversation row (stable per-message identity).
*
Expand Down Expand Up @@ -130,18 +212,9 @@ function rowOf(block: Element): Element {
* ordinal falls back to document order among ALL settled dsh-ui blocks so
* sibling fences never collide on the same `dom:unknown:N` identity. */
function fenceIndexOf(row: Element, block: Element): number {
if (row === block) {
let index = 0
for (const candidate of document.querySelectorAll(CODE_BLOCK)) {
if (candidate.closest(STREAMING) !== null) continue
if (infostringOf(candidate) === null) continue
index += 1
if (candidate === block) return index
}
return index + 1
}
const scope = row === block ? document : row
let index = 0
for (const candidate of row.querySelectorAll(CODE_BLOCK)) {
for (const candidate of findFenceCandidates(scope)) {
if (candidate.closest(STREAMING) !== null) continue
if (infostringOf(candidate) === null) continue
index += 1
Expand Down Expand Up @@ -195,6 +268,7 @@ export function installDomFenceRenderer(
sendAction: (sessionId: SessionId, action: string, payload: Record<string, unknown>) => void,
): () => void {
if (typeof document === 'undefined') return () => {}
driftWarned = false
const mounts = new Map<HTMLElement, Mount>()

const sessionIdOf = (): SessionId | undefined => {
Expand Down Expand Up @@ -345,7 +419,7 @@ export function installDomFenceRenderer(
}
}
repairSurgery()
for (const block of Array.from(document.querySelectorAll<HTMLElement>(CODE_BLOCK))) {
for (const block of findFenceCandidates()) {
renderBlock(block)
}
}
Expand Down
Loading
Loading