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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)。
Expand Down
2 changes: 1 addition & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ description: "Render structured interactive UI inline through the dsh-ui fence.
- image: `{"type":"image","src":"/mmx-files/result.png","alt":"<user-language description>"}` — 展示浏览器可访问的 http(s) 或同源相对图片地址;懒加载;不支持 `file:`/`data:` 等本地或主动协议
- audio: `{"type":"audio","src":"/mmx-files/result.mp3","alt":"<user-language description>","loop":true?}` — 原生控制条;用户主动播放,不自动播放;仅 http(s) 或同源相对地址
- video: `{"type":"video","src":"/mmx-files/result.mp4","alt":"<user-language description>","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":"..."}]}`
Expand Down
12 changes: 12 additions & 0 deletions src/client/genui-runtime/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
30 changes: 21 additions & 9 deletions src/client/genui-runtime/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ function isNode(value: unknown): value is Record<string, unknown> {

/** 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`
Expand Down Expand Up @@ -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)) {
Expand Down
10 changes: 5 additions & 5 deletions src/client/guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions tests/genui-guard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
60 changes: 60 additions & 0 deletions tests/genui-list-item-fields.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
}))
})
})
Loading