mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
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:
co-authored by
lefarcen
parent
3e9604979f
commit
4bcdddf331
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user