diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e6c062..beb629f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [0.8.1] - 2026-08-14 +### 修复 +- **Safari 围栏全部静默丢失(issue #1)**:Safari 宿主渲染消息行时不带 `data-chat-anchor-key`(该属性是 React key 派生值,key 为 undefined 时 React 直接不渲染该属性;Chrome 同页 14 个代码块全有锚点、Safari 0 个)→ DOM 通道 `rowOf` 落空 → 每个 `dsh-ui` 围栏在静默 return 点被放弃,控制台零报错。修复:行解析降级链 `[data-chat-anchor-key]` → `[data-chat-flow-key]/[data-chat-flow-kind]`(宿主同一行 div 上的路由属性,kind 与 key 相互独立、可幸存)→ 代码块自身(身份降级为 `dom:unknown:<序数>`,`contextOf` 的 `?? 'unknown'` 分支本就存在);`fenceIndexOf` 在无行兜底时改按全文档已落定 dsh-ui 块的序数计数,同行兄弟围栏不会撞同一个 `dom:unknown:N`;`anchorSeqOf` 文档序兜底改用联合选择器(锚点行 + flow 行),无锚点行仍得单调 seq 估计。**所有静默 return 点加一次性 `console.warn`(`[dsh-genui]` 前缀,WeakSet 每块一次,1s sweep 不刷屏)**:无锚点降级、落定空体、落定不可修复体各一条诊断 +### 测试 +- 258 → 263(+5 DOM 通道 Safari 回归:无锚点行渲染/无行直接挂 body/同无锚点行兄弟围栏身份不折叠(面板 fold 以第二个 replace 为准)/无锚点一次告警且锚点行跨 sweep 静默/落定坏体一次告警);360 全绿 + ## [0.8.0] - 2026-08-13 ### 发布 - **OSS 开源首发**:DSH 正式开源后 `dsh-genui` 随生态开放——仓库迁移至 `omdsh-dev` 组织并公开(topics 保持 `dsh`、`dsh-plugin`);包名定为 **`@omdsh-dev/dsh-genui`**(与 GitHub 组织 `omdsh-dev` 对齐;`@deepseek-ai` 为官方 scope,第三方插件不再占用),移除 `private: true`,准备公开发布 diff --git a/lib/client.js b/lib/client.js index 8b0686f..96169b4 100644 --- a/lib/client.js +++ b/lib/client.js @@ -2,4 +2,4 @@ window.__ModuleLoader__.load({id:`@omdsh-dev/dsh-genui`,factory:e=>{var t={expor `||e[r]===`\r`);)r++;let s=r0;)t+=n.pop(),a++;if(a===0)return null;try{return JSON.parse(t),{text:t,repairs:a}}catch{return null}}let Cn={margin:`0 0 6px`,padding:`6px 10px`,borderRadius:6,background:`rgba(239, 68, 68, 0.14)`,border:`1px solid rgba(239, 68, 68, 0.4)`,color:`#f87171`,fontSize:12,lineHeight:1.55,whiteSpace:`pre-wrap`};function wn({raw:e,fenceKey:t}){let n=(0,m.useRef)(null),[r,i]=(0,m.useState)(!1);(0,m.useLayoutEffect)(()=>{let e=n.current;e!==null&&e.closest(`[data-streaming]`)===null&&i(!0)});let a=r&&e.trim()!==``?bn(e):null;return(0,h.jsxs)(`div`,{ref:n,children:[a!==null&&(0,h.jsxs)(`div`,{style:Cn,role:`alert`,children:[`⚠️ dsh-ui fence JSON 解析失败`,a,` —— 围栏保持为代码块;请让模型检查并修复 JSON 后重发。`]}),(0,h.jsx)(p.CodeBlock,{code:`${e}\n`,lang:`dsh-ui`},t)]})}function Tn({sessionId:e,sourceId:t,order:n,spec:r}){return(0,m.useEffect)(()=>{cn(e,{sourceId:t,order:n,mode:r.append===!0?`append`:`replace`,spec:r})===`overflow`&&un(e,t)},[e,t,n,r]),null}function En(e,t){let n=G(e),r=n===null?null:R(n);if(r===null){let n=xn(e);if(n!==null){let e=G(n.text);r=e===null?null:R(e)}if(r===null&&t?.source!==void 0){let t=Sn(e);if(t!==null){let e=G(t.text);r=e===null?null:R(e)}}}return r}function Dn(e,t,n){let r=t?.sessionId;return(0,h.jsx)(b,{label:`该界面`,children:(0,h.jsx)(Jt,{spec:n,stateKey:r===void 0?void 0:ie(r,t?.source?.id??String(e),JSON.stringify(n))})},t?.source?.id??e)}function On(e,t,n){let r=En(e,n);return r===null?null:r.panel===!0?n!==void 0&&n.sessionId!==void 0&&n.source!==void 0?r.append===!0&&!yn(e)?(0,h.jsx)(m.Fragment,{},t):(0,h.jsx)(Tn,{sessionId:n.sessionId,sourceId:n.source.id,order:n.source.order,spec:r},t):(0,h.jsx)(m.Fragment,{},t):Dn(t,n,r)}function kn(e,t,n){let r=En(e,n);return r===null?(0,h.jsx)(wn,{fenceKey:t,raw:e},t):r.panel===!0?n!==void 0&&n.sessionId!==void 0&&n.source!==void 0?r.append===!0&&!yn(e)?null:(0,h.jsx)(Tn,{sessionId:n.sessionId,sourceId:n.source.id,order:n.source.order,spec:r},t):null:Dn(t,n,r)}let An=`.md-code-block`,Q=`data-genui-rendered`,jn=`[data-streaming]`;function Mn(e){return e.nodeType===Node.TEXT_NODE}function Nn(e){for(let t of e.querySelectorAll(`div`))if(t.childElementCount===0&&t.textContent===`dsh-ui`)return`dsh-ui`;return null}function Pn(e){for(let t of e.querySelectorAll(`div`))if(t.childElementCount===0)return t.textContent??``;return``}function Fn(e){let t=e.querySelector(`pre`);if(t===null)return``;let n=``;for(let e of t.childNodes)Mn(e),n+=e.textContent??``;return n}function In(e){return e.closest(jn)===null}function Ln(e){return e.closest(`[data-chat-anchor-key]`)}function Rn(e,t){let n=0;for(let r of e.querySelectorAll(An))if(r.closest(jn)===null&&Nn(r)!==null&&(n+=1,r===t))return n;return n+1}function zn(e){let t=e.getAttribute(`data-chat-anchor-key`)??``,n=/(\d+)/.exec(t);if(n!==null){let e=Number(n[1]);if(Number.isFinite(e))return e}let r=document.querySelectorAll(`[data-chat-anchor-key]`);for(let t=0;t`u`)return()=>{};let n=new Map,r=()=>{try{return e.sessions.list.getSnapshot().current}catch{return}};function i(e,t,n){let i=Rn(e,t),a=`dom:${e.getAttribute(`data-chat-anchor-key`)??`unknown`}:${i}`,o=r();return{key:a,context:{...o===void 0?{}:{sessionId:o},...n?{source:{id:a,order:[zn(e),0,i]}}:{}}}}function a(e){let t=n.get(e);t!==void 0&&(n.delete(e),t.root.unmount(),t.container.remove(),e.style.display=``,e.removeAttribute(Q))}function o(e){if(e.hasAttribute(Q))return;let a=Ln(e);if(a===null)return;let o=In(e);if(o&&Nn(e)===null)return;let s=Fn(e);if(s.trim()===``)return;let{key:c,context:l}=i(a,e,o),u=On(s,c,l);if(u===null)return;let d=document.createElement(`div`);d.className=`genui-dom-fence`,e.style.display=`none`,e.after(d),e.setAttribute(Q,``);let f=(0,ee.createRoot)(d);f.render((0,h.jsx)(_.Provider,{value:(e,n)=>{let i=r();i!==void 0&&t(i,e,n)},children:u})),n.set(e,{root:f,container:d,block:e,lastRaw:s,lastSettled:o})}function s(){for(let e of n.values()){let t=e.block;t.isConnected&&(t.style.display!==`none`&&(t.style.display=`none`),t.hasAttribute(Q)||t.setAttribute(Q,``),(e.container.parentElement!==t.parentElement||e.container.previousElementSibling!==t)&&t.after(e.container))}}function c(){for(let[e,o]of n){if(!e.isConnected){a(e);continue}let n=Fn(e),s=In(e);if(s&&!o.lastSettled){let t=Pn(e);if(t!==``&&t!==`dsh-ui`){a(e);continue}}if(o.lastRaw!==n||o.lastSettled!==s){let{key:c,context:l}=i(Ln(e)??e.parentElement??e,e,s),u=On(n,c,l);if(u===null){a(e);continue}o.lastRaw=n,o.lastSettled=s,o.root.render((0,h.jsx)(_.Provider,{value:(e,n)=>{let i=r();i!==void 0&&t(i,e,n)},children:u}))}}s();for(let e of Array.from(document.querySelectorAll(An)))o(e)}let l=!1,u=()=>{l||(l=!0,requestAnimationFrame(()=>{l=!1,c()}))},d=new MutationObserver(()=>{s(),u()});d.observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:[`data-streaming`],characterData:!0});let f=window.setInterval(c,1e3);return c(),()=>{d.disconnect(),window.clearInterval(f);for(let e of Array.from(n.keys()))a(e)}}let Vn={title:`GenUI 面板`,items:[{type:`text`,size:`h3`,content:`GenUI 生成式界面`},{type:`text`,size:`muted`,content:`面板会原地更新:对话里说「更新面板」,或再次执行 /panel 刷新。`},{type:`grid`,cols:4,items:[{type:`stat`,label:`组件`,value:`38`},{type:`stat`,label:`单个`,value:`12`},{type:`stat`,label:`组合`,value:`8`},{type:`stat`,label:`高级`,value:`18`}]},{type:`list`,items:[{title:`单个 ×12`,desc:`text button input select checkbox link badge stat progress divider avatar spacer`},{title:`组合 ×8`,desc:`row col grid card list table chart tabs`},{title:`数据 ×7`,desc:`plot callout steps keyvalue diff json code`},{title:`交互 ×5`,desc:`radio switch textarea accordion copy`},{title:`高级 ×5`,desc:`mermaid scene3d timeline file-tree breadcrumb`},{title:`教学 ×1`,desc:`quiz`}]},{type:`callout`,tone:`info`,title:`更新方式`,content:`对话说「更新面板」→ 模型输出 panel:true 围栏;/panel clear 清空面板。`}]};function $(e,t){let n=t.trim().toLowerCase();if(n===`clear`||n===`off`||n===`close`){ln(e,null);return}ln(e,Vn),gn(e)}function Hn(e,t){return{token:`/panel`,hint:`开启 GenUI 面板;/panel <指令> 让模型定制;/panel clear 清空`,submit:async(n,r)=>{let i=n.trim();return i===``?$(e,``):/^(clear|off|close)$/i.test(i)?$(e,i):($(e,``),t(e,i)),{kind:`success`}}}}function Un(e){return{trigger:`/`,name:`genui`,order:60,candidates:async(e,t)=>[{name:`panel`,description:`开启 GenUI 面板(/panel clear 清空;/panel <指令> 定制内容)`,hint:`/panel`}],onPick(t){return{claim:Hn(t.session.sessionId,e)}},matchEnter:async(t,n,r)=>{if(/^\/panel(?:\s|$)/.test(n.trim()))return{claim:Hn(t.sessionId,e)}}}}function Wn({sessionId:e,sendGenuiAction:t}){let n=(0,m.useSyncExternalStore)(pn,()=>fn(e)),r=(0,m.useSyncExternalStore)(vn,()=>_n(e)),[i,a]=(0,m.useState)(!0),[o,s]=(0,m.useState)(null),[c,l]=(0,m.useState)(!1),u=(0,m.useRef)(null),d=(0,m.useRef)(null);(0,m.useEffect)(()=>{r>0&&a(!1)},[r]),(0,m.useEffect)(()=>()=>{dn(e)},[e]),(0,m.useEffect)(()=>{if(c)return()=>{u.current=null}},[c]);let f=(0,m.useCallback)(e=>{e.preventDefault();let t=e.currentTarget,n=o??360;u.current={y:e.clientY,height:n},l(!0);try{t.setPointerCapture(e.pointerId)}catch{}let r=e=>{let t=u.current;if(t===null)return;let n=Math.min(600,Math.max(120,t.height+(t.y-e.clientY)));s(n)},i=()=>{u.current=null,l(!1),t.removeEventListener(`pointermove`,r),t.removeEventListener(`pointerup`,i),t.removeEventListener(`pointercancel`,i)};t.addEventListener(`pointermove`,r),t.addEventListener(`pointerup`,i),t.addEventListener(`pointercancel`,i)},[o]);return n===null||n.items.length===0?null:(0,h.jsxs)(`div`,{className:S.panel,"data-genui-panel":!0,children:[!i&&(0,h.jsx)(`div`,{role:`separator`,"aria-orientation":`horizontal`,"aria-label":`调整面板高度`,className:`${S.panelResizeHandle}${c?` ${S.panelResizeHandleActive}`:``}`,onPointerDown:f}),(0,h.jsx)(`div`,{className:S.panelHeader,children:(0,h.jsxs)(`button`,{type:`button`,className:S.panelToggle,"aria-expanded":!i,onClick:()=>a(e=>!e),children:[(0,h.jsx)(`span`,{className:S.panelBadge,children:`面板`}),(0,h.jsx)(`span`,{className:S.panelTitle,children:n.title??`GenUI 面板`}),(0,h.jsx)(`span`,{className:S.panelChevron,"aria-hidden":!0,children:i?`▸`:`▾`})]})}),!i&&(0,h.jsx)(`div`,{ref:d,className:S.panelBody,"data-genui-panel-body":!0,style:o===null?void 0:{height:o},children:(0,h.jsx)(_.Provider,{value:t,children:(0,h.jsx)(b,{label:`面板`,children:(0,h.jsx)(Jt,{spec:n,stateKey:ae(e,JSON.stringify(n))})})})})]})}function Gn({toolName:e,block:t,sessionId:n}){let r=`meta`in t?t.meta:void 0,i=(0,m.useMemo)(()=>r===void 0?null:R(r),[r]);return(0,m.useEffect)(()=>{i!==null&&i.items.length>0&&cn(n,{sourceId:JSON.stringify([`render_ui`,t.callId]),order:[`seq`in t&&typeof t.seq==`number`?t.seq:0,-1,0],mode:`replace`,spec:i})},[n,i,t]),i===null||i.items.length===0?(0,h.jsxs)(`div`,{className:S.toolFallback,"data-genui-tool":!0,children:[(0,h.jsx)(`span`,{className:S.toolFallbackTitle,children:e}),(0,h.jsx)(`span`,{className:S.toolFallbackMeta,children:t.callId})]}):(0,h.jsx)(`div`,{className:S.tool,"data-genui-tool":!0,children:(0,h.jsx)(b,{label:`工具卡片`,children:(0,h.jsx)(Jt,{spec:i,stateKey:oe(n,t.callId)})})})}_t();function Kn(){if(!(typeof document>`u`))for(let e of[`mermaid.js`,`three.js`]){if(document.head.querySelector(`link[rel="prefetch"][href="${H(e)}"]`)!==null)continue;let t=document.createElement(`link`);t.rel=`prefetch`,t.as=`script`,t.href=H(e),document.head.appendChild(t)}}function qn(e,t){let n=e.sessions.scope(t)?.get(`conversation`);return{sessionId:t,sendGenuiAction:(e,r)=>{if(n===void 0)return;let i=Object.keys(r).length===0?``:` 组件数据: ${JSON.stringify(r)}`;n.send(`[genui-action] ${e}。用户刚刚在面板中触发了动作 "${e}",请根据组件数据执行相应操作,只输出一个 panel:true 的 dsh-ui 围栏来更新面板,回复文本至多一行 10 字以内的确认(如"已刷新"),不要解释、不要普通围栏。${i}`).catch(n=>{console.warn(`[genui] 面板动作 "${e}" 发送失败(session ${t}):`,n instanceof Error?n.message:String(n))})}}}function Jn(e,t,n){let r=e.sessions.scope(t)?.get(`conversation`);r!==void 0&&r.send(`用户执行了 /panel 并请求:${n}。请只输出一个 panel:true 的 dsh-ui 围栏来更新会话面板,内容按请求定制;回复文本至多一行 10 字以内的确认(如"已更新"),不要解释、不要普通围栏。`).catch(e=>{console.warn(`[genui] /panel 指令发送失败(session ${t}):`,e instanceof Error?e.message:String(e))})}function Yn(e,t,n,r){let i=e.sessions.scope(t)?.get(`conversation`);if(i===void 0)return;let a=Object.keys(r).length===0?``:` 组件数据: ${JSON.stringify(r)}`;i.send(`[genui-action] ${n}。用户刚刚在界面中触发了动作 "${n}",请根据组件数据执行相应操作,并用 dsh-ui 输出更新后的界面。${a}`).catch(()=>{})}function Xn(e){let t=p.registerFenceRenderer,n=typeof t==`function`?[t(`dsh-ui`,kn)]:(console.info(`[genui] fence-registry 扩展点不存在(原版 DSH)——启用 DOM 渲染通道`),[Bn(e,(t,n,r)=>Yn(e,t,n,r))]);Kn(),n.push(e.slots.inject(`tool.call.toolview`,()=>e.slots.register({name:`tool.call.toolview`,key:`render_ui`},Gn))),n.push(e.slots.inject(`conversation.input.dock`,()=>e.slots.register({name:`conversation.input.dock`,id:`genui-panel`,order:50,inject:t=>qn(e,t)},Wn)));let r=e.get(`inputTriggers`);return r===void 0?console.warn(`[genui] inputTriggers service unavailable; /panel command disabled`):n.push(e.effect(()=>r.registerSource(Un((t,n)=>Jn(e,t,n))),`genui: /panel`)),()=>{for(let e of n)e()}}return n.apply=Xn,n.inject=[`slots`,`sessions`,`inputTriggers`],n.prefetchGenuiAssets=Kn,n.renderGenuiFence=kn,t.exports}}); \ No newline at end of file +`||e[t]===`\r`);)t++;let n=t0;)t+=n.pop(),a++;if(a===0)return null;try{return JSON.parse(t),{text:t,repairs:a}}catch{return null}}let Cn={margin:`0 0 6px`,padding:`6px 10px`,borderRadius:6,background:`rgba(239, 68, 68, 0.14)`,border:`1px solid rgba(239, 68, 68, 0.4)`,color:`#f87171`,fontSize:12,lineHeight:1.55,whiteSpace:`pre-wrap`};function wn({raw:e,fenceKey:t}){let n=(0,m.useRef)(null),[r,i]=(0,m.useState)(!1);(0,m.useLayoutEffect)(()=>{let e=n.current;e!==null&&e.closest(`[data-streaming]`)===null&&i(!0)});let a=r&&e.trim()!==``?bn(e):null;return(0,h.jsxs)(`div`,{ref:n,children:[a!==null&&(0,h.jsxs)(`div`,{style:Cn,role:`alert`,children:[`⚠️ dsh-ui fence JSON 解析失败`,a,` —— 围栏保持为代码块;请让模型检查并修复 JSON 后重发。`]}),(0,h.jsx)(p.CodeBlock,{code:`${e}\n`,lang:`dsh-ui`},t)]})}function Tn({sessionId:e,sourceId:t,order:n,spec:r}){return(0,m.useEffect)(()=>{cn(e,{sourceId:t,order:n,mode:r.append===!0?`append`:`replace`,spec:r})===`overflow`&&un(e,t)},[e,t,n,r]),null}function En(e,t){let n=G(e),r=n===null?null:R(n);if(r===null){let n=xn(e);if(n!==null){let e=G(n.text);r=e===null?null:R(e)}if(r===null&&t?.source!==void 0){let t=Sn(e);if(t!==null){let e=G(t.text);r=e===null?null:R(e)}}}return r}function Dn(e,t,n){let r=t?.sessionId;return(0,h.jsx)(b,{label:`该界面`,children:(0,h.jsx)(Jt,{spec:n,stateKey:r===void 0?void 0:ie(r,t?.source?.id??String(e),JSON.stringify(n))})},t?.source?.id??e)}function On(e,t,n){let r=En(e,n);return r===null?null:r.panel===!0?n!==void 0&&n.sessionId!==void 0&&n.source!==void 0?r.append===!0&&!yn(e)?(0,h.jsx)(m.Fragment,{},t):(0,h.jsx)(Tn,{sessionId:n.sessionId,sourceId:n.source.id,order:n.source.order,spec:r},t):(0,h.jsx)(m.Fragment,{},t):Dn(t,n,r)}function kn(e,t,n){let r=En(e,n);return r===null?(0,h.jsx)(wn,{fenceKey:t,raw:e},t):r.panel===!0?n!==void 0&&n.sessionId!==void 0&&n.source!==void 0?r.append===!0&&!yn(e)?null:(0,h.jsx)(Tn,{sessionId:n.sessionId,sourceId:n.source.id,order:n.source.order,spec:r},t):null:Dn(t,n,r)}let An=`.md-code-block`,Q=`data-genui-rendered`,jn=`[data-streaming]`;function Mn(e){return e.nodeType===Node.TEXT_NODE}function Nn(e){for(let t of e.querySelectorAll(`div`))if(t.childElementCount===0&&t.textContent===`dsh-ui`)return`dsh-ui`;return null}function Pn(e){for(let t of e.querySelectorAll(`div`))if(t.childElementCount===0)return t.textContent??``;return``}function Fn(e){let t=e.querySelector(`pre`);if(t===null)return``;let n=``;for(let e of t.childNodes)Mn(e),n+=e.textContent??``;return n}function In(e){return e.closest(jn)===null}let Ln=`[data-chat-flow-key], [data-chat-flow-kind]`;function Rn(e){return e.closest(`[data-chat-anchor-key]`)??e.closest(Ln)??e}function zn(e,t){if(e===t){let e=0;for(let n of document.querySelectorAll(An))if(n.closest(jn)===null&&Nn(n)!==null&&(e+=1,n===t))return e;return e+1}let n=0;for(let r of e.querySelectorAll(An))if(r.closest(jn)===null&&Nn(r)!==null&&(n+=1,r===t))return n;return n+1}function Bn(e){let t=e.getAttribute(`data-chat-anchor-key`)??``,n=/(\d+)/.exec(t);if(n!==null){let e=Number(n[1]);if(Number.isFinite(e))return e}let r=document.querySelectorAll(`[data-chat-anchor-key], ${Ln}`);for(let t=0;t`u`)return()=>{};let n=new Map,r=()=>{try{return e.sessions.list.getSnapshot().current}catch{return}};function i(e,t,n){n&&e.getAttribute(`data-chat-anchor-key`)===null&&s(t,`no [data-chat-anchor-key] ancestor for a dsh-ui fence (host render path without row anchor — e.g. Safari); using fallback identity dom:unknown:N`);let i=zn(e,t),a=`dom:${e.getAttribute(`data-chat-anchor-key`)??`unknown`}:${i}`,o=r();return{key:a,context:{...o===void 0?{}:{sessionId:o},...n?{source:{id:a,order:[Bn(e),0,i]}}:{}}}}function a(e){let t=n.get(e);t!==void 0&&(n.delete(e),t.root.unmount(),t.container.remove(),e.style.display=``,e.removeAttribute(Q))}let o=new WeakSet;function s(e,t){o.has(e)||(o.add(e),console.warn(`[dsh-genui] ${t}`))}function c(e){if(e.hasAttribute(Q))return;let a=Rn(e),o=In(e);if(o&&Nn(e)===null)return;let c=Fn(e);if(c.trim()===``){o&&s(e,`settled dsh-ui fence has an empty body; keeping the code block`);return}let{key:l,context:u}=i(a,e,o),d=On(c,l,u);if(d===null){o&&s(e,`settled dsh-ui fence body does not parse; keeping the code block`);return}let f=document.createElement(`div`);f.className=`genui-dom-fence`,e.style.display=`none`,e.after(f),e.setAttribute(Q,``);let p=(0,ee.createRoot)(f);p.render((0,h.jsx)(_.Provider,{value:(e,n)=>{let i=r();i!==void 0&&t(i,e,n)},children:d})),n.set(e,{root:p,container:f,block:e,lastRaw:c,lastSettled:o})}function l(){for(let e of n.values()){let t=e.block;t.isConnected&&(t.style.display!==`none`&&(t.style.display=`none`),t.hasAttribute(Q)||t.setAttribute(Q,``),(e.container.parentElement!==t.parentElement||e.container.previousElementSibling!==t)&&t.after(e.container))}}function u(){for(let[e,o]of n){if(!e.isConnected){a(e);continue}let n=Fn(e),s=In(e);if(s&&!o.lastSettled){let t=Pn(e);if(t!==``&&t!==`dsh-ui`){a(e);continue}}if(o.lastRaw!==n||o.lastSettled!==s){let{key:c,context:l}=i(Rn(e),e,s),u=On(n,c,l);if(u===null){a(e);continue}o.lastRaw=n,o.lastSettled=s,o.root.render((0,h.jsx)(_.Provider,{value:(e,n)=>{let i=r();i!==void 0&&t(i,e,n)},children:u}))}}l();for(let e of Array.from(document.querySelectorAll(An)))c(e)}let d=!1,f=()=>{d||(d=!0,requestAnimationFrame(()=>{d=!1,u()}))},p=new MutationObserver(()=>{l(),f()});p.observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:[`data-streaming`],characterData:!0});let m=window.setInterval(u,1e3);return u(),()=>{p.disconnect(),window.clearInterval(m);for(let e of Array.from(n.keys()))a(e)}}let Hn={title:`GenUI 面板`,items:[{type:`text`,size:`h3`,content:`GenUI 生成式界面`},{type:`text`,size:`muted`,content:`面板会原地更新:对话里说「更新面板」,或再次执行 /panel 刷新。`},{type:`grid`,cols:4,items:[{type:`stat`,label:`组件`,value:`38`},{type:`stat`,label:`单个`,value:`12`},{type:`stat`,label:`组合`,value:`8`},{type:`stat`,label:`高级`,value:`18`}]},{type:`list`,items:[{title:`单个 ×12`,desc:`text button input select checkbox link badge stat progress divider avatar spacer`},{title:`组合 ×8`,desc:`row col grid card list table chart tabs`},{title:`数据 ×7`,desc:`plot callout steps keyvalue diff json code`},{title:`交互 ×5`,desc:`radio switch textarea accordion copy`},{title:`高级 ×5`,desc:`mermaid scene3d timeline file-tree breadcrumb`},{title:`教学 ×1`,desc:`quiz`}]},{type:`callout`,tone:`info`,title:`更新方式`,content:`对话说「更新面板」→ 模型输出 panel:true 围栏;/panel clear 清空面板。`}]};function $(e,t){let n=t.trim().toLowerCase();if(n===`clear`||n===`off`||n===`close`){ln(e,null);return}ln(e,Hn),gn(e)}function Un(e,t){return{token:`/panel`,hint:`开启 GenUI 面板;/panel <指令> 让模型定制;/panel clear 清空`,submit:async(n,r)=>{let i=n.trim();return i===``?$(e,``):/^(clear|off|close)$/i.test(i)?$(e,i):($(e,``),t(e,i)),{kind:`success`}}}}function Wn(e){return{trigger:`/`,name:`genui`,order:60,candidates:async(e,t)=>[{name:`panel`,description:`开启 GenUI 面板(/panel clear 清空;/panel <指令> 定制内容)`,hint:`/panel`}],onPick(t){return{claim:Un(t.session.sessionId,e)}},matchEnter:async(t,n,r)=>{if(/^\/panel(?:\s|$)/.test(n.trim()))return{claim:Un(t.sessionId,e)}}}}function Gn({sessionId:e,sendGenuiAction:t}){let n=(0,m.useSyncExternalStore)(pn,()=>fn(e)),r=(0,m.useSyncExternalStore)(vn,()=>_n(e)),[i,a]=(0,m.useState)(!0),[o,s]=(0,m.useState)(null),[c,l]=(0,m.useState)(!1),u=(0,m.useRef)(null),d=(0,m.useRef)(null);(0,m.useEffect)(()=>{r>0&&a(!1)},[r]),(0,m.useEffect)(()=>()=>{dn(e)},[e]),(0,m.useEffect)(()=>{if(c)return()=>{u.current=null}},[c]);let f=(0,m.useCallback)(e=>{e.preventDefault();let t=e.currentTarget,n=o??360;u.current={y:e.clientY,height:n},l(!0);try{t.setPointerCapture(e.pointerId)}catch{}let r=e=>{let t=u.current;if(t===null)return;let n=Math.min(600,Math.max(120,t.height+(t.y-e.clientY)));s(n)},i=()=>{u.current=null,l(!1),t.removeEventListener(`pointermove`,r),t.removeEventListener(`pointerup`,i),t.removeEventListener(`pointercancel`,i)};t.addEventListener(`pointermove`,r),t.addEventListener(`pointerup`,i),t.addEventListener(`pointercancel`,i)},[o]);return n===null||n.items.length===0?null:(0,h.jsxs)(`div`,{className:S.panel,"data-genui-panel":!0,children:[!i&&(0,h.jsx)(`div`,{role:`separator`,"aria-orientation":`horizontal`,"aria-label":`调整面板高度`,className:`${S.panelResizeHandle}${c?` ${S.panelResizeHandleActive}`:``}`,onPointerDown:f}),(0,h.jsx)(`div`,{className:S.panelHeader,children:(0,h.jsxs)(`button`,{type:`button`,className:S.panelToggle,"aria-expanded":!i,onClick:()=>a(e=>!e),children:[(0,h.jsx)(`span`,{className:S.panelBadge,children:`面板`}),(0,h.jsx)(`span`,{className:S.panelTitle,children:n.title??`GenUI 面板`}),(0,h.jsx)(`span`,{className:S.panelChevron,"aria-hidden":!0,children:i?`▸`:`▾`})]})}),!i&&(0,h.jsx)(`div`,{ref:d,className:S.panelBody,"data-genui-panel-body":!0,style:o===null?void 0:{height:o},children:(0,h.jsx)(_.Provider,{value:t,children:(0,h.jsx)(b,{label:`面板`,children:(0,h.jsx)(Jt,{spec:n,stateKey:ae(e,JSON.stringify(n))})})})})]})}function Kn({toolName:e,block:t,sessionId:n}){let r=`meta`in t?t.meta:void 0,i=(0,m.useMemo)(()=>r===void 0?null:R(r),[r]);return(0,m.useEffect)(()=>{i!==null&&i.items.length>0&&cn(n,{sourceId:JSON.stringify([`render_ui`,t.callId]),order:[`seq`in t&&typeof t.seq==`number`?t.seq:0,-1,0],mode:`replace`,spec:i})},[n,i,t]),i===null||i.items.length===0?(0,h.jsxs)(`div`,{className:S.toolFallback,"data-genui-tool":!0,children:[(0,h.jsx)(`span`,{className:S.toolFallbackTitle,children:e}),(0,h.jsx)(`span`,{className:S.toolFallbackMeta,children:t.callId})]}):(0,h.jsx)(`div`,{className:S.tool,"data-genui-tool":!0,children:(0,h.jsx)(b,{label:`工具卡片`,children:(0,h.jsx)(Jt,{spec:i,stateKey:oe(n,t.callId)})})})}_t();function qn(){if(!(typeof document>`u`))for(let e of[`mermaid.js`,`three.js`]){if(document.head.querySelector(`link[rel="prefetch"][href="${H(e)}"]`)!==null)continue;let t=document.createElement(`link`);t.rel=`prefetch`,t.as=`script`,t.href=H(e),document.head.appendChild(t)}}function Jn(e,t){let n=e.sessions.scope(t)?.get(`conversation`);return{sessionId:t,sendGenuiAction:(e,r)=>{if(n===void 0)return;let i=Object.keys(r).length===0?``:` 组件数据: ${JSON.stringify(r)}`;n.send(`[genui-action] ${e}。用户刚刚在面板中触发了动作 "${e}",请根据组件数据执行相应操作,只输出一个 panel:true 的 dsh-ui 围栏来更新面板,回复文本至多一行 10 字以内的确认(如"已刷新"),不要解释、不要普通围栏。${i}`).catch(n=>{console.warn(`[genui] 面板动作 "${e}" 发送失败(session ${t}):`,n instanceof Error?n.message:String(n))})}}}function Yn(e,t,n){let r=e.sessions.scope(t)?.get(`conversation`);r!==void 0&&r.send(`用户执行了 /panel 并请求:${n}。请只输出一个 panel:true 的 dsh-ui 围栏来更新会话面板,内容按请求定制;回复文本至多一行 10 字以内的确认(如"已更新"),不要解释、不要普通围栏。`).catch(e=>{console.warn(`[genui] /panel 指令发送失败(session ${t}):`,e instanceof Error?e.message:String(e))})}function Xn(e,t,n,r){let i=e.sessions.scope(t)?.get(`conversation`);if(i===void 0)return;let a=Object.keys(r).length===0?``:` 组件数据: ${JSON.stringify(r)}`;i.send(`[genui-action] ${n}。用户刚刚在界面中触发了动作 "${n}",请根据组件数据执行相应操作,并用 dsh-ui 输出更新后的界面。${a}`).catch(()=>{})}function Zn(e){let t=p.registerFenceRenderer,n=typeof t==`function`?[t(`dsh-ui`,kn)]:(console.info(`[genui] fence-registry 扩展点不存在(原版 DSH)——启用 DOM 渲染通道`),[Vn(e,(t,n,r)=>Xn(e,t,n,r))]);qn(),n.push(e.slots.inject(`tool.call.toolview`,()=>e.slots.register({name:`tool.call.toolview`,key:`render_ui`},Kn))),n.push(e.slots.inject(`conversation.input.dock`,()=>e.slots.register({name:`conversation.input.dock`,id:`genui-panel`,order:50,inject:t=>Jn(e,t)},Gn)));let r=e.get(`inputTriggers`);return r===void 0?console.warn(`[genui] inputTriggers service unavailable; /panel command disabled`):n.push(e.effect(()=>r.registerSource(Wn((t,n)=>Yn(e,t,n))),`genui: /panel`)),()=>{for(let e of n)e()}}return n.apply=Zn,n.inject=[`slots`,`sessions`,`inputTriggers`],n.prefetchGenuiAssets=qn,n.renderGenuiFence=kn,t.exports}}); \ No newline at end of file diff --git a/package.json b/package.json index f7527e4..a8cc965 100644 --- a/package.json +++ b/package.json @@ -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.0", + "version": "0.8.1", "type": "module", "main": "lib/index.js", "types": "lib/types/plugin/index.d.ts", diff --git a/src/client/dom-fence.tsx b/src/client/dom-fence.tsx index ba1253a..8a93e5a 100644 --- a/src/client/dom-fence.tsx +++ b/src/client/dom-fence.tsx @@ -102,15 +102,44 @@ function isSettled(block: Element): boolean { return block.closest(STREAMING) === null } -/** The owning conversation row (stable per-message identity). */ -function rowOf(block: Element): Element | null { - return block.closest('[data-chat-anchor-key]') +/** + * The owning conversation row (stable per-message identity). + * + * The host renders `data-chat-anchor-key` from a React key that is OMITTED + * when the routed node's key is undefined — observed on Safari (and any + * fallback render path), where every fence row lacks the attribute while + * Chrome's identical page has it. Fences must not silently die there, so the + * lookup walks down a fallback chain and never gives up: + * + * 1. `[data-chat-anchor-key]` — the canonical stable row anchor; + * 2. `[data-chat-flow-key]` / `[data-chat-flow-kind]` — the same row div + * rendered by the host (both carry the routing key/kind, and the kind is + * a separate value that survives an undefined React key); + * 3. the code block itself — last resort; identity degrades to + * `dom:unknown:` (see `fenceIndexOf`/`contextOf`). + */ +const FLOW_ROW = '[data-chat-flow-key], [data-chat-flow-kind]' +function rowOf(block: Element): Element { + return block.closest('[data-chat-anchor-key]') ?? block.closest(FLOW_ROW) ?? block } /** 1-based ordinal of this block among the row's settled dsh-ui blocks * (document order). Streaming candidates are skipped, so the ordinal stays - * stable while the block itself is still streaming. */ + * stable while the block itself is still streaming. When the fallback chain + * bottoms out at the block itself (no owning row in the DOM at all), the + * 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 + } let index = 0 for (const candidate of row.querySelectorAll(CODE_BLOCK)) { if (candidate.closest(STREAMING) !== null) continue @@ -122,7 +151,9 @@ function fenceIndexOf(row: Element, block: Element): number { } /** messageSeq estimate: the numeric part of the anchor key when present, - * else the row's document-order index among chat rows (monotonic in seq). */ + * else the row's document-order index among chat rows (monotonic in seq). + * The document-order fallback counts every host flow row — anchored or not — + * so anchor-less (Safari) rows still get a monotonic seq estimate. */ function anchorSeqOf(row: Element): number { const key = row.getAttribute('data-chat-anchor-key') ?? '' const match = /(\d+)/.exec(key) @@ -130,7 +161,7 @@ function anchorSeqOf(row: Element): number { const value = Number(match[1]) if (Number.isFinite(value)) return value } - const rows = document.querySelectorAll('[data-chat-anchor-key]') + const rows = document.querySelectorAll(`[data-chat-anchor-key], ${FLOW_ROW}`) for (let i = 0; i < rows.length; i += 1) { if (rows[i] === row) return i } @@ -164,6 +195,13 @@ export function installDomFenceRenderer( * only once settled — streaming renders are identity-less (no panel * publish, no durable state), mirroring the registry channel. */ function contextOf(row: Element, block: Element, settled: boolean): { key: Key; context: GenuiFenceContext } { + if (settled && row.getAttribute('data-chat-anchor-key') === null) { + // Safari / fallback render path: the host omitted the row anchor (the + // attribute is a React key that React drops when undefined). Fences + // still render with the degraded `dom:unknown:N` identity — warn once + // per block so the degraded path is visible in the console. + warnOnce(block, 'no [data-chat-anchor-key] ancestor for a dsh-ui fence (host render path without row anchor — e.g. Safari); using fallback identity dom:unknown:N') + } const fenceIndex = fenceIndexOf(row, block) const anchorKey = row.getAttribute('data-chat-anchor-key') ?? 'unknown' const key = `dom:${anchorKey}:${fenceIndex}` as Key @@ -185,10 +223,18 @@ export function installDomFenceRenderer( block.removeAttribute(PROCESSED) } + /** One-time-per-block diagnostics: silent returns must be diagnosable + * (the 1s sweep would otherwise spam the console every pass). */ + const warned = new WeakSet() + function warnOnce(block: Element, message: string): void { + if (warned.has(block)) return + warned.add(block) + console.warn(`[dsh-genui] ${message}`) + } + function renderBlock(block: HTMLElement): void { if (block.hasAttribute(PROCESSED)) return const row = rowOf(block) - if (row === null) return const settled = isSettled(block) // Settled blocks must carry the dsh-ui label. Streaming blocks cannot: // the host renders the language label only once the reply settles @@ -198,12 +244,20 @@ export function installDomFenceRenderer( // happens to parse) is reverted at the settle transition below. if (settled && infostringOf(block) === null) return const raw = rawOf(block) - if (raw.trim() === '') return + if (raw.trim() === '') { + if (settled) warnOnce(block, 'settled dsh-ui fence has an empty body; keeping the code block') + return + } const { key, context } = contextOf(row, block, settled) const node: ReactNode | null = renderResolvedFenceNode(raw, key, context) // Null = no finished component yet (streaming half) or unrepairable: - // the stock code block stays visible until something renders. - if (node === null) return + // the stock code block stays visible until something renders. A settled + // unrepairable body warns once (the DOM channel has no visible + // diagnostic of its own — the stock block keeps the raw content). + if (node === null) { + if (settled) warnOnce(block, 'settled dsh-ui fence body does not parse; keeping the code block') + return + } const container = document.createElement('div') container.className = CONTAINER_CLASS block.style.display = 'none' @@ -261,8 +315,7 @@ export function installDomFenceRenderer( } } if (mount.lastRaw !== raw || mount.lastSettled !== settled) { - const row = rowOf(block) - const anchor = row ?? block.parentElement ?? block + const anchor = rowOf(block) const { key, context } = contextOf(anchor, block, settled) const node = renderResolvedFenceNode(raw, key, context) if (node === null) { diff --git a/tests/dom-fence.spec.tsx b/tests/dom-fence.spec.tsx index c8d154a..d64e598 100644 --- a/tests/dom-fence.spec.tsx +++ b/tests/dom-fence.spec.tsx @@ -323,3 +323,111 @@ describe('installDomFenceRenderer', () => { } }) }) + +describe('anchor-less rows (Safari fallback render path)', () => { + // 回归钉 #1: Safari 宿主渲染消息行时省略 data-chat-anchor-key(该属性是 + // React key 派生值,key 为 undefined 时 React 直接不渲染属性)→ rowOf 落空 + // → DOM 通道静默放弃所有围栏。降级链必须兜住:flow 行属性 → 代码块自身。 + it('renders a settled dsh-ui fence when the row lacks data-chat-anchor-key', async () => { + const row = document.createElement('div') + row.setAttribute('data-chat-flow-kind', 'assistant-step') + const block = stockCodeBlock(VALID_SPEC, 'dsh-ui') + row.appendChild(block) + document.body.appendChild(row) + const send = vi.fn() + const dispose = installDomFenceRenderer(makeCtx('sess-safari-1', send), send) + try { + await tick() + expect(block.hasAttribute('data-genui-rendered')).toBe(true) + expect(block.style.display).toBe('none') + expect(row.querySelector('.genui-dom-fence')?.textContent).toContain('你好,世界') + } finally { + dispose() + } + }) + + it('renders a fence with no owning row at all (block directly in the body)', async () => { + const block = stockCodeBlock(VALID_SPEC, 'dsh-ui') + document.body.appendChild(block) + const send = vi.fn() + const dispose = installDomFenceRenderer(makeCtx('sess-safari-2', send), send) + try { + await tick() + expect(block.hasAttribute('data-genui-rendered')).toBe(true) + expect(block.style.display).toBe('none') + expect(document.querySelector('.genui-dom-fence')?.textContent).toContain('你好,世界') + } finally { + dispose() + } + }) + + it('assigns distinct fallback identities to sibling fences in an anchor-less row', async () => { + // 两个 panel:true 围栏在同一无锚点行内不得折叠成同一个 dom:unknown:N + // source:后一个 fence 的 replace 应赢得 fold(证明是两个不同 source), + // 而不是被当作第一个的幂等重放丢弃(那样快照会停在「面板A」)。 + const row = document.createElement('div') + row.setAttribute('data-chat-flow-kind', 'assistant-step') + const first = stockCodeBlock('{"panel":true,"title":"面板A","items":[{"type":"text","content":"A"}]}', 'dsh-ui') + const second = stockCodeBlock('{"panel":true,"title":"面板B","items":[{"type":"text","content":"B"}]}', 'dsh-ui') + row.appendChild(first) + row.appendChild(second) + document.body.appendChild(row) + const send = vi.fn() + const dispose = installDomFenceRenderer(makeCtx('sess-safari-3', send), send) + try { + await tick() + expect(getPanelSpec('sess-safari-3')?.title).toBe('面板B') + } finally { + dispose() + } + }) + + it('warns once when the row anchor is missing, and stays silent for anchored rows', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const anchored = assistantRow('s15') + const anchoredBlock = stockCodeBlock(VALID_SPEC, 'dsh-ui') + anchored.appendChild(anchoredBlock) + document.body.appendChild(anchored) + const bare = document.createElement('div') + const bareBlock = stockCodeBlock(VALID_SPEC, 'dsh-ui') + bare.appendChild(bareBlock) + document.body.appendChild(bare) + const send = vi.fn() + const dispose = installDomFenceRenderer(makeCtx('sess-safari-4', send), send) + try { + await tick() + await tick() + const calls = warn.mock.calls.filter(([m]) => String(m).includes('[dsh-genui]')) + // 恰好一条诊断:只有无锚点块;锚点块跨多轮 sweep 也不得告警。 + expect(calls).toHaveLength(1) + expect(String(calls[0]![0])).toContain('data-chat-anchor-key') + // 两个围栏都照常渲染(降级不丢内容)。 + expect(anchoredBlock.hasAttribute('data-genui-rendered')).toBe(true) + expect(bareBlock.hasAttribute('data-genui-rendered')).toBe(true) + } finally { + dispose() + warn.mockRestore() + } + }) + + it('warns once for a settled unrepairable body', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const row = assistantRow('s16') + const block = stockCodeBlock(BROKEN_SPEC, 'dsh-ui') + row.appendChild(block) + document.body.appendChild(row) + const send = vi.fn() + const dispose = installDomFenceRenderer(makeCtx('sess-safari-5', send), send) + try { + await tick() + await tick() + const calls = warn.mock.calls.filter(([m]) => String(m).includes('[dsh-genui]')) + expect(calls).toHaveLength(1) + expect(String(calls[0]![0])).toContain('does not parse') + expect(block.hasAttribute('data-genui-rendered')).toBe(false) + } finally { + dispose() + warn.mockRestore() + } + }) +})