From dc8fd2ece8bc755899ea41021824f141645b0358 Mon Sep 17 00:00:00 2001 From: Sherry-hue <37186915+Sherry-hue@users.noreply.github.com> Date: Thu, 21 May 2026 18:10:58 +0800 Subject: [PATCH] feat(a2ui): add catalog example, json valid, stream repair --- packages/genui/server/.npmrc | 5 + packages/genui/server/agent/a2ui-catalog.ts | 9 +- packages/genui/server/agent/a2ui-examples.ts | 258 ++++++++++++++++++ packages/genui/server/agent/a2ui-prompt.ts | 69 ++--- packages/genui/server/agent/a2ui-validator.ts | 219 +++++++++++++-- .../server/app/a2ui/action/stream/route.ts | 88 ++++-- .../genui/server/app/a2ui/stream/route.ts | 78 +++++- 7 files changed, 627 insertions(+), 99 deletions(-) create mode 100644 packages/genui/server/.npmrc create mode 100644 packages/genui/server/agent/a2ui-examples.ts diff --git a/packages/genui/server/.npmrc b/packages/genui/server/.npmrc new file mode 100644 index 0000000000..9a76ecfed5 --- /dev/null +++ b/packages/genui/server/.npmrc @@ -0,0 +1,5 @@ +# Keep this package installable as a standalone server fixture even when npm +# sees peer ranges from Next/React-related packages that do not line up exactly +# with the monorepo's pinned versions. The workspace install still uses pnpm; +# this only affects npm-based installs in this package directory. +legacy-peer-deps=true diff --git a/packages/genui/server/agent/a2ui-catalog.ts b/packages/genui/server/agent/a2ui-catalog.ts index 4768114b9a..cbbb415876 100644 --- a/packages/genui/server/agent/a2ui-catalog.ts +++ b/packages/genui/server/agent/a2ui-catalog.ts @@ -2,6 +2,8 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. +import { BASIC_CATALOG_EXAMPLES } from './a2ui-examples'; +import type { A2UIExample } from './a2ui-examples'; import buttonManifest from './catalog/Button/catalog.json'; import cardManifest from './catalog/Card/catalog.json'; import checkBoxManifest from './catalog/CheckBox/catalog.json'; @@ -25,6 +27,7 @@ export interface A2UIComponentProp { description?: string; required?: boolean; enums?: readonly string[]; + schema?: JsonSchema; } export interface A2UIComponentSpec { @@ -41,13 +44,13 @@ export interface A2UICatalog { version?: string; components: A2UIComponentSpec[]; extraRules?: string[]; - examples?: string[]; + examples?: A2UIExample[]; } export const BASIC_CATALOG_ID = 'https://a2ui.org/specification/v0_9/basic_catalog.json'; -interface JsonSchema { +export interface JsonSchema { type?: string; enum?: unknown; oneOf?: JsonSchema[]; @@ -184,6 +187,7 @@ function componentFromManifest( type: inferType(propSchema), description: propSchema.description ?? '', required: required.has(propName), + schema: propSchema, ...(enums ? { enums } : {}), }; }); @@ -210,6 +214,7 @@ export const BASIC_CATALOG: A2UICatalog = { 'Use only components listed in this catalog; unsupported examples such as Video, AudioPlayer, DatePicker, or Checkbox are not available unless they appear here.', 'The implemented checkbox component is named "CheckBox" with a capital B.', ], + examples: BASIC_CATALOG_EXAMPLES, }; export function renderCatalogReference(catalog: A2UICatalog): string { diff --git a/packages/genui/server/agent/a2ui-examples.ts b/packages/genui/server/agent/a2ui-examples.ts new file mode 100644 index 0000000000..24fd88ac4f --- /dev/null +++ b/packages/genui/server/agent/a2ui-examples.ts @@ -0,0 +1,258 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +export interface A2UIExample { + name: string; + user: string; + messages: unknown[]; +} + +const BASIC_CATALOG_ID = + 'https://a2ui.org/specification/v0_9/basic_catalog.json'; + +export const BASIC_CATALOG_EXAMPLES: A2UIExample[] = [ + { + name: 'login-card', + user: 'Generate a login card with email, password, and a submit button.', + messages: [ + { + version: 'v0.9', + createSurface: { + surfaceId: 'main', + catalogId: BASIC_CATALOG_ID, + }, + }, + { + version: 'v0.9', + updateComponents: { + surfaceId: 'main', + components: [ + { id: 'root', component: 'Card', child: 'form-column' }, + { + id: 'form-column', + component: 'Column', + children: ['title', 'email', 'password', 'submit'], + }, + { id: 'title', component: 'Text', text: 'Sign in', variant: 'h2' }, + { + id: 'email', + component: 'TextField', + label: 'Email', + value: { path: '/form/email' }, + }, + { + id: 'password', + component: 'TextField', + label: 'Password', + variant: 'obscured', + value: { path: '/form/password' }, + }, + { + id: 'submit', + component: 'Button', + variant: 'primary', + child: 'submit-label', + action: { + event: { + name: 'submit_login', + context: { + email: { path: '/form/email' }, + password: { path: '/form/password' }, + }, + }, + }, + }, + { id: 'submit-label', component: 'Text', text: 'Sign in' }, + ], + }, + }, + { + version: 'v0.9', + updateDataModel: { + surfaceId: 'main', + value: { form: { email: '', password: '' } }, + }, + }, + ], + }, + { + name: 'dynamic-list', + user: 'Show three trip ideas as a compact list.', + messages: [ + { + version: 'v0.9', + createSurface: { + surfaceId: 'main', + catalogId: BASIC_CATALOG_ID, + }, + }, + { + version: 'v0.9', + updateComponents: { + surfaceId: 'main', + components: [ + { + id: 'root', + component: 'Column', + children: ['title', 'trip-list'], + }, + { + id: 'title', + component: 'Text', + text: 'Trip ideas', + variant: 'h2', + }, + { + id: 'trip-list', + component: 'List', + direction: 'vertical', + children: { path: '/items', componentId: 'trip-row' }, + }, + { + id: 'trip-row', + component: 'Row', + children: ['trip-icon', 'trip-copy'], + align: 'center', + }, + { id: 'trip-icon', component: 'Icon', name: 'location_on' }, + { + id: 'trip-copy', + component: 'Column', + children: ['trip-name', 'trip-detail'], + }, + { + id: 'trip-name', + component: 'Text', + text: { path: '/items/*/name' }, + variant: 'h3', + }, + { + id: 'trip-detail', + component: 'Text', + text: { path: '/items/*/detail' }, + variant: 'body', + }, + ], + }, + }, + { + version: 'v0.9', + updateDataModel: { + surfaceId: 'main', + path: '/items', + value: [ + { name: 'Canal walk', detail: 'Morning coffee and quiet bridges' }, + { + name: 'Museum loop', + detail: 'Design exhibits plus lunch nearby', + }, + { name: 'Sunset hill', detail: 'Short climb with skyline views' }, + ], + }, + }, + ], + }, + { + name: 'chart-card', + user: 'Show weekly active users as a line chart.', + messages: [ + { + version: 'v0.9', + createSurface: { + surfaceId: 'main', + catalogId: BASIC_CATALOG_ID, + }, + }, + { + version: 'v0.9', + updateComponents: { + surfaceId: 'main', + components: [ + { id: 'root', component: 'Card', child: 'chart-column' }, + { + id: 'chart-column', + component: 'Column', + children: ['title', 'chart'], + }, + { + id: 'title', + component: 'Text', + text: 'Weekly active users', + variant: 'h2', + }, + { + id: 'chart', + component: 'LineChart', + labels: { path: '/chart/labels' }, + series: { path: '/chart/series' }, + xLabel: 'Day', + yLabel: 'Users', + showGrid: true, + showLegend: true, + }, + ], + }, + }, + { + version: 'v0.9', + updateDataModel: { + surfaceId: 'main', + value: { + chart: { + labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'], + series: [{ name: 'Users', values: [120, 148, 132, 171, 190] }], + }, + }, + }, + }, + ], + }, + { + name: 'action-update', + user: + 'A2UI_USER_ACTION: {"surfaceId":"main","action":{"name":"submit_login","context":{"email":"me@example.com"}}}', + messages: [ + { + version: 'v0.9', + updateDataModel: { + surfaceId: 'main', + path: '/status', + value: { + kind: 'success', + message: 'Signed in as me@example.com', + }, + }, + }, + { + version: 'v0.9', + updateComponents: { + surfaceId: 'main', + components: [ + { + id: 'root', + component: 'Card', + child: 'status-column', + }, + { + id: 'status-column', + component: 'Column', + children: ['status-title', 'status-message'], + }, + { + id: 'status-title', + component: 'Text', + text: 'Success', + variant: 'h2', + }, + { + id: 'status-message', + component: 'Text', + text: { path: '/status/message' }, + }, + ], + }, + }, + ], + }, +]; diff --git a/packages/genui/server/agent/a2ui-prompt.ts b/packages/genui/server/agent/a2ui-prompt.ts index 5edde4a27c..ff01495413 100644 --- a/packages/genui/server/agent/a2ui-prompt.ts +++ b/packages/genui/server/agent/a2ui-prompt.ts @@ -62,8 +62,9 @@ and exactly ONE of the following keys: ...component specific props } - The component with id "root" is the entry point of the tree. -- Layout containers (Row / Column / List) take "children": ["id1", "id2", ...] - OR a template object for repeating from the data model. +- Layout containers (Row / Column / List) take "children": ["id1", "id2", ...]. + To repeat from the data model, use "children": + { "path": "/items", "componentId": "itemRow" } - Card uses "child": "id". Modal uses "trigger" + "content". Tabs uses an array of {title, child}. @@ -71,7 +72,7 @@ and exactly ONE of the following keys: - Static text: "text": "Hello" - Bound text: "text": { "path": "/user/name" } - Bound list children: - "children": { "template": { "path": "/items", "componentId": "itemRow" } } + "children": { "path": "/items", "componentId": "itemRow" } - Use updateDataModel messages to populate values at those paths. - DynamicString/DynamicNumber/DynamicBoolean props accept either a literal value or { "path": "/json/pointer" }. If you bind a prop to a path, create the @@ -129,57 +130,19 @@ function buildHardRules(catalogId: string): string { `; } -const FEW_SHOT_EXAMPLE = `## Example response (login card) +function renderCatalogExamples(catalog: A2UICatalog): string { + if (!catalog.examples || catalog.examples.length === 0) return ''; -User: "Generate a login card with email + password and a submit button." - -Assistant (response body, raw JSON, no fences): -[ - { - "version": "v0.9", - "createSurface": { - "surfaceId": "main", - "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json" - } - }, - { - "version": "v0.9", - "updateComponents": { - "surfaceId": "main", - "components": [ - { "id": "root", "component": "Card", "child": "form-col" }, - { "id": "form-col", "component": "Column", "children": ["title", "email", "password", "submit"] }, - { "id": "title", "component": "Text", "text": "Sign in", "variant": "h2" }, - { "id": "email", "component": "TextField", "label": "Email", - "value": { "path": "/form/email" } }, - { "id": "password", "component": "TextField", "label": "Password", - "value": { "path": "/form/password" } }, - { "id": "submit", "component": "Button", - "variant": "primary", - "action": { - "event": { - "name": "submit_login", - "context": { - "email": { "path": "/form/email" }, - "password": { "path": "/form/password" } - } - } - }, - "child": "submit-label" - }, - { "id": "submit-label", "component": "Text", "text": "Sign in" } - ] - } - }, - { - "version": "v0.9", - "updateDataModel": { - "surfaceId": "main", - "value": { "form": { "email": "", "password": "" } } - } + const lines = ['## Validated examples']; + for (const example of catalog.examples) { + lines.push(''); + lines.push(`### ${example.name}`); + lines.push(`User: ${JSON.stringify(example.user)}`); + lines.push('Assistant (raw JSON array, no fences):'); + lines.push(JSON.stringify(example.messages, null, 2)); } -] -`; + return lines.join('\n'); +} export interface BuildSystemPromptOptions { catalog?: A2UICatalog; @@ -202,7 +165,7 @@ export function buildA2UISystemPrompt( '', buildHardRules(catalog.id), '', - FEW_SHOT_EXAMPLE, + renderCatalogExamples(catalog), ]; if (opts.appendix) { parts.push('', opts.appendix); diff --git a/packages/genui/server/agent/a2ui-validator.ts b/packages/genui/server/agent/a2ui-validator.ts index 212628939c..122df54a37 100644 --- a/packages/genui/server/agent/a2ui-validator.ts +++ b/packages/genui/server/agent/a2ui-validator.ts @@ -4,7 +4,11 @@ import { z } from 'zod'; -import type { A2UICatalog } from './a2ui-catalog'; +import type { + A2UICatalog, + A2UIComponentSpec, + JsonSchema, +} from './a2ui-catalog'; const ChildTemplateSchema = z .object({ @@ -87,7 +91,7 @@ export const A2UIMessageArray = z.array(A2UIMessage).min(1); export type A2UIMessage = z.infer; function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object'; + return value !== null && typeof value === 'object' && !Array.isArray(value); } function hasActionName(value: unknown): value is { event: { name: string } } { @@ -248,6 +252,7 @@ export function validateA2UIOutput( }; }); const knownComponents = new Set(catalog.components.map((c) => c.name)); + const componentSpecs = new Map(catalog.components.map((c) => [c.name, c])); const requiresAction = new Set( catalog.components.filter((c) => c.requiresAction).map((c) => c.name), ); @@ -288,7 +293,13 @@ export function validateA2UIOutput( ?? new Map(); for (const rawComponent of msg.updateComponents.components) { const comp = rawComponent as A2UIComponent; - if (!knownComponents.has(comp.component)) { + if (knownComponents.has(comp.component)) { + validateComponentAgainstCatalog( + comp, + componentSpecs.get(comp.component)!, + errors, + ); + } else { errors.push( `Unknown component "${comp.component}" (id=${comp.id}). Allowed: ${ [...knownComponents].join(', ') @@ -378,14 +389,8 @@ export function validateA2UIOutput( for (const referenced of allPaths) { const providedSet = providedBySurface.get(referenced.surfaceId) ?? new Set(); - const hasMatch = [...providedSet].some( - (provided) => - referenced.path === provided - || provided.startsWith( - referenced.path.endsWith('/') - ? referenced.path - : referenced.path + '/', - ), + const hasMatch = [...providedSet].some((provided) => + isPathCovered(referenced.path, provided) ); if (!hasMatch) { errors.push( @@ -401,6 +406,39 @@ export function validateA2UIOutput( }; } +function isPathCovered(referencedPath: string, providedPath: string): boolean { + const referencedSegments = normalizePathSegments(referencedPath); + const providedSegments = normalizePathSegments(providedPath); + const comparableLength = Math.min( + referencedSegments.length, + providedSegments.length, + ); + + for (let i = 0; i < comparableLength; i++) { + const referenced = referencedSegments[i]; + const provided = providedSegments[i]; + if (referenced !== provided && referenced !== '*' && provided !== '*') { + return false; + } + } + + if (providedSegments.length === referencedSegments.length) return true; + const referencedExtra = referencedSegments.slice(comparableLength); + const providedExtra = providedSegments.slice(comparableLength); + return ( + referencedExtra.length > 0 + && referencedExtra.every((segment) => segment === '*') + ) + || ( + providedExtra.length > 0 + && providedExtra.every((segment) => segment === '*') + ); +} + +function normalizePathSegments(path: string): string[] { + return path.split('/').filter(Boolean); +} + function collectPaths(node: unknown, acc: string[]): void { if (!isRecord(node) && !Array.isArray(node)) return; if (Array.isArray(node)) { @@ -425,10 +463,14 @@ function collectChildRefs(comp: A2UIComponent): string[] { if (typeof c === 'string') refs.push(c); } } else if (isRecord(comp.children)) { - const children = comp.children as { template?: unknown }; - const template = children.template; - if (isRecord(template)) { - const childTemplate = template as { componentId?: unknown }; + const children = comp.children as { + componentId?: unknown; + template?: unknown; + }; + if (typeof children.componentId === 'string') { + refs.push(children.componentId); + } else if (isRecord(children.template)) { + const childTemplate = children.template as { componentId?: unknown }; if (typeof childTemplate.componentId === 'string') { refs.push(childTemplate.componentId); } @@ -454,12 +496,157 @@ function flattenProvidedPaths(basePath: string, value: unknown): string[] { return paths.length > 0 ? paths : [normalized]; } +function validateComponentAgainstCatalog( + comp: A2UIComponent, + spec: A2UIComponentSpec, + errors: string[], +): void { + const allowed = new Set([ + 'id', + 'component', + ...spec.props.map((p) => p.name), + ]); + for (const key of Object.keys(comp)) { + if (!allowed.has(key)) { + errors.push( + `Component "${comp.id}" (${comp.component}) has unknown prop "${key}". Allowed props: ${ + [...allowed].join(', ') + }.`, + ); + } + } + + for (const prop of spec.props) { + const hasValue = Object.prototype.hasOwnProperty.call(comp, prop.name); + if (prop.required && !hasValue) { + errors.push( + `Component "${comp.id}" (${comp.component}) is missing required prop "${prop.name}".`, + ); + continue; + } + if (!hasValue || !prop.schema) continue; + const value = (comp as Record)[prop.name]; + const propErrors = validateValueAgainstSchema( + value, + prop.schema, + `${comp.id}.${prop.name}`, + ); + errors.push(...propErrors); + } +} + +function validateValueAgainstSchema( + value: unknown, + schema: JsonSchema, + path: string, +): string[] { + if (schema.oneOf && schema.oneOf.length > 0) { + const branchErrors = schema.oneOf.map((branch) => + validateValueAgainstSchema(value, branch, path) + ); + if (branchErrors.some((branch) => branch.length === 0)) return []; + return [ + `Prop ${path} does not match any allowed shape: ${ + branchErrors.map((branch) => branch[0]).filter(Boolean).join(' | ') + }`, + ]; + } + + const errors: string[] = []; + if (Array.isArray(schema.enum) && !schema.enum.includes(value)) { + errors.push( + `Prop ${path} must be one of ${ + schema.enum.map(String).join(', ') + }; received ${JSON.stringify(value)}.`, + ); + return errors; + } + + switch (schema.type) { + case 'string': + if (typeof value !== 'string') { + errors.push(`Prop ${path} must be a string.`); + } + return errors; + case 'number': + if (typeof value !== 'number' || !Number.isFinite(value)) { + errors.push(`Prop ${path} must be a finite number.`); + } + return errors; + case 'boolean': + if (typeof value !== 'boolean') { + errors.push(`Prop ${path} must be a boolean.`); + } + return errors; + case 'array': + if (!Array.isArray(value)) { + errors.push(`Prop ${path} must be an array.`); + return errors; + } + if (schema.items) { + value.forEach((item, index) => { + errors.push( + ...validateValueAgainstSchema( + item, + schema.items!, + `${path}[${index}]`, + ), + ); + }); + } + return errors; + case 'object': + if (!isRecord(value)) { + errors.push(`Prop ${path} must be an object.`); + return errors; + } + validateObjectAgainstSchema(value, schema, path, errors); + return errors; + default: + return errors; + } +} + +function validateObjectAgainstSchema( + value: Record, + schema: JsonSchema, + path: string, + errors: string[], +): void { + const properties = schema.properties ?? {}; + const required = schema.required ?? []; + for (const key of required) { + if (!Object.prototype.hasOwnProperty.call(value, key)) { + errors.push(`Prop ${path} is missing required field "${key}".`); + } + } + + const additional = schema.additionalProperties; + for (const [key, child] of Object.entries(value)) { + const childSchema = properties[key]; + if (childSchema) { + errors.push( + ...validateValueAgainstSchema(child, childSchema, `${path}.${key}`), + ); + continue; + } + if (additional === false) { + errors.push(`Prop ${path} has unknown field "${key}".`); + } + } +} + function walk(prefix: string, value: unknown, acc: string[]): void { if (value === null || value === undefined) { if (prefix) acc.push(prefix || '/'); return; } - if (typeof value !== 'object' || Array.isArray(value)) { + if (Array.isArray(value)) { + acc.push(prefix || '/'); + for (const item of value) walk(`${prefix || ''}/*`, item, acc); + return; + } + if (typeof value !== 'object') { acc.push(prefix || '/'); return; } diff --git a/packages/genui/server/app/a2ui/action/stream/route.ts b/packages/genui/server/app/a2ui/action/stream/route.ts index 811a30eea0..dfedd0502a 100644 --- a/packages/genui/server/app/a2ui/action/stream/route.ts +++ b/packages/genui/server/app/a2ui/action/stream/route.ts @@ -139,7 +139,16 @@ export async function POST(req: Request) { enqueue('delta', { text: chunk }); } - const { text: finalText, usage, finishReason } = await finalize(); + let { text: finalText, usage, finishReason } = await finalize(); + let repair: + | { + attempted: true; + sourceErrors: string[]; + ok: boolean; + attempts: number; + errors?: string[]; + } + | undefined; let validation: { ok: boolean; errors: string[]; messages: unknown[] } = { @@ -147,21 +156,67 @@ export async function POST(req: Request) { errors: ['no text produced'], messages: [], }; - if (finalText) { - const v = validateA2UIOutput( - finalText, - opts.catalog ?? BASIC_CATALOG, - { - requireCreateSurface: false, - existingSurfaceIds: body.surfaceId ? [body.surfaceId] : [], - }, - ); - const messages = v.ok ? await resolveA2UIImageUrls(v.messages) : []; - validation = { - ok: v.ok, - errors: v.errors, - messages, - }; + const validationOptions = { + requireCreateSurface: false, + existingSurfaceIds: body.surfaceId ? [body.surfaceId] : [], + }; + const v = validateA2UIOutput( + finalText ?? '', + opts.catalog ?? BASIC_CATALOG, + validationOptions, + ); + let resolvedMessages = v.ok + ? await resolveA2UIImageUrls(v.messages) + : []; + validation = { + ok: v.ok, + errors: v.errors, + messages: resolvedMessages, + }; + if (!v.ok) { + try { + const repaired = await service.generateValidated( + [userMessage], + opts, + validatedConversation.conversation, + validationOptions, + ); + repair = { + attempted: true, + sourceErrors: v.errors, + ok: repaired.ok, + attempts: repaired.attempts, + }; + enqueue('repair', repair); + if (repaired.ok) { + finalText = repaired.text; + usage = repaired.usage; + finishReason = repaired.finishReason; + resolvedMessages = await resolveA2UIImageUrls( + repaired.messages, + ); + validation = { + ok: true, + errors: [], + messages: resolvedMessages, + }; + } else { + validation = { + ok: false, + errors: repaired.errors, + messages: [], + }; + } + } catch (err: unknown) { + repair = { + attempted: true, + sourceErrors: v.errors, + ok: false, + attempts: 0, + errors: [errorMessage(err).message], + }; + enqueue('repair', repair); + } } enqueue('done', { @@ -169,6 +224,7 @@ export async function POST(req: Request) { usage, finishReason, validation, + repair, }); } catch (err: unknown) { enqueue('error', errorMessage(err)); diff --git a/packages/genui/server/app/a2ui/stream/route.ts b/packages/genui/server/app/a2ui/stream/route.ts index b47231dee4..65173f934b 100644 --- a/packages/genui/server/app/a2ui/stream/route.ts +++ b/packages/genui/server/app/a2ui/stream/route.ts @@ -91,7 +91,16 @@ export async function POST(req: Request) { enqueue('delta', { text: chunk }); } - const { text: finalText, usage, finishReason } = await finalize(); + let { text: finalText, usage, finishReason } = await finalize(); + let repair: + | { + attempted: true; + sourceErrors: string[]; + ok: boolean; + attempts: number; + errors?: string[]; + } + | undefined; let validation: { ok: boolean; errors: string[]; messages: unknown[] } = { @@ -99,17 +108,61 @@ export async function POST(req: Request) { errors: ['no text produced'], messages: [], }; - if (finalText) { - const v = validateA2UIOutput( - finalText, - opts.catalog ?? BASIC_CATALOG, - ); - const messages = v.ok ? await resolveA2UIImageUrls(v.messages) : []; - validation = { - ok: v.ok, - errors: v.errors, - messages, - }; + const v = validateA2UIOutput( + finalText ?? '', + opts.catalog ?? BASIC_CATALOG, + ); + let resolvedMessages = v.ok + ? await resolveA2UIImageUrls(v.messages) + : []; + validation = { + ok: v.ok, + errors: v.errors, + messages: resolvedMessages, + }; + if (!v.ok) { + try { + const repaired = await service.generateValidated( + messages, + opts, + validatedConversation.conversation, + ); + repair = { + attempted: true, + sourceErrors: v.errors, + ok: repaired.ok, + attempts: repaired.attempts, + }; + enqueue('repair', repair); + if (repaired.ok) { + finalText = repaired.text; + usage = repaired.usage; + finishReason = repaired.finishReason; + resolvedMessages = await resolveA2UIImageUrls( + repaired.messages, + ); + validation = { + ok: true, + errors: [], + messages: resolvedMessages, + }; + } else { + validation = { + ok: false, + errors: repaired.errors, + messages: [], + }; + } + } catch (err: unknown) { + repair = { + attempted: true, + sourceErrors: v.errors, + ok: false, + attempts: 0, + errors: [errorMessage(err).message], + }; + enqueue('repair', repair); + } } enqueue('done', { @@ -117,6 +170,7 @@ export async function POST(req: Request) { usage, finishReason, validation, + repair, }); } catch (err: unknown) { enqueue('error', errorMessage(err));