diff --git a/app.py b/app.py index ff00b97..ce43eee 100644 --- a/app.py +++ b/app.py @@ -32,6 +32,9 @@ socketio = SocketIO( # 添加配置文件路径 CONFIG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config') +# API密钥配置文件路径 +API_KEYS_FILE = os.path.join(CONFIG_DIR, 'api_keys.json') + # 跟踪用户生成任务的字典 generation_tasks = {} @@ -76,7 +79,11 @@ def create_model_instance(model_id, settings, is_reasoning=False): # 确定需要哪个API密钥 api_key_id = None - if "claude" in model_id.lower() or "anthropic" in model_id.lower(): + # 特殊情况:o3-mini使用OpenAI API密钥 + if model_id.lower() == "o3-mini": + api_key_id = "OpenaiApiKey" + # 其他Anthropic/Claude模型 + elif "claude" in model_id.lower() or "anthropic" in model_id.lower(): api_key_id = "AnthropicApiKey" elif any(keyword in model_id.lower() for keyword in ["gpt", "openai"]): api_key_id = "OpenaiApiKey" @@ -85,7 +92,13 @@ def create_model_instance(model_id, settings, is_reasoning=False): elif "qvq" in model_id.lower() or "alibaba" in model_id.lower() or "qwen" in model_id.lower(): api_key_id = "AlibabaApiKey" - api_key = api_keys.get(api_key_id) + # 首先尝试从本地配置获取API密钥 + api_key = get_api_key(api_key_id) + + # 如果本地没有配置,尝试使用前端传递的密钥(向后兼容) + if not api_key: + api_key = api_keys.get(api_key_id) + if not api_key: raise ValueError(f"API key is required for the selected model (keyId: {api_key_id})") @@ -266,7 +279,17 @@ def handle_text_extraction(data): if not isinstance(settings, dict): raise ValueError("Invalid settings format") - mathpix_key = settings.get('mathpixApiKey') + # 尝试从本地配置获取Mathpix API密钥 + mathpix_app_id = get_api_key('MathpixAppId') + mathpix_app_key = get_api_key('MathpixAppKey') + + # 构建完整的Mathpix API密钥(格式:app_id:app_key) + mathpix_key = f"{mathpix_app_id}:{mathpix_app_key}" if mathpix_app_id and mathpix_app_key else None + + # 如果本地没有配置,尝试使用前端传递的密钥(向后兼容) + if not mathpix_key: + mathpix_key = settings.get('mathpixApiKey') + if not mathpix_key: raise ValueError("Mathpix API key is required") @@ -624,6 +647,232 @@ def get_models(): models = ModelFactory.get_available_models() return jsonify(models) +# 获取所有API密钥 +@app.route('/api/keys', methods=['GET']) +def get_api_keys(): + """获取所有API密钥""" + api_keys = load_api_keys() + return jsonify(api_keys) + +# 保存API密钥 +@app.route('/api/keys', methods=['POST']) +def update_api_keys(): + """更新API密钥配置""" + try: + new_keys = request.json + if not isinstance(new_keys, dict): + return jsonify({"success": False, "message": "无效的API密钥格式"}), 400 + + # 加载当前密钥 + current_keys = load_api_keys() + + # 更新密钥 + for key, value in new_keys.items(): + current_keys[key] = value + + # 保存回文件 + if save_api_keys(current_keys): + 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 + +# 验证API密钥 +@app.route('/api/keys/validate', methods=['POST']) +def validate_api_key(): + """验证API密钥有效性""" + try: + validate_data = request.json + if not isinstance(validate_data, dict) or 'key_type' not in validate_data or 'key_value' not in validate_data: + return jsonify({"success": False, "message": "无效的请求格式"}), 400 + + key_type = validate_data['key_type'] + key_value = validate_data['key_value'] + + if not key_value or key_value.strip() == "": + return jsonify({"success": False, "message": f"未提供{key_type}密钥"}), 400 + + # 基于密钥类型进行验证 + result = {"success": False, "message": "不支持的密钥类型"} + + if key_type == "AnthropicApiKey": + result = validate_anthropic_key(key_value) + elif key_type == "OpenaiApiKey": + result = validate_openai_key(key_value) + elif key_type == "DeepseekApiKey": + result = validate_deepseek_key(key_value) + elif key_type == "AlibabaApiKey": + result = validate_alibaba_key(key_value) + elif key_type == "MathpixAppId" or key_type == "MathpixAppKey": + # Mathpix需要两个密钥一起验证 + mathpix_app_id = key_value if key_type == "MathpixAppId" else get_api_key("MathpixAppId") + mathpix_app_key = key_value if key_type == "MathpixAppKey" else get_api_key("MathpixAppKey") + if mathpix_app_id and mathpix_app_key: + result = validate_mathpix_key(f"{mathpix_app_id}:{mathpix_app_key}") + else: + result = {"success": False, "message": "需要同时设置Mathpix App ID和App Key"} + + return jsonify(result) + + except Exception as e: + return jsonify({"success": False, "message": f"验证API密钥时出错: {str(e)}"}), 500 + +def validate_anthropic_key(api_key): + """验证Anthropic API密钥""" + try: + import anthropic + client = anthropic.Anthropic(api_key=api_key) + # 发送一个简单请求来验证密钥 + response = client.messages.create( + model="claude-3-haiku-20240307", + max_tokens=10, + messages=[{"role": "user", "content": "Hello"}] + ) + return {"success": True, "message": "Anthropic API密钥有效"} + except Exception as e: + error_message = str(e) + if "401" in error_message or "unauthorized" in error_message.lower(): + return {"success": False, "message": "Anthropic API密钥无效或已过期"} + return {"success": False, "message": f"验证Anthropic API密钥时出错: {error_message}"} + +def validate_openai_key(api_key): + """验证OpenAI API密钥""" + try: + import openai + client = openai.OpenAI(api_key=api_key) + # 发送一个简单请求来验证密钥 + response = client.chat.completions.create( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=10 + ) + return {"success": True, "message": "OpenAI API密钥有效"} + except Exception as e: + error_message = str(e) + if "401" in error_message or "invalid" in error_message.lower(): + return {"success": False, "message": "OpenAI API密钥无效或已过期"} + return {"success": False, "message": f"验证OpenAI API密钥时出错: {error_message}"} + +def validate_deepseek_key(api_key): + """验证DeepSeek API密钥""" + try: + import requests + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}" + } + data = { + "model": "deepseek-chat", + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 10 + } + response = requests.post( + "https://api.deepseek.com/v1/chat/completions", + headers=headers, + json=data, + timeout=10 + ) + if response.status_code == 200: + return {"success": True, "message": "DeepSeek API密钥有效"} + else: + error_message = response.json().get("error", {}).get("message", "未知错误") + return {"success": False, "message": f"DeepSeek API密钥无效: {error_message}"} + except Exception as e: + return {"success": False, "message": f"验证DeepSeek API密钥时出错: {str(e)}"} + +def validate_alibaba_key(api_key): + """验证阿里巴巴DashScope API密钥""" + try: + import dashscope + dashscope.api_key = api_key + response = dashscope.Generation.call( + model='qwen-vl-plus', + messages=[{'role': 'user', 'content': 'Hello'}], + result_format='message', + max_tokens=10 + ) + if response.status_code == 200: + return {"success": True, "message": "阿里巴巴API密钥有效"} + else: + error_message = response.message + return {"success": False, "message": f"阿里巴巴API密钥无效: {error_message}"} + except Exception as e: + error_message = str(e) + if "unauthorized" in error_message.lower() or "invalid" in error_message.lower(): + return {"success": False, "message": "阿里巴巴API密钥无效或已过期"} + return {"success": False, "message": f"验证阿里巴巴API密钥时出错: {error_message}"} + +def validate_mathpix_key(api_key): + """验证Mathpix API密钥""" + try: + import requests + # 分解API密钥 + app_id, app_key = api_key.split(":") + + headers = { + "app_id": app_id, + "app_key": app_key, + "Content-Type": "application/json" + } + # 构造一个简单的请求(只检查API密钥,不实际发送图像) + response = requests.get( + "https://api.mathpix.com/v3/app-setting", + headers=headers, + timeout=10 + ) + if response.status_code == 200: + return {"success": True, "message": "Mathpix API密钥有效"} + else: + error_message = response.json().get("error", "未知错误") + return {"success": False, "message": f"Mathpix API密钥无效: {error_message}"} + except Exception as e: + return {"success": False, "message": f"验证Mathpix API密钥时出错: {str(e)}"} + +# 加载API密钥配置 +def load_api_keys(): + """从配置文件加载API密钥""" + try: + if os.path.exists(API_KEYS_FILE): + with open(API_KEYS_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + else: + # 如果文件不存在,创建默认配置 + default_keys = { + "AnthropicApiKey": "", + "OpenaiApiKey": "", + "DeepseekApiKey": "", + "AlibabaApiKey": "", + "MathpixAppId": "", + "MathpixAppKey": "" + } + save_api_keys(default_keys) + return default_keys + except Exception as e: + print(f"加载API密钥配置失败: {e}") + return {} + +# 保存API密钥配置 +def save_api_keys(api_keys): + """保存API密钥到配置文件""" + try: + # 确保配置目录存在 + os.makedirs(os.path.dirname(API_KEYS_FILE), exist_ok=True) + + with open(API_KEYS_FILE, 'w', encoding='utf-8') as f: + json.dump(api_keys, f, ensure_ascii=False, indent=2) + return True + except Exception as e: + print(f"保存API密钥配置失败: {e}") + return False + +# 获取特定API密钥 +def get_api_key(key_name): + """获取指定的API密钥""" + api_keys = load_api_keys() + return api_keys.get(key_name, "") + if __name__ == '__main__': local_ip = get_local_ip() print(f"Local IP Address: {local_ip}") diff --git a/models/alibaba.py b/models/alibaba.py index d214b12..e1e2b29 100644 --- a/models/alibaba.py +++ b/models/alibaba.py @@ -5,7 +5,9 @@ from .base import BaseModel class AlibabaModel(BaseModel): def __init__(self, api_key: str, temperature: float = 0.7, system_prompt: str = None, language: str = None, model_name: str = None): - self.model_name = model_name or "QVQ-Max-2025-03-25" # 默认使用QVQ-Max模型 + # 如果没有提供模型名称,才使用默认值 + self.model_name = model_name if model_name else "QVQ-Max-2025-03-25" + print(f"初始化阿里巴巴模型: {self.model_name}") # 在super().__init__之前设置model_name,这样get_default_system_prompt能使用它 super().__init__(api_key, temperature, system_prompt, language) @@ -36,22 +38,35 @@ class AlibabaModel(BaseModel): "qwen-vl-max-latest": "qwen-vl-max", # 修正为正确的API标识符 } + print(f"模型名称: {self.model_name}") + # 从模型映射表中获取模型标识符,如果不存在则使用默认值 model_id = model_mapping.get(self.model_name) if model_id: + print(f"从映射表中获取到模型标识符: {model_id}") return model_id # 如果没有精确匹配,检查是否包含特定前缀 - if self.model_name and "qwen-vl" in self.model_name: - if "max" in self.model_name: + if self.model_name and "qwen-vl" in self.model_name.lower(): + if "max" in self.model_name.lower(): + print(f"识别为qwen-vl-max模型") return "qwen-vl-max" - elif "plus" in self.model_name: + elif "plus" in self.model_name.lower(): + print(f"识别为qwen-vl-plus模型") return "qwen-vl-plus" - elif "lite" in self.model_name: + elif "lite" in self.model_name.lower(): + print(f"识别为qwen-vl-lite模型") return "qwen-vl-lite" + print(f"默认使用qwen-vl-max模型") return "qwen-vl-max" # 默认使用最强版本 + + # 如果包含QVQ或alibaba关键词,默认使用qvq-max + if self.model_name and ("qvq" in self.model_name.lower() or "alibaba" in self.model_name.lower()): + print(f"识别为QVQ模型,使用qvq-max") + return "qvq-max" # 最后的默认值 + print(f"警告:无法识别的模型名称 {self.model_name},默认使用qvq-max") return "qvq-max" def analyze_text(self, text: str, proxies: dict = None) -> Generator[dict, None, None]: @@ -107,7 +122,8 @@ class AlibabaModel(BaseModel): is_answering = False # 检查是否为通义千问VL模型(不支持reasoning_content) - is_qwen_vl = "qwen-vl" in self.get_model_identifier() + is_qwen_vl = "qwen-vl" in self.get_model_identifier().lower() + print(f"分析文本使用模型标识符: {self.get_model_identifier()}, 是否为千问VL模型: {is_qwen_vl}") for chunk in response: if not chunk.choices: @@ -237,7 +253,8 @@ class AlibabaModel(BaseModel): is_answering = False # 检查是否为通义千问VL模型(不支持reasoning_content) - is_qwen_vl = "qwen-vl" in self.get_model_identifier() + is_qwen_vl = "qwen-vl" in self.get_model_identifier().lower() + print(f"分析图像使用模型标识符: {self.get_model_identifier()}, 是否为千问VL模型: {is_qwen_vl}") for chunk in response: if not chunk.choices: diff --git a/models/anthropic.py b/models/anthropic.py index bec2f4a..19d98fc 100644 --- a/models/anthropic.py +++ b/models/anthropic.py @@ -56,7 +56,7 @@ class AnthropicModel(BaseModel): # 处理推理配置 if hasattr(self, 'reasoning_config') and self.reasoning_config: # 如果设置了extended reasoning - if self.reasoning_config.get('reasoning_depth') == 'extended': + if self.reasoning_config.get('reasoning_depth') == 'extended': think_budget = self.reasoning_config.get('think_budget', max_tokens // 2) payload['thinking'] = { 'type': 'enabled', @@ -64,7 +64,6 @@ class AnthropicModel(BaseModel): } # 如果设置了instant模式 elif self.reasoning_config.get('speed_mode') == 'instant': - payload['speed_mode'] = 'instant' # 确保当使用speed_mode时不包含thinking参数 if 'thinking' in payload: del payload['thinking'] diff --git a/models/deepseek.py b/models/deepseek.py index 6fc53bd..fb18522 100644 --- a/models/deepseek.py +++ b/models/deepseek.py @@ -23,8 +23,22 @@ class DeepSeekModel(BaseModel): # 通过模型名称来确定实际的API调用标识符 if self.model_name == "deepseek-chat": return "deepseek-chat" - # deepseek-reasoner是默认的推理模型名称 - return "deepseek-reasoner" + # 如果是deepseek-reasoner或包含reasoner的模型名称,返回推理模型标识符 + if "reasoner" in self.model_name.lower(): + return "deepseek-reasoner" + # 对于deepseek-chat也返回对应的模型名称 + if "chat" in self.model_name.lower() or self.model_name == "deepseek-chat": + return "deepseek-chat" + + # 根据配置中的模型ID来确定实际的模型类型 + if self.model_name == "deepseek-reasoner": + return "deepseek-reasoner" + elif self.model_name == "deepseek-chat": + return "deepseek-chat" + + # 默认使用deepseek-chat作为API标识符 + print(f"未知的DeepSeek模型名称: {self.model_name},使用deepseek-chat作为默认值") + return "deepseek-chat" def analyze_text(self, text: str, proxies: dict = None) -> Generator[dict, None, None]: """Stream DeepSeek's response for text analysis""" @@ -75,10 +89,10 @@ class DeepSeekModel(BaseModel): } # 只有非推理模型才设置temperature参数 - if not self.model_name.endswith('reasoner') and self.temperature is not None: + if not self.get_model_identifier().endswith('reasoner') and self.temperature is not None: params["temperature"] = self.temperature - print(f"调用DeepSeek API: {self.get_model_identifier()}, 是否设置温度: {not self.model_name.endswith('reasoner')}") + print(f"调用DeepSeek API: {self.get_model_identifier()}, 是否设置温度: {not self.get_model_identifier().endswith('reasoner')}, 温度值: {self.temperature if not self.get_model_identifier().endswith('reasoner') else 'N/A'}") response = client.chat.completions.create(**params) @@ -253,7 +267,7 @@ class DeepSeekModel(BaseModel): } # 只有非推理模型才设置temperature参数 - if not self.model_name.endswith('reasoner') and self.temperature is not None: + if not self.get_model_identifier().endswith('reasoner') and self.temperature is not None: params["temperature"] = self.temperature response = client.chat.completions.create(**params) diff --git a/models/factory.py b/models/factory.py index 38b34b8..7019265 100644 --- a/models/factory.py +++ b/models/factory.py @@ -98,36 +98,26 @@ class ModelFactory: @classmethod def create_model(cls, model_name: str, api_key: str, temperature: float = 0.7, system_prompt: str = None, language: str = None) -> BaseModel: """ - Create and return an instance of the specified model. + Create a model instance based on the model name. Args: - model_name: The identifier of the model to create - api_key: The API key for the model - temperature: Optional temperature parameter for response generation - system_prompt: Optional custom system prompt - language: Optional language preference for responses + model_name: The identifier for the model + api_key: The API key for the model service + temperature: The temperature to use for generation + system_prompt: The system prompt to use + language: The preferred language for responses Returns: - An instance of the specified model - - Raises: - ValueError: If the model_name is not recognized + A model instance """ - model_info = cls._models.get(model_name) - if not model_info: + if model_name not in cls._models: raise ValueError(f"Unknown model: {model_name}") - + + model_info = cls._models[model_name] model_class = model_info['class'] - # 对于Mathpix模型,不传递language参数 - if model_name == 'mathpix': - return model_class( - api_key=api_key, - temperature=temperature, - system_prompt=system_prompt - ) - else: - # 对于所有其他模型,传递model_name参数 + # 对于DeepSeek模型,需要传递正确的模型名称 + if 'deepseek' in model_name.lower(): return model_class( api_key=api_key, temperature=temperature, @@ -135,6 +125,30 @@ class ModelFactory: language=language, model_name=model_name ) + # 对于阿里巴巴模型,也需要传递正确的模型名称 + elif 'qwen' in model_name.lower() or 'qvq' in model_name.lower() or 'alibaba' in model_name.lower(): + return model_class( + api_key=api_key, + temperature=temperature, + system_prompt=system_prompt, + language=language, + model_name=model_name + ) + # 对于Mathpix模型,不传递language参数 + elif model_name == 'mathpix': + return model_class( + api_key=api_key, + temperature=temperature, + system_prompt=system_prompt + ) + else: + # 其他模型仅传递标准参数 + return model_class( + api_key=api_key, + temperature=temperature, + system_prompt=system_prompt, + language=language + ) @classmethod def get_available_models(cls) -> list[Dict[str, Any]]: diff --git a/static/js/main.js b/static/js/main.js index 5061ecc..bc2c15c 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -1010,7 +1010,7 @@ class SnapSolver { if (!mathpixApiKey || mathpixApiKey === ':') { window.uiManager.showToast('请在设置中输入Mathpix API凭据', 'error'); - document.getElementById('settingsPanel').classList.remove('hidden'); + document.getElementById('settingsPanel').classList.add('active'); this.extractTextBtn.disabled = false; this.extractTextBtn.innerHTML = '提取文本'; return; @@ -1282,14 +1282,34 @@ class SnapSolver { } } - initialize() { + async initialize() { console.log('Initializing SnapSolver...'); // 初始化managers - window.uiManager = new UIManager(); + // 确保UIManager已经初始化,如果没有,等待它初始化 + if (!window.uiManager) { + console.log('等待UI管理器初始化...'); + window.uiManager = new UIManager(); + // 给UIManager一些时间初始化 + await new Promise(resolve => setTimeout(resolve, 100)); + } + window.settingsManager = new SettingsManager(); window.app = this; // 便于从其他地方访问 + // 等待SettingsManager初始化完成 + if (window.settingsManager) { + // 如果settingsManager还没初始化完成,等待它 + if (!window.settingsManager.isInitialized) { + console.log('等待设置管理器初始化完成...'); + // 最多等待5秒 + for (let i = 0; i < 50; i++) { + if (window.settingsManager.isInitialized) break; + await new Promise(resolve => setTimeout(resolve, 100)); + } + } + } + // 初始化Markdown工具 this.initializeMarkdownTools(); @@ -1554,9 +1574,9 @@ class SnapSolver { !e.target.closest('#settingsPanel') && !e.target.matches('#settingsToggle') && !e.target.closest('#settingsToggle') && - !document.getElementById('settingsPanel').classList.contains('hidden') + document.getElementById('settingsPanel').classList.contains('active') ) { - document.getElementById('settingsPanel').classList.add('hidden'); + document.getElementById('settingsPanel').classList.remove('active'); } // 检查是否点击在Claude面板、分析按钮或其子元素之外 @@ -1654,10 +1674,11 @@ class SnapSolver { } // Initialize the application when the DOM is loaded -document.addEventListener('DOMContentLoaded', () => { +document.addEventListener('DOMContentLoaded', async () => { try { console.log('Initializing application...'); window.app = new SnapSolver(); + await window.app.initialize(); console.log('Application initialized successfully'); } catch (error) { console.error('Failed to initialize application:', error); diff --git a/static/js/settings.js b/static/js/settings.js index c2f0ee0..c3e7721 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -8,28 +8,42 @@ class SettingsManager { this.initializeElements(); // 加载模型配置 - this.loadModelConfig() - .then(() => { + this.isInitialized = false; + this.initialize(); + } + + async initialize() { + try { + // 加载模型配置 + await this.loadModelConfig(); + // 成功加载配置后,执行后续初始化 this.updateModelOptions(); - this.loadSettings(); + await this.loadSettings(); this.setupEventListeners(); this.updateUIBasedOnModelType(); - }) - .catch(error => { - console.error('加载模型配置失败:', error); + + // 初始化可折叠内容逻辑 + this.initCollapsibleContent(); + + this.isInitialized = true; + console.log('设置管理器初始化完成'); + } catch (error) { + console.error('初始化设置管理器失败:', error); window.uiManager?.showToast('加载模型配置失败,使用默认配置', 'error'); // 使用默认配置作为备份 this.setupDefaultModels(); this.updateModelOptions(); - this.loadSettings(); + await this.loadSettings(); this.setupEventListeners(); this.updateUIBasedOnModelType(); - }); // 初始化可折叠内容逻辑 this.initCollapsibleContent(); + + this.isInitialized = true; + } } // 从配置文件加载模型定义 @@ -186,6 +200,16 @@ class SettingsManager { 'mathpixAppKey': this.mathpixAppKeyInput }; + // API密钥状态显示相关元素 + this.apiKeysList = document.getElementById('apiKeysList'); + + // 防止API密钥区域的点击事件冒泡 + if (this.apiKeysList) { + this.apiKeysList.addEventListener('click', (e) => { + e.stopPropagation(); + }); + } + // Settings toggle elements this.settingsToggle = document.getElementById('settingsToggle'); this.closeSettings = document.getElementById('closeSettings'); @@ -205,6 +229,58 @@ class SettingsManager { } }); }); + + // Initialize API key validate buttons + document.querySelectorAll('.validate-api-key').forEach(button => { + button.addEventListener('click', (e) => { + const keyType = e.currentTarget.getAttribute('data-key-type'); + const input = e.currentTarget.closest('.input-group').querySelector('input'); + const keyValue = input.value; + + if (keyValue.trim() === '') { + window.uiManager?.showToast('请先输入API密钥', 'warning'); + return; + } + + // 显示验证中状态 + const icon = e.currentTarget.querySelector('i'); + const originalClass = icon.className; + icon.className = 'fas fa-spinner fa-spin'; + e.currentTarget.disabled = true; + + this.validateApiKey(keyType, keyValue) + .then(result => { + if (result.success) { + window.uiManager?.showToast(result.message, 'success'); + // 更新状态显示 + this.saveSettings(); + } else { + window.uiManager?.showToast(result.message, 'error'); + } + }) + .catch(error => { + window.uiManager?.showToast(`验证失败: ${error.message}`, 'error'); + }) + .finally(() => { + // 恢复按钮状态 + icon.className = originalClass; + e.currentTarget.disabled = false; + }); + }); + }); + + // 存储API密钥的对象 + this.apiKeyValues = { + 'AnthropicApiKey': '', + 'OpenaiApiKey': '', + 'DeepseekApiKey': '', + 'AlibabaApiKey': '', + 'MathpixAppId': '', + 'MathpixAppKey': '' + }; + + // 初始化密钥编辑功能 + this.initApiKeyEditFunctions(); } // 更新模型选择下拉框 @@ -250,27 +326,16 @@ class SettingsManager { } } - loadSettings() { + async loadSettings() { + try { + // 先从localStorage加载大部分设置 const settings = JSON.parse(localStorage.getItem('aiSettings') || '{}'); - // Load Mathpix credentials - if (settings.mathpixAppId) { - this.mathpixAppIdInput.value = settings.mathpixAppId; - } - if (settings.mathpixAppKey) { - this.mathpixAppKeyInput.value = settings.mathpixAppKey; - } - - // Load API keys - if (settings.apiKeys) { - Object.entries(settings.apiKeys).forEach(([keyId, value]) => { - const input = this.apiKeyInputs[keyId]; - if (input) { - input.value = value; - } - }); - } - + // 刷新API密钥状态(自动从服务器获取最新状态) + await this.refreshApiKeyStatus(); + console.log('已自动刷新API密钥状态'); + + // 加载其他设置 // Load model selection if (settings.model && this.modelExists(settings.model)) { this.modelSelect.value = settings.model; @@ -329,6 +394,11 @@ class SettingsManager { // Update UI based on model type this.updateUIBasedOnModelType(); + + } catch (error) { + console.error('加载设置出错:', error); + window.uiManager?.showToast('加载设置出错', 'error'); + } } modelExists(modelId) { @@ -356,21 +426,17 @@ class SettingsManager { } } - updateVisibleApiKey(selectedModel) { - const modelInfo = this.modelDefinitions[selectedModel]; - if (!modelInfo) return; - - // 仅更新模型版本显示 - this.updateModelVersionDisplay(selectedModel); - - // 不再需要高亮API密钥 - // 这里我们不再进行API密钥的高亮处理 - } - + /** + * 根据选择的模型类型更新UI显示 + */ updateUIBasedOnModelType() { + // 更新UI元素显示,根据所选模型类型 const selectedModel = this.modelSelect.value; const modelInfo = this.modelDefinitions[selectedModel]; + // 更新当前可见的API密钥 + this.updateVisibleApiKey(selectedModel); + if (!modelInfo) return; // 处理温度设置显示 @@ -404,11 +470,47 @@ class SettingsManager { } } - saveSettings() { + /** + * 根据选择的模型更新显示的API密钥 + * @param {string} modelType 模型类型 + */ + updateVisibleApiKey(modelType) { + // 获取所有API密钥状态元素 + const allApiKeys = document.querySelectorAll('.api-key-status'); + + // 首先隐藏所有API密钥 + allApiKeys.forEach(key => { + key.classList.remove('highlight'); + }); + + // 根据当前选择的模型类型,突出显示对应的API密钥 + let apiKeyToHighlight = null; + + if (modelType.startsWith('claude')) { + apiKeyToHighlight = document.querySelector('.api-key-status:nth-child(1)'); // Anthropic + } else if (modelType.startsWith('gpt')) { + apiKeyToHighlight = document.querySelector('.api-key-status:nth-child(2)'); // OpenAI + } else if (modelType.startsWith('deepseek')) { + apiKeyToHighlight = document.querySelector('.api-key-status:nth-child(3)'); // DeepSeek + } else if (modelType.startsWith('qwen')) { + apiKeyToHighlight = document.querySelector('.api-key-status:nth-child(4)'); // Alibaba + } + + // 高亮显示对应的API密钥 + if (apiKeyToHighlight) { + apiKeyToHighlight.classList.add('highlight'); + // 延迟一秒后滚动到该元素 + setTimeout(() => { + apiKeyToHighlight.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 300); + } + } + + async saveSettings() { + try { + // 保存UI设置到localStorage(不包含API密钥) const settings = { - apiKeys: {}, - mathpixAppId: this.mathpixAppIdInput.value, - mathpixAppKey: this.mathpixAppKeyInput.value, + apiKeys: this.apiKeyValues, // 保存到localStorage(向后兼容) model: this.modelSelect.value, maxTokens: this.maxTokensInput.value, reasoningDepth: this.reasoningDepthSelect?.value || 'standard', @@ -421,15 +523,14 @@ class SettingsManager { proxyPort: this.proxyPortInput.value }; - // Save all API keys - Object.entries(this.apiKeyInputs).forEach(([keyId, input]) => { - if (input.value) { - settings.apiKeys[keyId] = input.value; - } - }); - + // 保存设置到localStorage localStorage.setItem('aiSettings', JSON.stringify(settings)); - window.showToast('Settings saved successfully'); + + window.uiManager?.showToast('设置已保存', 'success'); + } catch (error) { + console.error('保存设置出错:', error); + window.uiManager?.showToast('保存设置出错: ' + error.message, 'error'); + } } getApiKey() { @@ -482,6 +583,11 @@ class SettingsManager { } } + // 从apiKeyValues获取Mathpix信息,而不是直接从DOM读取 + const mathpixAppId = this.apiKeyValues['MathpixAppId'] || ''; + const mathpixAppKey = this.apiKeyValues['MathpixAppKey'] || ''; + const mathpixApiKey = mathpixAppId && mathpixAppKey ? `${mathpixAppId}:${mathpixAppKey}` : ''; + return { model: selectedModel, maxTokens: maxTokens, @@ -491,7 +597,7 @@ class SettingsManager { proxyEnabled: this.proxyEnabledInput.checked, proxyHost: this.proxyHostInput.value, proxyPort: this.proxyPortInput.value, - mathpixApiKey: `${this.mathpixAppIdInput.value}:${this.mathpixAppKeyInput.value}`, + mathpixApiKey: mathpixApiKey, modelInfo: { supportsMultimodal: modelInfo.supportsMultimodal || false, isReasoning: modelInfo.isReasoning || false, @@ -512,15 +618,6 @@ class SettingsManager { } setupEventListeners() { - // Save settings on change - Object.values(this.apiKeyInputs).forEach(input => { - input.addEventListener('change', () => this.saveSettings()); - }); - - // Save Mathpix settings on change - this.mathpixAppIdInput.addEventListener('change', () => this.saveSettings()); - this.mathpixAppKeyInput.addEventListener('change', () => this.saveSettings()); - this.modelSelect.addEventListener('change', (e) => { this.updateVisibleApiKey(e.target.value); this.updateUIBasedOnModelType(); @@ -536,6 +633,9 @@ class SettingsManager { // 最大Token输入框事件处理 if (this.maxTokensInput) { this.maxTokensInput.addEventListener('change', (e) => { + // 阻止事件冒泡 + e.stopPropagation(); + // 验证输入值在有效范围内 let value = parseInt(e.target.value); if (isNaN(value)) value = 8192; @@ -551,7 +651,10 @@ class SettingsManager { // 推理深度选择事件处理 if (this.reasoningDepthSelect) { - this.reasoningDepthSelect.addEventListener('change', () => { + this.reasoningDepthSelect.addEventListener('change', (e) => { + // 阻止事件冒泡 + e.stopPropagation(); + // 更新思考预算组的可见性 if (this.thinkBudgetGroup) { const showThinkBudget = this.reasoningDepthSelect.value === 'extended'; @@ -564,6 +667,9 @@ class SettingsManager { // 思考预算占比滑块事件处理 if (this.thinkBudgetPercentInput && this.thinkBudgetPercentValue) { this.thinkBudgetPercentInput.addEventListener('input', (e) => { + // 阻止事件冒泡 + e.stopPropagation(); + // 更新思考预算显示 this.updateThinkBudgetDisplay(); @@ -575,29 +681,78 @@ class SettingsManager { } this.temperatureInput.addEventListener('input', (e) => { + // 阻止事件冒泡 + e.stopPropagation(); + this.temperatureValue.textContent = e.target.value; this.updateRangeSliderBackground(e.target); this.saveSettings(); }); - this.systemPromptInput.addEventListener('change', () => this.saveSettings()); - this.languageInput.addEventListener('change', () => this.saveSettings()); + this.systemPromptInput.addEventListener('change', (e) => { + // 阻止事件冒泡 + e.stopPropagation(); + this.saveSettings(); + }); + + this.languageInput.addEventListener('change', (e) => { + // 阻止事件冒泡 + e.stopPropagation(); + this.saveSettings(); + }); + this.proxyEnabledInput.addEventListener('change', (e) => { + // 阻止事件冒泡 + e.stopPropagation(); + this.proxySettings.style.display = e.target.checked ? 'block' : 'none'; this.saveSettings(); }); - this.proxyHostInput.addEventListener('change', () => this.saveSettings()); - this.proxyPortInput.addEventListener('change', () => this.saveSettings()); + + this.proxyHostInput.addEventListener('change', (e) => { + // 阻止事件冒泡 + e.stopPropagation(); + this.saveSettings(); + }); + + this.proxyPortInput.addEventListener('change', (e) => { + // 阻止事件冒泡 + e.stopPropagation(); + this.saveSettings(); + }); // Panel visibility this.settingsToggle.addEventListener('click', () => { - window.closeAllPanels(); - this.settingsPanel.classList.toggle('hidden'); + this.toggleSettingsPanel(); }); this.closeSettings.addEventListener('click', () => { - this.settingsPanel.classList.add('hidden'); + this.closeSettingsPanel(); }); + + // 确保设置面板自身的点击不会干扰内部操作 + if (this.settingsPanel) { + const settingsSections = this.settingsPanel.querySelectorAll('.settings-section'); + settingsSections.forEach(section => { + section.addEventListener('click', (e) => { + // 只阻止直接点击设置部分的事件 + if (e.target === section) { + e.stopPropagation(); + } + }); + }); + + // 设置内容区域防止冒泡 + const settingsContent = this.settingsPanel.querySelector('.settings-content'); + if (settingsContent) { + settingsContent.addEventListener('click', (e) => { + // 只阻止直接点击设置内容区域的事件 + if (e.target === settingsContent) { + e.stopPropagation(); + } + }); + } + } } // 辅助方法:更新滑块的背景颜色 @@ -628,26 +783,356 @@ class SettingsManager { * 初始化可折叠内容的交互逻辑 */ initCollapsibleContent() { - // 获取API密钥折叠切换按钮和内容 - const apiKeysToggle = document.getElementById('apiKeysCollapseToggle'); - const apiKeysContent = document.getElementById('apiKeysContent'); - - // 添加点击事件以切换折叠状态 - if (apiKeysToggle && apiKeysContent) { - apiKeysToggle.addEventListener('click', () => { - // 切换折叠状态 - apiKeysContent.classList.toggle('collapsed'); + // 在新的实现中,我们不再需要折叠API密钥区域,因为所有功能都在同一区域完成 + console.log('初始化API密钥编辑功能完成'); + } + + /** + * 初始化API密钥编辑相关功能 + */ + initApiKeyEditFunctions() { + // 1. 编辑按钮点击事件 + document.querySelectorAll('.edit-api-key').forEach(button => { + button.addEventListener('click', (e) => { + // 阻止事件冒泡 + e.stopPropagation(); - // 更新图标方向 - const icon = apiKeysToggle.querySelector('.fa-chevron-down, .fa-chevron-up'); - if (icon) { - if (apiKeysContent.classList.contains('collapsed')) { - icon.classList.replace('fa-chevron-up', 'fa-chevron-down'); - } else { - icon.classList.replace('fa-chevron-down', 'fa-chevron-up'); + const keyType = e.currentTarget.getAttribute('data-key-type'); + const keyStatus = e.currentTarget.closest('.key-status-wrapper'); + + if (keyStatus) { + // 隐藏显示区域 + const displayArea = keyStatus.querySelector('.key-display'); + if (displayArea) displayArea.classList.add('hidden'); + + // 显示编辑区域 + const editArea = keyStatus.querySelector('.key-edit'); + if (editArea) { + editArea.classList.remove('hidden'); + + // 获取当前密钥值并填入输入框 + const keyInput = editArea.querySelector('.key-input'); + if (keyInput) { + // 从状态文本中获取当前值(如果不是"未设置") + const statusElement = keyStatus.querySelector('.key-status'); + if (statusElement && statusElement.textContent !== '未设置') { + keyInput.value = this.apiKeyValues[keyType] || ''; + } else { + keyInput.value = ''; + } + + // 聚焦输入框 + setTimeout(() => { + keyInput.focus(); + }, 100); + } } } }); + }); + + // 2. 保存按钮点击事件 + document.querySelectorAll('.save-api-key').forEach(button => { + button.addEventListener('click', (e) => { + // 阻止事件冒泡 + e.stopPropagation(); + + const keyType = e.currentTarget.getAttribute('data-key-type'); + const keyStatus = e.currentTarget.closest('.key-status-wrapper'); + + if (keyStatus) { + // 获取输入的新密钥值 + const keyInput = keyStatus.querySelector('.key-input'); + if (keyInput) { + const newValue = keyInput.value.trim(); + + // 保存到内存中 + this.apiKeyValues[keyType] = newValue; + + // 创建要保存的API密钥对象 + const apiKeysToSave = {}; + apiKeysToSave[keyType] = newValue; + + // 保存到服务器 + this.saveApiKey(keyType, newValue, keyStatus); + + // 隐藏编辑区域 + const editArea = keyStatus.querySelector('.key-edit'); + if (editArea) editArea.classList.add('hidden'); + + // 显示状态区域 + const displayArea = keyStatus.querySelector('.key-display'); + if (displayArea) displayArea.classList.remove('hidden'); + } + } + }); + }); + + // 3. 切换密码可见性按钮 + document.querySelectorAll('.toggle-visibility').forEach(button => { + button.addEventListener('click', (e) => { + // 阻止事件冒泡 + e.stopPropagation(); + + const keyInput = e.currentTarget.closest('.key-edit').querySelector('.key-input'); + if (keyInput) { + const type = keyInput.type === 'password' ? 'text' : 'password'; + keyInput.type = type; + + // 更新图标 + const icon = e.currentTarget.querySelector('i'); + if (icon) { + icon.className = `fas fa-${type === 'password' ? 'eye' : 'eye-slash'}`; + } + } + }); + }); + + // 4. 输入框按下Enter保存 + document.querySelectorAll('.key-input').forEach(input => { + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + // 阻止事件冒泡 + e.stopPropagation(); + + const saveButton = e.currentTarget.closest('.key-edit').querySelector('.save-api-key'); + if (saveButton) { + saveButton.click(); + } + } else if (e.key === 'Escape') { + // 阻止事件冒泡 + e.stopPropagation(); + + // 取消编辑 + const keyStatus = e.currentTarget.closest('.key-status-wrapper'); + if (keyStatus) { + const editArea = keyStatus.querySelector('.key-edit'); + if (editArea) editArea.classList.add('hidden'); + + const displayArea = keyStatus.querySelector('.key-display'); + if (displayArea) displayArea.classList.remove('hidden'); + } + } + }); + }); + } + + /** + * 更新API密钥状态显示 + * @param {Object} apiKeys 密钥对象 + */ + updateApiKeyStatus(apiKeys) { + if (!this.apiKeysList) return; + + // 保存API密钥值到内存中 + for (const [key, value] of Object.entries(apiKeys)) { + this.apiKeyValues[key] = value; + } + + // 找到所有密钥状态元素 + Object.keys(apiKeys).forEach(keyId => { + const statusElement = document.getElementById(`${keyId}Status`); + if (!statusElement) return; + + const value = apiKeys[keyId]; + + if (value && value.trim() !== '') { + // 显示密钥状态 - 已设置 + statusElement.className = 'key-status set'; + statusElement.innerHTML = ` 已设置`; + } else { + // 显示密钥状态 - 未设置 + statusElement.className = 'key-status not-set'; + statusElement.innerHTML = ` 未设置`; + } + }); + } + + /** + * 保存单个API密钥 + * @param {string} keyType 密钥类型 + * @param {string} value 密钥值 + * @param {HTMLElement} keyStatus 密钥状态容器 + */ + async saveApiKey(keyType, value, keyStatus) { + try { + // 显示保存中状态 + const saveToast = this.createToast('正在保存密钥...', 'info', true); + + // 创建要保存的数据对象 + const apiKeysData = {}; + apiKeysData[keyType] = value; + + // 发送到服务器 + const response = await fetch('/api/keys', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(apiKeysData) + }); + + // 移除保存中提示 + if (saveToast) { + saveToast.remove(); + } + + if (response.ok) { + const result = await response.json(); + if (result.success) { + // 更新密钥状态显示 + const statusElem = document.getElementById(`${keyType}Status`); + if (statusElem) { + if (value && value.trim() !== '') { + statusElem.className = 'key-status set'; + statusElem.innerHTML = ` 已设置`; + } else { + statusElem.className = 'key-status not-set'; + statusElem.innerHTML = ` 未设置`; + } + } + + this.createToast('密钥已保存', 'success'); + } else { + this.createToast('保存密钥失败: ' + result.message, 'error'); + } + } else { + this.createToast('无法连接到服务器', 'error'); + } + } catch (error) { + console.error('保存密钥出错:', error); + this.createToast('保存密钥出错: ' + error.message, 'error'); + } + } + + /** + * 验证API密钥 + * @param {string} keyType 密钥类型 + * @param {string} keyValue 密钥值 + * @returns {Promise} 验证结果 + */ + async validateApiKey(keyType, keyValue) { + try { + const response = await fetch('/api/keys/validate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + key_type: keyType, + key_value: keyValue + }) + }); + + if (!response.ok) { + throw new Error(`服务器响应错误: ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error('验证API密钥时出错:', error); + throw error; + } + } + + /** + * 创建一个Toast提示消息 + * @param {string} message 提示消息内容 + * @param {string} type 提示类型:'success', 'error', 'warning', 'info' + * @param {boolean} persistent 是否为持久性提示(需要手动关闭) + */ + createToast(message, type = 'success', persistent = false) { + const toastContainer = document.querySelector('.toast-container') || this.createToastContainer(); + + // 创建Toast元素 + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + if (persistent) { + toast.classList.add('persistent'); + } + + // 设置消息内容 + toast.textContent = message; + + // 如果是持久性提示,添加关闭按钮 + if (persistent) { + const closeBtn = document.createElement('button'); + closeBtn.className = 'toast-close'; + closeBtn.innerHTML = '×'; + closeBtn.addEventListener('click', () => { + toast.remove(); + }); + toast.appendChild(closeBtn); + } + + // 添加到容器 + toastContainer.appendChild(toast); + + // 非持久性提示自动消失 + if (!persistent) { + setTimeout(() => { + toast.remove(); + }, 3000); + } + + return toast; + } + + /** + * 创建Toast容器 + * @returns {HTMLElement} Toast容器元素 + */ + createToastContainer() { + const container = document.createElement('div'); + container.className = 'toast-container'; + document.body.appendChild(container); + return container; + } + + /** + * 刷新API密钥状态 + * 每次加载设置时自动调用,无需用户手动点击按钮 + */ + async refreshApiKeyStatus() { + try { + // 先将所有状态显示为"检查中" + Object.keys(this.apiKeyValues).forEach(keyId => { + const statusElement = document.getElementById(`${keyId}Status`); + if (statusElement) { + statusElement.className = 'key-status checking'; + statusElement.innerHTML = ' 检查中...'; + } + }); + + // 发送请求获取API密钥状态 + const response = await fetch('/api/keys', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + } + }); + + if (response.ok) { + const apiKeys = await response.json(); + this.updateApiKeyStatus(apiKeys); + console.log('API密钥状态已刷新'); + } else { + console.error('刷新API密钥状态失败'); + } + } catch (error) { + console.error('刷新API密钥状态出错:', error); + } + } + + toggleSettingsPanel() { + if (this.settingsPanel) { + this.settingsPanel.classList.toggle('active'); + } + } + + closeSettingsPanel() { + if (this.settingsPanel) { + this.settingsPanel.classList.remove('active'); } } } diff --git a/static/js/ui.js b/static/js/ui.js index 301a307..5b7047e 100644 --- a/static/js/ui.js +++ b/static/js/ui.js @@ -1,5 +1,16 @@ class UIManager { constructor() { + // 延迟初始化,确保DOM已加载 + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => this.init()); + } else { + // 如果DOM已经加载完成,则立即初始化 + this.init(); + } + } + + init() { + console.log('初始化UI管理器...'); // UI elements this.settingsPanel = document.getElementById('settingsPanel'); this.settingsToggle = document.getElementById('settingsToggle'); @@ -7,11 +18,34 @@ class UIManager { this.themeToggle = document.getElementById('themeToggle'); this.toastContainer = document.getElementById('toastContainer'); + // 验证关键元素是否存在 + if (!this.themeToggle) { + console.error('主题切换按钮未找到!'); + return; + } + + if (!this.toastContainer) { + console.error('Toast容器未找到!'); + // 尝试创建Toast容器 + this.toastContainer = this.createToastContainer(); + } + // Check for preferred color scheme this.checkPreferredColorScheme(); // Initialize event listeners this.setupEventListeners(); + + console.log('UI管理器初始化完成'); + } + + createToastContainer() { + console.log('创建Toast容器'); + const container = document.createElement('div'); + container.id = 'toastContainer'; + container.className = 'toast-container'; + document.body.appendChild(container); + return container; } checkPreferredColorScheme() { @@ -28,73 +62,195 @@ class UIManager { } setTheme(isDark) { - document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light'); - this.themeToggle.innerHTML = ``; - localStorage.setItem('theme', isDark ? 'dark' : 'light'); + try { + document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light'); + if (this.themeToggle) { + this.themeToggle.innerHTML = ``; + } + localStorage.setItem('theme', isDark ? 'dark' : 'light'); + console.log(`主题已切换为: ${isDark ? '深色' : '浅色'}`); + } catch (error) { + console.error('设置主题时出错:', error); + } } - showToast(message, type = 'success') { + /** + * 显示一个Toast消息 + * @param {string} message 显示的消息内容 + * @param {string} type 消息类型,可以是'success', 'error', 'info', 'warning' + * @param {number} displayTime 显示的时间(毫秒),如果为-1则持续显示直到手动关闭 + * @returns {HTMLElement} 返回创建的Toast元素,可用于后续移除 + */ + showToast(message, type = 'success', displayTime) { + if (!this.toastContainer) { + console.error('Toast容器不存在,无法显示消息'); + return null; + } + // 检查是否已经存在相同内容的提示 const existingToasts = this.toastContainer.querySelectorAll('.toast'); for (const existingToast of existingToasts) { const existingMessage = existingToast.querySelector('span').textContent; if (existingMessage === message) { // 已经存在相同的提示,不再创建新的 - return; + return existingToast; } } const toast = document.createElement('div'); toast.className = `toast ${type}`; + + // 根据类型设置图标 + let icon = 'check-circle'; + if (type === 'error') icon = 'exclamation-circle'; + else if (type === 'warning') icon = 'exclamation-triangle'; + else if (type === 'info') icon = 'info-circle'; + toast.innerHTML = ` - + ${message} `; + + // 如果是持续显示的Toast,添加关闭按钮 + if (displayTime === -1) { + const closeButton = document.createElement('button'); + closeButton.className = 'toast-close'; + closeButton.innerHTML = ''; + closeButton.addEventListener('click', (e) => { + this.hideToast(toast); + }); + toast.appendChild(closeButton); + toast.classList.add('persistent'); + } + this.toastContainer.appendChild(toast); // 为不同类型的提示设置不同的显示时间 - const displayTime = message === '截图成功' ? 1500 : 3000; + if (displayTime !== -1) { + // 如果没有指定时间,则根据消息类型和内容长度设置默认时间 + if (displayTime === undefined) { + displayTime = message === '截图成功' ? 1500 : + type === 'error' ? 5000 : + message.length > 50 ? 4000 : 3000; + } + + setTimeout(() => { + this.hideToast(toast); + }, displayTime); + } + return toast; + } + + /** + * 隐藏一个Toast消息 + * @param {HTMLElement} toast 要隐藏的Toast元素 + */ + hideToast(toast) { + if (!toast || !toast.parentNode) return; + + toast.style.opacity = '0'; setTimeout(() => { - toast.style.opacity = '0'; - setTimeout(() => toast.remove(), 300); - }, displayTime); + if (toast.parentNode) { + toast.remove(); + } + }, 300); } closeAllPanels() { - this.settingsPanel.classList.add('hidden'); + if (this.settingsPanel) { + this.settingsPanel.classList.remove('active'); + } + } + + hideSettingsPanel() { + if (this.settingsPanel) { + this.settingsPanel.classList.remove('active'); + } + } + + toggleSettingsPanel() { + if (this.settingsPanel) { + this.settingsPanel.classList.toggle('active'); + } + } + + closeSettingsPanel() { + if (this.settingsPanel) { + this.settingsPanel.classList.remove('active'); + } + } + + // 检查点击事件,如果点击了设置面板外部,则关闭设置面板 + checkClickOutsideSettings(e) { + if (this.settingsPanel && + !this.settingsPanel.contains(e.target) && + !e.target.closest('#settingsToggle')) { + this.settingsPanel.classList.remove('active'); + } } setupEventListeners() { + // 确保所有元素都存在 + if (!this.settingsToggle || !this.closeSettings || !this.themeToggle) { + console.error('无法设置事件监听器:一些UI元素未找到'); + return; + } + // Settings panel this.settingsToggle.addEventListener('click', () => { this.closeAllPanels(); - this.settingsPanel.classList.toggle('hidden'); + this.settingsPanel.classList.toggle('active'); }); this.closeSettings.addEventListener('click', () => { - this.settingsPanel.classList.add('hidden'); + this.settingsPanel.classList.remove('active'); }); // Theme toggle this.themeToggle.addEventListener('click', () => { - const currentTheme = document.documentElement.getAttribute('data-theme'); - this.setTheme(currentTheme !== 'dark'); + try { + const currentTheme = document.documentElement.getAttribute('data-theme'); + console.log('当前主题:', currentTheme); + this.setTheme(currentTheme !== 'dark'); + } catch (error) { + console.error('切换主题时出错:', error); + } }); // Close panels when clicking outside document.addEventListener('click', (e) => { - // Only close if click is outside the panel and not on the toggle button - if (this.settingsPanel && - !this.settingsPanel.contains(e.target) && - !e.target.closest('#settingsToggle')) { - this.settingsPanel.classList.add('hidden'); - } + this.checkClickOutsideSettings(e); }); } } -// Export for use in other modules +// 创建全局实例 window.UIManager = UIManager; -window.showToast = (message, type) => window.uiManager.showToast(message, type); -window.closeAllPanels = () => window.uiManager.closeAllPanels(); + +// 确保在DOM加载完毕后才创建UIManager实例 +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + window.uiManager = new UIManager(); + }); +} else { + window.uiManager = new UIManager(); +} + +// 导出全局辅助函数 +window.showToast = (message, type) => { + if (window.uiManager) { + return window.uiManager.showToast(message, type); + } else { + console.error('UI管理器未初始化,无法显示Toast'); + return null; + } +}; + +window.closeAllPanels = () => { + if (window.uiManager) { + window.uiManager.closeAllPanels(); + } else { + console.error('UI管理器未初始化,无法关闭面板'); + } +}; diff --git a/static/style.css b/static/style.css index 93781ca..0e9265e 100644 --- a/static/style.css +++ b/static/style.css @@ -1,71 +1,106 @@ :root { /* Light theme colors */ - --primary-color: #2196F3; - --primary-dark: #1976D2; - --secondary-color: #4CAF50; - --secondary-dark: #45a049; - --background: #f8f9fa; - --surface: #ffffff; - --text-primary: #212121; - --text-secondary: #666666; - --border-color: #e0e0e0; - --shadow-color: rgba(0, 0, 0, 0.1); - --error-color: #e53935; - --success-color: #4CAF50; - --primary: #2196f3; - --primary-rgb: 33, 150, 243; - --primary-light: #bbdefb; - --secondary: #ff9800; - --success: #4caf50; - --danger: #f44336; - --warning: #ff9800; - --info: #2196f3; - --background: #f5f7fb; + --primary: #0366d6; + --primary-rgb: 3, 102, 214; + --primary-rgb-alpha: rgba(3, 102, 214, 0.1); + --primary-light: #2188ff; + --primary-dark: #044289; + --text-primary: #24292e; + --text-secondary: #586069; + --text-tertiary: #6a737d; + --text-success: #28a745; + --text-warning: #f66a0a; + --text-error: #d73a49; --surface: #ffffff; --surface-rgb: 255, 255, 255; - --surface-alt: #f0f4f8; - --surface-alt-rgb: 248, 249, 250; + --surface-alt: #f6f8fa; + --surface-alt-rgb: 246, 248, 250; + --border-color: #e1e4e8; + --link-color: #0366d6; + --shadow-color: rgba(0, 0, 0, 0.05); + --highlight-bg-color: #f6f8fa; + --highlight-bg-color-dark: #2d333b; + --button-hover: #f3f4f6; + --button-active: #ebecf0; + --header-height: 60px; + --transition-speed: 0.3s; + --screen-md: 768px; + --screen-sm: 480px; + --editor-padding: 0.5rem; + + /* 其他颜色 */ + --error-color: #e53935; + --success-color: #4CAF50; + --secondary: #ff9800; + --info: #2196f3; + --background: #f5f7fb; --text: #2c3e50; - --text-tertiary: #9e9e9e; - --shadow-color: rgba(0, 0, 0, 0.1); --hover-color: #e9ecef; --accent: #4a6cf7; - --placeholder: #a0a0a0; - --disabled: #e6e6e6; - --error-hover-color: #c62828; } [data-theme="dark"] { - --primary-color: #64B5F6; - --primary-dark: #42A5F5; - --secondary-color: #81C784; - --secondary-dark: #66BB6A; - --background: #121212; - --surface: #1E1E1E; - --surface-rgb: 30, 30, 30; - --text-primary: #FFFFFF; - --text-secondary: #B0B0B0; - --text-tertiary: #909090; - --border-color: #333333; - --shadow-color: rgba(0, 0, 0, 0.3); - --primary: #64b5f6; - --primary-rgb: 100, 181, 246; - --primary-light: #bbdefb; - --secondary: #ff9800; - --danger: #f44336; - --success: #4caf50; - --background: #1a1a2e; - --surface: #272741; - --surface-alt: #202035; - --surface-alt-rgb: 37, 37, 37; - --text: #f0f0f0; - --text-secondary: #a0a0a0; + /* Dark theme colors */ + --primary: #58a6ff; + --primary-rgb: 88, 166, 255; + --primary-rgb-alpha: rgba(88, 166, 255, 0.1); + --primary-light: #79b8ff; + --primary-dark: #388bfd; + --text-primary: #c9d1d9; + --text-secondary: #8b949e; + --text-tertiary: #6e7681; + --text-success: #3fb950; + --text-warning: #d29922; + --text-error: #f85149; + --surface: #0d1117; + --surface-rgb: 13, 17, 23; + --surface-alt: #161b22; + --surface-alt-rgb: 22, 27, 34; + --border-color: #30363d; + --link-color: #58a6ff; --shadow-color: rgba(0, 0, 0, 0.4); - --border-color: #353545; - --hover-color: #32324b; - --accent: #4a6cf7; - --placeholder: #515151; - --disabled: #3a3a3a; + --highlight-bg-color: #161b22; + --highlight-bg-color-dark: #0d1117; + --button-hover: #2a303b; + --button-active: #252c36; + + /* 其他颜色 */ + --background: #0d1117; + --text: #c9d1d9; + --hover-color: #2a303b; + + /* 设置面板相关变量 */ + --card-background: #161b22; + --input-background: #0d1117; + --input-border: #30363d; + --input-focus-border: #58a6ff; + --input-focus-shadow: rgba(88, 166, 255, 0.2); + --input-text: #c9d1d9; + --input-placeholder: #6e7681; + + /* 按钮相关 */ + --btn-primary-bg: #238636; + --btn-primary-text: #ffffff; + --btn-primary-hover-bg: #2ea043; + --btn-primary-active-bg: #238636; + --btn-secondary-bg: #21262d; + --btn-secondary-text: #c9d1d9; + --btn-secondary-border: #30363d; + --btn-secondary-hover-bg: #30363d; + + /* 成功/警告/错误状态 */ + --success-color: #3fb950; + --warning-color: #d29922; + --error-color: #f85149; + --info: #58a6ff; + + /* 特殊元素 */ + --dropdown-background: #0d1117; + --tooltip-background: #30363d; + --tooltip-text: #c9d1d9; + --switch-bg-on: #238636; + --switch-bg-off: #484f58; + --danger: #f85149; } /* Base Styles */ @@ -80,7 +115,7 @@ body { background-color: var(--background); color: var(--text-primary); line-height: 1.6; - transition: background-color 0.3s, color 0.3s; + transition: background-color 0.3s ease, color 0.3s ease; touch-action: manipulation; -webkit-tap-highlight-color: transparent; } @@ -1073,240 +1108,221 @@ body { .settings-panel { position: fixed; top: 0; - right: 0; - bottom: 0; - width: 400px; - max-width: 100vw; - background-color: var(--surface); - box-shadow: -8px 0 20px var(--shadow-color); + right: -450px; + width: 450px; + height: 100vh; + background-color: var(--background); + box-shadow: 0 0 20px rgba(0, 0, 0, 0.15); + transition: right 0.3s cubic-bezier(0.16, 1, 0.3, 1); z-index: 1000; - transform: translateX(100%); - transition: transform 0.3s cubic-bezier(0.19, 1, 0.22, 1); - display: flex; - flex-direction: column; - border-left: 1px solid var(--border-color); + overflow-y: auto; } -.settings-panel:not(.hidden) { - transform: translateX(0); +.settings-panel.active { + right: 0; +} + +.settings-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + border-bottom: 1px solid var(--border-color); +} + +.settings-header h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary); } .settings-content { - flex: 1; - overflow-y: auto; padding: 1.5rem; } +/* 卡片式设置区域 */ .settings-section { - margin-bottom: 2.5rem; - background-color: var(--surface-alt); + opacity: 0; + transform: translateY(10px); + animation: fadeInUp 0.4s forwards; + animation-delay: calc(var(--anim-order, 0) * 0.1s); + margin-bottom: 2rem; + background-color: var(--card-background, #f8f9fa); border-radius: 0.75rem; - padding: 1.5rem; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); - transition: transform 0.2s ease, box-shadow 0.2s ease; + padding: 1.25rem; + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.05); + border: 1px solid var(--border-color); + transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); } .settings-section:hover { - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); - transform: translateY(-2px); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08); + transform: translateY(-3px) scale(1.01); } .settings-section h3 { + font-size: 1.125rem; + margin-top: 0; + margin-bottom: 1.25rem; color: var(--text-primary); - margin-bottom: 1.5rem; - font-size: 1.15rem; font-weight: 600; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.settings-section h3::before { - content: ""; - display: inline-block; - width: 4px; - height: 18px; - background-color: var(--primary); - border-radius: 2px; -} - -/* Form Elements */ -.setting-group { - margin-bottom: 1.5rem; position: relative; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--border-color); +} + +/* 设置组样式优化 */ +.setting-group { + margin-bottom: 1.25rem; + transition: all 0.2s ease; } .setting-group:last-child { margin-bottom: 0; } +.setting-group:hover { + transform: translateX(3px); +} + .setting-group label { display: block; - margin-bottom: 0.75rem; + margin-bottom: 0.5rem; color: var(--text-secondary); - font-size: 0.875rem; + font-size: 0.9375rem; font-weight: 500; transition: color 0.2s ease; } .setting-group:hover label { - color: var(--primary); -} - -input[type="text"], -input[type="password"], -input[type="number"], -select, -textarea { - width: 100%; - padding: 0.85rem 1rem; - border: 1.5px solid var(--border-color); - border-radius: 0.5rem; - background-color: var(--background); color: var(--text-primary); - font-size: 0.9375rem; - transition: all 0.2s ease-in-out; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); } -input:focus, -select:focus, -textarea:focus { - outline: none; +/* 下拉菜单美化 */ +.setting-group select { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%236c757d' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 1rem center; + background-size: 16px; + padding-right: 2.5rem; +} + +.setting-group select:hover { border-color: var(--primary); - box-shadow: 0 0 0 3px rgba(74, 108, 247, 0.15); - transform: translateY(-1px); -} - -.input-styled { - width: 100%; - padding: 8px 10px; - background-color: var(--input-bg-color); - border: 1px solid var(--border-color); - border-radius: 4px; - color: var(--input-text-color); - font-size: 1rem; - transition: border-color 0.2s; -} - -.input-styled:focus { - border-color: var(--accent-color); - outline: none; -} - -[data-theme="dark"] .input-styled { - background-color: var(--input-bg-color-dark); - color: var(--input-text-color-dark); - border-color: var(--border-color-dark); -} - -.input-group { - position: relative; - display: flex; - align-items: center; -} - -.input-group input { - padding-right: 2.75rem; -} - -.input-group .btn-icon { - position: absolute; - right: 0.75rem; - background-color: transparent; - transition: all 0.2s ease; -} - -.input-group .btn-icon:hover { - color: var(--primary); - transform: scale(1.1); -} - -.range-group { - display: flex; - align-items: center; - gap: 1rem; } +/* 滑块和范围输入优化 */ input[type="range"] { - flex: 1; - height: 6px; -webkit-appearance: none; - background: linear-gradient(to right, var(--primary) 0%, var(--primary) 50%, var(--border-color) 50%, var(--border-color) 100%); + appearance: none; + width: 100%; + height: 6px; + background: var(--border-color); border-radius: 3px; - cursor: pointer; + outline: none; + margin: 1rem 0; + position: relative; } input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; - width: 18px; - height: 18px; - border-radius: 50%; - background: var(--primary); - box-shadow: 0 0 6px rgba(0, 0, 0, 0.1); - transition: transform 0.2s ease, box-shadow 0.2s ease; - cursor: pointer; -} - -input[type="range"]::-webkit-slider-thumb:hover { - transform: scale(1.2); - box-shadow: 0 0 0 4px rgba(74, 108, 247, 0.2); -} - -#temperatureValue { - min-width: 30px; - text-align: center; - font-weight: 600; - color: var(--primary); -} - -.checkbox-label { - display: flex; - align-items: center; - gap: 0.75rem; - cursor: pointer; - user-select: none; -} - -.checkbox-label input[type="checkbox"] { appearance: none; width: 18px; height: 18px; - border: 1.5px solid var(--border-color); - border-radius: 4px; - position: relative; - transition: all 0.2s ease; - background-color: var(--background); -} - -.checkbox-label input[type="checkbox"]:checked { background-color: var(--primary); - border-color: var(--primary); + border-radius: 50%; + border: 2px solid white; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); + cursor: pointer; + transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275), background-color 0.2s ease; } -.checkbox-label input[type="checkbox"]:checked::after { - content: "✓"; - position: absolute; +input[type="range"]::-moz-range-thumb { + width: 18px; + height: 18px; + background-color: var(--primary); + border-radius: 50%; + border: 2px solid white; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); + cursor: pointer; + transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275), background-color 0.2s ease; +} + +input[type="range"]::-webkit-slider-thumb:hover, +input[type="range"]::-webkit-slider-thumb:active { + transform: scale(1.15); + box-shadow: 0 0 0 4px rgba(var(--primary-rgb), 0.2); +} + +.slider-value { + display: inline-block; + background-color: var(--primary); color: white; - font-size: 14px; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.875rem; + margin-left: 0.5rem; + font-weight: 500; + min-width: 3rem; + text-align: center; } -.checkbox-label input[type="checkbox"]:focus { - box-shadow: 0 0 0 3px rgba(74, 108, 247, 0.15); +/* 系统提示文本区域优化 */ +textarea#systemPrompt { + min-height: 150px; + resize: vertical; + font-family: monospace; + line-height: 1.5; + font-size: 0.9rem; } -.proxy-settings { - padding-top: 1rem; +/* 模型版本信息 */ +.model-version-info { + display: flex; + align-items: center; + gap: 0.5rem; margin-top: 0.5rem; - border-top: 1px dashed var(--border-color); - transition: opacity 0.3s ease, transform 0.3s ease; + font-size: 0.875rem; + color: var(--text-tertiary); + padding: 0.5rem 0.75rem; + background-color: rgba(0, 0, 0, 0.03); + border-radius: 0.5rem; + border: 1px dashed var(--border-color); } -#proxyEnabled:not(:checked) ~ #proxySettings { - opacity: 0.5; +.model-version-info i { + color: var(--info); + font-size: 1rem; +} + +/* API密钥设置区域的特殊样式 */ +.api-key-settings .setting-group { + background-color: rgba(0, 0, 0, 0.02); + padding: 1rem; + border-radius: 0.5rem; + border: 1px solid var(--border-color); +} + +.api-key-settings .setting-group:hover { + background-color: rgba(0, 0, 0, 0.03); +} + +/* 紧凑模式下的特殊样式调整 */ +@media (max-width: 1200px) { + .settings-panel { + width: 400px; + right: -400px; + } + + .settings-content { + padding: 1.25rem; + } + + .settings-section { + padding: 1rem; + } } /* Buttons */ @@ -1433,39 +1449,79 @@ button:disabled { /* Toast Notifications */ .toast-container { position: fixed; - top: 4rem; - right: 1rem; - left: auto; - transform: none; - z-index: 1000; + bottom: 20px; + right: 20px; display: flex; flex-direction: column; - gap: 0.5rem; - pointer-events: none; + align-items: flex-end; + gap: 10px; + z-index: 9999; + max-width: 300px; + pointer-events: none; /* 容器本身不接收鼠标事件 */ } .toast { - background-color: var(--surface); - color: var(--text-primary); - padding: 0.75rem 1rem; - border-radius: 0.375rem; - box-shadow: 0 4px 6px var(--shadow-color); + padding: 12px 16px; + background-color: white; + color: var(--text-color); + border-radius: 4px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15); display: flex; align-items: center; - gap: 0.75rem; - pointer-events: auto; + margin: 0; + opacity: 1; + position: relative; animation: toast-in 0.3s ease; - max-width: 250px; - opacity: 0.9; - font-size: 0.9rem; + border-left: 4px solid; + pointer-events: auto; /* toast本身仍接收鼠标事件 */ +} + +.toast.persistent { + padding-right: 32px; } .toast.success { - border-left: 4px solid var(--success-color); + border-color: var(--success-color); } .toast.error { - border-left: 4px solid var(--error-color); + border-color: var(--error-color); +} + +.toast.warning { + border-color: var(--warning-color); +} + +.toast.info { + border-color: var(--info-color); +} + +.toast-close { + position: absolute; + right: 8px; + top: 8px; + background: none; + border: none; + font-size: 16px; + color: var(--text-light); + cursor: pointer; + opacity: 0.7; + transition: opacity 0.3s; +} + +.toast-close:hover { + opacity: 1; +} + +@keyframes toast-in { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } } /* Crop Container */ @@ -1762,33 +1818,253 @@ button:disabled { /* API密钥高亮显示样式 */ .api-key-group { transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.api-key-group.highlight { + background-color: rgba(var(--primary-rgb), 0.1); + border-radius: 4px; + padding: 0.5rem; + box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.2); +} + +.api-key-group.saving { + background-color: rgba(var(--primary-rgb), 0.05); +} + +.api-key-group.saving::after { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient( + 90deg, + transparent, + rgba(var(--primary-rgb), 0.2), + transparent + ); + animation: saving-animation 1.5s linear; +} + +@keyframes saving-animation { + 0% { left: -100%; } + 100% { left: 100%; } +} + +/* API密钥状态显示样式 */ +.api-keys-status { + background-color: var(--surface-alt); + border-radius: 6px; + padding: 0.75rem; + margin-bottom: 1rem; + border: 1px solid var(--border-color); +} + +.status-header { + font-size: 0.9rem; + color: var(--text-secondary); + margin-bottom: 0.5rem; + font-weight: 500; + display: flex; + justify-content: space-between; + align-items: center; +} + +.api-keys-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.5rem 0; +} + +.api-key-status { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.85rem; + padding: 0.5rem 1rem; + border-radius: 4px; + background-color: var(--surface); + transition: background-color 0.3s ease; +} + +.api-key-status.highlight { + background-color: var(--hover-bg); + border-left: 4px solid var(--primary-color); + padding-left: 16px; + transition: all 0.3s ease; +} + +.key-name { + font-weight: 500; + color: var(--text-secondary); +} + +.key-status-wrapper { + display: flex; + align-items: center; + gap: 0.35rem; + position: relative; + width: 100%; + max-width: 300px; +} + +.key-display, .key-edit { + display: flex; + align-items: center; + gap: 0.35rem; + width: 100%; + transition: all 0.3s ease; +} + +.key-status { + font-weight: 500; + padding: 0.15rem 0; + text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +.key-status.set { + color: var(--success-color); + font-weight: 600; +} + +.key-status.not-set { + color: var(--warning-color); + font-weight: 600; +} + +.key-edit { + flex: 1; +} + +.key-input { + flex: 1; + padding: 0.15rem 0.5rem; + border-radius: 3px; + border: 1px solid var(--border-color); + background-color: var(--surface); + color: var(--text-primary); + font-family: monospace; + font-size: 0.85rem; + transition: all 0.2s ease; +} + +.key-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.2); +} + +.toggle-visibility, .save-api-key { + font-size: 0.8rem; + padding: 0.3rem; + color: var(--text-secondary); + transition: all 0.2s ease; +} + +.toggle-visibility:hover, .save-api-key:hover { + color: var(--primary); + background-color: rgba(var(--primary-rgb), 0.1); + transform: scale(1.1); +} + +.save-api-key { + color: var(--text-success); +} + +.save-api-key:hover { + color: var(--text-success); + background-color: rgba(40, 167, 69, 0.1); +} + +.key-status.set { + color: var(--text-success); +} + +.key-status.not-set { + color: var(--text-warning); +} + +.hidden { + display: none !important; +} + +.edit-api-key { + font-size: 0.8rem; + color: var(--text-secondary); + opacity: 0.7; + transition: all 0.2s ease; +} + +.edit-api-key:hover { + color: var(--primary); + opacity: 1; + transform: scale(1.1); +} + +.header-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.refresh-btn { + font-size: 0.85rem; + padding: 0.35rem; + color: var(--text-tertiary); +} + +.refresh-btn:hover { + color: var(--primary); + background-color: rgba(var(--primary-rgb), 0.1); +} + +.refresh-btn.spinning i { + animation: spinning 1s linear infinite; +} + +@keyframes spinning { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } } /* 可折叠内容样式 */ .collapsible-header { display: flex; - justify-content: space-between; + justify-content: center; align-items: center; cursor: pointer; padding-bottom: 1rem; + text-align: center; } .collapsible-header h3 { margin-bottom: 0 !important; + text-align: center; } .collapsible-content { overflow: hidden; - transition: max-height 0.3s ease, padding-top 0.3s ease; + transition: max-height 0.5s ease, padding-top 0.3s ease, opacity 0.3s ease; + max-height: 2000px; + opacity: 1; } .collapsible-content.collapsed { max-height: 0; padding-top: 0; + opacity: 0; + pointer-events: none; } .collapsible-content:not(.collapsed) { - max-height: 2000px; padding-top: 1rem; border-top: 1px dashed var(--border-color); } @@ -2208,3 +2484,719 @@ button:disabled { .btn-stop-generation.visible { display: flex; } + +/* API密钥状态高亮 */ +.api-key-status.highlight { + background-color: var(--hover-bg); + border-left: 4px solid var(--primary-color); + padding-left: 16px; + transition: all 0.3s ease; +} + +.api-key-status { + border-left: 4px solid transparent; + padding-left: 20px; + transition: all 0.3s ease; +} + +.key-status.checking { + color: var(--info); + font-weight: 600; +} + +.key-status.set i { + color: var(--success-color); +} + +.key-status.not-set i { + color: var(--warning-color); +} + +.key-status.checking i { + color: var(--info); +} + +/* 通用表单元素样式 */ +input[type="text"], +input[type="password"], +input[type="number"], +select, +textarea { + width: 100%; + padding: 0.85rem 1rem; + border: 1.5px solid var(--border-color); + border-radius: 0.5rem; + background-color: var(--background); + color: var(--text-primary); + font-size: 0.9375rem; + transition: all 0.2s ease-in-out; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} + +input:focus, +select:focus, +textarea:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(var(--primary-rgb), 0.15); + transform: translateY(-1px); + transition: all 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +.input-group { + display: flex; + align-items: center; + position: relative; +} + +.input-group input { + flex: 1; +} + +.input-group .btn-icon { + margin-left: 0.25rem; + padding: 0.35rem; + font-size: 0.9rem; + color: var(--text-tertiary); +} + +.input-group .btn-icon:hover { + color: var(--primary); + background-color: rgba(var(--primary-rgb), 0.1); +} + +.input-group .validate-api-key { + color: var(--text-success); +} + +.input-group .validate-api-key:hover { + color: var(--text-success); + background-color: rgba(40, 167, 69, 0.1); +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 0.75rem; + cursor: pointer; + user-select: none; +} + +.checkbox-label input[type="checkbox"] { + appearance: none; + width: 18px; + height: 18px; + border: 1.5px solid var(--border-color); + border-radius: 4px; + position: relative; + transition: all 0.2s ease; + background-color: var(--background); +} + +.checkbox-label input[type="checkbox"]:checked { + background-color: var(--primary); + border-color: var(--primary); +} + +.checkbox-label input[type="checkbox"]:checked::after { + content: "✓"; + position: absolute; + color: white; + font-size: 14px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) scale(0); + animation: checkmark 0.25s forwards; +} + +@keyframes checkmark { + from { + transform: translate(-50%, -50%) scale(0); + } + to { + transform: translate(-50%, -50%) scale(1); + } +} + +.checkbox-label input[type="checkbox"]:focus { + box-shadow: 0 0 0 3px rgba(74, 108, 247, 0.15); +} + +.proxy-settings { + padding-top: 1rem; + margin-top: 0.5rem; + border-top: 1px dashed var(--border-color); + transition: opacity 0.3s ease, transform 0.3s ease; +} + +#proxyEnabled:not(:checked) ~ #proxySettings { + opacity: 0.5; +} + +/* 滑块值显示优化 */ +#temperatureValue, +#thinkBudgetPercentValue { + display: inline-block; + background-color: var(--primary); + color: white; + padding: 0.25rem 0.5rem; + border-radius: 0.35rem; + font-size: 0.875rem; + margin-left: 0.5rem; + font-weight: 500; + min-width: 2.5rem; + text-align: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + transition: all 0.2s ease; +} + +/* 设置标签图标 */ +.setting-group label { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.setting-group label i { + color: var(--text-tertiary); + font-size: 0.9rem; + transition: color 0.2s ease; +} + +.setting-group:hover label i { + color: var(--primary); +} + +/* 模型选择增强 */ +#modelSelect { + font-weight: 500; +} + +#modelSelect option { + font-weight: normal; +} + +/* 特殊设置组样式 */ +.reasoning-setting-group, +.think-budget-group { + background-color: rgba(0, 0, 0, 0.02); + border-radius: 0.75rem; + padding: 1rem; + border: 1px dashed var(--border-color); + margin: 1rem 0; + transition: all 0.25s ease; +} + +.reasoning-setting-group:hover, +.think-budget-group:hover { + background-color: rgba(var(--primary-rgb), 0.03); + border-color: var(--primary); +} + +/* 设置面板按钮样式 */ +.settings-footer { + padding: 1rem 1.5rem; + display: flex; + justify-content: flex-end; + gap: 1rem; + border-top: 1px solid var(--border-color); + background-color: rgba(0, 0, 0, 0.02); +} + +.settings-footer .btn { + padding: 0.6rem 1.25rem; + border-radius: 0.5rem; + font-weight: 500; + transition: all 0.2s ease; +} + +.settings-footer .btn-primary { + background-color: var(--primary); + color: white; + border: none; +} + +.settings-footer .btn-primary:hover { + background-color: var(--primary-dark, #3a58d6); + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(var(--primary-rgb), 0.25); +} + +.settings-footer .btn-secondary { + background-color: transparent; + color: var(--text-secondary); + border: 1px solid var(--border-color); +} + +.settings-footer .btn-secondary:hover { + color: var(--text-primary); + border-color: var(--text-secondary); + background-color: rgba(0, 0, 0, 0.05); +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.model-settings { + --anim-order: 1; +} + +.api-key-settings { + --anim-order: 2; +} + +.proxy-settings-section { + --anim-order: 3; +} + +/* 按钮点击状态 */ +.btn:active { + transform: scale(0.97); + transition: transform 0.1s ease; +} + +/* 设置组内部元素的过渡效果 */ +.setting-group > * { + transition: all 0.2s ease; +} + +/* 开关切换的动画效果 */ +.checkbox-label input[type="checkbox"] { + position: relative; + transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +.checkbox-label input[type="checkbox"]:checked::after { + content: "✓"; + position: absolute; + color: white; + font-size: 14px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) scale(0); + animation: checkmark 0.25s forwards; +} + +@keyframes checkmark { + from { + transform: translate(-50%, -50%) scale(0); + } + to { + transform: translate(-50%, -50%) scale(1); + } +} + +/* 全局过渡效果,让主题切换更平滑 */ +html { + transition: background-color 0.3s ease, color 0.3s ease; +} + +/* 添加主题切换按钮的动画效果 */ +#themeToggle { + position: relative; + overflow: hidden; + transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); +} + +#themeToggle:hover { + transform: translateY(-2px) scale(1.05); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15); +} + +#themeToggle:active { + transform: translateY(0) scale(0.98); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +#themeToggle i { + transition: transform 0.5s cubic-bezier(0.68, -0.55, 0.27, 1.55), opacity 0.3s ease; +} + +#themeToggle:hover i { + transform: rotate(15deg) scale(1.1); +} + +/* ===== 夜间模式样式增强 ===== */ +/* 设置面板暗色模式样式 */ +[data-theme="dark"] .settings-panel { + background-color: var(--surface); + box-shadow: 0 0 20px rgba(0, 0, 0, 0.5); +} + +[data-theme="dark"] .settings-header { + border-bottom: 1px solid var(--border-color); + background-color: var(--surface); +} + +[data-theme="dark"] .settings-section { + background-color: var(--card-background); + border: 1px solid var(--border-color); + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2); +} + +[data-theme="dark"] .settings-section:hover { + box-shadow: 0 6px 15px rgba(0, 0, 0, 0.25); +} + +[data-theme="dark"] input[type="text"], +[data-theme="dark"] input[type="password"], +[data-theme="dark"] input[type="number"], +[data-theme="dark"] select, +[data-theme="dark"] textarea { + background-color: var(--input-background); + border-color: var(--input-border); + color: var(--input-text); +} + +[data-theme="dark"] input:focus, +[data-theme="dark"] select:focus, +[data-theme="dark"] textarea:focus { + border-color: var(--input-focus-border); + box-shadow: 0 0 0 3px var(--input-focus-shadow); +} + +[data-theme="dark"] input::placeholder { + color: var(--input-placeholder); +} + +[data-theme="dark"] .settings-footer { + background-color: rgba(0, 0, 0, 0.1); + border-top: 1px solid var(--border-color); +} + +[data-theme="dark"] .settings-footer .btn-primary { + background-color: var(--btn-primary-bg); + color: var(--btn-primary-text); +} + +[data-theme="dark"] .settings-footer .btn-primary:hover { + background-color: var(--btn-primary-hover-bg); +} + +[data-theme="dark"] .settings-footer .btn-secondary { + background-color: var(--btn-secondary-bg); + color: var(--btn-secondary-text); + border: 1px solid var(--btn-secondary-border); +} + +[data-theme="dark"] .settings-footer .btn-secondary:hover { + background-color: var(--btn-secondary-hover-bg); +} + +/* 滑块样式优化 */ +[data-theme="dark"] input[type="range"] { + background: var(--border-color); +} + +[data-theme="dark"] input[type="range"]::-webkit-slider-thumb { + background-color: var(--primary); + border: 2px solid var(--surface); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); +} + +[data-theme="dark"] input[type="range"]::-moz-range-thumb { + background-color: var(--primary); + border: 2px solid var(--surface); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); +} + +/* API密钥设置区域 */ +[data-theme="dark"] .api-key-settings .setting-group { + background-color: rgba(0, 0, 0, 0.2); + border: 1px solid var(--border-color); +} + +[data-theme="dark"] .api-key-settings .setting-group:hover { + background-color: rgba(0, 0, 0, 0.3); +} + +[data-theme="dark"] .key-input { + background-color: var(--input-background); + border-color: var(--input-border); + color: var(--input-text); +} + +[data-theme="dark"] .key-status.set { + color: var(--success-color); +} + +[data-theme="dark"] .key-status.not-set { + color: var(--warning-color); +} + +/* 特殊设置组样式 */ +[data-theme="dark"] .reasoning-setting-group, +[data-theme="dark"] .think-budget-group { + background-color: rgba(0, 0, 0, 0.2); + border: 1px dashed var(--border-color); +} + +[data-theme="dark"] .reasoning-setting-group:hover, +[data-theme="dark"] .think-budget-group:hover { + background-color: rgba(var(--primary-rgb), 0.1); + border-color: var(--primary); +} + +/* 温度和思考预算值显示 */ +[data-theme="dark"] #temperatureValue, +[data-theme="dark"] #thinkBudgetPercentValue { + background-color: var(--primary); + color: white; +} + +/* 模型版本信息 */ +[data-theme="dark"] .model-version-info { + background-color: rgba(0, 0, 0, 0.2); + border: 1px dashed var(--border-color); +} + +/* 复选框样式 */ +[data-theme="dark"] .checkbox-label input[type="checkbox"] { + background-color: var(--input-background); + border-color: var(--input-border); +} + +[data-theme="dark"] .checkbox-label input[type="checkbox"]:checked { + background-color: var(--primary); + border-color: var(--primary); +} + +/* 代理设置 */ +[data-theme="dark"] .proxy-settings { + border-top: 1px dashed var(--border-color); +} + +/* 设置界面的按钮 */ +[data-theme="dark"] .btn-icon { + background-color: var(--surface-alt); + color: var(--text-secondary); +} + +[data-theme="dark"] .btn-icon:hover { + background-color: var(--btn-secondary-hover-bg); + color: var(--primary); +} + +/* 改进API密钥按钮和状态 */ +[data-theme="dark"] .edit-api-key { + color: var(--text-secondary); +} + +[data-theme="dark"] .edit-api-key:hover { + color: var(--primary); + background-color: rgba(var(--primary-rgb), 0.1); +} + +[data-theme="dark"] .toggle-visibility, +[data-theme="dark"] .save-api-key { + color: var(--text-tertiary); + background-color: var(--surface-alt); +} + +[data-theme="dark"] .toggle-visibility:hover, +[data-theme="dark"] .save-api-key:hover { + color: var(--primary); + background-color: rgba(var(--primary-rgb), 0.1); +} + +/* 系统提示文本区域 */ +[data-theme="dark"] textarea#systemPrompt { + background-color: var(--input-background); + color: var(--input-text); + border-color: var(--input-border); +} + +/* 媒体查询中的暗色模式 */ +@media (max-width: 1200px) { + [data-theme="dark"] .settings-panel { + background-color: var(--surface); + } + + [data-theme="dark"] .settings-content { + background-color: var(--surface); + } +} + +/* 捕获面板和分析结果面板的夜间模式优化 */ +[data-theme="dark"] .capture-section { + background-color: var(--surface); + border-color: var(--border-color); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); +} + +[data-theme="dark"] .empty-state { + background-color: rgba(0, 0, 0, 0.1); +} + +[data-theme="dark"] .empty-state i { + color: var(--text-tertiary); +} + +[data-theme="dark"] .image-container { + background-color: var(--surface-alt); + border: 1px solid var(--border-color); +} + +[data-theme="dark"] .btn-action { + background-color: var(--primary); + color: white; +} + +[data-theme="dark"] .btn-action:hover { + background-color: var(--primary-light); +} + +[data-theme="dark"] .extracted-text-area { + background-color: var(--input-background); + border-color: var(--input-border); + color: var(--input-text); +} + +[data-theme="dark"] .extracted-text-area:focus { + border-color: var(--input-focus-border); + box-shadow: 0 0 0 3px var(--input-focus-shadow); +} + +/* Claude面板夜间模式 */ +[data-theme="dark"] .claude-panel { + background-color: var(--surface); + border-left: 1px solid var(--border-color); + box-shadow: -8px 0 20px rgba(0, 0, 0, 0.3); +} + +[data-theme="dark"] .panel-header { + border-bottom: 1px solid var(--border-color); + background-color: var(--surface); +} + +[data-theme="dark"] .thinking-section { + background-color: rgba(0, 0, 0, 0.2); + border: 1px solid var(--border-color); +} + +[data-theme="dark"] .thinking-header { + background-color: rgba(0, 0, 0, 0.1); +} + +[data-theme="dark"] .thinking-header:hover { + background-color: rgba(0, 0, 0, 0.2); +} + +[data-theme="dark"] .thinking-content { + background-color: var(--input-background); + border-top: 1px solid var(--border-color); +} + +/* 按钮夜间模式 */ +[data-theme="dark"] .btn-primary { + background-color: var(--btn-primary-bg); + color: var(--btn-primary-text); +} + +[data-theme="dark"] .btn-primary:hover { + background-color: var(--btn-primary-hover-bg); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); +} + +[data-theme="dark"] .btn-secondary { + background-color: var(--btn-secondary-bg); + color: var(--btn-secondary-text); + border: 1px solid var(--btn-secondary-border); +} + +[data-theme="dark"] .btn-secondary:hover { + background-color: var(--btn-secondary-hover-bg); +} + +/* Toast消息夜间模式 */ +[data-theme="dark"] .toast { + background-color: var(--surface-alt); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +[data-theme="dark"] .toast.success { + border-left: 4px solid var(--success-color); +} + +[data-theme="dark"] .toast.error { + border-left: 4px solid var(--error-color); +} + +[data-theme="dark"] .toast.warning { + border-left: 4px solid var(--warning-color); +} + +[data-theme="dark"] .toast.info { + border-left: 4px solid var(--info); +} + +/* 底部栏夜间模式 */ +[data-theme="dark"] .app-footer { + background-color: var(--surface); + border-top: 1px solid var(--border-color); +} + +[data-theme="dark"] .footer-link { + color: var(--text-secondary); +} + +[data-theme="dark"] .footer-link:hover { + color: var(--primary); +} + +/* 更新通知夜间模式 */ +[data-theme="dark"] .update-notice { + background-color: rgba(var(--primary-rgb), 0.1); + border-bottom: 1px solid var(--border-color); +} + +[data-theme="dark"] .update-link { + color: var(--primary); + background-color: rgba(var(--primary-rgb), 0.1); +} + +/* 增强夜间模式过渡效果 */ +html, +body, +.app-container, +.app-header, +.app-main, +.capture-section, +.settings-panel, +.claude-panel, +.app-footer, +button, +input, +select, +textarea, +.btn-primary, +.btn-secondary, +.btn-icon, +.settings-section, +.model-version-info, +.thinking-section, +.response-content, +.toast { + transition: background-color 0.5s ease, + color 0.5s ease, + border-color 0.5s ease, + box-shadow 0.5s ease; +} + +/* 夜间模式切换按钮动画增强 */ +#themeToggle i { + transition: transform 0.5s cubic-bezier(0.68, -0.55, 0.27, 1.55), opacity 0.3s ease; +} + +/* 切换按钮点击效果增强 */ +#themeToggle:active i { + transform: rotate(360deg) scale(0.8); +} diff --git a/templates/index.html b/templates/index.html index b5fa690..2834f4c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -124,50 +124,54 @@ - @@ -305,8 +394,12 @@ + + + +