mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-09-27 15:56:45 +00:00
648 lines
35 KiB
HTML
648 lines
35 KiB
HTML
{% 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
|
|
#}
|
|
<div class="tab-pane-inner" id="ai">
|
|
<script src="{{ url_for('static_content', group='js', filename='sub-tabs.js') }}"></script>
|
|
|
|
{# 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': 'info'},
|
|
{'id': 'provider', 'label': _('Provider'), 'icon': 'settings'},
|
|
{'id': 'prompts', 'label': _('Prompts'), 'icon': 'message-square'},
|
|
{'id': 'behaviour', 'label': _('Behaviour'), 'icon': 'sliders'},
|
|
{'id': 'usage', 'label': _usage_label, 'icon': 'bar-chart-2'},
|
|
]) %}
|
|
|
|
{# ── Overview ──────────────────────────────────────────────────────────── #}
|
|
{% call stab_pane('overview') %}
|
|
<div class="stab-overview-hero">
|
|
<h3><span class="stab-overview-glyph"><i data-feather="cpu"></i></span> {{ _('AI-powered change monitoring') }}</h3>
|
|
<p>{{ _('Connect an LLM to move from "something changed" to "only the thing you care about changed".') }}</p>
|
|
</div>
|
|
|
|
<div class="stab-overview-features">
|
|
<div class="stab-overview-feature">
|
|
<div class="stab-overview-icon"><i data-feather="target"></i></div>
|
|
<div class="stab-overview-text">
|
|
<strong>{{ _('Intent filtering') }}</strong>
|
|
<p>{{ _('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='<strong>"notify me only when the price drops"</strong>', ex2='<strong>"alert when the item goes out of stock"</strong>') | safe }}</p>
|
|
<p><small>{{ _('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='<code>qwen2.5:7b</code>',
|
|
gpt='<code>gpt-4o-mini</code>',
|
|
gemini='<code>gemini-2.0-flash</code>') | safe }}</small></p>
|
|
</div>
|
|
</div>
|
|
<div class="stab-overview-feature">
|
|
<div class="stab-overview-icon"><i data-feather="file-text"></i></div>
|
|
<div class="stab-overview-text">
|
|
<strong>{{ _('AI Change Summary') }}</strong>
|
|
<p>{{ _('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='<strong>"Price dropped from $89 to $67"</strong>', ex2='<strong>"3 new items added to the listing"</strong>') | safe }}</p>
|
|
</div>
|
|
</div>
|
|
<div class="stab-overview-feature">
|
|
<div class="stab-overview-icon"><i data-feather="code"></i></div>
|
|
<div class="stab-overview-text">
|
|
<strong>{{ _('Minimal cost') }}</strong>
|
|
<p>{{ _('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='<a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener">gpt-4o-mini</a>',
|
|
gemini='<a href="https://aistudio.google.com/apikey" target="_blank" rel="noopener">Gemini Flash</a>') | safe }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stab-overview-cta">
|
|
{% if llm_config and llm_config.get('model') %}
|
|
<span class="stab-configured-badge">✓ {{ _('AI / LLM configured:') }} {{ llm_config.get('model') }}</span>
|
|
{% if not llm_env_configured %}
|
|
<button type="button" class="cdio-btn" data-stab-goto="provider">
|
|
<i data-feather="settings"></i> {{ _('Edit provider') }} →
|
|
</button>
|
|
{% endif %}
|
|
{% else %}
|
|
<button type="button" class="cdio-btn pure-button-primary" data-stab-goto="provider">
|
|
<i data-feather="settings"></i> {{ _('Configure AI Provider') }} →
|
|
</button>
|
|
{% endif %}
|
|
</div>
|
|
{% endcall %}
|
|
|
|
{# ── Provider ──────────────────────────────────────────────────────────── #}
|
|
{% call stab_pane('provider') %}
|
|
<p class="stab-section-title">{{ _('AI Provider') }}</p>
|
|
|
|
<div class="pure-control-group">
|
|
<label></label>
|
|
{{ form.llm.form.enabled() }}
|
|
<label for="{{ form.llm.form.enabled.id }}" style="display:inline; font-weight:normal;">
|
|
{{ form.llm.form.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"><i data-feather="alert-triangle"></i></div>
|
|
<div class="stab-disclaimer-body">
|
|
<strong>{{ _('Third-party data transfer — please read') }}</strong>
|
|
<p>{{ _('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.') }}</p>
|
|
<ul>
|
|
<li>{{ _('You are solely responsible for ensuring this complies with the terms of service of each website you monitor.') }}</li>
|
|
<li>{{ _("You are solely responsible for compliance with applicable data-protection laws (e.g. GDPR) regarding any personal data that may appear in monitored content.") }}</li>
|
|
<li>{{ _('API costs charged by your chosen provider are your own responsibility; this software has no visibility into or control over those charges.') }}</li>
|
|
<li>{{ _('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.') }}</li>
|
|
<li>{{ _('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.') }}</li>
|
|
</ul>
|
|
<label class="stab-disclaimer-check">
|
|
<input type="checkbox" id="llm-disclaimer-accept" onchange="llmDisclaimerToggle(this)">
|
|
<span>{{ _('I have read and understood the above. I accept full responsibility and indemnify the creator(s) of this software.') }}</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div id="llm-provider-fields" style="display:none">
|
|
{% endif %}
|
|
|
|
{% if llm_env_configured %}
|
|
<div class="inline-warning" style="margin-bottom: 1em;">
|
|
<img class="inline-warning-icon" src="{{ url_for('static_content', group='images', filename='notice.svg') }}" alt="{{ _('Note') }}">
|
|
{{ _('AI / LLM is configured via environment variables (<code>LLM_MODEL=%(model)s</code>%(api_base)s). Remove the <code>LLM_MODEL</code> environment variable to configure via this form instead.',
|
|
model=llm_config.get('model', '')|e,
|
|
api_base=(', <code>LLM_API_BASE=' ~ (llm_config.get('api_base')|e) ~ '</code>') if llm_config.get('api_base') else '') | safe }}
|
|
</div>
|
|
{% else %}
|
|
|
|
<div class="pure-control-group">
|
|
<label for="llm-provider">{{ _('Provider') }}</label>
|
|
<select id="llm-provider" onchange="llmOnProviderChange(this.value)"{% if llm_provider_locked %} disabled{% endif %}>
|
|
<option value="">— {{ _('select a provider') }} —</option>
|
|
<option value="anthropic">Anthropic</option>
|
|
<option value="gemini">Google (Gemini)</option>
|
|
<option value="ollama">Ollama</option>
|
|
<option value="openai">OpenAI</option>
|
|
<option value="openai_compatible">{{ _('OpenAI-compatible (vLLM, LM Studio, llama.cpp)') }}</option>
|
|
<option value="openrouter">OpenRouter (200+ models)</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="pure-control-group">
|
|
{#- Disabled once a key is stored: a disabled input is not submitted at all, so there is
|
|
no way for an autofilled or mistyped value to reach the save path and overwrite a
|
|
working key. "Remove provider" is the only way to change it. -#}
|
|
{{ render_field(form.llm.form.api_key, disabled=llm_provider_locked) }}
|
|
<span class="pure-form-message-inline" id="llm-key-hint">
|
|
{%- if llm_provider_locked %}{{ _('Saved. Use "Remove provider" below to change the provider or key.') }}{% endif -%}
|
|
</span>
|
|
</div>
|
|
<div class="pure-control-group" id="llm-base-group" style="display:none">
|
|
{{ render_field(form.llm.form.api_base) }}
|
|
<span class="pure-form-message-inline">{{ _('Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers.') }}</span>
|
|
</div>
|
|
|
|
{# 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.provider_kind() }}
|
|
|
|
<div class="pure-control-group" id="llm-local-advanced-group" style="display:none">
|
|
<label for="{{ form.llm.form.local_token_multiplier.id }}">{{ form.llm.form.local_token_multiplier.label.text }}</label>
|
|
{{ form.llm.form.local_token_multiplier() }}
|
|
<span class="pure-form-message-inline">
|
|
{{ _('Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier scales every <code>max_tokens</code> 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 }}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="pure-control-group" id="llm-fetch-group" style="display:none">
|
|
<label></label>
|
|
<button type="button" id="llm-fetch-btn" class="cdio-btn button-xsmall" onclick="llmFetchModels()"
|
|
style="background:#27ae60;color:#fff;border:none;">
|
|
↻ {{ _('Load available models') }}
|
|
</button>
|
|
<span id="llm-fetch-status" style="margin-left:.6em;font-size:.85em;color:#888;"></span>
|
|
</div>
|
|
|
|
<div class="pure-control-group" id="llm-model-select-group" style="display:none">
|
|
<label for="llm-model-select">{{ _('Available models') }}</label>
|
|
<select id="llm-model-select" class="pure-input-1-2" onchange="llmOnModelPick(this.value)">
|
|
<option value="">— {{ _('choose a model') }} —</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="pure-control-group">
|
|
{{ render_field(form.llm.form.model,
|
|
placeholder=_("Type a model name or choose from available models"),
|
|
aria_describedby="llm-model-help") }}
|
|
<span class="pure-form-message-inline" id="llm-model-help">
|
|
{{ _('You can edit the model name directly or load available models above. Click Save to apply your change. Leave the API key blank to keep your saved key.') }}
|
|
</span>
|
|
</div>
|
|
|
|
{% if llm_config and llm_config.get('model') %}
|
|
<div class="pure-control-group">
|
|
<label></label>
|
|
<span style="color:#4a7c59;font-weight:bold;">
|
|
✓ {{ _('AI / LLM configured:') }} {{ llm_config.get('model') }}
|
|
</span>
|
|
|
|
<button type="button" class="cdio-btn button-xsmall"
|
|
onclick="const field = document.getElementById('{{ form.llm.form.model.id }}'); field.focus(); field.select();">
|
|
{{ _('Change model') }}
|
|
</button>
|
|
|
|
{# data-method="POST" tells modal.js to POST with the CSRF token instead of
|
|
navigating — GET previously allowed <img>-based CSRF wipe (GHSA-g36r-fm2p-87xm).
|
|
Stays as <a> because we're inside the outer settings <form> — nested forms are
|
|
invalid HTML, so modal.js builds a body-level hidden form for the POST. #}
|
|
<a href="{{ url_for('settings.llm.llm_clear') }}"
|
|
class="cdio-btn button-xsmall"
|
|
style="background:#c0392b;color:#fff;"
|
|
data-method="POST"
|
|
data-requires-confirm
|
|
data-confirm-type="danger"
|
|
data-confirm-title="{{ _('Remove AI / LLM configuration?') }}"
|
|
data-confirm-message="<p>{{ _('This will remove your saved AI provider, model, and API key.') }}</p>"
|
|
data-confirm-button="{{ _('Remove') }}"
|
|
data-cancel-button="{{ _('Cancel') }}">
|
|
✕ {{ _('Remove provider') }}
|
|
</a>
|
|
|
|
<button type="button" id="llm-test-btn" class="cdio-btn button-xsmall" onclick="llmRunTest()"
|
|
style="background:#2980b9;color:#fff;border:none;">
|
|
▶ {{ _('Test connection') }}
|
|
</button>
|
|
</div>
|
|
<div id="llm-test-result" style="display:none; margin-top:0.6em; padding:0.6em 0.85em; border-radius:5px; font-size:0.88em; line-height:1.45;"></div>
|
|
{% endif %}
|
|
|
|
<p class="pure-form-message-inline" style="margin-top:0.5em;">
|
|
{{ _("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.") }}
|
|
</p>
|
|
|
|
<div class="pure-control-group" style="margin-top:1.2em; padding-top:1em; border-top:1px solid rgba(128,128,128,0.15);">
|
|
<label style="color:#888; font-size:0.85em;">{{ _('Cache') }}</label>
|
|
{# See comment above on data-method="POST"+modal.js (GHSA-g36r-fm2p-87xm). #}
|
|
<a href="{{ url_for('settings.llm.llm_clear_summary_cache') }}"
|
|
class="cdio-btn button-xsmall"
|
|
style="background:#7f8c8d;color:#fff;"
|
|
data-method="POST"
|
|
data-requires-confirm
|
|
data-confirm-type="warning"
|
|
data-confirm-title="{{ _('Clear all summary cache?') }}"
|
|
data-confirm-message="<p>{{ _('This will remove all cached AI change summaries across all watches.') }}</p><p>{{ _('They will be regenerated on the next check.') }}</p>"
|
|
data-confirm-button="{{ _('Clear cache') }}"
|
|
data-cancel-button="{{ _('Cancel') }}">
|
|
✕ {{ _('Clear all summary cache') }}
|
|
</a>
|
|
<span class="pure-form-message-inline">{{ _('Removes all cached AI change summaries across all watches. They will be regenerated on the next check.') }}</span>
|
|
</div>
|
|
|
|
<div class="pure-control-group">
|
|
<label></label>
|
|
{{ form.llm.form.debug() }}
|
|
<label for="{{ form.llm.form.debug.id }}" style="display:inline; font-weight:normal;">
|
|
{{ form.llm.form.debug.label.text }}
|
|
</label>
|
|
<span class="pure-form-message-inline">
|
|
{{ _('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.') }}
|
|
</span>
|
|
</div>
|
|
{% endif %}{# llm_env_configured #}
|
|
|
|
{% if not llm_env_configured and not (llm_config and llm_config.get('model')) %}
|
|
</div>{# llm-provider-fields #}
|
|
{% endif %}
|
|
{% endcall %}
|
|
|
|
{# ── Prompts ───────────────────────────────────────────────────────────── #}
|
|
{% call stab_pane('prompts') %}
|
|
<p class="stab-section-title">{{ _('Default AI Change Summary') }}</p>
|
|
|
|
<div class="pure-control-group">
|
|
{{ render_field(form.llm.form.change_summary_default) }}
|
|
<span class="pure-form-message-inline">
|
|
{{ _('Used for all watches unless overridden by the watch or its tag/group.') }}
|
|
<a href="#" class="cdio-btn button-small" onclick="var t=document.getElementById('llm-change_summary_default'); if(!t.value && t.placeholder) t.value=t.placeholder; return false;">{{ _('Modify default prompt') }}</a>
|
|
</span>
|
|
</div>
|
|
|
|
{% endcall %}
|
|
|
|
{# ── Behaviour ─────────────────────────────────────────────────────────── #}
|
|
{% call stab_pane('behaviour') %}
|
|
<p class="stab-section-title">{{ _('Behaviour') }}</p>
|
|
|
|
{% if llm_config and llm_config.get('model') %}
|
|
<div class="pure-control-group">
|
|
<label></label>
|
|
{{ form.llm.form.override_diff_with_summary() }}
|
|
<label for="{{ form.llm.form.override_diff_with_summary.id }}" style="display:inline; font-weight:normal;">
|
|
{{ form.llm.form.override_diff_with_summary.label.text }}
|
|
</label>
|
|
<span class="pure-form-message-inline">
|
|
{{ _('When enabled, the <code>%(diff)s</code> notification token shows the AI summary instead of the raw diff. Use <code>%(raw_diff)s</code> to always get the original.',
|
|
diff='{{diff}}', raw_diff='{{raw_diff}}') | safe }}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="pure-control-group">
|
|
<label></label>
|
|
{{ form.llm.form.restock_use_fallback_extract() }}
|
|
<label for="{{ form.llm.form.restock_use_fallback_extract.id }}" style="display:inline; font-weight:normal;">
|
|
{{ form.llm.form.restock_use_fallback_extract.label.text }}
|
|
</label>
|
|
<span class="pure-form-message-inline">
|
|
{{ _('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.') }}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="pure-control-group">
|
|
<label for="{{ form.llm.form.thinking_budget.id }}">{{ form.llm.form.thinking_budget.label.text }}</label>
|
|
{{ form.llm.form.thinking_budget() }}
|
|
<span class="pure-form-message-inline">{{ _('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.') }}</span>
|
|
</div>
|
|
|
|
<div class="pure-control-group">
|
|
<label for="{{ form.llm.form.max_summary_tokens.id }}">{{ form.llm.form.max_summary_tokens.label.text }}</label>
|
|
{{ form.llm.form.max_summary_tokens() }}
|
|
<span class="pure-form-message-inline">{{ _('Upper limit on tokens the AI may use when writing a change summary. Higher values allow longer summaries but cost more.') }}</span>
|
|
</div>
|
|
|
|
<div class="pure-control-group">
|
|
<label>{{ form.llm.form.budget_action.label.text }}</label>
|
|
<div>
|
|
{% for subfield in form.llm.form.budget_action %}
|
|
<label class="pure-radio" style="display:block; font-weight:normal; margin-bottom:0.3em;">
|
|
{{ subfield() }} {{ subfield.label.text }}
|
|
</label>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="pure-control-group">
|
|
<label>{{ form.llm.form.watchlist_overview_summary.label.text }}</label>
|
|
<div>
|
|
{% for subfield in form.llm.form.watchlist_overview_summary %}
|
|
<label class="pure-radio" style="display:block; font-weight:normal; margin-bottom:0.3em;">
|
|
{{ subfield() }} {{ subfield.label.text }}
|
|
</label>
|
|
{% endfor %}
|
|
</div>
|
|
<span class="pure-form-message-inline">
|
|
{{ _('Which baseline the watchlist “Summary” link compares the latest version against by default.') }}
|
|
</span>
|
|
</div>
|
|
{% else %}
|
|
<p class="pure-form-message-inline" style="margin-top:0.5em;">
|
|
{{ _('Configure a provider first to unlock behaviour settings.') }}
|
|
</p>
|
|
{% endif %}
|
|
{% endcall %}
|
|
|
|
{# ── Usage ─────────────────────────────────────────────────────────────── #}
|
|
{% call stab_pane('usage') %}
|
|
<p class="stab-section-title">{{ _('Token & Cost Tracking') }}</p>
|
|
|
|
{% if llm_stored.get('tokens_total_cumulative') or llm_stored.get('tokens_this_month') %}
|
|
|
|
<div class="llm-usage-grid">
|
|
<div class="llm-stat-card">
|
|
<div class="llm-stat-label">{{ _('This month') }}</div>
|
|
<div class="llm-stat-value">{{ '{:,}'.format(llm_stored.get('tokens_this_month', 0)) }}</div>
|
|
<div class="llm-stat-sub">{{ _('tokens') }}{% if llm_show_costs and llm_stored.get('cost_usd_this_month') %} · ≈ ${{ '%.4f'|format(llm_stored.get('cost_usd_this_month', 0)) }}{% endif %}</div>
|
|
{% if llm_token_budget_month %}
|
|
{% set pct = (llm_stored.get('tokens_this_month', 0) / llm_token_budget_month * 100)|int %}
|
|
<div class="llm-stat-bar-wrap">
|
|
<div class="llm-stat-bar-fill {% if pct >= 100 %}bar-over{% elif pct >= 80 %}bar-warn{% else %}bar-ok{% endif %}"
|
|
style="width:{{ [pct, 100]|min }}%"></div>
|
|
</div>
|
|
<div class="llm-stat-budget-text">{{ _('%(percent)s%% of %(budget)s', percent=pct, budget='{:,}'.format(llm_token_budget_month)) }}</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<div class="llm-stat-card">
|
|
<div class="llm-stat-label">{{ _('All-time total') }}</div>
|
|
<div class="llm-stat-value">{{ '{:,}'.format(llm_stored.get('tokens_total_cumulative', 0)) }}</div>
|
|
<div class="llm-stat-sub">{{ _('tokens') }}{% if llm_show_costs and llm_stored.get('cost_usd_total_cumulative') %} · ≈ ${{ '%.4f'|format(llm_stored.get('cost_usd_total_cumulative', 0)) }}{% endif %}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{% if llm_token_budget_month and llm_stored.get('tokens_this_month', 0) >= llm_token_budget_month %}
|
|
<p class="llm-budget-alert">⚠ {{ _('Monthly token budget reached. AI summarisation is paused until next month.') }}</p>
|
|
{% endif %}
|
|
|
|
<div class="llm-usage-settings">
|
|
<div class="llm-usage-row">
|
|
<span class="llm-usage-row-label">{{ _('Token budget this period') }}</span>
|
|
<span class="llm-usage-row-value">
|
|
{% if llm_token_budget_month_env %}
|
|
<strong>{{ '{:,}'.format(llm_token_budget_month_env) }}</strong>
|
|
<span class="llm-env-badge">{{ _('(set via <code>LLM_TOKEN_BUDGET_MONTH</code>)') | safe }}</span>
|
|
<input type="hidden" name="llm-token_budget_month" value="{{ llm_token_budget_month_env }}">
|
|
{% else %}
|
|
{{ form.llm.form.token_budget_month(placeholder=_('0 = unlimited'), value=llm_stored.get('token_budget_month', 0) or '') }}
|
|
<span class="llm-field-hint">{{ _('tokens (0 = unlimited)') }}</span>
|
|
{% endif %}
|
|
</span>
|
|
</div>
|
|
{% if llm_stored.get('tokens_month_key') %}
|
|
<div class="llm-usage-row">
|
|
<span class="llm-usage-row-label">{{ _('Current billing period') }}</span>
|
|
<span class="llm-usage-row-value">{{ llm_stored.get('tokens_month_key') }}</span>
|
|
</div>
|
|
{% endif %}
|
|
<div class="llm-usage-row">
|
|
<span class="llm-usage-row-label">{{ _('Max input characters') }}</span>
|
|
<span class="llm-usage-row-value">
|
|
{% if llm_max_input_chars_env %}
|
|
{{ form.llm.form.max_input_chars(value=llm_max_input_chars_env, readonly=True, style="width:10em;opacity:0.6;cursor:not-allowed;") }}
|
|
<span class="llm-env-badge">{{ _('(set via <code>LLM_MAX_INPUT_CHARS</code>)') | safe }}</span>
|
|
{% else %}
|
|
{{ form.llm.form.max_input_chars(placeholder='100000', value=llm_stored.get('max_input_chars', 100000) or '') }}
|
|
<span class="llm-field-hint">{{ _('characters — currently enforcing: %(limit)s', limit='{:,}'.format(llm_effective_max_input_chars)) }}</span>
|
|
{% endif %}
|
|
</span>
|
|
</div>
|
|
<div class="llm-usage-row">
|
|
<span class="llm-usage-row-label">{{ _('Max tokens per watch per period') }}</span>
|
|
<span class="llm-usage-row-value">
|
|
{{ form.llm.form.max_tokens_per_count_period(placeholder=_('0 = unlimited'), value=llm_stored.get('max_tokens_per_count_period', 0) or '') }}
|
|
<span class="llm-field-hint">{{ _('tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = unlimited)') }}</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{% else %}
|
|
<p class="llm-no-usage">{{ _('No AI usage recorded yet.') }}</p>
|
|
|
|
<div class="llm-usage-settings">
|
|
<div class="llm-usage-row">
|
|
<span class="llm-usage-row-label">{{ _('Token budget') }}</span>
|
|
<span class="llm-usage-row-value">
|
|
{% if llm_token_budget_month_env %}
|
|
<strong>{{ '{:,}'.format(llm_token_budget_month_env) }}</strong>
|
|
<span class="llm-env-badge">{{ _('(set via <code>LLM_TOKEN_BUDGET_MONTH</code>)') | safe }}</span>
|
|
<input type="hidden" name="llm-token_budget_month" value="{{ llm_token_budget_month_env }}">
|
|
{% else %}
|
|
{{ form.llm.form.token_budget_month(placeholder=_('0 = unlimited'), value=llm_stored.get('token_budget_month', 0) or '') }}
|
|
<span class="llm-field-hint">{{ _('tokens per month (0 = unlimited)') }}</span>
|
|
{% endif %}
|
|
</span>
|
|
</div>
|
|
<div class="llm-usage-row">
|
|
<span class="llm-usage-row-label">{{ _('Max input characters') }}</span>
|
|
<span class="llm-usage-row-value">
|
|
{% if llm_max_input_chars_env %}
|
|
{{ form.llm.form.max_input_chars(value=llm_max_input_chars_env, readonly=True, style="width:10em;opacity:0.6;cursor:not-allowed;") }}
|
|
<span class="llm-env-badge">{{ _('(set via <code>LLM_MAX_INPUT_CHARS</code>)') | safe }}</span>
|
|
{% else %}
|
|
{{ form.llm.form.max_input_chars(placeholder='100000', value=llm_stored.get('max_input_chars', 100000) or '') }}
|
|
<span class="llm-field-hint">{{ _('characters — currently enforcing: %(limit)s', limit='{:,}'.format(llm_effective_max_input_chars)) }}</span>
|
|
{% endif %}
|
|
</span>
|
|
</div>
|
|
<div class="llm-usage-row">
|
|
<span class="llm-usage-row-label">{{ _('Max tokens per watch per period') }}</span>
|
|
<span class="llm-usage-row-value">
|
|
{{ form.llm.form.max_tokens_per_count_period(placeholder=_('0 = unlimited'), value=llm_stored.get('max_tokens_per_count_period', 0) or '') }}
|
|
<span class="llm-field-hint">{{ _('tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = unlimited)') }}</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
{% endcall %}
|
|
|
|
{% endcall %}{# stab_shell #}
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
const LIVE_PROVIDERS = ['openai', 'anthropic', 'gemini', 'ollama', 'openai_compatible', 'openrouter'];
|
|
const BASE_DEFAULTS = { ollama: 'http://localhost:11434' };
|
|
const KEY_HINTS = {
|
|
openai: '{{ _("platform.openai.com → API keys") }}',
|
|
anthropic: '{{ _("console.anthropic.com → API keys") }}',
|
|
gemini: '{{ _("aistudio.google.com → Get API key") }}',
|
|
ollama: '{{ _("No API key needed for local Ollama") }}',
|
|
openai_compatible: '{{ _("Bearer token for your self-hosted server (vLLM, LM Studio, etc.)") }}',
|
|
openrouter: '{{ _("openrouter.ai → Keys") }}',
|
|
};
|
|
|
|
window.llmDisclaimerToggle = function (cb) {
|
|
const fields = document.getElementById('llm-provider-fields');
|
|
if (fields) fields.style.display = cb.checked ? '' : 'none';
|
|
};
|
|
|
|
window.llmOnProviderChange = function (provider) {
|
|
const fetchGroup = document.getElementById('llm-fetch-group');
|
|
const baseGroup = document.getElementById('llm-base-group');
|
|
const modelSelGrp = document.getElementById('llm-model-select-group');
|
|
const localAdvGrp = document.getElementById('llm-local-advanced-group');
|
|
const baseField = document.querySelector('[name="llm-api_base"]');
|
|
const kindField = document.querySelector('[name="llm-provider_kind"]');
|
|
const hint = document.getElementById('llm-key-hint');
|
|
|
|
fetchGroup.style.display = LIVE_PROVIDERS.includes(provider) ? '' : 'none';
|
|
|
|
const needsBase = provider === 'ollama' || provider === 'openai_compatible';
|
|
baseGroup.style.display = needsBase ? '' : 'none';
|
|
if (BASE_DEFAULTS[provider] !== undefined) {
|
|
if (!baseField.value) baseField.value = BASE_DEFAULTS[provider];
|
|
}
|
|
|
|
// Persist the dropdown selection so the backend can branch on provider kind
|
|
// (self-hosted endpoints — 'ollama' and 'openai_compatible' — trigger the
|
|
// local-multiplier code path; cloud providers do not).
|
|
if (kindField) kindField.value = provider || '';
|
|
|
|
// Show the local-endpoint advanced settings (token multiplier) for self-hosted
|
|
// endpoints. Cloud providers get the original tight caps and don't see this
|
|
// section at all.
|
|
if (localAdvGrp) localAdvGrp.style.display = (provider === 'ollama' || provider === 'openai_compatible') ? '' : 'none';
|
|
|
|
hint.textContent = KEY_HINTS[provider] || '';
|
|
modelSelGrp.style.display = 'none';
|
|
document.getElementById('llm-fetch-status').textContent = '';
|
|
};
|
|
|
|
window.llmFetchModels = async function () {
|
|
const provider = document.getElementById('llm-provider').value;
|
|
const apiKey = document.querySelector('[name="llm-api_key"]').value.trim();
|
|
const apiBase = document.querySelector('[name="llm-api_base"]').value.trim();
|
|
const btn = document.getElementById('llm-fetch-btn');
|
|
const statusEl = document.getElementById('llm-fetch-status');
|
|
const selGroup = document.getElementById('llm-model-select-group');
|
|
const modelSel = document.getElementById('llm-model-select');
|
|
|
|
if (!provider) { statusEl.textContent = '{{ _("Select a provider first.") }}'; return; }
|
|
|
|
btn.disabled = true;
|
|
btn.textContent = '⏳ {{ _("Loading…") }}';
|
|
statusEl.textContent = '';
|
|
|
|
const params = new URLSearchParams({ provider });
|
|
if (apiKey) params.set('api_key', apiKey);
|
|
if (apiBase) params.set('api_base', apiBase);
|
|
|
|
try {
|
|
const resp = await fetch('{{ url_for("settings.llm.llm_get_models") }}?' + params);
|
|
const data = await resp.json();
|
|
|
|
if (data.error) {
|
|
statusEl.style.color = '#c0392b';
|
|
statusEl.textContent = '✗ ' + data.error;
|
|
selGroup.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
if (!data.models || data.models.length === 0) {
|
|
statusEl.style.color = '#e67e22';
|
|
statusEl.textContent = '{{ _("No models returned by the provider.") }}';
|
|
selGroup.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
modelSel.innerHTML = '<option value="">{{ _("— choose a model —") }}</option>';
|
|
const currentModel = document.querySelector('[name="llm-model"]').value.trim();
|
|
for (const m of data.models) {
|
|
const opt = document.createElement('option');
|
|
opt.value = m;
|
|
opt.textContent = m;
|
|
if (m === currentModel) opt.selected = true;
|
|
modelSel.appendChild(opt);
|
|
}
|
|
|
|
selGroup.style.display = '';
|
|
statusEl.style.color = '#27ae60';
|
|
statusEl.textContent = '✓ ' + data.models.length + ' {{ _("models available with your key") }}';
|
|
} catch (e) {
|
|
statusEl.style.color = '#c0392b';
|
|
statusEl.textContent = '✗ {{ _("Request failed") }}: ' + e.message;
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = '↻ {{ _("Load available models") }}';
|
|
}
|
|
};
|
|
|
|
window.llmOnModelPick = function (value) {
|
|
if (value) document.querySelector('[name="llm-model"]').value = value;
|
|
};
|
|
|
|
window.llmRunTest = async function () {
|
|
const btn = document.getElementById('llm-test-btn');
|
|
const result = document.getElementById('llm-test-result');
|
|
if (!btn || !result) return;
|
|
|
|
btn.disabled = true;
|
|
btn.textContent = '⏳ {{ _("Testing…") }}';
|
|
result.style.display = 'none';
|
|
|
|
// Send the form's current values so the user doesn't have to hit Save before
|
|
// testing a config change. Endpoint falls back to the stored datastore values
|
|
// for any field we don't send.
|
|
const params = new URLSearchParams();
|
|
const model = (document.querySelector('[name="llm-model"]') || {}).value || '';
|
|
const apiKey = (document.querySelector('[name="llm-api_key"]') || {}).value || '';
|
|
const apiBase = (document.querySelector('[name="llm-api_base"]') || {}).value || '';
|
|
const kind = (document.querySelector('[name="llm-provider_kind"]') || {}).value || '';
|
|
const mult = (document.querySelector('[name="llm-local_token_multiplier"]') || {}).value || '';
|
|
if (model.trim()) params.set('model', model.trim());
|
|
if (apiKey.trim()) params.set('api_key', apiKey.trim());
|
|
if (apiBase.trim()) params.set('api_base', apiBase.trim());
|
|
if (kind.trim()) params.set('provider_kind', kind.trim());
|
|
if (mult.trim()) params.set('local_token_multiplier', mult.trim());
|
|
|
|
try {
|
|
const resp = await fetch('{{ url_for("settings.llm.llm_test") }}?' + params, {
|
|
method: 'POST',
|
|
headers: {'X-CSRFToken': csrftoken}
|
|
});
|
|
const data = await resp.json();
|
|
if (data.ok) {
|
|
result.style.cssText = 'display:block; background:rgba(39,174,96,0.08); border:1px solid rgba(39,174,96,0.3); border-radius:5px; padding:0.6em 0.85em; font-size:0.88em; line-height:1.45;';
|
|
result.innerHTML = '<span style="color:#27ae60; font-weight:600;">✓ {{ _("Connected") }}</span>'
|
|
+ (data.tokens ? ' <span style="opacity:0.55; font-size:0.9em;">(' + data.tokens + ' {{ _("tokens") }})</span>' : '')
|
|
+ '<br><em style="opacity:0.75;">' + data.text.replace(/</g,'<') + '</em>';
|
|
} else {
|
|
result.style.cssText = 'display:block; background:rgba(192,57,43,0.07); border:1px solid rgba(192,57,43,0.25); border-radius:5px; padding:0.6em 0.85em; font-size:0.88em; line-height:1.45;';
|
|
result.innerHTML = '<span style="color:#c0392b; font-weight:600;">✗ {{ _("Failed") }}</span><br><code style="font-size:0.92em; word-break:break-all;">' + (data.error || '').replace(/</g,'<') + '</code>';
|
|
}
|
|
} catch (e) {
|
|
result.style.cssText = 'display:block; background:rgba(192,57,43,0.07); border:1px solid rgba(192,57,43,0.25); border-radius:5px; padding:0.6em 0.85em; font-size:0.88em;';
|
|
result.innerHTML = '<span style="color:#c0392b; font-weight:600;">✗ {{ _("Request failed") }}</span>: ' + e.message.replace(/</g,'<');
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = '▶ {{ _("Test connection") }}';
|
|
}
|
|
};
|
|
|
|
// On page load: detect and pre-select provider from current model
|
|
(function detectCurrentProvider() {
|
|
const modelField = document.querySelector('[name="llm-model"]');
|
|
if (!modelField) return;
|
|
const m = modelField.value.trim();
|
|
if (!m) return;
|
|
|
|
let guessed = '';
|
|
if (m.startsWith('gemini/')) guessed = 'gemini';
|
|
else if (m.startsWith('ollama/')) guessed = 'ollama';
|
|
else if (m.startsWith('openrouter/')) guessed = 'openrouter';
|
|
else if (m.startsWith('openai/')) {
|
|
// openai/<model> + custom api_base = self-hosted OpenAI-compatible (vLLM etc.)
|
|
const baseField = document.querySelector('[name="llm-api_base"]');
|
|
guessed = (baseField && baseField.value.trim()) ? 'openai_compatible' : 'openai';
|
|
}
|
|
else if (m.startsWith('claude')) guessed = 'anthropic';
|
|
else if (m.startsWith('gpt') || m.startsWith('o1') || m.startsWith('o3')) guessed = 'openai';
|
|
|
|
if (guessed) {
|
|
const sel = document.getElementById('llm-provider');
|
|
if (sel) { sel.value = guessed; llmOnProviderChange(guessed); }
|
|
}
|
|
})();
|
|
}());
|
|
</script>
|