Files
admin-ui/src/views/settings/workflow/component/modelParamUtils.ts
T
2910410219andClaude 63ac39cd4a 模型参数约束透传:collectExposedFields 从 constraint 归一化 fieldConstraint 供首页表单校验
模板字段约束实际挂在 constraint 键(min/max/uploadRules 等),原读取 fieldConstraint 键导致模型勾选字段的约束丢失。新增 toFieldConstraint 归一化(min→minValue、max→maxValue、uploadRules/accept→fileTypes/maxFileSize/maxFileCount),并将格式分隔符统一为英文逗号,修复 wan 模型音频上传被误拦。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 11:46:50 +08:00

510 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 模型参数表单共用工具(workflow 独立实现,不依赖内容创作)
// 与项目一致的深拷贝方式
export function deepClone<T>(val: T): T {
return JSON.parse(JSON.stringify(val ?? null)) as T;
}
// 按类型生成叶子字段空值
export function defaultLeafValue(def: any): any {
const t = def?.type || 'string';
const ft = def?.fieldType || '';
if (t === 'boolean' || ft === 'switch') return false;
if (t === 'number' || ft === 'number') return 0;
return '';
}
// 归一化数组元素:元素为 { type:'object', attrs } 包装时,递归预填 attrs 子字段
export function normalizeArrayItem(item: any): any {
if (item && typeof item === 'object') {
if (item.type === 'object' && item.attrs && typeof item.attrs === 'object' && !Array.isArray(item.attrs)) {
Object.values(item.attrs).forEach(normalizeFieldDef);
}
}
return item;
}
// 归一化字段定义:用 defaultValue 预填 value;递归处理 object/array 子树
export function normalizeFieldDef(def: any): any {
if (!def || typeof def !== 'object') return def;
const t = def.type;
if (t === 'object') {
const attrs = def.attrs;
if (attrs && typeof attrs === 'object' && !Array.isArray(attrs)) {
Object.values(attrs).forEach(normalizeFieldDef);
}
if (def.value === undefined || def.value === null) {
def.value = def.defaultValue !== undefined && def.defaultValue !== null ? def.defaultValue : {};
}
} else if (t === 'array') {
if (Array.isArray(def.value)) {
def.value.forEach(normalizeArrayItem);
} else if (Array.isArray(def.defaultValue)) {
def.value = def.defaultValue.map((it: any) => normalizeArrayItem(deepClone(it)));
} else {
def.value = [];
}
} else {
if (def.value === undefined || def.value === null) {
def.value = def.defaultValue !== undefined && def.defaultValue !== null ? def.defaultValue : defaultLeafValue(def);
}
}
return def;
}
// 归一化整个 requestBodyMapping 参数模板对象
export function normalizeModelParams(params: any): Record<string, any> {
if (!params || typeof params !== 'object') return {};
Object.values(params).forEach(normalizeFieldDef);
return params;
}
// 剔除 isForm === false(只读)字段:不在节点表单中显示,且不随工作流保存。
// 级联规则:容器若内部无可编辑子级则无意义,一并剔除
// 1. 字段自身 isForm === false → 整体剔除
// 2. object:剔除子字段后 attrs 为空(无可编辑子字段)→ object 一并剔除
// 3. array:过滤「子字段全部只读」的元素模板;无可编辑模板 → array 一并剔除
// 4. array 已添加元素内部被清空 → 一并移除
export function stripReadonlyFields(params: any): any {
if (!params || typeof params !== 'object' || Array.isArray(params)) return params;
for (const key of Object.keys(params)) {
const def = params[key];
if (!def || typeof def !== 'object') continue;
// 自身标记为只读 → 整体删除
if (def.isForm === false) {
delete params[key];
continue;
}
if (def.type === 'object') {
const attrs = def.attrs;
if (attrs && typeof attrs === 'object' && !Array.isArray(attrs)) {
stripReadonlyFields(attrs);
// 子字段全部只读 → object 无意义,一并剔除
if (Object.keys(attrs).length === 0) {
delete params[key];
}
}
} else if (def.type === 'array') {
let hasEditable = false;
// 单一元素模板(attrs 为 object
if (def.attrs && typeof def.attrs === 'object' && !Array.isArray(def.attrs)) {
stripReadonlyFields(def.attrs);
if (Object.keys(def.attrs).length > 0) hasEditable = true;
}
// 多元素模板:优先 enumValues(与 ModelField.arrayTemplates 一致),其次 attrs 数组;过滤子字段全部只读的空模板
const isEnumValues = Array.isArray(def.enumValues) && def.enumValues.length > 0;
const isAttrsArray = Array.isArray(def.attrs) && def.attrs.length > 0;
const templates = isEnumValues ? def.enumValues : isAttrsArray ? def.attrs : [];
const keptTemplates = templates.filter((t: any) => {
if (t && typeof t === 'object' && t.attrs && typeof t.attrs === 'object' && !Array.isArray(t.attrs)) {
stripReadonlyFields(t.attrs);
return Object.keys(t.attrs).length > 0;
}
return true;
});
// 有模板被剔除(子字段全只读)→ 数组索引被压缩;给保留的模板补记原始位置 __originIndex
// 供 buildModelRequestParamsPath 生成后端契约的 enumValues 原始索引(幂等:已带则不覆盖)
if (keptTemplates.length !== templates.length) {
templates.forEach((t: any, originIdx: number) => {
if (t && typeof t === 'object' && keptTemplates.includes(t) && t.__originIndex === undefined) {
t.__originIndex = originIdx;
}
});
}
if (isEnumValues) def.enumValues = keptTemplates;
if (isAttrsArray) def.attrs = keptTemplates;
if (keptTemplates.length > 0) hasEditable = true;
// 已添加元素内部清理后为空的 → 一并移除
if (Array.isArray(def.value)) {
def.value.forEach((it: any) => {
if (it && typeof it === 'object' && it.attrs && typeof it.attrs === 'object' && !Array.isArray(it.attrs)) {
stripReadonlyFields(it.attrs);
}
});
def.value = def.value.filter((it: any) => {
if (it && typeof it === 'object' && it.attrs && typeof it.attrs === 'object' && !Array.isArray(it.attrs)) {
return Object.keys(it.attrs).length > 0;
}
return true;
});
}
// 无可编辑内容 → array 无意义,一并剔除
if (!hasEditable) {
delete params[key];
}
}
}
return params;
}
// 剔除 array 类型字段的实例值(value):后端模型定义中 array 仅保存结构/模板
// enumValues / attrs),前端渲染时从模板复制并填充的 value 实例是冗余数据,
// 保存工作流时不提交。递归处理嵌套结构(array 字段可嵌套于 enumValues 模板中,
// 如 messages.enumValues[0].attrs.content 仍是 array,同样需删除 value)。
export function removeArrayValueInstances(params: any): any {
if (!params || typeof params !== 'object' || Array.isArray(params)) return params;
// 单个字段定义(带 type):array 删实例值并递归模板;object 递归 attrs
if (typeof params.type === 'string') {
if (params.type === 'array') {
delete params.value;
if (params.attrs && typeof params.attrs === 'object') {
if (Array.isArray(params.attrs)) params.attrs.forEach((it: any) => removeArrayValueInstances(it));
else removeArrayValueInstances(params.attrs);
}
if (Array.isArray(params.enumValues)) params.enumValues.forEach((tpl: any) => removeArrayValueInstances(tpl));
} else if (params.type === 'object' && params.attrs && typeof params.attrs === 'object' && !Array.isArray(params.attrs)) {
removeArrayValueInstances(params.attrs);
}
return params;
}
// 字段定义容器 { key: fieldDef }:逐个字段递归
for (const key of Object.keys(params)) {
removeArrayValueInstances(params[key]);
}
return params;
}
// ===== 暴露清单:在工作流表单中展示的勾选字段 =====
// array 的模板元素列表(与 ModelField.arrayTemplates 优先级一致):
// enumValues 数组 → attrs 数组 → 单个 attrs 对象。勾选/值都跟随模板,故 path 用
// enumValues[i] 定位模板,collect 与 resolve 共用本函数保持文法一致。
function arrayTemplatesOf(def: any): any[] {
if (!def || typeof def !== 'object') return [];
if (Array.isArray(def.enumValues) && def.enumValues.length) return def.enumValues;
if (Array.isArray(def.attrs) && def.attrs.length) return def.attrs;
if (def.attrs && typeof def.attrs === 'object' && !Array.isArray(def.attrs)) return [{ type: 'object', attrs: def.attrs }];
return [];
}
// 单个暴露叶子字段(路径带模板索引,如 "messages.enumValues[0].attrs.content"
export interface ExposedField {
path: string;
label: string;
fieldType: string;
type: string;
required: boolean;
options?: any[];
value?: any;
defaultValue?: any; // 默认值:首页初始化/回显用
fieldConstraint?: any; // 字段约束:上传格式/大小/数量、数字 min/max 等
valueSource?: any; // 引用上游节点输出([{ nodeId, field }] 数组,支持多选);有则首页只读展示
multiple?: boolean; // 多文件上传标记
refNodeId?: string; // 预留:引用其他节点功能(后续启用)
}
// 将模型模板字段级约束(constraintJsonEditor 风格:min/max/uploadRules/accept/maxSize/maxCount 等)
// 归一化为首页运行表单消费的 fieldConstraintminValue/maxValue/fileTypes/maxFileSize/maxFileCount)。
// 模板字段约束实际挂在 constraint 键下(如 video_url.url 的 uploadRules、duration 的 min/numberType),
// 与 collectExposedFields 原读取的 fieldConstraint 键不一致,需在此转换后随 modelFormFields 透传首页。
function toFieldConstraint(constraint: any): any {
if (!constraint || typeof constraint !== 'object') return undefined;
const out: Record<string, any> = {};
// 数字 min/max → 首页 el-input-number 的 minValue/maxValue
if (constraint.min !== undefined && constraint.min !== null) out.minValue = constraint.min;
if (constraint.max !== undefined && constraint.max !== null) out.maxValue = constraint.max;
// 上传约束:uploadRules 数组取第一条;兼容顶层 accept/maxSize/maxCount 单值形式
const rules = Array.isArray(constraint.uploadRules) ? constraint.uploadRules.filter((r: any) => r && typeof r === 'object') : [];
const rule = rules[0] || null;
const fileTypes = rule?.format ?? constraint.accept;
if (fileTypes !== undefined && fileTypes !== null && fileTypes !== '') {
// 模板 format 可能用中文顿号/空白分隔(如 wan 模型 "wav、mp3"),
// 统一转英文逗号,保证首页 split(',') 与扩展名匹配正确
out.fileTypes = String(fileTypes)
.replace(/[、\s]+/g, ',')
.replace(/,+/g, ',')
.replace(/^,|,$/g, '');
}
const maxSize = rule?.maxSize ?? constraint.maxSize;
if (maxSize !== undefined && maxSize !== null) out.maxFileSize = maxSize;
const maxCount = rule?.maxCount ?? constraint.maxCount;
if (maxCount !== undefined && maxCount !== null) out.maxFileCount = maxCount;
return Object.keys(out).length > 0 ? out : undefined;
}
// 收集 runtimeShow === true 的叶子字段,生成暴露清单
// 路径文法(与 restoreRuntimeShow 共用):
// object 子字段 → 父路径 + ".attrs." + 子key
// array 模板元素 → 父路径 + ".enumValues[i]"
// 原始值数组元素(非 object 包装)不可勾选,跳过
export function collectExposedFields(params: any, prefix = ''): ExposedField[] {
if (!params || typeof params !== 'object') return [];
const result: ExposedField[] = [];
for (const key of Object.keys(params)) {
const def = params[key];
if (!def || typeof def !== 'object') continue;
const path = prefix ? `${prefix}.${key}` : key;
const t = def.type;
if (t === 'object') {
const attrs = def.attrs;
if (attrs && typeof attrs === 'object' && !Array.isArray(attrs)) {
result.push(...collectExposedFields(attrs, `${path}.attrs`));
}
} else if (t === 'array') {
// 勾选跟随模板:渲染时 value 元素与模板共享引用,runtimeShow 落在模板上,
// 故遍历模板(enumValues 优先)收集,path 用 enumValues[i] 定位模板
const templates = arrayTemplatesOf(def);
templates.forEach((tpl: any, i: number) => {
if (tpl && typeof tpl === 'object' && tpl.attrs && typeof tpl.attrs === 'object' && !Array.isArray(tpl.attrs)) {
// 索引与 collectAllLeafFields 一致:优先用 strip 时记录的原始模板索引 __originIndex
// 保证 modelFormFields 与 modelRequestParamsPath 的 path 索引对齐后端模型完整定义
const originIdx = typeof tpl.__originIndex === 'number' ? tpl.__originIndex : i;
result.push(...collectExposedFields(tpl.attrs, `${path}.enumValues[${originIdx}].attrs`));
}
});
} else if (def.runtimeShow === true) {
// 叶子且已勾选
result.push({
path,
label: def.label || key,
fieldType: def.fieldType || 'string',
type: t || 'string',
required: !!def.required,
options: Array.isArray(def.options) ? def.options : undefined,
value: def.value,
defaultValue: def.defaultValue,
// 优先透传已有的 fieldConstraint(首页格式);模板字段约束在 constraint 键时归一化补全
fieldConstraint:
(def.fieldConstraint && typeof def.fieldConstraint === 'object' ? def.fieldConstraint : undefined) ??
toFieldConstraint(def.constraint),
valueSource: (() => {
const vs = def.valueSource;
// 契约统一数组;兼容旧 DSL 单对象 { nodeId, field }
return Array.isArray(vs) ? vs : vs && typeof vs === 'object' ? [vs] : undefined;
})(),
multiple: def.multiple || def.fieldType === 'uploadMultiple' || undefined,
});
}
}
return result;
}
// ===== 模型请求参数扁平清单(modelRequestParamsPath=====
// 后端契约:保存时与 modelRequestParams 平级新增 modelRequestParamsPath
// 为嵌套模型参数树的全部叶子字段扁平清单(path 文法与 collectExposedFields 一致),
// 后端按 path 重组请求参数。value 保留当前值;valueSource 规则(统一数组 [{ nodeId, field }]):
// - 已有引用配置(用户在设计器配置「引用上游」)→ 原样保留数组
// - 无引用但勾选「表单展示」(runtimeShow=true)→ 补 [{ nodeId: 开始节点id, field: path }]
// - 其余 → 不带 valueSource
export interface ModelRequestParamsPathItem {
path: string;
// 叶子字段中文名(回显/展示用;无 label 时兜底 path 最后一段字段名)
label: string;
type: string;
required: boolean;
value: any;
valueSource?: any;
}
// 收集嵌套树的全部叶子字段(含数组模板元素,不过滤 runtimeShow),返回 { path, def }
function collectAllLeafFields(params: any, prefix = ''): { path: string; def: any }[] {
if (!params || typeof params !== 'object' || Array.isArray(params)) return [];
const result: { path: string; def: any }[] = [];
for (const key of Object.keys(params)) {
const def = params[key];
if (!def || typeof def !== 'object') continue;
const path = prefix ? `${prefix}.${key}` : key;
const t = def.type;
if (t === 'object') {
const attrs = def.attrs;
if (attrs && typeof attrs === 'object' && !Array.isArray(attrs)) {
result.push(...collectAllLeafFields(attrs, `${path}.attrs`));
}
} else if (t === 'array') {
// 数组字段遍历模板(enumValues 优先,其次 attrs 数组),path 用 enumValues[i] 定位模板,
// 与 ModelField 渲染 / collectExposedFields 文法一致(值/勾选均落在模板上,不依赖 value 实例)。
// 模板带 __originIndexstrip 时记录的原始位置)时用原始索引,保证与后端模型完整定义的 enumValues 编号一致
const templates = arrayTemplatesOf(def);
templates.forEach((tpl: any, i: number) => {
if (tpl && typeof tpl === 'object' && tpl.attrs && typeof tpl.attrs === 'object' && !Array.isArray(tpl.attrs)) {
const originIdx = typeof tpl.__originIndex === 'number' ? tpl.__originIndex : i;
result.push(...collectAllLeafFields(tpl.attrs, `${path}.enumValues[${originIdx}].attrs`));
}
});
} else {
result.push({ path, def });
}
}
return result;
}
// 生成 modelRequestParamsPath 扁平清单
// 所有叶子字段(含对象/数组内部)都支持「引用上游」,valueSource 原样保留;
// 与编辑器「引用上级输出与表单展示同级」语义一致
export function buildModelRequestParamsPath(
params: any,
startNodeId = '',
// 节点输出索引(id → 节点名 + 字段 label),用于补全 valueSource 的引用来源 label
outputIndex?: Map<string, { name: string; fields: Map<string, string> }>
): ModelRequestParamsPathItem[] {
const fields = collectAllLeafFields(params);
// 引用来源 label:字段中文名(不带「节点名.」前缀;无索引/未命中时兜底 field)
const resolveRefLabel = (nodeId: string, field: string): string => {
const info = outputIndex?.get(nodeId);
if (!info) return field;
return info.fields.get(field) || field;
};
return fields.map(({ path, def }) => {
let valueSource: any;
if (Array.isArray(def.valueSource)) {
// 引用上游:前端编辑态 [{ nodeId, field }] 数组,逐项补 label
valueSource = def.valueSource.map((r: any) =>
r && r.nodeId && r.field
? { nodeId: r.nodeId, field: r.field, label: resolveRefLabel(r.nodeId, r.field) }
: r
);
} else if (def.valueSource && typeof def.valueSource === 'object') {
// 兼容旧 DSL 单对象 { nodeId, field }
const r = def.valueSource as any;
valueSource = [
r && r.nodeId && r.field
? { nodeId: r.nodeId, field: r.field, label: resolveRefLabel(r.nodeId, r.field) }
: r,
];
} else if (def.runtimeShow === true && startNodeId) {
// 勾选「表单展示」:补后端契约数组(指向开始节点,label 取开始节点字段名)
valueSource = [{ nodeId: startNodeId, field: path, label: resolveRefLabel(startNodeId, path) }];
}
return {
path,
label: def.label || path.split('.').pop() || '',
type: def.type || 'string',
required: Boolean(def.required),
value: def.value ?? '',
...(valueSource ? { valueSource } : {}),
};
});
}
// 按暴露清单的 path 反解,把对应叶子 def 的 runtimeShow 置 true(加载时还原勾选)
// startNodeId:勾选「表单展示」的字段保存时带 valueSource 指向开始节点(后端契约);
// 回显时该 valueSource 无编辑器引用语义,清除避免 ModelField 误判引用/误删
export function restoreRuntimeShow(params: any, fields: ExposedField[] | null | undefined, startNodeId = ''): void {
if (!params || typeof params !== 'object' || !Array.isArray(fields) || fields.length === 0) return;
for (const f of fields) {
if (!f || typeof f.path !== 'string') continue;
const cur = resolvePath(params, f.path);
if (cur && typeof cur === 'object' && !Array.isArray(cur)) {
cur.runtimeShow = true;
if (startNodeId && cur.valueSource && typeof cur.valueSource === 'object') {
// 移除指向开始节点(表单展示标记)的引用;兼容旧 DSL 单对象(统一数组化),全部移除后删除整个 valueSource
const arr = Array.isArray(cur.valueSource) ? cur.valueSource : [cur.valueSource];
const keep = arr.filter((r: any) => !(r && r.nodeId === startNodeId));
if (keep.length === 0) delete cur.valueSource;
else cur.valueSource = keep;
}
}
}
}
// 勾选「表单展示」的字段:保存前按后端契约补 valueSource={nodeId:开始节点id, field:path}
// 标记字段值来自主工作流开始节点(首页表单)。startNodeId 为空(无开始节点)时跳过。
export function attachFormFieldValueSources(params: any, fields: ExposedField[] | null | undefined, startNodeId = ''): void {
if (!params || typeof params !== 'object' || !Array.isArray(fields) || fields.length === 0 || !startNodeId) return;
for (const f of fields) {
if (!f || typeof f.path !== 'string') continue;
const cur = resolvePath(params, f.path);
if (cur && typeof cur === 'object' && !Array.isArray(cur)) {
// 已有指向上游节点的真实引用时保持互斥:不覆盖为开始节点标记,避免引用丢失
const vs = cur.valueSource;
const hasRealRef = Array.isArray(vs)
? vs.some((r: any) => r && r.nodeId && r.nodeId !== startNodeId)
: !!vs && typeof vs === 'object' && vs.nodeId && vs.nodeId !== startNodeId;
if (!hasRealRef) {
cur.valueSource = [{ nodeId: startNodeId, field: f.path }];
}
}
}
}
// 按路径走回 params 树;解析失败(schema 变更导致字段缺失)返回 undefined
function resolvePath(params: any, path: string): any {
if (!path) return undefined;
const segments = path.split('.');
let cur = params;
for (const seg of segments) {
if (cur === undefined || cur === null || typeof cur !== 'object' || Array.isArray(cur)) return undefined;
if (seg === 'attrs') {
cur = cur.attrs;
if (!cur || typeof cur !== 'object') return undefined;
} else {
const mv = seg.match(/^value\[(\d+)\]$/);
if (mv) {
const idx = Number(mv[1]);
if (!Array.isArray(cur.value) || idx >= cur.value.length) return undefined;
cur = cur.value[idx];
continue;
}
const me = seg.match(/^enumValues\[(\d+)\]$/);
if (me) {
const idx = Number(me[1]);
const templates = arrayTemplatesOf(cur);
// 路径索引可能为 strip 压缩前记录的原始索引(__originIndex),优先按原始索引匹配;
// 未带 __originIndex 时退化为按数组位置匹配(未压缩场景)
const byOrigin = templates.find((t: any) => t && typeof t.__originIndex === 'number' && t.__originIndex === idx);
if (byOrigin !== undefined) cur = byOrigin;
else if (idx < templates.length) cur = templates[idx];
else return undefined;
continue;
}
cur = cur[seg];
}
}
return cur;
}
// 深度比较(JSON 序列化方式),供 index.vue 的"仅变更才写"守卫使用
export function isEqual(a: any, b: any): boolean {
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
}
// ===== 模型返回参数中文 label(引用展示用)=====
// 模型 responseMapping(响应映射:路径→中文)支持两种结构:普通对象 {路径: 中文} 与
// { type, attrs/value, label } 包裹格式。独立实现平铺(不依赖 settings/modelConfigV2),
// 供保存 modelResponseBodyMapping 时把中文描述填入 value,引用上级节点输出展示中文而非英文 key。
function flattenResponseLabelMap(mapping: any, basePath = '', out: Record<string, string>): void {
if (!mapping || typeof mapping !== 'object') return;
// { type, attrs/value } 包裹格式:叶子节点带 label
if (typeof mapping.type === 'string' && ['string', 'number', 'boolean', 'null', 'object', 'array'].includes(mapping.type)) {
const jtype = mapping.type;
const dataKey = jtype === 'object' || jtype === 'array' ? 'attrs' : 'value';
if (jtype === 'object' && dataKey in mapping && mapping[dataKey] && typeof mapping[dataKey] === 'object' && !Array.isArray(mapping[dataKey])) {
for (const [key, val] of Object.entries(mapping[dataKey])) {
flattenResponseLabelMap(val, basePath ? `${basePath}.attrs.${key}` : key, out);
}
} else if (jtype === 'array' && dataKey in mapping && Array.isArray(mapping[dataKey])) {
mapping[dataKey].forEach((item: any, idx: number) => {
flattenResponseLabelMap(item, `${basePath}.attrs[${idx}]`, out);
});
} else if (['string', 'number', 'boolean', 'null'].includes(jtype)) {
if (mapping.label) out[basePath] = String(mapping.label);
}
return;
}
// 普通对象(非包裹格式)
if (!Array.isArray(mapping)) {
for (const [key, val] of Object.entries(mapping)) {
const childPath = basePath ? `${basePath}.${key}` : key;
if (val && typeof val === 'object' && !Array.isArray(val)) {
flattenResponseLabelMap(val, childPath, out);
} else if (typeof val === 'string' && val) {
out[childPath] = val;
}
}
}
}
// 用模型 responseMapping 的中文描述填充 modelResponseBodyMapping 的 value
// { key: '' } → { key: '中文' }。结构不变(对象 key→string),无对应中文时保留原 value(兜底 key)。
export function enrichResponseBodyMapping(body: any, mapping: any): any {
if (!body || typeof body !== 'object' || Array.isArray(body)) return body ?? null;
const labelMap: Record<string, string> = {};
flattenResponseLabelMap(mapping, '', labelMap);
const out: Record<string, string> = {};
for (const k of Object.keys(body)) {
const raw = typeof body[k] === 'string' ? body[k] : '';
out[k] = labelMap[k] || raw || k;
}
return out;
}