Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions app/api/commands/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@ import { NextResponse } from "next/server";
import { supabaseAdmin } from "@/lib/supabase/admin";
import { requireUserId } from "@/lib/auth";

export function pickCommandCreateFields(input: unknown) {
const fields: Record<string, unknown> = {};
if (!input || typeof input !== "object" || Array.isArray(input))
return fields;
const body = input as Record<string, unknown>;
if (typeof body.name === "string") fields.name = body.name;
if (typeof body.description === "string") {
fields.description = body.description;
}
if (typeof body.template === "string") fields.template = body.template;
return fields;
}

export async function GET() {
const userId = await requireUserId();
if (userId instanceof Response) return userId;
Expand All @@ -21,10 +34,17 @@ export async function POST(req: Request) {
const userId = await requireUserId();
if (userId instanceof Response) return userId;

const body = await req.json();
const body = await req.json().catch(() => null);
const fields = pickCommandCreateFields(body);
if (typeof fields.name !== "string" || typeof fields.template !== "string") {
return NextResponse.json(
{ error: "name and template are required" },
{ status: 400 }
);
}
const { data, error } = await supabaseAdmin
.from("custom_commands")
.insert({ ...body, user_id: userId })
.insert({ ...fields, user_id: userId })
.select()
.single();

Expand Down
99 changes: 89 additions & 10 deletions app/api/control/chat/_lib/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,15 @@ import type {
type NormalizedControlChatMessage = Omit<UIMessage, "id">;

const MAX_CONTROL_FILE_DATA_URL_CHARS = 5_600_000;
const MAX_CONTROL_FILE_BYTES = 4 * 1024 * 1024;
const MAX_CONTROL_FILE_PARTS = 5;
const MAX_CONTROL_TOTAL_FILE_BYTES =
MAX_CONTROL_FILE_BYTES * MAX_CONTROL_FILE_PARTS;

type ControlFileBudget = {
count: number;
bytes: number;
};

export class ControlChatValidationError extends Error {
constructor(message: string) {
Expand All @@ -16,7 +24,59 @@ export class ControlChatValidationError extends Error {
}
}

function normalizeFilePart(part: ControlChatRequestPart): FileUIPart {
function readFilePartBytes(part: ControlChatRequestPart): number {
Comment thread
charlesrhoward marked this conversation as resolved.
const match = /^data:([^;,]+);base64,([A-Za-z0-9+/]*={0,2})$/.exec(
part.type === "file" ? part.url : ""
);
if (
!match ||
part.type !== "file" ||
match[1]?.toLowerCase() !== part.mediaType.toLowerCase()
) {
throw new ControlChatValidationError(
"Invalid control chat file attachment."
);
}
const encodedData = match[2] ?? "";
if (encodedData.length % 4 !== 0) {
throw new ControlChatValidationError(
"Invalid control chat file attachment."
);
}
const decodedData = Buffer.from(encodedData, "base64");
if (decodedData.toString("base64") !== encodedData) {
throw new ControlChatValidationError(
"Invalid control chat file attachment."
);
}
const decodedBytes = decodedData.byteLength;
if (decodedBytes > MAX_CONTROL_FILE_BYTES) {
throw new ControlChatValidationError(
"Control chat file attachment exceeds the size limit."
);
}
return decodedBytes;
}

function applyFileBudget(budget: ControlFileBudget, decodedBytes: number) {
budget.count += 1;
if (budget.count > MAX_CONTROL_FILE_PARTS) {
throw new ControlChatValidationError(
`Control chat supports up to ${MAX_CONTROL_FILE_PARTS} file attachments.`
);
}
budget.bytes += decodedBytes;
if (budget.bytes > MAX_CONTROL_TOTAL_FILE_BYTES) {
throw new ControlChatValidationError(
"Control chat file attachments exceed the total size limit."
);
}
}

function normalizeFilePart(
part: ControlChatRequestPart,
budget: ControlFileBudget
): FileUIPart {
if (
part.type !== "file" ||
typeof part.mediaType !== "string" ||
Expand All @@ -37,6 +97,7 @@ function normalizeFilePart(part: ControlChatRequestPart): FileUIPart {
"Control chat file attachment exceeds the size limit."
);
}
applyFileBudget(budget, readFilePartBytes(part));
return {
type: "file" as const,
mediaType: part.mediaType,
Expand All @@ -52,12 +113,21 @@ export function normalizeControlChatMessages(
throw new ControlChatValidationError("Invalid control chat messages.");
}

const fileBudget: ControlFileBudget = { count: 0, bytes: 0 };
return (messages as unknown[]).map((message) => {
if (typeof message !== "object" || message === null) {
throw new ControlChatValidationError("Invalid control chat message.");
}
const controlMessage = message as ControlChatRequestMessage;
let filePartCount = 0;
if (
controlMessage.role !== "user" &&
controlMessage.role !== "assistant" &&
controlMessage.role !== "system"
) {
throw new ControlChatValidationError(
"Invalid control chat message role."
);
}
const parts =
controlMessage.parts ??
(typeof controlMessage.content === "string"
Expand All @@ -69,19 +139,28 @@ export function normalizeControlChatMessages(

return {
role: controlMessage.role as "user" | "assistant" | "system",
parts: parts.flatMap<TextUIPart | FileUIPart>(
parts: (parts as unknown[]).flatMap<TextUIPart | FileUIPart>(
(part): Array<TextUIPart | FileUIPart> => {
if (part.type === "text") {
return [{ type: "text" as const, text: part.text ?? "" }];
if (
typeof part !== "object" ||
part === null ||
Array.isArray(part)
) {
throw new ControlChatValidationError(
"Invalid control chat message part."
);
}
if (part.type === "file") {
filePartCount += 1;
if (filePartCount > MAX_CONTROL_FILE_PARTS) {
const controlPart = part as ControlChatRequestPart;
if (controlPart.type === "text") {
if (typeof controlPart.text !== "string") {
throw new ControlChatValidationError(
`Control chat supports up to ${MAX_CONTROL_FILE_PARTS} file attachments.`
"Invalid control chat text part."
);
}
return [normalizeFilePart(part)];
return [{ type: "text" as const, text: controlPart.text }];
}
if (controlPart.type === "file") {
return [normalizeFilePart(controlPart, fileBudget)];
}
return [];
}
Expand Down
Loading
Loading