diff --git a/app.py b/app.py index 1ce3097..8ab879d 100644 --- a/app.py +++ b/app.py @@ -41,6 +41,7 @@ API_KEYS_FILE = os.path.join(CONFIG_DIR, 'api_keys.json') VERSION_FILE = os.path.join(CONFIG_DIR, 'version.json') UPDATE_INFO_FILE = os.path.join(CONFIG_DIR, 'update_info.json') PROMPT_FILE = os.path.join(CONFIG_DIR, 'prompts.json') # 新增提示词配置文件路径 +PROXY_API_FILE = os.path.join(CONFIG_DIR, 'proxy_api.json') # 新增中转API配置文件路径 # 跟踪用户生成任务的字典 generation_tasks = {} @@ -114,13 +115,31 @@ def create_model_instance(model_id, settings, is_reasoning=False): # 获取maxTokens参数,默认为8192 max_tokens = int(settings.get('maxTokens', 8192)) + # 检查是否启用中转API + proxy_api_config = load_proxy_api() + base_url = None + + if proxy_api_config.get('enabled', False): + # 根据模型类型选择对应的中转API + if "claude" in model_id.lower() or "anthropic" in model_id.lower(): + base_url = proxy_api_config.get('apis', {}).get('anthropic', '') + elif any(keyword in model_id.lower() for keyword in ["gpt", "openai"]): + base_url = proxy_api_config.get('apis', {}).get('openai', '') + elif "deepseek" in model_id.lower(): + base_url = proxy_api_config.get('apis', {}).get('deepseek', '') + elif "qvq" in model_id.lower() or "alibaba" in model_id.lower() or "qwen" in model_id.lower(): + base_url = proxy_api_config.get('apis', {}).get('alibaba', '') + elif "gemini" in model_id.lower() or "google" in model_id.lower(): + base_url = proxy_api_config.get('apis', {}).get('google', '') + # 创建模型实例 model_instance = ModelFactory.create_model( model_name=model_id, api_key=api_key, temperature=None if is_reasoning else float(settings.get('temperature', 0.7)), system_prompt=settings.get('systemPrompt'), - language=settings.get('language', '中文') + language=settings.get('language', '中文'), + base_url=base_url # 添加中转API URL ) # 设置最大输出Token,但不为阿里巴巴模型设置(它们有自己内部的处理逻辑) @@ -159,24 +178,6 @@ def stream_model_response(response_generator, sid, model_name=None): for response in response_generator: # 处理Mathpix响应 if isinstance(response.get('content', ''), str) and 'mathpix' in response.get('model', ''): - socketio.emit('text_extracted', { - 'content': response['content'] - }, room=sid) - continue - - # 获取状态和内容 - status = response.get('status', '') - content = response.get('content', '') - - # 根据不同的状态进行处理 - if status == 'thinking': - # 仅对推理模型处理思考过程 - if is_reasoning: - # 直接使用模型提供的完整思考内容 - thinking_buffer = content - - # 控制发送频率,至少间隔0.3秒 - current_time = time.time() if current_time - last_emit_time >= 0.3: socketio.emit('ai_response', { 'status': 'thinking', @@ -774,9 +775,47 @@ def load_api_keys(): print(f"加载API密钥配置失败: {e}") return {} +# 加载中转API配置 +def load_proxy_api(): + """从配置文件加载中转API配置""" + try: + if os.path.exists(PROXY_API_FILE): + with open(PROXY_API_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + else: + # 如果文件不存在,创建默认配置 + default_proxy_apis = { + "enabled": False, + "apis": { + "anthropic": "", + "openai": "", + "deepseek": "", + "alibaba": "", + "google": "" + } + } + save_proxy_api(default_proxy_apis) + return default_proxy_apis + except Exception as e: + print(f"加载中转API配置失败: {e}") + return {"enabled": False, "apis": {}} + +# 保存中转API配置 +def save_proxy_api(proxy_api_config): + """保存中转API配置到文件""" + try: + # 确保配置目录存在 + os.makedirs(os.path.dirname(PROXY_API_FILE), exist_ok=True) + + with open(PROXY_API_FILE, 'w', encoding='utf-8') as f: + json.dump(proxy_api_config, f, ensure_ascii=False, indent=2) + return True + except Exception as e: + print(f"保存中转API配置失败: {e}") + return False + # 保存API密钥配置 def save_api_keys(api_keys): - """保存API密钥到配置文件""" try: # 确保配置目录存在 os.makedirs(os.path.dirname(API_KEYS_FILE), exist_ok=True) @@ -879,6 +918,33 @@ def remove_prompt(prompt_id): print(f"删除提示词时出错: {e}") return jsonify({"error": str(e)}), 500 +@app.route('/api/proxy-api', methods=['GET']) +def get_proxy_api(): + """API端点:获取中转API配置""" + try: + proxy_api_config = load_proxy_api() + return jsonify(proxy_api_config) + except Exception as e: + print(f"获取中转API配置时出错: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/proxy-api', methods=['POST']) +def update_proxy_api(): + """API端点:更新中转API配置""" + try: + new_config = request.json + if not isinstance(new_config, dict): + return jsonify({"success": False, "message": "无效的中转API配置格式"}), 400 + + # 保存回文件 + if save_proxy_api(new_config): + return jsonify({"success": True, "message": "中转API配置已保存"}) + else: + return jsonify({"success": False, "message": "保存中转API配置失败"}), 500 + + except Exception as e: + return jsonify({"success": False, "message": f"更新中转API配置错误: {str(e)}"}), 500 + if __name__ == '__main__': local_ip = get_local_ip() print(f"Local IP Address: {local_ip}") diff --git a/config/api_base_urls.json b/config/api_base_urls.json new file mode 100644 index 0000000..f5136e7 --- /dev/null +++ b/config/api_base_urls.json @@ -0,0 +1,7 @@ +{ + "AnthropicApiBaseUrl": "", + "OpenaiApiBaseUrl": "", + "DeepseekApiBaseUrl": "", + "AlibabaApiBaseUrl": "", + "GoogleApiBaseUrl": "" +} \ No newline at end of file diff --git a/models/anthropic.py b/models/anthropic.py index 668400e..4c4a60d 100644 --- a/models/anthropic.py +++ b/models/anthropic.py @@ -4,6 +4,11 @@ from typing import Generator from .base import BaseModel class AnthropicModel(BaseModel): + def __init__(self, api_key, temperature=0.7, system_prompt=None, language=None, api_base_url=None): + super().__init__(api_key, temperature, system_prompt, language) + # 设置API基础URL,默认为Anthropic官方API + self.api_base_url = api_base_url or "https://api.anthropic.com/v1" + def get_default_system_prompt(self) -> str: return """You are an expert at analyzing questions and providing detailed solutions. When presented with an image of a question: 1. First read and understand the question carefully @@ -82,8 +87,11 @@ class AnthropicModel(BaseModel): print(f"Debug - 推理配置: max_tokens={max_tokens}, thinking={payload.get('thinking', payload.get('speed_mode', 'default'))}") + # 使用配置的API基础URL + api_endpoint = f"{self.api_base_url}/messages" + response = requests.post( - 'https://api.anthropic.com/v1/messages', + api_endpoint, headers=headers, json=payload, stream=True, @@ -257,8 +265,11 @@ class AnthropicModel(BaseModel): print(f"Debug - 图像分析推理配置: max_tokens={max_tokens}, thinking={payload.get('thinking', payload.get('speed_mode', 'default'))}") + # 使用配置的API基础URL + api_endpoint = f"{self.api_base_url}/messages" + response = requests.post( - 'https://api.anthropic.com/v1/messages', + api_endpoint, headers=headers, json=payload, stream=True, diff --git a/models/factory.py b/models/factory.py index cbf3e3d..e4781d4 100644 --- a/models/factory.py +++ b/models/factory.py @@ -80,7 +80,8 @@ class ModelFactory: print(f"无法加载基础Mathpix工具: {str(e)}") @classmethod - def create_model(cls, model_name: str, api_key: str, temperature: float = 0.7, system_prompt: str = None, language: str = None) -> BaseModel: + def create_model(cls, model_name: str, api_key: str, temperature: float = 0.7, + system_prompt: str = None, language: str = None, api_base_url: str = None) -> BaseModel: """ Create a model instance based on the model name. @@ -90,6 +91,7 @@ class ModelFactory: temperature: The temperature to use for generation system_prompt: The system prompt to use language: The preferred language for responses + api_base_url: The base URL for API requests Returns: A model instance @@ -107,7 +109,8 @@ class ModelFactory: temperature=temperature, system_prompt=system_prompt, language=language, - model_name=model_name + model_name=model_name, + api_base_url=api_base_url ) # 对于阿里巴巴模型,也需要传递正确的模型名称 elif 'qwen' in model_name.lower() or 'qvq' in model_name.lower() or 'alibaba' in model_name.lower(): @@ -116,7 +119,8 @@ class ModelFactory: temperature=temperature, system_prompt=system_prompt, language=language, - model_name=model_name + model_name=model_name, + api_base_url=api_base_url ) # 对于Mathpix模型,不传递language参数 elif model_name == 'mathpix': @@ -131,7 +135,8 @@ class ModelFactory: api_key=api_key, temperature=temperature, system_prompt=system_prompt, - language=language + language=language, + api_base_url=api_base_url ) @classmethod diff --git a/models/google.py b/models/google.py index c9c07d8..6904bfe 100644 --- a/models/google.py +++ b/models/google.py @@ -11,7 +11,7 @@ class GoogleModel(BaseModel): 支持Gemini 2.5 Pro等模型,可处理文本和图像输入 """ - def __init__(self, api_key: str, temperature: float = 0.7, system_prompt: str = None, language: str = None, model_name: str = None): + def __init__(self, api_key: str, temperature: float = 0.7, system_prompt: str = None, language: str = None, model_name: str = None, api_base_url: str = None): """ 初始化Google模型 @@ -21,13 +21,20 @@ class GoogleModel(BaseModel): system_prompt: 系统提示词 language: 首选语言 model_name: 指定具体模型名称,如不指定则使用默认值 + api_base_url: API基础URL,用于设置自定义API端点 """ super().__init__(api_key, temperature, system_prompt, language) self.model_name = model_name or self.get_model_identifier() self.max_tokens = 8192 # 默认最大输出token数 + self.api_base_url = api_base_url # 配置Google API - genai.configure(api_key=api_key) + if api_base_url: + # 如果提供了自定义API基础URL,设置genai的api_url + genai.configure(api_key=api_key, transport="rest", client_options={"api_endpoint": api_base_url}) + else: + # 使用默认API端点 + genai.configure(api_key=api_key) def get_default_system_prompt(self) -> str: return """You are an expert at analyzing questions and providing detailed solutions. When presented with an image of a question: diff --git a/models/openai.py b/models/openai.py index 8fd8f45..090990f 100644 --- a/models/openai.py +++ b/models/openai.py @@ -4,6 +4,11 @@ from openai import OpenAI from .base import BaseModel class OpenAIModel(BaseModel): + def __init__(self, api_key, temperature=0.7, system_prompt=None, language=None, api_base_url=None): + super().__init__(api_key, temperature, system_prompt, language) + # 设置API基础URL,默认为OpenAI官方API + self.api_base_url = api_base_url + def get_default_system_prompt(self) -> str: return """You are an expert at analyzing questions and providing detailed solutions. When presented with an image of a question: 1. First read and understand the question carefully @@ -35,8 +40,11 @@ class OpenAIModel(BaseModel): if 'https' in proxies: os.environ['https_proxy'] = proxies['https'] - # Initialize OpenAI client - client = OpenAI(api_key=self.api_key) + # Initialize OpenAI client with base_url if provided + if self.api_base_url: + client = OpenAI(api_key=self.api_key, base_url=self.api_base_url) + else: + client = OpenAI(api_key=self.api_key) # Prepare messages messages = [ @@ -123,8 +131,11 @@ class OpenAIModel(BaseModel): if 'https' in proxies: os.environ['https_proxy'] = proxies['https'] - # Initialize OpenAI client - client = OpenAI(api_key=self.api_key) + # Initialize OpenAI client with base_url if provided + if self.api_base_url: + client = OpenAI(api_key=self.api_key, base_url=self.api_base_url) + else: + client = OpenAI(api_key=self.api_key) # 使用系统提供的系统提示词,不再自动添加语言指令 system_prompt = self.system_prompt diff --git a/static/js/settings.js b/static/js/settings.js index 3ef0f1c..182a738 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -374,6 +374,26 @@ class SettingsManager { // 模型选择器对象 this.modelSelector = null; + // 存储API密钥的对象 + this.apiKeyValues = { + 'AnthropicApiKey': '', + 'OpenaiApiKey': '', + 'DeepseekApiKey': '', + 'AlibabaApiKey': '', + 'GoogleApiKey': '', + 'MathpixAppId': '', + 'MathpixAppKey': '' + }; + + // 存储API基础URL的对象 + this.apiBaseUrlValues = { + 'AnthropicApiBaseUrl': '', + 'OpenaiApiBaseUrl': '', + 'DeepseekApiBaseUrl': '', + 'AlibabaApiBaseUrl': '', + 'GoogleApiBaseUrl': '' + }; + // 加载模型配置 this.isInitialized = false; this.initialize(); @@ -391,6 +411,12 @@ class SettingsManager { this.setupEventListeners(); this.updateUIBasedOnModelType(); + // 刷新API密钥状态 + await this.refreshApiKeyStatus(); + + // 刷新API基础URL状态 + await this.refreshApiBaseUrlStatus(); + // 初始化可折叠内容逻辑 this.initCollapsibleContent(); @@ -824,7 +850,8 @@ class SettingsManager { isReasoning: modelInfo.isReasoning || false, provider: modelInfo.provider || 'unknown' }, - reasoningConfig: reasoningConfig + reasoningConfig: reasoningConfig, + apiBaseUrls: this.apiBaseUrlValues // 添加API基础URL值 }; } @@ -1125,6 +1152,9 @@ class SettingsManager { if (this.modelSelectorDisplay && this.modelDropdown) { this.initCustomSelectorEvents(); } + + // 初始化API基础URL编辑功能 + this.initApiBaseUrlEditFunctions(); } // 更新思考预算显示 @@ -1196,8 +1226,38 @@ class SettingsManager { * 初始化可折叠内容的交互逻辑 */ initCollapsibleContent() { - // 在新的实现中,我们不再需要折叠API密钥区域,因为所有功能都在同一区域完成 - console.log('初始化API密钥编辑功能完成'); + const collapsibleHeaders = document.querySelectorAll('.collapsible-header'); + + collapsibleHeaders.forEach(header => { + header.addEventListener('click', () => { + const content = header.nextElementSibling; + if (content && content.classList.contains('collapsible-content')) { + // 切换展开/折叠状态 + content.classList.toggle('expanded'); + + // 切换箭头方向 + const arrow = header.querySelector('i.fa-chevron-down, i.fa-chevron-up'); + if (arrow) { + arrow.classList.toggle('fa-chevron-down'); + arrow.classList.toggle('fa-chevron-up'); + } + } + }); + }); + + // 默认展开API基础URL设置区域 + const apiBaseUrlHeader = document.querySelector('.api-url-settings .collapsible-header'); + if (apiBaseUrlHeader) { + const content = apiBaseUrlHeader.nextElementSibling; + if (content) { + content.classList.add('expanded'); + const arrow = apiBaseUrlHeader.querySelector('i.fa-chevron-down'); + if (arrow) { + arrow.classList.remove('fa-chevron-down'); + arrow.classList.add('fa-chevron-up'); + } + } + } } /** @@ -2068,6 +2128,18 @@ class SettingsManager { this.proxyPortInput = document.getElementById('proxyPort'); this.proxySettings = document.getElementById('proxySettings'); + // API基础URL相关元素 + this.apiBaseUrlsList = document.getElementById('apiBaseUrlsList'); + + // 获取所有API基础URL状态元素 + this.apiBaseUrlStatusElements = { + 'AnthropicApiBaseUrl': document.getElementById('AnthropicApiBaseUrlStatus'), + 'OpenaiApiBaseUrl': document.getElementById('OpenaiApiBaseUrlStatus'), + 'DeepseekApiBaseUrl': document.getElementById('DeepseekApiBaseUrlStatus'), + 'AlibabaApiBaseUrl': document.getElementById('AlibabaApiBaseUrlStatus'), + 'GoogleApiBaseUrl': document.getElementById('GoogleApiBaseUrlStatus') + }; + // 提示词管理相关元素 this.promptSelect = document.getElementById('promptSelect'); this.savePromptBtn = document.getElementById('savePromptBtn'); @@ -2219,6 +2291,235 @@ class SettingsManager { this.promptDialogMask.classList.remove('hidden'); } } + + /** + * 刷新API基础URL状态 + */ + async refreshApiBaseUrlStatus() { + try { + // 先将所有状态显示为"检查中" + Object.keys(this.apiBaseUrlValues).forEach(urlId => { + const statusElement = document.getElementById(`${urlId}Status`); + if (statusElement) { + statusElement.className = 'key-status checking'; + statusElement.innerHTML = ' 检查中...'; + } + }); + + // 发送请求获取API基础URL + const response = await fetch('/api/base_urls', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + } + }); + + if (response.ok) { + const apiBaseUrls = await response.json(); + this.updateApiBaseUrlStatus(apiBaseUrls); + console.log('API基础URL状态已刷新'); + } else { + console.error('刷新API基础URL状态失败'); + } + } catch (error) { + console.error('刷新API基础URL状态出错:', error); + } + } + + /** + * 更新API基础URL状态显示 + * @param {Object} apiBaseUrls 基础URL对象 + */ + updateApiBaseUrlStatus(apiBaseUrls) { + if (!this.apiBaseUrlsList) return; + + // 保存API基础URL值到内存中 + for (const [key, value] of Object.entries(apiBaseUrls)) { + this.apiBaseUrlValues[key] = value; + } + + // 找到所有基础URL状态元素 + Object.keys(apiBaseUrls).forEach(urlId => { + const statusElement = document.getElementById(`${urlId}Status`); + if (!statusElement) return; + + const value = apiBaseUrls[urlId]; + + if (value && value.trim() !== '') { + // 显示基础URL状态 - 已设置 + statusElement.className = 'key-status set'; + statusElement.innerHTML = ` 已设置`; + } else { + // 显示基础URL状态 - 未设置 + statusElement.className = 'key-status not-set'; + statusElement.innerHTML = ` 未设置`; + } + }); + } + + /** + * 保存单个API基础URL + * @param {string} urlType URL类型 + * @param {string} value URL值 + * @param {HTMLElement} urlStatus URL状态容器 + */ + async saveApiBaseUrl(urlType, value, urlStatus) { + try { + // 显示保存中状态 + const saveToast = this.createToast('正在保存API基础URL...', 'info', true); + + // 创建要保存的数据对象 + const apiBaseUrlsData = {}; + apiBaseUrlsData[urlType] = value; + + // 发送到服务器 + const response = await fetch('/api/base_urls', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(apiBaseUrlsData) + }); + + // 移除保存中提示 + if (saveToast) { + saveToast.remove(); + } + + if (response.ok) { + const result = await response.json(); + if (result.success) { + // 更新基础URL状态显示 + const statusElem = document.getElementById(`${urlType}Status`); + if (statusElem) { + if (value && value.trim() !== '') { + statusElem.className = 'key-status set'; + statusElem.innerHTML = ` 已设置`; + } else { + statusElem.className = 'key-status not-set'; + statusElem.innerHTML = ` 未设置`; + } + } + + // 保存到内存 + this.apiBaseUrlValues[urlType] = value; + + // 显示成功提示 + this.createToast('API基础URL已保存', 'success'); + } else { + this.createToast(`保存失败: ${result.message || '未知错误'}`, 'error'); + } + } else { + this.createToast('保存API基础URL失败', 'error'); + } + } catch (error) { + console.error('保存API基础URL错误:', error); + this.createToast(`保存失败: ${error.message || '未知错误'}`, 'error'); + } + } + + /** + * 初始化API基础URL编辑相关功能 + */ + initApiBaseUrlEditFunctions() { + // 1. 编辑按钮点击事件 + document.querySelectorAll('.edit-api-base-url').forEach(button => { + button.addEventListener('click', (e) => { + // 阻止事件冒泡 + e.stopPropagation(); + + const urlType = e.currentTarget.getAttribute('data-key-type'); + const urlStatus = e.currentTarget.closest('.key-status-wrapper'); + + if (urlStatus) { + // 隐藏显示区域 + const displayArea = urlStatus.querySelector('.key-display'); + if (displayArea) displayArea.classList.add('hidden'); + + // 显示编辑区域 + const editArea = urlStatus.querySelector('.key-edit'); + if (editArea) { + editArea.classList.remove('hidden'); + + // 获取当前URL值并填入输入框 + const urlInput = editArea.querySelector('.key-input'); + if (urlInput) { + // 从状态文本中获取当前值(如果不是"未设置") + const statusElement = urlStatus.querySelector('.key-status'); + if (statusElement && statusElement.textContent !== '未设置') { + urlInput.value = this.apiBaseUrlValues[urlType] || ''; + } else { + urlInput.value = ''; + } + + // 聚焦输入框 + setTimeout(() => { + urlInput.focus(); + }, 100); + } + } + } + }); + }); + + // 2. 保存按钮点击事件 + document.querySelectorAll('.save-api-base-url').forEach(button => { + button.addEventListener('click', (e) => { + // 阻止事件冒泡 + e.stopPropagation(); + + const urlType = e.currentTarget.getAttribute('data-key-type'); + const urlStatus = e.currentTarget.closest('.key-status-wrapper'); + + if (urlStatus) { + // 获取输入的新URL值 + const urlInput = urlStatus.querySelector('.key-input'); + if (urlInput) { + const newValue = urlInput.value.trim(); + + // 保存到服务器 + this.saveApiBaseUrl(urlType, newValue, urlStatus); + + // 隐藏编辑区域 + const editArea = urlStatus.querySelector('.key-edit'); + if (editArea) editArea.classList.add('hidden'); + + // 显示状态区域 + const displayArea = urlStatus.querySelector('.key-display'); + if (displayArea) displayArea.classList.remove('hidden'); + } + } + }); + }); + + // 3. 输入框按下Enter保存 + document.querySelectorAll('#apiBaseUrlsList .key-input').forEach(input => { + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + // 阻止事件冒泡 + e.stopPropagation(); + + const saveButton = e.currentTarget.closest('.key-edit').querySelector('.save-api-base-url'); + if (saveButton) { + saveButton.click(); + } + } else if (e.key === 'Escape') { + // 阻止事件冒泡 + e.stopPropagation(); + + // 取消编辑 + const urlStatus = e.currentTarget.closest('.key-status-wrapper'); + if (urlStatus) { + const editArea = urlStatus.querySelector('.key-edit'); + if (editArea) editArea.classList.add('hidden'); + + const displayArea = urlStatus.querySelector('.key-display'); + if (displayArea) displayArea.classList.remove('hidden'); + } + } + }); + }); + } } // Export for use in other modules diff --git a/static/style.css b/static/style.css index 0084a9f..7ba0233 100644 --- a/static/style.css +++ b/static/style.css @@ -5323,3 +5323,41 @@ textarea, display: none; } } + +/* API基础URL设置区域 */ +.api-url-settings { + margin-bottom: 20px; +} + +.api-url-settings .collapsible-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px; + background-color: var(--bg-secondary); + border-radius: 4px; + cursor: pointer; + margin-bottom: 10px; + font-weight: 500; + transition: background-color 0.2s; +} + +.api-url-settings .collapsible-header:hover { + background-color: var(--bg-hover); +} + +.api-url-settings .collapsible-content { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease; +} + +.api-url-settings .collapsible-content.expanded { + max-height: 1000px; +} + +.api-url-settings small { + color: var(--text-muted); + font-weight: normal; + margin-left: 5px; +} diff --git a/templates/index.html b/templates/index.html index eab2900..5432c2e 100644 --- a/templates/index.html +++ b/templates/index.html @@ -472,6 +472,116 @@ + +