| 裁定项 |
|---|
| 结论 |
|---|
| 落点 |
|---|
| 1 | 形态 | 2-A 内容级围栏块,不是 pi 工具 | 模型写 ```xray-card;Markdown.tsx 的 pre 覆盖识别 language-xray-card |
| 2 | 卡片类型 | 闭集六种:kv / table / list / stat / compare / tabs | apps/web/lib/xray-card.ts 校验;未知 kind 回落 |
| 3 | 交互边界 | 只允许声明式:tabs / 折叠 / 表格排序 / 单选切面板;动作按钮唯一允许的动作 = 把一句预设文本放进输入框(R-CROSSLINK 的预填原语,不自动发送) | 无表达式求值、无模型给的 HTML / JS、无外部资源 |
| 4 | 顺序 | 先画板、再进轮次 | 2s / 2t + 4y / 4z |
为什么不做工具级(2-B):① tool_end 帧与 messages.payload 现在只带摘要字符串、永不带结构(R-TOOLCARDS 裁定), 工具级要新增一个贯穿 turn-recorder / sessions 投影 / turn-view 的 card 字段;② 画板 2l 的折叠规则是「最终回答之前的一切进折叠行」, 工具调用属于处理过程,卡片会被折进去,得给 2l 加例外;③ 内容级的卡片是正文的一部分:随 content 落库、随 delta 流式到达、随折叠规则留在最终回答里, 三件事都不用新机制。代价是两条:模型收不到校验错误(非法 JSON 静默回落成代码块,访客看见裸 JSON)、Timeline 里看不到「画了一张卡」(与 markdown 表格同理)。 两条都记在下面「已认代价」。工具级留作备选,要做先重裁本条。
派生取舍(实现者定,画板照此画;可推翻):
DSL v1 形状(JSON,v 必填 = 1):
每种可选 collapsed: true(初始折叠,标题行可点开)、links: [{ "text": "…", "href": "…" }](卡底一行链接)、 action: { "label": "…", "ask": "…" }(卡底一枚按钮 → 预填)。所有值都是字符串,渲染为纯文本(React 转义),不解析 markdown、不解析 HTML。
上限(任一超限整卡回落):围栏原文 ≤ 8 KB;rows / items ≤ 20;columns ≤ 6;tabs ≤ 5 且每个 tab 里不能再套 tabs; 字符串 ≤ 200 字符(title ≤ 60);links ≤ 5;action.ask ≤ 500。
链接口径与 markdown 相同:href 只收 http(s):// 与站内相对路径(/ 开头、不含 //),渲染带 rel="noreferrer noopener",其余丢弃该条链接(不回落整卡)。 v1 不收图片(generate_image 的图片仍走 markdown ![](),那是既有路径)。
流式期间:围栏未闭合时 react-markdown 把它当未终结的代码块渲染,访客会看到半截 JSON 滚动。处理:Markdown 加 streaming 标志, language-xray-card 且 JSON 解析失败且 streaming 为真 → 渲染卡片骨架(2i 语汇:#eeeeee 填充、r7、omPulseBg 一块锚点), 闭合后解析成功 → 换成卡片;闭合后仍失败 → 代码块(这时 streaming 已不成立)。
只在会话区开:Markdown 加 cards 开关,Workbench.tsx / MobileChat.tsx 传 true;Notes / Skills / Source 的渲染器不传, 同一段围栏在那里就是代码块 —— Notes 的正文契约是「标准 markdown,服务端只校验不改写」(docs/mcp.md),不扩。要给 Notes 开是另一次裁定。
系统提示加一段(runtime.ts,单独一段、与工具无关):什么时候用卡片(访客要的是结构化数据:对比 / 清单 / 指标 / 键值,且条目 ≥ 3)、 六种 kind 与形状(压缩到 20 行以内)、上限、每次回复最多两张、正文不复述卡片内容但要能独立成句(卡片回落时回复仍可读)、 action.ask 只能写访客下一句可能想问的话、不在卡片里放链接以外的任何 URL。提示词修补也走完整 codex 循环。
移动端:table / compare 在 390 宽下放进既有 m-xscroll 容器横滚(页面本身不横滚,与 2o 长行同一规则);compare 两列窄于 160 时改为上下堆叠; stat 两列网格;tabs 胶囊化(SegmentedControl 既有语汇)。内核 token 照搬桌面,只做触控与换行适配(R-MOBILE 两层语言)。
回放 = 实时:卡片状态(哪个 tab / 折叠 / 排序)是纯前端状态,不产生事件、不落库,刷新回初始态;这与 R-TOOLCARDS 卡片展开态一致。
已认代价(所有者 2026-09-08 裁定 2-A 时一并认下):
| 画板 | 页面 / 组件 | 核对项 | 结果(2026-09-09 本机,faux provider) |
|---|---|---|---|
| 2s 桌面 · 六种卡片 | components/XrayCard.tsx + Markdown.tsx | 六种 kind 的解剖:标题行 / 主体 / 卡底链接与动作按钮;collapsed 态;卡与正文的间距(会话区节奏 14) | ✅ 外框 r6 + rgba(0,0,0,.03) + 1px #e0e0e0、marginTop 14;标题行 mono 10/600 #9ca3af 0.08em;stat 三格 22/650 mono + 竖分隔;kv 收起态只剩标题行 + › 箭头;table 全出血、web_search / 300 字符 两列 mono、分组 列系统字(与画板一致);list 序号列 22;compare 首列次级色;tabs 胶囊组选中白底 + 品牌色 600;卡底两条链接(站外带 ↗ + target=_blank + rel)+ ghost 32 动作按钮;javascript: 链接被丢、卡照常画 |
| 2t 桌面 · 骨架 / 回落 / 交互态 | 同上 | 流式骨架;非法 JSON 回落成 xray-card 代码块;tabs 切换态;表头排序态(升 / 降小箭头);折叠态 | ✅ 「停」剧本三阶段截图:围栏未闭合 → 卡框 + 标题条(r6,omPulseBg)+ 三条正文条;闭合的坏 JSON 在流式进行中就是 xray-card 代码块(2c 画法、无错误提示);闭合合法 → 卡。表头点第二列:降 ˅ → 升 ˄,当前列整格品牌色、列宽不动(221/128/88/107 两次一致);tabs 切到 Bun 1.2 只换主体;collapsed kv 点开 6 行、箭头转 ˅ |
| 4y 移动 · 六种卡片 | MobileChat.tsx 走同一组件 | 横滚容器、堆叠规则、胶囊 tabs | ✅ 390 宽:body.scrollWidth === 390;stat 两列(176.6 × 2,第 2 格左分隔、第 3 格上分隔);table 单元格 nowrap、盒内横滚(377 > 353)、超出卡片右缘直接裁切;compare 堆叠(表头隐藏、行 block、A / B 各一行 104px + 1fr、列名 mono 10 小标题);tabs 换 SegmentedControl 皮(r9 容器 + 白底投影选中、不套品牌色);动作按钮胶囊 44 高 r22 --m-fill 品牌色 600、换到链接下方独占一行;链接各 32 高;collapsed 标题行 44;正文 p 15px 而卡内 13px(内核 token 照搬) |
| 4z 移动 · 骨架 / 回落 / 交互态 | 同上 | 对位 2t | ✅ 同一组件、同一段 CSS:骨架条宽 100% / 100% / 62%(标题条 210 + max-width:100%);回落代码块正文横滚(既有 pre 的 overflow:auto);交互三连与桌面同一份状态 |
| 件 | 路径 | 内容 |
|---|---|---|
| 解析与校验 | apps/web/lib/xray-card.ts + xray-card.test.ts | 纯函数:围栏文本 → `CardSpec |
| 渲染 | apps/web/components/XrayCard.tsx | 六种 kind 各一个子组件;tabs / 折叠 / 排序 / 单选切面板的本地状态;action 经 props 回调到预填 |
| 接线 | apps/web/components/Markdown.tsx | pre 覆盖:language-xray-card 分流;新增 cards?: boolean 与 streaming?: boolean 两个 prop,默认都 false |
| 会话区 | apps/web/components/workbench/Workbench.tsx / components/mobile/MobileChat.tsx | 传 cards / streaming;onAsk 接到 setDraft |
| 提示词 | apps/api/agent/runtime.ts + runtime.test.ts | 卡片段(≤ 20 行);测试钉「每次最多两张」与六个 kind 名在 |
| faux 剧本 | apps/api/agent/cards-e2e.test.ts(或并进既有 e2e) | faux provider 吐六种 + 非法 + 超限,断言落库 content 原样(服务端不碰围栏) |
| 文档 | docs/security.md §0 第 11 条(已写)· docs/architecture.md 一段「会话区富内容 = 围栏 DSL,服务端不参与」· design/README.md(拉稿时)· docs/releases.md |
不交付:pi 工具、tool_config 种子、payload / SSE 变动、迁移、MCP 变动、Notes 侧开卡片、图片 / 图表 / 图标、任何表达式或公式求值、 模型给的 HTML / CSS / JS、外部脚本或字体、卡片状态落库、4z 之后的移动端编号。
| # | 检查 | 命令 / 期望 |
|---|---|---|
| 1 | 编译与测试 | dev.ps1 check / dev.ps1 test 全绿(web bun test lib 新增 xray-card ≥ 20 条:六种合法各一、每条上限各一、链接口径、未知 kind、未知字段丢弃、tabs 嵌套拒);apps/web tsc --noEmit 过 |
| 2 | 六种渲染 | 本机 faux 剧本:六张卡逐项对照 2s |
| 3 | 回落 | 非法 JSON / 超限 / 未知 kind → xray-card 代码块(既有代码块画法),对照 2t |
| 4 | 流式骨架 | 慢速 faux(逐字 delta):围栏未闭合期间是骨架、闭合后是卡;闭合后非法 → 代码块 |
| 5 | 折叠后仍在 | 一轮跑完(2l 折叠行出现)后卡片留在最终回答里 |
| 6 | 回放一致 | F5 前后会话区 innerHTML sha256 一致;历史会话(GET /agent/sessions/:id)渲染同样六张 |
| 7 | 交互 | tabs 切换 / 折叠 / 表头排序(字符串序,稳定)/ 单选切面板;刷新后回初始态;Network 零新增 |
| 8 | 动作按钮 | action.ask → 输入框带文本、未发送;R-CROSSLINK 未落地的分支里 action 不渲染 |
| 9 | 链接口径 | javascript: / data: / //evil / 相对路径不带 / 的 href 被丢弃;http(s) 与 /notes/... 保留且带 rel |
| 10 | 不解析 | 值里的 <b> / **x** / $x$ 原样显示为文本 |
| 11 | Notes 不受影响 | 把同一段围栏放进一篇本机章节,/notes/... 渲染为代码块;git diff 不碰 components/notes / skills / source |
| 12 | 移动端 | 390 宽六张卡 body.scrollWidth === innerWidth;表格在容器内横滚;对照 4y / 4z |
| 13 | 提示词 | runtime.test.ts 钉卡片段;真实 provider 上问一个「对比 A / B」类问题,拿到一张合法 compare 卡(留证在「本轮实测」) |
| 14 | 桌面既有零改动 | 画板之外的页面与组件 git diff --stat 为空(Markdown.tsx 的默认路径行为不变:cards 不传时与改前一字不差) |
| 15 | 文档同步 | docs/architecture.md 一段;design/README.md 增删记录;docs/releases.md 一行;MCP 51 不变 |
| # | finding | 处理 |
|---|---|---|
| 1 | P2 · 闭合之前就画了卡(Markdown.tsx pre):流式分片里 JSON 已经完整、闭围栏还没到时 parseCard 先成功、卡立刻出现;回复最终漏掉闭围栏的话也会永久是卡而不是代码块 —— 违反画板 2t「未闭合显示骨架、闭合后才切换」 | 采纳。pre 改成先问「闭合了没」:未闭合 → 流式期间骨架、流结束仍未闭合 → 代码块;闭合且合法 → 卡。判据 fenceUnterminated 顺带从「代码正文是整篇正文的后缀」改成按开围栏行号往后找独立的闭围栏行(行号取 hast 的 position,remarkDollarGuard 只插反斜杠不增减行,两边对得上),早先那版在「闭合的卡后面正好跟着同样的文字」时会误判 |
| 2 | P2 · 站内链接放过反斜杠(lib/xray-card.ts cardHref):/\evil.example/x 被当站内路径,浏览器把反斜杠规范化成 https://evil.example/x,没有 ↗ / 新窗口语义就把当前页换走 —— 不符 docs/security.md §0 第 11 条的站内相对路径闭集 | 采纳。以 / 开头的 href 拿一个假基址(https://xray.invalid)解析一遍,origin 变了就不是站内(与 //evil 同一类);路径中段的反斜杠(/a\b → /a/b)仍在本源、留着无害。用例 +4 |
| 3 | P2 · 8 KB 上限在 trim 之后量(parseCard):一段小 JSON 前后垫任意空白的围栏照样过 —— 绕过声明的卡片大小边界,也与 fenceBytes 的「围栏原文」定义不符 | 采纳。先量 raw 再 trim。用例 +2 |
复验:bun test lib 92 用例全绿(新增 12 条:围栏判据 7 条 + 反斜杠链接 4 条 + 8 KB 先于 trim 1 条),tsc --noEmit 过;本机 停 剧本重跑:围栏未闭合期间骨架、闭合的坏 JSON 立刻代码块、闭合合法 → 卡,流结束后 2 卡 + 1 代码块;六张 剧本重跑仍是六张卡 + 两代码块。 另一条跨轮次发现记 BACKLOG:Markdown.tsx 的 a 对 [x](/\evil.com) 有同一个洞(既有,与 R-CARDS 无关,不当场改)。
第 2 轮(仍全量,提交 aca80ad;15 分钟):5 条 findings,全部 P2、无 high;4 条采纳整改、1 条不采纳写明理由。
| # | finding | 处理 |
|---|---|---|
| 1 | P2 · 提示词里 tabs 的例子不是合法 JSON(runtime.ts):"rows":[…] 里裸的 …,而下一句又要求严格 JSON —— 六种里唯一一个具体例子会把模型带进回落 | 采纳。例子改成 "rows":[{"k":"…","v":"…"}](占位符都在字符串里) |
| 2 | P2 · 围栏紧跟在列表标记后面时认不出开围栏(fenceUnterminated):1. + 三反引号是 CommonMark 合法写法,开围栏行的正则不认列表标记 → 当作已闭合 → 闭合前就画卡、最终没闭合也留着卡 | 采纳。开围栏行的前缀允许 - / + / * / 1. / 1) 这类列表标记(后面必须跟空白);闭围栏行不变(闭围栏不可能与列表标记同行)。用例 +4 |
| 3 | P2 · 移动端表格应固定 480 内容宽、列宽不随视口变(XrayCard.tsx):width:100% 让短内容的表收缩到卡宽、可能没有横滚 | 不采纳,理由:画板 4y 的 480 = 那张四列示例表(180/110/70/120)的内容宽,不是给任意列数的表定的规则;实现按画板的规则句做 —— 单元格 nowrap、表在卡内盒子里横滚、页面不横滚;表比卡窄时按 2c 表格的既有约定 width:100% 填满卡(内容定宽 + 不填满会让表头灰底只到表尾、卡内右侧留白,那是新的视觉规则)。「短内容没有横滚」不是缺陷:没有溢出就没有东西可滚。记 BACKLOG 等所有者裁定 |
| 4 | P2 · 移动端 kv 键列仍是 150(XrayCard.tsx):画板 4y 明写「320 / 430 不变 = compare 与 kv 的键列 104」,150 在 320 宽下把值列挤到折行 | 采纳。容器查询里 .xcard-kv-row 键列 104、gap 8(与画板 4y 的 kv 行一致) |
| 5 | P2 · 移动端 tabs 内项命中区只有 34(globals.css):画板 4y 要求上下各 5px 透明外扩补到 44 | 采纳。.xcard-tab::after 上下各外扩 5px,与既有 .m-row-tap 同一手法,不参与布局 |
复验:bun test lib 93 用例全绿(+1 条列表标记开围栏,含四个子断言),tsc --noEmit 过;dev.ps1 test agent/runtime.test.ts agent/cards-e2e.test.ts 过(提示词例子改动后卡片段仍是合法 JSON 且六个 kind 都在);390 宽实测 kv 行 104px + 1fr、gap 8,tabs 内项 34 高 + ::after 上下各 −5px(命中 44)。
第 3 轮(按流程只审整改 diff,--base aca80ad,提交 ed6f21a;8 分钟):2 条 findings,全部 P2、无 high,两条全部采纳整改。
| # | finding | 处理 |
|---|---|---|
| 1 | P2 · kv 键列 104 的容器查询没套在移动断点里(globals.css):桌面视口略高于 768、或桌面聊天栏把卡压到 480 以下时也会触发,把桌面画板的 150 改成 104 —— 桌面样式要零改动 | 采纳。整块 @container 规则(kv 键列、stat 两列、compare 堆叠、表格不折行)套进 @media (max-width: 768px):4y 是移动画板,桌面卡片照桌面画板 2s,聊天栏再窄也不切换。同一模式的四条一起改,不只改被点的那条 |
| 2 | P2 · 无标题 tabs 卡的命中区不足 44(globals.css):tablist 顶着卡片上沿,卡的 overflow:hidden 把 ::after 上面那 5px 裁掉,实际命中约 41 | 采纳。无标题的 tabs 卡外层多一个 xcard-tabs-wrap-notitle 类,移动端顶部内边距 8px,外扩落在卡内;有标题时外扩落在标题行里、不用留 |
复验:bun test lib 93 用例全绿,tsc --noEmit 过;桌面 1000 宽(卡宽 < 480)kv 行仍是 150px + 1fr、compare 表头仍显示、stat 仍三格一排、表格单元格可折行;390 宽 kv 行 104px + 1fr,tabs 外层加 -notitle 类后 padding-top 8px、::after top −5px。
第 4 轮(只审整改 diff,--base ed6f21a,提交 f69bc14):零 findings。原话:「本次改动正确限制了移动端容器查询的作用域,并补足无标题 tabs 卡片的触控区域,未发现会破坏现有行为的缺陷。」
同一验收项针对性整改后连续 2 次验证仍不过 → 写 rounds/round-cards/BLOCKED.md,停下呼人。禁止放宽验收标准自我通过。
| # | 结果 | 留证 |
|---|---|---|
| 1 | ✅ | dev.ps1 check 过;dev.ps1 test:api 35 文件 / 613 用例(新增 cards-e2e 1 条 + runtime.test 3 条,全绿)+ web bun test lib 88 用例(新增 xray-card.test.ts 32 条);apps/web tsc --noEmit 过 |
| 2 | ✅ | 六张 剧本:会话区 [data-xray-card] = stat / kv / table / list / compare / tabs 各一,逐项对照 2s(见上表) |
| 3 | ✅ | 同一轮里尾逗号坏 JSON 与 21 行超限两张 → 两个 xray-card 代码块(2c 画法、语言标签 xray-card、无错误提示);lib 用例另钉未知 kind / 缺 v / 类型不对 / 行长不等 / tabs 套 tabs |
| 4 | ✅ | 停 剧本(正文停在围栏中间 25 s × 2):阶段①骨架(卡框 + 标题条脉动 + 三条正文条)→ 阶段②第一张换成卡、闭合的坏 JSON 立刻是代码块、第二张骨架 → 阶段③ 2 卡 + 1 代码块、发送按钮回常态。判据是 fenceUnterminated(代码正文是整篇正文的后缀 = 未闭合),不靠解析器 position |
| 5 | ✅ | 工具 剧本(先 notes_search 再吐正文):折叠行「处理详情 · 2 次模型往返 · 1 次工具调用 · 1.4s」出现在最上面,两张卡在折叠行之下的最终回答里(compareDocumentPosition FOLLOWING) |
| 6 | ✅ | 六张 会话:实时渲染完 → 会话区 innerHTML sha256 dcd4e581…0b228b(38,168 字节);F5 后从侧栏重开同一会话 → 同一哈希、同六张卡 + 两代码块 |
| 7 | ✅ | tabs 切换 / 折叠展开 / 表头升降序(Intl.Collator numeric:300 > 128 > 120 > 64 > 60,稳定)/ 单选切面板都是本地状态;F5 回初始态(#6 的哈希相等即证);交互期间 Network 无新增 /agent/* 请求(与 #6 同一次会话) |
| 8 | ✅ | 点「帮我按这五步写一份接入清单」→ 输入框 value 正是 action.ask、聚焦、md-chat 数不变、未进入生成态;onAsk 不传时 XrayCard 不渲染按钮(Markdown 默认 onAsk 为空) |
| 9 | ✅ | lib 用例:javascript: / data: / //evil / notes/a(不带 /)/ mailto: / 超长 → 丢该条;http(s) 与 /notes/… 保留并标 external;浏览器里两条链接都带 rel="noreferrer noopener",站外多 target=_blank |
| 10 | ✅ | 工具 剧本的 kv:<b>粗体标签</b> / **星号** 与 \反引号`/ 与 /点我 全部原样显示,单元格只有文本节点(children.length === 0`) |
| 11 | ✅ | 往本机 notes_chapters 临时插一章、正文带同一段围栏 → /notes/pi/tmp-xray-card-check 渲染为 xray-card 代码块、[data-xray-card] 为 0(验完删行);git diff 不碰 components/notes / skills / source |
| 12 | ✅ | 390 × 845 移动壳:body.scrollWidth === innerWidth;表格在 .xcard-scroll 内横滚;逐项对照 4y / 4z(见上表) |
| 13 | ✅ | runtime.test.ts 钉住卡片段(零工具 / 有工具都送达、排在工具段落之后、六个 kind / 最多两张 / 8 KB / 不套 tabs / 不发送);cards-e2e 断言这一段真的到了 provider。真实 provider 留证已在生产补齐(2026-09-09,d342b18 发版当日):两轮实跑,① 「用表格对比 REST / GraphQL / gRPC 三个维度」→ 模型自己写了围栏,DOM 链 TABLE.xcard-table < DIV.xcard-scroll < DIV.xcard < DIV.md-chat、页面 pre code 为 0(没有回落);② 「可排序的表格 + 一个按钮」→ 卡带 action,点按钮输入框出现那句话而 agent_settled 计数不变(预填未发送),点表头行序三态切换、aria-sort=ascending;移动壳(457 宽)下同一张卡的 action 与预填同样成立。命中率不是 100%:两次提问都明说了「表格」/「可排序的表格 + 按钮」,未测不明说时的自发命中 |
| 14 | ✅ | git diff --stat main -- apps/web 只有 Markdown.tsx / Workbench.tsx / MobileChat.tsx / globals.css 四处接线 + 三个新文件;Markdown 的 cards 不传时 pre 路径一字不变;apps/api 只有 runtime.ts 一段提示词 + 两个测试文件 |
| 15 | ✅ | docs/architecture.md 关键决策表新增一行;docs/security.md §0 第 11 条改「已落地」并指到边界;design/README.md 增删记录;MCP 仍 51(docs/mcp.md 未动);docs/releases.md 发版时补 |