diff --git a/.env.example b/.env.example index 5ca5cf1..8d66861 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index d5eb17d..5c06968 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/api/server.py b/api/server.py index 2fd6dcc..e2d1297 100644 --- a/api/server.py +++ b/api/server.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index b74095b..473b630 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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:-} diff --git a/tests/test_app.py b/tests/test_app.py index 4ed86bd..5856816 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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() diff --git a/web/app.js b/web/app.js index 92ec321..674e1ab 100644 --- a/web/app.js +++ b/web/app.js @@ -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.',`
NEW RUN

Find potential customers

Search Google Maps, deduplicate the catalogue, then enrich each result.

Email collection always on
HOW IT WORKS

Evidence-first pipeline

  1. SearchMaps worker finds public business listings.
  2. EnrichWebsite, HTTPS, DNS hints and public email checks run safely.
  3. ReviewYou decide what is relevant before any contact.
`)} function renderProspects(){ $('view').innerHTML=layout('Prospects','Search, filter and review your catalogue. Click a row to inspect the evidence.',`
${prospectTable()}
`)} function renderRuns(){ $('view').innerHTML=layout('Runs','Monitor discovery jobs and inspect errors without leaving the run history.',`
${runs.map(r=>``).join('')}
RunStatusResultsStartedSource
#${r.id}${esc(r.query)}${esc(r.status)}${esc(r.stage||'')}${r.added||0} added / ${r.found||0} found${date(r.created_at)}${esc(r.source||'google_maps')}
${runs.length?'':empty('No discovery runs yet')}
`)} -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.',`
SOURCE

Google Maps worker

The bundled standard gosom worker runs locally and does not require an API key.

${settings.maps?.ready?'Ready':'Not connected'}
Connection${esc(settings.maps?.worker_url||'Not configured')}
Email extractionAlways on · public pages only
Website scan8 second bounded timeout
AI PROVIDER

Draft assistant

Used only when you click “Draft AI email”.

SCHEDULES

Recurring discovery

Run bounded searches automatically; all results still arrive as needs-review.

${sch.map(s=>`
${esc(s.keywords)} · ${esc(s.category)}${esc(s.location)} · every ${s.interval_hours}h · next ${date(s.next_run_at)}
`).join('')||'

No schedules configured.

'}
SAFETY

Suppression centre

Suppressed domains and emails are excluded before new prospects are saved.

${sup.map(s=>`
${esc(s.value)}${esc(s.kind)} · ${esc(s.reason||'manual')} · ${date(s.created_at)}
`).join('')||'

No suppressions configured.

'}
`)} +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=>``).join('')+(selectedModel&&!modelList.some(m=>m.id===selectedModel)?``:'');$('view').innerHTML=layout('Settings','Configure providers, automation and safety controls here. These settings are separate from daily discovery.',`
SOURCE

Google Maps worker

The bundled standard gosom worker runs locally and does not require an API key.

${settings.maps?.ready?'Ready':'Not connected'}
Connection${esc(settings.maps?.worker_url||'Not configured')}
Email extractionAlways on · public pages only
Website scan8 second bounded timeout
AI PROVIDER

Draft assistant

Used only when you click “Draft AI email”.

${ai.configured?'Configured':'Not configured'}

${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.'}

Nous model availability can change. Refresh Settings to update the free catalog.

SCHEDULES

Recurring discovery

Run bounded searches automatically; all results still arrive as needs-review.

${sch.map(s=>`
${esc(s.keywords)} · ${esc(s.category)}${esc(s.location)} · every ${s.interval_hours}h · next ${date(s.next_run_at)}
`).join('')||'

No schedules configured.

'}
SAFETY

Suppression centre

Suppressed domains and emails are excluded before new prospects are saved.

${sup.map(s=>`
${esc(s.value)}${esc(s.kind)} · ${esc(s.reason||'manual')} · ${date(s.created_at)}
`).join('')||'

No suppressions configured.

'}
`)} 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=`
${p.opportunity_score||0}
Opportunity scoreExplainable triage signal, not an AI verdict

Contact

${esc(p.emails||'No public email found')}

${esc(p.phone||'No phone listed')}

Website health

Status${esc((p.website_status||'unknown').replaceAll('_',' '))}HTTPS${p.website_https?'Yes':'No / unknown'}Latency${p.website_latency_ms?p.website_latency_ms+' ms':'—'}Checked${date(p.website_checked_at)}

Why this scored

${p.website?`Open website ↗`:''}
`;$('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=>``).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(); diff --git a/web/index.html b/web/index.html index 8db0eec..504e858 100644 --- a/web/index.html +++ b/web/index.html @@ -1,3 +1,3 @@ -ProspectOS +ProspectOS
Workspace/Dashboard
—

Tailor a message

Use the evidence and your offer to create an editable draft.

diff --git a/web/style.css b/web/style.css index ee1c4b1..92db6a1 100644 --- a/web/style.css +++ b/web/style.css @@ -17,7 +17,7 @@ .message-dialog{width:min(680px,calc(100% - 32px));border:0;border-radius:18px;padding:0;box-shadow:0 24px 80px #11182733}.message-dialog::backdrop{background:#11182766}.message-dialog form{padding:26px;display:grid;gap:10px}.dialog-head{display:flex;justify-content:space-between;gap:16px;margin-bottom:8px}.message-dialog textarea,.message-dialog input{width:100%;box-sizing:border-box;border:1px solid #d7dbea;border-radius:10px;padding:11px;font:inherit}.dialog-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:8px} /* v7 workspace redesign */ -*{box-sizing:border-box}body{background:#f4f6fa;color:#182338;font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}.app-shell{display:flex;min-height:100vh}.sidebar{width:238px;background:#0d1726;color:#cbd5e1;display:flex;flex-direction:column;padding:25px 16px;position:fixed;inset:0 auto 0 0}.brand{display:flex;align-items:center;gap:11px;padding:0 10px 32px}.brand-mark{width:34px;height:34px;border-radius:10px;background:#635bff;color:white;display:grid;place-items:center;font-weight:900}.brand b{display:block;color:#fff;font-size:15px}.brand small{color:#8290a6;font-size:11px}.sidebar nav{display:grid;gap:5px}.sidebar nav a{color:#9eabc0;text-decoration:none;border-radius:9px;padding:12px 13px;display:flex;gap:12px;align-items:center;font-size:13px}.sidebar nav a span{width:18px;text-align:center;font-size:16px}.sidebar nav a:hover,.sidebar nav a.active{background:#1b2940;color:#fff}.sidebar nav a.active{box-shadow:inset 3px 0 #8179ff}.sidebar-foot{margin-top:auto;border-top:1px solid #233149;padding:17px 8px;display:flex;gap:9px;align-items:flex-start;font-size:11px}.sidebar-foot b{display:block;color:#dfe7f3;font-size:11px}.sidebar-foot small{color:#8190a7}.status-dot{width:8px;height:8px;border-radius:50%;background:#35c98b;margin-top:4px}.main{margin-left:238px;width:calc(100% - 238px);max-width:1500px;padding:0 42px 35px}.topbar{height:70px;border-bottom:1px solid #e5e9f1;display:flex;align-items:center;justify-content:space-between}.crumb{color:#6d7b90}.slash{padding:0 10px;color:#bac3d0}.top-actions{display:flex;gap:14px;align-items:center;color:#8a96a8;font-size:12px}.icon-btn{border:1px solid #dfe4ed;background:#fff;border-radius:9px;width:34px;height:34px;cursor:pointer;font-size:18px;color:#516079}.page-head{display:flex;justify-content:space-between;gap:24px;padding:38px 0 28px;align-items:end}.eyebrow{font-size:10px;letter-spacing:1.8px;color:#655bff;font-weight:800}.page-head h1{font-size:30px;letter-spacing:-.8px;margin:8px 0}.page-head p{margin:0;color:#708099;max-width:650px}.head-actions{display:flex;gap:8px}.metric-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px}.metric,.panel{background:#fff;border:1px solid #e3e8f0;border-radius:14px;box-shadow:0 3px 12px #1f2a3a08}.metric{padding:20px 21px}.metric small{display:block;color:#7b879c;letter-spacing:1.2px;font-size:10px;font-weight:800}.metric b{display:block;font-size:31px;margin:8px 0 2px;letter-spacing:-1px}.metric span{font-size:12px;color:#8894a6}.dashboard-grid,.two-col{display:grid;grid-template-columns:1.4fr 1fr;gap:18px}.panel{padding:23px}.panel-title{display:flex;justify-content:space-between;gap:15px;align-items:flex-start}.panel-title h2{font-size:18px;margin:6px 0}.panel-title p{color:#8491a5;font-size:12px;margin:0;line-height:1.5}.panel-title a{color:#635bff;text-decoration:none;font-size:12px;font-weight:700}.primary,.secondary{border-radius:9px;padding:10px 15px;border:0;font:inherit;font-size:12px;font-weight:800;cursor:pointer;text-decoration:none}.primary{background:#635bff;color:#fff}.secondary{background:#fff;border:1px solid #d9e0eb;color:#344258}.spotlight{margin-top:18px}.spot{display:flex;width:100%;align-items:center;text-align:left;border:0;border-top:1px solid #edf0f5;background:transparent;padding:13px 2px;gap:12px;cursor:pointer;color:inherit}.spot>span:nth-child(2){flex:1}.spot b,.spot small{display:block}.spot small{color:#8995a8;font-size:11px;margin-top:3px}.spot>span:last-child{color:#a3adbc;font-size:20px}.score{display:inline-grid;place-items:center;width:35px;height:28px;border-radius:8px;font-weight:900;font-size:12px}.score.hot{background:#ffe7e9;color:#d14457}.score.warm{background:#fff2d2;color:#aa7510}.score.cool{background:#e7f0ff;color:#3768ba}.activity{margin-top:12px}.activity>div{display:flex;align-items:center;gap:10px;border-top:1px solid #edf0f5;padding:12px 0}.activity>div>span:nth-child(2){flex:1}.activity b,.activity small{display:block}.activity b{font-size:12px}.activity small,.activity time{font-size:11px;color:#8b96a8}.run-dot{width:8px;height:8px;border-radius:50%;background:#aeb7c5}.run-dot.succeeded{background:#2dbd80}.run-dot.failed{background:#dc5663}.empty{padding:38px;text-align:center;color:#8995a8}.empty span{display:block;font-size:28px;margin-bottom:8px}.form-panel{min-height:430px}.form-panel form{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:25px}.form-panel label,.settings-grid label{display:grid;gap:7px;color:#4d5c73;font-size:12px;font-weight:700}.form-panel label:first-child{grid-column:1/-1}.form-panel input,.form-panel select,.settings-grid input,.inline-form input,.inline-form select,.toolbar input,.toolbar select,.message-dialog input,.message-dialog textarea{border:1px solid #dbe2ed;border-radius:8px;padding:11px;background:#fff;font:inherit;color:#21304a;font-size:13px}.checkline{display:flex!important;grid-column:1/-1;align-items:center;gap:8px}.checkline small{font-weight:400;color:#8995a8}.form-row{grid-column:1/-1;display:flex;align-items:center;gap:12px}.notice{font-size:12px;color:#7c899c}.notice.ok{color:#14845d}.notice.error{color:#c44959}.pill,.badge,.status{font-size:10px;font-weight:800;padding:6px 9px;border-radius:99px;background:#ecebff;color:#5b53d8;white-space:nowrap}.steps{list-style:none;padding:0;margin:20px 0}.steps li{border-top:1px solid #edf0f5;padding:15px 0;display:grid;gap:4px}.steps span{font-size:12px;color:#8793a5}.toolbar{display:flex;gap:10px;margin-bottom:18px}.search{display:flex;align-items:center;border:1px solid #dbe2ed;border-radius:8px;background:#fff;padding:0 10px;flex:1}.search input{border:0;outline:0;width:100%}.toolbar select{width:170px}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;font-size:12px}th{text-align:left;color:#7e8a9c;font-size:10px;letter-spacing:.8px;text-transform:uppercase;padding:11px 10px;border-bottom:1px solid #e6ebf2}td{padding:14px 10px;border-bottom:1px solid #edf0f5;vertical-align:middle}td b,td small{display:block}td small{color:#8a96a8;font-size:11px;margin-top:4px}td a{color:#635bff;text-decoration:none;font-size:11px;display:block;margin-top:5px}.badge.good{background:#e2f7ee;color:#19815e}.badge.warn{background:#fff0d3;color:#aa7510}.email{color:#45546d}.table-wrap select{border:1px solid #dbe2ed;background:#fff;border-radius:7px;padding:7px;font-size:11px}.icon-action{border:0;background:#f0efff;color:#635bff;border-radius:7px;padding:8px;cursor:pointer}.settings-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}.wide-panel{grid-column:1/-1}.setting-line{display:flex;justify-content:space-between;border-top:1px solid #edf0f5;padding:13px 0;margin-top:13px;color:#7d899b;font-size:12px}.setting-line b{color:#334158;text-align:right;max-width:60%;overflow-wrap:anywhere}.good-text{color:#14845d!important}.settings-grid form{display:grid;gap:14px;margin-top:18px}.inline-form{display:flex;gap:8px;margin-top:18px}.inline-form>*{flex:1}.list{margin-top:13px}.list>div{border-top:1px solid #edf0f5;padding:12px 0;display:flex;justify-content:space-between;gap:15px;font-size:12px}.list span{color:#8a96a8}.status.succeeded{background:#e1f6ed;color:#16815b}.status.failed{background:#ffe7e9;color:#bc4052}.status.running{background:#e8efff;color:#3d67af}footer{color:#96a1b2;font-size:11px;padding:28px 0}.message-dialog{width:min(700px,calc(100% - 30px));border:0;border-radius:15px;padding:0;box-shadow:0 30px 100px #121b2d44}.message-dialog form{padding:26px;display:grid;gap:10px}.dialog-head{display:flex;justify-content:space-between}.dialog-head h2{margin:7px 0}.dialog-head p{color:#8995a8;font-size:12px}.dialog-actions{display:flex;justify-content:flex-end;gap:8px} +*{box-sizing:border-box}body{background:#f4f6fa;color:#182338;font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}.app-shell{display:flex;min-height:100vh}.sidebar{width:238px;background:#0d1726;color:#cbd5e1;display:flex;flex-direction:column;padding:25px 16px;position:fixed;inset:0 auto 0 0}.brand{display:flex;align-items:center;gap:11px;padding:0 10px 32px}.brand-mark{width:34px;height:34px;border-radius:10px;background:#635bff;color:white;display:grid;place-items:center;font-weight:900}.brand b{display:block;color:#fff;font-size:15px}.brand small{color:#8290a6;font-size:11px}.sidebar nav{display:grid;gap:5px}.sidebar nav a{color:#9eabc0;text-decoration:none;border-radius:9px;padding:12px 13px;display:flex;gap:12px;align-items:center;font-size:13px}.sidebar nav a span{width:18px;text-align:center;font-size:16px}.sidebar nav a:hover,.sidebar nav a.active{background:#1b2940;color:#fff}.sidebar nav a.active{box-shadow:inset 3px 0 #8179ff}.sidebar-foot{margin-top:auto;border-top:1px solid #233149;padding:17px 8px;display:flex;gap:9px;align-items:flex-start;font-size:11px}.sidebar-foot b{display:block;color:#dfe7f3;font-size:11px}.sidebar-foot small{color:#8190a7}.status-dot{width:8px;height:8px;border-radius:50%;background:#35c98b;margin-top:4px}.main{margin-left:238px;width:calc(100% - 238px);max-width:1500px;padding:0 42px 35px}.topbar{height:70px;border-bottom:1px solid #e5e9f1;display:flex;align-items:center;justify-content:space-between}.crumb{color:#6d7b90}.slash{padding:0 10px;color:#bac3d0}.top-actions{display:flex;gap:14px;align-items:center;color:#8a96a8;font-size:12px}.icon-btn{border:1px solid #dfe4ed;background:#fff;border-radius:9px;width:34px;height:34px;cursor:pointer;font-size:18px;color:#516079}.page-head{display:flex;justify-content:space-between;gap:24px;padding:38px 0 28px;align-items:end}.eyebrow{font-size:10px;letter-spacing:1.8px;color:#655bff;font-weight:800}.page-head h1{font-size:30px;letter-spacing:-.8px;margin:8px 0}.page-head p{margin:0;color:#708099;max-width:650px}.head-actions{display:flex;gap:8px}.metric-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px}.metric,.panel{background:#fff;border:1px solid #e3e8f0;border-radius:14px;box-shadow:0 3px 12px #1f2a3a08}.metric{padding:20px 21px}.metric small{display:block;color:#7b879c;letter-spacing:1.2px;font-size:10px;font-weight:800}.metric b{display:block;font-size:31px;margin:8px 0 2px;letter-spacing:-1px}.metric span{font-size:12px;color:#8894a6}.dashboard-grid,.two-col{display:grid;grid-template-columns:1.4fr 1fr;gap:18px}.panel{padding:23px}.panel-title{display:flex;justify-content:space-between;gap:15px;align-items:flex-start}.panel-title h2{font-size:18px;margin:6px 0}.panel-title p{color:#8491a5;font-size:12px;margin:0;line-height:1.5}.panel-title a{color:#635bff;text-decoration:none;font-size:12px;font-weight:700}.primary,.secondary{border-radius:9px;padding:10px 15px;border:0;font:inherit;font-size:12px;font-weight:800;cursor:pointer;text-decoration:none}.primary{background:#635bff;color:#fff}.secondary{background:#fff;border:1px solid #d9e0eb;color:#344258}.spotlight{margin-top:18px}.spot{display:flex;width:100%;align-items:center;text-align:left;border:0;border-top:1px solid #edf0f5;background:transparent;padding:13px 2px;gap:12px;cursor:pointer;color:inherit}.spot>span:nth-child(2){flex:1}.spot b,.spot small{display:block}.spot small{color:#8995a8;font-size:11px;margin-top:3px}.spot>span:last-child{color:#a3adbc;font-size:20px}.score{display:inline-grid;place-items:center;width:35px;height:28px;border-radius:8px;font-weight:900;font-size:12px}.score.hot{background:#ffe7e9;color:#d14457}.score.warm{background:#fff2d2;color:#aa7510}.score.cool{background:#e7f0ff;color:#3768ba}.activity{margin-top:12px}.activity>div{display:flex;align-items:center;gap:10px;border-top:1px solid #edf0f5;padding:12px 0}.activity>div>span:nth-child(2){flex:1}.activity b,.activity small{display:block}.activity b{font-size:12px}.activity small,.activity time{font-size:11px;color:#8b96a8}.run-dot{width:8px;height:8px;border-radius:50%;background:#aeb7c5}.run-dot.succeeded{background:#2dbd80}.run-dot.failed{background:#dc5663}.empty{padding:38px;text-align:center;color:#8995a8}.empty span{display:block;font-size:28px;margin-bottom:8px}.form-panel{min-height:430px}.form-panel form{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:25px}.form-panel label,.settings-grid label{display:grid;gap:7px;color:#4d5c73;font-size:12px;font-weight:700}.form-panel label:first-child{grid-column:1/-1}.form-panel input,.form-panel select,.settings-grid input,.inline-form input,.inline-form select,.toolbar input,.toolbar select,.message-dialog input,.message-dialog textarea{border:1px solid #dbe2ed;border-radius:8px;padding:11px;background:#fff;font:inherit;color:#21304a;font-size:13px}.settings-grid input[readonly]{background:#f5f6fa;color:#7c8798}.field-help{margin:-7px 0 0;color:#8995a8;font-size:11px;line-height:1.45}.checkline{display:flex!important;grid-column:1/-1;align-items:center;gap:8px}.checkline small{font-weight:400;color:#8995a8}.form-row{grid-column:1/-1;display:flex;align-items:center;gap:12px}.notice{font-size:12px;color:#7c899c}.notice.ok{color:#14845d}.notice.error{color:#c44959}.pill,.badge,.status{font-size:10px;font-weight:800;padding:6px 9px;border-radius:99px;background:#ecebff;color:#5b53d8;white-space:nowrap}.steps{list-style:none;padding:0;margin:20px 0}.steps li{border-top:1px solid #edf0f5;padding:15px 0;display:grid;gap:4px}.steps span{font-size:12px;color:#8793a5}.toolbar{display:flex;gap:10px;margin-bottom:18px}.search{display:flex;align-items:center;border:1px solid #dbe2ed;border-radius:8px;background:#fff;padding:0 10px;flex:1}.search input{border:0;outline:0;width:100%}.toolbar select{width:170px}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;font-size:12px}th{text-align:left;color:#7e8a9c;font-size:10px;letter-spacing:.8px;text-transform:uppercase;padding:11px 10px;border-bottom:1px solid #e6ebf2}td{padding:14px 10px;border-bottom:1px solid #edf0f5;vertical-align:middle}td b,td small{display:block}td small{color:#8a96a8;font-size:11px;margin-top:4px}td a{color:#635bff;text-decoration:none;font-size:11px;display:block;margin-top:5px}.badge.good{background:#e2f7ee;color:#19815e}.badge.warn{background:#fff0d3;color:#aa7510}.email{color:#45546d}.table-wrap select{border:1px solid #dbe2ed;background:#fff;border-radius:7px;padding:7px;font-size:11px}.icon-action{border:0;background:#f0efff;color:#635bff;border-radius:7px;padding:8px;cursor:pointer}.settings-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}.wide-panel{grid-column:1/-1}.setting-line{display:flex;justify-content:space-between;border-top:1px solid #edf0f5;padding:13px 0;margin-top:13px;color:#7d899b;font-size:12px}.setting-line b{color:#334158;text-align:right;max-width:60%;overflow-wrap:anywhere}.good-text{color:#14845d!important}.settings-grid form{display:grid;gap:14px;margin-top:18px}.inline-form{display:flex;gap:8px;margin-top:18px}.inline-form>*{flex:1}.list{margin-top:13px}.list>div{border-top:1px solid #edf0f5;padding:12px 0;display:flex;justify-content:space-between;gap:15px;font-size:12px}.list span{color:#8a96a8}.status.succeeded{background:#e1f6ed;color:#16815b}.status.failed{background:#ffe7e9;color:#bc4052}.status.running{background:#e8efff;color:#3d67af}footer{color:#96a1b2;font-size:11px;padding:28px 0}.message-dialog{width:min(700px,calc(100% - 30px));border:0;border-radius:15px;padding:0;box-shadow:0 30px 100px #121b2d44}.message-dialog form{padding:26px;display:grid;gap:10px}.dialog-head{display:flex;justify-content:space-between}.dialog-head h2{margin:7px 0}.dialog-head p{color:#8995a8;font-size:12px}.dialog-actions{display:flex;justify-content:flex-end;gap:8px} @media(max-width:900px){.sidebar{width:70px;padding:18px 10px}.brand div,.sidebar nav a:not(.active)::after,.sidebar-foot div{display:none}.brand{padding:0 8px 25px}.sidebar nav a{justify-content:center;padding:12px}.main{margin-left:70px;width:calc(100% - 70px);padding:0 20px}.metric-grid{grid-template-columns:repeat(2,1fr)}.settings-grid,.dashboard-grid,.two-col{grid-template-columns:1fr}.wide-panel{grid-column:auto}.toolbar{flex-wrap:wrap}.toolbar .search{flex-basis:100%}} @media(max-width:560px){.main{padding:0 14px}.page-head{display:block}.page-head h1{font-size:25px}.metric-grid{gap:8px}.metric{padding:14px}.metric b{font-size:25px}.form-panel form{grid-template-columns:1fr}.form-panel label:first-child{grid-column:auto}.form-row{grid-column:auto}.inline-form{display:grid}.topbar{height:58px}.toolbar select,.toolbar button{flex:1}.list>div{display:block}.list span{display:block;margin-top:4px}}