Merge branch 'master' into master
This commit is contained in:
10
.env.example
10
.env.example
@@ -21,6 +21,8 @@ AISSTREAM_API_KEY=
|
|||||||
ACLED_EMAIL=
|
ACLED_EMAIL=
|
||||||
# OAuth2 password grant (API keys deprecated Sept 2025)
|
# OAuth2 password grant (API keys deprecated Sept 2025)
|
||||||
ACLED_PASSWORD=
|
ACLED_PASSWORD=
|
||||||
|
# Cloudflare Radar internet outages & traffic anomalies (free: dash.cloudflare.com/profile/api-tokens, Account Analytics Read)
|
||||||
|
CLOUDFLARE_API_TOKEN=
|
||||||
|
|
||||||
# === Server Configuration ===
|
# === Server Configuration ===
|
||||||
|
|
||||||
@@ -31,13 +33,15 @@ REFRESH_INTERVAL_MINUTES=15
|
|||||||
|
|
||||||
# === LLM Layer (optional) ===
|
# === LLM Layer (optional) ===
|
||||||
# Enables AI-enhanced trade ideas and breaking news Telegram alerts.
|
# Enables AI-enhanced trade ideas and breaking news Telegram alerts.
|
||||||
# Provider options: anthropic | openai | gemini | codex | openrouter | minimax | mistral | grok
|
# Provider options: anthropic | openai | gemini | codex | openrouter | minimax | mistral | ollama | grok
|
||||||
LLM_PROVIDER=
|
LLM_PROVIDER=
|
||||||
# Not needed for codex (uses ~/.codex/auth.json)
|
# Not needed for codex (uses ~/.codex/auth.json) or ollama (local)
|
||||||
LLM_API_KEY=
|
LLM_API_KEY=
|
||||||
# Optional override. Each provider has a sensible default:
|
# Optional override. Each provider has a sensible default:
|
||||||
# anthropic: claude-sonnet-4-6 | openai: gpt-5.4 | gemini: gemini-3.1-pro | codex: gpt-5.3-codex | openrouter: openrouter/auto | minimax: MiniMax-M2.5 | grok: grok-3
|
# anthropic: claude-sonnet-4-6 | openai: gpt-5.4 | gemini: gemini-3.1-pro | codex: gpt-5.3-codex | openrouter: openrouter/auto | minimax: MiniMax-M2.5 | ollama: llama3.1:8b | grok: grok-3
|
||||||
LLM_MODEL=
|
LLM_MODEL=
|
||||||
|
# Ollama base URL (only needed if not using default http://localhost:11434)
|
||||||
|
OLLAMA_BASE_URL=
|
||||||
|
|
||||||
# === Telegram Alerts (optional, requires LLM) ===
|
# === Telegram Alerts (optional, requires LLM) ===
|
||||||
# Create a bot via @BotFather, get chat ID via @userinfobot
|
# Create a bot via @BotFather, get chat ID via @userinfobot
|
||||||
|
|||||||
35
README.md
35
README.md
@@ -48,6 +48,13 @@ Try the live demo first at [https://www.crucix.live/](https://www.crucix.live/),
|
|||||||
|
|
||||||
No cloud. No telemetry. No subscriptions. Just `node server.mjs` and you're running.
|
No cloud. No telemetry. No subscriptions. Just `node server.mjs` and you're running.
|
||||||
|
|
||||||
|
## Token / Asset Warning
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> **Crucix has not launched any official token, coin, NFT, airdrop, presale, or other blockchain-based asset.**
|
||||||
|
> Any token or digital asset using the Crucix name, logo, or branding is not affiliated with or endorsed by Crucix.
|
||||||
|
> Do not buy it, promote it, connect a wallet to claim it, sign transactions, or send funds based on third-party posts, DMs, or websites.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Why This Exists
|
## Why This Exists
|
||||||
@@ -118,6 +125,22 @@ A self-contained Jarvis-style HUD with:
|
|||||||
- **Space watch** — CelesTrak satellite tracking: recent launches, ISS, military constellations, Starlink/OneWeb counts
|
- **Space watch** — CelesTrak satellite tracking: recent launches, ISS, military constellations, Starlink/OneWeb counts
|
||||||
- **Leverageable ideas** — AI-generated trade ideas (with LLM) or signal-correlated ideas (without)
|
- **Leverageable ideas** — AI-generated trade ideas (with LLM) or signal-correlated ideas (without)
|
||||||
|
|
||||||
|
### Performance Modes
|
||||||
|
The `VISUALS FULL` / `VISUALS LITE` button in the top bar only changes rendering behavior - it does **not** remove data sources or reduce sweep coverage.
|
||||||
|
|
||||||
|
When you switch to **VISUALS LITE**, the dashboard:
|
||||||
|
- Disables decorative background effects such as the radial/grid overlays and scanlines
|
||||||
|
- Removes expensive blur/backdrop-filter effects on panels and overlays
|
||||||
|
- Stops non-essential animations like the logo ring blink, conflict rings, and corridor flow effects
|
||||||
|
- Disables globe auto-rotation and turns off animated flight-arc dashes
|
||||||
|
- Converts the horizontal news ticker and OSINT stream into static, scrollable lists instead of continuously animated marquees
|
||||||
|
|
||||||
|
Mobile-specific behavior:
|
||||||
|
- On mobile, `VISUALS LITE` also forces the dashboard into **flat map mode** if you are currently on the globe
|
||||||
|
- Future mobile loads will continue to start flat while low-perf mode is enabled
|
||||||
|
|
||||||
|
The preference is saved in browser local storage, so the UI will remember your last setting.
|
||||||
|
|
||||||
### Auto-Refresh
|
### Auto-Refresh
|
||||||
The server runs a sweep cycle every 15 minutes (configurable). Each cycle:
|
The server runs a sweep cycle every 15 minutes (configurable). Each cycle:
|
||||||
1. Queries all 27 sources in parallel (~30s)
|
1. Queries all 27 sources in parallel (~30s)
|
||||||
@@ -520,6 +543,18 @@ For bugs and feature requests, please use GitHub Issues so discussion stays visi
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
<a href="https://www.star-history.com/?repos=calesthio%2FCrucix&type=date&legend=top-left">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=calesthio/Crucix&type=date&theme=dark&legend=top-left" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=calesthio/Crucix&type=date&legend=top-left" />
|
||||||
|
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=calesthio/Crucix&type=date&legend=top-left" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
AGPL-3.0
|
AGPL-3.0
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ import { briefing as space } from './sources/space.mjs';
|
|||||||
// === Tier 5: Live Market Data ===
|
// === Tier 5: Live Market Data ===
|
||||||
import { briefing as yfinance } from './sources/yfinance.mjs';
|
import { briefing as yfinance } from './sources/yfinance.mjs';
|
||||||
|
|
||||||
|
// === Tier 6: Cyber & Infrastructure ===
|
||||||
|
import { briefing as cisaKev } from './sources/cisa-kev.mjs';
|
||||||
|
import { briefing as cloudflareRadar } from './sources/cloudflare-radar.mjs';
|
||||||
|
|
||||||
const SOURCE_TIMEOUT_MS = 30_000; // 30s max per individual source
|
const SOURCE_TIMEOUT_MS = 30_000; // 30s max per individual source
|
||||||
|
|
||||||
export async function runSource(name, fn, ...args) {
|
export async function runSource(name, fn, ...args) {
|
||||||
@@ -63,7 +67,7 @@ export async function runSource(name, fn, ...args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fullBriefing() {
|
export async function fullBriefing() {
|
||||||
console.error('[Crucix] Starting intelligence sweep — 27 sources...');
|
console.error('[Crucix] Starting intelligence sweep — 29 sources...');
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
|
||||||
const allPromises = [
|
const allPromises = [
|
||||||
@@ -103,6 +107,10 @@ export async function fullBriefing() {
|
|||||||
|
|
||||||
// Tier 5: Live Market Data
|
// Tier 5: Live Market Data
|
||||||
runSource('YFinance', yfinance),
|
runSource('YFinance', yfinance),
|
||||||
|
|
||||||
|
// Tier 6: Cyber & Infrastructure
|
||||||
|
runSource('CISA-KEV', cisaKev),
|
||||||
|
runSource('Cloudflare-Radar', cloudflareRadar),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Each runSource has its own 30s timeout, so allSettled will resolve
|
// Each runSource has its own 30s timeout, so allSettled will resolve
|
||||||
|
|||||||
144
apis/sources/cisa-kev.mjs
Normal file
144
apis/sources/cisa-kev.mjs
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
// CISA KEV — Known Exploited Vulnerabilities Catalog
|
||||||
|
// No auth required. Tracks CVEs actively exploited in the wild.
|
||||||
|
// Federal agencies must patch these within due dates — useful signal
|
||||||
|
// for cybersecurity posture and active threat landscape.
|
||||||
|
|
||||||
|
import { safeFetch } from '../utils/fetch.mjs';
|
||||||
|
|
||||||
|
const KEV_URL = 'https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json';
|
||||||
|
|
||||||
|
function summarizeVulnerabilities(vulns) {
|
||||||
|
if (!vulns.length) return {};
|
||||||
|
|
||||||
|
// Recent additions (last 30 days)
|
||||||
|
const thirtyDaysAgo = new Date(Date.now() - 30 * 86400_000);
|
||||||
|
const recent = vulns.filter(v => {
|
||||||
|
const added = new Date(v.dateAdded);
|
||||||
|
return !isNaN(added) && added >= thirtyDaysAgo;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group by vendor
|
||||||
|
const byVendor = {};
|
||||||
|
for (const v of vulns) {
|
||||||
|
const vendor = v.vendorProject || 'Unknown';
|
||||||
|
byVendor[vendor] = (byVendor[vendor] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top vendors sorted by count
|
||||||
|
const topVendors = Object.entries(byVendor)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 15)
|
||||||
|
.map(([vendor, count]) => ({ vendor, count }));
|
||||||
|
|
||||||
|
// Ransomware-linked
|
||||||
|
const ransomwareLinked = vulns.filter(v => v.knownRansomwareCampaignUse === 'Known');
|
||||||
|
|
||||||
|
// Overdue (due date has passed)
|
||||||
|
const now = new Date();
|
||||||
|
const overdue = vulns.filter(v => {
|
||||||
|
const due = new Date(v.dueDate);
|
||||||
|
return !isNaN(due) && due < now;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group recent by product for signal detection
|
||||||
|
const recentByProduct = {};
|
||||||
|
for (const v of recent) {
|
||||||
|
const key = `${v.vendorProject} ${v.product}`;
|
||||||
|
if (!recentByProduct[key]) recentByProduct[key] = [];
|
||||||
|
recentByProduct[key].push(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hotProducts = Object.entries(recentByProduct)
|
||||||
|
.filter(([, vs]) => vs.length >= 2)
|
||||||
|
.sort((a, b) => b[1].length - a[1].length)
|
||||||
|
.slice(0, 10)
|
||||||
|
.map(([product, vs]) => ({
|
||||||
|
product,
|
||||||
|
count: vs.length,
|
||||||
|
cves: vs.map(v => v.cveID)
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalInCatalog: vulns.length,
|
||||||
|
recentAdditions: recent.length,
|
||||||
|
ransomwareLinked: ransomwareLinked.length,
|
||||||
|
overdueCount: overdue.length,
|
||||||
|
topVendors,
|
||||||
|
hotProducts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function briefing() {
|
||||||
|
const data = await safeFetch(KEV_URL, { timeout: 20000 });
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
return {
|
||||||
|
source: 'CISA-KEV',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
error: data.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const vulns = data.vulnerabilities || [];
|
||||||
|
const catalogVersion = data.catalogVersion || null;
|
||||||
|
const dateReleased = data.dateReleased || null;
|
||||||
|
|
||||||
|
const summary = summarizeVulnerabilities(vulns);
|
||||||
|
|
||||||
|
// Get the 20 most recently added
|
||||||
|
const sorted = [...vulns]
|
||||||
|
.sort((a, b) => new Date(b.dateAdded) - new Date(a.dateAdded));
|
||||||
|
|
||||||
|
const recentEntries = sorted.slice(0, 20).map(v => ({
|
||||||
|
cveID: v.cveID,
|
||||||
|
vendorProject: v.vendorProject,
|
||||||
|
product: v.product,
|
||||||
|
vulnerabilityName: v.vulnerabilityName,
|
||||||
|
dateAdded: v.dateAdded,
|
||||||
|
dueDate: v.dueDate,
|
||||||
|
shortDescription: (v.shortDescription || '').substring(0, 300),
|
||||||
|
knownRansomwareCampaignUse: v.knownRansomwareCampaignUse,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Signals — actionable intelligence
|
||||||
|
const signals = [];
|
||||||
|
|
||||||
|
if (summary.recentAdditions > 5) {
|
||||||
|
signals.push({
|
||||||
|
severity: 'high',
|
||||||
|
signal: `${summary.recentAdditions} new KEV entries in last 30 days — elevated exploit activity`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.hotProducts?.length > 0) {
|
||||||
|
const top = summary.hotProducts[0];
|
||||||
|
signals.push({
|
||||||
|
severity: 'medium',
|
||||||
|
signal: `${top.product} has ${top.count} actively exploited CVEs recently added`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ransomwareRecent = recentEntries.filter(v => v.knownRansomwareCampaignUse === 'Known');
|
||||||
|
if (ransomwareRecent.length > 0) {
|
||||||
|
signals.push({
|
||||||
|
severity: 'critical',
|
||||||
|
signal: `${ransomwareRecent.length} recently added CVEs linked to ransomware campaigns`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'CISA-KEV',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
catalogVersion,
|
||||||
|
dateReleased,
|
||||||
|
summary,
|
||||||
|
vulnerabilities: recentEntries,
|
||||||
|
signals,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run standalone
|
||||||
|
if (process.argv[1]?.endsWith('cisa-kev.mjs')) {
|
||||||
|
const data = await briefing();
|
||||||
|
console.log(JSON.stringify(data, null, 2));
|
||||||
|
}
|
||||||
224
apis/sources/cloudflare-radar.mjs
Normal file
224
apis/sources/cloudflare-radar.mjs
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
// Cloudflare Radar — Internet traffic anomalies and outages
|
||||||
|
// Requires a free Cloudflare API token (CLOUDFLARE_API_TOKEN).
|
||||||
|
// Get one at: https://dash.cloudflare.com/profile/api-tokens
|
||||||
|
// Create a custom token with Account → Account Analytics → Read permission.
|
||||||
|
//
|
||||||
|
// Monitors internet outages, traffic anomalies, and attack trends
|
||||||
|
// that correlate with conflict, censorship, and infrastructure disruption.
|
||||||
|
|
||||||
|
import { safeFetch } from '../utils/fetch.mjs';
|
||||||
|
import '../utils/env.mjs';
|
||||||
|
|
||||||
|
const RADAR_BASE = 'https://api.cloudflare.com/client/v4/radar';
|
||||||
|
|
||||||
|
// Countries of intelligence interest for internet monitoring
|
||||||
|
const WATCHLIST_COUNTRIES = [
|
||||||
|
'RU', 'UA', 'CN', 'IR', 'KP', 'SY', 'MM', 'ET', 'SD',
|
||||||
|
'YE', 'AF', 'IQ', 'LB', 'PS', 'TW', 'BY', 'VE', 'CU'
|
||||||
|
];
|
||||||
|
|
||||||
|
function getAuthHeaders() {
|
||||||
|
const token = process.env.CLOUDFLARE_API_TOKEN;
|
||||||
|
if (!token) return null;
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAnnotations() {
|
||||||
|
const headers = getAuthHeaders();
|
||||||
|
if (!headers) return { error: 'no_credentials' };
|
||||||
|
|
||||||
|
// Cloudflare Radar Annotations — internet outages and government shutdowns
|
||||||
|
const url = `${RADAR_BASE}/annotations/outages?dateRange=30d&format=json`;
|
||||||
|
const data = await safeFetch(url, { timeout: 15000, headers });
|
||||||
|
|
||||||
|
if (data.error) return { error: data.error };
|
||||||
|
|
||||||
|
const annotations = data.result?.annotations || [];
|
||||||
|
return annotations.map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
description: (a.description || '').substring(0, 500),
|
||||||
|
startDate: a.startDate,
|
||||||
|
endDate: a.endDate,
|
||||||
|
linkedUrl: a.linkedUrl || null,
|
||||||
|
scope: a.scope || null,
|
||||||
|
asns: a.asns || [],
|
||||||
|
locations: a.locations || [],
|
||||||
|
eventType: a.eventType || 'outage',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAttackSummary() {
|
||||||
|
const headers = getAuthHeaders();
|
||||||
|
if (!headers) return { error: 'no_credentials' };
|
||||||
|
|
||||||
|
// Layer 3 DDoS attack summaries by protocol and vector
|
||||||
|
// API requires a dimension: /summary/{dimension}
|
||||||
|
const [byProtocol, byVector] = await Promise.all([
|
||||||
|
safeFetch(`${RADAR_BASE}/attacks/layer3/summary/protocol?dateRange=7d&format=json`, { timeout: 15000, headers }),
|
||||||
|
safeFetch(`${RADAR_BASE}/attacks/layer3/summary/vector?dateRange=7d&format=json`, { timeout: 15000, headers }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = {};
|
||||||
|
|
||||||
|
if (!byProtocol.error && byProtocol.result) {
|
||||||
|
result.byProtocol = byProtocol.result.summary_0 || byProtocol.result;
|
||||||
|
}
|
||||||
|
if (!byVector.error && byVector.result) {
|
||||||
|
result.byVector = byVector.result.summary_0 || byVector.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.byProtocol && !result.byVector) {
|
||||||
|
return { error: byProtocol.error || byVector.error || 'No attack data returned' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTrafficAnomalies() {
|
||||||
|
const headers = getAuthHeaders();
|
||||||
|
if (!headers) return { error: 'no_credentials' };
|
||||||
|
|
||||||
|
// Traffic anomalies — significant deviations from normal patterns
|
||||||
|
const url = `${RADAR_BASE}/traffic_anomalies?dateRange=7d&format=json&limit=50`;
|
||||||
|
const data = await safeFetch(url, { timeout: 15000, headers });
|
||||||
|
|
||||||
|
if (data.error) return { error: data.error };
|
||||||
|
|
||||||
|
const anomalies = data.result?.trafficAnomalies || [];
|
||||||
|
return anomalies.map(a => ({
|
||||||
|
startDate: a.startDate,
|
||||||
|
endDate: a.endDate,
|
||||||
|
type: a.type || 'unknown',
|
||||||
|
status: a.status,
|
||||||
|
asnDetails: a.asnDetails || null,
|
||||||
|
locationDetails: a.locationDetails || null,
|
||||||
|
visibleInAllDataSources: a.visibleInAllDataSources || false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSignals(outages, anomalies) {
|
||||||
|
const signals = [];
|
||||||
|
|
||||||
|
if (!Array.isArray(outages)) return signals;
|
||||||
|
|
||||||
|
// Check for outages in watchlist countries
|
||||||
|
const watchlistOutages = outages.filter(o => {
|
||||||
|
const locations = o.locations || [];
|
||||||
|
return locations.some(l => WATCHLIST_COUNTRIES.includes(l));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (watchlistOutages.length > 0) {
|
||||||
|
const countries = [...new Set(watchlistOutages.flatMap(o => o.locations))].filter(l => WATCHLIST_COUNTRIES.includes(l));
|
||||||
|
signals.push({
|
||||||
|
severity: 'high',
|
||||||
|
signal: `Internet outages detected in ${countries.join(', ')} — possible government shutdown or infrastructure attack`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple outages in same country = sustained disruption
|
||||||
|
const locationCounts = {};
|
||||||
|
for (const o of outages) {
|
||||||
|
for (const loc of (o.locations || [])) {
|
||||||
|
locationCounts[loc] = (locationCounts[loc] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const repeated = Object.entries(locationCounts)
|
||||||
|
.filter(([, count]) => count >= 3)
|
||||||
|
.map(([loc]) => loc);
|
||||||
|
|
||||||
|
if (repeated.length > 0) {
|
||||||
|
signals.push({
|
||||||
|
severity: 'medium',
|
||||||
|
signal: `Sustained internet disruptions in ${repeated.join(', ')} — ${repeated.length} locations with 3+ outage events in 30 days`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traffic anomalies
|
||||||
|
if (Array.isArray(anomalies) && anomalies.length > 10) {
|
||||||
|
signals.push({
|
||||||
|
severity: 'medium',
|
||||||
|
signal: `${anomalies.length} traffic anomalies detected globally in last 7 days — elevated internet instability`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return signals;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function briefing() {
|
||||||
|
if (!process.env.CLOUDFLARE_API_TOKEN) {
|
||||||
|
return {
|
||||||
|
source: 'Cloudflare-Radar',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
status: 'no_credentials',
|
||||||
|
message: 'Set CLOUDFLARE_API_TOKEN in .env. Get a free token at https://dash.cloudflare.com/profile/api-tokens with Account → Account Analytics → Read permission.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [outages, attacks, anomalies] = await Promise.all([
|
||||||
|
fetchAnnotations(),
|
||||||
|
fetchAttackSummary(),
|
||||||
|
fetchTrafficAnomalies(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Handle complete failure
|
||||||
|
if (outages?.error && attacks?.error && anomalies?.error) {
|
||||||
|
return {
|
||||||
|
source: 'Cloudflare-Radar',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
error: outages.error || attacks.error || anomalies.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const outageList = Array.isArray(outages) ? outages : [];
|
||||||
|
const anomalyList = Array.isArray(anomalies) ? anomalies : [];
|
||||||
|
|
||||||
|
// Separate active vs resolved outages
|
||||||
|
const now = new Date();
|
||||||
|
const activeOutages = outageList.filter(o => !o.endDate || new Date(o.endDate) > now);
|
||||||
|
const recentResolved = outageList.filter(o => o.endDate && new Date(o.endDate) <= now).slice(0, 10);
|
||||||
|
|
||||||
|
// Group outages by location
|
||||||
|
const outagesByLocation = {};
|
||||||
|
for (const o of outageList) {
|
||||||
|
for (const loc of (o.locations || ['unknown'])) {
|
||||||
|
if (!outagesByLocation[loc]) outagesByLocation[loc] = [];
|
||||||
|
outagesByLocation[loc].push(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const topAffectedLocations = Object.entries(outagesByLocation)
|
||||||
|
.sort((a, b) => b[1].length - a[1].length)
|
||||||
|
.slice(0, 15)
|
||||||
|
.map(([location, events]) => ({
|
||||||
|
location,
|
||||||
|
eventCount: events.length,
|
||||||
|
activeCount: events.filter(e => !e.endDate || new Date(e.endDate) > now).length,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const signals = buildSignals(outageList, anomalyList);
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'Cloudflare-Radar',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
outages: {
|
||||||
|
total: outageList.length,
|
||||||
|
active: activeOutages.length,
|
||||||
|
activeEvents: activeOutages.slice(0, 20),
|
||||||
|
recentResolved: recentResolved,
|
||||||
|
topAffectedLocations,
|
||||||
|
},
|
||||||
|
anomalies: {
|
||||||
|
total: anomalyList.length,
|
||||||
|
events: anomalyList.slice(0, 20),
|
||||||
|
},
|
||||||
|
attacks: attacks?.error ? { error: attacks.error } : attacks,
|
||||||
|
signals,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run standalone
|
||||||
|
if (process.argv[1]?.endsWith('cloudflare-radar.mjs')) {
|
||||||
|
const data = await briefing();
|
||||||
|
console.log(JSON.stringify(data, null, 2));
|
||||||
|
}
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
// Crucix Configuration — all settings with env var overrides
|
// Crucix Configuration — all settings with env var overrides
|
||||||
|
|
||||||
import './apis/utils/env.mjs'; // Load .env first
|
import "./apis/utils/env.mjs"; // Load .env first
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
port: parseInt(process.env.PORT) || 3117,
|
port: parseInt(process.env.PORT) || 3117,
|
||||||
refreshIntervalMinutes: parseInt(process.env.REFRESH_INTERVAL_MINUTES) || 15,
|
refreshIntervalMinutes: parseInt(process.env.REFRESH_INTERVAL_MINUTES) || 15,
|
||||||
|
|
||||||
llm: {
|
llm: {
|
||||||
provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax | mistral | grok
|
provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax | mistral | ollama | grok
|
||||||
apiKey: process.env.LLM_API_KEY || null,
|
apiKey: process.env.LLM_API_KEY || null,
|
||||||
model: process.env.LLM_MODEL || null,
|
model: process.env.LLM_MODEL || null,
|
||||||
|
baseUrl: process.env.OLLAMA_BASE_URL || null,
|
||||||
},
|
},
|
||||||
|
|
||||||
telegram: {
|
telegram: {
|
||||||
@@ -22,7 +23,7 @@ export default {
|
|||||||
discord: {
|
discord: {
|
||||||
botToken: process.env.DISCORD_BOT_TOKEN || null,
|
botToken: process.env.DISCORD_BOT_TOKEN || null,
|
||||||
channelId: process.env.DISCORD_CHANNEL_ID || null,
|
channelId: process.env.DISCORD_CHANNEL_ID || null,
|
||||||
guildId: process.env.DISCORD_GUILD_ID || null, // Server ID (for instant slash command registration)
|
guildId: process.env.DISCORD_GUILD_ID || null, // Server ID (for instant slash command registration)
|
||||||
webhookUrl: process.env.DISCORD_WEBHOOK_URL || null, // Fallback: webhook-only alerts (no bot needed)
|
webhookUrl: process.env.DISCORD_WEBHOOK_URL || null, // Fallback: webhook-only alerts (no bot needed)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -167,6 +167,14 @@ async function fetchRSS(url, source) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RSS_SOURCE_FALLBACKS = {
|
||||||
|
'SBS Australia': { lat: -35.2809, lon: 149.13, region: 'Australia' },
|
||||||
|
'Indian Express': { lat: 28.6139, lon: 77.209, region: 'India' },
|
||||||
|
'The Hindu': { lat: 13.0827, lon: 80.2707, region: 'India' },
|
||||||
|
'MercoPress': { lat: -34.9011, lon: -56.1645, region: 'South America' }
|
||||||
|
};
|
||||||
|
const REGIONAL_NEWS_SOURCES = ['MercoPress', 'Indian Express', 'The Hindu', 'SBS Australia'];
|
||||||
|
|
||||||
export async function fetchAllNews() {
|
export async function fetchAllNews() {
|
||||||
const feeds = [
|
const feeds = [
|
||||||
// Global
|
// Global
|
||||||
@@ -189,6 +197,12 @@ export async function fetchAllNews() {
|
|||||||
['https://rss.nytimes.com/services/xml/rss/nyt/Africa.xml', 'NYT Africa'],
|
['https://rss.nytimes.com/services/xml/rss/nyt/Africa.xml', 'NYT Africa'],
|
||||||
// Asia-Pacific
|
// Asia-Pacific
|
||||||
['https://rss.nytimes.com/services/xml/rss/nyt/AsiaPacific.xml', 'NYT Asia'],
|
['https://rss.nytimes.com/services/xml/rss/nyt/AsiaPacific.xml', 'NYT Asia'],
|
||||||
|
['https://www.sbs.com.au/news/topic/australia/feed', 'SBS Australia'],
|
||||||
|
// India
|
||||||
|
['https://indianexpress.com/section/india/feed/', 'Indian Express'],
|
||||||
|
['https://www.thehindu.com/news/national/feeder/default.rss', 'The Hindu'],
|
||||||
|
// South America
|
||||||
|
['https://en.mercopress.com/rss/latin-america', 'MercoPress'],
|
||||||
];
|
];
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
@@ -206,7 +220,7 @@ export async function fetchAllNews() {
|
|||||||
const key = item.title.substring(0, 40).toLowerCase();
|
const key = item.title.substring(0, 40).toLowerCase();
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
const geo = geoTagText(item.title);
|
const geo = geoTagText(item.title) || RSS_SOURCE_FALLBACKS[item.source];
|
||||||
if (geo) {
|
if (geo) {
|
||||||
geoNews.push({
|
geoNews.push({
|
||||||
title: item.title.substring(0, 100),
|
title: item.title.substring(0, 100),
|
||||||
@@ -223,7 +237,23 @@ export async function fetchAllNews() {
|
|||||||
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||||
const filtered = geoNews.filter(n => !n.date || new Date(n.date) >= cutoff);
|
const filtered = geoNews.filter(n => !n.date || new Date(n.date) >= cutoff);
|
||||||
filtered.sort((a, b) => new Date(b.date || 0) - new Date(a.date || 0));
|
filtered.sort((a, b) => new Date(b.date || 0) - new Date(a.date || 0));
|
||||||
return filtered.slice(0, 50);
|
|
||||||
|
const selected = [];
|
||||||
|
const selectedKeys = new Set();
|
||||||
|
const keyFor = item => `${item.source}|${item.title}|${item.date}`;
|
||||||
|
const pushUnique = item => {
|
||||||
|
const key = keyFor(item);
|
||||||
|
if (selectedKeys.has(key)) return;
|
||||||
|
selected.push(item);
|
||||||
|
selectedKeys.add(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reserve a little space so newly-added regional feeds are not crowded out by larger globals.
|
||||||
|
for (const source of REGIONAL_NEWS_SOURCES) {
|
||||||
|
filtered.filter(item => item.source === source).slice(0, 2).forEach(pushUnique);
|
||||||
|
}
|
||||||
|
filtered.forEach(pushUnique);
|
||||||
|
return selected.slice(0, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Leverageable Ideas from Signals ===
|
// === Leverageable Ideas from Signals ===
|
||||||
@@ -620,7 +650,22 @@ function buildNewsFeed(rssNews, gdeltData, tgUrgent, tgTop) {
|
|||||||
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||||
const recent = feed.filter(item => !item.timestamp || new Date(item.timestamp) >= cutoff);
|
const recent = feed.filter(item => !item.timestamp || new Date(item.timestamp) >= cutoff);
|
||||||
recent.sort((a, b) => new Date(b.timestamp || 0) - new Date(a.timestamp || 0));
|
recent.sort((a, b) => new Date(b.timestamp || 0) - new Date(a.timestamp || 0));
|
||||||
return recent.slice(0, 50);
|
|
||||||
|
const selected = [];
|
||||||
|
const selectedKeys = new Set();
|
||||||
|
const keyFor = item => `${item.type}|${item.source}|${item.headline}|${item.timestamp}`;
|
||||||
|
const pushUnique = item => {
|
||||||
|
const key = keyFor(item);
|
||||||
|
if (selectedKeys.has(key)) return;
|
||||||
|
selected.push(item);
|
||||||
|
selectedKeys.add(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const source of REGIONAL_NEWS_SOURCES) {
|
||||||
|
recent.filter(item => item.source === source).slice(0, 2).forEach(pushUnique);
|
||||||
|
}
|
||||||
|
recent.forEach(pushUnique);
|
||||||
|
return selected.slice(0, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
// === CLI Mode: inject into HTML file ===
|
// === CLI Mode: inject into HTML file ===
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--s
|
|||||||
.lower{display:flex;flex-wrap:wrap;gap:10px;margin-top:10px;align-items:flex-start}
|
.lower{display:flex;flex-wrap:wrap;gap:10px;margin-top:10px;align-items:flex-start}
|
||||||
.lower .g-panel{min-width:0;box-sizing:border-box}
|
.lower .g-panel{min-width:0;box-sizing:border-box}
|
||||||
.lower .lp-ticker{flex:1.2 1 240px;max-width:380px}
|
.lower .lp-ticker{flex:1.2 1 240px;max-width:380px}
|
||||||
.lower .lp-delta{flex:1 1 200px;max-width:300px}
|
.right-delta .delta-list{max-height:200px}
|
||||||
.lower .lp-macro{flex:2.5 1 360px}
|
.lower .lp-macro{flex:2.5 1 360px}
|
||||||
.lower .lp-ideas{flex:1.5 1 300px}
|
.lower .lp-ideas{flex:1.5 1 300px}
|
||||||
.lower-wide{width:100%}
|
.lower-wide{width:100%}
|
||||||
@@ -233,6 +233,9 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--s
|
|||||||
.tk-src.dw{color:#ef9a9a;border-color:rgba(239,154,154,0.3)}
|
.tk-src.dw{color:#ef9a9a;border-color:rgba(239,154,154,0.3)}
|
||||||
.tk-src.eu{color:#ce93d8;border-color:rgba(206,147,216,0.3)}
|
.tk-src.eu{color:#ce93d8;border-color:rgba(206,147,216,0.3)}
|
||||||
.tk-src.af{color:#a5d6a7;border-color:rgba(165,214,167,0.3)}
|
.tk-src.af{color:#a5d6a7;border-color:rgba(165,214,167,0.3)}
|
||||||
|
.tk-src.sa{color:#ffab91;border-color:rgba(255,171,145,0.3)}
|
||||||
|
.tk-src.ind{color:#ffcc80;border-color:rgba(255,204,128,0.3)}
|
||||||
|
.tk-src.anz{color:#80cbc4;border-color:rgba(128,203,196,0.3)}
|
||||||
.tk-src.us{color:#90caf9;border-color:rgba(144,202,249,0.3)}
|
.tk-src.us{color:#90caf9;border-color:rgba(144,202,249,0.3)}
|
||||||
.tk-src.other{color:#b0bec5;border-color:rgba(176,190,197,0.2)}
|
.tk-src.other{color:#b0bec5;border-color:rgba(176,190,197,0.2)}
|
||||||
.tk-head{font-size:11px;line-height:1.35;color:#c8d8d2;margin-top:3px}
|
.tk-head{font-size:11px;line-height:1.35;color:#c8d8d2;margin-top:3px}
|
||||||
@@ -274,7 +277,7 @@ body.low-perf .ticker-wrap::-webkit-scrollbar-thumb{background:rgba(100,240,200,
|
|||||||
.top-left,.top-center,.top-right{width:100%}
|
.top-left,.top-center,.top-right{width:100%}
|
||||||
.top-center{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px}
|
.top-center{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px}
|
||||||
.map-region-bar{display:none}
|
.map-region-bar{display:none}
|
||||||
.top-right{gap:6px}
|
.top-right{gap:6px ; flex-wrap: wrap; justify-content: center;}
|
||||||
.region-btn,.meta-pill,.alert-badge,.guide-btn{font-size:10px}
|
.region-btn,.meta-pill,.alert-badge,.guide-btn{font-size:10px}
|
||||||
.grid{display:flex;flex-direction:column}
|
.grid{display:flex;flex-direction:column}
|
||||||
#centerCol{order:1}
|
#centerCol{order:1}
|
||||||
@@ -284,7 +287,7 @@ body.low-perf .ticker-wrap::-webkit-scrollbar-thumb{background:rgba(100,240,200,
|
|||||||
.map-hint{font-size:8px;right:8px}
|
.map-hint{font-size:8px;right:8px}
|
||||||
.map-legend{left:8px;right:8px;bottom:8px;gap:4px}
|
.map-legend{left:8px;right:8px;bottom:8px;gap:4px}
|
||||||
.leg-item{font-size:8px}
|
.leg-item{font-size:8px}
|
||||||
.lower .lp-ticker,.lower .lp-osint,.lower .lp-delta,.lower .lp-macro,.lower .lp-ideas{flex:1 1 100%;max-width:none}
|
.lower .lp-ticker,.lower .lp-osint,.lower .lp-macro,.lower .lp-ideas{flex:1 1 100%;max-width:none}
|
||||||
.metrics-row{grid-template-columns:repeat(2,1fr)}
|
.metrics-row{grid-template-columns:repeat(2,1fr)}
|
||||||
.src-grid{grid-template-columns:repeat(2,1fr)}
|
.src-grid{grid-template-columns:repeat(2,1fr)}
|
||||||
.glossary-panel{top:auto;right:0;left:0;bottom:0;width:100%;max-height:min(72vh,720px);border-left:none;border-right:none;border-bottom:none}
|
.glossary-panel{top:auto;right:0;left:0;bottom:0;width:100%;max-height:min(72vh,720px);border-left:none;border-right:none;border-bottom:none}
|
||||||
@@ -544,7 +547,7 @@ function togglePerfMode(){
|
|||||||
localStorage.setItem('crucix_low_perf', String(lowPerfMode));
|
localStorage.setItem('crucix_low_perf', String(lowPerfMode));
|
||||||
document.body.classList.toggle('low-perf', lowPerfMode);
|
document.body.classList.toggle('low-perf', lowPerfMode);
|
||||||
const perfStatus = document.getElementById('perfStatus');
|
const perfStatus = document.getElementById('perfStatus');
|
||||||
if(perfStatus) perfStatus.textContent = lowPerfMode ? 'LOW' : 'HIGH';
|
if(perfStatus) perfStatus.textContent = lowPerfMode ? 'LITE' : 'FULL';
|
||||||
if(globe){
|
if(globe){
|
||||||
globe.controls().autoRotate = !lowPerfMode;
|
globe.controls().autoRotate = !lowPerfMode;
|
||||||
globe.arcDashAnimateTime(lowPerfMode ? 0 : 2000);
|
globe.arcDashAnimateTime(lowPerfMode ? 0 : 2000);
|
||||||
@@ -588,7 +591,7 @@ function renderTopbar(){
|
|||||||
</div>
|
</div>
|
||||||
${mobile ? `<div class="top-center">${getRegionControlsMarkup()}</div>` : ''}
|
${mobile ? `<div class="top-center">${getRegionControlsMarkup()}</div>` : ''}
|
||||||
<div class="top-right">
|
<div class="top-right">
|
||||||
<button class="meta-pill perf-pill" onclick="togglePerfMode()" title="Reduce visual effects and start mobile in flat mode">${t('dashboard.perf','PERF')} <span class="v" id="perfStatus">${lowPerfMode?t('dashboard.perfLow','LOW'):t('dashboard.perfHigh','HIGH')}</span></button>
|
<button class="meta-pill perf-pill" onclick="togglePerfMode()" title="Reduce visual effects and start mobile in flat mode">${t('dashboard.visuals','VISUALS')} <span class="v" id="perfStatus">${lowPerfMode?t('dashboard.visualsLite','LITE'):t('dashboard.visualsFull','FULL')}</span></button>
|
||||||
<span class="meta-pill">${t('dashboard.sweep','SWEEP')} <span class="v">${(D.meta.totalDurationMs/1000).toFixed(1)}s</span></span>
|
<span class="meta-pill">${t('dashboard.sweep','SWEEP')} <span class="v">${(D.meta.totalDurationMs/1000).toFixed(1)}s</span></span>
|
||||||
<span class="meta-pill">${d} <span class="v">${timeStr}</span></span>
|
<span class="meta-pill">${d} <span class="v">${timeStr}</span></span>
|
||||||
<span class="meta-pill">${t('dashboard.sources','SOURCES')} <span class="v">${D.meta.sourcesOk}/${D.meta.sourcesQueried}</span></span>
|
<span class="meta-pill">${t('dashboard.sources','SOURCES')} <span class="v">${D.meta.sourcesOk}/${D.meta.sourcesQueried}</span></span>
|
||||||
@@ -1396,6 +1399,9 @@ function renderLower(){
|
|||||||
const sl = s.toLowerCase();
|
const sl = s.toLowerCase();
|
||||||
// Africa-focused sources first (before generic DW/NYT)
|
// Africa-focused sources first (before generic DW/NYT)
|
||||||
if (sl.includes('dw africa') || sl.includes('africa news') || sl.includes('nyt africa') || sl.includes('rfi')) return 'af';
|
if (sl.includes('dw africa') || sl.includes('africa news') || sl.includes('nyt africa') || sl.includes('rfi')) return 'af';
|
||||||
|
if (sl.includes('mercopress')) return 'sa';
|
||||||
|
if (sl.includes('indian express') || sl.includes('the hindu')) return 'ind';
|
||||||
|
if (sl.includes('sbs')) return 'anz';
|
||||||
if (sl.includes('bbc')) return 'bbc';
|
if (sl.includes('bbc')) return 'bbc';
|
||||||
if (sl.includes('jazeera') || sl.includes('alj')) return 'alj';
|
if (sl.includes('jazeera') || sl.includes('alj')) return 'alj';
|
||||||
if (sl.includes('gdelt')) return 'gdelt';
|
if (sl.includes('gdelt')) return 'gdelt';
|
||||||
@@ -1431,29 +1437,6 @@ function renderLower(){
|
|||||||
<div style="font-size:9px;margin-top:6px;opacity:0.6">Set LLM_PROVIDER + credentials in .env to enable AI-powered trade ideas</div>
|
<div style="font-size:9px;margin-top:6px;opacity:0.6">Set LLM_PROVIDER + credentials in .env to enable AI-powered trade ideas</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
// DELTA PANEL — what changed since last sweep
|
|
||||||
const delta = D.delta || {};
|
|
||||||
const ds = delta.summary || {};
|
|
||||||
const hasDelta = ds.totalChanges > 0;
|
|
||||||
const dirEmoji = {'risk-off':'▲','risk-on':'▼','mixed':'◆'}[ds.direction]||'◆';
|
|
||||||
const dirClass = {'risk-off':'up','risk-on':'down','mixed':''}[ds.direction]||'';
|
|
||||||
const escalated = (delta.signals?.escalated || []).slice(0,6);
|
|
||||||
const deescalated = (delta.signals?.deescalated || []).slice(0,4);
|
|
||||||
const newSigs = (delta.signals?.new || []).slice(0,4);
|
|
||||||
const deltaRows = [];
|
|
||||||
for(const s of newSigs){
|
|
||||||
deltaRows.push(`<div class="delta-row new"><span class="delta-badge new">NEW</span><span class="delta-label">${s.reason||s.label||s.key}</span></div>`);
|
|
||||||
}
|
|
||||||
for(const s of escalated){
|
|
||||||
const sev = s.severity==='critical'?'style="color:var(--warn);font-weight:600"':s.severity==='high'?'style="color:#ffab40"':'';
|
|
||||||
const val = s.pctChange!==undefined?`${s.pctChange>0?'+':''}${s.pctChange}%`:`${s.change>0?'+':''}${s.change}`;
|
|
||||||
deltaRows.push(`<div class="delta-row"><span class="delta-badge up">▲</span><span class="delta-label" ${sev}>${s.label}</span><span class="delta-val">${s.from}→${s.to} (${val})</span></div>`);
|
|
||||||
}
|
|
||||||
for(const s of deescalated){
|
|
||||||
const val = s.pctChange!==undefined?`${s.pctChange}%`:`${s.change}`;
|
|
||||||
deltaRows.push(`<div class="delta-row"><span class="delta-badge down">▼</span><span class="delta-label">${s.label||s.key}</span><span class="delta-val">${s.from}→${s.to} (${val})</span></div>`);
|
|
||||||
}
|
|
||||||
const deltaHtml = hasDelta ? deltaRows.join('') : '<div style="padding:12px;text-align:center;color:var(--dim);font-family:var(--mono);font-size:10px">No changes since last sweep</div>';
|
|
||||||
|
|
||||||
const tickerPanel = `<div class="g-panel lp-ticker" style="display:flex;flex-direction:column">
|
const tickerPanel = `<div class="g-panel lp-ticker" style="display:flex;flex-direction:column">
|
||||||
<div class="sec-head"><h3>${t('panels.newsTicker','Live News Ticker')}</h3><span class="badge">${feed.length} ${t('badges.items','ITEMS')}</span></div>
|
<div class="sec-head"><h3>${t('panels.newsTicker','Live News Ticker')}</h3><span class="badge">${feed.length} ${t('badges.items','ITEMS')}</span></div>
|
||||||
@@ -1489,17 +1472,7 @@ function renderLower(){
|
|||||||
${ideasHtml}
|
${ideasHtml}
|
||||||
<div class="disclosure">FOR INFORMATIONAL PURPOSES ONLY. This is not financial advice, a recommendation to buy or sell any security, or a solicitation of any kind. All signal-based observations are derived from publicly available OSINT data and should not be relied upon for investment decisions. Consult a licensed financial advisor before making any investment. Past performance does not guarantee future results.</div>
|
<div class="disclosure">FOR INFORMATIONAL PURPOSES ONLY. This is not financial advice, a recommendation to buy or sell any security, or a solicitation of any kind. All signal-based observations are derived from publicly available OSINT data and should not be relied upon for investment decisions. Consult a licensed financial advisor before making any investment. Past performance does not guarantee future results.</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
const deltaPanel = `<div class="g-panel lp-delta">
|
document.getElementById('lowerGrid').innerHTML=`${tickerPanel}${osintPanel}${macroPanel}${ideasPanel}`;
|
||||||
<div class="sec-head"><h3>${t('panels.sweepDelta','Sweep Delta')}</h3><span class="badge ${dirClass}">${dirEmoji} ${ds.direction?t('delta.'+ds.direction,ds.direction.toUpperCase()):t('delta.baseline','BASELINE')}</span></div>
|
|
||||||
${hasDelta?`<div style="display:flex;gap:12px;margin-bottom:6px;font-family:var(--mono);font-size:10px">
|
|
||||||
<span style="color:var(--dim)">${t('delta.changes','Changes')}: <span style="color:var(--accent)">${ds.totalChanges}</span></span>
|
|
||||||
<span style="color:var(--dim)">${t('delta.critical','Critical')}: <span style="color:${ds.criticalChanges>0?'var(--warn)':'var(--dim)'}">${ds.criticalChanges||0}</span></span>
|
|
||||||
${ds.signalBreakdown?`<span style="color:var(--dim)">${t('delta.new','New')}: <span style="color:#4dd0e1">${ds.signalBreakdown.new}</span> ↑${ds.signalBreakdown.escalated} ↓${ds.signalBreakdown.deescalated}</span>`:''}
|
|
||||||
</div>`:''}
|
|
||||||
<div class="delta-list">${deltaHtml}</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
document.getElementById('lowerGrid').innerHTML=`${tickerPanel}${osintPanel}${macroPanel}${ideasPanel}${deltaPanel}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// === RIGHT RAIL ===
|
// === RIGHT RAIL ===
|
||||||
@@ -1518,6 +1491,30 @@ function renderRight(){
|
|||||||
{l:'WHO Alerts',v:D.who.length,p:40}
|
{l:'WHO Alerts',v:D.who.length,p:40}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// DELTA PANEL — what changed since last sweep
|
||||||
|
const delta = D.delta || {};
|
||||||
|
const ds = delta.summary || {};
|
||||||
|
const hasDelta = ds.totalChanges > 0;
|
||||||
|
const dirEmoji = {'risk-off':'▲','risk-on':'▼','mixed':'◆'}[ds.direction]||'◆';
|
||||||
|
const dirClass = {'risk-off':'up','risk-on':'down','mixed':''}[ds.direction]||'';
|
||||||
|
const escalated = (delta.signals?.escalated || []).slice(0,6);
|
||||||
|
const deescalated = (delta.signals?.deescalated || []).slice(0,4);
|
||||||
|
const newSigs = (delta.signals?.new || []).slice(0,4);
|
||||||
|
const deltaRows = [];
|
||||||
|
for(const s of newSigs){
|
||||||
|
deltaRows.push(`<div class="delta-row new"><span class="delta-badge new">NEW</span><span class="delta-label">${s.reason||s.label||s.key}</span></div>`);
|
||||||
|
}
|
||||||
|
for(const s of escalated){
|
||||||
|
const sev = s.severity==='critical'?'style="color:var(--warn);font-weight:600"':s.severity==='high'?'style="color:#ffab40"':'';
|
||||||
|
const val = s.pctChange!==undefined?`${s.pctChange>0?'+':''}${s.pctChange}%`:`${s.change>0?'+':''}${s.change}`;
|
||||||
|
deltaRows.push(`<div class="delta-row"><span class="delta-badge up">▲</span><span class="delta-label" ${sev}>${s.label}</span><span class="delta-val">${s.from}→${s.to} (${val})</span></div>`);
|
||||||
|
}
|
||||||
|
for(const s of deescalated){
|
||||||
|
const val = s.pctChange!==undefined?`${s.pctChange}%`:`${s.change}`;
|
||||||
|
deltaRows.push(`<div class="delta-row"><span class="delta-badge down">▼</span><span class="delta-label">${s.label||s.key}</span><span class="delta-val">${s.from}→${s.to} (${val})</span></div>`);
|
||||||
|
}
|
||||||
|
const deltaHtml = hasDelta ? deltaRows.join('') : `<div style="padding:12px;text-align:center;color:var(--dim);font-family:var(--mono);font-size:10px">${t('delta.noChanges','No changes since last sweep')}</div>`;
|
||||||
|
|
||||||
document.getElementById('rightRail').innerHTML=`
|
document.getElementById('rightRail').innerHTML=`
|
||||||
<div class="g-panel right-signals">
|
<div class="g-panel right-signals">
|
||||||
<div class="sec-head"><h3>${t('panels.crossSourceSignals','Cross-Source Signals')}</h3><span class="badge">${t('badges.worldview','WORLDVIEW')}</span></div>
|
<div class="sec-head"><h3>${t('panels.crossSourceSignals','Cross-Source Signals')}</h3><span class="badge">${t('badges.worldview','WORLDVIEW')}</span></div>
|
||||||
@@ -1527,6 +1524,15 @@ function renderRight(){
|
|||||||
<div class="g-panel right-core">
|
<div class="g-panel right-core">
|
||||||
<div class="sec-head"><h3>${t('panels.signalCore','Signal Core')}</h3><span class="badge">${t('badges.hotMetrics','HOT METRICS')}</span></div>
|
<div class="sec-head"><h3>${t('panels.signalCore','Signal Core')}</h3><span class="badge">${t('badges.hotMetrics','HOT METRICS')}</span></div>
|
||||||
${signalMetrics.map(s=>`<div class="sm"><span class="sml">${s.l}</span><div class="smb"><span style="width:${s.p}%"></span></div><span class="smv">${s.v}</span></div>`).join('')}
|
${signalMetrics.map(s=>`<div class="sm"><span class="sml">${s.l}</span><div class="smb"><span style="width:${s.p}%"></span></div><span class="smv">${s.v}</span></div>`).join('')}
|
||||||
|
</div>
|
||||||
|
<div class="g-panel right-delta">
|
||||||
|
<div class="sec-head"><h3>${t('panels.sweepDelta','Sweep Delta')}</h3><span class="badge ${dirClass}">${dirEmoji} ${ds.direction?t('delta.'+ds.direction,ds.direction.toUpperCase()):t('delta.baseline','BASELINE')}</span></div>
|
||||||
|
${hasDelta?`<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:6px;font-family:var(--mono);font-size:10px">
|
||||||
|
<span style="color:var(--dim)">${t('delta.changes','Changes')}: <span style="color:var(--accent)">${ds.totalChanges}</span></span>
|
||||||
|
<span style="color:var(--dim)">${t('delta.critical','Critical')}: <span style="color:${ds.criticalChanges>0?'var(--warn)':'var(--dim)'}">${ds.criticalChanges||0}</span></span>
|
||||||
|
${ds.signalBreakdown?`<span style="color:var(--dim)">${t('delta.new','New')}: <span style="color:#4dd0e1">${ds.signalBreakdown.new}</span> ↑${ds.signalBreakdown.escalated} ↓${ds.signalBreakdown.deescalated}</span>`:''}
|
||||||
|
</div>`:''}
|
||||||
|
<div class="delta-list">${deltaHtml}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
// LLM Factory — creates the configured provider or returns null
|
// LLM Factory — creates the configured provider or returns null
|
||||||
|
|
||||||
import { AnthropicProvider } from './anthropic.mjs';
|
import { AnthropicProvider } from "./anthropic.mjs";
|
||||||
import { OpenAIProvider } from './openai.mjs';
|
import { OpenAIProvider } from "./openai.mjs";
|
||||||
import { OpenRouterProvider } from './openrouter.mjs';
|
import { OpenRouterProvider } from "./openrouter.mjs";
|
||||||
import { GeminiProvider } from './gemini.mjs';
|
import { GeminiProvider } from "./gemini.mjs";
|
||||||
import { CodexProvider } from './codex.mjs';
|
import { CodexProvider } from "./codex.mjs";
|
||||||
import { MiniMaxProvider } from './minimax.mjs';
|
import { MiniMaxProvider } from "./minimax.mjs";
|
||||||
import { MistralProvider } from './mistral.mjs';
|
import { MistralProvider } from "./mistral.mjs";
|
||||||
import { GrokProvider } from './grok.mjs';
|
import { OllamaProvider } from "./ollama.mjs";
|
||||||
|
import { GrokProvider } from "./grok.mjs";
|
||||||
export { LLMProvider } from './provider.mjs';
|
|
||||||
export { AnthropicProvider } from './anthropic.mjs';
|
|
||||||
export { OpenAIProvider } from './openai.mjs';
|
|
||||||
export { OpenRouterProvider } from './openrouter.mjs';
|
|
||||||
export { GeminiProvider } from './gemini.mjs';
|
|
||||||
export { CodexProvider } from './codex.mjs';
|
|
||||||
export { MiniMaxProvider } from './minimax.mjs';
|
|
||||||
export { MistralProvider } from './mistral.mjs';
|
|
||||||
export { GrokProvider } from './grok.mjs';
|
|
||||||
|
|
||||||
|
export { LLMProvider } from "./provider.mjs";
|
||||||
|
export { AnthropicProvider } from "./anthropic.mjs";
|
||||||
|
export { OpenAIProvider } from "./openai.mjs";
|
||||||
|
export { OpenRouterProvider } from "./openrouter.mjs";
|
||||||
|
export { GeminiProvider } from "./gemini.mjs";
|
||||||
|
export { CodexProvider } from "./codex.mjs";
|
||||||
|
export { MiniMaxProvider } from "./minimax.mjs";
|
||||||
|
export { MistralProvider } from "./mistral.mjs";
|
||||||
|
export { OllamaProvider } from "./ollama.mjs";
|
||||||
|
export { GrokProvider } from "./grok.mjs";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an LLM provider based on config.
|
* Create an LLM provider based on config.
|
||||||
@@ -31,24 +32,28 @@ export function createLLMProvider(llmConfig) {
|
|||||||
const { provider, apiKey, model } = llmConfig;
|
const { provider, apiKey, model } = llmConfig;
|
||||||
|
|
||||||
switch (provider.toLowerCase()) {
|
switch (provider.toLowerCase()) {
|
||||||
case 'anthropic':
|
case "anthropic":
|
||||||
return new AnthropicProvider({ apiKey, model });
|
return new AnthropicProvider({ apiKey, model });
|
||||||
case 'openai':
|
case "openai":
|
||||||
return new OpenAIProvider({ apiKey, model });
|
return new OpenAIProvider({ apiKey, model });
|
||||||
case 'openrouter':
|
case "openrouter":
|
||||||
return new OpenRouterProvider({ apiKey, model });
|
return new OpenRouterProvider({ apiKey, model });
|
||||||
case 'gemini':
|
case "gemini":
|
||||||
return new GeminiProvider({ apiKey, model });
|
return new GeminiProvider({ apiKey, model });
|
||||||
case 'codex':
|
case "codex":
|
||||||
return new CodexProvider({ model });
|
return new CodexProvider({ model });
|
||||||
case 'minimax':
|
case "minimax":
|
||||||
return new MiniMaxProvider({ apiKey, model });
|
return new MiniMaxProvider({ apiKey, model });
|
||||||
case 'mistral':
|
case "mistral":
|
||||||
return new MistralProvider({ apiKey, model });
|
return new MistralProvider({ apiKey, model });
|
||||||
|
case "ollama":
|
||||||
|
return new OllamaProvider({ model, baseUrl: llmConfig.baseUrl });
|
||||||
case 'grok':
|
case 'grok':
|
||||||
return new GrokProvider({ apiKey, model });
|
return new GrokProvider({ apiKey, model });
|
||||||
default:
|
default:
|
||||||
console.warn(`[LLM] Unknown provider "${provider}". LLM features disabled.`);
|
console.warn(
|
||||||
|
`[LLM] Unknown provider "${provider}". LLM features disabled.`,
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
49
lib/llm/ollama.mjs
Normal file
49
lib/llm/ollama.mjs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// Ollama Provider — raw fetch, no SDK
|
||||||
|
// Uses Ollama's OpenAI-compatible Chat Completions API
|
||||||
|
// No API key required — fully local inference
|
||||||
|
|
||||||
|
import { LLMProvider } from './provider.mjs';
|
||||||
|
|
||||||
|
export class OllamaProvider extends LLMProvider {
|
||||||
|
constructor(config) {
|
||||||
|
super(config);
|
||||||
|
this.name = 'ollama';
|
||||||
|
this.baseUrl = (config.baseUrl || 'http://localhost:11434').replace(/\/+$/, '');
|
||||||
|
this.model = config.model || 'llama3.1:8b';
|
||||||
|
}
|
||||||
|
|
||||||
|
get isConfigured() { return !!this.model; }
|
||||||
|
|
||||||
|
async complete(systemPrompt, userMessage, opts = {}) {
|
||||||
|
const res = await fetch(`${this.baseUrl}/v1/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: this.model,
|
||||||
|
max_tokens: opts.maxTokens || 4096,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: userMessage },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(opts.timeout || 120000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text().catch(() => '');
|
||||||
|
throw new Error(`Ollama API ${res.status}: ${err.substring(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const text = data.choices?.[0]?.message?.content || '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
usage: {
|
||||||
|
inputTokens: data.usage?.prompt_tokens || 0,
|
||||||
|
outputTokens: data.usage?.completion_tokens || 0,
|
||||||
|
},
|
||||||
|
model: data.model || this.model,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
45
test/llm-ollama-integration.test.mjs
Normal file
45
test/llm-ollama-integration.test.mjs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// Ollama provider — integration test (calls real Ollama instance)
|
||||||
|
// Requires a running Ollama server with a model pulled
|
||||||
|
// Run: OLLAMA_MODEL=llama3.1:8b node --test test/llm-ollama-integration.test.mjs
|
||||||
|
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { OllamaProvider } from '../lib/llm/ollama.mjs';
|
||||||
|
|
||||||
|
const BASE_URL = process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
|
||||||
|
const MODEL = process.env.OLLAMA_MODEL || 'llama3.1:8b';
|
||||||
|
|
||||||
|
// Check if Ollama is reachable and the requested model is installed
|
||||||
|
let ollamaAvailable = false;
|
||||||
|
let skipReason = 'Ollama not reachable';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE_URL}/api/tags`, { signal: AbortSignal.timeout(3000) });
|
||||||
|
if (res.ok) {
|
||||||
|
const { models = [] } = await res.json();
|
||||||
|
const installed = models.some((m) => m.name === MODEL || m.name.startsWith(`${MODEL}:`));
|
||||||
|
if (installed) {
|
||||||
|
ollamaAvailable = true;
|
||||||
|
} else {
|
||||||
|
skipReason = `Model "${MODEL}" not installed (available: ${models.map((m) => m.name).join(', ') || 'none'})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* not available */ }
|
||||||
|
|
||||||
|
describe('Ollama integration', { skip: !ollamaAvailable && skipReason }, () => {
|
||||||
|
it('should complete a prompt via local Ollama', async () => {
|
||||||
|
const provider = new OllamaProvider({ model: MODEL, baseUrl: BASE_URL });
|
||||||
|
assert.equal(provider.isConfigured, true);
|
||||||
|
|
||||||
|
const result = await provider.complete(
|
||||||
|
'You are a helpful assistant. Respond in exactly one sentence.',
|
||||||
|
'What is 2+2?',
|
||||||
|
{ maxTokens: 128, timeout: 60000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(result.text.length > 0, 'Response text should not be empty');
|
||||||
|
assert.ok(result.model, 'Should report model name');
|
||||||
|
console.log(` Response: ${result.text}`);
|
||||||
|
console.log(` Tokens: ${result.usage.inputTokens} in / ${result.usage.outputTokens} out`);
|
||||||
|
console.log(` Model: ${result.model}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
170
test/llm-ollama.test.mjs
Normal file
170
test/llm-ollama.test.mjs
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
// Ollama provider — unit tests
|
||||||
|
// Uses Node.js built-in test runner (node:test) — no extra dependencies
|
||||||
|
|
||||||
|
import { describe, it, mock, beforeEach } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { OllamaProvider } from '../lib/llm/ollama.mjs';
|
||||||
|
import { createLLMProvider } from '../lib/llm/index.mjs';
|
||||||
|
|
||||||
|
// ─── Unit Tests ───
|
||||||
|
|
||||||
|
describe('OllamaProvider', () => {
|
||||||
|
it('should set defaults correctly', () => {
|
||||||
|
const provider = new OllamaProvider({});
|
||||||
|
assert.equal(provider.name, 'ollama');
|
||||||
|
assert.equal(provider.model, 'llama3.1:8b');
|
||||||
|
assert.equal(provider.baseUrl, 'http://localhost:11434');
|
||||||
|
assert.equal(provider.isConfigured, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept custom model and base URL', () => {
|
||||||
|
const provider = new OllamaProvider({ model: 'qwen2.5:14b', baseUrl: 'http://192.168.1.10:11434' });
|
||||||
|
assert.equal(provider.model, 'qwen2.5:14b');
|
||||||
|
assert.equal(provider.baseUrl, 'http://192.168.1.10:11434');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should strip trailing slashes from base URL', () => {
|
||||||
|
const provider = new OllamaProvider({ baseUrl: 'http://localhost:11434/' });
|
||||||
|
assert.equal(provider.baseUrl, 'http://localhost:11434');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on API error', async () => {
|
||||||
|
const provider = new OllamaProvider({});
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = mock.fn(() =>
|
||||||
|
Promise.resolve({ ok: false, status: 404, text: () => Promise.resolve('model not found') })
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
() => provider.complete('system', 'user'),
|
||||||
|
(err) => {
|
||||||
|
assert.match(err.message, /Ollama API 404/);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse successful response', async () => {
|
||||||
|
const provider = new OllamaProvider({});
|
||||||
|
const mockResponse = {
|
||||||
|
choices: [{ message: { content: 'Hello from Ollama' } }],
|
||||||
|
usage: { prompt_tokens: 12, completion_tokens: 8 },
|
||||||
|
model: 'llama3.1:8b',
|
||||||
|
};
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = mock.fn(() =>
|
||||||
|
Promise.resolve({ ok: true, json: () => Promise.resolve(mockResponse) })
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const result = await provider.complete('You are helpful.', 'Say hello');
|
||||||
|
assert.equal(result.text, 'Hello from Ollama');
|
||||||
|
assert.equal(result.usage.inputTokens, 12);
|
||||||
|
assert.equal(result.usage.outputTokens, 8);
|
||||||
|
assert.equal(result.model, 'llama3.1:8b');
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send correct request format', async () => {
|
||||||
|
const provider = new OllamaProvider({ model: 'qwen2.5:14b', baseUrl: 'http://myhost:11434' });
|
||||||
|
let capturedUrl, capturedOpts;
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = mock.fn((url, opts) => {
|
||||||
|
capturedUrl = url;
|
||||||
|
capturedOpts = opts;
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
choices: [{ message: { content: 'ok' } }],
|
||||||
|
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
||||||
|
model: 'qwen2.5:14b',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await provider.complete('system prompt', 'user message', { maxTokens: 2048 });
|
||||||
|
assert.equal(capturedUrl, 'http://myhost:11434/v1/chat/completions');
|
||||||
|
assert.equal(capturedOpts.method, 'POST');
|
||||||
|
const headers = capturedOpts.headers;
|
||||||
|
assert.equal(headers['Content-Type'], 'application/json');
|
||||||
|
assert.equal(headers['Authorization'], undefined);
|
||||||
|
const body = JSON.parse(capturedOpts.body);
|
||||||
|
assert.equal(body.model, 'qwen2.5:14b');
|
||||||
|
assert.equal(body.max_tokens, 2048);
|
||||||
|
assert.equal(body.messages[0].role, 'system');
|
||||||
|
assert.equal(body.messages[0].content, 'system prompt');
|
||||||
|
assert.equal(body.messages[1].role, 'user');
|
||||||
|
assert.equal(body.messages[1].content, 'user message');
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty response gracefully', async () => {
|
||||||
|
const provider = new OllamaProvider({});
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = mock.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ choices: [], usage: {} }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const result = await provider.complete('sys', 'user');
|
||||||
|
assert.equal(result.text, '');
|
||||||
|
assert.equal(result.usage.inputTokens, 0);
|
||||||
|
assert.equal(result.usage.outputTokens, 0);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use longer default timeout than cloud providers', async () => {
|
||||||
|
const provider = new OllamaProvider({});
|
||||||
|
let capturedOpts;
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = mock.fn((url, opts) => {
|
||||||
|
capturedOpts = opts;
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
choices: [{ message: { content: 'ok' } }],
|
||||||
|
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await provider.complete('sys', 'user');
|
||||||
|
assert.ok(capturedOpts.signal, 'Should have an abort signal');
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Factory Tests ───
|
||||||
|
|
||||||
|
describe('createLLMProvider — ollama', () => {
|
||||||
|
it('should create OllamaProvider for provider=ollama', () => {
|
||||||
|
const provider = createLLMProvider({ provider: 'ollama', apiKey: null, model: null });
|
||||||
|
assert.ok(provider instanceof OllamaProvider);
|
||||||
|
assert.equal(provider.name, 'ollama');
|
||||||
|
assert.equal(provider.isConfigured, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be case-insensitive', () => {
|
||||||
|
const provider = createLLMProvider({ provider: 'Ollama', apiKey: null, model: null });
|
||||||
|
assert.ok(provider instanceof OllamaProvider);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass baseUrl from config', () => {
|
||||||
|
const provider = createLLMProvider({ provider: 'ollama', apiKey: null, model: 'mistral:7b', baseUrl: 'http://gpu-box:11434' });
|
||||||
|
assert.ok(provider instanceof OllamaProvider);
|
||||||
|
assert.equal(provider.baseUrl, 'http://gpu-box:11434');
|
||||||
|
assert.equal(provider.model, 'mistral:7b');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user