fix(web): render question-form array payloads (#5757) (#5801)

Generated-By: looper 0.0.0-dev (runner=worker, agent=codex)
(cherry picked from commit 188ae72f8e)

Co-authored-by: lefarcen <935902669@qq.com>
This commit is contained in:
open-design-release-bot[bot]
2026-07-17 07:29:49 +00:00
committed by GitHub
co-authored by lefarcen
parent 3e9604979f
commit 4bcdddf331
2 changed files with 139 additions and 26 deletions
+65 -24
View File
@@ -126,6 +126,9 @@ export type FormSegment =
| { kind: 'text'; text: string }
| { kind: 'form'; form: QuestionForm; raw: string };
const INVALID_QUESTION_FORM_FALLBACK =
'The assistant sent a question form that could not be rendered. Please ask it to resend the questions.';
// `question-form` is the canonical tag; `ask-question` is an alias the
// model occasionally drifts to (issue #1194). The close tag must match
// the open tag name, so each match captures the name and computes its
@@ -137,8 +140,9 @@ export function splitOnQuestionForms(input: string): FormSegment[] {
const out: FormSegment[] = [];
let cursor = 0;
// Scan repeatedly for question-form / ask-question opens; for each,
// locate the matching close tag and try to parse the JSON body.
// Anything that doesn't parse cleanly stays in the prose stream.
// locate the matching close tag and try to parse the JSON body. Complete
// protocol blocks that fail parsing render a safe fallback instead of
// leaking their raw JSON into prose.
while (cursor < input.length) {
const slice = input.slice(cursor);
const m = OPEN_RE.exec(slice);
@@ -176,10 +180,10 @@ export function splitOnQuestionForms(input: string): FormSegment[] {
}
const body = input.slice(openEnd, closeIdx);
const attrs = parseAttrs(m[2] ?? '');
const form = tryParseForm(body, attrs);
const parseResult = parseForm(body, attrs);
const blockEnd = closeIdx + closeTag.length;
if (form) {
out.push({ kind: 'form', form, raw: input.slice(openStart, blockEnd) });
if (parseResult.form) {
out.push({ kind: 'form', form: parseResult.form, raw: input.slice(openStart, blockEnd) });
cursor = blockEnd;
} else {
// The body between this open tag and the matched close tag isn't valid
@@ -196,7 +200,8 @@ export function splitOnQuestionForms(input: string): FormSegment[] {
out.push({ kind: 'text', text: input.slice(openStart, resumeAt) });
cursor = resumeAt;
} else {
out.push({ kind: 'text', text: input.slice(openStart, blockEnd) });
recordQuestionFormParseFailure(parseResult.reason, tagName, body);
out.push({ kind: 'text', text: INVALID_QUESTION_FORM_FALLBACK });
cursor = blockEnd;
}
}
@@ -268,9 +273,20 @@ function parseAttrs(raw: string): Record<string, string> {
return out;
}
function tryParseForm(body: string, attrs: Record<string, string>): QuestionForm | null {
type FormParseFailureReason =
| 'empty-body'
| 'invalid-json'
| 'unsupported-payload'
| 'empty-questions';
interface FormParseResult {
form: QuestionForm | null;
reason?: FormParseFailureReason;
}
function parseForm(body: string, attrs: Record<string, string>): FormParseResult {
const trimmed = body.trim();
if (!trimmed) return null;
if (!trimmed) return { form: null, reason: 'empty-body' };
// Allow the JSON to be wrapped in a fenced ```json block — common when
// the model echoes its own indented body.
const stripped = trimmed
@@ -281,18 +297,22 @@ function tryParseForm(body: string, attrs: Record<string, string>): QuestionForm
try {
data = JSON.parse(stripped);
} catch {
return null;
return { form: null, reason: 'invalid-json' };
}
if (!data || typeof data !== 'object') return null;
const obj = data as Record<string, unknown>;
const rawQuestions = Array.isArray(obj.questions) ? obj.questions : null;
if (!rawQuestions) return null;
if (!data || typeof data !== 'object') return { form: null, reason: 'unsupported-payload' };
const obj = Array.isArray(data) ? {} : (data as Record<string, unknown>);
const rawQuestions = Array.isArray(data)
? data
: Array.isArray(obj.questions)
? obj.questions
: null;
if (!rawQuestions) return { form: null, reason: 'unsupported-payload' };
const questions: FormQuestion[] = [];
rawQuestions.forEach((q, i) => {
const mapped = mapRawQuestion(q, i);
if (mapped) questions.push(mapped);
});
if (questions.length === 0) return null;
if (questions.length === 0) return { form: null, reason: 'empty-questions' };
const id = attrs.id ?? (typeof obj.id === 'string' ? obj.id : 'discovery');
const title =
attrs.title ?? (typeof obj.title === 'string' ? obj.title : 'A few quick questions');
@@ -300,12 +320,14 @@ function tryParseForm(body: string, attrs: Record<string, string>): QuestionForm
const submitLabel = typeof obj.submitLabel === 'string' ? obj.submitLabel : undefined;
const lang = typeof obj.lang === 'string' && obj.lang.trim().length > 0 ? obj.lang.trim() : undefined;
return {
id,
title,
questions,
...(description ? { description } : {}),
...(submitLabel ? { submitLabel } : {}),
...(lang ? { lang } : {}),
form: {
id,
title,
questions,
...(description ? { description } : {}),
...(submitLabel ? { submitLabel } : {}),
...(lang ? { lang } : {}),
},
};
}
@@ -314,9 +336,14 @@ function mapRawQuestion(q: unknown, index: number): FormQuestion | null {
const qo = q as Record<string, unknown>;
const id =
typeof qo.id === 'string' && qo.id.trim().length > 0 ? qo.id.trim() : `q${index + 1}`;
const label = typeof qo.label === 'string' ? qo.label : id;
const type = normalizeType(qo.type);
const options = parseOptions(qo.options);
const label =
typeof qo.label === 'string'
? qo.label
: typeof qo.prompt === 'string'
? qo.prompt
: id;
const type = normalizeType(qo.type, options);
const placeholder = typeof qo.placeholder === 'string' ? qo.placeholder : undefined;
const help = typeof qo.help === 'string' ? qo.help : undefined;
const required = qo.required === true;
@@ -614,8 +641,8 @@ function extractBalancedObject(s: string, start: number): string | null {
return null;
}
function normalizeType(raw: unknown): QuestionType {
if (typeof raw !== 'string') return 'text';
function normalizeType(raw: unknown, options?: FormOption[]): QuestionType {
if (typeof raw !== 'string') return options && options.length > 0 ? 'radio' : 'text';
const lower = raw.toLowerCase().trim();
if (lower === 'radio' || lower === 'single' || lower === 'choice') return 'radio';
if (lower === 'checkbox' || lower === 'multi' || lower === 'multiple') return 'checkbox';
@@ -648,6 +675,18 @@ function normalizeType(raw: unknown): QuestionType {
return 'text';
}
function recordQuestionFormParseFailure(
reason: FormParseFailureReason | undefined,
tagName: string,
body: string,
): void {
console.warn('[question-form] failed to render inline question form', {
reason: reason ?? 'unsupported-payload',
tagName,
bodyLength: body.length,
});
}
function parseNumberAttr(raw: unknown): number | undefined {
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
if (typeof raw !== 'string') return undefined;
@@ -675,6 +714,8 @@ function parseOption(raw: unknown): FormOption | null {
const value =
typeof obj.value === 'string' && obj.value.trim().length > 0
? obj.value.trim()
: typeof obj.id === 'string' && obj.id.trim().length > 0
? obj.id.trim()
: label;
const description =
typeof obj.description === 'string' && obj.description.trim().length > 0
+74 -2
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import {
formatFormAnswers,
@@ -219,6 +219,66 @@ describe('splitOnQuestionForms', () => {
}
});
it('parses the deliveryFormat/container array payload from Feishu acceptance', () => {
const input = [
'要的话我可以直接按这张图出 motion overlay。先选一下输出偏好:',
'<question-form>',
JSON.stringify([
{
id: 'deliveryFormat',
prompt: '导出格式?',
type: 'radio',
options: [
{ id: 'mov', label: 'MOV(透明背景,可直接导入剪映 / PR / FCP 叠加)' },
{ id: 'webm', label: 'WebM(透明背景,适合网页 / 浏览器播放)' },
{ id: 'preview', label: '预览图 / 预览工程(适合先确认效果)' },
{ id: 'remotion', label: 'Remotion 工程(适合继续编辑和拼装)' },
],
},
{
id: 'container',
prompt: '对话外壳?',
type: 'radio',
options: [
{ id: 'none', label: '不要外壳,只要气泡' },
{ id: 'phone', label: '手机聊天界面' },
],
},
]),
'</question-form>',
].join('\n');
const out = splitOnQuestionForms(input);
expect(out.map((s) => s.kind)).toEqual(['text', 'form']);
const form = out[1]?.kind === 'form' ? out[1].form : null;
expect(form?.questions.map((q) => [q.id, q.label, q.type])).toEqual([
['deliveryFormat', '导出格式?', 'radio'],
['container', '对话外壳?', 'radio'],
]);
expect(form?.questions[0]?.options?.map((option) => option.value)).toEqual([
'mov',
'webm',
'preview',
'remotion',
]);
});
it('normalizes partially missing question fields instead of dropping the form', () => {
const out = splitOnQuestionForms(
`<question-form>${JSON.stringify([
{ prompt: 'Pick one', options: ['A', 'B'] },
{ id: 'notes', type: 'textarea' },
])}</question-form>`,
);
expect(out.map((s) => s.kind)).toEqual(['form']);
const form = out[0]?.kind === 'form' ? out[0].form : null;
expect(form?.questions).toMatchObject([
{ id: 'q1', label: 'Pick one', type: 'radio' },
{ id: 'notes', label: 'notes', type: 'textarea' },
]);
});
it('accepts <ask-question> as an alias for <question-form> (#1194)', () => {
const out = splitOnQuestionForms(`<ask-question id="brief" title="Quick brief">${VALID_BODY}</ask-question>`);
expect(out.map((s) => s.kind)).toEqual(['form']);
@@ -239,9 +299,21 @@ describe('splitOnQuestionForms', () => {
expect(out.map((s) => s.kind)).toEqual(['text']);
});
it('keeps malformed JSON bodies as raw text', () => {
it('replaces malformed JSON bodies with a safe fallback and diagnostics', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const out = splitOnQuestionForms(`<ask-question>not json</ask-question>`);
expect(out.map((s) => s.kind)).toEqual(['text']);
expect(out[0]).toMatchObject({
kind: 'text',
text: 'The assistant sent a question form that could not be rendered. Please ask it to resend the questions.',
});
expect(out[0]?.kind === 'text' ? out[0].text : '').not.toContain('<ask-question>');
expect(out[0]?.kind === 'text' ? out[0].text : '').not.toContain('not json');
expect(warn).toHaveBeenCalledWith(
'[question-form] failed to render inline question form',
expect.objectContaining({ reason: 'invalid-json', tagName: 'ask-question' }),
);
warn.mockRestore();
});
it('keeps unterminated tags as prose without swallowing trailing text', () => {