Skip to content

Commit cd0dad8

Browse files
feat(forms): Add streaming support for AI form generation (#103)
* feat(forms): add streaming support for AI form generation - Add @ai-sdk/openai and @ai-sdk/react packages - Create streaming API route at /api/generate-form - Update CreateTemplateForm to use useObject hook for progressive updates - Add streamGenerateForm method to JsonRenderProvider - Form fields now appear progressively as AI generates them Part of #51 - AI-powered form builder streaming support * fix(forms): remove unused import, fix zod error property * fix(templates): move form.setValue to useEffect (review feedback) * fix(forms): resolve type error with maxResponses schema Change z.coerce.number() to z.number() to fix type inference issue with react-hook-form resolver types. * chore: retrigger CI * chore: retrigger CI (flaky e2e) --------- Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
1 parent 0cdcf5e commit cd0dad8

11 files changed

Lines changed: 553 additions & 69 deletions

File tree

.github/MILESTONE_STATUS.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
## 2026-02-12 18:18 UTC — Hourly Check
2+
3+
**Milestones:**
4+
5+
- M1: Admin Onboarding — ✅ COMPLETE (4/4 closed)
6+
- M2: Employee Auto-Join — ✅ COMPLETE (3/3 closed)
7+
- M3: Managed Templates & Links — 3/4 (1 open: #51 AI form builder)
8+
- M4: Groups & Hierarchy — ✅ COMPLETE (2/2 closed)
9+
- M5: Feedback Collection — 3/5 (2 open)
10+
11+
**PRs:**
12+
13+
- #75 (hidden flinks) — MERGED
14+
- #76 (custom slugs) — MERGED
15+
- #102 (public group pages) — Approved, CI green, awaiting merge approval
16+
17+
**Next:** PR #102 ready to merge when Leo OKs. Issue #51 (AI form builder) next in queue.

apps/app/package.json

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,24 @@
1717
"type-check": "tsc --noEmit"
1818
},
1919
"dependencies": {
20+
"@ai-sdk/openai": "^3.0.27",
21+
"@ai-sdk/react": "^3.0.84",
2022
"@hookform/resolvers": "^5.2.2",
2123
"@propsto/auth": "workspace:*",
2224
"@propsto/config": "workspace:*",
2325
"@propsto/constants": "workspace:*",
2426
"@propsto/data": "workspace:*",
27+
"@propsto/email": "workspace:*",
28+
"@propsto/forms": "workspace:*",
2529
"@propsto/logger": "workspace:*",
2630
"@propsto/ui": "workspace:*",
31+
"@upstash/ratelimit": "^2.0.5",
32+
"@upstash/redis": "^1.36.2",
33+
"ai": "^6.0.82",
2734
"date-fns": "4.1.0",
2835
"react-hook-form": "7.60.0",
2936
"survey-core": "^2.5.6",
30-
"survey-react-ui": "^2.5.6",
31-
"@propsto/email": "workspace:*",
32-
"@propsto/forms": "workspace:*",
33-
"@upstash/ratelimit": "^2.0.5",
34-
"@upstash/redis": "^1.36.2"
37+
"survey-react-ui": "^2.5.6"
3538
},
3639
"peerDependencies": {
3740
"@types/node": "*",

apps/app/src/app/(dashboard)/links/new/create-link-form.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ const formSchema = z.object({
5151
templateId: z.string().min(1, "Please select a template"),
5252
feedbackType: z.nativeEnum(FeedbackType),
5353
visibility: z.nativeEnum(FeedbackVisibility),
54-
maxResponses: z.coerce.number().min(0).optional(),
54+
maxResponses: z.number().min(0).optional(),
5555
isHidden: z.boolean(),
5656
organizationId: z.string().optional(),
5757
});

apps/app/src/app/(dashboard)/org/[orgSlug]/admin/settings/slug-actions.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export async function checkSlugAvailability(
3333
try {
3434
const parsed = slugSchema.safeParse(slug);
3535
if (!parsed.success) {
36-
return { available: false, error: parsed.error.errors[0]?.message };
36+
return { available: false, error: parsed.error.issues[0]?.message };
3737
}
3838

3939
// Check reserved slugs
@@ -83,7 +83,7 @@ export async function updateOrgSlug(
8383
// Validate new slug
8484
const parsed = slugSchema.safeParse(newSlug);
8585
if (!parsed.success) {
86-
return { success: false, error: parsed.error.errors[0]?.message };
86+
return { success: false, error: parsed.error.issues[0]?.message };
8787
}
8888

8989
const normalizedSlug = newSlug.toLowerCase();

apps/app/src/app/(dashboard)/templates/new/create-template-form.tsx

Lines changed: 85 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"use client";
22

3-
import { useState, useTransition } from "react";
3+
import { useState, useTransition, useEffect } from "react";
44
import { useRouter } from "next/navigation";
55
import { zodResolver } from "@hookform/resolvers/zod";
66
import { useForm, useFieldArray } from "react-hook-form";
77
import { z } from "zod";
8+
import { experimental_useObject as useObject } from "@ai-sdk/react";
89
import { Button } from "@propsto/ui/atoms/button";
910
import { Input } from "@propsto/ui/atoms/input";
1011
import { Textarea } from "@propsto/ui/atoms/text-area";
@@ -33,8 +34,9 @@ import {
3334
SelectValue,
3435
} from "@propsto/ui/atoms/select";
3536
import { FeedbackType, FieldType } from "@prisma/client";
36-
import { Plus, Trash2, Sparkles, GripVertical } from "lucide-react";
37-
import { generateFormAction, createTemplateAction } from "./actions";
37+
import { Plus, Trash2, Sparkles, GripVertical, Loader2 } from "lucide-react";
38+
import { createTemplateAction } from "./actions";
39+
import { FieldTypeSchema } from "@propsto/forms";
3840

3941
const fieldSchema = z.object({
4042
label: z.string().min(1, "Label is required"),
@@ -78,12 +80,34 @@ const fieldTypeOptions = [
7880
{ value: "DATE", label: "Date" },
7981
];
8082

83+
// Schema for AI object streaming
84+
const aiFormSchema = z.object({
85+
name: z.string().describe("A concise, descriptive name for the form"),
86+
description: z
87+
.string()
88+
.optional()
89+
.describe("Brief description of the form's purpose"),
90+
fields: z
91+
.array(
92+
z.object({
93+
label: z.string().describe("The question or field label"),
94+
type: FieldTypeSchema.describe("The field type"),
95+
required: z.boolean().describe("Whether this field is required"),
96+
options: z
97+
.array(z.string())
98+
.optional()
99+
.describe("Options for SELECT/RADIO fields"),
100+
placeholder: z.string().optional().describe("Placeholder text hint"),
101+
helpText: z.string().optional().describe("Help text for the field"),
102+
})
103+
)
104+
.describe("Form fields in order"),
105+
});
106+
81107
export function CreateTemplateForm(): React.JSX.Element {
82108
const router = useRouter();
83109
const [isPending, startTransition] = useTransition();
84-
const [isGenerating, setIsGenerating] = useState(false);
85110
const [aiPrompt, setAiPrompt] = useState("");
86-
const [aiError, setAiError] = useState<string | null>(null);
87111

88112
const form = useForm<FormValues>({
89113
resolver: zodResolver(formSchema),
@@ -101,6 +125,52 @@ export function CreateTemplateForm(): React.JSX.Element {
101125
name: "fields",
102126
});
103127

128+
// Streaming AI form generation
129+
const {
130+
object: aiForm,
131+
submit: submitGeneration,
132+
isLoading: isGenerating,
133+
error: aiError,
134+
} = useObject({
135+
api: "/api/generate-form",
136+
schema: aiFormSchema,
137+
onFinish: ({ object }) => {
138+
if (object) {
139+
// Apply final form values
140+
form.setValue("name", object.name);
141+
if (object.description) {
142+
form.setValue("description", object.description);
143+
}
144+
if (object.fields) {
145+
replace(
146+
object.fields.map((f, i) => ({
147+
label: f.label,
148+
type: f.type as FieldType,
149+
required: f.required,
150+
options: f.options,
151+
placeholder: f.placeholder,
152+
helpText: f.helpText,
153+
order: i,
154+
}))
155+
);
156+
}
157+
setAiPrompt("");
158+
}
159+
},
160+
});
161+
162+
// Update form progressively as AI streams
163+
useEffect(() => {
164+
if (aiForm) {
165+
if (aiForm.name) {
166+
form.setValue("name", aiForm.name);
167+
}
168+
if (aiForm.description) {
169+
form.setValue("description", aiForm.description);
170+
}
171+
}
172+
}, [aiForm, form]);
173+
104174
function addField(): void {
105175
append({
106176
label: "",
@@ -110,36 +180,9 @@ export function CreateTemplateForm(): React.JSX.Element {
110180
});
111181
}
112182

113-
async function handleGenerate(): Promise<void> {
114-
if (!aiPrompt.trim()) return;
115-
116-
setIsGenerating(true);
117-
setAiError(null);
118-
119-
const result = await generateFormAction({ prompt: aiPrompt });
120-
121-
if (result.success && result.form) {
122-
form.setValue("name", result.form.name);
123-
if (result.form.description) {
124-
form.setValue("description", result.form.description);
125-
}
126-
replace(
127-
result.form.fields.map((f, i) => ({
128-
label: f.label,
129-
type: f.type as FieldType,
130-
required: f.required ?? false,
131-
options: f.options,
132-
placeholder: f.placeholder,
133-
helpText: f.helpText,
134-
order: i,
135-
})),
136-
);
137-
setAiPrompt("");
138-
} else {
139-
setAiError(result.error ?? "Failed to generate form");
140-
}
141-
142-
setIsGenerating(false);
183+
function handleGenerate(): void {
184+
if (!aiPrompt.trim() || isGenerating) return;
185+
submitGeneration({ prompt: aiPrompt });
143186
}
144187

145188
function onSubmit(values: FormValues): void {
@@ -191,7 +234,10 @@ export function CreateTemplateForm(): React.JSX.Element {
191234
disabled={isGenerating || !aiPrompt.trim()}
192235
>
193236
{isGenerating ? (
194-
<>Generating...</>
237+
<>
238+
<Loader2 className="mr-2 size-4 animate-spin" />
239+
Generating...
240+
</>
195241
) : (
196242
<>
197243
<Sparkles className="mr-2 size-4" />
@@ -200,7 +246,9 @@ export function CreateTemplateForm(): React.JSX.Element {
200246
)}
201247
</Button>
202248
{aiError && (
203-
<span className="text-sm text-destructive">{aiError}</span>
249+
<span className="text-sm text-destructive">
250+
{aiError.message || "Failed to generate form"}
251+
</span>
204252
)}
205253
</div>
206254
</CardContent>
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { createOpenAI } from "@ai-sdk/openai";
2+
import { streamObject } from "ai";
3+
import { z } from "zod";
4+
import { auth } from "@/server/auth.server";
5+
import { constServer } from "@propsto/constants/server";
6+
import { FieldTypeSchema } from "@propsto/forms";
7+
8+
const formSchema = z.object({
9+
name: z.string().describe("A concise, descriptive name for the form"),
10+
description: z
11+
.string()
12+
.optional()
13+
.describe("Brief description of the form's purpose"),
14+
fields: z
15+
.array(
16+
z.object({
17+
label: z.string().describe("The question or field label"),
18+
type: FieldTypeSchema.describe("The field type"),
19+
required: z.boolean().describe("Whether this field is required"),
20+
options: z
21+
.array(z.string())
22+
.optional()
23+
.describe("Options for SELECT/RADIO fields"),
24+
placeholder: z.string().optional().describe("Placeholder text hint"),
25+
helpText: z.string().optional().describe("Help text for the field"),
26+
})
27+
)
28+
.describe("Form fields in order"),
29+
});
30+
31+
const systemPrompt = `You are a form builder assistant. Create feedback forms based on user requests.
32+
33+
Generate a form with:
34+
- A clear, descriptive name
35+
- Optional description
36+
- Up to 10 fields
37+
38+
Available field types:
39+
- TEXT: Single line text
40+
- TEXTAREA: Multi-line text
41+
- NUMBER: Numeric input
42+
- RATING: 1-5 star rating
43+
- SCALE: 1-10 scale
44+
- SELECT: Dropdown (requires options)
45+
- RADIO: Radio buttons (requires options)
46+
- CHECKBOX: Single checkbox
47+
- DATE: Date picker
48+
49+
Guidelines:
50+
- Use clear, concise labels
51+
- Only mark truly important fields as required
52+
- For SELECT/RADIO, provide sensible options
53+
- Keep forms focused and user-friendly`;
54+
55+
export async function POST(request: Request): Promise<Response> {
56+
const session = await auth();
57+
if (!session?.user?.id) {
58+
return new Response("Unauthorized", { status: 401 });
59+
}
60+
61+
const apiKey = constServer.OPENAI_API_KEY;
62+
if (!apiKey) {
63+
return new Response(
64+
"AI form generation is not configured. Please add an OpenAI API key.",
65+
{ status: 503 }
66+
);
67+
}
68+
69+
const { prompt } = (await request.json()) as { prompt: string };
70+
if (!prompt || typeof prompt !== "string") {
71+
return new Response("Invalid prompt", { status: 400 });
72+
}
73+
74+
const openai = createOpenAI({ apiKey });
75+
76+
const result = streamObject({
77+
model: openai("gpt-4o-mini"),
78+
schema: formSchema,
79+
system: systemPrompt,
80+
prompt,
81+
});
82+
83+
return result.toTextStreamResponse();
84+
}

packages/forms/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
"type-check": "tsc --noEmit"
1515
},
1616
"dependencies": {
17+
"@ai-sdk/openai": "^3.0.27",
1718
"@json-render/core": "^0.3.0",
19+
"ai": "^6.0.82",
1820
"zod": "4.2.1"
1921
},
2022
"devDependencies": {

packages/forms/provider.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import type { GenerateFormOptions, GenerateFormResult } from "./types";
1+
import type {
2+
GenerateFormOptions,
3+
GenerateFormResult,
4+
PartialGeneratedForm,
5+
} from "./types";
26

37
/**
48
* Abstract interface for form generation providers.
@@ -20,6 +24,14 @@ export interface FormBuilderProvider {
2024
*/
2125
generateForm(options: GenerateFormOptions): Promise<GenerateFormResult>;
2226

27+
/**
28+
* Stream form generation with progressive updates.
29+
* Returns an async generator that yields partial forms.
30+
*/
31+
streamGenerateForm?(
32+
options: GenerateFormOptions
33+
): AsyncGenerator<PartialGeneratedForm, GenerateFormResult, unknown>;
34+
2335
/**
2436
* Check if the provider is properly configured and ready.
2537
*/

0 commit comments

Comments
 (0)