From 7209c58f32ab27276ed48a61b7705627352c1dbd Mon Sep 17 00:00:00 2001 From: linshule Date: Sat, 15 Aug 2026 21:26:19 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E5=8E=9F=E7=89=88=20DSH=20=E5=A3=B3?= =?UTF-8?q?=E4=B8=8A=20dsh-ui=20=E5=9B=B4=E6=A0=8F=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E4=B8=8D=E6=B8=B2=E6=9F=93=EF=BC=88inputTriggers=20=E7=A1=AC?= =?UTF-8?q?=E6=B3=A8=E5=85=A5=E9=97=A8=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit client 入口硬注入声明把 inputTriggers 当成激活前置,但 cordis 的 inject 是硬激活门控:原版 DSH 壳没有任何插件提供该服务 → fiber 永久 waiting、 apply() 永不执行 → 渲染器整体未启动,围栏保持代码块、控制台零报错。 apply() 体内早已用 ctx.get('inputTriggers') 可选降级(缺失仅禁用 /panel 并告警),硬注入声明与可选用法自相矛盾。修复:从硬注入列表移除 inputTriggers,保留可选查询路径;带该服务的宿主行为不变。 - src/client/index.tsx: inject 列表 + 原因注释 - tests/dom-fence.spec.tsx: 注入回归钉同步更新 - lib/client.js: 重建产物 - CHANGELOG.md: 变更记录 --- CHANGELOG.md | 6 ++++++ src/client/index.tsx | 19 ++++++++++++++----- tests/dom-fence.spec.tsx | 5 ++++- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5957b3f..3aed673 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] +### 修复 +- **原版 DSH(0.1.0-rc.6)壳上 dsh-ui 围栏全部静默不渲染**:client 入口硬注入声明 `inject: ['slots','sessions','inputTriggers']` 把 `inputTriggers` 当成了激活前置——但 cordis 的 `inject` 是**硬激活门控**:声明的服务永不出现(原版 DSH 壳没有任何插件提供 `inputTriggers` 服务,仅有 vision-toolkit 以 `ctx.inject()` 可选订阅)→ fiber 永久停在 waiting、`apply()` 永不执行 → 渲染器整体未启动:围栏保持代码块、控制台零报错。修复:从硬注入列表移除 `inputTriggers`,`/panel` 改为 `ctx.inject(['inputTriggers'], …)` **可选订阅**(服务与 slots/sessions 由不同 bundle 并发提供,任意到场顺序都能正确注册;缺失时仅不注册 `/panel`,渲染不受影响);带该服务的宿主行为不变,原版壳上 GenUI 恢复渲染 +### 测试 +- 回归钉更新(数量不变):`dom-fence.spec.tsx` 的注入列表断言改为 `['sessions','slots']`——原断言含 `inputTriggers`,与硬激活门控语义冲突(见上条修复说明),注释附原因;jsdom 端到端补充验证:DOM 通道在无 `inputTriggers` 的宿主上发现围栏并渲染 callout/chart + ## [0.8.5] - 2026-08-16 ### 发布 - **发布规范对齐 `plugin_check`(issue #15)**: diff --git a/src/client/index.tsx b/src/client/index.tsx index 6ee1661..b777118 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -164,11 +164,20 @@ export function apply(ctx: Context): () => void { } // Browser services the client entry needs: the slots registry (toolview + -// dock), sessions (scoped conversation send behind actions), and -// inputTriggers (the /panel command source). This declaration is what the -// host's fiber inject waiting uses — without it apply() runs before the -// services bind and the whole plugin tree fails the boot sweep. -export const inject = ['slots', 'sessions', 'inputTriggers'] +// dock) and sessions (scoped conversation send behind actions). This +// declaration is what the host's fiber inject waiting uses — without it +// apply() runs before the services bind and the whole plugin tree fails the +// boot sweep. +// +// inputTriggers (the /panel command source) is deliberately NOT declared: +// cordis `inject` is a hard activation gate — a declared service that is +// never provided (pristine DSH shells ship no inputTriggers provider) parks +// the fiber in waiting forever and apply() never runs, silently killing all +// GenUI rendering. The apply() body already treats it as optional via +// ctx.get('inputTriggers') and falls back to disabling only the /panel +// command, so the optional-lookup pattern (the same one dsh-vision-toolkit +// uses for `slash`/`inputTriggers`) is correct here. +export const inject = ['slots', 'sessions'] // Re-export the registry renderer for the test suite (setup.ts registers it // exactly like apply() does on contract hosts). diff --git a/tests/dom-fence.spec.tsx b/tests/dom-fence.spec.tsx index 51af593..c4d79a7 100644 --- a/tests/dom-fence.spec.tsx +++ b/tests/dom-fence.spec.tsx @@ -82,7 +82,10 @@ describe('installDomFenceRenderer', () => { it('declares its cordis service injects (boot sweep depends on it)', () => { // 回归钉:曾丢失 inject 导出 → 宿主 fiber inject waiting 失效 → // apply 早于 slots 服务运行 → 整页 "Failed to load plugins"。 - expect([...inject].sort()).toEqual(['inputTriggers', 'sessions', 'slots']) + // inputTriggers 刻意不在硬注入列表里:cordis `inject` 是硬激活门控, + // 原版 DSH 壳不提供该服务 → fiber 永久 waiting、apply 永不执行 → + // 全部 dsh-ui 围栏静默保持代码块。apply() 体内已用 ctx.get() 可选降级。 + expect([...inject].sort()).toEqual(['sessions', 'slots']) }) it('renders a settled dsh-ui fence into its own root and hides the stock block', async () => { From a5d4f4dfe7059e83b0d03f8d3245dd0392fc30f4 Mon Sep 17 00:00:00 2001 From: linshule Date: Sun, 16 Aug 2026 07:28:05 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20/panel=20=E6=94=B9=E4=B8=BA=20ctx.in?= =?UTF-8?q?ject=20=E5=8F=AF=E9=80=89=E8=AE=A2=E9=98=85=EF=BC=88=E6=B6=88?= =?UTF-8?q?=E9=99=A4=E6=9C=8D=E5=8A=A1=E5=88=B0=E5=9C=BA=E9=A1=BA=E5=BA=8F?= =?UTF-8?q?=E7=AB=9E=E6=80=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审反馈:slots/sessions 与 inputTriggers 往往由不同 bundle 并发提供, apply() 运行时一次性 ctx.get('inputTriggers') 可能 miss —— 带该服务的 宿主也会静默禁掉 /panel。改用 cordis 可选订阅: ctx.inject(['inputTriggers'], (scope) => { … }) 服务任意到场顺序都能注册 /panel;缺失时仅不注册,渲染不受影响; 订阅 fiber 卸载时自动 dispose,无需塞进外层 disposers。 - src/client/index.tsx: /panel 注册 + 注释更新 - lib/client.js: 重建产物 - CHANGELOG.md: 注入修复条目同步描述订阅方式 --- lib/client.js | 2 +- src/client/index.tsx | 31 ++++++++++++++++++++----------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/lib/client.js b/lib/client.js index 43c6cff..a250d3f 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 Tn={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 En({raw:e,fenceKey:t}){let n=(0,h.useRef)(null),[r,i]=(0,h.useState)(!1);(0,h.useLayoutEffect)(()=>{let e=n.current;e!==null&&e.closest(`[data-streaming]`)===null&&i(!0)});let a=r&&e.trim()!==``?Sn(e):null;return(0,g.jsxs)(`div`,{ref:n,children:[a!==null&&(0,g.jsxs)(`div`,{style:Tn,role:`alert`,children:[`⚠️ dsh-ui fence JSON 解析失败`,a,` —— 围栏保持为代码块;请让模型检查并修复 JSON 后重发。`]}),(0,g.jsx)(p.CodeBlock,{code:`${e}\n`,lang:`dsh-ui`},t)]})}function Dn({sessionId:e,sourceId:t,order:n,spec:r}){return(0,h.useEffect)(()=>{ln(e,{sourceId:t,order:n,mode:r.append===!0?`append`:`replace`,spec:r})===`overflow`&&fn(e,t)},[e,t,n,r]),null}function On(e,t){let n=$t(e),r=n===null?null:R(n);if(r===null){let n=Cn(e);if(n!==null){let e=$t(n.text);r=e===null?null:R(e)}if(r===null&&t?.source!==void 0){let t=wn(e);if(t!==null){let e=$t(t.text);r=e===null?null:R(e)}}}return r}function kn(e,t,n){let r=t?.sessionId;return(0,g.jsx)(x,{label:`该界面`,children:(0,g.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 An(e,t,n){let r=On(e,n);return r===null?null:r.panel===!0?n!==void 0&&n.sessionId!==void 0&&n.source!==void 0?r.append===!0&&!xn(e)?(0,g.jsx)(h.Fragment,{},t):(0,g.jsx)(Dn,{sessionId:n.sessionId,sourceId:n.source.id,order:n.source.order,spec:r},t):(0,g.jsx)(h.Fragment,{},t):kn(t,n,r)}function jn(e,t,n){let r=On(e,n);return r===null?(0,g.jsx)(En,{fenceKey:t,raw:e},t):r.panel===!0?n!==void 0&&n.sessionId!==void 0&&n.source!==void 0?r.append===!0&&!xn(e)?null:(0,g.jsx)(Dn,{sessionId:n.sessionId,sourceId:n.source.id,order:n.source.order,spec:r},t):null:kn(t,n,r)}let Q=`.md-code-block, .code-block, .code-block-small`,$=`data-genui-rendered`,Mn=`[data-streaming]`;function Nn(e){return e.nodeType===Node.TEXT_NODE}function Pn(e){let t=e.querySelector(`pre`);for(let n of e.querySelectorAll(`*`)){if(n.childElementCount!==0||n.textContent!==`dsh-ui`||t!==null&&t.contains(n))continue;let r=n.closest(Q);if(r===null||r===e)return`dsh-ui`}return null}function Fn(e){let t=e.querySelector(`pre`);for(let n of e.querySelectorAll(`*`))if(n.childElementCount===0&&!(t!==null&&t.contains(n)))return n.textContent??``;return``}function In(e){let t=e.querySelector(`pre`);if(t===null)return``;let n=``;for(let e of t.childNodes)Nn(e),n+=e.textContent??``;return n}function Ln(e){return e.closest(Mn)===null}function Rn(e,t=document){let n=e.parentElement;for(let e=0;n!==null&&n!==t&&e<4;e+=1,n=n.parentElement)if(Pn(n)===`dsh-ui`)return n;return null}function zn(e=document){let t=new Set,n=[];for(let r of e.querySelectorAll(Q))(r.parentElement===null||r.parentElement.closest(Q)===null)&&(t.has(r)||(n.push(r),t.add(r)));for(let r of e.querySelectorAll(`pre`)){if(r.closest(Q)!==null)continue;let i=Rn(r,e);i===null||t.has(i)||(Bn||(Bn=!0,console.warn(`[dsh-genui] 围栏表面类名未被已知选择器命中(宿主 DOM 漂移),已按 label+pre 结构识别 dsh-ui 围栏`)),n.push(i),t.add(i))}return n}let Bn=!1,Vn=`[data-chat-flow-key], [data-chat-flow-kind]`;function Hn(e){return e.closest(`[data-chat-anchor-key]`)??e.closest(Vn)??e}function Un(e,t){let n=e===t?document:e,r=0;for(let e of zn(n))if(e.closest(Mn)===null&&Pn(e)!==null&&(r+=1,e===t))return r;return r+1}function Wn(e){let t=e.getAttribute(`data-chat-anchor-key`)??``,n=/assistant-step(\d+):(\d+)$/.exec(t);if(n!==null){let e=Number(n[1]),t=Number(n[2]);if(Number.isFinite(e)&&Number.isFinite(t))return e*1e3+t}let r=document.querySelectorAll(`[data-chat-anchor-key], ${Vn}`);for(let t=0;t`u`)return()=>{};Bn=!1;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=Un(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:[Wn(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($))}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($))return;let a=Hn(e),o=Ln(e);if(o&&Pn(e)===null)return;let c=In(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=An(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($,``);let p=(0,m.createRoot)(f);p.render((0,g.jsx)(v.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($)||t.setAttribute($,``),(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=In(e),s=Ln(e);if(s&&!o.lastSettled){let t=Fn(e);if(t!==``&&t!==`dsh-ui`){a(e);continue}}if(o.lastRaw!==n||o.lastSettled!==s){let{key:c,context:l}=i(Hn(e),e,s),u=An(n,c,l);if(u===null){a(e);continue}o.lastRaw=n,o.lastSettled=s,o.root.render((0,g.jsx)(v.Provider,{value:(e,n)=>{let i=r();i!==void 0&&t(i,e,n)},children:u}))}}l();for(let e of zn())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 h=window.setInterval(u,1e3);return u(),()=>{p.disconnect(),window.clearInterval(h);for(let e of Array.from(n.keys()))a(e)}}let Kn={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 qn(e,t){let n=t.trim().toLowerCase();if(n===`clear`||n===`off`||n===`close`){dn(e,null);return}dn(e,Kn),vn(e)}function Jn(e,t){return{token:`/panel`,hint:`开启 GenUI 面板;/panel <指令> 让模型定制;/panel clear 清空`,submit:async(n,r)=>{let i=n.trim();return i===``?qn(e,``):/^(clear|off|close)$/i.test(i)?qn(e,i):(qn(e,``),t(e,i)),{kind:`success`}}}}function Yn(e){return{trigger:`/`,name:`genui`,order:60,candidates:async(e,t)=>[{name:`panel`,description:`开启 GenUI 面板(/panel clear 清空;/panel <指令> 定制内容)`,hint:`/panel`}],onPick(t){return{claim:Jn(t.session.sessionId,e)}},matchEnter:async(t,n,r)=>{if(/^\/panel(?:\s|$)/.test(n.trim()))return{claim:Jn(t.sessionId,e)}}}}function Xn({sessionId:e,sendGenuiAction:t}){let n=(0,h.useSyncExternalStore)(hn,()=>mn(e)),r=(0,h.useSyncExternalStore)(bn,()=>yn(e)),[i,a]=(0,h.useState)(!0),[o,s]=(0,h.useState)(null),[c,l]=(0,h.useState)(!1),u=(0,h.useRef)(null),d=(0,h.useRef)(null);(0,h.useEffect)(()=>{r>0&&a(!1)},[r]),(0,h.useEffect)(()=>()=>{pn(e)},[e]),(0,h.useEffect)(()=>{if(c)return()=>{u.current=null}},[c]);let f=(0,h.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,g.jsxs)(`div`,{className:C.panel,"data-genui-panel":!0,children:[!i&&(0,g.jsx)(`div`,{role:`separator`,"aria-orientation":`horizontal`,"aria-label":`调整面板高度`,className:`${C.panelResizeHandle}${c?` ${C.panelResizeHandleActive}`:``}`,onPointerDown:f}),(0,g.jsx)(`div`,{className:C.panelHeader,children:(0,g.jsxs)(`button`,{type:`button`,className:C.panelToggle,"aria-expanded":!i,onClick:()=>a(e=>!e),children:[(0,g.jsx)(`span`,{className:C.panelBadge,children:`面板`}),(0,g.jsx)(`span`,{className:C.panelTitle,children:n.title??`GenUI 面板`}),(0,g.jsx)(`span`,{className:C.panelChevron,"aria-hidden":!0,children:i?`▸`:`▾`})]})}),!i&&(0,g.jsx)(`div`,{ref:d,className:C.panelBody,"data-genui-panel-body":!0,style:o===null?void 0:{height:o},children:(0,g.jsx)(v.Provider,{value:t,children:(0,g.jsx)(x,{label:`面板`,children:(0,g.jsx)(Jt,{spec:n,stateKey:ae(e,JSON.stringify(n))})})})})]})}function Zn({toolName:e,block:t,sessionId:n}){let r=`meta`in t?t.meta:void 0,i=(0,h.useMemo)(()=>r===void 0?null:R(r),[r]);return(0,h.useEffect)(()=>{i!==null&&i.items.length>0&&ln(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,g.jsxs)(`div`,{className:C.toolFallback,"data-genui-tool":!0,children:[(0,g.jsx)(`span`,{className:C.toolFallbackTitle,children:e}),(0,g.jsx)(`span`,{className:C.toolFallbackMeta,children:t.callId})]}):(0,g.jsx)(`div`,{className:C.tool,"data-genui-tool":!0,children:(0,g.jsx)(x,{label:`工具卡片`,children:(0,g.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="${U(e)}"]`)!==null)continue;let t=document.createElement(`link`);t.rel=`prefetch`,t.as=`script`,t.href=U(e),document.head.appendChild(t)}}function $n(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 er(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 tr(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 nr(e){let t=p.registerFenceRenderer,n=typeof t==`function`?[t(`dsh-ui`,jn)]:(console.info(`[genui] fence-registry 扩展点不存在(原版 DSH)——启用 DOM 渲染通道`),[Gn(e,(t,n,r)=>tr(e,t,n,r))]);Qn(),n.push(e.slots.inject(`tool.call.toolview`,()=>e.slots.register({name:`tool.call.toolview`,key:`render_ui`},Zn))),n.push(e.slots.inject(`conversation.input.dock`,()=>e.slots.register({name:`conversation.input.dock`,id:`genui-panel`,order:50,inject:t=>$n(e,t)},Xn)));let r=e.get(`inputTriggers`);return r===void 0?console.warn(`[genui] inputTriggers service unavailable; /panel command disabled`):n.push(e.effect(()=>r.registerSource(Yn((t,n)=>er(e,t,n))),`genui: /panel`)),()=>{for(let e of n)e()}}return n.apply=nr,n.inject=[`slots`,`sessions`,`inputTriggers`],n.prefetchGenuiAssets=Qn,n.renderGenuiFence=jn,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 Tn={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 En({raw:e,fenceKey:t}){let n=(0,h.useRef)(null),[r,i]=(0,h.useState)(!1);(0,h.useLayoutEffect)(()=>{let e=n.current;e!==null&&e.closest(`[data-streaming]`)===null&&i(!0)});let a=r&&e.trim()!==``?Sn(e):null;return(0,g.jsxs)(`div`,{ref:n,children:[a!==null&&(0,g.jsxs)(`div`,{style:Tn,role:`alert`,children:[`⚠️ dsh-ui fence JSON 解析失败`,a,` —— 围栏保持为代码块;请让模型检查并修复 JSON 后重发。`]}),(0,g.jsx)(p.CodeBlock,{code:`${e}\n`,lang:`dsh-ui`},t)]})}function Dn({sessionId:e,sourceId:t,order:n,spec:r}){return(0,h.useEffect)(()=>{ln(e,{sourceId:t,order:n,mode:r.append===!0?`append`:`replace`,spec:r})===`overflow`&&fn(e,t)},[e,t,n,r]),null}function On(e,t){let n=$t(e),r=n===null?null:R(n);if(r===null){let n=Cn(e);if(n!==null){let e=$t(n.text);r=e===null?null:R(e)}if(r===null&&t?.source!==void 0){let t=wn(e);if(t!==null){let e=$t(t.text);r=e===null?null:R(e)}}}return r}function kn(e,t,n){let r=t?.sessionId;return(0,g.jsx)(x,{label:`该界面`,children:(0,g.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 An(e,t,n){let r=On(e,n);return r===null?null:r.panel===!0?n!==void 0&&n.sessionId!==void 0&&n.source!==void 0?r.append===!0&&!xn(e)?(0,g.jsx)(h.Fragment,{},t):(0,g.jsx)(Dn,{sessionId:n.sessionId,sourceId:n.source.id,order:n.source.order,spec:r},t):(0,g.jsx)(h.Fragment,{},t):kn(t,n,r)}function jn(e,t,n){let r=On(e,n);return r===null?(0,g.jsx)(En,{fenceKey:t,raw:e},t):r.panel===!0?n!==void 0&&n.sessionId!==void 0&&n.source!==void 0?r.append===!0&&!xn(e)?null:(0,g.jsx)(Dn,{sessionId:n.sessionId,sourceId:n.source.id,order:n.source.order,spec:r},t):null:kn(t,n,r)}let Q=`.md-code-block, .code-block, .code-block-small`,$=`data-genui-rendered`,Mn=`[data-streaming]`;function Nn(e){return e.nodeType===Node.TEXT_NODE}function Pn(e){let t=e.querySelector(`pre`);for(let n of e.querySelectorAll(`*`)){if(n.childElementCount!==0||n.textContent!==`dsh-ui`||t!==null&&t.contains(n))continue;let r=n.closest(Q);if(r===null||r===e)return`dsh-ui`}return null}function Fn(e){let t=e.querySelector(`pre`);for(let n of e.querySelectorAll(`*`))if(n.childElementCount===0&&!(t!==null&&t.contains(n)))return n.textContent??``;return``}function In(e){let t=e.querySelector(`pre`);if(t===null)return``;let n=``;for(let e of t.childNodes)Nn(e),n+=e.textContent??``;return n}function Ln(e){return e.closest(Mn)===null}function Rn(e,t=document){let n=e.parentElement;for(let e=0;n!==null&&n!==t&&e<4;e+=1,n=n.parentElement)if(Pn(n)===`dsh-ui`)return n;return null}function zn(e=document){let t=new Set,n=[];for(let r of e.querySelectorAll(Q))(r.parentElement===null||r.parentElement.closest(Q)===null)&&(t.has(r)||(n.push(r),t.add(r)));for(let r of e.querySelectorAll(`pre`)){if(r.closest(Q)!==null)continue;let i=Rn(r,e);i===null||t.has(i)||(Bn||(Bn=!0,console.warn(`[dsh-genui] 围栏表面类名未被已知选择器命中(宿主 DOM 漂移),已按 label+pre 结构识别 dsh-ui 围栏`)),n.push(i),t.add(i))}return n}let Bn=!1,Vn=`[data-chat-flow-key], [data-chat-flow-kind]`;function Hn(e){return e.closest(`[data-chat-anchor-key]`)??e.closest(Vn)??e}function Un(e,t){let n=e===t?document:e,r=0;for(let e of zn(n))if(e.closest(Mn)===null&&Pn(e)!==null&&(r+=1,e===t))return r;return r+1}function Wn(e){let t=e.getAttribute(`data-chat-anchor-key`)??``,n=/assistant-step(\d+):(\d+)$/.exec(t);if(n!==null){let e=Number(n[1]),t=Number(n[2]);if(Number.isFinite(e)&&Number.isFinite(t))return e*1e3+t}let r=document.querySelectorAll(`[data-chat-anchor-key], ${Vn}`);for(let t=0;t`u`)return()=>{};Bn=!1;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=Un(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:[Wn(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($))}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($))return;let a=Hn(e),o=Ln(e);if(o&&Pn(e)===null)return;let c=In(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=An(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($,``);let p=(0,m.createRoot)(f);p.render((0,g.jsx)(v.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($)||t.setAttribute($,``),(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=In(e),s=Ln(e);if(s&&!o.lastSettled){let t=Fn(e);if(t!==``&&t!==`dsh-ui`){a(e);continue}}if(o.lastRaw!==n||o.lastSettled!==s){let{key:c,context:l}=i(Hn(e),e,s),u=An(n,c,l);if(u===null){a(e);continue}o.lastRaw=n,o.lastSettled=s,o.root.render((0,g.jsx)(v.Provider,{value:(e,n)=>{let i=r();i!==void 0&&t(i,e,n)},children:u}))}}l();for(let e of zn())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 h=window.setInterval(u,1e3);return u(),()=>{p.disconnect(),window.clearInterval(h);for(let e of Array.from(n.keys()))a(e)}}let Kn={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 qn(e,t){let n=t.trim().toLowerCase();if(n===`clear`||n===`off`||n===`close`){dn(e,null);return}dn(e,Kn),vn(e)}function Jn(e,t){return{token:`/panel`,hint:`开启 GenUI 面板;/panel <指令> 让模型定制;/panel clear 清空`,submit:async(n,r)=>{let i=n.trim();return i===``?qn(e,``):/^(clear|off|close)$/i.test(i)?qn(e,i):(qn(e,``),t(e,i)),{kind:`success`}}}}function Yn(e){return{trigger:`/`,name:`genui`,order:60,candidates:async(e,t)=>[{name:`panel`,description:`开启 GenUI 面板(/panel clear 清空;/panel <指令> 定制内容)`,hint:`/panel`}],onPick(t){return{claim:Jn(t.session.sessionId,e)}},matchEnter:async(t,n,r)=>{if(/^\/panel(?:\s|$)/.test(n.trim()))return{claim:Jn(t.sessionId,e)}}}}function Xn({sessionId:e,sendGenuiAction:t}){let n=(0,h.useSyncExternalStore)(hn,()=>mn(e)),r=(0,h.useSyncExternalStore)(bn,()=>yn(e)),[i,a]=(0,h.useState)(!0),[o,s]=(0,h.useState)(null),[c,l]=(0,h.useState)(!1),u=(0,h.useRef)(null),d=(0,h.useRef)(null);(0,h.useEffect)(()=>{r>0&&a(!1)},[r]),(0,h.useEffect)(()=>()=>{pn(e)},[e]),(0,h.useEffect)(()=>{if(c)return()=>{u.current=null}},[c]);let f=(0,h.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,g.jsxs)(`div`,{className:C.panel,"data-genui-panel":!0,children:[!i&&(0,g.jsx)(`div`,{role:`separator`,"aria-orientation":`horizontal`,"aria-label":`调整面板高度`,className:`${C.panelResizeHandle}${c?` ${C.panelResizeHandleActive}`:``}`,onPointerDown:f}),(0,g.jsx)(`div`,{className:C.panelHeader,children:(0,g.jsxs)(`button`,{type:`button`,className:C.panelToggle,"aria-expanded":!i,onClick:()=>a(e=>!e),children:[(0,g.jsx)(`span`,{className:C.panelBadge,children:`面板`}),(0,g.jsx)(`span`,{className:C.panelTitle,children:n.title??`GenUI 面板`}),(0,g.jsx)(`span`,{className:C.panelChevron,"aria-hidden":!0,children:i?`▸`:`▾`})]})}),!i&&(0,g.jsx)(`div`,{ref:d,className:C.panelBody,"data-genui-panel-body":!0,style:o===null?void 0:{height:o},children:(0,g.jsx)(v.Provider,{value:t,children:(0,g.jsx)(x,{label:`面板`,children:(0,g.jsx)(Jt,{spec:n,stateKey:ae(e,JSON.stringify(n))})})})})]})}function Zn({toolName:e,block:t,sessionId:n}){let r=`meta`in t?t.meta:void 0,i=(0,h.useMemo)(()=>r===void 0?null:R(r),[r]);return(0,h.useEffect)(()=>{i!==null&&i.items.length>0&&ln(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,g.jsxs)(`div`,{className:C.toolFallback,"data-genui-tool":!0,children:[(0,g.jsx)(`span`,{className:C.toolFallbackTitle,children:e}),(0,g.jsx)(`span`,{className:C.toolFallbackMeta,children:t.callId})]}):(0,g.jsx)(`div`,{className:C.tool,"data-genui-tool":!0,children:(0,g.jsx)(x,{label:`工具卡片`,children:(0,g.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="${U(e)}"]`)!==null)continue;let t=document.createElement(`link`);t.rel=`prefetch`,t.as=`script`,t.href=U(e),document.head.appendChild(t)}}function $n(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 er(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 tr(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 nr(e){let t=p.registerFenceRenderer,n=typeof t==`function`?[t(`dsh-ui`,jn)]:(console.info(`[genui] fence-registry 扩展点不存在(原版 DSH)——启用 DOM 渲染通道`),[Gn(e,(t,n,r)=>tr(e,t,n,r))]);return Qn(),n.push(e.slots.inject(`tool.call.toolview`,()=>e.slots.register({name:`tool.call.toolview`,key:`render_ui`},Zn))),n.push(e.slots.inject(`conversation.input.dock`,()=>e.slots.register({name:`conversation.input.dock`,id:`genui-panel`,order:50,inject:t=>$n(e,t)},Xn))),e.inject([`inputTriggers`],t=>{let n=t.get(`inputTriggers`);n!==void 0&&t.effect(()=>n.registerSource(Yn((t,n)=>er(e,t,n))),`genui: /panel`)}),()=>{for(let e of n)e()}}return n.apply=nr,n.inject=[`slots`,`sessions`],n.prefetchGenuiAssets=Qn,n.renderGenuiFence=jn,t.exports}}); \ No newline at end of file diff --git a/src/client/index.tsx b/src/client/index.tsx index b777118..8b7bdb5 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -150,14 +150,23 @@ export function apply(ctx: Context): () => void { // opens the panel dock (publishes the default spec + expand request), // clears it (/panel clear), or relays an instruction to the model // (/panel <指令>) so the panel gets tailored content. - const slash = ctx.get('inputTriggers') as InputTriggerServiceContract | undefined - if (slash !== undefined) { - disposers.push(ctx.effect(() => slash.registerSource( + // + // inputTriggers is subscribed via cordis OPTIONAL injection (ctx.inject), + // NOT a one-shot ctx.get() at apply time: the service is typically + // provided by a different bundle than slots/sessions, so it can arrive + // AFTER apply() runs — a one-shot lookup would silently disable /panel + // even on hosts that DO ship the service (service arrival order race). + // ctx.inject activates the callback only when the service arrives (any + // order) and disposes with the subscription fiber; hosts without the + // service simply never register /panel, and rendering is unaffected + // either way. + ctx.inject(['inputTriggers'], (scope) => { + const slash = scope.get('inputTriggers') as InputTriggerServiceContract | undefined + if (slash === undefined) return + scope.effect(() => slash.registerSource( createPanelSlashSource((sessionId, instruction) => sendPanelInstruction(ctx, sessionId, instruction)), - ), 'genui: /panel')) - } else { - console.warn('[genui] inputTriggers service unavailable; /panel command disabled') - } + ), 'genui: /panel') + }) return () => { for (const dispose of disposers) dispose() } @@ -173,10 +182,10 @@ export function apply(ctx: Context): () => void { // cordis `inject` is a hard activation gate — a declared service that is // never provided (pristine DSH shells ship no inputTriggers provider) parks // the fiber in waiting forever and apply() never runs, silently killing all -// GenUI rendering. The apply() body already treats it as optional via -// ctx.get('inputTriggers') and falls back to disabling only the /panel -// command, so the optional-lookup pattern (the same one dsh-vision-toolkit -// uses for `slash`/`inputTriggers`) is correct here. +// GenUI rendering. Instead the apply() body subscribes via ctx.inject +// (optional injection): hosts with the service get /panel registered in any +// arrival order, hosts without it never register /panel — and the renderer +// itself never depends on the service either way. export const inject = ['slots', 'sessions'] // Re-export the registry renderer for the test suite (setup.ts registers it