init: Atlas OS — open-source patterns + tools for solo founder LLM cluster

3 browser-native chat surfaces (atlas-chat / atlas-hud / atlas-command, no build step)
+ 11 Python automation scripts (cluster, tray, finance, reminders, autonomy loops)
+ 2 patterns documented (cluster-then-polish, anti-impulse R1-R10)
MIT licensed, anonymized from production stack.
This commit is contained in:
Chaib Aarab 2026-05-26 16:21:16 +02:00
commit 301cc75de9
19 changed files with 5372 additions and 0 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
__pycache__/
*.pyc
.env
*.log
.DS_Store
Thumbs.db
node_modules/

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Atlas Corporation B.V.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

121
README.md Normal file
View file

@ -0,0 +1,121 @@
# 🛰️ Atlas OS
> Open-source patterns + tools for solo founders running a multi-node local LLM cluster + an own AI-first workspace.
Built by [Atlas Corporation](https://atlascorporation.nl) — extracted from a working production stack. MIT licensed.
## What's inside
### Browser-native LLM surfaces (no build step, no React)
| File | What | Lines |
|---|---|---|
| `web/atlas-chat.html` | Mobile-first chat UI for Ollama clusters + optional Claude API. Streaming, localStorage history, multi-model picker, Vault RAG toggle | ~520 |
| `web/atlas-hud.html` | Iron Man-style ambient HUD: camera feed + face detection (face-api.js) + circular audio waveform + Web Speech API ASR/TTS | ~600 |
| `web/atlas-command.html` | Unified cockpit: 6 tabs (Chat / Brief / Buy? / Autonomy / HUD / Portal). Live cluster + finance + strategy + R10 cards | ~800 |
Drop in any web server. Single-file each. No npm install.
### Python automation
| Script | Purpose |
|---|---|
| `scripts/cluster.py` | Multi-node Ollama orchestrator (task → node → model routing, fallback chain) |
| `scripts/cluster_then_polish.py` | Two-pass LLM pattern: cluster generates draft → flagged `polish_status: pending` |
| `scripts/atlas_self_prompting_loop.py` | Self-perpetuating autonomy: 8 topic templates × N nodes |
| `scripts/atlas_tray.py` | pystray system tray icon — color-coded cluster health (polls every 30s) |
| `scripts/atlas_seasonal_strategy.py` | NL fiscal calendar awareness (vakantiegeld, BTW deadlines, schoolvakantie, Sint/Kerst) |
| `scripts/atlas_finance_export.py` | NL freelancer Inkomsten xlsx parser (Betaalcontrole sheet pattern) → JSON |
| `scripts/atlas_reminders.py` | Burden-remover daily schtask: monthly Inkomsten + quarterly BTW + savings + IB prep |
| `scripts/atlas_activity_export.py` | ActivityWatch → R10 hours ratio (sales vs infra) for daily/weekly discipline |
| `scripts/atlas_autonomy_export.py` | Expose autonomy loop state via JSON for live dashboards |
| `scripts/vault_rag_expand.py` | Batch upload Markdown to Open WebUI Knowledge Base via API |
| `scripts/atlas_lead_enrich.py` | Per-prospect personalized cold-WhatsApp generator (NL sectors) |
### Patterns documented
- `patterns/CLUSTER_THEN_POLISH.md` — Local 7B-8B for volume, Claude for signal
- `patterns/ANTI_IMPULSE_RULES.md` — R1-R10 cash-before-code rules
## Why this exists
After 12 months of solo founder building, the patterns that actually survived. Specifically for the founder who:
- Runs Ollama or similar local LLM (on PC + VPS + secondary desktop)
- Wants Atlas-style live dashboard, not enterprise BI
- Uses Obsidian as second brain
- Needs Dutch fiscal calendar awareness
- Hates WordPress + Elementor for AI-edit-friendly new builds
- Wants "cash before code" enforced by automation, not willpower
## Install
```bash
git clone https://git.atlascorporation.nl/chaib/atlas-os.git
cd atlas-os
```
### Web surfaces
Serve any `web/*.html` via Caddy / nginx / Cloudflare Pages. Each is self-contained (CDN-loaded marked.js + DOMPurify).
### Python tools
```bash
cd scripts
pip install -r requirements.txt # pystray, pillow, openpyxl
# Run individual scripts; each documents its env vars
```
## Configuration
Each tool has `<YOUR_*>` placeholders. Replace with your:
- VPS IP (Hetzner / DigitalOcean / wherever Ollama runs always-on)
- Desktop node IP (your secondary GPU machine)
- PC node IP (your daily-driver localhost or tailnet IP)
- Open WebUI JWT (for Vault RAG queries)
- Knowledge Base ID
- SMTP credentials (for reminder emails)
- Domain
See individual file headers.
## Architecture (the actual production setup)
```
Operator PC (Windows)
- Ollama (GPU) — ~10 models
- Atlas Command HTML served via Caddy on VPS
- Atlas Tray Python (taskbar — always visible)
- atlas_self_prompting_loop schtask (every 15 min, 12h windows)
|
v Tailscale mesh (encrypted overlay)
|
VPS (Hetzner CPX32 — always-on)
- Ollama (CPU) — 5 small/medium models
- Caddy reverse proxy
- Open WebUI (Vault RAG, multi-tenant chats)
- Odoo / Nextcloud / Vaultwarden / MeshCentral / Forgejo (this repo!)
|
v
Secondary desktop (GPU node)
- Ollama — Llama 3.1 8B + smaller
```
Total monthly hosting cost: ~€25 (VPS) + electricity for PC + desktop. No SaaS subscriptions.
## License
MIT. Use freely. Attribution appreciated but not required.
## Roadmap (PRs welcome)
- Astro + Decap CMS template for AI-edit-friendly websites
- OpenWakeWord OS-level voice trigger (replaces browser-tab requirement)
- Multi-tenant pattern for solo agencies running N client sites
- Local sentence-transformer embeddings (replace Open WebUI dependency)
- Cluster-of-experts pattern for narrative tasks (rotate prompt across nodes, vote on best)
## Credits
Built by [@chaib](https://atlascorporation.nl) · Tilburg, NL · 2025-2026.
Patterns surfaced through 8 months of daily live use + ruthless removal of what didn't earn its keep.
> One person, one stack, one workspace. Atlas because it carries the load.

View file

@ -0,0 +1,98 @@
# Pattern: Anti-impulse Decision Rules (R1-R10)
> Rules that prevent the operator-over-builds-instead-of-sells pattern.
For solo founders who code, the temptation is always to build instead of sell. These rules, enforced by automation, fix that.
## The rules
### R1. Hardware lot purchase guard
Do NOT buy a refurbished hardware lot if ANY of:
- Existing inventory > 8 units of same category
- Pre-sales coverage < 50% (i.e., orders waiting on stock)
- Last 5 lots averaged > 60 days on shelf
### R2. Pre-purchase justification (write before clicking buy)
1. Why this lot? (specific clients waiting? Or "looked cheap"?)
2. Projected sell-through (units × weeks to clear)
3. Holding cost (3%/mo capital tied + storage)
4. GO / NO-GO: if projected net ROI < 30% OR sell-through > 90 days → NO
### R3. Frequency cap
Max 1 lot purchase per week. New opportunity? Wait until next Monday.
### R4. Client acceptance
Accept new client only if EITHER:
- Recurring potential ≥ €100/mo (3-month commit), OR
- One-time margin ≥ €500, OR
- Strategic referral source (unlocks network)
### R5. Minimum billable rate
€75/hour for ad-hoc work. Below that, decline politely.
### R6. Daily time budget
- 4h cash generation (outreach, calls, quotes, sales conversion)
- 2h delivery (existing client work)
- 1h admin (invoicing, follow-up)
- 1h learning/infra (NOT during cash window)
### R7. Morning protection
**No infra/code work before 14:00 on weekdays.** Morning = cash + delivery only.
### R8. Build justification
Before building anything new, ask: "Which paying client needs this RIGHT NOW?"
- Unclear → do not build.
- Clear → log it as a feature request with client name + proceed.
### R9. Use-it-or-archive
If a tool isn't used by operator OR a client within 2 weeks of building, archive it.
### R10. Cash before code (umbrella)
Every Friday: if sales hours < infra hours that week, the week failed and resets next Monday. No exceptions.
## Enforcement via automation
### R10 — ActivityWatch + Friday-Wrap email
`scripts/atlas_activity_export.py` reads ActivityWatch event log, classifies into 6 categories:
- sales (Asana, WhatsApp Web, mail compose, Atlas Chat/Command/HUD)
- delivery (VSCode/Claude on existing-client work, WordPress admin)
- infra (Claude Code, terminal, GitHub, Docker, config files)
- admin (Inkomsten xlsx, Odoo, bank statements)
- learning (YouTube courses, BiSL/certification material)
- other
Output JSON includes `r10_pass` boolean. Friday-Wrap email shows verdict.
### R1-R3 — Browser calculator
`web/atlas-command.html` has a "BUY?" tab. Inputs: lot price, units, category, pre-sales %. Outputs: GO/NO-GO verdict + holding cost + net ROI calculation per category baselines.
### R5/R8 — Atlas Reminders
`scripts/atlas_reminders.py` daily schtask. Triggers conditional emails:
- 26th of month → savings pots check
- 14d + 3d before BTW deadlines
- 1st of month → Inkomsten xlsx update reminder
- 5 juni + 25 nov → vacation booking reminder
- 1 mrt + 15 apr → IB aangifte prep
## Why these rules
After 12 months of building infrastructure that produced €5K annual revenue (vault: 200+ project files, ~15 paid client events = 13:1 build:sell ratio), the founder needed rules that prevent willpower-based discipline.
Rules survive willpower. Automation survives both.
## Customize
The numbers (€100/mo, €500 margin, 4h/2h/1h/1h budget) are Atlas-specific. Adjust to your context. The structure (one umbrella + nine specific rules + automated enforcement) is the transferable pattern.

View file

@ -0,0 +1,71 @@
# Pattern: Cluster-then-Polish
> Local 7B-8B models generate volume. Claude / human polishes signal.
## Problem
Open-source LLMs (qwen2.5:7b, llama3.1:8b, hermes3:8b on consumer hardware) are good at:
- Classification / categorization
- Embeddings / RAG retrieval
- Code completion (within trained languages)
- Structured output (JSON, tables)
- Triage of inbox / queues at volume
They are **bad** at:
- Narrative copywriting for clients
- Strategic analysis without grounded data
- Tone / voice consistency
- Factual claims — they will hallucinate numbers, dates, names
- Anything where wrong output sent externally has cost
## Pattern
1. Cluster generates a first draft with explicit `polish_status: pending` YAML frontmatter
2. Output saved as Markdown file with full metadata (node, model, elapsed_s, prompt, draft_id)
3. Polish review queue tracked in `~/.config/atlas/drafts/_registry.json`
4. Operator OR a stronger model (Claude, GPT-4) reviews + edits + flips frontmatter to `polish_status: polished`
5. Only `polished` items are usable externally (sent to clients, published, etc.)
## Reference implementation
`scripts/cluster_then_polish.py` — simple wrapper around any Ollama-compatible HTTP API.
```python
from cluster_then_polish import draft, save_draft
d = draft(
prompt="Schrijf cold-WhatsApp voor Tilburg lunchroom",
task='chat',
node_pref='pc',
purpose='cold_outreach',
)
save_draft(d, '~/.config/atlas/drafts/lunchroom_outreach_001.md')
# operator reviews -> mark_polished(d['draft_id'])
```
## Real-world data (production)
After 7.5 hours of perpetual loop across 3 nodes:
- 1172 autonomy outputs generated
- ~5-10% immediately useful (operator polishes light)
- ~30% spark a useful follow-up question
- ~60% are noise (still saved for pattern detection later)
- 0% should be sent externally without review
The pattern is not "less work" — it is "different work". Operator becomes editor, not writer. The cluster does the cold-start text generation that's the worst part of writing.
## When NOT to use this pattern
- When you have Claude API access and the task is small enough to just call Claude directly
- When the output must be perfect (legal documents, financial figures, contracts)
- When you don't have a polish step set up — generating drafts you never review is worse than no drafts
## Anti-pattern to avoid
Don't trust cluster output because it "looks confident". 7B models will produce well-formatted Dutch with hallucinated company names, wrong euro amounts, invented words. Production example:
> qwen2.5:7b output: "Bisogno's marktshare neemt toe met €4.578 miljoen, Inbrengking bleef stabiel"
>
> Reality: revenue is €4,578 (not millions), "Inbrengking" is not a Dutch word, "marktshare" was confused with revenue concentration percentage.
The `polish_status: pending` flag exists because of this exact failure mode.

View file

@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""
atlas_activity_export.py - Query ActivityWatch API + classify into Atlas categories.
Outputs atlas-activity.json (today summary), pushed to atlas-01 dashboard.
Categories:
sales Asana, WhatsApp Web, Outlook compose, Atlas Chat/Command/HUD
delivery VSCode/Claude on existing-client work, atlasworks.nl wp-admin
infra Claude Code, terminal, github, docker, atlas-* configs
admin Inkomsten xlsx, Odoo, Knab/ING/Revolut, MEGA folder
learning YouTube, BiSL/IT material, Obsidian vault read
other everything else
Run daily via Atlas-Activity-Export schtask + before Friday-Wrap.
"""
import json
import sys
import subprocess
import urllib.request
import urllib.error
from datetime import datetime, timedelta
from pathlib import Path
from collections import defaultdict
AW_URL = "http://localhost:5600"
OUT_LOCAL = Path(r"C:\Users\Gebruiker\AppData\Local\Atlas\analyses\atlas-activity.json")
SSH = "root@<YOUR_VPS_IP>"
REMOTE = "/opt/atlas/dashboard/atlas-activity.json"
# Category classifiers (keyword in title or app)
CATEGORIES = {
"sales": [
"asana", "whatsapp", "atlas chat", "atlas command", "atlas hud",
"outlook.com", "mail.atlasworks", "compose", "messenger",
"instagram.com/direct", "linkedin.com/messag",
],
"delivery": [
"wp-admin", "atlasworks.nl/", "couscousbar", "charif", "bisogno",
"le miris", "kolvari", "dushi", "al mahaba", "kapper", "barber",
"elementor", "woocommerce",
],
"infra": [
"claude code", "claude.ai/", "cmd", "powershell", "terminal", "ms-terminal",
"git", "github", "vscode", "code -",
"docker", "atlas-01", "ssh ", "127.0.0.1", "tailscale",
".py - ", ".ps1 - ", "caddy", "ollama",
],
"admin": [
"inkomsten", "odoo", "knab", "ing.nl", "revolut", "mega",
"vaultwarden", "factuur", "rekeningafschrift", "boekhoud",
],
"learning": [
"youtube.com/watch", "bisl", "udemy", "coursera",
"obsidian", "atlas wiki", "00_atlas",
],
}
def fetch(url):
try:
with urllib.request.urlopen(url, timeout=5) as r:
return json.loads(r.read().decode("utf-8", errors="replace"))
except Exception:
return None
def classify(text):
t = (text or "").lower()
for cat, keywords in CATEGORIES.items():
for kw in keywords:
if kw in t:
return cat
return "other"
def get_today_events():
"""Pull today's window events from aw-watcher-window bucket."""
buckets = fetch(f"{AW_URL}/api/0/buckets/") or {}
win_bucket = next((k for k in buckets if "aw-watcher-window" in k), None)
if not win_bucket: return []
now = datetime.now()
start = datetime(now.year, now.month, now.day).isoformat()
end = now.isoformat()
url = f"{AW_URL}/api/0/buckets/{win_bucket}/events?start={start}&end={end}&limit=10000"
return fetch(url) or []
def get_afk_events():
"""Pull today's AFK events to filter non-active time."""
buckets = fetch(f"{AW_URL}/api/0/buckets/") or {}
afk_bucket = next((k for k in buckets if "aw-watcher-afk" in k), None)
if not afk_bucket: return []
now = datetime.now()
start = datetime(now.year, now.month, now.day).isoformat()
end = now.isoformat()
url = f"{AW_URL}/api/0/buckets/{afk_bucket}/events?start={start}&end={end}&limit=10000"
return fetch(url) or []
def aggregate():
win_events = get_today_events()
afk_events = get_afk_events()
# Build active timeline from AFK (only count non-afk windows)
active_intervals = []
for e in afk_events:
if e.get("data", {}).get("status") == "not-afk":
start = datetime.fromisoformat(e["timestamp"].replace("Z", "+00:00"))
dur = e.get("duration", 0)
active_intervals.append((start, start + timedelta(seconds=dur)))
by_cat = defaultdict(float)
by_app = defaultdict(float)
by_title_top = defaultdict(float)
for e in win_events:
t = datetime.fromisoformat(e["timestamp"].replace("Z", "+00:00"))
dur = e.get("duration", 0)
if dur < 1: continue
# Check if this event is during active (non-afk) time — approximate
is_active = any(s <= t <= en for s, en in active_intervals)
if not is_active: continue
data = e.get("data", {})
app = (data.get("app") or "?").lower()
title = data.get("title", "")
combined = f"{app} {title}"
cat = classify(combined)
by_cat[cat] += dur
by_app[app] += dur
by_title_top[title[:80]] += dur
total = sum(by_cat.values())
cat_pct = {c: round(s / total * 100, 1) if total else 0 for c, s in by_cat.items()}
top_apps = sorted(by_app.items(), key=lambda x: x[1], reverse=True)[:5]
top_titles = sorted(by_title_top.items(), key=lambda x: x[1], reverse=True)[:5]
return {
"generated_at": datetime.now().isoformat(timespec="seconds"),
"date": datetime.now().date().isoformat(),
"active_total_hours": round(total / 3600, 2),
"by_category_seconds": {c: int(s) for c, s in by_cat.items()},
"by_category_pct": cat_pct,
"top_apps": [{"app": a, "hours": round(s / 3600, 2)} for a, s in top_apps],
"top_titles": [{"title": t, "hours": round(s / 3600, 2)} for t, s in top_titles],
# Rule R10 check
"r10_sales_hours": round(by_cat.get("sales", 0) / 3600, 2),
"r10_infra_hours": round(by_cat.get("infra", 0) / 3600, 2),
"r10_pass": by_cat.get("sales", 0) >= by_cat.get("infra", 0),
}
def main():
summary = aggregate()
OUT_LOCAL.parent.mkdir(parents=True, exist_ok=True)
OUT_LOCAL.write_text(json.dumps(summary, indent=2), encoding="utf-8")
print(f"wrote {OUT_LOCAL}")
print(f" active: {summary['active_total_hours']}h today")
print(f" by category: {summary['by_category_pct']}")
print(f" R10 (sales >= infra): {'PASS' if summary['r10_pass'] else 'FAIL'} ({summary['r10_sales_hours']}h sales vs {summary['r10_infra_hours']}h infra)")
try:
subprocess.run(["scp", str(OUT_LOCAL), f"{SSH}:{REMOTE}"], check=True, timeout=20)
print(f"pushed to {SSH}:{REMOTE}")
except Exception as e:
print(f"scp failed: {e}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""
atlas_autonomy_export.py - Expose autonomy loop state via JSON for live dashboard.
Reads:
- autonomy_queue.json (pending/completed)
- autonomy_loop.log (last 50 lines)
- vault/00_Atlas_Wiki/_autonomy/*.md (last 20 outputs)
Writes:
- atlas-autonomy.json consumed by Atlas Command AUTONOMY tab
Runs: every 1 min via Atlas-Autonomy-Export schtask (lightweight).
"""
import json
import sys
import subprocess
from pathlib import Path
from datetime import datetime
import io
if sys.stdout.encoding != 'utf-8':
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
QUEUE = Path(r"C:\Users\Gebruiker\.config\atlas\autonomy_queue.json")
LOG = Path(r"C:\Users\Gebruiker\.config\atlas\autonomy_loop.log")
AUTO = Path(r"C:\Users\Gebruiker\MEGA\Atlas Corporation\00_Atlas_Wiki\_autonomy")
OUT = Path(r"C:\Users\Gebruiker\AppData\Local\Atlas\analyses\atlas-autonomy.json")
SSH = "root@<YOUR_VPS_IP>"
REMOTE = "/opt/atlas/dashboard/atlas-autonomy.json"
def parse_frontmatter(text):
"""Extract simple YAML frontmatter (between --- markers)."""
if not text.startswith("---"):
return {}, text
end = text.find("---", 3)
if end == -1:
return {}, text
yaml_block = text[3:end].strip()
body = text[end+3:].strip()
fm = {}
for line in yaml_block.split("\n"):
if ":" in line:
k, v = line.split(":", 1)
fm[k.strip()] = v.strip()
return fm, body
def main():
state = {
"generated_at": datetime.now().isoformat(timespec="seconds"),
"loop_running": False,
"queue_pending": 0,
"queue_completed": 0,
"log_tail": [],
"recent_outputs": [],
"next_fire": None,
"task_state": None,
}
# Queue
if QUEUE.exists():
try:
q = json.loads(QUEUE.read_text(encoding="utf-8"))
state["queue_pending"] = len(q.get("pending", []))
state["queue_completed"] = len(q.get("completed", []))
if q.get("completed"):
last = q["completed"][-1]
state["last_completed"] = {
"topic": last.get("topic"),
"node": last.get("node"),
"model": last.get("model"),
"completed_at": last.get("completed_at"),
"elapsed_s": last.get("elapsed_s"),
}
except Exception as e:
state["queue_err"] = str(e)[:100]
# Log tail
if LOG.exists():
try:
lines = LOG.read_text(encoding="utf-8", errors="replace").splitlines()
state["log_tail"] = lines[-50:]
except Exception:
pass
# Schtask state
try:
r = subprocess.run(
["powershell.exe", "-NoProfile", "-Command",
"$t=Get-ScheduledTask -TaskName Atlas-Autonomy-Hourly -ErrorAction SilentlyContinue;"
"if ($t) { $i=Get-ScheduledTaskInfo $t; "
"Write-Output ('STATE:' + $t.State + '|LAST:' + $i.LastRunTime + '|NEXT:' + $i.NextRunTime + '|RESULT:' + $i.LastTaskResult) }"],
capture_output=True, text=True, timeout=10
)
out = r.stdout.strip()
if out.startswith("STATE:"):
parts = dict(p.split(":", 1) for p in out[6:].split("|"))
state["task_state"] = "STATE:" + out[6:]
state["loop_running"] = (parts.get("STATE") != "Disabled")
state["next_fire"] = parts.get("NEXT")
except Exception as e:
state["task_state_err"] = str(e)[:100]
# Recent outputs (last 20 by mtime)
if AUTO.exists():
try:
md_files = sorted(AUTO.glob("*.md"), key=lambda p: p.stat().st_mtime, reverse=True)[:20]
for p in md_files:
try:
text = p.read_text(encoding="utf-8", errors="replace")
fm, body = parse_frontmatter(text)
state["recent_outputs"].append({
"filename": p.name,
"mtime": datetime.fromtimestamp(p.stat().st_mtime).isoformat(timespec="seconds"),
"topic": fm.get("topic", "?"),
"node": fm.get("node", "?"),
"model": fm.get("model", "?"),
"elapsed_s": fm.get("elapsed_s", "?"),
"polish_status": fm.get("polish_status", "?"),
"body_preview": body[:400].replace("\n", " ").strip(),
})
except Exception:
pass
except Exception as e:
state["outputs_err"] = str(e)[:100]
state["recent_output_count"] = len(state["recent_outputs"])
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"wrote {OUT}")
print(f" pending={state['queue_pending']} completed={state['queue_completed']} outputs={state['recent_output_count']}")
try:
subprocess.run(["scp", str(OUT), f"{SSH}:{REMOTE}"], check=True, timeout=20)
print(f"pushed to {SSH}:{REMOTE}")
except Exception as e:
print(f"scp fail: {e}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""
atlas_finance_export.py - Parse Inkomsten xlsx + compute YTD/MRR/concentration,
output atlas-finance.json and scp to atlas-01 dashboard.
Run manually or via schtask (Atlas-Finance-Export, daily).
"""
import json
import sys
import time
import subprocess
from pathlib import Path
from datetime import datetime, date
from collections import defaultdict
try:
import openpyxl
except ImportError:
print("openpyxl required"); sys.exit(1)
XLSX = Path(r"C:\Users\Gebruiker\MEGA\Atlas Corporation\07_Finance\2026\Inkomsten uit onderneming 2026.xlsx")
OUT_LOCAL = Path(r"C:\Users\Gebruiker\AppData\Local\Atlas\analyses\atlas-finance.json")
SSH = "root@<YOUR_VPS_IP>"
REMOTE = "/opt/atlas/dashboard/atlas-finance.json"
DUTCH_MONTH = {
"jan": 1, "januari": 1,
"febr": 2, "feb": 2, "februari": 2,
"mrt": 3, "maart": 3,
"apr": 4, "april": 4,
"mei": 5,
"juni": 6, "jun": 6,
"juli": 7, "jul": 7,
"aug": 8, "augustus": 8,
"sept": 9, "sep": 9, "september": 9,
"okt": 10, "oktober": 10,
"nov": 11, "november": 11,
"dec": 12, "december": 12,
}
def parse_xlsx(p):
"""Read Betaalcontrole sheet — canonical paid-invoices list with bank-verified amounts."""
wb = openpyxl.load_workbook(p, data_only=True, read_only=True)
if "Betaalcontrole" not in wb.sheetnames:
return []
sheet = wb["Betaalcontrole"]
rows = []
headers = None
for row in sheet.iter_rows(values_only=True):
if not headers:
headers = [str(c).strip().lower() if c else "" for c in row]
continue
if not any(row): continue
d = dict(zip(headers, row))
rows.append(d)
return rows
def to_float(v):
if v is None: return 0.0
if isinstance(v, (int, float)): return float(v)
s = str(v).replace("", "").replace(",", ".").replace(" ", "").strip()
try: return float(s)
except: return 0.0
def to_date(v):
if isinstance(v, (datetime, date)): return v if isinstance(v, date) and not isinstance(v, datetime) else v.date()
if isinstance(v, str):
for fmt in ("%Y-%m-%d", "%d-%m-%Y", "%d/%m/%Y", "%Y/%m/%d"):
try: return datetime.strptime(v.strip(), fmt).date()
except: continue
return None
def normalize(rows):
"""Normalize Betaalcontrole rows: factuur, maand in rapport, klant, excl btw, incl/bankbedrag, betaalbewijs, actie."""
out = []
for r in rows:
# Skip rows without invoice number (= template/blank)
factuur = (r.get("factuur") or "").strip() if r.get("factuur") else ""
if not factuur or not factuur.startswith("INV"):
continue
# Skip rows marked as not paid or cancelled
actie = (r.get("actie") or "").lower()
if actie and ("niet" in actie or "geannul" in actie or "credit" in actie or "open" in actie):
continue
month_str = (r.get("maand in rapport") or "").strip().lower()
month_num = DUTCH_MONTH.get(month_str)
if not month_num: continue
# Build date as 15th of month (since exact paid date may not be in xlsx — bewijs column has it)
from datetime import date as _date
d = _date(2026, month_num, 15)
client = (r.get("klant") or "?").strip()[:60]
# Use "incl/bankbedrag" (actual money received), fall back to excl btw
amount = to_float(r.get("incl/bankbedrag") or r.get("excl btw"))
if amount <= 0: continue
out.append({
"date": d.isoformat(),
"month_num": month_num,
"factuur": factuur,
"client": client,
"amount": round(amount, 2),
})
return out
def aggregate(payments):
"""Compute YTD, monthly, top clients, MRR estimate."""
today = date.today()
ytd = 0
by_month = defaultdict(float)
by_client = defaultdict(float)
by_client_count = defaultdict(int)
last_3mo_by_client = defaultdict(float)
for p in payments:
d = datetime.fromisoformat(p["date"]).date()
if d.year != today.year: continue
ytd += p["amount"]
by_month[d.strftime("%Y-%m")] += p["amount"]
by_client[p["client"]] += p["amount"]
by_client_count[p["client"]] += 1
days_ago = (today - d).days
if days_ago <= 90:
last_3mo_by_client[p["client"]] += p["amount"]
sorted_clients = sorted(by_client.items(), key=lambda x: x[1], reverse=True)
top_5 = sorted_clients[:5]
total_top = sum(a for _, a in top_5)
concentration = round(top_5[0][1] / ytd * 100, 1) if top_5 and ytd > 0 else 0
# MRR estimate: clients who paid 2+ times last 3 months
mrr_eligible = {k: v for k, v in last_3mo_by_client.items() if by_client_count[k] >= 2}
mrr_est = round(sum(mrr_eligible.values()) / 3, 2) if mrr_eligible else 0
months_in_year = today.month
avg_per_month = round(ytd / months_in_year, 2) if months_in_year else 0
return {
"generated_at": datetime.now().isoformat(timespec="seconds"),
"as_of": today.isoformat(),
"ytd_eur": round(ytd, 2),
"ytd_count": sum(by_client_count.values()),
"unique_clients_ytd": len(by_client),
"avg_per_month_eur": avg_per_month,
"current_month_eur": round(by_month.get(today.strftime("%Y-%m"), 0), 2),
"by_month": dict(sorted(by_month.items())),
"top_5_clients": [{"name": k, "amount": round(v, 2), "share_pct": round(v/ytd*100, 1) if ytd else 0} for k, v in top_5],
"concentration_top_pct": concentration,
"mrr_estimate_eur": mrr_est,
"mrr_eligible_clients": len(mrr_eligible),
}
def main():
if not XLSX.exists():
print(f"missing: {XLSX}"); sys.exit(1)
rows = parse_xlsx(XLSX)
payments = normalize(rows)
print(f"parsed {len(rows)} rows, {len(payments)} usable payments")
summary = aggregate(payments)
OUT_LOCAL.parent.mkdir(parents=True, exist_ok=True)
OUT_LOCAL.write_text(json.dumps(summary, indent=2), encoding="utf-8")
print(f"wrote {OUT_LOCAL}")
print(f" ytd: €{summary['ytd_eur']:.2f} ({summary['ytd_count']} payments)")
print(f" unique clients: {summary['unique_clients_ytd']}")
print(f" top client: {summary['top_5_clients'][0]['name'] if summary['top_5_clients'] else '?'} ({summary['concentration_top_pct']}%)")
print(f" MRR est: €{summary['mrr_estimate_eur']}")
# scp to atlas-01
try:
subprocess.run(["scp", str(OUT_LOCAL), f"{SSH}:{REMOTE}"], check=True, timeout=20)
print(f"pushed to {SSH}:{REMOTE}")
except Exception as e:
print(f"scp failed: {e}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""
atlas_lead_enrich.py Per-prospect WhatsApp message generator.
Use:
atlas_lead_enrich.py "Lunchroom Carrousel" "Oude Markt" --sector lunchroom --ig "ze posten dagmenu's"
Generates a personalized WhatsApp message using cluster_then_polish pattern.
Output: text printed + saved to ~/.config/atlas/drafts/lead_<slug>.md
"""
import sys
import re
import argparse
import io
from pathlib import Path
from datetime import datetime
if sys.stdout.encoding != 'utf-8':
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.path.insert(0, r"C:\Users\Gebruiker\.config\atlas")
try:
from cluster_then_polish import draft, save_draft
except ImportError:
print("Need cluster_then_polish.py — see ~/.config/atlas/")
sys.exit(1)
SECTOR_TEMPLATES = {
"lunchroom": {
"package": "Werkplek STARTER €799",
"pain": "bonnetjes tellen 4-5u/wk",
"ref": "Couscousbar",
},
"koffiebar": {
"package": "Werkplek STARTER €799",
"pain": "voorraad bijhouden + dagomzet tellen",
"ref": "Coffee Almahaba",
},
"friteshouse": {
"package": "Werkplek PRO €1.499",
"pain": "afhalen-rush volgorde + tap-pay",
"ref": "Dushi Sazon",
},
"cafetaria": {
"package": "Werkplek PRO €1.499",
"pain": "voorraad-melding + dag-rapportage",
"ref": "Cafetaria Online",
},
"kapper": {
"package": "Werkplek STARTER €799",
"pain": "gemiste afspraken want naast knippen ook bellen/typen",
"ref": "Charif's Barbershop",
},
"barber": {
"package": "Werkplek STARTER €799",
"pain": "afsprakenbeheer + kassa zonder onderbreken",
"ref": "Charif's Barbershop",
},
"horeca": {
"package": "Werkplek KASSA €2.499",
"pain": "POS + voorraad + rapportage in één",
"ref": "Bisogno",
},
"retail": {
"package": "Werkplek PRO €1.499",
"pain": "voorraad + kassa + dag-overzicht",
"ref": "Le Miris",
},
}
def slugify(name):
s = re.sub(r"[^a-z0-9]+", "-", name.lower())
return s.strip("-")[:40]
def build_message(name, location, sector, ig_hint, voornaam_hint=""):
"""Build personalized WhatsApp via cluster + polish."""
t = SECTOR_TEMPLATES.get(sector.lower(), SECTOR_TEMPLATES["horeca"])
voornaam = voornaam_hint or "{voornaam}"
prompt = f"""Schrijf één persoonlijk cold-WhatsApp bericht voor een Tilburgse zaak. NL, casual maar professional.
ZAAK: {name}
LOCATIE: {location}
SECTOR: {sector}
IG/CONTEXT HINT: {ig_hint}
EIGENAAR VOORNAAM: {voornaam}
AANBOD: {t['package']}
PIJNPUNT: {t['pain']}
REFERENTIE: {t['ref']}
REGELS:
- Open met "Hoi {voornaam}," + 1 zin die TONE-PERSOONLIJK aansluit op IG-context hint
- Korte pijn-kwestie noemen (max 1 zin)
- Aanbod met prijs upfront
- Zachte CTA: 15-20 min koffie of bezoek, geen verkoop-druk
- Max 80 woorden totaal
- Eindigt met één concrete vraag
Output ALLEEN het bericht, geen toelichting."""
# task=general → qwen2.5:7b on atlas-01 (always-on, NL-decent)
return draft(prompt, task="general", node_pref="atlas-01",
max_tokens=400, temperature=0.6, purpose="lead_outreach")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("name", help="Zaak naam")
ap.add_argument("location", help="Adres / wijk")
ap.add_argument("--sector", required=True, choices=list(SECTOR_TEMPLATES.keys()))
ap.add_argument("--ig", default="", help="IG/context detail om aan te haken")
ap.add_argument("--voornaam", default="", help="Eigenaar voornaam")
ap.add_argument("--out", default=None, help="Save path (auto if omitted)")
args = ap.parse_args()
print(f"Generating message for: {args.name} ({args.sector}) at {args.location}")
print(f"Hook: {args.ig or '(none)'}")
print()
result = build_message(args.name, args.location, args.sector, args.ig, args.voornaam)
if not result.get("ok"):
print("ERR:", result.get("err"))
sys.exit(1)
print("=" * 60)
print(result["text"])
print("=" * 60)
print(f"\n[{result['node']}/{result['model']} {result['elapsed_s']}s]")
print(f"polish_status: {result['polish_status']}")
print(f"HINT: {result['hint']}")
print()
out_path = args.out or (
Path.home() / ".config" / "atlas" / "drafts" /
f"lead_{slugify(args.name)}_{datetime.now().strftime('%Y%m%d_%H%M')}.md"
)
saved = save_draft(result, out_path)
print(f"\nSaved → {saved}")
print("\nReview, edit if needed, copy to WhatsApp, send.")
print("After sending: mark_polished(draft_id) to track conversion.")
if __name__ == "__main__":
main()

240
scripts/atlas_reminders.py Normal file
View file

@ -0,0 +1,240 @@
#!/usr/bin/env python3
"""
atlas_reminders.py Operator burden-remover.
Fires based on date-pattern matching:
- 1e van de maand: Inkomsten xlsx update reminder
- 14d + 3d vóór BTW deadlines: aangifte reminder
- Friday-end-of-month: personal financial summary
- Quarterly mid-last-month: plan revision reminder
- Annual prep windows
Each reminder is an email to info@your-domain.tld via TransIP SMTP.
Schedule:
Atlas-Reminders-Daily at 07:45 checks all conditions, fires what matches.
"""
import sys
import smtplib
import json
from pathlib import Path
from datetime import date, datetime, timedelta
from email.mime.text import MIMEText
SMTP_HOST = "smtp.transip.email"
SMTP_PORT = 587
SMTP_USER = "your@your-domain.tld"
SMTP_PASS = "<YOUR_SMTP_PASSWORD>"
MAIL_FROM = "your@your-domain.tld"
MAIL_TO = "info@your-domain.tld"
LOG = Path(r"C:\Users\Gebruiker\.config\atlas\atlas_reminders.log")
def log(msg):
LOG.parent.mkdir(parents=True, exist_ok=True)
with open(LOG, "a", encoding="utf-8") as f:
f.write(f"[{datetime.now().isoformat(timespec='seconds')}] {msg}\n")
def send_html(subject, html_body):
msg = MIMEText(html_body, "html", "utf-8")
msg["Subject"] = subject
msg["From"] = MAIL_FROM
msg["To"] = MAIL_TO
try:
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=30) as s:
s.starttls()
s.login(SMTP_USER, SMTP_PASS)
s.send_message(msg)
log(f"sent: {subject}")
return True
except Exception as e:
log(f"send FAIL: {e}")
return False
def reminder_card(title, urgency, body_html):
color = {"high": "#dc2626", "med": "#f59e0b", "low": "#3b82f6"}[urgency]
return f"""
<div style="border-left:4px solid {color};padding:14px;background:#f8fafc;border-radius:4px;margin-bottom:14px">
<div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:{color}">{urgency} priority reminder</div>
<h2 style="margin:4px 0 8px;font-size:18px;color:#0f172a">{title}</h2>
<div style="font-size:14px;color:#334155;line-height:1.5">{body_html}</div>
</div>
"""
def check_monthly_inkomsten(today):
"""1e van de maand: update Inkomsten xlsx voor afgesloten maand."""
if today.day not in (1, 2, 5):
return None
last_month = today.replace(day=1) - timedelta(days=1)
body = f"""
<p>Vandaag ({today.strftime('%d %B')}) is een check-dag voor de <strong>Inkomsten uit onderneming 2026.xlsx</strong>.</p>
<p><strong>Actie</strong>: open Knab download afschrift {last_month.strftime('%B %Y')}
folder <code>07_Finance/2026/03_bank/</code> en update de
<strong>Betaalcontrole</strong> sheet met:</p>
<ul>
<li>Klant + factuurnummer + bedrag voor elke ontvangst</li>
<li>Knab afschrift-regel als bewijs</li>
<li>Status "opgenomen" zodra reconciled</li>
</ul>
<p>Atlas Command BRIEF tab leest de xlsx automatisch (06:30) refresht je
live YTD/MRR/concentration cijfers.</p>
<p style="color:#64748b;font-size:12px"><em>Reminder doel: voorkom "xlsx niet bijgewerkt sinds vorige maand" patroon (gebeurde mei 2026).</em></p>
"""
return ("📊 Monthly: Inkomsten xlsx update", "med",
reminder_card("Update Inkomsten xlsx voor afgelopen maand", "med", body))
def check_btw_deadline(today):
"""14d en 3d vóór BTW kwartaal-deadlines."""
btw_deadlines = [
date(today.year, 4, 30), # Q1
date(today.year, 7, 31), # Q2
date(today.year, 10, 31), # Q3
date(today.year + 1, 1, 31), # Q4
]
quarters = ["Q1", "Q2", "Q3", "Q4"]
for d, q in zip(btw_deadlines, quarters):
delta = (d - today).days
if delta in (14, 3):
urgency = "high" if delta == 3 else "med"
body = f"""
<p><strong>BTW aangifte {q} {today.year} deadline: {d.strftime('%d %B %Y')}</strong>
over {delta} dagen.</p>
<p><strong>Actie</strong>:</p>
<ol>
<li>Open Odoo (<a href="http://<YOUR_VPS_IP>:8069"><YOUR_VPS_IP>:8069</a>) Boekhouding BTW</li>
<li>Genereer aangifte-overzicht voor {q}</li>
<li>Controleer met Knab + ING + Revolut totalen</li>
<li>Login Belastingdienst (Mijn Belastingdienst Zakelijk) aangifte indienen</li>
<li>BTW-bedrag overmaken vanuit Knab vóór {d.strftime('%d %B')}</li>
</ol>
<p style="color:#64748b;font-size:12px">
<em>Boete bij te late aangifte: vanaf 68. Bij te late betaling: 4% rente op jaarbasis.</em>
</p>
"""
return (f"⏳ BTW {q} deadline {delta}d - actie nodig", urgency,
reminder_card(f"BTW aangifte {q}{delta} dagen", urgency, body))
return None
def check_quarterly_review(today):
"""15e van laatste maand van kwartaal: revise Q+1 plan."""
quarterly_review_days = [
date(today.year, 3, 15),
date(today.year, 6, 15),
date(today.year, 9, 15),
date(today.year, 12, 15),
]
if today not in quarterly_review_days:
return None
quarter = (today.month - 1) // 3 + 1
body = f"""
<p>Mid-laatste-maand Q{quarter} tijd voor Q{quarter+1 if quarter < 4 else 1} planning.</p>
<p><strong>Acties</strong>:</p>
<ul>
<li>Open Atlas Command BRIEF tab screenshot finance + concentration + R10</li>
<li>Open vault note <code>05.5 - Q3 2026 Plan.md</code> (of Q+1 versie)</li>
<li>Herzie cijfers: YTD vs target, MRR groei, concentration delta</li>
<li>Definieer 3-5 top prioriteiten voor komend kwartaal</li>
<li>Schrijf Q+1 versie in <code>00_Atlas_Wiki/</code></li>
</ul>
<p>Tip: gebruik [[06.5 - Master Cockpit & 90-day System]] als template.</p>
"""
return (f"🔁 Q{quarter+1 if quarter<4 else 1} planning tijd", "med",
reminder_card(f"Quarterly review — Q{quarter+1 if quarter<4 else 1} plan opstellen", "med", body))
def check_holiday_booking(today):
"""Vrij van eerste juni-week: reminder om vakantie te boeken voor zomer."""
triggers = [date(today.year, 6, 5), date(today.year, 11, 25)] # June + late Nov
if today not in triggers:
return None
body = f"""
<p>Solo founder herinnering: <strong>plan je vakantie zelf in</strong>, anders gebeurt het niet.</p>
<p><strong>Suggestie 2026 windows</strong> (zie [[06.5 - Master Cockpit & 90-day System]] §7):</p>
<ul>
<li>6-12 juli: eerste zomerweek (Bisogno retainer dekt cash)</li>
<li>17-21 augustus: pre-schoolstart week</li>
<li>14-20 december: pre-kerst rust</li>
</ul>
<p><strong>Actie</strong>: blokkeer in Nextcloud Kalender + Apple Calendar.
Stuur "out of office" voor Atlas Agency klanten.
Werkplek-pakket levering = plan voor of na vakantie.</p>
"""
return ("🌴 Vakantie boeking reminder", "low",
reminder_card("Plan je zomer/winter vakantie nu", "low", body))
def check_savings(today):
"""26e van maand: na vakantiegeld / na payday → savings reminder."""
if today.day != 26:
return None
body = """
<p>Het is de 26e payday is geweest (25e is NL salaris-norm).</p>
<p><strong>Actie</strong>: open Knab check 3 spaar-pots:</p>
<ul>
<li>🚗 <strong>Auto-pot</strong>: doel 6.500 voor december storting deze maand?</li>
<li>🛡 <strong>Buffer-pot</strong>: 3 mnd vaste lasten doel 5.000 storting deze maand?</li>
<li>🌴 <strong>Vakantie-pot</strong>: doel 1.500/jaar 125/mnd</li>
</ul>
<p>Niet sparen = niet halen. Auto-transfer instellen via Knab is one-time setup, daarna fire-and-forget.</p>
<p style="color:#64748b;font-size:12px"><em>Bron: [[06.5 - Master Cockpit]] §7 savings goals tabel.</em></p>
"""
return ("💰 Savings reminder (26e)", "low",
reminder_card("Spaar-pots check", "low", body))
def check_annual_prep(today):
"""1 maart: IB aangifte voorjaars-window opent. 1 juni: deadline 1 mei is voorbij."""
if today not in (date(today.year, 3, 1), date(today.year, 4, 15)):
return None
body = f"""
<p>IB aangifte 2025 voor {today.year} deadline 1 mei {today.year}.</p>
<p><strong>Acties</strong>:</p>
<ol>
<li>Stuur boekhouder Inkomsten + Kosten 2025 xlsx (al beschikbaar in 07_Finance)</li>
<li>Verzamel BTW aangiften 2025 (4 kwartalen)</li>
<li>Verzamel zakelijke kosten-bonnetjes 2025 (05_overig/ + 02_uitgaven/)</li>
<li>Vraag KOR-check (kleine ondernemingsregeling) bij boekhouder</li>
<li>Plan voorlopige IB-aanslag voor {today.year} samen met boekhouder</li>
</ol>
"""
return ("📋 IB aangifte voorjaarsstart", "high",
reminder_card("IB aangifte 2025 voorbereiden", "high", body))
def main():
today = date.today()
log(f"reminders check for {today.isoformat()}")
cards = []
for check in [check_monthly_inkomsten, check_btw_deadline,
check_quarterly_review, check_holiday_booking,
check_savings, check_annual_prep]:
result = check(today)
if result:
subject, urgency, html = result
cards.append((subject, urgency, html))
log(f" triggered: {subject}")
if not cards:
log(" no reminders today")
return
# Combine all cards into one email
body_html = f"""<!doctype html><html><body style="font-family:-apple-system,Segoe UI,sans-serif;color:#1e293b;max-width:680px;margin:auto;padding:24px">
<h1 style="font-size:22px;margin-bottom:18px">🔔 Atlas Reminders {today.strftime('%A %d %B %Y')}</h1>
{''.join(c[2] for c in cards)}
<div style="margin-top:24px;padding-top:14px;border-top:1px solid #e2e8f0;font-size:11px;color:#94a3b8">
Atlas Reminders · auto-fired by daily 07:45 schtask · disable via Disable-ScheduledTask Atlas-Reminders-Daily
</div>
</body></html>"""
subject = f"🔔 Atlas Reminders ({len(cards)}) — {today.strftime('%d %b')}"
send_html(subject, body_html)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,265 @@
#!/usr/bin/env python3
"""
atlas_seasonal_strategy.py Generate context-aware strategic recommendations
based on calendar season, NL economic windows, local Tilburg events, and
Google Trends signals where available.
Output: atlas-strategy.json consumed by Atlas Command BRIEF tab "Strategy" card.
The engine knows about:
- Vakantiegeld payouts (25 mei 5 juni)
- Belastingteruggaaf windows (Mar-Apr)
- Schoolstart (late aug / early sept)
- Sint/Kerst spending (Nov-Dec)
- Tilburgse Kermis (juli)
- Q1/Q2/Q3/Q4 BTW aangifte deadlines
- Ramadan / Suikerfeest (year-variable)
Per current window, recommends:
- Which revenue stream to prioritize
- Which prospects to push
- Which framing to use
- What NOT to do
"""
import json
import subprocess
import sys
import urllib.request
from pathlib import Path
from datetime import datetime, date, timedelta
OUT_LOCAL = Path(r"C:\Users\Gebruiker\AppData\Local\Atlas\analyses\atlas-strategy.json")
SSH = "root@<YOUR_VPS_IP>"
REMOTE = "/opt/atlas/dashboard/atlas-strategy.json"
# Calendar windows (year-relative — adjusted to 2026)
WINDOWS_2026 = [
{
"name": "Vakantiegeld",
"start": "2026-05-25", "end": "2026-06-05",
"signal": "Consumer cash boost (€1.500-3.000 per consumer)",
"push": [
"Werkplek STARTER €799 (consumer-friendly price)",
"Kapper / horeca consumer-facing zaak-eigenaars (zij ervaren spend-piek)",
"Atlas Agency retainers (klanten hebben budget voor pakketten)"
],
"framing": "\"Vakantiegeld al binnen? Nieuwe werkplek-set is nu beste timing.\"",
"avoid": ["Geen B2B-koud sales — owners distracted door consumer-rush"],
"channels": ["WhatsApp", "Instagram", "Tilburg community"],
},
{
"name": "Pre-zomer rust",
"start": "2026-06-06", "end": "2026-07-05",
"signal": "Stabiele B2B window, vóór schoolvakantie",
"push": [
"Atlas Agency cold-outreach (max response rate)",
"POS Support Lite contract conversions (existing klanten)",
"Werkplek PRO + KASSA (besluitvorming-tijd)"
],
"framing": "\"Inrichten vóór zomerstop — na zomer = nieuwe kalender, schone start.\"",
"avoid": ["Geen consumer-spend campagnes — vakantiegeld op"],
"channels": ["WhatsApp", "Email B2B", "LinkedIn (1×/wk max)"],
},
{
"name": "Tilburgse Kermis",
"start": "2026-07-17", "end": "2026-07-26",
"signal": "Lokale horeca-piek (10 dagen)",
"push": [
"POS Support Lite NU verkopen voor july readiness",
"Kermis-tijdelijke POS rental aanbieden (€50/dag) — test market",
"WhatsApp campagne richting kermis-deelnemers vóór kermis"
],
"framing": "\"Kermis = jouw drukste week. Atlas Support Lite zorgt dat je POS niet stilvalt.\"",
"avoid": ["Geen large-budget B2B proposals — operationele focus"],
"channels": ["WhatsApp horeca", "Instagram Tilburg-tags"],
},
{
"name": "Schoolvakantie",
"start": "2026-07-06", "end": "2026-08-17",
"signal": "MKB-eigenaars meer tijd voor strategie + nieuwe leveranciers",
"push": [
"Atlas Agency cold-outreach (BEST window — owners hebben tijd)",
"Werkplek follow-ups op bestaande quotes",
"Workshop POS voor Horeca (eerste editie juli)"
],
"framing": "\"Tijd voor de zaak achter de zaak — Atlas helpt het systeem rond te krijgen.\"",
"avoid": ["Geen sales-druk eerste 2 weken juli (vakantie-spirit)"],
"channels": ["WhatsApp", "Instagram zaak-eigenaar netwerken", "Charif referral"],
},
{
"name": "Back to school + work",
"start": "2026-08-18", "end": "2026-08-31",
"signal": "Nieuwe office-setups, MKB equipment-piek",
"push": [
"Werkplek PRO + KASSA push (office-upgrade tijd)",
"Group-buy aanbod (5+ werkplekken voor MKB)",
"Studenten-werkplekken (TilburgU/Avans semesterstart)"
],
"framing": "\"Begin het nieuwe seizoen met systemen die werken.\"",
"avoid": ["Geen low-budget peripherals — push hoog-marge bundels"],
"channels": ["WhatsApp MKB", "TilburgU community", "Avans-area outreach"],
},
{
"name": "Najaar groei",
"start": "2026-09-01", "end": "2026-10-31",
"signal": "Najaars-momentum, beslissingen-tijd",
"push": [
"Atlas Agency retainers (€250/mo) — vol momentum",
"Bisogno + andere bestaande klanten verdieping",
"Workshops 2×/mo schaal"
],
"framing": "\"Voor december komt: maak je systeem klaar voor jaar-eind drukte.\"",
"avoid": [],
"channels": ["Alles open"],
},
{
"name": "Sint/Kerst opmaat",
"start": "2026-11-01", "end": "2026-11-30",
"signal": "B2C horeca-piek voorbereiden + boekhouders druk",
"push": [
"Werkplek KASSA voor horeca die met sint/kerst druk gaat krijgen",
"Cross-sell naar boekhouders (referral network — zij zien hun klanten worstelen)",
"Year-end Atlas Support Lite incentives"
],
"framing": "\"December = duurste maand om POS te laten falen. Nu is het tijd.\"",
"avoid": [],
"channels": ["Alles open"],
},
{
"name": "Eindejaarsspecial",
"start": "2026-12-01", "end": "2026-12-23",
"signal": "Cash + planning + 'kerstcadeau voor jezelf'-framing",
"push": [
"Werkplek STARTER €799 als kerstcadeau voor zzp'er",
"Q4 retainer-vernieuwingen + annual prepay deals",
"Atlas Support Lite jaarcontracten (12% korting bij vooruitbetaling)"
],
"framing": "\"Kerstcadeau aan jezelf én aan je zaak.\"",
"avoid": ["Geen B2B sales na 23 dec — niemand bereikbaar"],
"channels": ["WhatsApp warm-list", "Email bestaande klanten", "IG"],
},
{
"name": "Kerst-doodweek",
"start": "2026-12-24", "end": "2027-01-04",
"signal": "Holiday lull — minimaal contact",
"push": ["Eigen administratie afronden", "2027 plan schrijven", "Cluster onderhoud"],
"framing": "n.v.t. — geen sales",
"avoid": ["Alle externe outreach"],
"channels": [],
},
{
"name": "Q1 freshness",
"start": "2027-01-05", "end": "2027-01-31",
"signal": "\"Nieuw jaar nieuw systeem\" — annual contract renewals",
"push": [
"Werkplek upgrade calls naar Q1 klanten",
"Atlas Agency jaarcontracten ipv maandelijks",
"POS Support Lite hernieuwingen + uitbreidingen"
],
"framing": "\"Begin 2027 met systemen die in 2026 hebben bewezen te werken.\"",
"avoid": [],
"channels": ["Alles open"],
},
]
def current_window(today=None):
today = today or date.today()
for w in WINDOWS_2026:
start = date.fromisoformat(w["start"])
end = date.fromisoformat(w["end"])
if start <= today <= end:
days_in = (today - start).days
days_left = (end - today).days
return {**w, "days_in": days_in, "days_left": days_left}
return None
def upcoming_windows(today=None, n=3):
today = today or date.today()
upcoming = []
for w in WINDOWS_2026:
start = date.fromisoformat(w["start"])
if start > today:
upcoming.append({**w, "days_until": (start - today).days})
if len(upcoming) >= n: break
return upcoming
def btw_deadline(today=None):
"""Quarterly NL BTW aangifte deadlines: end of month after quarter end."""
today = today or date.today()
q_ends = [
(date(today.year, 4, 30), "Q1"),
(date(today.year, 7, 31), "Q2"),
(date(today.year, 10, 31), "Q3"),
(date(today.year + 1, 1, 31), "Q4"),
]
for d, q in q_ends:
if d >= today:
return {"quarter": q, "deadline": d.isoformat(), "days_until": (d - today).days}
return None
def days_to_next_payday():
"""Most NL employers pay 25th of month — quick proxy."""
today = date.today()
if today.day < 25:
nxt = date(today.year, today.month, 25)
else:
m = today.month + 1
y = today.year
if m > 12: m = 1; y += 1
nxt = date(y, m, 25)
return (nxt - today).days
def build_summary():
today = date.today()
curr = current_window(today)
upcoming = upcoming_windows(today, 3)
btw = btw_deadline(today)
summary = {
"generated_at": datetime.now().isoformat(timespec="seconds"),
"as_of": today.isoformat(),
"current_window": curr,
"upcoming_windows": upcoming,
"btw_aangifte": btw,
"days_to_next_payday": days_to_next_payday(),
"weekday": today.strftime("%A"),
"is_weekend": today.weekday() >= 5,
# Action triggers
"primary_recommendation": curr["push"][0] if curr and curr["push"] else "Geen actieve window — gebruik default plan",
"primary_framing": curr["framing"] if curr else "n.v.t.",
"avoid_today": curr["avoid"] if curr else [],
"channels_today": curr["channels"] if curr else [],
}
return summary
def push_to_atlas01(summary):
OUT_LOCAL.parent.mkdir(parents=True, exist_ok=True)
OUT_LOCAL.write_text(json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"wrote {OUT_LOCAL}")
try:
subprocess.run(["scp", str(OUT_LOCAL), f"{SSH}:{REMOTE}"], check=True, timeout=20)
print(f"pushed to {SSH}:{REMOTE}")
except Exception as e:
print(f"scp fail: {e}")
def main():
s = build_summary()
push_to_atlas01(s)
print(f"\nCurrent window: {s['current_window']['name'] if s['current_window'] else 'none'}")
if s['current_window']:
print(f" Days in: {s['current_window']['days_in']}, days left: {s['current_window']['days_left']}")
print(f" Push: {s['primary_recommendation']}")
print(f"BTW deadline: {s['btw_aangifte']['quarter']} on {s['btw_aangifte']['deadline']} ({s['btw_aangifte']['days_until']}d)")
print(f"Next payday (25th): {s['days_to_next_payday']}d")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,320 @@
#!/usr/bin/env python3
"""
atlas_self_prompting_loop.py - Overnight autonomy.
Runs every 15 min via schtask (Atlas-Autonomy-Hourly):
1. Read state (atlas-finance.json, atlas-strategy.json, atlas-activity.json, recent vault changes)
2. Pick next task from queue OR generate new task if queue empty
3. Dispatch to cluster (atlas-01 + MET + PC) via cluster_then_polish
4. Save output to vault under 00_Atlas_Wiki/_autonomy/<timestamp>_<topic>.md
5. Maintain a "what happened tonight" log readable by operator in morning
Generated tasks pull from a recurring backlog + state-driven prompts.
NO external messages, NO code modifications, NO destructive ops pure
note generation + analysis + plan refinement.
"""
import sys
import os
import json
import time
import random
import urllib.request
from pathlib import Path
from datetime import datetime, date
sys.path.insert(0, r"C:\Users\Gebruiker\.config\atlas")
import io
if sys.stdout.encoding != 'utf-8':
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
try:
from cluster_then_polish import draft, save_draft
import cluster
except ImportError as e:
print(f"missing dep: {e}")
sys.exit(1)
VAULT_AUTONOMY = Path(r"C:\Users\Gebruiker\MEGA\Atlas Corporation\00_Atlas_Wiki\_autonomy")
QUEUE_FILE = Path(r"C:\Users\Gebruiker\.config\atlas\autonomy_queue.json")
LOG_FILE = Path(r"C:\Users\Gebruiker\.config\atlas\autonomy_loop.log")
STATE_URLS = {
"finance": "http://atlas-01/atlas-finance.json",
"strategy": "http://atlas-01/atlas-strategy.json",
"activity": "http://atlas-01/atlas-activity.json",
"priorities": "http://atlas-01/priorities.json",
}
MAX_RUNTIME_SECONDS = 600 # 10 min hard cap per fire — never block schtask
MIN_QUEUE_SIZE = 5
# Recurring task templates — picked when queue is low
RECURRING_TEMPLATES = [
{
"topic": "client_deepdive",
"rotation": "random_client", # picks from client list
"prompt_template": "Schrijf een korte client-summary voor {client}: hun sector, laatste touchpoint, recurring revenue potential, 1 concrete actie deze week. NL, 100 woorden max.",
"task": "general",
"node": "atlas-01",
},
{
"topic": "season_action",
"prompt_template": "Gegeven huidige seasonal window {window} (vakantiegeld/kermis/etc.), wat is 1 specifieke actie voor Atlas Agency deze week die geen ander cluster ook al heeft? Schrijf in NL, 150 woorden.",
"task": "reason",
"node": "atlas-01",
},
{
"topic": "vault_synthesis",
"prompt_template": "Vat de 3 belangrijkste lessons uit Atlas Q1 2026 in 100 woorden NL. Bron: YTD revenue €4.578, Bisogno 57%, mei €0 ingebrekening.",
"task": "general",
"node": "atlas-01",
},
{
"topic": "anti_impulse_check",
"prompt_template": "Gegeven decision rules R1-R10 (anti-impulse hardware buy + cash-before-code), schrijf 1 paragraaf 'wat ik vannacht zou doen als ik in deze valkuil viel' (om operator te helpen herkennen). NL, 100 woorden.",
"task": "general",
"node": "atlas-01",
},
{
"topic": "outreach_personalization",
"rotation": "random_prospect",
"prompt_template": "Schrijf 1 zin alternative opening voor cold WhatsApp aan {prospect} ({sector}) die anders is dan een template. NL, max 25 woorden, hook moet specifiek aan zaak-context aansluiten.",
"task": "chat",
"node": "pc", # PC heeft hermes3:8b voor chat-tone
},
{
"topic": "weekly_metrics",
"prompt_template": "Gegeven YTD €{ytd}, MRR €{mrr}, concentration {conc}%, R10 status {r10}, wat is 1 KPI die niet maar in de dashboard zit en wel moet? NL, 80 woorden.",
"task": "reason",
"node": "atlas-01",
},
{
"topic": "scale_path",
"prompt_template": "Atlas Agency target 1200 sites. Huidige: 1 (Charif). Wat is concrete week-22 actie die schaalbaarheid voorbereidt zonder grote build? NL, 150 woorden.",
"task": "reason",
"node": "atlas-01",
},
{
"topic": "vault_health_check",
"prompt_template": "Atlas vault heeft 7000+ MD files. Welke 3 hygiene-acties zouden vault zoekbaarder maken zonder content te verliezen? NL, 100 woorden.",
"task": "general",
"node": "met",
},
]
# Known prospects (for rotation)
PROSPECTS = [
{"name": "Lunchroom Carrousel", "sector": "lunchroom"},
{"name": "June Tilburg", "sector": "koffiebar"},
{"name": "Buutvrij", "sector": "lunchroom"},
{"name": "Cafetaria Online", "sector": "cafetaria"},
{"name": "Cafetaria Franske", "sector": "cafetaria"},
{"name": "Kapper Muro", "sector": "kapper"},
{"name": "The Crooks Barbershop", "sector": "barber"},
{"name": "De Meesterbarbier", "sector": "kapper"},
{"name": "Fresh Up Barbershop", "sector": "barber"},
{"name": "Picasso Barbers", "sector": "kapper"},
]
KNOWN_CLIENTS = [
"Koffie Bisogno", "Cargo Nexus", "HK International Care",
"Edessa Management", "Akmoh IT", "Ozside V.O.F.",
"Dushi Sazon", "Ayintap Food", "GobYtes B.V.", "TRWC B.V.",
"Coffee Almahaba", "WinstUITstroom", "Couscousbar", "Kolvari",
"Le Miris", "Sweet Mahaba",
]
def log(msg):
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(f"[{datetime.now().isoformat(timespec='seconds')}] {msg}\n")
def fetch_state(url, timeout=4):
try:
with urllib.request.urlopen(url, timeout=timeout) as r:
return json.loads(r.read().decode("utf-8", errors="replace"))
except Exception:
return None
def load_queue():
if QUEUE_FILE.exists():
try: return json.loads(QUEUE_FILE.read_text(encoding="utf-8"))
except: pass
return {"pending": [], "completed": []}
def save_queue(q):
QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True)
QUEUE_FILE.write_text(json.dumps(q, indent=2, ensure_ascii=False), encoding="utf-8")
def generate_task(state):
"""Pick a template + fill in dynamic vars."""
tpl = random.choice(RECURRING_TEMPLATES)
fin = state.get("finance", {}) or {}
strat = state.get("strategy", {}) or {}
act = state.get("activity", {}) or {}
vars_ctx = {
"client": random.choice(KNOWN_CLIENTS),
"ytd": fin.get("ytd_eur", 0),
"mrr": fin.get("mrr_estimate_eur", 0),
"conc": fin.get("concentration_top_pct", 0),
"r10": "PASS" if act.get("r10_pass") else "FAIL",
"window": (strat.get("current_window") or {}).get("name", "default"),
}
if tpl.get("rotation") == "random_prospect":
p = random.choice(PROSPECTS)
vars_ctx["prospect"] = p["name"]
vars_ctx["sector"] = p["sector"]
try:
prompt = tpl["prompt_template"].format(**vars_ctx)
except KeyError as e:
log(f"template format fail {tpl['topic']}: {e}")
return None
return {
"topic": tpl["topic"],
"prompt": prompt,
"task": tpl["task"],
"node": tpl["node"],
"context": vars_ctx,
"queued_at": datetime.now().isoformat(timespec="seconds"),
}
def refill_queue_if_low(q, state):
if len(q["pending"]) >= MIN_QUEUE_SIZE:
return
needed = MIN_QUEUE_SIZE * 2 - len(q["pending"])
for _ in range(needed):
t = generate_task(state)
if t: q["pending"].append(t)
log(f"refilled queue: now {len(q['pending'])} pending")
def execute_one(task):
"""Dispatch task to cluster via cluster_then_polish.draft()."""
t0 = time.time()
try:
result = draft(
task["prompt"],
task=task.get("task", "general"),
node_pref=task.get("node"),
max_tokens=500,
temperature=0.6,
purpose=f"autonomy_{task['topic']}",
)
elapsed = round(time.time() - t0, 1)
if not result.get("ok"):
return {"ok": False, "err": result.get("err"), "elapsed": elapsed}
return {"ok": True, "text": result["text"], "model": result["model"],
"node": result["node"], "elapsed": elapsed,
"draft_id": result.get("draft_id")}
except Exception as e:
return {"ok": False, "err": str(e)[:200], "elapsed": round(time.time() - t0, 1)}
def save_output_to_vault(task, result):
"""Write to 00_Atlas_Wiki/_autonomy/<topic>_<timestamp>.md"""
VAULT_AUTONOMY.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y-%m-%d_%H%M")
safe_topic = task["topic"].replace("/", "_")[:40]
fname = f"{ts}_{safe_topic}.md"
p = VAULT_AUTONOMY / fname
body = f"""---
generated_at: {datetime.now().isoformat(timespec='seconds')}
topic: {task['topic']}
node: {result.get('node', '?')}
model: {result.get('model', '?')}
elapsed_s: {result.get('elapsed', 0)}
draft_id: {result.get('draft_id', '?')}
polish_status: pending
tags: [autonomy, generated, {task['topic']}]
context: {json.dumps(task.get('context', {}), ensure_ascii=False)}
---
# {task['topic'].replace('_', ' ').title()}
> [!note] Autonomous output review needed before any external use
> Generated by Atlas overnight autonomy loop. Cluster output, not Claude-polished.
**Prompt**: {task['prompt']}
---
{result.get('text', '(no output)')}
---
*Source: atlas_self_prompting_loop.py · [[06.5 - Master Cockpit & 90-day System]]*
"""
p.write_text(body, encoding="utf-8")
return p
def main():
log("=== loop fire ===")
start = time.time()
# Fetch live state
state = {k: fetch_state(v) for k, v in STATE_URLS.items()}
log(f"state fetched: { {k: bool(v) for k, v in state.items()} }")
# Verify cluster has at least one node
nodes_alive = cluster.nodes_status(force=False)
alive = [n for n, info in nodes_alive.items() if info.get("reachable")]
log(f"alive nodes: {alive}")
if not alive:
log("NO CLUSTER NODES ALIVE — abort this fire")
return
# Queue management
q = load_queue()
refill_queue_if_low(q, state)
# Execute up to 2 tasks per fire (rate-limit cluster + save energy)
executed = 0
max_per_fire = 2
while executed < max_per_fire and q["pending"]:
if time.time() - start > MAX_RUNTIME_SECONDS:
log("hit runtime cap")
break
task = q["pending"].pop(0)
log(f"executing: {task['topic']} ({task['node']}/{task['task']})")
result = execute_one(task)
if result["ok"]:
saved = save_output_to_vault(task, result)
q["completed"].append({
**task,
"completed_at": datetime.now().isoformat(timespec="seconds"),
"model": result["model"],
"node": result["node"],
"elapsed_s": result["elapsed"],
"file": str(saved),
})
log(f" OK {result['node']}/{result['model']} {result['elapsed']}s → {saved.name}")
else:
q["completed"].append({
**task,
"completed_at": datetime.now().isoformat(timespec="seconds"),
"err": result["err"],
})
log(f" FAIL: {result['err']}")
executed += 1
save_queue(q)
# Keep only last 200 completed entries in queue file (rolling log)
if len(q["completed"]) > 200:
q["completed"] = q["completed"][-200:]
save_queue(q)
log(f"=== loop end: executed {executed} ===")
if __name__ == "__main__":
main()

229
scripts/atlas_tray.py Normal file
View file

@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""
atlas_tray.py - Atlas always-on system tray icon.
Right-click menu for quick access to Atlas Command / HUD / Chat / Portal +
util actions. Icon color reflects cluster health.
Run with pythonw.exe so no console flash:
pythonw atlas_tray.py
"""
import os
import sys
import time
import json
import webbrowser
import threading
import urllib.request
import subprocess
from pathlib import Path
try:
import pystray
from pystray import MenuItem as Item, Menu
from PIL import Image, ImageDraw, ImageFont
except ImportError as e:
print(f"Missing dep: {e}\nInstall: pip install pystray pillow")
sys.exit(1)
HOME = Path.home()
ATLAS = HOME / ".config" / "atlas"
LOG = ATLAS / "atlas_tray.log"
CFG = {
"ollama_url": "http://atlas-01/ollama/api/tags",
"priorities": "http://atlas-01/priorities.json",
"poll_seconds": 30,
}
STATE = {
"status": "unknown",
"overdue_eur": 0,
"disk_free_gb": 0,
"containers_down": 0,
"last_update": None,
"models": 0,
}
def log(msg):
try:
LOG.parent.mkdir(parents=True, exist_ok=True)
with open(LOG, "a", encoding="utf-8") as f:
f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n")
except Exception:
pass
def fetch_json(url, timeout=4):
try:
with urllib.request.urlopen(url, timeout=timeout) as r:
return json.loads(r.read().decode("utf-8", errors="replace"))
except Exception:
return None
def make_icon(color_hex="#3B82F6", text="A"):
"""Generate 64x64 icon: filled circle + white letter."""
size = 64
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
d.ellipse((4, 4, size - 4, size - 4), fill=color_hex)
try:
font = ImageFont.truetype("arial.ttf", 38)
except Exception:
font = ImageFont.load_default()
bbox = d.textbbox((0, 0), text, font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
d.text(((size - tw) / 2 - bbox[0], (size - th) / 2 - bbox[1]), text, fill="white", font=font)
return img
def color_for(status):
return {
"ok": "#10B981",
"warn": "#F59E0B",
"err": "#EF4444",
"unknown": "#6B7280",
}.get(status, "#3B82F6")
def poll_loop(icon):
while True:
try:
pri = fetch_json(CFG["priorities"])
tags = fetch_json(CFG["ollama_url"])
if pri:
STATE["overdue_eur"] = pri.get("odoo", {}).get("overdue_eur", 0)
STATE["disk_free_gb"] = pri.get("disk_free_gb", 0)
STATE["containers_down"] = pri.get("containers_down", 0)
STATE["last_update"] = time.strftime("%H:%M:%S")
STATE["models"] = len(tags.get("models", [])) if tags else 0
if tags is None and pri is None:
STATE["status"] = "err"
elif STATE["containers_down"] > 0 or STATE["disk_free_gb"] < 5:
STATE["status"] = "err"
elif STATE["overdue_eur"] > 0 or STATE["disk_free_gb"] < 15:
STATE["status"] = "warn"
else:
STATE["status"] = "ok"
try:
icon.icon = make_icon(color_for(STATE["status"]))
icon.title = (
f"Atlas — {STATE['status'].upper()}\n"
f"Overdue: €{STATE['overdue_eur']}\n"
f"Disk: {STATE['disk_free_gb']}GB free\n"
f"Containers down: {STATE['containers_down']}\n"
f"Models on atlas-01: {STATE['models']}\n"
f"Updated: {STATE['last_update']}"
)
except Exception as e:
log(f"icon update fail: {e}")
except Exception as e:
log(f"poll error: {e}")
time.sleep(CFG["poll_seconds"])
def open_url(url):
def _open(icon, item):
webbrowser.open(url)
log(f"opened url {url}")
return _open
def open_app(url, profile):
"""Open in Edge --app mode for native-window feel."""
edge = r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"
profile_dir = HOME / "AppData" / "Local" / "Atlas" / profile
def _open(icon, item):
if not Path(edge).exists():
webbrowser.open(url)
return
try:
subprocess.Popen([
edge,
f"--app={url}",
f"--user-data-dir={profile_dir}",
"--no-first-run", "--no-default-browser-check",
])
log(f"app opened {url}")
except Exception as e:
log(f"app open fail: {e}")
return _open
def restart_ollama(icon, item):
log("restart ollama requested")
try:
subprocess.run(["taskkill", "/F", "/IM", "ollama.exe"], capture_output=True, timeout=5)
subprocess.run(["taskkill", "/F", "/IM", "ollama app.exe"], capture_output=True, timeout=5)
time.sleep(1)
ollama = HOME / "AppData" / "Local" / "Programs" / "Ollama" / "ollama.exe"
if ollama.exists():
env = os.environ.copy()
env["OLLAMA_HOST"] = "0.0.0.0:11434"
env["OLLAMA_KEEP_ALIVE"] = "30m"
subprocess.Popen([str(ollama), "serve"], env=env, creationflags=0x08000000)
log("ollama restarted")
except Exception as e:
log(f"restart ollama fail: {e}")
def open_log(icon, item):
try:
subprocess.Popen(["notepad.exe", str(LOG)])
except Exception:
webbrowser.open(f"file:///{LOG}")
def quit_tray(icon, item):
log("tray quit")
icon.stop()
def status_label(item):
return f"Status: {STATE['status'].upper()} · €{STATE['overdue_eur']} open · {STATE['disk_free_gb']}GB"
def updated_label(item):
return f"Updated: {STATE['last_update'] or 'never'}"
def main():
log("=== atlas_tray starting ===")
menu = Menu(
Item(status_label, lambda i, x: None, enabled=False),
Item(updated_label, lambda i, x: None, enabled=False),
Menu.SEPARATOR,
Item("Atlas Command · Ctrl+Shift+P", open_app("http://atlas-01/atlas-command.html", "Cockpit-Profile"), default=True),
Item("Atlas HUD · Ctrl+Shift+H", open_app("http://atlas-01/atlas-hud.html", "HUD-Profile")),
Item("Atlas Chat · Ctrl+Shift+C", open_app("http://atlas-01/atlas-chat.html", "Chat-Profile")),
Item("Atlas Portal (legacy)", open_url("http://atlas-01/portal.html")),
Menu.SEPARATOR,
Item("Odoo", open_url("http://<YOUR_VPS_IP>:8069")),
Item("Nextcloud", open_url("http://cloud.your-domain.tld")),
Item("MeshCentral", open_url("http://mesh.your-domain.tld")),
Item("Open WebUI", open_url("http://<YOUR_VPS_IP>:8082")),
Menu.SEPARATOR,
Item("Restart Ollama (PC)", restart_ollama),
Item("Open tray log", open_log),
Menu.SEPARATOR,
Item("Quit", quit_tray),
)
img = make_icon(color_for("unknown"))
icon = pystray.Icon("atlas", img, "Atlas (loading...)", menu)
threading.Thread(target=poll_loop, args=(icon,), daemon=True).start()
icon.run()
if __name__ == "__main__":
main()

179
scripts/cluster.py Normal file
View file

@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""
cluster.py multi-node Ollama orchestrator.
Three potential nodes:
- pc : localhost (this PC, GPU, 10 models, fast but laptop-up only)
- atlas-01 : <YOUR_VPS_IP>:11434 (Hetzner VPS, CPU, 5 models, always-on)
- met : <YOUR_PC_NODE_IP>:11434 (MET desktop, secondary capacity)
Routing strategy:
- For "always_on" tasks (overnight, scheduled, server-side): prefer atlas-01
- For "fast" tasks (interactive, jarvis, code completion): prefer pc
- For "embed": prefer pc (nomic on both, pc is faster)
- For "vision" (qwen2.5vl): pc only (atlas-01 doesn't have it)
- Fallback chain: if preferred unreachable try next available node that has the model
Public API:
ask(prompt, task='general', node_pref=None, max_tokens=512) -> {ok, text, node, model, elapsed_s}
embed(text) -> {ok, vector, node, model}
nodes_status() -> [{name, reachable, latency_ms, models}]
pick_node(task, model=None) -> (node_name, model_name)
"""
from __future__ import annotations
import json, time, urllib.request, urllib.error, socket
NODES = {
'pc': {'host': '127.0.0.1', 'port': 11434, 'always_on': False, 'fast': True, 'has_gpu': True},
'atlas-01': {'host': '<YOUR_VPS_IP>', 'port': 11434, 'always_on': True, 'fast': False, 'has_gpu': False},
# FIX 2026-05-26: was <YOUR_PC_NODE_IP> (which is PC's own tailnet IP).
# MET-Desktop actually lives at <YOUR_DESKTOP_NODE_IP>.
'met': {'host': '<YOUR_DESKTOP_NODE_IP>','port': 11434, 'always_on': False, 'fast': True, 'has_gpu': False},
}
TASK_MODEL = {
'chat': 'hermes3:8b',
'voice': 'llama3.2:3b',
'fast': 'llama3.2:1b',
'code': 'qwen2.5-coder:7b',
'code_fast': 'qwen2.5-coder:1.5b',
'reason': 'phi3:medium',
'general': 'qwen2.5:7b',
'general_fast':'qwen2.5:1.5b',
'embed': 'nomic-embed-text',
'vision': 'qwen2.5vl:7b',
}
# Per-node model availability — populated lazily on first nodes_status() call.
_NODE_MODELS_CACHE = {'pc': None, 'atlas-01': None, 'met': None}
_CACHE_AT = {'pc': 0, 'atlas-01': 0, 'met': 0}
_CACHE_TTL = 300 # 5 min
def _http(url: str, body: dict | None = None, timeout=8):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method='POST' if body else 'GET')
if body: req.add_header('Content-Type', 'application/json')
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode())
def _check_node(name: str) -> dict:
"""Probe a node — returns {reachable, latency_ms, models, err?}."""
if name not in NODES: return {'reachable': False, 'err': 'unknown node'}
n = NODES[name]
t0 = time.time()
try:
# Fast TCP check first
with socket.create_connection((n['host'], n['port']), timeout=2): pass
# Then fetch tags
r = _http(f"http://{n['host']}:{n['port']}/api/tags", timeout=5)
models = [m['name'] for m in r.get('models', [])]
return {'reachable': True, 'latency_ms': int((time.time() - t0) * 1000), 'models': models}
except (socket.error, urllib.error.URLError, urllib.error.HTTPError) as e:
return {'reachable': False, 'latency_ms': int((time.time() - t0) * 1000), 'err': str(e)[:60]}
def nodes_status(force=False) -> dict:
"""Return per-node {reachable, models, latency_ms}. Cached 5 min."""
out = {}
now = time.time()
for name in NODES:
if not force and _NODE_MODELS_CACHE[name] is not None and now - _CACHE_AT[name] < _CACHE_TTL:
out[name] = _NODE_MODELS_CACHE[name]
else:
s = _check_node(name)
_NODE_MODELS_CACHE[name] = s
_CACHE_AT[name] = now
out[name] = s
return out
def pick_node(task: str = 'general', model: str | None = None, prefer: str | None = None) -> tuple[str, str] | None:
"""Decide (node, model) for a task. Returns None if no node available."""
if not model: model = TASK_MODEL.get(task, TASK_MODEL['general'])
status = nodes_status()
# Build preference order
order = []
if prefer and prefer in NODES: order.append(prefer)
# task-driven defaults
if task in ('embed',) or task.endswith('_fast') or task == 'fast' or task == 'voice':
order += ['pc', 'atlas-01', 'met']
elif task == 'vision':
order += ['pc'] # only PC has vision model
else:
order += ['pc', 'atlas-01', 'met']
# dedupe preserving order
seen = set(); order = [n for n in order if not (n in seen or seen.add(n))]
for name in order:
s = status.get(name, {})
if not s.get('reachable'): continue
models = s.get('models', [])
# exact match
if model in models: return (name, model)
# match-by-prefix (qwen2.5-coder:7b matches qwen2.5-coder:7b-instruct-q4_K_M etc.)
for m in models:
if m.startswith(model.split(':')[0]): return (name, m)
return None
def ask(prompt: str, task: str = 'general', node_pref: str | None = None,
max_tokens: int = 512, temperature: float = 0.4, timeout: int = 120) -> dict:
pick = pick_node(task, prefer=node_pref)
if not pick: return {'ok': False, 'err': 'no node has the required model'}
node, model = pick
n = NODES[node]
t0 = time.time()
try:
r = _http(f"http://{n['host']}:{n['port']}/api/generate", {
'model': model, 'prompt': prompt, 'stream': False,
'options': {'temperature': temperature, 'num_predict': max_tokens},
}, timeout=timeout)
return {'ok': True, 'text': r.get('response', '').strip(),
'node': node, 'model': model,
'elapsed_s': round(time.time() - t0, 2)}
except Exception as e:
return {'ok': False, 'err': str(e)[:120], 'node': node, 'model': model,
'elapsed_s': round(time.time() - t0, 2)}
def embed(text: str, node_pref: str | None = None) -> dict:
pick = pick_node('embed', prefer=node_pref)
if not pick: return {'ok': False, 'err': 'no embed model'}
node, model = pick
n = NODES[node]
t0 = time.time()
try:
r = _http(f"http://{n['host']}:{n['port']}/api/embeddings", {
'model': model, 'prompt': text,
}, timeout=30)
return {'ok': True, 'vector': r.get('embedding', []),
'node': node, 'model': model,
'elapsed_s': round(time.time() - t0, 2)}
except Exception as e:
return {'ok': False, 'err': str(e)[:120]}
if __name__ == '__main__':
import sys
cmd = sys.argv[1] if len(sys.argv) > 1 else 'status'
if cmd == 'status':
import io
try: sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
except: pass
s = nodes_status(force=True)
for name, info in s.items():
mark = 'OK ' if info.get('reachable') else 'FAIL'
mc = len(info.get('models', []))
lat = info.get('latency_ms', '?')
err = info.get('err', '')
print(f' {mark} {name:10s} {mc:>2d} models {lat:>4}ms {err}')
elif cmd == 'ask':
task = sys.argv[2] if len(sys.argv) > 2 else 'general'
prompt = ' '.join(sys.argv[3:]) or 'say OK only'
r = ask(prompt, task=task)
if r['ok']:
print(f'[{r["node"]} / {r["model"]} / {r["elapsed_s"]}s]')
print(r['text'])
else:
print('ERR:', r.get('err'))
elif cmd == 'pick':
task = sys.argv[2] if len(sys.argv) > 2 else 'general'
prefer = sys.argv[3] if len(sys.argv) > 3 else None
p = pick_node(task, prefer=prefer)
print(p or 'no node available')

View file

@ -0,0 +1,214 @@
#!/usr/bin/env python3
"""
cluster_then_polish.py two-pass LLM pattern wrapper.
Wraps cluster.ask() with explicit "this is a draft, mark for human/Claude polish"
metadata. Goal: cluster does the heavy lifting (parallel, cheap, always-on),
Claude or human review handles the final polish.
Why: open-source 7B models can produce structured / classified / extracted output
reliably, but produce generic narrative text without grounding. Letting them
"finish" content sent to clients = quality loss. This wrapper bakes in the
two-pass expectation: cluster draft human/Claude approval before any external use.
Public API:
draft(prompt, task='general', node_pref=None, **kw) -> {ok, text, model, node, polish_required: True, draft_id, hint}
save_draft(draft, path) -> writes md with frontmatter
mark_polished(draft_id) -> updates draft registry
Usage example:
from cluster_then_polish import draft, save_draft
d = draft("Schrijf cold-WhatsApp voor lunchroom in Tilburg", task='chat', node_pref='pc')
save_draft(d, '~/.config/atlas/drafts/lunchroom_outreach.md')
# User/Claude reviews + edits before sending
Registry: drafts logged to ~/.config/atlas/drafts/_registry.json with
status (draft/polished/sent), creation time, node/model used, prompt hash.
"""
from __future__ import annotations
import sys, json, hashlib, time
from pathlib import Path
from datetime import datetime
HOME = Path.home()
ATLAS = HOME / '.config' / 'atlas'
DRAFTS_DIR = ATLAS / 'drafts'
REGISTRY = DRAFTS_DIR / '_registry.json'
sys.path.insert(0, str(ATLAS))
try:
import cluster
except ImportError:
raise RuntimeError("cluster.py not importable; check ~/.config/atlas/cluster.py")
# Task types that should ALWAYS go through polish (narrative, klant-facing)
POLISH_REQUIRED_TASKS = {'chat', 'reason', 'general'}
# Task types where cluster output is directly usable (structured, classified)
POLISH_OPTIONAL_TASKS = {'fast', 'embed', 'code', 'vision'}
def _hash_prompt(prompt: str) -> str:
return hashlib.sha1(prompt.encode('utf-8')).hexdigest()[:10]
def _load_registry() -> dict:
DRAFTS_DIR.mkdir(parents=True, exist_ok=True)
if REGISTRY.exists():
try:
return json.loads(REGISTRY.read_text(encoding='utf-8'))
except Exception:
pass
return {"drafts": {}}
def _save_registry(reg: dict) -> None:
REGISTRY.write_text(json.dumps(reg, indent=2), encoding='utf-8')
def draft(prompt: str, task: str = 'general', node_pref: str | None = None,
max_tokens: int = 800, temperature: float = 0.5, timeout: int = 180,
purpose: str = '') -> dict:
"""
Call cluster.ask() but tag the result as draft requiring polish.
:param purpose: short label (e.g. 'cold_outreach', 'morning_brief', 'mail_triage')
used for the draft filename and routing decisions
"""
t0 = time.time()
r = cluster.ask(prompt, task=task, node_pref=node_pref,
max_tokens=max_tokens, temperature=temperature, timeout=timeout)
elapsed = round(time.time() - t0, 1)
if not r.get('ok'):
return {
"ok": False,
"err": r.get('err', 'cluster error'),
"polish_required": True,
"hint": "cluster call failed; do not use any partial output"
}
draft_id = f"{int(time.time())}-{_hash_prompt(prompt)}"
polish_required = task in POLISH_REQUIRED_TASKS
result = {
"ok": True,
"draft_id": draft_id,
"text": r['text'],
"model": r.get('model', '?'),
"node": r.get('node', '?'),
"elapsed_s": elapsed,
"task": task,
"purpose": purpose,
"polish_required": polish_required,
"polish_status": "pending" if polish_required else "not_required",
"created_at": datetime.now().isoformat(timespec='seconds'),
"hint": (
"POLISH REQUIRED before external use. Cluster 7B models produce "
"generic narrative; review for: specificity, sector-fit, tone, "
"factual claims. Then call mark_polished(draft_id)."
if polish_required else
"Structured output, safe to use as-is for triage/classify. "
"Manual spot-check still recommended."
)
}
# Register
reg = _load_registry()
reg['drafts'][draft_id] = {
"purpose": purpose,
"task": task,
"node": result['node'],
"model": result['model'],
"created_at": result['created_at'],
"polish_status": result['polish_status'],
"prompt_hash": _hash_prompt(prompt),
"char_count": len(r['text']),
}
_save_registry(reg)
return result
def save_draft(draft_result: dict, path: str | Path) -> Path:
"""Save draft to .md with frontmatter so reviewer knows status."""
p = Path(path).expanduser()
p.parent.mkdir(parents=True, exist_ok=True)
fm = (
f"---\n"
f"draft_id: {draft_result.get('draft_id', '?')}\n"
f"polish_status: {draft_result.get('polish_status', '?')}\n"
f"node: {draft_result.get('node', '?')}\n"
f"model: {draft_result.get('model', '?')}\n"
f"task: {draft_result.get('task', '?')}\n"
f"purpose: {draft_result.get('purpose', '?')}\n"
f"created_at: {draft_result.get('created_at', '?')}\n"
f"polish_required: {draft_result.get('polish_required', False)}\n"
f"---\n\n"
f"> **HINT**: {draft_result.get('hint', '')}\n\n"
)
p.write_text(fm + draft_result.get('text', ''), encoding='utf-8')
return p
def mark_polished(draft_id: str, polished_by: str = 'human') -> bool:
"""Update registry to indicate draft has been reviewed/polished."""
reg = _load_registry()
if draft_id in reg.get('drafts', {}):
reg['drafts'][draft_id]['polish_status'] = 'polished'
reg['drafts'][draft_id]['polished_by'] = polished_by
reg['drafts'][draft_id]['polished_at'] = datetime.now().isoformat(timespec='seconds')
_save_registry(reg)
return True
return False
def list_pending(purpose_filter: str = '') -> list:
"""Return list of drafts still awaiting polish (polish_status='pending')."""
reg = _load_registry()
out = []
for did, info in reg.get('drafts', {}).items():
if info.get('polish_status') != 'pending':
continue
if purpose_filter and purpose_filter not in info.get('purpose', ''):
continue
out.append({"draft_id": did, **info})
return sorted(out, key=lambda x: x.get('created_at', ''), reverse=True)
if __name__ == '__main__':
import argparse
ap = argparse.ArgumentParser()
ap.add_argument('prompt', nargs='?', help='Prompt text')
ap.add_argument('-task', default='general')
ap.add_argument('-node', default=None)
ap.add_argument('-purpose', default='ad_hoc')
ap.add_argument('-out', default=None, help='Save draft to this path')
ap.add_argument('--list-pending', action='store_true')
args = ap.parse_args()
if args.list_pending:
for d in list_pending():
print(f"{d['draft_id']} [{d['purpose']}] {d['model']}@{d['node']} {d['char_count']}ch {d['created_at']}")
sys.exit(0)
if not args.prompt:
print("usage: cluster_then_polish.py 'prompt' [-task T] [-node N] [-purpose P] [-out FILE]")
sys.exit(1)
d = draft(args.prompt, task=args.task, node_pref=args.node, purpose=args.purpose)
if not d['ok']:
print(f"ERROR: {d.get('err')}")
sys.exit(1)
print(f"[{d['node']}/{d['model']} {d['elapsed_s']}s] draft_id={d['draft_id']}")
print(f"polish_status: {d['polish_status']}")
print(f"--- text ({len(d['text'])} chars) ---")
print(d['text'])
print()
if args.out:
p = save_draft(d, args.out)
print(f"saved -> {p}")

189
scripts/vault_rag_expand.py Normal file
View file

@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""
vault_rag_expand.py Atlas Mind expansion.
Walks selected vault folders + batch uploads .md files to Open WebUI KB
so ALL cluster models query against the same knowledge graph.
Curated set (signal > noise):
- 00_Atlas_Wiki/* operator-curated canonical pages
- 01_Clients/* client briefs + relationship history
- 04_Sales/* pipeline, pricing, templates
- 06_Marketing/* brand, positioning, campaigns
- 07_Finance/*.md money facts + decisions
Skip:
- 99_Quarantine/ + 99_Archive/ (dead)
- .git/ .obsidian/ (config)
- Files > 100 KB (template-heavy, low signal)
"""
import os
import json
import time
import hashlib
import urllib.request
import urllib.error
from pathlib import Path
OPENWEBUI = "http://<YOUR_VPS_IP>:8082"
KB_ID = "c2b9f985-69f6-4dee-b216-73d15bdfb8bd"
TOKEN_PATH = Path(r"C:\Users\Gebruiker\AppData\Local\Atlas\openwebui-token.json")
VAULT_ROOT = Path(r"C:\Users\Gebruiker\MEGA\Atlas Corporation")
INDEX_LOG = Path(r"C:\Users\Gebruiker\AppData\Local\Atlas\analyses\rag_index.json")
INCLUDE_FOLDERS = [
"00_Atlas_Wiki",
"01_Clients",
"04_Sales",
"06_Marketing",
"07_Finance",
]
EXCLUDE_KEYWORDS = ["99_Quarantine", "99_Archive", "_archive", ".git", ".obsidian",
"backup", "_backup", "draft-202", "old_"]
MAX_FILE_SIZE = 100_000 # 100 KB
MAX_FILES_PER_RUN = 200 # rate-limit per cron tick
def load_token():
with open(TOKEN_PATH) as f:
return json.load(f)["token"]
def load_index():
if INDEX_LOG.exists():
try: return json.loads(INDEX_LOG.read_text(encoding="utf-8"))
except: pass
return {"uploaded": {}, "errors": []}
def save_index(idx):
INDEX_LOG.parent.mkdir(parents=True, exist_ok=True)
INDEX_LOG.write_text(json.dumps(idx, indent=2), encoding="utf-8")
def file_hash(p):
return hashlib.sha1(p.read_bytes()).hexdigest()[:12]
def list_candidates():
"""Walk include-folders, yield (Path, relpath_str, hash)."""
out = []
for folder in INCLUDE_FOLDERS:
root = VAULT_ROOT / folder
if not root.exists():
print(f"skip (not found): {root}")
continue
for p in root.rglob("*.md"):
rel = p.relative_to(VAULT_ROOT).as_posix()
if any(kw in rel for kw in EXCLUDE_KEYWORDS): continue
if p.stat().st_size > MAX_FILE_SIZE: continue
if p.stat().st_size < 100: continue # near-empty
try:
h = file_hash(p)
out.append((p, rel, h))
except Exception:
pass
return out
def upload_file(token, path: Path, relpath: str):
"""Upload single file to Open WebUI via multipart, then attach to KB."""
# Build multipart manually (urllib doesn't have it natively, use boundary)
import uuid
boundary = "----atlas-" + uuid.uuid4().hex
filename = path.name
file_bytes = path.read_bytes()
body_parts = [
f"--{boundary}".encode(),
f'Content-Disposition: form-data; name="file"; filename="{filename}"'.encode(),
b"Content-Type: text/markdown",
b"",
file_bytes,
f"--{boundary}--".encode(),
b"",
]
body = b"\r\n".join(body_parts)
# Step 1: upload file
req = urllib.request.Request(
f"{OPENWEBUI}/api/v1/files/",
data=body, method="POST",
)
req.add_header("Authorization", f"Bearer {token}")
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
try:
with urllib.request.urlopen(req, timeout=30) as r:
file_info = json.loads(r.read().decode())
file_id = file_info.get("id")
if not file_id:
return False, "no file_id returned"
except urllib.error.HTTPError as e:
return False, f"upload HTTP {e.code}: {e.read().decode()[:120]}"
except Exception as e:
return False, f"upload err: {type(e).__name__}"
# Step 2: attach to KB
req2 = urllib.request.Request(
f"{OPENWEBUI}/api/v1/knowledge/{KB_ID}/file/add",
data=json.dumps({"file_id": file_id}).encode(),
method="POST",
)
req2.add_header("Authorization", f"Bearer {token}")
req2.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req2, timeout=30) as r:
r.read()
return True, file_id
except urllib.error.HTTPError as e:
return False, f"attach HTTP {e.code}: {e.read().decode()[:120]}"
except Exception as e:
return False, f"attach err: {type(e).__name__}"
def main():
token = load_token()
idx = load_index()
cands = list_candidates()
print(f"Found {len(cands)} candidate files in {len(INCLUDE_FOLDERS)} folders")
new_to_upload = []
skipped = 0
for p, rel, h in cands:
prev = idx["uploaded"].get(rel)
if prev and prev.get("hash") == h:
skipped += 1
continue
new_to_upload.append((p, rel, h))
new_to_upload = new_to_upload[:MAX_FILES_PER_RUN]
print(f"Already uploaded (skip): {skipped}")
print(f"To upload this run: {len(new_to_upload)} (cap {MAX_FILES_PER_RUN})")
ok_count = 0
err_count = 0
t_start = time.time()
for i, (p, rel, h) in enumerate(new_to_upload, 1):
success, info = upload_file(token, p, rel)
if success:
idx["uploaded"][rel] = {"file_id": info, "hash": h, "ts": time.strftime("%Y-%m-%d %H:%M:%S")}
ok_count += 1
print(f" [{i}/{len(new_to_upload)}] OK {rel}")
else:
idx["errors"].append({"rel": rel, "err": str(info), "ts": time.strftime("%Y-%m-%d %H:%M:%S")})
err_count += 1
print(f" [{i}/{len(new_to_upload)}] FAIL {rel} -> {info}")
# Save state every 10 uploads in case we crash
if i % 10 == 0:
save_index(idx)
time.sleep(0.3) # gentle pacing
save_index(idx)
elapsed = round(time.time() - t_start, 1)
print(f"\nDone in {elapsed}s")
print(f" uploaded: {ok_count}")
print(f" errors: {err_count}")
print(f" total in index: {len(idx['uploaded'])}")
if __name__ == "__main__":
main()

840
web/atlas-chat.html Normal file
View file

@ -0,0 +1,840 @@
<!doctype html>
<html lang="nl" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#0b1220">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>Atlas Chat</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%230b1220'/%3E%3Ctext x='16' y='22' text-anchor='middle' font-family='system-ui,sans-serif' font-weight='700' font-size='18' fill='%233B82F6'%3EA%3C/text%3E%3C/svg%3E">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
<style>
:root {
--bg: #0b1220;
--surface: #111a2e;
--surface-2: #1a253f;
--border: #1f2c4a;
--text: #e6edf6;
--text-dim: #8b96b1;
--accent: #3b82f6;
--accent-soft: rgba(59,130,246,.15);
--user: #2dd4bf;
--assistant: #93c5fd;
--err: #f87171;
--ok: #4ade80;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
.app { display: grid; grid-template-columns: 280px 1fr; height: 100vh; height: 100dvh; }
/* SIDEBAR */
.sidebar {
background: var(--surface);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
overflow: hidden;
}
.brand {
padding: 14px 16px;
border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 10px;
}
.brand-logo {
width: 30px; height: 30px; border-radius: 6px;
background: linear-gradient(135deg, var(--accent), #1e40af);
color: white; font-weight: 800; font-size: 16px;
display: flex; align-items: center; justify-content: center;
}
.brand-text { font-weight: 600; letter-spacing: .2px; }
.brand-sub { font-size: 10px; color: var(--text-dim); margin-top: 2px; }
.btn-new {
margin: 12px;
padding: 10px 14px;
background: var(--accent);
color: white; border: none; border-radius: 8px;
font-weight: 500; cursor: pointer; font-size: 13px;
display: flex; align-items: center; gap: 8px; justify-content: center;
transition: background .15s;
}
.btn-new:hover { background: #2563eb; }
.history {
flex: 1; overflow-y: auto; padding: 6px;
}
.history-item {
padding: 10px 12px; margin: 2px 0;
border-radius: 6px; cursor: pointer;
font-size: 13px; color: var(--text-dim);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.history-item:hover { background: var(--surface-2); color: var(--text); }
.history-item.active { background: var(--accent-soft); color: var(--text); border-left: 2px solid var(--accent); padding-left: 10px; }
.history-empty { padding: 16px; font-size: 12px; color: var(--text-dim); text-align: center; }
.sidebar-footer {
border-top: 1px solid var(--border); padding: 10px 14px;
display: flex; flex-direction: column; gap: 6px;
}
.node-status {
display: flex; align-items: center; gap: 6px;
font-size: 11px; color: var(--text-dim);
}
.node-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-dim); }
.node-dot.ok { background: var(--ok); }
.node-dot.err { background: var(--err); }
.settings-btn {
background: none; border: 1px solid var(--border); color: var(--text-dim);
padding: 6px 10px; border-radius: 6px; font-size: 11px;
cursor: pointer; margin-top: 6px;
}
.settings-btn:hover { color: var(--text); border-color: var(--accent); }
/* MAIN CHAT */
.main { display: flex; flex-direction: column; overflow: hidden; }
.topbar {
padding: 12px 20px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 12px;
background: var(--surface);
}
.model-select {
background: var(--surface-2); color: var(--text);
border: 1px solid var(--border); border-radius: 6px;
padding: 6px 10px; font-size: 13px; cursor: pointer;
min-width: 200px;
}
.menu-toggle {
display: none;
background: none; border: none; color: var(--text);
font-size: 22px; cursor: pointer; padding: 4px;
}
.title-bar { flex: 1; font-size: 14px; color: var(--text-dim); }
.title-bar strong { color: var(--text); font-weight: 500; }
.chat-area {
flex: 1; overflow-y: auto; padding: 20px;
scroll-behavior: smooth;
}
.welcome {
max-width: 720px; margin: 60px auto; text-align: center;
}
.welcome h1 {
font-size: 28px; margin: 0 0 12px; font-weight: 600;
background: linear-gradient(135deg, var(--accent), #93c5fd);
-webkit-background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent;
}
.welcome p { color: var(--text-dim); margin: 0 0 24px; line-height: 1.6; }
.suggestions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
.suggestion {
padding: 14px; background: var(--surface);
border: 1px solid var(--border); border-radius: 10px;
cursor: pointer; text-align: left; font-size: 13px;
transition: all .15s;
}
.suggestion:hover { border-color: var(--accent); background: var(--surface-2); }
.suggestion strong { display: block; color: var(--accent); margin-bottom: 4px; font-size: 12px; }
.message {
max-width: 800px; margin: 0 auto 20px;
padding: 16px 18px; border-radius: 12px;
}
.message.user {
background: var(--accent-soft);
border: 1px solid rgba(59,130,246,.3);
color: var(--text);
}
.message.assistant {
background: var(--surface);
border: 1px solid var(--border);
}
.message.error {
background: rgba(248,113,113,.1); border-color: rgba(248,113,113,.3);
}
.msg-meta {
display: flex; align-items: center; gap: 8px;
font-size: 11px; color: var(--text-dim); margin-bottom: 8px;
}
.msg-role { font-weight: 600; }
.msg-role.user { color: var(--user); }
.msg-role.assistant { color: var(--assistant); }
.msg-model { font-family: ui-monospace, 'Cascadia Code', Consolas, monospace; opacity: .7; }
.msg-content { font-size: 14.5px; line-height: 1.65; }
.msg-content p { margin: 0 0 10px; }
.msg-content p:last-child { margin-bottom: 0; }
.msg-content code {
background: #0a1322; padding: 1px 6px; border-radius: 4px;
font-family: ui-monospace, 'Cascadia Code', Consolas, monospace;
font-size: 13px; color: #93c5fd;
}
.msg-content pre {
background: #0a1322; padding: 12px; border-radius: 8px;
overflow-x: auto; border: 1px solid var(--border); margin: 10px 0;
}
.msg-content pre code { background: transparent; padding: 0; color: var(--text); }
.msg-content ul, .msg-content ol { padding-left: 20px; margin: 8px 0; }
.msg-content li { margin: 4px 0; }
.msg-content blockquote {
border-left: 3px solid var(--accent); padding-left: 12px;
margin: 10px 0; color: var(--text-dim);
}
.msg-content table { border-collapse: collapse; margin: 10px 0; font-size: 13px; }
.msg-content th, .msg-content td {
border: 1px solid var(--border); padding: 6px 10px;
}
.msg-content th { background: var(--surface-2); }
.msg-content a { color: var(--accent); }
.input-area {
padding: 12px 20px 16px;
border-top: 1px solid var(--border);
background: var(--surface);
}
.input-wrap {
max-width: 800px; margin: 0 auto;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 12px;
padding: 4px;
display: flex; align-items: flex-end; gap: 6px;
transition: border-color .15s;
}
.input-wrap:focus-within { border-color: var(--accent); }
#input {
flex: 1;
background: transparent; border: none; outline: none;
color: var(--text); font-family: inherit; font-size: 14.5px;
padding: 10px 12px; resize: none;
max-height: 200px; min-height: 38px;
line-height: 1.5;
}
.send-btn {
background: var(--accent); color: white;
border: none; border-radius: 8px;
width: 36px; height: 36px;
cursor: pointer; font-size: 16px;
display: flex; align-items: center; justify-content: center;
transition: all .15s;
}
.send-btn:hover { background: #2563eb; }
.send-btn:disabled { background: var(--border); cursor: not-allowed; }
.send-btn.stop { background: var(--err); }
.input-hint {
max-width: 800px; margin: 6px auto 0;
font-size: 11px; color: var(--text-dim);
display: flex; justify-content: space-between;
}
/* settings modal */
.modal-bg {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,.6); z-index: 100;
align-items: center; justify-content: center;
}
.modal-bg.open { display: flex; }
.modal {
background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; padding: 20px; max-width: 480px; width: 90%;
}
.modal h2 { margin-top: 0; font-size: 16px; }
.modal label { display: block; margin-top: 12px; font-size: 12px; color: var(--text-dim); }
.modal input {
width: 100%; padding: 8px 10px; margin-top: 4px;
background: var(--surface-2); color: var(--text);
border: 1px solid var(--border); border-radius: 6px;
font-family: ui-monospace, monospace; font-size: 12px;
}
.modal-actions { margin-top: 16px; display: flex; gap: 8px; justify-content: flex-end; }
.modal-actions button {
padding: 8px 14px; border-radius: 6px; cursor: pointer;
font-size: 13px; border: 1px solid var(--border);
background: var(--surface-2); color: var(--text);
}
.modal-actions button.primary { background: var(--accent); border-color: var(--accent); color: white; }
/* mobile */
@media (max-width: 768px) {
.app { grid-template-columns: 1fr; }
.sidebar {
position: fixed; left: -280px; top: 0; bottom: 0; width: 280px;
z-index: 50; transition: left .25s;
}
.sidebar.open { left: 0; }
.menu-toggle { display: block; }
.suggestions { grid-template-columns: 1fr; }
.topbar { padding: 10px 14px; }
.chat-area { padding: 14px; }
.input-area { padding: 10px 14px 14px; }
.model-select { min-width: 140px; max-width: 200px; }
}
/* scrollbar */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--accent); }
/* streaming cursor */
.cursor::after {
content: '▊'; color: var(--accent); animation: blink 1s steps(2) infinite;
}
@keyframes blink { 50% { opacity: 0; } }
</style>
</head>
<body>
<div class="app">
<!-- SIDEBAR -->
<aside class="sidebar" id="sidebar">
<div class="brand">
<div class="brand-logo">A</div>
<div>
<div class="brand-text">Atlas Chat</div>
<div class="brand-sub">cluster · vault · tools</div>
</div>
</div>
<button class="btn-new" onclick="newConversation()"> Nieuw gesprek</button>
<div class="history" id="history">
<div class="history-empty">Geen gesprekken</div>
</div>
<div class="sidebar-footer">
<div class="node-status">
<span class="node-dot" id="dot-atlas01"></span>
<span>atlas-01</span>
<span id="lat-atlas01" style="margin-left:auto"></span>
</div>
<div class="node-status">
<span class="node-dot" id="dot-pc"></span>
<span>pc</span>
<span id="lat-pc" style="margin-left:auto"></span>
</div>
<div class="node-status">
<span class="node-dot" id="dot-met"></span>
<span>met</span>
<span id="lat-met" style="margin-left:auto"></span>
</div>
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--text-dim);margin-top:8px;cursor:pointer">
<input type="checkbox" id="rag-toggle" style="cursor:pointer"> Atlas Vault RAG
</label>
<button class="settings-btn" onclick="openSettings()">⚙ Instellingen</button>
</div>
</aside>
<!-- MAIN -->
<main class="main">
<div class="topbar">
<button class="menu-toggle" onclick="toggleSidebar()"></button>
<select class="model-select" id="model-select"></select>
<div class="title-bar"><strong id="title">Atlas Chat</strong></div>
</div>
<div class="chat-area" id="chat">
<div class="welcome" id="welcome">
<h1>Atlas Chat</h1>
<p>Lokale cluster (3 nodes, 25 modellen) of Claude API als je een sleutel hebt gezet.<br>
Geen Claude-UI nodig.</p>
<div class="suggestions">
<div class="suggestion" onclick="suggest(this)">
<strong>📊 Cash check</strong>
Wat staat er open in Odoo en wat is de status?
</div>
<div class="suggestion" onclick="suggest(this)">
<strong>📋 Sprint plan</strong>
Maak een dag-planning voor vandaag op basis van Asana
</div>
<div class="suggestion" onclick="suggest(this)">
<strong>✍️ Outreach</strong>
Schrijf een cold-WhatsApp voor een Tilburgse barbershop
</div>
<div class="suggestion" onclick="suggest(this)">
<strong>🔧 Server check</strong>
Welke containers op atlas-01 draaien niet?
</div>
</div>
</div>
</div>
<div class="input-area">
<div class="input-wrap">
<textarea id="input" placeholder="Typ je vraag... (Enter = verstuur, Shift+Enter = nieuwe regel)" rows="1"></textarea>
<button class="send-btn" id="send-btn" onclick="sendOrStop()"></button>
</div>
<div class="input-hint">
<span id="status-hint">Gereed</span>
<span id="token-hint"></span>
</div>
</div>
</main>
</div>
<!-- SETTINGS MODAL -->
<div class="modal-bg" id="modal" onclick="if(event.target===this)closeSettings()">
<div class="modal">
<h2>Atlas Chat instellingen</h2>
<label>Anthropic API key (optioneel — voor Claude modellen)</label>
<input type="password" id="anthropic-key" placeholder="sk-ant-..." />
<label>Open WebUI JWT (optioneel — voor Vault RAG)</label>
<input type="password" id="openwebui-jwt" placeholder="eyJ..." />
<label>System prompt (default)</label>
<input type="text" id="system-prompt" placeholder="Je bent Atlas — Chaib's AI assistent..." />
<div class="modal-actions">
<button onclick="clearStorage()" style="margin-right:auto;color:var(--err)">Wis alles</button>
<button onclick="closeSettings()">Annuleer</button>
<button class="primary" onclick="saveSettings()">Opslaan</button>
</div>
</div>
</div>
<script>
// ===== CONFIG =====
const NODES = {
'atlas-01': { url: location.origin + '/ollama', display: 'atlas-01 (vps)' },
'met': { url: location.origin + '/ollama/met', display: 'met (desktop)' },
// PC nog niet bereikbaar via tailnet vanaf atlas-01 (Windows bind issue)
};
const STORAGE_KEY = 'atlas-chat-v1';
const SETTINGS_KEY = 'atlas-chat-settings';
const VAULT_KB_ID = '<YOUR_KB_ID>';
const OPENWEBUI_URL = 'http://<YOUR_VPS_IP>:8082';
// ===== STATE =====
let state = {
conversations: {},
current: null,
models: [],
currentModel: null,
streaming: false,
abortController: null,
};
// Baked Vault JWT (operator's own infra, tailnet-only — expires 2026-06-23)
const BAKED_JWT = '<YOUR_OPENWEBUI_JWT_HERE>';
const BAKED_JWT_EXPIRES = '2026-06-23';
let settings = {
anthropicKey: '',
openwebuiJwt: BAKED_JWT,
systemPrompt: 'Je bent Atlas — Chaib\'s AI assistent. Antwoord direct, NL of EN volgens de vraag, kort en gegrond.',
};
// ===== INIT =====
async function init() {
loadStorage();
loadSettings();
await refreshNodes();
await loadModels();
renderHistory();
bindInput();
if (!state.current) newConversation();
}
function loadStorage() {
try {
const s = localStorage.getItem(STORAGE_KEY);
if (s) {
const d = JSON.parse(s);
state.conversations = d.conversations || {};
state.current = d.current || null;
}
} catch (e) { console.warn('storage load fail', e); }
}
function saveStorage() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
conversations: state.conversations, current: state.current,
}));
} catch (e) { console.warn('storage save fail', e); }
}
function loadSettings() {
try {
const s = localStorage.getItem(SETTINGS_KEY);
if (s) settings = { ...settings, ...JSON.parse(s) };
} catch (e) {}
}
// ===== NODES =====
async function refreshNodes() {
for (const [name, conf] of Object.entries(NODES)) {
const dotId = 'dot-' + name.replace('-', '');
const latId = 'lat-' + name.replace('-', '');
const dot = document.getElementById(dotId);
const lat = document.getElementById(latId);
const t0 = performance.now();
try {
const r = await fetch(conf.url + '/api/tags', { signal: AbortSignal.timeout(4000) });
if (r.ok) {
const t = Math.round(performance.now() - t0);
if (dot) dot.className = 'node-dot ok';
if (lat) lat.textContent = t + 'ms';
const d = await r.json();
conf.models = (d.models || []).map(m => m.name);
} else throw new Error('http ' + r.status);
} catch (e) {
if (dot) dot.className = 'node-dot err';
if (lat) lat.textContent = 'off';
conf.models = [];
}
}
}
// ===== VAULT RAG =====
async function queryVaultRAG(query, k=3) {
if (!settings.openwebuiJwt) return null;
try {
const r = await fetch(OPENWEBUI_URL + '/api/v1/retrieval/query/collection', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + settings.openwebuiJwt },
body: JSON.stringify({ collection_names: [VAULT_KB_ID], query, k }),
signal: AbortSignal.timeout(8000),
});
if (!r.ok) return null;
const d = await r.json();
const docs = (d.documents?.[0] || []).map((doc, i) => {
const meta = d.metadatas?.[0]?.[i] || {};
return `[${meta.source || 'vault'}]\n${doc}`;
});
return docs.join('\n\n---\n\n');
} catch (e) {
console.warn('vault RAG fail', e);
return null;
}
}
async function loadModels() {
const sel = document.getElementById('model-select');
sel.innerHTML = '';
for (const [name, conf] of Object.entries(NODES)) {
if (!conf.models) continue;
for (const m of conf.models) {
const opt = document.createElement('option');
opt.value = name + '|' + m;
opt.textContent = `${m} (${name})`;
sel.appendChild(opt);
}
}
// add Claude option if key set
if (settings.anthropicKey) {
const sep = document.createElement('option');
sep.disabled = true; sep.textContent = '─── Cloud ───';
sel.appendChild(sep);
for (const m of ['claude-sonnet-4-5-20250929', 'claude-opus-4-20250514', 'claude-haiku-4-5']) {
const opt = document.createElement('option');
opt.value = 'anthropic|' + m;
opt.textContent = m + ' (cloud)';
sel.appendChild(opt);
}
}
// Default = qwen2.5:7b on atlas-01
const def = Array.from(sel.options).find(o => o.value.includes('qwen2.5:7b'));
if (def) sel.value = def.value;
state.currentModel = sel.value;
sel.onchange = () => { state.currentModel = sel.value; };
}
// ===== CONVERSATIONS =====
function newConversation() {
const id = 'c-' + Date.now();
state.conversations[id] = { id, title: 'Nieuw gesprek', messages: [], created: Date.now() };
state.current = id;
saveStorage(); renderHistory(); renderChat();
document.getElementById('input').focus();
}
function selectConversation(id) {
state.current = id;
saveStorage(); renderHistory(); renderChat();
if (window.innerWidth <= 768) toggleSidebar();
}
function renderHistory() {
const h = document.getElementById('history');
const list = Object.values(state.conversations).sort((a,b)=>b.created-a.created);
if (!list.length) { h.innerHTML = '<div class="history-empty">Geen gesprekken</div>'; return; }
h.innerHTML = '';
for (const c of list) {
const div = document.createElement('div');
div.className = 'history-item' + (c.id === state.current ? ' active' : '');
div.textContent = c.title || 'Naamloos';
div.onclick = () => selectConversation(c.id);
h.appendChild(div);
}
}
function renderChat() {
const chat = document.getElementById('chat');
const conv = state.conversations[state.current];
if (!conv || !conv.messages.length) {
chat.innerHTML = document.getElementById('welcome')?.outerHTML
|| '<div class="welcome"><p style="color:var(--text-dim)">Stel een vraag.</p></div>';
document.getElementById('title').textContent = conv?.title || 'Atlas Chat';
return;
}
chat.innerHTML = '';
for (const m of conv.messages) appendMessage(m, false);
document.getElementById('title').textContent = conv.title;
chat.scrollTop = chat.scrollHeight;
}
function appendMessage(m, save=true) {
const chat = document.getElementById('chat');
document.getElementById('welcome')?.remove();
const div = document.createElement('div');
div.className = 'message ' + m.role + (m.error ? ' error' : '');
div.dataset.idx = (state.conversations[state.current]?.messages.length || 0) - 1;
const meta = `<div class="msg-meta"><span class="msg-role ${m.role}">${m.role === 'user' ? 'Jij' : 'Atlas'}</span>${m.model ? `<span class="msg-model">· ${m.model}</span>` : ''}${m.elapsed ? `<span class="msg-model">· ${m.elapsed}s</span>` : ''}</div>`;
div.innerHTML = meta + `<div class="msg-content">${renderMd(m.content)}</div>`;
chat.appendChild(div);
if (save) {
state.conversations[state.current].messages.push(m);
saveStorage();
}
chat.scrollTop = chat.scrollHeight;
return div;
}
function renderMd(text) {
if (!text) return '';
const html = marked.parse(text, { breaks: true, gfm: true });
return DOMPurify.sanitize(html);
}
// ===== SEND =====
function bindInput() {
const inp = document.getElementById('input');
inp.addEventListener('input', () => {
inp.style.height = 'auto';
inp.style.height = Math.min(200, inp.scrollHeight) + 'px';
});
inp.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendOrStop();
}
});
}
function sendOrStop() {
if (state.streaming) {
if (state.abortController) state.abortController.abort();
return;
}
const inp = document.getElementById('input');
const text = inp.value.trim();
if (!text) return;
inp.value = ''; inp.style.height = 'auto';
send(text);
}
async function send(text) {
const conv = state.conversations[state.current];
if (!conv) return;
// First message → set title
if (!conv.messages.length) {
conv.title = text.slice(0, 50).trim() + (text.length > 50 ? '…' : '');
document.getElementById('title').textContent = conv.title;
renderHistory();
}
appendMessage({ role: 'user', content: text });
// Vault RAG: if toggle is on + JWT set, fetch context first
let ragContext = '';
if (document.getElementById('rag-toggle')?.checked) {
setStatus('Atlas Vault RAG zoeken…');
const ctx = await queryVaultRAG(text, 3);
if (ctx) ragContext = '\n\n[ATLAS VAULT CONTEXT — gebruik dit als bron, citeer source-bestanden indien relevant]\n' + ctx;
else setStatus('RAG faalde (geen JWT?) — doorgaan zonder vault');
}
// Build messages array
const msgs = [];
if (settings.systemPrompt || ragContext) {
msgs.push({ role: 'system', content: (settings.systemPrompt || '') + ragContext });
}
for (const m of conv.messages) {
if (m.role === 'user' || m.role === 'assistant') {
msgs.push({ role: m.role, content: m.content });
}
}
// Pick endpoint
const [node, model] = state.currentModel.split('|');
state.streaming = true;
setSendBtn('stop');
setStatus('Bezig met ' + model + ' op ' + node + '…');
// Placeholder assistant message for streaming
const t0 = performance.now();
appendMessage({ role: 'assistant', content: '', model: `${node}/${model}` });
const asstDiv = document.querySelector('.message.assistant:last-of-type .msg-content');
asstDiv.classList.add('cursor');
state.abortController = new AbortController();
try {
if (node === 'anthropic') {
await streamAnthropic(model, msgs, asstDiv, t0);
} else {
await streamOllama(NODES[node], model, msgs, asstDiv, t0);
}
// Persist final assistant message
const finalText = asstDiv.dataset.fulltext || '';
const lastMsg = conv.messages[conv.messages.length - 1];
if (lastMsg && lastMsg.role === 'assistant') {
lastMsg.content = finalText;
lastMsg.elapsed = ((performance.now() - t0) / 1000).toFixed(1);
}
saveStorage();
} catch (e) {
asstDiv.innerHTML = renderMd('**Fout:** ' + (e.message || 'onbekend')) ;
const lastMsg = conv.messages[conv.messages.length - 1];
if (lastMsg) { lastMsg.content = '**Fout:** ' + e.message; lastMsg.error = true; }
saveStorage();
} finally {
asstDiv.classList.remove('cursor');
state.streaming = false;
state.abortController = null;
setSendBtn('send');
setStatus('Gereed');
}
}
async function streamOllama(nodeConf, model, msgs, div, t0) {
const r = await fetch(nodeConf.url + '/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, messages: msgs, stream: true }),
signal: state.abortController.signal,
});
if (!r.ok) throw new Error('Ollama HTTP ' + r.status);
const reader = r.body.getReader();
const dec = new TextDecoder();
let buf = '', full = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let nl;
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
try {
const j = JSON.parse(line);
if (j.message?.content) {
full += j.message.content;
div.dataset.fulltext = full;
div.innerHTML = renderMd(full);
document.getElementById('chat').scrollTop = document.getElementById('chat').scrollHeight;
}
if (j.done) {
const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
setStatus(`Klaar in ${elapsed}s`);
}
} catch (e) {}
}
}
}
async function streamAnthropic(model, msgs, div, t0) {
const sysMsg = msgs.find(m => m.role === 'system')?.content;
const filtered = msgs.filter(m => m.role !== 'system');
const r = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': settings.anthropicKey,
'anthropic-version': '2023-06-01',
'anthropic-dangerous-direct-browser-access': 'true',
},
body: JSON.stringify({
model, max_tokens: 4096, system: sysMsg,
messages: filtered, stream: true,
}),
signal: state.abortController.signal,
});
if (!r.ok) {
const err = await r.text();
throw new Error('Anthropic ' + r.status + ': ' + err.slice(0, 200));
}
const reader = r.body.getReader();
const dec = new TextDecoder();
let buf = '', full = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let lines = buf.split('\n');
buf = lines.pop();
for (const line of lines) {
if (!line.startsWith('data:')) continue;
const data = line.slice(5).trim();
if (data === '[DONE]') continue;
try {
const j = JSON.parse(data);
if (j.type === 'content_block_delta' && j.delta?.text) {
full += j.delta.text;
div.dataset.fulltext = full;
div.innerHTML = renderMd(full);
document.getElementById('chat').scrollTop = document.getElementById('chat').scrollHeight;
}
} catch (e) {}
}
}
}
// ===== UI HELPERS =====
function setSendBtn(mode) {
const b = document.getElementById('send-btn');
if (mode === 'stop') { b.classList.add('stop'); b.textContent = '◼'; }
else { b.classList.remove('stop'); b.textContent = '➤'; }
}
function setStatus(t) { document.getElementById('status-hint').textContent = t; }
function toggleSidebar() { document.getElementById('sidebar').classList.toggle('open'); }
function suggest(el) {
const inp = document.getElementById('input');
inp.value = el.querySelector('strong').nextSibling.textContent.trim();
inp.focus(); inp.dispatchEvent(new Event('input'));
}
// ===== SETTINGS =====
function openSettings() {
document.getElementById('anthropic-key').value = settings.anthropicKey;
document.getElementById('openwebui-jwt').value = settings.openwebuiJwt;
document.getElementById('system-prompt').value = settings.systemPrompt;
document.getElementById('modal').classList.add('open');
}
function closeSettings() { document.getElementById('modal').classList.remove('open'); }
function saveSettings() {
settings.anthropicKey = document.getElementById('anthropic-key').value.trim();
settings.openwebuiJwt = document.getElementById('openwebui-jwt').value.trim();
settings.systemPrompt = document.getElementById('system-prompt').value.trim();
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
closeSettings();
loadModels();
}
function clearStorage() {
if (!confirm('Alles wissen (gesprekken + instellingen)?')) return;
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(SETTINGS_KEY);
location.reload();
}
// ===== START =====
init();
</script>
</body>
</html>

984
web/atlas-command.html Normal file
View file

@ -0,0 +1,984 @@
<!doctype html>
<html lang="nl" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="theme-color" content="#000814">
<title>Atlas Command — unified cockpit</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%230b1220'/%3E%3Cpath d='M8 22L16 8L24 22M12 18H20' stroke='%233B82F6' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
<style>
:root {
--bg: #050b16;
--panel: rgba(11,22,40,.8);
--panel-2: #0e1a30;
--border: rgba(59,130,246,.25);
--text: #cce7ff;
--text-dim: #6896c4;
--accent: #3B82F6;
--cyan: #00d4ff;
--gold: #ffb020;
--green: #00ff88;
--err: #ff4060;
--user: #2dd4bf;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; }
body {
background: var(--bg);
background-image:
radial-gradient(circle at 10% 20%, rgba(59,130,246,.12) 0%, transparent 50%),
radial-gradient(circle at 90% 80%, rgba(0,212,255,.08) 0%, transparent 50%);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
}
/* ===== LAYOUT ===== */
.app {
position: fixed; inset: 0;
display: grid;
grid-template-rows: 50px 1fr 30px;
grid-template-columns: 280px 1fr 380px;
grid-template-areas:
"header header header"
"left center right"
"footer footer footer";
gap: 8px;
padding: 8px;
}
/* ===== HEADER ===== */
header {
grid-area: header;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0 16px;
display: flex; align-items: center; gap: 20px;
backdrop-filter: blur(8px);
}
.brand {
font-weight: 700; font-size: 15px; letter-spacing: 3px;
color: var(--cyan); text-shadow: 0 0 10px rgba(0,212,255,.4);
}
.brand-sub { font-size: 11px; color: var(--text-dim); margin-left: 6px; }
.live-brief {
flex: 1; font-size: 12px; color: var(--text);
font-family: 'Share Tech Mono', ui-monospace, monospace;
overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
}
.live-brief .label { color: var(--cyan); margin-right: 6px; }
.live-brief .err { color: var(--err); }
.live-brief .ok { color: var(--green); }
.btn {
background: var(--panel-2); border: 1px solid var(--border);
color: var(--text); padding: 5px 10px; border-radius: 3px;
font-size: 12px; cursor: pointer; font-family: inherit;
transition: all .15s;
}
.btn:hover { border-color: var(--cyan); color: var(--cyan); }
.btn.active { background: var(--accent); color: white; border-color: var(--accent); }
.btn.voice { background: linear-gradient(135deg, var(--accent), var(--cyan)); border: none; color: white; padding: 5px 14px; }
.btn.voice.listening { animation: pulse 1.5s infinite; }
@keyframes pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(0,212,255,.6); } 50% { box-shadow: 0 0 0 8px rgba(0,212,255,0); } }
/* ===== LEFT PANEL — Tiles ===== */
.left {
grid-area: left;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 4px;
padding: 12px;
overflow-y: auto;
}
.section-title {
font-size: 10px; color: var(--cyan); letter-spacing: 2px;
text-transform: uppercase; margin: 14px 0 6px;
padding-bottom: 4px; border-bottom: 1px solid var(--border);
}
.section-title:first-child { margin-top: 0; }
.tile-row {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px; margin: 2px 0;
border-radius: 4px; cursor: pointer;
text-decoration: none; color: var(--text);
font-size: 13px;
border: 1px solid transparent;
transition: all .15s;
}
.tile-row:hover { background: var(--panel-2); border-color: var(--border); }
.tile-row .icon { font-size: 16px; width: 20px; text-align: center; }
.tile-row .name { flex: 1; }
.tile-row .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-dim); }
.tile-row .dot.ok { background: var(--green); box-shadow: 0 0 6px var(--green); }
.tile-row .dot.err { background: var(--err); }
.tile-row.hot { border-color: var(--accent); background: rgba(59,130,246,.08); }
/* ===== CENTER — Chat + viewer ===== */
.center {
grid-area: center;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 4px;
display: flex; flex-direction: column;
overflow: hidden;
}
.tabbar {
display: flex; gap: 2px; border-bottom: 1px solid var(--border);
padding: 0 8px;
}
.tab {
padding: 8px 16px; cursor: pointer; font-size: 12px;
color: var(--text-dim); border-bottom: 2px solid transparent;
transition: all .15s;
}
.tab.active { color: var(--cyan); border-color: var(--cyan); }
.tab:hover { color: var(--text); }
.view { flex: 1; overflow: hidden; position: relative; }
.view > * { display: none; height: 100%; }
.view > .active { display: block; }
.view iframe { width: 100%; height: 100%; border: none; }
/* Chat panel */
#chat-view {
display: flex; flex-direction: column;
padding: 12px;
}
#chat-msgs {
flex: 1; overflow-y: auto; padding-right: 8px;
}
.msg {
margin-bottom: 12px; padding: 10px 14px;
border-radius: 8px; font-size: 13.5px; line-height: 1.55;
}
.msg.user { background: rgba(0,212,255,.08); border: 1px solid rgba(0,212,255,.3); }
.msg.assistant { background: rgba(59,130,246,.05); border: 1px solid var(--border); }
.msg .role { font-size: 10px; color: var(--cyan); letter-spacing: 2px; margin-bottom: 4px; font-weight: 600; }
.msg.assistant .role { color: var(--accent); }
.msg .content p { margin: 0 0 6px; }
.msg .content code { background: #0a1322; padding: 1px 6px; border-radius: 3px; font-family: ui-monospace; font-size: 12.5px; color: #93c5fd; }
.msg .content pre { background: #0a1322; padding: 10px; border-radius: 6px; overflow-x: auto; border: 1px solid var(--border); margin: 8px 0; }
.msg .content table { border-collapse: collapse; font-size: 12.5px; margin: 6px 0; }
.msg .content th, .msg .content td { border: 1px solid var(--border); padding: 4px 8px; }
.input-row {
display: flex; gap: 8px; margin-top: 10px; align-items: flex-end;
}
#chat-input {
flex: 1; background: rgba(0,212,255,.06);
border: 1px solid var(--border); border-radius: 4px;
color: var(--text); font-family: inherit; font-size: 14px;
padding: 8px 12px; outline: none; resize: none; min-height: 38px; max-height: 140px;
}
#chat-input:focus { border-color: var(--cyan); }
/* Quick Brief panel */
#brief-view {
padding: 20px; overflow-y: auto;
}
.brief-card {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 16px;
margin-bottom: 14px;
}
.brief-card h3 {
font-size: 11px; color: var(--cyan); letter-spacing: 2px;
text-transform: uppercase; margin-bottom: 10px;
}
.brief-card .row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 13px; }
.brief-card .row .lbl { color: var(--text-dim); }
.brief-card .row .val { color: var(--text); font-weight: 500; font-family: 'Share Tech Mono', ui-monospace, monospace; }
.brief-card .val.err { color: var(--err); }
.brief-card .val.ok { color: var(--green); }
.brief-card .val.warn { color: var(--gold); }
/* ===== RIGHT PANEL — context / live ===== */
.right {
grid-area: right;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 4px;
padding: 12px;
overflow-y: auto;
}
.event {
font-family: ui-monospace, 'Cascadia Code', monospace;
font-size: 11px; padding: 5px 0;
border-bottom: 1px dashed rgba(59,130,246,.12);
color: var(--text);
}
.event .t { color: var(--text-dim); margin-right: 6px; }
.event.ok { color: var(--green); }
.event.warn { color: var(--gold); }
.event.err { color: var(--err); }
/* ===== FOOTER ===== */
footer {
grid-area: footer;
font-family: ui-monospace, monospace; font-size: 10px;
color: var(--text-dim);
display: flex; align-items: center; padding: 0 16px;
gap: 14px;
}
footer .pill {
display: flex; align-items: center; gap: 4px;
}
footer .pill .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-dim); }
footer .pill .dot.ok { background: var(--green); }
footer .pill .dot.err { background: var(--err); }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
/* mobile */
@media (max-width: 1100px) {
.app {
grid-template-rows: 50px 1fr 30px;
grid-template-columns: 1fr;
grid-template-areas: "header" "center" "footer";
}
.left, .right { display: none; }
}
</style>
</head>
<body>
<div class="app">
<!-- HEADER -->
<header>
<div>
<span class="brand">ATLAS COMMAND</span>
<span class="brand-sub">unified cockpit</span>
</div>
<div class="live-brief" id="brief">
<span class="label">▸ LIVE</span><span id="brief-text">laden…</span>
</div>
<select id="model-picker" class="btn" style="min-width:160px;font-family:ui-monospace,monospace;font-size:11px" title="Model + node"></select>
<label style="display:flex;align-items:center;gap:4px;font-size:11px;color:var(--text-dim);cursor:pointer" title="Atlas Vault RAG">
<input type="checkbox" id="rag-toggle" style="cursor:pointer"> RAG
</label>
<button class="btn voice" id="voice-btn" onclick="toggleVoice()" title="Wake: 'Atlas, ...' (Ctrl+Space)">🎤 ATLAS</button>
<button class="btn" onclick="clearHistory()" title="Clear conversation (Ctrl+Shift+Del)"></button>
<button class="btn" onclick="openPortal()" title="Original portal">PORTAL</button>
<button class="btn" onclick="toggleFullscreen()" title="Fullscreen"></button>
</header>
<!-- LEFT — tiles -->
<aside class="left">
<div class="section-title">▸ DAILY</div>
<a class="tile-row" href="http://atlas-01/portal.html" target="_blank"><span class="icon">🏠</span><span class="name">Portal (old)</span></a>
<a class="tile-row hot" href="javascript:openInline('atlas-chat.html')"><span class="icon"></span><span class="name">Atlas Chat</span><span class="dot ok"></span></a>
<a class="tile-row hot" href="javascript:openInline('atlas-hud.html')"><span class="icon">🎯</span><span class="name">Atlas HUD</span><span class="dot ok"></span></a>
<div class="section-title">▸ MESH</div>
<a class="tile-row" href="http://mesh.your-domain.tld" target="_blank"><span class="icon">🕸️</span><span class="name">MeshCentral</span></a>
<a class="tile-row" href="http://cloud.your-domain.tld" target="_blank"><span class="icon">☁️</span><span class="name">Nextcloud</span></a>
<a class="tile-row" href="http://<YOUR_VPS_IP>:8069" target="_blank"><span class="icon">📊</span><span class="name">Odoo</span></a>
<a class="tile-row" href="http://<YOUR_VPS_IP>:8222" target="_blank"><span class="icon">🔐</span><span class="name">Vaultwarden</span></a>
<div class="section-title">▸ AI</div>
<a class="tile-row" href="http://<YOUR_VPS_IP>:8082" target="_blank"><span class="icon">💬</span><span class="name">Open WebUI</span></a>
<a class="tile-row" href="http://<YOUR_VPS_IP>:8899" target="_blank"><span class="icon">🤖</span><span class="name">Agent TARS</span></a>
<a class="tile-row" href="/command/mindmap.html" target="_blank"><span class="icon">🧠</span><span class="name">Mindmap</span></a>
<div class="section-title">▸ INTERNALS</div>
<a class="tile-row" href="/feeds/today.json" target="_blank"><span class="icon">📡</span><span class="name">Today feed</span></a>
<a class="tile-row" href="/priorities.json" target="_blank"><span class="icon">💰</span><span class="name">Priorities</span></a>
<a class="tile-row" href="/sprints/" target="_blank"><span class="icon">🚀</span><span class="name">Sprints</span></a>
<a class="tile-row" href="/atlas/" target="_blank"><span class="icon">🛰️</span><span class="name">Atlas dashboard</span></a>
</aside>
<!-- CENTER — chat / hud / brief tabs -->
<main class="center">
<div class="tabbar">
<div class="tab active" onclick="switchTab('chat')">CHAT</div>
<div class="tab" onclick="switchTab('brief')">BRIEF</div>
<div class="tab" onclick="switchTab('buy')">BUY?</div>
<div class="tab" onclick="switchTab('autonomy')">🌙 AUTONOMY</div>
<div class="tab" onclick="switchTab('hud')">HUD</div>
<div class="tab" onclick="switchTab('portal')">PORTAL</div>
</div>
<div class="view">
<div id="chat-view" class="active">
<div id="chat-msgs">
<div class="msg assistant">
<div class="role">ATLAS</div>
<div class="content">
<p>Welkom in de unified cockpit. Type een vraag, klik <strong>🎤 ATLAS</strong> in de top bar voor voice, of switch naar BRIEF voor live cijfers.</p>
<p style="color:var(--text-dim);font-size:12px;margin-top:8px">Sneltoetsen: <code>Ctrl+1/2/3/4</code> tabs · <code>Ctrl+Space</code> voice · <code>Ctrl+K</code> focus input</p>
</div>
</div>
</div>
<div class="input-row">
<textarea id="chat-input" placeholder="Vraag iets aan Atlas... (Enter = verstuur, Shift+Enter = nieuwe regel)" rows="1"></textarea>
<button class="btn" onclick="sendChat()">SEND</button>
</div>
</div>
<div id="brief-view"></div>
<div id="buy-view" style="padding:20px;overflow-y:auto">
<h2 style="color:var(--cyan);font-size:18px;margin-bottom:6px">🛒 Pre-buy ROI calculator (Rule R1-R3)</h2>
<p style="color:var(--text-dim);font-size:12px;margin-bottom:18px">Vul in vóór je een hardware-lot bestelt. Krijg een direct GO/NO-GO advies op basis van strategic_plan rules.</p>
<div class="brief-card">
<h3>📦 Inputs</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;font-size:13px">
<label>Lot prijs (€)<br><input id="buy-price" type="number" value="800" style="width:100%;padding:5px;background:rgba(0,212,255,.08);border:1px solid var(--border);color:var(--text);border-radius:3px"></label>
<label>Aantal units<br><input id="buy-units" type="number" value="10" style="width:100%;padding:5px;background:rgba(0,212,255,.08);border:1px solid var(--border);color:var(--text);border-radius:3px"></label>
<label>Categorie<br>
<select id="buy-cat" style="width:100%;padding:5px;background:rgba(0,212,255,.08);border:1px solid var(--border);color:var(--text);border-radius:3px">
<option value="laptop_business">Laptop business (HP/Dell/Lenovo)</option>
<option value="laptop_surface">Surface laptops</option>
<option value="monitor">Monitor 22-27"</option>
<option value="dock">Docking station</option>
<option value="peripheral">Muis / keyboard / accessoire</option>
<option value="pos">POS / kassasysteem</option>
</select>
</label>
<label>Verkoopprijs/unit (€)<br><input id="buy-sell" type="number" value="250" style="width:100%;padding:5px;background:rgba(0,212,255,.08);border:1px solid var(--border);color:var(--text);border-radius:3px"></label>
<label>Huidige voorraad (units)<br><input id="buy-stock" type="number" value="5" style="width:100%;padding:5px;background:rgba(0,212,255,.08);border:1px solid var(--border);color:var(--text);border-radius:3px"></label>
<label>Pre-sales coverage (%)<br><input id="buy-presales" type="number" value="0" min="0" max="100" style="width:100%;padding:5px;background:rgba(0,212,255,.08);border:1px solid var(--border);color:var(--text);border-radius:3px"></label>
</div>
<button class="btn" onclick="computeBuy()" style="margin-top:12px;width:100%;padding:8px;background:var(--accent);color:white;border:none">Bereken GO/NO-GO</button>
</div>
<div id="buy-result"></div>
</div>
<div id="autonomy-view" style="padding:16px;overflow-y:auto">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:14px">
<h2 style="color:var(--cyan);font-size:18px;margin:0">🌙 Autonomy Loop — live view</h2>
<span id="auto-status" style="font-family:ui-monospace,monospace;font-size:11px;color:var(--text-dim)">loading…</span>
<button class="btn" onclick="refreshAutonomy()" style="margin-left:auto;font-size:11px">🔄 refresh</button>
<label style="font-size:11px;color:var(--text-dim);cursor:pointer"><input type="checkbox" id="auto-autorefresh" checked onchange="toggleAutoRefresh()"> auto 30s</label>
</div>
<div id="autonomy-content"><p style="color:var(--text-dim)">laden…</p></div>
</div>
<div id="hud-view"><iframe src="/atlas-hud.html"></iframe></div>
<div id="portal-view"><iframe src="/portal.html"></iframe></div>
</div>
</main>
<!-- RIGHT — context / events -->
<aside class="right">
<div class="section-title">▸ CLUSTER</div>
<div id="cluster-status">
<div class="event"><span class="t">--:--</span>checking…</div>
</div>
<div class="section-title">▸ TODAY</div>
<div id="today-events">
<div class="event"><span class="t">--:--</span>laden…</div>
</div>
<div class="section-title">▸ EVENT STREAM</div>
<div id="event-stream">
<div class="event"><span class="t">--:--</span>Cockpit initialized</div>
</div>
</aside>
<!-- FOOTER -->
<footer>
<span class="pill"><span class="dot ok" id="dot-atlas01"></span>atlas-01</span>
<span class="pill"><span class="dot" id="dot-pc"></span>pc</span>
<span class="pill"><span class="dot" id="dot-met"></span>met</span>
<span class="pill"><span class="dot" id="dot-rag"></span>vault rag</span>
<span style="margin-left:auto">Atlas Command v0.1 · <span id="clock">--:--</span> · <kbd>Ctrl+Shift+P</kbd> recall</span>
</footer>
</div>
<script>
const STATE = {
currentTab: 'chat',
conversation: [],
recognition: null,
listening: false,
currentNode: 'atlas-01',
currentModel: 'qwen2.5:7b',
nodes: {
'atlas-01': { url: location.origin + '/ollama', models: [] },
'pc': { url: location.origin + '/ollama/pc', models: [] },
'met': { url: location.origin + '/ollama/met', models: [] },
},
};
const CFG = {
ollamaURL: location.origin + '/ollama',
metURL: location.origin + '/ollama/met',
pcURL: location.origin + '/ollama/pc',
wakeWord: 'atlas',
vaultKbId: '<YOUR_KB_ID>',
openWebUI: 'http://<YOUR_VPS_IP>:8082',
systemPrompt: 'Je bent Atlas — Chaib\'s assistent in de unified cockpit. Antwoord direct, NL of EN volgens input, max 3 zinnen tenzij detail gevraagd. Gegrond, geen fluff.',
};
const STORAGE_KEY = 'atlas-command-v1';
const SETTINGS_KEY = 'atlas-chat-settings'; // share JWT with atlas-chat
// ===== TABS =====
function switchTab(name) {
STATE.currentTab = name;
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.view > div').forEach(v => v.classList.remove('active'));
document.querySelectorAll('.tab')[['chat','brief','buy','autonomy','hud','portal'].indexOf(name)].classList.add('active');
document.getElementById(name+'-view').classList.add('active');
if (name === 'brief') refreshBrief();
if (name === 'autonomy') refreshAutonomy();
}
// ===== AUTONOMY TAB =====
let autonomyAutoRefreshTimer = null;
async function refreshAutonomy() {
const el = document.getElementById('autonomy-content');
const statusEl = document.getElementById('auto-status');
try {
const r = await fetch('/atlas-autonomy.json?t=' + Date.now());
const d = await r.json();
const loop = d.loop_running ? '<span style="color:var(--green)">● running</span>' : '<span style="color:var(--err)">● stopped</span>';
statusEl.innerHTML = `${loop} · pending: ${d.queue_pending} · completed: ${d.queue_completed} · outputs: ${d.recent_output_count} · next fire: ${d.next_fire || '?'}`;
let html = '';
// Last completed
if (d.last_completed) {
const lc = d.last_completed;
html += `<div class="brief-card" style="border-color:rgba(0,212,255,.3)">
<h3>Last completed task</h3>
<div class="row"><span class="lbl">topic</span><span class="val ok">${lc.topic}</span></div>
<div class="row"><span class="lbl">node/model</span><span class="val">${lc.node}/${lc.model}</span></div>
<div class="row"><span class="lbl">elapsed</span><span class="val">${lc.elapsed_s}s</span></div>
<div class="row"><span class="lbl">when</span><span class="val">${lc.completed_at}</span></div>
</div>`;
}
// Recent outputs preview
if (d.recent_outputs && d.recent_outputs.length) {
html += `<div class="brief-card"><h3>📝 Recent outputs (${d.recent_outputs.length})</h3>`;
for (const o of d.recent_outputs) {
const ts = o.mtime.replace('T', ' ').slice(11, 19);
html += `<div style="border-bottom:1px dashed rgba(59,130,246,.15);padding:8px 0;font-size:12px">
<div style="display:flex;gap:8px;align-items:center">
<span style="color:var(--cyan);font-family:ui-monospace,monospace;font-size:11px">${ts}</span>
<span style="color:var(--accent);font-weight:600">${o.topic}</span>
<span style="color:var(--text-dim);font-size:10px">${o.node}/${o.model} · ${o.elapsed_s}s</span>
<span style="color:var(--gold);font-size:10px;margin-left:auto">${o.polish_status}</span>
</div>
<div style="color:var(--text);margin-top:4px;line-height:1.4">${DOMPurify.sanitize(o.body_preview.slice(0, 280))}${o.body_preview.length > 280 ? '…' : ''}</div>
</div>`;
}
html += '</div>';
} else {
html += '<div class="brief-card"><p style="color:var(--text-dim)">No outputs yet — wachten op eerste fire</p></div>';
}
// Log tail
if (d.log_tail && d.log_tail.length) {
html += `<div class="brief-card"><h3>📋 Loop log (last 15)</h3>
<pre style="font-family:ui-monospace,monospace;font-size:11px;color:var(--text-dim);overflow-x:auto;white-space:pre-wrap;line-height:1.4">${DOMPurify.sanitize(d.log_tail.slice(-15).join('\n'))}</pre>
</div>`;
}
el.innerHTML = html;
} catch (e) {
statusEl.textContent = 'error';
el.innerHTML = `<p style="color:var(--err)">Fout: ${e.message}. Endpoint /atlas-autonomy.json reachable?</p>`;
}
}
function toggleAutoRefresh() {
const on = document.getElementById('auto-autorefresh').checked;
if (autonomyAutoRefreshTimer) { clearInterval(autonomyAutoRefreshTimer); autonomyAutoRefreshTimer = null; }
if (on) {
autonomyAutoRefreshTimer = setInterval(() => {
if (STATE.currentTab === 'autonomy') refreshAutonomy();
}, 30000);
}
}
// ===== BUY? CALCULATOR (R1-R3) =====
// Category sell-through baselines (median days on shelf, derived from operator's history)
const CAT_BASELINES = {
laptop_business: { days_on_shelf: 35, margin_pct: 40 },
laptop_surface: { days_on_shelf: 50, margin_pct: 45 },
monitor: { days_on_shelf: 60, margin_pct: 30 },
dock: { days_on_shelf: 75, margin_pct: 35 },
peripheral: { days_on_shelf: 90, margin_pct: 25 },
pos: { days_on_shelf: 45, margin_pct: 50 },
};
function computeBuy() {
const price = parseFloat(document.getElementById('buy-price').value) || 0;
const units = parseInt(document.getElementById('buy-units').value) || 0;
const cat = document.getElementById('buy-cat').value;
const sell = parseFloat(document.getElementById('buy-sell').value) || 0;
const stock = parseInt(document.getElementById('buy-stock').value) || 0;
const presalesPct = parseFloat(document.getElementById('buy-presales').value) || 0;
if (units < 1 || price < 1) {
document.getElementById('buy-result').innerHTML = '<div class="brief-card"><h3 style="color:var(--err)">⚠️ Vul prijs + aantal in</h3></div>';
return;
}
const base = CAT_BASELINES[cat] || CAT_BASELINES.peripheral;
const cost_per_unit = price / units;
const total_revenue = sell * units;
const total_margin = total_revenue - price;
const roi_pct = price > 0 ? (total_margin / price) * 100 : 0;
// Holding cost: 3%/mo capital + €2/unit/mo storage estimate
const sell_through_days = base.days_on_shelf;
const months_to_clear = sell_through_days / 30;
const capital_hold = price * 0.03 * months_to_clear;
const storage_hold = units * 2 * months_to_clear;
const total_holding = capital_hold + storage_hold;
const net_margin = total_margin - total_holding;
const net_roi = price > 0 ? (net_margin / price) * 100 : 0;
// Rule checks
const rules = [];
if (stock > 8) rules.push({ pass: false, text: `R1a FAIL: huidige voorraad ${stock} > 8 units. Verkoop eerst.` });
else rules.push({ pass: true, text: `R1a OK: huidige voorraad ${stock} ≤ 8 units` });
if (presalesPct < 50) rules.push({ pass: false, text: `R1b WARN: pre-sales ${presalesPct}% < 50%. Risico.` });
else rules.push({ pass: true, text: `R1b OK: pre-sales coverage ${presalesPct}%` });
if (sell_through_days > 90) rules.push({ pass: false, text: `R1c FAIL: sell-through ${sell_through_days}d > 90d (categorie-baseline).` });
else rules.push({ pass: true, text: `R1c OK: sell-through ${sell_through_days}d ≤ 90d` });
if (net_roi < 30) rules.push({ pass: false, text: `R2 FAIL: net ROI ${net_roi.toFixed(1)}% < 30% minimum.` });
else rules.push({ pass: true, text: `R2 OK: net ROI ${net_roi.toFixed(1)}%` });
const allPass = rules.every(r => r.pass);
const verdict = allPass ? 'GO' : 'NO-GO';
const verdictColor = allPass ? 'var(--green)' : 'var(--err)';
document.getElementById('buy-result').innerHTML = `
<div class="brief-card" style="border:2px solid ${verdictColor}">
<h3 style="color:${verdictColor};font-size:24px;letter-spacing:4px">${verdict}</h3>
<div class="row"><span class="lbl">Cost per unit</span><span class="val">€${cost_per_unit.toFixed(2)}</span></div>
<div class="row"><span class="lbl">Total revenue (potential)</span><span class="val">€${total_revenue.toFixed(2)}</span></div>
<div class="row"><span class="lbl">Gross margin</span><span class="val">€${total_margin.toFixed(2)} (${roi_pct.toFixed(1)}%)</span></div>
<div class="row"><span class="lbl">Holding cost (est, ${months_to_clear.toFixed(1)} mnd)</span><span class="val">€${total_holding.toFixed(2)}</span></div>
<div class="row"><span class="lbl">Net margin after holding</span><span class="val ${net_margin > 0 ? 'ok' : 'err'}">€${net_margin.toFixed(2)} (${net_roi.toFixed(1)}%)</span></div>
<div class="row"><span class="lbl">Sell-through baseline</span><span class="val">${sell_through_days}d</span></div>
</div>
<div class="brief-card">
<h3>📋 Rule checks (per strategic_plan R1-R3)</h3>
${rules.map(r => `<div class="row"><span class="lbl">${r.pass ? '✅' : '❌'}</span><span class="val ${r.pass ? 'ok' : 'err'}">${r.text}</span></div>`).join('')}
${allPass ? '' : '<div style="margin-top:10px;padding:8px;background:rgba(255,64,96,.1);border:1px solid rgba(255,64,96,.3);border-radius:4px;font-size:12px;color:var(--err)"><strong>NIET KOPEN.</strong> Address de fails hierboven of wacht tot omstandigheden anders zijn.</div>'}
</div>
`;
}
// ===== LIVE BRIEF (header ticker) =====
async function refreshHeaderBrief() {
try {
const r = await fetch('/priorities.json', { cache: 'no-store' });
const d = await r.json();
const eur = d.odoo?.overdue_eur ?? 0;
const cnt = d.odoo?.overdue_count ?? 0;
const disk = d.disk_free_gb ?? '?';
const down = d.containers_down ?? 0;
const klass = (cnt > 0 || down > 0) ? 'err' : 'ok';
document.getElementById('brief-text').innerHTML = `<span class="${klass}">AR €${eur}</span> · ${cnt} open · disk ${disk}GB free · ${down} containers down · ${new Date().toLocaleTimeString('nl-NL',{hour:'2-digit',minute:'2-digit'})}`;
} catch {
document.getElementById('brief-text').innerHTML = '<span class="err">live feed offline</span>';
}
}
async function refreshBrief() {
const el = document.getElementById('brief-view');
el.innerHTML = '<p style="color:var(--text-dim)">laden…</p>';
try {
const [pri, today, fin, strat, act] = await Promise.all([
fetch('/priorities.json').then(r=>r.json()).catch(()=>null),
fetch('/today.json').then(r=>r.json()).catch(()=>null),
fetch('/atlas-finance.json').then(r=>r.json()).catch(()=>null),
fetch('/atlas-strategy.json').then(r=>r.json()).catch(()=>null),
fetch('/atlas-activity.json').then(r=>r.json()).catch(()=>null),
]);
let html = '';
// 🌤️ SEASON-AWARE STRATEGY card (first, sets context)
if (strat) {
const cw = strat.current_window;
const btw = strat.btw_aangifte;
html += `<div class="brief-card" style="border-color:rgba(0,212,255,.5)">
<h3>🌤️ Strategy NOW — ${cw ? cw.name : 'open'} ${cw ? `(${cw.days_left}d left)` : ''}</h3>
${cw ? `
<div class="row"><span class="lbl">Signal</span><span class="val" style="font-size:12px">${cw.signal}</span></div>
<div class="row"><span class="lbl">▶ Primary push</span><span class="val ok">${strat.primary_recommendation}</span></div>
<div class="row"><span class="lbl">Framing</span><span class="val" style="font-size:11px;font-style:italic">${strat.primary_framing}</span></div>
<div class="row"><span class="lbl">Channels today</span><span class="val">${(strat.channels_today||[]).join(' · ') || 'none'}</span></div>
${cw.push?.length > 1 ? `<div style="font-size:11px;color:var(--text-dim);margin-top:6px">Also: ${cw.push.slice(1).join(' · ')}</div>` : ''}
${(strat.avoid_today||[]).length ? `<div style="font-size:11px;color:var(--err);margin-top:6px">❌ Avoid: ${strat.avoid_today.join(' · ')}</div>` : ''}
` : '<div style="color:var(--text-dim)">Default plan — no specific window active</div>'}
<div class="row" style="margin-top:8px;border-top:1px solid var(--border);padding-top:6px;font-size:11px"><span class="lbl">Upcoming</span><span class="val">${(strat.upcoming_windows||[]).slice(0,2).map(u => u.name + ' (' + u.days_until + 'd)').join(' · ')}</span></div>
<div class="row" style="font-size:11px"><span class="lbl">BTW ${btw.quarter}</span><span class="val ${btw.days_until < 14 ? 'err' : btw.days_until < 30 ? 'warn' : 'ok'}">${btw.deadline} (${btw.days_until}d)</span></div>
</div>`;
}
// ⏱️ R10 / Activity card
if (act) {
const r10cls = act.r10_pass ? 'ok' : 'err';
html += `<div class="brief-card">
<h3>⏱️ Time today (R10 check)</h3>
<div class="row"><span class="lbl">Active hours</span><span class="val">${act.active_total_hours}h</span></div>
<div class="row"><span class="lbl">Sales</span><span class="val ok">${act.r10_sales_hours}h (${(act.by_category_pct?.sales || 0)}%)</span></div>
<div class="row"><span class="lbl">Delivery</span><span class="val">${act.by_category_pct?.delivery || 0}%</span></div>
<div class="row"><span class="lbl">Infra</span><span class="val ${act.r10_infra_hours > act.r10_sales_hours ? 'err' : ''}">${act.r10_infra_hours}h (${act.by_category_pct?.infra || 0}%)</span></div>
<div class="row"><span class="lbl">Admin / Learning / Other</span><span class="val">${act.by_category_pct?.admin || 0}% / ${act.by_category_pct?.learning || 0}% / ${act.by_category_pct?.other || 0}%</span></div>
<div class="row" style="margin-top:6px;border-top:1px solid var(--border);padding-top:6px"><span class="lbl">R10 verdict</span><span class="val ${r10cls}">${act.r10_pass ? '✅ PASS (sales ≥ infra)' : '❌ FAIL (infra > sales)'}</span></div>
</div>`;
}
// FINANCE card (Inkomsten xlsx → bank-verified YTD)
if (fin) {
const concCls = fin.concentration_top_pct > 40 ? 'err' : fin.concentration_top_pct > 25 ? 'warn' : 'ok';
const mrrCls = fin.mrr_estimate_eur >= 1000 ? 'ok' : fin.mrr_estimate_eur >= 500 ? 'warn' : 'err';
html += `<div class="brief-card"><h3>📈 Finance (Inkomsten xlsx)</h3>
<div class="row"><span class="lbl">YTD revenue</span><span class="val ok">€${fin.ytd_eur.toLocaleString('nl-NL')}</span></div>
<div class="row"><span class="lbl">Avg per month</span><span class="val">€${fin.avg_per_month_eur.toLocaleString('nl-NL')}</span></div>
<div class="row"><span class="lbl">Current month</span><span class="val ${fin.current_month_eur < 500 ? 'err' : 'ok'}">€${fin.current_month_eur.toLocaleString('nl-NL')}</span></div>
<div class="row"><span class="lbl">Unique clients YTD</span><span class="val">${fin.unique_clients_ytd}</span></div>
<div class="row"><span class="lbl">MRR estimate</span><span class="val ${mrrCls}">€${fin.mrr_estimate_eur.toLocaleString('nl-NL')}/mo</span></div>
<div class="row"><span class="lbl">Concentration (top client)</span><span class="val ${concCls}">${fin.concentration_top_pct}%</span></div>
${fin.top_5_clients?.[0] ? `<div class="row"><span class="lbl">Top client</span><span class="val">${fin.top_5_clients[0].name}</span></div>` : ''}
<div class="row" style="margin-top:6px;border-top:1px solid var(--border);padding-top:6px;font-size:11px;color:var(--text-dim)"><span>As of: ${fin.as_of}</span></div>
</div>`;
}
if (pri) {
const o = pri.odoo || {};
html += `<div class="brief-card"><h3>💰 AR (Odoo)</h3>
<div class="row"><span class="lbl">Open AR</span><span class="val ${o.overdue_eur>0?'err':'ok'}">€${o.overdue_eur ?? 0}</span></div>
<div class="row"><span class="lbl">Overdue count</span><span class="val">${o.overdue_count ?? 0}</span></div>
${o.oldest_inv ? `<div class="row"><span class="lbl">Oldest</span><span class="val">${o.oldest_inv} · ${o.oldest_partner ?? '?'} · ${o.oldest_age ?? 0}d</span></div>` : ''}
</div>`;
html += `<div class="brief-card"><h3>🔧 Infrastructure</h3>
<div class="row"><span class="lbl">atlas-01 disk free</span><span class="val ${pri.disk_free_gb<10?'err':pri.disk_free_gb<30?'warn':'ok'}">${pri.disk_free_gb ?? '?'}GB</span></div>
<div class="row"><span class="lbl">Containers down</span><span class="val ${pri.containers_down>0?'err':'ok'}">${pri.containers_down ?? 0}</span></div>
<div class="row"><span class="lbl">Generated at</span><span class="val">${pri.generated_at?.replace('T',' ').slice(0,16) ?? '?'}</span></div>
</div>`;
if (pri.attention?.top?.length) {
html += `<div class="brief-card"><h3>🎯 Attention top-${pri.attention.top.length}</h3>` +
pri.attention.top.map(t => `<div class="row"><span class="lbl">${t.kind}</span><span class="val">${t.label}</span></div>`).join('') +
`</div>`;
}
}
if (today) {
const evs = (today.events || today.calendar?.events || []).slice(0,5);
if (evs.length) {
html += `<div class="brief-card"><h3>📅 Vandaag (${evs.length})</h3>` +
evs.map(e => `<div class="row"><span class="lbl">${e.start || e.time || ''}</span><span class="val">${e.title || e.summary || '?'}</span></div>`).join('') +
`</div>`;
}
}
el.innerHTML = html || '<p style="color:var(--text-dim)">geen data</p>';
} catch (e) {
el.innerHTML = '<p style="color:var(--err)">Fout: ' + e.message + '</p>';
}
}
// ===== CLUSTER STATUS + MODEL DISCOVERY =====
async function refreshCluster() {
const targets = [
{ id: 'atlas01', key: 'atlas-01' },
{ id: 'pc', key: 'pc' },
{ id: 'met', key: 'met' },
];
const cs = document.getElementById('cluster-status');
const time = new Date().toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' });
cs.innerHTML = '';
for (const t of targets) {
const node = STATE.nodes[t.key];
const t0 = performance.now();
let status, msg;
try {
const r = await fetch(node.url + '/api/tags', { signal: AbortSignal.timeout(3000) });
if (r.ok) {
const d = await r.json();
const lat = Math.round(performance.now() - t0);
node.models = (d.models || []).map(m => m.name);
msg = `${t.key} · ${node.models.length} models · ${lat}ms`;
status = 'ok';
document.getElementById('dot-'+t.id).className = 'dot ok';
} else throw 0;
} catch {
msg = `${t.key} · offline`;
status = 'err';
node.models = [];
document.getElementById('dot-'+t.id).className = 'dot err';
}
cs.innerHTML += `<div class="event ${status}"><span class="t">${time}</span>${msg}</div>`;
}
populateModelPicker();
}
function populateModelPicker() {
const sel = document.getElementById('model-picker');
const prev = sel.value;
sel.innerHTML = '';
for (const [key, node] of Object.entries(STATE.nodes)) {
if (!node.models.length) continue;
const group = document.createElement('optgroup');
group.label = key;
for (const m of node.models) {
const opt = document.createElement('option');
opt.value = key + '|' + m;
opt.textContent = m;
group.appendChild(opt);
}
sel.appendChild(group);
}
// Restore previous or pick default
if (prev && Array.from(sel.options).some(o => o.value === prev)) {
sel.value = prev;
} else {
const def = Array.from(sel.options).find(o => o.value.includes('qwen2.5:7b'));
if (def) sel.value = def.value;
}
if (sel.value) {
const [node, model] = sel.value.split('|');
STATE.currentNode = node; STATE.currentModel = model;
}
sel.onchange = () => {
const [node, model] = sel.value.split('|');
STATE.currentNode = node; STATE.currentModel = model;
emit('ok', `model switched: ${node}/${model}`);
};
}
// Baked Vault JWT (operator's own infra, tailnet-only — expires 2026-06-23)
const BAKED_JWT = '<YOUR_OPENWEBUI_JWT_HERE>';
// ===== VAULT RAG =====
async function queryVaultRAG(query, k=3) {
let jwt = BAKED_JWT;
try {
const s = localStorage.getItem(SETTINGS_KEY);
if (s) jwt = JSON.parse(s).openwebuiJwt || BAKED_JWT;
} catch {}
if (!jwt) {
emit('warn', 'RAG: no JWT available');
return null;
}
try {
const r = await fetch(CFG.openWebUI + '/api/v1/retrieval/query/collection', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + jwt },
body: JSON.stringify({ collection_names: [CFG.vaultKbId], query, k }),
signal: AbortSignal.timeout(8000),
});
if (!r.ok) { emit('err', 'RAG HTTP ' + r.status); return null; }
const d = await r.json();
const docs = (d.documents?.[0] || []).map((doc, i) => {
const meta = d.metadatas?.[0]?.[i] || {};
return `[${meta.source || 'vault'}]\n${doc}`;
});
if (docs.length) emit('ok', `RAG: ${docs.length} chunks (vault)`);
return docs.join('\n\n---\n\n');
} catch (e) {
emit('err', 'RAG fail: ' + e.message);
return null;
}
}
// ===== HISTORY PERSISTENCE =====
function saveConversation() {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(STATE.conversation.slice(-50))); } catch {}
}
function loadConversation() {
try {
const s = localStorage.getItem(STORAGE_KEY);
if (s) {
STATE.conversation = JSON.parse(s);
const msgsDiv = document.getElementById('chat-msgs');
// keep welcome msg, append history
for (const m of STATE.conversation) {
if (m.role === 'system') continue;
appendMsg(m.role, m.content);
}
}
} catch {}
}
function clearHistory() {
if (!confirm('Wis gesprek historie?')) return;
STATE.conversation = [];
localStorage.removeItem(STORAGE_KEY);
document.getElementById('chat-msgs').innerHTML = '<div class="msg assistant"><div class="role">ATLAS</div><div class="content"><p>Geschiedenis gewist. Stel een nieuwe vraag.</p></div></div>';
emit('ok', 'history cleared');
}
// ===== TODAY EVENTS =====
async function refreshToday() {
try {
const r = await fetch('/today.json');
const d = await r.json();
const events = (d.events || d.calendar?.events || []).slice(0, 4);
const el = document.getElementById('today-events');
if (!events.length) { el.innerHTML = '<div class="event"><span class="t">--:--</span>geen events</div>'; return; }
el.innerHTML = events.map(e => `<div class="event"><span class="t">${(e.start||e.time||'').slice(0,5)}</span>${(e.title||e.summary||'?').slice(0,40)}</div>`).join('');
} catch { document.getElementById('today-events').innerHTML = '<div class="event err"><span class="t">--:--</span>today.json offline</div>'; }
}
// ===== CHAT =====
async function sendChat(prefill) {
const inp = document.getElementById('chat-input');
const text = prefill || inp.value.trim();
if (!text) return;
inp.value = '';
inp.style.height = 'auto';
appendMsg('user', text);
STATE.conversation.push({ role: 'user', content: text });
// Vault RAG if toggle on
let ragContext = '';
if (document.getElementById('rag-toggle')?.checked) {
const ctx = await queryVaultRAG(text, 3);
if (ctx) ragContext = '\n\n[ATLAS VAULT CONTEXT — gegrond hierop, citeer source]\n' + ctx;
}
const msgs = [
{ role: 'system', content: CFG.systemPrompt + ragContext },
...STATE.conversation.slice(-10).filter(m => m.role !== 'system'),
];
const div = appendMsg('assistant', '');
const contentEl = div.querySelector('.content');
const node = STATE.nodes[STATE.currentNode] || STATE.nodes['atlas-01'];
let full = '';
try {
const r = await fetch(node.url + '/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: STATE.currentModel, messages: msgs, stream: true }),
});
if (!r.ok) throw new Error('HTTP ' + r.status);
const reader = r.body.getReader();
const dec = new TextDecoder();
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let nl;
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
try {
const j = JSON.parse(line);
if (j.message?.content) {
full += j.message.content;
contentEl.innerHTML = DOMPurify.sanitize(marked.parse(full));
div.scrollIntoView({ block: 'end' });
}
} catch {}
}
}
STATE.conversation.push({ role: 'assistant', content: full });
saveConversation();
emit('ok', `chat: ${full.slice(0,40)}…`);
} catch (e) {
contentEl.innerHTML = '<p style="color:var(--err)">Fout: ' + e.message + '</p>';
emit('err', 'chat fail: ' + e.message);
}
}
function appendMsg(role, text) {
const div = document.createElement('div');
div.className = 'msg ' + role;
div.innerHTML = `<div class="role">${role === 'user' ? 'JIJ' : 'ATLAS'}</div><div class="content">${DOMPurify.sanitize(marked.parse(text || ''))}</div>`;
document.getElementById('chat-msgs').appendChild(div);
div.scrollIntoView({ block: 'end' });
return div;
}
// ===== VOICE (wake = Atlas) =====
function toggleVoice() {
const btn = document.getElementById('voice-btn');
if (STATE.listening) {
STATE.recognition?.stop();
STATE.listening = false;
btn.classList.remove('listening');
emit('ok', 'voice off');
return;
}
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) { emit('err', 'no SpeechRecognition in this browser'); return; }
STATE.recognition = new SR();
STATE.recognition.lang = 'nl-NL';
STATE.recognition.continuous = true;
STATE.recognition.interimResults = false;
STATE.recognition.onresult = (e) => {
for (let i = e.resultIndex; i < e.results.length; i++) {
if (!e.results[i].isFinal) continue;
let t = e.results[i][0].transcript.trim();
const low = t.toLowerCase();
const aliases = ['atlas', 'hey atlas', 'hi atlas'];
const hit = aliases.find(a => low.includes(a));
if (hit) {
for (const a of aliases) t = t.replace(new RegExp(a, 'gi'), '');
t = t.replace(/^[,.\s]+|[,.\s]+$/g, '');
if (t.length > 2) {
switchTab('chat');
sendChat(t);
}
}
}
};
STATE.recognition.onerror = (e) => emit('err', 'ASR: ' + e.error);
STATE.recognition.onend = () => { if (STATE.listening) STATE.recognition.start(); };
STATE.recognition.start();
STATE.listening = true;
btn.classList.add('listening');
emit('ok', 'voice on — say "Atlas, ..."');
}
// ===== EVENT STREAM =====
function emit(kind, text) {
const es = document.getElementById('event-stream');
const div = document.createElement('div');
div.className = 'event ' + kind;
div.innerHTML = `<span class="t">${new Date().toLocaleTimeString('nl-NL',{hour:'2-digit',minute:'2-digit'})}</span>${text}`;
es.appendChild(div);
while (es.children.length > 25) es.firstChild.remove();
es.scrollTop = es.scrollHeight;
}
// ===== UTILS =====
function openPortal() { window.open('/portal.html', '_blank'); }
function openInline(file) { switchTab('hud'); document.querySelector('#hud-view iframe').src = '/' + file; }
function toggleFullscreen() { if (document.fullscreenElement) document.exitFullscreen(); else document.documentElement.requestFullscreen(); }
function tick() { document.getElementById('clock').textContent = new Date().toLocaleTimeString('nl-NL'); }
// ===== KEYBINDINGS =====
document.addEventListener('keydown', (e) => {
if (e.ctrlKey) {
if (e.key === ' ') { e.preventDefault(); toggleVoice(); }
else if (e.key === 'k') { e.preventDefault(); document.getElementById('chat-input').focus(); }
else if (e.key >= '1' && e.key <= '6') {
e.preventDefault();
switchTab(['chat','brief','buy','autonomy','hud','portal'][parseInt(e.key)-1]);
}
}
});
document.getElementById('chat-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChat(); }
// Auto-grow
setTimeout(() => {
e.target.style.height = 'auto';
e.target.style.height = Math.min(140, e.target.scrollHeight) + 'px';
}, 0);
});
// ===== INIT =====
function init() {
refreshHeaderBrief(); setInterval(refreshHeaderBrief, 30000);
refreshCluster(); setInterval(refreshCluster, 30000);
refreshToday(); setInterval(refreshToday, 60000);
tick(); setInterval(tick, 1000);
loadConversation();
// RAG dot — green by default (baked JWT) unless explicitly cleared
document.getElementById('dot-rag').className = 'dot ok';
}
init();
</script>
</body>
</html>

952
web/atlas-hud.html Normal file
View file

@ -0,0 +1,952 @@
<!doctype html>
<html lang="nl" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#000814">
<title>Atlas HUD</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='14' fill='none' stroke='%233B82F6' stroke-width='2'/%3E%3Ccircle cx='16' cy='16' r='3' fill='%2300d4ff'/%3E%3C/svg%3E">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
<style>
:root {
--bg: #000814;
--bg-2: #001528;
--panel: rgba(8,30,52,.6);
--border: rgba(59,130,246,.3);
--text: #cce7ff;
--text-dim: #6896c4;
--accent: #3B82F6;
--cyan: #00d4ff;
--gold: #ffb020;
--green: #00ff88;
--err: #ff4060;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%; height: 100%; overflow: hidden;
background: var(--bg);
background-image:
radial-gradient(circle at 20% 30%, rgba(59,130,246,.15) 0%, transparent 50%),
radial-gradient(circle at 80% 70%, rgba(0,212,255,.1) 0%, transparent 50%);
color: var(--text);
font-family: 'Rajdhani', 'Segoe UI', -apple-system, sans-serif;
letter-spacing: .5px;
user-select: none;
}
@import url('https://fonts.googleapis.com/css2?family=Rajdhani:wght@300;400;500;600;700&family=Share+Tech+Mono&display=swap');
.hud {
position: fixed; inset: 0;
display: grid;
grid-template-rows: 60px 1fr 200px;
grid-template-columns: 280px 1fr 280px;
grid-template-areas:
"topnav topnav topnav"
"left center right"
"trans trans trans";
gap: 12px;
padding: 12px;
}
/* ===== TOP NAV ===== */
.topnav {
grid-area: topnav;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0 20px;
display: flex; align-items: center; gap: 20px;
position: relative;
overflow: hidden;
backdrop-filter: blur(6px);
}
.topnav::before {
content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
background: var(--accent);
box-shadow: 0 0 10px var(--accent);
}
.brand-text {
font-weight: 700; font-size: 20px; letter-spacing: 4px;
color: var(--cyan); text-shadow: 0 0 12px var(--cyan);
}
.brand-sub {
font-family: 'Share Tech Mono', monospace;
font-size: 11px; color: var(--text-dim);
margin-left: 4px;
}
.status-bar { display: flex; gap: 16px; margin-left: auto; align-items: center; }
.status-pill {
font-family: 'Share Tech Mono', monospace;
font-size: 11px;
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: 3px;
display: flex; align-items: center; gap: 6px;
}
.status-pill .dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--text-dim);
box-shadow: 0 0 6px var(--text-dim);
}
.status-pill.ok .dot { background: var(--green); box-shadow: 0 0 6px var(--green); }
.status-pill.warn .dot { background: var(--gold); box-shadow: 0 0 6px var(--gold); }
.status-pill.err .dot { background: var(--err); box-shadow: 0 0 6px var(--err); }
.btn-icon {
background: transparent; border: 1px solid var(--border);
color: var(--text); font-size: 14px;
padding: 4px 12px; border-radius: 3px;
cursor: pointer; font-family: inherit;
transition: all .15s;
}
.btn-icon:hover { background: var(--accent); color: white; border-color: var(--accent); }
.btn-icon.active { background: var(--cyan); color: var(--bg); border-color: var(--cyan); }
/* ===== LEFT PANEL — system status ===== */
.left {
grid-area: left;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 4px;
padding: 16px;
position: relative;
overflow-y: auto;
backdrop-filter: blur(6px);
}
.panel-title {
font-size: 11px; color: var(--cyan); letter-spacing: 3px;
margin-bottom: 12px; font-weight: 600;
text-transform: uppercase;
border-bottom: 1px solid var(--border);
padding-bottom: 6px;
}
.metric {
display: flex; justify-content: space-between;
font-family: 'Share Tech Mono', monospace;
font-size: 12px; padding: 6px 0;
color: var(--text);
}
.metric-label { color: var(--text-dim); }
.metric-bar {
height: 4px; background: rgba(59,130,246,.15);
border-radius: 2px; margin: 4px 0 8px;
overflow: hidden; position: relative;
}
.metric-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--cyan), var(--accent));
box-shadow: 0 0 8px var(--cyan);
transition: width .3s;
}
.section { margin-bottom: 18px; }
.node-row {
display: flex; align-items: center; gap: 8px;
font-size: 12px; font-family: 'Share Tech Mono', monospace;
padding: 4px 0;
}
.node-row .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-dim); }
.node-row .dot.ok { background: var(--green); box-shadow: 0 0 6px var(--green); }
.node-row .dot.err { background: var(--err); }
.node-row .name { flex: 1; }
.node-row .lat { color: var(--text-dim); font-size: 10px; }
/* ===== CENTER — circular HUD + camera ===== */
.center {
grid-area: center;
position: relative;
display: flex; align-items: center; justify-content: center;
}
.hud-ring {
position: relative;
width: min(70vh, 60vw, 600px);
height: min(70vh, 60vw, 600px);
display: flex; align-items: center; justify-content: center;
}
.ring-svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.ring-svg circle { fill: none; transition: all .4s; }
.viz-canvas {
position: absolute;
inset: 12%;
border-radius: 50%;
}
.video-feed {
position: absolute; inset: 18%;
border-radius: 50%;
object-fit: cover;
opacity: 0;
transition: opacity .5s;
filter: hue-rotate(180deg) brightness(.9) contrast(1.2);
transform: scaleX(-1);
}
.video-feed.active { opacity: .85; }
.hud-state {
position: absolute;
font-size: 14px; letter-spacing: 4px; color: var(--cyan);
text-transform: uppercase;
text-shadow: 0 0 10px var(--cyan);
font-weight: 600;
pointer-events: none;
}
.face-box {
position: absolute; border: 1px solid var(--cyan);
box-shadow: 0 0 6px var(--cyan);
pointer-events: none;
font-family: 'Share Tech Mono', monospace; font-size: 9px;
color: var(--cyan);
padding: 2px 4px;
}
.scan-line {
position: absolute; left: 0; right: 0; height: 2px;
background: linear-gradient(90deg, transparent, var(--cyan), transparent);
box-shadow: 0 0 12px var(--cyan);
opacity: 0;
pointer-events: none;
}
.center.scanning .scan-line {
opacity: 1; animation: scan 2.5s linear infinite;
}
@keyframes scan {
0% { top: 12%; }
100% { top: 88%; }
}
/* ===== RIGHT PANEL — context / events ===== */
.right {
grid-area: right;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 4px;
padding: 16px;
position: relative;
overflow-y: auto;
backdrop-filter: blur(6px);
}
.event {
font-family: 'Share Tech Mono', monospace;
font-size: 11px; padding: 5px 0;
border-bottom: 1px dashed rgba(59,130,246,.15);
color: var(--text);
word-wrap: break-word;
}
.event .time { color: var(--text-dim); margin-right: 6px; }
.event.user { color: var(--cyan); }
.event.system { color: var(--gold); }
/* ===== TRANSCRIPT BAR (bottom) ===== */
.transcript {
grid-area: trans;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 4px;
padding: 14px 20px;
position: relative;
display: flex; flex-direction: column;
gap: 10px;
backdrop-filter: blur(6px);
overflow-y: auto;
}
.transcript-line {
display: flex; align-items: flex-start; gap: 10px;
font-size: 14px; line-height: 1.5;
}
.transcript-line .role {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
padding: 1px 6px; border-radius: 2px;
white-space: nowrap; margin-top: 4px;
}
.transcript-line .role.user { background: rgba(0,212,255,.2); color: var(--cyan); border: 1px solid var(--cyan); }
.transcript-line .role.atlas { background: rgba(59,130,246,.2); color: var(--accent); border: 1px solid var(--accent); }
.transcript-line .text { flex: 1; }
.transcript-line .text p { margin: 0 0 4px; }
.input-row {
display: flex; gap: 8px; align-items: center;
border-top: 1px solid var(--border); padding-top: 10px;
}
#input {
flex: 1; background: rgba(0,212,255,.08);
border: 1px solid var(--border); border-radius: 3px;
color: var(--text); font-family: inherit; font-size: 14px;
padding: 8px 12px; outline: none;
}
#input:focus { border-color: var(--cyan); box-shadow: 0 0 6px rgba(0,212,255,.3); }
#input::placeholder { color: var(--text-dim); }
.send-btn {
background: var(--accent); color: white;
border: none; padding: 8px 16px;
border-radius: 3px; cursor: pointer; font-family: inherit;
font-weight: 600; letter-spacing: 1px;
}
.send-btn:disabled { background: var(--text-dim); cursor: not-allowed; }
/* ===== TYPING CURSOR ===== */
.typing::after { content: '▊'; color: var(--cyan); animation: blink 1s steps(2) infinite; }
@keyframes blink { 50% { opacity: 0; } }
/* corner brackets */
.bracket {
position: absolute; width: 14px; height: 14px;
border: 2px solid var(--cyan);
pointer-events: none;
}
.bracket.tl { top: 4px; left: 4px; border-right: none; border-bottom: none; }
.bracket.tr { top: 4px; right: 4px; border-left: none; border-bottom: none; }
.bracket.bl { bottom: 4px; left: 4px; border-right: none; border-top: none; }
.bracket.br { bottom: 4px; right: 4px; border-left: none; border-top: none; }
/* ===== RESPONSIVE ===== */
@media (max-width: 900px) {
.hud {
grid-template-rows: 50px 50vh 1fr;
grid-template-columns: 1fr;
grid-template-areas: "topnav" "center" "trans";
}
.left, .right { display: none; }
}
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
/* boot animation */
.boot-text {
position: fixed; inset: 0;
display: flex; align-items: center; justify-content: center;
background: var(--bg); z-index: 100;
font-family: 'Share Tech Mono', monospace;
flex-direction: column; gap: 10px;
transition: opacity .6s;
}
.boot-text.hidden { opacity: 0; pointer-events: none; }
.boot-text .big {
font-size: 32px; color: var(--cyan); letter-spacing: 8px;
text-shadow: 0 0 14px var(--cyan); font-weight: 700;
}
.boot-text .lines { font-size: 12px; color: var(--text-dim); }
.boot-text .lines div { animation: bootline .5s ease-out forwards; opacity: 0; }
@keyframes bootline { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
</style>
</head>
<body>
<div class="boot-text" id="boot">
<div class="big">A T L A S</div>
<div class="lines" id="boot-lines"></div>
</div>
<div class="hud">
<!-- TOP NAV -->
<header class="topnav">
<div>
<span class="brand-text">ATLAS HUD</span>
<span class="brand-sub">v0.1 · jarvis-class</span>
</div>
<div class="status-bar">
<span class="status-pill" id="pill-cluster"><span class="dot"></span><span>CLUSTER</span></span>
<span class="status-pill" id="pill-mic"><span class="dot"></span><span>MIC</span></span>
<span class="status-pill" id="pill-cam"><span class="dot"></span><span>CAM</span></span>
<button class="btn-icon" id="btn-camera" onclick="toggleCamera()">📷 CAM</button>
<button class="btn-icon" id="btn-mic" onclick="toggleListening()">🎤 LISTEN</button>
<button class="btn-icon" id="btn-fs" onclick="toggleFullscreen()">⛶ FULL</button>
</div>
<div class="bracket tl"></div>
<div class="bracket tr"></div>
</header>
<!-- LEFT — system status -->
<aside class="left">
<div class="bracket tl"></div>
<div class="bracket tr"></div>
<div class="section">
<div class="panel-title">▸ Cluster Nodes</div>
<div class="node-row"><span class="dot" id="dot-atlas01"></span><span class="name">atlas-01</span><span class="lat" id="lat-atlas01"></span></div>
<div class="node-row"><span class="dot" id="dot-pc"></span><span class="name">pc (local)</span><span class="lat" id="lat-pc"></span></div>
<div class="node-row"><span class="dot" id="dot-met"></span><span class="name">met-desktop</span><span class="lat" id="lat-met"></span></div>
</div>
<div class="section">
<div class="panel-title">▸ Active Model</div>
<div class="metric"><span class="metric-label">model</span><span id="m-model"></span></div>
<div class="metric"><span class="metric-label">node</span><span id="m-node"></span></div>
<div class="metric"><span class="metric-label">last latency</span><span id="m-lat"></span></div>
</div>
<div class="section">
<div class="panel-title">▸ System</div>
<div class="metric"><span class="metric-label">time</span><span id="m-time"></span></div>
<div class="metric"><span class="metric-label">date</span><span id="m-date"></span></div>
<div class="metric"><span class="metric-label">overdue €</span><span id="m-overdue"></span></div>
<div class="metric"><span class="metric-label">disk atlas-01</span><span id="m-disk"></span></div>
</div>
<div class="section">
<div class="panel-title">▸ Voice</div>
<div class="metric"><span class="metric-label">wake word</span><span>"atlas"</span></div>
<div class="metric"><span class="metric-label">recognition</span><span id="m-asr">native</span></div>
<div class="metric"><span class="metric-label">tts</span><span id="m-tts">native</span></div>
</div>
</aside>
<!-- CENTER — circular HUD -->
<main class="center" id="center">
<div class="scan-line"></div>
<div class="hud-ring">
<svg class="ring-svg" viewBox="0 0 200 200">
<!-- outer thin ring -->
<circle cx="100" cy="100" r="95" stroke="#3B82F6" stroke-width=".5" stroke-opacity=".4" />
<!-- main ring (state color) -->
<circle id="main-ring" cx="100" cy="100" r="88" stroke="#00d4ff" stroke-width="1" stroke-dasharray="3 2" opacity=".8" />
<!-- rotating ring -->
<circle id="rotate-ring" cx="100" cy="100" r="80" stroke="#3B82F6" stroke-width=".6" stroke-dasharray="120 553">
<animateTransform attributeName="transform" type="rotate" from="0 100 100" to="360 100 100" dur="20s" repeatCount="indefinite" />
</circle>
<!-- inner accent -->
<circle cx="100" cy="100" r="72" stroke="#00d4ff" stroke-width=".4" stroke-opacity=".3" />
</svg>
<canvas class="viz-canvas" id="viz" width="500" height="500"></canvas>
<video class="video-feed" id="cam" autoplay muted playsinline></video>
<div class="hud-state" id="state-label">IDLE</div>
</div>
</main>
<!-- RIGHT — events / context -->
<aside class="right">
<div class="bracket tl"></div>
<div class="bracket tr"></div>
<div class="panel-title">▸ Quick Tools</div>
<div style="display:flex;flex-direction:column;gap:6px;margin-bottom:14px">
<button class="btn-icon" onclick="toolCash()" style="text-align:left">📊 Cash check</button>
<button class="btn-icon" onclick="toolStatus()" style="text-align:left">🔧 System status</button>
<button class="btn-icon" onclick="toolToday()" style="text-align:left">📅 Today events</button>
</div>
<div class="panel-title">▸ Event Stream</div>
<div id="events">
<div class="event system"><span class="time">00:00</span>HUD initialized</div>
</div>
</aside>
<!-- TRANSCRIPT BAR -->
<section class="transcript">
<div class="bracket bl"></div>
<div class="bracket br"></div>
<div id="transcript"></div>
<div class="input-row">
<input id="input" placeholder="Spreek of typ — &quot;Atlas, ...&quot;" />
<button class="send-btn" id="send-btn">SEND</button>
</div>
</section>
</div>
<script>
// =============================================================================
// ATLAS HUD — browser-native Jarvis-class front-end
// =============================================================================
const CONFIG = {
nodes: {
'atlas-01': { url: location.origin + '/ollama', defaultModel: 'qwen2.5:7b' },
'met': { url: location.origin + '/ollama/met', defaultModel: 'llama3.1:8b' },
},
currentNode: 'atlas-01',
model: 'qwen2.5:7b',
wakeWord: 'atlas',
systemPrompt: 'Je bent Atlas, een AI-assistent voor Chaib. Antwoord kort en direct, NL of EN volgens de input. Vermijd uitweidingen — maximaal 2-3 zinnen tenzij om detail gevraagd. Spreek alsof je in een HUD verschijnt.',
voiceName: '', // auto-pick
};
let state = {
mode: 'IDLE', // IDLE / LISTENING / THINKING / SPEAKING
cameraActive: false,
micActive: false,
recognition: null,
audioCtx: null,
analyser: null,
micStream: null,
vizFrame: null,
conversation: [],
speaking: false,
};
// ===== BOOT SEQUENCE =====
async function boot() {
const lines = [
'INITIALIZING ATLAS PROTOCOL',
'CHECKING CLUSTER NODES…',
'LOADING MODEL ROUTER…',
'AUDIO PIPELINE READY',
'SYSTEM ONLINE',
];
const el = document.getElementById('boot-lines');
for (let i = 0; i < lines.length; i++) {
const d = document.createElement('div');
d.textContent = '> ' + lines[i];
el.appendChild(d);
await new Promise(r => setTimeout(r, 280));
}
await new Promise(r => setTimeout(r, 400));
document.getElementById('boot').classList.add('hidden');
setTimeout(() => document.getElementById('boot').remove(), 700);
setState('IDLE');
await refreshCluster();
await refreshPriorities();
setInterval(refreshClockOnly, 1000);
setInterval(refreshCluster, 30000);
setInterval(refreshPriorities, 60000);
emit('system', 'Cluster nodes detected: see left panel');
emit('system', 'Click 🎤 LISTEN to activate voice; "Atlas" = wake word');
setupInput();
}
// ===== STATE MACHINE =====
function setState(s) {
state.mode = s;
document.getElementById('state-label').textContent = s;
const ring = document.getElementById('main-ring');
const colors = { IDLE: '#3B82F6', LISTENING: '#00d4ff', THINKING: '#ffb020', SPEAKING: '#00ff88' };
ring.setAttribute('stroke', colors[s] || '#3B82F6');
document.getElementById('center').classList.toggle('scanning', s === 'LISTENING' || s === 'THINKING');
}
// ===== CLUSTER STATUS =====
async function refreshCluster() {
const nodes = [
{ id: 'atlas01', url: CONFIG.nodes['atlas-01'].url },
{ id: 'met', url: CONFIG.nodes.met.url },
];
let anyOk = false;
for (const n of nodes) {
const dot = document.getElementById('dot-' + n.id);
const lat = document.getElementById('lat-' + n.id);
if (!dot || !lat) continue;
const t0 = performance.now();
try {
const r = await fetch(n.url + '/api/tags', { signal: AbortSignal.timeout(4000) });
if (r.ok) {
const t = Math.round(performance.now() - t0);
dot.className = 'dot ok';
lat.textContent = t + 'ms';
anyOk = true;
} else throw 0;
} catch {
dot.className = 'dot err';
lat.textContent = 'off';
}
}
document.getElementById('pill-cluster').className = 'status-pill ' + (anyOk ? 'ok' : 'err');
document.getElementById('m-model').textContent = CONFIG.model;
document.getElementById('m-node').textContent = CONFIG.currentNode;
}
// ===== TOOL BUTTONS =====
async function toolCash() {
emit('user', 'tool: cash check');
appendTranscript('user', '📊 Cash check');
try {
const r = await fetch('/priorities.json');
const d = await r.json();
const eur = d.odoo?.overdue_eur || 0;
const cnt = d.odoo?.overdue_count || 0;
const txt = cnt > 0
? `Open openstaand AR: **€${eur}** in ${cnt} facturen. Oudste: ${d.odoo.oldest_inv} van ${d.odoo.oldest_partner} (${d.odoo.oldest_age} dagen).`
: `Open AR: **€0** — geen openstaande facturen.`;
appendTranscript('atlas', txt);
speak(`Open AR is ${eur} euro in ${cnt} facturen.`);
} catch (e) { emit('system', 'tool fail: ' + e.message); }
}
async function toolStatus() {
emit('user', 'tool: system status');
appendTranscript('user', '🔧 System status');
try {
const r = await fetch('/priorities.json');
const d = await r.json();
const txt = `Atlas-01: disk ${d.disk_free_gb}GB free · ${d.containers_down || 0} containers down. Attention score: ${d.attention?.score || 0}.`;
appendTranscript('atlas', txt);
speak(`Atlas-01 disk ${d.disk_free_gb} gigabyte free. ${d.containers_down || 0} containers down.`);
} catch (e) { emit('system', 'tool fail: ' + e.message); }
}
async function toolToday() {
emit('user', 'tool: today events');
appendTranscript('user', '📅 Vandaag');
try {
const r = await fetch('/today.json');
const d = await r.json();
const events = (d.events || d.calendar?.events || []).slice(0, 5);
if (!events.length) { appendTranscript('atlas', 'Geen events vandaag.'); speak('Geen events vandaag.'); return; }
const txt = `**Vandaag (${events.length}):**\n` + events.map(e => `- ${e.start || e.time || '?'} · ${e.title || e.summary || '?'}`).join('\n');
appendTranscript('atlas', txt);
speak(`Vandaag ${events.length} events. Eerste: ${events[0].title || events[0].summary || ''}.`);
} catch (e) { emit('system', 'tool fail: ' + e.message); }
}
// ===== FACE DETECTION (lazy-loaded face-api.js) =====
let faceApiLoaded = false;
let faceDetectorRunning = false;
async function loadFaceApi() {
if (faceApiLoaded) return true;
try {
await new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/face-api.js@0.22.2/dist/face-api.min.js';
s.onload = resolve; s.onerror = reject;
document.head.appendChild(s);
});
const MODELS = 'https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@master/weights';
await faceapi.nets.tinyFaceDetector.loadFromUri(MODELS);
faceApiLoaded = true;
emit('system', 'Face detection loaded');
return true;
} catch (e) {
emit('system', 'Face API load fail: ' + e.message);
return false;
}
}
async function runFaceDetection() {
if (!state.cameraActive) return;
if (!faceApiLoaded) {
const ok = await loadFaceApi();
if (!ok) return;
}
if (faceDetectorRunning) return;
faceDetectorRunning = true;
const v = document.getElementById('cam');
const center = document.getElementById('center');
async function detect() {
if (!state.cameraActive) { faceDetectorRunning = false; return; }
try {
const dets = await faceapi.detectAllFaces(v, new faceapi.TinyFaceDetectorOptions({ inputSize: 224, scoreThreshold: 0.4 }));
// Clear old boxes
center.querySelectorAll('.face-box').forEach(el => el.remove());
const ring = document.querySelector('.hud-ring').getBoundingClientRect();
const vRect = v.getBoundingClientRect();
const sx = vRect.width / v.videoWidth;
const sy = vRect.height / v.videoHeight;
for (const d of dets) {
const b = d.box;
const box = document.createElement('div');
box.className = 'face-box';
const left = vRect.left - ring.left + (v.videoWidth - b.x - b.width) * sx; // mirror
const top = vRect.top - ring.top + b.y * sy;
box.style.left = left + 'px';
box.style.top = top + 'px';
box.style.width = (b.width * sx) + 'px';
box.style.height = (b.height * sy) + 'px';
box.innerHTML = `<span style="position:absolute;top:-14px;left:0">FACE ${Math.round(d.score*100)}%</span>`;
center.appendChild(box);
}
} catch (e) {}
setTimeout(detect, 200);
}
detect();
}
async function refreshPriorities() {
try {
const r = await fetch('/priorities.json', { signal: AbortSignal.timeout(4000) });
if (r.ok) {
const d = await r.json();
document.getElementById('m-overdue').textContent = '€' + (d.odoo?.overdue_eur || 0);
document.getElementById('m-disk').textContent = (d.disk_free_gb || '?') + 'GB free';
}
} catch {}
}
function refreshClockOnly() {
const d = new Date();
document.getElementById('m-time').textContent = d.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
document.getElementById('m-date').textContent = d.toLocaleDateString('nl-NL', { day: '2-digit', month: 'short', year: 'numeric' });
}
// ===== CAMERA =====
async function toggleCamera() {
const v = document.getElementById('cam');
const btn = document.getElementById('btn-camera');
const pill = document.getElementById('pill-cam');
if (state.cameraActive) {
v.srcObject?.getTracks().forEach(t => t.stop());
v.srcObject = null;
v.classList.remove('active');
btn.classList.remove('active');
pill.className = 'status-pill';
state.cameraActive = false;
emit('system', 'Camera offline');
return;
}
try {
const s = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 640, facingMode: 'user' } });
v.srcObject = s;
v.classList.add('active');
btn.classList.add('active');
pill.className = 'status-pill ok';
state.cameraActive = true;
emit('system', 'Camera online');
// Kick off face detection (async load, won't block UI)
runFaceDetection();
} catch (e) {
emit('system', 'Camera fail: ' + e.message);
}
}
// ===== MIC + WAKE WORD =====
async function toggleListening() {
const btn = document.getElementById('btn-mic');
const pill = document.getElementById('pill-mic');
if (state.micActive) {
state.recognition?.stop();
state.micStream?.getTracks().forEach(t => t.stop());
if (state.vizFrame) cancelAnimationFrame(state.vizFrame);
state.micActive = false;
btn.classList.remove('active');
pill.className = 'status-pill';
setState('IDLE');
emit('system', 'Listening off');
return;
}
// Audio viz
try {
state.micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
state.audioCtx = state.audioCtx || new AudioContext();
const src = state.audioCtx.createMediaStreamSource(state.micStream);
state.analyser = state.audioCtx.createAnalyser();
state.analyser.fftSize = 256;
src.connect(state.analyser);
drawViz();
} catch (e) {
emit('system', 'Mic fail: ' + e.message);
return;
}
// Speech recognition
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) {
emit('system', 'Speech recognition unavailable in this browser');
return;
}
state.recognition = new SR();
state.recognition.lang = 'nl-NL';
state.recognition.continuous = true;
state.recognition.interimResults = true;
state.recognition.onresult = (e) => {
let final = '';
let interim = '';
for (let i = e.resultIndex; i < e.results.length; i++) {
const t = e.results[i][0].transcript;
if (e.results[i].isFinal) final += t;
else interim += t;
}
if (final.trim()) {
const low = final.toLowerCase().trim();
// wake word match — accept multiple aliases
const aliases = [CONFIG.wakeWord, 'hey atlas', 'atlas chat', 'jarvis'];
const hit = aliases.find(a => low.includes(a));
if (hit || state.mode === 'LISTENING') {
let payload = final;
for (const a of aliases) payload = payload.replace(new RegExp(a, 'gi'), '');
payload = payload.replace(/^[,.\s]+|[,.\s]+$/g, '');
if (payload.length > 2) {
handleVoice(payload);
}
}
}
if (interim) {
setState('LISTENING');
}
};
state.recognition.onerror = (e) => { emit('system', 'ASR error: ' + e.error); };
state.recognition.onend = () => {
if (state.micActive) state.recognition.start(); // auto-restart for continuous
};
state.recognition.start();
state.micActive = true;
btn.classList.add('active');
pill.className = 'status-pill ok';
setState('LISTENING');
emit('system', 'Listening… say "Atlas" + your question');
}
function drawViz() {
if (!state.analyser) return;
const c = document.getElementById('viz');
const ctx = c.getContext('2d');
const W = c.width, H = c.height, cx = W/2, cy = H/2;
const bufLen = state.analyser.frequencyBinCount;
const data = new Uint8Array(bufLen);
function tick() {
state.analyser.getByteFrequencyData(data);
ctx.clearRect(0, 0, W, H);
const bars = 64;
const radius = W * 0.35;
const color = state.mode === 'SPEAKING' ? '0,255,136'
: state.mode === 'THINKING' ? '255,176,32'
: state.mode === 'LISTENING' ? '0,212,255' : '59,130,246';
for (let i = 0; i < bars; i++) {
const dataIdx = Math.floor(i / bars * bufLen / 2);
const v = data[dataIdx] / 255;
const barH = 8 + v * 80;
const angle = (i / bars) * Math.PI * 2 - Math.PI/2;
const x1 = cx + Math.cos(angle) * radius;
const y1 = cy + Math.sin(angle) * radius;
const x2 = cx + Math.cos(angle) * (radius + barH);
const y2 = cy + Math.sin(angle) * (radius + barH);
ctx.strokeStyle = `rgba(${color},${0.4 + v * 0.6})`;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
state.vizFrame = requestAnimationFrame(tick);
}
tick();
}
async function handleVoice(text) {
emit('user', text);
appendTranscript('user', text);
await sendToAtlas(text);
}
// ===== INPUT (text fallback) =====
function setupInput() {
const inp = document.getElementById('input');
const btn = document.getElementById('send-btn');
const submit = () => {
const t = inp.value.trim();
if (!t) return;
inp.value = '';
appendTranscript('user', t);
sendToAtlas(t);
};
btn.onclick = submit;
inp.onkeydown = (e) => { if (e.key === 'Enter') submit(); };
}
// ===== SEND TO OLLAMA (streaming) =====
async function sendToAtlas(prompt) {
setState('THINKING');
state.conversation.push({ role: 'user', content: prompt });
const msgs = [
{ role: 'system', content: CONFIG.systemPrompt },
...state.conversation.slice(-12),
];
const t0 = performance.now();
const lineEl = appendTranscript('atlas', '');
const textEl = lineEl.querySelector('.text');
textEl.classList.add('typing');
let full = '';
try {
const nodeUrl = CONFIG.nodes[CONFIG.currentNode]?.url || CONFIG.nodes['atlas-01'].url;
const r = await fetch(nodeUrl + '/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: CONFIG.model, messages: msgs, stream: true }),
});
if (!r.ok) throw new Error('HTTP ' + r.status);
const reader = r.body.getReader();
const dec = new TextDecoder();
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let nl;
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
try {
const j = JSON.parse(line);
if (j.message?.content) {
full += j.message.content;
textEl.innerHTML = DOMPurify.sanitize(marked.parse(full));
scrollTranscript();
}
} catch {}
}
}
const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
document.getElementById('m-lat').textContent = elapsed + 's';
emit('atlas', full.slice(0, 80) + (full.length > 80 ? '…' : ''));
state.conversation.push({ role: 'assistant', content: full });
// Speak it
speak(full);
} catch (e) {
textEl.textContent = 'Fout: ' + e.message;
emit('system', 'ATLAS error: ' + e.message);
setState('IDLE');
} finally {
textEl.classList.remove('typing');
}
}
// ===== TTS =====
function speak(text) {
if (!('speechSynthesis' in window)) { setState('IDLE'); return; }
// Strip markdown for cleaner TTS
const clean = text.replace(/[*_`#>\[\]\(\)]/g, '').replace(/\n+/g, '. ');
const u = new SpeechSynthesisUtterance(clean);
u.lang = /[Ѐ-ӿéëçàáâäæ]/i.test(clean) || /\b(de|het|een|ik|je|wij|maar|niet)\b/i.test(clean) ? 'nl-NL' : 'en-US';
u.rate = 1.05;
u.pitch = 0.95;
// pick voice
const voices = speechSynthesis.getVoices();
const v = voices.find(v => v.lang.startsWith(u.lang.split('-')[0]) && /Microsoft|Google/.test(v.name))
|| voices.find(v => v.lang.startsWith(u.lang.split('-')[0]));
if (v) u.voice = v;
u.onstart = () => { setState('SPEAKING'); state.speaking = true; };
u.onend = () => { state.speaking = false; setState(state.micActive ? 'LISTENING' : 'IDLE'); };
u.onerror = () => { state.speaking = false; setState('IDLE'); };
speechSynthesis.speak(u);
}
// ===== TRANSCRIPT + EVENTS =====
function appendTranscript(role, text) {
const t = document.getElementById('transcript');
const div = document.createElement('div');
div.className = 'transcript-line';
div.innerHTML = `<span class="role ${role}">${role === 'user' ? 'JIJ' : 'ATLAS'}</span><div class="text">${DOMPurify.sanitize(marked.parse(text || ''))}</div>`;
t.appendChild(div);
scrollTranscript();
// keep only last 12
while (t.children.length > 12) t.firstChild.remove();
return div;
}
function scrollTranscript() {
const t = document.getElementById('transcript');
t.scrollTop = t.scrollHeight;
}
function emit(kind, text) {
const events = document.getElementById('events');
const div = document.createElement('div');
div.className = 'event ' + kind;
const time = new Date().toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' });
div.innerHTML = `<span class="time">${time}</span>${DOMPurify.sanitize(text)}`;
events.appendChild(div);
events.scrollTop = events.scrollHeight;
// keep only last 20
while (events.children.length > 20) events.firstChild.remove();
}
// ===== FULLSCREEN =====
function toggleFullscreen() {
if (document.fullscreenElement) document.exitFullscreen();
else document.documentElement.requestFullscreen();
}
// ===== START =====
window.addEventListener('load', () => {
// load voices async (Chrome)
if (speechSynthesis.onvoiceschanged !== undefined) {
speechSynthesis.onvoiceschanged = () => {};
}
boot();
});
</script>
</body>
</html>