ubiquitous-invention/apps/web/app/api/chat/route.ts

102 lines
3.2 KiB
TypeScript
Raw Normal View History

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 01:23:21 -04:00
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");
}
}