新增自定义模型
This commit is contained in:
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Prisma",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
2
prisma/.gitignore
vendored
2
prisma/.gitignore
vendored
@@ -22,3 +22,5 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
prisma/.env
|
||||
prisma/.env.example
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
1
prisma/index.css
Normal file
@@ -0,0 +1 @@
|
||||
/* Base styles are handled by Tailwind CSS */
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user