Back to Changedetection Io

Settings Llm Tab

changedetectionio/blueprint/settings/templates/settings_llm_tab.html

0.55.513.4 KB
Original Source

{% from '_helpers.html' import render_field %} {% from '_stab.html' import stab_shell, stab_pane %} {# AI / LLM settings tab content — included from settings.html. Requires template context: form, llm_config, llm_env_configured #}

{# TRANSLATORS: 'Usage' here means token consumption/cost stats for the AI provider, not a how-to guide #} {% set _usage_label = pgettext('AI usage stats', 'Usage') %} {% call stab_shell('ai-settings', [{'id': 'overview', 'label': _('Overview'), 'icon': '✦'}, {'id': 'provider', 'label': _('Provider'), 'icon': '⚙'}, {'id': 'prompts', 'label': _('Prompts'), 'icon': '≡'}, {'id': 'behaviour', 'label': _('Behaviour'), 'icon': '⚑'}, {'id': 'usage', 'label': _usage_label, 'icon': '$'},]) %} {# ── Overview ──────────────────────────────────────────────────────────── #} {% call stab_pane('overview') %}

✦ {{ _('AI-powered change monitoring') }}

{{ _('Connect an LLM to move from "something changed" to "only the thing you care about changed".') }}

{{ _('Intent filtering') }}

{{ _('Each watch or tag can carry a plain-text intent — %(ex1)s or %(ex2)s. On every detected change the AI evaluates the diff against it and suppresses irrelevant noise.', ex1=' "notify me only when the price drops"', ex2=' "alert when the item goes out of stock"') | safe }}

{{ _('Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very small models (≤3B) may misjudge numeric comparisons.', local='qwen2.5:7b', gpt='gpt-4o-mini', gemini='gemini-2.0-flash') | safe }}

{{ _('AI Change Summary') }}

{{ _('Instead of raw diffs, receive plain-language summaries in notifications — %(ex1)s or %(ex2)s. Set a global default prompt here, or override per watch or tag.', ex1=' "Price dropped from $89 to $67"', ex2=' "3 new items added to the listing"') | safe }}

{{ _('Minimal cost') }}

{{ _('The AI sees only a unified diff of what changed — never full page HTML. Low-cost models like %(gpt)s or %(gemini)s handle this well, typically fractions of a cent per check.', gpt='gpt-4o-mini', gemini='Gemini Flash') | safe }}

{% if llm_config and llm_config.get('model') %} ✓ {{ _('AI / LLM configured:') }} {{ llm_config.get('model') }} {% else %} ⚙ {{ _('Configure AI Provider') }} → {% endif %} {% endcall %} {# ── Provider ──────────────────────────────────────────────────────────── #} {% call stab_pane('provider') %}

{{ _('AI Provider') }}

{{ form.llm.form.llm_enabled() }} {{ form.llm.form.llm_enabled.label.text }} {{ _('Master switch — when off, all AI lookups are skipped even if a provider is configured below.') }} {% if not llm_env_configured and not (llm_config and llm_config.get('model')) %}

{{ _('Third-party data transfer — please read') }}

{{ _('When AI features are active, change data from the websites you monitor — including page diffs and extracted text — is sent to an external AI provider of your choice.') }}

  • {{ _('You are solely responsible for ensuring this complies with the terms of service of each website you monitor.') }}
  • {{ _("You are solely responsible for compliance with applicable data-protection laws (e.g. GDPR) regarding any personal data that may appear in monitored content.") }}
  • {{ _('API costs charged by your chosen provider are your own responsibility; this software has no visibility into or control over those charges.') }}
  • {{ _('AI / LLM models are known to hallucinate — producing plausible-sounding but factually incorrect or entirely fabricated output with apparent confidence — and by design may omit or truncate relevant data during summarisation. AI output must never be relied upon as complete or accurate. This software is provided as-is with no warranty of any kind.') }}
  • {{ _('By enabling AI features you personally indemnify and hold harmless the creator(s) and contributor(s) of this software from any claims, damages, or liability arising from this data transfer or your use of AI features.') }}

{{ _('I have read and understood the above. I accept full responsibility and indemnify the creator(s) of this software.') }}

{% endif %} {% if llm_env_configured %} {{ _('AI / LLM is configured via environment variables (LLM_MODEL=%(model)s%(api_base)s). Remove the LLM_MODEL environment variable to configure via this form instead.', model=llm_config.get('model', '')|e, api_base=(', LLM_API_BASE=' ~ (llm_config.get('api_base')|e) ~ '') if llm_config.get('api_base') else '') | safe }} {% else %} {{ _('Provider') }}— {{ _('select a provider') }} —AnthropicGoogle (Gemini)OllamaOpenAI{{ _('OpenAI-compatible (vLLM, LM Studio, llama.cpp)') }}OpenRouter (200+ models)

{{ render_field(form.llm.form.llm_api_key) }}

{{ render_field(form.llm.form.llm_api_base) }} {{ _('Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers.') }} {# Hidden field carrying the dropdown selection so the backend knows when to apply reasoning-friendly token caps (Ollama and OpenAI-compatible endpoints, which commonly serve reasoning models that need headroom for chain-of-thought to complete). #} {{ form.llm.form.llm_provider_kind() }} {{ form.llm.form.llm_local_token_multiplier.label.text }} {{ form.llm.form.llm_local_token_multiplier() }} {{ _('Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier scales every max_tokens cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps.', default='5x') | safe }}

↻ {{ _('Load available models') }}

{{ _('Available models') }}— {{ _('choose a model') }} —

{{ render_field(form.llm.form.llm_model, placeholder=_("Enter API key and click 'Load available models'")) }} {% if llm_config and llm_config.get('model') %} ✓ {{ _('AI / LLM configured:') }} {{ llm_config.get('model') }} {# data-method="POST" tells modal.js to POST with the CSRF token instead of navigating — GET previously allowed -based CSRF wipe (GHSA-g36r-fm2p-87xm). Stays as because we're inside the outer settings — nested forms are invalid HTML, so modal.js builds a body-level hidden form for the POST. #} ✕ {{ _('Remove') }} ▶ {{ _('Test connection') }}

{% endif %}

{{ _("Your API key is stored locally and sent only to your chosen provider. On each detected change, the watch's diff and extracted text are sent to the LLM — no full page HTML.") }}

{{ _('Cache') }} {# See comment above on data-method="POST"+modal.js (GHSA-g36r-fm2p-87xm). #} ✕ {{ _('Clear all summary cache') }}{{ _('Removes all cached AI change summaries across all watches. They will be regenerated on the next check.') }}

{{ form.llm.form.llm_debug() }} {{ form.llm.form.llm_debug.label.text }} {{ _('Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. Leave off in production — generates a lot of log volume.') }} {% endif %}{# llm_env_configured #} {% if not llm_env_configured and not (llm_config and llm_config.get('model')) %} {# llm-provider-fields #} {% endif %} {% endcall %} {# ── Prompts ───────────────────────────────────────────────────────────── #} {% call stab_pane('prompts') %}

{{ _('Default AI Change Summary') }}

{{ render_field(form.llm.form.llm_change_summary_default) }} {{ _('Used for all watches unless overridden by the watch or its tag/group.') }} {{ _('Modify default prompt') }} {% endcall %} {# ── Behaviour ─────────────────────────────────────────────────────────── #} {% call stab_pane('behaviour') %}

{{ _('Behaviour') }}

{% if llm_config and llm_config.get('model') %} {{ form.llm.form.llm_override_diff_with_summary() }} {{ form.llm.form.llm_override_diff_with_summary.label.text }} {{ _('When enabled, the %(diff)s notification token shows the AI summary instead of the raw diff. Use %(raw_diff)s to always get the original.', diff='{{diff}}', raw_diff='{{raw_diff}}') | safe }}

{{ form.llm.form.llm_restock_use_fallback_extract() }} {{ form.llm.form.llm_restock_use_fallback_extract.label.text }} {{ _('When enabled, the AI will be used as a last resort to extract price and stock status from product pages where no structured metadata (JSON-LD, microdata, OpenGraph) is found.') }}

{{ form.llm.form.llm_thinking_budget.label.text }} {{ form.llm.form.llm_thinking_budget() }} {{ _('For Gemini 2.5+ models only. Thinking tokens improve reasoning quality but count against the output budget. Set to Off if summaries are being cut short.') }}

{{ form.llm.form.llm_max_summary_tokens.label.text }} {{ form.llm.form.llm_max_summary_tokens() }} {{ _('Upper limit on tokens the AI may use when writing a change summary. Higher values allow longer summaries but cost more.') }}

{{ form.llm.form.llm_budget_action.label.text }} {% for subfield in form.llm.form.llm_budget_action %} {{ subfield() }} {{ subfield.label.text }} {% endfor %}

{% else %}

{{ _('Configure a provider first to unlock behaviour settings.') }}

{% endif %} {% endcall %} {# ── Usage ─────────────────────────────────────────────────────────────── #} {% call stab_pane('usage') %}

{{ _('Token & Cost Tracking') }}

{% if llm_stored.get('tokens_total_cumulative') or llm_stored.get('tokens_this_month') %}

{{ _('This month') }}

{{ '{:,}'.format(llm_stored.get('tokens_this_month', 0)) }}

{{ _('tokens') }}{% if llm_show_costs and llm_stored.get('cost_usd_this_month') %} · ≈ ${{ '%.4f'|format(llm_stored.get('cost_usd_this_month', 0)) }}{% endif %} {% if llm_token_budget_month %} {% set pct = (llm_stored.get('tokens_this_month', 0) / llm_token_budget_month * 100)|int %}

{{ _('%(percent)s%% of %(budget)s', percent=pct, budget='{:,}'.format(llm_token_budget_month)) }} {% endif %}

{{ _('All-time total') }}

{{ '{:,}'.format(llm_stored.get('tokens_total_cumulative', 0)) }}

{{ _('tokens') }}{% if llm_show_costs and llm_stored.get('cost_usd_total_cumulative') %} · ≈ ${{ '%.4f'|format(llm_stored.get('cost_usd_total_cumulative', 0)) }}{% endif %}

{% if llm_token_budget_month and llm_stored.get('tokens_this_month', 0) >= llm_token_budget_month %}

⚠ {{ _('Monthly token budget reached. AI summarisation is paused until next month.') }}

{% endif %}

{{ _('Token budget this period') }} {% if llm_token_budget_month_env %} {{ '{:,}'.format(llm_token_budget_month_env) }} {{ _('(set via LLM_TOKEN_BUDGET_MONTH)') | safe }} {% else %} {{ form.llm.form.llm_token_budget_month(placeholder=_('0 = unlimited'), value=llm_stored.get('token_budget_month', 0) or '') }} {{ _('tokens (0 = unlimited)') }} {% endif %} {% if llm_stored.get('tokens_month_key') %} {{ _('Current billing period') }}{{ llm_stored.get('tokens_month_key') }} {% endif %} {{ _('Max input characters') }} {% if llm_max_input_chars_env %} {{ form.llm.form.llm_max_input_chars(value=llm_max_input_chars_env, readonly=True, style="width:10em;opacity:0.6;cursor:not-allowed;") }} {{ _('(set via LLM_MAX_INPUT_CHARS)') | safe }} {% else %} {{ form.llm.form.llm_max_input_chars(placeholder='100000', value=llm_stored.get('max_input_chars', 100000) or '') }} {{ _('characters — currently enforcing: %(limit)s', limit='{:,}'.format(llm_effective_max_input_chars)) }} {% endif %}

{% else %}

{{ _('No AI usage recorded yet.') }}

{{ _('Token budget') }} {% if llm_token_budget_month_env %} {{ '{:,}'.format(llm_token_budget_month_env) }} {{ _('(set via LLM_TOKEN_BUDGET_MONTH)') | safe }} {% else %} {{ form.llm.form.llm_token_budget_month(placeholder=_('0 = unlimited'), value=llm_stored.get('token_budget_month', 0) or '') }} {{ _('tokens per month (0 = unlimited)') }} {% endif %}

{{ _('Max input characters') }} {% if llm_max_input_chars_env %} {{ form.llm.form.llm_max_input_chars(value=llm_max_input_chars_env, readonly=True, style="width:10em;opacity:0.6;cursor:not-allowed;") }} {{ _('(set via LLM_MAX_INPUT_CHARS)') | safe }} {% else %} {{ form.llm.form.llm_max_input_chars(placeholder='100000', value=llm_stored.get('max_input_chars', 100000) or '') }} {{ _('characters — currently enforcing: %(limit)s', limit='{:,}'.format(llm_effective_max_input_chars)) }} {% endif %}

{% endif %} {% endcall %} {% endcall %}{# stab_shell #}