From 98270dfcac271d94f84b21e3668924a9ef2c3d0d Mon Sep 17 00:00:00 2001 From: Keyang556 <65295310+keyang556@users.noreply.github.com> Date: Thu, 14 May 2026 13:01:50 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E5=9C=96=E7=89=87=E6=8F=8F=E8=BF=B0?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E8=AA=BF=E6=95=B4=EF=BC=9AToken=20=E7=84=A1?= =?UTF-8?q?=E9=99=90=E5=88=B6=E9=A0=90=E8=A8=AD=E3=80=81API=20Key=20?= =?UTF-8?q?=E9=81=AE=E8=94=BD=E3=80=81=E6=96=B0=E5=A2=9E=20Pollinations=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Token 限制改為預設無限制:新增「限制圖片描述最大 Token」checkbox, 未勾選時不向 API 傳送 max_tokens(Google/NVIDIA/Pollinations 三個 backend 皆適用)。移除已無用的 _getEffectiveImageMaxTokens 快取函式。 2. API Key 欄位改為密碼模式:預設以 wx.TE_PASSWORD 遮蔽,旁邊新增 「顯示 API 金鑰」checkbox,勾選後透過 sizer.Replace() 換成明文控制項。 3. Pollinations.AI 新增 10 個模型: mistral / llama-scout / gemini-fast / qwen-vision / gemini-search / grok / kimi / mistral-large / qwen-large / kimi-k2.6 Co-Authored-By: Claude Sonnet 4.6 --- addon/appModules/line.py | 45 +++++++----- addon/globalPlugins/lineDesktopHelper.py | 88 ++++++++++++++++++++---- 2 files changed, 105 insertions(+), 28 deletions(-) diff --git a/addon/appModules/line.py b/addon/appModules/line.py index d7afaa0..2b5debe 100644 --- a/addon/appModules/line.py +++ b/addon/appModules/line.py @@ -2082,6 +2082,16 @@ def _restoreFocusToElement(element, expectedRuntimeId=None): "openai", "openai-fast", "openai-large", + "mistral", + "llama-scout", + "gemini-fast", + "qwen-vision", + "gemini-search", + "grok", + "kimi", + "mistral-large", + "qwen-large", + "kimi-k2.6", ) # Display labels for Pollinations.AI models, surfaced in the settings dropdown. _IMAGE_DESCRIPTION_POLLINATIONS_MODEL_LABELS = { @@ -2089,6 +2099,16 @@ def _restoreFocusToElement(element, expectedRuntimeId=None): "openai": "GPT-5.4 Nano", "openai-fast": "GPT-5 Nano", "openai-large": "GPT-5.4", + "mistral": "Mistral Small 3.1", + "llama-scout": "Meta Llama 4 Scout 17B 16E Instruct", + "gemini-fast": "Gemini 2.5 Flash Lite", + "qwen-vision": "Qwen3 VL 30B A3B Thinking", + "gemini-search": "Google Gemini 2.5 Flash Lite Search", + "grok": "Grok 4.20 Non-Reasoning", + "kimi": "Moonshot Kimi K2.5", + "mistral-large": "Mistral Large 3", + "qwen-large": "Qwen3.6 Plus", + "kimi-k2.6": "Moonshot Kimi K2.6", } _IMAGE_DESCRIPTION_ENDPOINT = ( "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={key}" @@ -2125,7 +2145,6 @@ def _restoreFocusToElement(element, expectedRuntimeId=None): _cachedEffectiveOllamaModel = _NOT_COMPUTED _cachedEffectiveNvidiaModel = _NOT_COMPUTED _cachedEffectivePollinationsModel = _NOT_COMPUTED -_cachedEffectiveImageMaxTokens = _NOT_COMPUTED def _deriveImageApiKeyMaterial(salt, length): @@ -2957,16 +2976,15 @@ def getUserImageMaxTokens(): def setUserImageMaxTokens(value): - """Persist a user-configured max-tokens value. None or default clears the file.""" - global _cachedEffectiveImageMaxTokens + """Persist a user-configured max-tokens value. ``None`` clears the file, + leaving the backend unconstrained (no ``max_tokens`` sent to the API).""" path = _getImageMaxTokensStorePath() if not path: return False try: - if value is None or value == _IMAGE_DESCRIPTION_DEFAULT_MAX_TOKENS: + if value is None: if os.path.isfile(path): os.remove(path) - _cachedEffectiveImageMaxTokens = _IMAGE_DESCRIPTION_DEFAULT_MAX_TOKENS return True try: n = int(value) @@ -2978,21 +2996,12 @@ def setUserImageMaxTokens(value): return False with open(path, "w", encoding="utf-8") as f: f.write(str(n)) - _cachedEffectiveImageMaxTokens = n return True except Exception as e: log.warning(f"LINE: failed to save user image max tokens: {e}", exc_info=True) return False -def _getEffectiveImageMaxTokens(): - """Return the cached max-tokens value; lazily resolved from disk on first call.""" - global _cachedEffectiveImageMaxTokens - if _cachedEffectiveImageMaxTokens is _NOT_COMPUTED: - _cachedEffectiveImageMaxTokens = getUserImageMaxTokens() or _IMAGE_DESCRIPTION_DEFAULT_MAX_TOKENS - return _cachedEffectiveImageMaxTokens - - _NOTES_WINDOW_KEYWORDS = ("記事本", "note", "keep", "ノート", "บันทึก", "노트") _NOTES_OCR_KEYWORDS = ( "記事本", @@ -3372,9 +3381,11 @@ def _callNvidiaImageDescriptionApi(contents, timeout=60.0): body = { "model": _getEffectiveNvidiaModel(), "messages": messages, - "max_tokens": _getEffectiveImageMaxTokens(), "stream": False, } + _userMaxTokens = getUserImageMaxTokens() + if _userMaxTokens is not None: + body["max_tokens"] = _userMaxTokens req = urllib.request.Request( _IMAGE_DESCRIPTION_NVIDIA_ENDPOINT, data=json.dumps(body).encode("utf-8"), @@ -3482,9 +3493,11 @@ def _callPollinationsImageDescriptionApi(contents, timeout=60.0): body = { "model": _getEffectivePollinationsModel(), "messages": messages, - "max_tokens": _getEffectiveImageMaxTokens(), "stream": False, } + _userMaxTokens = getUserImageMaxTokens() + if _userMaxTokens is not None: + body["max_tokens"] = _userMaxTokens headers = { "Content-Type": "application/json", "Accept": "application/json", diff --git a/addon/globalPlugins/lineDesktopHelper.py b/addon/globalPlugins/lineDesktopHelper.py index 0c6571e..55c5ad0 100644 --- a/addon/globalPlugins/lineDesktopHelper.py +++ b/addon/globalPlugins/lineDesktopHelper.py @@ -133,7 +133,18 @@ def makeSettings(self, settingsSizer): # Translators: Text field label for the image-description API key apiLabel = _("圖片描述 API Key,留空則使用預設金鑰 (&I)") - self._apiKeyText = sHelper.addLabeledControl(apiLabel, wx.TextCtrl) + # Stored under password mask by default to avoid shoulder-surfing; the + # "show" checkbox below recreates the control without the mask on demand. + self._apiKeyText = sHelper.addLabeledControl( + apiLabel, + wx.TextCtrl, + style=wx.TE_PASSWORD, + ) + # Translators: Checkbox label for revealing the image-description API key. + showApiLabel = _("顯示 API 金鑰 (&S)") + self._showApiKeyCheck = sHelper.addItem(wx.CheckBox(self, label=showApiLabel)) + self._showApiKeyCheck.SetValue(False) + self._showApiKeyCheck.Bind(wx.EVT_CHECKBOX, self._onShowApiKeyChange) # Translators: Dropdown label for selecting the image-description model modelLabel = _("圖片描述模型 (&M)") @@ -153,6 +164,14 @@ def makeSettings(self, settingsSizer): ) self._promptText.SetValue(self._loadCurrentPrompt()) + userMaxTokens = self._loadUserMaxTokens() + # Translators: Checkbox label for enabling the maximum-output-tokens limit. + limitTokensLabel = _("限制圖片描述最大 Token (&L)") + self._limitTokensCheck = sHelper.addItem( + wx.CheckBox(self, label=limitTokensLabel), + ) + self._limitTokensCheck.SetValue(userMaxTokens is not None) + # Translators: Spin control label for the maximum output tokens used by the image-description AI. maxTokensLabel = _("圖片描述最大 Token (&K)") maxTokensRange = self._maxTokensRange() @@ -161,14 +180,49 @@ def makeSettings(self, settingsSizer): wx.SpinCtrl, min=maxTokensRange[0], max=maxTokensRange[1], - initial=self._loadCurrentMaxTokens(), + initial=userMaxTokens if userMaxTokens is not None else self._defaultMaxTokens(), ) + self._maxTokensSpin.Enable(self._limitTokensCheck.GetValue()) + self._limitTokensCheck.Bind(wx.EVT_CHECKBOX, self._onLimitTokensChange) self._activeProviderId = self._currentSelectedProviderId() or _safeDefaultProvider() self._refreshProviderUI(self._activeProviderId) self._providerChoice.Bind(wx.EVT_CHOICE, self._onProviderChange) + def _onLimitTokensChange(self, evt): + self._maxTokensSpin.Enable(self._limitTokensCheck.GetValue()) + + def _onShowApiKeyChange(self, evt): + """Recreate the API-key field so its TE_PASSWORD style can flip. + + wxPython exposes no portable way to toggle ``wx.TE_PASSWORD`` on an + existing control, so we swap in a fresh ``wx.TextCtrl`` and let the + surrounding horizontal sizer re-adopt it with the same flags. + """ + if not getattr(self, "_apiKeyText", None): + return + sizer = self._apiKeyText.GetContainingSizer() + if sizer is None: + return + currentValue = self._apiKeyText.GetValue() + insertionPoint = self._apiKeyText.GetInsertionPoint() + hadFocus = self._apiKeyText.HasFocus() + showPlain = bool(self._showApiKeyCheck.GetValue()) + style = 0 if showPlain else wx.TE_PASSWORD + newCtrl = wx.TextCtrl(self, style=style) + newCtrl.ChangeValue(currentValue) + try: + newCtrl.SetInsertionPoint(insertionPoint) + except Exception: + pass + sizer.Replace(self._apiKeyText, newCtrl) + self._apiKeyText.Destroy() + self._apiKeyText = newCtrl + self.Layout() + if hadFocus: + newCtrl.SetFocus() + def _currentSelectedProviderId(self): """Return the provider ID matching the dropdown selection, or None.""" idx = self._providerChoice.GetSelection() if hasattr(self, "_providerChoice") else wx.NOT_FOUND @@ -366,16 +420,24 @@ def _maxTokensRange(self): log.debug("LINE: cannot load max-tokens bounds", exc_info=True) return (50, 4000) - def _loadCurrentMaxTokens(self): + def _loadUserMaxTokens(self): + """Return the user-configured max tokens, or ``None`` when unset (= no limit).""" try: - from appModules.line import ( - _IMAGE_DESCRIPTION_DEFAULT_MAX_TOKENS, - getUserImageMaxTokens, - ) + from appModules.line import getUserImageMaxTokens - return getUserImageMaxTokens() or _IMAGE_DESCRIPTION_DEFAULT_MAX_TOKENS + return getUserImageMaxTokens() except Exception: log.debug("LINE: cannot load image max tokens", exc_info=True) + return None + + def _defaultMaxTokens(self): + """Return the suggested spin-control initial value when the user enables the limit.""" + try: + from appModules.line import _IMAGE_DESCRIPTION_DEFAULT_MAX_TOKENS + + return _IMAGE_DESCRIPTION_DEFAULT_MAX_TOKENS + except Exception: + log.debug("LINE: cannot load default image max tokens", exc_info=True) return 700 def _loadCurrentPrompt(self): @@ -559,16 +621,18 @@ def onSave(self): exc_info=True, ) - # Image description max output tokens + # Image description max output tokens: ``None`` means no limit (file cleared). try: from appModules.line import ( - _IMAGE_DESCRIPTION_DEFAULT_MAX_TOKENS, getUserImageMaxTokens, setUserImageMaxTokens, ) - newMaxTokens = int(self._maxTokensSpin.GetValue()) - currentMaxTokens = getUserImageMaxTokens() or _IMAGE_DESCRIPTION_DEFAULT_MAX_TOKENS + if self._limitTokensCheck.GetValue(): + newMaxTokens = int(self._maxTokensSpin.GetValue()) + else: + newMaxTokens = None + currentMaxTokens = getUserImageMaxTokens() if newMaxTokens != currentMaxTokens: if not setUserImageMaxTokens(newMaxTokens): gui.messageBox( From de04ecaa5f46df4c10189fcde116bc76bbd4f432 Mon Sep 17 00:00:00 2001 From: Keyang556 <65295310+keyang556@users.noreply.github.com> Date: Thu, 14 May 2026 13:04:50 +0800 Subject: [PATCH 2/4] Fix ruff format issues Co-Authored-By: Claude Sonnet 4.6 --- addon/appModules/line.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/addon/appModules/line.py b/addon/appModules/line.py index 2b5debe..a1ced90 100644 --- a/addon/appModules/line.py +++ b/addon/appModules/line.py @@ -2059,9 +2059,7 @@ def _restoreFocusToElement(element, expectedRuntimeId=None): ) # Vision-capable models commonly available on Ollama Cloud. Users can pick # whichever they have access to; unavailable IDs surface as an HTTP error. -_IMAGE_DESCRIPTION_OLLAMA_AVAILABLE_MODELS = ( - "gemma4:31b-cloud", -) +_IMAGE_DESCRIPTION_OLLAMA_AVAILABLE_MODELS = ("gemma4:31b-cloud",) # Vision-capable models exposed for NVIDIA NIM. Users can pick whichever they have # access to; unavailable IDs surface as an HTTP error. _IMAGE_DESCRIPTION_NVIDIA_AVAILABLE_MODELS = ( From 8578adeb802ab1be48d3e5fa1d53e3d457768463 Mon Sep 17 00:00:00 2001 From: Keyang556 <65295310+keyang556@users.noreply.github.com> Date: Thu, 14 May 2026 13:11:53 +0800 Subject: [PATCH 3/4] Address review comments: sizer.Replace guard and rename _userMaxTokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Check sizer.Replace() return value; destroy orphaned newCtrl and bail if the replacement fails, so the existing field is never lost. - Rename _userMaxTokens -> userMaxTokens in all three API backends (Google, NVIDIA, Pollinations) — underscore prefix implies module-private in Python convention, not appropriate for a local variable. Co-Authored-By: Claude Sonnet 4.6 --- addon/appModules/line.py | 18 +++++++++--------- addon/globalPlugins/lineDesktopHelper.py | 5 ++++- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/addon/appModules/line.py b/addon/appModules/line.py index a1ced90..eadb569 100644 --- a/addon/appModules/line.py +++ b/addon/appModules/line.py @@ -3178,9 +3178,9 @@ def _callGoogleImageDescriptionApi(contents, timeout=30.0): key=apiKey, ) body = {"contents": contents} - _userMaxTokens = getUserImageMaxTokens() - if _userMaxTokens is not None: - body["generationConfig"] = {"maxOutputTokens": _userMaxTokens} + userMaxTokens = getUserImageMaxTokens() + if userMaxTokens is not None: + body["generationConfig"] = {"maxOutputTokens": userMaxTokens} req = urllib.request.Request( url, data=json.dumps(body).encode("utf-8"), @@ -3381,9 +3381,9 @@ def _callNvidiaImageDescriptionApi(contents, timeout=60.0): "messages": messages, "stream": False, } - _userMaxTokens = getUserImageMaxTokens() - if _userMaxTokens is not None: - body["max_tokens"] = _userMaxTokens + userMaxTokens = getUserImageMaxTokens() + if userMaxTokens is not None: + body["max_tokens"] = userMaxTokens req = urllib.request.Request( _IMAGE_DESCRIPTION_NVIDIA_ENDPOINT, data=json.dumps(body).encode("utf-8"), @@ -3493,9 +3493,9 @@ def _callPollinationsImageDescriptionApi(contents, timeout=60.0): "messages": messages, "stream": False, } - _userMaxTokens = getUserImageMaxTokens() - if _userMaxTokens is not None: - body["max_tokens"] = _userMaxTokens + userMaxTokens = getUserImageMaxTokens() + if userMaxTokens is not None: + body["max_tokens"] = userMaxTokens headers = { "Content-Type": "application/json", "Accept": "application/json", diff --git a/addon/globalPlugins/lineDesktopHelper.py b/addon/globalPlugins/lineDesktopHelper.py index 55c5ad0..8cb10ad 100644 --- a/addon/globalPlugins/lineDesktopHelper.py +++ b/addon/globalPlugins/lineDesktopHelper.py @@ -216,7 +216,10 @@ def _onShowApiKeyChange(self, evt): newCtrl.SetInsertionPoint(insertionPoint) except Exception: pass - sizer.Replace(self._apiKeyText, newCtrl) + if not sizer.Replace(self._apiKeyText, newCtrl): + log.warning("LINE: sizer.Replace failed when toggling API-key visibility") + newCtrl.Destroy() + return self._apiKeyText.Destroy() self._apiKeyText = newCtrl self.Layout() From 7774ca1f0804a357f60c2f0ad528ffabb988ed59 Mon Sep 17 00:00:00 2001 From: Keyang556 <65295310+keyang556@users.noreply.github.com> Date: Thu, 14 May 2026 13:16:45 +0800 Subject: [PATCH 4/4] Fix API key show/hide: use EM_SETPASSWORDCHAR instead of sizer.Replace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sizer.Replace approach caused the field to move and lose its label. Replace with Win32 EM_SETPASSWORDCHAR message: wParam=0 shows plain text, wParam=ord('*') re-enables masking. The TextCtrl stays in its original position and retains its label — no sizer manipulation needed. Co-Authored-By: Claude Sonnet 4.6 --- addon/globalPlugins/lineDesktopHelper.py | 38 +++++++----------------- 1 file changed, 10 insertions(+), 28 deletions(-) diff --git a/addon/globalPlugins/lineDesktopHelper.py b/addon/globalPlugins/lineDesktopHelper.py index 8cb10ad..784fba7 100644 --- a/addon/globalPlugins/lineDesktopHelper.py +++ b/addon/globalPlugins/lineDesktopHelper.py @@ -133,8 +133,8 @@ def makeSettings(self, settingsSizer): # Translators: Text field label for the image-description API key apiLabel = _("圖片描述 API Key,留空則使用預設金鑰 (&I)") - # Stored under password mask by default to avoid shoulder-surfing; the - # "show" checkbox below recreates the control without the mask on demand. + # wx.TE_PASSWORD creates a Win32 Edit with ES_PASSWORD; the "show" + # checkbox below sends EM_SETPASSWORDCHAR to toggle masking in place. self._apiKeyText = sHelper.addLabeledControl( apiLabel, wx.TextCtrl, @@ -194,37 +194,19 @@ def _onLimitTokensChange(self, evt): self._maxTokensSpin.Enable(self._limitTokensCheck.GetValue()) def _onShowApiKeyChange(self, evt): - """Recreate the API-key field so its TE_PASSWORD style can flip. + """Toggle password masking on the API-key field in place. - wxPython exposes no portable way to toggle ``wx.TE_PASSWORD`` on an - existing control, so we swap in a fresh ``wx.TextCtrl`` and let the - surrounding horizontal sizer re-adopt it with the same flags. + Uses the Win32 ``EM_SETPASSWORDCHAR`` message so the TextCtrl never + moves or loses its label — no sizer manipulation required. """ if not getattr(self, "_apiKeyText", None): return - sizer = self._apiKeyText.GetContainingSizer() - if sizer is None: - return - currentValue = self._apiKeyText.GetValue() - insertionPoint = self._apiKeyText.GetInsertionPoint() - hadFocus = self._apiKeyText.HasFocus() showPlain = bool(self._showApiKeyCheck.GetValue()) - style = 0 if showPlain else wx.TE_PASSWORD - newCtrl = wx.TextCtrl(self, style=style) - newCtrl.ChangeValue(currentValue) - try: - newCtrl.SetInsertionPoint(insertionPoint) - except Exception: - pass - if not sizer.Replace(self._apiKeyText, newCtrl): - log.warning("LINE: sizer.Replace failed when toggling API-key visibility") - newCtrl.Destroy() - return - self._apiKeyText.Destroy() - self._apiKeyText = newCtrl - self.Layout() - if hadFocus: - newCtrl.SetFocus() + _EM_SETPASSWORDCHAR = 0x00CC + hwnd = self._apiKeyText.GetHandle() + # wParam 0 = show plain text; ord('*') = mask with asterisks + ctypes.windll.user32.SendMessageW(hwnd, _EM_SETPASSWORDCHAR, 0 if showPlain else ord("*"), 0) + self._apiKeyText.Refresh() def _currentSelectedProviderId(self): """Return the provider ID matching the dropdown selection, or None."""