diff --git a/packages/spec/src/ai/solution-blueprint.test.ts b/packages/spec/src/ai/solution-blueprint.test.ts index aa90bd43f5..74d87c321c 100644 --- a/packages/spec/src/ai/solution-blueprint.test.ts +++ b/packages/spec/src/ai/solution-blueprint.test.ts @@ -53,6 +53,21 @@ describe('SolutionBlueprintSchema', () => { expect(parsed.views?.[0].type).toBe('list'); }); + it('accepts gallery and gantt as first-class view kinds (not only list/form/kanban/calendar)', () => { + // A build agent that wants a poster wall or a schedule must be able to PROPOSE + // a gallery/gantt view directly — otherwise the closest allowed enum value + // (list) wins and the view silently downgrades to a grid. + const parsed = SolutionBlueprintSchema.parse({ + summary: 'events', + objects: [{ name: 'event', fields: [{ name: 'name', type: 'text' }, { name: 'poster', type: 'image' }] }], + views: [ + { object: 'event', name: 'poster_wall', type: 'gallery', columns: ['poster', 'name'] }, + { object: 'event', name: 'schedule', type: 'gantt', columns: ['name'] }, + ], + }); + expect(parsed.views?.map((v) => v.type)).toEqual(['gallery', 'gantt']); + }); + it('rejects a missing summary', () => { const { summary: _drop, ...noSummary } = validBlueprint; expect(() => SolutionBlueprintSchema.parse(noSummary)).toThrow(); @@ -204,6 +219,20 @@ describe('SolutionBlueprintStrictSchema (OpenAI strict mirror)', () => { expect(parsed.app).toBeNull(); }); + it('accepts gallery/gantt view kinds in the strict mirror (the structured-output contract)', () => { + // This is the schema the build agent's structured output is validated against, + // so the gallery/gantt enum values MUST live here too — else the model can + // never emit them and a requested gallery degrades to a list/grid. + const parsed = SolutionBlueprintStrictSchema.parse({ + ...strictBp, + views: [ + { object: 'event', name: 'wall', label: null, type: 'gallery', columns: null, groupBy: null }, + { object: 'event', name: 'plan', label: null, type: 'gantt', columns: null, groupBy: null }, + ], + }); + expect(parsed.views?.map((v) => v.type)).toEqual(['gallery', 'gantt']); + }); + it('requires every top-level key to be present (OpenAI strict needs all in `required`)', () => { const { views: _v, ...missingViews } = strictBp; expect(() => SolutionBlueprintStrictSchema.parse(missingViews)).toThrow(); diff --git a/packages/spec/src/ai/solution-blueprint.zod.ts b/packages/spec/src/ai/solution-blueprint.zod.ts index d702fb00b4..d7e0009921 100644 --- a/packages/spec/src/ai/solution-blueprint.zod.ts +++ b/packages/spec/src/ai/solution-blueprint.zod.ts @@ -50,16 +50,17 @@ export const BlueprintObjectSchema = lazySchema(() => z.object({ })); export type BlueprintObject = z.infer; -/** A proposed list/form/kanban/calendar view over an object. */ +/** A proposed list/form/kanban/calendar/gallery/gantt view over an object. */ export const BlueprintViewSchema = lazySchema(() => z.object({ object: z.string().regex(SNAKE_CASE).describe('Object this view displays (snake_case)'), name: z.string().regex(SNAKE_CASE).describe('View machine name (snake_case)'), label: z.string().optional().describe('Human-readable view label'), - type: z.enum(['list', 'form', 'kanban', 'calendar']).default('list').describe('View kind'), + type: z.enum(['list', 'form', 'kanban', 'calendar', 'gallery', 'gantt']).default('list') + .describe('View kind. Pick the surface that fits the data: "gallery" for a visual card/cover browse when the user asks for a 画廊/相册/卡片墙/封面/海报/图集 (a gallery / card wall / cover / poster grid) or the object has an image/avatar/file field worth showing as a card cover; "gantt" for a 甘特图/时间线/排期 (timeline / schedule) when the object has BOTH a start and an end date field; "kanban" for a board grouped by a status/select field; "calendar" for a single-date schedule; "form" for a record editor; else "list".'), columns: z.array(z.string().regex(SNAKE_CASE)).optional() - .describe('Field names shown as columns (in order)'), + .describe('Field names shown as columns (in order). For a gallery, INCLUDE the image/avatar/file field (it becomes the card cover); for a gantt, INCLUDE the start date column before the end date column.'), groupBy: z.string().regex(SNAKE_CASE).optional() - .describe('REQUIRED for kanban views: the select/status field whose options become the board columns (e.g. "stage", "status"). Without it a kanban renders as a plain list.'), + .describe('REQUIRED for kanban views: the select/status field whose options become the board columns (e.g. "stage", "status"). Without it a kanban renders as a plain list. Optional for gantt (groups leaf tasks into summary rows).'), })); export type BlueprintView = z.infer; @@ -203,9 +204,9 @@ const StrictView = z.object({ object: z.string().describe('Object this view displays (snake_case)'), name: z.string().describe('View machine name (snake_case)'), label: z.string().nullable().describe('Human-readable view label, or null'), - type: z.enum(['list', 'form', 'kanban', 'calendar']).nullable().describe('View kind, or null for list'), - columns: z.array(z.string()).nullable().describe('Field names shown as columns, or null'), - groupBy: z.string().nullable().describe('REQUIRED for kanban: the select/status field whose options become the board columns (e.g. "stage"). Null for non-kanban views.'), + type: z.enum(['list', 'form', 'kanban', 'calendar', 'gallery', 'gantt']).nullable().describe('View kind, or null for list. "gallery" = visual card/cover browse (画廊/相册/卡片墙/封面/海报, or an object with an image/avatar/file field); "gantt" = timeline/schedule (甘特图/时间线/排期, object with BOTH a start and an end date field); "kanban" = board grouped by a status/select field; "calendar" = single-date schedule; "form" = record editor.'), + columns: z.array(z.string()).nullable().describe('Field names shown as columns, or null. For a gallery, INCLUDE the image/avatar/file field (becomes the card cover); for a gantt, INCLUDE the start date column before the end date column.'), + groupBy: z.string().nullable().describe('REQUIRED for kanban: the select/status field whose options become the board columns (e.g. "stage"). Optional for gantt (groups leaf tasks). Null for list/form/calendar/gallery.'), }); const StrictDashboard = z.object({