diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 0853fac..75a3e8e 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -27,6 +27,12 @@ import { editDocument } from "@/lib/ai/tools/edit-document"; import { getWeather } from "@/lib/ai/tools/get-weather"; import { requestSuggestions } from "@/lib/ai/tools/request-suggestions"; import { updateDocument } from "@/lib/ai/tools/update-document"; +import { + requireOwnership, + requireSearchParam, + requireSession, + withErrorHandling, +} from "@/lib/api/route-helpers"; import { isProductionEnvironment } from "@/lib/constants"; import { createStreamId, @@ -444,27 +450,15 @@ export async function POST(request: Request) { } } -export async function DELETE(request: Request) { - const { searchParams } = new URL(request.url); - const id = searchParams.get("id"); - - if (!id) { - return new ChatbotError("bad_request:api").toResponse(); - } - - const session = await auth(); - - if (!session?.user) { - return new ChatbotError("unauthorized:chat").toResponse(); - } +export const DELETE = withErrorHandling(async (request: Request) => { + const id = requireSearchParam(request, "id"); + const session = await requireSession("chat"); const chat = await getChatById({ id }); - if (chat?.userId !== session.user.id) { - return new ChatbotError("forbidden:chat").toResponse(); - } + requireOwnership({ ownerId: chat?.userId, session, surface: "chat" }); const deletedChat = await deleteChatById({ id }); return Response.json(deletedChat, { status: 200 }); -} +}); diff --git a/app/(chat)/api/document/route.ts b/app/(chat)/api/document/route.ts index 81e676b..4a3c831 100644 --- a/app/(chat)/api/document/route.ts +++ b/app/(chat)/api/document/route.ts @@ -1,6 +1,11 @@ import { z } from "zod"; -import { auth } from "@/app/(auth)/auth"; -import type { ArtifactKind } from "@/components/chat/artifact"; +import { + parseJsonBody, + requireOwnership, + requireSearchParam, + requireSession, + withErrorHandling, +} from "@/lib/api/route-helpers"; import { deleteDocumentsByIdAfterTimestamp, getDocumentsById, @@ -16,22 +21,9 @@ const documentSchema = z.object({ title: z.string(), }); -export async function GET(request: Request) { - const { searchParams } = new URL(request.url); - const id = searchParams.get("id"); - - if (!id) { - return new ChatbotError( - "bad_request:api", - "Parameter id is missing" - ).toResponse(); - } - - const session = await auth(); - - if (!session?.user) { - return new ChatbotError("unauthorized:document").toResponse(); - } +export const GET = withErrorHandling(async (request: Request) => { + const id = requireSearchParam(request, "id"); + const session = await requireSession("document"); const documents = await getDocumentsById({ id }); @@ -41,54 +33,30 @@ export async function GET(request: Request) { return new ChatbotError("not_found:document").toResponse(); } - if (document.userId !== session.user.id) { - return new ChatbotError("forbidden:document").toResponse(); - } + requireOwnership({ + ownerId: document.userId, + session, + surface: "document", + }); return Response.json(documents, { status: 200 }); -} - -export async function POST(request: Request) { - const { searchParams } = new URL(request.url); - const id = searchParams.get("id"); - - if (!id) { - return new ChatbotError( - "bad_request:api", - "Parameter id is required." - ).toResponse(); - } +}); - const session = await auth(); +export const POST = withErrorHandling(async (request: Request) => { + const id = requireSearchParam(request, "id"); + const session = await requireSession("document"); - if (!session?.user) { - return new ChatbotError("not_found:document").toResponse(); - } - - let content: string; - let title: string; - let kind: ArtifactKind; - let isManualEdit: boolean | undefined; - - try { - ({ content, isManualEdit, kind, title } = documentSchema.parse( - await request.json() - )); - } catch { - return new ChatbotError( - "bad_request:api", - "Invalid request body." - ).toResponse(); - } + const { content, isManualEdit, kind, title } = await parseJsonBody( + request, + documentSchema + ); const documents = await getDocumentsById({ id }); if (documents.length > 0) { const [doc] = documents; - if (doc.userId !== session.user.id) { - return new ChatbotError("forbidden:document").toResponse(); - } + requireOwnership({ ownerId: doc.userId, session, surface: "document" }); } if (isManualEdit && documents.length > 0) { @@ -105,40 +73,22 @@ export async function POST(request: Request) { }); return Response.json(document, { status: 200 }); -} - -export async function DELETE(request: Request) { - const { searchParams } = new URL(request.url); - const id = searchParams.get("id"); - const timestamp = searchParams.get("timestamp"); - - if (!id) { - return new ChatbotError( - "bad_request:api", - "Parameter id is required." - ).toResponse(); - } - - if (!timestamp) { - return new ChatbotError( - "bad_request:api", - "Parameter timestamp is required." - ).toResponse(); - } - - const session = await auth(); +}); - if (!session?.user) { - return new ChatbotError("unauthorized:document").toResponse(); - } +export const DELETE = withErrorHandling(async (request: Request) => { + const id = requireSearchParam(request, "id"); + const timestamp = requireSearchParam(request, "timestamp"); + const session = await requireSession("document"); const documents = await getDocumentsById({ id }); const [document] = documents; - if (document.userId !== session.user.id) { - return new ChatbotError("forbidden:document").toResponse(); - } + requireOwnership({ + ownerId: document?.userId, + session, + surface: "document", + }); const parsedTimestamp = new Date(timestamp); @@ -155,4 +105,4 @@ export async function DELETE(request: Request) { }); return Response.json(documentsDeleted, { status: 200 }); -} +}); diff --git a/app/(chat)/api/history/route.ts b/app/(chat)/api/history/route.ts index 06d53e6..4da48bb 100644 --- a/app/(chat)/api/history/route.ts +++ b/app/(chat)/api/history/route.ts @@ -1,9 +1,9 @@ import type { NextRequest } from "next/server"; -import { auth } from "@/app/(auth)/auth"; +import { requireSession, withErrorHandling } from "@/lib/api/route-helpers"; import { deleteAllChatsByUserId, getChatsByUserId } from "@/lib/db/queries"; import { ChatbotError } from "@/lib/errors"; -export async function GET(request: NextRequest) { +export const GET = withErrorHandling(async (request: NextRequest) => { const { searchParams } = request.nextUrl; const limit = Math.min( @@ -20,11 +20,7 @@ export async function GET(request: NextRequest) { ).toResponse(); } - const session = await auth(); - - if (!session?.user) { - return new ChatbotError("unauthorized:chat").toResponse(); - } + const session = await requireSession("chat"); const chats = await getChatsByUserId({ endingBefore, @@ -34,16 +30,12 @@ export async function GET(request: NextRequest) { }); return Response.json(chats); -} +}); -export async function DELETE() { - const session = await auth(); - - if (!session?.user) { - return new ChatbotError("unauthorized:chat").toResponse(); - } +export const DELETE = withErrorHandling(async () => { + const session = await requireSession("chat"); const result = await deleteAllChatsByUserId({ userId: session.user.id }); return Response.json(result, { status: 200 }); -} +}); diff --git a/app/(chat)/api/suggestions/route.ts b/app/(chat)/api/suggestions/route.ts index 303f45e..c1e68c1 100644 --- a/app/(chat)/api/suggestions/route.ts +++ b/app/(chat)/api/suggestions/route.ts @@ -1,23 +1,14 @@ -import { auth } from "@/app/(auth)/auth"; +import { + requireOwnership, + requireSearchParam, + requireSession, + withErrorHandling, +} from "@/lib/api/route-helpers"; import { getSuggestionsByDocumentId } from "@/lib/db/queries"; -import { ChatbotError } from "@/lib/errors"; -export async function GET(request: Request) { - const { searchParams } = new URL(request.url); - const documentId = searchParams.get("documentId"); - - if (!documentId) { - return new ChatbotError( - "bad_request:api", - "Parameter documentId is required." - ).toResponse(); - } - - const session = await auth(); - - if (!session?.user) { - return new ChatbotError("unauthorized:suggestions").toResponse(); - } +export const GET = withErrorHandling(async (request: Request) => { + const documentId = requireSearchParam(request, "documentId"); + const session = await requireSession("suggestions"); const suggestions = await getSuggestionsByDocumentId({ documentId, @@ -29,9 +20,7 @@ export async function GET(request: Request) { return Response.json([], { status: 200 }); } - if (suggestion.userId !== session.user.id) { - return new ChatbotError("forbidden:api").toResponse(); - } + requireOwnership({ ownerId: suggestion.userId, session, surface: "api" }); return Response.json(suggestions, { status: 200 }); -} +}); diff --git a/app/(chat)/api/vote/route.ts b/app/(chat)/api/vote/route.ts index 8ac9e99..9158013 100644 --- a/app/(chat)/api/vote/route.ts +++ b/app/(chat)/api/vote/route.ts @@ -1,5 +1,11 @@ import { z } from "zod"; -import { auth } from "@/app/(auth)/auth"; +import { + parseJsonBody, + requireOwnership, + requireSearchParam, + requireSession, + withErrorHandling, +} from "@/lib/api/route-helpers"; import { getChatById, getVotesByChatId, voteMessage } from "@/lib/db/queries"; import { ChatbotError } from "@/lib/errors"; @@ -9,22 +15,9 @@ const voteSchema = z.object({ type: z.enum(["up", "down"]), }); -export async function GET(request: Request) { - const { searchParams } = new URL(request.url); - const chatId = searchParams.get("chatId"); - - if (!chatId) { - return new ChatbotError( - "bad_request:api", - "Parameter chatId is required." - ).toResponse(); - } - - const session = await auth(); - - if (!session?.user) { - return new ChatbotError("unauthorized:vote").toResponse(); - } +export const GET = withErrorHandling(async (request: Request) => { + const chatId = requireSearchParam(request, "chatId"); + const session = await requireSession("vote"); const chat = await getChatById({ id: chatId }); @@ -32,34 +25,20 @@ export async function GET(request: Request) { return new ChatbotError("not_found:chat").toResponse(); } - if (chat.userId !== session.user.id) { - return new ChatbotError("forbidden:vote").toResponse(); - } + requireOwnership({ ownerId: chat.userId, session, surface: "vote" }); const votes = await getVotesByChatId({ id: chatId }); return Response.json(votes, { status: 200 }); -} - -export async function PATCH(request: Request) { - let chatId: string; - let messageId: string; - let type: "up" | "down"; - - try { - ({ chatId, messageId, type } = voteSchema.parse(await request.json())); - } catch { - return new ChatbotError( - "bad_request:api", - "Parameters chatId, messageId, and type are required." - ).toResponse(); - } +}); - const session = await auth(); - - if (!session?.user) { - return new ChatbotError("unauthorized:vote").toResponse(); - } +export const PATCH = withErrorHandling(async (request: Request) => { + const { chatId, messageId, type } = await parseJsonBody( + request, + voteSchema, + "Parameters chatId, messageId, and type are required." + ); + const session = await requireSession("vote"); const chat = await getChatById({ id: chatId }); @@ -67,9 +46,7 @@ export async function PATCH(request: Request) { return new ChatbotError("not_found:vote").toResponse(); } - if (chat.userId !== session.user.id) { - return new ChatbotError("forbidden:vote").toResponse(); - } + requireOwnership({ ownerId: chat.userId, session, surface: "vote" }); await voteMessage({ chatId, @@ -78,4 +55,4 @@ export async function PATCH(request: Request) { }); return new Response("Message voted", { status: 200 }); -} +}); diff --git a/app/layout.tsx b/app/layout.tsx index 4bcc1b5..7b38ed8 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,6 +5,7 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import "./globals.css"; import { SessionProvider } from "next-auth/react"; +import { withBasePath } from "@/lib/utils"; export const metadata: Metadata = { description: "Next.js chatbot template using the AI SDK.", @@ -74,9 +75,7 @@ export default function RootLayout({ disableTransitionOnChange enableSystem > - + {children} diff --git a/artifacts/code/client.tsx b/artifacts/code/client.tsx index f2afe91..1baa79a 100644 --- a/artifacts/code/client.tsx +++ b/artifacts/code/client.tsx @@ -1,5 +1,4 @@ import { useCallback } from "react"; -import { toast } from "sonner"; import { CodeEditor } from "@/components/chat/code-editor"; import { Console, @@ -7,15 +6,13 @@ import { type ConsoleOutputContent, } from "@/components/chat/console"; import { Artifact } from "@/components/chat/create-artifact"; -import { - CopyIcon, - LogsIcon, - MessageIcon, - PlayIcon, - RedoIcon, - UndoIcon, -} from "@/components/chat/icons"; +import { LogsIcon, MessageIcon, PlayIcon } from "@/components/chat/icons"; import { generateUUID } from "@/lib/utils"; +import { + copyToClipboardAction, + nextVersionAction, + previousVersionAction, +} from "../common-actions"; const OUTPUT_HANDLERS = { basic: ` @@ -203,42 +200,9 @@ export const codeArtifact = new Artifact<"code", Metadata>({ } }, }, - { - description: "View Previous version", - icon: , - isDisabled: ({ currentVersionIndex }) => { - if (currentVersionIndex === 0) { - return true; - } - - return false; - }, - onClick: ({ handleVersionChange }) => { - handleVersionChange("prev"); - }, - }, - { - description: "View Next version", - icon: , - isDisabled: ({ isCurrentVersion }) => { - if (isCurrentVersion) { - return true; - } - - return false; - }, - onClick: ({ handleVersionChange }) => { - handleVersionChange("next"); - }, - }, - { - description: "Copy code to clipboard", - icon: , - onClick: ({ content }) => { - navigator.clipboard.writeText(content); - toast.success("Copied to clipboard!"); - }, - }, + previousVersionAction(), + nextVersionAction(), + copyToClipboardAction({ description: "Copy code to clipboard" }), ], content: codeArtifactContent, description: diff --git a/artifacts/code/server.ts b/artifacts/code/server.ts index 09dba77..c58ae67 100644 --- a/artifacts/code/server.ts +++ b/artifacts/code/server.ts @@ -1,7 +1,8 @@ -import { streamText } from "ai"; import { codePrompt, updateDocumentPrompt } from "@/lib/ai/prompts"; -import { getLanguageModel } from "@/lib/ai/providers"; -import { createDocumentHandler } from "@/lib/artifacts/server"; +import { + createDocumentHandler, + streamArtifactContent, +} from "@/lib/artifacts/server"; function stripFences(code: string): string { return code @@ -12,48 +13,24 @@ function stripFences(code: string): string { export const codeDocumentHandler = createDocumentHandler<"code">({ kind: "code", - onCreateDocument: async ({ title, dataStream, modelId }) => { - let draftContent = ""; - - const { stream } = streamText({ + onCreateDocument: ({ title, dataStream, modelId }) => + streamArtifactContent({ + dataStream, + format: stripFences, instructions: `${codePrompt}\n\nOutput ONLY the code. No explanations, no markdown fences, no wrapping.`, - model: getLanguageModel(modelId), + modelId, prompt: title, - }); - - for await (const delta of stream) { - if (delta.type === "text-delta") { - draftContent += delta.text; - dataStream.write({ - data: stripFences(draftContent), - transient: true, - type: "data-codeDelta", - }); - } - } - - return stripFences(draftContent); - }, - onUpdateDocument: async ({ document, description, dataStream, modelId }) => { - let draftContent = ""; - - const { stream } = streamText({ + type: "data-codeDelta", + write: "draft", + }), + onUpdateDocument: ({ document, description, dataStream, modelId }) => + streamArtifactContent({ + dataStream, + format: stripFences, instructions: `${updateDocumentPrompt(document.content, "code")}\n\nOutput ONLY the complete updated code. No explanations, no markdown fences, no wrapping.`, - model: getLanguageModel(modelId), + modelId, prompt: description, - }); - - for await (const delta of stream) { - if (delta.type === "text-delta") { - draftContent += delta.text; - dataStream.write({ - data: stripFences(draftContent), - transient: true, - type: "data-codeDelta", - }); - } - } - - return stripFences(draftContent); - }, + type: "data-codeDelta", + write: "draft", + }), }); diff --git a/artifacts/common-actions.tsx b/artifacts/common-actions.tsx new file mode 100644 index 0000000..6f93a80 --- /dev/null +++ b/artifacts/common-actions.tsx @@ -0,0 +1,66 @@ +import { toast } from "sonner"; +import type { ArtifactAction } from "@/components/chat/create-artifact"; +import { + ClockRewind, + CopyIcon, + RedoIcon, + UndoIcon, +} from "@/components/chat/icons"; + +/** + * Toggles between the editor and the diff of the previous version. + */ +export function viewChangesAction(): ArtifactAction { + return { + description: "View changes", + icon: , + isDisabled: ({ currentVersionIndex }) => currentVersionIndex === 0, + onClick: ({ handleVersionChange }) => { + handleVersionChange("toggle"); + }, + }; +} + +export function previousVersionAction(): ArtifactAction { + return { + description: "View Previous version", + icon: , + isDisabled: ({ currentVersionIndex }) => currentVersionIndex === 0, + onClick: ({ handleVersionChange }) => { + handleVersionChange("prev"); + }, + }; +} + +export function nextVersionAction(): ArtifactAction { + return { + description: "View Next version", + icon: , + isDisabled: ({ isCurrentVersion }) => isCurrentVersion, + onClick: ({ handleVersionChange }) => { + handleVersionChange("next"); + }, + }; +} + +/** + * Copies the artifact content, optionally transformed, to the clipboard. + */ +export function copyToClipboardAction({ + description = "Copy to clipboard", + successMessage = "Copied to clipboard!", + transform, +}: { + description?: string; + successMessage?: string; + transform?: (content: string) => string; +} = {}): ArtifactAction { + return { + description, + icon: , + onClick: ({ content }) => { + navigator.clipboard.writeText(transform ? transform(content) : content); + toast.success(successMessage); + }, + }; +} diff --git a/artifacts/image/client.tsx b/artifacts/image/client.tsx index 8eb2e13..772e8e3 100644 --- a/artifacts/image/client.tsx +++ b/artifacts/image/client.tsx @@ -1,38 +1,13 @@ import { toast } from "sonner"; import { Artifact } from "@/components/chat/create-artifact"; -import { CopyIcon, RedoIcon, UndoIcon } from "@/components/chat/icons"; +import { CopyIcon } from "@/components/chat/icons"; import { ImageEditor } from "@/components/chat/image-editor"; +import { nextVersionAction, previousVersionAction } from "../common-actions"; export const imageArtifact = new Artifact({ actions: [ - { - description: "View Previous version", - icon: , - isDisabled: ({ currentVersionIndex }) => { - if (currentVersionIndex === 0) { - return true; - } - - return false; - }, - onClick: ({ handleVersionChange }) => { - handleVersionChange("prev"); - }, - }, - { - description: "View Next version", - icon: , - isDisabled: ({ isCurrentVersion }) => { - if (isCurrentVersion) { - return true; - } - - return false; - }, - onClick: ({ handleVersionChange }) => { - handleVersionChange("next"); - }, - }, + previousVersionAction(), + nextVersionAction(), { description: "Copy image to clipboard", icon: , diff --git a/artifacts/sheet/client.tsx b/artifacts/sheet/client.tsx index fa57c25..cb2cb18 100644 --- a/artifacts/sheet/client.tsx +++ b/artifacts/sheet/client.tsx @@ -1,63 +1,34 @@ import { parse, unparse } from "papaparse"; -import { toast } from "sonner"; import { Artifact } from "@/components/chat/create-artifact"; -import { - CopyIcon, - LineChartIcon, - RedoIcon, - SparklesIcon, - UndoIcon, -} from "@/components/chat/icons"; +import { LineChartIcon, SparklesIcon } from "@/components/chat/icons"; import { SpreadsheetEditor } from "@/components/chat/sheet-editor"; +import { + copyToClipboardAction, + nextVersionAction, + previousVersionAction, +} from "../common-actions"; + +function toCleanedCsv(content: string) { + const parsed = parse(content, { skipEmptyLines: true }); + + const nonEmptyRows = parsed.data.filter((row) => + row.some((cell) => cell.trim() !== "") + ); + + return unparse(nonEmptyRows); +} type Metadata = Record; export const sheetArtifact = new Artifact<"sheet", Metadata>({ actions: [ - { - description: "View Previous version", - icon: , - isDisabled: ({ currentVersionIndex }) => { - if (currentVersionIndex === 0) { - return true; - } - - return false; - }, - onClick: ({ handleVersionChange }) => { - handleVersionChange("prev"); - }, - }, - { - description: "View Next version", - icon: , - isDisabled: ({ isCurrentVersion }) => { - if (isCurrentVersion) { - return true; - } - - return false; - }, - onClick: ({ handleVersionChange }) => { - handleVersionChange("next"); - }, - }, - { + previousVersionAction(), + nextVersionAction(), + copyToClipboardAction({ description: "Copy as .csv", - icon: , - onClick: ({ content }) => { - const parsed = parse(content, { skipEmptyLines: true }); - - const nonEmptyRows = parsed.data.filter((row) => - row.some((cell) => cell.trim() !== "") - ); - - const cleanedCsv = unparse(nonEmptyRows); - - navigator.clipboard.writeText(cleanedCsv); - toast.success("Copied csv to clipboard!"); - }, - }, + successMessage: "Copied csv to clipboard!", + transform: toCleanedCsv, + }), ], content: ({ content, currentVersionIndex, onSaveContent, status }) => ( ({ kind: "sheet", - onCreateDocument: async ({ title, dataStream, modelId }) => { - let draftContent = ""; - - const { stream } = streamText({ - instructions: `${sheetPrompt}\n\nOutput ONLY the raw CSV data. No explanations, no markdown fences.`, - model: getLanguageModel(modelId), + onCreateDocument: ({ title, dataStream, modelId }) => + streamArtifactContent({ + dataStream, + instructions: `${sheetPrompt}\n\n${CSV_ONLY}`, + modelId, prompt: title, - }); - - for await (const delta of stream) { - if (delta.type === "text-delta") { - draftContent += delta.text; - dataStream.write({ - data: draftContent, - transient: true, - type: "data-sheetDelta", - }); - } - } - - return draftContent; - }, - onUpdateDocument: async ({ document, description, dataStream, modelId }) => { - let draftContent = ""; - - const { stream } = streamText({ - instructions: `${updateDocumentPrompt(document.content, "sheet")}\n\nOutput ONLY the raw CSV data. No explanations, no markdown fences.`, - model: getLanguageModel(modelId), + type: "data-sheetDelta", + write: "draft", + }), + onUpdateDocument: ({ document, description, dataStream, modelId }) => + streamArtifactContent({ + dataStream, + instructions: `${updateDocumentPrompt(document.content, "sheet")}\n\n${CSV_ONLY}`, + modelId, prompt: description, - }); - - for await (const delta of stream) { - if (delta.type === "text-delta") { - draftContent += delta.text; - dataStream.write({ - data: draftContent, - transient: true, - type: "data-sheetDelta", - }); - } - } - - return draftContent; - }, + type: "data-sheetDelta", + write: "draft", + }), }); diff --git a/artifacts/text/client.tsx b/artifacts/text/client.tsx index bddd549..1eb5dd7 100644 --- a/artifacts/text/client.tsx +++ b/artifacts/text/client.tsx @@ -1,18 +1,16 @@ -import { toast } from "sonner"; import { Artifact } from "@/components/chat/create-artifact"; import { DiffView } from "@/components/chat/diffview"; import { DocumentSkeleton } from "@/components/chat/document-skeleton"; -import { - ClockRewind, - CopyIcon, - MessageIcon, - PenIcon, - RedoIcon, - UndoIcon, -} from "@/components/chat/icons"; +import { MessageIcon, PenIcon } from "@/components/chat/icons"; import { Editor } from "@/components/chat/text-editor"; import type { Suggestion } from "@/lib/db/schema"; import { getSuggestions } from "../actions"; +import { + copyToClipboardAction, + nextVersionAction, + previousVersionAction, + viewChangesAction, +} from "../common-actions"; type TextArtifactMetadata = { suggestions: Suggestion[]; @@ -20,56 +18,10 @@ type TextArtifactMetadata = { export const textArtifact = new Artifact<"text", TextArtifactMetadata>({ actions: [ - { - description: "View changes", - icon: , - isDisabled: ({ currentVersionIndex }) => { - if (currentVersionIndex === 0) { - return true; - } - - return false; - }, - onClick: ({ handleVersionChange }) => { - handleVersionChange("toggle"); - }, - }, - { - description: "View Previous version", - icon: , - isDisabled: ({ currentVersionIndex }) => { - if (currentVersionIndex === 0) { - return true; - } - - return false; - }, - onClick: ({ handleVersionChange }) => { - handleVersionChange("prev"); - }, - }, - { - description: "View Next version", - icon: , - isDisabled: ({ isCurrentVersion }) => { - if (isCurrentVersion) { - return true; - } - - return false; - }, - onClick: ({ handleVersionChange }) => { - handleVersionChange("next"); - }, - }, - { - description: "Copy to clipboard", - icon: , - onClick: ({ content }) => { - navigator.clipboard.writeText(content); - toast.success("Copied to clipboard!"); - }, - }, + viewChangesAction(), + previousVersionAction(), + nextVersionAction(), + copyToClipboardAction(), ], content: ({ mode, diff --git a/artifacts/text/server.ts b/artifacts/text/server.ts index 1c16809..539b3fb 100644 --- a/artifacts/text/server.ts +++ b/artifacts/text/server.ts @@ -1,55 +1,31 @@ -import { smoothStream, streamText } from "ai"; +import { smoothStream } from "ai"; import { updateDocumentPrompt } from "@/lib/ai/prompts"; -import { getLanguageModel } from "@/lib/ai/providers"; -import { createDocumentHandler } from "@/lib/artifacts/server"; +import { + createDocumentHandler, + streamArtifactContent, +} from "@/lib/artifacts/server"; export const textDocumentHandler = createDocumentHandler<"text">({ kind: "text", - onCreateDocument: async ({ title, dataStream, modelId }) => { - let draftContent = ""; - - const { stream } = streamText({ - experimental_transform: smoothStream({ chunking: "word" }), + onCreateDocument: ({ title, dataStream, modelId }) => + streamArtifactContent({ + dataStream, instructions: "Write about the given topic. Markdown is supported. Use headings wherever appropriate.", - model: getLanguageModel(modelId), + modelId, prompt: title, - }); - - for await (const delta of stream) { - if (delta.type === "text-delta") { - draftContent += delta.text; - dataStream.write({ - data: delta.text, - transient: true, - type: "data-textDelta", - }); - } - } - - return draftContent; - }, - onUpdateDocument: async ({ document, description, dataStream, modelId }) => { - let draftContent = ""; - - const { stream } = streamText({ - experimental_transform: smoothStream({ chunking: "word" }), + transform: smoothStream({ chunking: "word" }), + type: "data-textDelta", + write: "chunk", + }), + onUpdateDocument: ({ document, description, dataStream, modelId }) => + streamArtifactContent({ + dataStream, instructions: updateDocumentPrompt(document.content, "text"), - model: getLanguageModel(modelId), + modelId, prompt: description, - }); - - for await (const delta of stream) { - if (delta.type === "text-delta") { - draftContent += delta.text; - dataStream.write({ - data: delta.text, - transient: true, - type: "data-textDelta", - }); - } - } - - return draftContent; - }, + transform: smoothStream({ chunking: "word" }), + type: "data-textDelta", + write: "chunk", + }), }); diff --git a/components/chat/app-sidebar.tsx b/components/chat/app-sidebar.tsx index d3cd842..abc3884 100644 --- a/components/chat/app-sidebar.tsx +++ b/components/chat/app-sidebar.tsx @@ -32,6 +32,7 @@ import { SidebarTrigger, useSidebar, } from "@/components/ui/sidebar"; +import { withBasePath } from "@/lib/utils"; import { AlertDialog, AlertDialogAction, @@ -74,7 +75,7 @@ export function AppSidebar({ user }: { user: User | undefined }) { revalidate: false, }); - fetch(`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/history`, { + fetch(withBasePath("/api/history"), { method: "DELETE", }); diff --git a/components/chat/artifact.tsx b/components/chat/artifact.tsx index a0e81b5..ce3409d 100644 --- a/components/chat/artifact.tsx +++ b/components/chat/artifact.tsx @@ -20,7 +20,7 @@ import { textArtifact } from "@/artifacts/text/client"; import { useArtifact } from "@/hooks/use-artifact"; import type { Document, Vote } from "@/lib/db/schema"; import type { Attachment, ChatMessage } from "@/lib/types"; -import { fetcher } from "@/lib/utils"; +import { fetcher, withBasePath } from "@/lib/utils"; import { useSidebar } from "../ui/sidebar"; import { ArtifactActions } from "./artifact-actions"; import { ArtifactCloseButton } from "./artifact-close-button"; @@ -95,7 +95,7 @@ function PureArtifact({ mutate: mutateDocuments, } = useSWR( artifact.documentId !== "init" && artifact.status !== "streaming" - ? `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/document?id=${artifact.documentId}` + ? withBasePath(`/api/document?id=${artifact.documentId}`) : null, fetcher ); @@ -154,7 +154,7 @@ function PureArtifact({ } mutate( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/document?id=${artifact.documentId}`, + withBasePath(`/api/document?id=${artifact.documentId}`), async (currentDocuments) => { if (!currentDocuments) { return []; @@ -172,18 +172,15 @@ function PureArtifact({ return currentDocuments; } - await fetch( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/document?id=${artifact.documentId}`, - { - body: JSON.stringify({ - content: updatedContent, - isManualEdit: true, - kind: artifact.kind, - title: artifact.title, - }), - method: "POST", - } - ); + await fetch(withBasePath(`/api/document?id=${artifact.documentId}`), { + body: JSON.stringify({ + content: updatedContent, + isManualEdit: true, + kind: artifact.kind, + title: artifact.title, + }), + method: "POST", + }); setIsContentDirty(false); diff --git a/components/chat/create-artifact.tsx b/components/chat/create-artifact.tsx index 78b24b1..29ce67f 100644 --- a/components/chat/create-artifact.tsx +++ b/components/chat/create-artifact.tsx @@ -15,7 +15,7 @@ export type ArtifactActionContext = { setMetadata: Dispatch>; }; -type ArtifactAction = { +export type ArtifactAction = { icon: ReactNode; label?: string; description: string; diff --git a/components/chat/document-preview.tsx b/components/chat/document-preview.tsx index d1dc802..588c36d 100644 --- a/components/chat/document-preview.tsx +++ b/components/chat/document-preview.tsx @@ -12,7 +12,7 @@ import { import useSWR from "swr"; import { useArtifact } from "@/hooks/use-artifact"; import type { Document } from "@/lib/db/schema"; -import { cn, fetcher } from "@/lib/utils"; +import { cn, fetcher, withBasePath } from "@/lib/utils"; import type { ArtifactKind, UIArtifact } from "./artifact"; import { CodeEditor } from "./code-editor"; import { InlineDocumentSkeleton } from "./document-skeleton"; @@ -49,12 +49,7 @@ export function DocumentPreview({ const { data: documents, isLoading: isDocumentsFetching } = useSWR< Document[] - >( - result - ? `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/document?id=${result.id}` - : null, - fetcher - ); + >(result ? withBasePath(`/api/document?id=${result.id}`) : null, fetcher); const previewDocument = useMemo(() => documents?.[0], [documents]); const hitboxRef = useRef(null); diff --git a/components/chat/message-actions.tsx b/components/chat/message-actions.tsx index bb5f64e..e4c7a0f 100644 --- a/components/chat/message-actions.tsx +++ b/components/chat/message-actions.tsx @@ -5,12 +5,18 @@ import { useSWRConfig } from "swr"; import { useCopyToClipboard } from "usehooks-ts"; import type { Vote } from "@/lib/db/schema"; import type { ChatMessage } from "@/lib/types"; +import { withBasePath } from "@/lib/utils"; import { MessageAction as Action, MessageActions as Actions, } from "../ai-elements/message"; import { CopyIcon, PencilEditIcon, ThumbDownIcon, ThumbUpIcon } from "./icons"; +const VOTE_LABELS = { + down: { gerund: "Downvoting", noun: "downvote", past: "Downvoted" }, + up: { gerund: "Upvoting", noun: "upvote", past: "Upvoted" }, +} as const; + export function PureMessageActions({ chatId, message, @@ -43,95 +49,56 @@ export function PureMessageActions({ toast.success("Copied to clipboard!"); }, [copyToClipboard, textFromParts]); - const handleUpvote = useCallback(() => { - const upvote = fetch( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/vote`, - { + const sendVote = useCallback( + (type: "up" | "down") => { + const request = fetch(withBasePath("/api/vote"), { body: JSON.stringify({ chatId, messageId: message.id, - type: "up", + type, }), method: "PATCH", - } - ); + }); + + const { gerund, noun, past } = VOTE_LABELS[type]; + + toast.promise(request, { + error: `Failed to ${noun} response.`, + loading: `${gerund} Response...`, + success: () => { + mutate( + withBasePath(`/api/vote?chatId=${chatId}`), + (currentVotes) => { + if (!currentVotes) { + return []; + } + + const votesWithoutCurrent = currentVotes.filter( + (currentVote) => currentVote.messageId !== message.id + ); + + return [ + ...votesWithoutCurrent, + { + chatId, + isUpvoted: type === "up", + messageId: message.id, + }, + ]; + }, + { revalidate: false } + ); + + return `${past} Response!`; + }, + }); + }, + [chatId, message.id, mutate] + ); - toast.promise(upvote, { - error: "Failed to upvote response.", - loading: "Upvoting Response...", - success: () => { - mutate( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/vote?chatId=${chatId}`, - (currentVotes) => { - if (!currentVotes) { - return []; - } - - const votesWithoutCurrent = currentVotes.filter( - (currentVote) => currentVote.messageId !== message.id - ); - - return [ - ...votesWithoutCurrent, - { - chatId, - isUpvoted: true, - messageId: message.id, - }, - ]; - }, - { revalidate: false } - ); - - return "Upvoted Response!"; - }, - }); - }, [chatId, message.id, mutate]); - - const handleDownvote = useCallback(() => { - const downvote = fetch( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/vote`, - { - body: JSON.stringify({ - chatId, - messageId: message.id, - type: "down", - }), - method: "PATCH", - } - ); + const handleUpvote = useCallback(() => sendVote("up"), [sendVote]); - toast.promise(downvote, { - error: "Failed to downvote response.", - loading: "Downvoting Response...", - success: () => { - mutate( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/vote?chatId=${chatId}`, - (currentVotes) => { - if (!currentVotes) { - return []; - } - - const votesWithoutCurrent = currentVotes.filter( - (currentVote) => currentVote.messageId !== message.id - ); - - return [ - ...votesWithoutCurrent, - { - chatId, - isUpvoted: false, - messageId: message.id, - }, - ]; - }, - { revalidate: false } - ); - - return "Downvoted Response!"; - }, - }); - }, [chatId, message.id, mutate]); + const handleDownvote = useCallback(() => sendVote("down"), [sendVote]); if (isLoading) { return null; diff --git a/components/chat/multimodal-input.tsx b/components/chat/multimodal-input.tsx index 987aefa..2a3c01c 100644 --- a/components/chat/multimodal-input.tsx +++ b/components/chat/multimodal-input.tsx @@ -44,7 +44,7 @@ import { type ModelCapabilities, } from "@/lib/ai/models"; import type { Attachment, ChatMessage } from "@/lib/types"; -import { cn } from "@/lib/utils"; +import { cn, withBasePath } from "@/lib/utils"; import { PromptInput, PromptInputFooter, @@ -192,10 +192,9 @@ function PureMultimodalInput({ action: { label: "Delete", onClick: () => { - fetch( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/chat?id=${chatId}`, - { method: "DELETE" } - ); + fetch(withBasePath(`/api/chat?id=${chatId}`), { + method: "DELETE", + }); router.push("/"); toast.success("Chat deleted"); }, @@ -207,12 +206,9 @@ function PureMultimodalInput({ action: { label: "Delete all", onClick: () => { - fetch( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/history`, - { - method: "DELETE", - } - ); + fetch(withBasePath("/api/history"), { + method: "DELETE", + }); router.push("/"); toast.success("All chats deleted"); }, @@ -227,11 +223,7 @@ function PureMultimodalInput({ ); const submitForm = useCallback(() => { - window.history.pushState( - {}, - "", - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/chat/${chatId}` - ); + window.history.pushState({}, "", withBasePath(`/chat/${chatId}`)); sendMessage({ parts: [ @@ -272,13 +264,10 @@ function PureMultimodalInput({ formData.append("file", file); try { - const response = await fetch( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/files/upload`, - { - body: formData, - method: "POST", - } - ); + const response = await fetch(withBasePath("/api/files/upload"), { + body: formData, + method: "POST", + }); if (response.ok) { const data = await response.json(); @@ -645,7 +634,7 @@ function PureAttachmentsButton({ selectedModelId: string; }) { const { data: modelsResponse } = useSWR( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/models`, + withBasePath("/api/models"), (url: string) => fetch(url).then((r) => r.json()), { dedupingInterval: 3_600_000, revalidateOnFocus: false } ); @@ -792,7 +781,7 @@ function PureModelSelectorCompact({ }) { const [open, setOpen] = useState(false); const { data: modelsData } = useSWR( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/models`, + withBasePath("/api/models"), (url: string) => fetch(url).then((r) => r.json()), { dedupingInterval: 3_600_000, revalidateOnFocus: false } ); diff --git a/components/chat/shell.tsx b/components/chat/shell.tsx index 53c5567..e2a40f5 100644 --- a/components/chat/shell.tsx +++ b/components/chat/shell.tsx @@ -18,7 +18,7 @@ import { useArtifactSelector, } from "@/hooks/use-artifact"; import type { Attachment, ChatMessage } from "@/lib/types"; -import { cn } from "@/lib/utils"; +import { cn, withBasePath } from "@/lib/utils"; import { Artifact } from "./artifact"; import { ChatHeader } from "./chat-header"; import { DataStreamHandler } from "./data-stream-handler"; @@ -107,7 +107,7 @@ export function ChatShell() { "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%3Fmodal%3Dadd-credit-card", "_blank" ); - window.location.href = `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/`; + window.location.href = withBasePath("/"); }, []); return ( diff --git a/components/chat/sidebar-history.tsx b/components/chat/sidebar-history.tsx index df4f5f7..7a7fcbb 100644 --- a/components/chat/sidebar-history.tsx +++ b/components/chat/sidebar-history.tsx @@ -25,7 +25,7 @@ import { useSidebar, } from "@/components/ui/sidebar"; import type { Chat } from "@/lib/db/schema"; -import { fetcher } from "@/lib/utils"; +import { fetcher, withBasePath } from "@/lib/utils"; import { LoaderIcon } from "./icons"; import { ChatItem } from "./sidebar-history-item"; @@ -86,7 +86,7 @@ export function getChatHistoryPaginationKey( } if (pageIndex === 0) { - return `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/history?limit=${PAGE_SIZE}`; + return withBasePath(`/api/history?limit=${PAGE_SIZE}`); } const firstChatFromPage = previousPageData.chats.at(-1); @@ -95,7 +95,9 @@ export function getChatHistoryPaginationKey( return null; } - return `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/history?ending_before=${firstChatFromPage.id}&limit=${PAGE_SIZE}`; + return withBasePath( + `/api/history?ending_before=${firstChatFromPage.id}&limit=${PAGE_SIZE}` + ); } export function SidebarHistory({ user }: { user: User | undefined }) { @@ -146,10 +148,7 @@ export function SidebarHistory({ user }: { user: User | undefined }) { } }); - fetch( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/chat?id=${chatToDelete}`, - { method: "DELETE" } - ); + fetch(withBasePath(`/api/chat?id=${chatToDelete}`), { method: "DELETE" }); toast.success("Chat deleted"); }, [deleteId, mutate, pathname, router]); diff --git a/components/chat/suggested-actions.tsx b/components/chat/suggested-actions.tsx index c69c139..b1ad667 100644 --- a/components/chat/suggested-actions.tsx +++ b/components/chat/suggested-actions.tsx @@ -5,6 +5,7 @@ import { motion } from "framer-motion"; import { memo, useCallback } from "react"; import { suggestions } from "@/lib/constants"; import type { ChatMessage } from "@/lib/types"; +import { withBasePath } from "@/lib/utils"; import { Suggestion } from "../ai-elements/suggestion"; import type { VisibilityType } from "./visibility-selector"; @@ -18,11 +19,7 @@ function PureSuggestedActions({ chatId, sendMessage }: SuggestedActionsProps) { const suggestedActions = suggestions; const handleSuggestionClick = useCallback( (suggestion: string) => { - window.history.pushState( - {}, - "", - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/chat/${chatId}` - ); + window.history.pushState({}, "", withBasePath(`/chat/${chatId}`)); sendMessage({ parts: [{ text: suggestion, type: "text" }], role: "user", diff --git a/components/chat/version-footer.tsx b/components/chat/version-footer.tsx index 7a1787a..869278b 100644 --- a/components/chat/version-footer.tsx +++ b/components/chat/version-footer.tsx @@ -8,7 +8,7 @@ import { useCallback, useState } from "react"; import { useSWRConfig } from "swr"; import { useArtifact } from "@/hooks/use-artifact"; import type { Document } from "@/lib/db/schema"; -import { cn, getDocumentTimestampByIndex } from "@/lib/utils"; +import { cn, getDocumentTimestampByIndex, withBasePath } from "@/lib/utils"; import { LoaderIcon } from "./icons"; type VersionFooterProps = { @@ -56,12 +56,14 @@ export const VersionFooter = ({ try { await mutate( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/document?id=${artifact.documentId}`, + withBasePath(`/api/document?id=${artifact.documentId}`), await fetch( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/document?id=${artifact.documentId}×tamp=${getDocumentTimestampByIndex( - documents, - currentVersionIndex - )}`, + withBasePath( + `/api/document?id=${artifact.documentId}×tamp=${getDocumentTimestampByIndex( + documents, + currentVersionIndex + )}` + ), { method: "DELETE", } diff --git a/hooks/use-active-chat.tsx b/hooks/use-active-chat.tsx index f3b9522..43f962d 100644 --- a/hooks/use-active-chat.tsx +++ b/hooks/use-active-chat.tsx @@ -26,7 +26,12 @@ import { DEFAULT_CHAT_MODEL } from "@/lib/ai/models"; import type { Vote } from "@/lib/db/schema"; import { ChatbotError } from "@/lib/errors"; import type { ChatMessage } from "@/lib/types"; -import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils"; +import { + fetcher, + fetchWithErrorHandlers, + generateUUID, + withBasePath, +} from "@/lib/utils"; type ActiveChatContextValue = { chatId: string; @@ -83,9 +88,7 @@ export function ActiveChatProvider({ children }: { children: ReactNode }) { const [showCreditCardAlert, setShowCreditCardAlert] = useState(false); const { data: chatData, isLoading } = useSWR( - isNewChat - ? null - : `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/messages?chatId=${chatId}`, + isNewChat ? null : withBasePath(`/api/messages?chatId=${chatId}`), fetcher, { revalidateOnFocus: false } ); @@ -145,7 +148,7 @@ export function ActiveChatProvider({ children }: { children: ReactNode }) { ); }, transport: new DefaultChatTransport({ - api: `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/chat`, + api: withBasePath("/api/chat"), fetch: fetchWithErrorHandlers, prepareSendMessagesRequest(request) { const lastMessage = request.messages.at(-1); @@ -225,11 +228,7 @@ export function ActiveChatProvider({ children }: { children: ReactNode }) { const query = params.get("query"); if (query && !hasAppendedQueryRef.current) { hasAppendedQueryRef.current = true; - window.history.replaceState( - {}, - "", - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/chat/${chatId}` - ); + window.history.replaceState({}, "", withBasePath(`/chat/${chatId}`)); sendMessage({ parts: [{ text: query, type: "text" }], role: "user" as const, @@ -248,7 +247,7 @@ export function ActiveChatProvider({ children }: { children: ReactNode }) { const { data: votes } = useSWR( !isReadonly && messages.length >= 2 - ? `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/vote?chatId=${chatId}` + ? withBasePath(`/api/vote?chatId=${chatId}`) : null, fetcher, { revalidateOnFocus: false } diff --git a/hooks/use-chat-visibility.ts b/hooks/use-chat-visibility.ts index dac002a..b73c7b2 100644 --- a/hooks/use-chat-visibility.ts +++ b/hooks/use-chat-visibility.ts @@ -9,6 +9,7 @@ import { getChatHistoryPaginationKey, } from "@/components/chat/sidebar-history"; import type { VisibilityType } from "@/components/chat/visibility-selector"; +import { withBasePath } from "@/lib/utils"; export function useChatVisibility({ chatId, @@ -18,9 +19,7 @@ export function useChatVisibility({ initialVisibilityType: VisibilityType; }) { const { mutate, cache } = useSWRConfig(); - const history: ChatHistory = cache.get( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/history` - )?.data; + const history: ChatHistory = cache.get(withBasePath("/api/history"))?.data; const { data: localVisibility, mutate: setLocalVisibility } = useSWR( `${chatId}-visibility`, diff --git a/lib/ai/tools/create-document.ts b/lib/ai/tools/create-document.ts index 07e664f..3fb39a1 100644 --- a/lib/ai/tools/create-document.ts +++ b/lib/ai/tools/create-document.ts @@ -3,7 +3,7 @@ import type { Session } from "next-auth"; import { z } from "zod"; import { artifactKinds, - documentHandlersByArtifactKind, + getDocumentHandlerByKind, } from "@/lib/artifacts/server"; import type { ChatMessage } from "@/lib/types"; import { generateUUID } from "@/lib/utils"; @@ -49,16 +49,7 @@ export const createDocument = ({ type: "data-clear", }); - const documentHandler = documentHandlersByArtifactKind.find( - (documentHandlerByArtifactKind) => - documentHandlerByArtifactKind.kind === kind - ); - - if (!documentHandler) { - throw new Error(`No document handler found for kind: ${kind}`); - } - - await documentHandler.onCreateDocument({ + await getDocumentHandlerByKind(kind).onCreateDocument({ dataStream, id, modelId, diff --git a/lib/ai/tools/documents.ts b/lib/ai/tools/documents.ts new file mode 100644 index 0000000..1c3def7 --- /dev/null +++ b/lib/ai/tools/documents.ts @@ -0,0 +1,56 @@ +import type { Session } from "next-auth"; +import { getDocumentById } from "@/lib/db/queries"; +import type { Document } from "@/lib/db/schema"; + +type LoadOwnedDocumentResult = + | { ok: true; document: TDocument } + | { ok: false; error: string }; + +/** + * Loads a document and checks it belongs to the session user, returning the + * tool-facing error string instead of throwing. + */ +export async function loadOwnedDocument({ + id, + session, +}: { + id: string; + session: Session; +}): Promise> { + const document = await getDocumentById({ id }); + + if (!document) { + return { error: "Document not found", ok: false }; + } + + if (document.userId !== session.user?.id) { + return { error: "Forbidden", ok: false }; + } + + return { document, ok: true }; +} + +/** + * Like `loadOwnedDocument`, but treats a document without content as missing. + */ +export async function loadOwnedDocumentWithContent({ + id, + session, +}: { + id: string; + session: Session; +}): Promise> { + const result = await loadOwnedDocument({ id, session }); + + if (!result.ok) { + return result; + } + + const { content } = result.document; + + if (!content) { + return { error: "Document not found", ok: false }; + } + + return { document: { ...result.document, content }, ok: true }; +} diff --git a/lib/ai/tools/edit-document.ts b/lib/ai/tools/edit-document.ts index 575cf0a..7cf81dd 100644 --- a/lib/ai/tools/edit-document.ts +++ b/lib/ai/tools/edit-document.ts @@ -1,8 +1,9 @@ import { tool, type UIMessageStreamWriter } from "ai"; import type { Session } from "next-auth"; import { z } from "zod"; -import { getDocumentById, saveDocument } from "@/lib/db/queries"; +import { saveDocument } from "@/lib/db/queries"; import type { ChatMessage } from "@/lib/types"; +import { loadOwnedDocument } from "./documents"; type EditDocumentProps = { session: Session; @@ -14,15 +15,13 @@ export const editDocument = ({ session, dataStream }: EditDocumentProps) => description: "Make a targeted edit to an existing artifact by finding and replacing an exact string. Preferred over updateDocument for small changes. The old_string must match exactly.", execute: async ({ id, old_string, new_string, replace_all }) => { - const document = await getDocumentById({ id }); + const result = await loadOwnedDocument({ id, session }); - if (!document) { - return { error: "Document not found" }; + if (!result.ok) { + return { error: result.error }; } - if (document.userId !== session.user?.id) { - return { error: "Forbidden" }; - } + const { document } = result; if (!document.content) { return { error: "Document has no content" }; diff --git a/lib/ai/tools/request-suggestions.ts b/lib/ai/tools/request-suggestions.ts index c2f1df8..71e25ca 100644 --- a/lib/ai/tools/request-suggestions.ts +++ b/lib/ai/tools/request-suggestions.ts @@ -1,11 +1,12 @@ import { Output, streamText, tool, type UIMessageStreamWriter } from "ai"; import type { Session } from "next-auth"; import { z } from "zod"; -import { getDocumentById, saveSuggestions } from "@/lib/db/queries"; +import { saveSuggestions } from "@/lib/db/queries"; import type { Suggestion } from "@/lib/db/schema"; import type { ChatMessage } from "@/lib/types"; import { generateUUID } from "@/lib/utils"; import { getLanguageModel } from "../providers"; +import { loadOwnedDocumentWithContent } from "./documents"; type RequestSuggestionsProps = { session: Session; @@ -22,17 +23,16 @@ export const requestSuggestions = ({ description: "Request writing suggestions for an existing document artifact. Only use this when the user explicitly asks to improve or get suggestions for a document they have already created. Never use for general questions.", execute: async ({ documentId }) => { - const document = await getDocumentById({ id: documentId }); + const result = await loadOwnedDocumentWithContent({ + id: documentId, + session, + }); - if (!document?.content) { - return { - error: "Document not found", - }; + if (!result.ok) { + return { error: result.error }; } - if (document.userId !== session.user?.id) { - return { error: "Forbidden" }; - } + const { document } = result; const suggestions: Omit< Suggestion, diff --git a/lib/ai/tools/update-document.ts b/lib/ai/tools/update-document.ts index eef7594..296ce43 100644 --- a/lib/ai/tools/update-document.ts +++ b/lib/ai/tools/update-document.ts @@ -1,9 +1,9 @@ import { tool, type UIMessageStreamWriter } from "ai"; import type { Session } from "next-auth"; import { z } from "zod"; -import { documentHandlersByArtifactKind } from "@/lib/artifacts/server"; -import { getDocumentById } from "@/lib/db/queries"; +import { getDocumentHandlerByKind } from "@/lib/artifacts/server"; import type { ChatMessage } from "@/lib/types"; +import { loadOwnedDocument } from "./documents"; type UpdateDocumentProps = { session: Session; @@ -20,17 +20,13 @@ export const updateDocument = ({ description: "Full rewrite of an existing artifact. Only use for major changes where most content needs replacing. Prefer editDocument for targeted changes.", execute: async ({ id, description }) => { - const document = await getDocumentById({ id }); + const result = await loadOwnedDocument({ id, session }); - if (!document) { - return { - error: "Document not found", - }; + if (!result.ok) { + return { error: result.error }; } - if (document.userId !== session.user?.id) { - return { error: "Forbidden" }; - } + const { document } = result; dataStream.write({ data: null, @@ -38,16 +34,7 @@ export const updateDocument = ({ type: "data-clear", }); - const documentHandler = documentHandlersByArtifactKind.find( - (documentHandlerByArtifactKind) => - documentHandlerByArtifactKind.kind === document.kind - ); - - if (!documentHandler) { - throw new Error(`No document handler found for kind: ${document.kind}`); - } - - await documentHandler.onUpdateDocument({ + await getDocumentHandlerByKind(document.kind).onUpdateDocument({ dataStream, description, document, diff --git a/lib/api/route-helpers.ts b/lib/api/route-helpers.ts new file mode 100644 index 0000000..b8768cf --- /dev/null +++ b/lib/api/route-helpers.ts @@ -0,0 +1,89 @@ +import type { Session } from "next-auth"; +import type { z } from "zod"; +import { auth } from "@/app/(auth)/auth"; +import { ChatbotError, type Surface } from "@/lib/errors"; + +/** + * Reads a search parameter, failing with `bad_request:api` when it is absent. + */ +export function requireSearchParam(request: Request, name: string): string { + const value = new URL(request.url).searchParams.get(name); + + if (!value) { + throw new ChatbotError("bad_request:api", `Parameter ${name} is required.`); + } + + return value; +} + +/** + * Resolves the current session, failing with `unauthorized:` when the + * request is not authenticated. + */ +export async function requireSession( + surface: Surface +): Promise }> { + const session = await auth(); + + if (!session?.user) { + throw new ChatbotError(`unauthorized:${surface}`); + } + + return session as Session & { user: NonNullable }; +} + +/** + * Fails with `forbidden:` when a resource belongs to another user. + */ +export function requireOwnership({ + ownerId, + session, + surface, +}: { + ownerId: string | null | undefined; + session: Session; + surface: Surface; +}) { + if (ownerId !== session.user?.id) { + throw new ChatbotError(`forbidden:${surface}`); + } +} + +/** + * Parses a JSON body against a schema, failing with `bad_request:api` when the + * body does not match. + */ +export async function parseJsonBody( + request: Request, + schema: TSchema, + message = "Invalid request body." +): Promise> { + const body = await request.json().catch(() => undefined); + const parsed = schema.safeParse(body); + + if (!parsed.success) { + throw new ChatbotError("bad_request:api", message); + } + + return parsed.data as z.infer; +} + +/** + * Turns thrown ChatbotErrors into their HTTP responses so route handlers can + * use the helpers above instead of branching on every failure case. + */ +export function withErrorHandling( + handler: (...args: TArgs) => Promise +): (...args: TArgs) => Promise { + return async (...args: TArgs) => { + try { + return await handler(...args); + } catch (error) { + if (error instanceof ChatbotError) { + return error.toResponse(); + } + + throw error; + } + }; +} diff --git a/lib/artifacts/server.ts b/lib/artifacts/server.ts index 8ae29ba..9b412a9 100644 --- a/lib/artifacts/server.ts +++ b/lib/artifacts/server.ts @@ -1,13 +1,69 @@ -import type { UIMessageStreamWriter } from "ai"; +import { streamText, type UIMessageStreamWriter } from "ai"; import type { Session } from "next-auth"; import { codeDocumentHandler } from "@/artifacts/code/server"; import { sheetDocumentHandler } from "@/artifacts/sheet/server"; import { textDocumentHandler } from "@/artifacts/text/server"; import type { ArtifactKind } from "@/components/chat/artifact"; +import { getLanguageModel } from "../ai/providers"; import { saveDocument } from "../db/queries"; import type { Document } from "../db/schema"; import type { ChatMessage } from "../types"; +type ArtifactDeltaType = + | "data-textDelta" + | "data-codeDelta" + | "data-sheetDelta"; + +/** + * Streams model output into the UI stream as artifact deltas and returns the + * final content. `write: "chunk"` streams each new text chunk, `write: "draft"` + * streams the whole draft on every chunk. + */ +export async function streamArtifactContent({ + dataStream, + format = (content) => content, + instructions, + modelId, + prompt, + transform, + type, + write, +}: { + dataStream: UIMessageStreamWriter; + format?: (content: string) => string; + instructions: string; + modelId: string; + prompt: string; + transform?: Parameters[0]["experimental_transform"]; + type: ArtifactDeltaType; + write: "chunk" | "draft"; +}): Promise { + let draftContent = ""; + + const { stream } = streamText({ + experimental_transform: transform, + instructions, + model: getLanguageModel(modelId), + prompt, + }); + + for await (const delta of stream) { + if (delta.type !== "text-delta") { + continue; + } + + draftContent += delta.text; + + dataStream.write({ + data: write === "chunk" ? delta.text : format(draftContent), + transient: true, + type, + }); + } + + return format(draftContent); +} + export type SaveDocumentProps = { id: string; title: string; @@ -93,3 +149,18 @@ export const documentHandlersByArtifactKind: DocumentHandler[] = [ ]; export const artifactKinds = ["text", "code", "sheet"] as const; + +/** + * Returns the handler registered for an artifact kind. + */ +export function getDocumentHandlerByKind(kind: ArtifactKind): DocumentHandler { + const documentHandler = documentHandlersByArtifactKind.find( + (handler) => handler.kind === kind + ); + + if (!documentHandler) { + throw new Error(`No document handler found for kind: ${kind}`); + } + + return documentHandler; +} diff --git a/lib/db/queries.ts b/lib/db/queries.ts index 7e2972d..1dc4345 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -36,84 +36,84 @@ import { generateHashedPassword } from "./utils"; const client = postgres(process.env.POSTGRES_URL ?? ""); const db = drizzle(client); -export async function getUser(email: string): Promise { - try { - return await db.select().from(user).where(eq(user.email, email)); - } catch (error) { - throw new ChatbotError("bad_request:database", { cause: error }); - } +/** + * Wraps a database operation so unexpected failures surface as a + * `bad_request:database` ChatbotError while explicit ChatbotErrors pass through. + */ +function withDbErrors( + operation: (...args: TArgs) => Promise +): (...args: TArgs) => Promise { + return async (...args: TArgs) => { + try { + return await operation(...args); + } catch (error) { + if (error instanceof ChatbotError) { + throw error; + } + + throw new ChatbotError("bad_request:database", { cause: error }); + } + }; } -export async function createUser(email: string, password: string) { - const hashedPassword = generateHashedPassword(password); +export const getUser = withDbErrors( + async (email: string): Promise => + await db.select().from(user).where(eq(user.email, email)) +); + +export const createUser = withDbErrors( + async (email: string, password: string) => { + const hashedPassword = generateHashedPassword(password); - try { return await db.insert(user).values({ email, password: hashedPassword }); - } catch (error) { - throw new ChatbotError("bad_request:database", { - cause: error, - }); } -} +); -export async function createGuestUser() { +export const createGuestUser = withDbErrors(async () => { const email = `guest-${Date.now()}`; const password = generateHashedPassword(generateUUID()); - try { - return await db.insert(user).values({ email, password }).returning({ - email: user.email, - id: user.id, - }); - } catch (error) { - throw new ChatbotError("bad_request:database", { cause: error }); - } -} - -export async function saveChat({ - id, - userId, - title, - visibility, -}: { - id: string; - userId: string; - title: string; - visibility: VisibilityType; -}) { - try { - return await db.insert(chat).values({ + return await db.insert(user).values({ email, password }).returning({ + email: user.email, + id: user.id, + }); +}); + +export const saveChat = withDbErrors( + async ({ + id, + userId, + title, + visibility, + }: { + id: string; + userId: string; + title: string; + visibility: VisibilityType; + }) => + await db.insert(chat).values({ createdAt: new Date(), id, title, userId, visibility, - }); - } catch (error) { - throw new ChatbotError("bad_request:database", { - cause: error, - }); - } -} - -export async function deleteChatById({ id }: { id: string }) { - try { - await db.delete(vote).where(eq(vote.chatId, id)); - await db.delete(message).where(eq(message.chatId, id)); - await db.delete(stream).where(eq(stream.chatId, id)); - - const [chatsDeleted] = await db - .delete(chat) - .where(eq(chat.id, id)) - .returning(); - return chatsDeleted; - } catch (error) { - throw new ChatbotError("bad_request:database", { cause: error }); - } -} - -export async function deleteAllChatsByUserId({ userId }: { userId: string }) { - try { + }) +); + +export const deleteChatById = withDbErrors(async ({ id }: { id: string }) => { + await db.delete(vote).where(eq(vote.chatId, id)); + await db.delete(message).where(eq(message.chatId, id)); + await db.delete(stream).where(eq(stream.chatId, id)); + + const [chatsDeleted] = await db + .delete(chat) + .where(eq(chat.id, id)) + .returning(); + return chatsDeleted; +}); + +export const deleteAllChatsByUserId = withDbErrors( + async ({ userId }: { userId: string }) => { const userChats = await db .select({ id: chat.id }) .from(chat) @@ -135,23 +135,21 @@ export async function deleteAllChatsByUserId({ userId }: { userId: string }) { .returning(); return { deletedCount: deletedChats.length }; - } catch (error) { - throw new ChatbotError("bad_request:database", { cause: error }); } -} - -export async function getChatsByUserId({ - id, - limit, - startingAfter, - endingBefore, -}: { - id: string; - limit: number; - startingAfter: string | null; - endingBefore: string | null; -}) { - try { +); + +export const getChatsByUserId = withDbErrors( + async ({ + id, + limit, + startingAfter, + endingBefore, + }: { + id: string; + limit: number; + startingAfter: string | null; + endingBefore: string | null; + }) => { const extendedLimit = limit + 1; const query = (whereCondition?: SQL) => @@ -208,74 +206,47 @@ export async function getChatsByUserId({ chats: hasMore ? filteredChats.slice(0, limit) : filteredChats, hasMore, }; - } catch (error) { - throw new ChatbotError("bad_request:database", { cause: error }); } -} +); -export async function getChatById({ id }: { id: string }) { - try { - const [selectedChat] = await db.select().from(chat).where(eq(chat.id, id)); - if (!selectedChat) { - return null; - } - - return selectedChat; - } catch (error) { - throw new ChatbotError("bad_request:database", { - cause: error, - }); +export const getChatById = withDbErrors(async ({ id }: { id: string }) => { + const [selectedChat] = await db.select().from(chat).where(eq(chat.id, id)); + if (!selectedChat) { + return null; } -} -export async function saveMessages({ messages }: { messages: DBMessage[] }) { - try { - return await db.insert(message).values(messages); - } catch (error) { - throw new ChatbotError("bad_request:database", { - cause: error, - }); - } -} + return selectedChat; +}); -export async function updateMessage({ - id, - parts, -}: { - id: string; - parts: DBMessage["parts"]; -}) { - try { - return await db.update(message).set({ parts }).where(eq(message.id, id)); - } catch (error) { - throw new ChatbotError("bad_request:database", { - cause: error, - }); - } -} +export const saveMessages = withDbErrors( + async ({ messages }: { messages: DBMessage[] }) => + await db.insert(message).values(messages) +); -export async function getMessagesByChatId({ id }: { id: string }) { - try { - return await db +export const updateMessage = withDbErrors( + async ({ id, parts }: { id: string; parts: DBMessage["parts"] }) => + await db.update(message).set({ parts }).where(eq(message.id, id)) +); + +export const getMessagesByChatId = withDbErrors( + async ({ id }: { id: string }) => + await db .select() .from(message) .where(eq(message.chatId, id)) - .orderBy(asc(message.createdAt)); - } catch (error) { - throw new ChatbotError("bad_request:database", { cause: error }); - } -} - -export async function voteMessage({ - chatId, - messageId, - type, -}: { - chatId: string; - messageId: string; - type: "up" | "down"; -}) { - try { + .orderBy(asc(message.createdAt)) +); + +export const voteMessage = withDbErrors( + async ({ + chatId, + messageId, + type, + }: { + chatId: string; + messageId: string; + type: "up" | "down"; + }) => { const [existingVote] = await db .select() .from(vote) @@ -292,36 +263,29 @@ export async function voteMessage({ isUpvoted: type === "up", messageId, }); - } catch (error) { - throw new ChatbotError("bad_request:database", { - cause: error, - }); } -} - -export async function getVotesByChatId({ id }: { id: string }) { - try { - return await db.select().from(vote).where(eq(vote.chatId, id)); - } catch (error) { - throw new ChatbotError("bad_request:database", { cause: error }); - } -} - -export async function saveDocument({ - id, - title, - kind, - content, - userId, -}: { - id: string; - title: string; - kind: ArtifactKind; - content: string; - userId: string; -}) { - try { - return await db +); + +export const getVotesByChatId = withDbErrors( + async ({ id }: { id: string }) => + await db.select().from(vote).where(eq(vote.chatId, id)) +); + +export const saveDocument = withDbErrors( + async ({ + id, + title, + kind, + content, + userId, + }: { + id: string; + title: string; + kind: ArtifactKind; + content: string; + userId: string; + }) => + await db .insert(document) .values({ content, @@ -331,22 +295,11 @@ export async function saveDocument({ title, userId, }) - .returning(); - } catch (error) { - throw new ChatbotError("bad_request:database", { - cause: error, - }); - } -} + .returning() +); -export async function updateDocumentContent({ - id, - content, -}: { - id: string; - content: string; -}) { - try { +export const updateDocumentContent = withDbErrors( + async ({ id, content }: { id: string; content: string }) => { const docs = await db .select() .from(document) @@ -364,52 +317,31 @@ export async function updateDocumentContent({ .set({ content }) .where(and(eq(document.id, id), eq(document.createdAt, latest.createdAt))) .returning(); - } catch (error) { - if (error instanceof ChatbotError) { - throw error; - } - throw new ChatbotError("bad_request:database", { - cause: error, - }); - } -} - -export async function getDocumentsById({ id }: { id: string }) { - try { - const documents = await db - .select() - .from(document) - .where(eq(document.id, id)) - .orderBy(asc(document.createdAt)); - - return documents; - } catch (error) { - throw new ChatbotError("bad_request:database", { cause: error }); - } -} - -export async function getDocumentById({ id }: { id: string }) { - try { - const [selectedDocument] = await db - .select() - .from(document) - .where(eq(document.id, id)) - .orderBy(desc(document.createdAt)); - - return selectedDocument; - } catch (error) { - throw new ChatbotError("bad_request:database", { cause: error }); } -} - -export async function deleteDocumentsByIdAfterTimestamp({ - id, - timestamp, -}: { - id: string; - timestamp: Date; -}) { - try { +); + +export const getDocumentsById = withDbErrors(async ({ id }: { id: string }) => { + const documents = await db + .select() + .from(document) + .where(eq(document.id, id)) + .orderBy(asc(document.createdAt)); + + return documents; +}); + +export const getDocumentById = withDbErrors(async ({ id }: { id: string }) => { + const [selectedDocument] = await db + .select() + .from(document) + .where(eq(document.id, id)) + .orderBy(desc(document.createdAt)); + + return selectedDocument; +}); + +export const deleteDocumentsByIdAfterTimestamp = withDbErrors( + async ({ id, timestamp }: { id: string; timestamp: Date }) => { await db .delete(suggestion) .where( @@ -423,54 +355,29 @@ export async function deleteDocumentsByIdAfterTimestamp({ .delete(document) .where(and(eq(document.id, id), gt(document.createdAt, timestamp))) .returning(); - } catch (error) { - throw new ChatbotError("bad_request:database", { cause: error }); } -} +); -export async function saveSuggestions({ - suggestions, -}: { - suggestions: Suggestion[]; -}) { - try { - return await db.insert(suggestion).values(suggestions); - } catch (error) { - throw new ChatbotError("bad_request:database", { cause: error }); - } -} +export const saveSuggestions = withDbErrors( + async ({ suggestions }: { suggestions: Suggestion[] }) => + await db.insert(suggestion).values(suggestions) +); -export async function getSuggestionsByDocumentId({ - documentId, -}: { - documentId: string; -}) { - try { - return await db +export const getSuggestionsByDocumentId = withDbErrors( + async ({ documentId }: { documentId: string }) => + await db .select() .from(suggestion) - .where(eq(suggestion.documentId, documentId)); - } catch (error) { - throw new ChatbotError("bad_request:database", { cause: error }); - } -} + .where(eq(suggestion.documentId, documentId)) +); -export async function getMessageById({ id }: { id: string }) { - try { - return await db.select().from(message).where(eq(message.id, id)); - } catch (error) { - throw new ChatbotError("bad_request:database", { cause: error }); - } -} +export const getMessageById = withDbErrors( + async ({ id }: { id: string }) => + await db.select().from(message).where(eq(message.id, id)) +); -export async function deleteMessagesByChatIdAfterTimestamp({ - chatId, - timestamp, -}: { - chatId: string; - timestamp: Date; -}) { - try { +export const deleteMessagesByChatIdAfterTimestamp = withDbErrors( + async ({ chatId, timestamp }: { chatId: string; timestamp: Date }) => { const messagesToDelete = await db .select({ id: message.id }) .from(message) @@ -495,24 +402,18 @@ export async function deleteMessagesByChatIdAfterTimestamp({ and(eq(message.chatId, chatId), inArray(message.id, messageIds)) ); } - } catch (error) { - throw new ChatbotError("bad_request:database", { cause: error }); } -} - -export async function updateChatVisibilityById({ - chatId, - visibility, -}: { - chatId: string; - visibility: "private" | "public"; -}) { - try { - return await db.update(chat).set({ visibility }).where(eq(chat.id, chatId)); - } catch (error) { - throw new ChatbotError("bad_request:database", { cause: error }); - } -} +); + +export const updateChatVisibilityById = withDbErrors( + async ({ + chatId, + visibility, + }: { + chatId: string; + visibility: "private" | "public"; + }) => await db.update(chat).set({ visibility }).where(eq(chat.id, chatId)) +); export async function updateChatTitleById({ chatId, @@ -528,14 +429,14 @@ export async function updateChatTitleById({ } } -export async function getMessageCountByUserId({ - id, - differenceInHours, -}: { - id: string; - differenceInHours: number; -}) { - try { +export const getMessageCountByUserId = withDbErrors( + async ({ + id, + differenceInHours, + }: { + id: string; + differenceInHours: number; + }) => { const cutoffTime = new Date( Date.now() - differenceInHours * 60 * 60 * 1000 ); @@ -554,29 +455,19 @@ export async function getMessageCountByUserId({ .execute(); return stats?.count ?? 0; - } catch (error) { - throw new ChatbotError("bad_request:database", { cause: error }); } -} +); -export async function createStreamId({ - streamId, - chatId, -}: { - streamId: string; - chatId: string; -}) { - try { +export const createStreamId = withDbErrors( + async ({ streamId, chatId }: { streamId: string; chatId: string }) => { await db .insert(stream) .values({ chatId, createdAt: new Date(), id: streamId }); - } catch (error) { - throw new ChatbotError("bad_request:database", { cause: error }); } -} +); -export async function getStreamIdsByChatId({ chatId }: { chatId: string }) { - try { +export const getStreamIdsByChatId = withDbErrors( + async ({ chatId }: { chatId: string }) => { const streamIds = await db .select({ id: stream.id }) .from(stream) @@ -585,7 +476,5 @@ export async function getStreamIdsByChatId({ chatId }: { chatId: string }) { .execute(); return streamIds.map(({ id }) => id); - } catch (error) { - throw new ChatbotError("bad_request:database", { cause: error }); } -} +); diff --git a/lib/utils.ts b/lib/utils.ts index 27dc247..62d5da1 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -13,14 +13,25 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } -export const fetcher = async (url: string) => { - const response = await fetch(url); +/** + * Prefixes an app-relative path with the deployment base path. + */ +export function withBasePath(path: string) { + return `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}${path}`; +} +async function throwOnErrorResponse(response: Response) { if (!response.ok) { const { code, cause } = await response.json(); throw new ChatbotError(code as ErrorCode, cause); } + return response; +} + +export const fetcher = async (url: string) => { + const response = await throwOnErrorResponse(await fetch(url)); + return response.json(); }; @@ -29,14 +40,7 @@ export async function fetchWithErrorHandlers( init?: RequestInit, ) { try { - const response = await fetch(input, init); - - if (!response.ok) { - const { code, cause } = await response.json(); - throw new ChatbotError(code as ErrorCode, cause); - } - - return response; + return await throwOnErrorResponse(await fetch(input, init)); } catch (error: unknown) { if (typeof navigator !== 'undefined' && !navigator.onLine) { throw new ChatbotError('offline:chat');