From 66d593235b90c31e96cc36829de8dd9e73076e9e Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:30:32 +0800 Subject: [PATCH] feat(spec): gallery + gantt are first-class blueprint view kinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AI build agent's blueprint proposal could only express list/form/kanban/ calendar views — both the lenient schema and the strict mirror (the structured-output contract the model is validated against) capped `view.type` at those four. So a user asking for a 画廊/gallery or 甘特图/gantt could never get one from a blueprint: the closest allowed enum value (list) won and the view silently downgraded to a grid, even though the view spec (ListViewSchema) and the objectui renderers (ObjectGallery, plugin-gantt) fully support both. Widen BlueprintViewSchema.type and the StrictView mirror to include 'gallery' and 'gantt', with guidance in the field descriptions on when to pick each and which columns to include (the image field as the gallery cover; the start date before the end date for a gantt). No new blueprint slots — the cover image and the gantt date fields are inferred downstream from the object's fields, exactly as kanban's groupBy already is. Consumed by @objectstack/cloud's service-ai-studio (blueprint LIST_TYPE + viewBody honor the new kinds; the build skill teaches them). Co-Authored-By: Claude Opus 4.8 --- .../spec/src/ai/solution-blueprint.test.ts | 29 +++++++++++++++++++ .../spec/src/ai/solution-blueprint.zod.ts | 15 +++++----- 2 files changed, 37 insertions(+), 7 deletions(-) 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({