Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 33 additions & 22 deletions addon/appModules/line.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand All @@ -2082,13 +2080,33 @@ 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 = {
"claude-fast": "Claude Haiku 4.5",
"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}"
Expand Down Expand Up @@ -2125,7 +2143,6 @@ def _restoreFocusToElement(element, expectedRuntimeId=None):
_cachedEffectiveOllamaModel = _NOT_COMPUTED
_cachedEffectiveNvidiaModel = _NOT_COMPUTED
_cachedEffectivePollinationsModel = _NOT_COMPUTED
_cachedEffectiveImageMaxTokens = _NOT_COMPUTED


def _deriveImageApiKeyMaterial(salt, length):
Expand Down Expand Up @@ -2957,16 +2974,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)
Expand All @@ -2978,21 +2994,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 = (
"記事本",
Expand Down Expand Up @@ -3171,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"),
Expand Down Expand Up @@ -3372,9 +3379,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"),
Expand Down Expand Up @@ -3482,9 +3491,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",
Expand Down
73 changes: 61 additions & 12 deletions addon/globalPlugins/lineDesktopHelper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# 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,
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)")
Expand All @@ -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()
Expand All @@ -161,14 +180,34 @@ 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):
"""Toggle password masking on the API-key field in place.

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
showPlain = bool(self._showApiKeyCheck.GetValue())
_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."""
idx = self._providerChoice.GetSelection() if hasattr(self, "_providerChoice") else wx.NOT_FOUND
Expand Down Expand Up @@ -366,16 +405,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):
Expand Down Expand Up @@ -559,16 +606,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(
Expand Down