diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 820c88f..5e872c6 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -1,81 +1,52 @@ -import { createOpenAI } from "@ai-sdk/openai"; -import { createGoogleGenerativeAI } from "@ai-sdk/google"; import { streamText, UIMessage, convertToModelMessages } from "ai"; +import { getModel, requiresApiKey, type AIProvider } from "@/lib/ai/models"; +import { buildSystemMessage } from "@/lib/ai/prompt"; -// Allow streaming responses up to 30 seconds +// 流式响应最长30秒 export const maxDuration = 30; -export async function POST(req: Request) { - const { - messages, - system, - pageContext, - provider, - apiKey, - }: { - messages: UIMessage[]; - system?: string; // System message forwarded from AssistantChatTransport - tools?: unknown; // Frontend tools forwarded from AssistantChatTransport - pageContext?: { - title?: string; - description?: string; - content?: string; - slug?: string; - }; - provider?: "openai" | "gemini"; - apiKey?: string; - } = await req.json(); - - // Check if API key is provided - if (!apiKey || apiKey.trim() === "") { - return Response.json( - { - error: - "API key is required. Please configure your API key in the settings.", - }, - { status: 400 }, - ); - } +interface ChatRequest { + messages: UIMessage[]; + system?: string; + tools?: unknown; + pageContext?: { + title?: string; + description?: string; + content?: string; + slug?: string; + }; + provider?: AIProvider; + apiKey?: string; +} +export async function POST(req: Request) { try { - // Build system message with page context - let systemMessage = - system || - `You are a helpful AI assistant for a documentation website. - You can help users understand the documentation, answer questions about the content, - and provide guidance on the topics covered in the docs. Be concise and helpful.`; + const { + messages, + system, + pageContext, + provider = "intern", // 默认使用书生模型 + apiKey, + }: ChatRequest = await req.json(); - // Add current page context if available - if (pageContext?.content) { - systemMessage += `\n\n--- CURRENT PAGE CONTEXT ---\n`; - if (pageContext.title) { - systemMessage += `Page Title: ${pageContext.title}\n`; - } - if (pageContext.description) { - systemMessage += `Page Description: ${pageContext.description}\n`; - } - if (pageContext.slug) { - systemMessage += `Page URL: /docs/${pageContext.slug}\n`; - } - systemMessage += `Page Content:\n${pageContext.content}`; - systemMessage += `\n--- END OF CONTEXT ---\n\nWhen users ask about "this page", "current page", or refer to the content they're reading, use the above context to provide accurate answers. You can summarize, explain, or answer specific questions about the current page content.`; + // 对指定Provider验证key是否存在 + if (requiresApiKey(provider) && (!apiKey || apiKey.trim() === "")) { + return Response.json( + { + error: + "API key is required. Please configure your API key in the settings.", + }, + { status: 400 }, + ); } - // Select model based on provider - let model; - if (provider === "gemini") { - const customGoogle = createGoogleGenerativeAI({ - apiKey: apiKey, - }); - model = customGoogle("models/gemini-2.0-flash"); - } else { - // Default to OpenAI - const customOpenAI = createOpenAI({ - apiKey: apiKey, - }); - model = customOpenAI("gpt-4.1-nano"); - } + // 构建系统消息,包含页面上下文 + const systemMessage = buildSystemMessage(system, pageContext); + + // 根据Provider获取 AI 模型实例 + const model = getModel(provider, apiKey); + // 生成流式响应 const result = streamText({ model: model, system: systemMessage, @@ -85,6 +56,12 @@ export async function POST(req: Request) { return result.toUIMessageStreamResponse(); } catch (error) { console.error("Chat API error:", error); + + // 处理特定模型创建错误 + if (error instanceof Error && error.message.includes("API key")) { + return Response.json({ error: error.message }, { status: 400 }); + } + return Response.json( { error: "Failed to process chat request" }, { status: 500 }, diff --git a/app/components/DocsAssistant.tsx b/app/components/DocsAssistant.tsx index 8e25a62..92b76fb 100644 --- a/app/components/DocsAssistant.tsx +++ b/app/components/DocsAssistant.tsx @@ -53,7 +53,9 @@ function DocsAssistantInner({ pageContext }: DocsAssistantProps) { const currentApiKey = currentProvider === "openai" ? openaiApiKeyRef.current - : geminiApiKeyRef.current; + : currentProvider === "gemini" + ? geminiApiKeyRef.current + : ""; // intern provider doesn't need API key console.log("[DocsAssistant] useChat body function called with:", { provider: currentProvider, @@ -118,9 +120,14 @@ interface AssistantErrorState { function deriveAssistantError( err: unknown, - provider: "openai" | "gemini", + provider: "openai" | "gemini" | "intern", ): AssistantErrorState { - const providerLabel = provider === "gemini" ? "Google Gemini" : "OpenAI"; + const providerLabel = + provider === "gemini" + ? "Google Gemini" + : provider === "intern" + ? "Intern-AI" + : "OpenAI"; const fallback: AssistantErrorState = { message: "The assistant couldn't complete that request. Please try again later.", @@ -176,14 +183,16 @@ function deriveAssistantError( let showSettingsCTA = false; + // For intern provider, don't show settings CTA for API key related errors if ( - statusCode === 400 || - statusCode === 401 || - statusCode === 403 || - normalized.includes("api key") || - normalized.includes("apikey") || - normalized.includes("missing key") || - normalized.includes("unauthorized") + provider !== "intern" && + (statusCode === 400 || + statusCode === 401 || + statusCode === 403 || + normalized.includes("api key") || + normalized.includes("apikey") || + normalized.includes("missing key") || + normalized.includes("unauthorized")) ) { showSettingsCTA = true; } diff --git a/app/components/assistant-ui/SettingsDialog.tsx b/app/components/assistant-ui/SettingsDialog.tsx index a8520cf..9929afe 100644 --- a/app/components/assistant-ui/SettingsDialog.tsx +++ b/app/components/assistant-ui/SettingsDialog.tsx @@ -51,9 +51,13 @@ export const SettingsDialog = ({ - setProvider(value as "openai" | "gemini") + setProvider(value as "openai" | "gemini" | "intern") } > +
+ + +
@@ -90,6 +94,15 @@ export const SettingsDialog = ({ />
)} + + {provider === "intern" && ( +
+
+ 感谢上海AILab的书生大模型对本项目的算力支持,Intern-AI + 模型已预配置,无需提供 API Key。 +
+
+ )} diff --git a/app/components/assistant-ui/assistant-modal.tsx b/app/components/assistant-ui/assistant-modal.tsx index ee46937..43e7b5e 100644 --- a/app/components/assistant-ui/assistant-modal.tsx +++ b/app/components/assistant-ui/assistant-modal.tsx @@ -21,7 +21,7 @@ export const AssistantModal: FC = ({ }) => { return ( - + @@ -59,12 +59,12 @@ const AssistantModalButton = forwardRef< > {tooltip} diff --git a/app/components/assistant-ui/thread.tsx b/app/components/assistant-ui/thread.tsx index c71b6cc..4a0394f 100644 --- a/app/components/assistant-ui/thread.tsx +++ b/app/components/assistant-ui/thread.tsx @@ -268,9 +268,19 @@ const Composer: FC = ({ onClearError, }) => { const { provider, openaiApiKey, geminiApiKey } = useAssistantSettings(); - const activeKey = provider === "openai" ? openaiApiKey : geminiApiKey; - const hasActiveKey = activeKey.trim().length > 0; - const providerLabel = provider === "gemini" ? "Google Gemini" : "OpenAI"; + const activeKey = + provider === "openai" + ? openaiApiKey + : provider === "gemini" + ? geminiApiKey + : ""; + const hasActiveKey = provider === "intern" || activeKey.trim().length > 0; + const providerLabel = + provider === "gemini" + ? "Google Gemini" + : provider === "intern" + ? "Intern-AI" + : "OpenAI"; const handleOpenSettings = useCallback(() => { onClearError?.(); @@ -278,7 +288,7 @@ const Composer: FC = ({ }, [onClearError, onOpenChange]); return ( -
+
diff --git a/app/hooks/useAssistantSettings.tsx b/app/hooks/useAssistantSettings.tsx index 878d697..e8beecc 100644 --- a/app/hooks/useAssistantSettings.tsx +++ b/app/hooks/useAssistantSettings.tsx @@ -10,7 +10,7 @@ import { } from "react"; import type { ReactNode } from "react"; -type Provider = "openai" | "gemini"; +type Provider = "openai" | "gemini" | "intern"; interface AssistantSettingsState { provider: Provider; @@ -45,7 +45,12 @@ const parseStoredSettings = (raw: string | null): AssistantSettingsState => { try { const parsed = JSON.parse(raw) as Partial; return { - provider: parsed.provider === "gemini" ? "gemini" : "openai", + provider: + parsed.provider === "gemini" + ? "gemini" + : parsed.provider === "intern" + ? "intern" + : "openai", openaiApiKey: typeof parsed.openaiApiKey === "string" ? parsed.openaiApiKey : "", geminiApiKey: diff --git a/lib/ai/models.ts b/lib/ai/models.ts new file mode 100644 index 0000000..b94968d --- /dev/null +++ b/lib/ai/models.ts @@ -0,0 +1,43 @@ +import { createOpenAIModel } from "./providers/openai"; +import { createGeminiModel } from "./providers/gemini"; +import { createInternModel } from "./providers/intern"; + +export type AIProvider = "openai" | "gemini" | "intern"; + +/** + * Model工厂 用于返回对应的 AI 模型实例 + * @param provider - 要用的provider + * @param apiKey - API key (intern provider不需要用户提供 API key) + * @returns 配置好的 AI 模型实例 + */ +export function getModel(provider: AIProvider, apiKey?: string) { + switch (provider) { + case "openai": + if (!apiKey || apiKey.trim() === "") { + throw new Error("OpenAI API key is required"); + } + return createOpenAIModel(apiKey); + + case "gemini": + if (!apiKey || apiKey.trim() === "") { + throw new Error("Gemini API key is required"); + } + return createGeminiModel(apiKey); + + case "intern": + // Intern 书生模型不需要用户提供 API key + return createInternModel(); + + default: + throw new Error(`Unsupported AI provider: ${provider}`); + } +} + +/** + * 检查指定的提供者是否需要用户提供 API key + * @param provider - 要检查的provider + * @returns 如果需要 API key,返回 true,否则返回 false + */ +export function requiresApiKey(provider: AIProvider): boolean { + return provider !== "intern"; +} diff --git a/lib/ai/prompt.ts b/lib/ai/prompt.ts new file mode 100644 index 0000000..5dacf20 --- /dev/null +++ b/lib/ai/prompt.ts @@ -0,0 +1,46 @@ +interface PageContext { + title?: string; + description?: string; + content?: string; + slug?: string; +} + +/** + * 构建系统消息,包含页面上下文 + * @param customSystem - 自定义系统消息 (可选) + * @param pageContext - 当前页面上下文 (可选) + * @returns 完整的系统消息字符串 + */ +export function buildSystemMessage( + customSystem?: string, + pageContext?: PageContext, +): string { + // 默认系统消息 + let systemMessage = + customSystem || + `You are a helpful AI assistant for a documentation website. + Always respond in the same language as the user's question: if the user asks in 中文, answer in 中文; if the user asks in English, answer in English. + You can help users understand the documentation, answer questions about the content, and provide guidance on the topics covered in the docs. Be concise and helpful.`; + + // 如果当前页面上下文可用,则添加到系统消息中 + if (pageContext?.content) { + systemMessage += `\n\n--- CURRENT PAGE CONTEXT ---\n`; + + if (pageContext.title) { + systemMessage += `Page Title: ${pageContext.title}\n`; + } + + if (pageContext.description) { + systemMessage += `Page Description: ${pageContext.description}\n`; + } + + if (pageContext.slug) { + systemMessage += `Page URL: /docs/${pageContext.slug}\n`; + } + + systemMessage += `Page Content:\n${pageContext.content}`; + systemMessage += `\n--- END OF CONTEXT ---\n\nWhen users ask about "this page", "current page", or refer to the content they're reading, use the above context to provide accurate answers. You can summarize, explain, or answer specific questions about the current page content.`; + } + + return systemMessage; +} diff --git a/lib/ai/providers/gemini.ts b/lib/ai/providers/gemini.ts new file mode 100644 index 0000000..9c6249d --- /dev/null +++ b/lib/ai/providers/gemini.ts @@ -0,0 +1,15 @@ +import { createGoogleGenerativeAI } from "@ai-sdk/google"; + +/** + * Create Google Gemini model instance + * @param apiKey - Google Gemini API key provided by user + * @returns Configured Gemini model instance + */ +export function createGeminiModel(apiKey: string) { + const customGoogle = createGoogleGenerativeAI({ + apiKey: apiKey, + }); + + // Use the specific model configured for this project + return customGoogle("models/gemini-2.0-flash"); +} diff --git a/lib/ai/providers/intern.ts b/lib/ai/providers/intern.ts new file mode 100644 index 0000000..71d06a5 --- /dev/null +++ b/lib/ai/providers/intern.ts @@ -0,0 +1,17 @@ +import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; + +/** + * Create Intern-AI model instance + * Uses environment variable INTERN_KEY for API key + * @returns Configured Intern-AI model instance + */ +export function createInternModel() { + const intern = createOpenAICompatible({ + name: "intern", + baseURL: "https://chat.intern-ai.org.cn/api/v1/", + apiKey: process.env.INTERN_KEY, + }); + + // Use the specific model configured for this project + return intern("intern-s1"); +} diff --git a/lib/ai/providers/openai.ts b/lib/ai/providers/openai.ts new file mode 100644 index 0000000..88f2724 --- /dev/null +++ b/lib/ai/providers/openai.ts @@ -0,0 +1,15 @@ +import { createOpenAI } from "@ai-sdk/openai"; + +/** + * Create OpenAI model instance + * @param apiKey - OpenAI API key provided by user + * @returns Configured OpenAI model instance + */ +export function createOpenAIModel(apiKey: string) { + const customOpenAI = createOpenAI({ + apiKey: apiKey, + }); + + // Use the specific model configured for this project + return customOpenAI("gpt-4.1-nano"); +} diff --git a/package.json b/package.json index 65079af..b5e69d9 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dependencies": { "@ai-sdk/google": "^2.0.14", "@ai-sdk/openai": "^2.0.32", + "@ai-sdk/openai-compatible": "^1.0.19", "@ai-sdk/react": "^2.0.48", "@assistant-ui/react": "^0.11.14", "@assistant-ui/react-ai-sdk": "^1.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d873bc..30619f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,9 @@ importers: "@ai-sdk/openai": specifier: ^2.0.32 version: 2.0.32(zod@4.1.11) + "@ai-sdk/openai-compatible": + specifier: ^1.0.19 + version: 1.0.19(zod@4.1.11) "@ai-sdk/react": specifier: ^2.0.48 version: 2.0.48(react@19.1.1)(zod@4.1.11) @@ -208,6 +211,15 @@ packages: peerDependencies: zod: ^3.25.76 || ^4 + "@ai-sdk/openai-compatible@1.0.19": + resolution: + { + integrity: sha512-hnsqPCCSNKgpZRNDOAIXZs7OcUDM4ut5ggWxj2sjB4tNL/aBn/xrM7pJkqu+WuPowyrE60wPVSlw0LvtXAlMXQ==, + } + engines: { node: ">=18" } + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + "@ai-sdk/openai@2.0.32": resolution: { @@ -217,6 +229,15 @@ packages: peerDependencies: zod: ^3.25.76 || ^4 + "@ai-sdk/provider-utils@3.0.10": + resolution: + { + integrity: sha512-T1gZ76gEIwffep6MWI0QNy9jgoybUHE7TRaHB5k54K8mF91ciGFlbtCGxDYhMH3nCRergKwYFIDeFF0hJSIQHQ==, + } + engines: { node: ">=18" } + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + "@ai-sdk/provider-utils@3.0.9": resolution: { @@ -8409,12 +8430,25 @@ snapshots: "@ai-sdk/provider-utils": 3.0.9(zod@4.1.11) zod: 4.1.11 + "@ai-sdk/openai-compatible@1.0.19(zod@4.1.11)": + dependencies: + "@ai-sdk/provider": 2.0.0 + "@ai-sdk/provider-utils": 3.0.10(zod@4.1.11) + zod: 4.1.11 + "@ai-sdk/openai@2.0.32(zod@4.1.11)": dependencies: "@ai-sdk/provider": 2.0.0 "@ai-sdk/provider-utils": 3.0.9(zod@4.1.11) zod: 4.1.11 + "@ai-sdk/provider-utils@3.0.10(zod@4.1.11)": + dependencies: + "@ai-sdk/provider": 2.0.0 + "@standard-schema/spec": 1.0.0 + eventsource-parser: 3.0.6 + zod: 4.1.11 + "@ai-sdk/provider-utils@3.0.9(zod@4.1.11)": dependencies: "@ai-sdk/provider": 2.0.0