diff --git a/apps/web/src/tests/jest/writer-schema-page.test.js b/apps/web/src/tests/jest/writer-schema-page.test.js new file mode 100644 index 00000000..cdf6f41c --- /dev/null +++ b/apps/web/src/tests/jest/writer-schema-page.test.js @@ -0,0 +1,347 @@ +import { jest } from '@jest/globals'; +import { JSDOM } from 'jsdom'; +import { + DEFAULT_PROMPT, + bootstrapWriterSchemaPage, + buildWriterSharedContext, + extractJsonPayload, + normalizeStructuredSchemaPayload, + parseWriterStructuredOutput, + runWriterSchemaGeneration, +} from '../../writer-schema-page.mjs'; + +describe('writer schema prototype page', () => { + let dom; + + beforeEach(() => { + dom = new JSDOM( + ` +
AnyWayData
+
+

Checking Writer API availability...

+ + + +

Ready for a prompt.

+
No output yet.
+
No Writer request has been sent yet.
+
No raw Writer response yet.
+
No errors yet.
+
  1. No generation activity yet.
+
+
+ `, + { url: 'https://example.test/writer-schema.html' } + ); + }); + + afterEach(() => { + dom.window.close(); + jest.restoreAllMocks(); + }); + + test('extractJsonPayload unwraps fenced JSON', () => { + expect(extractJsonPayload('```json\n{"schemaFields":[]}\n```')).toBe('{"schemaFields":[]}'); + }); + + test('parseWriterStructuredOutput parses JSON objects from plain text responses', () => { + expect( + parseWriterStructuredOutput('Result:\n{"schemaFields":[{"name":"Title","sourceType":"literal","value":"book"}]}') + ).toEqual({ + schemaFields: [{ name: 'Title', sourceType: 'literal', value: 'book' }], + }); + }); + + test('normalizeStructuredSchemaPayload maps supported source types into schema rows', () => { + expect( + normalizeStructuredSchemaPayload({ + schemaFields: [ + { name: 'Category', sourceType: 'enum', values: ['A', 'B'] }, + { name: 'Book Title', sourceType: 'domain', command: 'commerce.productName' }, + { name: 'ISBN', sourceType: 'regex', pattern: '[0-9]{3}' }, + ], + }) + ).toMatchObject({ + schemaRows: [ + { name: 'Category', sourceType: 'enum', value: '"A","B"' }, + { name: 'Book Title', sourceType: 'domain', command: 'commerce.productName' }, + { name: 'ISBN', sourceType: 'regex', value: '[0-9]{3}' }, + ], + normalizationErrors: [], + }); + }); + + test('buildWriterSharedContext includes schema guidance and allowed domain commands', () => { + const context = buildWriterSharedContext({ + domainCommands: ['person.fullName', 'commerce.productName'], + sampleSchemaText: 'Name\nperson.fullName', + }); + + expect(context).toContain('Supported sourceType values are exactly: domain, enum, literal, regex.'); + expect(context).toContain('Never invent or rename commands'); + expect(context).toContain('Do not use literal placeholders like YYYY-MM-DD'); + expect(context).toContain('person.fullName, commerce.productName'); + expect(context).toContain('Name\nperson.fullName'); + }); + + test('normalizeStructuredSchemaPayload rejects invented domain commands', () => { + const result = normalizeStructuredSchemaPayload( + { + schemaFields: [ + { name: 'Author Name', sourceType: 'domain', command: 'person.fullName' }, + { name: 'Publisher', sourceType: 'domain', command: 'commerce.publisher' }, + ], + }, + { + allowedDomainCommands: ['book.publisher', 'commerce.productName', 'person.fullName'], + } + ); + + expect(result.schemaRows).toMatchObject([{ name: 'Author Name', command: 'person.fullName' }]); + expect(result.normalizationErrors).toHaveLength(1); + expect(result.normalizationErrors[0].message).toContain('unsupported command "commerce.publisher"'); + }); + + test('normalizeStructuredSchemaPayload rejects date placeholder literals for date-like fields', () => { + expect(() => + normalizeStructuredSchemaPayload({ + schemaFields: [{ name: 'Published Date', sourceType: 'literal', value: 'YYYY-MM-DD' }], + }) + ).toThrow('Use an allowed date.* domain command instead'); + }); + + test('runWriterSchemaGeneration parses structured JSON text returned by Writer', async () => { + const writer = { + destroy: jest.fn(), + write: jest.fn(async () => + JSON.stringify({ + schemaFields: [ + { name: 'Book Title', sourceType: 'domain', command: 'commerce.productName' }, + { name: 'Genre', sourceType: 'enum', values: ['Fiction', 'Non-fiction'] }, + ], + }) + ), + }; + const WriterCtor = { + create: jest.fn(async () => writer), + }; + + const result = await runWriterSchemaGeneration({ + WriterCtor, + promptText: DEFAULT_PROMPT, + domainCommands: ['commerce.productName', 'person.fullName'], + sampleSchemaText: 'Name\nperson.fullName', + onStatus: jest.fn(), + }); + + expect(WriterCtor.create).toHaveBeenCalledTimes(1); + expect(WriterCtor.create).toHaveBeenCalledWith( + expect.objectContaining({ + expectedInputLanguages: ['en'], + expectedContextLanguages: ['en'], + outputLanguage: 'en', + }) + ); + expect(writer.write).toHaveBeenCalledWith( + DEFAULT_PROMPT, + expect.objectContaining({ + context: expect.any(String), + expectedInputLanguages: ['en'], + expectedContextLanguages: ['en'], + outputLanguage: 'en', + }) + ); + expect(result.parsedPayload.schemaFields).toHaveLength(2); + expect(result.requestDetails).toMatchObject({ + promptText: DEFAULT_PROMPT, + taskContext: expect.any(String), + writeOptions: expect.objectContaining({ + outputLanguage: 'en', + }), + createOptions: expect.objectContaining({ + sharedContext: expect.any(String), + }), + }); + expect(result.schemaRows).toMatchObject([ + { name: 'Book Title', sourceType: 'domain', command: 'commerce.productName' }, + { name: 'Genre', sourceType: 'enum', value: '"Fiction","Non-fiction"' }, + ]); + expect(result.normalizationErrors).toEqual([]); + expect(writer.destroy).toHaveBeenCalledTimes(1); + }); + + test('bootstrap warns when Writer API support is unavailable', async () => { + const schemaComponent = { + destroy: jest.fn(), + replaceRows: jest.fn(), + setTextMode: jest.fn(), + render: jest.fn(), + syncTextFromRows: jest.fn(), + validateRows: jest.fn(() => ({ errors: [] })), + getSchemaText: jest.fn(() => ''), + }; + + await bootstrapWriterSchemaPage({ + documentObj: dom.window.document, + WriterCtor: null, + createThemeToggleComponentFn: () => ({ destroy: jest.fn() }), + createSharedSchemaDefinitionComponentFn: () => schemaComponent, + }); + + expect(dom.window.document.getElementById('writer-schema-support-status').textContent).toContain( + 'Writer API is not available' + ); + expect(dom.window.document.getElementById('writer-schema-prompt').value).toBe(DEFAULT_PROMPT); + }); + + test('bootstrap generates rows and populates the shared schema component', async () => { + const schemaComponent = { + destroy: jest.fn(), + replaceRows: jest.fn(), + setTextMode: jest.fn(), + render: jest.fn(), + syncTextFromRows: jest.fn(), + validateRows: jest.fn(() => ({ errors: [] })), + getSchemaText: jest.fn(() => 'Book Title\ncommerce.productName()'), + }; + const writer = { + write: jest.fn(async () => + JSON.stringify({ + schemaFields: [{ name: 'Book Title', sourceType: 'domain', command: 'commerce.productName' }], + }) + ), + }; + const WriterCtor = { + availability: jest.fn(async () => 'available'), + create: jest.fn(async () => writer), + }; + + const page = await bootstrapWriterSchemaPage({ + documentObj: dom.window.document, + WriterCtor, + createThemeToggleComponentFn: () => ({ destroy: jest.fn() }), + createSharedSchemaDefinitionComponentFn: () => schemaComponent, + }); + + await page.generateFromPrompt(); + + expect(schemaComponent.replaceRows).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ name: 'Book Title', command: 'commerce.productName' })]) + ); + expect(schemaComponent.setTextMode.mock.invocationCallOrder[0]).toBeLessThan( + schemaComponent.replaceRows.mock.invocationCallOrder[0] + ); + expect(schemaComponent.syncTextFromRows).toHaveBeenCalledTimes(1); + expect(dom.window.document.getElementById('writer-schema-generation-status').textContent).toContain( + 'Generated 1 schema fields' + ); + expect(dom.window.document.getElementById('writer-schema-json-output').textContent).toContain('Book Title'); + expect(dom.window.document.getElementById('writer-schema-request-output').textContent).toContain( + '"promptText": "Create 10 fields that represent the inventory of a bookshop"' + ); + expect(dom.window.document.getElementById('writer-schema-raw-output').textContent).toContain( + 'commerce.productName' + ); + expect(dom.window.document.getElementById('writer-schema-error-output').textContent).toBe('No errors yet.'); + expect(dom.window.document.getElementById('writer-schema-progress-output').textContent).toContain( + 'Schema generation completed successfully.' + ); + }); + + test('bootstrap shows full error and raw response when generated schema cannot be normalized', async () => { + const schemaComponent = { + destroy: jest.fn(), + replaceRows: jest.fn(), + setTextMode: jest.fn(), + render: jest.fn(), + syncTextFromRows: jest.fn(), + validateRows: jest.fn(() => ({ errors: [] })), + getSchemaText: jest.fn(() => ''), + }; + const invalidResponse = JSON.stringify({ + schemaFields: [{ name: 'Book Title', sourceType: 'domain' }], + }); + const writer = { + write: jest.fn(async () => invalidResponse), + }; + const WriterCtor = { + availability: jest.fn(async () => 'available'), + create: jest.fn(async () => writer), + }; + + const page = await bootstrapWriterSchemaPage({ + documentObj: dom.window.document, + WriterCtor, + createThemeToggleComponentFn: () => ({ destroy: jest.fn() }), + createSharedSchemaDefinitionComponentFn: () => schemaComponent, + }); + + await expect(page.generateFromPrompt()).rejects.toThrow( + 'Generated domain field "Book Title" is missing a command.' + ); + + expect(dom.window.document.getElementById('writer-schema-generation-status').textContent).toContain( + 'Unable to generate a schema from the prompt' + ); + expect(dom.window.document.getElementById('writer-schema-error-output').textContent).toContain( + 'Generated domain field "Book Title" is missing a command.' + ); + expect(dom.window.document.getElementById('writer-schema-raw-output').textContent).toContain( + '"sourceType":"domain"' + ); + expect(dom.window.document.getElementById('writer-schema-request-output').textContent).toContain( + '"outputLanguage": "en"' + ); + expect(dom.window.document.getElementById('writer-schema-progress-output').textContent).toContain( + 'Generation failed: Generated domain field "Book Title" is missing a command.' + ); + }); + + test('bootstrap keeps valid schema rows when some generated fields are invalid', async () => { + const schemaComponent = { + destroy: jest.fn(), + replaceRows: jest.fn(), + setTextMode: jest.fn(), + render: jest.fn(), + syncTextFromRows: jest.fn(), + validateRows: jest.fn(() => ({ errors: [] })), + getSchemaText: jest.fn(() => 'Author Name\nperson.fullName()'), + }; + const writer = { + write: jest.fn(async () => + JSON.stringify({ + schemaFields: [ + { name: 'Author Name', sourceType: 'domain', command: 'person.fullName' }, + { name: 'Publisher', sourceType: 'domain', command: 'commerce.publisher' }, + ], + }) + ), + }; + const WriterCtor = { + availability: jest.fn(async () => 'available'), + create: jest.fn(async () => writer), + }; + + const page = await bootstrapWriterSchemaPage({ + documentObj: dom.window.document, + WriterCtor, + createThemeToggleComponentFn: () => ({ destroy: jest.fn() }), + createSharedSchemaDefinitionComponentFn: () => schemaComponent, + }); + + await page.generateFromPrompt(); + + expect(schemaComponent.replaceRows).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ name: 'Author Name', command: 'person.fullName' })]) + ); + expect(dom.window.document.getElementById('writer-schema-generation-status').textContent).toContain( + 'could not be mapped and were left out' + ); + expect(dom.window.document.getElementById('writer-schema-error-output').textContent).toContain( + 'unsupported command "commerce.publisher"' + ); + expect(dom.window.document.getElementById('writer-schema-progress-output').textContent).toContain( + 'Completed with partial recovery.' + ); + }); +}); diff --git a/apps/web/src/writer-schema-entry.mjs b/apps/web/src/writer-schema-entry.mjs new file mode 100644 index 00000000..0195bde7 --- /dev/null +++ b/apps/web/src/writer-schema-entry.mjs @@ -0,0 +1,5 @@ +void import('./writer-schema-page.mjs') + .then(({ bootstrapWriterSchemaPage }) => bootstrapWriterSchemaPage()) + .catch((error) => { + globalThis.console?.error?.('Failed to load Writer schema prototype page', error); + }); diff --git a/apps/web/src/writer-schema-page.mjs b/apps/web/src/writer-schema-page.mjs new file mode 100644 index 00000000..c170d8d1 --- /dev/null +++ b/apps/web/src/writer-schema-page.mjs @@ -0,0 +1,889 @@ +import RandExp from 'randexp'; +import { faker } from '@faker-js/faker'; +import { createThemeToggleComponent } from '../../../packages/core-ui/js/gui_components/shared/theme-toggle.js'; +import { createSharedSchemaDefinitionComponent } from '../../../packages/core-ui/js/gui_components/shared/schema-definition/index.js'; +import { + schemaTextToDataRules, + dataRulesToSchemaText, + schemaRowsToDataRules, +} from '@anywaydata/core/data_generation/schema-rules-adapter.js'; +import { validateSchemaRows as validateSharedSchemaRows } from '../../../packages/core-ui/js/gui_components/shared/test-data/schema/schema-editor-core.js'; +import { mapDataRuleToSchemaRow } from '../../../packages/core-ui/js/gui_components/shared/test-data/schema/schema-row-mapper.js'; +import { + buildRuleSpecFromSchemaRow, + extractEnumValueFromRuleSpec, +} from '../../../packages/core-ui/js/gui_components/shared/schema-row-rule-mapper.js'; +import { TEST_DATA_GRID_SAMPLE_SCHEMA_TEXT } from '../../../packages/core-ui/js/gui_components/shared/test-data/schema/schema-examples.js'; +import { + identifyFakerCommands, + getMethodPickerOptions, + getFakerCommands, + getDomainCommands, +} from '../../../packages/core-ui/js/gui_components/app/test-data-grid/schema/test-data-command-catalog.js'; +import { getDomainCommandHelp } from '../../../packages/core-ui/js/gui_components/shared/domain-command-help-metadata.js'; +import { getVisibleDomainCommands } from '../../../packages/core-ui/js/gui_components/shared/test-data/help/domain-command-provider.js'; + +const DEFAULT_PROMPT = 'Create 10 fields that represent the inventory of a bookshop'; +const WRITER_SUPPORTED_SOURCE_TYPES = new Set(['domain', 'enum', 'literal', 'regex']); +const WRITER_DATE_FIELD_HINT = + /\b(date|time|timestamp|day|month|year|birthday|dob|created|updated|published|expires?)\b/i; +const WRITER_DATE_LITERAL_PLACEHOLDER = + /^(yyyy[-/]mm[-/]dd|yyyy-mm-dd|mm[-/]dd[-/]yyyy|dd[-/]mm[-/]yyyy|iso[-_ ]?date|date string|timestamp)$/i; +const WRITER_DOMAIN_GUIDE_DOMAINS = [ + 'book', + 'commerce', + 'company', + 'person', + 'date', + 'location', + 'internet', + 'phone', + 'finance', + 'number', + 'string', +]; + +function setStatus(statusElement, message, options = {}) { + if (!statusElement) { + return; + } + + statusElement.textContent = message; + statusElement.classList.remove('is-loading'); + + if (options.isLoading) { + statusElement.classList.add('is-loading'); + } + + if (options.severity) { + statusElement.setAttribute('data-severity', options.severity); + } else { + statusElement.removeAttribute('data-severity'); + } +} + +function setJsonOutput(element, value) { + if (!element) { + return; + } + + if (typeof value === 'string') { + element.textContent = value; + return; + } + + element.textContent = JSON.stringify(value, null, 2); +} + +function setTextOutput(element, value, fallback = '') { + if (!element) { + return; + } + + const text = String(value ?? '').trim(); + element.textContent = text || fallback; +} + +function clearProgressOutput(element, documentObj = globalThis.document) { + if (!element) { + return; + } + + element.innerHTML = ''; + + const item = documentObj?.createElement?.('li'); + if (!item) { + return; + } + + item.textContent = 'No generation activity yet.'; + element.append(item); +} + +function appendProgressOutput(element, message, documentObj = globalThis.document) { + if (!element || !documentObj) { + return; + } + + if (element.children.length === 1 && element.firstElementChild?.textContent === 'No generation activity yet.') { + element.innerHTML = ''; + } + + const item = documentObj.createElement('li'); + item.textContent = message; + element.append(item); +} + +function createWriterGenerationError(message, details = {}) { + const error = new Error(message); + Object.assign(error, details); + return error; +} + +function buildWriterRequestDetails({ promptText, sharedContext, taskContext, writeOptions }) { + return { + promptText, + sharedContext, + taskContext, + writeOptions, + }; +} + +function createBlankRowFactory(prefix = 'writer-schema-row') { + let counter = 0; + return () => ({ + id: `${prefix}-${counter++}`, + name: '', + sourceType: 'regex', + command: '', + params: '', + value: '', + comments: '', + leadingTextLines: [], + }); +} + +function validateSchemaRows(schemaRows) { + return validateSharedSchemaRows({ + schemaRows, + schemaRowsToDataRules, + }); +} + +function buildModeHelpHtml({ inTextMode }) { + if (inTextMode) { + return ` +

Edit as Schema

+

Switch back to row mode after reviewing the generated schema text or making manual edits.

+ + `; + } + + return ` +

Edit as Text

+

Switch to text mode when you want to inspect the generated schema spec directly.

+ + `; +} + +function createSchemaDefinitionProps() { + identifyFakerCommands(); + const createBlankRow = createBlankRowFactory(); + const domainCommands = getDomainCommands(); + const fakerCommands = getFakerCommands().filter((command) => command !== 'RegEx' && command.startsWith('helpers.')); + + return { + headingText: 'Schema', + schemaTextToDataRules, + dataRulesToSchemaText, + faker, + RandExp, + createBlankRow, + mapRuleToRow: (rule, leadingTextLines = []) => { + const row = mapDataRuleToSchemaRow(rule, { + createBlankSchemaRow: createBlankRow, + }); + row.leadingTextLines = Array.isArray(leadingTextLines) ? leadingTextLines.slice() : []; + return row; + }, + getMethodPickerOptions, + getVisibleDomainCommands: (currentValue = '') => + getVisibleDomainCommands({ + commands: domainCommands, + currentCommand: String(currentValue || '').trim(), + }), + fakerCommands, + sampleSchemaText: TEST_DATA_GRID_SAMPLE_SCHEMA_TEXT, + buildModeHelpHtml, + validateSchemaRows, + writerContextDomainCommands: getVisibleDomainCommands({ + commands: domainCommands, + currentCommand: '', + }), + }; +} + +function createExampleStructuredSchema() { + return { + schemaFields: [ + { name: 'Book Title', sourceType: 'domain', command: 'commerce.productName' }, + { name: 'Author Name', sourceType: 'domain', command: 'person.fullName' }, + { name: 'Genre', sourceType: 'enum', values: ['Fiction', 'Non-fiction', 'Sci-fi', 'Children'] }, + { name: 'ISBN', sourceType: 'regex', pattern: '[0-9]{3}-[0-9]{10}' }, + { name: 'In Stock', sourceType: 'enum', values: ['Yes', 'No'] }, + { name: 'Price Band', sourceType: 'enum', values: ['Budget', 'Standard', 'Premium'] }, + ], + }; +} + +function buildWriterDomainCommandGuide(domainCommands = []) { + const commandsByDomain = new Map(); + + domainCommands.forEach((command) => { + const domainName = String(command || '') + .trim() + .split('.')[0]; + if (!domainName) { + return; + } + + if (!commandsByDomain.has(domainName)) { + commandsByDomain.set(domainName, []); + } + commandsByDomain.get(domainName).push(command); + }); + + return WRITER_DOMAIN_GUIDE_DOMAINS.filter((domainName) => commandsByDomain.has(domainName)) + .map((domainName) => { + const entries = commandsByDomain + .get(domainName) + .slice(0, 6) + .map((command) => { + const help = getDomainCommandHelp(command); + const example = String(help?.example || '').trim(); + const summary = String(help?.summary || '') + .trim() + .replace(/\s+/g, ' '); + const parts = [command]; + if (example) { + parts.push(`example: ${example}`); + } + if (summary) { + parts.push(summary); + } + return `- ${parts.join(' | ')}`; + }); + + return `${domainName} commands:\n${entries.join('\n')}`; + }) + .join('\n\n'); +} + +function buildWriterSharedContext({ domainCommands = [], sampleSchemaText = TEST_DATA_GRID_SAMPLE_SCHEMA_TEXT } = {}) { + const domainCommandGuide = buildWriterDomainCommandGuide(domainCommands); + + return [ + 'You create schemas for AnyWayData, a schema-based test data generator.', + 'The app will parse your response into a schema editor.', + 'Return JSON only. Do not wrap the JSON in markdown fences. Do not add explanation before or after the JSON.', + 'Supported sourceType values are exactly: domain, enum, literal, regex.', + 'A domain command is valid only if it exactly matches one of the allowed commands. Never invent or rename commands.', + 'If you are unsure about the exact domain command, prefer enum, literal, or regex instead of guessing a domain command.', + 'Use domain commands when they fit the prompt naturally. Use enum for small closed sets, literal for one fixed value, and regex for pattern-shaped identifiers.', + 'For date-like fields, prefer date domain commands such as date.recent, date.past, date.future, date.birthdate, date.anytime, date.month, or date.weekday when one fits.', + 'Do not use literal placeholders like YYYY-MM-DD, MM/DD/YYYY, date string, or timestamp for date fields.', + 'For enum fields, provide a values array of strings.', + 'For literal fields, provide a value string.', + 'For regex fields, provide a pattern string.', + 'For domain fields, provide a command string and optional params string without surrounding parentheses.', + 'Do not create commands like commerce.publisher if they are not explicitly listed. For example, use book.publisher if that is the listed command.', + 'Output shape:', + JSON.stringify(createExampleStructuredSchema(), null, 2), + 'Compact command guide:', + domainCommandGuide, + 'Only use domain commands from this list:', + domainCommands.join(', '), + 'Example AnyWayData schema text:', + sampleSchemaText, + ].join('\n\n'); +} + +function buildWriterTaskContext() { + return [ + 'Respond with a single JSON object containing a schemaFields array.', + 'Write the response in English.', + 'Each schemaFields item must include a user-facing field name and one supported sourceType.', + 'Prefer between 6 and 12 fields unless the prompt clearly requests another count.', + 'Keep field names concise and realistic for the requested domain.', + ].join(' '); +} + +function extractJsonPayload(text) { + const source = String(text || '').trim(); + if (!source) { + throw new Error('Writer API returned an empty response.'); + } + + const fencedMatch = source.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fencedMatch?.[1]) { + return fencedMatch[1].trim(); + } + + const objectStart = source.indexOf('{'); + const objectEnd = source.lastIndexOf('}'); + if (objectStart >= 0 && objectEnd > objectStart) { + return source.slice(objectStart, objectEnd + 1).trim(); + } + + return source; +} + +function parseWriterStructuredOutput(text) { + const jsonPayload = extractJsonPayload(text); + + try { + return JSON.parse(jsonPayload); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Writer API returned non-JSON output: ${message}`, { + cause: error, + }); + } +} + +function quoteEnumValue(value) { + return JSON.stringify(String(value ?? '')); +} + +function isDatePlaceholderLiteral(name, value) { + const fieldName = String(name || '').trim(); + const literalValue = String(value || '').trim(); + + if (!fieldName || !literalValue) { + return false; + } + + return WRITER_DATE_FIELD_HINT.test(fieldName) && WRITER_DATE_LITERAL_PLACEHOLDER.test(literalValue); +} + +function suggestAllowedDomainCommands(command, allowedDomainCommands = []) { + const normalizedCommand = String(command || '') + .trim() + .toLowerCase(); + const [domainName = '', suffix = ''] = normalizedCommand.split('.'); + + const sameDomain = allowedDomainCommands.filter((candidate) => candidate.toLowerCase().startsWith(`${domainName}.`)); + const suffixMatches = allowedDomainCommands.filter((candidate) => candidate.toLowerCase().endsWith(`.${suffix}`)); + return [...new Set([...sameDomain, ...suffixMatches])].slice(0, 5); +} + +function inferSourceTypeFromField(field = {}) { + const explicitSourceType = String(field.sourceType || '') + .trim() + .toLowerCase(); + if (WRITER_SUPPORTED_SOURCE_TYPES.has(explicitSourceType)) { + return explicitSourceType; + } + + if (Array.isArray(field.values)) { + return 'enum'; + } + if (typeof field.pattern === 'string' && field.pattern.trim()) { + return 'regex'; + } + if (typeof field.command === 'string' && field.command.trim()) { + return 'domain'; + } + + const rule = String(field.rule || '').trim(); + if (!rule) { + return ''; + } + if (/^(enum|datatype\.enum|awd\.datatype\.enum)\s*\(/i.test(rule)) { + return 'enum'; + } + if (/^(literal|datatype\.literal|awd\.datatype\.literal)\s*\(/i.test(rule)) { + return 'literal'; + } + if (/^(regex|datatype\.regex|awd\.datatype\.regex)\s*\(/i.test(rule)) { + return 'regex'; + } + if (/^[A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*)+/.test(rule)) { + return 'domain'; + } + + return ''; +} + +function normalizeStructuredField(field, index = 0, { allowedDomainCommands = [] } = {}) { + const name = String(field?.name || '') + .trim() + .replace(/\s+/g, ' '); + if (!name) { + throw new Error(`Generated field ${index + 1} is missing a name.`); + } + + const sourceType = inferSourceTypeFromField(field); + if (!WRITER_SUPPORTED_SOURCE_TYPES.has(sourceType)) { + throw new Error(`Generated field "${name}" used an unsupported sourceType.`); + } + + const row = { + id: `writer-schema-generated-${index}`, + name, + sourceType, + command: '', + params: '', + value: '', + comments: '', + leadingTextLines: [], + }; + + if (sourceType === 'domain') { + const command = String(field.command || field.rule || '') + .trim() + .replace(/\s*\([\s\S]*\)\s*$/, ''); + if (!command) { + throw new Error(`Generated domain field "${name}" is missing a command.`); + } + if ( + Array.isArray(allowedDomainCommands) && + allowedDomainCommands.length > 0 && + !allowedDomainCommands.includes(command) + ) { + const suggestions = suggestAllowedDomainCommands(command, allowedDomainCommands); + const suggestionText = + suggestions.length > 0 ? ` Try one of: ${suggestions.join(', ')}.` : ' Use one of the listed allowed commands.'; + throw new Error(`Generated domain field "${name}" used unsupported command "${command}".${suggestionText}`); + } + row.command = command; + row.params = String(field.params || '').trim(); + return row; + } + + if (sourceType === 'enum') { + if (Array.isArray(field.values) && field.values.length > 0) { + row.value = field.values.map((value) => quoteEnumValue(value)).join(','); + return row; + } + + const enumSpec = String(field.value || field.rule || '').trim(); + row.value = extractEnumValueFromRuleSpec(enumSpec); + if (!row.value) { + throw new Error(`Generated enum field "${name}" is missing values.`); + } + return row; + } + + if (sourceType === 'regex') { + row.value = String(field.pattern || field.value || field.rule || '').trim(); + if (!row.value) { + throw new Error(`Generated regex field "${name}" is missing a pattern.`); + } + return row; + } + + row.value = String(field.value || field.literal || field.rule || ''); + if (isDatePlaceholderLiteral(name, row.value)) { + throw new Error( + `Generated field "${name}" used placeholder literal "${row.value}" for a date-like field. Use an allowed date.* domain command instead.` + ); + } + return row; +} + +function normalizeStructuredSchemaPayload(payload, { allowedDomainCommands = [] } = {}) { + if (!payload || typeof payload !== 'object' || !Array.isArray(payload.schemaFields)) { + throw new Error('Writer API output must be a JSON object with a schemaFields array.'); + } + + if (payload.schemaFields.length === 0) { + throw new Error('Writer API output did not contain any schema fields.'); + } + + const schemaRows = []; + const normalizationErrors = []; + + payload.schemaFields.forEach((field, index) => { + try { + schemaRows.push( + normalizeStructuredField(field, index, { + allowedDomainCommands, + }) + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + normalizationErrors.push({ + index, + field, + message, + }); + } + }); + + if (schemaRows.length === 0 && normalizationErrors.length > 0) { + throw createWriterGenerationError( + normalizationErrors.map((item) => item.message).join('; '), + { + normalizationErrors, + } + ); + } + + return { + schemaRows, + normalizationErrors, + }; +} + +async function resolveWriterAvailability(WriterCtor) { + if (!WriterCtor?.availability) { + return 'unavailable'; + } + + return WriterCtor.availability(); +} + +async function createWriterInstance(WriterCtor, { onStatus, sharedContext }) { + const createOptions = { + format: 'plain-text', + length: 'medium', + tone: 'neutral', + expectedInputLanguages: ['en'], + expectedContextLanguages: ['en'], + outputLanguage: 'en', + sharedContext, + monitor(monitorHandle) { + monitorHandle.addEventListener('downloadprogress', (event) => { + const loadedPercent = typeof event?.loaded === 'number' ? Math.round(event.loaded * 100) : null; + onStatus?.( + loadedPercent === null + ? 'Downloading the local Writer model...' + : `Downloading the local Writer model... ${loadedPercent}%`, + { isLoading: true, severity: 'info' } + ); + }); + }, + }; + const writer = await WriterCtor.create(createOptions); + + onStatus?.('Writer model ready. Generating structured schema output...', { + isLoading: true, + severity: 'info', + }); + + return { + writer, + createOptions, + }; +} + +async function runWriterSchemaGeneration({ WriterCtor, promptText, domainCommands, sampleSchemaText, onStatus }) { + let responseText = ''; + + const sharedContext = buildWriterSharedContext({ + domainCommands, + sampleSchemaText, + }); + const taskContext = buildWriterTaskContext(); + const { writer, createOptions } = await createWriterInstance(WriterCtor, { onStatus, sharedContext }); + const writeOptions = { + context: taskContext, + expectedInputLanguages: ['en'], + expectedContextLanguages: ['en'], + outputLanguage: 'en', + }; + const requestDetails = buildWriterRequestDetails({ + promptText, + sharedContext, + taskContext, + writeOptions, + }); + + try { + onStatus?.('Writer session ready. Requesting schema output...', { + isLoading: true, + severity: 'info', + requestDetails, + }); + + if (typeof writer.writeStreaming === 'function') { + onStatus?.('Streaming Writer response...', { + isLoading: true, + severity: 'info', + requestDetails, + }); + + for await (const chunk of writer.writeStreaming(promptText, writeOptions)) { + responseText += String(chunk || ''); + onStatus?.(`Streaming Writer response... ${responseText.length} characters received.`, { + isLoading: true, + severity: 'info', + rawResponseText: responseText, + requestDetails, + }); + } + } else { + onStatus?.('Waiting for Writer response...', { + isLoading: true, + severity: 'info', + requestDetails, + }); + + responseText = await writer.write(promptText, writeOptions); + onStatus?.('Writer response received. Parsing structured schema output...', { + isLoading: true, + severity: 'info', + rawResponseText: responseText, + requestDetails, + }); + } + + const parsedPayload = parseWriterStructuredOutput(responseText); + onStatus?.('Structured output parsed. Validating generated schema fields...', { + isLoading: true, + severity: 'info', + rawResponseText: responseText, + requestDetails, + }); + + const { schemaRows, normalizationErrors } = normalizeStructuredSchemaPayload(parsedPayload, { + allowedDomainCommands: domainCommands, + }); + + return { + parsedPayload, + schemaRows, + normalizationErrors, + responseText, + requestDetails: { + ...requestDetails, + createOptions, + }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw createWriterGenerationError(message, { + cause: error, + writerResponseText: responseText, + writerRequestDetails: { + ...requestDetails, + createOptions, + }, + }); + } finally { + writer.destroy?.(); + } +} + +async function bootstrapWriterSchemaPage({ + documentObj = globalThis.document, + WriterCtor = globalThis.Writer, + createThemeToggleComponentFn = createThemeToggleComponent, + createSharedSchemaDefinitionComponentFn = createSharedSchemaDefinitionComponent, +} = {}) { + if (!documentObj) { + return null; + } + + const supportStatusElement = documentObj.getElementById('writer-schema-support-status'); + const generationStatusElement = documentObj.getElementById('writer-schema-generation-status'); + const jsonOutputElement = documentObj.getElementById('writer-schema-json-output'); + const rawOutputElement = documentObj.getElementById('writer-schema-raw-output'); + const errorOutputElement = documentObj.getElementById('writer-schema-error-output'); + const progressOutputElement = documentObj.getElementById('writer-schema-progress-output'); + const requestOutputElement = documentObj.getElementById('writer-schema-request-output'); + const promptElement = documentObj.getElementById('writer-schema-prompt'); + const generateButton = documentObj.getElementById('writer-schema-generate'); + const examplePromptButton = documentObj.getElementById('writer-schema-example-prompt'); + const schemaRoot = documentObj.getElementById('writer-schema-editor-root'); + + const schemaDefinitionProps = createSchemaDefinitionProps(); + const schemaComponent = createSharedSchemaDefinitionComponentFn({ + root: schemaRoot, + documentObj, + props: schemaDefinitionProps, + callbacks: { + onSchemaError: (message) => { + const errorElement = schemaRoot?.querySelector?.('[data-role="schema-error"]'); + if (errorElement) { + errorElement.textContent = message; + errorElement.style.display = message ? 'inline-block' : 'none'; + } + }, + onSchemaClear: () => { + const errorElement = schemaRoot?.querySelector?.('[data-role="schema-error"]'); + if (errorElement) { + errorElement.textContent = ''; + errorElement.style.display = 'none'; + } + }, + }, + }); + + const themeToggle = createThemeToggleComponentFn({ + documentObj, + hostElement: documentObj.querySelector('[data-role="theme-toggle-host"]'), + }); + + promptElement.value = DEFAULT_PROMPT; + setJsonOutput(jsonOutputElement, 'No output yet.'); + setTextOutput(rawOutputElement, '', 'No raw Writer response yet.'); + setTextOutput(errorOutputElement, '', 'No errors yet.'); + setJsonOutput(requestOutputElement, 'No Writer request has been sent yet.'); + clearProgressOutput(progressOutputElement, documentObj); + + const availability = await resolveWriterAvailability(WriterCtor); + if (!WriterCtor) { + setStatus( + supportStatusElement, + 'Writer API is not available in this browser session. Open this page in a supported Chrome build with the Writer API flags or origin trial enabled.', + { severity: 'warning' } + ); + } else if (availability === 'available') { + setStatus(supportStatusElement, 'Writer API is available in this browser session.', { severity: 'info' }); + } else if (availability === 'downloadable') { + setStatus( + supportStatusElement, + 'Writer API support is present, but the local model still needs to download the first time you run it.', + { severity: 'warning' } + ); + } else { + setStatus( + supportStatusElement, + `Writer API is currently reported as "${availability}". Check Chrome support, local hardware requirements, and the relevant flags.`, + { severity: 'warning' } + ); + } + + let isGenerating = false; + + async function generateFromPrompt() { + const promptText = String(promptElement?.value || '').trim(); + if (!promptText) { + setStatus(generationStatusElement, 'Enter a prompt before generating a schema.', { severity: 'warning' }); + return null; + } + + if (!WriterCtor) { + setStatus(generationStatusElement, 'Writer API is not available in this browser session.', { severity: 'error' }); + return null; + } + + if (isGenerating) { + return null; + } + + isGenerating = true; + generateButton.disabled = true; + setJsonOutput(jsonOutputElement, 'No structured output parsed yet.'); + setTextOutput(rawOutputElement, '', 'Waiting for Writer response...'); + setTextOutput(errorOutputElement, '', 'No errors yet.'); + setJsonOutput(requestOutputElement, 'Preparing Writer request details...'); + clearProgressOutput(progressOutputElement, documentObj); + appendProgressOutput(progressOutputElement, 'Starting schema generation request.', documentObj); + setStatus(generationStatusElement, 'Generating structured schema output...', { + isLoading: true, + severity: 'info', + }); + + try { + const result = await runWriterSchemaGeneration({ + WriterCtor, + promptText, + domainCommands: schemaDefinitionProps.writerContextDomainCommands, + sampleSchemaText: schemaDefinitionProps.sampleSchemaText, + onStatus: (message, options = {}) => { + setStatus(generationStatusElement, message, options); + appendProgressOutput(progressOutputElement, message, documentObj); + if (typeof options.rawResponseText === 'string') { + setTextOutput(rawOutputElement, options.rawResponseText, 'No raw Writer response yet.'); + } + if (options.requestDetails) { + setJsonOutput(requestOutputElement, options.requestDetails); + } + }, + }); + + setJsonOutput(jsonOutputElement, result.parsedPayload); + setTextOutput(rawOutputElement, result.responseText, 'No raw Writer response yet.'); + const normalizationErrors = Array.isArray(result.normalizationErrors) ? result.normalizationErrors : []; + setTextOutput( + errorOutputElement, + normalizationErrors.length > 0 + ? normalizationErrors.map((item) => item.message).join('\n\n') + : '', + 'No errors yet.' + ); + setJsonOutput(requestOutputElement, result.requestDetails); + schemaComponent.setTextMode?.(false); + schemaComponent.replaceRows?.(result.schemaRows); + schemaComponent.render?.(); + schemaComponent.syncTextFromRows?.(); + + const validation = schemaComponent.validateRows?.() || { errors: [] }; + const renderedSchemaText = + typeof schemaComponent.getSchemaText === 'function' + ? schemaComponent.getSchemaText() + : result.schemaRows.map((row) => `${row.name}\n${buildRuleSpecFromSchemaRow(row)}`).join('\n\n'); + + if (Array.isArray(validation.errors) && validation.errors.length > 0) { + throw new Error(validation.errors.map((error) => error?.message || String(error)).join('; ')); + } + + if (normalizationErrors.length > 0) { + setStatus( + generationStatusElement, + `Generated ${result.schemaRows.length} schema fields. ${normalizationErrors.length} generated field${normalizationErrors.length === 1 ? '' : 's'} could not be mapped and were left out.`, + { severity: 'warning' } + ); + appendProgressOutput( + progressOutputElement, + `Completed with partial recovery. ${normalizationErrors.length} field${normalizationErrors.length === 1 ? '' : 's'} were rejected during normalization.`, + documentObj + ); + } else { + setStatus( + generationStatusElement, + `Generated ${result.schemaRows.length} schema fields and populated the shared schema editor.`, + { severity: 'info' } + ); + appendProgressOutput(progressOutputElement, 'Schema generation completed successfully.', documentObj); + } + + return { + ...result, + renderedSchemaText, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const fullErrorText = + error instanceof Error && error.stack ? error.stack : `Error: ${message}`; + const rawResponseText = + typeof error?.writerResponseText === 'string' ? error.writerResponseText : ''; + const requestDetails = error?.writerRequestDetails ?? 'Writer request details were not captured.'; + + setTextOutput(errorOutputElement, fullErrorText, 'No errors yet.'); + setTextOutput(rawOutputElement, rawResponseText, 'No raw Writer response was returned.'); + setJsonOutput(requestOutputElement, requestDetails); + appendProgressOutput(progressOutputElement, `Generation failed: ${message}`, documentObj); + setStatus(generationStatusElement, `Unable to generate a schema from the prompt: ${message}`, { + severity: 'error', + }); + throw error; + } finally { + isGenerating = false; + generateButton.disabled = false; + } + } + + examplePromptButton?.addEventListener('click', () => { + promptElement.value = DEFAULT_PROMPT; + promptElement.focus(); + }); + generateButton?.addEventListener('click', () => { + void generateFromPrompt().catch(() => {}); + }); + + return { + destroy() { + themeToggle?.destroy?.(); + schemaComponent?.destroy?.(); + }, + generateFromPrompt, + getSchemaComponent() { + return schemaComponent; + }, + }; +} + +export { + DEFAULT_PROMPT, + bootstrapWriterSchemaPage, + buildWriterSharedContext, + buildWriterTaskContext, + extractJsonPayload, + normalizeStructuredSchemaPayload, + parseWriterStructuredOutput, + runWriterSchemaGeneration, +}; diff --git a/apps/web/styles.css b/apps/web/styles.css index 3bcef639..a427cfef 100644 --- a/apps/web/styles.css +++ b/apps/web/styles.css @@ -217,6 +217,168 @@ body.theme-dark .dragdropzone { gap: 0.75rem; } +.writer-schema-page { + display: flex; + flex-direction: column; + gap: 1.25rem; + padding: 1rem; +} + +.writer-schema-hero, +.writer-schema-card { + border: 1px solid var(--border-color); + border-radius: 1rem; + background: var(--panel-bg); + padding: 1rem; +} + +.writer-schema-hero { + background: linear-gradient(135deg, rgba(169, 214, 229, 0.2), rgba(216, 243, 220, 0.45)), var(--panel-bg); +} + +.writer-schema-hero__toolbar { + display: flex; + justify-content: flex-end; + min-height: 2.25rem; + margin-bottom: 0.5rem; +} + +.writer-schema-eyebrow { + margin: 0 0 0.4rem; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #1f5f80; +} + +.writer-schema-lead, +.writer-schema-supporting-copy { + max-width: 75ch; +} + +.writer-schema-layout { + display: grid; + grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); + gap: 1rem; +} + +.writer-schema-card { + min-width: 0; +} + +.writer-schema-card__header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.writer-schema-card__header h2 { + margin: 0; +} + +.writer-schema-label { + display: inline-block; + margin-bottom: 0.5rem; + font-weight: 700; +} + +.writer-schema-textarea, +.writer-schema-json-output { + box-sizing: border-box; + width: 100%; + min-height: 14rem; + padding: 0.85rem; + border: 1px solid var(--border-color); + border-radius: 0.75rem; + background: rgba(245, 251, 255, 0.6); + color: var(--page-text); + font: inherit; +} + +.writer-schema-json-output { + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font-family: 'Courier New', Courier, monospace; + line-height: 1.4; + resize: vertical; + max-height: 32rem; +} + +.writer-schema-progress-output { + box-sizing: border-box; + margin: 0; + padding: 0 0 0 1.25rem; + display: flex; + flex-direction: column; + gap: 0.55rem; + width: 100%; + min-height: 14rem; + max-height: 32rem; + overflow: auto; + resize: vertical; + border: 1px solid var(--border-color); + border-radius: 0.75rem; + background: rgba(245, 251, 255, 0.45); + padding: 0.85rem 0.85rem 0.85rem 2rem; +} + +.writer-schema-progress-output li { + padding: 0.35rem 0.5rem; + border-left: 3px solid rgba(31, 95, 128, 0.25); + background: rgba(245, 251, 255, 0.45); + border-radius: 0.35rem; +} + +.writer-schema-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin: 0.75rem 0; +} + +.writer-schema-card__header > button, +.writer-schema-actions > button { + border: 0; + border-radius: 999px; + background: var(--button-bg); + color: var(--button-text); + cursor: pointer; + font: inherit; + font-weight: 700; + padding: 0.7rem 1rem; +} + +.writer-schema-card__header > button:disabled, +.writer-schema-actions > button:disabled { + cursor: progress; + opacity: 0.7; +} + +body.theme-dark .writer-schema-eyebrow { + color: #89c2d9; +} + +body.theme-dark .writer-schema-textarea, +body.theme-dark .writer-schema-json-output { + background: rgba(17, 17, 18, 0.55); +} + +body.theme-dark .writer-schema-progress-output li { + background: rgba(17, 17, 18, 0.45); + border-left-color: rgba(137, 194, 217, 0.45); +} + +@media (max-width: 900px) { + .writer-schema-layout { + grid-template-columns: 1fr; + } +} + .import-export-workspace { display: flex; flex-direction: column; @@ -780,7 +942,8 @@ Custom header styles for default items padding: 1.4rem; border: 1px solid var(--border-color); border-radius: 14px; - background: radial-gradient(circle at top right, rgba(46, 133, 85, 0.14), transparent 36%), + background: + radial-gradient(circle at top right, rgba(46, 133, 85, 0.14), transparent 36%), linear-gradient(135deg, color-mix(in srgb, var(--panel-bg) 88%, #ebf9f2 12%), var(--panel-bg)); } diff --git a/apps/web/vite.config.mjs b/apps/web/vite.config.mjs index 6b2191f1..e623e812 100644 --- a/apps/web/vite.config.mjs +++ b/apps/web/vite.config.mjs @@ -11,8 +11,14 @@ export default defineConfig({ alias: [ { find: /^@anywaydata\/core$/, replacement: path.resolve(__dirname, '../../packages/core/src/index.js') }, { find: /^@anywaydata\/core\/mcp\/(.*)$/, replacement: path.resolve(__dirname, '../../packages/core/js/mcp/$1') }, - { find: /^@anywaydata\/core\/faker\/(.*)$/, replacement: path.resolve(__dirname, '../../packages/core/js/faker/$1') }, - { find: /^@anywaydata\/core\/domain\/(.*)$/, replacement: path.resolve(__dirname, '../../packages/core/js/domain/$1') }, + { + find: /^@anywaydata\/core\/faker\/(.*)$/, + replacement: path.resolve(__dirname, '../../packages/core/js/faker/$1'), + }, + { + find: /^@anywaydata\/core\/domain\/(.*)$/, + replacement: path.resolve(__dirname, '../../packages/core/js/domain/$1'), + }, { find: /^@anywaydata\/core\/data_formats\/(.*)$/, replacement: path.resolve(__dirname, '../../packages/core/js/data_formats/$1'), @@ -21,9 +27,18 @@ export default defineConfig({ find: /^@anywaydata\/core\/data_generation\/(.*)$/, replacement: path.resolve(__dirname, '../../packages/core/js/data_generation/$1'), }, - { find: /^@anywaydata\/core\/grid\/(.*)$/, replacement: path.resolve(__dirname, '../../packages/core/js/grid/$1') }, - { find: /^@anywaydata\/core\/utils\/(.*)$/, replacement: path.resolve(__dirname, '../../packages/core/js/utils/$1') }, - { find: /^@anywaydata\/core\/libs\/(.*)$/, replacement: path.resolve(__dirname, '../../packages/core/js/libs/$1') }, + { + find: /^@anywaydata\/core\/grid\/(.*)$/, + replacement: path.resolve(__dirname, '../../packages/core/js/grid/$1'), + }, + { + find: /^@anywaydata\/core\/utils\/(.*)$/, + replacement: path.resolve(__dirname, '../../packages/core/js/utils/$1'), + }, + { + find: /^@anywaydata\/core\/libs\/(.*)$/, + replacement: path.resolve(__dirname, '../../packages/core/js/libs/$1'), + }, ], }, server: { @@ -44,6 +59,7 @@ export default defineConfig({ generator: path.resolve(__dirname, 'generator.html'), webmcp: path.resolve(__dirname, 'webmcp.html'), combinatorial: path.resolve(__dirname, 'combinatorial.html'), + writerSchema: path.resolve(__dirname, 'writer-schema.html'), }, }, }, diff --git a/apps/web/writer-schema.html b/apps/web/writer-schema.html new file mode 100644 index 00000000..48e0ed57 --- /dev/null +++ b/apps/web/writer-schema.html @@ -0,0 +1,155 @@ + + + + Writer API Schema Prototype - AnyWayData + + + + + + + + + + + + +
+
+
+

Experimental Test Environment

+

Writer API Schema Prototype

+

+ This page uses Chrome's in-browser Writer API to turn a natural-language prompt into structured schema field + data, then maps that result into the real AnyWayData schema editor. +

+ +

+ Checking Writer API availability... +

+
+ +
+
+
+

Prompt

+ +
+ + +
+ +
+

+ Local Chrome setup reference: + chrome://flags/#optimization-guide-on-device-model, + chrome://flags/#prompt-api-for-gemini-nano-multimodal-input, and + chrome://flags/#writer-api-for-gemini-nano. +

+

+ Ready for a prompt. +

+
+ +
+
+

Progress Log

+
+

+ Generation stages and streaming updates from the Writer request lifecycle. +

+
    +
  1. No generation activity yet.
  2. +
+
+
+ +
+
+
+

Full Request

+
+

+ The exact prompt, shared context, task context, and language options sent to the Writer API. +

+
+No Writer request has been sent yet.
+
+ +
+
+

Raw AI Response

+
+

+ The full text returned by the Writer API before parsing and schema validation. +

+
+No raw Writer response yet.
+
+
+ +
+
+
+

Structured Output

+
+

+ The Writer API returns prompt-constrained JSON text for this prototype. The page validates and normalizes it + before populating the schema editor. +

+
No output yet.
+
+ +
+
+

Full Error

+
+

+ The full error text, including the exact validation failure when schema parsing goes wrong. +

+
No errors yet.
+
+
+ +
+
+

Schema Editor

+
+

+ This is the same shared schema-definition component used by the app and generator paths. +

+
+
+
+ + diff --git a/scripts/create-testenv.mjs b/scripts/create-testenv.mjs index b2fd2cf4..6c47cf15 100644 --- a/scripts/create-testenv.mjs +++ b/scripts/create-testenv.mjs @@ -26,6 +26,7 @@ const ROOT_PAGE_CANONICALS = { 'generator.html': `${TESTENV_CANONICAL_SITE_URL}/generator.html`, 'combinatorial.html': `${TESTENV_CANONICAL_SITE_URL}/combinatorial.html`, 'webmcp.html': `${TESTENV_CANONICAL_SITE_URL}/webmcp.html`, + 'writer-schema.html': `${TESTENV_CANONICAL_SITE_URL}/writer-schema.html`, }; const TESTENV_INDICATOR_STYLE = `', + '' ); expect(html.match(/data-testenv-hide-header/g)).toHaveLength(1);