From 5197babb208a341f7d93d52342ee7a19aedffef3 Mon Sep 17 00:00:00 2001
From: yawiii <77927734@qq.com>
Date: Tue, 14 Oct 2025 14:54:05 +0800
Subject: [PATCH] 1.2.1
---
config_manager.py | 26 +-
js/css/assistant.css | 37 +-
js/css/popup.css | 48 +--
js/css/settings.css | 6 +-
js/modules/PromptAssistant.js | 283 +++++++++-----
js/modules/apiConfigManager.js | 686 +++++++++++++++++++++++++++++++--
js/modules/imageCaption.js | 7 +-
js/modules/settings.js | 83 +++-
js/modules/tag.js | 585 ++++++++++++++++++++++------
js/services/features.js | 14 +
js/utils/popupManager.js | 11 +
js/utils/promptFormatter.js | 87 +++--
node/expand_node.py | 8 +-
node/image_caption_node.py | 6 +-
node/kontext_preset_node.py | 4 +-
node/translate_node.py | 10 +-
server.py | 58 ++-
services/llm.py | 287 ++++++++++----
services/model_list.py | 433 +++++++++++++++++++++
services/vlm.py | 215 ++++++++---
20 files changed, 2446 insertions(+), 448 deletions(-)
create mode 100644 services/model_list.py
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": {