diff --git a/CHANGELOG.md b/CHANGELOG.md index bbb232b..e8a0a9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### 修复 +- `list` 项支持 `label`/`name` 标题字段与 `description`/`content`/`text`/`body`/`detail` 正文字段;只有正文的记录也能显示,无法渲染的记录字段会给出位置明确的警告(#265)。 - **GenUI reasoning-only 回合进入宿主重试**:当前回合调用 `validate_dsh_ui` 后,普通顶层会话若以 `stop` 结束且完整内容块只有 reasoning,插件会将终止结果改写为 `EMPTY_RESPONSE`,交由宿主现有重试策略处理;普通会话、子代理、辅助请求及含正文或工具调用的结果保持原行为。`fenceFeedback: false` 仍只关闭同回合围栏修正(#259)。 - **非英文宿主 locale 下围栏永远不渲染**:通用代码块的判定依赖硬编码标题白名单(`Code` / `Code block` / `代码块`),宿主 locale 为其它语言时(如俄语包的 «Код»),本地化标题被当成真实语言,同一份 `dsh-ui` 围栏既不走带标签路径也不进内容识别——界面永远停在代码块。现在判定反转:banner 标签命中**已知真实语言闭集**(语言 id 有限、由插件维护)才视为带语言,空标签与任何本地化通用词一律视为无语言、交给内容校验把关——同一份正文的渲染结果不再因宿主语言而不同(#258)。 - **围栏自修:每轮两次机会 + 把正文原样交回**。真实会话里「第一次修正(围栏渲染失败)被模型答成又一个只含 reasoning 的回合」出现 4/4 次——单次预算让这些轮次直接空转;同时那条 reasoning-only 修正只描述要求、未给出目标,模型会把上一轮的思考逐字节重放。现在:每轮至多两次修正(第二次专用于该情形)、reasoning-only 修正直接**附上要输出的围栏正文**并要求「只输出这一段、不要先写思考」,渲染失败路径仍受「每个围栏至多一次」约束(#235、#240)。 diff --git a/SKILL.md b/SKILL.md index 1e85255..8ceacb9 100644 --- a/SKILL.md +++ b/SKILL.md @@ -40,7 +40,7 @@ description: "Render structured interactive UI inline through the dsh-ui fence. - image: `{"type":"image","src":"/mmx-files/result.png","alt":""}` — 展示浏览器可访问的 http(s) 或同源相对图片地址;懒加载;不支持 `file:`/`data:` 等本地或主动协议 - audio: `{"type":"audio","src":"/mmx-files/result.mp3","alt":"","loop":true?}` — 原生控制条;用户主动播放,不自动播放;仅 http(s) 或同源相对地址 - video: `{"type":"video","src":"/mmx-files/result.mp4","alt":"","poster":"/mmx-files/poster.jpg"?,"loop":true?,"muted":true?,"aspectRatio":"16:9|4:3|1:1|9:16"?}` — 原生播放/音量/全屏控制;不自动播放 -- list: `{"type":"list","items":["..."] 或 [{"title":"...","desc":"..."}] 或嵌套节点(如 {"type":"badge","label":"TS"})}` — 行内可嵌节点(计入节点预算) +- list: `{"type":"list","items":["..."] 或 [{"title":"...","desc":"..."}] 或嵌套节点(如 {"type":"badge","label":"TS"})}` — 行内可嵌节点(计入节点预算);记录中的 `label`/`name` 归一化为 `title`,`description`/`content`/`text`/`body`/`detail` 归一化为 `desc`;只有正文时将正文用作标题,未知字段会收到警告 - table: `{"type":"table","columns":["..."],"rows":[["...","..."]],"types":["text|num|delta|bar|badge"]?,"details":[[...]]?,"total":true?}` — 表头点击本地排序(升/降/还原,零往返);数值感知:千分位(`1,234`)、`k/m/b`、`万/亿`、`%`、货币符号都能按真实数值比较,纯数值列自动右对齐;**带符号单元格自动着色**(`+12.4%` 绿、`-3` 红,无需额外字段);`types` 可按列指定 `bar`(0-100 内联进度条)、`ring`(0-100 小环)、`spark`(单元格写 `"3,5,4,8"` 画微趋势线)、`badge`(胶囊标签)、`delta`(强制涨跌色)、`num`(强制右对齐)、`index`(行号)、`group`(首列当分组标题:该行只有第一格有内容时渲染成跨列小标题);`"total":true` 追加合计行(数值列自动求和);**`"export":true`**:表格上方出现「复制 Markdown / 复制 CSV」两个小按钮(纯本地剪贴板,不发请求);**`"filter":"输入框id"`**:把表格和某个 input/select 绑定,读者输入即时过滤(`filterColumn` 可限定列)——数据多时**默认就该配一个**;**`"sortField":"下拉id"`** 用下拉的值(列名)排序;**`"details"` 与 rows 同序**,第 i 项是该行展开后的内容(可放任意组件,`null` = 该行不可展开)——首列出现 chevron,点开在整行下方展开明细,适合「主表 + 明细」 - keyvalue: `{"type":"keyvalue","pairs":[{"key":"...","value":"..."}]}` - timeline: `{"type":"timeline","items":[{"title":"...","desc":"...","time":"..."}]}` diff --git a/src/client/genui-runtime/diagnostics.ts b/src/client/genui-runtime/diagnostics.ts index e25d170..d61646d 100644 --- a/src/client/genui-runtime/diagnostics.ts +++ b/src/client/genui-runtime/diagnostics.ts @@ -131,6 +131,18 @@ export function diagnoseUnknownGenuiFields(value: unknown): GenuiDiagnostic[] { pushUnknownField(warnings, path, field, node.type as string) } diagnoseNestedFields(node, path, definition, warnings) + if (node.type === 'list' && Array.isArray(node.items)) { + node.items.forEach((item, index) => { + if (isNode(item)) return + const holder = record(item) + if (holder === undefined) return + for (const field of Object.keys(holder)) { + if (field === 'title' || field === 'desc') continue + const itemPath = `${path}.items[${index}]` + warnings.push({ kind: 'unknown-field', path: `${itemPath}.${field}`, message: `${itemPath}.${field}: list items render 'title' and 'desc' only — '${field}' is dropped`, type: 'list', field }) + } + }) + } } if (Array.isArray(root.items) && !isComponentRoot(root)) { for (const field of Object.keys(root)) { diff --git a/src/client/genui-runtime/normalize.ts b/src/client/genui-runtime/normalize.ts index c41d812..971c347 100644 --- a/src/client/genui-runtime/normalize.ts +++ b/src/client/genui-runtime/normalize.ts @@ -18,6 +18,7 @@ function isNode(value: unknown): value is Record { /** Fields a `stat` keeps when a metric record is folded into its own node. */ const STAT_METRIC_FIELDS = ['label', 'value', 'delta', 'spark', 'size'] as const +const LIST_ITEM_ALIASES = { label: 'title', name: 'title', description: 'desc', content: 'desc', text: 'desc', body: 'desc', detail: 'desc' } as const /** * Normalize one metric record of a stat group into a single-metric `stat` @@ -188,20 +189,31 @@ function normalizeNode(value: unknown, path: string, warnings: GenuiDiagnostic[] if (type === 'row' || type === 'col' || type === 'grid' || type === 'card' || type === 'file-tree' || type === 'timeline' || type === 'breadcrumb') { if (type !== 'file-tree' && type !== 'timeline' && type !== 'breadcrumb') out.items = normalizeNodeArray(out.items, `${path}.items`) } else if (type === 'list' && Array.isArray(out.items)) { - // List items are a union (string | {title,desc} | nested node), and models - // routinely dump 1×N / N×1 table cells in place of the item: `[["文本"]]` - // renders as an EMPTY list and `{title, description}` loses its body - // (repair reads `desc`). Both are pure shape defects — normalize them here - // so validation, diagnostics, and repair all see the canonical item. + // List records and nested nodes share items; aliases only belong to records. out.items = out.items.map((child, index) => { if (isNode(child)) return normalizeNodeValue(child, `${path}.items[${index}]`) if (Array.isArray(child) && child.length === 1 && typeof child[0] === 'string') return child[0] const holder = record(child) - if (holder === undefined || !('description' in holder)) return holder === undefined ? child : { ...holder } + if (holder === undefined) return child const normalizedHolder = { ...holder } - const description = normalizedHolder.description - delete normalizedHolder.description - if (!('desc' in normalizedHolder) && typeof description === 'string') normalizedHolder.desc = description + const itemPath = `${path}.items[${index}]` + for (const [alias, canonical] of Object.entries(LIST_ITEM_ALIASES)) { + if (!(alias in normalizedHolder)) continue + const keptCanonical = canonical in normalizedHolder + if (!keptCanonical) normalizedHolder[canonical] = normalizedHolder[alias] + delete normalizedHolder[alias] + warnings.push({ + kind: 'alias', path: `${itemPath}.${alias}`, type, field: alias, canonical, + message: keptCanonical + ? `${itemPath}.${alias} is ignored because canonical field '${canonical}' is present` + : `${itemPath}.${alias} normalized/adopted as '${canonical}'`, + }) + } + if (!('title' in normalizedHolder) && typeof normalizedHolder.desc === 'string') { + normalizedHolder.title = normalizedHolder.desc + delete normalizedHolder.desc + warnings.push({ kind: 'alias', path: `${itemPath}.desc`, type, field: 'desc', canonical: 'title', message: `${itemPath}.desc normalized/adopted as 'title'` }) + } return normalizedHolder }) } else if (type === 'keyvalue' && Array.isArray(out.pairs)) { diff --git a/src/client/guard.ts b/src/client/guard.ts index f96fc6f..b8a87e0 100644 --- a/src/client/guard.ts +++ b/src/client/guard.ts @@ -728,11 +728,6 @@ function repairListItems( continue } const o = obj(item) - const title = o === undefined ? undefined : str(o.title, GENUI_LIMITS.maxString) - if (title !== undefined) { - out.push({ title, ...opt('desc', o === undefined ? undefined : str(o.desc, GENUI_LIMITS.maxString) ?? str(o.description, GENUI_LIMITS.maxString)) }) - continue - } if (o !== undefined && typeof o.type === 'string') { // Typed children are GenuiNodes: charge them against the shared node // budget (module header promise — exhausted budget elides remaining @@ -742,6 +737,11 @@ function repairListItems( ctx.remaining -= 1 const node = repairNode(o, ctx, depth) if (node !== null) out.push(node) + continue + } + const title = o === undefined ? undefined : str(o.title, GENUI_LIMITS.maxString) + if (title !== undefined) { + out.push({ title, ...opt('desc', str(o?.desc, GENUI_LIMITS.maxString)) }) } } return out diff --git a/tests/genui-guard.spec.ts b/tests/genui-guard.spec.ts index e4a121d..54ade74 100644 --- a/tests/genui-guard.spec.ts +++ b/tests/genui-guard.spec.ts @@ -439,14 +439,14 @@ describe('repairGenuiSpec: list nodes', () => { ]) }) - it('prefers the title form when an object carries both title and type', () => { + it('treats an object with both title and type as a typed node', () => { const spec = repairGenuiSpec({ items: [ { type: 'list', items: [{ title: 'T', desc: 'D', type: 'badge', label: 'B' }] }, ], }) const [list] = spec!.items as Array<{ items: GenuiList['items'] }> - expect(list.items).toEqual([{ title: 'T', desc: 'D' }]) + expect(list.items).toEqual([{ type: 'badge', label: 'B' }]) }) it('countGenuiNodes includes typed list children', () => { diff --git a/tests/genui-list-item-fields.spec.ts b/tests/genui-list-item-fields.spec.ts new file mode 100644 index 0000000..1ca6377 --- /dev/null +++ b/tests/genui-list-item-fields.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { processGenuiSpec, validateGenuiSpec } from '../src/client/guard.ts' + +describe('list item fields', () => { + it('keeps list bodies written with common field names', () => { + const value = { items: [{ type: 'list', items: [ + { title: '一', content: '正文一' }, + { title: '二', text: '正文二' }, + { title: '三', body: '正文三' }, + { title: '四', detail: '正文四' }, + { title: '五', description: '正文五' }, + ] }] } + const processed = processGenuiSpec(value) + + expect(validateGenuiSpec(value).ok).toBe(true) + expect(processed.errors).toEqual([]) + expect(processed.repaired?.items[0]).toEqual({ type: 'list', items: [ + { title: '一', desc: '正文一' }, + { title: '二', desc: '正文二' }, + { title: '三', desc: '正文三' }, + { title: '四', desc: '正文四' }, + { title: '五', desc: '正文五' }, + ] }) + }) + + it('keeps record titles and promotes body-only records', () => { + const processed = processGenuiSpec({ items: [{ type: 'list', items: [ + { label: '标签', description: '正文' }, + { name: '名称', detail: '说明' }, + { content: '只有正文' }, + { title: '正式标题', label: '备用标题', desc: '正式正文', body: '备用正文' }, + ] }] }) + + expect(processed.errors).toEqual([]) + expect(processed.repaired?.items[0]).toEqual({ type: 'list', items: [ + { title: '标签', desc: '正文' }, + { title: '名称', desc: '说明' }, + { title: '只有正文' }, + { title: '正式标题', desc: '正式正文' }, + ] }) + }) + + it('reports fields a list record cannot render and preserves typed children', () => { + const processed = processGenuiSpec({ items: [{ type: 'list', items: [ + { title: '标题', desc: '正文', note: '额外说明' }, + { type: 'badge', title: '节点字段', label: '徽章' }, + ] }] }) + + expect(processed.repaired?.items[0]).toEqual({ type: 'list', items: [ + { title: '标题', desc: '正文' }, + { type: 'badge', label: '徽章' }, + ] }) + expect(processed.warnings).toContainEqual(expect.objectContaining({ + kind: 'unknown-field', path: 'items[0].items[0].note', type: 'list', field: 'note', + })) + expect(processed.warnings).toContainEqual(expect.objectContaining({ + kind: 'unknown-field', path: 'items[0].items[1].title', type: 'badge', field: 'title', + })) + }) +})