102 lines
3.2 KiB
TypeScript
102 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");
|
||
|
|
}
|
||
|
|
}
|