add DeepSeek AI provider

This commit is contained in:
Marco0300
2026-09-10 09:02:02 +02:00
parent 9db943d5df
commit a2320ecdc8
5 changed files with 63 additions and 24 deletions
+3 -3
View File
@@ -46,10 +46,10 @@ BACKUP_DIR=/data/backups
BACKUP_RETENTION=14
BACKUP_INTERVAL_HOURS=24
# AI provider for analysis and tailored drafts: nous or custom.
# AI provider for analysis and tailored drafts: nous, deepseek, or custom.
AI_PROVIDER=custom
# For Nous, use https://portal.nousresearch.com to create an API key;
# the app uses https://inference-api.nousresearch.com/v1 automatically.
# DeepSeek uses https://api.deepseek.com/v1 with model deepseek-chat by default.
# For Nous, use https://portal.nousresearch.com to create an API key.
AI_BASE_URL=
AI_API_KEY=
AI_MODEL=
+3 -3
View File
@@ -126,10 +126,10 @@ error rather than remaining stuck forever.
| `WEBSITE_SCAN_TIMEOUT` | `8` | Per-page public website/email scan timeout (2–60 seconds); can be changed in Settings |
| `ANONYMIZED_TELEMETRY` | `false` | Keeps Browser Use telemetry disabled in this personal deployment |
| `LOG_RETENTION` | `20000` | Maximum operational log entries retained in SQLite |
| `AI_PROVIDER` | `custom` | Set to `nous` for the Nous Research Portal, or `custom` for another OpenAI-compatible endpoint |
| `AI_BASE_URL` | empty | Optional OpenAI-compatible `/chat/completions` endpoint |
| `AI_PROVIDER` | `custom` | Set to `nous`, `deepseek`, or `custom` for an OpenAI-compatible endpoint |
| `AI_BASE_URL` | empty | Optional OpenAI-compatible `/chat/completions` endpoint; DeepSeek uses `https://api.deepseek.com/v1` automatically |
| `AI_MODEL` | empty | Model name; DeepSeek defaults to `deepseek-chat` |
| `AI_API_KEY` | empty | Optional provider key; keep server-side |
| `AI_MODEL` | empty | Model name used for evidence-grounded drafts |
| `AI_MOCKUP_TIMEOUT_SECONDS` | `45` | Maximum AI wait for mockup copy before the deterministic premium renderer is used (20–300 seconds) |
### Nous Research Portal
+35 -16
View File
@@ -83,6 +83,11 @@ try:
except (TypeError, ValueError):
BACKUP_INTERVAL_HOURS = 24
NOUS_BASE_URL = "https://inference-api.nousresearch.com/v1"
DEEPSEEK_BASE_URL = "https://api.deepseek.com/v1"
DEEPSEEK_MODELS = [
{"id": "deepseek-chat", "label": "DeepSeek Chat", "free": False},
{"id": "deepseek-reasoner", "label": "DeepSeek Reasoner", "free": False},
]
NOUS_FREE_MODELS = [
{"id": "meituan/longcat-2.0:free", "label": "Meituan · LongCat 2.0", "free": True},
{"id": "upstage/solar-pro-4:free", "label": "Upstage · Solar Pro 4", "free": True},
@@ -1412,6 +1417,14 @@ def http_bytes(method: str, url: str, payload: Any = None, headers: dict[str, st
raise RuntimeError(f"{error_prefix}_unreachable:{exc.reason}") from exc
def ai_requires_api_key(provider: str) -> bool:
return str(provider or "").strip().lower() in {"nous", "deepseek"}
def ai_is_configured(runtime: dict[str, str]) -> bool:
return bool(runtime.get("base_url") and runtime.get("model") and (not ai_requires_api_key(runtime.get("provider", "")) or runtime.get("api_key")))
def ai_runtime_settings() -> dict[str, str]:
saved: dict[str, str] = {}
try:
@@ -1422,10 +1435,14 @@ def ai_runtime_settings() -> dict[str, str]:
pass
provider = saved.get("AI_PROVIDER", AI_PROVIDER).strip().lower() or "custom"
base = saved.get("AI_BASE_URL", AI_BASE_URL).strip().rstrip("/")
model = saved.get("AI_MODEL", AI_MODEL).strip()
if provider == "nous":
base = NOUS_BASE_URL
elif provider == "deepseek":
base = DEEPSEEK_BASE_URL
model = model or "deepseek-chat"
mockup_timeout = _setting_int(saved, "AI_MOCKUP_TIMEOUT_SECONDS", "AI_MOCKUP_TIMEOUT_SECONDS", AI_MOCKUP_TIMEOUT_SECONDS, 20, 300)
return {"provider": provider, "base_url": base, "api_key": saved.get("AI_API_KEY", AI_API_KEY).strip(), "model": saved.get("AI_MODEL", AI_MODEL).strip(), "mockup_timeout_seconds": str(mockup_timeout)}
return {"provider": provider, "base_url": base, "api_key": saved.get("AI_API_KEY", AI_API_KEY).strip(), "model": model, "mockup_timeout_seconds": str(mockup_timeout)}
def nous_models() -> list[dict[str, Any]]:
@@ -1980,8 +1997,8 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
ai_base_url = runtime["base_url"]
ai_api_key = runtime["api_key"]
ai_model = runtime["model"]
if runtime["provider"] == "nous" and not ai_api_key:
raise RuntimeError("nous_api_key_required_for_standalone_app")
if ai_requires_api_key(runtime["provider"]) and not ai_api_key:
raise RuntimeError("ai_provider_api_key_required")
if not ai_base_url or not ai_model:
raise RuntimeError("ai_not_configured_set_AI_BASE_URL_and_AI_MODEL")
evidence_ids = ["prospect_record"]
@@ -2412,8 +2429,8 @@ def ai_revise_message(prospect: dict[str, Any], current_subject: str, current_me
ai_base_url = runtime["base_url"]
ai_api_key = runtime["api_key"]
ai_model = runtime["model"]
if runtime["provider"] == "nous" and not ai_api_key:
raise RuntimeError("nous_api_key_required_for_standalone_app")
if ai_requires_api_key(runtime["provider"]) and not ai_api_key:
raise RuntimeError("ai_provider_api_key_required")
if not ai_base_url or not ai_model:
raise RuntimeError("ai_not_configured_set_AI_BASE_URL_and_AI_MODEL")
current_subject = str(current_subject or "").strip()[:300]
@@ -2551,8 +2568,8 @@ def _parse_ai_object(content: Any) -> dict[str, Any]:
def ai_assess_prospect(prospect: dict[str, Any]) -> dict[str, Any]:
"""Assess whether a prospect is worth human review/contact."""
runtime = ai_runtime_settings()
if runtime["provider"] == "nous" and not runtime["api_key"]:
raise RuntimeError("nous_api_key_required_for_standalone_app")
if ai_requires_api_key(runtime["provider"]) and not runtime["api_key"]:
raise RuntimeError("ai_provider_api_key_required")
if not runtime["base_url"] or not runtime["model"]:
raise RuntimeError("ai_not_configured_set_AI_BASE_URL_and_AI_MODEL")
profile = company_profile_settings()
@@ -2719,7 +2736,7 @@ def generate_mockup_html(prospect: dict[str, Any], snapshot: dict[str, Any] | No
snapshot = snapshot or {}
runtime = ai_runtime_settings()
fallback = fallback_mockup_html(prospect, snapshot)
if not runtime["base_url"] or not runtime["model"] or (runtime["provider"] == "nous" and not runtime["api_key"]):
if not runtime["base_url"] or not runtime["model"] or (ai_requires_api_key(runtime["provider"]) and not runtime["api_key"]):
return fallback, "fallback", ""
source = {
"business_name": _mockup_text(prospect.get("name"), 160),
@@ -2779,7 +2796,7 @@ def generate_mockup_pages(prospect: dict[str, Any], snapshot: dict[str, Any] | N
return {"home": html}, source, model
fallback = fallback_mockup_pages(prospect, snapshot)
runtime = ai_runtime_settings()
if not runtime["base_url"] or not runtime["model"] or (runtime["provider"] == "nous" and not runtime["api_key"]):
if not runtime["base_url"] or not runtime["model"] or (ai_requires_api_key(runtime["provider"]) and not runtime["api_key"]):
return fallback, "fallback", ""
source = {
"business_name": _mockup_text(prospect.get("name"), 160),
@@ -3033,7 +3050,7 @@ def generate_premium_mockup(prospect: dict[str, Any], snapshot: dict[str, Any] |
requested_design = _premium_design_from_instructions(instructions)
fallback = premium_render_pages(prospect, snapshot, mode=mode, design=requested_design)
runtime = ai_runtime_settings()
if not runtime["base_url"] or not runtime["model"] or (runtime["provider"] == "nous" and not runtime["api_key"]):
if not runtime["base_url"] or not runtime["model"] or (ai_requires_api_key(runtime["provider"]) and not runtime["api_key"]):
return fallback, "fallback", ""
source = {
"business_name": _mockup_text(prospect.get("name"), 160),
@@ -3198,12 +3215,14 @@ class Handler(BaseHTTPRequestHandler):
"email_extraction_always": True,
"browser_use_enabled": browser_runtime["enabled"],
"ai_provider": ai_runtime["provider"],
"ai_configured": bool(ai_runtime["base_url"] and ai_runtime["model"] and (ai_runtime["provider"] != "nous" or ai_runtime["api_key"])),
"ai_configured": ai_is_configured(ai_runtime),
})
if parsed.path == "/api/ai/models":
provider = (parse_qs(parsed.query).get("provider", [AI_PROVIDER])[0] or AI_PROVIDER).strip().lower()
if provider == "nous":
return self.send_json(200, {"provider": "nous", "base_url": NOUS_BASE_URL, "models": nous_models()})
if provider == "deepseek":
return self.send_json(200, {"provider": "deepseek", "base_url": DEEPSEEK_BASE_URL, "models": DEEPSEEK_MODELS})
return self.send_json(200, {"provider": provider or "custom", "models": []})
if parsed.path == "/api/logs":
params = parse_qs(parsed.query)
@@ -3241,7 +3260,7 @@ class Handler(BaseHTTPRequestHandler):
if parsed.path == "/api/settings":
saved = _saved_settings()
ai_runtime = ai_runtime_settings()
models = nous_models() if ai_runtime["provider"] == "nous" else []
models = nous_models() if ai_runtime["provider"] == "nous" else DEEPSEEK_MODELS if ai_runtime["provider"] == "deepseek" else []
browser_runtime = browser_use_settings()
maps_runtime = gmaps_runtime_settings()
scan_timeout = website_scan_timeout_seconds()
@@ -3251,7 +3270,7 @@ class Handler(BaseHTTPRequestHandler):
"ready": backend_mode() == "browser" or bool(GMAPS_URL),
**maps_runtime,
},
"ai": {"provider": ai_runtime["provider"], "base_url": ai_runtime["base_url"], "model": ai_runtime["model"], "configured": bool(ai_runtime["base_url"] and ai_runtime["model"] and (ai_runtime["provider"] != "nous" or ai_runtime["api_key"])), "models": models},
"ai": {"provider": ai_runtime["provider"], "base_url": ai_runtime["base_url"], "model": ai_runtime["model"], "configured": ai_is_configured(ai_runtime), "models": models},
"browser_use": browser_runtime,
"email_collection": {"always_on": True, "public_only": True},
"limits": {"max_results": MAX_RESULTS, "website_scan_timeout_seconds": scan_timeout, "ai_mockup_timeout_seconds": int(ai_runtime.get("mockup_timeout_seconds") or AI_MOCKUP_TIMEOUT_SECONDS)},
@@ -3702,7 +3721,7 @@ class Handler(BaseHTTPRequestHandler):
return self.send_json(200, {
"saved": True,
"saved_keys": saved_keys,
"ai": {"provider": ai_runtime["provider"], "base_url": ai_runtime["base_url"], "model": ai_runtime["model"], "configured": bool(ai_runtime["base_url"] and ai_runtime["model"] and (ai_runtime["provider"] != "nous" or ai_runtime["api_key"]))},
"ai": {"provider": ai_runtime["provider"], "base_url": ai_runtime["base_url"], "model": ai_runtime["model"], "configured": ai_is_configured(ai_runtime)},
"browser_use": browser_use_settings(),
"maps": gmaps_runtime_settings(),
"limits": {"website_scan_timeout_seconds": website_scan_timeout_seconds(), "ai_mockup_timeout_seconds": int(ai_runtime.get("mockup_timeout_seconds") or AI_MOCKUP_TIMEOUT_SECONDS)},
@@ -3854,8 +3873,8 @@ class Handler(BaseHTTPRequestHandler):
runtime = ai_runtime_settings()
if not runtime["base_url"] or not runtime["model"]:
return self.send_json(400, {"error": "ai_not_configured_set_provider_and_model"})
if runtime["provider"] == "nous" and not runtime["api_key"]:
return self.send_json(400, {"error": "nous_api_key_required_for_standalone_app"})
if ai_requires_api_key(runtime["provider"]) and not runtime["api_key"]:
return self.send_json(400, {"error": "ai_provider_api_key_required"})
headers = {"Authorization": "Bearer " + runtime["api_key"]} if runtime["api_key"] else {}
result = http_json("POST", runtime["base_url"] + "/chat/completions", {"model": runtime["model"], "temperature": 0, "max_tokens": 8, "messages": [{"role": "user", "content": "Reply with exactly OK."}]}, headers=headers, timeout=30, error_prefix="ai")
content = (((result.get("choices") or [{}])[0].get("message") or {}).get("content") or "") if isinstance(result, dict) else ""
+20
View File
@@ -123,6 +123,26 @@ class BrowserOnlyAppTests(unittest.TestCase):
self.assertTrue(body["ok"])
self.assertEqual(http_json.call_args.args[0:2], ("POST", "https://inference-api.nousresearch.com/v1/chat/completions"))
def test_deepseek_provider_uses_fixed_endpoint_and_default_models(self):
status, body = self.request("GET", "/api/ai/models?provider=deepseek")
self.assertEqual(status, 200)
self.assertEqual(body["base_url"], "https://api.deepseek.com/v1")
self.assertEqual([item["id"] for item in body["models"]], ["deepseek-chat", "deepseek-reasoner"])
status, body = self.request("POST", "/api/settings", {"ai": {"AI_PROVIDER": "deepseek", "AI_API_KEY": "deepseek-test-key"}})
self.assertEqual(status, 200)
self.assertEqual(body["ai"]["base_url"], "https://api.deepseek.com/v1")
self.assertEqual(body["ai"]["model"], "deepseek-chat")
self.assertTrue(body["ai"]["configured"])
status, body = self.request("GET", "/api/config")
self.assertEqual(status, 200)
self.assertEqual(body["ai_provider"], "deepseek")
self.assertNotIn("deepseek-test-key", json.dumps(body))
with patch("api.server.http_json", return_value={"choices": [{"message": {"content": "OK"}}]}) as http_json:
status, body = self.request("POST", "/api/ai/test", {})
self.assertEqual(status, 200)
self.assertTrue(body["ok"])
self.assertEqual(http_json.call_args.args[0:2], ("POST", "https://api.deepseek.com/v1/chat/completions"))
def test_provider_errors_are_not_mislabelled_as_maps_errors(self):
class CloudflareResponse:
def read(self):
+2 -2
View File
@@ -54,7 +54,7 @@ function renderSettings(){
<div class="form-row"><button class="primary" type="submit">Save runtime settings</button><span id="runtimeSettingsMessage" class="notice"></span></div>
</form>
</article>
<article class="panel"><div class="panel-title"><div><span class="eyebrow">AI PROVIDER</span><h2>Draft assistant</h2><p>Used when you assess a prospect or draft an email.</p></div><span class="status ${ai.configured?'succeeded':'failed'}">${ai.configured?'Configured':'Not configured'}</span></div><form id="settingsForm"><label>Provider<select id="aiProvider"><option value="nous" ${provider==='nous'?'selected':''}>Nous Portal · free models</option><option value="custom" ${provider!=='nous'?'selected':''}>Custom OpenAI-compatible</option></select></label><p class="field-help" id="aiProviderNote">${provider==='nous'?'Nous uses the official inference endpoint. OAuth is supported by Hermes CLI; this standalone app uses a server-side API key.':'Use any OpenAI-compatible /v1 endpoint.'}</p><label>Base URL<input id="aiBase" value="${esc(ai.base_url||'')}" ${provider==='nous'?'readonly':''} placeholder="https://provider.example/v1"></label><label>API key<input id="aiKey" type="password" placeholder="Leave blank to keep current key"></label><label>Model<input id="aiModel" list="nousModelList" value="${esc(selectedModel)}" placeholder="model-name"><datalist id="nousModelList">${options}</datalist></label><p class="field-help">Nous model availability can change. Refresh Settings to update the free catalog.</p><div class="form-row"><button class="primary" type="submit">Save AI settings</button><button class="secondary" id="testAi" type="button">Test connection</button><span id="settingsMessage" class="notice"></span></div></form></article>
<article class="panel"><div class="panel-title"><div><span class="eyebrow">AI PROVIDER</span><h2>Draft assistant</h2><p>Used when you assess a prospect or draft an email.</p></div><span class="status ${ai.configured?'succeeded':'failed'}">${ai.configured?'Configured':'Not configured'}</span></div><form id="settingsForm"><label>Provider<select id="aiProvider"><option value="nous" ${provider==='nous'?'selected':''}>Nous Portal · free models</option><option value="deepseek" ${provider==='deepseek'?'selected':''}>DeepSeek · API</option><option value="custom" ${provider!=='nous'&&provider!=='deepseek'?'selected':''}>Custom OpenAI-compatible</option></select></label><p class="field-help" id="aiProviderNote">${provider==='nous'?'Nous uses the official inference endpoint. OAuth is supported by Hermes CLI; this standalone app uses a server-side API key.':provider==='deepseek'?'DeepSeek uses https://api.deepseek.com/v1 and requires a DeepSeek API key.':'Use any OpenAI-compatible /v1 endpoint.'}</p><label>Base URL<input id="aiBase" value="${esc(ai.base_url||'')}" ${(provider==='nous'||provider==='deepseek')?'readonly':''} placeholder="https://provider.example/v1"></label><label>API key<input id="aiKey" type="password" placeholder="Leave blank to keep current key"></label><label>Model<input id="aiModel" list="nousModelList" value="${esc(selectedModel)}" placeholder="model-name"><datalist id="nousModelList">${options}</datalist></label><p class="field-help">DeepSeek defaults to deepseek-chat. Nous model availability can change. Refresh Settings to update catalogs.</p><div class="form-row"><button class="primary" type="submit">Save AI settings</button><button class="secondary" id="testAi" type="button">Test connection</button><span id="settingsMessage" class="notice"></span></div></form></article>
<article class="panel wide-panel"><div class="panel-title"><div><span class="eyebrow">SCHEDULES</span><h2>Recurring discovery</h2><p>Run bounded searches automatically; all results still arrive as needs-review.</p></div></div><form id="scheduleForm" class="inline-form"><input id="sCat" placeholder="Category"><input id="sKey" placeholder="Keywords"><input id="sLoc" value="South Africa" placeholder="Location"><select id="sHours"><option value="24">Daily</option><option value="48">Every 2 days</option><option value="168">Weekly</option></select><button class="secondary">Add schedule</button></form><div class="list">${sch.map(s=>`<div><b>${esc(s.keywords)} · ${esc(s.category)}</b><span>${esc(s.location)} · every ${s.interval_hours}h · next ${date(s.next_run_at)}</span></div>`).join('')||'<p class="muted">No schedules configured.</p>'}</div></article>
<article class="panel wide-panel"><div class="panel-title"><div><span class="eyebrow">SAFETY</span><h2>Suppression centre</h2><p>Suppressed domains and emails are excluded before new prospects are saved.</p></div></div><form id="suppressionForm" class="inline-form"><select id="supKind"><option value="domain">Domain</option><option value="email">Email</option></select><input id="supValue" placeholder="example.co.za or info@example.co.za"><input id="supReason" placeholder="Reason"><button class="secondary">Suppress</button></form><div class="list">${sup.map(s=>`<div><b>${esc(s.value)}</b><span>${esc(s.kind)} · ${esc(s.reason||'manual')} · ${date(s.created_at)}</span></div>`).join('')||'<p class="muted">No suppressions configured.</p>'}</div></article>
</div>`)
@@ -82,7 +82,7 @@ function reviewStatusTabs(p){const options=[['pending','In review'],['approved',
function openDrawer(id){const p=prospects.find(x=>String(x.id)===String(id));if(!p)return;selected=p;const score=effectiveScore(p);const assessed=Boolean(p.ai_checked_at&&Number.isFinite(Number(p.ai_probability)));$('drawerName').textContent=p.name;let reasons=[];try{reasons=JSON.parse(p.score_reasons||'[]')}catch{};$('drawerBody').innerHTML=`<div class="drawer-score"><span class="score ${scoreClass(score)}">${score}</span><div><b>${scoreLabel(p)}</b><small>${assessed?'Updated from the latest AI assessment':'Explainable triage signal; assess with AI to update this score'}</small></div></div>${reviewStatusTabs(p)}<div class="drawer-section"><h3>Contact</h3><p>${esc(p.emails||'No public email found')}</p><p>${esc(p.phone||'No phone listed')}</p></div><div class="drawer-section"><h3>Website health</h3><div class="detail-grid"><span>Status<b>${esc((p.website_status||'unknown').replaceAll('_',' '))}</b></span><span>HTTPS<b>${p.website_https?'Yes':'No / unknown'}</b></span><span>Latency<b>${p.website_latency_ms?p.website_latency_ms+' ms':'—'}</b></span><span>Checked<b>${date(p.website_checked_at)}</b></span></div></div><div class="drawer-section"><h3>Why this scored</h3><ul>${reasons.map(r=>`<li>${esc(r)}</li>`).join('')||'<li>No score reasons recorded</li>'}</ul></div><div class="drawer-actions"><button class="primary" data-message="${p.id}">Draft AI email</button>${p.website?`<a class="secondary" href="${esc(p.website)}" target="_blank" rel="noopener">Open website ↗</a>`:''}</div>`;$('detailDrawer').classList.add('open');$('detailDrawer').setAttribute('aria-hidden','false')}
document.addEventListener('click',async e=>{const open=e.target.closest('[data-open]');if(open)openDrawer(open.dataset.open);const msg=e.target.closest('[data-message]');if(msg)openMessage(msg.dataset.message);if(e.target.id==='exportCsv')location.href='/api/export.csv';if(e.target.id==='refreshProspects')route();if(e.target.id==='closeDrawer'||!e.target.closest('#detailDrawer')&&$('detailDrawer').classList.contains('open')&&!e.target.closest('[data-open]')){$('detailDrawer').classList.remove('open');$('detailDrawer').setAttribute('aria-hidden','true')}});
document.addEventListener('click',async e=>{const button=e.target.closest('[data-drawer-review]');if(!button)return;e.preventDefault();e.stopPropagation();const id=button.dataset.drawerReview,status=button.dataset.status;const panel=button.closest('.review-section');const buttons=panel?panel.querySelectorAll('[data-drawer-review]'):[];buttons.forEach(item=>item.disabled=true);try{const updated=await api(`/api/prospects/${id}/review`,{method:'PATCH',body:JSON.stringify({status})});const index=prospects.findIndex(p=>String(p.id)===String(id));if(index>=0)prospects[index]=updated;selected=updated;openDrawer(id);if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()}catch(error){buttons.forEach(item=>item.disabled=false);if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=`Could not save status: ${error.message}`;panel.append(notice)}}});
document.addEventListener('change',async e=>{if(e.target.id==='prospectSearch'||['prospectScoreBand','prospectWebsiteFilter','prospectEmailFilter','prospectReviewFilter'].includes(e.target.id)){if(e.target.id==='prospectScoreBand')prospectScoreBand=e.target.value;if(e.target.id==='prospectWebsiteFilter')prospectWebsiteFilter=e.target.value;if(e.target.id==='prospectEmailFilter')prospectEmailFilter=e.target.value;if(e.target.id==='prospectReviewFilter')prospectReviewFilter=e.target.value;if($('prospectTable'))$('prospectTable').innerHTML=prospectTable();return}if(e.target.id==='aiProvider'){const nous=e.target.value==='nous';$('aiBase').readOnly=nous;$('aiBase').value=nous?'https://inference-api.nousresearch.com/v1':$('aiBase').value;const note=$('aiProviderNote');if(note)note.textContent=nous?'Nous uses the official inference endpoint. OAuth is supported by Hermes CLI; this standalone app uses a server-side API key.':'Use any OpenAI-compatible /v1 endpoint.';if(nous){try{const data=await api('/api/ai/models?provider=nous');const model=$('aiModel');const list=$('nousModelList');const models=data.models||[];if(list)list.innerHTML=models.map(m=>`<option value="${esc(m.id)}" label="${esc(m.label||m.id)}"></option>`).join('');if(model&&models.length&&!models.some(m=>m.id===model.value))model.value=models[0].id;}catch(err){}}}});
document.addEventListener('change',async e=>{if(e.target.id==='prospectSearch'||['prospectScoreBand','prospectWebsiteFilter','prospectEmailFilter','prospectReviewFilter'].includes(e.target.id)){if(e.target.id==='prospectScoreBand')prospectScoreBand=e.target.value;if(e.target.id==='prospectWebsiteFilter')prospectWebsiteFilter=e.target.value;if(e.target.id==='prospectEmailFilter')prospectEmailFilter=e.target.value;if(e.target.id==='prospectReviewFilter')prospectReviewFilter=e.target.value;if($('prospectTable'))$('prospectTable').innerHTML=prospectTable();return}if(e.target.id==='aiProvider'){const provider=e.target.value;const fixed=provider==='nous'||provider==='deepseek';$('aiBase').readOnly=fixed;if(provider==='nous'){$('aiBase').value='https://inference-api.nousresearch.com/v1';}else if(provider==='deepseek'){$('aiBase').value='https://api.deepseek.com/v1';if(!$('aiModel').value||$('aiModel').value==='')$('aiModel').value='deepseek-chat';}const note=$('aiProviderNote');if(note)note.textContent=provider==='nous'?'Nous uses the official inference endpoint. OAuth is supported by Hermes CLI; this standalone app uses a server-side API key.':provider==='deepseek'?'DeepSeek uses the official API endpoint and requires a DeepSeek API key.':'Use any OpenAI-compatible /v1 endpoint.';if(provider==='nous'||provider==='deepseek'){try{const data=await api('/api/ai/models?provider='+encodeURIComponent(provider));const model=$('aiModel');const list=$('nousModelList');const models=data.models||[];if(list)list.innerHTML=models.map(m=>`<option value="${esc(m.id)}" label="${esc(m.label||m.id)}"></option>`).join('');if(model&&models.length&&!models.some(m=>m.id===model.value))model.value=models[0].id;}catch(err){}}}});
document.addEventListener('input',e=>{if(e.target.id==='prospectSearch'&&$('prospectTable'))$('prospectTable').innerHTML=prospectTable()});
document.addEventListener('submit',async e=>{
if(e.target.id==='discoverForm'){