Files
open-design-crew[bot] 98f2fd1a46 fix(chat): hide undesigned cards and auxiliary message panels (#7956)
* fix(chat): render cards inside execution records

Render supported od-card payloads inside execution records with the existing
card components, instance scopes, and browser-assistance callback. Preserve
Markdown code examples and isolate card payloads from later Markdown parsing.

* test(web): await writable scope before deleting a conversation

* fix(web): buffer streamed card opener prefixes

* fix(web): remove undesigned ChatPanel cards (OPEND-2971)

Remove task-brief and rule-proposal presentation and their exclusive UI,
styles, and translations following the explicit product decision.
Preserve quoted card examples, neighboring prose, and the three retained
card kinds, including browser-assist callbacks.

* fix(chat): stop generating undesigned brief and rule cards

* test(web): use supported role query options for removed cards

* fix(chat): hide retired brand browser assistance cards

* fix(web): hide undesigned assistant message panels

* test(web): specify the brand project kind in hidden UI fixture

* style(web): remove trailing blank line after panel styles

* fix(web): hide verification scorecards in ChatPanel

* test(chat): align retired UI expectations with approved hiding

* fix(chat): keep card payloads opaque to question form parsing

* fix(web): preserve card parsing across replay metadata

Keep marker masking aligned with physical run boundaries, and replace repeated
Markdown tail scans with a forward indexed walk. Preserve actual forms and raw
code examples with replay and paragraph-boundary regression coverage.

* fix(web): keep form field syntax inside its payload

Recognize complete forms through the existing parser before scanning their
fields for card or code markers. Preserve sibling form actions and keep outer
card/code ownership with callback-based parsing that avoids an import cycle.

* fix(chat): share question form protocol context with host detection

---------

Co-authored-by: lefarcen <935902669@qq.com>
2026-09-10 10:24:07 +00:00

1245 lines
49 KiB
TypeScript
Raw Permalink 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.
/**
* Parser for inline <question-form>...</question-form> blocks the agent
* emits to ask the user a structured set of clarifying questions before
* starting design work.
*
* Canonical bodies are JSON. A narrow legacy reader also accepts the
* `question-select` / `question-text` child-tag shape already persisted in
* older conversations. New output must keep using JSON. Example:
*
* <question-form id="discovery" title="Quick brief">
* {
* "questions": [
* { "id": "platform", "label": "Platform", "type": "radio",
* "options": ["Mobile (iOS/Android)", "Desktop web", "Responsive"],
* "required": true },
* { "id": "audience", "label": "Primary audience", "type": "text",
* "placeholder": "e.g. SaaS buyers" }
* ]
* }
* </question-form>
*
* `<ask-question>...</ask-question>` is accepted as an alias for
* `<question-form>`, so a model that drifts to the colloquial tag
* name still renders correctly instead of leaking raw markup into
* prose (see issue #1194).
*
* Splits a final assistant text payload into ordered segments — prose +
* forms — so AssistantMessage can render the form inline.
*/
import { parsePartialJson } from '../runtime/partial-json';
import { chatProtocolSkipRanges } from './chat-protocol-context';
import { rangeContains, type Range } from './markdown-context';
export type QuestionType =
| 'radio'
| 'checkbox'
| 'select'
| 'text'
| 'textarea'
| 'number'
| 'range'
| 'date'
| 'time'
| 'datetime-local'
| 'color'
| 'url'
| 'email'
| 'tel'
| 'file'
| 'switch'
| 'direction-cards';
/**
* 颜色答案的规范形 —— **唯一**的一处实现。
*
* 规范形是 `#` + **6 位小写** hex,`null` 表示「这不是一个颜色」。
*
* 为什么小写:原生 `<input type="color">` 的 value sanitization algorithm 会把值
* 小写化。规范形若定成大写,受控组件每一帧都在和 DOM 打架 —— props 写下
* `#3B82F6`,读回来是 `#3b82f6`,两边永远对不上。交付稿本身也全篇小写。
*
* 为什么只收 6 位:交付稿 `interactions.js` 的正则就是 `^#[0-9a-f]{6}$`。
* alpha(`#rrggbbaa`)和 3 位简写(`#abc`)一概判非法 —— 答案是要作为**文本**
* 发回给模型、并被历史回放的,多一种形态就多一种下游要认的东西;悄悄收下它们
* 等于扩大协议。要改成收,得先有产品裁决。
*
* 输入侧只留一处宽容:允许缺 `#`(粘贴 `3b82f6`)。这不构成协议扩大 —— 吐出去的
* 仍然只有一种形态。
*/
export function normalizeHexColor(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
const trimmed = raw.trim();
if (!trimmed) return null;
const body = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed;
if (!/^[0-9a-fA-F]{6}$/.test(body)) return null;
return `#${body.toLowerCase()}`;
}
/**
* Rich card metadata for a single `direction-cards` option. The picker
* renders a swatch row, a serif/sans type sample, a mood blurb, and a
* "refs" line so users can scan visually instead of squinting at radio
* labels. This remains a legacy-compatibility payload: current catalog-backed
* forms omit it and let the host supply versioned cards for the project kind.
*/
export interface DirectionCard {
/** Legacy radio value returned in the answer; matches an option when legacy options exist. */
id: string;
/** Short headline on the card (e.g. "Editorial — Monocle / FT magazine"). */
label: string;
/** One- or two-sentence mood blurb. */
mood: string;
/** Real-world exemplars (≤ 4). */
references: string[];
/** 4–6 swatch hex / OKLch strings for the palette row. */
palette: string[];
/** Display (headline) font stack, used to render the live "Aa" sample. */
displayFont: string;
/** Body font stack, used to render the secondary sample. */
bodyFont: string;
}
export interface FormOption {
label: string;
value: string;
description?: string;
/**
* 这一项归属的分组名。**只对「查找型单选」有意义**,而且一律可选 ——
* 缺了就是今天那条平铺的选项列表,逐元素一致。
*
* 渲染约定只有一条,不需要第二个字段来描述层级:**第一个出现的分组直接展开
* 并带上组名,其后的每一组各自收在一个开关后面,开关上的字就是那一组的组名。**
* 交付稿的「常用语言 / 更多语言」正好是这条规则下的两组;host 因此不必编一句
* 「更多选项」压在模型自己的措辞上。
*/
group?: string;
/**
* 行尾那枚副标(交付稿里是 `ZH-CN` 这种语言代码)。一律可选。
* 它是**给人扫读用的短标记**,不是答案:提交出去的仍然是 `value`。
*/
trailingLabel?: string;
/** Host-only context returned to the agent for a catalog-backed visual choice. */
foundationDirectionId?: string;
/** Host-only refinement text returned with the selected visual choice. */
agentGuidance?: string;
}
export interface FormQuestion {
id: string;
label: string;
type: QuestionType;
options?: FormOption[];
placeholder?: string;
required?: boolean;
/**
* **休眠件** —— 解析进来,但**没有任何人再读它**。
*
* 每题副标题。曾经渲染在题目和控件之间。
*
* 什么时候、因为什么停用:
* · 2026-09-07(OPEND-2707 ①,已合并):`QuestionForm.tsx` 停止渲染它,
* `styles/viewer/composio.css` 的 `.qf-help` 规则一并删除。一道题就是
* 「题目 + 必填标识 + 控件」,副标题夹在中间读起来像卡片自己的旁白,
* 还占着一整行的行盒。
* · 2026-09-08(OPEND-2707 ②,用户拍板「改彻底,提示词也改」):
* 提示词不再要求模型写这个字段,宿主自己写的唯一一条(ElevenLabs 选音色题)
* 并进了那道题的 `label`。全仓自此**没有生产者**。
*
* 为什么字段还留着:删掉它会让 `tests/components/QuestionForm.no-question-subtitle.test.tsx`
* 和 `tests/components/QuestionForm.test.tsx` 编译不过 —— 前者正是「副标题不再
* 渲染」这条不变量的唯一正面证据。同时,缓存的旧提示词 / 旧客户端 / 模型记住的
* 旧格式仍可能发来带这个键的表单,解析器继续容忍它,这类输入的解析形状才不会变。
* 参照 `specs/current/chat-panel-decisions-sheet.md`「六个 qf.visual* 键一个不删」。
*
* 怎么找回:渲染那一半的原文在 `4c5873c7cc`(#7863)的 parent 里 ——
* `git show 4c5873c7cc^:apps/web/src/components/QuestionForm.tsx` 拿渲染分支,
* `git show 4c5873c7cc^:apps/web/src/styles/viewer/composio.css` 拿 `.qf-help` 规则。
* 提示词那一半要同时撤掉 `e2e/tests/question-form-help-retired.test.ts` 的守卫,
* 那需要一次新的产品裁决。
*/
help?: string;
defaultValue?: string | string[];
/** Only applies when `type === 'checkbox'`. Caps the number of selected options. */
maxSelections?: number;
/**
* For finite-choice controls, show a free-form override beside the generated
* options so the user can take over a choice instead of being trapped by the
* model's presets.
*/
allowCustom?: boolean;
customLabel?: string;
customPlaceholder?: string;
/** Numeric/range inputs only. */
min?: number;
max?: number;
step?: number;
/** File inputs only. The answer serializes selected file names, not bytes. */
multiple?: boolean;
/** File inputs only. Mirrors the native file input accept attribute. */
accept?: string;
/** Legacy compatibility for `direction-cards`; current host-owned forms omit it. */
cards?: DirectionCard[];
}
export interface QuestionForm {
id: string;
title: string;
questions: FormQuestion[];
submitLabel?: string;
/**
* BCP-47 tag of the language the model localized the form into (e.g.
* "zh-CN"). Host-rendered strings inside the form card (the "Other" chip,
* custom input copy) follow this language so a Chinese form in an English
* UI doesn't mix scripts; absent → the app UI locale.
*/
lang?: string;
}
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
// own close-tag string. Treat the lookup case-insensitively at scan
// time so `<Question-Form>` and `<ASK-QUESTION>` still parse.
const OPEN_RE = /<(question-form|ask-question)\b([^>]*)>/i;
/** Recognize only an existing complete form; never recurse into the scanner. */
export function readQuestionFormPayloadAt(input: string, openStart: number): Range | null {
const match = OPEN_RE.exec(input.slice(openStart));
if (!match || match.index !== 0) return null;
const openEnd = openStart + match[0].length;
const closeTag = `</${(match[1] ?? 'question-form').toLowerCase()}>`;
const closeStart = findCloseTag(input, openEnd, closeTag);
if (closeStart < 0) return null;
const body = input.slice(openEnd, closeStart);
if (!parseForm(body, parseAttrs(match[2] ?? '')).form) return null;
return [openEnd, closeStart + closeTag.length];
}
export function splitOnQuestionForms(input: string): FormSegment[] {
const out: FormSegment[] = [];
const protectedRanges = chatProtocolSkipRanges(input, readQuestionFormPayloadAt);
let cursor = 0;
let searchFrom = 0;
// Scan repeatedly for question-form / ask-question opens; for each,
// 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(input.slice(searchFrom));
if (!m) {
out.push({ kind: 'text', text: slice });
break;
}
const tagName = (m[1] ?? 'question-form').toLowerCase();
const closeTag = `</${tagName}>`;
const openStart = searchFrom + m.index;
const openEnd = openStart + m[0].length;
if (rangeContains(protectedRanges, openStart)) {
searchFrom = openEnd;
continue;
}
const closeIdx = findCloseTag(input, openEnd, closeTag);
if (closeIdx === -1) {
// No matching close tag found for this open tag name. The body may
// contain an open tag of the other name (e.g. prose mentioned
// <ask-question> but the real form uses <question-form>). Try to
// unwind to an inner open before giving up.
const remainder = input.slice(openEnd);
const nestedOpen = OPEN_RE.exec(remainder);
if (nestedOpen) {
const resumeAt = openEnd + nestedOpen.index;
if (openStart > cursor) {
out.push({ kind: 'text', text: input.slice(cursor, openStart) });
}
out.push({ kind: 'text', text: input.slice(openStart, resumeAt) });
cursor = resumeAt;
searchFrom = cursor;
continue;
}
// Genuinely unterminated — leave the rest as prose.
out.push({ kind: 'text', text: slice });
break;
}
if (openStart > cursor) {
out.push({ kind: 'text', text: input.slice(cursor, openStart) });
}
const body = input.slice(openEnd, closeIdx);
const attrs = parseAttrs(m[2] ?? '');
const parseResult = parseForm(body, attrs);
const blockEnd = closeIdx + closeTag.length;
if (parseResult.form) {
out.push({ kind: 'form', form: parseResult.form, raw: input.slice(openStart, blockEnd) });
cursor = blockEnd;
searchFrom = cursor;
} else {
// The body between this open tag and the matched close tag isn't valid
// JSON. If the body itself contains another question-form / ask-question
// open tag, the outer match was a false positive (e.g. the model
// mentioned the tag name inside backtick-quoted prose). Unwind to the
// nested open so it gets a clean parse on the next iteration.
const nestedOpen = OPEN_RE.exec(body);
if (nestedOpen) {
const resumeAt = openEnd + nestedOpen.index;
// Text before the false-positive tag was already emitted above.
// Slice from openStart so only the tag and the gap up to the
// nested open are emitted — no duplication.
out.push({ kind: 'text', text: input.slice(openStart, resumeAt) });
cursor = resumeAt;
searchFrom = cursor;
} else {
recordQuestionFormParseFailure(parseResult.reason, tagName, body);
out.push({ kind: 'text', text: INVALID_QUESTION_FORM_FALLBACK });
cursor = blockEnd;
searchFrom = cursor;
}
}
}
return out;
}
// First parseable form in a message, used by callers that need to inspect the
// active discovery form without duplicating the renderer's split logic.
export function findFirstQuestionForm(
input: string,
): { form: QuestionForm; raw: string } | null {
for (const seg of splitOnQuestionForms(input)) {
if (seg.kind === 'form') return { form: seg.form, raw: seg.raw };
}
return null;
}
// Drop a trailing, not-yet-closed question-form block from streaming text so
// the chat doesn't flash raw `<question-form>{…` markup before the JSON
// finishes. Returns the visible text plus whether such an open block existed
// (which means a form is mid-generation).
export function stripTrailingOpenQuestionForm(
input: string,
): { text: string; hadOpenForm: boolean } {
const protectedRanges = chatProtocolSkipRanges(input, readQuestionFormPayloadAt);
let cursor = 0;
while (cursor < input.length) {
const slice = input.slice(cursor);
const m = OPEN_RE.exec(slice);
if (!m) break;
const tagName = (m[1] ?? 'question-form').toLowerCase();
const closeTag = `</${tagName}>`;
const openStart = cursor + m.index;
const openEnd = openStart + m[0].length;
if (rangeContains(protectedRanges, openStart)) {
cursor = openEnd;
continue;
}
const closeIdx = findCloseTag(input, openEnd, closeTag);
if (closeIdx === -1) {
if (!couldCompleteAsQuestionFormBody(input.slice(openEnd))) {
// Prose, not a form body: the tag is narration the model typed
// inline. Keep scanning past it so a genuine form later in the
// message still wins, and leave every character visible.
cursor = openEnd;
continue;
}
return { text: input.slice(0, openStart), hadOpenForm: true };
}
cursor = closeIdx + closeTag.length;
}
return { text: input, hadOpenForm: false };
}
// True when a question-form open tag is present but its close tag hasn't
// streamed in yet — i.e. the model is still generating the form.
export function hasUnterminatedQuestionForm(input: string): boolean {
return stripTrailingOpenQuestionForm(input).hadOpenForm;
}
/**
* The single precondition every "is a form in flight?" decision shares: an
* open `<question-form>` / `<ask-question>` tag only means a form is being
* generated while the text after it could still grow into a valid form body.
*
* {@link parseForm} accepts exactly one grammar — JSON, optionally wrapped in
* a ```` ```json ```` fence, that parses to an object carrying a `questions`
* array (or to a bare array of questions). A tail is therefore still a
* candidate only while it is empty (nothing has streamed past the tag yet),
* is a fence opener that has not finished arriving, or begins a JSON object
* or array.
*
* A tail that opens with prose — the model narrating "no questions needed"
* straight into the tag instead of emitting a form — can never satisfy that
* grammar. Such a tag is ordinary text: it must not raise a loading skeleton,
* must not hold the turn in a clarification handshake, and must not hide the
* narration that follows it.
*/
export function couldCompleteAsQuestionFormBody(tail: string): boolean {
const body = stripLeadingJsonFence(tail).trim();
if (body.length === 0) return true;
if (body.startsWith('{') || body.startsWith('[')) return true;
return couldCompleteAsLegacyQuestionFormBody(body);
}
const LEGACY_QUESTION_TAGS = ['<question-select', '<question-text'] as const;
function couldCompleteAsLegacyQuestionFormBody(body: string): boolean {
const lower = body.toLowerCase();
return LEGACY_QUESTION_TAGS.some(
(tag) => tag.startsWith(lower) || lower.startsWith(tag),
);
}
// Consume a leading ```` ```json ```` fence, including one that is itself only
// partially streamed (anything from a lone backtick through ```` ```json ````).
// Whatever the fence does not cover is left for the JSON check.
function stripLeadingJsonFence(tail: string): string {
const m = /^\s*`{1,3}(?:json|jso|js|j)?[^\S\n]*\n?/.exec(tail);
return m ? tail.slice(m[0].length) : tail;
}
/**
* True when the content actually asks the user something through the form
* protocol: a closed `<question-form>` block, or an open tag whose body could
* still complete into one (see {@link couldCompleteAsQuestionFormBody}).
*
* A stray open tag followed by prose is narration, not an ask, so callers that
* classify a turn as "awaiting user input" must not be tripped by it.
*/
export function containsQuestionFormAsk(input: string): boolean {
let cursor = 0;
while (cursor < input.length) {
const m = OPEN_RE.exec(input.slice(cursor));
if (!m) return false;
const tagName = (m[1] ?? 'question-form').toLowerCase();
const openEnd = cursor + m.index + m[0].length;
const closeTag = `</${tagName}>`;
const closeIdx = findCloseTag(input, openEnd, closeTag);
if (closeIdx === -1) {
const nestedOpen = OPEN_RE.exec(input.slice(openEnd));
if (nestedOpen) {
cursor = openEnd + nestedOpen.index;
continue;
}
return couldCompleteAsQuestionFormBody(input.slice(openEnd));
}
const body = input.slice(openEnd, closeIdx);
if (parseForm(body, parseAttrs(m[2] ?? '')).form) return true;
const nestedOpen = OPEN_RE.exec(body);
cursor = nestedOpen
? openEnd + nestedOpen.index
: closeIdx + closeTag.length;
}
return false;
}
/**
* True when a complete protocol block was emitted but cannot render.
*
* This is intentionally separate from {@link containsQuestionFormAsk}: an
* invalid closed form is neither a text answer nor a clarification handshake.
* Delivery classification uses this signal to avoid turning a protocol error
* into a green, report-only success. Unterminated bodies are excluded because
* they may still be arriving while run finalization and SSE delivery race.
*/
export function containsUnrenderableQuestionForm(input: string): boolean {
let cursor = 0;
while (cursor < input.length) {
const m = OPEN_RE.exec(input.slice(cursor));
if (!m) return false;
const tagName = (m[1] ?? 'question-form').toLowerCase();
const closeTag = `</${tagName}>`;
const openEnd = cursor + m.index + m[0].length;
const closeIdx = findCloseTag(input, openEnd, closeTag);
if (closeIdx === -1) {
const nestedOpen = OPEN_RE.exec(input.slice(openEnd));
if (!nestedOpen) return false;
cursor = openEnd + nestedOpen.index;
continue;
}
const body = input.slice(openEnd, closeIdx);
if (parseForm(body, parseAttrs(m[2] ?? '')).form) {
cursor = closeIdx + closeTag.length;
continue;
}
const nestedOpen = OPEN_RE.exec(body);
if (!nestedOpen) return true;
cursor = openEnd + nestedOpen.index;
}
return false;
}
function findCloseTag(input: string, from: number, closeTag: string): number {
const closeLower = closeTag.toLowerCase();
const tagLen = closeTag.length;
const maxStart = input.length - tagLen;
for (let i = from; i <= maxStart; i++) {
if (input.slice(i, i + tagLen).toLowerCase() === closeLower) {
return i;
}
}
return -1;
}
function parseAttrs(raw: string): Record<string, string> {
const re = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
const out: Record<string, string> = {};
let m: RegExpExecArray | null;
while ((m = re.exec(raw)) !== null) {
out[m[1] as string] = (m[2] ?? m[3] ?? '') as string;
}
return out;
}
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 { 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
.replace(/^```(?:json)?\s*/i, '')
.replace(/```\s*$/i, '')
.trim();
let data: unknown;
try {
data = JSON.parse(stripped);
} catch {
const legacyForm = parseLegacyForm(body, attrs);
return legacyForm
? { form: legacyForm }
: { form: null, reason: 'invalid-json' };
}
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 { 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');
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 {
form: {
id,
title,
questions,
...(submitLabel ? { submitLabel } : {}),
...(lang ? { lang } : {}),
},
};
}
/**
* Compatibility reader for the child-tag protocol persisted by older runs.
* It is deliberately narrow: the body must contain only `question-select` or
* `question-text` children (plus whitespace), and select options must be
* balanced `<option>` elements. Arbitrary XML must remain unrenderable.
*/
function parseLegacyForm(
body: string,
attrs: Record<string, string>,
): QuestionForm | null {
const questions: FormQuestion[] = [];
const childRe = /<(question-select|question-text)\b([^>]*?)(\/?)>/gi;
let cursor = 0;
let match: RegExpExecArray | null;
while ((match = childRe.exec(body)) !== null) {
if (body.slice(cursor, match.index).trim()) return null;
const tagName = (match[1] ?? '').toLowerCase();
const questionAttrs = parseAttrs(match[2] ?? '');
const selfClosing = match[3] === '/';
const openEnd = match.index + match[0].length;
let inner = '';
let nextCursor = openEnd;
if (!selfClosing) {
const closeTag = `</${tagName}>`;
const closeIdx = findCloseTag(body, openEnd, closeTag);
if (closeIdx === -1) return null;
inner = body.slice(openEnd, closeIdx);
nextCursor = closeIdx + closeTag.length;
}
const id = cleanLegacyText(questionAttrs.id) || `q${questions.length + 1}`;
const labelAttr =
cleanLegacyText(questionAttrs.label) ||
cleanLegacyText(questionAttrs.prompt) ||
cleanLegacyText(questionAttrs.question);
const required = questionAttrs.required?.toLowerCase() === 'true';
const placeholder = cleanLegacyText(questionAttrs.placeholder);
if (tagName === 'question-select') {
const parsed = parseLegacyOptions(inner);
if (!parsed || parsed.options.length === 0) return null;
const label = labelAttr || parsed.leadingText || id;
questions.push({
id,
label,
type: 'select',
options: parsed.options,
...(required ? { required: true } : {}),
...(placeholder ? { placeholder } : {}),
});
} else {
if (/<[^>]+>/.test(inner)) return null;
const label = labelAttr || cleanLegacyText(inner) || id;
questions.push({
id,
label,
type: 'text',
...(required ? { required: true } : {}),
...(placeholder ? { placeholder } : {}),
});
}
cursor = nextCursor;
childRe.lastIndex = nextCursor;
}
if (questions.length === 0 || body.slice(cursor).trim()) return null;
return {
id: cleanLegacyText(attrs.id) || 'discovery',
title: cleanLegacyText(attrs.title) || 'A few quick questions',
questions,
};
}
function parseLegacyOptions(
inner: string,
): { options: FormOption[]; leadingText?: string } | null {
const optionRe = /<option\b([^>]*)>([\s\S]*?)<\/option\s*>/gi;
const options: FormOption[] = [];
let cursor = 0;
let leadingText: string | undefined;
let match: RegExpExecArray | null;
while ((match = optionRe.exec(inner)) !== null) {
const rawBetween = inner.slice(cursor, match.index);
if (/<[^>]+>/.test(rawBetween)) return null;
const between = cleanLegacyText(rawBetween);
if (between) {
if (options.length > 0 || leadingText) return null;
leadingText = between;
}
const optionAttrs = parseAttrs(match[1] ?? '');
const label = cleanLegacyText(match[2] ?? '');
if (!label || /<[^>]+>/.test(match[2] ?? '')) return null;
const value =
cleanLegacyText(optionAttrs.value) || cleanLegacyText(optionAttrs.id) || label;
options.push({ label, value });
cursor = match.index + match[0].length;
}
const trailing = inner.slice(cursor);
if (/<[^>]+>/.test(trailing) || cleanLegacyText(trailing)) return null;
return { options, ...(leadingText ? { leadingText } : {}) };
}
function cleanLegacyText(value: string | undefined): string {
if (!value) return '';
return value
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'")
.replace(/&amp;/g, '&')
.replace(/\s+/g, ' ')
.trim();
}
function mapRawQuestion(q: unknown, index: number): FormQuestion | null {
if (!q || typeof q !== 'object') return 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 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;
const maxSelections =
typeof qo.maxSelections === 'number' &&
Number.isInteger(qo.maxSelections) &&
qo.maxSelections > 0
? qo.maxSelections
: undefined;
const cards = parseDirectionCards(qo.cards);
const defaultValue = normalizeDefaultValueForType(type, parseDefaultValue(qo, options));
const allowCustom =
qo.allowCustom === false
? false
: qo.allowCustom === true || qo.custom === true
? true
: undefined;
const customLabel = typeof qo.customLabel === 'string' ? qo.customLabel : undefined;
const customPlaceholder =
typeof qo.customPlaceholder === 'string' ? qo.customPlaceholder : undefined;
const min = parseNumberAttr(qo.min);
const max = parseNumberAttr(qo.max);
const step = parseNumberAttr(qo.step);
const multiple = qo.multiple === true;
const accept = typeof qo.accept === 'string' ? qo.accept : undefined;
return {
id,
label,
type,
...(options ? { options } : {}),
...(placeholder ? { placeholder } : {}),
...(help ? { help } : {}),
...(required ? { required } : {}),
...(defaultValue !== undefined ? { defaultValue } : {}),
...(maxSelections !== undefined && type === 'checkbox' ? { maxSelections } : {}),
...(allowCustom !== undefined ? { allowCustom } : {}),
...(customLabel ? { customLabel } : {}),
...(customPlaceholder ? { customPlaceholder } : {}),
...(min !== undefined ? { min } : {}),
...(max !== undefined ? { max } : {}),
...(step !== undefined ? { step } : {}),
...(multiple && type === 'file' ? { multiple } : {}),
...(accept && type === 'file' ? { accept } : {}),
...(cards ? { cards } : {}),
};
}
/**
* Tolerant parser for a still-streaming `<question-form>` block. Unlike
* {@link tryParseForm} it does not require valid, complete JSON: it reads the
* title/id from the open tag's attrs (available the instant the tag streams in)
* and extracts however many *complete* question objects have arrived so far.
* This lets the chat render a frame immediately and fill questions in
* progressively as the model streams them, instead of flashing raw JSON and
* then a finished form. Returns null only when no open tag is present.
*/
export function parsePartialQuestionForm(input: string): QuestionForm | null {
const m = OPEN_RE.exec(input);
if (!m) return null;
const tagName = (m[1] ?? 'question-form').toLowerCase();
const closeTag = `</${tagName}>`;
const openEnd = m.index + m[0].length;
const attrs = parseAttrs(m[2] ?? '');
const closeIdx = findCloseTag(input, openEnd, closeTag);
const rawBody = closeIdx === -1 ? input.slice(openEnd) : input.slice(openEnd, closeIdx);
if (couldCompleteAsLegacyQuestionFormBody(rawBody.trim())) {
const completed = parseLegacyForm(rawBody, attrs);
return completed ?? {
id: cleanLegacyText(attrs.id) || 'discovery',
title: cleanLegacyText(attrs.title) || 'A few quick questions',
questions: [],
};
}
// Strip the fenced ```json wrapper some models emit. The opening fence is
// removed always; the trailing fence is removed too once it streams in
// (possibly only a partial ``` so far) — otherwise the leftover backticks
// make the JSON unparseable in the gap between "fence closed" and
// "</question-form> arrived", dropping the live preview back to empty.
const body = stripTrailingFence(rawBody.replace(/^\s*```(?:json)?\s*/i, ''));
// Derive form-level metadata from the *parsed top-level object*, not a
// whole-body regex scan: a nested question/option `id`/`title`/`description`
// must not masquerade as the form's own. `id` keys the live Questions panel
// (see ProjectView), so a mid-stream identity change would remount it.
const parsed = parsePartialJson(body);
const top =
parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
// `id` keys the live (still-editable) Questions panel, so it must be stable
// for the whole stream. Don't adopt the *streaming* body `id`: it arrives
// char-by-char and `parsePartialJson` repairs the open string, so it would
// churn (`"d"` → `"di"` → …) and remount the panel. Adopt the body id only
// once its string literal is fully terminated — then it equals the id the
// final parse (`tryParseForm`) assigns, so there's also no preview→final
// remount. The open-tag attr (complete the instant the tag streams) wins,
// and a stable default covers the gap before any id is known.
const topTitle = typeof top.title === 'string' && top.title.trim().length > 0 ? top.title : undefined;
const id = attrs.id ?? completeTopLevelString(body, 'id') ?? 'discovery';
const title = attrs.title ?? topTitle ?? 'A few quick questions';
// Carry submitLabel through the preview too — `tryParseForm` reads it for the
// final form and `QuestionForm` renders `form.submitLabel ?? default`, so
// omitting it here makes a custom CTA flicker in only once the close tag
// arrives.
const submitLabel = typeof top.submitLabel === 'string' ? top.submitLabel : undefined;
// Adopt `lang` only once its string literal is fully terminated (same
// churn-avoidance as `id`): a partially streamed "zh-C" would briefly
// resolve to the wrong dictionary.
const lang = completeTopLevelString(body, 'lang');
const questions = shapeStreamingQuestions(top.questions, countClosedQuestionObjects(body));
return {
id,
title,
questions,
...(submitLabel ? { submitLabel } : {}),
...(lang ? { lang } : {}),
};
}
// Strip a trailing ```` ``` ```` fence (possibly only partially streamed) from
// a form body — but only when those backticks are the closing wrapper, not
// content of a JSON string value still being typed. Stripping unconditionally
// would eat real backticks from a label like `"Use ``` ..."` mid-stream.
function stripTrailingFence(body: string): string {
const m = /\s*`{1,3}\s*$/.exec(body);
if (!m) return body;
const before = body.slice(0, m.index);
// If the text before the trailing backticks ends inside an open JSON string,
// the backticks belong to that value — leave them for the repair pass.
if (endsInsideJsonString(before)) return body;
return before;
}
function endsInsideJsonString(s: string): boolean {
let inStr = false;
let esc = false;
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (inStr) {
if (esc) esc = false;
else if (c === '\\') esc = true;
else if (c === '"') inStr = false;
} else if (c === '"') {
inStr = true;
}
}
return inStr;
}
// Shape questions from a still-streaming, already-parsed `questions` array.
// Unlike a complete-objects-only pass, the repaired prefix (see
// `parsePartialJson`) means a question shows the moment its `label` (prompt)
// text exists and its options grow in one at a time — true token-by-token
// streaming, matching the question-form card. The trailing in-flight object
// with no label yet is held back (no "q1" placeholder flicker); it appears
// once its label lands.
// Return a top-level (depth-1) string field's value ONLY if its string literal
// is fully terminated in the (possibly partial) body. Used to adopt the form
// `id` from a streaming body without churn: while the value is still arriving
// it returns undefined (caller keeps the stable default); once the closing
// quote lands it returns the final value. Depth-aware so a nested question
// `id` can't be mistaken for the form's own.
function completeTopLevelString(body: string, field: string): string | undefined {
const marker = `"${field}"`;
let depth = 0;
let inStr = false;
let esc = false;
for (let i = 0; i < body.length; i++) {
const c = body[i];
if (inStr) {
if (esc) esc = false;
else if (c === '\\') esc = true;
else if (c === '"') inStr = false;
continue;
}
if (c === '{' || c === '[') {
depth++;
continue;
}
if (c === '}' || c === ']') {
depth--;
continue;
}
if (c === '"') {
if (depth === 1 && body.startsWith(marker, i)) {
let j = i + marker.length;
while (j < body.length && /\s/.test(body[j] as string)) j++;
if (body[j] !== ':') {
inStr = true; // it's a value string, not our key — skip it
continue;
}
j++;
while (j < body.length && /\s/.test(body[j] as string)) j++;
if (body[j] !== '"') return undefined; // value not a (started) string
let value = '';
let vesc = false;
for (let k = j + 1; k < body.length; k++) {
const vc = body[k] as string;
if (vesc) {
value += vc;
vesc = false;
} else if (vc === '\\') {
value += vc;
vesc = true;
} else if (vc === '"') {
try {
return JSON.parse(`"${value}"`) as string;
} catch {
return value;
}
} else {
value += vc;
}
}
return undefined; // closing quote hasn't streamed yet
}
inStr = true;
}
}
return undefined;
}
function shapeStreamingQuestions(rawQuestions: unknown, closedCount: number): FormQuestion[] {
if (!Array.isArray(rawQuestions)) return [];
const out: FormQuestion[] = [];
rawQuestions.forEach((raw, index) => {
if (!raw || typeof raw !== 'object') return;
const q = raw as Record<string, unknown>;
const label = q.label;
if (typeof label !== 'string' || label.trim().length === 0) return;
// Surface a question only once its canonical id is determinable, so the
// preview id is identical to the id the final parse assigns — `id` keys
// both the rendered field and the user's answer in the still-editable
// panel, so a mismatch would orphan an in-progress answer (mid-stream when
// a late id replaces the fallback, and again at the preview→final swap).
// - object's braces have streamed (closed) → its id is final, whether a
// real `id` or `mapRawQuestion`'s `q${index+1}` fallback → show it.
// - in-flight (last, not yet closed) object WITH an `id` → id is stable
// even as more fields stream → show it (options keep growing).
// - in-flight object with no id yet → it may still gain one; hold back.
const isClosed = index < closedCount;
const hasId = typeof q.id === 'string' && q.id.trim().length > 0;
if (!isClosed && !hasId) return;
const mapped = mapRawQuestion(raw, index);
if (mapped) {
// The trailing in-flight object may hold a MID-STREAM `default`: the
// partial-JSON repair terminates it early ("单位教育" → "单位教",
// ["历史背景与经过", "抗战精神…"] → ["历史背景与经过", "抗战精"]).
// The card adopts a streamed default only while the answer is still
// empty, so surfacing a truncated one would freeze garbage the
// completed value can never overwrite. A closed object's braces are
// balanced, so its default is complete — only then expose it.
if (!isClosed && mapped.defaultValue !== undefined) delete mapped.defaultValue;
out.push(mapped);
}
});
return out;
}
// Count how many question objects in a partial `"questions": [ … ]` body have
// their closing brace already streamed (string-aware). A closed object's id is
// final (real `id` or the `q${index+1}` fallback), so it's safe to surface;
// only the trailing still-open object might still gain an `id`.
function countClosedQuestionObjects(body: string): number {
const keyMatch = /"questions"\s*:\s*\[/.exec(body);
if (!keyMatch) return 0;
let i = keyMatch.index + keyMatch[0].length;
let count = 0;
while (i < body.length) {
while (i < body.length && /[\s,]/.test(body[i] as string)) i++;
if (i >= body.length || body[i] === ']') break;
if (body[i] !== '{') break;
const obj = extractBalancedObject(body, i);
if (!obj) break; // trailing object hasn't closed yet
count++;
i += obj.length;
}
return count;
}
// Return the substring for the balanced `{...}` object starting at `start`, or
// null if it never closes (string-aware so braces inside strings don't count).
function extractBalancedObject(s: string, start: number): string | null {
let depth = 0;
let inStr = false;
let esc = false;
for (let i = start; i < s.length; i++) {
const c = s[i] as string;
if (inStr) {
if (esc) esc = false;
else if (c === '\\') esc = true;
else if (c === '"') inStr = false;
continue;
}
if (c === '"') inStr = true;
else if (c === '{') depth++;
else if (c === '}') {
depth--;
if (depth === 0) return s.slice(start, i + 1);
}
}
return null;
}
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';
if (lower === 'select' || lower === 'dropdown') return 'select';
if (lower === 'textarea' || lower === 'long' || lower === 'paragraph') return 'textarea';
if (lower === 'number' || lower === 'numeric') return 'number';
if (lower === 'range' || lower === 'slider') return 'range';
if (lower === 'date') return 'date';
if (lower === 'time') return 'time';
if (
lower === 'datetime-local' ||
lower === 'datetime' ||
lower === 'date-time' ||
lower === 'datetime_local'
)
return 'datetime-local';
if (lower === 'color' || lower === 'colour' || lower === 'color-picker') return 'color';
if (lower === 'url' || lower === 'link') return 'url';
if (lower === 'email') return 'email';
if (lower === 'tel' || lower === 'phone') return 'tel';
if (lower === 'file' || lower === 'upload' || lower === 'attachment') return 'file';
if (lower === 'switch' || lower === 'toggle' || lower === 'boolean') return 'switch';
if (
lower === 'direction-cards' ||
lower === 'directions' ||
lower === 'cards' ||
lower === 'direction'
)
return 'direction-cards';
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;
const n = Number(raw);
return Number.isFinite(n) ? n : undefined;
}
function parseOptions(raw: unknown): FormOption[] | undefined {
if (!Array.isArray(raw)) return undefined;
const options = raw
.map(parseOption)
.filter((option): option is FormOption => option !== null);
return options.length > 0 ? options : undefined;
}
function parseOption(raw: unknown): FormOption | null {
if (typeof raw === 'string') {
const label = raw.trim();
return label.length > 0 ? { label, value: label } : null;
}
if (!raw || typeof raw !== 'object') return null;
const obj = raw as Record<string, unknown>;
const label = typeof obj.label === 'string' ? obj.label.trim() : '';
if (label.length === 0) return 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
? obj.description.trim()
: undefined;
const group =
typeof obj.group === 'string' && obj.group.trim().length > 0
? obj.group.trim()
: undefined;
const trailingLabel =
typeof obj.trailingLabel === 'string' && obj.trailingLabel.trim().length > 0
? obj.trailingLabel.trim()
: undefined;
return {
label,
value,
...(description ? { description } : {}),
...(group ? { group } : {}),
...(trailingLabel ? { trailingLabel } : {}),
};
}
/**
* 类型自己的规范化。目前只有颜色有话要说:模型爱写 `#3B82F6` / `3b82f6`,
* 落到状态和原生控件之前先收成规范形。收不了的默认值当没给 —— 与其让控件
* 拿着一个渲染不出来的值,不如让用户从空开始选。
*/
function normalizeDefaultValueForType(
type: QuestionType,
value: string | string[] | undefined,
): string | string[] | undefined {
if (type !== 'color' || typeof value !== 'string') return value;
return normalizeHexColor(value) ?? undefined;
}
function parseDefaultValue(
question: Record<string, unknown>,
options: FormOption[] | undefined,
): string | string[] | undefined {
const raw =
typeof question.defaultValue === 'string' || Array.isArray(question.defaultValue)
? question.defaultValue
: typeof question.defaultValue === 'number' || typeof question.defaultValue === 'boolean'
? String(question.defaultValue)
: typeof question.default === 'string' || Array.isArray(question.default)
? question.default
: typeof question.default === 'number' || typeof question.default === 'boolean'
? String(question.default)
: undefined;
if (typeof raw === 'string') return formOptionValueForLabel({ options }, raw);
if (Array.isArray(raw)) {
return raw
.filter((value): value is string => typeof value === 'string')
.map((value) => formOptionValueForLabel({ options }, value));
}
return undefined;
}
function parseDirectionCards(raw: unknown): DirectionCard[] | undefined {
if (!Array.isArray(raw)) return undefined;
const out: DirectionCard[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== 'object') continue;
const e = entry as Record<string, unknown>;
const id = typeof e.id === 'string' && e.id.trim().length > 0 ? e.id.trim() : null;
const label = typeof e.label === 'string' ? e.label : null;
if (id === null || label === null) continue;
const mood = typeof e.mood === 'string' ? e.mood : '';
const references = Array.isArray(e.references)
? e.references.filter((r): r is string => typeof r === 'string').slice(0, 6)
: [];
const palette = Array.isArray(e.palette)
? e.palette.filter((p): p is string => typeof p === 'string').slice(0, 8)
: [];
const displayFont = typeof e.displayFont === 'string' ? e.displayFont : 'Georgia, serif';
const bodyFont =
typeof e.bodyFont === 'string'
? e.bodyFont
: '-apple-system, system-ui, sans-serif';
out.push({ id, label, mood, references, palette, displayFont, bodyFont });
}
return out.length > 0 ? out : undefined;
}
/**
* Format a finished set of answers into a prose user message that the
* agent can read on its next turn. The shape is stable enough that the
* agent can recognise "the form was answered" without us emitting any
* structured wrapper.
*/
export function formatFormAnswers(
form: QuestionForm,
answers: Record<string, string | string[]>,
): string {
const lines: string[] = [];
lines.push(`[form answers — ${form.id}]`);
for (const q of form.questions) {
const v = answers[q.id];
let display: string;
if (Array.isArray(v)) {
display = v.length > 0 ? v.map((value) => formOptionDisplayForValue(q, value)).join(', ') : '(skipped)';
} else if (typeof v === 'string') {
display = v.trim().length > 0 ? formOptionDisplayForValue(q, v.trim()) : '(skipped)';
}
else display = '(skipped)';
lines.push(`- ${q.label}: ${display}`);
}
return lines.join('\n');
}
/**
* `[form answers — <id>]` —— {@link formatFormAnswers} 顶上那一行**机器载荷**。
*
* 它是写给 agent 的路由头,不是写给人的。id 后面允许任意文字是因为
* `QuestionForm.parseSubmittedAnswers` 也只认「以 `[form answers` 开头」,
* agent 复述时可以改写这一行。
*/
const FORM_ANSWERS_HEADER_LINE = /^\[form answers\b[^\n]*\n?/i;
/**
* 这条用户消息是不是一份表单答案。
*
* 判据和 `QuestionForm.parseSubmittedAnswers` 同源:只认 `[form answers` 开头,
* 用户随口说的话不会被误判。
*/
export function isFormAnswersMessage(content: string): boolean {
return /^\[form answers\b/i.test(content.trim());
}
/**
* 一份表单答案里**给人看的**那一半。
*
* 交付成功的答案根本不画用户气泡(#5496:摘要已经长在上一条助手消息上)。
* 但发送失败的那一条必须留在流水里 —— 它是「这一轮为什么没了」的唯一凭据,
* 也挂着唯一的复原入口(那颗「重试」)。放它出来的同时不能把
* `[form answers — <id>]` 这行机器载荷摆到用户脸上,所以这里只去掉头一行,
* 底下那几条 `- 问题: 回答` 本来就是人话。
*
* 去掉之后什么都不剩(agent 只复述了个头)时原样返回,宁可露出机器载荷也
* 不给一个空气泡 —— 空气泡等于这一轮又消失了一次。
*/
export function formAnswersDisplayBody(content: string): string {
if (!isFormAnswersMessage(content)) return content;
const body = content.trim().replace(FORM_ANSWERS_HEADER_LINE, '').trim();
return body.length > 0 ? body : content;
}
function formOptionDisplayForValue(
question: Pick<FormQuestion, 'options' | 'type'>,
value: string,
): string {
const match = question.options?.find((option) => option.value === value || option.label === value);
if (!match) return value;
if (match.value === match.label) return match.label;
if (
question.type === 'direction-cards' &&
match.foundationDirectionId &&
match.agentGuidance
) {
return (
`${match.label} [foundation: ${match.foundationDirectionId}; ` +
`guidance: ${match.agentGuidance}] [value: ${match.value}]`
);
}
return `${match.label} [value: ${match.value}]`;
}
export function formOptionLabelForValue(
question: Pick<FormQuestion, 'options'>,
value: string,
): string {
const match = question.options?.find((option) => option.value === value || option.label === value);
return match?.label ?? value;
}
export function formOptionValueForLabel(
question: Pick<FormQuestion, 'options'>,
labelOrValue: string,
): string {
const match = question.options?.find(
(option) => option.value === labelOrValue || option.label === labelOrValue,
);
return match?.value ?? labelOrValue;
}