assistant-panel.tsx222 lines · main
1'use client';
2
3import { useState, type FormEvent } from 'react';
4import { useRouter } from 'next/navigation';
5
6interface PlannedColumn {
7 name: string;
8 type: string;
9 notNull: boolean;
10}
11interface PlannedTable {
12 name: string;
13 description: string;
14 columns: PlannedColumn[];
15 sampleRows: Record<string, unknown>[];
16}
17interface AssistantPlan {
18 summary: string;
19 tables: PlannedTable[];
20}
21
22// Plain-English names for the raw types (mirrors new-table-form).
23const TYPE_LABELS: Record<string, string> = {
24 text: 'text',
25 integer: 'whole number',
26 bigint: 'whole number',
27 boolean: 'yes / no',
28 timestamptz: 'date & time',
29 jsonb: 'data',
30 uuid: 'link',
31 numeric: 'decimal',
32};
33
34const EXAMPLES = [
35 'Track my customers and the orders they place',
36 'A simple CRM with companies, contacts and deals',
37 'Inventory for my shop: products, suppliers and stock counts',
38];
39
40export function AssistantPanel({ projectId }: { projectId: string }) {
41 const router = useRouter();
42 const [prompt, setPrompt] = useState('');
43 const [plan, setPlan] = useState<AssistantPlan | null>(null);
44 const [phase, setPhase] = useState<'idle' | 'planning' | 'applying'>('idle');
45 const [error, setError] = useState<string | null>(null);
46 const [done, setDone] = useState<string | null>(null);
47
48 async function design(e: FormEvent) {
49 e.preventDefault();
50 if (prompt.trim() === '') return;
51 setError(null);
52 setDone(null);
53 setPlan(null);
54 setPhase('planning');
55 try {
56 const res = await fetch(`/api/v1/projects/${projectId}/studio/assistant/plan`, {
57 method: 'POST',
58 headers: { 'content-type': 'application/json' },
59 body: JSON.stringify({ prompt }),
60 });
61 const body = (await res.json().catch(() => ({}))) as { plan?: AssistantPlan; message?: string };
62 if (!res.ok) throw new Error(body.message ?? `the assistant couldn't help just now (${res.status})`);
63 setPlan(body.plan ?? null);
64 } catch (err) {
65 setError(err instanceof Error ? err.message : 'something went wrong');
66 } finally {
67 setPhase('idle');
68 }
69 }
70
71 async function build() {
72 if (!plan) return;
73 setError(null);
74 setPhase('applying');
75 try {
76 const res = await fetch(`/api/v1/projects/${projectId}/studio/assistant/apply`, {
77 method: 'POST',
78 headers: { 'content-type': 'application/json' },
79 body: JSON.stringify({ plan }),
80 });
81 const body = (await res.json().catch(() => ({}))) as {
82 created?: { table: string }[];
83 skipped?: string[];
84 message?: string;
85 };
86 if (!res.ok) throw new Error(body.message ?? `build failed (${res.status})`);
87 const made = body.created?.length ?? 0;
88 const skip = body.skipped?.length ?? 0;
89 setDone(
90 `✅ built ${made} table${made === 1 ? '' : 's'}` +
91 (skip > 0 ? ` · skipped ${skip} that already existed` : ''),
92 );
93 setPlan(null);
94 setPrompt('');
95 router.refresh();
96 } catch (err) {
97 setError(err instanceof Error ? err.message : 'build failed');
98 } finally {
99 setPhase('idle');
100 }
101 }
102
103 return (
104 <section className="flex flex-col gap-4 rounded-md border border-[var(--color-border-primary)] bg-[var(--color-primary-subtle)]/30 p-5">
105 <div className="flex flex-col gap-1">
106 <h3 className="font-mono text-sm text-[var(--color-text)]">✨ ask the assistant</h3>
107 <p className="font-mono text-xs text-[var(--color-text-muted)]">
108 describe what you want to keep track of, in your own words — the assistant designs the
109 tables, you review them, and build with one click. nothing is created until you say so.
110 </p>
111 </div>
112
113 <form onSubmit={design} className="flex flex-col gap-2">
114 <textarea
115 value={prompt}
116 onChange={(e) => setPrompt(e.target.value)}
117 rows={3}
118 placeholder={`e.g. "${EXAMPLES[0]}"`}
119 className="w-full resize-y rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 font-mono text-sm outline-none focus:border-[var(--color-primary)]"
120 />
121 <div className="flex flex-wrap items-center gap-2">
122 <button
123 type="submit"
124 disabled={phase !== 'idle' || prompt.trim() === ''}
125 className="rounded-md bg-[var(--color-primary)] px-4 py-2 font-mono text-sm font-medium text-[var(--color-text-inverse)] transition hover:bg-[var(--color-primary-hover)] disabled:opacity-50"
126 >
127 {phase === 'planning' ? 'designing…' : 'design it'}
128 </button>
129 {phase === 'idle' && !plan
130 ? EXAMPLES.map((ex) => (
131 <button
132 key={ex}
133 type="button"
134 onClick={() => setPrompt(ex)}
135 className="rounded-md border border-[var(--color-border-subtle)] px-2 py-1 font-mono text-[10px] text-[var(--color-text-subtle)] hover:text-[var(--color-text-muted)]"
136 >
137 {ex.toLowerCase()}
138 </button>
139 ))
140 : null}
141 </div>
142 </form>
143
144 {error ? (
145 <p className="rounded-md bg-red-400/10 px-3 py-2 font-mono text-xs text-red-400">{error}</p>
146 ) : null}
147 {done ? (
148 <p className="rounded-md bg-[var(--color-primary-subtle)] px-3 py-2 font-mono text-xs text-[var(--color-primary)]">
149 {done}
150 </p>
151 ) : null}
152
153 {plan ? (
154 <div className="flex flex-col gap-3 border-t border-[var(--color-border-subtle)] pt-4">
155 {plan.summary ? (
156 <p className="font-mono text-xs text-[var(--color-text-muted)]">{plan.summary}</p>
157 ) : null}
158 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
159 {plan.tables.map((t) => (
160 <div
161 key={t.name}
162 className="flex flex-col gap-2 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-3"
163 >
164 <div>
165 <p className="font-mono text-sm text-[var(--color-text)]">{t.name}</p>
166 {t.description ? (
167 <p className="font-mono text-[11px] text-[var(--color-text-subtle)]">
168 {t.description}
169 </p>
170 ) : null}
171 </div>
172 <ul className="flex flex-col gap-1">
173 {t.columns.map((col) => (
174 <li
175 key={col.name}
176 className="flex items-center justify-between gap-2 font-mono text-[11px]"
177 >
178 <span className="text-[var(--color-text-muted)]">{col.name}</span>
179 <span className="text-[var(--color-text-subtle)]">
180 {TYPE_LABELS[col.type] ?? col.type}
181 {col.notNull ? ' · required' : ''}
182 </span>
183 </li>
184 ))}
185 </ul>
186 {t.sampleRows.length > 0 ? (
187 <p className="font-mono text-[10px] text-[var(--color-text-subtle)]">
188 + {t.sampleRows.length} example row{t.sampleRows.length === 1 ? '' : 's'}
189 </p>
190 ) : null}
191 </div>
192 ))}
193 </div>
194 <div className="flex flex-wrap items-center gap-2">
195 <button
196 type="button"
197 onClick={build}
198 disabled={phase !== 'idle'}
199 className="rounded-md bg-[var(--color-primary)] px-4 py-2 font-mono text-sm font-medium text-[var(--color-text-inverse)] transition hover:bg-[var(--color-primary-hover)] disabled:opacity-50"
200 >
201 {phase === 'applying' ? 'building…' : '✅ build it'}
202 </button>
203 <button
204 type="button"
205 onClick={() => {
206 setPlan(null);
207 setError(null);
208 }}
209 disabled={phase !== 'idle'}
210 className="rounded-md border border-[var(--color-border)] px-4 py-2 font-mono text-sm text-[var(--color-text-muted)] transition hover:text-[var(--color-text)] disabled:opacity-50"
211 >
212 start over
213 </button>
214 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
215 tip: save a snapshot first if you want a clean undo point.
216 </span>
217 </div>
218 </div>
219 ) : null}
220 </section>
221 );
222}