deploy Nous v14 update
This commit is contained in:
+4
-2
@@ -12,8 +12,10 @@ GMAPS_API_KEY=
|
||||
GMAPS_REQUEST_TIMEOUT=900
|
||||
GMAPS_POLL_SECONDS=2
|
||||
|
||||
# Optional OpenAI-compatible provider for AI analysis and tailored drafts.
|
||||
# Keep these empty to use the rest of the app without AI.
|
||||
# AI provider for analysis and tailored drafts: nous 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.
|
||||
AI_BASE_URL=
|
||||
AI_API_KEY=
|
||||
AI_MODEL=
|
||||
|
||||
@@ -47,10 +47,29 @@ error rather than remaining stuck forever.
|
||||
| `GMAPS_REQUEST_TIMEOUT` | `900` | Maximum wait for the Maps worker in seconds; raise it for very large searches |
|
||||
| `GMAPS_POLL_SECONDS` | `2` | Poll interval while a job is running |
|
||||
| `DISCOVERY_BACKEND` | `gmaps` when `GMAPS_URL` is set | Set to `browser` only for the bounded public-search fallback |
|
||||
| `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_API_KEY` | empty | Optional provider key; keep server-side |
|
||||
| `AI_MODEL` | empty | Model name used for evidence-grounded drafts |
|
||||
|
||||
### Nous Research Portal
|
||||
|
||||
Choose **Nous Portal · free models** in Settings, paste a Nous API key, select a
|
||||
free model, save, and use **Test connection** before drafting a message. The
|
||||
app uses `https://inference-api.nousresearch.com/v1` automatically and tries
|
||||
the live `/models` catalog; if the catalog is unavailable it shows a stable
|
||||
fallback list of the free models currently published by Nous. Availability and
|
||||
model IDs can change, so refresh Settings periodically.
|
||||
|
||||
The bundled fallback entries are LongCat 2.0, Solar Pro 4, Step 3.7 Flash,
|
||||
Laguna S 2.1, Laguna XS 2.1, Ling 3.0 Flash Fin, and Ling 3.0 Flash Sante
|
||||
(using the Portal's `:free` model suffix).
|
||||
|
||||
Hermes Agent has its own browser-based OAuth flow, but that flow stores a
|
||||
Hermes refresh token and is not a portable credential for this standalone
|
||||
Docker app. ProspectOS therefore uses a server-side Nous API key here. The key
|
||||
is never returned by the Settings API or sent to the browser after saving.
|
||||
|
||||
The app stores its catalogue in the `prospect_data` Docker volume. The worker
|
||||
stores its own job data in `gmaps_data`; keep both volumes if you want to retain
|
||||
history. To inspect status:
|
||||
|
||||
+116
-15
@@ -46,6 +46,19 @@ GMAPS_API_KEY = os.environ.get("GMAPS_API_KEY", "").strip()
|
||||
AI_BASE_URL = os.environ.get("AI_BASE_URL", "").strip().rstrip("/")
|
||||
AI_API_KEY = os.environ.get("AI_API_KEY", "").strip()
|
||||
AI_MODEL = os.environ.get("AI_MODEL", "").strip()
|
||||
AI_PROVIDER = os.environ.get("AI_PROVIDER", "custom").strip().lower()
|
||||
NOUS_BASE_URL = "https://inference-api.nousresearch.com/v1"
|
||||
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},
|
||||
{"id": "stepfun/step-3.7-flash:free", "label": "StepFun · Step 3.7 Flash", "free": True},
|
||||
{"id": "poolside/laguna-s-2.1:free", "label": "Poolside · Laguna S 2.1", "free": True},
|
||||
{"id": "poolside/laguna-xs-2.1:free", "label": "Poolside · Laguna XS 2.1", "free": True},
|
||||
{"id": "inclusionai/ling-3.0-flash-fin:free", "label": "inclusionAI · Ling 3.0 Flash Fin", "free": True},
|
||||
{"id": "inclusionai/ling-3.0-flash-sante:free", "label": "inclusionAI · Ling 3.0 Flash Sante", "free": True},
|
||||
]
|
||||
_nous_models_cache: list[dict[str, Any]] = NOUS_FREE_MODELS.copy()
|
||||
_nous_models_cached_at = 0.0
|
||||
ALLOWED_REVIEW = {"pending", "approved", "rejected", "contacted"}
|
||||
_discover_lock = threading.Lock()
|
||||
_schema_lock = threading.Lock()
|
||||
@@ -479,6 +492,55 @@ def http_bytes(method: str, url: str, payload: Any = None, headers: dict[str, st
|
||||
raise RuntimeError(f"gmaps_unreachable:{exc.reason}") from exc
|
||||
|
||||
|
||||
def ai_runtime_settings() -> dict[str, str]:
|
||||
saved: dict[str, str] = {}
|
||||
try:
|
||||
conn = db()
|
||||
saved = {str(r["key"]): str(r["value"]) for r in conn.execute("SELECT key,value FROM settings")}
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
provider = saved.get("AI_PROVIDER", AI_PROVIDER).strip().lower() or "custom"
|
||||
base = saved.get("AI_BASE_URL", AI_BASE_URL).strip().rstrip("/")
|
||||
if provider == "nous":
|
||||
base = NOUS_BASE_URL
|
||||
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()}
|
||||
|
||||
|
||||
def nous_models() -> list[dict[str, Any]]:
|
||||
"""Return the live Nous catalog when reachable, with stable free fallbacks."""
|
||||
global _nous_models_cache, _nous_models_cached_at
|
||||
# Avoid making every Settings refresh wait on an external catalog request.
|
||||
# A successful catalog is reused for ten minutes; fallback data is also
|
||||
# cached after a failed request and can be replaced on the next refresh.
|
||||
if time.monotonic() - _nous_models_cached_at < 600:
|
||||
return _nous_models_cache
|
||||
try:
|
||||
response = http_json("GET", NOUS_BASE_URL + "/models", timeout=10)
|
||||
raw = response.get("data", response) if isinstance(response, dict) else response
|
||||
models: list[dict[str, Any]] = []
|
||||
if isinstance(raw, list):
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
model_id = str(item.get("id") or "").strip()
|
||||
if not model_id:
|
||||
continue
|
||||
pricing = item.get("pricing") if isinstance(item.get("pricing"), dict) else {}
|
||||
prices = [str(pricing[k]).strip() for k in ("prompt", "completion", "input", "output") if k in pricing]
|
||||
free = bool(prices) and all(price in {"0", "0.0", "0.00"} for price in prices)
|
||||
models.append({"id": model_id, "label": model_id, "free": free, "context_length": item.get("context_length")})
|
||||
free = [item for item in models if item.get("free")]
|
||||
if free:
|
||||
_nous_models_cache = free
|
||||
_nous_models_cached_at = time.monotonic()
|
||||
return free
|
||||
except Exception:
|
||||
pass
|
||||
_nous_models_cached_at = time.monotonic()
|
||||
return _nous_models_cache
|
||||
|
||||
|
||||
def gmaps_headers() -> dict[str, str]:
|
||||
if GMAPS_API_MODE == "saas":
|
||||
if not GMAPS_API_KEY:
|
||||
@@ -810,6 +872,11 @@ def insert_catalogue(run_id: int, query: str, raw_results: list[dict[str, Any]],
|
||||
except Exception as exc:
|
||||
item.update({"website_status": "unknown", "website_checked_at": now(), "website_error": "analysis_failed:" + str(exc)[:240], "email_evidence": item.get("email_evidence", "[]"), "score_reasons": "[]"})
|
||||
item["opportunity_score"] = score_opportunity(item)[0]
|
||||
# Release SQLite's read/write transaction before the progress
|
||||
# callback opens its own connection to update the run row. Without
|
||||
# this commit, a multi-result enrichment can deadlock forever in
|
||||
# the website_analysis stage while waiting on the catalogue read.
|
||||
conn.commit()
|
||||
if progress:
|
||||
progress(index, total)
|
||||
emails = set(normalize_public_emails(item.get("emails")))
|
||||
@@ -918,14 +985,12 @@ def scheduler_loop() -> None:
|
||||
|
||||
def ai_generate_message(prospect: dict[str, Any], offer: str) -> dict[str, Any]:
|
||||
"""Generate an evidence-grounded draft through an OpenAI-compatible endpoint."""
|
||||
runtime = {}
|
||||
try:
|
||||
c = db(); runtime = {str(r["key"]): str(r["value"]) for r in c.execute("SELECT key,value FROM settings")}; c.close()
|
||||
except Exception:
|
||||
pass
|
||||
ai_base_url = runtime.get("AI_BASE_URL", AI_BASE_URL).strip().rstrip("/")
|
||||
ai_api_key = runtime.get("AI_API_KEY", AI_API_KEY).strip()
|
||||
ai_model = runtime.get("AI_MODEL", AI_MODEL).strip()
|
||||
runtime = ai_runtime_settings()
|
||||
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 not ai_base_url or not ai_model:
|
||||
raise RuntimeError("ai_not_configured_set_AI_BASE_URL_and_AI_MODEL")
|
||||
evidence = {
|
||||
@@ -942,7 +1007,18 @@ def ai_generate_message(prospect: dict[str, Any], offer: str) -> dict[str, Any]:
|
||||
"The message must be plain text, personalised, transparent, and include a polite opt-out line.")
|
||||
prompt = json.dumps({"evidence": evidence, "user_offer_or_instruction": offer[:2000]}, ensure_ascii=False)
|
||||
headers = {"Authorization": "Bearer " + ai_api_key} if ai_api_key else {}
|
||||
result = http_json("POST", ai_base_url + "/chat/completions", {"model": ai_model, "temperature": 0.4, "response_format": {"type": "json_object"}, "messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}]}, headers=headers, timeout=60)
|
||||
request_payload: dict[str, Any] = {
|
||||
"model": ai_model,
|
||||
"temperature": 0.4,
|
||||
"max_tokens": 700,
|
||||
"messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}],
|
||||
}
|
||||
# Nous exposes an OpenAI-compatible chat endpoint, but not every free model
|
||||
# accepts OpenAI's optional response_format parameter. The prompt still
|
||||
# requires strict JSON and the parser below validates the response.
|
||||
if runtime["provider"] != "nous":
|
||||
request_payload["response_format"] = {"type": "json_object"}
|
||||
result = http_json("POST", ai_base_url + "/chat/completions", request_payload, headers=headers, timeout=90)
|
||||
content = (((result.get("choices") or [{}])[0].get("message") or {}).get("content") or "") if isinstance(result, dict) else ""
|
||||
try:
|
||||
parsed = json.loads(content)
|
||||
@@ -987,7 +1063,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if parsed.path == "/api/config":
|
||||
auth_required = GMAPS_API_MODE == "saas"
|
||||
ready = backend_mode() == "browser" or (bool(GMAPS_URL) and (not auth_required or bool(GMAPS_API_KEY)))
|
||||
saved = {str(r["key"]): str(r["value"]) for r in conn.execute("SELECT key,value FROM settings")}
|
||||
ai_runtime = ai_runtime_settings()
|
||||
return self.send_json(200, {
|
||||
"backend": backend_mode(),
|
||||
"gmaps_url": bool(GMAPS_URL),
|
||||
@@ -997,11 +1073,18 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"ready": ready,
|
||||
"email_extraction_default": True,
|
||||
"email_extraction_always": True,
|
||||
"ai_configured": bool(saved.get("AI_BASE_URL", AI_BASE_URL) and saved.get("AI_MODEL", AI_MODEL)),
|
||||
"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"])),
|
||||
})
|
||||
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()})
|
||||
return self.send_json(200, {"provider": provider or "custom", "models": []})
|
||||
if parsed.path == "/api/settings":
|
||||
saved = {str(r["key"]): str(r["value"]) for r in conn.execute("SELECT key,value FROM settings")}
|
||||
return self.send_json(200, {"maps": {"backend": backend_mode(), "worker_url": GMAPS_URL, "ready": backend_mode() == "browser" or bool(GMAPS_URL)}, "ai": {"base_url": saved.get("AI_BASE_URL", AI_BASE_URL), "model": saved.get("AI_MODEL", AI_MODEL), "configured": bool(saved.get("AI_BASE_URL", AI_BASE_URL) and saved.get("AI_MODEL", AI_MODEL))}, "email_collection": {"always_on": True, "public_only": True}, "limits": {"max_results": MAX_RESULTS, "website_scan_timeout_seconds": 8}})
|
||||
ai_runtime = ai_runtime_settings()
|
||||
models = nous_models() if ai_runtime["provider"] == "nous" else []
|
||||
return self.send_json(200, {"maps": {"backend": backend_mode(), "worker_url": GMAPS_URL, "ready": backend_mode() == "browser" or bool(GMAPS_URL)}, "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}, "email_collection": {"always_on": True, "public_only": True}, "limits": {"max_results": MAX_RESULTS, "website_scan_timeout_seconds": 8}})
|
||||
if parsed.path == "/api/prospects":
|
||||
params = parse_qs(parsed.query)
|
||||
q = (params.get("q", [""])[0]).strip().lower()
|
||||
@@ -1084,14 +1167,32 @@ class Handler(BaseHTTPRequestHandler):
|
||||
try:
|
||||
payload = self.read_json(); ai = payload.get("ai") if isinstance(payload.get("ai"), dict) else {}
|
||||
conn = db()
|
||||
for key in ("AI_BASE_URL", "AI_API_KEY", "AI_MODEL"):
|
||||
for key in ("AI_PROVIDER", "AI_BASE_URL", "AI_API_KEY", "AI_MODEL"):
|
||||
if key in ai:
|
||||
value = str(ai.get(key) or "").strip()[:500]
|
||||
conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at", (key, value, now()))
|
||||
conn.commit(); saved={str(r["key"]):str(r["value"]) for r in conn.execute("SELECT key,value FROM settings")}; conn.close()
|
||||
return self.send_json(200, {"saved": True, "ai": {"base_url": saved.get("AI_BASE_URL", AI_BASE_URL), "model": saved.get("AI_MODEL", AI_MODEL), "configured": bool(saved.get("AI_BASE_URL", AI_BASE_URL) and saved.get("AI_MODEL", AI_MODEL))}})
|
||||
ai_runtime = ai_runtime_settings()
|
||||
return self.send_json(200, {"saved": True, "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"]))}})
|
||||
except Exception as exc:
|
||||
return self.send_json(400, {"error": str(exc)})
|
||||
if urlparse(self.path).path == "/api/ai/test":
|
||||
try:
|
||||
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"})
|
||||
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)
|
||||
content = (((result.get("choices") or [{}])[0].get("message") or {}).get("content") or "") if isinstance(result, dict) else ""
|
||||
if not str(content).strip():
|
||||
return self.send_json(502, {"error": "ai_empty_response"})
|
||||
return self.send_json(200, {"ok": True, "provider": runtime["provider"], "model": runtime["model"], "reply": str(content).strip()[:200]})
|
||||
except RuntimeError as exc:
|
||||
return self.send_json(502, {"error": str(exc)})
|
||||
except Exception as exc:
|
||||
return self.send_json(502, {"error": "ai_test_failed:" + str(exc)[:200]})
|
||||
message_match = re.fullmatch(r"/api/prospects/(\d+)/message", urlparse(self.path).path)
|
||||
if message_match:
|
||||
conn = None
|
||||
|
||||
@@ -24,6 +24,7 @@ services:
|
||||
GMAPS_API_KEY: ${GMAPS_API_KEY:-}
|
||||
GMAPS_REQUEST_TIMEOUT: ${GMAPS_REQUEST_TIMEOUT:-900}
|
||||
GMAPS_POLL_SECONDS: ${GMAPS_POLL_SECONDS:-2}
|
||||
AI_PROVIDER: ${AI_PROVIDER:-custom}
|
||||
AI_BASE_URL: ${AI_BASE_URL:-}
|
||||
AI_API_KEY: ${AI_API_KEY:-}
|
||||
AI_MODEL: ${AI_MODEL:-}
|
||||
|
||||
@@ -93,6 +93,29 @@ class BrowserOnlyAppTests(unittest.TestCase):
|
||||
self.assertTrue(first_call.args[2]["email"])
|
||||
self.assertEqual(http_bytes.call_args.args[1], "http://gmaps:8080/api/v1/jobs/job-1/download")
|
||||
|
||||
def test_nous_provider_exposes_models_and_tests_connection(self):
|
||||
free_models = [{"id": "meituan/longcat-2.0:free", "label": "Meituan · LongCat 2.0", "free": True}]
|
||||
with patch.object(server, "nous_models", return_value=free_models):
|
||||
status, body = self.request("GET", "/api/ai/models?provider=nous")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(body["models"], free_models)
|
||||
status, body = self.request("POST", "/api/settings", {"ai": {"AI_PROVIDER": "nous", "AI_MODEL": "meituan/longcat-2.0:free", "AI_API_KEY": "nous-test-key"}})
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(body["ai"]["provider"], "nous")
|
||||
status, body = self.request("GET", "/api/settings")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(body["ai"]["configured"])
|
||||
status, body = self.request("GET", "/api/config")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(body["ai_provider"], "nous")
|
||||
self.assertNotIn("nous-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://inference-api.nousresearch.com/v1/chat/completions"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+4
-4
@@ -11,13 +11,13 @@ function renderDashboard(){const total=prospects.length,pending=prospects.filter
|
||||
function renderDiscover(){ $('view').innerHTML=layout('Discover','Set the criteria for this run. Source configuration and provider settings live in Settings.',`<div class="two-col"><article class="panel form-panel"><div class="panel-title"><div><span class="eyebrow">NEW RUN</span><h2>Find potential customers</h2><p>Search Google Maps, deduplicate the catalogue, then enrich each result.</p></div><span class="pill">Email collection always on</span></div><form id="discoverForm"><label>Keywords<input id="keywords" required placeholder="plumbing services"></label><label>Category<input id="category" required placeholder="plumbers"></label><label>Location<input id="location" value="South Africa" required></label><label>Maximum results<select id="max-results"><option>10</option><option selected>25</option><option>50</option></select></label><label class="checkline"><input type="checkbox" id="extra-reviews"> Collect extra reviews <small>May increase run time</small></label><div class="form-row"><button class="primary" type="submit" id="discoverBtn">Run discovery →</button><span id="discoverMessage" class="notice"></span></div></form></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">HOW IT WORKS</span><h2>Evidence-first pipeline</h2></div></div><ol class="steps"><li><b>Search</b><span>Maps worker finds public business listings.</span></li><li><b>Enrich</b><span>Website, HTTPS, DNS hints and public email checks run safely.</span></li><li><b>Review</b><span>You decide what is relevant before any contact.</span></li></ol></article></div>`)}
|
||||
function renderProspects(){ $('view').innerHTML=layout('Prospects','Search, filter and review your catalogue. Click a row to inspect the evidence.',`<article class="panel"><div class="toolbar"><div class="search"><span>⌕</span><input id="prospectSearch" placeholder="Search name, address, domain or email"></div><select id="prospectStatus"><option value="">All review statuses</option><option value="pending">Needs review</option><option value="approved">Approved</option><option value="rejected">Rejected</option><option value="contacted">Contacted</option></select><button class="secondary" id="exportCsv">Export CSV</button><button class="secondary" id="refreshProspects">Refresh</button></div><div id="prospectTable">${prospectTable()}</div></article><aside id="detail" class="detail-panel" hidden></aside>`)}
|
||||
function renderRuns(){ $('view').innerHTML=layout('Runs','Monitor discovery jobs and inspect errors without leaving the run history.',`<article class="panel"><div class="table-wrap"><table><thead><tr><th>Run</th><th>Status</th><th>Results</th><th>Started</th><th>Source</th></tr></thead><tbody>${runs.map(r=>`<tr><td><b>#${r.id}</b><small>${esc(r.query)}</small></td><td><span class="status ${r.status}">${esc(r.status)}</span><small>${esc(r.stage||'')}</small></td><td>${r.added||0} added / ${r.found||0} found</td><td>${date(r.created_at)}</td><td>${esc(r.source||'google_maps')}</td></tr>`).join('')}</tbody></table></div>${runs.length?'':empty('No discovery runs yet')}</article>`)}
|
||||
function renderSettings(){const ai=settings.ai||{};const sch=settings.schedules||[];const sup=settings.suppressions||[];$('view').innerHTML=layout('Settings','Configure providers, automation and safety controls here. These settings are separate from daily discovery.',`<div class="settings-grid"><article class="panel"><div class="panel-title"><div><span class="eyebrow">SOURCE</span><h2>Google Maps worker</h2><p>The bundled standard gosom worker runs locally and does not require an API key.</p></div><span class="status ${settings.maps?.ready?'succeeded':'failed'}">${settings.maps?.ready?'Ready':'Not connected'}</span></div><div class="setting-line"><span>Connection</span><b>${esc(settings.maps?.worker_url||'Not configured')}</b></div><div class="setting-line"><span>Email extraction</span><b class="good-text">Always on · public pages only</b></div><div class="setting-line"><span>Website scan</span><b>8 second bounded timeout</b></div></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">AI PROVIDER</span><h2>Draft assistant</h2><p>Used only when you click “Draft AI email”.</p></div></div><form id="settingsForm"><label>OpenAI-compatible base URL<input id="aiBase" value="${esc(ai.base_url||'')}" 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" value="${esc(ai.model||'')}" placeholder="model-name"></label><div class="form-row"><button class="primary" type="submit">Save AI settings</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>`)}
|
||||
function renderSettings(){const ai=settings.ai||{};const sch=settings.schedules||[];const sup=settings.suppressions||[];const provider=ai.provider||'custom';const modelList=Array.isArray(ai.models)?ai.models:[];const selectedModel=ai.model||'';const options=modelList.map(m=>`<option value="${esc(m.id)}">${esc(m.label||m.id)}${m.free?' · free':''}</option>`).join('')+(selectedModel&&!modelList.some(m=>m.id===selectedModel)?`<option value="${esc(selectedModel)}">${esc(selectedModel)}</option>`:'');$('view').innerHTML=layout('Settings','Configure providers, automation and safety controls here. These settings are separate from daily discovery.',`<div class="settings-grid"><article class="panel"><div class="panel-title"><div><span class="eyebrow">SOURCE</span><h2>Google Maps worker</h2><p>The bundled standard gosom worker runs locally and does not require an API key.</p></div><span class="status ${settings.maps?.ready?'succeeded':'failed'}">${settings.maps?.ready?'Ready':'Not connected'}</span></div><div class="setting-line"><span>Connection</span><b>${esc(settings.maps?.worker_url||'Not configured')}</b></div><div class="setting-line"><span>Email extraction</span><b class="good-text">Always on · public pages only</b></div><div class="setting-line"><span>Website scan</span><b>8 second bounded timeout</b></div></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">AI PROVIDER</span><h2>Draft assistant</h2><p>Used only when you click “Draft AI 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 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>`)}
|
||||
async function load(){[prospects,runs,settings]=await Promise.all([api('/api/prospects').then(x=>x.items||[]),api('/api/runs').then(x=>x.items||[]),Promise.all([api('/api/settings'),api('/api/schedules'),api('/api/suppressions')]).then(([s,sch,sup])=>({...s,schedules:sch.items||[],suppressions:sup.items||[]}))]);}
|
||||
async function route(){const r=location.hash.slice(1)||'dashboard';document.querySelectorAll('[data-route]').forEach(a=>a.classList.toggle('active',a.dataset.route===r));try{await load();({dashboard:renderDashboard,discover:renderDiscover,prospects:renderProspects,runs:renderRuns,settings:renderSettings}[r]||renderDashboard)();$('updated').textContent='Updated '+new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});}catch(e){$('view').innerHTML=empty(e.message)}}
|
||||
async function openMessage(id){selected=prospects.find(p=>String(p.id)===String(id));if(!selected)return;$('messageTitle').textContent='Draft for '+selected.name;$('offerInput').value='';$('subjectOutput').value=selected.ai_subject||'';$('bodyOutput').value=selected.ai_message||'';$('messageStatus').textContent='';$('messageDialog').showModal()}
|
||||
function openDrawer(id){const p=prospects.find(x=>String(x.id)===String(id));if(!p)return;selected=p;$('drawerName').textContent=p.name;let reasons=[];try{reasons=JSON.parse(p.score_reasons||'[]')}catch{};$('drawerBody').innerHTML=`<div class="drawer-score"><span class="score ${scoreClass(Number(p.opportunity_score||0))}">${p.opportunity_score||0}</span><div><b>Opportunity score</b><small>Explainable triage signal, not an AI verdict</small></div></div><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('change',async e=>{if(e.target.dataset.review){try{const p=await api(`/api/prospects/${e.target.dataset.review}/review`,{method:'PATCH',body:JSON.stringify({status:e.target.value})});const i=prospects.findIndex(x=>x.id===p.id);if(i>=0)prospects[i]=p}catch(err){alert(err.message)}}if(e.target.id==='prospectSearch'||e.target.id==='prospectStatus')$('prospectTable').innerHTML=prospectTable()});
|
||||
document.addEventListener('submit',async e=>{if(e.target.id==='discoverForm'){e.preventDefault();const m=$('discoverMessage');$('discoverBtn').disabled=true;m.textContent='Queueing…';try{const r=await api('/api/discover',{method:'POST',body:JSON.stringify({keywords:$('keywords').value,category:$('category').value,location:$('location').value,max_results:Number($('max-results').value),extra_reviews:$('extra-reviews').checked,include_email:true})});m.textContent=`Run #${r.id} started. View it in Runs.`;setTimeout(route,500)}catch(x){m.textContent=x.message}finally{$('discoverBtn').disabled=false}}if(e.target.id==='settingsForm'){e.preventDefault();const m=$('settingsMessage');try{const ai={AI_BASE_URL:$('aiBase').value,AI_MODEL:$('aiModel').value};if($('aiKey').value)ai.AI_API_KEY=$('aiKey').value;await api('/api/settings',{method:'POST',body:JSON.stringify({ai})});m.textContent='Settings saved.';m.className='notice ok';}catch(x){m.textContent=x.message}}if(e.target.id==='scheduleForm'){e.preventDefault();try{await api('/api/schedules',{method:'POST',body:JSON.stringify({category:$('sCat').value,keywords:$('sKey').value,location:$('sLoc').value,interval_hours:Number($('sHours').value)})});route()}catch(x){alert(x.message)}}if(e.target.id==='suppressionForm'){e.preventDefault();try{await api('/api/suppressions',{method:'POST',body:JSON.stringify({kind:$('supKind').value,value:$('supValue').value,reason:$('supReason').value})});route()}catch(x){alert(x.message)}}});
|
||||
document.addEventListener('click',async e=>{if(e.target.id==='generateMessage'){if(!selected)return;const s=$('messageStatus');s.textContent='Generating…';try{const d=await api(`/api/prospects/${selected.id}/message`,{method:'POST',body:JSON.stringify({offer:$('offerInput').value})});$('subjectOutput').value=d.subject;$('bodyOutput').value=d.message;s.textContent='Draft ready. Review and edit before sending.';s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}}if(e.target.id==='copyMessage'){await navigator.clipboard.writeText(`Subject: ${$('subjectOutput').value}\n\n${$('bodyOutput').value}`);$('messageStatus').textContent='Copied to clipboard.'}});
|
||||
document.addEventListener('change',async e=>{if(e.target.dataset.review){try{const p=await api(`/api/prospects/${e.target.dataset.review}/review`,{method:'PATCH',body:JSON.stringify({status:e.target.value})});const i=prospects.findIndex(x=>x.id===p.id);if(i>=0)prospects[i]=p}catch(err){alert(err.message)}}if(e.target.id==='prospectSearch'||e.target.id==='prospectStatus')$('prospectTable').innerHTML=prospectTable();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('submit',async e=>{if(e.target.id==='discoverForm'){e.preventDefault();const m=$('discoverMessage');$('discoverBtn').disabled=true;m.textContent='Queueing…';try{const r=await api('/api/discover',{method:'POST',body:JSON.stringify({keywords:$('keywords').value,category:$('category').value,location:$('location').value,max_results:Number($('max-results').value),extra_reviews:$('extra-reviews').checked,include_email:true})});m.textContent=`Run #${r.id} started. View it in Runs.`;setTimeout(route,500)}catch(x){m.textContent=x.message}finally{$('discoverBtn').disabled=false}}if(e.target.id==='settingsForm'){e.preventDefault();const m=$('settingsMessage');try{const ai={AI_PROVIDER:$('aiProvider').value,AI_BASE_URL:$('aiBase').value,AI_MODEL:$('aiModel').value};if($('aiKey').value)ai.AI_API_KEY=$('aiKey').value;await api('/api/settings',{method:'POST',body:JSON.stringify({ai})});m.textContent='Settings saved.';m.className='notice ok';}catch(x){m.textContent=x.message}}if(e.target.id==='scheduleForm'){e.preventDefault();try{await api('/api/schedules',{method:'POST',body:JSON.stringify({category:$('sCat').value,keywords:$('sKey').value,location:$('sLoc').value,interval_hours:Number($('sHours').value)})});route()}catch(x){alert(x.message)}}if(e.target.id==='suppressionForm'){e.preventDefault();try{await api('/api/suppressions',{method:'POST',body:JSON.stringify({kind:$('supKind').value,value:$('supValue').value,reason:$('supReason').value})});route()}catch(x){alert(x.message)}}});
|
||||
document.addEventListener('click',async e=>{if(e.target.id==='generateMessage'){if(!selected)return;const s=$('messageStatus');s.textContent='Generating…';try{const d=await api(`/api/prospects/${selected.id}/message`,{method:'POST',body:JSON.stringify({offer:$('offerInput').value})});$('subjectOutput').value=d.subject;$('bodyOutput').value=d.message;s.textContent='Draft ready. Review and edit before sending.';s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}}if(e.target.id==='copyMessage'){await navigator.clipboard.writeText(`Subject: ${$('subjectOutput').value}\n\n${$('bodyOutput').value}`);$('messageStatus').textContent='Copied to clipboard.'}if(e.target.id==='testAi'){const s=$('settingsMessage');s.textContent='Testing…';s.className='notice';try{const d=await api('/api/ai/test',{method:'POST',body:'{}'});s.textContent=`Connection works · ${d.provider} · ${d.model}`;s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}}});
|
||||
window.addEventListener('hashchange',route);$('refreshAll').addEventListener('click',route);route();
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#0d1726"><title>ProspectOS</title><link rel="stylesheet" href="/style.css?v=ui-v11"></head>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#0d1726"><title>ProspectOS</title><link rel="stylesheet" href="/style.css?v=ui-v14"></head>
|
||||
<body><div class="app-shell"><aside class="sidebar"><div class="brand"><span class="brand-mark">P</span><div><b>ProspectOS</b><small>Growth workspace</small></div></div><nav id="nav"><a href="#dashboard" data-route="dashboard"><span>⌂</span>Dashboard</a><a href="#discover" data-route="discover"><span>⌁</span>Discover</a><a href="#prospects" data-route="prospects"><span>▦</span>Prospects</a><a href="#runs" data-route="runs"><span>◷</span>Runs</a><a href="#settings" data-route="settings"><span>⚙</span>Settings</a></nav><div class="sidebar-foot"><span class="status-dot" id="sourceDot"></span><div><b id="sourceLabel">Checking worker</b><small id="sourceSub">Connecting…</small></div></div></aside><main class="main"><header class="topbar"><div><span class="crumb">Workspace</span><span class="slash">/</span><b id="pageName">Dashboard</b></div><div class="top-actions"><span id="updated">—</span><button class="icon-btn" id="refreshAll" title="Refresh">↻</button></div></header><div id="view"></div><footer>ProspectOS · public data only · manual review before contact</footer></main></div><aside id="detailDrawer" class="detail-drawer" aria-hidden="true"><div class="drawer-head"><div><span class="eyebrow">PROSPECT DETAIL</span><h2 id="drawerName">Prospect</h2></div><button class="icon-btn" id="closeDrawer">×</button></div><div id="drawerBody"></div></aside><dialog id="messageDialog"><form method="dialog" id="messageForm"><div class="dialog-head"><div><label class="eyebrow">AI ASSISTED DRAFT</label><h2 id="messageTitle">Tailor a message</h2><p>Use the evidence and your offer to create an editable draft.</p></div><button class="icon-btn" value="cancel">×</button></div><label for="offerInput">Your offer or instruction</label><textarea id="offerInput" rows="4" placeholder="Move hosting to Gold Plus for R119/month and rebuild the website free."></textarea><div id="messageStatus" class="notice"></div><button class="primary" id="generateMessage" type="button">Generate subject and email</button><label for="subjectOutput">Subject</label><input id="subjectOutput"><label for="bodyOutput">Email draft</label><textarea id="bodyOutput" rows="12"></textarea><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy draft</button><button class="secondary" value="cancel">Close</button></div></form></dialog><script src="/app.js?v=ui-v10"></script></body></html>
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user