diff --git a/config_manager.py b/config_manager.py index cb198a4..cd3e91a 100644 --- a/config_manager.py +++ b/config_manager.py @@ -56,7 +56,8 @@ class ConfigManager: "api_key": "ollama", "temperature": 0.7, "max_tokens": 1000, - "top_p": 0.9 + "top_p": 0.9, + "auto_unload": True }, "custom": { "model": "", @@ -101,7 +102,8 @@ class ConfigManager: "api_key": "ollama", "temperature": 0.7, "max_tokens": 500, - "top_p": 0.9 + "top_p": 0.9, + "auto_unload": True }, "custom": { "model": "", @@ -417,7 +419,7 @@ class ConfigManager: return self.save_config(config) - def update_llm_config(self, provider=None, model=None, base_url=None, api_key=None, temperature=None, max_tokens=None, top_p=None, update_current=True): + def update_llm_config(self, provider=None, model=None, base_url=None, api_key=None, temperature=None, max_tokens=None, top_p=None, auto_unload=None, update_current=True): """更新LLM配置""" config = self.load_config() if "llm" not in config: @@ -455,10 +457,12 @@ class ConfigManager: config["llm"]["providers"][provider]["max_tokens"] = max_tokens if provider is not None and top_p is not None: config["llm"]["providers"][provider]["top_p"] = top_p + if provider is not None and auto_unload is not None: + config["llm"]["providers"][provider]["auto_unload"] = auto_unload return self.save_config(config) - def update_vision_config(self, provider=None, model=None, base_url=None, api_key=None, temperature=None, max_tokens=None, top_p=None, update_current=True): + def update_vision_config(self, provider=None, model=None, base_url=None, api_key=None, temperature=None, max_tokens=None, top_p=None, auto_unload=None, update_current=True): """更新视觉模型配置""" config = self.load_config() if "vlm" not in config: @@ -496,6 +500,8 @@ class ConfigManager: config["vlm"]["providers"][provider]["max_tokens"] = max_tokens if provider is not None and top_p is not None: config["vlm"]["providers"][provider]["top_p"] = top_p + if provider is not None and auto_unload is not None: + config["vlm"]["providers"][provider]["auto_unload"] = auto_unload return self.save_config(config) def validate_and_fix_system_prompts(self): @@ -733,6 +739,12 @@ class ConfigManager: need_update = True self._log(f"为LLM提供商 {provider_name} 添加默认top_p参数: 0.9") + # 检查并添加缺失的auto_unload参数(仅Ollama) + if provider_name == "ollama" and "auto_unload" not in provider_config: + provider_config["auto_unload"] = True + need_update = True + self._log(f"为LLM提供商 {provider_name} 添加默认auto_unload参数: True") + # 检查视觉模型配置,并补全新增提供商 if "vlm" in config: if "providers" not in config["vlm"]: @@ -763,6 +775,12 @@ class ConfigManager: need_update = True self._log(f"为视觉模型提供商 {provider_name} 添加默认top_p参数: 0.9") + # 检查并添加缺失的auto_unload参数(仅Ollama) + if provider_name == "ollama" and "auto_unload" not in provider_config: + provider_config["auto_unload"] = True + need_update = True + self._log(f"为视觉模型提供商 {provider_name} 添加默认auto_unload参数: True") + # 如果有更新,保存配置 if need_update: self.save_config(config) diff --git a/js/css/assistant.css b/js/css/assistant.css index 69d2f54..b9cfa89 100644 --- a/js/css/assistant.css +++ b/js/css/assistant.css @@ -67,6 +67,11 @@ transform 0.4s cubic-bezier(.78, 1.76, .68, .96) 0.25s; } +/* 指示器图标创建时的动画 */ +.prompt-assistant-container.collapsed .prompt-assistant-indicator.animate-creation { + animation: indicator-creation 0.4s cubic-bezier(.78, 1.76, .68, .96); +} + /* 指示器图标隐藏动画 */ .prompt-assistant-container:not(.collapsed) .prompt-assistant-indicator { opacity: 0; @@ -182,24 +187,27 @@ /* 指示器图标创建时的动画 */ .image-assistant-container.collapsed .image-assistant-indicator.animate-creation { - animation: indicator-creation 0.6s cubic-bezier(.49, 1.98, .67, .83); + animation: indicator-creation 0.4s cubic-bezier(.78, 1.76, .68, .96); } -/* 指示器创建动画 */ +/* 指示器创建动画 - 透明度过冲效果 */ @keyframes indicator-creation { 0% { opacity: 0; transform: translateY(-50%) scale(0); + filter: drop-shadow(0 0 0px rgba(255, 255, 255, 0)) drop-shadow(0 0 0px rgba(100, 200, 255, 0)); } - 60% { - opacity: 0.5; - transform: translateY(-50%) scale(1.0); + 65% { + opacity: calc(var(--assistant-icon-opacity) + 0.1); + transform: translateY(-50%) scale(0.8); + filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.6)) drop-shadow(0 0 4px rgba(100, 200, 255, 0.5)); } 100% { - opacity: 0.3; + opacity: var(--assistant-icon-opacity); transform: translateY(-50%) scale(0.8); + filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.6)) drop-shadow(0 0 4px rgba(100, 200, 255, 0.5)); } } @@ -237,6 +245,13 @@ opacity: 0; } +/* 折叠状态下隐藏分割线 */ +.image-assistant-container.collapsed .image-assistant-divider { + opacity: 0 !important; + pointer-events: none !important; + transition: opacity 0.3s cubic-bezier(0.11, 0, 0.5, 0); +} + /* 图像小助手过渡效果 */ .image-assistant-transition { transition: width 0.3s cubic-bezier(0.25, 1, 0.5, 1), @@ -290,10 +305,9 @@ left 0.2s ease-out; } -/* 显示动画类 */ +/* 显示动画类 - 直接显示,无创建动画 */ .assistant-show { display: flex !important; - animation: clipFadeInDown 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); } @@ -342,6 +356,13 @@ transition: opacity 0.4s ease-out, pointer-events 0s 0.4s; } +/* 折叠状态下隐藏分割线 */ +.prompt-assistant-container.collapsed .prompt-assistant-divider { + opacity: 0 !important; + pointer-events: none !important; + transition: opacity 0.4s ease-out; +} + /* SVG图标样式 */ .svg-icon { display: flex; diff --git a/js/css/popup.css b/js/css/popup.css index 278302d..19468bb 100644 --- a/js/css/popup.css +++ b/js/css/popup.css @@ -634,7 +634,7 @@ flex: 1; display: flex; align-items: center; - margin: 0 2px; + width: 100%; } /* 隐藏滚动条 */ @@ -769,51 +769,53 @@ flex-direction: column; } -/* 滚动指示器 - PrimeVue 风格 */ +/* 滚动指示器 - 漂浮设计 */ .popup_container .tabs_scroll_indicator { - position: relative; - width: 20px; - height: 20px; + position: absolute; + top: 0; + bottom: 0; + width: 36px; display: flex; align-items: center; justify-content: center; - /* color: rgba(255, 255, 255, 0.7); */ color: var(--p-text-muted-color); - font-size: 16px; cursor: pointer; - z-index: 5; - transition: all 0.2s ease; - flex-shrink: 0; - margin: 0 2px; + z-index: 10; + pointer-events: auto; + transition: color 0.2s ease; border: none; - border-radius: 50%; } /* 添加滚动指示器图标尺寸控制 */ .popup_container .tabs_scroll_indicator .pi { font-size: 14px; + transition: transform 0.2s ease; } /* 左侧滚动指示器 */ .popup_container .tabs_scroll_indicator.left { - order: -1; - /* 确保在滚动区域左侧 */ - margin: 0 4px; + left: 0; + background: linear-gradient(to right, + var(--p-content-background) 0%, + var(--p-content-background) 40%, + transparent 100%); } /* 右侧滚动指示器 */ .popup_container .tabs_scroll_indicator.right { - order: 1; - /* 确保在滚动区域右侧 */ - margin: 0 4px; + right: 0; + background: linear-gradient(to left, + var(--p-content-background) 0%, + var(--p-content-background) 40%, + transparent 100%); } .popup_container .tabs_scroll_indicator:hover { - color: rgba(255, 255, 255, 1); - background-color: rgba(60, 60, 60, 0.7); - transform: scale(1.1); - background-color: rgba(255, 255, 255, 0.1); - box-shadow: 0 0 5px rgba(255, 255, 255, 0.2); + color: var(--p-text-color); +} + +.popup_container .tabs_scroll_indicator:hover .pi { + transform: scale(1.2); } /* 滚动指示器图标样式 */ diff --git a/js/css/settings.css b/js/css/settings.css index 19b37d8..e00dc2a 100644 --- a/js/css/settings.css +++ b/js/css/settings.css @@ -165,7 +165,7 @@ } .settings-modal .p-dialog-content { - padding: 20px 40px; + padding: 10px 40px; position: relative; /* 作为二维码的定位参照,避免被外层裁剪 */ /* 内容区域自适应 */ @@ -1090,7 +1090,7 @@ width: 100%; display: flex; flex-direction: column; - gap: 16px; + gap: 1px; } /* 刷新按钮样式 */ @@ -1121,7 +1121,7 @@ /* 表单分组的间距 */ .settings-form-section { - margin-bottom: 16px; + margin-bottom: 10px; } /* 表单输入组件的样式 */ diff --git a/js/modules/PromptAssistant.js b/js/modules/PromptAssistant.js index 1f4f028..72fe625 100644 --- a/js/modules/PromptAssistant.js +++ b/js/modules/PromptAssistant.js @@ -65,6 +65,49 @@ export function updateAutoTranslateIndicators(enabled) { // ====================== 工具函数 ====================== +/** + * 计算小助手UI的预设宽度 + * 根据当前启用的功能数量返回对应的固定宽度值 + * @returns {number} 宽度值(像素) + */ +function calculateAssistantWidth() { + // 统计启用的功能 + const hasHistory = window.FEATURES.history; + const hasTag = window.FEATURES.tag; + const hasExpand = window.FEATURES.expand; + const hasTranslate = window.FEATURES.translate; + + // 统计非历史功能的数量 + const otherFeaturesCount = [hasTag, hasExpand, hasTranslate].filter(Boolean).length; + + // 根据功能组合返回预设宽度 + if (hasHistory && otherFeaturesCount === 3) { + // 所有功能全开 + return 179.04; + } else if (hasHistory && otherFeaturesCount === 2) { + // 历史 + 两个其他功能 + return 151.36; + } else if (!hasHistory && otherFeaturesCount === 3) { + // 只有三个其他功能 + return 87.21; + } else if (hasHistory && otherFeaturesCount === 1) { + // 历史 + 一个其他功能 + return 123.69; + } else if (!hasHistory && otherFeaturesCount === 2) { + // 只有两个其他功能 + return 59.54; + } else if (hasHistory && otherFeaturesCount === 0) { + // 只有历史功能 + return 87.21; + } else if (!hasHistory && otherFeaturesCount === 1) { + // 只有一个其他功能 + return 31.87; + } + + // 默认值(理论上不应该到这里) + return 31.87; +} + /** * 生成唯一请求ID */ @@ -460,9 +503,29 @@ class PromptAssistant { } // 为每个有效控件创建小助手 - validInputs.forEach(inputWidget => { + validInputs.forEach((inputWidget, widgetIndex) => { const inputId = inputWidget.name || inputWidget.id; - const assistantKey = `${node.id}_${inputId}`; + + // 生成唯一的 assistantKey + // 对于同名的多个输入框(如 Show Any 节点的列表输入),使用索引区分 + let assistantKey = `${node.id}_${inputId}`; + + // 检查是否存在同名的输入框,如果存在则使用索引或 DOM 元素的唯一标识 + const sameNameWidgets = validInputs.filter(w => (w.name || w.id) === inputId); + if (sameNameWidgets.length > 1) { + // 多个同名输入框,使用索引或输入框元素的内存地址作为唯一标识 + const inputEl = inputWidget.inputEl || inputWidget.element; + if (inputEl) { + // 为输入框元素添加唯一标识 + if (!inputEl.dataset.promptAssistantUniqueId) { + inputEl.dataset.promptAssistantUniqueId = `${node.id}_${inputId}_${widgetIndex}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + } + assistantKey = inputEl.dataset.promptAssistantUniqueId; + } else { + // 降级方案:使用索引 + assistantKey = `${node.id}_${inputId}_${widgetIndex}`; + } + } // 检查实例是否已存在 if (PromptAssistant.hasInstance(assistantKey)) { @@ -482,9 +545,9 @@ class PromptAssistant { } // 创建小助手实例 - const assistant = this.setupNodeAssistant(node, inputWidget); + const assistant = this.setupNodeAssistant(node, inputWidget, assistantKey); if (assistant) { - logger.log(`创建小助手 | 节点:${node.id} | 控件:${inputId} | 实例:${assistantKey}`); + logger.log(`创建小助手 | 节点:${node.id} | 控件:${inputId} | 索引:${widgetIndex} | 实例:${assistantKey}`); // 检查是否为指定的CLIP编码器节点,并应用自动翻译样式 // 使用从interceptor.js导入的CLIP_NODE_TYPES,确保UI指示器与实际功能一致 @@ -504,7 +567,7 @@ class PromptAssistant { * 为节点设置小助手 * 创建小助手实例并初始化显示状态 */ - setupNodeAssistant(node, inputWidget) { + setupNodeAssistant(node, inputWidget, assistantKey = null) { // 简化参数检查 if (!node || !inputWidget) { return null; @@ -528,7 +591,8 @@ class PromptAssistant { node, inputId, isNoteNode ? { ...inputWidget, inputEl: inputWidget.element } : inputWidget, // Note节点特殊处理 - nodeInfo + nodeInfo, + assistantKey ); if (assistant) { @@ -549,7 +613,7 @@ class PromptAssistant { * 创建小助手实例 * 根据节点和输入控件构建小助手对象并初始化UI */ - createAssistant(node, inputId, inputWidget, nodeInfo = {}) { + createAssistant(node, inputId, inputWidget, nodeInfo = {}, assistantKey = null) { // 简化前置检查 if (!window.FEATURES.enabled || !node || !inputId || !inputWidget || !UIToolkit.isValidInput(inputWidget)) { return null; @@ -562,7 +626,8 @@ class PromptAssistant { } const nodeId = node.id; - const widgetKey = `${nodeId}_${inputId}`; + // 使用传入的 assistantKey,如果没有则使用默认生成方式 + const widgetKey = assistantKey || `${nodeId}_${inputId}`; // 检查是否已存在实例 if (PromptAssistant.hasInstance(widgetKey)) { @@ -707,7 +772,7 @@ class PromptAssistant { // 创建折叠状态指示器图标 const indicatorDiv = document.createElement('div'); - indicatorDiv.className = 'prompt-assistant-indicator'; + indicatorDiv.className = 'prompt-assistant-indicator animate-creation'; // 从ResourceManager获取图标并添加到指示器 const mainIcon = ResourceManager.getIcon('icon-main.svg'); @@ -724,21 +789,37 @@ class PromptAssistant { widget.hoverArea = hoverAreaDiv; widget.indicator = indicatorDiv; widget.buttons = {}; - widget.isCollapsed = false; // 初始状态为展开 + widget.isCollapsed = true; // 初始状态为折叠 // 初始化UI组件和事件 this.addFunctionButtons(widget); this._setupUIEventHandling(widget, inputEl, containerDiv); this._setupUIPosition(widget, inputEl, containerDiv, canvasContainerRect); + // 立即设置预设的展开宽度,避免首次展开时需要测量 + const presetWidth = calculateAssistantWidth(); + containerDiv.style.setProperty('--expanded-width', `${presetWidth}px`); + logger.debug(`[宽度预设] 节点:${nodeId} | 宽度:${presetWidth}px`); + // 初始滚动条检测和位置调整 setTimeout(() => { this._adjustPositionForScrollbar(widget, inputEl); }, 100); // 延迟执行,确保DOM完全渲染 + // 设置初始折叠状态 + containerDiv.classList.add('collapsed'); + hoverAreaDiv.style.display = 'block'; // 显示悬停区域以便用户展开 + // 默认隐藏状态 containerDiv.style.display = 'none'; + // 移除动画类的定时器 + setTimeout(() => { + if (indicatorDiv && indicatorDiv.classList.contains('animate-creation')) { + indicatorDiv.classList.remove('animate-creation'); + } + }, 1000); + logger.log(`UI创建 | 结果:完成 | 节点ID: ${nodeId}`); return containerDiv; } catch (error) { @@ -749,40 +830,36 @@ class PromptAssistant { /** * 显示小助手UI - * 控制UI显示动画和状态,始终保持显示 + * 控制UI显示动画和状态,创建时直接以折叠状态显示 */ showAssistantUI(widget, forceAnimation = false) { if (!widget?.element) return; - // 避免重复显示和不必要的动画 - const isCurrentlyShown = widget.element.classList.contains('assistant-show'); - if (isCurrentlyShown && !forceAnimation) { - // 如果已经显示且不需要强制动画,则仅确保显示状态 + // 避免重复显示 + if (widget.element.classList.contains('assistant-show')) { + // 确保元素可见 widget.element.style.display = 'flex'; widget.element.style.opacity = '1'; return; } - // 设置过渡状态 - widget.isTransitioning = true; - - // 优化渲染性能 - widget.element.style.willChange = 'auto'; - widget.element.style.transform = 'translateZ(0)'; + // 直接显示,无动画过渡 widget.element.style.opacity = '1'; - - // 显示元素并应用动画类 widget.element.style.display = 'flex'; - void widget.element.offsetWidth; // 触发回流,确保动画生效 widget.element.classList.add('assistant-show'); - // 动画结束后重置过渡状态 - setTimeout(() => { - widget.isTransitioning = false; + // 确保悬停区域可见(用于折叠状态下的交互) + if (widget.isCollapsed && widget.hoverArea) { + widget.hoverArea.style.display = 'block'; + } - // 检查是否需要自动折叠 + // 重置过渡状态 + widget.isTransitioning = false; + + // 只有当明确不是折叠状态时才触发自动折叠 + if (!widget.isCollapsed) { this.triggerAutoCollapse(widget); - }, 300); // 与CSS动画时长匹配 + } } /** @@ -851,7 +928,24 @@ class PromptAssistant { * 展开小助手 */ _expandAssistant(widget) { - if (!widget || !widget.element || !widget.isCollapsed || widget.isTransitioning) return; + if (!widget || !widget.element) return; + + // 如果已经是展开状态,直接返回 + if (!widget.isCollapsed && !widget.isTransitioning) return; + + // 如果正在折叠中,允许打断折叠动画 + if (widget.isTransitioning) { + // 清理折叠动画的定时器 + if (widget._transitionTimer) { + clearTimeout(widget._transitionTimer); + widget._transitionTimer = null; + } + // 重置过渡状态,准备执行展开 + widget.isTransitioning = false; + } + + // 如果不是折叠状态,不执行展开 + if (!widget.isCollapsed) return; widget.isTransitioning = true; @@ -862,38 +956,15 @@ class PromptAssistant { const containerDiv = widget.element; - // 计算展开后的宽度 + // 使用预设宽度,不再实时测量 let targetWidth = containerDiv.style.getPropertyValue('--expanded-width'); - // 如果没有保存宽度或需要重新计算 + // 如果没有预设宽度,立即计算并设置 if (!targetWidth || targetWidth === '') { - // 先移除折叠类,但保持不可见状态进行测量 - containerDiv.classList.remove('collapsed'); - const originalDisplay = containerDiv.style.display; - const originalOpacity = containerDiv.style.opacity; - - // 临时设置样式以便测量 - containerDiv.style.opacity = '0'; - containerDiv.style.display = 'flex'; - containerDiv.style.position = 'absolute'; - - // 强制回流并测量 - void containerDiv.offsetWidth; - - // 获取自然宽度 - const naturalWidth = containerDiv.offsetWidth; - targetWidth = naturalWidth + 'px'; - - // 保存宽度供将来使用 + const presetWidth = calculateAssistantWidth(); + targetWidth = `${presetWidth}px`; containerDiv.style.setProperty('--expanded-width', targetWidth); - - // 恢复原始样式 - containerDiv.style.opacity = originalOpacity; - containerDiv.style.position = ''; - - // 重新应用折叠类以准备动画 - containerDiv.classList.add('collapsed'); - void containerDiv.offsetWidth; // 强制回流 + logger.debug(`[宽度预设] 展开时设置 | 宽度:${targetWidth}`); } // 手动设置宽度转换 @@ -908,12 +979,18 @@ class PromptAssistant { // 设置目标宽度以触发过渡 containerDiv.style.width = targetWidth; + // 清理之前的过渡定时器 + if (widget._transitionTimer) { + clearTimeout(widget._transitionTimer); + } + // 动画结束后清理 - setTimeout(() => { + widget._transitionTimer = setTimeout(() => { // 移除固定宽度,恢复自动宽度 containerDiv.style.width = ''; widget.isCollapsed = false; widget.isTransitioning = false; + widget._transitionTimer = null; }, 300); } @@ -1003,6 +1080,21 @@ class PromptAssistant { }); } + /** + * 更新所有实例的预设宽度 + * 在功能开关变更时调用,重新计算并设置宽度 + */ + updateAllInstancesWidth() { + const newWidth = calculateAssistantWidth(); + logger.debug(`[宽度更新] 计算新宽度:${newWidth}px | 实例数量:${PromptAssistant.instances.size}`); + + PromptAssistant.instances.forEach((widget) => { + if (widget && widget.element) { + widget.element.style.setProperty('--expanded-width', `${newWidth}px`); + } + }); + } + /** * 显示状态提示 * 创建临时提示信息气泡 @@ -1048,6 +1140,9 @@ class PromptAssistant { // 延迟保存宽度,确保DOM已完全渲染 setTimeout(saveOriginalWidth, 300); + // 过渡动画定时器,保存到widget对象上供所有函数访问 + widget._transitionTimer = null; + // 折叠函数 const collapseAssistant = () => { if (widget.isCollapsed || widget.isTransitioning) return; @@ -1063,53 +1158,51 @@ class PromptAssistant { // 显示悬停区域,用于检测鼠标悬停以展开UI widget.hoverArea.style.display = 'block'; + // 清理之前的过渡定时器 + if (widget._transitionTimer) { + clearTimeout(widget._transitionTimer); + } + // 动画结束后重置过渡状态 - setTimeout(() => { + widget._transitionTimer = setTimeout(() => { widget.isTransitioning = false; + widget._transitionTimer = null; }, 300); }; // 展开函数 const expandAssistant = () => { - if (!widget.isCollapsed || widget.isTransitioning) return; + // 如果已经是展开状态,直接返回 + if (!widget.isCollapsed && !widget.isTransitioning) return; + + // 如果正在折叠中,允许打断折叠动画 + if (widget.isTransitioning) { + // 清理折叠动画的定时器 + if (widget._transitionTimer) { + clearTimeout(widget._transitionTimer); + widget._transitionTimer = null; + } + // 重置过渡状态,准备执行展开 + widget.isTransitioning = false; + } + + // 如果不是折叠状态,不执行展开 + if (!widget.isCollapsed) return; widget.isTransitioning = true; // 隐藏悬停区域,避免覆盖按钮 widget.hoverArea.style.display = 'none'; - // 计算展开后的宽度 + // 使用预设宽度,不再实时测量 let targetWidth = widget.element.style.getPropertyValue('--expanded-width'); - // 如果没有保存宽度或需要重新计算 + // 如果没有预设宽度,立即计算并设置 if (!targetWidth || targetWidth === '') { - // 先移除折叠类,但保持不可见状态进行测量 - containerDiv.classList.remove('collapsed'); - const originalDisplay = containerDiv.style.display; - const originalOpacity = containerDiv.style.opacity; - - // 临时设置样式以便测量 - containerDiv.style.opacity = '0'; - containerDiv.style.display = 'flex'; - containerDiv.style.position = 'absolute'; - - // 强制回流并测量 - void containerDiv.offsetWidth; - - // 获取自然宽度 - const naturalWidth = containerDiv.offsetWidth; - targetWidth = naturalWidth + 'px'; - - // 保存宽度供将来使用 + const presetWidth = calculateAssistantWidth(); + targetWidth = `${presetWidth}px`; containerDiv.style.setProperty('--expanded-width', targetWidth); - - // 恢复原始样式 - containerDiv.style.opacity = originalOpacity; - containerDiv.style.position = ''; - - // 重新应用折叠类以准备动画 - containerDiv.classList.add('collapsed'); - void containerDiv.offsetWidth; // 强制回流 + logger.debug(`[宽度预设] 事件处理中设置 | 宽度:${targetWidth}`); } // 手动设置宽度转换 @@ -1124,12 +1217,18 @@ class PromptAssistant { // 设置目标宽度以触发过渡 containerDiv.style.width = targetWidth; + // 清理之前的过渡定时器 + if (widget._transitionTimer) { + clearTimeout(widget._transitionTimer); + } + // 动画结束后清理 - setTimeout(() => { + widget._transitionTimer = setTimeout(() => { // 移除固定宽度,恢复自动宽度 containerDiv.style.width = ''; widget.isCollapsed = false; widget.isTransitioning = false; + widget._transitionTimer = null; }, 300); }; @@ -1201,6 +1300,10 @@ class PromptAssistant { clearTimeout(autoCollapseTimer); autoCollapseTimer = null; } + if (widget._transitionTimer) { + clearTimeout(widget._transitionTimer); + widget._transitionTimer = null; + } }); // 创建后自动折叠功能 diff --git a/js/modules/apiConfigManager.js b/js/modules/apiConfigManager.js index 486ba7e..40cc9f7 100644 --- a/js/modules/apiConfigManager.js +++ b/js/modules/apiConfigManager.js @@ -36,6 +36,19 @@ class APIConfigManager { title: 'API管理器', dialogClassName: 'api-config-dialog', disableBackdropAndCloseOnClickOutside: true, + renderNotice: (noticeArea) => { + // 添加副标题说明 + const subtitle = document.createElement('div'); + subtitle.style.cssText = ` + font-size: 12px; + color: var(--p-text-muted-color); + padding: 4px 40px 4px 40px; + line-height: 1.6; + text-align: left; + `; + subtitle.textContent = '⚠️本插件仅提供API调用,服务可用性、质量效果及收费标准均由第三方提供商以及所调用的模型能力决定。填写的APIkey仅保存在本地插件目录“config/config.json”文件中。请不要向别人分享config.json文件!避免APIkey泄露造成损失。本插件不对账号问题负责。'; + noticeArea.appendChild(subtitle); + }, renderContent: (container) => { this._createAPIConfigUI(container); }, @@ -80,32 +93,46 @@ class APIConfigManager { // 确保每个提供商都有配置 const providerList = ["zhipu", "siliconflow", "302ai", "ollama", "custom"]; providerList.forEach(provider => { - // 确保LLM提供商配置存在 - if (!config.llm.providers[provider]) { - config.llm.providers[provider] = { - model: provider === "zhipu" ? "glm-4-flash-250414" : - (provider === "siliconflow" ? "Qwen/Qwen2.5-7B-Instruct" : ""), - base_url: provider === "zhipu" ? "https://open.bigmodel.cn/api/paas/v4" : - (provider === "siliconflow" ? "https://api.siliconflow.cn/v1" : ""), - api_key: "", - temperature: 0.7, - max_tokens: 2000, - top_p: 0.9 - }; + // 确保LLM提供商配置存在 + if (!config.llm.providers[provider]) { + const providerConfig = { + model: provider === "zhipu" ? "glm-4-flash-250414" : + (provider === "siliconflow" ? "Qwen/Qwen2.5-7B-Instruct" : + (provider === "ollama" ? "llama3.1" : "")), + base_url: provider === "zhipu" ? "https://open.bigmodel.cn/api/paas/v4" : + (provider === "siliconflow" ? "https://api.siliconflow.cn/v1" : + (provider === "ollama" ? "http://localhost:11434/v1" : "")), + api_key: provider === "ollama" ? "ollama" : "", + temperature: 0.7, + max_tokens: 2000, + top_p: 0.9 + }; + // Ollama添加auto_unload参数 + if (provider === "ollama") { + providerConfig.auto_unload = true; } + config.llm.providers[provider] = providerConfig; + } // 确保视觉模型提供商配置存在 if (!config.vision.providers[provider]) { - config.vision.providers[provider] = { + const visionProviderConfig = { model: provider === "zhipu" ? "glm-4v-flash" : - (provider === "siliconflow" ? "THUDM/GLM-4.1V-9B-Thinking" : ""), + (provider === "siliconflow" ? "THUDM/GLM-4.1V-9B-Thinking" : + (provider === "ollama" ? "llava:latest" : "")), base_url: provider === "zhipu" ? "https://open.bigmodel.cn/api/paas/v4/chat/completions" : - (provider === "siliconflow" ? "https://api.siliconflow.cn/v1/chat/completions" : ""), - api_key: "", + (provider === "siliconflow" ? "https://api.siliconflow.cn/v1/chat/completions" : + (provider === "ollama" ? "http://localhost:11434/v1" : "")), + api_key: provider === "ollama" ? "ollama" : "", temperature: 0.7, max_tokens: 2000, top_p: 0.9 }; + // Ollama添加auto_unload参数 + if (provider === "ollama") { + visionProviderConfig.auto_unload = true; + } + config.vision.providers[provider] = visionProviderConfig; } }); @@ -245,7 +272,7 @@ class APIConfigManager { { text: '智谱', url: 'https://www.bigmodel.cn/invite?icode=Wz1tQAT40T9M8vwp%2F1db7nHEaazDlIZGj9HxftzTbt4%3D' }, { text: '硅基流动', url: 'https://cloud.siliconflow.cn/i/FCDL2zBQ' }, { text: '302.AI', url: 'https://share.302.ai/JrO51c' } - ], { prefixText: '大模型API申请:' }); + ], { prefixText: 'API key申请:' }); // 创建选择器和输入框 const llmProvider = createSelectGroup('', [ @@ -257,6 +284,9 @@ class APIConfigManager { ]); const llmModelInput = createInputGroup('', '请输入模型名称'); this.llmModel = llmModelInput.input; // 保存引用以供外部函数使用 + + // 为模型输入框添加自动完成功能 + this._attachAutocomplete(this.llmModel, 'llm', () => this.llmProvider.value); // 创建base_url输入框(初始隐藏) const llmBaseUrlInput = createInputGroup('Base URL', '请输入API基础URL,例如: https://api.example.com/v1'); @@ -283,7 +313,54 @@ class APIConfigManager { const llmApiKeyInput = createInputGroup('API Key', '请输入模型 API Key'); this.llmApiKey = llmApiKeyInput.input; // 保存引用以供外部函数使用 - // ---模型参数配置--- + // ---模型参数配置(可折叠)--- + // 创建高级设置折叠容器 + const llmAdvancedContainer = document.createElement('div'); + llmAdvancedContainer.className = 'advanced-settings-container'; + llmAdvancedContainer.style.cssText = ` + margin-top: 12px; + `; + + // 创建高级设置标题(可点击折叠/展开) + const llmAdvancedHeader = document.createElement('div'); + llmAdvancedHeader.className = 'advanced-settings-header'; + llmAdvancedHeader.style.cssText = ` + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + padding: 2px 0; + user-select: none; + `; + + const llmAdvancedIcon = document.createElement('span'); + llmAdvancedIcon.className = 'pi pi-angle-right'; + llmAdvancedIcon.style.cssText = ` + font-size: 12px; + color: var(--p-text-muted-color); + transition: transform 0.2s; + `; + + const llmAdvancedTitle = document.createElement('span'); + llmAdvancedTitle.textContent = '高级设置'; + llmAdvancedTitle.style.cssText = ` + font-size: 13px; + color: var(--p-text-color); + font-weight: 500; + `; + + llmAdvancedHeader.appendChild(llmAdvancedIcon); + llmAdvancedHeader.appendChild(llmAdvancedTitle); + + // 创建参数内容区域(默认隐藏) + const llmAdvancedContent = document.createElement('div'); + llmAdvancedContent.className = 'advanced-settings-content'; + llmAdvancedContent.style.cssText = ` + display: none; + overflow: hidden; + transition: all 0.3s ease; + `; + // 创建温度和最大token输入框 const llmTemperatureInput = createInputGroup('', '0.1-2.0,控制输出随机性'); llmTemperatureInput.input.type = 'number'; @@ -315,11 +392,82 @@ class APIConfigManager { { label: '核采样 (Top-P)', element: this.llmTopP }, { label: '最大Token数', element: this.llmMaxTokens } ]); + + llmAdvancedContent.appendChild(llmParamsGroup); + + // 创建Ollama专属选项(初始隐藏) + const llmOllamaOptionsGroup = document.createElement('div'); + llmOllamaOptionsGroup.className = 'ollama-options-group'; + llmOllamaOptionsGroup.style.cssText = ` + display: none; + margin-top: 12px; + padding: 12px; + background: var(--p-content-background); + border: 1px solid var(--p-divider-color); + border-radius: 4px; + `; + + const llmOllamaAutoUnload = document.createElement('div'); + llmOllamaAutoUnload.className = 'p-field-checkbox'; + llmOllamaAutoUnload.style.cssText = ` + display: flex; + align-items: center; + gap: 8px; + `; + + const llmOllamaAutoUnloadCheckbox = document.createElement('input'); + llmOllamaAutoUnloadCheckbox.type = 'checkbox'; + llmOllamaAutoUnloadCheckbox.id = 'llm-ollama-auto-unload'; + llmOllamaAutoUnloadCheckbox.className = 'p-checkbox'; + llmOllamaAutoUnloadCheckbox.checked = true; // 默认勾选 + this.llmOllamaAutoUnload = llmOllamaAutoUnloadCheckbox; + + const llmOllamaAutoUnloadLabel = document.createElement('label'); + llmOllamaAutoUnloadLabel.htmlFor = 'llm-ollama-auto-unload'; + llmOllamaAutoUnloadLabel.textContent = '自动释放显存'; + llmOllamaAutoUnloadLabel.style.cssText = ` + font-size: 13px; + color: var(--p-text-color); + cursor: pointer; + user-select: none; + `; + + const llmOllamaAutoUnloadHint = document.createElement('div'); + llmOllamaAutoUnloadHint.style.cssText = ` + font-size: 12px; + color: var(--p-text-muted-color); + margin-top: 4px; + margin-left: 24px; + `; + llmOllamaAutoUnloadHint.textContent = '启用后,模型在生成响应后会立即卸载,释放显存和内存(推荐)'; + + llmOllamaAutoUnload.appendChild(llmOllamaAutoUnloadCheckbox); + llmOllamaAutoUnload.appendChild(llmOllamaAutoUnloadLabel); + llmOllamaOptionsGroup.appendChild(llmOllamaAutoUnload); + llmOllamaOptionsGroup.appendChild(llmOllamaAutoUnloadHint); + + llmAdvancedContent.appendChild(llmOllamaOptionsGroup); + + // 折叠/展开切换 + let llmIsExpanded = false; + llmAdvancedHeader.addEventListener('click', () => { + llmIsExpanded = !llmIsExpanded; + if (llmIsExpanded) { + llmAdvancedContent.style.display = 'block'; + llmAdvancedIcon.style.transform = 'rotate(90deg)'; + } else { + llmAdvancedContent.style.display = 'none'; + llmAdvancedIcon.style.transform = 'rotate(0deg)'; + } + }); + + llmAdvancedContainer.appendChild(llmAdvancedHeader); + llmAdvancedContainer.appendChild(llmAdvancedContent); llmSection.appendChild(llmProviderGroup); llmSection.appendChild(llmBaseUrlInput.group); // 添加base_url输入框 llmSection.appendChild(llmApiKeyInput.group); - llmSection.appendChild(llmParamsGroup); // 添加模型参数配置(三个参数在同一行) + llmSection.appendChild(llmAdvancedContainer); // 添加高级设置折叠容器 // 监听LLM提供商选择变化 llmProvider.select.addEventListener('change', () => { @@ -331,19 +479,23 @@ class APIConfigManager { this._saveLLMProviderConfig(oldProvider); } - // 根据提供商类型显示/隐藏 base_url 与 API Key + // 根据提供商类型显示/隐藏 base_url 与 API Key 以及 Ollama 选项 if (provider === 'custom') { llmBaseUrlInput.group.style.display = 'block'; llmApiKeyInput.group.style.display = 'block'; + llmOllamaOptionsGroup.style.display = 'none'; } else if (provider === '302ai') { llmBaseUrlInput.group.style.display = 'none'; llmApiKeyInput.group.style.display = 'block'; + llmOllamaOptionsGroup.style.display = 'none'; } else if (provider === 'ollama') { llmBaseUrlInput.group.style.display = 'none'; llmApiKeyInput.group.style.display = 'none'; + llmOllamaOptionsGroup.style.display = 'block'; } else { llmBaseUrlInput.group.style.display = 'none'; llmApiKeyInput.group.style.display = 'block'; + llmOllamaOptionsGroup.style.display = 'none'; } // 加载选定提供商的配置 @@ -373,6 +525,8 @@ class APIConfigManager { // Ollama base_url 与 api_key 内置 this.llmBaseUrl.value = ''; this._setMaskedValue(this.llmApiKey, 'ollama'); + // 加载 Ollama 自动释放显存选项 + this.llmOllamaAutoUnload.checked = providerConfig.auto_unload !== false; // 默认为 true } } else { // 设置默认模型名称 @@ -384,6 +538,8 @@ class APIConfigManager { this.llmModel.value = 'Qwen/Qwen2.5-7B-Instruct'; } else if (provider === 'ollama') { this.llmModel.value = 'llama3.1'; + // Ollama 默认启用自动释放显存 + this.llmOllamaAutoUnload.checked = true; } else { this.llmModel.value = ''; } @@ -427,6 +583,9 @@ class APIConfigManager { ]); const visionModelInput = createInputGroup('', '请输入模型名称'); this.visionModel = visionModelInput.input; // 保存引用以供外部函数使用 + + // 为模型输入框添加自动完成功能 + this._attachAutocomplete(this.visionModel, 'vision', () => this.visionProvider.value); // 创建base_url输入框(初始隐藏) const visionBaseUrlInput = createInputGroup('Base URL', '请输入API基础URL,例如: https://api.example.com/v1'); @@ -453,7 +612,54 @@ class APIConfigManager { const visionApiKeyInput = createInputGroup('API Key', '请输入模型 API Key'); this.visionApiKey = visionApiKeyInput.input; // 保存引用以供外部函数使用 - // ---视觉模型参数配置--- + // ---视觉模型参数配置(可折叠)--- + // 创建高级设置折叠容器 + const visionAdvancedContainer = document.createElement('div'); + visionAdvancedContainer.className = 'advanced-settings-container'; + visionAdvancedContainer.style.cssText = ` + margin-top: 12px; + `; + + // 创建高级设置标题(可点击折叠/展开) + const visionAdvancedHeader = document.createElement('div'); + visionAdvancedHeader.className = 'advanced-settings-header'; + visionAdvancedHeader.style.cssText = ` + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + padding: 8px 0; + user-select: none; + `; + + const visionAdvancedIcon = document.createElement('span'); + visionAdvancedIcon.className = 'pi pi-angle-right'; + visionAdvancedIcon.style.cssText = ` + font-size: 12px; + color: var(--p-text-muted-color); + transition: transform 0.2s; + `; + + const visionAdvancedTitle = document.createElement('span'); + visionAdvancedTitle.textContent = '高级设置'; + visionAdvancedTitle.style.cssText = ` + font-size: 13px; + color: var(--p-text-color); + font-weight: 500; + `; + + visionAdvancedHeader.appendChild(visionAdvancedIcon); + visionAdvancedHeader.appendChild(visionAdvancedTitle); + + // 创建参数内容区域(默认隐藏) + const visionAdvancedContent = document.createElement('div'); + visionAdvancedContent.className = 'advanced-settings-content'; + visionAdvancedContent.style.cssText = ` + display: none; + overflow: hidden; + transition: all 0.3s ease; + `; + // 创建温度和最大token输入框 const visionTemperatureInput = createInputGroup('', '0.1-2.0,控制输出随机性'); visionTemperatureInput.input.type = 'number'; @@ -485,11 +691,82 @@ class APIConfigManager { { label: '核采样 (Top-P)', element: this.visionTopP }, { label: '最大Token数', element: this.visionMaxTokens } ]); + + visionAdvancedContent.appendChild(visionParamsGroup); + + // 创建Ollama专属选项(初始隐藏) + const visionOllamaOptionsGroup = document.createElement('div'); + visionOllamaOptionsGroup.className = 'ollama-options-group'; + visionOllamaOptionsGroup.style.cssText = ` + display: none; + margin-top: 12px; + padding: 12px; + background: var(--p-content-background); + border: 1px solid var(--p-divider-color); + border-radius: 4px; + `; + + const visionOllamaAutoUnload = document.createElement('div'); + visionOllamaAutoUnload.className = 'p-field-checkbox'; + visionOllamaAutoUnload.style.cssText = ` + display: flex; + align-items: center; + gap: 8px; + `; + + const visionOllamaAutoUnloadCheckbox = document.createElement('input'); + visionOllamaAutoUnloadCheckbox.type = 'checkbox'; + visionOllamaAutoUnloadCheckbox.id = 'vision-ollama-auto-unload'; + visionOllamaAutoUnloadCheckbox.className = 'p-checkbox'; + visionOllamaAutoUnloadCheckbox.checked = true; // 默认勾选 + this.visionOllamaAutoUnload = visionOllamaAutoUnloadCheckbox; + + const visionOllamaAutoUnloadLabel = document.createElement('label'); + visionOllamaAutoUnloadLabel.htmlFor = 'vision-ollama-auto-unload'; + visionOllamaAutoUnloadLabel.textContent = '自动释放显存'; + visionOllamaAutoUnloadLabel.style.cssText = ` + font-size: 13px; + color: var(--p-text-color); + cursor: pointer; + user-select: none; + `; + + const visionOllamaAutoUnloadHint = document.createElement('div'); + visionOllamaAutoUnloadHint.style.cssText = ` + font-size: 12px; + color: var(--p-text-muted-color); + margin-top: 4px; + margin-left: 24px; + `; + visionOllamaAutoUnloadHint.textContent = '启用后,模型在生成响应后会立即卸载,释放显存和内存(推荐)'; + + visionOllamaAutoUnload.appendChild(visionOllamaAutoUnloadCheckbox); + visionOllamaAutoUnload.appendChild(visionOllamaAutoUnloadLabel); + visionOllamaOptionsGroup.appendChild(visionOllamaAutoUnload); + visionOllamaOptionsGroup.appendChild(visionOllamaAutoUnloadHint); + + visionAdvancedContent.appendChild(visionOllamaOptionsGroup); + + // 折叠/展开切换 + let visionIsExpanded = false; + visionAdvancedHeader.addEventListener('click', () => { + visionIsExpanded = !visionIsExpanded; + if (visionIsExpanded) { + visionAdvancedContent.style.display = 'block'; + visionAdvancedIcon.style.transform = 'rotate(90deg)'; + } else { + visionAdvancedContent.style.display = 'none'; + visionAdvancedIcon.style.transform = 'rotate(0deg)'; + } + }); + + visionAdvancedContainer.appendChild(visionAdvancedHeader); + visionAdvancedContainer.appendChild(visionAdvancedContent); visionSection.appendChild(visionProviderGroup); visionSection.appendChild(visionBaseUrlInput.group); // 添加base_url输入框 visionSection.appendChild(visionApiKeyInput.group); - visionSection.appendChild(visionParamsGroup); // 添加视觉模型参数配置(三个参数在同一行) + visionSection.appendChild(visionAdvancedContainer); // 添加高级设置折叠容器 // 监听视觉模型提供商选择变化 visionProvider.select.addEventListener('change', () => { @@ -501,19 +778,23 @@ class APIConfigManager { this._saveVisionProviderConfig(oldProvider); } - // 根据提供商类型显示/隐藏 base_url 与 API Key + // 根据提供商类型显示/隐藏 base_url 与 API Key 以及 Ollama 选项 if (provider === 'custom') { visionBaseUrlInput.group.style.display = 'block'; visionApiKeyInput.group.style.display = 'block'; + visionOllamaOptionsGroup.style.display = 'none'; } else if (provider === '302ai') { visionBaseUrlInput.group.style.display = 'none'; visionApiKeyInput.group.style.display = 'block'; + visionOllamaOptionsGroup.style.display = 'none'; } else if (provider === 'ollama') { visionBaseUrlInput.group.style.display = 'none'; visionApiKeyInput.group.style.display = 'none'; + visionOllamaOptionsGroup.style.display = 'block'; } else { visionBaseUrlInput.group.style.display = 'none'; visionApiKeyInput.group.style.display = 'block'; + visionOllamaOptionsGroup.style.display = 'none'; } // 加载选定提供商的配置 @@ -541,6 +822,8 @@ class APIConfigManager { } else if (provider === 'ollama') { this.visionBaseUrl.value = ''; this._setMaskedValue(this.visionApiKey, 'ollama'); + // 加载 Ollama 自动释放显存选项 + this.visionOllamaAutoUnload.checked = providerConfig.auto_unload !== false; // 默认为 true } } else { // 设置默认模型名称 @@ -552,6 +835,8 @@ class APIConfigManager { this.visionModel.value = 'THUDM/GLM-4.1V-9B-Thinking'; } else if (provider === 'ollama') { this.visionModel.value = 'llava:latest'; + // Ollama 默认启用自动释放显存 + this.visionOllamaAutoUnload.checked = true; } else { this.visionModel.value = ''; } @@ -780,6 +1065,11 @@ class APIConfigManager { max_tokens: parseInt(this.llmMaxTokens.value) || 2000, top_p: parseFloat(this.llmTopP.value) || 0.9 }; + + // 如果是 Ollama,保存自动释放显存选项 + if (provider === 'ollama') { + providerConfig.auto_unload = this.llmOllamaAutoUnload ? this.llmOllamaAutoUnload.checked : true; + } // 保留现有配置中的has_key标记 if (this.llmAllProviders[provider] && this.llmAllProviders[provider].has_key) { @@ -842,6 +1132,11 @@ class APIConfigManager { max_tokens: parseInt(this.visionMaxTokens.value) || 2000, top_p: parseFloat(this.visionTopP.value) || 0.9 }; + + // 如果是 Ollama,保存自动释放显存选项 + if (provider === 'ollama') { + providerConfig.auto_unload = this.visionOllamaAutoUnload ? this.visionOllamaAutoUnload.checked : true; + } // 保留现有配置中的has_key标记 if (this.visionAllProviders[provider] && this.visionAllProviders[provider].has_key) { @@ -865,6 +1160,351 @@ class APIConfigManager { logger.debug(`已保存 ${provider} 提供商的视觉模型配置`); } + + /** + * 为输入框附加自动完成功能 + * @param {HTMLInputElement} input 输入框元素 + * @param {string} modelType 模型类型 ('llm' 或 'vision') + * @param {Function} getProvider 获取当前提供商的函数 + */ + _attachAutocomplete(input, modelType, getProvider) { + let autocompletePanel = null; + let modelsList = []; + let isOpen = false; + let selectedIndex = -1; + let showRecommended = false; // 当前显示模式:false=所有模型,true=推荐模型 + + // 创建自动完成下拉面板 + const createAutocompletePanel = () => { + const panel = document.createElement('div'); + panel.className = 'pa-dropdown-panel p-dropdown-panel p-component settings-modal-dropdown-panel p-hidden'; + panel.style.position = 'fixed'; + panel.style.zIndex = '10001'; + + const itemsWrapper = document.createElement('div'); + itemsWrapper.className = 'p-dropdown-items-wrapper'; + + const itemsList = document.createElement('ul'); + itemsList.className = 'p-dropdown-items'; + itemsList.setAttribute('role', 'listbox'); + + itemsWrapper.appendChild(itemsList); + panel.appendChild(itemsWrapper); + + return panel; + }; + + // 创建切换按钮(用于智谱、硅基流动、302) + const createToggleButton = () => { + const toggleWrapper = document.createElement('div'); + toggleWrapper.className = 'model-list-toggle-wrapper'; + toggleWrapper.style.cssText = ` + padding: 8px 12px; + border-bottom: 1px solid var(--p-divider-color); + background: var(--p-panel-header-background); + display: flex; + align-items: center; + gap: 8px; + `; + + const label = document.createElement('span'); + label.textContent = '筛选:'; + label.style.cssText = ` + font-size: 12px; + color: var(--p-text-color); + `; + + // 创建按钮容器 + const buttonsContainer = document.createElement('div'); + buttonsContainer.style.cssText = ` + display: flex; + gap: 4px; + `; + + // 创建"所有模型"按钮 + const allModelsButton = document.createElement('button'); + allModelsButton.className = 'p-button p-button-sm p-button-text'; + allModelsButton.textContent = '所有模型'; + allModelsButton.style.cssText = ` + padding: 4px 8px; + font-size: 12px; + height: auto; + min-width: auto; + color: var(--p-primary-color); + `; + + // 创建"推荐模型"按钮 + const recommendedButton = document.createElement('button'); + recommendedButton.className = 'p-button p-button-sm p-button-text'; + recommendedButton.textContent = '推荐模型'; + recommendedButton.style.cssText = ` + padding: 4px 8px; + font-size: 12px; + height: auto; + min-width: auto; + color: var(--p-text-muted-color); + `; + + // 更新按钮状态 + const updateButtonStates = () => { + if (showRecommended) { + // 推荐模型激活 + allModelsButton.style.color = 'var(--p-text-muted-color)'; + recommendedButton.style.color = 'var(--p-primary-color)'; + } else { + // 所有模型激活 + allModelsButton.style.color = 'var(--p-primary-color)'; + recommendedButton.style.color = 'var(--p-text-muted-color)'; + } + }; + + // "所有模型"按钮点击事件 + allModelsButton.addEventListener('click', (e) => { + e.stopPropagation(); + if (showRecommended) { + showRecommended = false; + updateButtonStates(); + loadModels(); + } + }); + + // "推荐模型"按钮点击事件 + recommendedButton.addEventListener('click', (e) => { + e.stopPropagation(); + if (!showRecommended) { + showRecommended = true; + updateButtonStates(); + loadModels(); + } + }); + + buttonsContainer.appendChild(allModelsButton); + buttonsContainer.appendChild(recommendedButton); + + toggleWrapper.appendChild(label); + toggleWrapper.appendChild(buttonsContainer); + + return toggleWrapper; + }; + + // 更新面板位置 + const updatePanelPosition = () => { + if (!isOpen || !autocompletePanel) return; + + const rect = input.getBoundingClientRect(); + autocompletePanel.style.top = rect.bottom + 'px'; + autocompletePanel.style.left = rect.left + 'px'; + autocompletePanel.style.width = rect.width + 'px'; + }; + + // 关闭面板 + const closePanel = () => { + if (!isOpen || !autocompletePanel) return; + isOpen = false; + selectedIndex = -1; + + autocompletePanel.classList.add('p-hidden'); + autocompletePanel.classList.remove('p-enter-active'); + + window.removeEventListener('resize', updatePanelPosition); + window.removeEventListener('scroll', updatePanelPosition, true); + + setTimeout(() => { + if (autocompletePanel && autocompletePanel.parentNode === document.body) { + document.body.removeChild(autocompletePanel); + } + // 清空面板引用,下次打开时重新创建(以便根据提供商决定是否显示切换按钮) + autocompletePanel = null; + showRecommended = false; // 重置为默认状态 + document.removeEventListener('click', handleOutsideClick, true); + }, 120); + }; + + // 加载模型列表(独立函数,用于初次加载和切换时重载) + const loadModels = async () => { + const provider = getProvider(); + const itemsList = autocompletePanel.querySelector('.p-dropdown-items'); + + // 显示加载状态 + itemsList.innerHTML = '
  • 正在加载模型列表...
  • '; + + // 获取模型列表 + try { + const response = await fetch('/prompt_assistant/api/models/list', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + provider: provider, + model_type: modelType, + recommended: showRecommended + }) + }); + + const result = await response.json(); + + if (result.success && result.models && result.models.length > 0) { + modelsList = result.models; + renderModelsList(modelsList); + } else { + const errorMsg = result.error || '未找到可用的模型'; + itemsList.innerHTML = `
  • ${errorMsg}
  • `; + modelsList = []; + } + } catch (error) { + logger.error(`获取模型列表失败: ${error.message}`); + itemsList.innerHTML = `
  • 获取模型列表失败: ${error.message}
  • `; + modelsList = []; + } + }; + + // 打开面板并显示模型列表 + const openPanel = async () => { + const provider = getProvider(); + + // 创建面板(如果不存在) + if (!autocompletePanel) { + autocompletePanel = createAutocompletePanel(); + + // 如果是智谱、硅基流动或302,添加切换按钮 + if (provider === 'zhipu' || provider === 'siliconflow' || provider === '302ai') { + const toggleButton = createToggleButton(); + autocompletePanel.insertBefore(toggleButton, autocompletePanel.firstChild); + } + } + + // 添加到body + document.body.appendChild(autocompletePanel); + + // 计算位置 + const rect = input.getBoundingClientRect(); + autocompletePanel.style.top = rect.bottom + 'px'; + autocompletePanel.style.left = rect.left + 'px'; + autocompletePanel.style.width = rect.width + 'px'; + + // 显示面板 + autocompletePanel.classList.remove('p-hidden'); + autocompletePanel.offsetHeight; // 强制重排 + autocompletePanel.classList.add('p-enter-active'); + + isOpen = true; + + // 添加事件监听 + document.addEventListener('click', handleOutsideClick, true); + window.addEventListener('resize', updatePanelPosition); + window.addEventListener('scroll', updatePanelPosition, true); + + // 加载模型列表 + await loadModels(); + }; + + // 渲染模型列表 + const renderModelsList = (models, filterText = '') => { + if (!autocompletePanel) return; + + const itemsList = autocompletePanel.querySelector('.p-dropdown-items'); + itemsList.innerHTML = ''; + + // 过滤模型 + const filteredModels = filterText + ? models.filter(model => model.id.toLowerCase().includes(filterText.toLowerCase())) + : models; + + if (filteredModels.length === 0) { + itemsList.innerHTML = '
  • 未找到匹配的模型
  • '; + return; + } + + filteredModels.forEach((model, index) => { + const item = document.createElement('li'); + item.className = 'p-dropdown-item'; + item.textContent = model.name; + item.dataset.value = model.id; + item.dataset.index = index; + item.setAttribute('role', 'option'); + + item.addEventListener('click', (e) => { + e.stopPropagation(); + input.value = model.id; + closePanel(); + }); + + itemsList.appendChild(item); + }); + }; + + // 处理键盘导航 + const handleKeyDown = (e) => { + if (!isOpen || !autocompletePanel) return; + + const itemsList = autocompletePanel.querySelector('.p-dropdown-items'); + const items = Array.from(itemsList.querySelectorAll('.p-dropdown-item[data-value]')); + + if (items.length === 0) return; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + selectedIndex = (selectedIndex + 1) % items.length; + updateSelection(items); + break; + case 'ArrowUp': + e.preventDefault(); + selectedIndex = selectedIndex <= 0 ? items.length - 1 : selectedIndex - 1; + updateSelection(items); + break; + case 'Enter': + e.preventDefault(); + if (selectedIndex >= 0 && selectedIndex < items.length) { + const selectedItem = items[selectedIndex]; + input.value = selectedItem.dataset.value; + closePanel(); + } + break; + case 'Escape': + e.preventDefault(); + closePanel(); + break; + } + }; + + // 更新选中状态 + const updateSelection = (items) => { + items.forEach((item, index) => { + if (index === selectedIndex) { + item.classList.add('p-highlight'); + item.scrollIntoView({ block: 'nearest' }); + } else { + item.classList.remove('p-highlight'); + } + }); + }; + + // 处理外部点击 + const handleOutsideClick = (e) => { + if (input.contains(e.target)) { + return; + } + if (autocompletePanel && autocompletePanel.contains(e.target)) { + return; + } + closePanel(); + }; + + // 添加事件监听器 + input.addEventListener('focus', () => { + // 所有提供商都支持自动完成 + openPanel(); + }); + + input.addEventListener('input', () => { + if (isOpen && modelsList.length > 0) { + renderModelsList(modelsList, input.value); + selectedIndex = -1; + } + }); + + input.addEventListener('keydown', handleKeyDown); + } } // 导出API配置管理器实例 diff --git a/js/modules/imageCaption.js b/js/modules/imageCaption.js index 81f8424..538542b 100644 --- a/js/modules/imageCaption.js +++ b/js/modules/imageCaption.js @@ -1055,9 +1055,12 @@ class ImageCaption { const success = document.execCommand('copy'); if (success) { copyButton.textContent = '复制成功!'; + // 复制成功后,延迟500ms关闭弹窗,让用户看到成功提示 setTimeout(() => { - copyButton.textContent = '复制到剪贴板'; - }, 2000); + if (document.body.contains(dialogContainer)) { + document.body.removeChild(dialogContainer); + } + }, 500); } else { copyButton.textContent = '复制失败,请手动选择和复制'; contentArea.focus(); diff --git a/js/modules/settings.js b/js/modules/settings.js index 81bab24..df3d9e7 100644 --- a/js/modules/settings.js +++ b/js/modules/settings.js @@ -23,6 +23,7 @@ let isFirstLoad = true; * @param {Object} options 弹窗配置选项 * @param {string} options.title 弹窗标题 * @param {Function} options.renderContent 渲染弹窗内容的函数 + * @param {Function} options.renderNotice 渲染通知区域的函数(可选,显示在标题和内容之间) * @param {Function} options.onSave 保存按钮点击回调 * @param {Function} options.onCancel 取消按钮点击回调(可选) * @param {boolean} options.isConfirmDialog 是否是确认对话框(可选) @@ -36,6 +37,7 @@ export function createSettingsDialog(options) { const { title, renderContent, + renderNotice = null, onSave, onCancel = null, isConfirmDialog = false, @@ -173,6 +175,14 @@ export function createSettingsDialog(options) { header.appendChild(titleSpan); header.appendChild(headerIcons); + // 创建通知区域(在标题和内容之间) + let noticeArea = null; + if (renderNotice) { + noticeArea = document.createElement('div'); + noticeArea.className = 'p-dialog-notice'; + renderNotice(noticeArea); + } + // 创建弹窗内容 const content = document.createElement('div'); content.className = 'p-dialog-content'; @@ -358,6 +368,9 @@ export function createSettingsDialog(options) { // 组装弹窗 modal.appendChild(header); + if (noticeArea) { + modal.appendChild(noticeArea); + } modal.appendChild(content); modal.appendChild(footer); @@ -1029,7 +1042,7 @@ export function registerSettings() { category: ["✨提示词小助手", " 翻译功能设置", "翻译缓存"], type: "boolean", defaultValue: true, - tooltip: "开启后,会优先使用之前相同翻译的结果,避免相同内容重复翻译", + tooltip: "开启后,如果翻译内容翻译过,则使用历史翻译结果,避免相同内容重复翻译改变原意。如果需要重新翻译,请随便加一个空格即可跳过缓存。", onChange: (value) => { const oldValue = FEATURES.useTranslateCache; FEATURES.useTranslateCache = value; @@ -1037,6 +1050,56 @@ export function registerSettings() { } }, + // 翻译格式化选项 + { + id: "PromptAssistant.Features.TranslateFormatPunctuation", + name: "始终使用半角标点符号", + category: ["✨提示词小助手", " 翻译功能设置", "标点处理"], + type: "boolean", + defaultValue: false, + tooltip: "打开后,翻译结果会自动将中文标点符号替换成英文标点符号", + onChange: (value) => { + FEATURES.translateFormatPunctuation = value; + logger.log(`标点符号转换 - 已${value ? "启用" : "禁用"}`); + } + }, + { + id: "PromptAssistant.Features.TranslateFormatSpace", + name: "自动移除多余空格", + category: ["✨提示词小助手", " 翻译功能设置", "空格处理"], + type: "boolean", + defaultValue: false, + tooltip: "打开后,翻译结果会自动移除多余空格", + onChange: (value) => { + FEATURES.translateFormatSpace = value; + logger.log(`移除多余空格 - 已${value ? "启用" : "禁用"}`); + } + }, + { + id: "PromptAssistant.Features.TranslateFormatDots", + name: "移除多余连续点号", + category: ["✨提示词小助手", " 翻译功能设置", "点号处理"], + type: "boolean", + defaultValue: false, + tooltip: "打开后,翻译结果会将多余的“......”统一为“...”", + onChange: (value) => { + FEATURES.translateFormatDots = value; + logger.log(`处理连续点号 - 已${value ? "启用" : "禁用"}`); + } + }, + { + id: "PromptAssistant.Features.TranslateFormatNewline", + name: "保留换行符", + category: ["✨提示词小助手", " 翻译功能设置", "换行处理"], + type: "boolean", + defaultValue: true, + tooltip: "打开后,翻译结果会尽量保持原文的换行,避免翻译后丢失段落", + onChange: (value) => { + FEATURES.translateFormatNewline = value; + logger.log(`保留换行符 - 已${value ? "启用" : "禁用"}`); + } + }, + // 自动翻译功能 - 暂时禁用 /* { @@ -1316,7 +1379,7 @@ export function registerSettings() { wechatQr.className = "pa-wechat-qr"; const wechatQrImg = document.createElement("img"); // 优先加载远程二维码,失败则回退到本地备用图 - const remoteQrUrl = 'https://raw.githubusercontent.com/yawiii/assets/refs/heads/main/prompt_assistant/wechat.png'; + const remoteQrUrl = 'http://data.xflow.cc/wechat.png'; let qrFallbackTimer = null; const localQrUrl = ResourceManager.getAssetUrl('wechat.png'); @@ -1326,12 +1389,22 @@ export function registerSettings() { wechatQrImg.dataset.fallbackApplied = ''; wechatQrImg.dataset.source = 'remote'; wechatQrImg.src = `${remoteQrUrl}?t=${Date.now()}`; - // 超时回退到本地,防止 onerror 未触发时无法回退 + // 超时回退到本地,但需要判断图片是否已开始加载 qrFallbackTimer = setTimeout(() => { - if (wechatQrImg.dataset.fallbackApplied !== '1') { + // 检查是否已标记为已回退 + if (wechatQrImg.dataset.fallbackApplied === '1') return; + + // 检查图片是否已开始加载(naturalHeight > 0 说明图片正在加载) + if (wechatQrImg.naturalHeight > 0) { + Logger.log(2, '远程二维码加载中,延长等待时间'); + // 图片已开始加载,继续等待 onload,取消超时回退 + if (qrFallbackTimer) { clearTimeout(qrFallbackTimer); qrFallbackTimer = null; } + } else { + // 图片未开始加载,可能是网络问题,回退到本地 + Logger.log(1, '远程二维码加载超时,切换到本地备用图'); loadLocalQr(); } - }, 1500); + }, 3000); // 延长到 3 秒,给远程图片更多加载时间 }; // 手动切换到本地二维码(带时间戳),清理超时 const loadLocalQr = () => { diff --git a/js/modules/tag.js b/js/modules/tag.js index 6b67ad1..1df0900 100644 --- a/js/modules/tag.js +++ b/js/modules/tag.js @@ -17,8 +17,10 @@ import { TagConfigManager } from "./tagConfigManager.js"; * 管理标签弹窗和标签选择 */ class TagManager { - // ---UI状态持久化:上次激活的标签页--- - static LAST_TAB_KEY = 'PromptAssistant_TagPopup_LastTab'; + // ---UI状态持久化配置--- + static LAST_TAB_KEY = 'PromptAssistant_TagPopup_LastTab'; // 上次激活的标签页 + static ACCORDION_STATE_KEY = 'PromptAssistant_TagPopup_AccordionState'; // 手风琴展开状态 + static POPUP_SIZE_KEY = 'PromptAssistant_TagPopup_Size'; // 弹窗尺寸 /** * 获取上次激活的标签页(分类名) @@ -42,6 +44,67 @@ class TagManager { } } catch (e) {} } + + /** + * 获取手风琴展开状态 + * @returns {Object} { tabName: { accordionPath: isExpanded } } + */ + static getAccordionState() { + try { + const state = CacheService.get(this.ACCORDION_STATE_KEY); + return state ? JSON.parse(state) : {}; + } catch (e) { + return {}; + } + } + + /** + * 保存手风琴展开状态 + * @param {string} tabName 标签页名称 + * @param {string} accordionPath 手风琴路径(用分类名表示) + * @param {boolean} isExpanded 是否展开 + */ + static setAccordionState(tabName, accordionPath, isExpanded) { + try { + const state = this.getAccordionState(); + if (!state[tabName]) { + state[tabName] = {}; + } + state[tabName][accordionPath] = isExpanded; + CacheService.set(this.ACCORDION_STATE_KEY, JSON.stringify(state)); + logger.debug(`[助手-标签] 记忆手风琴 | 标签页:${tabName} | 路径:${accordionPath} | 展开:${isExpanded}`); + } catch (e) { + logger.error(`保存手风琴状态失败: ${e.message}`); + } + } + + /** + * 获取保存的弹窗尺寸 + * @returns {Object|null} { width: number, height: number } + */ + static getPopupSize() { + try { + const size = CacheService.get(this.POPUP_SIZE_KEY); + return size ? JSON.parse(size) : null; + } catch (e) { + return null; + } + } + + /** + * 保存弹窗尺寸 + * @param {number} width 宽度 + * @param {number} height 高度 + */ + static setPopupSize(width, height) { + try { + const size = { width, height }; + CacheService.set(this.POPUP_SIZE_KEY, JSON.stringify(size)); + logger.debug(`[助手-标签] 记忆窗口大小 | 宽:${width}px | 高:${height}px`); + } catch (e) { + logger.error(`保存窗口大小失败: ${e.message}`); + } + } static popupInstance = null; static onCloseCallback = null; // 添加关闭回调存储 static eventCleanups = []; // 事件清理函数数组 @@ -504,8 +567,11 @@ class TagManager { /** * 递归创建标签结构 + * @param {Object} data 数据对象 + * @param {string} level 层级 + * @param {string} tabName 标签页名称(用于恢复状态) */ - static _createAccordionContent(data, level = '0') { + static _createAccordionContent(data, level = '0', tabName = null) { // 如果是顶级(一级分类),则创建标签页结构 if (level === '0') { // 创建外层容器 @@ -554,7 +620,7 @@ class TagManager { // 添加图标 const leftIconSpan = document.createElement('span'); - leftIconSpan.className = 'pi pi-chevron-down rotate_left scroll_indicator_icon'; + leftIconSpan.className = 'pi pi-angle-left scroll_indicator_icon'; leftIndicator.appendChild(leftIconSpan); leftIndicator.style.display = 'none'; // 初始隐藏 @@ -563,67 +629,124 @@ class TagManager { // 添加图标 const rightIconSpan = document.createElement('span'); - rightIconSpan.className = 'pi pi-chevron-down rotate_right scroll_indicator_icon'; + rightIconSpan.className = 'pi pi-angle-right scroll_indicator_icon'; rightIndicator.appendChild(rightIconSpan); rightIndicator.style.display = 'none'; // 初始隐藏 - // 添加指示器点击事件 - 改进滚动逻辑 - const leftClickCleanup = EventManager.addDOMListener(leftIndicator, 'click', () => { - // 获取可视区域宽度 - const visibleWidth = tabsScroll.clientWidth; - - // 计算滚动距离,PrimeVue风格是滚动一个较大的距离 - const scrollDistance = visibleWidth * 0.75; - - // 平滑滚动 - tabsScroll.scrollBy({ - left: -scrollDistance, - behavior: 'smooth' - }); - }); - - const rightClickCleanup = EventManager.addDOMListener(rightIndicator, 'click', () => { - // 获取可视区域宽度 - const visibleWidth = tabsScroll.clientWidth; - - // 计算滚动距离,PrimeVue风格是滚动一个较大的距离 - const scrollDistance = visibleWidth * 0.75; - - // 平滑滚动 - tabsScroll.scrollBy({ - left: scrollDistance, - behavior: 'smooth' - }); - }); - - // 监听滚动事件,显示/隐藏滚动指示器 - const scrollCleanup = EventManager.addDOMListener(tabsScroll, 'scroll', () => { - // 检查是否需要滚动 + // 更新指示器状态的函数 + const updateIndicators = () => { const canScroll = tabsScroll.scrollWidth > tabsScroll.clientWidth; - if (!canScroll) { - // 如果不需要滚动,隐藏两个指示器 leftIndicator.style.display = 'none'; rightIndicator.style.display = 'none'; return; } + // 使用更大的阈值(5像素)确保边界情况下能正确隐藏 + const scrollLeft = tabsScroll.scrollLeft; + const maxScroll = tabsScroll.scrollWidth - tabsScroll.clientWidth; + + // 临时调试日志 - 显示接近边界时的滚动值 + if (scrollLeft < 10 || scrollLeft > maxScroll - 10) { + logger.debug(`[标签-滚动] scrollLeft: ${scrollLeft.toFixed(2)}, maxScroll: ${maxScroll.toFixed(2)}`); + } + + leftIndicator.style.display = scrollLeft > 5 ? 'flex' : 'none'; + rightIndicator.style.display = scrollLeft < (maxScroll - 5) ? 'flex' : 'none'; + }; - // 显示/隐藏左右滚动指示器 - leftIndicator.style.display = tabsScroll.scrollLeft > 0 ? 'flex' : 'none'; - rightIndicator.style.display = - tabsScroll.scrollLeft < (tabsScroll.scrollWidth - tabsScroll.clientWidth - 2) ? 'flex' : 'none'; + // 添加指示器点击事件 - 每次滚动一个标签 + const leftClickCleanup = EventManager.addDOMListener(leftIndicator, 'click', () => { + // 获取所有标签 + const allTabs = tabs.querySelectorAll('.popup_tab'); + if (allTabs.length === 0) return; + + // 找到当前第一个可见的标签 + const scrollRect = tabsScroll.getBoundingClientRect(); + let firstVisibleTab = null; + + for (const tab of allTabs) { + const tabRect = tab.getBoundingClientRect(); + // 如果标签的右边缘在可视区域内,说明它至少部分可见 + if (tabRect.right > scrollRect.left + 10) { + firstVisibleTab = tab; + break; + } + } + + // 找到前一个标签 + if (firstVisibleTab) { + const currentIndex = Array.from(allTabs).indexOf(firstVisibleTab); + if (currentIndex > 0) { + const prevTab = allTabs[currentIndex - 1]; + prevTab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' }); + // 滚动完成后更新指示器状态(使用更长的延迟确保动画完成) + setTimeout(updateIndicators, 600); + } + } }); - this.eventCleanups.push(leftClickCleanup, rightClickCleanup, scrollCleanup); + const rightClickCleanup = EventManager.addDOMListener(rightIndicator, 'click', () => { + // 获取所有标签 + const allTabs = tabs.querySelectorAll('.popup_tab'); + if (allTabs.length === 0) return; - // 初始检测是否需要滚动指示器 + // 找到当前最后一个可见的标签 + const scrollRect = tabsScroll.getBoundingClientRect(); + let lastVisibleTab = null; + + for (let i = allTabs.length - 1; i >= 0; i--) { + const tab = allTabs[i]; + const tabRect = tab.getBoundingClientRect(); + // 如果标签的左边缘在可视区域内,说明它至少部分可见 + if (tabRect.left < scrollRect.right - 10) { + lastVisibleTab = tab; + break; + } + } + + // 找到下一个标签 + if (lastVisibleTab) { + const currentIndex = Array.from(allTabs).indexOf(lastVisibleTab); + if (currentIndex < allTabs.length - 1) { + const nextTab = allTabs[currentIndex + 1]; + nextTab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'end' }); + // 滚动完成后更新指示器状态(使用更长的延迟确保动画完成) + setTimeout(updateIndicators, 600); + } + } + }); + + // 监听滚动事件,显示/隐藏滚动指示器 + const scrollCleanup = EventManager.addDOMListener(tabsScroll, 'scroll', updateIndicators); + + // 监听窗口大小调整事件 + const resizeObserver = new ResizeObserver(() => { + updateIndicators(); + }); + resizeObserver.observe(popup); + + // 添加清理函数 + const resizeCleanup = () => { + resizeObserver.disconnect(); + }; + + this.eventCleanups.push(leftClickCleanup, rightClickCleanup, scrollCleanup, resizeCleanup); + + // 初始检测是否需要滚动指示器,并自动定位到激活的标签页 setTimeout(() => { // 检查是否需要滚动 const canScroll = tabsScroll.scrollWidth > tabsScroll.clientWidth; if (canScroll) { - // 如果需要滚动,显示右侧指示器 - rightIndicator.style.display = 'flex'; + // 找到激活的标签页 + const activeTab = tabs.querySelector('.popup_tab.active'); + if (activeTab) { + // 将激活的标签页滚动到可见区域 + activeTab.scrollIntoView({ behavior: 'auto', block: 'nearest', inline: 'center' }); + } + + // 等待滚动完成后,更新滚动指示器显示状态 + setTimeout(updateIndicators, 50); } }, 100); @@ -719,7 +842,7 @@ class TagManager { // 递归创建子内容(二级分类开始使用手风琴) if (typeof data[category] === 'object' && data[category] !== null) { - const innerContent = this._createInnerAccordion(data[category], '1'); + const innerContent = this._createInnerAccordion(data[category], '1', category); content.appendChild(innerContent); } @@ -812,19 +935,27 @@ class TagManager { const content = document.createElement('div'); content.className = 'tag_accordion_content'; - // 递归创建子内容 - const childContent = this._createAccordionContent(value, (parseInt(level) + 1).toString()); + // 递归创建子内容,传递 tabName + const childContent = this._createAccordionContent(value, (parseInt(level) + 1).toString(), tabName); content.appendChild(childContent); - // 如果是当前层级的第一个手风琴,默认展开 - if (isFirstAccordionInLevel) { + // 根据保存的状态或默认行为确定是否展开 + const accordionState = this.getAccordionState(); + const shouldExpand = tabName && accordionState[tabName]?.[key] !== undefined + ? accordionState[tabName][key] // 使用保存的状态 + : isFirstAccordionInLevel; // 默认展开第一个 + + if (shouldExpand) { header.classList.add('active'); content.classList.add('active'); const arrowIconSpan = headerIcon.querySelector('.accordion_arrow_icon'); if (arrowIconSpan) { arrowIconSpan.classList.add('rotate-180'); } - isFirstAccordionInLevel = false; + if (!tabName || !accordionState[tabName]?.[key]) { + // 只有在使用默认行为时才更新标志 + isFirstAccordionInLevel = false; + } } // 添加手风琴切换事件 @@ -850,6 +981,12 @@ class TagManager { // 使用优化的切换方法关闭其他手风琴 if (otherHeader.classList.contains('active')) { this._toggleAccordion(otherHeader, otherContent, otherHeaderIcon); + // 保存关闭状态 + const otherAccordionCategory = parentAccordion.getAttribute('data-category'); + const otherTabName = parentTab.getAttribute('data-category'); + if (otherTabName && otherAccordionCategory) { + this.setAccordionState(otherTabName, otherAccordionCategory, false); + } } } } @@ -859,6 +996,14 @@ class TagManager { // 使用优化的切换方法切换当前手风琴 this._toggleAccordion(header, content, headerIcon); + + // 保存当前手风琴状态 + const accordionCategory = accordion.getAttribute('data-category'); + const tabName = parentTab?.getAttribute('data-category'); + if (tabName && accordionCategory) { + const isExpanded = header.classList.contains('active'); + this.setAccordionState(tabName, accordionCategory, isExpanded); + } }); this.eventCleanups.push(accordionCleanup); @@ -959,8 +1104,11 @@ class TagManager { /** * 为二级分类创建内容 + * @param {Object} data 数据对象 + * @param {string} level 层级 + * @param {string} tabName 标签页名称(用于恢复状态) */ - static _createInnerAccordion(data, level) { + static _createInnerAccordion(data, level, tabName = null) { const container = document.createElement('div'); container.className = 'tag_category_container'; container.style.flex = '1'; @@ -997,21 +1145,29 @@ class TagManager { const content = document.createElement('div'); content.className = 'tag_accordion_content'; - // 递归创建子内容 - const childContent = this._createAccordionContent(value, (parseInt(level) + 1).toString()); + // 递归创建子内容,传递 tabName + const childContent = this._createAccordionContent(value, (parseInt(level) + 1).toString(), tabName); childContent.style.flex = '1'; // 让子内容占满可用空间 childContent.style.minHeight = '0'; // 允许flex收缩 content.appendChild(childContent); - // 如果是第一个手风琴,默认展开 - if (isFirstAccordion) { + // 根据保存的状态或默认行为确定是否展开 + const accordionState = this.getAccordionState(); + const shouldExpand = tabName && accordionState[tabName]?.[key] !== undefined + ? accordionState[tabName][key] // 使用保存的状态 + : isFirstAccordion; // 默认展开第一个 + + if (shouldExpand) { header.classList.add('active'); content.classList.add('active'); const firstArrowIcon = headerIcon.querySelector('.pi.pi-chevron-down'); if (firstArrowIcon) { firstArrowIcon.classList.add('rotate-180'); } - isFirstAccordion = false; + if (!tabName || !accordionState[tabName]?.[key]) { + // 只有在使用默认行为时才更新标志 + isFirstAccordion = false; + } } // 添加手风琴切换事件,包含关闭其他手风琴的逻辑 @@ -1029,6 +1185,13 @@ class TagManager { // 使用优化的切换方法关闭其他手风琴 if (otherHeader.classList.contains('active')) { this._toggleAccordion(otherHeader, otherContent, otherHeaderIcon); + // 保存关闭状态 + const otherAccordion = otherHeader.closest('.tag_accordion'); + const otherAccordionCategory = otherAccordion?.getAttribute('data-category'); + const tabName = parentTab.getAttribute('data-category'); + if (tabName && otherAccordionCategory) { + this.setAccordionState(tabName, otherAccordionCategory, false); + } } } }); @@ -1036,6 +1199,14 @@ class TagManager { } // 使用优化的切换方法切换当前手风琴 this._toggleAccordion(header, content, headerIcon); + + // 保存当前手风琴状态 + const accordionCategory = accordion.getAttribute('data-category'); + const tabName = parentTab?.getAttribute('data-category'); + if (tabName && accordionCategory) { + const isExpanded = header.classList.contains('active'); + this.setAccordionState(tabName, accordionCategory, isExpanded); + } }); this.eventCleanups.push(accordionCleanup); @@ -1188,58 +1359,111 @@ class TagManager { // 创建左右滚动指示器 const leftIndicator = document.createElement('div'); leftIndicator.className = 'tabs_scroll_indicator left'; - const leftIcon = ResourceManager.getIcon('icon-movedown.svg'); - if (leftIcon) { - leftIcon.classList.add('rotate_left', 'scroll_indicator_icon'); - leftIndicator.appendChild(leftIcon); - } + const leftIconSpan = document.createElement('span'); + leftIconSpan.className = 'pi pi-angle-left scroll_indicator_icon'; + leftIndicator.appendChild(leftIconSpan); leftIndicator.style.display = 'none'; const rightIndicator = document.createElement('div'); rightIndicator.className = 'tabs_scroll_indicator right'; - const rightIcon = ResourceManager.getIcon('icon-movedown.svg'); - if (rightIcon) { - rightIcon.classList.add('rotate_right', 'scroll_indicator_icon'); - rightIndicator.appendChild(rightIcon); - } + const rightIconSpan = document.createElement('span'); + rightIconSpan.className = 'pi pi-angle-right scroll_indicator_icon'; + rightIndicator.appendChild(rightIconSpan); rightIndicator.style.display = 'none'; - // 添加滚动指示器点击事件 - const leftClickCleanup = EventManager.addDOMListener(leftIndicator, 'click', () => { - const visibleWidth = tabsScroll.clientWidth; - const scrollDistance = visibleWidth * 0.75; - tabsScroll.scrollBy({ - left: -scrollDistance, - behavior: 'smooth' - }); - }); - - const rightClickCleanup = EventManager.addDOMListener(rightIndicator, 'click', () => { - const visibleWidth = tabsScroll.clientWidth; - const scrollDistance = visibleWidth * 0.75; - tabsScroll.scrollBy({ - left: scrollDistance, - behavior: 'smooth' - }); - }); - - this.eventCleanups.push(leftClickCleanup, rightClickCleanup); - - // 监听滚动事件 - const scrollCleanup = EventManager.addDOMListener(tabsScroll, 'scroll', () => { + // 更新指示器状态的函数 + const updateIndicators = () => { const canScroll = tabsScroll.scrollWidth > tabsScroll.clientWidth; if (!canScroll) { leftIndicator.style.display = 'none'; rightIndicator.style.display = 'none'; return; } + // 使用更大的阈值(8像素)确保边界情况下能正确隐藏 + const scrollLeft = tabsScroll.scrollLeft; + const maxScroll = tabsScroll.scrollWidth - tabsScroll.clientWidth; + + leftIndicator.style.display = scrollLeft > 8 ? 'flex' : 'none'; + rightIndicator.style.display = scrollLeft < (maxScroll - 8) ? 'flex' : 'none'; + }; - leftIndicator.style.display = tabsScroll.scrollLeft > 0 ? 'flex' : 'none'; - rightIndicator.style.display = - tabsScroll.scrollLeft < (tabsScroll.scrollWidth - tabsScroll.clientWidth - 2) ? 'flex' : 'none'; + // 添加滚动指示器点击事件 - 每次滚动一个标签 + const leftClickCleanup = EventManager.addDOMListener(leftIndicator, 'click', () => { + // 获取所有标签 + const allTabs = tabs.querySelectorAll('.popup_tab'); + if (allTabs.length === 0) return; + + // 找到当前第一个可见的标签 + const scrollRect = tabsScroll.getBoundingClientRect(); + let firstVisibleTab = null; + + for (const tab of allTabs) { + const tabRect = tab.getBoundingClientRect(); + // 如果标签的右边缘在可视区域内,说明它至少部分可见 + if (tabRect.right > scrollRect.left + 10) { + firstVisibleTab = tab; + break; + } + } + + // 找到前一个标签 + if (firstVisibleTab) { + const currentIndex = Array.from(allTabs).indexOf(firstVisibleTab); + if (currentIndex > 0) { + const prevTab = allTabs[currentIndex - 1]; + prevTab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' }); + // 滚动完成后更新指示器状态(使用更长的延迟确保动画完成) + setTimeout(updateIndicators, 600); + } + } }); - this.eventCleanups.push(scrollCleanup); + const rightClickCleanup = EventManager.addDOMListener(rightIndicator, 'click', () => { + // 获取所有标签 + const allTabs = tabs.querySelectorAll('.popup_tab'); + if (allTabs.length === 0) return; + + // 找到当前最后一个可见的标签 + const scrollRect = tabsScroll.getBoundingClientRect(); + let lastVisibleTab = null; + + for (let i = allTabs.length - 1; i >= 0; i--) { + const tab = allTabs[i]; + const tabRect = tab.getBoundingClientRect(); + // 如果标签的左边缘在可视区域内,说明它至少部分可见 + if (tabRect.left < scrollRect.right - 10) { + lastVisibleTab = tab; + break; + } + } + + // 找到下一个标签 + if (lastVisibleTab) { + const currentIndex = Array.from(allTabs).indexOf(lastVisibleTab); + if (currentIndex < allTabs.length - 1) { + const nextTab = allTabs[currentIndex + 1]; + nextTab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'end' }); + // 滚动完成后更新指示器状态(使用更长的延迟确保动画完成) + setTimeout(updateIndicators, 600); + } + } + }); + + // 监听滚动事件 + const scrollCleanup = EventManager.addDOMListener(tabsScroll, 'scroll', updateIndicators); + + // 监听窗口大小调整事件 + const resizeObserver = new ResizeObserver(() => { + updateIndicators(); + }); + resizeObserver.observe(container); + + // 添加清理函数 + const resizeCleanup = () => { + resizeObserver.disconnect(); + }; + + this.eventCleanups.push(leftClickCleanup, rightClickCleanup, scrollCleanup, resizeCleanup); // 组装标签页结构 tabsScroll.appendChild(tabs); @@ -1338,11 +1562,19 @@ class TagManager { tabContents.appendChild(content); }); - // 初始化滚动指示器 + // 初始化滚动指示器,并自动定位到激活的标签页 setTimeout(() => { const canScroll = tabsScroll.scrollWidth > tabsScroll.clientWidth; if (canScroll) { - rightIndicator.style.display = 'flex'; + // 找到激活的标签页 + const activeTab = tabs.querySelector('.popup_tab.active'); + if (activeTab) { + // 将激活的标签页滚动到可见区域 + activeTab.scrollIntoView({ behavior: 'auto', block: 'nearest', inline: 'center' }); + } + + // 等待滚动完成后,更新滚动指示器显示状态 + setTimeout(updateIndicators, 50); } }, 50); @@ -1374,7 +1606,7 @@ class TagManager { try { // 创建分类内容 if (typeof categoryData === 'object' && categoryData !== null) { - const innerContent = this._createInnerAccordion(categoryData, '1'); + const innerContent = this._createInnerAccordion(categoryData, '1', categoryName); contentWrapper.appendChild(innerContent); // 添加到父容器 @@ -1597,9 +1829,16 @@ class TagManager { // 添加标签弹窗特有的类名,用于识别 popup.classList.add('tag_popup'); - // 设置初始尺寸 - popup.style.width = '600px'; - popup.style.height = '400px'; + // 恢复保存的窗口大小或使用默认尺寸 + const savedSize = this.getPopupSize(); + if (savedSize) { + popup.style.width = `${savedSize.width}px`; + popup.style.height = `${savedSize.height}px`; + logger.debug(`[助手-标签] 恢复窗口大小 | 宽:${savedSize.width}px | 高:${savedSize.height}px`); + } else { + popup.style.width = '600px'; + popup.style.height = '400px'; + } // 显示弹窗 PopupManager.showPopup({ @@ -1615,8 +1854,24 @@ class TagManager { if (activeTab) { const category = activeTab.getAttribute('data-category'); TagManager.setLastActiveTab(category); + + // 保存当前标签页中所有手风琴的展开状态 + const activeTabContent = popup.querySelector('.popup_tab_content.active'); + if (activeTabContent) { + const accordions = activeTabContent.querySelectorAll('.tag_accordion'); + accordions.forEach(accordion => { + const accordionCategory = accordion.getAttribute('data-category'); + const header = accordion.querySelector('.tag_accordion_header'); + if (accordionCategory && header) { + const isExpanded = header.classList.contains('active'); + TagManager.setAccordionState(category, accordionCategory, isExpanded); + } + }); + } } - } catch (e) {} + } catch (e) { + logger.error(`保存弹窗状态失败: ${e.message}`); + } this._cleanupEvents(); if (typeof onClose === 'function') { onClose(); @@ -1702,22 +1957,112 @@ class TagManager { // 创建左右滚动指示器 const leftIndicator = document.createElement('div'); leftIndicator.className = 'tabs_scroll_indicator left'; - const leftIcon = ResourceManager.getIcon('icon-movedown.svg'); - if (leftIcon) { - leftIcon.classList.add('rotate_left', 'scroll_indicator_icon'); - leftIndicator.appendChild(leftIcon); - } + const leftIconSpan = document.createElement('span'); + leftIconSpan.className = 'pi pi-angle-left scroll_indicator_icon'; + leftIndicator.appendChild(leftIconSpan); leftIndicator.style.display = 'none'; const rightIndicator = document.createElement('div'); rightIndicator.className = 'tabs_scroll_indicator right'; - const rightIcon = ResourceManager.getIcon('icon-movedown.svg'); - if (rightIcon) { - rightIcon.classList.add('rotate_right', 'scroll_indicator_icon'); - rightIndicator.appendChild(rightIcon); - } + const rightIconSpan = document.createElement('span'); + rightIconSpan.className = 'pi pi-angle-right scroll_indicator_icon'; + rightIndicator.appendChild(rightIconSpan); rightIndicator.style.display = 'none'; + // 更新指示器状态的函数 + const updateIndicators = () => { + const canScroll = tabsScroll.scrollWidth > tabsScroll.clientWidth; + if (!canScroll) { + leftIndicator.style.display = 'none'; + rightIndicator.style.display = 'none'; + return; + } + // 使用更大的阈值(8像素)确保边界情况下能正确隐藏 + const scrollLeft = tabsScroll.scrollLeft; + const maxScroll = tabsScroll.scrollWidth - tabsScroll.clientWidth; + + leftIndicator.style.display = scrollLeft > 8 ? 'flex' : 'none'; + rightIndicator.style.display = scrollLeft < (maxScroll - 8) ? 'flex' : 'none'; + }; + + // 添加滚动指示器点击事件 - 每次滚动一个标签 + const leftClickCleanup = EventManager.addDOMListener(leftIndicator, 'click', () => { + // 获取所有标签 + const allTabs = tabs.querySelectorAll('.popup_tab'); + if (allTabs.length === 0) return; + + // 找到当前第一个可见的标签 + const scrollRect = tabsScroll.getBoundingClientRect(); + let firstVisibleTab = null; + + for (const tab of allTabs) { + const tabRect = tab.getBoundingClientRect(); + // 如果标签的右边缘在可视区域内,说明它至少部分可见 + if (tabRect.right > scrollRect.left + 10) { + firstVisibleTab = tab; + break; + } + } + + // 找到前一个标签 + if (firstVisibleTab) { + const currentIndex = Array.from(allTabs).indexOf(firstVisibleTab); + if (currentIndex > 0) { + const prevTab = allTabs[currentIndex - 1]; + prevTab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' }); + // 滚动完成后更新指示器状态(使用更长的延迟确保动画完成) + setTimeout(updateIndicators, 600); + } + } + }); + + const rightClickCleanup = EventManager.addDOMListener(rightIndicator, 'click', () => { + // 获取所有标签 + const allTabs = tabs.querySelectorAll('.popup_tab'); + if (allTabs.length === 0) return; + + // 找到当前最后一个可见的标签 + const scrollRect = tabsScroll.getBoundingClientRect(); + let lastVisibleTab = null; + + for (let i = allTabs.length - 1; i >= 0; i--) { + const tab = allTabs[i]; + const tabRect = tab.getBoundingClientRect(); + // 如果标签的左边缘在可视区域内,说明它至少部分可见 + if (tabRect.left < scrollRect.right - 10) { + lastVisibleTab = tab; + break; + } + } + + // 找到下一个标签 + if (lastVisibleTab) { + const currentIndex = Array.from(allTabs).indexOf(lastVisibleTab); + if (currentIndex < allTabs.length - 1) { + const nextTab = allTabs[currentIndex + 1]; + nextTab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'end' }); + // 滚动完成后更新指示器状态(使用更长的延迟确保动画完成) + setTimeout(updateIndicators, 600); + } + } + }); + + // 监听滚动事件,控制滚动指示器的显示/隐藏 + const scrollCleanup = EventManager.addDOMListener(tabsScroll, 'scroll', updateIndicators); + + // 监听窗口大小调整事件 + const resizeObserver = new ResizeObserver(() => { + updateIndicators(); + }); + resizeObserver.observe(container); + + // 添加清理函数 + const resizeCleanup = () => { + resizeObserver.disconnect(); + }; + + this.eventCleanups.push(leftClickCleanup, rightClickCleanup, scrollCleanup, resizeCleanup); + // 添加滚动指示器 tabsContainer.appendChild(leftIndicator); tabsContainer.appendChild(tabsScroll); @@ -1832,11 +2177,19 @@ class TagManager { tabContents.appendChild(content); }); - // 初始化滚动指示器 + // 初始化滚动指示器,并自动定位到激活的标签页 setTimeout(() => { const canScroll = tabsScroll.scrollWidth > tabsScroll.clientWidth; if (canScroll) { - rightIndicator.style.display = 'flex'; + // 找到激活的标签页 + const activeTab = tabs.querySelector('.popup_tab.active'); + if (activeTab) { + // 将激活的标签页滚动到可见区域 + activeTab.scrollIntoView({ behavior: 'auto', block: 'nearest', inline: 'center' }); + } + + // 等待滚动完成后,更新滚动指示器显示状态 + setTimeout(updateIndicators, 50); } }, 50); @@ -2315,7 +2668,7 @@ class TagManager { contentWrapper.appendChild(emptyMessage); } else { // 如果有自定义标签,使用标准的创建方法 - const tagsContent = this._createInnerAccordion(userTagData, '1'); + const tagsContent = this._createInnerAccordion(userTagData, '1', '⭐️'); contentWrapper.appendChild(tagsContent); } diff --git a/js/services/features.js b/js/services/features.js index 70613e2..fbf2125 100644 --- a/js/services/features.js +++ b/js/services/features.js @@ -57,6 +57,12 @@ export const FEATURES = { autoTranslate: false, // 自动翻译功能 imageCaption: true, // 图像反推提示词功能 + // 翻译格式化选项 + translateFormatPunctuation: true, // 标点符号自动转成半角 + translateFormatSpace: true, // 移除多余空格 + translateFormatDots: false, // 处理连续点号 + translateFormatNewline: false, // 保留换行符 + /** * 更新所有实例的按钮显示状态 * 根据功能开关状态控制UI元素的显示和隐藏 @@ -149,6 +155,9 @@ export function handleFeatureChange(featureName, value, oldValue) { } }); logger.debug(`功能重建 | 结果:完成 | 功能: ${featureName}`); + + // 重新计算并更新所有实例的宽度 + promptAssistant.updateAllInstancesWidth(); } // 如果是图像反推功能被启用 @@ -188,5 +197,10 @@ export function handleFeatureChange(featureName, value, oldValue) { // 清理所有图像小助手实例 imageCaption.cleanup(); } + + // 功能开关变化时,更新所有实例的宽度 + if (PromptAssistant.instances.size > 0) { + promptAssistant.updateAllInstancesWidth(); + } } } \ No newline at end of file diff --git a/js/utils/popupManager.js b/js/utils/popupManager.js index 1cb1494..8e0b293 100644 --- a/js/utils/popupManager.js +++ b/js/utils/popupManager.js @@ -395,6 +395,17 @@ class PopupManager { document.removeEventListener('mousemove', handleResizeMove, { capture: true }); document.removeEventListener('mouseup', handleResizeEnd, { capture: true }); + // 保存标签弹窗的尺寸 + if (popup.classList.contains('tag_popup')) { + const rect = popup.getBoundingClientRect(); + // 动态导入 TagManager 并保存尺寸 + import('../modules/tag.js').then(({ TagManager }) => { + TagManager.setPopupSize(rect.width, rect.height); + }).catch(err => { + logger.error(`保存窗口大小失败: ${err.message}`); + }); + } + logger.debug('完成调节窗口大小'); }; diff --git a/js/utils/promptFormatter.js b/js/utils/promptFormatter.js index ec19615..5096675 100644 --- a/js/utils/promptFormatter.js +++ b/js/utils/promptFormatter.js @@ -198,7 +198,7 @@ class PromptFormatter { /** * 格式化翻译后的文本 - * 将中文标点符号转换为英文标点符号 + * 根据用户设置的格式化选项进行处理 * 注意:此方法负责处理所有翻译结果的格式(包括百度翻译和LLM翻译), * 后端不再进行任何格式预处理或后处理。 */ @@ -208,48 +208,81 @@ class PromptFormatter { // 记录原始文本用于日志 const originalText = text; + + // 获取格式化选项(从全局 FEATURES 对象) + const options = { + punctuation: window.FEATURES?.translateFormatPunctuation ?? true, + space: window.FEATURES?.translateFormatSpace ?? true, + dots: window.FEATURES?.translateFormatDots ?? false, + newline: window.FEATURES?.translateFormatNewline ?? false + }; - // 保留换行符,按行处理文本 - const lines = text.split('\n'); - const formattedLines = lines.map(line => { - // 使用映射表替换所有中文标点 - let formattedLine = line; - for (const [cnPunct, enPunct] of Object.entries(this.PUNCTUATION_MAP)) { - formattedLine = formattedLine.split(cnPunct).join(enPunct); - } + let formattedText = text; - // 处理连续的点号(超过3个的情况) - formattedLine = formattedLine.replace(/\.{3,}/g, '...'); - - // 处理多余的空格 - formattedLine = formattedLine - .replace(/\s+/g, ' ') // 多个空格转换为单个空格 - .replace(/\s*,\s*/g, ', ') // 统一逗号后的空格 - .trim(); // 去除首尾空格 - - return formattedLine; - }); - - // 合并处理后的行,保留原始换行符 - const formattedText = formattedLines.join('\n'); + // 根据是否保留换行符选择不同的处理方式 + if (options.newline) { + // 保留换行符:按行处理 + const lines = text.split('\n'); + const formattedLines = lines.map(line => { + return this._formatLine(line, options); + }); + formattedText = formattedLines.join('\n'); + } else { + // 不保留换行符:整体处理(换行符会被空格替换逻辑处理) + formattedText = this._formatLine(text, options); + } // 记录日志 if (originalText !== formattedText) { - const logOriginal = originalText.length > 100 ? - originalText.substring(0, 100) + '...' : originalText; + const enabledOptions = []; + if (options.punctuation) enabledOptions.push('标点转换'); + if (options.space) enabledOptions.push('空格处理'); + if (options.dots) enabledOptions.push('点号处理'); + if (options.newline) enabledOptions.push('保留换行'); + const logFormatted = formattedText.length > 100 ? formattedText.substring(0, 100) + '...' : formattedText; - logger.debug(`标点符号转换 | 结果:成功 | 原行数:${lines.length} | 转换后行数:${formattedLines.length} | 样例:"${logFormatted}"`); + logger.debug(`文本格式化 | 选项:[${enabledOptions.join(', ')}] | 结果:"${logFormatted}"`); } return formattedText; } catch (error) { - logger.error(`标点符号转换 | 结果:异常 | 错误:${error.message}`); + logger.error(`文本格式化 | 结果:异常 | 错误:${error.message}`); return text; // 发生错误时返回原始文本 } } + /** + * 格式化单行文本 + * 根据选项执行对应的格式化操作 + */ + static _formatLine(line, options) { + let formattedLine = line; + + // 1. 标点符号转换 + if (options.punctuation) { + for (const [cnPunct, enPunct] of Object.entries(this.PUNCTUATION_MAP)) { + formattedLine = formattedLine.split(cnPunct).join(enPunct); + } + } + + // 2. 处理连续点号 + if (options.dots) { + formattedLine = formattedLine.replace(/\.{3,}/g, '...'); + } + + // 3. 处理多余空格 + if (options.space) { + formattedLine = formattedLine + .replace(/\s+/g, ' ') // 多个空格转换为单个空格 + .replace(/\s*,\s*/g, ', ') // 统一逗号后的空格 + .trim(); // 去除首尾空格 + } + + return formattedLine; + } + /** * 判断文本的语言类型 */ diff --git a/node/expand_node.py b/node/expand_node.py index 31d6470..bac4740 100644 --- a/node/expand_node.py +++ b/node/expand_node.py @@ -58,11 +58,11 @@ class PromptExpand: # 临时规则开关:BOOLEAN,并添加中文开关标签 "临时规则": ("BOOLEAN", {"default": False, "label_on": "启用", "label_off": "禁用"}), # 临时规则内容输入框:仅在启用临时规则时生效 - "临时规则内容": ("STRING", {"multiline": True, "default": "", "placeholder": "请输入临时规则内容,仅在启用“临时规则”时生效"}), + "临时规则内容": ("STRING", {"multiline": True, "default": "", "placeholder": "请输入临时规则内容,仅在启用'临时规则'时生效"}), # 用户提示词:与原文合并后提交 "用户提示词": ("STRING", {"multiline": True, "default": "", "placeholder": "填写的要扩写的提示词原文,若存在原文端口输入和内容输入,将合并提交"}), # 扩写服务(使用LLM提供商) - "扩写服务": (["智谱", "硅基流动", "自定义"], {"default": "智谱"}), + "扩写服务": (["智谱", "硅基流动", "302.AI", "Ollama", "自定义"], {"default": "智谱"}), }, "optional": { # 原文输入端口:非必选 @@ -156,6 +156,8 @@ class PromptExpand: provider_map = { "智谱": "zhipu", "硅基流动": "siliconflow", + "302.AI": "302ai", + "Ollama": "ollama", "自定义": "custom", } selected_provider = provider_map.get(扩写服务) @@ -166,7 +168,7 @@ class PromptExpand: from ..config_manager import config_manager provider_config = self._get_provider_config(config_manager, selected_provider) if not provider_config: - provider_display = {"zhipu": "智谱", "siliconflow": "硅基流动", "custom": "自定义"}.get(selected_provider, selected_provider) + provider_display = {"zhipu": "智谱", "siliconflow": "硅基流动", "302ai": "302.AI", "ollama": "Ollama", "custom": "自定义"}.get(selected_provider, selected_provider) raise ValueError(f"未找到{provider_display}的配置,请先完成API配置") print(f"{self.LOG_PREFIX} 扩写: 使用{扩写服务}服务, 模型: {provider_config.get('model')}") diff --git a/node/image_caption_node.py b/node/image_caption_node.py index 8e2984b..73ab41d 100644 --- a/node/image_caption_node.py +++ b/node/image_caption_node.py @@ -55,8 +55,8 @@ class ImageCaptionNode: "图像": ("IMAGE",), "规则模板": (prompt_template_options, {"default": prompt_template_options[0] if prompt_template_options else "默认中文反推提示词"}), "临时规则": ("BOOLEAN", {"default": False, "label_on": "启用", "label_off": "禁用"}), - "临时规则内容": ("STRING", {"multiline": True, "default": "", "placeholder": "请输入临时规则内容,仅在启用“临时规则”时生效"}), - "视觉服务": (["智谱", "硅基流动", "自定义"], {"default": "智谱"}), + "临时规则内容": ("STRING", {"multiline": True, "default": "", "placeholder": "请输入临时规则内容,仅在启用'临时规则'时生效"}), + "视觉服务": (["智谱", "硅基流动", "302.AI", "Ollama", "自定义"], {"default": "智谱"}), }, } @@ -173,6 +173,8 @@ class ImageCaptionNode: provider_map = { "智谱": "zhipu", "硅基流动": "siliconflow", + "302.AI": "302ai", + "Ollama": "ollama", "自定义": "custom" } diff --git a/node/kontext_preset_node.py b/node/kontext_preset_node.py index fea41d7..d79d90e 100644 --- a/node/kontext_preset_node.py +++ b/node/kontext_preset_node.py @@ -71,7 +71,7 @@ class KontextPresetNode: "required": { "图像": ("IMAGE",), "Kontext预设": (prompt_template_options, {"default": prompt_template_options[0] if prompt_template_options else "情境深度融合"}), - "视觉服务": (["智谱", "硅基流动", "自定义"], {"default": "智谱"}), + "视觉服务": (["智谱", "硅基流动", "302.AI", "Ollama", "自定义"], {"default": "智谱"}), }, } @@ -183,6 +183,8 @@ class KontextPresetNode: provider_map = { "智谱": "zhipu", "硅基流动": "siliconflow", + "302.AI": "302ai", + "Ollama": "ollama", "自定义": "custom" } diff --git a/node/translate_node.py b/node/translate_node.py index 460ff08..40e26e2 100644 --- a/node/translate_node.py +++ b/node/translate_node.py @@ -32,7 +32,7 @@ class PromptTranslate: "required": { "原文": ("STRING", {"forceInput": True, "default": "", "multiline": True, "placeholder": "输入要翻译的文本..."}), "目标语言": (["英文", "中文"], {"default": "英文"}), - "翻译服务": (["百度翻译", "智谱翻译", "硅基流动翻译", "自定义翻译"], {"default": "百度翻译"}), + "翻译服务": (["百度翻译", "智谱翻译", "硅基流动翻译", "302.AI翻译", "Ollama翻译", "自定义翻译"], {"default": "百度翻译"}), }, } @@ -128,6 +128,10 @@ class PromptTranslate: result = self._translate_with_llm(原文, detected_lang, to_lang, "zhipu") elif 翻译服务 == "硅基流动翻译": result = self._translate_with_llm(原文, detected_lang, to_lang, "siliconflow") + elif 翻译服务 == "302.AI翻译": + result = self._translate_with_llm(原文, detected_lang, to_lang, "302ai") + elif 翻译服务 == "Ollama翻译": + result = self._translate_with_llm(原文, detected_lang, to_lang, "ollama") elif 翻译服务 == "自定义翻译": result = self._translate_with_llm(原文, detected_lang, to_lang, "custom") else: @@ -204,6 +208,8 @@ class PromptTranslate: provider_display_map = { "zhipu": "智谱翻译", "siliconflow": "硅基流动翻译", + "302ai": "302.AI翻译", + "ollama": "Ollama翻译", "custom": "自定义翻译" } provider_display_name = provider_display_map.get(provider, provider) @@ -380,6 +386,8 @@ class PromptTranslate: provider_display_map = { "zhipu": "智谱翻译", "siliconflow": "硅基流动翻译", + "302ai": "302.AI翻译", + "ollama": "Ollama翻译", "custom": "自定义翻译" } provider_display_name = provider_display_map.get(provider, provider) diff --git a/server.py b/server.py index f1a86fd..c0c0d53 100644 --- a/server.py +++ b/server.py @@ -4,6 +4,7 @@ from .config_manager import config_manager from .services.baidu import BaiduTranslateService from .services.llm import LLMService from .services.vlm import VisionService +from .services.model_list import ModelListService import base64 import json import traceback @@ -310,6 +311,7 @@ async def update_llm_config(request): temperature = provider_config.get('temperature') max_tokens = provider_config.get('max_tokens') top_p = provider_config.get('top_p') + auto_unload = provider_config.get('auto_unload') # 更新配置,但不更新current_provider success = success and config_manager.update_llm_config( @@ -320,6 +322,7 @@ async def update_llm_config(request): temperature=temperature, max_tokens=max_tokens, top_p=top_p, + auto_unload=auto_unload, update_current=False ) @@ -369,6 +372,7 @@ async def update_vision_config(request): temperature = provider_config.get('temperature') max_tokens = provider_config.get('max_tokens') top_p = provider_config.get('top_p') + auto_unload = provider_config.get('auto_unload') # 更新配置,但不更新current_provider success = success and config_manager.update_vision_config( @@ -379,6 +383,7 @@ async def update_vision_config(request): temperature=temperature, max_tokens=max_tokens, top_p=top_p, + auto_unload=auto_unload, update_current=False ) @@ -585,4 +590,55 @@ async def vlm_analyze(request): return web.json_response({"success": False, "error": error_msg}) finally: if request_id and request_id in ACTIVE_TASKS: - del ACTIVE_TASKS[request_id] \ No newline at end of file + del ACTIVE_TASKS[request_id] + +@PromptServer.instance.routes.post(f'{API_PREFIX}/models/list') +async def get_models_list(request): + """ + 获取模型列表API + 请求参数: + - provider: 服务提供商 (zhipu, siliconflow, 302ai, ollama, custom) + - model_type: 模型类型 ('llm' 或 'vision') + - recommended: 是否获取推荐列表 (可选,默认False) + """ + try: + data = await request.json() + provider = data.get("provider") + model_type = data.get("model_type", "llm") + recommended = data.get("recommended", False) + + if not provider: + return web.json_response({ + "success": False, + "error": "缺少provider参数" + }, status=400) + + # 如果请求推荐模型列表,直接返回静态列表 + if recommended: + result = ModelListService.get_recommended_models(provider, model_type) + return web.json_response(result) + + # 从配置中获取对应provider的API Key + if model_type == 'llm': + config = config_manager.get_llm_config() + else: + config = config_manager.get_vision_config() + + # 获取对应provider的配置 + providers_config = config.get('providers', {}) + provider_config = providers_config.get(provider, {}) + api_key = provider_config.get('api_key', '') + base_url = provider_config.get('base_url', '') + + # 调用模型列表服务 + result = ModelListService.get_models(provider, api_key, model_type, base_url) + + return web.json_response(result) + + except Exception as e: + error_msg = str(e) + print(f"{ERROR_PREFIX} 获取模型列表异常 | 错误:{error_msg}") + return web.json_response({ + "success": False, + "error": error_msg + }, status=500) \ No newline at end of file diff --git a/services/llm.py b/services/llm.py index 934a211..c7d111c 100644 --- a/services/llm.py +++ b/services/llm.py @@ -14,8 +14,8 @@ from .thinking_control import build_thinking_suppression class LLMService: _provider_base_urls = { 'openai': None, # 使用默认 - 'siliconflow': 'https://api.siliconflow.cn/v1', - 'zhipu': 'https://open.bigmodel.cn/api/paas/v4', + 'siliconflow': 'https://api.siliconflow.cn/v1', # 硅基流动 + 'zhipu': 'https://open.bigmodel.cn/api/paas/v4/', # 智谱(官方文档要求末尾斜杠) '302ai': 'https://api.302.ai/v1', 'ollama': 'http://localhost:11434/v1', 'custom': None # 使用配置中的自定义URL @@ -53,8 +53,8 @@ class LLMService: else: base_url = cls._provider_base_urls.get(provider) - # 创建简化的httpx客户端,不使用HTTP/2,避免额外依赖 - # 仅在“发起请求”阶段打印一条请求日志,避免重复 + # 创建简化的httpx客户端,明确禁用HTTP/2以提高国内服务商兼容性 + # 仅在"发起请求"阶段打印一条请求日志,避免重复 from ..server import PROCESS_PREFIX async def _on_request(request: httpx.Request): @@ -63,19 +63,34 @@ class LLMService: except Exception: pass + # 使用细粒度的超时配置,提高网络波动下的稳定性 + # connect: 建立连接的超时时间 + # read: 等待服务器响应的超时时间 + # write: 发送请求数据的超时时间 + # pool: 从连接池获取连接的超时时间 http_client = httpx.AsyncClient( - timeout=httpx.Timeout(15.0), # 设置超时时间,稍微增加以适应网络延迟 - event_hooks={'request': [_on_request]} + timeout=httpx.Timeout( + connect=10.0, # 连接超时:10秒 + read=60.0, # 读取超时:60秒(流式响应需要较长时间) + write=10.0, # 写入超时:10秒 + pool=5.0 # 连接池超时:5秒 + ), + event_hooks={'request': [_on_request]}, + http2=False, # 明确禁用HTTP/2,提高国内服务商兼容性 + proxy=None, # 明确禁用代理,避免连接问题 + verify=True # 启用SSL验证,但使用系统证书 ) kwargs = { "api_key": api_key, "http_client": http_client, - "max_retries": 2 # 设置最大重试次数 + "max_retries": 3 # 增加重试次数到3次 } if base_url: - # 确保base_url末尾没有斜杠 - base_url = base_url.rstrip('/') + # 根据官方文档,保留base_url原样,不做处理 + # 智谱文档要求:https://open.bigmodel.cn/api/paas/v4/ + # 硅基流动文档要求:https://api.siliconflow.cn/v1 + # OpenAI SDK会自动处理URL拼接 kwargs["base_url"] = base_url @@ -100,12 +115,52 @@ class LLMService: 'api_key': provider_config.get('api_key', ''), 'temperature': provider_config.get('temperature', 0.7), 'top_p': provider_config.get('top_p', 0.9), - 'max_tokens': provider_config.get('max_tokens', 2000) + 'max_tokens': provider_config.get('max_tokens', 2000), + 'auto_unload': provider_config.get('auto_unload', True) } else: # 兼容旧版配置格式 return config + @staticmethod + async def _unload_ollama_model(model: str, config: Dict[str, Any]): + """ + 卸载Ollama模型以释放显存和内存 + + 参数: + model: 模型名称 + config: 配置字典 + """ + try: + # 检查是否启用自动释放 + auto_unload = config.get('auto_unload', True) + if not auto_unload: + return + + # 获取base_url + base_url = config.get('base_url', 'http://localhost:11434') + # 确保URL不以/v1结尾(Ollama原生API不需要/v1) + if base_url.endswith('/v1'): + base_url = base_url[:-3] + + # 调用Ollama API卸载模型 + url = f"{base_url}/api/generate" + payload = { + "model": model, + "keep_alive": 0 # 立即卸载模型 + } + + from ..server import PROCESS_PREFIX + print(f"{PROCESS_PREFIX} Ollama自动释放显存 | 模型:{model}") + + async with httpx.AsyncClient(timeout=5.0) as client: + await client.post(url, json=payload) + + except Exception as e: + # 释放失败不影响主流程,只记录警告 + from ..server import WARN_PREFIX + print(f"{WARN_PREFIX} Ollama模型释放失败(不影响结果) | 模型:{model} | 错误:{str(e)[:50]}") + @staticmethod async def expand_prompt(prompt: str, request_id: Optional[str] = None, stream_callback: Optional[Callable[[str], None]] = None) -> Dict[str, Any]: """ @@ -191,40 +246,34 @@ class LLMService: # 使用OpenAI SDK client = LLMService.get_openai_client(api_key, provider) - try: - # 添加调试信息 - _thinking_extra = build_thinking_suppression(provider, model) - _thinking_tag = "(已关闭思维链)" if _thinking_extra else "" - from ..server import PROCESS_PREFIX - print(f"{PROCESS_PREFIX} 调用LLM API | 服务:{provider_display_name} | 模型:{model}{_thinking_tag}") - start_time = time.perf_counter() - - # 兼容不同提供商:将 reasoning_effort 放到顶层,其它参数放入 extra_body - extra_args = {} - if _thinking_extra: - _extra_copy = dict(_thinking_extra) - if "reasoning_effort" in _extra_copy: - extra_args["reasoning_effort"] = _extra_copy.pop("reasoning_effort") - if _extra_copy: - extra_args["extra_body"] = _extra_copy - - # 使用配置中的参数 + + # 添加手动重试机制,处理连接错误 + max_retries = 3 + retry_delay = 1.0 # 初始重试延迟(秒) + + for attempt in range(max_retries): try: - stream = await client.chat.completions.create( - model=model, - messages=[{"role": m["role"], "content": m["content"]} for m in messages], - temperature=temperature, - top_p=top_p, - max_tokens=max_tokens, - stream=True, - response_format={"type": "text"}, - **extra_args - ) - except Exception as e_first: - # 针对硅基流动返回 "enable_thinking 不支持" 的场景做一次性回退 - msg = str(e_first).lower() - if "enable_thinking" in msg and "not support" in msg: - print(f"{PREFIX} 发现 enable_thinking 不被模型支持,移除后重试 | 模型:{model}") + # 添加调试信息 + _thinking_extra = build_thinking_suppression(provider, model) + _thinking_tag = "(已关闭思维链)" if _thinking_extra else "" + from ..server import PROCESS_PREFIX + if attempt > 0: + print(f"{PROCESS_PREFIX} 重试LLM API调用 | 尝试:{attempt + 1}/{max_retries} | 服务:{provider_display_name} | 模型:{model}{_thinking_tag}") + else: + print(f"{PROCESS_PREFIX} 调用LLM API | 服务:{provider_display_name} | 模型:{model}{_thinking_tag}") + start_time = time.perf_counter() + + # 兼容不同提供商:将 reasoning_effort 放到顶层,其它参数放入 extra_body + extra_args = {} + if _thinking_extra: + _extra_copy = dict(_thinking_extra) + if "reasoning_effort" in _extra_copy: + extra_args["reasoning_effort"] = _extra_copy.pop("reasoning_effort") + if _extra_copy: + extra_args["extra_body"] = _extra_copy + + # 使用配置中的参数 + try: stream = await client.chat.completions.create( model=model, messages=[{"role": m["role"], "content": m["content"]} for m in messages], @@ -232,10 +281,49 @@ class LLMService: top_p=top_p, max_tokens=max_tokens, stream=True, - response_format={"type": "text"} + response_format={"type": "text"}, + **extra_args ) - else: + except Exception as e_first: + # 针对硅基流动返回 "enable_thinking 不支持" 的场景做一次性回退 + msg = str(e_first).lower() + if "enable_thinking" in msg and "not support" in msg: + print(f"{PREFIX} 发现 enable_thinking 不被模型支持,移除后重试 | 模型:{model}") + stream = await client.chat.completions.create( + model=model, + messages=[{"role": m["role"], "content": m["content"]} for m in messages], + temperature=temperature, + top_p=top_p, + max_tokens=max_tokens, + stream=True, + response_format={"type": "text"} + ) + else: + raise + + # 请求成功,跳出重试循环 + break + + except Exception as e: + # 检查是否为连接错误 + error_msg = str(e).lower() + is_connection_error = any(keyword in error_msg for keyword in [ + 'connection', 'connect', 'timeout', 'timed out', + 'network', 'unreachable', 'refused' + ]) + + # 如果是最后一次尝试,或者不是连接错误,则抛出异常 + if attempt == max_retries - 1 or not is_connection_error: raise + + # 否则等待后重试(使用指数退避策略) + wait_time = retry_delay * (2 ** attempt) + from ..server import WARN_PREFIX + print(f"{WARN_PREFIX} LLM API连接失败 | 尝试:{attempt + 1}/{max_retries} | 错误:{str(e)[:100]} | {wait_time:.1f}秒后重试") + await asyncio.sleep(wait_time) + continue + + try: full_content = "" async for chunk in stream: @@ -255,6 +343,10 @@ class LLMService: elapsed_ms = int((time.perf_counter() - start_time) * 1000) print(f"{PREFIX} LLM扩写成功 | 服务:{provider_display_name} | 请求ID:{request_id} | 结果字符数:{len(full_content)} | 耗时:{elapsed_ms}ms") + # Ollama自动释放显存 + if provider == 'ollama': + await LLMService._unload_ollama_model(model, config) + return { "success": True, "data": { @@ -357,39 +449,34 @@ class LLMService: # 使用OpenAI SDK client = LLMService.get_openai_client(api_key, provider) - try: - # 添加调试信息 - _thinking_extra = build_thinking_suppression(provider, model) - _thinking_tag = "(已关闭思维链)" if _thinking_extra else "" - from ..server import PROCESS_PREFIX - print(f"{PROCESS_PREFIX} 调用LLM API | 服务:{provider_display_name} | 模型:{model}{_thinking_tag}") - start_time = time.perf_counter() - - # 兼容不同提供商:将 reasoning_effort 放到顶层,其它参数放入 extra_body - extra_args = {} - if _thinking_extra: - _extra_copy = dict(_thinking_extra) - if "reasoning_effort" in _extra_copy: - extra_args["reasoning_effort"] = _extra_copy.pop("reasoning_effort") - if _extra_copy: - extra_args["extra_body"] = _extra_copy - - # 使用配置中的参数 + + # 添加手动重试机制,处理连接错误 + max_retries = 3 + retry_delay = 1.0 # 初始重试延迟(秒) + + for attempt in range(max_retries): try: - stream = await client.chat.completions.create( - model=model, - messages=[{"role": m["role"], "content": m["content"]} for m in messages], - temperature=temperature, - top_p=top_p, - max_tokens=max_tokens, - stream=True, - response_format={"type": "text"}, - **extra_args - ) - except Exception as e_first: - msg = str(e_first).lower() - if "enable_thinking" in msg and "not support" in msg: - print(f"{PREFIX} 发现 enable_thinking 不被模型支持,移除后重试 | 模型:{model}") + # 添加调试信息 + _thinking_extra = build_thinking_suppression(provider, model) + _thinking_tag = "(已关闭思维链)" if _thinking_extra else "" + from ..server import PROCESS_PREFIX + if attempt > 0: + print(f"{PROCESS_PREFIX} 重试LLM API调用 | 尝试:{attempt + 1}/{max_retries} | 服务:{provider_display_name} | 模型:{model}{_thinking_tag}") + else: + print(f"{PROCESS_PREFIX} 调用LLM API | 服务:{provider_display_name} | 模型:{model}{_thinking_tag}") + start_time = time.perf_counter() + + # 兼容不同提供商:将 reasoning_effort 放到顶层,其它参数放入 extra_body + extra_args = {} + if _thinking_extra: + _extra_copy = dict(_thinking_extra) + if "reasoning_effort" in _extra_copy: + extra_args["reasoning_effort"] = _extra_copy.pop("reasoning_effort") + if _extra_copy: + extra_args["extra_body"] = _extra_copy + + # 使用配置中的参数 + try: stream = await client.chat.completions.create( model=model, messages=[{"role": m["role"], "content": m["content"]} for m in messages], @@ -397,10 +484,48 @@ class LLMService: top_p=top_p, max_tokens=max_tokens, stream=True, - response_format={"type": "text"} + response_format={"type": "text"}, + **extra_args ) - else: + except Exception as e_first: + msg = str(e_first).lower() + if "enable_thinking" in msg and "not support" in msg: + print(f"{PREFIX} 发现 enable_thinking 不被模型支持,移除后重试 | 模型:{model}") + stream = await client.chat.completions.create( + model=model, + messages=[{"role": m["role"], "content": m["content"]} for m in messages], + temperature=temperature, + top_p=top_p, + max_tokens=max_tokens, + stream=True, + response_format={"type": "text"} + ) + else: + raise + + # 请求成功,跳出重试循环 + break + + except Exception as e: + # 检查是否为连接错误 + error_msg = str(e).lower() + is_connection_error = any(keyword in error_msg for keyword in [ + 'connection', 'connect', 'timeout', 'timed out', + 'network', 'unreachable', 'refused' + ]) + + # 如果是最后一次尝试,或者不是连接错误,则抛出异常 + if attempt == max_retries - 1 or not is_connection_error: raise + + # 否则等待后重试(使用指数退避策略) + wait_time = retry_delay * (2 ** attempt) + from ..server import WARN_PREFIX + print(f"{WARN_PREFIX} LLM API连接失败 | 尝试:{attempt + 1}/{max_retries} | 错误:{str(e)[:100]} | {wait_time:.1f}秒后重试") + await asyncio.sleep(wait_time) + continue + + try: full_content = "" async for chunk in stream: @@ -421,6 +546,10 @@ class LLMService: elapsed_ms = int((time.perf_counter() - start_time) * 1000) print(f"{prefix} {'工作流翻译完成' if is_auto else '翻译完成'} | 服务:{provider_display_name}翻译 | 请求ID:{request_id} | 结果字符数:{len(full_content)} | 耗时:{elapsed_ms}ms") + # Ollama自动释放显存 + if provider == 'ollama': + await LLMService._unload_ollama_model(model, config) + return { "success": True, "data": { diff --git a/services/model_list.py b/services/model_list.py new file mode 100644 index 0000000..33f5eed --- /dev/null +++ b/services/model_list.py @@ -0,0 +1,433 @@ +""" +模型列表服务 +负责从不同的服务提供商获取可用的模型列表 +""" + +import logging +import requests +from typing import Dict, List, Optional + +logger = logging.getLogger(__name__) + + +class ModelListService: + """模型列表服务类""" + + # 智谱官方模型列表(手动维护) + # 注意:智谱官方不提供模型列表API,需要手动维护 + # 来源:https://docs.bigmodel.cn/ + ZHIPU_MODELS = { + 'llm': [ + # GLM-4 系列 + 'glm-4-flash-250414', + 'glm-4-flash', + 'glm-4-plus', + 'glm-4-air-250414', + # GLM-4.5 系列(新) + 'GLM-4.5-Flash', + 'GLM-4.5-AirX', + 'glm-4.5', + 'glm-4.6', + ], + 'vision': [ + # GLM-4V 系列 + 'GLM-4V-Flash', + 'GLM-4V', + 'glm-4v-plus-0111', + # GLM-4.xV 系列(新) + 'GLM-4.5V', + 'glm-4.1v-thinking-flash', + ] + } + + # 推荐模型列表(精选的常用模型) + RECOMMENDED_MODELS = { + 'zhipu': { + 'llm': [ + 'GLM-4.5-Flash', + 'glm-4-flash-250414' + ], + 'vision': [ + 'glm-4v-flash', + 'GLM-4.5V' + ] + }, + 'siliconflow': { + 'llm': [ + 'Qwen/Qwen3-8B', + 'Qwen/Qwen2.5-7B-Instruct', + 'Qwen/Qwen2-7B-Instruct', + 'deepseek-ai/DeepSeek-R1-0528-Qwen3-8B', + 'deepseek-ai/DeepSeek-R1-Distill-Qwen-7B' + ], + 'vision': [ + 'THUDM/GLM-4.1V-9B-Thinking', + 'Qwen/Qwen3-VL-30B-A3B-Instruct', + 'Pro/Qwen/Qwen2.5-VL-7B-Instruct' + ] + }, + '302ai': { + 'llm': [ + 'gpt-5-pro', + 'gpt-5-nano-2025-08-07', + 'gemini-2.5-flash-nothink', + 'gemini-2.5-pro', + 'doubao-seed-1-6-250615' + ], + 'vision': [ + 'grok-2-vision-1212', + 'grok-4-fast-non-reasoning', + 'gemini-2.5-flash-nothink', + 'gemini-2.5-pro' + ] + } + } + + # 各服务提供商的API端点 + ENDPOINTS = { + 'zhipu': { + 'base_url': 'https://open.bigmodel.cn/api/paas/v4', + 'models_path': '/models' + }, + 'siliconflow': { + 'base_url': 'https://api.siliconflow.cn/v1', + 'models_path': '/models' + }, + '302ai': { + 'base_url': 'https://api.302.ai/v1', + 'models_path': '/models' + } + } + + @staticmethod + def get_recommended_models(provider: str, model_type: str = 'llm') -> Dict: + """ + 获取推荐模型列表(静态预设) + + Args: + provider: 服务提供商 (zhipu, siliconflow, 302ai) + model_type: 模型类型 ('llm' 或 'vision') + + Returns: + 包含推荐模型列表的字典 + """ + try: + if provider not in ModelListService.RECOMMENDED_MODELS: + return { + 'success': False, + 'error': f'提供商 {provider} 暂无推荐模型列表' + } + + model_ids = ModelListService.RECOMMENDED_MODELS.get(provider, {}).get(model_type, []) + models = [{'id': model_id, 'name': model_id} for model_id in model_ids] + + return { + 'success': True, + 'models': models + } + except Exception as e: + logger.error(f"获取推荐模型列表失败: {str(e)}") + return { + 'success': False, + 'error': f'获取推荐模型列表失败: {str(e)}' + } + + @staticmethod + def get_models(provider: str, api_key: str, model_type: str = 'llm', base_url: str = None) -> Dict: + """ + 获取指定服务提供商的模型列表 + + Args: + provider: 服务提供商 (zhipu, siliconflow, 302ai, custom) + api_key: API密钥 + model_type: 模型类型 ('llm' 或 'vision') + base_url: 自定义服务的base_url(仅用于custom) + + Returns: + 包含模型列表的字典 + """ + if provider == 'ollama': + return ModelListService._get_ollama_models(model_type) + + # 自定义服务:使用OpenAI标准接口 + if provider == 'custom': + return ModelListService._get_custom_models(api_key, base_url, model_type) + + # 智谱官方不提供模型列表API,使用静态列表 + if provider == 'zhipu': + return ModelListService._get_zhipu_static_models(model_type) + + if provider not in ModelListService.ENDPOINTS: + return { + 'success': False, + 'error': f'不支持的服务提供商: {provider}' + } + + if not api_key or api_key.strip() == '': + return { + 'success': False, + 'error': 'API Key为空,无法获取模型列表' + } + + endpoint = ModelListService.ENDPOINTS[provider] + url = f"{endpoint['base_url']}{endpoint['models_path']}" + + headers = { + 'Authorization': f'Bearer {api_key}', + 'Content-Type': 'application/json' + } + + params = {} + + # 根据不同的服务提供商和模型类型设置参数 + if provider == 'siliconflow': + if model_type == 'llm': + params['sub_type'] = 'chat' + elif model_type == 'vision': + # 硅基流动的视觉模型需要通过chat类型过滤,后续手动筛选支持图像的模型 + params['sub_type'] = 'chat' + elif provider == '302ai': + if model_type == 'llm': + params['llm'] = '1' + elif model_type == 'vision': + params['vision'] = '1' + + try: + response = requests.get(url, headers=headers, params=params, timeout=10) + + if response.status_code == 200: + data = response.json() + models = ModelListService._parse_models(data, provider, model_type) + return { + 'success': True, + 'models': models + } + else: + error_msg = f'获取模型列表失败: HTTP {response.status_code}' + try: + error_data = response.json() + if 'error' in error_data: + error_msg = error_data['error'].get('message', error_msg) + elif 'message' in error_data: + error_msg = error_data['message'] + except: + pass + + logger.error(f"获取{provider}模型列表失败: {error_msg}") + return { + 'success': False, + 'error': error_msg + } + + except requests.exceptions.Timeout: + return { + 'success': False, + 'error': '请求超时,请检查网络连接' + } + except requests.exceptions.RequestException as e: + logger.error(f"获取{provider}模型列表异常: {str(e)}") + return { + 'success': False, + 'error': f'网络请求失败: {str(e)}' + } + except Exception as e: + logger.error(f"获取{provider}模型列表时发生未知错误: {str(e)}") + return { + 'success': False, + 'error': f'未知错误: {str(e)}' + } + + @staticmethod + def _get_custom_models(api_key: str, base_url: str, model_type: str = 'llm') -> Dict: + """ + 获取自定义服务的模型列表(OpenAI标准) + + Args: + api_key: API密钥 + base_url: 自定义服务的base_url + model_type: 模型类型 ('llm' 或 'vision') + + Returns: + 包含模型列表的字典 + """ + if not base_url or base_url.strip() == '': + return { + 'success': False, + 'error': '自定义服务需要配置Base URL才能获取模型列表' + } + + # 确保base_url不以/结尾 + base_url = base_url.rstrip('/') + + # 构建完整的URL(OpenAI标准) + url = f"{base_url}/models" + + headers = { + 'Content-Type': 'application/json' + } + + # 只有在API Key存在时才添加认证头 + if api_key and api_key.strip(): + headers['Authorization'] = f'Bearer {api_key}' + + try: + response = requests.get(url, headers=headers, timeout=10) + + if response.status_code == 200: + data = response.json() + models = ModelListService._parse_models(data, 'custom', model_type) + return { + 'success': True, + 'models': models + } + else: + error_msg = f'获取模型列表失败: HTTP {response.status_code}' + try: + error_data = response.json() + if 'error' in error_data: + error_msg = error_data['error'].get('message', error_msg) + elif 'message' in error_data: + error_msg = error_data['message'] + except: + pass + + logger.error(f"获取自定义服务模型列表失败: {error_msg}") + return { + 'success': False, + 'error': error_msg + } + + except requests.exceptions.Timeout: + return { + 'success': False, + 'error': '请求超时,请检查Base URL是否正确' + } + except requests.exceptions.ConnectionError: + return { + 'success': False, + 'error': '连接失败,请检查Base URL和网络连接' + } + except requests.exceptions.RequestException as e: + logger.error(f"获取自定义服务模型列表异常: {str(e)}") + return { + 'success': False, + 'error': f'网络请求失败: {str(e)}' + } + except Exception as e: + logger.error(f"获取自定义服务模型列表时发生未知错误: {str(e)}") + return { + 'success': False, + 'error': f'未知错误: {str(e)}' + } + + @staticmethod + def _get_zhipu_static_models(model_type: str = 'llm') -> Dict: + """ + 获取智谱静态模型列表 + 注意:智谱官方不提供模型列表API,这里使用手动维护的静态列表 + + Args: + model_type: 模型类型 ('llm' 或 'vision') + + Returns: + 包含模型列表的字典 + """ + try: + model_ids = ModelListService.ZHIPU_MODELS.get(model_type, []) + models = [{'id': model_id, 'name': model_id} for model_id in model_ids] + + return { + 'success': True, + 'models': models + } + except Exception as e: + logger.error(f"获取智谱静态模型列表失败: {str(e)}") + return { + 'success': False, + 'error': f'获取智谱模型列表失败: {str(e)}' + } + + @staticmethod + def _get_ollama_models(model_type: str = 'llm') -> Dict: + """ + 获取Ollama本地模型列表 + + Args: + model_type: 模型类型 ('llm' 或 'vision') + + Returns: + 包含模型列表的字典 + """ + try: + url = 'http://localhost:11434/api/tags' + response = requests.get(url, timeout=5) + + if response.status_code == 200: + data = response.json() + models = [] + + if 'models' in data: + for model in data['models']: + model_name = model.get('name', '') + # 不做筛选,返回所有模型 + models.append({ + 'id': model_name, + 'name': model_name + }) + + return { + 'success': True, + 'models': models + } + else: + return { + 'success': False, + 'error': 'Ollama服务未运行或无法连接' + } + + except requests.exceptions.ConnectionError: + return { + 'success': False, + 'error': 'Ollama服务未运行,请先启动Ollama' + } + except Exception as e: + logger.error(f"获取Ollama模型列表失败: {str(e)}") + return { + 'success': False, + 'error': f'获取Ollama模型列表失败: {str(e)}' + } + + @staticmethod + def _parse_models(data: Dict, provider: str, model_type: str) -> List[Dict]: + """ + 解析不同服务提供商的模型列表数据 + 不做任何筛选,API返回什么就显示什么 + + Args: + data: API返回的原始数据 + provider: 服务提供商 + model_type: 模型类型 + + Returns: + 标准化的模型列表 + """ + models = [] + + if 'data' not in data: + return models + + for model in data['data']: + model_id = model.get('id', '') + + # 跳过非模型项 + if not model_id: + continue + + # 直接添加到模型列表,不做任何筛选 + models.append({ + 'id': model_id, + 'name': model_id + }) + + return models + diff --git a/services/vlm.py b/services/vlm.py index 845c505..75dc4dd 100644 --- a/services/vlm.py +++ b/services/vlm.py @@ -15,8 +15,8 @@ from .error_util import format_api_error class VisionService: _provider_base_urls = { 'openai': None, # 使用默认 - 'siliconflow': 'https://api.siliconflow.cn/v1', - 'zhipu': 'https://open.bigmodel.cn/api/paas/v4', + 'siliconflow': 'https://api.siliconflow.cn/v1', # 硅基流动 + 'zhipu': 'https://open.bigmodel.cn/api/paas/v4/', # 智谱(官方文档要求末尾斜杠) '302ai': 'https://api.302.ai/v1', 'ollama': 'http://localhost:11434/v1', 'custom': None # 使用配置中的自定义URL @@ -52,9 +52,9 @@ class VisionService: else: base_url = cls._provider_base_urls.get(provider) - # 创建简化的httpx客户端,不使用HTTP/2,避免额外依赖 + # 创建简化的httpx客户端,明确禁用HTTP/2以提高国内服务商兼容性 # 视觉模型需要较长的超时时间 - # 仅在“发起请求”阶段打印一条请求日志,避免重复 + # 仅在"发起请求"阶段打印一条请求日志,避免重复 from ..server import PROCESS_PREFIX async def _on_request(request: httpx.Request): @@ -63,19 +63,31 @@ class VisionService: except Exception: pass + # 使用细粒度的超时配置,提高网络波动下的稳定性 + # 视觉模型处理需要较长时间,因此读取超时设置更长 http_client = httpx.AsyncClient( - timeout=httpx.Timeout(90.0), # 视觉模型通常较慢,适当增加超时时间 - event_hooks={'request': [_on_request]} + timeout=httpx.Timeout( + connect=15.0, # 连接超时:15秒 + read=120.0, # 读取超时:120秒(视觉模型分析需要更长时间) + write=15.0, # 写入超时:15秒 + pool=10.0 # 连接池超时:10秒 + ), + event_hooks={'request': [_on_request]}, + http2=False, # 明确禁用HTTP/2,提高国内服务商兼容性 + proxy=None, # 明确禁用代理,避免连接问题 + verify=True # 启用SSL验证,但使用系统证书 ) kwargs = { "api_key": api_key, "http_client": http_client, - "max_retries": 2 # 设置最大重试次数 + "max_retries": 3 # 增加重试次数到3次 } if base_url: - # 确保base_url末尾没有斜杠 - base_url = base_url.rstrip('/') + # 根据官方文档,保留base_url原样,不做处理 + # 智谱文档要求:https://open.bigmodel.cn/api/paas/v4/ + # 硅基流动文档要求:https://api.siliconflow.cn/v1 + # OpenAI SDK会自动处理URL拼接 kwargs["base_url"] = base_url @@ -100,12 +112,53 @@ class VisionService: 'api_key': provider_config.get('api_key', ''), 'temperature': provider_config.get('temperature', 0.7), 'top_p': provider_config.get('top_p', 0.9), - 'max_tokens': provider_config.get('max_tokens', 2000) + 'max_tokens': provider_config.get('max_tokens', 2000), + 'auto_unload': provider_config.get('auto_unload', True) } else: # 兼容旧版配置格式 return config + @staticmethod + async def _unload_ollama_model(model: str, provider_config: Dict[str, Any]): + """ + 卸载Ollama模型以释放显存和内存 + + 参数: + model: 模型名称 + provider_config: 提供商配置字典 + """ + try: + # 检查是否启用自动释放 + auto_unload = provider_config.get('auto_unload', True) + if not auto_unload: + return + + # 获取base_url + base_url = provider_config.get('base_url', 'http://localhost:11434') + # 确保URL不以/v1结尾(Ollama原生API不需要/v1) + if base_url.endswith('/v1'): + base_url = base_url[:-3] + + # 调用Ollama API卸载模型 + url = f"{base_url}/api/generate" + payload = { + "model": model, + "keep_alive": 0 # 立即卸载模型 + } + + from ..server import PROCESS_PREFIX + print(f"{PROCESS_PREFIX} Ollama自动释放显存 | 模型:{model}") + + import httpx as httpx_module + async with httpx_module.AsyncClient(timeout=5.0) as client: + await client.post(url, json=payload) + + except Exception as e: + # 释放失败不影响主流程,只记录警告 + from ..server import WARN_PREFIX + print(f"{WARN_PREFIX} Ollama模型释放失败(不影响结果) | 模型:{model} | 错误:{str(e)[:50]}") + @staticmethod def preprocess_image(image_data: str, request_id: Optional[str] = None) -> str: """ @@ -278,59 +331,92 @@ class VisionService: # 使用OpenAI SDK client = VisionService.get_openai_client(api_key, provider) - try: - # 添加调试信息 - from ..server import PROCESS_PREFIX - print(f"{PROCESS_PREFIX} 调用视觉模型API | 服务:{provider_display_name} | 模型:{model}{_thinking_tag}") - start_time = time.perf_counter() - - # 使用配置中的参数 - _thinking_extra = build_thinking_suppression(provider, model) + + # 添加手动重试机制,处理连接错误 + max_retries = 3 + retry_delay = 1.0 # 初始重试延迟(秒) + + for attempt in range(max_retries): try: - # 构建基础参数(保守:不带 response_format,默认流式) - create_kwargs = dict( - model=model, - messages=[{ - "role": "user", - "content": [ - {"type": "text", "text": system_prompt}, - {"type": "image_url", "image_url": {"url": image_data}} - ] - }], - max_tokens=max_tokens, - temperature=temperature, - top_p=top_p, - stream=True - ) - # 思维链控制:extra_body 或 顶层参数 - if _thinking_extra: - # Gemini 的 reasoning_effort 需要顶层;其余放入 extra_body - _extra = dict(_thinking_extra) - if "reasoning_effort" in _extra: - create_kwargs["reasoning_effort"] = _extra.pop("reasoning_effort") - if _extra: - create_kwargs["extra_body"] = _extra - response = await client.chat.completions.create(**create_kwargs) - except Exception as e_first: - msg = str(e_first).lower() - if "enable_thinking" in msg and "not support" in msg: - print(f"{PREFIX} 发现 enable_thinking 不被模型支持,移除后重试 | 模型:{model}") - create_kwargs.pop("extra_body", None) - response = await client.chat.completions.create(**create_kwargs) - elif ("response_format" in msg and ("unknown" in msg or "not support" in msg)) or ( - "stream" in msg and ("not support" in msg or "unsupported" in msg) - ): - # 去除不被支持的参数并关闭流式重试一次 - create_kwargs.pop("response_format", None) - create_kwargs["stream"] = False - response = await client.chat.completions.create(**create_kwargs) - elif "max_tokens" in msg and ("unknown" in msg or "invalid" in msg): - # 少数聚合网关可能要求 max_output_tokens - create_kwargs.pop("max_tokens", None) - create_kwargs["max_output_tokens"] = max_tokens - response = await client.chat.completions.create(**create_kwargs) + # 添加调试信息 + from ..server import PROCESS_PREFIX + if attempt > 0: + print(f"{PROCESS_PREFIX} 重试视觉模型API调用 | 尝试:{attempt + 1}/{max_retries} | 服务:{provider_display_name} | 模型:{model}{_thinking_tag}") else: + print(f"{PROCESS_PREFIX} 调用视觉模型API | 服务:{provider_display_name} | 模型:{model}{_thinking_tag}") + start_time = time.perf_counter() + + # 使用配置中的参数 + _thinking_extra = build_thinking_suppression(provider, model) + try: + # 构建基础参数(保守:不带 response_format,默认流式) + create_kwargs = dict( + model=model, + messages=[{ + "role": "user", + "content": [ + {"type": "text", "text": system_prompt}, + {"type": "image_url", "image_url": {"url": image_data}} + ] + }], + max_tokens=max_tokens, + temperature=temperature, + top_p=top_p, + stream=True + ) + # 思维链控制:extra_body 或 顶层参数 + if _thinking_extra: + # Gemini 的 reasoning_effort 需要顶层;其余放入 extra_body + _extra = dict(_thinking_extra) + if "reasoning_effort" in _extra: + create_kwargs["reasoning_effort"] = _extra.pop("reasoning_effort") + if _extra: + create_kwargs["extra_body"] = _extra + response = await client.chat.completions.create(**create_kwargs) + except Exception as e_first: + msg = str(e_first).lower() + if "enable_thinking" in msg and "not support" in msg: + print(f"{PREFIX} 发现 enable_thinking 不被模型支持,移除后重试 | 模型:{model}") + create_kwargs.pop("extra_body", None) + response = await client.chat.completions.create(**create_kwargs) + elif ("response_format" in msg and ("unknown" in msg or "not support" in msg)) or ( + "stream" in msg and ("not support" in msg or "unsupported" in msg) + ): + # 去除不被支持的参数并关闭流式重试一次 + create_kwargs.pop("response_format", None) + create_kwargs["stream"] = False + response = await client.chat.completions.create(**create_kwargs) + elif "max_tokens" in msg and ("unknown" in msg or "invalid" in msg): + # 少数聚合网关可能要求 max_output_tokens + create_kwargs.pop("max_tokens", None) + create_kwargs["max_output_tokens"] = max_tokens + response = await client.chat.completions.create(**create_kwargs) + else: + raise + + # 请求成功,跳出重试循环 + break + + except Exception as e: + # 检查是否为连接错误 + error_msg = str(e).lower() + is_connection_error = any(keyword in error_msg for keyword in [ + 'connection', 'connect', 'timeout', 'timed out', + 'network', 'unreachable', 'refused' + ]) + + # 如果是最后一次尝试,或者不是连接错误,则抛出异常 + if attempt == max_retries - 1 or not is_connection_error: raise + + # 否则等待后重试(使用指数退避策略) + wait_time = retry_delay * (2 ** attempt) + from ..server import WARN_PREFIX + print(f"{WARN_PREFIX} 视觉模型API连接失败 | 尝试:{attempt + 1}/{max_retries} | 错误:{str(e)[:100]} | {wait_time:.1f}秒后重试") + await asyncio.sleep(wait_time) + continue + + try: full_content = "" async for chunk in response: @@ -350,6 +436,15 @@ class VisionService: elapsed_ms = int((time.perf_counter() - start_time) * 1000) print(f"{PREFIX} 视觉模型分析成功 | 服务:{provider_display_name} | 请求ID:{request_id} | 结果字符数:{len(full_content)} | 耗时:{elapsed_ms}ms") + # Ollama自动释放显存 + if provider == 'ollama': + # 构建配置字典用于释放 + provider_config = { + 'auto_unload': custom_provider_config.get('auto_unload', True) if custom_provider_config else config.get('auto_unload', True), + 'base_url': base_url if custom_provider_config else config.get('base_url', 'http://localhost:11434') + } + await VisionService._unload_ollama_model(model, provider_config) + return { "success": True, "data": {