Compare commits

...

9 Commits

Author SHA1 Message Date
dgtlmoon 4f5674f42b Notifications - raw_diff token was missing 2026-05-22 09:21:51 +02:00
dgtlmoon 701833b6ed UI - LLM - Flag LLM_FEATURES_DISABLED to disable all LLM from the UI/system (#4171)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-05-21 12:51:56 +02:00
dgtlmoon 43bb196aa4 UI - Preview problem fix for extract_text/ignore_text #4138 (#4169)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-05-20 13:57:17 +02:00
dgtlmoon d04862d2fa 0.55.5
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-05-19 19:05:53 +02:00
dgtlmoon 9d9a58e763 LLM - Master on/off switch (enable/disable) (#4162) 2026-05-19 19:05:13 +02:00
dgtlmoon 649c153bf4 Notifications - Fix 'str' object is not callable when {{ diff(...) }} callable tokens are used with HTML/htmlcolor output (#4161)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-05-19 18:09:28 +02:00
Manuel Pérez be3ba3bca3 Fix Spanish translations for 'Changed' and 'Last Changed' (#4160) 2026-05-19 17:23:15 +02:00
dgtlmoon d42bb74918 0.55.4 2026-05-19 11:38:31 +02:00
dgtlmoon 624dee60d5 API Security - Watch GET history snapshot - Should return text/plain mimetype so it cant be accidently executed in the browser (#4158) 2026-05-19 11:36:42 +02:00
39 changed files with 576 additions and 34 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
# Semver means never use .01, or 00. Should be .1.
__version__ = '0.55.3'
__version__ = '0.55.5'
from changedetectionio.strtobool import strtobool
from json.decoder import JSONDecodeError
+22 -1
View File
@@ -1,4 +1,5 @@
import os
import re
import threading
from changedetectionio.validate_url import is_safe_valid_url
@@ -278,8 +279,28 @@ class WatchSingleHistory(Resource):
if request.args.get('html'):
content = watch.get_fetched_html(timestamp)
if content:
# XSS mitigation (GHSA-cgj8-g98g-4p9x): this is an API endpoint, not a
# browser-rendered view. The bytes ARE HTML (that's what the caller asked
# for) but a programmatic client doesn't need text/html — and serving
# text/html lets attacker-planted <script> in a monitored site execute
# in our origin if someone opens the URL in a browser.
#
# text/plain + explicit utf-8 + nosniff = browser shows inert text,
# sniffing can't re-classify it as HTML, an absent charset can't be
# auto-detected as UTF-7 (an alternative XSS vector). API clients
# still get the raw bytes — they don't care about Content-Type.
response = make_response(content, 200)
response.mimetype = "text/html"
response.headers['Content-Type'] = 'text/plain; charset=utf-8'
response.headers['X-Content-Type-Options'] = 'nosniff'
# Include the timestamp in the download name so downloading multiple
# snapshots doesn't collide. No extension — the stored bytes are
# "whatever the fetcher captured" (HTML, JSON, XML, text…), so
# claiming .html on the download would be a false content-type label
# for non-HTML watches. The user/curl can rename if needed.
# Strip to safe filename chars (timestamp is already validated as a
# watch.history key — this is defense in depth against header injection).
safe_ts = re.sub(r'[^0-9A-Za-z_-]', '', str(timestamp))[:32] or 'snapshot'
response.headers['Content-Disposition'] = f'attachment; filename="snapshot-{safe_ts}"'
else:
response = make_response("No content found", 404)
response.mimetype = "text/plain"
@@ -14,8 +14,10 @@ from changedetectionio.auth_decorator import login_optionally_required
def construct_blueprint(datastore: ChangeDetectionStore):
from changedetectionio.blueprint.settings.llm import construct_llm_blueprint
from changedetectionio.llm.evaluator import is_llm_features_disabled
settings_blueprint = Blueprint('settings', __name__, template_folder="templates")
settings_blueprint.register_blueprint(construct_llm_blueprint(datastore), url_prefix='/llm')
if not is_llm_features_disabled():
settings_blueprint.register_blueprint(construct_llm_blueprint(datastore), url_prefix='/llm')
@settings_blueprint.route("", methods=['GET', "POST"])
@login_optionally_required
@@ -39,6 +41,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
'llm_provider_kind': _stored_llm.get('provider_kind', ''),
'llm_local_token_multiplier': _stored_llm.get('local_token_multiplier', 5),
'llm_change_summary_default': datastore.data['settings']['application'].get('llm_change_summary_default', ''),
'llm_enabled': datastore.data['settings']['application'].get('llm_enabled', True),
'llm_override_diff_with_summary': datastore.data['settings']['application'].get('llm_override_diff_with_summary', True),
'llm_restock_use_fallback_extract': datastore.data['settings']['application'].get('llm_restock_use_fallback_extract', True),
'llm_debug': datastore.data['settings']['application'].get('llm_debug', False),
@@ -120,6 +123,9 @@ def construct_blueprint(datastore: ChangeDetectionStore):
datastore.data['settings']['application']['llm_change_summary_default'] = (
llm_data.get('llm_change_summary_default') or ''
).strip()
datastore.data['settings']['application']['llm_enabled'] = (
bool(llm_data.get('llm_enabled', True))
)
datastore.data['settings']['application']['llm_override_diff_with_summary'] = (
bool(llm_data.get('llm_override_diff_with_summary', True))
)
@@ -34,7 +34,9 @@
<li class="tab"><a href="#plugin-{{ tab.plugin_id }}">{{ tab.tab_label }}</a></li>
{% endfor %}
{% endif %}
{% if not llm_features_disabled %}
<li class="tab"><a href="#ai">{{ _('AI / LLM') }}</a></li>
{% endif %}
<li class="tab"><a href="#info">{{ _('Info') }}</a></li>
</ul>
</div>
@@ -394,7 +396,9 @@ nav
</div>
{% endfor %}
{% endif %}
{% if not llm_features_disabled %}
{% include 'settings_llm_tab.html' %}
{% endif %}
<div class="tab-pane-inner" id="info">
<p><strong>{{ _('Uptime:') }}</strong> {{ uptime_seconds|format_duration }}</p>
<p><strong>{{ _('Python version:') }}</strong> {{ python_version }}</p>
@@ -69,6 +69,17 @@
{% call stab_pane('provider') %}
<p class="stab-section-title">{{ _('AI Provider') }}</p>
<div class="pure-control-group">
<label></label>
{{ form.llm.form.llm_enabled() }}
<label for="{{ form.llm.form.llm_enabled.id }}" style="display:inline; font-weight:normal;">
{{ form.llm.form.llm_enabled.label.text }}
</label>
<span class="pure-form-message-inline">
{{ _('Master switch — when off, all AI lookups are skipped even if a provider is configured below.') }}
</span>
</div>
{% if not llm_env_configured and not (llm_config and llm_config.get('model')) %}
<div class="stab-overview-disclaimer">
<div class="stab-disclaimer-icon"></div>
@@ -57,7 +57,9 @@
{% if capabilities.supports_visual_selector %}
<li class="tab"><a id="visualselector-tab" href="#visualselector">{{ _('Visual Filter Selector') }}</a></li>
{% endif %}
{% if not llm_features_disabled %}
<li class="tab"><a href="#ai-llm">{{ _('AI / LLM') }}</a></li>
{% endif %}
{% if capabilities.supports_text_filters_and_triggers %}
<li class="tab" id="filters-and-triggers-tab"><a href="#filters-and-triggers">{{ _('Filters & Triggers') }}</a></li>
<li class="tab" id="conditions-tab"><a href="#conditions">{{ _('Conditions') }}</a></li>
@@ -321,9 +323,11 @@ Math: {{ 1 + 1 }}") }}
</div>
</div>
</div>
{% if not llm_features_disabled %}
<div class="tab-pane-inner" id="ai-llm">
{% include "edit/include_llm_intent.html" %}
</div>
{% endif %}
<div class="tab-pane-inner" id="filters-and-triggers">
<span id="activate-text-preview" class="pure-button pure-button-primary button-xsmall">{{ _('Activate preview') }}</span>
@@ -503,7 +507,7 @@ Math: {{ 1 + 1 }}") }}
<td>{{ _('Server type reply') }}</td>
<td>{{ watch.get('remote_server_reply') }}</td>
</tr>
{% if settings_application.get('llm', {}).get('model') %}
{% if not llm_features_disabled and settings_application.get('llm', {}).get('model') %}
<tr>
<td>{{ _('AI tokens (last check)') }}</td>
<td>{{ "{:,}".format(watch.get('llm_last_tokens_used') or 0) }}</td>
+5
View File
@@ -522,6 +522,11 @@ def changedetection_app(config=None, datastore_o=None):
available_languages=available_languages
)
@app.context_processor
def inject_llm_features_disabled():
from changedetectionio.llm.evaluator import is_llm_features_disabled
return dict(llm_features_disabled=is_llm_features_disabled())
# Set up a request hook to check authentication for all routes
@app.before_request
def check_authentication():
+8
View File
@@ -1193,6 +1193,14 @@ class globalSettingsLLMForm(Form):
"style": "width: 10em;",
},
)
# Master on/off switch for ALL LLM lookups at runtime. When False, every entry point
# in evaluator.py (and the restock fallback) short-circuits with a logger.debug
# message — even if a provider+model is still configured. Saved config and the
# "configured" badge remain visible so the user can toggle back on without re-entering.
llm_enabled = BooleanField(
_l('Enable AI / LLM features'),
default=True,
)
llm_override_diff_with_summary = BooleanField(
_l('Replace {{diff}} notification token with AI summary'),
default=True,
+37 -4
View File
@@ -20,6 +20,8 @@ from dataclasses import dataclass
from datetime import datetime, timezone
from loguru import logger
from changedetectionio.strtobool import strtobool
from . import client as llm_client
from .prompt_builder import (
build_change_summary_prompt, build_change_summary_system_prompt,
@@ -31,6 +33,11 @@ from .response_parser import parse_eval_response, parse_preview_response, parse_
_DEFAULT_MAX_INPUT_CHARS = 100_000
def is_llm_features_disabled() -> bool:
"""True when the LLM_FEATURES_DISABLED env var is set to a truthy value."""
return bool(strtobool(os.getenv('LLM_FEATURES_DISABLED', '')))
def _get_max_input_chars(datastore) -> int:
"""Max input characters to send to the LLM. Resolution: env var → datastore → 100,000.
Always returns at least 1 unlimited is not permitted.
@@ -207,6 +214,8 @@ def get_llm_config(datastore) -> dict | None:
1. Environment variables: LLM_MODEL, LLM_API_KEY, LLM_API_BASE
2. Datastore settings (set via UI)
"""
if is_llm_features_disabled():
return None
# 1. Environment variable override
env_model = os.getenv('LLM_MODEL', '').strip()
if env_model:
@@ -225,9 +234,33 @@ def get_llm_config(datastore) -> dict | None:
def llm_configured_via_env() -> bool:
"""True when LLM config comes from environment variables, not the UI."""
if is_llm_features_disabled():
return False
return bool(os.getenv('LLM_MODEL', '').strip())
def _runtime_llm_config(datastore) -> dict | None:
"""
Runtime gate used by every LLM entry point in this module (and the restock
fallback). Returns the resolved config dict only when both:
- the master 'llm_enabled' toggle is on (default True)
- a provider+model is actually configured
When the toggle is off but a config exists, logs a debug message and returns
None so callers fall through their existing "not configured" early-return path.
The settings UI deliberately still calls get_llm_config() directly so the
"AI / LLM configured: ..." badge keeps showing the saved provider even while
the toggle is off.
"""
cfg = get_llm_config(datastore)
if not bool(datastore.data['settings']['application'].get('llm_enabled', True)):
if cfg:
logger.debug("LLM features disabled via settings (llm_enabled=False) — skipping LLM lookup")
return None
return cfg
# ---------------------------------------------------------------------------
# Global monthly token budget
# ---------------------------------------------------------------------------
@@ -379,7 +412,7 @@ def run_setup(watch, datastore, snapshot_text: str) -> None:
Stores result in watch['llm_prefilter'] (str selector or None).
Called once when intent is first set, and again if pre-filter returns zero matches.
"""
cfg = get_llm_config(datastore)
cfg = _runtime_llm_config(datastore)
if not cfg:
return
@@ -509,7 +542,7 @@ def summarise_change(watch, datastore, diff: str, current_snapshot: str = '') ->
The result replaces {{ diff }} in notifications so the user gets a
readable description instead of raw +/- diff lines.
"""
cfg = get_llm_config(datastore)
cfg = _runtime_llm_config(datastore)
if not cfg:
return ''
@@ -597,7 +630,7 @@ def preview_extract(watch, datastore, content: str) -> dict | None:
Returns {'found': bool, 'answer': str} or None if LLM not configured / no intent.
"""
cfg = get_llm_config(datastore)
cfg = _runtime_llm_config(datastore)
if not cfg:
return None
@@ -648,7 +681,7 @@ def evaluate_change(watch, datastore, diff: str, current_snapshot: str = '') ->
Results are cached by (intent, diff) hash each unique diff is evaluated exactly once.
"""
cfg = get_llm_config(datastore)
cfg = _runtime_llm_config(datastore)
if not cfg:
return None
+1
View File
@@ -71,6 +71,7 @@ class model(dict):
'shared_diff_access': False,
'strip_ignored_lines': False,
'tags': None, # Initialized in __init__ with real datastore_path
'llm_enabled': True,
'llm_thinking_budget': LLM_DEFAULT_THINKING_BUDGET,
'llm_max_summary_tokens': LLM_DEFAULT_MAX_SUMMARY_TOKENS,
'webdriver_delay': None , # Extra delay in seconds before extracting text
+15 -2
View File
@@ -364,6 +364,10 @@ def process_notification(n_object: NotificationContextData, datastore):
# Should always be false for 'text' mode or its too hard to read
# But otherwise, this could be some setting
word_diff=False if requested_output_format_original == 'text' else True,
# HTML-format notifications must escape diff content (GHSA-q8xq-qg4x-wphg).
# FormattableDiff/Extract escape internally so {{ diff(...) }} stays callable —
# the post-Jinja escape loop below would otherwise convert them to plain str.
escape_output='html' in requested_output_format,
)
)
@@ -394,10 +398,19 @@ def process_notification(n_object: NotificationContextData, datastore):
# so they survive escape and are still replaced with <span> tags later.
if 'html' in requested_output_format:
from markupsafe import escape as html_escape
from changedetectionio.notification_service import FormattableDiff, FormattableExtract
_page_content_keys = {'raw_diff', 'current_snapshot', 'prev_snapshot', 'triggered_text'}
for key in [k for k in notification_parameters if k.startswith('diff') or k in _page_content_keys]:
if notification_parameters.get(key):
notification_parameters[key] = str(html_escape(str(notification_parameters[key])))
value = notification_parameters.get(key)
if not value:
continue
# FormattableDiff / FormattableExtract are callable str subclasses — {{ diff(lines=5) }}
# etc. relies on __call__. Wrapping them with str(html_escape(...)) here would lose
# __call__ and break those tokens. They escape internally via escape_output=True
# (set by add_rendered_diff_to_notification_vars above) for both __str__ and __call__.
if isinstance(value, (FormattableDiff, FormattableExtract)):
continue
notification_parameters[key] = str(html_escape(str(value)))
with (apprise.LogCapture(level=apprise.logging.DEBUG) as logs):
for url in n_object['notification_urls']:
+27 -5
View File
@@ -99,7 +99,7 @@ class FormattableExtract(str):
Multiple changed fragments are joined with newlines.
Being a str subclass means it is natively JSON serializable.
"""
def __new__(cls, prev_snapshot, current_snapshot, extract_fn):
def __new__(cls, prev_snapshot, current_snapshot, extract_fn, escape_output=False):
if prev_snapshot or current_snapshot:
from changedetectionio import diff as diff_module
# word_diff=True is required — placemarker extraction regexes only exist in word-diff output
@@ -107,6 +107,12 @@ class FormattableExtract(str):
extracted = extract_fn(raw)
else:
extracted = ''
if escape_output and extracted:
# Placemarkers (@removed_PLACEMARKER_OPEN etc) contain no HTML chars,
# so html_escape leaves them intact — they still get swapped to <span>
# tags later by apply_service_tweaks. See GHSA-q8xq-qg4x-wphg.
from markupsafe import escape as html_escape
extracted = str(html_escape(extracted))
instance = super().__new__(cls, extracted)
return instance
@@ -128,16 +134,23 @@ class FormattableDiff(str):
Being a str subclass means it is natively JSON serializable.
"""
def __new__(cls, prev_snapshot, current_snapshot, **base_kwargs):
def __new__(cls, prev_snapshot, current_snapshot, escape_output=False, **base_kwargs):
if prev_snapshot or current_snapshot:
from changedetectionio import diff as diff_module
rendered = diff_module.render_diff(prev_snapshot, current_snapshot, **base_kwargs)
else:
rendered = ''
if escape_output and rendered:
# Placemarkers (@removed_PLACEMARKER_OPEN etc) contain no HTML chars,
# so html_escape leaves them intact — they still get swapped to <span>
# tags later by apply_service_tweaks. See GHSA-q8xq-qg4x-wphg.
from markupsafe import escape as html_escape
rendered = str(html_escape(rendered))
instance = super().__new__(cls, rendered)
instance._prev = prev_snapshot
instance._current = current_snapshot
instance._base_kwargs = base_kwargs
instance._escape_output = escape_output
return instance
def __call__(self, lines=None, added_only=False, removed_only=False, context=0,
@@ -163,6 +176,10 @@ class FormattableDiff(str):
if lines is not None:
result = '\n'.join(result.splitlines()[:int(lines)])
if self._escape_output and result:
from markupsafe import escape as html_escape
result = str(html_escape(result))
return result
@@ -187,6 +204,8 @@ class NotificationContextData(dict):
'diff_changed_from': FormattableExtract('', '', extract_fn=lambda x: x),
'diff_changed_to': FormattableExtract('', '', extract_fn=lambda x: x),
'diff_url': None,
# Always the raw +/- diff regardless of LLM summary override (populated in handler.py from {{diff}})
'raw_diff': FormattableDiff('', ''),
'markup_text_links_to_html_links': False, # If automatic conversion of plaintext to HTML should happen
'notification_timestamp': time.time(),
'prev_snapshot': None,
@@ -236,7 +255,7 @@ class NotificationContextData(dict):
super().__setitem__(key, value)
def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snapshot:str, current_snapshot:str, word_diff:bool):
def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snapshot:str, current_snapshot:str, word_diff:bool, escape_output:bool=False):
"""
Efficiently renders only the diff placeholders that are actually used in the notification text.
@@ -249,6 +268,9 @@ def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snap
prev_snapshot: Previous version of content for diff comparison
current_snapshot: Current version of content for diff comparison
word_diff: Whether to use word-level (True) or line-level (False) diffing
escape_output: If True, the rendered diff output is HTML-escaped. Used for HTML-format
notifications so attacker-controlled page content can't inject live markup.
Both the cached str representation and the result of {{ diff(...) }} calls are escaped.
Returns:
dict: Only the diff placeholders that were found in notification_scan_text, with rendered content
@@ -287,10 +309,10 @@ def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snap
if not re.search(pattern, notification_scan_text, re.IGNORECASE):
continue
if key in diff_specs:
ret[key] = FormattableDiff(prev_snapshot, current_snapshot, **diff_specs[key])
ret[key] = FormattableDiff(prev_snapshot, current_snapshot, escape_output=escape_output, **diff_specs[key])
rendered_count += 1
elif key in extract_specs:
ret[key] = FormattableExtract(prev_snapshot, current_snapshot, extract_fn=extract_specs[key])
ret[key] = FormattableExtract(prev_snapshot, current_snapshot, extract_fn=extract_specs[key], escape_output=escape_output)
rendered_count += 1
if rendered_count:
@@ -203,15 +203,17 @@ def get_itemprop_availability_override(content, fetcher_name, fetcher_instance,
return None
try:
from changedetectionio.llm.evaluator import get_llm_config, accumulate_global_tokens
from changedetectionio.llm.evaluator import _runtime_llm_config, accumulate_global_tokens
from changedetectionio.llm import client as llm_client
except ImportError as e:
logger.debug(f"LLM restock fallback: LLM libraries not available ({e})")
return None
llm_cfg = get_llm_config(datastore)
# _runtime_llm_config returns None (with a debug log) when the master 'llm_enabled'
# toggle is off, so this path is gated for free.
llm_cfg = _runtime_llm_config(datastore)
if not llm_cfg or not llm_cfg.get('model'):
logger.debug("LLM restock fallback: no LLM model configured, skipping")
logger.debug("LLM restock fallback: no LLM model configured or LLM disabled, skipping")
return None
text_content = _strip_html(content) if content else ''
@@ -35,6 +35,50 @@ def _task(watch, update_handler):
return text_after_filter
def _compute_ignore_line_numbers_for_preview(text_pre_extract, ignore_patterns, extract_patterns):
"""1-indexed output line numbers in the post-extract display that correspond
to input lines matching ignore_text patterns.
Needed because extract_text (#4138) transforms line content — e.g. "0.54.10"
becomes ".54.10" so a substring match for "0.54.10" against the post-extract
text fails and the preview UI can no longer mark the line as ignored. We find
the ignored line numbers in the pre-extract text and replay extract_by_regex
line-by-line to map them forward.
"""
from changedetectionio import html_tools
from changedetectionio.processors.text_json_diff.processor import ContentTransformer
if not text_pre_extract or not ignore_patterns:
return []
ignored_input_lines = set(
html_tools.strip_ignore_text(
content=text_pre_extract,
wordlist=ignore_patterns,
mode='line numbers'
)
)
if not ignored_input_lines:
return []
if not extract_patterns:
return sorted(ignored_input_lines)
# Replay extract_by_regex per-line. Each emitted match ends with exactly one
# '\n', so counting newlines tells us how many output lines this input produced.
output_line_counter = 0
result = []
for input_idx, line in enumerate(text_pre_extract.splitlines()):
is_ignored = (input_idx + 1) in ignored_input_lines
matches_in_line = ContentTransformer.extract_by_regex(line, extract_patterns).count('\n')
for _ in range(matches_in_line):
output_line_counter += 1
if is_ignored:
result.append(output_line_counter)
return result
def prepare_filter_prevew(datastore, watch_uuid, form_data):
'''Used by @app.route("/edit/<uuid_str:uuid>/preview-rendered", methods=['POST'])'''
from changedetectionio import forms, html_tools
@@ -50,6 +94,7 @@ def prepare_filter_prevew(datastore, watch_uuid, form_data):
text_after_filter = ''
text_before_filter = ''
text_pre_extract = ''
trigger_line_numbers = []
ignore_line_numbers = []
blocked_line_numbers = []
@@ -89,15 +134,22 @@ def prepare_filter_prevew(datastore, watch_uuid, form_data):
update_handler.fetcher.content = str(decompressed_data) # str() because playwright/puppeteer/requests return string
update_handler.fetcher.headers['content-type'] = tmp_watch.get('content-type')
# Process our watch with filters and the HTML from disk, and also a blank watch with no filters but also with the same HTML from disk
# Process our watch with filters and the HTML from disk, and also a blank watch with no filters but also with the same HTML from disk.
# The third task runs with extract_text cleared so we can compute ignore_line_numbers
# against the pre-extract text (extract_text transforms lines so post-extract substring
# matching for ignore patterns would otherwise fail — see #4138 follow-up).
# Do this as parallel threads (not processes) to avoid pickle issues with Lock objects
tmp_watch_no_extract = deepcopy(tmp_watch)
tmp_watch_no_extract['extract_text'] = []
try:
with ThreadPoolExecutor(max_workers=2) as executor:
with ThreadPoolExecutor(max_workers=3) as executor:
future1 = executor.submit(_task, tmp_watch, update_handler)
future2 = executor.submit(_task, blank_watch_no_filters, update_handler)
future3 = executor.submit(_task, tmp_watch_no_extract, update_handler)
text_after_filter = future1.result()
text_before_filter = future2.result()
text_pre_extract = future3.result()
except Exception as e:
x=1
@@ -111,10 +163,11 @@ def prepare_filter_prevew(datastore, watch_uuid, form_data):
try:
text_to_ignore = tmp_watch.get('ignore_text', []) + datastore.data['settings']['application'].get('global_ignore_text', [])
ignore_line_numbers = html_tools.strip_ignore_text(content=text_after_filter,
wordlist=text_to_ignore,
mode='line numbers'
)
ignore_line_numbers = _compute_ignore_line_numbers_for_preview(
text_pre_extract=text_pre_extract,
ignore_patterns=text_to_ignore,
extract_patterns=tmp_watch.get('extract_text', [])
)
except Exception as e:
text_before_filter = f"Error: {str(e)}"
@@ -9,6 +9,10 @@ function request_textpreview_update() {
$('textarea:visible, input:visible').each(function () {
const $element = $(this); // Cache the jQuery object for the current element
const name = $element.attr('name'); // Get the name attribute of the element
// Radios share a name across multiple inputs; .val() returns the value
// attribute regardless of checked state, so iterating would let the last
// unchecked radio overwrite the user's actual selection. Skip unchecked.
if ($element.is(':radio') && !$element.is(':checked')) return;
data[name] = $element.is(':checkbox') ? ($element.is(':checked') ? $element.val() : false) : $element.val();
});
@@ -112,7 +112,7 @@
<td><code>{{ '{{triggered_text}}' }}</code></td>
<td>{{ _('Text that tripped the trigger from filters') }}</td>
</tr>
{% if settings_application and settings_application.get('llm', {}).get('model') %}
{% if not llm_features_disabled and settings_application and settings_application.get('llm', {}).get('model') %}
<tr>
<td><code>{{ '{{diff}}' }}</code> <small style="opacity:0.6">{{ _('(upgraded)') }}</small></td>
<td>{{ _('When AI Change Summary is configured, contains the AI-generated description instead of the raw diff. Falls back to raw diff when not configured.') }}</td>
+2
View File
@@ -281,6 +281,7 @@
</div>
</dialog>
{% if not llm_features_disabled %}
<!-- LLM Not Configured Modal -->
<dialog id="llm-not-configured-modal" class="modal-dialog" aria-labelledby="llm-not-configured-modal-title">
<div class="modal-header">
@@ -294,6 +295,7 @@
<button type="button" class="pure-button" id="close-llm-not-configured-modal">{{ _('Close') }}</button>
</div>
</dialog>
{% endif %}
<!-- Search Modal -->
{% if current_user.is_authenticated or not has_password %}
+2
View File
@@ -37,10 +37,12 @@
</li>
{% endif %}
<li class="pure-menu-item menu-collapsible" id="inline-menu-extras-group">
{% if not llm_features_disabled %}
<button class="toggle-button toggle-ai-mode" type="button" title="{{ _('Toggle AI Mode') }}" data-llm-configured="{{ 'true' if llm_configured else 'false' }}" data-llm-settings-url="{{ url_for('settings.settings_page') }}#ai">
<span class="visually-hidden">{{ _('Toggle AI mode') }}</span>
{% include "svgs/ai-mode-icon.svg" %}<span class="ai-mode-label">LLM</span>
</button>
{% endif %}
<button class="toggle-button toggle-light-mode " type="button" title="{{ _('Toggle Light/Dark Mode') }}">
<span class="visually-hidden">{{ _('Toggle light/dark mode') }}</span>
<span class="icon-light">
@@ -0,0 +1,62 @@
"""
Smoke test for the LLM_FEATURES_DISABLED env var.
The env var is intended to hide every LLM/AI surface (settings tab, edit tab,
base-template AI toggle/modal) for hosted deployments. This test renders the
three primary pages with the env var set and verifies that none of the
LLM-related markers leak through.
"""
from flask import url_for
def _llm_markers_absent(body: bytes, where: str = ''):
"""All of these strings appear in LLM UI surfaces — none should render."""
for marker in (b'AI / LLM', b'toggle-ai-mode', b'llm-not-configured-modal',
b'id="ai-llm"', b'#ai-llm', b'href="#ai"'):
if marker in body:
idx = body.find(marker)
context = body[max(0, idx - 80):idx + len(marker) + 80].decode('utf-8', 'replace')
raise AssertionError(f"[{where}] {marker!r} found in body, context: ...{context}...")
def test_llm_features_disabled_hides_ui(client, live_server, monkeypatch):
monkeypatch.setenv('LLM_FEATURES_DISABLED', 'true')
# Sanity: helper reports the env var is in effect
from changedetectionio.llm.evaluator import is_llm_features_disabled, get_llm_config
assert is_llm_features_disabled() is True
# get_llm_config() must return None so every `if llm_configured` template hides
datastore = client.application.config.get('DATASTORE')
assert get_llm_config(datastore) is None
# 1. Watch list (base.html + menu.html surface)
res = client.get(url_for('watchlist.index'))
assert res.status_code == 200
_llm_markers_absent(res.data, where='watchlist')
# 2. Settings page (should not have an AI / LLM tab or the LLM tab body)
res = client.get(url_for('settings.settings_page'))
assert res.status_code == 200
_llm_markers_absent(res.data, where='settings')
# 3. Edit page for a watch (should not have an AI / LLM tab or include_llm_intent body)
uuid = datastore.add_watch(url='http://example.com', extras={'title': 'Disabled LLM watch'})
res = client.get(url_for('ui.ui_edit.edit_page', uuid=uuid))
assert res.status_code == 200
_llm_markers_absent(res.data, where='edit')
# The watch-edit-only intent textarea should also be absent
assert b'name="llm_intent"' not in res.data
assert b'name="llm_change_summary"' not in res.data
def test_llm_features_enabled_by_default(client, live_server, monkeypatch):
"""When LLM_FEATURES_DISABLED is unset, the AI / LLM surfaces are still rendered."""
monkeypatch.delenv('LLM_FEATURES_DISABLED', raising=False)
from changedetectionio.llm.evaluator import is_llm_features_disabled
assert is_llm_features_disabled() is False
res = client.get(url_for('settings.settings_page'))
assert res.status_code == 200
# The AI / LLM settings tab anchor should be present when not disabled
assert b'href="#ai"' in res.data
+75 -1
View File
@@ -9,7 +9,7 @@ import json
import threading
import uuid as uuid_module
from flask import url_for
from .util import live_server_setup, wait_for_all_checks, delete_all_watches
from .util import live_server_setup, wait_for_all_checks, wait_for_watch_history, delete_all_watches
import os
@@ -653,6 +653,80 @@ def test_api_history_edge_cases(client, live_server, measure_memory_usage, datas
delete_all_watches(client)
def test_api_history_html_does_not_serve_as_text_html(client, live_server, measure_memory_usage, datastore_path):
"""
GHSA-cgj8-g98g-4p9x: GET /api/v1/watch/<uuid>/history/<timestamp>?html=true
must not serve the stored snapshot with Content-Type: text/html. The bytes
are an external site's HTML — if the response is labelled text/html, a
<script> the attacker planted on that site executes in our origin when an
operator opens the URL in a browser (stored XSS).
The fix is text/plain; charset=utf-8 + X-Content-Type-Options: nosniff so
browsers render inert text and can't sniff back to HTML/UTF-7. API clients
don't care about Content-Type and still receive the same bytes.
This test injects the snapshot directly via Watch.save_history_blob() and
save_last_fetched_html() so we exercise the API endpoint's response
shaping without depending on the live-fetch pipeline.
"""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
test_url = url_for('test_endpoint', _external=True)
res = client.post(
url_for("createwatch"),
data=json.dumps({"url": test_url}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
watch_uuid = res.json.get('uuid')
# Plant a payload that would execute if the response were rendered as HTML.
malicious_html = (
"<html><body>"
"<script>window.__CD_XSS_PROBE = 1</script>"
"<img src=x onerror=\"window.__CD_XSS_PROBE = 1\">"
"</body></html>"
)
ts = '1700000000'
watch = live_server.app.config['DATASTORE'].data['watching'][watch_uuid]
watch.save_history_blob(contents=malicious_html, timestamp=ts, snapshot_id=ts)
watch.save_last_fetched_html(timestamp=ts, contents=malicious_html)
# The actual XSS-relevant assertion: how is the snapshot served?
res = client.get(
url_for("watchsinglehistory", uuid=watch_uuid, timestamp=ts) + '?html=true',
headers={'x-api-key': api_key},
)
assert res.status_code == 200, f"unexpected status {res.status_code}: {res.data!r}"
ctype = res.headers.get('Content-Type', '')
assert 'text/html' not in ctype, \
f"snapshot must not be served as text/html (got {ctype!r}) — see GHSA-cgj8-g98g-4p9x"
# Explicit utf-8 closes the UTF-7 sniffing bypass — without a charset, some
# browsers will auto-detect UTF-7 from byte patterns and a crafted snapshot
# can still execute via `+ADw-script+AD4-...`
assert 'charset=utf-8' in ctype.lower(), \
f"Content-Type must pin charset=utf-8 to defeat UTF-7 sniffing XSS (got {ctype!r})"
nosniff = res.headers.get('X-Content-Type-Options', '')
assert nosniff.lower() == 'nosniff', \
f"X-Content-Type-Options: nosniff required to defeat MIME-sniffing (got {nosniff!r})"
# Download filename should include the timestamp so multiple snapshots from
# the same watch don't overwrite each other on disk.
disp = res.headers.get('Content-Disposition', '')
assert 'attachment' in disp and ts in disp, \
f"Content-Disposition should be attachment + per-timestamp filename (got {disp!r})"
# API contract: the raw bytes must still be the original HTML — programmatic
# consumers depend on getting the stored snapshot back.
assert b'<script>' in res.data, \
"Response body must still contain the raw stored bytes (the API contract)"
# Cleanup
client.delete(url_for("watch", uuid=watch_uuid), headers={'x-api-key': api_key})
delete_all_watches(client)
def test_api_notification_edge_cases(client, live_server, measure_memory_usage, datastore_path):
"""
Test notification configuration edge cases.
@@ -77,3 +77,82 @@ def test_content_filter_live_preview(client, live_server, measure_memory_usage,
assert reply.get('trigger_line_numbers') == [1] # Triggers "Awesome" in line 1
delete_all_watches(client)
def _setup_version_list_preview(datastore_path, client):
"""Shared HTML fixture for #4138 preview regressions (version tag list)."""
import time
data = """<html><body>
0.55.5<br>
0.55.4<br>
0.55.3<br>
0.54.10<br>
0.54.9<br>
</body></html>"""
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
f.write(data)
test_url = url_for('test_endpoint', _external=True)
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
time.sleep(0.5)
wait_for_all_checks(client)
return test_url, uuid
def test_preview_ignore_highlight_with_extract_text(client, live_server, measure_memory_usage, datastore_path):
"""Regression for #4138 follow-up: when extract_text rewrites a line (e.g. "0.54.10"".54.10"),
the preview must still highlight that row as 'ignored' even though substring matching against the
post-extract text fails."""
import json
test_url, uuid = _setup_version_list_preview(datastore_path, client)
res = client.post(
url_for("ui.ui_edit.watch_get_preview_rendered", uuid=uuid),
data={
"include_filters": "",
"fetch_backend": 'html_requests',
"ignore_text": "0.54.10",
"extract_text": r"/(.\d+\.\d+)/",
"url": test_url,
},
)
reply = json.loads(res.data.decode('utf-8'))
# The regex strips the leading "0", so the post-extract line for the ignored input is ".54.10".
# The preview should still mark its position (line 4) as ignored.
assert reply.get('ignore_line_numbers') == [4], \
f"Expected line 4 to be highlighted as ignored, got {reply.get('ignore_line_numbers')!r}"
delete_all_watches(client)
def test_preview_strip_ignored_lines_with_extract_text(client, live_server, measure_memory_usage, datastore_path):
"""Regression for #4138 follow-up: with strip_ignored_lines enabled, an ignored line must be
removed from the preview output even when extract_text would otherwise rewrite it (0.54.10 .54.10)."""
import json
test_url, uuid = _setup_version_list_preview(datastore_path, client)
res = client.post(
url_for("ui.ui_edit.watch_get_preview_rendered", uuid=uuid),
data={
"include_filters": "",
"fetch_backend": 'html_requests',
"ignore_text": "0.54.10",
"extract_text": r"/(.\d+\.\d+)/",
"strip_ignored_lines": "true",
"url": test_url,
},
)
reply = json.loads(res.data.decode('utf-8'))
after_filter = reply.get('after_filter', '')
assert '.54.10' not in after_filter, \
f"Stripped ignored line should not appear in preview output, got:\n{after_filter!r}"
assert '0.54.10' not in after_filter
assert reply.get('ignore_line_numbers') == [], \
f"Stripped lines need no highlight, got {reply.get('ignore_line_numbers')!r}"
delete_all_watches(client)
@@ -634,6 +634,12 @@ def _test_color_notifications(client, notification_body_token, datastore_path):
def test_html_color_notifications(client, live_server, measure_memory_usage, datastore_path):
_test_color_notifications(client, '{{diff}}',datastore_path=datastore_path)
_test_color_notifications(client, '{{diff_full}}',datastore_path=datastore_path)
# Regression: the html-output escape pass in handler.py used to convert
# FormattableDiff into a plain str, stripping its __call__ and breaking any
# {{ diff(...) }} / {{ diff_added(...) }} token on htmlcolor/html notifications
# with 'str' object is not callable (see commit 08d30c6 + #3923).
# word_diff=false reproduces the exact form the user-reported failure used.
_test_color_notifications(client, '{{diff(word_diff=false)}}', datastore_path=datastore_path)
def _test_custom_html_in_notification_body_not_escaped(client, datastore_path, content_type=None):
@@ -842,6 +842,10 @@ msgstr ""
msgid "AI Provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Third-party data transfer — please read"
msgstr ""
@@ -3178,6 +3182,10 @@ msgstr "Měsíční rozpočet tokenů"
msgid "Max input characters"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr ""
@@ -858,6 +858,10 @@ msgstr ""
msgid "AI Provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Third-party data transfer — please read"
msgstr ""
@@ -3230,6 +3234,10 @@ msgstr ""
msgid "Max input characters"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr ""
@@ -840,6 +840,10 @@ msgstr ""
msgid "AI Provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Third-party data transfer — please read"
msgstr ""
@@ -3172,6 +3176,10 @@ msgstr ""
msgid "Max input characters"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr ""
@@ -840,6 +840,10 @@ msgstr ""
msgid "AI Provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Third-party data transfer — please read"
msgstr ""
@@ -3172,6 +3176,10 @@ msgstr ""
msgid "Max input characters"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr ""
@@ -878,6 +878,10 @@ msgstr ""
msgid "AI Provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Third-party data transfer — please read"
msgstr ""
@@ -2314,11 +2318,11 @@ msgstr "Último Comprobado"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Changed"
msgstr "Cambiadp"
msgstr "Cambiado"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Last Changed"
msgstr "Último Cambiadp"
msgstr "Último Cambiado"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "No web page change detection watches configured, please add a URL in the box above, or"
@@ -3245,6 +3249,10 @@ msgstr ""
msgid "Max input characters"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr ""
@@ -846,6 +846,10 @@ msgstr ""
msgid "AI Provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Third-party data transfer — please read"
msgstr ""
@@ -3185,6 +3189,10 @@ msgstr ""
msgid "Max input characters"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr ""
@@ -842,6 +842,10 @@ msgstr ""
msgid "AI Provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Third-party data transfer — please read"
msgstr ""
@@ -3174,6 +3178,10 @@ msgstr ""
msgid "Max input characters"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr ""
@@ -847,6 +847,10 @@ msgstr ""
msgid "AI Provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Third-party data transfer — please read"
msgstr ""
@@ -3191,6 +3195,10 @@ msgstr ""
msgid "Max input characters"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr ""
@@ -844,6 +844,10 @@ msgstr "AI 프로바이더 설정"
msgid "AI Provider"
msgstr "AI 프로바이더"
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Third-party data transfer — please read"
msgstr "제3자 데이터 전송 - 읽어 주세요"
@@ -3182,6 +3186,10 @@ msgstr "월간 토큰 예산"
msgid "Max input characters"
msgstr "최대 입력 문자 수"
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr "{{diff}} 알림 토큰을 AI 요약으로 대체"
+10 -2
View File
@@ -6,9 +6,9 @@
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: changedetection.io 0.55.3\n"
"Project-Id-Version: changedetection.io 0.55.5\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-05-19 10:29+0200\n"
"POT-Creation-Date: 2026-05-19 19:05+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -839,6 +839,10 @@ msgstr ""
msgid "AI Provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Third-party data transfer — please read"
msgstr ""
@@ -3171,6 +3175,10 @@ msgstr ""
msgid "Max input characters"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr ""
@@ -865,6 +865,10 @@ msgstr ""
msgid "AI Provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Third-party data transfer — please read"
msgstr ""
@@ -3222,6 +3226,10 @@ msgstr ""
msgid "Max input characters"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr ""
@@ -875,6 +875,10 @@ msgstr ""
msgid "AI Provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Third-party data transfer — please read"
msgstr ""
@@ -3225,6 +3229,10 @@ msgstr ""
msgid "Max input characters"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr ""
@@ -855,6 +855,10 @@ msgstr ""
msgid "AI Provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Third-party data transfer — please read"
msgstr ""
@@ -3204,6 +3208,10 @@ msgstr ""
msgid "Max input characters"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr ""
@@ -844,6 +844,10 @@ msgstr ""
msgid "AI Provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Third-party data transfer — please read"
msgstr ""
@@ -3177,6 +3181,10 @@ msgstr ""
msgid "Max input characters"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr ""
@@ -843,6 +843,10 @@ msgstr ""
msgid "AI Provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Third-party data transfer — please read"
msgstr ""
@@ -3176,6 +3180,10 @@ msgstr ""
msgid "Max input characters"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr ""
+14 -4
View File
@@ -432,9 +432,14 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
update_obj['_llm_result'] = None
update_obj['_llm_intent'] = ''
update_obj['_llm_change_summary'] = ''
# skip_check: when budget exceeded, don't run LLM or the check
# skip_check: when budget exceeded, don't run LLM or the check.
# Also gated on llm_enabled — a disabled LLM can't be spending tokens,
# so the budget enforcement shouldn't suppress changes when the user
# has explicitly switched LLM off.
from changedetectionio.llm.evaluator import is_llm_features_disabled as _is_llm_features_disabled
_llm_master_enabled = bool(datastore.data['settings']['application'].get('llm_enabled', True)) and not _is_llm_features_disabled()
_llm_budget_action = datastore.data['settings']['application'].get('llm_budget_action', 'skip_llm')
if _llm_budget_action == 'skip_check':
if _llm_master_enabled and _llm_budget_action == 'skip_check':
from changedetectionio.llm.evaluator import is_global_token_budget_exceeded
if is_global_token_budget_exceeded(datastore):
logger.info(f"LLM monthly budget exceeded — skipping check for {uuid} (budget_action=skip_check)")
@@ -444,9 +449,14 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
try:
from changedetectionio.llm.evaluator import (
evaluate_change, resolve_intent, resolve_llm_field,
summarise_change, get_llm_config,
summarise_change, _runtime_llm_config,
)
_llm_cfg = get_llm_config(datastore)
# _runtime_llm_config returns None (and logs a debug skip
# message) when the master 'llm_enabled' toggle is off, so
# the whole block — diff computation, status minitext, and
# the two executor dispatches — is skipped, not just the
# inner LLM lookups.
_llm_cfg = _runtime_llm_config(datastore)
if _llm_cfg:
# Compute unified diff once — used by both intent and summary
_watch_dates = list(watch.history.keys())