Merge branch 'master' into feat/docker-ghcr-publish
This commit is contained in:
@@ -31,12 +31,12 @@ REFRESH_INTERVAL_MINUTES=15
|
||||
|
||||
# === LLM Layer (optional) ===
|
||||
# Enables AI-enhanced trade ideas and breaking news Telegram alerts.
|
||||
# Provider options: anthropic | openai | gemini | codex
|
||||
# Provider options: anthropic | openai | gemini | codex | openrouter | minimax
|
||||
LLM_PROVIDER=
|
||||
# Not needed for codex (uses ~/.codex/auth.json)
|
||||
LLM_API_KEY=
|
||||
# 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
|
||||
# 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
|
||||
LLM_MODEL=
|
||||
|
||||
# === Telegram Alerts (optional, requires LLM) ===
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -43,3 +43,6 @@ npm-debug.log*
|
||||
|
||||
# Local maintainer notes
|
||||
MAINTAINER_DECISIONS.local.md
|
||||
|
||||
# Local deploy config
|
||||
dashboard/public/vercel.json
|
||||
|
||||
28
README.md
28
README.md
@@ -4,6 +4,11 @@
|
||||
|
||||
**Your own intelligence terminal. 27 sources. One command. Zero cloud.**
|
||||
|
||||
## [Visit The Live Site: crucix.live](https://www.crucix.live/)
|
||||
|
||||
[](https://www.crucix.live/)
|
||||
[](https://www.crucix.live/)
|
||||
|
||||
[](#quick-start)
|
||||
[](LICENSE)
|
||||
[-orange)](#architecture)
|
||||
@@ -27,10 +32,15 @@
|
||||
|
||||
</div>
|
||||
|
||||
> **Live website:** [https://www.crucix.live/](https://www.crucix.live/)
|
||||
> Explore the public demo first, then clone the repo to run Crucix locally.
|
||||
|
||||
Crucix pulls satellite fire detection, flight tracking, radiation monitoring, satellite constellation tracking, economic indicators, live market prices, conflict data, sanctions lists, and social sentiment from 27 open-source intelligence feeds — in parallel, every 15 minutes — and renders everything on a single self-contained Jarvis-style dashboard.
|
||||
|
||||
Hook it up to an LLM and it becomes a **two-way intelligence assistant** — pushing multi-tier alerts to Telegram and Discord when something meaningful changes, responding to commands like `/brief` and `/sweep` from your phone, and generating actionable trade ideas grounded in real cross-domain data. Your own analyst that watches the world while you sleep.
|
||||
|
||||
Try the live demo first at [https://www.crucix.live/](https://www.crucix.live/), then clone the repo when you want the full local stack.
|
||||
|
||||
No cloud. No telemetry. No subscriptions. Just `node server.mjs` and you're running.
|
||||
|
||||
---
|
||||
@@ -50,7 +60,7 @@ It was built for anyone who wants to understand what's actually happening in the
|
||||
```bash
|
||||
# 1. Clone the repo
|
||||
git clone https://github.com/calesthio/Crucix.git
|
||||
cd crucix
|
||||
cd Crucix
|
||||
|
||||
# 2. Install dependencies (just Express)
|
||||
npm install
|
||||
@@ -76,7 +86,7 @@ The dashboard opens automatically at `http://localhost:3117` and immediately beg
|
||||
|
||||
```bash
|
||||
git clone https://github.com/calesthio/Crucix.git
|
||||
cd crucix
|
||||
cd Crucix
|
||||
cp .env.example .env # add your API keys
|
||||
docker compose up -d
|
||||
```
|
||||
@@ -148,10 +158,10 @@ Alerts are delivered as rich embeds with color-coded sidebars: red for FLASH, ye
|
||||
**Optional dependency:** The full bot requires `discord.js`. Install it with `npm install discord.js`. If it's not installed, Crucix automatically falls back to webhook-only mode.
|
||||
|
||||
### Optional LLM Layer
|
||||
Connect any of 4 LLM providers for enhanced analysis:
|
||||
Connect any of 6 LLM providers for enhanced analysis:
|
||||
- **AI trade ideas** — quantitative analyst producing 5-8 actionable ideas citing specific data
|
||||
- **Smarter alert evaluation** — LLM classifies signals into FLASH/PRIORITY/ROUTINE tiers with cross-domain correlation and confidence scoring
|
||||
- Providers: Anthropic Claude, OpenAI, Google Gemini, OpenAI Codex (ChatGPT subscription)
|
||||
- Providers: Anthropic Claude, OpenAI, Google Gemini, OpenRouter (Unified API), OpenAI Codex (ChatGPT subscription), MiniMax
|
||||
- Graceful fallback — when LLM is unavailable, a rule-based engine takes over alert evaluation. LLM failures never crash the sweep cycle.
|
||||
|
||||
---
|
||||
@@ -184,14 +194,16 @@ These three unlock the most valuable economic and satellite data. Each takes abo
|
||||
|
||||
### LLM Provider (optional, for AI-enhanced ideas)
|
||||
|
||||
Set `LLM_PROVIDER` to one of: `anthropic`, `openai`, `gemini`, `codex`
|
||||
Set `LLM_PROVIDER` to one of: `anthropic`, `openai`, `gemini`, `codex`, `openrouter`, `minimax`
|
||||
|
||||
| Provider | Key Required | Default Model |
|
||||
|----------|-------------|---------------|
|
||||
| `anthropic` | `LLM_API_KEY` | claude-sonnet-4-6 |
|
||||
| `openai` | `LLM_API_KEY` | gpt-5.4 |
|
||||
| `gemini` | `LLM_API_KEY` | gemini-3.1-pro |
|
||||
| `openrouter` | `LLM_API_KEY` | openrouter/auto |
|
||||
| `codex` | None (uses `~/.codex/auth.json`) | gpt-5.3-codex |
|
||||
| `minimax` | `LLM_API_KEY` | MiniMax-M2.5 |
|
||||
|
||||
For Codex, run `npx @openai/codex login` to authenticate via your ChatGPT subscription.
|
||||
|
||||
@@ -261,12 +273,14 @@ crucix/
|
||||
│ └── jarvis.html # Self-contained Jarvis HUD
|
||||
│
|
||||
├── lib/
|
||||
│ ├── llm/ # LLM abstraction (4 providers, raw fetch, no SDKs)
|
||||
│ ├── llm/ # LLM abstraction (5 providers, raw fetch, no SDKs)
|
||||
│ │ ├── provider.mjs # Base class
|
||||
│ │ ├── anthropic.mjs # Claude
|
||||
│ │ ├── openai.mjs # GPT
|
||||
│ │ ├── gemini.mjs # Gemini
|
||||
│ │ ├── openrouter.mjs # OpenRouter (Unified API)
|
||||
│ │ ├── codex.mjs # Codex (ChatGPT subscription)
|
||||
│ │ ├── minimax.mjs # MiniMax (M2.5, 204K context)
|
||||
│ │ ├── ideas.mjs # LLM-powered trade idea generation
|
||||
│ │ └── index.mjs # Factory: createLLMProvider()
|
||||
│ ├── delta/ # Change tracking between sweeps
|
||||
@@ -368,7 +382,7 @@ All settings are in `.env` with sensible defaults:
|
||||
|----------|---------|-------------|
|
||||
| `PORT` | `3117` | Dashboard server port |
|
||||
| `REFRESH_INTERVAL_MINUTES` | `15` | Auto-refresh interval |
|
||||
| `LLM_PROVIDER` | disabled | `anthropic`, `openai`, `gemini`, or `codex` |
|
||||
| `LLM_PROVIDER` | disabled | `anthropic`, `openai`, `gemini`, `codex`, `openrouter`, or `minimax` |
|
||||
| `LLM_API_KEY` | — | API key (not needed for codex) |
|
||||
| `LLM_MODEL` | per-provider default | Override model selection |
|
||||
| `TELEGRAM_BOT_TOKEN` | disabled | For Telegram alerts + bot commands |
|
||||
|
||||
@@ -14,16 +14,16 @@ const RADNET_AUX = `${BASE}/RADNET_AUX`;
|
||||
|
||||
// Key US cities with RadNet monitoring stations
|
||||
const MONITORING_STATIONS = {
|
||||
washingtonDC: { label: 'Washington, DC', state: 'DC' },
|
||||
newYork: { label: 'New York, NY', state: 'NY' },
|
||||
losAngeles: { label: 'Los Angeles, CA', state: 'CA' },
|
||||
chicago: { label: 'Chicago, IL', state: 'IL' },
|
||||
seattle: { label: 'Seattle, WA', state: 'WA' },
|
||||
denver: { label: 'Denver, CO', state: 'CO' },
|
||||
honolulu: { label: 'Honolulu, HI', state: 'HI' },
|
||||
anchorage: { label: 'Anchorage, AK', state: 'AK' },
|
||||
miami: { label: 'Miami, FL', state: 'FL' },
|
||||
sanFrancisco: { label: 'San Francisco, CA', state: 'CA' },
|
||||
washingtonDC: { label: 'Washington, DC', state: 'DC', lat: 38.9, lon: -77.0 },
|
||||
newYork: { label: 'New York, NY', state: 'NY', lat: 40.7, lon: -74.0 },
|
||||
losAngeles: { label: 'Los Angeles, CA', state: 'CA', lat: 34.1, lon: -118.2 },
|
||||
chicago: { label: 'Chicago, IL', state: 'IL', lat: 41.9, lon: -87.6 },
|
||||
seattle: { label: 'Seattle, WA', state: 'WA', lat: 47.6, lon: -122.3 },
|
||||
denver: { label: 'Denver, CO', state: 'CO', lat: 39.7, lon: -105.0 },
|
||||
honolulu: { label: 'Honolulu, HI', state: 'HI', lat: 21.3, lon: -157.9 },
|
||||
anchorage: { label: 'Anchorage, AK', state: 'AK', lat: 61.2, lon: -149.9 },
|
||||
miami: { label: 'Miami, FL', state: 'FL', lat: 25.8, lon: -80.2 },
|
||||
sanFrancisco: { label: 'San Francisco, CA', state: 'CA', lat: 37.8, lon: -122.4 },
|
||||
};
|
||||
|
||||
// Analyte types that indicate concerning radiation
|
||||
@@ -76,8 +76,15 @@ export async function getResultsByAnalyte(analyte, opts = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
// Lookup coords by city name or state
|
||||
const CITY_COORDS = Object.fromEntries(
|
||||
Object.values(MONITORING_STATIONS).map(s => [s.label.split(',')[0].toUpperCase(), s])
|
||||
);
|
||||
|
||||
// Compact a reading for briefing output
|
||||
function compactReading(r) {
|
||||
const city = (r.ANA_CITY || r.LOCATION || '').toUpperCase().trim();
|
||||
const station = CITY_COORDS[city];
|
||||
return {
|
||||
location: r.ANA_CITY || r.LOCATION || 'Unknown',
|
||||
state: r.ANA_STATE || r.STATE || null,
|
||||
@@ -86,6 +93,8 @@ function compactReading(r) {
|
||||
unit: r.RESULT_UNIT || r.ANA_UNIT || null,
|
||||
collectDate: r.COLLECT_DATE || r.SAMPLE_DATE || null,
|
||||
medium: r.SAMPLE_TYPE || r.MEDIUM || null,
|
||||
lat: station?.lat || null,
|
||||
lon: station?.lon || null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -104,11 +104,26 @@ export async function briefing() {
|
||||
keywords.some(k => a.title?.toLowerCase().includes(k))
|
||||
);
|
||||
|
||||
// Geo events — get mapped event locations (separate API, respects rate limit)
|
||||
await delay(5500);
|
||||
let geoPoints = [];
|
||||
try {
|
||||
const geo = await geoEvents('conflict OR military OR protest OR crisis', { maxPoints: 30, timespan: '24h' });
|
||||
geoPoints = (geo?.features || []).filter(f => f.geometry?.coordinates).map(f => ({
|
||||
lat: f.geometry.coordinates[1],
|
||||
lon: f.geometry.coordinates[0],
|
||||
name: f.properties?.name || f.properties?.html || '',
|
||||
count: f.properties?.count || 1,
|
||||
type: f.properties?.type || 'event',
|
||||
}));
|
||||
} catch (e) { /* geo endpoint optional — don't break briefing */ }
|
||||
|
||||
return {
|
||||
source: 'GDELT',
|
||||
timestamp: new Date().toISOString(),
|
||||
totalArticles: articles.length,
|
||||
allArticles: articles,
|
||||
geoPoints,
|
||||
conflicts: categorize(['military', 'conflict', 'war', 'strike', 'missile', 'attack', 'bomb', 'troops']),
|
||||
economy: categorize(['economy', 'recession', 'inflation', 'market', 'sanctions', 'tariff', 'trade', 'gdp']),
|
||||
health: categorize(['pandemic', 'outbreak', 'epidemic', 'disease', 'virus', 'health']),
|
||||
|
||||
@@ -57,15 +57,33 @@ export async function briefing() {
|
||||
wildfires: fire.length,
|
||||
other: other.length,
|
||||
},
|
||||
topAlerts: features.slice(0, 15).map(f => ({
|
||||
event: f.properties?.event,
|
||||
severity: f.properties?.severity,
|
||||
urgency: f.properties?.urgency,
|
||||
headline: f.properties?.headline,
|
||||
areas: f.properties?.areaDesc,
|
||||
onset: f.properties?.onset,
|
||||
expires: f.properties?.expires,
|
||||
})),
|
||||
topAlerts: features.slice(0, 15).map(f => {
|
||||
// Extract centroid from GeoJSON geometry
|
||||
let lat = null, lon = null;
|
||||
const geo = f.geometry;
|
||||
if (geo?.type === 'Polygon' && geo.coordinates?.[0]?.length) {
|
||||
const coords = geo.coordinates[0];
|
||||
lat = coords.reduce((s, c) => s + c[1], 0) / coords.length;
|
||||
lon = coords.reduce((s, c) => s + c[0], 0) / coords.length;
|
||||
} else if (geo?.type === 'MultiPolygon' && geo.coordinates?.length) {
|
||||
const coords = geo.coordinates[0][0];
|
||||
lat = coords.reduce((s, c) => s + c[1], 0) / coords.length;
|
||||
lon = coords.reduce((s, c) => s + c[0], 0) / coords.length;
|
||||
} else if (geo?.type === 'Point') {
|
||||
[lon, lat] = geo.coordinates;
|
||||
}
|
||||
return {
|
||||
event: f.properties?.event,
|
||||
severity: f.properties?.severity,
|
||||
urgency: f.properties?.urgency,
|
||||
headline: f.properties?.headline,
|
||||
areas: f.properties?.areaDesc,
|
||||
onset: f.properties?.onset,
|
||||
expires: f.properties?.expires,
|
||||
lat: lat != null ? +lat.toFixed(3) : null,
|
||||
lon: lon != null ? +lon.toFixed(3) : null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,10 @@ const HOTSPOTS = {
|
||||
baltics: { lamin: 53, lomin: 19, lamax: 60, lomax: 29, label: 'Baltic Region' },
|
||||
southChinaSea: { lamin: 5, lomin: 105, lamax: 23, lomax: 122, label: 'South China Sea' },
|
||||
koreanPeninsula: { lamin: 33, lomin: 124, lamax: 43, lomax: 132, label: 'Korean Peninsula' },
|
||||
caribbean: { lamin: 18, lomin: -90, lamax: 30, lomax: -72, label: 'Caribbean' },
|
||||
gulfOfGuinea: { lamin: -2, lomin: -5, lamax: 8, lomax: 10, label: 'Gulf of Guinea' },
|
||||
capeRoute: { lamin: -38, lomin: 12, lamax: -28, lomax: 24, label: 'Cape Route' },
|
||||
hornOfAfrica: { lamin: 5, lomin: 40, lamax: 15, lomax: 55, label: 'Horn of Africa' },
|
||||
};
|
||||
|
||||
// Briefing — check hotspot regions for flight activity
|
||||
|
||||
@@ -7,7 +7,7 @@ export default {
|
||||
refreshIntervalMinutes: parseInt(process.env.REFRESH_INTERVAL_MINUTES) || 15,
|
||||
|
||||
llm: {
|
||||
provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex
|
||||
provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax
|
||||
apiKey: process.env.LLM_API_KEY || null,
|
||||
model: process.env.LLM_MODEL || null,
|
||||
},
|
||||
|
||||
@@ -9,6 +9,9 @@ import { readFileSync, writeFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { exec } from 'child_process';
|
||||
import config from '../crucix.config.mjs';
|
||||
import { createLLMProvider } from '../lib/llm/index.mjs';
|
||||
import { generateLLMIdeas } from '../lib/llm/ideas.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..');
|
||||
@@ -125,7 +128,7 @@ export async function fetchAllNews() {
|
||||
const feeds = [
|
||||
['http://feeds.bbci.co.uk/news/world/rss.xml', 'BBC'],
|
||||
['https://rss.nytimes.com/services/xml/rss/nyt/World.xml', 'NYT'],
|
||||
['https://feeds.aljazeera.com/xml/rss/all.xml', 'Al Jazeera'],
|
||||
['https://www.aljazeera.com/xml/rss/all.xml', 'Al Jazeera'],
|
||||
['https://rss.nytimes.com/services/xml/rss/nyt/Americas.xml', 'NYT Americas'],
|
||||
['https://rss.nytimes.com/services/xml/rss/nyt/AsiaPacific.xml', 'NYT Asia'],
|
||||
['https://feeds.bbci.co.uk/news/technology/rss.xml', 'BBC Tech'],
|
||||
@@ -364,16 +367,54 @@ export async function synthesize(data) {
|
||||
const defense = (data.sources.USAspending?.recentDefenseContracts || []).slice(0, 5).map(c => ({
|
||||
recipient: c.recipient?.substring(0, 40), amount: c.amount, desc: c.description?.substring(0, 80)
|
||||
}));
|
||||
const noaa = { totalAlerts: data.sources.NOAA?.totalSevereAlerts || 0 };
|
||||
const noaa = {
|
||||
totalAlerts: data.sources.NOAA?.totalSevereAlerts || 0,
|
||||
alerts: (data.sources.NOAA?.topAlerts || []).filter(a => a.lat != null && a.lon != null).slice(0, 10).map(a => ({
|
||||
event: a.event, severity: a.severity, headline: a.headline?.substring(0, 120),
|
||||
lat: a.lat, lon: a.lon
|
||||
}))
|
||||
};
|
||||
|
||||
// EPA RadNet — pass through geo-tagged readings
|
||||
const epaData = data.sources.EPA || {};
|
||||
const epaStations = [];
|
||||
const seenEpa = new Set();
|
||||
for (const r of (epaData.readings || [])) {
|
||||
if (r.lat == null || r.lon == null) continue;
|
||||
const key = `${r.lat},${r.lon}`;
|
||||
if (seenEpa.has(key)) continue;
|
||||
seenEpa.add(key);
|
||||
epaStations.push({ location: r.location, state: r.state, lat: r.lat, lon: r.lon, analyte: r.analyte, result: r.result, unit: r.unit });
|
||||
}
|
||||
const epa = { totalReadings: epaData.totalReadings || 0, stations: epaStations.slice(0, 10) };
|
||||
|
||||
// Space/CelesTrak satellite data
|
||||
const spaceData = data.sources.Space || {};
|
||||
// Approximate subsatellite position from TLE orbital elements
|
||||
function estimateSatPosition(sat) {
|
||||
if (!sat?.inclination || !sat?.epoch) return null;
|
||||
const epoch = new Date(sat.epoch);
|
||||
const now = new Date();
|
||||
const elapsed = (now - epoch) / 1000;
|
||||
const period = (sat.period || 92.7) * 60; // minutes to seconds
|
||||
const orbits = elapsed / period;
|
||||
const frac = orbits % 1;
|
||||
const lat = sat.inclination * Math.sin(frac * 2 * Math.PI);
|
||||
const lonShift = (elapsed / 86400) * 360;
|
||||
const orbitLon = frac * 360;
|
||||
const lon = ((orbitLon - lonShift) % 360 + 540) % 360 - 180;
|
||||
return { lat: +lat.toFixed(2), lon: +lon.toFixed(2), name: sat.name };
|
||||
}
|
||||
const issPos = estimateSatPosition(spaceData.iss);
|
||||
const spaceStations = (spaceData.spaceStations || []).map(s => estimateSatPosition(s)).filter(Boolean);
|
||||
const space = {
|
||||
totalNewObjects: spaceData.totalNewObjects || 0,
|
||||
militarySats: spaceData.militarySatellites || 0,
|
||||
militaryByCountry: spaceData.militaryByCountry || {},
|
||||
constellations: spaceData.constellations || {},
|
||||
iss: spaceData.iss || null,
|
||||
issPosition: issPos,
|
||||
stationPositions: spaceStations.slice(0, 5),
|
||||
recentLaunches: (spaceData.recentLaunches || []).slice(0, 10).map(l => ({
|
||||
name: l.name, country: l.country, epoch: l.epoch,
|
||||
apogee: l.apogee, perigee: l.perigee, type: l.objectType
|
||||
@@ -395,7 +436,7 @@ export async function synthesize(data) {
|
||||
}))
|
||||
};
|
||||
|
||||
// GDELT news articles
|
||||
// GDELT news articles + geo events
|
||||
const gdeltData = data.sources.GDELT || {};
|
||||
const gdelt = {
|
||||
totalArticles: gdeltData.totalArticles || 0,
|
||||
@@ -403,7 +444,10 @@ export async function synthesize(data) {
|
||||
economy: (gdeltData.economy || []).length,
|
||||
health: (gdeltData.health || []).length,
|
||||
crisis: (gdeltData.crisis || []).length,
|
||||
topTitles: (gdeltData.allArticles || []).slice(0, 5).map(a => a.title?.substring(0, 80))
|
||||
topTitles: (gdeltData.allArticles || []).slice(0, 5).map(a => a.title?.substring(0, 80)),
|
||||
geoPoints: (gdeltData.geoPoints || []).slice(0, 20).map(p => ({
|
||||
lat: p.lat, lon: p.lon, name: (p.name || '').substring(0, 80), count: p.count || 1
|
||||
}))
|
||||
};
|
||||
|
||||
const health = Object.entries(data.sources).map(([name, src]) => ({
|
||||
@@ -454,7 +498,7 @@ export async function synthesize(data) {
|
||||
meta: data.crucix, air, thermal, tSignals, chokepoints, nuke, nukeSignals,
|
||||
sdr: { total: sdrNet.totalReceivers || 0, online: sdrNet.online || 0, zones: sdrZones },
|
||||
tg: { posts: tgData.totalPosts || 0, urgent: tgUrgent, topPosts: tgTop },
|
||||
who, fred, energy, bls, treasury, gscpi, defense, noaa, acled, gdelt, space, health, news,
|
||||
who, fred, energy, bls, treasury, gscpi, defense, noaa, epa, acled, gdelt, space, health, news,
|
||||
markets, // Live Yahoo Finance market data
|
||||
ideas: [], ideasSource: 'disabled',
|
||||
// newsFeed for ticker (merged RSS + GDELT + Telegram)
|
||||
@@ -511,11 +555,42 @@ function buildNewsFeed(rssNews, gdeltData, tgUrgent, tgTop) {
|
||||
}
|
||||
|
||||
// === CLI Mode: inject into HTML file ===
|
||||
function getCliArg(flag) {
|
||||
const idx = process.argv.indexOf(flag);
|
||||
return idx >= 0 ? process.argv[idx + 1] : null;
|
||||
}
|
||||
|
||||
async function cliInject() {
|
||||
const data = JSON.parse(readFileSync(join(ROOT, 'runs/latest.json'), 'utf8'));
|
||||
const htmlOverride = getCliArg('--html');
|
||||
const shouldOpen = !process.argv.includes('--no-open');
|
||||
|
||||
console.log('Fetching RSS news feeds...');
|
||||
const V2 = await synthesize(data);
|
||||
const llmProvider = createLLMProvider(config.llm);
|
||||
|
||||
if (llmProvider?.isConfigured) {
|
||||
try {
|
||||
console.log(`[LLM] Generating ideas via ${llmProvider.name}...`);
|
||||
const llmIdeas = await generateLLMIdeas(llmProvider, V2, null, []);
|
||||
if (llmIdeas?.length) {
|
||||
V2.ideas = llmIdeas;
|
||||
V2.ideasSource = 'llm';
|
||||
console.log(`[LLM] Generated ${llmIdeas.length} ideas`);
|
||||
} else {
|
||||
V2.ideas = [];
|
||||
V2.ideasSource = 'llm-failed';
|
||||
console.log('[LLM] No ideas returned');
|
||||
}
|
||||
} catch (err) {
|
||||
V2.ideas = [];
|
||||
V2.ideasSource = 'llm-failed';
|
||||
console.log('[LLM] Idea generation failed:', err.message);
|
||||
}
|
||||
} else {
|
||||
V2.ideas = [];
|
||||
V2.ideasSource = 'disabled';
|
||||
}
|
||||
console.log(`Generated ${V2.ideas.length} leverageable ideas`);
|
||||
|
||||
const json = JSON.stringify(V2);
|
||||
@@ -523,12 +598,15 @@ async function cliInject() {
|
||||
console.log('Size:', json.length, 'bytes | Air:', V2.air.length, '| Thermal:', V2.thermal.length,
|
||||
'| News:', V2.news.length, '| Ideas:', V2.ideas.length, '| Sources:', V2.health.length);
|
||||
|
||||
const htmlPath = join(ROOT, 'dashboard/public/jarvis.html');
|
||||
const htmlPath = htmlOverride || join(ROOT, 'dashboard/public/jarvis.html');
|
||||
let html = readFileSync(htmlPath, 'utf8');
|
||||
html = html.replace(/^(let|const) D = .*;\s*$/m, 'let D = ' + json + ';');
|
||||
// Use a replacer function so JSON is inserted literally even if it contains `$`.
|
||||
html = html.replace(/^(let|const) D = .*;\s*$/m, () => 'let D = ' + json + ';');
|
||||
writeFileSync(htmlPath, html);
|
||||
console.log('Data injected into jarvis.html!');
|
||||
|
||||
if (!shouldOpen) return;
|
||||
|
||||
// Auto-open dashboard in default browser
|
||||
// NOTE: On Windows, `start` in PowerShell is an alias for Start-Service, not cmd's start.
|
||||
// We must use `cmd /c start ""` to ensure it works in both cmd.exe and PowerShell.
|
||||
@@ -542,7 +620,8 @@ async function cliInject() {
|
||||
}
|
||||
|
||||
// Run CLI if invoked directly
|
||||
const isMain = process.argv[1] && fileURLToPath(import.meta.url).includes(process.argv[1].replace(/\\/g, '/'));
|
||||
const isMain = process.argv[1]
|
||||
&& fileURLToPath(import.meta.url).replace(/\\/g, '/') === process.argv[1].replace(/\\/g, '/');
|
||||
if (isMain) {
|
||||
cliInject();
|
||||
await cliInject();
|
||||
}
|
||||
|
||||
@@ -298,12 +298,12 @@ let flightsVisible = true;
|
||||
let isFlat = true;
|
||||
let flatSvg, flatProjection, flatPath, flatG, flatZoom, flatW, flatH;
|
||||
const regionPOV = {
|
||||
world: { lat: 20, lng: 20, altitude: 2.5 },
|
||||
americas: { lat: 15, lng: -80, altitude: 1.6 },
|
||||
europe: { lat: 50, lng: 15, altitude: 1.2 },
|
||||
middleEast: { lat: 28, lng: 45, altitude: 1.4 },
|
||||
asiaPacific: { lat: 25, lng: 110, altitude: 1.6 },
|
||||
africa: { lat: 5, lng: 20, altitude: 1.5 }
|
||||
world: { lat: 20, lng: 20, altitude: 1.8 },
|
||||
americas: { lat: 35, lng: -95, altitude: 1.0 },
|
||||
europe: { lat: 50, lng: 15, altitude: 1.0 },
|
||||
middleEast: { lat: 28, lng: 45, altitude: 1.1 },
|
||||
asiaPacific: { lat: 25, lng: 110, altitude: 1.2 },
|
||||
africa: { lat: 5, lng: 20, altitude: 1.2 }
|
||||
};
|
||||
|
||||
// === TOPBAR ===
|
||||
@@ -414,7 +414,7 @@ function initMap(){
|
||||
.pointRadius(d => d.size || 0.3)
|
||||
.pointColor(d => d.color)
|
||||
.pointLabel(d => `<b>${d.popHead||''}</b><br><span style="opacity:0.7">${d.popMeta||''}</span>`)
|
||||
.onPointClick((pt, ev) => { showPopup(ev, pt.popHead, pt.popText, pt.popMeta); })
|
||||
.onPointClick((pt, ev) => { showPopup(ev, pt.popHead, pt.popText, pt.popMeta, pt.lat, pt.lng, pt.alt); })
|
||||
.onPointHover(pt => { document.getElementById('globeViz').style.cursor = pt ? 'pointer' : 'grab'; })
|
||||
// Arcs layer (flight corridors)
|
||||
.arcColor(d => d.color)
|
||||
@@ -502,7 +502,7 @@ function initMap(){
|
||||
// Legend
|
||||
document.getElementById('mapLegend').innerHTML=
|
||||
[{c:'#64f0c8',l:'Air Traffic'},{c:'#ff5f63',l:'Thermal/Fire'},{c:'rgba(255,120,80,0.8)',l:'Conflict'},{c:'#44ccff',l:'SDR Receiver'},
|
||||
{c:'#ffe082',l:'Nuclear Site'},{c:'#b388ff',l:'Chokepoint'},{c:'#ffb84c',l:'OSINT Event'},{c:'#69f0ae',l:'Health Alert'},{c:'#81d4fa',l:'World News'}]
|
||||
{c:'#ffe082',l:'Nuclear Site'},{c:'#b388ff',l:'Chokepoint'},{c:'#ffb84c',l:'OSINT Event'},{c:'#69f0ae',l:'Health Alert'},{c:'#81d4fa',l:'World News'},{c:'#ff9800',l:'Weather Alert'},{c:'#cddc39',l:'EPA RadNet'},{c:'#ffffff',l:'Space Station'},{c:'#6495ed',l:'GDELT Event'}]
|
||||
.map(x=>`<div class="leg-item"><div class="leg-dot" style="background:${x.c}"></div>${x.l}</div>`).join('');
|
||||
}
|
||||
|
||||
@@ -511,12 +511,12 @@ function plotMarkers(){
|
||||
const labels = [];
|
||||
|
||||
// === Air hotspots (green) ===
|
||||
const airCoords=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127}];
|
||||
const airCoords=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127},{lat:25,lon:-80},{lat:4,lon:2},{lat:-34,lon:18},{lat:10,lon:51}];
|
||||
D.air.forEach((a,i)=>{
|
||||
const c=airCoords[i]; if(!c) return;
|
||||
points.push({
|
||||
lat:c.lat, lng:c.lon, size:0.25+a.total/200, alt:0.015,
|
||||
color:'rgba(100,240,200,0.8)', type:'air',
|
||||
color:'rgba(100,240,200,0.8)', type:'air', priority:1,
|
||||
label: a.region.replace(' Region','')+' '+a.total,
|
||||
popHead: a.region, popMeta: 'Air Activity',
|
||||
popText: `${a.total} aircraft tracked<br>No callsign: ${a.noCallsign}<br>High altitude: ${a.highAlt}<br>Top: ${a.top.slice(0,3).map(t=>t[0]+' ('+t[1]+')').join(', ')}`
|
||||
@@ -529,7 +529,7 @@ function plotMarkers(){
|
||||
t.fires.forEach(f=>{
|
||||
points.push({
|
||||
lat:f.lat, lng:f.lon, size:0.12+Math.min(f.frp/200,0.3), alt:0.008,
|
||||
color:'rgba(255,95,99,0.7)', type:'thermal',
|
||||
color:'rgba(255,95,99,0.7)', type:'thermal', priority:3,
|
||||
popHead:'Thermal Detection', popMeta:'FIRMS Satellite',
|
||||
popText:`Region: ${t.region}<br>FRP: ${f.frp.toFixed(1)} MW<br>Total: ${t.det.toLocaleString()}<br>Night: ${t.night.toLocaleString()}`
|
||||
});
|
||||
@@ -540,7 +540,7 @@ function plotMarkers(){
|
||||
D.chokepoints.forEach(cp=>{
|
||||
points.push({
|
||||
lat:cp.lat, lng:cp.lon, size:0.35, alt:0.02,
|
||||
color:'rgba(179,136,255,0.8)', type:'maritime',
|
||||
color:'rgba(179,136,255,0.8)', type:'maritime', priority:1,
|
||||
popHead:cp.label, popMeta:'Maritime Intelligence', popText:cp.note
|
||||
});
|
||||
labels.push({lat:cp.lat, lng:cp.lon+1.5, text:cp.label, size:0.3, color:'rgba(179,136,255,0.6)'});
|
||||
@@ -552,7 +552,7 @@ function plotMarkers(){
|
||||
const c=nukeCoords[i]; if(!c) return;
|
||||
points.push({
|
||||
lat:c.lat, lng:c.lon, size:0.3, alt:0.012,
|
||||
color: n.anom ? 'rgba(255,95,99,0.9)' : 'rgba(255,224,130,0.8)', type:'nuke',
|
||||
color: n.anom ? 'rgba(255,95,99,0.9)' : 'rgba(255,224,130,0.8)', type:'nuke', priority:2,
|
||||
popHead:n.site, popMeta:'Radiation Monitoring',
|
||||
popText:`Status: ${n.anom?'ANOMALY':'Normal'}<br>Avg CPM: ${n.cpm?.toFixed(1)||'No data'}<br>Readings: ${n.n}`
|
||||
});
|
||||
@@ -563,7 +563,7 @@ function plotMarkers(){
|
||||
z.receivers.forEach(r=>{
|
||||
points.push({
|
||||
lat:r.lat, lng:r.lon, size:0.15, alt:0.005,
|
||||
color:'rgba(68,204,255,0.6)', type:'sdr',
|
||||
color:'rgba(68,204,255,0.6)', type:'sdr', priority:3,
|
||||
popHead:'SDR Receiver', popMeta:'KiwiSDR Network',
|
||||
popText:`${r.name}<br>Zone: ${z.region}<br>${z.count} in zone`
|
||||
});
|
||||
@@ -576,7 +576,7 @@ function plotMarkers(){
|
||||
const post=D.tg.urgent[o.idx]; if(!post) return;
|
||||
points.push({
|
||||
lat:o.lat, lng:o.lon, size:0.3, alt:0.018,
|
||||
color:'rgba(255,184,76,0.8)', type:'osint',
|
||||
color:'rgba(255,184,76,0.8)', type:'osint', priority:2,
|
||||
popHead:(post.channel||'').toUpperCase(), popMeta:`${post.views?.toLocaleString()||'?'} views`,
|
||||
popText:cleanText(post.text?.substring(0,200)||'')
|
||||
});
|
||||
@@ -588,7 +588,7 @@ function plotMarkers(){
|
||||
const c=whoGeo[i]; if(!c) return;
|
||||
points.push({
|
||||
lat:c.lat, lng:c.lon, size:0.25, alt:0.01,
|
||||
color:'rgba(105,240,174,0.7)', type:'health',
|
||||
color:'rgba(105,240,174,0.7)', type:'health', priority:2,
|
||||
popHead:w.title, popMeta:'WHO Outbreak', popText:w.summary||''
|
||||
});
|
||||
});
|
||||
@@ -597,12 +597,53 @@ function plotMarkers(){
|
||||
(D.news||[]).forEach(n=>{
|
||||
points.push({
|
||||
lat:n.lat, lng:n.lon, size:0.2, alt:0.008,
|
||||
color:'rgba(129,212,250,0.7)', type:'news',
|
||||
color:'rgba(129,212,250,0.7)', type:'news', priority:3,
|
||||
popHead:n.source+' NEWS', popMeta:n.region+' · '+getAge(n.date),
|
||||
popText:cleanText(n.title)
|
||||
});
|
||||
});
|
||||
|
||||
// === NOAA severe weather alerts (orange) ===
|
||||
(D.noaa?.alerts||[]).forEach(a=>{
|
||||
points.push({
|
||||
lat:a.lat, lng:a.lon, size:0.22, alt:0.01,
|
||||
color:'rgba(255,152,0,0.8)', type:'weather', priority:2,
|
||||
popHead:a.event, popMeta:'NOAA/NWS · '+a.severity,
|
||||
popText:a.headline||''
|
||||
});
|
||||
});
|
||||
|
||||
// === EPA RadNet stations (lime green) ===
|
||||
(D.epa?.stations||[]).forEach(s=>{
|
||||
points.push({
|
||||
lat:s.lat, lng:s.lon, size:0.18, alt:0.006,
|
||||
color:'rgba(205,220,57,0.7)', type:'radiation', priority:3,
|
||||
popHead:'RadNet: '+s.location, popMeta:'EPA Radiation Monitor',
|
||||
popText:`${s.analyte||'--'}: ${s.result||'--'} ${s.unit||''}<br>State: ${s.state}`
|
||||
});
|
||||
});
|
||||
|
||||
// === ISS + Space Stations (bright white, pulsing) ===
|
||||
(D.space?.stationPositions||[]).forEach(s=>{
|
||||
points.push({
|
||||
lat:s.lat, lng:s.lon, size:0.4, alt:0.04,
|
||||
color:'rgba(255,255,255,0.95)', type:'space', priority:1,
|
||||
popHead:s.name, popMeta:'Space Station (approx.)',
|
||||
popText:`Orbital position estimate<br>Lat: ${s.lat}° Lon: ${s.lon}°`
|
||||
});
|
||||
labels.push({lat:s.lat, lng:s.lon+3, text:s.name.split('(')[0].trim(), size:0.35, color:'rgba(255,255,255,0.7)'});
|
||||
});
|
||||
|
||||
// === GDELT geo events (steel blue) ===
|
||||
(D.gdelt?.geoPoints||[]).forEach(g=>{
|
||||
points.push({
|
||||
lat:g.lat, lng:g.lon, size:0.15+Math.min(g.count/50,0.2), alt:0.007,
|
||||
color:'rgba(100,149,237,0.6)', type:'gdelt', priority:3,
|
||||
popHead:'GDELT Event', popMeta:g.count+' reports',
|
||||
popText:g.name||'Global event detection'
|
||||
});
|
||||
});
|
||||
|
||||
// Set points on globe
|
||||
globe.pointsData(points);
|
||||
globe.labelsData(labels);
|
||||
@@ -625,7 +666,9 @@ function plotMarkers(){
|
||||
const airCoordsFlight = [
|
||||
{region:'Middle East',lat:30,lon:44}, {region:'Taiwan Strait',lat:24,lon:120},
|
||||
{region:'Ukraine Region',lat:49,lon:32}, {region:'Baltic Region',lat:57,lon:24},
|
||||
{region:'South China Sea',lat:14,lon:114}, {region:'Korean Peninsula',lat:37,lon:127}
|
||||
{region:'South China Sea',lat:14,lon:114}, {region:'Korean Peninsula',lat:37,lon:127},
|
||||
{region:'Caribbean',lat:25,lon:-80}, {region:'Gulf of Guinea',lat:4,lon:2},
|
||||
{region:'Cape Route',lat:-34,lon:18}, {region:'Horn of Africa',lat:10,lon:51}
|
||||
];
|
||||
const globalHubs = [
|
||||
{lat:40.6,lon:-73.8},{lat:51.5,lon:-0.5},{lat:25.3,lon:55.4},
|
||||
@@ -667,14 +710,41 @@ function plotMarkers(){
|
||||
});
|
||||
});
|
||||
globe.arcsData(arcs);
|
||||
|
||||
// Zoom-aware marker sizing: scale markers and labels with camera altitude
|
||||
const onGlobeZoom = () => {
|
||||
const alt = globe.pointOfView().altitude;
|
||||
const sf = Math.max(0.6, Math.min(2.5, 1.5 / alt));
|
||||
globe.pointRadius(d => (d.size || 0.3) * sf);
|
||||
// Hide labels when zoomed far out to reduce clutter
|
||||
const showLabels = alt < 1.8;
|
||||
globe.labelSize(d => showLabels ? (d.size || 0.4) : 0);
|
||||
// Scale arc strokes with zoom
|
||||
globe.arcStroke(d => (d.stroke || 0.4) * Math.max(0.5, Math.min(1.5, 1.2 / alt)));
|
||||
// Priority-based point visibility: hide low-priority markers when zoomed out
|
||||
if(alt > 2.0){
|
||||
globe.pointsData(points.filter(p => (p.priority||3) <= 1));
|
||||
} else if(alt > 1.2){
|
||||
globe.pointsData(points.filter(p => (p.priority||3) <= 2));
|
||||
} else {
|
||||
globe.pointsData(points);
|
||||
}
|
||||
};
|
||||
if(typeof globe.onZoom==='function') globe.onZoom(onGlobeZoom);
|
||||
}
|
||||
|
||||
function showPopup(event,head,text,meta){
|
||||
function showPopup(event,head,text,meta,lat,lng,alt){
|
||||
const popup=document.getElementById('mapPopup');
|
||||
const container=document.getElementById('mapContainer');
|
||||
const rect=container.getBoundingClientRect();
|
||||
let left, top;
|
||||
if(event && event.clientX != null){
|
||||
if(!isFlat && lat!=null && globe && typeof globe.getScreenCoords==='function'){
|
||||
const sc=globe.getScreenCoords(lat,lng,alt||0.01);
|
||||
if(!sc||isNaN(sc.x)||isNaN(sc.y)||sc.x<0||sc.y<0||sc.x>rect.width||sc.y>rect.height){
|
||||
if(event&&event.clientX!=null){left=event.clientX-rect.left+10;top=event.clientY-rect.top-10;}
|
||||
else return;
|
||||
} else {left=sc.x+10;top=sc.y-10;}
|
||||
} else if(event && event.clientX != null){
|
||||
left=event.clientX - rect.left + 10;
|
||||
top=event.clientY - rect.top - 10;
|
||||
} else {
|
||||
@@ -711,7 +781,7 @@ function toggleFlights() {
|
||||
|
||||
// === FLAT/GLOBE TOGGLE ===
|
||||
const flatRegionBounds = {
|
||||
world:[[-180,-60],[180,80]], americas:[[-170,-56],[-30,72]], europe:[[-12,34],[45,72]],
|
||||
world:[[-180,-60],[180,80]], americas:[[-130,10],[-60,55]], europe:[[-12,34],[45,72]],
|
||||
middleEast:[[24,10],[65,45]], asiaPacific:[[60,-12],[180,55]], africa:[[-20,-36],[55,38]]
|
||||
};
|
||||
|
||||
@@ -745,7 +815,15 @@ function initFlatMap(){
|
||||
flatG.attr('transform',event.transform);
|
||||
const k=event.transform.k;
|
||||
flatG.selectAll('.marker-circle').attr('r',function(){return +this.dataset.baseR/Math.sqrt(k)});
|
||||
flatG.selectAll('.marker-label').style('font-size',Math.max(7,9/Math.sqrt(k))+'px');
|
||||
flatG.selectAll('.marker-label').style('font-size',Math.max(7,9/Math.sqrt(k))+'px')
|
||||
.style('display',k>=2.5?'block':'none');
|
||||
// Priority-based visibility: hide low-priority markers at low zoom
|
||||
flatG.selectAll('[data-priority]').style('display',function(){
|
||||
const p=+this.dataset.priority;
|
||||
if(p<=1) return 'block';
|
||||
if(p<=2) return k>=2?'block':'none';
|
||||
return k>=3.5?'block':'none';
|
||||
});
|
||||
});
|
||||
flatSvg.call(flatZoom);
|
||||
drawFlatMap();
|
||||
@@ -765,58 +843,69 @@ function drawFlatMap(){
|
||||
function plotFlatMarkers(){
|
||||
const mg=flatG.append('g').attr('class','markers');
|
||||
const proj=flatProjection;
|
||||
const addPt=(lat,lon,r,fill,stroke,onClick)=>{
|
||||
const addPt=(lat,lon,r,fill,stroke,onClick,priority)=>{
|
||||
const[x,y]=proj([lon,lat]);if(!x||!y)return null;
|
||||
const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer');
|
||||
const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer').attr('data-priority',priority||3);
|
||||
if(onClick) g.on('click',ev=>{ev.stopPropagation();onClick(ev)});
|
||||
g.append('circle').attr('class','marker-circle').attr('r',r).attr('data-base-r',r).attr('fill',fill).attr('stroke',stroke).attr('stroke-width',0.8);
|
||||
return g;
|
||||
};
|
||||
// Air
|
||||
const airCoords=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127}];
|
||||
const airCoords=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127},{lat:25,lon:-80},{lat:4,lon:2},{lat:-34,lon:18},{lat:10,lon:51}];
|
||||
D.air.forEach((a,i)=>{
|
||||
const c=airCoords[i];if(!c)return;
|
||||
const g=addPt(c.lat,c.lon,4+a.total/40,'rgba(100,240,200,0.7)','rgba(100,240,200,0.3)',
|
||||
ev=>showPopup(ev,a.region,`${a.total} aircraft<br>No callsign: ${a.noCallsign}<br>High alt: ${a.highAlt}`,'Air Activity'));
|
||||
ev=>showPopup(ev,a.region,`${a.total} aircraft<br>No callsign: ${a.noCallsign}<br>High alt: ${a.highAlt}`,'Air Activity'),1);
|
||||
if(g) g.append('text').attr('class','marker-label').attr('x',10).attr('y',3).attr('fill','var(--dim)').attr('font-size','9px').attr('font-family','var(--mono)').text(a.region.replace(' Region','')+' '+a.total);
|
||||
});
|
||||
// Thermal
|
||||
D.thermal.forEach(t=>t.fires.forEach(f=>{
|
||||
addPt(f.lat,f.lon,2+Math.min(f.frp/50,5),'rgba(255,95,99,0.6)','rgba(255,95,99,0.2)',
|
||||
ev=>showPopup(ev,'Thermal',`${t.region}<br>FRP: ${f.frp.toFixed(1)} MW`,'FIRMS'));
|
||||
ev=>showPopup(ev,'Thermal',`${t.region}<br>FRP: ${f.frp.toFixed(1)} MW`,'FIRMS'),3);
|
||||
}));
|
||||
// Chokepoints
|
||||
D.chokepoints.forEach(cp=>{
|
||||
const[x,y]=proj([cp.lon,cp.lat]);if(!x||!y)return;
|
||||
const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer')
|
||||
const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer').attr('data-priority',1)
|
||||
.on('click',ev=>{ev.stopPropagation();showPopup(ev,cp.label,cp.note,'Maritime')});
|
||||
g.append('rect').attr('x',-4).attr('y',-4).attr('width',8).attr('height',8).attr('fill','rgba(179,136,255,0.7)').attr('stroke','rgba(179,136,255,0.3)').attr('stroke-width',0.5).attr('transform','rotate(45)');
|
||||
g.append('text').attr('class','marker-label').attr('x',8).attr('y',3).attr('fill','var(--dim)').attr('font-size','8px').attr('font-family','var(--mono)').text(cp.label);
|
||||
});
|
||||
// Nuclear
|
||||
const nukeCoords=[{lat:47.5,lon:34.6},{lat:51.4,lon:30.1},{lat:28.8,lon:50.9},{lat:39.8,lon:125.8},{lat:37.4,lon:141},{lat:31.0,lon:35.1}];
|
||||
D.nuke.forEach((n,i)=>{const c=nukeCoords[i];if(!c)return;addPt(c.lat,c.lon,4,'rgba(255,224,130,0.7)','rgba(255,224,130,0.3)',ev=>showPopup(ev,n.site,`CPM: ${n.cpm?.toFixed(1)||'--'}`,'Radiation'))});
|
||||
D.nuke.forEach((n,i)=>{const c=nukeCoords[i];if(!c)return;addPt(c.lat,c.lon,4,'rgba(255,224,130,0.7)','rgba(255,224,130,0.3)',ev=>showPopup(ev,n.site,`CPM: ${n.cpm?.toFixed(1)||'--'}`,'Radiation'),2)});
|
||||
// SDR
|
||||
D.sdr.zones.forEach(z=>z.receivers.forEach(r=>{addPt(r.lat,r.lon,2.5,'rgba(68,204,255,0.5)','rgba(68,204,255,0.2)',ev=>showPopup(ev,'SDR',`${r.name}<br>${z.region}`,'KiwiSDR'))}));
|
||||
D.sdr.zones.forEach(z=>z.receivers.forEach(r=>{addPt(r.lat,r.lon,2.5,'rgba(68,204,255,0.5)','rgba(68,204,255,0.2)',ev=>showPopup(ev,'SDR',`${r.name}<br>${z.region}`,'KiwiSDR'),3)}));
|
||||
// OSINT
|
||||
const osintGeo=[{lat:45,lon:41,idx:0},{lat:48,lon:37,idx:1},{lat:48.5,lon:37.5,idx:2},{lat:45,lon:40.2,idx:3},{lat:50.6,lon:36.6,idx:5},{lat:48.5,lon:35,idx:6}];
|
||||
osintGeo.forEach(o=>{const p=D.tg.urgent[o.idx];if(!p)return;addPt(o.lat,o.lon,4,'rgba(255,184,76,0.7)','rgba(255,184,76,0.3)',ev=>showPopup(ev,(p.channel||'').toUpperCase(),cleanText(p.text?.substring(0,200)||''),`${p.views||'?'} views`))});
|
||||
osintGeo.forEach(o=>{const p=D.tg.urgent[o.idx];if(!p)return;addPt(o.lat,o.lon,4,'rgba(255,184,76,0.7)','rgba(255,184,76,0.3)',ev=>showPopup(ev,(p.channel||'').toUpperCase(),cleanText(p.text?.substring(0,200)||''),`${p.views||'?'} views`),2)});
|
||||
// WHO
|
||||
const whoGeo=[{lat:0.3,lon:32.6},{lat:-6.2,lon:106.8},{lat:-4.3,lon:15.3},{lat:35,lon:105},{lat:12.5,lon:105},{lat:35,lon:105},{lat:28,lon:84},{lat:24,lon:45},{lat:30,lon:70},{lat:-0.8,lon:11.6}];
|
||||
D.who.slice(0,10).forEach((w,i)=>{const c=whoGeo[i];if(!c)return;addPt(c.lat,c.lon,3.5,'rgba(105,240,174,0.6)','rgba(105,240,174,0.2)',ev=>showPopup(ev,w.title,w.summary||'','WHO'))});
|
||||
D.who.slice(0,10).forEach((w,i)=>{const c=whoGeo[i];if(!c)return;addPt(c.lat,c.lon,3.5,'rgba(105,240,174,0.6)','rgba(105,240,174,0.2)',ev=>showPopup(ev,w.title,w.summary||'','WHO'),2)});
|
||||
// News
|
||||
(D.news||[]).forEach(n=>{addPt(n.lat,n.lon,3,'rgba(129,212,250,0.6)','rgba(129,212,250,0.2)',ev=>showPopup(ev,n.source+' NEWS',cleanText(n.title),n.region))});
|
||||
(D.news||[]).forEach(n=>{addPt(n.lat,n.lon,3,'rgba(129,212,250,0.6)','rgba(129,212,250,0.2)',ev=>showPopup(ev,n.source+' NEWS',cleanText(n.title),n.region),3)});
|
||||
// NOAA weather
|
||||
(D.noaa?.alerts||[]).forEach(a=>{addPt(a.lat,a.lon,4,'rgba(255,152,0,0.7)','rgba(255,152,0,0.3)',ev=>showPopup(ev,a.event,a.headline||'','NOAA/NWS'),2)});
|
||||
// EPA RadNet
|
||||
(D.epa?.stations||[]).forEach(s=>{addPt(s.lat,s.lon,3,'rgba(205,220,57,0.6)','rgba(205,220,57,0.2)',ev=>showPopup(ev,'RadNet: '+s.location,`${s.analyte||'--'}: ${s.result||'--'} ${s.unit||''}`,'EPA'),3)});
|
||||
// Space stations
|
||||
(D.space?.stationPositions||[]).forEach(s=>{
|
||||
const g=addPt(s.lat,s.lon,5,'rgba(255,255,255,0.9)','rgba(255,255,255,0.4)',ev=>showPopup(ev,s.name,'Orbital position estimate','Space Station'),1);
|
||||
if(g) g.append('text').attr('class','marker-label').attr('x',8).attr('y',3).attr('fill','rgba(255,255,255,0.7)').attr('font-size','8px').attr('font-family','var(--mono)').text(s.name.split('(')[0].trim());
|
||||
});
|
||||
// GDELT geo events
|
||||
(D.gdelt?.geoPoints||[]).forEach(g=>{addPt(g.lat,g.lon,2.5,'rgba(100,149,237,0.5)','rgba(100,149,237,0.2)',ev=>showPopup(ev,'GDELT Event',g.name||'','GDELT · '+g.count+' reports'),3)});
|
||||
// ACLED
|
||||
(D.acled?.deadliestEvents||[]).filter(e=>e.lat&&e.lon).forEach(e=>{
|
||||
const[x,y]=proj([e.lon,e.lat]);if(!x||!y)return;
|
||||
const r=Math.max(4,Math.min(14,2+Math.log2(Math.max(e.fatalities,1))*1.5));
|
||||
const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer')
|
||||
const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer').attr('data-priority',1)
|
||||
.on('click',ev=>{ev.stopPropagation();showPopup(ev,e.type||'CONFLICT',`${e.fatalities} fatalities<br>${e.location}, ${e.country}`,'ACLED')});
|
||||
g.append('circle').attr('class','conflict-ring marker-circle').attr('r',r).attr('data-base-r',r).attr('fill','none').attr('stroke','rgba(255,120,80,0.7)').attr('stroke-width',1.5);
|
||||
g.append('circle').attr('r',r*0.4).attr('fill','rgba(255,120,80,0.3)');
|
||||
});
|
||||
// Flight corridors
|
||||
const airCoordsFlight=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127}];
|
||||
const airCoordsFlight=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127},{lat:25,lon:-80},{lat:4,lon:2},{lat:-34,lon:18},{lat:10,lon:51}];
|
||||
const hubs=[{lat:40.6,lon:-73.8},{lat:51.5,lon:-0.5},{lat:25.3,lon:55.4},{lat:1.4,lon:103.8},{lat:-33.9,lon:151.2},{lat:-23.4,lon:-46.5}];
|
||||
const cG=flatG.append('g').attr('class','corridors-layer');
|
||||
for(let i=0;i<D.air.length;i++){for(let j=i+1;j<D.air.length;j++){
|
||||
@@ -1202,9 +1291,10 @@ function init(){
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const isServer = location.protocol !== 'file:';
|
||||
const hasInlineData = !!(D && D.meta);
|
||||
const canProbeApi = location.protocol !== 'file:';
|
||||
|
||||
if (isServer) {
|
||||
if (canProbeApi && !hasInlineData) {
|
||||
// Server mode: always fetch live data from API (ignore any stale inline D)
|
||||
fetch('/api/data')
|
||||
.then(r => r.json())
|
||||
@@ -1213,7 +1303,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// Should not reach here — server routes to loading.html when no data
|
||||
if (D && D.meta) { init(); connectSSE(); }
|
||||
});
|
||||
} else if (D && D.meta) {
|
||||
} else if (hasInlineData) {
|
||||
// File mode: use inline data
|
||||
init();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
crucix:
|
||||
build: .
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
|
||||
import { AnthropicProvider } from './anthropic.mjs';
|
||||
import { OpenAIProvider } from './openai.mjs';
|
||||
import { OpenRouterProvider } from './openrouter.mjs';
|
||||
import { GeminiProvider } from './gemini.mjs';
|
||||
import { CodexProvider } from './codex.mjs';
|
||||
import { MiniMaxProvider } from './minimax.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';
|
||||
|
||||
/**
|
||||
* Create an LLM provider based on config.
|
||||
@@ -26,10 +30,14 @@ export function createLLMProvider(llmConfig) {
|
||||
return new AnthropicProvider({ apiKey, model });
|
||||
case 'openai':
|
||||
return new OpenAIProvider({ apiKey, model });
|
||||
case 'openrouter':
|
||||
return new OpenRouterProvider({ apiKey, model });
|
||||
case 'gemini':
|
||||
return new GeminiProvider({ apiKey, model });
|
||||
case 'codex':
|
||||
return new CodexProvider({ model });
|
||||
case 'minimax':
|
||||
return new MiniMaxProvider({ apiKey, model });
|
||||
default:
|
||||
console.warn(`[LLM] Unknown provider "${provider}". LLM features disabled.`);
|
||||
return null;
|
||||
|
||||
51
lib/llm/minimax.mjs
Normal file
51
lib/llm/minimax.mjs
Normal file
@@ -0,0 +1,51 @@
|
||||
// MiniMax Provider — raw fetch, no SDK
|
||||
// Uses MiniMax's OpenAI-compatible Chat Completions API
|
||||
|
||||
import { LLMProvider } from './provider.mjs';
|
||||
|
||||
export class MiniMaxProvider extends LLMProvider {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
this.name = 'minimax';
|
||||
this.apiKey = config.apiKey;
|
||||
this.model = config.model || 'MiniMax-M2.5';
|
||||
}
|
||||
|
||||
get isConfigured() { return !!this.apiKey; }
|
||||
|
||||
async complete(systemPrompt, userMessage, opts = {}) {
|
||||
const res = await fetch('https://api.minimax.io/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
},
|
||||
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 || 60000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text().catch(() => '');
|
||||
throw new Error(`MiniMax 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
52
lib/llm/openrouter.mjs
Normal file
52
lib/llm/openrouter.mjs
Normal file
@@ -0,0 +1,52 @@
|
||||
// OpenRouter Provider — raw fetch, no SDK
|
||||
|
||||
import { LLMProvider } from './provider.mjs';
|
||||
|
||||
export class OpenRouterProvider extends LLMProvider {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
this.name = 'openrouter';
|
||||
this.apiKey = config.apiKey;
|
||||
this.model = config.model || 'openrouter/auto';
|
||||
}
|
||||
|
||||
get isConfigured() { return !!this.apiKey; }
|
||||
|
||||
async complete(systemPrompt, userMessage, opts = {}) {
|
||||
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
'HTTP-Referer': 'https://github.com/calesthio/Crucix',
|
||||
'X-Title': 'Crucix',
|
||||
},
|
||||
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 || 60000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text().catch(() => '');
|
||||
throw new Error(`OpenRouter 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
332
package-lock.json
generated
332
package-lock.json
generated
@@ -7,12 +7,221 @@
|
||||
"": {
|
||||
"name": "crucix",
|
||||
"version": "2.0.0",
|
||||
"license": "ISC",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"express": "^5.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=22",
|
||||
"npm": ">=10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"discord.js": "^14.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/builders": {
|
||||
"version": "1.13.1",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz",
|
||||
"integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@discordjs/formatters": "^0.6.2",
|
||||
"@discordjs/util": "^1.2.0",
|
||||
"@sapphire/shapeshift": "^4.0.0",
|
||||
"discord-api-types": "^0.38.33",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"ts-mixer": "^6.0.4",
|
||||
"tslib": "^2.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.11.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/collection": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz",
|
||||
"integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=16.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/formatters": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz",
|
||||
"integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"discord-api-types": "^0.38.33"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.11.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/rest": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz",
|
||||
"integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@discordjs/collection": "^2.1.1",
|
||||
"@discordjs/util": "^1.1.1",
|
||||
"@sapphire/async-queue": "^1.5.3",
|
||||
"@sapphire/snowflake": "^3.5.3",
|
||||
"@vladfrangu/async_event_emitter": "^2.4.6",
|
||||
"discord-api-types": "^0.38.16",
|
||||
"magic-bytes.js": "^1.10.0",
|
||||
"tslib": "^2.6.3",
|
||||
"undici": "6.21.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/rest/node_modules/@discordjs/collection": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
|
||||
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/util": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz",
|
||||
"integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"discord-api-types": "^0.38.33"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/ws": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz",
|
||||
"integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@discordjs/collection": "^2.1.0",
|
||||
"@discordjs/rest": "^2.5.1",
|
||||
"@discordjs/util": "^1.1.0",
|
||||
"@sapphire/async-queue": "^1.5.2",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@vladfrangu/async_event_emitter": "^2.2.4",
|
||||
"discord-api-types": "^0.38.1",
|
||||
"tslib": "^2.6.2",
|
||||
"ws": "^8.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.11.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/ws/node_modules/@discordjs/collection": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
|
||||
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/@sapphire/async-queue": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
|
||||
"integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=v14.0.0",
|
||||
"npm": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sapphire/shapeshift": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz",
|
||||
"integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v16"
|
||||
}
|
||||
},
|
||||
"node_modules/@sapphire/snowflake": {
|
||||
"version": "3.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz",
|
||||
"integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=v14.0.0",
|
||||
"npm": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vladfrangu/async_event_emitter": {
|
||||
"version": "2.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz",
|
||||
"integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=v14.0.0",
|
||||
"npm": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
@@ -156,6 +365,44 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/discord-api-types": {
|
||||
"version": "0.38.42",
|
||||
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.42.tgz",
|
||||
"integrity": "sha512-qs1kya7S84r5RR8m9kgttywGrmmoHaRifU1askAoi+wkoSefLpZP6aGXusjNw5b0jD3zOg3LTwUa3Tf2iHIceQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"workspaces": [
|
||||
"scripts/actions/documentation"
|
||||
]
|
||||
},
|
||||
"node_modules/discord.js": {
|
||||
"version": "14.25.1",
|
||||
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz",
|
||||
"integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@discordjs/builders": "^1.13.0",
|
||||
"@discordjs/collection": "1.5.3",
|
||||
"@discordjs/formatters": "^0.6.2",
|
||||
"@discordjs/rest": "^2.6.0",
|
||||
"@discordjs/util": "^1.2.0",
|
||||
"@discordjs/ws": "^1.2.3",
|
||||
"@sapphire/snowflake": "3.5.3",
|
||||
"discord-api-types": "^0.38.33",
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"lodash.snakecase": "4.1.1",
|
||||
"magic-bytes.js": "^1.10.0",
|
||||
"tslib": "^2.6.3",
|
||||
"undici": "6.21.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -273,6 +520,13 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
||||
@@ -451,6 +705,27 @@
|
||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/lodash.snakecase": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
|
||||
"integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/magic-bytes.js": {
|
||||
"version": "1.13.0",
|
||||
"resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz",
|
||||
"integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -788,6 +1063,20 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-mixer": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
|
||||
"integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||
@@ -802,6 +1091,23 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.24.4",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz",
|
||||
"integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
@@ -825,6 +1131,28 @@
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
14
package.json
14
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "crucix",
|
||||
"version": "2.0.0",
|
||||
"description": "Local intelligence engine — 26 OSINT sources, live dashboard, auto-refresh, optional LLM layer.",
|
||||
"description": "Local intelligence engine - 27 OSINT sources, live dashboard, public demo at crucix.live, auto-refresh, optional LLM layer.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.mjs",
|
||||
@@ -14,7 +14,12 @@
|
||||
"clean": "node scripts/clean.mjs",
|
||||
"fresh-start": "npm run clean && npm start"
|
||||
},
|
||||
"keywords": ["osint", "intelligence", "dashboard", "geopolitical"],
|
||||
"keywords": [
|
||||
"osint",
|
||||
"intelligence",
|
||||
"dashboard",
|
||||
"geopolitical"
|
||||
],
|
||||
"author": "Crucix",
|
||||
"license": "AGPL-3.0-only",
|
||||
"engines": {
|
||||
@@ -25,6 +30,7 @@
|
||||
"express": "^5.1.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"discord.js": "^14.25.0"
|
||||
}
|
||||
"discord.js": "^14.25.1" },
|
||||
"overrides": {
|
||||
"undici": "^7.24.4" }
|
||||
}
|
||||
|
||||
30
test/llm-minimax-integration.test.mjs
Normal file
30
test/llm-minimax-integration.test.mjs
Normal file
@@ -0,0 +1,30 @@
|
||||
// MiniMax provider — integration test (calls real API)
|
||||
// Requires MINIMAX_API_KEY environment variable
|
||||
// Run: MINIMAX_API_KEY=sk-... node --test test/llm-minimax-integration.test.mjs
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MiniMaxProvider } from '../lib/llm/minimax.mjs';
|
||||
|
||||
const API_KEY = process.env.MINIMAX_API_KEY;
|
||||
|
||||
describe('MiniMax integration', { skip: !API_KEY && 'MINIMAX_API_KEY not set' }, () => {
|
||||
it('should complete a prompt with MiniMax-M2.5', async () => {
|
||||
const provider = new MiniMaxProvider({ apiKey: API_KEY, model: 'MiniMax-M2.5' });
|
||||
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: 30000 }
|
||||
);
|
||||
|
||||
assert.ok(result.text.length > 0, 'Response text should not be empty');
|
||||
assert.ok(result.usage.inputTokens > 0, 'Should report input tokens');
|
||||
assert.ok(result.usage.outputTokens > 0, 'Should report output tokens');
|
||||
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}`);
|
||||
});
|
||||
});
|
||||
144
test/llm-minimax.test.mjs
Normal file
144
test/llm-minimax.test.mjs
Normal file
@@ -0,0 +1,144 @@
|
||||
// MiniMax 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 { MiniMaxProvider } from '../lib/llm/minimax.mjs';
|
||||
import { createLLMProvider } from '../lib/llm/index.mjs';
|
||||
|
||||
// ─── Unit Tests ───
|
||||
|
||||
describe('MiniMaxProvider', () => {
|
||||
it('should set defaults correctly', () => {
|
||||
const provider = new MiniMaxProvider({ apiKey: 'sk-test' });
|
||||
assert.equal(provider.name, 'minimax');
|
||||
assert.equal(provider.model, 'MiniMax-M2.5');
|
||||
assert.equal(provider.isConfigured, true);
|
||||
});
|
||||
|
||||
it('should accept custom model', () => {
|
||||
const provider = new MiniMaxProvider({ apiKey: 'sk-test', model: 'MiniMax-M2.5-highspeed' });
|
||||
assert.equal(provider.model, 'MiniMax-M2.5-highspeed');
|
||||
});
|
||||
|
||||
it('should report not configured without API key', () => {
|
||||
const provider = new MiniMaxProvider({});
|
||||
assert.equal(provider.isConfigured, false);
|
||||
});
|
||||
|
||||
it('should throw on API error', async () => {
|
||||
const provider = new MiniMaxProvider({ apiKey: 'sk-test' });
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = mock.fn(() =>
|
||||
Promise.resolve({ ok: false, status: 401, text: () => Promise.resolve('Unauthorized') })
|
||||
);
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => provider.complete('system', 'user'),
|
||||
(err) => {
|
||||
assert.match(err.message, /MiniMax API 401/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse successful response', async () => {
|
||||
const provider = new MiniMaxProvider({ apiKey: 'sk-test' });
|
||||
const mockResponse = {
|
||||
choices: [{ message: { content: 'Hello from MiniMax' } }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5 },
|
||||
model: 'MiniMax-M2.5',
|
||||
};
|
||||
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 MiniMax');
|
||||
assert.equal(result.usage.inputTokens, 10);
|
||||
assert.equal(result.usage.outputTokens, 5);
|
||||
assert.equal(result.model, 'MiniMax-M2.5');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('should send correct request format', async () => {
|
||||
const provider = new MiniMaxProvider({ apiKey: 'sk-test-key', model: 'MiniMax-M2.5' });
|
||||
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: 'MiniMax-M2.5',
|
||||
}),
|
||||
});
|
||||
});
|
||||
try {
|
||||
await provider.complete('system prompt', 'user message', { maxTokens: 2048 });
|
||||
assert.equal(capturedUrl, 'https://api.minimax.io/v1/chat/completions');
|
||||
assert.equal(capturedOpts.method, 'POST');
|
||||
const headers = capturedOpts.headers;
|
||||
assert.equal(headers['Content-Type'], 'application/json');
|
||||
assert.equal(headers['Authorization'], 'Bearer sk-test-key');
|
||||
const body = JSON.parse(capturedOpts.body);
|
||||
assert.equal(body.model, 'MiniMax-M2.5');
|
||||
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 MiniMaxProvider({ apiKey: 'sk-test' });
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Factory Tests ───
|
||||
|
||||
describe('createLLMProvider — minimax', () => {
|
||||
it('should create MiniMaxProvider for provider=minimax', () => {
|
||||
const provider = createLLMProvider({ provider: 'minimax', apiKey: 'sk-test', model: null });
|
||||
assert.ok(provider instanceof MiniMaxProvider);
|
||||
assert.equal(provider.name, 'minimax');
|
||||
assert.equal(provider.isConfigured, true);
|
||||
});
|
||||
|
||||
it('should be case-insensitive', () => {
|
||||
const provider = createLLMProvider({ provider: 'MiniMax', apiKey: 'sk-test', model: null });
|
||||
assert.ok(provider instanceof MiniMaxProvider);
|
||||
});
|
||||
|
||||
it('should return null for empty provider', () => {
|
||||
const provider = createLLMProvider({ provider: null, apiKey: 'sk-test', model: null });
|
||||
assert.equal(provider, null);
|
||||
});
|
||||
});
|
||||
17
test/llm-openrouter-integration.test.mjs
Normal file
17
test/llm-openrouter-integration.test.mjs
Normal file
@@ -0,0 +1,17 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createLLMProvider } from '../lib/llm/index.mjs';
|
||||
|
||||
test('OpenRouterProvider Integration Test', { skip: !process.env.LLM_API_KEY || process.env.LLM_PROVIDER !== 'openrouter' }, async (t) => {
|
||||
await t.test('Performs live API call', async () => {
|
||||
const provider = createLLMProvider({
|
||||
provider: 'openrouter',
|
||||
apiKey: process.env.LLM_API_KEY,
|
||||
model: process.env.LLM_MODEL || 'openrouter/auto'
|
||||
});
|
||||
|
||||
const result = await provider.complete('Reply with exactly "Hello".', 'Hi');
|
||||
assert.ok(result.text.length > 0, 'Should return text');
|
||||
assert.ok(result.usage.inputTokens > 0, 'Should return input token usage');
|
||||
});
|
||||
});
|
||||
90
test/llm-openrouter.test.mjs
Normal file
90
test/llm-openrouter.test.mjs
Normal file
@@ -0,0 +1,90 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { OpenRouterProvider } from '../lib/llm/openrouter.mjs';
|
||||
import { createLLMProvider } from '../lib/llm/index.mjs';
|
||||
|
||||
test('OpenRouterProvider Unit Tests', async (t) => {
|
||||
await t.test('initializes correctly', () => {
|
||||
const provider = new OpenRouterProvider({ apiKey: 'test-key', model: 'openrouter/auto' });
|
||||
assert.equal(provider.name, 'openrouter');
|
||||
assert.equal(provider.apiKey, 'test-key');
|
||||
assert.equal(provider.model, 'openrouter/auto');
|
||||
assert.equal(provider.isConfigured, true);
|
||||
});
|
||||
|
||||
await t.test('isConfigured is false without apiKey', () => {
|
||||
const provider = new OpenRouterProvider({ apiKey: null });
|
||||
assert.equal(provider.isConfigured, false);
|
||||
});
|
||||
|
||||
await t.test('createLLMProvider factory returns OpenRouterProvider', () => {
|
||||
const provider = createLLMProvider({ provider: 'openrouter', apiKey: 'test-key', model: 'test-model' });
|
||||
assert.ok(provider instanceof OpenRouterProvider);
|
||||
assert.equal(provider.apiKey, 'test-key');
|
||||
assert.equal(provider.model, 'test-model');
|
||||
});
|
||||
|
||||
await t.test('complete() returns expected result', async () => {
|
||||
const provider = new OpenRouterProvider({ apiKey: 'test-key', model: 'test-model' });
|
||||
|
||||
// Mock the global fetch
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async (url, options) => {
|
||||
assert.equal(url, 'https://openrouter.ai/api/v1/chat/completions');
|
||||
assert.equal(options.headers['Authorization'], 'Bearer test-key');
|
||||
assert.equal(options.headers['X-Title'], 'Crucix');
|
||||
assert.equal(options.headers['HTTP-Referer'], 'https://github.com/calesthio/Crucix');
|
||||
|
||||
const body = JSON.parse(options.body);
|
||||
assert.equal(body.model, 'test-model');
|
||||
assert.deepEqual(body.messages, [
|
||||
{ role: 'system', content: 'You are a test.' },
|
||||
{ role: 'user', content: 'Hello' }
|
||||
]);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{ message: { content: 'Test response' } }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5 },
|
||||
model: 'test-model'
|
||||
})
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await provider.complete('You are a test.', 'Hello');
|
||||
assert.equal(result.text, 'Test response');
|
||||
assert.deepEqual(result.usage, { inputTokens: 10, outputTokens: 5 });
|
||||
assert.equal(result.model, 'test-model');
|
||||
} finally {
|
||||
// Restore original fetch
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
await t.test('complete() throws error on API failure', async () => {
|
||||
const provider = new OpenRouterProvider({ apiKey: 'test-key', model: 'test-model' });
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async () => {
|
||||
return {
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: async () => 'Unauthorized access'
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
provider.complete('system', 'user'),
|
||||
{
|
||||
name: 'Error',
|
||||
message: 'OpenRouter API 401: Unauthorized access'
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user