mirror of
https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
synced 2025-11-14 05:03:43 +08:00
修改: app/api/[provider]/[...path]/route.ts
修改: app/api/auth.ts 新文件: app/api/bedrock.ts 新文件: app/api/bedrock/models.ts 新文件: app/api/bedrock/utils.ts 修改: app/client/api.ts 新文件: app/client/platforms/bedrock.ts 修改: app/components/settings.tsx 修改: app/config/server.ts 修改: app/constant.ts 修改: app/locales/cn.ts 修改: app/locales/en.ts 修改: app/store/access.ts 修改: app/utils.ts 修改: package.json
This commit is contained in:
280
app/api/bedrock/models.ts
Normal file
280
app/api/bedrock/models.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import {
|
||||
Message,
|
||||
validateMessageOrder,
|
||||
processDocumentContent,
|
||||
BedrockTextBlock,
|
||||
BedrockImageBlock,
|
||||
BedrockDocumentBlock,
|
||||
} from "./utils";
|
||||
|
||||
export interface ConverseRequest {
|
||||
modelId: string;
|
||||
messages: Message[];
|
||||
inferenceConfig?: {
|
||||
maxTokens?: number;
|
||||
temperature?: number;
|
||||
topP?: number;
|
||||
};
|
||||
system?: string;
|
||||
tools?: Array<{
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: {
|
||||
type: string;
|
||||
properties: Record<string, any>;
|
||||
required: string[];
|
||||
};
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ContentItem {
|
||||
type: string;
|
||||
text?: string;
|
||||
image_url?: {
|
||||
url: string;
|
||||
};
|
||||
document?: {
|
||||
format: string;
|
||||
name: string;
|
||||
source: {
|
||||
bytes: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
type ProcessedContent =
|
||||
| ContentItem
|
||||
| BedrockTextBlock
|
||||
| BedrockImageBlock
|
||||
| BedrockDocumentBlock
|
||||
| {
|
||||
type: string;
|
||||
source: { type: string; media_type: string; data: string };
|
||||
};
|
||||
|
||||
// Helper function to format request body based on model type
|
||||
export function formatRequestBody(request: ConverseRequest) {
|
||||
const baseModel = request.modelId;
|
||||
const messages = validateMessageOrder(request.messages).map((msg) => ({
|
||||
role: msg.role,
|
||||
content: Array.isArray(msg.content)
|
||||
? msg.content.map((item: ContentItem) => {
|
||||
if (item.type === "image_url" && item.image_url?.url) {
|
||||
// If it's a base64 image URL
|
||||
const base64Match = item.image_url.url.match(
|
||||
/^data:image\/([a-zA-Z]*);base64,([^"]*)$/,
|
||||
);
|
||||
if (base64Match) {
|
||||
return {
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: `image/${base64Match[1]}`,
|
||||
data: base64Match[2],
|
||||
},
|
||||
};
|
||||
}
|
||||
// If it's not a base64 URL, return as is
|
||||
return item;
|
||||
}
|
||||
if ("document" in item) {
|
||||
try {
|
||||
return processDocumentContent(item);
|
||||
} catch (error) {
|
||||
console.error("Error processing document:", error);
|
||||
return {
|
||||
type: "text",
|
||||
text: `[Document: ${item.document?.name || "Unknown"}]`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { type: "text", text: item.text };
|
||||
})
|
||||
: [{ type: "text", text: msg.content }],
|
||||
}));
|
||||
|
||||
const systemPrompt = request.system
|
||||
? [{ type: "text", text: request.system }]
|
||||
: undefined;
|
||||
|
||||
const baseConfig = {
|
||||
max_tokens: request.inferenceConfig?.maxTokens || 2048,
|
||||
temperature: request.inferenceConfig?.temperature || 0.7,
|
||||
top_p: request.inferenceConfig?.topP || 0.9,
|
||||
};
|
||||
|
||||
if (baseModel.startsWith("anthropic.claude")) {
|
||||
return {
|
||||
messages,
|
||||
system: systemPrompt,
|
||||
anthropic_version: "bedrock-2023-05-31",
|
||||
...baseConfig,
|
||||
...(request.tools && { tools: request.tools }),
|
||||
};
|
||||
} else if (
|
||||
baseModel.startsWith("meta.llama") ||
|
||||
baseModel.startsWith("mistral.")
|
||||
) {
|
||||
return {
|
||||
messages: messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: Array.isArray(m.content)
|
||||
? m.content.map((c: ProcessedContent) => {
|
||||
if ("text" in c) return { type: "text", text: c.text || "" };
|
||||
if ("image_url" in c)
|
||||
return {
|
||||
type: "text",
|
||||
text: `[Image: ${c.image_url?.url || "URL not provided"}]`,
|
||||
};
|
||||
if ("document" in c)
|
||||
return {
|
||||
type: "text",
|
||||
text: `[Document: ${c.document?.name || "Unknown"}]`,
|
||||
};
|
||||
return { type: "text", text: "" };
|
||||
})
|
||||
: [{ type: "text", text: m.content }],
|
||||
})),
|
||||
...baseConfig,
|
||||
stop_sequences: ["\n\nHuman:", "\n\nAssistant:"],
|
||||
};
|
||||
} else if (baseModel.startsWith("amazon.titan")) {
|
||||
const formattedText = messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${m.role === "user" ? "Human" : "Assistant"}: ${
|
||||
Array.isArray(m.content)
|
||||
? m.content
|
||||
.map((c: ProcessedContent) => {
|
||||
if ("text" in c) return c.text || "";
|
||||
if ("image_url" in c)
|
||||
return `[Image: ${
|
||||
c.image_url?.url || "URL not provided"
|
||||
}]`;
|
||||
if ("document" in c)
|
||||
return `[Document: ${c.document?.name || "Unknown"}]`;
|
||||
return "";
|
||||
})
|
||||
.join("")
|
||||
: m.content
|
||||
}`,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
return {
|
||||
messages: formattedText,
|
||||
textGenerationConfig: {
|
||||
maxTokenCount: baseConfig.max_tokens,
|
||||
temperature: baseConfig.temperature,
|
||||
topP: baseConfig.top_p,
|
||||
stopSequences: ["Human:", "Assistant:"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported model: ${baseModel}`);
|
||||
}
|
||||
|
||||
// Helper function to parse and format response based on model type
|
||||
export function parseModelResponse(responseBody: string, modelId: string): any {
|
||||
const baseModel = modelId;
|
||||
|
||||
try {
|
||||
const response = JSON.parse(responseBody);
|
||||
|
||||
// Common response format for all models
|
||||
const formatResponse = (content: string | any[]) => ({
|
||||
role: "assistant",
|
||||
content: Array.isArray(content)
|
||||
? content.map((item) => {
|
||||
if (typeof item === "string") {
|
||||
return { type: "text", text: item };
|
||||
}
|
||||
// Handle different content types
|
||||
if ("text" in item) {
|
||||
return { type: "text", text: item.text || "" };
|
||||
}
|
||||
if ("image" in item) {
|
||||
return {
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:image/${
|
||||
item.source?.media_type || "image/png"
|
||||
};base64,${item.source?.data || ""}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
// Document responses are converted to text
|
||||
if ("document" in item) {
|
||||
return {
|
||||
type: "text",
|
||||
text: `[Document Content]\n${item.text || ""}`,
|
||||
};
|
||||
}
|
||||
return { type: "text", text: item.text || "" };
|
||||
})
|
||||
: [{ type: "text", text: content }],
|
||||
stop_reason: response.stop_reason || response.stopReason || "end_turn",
|
||||
usage: response.usage || {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
total_tokens: 0,
|
||||
},
|
||||
});
|
||||
|
||||
if (baseModel.startsWith("anthropic.claude")) {
|
||||
// Handle the new Converse API response format
|
||||
if (response.output?.message) {
|
||||
return {
|
||||
role: response.output.message.role,
|
||||
content: response.output.message.content.map((item: any) => {
|
||||
if ("text" in item) return { type: "text", text: item.text || "" };
|
||||
if ("image" in item) {
|
||||
return {
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:${item.source?.media_type || "image/png"};base64,${
|
||||
item.source?.data || ""
|
||||
}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { type: "text", text: item.text || "" };
|
||||
}),
|
||||
stop_reason: response.stopReason,
|
||||
usage: response.usage,
|
||||
};
|
||||
}
|
||||
// Fallback for older format
|
||||
return formatResponse(
|
||||
response.content ||
|
||||
(response.completion
|
||||
? [{ type: "text", text: response.completion }]
|
||||
: []),
|
||||
);
|
||||
} else if (baseModel.startsWith("meta.llama")) {
|
||||
return formatResponse(response.generation || response.completion || "");
|
||||
} else if (baseModel.startsWith("amazon.titan")) {
|
||||
return formatResponse(response.results?.[0]?.outputText || "");
|
||||
} else if (baseModel.startsWith("mistral.")) {
|
||||
return formatResponse(
|
||||
response.outputs?.[0]?.text || response.response || "",
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported model: ${baseModel}`);
|
||||
} catch (e) {
|
||||
console.error("[Bedrock] Failed to parse response:", e);
|
||||
// Return raw text as fallback
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: responseBody }],
|
||||
};
|
||||
}
|
||||
}
|
||||
218
app/api/bedrock/utils.ts
Normal file
218
app/api/bedrock/utils.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
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
|
||||
}
|
||||
|
||||
export interface BedrockImageBlock {
|
||||
image: {
|
||||
format: "png" | "jpeg" | "gif" | "webp";
|
||||
source: ImageSource;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BedrockDocumentBlock {
|
||||
document: {
|
||||
format:
|
||||
| "pdf"
|
||||
| "csv"
|
||||
| "doc"
|
||||
| "docx"
|
||||
| "xls"
|
||||
| "xlsx"
|
||||
| "html"
|
||||
| "txt"
|
||||
| "md";
|
||||
name: string;
|
||||
source: DocumentSource;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BedrockTextBlock {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export type BedrockContentBlock =
|
||||
| BedrockTextBlock
|
||||
| BedrockImageBlock
|
||||
| BedrockDocumentBlock;
|
||||
|
||||
export interface BedrockResponse {
|
||||
content?: any[];
|
||||
completion?: string;
|
||||
stop_reason?: string;
|
||||
usage?: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
tool_calls?: any[];
|
||||
}
|
||||
|
||||
// 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 process document content for Bedrock
|
||||
export function processDocumentContent(content: any): BedrockContentBlock {
|
||||
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 (
|
||||
!["pdf", "csv", "doc", "docx", "xls", "xlsx", "html", "txt", "md"].includes(
|
||||
format,
|
||||
)
|
||||
) {
|
||||
throw new Error(`Unsupported document format: ${format}`);
|
||||
}
|
||||
|
||||
return {
|
||||
document: {
|
||||
format: format as BedrockDocumentBlock["document"]["format"],
|
||||
name: sanitizeDocumentName(content.document.name),
|
||||
source: {
|
||||
bytes: content.document.source.bytes,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to format content for Bedrock
|
||||
export function formatContent(
|
||||
content: string | MultimodalContent[],
|
||||
): BedrockContentBlock[] {
|
||||
if (typeof content === "string") {
|
||||
return [{ text: content }];
|
||||
}
|
||||
|
||||
const formattedContent: BedrockContentBlock[] = [];
|
||||
|
||||
for (const item of content) {
|
||||
if (item.type === "text" && item.text) {
|
||||
formattedContent.push({ text: item.text });
|
||||
} else if (item.type === "image_url" && item.image_url?.url) {
|
||||
// Extract base64 data from data 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)) {
|
||||
formattedContent.push({
|
||||
image: {
|
||||
format: format as "png" | "jpeg" | "gif" | "webp",
|
||||
source: {
|
||||
bytes: base64Match[2],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if ("document" in item) {
|
||||
try {
|
||||
formattedContent.push(processDocumentContent(item));
|
||||
} catch (error) {
|
||||
console.error("Error processing document:", error);
|
||||
// Convert document to text as fallback
|
||||
formattedContent.push({
|
||||
text: `[Document: ${(item as any).document?.name || "Unknown"}]`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return formattedContent;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (message.role === lastRole) {
|
||||
// Skip duplicate roles to maintain alternation
|
||||
continue;
|
||||
}
|
||||
validatedMessages.push(message);
|
||||
lastRole = message.role;
|
||||
}
|
||||
|
||||
return validatedMessages;
|
||||
}
|
||||
|
||||
// Helper function to sanitize document names according to Bedrock requirements
|
||||
function sanitizeDocumentName(name: string): string {
|
||||
// Remove any characters that aren't alphanumeric, whitespace, hyphens, or parentheses
|
||||
let sanitized = name.replace(/[^a-zA-Z0-9\s\-\(\)\[\]]/g, "");
|
||||
// Replace multiple whitespace characters with a single space
|
||||
sanitized = sanitized.replace(/\s+/g, " ");
|
||||
// Trim whitespace from start and end
|
||||
return sanitized.trim();
|
||||
}
|
||||
|
||||
// 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 ("text" in block) {
|
||||
return {
|
||||
type: "text",
|
||||
text: block.text,
|
||||
};
|
||||
} else if ("image" in block) {
|
||||
return {
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:image/${block.image.format};base64,${block.image.source.bytes}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
// Document responses are converted to text content
|
||||
return {
|
||||
type: "text",
|
||||
text: block.text || "",
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user