seo-intel 1.0.1 → 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.
- package/Setup SEO Intel.command +11 -0
- package/cli.js +11 -4
- package/crawler/index.js +5 -0
- package/db/db.js +13 -0
- package/package.json +2 -1
- package/reports/generate-html.js +86 -14
- package/server.js +25 -0
- package/setup/checks.js +24 -17
- package/setup/models.js +62 -5
- package/setup/validator.js +60 -16
- package/setup/web-routes.js +78 -2
- package/setup/wizard.html +287 -46
|
@@ -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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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",
|
|
@@ -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
|
],
|
package/reports/generate-html.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5557
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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-
|
|
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:
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
};
|
package/setup/validator.js
CHANGED
|
@@ -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(),
|
|
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
|
|
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 = '
|
|
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:
|
|
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
|
|
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: `
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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 ? `${
|
|
350
|
+
detail: result.valid ? `${apiProvider} key valid (${result.latencyMs}ms)` : `Invalid: ${result.error}`,
|
|
328
351
|
latencyMs: result.latencyMs,
|
|
329
352
|
};
|
|
330
353
|
} else {
|
|
331
|
-
|
|
332
|
-
yield { step: 'api-key', status: '
|
|
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,
|
package/setup/web-routes.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
<
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
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
|
-
<
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
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
|
-
|
|
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' ?
|
|
2344
|
-
|
|
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
|
|
|
2368
|
-
<i class="fa-solid fa-brain" style="color:var(--accent-gold); width:14px;"></i> Extract: ~${extractMin} min (
|
|
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
|
|
|
2370
|
-
<i class="fa-solid fa-chart-column" style="color:var(--accent-gold); width:14px;"></i> Analyze: ~${analyzeMin} min (
|
|
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 {
|