deploy Nous v21 update

This commit is contained in:
Marco0300
2026-09-05 22:25:52 +02:00
parent c2f14edd29
commit aacb71221b
9 changed files with 320 additions and 16 deletions
+1
View File
@@ -24,6 +24,7 @@ BROWSER_USE_ENABLED=true
BROWSER_USE_MAX_STEPS=10
BROWSER_USE_TIMEOUT_SECONDS=120
ANONYMIZED_TELEMETRY=false
LOG_RETENTION=20000
# AI provider for analysis and tailored drafts: nous or custom.
AI_PROVIDER=custom
+7 -1
View File
@@ -5,7 +5,7 @@ the bundled [gosom/google-maps-scraper](https://github.com/gosom/google-maps-scr
worker, waits for its asynchronous job to finish, filters places already in
the local catalogue, and presents the remaining places for human review.
The interface is split into Dashboard, Discover, Prospects, Runs, and Settings
The interface is split into Dashboard, Discover, Prospects, Runs, Logs, and Settings
views. It includes website health and email evidence, explainable scoring,
domain ideas, suppression, CSV export, scheduled runs, rescans, and human review. It never sends outreach
automatically. The Prospects explorer has an **All high potential** tab and
@@ -17,6 +17,11 @@ evidence and your offer; you edit and approve the draft yourself. Drafts now
also return the reasoning, stored evidence IDs used, facts that still need
confirmation, and a follow-up review note so a polished email is not mistaken
for verified information.
The **Logs** view separates API calls, user actions, AI requests, searches,
Browser Use/tool steps, and errors. AI request payloads and responses are
truncated and credentials such as API keys, authorization headers, tokens, and
passwords are redacted. Logs are retained up to `LOG_RETENTION` entries (20,000
by default).
From a prospect's detail drawer, **Assess with AI** stores a contact probability,
recommendation, and short evidence-grounded rationale for that specific company.
The same drawer can optionally run a bounded Browser Use investigation to look
@@ -65,6 +70,7 @@ error rather than remaining stuck forever.
| `BROWSER_USE_MAX_STEPS` | `10` | Maximum Browser Use actions for one investigation (3–15) |
| `BROWSER_USE_TIMEOUT_SECONDS` | `120` | Hard timeout for one investigation (30–300 seconds) |
| `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_API_KEY` | empty | Optional provider key; keep server-side |
+61 -4
View File
@@ -7,10 +7,11 @@ CAPTCHAs, rotate proxies, or continue after an access challenge.
from __future__ import annotations
import asyncio
import inspect
import json
import os
import re
from typing import Any
from typing import Any, Callable
from urllib.parse import urlparse
@@ -96,6 +97,7 @@ def investigate_websites(
api_key: str,
model: str,
user_agent: str,
log_callback: Callable[[str, str, dict[str, Any]], Any] | None = None,
) -> list[dict[str, Any]]:
"""Run one bounded Browser Use investigation and return candidates."""
if os.environ.get("BROWSER_USE_ENABLED", "1").strip().lower() in {"0", "false", "no", "off"}:
@@ -132,6 +134,52 @@ Return ONLY JSON in this shape:
Confidence must be an integer from 0 to 100. Include only plausible official website URLs and never invent a URL.
""".strip()
async def emit(kind: str, event: str, details: dict[str, Any]) -> None:
if not log_callback:
return
result = log_callback(kind, event, details)
if inspect.isawaitable(result):
await result
def output_actions(model_output: Any) -> list[Any]:
try:
actions = getattr(model_output, "action", []) or []
values: list[Any] = []
for action in list(actions)[:8]:
if hasattr(action, "model_dump"):
values.append(action.model_dump(mode="json"))
else:
values.append(str(action)[:2_000])
return values
except Exception:
return [str(model_output)[:2_000]] if model_output is not None else []
async def on_step(browser_state: Any, model_output: Any, step_number: int) -> None:
details = {
"step": step_number,
"url": str(getattr(browser_state, "url", "") or "")[:700],
"title": str(getattr(browser_state, "title", "") or "")[:300],
"actions": output_actions(model_output),
"model": model,
}
await emit("browser", "browser_use_step", details)
await emit("ai", "browser_use_model_step", {"step": step_number, "model": model, "actions": details["actions"]})
class LoggedChatOpenAI(ChatOpenAI):
"""Capture Browser Use LLM turns without recording provider secrets."""
async def ainvoke(self, messages: list[Any], output_format: Any = None, **kwargs: Any) -> Any:
started = asyncio.get_running_loop().time()
serialised = [{"role": getattr(message, "type", "message"), "content": str(getattr(message, "content", ""))[:4_000]} for message in list(messages)[-20:]]
await emit("ai", "browser_use_llm_request", {"model": self.model, "messages": serialised, "structured_output": bool(output_format)})
try:
result = await super().ainvoke(messages, output_format=output_format, **kwargs)
await emit("ai", "browser_use_llm_response", {"model": self.model, "completion": str(getattr(result, "completion", result))[:4_000], "duration_ms": int((asyncio.get_running_loop().time() - started) * 1000)})
return result
except Exception as exc:
await emit("ai", "browser_use_llm_error", {"model": self.model, "duration_ms": int((asyncio.get_running_loop().time() - started) * 1000)},)
raise
async def run() -> Any:
browser_profile = BrowserProfile(
headless=True,
@@ -141,7 +189,7 @@ Confidence must be an integer from 0 to 100. Include only plausible official web
enable_default_extensions=False,
captcha_solver=False,
)
llm = ChatOpenAI(
llm = LoggedChatOpenAI(
model=model,
api_key=api_key,
base_url=base_url,
@@ -161,10 +209,19 @@ Confidence must be an integer from 0 to 100. Include only plausible official web
use_judge=False,
enable_planning=False,
enable_signal_handler=False,
register_new_step_callback=on_step,
)
return await agent.run(max_steps=max(3, min(15, int(os.environ.get("BROWSER_USE_MAX_STEPS", "10")))))
try:
max_steps = int(os.environ.get("BROWSER_USE_MAX_STEPS", "10"))
except (TypeError, ValueError):
max_steps = 10
return await agent.run(max_steps=max(3, min(15, max_steps)))
timeout = max(30, min(300, int(os.environ.get("BROWSER_USE_TIMEOUT_SECONDS", "120"))))
try:
timeout = int(os.environ.get("BROWSER_USE_TIMEOUT_SECONDS", "120"))
except (TypeError, ValueError):
timeout = 120
timeout = max(30, min(300, timeout))
try:
history = asyncio.run(asyncio.wait_for(run(), timeout=timeout))
except asyncio.TimeoutError as exc:
+217 -8
View File
@@ -90,6 +90,63 @@ def now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
try:
LOG_RETENTION = max(2_000, min(50_000, int(os.environ.get("LOG_RETENTION", "20000"))))
except (TypeError, ValueError):
LOG_RETENTION = 20_000
_LOG_SECRET_KEYS = {"api_key", "authorization", "password", "secret", "token", "access_token", "refresh_token"}
def _redact_log_value(value: Any, depth: int = 0) -> Any:
"""Keep logs useful without persisting provider credentials."""
if depth > 5:
return "[truncated]"
if isinstance(value, dict):
output: dict[str, Any] = {}
for key, item in list(value.items())[:80]:
key_text = str(key)
if key_text.lower() in _LOG_SECRET_KEYS or key_text.lower().endswith("_api_key"):
output[key_text] = "[redacted]"
else:
output[key_text] = _redact_log_value(item, depth + 1)
return output
if isinstance(value, (list, tuple, set)):
return [_redact_log_value(item, depth + 1) for item in list(value)[:100]]
if isinstance(value, str):
return value[:4_000] + ("…" if len(value) > 4_000 else "")
if isinstance(value, (int, float, bool)) or value is None:
return value
return str(value)[:4_000]
def record_log(kind: str, event: str, *, level: str = "info", method: str = "", path: str = "",
status: int | None = None, duration_ms: int | None = None, details: Any = None,
error: str = "", run_id: int | None = None, prospect_id: int | None = None) -> None:
"""Persist one bounded operational event; logging must never break a request."""
try:
safe_kind = str(kind or "system").strip().lower()[:30] or "system"
safe_level = str(level or "info").strip().lower()[:12] or "info"
safe_details = _redact_log_value(details if details is not None else {})
encoded = json.dumps(safe_details, ensure_ascii=False, default=str)[:20_000]
conn = db()
try:
cur = conn.execute(
"""INSERT INTO logs(created_at,kind,level,event,method,path,status,duration_ms,run_id,prospect_id,details,error)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""",
(now(), safe_kind, safe_level, str(event or "event")[:240], str(method or "")[:12],
str(path or "")[:500], status, duration_ms, run_id, prospect_id, encoded, str(error or "")[:2_000]),
)
# Retain enough history for investigation without allowing a busy
# browser session to grow the SQLite volume indefinitely.
if int(cur.lastrowid or 0) % 100 == 0:
conn.execute("DELETE FROM logs WHERE id NOT IN (SELECT id FROM logs ORDER BY id DESC LIMIT ?)", (LOG_RETENTION,))
conn.commit()
finally:
conn.close()
except Exception:
return
def backend_mode() -> str:
configured = os.environ.get("DISCOVERY_BACKEND", "").strip().lower()
if configured in {"gmaps", "browser"}:
@@ -205,6 +262,21 @@ def db() -> sqlite3.Connection:
raw_json TEXT NOT NULL DEFAULT '{}',
UNIQUE(prospect_id, domain)
)""")
conn.execute("""CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'system',
level TEXT NOT NULL DEFAULT 'info',
event TEXT NOT NULL,
method TEXT NOT NULL DEFAULT '',
path TEXT NOT NULL DEFAULT '',
status INTEGER,
duration_ms INTEGER,
run_id INTEGER,
prospect_id INTEGER,
details TEXT NOT NULL DEFAULT '{}',
error TEXT NOT NULL DEFAULT ''
)""")
# Migrate databases created by v1-v5 without destroying the user's
# catalogue. SQLite permits adding nullable/defaulted columns in place.
@@ -268,6 +340,9 @@ def db() -> sqlite3.Connection:
conn.execute("CREATE INDEX IF NOT EXISTS idx_prospects_score ON prospects(opportunity_score DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_runs_created ON runs(id DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_website_candidates_prospect ON website_candidates(prospect_id, confidence DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_logs_created ON logs(id DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_logs_kind ON logs(kind, id DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_logs_level ON logs(level, id DESC)")
conn.commit()
_schema_path = DB_PATH
_schema_lock.release()
@@ -507,7 +582,19 @@ def _upstream_error(prefix: str, code: int, detail: str) -> str:
return f"{prefix}_http_{code}:{detail}"
def _log_http_kind(error_prefix: str) -> str:
return "ai" if error_prefix == "ai" else "search" if error_prefix == "gmaps" else "api"
def _safe_log_url(url: str) -> str:
parsed = urlparse(url)
return f"{parsed.scheme}://{parsed.netloc}{parsed.path}"[:700]
def http_json(method: str, url: str, payload: Any = None, headers: dict[str, str] | None = None, timeout: float = 20, error_prefix: str = "upstream") -> Any:
started = time.monotonic()
log_kind = _log_http_kind(error_prefix)
log_details = {"url": _safe_log_url(url), "timeout_seconds": timeout, "request": payload}
data = None if payload is None else json.dumps(payload).encode("utf-8")
request_headers = {"Accept": "application/json", "User-Agent": OUTBOUND_USER_AGENT, **(headers or {})}
if data is not None:
@@ -516,19 +603,32 @@ def http_json(method: str, url: str, payload: Any = None, headers: dict[str, str
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
body = response.read()
response_status = int(getattr(response, "status", 200) or 200)
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")[:300]
record_log(log_kind, "upstream_http_error", level="error", method=method, path=_safe_log_url(url), status=int(exc.code),
duration_ms=int((time.monotonic() - started) * 1000), details={**log_details, "response": detail}, error=_upstream_error(error_prefix, int(exc.code), detail))
raise RuntimeError(_upstream_error(error_prefix, int(exc.code), detail)) from exc
except urllib.error.URLError as exc:
record_log(log_kind, "upstream_unreachable", level="error", method=method, path=_safe_log_url(url),
duration_ms=int((time.monotonic() - started) * 1000), details=log_details, error=str(exc.reason)[:500])
raise RuntimeError(f"{error_prefix}_unreachable:{exc.reason}") from exc
try:
return json.loads(body or b"{}")
parsed = json.loads(body or b"{}")
record_log(log_kind, "upstream_request", method=method, path=_safe_log_url(url), status=response_status,
duration_ms=int((time.monotonic() - started) * 1000), details={**log_details, "response": parsed})
return parsed
except json.JSONDecodeError as exc:
raise RuntimeError("gmaps_invalid_json") from exc
record_log(log_kind, "upstream_invalid_json", level="error", method=method, path=_safe_log_url(url), status=response_status,
duration_ms=int((time.monotonic() - started) * 1000), details={**log_details, "response": body[:2_000].decode("utf-8", "replace")}, error=str(exc))
raise RuntimeError(f"{error_prefix}_invalid_json") from exc
def http_bytes(method: str, url: str, payload: Any = None, headers: dict[str, str] | None = None, timeout: float = 20, error_prefix: str = "upstream") -> bytes:
"""Make a small HTTP request where the response is not JSON (CSV download)."""
started = time.monotonic()
log_kind = _log_http_kind(error_prefix)
log_details = {"url": _safe_log_url(url), "timeout_seconds": timeout, "request": payload}
data = None if payload is None else json.dumps(payload).encode("utf-8")
request_headers = {"Accept": "*/*", "User-Agent": OUTBOUND_USER_AGENT, **(headers or {})}
if data is not None:
@@ -536,11 +636,19 @@ def http_bytes(method: str, url: str, payload: Any = None, headers: dict[str, st
request = urllib.request.Request(url, data=data, headers=request_headers, method=method.upper())
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.read()
body = response.read()
response_status = int(getattr(response, "status", 200) or 200)
record_log(log_kind, "upstream_request", method=method, path=_safe_log_url(url), status=response_status,
duration_ms=int((time.monotonic() - started) * 1000), details={**log_details, "response_bytes": len(body)})
return body
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")[:300]
record_log(log_kind, "upstream_http_error", level="error", method=method, path=_safe_log_url(url), status=int(exc.code),
duration_ms=int((time.monotonic() - started) * 1000), details={**log_details, "response": detail}, error=_upstream_error(error_prefix, int(exc.code), detail))
raise RuntimeError(_upstream_error(error_prefix, int(exc.code), detail)) from exc
except urllib.error.URLError as exc:
record_log(log_kind, "upstream_unreachable", level="error", method=method, path=_safe_log_url(url),
duration_ms=int((time.monotonic() - started) * 1000), details=log_details, error=str(exc.reason)[:500])
raise RuntimeError(f"{error_prefix}_unreachable:{exc.reason}") from exc
@@ -765,7 +873,7 @@ def extract_search_results(page: Any, selector: str, title_selector: str | None,
return output
def google_search(query: str, requested: int) -> list[dict[str, Any]]:
def google_search(query: str, requested: int, run_id: int | None = None) -> list[dict[str, Any]]:
try:
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
except ImportError as exc:
@@ -793,6 +901,7 @@ def google_search(query: str, requested: int) -> list[dict[str, Any]]:
break
context = None
try:
record_log("search", "search_engine_started", run_id=run_id, details={"engine": engine, "query": query, "requested": requested})
context = browser.new_context(locale="en-ZA", java_script_enabled=True, service_workers="block", viewport={"width": 1280, "height": 900})
page = context.new_page()
page.set_default_timeout(ENGINE_TIMEOUT_MS)
@@ -806,15 +915,20 @@ def google_search(query: str, requested: int) -> list[dict[str, Any]]:
html = page.content()
if blocked(body) or blocked(html):
errors.append(engine + "_blocked")
record_log("search", "search_engine_blocked", level="warn", run_id=run_id, details={"engine": engine, "query": query})
continue
output = extract_search_results(page, selector, title_selector, excluded_hosts, requested)
if output:
record_log("search", "search_engine_results", run_id=run_id, details={"engine": engine, "query": query, "count": len(output), "results": output})
return output
errors.append(engine + ("_timeout" if navigation_timed_out else "_empty"))
record_log("search", "search_engine_empty", level="warn", run_id=run_id, details={"engine": engine, "query": query, "navigation_timed_out": navigation_timed_out})
except PlaywrightTimeout:
errors.append(engine + "_timeout")
except Exception:
record_log("search", "search_engine_timeout", level="warn", run_id=run_id, details={"engine": engine, "query": query})
except Exception as exc:
errors.append(engine + "_error")
record_log("search", "search_engine_error", level="error", run_id=run_id, details={"engine": engine, "query": query}, error=str(exc)[:500])
finally:
if context is not None:
try:
@@ -823,7 +937,9 @@ def google_search(query: str, requested: int) -> list[dict[str, Any]]:
pass
finally:
browser.close()
raise RuntimeError("search_engines_unavailable:" + ",".join(errors))
message = "search_engines_unavailable:" + ",".join(errors)
record_log("search", "search_failed", level="error", run_id=run_id, details={"query": query, "errors": errors}, error=message)
raise RuntimeError(message)
def validate_payload(payload: dict[str, Any]) -> tuple[str, str, str, int, bool, bool]:
@@ -859,6 +975,10 @@ def update_run(run_id: int, **fields: Any) -> None:
conn.commit()
finally:
conn.close()
if any(key in safe for key in ("stage", "status", "error")):
record_log("system", "run_state_updated", level="error" if safe.get("status") == "failed" else "info",
run_id=run_id, details={key: value for key, value in safe.items() if key in {"stage", "status", "progress", "found", "added", "error", "external_job_id"}},
error=str(safe.get("error") or ""))
def _json_list(value: Any) -> list[Any]:
@@ -873,10 +993,19 @@ def enrich_place(item: dict[str, Any]) -> dict[str, Any]:
"""Add website health, public email evidence, and explainable scoring."""
prior_evidence = _json_list(item.get("email_evidence"))
if item.get("website"):
scan_started = time.monotonic()
record_log("search", "website_scan_started", prospect_id=as_int(item.get("id")) or None,
details={"business": item.get("name"), "url": _safe_log_url(str(item.get("website")))})
try:
item.update(scan_website(str(item["website"])))
record_log("search", "website_scan_completed", prospect_id=as_int(item.get("id")) or None,
duration_ms=int((time.monotonic() - scan_started) * 1000),
details={"business": item.get("name"), "url": _safe_log_url(str(item.get("website"))), "status": item.get("website_status"), "emails_found": len(item.get("website_emails") or [])})
except Exception as exc: # a single hostile site must not abort a run
item.update({"website_status": "unknown", "website_checked_at": now(), "website_error": str(exc)[:300], "website_emails": [], "email_evidence": []})
record_log("search", "website_scan_failed", level="error", prospect_id=as_int(item.get("id")) or None,
duration_ms=int((time.monotonic() - scan_started) * 1000),
details={"business": item.get("name"), "url": _safe_log_url(str(item.get("website")))}, error=str(exc)[:500])
else:
item.setdefault("website_status", "no_detected_website")
item.setdefault("website_emails", [])
@@ -935,6 +1064,7 @@ def insert_catalogue(run_id: int, query: str, raw_results: list[dict[str, Any]],
progress(index, total)
emails = set(normalize_public_emails(item.get("emails")))
if item["domain"].lower() in suppressed_domains or emails.intersection(suppressed_emails):
record_log("system", "prospect_suppressed", run_id=run_id, details={"name": item.get("name"), "domain": item.get("domain"), "email_match": bool(emails.intersection(suppressed_emails))})
continue
try:
cur = conn.execute("""INSERT OR IGNORE INTO prospects(
@@ -961,6 +1091,11 @@ def insert_catalogue(run_id: int, query: str, raw_results: list[dict[str, Any]],
continue
if cur.rowcount:
added += 1
conn.commit()
record_log("system", "prospect_added", run_id=run_id, details={"name": item.get("name"), "domain": item.get("domain"), "score": item.get("opportunity_score")})
else:
conn.commit()
record_log("system", "prospect_duplicate", run_id=run_id, details={"name": item.get("name"), "domain": item.get("domain")})
conn.commit()
finally:
conn.close()
@@ -972,6 +1107,7 @@ def run_discovery(run_id: int, payload: dict[str, Any]) -> None:
# Serialize local runs so two personal browser jobs do not fight over the
# same machine's Chromium/RAM. The Maps worker itself remains async.
with _discover_lock:
record_log("search", "discovery_started", run_id=run_id, details={"query": query, "location": location, "requested": requested, "backend": backend_mode(), "email_collection": include_email})
update_run(run_id, status="running", stage="connecting", progress=5, source=backend_mode())
try:
if backend_mode() == "gmaps":
@@ -985,7 +1121,7 @@ def run_discovery(run_id: int, payload: dict[str, Any]) -> None:
raw_results = poll_gmaps_job(external_job_id, on_progress)
else:
update_run(run_id, stage="browser_search", progress=20)
raw_results = google_search(query, requested)
raw_results = google_search(query, requested, run_id=run_id)
update_run(run_id, stage="website_analysis", progress=76, found=len(raw_results))
def enrichment_progress(index: int, total: int) -> None:
@@ -994,9 +1130,11 @@ def run_discovery(run_id: int, payload: dict[str, Any]) -> None:
results, added = insert_catalogue(run_id, query, raw_results, requested, enrichment_progress, search_category=category)
update_run(run_id, found=len(results), added=added, status="succeeded", stage="complete", progress=100, completed_at=now(), error="")
record_log("search", "discovery_completed", run_id=run_id, details={"found": len(results), "added": added, "backend": backend_mode()})
except Exception as exc:
message = str(exc).strip()[:500] or "discovery_failed"
update_run(run_id, status="failed", stage="failed", progress=100, error=message, completed_at=now())
record_log("search", "discovery_failed", level="error", run_id=run_id, details={"backend": backend_mode()}, error=message)
def create_discovery(payload: dict[str, Any]) -> dict[str, Any]:
@@ -1205,6 +1343,10 @@ def ai_assess_prospect(prospect: dict[str, Any]) -> dict[str, Any]:
class Handler(BaseHTTPRequestHandler):
server_version = "PersonalProspectOS/2.0"
def handle_one_request(self) -> None:
self._request_started_at = time.monotonic()
super().handle_one_request()
def send_json(self, status: int, value: Any) -> None:
body = json_bytes(value)
self.send_response(status)
@@ -1213,6 +1355,15 @@ class Handler(BaseHTTPRequestHandler):
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
if str(getattr(self, "path", "")).startswith("/api/"):
error_value = value.get("error") if isinstance(value, dict) else ""
record_log(
"api", f"{getattr(self, 'command', 'HTTP')} {urlparse(self.path).path}",
level="error" if status >= 400 else "info", method=getattr(self, "command", "HTTP"),
path=urlparse(self.path).path, status=status,
duration_ms=int((time.monotonic() - getattr(self, "_request_started_at", time.monotonic())) * 1000),
details={"response_status": status}, error=str(error_value or "")[:500],
)
def read_json(self) -> dict[str, Any]:
length = min(int(self.headers.get("Content-Length", "0") or 0), 100_000)
@@ -1255,6 +1406,39 @@ class Handler(BaseHTTPRequestHandler):
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/logs":
params = parse_qs(parsed.query)
kind = (params.get("kind", [""])[0] or "").strip().lower()
level = (params.get("level", [""])[0] or "").strip().lower()
query = (params.get("q", [""])[0] or "").strip().lower()
try:
limit = max(1, min(500, int(params.get("limit", ["200"])[0])))
except (TypeError, ValueError):
limit = 200
clauses: list[str] = []
values: list[Any] = []
if kind and kind != "all":
clauses.append("kind=?"); values.append(kind)
if level and level != "all":
clauses.append("level=?"); values.append(level)
if query:
clauses.append("(lower(event) LIKE ? OR lower(path) LIKE ? OR lower(error) LIKE ? OR lower(details) LIKE ?)")
needle = "%" + query[:120] + "%"; values.extend([needle, needle, needle, needle])
where = " WHERE " + " AND ".join(clauses) if clauses else ""
rows = conn.execute("SELECT * FROM logs" + where + " ORDER BY id DESC LIMIT ?", (*values, limit)).fetchall()
items: list[dict[str, Any]] = []
for row in rows:
item = dict(row)
try:
item["details"] = json.loads(item.get("details") or "{}")
except (TypeError, ValueError, json.JSONDecodeError):
item["details"] = {"raw": str(item.get("details") or "")[:20_000]}
items.append(item)
total = int(conn.execute("SELECT COUNT(*) FROM logs" + where, tuple(values)).fetchone()[0])
counts = {str(row[0]): int(row[1]) for row in conn.execute("SELECT kind,COUNT(*) FROM logs GROUP BY kind")}
error_count = int(conn.execute("SELECT COUNT(*) FROM logs WHERE level='error'").fetchone()[0])
counts["errors"] = error_count
return self.send_json(200, {"items": items, "total": total, "counts": counts})
if parsed.path == "/api/settings":
ai_runtime = ai_runtime_settings()
models = nous_models() if ai_runtime["provider"] == "nous" else []
@@ -1345,6 +1529,21 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.write(content)
def do_POST(self) -> None:
if urlparse(self.path).path == "/api/logs":
try:
payload = self.read_json()
action = str(payload.get("action") or "").strip()
if not action:
return self.send_json(400, {"error": "action_required"})
level = str(payload.get("level") or "info").strip().lower()
if level not in {"info", "warn", "error"}:
level = "info"
details = payload.get("details") if isinstance(payload.get("details"), (dict, list, str, int, float, bool)) else {}
record_log("action", action, level=level, method="CLIENT", path=str(payload.get("path") or "")[:500], details=details,
error=str(payload.get("error") or "")[:500])
return self.send_json(202, {"logged": True})
except (ValueError, TypeError, json.JSONDecodeError) as exc:
return self.send_json(400, {"error": str(exc)})
if self.path == "/api/settings":
try:
payload = self.read_json(); ai = payload.get("ai") if isinstance(payload.get("ai"), dict) else {}
@@ -1389,10 +1588,18 @@ class Handler(BaseHTTPRequestHandler):
return self.send_json(503, {"error": "browser_use_disabled"})
try:
runtime = ai_runtime_settings()
record_log("browser", "website_investigation_started", prospect_id=prospect_id,
details={"business": row["name"], "model": runtime["model"], "public_only": True})
def browser_log(kind: str, event: str, details: dict[str, Any]) -> None:
record_log(kind, event, prospect_id=prospect_id, details=details)
candidates = investigate_websites(
dict(row), base_url=runtime["base_url"], api_key=runtime["api_key"],
model=runtime["model"], user_agent=OUTBOUND_USER_AGENT,
model=runtime["model"], user_agent=OUTBOUND_USER_AGENT, log_callback=browser_log,
)
record_log("browser", "website_investigation_completed", prospect_id=prospect_id,
details={"candidate_count": len(candidates), "model": runtime["model"]})
conn = db()
for candidate in candidates:
encoded_evidence = json.dumps(candidate.get("evidence") or [], ensure_ascii=False)
@@ -1411,8 +1618,10 @@ class Handler(BaseHTTPRequestHandler):
rows = conn.execute("SELECT * FROM website_candidates WHERE prospect_id=? ORDER BY confidence DESC, id DESC", (prospect_id,)).fetchall()
return self.send_json(200, {"items": [dict(item) for item in rows], "count": len(rows)})
except RuntimeError as exc:
record_log("browser", "website_investigation_failed", level="error", prospect_id=prospect_id, error=str(exc))
return self.send_json(503, {"error": str(exc)})
except Exception as exc:
record_log("browser", "website_investigation_failed", level="error", prospect_id=prospect_id, error=str(exc)[:500])
return self.send_json(502, {"error": "browser_use_failed:" + str(exc)[:240]})
finally:
try:
+1
View File
@@ -29,6 +29,7 @@ services:
BROWSER_USE_MAX_STEPS: ${BROWSER_USE_MAX_STEPS:-10}
BROWSER_USE_TIMEOUT_SECONDS: ${BROWSER_USE_TIMEOUT_SECONDS:-120}
ANONYMIZED_TELEMETRY: ${ANONYMIZED_TELEMETRY:-false}
LOG_RETENTION: ${LOG_RETENTION:-20000}
AI_PROVIDER: ${AI_PROVIDER:-custom}
AI_BASE_URL: ${AI_BASE_URL:-}
AI_API_KEY: ${AI_API_KEY:-}
+13
View File
@@ -198,6 +198,19 @@ class BrowserOnlyAppTests(unittest.TestCase):
self.assertEqual(json.loads(saved[0]), body["unknowns"])
self.assertEqual(json.loads(saved[1]), body["evidence_used"])
def test_logs_capture_api_actions_and_redact_credentials(self):
server.record_log("ai", "test_ai_call", details={"api_key": "do-not-store", "model": "test", "prompt": "hello"})
status, body = self.request("POST", "/api/logs", {"action": "ui_test", "details": {"button": "test"}})
self.assertEqual(status, 202)
status, body = self.request("GET", "/api/logs?kind=ai")
self.assertEqual(status, 200)
item = next(row for row in body["items"] if row["event"] == "test_ai_call")
self.assertEqual(item["details"]["api_key"], "[redacted]")
self.assertEqual(item["details"]["model"], "test")
status, body = self.request("GET", "/api/logs?kind=action")
self.assertEqual(status, 200)
self.assertTrue(any(row["event"] == "ui_test" for row in body["items"]))
if __name__ == "__main__":
unittest.main()
+18 -2
View File
@@ -1,5 +1,10 @@
const $=id=>document.getElementById(id); const esc=v=>String(v??"").replace(/[&<>\"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',"'":'&#39;'}[c]));
let prospects=[],runs=[],settings={},selected=null,pollTimer=null;
let prospects=[],runs=[],settings={},logs=[],selected=null,pollTimer=null;
let activeLogTab='all';
const LOG_TABS=[['all','All'],['action','Actions'],['api','API calls'],['ai','AI calls'],['search','Searches'],['browser','Browser/tool steps'],['errors','Errors']];
function logAction(action,details={},level='info'){try{fetch('/api/logs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action,details,level,path:location.hash.slice(1)||'dashboard'})}).catch(()=>{})}catch{}}
function installLogsNav(){const nav=$('nav');if(!nav||nav.querySelector('[data-route="logs"]'))return;const link=document.createElement('a');link.href='#logs';link.dataset.route='logs';link.innerHTML='<span>≋</span>Logs';nav.append(link)}
installLogsNav();
let activeProspectCategory='all';
const HIGH_POTENTIAL_SCORE=70;
async function api(path,opt={}){const r=await fetch(path,{...opt,headers:{'Content-Type':'application/json',...(opt.headers||{})}});const b=await r.json().catch(()=>({}));if(!r.ok)throw Error(b.error||`Request failed (${r.status})`);return b}
@@ -15,9 +20,14 @@ 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','Browse the strongest opportunities first, then switch to a search category when you want a focused list.',`<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="prospectTabs" class="prospect-tabs">${prospectTabs()}</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 logTabCount(tab,counts){return tab==='errors'?Number(counts?.errors||0):Number(counts?.[tab]||0)}
function logTabs(counts={}){return `<div class="logs-tab-list">${LOG_TABS.map(([key,label])=>`<button class="logs-tab ${activeLogTab===key?'active':''}" data-log-tab="${key}">${label}<span>${logTabCount(key,counts)}</span></button>`).join('')}</div>`}
function renderLogTable(){const rows=Array.isArray(logs)?logs:[];return rows.length?`<div class="table-wrap log-table-wrap"><table><thead><tr><th>Time</th><th>Type</th><th>Event</th><th>Status</th><th>Duration</th><th>Details</th></tr></thead><tbody>${rows.map(row=>{const details=typeof row.details==='string'?row.details:JSON.stringify(row.details||{},null,2);return `<tr><td>${esc(date(row.created_at))}</td><td><span class="log-kind ${esc(row.level||'info')}">${esc(row.kind||'system')}</span></td><td><b>${esc(row.event||'')}</b>${row.path?`<small>${esc(row.method||'')} ${esc(row.path)}</small>`:''}${row.error?`<small class="log-error">${esc(row.error)}</small>`:''}</td><td>${row.status?esc(row.status):'—'}</td><td>${row.duration_ms!=null?esc(row.duration_ms)+' ms':'—'}</td><td><details><summary>View</summary><pre>${esc(details)}</pre></details></td></tr>`}).join('')}</tbody></table></div>`:empty('No log entries for this tab')}
async function refreshLogs(){const status=$('logsStatus');if(status)status.textContent='Loading logs…';try{const params=new URLSearchParams({limit:'250'});if(activeLogTab==='errors')params.set('level','error');else if(activeLogTab!=='all')params.set('kind',activeLogTab);const data=await api('/api/logs?'+params.toString());logs=data.items||[];const tabs=$('logsTabs');if(tabs)tabs.innerHTML=logTabs(data.counts||{});const table=$('logsTable');if(table)table.innerHTML=renderLogTable();if(status)status.textContent=`Showing ${logs.length} of ${data.total||logs.length} entries · credentials are redacted`; }catch(error){if(status)status.textContent=error.message;if($('logsTable'))$('logsTable').innerHTML=empty('Logs could not be loaded')}}
function renderLogs(){ $('view').innerHTML=layout('Logs','Inspect user actions, API calls, AI requests, searches, Browser Use steps, and errors in one place.',`<article class="panel logs-panel"><div class="panel-title"><div><span class="eyebrow">OPERATIONS JOURNAL</span><h2>Detailed activity</h2><p>Requests and actions are timestamped. API keys, authorization headers, and passwords are never stored.</p></div><div class="head-actions"><button class="secondary" id="refreshLogs">Refresh</button></div></div><div id="logsTabs" class="logs-tabs">${logTabs()}</div><div id="logsStatus" class="notice">Loading logs…</div><div id="logsTable"></div></article>`);refreshLogs()}
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 route(){const r=location.hash.slice(1)||'dashboard';installLogsNav();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,logs:renderLogs,settings:renderSettings}[r]||renderDashboard)();$('updated').textContent='Updated '+new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});}catch(e){$('view').innerHTML=empty(e.message)}}
function suggestedOffer(p){if(p.ai_offer)return p.ai_offer;if(['broken','unreachable','error','slow'].includes(String(p.website_status||'')))return'Move their hosting to our Gold Plus package at R119/month and rebuild the website at no extra design charge.';if(!p.website)return'Offer a professional .co.za domain, reliable hosting from R119/month, professional email, and a free one-page website.';return'Offer a friendly website and hosting review; only suggest a change that is supported by the evidence.'}
function jsonArray(value){try{const parsed=JSON.parse(value||'[]');return Array.isArray(parsed)?parsed:[]}catch{return[]}}
function showDraftNotes(d){let notes=$('draftNotes');if(!notes){notes=document.createElement('div');notes.id='draftNotes';notes.className='draft-notes';const status=$('messageStatus');if(status)status.parentNode.insertBefore(notes,status)}if(!notes)return;const unknowns=Array.isArray(d?.unknowns)?d.unknowns:[];const used=Array.isArray(d?.evidence_used)?d.evidence_used:[];notes.innerHTML=`<b>AI review notes</b><p>${esc(d?.rationale||'The draft uses the prospect evidence and your instructions.')}</p>${unknowns.length?`<p><b>Confirm before sending:</b> ${esc(unknowns.join(' · '))}</p>`:''}${used.length?`<p class="muted">Evidence used: ${esc(used.join(', '))}</p>`:''}${d?.follow_up?`<p class="muted">${esc(d.follow_up)}</p>`:''}`}
@@ -39,3 +49,9 @@ async function findWebsiteCandidates(id,button){if(button){button.disabled=true;
document.addEventListener('click',async e=>{const find=e.target.closest('[data-find-websites]');if(find){e.preventDefault();e.stopPropagation();await findWebsiteCandidates(find.dataset.findWebsites,find);return}const accept=e.target.closest('[data-accept-website]');if(accept){e.preventDefault();e.stopPropagation();accept.disabled=true;accept.textContent='Checking…';try{const d=await api(`/api/prospects/${accept.dataset.prospectId}/website-candidates/${accept.dataset.acceptWebsite}`,{method:'PATCH',body:JSON.stringify({status:'accepted'})});if(d.prospect){const i=prospects.findIndex(p=>String(p.id)===String(d.prospect.id));if(i>=0)prospects[i]={...prospects[i],...d.prospect};selected=d.prospect}openDrawer(accept.dataset.prospectId);if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()}catch(error){accept.disabled=false;accept.textContent='Accept domain';const panel=$('websiteCandidatesPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}}return}const reject=e.target.closest('[data-reject-website]');if(reject){e.preventDefault();e.stopPropagation();reject.disabled=true;try{const d=await api(`/api/prospects/${reject.dataset.prospectId}/website-candidates/${reject.dataset.rejectWebsite}`,{method:'PATCH',body:JSON.stringify({status:'rejected'})});const current=await api(`/api/prospects/${reject.dataset.prospectId}/website-candidates`);renderWebsiteCandidates(current.items||[],reject.dataset.prospectId)}catch(error){reject.disabled=false;const panel=$('websiteCandidatesPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}}}});
window.addEventListener('hashchange',route);$('refreshAll').addEventListener('click',route);route();
document.addEventListener('click',e=>{const tab=e.target.closest('[data-category-tab]');if(!tab)return;e.preventDefault();e.stopPropagation();activeProspectCategory=tab.dataset.categoryTab||'all';if($('prospectTabs'))$('prospectTabs').innerHTML=prospectTabs();if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()});
document.addEventListener('click',e=>{const tab=e.target.closest('[data-log-tab]');if(tab){e.preventDefault();activeLogTab=tab.dataset.logTab||'all';refreshLogs();return}if(e.target.id==='refreshLogs')refreshLogs()});
document.addEventListener('click',e=>{const target=e.target.closest('button,a,select,input,textarea,tr,[role="button"]');if(!target)return;const details={tag:target.tagName.toLowerCase(),id:target.id||'',label:target.getAttribute('aria-label')||target.getAttribute('title')||'',route:location.hash.slice(1)||'dashboard'};for(const name of ['open','message','assess','findWebsites','acceptWebsite','rejectWebsite','categoryTab','logTab','review'])if(target.dataset[name])details[name]=target.dataset[name];logAction('ui_click',details)},true);
document.addEventListener('change',e=>{const target=e.target;if(!target||!target.id&&!target.dataset.review)return;logAction('ui_change',{tag:target.tagName.toLowerCase(),id:target.id||'',review_id:target.dataset.review||'',route:location.hash.slice(1)||'dashboard'})},true);
document.addEventListener('submit',e=>{if(e.target?.id)logAction('ui_submit',{form:e.target.id,route:location.hash.slice(1)||'dashboard'})},true);
window.addEventListener('error',e=>logAction('client_error',{message:String(e.message||'Unknown browser error').slice(0,500),source:String(e.filename||'').slice(0,500),line:e.lineno||0},'error'));
window.addEventListener('unhandledrejection',e=>logAction('client_unhandled_rejection',{message:String(e.reason?.message||e.reason||'Unhandled promise rejection').slice(0,500)},'error'));
+1 -1
View File
@@ -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-v16"></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=prospects-tabs-v1"></script></body></html>
<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=logs-v1"></script></body></html>
+1
View File
@@ -106,6 +106,7 @@ input::placeholder { color: #aeb6c5; } input:focus, select:focus { border-color:
.setup-section { max-width: 1160px; display: grid; grid-template-columns: 1.2fr 1fr; gap: 30px; margin-top: 17px; padding: 22px 24px; border: 1px solid #e3e6ee; border-radius: 13px; background: #fbfcfe; }.setup-section h2 { margin: 5px 0; color: #38445a; font-size: 15px; }.setup-section p, .setup-section ol { margin: 6px 0 0; color: #8792a5; font-size: 11px; }.setup-section ol { padding-left: 17px; line-height: 1.9; }.setup-section code { padding: 2px 4px; border-radius: 4px; background: #eff1f6; color: #5d687d; font-size: 10px; }.footer { max-width: 1160px; display: flex; justify-content: space-between; gap: 20px; padding: 23px 2px 0; color: #a3acbb; font-size: 10px; }
.muted { color: #9ca5b5; font-size: 11px; }
.logs-panel{min-width:0}.logs-tabs{margin:22px 0 12px;padding:2px 0 0;border-bottom:1px solid #edf0f5}.logs-tab-list{display:flex;gap:5px;overflow-x:auto}.logs-tab{border:0;border-bottom:2px solid transparent;background:transparent;color:#718097;padding:11px 12px;margin-bottom:-1px;white-space:nowrap;font:inherit;font-size:11px;font-weight:800;cursor:pointer}.logs-tab:hover{color:#4f47df}.logs-tab.active{border-color:#635bff;color:#4f47df}.logs-tab span{display:inline-grid;place-items:center;min-width:19px;height:18px;margin-left:4px;padding:0 5px;border-radius:99px;background:#eef1f7;color:#68758a;font-size:9px}.logs-tab.active span{background:#eeecff;color:#574fe2}.log-table-wrap table{min-width:980px}.log-kind{display:inline-block;padding:5px 7px;border-radius:99px;background:#edf1f8;color:#5d6c83;font-size:9px;font-weight:800;text-transform:uppercase}.log-kind.error{background:#ffe7e9;color:#bd4253}.log-kind.warn{background:#fff2d7;color:#a16e17}.log-table-wrap td{vertical-align:top}.log-table-wrap td small{max-width:300px;overflow-wrap:anywhere}.log-error{color:#bd4253!important}.log-table-wrap details{max-width:370px}.log-table-wrap summary{color:#5b50d3;cursor:pointer;font-size:11px;font-weight:750}.log-table-wrap pre{max-height:240px;overflow:auto;margin:8px 0 0;padding:9px;border-radius:7px;background:#f6f7fb;color:#536178;font:10px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;overflow-wrap:anywhere}
.draft-notes{padding:12px;border:1px solid #e6eaf2;border-radius:9px;background:#fafbff;color:#536178;font-size:11px;line-height:1.5}.draft-notes b{color:#334158}.draft-notes p{margin:4px 0}.draft-notes .muted{font-size:10px}
.prospect-tabs{margin:4px 0 22px;padding:14px 0 0;border-top:1px solid #edf0f5}.prospect-tab-list{display:flex;gap:8px;overflow-x:auto;padding:2px 1px 3px}.prospect-tab{border:1px solid #dfe4ed;background:#fff;color:#536178;border-radius:999px;padding:9px 13px;white-space:nowrap;font:inherit;font-size:12px;font-weight:750;cursor:pointer;transition:.18s}.prospect-tab:hover{border-color:#b9b5ff;background:#f7f6ff;color:#4f47df}.prospect-tab.active{background:#635bff;border-color:#635bff;color:#fff;box-shadow:0 6px 14px #635bff2e}.prospect-tab span{display:inline-grid;place-items:center;min-width:20px;height:20px;margin-left:5px;padding:0 5px;border-radius:99px;background:#eef1f7;color:#647188;font-size:10px}.prospect-tab.active span{background:#fff3;color:#fff}.tab-hint{margin:10px 0 0;color:#8a96a8;font-size:11px}.candidate-list{display:grid;gap:10px;margin-top:15px}.candidate-card{padding:13px;border:1px solid #e6eaf2;border-radius:10px;background:#fbfcff}.candidate-card>div:first-child{display:flex;align-items:center;gap:8px}.candidate-card a{color:#4f47df;font-weight:750;text-decoration:none;font-size:13px}.candidate-card p{font-size:12px!important;color:#68758a!important}.candidate-card ul{padding-left:18px;margin:7px 0}.candidate-card li{font-size:11px!important;padding:2px 0!important}.candidate-actions{display:flex;gap:7px;margin-top:10px}.candidate-actions button{padding:8px 10px;font-size:11px}