ask-form.tsx140 lines · main
1'use client';
2
3import Link from 'next/link';
4import { useState, type FormEvent } from 'react';
5
6interface AskResponse {
7 question: string;
8 answer: string;
9 citations: { slug: string; title: string }[];
10 model: string;
11 elapsedMs: number;
12}
13
14interface NotConfiguredResponse {
15 code: 'not_configured';
16 message: string;
17}
18
19export function AskForm() {
20 const [question, setQuestion] = useState('');
21 const [pending, setPending] = useState(false);
22 const [result, setResult] = useState<AskResponse | null>(null);
23 const [error, setError] = useState<string | null>(null);
24 const [notConfigured, setNotConfigured] = useState(false);
25
26 async function submit(e: FormEvent<HTMLFormElement>) {
27 e.preventDefault();
28 if (!question.trim()) return;
29 setPending(true);
30 setError(null);
31 setResult(null);
32 setNotConfigured(false);
33 try {
34 const res = await fetch('/api/ask', {
35 method: 'POST',
36 headers: { 'content-type': 'application/json' },
37 body: JSON.stringify({ question: question.trim() }),
38 });
39 if (res.status === 503) {
40 const body = (await res.json().catch(() => null)) as NotConfiguredResponse | null;
41 setNotConfigured(true);
42 setError(body?.message ?? 'AI features are disabled on this deployment');
43 return;
44 }
45 if (!res.ok) {
46 const text = await res.text().catch(() => '');
47 throw new Error(text || `request failed (${res.status})`);
48 }
49 const data = (await res.json()) as AskResponse;
50 setResult(data);
51 } catch (err) {
52 setError(err instanceof Error ? err.message : 'request failed');
53 } finally {
54 setPending(false);
55 }
56 }
57
58 return (
59 <div className="flex flex-col gap-4">
60 <form onSubmit={submit} className="flex flex-col gap-3">
61 <textarea
62 value={question}
63 onChange={(e) => setQuestion(e.currentTarget.value)}
64 rows={3}
65 maxLength={2000}
66 required
67 disabled={pending}
68 placeholder="how do I make a query reactive? — what's the difference between query and action? — how do I migrate from prisma?"
69 className="resize-y rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 font-mono text-sm outline-none focus:border-[var(--color-primary)] disabled:opacity-60"
70 />
71 <div className="flex items-center justify-between">
72 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
73 {question.length} / 2000
74 </span>
75 <button
76 type="submit"
77 disabled={pending || !question.trim()}
78 className="rounded-md bg-[var(--color-primary)] px-4 py-2 font-mono text-sm font-medium text-[var(--color-text-inverse)] hover:bg-[var(--color-primary-hover)] disabled:opacity-60"
79 >
80 {pending ? 'thinking…' : 'ask'}
81 </button>
82 </div>
83 </form>
84
85 {error ? (
86 <div
87 className={`rounded-md border p-4 font-mono text-xs ${
88 notConfigured
89 ? 'border-[var(--color-border-subtle)] bg-[var(--color-surface)] text-[var(--color-text-muted)]'
90 : 'border-red-400/40 bg-red-500/5 text-red-300'
91 }`}
92 >
93 {notConfigured ? (
94 <>
95 <p className="text-[var(--color-text)]">ask is offline</p>
96 <p className="mt-1">{error}</p>
97 <p className="mt-2 text-[var(--color-text-subtle)]">
98 in the meantime, try{' '}
99 <Link href="/search" className="underline">
100 keyword search
101 </Link>
102 .
103 </p>
104 </>
105 ) : (
106 error
107 )}
108 </div>
109 ) : null}
110
111 {result ? (
112 <div className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-4">
113 <p className="mb-3 font-mono text-xs text-[var(--color-text-subtle)]">
114 answered by {result.model} in {result.elapsedMs}ms · grounded in{' '}
115 {result.citations.length} page{result.citations.length === 1 ? '' : 's'}
116 </p>
117 <div className="whitespace-pre-wrap font-mono text-sm leading-relaxed text-[var(--color-text)]">
118 {result.answer}
119 </div>
120 {result.citations.length > 0 ? (
121 <div className="mt-4 flex flex-col gap-2 border-t border-[var(--color-border-subtle)] pt-3">
122 <p className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
123 citations
124 </p>
125 {result.citations.map((c) => (
126 <Link
127 key={c.slug}
128 href={c.slug}
129 className="font-mono text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
130 >
131 {c.slug} — {c.title}
132 </Link>
133 ))}
134 </div>
135 ) : null}
136 </div>
137 ) : null}
138 </div>
139 );
140}