ubiquitous-invention/apps/web/app/api/chat/route.ts
Randall Stillwell a1e6c863d5 feat(web): wire AI chat page to streaming /api/chat handler
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>
2026-06-02 00:23:21 -05:00

101 lines
3.2 KiB
TypeScript

import { TRPCError } from "@trpc/server";
import { createOpenAI } from "@ai-sdk/openai";
import { streamText, type CoreMessage } from "ai";
import { z } from "zod";
import { auth } from "@/lib/auth";
import { resolveWorkspace } from "@/server/lib/resolve-workspace";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const BASE_SYSTEM = `You are a helpful AI assistant embedded in a project management and collaboration app. Users organize work in workspaces with objects such as projects, tasks, documents, and groups. You help them plan work, clarify requirements, break down tasks, summarize content, and suggest next steps. Be concise, actionable, and friendly. Use markdown when it improves readability (bold, lists, short code snippets).`;
const messageSchema = z.object({
role: z.enum(["user", "assistant", "system"]),
content: z.string(),
});
const bodySchema = z.object({
workspace: z.string().min(1),
messages: z.array(messageSchema).min(1),
});
function errorResponse(status: number, message: string): Response {
return new Response(JSON.stringify({ error: message }), {
status,
headers: { "Content-Type": "application/json" },
});
}
export async function POST(req: Request): Promise<Response> {
const session = await auth();
if (!session?.user?.id) {
return errorResponse(401, "Sign in to use the assistant");
}
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await req.json());
} catch (e) {
if (e instanceof z.ZodError) {
return errorResponse(400, e.issues[0]?.message ?? "Invalid request");
}
return errorResponse(400, "Invalid request body");
}
let workspace;
try {
workspace = await resolveWorkspace({
handle: body.workspace,
userId: session.user.id,
});
} catch (e) {
if (e instanceof TRPCError) {
const status =
e.code === "NOT_FOUND" ? 404 :
e.code === "FORBIDDEN" ? 403 :
e.code === "BAD_REQUEST" ? 400 :
500;
return errorResponse(status, e.message);
}
console.error("[api/chat] resolveWorkspace failed:", e);
return errorResponse(500, "Failed to resolve workspace");
}
const apiKey = process.env.OPENAI_API_KEY?.trim();
if (!apiKey) {
// Surface as a structured error so the client can render an "unavailable"
// banner instead of a generic streaming failure. We don't fake a stream
// here — that would mask the misconfiguration and confuse operators.
return errorResponse(
503,
"AI is unavailable. Set OPENAI_API_KEY in the server environment to enable the assistant.",
);
}
const system =
BASE_SYSTEM +
`\n\n---\nCurrent workspace: ${workspace.name} (${workspace.slug})`;
const openai = createOpenAI({
apiKey,
baseURL: process.env.OPENAI_BASE_URL?.trim() || undefined,
});
const model =
process.env.OPENAI_MODEL?.trim() || "gpt-4o-mini";
try {
const result = streamText({
model: openai(model),
system,
messages: body.messages as CoreMessage[],
temperature: 0.7,
});
return result.toDataStreamResponse();
} catch (e) {
console.error("[api/chat] streamText failed:", e);
return errorResponse(502, "Provider request failed");
}
}