feat: v1 — persistent shell, model gateway, artifact improvements (#1462)

This commit is contained in:
dancer 2026-03-20 09:37:02 +00:00 committed by GitHub
parent 3651670fb9
commit f9652b452a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
161 changed files with 5166 additions and 8009 deletions

View file

@ -1,4 +0,0 @@
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth";
export const { GET, POST } = toNextJsHandler(auth);

View file

@ -10,15 +10,21 @@ import {
import { checkBotId } from "botid/server";
import { after } from "next/server";
import { createResumableStreamContext } from "resumable-stream";
import { auth, type UserType } from "@/app/(auth)/auth";
import { entitlementsByUserType } from "@/lib/ai/entitlements";
import { allowedModelIds } from "@/lib/ai/models";
import {
allowedModelIds,
chatModels,
DEFAULT_CHAT_MODEL,
getCapabilities,
} from "@/lib/ai/models";
import { type RequestHints, systemPrompt } from "@/lib/ai/prompts";
import { getLanguageModel } from "@/lib/ai/providers";
import { createDocument } from "@/lib/ai/tools/create-document";
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 { getSession, getUserType, type UserType } from "@/lib/auth";
import { isProductionEnvironment } from "@/lib/constants";
import {
createStreamId,
@ -67,20 +73,20 @@ export async function POST(request: Request) {
const [, session] = await Promise.all([
checkBotId().catch(() => null),
getSession(),
auth(),
]);
if (!session?.user) {
return new ChatbotError("unauthorized:chat").toResponse();
}
if (!allowedModelIds.has(selectedChatModel)) {
return new ChatbotError("bad_request:api").toResponse();
}
const chatModel = allowedModelIds.has(selectedChatModel)
? selectedChatModel
: DEFAULT_CHAT_MODEL;
await checkIpRateLimit(ipAddress(request));
const userType: UserType = getUserType(session.user);
const userType: UserType = session.user.type;
const messageCount = await getMessageCountByUserId({
id: session.user.id,
@ -101,9 +107,7 @@ export async function POST(request: Request) {
if (chat.userId !== session.user.id) {
return new ChatbotError("forbidden:chat").toResponse();
}
if (!isToolApprovalFlow) {
messagesFromDb = await getMessagesByChatId({ id });
}
messagesFromDb = await getMessagesByChatId({ id });
} else if (message?.role === "user") {
await saveChat({
id,
@ -114,9 +118,43 @@ export async function POST(request: Request) {
titlePromise = generateTitleFromUserMessage({ message });
}
const uiMessages = isToolApprovalFlow
? (messages as ChatMessage[])
: [...convertToUIMessages(messagesFromDb), message as ChatMessage];
let uiMessages: ChatMessage[];
if (isToolApprovalFlow && messages) {
const dbMessages = convertToUIMessages(messagesFromDb);
const approvalStates = new Map(
messages.flatMap(
(m) =>
m.parts
?.filter(
(p: Record<string, unknown>) =>
p.state === "approval-responded" ||
p.state === "output-denied"
)
.map((p: Record<string, unknown>) => [
String(p.toolCallId ?? ""),
p,
]) ?? []
)
);
uiMessages = dbMessages.map((msg) => ({
...msg,
parts: msg.parts.map((part) => {
if (
"toolCallId" in part &&
approvalStates.has(String(part.toolCallId))
) {
return { ...part, ...approvalStates.get(String(part.toolCallId)) };
}
return part;
}),
})) as ChatMessage[];
} else {
uiMessages = [
...convertToUIMessages(messagesFromDb),
message as ChatMessage,
];
}
const { longitude, latitude, city, country } = geolocation(request);
@ -142,10 +180,11 @@ export async function POST(request: Request) {
});
}
const isReasoningModel =
selectedChatModel.endsWith("-thinking") ||
(selectedChatModel.includes("reasoning") &&
!selectedChatModel.includes("non-reasoning"));
const modelConfig = chatModels.find((m) => m.id === chatModel);
const modelCapabilities = await getCapabilities();
const capabilities = modelCapabilities[chatModel];
const isReasoningModel = capabilities?.reasoning === true;
const supportsTools = capabilities?.tools === true;
const modelMessages = await convertToModelMessages(uiMessages);
@ -153,30 +192,46 @@ export async function POST(request: Request) {
originalMessages: isToolApprovalFlow ? uiMessages : undefined,
execute: async ({ writer: dataStream }) => {
const result = streamText({
model: getLanguageModel(selectedChatModel),
system: systemPrompt({ selectedChatModel, requestHints }),
model: getLanguageModel(chatModel),
system: systemPrompt({ requestHints, supportsTools }),
messages: modelMessages,
stopWhen: stepCountIs(5),
experimental_activeTools: isReasoningModel
? []
: [
"getWeather",
"createDocument",
"updateDocument",
"requestSuggestions",
],
providerOptions: isReasoningModel
? {
anthropic: {
thinking: { type: "enabled", budgetTokens: 10_000 },
},
}
: undefined,
experimental_activeTools:
isReasoningModel && !supportsTools
? []
: [
"getWeather",
"createDocument",
"editDocument",
"updateDocument",
"requestSuggestions",
],
providerOptions: {
...(modelConfig?.gatewayOrder && {
gateway: { order: modelConfig.gatewayOrder },
}),
...(modelConfig?.reasoningEffort && {
openai: { reasoningEffort: modelConfig.reasoningEffort },
}),
},
tools: {
getWeather,
createDocument: createDocument({ session, dataStream }),
updateDocument: updateDocument({ session, dataStream }),
requestSuggestions: requestSuggestions({ session, dataStream }),
createDocument: createDocument({
session,
dataStream,
modelId: chatModel,
}),
editDocument: editDocument({ dataStream, session }),
updateDocument: updateDocument({
session,
dataStream,
modelId: chatModel,
}),
requestSuggestions: requestSuggestions({
session,
dataStream,
modelId: chatModel,
}),
},
experimental_telemetry: {
isEnabled: isProductionEnvironment,
@ -262,7 +317,7 @@ export async function POST(request: Request) {
);
}
} catch (_) {
// ignore redis errors
/* non-critical */
}
},
});
@ -295,7 +350,7 @@ export async function DELETE(request: Request) {
return new ChatbotError("bad_request:api").toResponse();
}
const session = await getSession();
const session = await auth();
if (!session?.user) {
return new ChatbotError("unauthorized:chat").toResponse();

View file

@ -20,18 +20,16 @@ const userMessageSchema = z.object({
parts: z.array(partSchema),
});
// For tool approval flows, we accept all messages (more permissive schema)
const messageSchema = z.object({
const toolApprovalMessageSchema = z.object({
id: z.string(),
role: z.string(),
parts: z.array(z.any()),
role: z.enum(["user", "assistant"]),
parts: z.array(z.record(z.unknown())),
});
export const postRequestBodySchema = z.object({
id: z.string().uuid(),
// Either a single new message or all messages (for tool approvals)
message: userMessageSchema.optional(),
messages: z.array(messageSchema).optional(),
messages: z.array(toolApprovalMessageSchema).optional(),
selectedChatModel: z.string(),
selectedVisibilityType: z.enum(["public", "private"]),
});

View file

@ -1,12 +1,21 @@
import type { ArtifactKind } from "@/components/artifact";
import { getSession } from "@/lib/auth";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import type { ArtifactKind } from "@/components/chat/artifact";
import {
deleteDocumentsByIdAfterTimestamp,
getDocumentsById,
saveDocument,
updateDocumentContent,
} from "@/lib/db/queries";
import { ChatbotError } from "@/lib/errors";
const documentSchema = z.object({
content: z.string(),
title: z.string(),
kind: z.enum(["text", "code", "image", "sheet"]),
isManualEdit: z.boolean().optional(),
});
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
@ -18,7 +27,7 @@ export async function GET(request: Request) {
).toResponse();
}
const session = await getSession();
const session = await auth();
if (!session?.user) {
return new ChatbotError("unauthorized:document").toResponse();
@ -50,18 +59,29 @@ export async function POST(request: Request) {
).toResponse();
}
const session = await getSession();
const session = await auth();
if (!session?.user) {
return new ChatbotError("not_found:document").toResponse();
}
const {
content,
title,
kind,
}: { content: string; title: string; kind: ArtifactKind } =
await request.json();
let content: string;
let title: string;
let kind: ArtifactKind;
let isManualEdit: boolean | undefined;
try {
const parsed = documentSchema.parse(await request.json());
content = parsed.content;
title = parsed.title;
kind = parsed.kind;
isManualEdit = parsed.isManualEdit;
} catch {
return new ChatbotError(
"bad_request:api",
"Invalid request body."
).toResponse();
}
const documents = await getDocumentsById({ id });
@ -73,6 +93,11 @@ export async function POST(request: Request) {
}
}
if (isManualEdit && documents.length > 0) {
const result = await updateDocumentContent({ id, content });
return Response.json(result, { status: 200 });
}
const document = await saveDocument({
id,
content,
@ -103,7 +128,7 @@ export async function DELETE(request: Request) {
).toResponse();
}
const session = await getSession();
const session = await auth();
if (!session?.user) {
return new ChatbotError("unauthorized:document").toResponse();
@ -117,9 +142,18 @@ export async function DELETE(request: Request) {
return new ChatbotError("forbidden:document").toResponse();
}
const parsedTimestamp = new Date(timestamp);
if (Number.isNaN(parsedTimestamp.getTime())) {
return new ChatbotError(
"bad_request:api",
"Invalid timestamp."
).toResponse();
}
const documentsDeleted = await deleteDocumentsByIdAfterTimestamp({
id,
timestamp: new Date(timestamp),
timestamp: parsedTimestamp,
});
return Response.json(documentsDeleted, { status: 200 });

View file

@ -2,23 +2,21 @@ import { put } from "@vercel/blob";
import { NextResponse } from "next/server";
import { z } from "zod";
import { getSession } from "@/lib/auth";
import { auth } from "@/app/(auth)/auth";
// Use Blob instead of File since File is not available in Node.js environment
const FileSchema = z.object({
file: z
.instanceof(Blob)
.refine((file) => file.size <= 5 * 1024 * 1024, {
message: "File size should be less than 5MB",
})
// Update the file type based on the kind of files you want to accept
.refine((file) => ["image/jpeg", "image/png"].includes(file.type), {
message: "File type should be JPEG or PNG",
}),
});
export async function POST(request: Request) {
const session = await getSession();
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@ -46,12 +44,12 @@ export async function POST(request: Request) {
return NextResponse.json({ error: errorMessage }, { status: 400 });
}
// Get filename from formData since Blob doesn't have name property
const filename = (formData.get("file") as File).name;
const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, "_");
const fileBuffer = await file.arrayBuffer();
try {
const data = await put(`${filename}`, fileBuffer, {
const data = await put(`${safeName}`, fileBuffer, {
access: "public",
});

View file

@ -1,12 +1,15 @@
import type { NextRequest } from "next/server";
import { getSession } from "@/lib/auth";
import { auth } from "@/app/(auth)/auth";
import { deleteAllChatsByUserId, getChatsByUserId } from "@/lib/db/queries";
import { ChatbotError } from "@/lib/errors";
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const limit = Number.parseInt(searchParams.get("limit") || "10", 10);
const limit = Math.min(
Math.max(Number.parseInt(searchParams.get("limit") || "10", 10), 1),
50
);
const startingAfter = searchParams.get("starting_after");
const endingBefore = searchParams.get("ending_before");
@ -17,7 +20,7 @@ export async function GET(request: NextRequest) {
).toResponse();
}
const session = await getSession();
const session = await auth();
if (!session?.user) {
return new ChatbotError("unauthorized:chat").toResponse();
@ -34,7 +37,7 @@ export async function GET(request: NextRequest) {
}
export async function DELETE() {
const session = await getSession();
const session = await auth();
if (!session?.user) {
return new ChatbotError("unauthorized:chat").toResponse();

View file

@ -0,0 +1,43 @@
import { auth } from "@/app/(auth)/auth";
import { getChatById, getMessagesByChatId } from "@/lib/db/queries";
import { convertToUIMessages } from "@/lib/utils";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const chatId = searchParams.get("chatId");
if (!chatId) {
return Response.json({ error: "chatId required" }, { status: 400 });
}
const [session, chat, messages] = await Promise.all([
auth(),
getChatById({ id: chatId }),
getMessagesByChatId({ id: chatId }),
]);
if (!chat) {
return Response.json({
messages: [],
visibility: "private",
userId: null,
isReadonly: false,
});
}
if (
chat.visibility === "private" &&
(!session?.user || session.user.id !== chat.userId)
) {
return Response.json({ error: "forbidden" }, { status: 403 });
}
const isReadonly = !session?.user || session.user.id !== chat.userId;
return Response.json({
messages: convertToUIMessages(messages),
visibility: chat.visibility,
userId: chat.userId,
isReadonly,
});
}

View file

@ -0,0 +1,20 @@
import { getAllGatewayModels, getCapabilities, isDemo } from "@/lib/ai/models";
export async function GET() {
const headers = {
"Cache-Control": "public, max-age=86400, s-maxage=86400",
};
const curatedCapabilities = await getCapabilities();
if (isDemo) {
const models = await getAllGatewayModels();
const capabilities = Object.fromEntries(
models.map((m) => [m.id, curatedCapabilities[m.id] ?? m.capabilities])
);
return Response.json({ capabilities, models }, { headers });
}
return Response.json(curatedCapabilities, { headers });
}

View file

@ -1,4 +1,4 @@
import { getSession } from "@/lib/auth";
import { auth } from "@/app/(auth)/auth";
import { getSuggestionsByDocumentId } from "@/lib/db/queries";
import { ChatbotError } from "@/lib/errors";
@ -13,7 +13,7 @@ export async function GET(request: Request) {
).toResponse();
}
const session = await getSession();
const session = await auth();
if (!session?.user) {
return new ChatbotError("unauthorized:suggestions").toResponse();

View file

@ -1,7 +1,14 @@
import { getSession } from "@/lib/auth";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { getChatById, getVotesByChatId, voteMessage } from "@/lib/db/queries";
import { ChatbotError } from "@/lib/errors";
const voteSchema = z.object({
chatId: z.string(),
messageId: z.string(),
type: z.enum(["up", "down"]),
});
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const chatId = searchParams.get("chatId");
@ -13,7 +20,7 @@ export async function GET(request: Request) {
).toResponse();
}
const session = await getSession();
const session = await auth();
if (!session?.user) {
return new ChatbotError("unauthorized:vote").toResponse();
@ -35,21 +42,23 @@ export async function GET(request: Request) {
}
export async function PATCH(request: Request) {
const {
chatId,
messageId,
type,
}: { chatId: string; messageId: string; type: "up" | "down" } =
await request.json();
let chatId: string;
let messageId: string;
let type: "up" | "down";
if (!chatId || !messageId || !type) {
try {
const parsed = voteSchema.parse(await request.json());
chatId = parsed.chatId;
messageId = parsed.messageId;
type = parsed.type;
} catch {
return new ChatbotError(
"bad_request:api",
"Parameters chatId, messageId, and type are required."
).toResponse();
}
const session = await getSession();
const session = await auth();
if (!session?.user) {
return new ChatbotError("unauthorized:vote").toResponse();