diff --git a/app/api/bedrock.ts b/app/api/bedrock.ts index 9d7ddb4faaa..e3ca645bd7a 100644 --- a/app/api/bedrock.ts +++ b/app/api/bedrock.ts @@ -4,18 +4,350 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "./auth"; import { BedrockRuntimeClient, + ConverseStreamCommand, + ConverseStreamCommandInput, ConverseStreamOutput, - ValidationException, ModelStreamErrorException, - ThrottlingException, - ServiceUnavailableException, - InternalServerException, + type Message, + type ContentBlock, + type SystemContentBlock, + type Tool, + type ToolChoice, + type ToolResultContentBlock, } from "@aws-sdk/client-bedrock-runtime"; -import { validateModelId } from "./bedrock/utils"; -import { ConverseRequest, createConverseStreamCommand } from "./bedrock/models"; +// Constants and Types const ALLOWED_PATH = new Set(["converse"]); +export interface ConverseRequest { + modelId: string; + messages: { + role: "user" | "assistant" | "system"; + content: string | ContentItem[]; + }[]; + inferenceConfig?: { + maxTokens?: number; + temperature?: number; + topP?: number; + stopSequences?: string[]; + }; + toolConfig?: { + tools: Tool[]; + toolChoice?: ToolChoice; + }; +} + +interface ContentItem { + type: "text" | "image_url" | "document" | "tool_use" | "tool_result"; + text?: string; + image_url?: { + url: string; // base64 data URL + }; + document?: { + format: DocumentFormat; + name: string; + source: { + bytes: string; // base64 + }; + }; + tool_use?: { + tool_use_id: string; + name: string; + input: any; + }; + tool_result?: { + tool_use_id: string; + content: ToolResultItem[]; + status: "success" | "error"; + }; +} + +interface ToolResultItem { + type: "text" | "image" | "document" | "json"; + text?: string; + image?: { + format: "png" | "jpeg" | "gif" | "webp"; + source: { + bytes: string; // base64 + }; + }; + document?: { + format: DocumentFormat; + name: string; + source: { + bytes: string; // base64 + }; + }; + json?: any; +} + +type DocumentFormat = + | "pdf" + | "csv" + | "doc" + | "docx" + | "xls" + | "xlsx" + | "html" + | "txt" + | "md"; + +// Validation Functions +function validateModelId(modelId: string): string | null { + if ( + modelId.startsWith("meta.llama") && + !modelId.includes("inference-profile") + ) { + return "Llama models require an inference profile. Please use the full inference profile ARN."; + } + return null; +} + +function validateDocumentSize(base64Data: string): boolean { + const sizeInBytes = (base64Data.length * 3) / 4; + const maxSize = 4.5 * 1024 * 1024; + if (sizeInBytes > maxSize) { + throw new Error("Document size exceeds 4.5 MB limit"); + } + return true; +} + +function validateImageSize(base64Data: string): boolean { + const sizeInBytes = (base64Data.length * 3) / 4; + const maxSize = 3.75 * 1024 * 1024; + if (sizeInBytes > maxSize) { + throw new Error("Image size exceeds 3.75 MB limit"); + } + return true; +} + +// Content Processing Functions +function convertContentToAWSBlock(item: ContentItem): ContentBlock | null { + if (item.type === "text" && item.text) { + return { text: item.text }; + } + + if (item.type === "image_url" && item.image_url?.url) { + const base64Match = item.image_url.url.match( + /^data:image\/([a-zA-Z]*);base64,([^"]*)/, + ); + if (base64Match) { + const format = base64Match[1].toLowerCase(); + if (["png", "jpeg", "gif", "webp"].includes(format)) { + validateImageSize(base64Match[2]); + return { + image: { + format: format as "png" | "jpeg" | "gif" | "webp", + source: { + bytes: Uint8Array.from(Buffer.from(base64Match[2], "base64")), + }, + }, + }; + } + } + } + + if (item.type === "document" && item.document) { + validateDocumentSize(item.document.source.bytes); + return { + document: { + format: item.document.format, + name: item.document.name, + source: { + bytes: Uint8Array.from( + Buffer.from(item.document.source.bytes, "base64"), + ), + }, + }, + }; + } + + if (item.type === "tool_use" && item.tool_use) { + return { + toolUse: { + toolUseId: item.tool_use.tool_use_id, + name: item.tool_use.name, + input: item.tool_use.input, + }, + }; + } + + if (item.type === "tool_result" && item.tool_result) { + const toolResultContent = item.tool_result.content + .map((resultItem) => { + if (resultItem.type === "text" && resultItem.text) { + return { text: resultItem.text } as ToolResultContentBlock; + } + if (resultItem.type === "image" && resultItem.image) { + return { + image: { + format: resultItem.image.format, + source: { + bytes: Uint8Array.from( + Buffer.from(resultItem.image.source.bytes, "base64"), + ), + }, + }, + } as ToolResultContentBlock; + } + if (resultItem.type === "document" && resultItem.document) { + return { + document: { + format: resultItem.document.format, + name: resultItem.document.name, + source: { + bytes: Uint8Array.from( + Buffer.from(resultItem.document.source.bytes, "base64"), + ), + }, + }, + } as ToolResultContentBlock; + } + if (resultItem.type === "json" && resultItem.json) { + return { json: resultItem.json } as ToolResultContentBlock; + } + return null; + }) + .filter((content): content is ToolResultContentBlock => content !== null); + + if (toolResultContent.length === 0) { + return null; + } + + return { + toolResult: { + toolUseId: item.tool_result.tool_use_id, + content: toolResultContent, + status: item.tool_result.status, + }, + }; + } + + return null; +} + +function convertContentToAWS(content: string | ContentItem[]): ContentBlock[] { + if (typeof content === "string") { + return [{ text: content }]; + } + + const blocks = content + .map(convertContentToAWSBlock) + .filter((block): block is ContentBlock => block !== null); + + return blocks.length > 0 ? blocks : [{ text: "" }]; +} + +function formatMessages(messages: ConverseRequest["messages"]): { + messages: Message[]; + systemPrompt?: SystemContentBlock[]; +} { + const systemMessages = messages.filter((msg) => msg.role === "system"); + const nonSystemMessages = messages.filter((msg) => msg.role !== "system"); + + const systemPrompt = + systemMessages.length > 0 + ? systemMessages.map((msg) => { + if (typeof msg.content === "string") { + return { text: msg.content } as SystemContentBlock; + } + const blocks = convertContentToAWS(msg.content); + return blocks[0] as SystemContentBlock; + }) + : undefined; + + const formattedMessages = nonSystemMessages.reduce( + (acc: Message[], curr, idx) => { + if (idx > 0 && curr.role === nonSystemMessages[idx - 1].role) { + return acc; + } + + const content = convertContentToAWS(curr.content); + if (content.length > 0) { + acc.push({ + role: curr.role as "user" | "assistant", + content, + }); + } + return acc; + }, + [], + ); + + if (formattedMessages.length === 0 || formattedMessages[0].role !== "user") { + formattedMessages.unshift({ + role: "user", + content: [{ text: "Hello" }], + }); + } + + if (formattedMessages[formattedMessages.length - 1].role !== "user") { + formattedMessages.push({ + role: "user", + content: [{ text: "Continue" }], + }); + } + + return { messages: formattedMessages, systemPrompt }; +} + +function formatRequestBody( + request: ConverseRequest, +): ConverseStreamCommandInput { + const { messages, systemPrompt } = formatMessages(request.messages); + const input: ConverseStreamCommandInput = { + modelId: request.modelId, + messages, + ...(systemPrompt && { system: systemPrompt }), + }; + + if (request.inferenceConfig) { + input.inferenceConfig = { + maxTokens: request.inferenceConfig.maxTokens, + temperature: request.inferenceConfig.temperature, + topP: request.inferenceConfig.topP, + stopSequences: request.inferenceConfig.stopSequences, + }; + } + + if (request.toolConfig) { + input.toolConfig = { + tools: request.toolConfig.tools, + toolChoice: request.toolConfig.toolChoice, + }; + } + + const logInput = { + ...input, + messages: messages.map((msg) => ({ + role: msg.role, + content: msg.content?.map((content) => { + if ("image" in content && content.image) { + return { + image: { + format: content.image.format, + source: { bytes: "[BINARY]" }, + }, + }; + } + if ("document" in content && content.document) { + return { + document: { ...content.document, source: { bytes: "[BINARY]" } }, + }; + } + return content; + }), + })), + }; + + console.log( + "[Bedrock] Formatted request:", + JSON.stringify(logInput, null, 2), + ); + return input; +} + +// Main Request Handler export async function handle( req: NextRequest, { params }: { params: { path: string[] } }, @@ -102,14 +434,13 @@ async function handleConverseRequest(req: NextRequest) { console.log("[Bedrock] Invoking model:", modelId); - const command = createConverseStreamCommand(body); + const command = new ConverseStreamCommand(formatRequestBody(body)); const response = await client.send(command); if (!response.stream) { throw new Error("No stream in response"); } - // Create a ReadableStream for the response const stream = new ReadableStream({ async start(controller) { try { @@ -190,58 +521,17 @@ async function handleConverseRequest(req: NextRequest) { } controller.close(); } catch (error) { - if (error instanceof ValidationException) { - controller.enqueue( - `data: ${JSON.stringify({ - type: "error", - error: "ValidationException", - message: error.message, - })}\n\n`, - ); - } else if (error instanceof ModelStreamErrorException) { - controller.enqueue( - `data: ${JSON.stringify({ - type: "error", - error: "ModelStreamErrorException", - message: error.message, - originalStatusCode: error.originalStatusCode, - originalMessage: error.originalMessage, - })}\n\n`, - ); - } else if (error instanceof ThrottlingException) { - controller.enqueue( - `data: ${JSON.stringify({ - type: "error", - error: "ThrottlingException", - message: error.message, - })}\n\n`, - ); - } else if (error instanceof ServiceUnavailableException) { - controller.enqueue( - `data: ${JSON.stringify({ - type: "error", - error: "ServiceUnavailableException", - message: error.message, - })}\n\n`, - ); - } else if (error instanceof InternalServerException) { - controller.enqueue( - `data: ${JSON.stringify({ - type: "error", - error: "InternalServerException", - message: error.message, - })}\n\n`, - ); - } else { - controller.enqueue( - `data: ${JSON.stringify({ - type: "error", - error: "UnknownError", - message: - error instanceof Error ? error.message : "Unknown error", - })}\n\n`, - ); - } + const errorResponse = { + type: "error", + error: + error instanceof Error ? error.constructor.name : "UnknownError", + message: error instanceof Error ? error.message : "Unknown error", + ...(error instanceof ModelStreamErrorException && { + originalStatusCode: error.originalStatusCode, + originalMessage: error.originalMessage, + }), + }; + controller.enqueue(`data: ${JSON.stringify(errorResponse)}\n\n`); controller.close(); } }, diff --git a/app/api/bedrock/models.ts b/app/api/bedrock/models.ts deleted file mode 100644 index f6bb297d268..00000000000 --- a/app/api/bedrock/models.ts +++ /dev/null @@ -1,405 +0,0 @@ -import { - ConverseStreamCommand, - type ConverseStreamCommandInput, - type Message, - type ContentBlock, - type SystemContentBlock, - type Tool, - type ToolChoice, - type ToolResultContentBlock, -} from "@aws-sdk/client-bedrock-runtime"; - -export interface ConverseRequest { - modelId: string; - messages: { - role: "user" | "assistant" | "system"; - content: string | ContentItem[]; - }[]; - inferenceConfig?: { - maxTokens?: number; - temperature?: number; - topP?: number; - stopSequences?: string[]; - }; - toolConfig?: { - tools: Tool[]; - toolChoice?: ToolChoice; - }; -} - -interface ContentItem { - type: "text" | "image_url" | "document" | "tool_use" | "tool_result"; - text?: string; - image_url?: { - url: string; // base64 data URL - }; - document?: { - format: - | "pdf" - | "csv" - | "doc" - | "docx" - | "xls" - | "xlsx" - | "html" - | "txt" - | "md"; - name: string; - source: { - bytes: string; // base64 - }; - }; - tool_use?: { - tool_use_id: string; - name: string; - input: any; - }; - tool_result?: { - tool_use_id: string; - content: ToolResultItem[]; - status: "success" | "error"; - }; -} - -interface ToolResultItem { - type: "text" | "image" | "document" | "json"; - text?: string; - image?: { - format: "png" | "jpeg" | "gif" | "webp"; - source: { - bytes: string; // base64 - }; - }; - document?: { - format: - | "pdf" - | "csv" - | "doc" - | "docx" - | "xls" - | "xlsx" - | "html" - | "txt" - | "md"; - name: string; - source: { - bytes: string; // base64 - }; - }; - json?: any; -} - -function convertContentToAWSBlock(item: ContentItem): ContentBlock | null { - if (item.type === "text" && item.text) { - return { text: item.text }; - } - - if (item.type === "image_url" && item.image_url?.url) { - const base64Match = item.image_url.url.match( - /^data:image\/([a-zA-Z]*);base64,([^"]*)/, - ); - if (base64Match) { - const format = base64Match[1].toLowerCase(); - if ( - format === "png" || - format === "jpeg" || - format === "gif" || - format === "webp" - ) { - const base64Data = base64Match[2]; - return { - image: { - format: format as "png" | "jpeg" | "gif" | "webp", - source: { - bytes: Uint8Array.from(Buffer.from(base64Data, "base64")), - }, - }, - }; - } - } - } - - if (item.type === "document" && item.document) { - return { - document: { - format: item.document.format, - name: item.document.name, - source: { - bytes: Uint8Array.from( - Buffer.from(item.document.source.bytes, "base64"), - ), - }, - }, - }; - } - - if (item.type === "tool_use" && item.tool_use) { - return { - toolUse: { - toolUseId: item.tool_use.tool_use_id, - name: item.tool_use.name, - input: item.tool_use.input, - }, - }; - } - - if (item.type === "tool_result" && item.tool_result) { - const toolResultContent = item.tool_result.content - .map((resultItem) => { - if (resultItem.type === "text" && resultItem.text) { - return { text: resultItem.text } as ToolResultContentBlock; - } - if (resultItem.type === "image" && resultItem.image) { - return { - image: { - format: resultItem.image.format, - source: { - bytes: Uint8Array.from( - Buffer.from(resultItem.image.source.bytes, "base64"), - ), - }, - }, - } as ToolResultContentBlock; - } - if (resultItem.type === "document" && resultItem.document) { - return { - document: { - format: resultItem.document.format, - name: resultItem.document.name, - source: { - bytes: Uint8Array.from( - Buffer.from(resultItem.document.source.bytes, "base64"), - ), - }, - }, - } as ToolResultContentBlock; - } - if (resultItem.type === "json" && resultItem.json) { - return { json: resultItem.json } as ToolResultContentBlock; - } - return null; - }) - .filter((content): content is ToolResultContentBlock => content !== null); - - if (toolResultContent.length === 0) { - return null; - } - - return { - toolResult: { - toolUseId: item.tool_result.tool_use_id, - content: toolResultContent, - status: item.tool_result.status, - }, - }; - } - - return null; -} - -function convertContentToAWS(content: string | ContentItem[]): ContentBlock[] { - if (typeof content === "string") { - return [{ text: content }]; - } - - // Filter out null blocks and ensure each content block is valid - const blocks = content - .map(convertContentToAWSBlock) - .filter((block): block is ContentBlock => block !== null); - - // If no valid blocks, provide a default text block - if (blocks.length === 0) { - return [{ text: "" }]; - } - - return blocks; -} - -function formatMessages(messages: ConverseRequest["messages"]): { - messages: Message[]; - systemPrompt?: SystemContentBlock[]; -} { - // Extract system messages - const systemMessages = messages.filter((msg) => msg.role === "system"); - const nonSystemMessages = messages.filter((msg) => msg.role !== "system"); - - // Convert system messages to SystemContentBlock array - const systemPrompt = - systemMessages.length > 0 - ? systemMessages.map((msg) => { - if (typeof msg.content === "string") { - return { text: msg.content } as SystemContentBlock; - } - // For multimodal content, convert each content item - const blocks = convertContentToAWS(msg.content); - return blocks[0] as SystemContentBlock; // Take first block as system content - }) - : undefined; - - // Format remaining messages - const formattedMessages = nonSystemMessages.reduce( - (acc: Message[], curr, idx) => { - // Skip if same role as previous message - if (idx > 0 && curr.role === nonSystemMessages[idx - 1].role) { - return acc; - } - - const content = convertContentToAWS(curr.content); - if (content.length > 0) { - acc.push({ - role: curr.role as "user" | "assistant", - content, - }); - } - return acc; - }, - [], - ); - - // Ensure conversation starts with user - if (formattedMessages.length === 0 || formattedMessages[0].role !== "user") { - formattedMessages.unshift({ - role: "user", - content: [{ text: "Hello" }], - }); - } - - // Ensure conversation ends with user - if (formattedMessages[formattedMessages.length - 1].role !== "user") { - formattedMessages.push({ - role: "user", - content: [{ text: "Continue" }], - }); - } - - return { messages: formattedMessages, systemPrompt }; -} - -export function formatRequestBody( - request: ConverseRequest, -): ConverseStreamCommandInput { - const { messages, systemPrompt } = formatMessages(request.messages); - const input: ConverseStreamCommandInput = { - modelId: request.modelId, - messages, - ...(systemPrompt && { system: systemPrompt }), - }; - - if (request.inferenceConfig) { - input.inferenceConfig = { - maxTokens: request.inferenceConfig.maxTokens, - temperature: request.inferenceConfig.temperature, - topP: request.inferenceConfig.topP, - stopSequences: request.inferenceConfig.stopSequences, - }; - } - - if (request.toolConfig) { - input.toolConfig = { - tools: request.toolConfig.tools, - toolChoice: request.toolConfig.toolChoice, - }; - } - - // Create a clean version of the input for logging - const logInput = { - ...input, - messages: messages.map((msg) => ({ - role: msg.role, - content: msg.content?.map((content) => { - if ("image" in content && content.image) { - return { - image: { - format: content.image.format, - source: { bytes: "[BINARY]" }, - }, - }; - } - if ("document" in content && content.document) { - return { - document: { ...content.document, source: { bytes: "[BINARY]" } }, - }; - } - return content; - }), - })), - }; - - console.log( - "[Bedrock] Formatted request:", - JSON.stringify(logInput, null, 2), - ); - return input; -} - -export function createConverseStreamCommand(request: ConverseRequest) { - const input = formatRequestBody(request); - return new ConverseStreamCommand(input); -} - -export interface StreamResponse { - type: - | "messageStart" - | "contentBlockStart" - | "contentBlockDelta" - | "contentBlockStop" - | "messageStop" - | "metadata" - | "error"; - role?: string; - index?: number; - start?: any; - delta?: any; - stopReason?: string; - additionalModelResponseFields?: any; - usage?: any; - metrics?: any; - trace?: any; - error?: string; - message?: string; - originalStatusCode?: number; - originalMessage?: string; -} - -export function parseStreamResponse(chunk: any): StreamResponse | null { - if (chunk.messageStart) { - return { type: "messageStart", role: chunk.messageStart.role }; - } - if (chunk.contentBlockStart) { - return { - type: "contentBlockStart", - index: chunk.contentBlockStart.contentBlockIndex, - start: chunk.contentBlockStart.start, - }; - } - if (chunk.contentBlockDelta) { - return { - type: "contentBlockDelta", - index: chunk.contentBlockDelta.contentBlockIndex, - delta: chunk.contentBlockDelta.delta, - }; - } - if (chunk.contentBlockStop) { - return { - type: "contentBlockStop", - index: chunk.contentBlockStop.contentBlockIndex, - }; - } - if (chunk.messageStop) { - return { - type: "messageStop", - stopReason: chunk.messageStop.stopReason, - additionalModelResponseFields: - chunk.messageStop.additionalModelResponseFields, - }; - } - if (chunk.metadata) { - return { - type: "metadata", - usage: chunk.metadata.usage, - metrics: chunk.metadata.metrics, - trace: chunk.metadata.trace, - }; - } - return null; -} diff --git a/app/api/bedrock/utils.ts b/app/api/bedrock/utils.ts deleted file mode 100644 index c58808a01cd..00000000000 --- a/app/api/bedrock/utils.ts +++ /dev/null @@ -1,400 +0,0 @@ -import { MultimodalContent } from "../../client/api"; - -export interface Message { - role: string; - content: string | MultimodalContent[]; -} - -export interface ImageSource { - bytes: string; // base64 encoded image bytes -} - -export interface DocumentSource { - bytes: string; // base64 encoded document bytes - media_type?: string; // MIME type of the document -} - -export type DocumentFormat = - | "pdf" - | "csv" - | "doc" - | "docx" - | "xls" - | "xlsx" - | "html" - | "txt" - | "md"; -export type ImageFormat = "png" | "jpeg" | "gif" | "webp"; - -export interface BedrockImageBlock { - type: "image"; - image: { - format: ImageFormat; - source: { - bytes: string; - }; - }; -} - -export interface BedrockDocumentBlock { - type: "document"; - document: { - format: string; - name: string; - source: { - bytes: string; - media_type?: string; - }; - }; -} - -export interface BedrockTextBlock { - type: "text"; - text: string; -} - -export interface BedrockToolCallBlock { - type: "tool_calls"; - tool_calls: BedrockToolCall[]; -} - -export interface BedrockToolResultBlock { - type: "tool_result"; - tool_result: BedrockToolResult; -} - -export type BedrockContent = - | BedrockTextBlock - | BedrockImageBlock - | BedrockDocumentBlock - | BedrockToolCallBlock - | BedrockToolResultBlock; - -export interface BedrockToolSpec { - type: string; - function: { - name: string; - description: string; - parameters: Record; - }; -} - -export interface BedrockToolCall { - type: string; - function: { - name: string; - arguments: string; - }; -} - -export interface BedrockToolResult { - type: string; - output: string; -} - -export interface ContentItem { - type: string; - text?: string; - image_url?: { - url: string; - }; - document?: { - format: string; - name: string; - source: { - bytes: string; - media_type?: string; - }; - }; - tool_calls?: BedrockToolCall[]; - tool_result?: BedrockToolResult; -} - -export interface StreamEvent { - messageStart?: { role: string }; - contentBlockStart?: { index: number }; - contentBlockDelta?: { - delta: { - type?: string; - text?: string; - tool_calls?: BedrockToolCall[]; - tool_result?: BedrockToolResult; - }; - contentBlockIndex: number; - }; - contentBlockStop?: { index: number }; - messageStop?: { stopReason: string }; - metadata?: { - usage: { - inputTokens: number; - outputTokens: number; - totalTokens: number; - }; - metrics: { - latencyMs: number; - }; - }; -} - -export interface ConverseRequest { - modelId: string; - messages: Message[]; - inferenceConfig?: { - maxTokens?: number; - temperature?: number; - topP?: number; - stopSequences?: string[]; - stream?: boolean; - }; - system?: { text: string }[]; - tools?: BedrockToolSpec[]; - additionalModelRequestFields?: Record; - additionalModelResponseFieldPaths?: string[]; -} - -export interface BedrockResponse { - content: BedrockContent[]; - completion?: string; - stop_reason?: string; - usage?: { - input_tokens: number; - output_tokens: number; - total_tokens: number; - }; - tool_calls?: BedrockToolCall[]; -} - -// Helper function to get the base model type from modelId -export function getModelType(modelId: string): string { - if (modelId.includes("inference-profile")) { - const match = modelId.match(/us\.(meta\.llama.+?)$/); - if (match) return match[1]; - } - return modelId; -} - -// Helper function to validate model ID -export function validateModelId(modelId: string): string | null { - // Check if model requires inference profile - if ( - modelId.startsWith("meta.llama") && - !modelId.includes("inference-profile") - ) { - return "Llama models require an inference profile. Please use the full inference profile ARN."; - } - return null; -} - -// Helper function to validate document name -export function validateDocumentName(name: string): boolean { - const validPattern = /^[a-zA-Z0-9\s\-\(\)\[\]]+$/; - const noMultipleSpaces = !/\s{2,}/.test(name); - return validPattern.test(name) && noMultipleSpaces; -} - -// Helper function to validate document format -export function validateDocumentFormat( - format: string, -): format is DocumentFormat { - const validFormats: DocumentFormat[] = [ - "pdf", - "csv", - "doc", - "docx", - "xls", - "xlsx", - "html", - "txt", - "md", - ]; - return validFormats.includes(format as DocumentFormat); -} - -// Helper function to validate image size and dimensions -export function validateImageSize(base64Data: string): boolean { - // Check size (3.75 MB limit) - const sizeInBytes = (base64Data.length * 3) / 4; // Approximate size of decoded base64 - const maxSize = 3.75 * 1024 * 1024; // 3.75 MB in bytes - - if (sizeInBytes > maxSize) { - throw new Error("Image size exceeds 3.75 MB limit"); - } - - return true; -} - -// Helper function to validate document size -export function validateDocumentSize(base64Data: string): boolean { - // Check size (4.5 MB limit) - const sizeInBytes = (base64Data.length * 3) / 4; // Approximate size of decoded base64 - const maxSize = 4.5 * 1024 * 1024; // 4.5 MB in bytes - - if (sizeInBytes > maxSize) { - throw new Error("Document size exceeds 4.5 MB limit"); - } - - return true; -} - -// Helper function to process document content for Bedrock -export function processDocumentContent(content: any): BedrockDocumentBlock { - if ( - !content?.document?.format || - !content?.document?.name || - !content?.document?.source?.bytes - ) { - throw new Error("Invalid document content format"); - } - - const format = content.document.format.toLowerCase(); - if (!validateDocumentFormat(format)) { - throw new Error(`Unsupported document format: ${format}`); - } - - if (!validateDocumentName(content.document.name)) { - throw new Error( - `Invalid document name: ${content.document.name}. Only alphanumeric characters, single spaces, hyphens, parentheses, and square brackets are allowed.`, - ); - } - - // Validate document size - if (!validateDocumentSize(content.document.source.bytes)) { - throw new Error("Document size validation failed"); - } - - return { - type: "document", - document: { - format: format, - name: content.document.name, - source: { - bytes: content.document.source.bytes, - media_type: content.document.source.media_type, - }, - }, - }; -} - -// Helper function to process image content for Bedrock -export function processImageContent(content: any): BedrockImageBlock { - if (content.type === "image_url" && content.image_url?.url) { - const base64Match = content.image_url.url.match( - /^data:image\/([a-zA-Z]*);base64,([^"]*)$/, - ); - if (base64Match) { - const format = base64Match[1].toLowerCase(); - if (["png", "jpeg", "gif", "webp"].includes(format)) { - // Validate image size - if (!validateImageSize(base64Match[2])) { - throw new Error("Image size validation failed"); - } - - return { - type: "image", - image: { - format: format as ImageFormat, - source: { - bytes: base64Match[2], - }, - }, - }; - } - } - } - throw new Error("Invalid image content format"); -} - -// Helper function to validate message content restrictions -export function validateMessageContent(message: Message): void { - if (Array.isArray(message.content)) { - // Count images and documents in user messages - if (message.role === "user") { - const imageCount = message.content.filter( - (item) => item.type === "image_url", - ).length; - const documentCount = message.content.filter( - (item) => item.type === "document", - ).length; - - if (imageCount > 20) { - throw new Error("User messages can include up to 20 images"); - } - - if (documentCount > 5) { - throw new Error("User messages can include up to 5 documents"); - } - } else if ( - message.role === "assistant" && - (message.content.some((item) => item.type === "image_url") || - message.content.some((item) => item.type === "document")) - ) { - throw new Error("Assistant messages cannot include images or documents"); - } - } -} - -// Helper function to ensure messages alternate between user and assistant -export function validateMessageOrder(messages: Message[]): Message[] { - const validatedMessages: Message[] = []; - let lastRole = ""; - - for (const message of messages) { - // Validate content restrictions for each message - validateMessageContent(message); - - if (message.role === lastRole) { - // Skip duplicate roles to maintain alternation - continue; - } - validatedMessages.push(message); - lastRole = message.role; - } - - return validatedMessages; -} - -// Helper function to convert Bedrock response back to MultimodalContent format -export function convertBedrockResponseToMultimodal( - response: BedrockResponse, -): string | MultimodalContent[] { - if (response.completion) { - return response.completion; - } - - if (!response.content) { - return ""; - } - - return response.content.map((block) => { - if (block.type === "text") { - return { - type: "text", - text: block.text, - }; - } else if (block.type === "image") { - return { - type: "image_url", - image_url: { - url: `data:image/${block.image.format};base64,${block.image.source.bytes}`, - }, - }; - } else if (block.type === "document") { - return { - type: "document", - document: { - format: block.document.format, - name: block.document.name, - source: { - bytes: block.document.source.bytes, - media_type: block.document.source.media_type, - }, - }, - }; - } - // Fallback to text content - return { - type: "text", - text: "", - }; - }); -} diff --git a/app/client/platforms/bedrock.ts b/app/client/platforms/bedrock.ts index e2197565064..043fa7aa240 100644 --- a/app/client/platforms/bedrock.ts +++ b/app/client/platforms/bedrock.ts @@ -239,7 +239,7 @@ export class BedrockApi implements LLMApi { // Add error message as text content content.push({ type: "text", - text: `Error processing image: ${e.message}`, + text: `Error processing image: ${e}`, }); } } diff --git a/app/components/chat-actions.tsx b/app/components/chat-actions.tsx deleted file mode 100644 index 25cdfe16d8b..00000000000 --- a/app/components/chat-actions.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import { ChatActions as Actions } from "./chat"; -import DocumentIcon from "../icons/document.svg"; -import LoadingButtonIcon from "../icons/loading.svg"; -import { ServiceProvider } from "../constant"; -import { useChatStore } from "../store"; -import { showToast } from "./ui-lib"; -import { MultimodalContent, MessageRole } from "../client/api"; -import { ChatMessage } from "../store/chat"; - -export function ChatActions(props: Parameters[0]) { - const chatStore = useChatStore(); - const currentProviderName = - chatStore.currentSession().mask.modelConfig?.providerName; - const isBedrockProvider = currentProviderName === ServiceProvider.Bedrock; - - async function uploadDocument() { - const fileInput = document.createElement("input"); - fileInput.type = "file"; - fileInput.accept = ".pdf,.csv,.doc,.docx,.xls,.xlsx,.html,.txt,.md"; - fileInput.onchange = async (event: any) => { - const file = event.target.files[0]; - if (!file) return; - - props.setUploading(true); - try { - // Get file extension and MIME type - const format = file.name.split(".").pop()?.toLowerCase() || ""; - const supportedFormats = [ - "pdf", - "csv", - "doc", - "docx", - "xls", - "xlsx", - "html", - "txt", - "md", - ]; - - if (!supportedFormats.includes(format)) { - throw new Error("Unsupported file format"); - } - - // Map file extensions to MIME types - const mimeTypes: { [key: string]: string } = { - pdf: "application/pdf", - csv: "text/csv", - doc: "application/msword", - docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - xls: "application/vnd.ms-excel", - xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - html: "text/html", - txt: "text/plain", - md: "text/markdown", - }; - - // Convert file to base64 - const base64 = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = (e) => { - if (!e.target?.result) return reject("Failed to read file"); - // Get just the base64 data without the data URL prefix - const base64 = (e.target.result as string).split(",")[1]; - resolve(base64); - }; - reader.onerror = reject; - reader.readAsDataURL(file); - }); - - // Format file size - const size = file.size; - let sizeStr = ""; - if (size < 1024) { - sizeStr = size + " B"; - } else if (size < 1024 * 1024) { - sizeStr = (size / 1024).toFixed(2) + " KB"; - } else { - sizeStr = (size / (1024 * 1024)).toFixed(2) + " MB"; - } - - // Create document content - const content: MultimodalContent[] = [ - { - type: "text", - text: `Document: ${file.name} (${sizeStr})`, - }, - { - type: "document", - document: { - format, - name: file.name, - source: { - bytes: base64, - media_type: mimeTypes[format] || `application/${format}`, - }, - }, - }, - ]; - - // Send content to Bedrock - const session = chatStore.currentSession(); - const modelConfig = session.mask.modelConfig; - const api = await import("../client/api").then((m) => - m.getClientApi(modelConfig.providerName), - ); - - // Create user message - const userMessage: ChatMessage = { - id: Date.now().toString(), - role: "user" as MessageRole, - content, - date: new Date().toLocaleString(), - isError: false, - }; - - // Create bot message - const botMessage: ChatMessage = { - id: (Date.now() + 1).toString(), - role: "assistant" as MessageRole, - content: "", - date: new Date().toLocaleString(), - streaming: true, - isError: false, - }; - - // Add messages to session - chatStore.updateCurrentSession((session) => { - session.messages.push(userMessage, botMessage); - }); - - // Make request - api.llm.chat({ - messages: [userMessage], - config: { ...modelConfig, stream: true }, - onUpdate(message) { - botMessage.streaming = true; - if (message) { - botMessage.content = message; - } - chatStore.updateCurrentSession((session) => { - session.messages = session.messages.concat(); - }); - }, - onFinish(message) { - botMessage.streaming = false; - if (message) { - botMessage.content = message; - chatStore.onNewMessage(botMessage); - } - }, - onError(error) { - botMessage.content = error.message; - botMessage.streaming = false; - userMessage.isError = true; - botMessage.isError = true; - chatStore.updateCurrentSession((session) => { - session.messages = session.messages.concat(); - }); - console.error("[Chat] failed ", error); - }, - }); - } catch (error) { - console.error("Failed to upload document:", error); - showToast("Failed to upload document"); - } finally { - props.setUploading(false); - } - }; - fileInput.click(); - } - - return ( -
- {/* Original actions */} - - - {/* Document upload button (only for Bedrock) */} - {isBedrockProvider && ( -
-
- {props.uploading ? : } -
-
Upload Document
-
- )} -
- ); -} diff --git a/app/components/chat.tsx b/app/components/chat.tsx index c4d11e31876..3d5b6a4f2c4 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -8,7 +8,7 @@ import React, { Fragment, RefObject, } from "react"; -import DocumentIcon from "../icons/document.svg"; + import SendWhiteIcon from "../icons/send-white.svg"; import BrainIcon from "../icons/brain.svg"; import RenameIcon from "../icons/rename.svg"; @@ -548,91 +548,6 @@ export function ChatActions(props: { ); } }, [chatStore, currentModel, models]); - const isBedrockProvider = currentProviderName === ServiceProvider.Bedrock; - - // ... (rest of the existing state and functions) - - async function uploadDocument() { - const fileInput = document.createElement("input"); - fileInput.type = "file"; - fileInput.accept = ".pdf,.csv,.doc,.docx,.xls,.xlsx,.html,.txt,.md"; - fileInput.onchange = async (event: any) => { - const file = event.target.files[0]; - if (!file) return; - - props.setUploading(true); - try { - // Convert file to base64 - const base64 = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = (e) => { - if (!e.target?.result) return reject("Failed to read file"); - const base64 = (e.target.result as string).split(",")[1]; - resolve(base64); - }; - reader.onerror = reject; - reader.readAsDataURL(file); - }); - - // Get file extension - const format = file.name.split(".").pop()?.toLowerCase() || ""; - const supportedFormats = [ - "pdf", - "csv", - "doc", - "docx", - "xls", - "xlsx", - "html", - "txt", - "md", - ]; - - if (!supportedFormats.includes(format)) { - throw new Error("Unsupported file format"); - } - - // Format file size - const size = file.size; - let sizeStr = ""; - if (size < 1024) { - sizeStr = size + " B"; - } else if (size < 1024 * 1024) { - sizeStr = (size / 1024).toFixed(2) + " KB"; - } else { - sizeStr = (size / (1024 * 1024)).toFixed(2) + " MB"; - } - - // Create document content with only filename and size - const documentContent = { - type: "document", - document: { - format, - name: file.name, - size: sizeStr, - source: { - bytes: base64, - }, - }, - }; - - // Submit the document content as a JSON string but only display filename and size - const displayContent = `Document: ${file.name} (${sizeStr})`; - chatStore.onUserInput(displayContent); - - // Store the actual document content separately if needed - // chatStore.updateCurrentSession((session) => { - // session.lastDocument = documentContent; - // }); - } catch (error) { - console.error("Failed to upload document:", error); - showToast("Failed to upload document"); - } finally { - props.setUploading(false); - } - }; - fileInput.click(); - } return (
@@ -665,14 +580,6 @@ export function ChatActions(props: { icon={props.uploading ? : } /> )} - {/* Add document upload button for Bedrock */} - {isBedrockProvider && ( - : } - /> - )} - - - - - -