seo-intel 1.0.0 → 1.1.0

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.
@@ -0,0 +1,11 @@
1
+ #!/bin/bash
2
+ cd "$(dirname "$0")" || cd ~
3
+ clear
4
+ echo ""
5
+ echo " SEO Intel — Setup Wizard"
6
+ echo " ========================"
7
+ echo ""
8
+ npx seo-intel setup
9
+ echo ""
10
+ echo " Setup complete. You can close this window."
11
+ read -n 1 -s -r -p " Press any key to exit..."
package/cli.js CHANGED
@@ -24,6 +24,7 @@ import { getNextCrawlTarget, needsAnalysis, getCrawlStatus, loadAllConfigs } fro
24
24
  import {
25
25
  getDb, upsertDomain, upsertPage, insertExtraction,
26
26
  insertKeywords, insertHeadings, insertLinks, insertPageSchemas,
27
+ upsertTechnical,
27
28
  getCompetitorSummary, getKeywordMatrix, getHeadingStructure,
28
29
  getPageHash, getSchemasByProject
29
30
  } from './db/db.js';
@@ -53,10 +54,13 @@ try { mkdirSync(join(__dirname, 'config'), { recursive: true }); } catch { /* ok
53
54
  * Fast: 2s timeout per host, runs sequentially.
54
55
  */
55
56
  async function checkOllamaAvailability() {
56
- const hosts = [
57
- process.env.OLLAMA_URL || 'http://localhost:11434',
58
- ...(process.env.OLLAMA_FALLBACK_URL ? [process.env.OLLAMA_FALLBACK_URL] : []),
59
- ];
57
+ // Build host chain: primary → fallback → always try localhost as last resort
58
+ const configured = [
59
+ process.env.OLLAMA_URL,
60
+ process.env.OLLAMA_FALLBACK_URL,
61
+ ].filter(Boolean);
62
+ const localhost = 'http://localhost:11434';
63
+ const hosts = [...new Set([...configured, localhost])];
60
64
 
61
65
  for (const host of hosts) {
62
66
  try {
@@ -478,6 +482,7 @@ program
478
482
  started_at: crawlStart,
479
483
  failed: totalFailed,
480
484
  });
485
+ upsertTechnical(db, { pageId, hasCanonical: page.hasCanonical, hasOgTags: page.hasOgTags, hasSchema: page.hasSchema, hasRobots: page.hasRobots });
481
486
  try {
482
487
  const extractFn = await getExtractPage();
483
488
  const extraction = await extractFn(page);
@@ -494,6 +499,7 @@ program
494
499
  totalFailed++;
495
500
  }
496
501
  } else {
502
+ upsertTechnical(db, { pageId, hasCanonical: page.hasCanonical, hasOgTags: page.hasOgTags, hasSchema: page.hasSchema, hasRobots: page.hasRobots });
497
503
  insertHeadings(db, pageId, page.headings);
498
504
  insertLinks(db, pageId, page.links);
499
505
  if (page.parsedSchemas?.length) insertPageSchemas(db, pageId, page.parsedSchemas);
@@ -1051,6 +1057,7 @@ program
1051
1057
  }
1052
1058
  }
1053
1059
 
1060
+ upsertTechnical(db, { pageId, hasCanonical: page.hasCanonical, hasOgTags: page.hasOgTags, hasSchema: page.hasSchema, hasRobots: page.hasRobots });
1054
1061
  process.stdout.write(chalk.gray(` [${pageCount + 1}] d${page.depth ?? 0} ${page.url.slice(0, 65)} → extracting...`));
1055
1062
  writeProgress({
1056
1063
  status: 'running', command: 'run', project: next.project,
package/crawler/index.js CHANGED
@@ -495,6 +495,8 @@ async function processPage(page, url, base, depth, queue, maxDepth) {
495
495
 
496
496
  const robotsMeta = await page.$eval('meta[name="robots"]', el => el.content).catch(() => '');
497
497
  const isIndexable = !robotsMeta.toLowerCase().includes('noindex');
498
+ const hasCanonical = await page.$('link[rel="canonical"]').then(el => !!el).catch(() => false);
499
+ const hasOgTags = await page.$('meta[property^="og:"]').then(el => !!el).catch(() => false);
498
500
 
499
501
  const publishedDate = await page.evaluate(() => {
500
502
  for (const sel of ['meta[property="article:published_time"]','meta[name="date"]','meta[itemprop="datePublished"]']) {
@@ -553,6 +555,9 @@ async function processPage(page, url, base, depth, queue, maxDepth) {
553
555
  schemaTypes, parsedSchemas, vitals, publishedDate, modifiedDate,
554
556
  contentHash: hash,
555
557
  quality: quality.ok, qualityReason: quality.reason,
558
+ hasCanonical, hasOgTags,
559
+ hasRobots: !!robotsMeta,
560
+ hasSchema: schemaTypes.length > 0,
556
561
  };
557
562
  }
558
563
 
package/db/db.js CHANGED
@@ -61,6 +61,19 @@ export function upsertPage(db, { domainId, url, statusCode, wordCount, loadMs, i
61
61
  return db.prepare('SELECT id FROM pages WHERE url = ?').get(url);
62
62
  }
63
63
 
64
+ export function upsertTechnical(db, { pageId, hasCanonical, hasOgTags, hasSchema, hasRobots, isMobileOk = 0 }) {
65
+ db.prepare(`
66
+ INSERT INTO technical (page_id, has_canonical, has_og_tags, has_schema, has_robots, is_mobile_ok)
67
+ VALUES (?, ?, ?, ?, ?, ?)
68
+ ON CONFLICT(page_id) DO UPDATE SET
69
+ has_canonical = excluded.has_canonical,
70
+ has_og_tags = excluded.has_og_tags,
71
+ has_schema = excluded.has_schema,
72
+ has_robots = excluded.has_robots,
73
+ is_mobile_ok = excluded.is_mobile_ok
74
+ `).run(pageId, hasCanonical ? 1 : 0, hasOgTags ? 1 : 0, hasSchema ? 1 : 0, hasRobots ? 1 : 0, isMobileOk ? 1 : 0);
75
+ }
76
+
64
77
  export function getPageHash(db, url) {
65
78
  return db.prepare('SELECT content_hash FROM pages WHERE url = ?').get(url)?.content_hash || null;
66
79
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seo-intel",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Local Ahrefs-style SEO competitor intelligence. Crawl → SQLite → cloud analysis.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -8,7 +8,7 @@
8
8
  "homepage": "https://ukkometa.fi/en/seo-intel/",
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "https://github.com/ukkometa/seo-intel"
11
+ "url": "git+https://github.com/ukkometa/seo-intel.git"
12
12
  },
13
13
  "keywords": [
14
14
  "seo",
@@ -20,7 +20,7 @@
20
20
  "local-first"
21
21
  ],
22
22
  "bin": {
23
- "seo-intel": "./cli.js"
23
+ "seo-intel": "cli.js"
24
24
  },
25
25
  "engines": {
26
26
  "node": ">=22.5.0"
@@ -46,6 +46,7 @@
46
46
  "README.md",
47
47
  "seo-intel.png",
48
48
  "Start SEO Intel.command",
49
+ "Setup SEO Intel.command",
49
50
  "Start SEO Intel.bat",
50
51
  "start-seo-intel.sh"
51
52
  ],
@@ -42,7 +42,12 @@ function gatherProjectData(db, project, config) {
42
42
  // Merge owned subdomains (blog.x, docs.x) into target at the SQL level.
43
43
  // Uses a savepoint so changes are rolled back after report generation —
44
44
  // the actual DB stays intact, but ALL downstream queries see unified data.
45
- const ownedDomains = (config.owned || []).map(o => o.domain);
45
+ // Include BOTH config-owned domains AND DB role='owned' domains.
46
+ const configOwned = (config.owned || []).map(o => o.domain);
47
+ const dbOwned = db.prepare(
48
+ `SELECT domain FROM domains WHERE project = ? AND role = 'owned'`
49
+ ).all(project).map(r => r.domain);
50
+ const ownedDomains = [...new Set([...configOwned, ...dbOwned])];
46
51
  const hasOwned = ownedDomains.length > 0;
47
52
  if (hasOwned) {
48
53
  db.prepare('SAVEPOINT owned_merge').run();
@@ -84,7 +89,7 @@ function gatherProjectData(db, project, config) {
84
89
  const schemaBreakdown = getSchemaBreakdown(db, project);
85
90
 
86
91
  // Advanced visualization data
87
- const gravityMap = getGravityMapData(db, project);
92
+ const gravityMap = getGravityMapData(db, project, config);
88
93
  const contentTerrain = getContentTerrainData(db, project);
89
94
  const keywordVenn = getKeywordVennData(db, project);
90
95
  const performanceBubbles = getPerformanceBubbleData(db, project);
@@ -468,6 +473,8 @@ function buildHtmlTemplate(data, opts = {}) {
468
473
  white-space: nowrap;
469
474
  }
470
475
  .es-btn:hover { border-color: var(--accent-gold); color: var(--accent-gold); }
476
+ .es-btn-stop { border-color: rgba(220,80,80,0.3); color: #dc5050; }
477
+ .es-btn-stop:hover { border-color: #dc5050; color: #ff6b6b; background: rgba(220,80,80,0.08); }
471
478
  .es-btn:disabled {
472
479
  opacity: 0.4; cursor: not-allowed;
473
480
  border-color: var(--border-card);
@@ -1740,9 +1747,9 @@ function buildHtmlTemplate(data, opts = {}) {
1740
1747
  bar('Open Graph Tags', techCoverage.og) +
1741
1748
  bar('Schema Markup', techCoverage.schema) +
1742
1749
  bar('Robots Meta', techCoverage.robots) +
1743
- `<div style="font-size:0.65rem;color:var(--text-muted);margin-top:8px;">
1750
+ (avgLoad > 0 ? `<div style="font-size:0.65rem;color:var(--text-muted);margin-top:8px;">
1744
1751
  Avg load: <span style="color:${avgLoad > 3000 ? 'var(--color-danger)' : avgLoad > 1500 ? 'var(--color-warning)' : 'var(--color-success)'};">${avgLoad > 1000 ? (avgLoad/1000).toFixed(1) + 's' : avgLoad + 'ms'}</span>
1745
- </div>`;
1752
+ </div>` : '');
1746
1753
  })() : '<div style="font-size:0.72rem;color:var(--text-muted);">No technical data yet. Run a crawl first.</div>'}
1747
1754
  </div>
1748
1755
  </div>
@@ -1874,6 +1881,9 @@ function buildHtmlTemplate(data, opts = {}) {
1874
1881
  <button class="es-btn" id="btnExtract${suffix}" onclick="startJob('extract','${project}')">
1875
1882
  <i class="fa-solid fa-brain"></i> Extract
1876
1883
  </button>
1884
+ <button class="es-btn es-btn-stop" id="btnStop${suffix}" onclick="stopJob()" style="display:none;">
1885
+ <i class="fa-solid fa-stop"></i> Stop
1886
+ </button>
1877
1887
  <label class="es-stealth-toggle">
1878
1888
  <input type="checkbox" id="stealthToggle${suffix}">
1879
1889
  <i class="fa-solid fa-user-ninja"></i> Stealth
@@ -1904,6 +1914,7 @@ function buildHtmlTemplate(data, opts = {}) {
1904
1914
  <button class="term-btn" data-cmd="keywords" data-project="${project}"><i class="fa-solid fa-key"></i> Keywords</button>` : ''}
1905
1915
  <button class="term-btn" data-cmd="status" data-project=""><i class="fa-solid fa-circle-info"></i> Status</button>
1906
1916
  <button class="term-btn" data-cmd="guide" data-project="${project}"><i class="fa-solid fa-map"></i> Guide</button>
1917
+ <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>
1907
1918
  ${!pro ? `<span style="font-size:0.55rem;color:var(--text-muted);margin-left:auto;"><i class="fa-solid fa-lock" style="color:var(--accent-gold);margin-right:3px;"></i><a href="https://ukkometa.fi/en/seo-intel/" target="_blank" style="color:var(--accent-gold);text-decoration:none;">Solo</a> for extract, analyze, exports</span>` : ''}
1908
1919
  </div>
1909
1920
  <!-- Terminal output -->
@@ -1972,7 +1983,18 @@ function buildHtmlTemplate(data, opts = {}) {
1972
1983
  }
1973
1984
 
1974
1985
  function runCommand(command, proj, extra) {
1975
- if (running) return;
1986
+ // Setup always works — even during a running crawl
1987
+ if (command === 'setup') {
1988
+ if (isServed) {
1989
+ window.open('/setup', '_blank');
1990
+ } else {
1991
+ window.open('http://localhost:3000/setup', '_blank');
1992
+ appendLine('Opening setup wizard at localhost:3000/setup', 'stdout');
1993
+ appendLine('If it does not open, run: seo-intel setup', 'cmd');
1994
+ }
1995
+ return;
1996
+ }
1997
+
1976
1998
  if (!isServed) {
1977
1999
  appendLine('', 'cmd');
1978
2000
  appendLine('Not connected to server. Run in your terminal:', 'error');
@@ -3415,9 +3437,22 @@ function buildHtmlTemplate(data, opts = {}) {
3415
3437
  .catch(function(err) { alert('Server error: ' + err.message); });
3416
3438
  };
3417
3439
 
3440
+ window.stopJob = function() {
3441
+ if (!confirm('Stop the running job?')) return;
3442
+ fetch('/api/stop', { method: 'POST' })
3443
+ .then(function(r) { return r.json(); })
3444
+ .then(function(data) {
3445
+ if (data.stopped) {
3446
+ setButtonsState(false, null);
3447
+ }
3448
+ })
3449
+ .catch(function(err) { alert('Stop failed: ' + err.message); });
3450
+ };
3451
+
3418
3452
  function setButtonsState(disabled, activeCmd) {
3419
3453
  var btnC = document.getElementById('btnCrawl' + sfx);
3420
3454
  var btnE = document.getElementById('btnExtract' + sfx);
3455
+ var btnS = document.getElementById('btnStop' + sfx);
3421
3456
  if (btnC) {
3422
3457
  btnC.disabled = disabled;
3423
3458
  if (disabled && activeCmd === 'crawl') {
@@ -3438,6 +3473,9 @@ function buildHtmlTemplate(data, opts = {}) {
3438
3473
  btnE.innerHTML = '<i class="fa-solid fa-brain"></i> Extract';
3439
3474
  }
3440
3475
  }
3476
+ if (btnS) {
3477
+ btnS.style.display = disabled ? 'inline-flex' : 'none';
3478
+ }
3441
3479
  }
3442
3480
 
3443
3481
  function startPolling() {
@@ -4591,10 +4629,21 @@ function buildMultiHtmlTemplate(allProjectData) {
4591
4629
  .catch(function(err) { alert('Server error: ' + err.message); });
4592
4630
  };
4593
4631
 
4632
+ window.stopJob = function() {
4633
+ if (!confirm('Stop the running job?')) return;
4634
+ fetch('/api/stop', { method: 'POST' })
4635
+ .then(function(r) { return r.json(); })
4636
+ .then(function(data) {
4637
+ if (data.stopped) setButtonsState(false, null);
4638
+ })
4639
+ .catch(function(err) { alert('Stop failed: ' + err.message); });
4640
+ };
4641
+
4594
4642
  function setButtonsState(disabled, activeCmd) {
4595
4643
  var sfx = '-' + currentProject;
4596
4644
  var btnC = document.getElementById('btnCrawl' + sfx);
4597
4645
  var btnE = document.getElementById('btnExtract' + sfx);
4646
+ var btnS = document.getElementById('btnStop' + sfx);
4598
4647
  if (btnC) {
4599
4648
  btnC.disabled = disabled;
4600
4649
  btnC.classList.toggle('running', disabled && activeCmd === 'crawl');
@@ -4609,6 +4658,9 @@ function buildMultiHtmlTemplate(allProjectData) {
4609
4658
  ? '<i class="fa-solid fa-spinner fa-spin"></i> Extracting\u2026'
4610
4659
  : '<i class="fa-solid fa-brain"></i> Extract';
4611
4660
  }
4661
+ if (btnS) {
4662
+ btnS.style.display = disabled ? 'inline-flex' : 'none';
4663
+ }
4612
4664
  }
4613
4665
 
4614
4666
  function startPolling() { if (!pollTimer) { pollTimer = setInterval(pollProgress, 2000); pollProgress(); } }
@@ -4679,6 +4731,23 @@ function getLatestKeywordsReport(project) {
4679
4731
  * Works on any array of objects with { domain, role, ...numeric fields }.
4680
4732
  * Numeric fields are summed; role is set to 'target'.
4681
4733
  */
4734
+
4735
+ /**
4736
+ * Build a domain resolver closure. Returns a function that maps
4737
+ * any (domain, role) pair to the resolved domain name.
4738
+ * Owned domains (by config OR by DB role) resolve to the target domain.
4739
+ */
4740
+ function buildDomainResolver(config) {
4741
+ const targetDomain = config?.target?.domain;
4742
+ const ownedSet = new Set((config?.owned || []).map(o => o.domain));
4743
+ return function(domain, role) {
4744
+ if (!targetDomain) return domain;
4745
+ if (domain === targetDomain) return targetDomain;
4746
+ if (ownedSet.has(domain) || role === 'owned') return targetDomain;
4747
+ return domain;
4748
+ };
4749
+ }
4750
+
4682
4751
  function mergeOwnedDomains(rows, config) {
4683
4752
  if (!config?.target?.domain) return rows;
4684
4753
 
@@ -4940,7 +5009,7 @@ function getTechnicalScores(db, project, config) {
4940
5009
  let targetRow = null;
4941
5010
 
4942
5011
  for (const row of rawScores) {
4943
- const isOwned = ownedDomains.has(row.domain);
5012
+ const isOwned = ownedDomains.has(row.domain) || row.role === 'owned';
4944
5013
  const isTarget = row.domain === targetDomain;
4945
5014
 
4946
5015
  if (isOwned || isTarget) {
@@ -5543,7 +5612,7 @@ function getSchemaBreakdown(db, project) {
5543
5612
 
5544
5613
  // ─── Advanced Visualization Data Functions ───────────────────────────────────
5545
5614
 
5546
- function getGravityMapData(db, project) {
5615
+ function getGravityMapData(db, project, config) {
5547
5616
  // Get keyword sets per domain for overlap calculation
5548
5617
  const rows = db.prepare(`
5549
5618
  SELECT DISTINCT d.domain, d.role, k.keyword
@@ -5551,10 +5620,13 @@ function getGravityMapData(db, project) {
5551
5620
  WHERE d.project = ? AND k.keyword LIKE '% %'
5552
5621
  `).all(project);
5553
5622
 
5623
+ const resolve = buildDomainResolver(config);
5554
5624
  const domainKws = {};
5555
5625
  for (const r of rows) {
5556
- if (!domainKws[r.domain]) domainKws[r.domain] = { role: r.role, keywords: new Set() };
5557
- domainKws[r.domain].keywords.add(r.keyword.toLowerCase());
5626
+ const domain = resolve(r.domain, r.role);
5627
+ const role = domain === config?.target?.domain ? 'target' : r.role;
5628
+ if (!domainKws[domain]) domainKws[domain] = { role, keywords: new Set() };
5629
+ domainKws[domain].keywords.add(r.keyword.toLowerCase());
5558
5630
  }
5559
5631
 
5560
5632
  // Build nodes
@@ -5661,11 +5733,11 @@ function getHeadingFlowData(db, project, config) {
5661
5733
  // Resolve owned → target
5662
5734
  const targetDomain = config?.target?.domain;
5663
5735
  const ownedDomains = new Set((config?.owned || []).map(o => o.domain));
5664
- const resolve = (d) => ownedDomains.has(d) ? targetDomain : d;
5736
+ const resolve = (d, role) => (ownedDomains.has(d) || role === 'owned') ? targetDomain : d;
5665
5737
 
5666
5738
  const domains = {};
5667
5739
  for (const r of rows) {
5668
- const domain = resolve(r.domain);
5740
+ const domain = resolve(r.domain, r.role);
5669
5741
  const role = domain === targetDomain ? 'target' : r.role;
5670
5742
  if (!domains[domain]) domains[domain] = { role, h1: 0, h2: 0, h3: 0 };
5671
5743
  domains[domain][`h${r.level}`] += r.cnt;
@@ -5786,11 +5858,11 @@ function getLinkRadarPulseData(db, project, config) {
5786
5858
  // Resolve owned domains → target
5787
5859
  const targetDomain = config?.target?.domain;
5788
5860
  const ownedDomains = new Set((config?.owned || []).map(o => o.domain));
5789
- const resolveDomain = (d) => ownedDomains.has(d) ? targetDomain : d;
5861
+ const resolveDomain = (d, role) => (ownedDomains.has(d) || role === 'owned') ? targetDomain : d;
5790
5862
 
5791
5863
  const domains = {};
5792
5864
  for (const r of rows) {
5793
- const domain = resolveDomain(r.domain);
5865
+ const domain = resolveDomain(r.domain, r.role);
5794
5866
  const role = domain === targetDomain ? 'target' : r.role;
5795
5867
  if (!domains[domain]) domains[domain] = { role, depths: [] };
5796
5868
 
@@ -5832,7 +5904,7 @@ function getExtractionStatus(db, project, config) {
5832
5904
  let targetRow = null;
5833
5905
 
5834
5906
  for (const row of rawCoverage) {
5835
- if (ownedDomains.has(row.domain) && targetDomain) {
5907
+ if ((ownedDomains.has(row.domain) || row.role === 'owned') && targetDomain) {
5836
5908
  // Merge into target
5837
5909
  if (!targetRow) {
5838
5910
  targetRow = { ...row, domain: targetDomain, role: 'target' };
package/server.js CHANGED
@@ -324,6 +324,31 @@ async function handleRequest(req, res) {
324
324
  }
325
325
 
326
326
 
327
+ // ─── API: Stop running job ───
328
+ if (req.method === 'POST' && path === '/api/stop') {
329
+ try {
330
+ const progress = readProgress();
331
+ if (!progress || progress.status !== 'running' || !progress.pid) {
332
+ json(res, 404, { error: 'No running job to stop' });
333
+ return;
334
+ }
335
+ try {
336
+ process.kill(progress.pid, 'SIGTERM');
337
+ // Give it a moment, then force kill if still alive
338
+ setTimeout(() => {
339
+ try { process.kill(progress.pid, 'SIGKILL'); } catch {}
340
+ }, 3000);
341
+ } catch (e) {
342
+ if (e.code !== 'ESRCH') throw e;
343
+ // Already dead
344
+ }
345
+ json(res, 200, { stopped: true, pid: progress.pid, command: progress.command });
346
+ } catch (e) {
347
+ json(res, 500, { error: e.message });
348
+ }
349
+ return;
350
+ }
351
+
327
352
  // ─── API: Export actions ───
328
353
  if (req.method === 'POST' && path === '/api/export-actions') {
329
354
  try {
package/setup/checks.js CHANGED
@@ -79,28 +79,33 @@ export async function checkOllamaRemote(host) {
79
79
  export async function checkOllamaAuto(customHosts = []) {
80
80
  // 1. Try local
81
81
  const local = checkOllamaLocal();
82
+ const allHosts = []; // Track all reachable hosts for UI
83
+
82
84
  if (local.running && local.models.length > 0) {
83
- return {
84
- available: true,
85
- mode: 'local',
86
- host: local.host,
87
- models: local.models,
88
- installed: local.installed,
89
- };
85
+ allHosts.push({ host: local.host, mode: 'local', models: local.models, reachable: true });
90
86
  }
91
87
 
92
- // 2. Try custom/LAN hosts
88
+ // 2. Try custom/LAN hosts (check ALL, not just first)
93
89
  for (const host of customHosts) {
90
+ if (host === 'http://localhost:11434') continue; // already checked
94
91
  const remote = await checkOllamaRemote(host);
95
- if (remote.reachable && remote.models.length > 0) {
96
- return {
97
- available: true,
98
- mode: 'remote',
99
- host: remote.host,
100
- models: remote.models,
101
- installed: local.installed,
102
- };
103
- }
92
+ allHosts.push({ host: remote.host, mode: 'remote', models: remote.models, reachable: remote.reachable });
93
+ }
94
+
95
+ // Pick best available host (first with models)
96
+ const best = allHosts.find(h => h.reachable && h.models.length > 0);
97
+
98
+ if (best) {
99
+ // Combine models from all reachable hosts
100
+ const allModels = [...new Set(allHosts.filter(h => h.reachable).flatMap(h => h.models))];
101
+ return {
102
+ available: true,
103
+ mode: best.mode,
104
+ host: best.host,
105
+ models: allModels,
106
+ installed: local.installed,
107
+ allHosts,
108
+ };
104
109
  }
105
110
 
106
111
  // 3. Local installed but not running or no models
@@ -111,6 +116,7 @@ export async function checkOllamaAuto(customHosts = []) {
111
116
  host: local.host,
112
117
  models: [],
113
118
  installed: true,
119
+ allHosts,
114
120
  };
115
121
  }
116
122
 
@@ -120,6 +126,7 @@ export async function checkOllamaAuto(customHosts = []) {
120
126
  host: null,
121
127
  models: [],
122
128
  installed: false,
129
+ allHosts,
123
130
  };
124
131
  }
125
132
 
package/setup/models.js CHANGED
@@ -114,7 +114,7 @@ export const EXTRACTION_MODELS = [
114
114
  // Output: structured JSON with strategic recommendations, positioning, gap analysis
115
115
  // Complexity: high — comparative reasoning across multiple domains
116
116
  // Minimum viable: 14B+ parameters for reliable strategic output
117
- // Cloud models (Claude, GPT-4o, DeepSeek) available via OpenClaw agent setup
117
+ // Cloud models (Claude, GPT-5.4, Gemini) available via OpenClaw agent setup
118
118
 
119
119
  export const ANALYSIS_MODELS = [
120
120
  {
@@ -169,6 +169,60 @@ export const ANALYSIS_MODELS = [
169
169
  recommended: false,
170
170
  description: 'MoE — 120B total but only 12B active params. Excellent reasoning at efficient compute. Needs 64GB+ unified memory or multi-GPU.',
171
171
  },
172
+ // ── Cloud frontier models (require API key in .env) ──
173
+ // ── Cloud frontier models (require API key in .env or via OpenClaw) ──
174
+ {
175
+ id: 'gemini-3.1-pro',
176
+ name: 'Gemini 3.1 Pro',
177
+ family: 'gemini',
178
+ type: 'cloud',
179
+ provider: 'gemini',
180
+ envKey: 'GEMINI_API_KEY',
181
+ context: '2M tokens',
182
+ costNote: '~$0.01–0.05/analysis',
183
+ quality: 'frontier',
184
+ recommended: false,
185
+ description: 'Google\'s latest frontier model. Massive 2M context handles the largest competitive datasets. Best value for cloud analysis.',
186
+ },
187
+ {
188
+ id: 'claude-opus-4.6',
189
+ name: 'Claude Opus 4.6',
190
+ family: 'claude',
191
+ type: 'cloud',
192
+ provider: 'anthropic',
193
+ envKey: 'ANTHROPIC_API_KEY',
194
+ context: '1M tokens',
195
+ costNote: '~$0.10–0.30/analysis',
196
+ quality: 'frontier',
197
+ recommended: false,
198
+ description: 'Anthropic\'s most capable model. Deepest reasoning for competitive gap analysis, strategic positioning, and implementation briefs.',
199
+ },
200
+ {
201
+ id: 'gpt-5.4',
202
+ name: 'GPT-5.4',
203
+ family: 'gpt',
204
+ type: 'cloud',
205
+ provider: 'openai',
206
+ envKey: 'OPENAI_API_KEY',
207
+ context: '256K tokens',
208
+ costNote: '~$0.05–0.15/analysis',
209
+ quality: 'frontier',
210
+ recommended: false,
211
+ description: 'OpenAI\'s flagship frontier model. Strong analytical reasoning for competitive intelligence and strategic recommendations.',
212
+ },
213
+ {
214
+ id: 'deepseek-r1',
215
+ name: 'DeepSeek R1',
216
+ family: 'deepseek',
217
+ type: 'cloud',
218
+ provider: 'deepseek',
219
+ envKey: 'DEEPSEEK_API_KEY',
220
+ context: '128K tokens',
221
+ costNote: '~$0.005–0.02/analysis',
222
+ quality: 'great',
223
+ recommended: false,
224
+ description: 'Reasoning-optimized model at a fraction of the cost. Excellent quality-to-price ratio for budget-conscious analysis.',
225
+ },
172
226
  ];
173
227
 
174
228
  // ── VRAM-Based Recommendations ──────────────────────────────────────────────
@@ -318,10 +372,13 @@ export function getModelRecommendations(availableModels = [], envKeys = {}, vram
318
372
  })),
319
373
  allAnalysis: ANALYSIS_MODELS.map(m => ({
320
374
  ...m,
321
- installed: availableModels.some(am =>
322
- am.startsWith(m.family) && am.includes(m.id.split(':')[1])
323
- ),
324
- fitsVram: !vramMB || vramMB >= m.minVramMB,
375
+ installed: m.type === 'cloud'
376
+ ? !!(m.envKey && envKeys[m.envKey])
377
+ : availableModels.some(am =>
378
+ am.startsWith(m.family) && am.includes(m.id.split(':')[1])
379
+ ),
380
+ configured: m.type === 'cloud' ? !!(m.envKey && envKeys[m.envKey]) : undefined,
381
+ fitsVram: m.type === 'cloud' ? true : (!vramMB || vramMB >= m.minVramMB),
325
382
  })),
326
383
  vramMB,
327
384
  };
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { dirname, join } from 'path';
12
12
  import { fileURLToPath } from 'url';
13
+ import { isGatewayReady } from './openclaw-bridge.js';
13
14
 
14
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
15
16
  const ROOT = join(__dirname, '..');
@@ -28,7 +29,7 @@ export async function testOllamaConnectivity(host, model) {
28
29
 
29
30
  try {
30
31
  const controller = new AbortController();
31
- const timeout = setTimeout(() => controller.abort(), 15000);
32
+ const timeout = setTimeout(() => controller.abort(), 90000); // 90s — model loading can be slow on first run
32
33
 
33
34
  const res = await fetch(`${host}/api/generate`, {
34
35
  method: 'POST',
@@ -211,7 +212,7 @@ export async function testCrawl(url) {
211
212
  return {
212
213
  success: false,
213
214
  latencyMs: Date.now() - start,
214
- error: err.message.slice(0, 300),
215
+ error: (err?.message || String(err) || 'Unknown error').slice(0, 300),
215
216
  };
216
217
  }
217
218
  }
@@ -239,7 +240,7 @@ export async function testExtraction(host, model, samplePage) {
239
240
 
240
241
  process.env.OLLAMA_URL = host;
241
242
  process.env.OLLAMA_MODEL = model;
242
- process.env.OLLAMA_TIMEOUT_MS = '30000'; // generous timeout for test
243
+ process.env.OLLAMA_TIMEOUT_MS = '120000'; // 2 min first model load can be slow
243
244
 
244
245
  try {
245
246
  const result = await extractPage({
@@ -254,10 +255,12 @@ export async function testExtraction(host, model, samplePage) {
254
255
  });
255
256
 
256
257
  const keywordsFound = (result.keywords || []).length;
258
+ const isDegraded = result.extraction_source === 'degraded';
257
259
  return {
258
- success: result.extraction_source !== 'degraded',
260
+ success: !isDegraded,
259
261
  keywordsFound,
260
262
  latencyMs: Date.now() - start,
263
+ error: isDegraded ? 'Extraction returned degraded results — model may need more VRAM or a longer timeout' : undefined,
261
264
  preview: {
262
265
  title: result.title?.slice(0, 60),
263
266
  intent: result.search_intent,
@@ -278,7 +281,7 @@ export async function testExtraction(host, model, samplePage) {
278
281
  return {
279
282
  success: false,
280
283
  latencyMs: Date.now() - start,
281
- error: err.message.slice(0, 300),
284
+ error: (err?.message || String(err) || 'Unknown error').slice(0, 300),
282
285
  };
283
286
  }
284
287
  }
@@ -300,15 +303,15 @@ export async function testExtraction(host, model, samplePage) {
300
303
  export async function* runFullValidation(config) {
301
304
  const steps = [];
302
305
 
303
- // Step 1: Ollama
306
+ // Step 1: Ollama — warm up model with connectivity test
304
307
  if (config.ollamaHost && config.ollamaModel) {
305
- yield { step: 'ollama', status: 'running', detail: `Testing ${config.ollamaModel} at ${config.ollamaHost}...` };
308
+ yield { step: 'ollama', status: 'running', detail: `Loading ${config.ollamaModel}... (first run loads model into memory)` };
306
309
  const result = await testOllamaConnectivity(config.ollamaHost, config.ollamaModel);
307
310
  steps.push({ name: 'Ollama Connectivity', ...result, status: result.success ? 'pass' : 'fail' });
308
311
  yield {
309
312
  step: 'ollama',
310
313
  status: result.success ? 'pass' : 'fail',
311
- detail: result.success ? `Connected (${result.latencyMs}ms)` : `Failed: ${result.error}`,
314
+ detail: result.success ? `Connected, model warm (${result.latencyMs}ms)` : `Failed: ${result.error}`,
312
315
  latencyMs: result.latencyMs,
313
316
  };
314
317
  } else {
@@ -316,20 +319,61 @@ export async function* runFullValidation(config) {
316
319
  yield { step: 'ollama', status: 'skip', detail: 'No Ollama configured — extraction will use degraded mode' };
317
320
  }
318
321
 
319
- // Step 2: API Key
320
- if (config.apiProvider && config.apiKey) {
321
- yield { step: 'api-key', status: 'running', detail: `Validating ${config.apiProvider} API key...` };
322
- const result = await testApiKey(config.apiProvider, config.apiKey);
322
+ // Step 2: API Key — resolve from .env if needed
323
+ let apiProvider = config.apiProvider || '';
324
+ let apiKey = config.apiKey || '';
325
+
326
+ if (apiProvider === '__check_env__' || apiKey === '__from_env__') {
327
+ // Auto-detect from .env
328
+ const envKeys = [
329
+ { provider: 'gemini', env: 'GEMINI_API_KEY' },
330
+ { provider: 'openai', env: 'OPENAI_API_KEY' },
331
+ { provider: 'anthropic', env: 'ANTHROPIC_API_KEY' },
332
+ { provider: 'deepseek', env: 'DEEPSEEK_API_KEY' },
333
+ ];
334
+ for (const { provider, env } of envKeys) {
335
+ if (process.env[env]) {
336
+ apiProvider = provider;
337
+ apiKey = process.env[env];
338
+ break;
339
+ }
340
+ }
341
+ }
342
+
343
+ if (apiProvider && apiKey && apiKey !== '__from_env__') {
344
+ yield { step: 'api-key', status: 'running', detail: `Validating ${apiProvider} API key...` };
345
+ const result = await testApiKey(apiProvider, apiKey);
323
346
  steps.push({ name: 'API Key', ...result, status: result.valid ? 'pass' : 'fail' });
324
347
  yield {
325
348
  step: 'api-key',
326
349
  status: result.valid ? 'pass' : 'fail',
327
- detail: result.valid ? `${config.apiProvider} key valid (${result.latencyMs}ms)` : `Invalid: ${result.error}`,
350
+ detail: result.valid ? `${apiProvider} key valid (${result.latencyMs}ms)` : `Invalid: ${result.error}`,
328
351
  latencyMs: result.latencyMs,
329
352
  };
330
353
  } else {
331
- steps.push({ name: 'API Key', status: 'skip' });
332
- yield { step: 'api-key', status: 'skip', detail: 'No API key configured — analysis unavailable' };
354
+ // Check if OpenClaw gateway is available as alternative
355
+ yield { step: 'api-key', status: 'running', detail: 'Checking OpenClaw gateway...' };
356
+ const openclawReady = await isGatewayReady();
357
+ if (openclawReady) {
358
+ // Verify models are accessible
359
+ let modelInfo = '';
360
+ try {
361
+ const ctrl = new AbortController();
362
+ const t = setTimeout(() => ctrl.abort(), 5000);
363
+ const mRes = await fetch('http://127.0.0.1:18789/v1/models', { signal: ctrl.signal });
364
+ clearTimeout(t);
365
+ if (mRes.ok) {
366
+ const mData = await mRes.json();
367
+ const count = mData?.data?.length || 0;
368
+ modelInfo = count > 0 ? ` — ${count} model(s) available` : '';
369
+ }
370
+ } catch {}
371
+ steps.push({ name: 'API Key', status: 'pass' });
372
+ yield { step: 'api-key', status: 'pass', detail: `OpenClaw gateway connected${modelInfo}. Use frontier models (Opus, Gemini Pro, GPT-5.4) for analysis.` };
373
+ } else {
374
+ steps.push({ name: 'API Key', status: 'skip' });
375
+ yield { step: 'api-key', status: 'skip', detail: 'No API key or OpenClaw gateway found. Add a key in .env or install OpenClaw.' };
376
+ }
333
377
  }
334
378
 
335
379
  // Step 3: Test Crawl
@@ -350,7 +394,7 @@ export async function* runFullValidation(config) {
350
394
 
351
395
  // Step 4: Test Extraction (only if crawl succeeded AND Ollama available)
352
396
  if (result.success && config.ollamaHost && config.ollamaModel && steps[0]?.status === 'pass') {
353
- yield { step: 'extraction', status: 'running', detail: `Extracting with ${config.ollamaModel}...` };
397
+ yield { step: 'extraction', status: 'running', detail: `Extracting with ${config.ollamaModel}... (first run may take 1-2 min while model loads)` };
354
398
  const extractResult = await testExtraction(config.ollamaHost, config.ollamaModel, {
355
399
  url: config.targetUrl,
356
400
  title: result.title,
@@ -122,12 +122,37 @@ export function handleSetupRequest(req, res, url) {
122
122
  return true;
123
123
  }
124
124
 
125
+ // GET /api/setup/ping-ollama?host=... — ping a remote Ollama host
126
+ if (path === '/api/setup/ping-ollama' && method === 'GET') {
127
+ handlePingOllama(req, res);
128
+ return true;
129
+ }
130
+
131
+ // POST /api/setup/save-env — save a key to .env
132
+ if (path === '/api/setup/save-env' && method === 'POST') {
133
+ handleSaveEnv(req, res);
134
+ return true;
135
+ }
136
+
125
137
  // POST /api/setup/env — update .env keys
126
138
  if (path === '/api/setup/env' && method === 'POST') {
127
139
  handleEnv(req, res);
128
140
  return true;
129
141
  }
130
142
 
143
+ // GET /api/setup/config/:project — read existing project config
144
+ if (path.startsWith('/api/setup/config/') && method === 'GET') {
145
+ const projectName = decodeURIComponent(path.split('/').pop());
146
+ try {
147
+ const configPath = join(ROOT, 'config', `${projectName}.json`);
148
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
149
+ jsonResponse(res, config);
150
+ } catch (err) {
151
+ jsonResponse(res, { error: 'Config not found: ' + projectName }, 404);
152
+ }
153
+ return true;
154
+ }
155
+
131
156
  // POST /api/setup/config — create project config
132
157
  if (path === '/api/setup/config' && method === 'POST') {
133
158
  handleConfig(req, res);
@@ -227,9 +252,16 @@ function serveWizardHtml(res) {
227
252
  res.end(html);
228
253
  }
229
254
 
255
+ function getOllamaHosts() {
256
+ const hosts = [];
257
+ if (process.env.OLLAMA_URL) hosts.push(process.env.OLLAMA_URL);
258
+ if (process.env.OLLAMA_FALLBACK_URL) hosts.push(process.env.OLLAMA_FALLBACK_URL);
259
+ return hosts;
260
+ }
261
+
230
262
  async function handleStatus(req, res) {
231
263
  try {
232
- const status = await fullSystemCheck();
264
+ const status = await fullSystemCheck({ customOllamaHosts: getOllamaHosts() });
233
265
  jsonResponse(res, status);
234
266
  } catch (err) {
235
267
  jsonResponse(res, { error: err.message }, 500);
@@ -238,7 +270,7 @@ async function handleStatus(req, res) {
238
270
 
239
271
  async function handleModels(req, res) {
240
272
  try {
241
- const status = await fullSystemCheck();
273
+ const status = await fullSystemCheck({ customOllamaHosts: getOllamaHosts() });
242
274
  const models = getModelRecommendations(
243
275
  status.ollama.models,
244
276
  status.env.keys,
@@ -254,6 +286,50 @@ async function handleModels(req, res) {
254
286
  }
255
287
  }
256
288
 
289
+ async function handlePingOllama(req, res) {
290
+ try {
291
+ const url = new URL(req.url, 'http://localhost');
292
+ const host = url.searchParams.get('host');
293
+ if (!host) { jsonResponse(res, { error: 'Missing host param' }, 400); return; }
294
+
295
+ const { checkOllamaRemote } = await import('./checks.js');
296
+ const result = await checkOllamaRemote(host);
297
+ jsonResponse(res, result);
298
+ } catch (err) {
299
+ jsonResponse(res, { error: err.message }, 500);
300
+ }
301
+ }
302
+
303
+ async function handleSaveEnv(req, res) {
304
+ try {
305
+ const body = await readBody(req);
306
+ const { key, value } = body;
307
+ if (!key || !key.match(/^[A-Z_]+$/)) { jsonResponse(res, { error: 'Invalid key' }, 400); return; }
308
+
309
+ const { join } = await import('path');
310
+ const { readFileSync, writeFileSync, existsSync } = await import('fs');
311
+ const envPath = join(process.cwd(), '.env');
312
+ let envContent = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '';
313
+
314
+ const regex = new RegExp(`^${key}=.*$`, 'm');
315
+ if (value) {
316
+ if (regex.test(envContent)) {
317
+ envContent = envContent.replace(regex, `${key}=${value}`);
318
+ } else {
319
+ envContent = envContent.trimEnd() + `\n${key}=${value}\n`;
320
+ }
321
+ process.env[key] = value;
322
+ } else {
323
+ envContent = envContent.replace(regex, '').replace(/\n{3,}/g, '\n\n');
324
+ delete process.env[key];
325
+ }
326
+ writeFileSync(envPath, envContent);
327
+ jsonResponse(res, { saved: true, key });
328
+ } catch (err) {
329
+ jsonResponse(res, { error: err.message }, 500);
330
+ }
331
+ }
332
+
257
333
  async function handleInstall(req, res) {
258
334
  try {
259
335
  const body = await readBody(req);
package/setup/wizard.html CHANGED
@@ -1297,8 +1297,8 @@ input::placeholder {
1297
1297
  <div class="openclaw-banner-desc">OpenClaw guides you through the entire setup conversationally — LLM configuration, cloud model routing, OAuth, and troubleshooting. <strong>Recommended for the best experience.</strong></div>
1298
1298
  <div style="margin-top:10px; padding:8px 10px; 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.72rem; color:var(--text-secondary); display:flex; align-items:center; gap:8px;">
1299
1299
  <span style="color:var(--text-muted);">$</span>
1300
- <span id="clawhubCmd">clawhub install seo-intel</span>
1301
- <button class="btn btn-sm" style="margin-left:auto; padding:3px 8px; font-size:0.65rem;" onclick="navigator.clipboard.writeText('clawhub install seo-intel');this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500);">Copy</button>
1300
+ <span id="clawhubCmd">clawhub install ukkometa/seo-intel</span>
1301
+ <button class="btn btn-sm" style="margin-left:auto; padding:3px 8px; font-size:0.65rem;" onclick="navigator.clipboard.writeText('clawhub install ukkometa/seo-intel');this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500);">Copy</button>
1302
1302
  </div>
1303
1303
  <div class="openclaw-banner-actions">
1304
1304
  <button class="btn btn-gold" onclick="startAgentSetup()"><i class="fa-solid fa-play"></i> Start Setup</button>
@@ -1315,7 +1315,7 @@ input::placeholder {
1315
1315
  </div>
1316
1316
  <div class="openclaw-banner-content">
1317
1317
  <div class="openclaw-banner-desc" style="color:var(--text-muted); font-size:0.72rem;">
1318
- <strong style="color:var(--text-secondary);">Tip:</strong> Run <code style="background:rgba(124,109,235,0.1); padding:1px 5px; border-radius:3px; color:var(--accent-purple);">clawhub install seo-intel</code> for guided agent setup with cloud model routing.
1318
+ <strong style="color:var(--text-secondary);">Tip:</strong> Run <code style="background:rgba(124,109,235,0.1); padding:1px 5px; border-radius:3px; color:var(--accent-purple);">clawhub install ukkometa/seo-intel</code> for guided agent setup with cloud model routing.
1319
1319
  </div>
1320
1320
  </div>
1321
1321
  </div>
@@ -1337,8 +1337,32 @@ input::placeholder {
1337
1337
 
1338
1338
  <!-- ─── Step 2: Model Selection ───────────────────────────────────────── -->
1339
1339
  <div class="step-panel" id="step2">
1340
- <div class="card">
1340
+ <div style="display:grid; grid-template-columns:1fr 260px; gap:16px; align-items:start;">
1341
+ <div class="card" style="margin-bottom:0; min-width:0;">
1341
1342
  <h2><i class="fa-solid fa-microchip"></i> Model Selection</h2>
1343
+
1344
+ <!-- Ollama Hosts -->
1345
+ <div id="ollamaHostsPanel" style="margin-bottom:16px; padding:12px 14px; background:var(--bg-card); border:1px solid var(--border-card); border-radius:var(--radius);">
1346
+ <div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:8px;">
1347
+ <span style="font-size:0.72rem; font-weight:600; color:var(--text-muted); letter-spacing:0.04em; text-transform:uppercase;">Ollama Hosts</span>
1348
+ <button class="btn btn-sm" onclick="addOllamaHost()" style="padding:3px 8px; font-size:0.6rem;"><i class="fa-solid fa-plus"></i> Add LAN Host</button>
1349
+ </div>
1350
+ <div id="ollamaHostList" style="display:flex; flex-direction:column; gap:6px;">
1351
+ <!-- Populated by JS on load -->
1352
+ </div>
1353
+ <div id="addHostRow" style="display:none; margin-top:8px;">
1354
+ <div style="display:flex; gap:6px; align-items:center;">
1355
+ <input type="text" id="newHostInput" placeholder="http://192.168.1.100:11434" style="flex:1; padding:6px 8px; background:var(--bg-elevated); border:1px solid var(--border-subtle); border-radius:var(--radius); color:var(--text-primary); font-family:var(--font-mono); font-size:0.7rem;">
1356
+ <button class="btn btn-sm" onclick="pingAndAddHost()" id="pingHostBtn" style="padding:4px 10px; font-size:0.62rem;"><i class="fa-solid fa-satellite-dish"></i> Ping</button>
1357
+ <button class="btn btn-sm" onclick="document.getElementById('addHostRow').style.display='none'" style="padding:4px 8px; font-size:0.62rem;"><i class="fa-solid fa-xmark"></i></button>
1358
+ </div>
1359
+ <p id="pingResult" style="font-size:0.6rem; color:var(--text-muted); margin-top:4px;"></p>
1360
+ </div>
1361
+ <p style="font-size:0.58rem; color:var(--text-muted); margin-top:6px; line-height:1.4;">
1362
+ Run Ollama on any machine in your network. SEO Intel auto-detects localhost and falls back through configured hosts.
1363
+ </p>
1364
+ </div>
1365
+
1342
1366
  <div class="model-columns">
1343
1367
  <div class="model-section" id="extractionSection">
1344
1368
  <h3><i class="fa-solid fa-robot"></i> Extraction Tier</h3>
@@ -1393,25 +1417,18 @@ input::placeholder {
1393
1417
  <button class="btn btn-sm" id="analysisPullBtn" onclick="pullAnalysisModel()"><i class="fa-solid fa-download"></i> Pull Model</button>
1394
1418
  <span id="analysisPullStatus" style="font-size:0.7rem; color:var(--text-muted);"></span>
1395
1419
  </div>
1396
- <div id="analysisPullLog" class="log-output"></div>
1397
- <!-- OpenClaw cloud upgrade banner -->
1398
- <div style="margin-top:12px; padding:12px 14px; background:rgba(124,109,235,0.06); border:1px solid rgba(124,109,235,0.15); border-radius:var(--radius);">
1399
- <div style="display:flex; align-items:center; gap:8px; margin-bottom:6px;">
1400
- <svg width="16" height="16" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg" style="flex-shrink:0;">
1401
- <circle cx="14" cy="14" r="13" stroke="var(--accent-purple)" stroke-width="1.5" opacity="0.6"/>
1402
- <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"/>
1403
- </svg>
1404
- <span style="font-size:0.75rem; font-weight:500; color:var(--text-primary);">Want cloud-quality analysis?</span>
1405
- </div>
1406
- <p style="font-size:0.68rem; color:var(--text-muted); line-height:1.5; margin-bottom:8px;">
1407
- Route analysis through Claude, GPT-4o, or DeepSeek for deeper strategic insights on large datasets.
1408
- </p>
1409
- <div style="padding:6px 8px; 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.68rem; color:var(--text-secondary); display:flex; align-items:center; gap:6px; margin-bottom:8px;">
1410
- <span style="color:var(--text-muted);">$</span>
1411
- <span>clawhub install seo-intel</span>
1412
- <button class="btn btn-sm" style="margin-left:auto; padding:2px 6px; font-size:0.6rem;" onclick="navigator.clipboard.writeText('clawhub install seo-intel');this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500);">Copy</button>
1420
+ <!-- Cloud model key hint (shown instead of pull for cloud models) -->
1421
+ <div id="analysisCloudHint" style="display:none; margin-top:8px; padding:10px 12px; background:rgba(232,213,163,0.04); border:1px solid rgba(232,213,163,0.12); border-radius:var(--radius);">
1422
+ <p style="font-size:0.72rem; color:var(--text-secondary); margin-bottom:6px;"><i class="fa-solid fa-key" style="color:var(--accent-gold); margin-right:6px;"></i><span id="cloudHintProvider"></span></p>
1423
+ <p style="font-size:0.65rem; color:var(--text-muted); line-height:1.5; margin-bottom:6px;">Add the API key to your <code style="background:rgba(232,213,163,0.08); padding:1px 4px; border-radius:3px;">.env</code> file:</p>
1424
+ <div style="padding:6px 8px; 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.65rem; color:var(--text-secondary); display:flex; align-items:center; gap:6px;">
1425
+ <span id="cloudHintEnvLine"></span>
1426
+ <button class="btn btn-sm" style="margin-left:auto; padding:2px 6px; font-size:0.55rem;" onclick="navigator.clipboard.writeText(document.getElementById('cloudHintEnvLine').textContent);this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500);">Copy</button>
1413
1427
  </div>
1428
+ <p style="font-size:0.6rem; color:var(--text-muted); margin-top:6px;">Or use <strong style="color:var(--accent-purple);">OpenClaw</strong> to manage API keys automatically.</p>
1414
1429
  </div>
1430
+ <div id="analysisPullLog" class="log-output"></div>
1431
+ <!-- OpenClaw hint moved to standalone sidebar card -->
1415
1432
  <!-- Upgrade overlay (shown on free tier) -->
1416
1433
  <div class="upgrade-overlay">
1417
1434
  <div class="upgrade-overlay-bg"></div>
@@ -1455,6 +1472,56 @@ input::placeholder {
1455
1472
  </button>
1456
1473
  </div>
1457
1474
  </div>
1475
+
1476
+ <!-- OpenClaw sidebar card -->
1477
+ <div class="card" style="margin-bottom:0; position:sticky; top:20px;">
1478
+ <div style="display:flex; align-items:center; gap:10px; margin-bottom:12px;">
1479
+ <svg width="22" height="22" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
1480
+ <circle cx="14" cy="14" r="13" stroke="var(--accent-purple)" stroke-width="1.5" opacity="0.6"/>
1481
+ <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"/>
1482
+ </svg>
1483
+ <h3 style="font-size:0.85rem; font-weight:600; color:var(--text-primary); margin:0;">OpenClaw</h3>
1484
+ </div>
1485
+
1486
+ <p style="font-size:0.72rem; color:var(--text-muted); line-height:1.6; margin-bottom:12px;">
1487
+ Route analysis through frontier models. OpenClaw manages API keys, OAuth, and model routing automatically.
1488
+ </p>
1489
+
1490
+ <div style="font-size:0.68rem; color:var(--text-secondary); margin-bottom:12px;">
1491
+ <div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
1492
+ <i class="fa-solid fa-check" style="color:var(--accent-purple); font-size:0.6rem; width:12px;"></i>
1493
+ Claude Opus 4.6
1494
+ </div>
1495
+ <div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
1496
+ <i class="fa-solid fa-check" style="color:var(--accent-purple); font-size:0.6rem; width:12px;"></i>
1497
+ Gemini 3.1 Pro
1498
+ </div>
1499
+ <div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
1500
+ <i class="fa-solid fa-check" style="color:var(--accent-purple); font-size:0.6rem; width:12px;"></i>
1501
+ GPT-5.4
1502
+ </div>
1503
+ </div>
1504
+
1505
+ <p style="font-size:0.62rem; color:var(--text-muted); line-height:1.5; margin-bottom:8px;">
1506
+ No .env keys needed. OAuth-based model access means the pipeline test may show "no API key" — this is normal when using OpenClaw.
1507
+ </p>
1508
+
1509
+ <div style="padding:6px 8px; 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.65rem; color:var(--text-secondary); display:flex; align-items:center; gap:6px; margin-bottom:8px;">
1510
+ <span style="color:var(--text-muted);">$</span>
1511
+ <span>clawhub install ukkometa/seo-intel</span>
1512
+ <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='Copied!';setTimeout(()=>this.textContent='Copy',1500);">Copy</button>
1513
+ </div>
1514
+
1515
+ <p style="font-size:0.6rem; color:var(--text-muted); margin-bottom:6px;">Or paste into OpenClaw chat:</p>
1516
+ <div style="padding:8px 10px; 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.6rem; color:var(--text-secondary); line-height:1.6; position:relative;">
1517
+ <div style="white-space:pre;">Set up SEO Intel cloud analysis.
1518
+ Use Gemini for extraction, Claude for analysis.
1519
+ Configure API keys and run a test crawl.</div>
1520
+ <button class="btn btn-sm" style="position:absolute; top:6px; right:6px; padding:2px 6px; font-size:0.5rem;" onclick="navigator.clipboard.writeText('Set up SEO Intel cloud analysis.\nUse Gemini for extraction, Claude for analysis.\nConfigure API keys and run a test crawl.');this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500);">Copy</button>
1521
+ </div>
1522
+ </div>
1523
+
1524
+ </div><!-- close grid -->
1458
1525
  </div>
1459
1526
 
1460
1527
  <!-- ─── Step 3: Project Configuration ─────────────────────────────────── -->
@@ -1462,6 +1529,13 @@ input::placeholder {
1462
1529
  <div class="card">
1463
1530
  <h2><i class="fa-solid fa-folder-open"></i> Project Configuration</h2>
1464
1531
 
1532
+ <div id="existingProjectBar" style="display:none;margin-bottom:1.25rem;padding:10px 14px;background:rgba(232,213,163,0.06);border:1px solid rgba(232,213,163,0.15);border-radius:8px;">
1533
+ <label style="font-size:0.72rem;font-weight:600;color:var(--text-muted);letter-spacing:0.04em;text-transform:uppercase;margin-bottom:6px;display:block;">Edit existing project</label>
1534
+ <select id="existingProjectSelect" onchange="loadExistingProject(this.value)" style="width:100%;padding:8px 10px;background:var(--bg-elevated);border:1px solid var(--border-subtle);border-radius:6px;color:var(--text-primary);font-size:0.82rem;font-family:var(--font-body);">
1535
+ <option value="">New project</option>
1536
+ </select>
1537
+ </div>
1538
+
1465
1539
  <div class="form-row">
1466
1540
  <div class="form-group">
1467
1541
  <label>Project Name</label>
@@ -1543,11 +1617,7 @@ input::placeholder {
1543
1617
  <p style="font-size:0.6rem; color:var(--text-muted); margin-top:4px; font-style:italic;" id="crawlEstimateNote"></p>
1544
1618
  </div>
1545
1619
  </div>
1546
- <div class="form-group">
1547
- <label>Pages per Domain</label>
1548
- <input type="number" id="cfgPagesPerDomain" value="50" min="1" max="500">
1549
- <div class="hint">Max pages to crawl from each domain.</div>
1550
- </div>
1620
+ <input type="hidden" id="cfgPagesPerDomain" value="9999">
1551
1621
  </div>
1552
1622
 
1553
1623
  <!-- Time Estimate -->
@@ -1859,7 +1929,7 @@ input::placeholder {
1859
1929
  });
1860
1930
 
1861
1931
  // Step-specific initialization
1862
- if (n === 2 && !state.modelData) loadModels();
1932
+ if (n === 2) { renderOllamaHosts(); if (!state.modelData) loadModels(); }
1863
1933
  if (n === 3) updateCrawlEstimate();
1864
1934
  if (n === 4) checkGscStatus();
1865
1935
  };
@@ -1892,7 +1962,14 @@ input::placeholder {
1892
1962
  if (status.playwright.installed) return { cls: 'warn', icon: 'fa-exclamation', detail: 'Installed, Chromium needs setup', action: 'playwright' };
1893
1963
  return { cls: 'fail', icon: 'fa-xmark', detail: 'Not installed', action: 'playwright' };
1894
1964
  case 'ollama':
1895
- if (status.ollama.available) return { cls: 'ok', icon: 'fa-check', detail: `${status.ollama.models.length} model(s) at ${status.ollama.host}` };
1965
+ if (status.ollama.available) {
1966
+ const hosts = status.ollama.allHosts || [];
1967
+ const reachable = hosts.filter(h => h.reachable);
1968
+ const hostInfo = reachable.length > 1
1969
+ ? `${status.ollama.models.length} model(s) across ${reachable.length} hosts (${reachable.map(h => h.mode === 'local' ? 'local' : new URL(h.host).hostname).join(', ')})`
1970
+ : `${status.ollama.models.length} model(s) at ${status.ollama.host}`;
1971
+ return { cls: 'ok', icon: 'fa-check', detail: hostInfo };
1972
+ }
1896
1973
  if (status.ollama.installed) return { cls: 'warn', icon: 'fa-exclamation', detail: 'Installed but not running or no models' };
1897
1974
  return { cls: 'warn', icon: 'fa-exclamation', detail: 'Not installed (optional for extraction)' };
1898
1975
  case 'vram':
@@ -1992,6 +2069,90 @@ input::placeholder {
1992
2069
  }
1993
2070
 
1994
2071
  // ── Step 2: Model Selection ───────────────────────────────────────────
2072
+ // ── Ollama Host Management ────────────────────────────────────────────
2073
+ function renderOllamaHosts() {
2074
+ const list = document.getElementById('ollamaHostList');
2075
+ if (!list || !state.systemStatus?.ollama) return;
2076
+
2077
+ const allHosts = state.systemStatus.ollama.allHosts || [];
2078
+ // Always show localhost
2079
+ const hasLocalhost = allHosts.some(h => h.host === 'http://localhost:11434');
2080
+ if (!hasLocalhost) {
2081
+ allHosts.unshift({ host: 'http://localhost:11434', mode: 'local', models: state.systemStatus.ollama.models || [], reachable: state.systemStatus.ollama.available && state.systemStatus.ollama.mode === 'local' });
2082
+ }
2083
+
2084
+ list.innerHTML = allHosts.map(h => {
2085
+ const hostname = h.mode === 'local' ? 'localhost' : new URL(h.host).hostname;
2086
+ const port = new URL(h.host).port || '11434';
2087
+ const dot = h.reachable ? 'color:var(--color-success)' : 'color:var(--color-danger); opacity:0.5';
2088
+ const status = h.reachable ? `${h.models.length} model(s)` : 'unreachable';
2089
+ const active = h.host === state.systemStatus.ollama.host;
2090
+ return `
2091
+ <div style="display:flex; align-items:center; gap:8px; padding:6px 8px; background:${active ? 'rgba(142,203,168,0.06)' : 'transparent'}; border:1px solid ${active ? 'rgba(142,203,168,0.15)' : 'var(--border-subtle)'}; border-radius:6px;">
2092
+ <i class="fa-solid fa-circle" style="font-size:0.45rem; ${dot}"></i>
2093
+ <span style="font-family:var(--font-mono); font-size:0.68rem; color:var(--text-secondary);">${hostname}:${port}</span>
2094
+ <span style="font-size:0.6rem; color:var(--text-muted); margin-left:auto;">${status}</span>
2095
+ ${active ? '<span style="font-size:0.55rem; padding:1px 6px; border-radius:3px; background:rgba(142,203,168,0.12); color:#8ecba8;">active</span>' : ''}
2096
+ ${!h.reachable && h.mode !== 'local' ? `<button class="btn btn-sm" style="padding:1px 5px; font-size:0.5rem;" onclick="removeOllamaHost('${h.host}')"><i class="fa-solid fa-xmark"></i></button>` : ''}
2097
+ </div>`;
2098
+ }).join('');
2099
+ }
2100
+
2101
+ window.addOllamaHost = function() {
2102
+ document.getElementById('addHostRow').style.display = 'block';
2103
+ document.getElementById('newHostInput').focus();
2104
+ document.getElementById('pingResult').textContent = '';
2105
+ };
2106
+
2107
+ window.pingAndAddHost = async function() {
2108
+ const input = document.getElementById('newHostInput');
2109
+ const result = document.getElementById('pingResult');
2110
+ const btn = document.getElementById('pingHostBtn');
2111
+ let host = input.value.trim();
2112
+ if (!host) return;
2113
+
2114
+ // Auto-add protocol and port
2115
+ if (!host.startsWith('http')) host = 'http://' + host;
2116
+ if (!host.includes(':', host.indexOf('//') + 2)) host += ':11434';
2117
+
2118
+ btn.disabled = true;
2119
+ btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Pinging...';
2120
+ result.textContent = '';
2121
+
2122
+ try {
2123
+ const res = await API.get(`/api/setup/ping-ollama?host=${encodeURIComponent(host)}`);
2124
+ if (res.reachable) {
2125
+ result.innerHTML = `<span style="color:var(--color-success);"><i class="fa-solid fa-check"></i> Connected — ${res.models.length} model(s) found</span>`;
2126
+ // Add to .env via server
2127
+ await API.post('/api/setup/save-env', { key: 'OLLAMA_FALLBACK_URL', value: host });
2128
+ // Refresh status
2129
+ const status = await API.get('/api/setup/status');
2130
+ state.systemStatus = status;
2131
+ renderOllamaHosts();
2132
+ document.getElementById('addHostRow').style.display = 'none';
2133
+ // Reload models with new host
2134
+ loadModels();
2135
+ } else {
2136
+ result.innerHTML = `<span style="color:var(--color-danger);"><i class="fa-solid fa-xmark"></i> Unreachable — check IP, port, and that Ollama is running on that machine</span>`;
2137
+ }
2138
+ } catch (err) {
2139
+ result.innerHTML = `<span style="color:var(--color-danger);"><i class="fa-solid fa-xmark"></i> ${err.message}</span>`;
2140
+ }
2141
+ btn.disabled = false;
2142
+ btn.innerHTML = '<i class="fa-solid fa-satellite-dish"></i> Ping';
2143
+ };
2144
+
2145
+ window.removeOllamaHost = async function(host) {
2146
+ // Clear from .env
2147
+ await API.post('/api/setup/save-env', { key: 'OLLAMA_FALLBACK_URL', value: '' });
2148
+ const status = await API.get('/api/setup/status');
2149
+ state.systemStatus = status;
2150
+ renderOllamaHosts();
2151
+ };
2152
+
2153
+ // Render hosts when step 2 loads
2154
+ if (state.systemStatus) renderOllamaHosts();
2155
+
1995
2156
  async function loadModels() {
1996
2157
  const extDiv = document.getElementById('extractionModels');
1997
2158
  const anaDiv = document.getElementById('analysisModels');
@@ -2155,14 +2316,10 @@ input::placeholder {
2155
2316
  div.appendChild(card);
2156
2317
  }
2157
2318
 
2158
- // Show pull row if selected model is not installed
2319
+ // Show pull row or cloud hint if selected model is not installed
2159
2320
  const selected = models.find(m => m.id === state.selectedAnalysis);
2160
2321
  if (selected && !selected.installed) {
2161
- const pullRow = document.getElementById('analysisPullRow');
2162
- if (pullRow) {
2163
- pullRow.style.display = 'flex';
2164
- document.getElementById('analysisPullStatus').textContent = `${selected.name} not installed`;
2165
- }
2322
+ selectAnalysisModel(selected.id);
2166
2323
  }
2167
2324
  }
2168
2325
 
@@ -2171,16 +2328,29 @@ input::placeholder {
2171
2328
  document.querySelectorAll('#analysisModels .model-radio-card').forEach(c => {
2172
2329
  c.classList.toggle('selected', c.dataset.modelId === id);
2173
2330
  });
2174
- // Update pull row
2331
+ // Update pull row or cloud hint
2175
2332
  const models = state.modelData?.allAnalysis || [];
2176
2333
  const selected = models.find(m => m.id === id);
2177
2334
  const pullRow = document.getElementById('analysisPullRow');
2178
- if (pullRow) {
2179
- if (selected && !selected.installed) {
2180
- pullRow.style.display = 'flex';
2181
- document.getElementById('analysisPullStatus').textContent = `${selected.name} not installed`;
2335
+ const cloudHint = document.getElementById('analysisCloudHint');
2336
+ if (pullRow) pullRow.style.display = 'none';
2337
+ if (cloudHint) cloudHint.style.display = 'none';
2338
+
2339
+ if (selected && !selected.installed) {
2340
+ if (selected.type === 'cloud') {
2341
+ // Cloud model — show API key hint
2342
+ if (cloudHint) {
2343
+ const providerNames = { gemini: 'Google AI Studio', anthropic: 'Anthropic', openai: 'OpenAI', deepseek: 'DeepSeek' };
2344
+ document.getElementById('cloudHintProvider').textContent = `Requires ${providerNames[selected.provider] || selected.provider} API key`;
2345
+ document.getElementById('cloudHintEnvLine').textContent = `${selected.envKey}=your-key-here`;
2346
+ cloudHint.style.display = 'block';
2347
+ }
2182
2348
  } else {
2183
- pullRow.style.display = 'none';
2349
+ // Local model — show pull button
2350
+ if (pullRow) {
2351
+ pullRow.style.display = 'flex';
2352
+ document.getElementById('analysisPullStatus').textContent = `${selected.name} not installed`;
2353
+ }
2184
2354
  }
2185
2355
  }
2186
2356
  }
@@ -2340,11 +2510,23 @@ input::placeholder {
2340
2510
 
2341
2511
  const totalPages = totalDomains * pagesPerDomain;
2342
2512
  const mode = state.crawlMode || 'standard';
2343
- const crawlSec = mode === 'stealth' ? 3.5 : 1.5;
2344
- const extractSec = 3; // ~3s/page with qwen3.5:9b
2513
+ const crawlSec = mode === 'stealth' ? 4 : 2.5;
2514
+
2515
+ // Extraction speed depends on model size
2516
+ const extractModel = state.selectedExtraction || '';
2517
+ let extractSec = 4; // default
2518
+ if (extractModel.includes('4b')) extractSec = 2;
2519
+ else if (extractModel.includes('9b')) extractSec = 4;
2520
+ else if (extractModel.includes('14b')) extractSec = 6;
2521
+ else if (extractModel.includes('27b') || extractModel.includes('35b')) extractSec = 8;
2522
+
2523
+ // Analysis time depends on cloud vs local
2524
+ const analysisModel = state.selectedAnalysis || '';
2525
+ const isCloudAnalysis = ['gemini', 'claude', 'gpt', 'deepseek'].some(p => analysisModel.includes(p));
2526
+ const analyzeMin = isCloudAnalysis ? (totalPages > 500 ? 2 : 1) : (totalPages > 200 ? 5 : 2);
2527
+
2345
2528
  const crawlMin = Math.ceil((totalPages * crawlSec) / 60);
2346
2529
  const extractMin = Math.ceil((totalPages * extractSec) / 60);
2347
- const analyzeMin = totalPages > 200 ? 3 : 1; // analysis is one call
2348
2530
 
2349
2531
  el.style.display = 'block';
2350
2532
  body.innerHTML = `
@@ -2365,9 +2547,9 @@ input::placeholder {
2365
2547
  <div style="font-size:0.62rem; color:var(--text-muted);">
2366
2548
  <i class="fa-solid fa-spider" style="color:var(--color-success); width:14px;"></i> Crawl: ~${crawlMin} min (${crawlSec}s/page, ${mode})
2367
2549
  &nbsp;&nbsp;
2368
- <i class="fa-solid fa-brain" style="color:var(--accent-gold); width:14px;"></i> Extract: ~${extractMin} min (Solo)
2550
+ <i class="fa-solid fa-brain" style="color:var(--accent-gold); width:14px;"></i> Extract: ~${extractMin} min (~${extractSec}s/page${extractModel ? ', ' + extractModel.split(':')[0] : ''})
2369
2551
  &nbsp;&nbsp;
2370
- <i class="fa-solid fa-chart-column" style="color:var(--accent-gold); width:14px;"></i> Analyze: ~${analyzeMin} min (Solo)
2552
+ <i class="fa-solid fa-chart-column" style="color:var(--accent-gold); width:14px;"></i> Analyze: ~${analyzeMin} min (${isCloudAnalysis ? 'cloud' : 'local'})
2371
2553
  </div>
2372
2554
  ${totalPages > 500 ? '<div style="font-size:0.6rem; color:var(--color-warning); margin-top:4px;"><i class="fa-solid fa-triangle-exclamation"></i> Large crawl — consider lowering pages per domain or running overnight.</div>' : ''}
2373
2555
  `;
@@ -2440,8 +2622,62 @@ input::placeholder {
2440
2622
  // Init competitor rows
2441
2623
  function initStep3() {
2442
2624
  for (let i = 0; i < 3; i++) addCompetitorRow();
2625
+ loadExistingProjects();
2626
+ }
2627
+
2628
+ async function loadExistingProjects() {
2629
+ try {
2630
+ const raw = await API.get('/api/projects');
2631
+ // Handle both {projects: [...]} and [...] formats
2632
+ const projects = Array.isArray(raw) ? raw : (raw.projects || []);
2633
+ if (!projects.length) return;
2634
+ const bar = document.getElementById('existingProjectBar');
2635
+ const sel = document.getElementById('existingProjectSelect');
2636
+ bar.style.display = 'block';
2637
+ projects.forEach(p => {
2638
+ const opt = document.createElement('option');
2639
+ opt.value = p.project;
2640
+ opt.textContent = p.project + ' (' + (p.target || p.domain || '?') + ')';
2641
+ sel.appendChild(opt);
2642
+ });
2643
+ } catch { /* not served, skip */ }
2443
2644
  }
2444
2645
 
2646
+ window.loadExistingProject = async function(projectName) {
2647
+ if (!projectName) return;
2648
+ try {
2649
+ const full = await API.get('/api/setup/config/' + encodeURIComponent(projectName));
2650
+ if (!full) return;
2651
+
2652
+ // Populate form fields
2653
+ document.getElementById('cfgProjectName').value = full.project || '';
2654
+ updateSlug();
2655
+ document.getElementById('cfgTargetUrl').value = full.target?.url || ('https://' + (full.target?.domain || ''));
2656
+ document.getElementById('cfgSiteName').value = full.context?.siteName || '';
2657
+ document.getElementById('cfgIndustry').value = full.context?.industry || '';
2658
+ document.getElementById('cfgAudience').value = full.context?.audience || '';
2659
+ document.getElementById('cfgGoal').value = full.context?.goal || '';
2660
+
2661
+ // Clear and populate competitors
2662
+ const compList = document.getElementById('competitorList');
2663
+ compList.innerHTML = '';
2664
+ const comps = full.competitors || [];
2665
+ if (comps.length === 0) { addCompetitorRow(); addCompetitorRow(); addCompetitorRow(); }
2666
+ else {
2667
+ comps.forEach(c => {
2668
+ addCompetitorRow();
2669
+ const rows = compList.querySelectorAll('.dyn-list-item');
2670
+ const last = rows[rows.length - 1];
2671
+ if (last) last.querySelector('input').value = c.url || ('https://' + c.domain);
2672
+ });
2673
+ }
2674
+
2675
+ updateTimeEstimate();
2676
+ } catch (err) {
2677
+ console.warn('Failed to load project config:', err);
2678
+ }
2679
+ };
2680
+
2445
2681
  // ── Step 4: Pipeline Test ─────────────────────────────────────────────
2446
2682
  const TEST_MAP = {
2447
2683
  ollama: { card: 'testOllama', detail: 'testOllamaDetail', latency: 'testOllamaLatency' },
@@ -2500,11 +2736,16 @@ input::placeholder {
2500
2736
  const m = state.modelData.allAnalysis.find(x => x.id === state.selectedAnalysis);
2501
2737
  if (m && m.configured) {
2502
2738
  apiProvider = m.provider;
2503
- // The server will use the .env key
2504
2739
  apiKey = '__from_env__';
2505
2740
  }
2506
2741
  }
2507
2742
 
2743
+ // Final fallback: tell server to check .env directly
2744
+ if (!apiKey) {
2745
+ apiProvider = '__check_env__';
2746
+ apiKey = '__from_env__';
2747
+ }
2748
+
2508
2749
  const body = { ollamaHost, ollamaModel, apiProvider, apiKey, targetUrl };
2509
2750
 
2510
2751
  try {