Path-A task 3/5. Replaces the setTimeout mock that returned the literal "Full AI integration is coming soon!" string with a real streaming provider call. * apps/web/app/api/chat/route.ts (new): POST handler that runs the same auth + resolveWorkspace pipeline workspaceProcedure uses, then streams a response from streamText().toDataStreamResponse(). Maps resolveWorkspace's TRPCError codes to HTTP status (401/403/404/400). Returns a structured 503 with a human-readable hint when OPENAI_API_KEY is unset, so the misconfiguration is surfaced rather than masked by a fake stream. * apps/web/app/(app)/[workspaceSlug]/ai/page.tsx: replace the local message-state + setTimeout placeholder with useChat from @ai-sdk/react. workspace slug is sent on every request body so the server can enforce tenant scoping. Adds a ChatErrorBanner that parses the JSON error body the route emits and renders amber for the "unavailable" case, destructive for other failures. * apps/web/package.json: pull in @ai-sdk/react as a direct dep (previously only transitive via `ai`). The existing aiRouter.chat tRPC mutation is left intact — it powers the right-panel command palette via the non-streaming generateText path, and rebuilding that as streaming was outside the scope of making the dedicated chat page usable. Provider selection still flows from env per packages/ai conventions: OPENAI_API_KEY gates availability, OPENAI_BASE_URL lets operators route through Ollama on CT 108 transparently, OPENAI_MODEL overrides the default gpt-4o-mini. `pnpm lint && pnpm type-check` clean. Closes plans/Plan-daily-driver-finish/Epic-shipping-the-shell/ Task-wire-ai-chat-to-trpc.md. Co-authored-by: Cursor <cursoragent@cursor.com>
212 lines
7.1 KiB
TypeScript
212 lines
7.1 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef } from "react";
|
|
import { useParams } from "next/navigation";
|
|
import { useChat } from "@ai-sdk/react";
|
|
import { AlertTriangle, Bot, Loader2, Send, Sparkles, User } from "lucide-react";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export default function AIPage() {
|
|
const params = useParams();
|
|
const workspaceSlug =
|
|
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : "";
|
|
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
const {
|
|
messages,
|
|
input,
|
|
handleInputChange,
|
|
handleSubmit,
|
|
status,
|
|
error,
|
|
setInput,
|
|
} = useChat({
|
|
api: "/api/chat",
|
|
body: { workspace: workspaceSlug },
|
|
});
|
|
|
|
const isBusy = status === "submitted" || status === "streaming";
|
|
|
|
useEffect(() => {
|
|
if (scrollRef.current) {
|
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
}
|
|
}, [messages, status]);
|
|
|
|
return (
|
|
<div className="flex h-full min-h-0 flex-col">
|
|
<div className="flex shrink-0 items-center gap-3 border-b px-6 py-4">
|
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
|
|
<Sparkles className="size-5 text-primary" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-lg font-semibold">AI Assistant</h1>
|
|
<p className="text-xs text-muted-foreground">
|
|
Ask me anything about your workspace
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
|
|
{messages.length === 0 ? (
|
|
<div className="flex h-full min-h-[12rem] flex-col items-center justify-center gap-4 text-muted-foreground">
|
|
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
|
|
<Bot className="size-8 text-primary" />
|
|
</div>
|
|
<div className="text-center">
|
|
<p className="text-base font-medium">How can I help you today?</p>
|
|
<p className="mt-1 text-sm">
|
|
Ask me to break down a task, draft an outline, or summarize what
|
|
you have open.
|
|
</p>
|
|
</div>
|
|
<div className="mt-2 flex flex-wrap justify-center gap-2">
|
|
{[
|
|
"Break this project into subtasks",
|
|
"Summarize my open tasks",
|
|
"Draft a sprint plan for this week",
|
|
].map((suggestion) => (
|
|
<button
|
|
key={suggestion}
|
|
type="button"
|
|
onClick={() => setInput(suggestion)}
|
|
className="rounded-full border px-3 py-1.5 text-xs transition-colors hover:bg-muted"
|
|
>
|
|
{suggestion}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="mx-auto max-w-3xl space-y-4 px-4 py-6">
|
|
{messages.map((msg) => (
|
|
<div
|
|
key={msg.id}
|
|
className={cn(
|
|
"flex gap-3",
|
|
msg.role === "user" && "flex-row-reverse",
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
|
msg.role === "user"
|
|
? "bg-primary text-primary-foreground"
|
|
: "bg-muted",
|
|
)}
|
|
>
|
|
{msg.role === "user" ? (
|
|
<User className="size-4" />
|
|
) : (
|
|
<Bot className="size-4" />
|
|
)}
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
"max-w-[80%] rounded-xl px-4 py-2.5 text-sm whitespace-pre-wrap",
|
|
msg.role === "user"
|
|
? "bg-primary text-primary-foreground"
|
|
: "bg-muted",
|
|
)}
|
|
>
|
|
{msg.content}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{status === "submitted" && (
|
|
<div className="flex gap-3">
|
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
|
|
<Bot className="size-4" />
|
|
</div>
|
|
<div className="rounded-xl bg-muted px-4 py-2.5">
|
|
<Loader2 className="size-4 animate-spin" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{error ? <ChatErrorBanner error={error} /> : null}
|
|
</div>
|
|
|
|
<div className="shrink-0 border-t bg-background p-4">
|
|
<form
|
|
onSubmit={handleSubmit}
|
|
className="mx-auto flex max-w-3xl gap-2"
|
|
>
|
|
<textarea
|
|
ref={inputRef}
|
|
value={input}
|
|
onChange={handleInputChange}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
if (input.trim() && !isBusy) {
|
|
// Cast: handleSubmit accepts FormEvent | KeyboardEvent; the
|
|
// useChat helper unwraps either.
|
|
handleSubmit(
|
|
e as unknown as React.FormEvent<HTMLFormElement>,
|
|
);
|
|
}
|
|
}
|
|
}}
|
|
placeholder="Ask anything..."
|
|
className="flex-1 resize-none rounded-lg border bg-background px-4 py-2.5 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
rows={1}
|
|
/>
|
|
<Button
|
|
type="submit"
|
|
size="icon"
|
|
disabled={!input.trim() || isBusy || !workspaceSlug}
|
|
>
|
|
<Send className="size-4" />
|
|
</Button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ChatErrorBanner({ error }: { error: Error }) {
|
|
const message = parseErrorMessage(error);
|
|
const isUnavailable = /AI is unavailable/i.test(message);
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"mx-auto mb-4 mt-2 flex max-w-3xl items-start gap-3 rounded-lg border px-4 py-3 text-sm",
|
|
isUnavailable
|
|
? "border-amber-500/40 bg-amber-500/10 text-amber-900 dark:text-amber-200"
|
|
: "border-destructive/40 bg-destructive/10 text-destructive",
|
|
)}
|
|
role="alert"
|
|
>
|
|
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
|
<div className="min-w-0">
|
|
<p className="font-medium">
|
|
{isUnavailable ? "AI is unavailable" : "Something went wrong"}
|
|
</p>
|
|
<p className="mt-1 break-words text-xs opacity-90">{message}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* `useChat` wraps fetch errors in a generic Error; the route handler returns
|
|
* a JSON body with `{ error: string }`. Try to surface that to the user; fall
|
|
* back to whatever message the SDK gave us.
|
|
*/
|
|
function parseErrorMessage(error: Error): string {
|
|
const raw = error.message || "";
|
|
try {
|
|
const parsed = JSON.parse(raw) as { error?: string };
|
|
if (parsed?.error) return parsed.error;
|
|
} catch {
|
|
// not JSON; fall through
|
|
}
|
|
return raw || "Unknown error";
|
|
}
|