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:
commit
301cc75de9
19 changed files with 5372 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
node_modules/
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
121
README.md
Normal 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.
|
||||||
98
patterns/ANTI_IMPULSE_RULES.md
Normal file
98
patterns/ANTI_IMPULSE_RULES.md
Normal 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.
|
||||||
71
patterns/CLUSTER_THEN_POLISH.md
Normal file
71
patterns/CLUSTER_THEN_POLISH.md
Normal 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.
|
||||||
169
scripts/atlas_activity_export.py
Normal file
169
scripts/atlas_activity_export.py
Normal 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()
|
||||||
143
scripts/atlas_autonomy_export.py
Normal file
143
scripts/atlas_autonomy_export.py
Normal 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()
|
||||||
181
scripts/atlas_finance_export.py
Normal file
181
scripts/atlas_finance_export.py
Normal 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()
|
||||||
149
scripts/atlas_lead_enrich.py
Normal file
149
scripts/atlas_lead_enrich.py
Normal 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
240
scripts/atlas_reminders.py
Normal 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()
|
||||||
265
scripts/atlas_seasonal_strategy.py
Normal file
265
scripts/atlas_seasonal_strategy.py
Normal 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()
|
||||||
320
scripts/atlas_self_prompting_loop.py
Normal file
320
scripts/atlas_self_prompting_loop.py
Normal 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
229
scripts/atlas_tray.py
Normal 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
179
scripts/cluster.py
Normal 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')
|
||||||
214
scripts/cluster_then_polish.py
Normal file
214
scripts/cluster_then_polish.py
Normal 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
189
scripts/vault_rag_expand.py
Normal 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
840
web/atlas-chat.html
Normal 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
984
web/atlas-command.html
Normal 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
952
web/atlas-hud.html
Normal 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 — "Atlas, ..."" />
|
||||||
|
<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>
|
||||||
Loading…
Reference in a new issue