新增自定义模型

This commit is contained in:
jwangkun
2026-01-08 20:47:59 +08:00
parent 579071ac95
commit 82b46f93ce
10 changed files with 242 additions and 47 deletions

2
prisma/.gitignore vendored
View File

@@ -22,3 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
prisma/.env
prisma/.env.example

View File

@@ -1,6 +1,6 @@
import { GoogleGenAI } from "@google/genai";
import OpenAI from "openai";
import { ApiProvider, AppConfig, CustomModel } from './types';
import { ApiProvider, CustomModel } from './types';
type AIProviderConfig = {
provider?: ApiProvider;
@@ -8,39 +8,99 @@ type AIProviderConfig = {
baseUrl?: string;
};
/**
* Find custom model configuration by model name
*/
export const findCustomModel = (modelName: string, customModels?: CustomModel[]): CustomModel | undefined => {
return customModels?.find(m => m.name === modelName);
};
// External API base URLs for production
const PROVIDER_BASE_URLS: Record<string, string> = {
openai: 'https://api.openai.com/v1',
deepseek: 'https://api.deepseek.com/v1',
anthropic: 'https://api.anthropic.com/v1',
xai: 'https://api.x.ai/v1',
mistral: 'https://api.mistral.ai/v1',
custom: '',
};
// Check if we're in development mode
const isDevelopment = import.meta.env?.MODE === 'development' || process.env.NODE_ENV === 'development';
// Store the current custom API target URL
let currentCustomApiUrl: string | null = null;
// Setup fetch interceptor to add X-Target-URL header for custom API proxy
const originalFetch = typeof window !== 'undefined' ? window.fetch.bind(window) : null;
if (typeof window !== 'undefined' && originalFetch) {
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
let urlString: string;
if (typeof input === 'string') {
urlString = input;
} else if (input instanceof URL) {
urlString = input.toString();
} else {
urlString = input.url;
}
// If this is a custom-api request and we have a target URL, add the header
if (urlString.includes('/custom-api') && currentCustomApiUrl) {
const headers = new Headers(init?.headers);
headers.set('X-Target-URL', currentCustomApiUrl);
console.log('[Fetch] Adding X-Target-URL header:', currentCustomApiUrl);
return originalFetch(input, {
...init,
headers,
});
}
return originalFetch(input, init);
};
}
export const getAI = (config?: AIProviderConfig) => {
const provider = config?.provider || 'google';
// Support both Vite env vars (VITE_) and standard env vars for flexibility
const apiKey = config?.apiKey || (import.meta.env as any).VITE_API_KEY || process.env.API_KEY;
if (provider === 'openai' || provider === 'deepseek' || provider === 'custom' || provider === 'anthropic' || provider === 'xai' || provider === 'mistral') {
const options: any = {
apiKey: apiKey,
// WARNING: dangerouslyAllowBrowser enables client-side API calls
// This is acceptable for local development but NOT production
// In production, use a backend proxy to protect API keys
dangerouslyAllowBrowser: true,
};
if (config?.baseUrl) {
options.baseURL = config.baseUrl;
} else if (provider === 'deepseek') {
options.baseURL = 'https://api.deepseek.com/v1';
} else if (provider === 'anthropic') {
options.baseURL = 'https://api.anthropic.com/v1';
} else if (provider === 'xai') {
options.baseURL = 'https://api.x.ai/v1';
} else if (provider === 'mistral') {
options.baseURL = 'https://api.mistral.ai/v1';
// Custom baseUrl from Configuration UI
if (isDevelopment) {
// Store the target URL for the fetch interceptor
currentCustomApiUrl = config.baseUrl;
// Use proxy path
options.baseURL = `${window.location.origin}/custom-api`;
console.log('[API] Using custom API proxy:', {
proxyPath: options.baseURL,
targetUrl: currentCustomApiUrl,
});
} else {
// In production, use the URL directly
options.baseURL = config.baseUrl;
}
} else {
const providerBaseUrl = PROVIDER_BASE_URLS[provider];
if (providerBaseUrl) {
if (isDevelopment) {
// In development, use proxy to avoid CORS for known providers
options.baseURL = `${window.location.origin}/${provider}/v1`;
} else {
options.baseURL = providerBaseUrl;
}
}
}
console.log('[API] OpenAI client config:', {
provider,
baseURL: options.baseURL,
hasApiKey: !!options.apiKey,
customTarget: currentCustomApiUrl,
});
return new OpenAI(options);
} else {
const options: any = {
@@ -75,4 +135,4 @@ export const getAIProvider = (model: string): ApiProvider => {
return 'custom';
}
return 'google';
};
};

View File

@@ -49,7 +49,7 @@ const Header = ({ selectedModel, setSelectedModel, onOpenSettings, onToggleSideb
className="relative bg-white border border-slate-200 text-slate-800 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-auto p-2.5 outline-none appearance-none cursor-pointer pl-3 pr-8 shadow-sm font-medium hover:bg-slate-50 transition-colors"
>
{availableModels.map(m => (
<option key={m.value} value={m.value}>{m.label}</option>
<option key={`${m.provider}-${m.value}`} value={m.value}>{m.label}</option>
))}
</select>
<ChevronDown className="absolute right-3 top-3 text-slate-400 pointer-events-none group-hover:text-slate-600 transition-colors" size={14} />

View File

@@ -2,6 +2,7 @@
import React, { useState } from 'react';
import { Plus, Trash2, Bot, Key, Globe, ChevronDown, ChevronUp } from 'lucide-react';
import { AppConfig, ApiProvider, CustomModel } from '../../types';
import { MODELS } from '../../config';
interface ModelSectionProps {
config: AppConfig;
@@ -20,9 +21,25 @@ const ModelSection = ({ config, setConfig }: ModelSectionProps) => {
const handleAddModel = () => {
if (!newModelName.trim()) return;
const trimmedName = newModelName.trim();
// Check if model name already exists in preset models
const existingPresetModel = MODELS.find(m => m.value === trimmedName);
if (existingPresetModel) {
alert(`Model name "${trimmedName}" already exists as a preset model. Please choose a different name.`);
return;
}
// Check if model name already exists in custom models
const existingCustomModel = customModels.find(m => m.name === trimmedName);
if (existingCustomModel) {
alert(`Model name "${trimmedName}" already exists. Please choose a different name.`);
return;
}
const newModel: CustomModel = {
id: `custom-${Date.now()}`,
name: newModelName.trim(),
name: trimmedName,
provider: newModelProvider,
apiKey: newModelApiKey || undefined,
baseUrl: newModelBaseUrl || undefined

View File

@@ -99,8 +99,8 @@ export const useDeepThink = () => {
setProcessStartTime(Date.now());
setProcessEndTime(null);
const provider = getAIProvider(model);
const customModelConfig = findCustomModel(model, config.customModels);
const provider = customModelConfig?.provider || getAIProvider(model);
const ai = getAI({
provider,

1
prisma/index.css Normal file
View File

@@ -0,0 +1 @@
/* Base styles are handled by Tailwind CSS */

View File

@@ -1,18 +1,5 @@
/**
* Network Interceptor
*
* Intercepts global fetch calls to redirect Gemini API requests
* from the default endpoint to a user-defined custom base URL.
*
* Uses Object.defineProperty to bypass "getter-only" restrictions on window.fetch
* in certain sandboxed or strict environments.
*/
const originalFetch = window.fetch;
/**
* Robustly applies a function to the window.fetch property.
*/
const applyFetch = (fn: typeof window.fetch) => {
try {
Object.defineProperty(window, 'fetch', {
@@ -22,7 +9,6 @@ const applyFetch = (fn: typeof window.fetch) => {
enumerable: true
});
} catch (e) {
// Fallback for environments where defineProperty on window might fail
try {
(window as any).fetch = fn;
} catch (err) {
@@ -33,15 +19,12 @@ const applyFetch = (fn: typeof window.fetch) => {
export const setInterceptorUrl = (baseUrl: string | null) => {
if (!baseUrl) {
// Restore original fetch when disabled
applyFetch(originalFetch);
return;
}
// Normalize the base URL
let normalizedBase = baseUrl.trim();
try {
// Basic validation
new URL(normalizedBase);
} catch (e) {
console.warn("[Prisma] Invalid Base URL provided:", normalizedBase);
@@ -65,28 +48,22 @@ export const setInterceptorUrl = (baseUrl: string | null) => {
const defaultHost = 'generativelanguage.googleapis.com';
// Check if the request is targeting the Google Gemini API
if (urlString.includes(defaultHost)) {
try {
const url = new URL(urlString);
const proxy = new URL(normalizedBase);
// Replace protocol and host
url.protocol = proxy.protocol;
url.host = proxy.host;
// Prepend proxy path if it exists (e.g., /v1/proxy)
if (proxy.pathname !== '/') {
const cleanPath = proxy.pathname.endsWith('/') ? proxy.pathname.slice(0, -1) : proxy.pathname;
// Ensure we don't double up slashes
url.pathname = cleanPath + url.pathname;
}
const newUrl = url.toString();
// Handle the different types of fetch inputs
if (input instanceof Request) {
// Re-create the request with the new URL and original properties
const requestData: RequestInit = {
method: input.method,
headers: input.headers,
@@ -99,9 +76,7 @@ export const setInterceptorUrl = (baseUrl: string | null) => {
integrity: input.integrity,
};
// Merge with init if provided
const mergedInit = { ...requestData, ...init };
return originalFetch(new URL(newUrl), mergedInit);
}

View File

@@ -1,15 +1,149 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import type { Connect } from 'vite';
// Custom middleware to handle dynamic proxy for /custom-api
function customApiProxyMiddleware(): Connect.NextHandleFunction {
return async (req, res, next) => {
if (!req.url?.startsWith('/custom-api')) {
return next();
}
const targetUrl = req.headers['x-target-url'] as string;
if (!targetUrl) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Missing X-Target-URL header' }));
return;
}
try {
const url = new URL(targetUrl);
const targetPath = req.url.replace(/^\/custom-api/, '');
const fullUrl = `${url.origin}${targetPath}`;
console.log('[Custom Proxy] Forwarding:', req.method, req.url, '->', fullUrl);
// Collect request body
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(chunk as Buffer);
}
const body = Buffer.concat(chunks);
// Forward headers (excluding hop-by-hop headers)
const forwardHeaders: Record<string, string> = {};
const skipHeaders = ['host', 'connection', 'x-target-url', 'transfer-encoding'];
for (const [key, value] of Object.entries(req.headers)) {
if (!skipHeaders.includes(key.toLowerCase()) && value) {
forwardHeaders[key] = Array.isArray(value) ? value[0] : value;
}
}
forwardHeaders['host'] = url.host;
// Make the request
const response = await fetch(fullUrl, {
method: req.method,
headers: forwardHeaders,
body: ['GET', 'HEAD'].includes(req.method || '') ? undefined : body,
});
// Forward response status and headers
res.statusCode = response.status;
response.headers.forEach((value, key) => {
if (!['transfer-encoding', 'connection'].includes(key.toLowerCase())) {
res.setHeader(key, value);
}
});
// Stream the response body
if (response.body) {
const reader = response.body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(value);
}
} finally {
reader.releaseLock();
}
}
res.end();
} catch (error: any) {
console.error('[Custom Proxy] Error:', error.message);
res.statusCode = 502;
res.end(JSON.stringify({ error: 'Proxy error', message: error.message }));
}
};
}
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
host: '0.0.0.0',
proxy: {
// Proxy for OpenAI API
'/openai/v1': {
target: 'https://api.openai.com',
changeOrigin: true,
secure: true,
rewrite: (path) => path.replace(/^\/openai\/v1/, '/v1'),
},
// Proxy for DeepSeek API
'/deepseek/v1': {
target: 'https://api.deepseek.com',
changeOrigin: true,
secure: true,
rewrite: (path) => path.replace(/^\/deepseek\/v1/, '/v1'),
},
// Proxy for Anthropic API
'/anthropic/v1': {
target: 'https://api.anthropic.com',
changeOrigin: true,
secure: true,
rewrite: (path) => path.replace(/^\/anthropic\/v1/, '/v1'),
},
// Proxy for xAI API
'/xai/v1': {
target: 'https://api.x.ai',
changeOrigin: true,
secure: true,
rewrite: (path) => path.replace(/^\/xai\/v1/, '/v1'),
},
// Proxy for Mistral API
'/mistral/v1': {
target: 'https://api.mistral.ai',
changeOrigin: true,
secure: true,
rewrite: (path) => path.replace(/^\/mistral\/v1/, '/v1'),
},
// Proxy for Google Gemini API
'/v1beta/models': {
target: 'https://generativelanguage.googleapis.com',
changeOrigin: true,
secure: true,
},
'/v1/models': {
target: 'https://generativelanguage.googleapis.com',
changeOrigin: true,
secure: true,
},
}
},
plugins: [react()],
plugins: [
react(),
{
name: 'custom-api-proxy',
configureServer(server) {
server.middlewares.use(customApiProxyMiddleware());
},
},
],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)