mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-05-04 00:30:53 +00:00
WIP
This commit is contained in:
@@ -45,6 +45,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
extra_notification_tokens=datastore.get_unique_notification_tokens_available()
|
||||
)
|
||||
|
||||
|
||||
# Remove the last option 'System default'
|
||||
form.application.form.notification_format.choices.pop()
|
||||
|
||||
@@ -76,15 +77,6 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
|
||||
datastore.data['settings']['application'].update(app_update)
|
||||
|
||||
# Persist LLM connections (JSON blob from the JS-managed hidden field)
|
||||
import json as _json
|
||||
llm_connections_raw = request.form.get('llm_connections', '')
|
||||
if llm_connections_raw:
|
||||
try:
|
||||
datastore.data['settings']['application']['llm_connections'] = _json.loads(llm_connections_raw)
|
||||
except (ValueError, TypeError):
|
||||
flash(gettext("Invalid LLM connections data."), 'error')
|
||||
|
||||
# Handle dynamic worker count adjustment
|
||||
old_worker_count = datastore.data['settings']['requests'].get('workers', 1)
|
||||
new_worker_count = form.data['requests'].get('workers', 1)
|
||||
@@ -138,8 +130,12 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
# Instantiate plugin form with POST data
|
||||
plugin_form = form_class(formdata=request.form)
|
||||
|
||||
# Save plugin settings (validation is optional for plugins)
|
||||
if plugin_form.data:
|
||||
# Save plugin settings — use plugin's own save_fn if provided
|
||||
# (allows plugins to strip ephemeral staging fields etc.)
|
||||
save_fn = tab.get('save_fn')
|
||||
if save_fn:
|
||||
save_fn(datastore, plugin_form)
|
||||
elif plugin_form.data:
|
||||
save_plugin_settings(datastore.datastore_path, plugin_id, plugin_form.data)
|
||||
|
||||
flash(gettext("Settings updated."))
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
<script>
|
||||
var LLM_CONNECTIONS = {{ settings_application.get('llm_connections', {})|tojson }};
|
||||
var LLM_I18N = {
|
||||
noConnections: '{{ _("No connections configured yet.") }}',
|
||||
setDefault: '{{ _("Set as default") }}',
|
||||
remove: '{{ _("Remove") }}',
|
||||
show: '{{ _("show") }}',
|
||||
hide: '{{ _("hide") }}',
|
||||
nameModelRequired: '{{ _("Name and Model string are required.") }}'
|
||||
};
|
||||
</script>
|
||||
|
||||
{# ── Configured connections table ──────────────────── #}
|
||||
<fieldset>
|
||||
<legend>{{ _('LLM Connections') }}</legend>
|
||||
|
||||
<table class="pure-table pure-table-horizontal llm-connections">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="llm-col-def" title="{{ _('Default') }}">{{ _('Default') }}</th>
|
||||
<th class="llm-col-name">{{ _('Name') }}</th>
|
||||
<th class="llm-col-model">{{ _('Model') }}</th>
|
||||
<th class="llm-col-key">{{ _('API Key') }}</th>
|
||||
<th class="llm-col-tpm" title="{{ _('Tokens per minute limit (0 = unlimited)') }}">{{ _('TPM') }}</th>
|
||||
<th class="llm-col-del"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="llm-connections-tbody">
|
||||
</tbody>
|
||||
</table>
|
||||
</fieldset>
|
||||
|
||||
{# ── Add connection ─────────────────────────────────── #}
|
||||
<fieldset>
|
||||
<legend>{{ _('Add a connection') }}</legend>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label for="llm-preset">{{ _('Provider template') }}</label>
|
||||
<select id="llm-preset" class="pure-input-1-2">
|
||||
<option value="">— {{ _('select a provider template') }} —</option>
|
||||
<optgroup label="{{ _('Cloud') }}">
|
||||
<option value="openai-mini">OpenAI — gpt-4o-mini</option>
|
||||
<option value="openai-4o">OpenAI — gpt-4o</option>
|
||||
<option value="anthropic-haiku">Anthropic — claude-3-haiku</option>
|
||||
<option value="anthropic-sonnet">Anthropic — claude-3-5-sonnet</option>
|
||||
<option value="groq-8b">Groq — llama-3.1-8b-instant</option>
|
||||
<option value="groq-70b">Groq — llama-3.3-70b-versatile</option>
|
||||
<option value="gemini-flash">Google — gemini-1.5-flash</option>
|
||||
<option value="mistral-small">Mistral — mistral-small</option>
|
||||
<option value="deepseek">DeepSeek — deepseek-chat</option>
|
||||
<option value="openrouter">OpenRouter (custom model)</option>
|
||||
</optgroup>
|
||||
<optgroup label="{{ _('Local') }}">
|
||||
<option value="ollama-llama">Ollama — llama3.1</option>
|
||||
<option value="ollama-mistral">Ollama — mistral</option>
|
||||
<option value="lmstudio">LM Studio</option>
|
||||
</optgroup>
|
||||
<optgroup label="{{ _('Custom') }}">
|
||||
<option value="custom">{{ _('Manual entry') }}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="pure-g">
|
||||
<div class="pure-u-1 pure-u-md-1-2">
|
||||
<div class="pure-control-group">
|
||||
<label for="llm-add-name">{{ _('Name') }}</label>
|
||||
<input type="text" id="llm-add-name" class="pure-input-1"
|
||||
placeholder="{{ _('e.g. My OpenAI') }}" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-u-1 pure-u-md-1-2">
|
||||
<div class="pure-control-group">
|
||||
<label for="llm-add-model">{{ _('Model string') }}</label>
|
||||
<input type="text" id="llm-add-model" class="pure-input-1"
|
||||
placeholder="gpt-4o-mini" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-u-1 pure-u-md-1-2">
|
||||
<div class="pure-control-group">
|
||||
<label for="llm-add-key">
|
||||
{{ _('API Key') }}
|
||||
<span class="pure-form-message-inline">({{ _('leave blank for local') }})</span>
|
||||
</label>
|
||||
<div class="llm-key-wrap">
|
||||
<input type="password" id="llm-add-key" class="pure-input-1"
|
||||
placeholder="sk-…" autocomplete="off">
|
||||
<button type="button" id="llm-key-toggle" class="pure-button">{{ _('show') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-u-1 pure-u-md-1-2">
|
||||
<div class="pure-control-group">
|
||||
<label for="llm-add-base">
|
||||
{{ _('Base URL') }}
|
||||
<span class="pure-form-message-inline">({{ _('optional') }})</span>
|
||||
</label>
|
||||
<input type="text" id="llm-add-base" class="pure-input-1"
|
||||
placeholder="http://localhost:11434" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-u-1 pure-u-md-1-2">
|
||||
<div class="pure-control-group">
|
||||
<label for="llm-add-tpm">
|
||||
{{ _('Tokens/min limit') }}
|
||||
<span class="pure-form-message-inline">({{ _('0 = unlimited') }})</span>
|
||||
</label>
|
||||
<input type="number" id="llm-add-tpm" class="pure-input-1"
|
||||
min="0" step="1000" value="0" placeholder="0">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pure-controls">
|
||||
<button type="button" id="llm-btn-add" class="pure-button pure-button-primary">{{ _('+ Add connection') }}</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{# Hidden field carries the JSON to the backend #}
|
||||
<input type="hidden" name="llm_connections" id="llm-connections-json">
|
||||
@@ -15,7 +15,6 @@
|
||||
<script src="{{url_for('static_content', group='js', filename='vis.js')}}" defer></script>
|
||||
<script src="{{url_for('static_content', group='js', filename='global-settings.js')}}" defer></script>
|
||||
<script src="{{url_for('static_content', group='js', filename='scheduler.js')}}" defer></script>
|
||||
<script src="{{url_for('static_content', group='js', filename='llm.js')}}" defer></script>
|
||||
<div class="edit-form">
|
||||
<div class="tabs collapsable">
|
||||
<ul>
|
||||
@@ -28,26 +27,7 @@
|
||||
<li class="tab"><a href="#rss">{{ _('RSS') }}</a></li>
|
||||
<li class="tab"><a href="{{ url_for('backups.create') }}">{{ _('Backups') }}</a></li>
|
||||
<li class="tab"><a href="#timedate">{{ _('Time & Date') }}</a></li>
|
||||
<li class="tab"><a href="#llm">{{ _('LLM') }} <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="18" height="18" style="vertical-align:middle;margin-right:3px;margin-bottom:2px" aria-hidden="true">
|
||||
<!-- connection lines, drawn first so nodes sit on top -->
|
||||
<line x1="3" y1="4.5" x2="9.5" y2="7.5" stroke="currentColor" stroke-width="0.75" opacity="0.4"/>
|
||||
<line x1="3" y1="4.5" x2="9.5" y2="12.5" stroke="currentColor" stroke-width="0.75" opacity="0.28"/>
|
||||
<line x1="3" y1="10" x2="9.5" y2="7.5" stroke="currentColor" stroke-width="0.75" opacity="0.65"/>
|
||||
<line x1="3" y1="10" x2="9.5" y2="12.5" stroke="currentColor" stroke-width="0.75" opacity="0.65"/>
|
||||
<line x1="3" y1="15.5" x2="9.5" y2="7.5" stroke="currentColor" stroke-width="0.75" opacity="0.28"/>
|
||||
<line x1="3" y1="15.5" x2="9.5" y2="12.5" stroke="currentColor" stroke-width="0.75" opacity="0.4"/>
|
||||
<line x1="11" y1="7.5" x2="17" y2="10" stroke="currentColor" stroke-width="0.9" opacity="0.7"/>
|
||||
<line x1="11" y1="12.5" x2="17" y2="10" stroke="currentColor" stroke-width="0.9" opacity="0.7"/>
|
||||
<!-- input nodes -->
|
||||
<circle cx="3" cy="4.5" r="1.35" fill="currentColor" opacity="0.6"/>
|
||||
<circle cx="3" cy="10" r="1.35" fill="currentColor" opacity="0.9"/>
|
||||
<circle cx="3" cy="15.5" r="1.35" fill="currentColor" opacity="0.6"/>
|
||||
<!-- hidden layer -->
|
||||
<circle cx="10.25" cy="7.5" r="1.55" fill="currentColor"/>
|
||||
<circle cx="10.25" cy="12.5" r="1.55" fill="currentColor"/>
|
||||
<!-- output node — slightly larger, full opacity -->
|
||||
<circle cx="17" cy="10" r="1.75" fill="currentColor"/>
|
||||
</svg></a></li>
|
||||
|
||||
<li class="tab"><a href="#proxies">{{ _('CAPTCHA & Proxies') }}</a></li>
|
||||
{% if plugin_tabs %}
|
||||
{% for tab in plugin_tabs %}
|
||||
@@ -329,9 +309,7 @@ nav
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="tab-pane-inner" id="llm">
|
||||
{% include 'settings-llm.html' %}
|
||||
</div>
|
||||
|
||||
<div class="tab-pane-inner" id="proxies">
|
||||
<div id="recommended-proxy">
|
||||
<div>
|
||||
|
||||
@@ -984,6 +984,9 @@ def changedetection_app(config=None, datastore_o=None):
|
||||
from changedetectionio.pluggy_interface import plugin_manager
|
||||
plugin_manager.register(LLMQueuePlugin(llm_summary_q), 'llm_queue_plugin')
|
||||
|
||||
# Re-run template path configuration now that all plugins (including LLM) are registered
|
||||
_configure_plugin_templates()
|
||||
|
||||
in_pytest = "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ
|
||||
# Check for new release version, but not when running in test/build or pytest
|
||||
if not os.getenv("GITHUB_REF", False) and not strtobool(os.getenv('DISABLE_VERSION_CHECK', 'no')) and not in_pytest:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
LLM queue plugin — enqueues an LLM summary job whenever a change is detected.
|
||||
LLM plugin — provides settings tab and enqueues summary jobs on change detection.
|
||||
|
||||
Registered with the pluggy plugin manager at startup (flask_app.py).
|
||||
The worker (llm/queue_worker.py) drains the queue asynchronously.
|
||||
@@ -8,18 +8,74 @@ from loguru import logger
|
||||
from changedetectionio.pluggy_interface import hookimpl
|
||||
|
||||
|
||||
def get_llm_settings(datastore):
|
||||
"""Load LLM plugin settings with fallback to legacy datastore settings.
|
||||
|
||||
Tries the plugin settings file (llm.json) first.
|
||||
Falls back to the old storage location in datastore.data['settings']['application']
|
||||
for users upgrading from a version before LLM became a first-class plugin.
|
||||
"""
|
||||
from changedetectionio.pluggy_interface import load_plugin_settings
|
||||
settings = load_plugin_settings(datastore.datastore_path, 'llm')
|
||||
|
||||
if settings.get('llm_connection') is not None:
|
||||
return settings
|
||||
|
||||
# Legacy fallback: settings were stored in datastore application settings
|
||||
app_settings = datastore.data['settings']['application']
|
||||
connections_dict = app_settings.get('llm_connections') or {}
|
||||
connections_list = [
|
||||
{
|
||||
'connection_id': k,
|
||||
'name': v.get('name', ''),
|
||||
'model': v.get('model', ''),
|
||||
'api_key': v.get('api_key', ''),
|
||||
'api_base': v.get('api_base', ''),
|
||||
'tokens_per_minute': int(v.get('tokens_per_minute', 0) or 0),
|
||||
'is_default': bool(v.get('is_default', False)),
|
||||
}
|
||||
for k, v in connections_dict.items()
|
||||
]
|
||||
|
||||
return {
|
||||
'llm_connection': connections_list,
|
||||
'llm_summary_prompt': app_settings.get('llm_summary_prompt', ''),
|
||||
}
|
||||
|
||||
|
||||
def save_llm_settings(datastore, plugin_form):
|
||||
"""Custom save handler — strips the ephemeral new_connection staging fields
|
||||
so they are never persisted to llm.json."""
|
||||
from changedetectionio.pluggy_interface import save_plugin_settings
|
||||
data = {
|
||||
'llm_connection': plugin_form.llm_connection.data,
|
||||
'llm_summary_prompt': plugin_form.llm_summary_prompt.data or '',
|
||||
}
|
||||
save_plugin_settings(datastore.datastore_path, 'llm', data)
|
||||
|
||||
|
||||
class LLMQueuePlugin:
|
||||
"""Enqueues LLM summary jobs on successful change detection."""
|
||||
"""Enqueues LLM summary jobs on successful change detection and provides settings tab."""
|
||||
|
||||
def __init__(self, llm_q):
|
||||
self.llm_q = llm_q
|
||||
|
||||
@hookimpl
|
||||
def plugin_settings_tab(self):
|
||||
from changedetectionio.llm.settings_form import LLMSettingsForm
|
||||
return {
|
||||
'plugin_id': 'llm',
|
||||
'tab_label': 'LLM',
|
||||
'form_class': LLMSettingsForm,
|
||||
'template_path': 'settings-llm.html',
|
||||
'save_fn': save_llm_settings,
|
||||
}
|
||||
|
||||
@hookimpl
|
||||
def update_finalize(self, update_handler, watch, datastore, processing_exception,
|
||||
changed_detected=False, snapshot_id=None):
|
||||
"""Queue an LLM summary job when a change was successfully detected."""
|
||||
|
||||
# Only act on successful changes with a known snapshot
|
||||
if not changed_detected or processing_exception or not snapshot_id:
|
||||
return
|
||||
|
||||
@@ -31,11 +87,11 @@ class LLMQueuePlugin:
|
||||
return
|
||||
|
||||
# Only queue when at least one LLM connection is configured
|
||||
app_settings = datastore.data['settings']['application']
|
||||
has_connection = (
|
||||
app_settings.get('llm_connections')
|
||||
or app_settings.get('llm_api_key')
|
||||
or app_settings.get('llm_model')
|
||||
llm_settings = get_llm_settings(datastore)
|
||||
has_connection = bool(
|
||||
llm_settings.get('llm_connection')
|
||||
or datastore.data['settings']['application'].get('llm_api_key') # legacy
|
||||
or datastore.data['settings']['application'].get('llm_model') # legacy
|
||||
or watch.get('llm_api_key')
|
||||
or watch.get('llm_model')
|
||||
)
|
||||
|
||||
@@ -163,32 +163,35 @@ def _resolve_llm_connection(watch, datastore):
|
||||
"""Return (model, api_key, api_base, conn_id, tpm) for the given watch.
|
||||
|
||||
Resolution order:
|
||||
1. Watch-level connection ID (``watch['llm_connection_id']``) pointing to a
|
||||
named entry in ``settings.application.llm_connections``.
|
||||
2. The default entry in ``settings.application.llm_connections`` (``is_default=True``).
|
||||
3. Legacy flat fields (``llm_model`` / ``llm_api_key`` / ``llm_api_base``) on
|
||||
the watch or in global settings — kept for backward compatibility.
|
||||
4. Hard-coded fallback: ``gpt-4o-mini`` with no key / base.
|
||||
|
||||
``tpm`` is tokens-per-minute for the proactive rate limiter; 0 means unlimited.
|
||||
1. Watch-level connection_id pointing to a named entry in plugin settings.
|
||||
2. The default entry in plugin settings (is_default=True).
|
||||
3. Legacy flat fields on the watch or in global settings — backward compat.
|
||||
4. Hard-coded fallback: gpt-4o-mini with no key / base.
|
||||
"""
|
||||
app_settings = datastore.data['settings']['application']
|
||||
connections = app_settings.get('llm_connections') or {}
|
||||
from changedetectionio.llm.plugin import get_llm_settings
|
||||
from changedetectionio.llm.settings_form import sanitised_conn_id
|
||||
|
||||
# 1. Watch-level override by explicit connection UUID
|
||||
conn_id = watch.get('llm_connection_id')
|
||||
if conn_id and conn_id in connections:
|
||||
c = connections[conn_id]
|
||||
return (c.get('model', 'gpt-4o-mini'), c.get('api_key', ''), c.get('api_base', ''),
|
||||
conn_id, int(c.get('tokens_per_minute', 0) or 0))
|
||||
llm_settings = get_llm_settings(datastore)
|
||||
connections = llm_settings.get('llm_connection') or []
|
||||
|
||||
# 1. Watch-level override by explicit connection_id
|
||||
watch_conn_id = watch.get('llm_connection_id')
|
||||
if watch_conn_id:
|
||||
for c in connections:
|
||||
if c.get('connection_id') == watch_conn_id:
|
||||
cid = sanitised_conn_id(c.get('connection_id', ''))
|
||||
return (c.get('model', 'gpt-4o-mini'), c.get('api_key', ''), c.get('api_base', ''),
|
||||
cid, int(c.get('tokens_per_minute', 0) or 0))
|
||||
|
||||
# 2. Global default connection
|
||||
for cid, c in connections.items():
|
||||
for c in connections:
|
||||
if c.get('is_default'):
|
||||
cid = sanitised_conn_id(c.get('connection_id', ''))
|
||||
return (c.get('model', 'gpt-4o-mini'), c.get('api_key', ''), c.get('api_base', ''),
|
||||
cid, int(c.get('tokens_per_minute', 0) or 0))
|
||||
|
||||
# 3. Legacy flat fields (backward compat)
|
||||
app_settings = datastore.data['settings']['application']
|
||||
model = watch.get('llm_model') or app_settings.get('llm_model', 'gpt-4o-mini')
|
||||
api_key = watch.get('llm_api_key') or app_settings.get('llm_api_key', '')
|
||||
api_base = watch.get('llm_api_base') or app_settings.get('llm_api_base', '')
|
||||
@@ -242,11 +245,16 @@ def _enumerate_changes(diff_text, url, model, llm_kwargs):
|
||||
return _call_llm(model=model, messages=messages, max_tokens=1200, **llm_kwargs)
|
||||
|
||||
|
||||
def _summarise_enumeration(enumerated, url, model, llm_kwargs):
|
||||
def _summarise_enumeration(enumerated, url, model, llm_kwargs, summary_instruction=None):
|
||||
"""
|
||||
Pass 2 — compress the exhaustive enumeration into structured JSON output.
|
||||
Pass 2 — compress the exhaustive enumeration into the final output.
|
||||
Operates on a small, structured input so nothing is lost that wasn't already listed.
|
||||
summary_instruction overrides the default STRUCTURED_OUTPUT_INSTRUCTION when set.
|
||||
"""
|
||||
instruction = summary_instruction or (
|
||||
"Now produce the final structured output for all of these changes.\n\n"
|
||||
+ STRUCTURED_OUTPUT_INSTRUCTION
|
||||
)
|
||||
messages = [
|
||||
{'role': 'system', 'content': SYSTEM_PROMPT},
|
||||
{
|
||||
@@ -254,8 +262,7 @@ def _summarise_enumeration(enumerated, url, model, llm_kwargs):
|
||||
'content': (
|
||||
f"URL: {url}\n"
|
||||
f"All changes detected:\n{enumerated}\n\n"
|
||||
"Now produce the final structured output for all of these changes.\n\n"
|
||||
+ STRUCTURED_OUTPUT_INSTRUCTION
|
||||
+ instruction
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -337,6 +344,14 @@ def process_llm_summary(item, datastore):
|
||||
if tpm:
|
||||
llm_kwargs['tpm'] = tpm
|
||||
|
||||
# Use custom prompt if configured, otherwise fall back to the built-in default
|
||||
from changedetectionio.llm.plugin import get_llm_settings
|
||||
llm_settings = get_llm_settings(datastore)
|
||||
custom_prompt = (llm_settings.get('llm_summary_prompt') or '').strip()
|
||||
summary_instruction = custom_prompt if custom_prompt else (
|
||||
"Analyse all changes in this diff.\n\n" + STRUCTURED_OUTPUT_INSTRUCTION
|
||||
)
|
||||
|
||||
diff_tokens = litellm.token_counter(model=model, text=diff_text)
|
||||
logger.debug(f"LLM: diff is {diff_tokens} tokens for {uuid}/{snapshot_id}")
|
||||
|
||||
@@ -349,8 +364,7 @@ def process_llm_summary(item, datastore):
|
||||
'content': (
|
||||
f"URL: {url}\n"
|
||||
f"Diff:\n{diff_text}\n\n"
|
||||
"Analyse all changes in this diff.\n\n"
|
||||
+ STRUCTURED_OUTPUT_INSTRUCTION
|
||||
+ summary_instruction
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -358,13 +372,13 @@ def process_llm_summary(item, datastore):
|
||||
strategy = 'single'
|
||||
|
||||
elif diff_tokens < TOKEN_TWO_PASS_THRESHOLD:
|
||||
# Medium diff — two-pass: enumerate exhaustively, then compress to JSON
|
||||
# Medium diff — two-pass: enumerate exhaustively, then compress
|
||||
enumerated = _enumerate_changes(diff_text, url, model, llm_kwargs)
|
||||
raw = _summarise_enumeration(enumerated, url, model, llm_kwargs)
|
||||
raw = _summarise_enumeration(enumerated, url, model, llm_kwargs, summary_instruction)
|
||||
strategy = 'two-pass'
|
||||
|
||||
else:
|
||||
# Large diff — map-reduce: chunk → enumerate per chunk → synthesise to JSON
|
||||
# Large diff — map-reduce: chunk → enumerate per chunk → synthesise
|
||||
chunks = _chunk_lines(diff_lines, model, TOKEN_CHUNK_SIZE)
|
||||
logger.debug(f"LLM: map-reduce over {len(chunks)} chunks for {uuid}/{snapshot_id}")
|
||||
|
||||
@@ -376,7 +390,7 @@ def process_llm_summary(item, datastore):
|
||||
)
|
||||
|
||||
combined = '\n'.join(chunk_enumerations)
|
||||
raw = _summarise_enumeration(combined, url, model, llm_kwargs)
|
||||
raw = _summarise_enumeration(combined, url, model, llm_kwargs, summary_instruction)
|
||||
strategy = 'map-reduce'
|
||||
|
||||
llm_data = parse_llm_response(raw)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import re
|
||||
import uuid as _uuid
|
||||
|
||||
from flask_babel import lazy_gettext as _l
|
||||
from wtforms import (
|
||||
BooleanField,
|
||||
FieldList,
|
||||
Form,
|
||||
FormField,
|
||||
HiddenField,
|
||||
IntegerField,
|
||||
PasswordField,
|
||||
SelectField,
|
||||
StringField,
|
||||
TextAreaField,
|
||||
)
|
||||
from wtforms.validators import Length, NumberRange, Optional
|
||||
|
||||
from changedetectionio.llm.tokens import STRUCTURED_OUTPUT_INSTRUCTION
|
||||
|
||||
# The built-in instruction appended after the diff — shown as placeholder text.
|
||||
DEFAULT_SUMMARY_PROMPT = (
|
||||
"Analyse all changes in this diff.\n\n"
|
||||
+ STRUCTURED_OUTPUT_INSTRUCTION
|
||||
)
|
||||
|
||||
# Allowed characters for a connection ID coming from the browser.
|
||||
_CONN_ID_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
||||
|
||||
|
||||
def sanitised_conn_id(raw):
|
||||
"""Return raw if it looks like a safe identifier, otherwise a fresh UUID."""
|
||||
s = (raw or '').strip()
|
||||
return s if _CONN_ID_RE.match(s) else str(_uuid.uuid4())
|
||||
|
||||
|
||||
class LLMConnectionEntryForm(Form):
|
||||
"""Schema for a single LLM connection.
|
||||
|
||||
Declaring every field here is what prevents arbitrary key injection:
|
||||
only these fields can ever reach the datastore from this form.
|
||||
"""
|
||||
connection_id = HiddenField()
|
||||
name = StringField(_l('Name'), validators=[Optional(), Length(max=100)])
|
||||
model = StringField(_l('Model string'), validators=[Optional(), Length(max=200)])
|
||||
api_key = StringField(_l('API Key'), validators=[Optional(), Length(max=500)])
|
||||
api_base = StringField(_l('Base URL'), validators=[Optional(), Length(max=500)])
|
||||
tokens_per_minute = IntegerField(_l('Tokens/min'), validators=[Optional(), NumberRange(min=0, max=10_000_000)], default=0)
|
||||
is_default = BooleanField(_l('Default'), validators=[Optional()])
|
||||
|
||||
|
||||
class LLMNewConnectionForm(Form):
|
||||
"""Staging fields for the 'Add a connection' UI.
|
||||
|
||||
These are read client-side by llm.js to build a new FieldList entry on click.
|
||||
They are never used server-side — render_kw sets the id attributes llm.js
|
||||
looks up with $('#llm-add-name') etc.
|
||||
"""
|
||||
preset = SelectField(
|
||||
_l('Provider template'),
|
||||
validate_choice=False,
|
||||
# WTForms 3.x uses a dict for optgroups (has_groups() checks isinstance(choices, dict)).
|
||||
# An empty-string key renders as <optgroup label=""> which browsers treat as ungrouped.
|
||||
choices={
|
||||
'': [('', '')],
|
||||
_l('Cloud'): [
|
||||
('openai-mini', 'OpenAI — gpt-4o-mini'),
|
||||
('openai-4o', 'OpenAI — gpt-4o'),
|
||||
('anthropic-haiku', 'Anthropic — claude-3-haiku'),
|
||||
('anthropic-sonnet', 'Anthropic — claude-3-5-sonnet'),
|
||||
('groq-8b', 'Groq — llama-3.1-8b-instant'),
|
||||
('groq-70b', 'Groq — llama-3.3-70b-versatile'),
|
||||
('gemini-flash', 'Google — gemini-1.5-flash'),
|
||||
('mistral-small', 'Mistral — mistral-small'),
|
||||
('deepseek', 'DeepSeek — deepseek-chat'),
|
||||
('openrouter', 'OpenRouter (custom model)'),
|
||||
],
|
||||
_l('Local'): [
|
||||
('ollama-llama', 'Ollama — llama3.1'),
|
||||
('ollama-mistral', 'Ollama — mistral'),
|
||||
('lmstudio', 'LM Studio'),
|
||||
],
|
||||
_l('Custom'): [
|
||||
('custom', _l('Manual entry')),
|
||||
],
|
||||
},
|
||||
render_kw={'id': 'llm-preset'},
|
||||
)
|
||||
name = StringField(_l('Name'),
|
||||
render_kw={'id': 'llm-add-name', 'size': 30,
|
||||
'autocomplete': 'off'})
|
||||
model = StringField(_l('Model string'),
|
||||
render_kw={'id': 'llm-add-model', 'size': 40,
|
||||
'placeholder': 'gpt-4o-mini', 'autocomplete': 'off'})
|
||||
api_key = PasswordField(_l('API Key'),
|
||||
render_kw={'id': 'llm-add-key', 'size': 40,
|
||||
'placeholder': 'sk-…', 'autocomplete': 'off'})
|
||||
api_base = StringField(_l('Base URL'),
|
||||
render_kw={'id': 'llm-add-base', 'size': 40,
|
||||
'placeholder': 'http://localhost:11434', 'autocomplete': 'off'})
|
||||
tokens_per_minute = IntegerField(_l('Tokens/min'), default=0,
|
||||
render_kw={'id': 'llm-add-tpm', 'style': 'width: 8em;',
|
||||
'min': '0', 'step': '1000'})
|
||||
|
||||
|
||||
class LLMSettingsForm(Form):
|
||||
"""WTForms form for the LLM settings tab.
|
||||
|
||||
llm_connection is a FieldList of LLMConnectionEntryForm entries.
|
||||
llm.js emits individual hidden inputs (llm_connection-N-fieldname) on submit
|
||||
instead of a JSON blob, so WTForms processes them through the declared schema.
|
||||
"""
|
||||
llm_connection = FieldList(FormField(LLMConnectionEntryForm), min_entries=0)
|
||||
new_connection = FormField(LLMNewConnectionForm)
|
||||
|
||||
llm_summary_prompt = TextAreaField(
|
||||
_l('Summary prompt'),
|
||||
validators=[Optional()],
|
||||
description=_l(
|
||||
'Override the instruction sent to the LLM after the diff. '
|
||||
'Leave blank to use the built-in default (structured JSON output).'
|
||||
),
|
||||
render_kw={
|
||||
'rows': 8,
|
||||
'placeholder': DEFAULT_SUMMARY_PROMPT,
|
||||
'class': 'pure-input-1',
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,96 @@
|
||||
<script src="{{url_for('static_content', group='js', filename='llm.js')}}" defer></script>
|
||||
<script>
|
||||
var LLM_CONNECTIONS = (function () {
|
||||
var list = {{ plugin_form.llm_connection.data|tojson }};
|
||||
var out = {};
|
||||
(list || []).forEach(function (c) { if (c && c.connection_id) out[c.connection_id] = c; });
|
||||
return out;
|
||||
}());
|
||||
var LLM_I18N = {
|
||||
noConnections: '{{ _("No connections configured yet.") }}',
|
||||
setDefault: '{{ _("Set as default") }}',
|
||||
remove: '{{ _("Remove") }}',
|
||||
show: '{{ _("show") }}',
|
||||
hide: '{{ _("hide") }}',
|
||||
nameModelRequired: '{{ _("Name and Model string are required.") }}'
|
||||
};
|
||||
</script>
|
||||
|
||||
{# ── Configured connections table ──────────────────── #}
|
||||
<fieldset>
|
||||
<legend>{{ _('LLM Connections') }}</legend>
|
||||
|
||||
<table class="pure-table pure-table-horizontal llm-connections">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="llm-col-def" title="{{ _('Default') }}">{{ _('Default') }}</th>
|
||||
<th class="llm-col-name">{{ _('Name') }}</th>
|
||||
<th class="llm-col-model">{{ _('Model') }}</th>
|
||||
<th class="llm-col-key">{{ _('API Key') }}</th>
|
||||
<th class="llm-col-tpm" title="{{ _('Tokens per minute limit (0 = unlimited)') }}">{{ _('TPM') }}</th>
|
||||
<th class="llm-col-del"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="llm-connections-tbody">
|
||||
</tbody>
|
||||
</table>
|
||||
</fieldset>
|
||||
|
||||
{# ── Add connection ─────────────────────────────────── #}
|
||||
{% set nf = plugin_form.new_connection.form %}
|
||||
<fieldset>
|
||||
<legend>{{ _('Add a connection') }}</legend>
|
||||
|
||||
<div class="pure-control-group">
|
||||
{{ nf.preset.label }}
|
||||
{{ nf.preset() }}
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
{{ nf.name.label }}
|
||||
{{ nf.name(placeholder=_('e.g. My OpenAI')) }}
|
||||
</div>
|
||||
<div class="pure-control-group">
|
||||
{{ nf.model.label }}
|
||||
{{ nf.model() }}
|
||||
</div>
|
||||
<div class="pure-control-group">
|
||||
<label for="llm-add-key">
|
||||
{{ _('API Key') }}
|
||||
<span class="pure-form-message-inline">({{ _('leave blank for local') }})</span>
|
||||
</label>
|
||||
<div class="llm-key-wrap">
|
||||
{{ nf.api_key() }}
|
||||
<button type="button" id="llm-key-toggle" class="pure-button">{{ _('show') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-control-group">
|
||||
<label for="llm-add-base">
|
||||
{{ _('Base URL') }}
|
||||
<span class="pure-form-message-inline">({{ _('optional') }})</span>
|
||||
</label>
|
||||
{{ nf.api_base() }}
|
||||
</div>
|
||||
<div class="pure-control-group">
|
||||
<label for="llm-add-tpm">
|
||||
{{ _('Tokens/min limit') }}
|
||||
<span class="pure-form-message-inline">({{ _('0 = unlimited') }})</span>
|
||||
</label>
|
||||
{{ nf.tokens_per_minute() }}
|
||||
</div>
|
||||
|
||||
<div class="pure-controls">
|
||||
<button type="button" id="llm-btn-add" class="pure-button pure-button-primary">{{ _('+ Add connection') }}</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{# ── Prompt configuration ────────────────────────────────── #}
|
||||
<fieldset>
|
||||
<legend>{{ _('Summary Prompt') }}</legend>
|
||||
<div class="pure-control-group">
|
||||
{{ render_field(plugin_form.llm_summary_prompt) }}
|
||||
<span class="pure-form-message-inline">
|
||||
{{ _('Instruction appended after the diff in every LLM call. Leave blank to use the built-in default (structured JSON output).') }}
|
||||
</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -35,8 +35,32 @@
|
||||
return escHtml(k.substring(0, 4)) + '••••';
|
||||
}
|
||||
|
||||
// Emit WTForms FieldList hidden inputs (llm_connection-N-fieldname) so the
|
||||
// server processes connections through the declared schema — no arbitrary keys.
|
||||
function serialise() {
|
||||
$('#llm-connections-json').val(JSON.stringify(LLM_CONNECTIONS));
|
||||
var $form = $('form.settings');
|
||||
$form.find('input[data-llm-gen]').remove();
|
||||
|
||||
var ids = Object.keys(LLM_CONNECTIONS);
|
||||
$.each(ids, function (i, id) {
|
||||
var c = LLM_CONNECTIONS[id];
|
||||
var prefix = 'llm_connection-' + i + '-';
|
||||
var fields = {
|
||||
connection_id: id,
|
||||
name: c.name || '',
|
||||
model: c.model || '',
|
||||
api_key: c.api_key || '',
|
||||
api_base: c.api_base || '',
|
||||
tokens_per_minute: parseInt(c.tokens_per_minute || 0, 10)
|
||||
};
|
||||
$.each(fields, function (field, value) {
|
||||
$('<input>').attr({ type: 'hidden', name: prefix + field, value: value, 'data-llm-gen': '1' }).appendTo($form);
|
||||
});
|
||||
// BooleanField: only emit when true (absence == false in WTForms)
|
||||
if (c.is_default) {
|
||||
$('<input>').attr({ type: 'hidden', name: prefix + 'is_default', value: 'y', 'data-llm-gen': '1' }).appendTo($form);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
|
||||
Reference in New Issue
Block a user