seo-intel 1.5.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.1 (2026-04-11)
4
+
5
+ ### Setup Wizard
6
+ - Fixed Playwright detection on macOS — now checks correct browser cache paths instead of legacy node_modules location
7
+ - Added persistent "Open Dashboard" link in wizard header, visible on all setup steps
8
+ - Renamed floating helper card to "Agentic Installations" with extended per-runtime setup prompts
9
+ - Cloud model cards now show live connection status (Connected via API key or OpenClaw gateway)
10
+ - OpenClaw gateway model detection with authenticated `/v1/models` query
11
+
12
+ ### Extraction: LAN host model fix
13
+ - Fixed LAN/fallback hosts checking for wrong model (used stale `OLLAMA_FALLBACK_MODEL` instead of project-selected model)
14
+ - All Ollama hosts now use the project's configured extraction model consistently
15
+ - Added `OLLAMA_HOSTS` support — comma-separated LAN hosts from setup wizard are picked up by extractor
16
+
17
+ ### Dashboard
18
+ - Stealth toggle moved next to Crawl button (only affects crawl, not extract)
19
+ - Analysis buttons (Analyze, Brief, Keywords, Templates) get subtle blue accent border
20
+ - Visual separator between action and intelligence command groups
21
+
3
22
  ## 1.5.0 (2026-04-10)
4
23
 
5
24
  ### Export: dashboard data, not raw DB dumps
package/cli.js CHANGED
@@ -69,19 +69,27 @@ function defaultSiteUrl(domain) {
69
69
  function resolveExtractionRuntime(config) {
70
70
  const primaryUrl = config?.crawl?.ollamaHost || process.env.OLLAMA_URL || 'http://localhost:11434';
71
71
  const primaryModel = config?.crawl?.extractionModel || process.env.OLLAMA_MODEL || 'gemma4:e4b';
72
- const fallbackUrl = process.env.OLLAMA_FALLBACK_URL || '';
73
- const fallbackModel = process.env.OLLAMA_FALLBACK_MODEL || primaryModel;
74
72
  const localhost = 'http://localhost:11434';
73
+ const norm = h => String(h || '').trim().replace(/\/+$/, '');
75
74
 
76
75
  const candidates = [
77
- { host: String(primaryUrl).trim().replace(/\/+$/, ''), model: String(primaryModel).trim() || 'gemma4:e4b' },
76
+ { host: norm(primaryUrl), model: String(primaryModel).trim() || 'gemma4:e4b' },
78
77
  ];
79
78
 
80
- if (fallbackUrl) {
81
- candidates.push({
82
- host: String(fallbackUrl).trim().replace(/\/+$/, ''),
83
- model: String(fallbackModel).trim() || String(primaryModel).trim() || 'gemma4:e4b',
84
- });
79
+ // Legacy single fallback — always use project-selected model, not OLLAMA_FALLBACK_MODEL
80
+ const fallbackUrl = norm(process.env.OLLAMA_FALLBACK_URL || '');
81
+ if (fallbackUrl && !candidates.some(c => c.host === fallbackUrl)) {
82
+ candidates.push({ host: fallbackUrl, model: String(primaryModel).trim() || 'gemma4:e4b' });
83
+ }
84
+
85
+ // OLLAMA_HOSTS — comma-separated LAN hosts from setup wizard
86
+ if (process.env.OLLAMA_HOSTS) {
87
+ for (const h of process.env.OLLAMA_HOSTS.split(',')) {
88
+ const host = norm(h);
89
+ if (host && !candidates.some(c => c.host === host)) {
90
+ candidates.push({ host, model: String(primaryModel).trim() || 'gemma4:e4b' });
91
+ }
92
+ }
85
93
  }
86
94
 
87
95
  if (!candidates.some(candidate => candidate.host === localhost)) {
package/extractor/qwen.js CHANGED
@@ -24,16 +24,29 @@ function getConfiguredOllamaRoutes() {
24
24
  const primaryUrl = normalizeHost(process.env.OLLAMA_URL || DEFAULT_OLLAMA_URL) || DEFAULT_OLLAMA_URL;
25
25
  const primaryModel = String(process.env.OLLAMA_MODEL || DEFAULT_OLLAMA_MODEL).trim() || DEFAULT_OLLAMA_MODEL;
26
26
  const fallbackUrl = normalizeHost(process.env.OLLAMA_FALLBACK_URL || '');
27
- const fallbackModel = String(process.env.OLLAMA_FALLBACK_MODEL || primaryModel).trim() || primaryModel;
27
+ // BUG FIX: fallback hosts MUST use the project-selected model (primaryModel),
28
+ // not a separate OLLAMA_FALLBACK_MODEL env var. The project config sets
29
+ // OLLAMA_MODEL to the user's choice — all hosts should respect that.
30
+ const fallbackModel = primaryModel;
28
31
 
29
32
  const candidates = [
30
33
  { label: 'primary', host: primaryUrl, model: primaryModel },
31
34
  ];
32
35
 
33
- if (fallbackUrl) {
36
+ if (fallbackUrl && !candidates.some(r => r.host === normalizeHost(fallbackUrl))) {
34
37
  candidates.push({ label: 'fallback', host: fallbackUrl, model: fallbackModel });
35
38
  }
36
39
 
40
+ // Support OLLAMA_HOSTS — comma-separated list of additional LAN Ollama hosts
41
+ if (process.env.OLLAMA_HOSTS) {
42
+ for (const h of process.env.OLLAMA_HOSTS.split(',')) {
43
+ const host = normalizeHost(h);
44
+ if (host && !candidates.some(r => r.host === host)) {
45
+ candidates.push({ label: 'lan', host, model: primaryModel });
46
+ }
47
+ }
48
+ }
49
+
37
50
  if (!candidates.some(route => route.host === LOCALHOST_OLLAMA_URL)) {
38
51
  candidates.push({ label: 'localhost', host: LOCALHOST_OLLAMA_URL, model: primaryModel });
39
52
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seo-intel",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "Local Ahrefs-style SEO competitor intelligence. Crawl → SQLite → cloud analysis.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -1466,6 +1466,12 @@ function buildHtmlTemplate(data, opts = {}) {
1466
1466
  .term-btn:hover { border-color: var(--accent-gold); color: var(--accent-gold); }
1467
1467
  .term-btn:active { background: rgba(232,213,163,0.1); }
1468
1468
  .term-btn i { margin-right: 3px; font-size: 0.55rem; }
1469
+ .term-btn-intel { border-color: rgba(96,165,250,0.2); }
1470
+ .term-btn-intel:hover { border-color: rgba(96,165,250,0.6); color: rgba(150,200,255,0.9); }
1471
+ .term-btn-intel:active { background: rgba(96,165,250,0.08); }
1472
+ .term-stealth { display:inline-flex; align-items:center; gap:4px; font-size:0.58rem; color:var(--text-muted); cursor:pointer; user-select:none; margin-left:2px; padding:2px 6px; border-radius:4px; border:1px solid transparent; transition:all 0.15s; }
1473
+ .term-stealth:hover { border-color: rgba(124,109,235,0.3); color: var(--text-secondary); }
1474
+ .term-stealth input[type="checkbox"] { accent-color: var(--accent-purple,#7c6deb); width:12px; height:12px; cursor:pointer; }
1469
1475
 
1470
1476
  /* ─── Terminal + Export split layout ───────────────────────────────── */
1471
1477
  .term-split {
@@ -2122,10 +2128,6 @@ function buildHtmlTemplate(data, opts = {}) {
2122
2128
  <button class="es-btn es-btn-restart" id="btnRestart${suffix}" onclick="restartServer()">
2123
2129
  <i class="fa-solid fa-rotate-right"></i> Restart
2124
2130
  </button>
2125
- <label class="es-stealth-toggle">
2126
- <input type="checkbox" id="stealthToggle${suffix}"${extractionStatus.liveProgress?.stealth ? ' checked' : ''}>
2127
- <i class="fa-solid fa-user-ninja"></i> Stealth
2128
- </label>
2129
2131
  </div>
2130
2132
  </div>
2131
2133
  </div>
@@ -2147,11 +2149,13 @@ function buildHtmlTemplate(data, opts = {}) {
2147
2149
  <div style="padding:8px 12px;background:#111;border-bottom:1px solid var(--border-subtle);display:flex;flex-wrap:wrap;gap:6px;align-items:center;">
2148
2150
  <span style="font-size:0.6rem;color:var(--text-muted);margin-right:4px;"><i class="fa-solid fa-play" style="margin-right:3px;"></i>Run:</span>
2149
2151
  <button class="term-btn" data-cmd="crawl" data-project="${project}"><i class="fa-solid fa-spider"></i> Crawl</button>
2152
+ <label class="term-stealth"><input type="checkbox" id="stealthToggle${suffix}"${extractionStatus.liveProgress?.stealth ? ' checked' : ''}><i class="fa-solid fa-user-ninja"></i></label>
2150
2153
  ${pro ? `<button class="term-btn" data-cmd="extract" data-project="${project}"><i class="fa-solid fa-brain"></i> Extract</button>
2151
- <button class="term-btn" data-cmd="analyze" data-project="${project}"><i class="fa-solid fa-chart-column"></i> Analyze</button>
2152
- <button class="term-btn" data-cmd="brief" data-project="${project}"><i class="fa-solid fa-file-lines"></i> Brief</button>
2153
- <button class="term-btn" data-cmd="keywords" data-project="${project}"><i class="fa-solid fa-key"></i> Keywords</button>
2154
- <button class="term-btn" data-cmd="templates" data-project="${project}"><i class="fa-solid fa-clone"></i> Templates</button>` : ''}
2154
+ <span style="width:1px;height:16px;background:var(--border-subtle);margin:0 2px;"></span>
2155
+ <button class="term-btn term-btn-intel" data-cmd="analyze" data-project="${project}"><i class="fa-solid fa-chart-column"></i> Analyze</button>
2156
+ <button class="term-btn term-btn-intel" data-cmd="brief" data-project="${project}"><i class="fa-solid fa-file-lines"></i> Brief</button>
2157
+ <button class="term-btn term-btn-intel" data-cmd="keywords" data-project="${project}"><i class="fa-solid fa-key"></i> Keywords</button>
2158
+ <button class="term-btn term-btn-intel" data-cmd="templates" data-project="${project}"><i class="fa-solid fa-clone"></i> Templates</button>` : ''}
2155
2159
  <button class="term-btn" data-cmd="status" data-project=""><i class="fa-solid fa-circle-info"></i> Status</button>
2156
2160
  <button class="term-btn" data-cmd="guide" data-project="${project}"><i class="fa-solid fa-map"></i> Guide</button>
2157
2161
  <button class="term-btn" data-cmd="setup" data-project="" style="margin-left:auto;border-color:rgba(232,213,163,0.25);"><i class="fa-solid fa-gear"></i> Setup</button>
@@ -2368,10 +2372,12 @@ function buildHtmlTemplate(data, opts = {}) {
2368
2372
  const proj = btn.getAttribute('data-project');
2369
2373
  const scope = btn.getAttribute('data-scope');
2370
2374
  var extra = scope ? { scope: scope } : {};
2371
- // Crawl/extract: read stealth toggle + update status bar
2372
- if (cmd === 'crawl' || cmd === 'extract') {
2375
+ // Crawl: read stealth toggle; Crawl/extract: update status bar
2376
+ if (cmd === 'crawl') {
2373
2377
  var stealthEl = btn.closest('.project-panel')?.querySelector('[id^="stealthToggle"]') || document.getElementById('stealthToggle' + suffix);
2374
2378
  if (stealthEl?.checked) extra.stealth = true;
2379
+ }
2380
+ if (cmd === 'crawl' || cmd === 'extract') {
2375
2381
  if (window._setButtonsState) window._setButtonsState(true, cmd);
2376
2382
  if (window._startPolling) window._startPolling();
2377
2383
  }
@@ -4057,9 +4063,12 @@ function buildHtmlTemplate(data, opts = {}) {
4057
4063
  let pollTimer = null;
4058
4064
 
4059
4065
  window.startJob = function(command, proj) {
4060
- var stealth = document.getElementById('stealthToggle' + sfx)?.checked || false;
4061
4066
  var extra = {};
4062
- if (stealth) extra.stealth = true;
4067
+ // Stealth only applies to crawl — extract has no network
4068
+ if (command === 'crawl') {
4069
+ var stealth = document.getElementById('stealthToggle' + sfx)?.checked || false;
4070
+ if (stealth) extra.stealth = true;
4071
+ }
4063
4072
 
4064
4073
  // Route through terminal for visible output
4065
4074
  if (window._terminalRun) {
@@ -5279,9 +5288,12 @@ function buildMultiHtmlTemplate(allProjectData) {
5279
5288
 
5280
5289
  window.startJob = function(command, proj) {
5281
5290
  var sfx = '-' + proj;
5282
- var stealth = document.getElementById('stealthToggle' + sfx)?.checked || false;
5283
5291
  var extra = {};
5284
- if (stealth) extra.stealth = true;
5292
+ // Stealth only applies to crawl — extract has no network
5293
+ if (command === 'crawl') {
5294
+ var stealth = document.getElementById('stealthToggle' + sfx)?.checked || false;
5295
+ if (stealth) extra.stealth = true;
5296
+ }
5285
5297
 
5286
5298
  // Route through terminal for visible output
5287
5299
  if (window._terminalRun) {
package/setup/checks.js CHANGED
@@ -10,6 +10,7 @@ import { execSync, spawnSync } from 'child_process';
10
10
  import { existsSync, readFileSync, readdirSync } from 'fs';
11
11
  import { join, dirname } from 'path';
12
12
  import { fileURLToPath } from 'url';
13
+ import { createRequire } from 'module';
13
14
 
14
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
15
16
  const ROOT = join(__dirname, '..');
@@ -141,24 +142,35 @@ export function checkPlaywright() {
141
142
  // Check if Chromium binary is actually available
142
143
  let chromiumReady = false;
143
144
  try {
144
- // Playwright stores browser paths in its package
145
- const browserPath = join(pkgPath, '.local-browsers');
146
- if (existsSync(browserPath)) {
147
- const browsers = readdirSync(browserPath);
148
- chromiumReady = browsers.some(b => b.toLowerCase().includes('chromium'));
145
+ // 1. Shared cache (macOS: ~/Library/Caches/ms-playwright, Linux: ~/.cache/ms-playwright)
146
+ const home = process.env.HOME || process.env.USERPROFILE || '';
147
+ const cachePaths = [
148
+ join(home, 'Library', 'Caches', 'ms-playwright'), // macOS
149
+ join(home, '.cache', 'ms-playwright'), // Linux
150
+ join(process.env.LOCALAPPDATA || '', 'ms-playwright'), // Windows
151
+ join(pkgPath, '.local-browsers'), // legacy / bundled
152
+ ];
153
+ for (const cachePath of cachePaths) {
154
+ if (existsSync(cachePath)) {
155
+ try {
156
+ const browsers = readdirSync(cachePath);
157
+ if (browsers.some(b => b.toLowerCase().includes('chromium'))) {
158
+ chromiumReady = true;
159
+ break;
160
+ }
161
+ } catch { /* permission error, skip */ }
162
+ }
149
163
  }
150
- // Alternative check: playwright's own registry
164
+ // 2. Fallback: require playwright and check chromium executablePath
151
165
  if (!chromiumReady) {
152
- const result = spawnSync('npx', ['playwright', 'install', '--dry-run'], {
153
- encoding: 'utf8',
154
- timeout: 10000,
155
- stdio: ['pipe', 'pipe', 'pipe'],
156
- });
157
- // If dry-run shows chromium is already installed, it's ready
158
- chromiumReady = result.status === 0 && !result.stdout.includes('chromium');
166
+ try {
167
+ const req = createRequire(join(ROOT, 'package.json'));
168
+ const pw = req('playwright');
169
+ const execPath = pw.chromium?.executablePath?.();
170
+ if (execPath && existsSync(execPath)) chromiumReady = true;
171
+ } catch { /* playwright may not be requireable */ }
159
172
  }
160
173
  } catch {
161
- // If we can't determine, assume it needs install
162
174
  chromiumReady = false;
163
175
  }
164
176
 
@@ -443,6 +455,7 @@ export function checkOpenClaw() {
443
455
  hasSkillsDir: false,
444
456
  skillsPath: null,
445
457
  canAgentSetup: false,
458
+ gatewayModels: [], // model IDs available via OpenClaw gateway
446
459
  };
447
460
 
448
461
  // 1. Check if openclaw binary exists
@@ -456,10 +469,25 @@ export function checkOpenClaw() {
456
469
  result.version = match ? match[1] : ver;
457
470
  } catch { /* ok */ }
458
471
 
459
- // 3. Check if gateway is running (quick HTTP ping)
472
+ // 3. Check if gateway is running + fetch available models
473
+ // Read gateway auth token from config
474
+ let gwToken = '';
475
+ try {
476
+ const homeDir = process.env.HOME || process.env.USERPROFILE;
477
+ const ocConf = JSON.parse(readFileSync(join(homeDir, '.openclaw', 'openclaw.json'), 'utf8'));
478
+ gwToken = ocConf?.gateway?.auth?.token || '';
479
+ } catch { /* no config */ }
480
+
460
481
  try {
461
- execSync('curl -s --max-time 2 http://127.0.0.1:18789/v1/models >/dev/null 2>&1', { timeout: 5000 });
482
+ const authHeader = gwToken ? `-H "Authorization: Bearer ${gwToken}"` : '';
483
+ const raw = execSync(`curl -s --max-time 2 ${authHeader} http://127.0.0.1:18789/v1/models 2>/dev/null`, { timeout: 5000 }).toString().trim();
462
484
  result.gatewayRunning = true;
485
+ try {
486
+ const parsed = JSON.parse(raw);
487
+ if (parsed?.data && Array.isArray(parsed.data)) {
488
+ result.gatewayModels = parsed.data.map(m => m.id).filter(Boolean);
489
+ }
490
+ } catch { /* json parse fail — gateway running but no model list */ }
463
491
  } catch {
464
492
  result.gatewayRunning = false;
465
493
  }
@@ -296,6 +296,10 @@ async function handleModels(req, res) {
296
296
  ...models,
297
297
  gpu: status.vram,
298
298
  ollama: status.ollama,
299
+ openclaw: {
300
+ gatewayRunning: status.openclaw?.gatewayRunning || false,
301
+ gatewayModels: status.openclaw?.gatewayModels || [],
302
+ },
299
303
  });
300
304
  } catch (err) {
301
305
  jsonResponse(res, { error: err.message }, 500);
package/setup/wizard.html CHANGED
@@ -1198,7 +1198,10 @@ input::placeholder {
1198
1198
  <div class="wizard-header">
1199
1199
  <h1>SEO Intel</h1>
1200
1200
  <div class="subtitle">Setup Wizard</div>
1201
- <a href="http://localhost:3000/?autorun=setup-classic" title="Run setup in Terminal instead" style="position:absolute;top:14px;right:18px;font-size:0.68rem;color:var(--color-muted,#888);text-decoration:none;opacity:0.7;transition:opacity 0.15s;" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.7'"><i class="fa-solid fa-terminal"></i> Terminal setup</a>
1201
+ <div style="position:absolute;top:14px;right:18px;display:flex;gap:14px;align-items:center;">
1202
+ <a href="/" title="Open Dashboard" style="font-size:0.68rem;color:var(--color-success,#8ecba8);text-decoration:none;opacity:0.8;transition:opacity 0.15s;" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.8'"><i class="fa-solid fa-chart-line"></i> Dashboard</a>
1203
+ <a href="http://localhost:3000/?autorun=setup-classic" title="Run setup in Terminal instead" style="font-size:0.68rem;color:var(--color-muted,#888);text-decoration:none;opacity:0.7;transition:opacity 0.15s;" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.7'"><i class="fa-solid fa-terminal"></i> Terminal setup</a>
1204
+ </div>
1202
1205
  </div>
1203
1206
 
1204
1207
  <!-- ═══════════════════════════════════════════════════════════════════════
@@ -1894,7 +1897,7 @@ input::placeholder {
1894
1897
  <circle cx="14" cy="14" r="13" stroke="var(--accent-purple)" stroke-width="1.5" opacity="0.6"/>
1895
1898
  <path d="M9 12c0-2.8 2.2-5 5-5s5 2.2 5 5c0 1.8-1 3.4-2.4 4.3L18 20H10l1.4-3.7C10 15.4 9 13.8 9 12z" fill="var(--accent-purple)" opacity="0.8"/>
1896
1899
  </svg>
1897
- <span style="font-size:0.92rem; font-weight:600; color:var(--text-primary);">OpenClaw</span>
1900
+ <span style="font-size:0.92rem; font-weight:600; color:var(--text-primary);">Agentic Installations</span>
1898
1901
  </div>
1899
1902
 
1900
1903
  <!-- Tab switcher: Agentic Setup vs Cloud Models -->
@@ -1925,7 +1928,7 @@ input::placeholder {
1925
1928
  <button class="btn btn-sm" style="margin-left:auto; padding:2px 6px; font-size:0.55rem;" onclick="navigator.clipboard.writeText('clawhub install ukkometa/seo-intel');this.textContent='OK';setTimeout(()=>this.textContent='Copy',1200);">Copy</button>
1926
1929
  </div>
1927
1930
  <div style="position:relative; padding:7px 9px; background:rgba(10,10,10,0.5); border:1px solid var(--border-subtle); border-radius:var(--radius); font-family:var(--font-mono); font-size:0.62rem; color:var(--text-secondary); line-height:1.5;">
1928
- <div id="genie-oc-prompt">Set up SEO Intel on my machine. Check deps, install Ollama, recommend a model, configure a project, run a test crawl.</div>
1931
+ <div id="genie-oc-prompt">Set up SEO Intel (npm package: seo-intel) on my machine. Steps: 1) Verify Node.js ≥22.5 and npm are installed. 2) Install Ollama and pull the gemma4:e4b model (default extraction engine, ~4GB). 3) Run `seo-intel setup` to create a project config — ask me for my website URL and up to 3 competitor URLs. 4) Run a test crawl with `seo-intel crawl &lt;project&gt;` to verify everything works. 5) Run `seo-intel extract &lt;project&gt;` to test local AI extraction. Report any errors with full context.</div>
1929
1932
  <button class="btn btn-sm" style="position:absolute; top:5px; right:5px; padding:2px 5px; font-size:0.52rem;" onclick="navigator.clipboard.writeText(document.getElementById('genie-oc-prompt').textContent.trim());this.textContent='OK';setTimeout(()=>this.textContent='Copy',1200);">Copy</button>
1930
1933
  </div>
1931
1934
  <div style="display:flex; gap:6px; margin-top:10px;">
@@ -1936,21 +1939,21 @@ input::placeholder {
1936
1939
 
1937
1940
  <div id="genieRuntime-claudecode" class="genie-runtime-panel" style="display:none;">
1938
1941
  <div style="position:relative; padding:7px 9px; background:rgba(10,10,10,0.5); border:1px solid var(--border-subtle); border-radius:var(--radius); font-family:var(--font-mono); font-size:0.62rem; color:var(--text-secondary); line-height:1.5;">
1939
- <div>Set up seo-intel: check Node 22.5+, install Ollama, pull gemma4:e4b, create config, run crawl.</div>
1942
+ <div>Set up the npm package `seo-intel` on this machine. Requirements: Node.js 22.5, Ollama with gemma4:e4b model (~4GB download). Create a project config for my website — ask me for my URL and competitor domains. Then run a test crawl (`seo-intel crawl &lt;project&gt;`) and extraction (`seo-intel extract &lt;project&gt;`). The tool stores data in SQLite locally. If Ollama isn't installed, install it from ollama.com. After setup, open the dashboard with `seo-intel serve`.</div>
1940
1943
  <button class="btn btn-sm" style="position:absolute; top:5px; right:5px; padding:2px 5px; font-size:0.52rem;" onclick="navigator.clipboard.writeText(this.previousElementSibling.textContent.trim());this.textContent='OK';setTimeout(()=>this.textContent='Copy',1200);">Copy</button>
1941
1944
  </div>
1942
1945
  </div>
1943
1946
 
1944
1947
  <div id="genieRuntime-codex" class="genie-runtime-panel" style="display:none;">
1945
1948
  <div style="position:relative; padding:7px 9px; background:rgba(10,10,10,0.5); border:1px solid var(--border-subtle); border-radius:var(--radius); font-family:var(--font-mono); font-size:0.62rem; color:var(--text-secondary); line-height:1.5;">
1946
- <div>Install seo-intel deps (Node 22.5+, Ollama, gemma4:e4b), create project config, run: seo-intel crawl myproject. Full disk access.</div>
1949
+ <div>Install and configure the `seo-intel` npm package. Needs: Node.js 22.5, Ollama running locally with `gemma4:e4b` pulled. Create a JSON config at configs/&lt;project&gt;.json with target URL + competitor URLs (ask me). Run the full pipeline: `seo-intel crawl &lt;project&gt;` → `seo-intel extract &lt;project&gt;` → `seo-intel serve` to open the dashboard. Needs full disk access for Playwright browser automation.</div>
1947
1950
  <button class="btn btn-sm" style="position:absolute; top:5px; right:5px; padding:2px 5px; font-size:0.52rem;" onclick="navigator.clipboard.writeText(this.previousElementSibling.textContent.trim());this.textContent='OK';setTimeout(()=>this.textContent='Copy',1200);">Copy</button>
1948
1951
  </div>
1949
1952
  </div>
1950
1953
 
1951
1954
  <div id="genieRuntime-perplexity" class="genie-runtime-panel" style="display:none;">
1952
1955
  <div style="position:relative; padding:7px 9px; background:rgba(10,10,10,0.5); border:1px solid var(--border-subtle); border-radius:var(--radius); font-family:var(--font-mono); font-size:0.62rem; color:var(--text-secondary); line-height:1.5;">
1953
- <div>Help me set up SEO Intel: install Node.js 22.5+, Ollama, download Gemma 4 model, create config for my website. Ask for my URL.</div>
1956
+ <div>I want to install SEO Intel (seo-intel on npm), a local-first SEO competitor analysis tool. Walk me through: 1) Installing Node.js 22.5+ if I don't have it. 2) Installing Ollama and downloading the Gemma 4 e4b model for AI extraction. 3) Running `npx seo-intel setup` to configure my first project. I need to provide my website URL and competitor URLs. 4) Testing the crawl pipeline. Ask me for my website URL to get started.</div>
1954
1957
  <button class="btn btn-sm" style="position:absolute; top:5px; right:5px; padding:2px 5px; font-size:0.52rem;" onclick="navigator.clipboard.writeText(this.previousElementSibling.textContent.trim());this.textContent='OK';setTimeout(()=>this.textContent='Copy',1200);">Copy</button>
1955
1958
  </div>
1956
1959
  </div>
@@ -1959,21 +1962,15 @@ input::placeholder {
1959
1962
  <!-- Cloud Models tab -->
1960
1963
  <div id="genieCloud" style="display:none;">
1961
1964
  <p style="font-size:0.75rem; color:var(--text-muted); line-height:1.6; margin-bottom:12px;">
1962
- Route analysis through frontier models. OpenClaw manages API keys and OAuth automatically.
1965
+ Cloud frontier models for competitive analysis. Add API keys in the Models step, or use OpenClaw for automatic key management.
1963
1966
  </p>
1964
1967
  <div style="font-size:0.72rem; color:var(--text-secondary);">
1965
1968
  <div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
1966
- <i class="fa-solid fa-check" style="color:var(--accent-purple); font-size:0.62rem; width:12px;"></i> Claude Opus 4.6
1967
- </div>
1968
- <div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
1969
- <i class="fa-solid fa-check" style="color:var(--accent-purple); font-size:0.62rem; width:12px;"></i> Gemini 3.1 Pro
1970
- </div>
1971
- <div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
1972
- <i class="fa-solid fa-check" style="color:var(--accent-purple); font-size:0.62rem; width:12px;"></i> GPT-5.4
1969
+ <i class="fa-solid fa-spinner fa-spin-pulse" style="color:var(--text-muted); font-size:0.62rem; width:12px;"></i> <span style="color:var(--text-muted);">Checking connections...</span>
1973
1970
  </div>
1974
1971
  </div>
1975
1972
  <p style="font-size:0.65rem; color:var(--text-muted); line-height:1.5; margin-top:10px;">
1976
- No .env keys needed. OAuth-based model access means the pipeline test may show "no API key" this is normal.
1973
+ Set up API keys in the <strong>Models &amp; Auth</strong> step, or use OpenClaw to skip key management entirely.
1977
1974
  </p>
1978
1975
  </div>
1979
1976
  </div>
@@ -2332,6 +2329,7 @@ input::placeholder {
2332
2329
  state.modelData = await API.get('/api/setup/models');
2333
2330
  renderExtractionModels();
2334
2331
  renderAnalysisModels();
2332
+ renderCloudModelStatus();
2335
2333
  } catch (err) {
2336
2334
  extDiv.innerHTML = `<div style="color:var(--color-danger); font-size:0.75rem;">Failed: ${err.message}</div>`;
2337
2335
  anaDiv.innerHTML = extDiv.innerHTML;
@@ -2493,6 +2491,76 @@ input::placeholder {
2493
2491
  }
2494
2492
  }
2495
2493
 
2494
+ function renderCloudModelStatus() {
2495
+ const cloudModels = (state.modelData?.allAnalysis || []).filter(m => m.type === 'cloud');
2496
+ const gwModels = state.modelData?.openclaw?.gatewayModels || [];
2497
+ const gwRunning = state.modelData?.openclaw?.gatewayRunning || false;
2498
+
2499
+ // Determine connection status per cloud model
2500
+ // A model is "connected" if: .env key exists OR OpenClaw gateway is active (routes all cloud models)
2501
+ function isConnected(cm) {
2502
+ if (cm.configured) return { connected: true, via: 'key' };
2503
+ // If OpenClaw gateway is running with models, all cloud models are available through it
2504
+ if (gwRunning && gwModels.length > 0) return { connected: true, via: 'openclaw' };
2505
+ return { connected: false };
2506
+ }
2507
+
2508
+ // Mark cloud model cards with visible connection badges
2509
+ const cloudCards = document.querySelectorAll('#cloudAnalysisModels .cloud-model');
2510
+ cloudCards.forEach(card => {
2511
+ const modelId = card.dataset.model;
2512
+ const cm = cloudModels.find(m => m.id === modelId);
2513
+ const old = card.querySelector('.cloud-status-badge');
2514
+ if (old) old.remove();
2515
+ if (!cm) return;
2516
+
2517
+ const status = isConnected(cm);
2518
+ const badge = document.createElement('div');
2519
+ badge.className = 'cloud-status-badge';
2520
+
2521
+ if (status.connected) {
2522
+ const viaLabel = status.via === 'openclaw' ? 'via OpenClaw' : 'API key set';
2523
+ badge.innerHTML = `<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:4px;background:rgba(142,203,168,0.15);border:1px solid rgba(142,203,168,0.3);font-size:0.6rem;font-weight:600;color:var(--color-success);letter-spacing:0.02em;"><i class="fa-solid fa-circle-check"></i> Connected <span style="font-weight:400;opacity:0.8;">— ${viaLabel}</span></span>`;
2524
+ badge.style.cssText = 'margin-top:8px;';
2525
+ card.style.borderColor = 'rgba(142,203,168,0.3)';
2526
+ } else {
2527
+ badge.innerHTML = `<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:4px;background:rgba(255,255,255,0.03);border:1px solid var(--border-subtle);font-size:0.6rem;color:var(--text-muted);"><i class="fa-solid fa-circle-xmark"></i> No API key</span>`;
2528
+ badge.style.cssText = 'margin-top:8px;';
2529
+ card.style.borderColor = '';
2530
+ }
2531
+ card.appendChild(badge);
2532
+ });
2533
+
2534
+ // Update the genie Cloud Models tab with live status
2535
+ const genieCloud = document.getElementById('genieCloud');
2536
+ if (genieCloud) {
2537
+ const existingList = genieCloud.querySelector('div[style*="font-size:0.72rem"]');
2538
+ if (!existingList) return;
2539
+
2540
+ let html = '<div style="font-size:0.72rem; color:var(--text-secondary);">';
2541
+ for (const cm of cloudModels) {
2542
+ const status = isConnected(cm);
2543
+ if (status.connected) {
2544
+ const via = status.via === 'openclaw' ? ' <span style="font-size:0.58rem;color:var(--text-muted);">via OpenClaw</span>' : '';
2545
+ html += `<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;">
2546
+ <i class="fa-solid fa-circle-check" style="color:var(--color-success);font-size:0.62rem;width:12px;"></i> ${cm.name}${via}
2547
+ </div>`;
2548
+ } else {
2549
+ html += `<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;">
2550
+ <i class="fa-solid fa-circle-xmark" style="color:var(--text-muted);font-size:0.62rem;width:12px;"></i> <span style="color:var(--text-muted);">${cm.name}</span>
2551
+ </div>`;
2552
+ }
2553
+ }
2554
+ if (gwRunning) {
2555
+ html += `<div style="margin-top:8px;padding:6px 8px;background:rgba(124,109,235,0.08);border:1px solid rgba(124,109,235,0.2);border-radius:var(--radius);font-size:0.6rem;color:var(--accent-purple);">
2556
+ <i class="fa-solid fa-plug" style="margin-right:4px;"></i> OpenClaw gateway active — ${gwModels.length} model(s)
2557
+ </div>`;
2558
+ }
2559
+ html += '</div>';
2560
+ existingList.outerHTML = html;
2561
+ }
2562
+ }
2563
+
2496
2564
  function selectAnalysisModel(id) {
2497
2565
  state.selectedAnalysis = id;
2498
2566
  document.querySelectorAll('#analysisModels .model-radio-card').forEach(c => {
@@ -3613,6 +3681,8 @@ input::placeholder {
3613
3681
  const data = await res.json();
3614
3682
  if (!res.ok) throw new Error(data.error || 'Save failed');
3615
3683
  if (saveStatus) { saveStatus.textContent = '✓ Saved to .env'; saveStatus.style.color = 'var(--color-success)'; }
3684
+ // Refresh model data to update cloud connection status
3685
+ try { state.modelData = await API.get('/api/setup/models'); renderCloudModelStatus(); } catch {}
3616
3686
  } catch (err) {
3617
3687
  if (saveStatus) { saveStatus.textContent = err.message; saveStatus.style.color = 'var(--color-danger)'; }
3618
3688
  }