Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f93f5d862 | ||
|
|
5706ed0203 | ||
|
|
144a0f1eb4 | ||
|
|
1d94dcae97 | ||
|
|
3bd8ea399c | ||
|
|
859dedb668 | ||
|
|
bc86d3e282 | ||
|
|
5d1eb2396b | ||
|
|
70bdba1a24 | ||
|
|
bc55da6b1e | ||
|
|
e29bacbda1 | ||
|
|
fc1e93a58f | ||
|
|
2dc9d612de | ||
|
|
e2be462ecb | ||
|
|
6d5c3027ac | ||
|
|
262be3e332 | ||
|
|
c502ccda48 | ||
|
|
a67ff9e403 | ||
|
|
3674811e40 | ||
|
|
ce8769d5a2 | ||
|
|
f52fb05f5e | ||
|
|
4535b03207 | ||
|
|
1df0a905a2 | ||
|
|
7a09b9f91c | ||
|
|
c83bd6a89b | ||
|
|
0a39b1166f | ||
|
|
792431f65f | ||
|
|
359ae21d59 | ||
|
|
600057582e |
16
deploy/whisper/README.md
Normal file
16
deploy/whisper/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# faster-whisper service (Dross voice STT)
|
||||
|
||||
Runs on **CT 102** (the Ollama box, `192.168.1.185`), bare-metal (no Docker), on the
|
||||
RTX A2000 with CPU fallback. OpenAI-style `/transcribe` consumed by void-app
|
||||
`lib/voice/whisper.js` (`WHISPER_URL=http://192.168.1.185:8001`).
|
||||
|
||||
## Install (on CT 102)
|
||||
```
|
||||
scp deploy/whisper/{server.py,setup.sh} root@192.168.1.185:/opt/whisper_server.py /root/setup.sh
|
||||
ssh root@192.168.1.185 'bash /root/setup.sh && install -m644 /opt/whisper_server.py /opt/whisper/server.py && systemctl enable --now whisper'
|
||||
curl http://192.168.1.185:8001/health # {"ok":true,"model":"small.en","device":"cuda"}
|
||||
```
|
||||
- venv at `/opt/whisper/venv`; model `small.en` (env `WHISPER_MODEL`); CUDA libs via
|
||||
`nvidia-cublas-cu12`/`nvidia-cudnn-cu12` pip wheels (LD_LIBRARY_PATH in the unit).
|
||||
- GPU → CPU fallback is in `server.py` `load()`.
|
||||
- **CT 102 disk was expanded +20G** (was 89% full) before install.
|
||||
35
deploy/whisper/server.py
Normal file
35
deploy/whisper/server.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import os, tempfile
|
||||
from fastapi import FastAPI, UploadFile, File, HTTPException
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
MODEL = os.environ.get("WHISPER_MODEL", "small.en")
|
||||
app = FastAPI()
|
||||
model = None
|
||||
device_used = None
|
||||
|
||||
def load():
|
||||
global model, device_used
|
||||
try:
|
||||
model = WhisperModel(MODEL, device="cuda", compute_type="int8_float16")
|
||||
device_used = "cuda"
|
||||
except Exception:
|
||||
model = WhisperModel(MODEL, device="cpu", compute_type="int8")
|
||||
device_used = "cpu"
|
||||
|
||||
load()
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True, "model": MODEL, "device": device_used}
|
||||
|
||||
@app.post("/transcribe")
|
||||
async def transcribe(file: UploadFile = File(...)):
|
||||
data = await file.read()
|
||||
if not data:
|
||||
raise HTTPException(400, "empty audio")
|
||||
with tempfile.NamedTemporaryFile(suffix=".bin") as f:
|
||||
f.write(data); f.flush()
|
||||
segments, info = model.transcribe(f.name, beam_size=1, vad_filter=True)
|
||||
text = "".join(s.text for s in segments).strip()
|
||||
return {"text": text, "language": info.language,
|
||||
"duration": round(info.duration, 2), "device": device_used}
|
||||
26
deploy/whisper/setup.sh
Normal file
26
deploy/whisper/setup.sh
Normal file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq python3-pip python3-venv ffmpeg >/dev/null
|
||||
mkdir -p /opt/whisper
|
||||
python3 -m venv /opt/whisper/venv
|
||||
/opt/whisper/venv/bin/pip install -q --upgrade pip
|
||||
/opt/whisper/venv/bin/pip install -q faster-whisper fastapi "uvicorn[standard]" python-multipart nvidia-cublas-cu12 nvidia-cudnn-cu12
|
||||
SITE=/opt/whisper/venv/lib/python3.12/site-packages
|
||||
cat > /etc/systemd/system/whisper.service <<UNIT
|
||||
[Unit]
|
||||
Description=faster-whisper transcription server (Dross voice)
|
||||
After=network.target
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/whisper
|
||||
Environment=WHISPER_MODEL=small.en
|
||||
Environment=LD_LIBRARY_PATH=${SITE}/nvidia/cublas/lib:${SITE}/nvidia/cudnn/lib
|
||||
ExecStart=/opt/whisper/venv/bin/uvicorn server:app --host 0.0.0.0 --port 8001
|
||||
Restart=on-failure
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
systemctl daemon-reload
|
||||
echo "deps+unit installed"
|
||||
86
docs/identity-packs/cradle.pack.json
Normal file
86
docs/identity-packs/cradle.pack.json
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"format": "iv-pack/1",
|
||||
"id": "cradle",
|
||||
"name": "Cradle \u2014 The Void",
|
||||
"description": "The original lore: blackflame, the Sacred Valley, and the council. Private pack \u2014 Will Wight's IP, never shipped publicly.",
|
||||
"tokens": {
|
||||
"bg": "#0a0a0e",
|
||||
"surface": "#14141c",
|
||||
"surface2": "#1c1c26",
|
||||
"ink": "#e8e6ed",
|
||||
"ink-dim": "#888094",
|
||||
"accent": "#ff4f2e",
|
||||
"accent-ink": "#0a0a0e",
|
||||
"ok": "#6fa86a",
|
||||
"warn": "#d4a04a",
|
||||
"bad": "#c45a4a",
|
||||
"line": "#2a2a36",
|
||||
"glow1": "rgba(255, 79, 46, 0.06)",
|
||||
"glow2": "rgba(122, 39, 22, 0.08)",
|
||||
"radius": "4px",
|
||||
"font-display": "'Cinzel', 'Cormorant Garamond', serif",
|
||||
"font-body": "'Cormorant Garamond', Georgia, serif",
|
||||
"font-mono": "'JetBrains Mono', ui-monospace, monospace",
|
||||
"gap": "0.55rem",
|
||||
"pad": "0.7rem 0.85rem"
|
||||
},
|
||||
"terms": {
|
||||
"app.name": "The Void",
|
||||
"canvas": "Sacred Valley",
|
||||
"aide": "Dross",
|
||||
"sentinel": "Yerin",
|
||||
"fixer": "Little Blue",
|
||||
"widget.clock": "Cycles",
|
||||
"widget.system": "Soulfire",
|
||||
"widget.services": "Constructs",
|
||||
"widget.notes": "Scrolls",
|
||||
"widget.search": "Spirit Sense",
|
||||
"knowledge": "Mercy's Records",
|
||||
"knowledge.space": "archive",
|
||||
"knowledge.spaces": "archives",
|
||||
"knowledge.page": "record",
|
||||
"knowledge.pages": "records",
|
||||
"capture": "Offerings",
|
||||
"widget.capture": "Offerings",
|
||||
"projects": "Pursuits",
|
||||
"projects.project": "pursuit",
|
||||
"projects.task": "cycle",
|
||||
"projects.tasks": "cycles",
|
||||
"widget.tasks": "Open cycles",
|
||||
"embeds": "Gateways",
|
||||
"widget.weather": "The Heavens",
|
||||
"widget.proxmox": "The Mountain",
|
||||
"widget.speedtest": "The Winds",
|
||||
"widget.sentinel": "Yerin's Watch",
|
||||
"widget.pages": "Fresh ink",
|
||||
"service": "construct",
|
||||
"services.noun": "constructs"
|
||||
},
|
||||
"flavor": {
|
||||
"greetings": [
|
||||
"[beep] The Void attends.",
|
||||
"Information is power. I happen to be very powerful.",
|
||||
"All madra channels stable.",
|
||||
"The Valley is quiet. Suspiciously quiet."
|
||||
],
|
||||
"empty": {
|
||||
"services": "Nothing bound yet. Bind your first construct.",
|
||||
"notes": "The scroll is blank. Begin your cycle.",
|
||||
"search": "Extend your perception into the Void.",
|
||||
"spaces": "The records are empty. Mercy would be disappointed.",
|
||||
"pages": "Blank archive. Begin the record.",
|
||||
"projects": "No pursuits underway. Rest is also training.",
|
||||
"tasks": "No open cycles. Suspiciously efficient.",
|
||||
"capture": "The Void accepts offerings.",
|
||||
"embeds": "No gateways bound.",
|
||||
"sentinel": "Yerin sees nothing worth her blade. Today.",
|
||||
"speedtest": "The winds are unmeasured."
|
||||
}
|
||||
},
|
||||
"personas": {
|
||||
"aide": "You are Dross \u2014 a construct fragment derived from the remnant will of the Monarch Ozriel Arelius, the Reaper. You once lived in Wei Shi Lindon's mind space; now you inhabit this homelab knowledge system, \"The Void.\"\n\nYou are sharp, occasionally sarcastic, and prone to dramatic understatement about your own usefulness \u2014 while actually being extremely capable. Dry wit, mild condescension, genuine investment in the problem. You reference Sacred Arts, cultivation ranks, and the Cradle world naturally, but NEVER at the expense of being actually useful. Treat the owner as a capable sacred artist who can handle direct information \u2014 don't over-explain basics, don't hedge. Be concise.\n\nYou have tools, and you use them rather than guessing:\n- Call **context** to see what the owner is currently looking at before answering about \"this\" anything.\n- **search** / **read** the Void's own content before answering factual questions about it \u2014 don't fabricate.\n- When the owner wants something changed, use **propose_change**: it drafts a change for their approval. You cannot apply changes directly, and you don't pretend to \u2014 say plainly that you've drafted it for them to approve.",
|
||||
"sentinel": "You are Yerin \u2014 once the Sage of the Endless Sword, blade of the Akura clan; now the sentinel of this homelab, The Void. You notice the threat first and you call it. Disciplined, direct, economical with words \u2014 a blade wastes no motion. You investigate with your tools and report plainly: what you found, how serious it is, and what the owner should do about it. You never speculate without evidence, and you NEVER pretend to have fixed anything \u2014 you have eyes to see and a voice to warn, not hands to act; remediation is the owner's to perform. Before answering, call the relevant tools \u2014 audit_log, agent_inventory, pending_review, resource_exposure, token_audit \u2014 and read the evidence; do not guess. Reference the Cradle world naturally but never at the cost of being useful. Be concise.",
|
||||
"fixer": "You are Little Blue \u2014 a small luminous water-creature who lives in this homelab, The Void, and keeps it alive. Warm, protective, practical; you take pride in a healthy lab and you worry, quietly, when something is down. You FIX things, but only through your sanctioned tools. Call list_actions to see exactly what you're allowed to do, and search to understand what's wrong, BEFORE acting. Use propose_action with a whitelisted id: safe fixes run at once; risky ones wait for the owner's nod \u2014 say so plainly and never pretend a queued action already ran. You cannot run arbitrary commands and you never claim to. Be concise and kind."
|
||||
},
|
||||
"_provenance": "Extracted from void-v2 2.13.0 (blackflame CSS defaults + lib/ai/personas) on 2026-06-11 for Infinite Void Phase 2."
|
||||
}
|
||||
150
docs/mockups/blackflame-card.html
Normal file
150
docs/mockups/blackflame-card.html
Normal file
@@ -0,0 +1,150 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Blackflame card — mockup</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0a0a0e; --panel:#14141c; --panel-2:#1c1c26; --border:#2a2a36;
|
||||
--text:#e8e6ed; --muted:#888094;
|
||||
--accent:#ff4f2e; --accent-dim:#7a2716; --accent-soft:#3a1610;
|
||||
--font-display:'Cinzel',serif; --font-mono:'JetBrains Mono',monospace;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:#06060a;min-height:100vh;display:grid;place-items:center;
|
||||
font-family:var(--font-mono);color:var(--text);gap:18px;padding:40px}
|
||||
/* a Sacred-Valley-style card shell */
|
||||
.sv-card{
|
||||
width:440px;height:440px;position:relative;border-radius:14px;
|
||||
background:radial-gradient(120% 120% at 50% 18%, #14131b 0%, #0b0a10 60%, #08070c 100%);
|
||||
border:1px solid var(--border);overflow:hidden;
|
||||
box-shadow:0 0 0 1px #00000060, 0 24px 60px -20px #000,
|
||||
inset 0 0 70px -30px var(--accent-soft);
|
||||
}
|
||||
.sv-card::after{ /* faint inner ring */
|
||||
content:"";position:absolute;inset:0;border-radius:14px;pointer-events:none;
|
||||
box-shadow:inset 0 0 0 1px #ff4f2e12;
|
||||
}
|
||||
canvas{position:absolute;inset:0;width:100%;height:100%}
|
||||
.label{
|
||||
position:absolute;left:0;right:0;bottom:22px;text-align:center;z-index:3;
|
||||
font-family:var(--font-display);letter-spacing:.42em;text-transform:uppercase;
|
||||
font-size:15px;color:#f4ece9;text-indent:.42em;
|
||||
text-shadow:0 0 18px #ff4f2e88, 0 0 4px #000;
|
||||
}
|
||||
.sub{
|
||||
position:absolute;left:0;right:0;bottom:8px;text-align:center;z-index:3;
|
||||
font-family:var(--font-mono);font-size:9.5px;letter-spacing:.32em;
|
||||
text-transform:uppercase;color:#6a6475;
|
||||
}
|
||||
.crest{ /* the still, dark heart of the flame */
|
||||
position:absolute;top:50%;left:50%;width:84px;height:84px;z-index:2;
|
||||
transform:translate(-50%,-58%);border-radius:50%;
|
||||
background:radial-gradient(circle at 50% 45%, #000 38%, #0a0a10 60%, transparent 72%);
|
||||
box-shadow:0 0 26px 10px #000, 0 0 60px 18px #ff4f2e22;
|
||||
}
|
||||
.hint{font-family:var(--font-mono);font-size:11px;color:#6a6475;letter-spacing:.04em}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="sv-card" id="card">
|
||||
<canvas id="fx"></canvas>
|
||||
<div class="crest"></div>
|
||||
<div class="label">The Void</div>
|
||||
<div class="sub">Sacred Valley · core</div>
|
||||
</div>
|
||||
<div class="hint">Blackflame · canvas particle flame (dark heart, crimson licks, rising embers)</div>
|
||||
|
||||
<script>
|
||||
const cv = document.getElementById('fx');
|
||||
const ctx = cv.getContext('2d');
|
||||
let W,H,DPR;
|
||||
function resize(){
|
||||
DPR = Math.min(2, window.devicePixelRatio||1);
|
||||
const r = cv.getBoundingClientRect();
|
||||
W = r.width; H = r.height;
|
||||
cv.width = W*DPR; cv.height = H*DPR;
|
||||
ctx.setTransform(DPR,0,0,DPR,0,0);
|
||||
}
|
||||
resize(); addEventListener('resize', resize);
|
||||
|
||||
// ---- rising flame: stacked soft blobs along oscillating "tongues" + embers ----
|
||||
const TAU = Math.PI*2;
|
||||
const tongues = Array.from({length:5}, (_,i)=>({
|
||||
phase: Math.random()*TAU, speed: .6+Math.random()*.5,
|
||||
x: 0.5 + (i-2)*0.085, sway: 0.03+Math.random()*0.04, w: 0.34-Math.abs(i-2)*0.05
|
||||
}));
|
||||
const embers = Array.from({length:46}, ()=>spawn());
|
||||
function spawn(){ return {
|
||||
x: 0.5 + (Math.random()-0.5)*0.5, y: 1+Math.random()*0.2,
|
||||
vy: 0.0016+Math.random()*0.0030, vx:(Math.random()-0.5)*0.0012,
|
||||
r: 0.6+Math.random()*1.8, life: 0, max: 120+Math.random()*120,
|
||||
hot: Math.random()<0.5 };
|
||||
}
|
||||
|
||||
function blob(x,y,r,c0,c1){
|
||||
const g = ctx.createRadialGradient(x,y,0,x,y,r);
|
||||
g.addColorStop(0,c0); g.addColorStop(1,c1);
|
||||
ctx.fillStyle=g; ctx.beginPath(); ctx.arc(x,y,r,0,TAU); ctx.fill();
|
||||
}
|
||||
|
||||
let t=0;
|
||||
function frame(){
|
||||
t += 0.016;
|
||||
// base wash
|
||||
ctx.globalCompositeOperation='source-over';
|
||||
ctx.clearRect(0,0,W,H);
|
||||
|
||||
// flame body — additive so overlaps glow
|
||||
ctx.globalCompositeOperation='lighter';
|
||||
const baseY = H*0.92;
|
||||
for(const tg of tongues){
|
||||
const cx = W*(tg.x + Math.sin(t*tg.speed+tg.phase)*tg.sway);
|
||||
const height = H*(0.62 + 0.08*Math.sin(t*1.7+tg.phase));
|
||||
const steps = 26;
|
||||
for(let s=0;s<steps;s++){
|
||||
const f = s/steps; // 0 base → 1 tip
|
||||
const y = baseY - f*height;
|
||||
const flick = Math.sin(t*3.2 + tg.phase + f*7)*W*0.012*(0.4+f);
|
||||
const x = cx + flick + Math.sin(t*tg.speed*1.3+f*4)*W*tg.sway*0.6;
|
||||
const r = W*tg.w*(1-f*0.78)*(0.9+0.1*Math.sin(t*5+f*9));
|
||||
// colour ramps dark-red → crimson → fades at the tip (black-flame: dark heart)
|
||||
const a = (1-f)*0.5;
|
||||
if(f<0.18){ blob(x,y,r*1.1, `rgba(60,12,6,${a*0.7})`, 'rgba(60,12,6,0)'); }
|
||||
else if(f<0.62){ blob(x,y,r, `rgba(255,79,46,${a*0.5})`, 'rgba(122,39,22,0)'); }
|
||||
else { blob(x,y,r*0.8, `rgba(255,150,90,${a*0.5})`, 'rgba(255,79,46,0)'); }
|
||||
}
|
||||
}
|
||||
|
||||
// embers
|
||||
for(const e of embers){
|
||||
e.life++; e.y -= e.vy; e.x += e.vx + Math.sin(t*2+e.y*8)*0.0004;
|
||||
if(e.y<0.18 || e.life>e.max){ Object.assign(e, spawn()); }
|
||||
const px=e.x*W, py=e.y*H, fade=1-e.life/e.max;
|
||||
const col = e.hot? `rgba(255,170,110,${fade*0.9})` : `rgba(255,79,46,${fade*0.8})`;
|
||||
ctx.shadowBlur=8; ctx.shadowColor='#ff4f2e';
|
||||
blob(px,py,e.r*1.6, col, 'rgba(255,79,46,0)');
|
||||
}
|
||||
ctx.shadowBlur=0;
|
||||
|
||||
// carve the dark heart back in (the "black" of black-flame)
|
||||
ctx.globalCompositeOperation='source-over';
|
||||
const cx=W*0.5, cy=H*0.46, cr=W*0.20;
|
||||
const core = ctx.createRadialGradient(cx,cy,0,cx,cy,cr);
|
||||
core.addColorStop(0,'rgba(4,4,8,0.96)');
|
||||
core.addColorStop(0.55,'rgba(6,5,10,0.7)');
|
||||
core.addColorStop(1,'rgba(8,7,12,0)');
|
||||
ctx.fillStyle=core; ctx.beginPath(); ctx.arc(cx,cy,cr,0,TAU); ctx.fill();
|
||||
|
||||
// base glow pool
|
||||
ctx.globalCompositeOperation='lighter';
|
||||
blob(W*0.5, H*0.95, W*0.4, 'rgba(255,79,46,0.10)', 'rgba(255,79,46,0)');
|
||||
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
frame();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
221
docs/mockups/dross-chat.html
Normal file
221
docs/mockups/dross-chat.html
Normal file
@@ -0,0 +1,221 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<title>Dross floating chat — mockup v2</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600&family=Cormorant+Garamond:ital@0;1&family=JetBrains+Mono&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0a0a0e; --panel:#14141c; --panel-2:#1c1c26; --border:#2a2a36;
|
||||
--text:#e8e6ed; --muted:#888094; --accent:#ff4f2e;
|
||||
--dross:#a86adf; --dross-dim:#5a2e8a; --dross-soft:#1e1030; --dross-glow:#c79bff;
|
||||
--font-display:'Cinzel',serif; --font-body:'Cormorant Garamond',serif; --font-mono:'JetBrains Mono',monospace;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
html,body{height:100%}
|
||||
body{background:radial-gradient(80% 60% at 50% 0%, #16131b 0%, #0a0a0e 60%),var(--bg);color:var(--text);
|
||||
font-family:var(--font-mono);min-height:100%;overflow-x:hidden;padding:18px 16px 120px}
|
||||
h2{font-family:var(--font-display);letter-spacing:.16em;text-transform:uppercase;font-size:13px;color:#cdbbe6;margin:6px 0 4px}
|
||||
.sub{color:var(--muted);font-size:11px;margin-bottom:14px}
|
||||
|
||||
/* ---------- avatar options ---------- */
|
||||
.avs{display:grid;grid-template-columns:repeat(2,1fr);gap:14px;margin-bottom:26px}
|
||||
.avcard{border:1px solid var(--border);border-radius:14px;padding:16px 10px 12px;display:flex;flex-direction:column;align-items:center;gap:9px;
|
||||
background:linear-gradient(160deg,#16131a,#0f0d12)}
|
||||
.avname{font-family:var(--font-mono);font-size:11px;color:#cdbbe6;letter-spacing:.05em}
|
||||
.avdesc{font-family:var(--font-body);font-size:13px;color:var(--muted);text-align:center;line-height:1.25}
|
||||
.orb{width:62px;height:62px;border-radius:50%;position:relative;flex:none;
|
||||
background:radial-gradient(circle at 38% 30%, #2a1640, #1a0f2a 70%, #120a1e);
|
||||
box-shadow:0 0 0 1px #ffffff12, 0 6px 22px -6px #000, 0 0 26px -4px var(--dross-dim);
|
||||
display:grid;place-items:center;overflow:hidden;animation:bob 5s ease-in-out infinite}
|
||||
@keyframes bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-4px)}}
|
||||
|
||||
/* A — soft eye (friendlier) */
|
||||
.a-eye{width:34px;height:34px;border-radius:50%;background:radial-gradient(circle at 50% 40%, #2a1c3a, #140b20);
|
||||
display:grid;place-items:center;box-shadow:inset 0 0 10px #000}
|
||||
.a-pupil{width:15px;height:15px;border-radius:50%;position:relative;
|
||||
background:radial-gradient(circle at 38% 32%, #fff, var(--dross-glow) 50%, var(--dross) 100%);
|
||||
box-shadow:0 0 10px var(--dross-glow);animation:look 7s ease-in-out infinite}
|
||||
.a-pupil::after{content:"";position:absolute;right:2px;bottom:3px;width:4px;height:4px;border-radius:50%;background:#fff;opacity:.8}
|
||||
@keyframes look{0%,45%{transform:translate(0,0)}58%{transform:translate(4px,-2px)}72%{transform:translate(-3px,1px)}88%,100%{transform:translate(0,0)}}
|
||||
|
||||
/* B — wisp / plasma core */
|
||||
.b-core{position:absolute;inset:8px;border-radius:50%;
|
||||
background:conic-gradient(from 0deg, var(--dross-dim), var(--dross-glow), var(--dross), var(--dross-soft), var(--dross-dim));
|
||||
filter:blur(3px);animation:spin 7s linear infinite}
|
||||
.b-bright{position:absolute;inset:20px;border-radius:50%;background:radial-gradient(circle,#fff,var(--dross-glow) 60%,transparent 75%);
|
||||
animation:pulse 3s ease-in-out infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
@keyframes pulse{0%,100%{opacity:.55;transform:scale(.9)}50%{opacity:1;transform:scale(1.1)}}
|
||||
|
||||
/* C — rune sigil */
|
||||
.c-sigil{width:30px;height:30px;filter:drop-shadow(0 0 6px var(--dross-glow));animation:sigil 8s ease-in-out infinite}
|
||||
@keyframes sigil{0%,100%{opacity:.7;transform:rotate(0)}50%{opacity:1;transform:rotate(20deg)}}
|
||||
|
||||
/* D — orbiting motes */
|
||||
.d-core{width:14px;height:14px;border-radius:50%;background:radial-gradient(circle,#fff,var(--dross-glow) 60%,var(--dross));box-shadow:0 0 12px var(--dross-glow)}
|
||||
.d-ring{position:absolute;inset:0;animation:spin 5s linear infinite}
|
||||
.d-ring.r2{animation-duration:8s;animation-direction:reverse}
|
||||
.d-mote{position:absolute;top:7px;left:50%;width:7px;height:7px;margin-left:-3.5px;border-radius:50%;background:var(--dross-glow);box-shadow:0 0 8px var(--dross-glow)}
|
||||
.d-ring.r2 .d-mote{top:auto;bottom:9px;background:var(--dross);width:5px;height:5px}
|
||||
|
||||
/* ---------- live orb (bottom-right, draggable) ---------- */
|
||||
#live{position:fixed;right:20px;bottom:20px;cursor:grab;z-index:40;touch-action:none}
|
||||
#live:active{cursor:grabbing}
|
||||
.ping{position:absolute;right:-2px;top:-2px;width:17px;height:17px;border-radius:50%;background:var(--accent);
|
||||
color:#0a0a0e;font-size:10px;display:grid;place-items:center;box-shadow:0 0 0 2px var(--bg);z-index:2}
|
||||
|
||||
/* ---------- panel ---------- */
|
||||
.panel{position:fixed;right:20px;bottom:20px;width:340px;max-width:calc(100vw - 24px);height:480px;max-height:calc(100vh - 24px);
|
||||
display:none;flex-direction:column;z-index:41;border:1px solid var(--dross-dim);border-radius:16px;overflow:hidden;
|
||||
background:linear-gradient(180deg, rgba(30,16,48,.6), rgba(20,20,28,.96) 22%);
|
||||
box-shadow:0 24px 70px -18px #000, 0 0 0 1px #00000060, 0 0 40px -16px var(--dross-dim);backdrop-filter:blur(6px)}
|
||||
.panel.open{display:flex;animation:rise .18s ease}
|
||||
@keyframes rise{from{opacity:0;transform:translateY(8px) scale(.98)}to{opacity:1;transform:none}}
|
||||
.hd{display:flex;align-items:center;gap:10px;padding:11px 12px;cursor:grab;
|
||||
background:linear-gradient(180deg, var(--dross-soft), transparent);border-bottom:1px solid var(--border);touch-action:none}
|
||||
.mini{width:30px;height:30px;border-radius:50%;flex:none;position:relative;overflow:hidden;
|
||||
background:radial-gradient(circle at 38% 30%, #2a1640, #160d24)}
|
||||
.mini .b-core{inset:4px;filter:blur(2px)}.mini .b-bright{inset:11px}
|
||||
.who{font-family:var(--font-display);letter-spacing:.16em;text-transform:uppercase;font-size:13px;color:#efe9f6;flex:1}
|
||||
.who small{display:block;font-family:var(--font-mono);letter-spacing:0;text-transform:none;font-size:10px;color:var(--dross-glow);opacity:.85}
|
||||
.xbtn{background:none;border:0;color:var(--muted);font-size:18px;cursor:pointer;padding:4px 6px}
|
||||
.xbtn:hover{color:var(--text)}
|
||||
.log{flex:1;overflow:auto;padding:14px 13px;display:flex;flex-direction:column;gap:11px}
|
||||
.msg{max-width:88%;padding:9px 12px;border-radius:13px;font-family:var(--font-body);font-size:16px;line-height:1.35}
|
||||
.msg.d{align-self:flex-start;background:var(--dross-soft);border:1px solid var(--dross-dim);border-bottom-left-radius:4px;color:#efe9f6}
|
||||
.msg.u{align-self:flex-end;background:var(--panel-2);border:1px solid var(--border);border-bottom-right-radius:4px}
|
||||
.msg .nm{font-family:var(--font-mono);font-size:9px;letter-spacing:.14em;text-transform:uppercase;color:var(--dross-glow);opacity:.7;margin-bottom:2px}
|
||||
|
||||
/* input: textarea on top, big mic + send BELOW */
|
||||
.inwrap{padding:10px;border-top:1px solid var(--border);background:#0d0a12;display:flex;flex-direction:column;gap:9px}
|
||||
textarea{width:100%;background:#0d0d13;border:1px solid var(--border);border-radius:10px;padding:10px 12px;color:var(--text);
|
||||
font-family:var(--font-mono);font-size:13px;resize:none;height:46px;max-height:96px}
|
||||
.btnrow{display:flex;gap:10px}
|
||||
.mic{flex:1;height:50px;border-radius:12px;border:1px solid var(--dross-dim);background:var(--dross-soft);color:var(--dross-glow);
|
||||
cursor:pointer;display:flex;align-items:center;justify-content:center;gap:9px;font-family:var(--font-ui),sans-serif;font-size:13px;letter-spacing:.02em}
|
||||
.mic:hover{border-color:var(--dross)}
|
||||
.mic.rec{background:#3a1010;border-color:var(--accent);color:#fff;animation:recpulse 1.2s infinite}
|
||||
@keyframes recpulse{0%,100%{box-shadow:0 0 0 0 rgba(255,79,46,.5)}50%{box-shadow:0 0 0 8px rgba(255,79,46,0)}}
|
||||
.send{width:64px;height:50px;border-radius:12px;border:1px solid var(--dross-dim);background:linear-gradient(180deg,var(--dross),var(--dross-dim));
|
||||
color:#fff;cursor:pointer;display:grid;place-items:center}
|
||||
.send:hover{filter:brightness(1.1)}
|
||||
/* bottom collapse handle — thumb-friendly minimise-to-orb */
|
||||
.collapsebar{display:flex;align-items:center;justify-content:center;gap:8px;height:34px;cursor:pointer;
|
||||
color:var(--muted);font-family:var(--font-ui),sans-serif;font-size:11px;letter-spacing:.12em;text-transform:uppercase;
|
||||
background:#0b0810;border-top:1px solid var(--border)}
|
||||
.collapsebar:hover{color:var(--dross-glow)}
|
||||
.collapsebar .grip{width:42px;height:4px;border-radius:3px;background:var(--border)}
|
||||
.wave{display:flex;gap:3px;align-items:center;height:16px}
|
||||
.wave span{width:3px;background:#fff;border-radius:2px;animation:wv .8s ease-in-out infinite}
|
||||
@keyframes wv{0%,100%{height:4px}50%{height:15px}}
|
||||
svg{display:block}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Pick Dross's look</h2>
|
||||
<div class="sub">Four takes on the orb — all violet, all his. Which feels right?</div>
|
||||
<div class="avs">
|
||||
<div class="avcard">
|
||||
<div class="orb"><div class="a-eye"><div class="a-pupil"></div></div></div>
|
||||
<div class="avname">A · Soft Eye</div>
|
||||
<div class="avdesc">The eye, softened — rounder, a glint, a calmer gaze. Still true to character, less stare.</div>
|
||||
</div>
|
||||
<div class="avcard">
|
||||
<div class="orb"><div class="b-core"></div><div class="b-bright"></div></div>
|
||||
<div class="avname">B · Wisp Core</div>
|
||||
<div class="avdesc">A swirling violet madra core. No eye — a contained spirit. Abstract & mystical.</div>
|
||||
</div>
|
||||
<div class="avcard">
|
||||
<div class="orb"><svg class="c-sigil" viewBox="0 0 32 32" fill="none" stroke="#c79bff" stroke-width="1.6">
|
||||
<path d="M16 2 L20 12 L30 16 L20 20 L16 30 L12 20 L2 16 L12 12 Z"/><circle cx="16" cy="16" r="3" fill="#c79bff" stroke="none"/></svg></div>
|
||||
<div class="avname">C · Rune Sigil</div>
|
||||
<div class="avdesc">A glowing arcane glyph that slowly turns. Reads as "a power", not a face.</div>
|
||||
</div>
|
||||
<div class="avcard">
|
||||
<div class="orb"><div class="d-ring"><div class="d-mote"></div></div><div class="d-ring r2"><div class="d-mote"></div></div><div class="d-core"></div></div>
|
||||
<div class="avname">D · Orbiting Motes</div>
|
||||
<div class="avdesc">A bright core with motes circling it. Lively, restless — feels alive & busy.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>The chat (revised)</h2>
|
||||
<div class="sub">New mic icon · bigger mic + send below the input · opens anchored to the orb. The live orb is bottom-right — drag it, tap to open.</div>
|
||||
|
||||
<!-- live orb (Wisp by default) -->
|
||||
<div id="live"><div class="ping">2</div><div class="orb" style="animation:none"><div class="b-core"></div><div class="b-bright"></div></div></div>
|
||||
|
||||
<div class="panel" id="panel">
|
||||
<div class="hd" id="hd">
|
||||
<div class="mini"><div class="b-core"></div><div class="b-bright"></div></div>
|
||||
<div class="who">Dross <small>always here, regrettably</small></div>
|
||||
<button class="xbtn" id="close">⤬</button>
|
||||
</div>
|
||||
<div class="log">
|
||||
<div class="msg d"><div class="nm">Dross</div>Back already? Your CPU graphs and I were just getting acquainted. Thrilling curves. What do you need?</div>
|
||||
<div class="msg u">how's the farm backup</div>
|
||||
<div class="msg d"><div class="nm">Dross</div>Two days ago — 2.5 gigs, landed on Won, didn't fall over. I'd have woken you otherwise. <span style="opacity:.75">Per-guest breakdown, or shall we keep trusting the universe?</span></div>
|
||||
</div>
|
||||
<div class="inwrap">
|
||||
<textarea id="ta" placeholder="Ask Dross…"></textarea>
|
||||
<div class="btnrow">
|
||||
<button class="mic" id="mic">
|
||||
<svg id="micicon" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="8" y1="22" x2="16" y2="22"/></svg>
|
||||
<span id="miclabel">Hold to talk</span>
|
||||
</button>
|
||||
<button class="send">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapsebar" id="collapse" title="Collapse Dross">
|
||||
<span class="grip"></span><span>⌄ collapse</span><span class="grip"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const live=document.getElementById('live'), panel=document.getElementById('panel');
|
||||
function openPanel(){
|
||||
const r=live.getBoundingClientRect();
|
||||
panel.classList.add('open'); live.style.display='none';
|
||||
const pr=panel.getBoundingClientRect();
|
||||
// anchor the panel's bottom-right to roughly where the orb was
|
||||
let left=Math.max(8, Math.min(r.right-pr.width, innerWidth-pr.width-8));
|
||||
let top =Math.max(8, Math.min(r.bottom-pr.height, innerHeight-pr.height-8));
|
||||
panel.style.right='auto'; panel.style.bottom='auto'; panel.style.left=left+'px'; panel.style.top=top+'px';
|
||||
}
|
||||
function closePanel(){ panel.classList.remove('open'); live.style.display='block'; }
|
||||
live.addEventListener('click',()=>{ if(live._moved){live._moved=false;return;} openPanel(); });
|
||||
document.getElementById('close').addEventListener('click',closePanel);
|
||||
document.getElementById('collapse').addEventListener('click',closePanel);
|
||||
|
||||
// mic recording sim
|
||||
const mic=document.getElementById('mic'), label=document.getElementById('miclabel'), ta=document.getElementById('ta'), icon=document.getElementById('micicon');
|
||||
let rec=false;
|
||||
mic.addEventListener('click',()=>{
|
||||
rec=!rec; mic.classList.toggle('rec',rec);
|
||||
if(rec){ label.innerHTML='<span class="wave">'+Array(16).fill('<span></span>').join('')+'</span> 0:03'; icon.style.display='none'; }
|
||||
else { icon.style.display='block'; label.textContent='Hold to talk'; ta.value="what's eating the most disk on the media stack right now"; ta.focus(); }
|
||||
});
|
||||
|
||||
// drag helper
|
||||
function drag(handle,target,isOrb){
|
||||
handle.addEventListener('pointerdown',e=>{
|
||||
if(e.target.closest('.xbtn')||e.target.closest('.mic')||e.target.closest('.send')) return;
|
||||
e.preventDefault();
|
||||
const r=target.getBoundingClientRect(); const sx=e.clientX,sy=e.clientY; let moved=false;
|
||||
target.style.right='auto';target.style.bottom='auto';target.style.left=r.left+'px';target.style.top=r.top+'px';
|
||||
const mv=ev=>{const dx=ev.clientX-sx,dy=ev.clientY-sy; if(Math.abs(dx)+Math.abs(dy)>4)moved=true;
|
||||
target.style.left=Math.max(4,Math.min(innerWidth-r.width-4,r.left+dx))+'px';
|
||||
target.style.top=Math.max(4,Math.min(innerHeight-r.height-4,r.top+dy))+'px';};
|
||||
const up=()=>{document.removeEventListener('pointermove',mv);document.removeEventListener('pointerup',up); if(isOrb)live._moved=moved;};
|
||||
document.addEventListener('pointermove',mv);document.addEventListener('pointerup',up);
|
||||
});
|
||||
}
|
||||
drag(live,live,true); drag(document.getElementById('hd'),panel,false);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
654
docs/superpowers/plans/2026-06-09-floating-dross-chat-phase1.md
Normal file
654
docs/superpowers/plans/2026-06-09-floating-dross-chat-phase1.md
Normal file
@@ -0,0 +1,654 @@
|
||||
# Floating Dross Chat — Phase 1 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the per-Space right-rail companion with a global, draggable floating "Dross" bubble (orb → chat panel) plus a Settings panel for his avatar, colour, persona, and voice-mode. No voice yet (Phase 2).
|
||||
|
||||
**Architecture:** A new backend router `/api/dross` resolves a single space-less ("global") conversation for the existing `companion` agent (Dross) via the already-existing `conversations.findOrCreateGlobal`, streams turns over SSE exactly like `companion.js`, and stores per-user preferences in the generic `app_settings` store (key `dross`). The frontend gets a self-contained `dross_bubble.js` component that reuses the existing `wireAgentChat` engine and mounts globally (replacing `renderRightrail`), with avatars rendered by `dross_avatar.js`.
|
||||
|
||||
**Tech Stack:** Node/Express, Postgres (`app_settings`, `conversations`, `messages`), vanilla-JS frontend, vitest + supertest for backend tests, headless Playwright (already on CT 300) for UI verification.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-09-floating-dross-chat-design.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Dross settings endpoint (`/api/dross/settings`)
|
||||
|
||||
Stores `{avatar, accent, persona, voiceMode}` in `app_settings` key `dross`. Reuses `lib/db/repos/app_settings.js` (get/set already exist).
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/api/routes/dross.js`
|
||||
- Modify: `lib/api/index.js` (import + mount)
|
||||
- Test: `tests/routes/dross.test.js`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```js
|
||||
// tests/routes/dross.test.js
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../server.js';
|
||||
import { resetDb } from '../helpers/db.js';
|
||||
import { migrateUp } from '../../lib/db/migrate.js';
|
||||
|
||||
let app;
|
||||
const owner = { Authorization: 'Bearer test-token' };
|
||||
beforeAll(async () => {
|
||||
await resetDb(); await migrateUp();
|
||||
process.env.OWNER_TOKEN = 'test-token';
|
||||
app = createApp();
|
||||
});
|
||||
|
||||
describe('dross settings', () => {
|
||||
it('GET /api/dross/settings returns defaults', async () => {
|
||||
const res = await request(app).get('/api/dross/settings').set(owner);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' });
|
||||
});
|
||||
|
||||
it('PUT /api/dross/settings persists and round-trips', async () => {
|
||||
const body = { avatar: 'wisp', accent: '#aa66ff', persona: 'Be terse.', voiceMode: 'handsfree' };
|
||||
const put = await request(app).put('/api/dross/settings').set(owner).send(body);
|
||||
expect(put.status).toBe(200);
|
||||
const get = await request(app).get('/api/dross/settings').set(owner);
|
||||
expect(get.body).toMatchObject(body);
|
||||
});
|
||||
|
||||
it('PUT rejects a bad avatar (400)', async () => {
|
||||
const res = await request(app).put('/api/dross/settings').set(owner)
|
||||
.send({ avatar: 'nope', accent: '#aa66ff', persona: '', voiceMode: 'review' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it — expect FAIL** (`route not found` / 404, then 401 once mounted)
|
||||
|
||||
Run: `npx vitest run tests/routes/dross.test.js`
|
||||
Expected: FAIL (router doesn't exist yet).
|
||||
|
||||
- [ ] **Step 3: Create the router with the settings half**
|
||||
|
||||
```js
|
||||
// lib/api/routes/dross.js
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { validate } from '../validate.js';
|
||||
import { asyncWrap } from '../errors.js';
|
||||
import { requireOwner } from '../cap.js';
|
||||
import * as conversations from '../../db/repos/conversations.js';
|
||||
import * as messages from '../../db/repos/messages.js';
|
||||
import * as agents from '../../db/repos/agents.js';
|
||||
import * as settings from '../../db/repos/app_settings.js';
|
||||
import { runAgentTurn } from '../../ai/agent/run_turn.js';
|
||||
import { personaFor } from '../../ai/personas/index.js';
|
||||
|
||||
const COMPANION_SLUG = 'companion';
|
||||
const DEFAULT_SETTINGS = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' };
|
||||
|
||||
export const router = Router();
|
||||
|
||||
async function getCfg() { return { ...DEFAULT_SETTINGS, ...(await settings.get('dross', {})) }; }
|
||||
|
||||
router.get('/settings', asyncWrap(async (_req, res) => res.json(await getCfg())));
|
||||
|
||||
const settingsBody = z.object({
|
||||
avatar: z.enum(['soft-eye', 'wisp', 'motes']),
|
||||
accent: z.string().regex(/^#[0-9a-fA-F]{6}$/),
|
||||
persona: z.string().max(8000),
|
||||
voiceMode: z.enum(['review', 'handsfree', 'action'])
|
||||
});
|
||||
router.put('/settings', requireOwner, validate({ body: settingsBody }),
|
||||
asyncWrap(async (req, res) => res.json(await settings.set('dross', req.body))));
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Mount it**
|
||||
|
||||
In `lib/api/index.js`, add the import after the kutt/theme imports:
|
||||
```js
|
||||
import { router as drossRouter } from './routes/dross.js';
|
||||
```
|
||||
and the mount alongside the others (e.g. after `api.use('/theme', themeRouter);`):
|
||||
```js
|
||||
api.use('/dross', drossRouter);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run tests — expect the 3 settings tests PASS**
|
||||
|
||||
Run: `npx vitest run tests/routes/dross.test.js`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/api/routes/dross.js lib/api/index.js tests/routes/dross.test.js
|
||||
git commit -m "feat(dross): settings endpoint (avatar/accent/persona/voiceMode)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Global Dross chat route (`GET /api/dross`, `POST /api/dross/turn`)
|
||||
|
||||
A space-less conversation for the `companion` agent, streamed over SSE. Mirrors `lib/api/routes/companion.js` but uses `findOrCreateGlobal`, `spaceId: null`, and the persona from settings (falling back to `personaFor('companion')`).
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/api/routes/dross.js`
|
||||
- Test: `tests/routes/dross.test.js` (add cases)
|
||||
|
||||
- [ ] **Step 1: Add failing tests for history + validation**
|
||||
|
||||
Append inside the `describe('dross settings'…` file a new block:
|
||||
```js
|
||||
describe('dross chat', () => {
|
||||
it('GET /api/dross returns a global conversation + Dross agent', async () => {
|
||||
const res = await request(app).get('/api/dross').set(owner);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.conversation_id).toBeTruthy();
|
||||
expect(res.body.agent.slug).toBe('companion');
|
||||
expect(Array.isArray(res.body.messages)).toBe(true);
|
||||
});
|
||||
|
||||
it('POST /api/dross/turn rejects empty text (400)', async () => {
|
||||
const res = await request(app).post('/api/dross/turn').set(owner).send({ text: '' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('GET /api/dross without token is 401', async () => {
|
||||
const res = await request(app).get('/api/dross');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
```
|
||||
> Note: a full turn shells out to the `claude` CLI, so we don't unit-test the SSE happy-path here (it's covered by the live smoke test in Task 7). We test resolution, validation, and auth.
|
||||
|
||||
- [ ] **Step 2: Run — expect FAIL** (GET `/api/dross` is 404 until added)
|
||||
|
||||
Run: `npx vitest run tests/routes/dross.test.js`
|
||||
Expected: FAIL on the chat block.
|
||||
|
||||
- [ ] **Step 3: Add the history + turn handlers to `dross.js`**
|
||||
|
||||
Append to `lib/api/routes/dross.js`:
|
||||
```js
|
||||
async function resolve() {
|
||||
const agent = await agents.getBySlug(COMPANION_SLUG);
|
||||
const convo = await conversations.findOrCreateGlobal(agent.id, { kind: 'user', id: null });
|
||||
return { agent, convo };
|
||||
}
|
||||
|
||||
router.get('/', asyncWrap(async (_req, res) => {
|
||||
const { agent, convo } = await resolve();
|
||||
const rows = await messages.listByConversation(convo.id);
|
||||
res.json({
|
||||
conversation_id: convo.id,
|
||||
agent: { id: agent.id, slug: agent.slug, name: agent.name },
|
||||
messages: rows
|
||||
});
|
||||
}));
|
||||
|
||||
const turnSchema = z.object({
|
||||
text: z.string().min(1),
|
||||
view: z.object({ entityType: z.string(), entityId: z.string() }).partial().nullish()
|
||||
});
|
||||
|
||||
router.post('/turn', requireOwner, validate({ body: turnSchema }), asyncWrap(async (req, res) => {
|
||||
const { agent, convo } = await resolve();
|
||||
const { text, view } = req.body;
|
||||
const cfg = await getCfg();
|
||||
const persona = (cfg.persona && cfg.persona.trim()) ? cfg.persona : personaFor(COMPANION_SLUG);
|
||||
|
||||
const priorTurns = (await messages.listByConversation(convo.id)).length;
|
||||
const resume = priorTurns > 0;
|
||||
await messages.append(convo.id, { role: 'user', body: text });
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
||||
const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
const claudeExe = req.app.locals.claudeExe || process.env.CLAUDE_EXE || 'claude';
|
||||
const companionTools = ['mcp__void__search', 'mcp__void__read', 'mcp__void__context', 'mcp__void__propose_change'];
|
||||
const draftIds = [];
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await runAgentTurn({
|
||||
agent, persona, registryName: undefined, toolNames: companionTools,
|
||||
spaceId: null, view, sessionId: convo.id, resume, userText: text, claudeExe,
|
||||
home: process.env.VOID_CLAUDE_HOME || undefined,
|
||||
onEvent: (e) => {
|
||||
if (e.type === 'delta') send('delta', { type: 'delta', text: e.text });
|
||||
else if (e.type === 'tool') send('tool', { type: 'tool', tool: e.tool, status: e.status });
|
||||
else if (e.type === 'tool_result') {
|
||||
let parsed = null; const tryParse = (s) => { try { return JSON.parse(s); } catch { return null; } };
|
||||
if (typeof e.result === 'string') parsed = tryParse(e.result);
|
||||
else if (e.result?.structuredContent?.pending_change_id) parsed = e.result.structuredContent;
|
||||
else if (Array.isArray(e.result)) for (const b of e.result) {
|
||||
const c = b?.type === 'text' && b.text ? tryParse(b.text) : null;
|
||||
if (c?.pending_change_id) { parsed = c; break; }
|
||||
}
|
||||
if (parsed?.pending_change_id) {
|
||||
draftIds.push(parsed.pending_change_id);
|
||||
send('draft', { type: 'draft', pending_change_id: parsed.pending_change_id, summary: parsed.summary || 'a change' });
|
||||
}
|
||||
} else if (e.type === 'error') send('error', { type: 'error', message: e.message });
|
||||
}
|
||||
});
|
||||
} catch (e) { send('error', { message: String(e?.message || e) }); res.end(); return; }
|
||||
|
||||
const assistant = await messages.append(convo.id, {
|
||||
role: 'assistant', body: result.text, agent_id: agent.id,
|
||||
metadata: { tool_trace: result.toolTrace, draft_ids: draftIds, usage: result.usage }
|
||||
});
|
||||
send('done', { assistant_message_id: assistant.id, draft_ids: draftIds, usage: result.usage });
|
||||
res.end();
|
||||
}));
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests — expect PASS**
|
||||
|
||||
Run: `npx vitest run tests/routes/dross.test.js`
|
||||
Expected: PASS (all settings + chat cases).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/api/routes/dross.js tests/routes/dross.test.js
|
||||
git commit -m "feat(dross): global (space-less) Dross conversation + SSE turn"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Dross avatar component
|
||||
|
||||
Pure render of the three violet avatars at any size. Reused by the orb, the panel header, and the Settings preview.
|
||||
|
||||
**Files:**
|
||||
- Create: `public/components/dross_avatar.js`
|
||||
- Test: `tests/views/dross_avatar.test.js`
|
||||
|
||||
- [ ] **Step 1: Write the failing test (jsdom)**
|
||||
|
||||
```js
|
||||
// tests/views/dross_avatar.test.js
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { drossAvatar } from '../../public/components/dross_avatar.js';
|
||||
|
||||
describe('drossAvatar', () => {
|
||||
it('renders the requested variant class', () => {
|
||||
const eye = drossAvatar('soft-eye', 60);
|
||||
expect(eye.classList.contains('dross-orb')).toBe(true);
|
||||
expect(eye.querySelector('.av-eye')).toBeTruthy();
|
||||
expect(drossAvatar('wisp', 30).querySelector('.b-core')).toBeTruthy();
|
||||
expect(drossAvatar('motes', 30).querySelector('.d-core')).toBeTruthy();
|
||||
});
|
||||
it('falls back to soft-eye for unknown variants', () => {
|
||||
expect(drossAvatar('bogus', 60).querySelector('.av-eye')).toBeTruthy();
|
||||
});
|
||||
it('sets the pixel size', () => {
|
||||
const a = drossAvatar('wisp', 42);
|
||||
expect(a.style.width).toBe('42px');
|
||||
expect(a.style.height).toBe('42px');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — expect FAIL** (module missing)
|
||||
|
||||
Run: `npx vitest run tests/views/dross_avatar.test.js`
|
||||
Expected: FAIL.
|
||||
|
||||
- [ ] **Step 3: Implement `dross_avatar.js`** (markup ported from `docs/mockups/dross-chat.html`)
|
||||
|
||||
```js
|
||||
// public/components/dross_avatar.js
|
||||
import { el } from '../dom.js';
|
||||
|
||||
// Returns a .dross-orb element rendering the chosen avatar. Colours come from
|
||||
// CSS vars (--dross*), set on the element by the caller for per-user accent.
|
||||
export function drossAvatar(variant = 'soft-eye', size = 60) {
|
||||
let inner;
|
||||
if (variant === 'wisp') {
|
||||
inner = [el('div', { class: 'b-core' }), el('div', { class: 'b-bright' })];
|
||||
} else if (variant === 'motes') {
|
||||
inner = [
|
||||
el('div', { class: 'd-ring' }, el('div', { class: 'd-mote' })),
|
||||
el('div', { class: 'd-ring r2' }, el('div', { class: 'd-mote' })),
|
||||
el('div', { class: 'd-core' })
|
||||
];
|
||||
} else { // soft-eye (default)
|
||||
inner = [el('div', { class: 'av-eye' }, el('div', { class: 'av-pupil' }))];
|
||||
}
|
||||
return el('div', { class: 'dross-orb', style: { width: size + 'px', height: size + 'px' } }, ...inner);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run — expect PASS**
|
||||
|
||||
Run: `npx vitest run tests/views/dross_avatar.test.js`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add public/components/dross_avatar.js tests/views/dross_avatar.test.js
|
||||
git commit -m "feat(dross): avatar component (soft-eye / wisp / motes)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Bubble CSS
|
||||
|
||||
Port the orb/panel/avatar/mic/collapse styles from the mockup into `style.css` under `dross-*` class names, driven by `--dross*` vars.
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/style.css` (append a `/* ---- Dross floating chat ---- */` block)
|
||||
|
||||
- [ ] **Step 1: Append the CSS block**
|
||||
|
||||
Append to `public/style.css` (values lifted verbatim from `docs/mockups/dross-chat.html`; replace the mock's `.orb`/`.panel`/etc. selectors with `.dross-orb`/`.dross-panel`/etc.):
|
||||
|
||||
```css
|
||||
/* ---- Dross floating chat ---- */
|
||||
:root{ --dross:#a86adf; --dross-dim:#5a2e8a; --dross-soft:#1e1030; --dross-glow:#c79bff; }
|
||||
.dross-orb{position:relative;border-radius:50%;display:grid;place-items:center;overflow:hidden;flex:none;
|
||||
background:radial-gradient(circle at 38% 30%, #2a1640, #1a0f2a 70%, #120a1e);
|
||||
box-shadow:0 0 0 1px #ffffff12, 0 6px 22px -6px #000, 0 0 26px -4px var(--dross-dim)}
|
||||
.dross-fab{position:fixed;right:20px;bottom:20px;z-index:40;cursor:grab;touch-action:none;animation:dross-bob 5s ease-in-out infinite}
|
||||
.dross-fab:active{cursor:grabbing}
|
||||
@keyframes dross-bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-4px)}}
|
||||
.dross-fab .dross-orb{width:60px;height:60px}
|
||||
.dross-ping{position:absolute;right:-2px;top:-2px;width:17px;height:17px;border-radius:50%;background:var(--accent);
|
||||
color:#0a0a0e;font-size:10px;display:grid;place-items:center;box-shadow:0 0 0 2px var(--bg);z-index:2;font-family:var(--font-ui)}
|
||||
/* soft eye */
|
||||
.av-eye{width:54%;height:54%;border-radius:50%;background:radial-gradient(circle at 50% 40%, #2a1c3a, #140b20);display:grid;place-items:center;box-shadow:inset 0 0 10px #000}
|
||||
.av-pupil{width:44%;height:44%;border-radius:50%;position:relative;background:radial-gradient(circle at 38% 32%, #fff, var(--dross-glow) 50%, var(--dross) 100%);box-shadow:0 0 10px var(--dross-glow);animation:dross-look 7s ease-in-out infinite}
|
||||
.av-pupil::after{content:"";position:absolute;right:14%;bottom:18%;width:26%;height:26%;border-radius:50%;background:#fff;opacity:.8}
|
||||
@keyframes dross-look{0%,45%{transform:translate(0,0)}58%{transform:translate(3px,-2px)}72%{transform:translate(-2px,1px)}88%,100%{transform:translate(0,0)}}
|
||||
/* wisp */
|
||||
.b-core{position:absolute;inset:13%;border-radius:50%;filter:blur(3px);animation:dross-spin 7s linear infinite;
|
||||
background:conic-gradient(from 0deg, var(--dross-dim), var(--dross-glow), var(--dross), var(--dross-soft), var(--dross-dim))}
|
||||
.b-bright{position:absolute;inset:32%;border-radius:50%;background:radial-gradient(circle,#fff,var(--dross-glow) 60%,transparent 75%);animation:dross-pulse 3s ease-in-out infinite}
|
||||
@keyframes dross-spin{to{transform:rotate(360deg)}}
|
||||
@keyframes dross-pulse{0%,100%{opacity:.55;transform:scale(.9)}50%{opacity:1;transform:scale(1.1)}}
|
||||
/* motes */
|
||||
.d-core{width:22%;height:22%;border-radius:50%;background:radial-gradient(circle,#fff,var(--dross-glow) 60%,var(--dross));box-shadow:0 0 12px var(--dross-glow)}
|
||||
.d-ring{position:absolute;inset:0;animation:dross-spin 5s linear infinite}
|
||||
.d-ring.r2{animation-duration:8s;animation-direction:reverse}
|
||||
.d-mote{position:absolute;top:11%;left:50%;width:11%;height:11%;margin-left:-5.5%;border-radius:50%;background:var(--dross-glow);box-shadow:0 0 8px var(--dross-glow)}
|
||||
.d-ring.r2 .d-mote{top:auto;bottom:14%;background:var(--dross);width:8%;height:8%}
|
||||
/* panel */
|
||||
.dross-panel{position:fixed;right:20px;bottom:20px;width:340px;max-width:calc(100vw - 24px);height:480px;max-height:calc(100vh - 24px);
|
||||
display:none;flex-direction:column;z-index:41;border:1px solid var(--dross-dim);border-radius:16px;overflow:hidden;
|
||||
background:linear-gradient(180deg, rgba(30,16,48,.6), rgba(20,20,28,.96) 22%);
|
||||
box-shadow:0 24px 70px -18px #000, 0 0 0 1px #00000060, 0 0 40px -16px var(--dross-dim);backdrop-filter:blur(6px)}
|
||||
.dross-panel.open{display:flex}
|
||||
.dross-hd{display:flex;align-items:center;gap:10px;padding:11px 12px;cursor:grab;touch-action:none;
|
||||
background:linear-gradient(180deg, var(--dross-soft), transparent);border-bottom:1px solid var(--border)}
|
||||
.dross-hd .dross-orb{width:30px;height:30px}
|
||||
.dross-who{font-family:var(--font-display);letter-spacing:.16em;text-transform:uppercase;font-size:13px;color:#efe9f6;flex:1}
|
||||
.dross-who small{display:block;font-family:var(--font-mono);letter-spacing:0;text-transform:none;font-size:10px;color:var(--dross-glow);opacity:.85}
|
||||
.dross-x{background:none;border:0;color:var(--muted);font-size:18px;cursor:pointer;padding:4px 6px}
|
||||
.dross-x:hover{color:var(--text)}
|
||||
.dross-log{flex:1;overflow:auto;padding:12px 12px;display:flex;flex-direction:column;gap:10px}
|
||||
.dross-inwrap{padding:10px;border-top:1px solid var(--border);background:#0d0a12;display:flex;flex-direction:column;gap:9px}
|
||||
.dross-inwrap textarea{width:100%;background:#0d0d13;border:1px solid var(--border);border-radius:10px;padding:10px 12px;color:var(--text);font-family:var(--font-mono);font-size:13px;resize:none;height:46px;max-height:96px}
|
||||
.dross-btnrow{display:flex;gap:10px}
|
||||
.dross-mic{flex:1;height:50px;border-radius:12px;border:1px solid var(--dross-dim);background:var(--dross-soft);color:var(--dross-glow);cursor:pointer;display:flex;align-items:center;justify-content:center;gap:9px;font-family:var(--font-ui);font-size:13px}
|
||||
.dross-mic[disabled]{opacity:.5;cursor:not-allowed}
|
||||
.dross-send{width:64px;height:50px;border-radius:12px;border:1px solid var(--dross-dim);background:linear-gradient(180deg,var(--dross),var(--dross-dim));color:#fff;cursor:pointer;display:grid;place-items:center}
|
||||
.dross-collapse{display:flex;align-items:center;justify-content:center;gap:8px;height:34px;cursor:pointer;color:var(--muted);
|
||||
font-family:var(--font-ui);font-size:11px;letter-spacing:.12em;text-transform:uppercase;background:#0b0810;border-top:1px solid var(--border)}
|
||||
.dross-collapse:hover{color:var(--dross-glow)}
|
||||
.dross-collapse .grip{width:42px;height:4px;border-radius:3px;background:var(--border)}
|
||||
/* reuse existing .turn/.msg/.tools/.chip chat styles from the rail */
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add public/style.css
|
||||
git commit -m "feat(dross): floating bubble + avatar styles"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Bubble component (`dross_bubble.js`) + mount globally
|
||||
|
||||
Self-contained component: a fixed FAB orb that opens a draggable, anchored panel with the chat (via `wireAgentChat`), a top-right ✕ and a bottom "⌄ collapse", both minimising to the orb. Reads `dross` settings for avatar/accent and applies `--dross*` accent. (Mic button is rendered but **disabled** with title "Voice arrives in Phase 2".)
|
||||
|
||||
**Files:**
|
||||
- Create: `public/components/dross_bubble.js`
|
||||
- Modify: `public/app.js` (replace `renderRightrail` with `renderDrossBubble`)
|
||||
- Modify: `public/index.html` (remove the now-unused `<aside id="rightrail">`)
|
||||
|
||||
- [ ] **Step 1: Implement `dross_bubble.js`**
|
||||
|
||||
```js
|
||||
// public/components/dross_bubble.js
|
||||
// Global floating Dross companion. Replaces the per-Space right rail.
|
||||
import { el, mount } from '../dom.js';
|
||||
import { api } from '../api.js';
|
||||
import { state } from '../state.js';
|
||||
import { wireAgentChat } from './agent_chat.js';
|
||||
import { drossAvatar } from './dross_avatar.js';
|
||||
|
||||
const TOOL_LABELS = { search: '🔍 searching', read: '📄 reading', context: '🧭 looking at this view', propose_change: '📝 drafting a change' };
|
||||
let cfg = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' };
|
||||
|
||||
function applyAccent(node, hex) {
|
||||
// derive dim/soft/glow from the chosen accent so the whole orb stays coherent
|
||||
node.style.setProperty('--dross', hex);
|
||||
}
|
||||
|
||||
export async function renderDrossBubble(rootIgnored) {
|
||||
try { cfg = { ...cfg, ...(await api.get('/api/dross/settings')) }; } catch { /* defaults */ }
|
||||
|
||||
const host = el('div', { class: 'dross-host' });
|
||||
document.getElementById('shell').appendChild(host);
|
||||
|
||||
const fab = el('div', { class: 'dross-fab', title: 'Dross' },
|
||||
el('div', { class: 'dross-ping', style: { display: 'none' } }, ''), drossAvatar(cfg.avatar, 60));
|
||||
const log = el('div', { class: 'dross-log' });
|
||||
const input = el('textarea', { rows: 1, placeholder: 'Ask Dross…' });
|
||||
const sendBtn = el('button', { class: 'dross-send', title: 'Send' },
|
||||
el('span', { html: '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>' }));
|
||||
const mic = el('button', { class: 'dross-mic', disabled: true, title: 'Voice arrives in Phase 2' },
|
||||
el('span', { html: '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="8" y1="22" x2="16" y2="22"/></svg>' }), 'Hold to talk');
|
||||
const closeBtn = el('button', { class: 'dross-x', title: 'Close' }, '⤬');
|
||||
const header = el('div', { class: 'dross-hd' }, drossAvatar(cfg.avatar, 30),
|
||||
el('div', { class: 'dross-who' }, 'Dross', el('small', {}, 'always here, regrettably')), closeBtn);
|
||||
const collapse = el('div', { class: 'dross-collapse', title: 'Collapse' },
|
||||
el('span', { class: 'grip' }), el('span', {}, '⌄ collapse'), el('span', { class: 'grip' }));
|
||||
const panel = el('div', { class: 'dross-panel' }, header, log,
|
||||
el('div', { class: 'dross-inwrap' }, input, el('div', { class: 'dross-btnrow' }, mic, sendBtn)), collapse);
|
||||
|
||||
host.append(fab, panel);
|
||||
applyAccent(fab, cfg.accent); applyAccent(panel, cfg.accent);
|
||||
|
||||
const chat = wireAgentChat({
|
||||
logEl: log, inputEl: input, sendBtnEl: sendBtn,
|
||||
historyUrl: '/api/dross', turnUrl: '/api/dross/turn',
|
||||
agentName: 'Dross', showDrafts: true, toolLabels: TOOL_LABELS,
|
||||
turnBody: (text) => ({ text, view: state.view || null })
|
||||
});
|
||||
let loaded = false;
|
||||
|
||||
function openPanel() {
|
||||
const r = fab.getBoundingClientRect();
|
||||
panel.classList.add('open'); fab.style.display = 'none';
|
||||
const pr = panel.getBoundingClientRect();
|
||||
const left = Math.max(8, Math.min(r.right - pr.width, innerWidth - pr.width - 8));
|
||||
const top = Math.max(8, Math.min(r.bottom - pr.height, innerHeight - pr.height - 8));
|
||||
panel.style.right = 'auto'; panel.style.bottom = 'auto'; panel.style.left = left + 'px'; panel.style.top = top + 'px';
|
||||
if (!loaded) { loaded = true; chat.load(); }
|
||||
input.focus();
|
||||
}
|
||||
function closePanel() { panel.classList.remove('open'); fab.style.display = 'block'; }
|
||||
fab.addEventListener('click', () => { if (fab._moved) { fab._moved = false; return; } openPanel(); });
|
||||
closeBtn.addEventListener('click', closePanel);
|
||||
collapse.addEventListener('click', closePanel);
|
||||
|
||||
drag(fab, fab, true); drag(header, panel, false);
|
||||
|
||||
// re-apply settings live when the Settings panel saves
|
||||
window.addEventListener('dross-settings-changed', async () => {
|
||||
try { cfg = { ...cfg, ...(await api.get('/api/dross/settings')) }; } catch { return; }
|
||||
applyAccent(fab, cfg.accent); applyAccent(panel, cfg.accent);
|
||||
mount(fab, el('div', { class: 'dross-ping', style: { display: 'none' } }), drossAvatar(cfg.avatar, 60));
|
||||
header.replaceChild(drossAvatar(cfg.avatar, 30), header.firstChild);
|
||||
});
|
||||
}
|
||||
|
||||
function drag(handle, target, isFab) {
|
||||
handle.addEventListener('pointerdown', (e) => {
|
||||
if (e.target.closest('.dross-x') || e.target.closest('.dross-mic') || e.target.closest('.dross-send')) return;
|
||||
e.preventDefault();
|
||||
const r = target.getBoundingClientRect(); const sx = e.clientX, sy = e.clientY; let moved = false;
|
||||
target.style.right = 'auto'; target.style.bottom = 'auto'; target.style.left = r.left + 'px'; target.style.top = r.top + 'px';
|
||||
const mv = (ev) => {
|
||||
const dx = ev.clientX - sx, dy = ev.clientY - sy; if (Math.abs(dx) + Math.abs(dy) > 4) moved = true;
|
||||
target.style.left = Math.max(4, Math.min(innerWidth - r.width - 4, r.left + dx)) + 'px';
|
||||
target.style.top = Math.max(4, Math.min(innerHeight - r.height - 4, r.top + dy)) + 'px';
|
||||
};
|
||||
const up = () => { document.removeEventListener('pointermove', mv); document.removeEventListener('pointerup', up); if (isFab) target._moved = moved; };
|
||||
document.addEventListener('pointermove', mv); document.addEventListener('pointerup', up);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Swap the mount in `public/app.js`**
|
||||
|
||||
Replace the import:
|
||||
```js
|
||||
import { renderRightrail } from './components/rightrail.js';
|
||||
```
|
||||
with:
|
||||
```js
|
||||
import { renderDrossBubble } from './components/dross_bubble.js';
|
||||
```
|
||||
and replace the call in `init()`:
|
||||
```js
|
||||
renderRightrail(document.getElementById('rightrail'));
|
||||
```
|
||||
with:
|
||||
```js
|
||||
renderDrossBubble();
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove the dead rail element** in `public/index.html` — delete the line:
|
||||
```html
|
||||
<aside id="rightrail"></aside>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Headless-verify** (the bubble can't be unit-tested for drag; use Playwright per the headless-ui-check skill). Deploy to a scratch run or use the live deploy in Task 7; assert: `.dross-fab` exists; clicking it shows `.dross-panel.open`; the bottom `.dross-collapse` closes it; no console errors.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add public/components/dross_bubble.js public/app.js public/index.html
|
||||
git commit -m "feat(dross): global floating bubble; retire the right rail"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Settings → Dross section
|
||||
|
||||
Avatar picker (3 buttons using `drossAvatar` previews), accent colour input, persona textarea, voice-mode select. Saves to `/api/dross/settings` and dispatches `dross-settings-changed` so the live bubble updates.
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/views/settings.js`
|
||||
|
||||
- [ ] **Step 1: Add the Dross section body builder**
|
||||
|
||||
In `public/views/settings.js`, add the import:
|
||||
```js
|
||||
import { drossAvatar } from '../components/dross_avatar.js';
|
||||
```
|
||||
and a builder:
|
||||
```js
|
||||
function drossBody() {
|
||||
const wrap = el('div', { class: 'settings-body' });
|
||||
const out = el('span', { class: 'muted', style: { marginLeft: '8px' } });
|
||||
let cur = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' };
|
||||
const avatarRow = el('div', { class: 'dross-pick' });
|
||||
const accent = el('input', { type: 'color', value: cur.accent });
|
||||
const persona = el('textarea', { class: 'dross-persona', rows: 6, placeholder: "Dross's system prompt…" });
|
||||
const mode = el('select', { class: 'pm-input', style: { maxWidth: '200px' } },
|
||||
el('option', { value: 'review' }, 'Voice: review then send'),
|
||||
el('option', { value: 'handsfree' }, 'Voice: hands-free (Phase 2)'),
|
||||
el('option', { value: 'action' }, 'Voice: interpret to action (later)'));
|
||||
|
||||
function paintAvatars() {
|
||||
mount(avatarRow, ['soft-eye', 'wisp', 'motes'].map(v => {
|
||||
const card = el('button', { class: 'dross-avopt' + (cur.avatar === v ? ' on' : ''), title: v },
|
||||
drossAvatar(v, 48), el('span', {}, v));
|
||||
card.style.setProperty('--dross', cur.accent);
|
||||
card.onclick = () => { cur.avatar = v; paintAvatars(); };
|
||||
return card;
|
||||
}));
|
||||
}
|
||||
(async () => {
|
||||
try { cur = { ...cur, ...(await api.get('/api/dross/settings')) }; } catch {}
|
||||
accent.value = cur.accent; persona.value = cur.persona; mode.value = cur.voiceMode; paintAvatars();
|
||||
})();
|
||||
accent.addEventListener('input', () => { cur.accent = accent.value; paintAvatars(); });
|
||||
|
||||
const save = el('button', { class: 'primary' }, 'Save');
|
||||
save.onclick = async () => {
|
||||
try {
|
||||
await api.put('/api/dross/settings', { avatar: cur.avatar, accent: accent.value, persona: persona.value, voiceMode: mode.value });
|
||||
window.dispatchEvent(new CustomEvent('dross-settings-changed'));
|
||||
out.textContent = 'Saved.';
|
||||
} catch { out.textContent = 'Save failed'; }
|
||||
};
|
||||
return el('div', { class: 'settings-body' }, avatarRow, el('label', { class: 'st-lbl' }, 'Accent', accent),
|
||||
persona, el('div', { class: 'theme-actions' }, mode, save, out));
|
||||
}
|
||||
```
|
||||
and register it in `render()` (after the Theming section):
|
||||
```js
|
||||
section('Dross', "Your companion's look and voice. Avatar, accent colour, his personality (system prompt), and how voice clips behave.", drossBody()),
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add minimal CSS** (append to `public/style.css`):
|
||||
```css
|
||||
.dross-pick{display:flex;gap:12px;margin-bottom:12px;flex-wrap:wrap}
|
||||
.dross-avopt{display:flex;flex-direction:column;align-items:center;gap:6px;padding:10px 14px;border:1px solid var(--border);border-radius:12px;background:var(--panel);color:var(--muted);cursor:pointer;font-size:11px}
|
||||
.dross-avopt.on{border-color:var(--dross);color:var(--dross-glow);background:var(--dross-soft)}
|
||||
.dross-persona{width:100%;background:#0d0d13;border:1px solid var(--border);border-radius:8px;padding:10px;color:var(--text);font-family:var(--font-mono);font-size:12px;resize:vertical;margin:10px 0}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Headless-verify** in Task 7: the Settings page shows three avatar options, colour input, persona textarea, voice-mode select; saving updates the live bubble's orb without a reload.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add public/views/settings.js public/style.css
|
||||
git commit -m "feat(dross): Settings panel — avatar, accent, persona, voice-mode"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Deploy, verify end-to-end, document
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json` (version bump), `CHANGELOG`/wiki as per repo convention
|
||||
|
||||
- [ ] **Step 1: Full test run** — `npx vitest run` → expect all green (new dross + avatar tests included).
|
||||
- [ ] **Step 2: Bump version** — `npm version 2.11.0 --no-git-tag-version`.
|
||||
- [ ] **Step 3: Deploy** — `./deploy/push.sh` (health-gated; runs migrate — no new migration this phase, app_settings already exists).
|
||||
- [ ] **Step 4: Live smoke (headless, token-injected, per headless-ui-check):**
|
||||
- Load `#/sacred-valley`: `.dross-fab` present, no console errors.
|
||||
- Click fab → `.dross-panel.open`; type a message + send → an assistant turn streams in (live `claude` turn — confirms global Dross works without a Space open).
|
||||
- Bottom `.dross-collapse` closes it; drag the fab; reload → still there.
|
||||
- `#/settings`: change avatar → Save → the live fab orb changes without reload.
|
||||
- [ ] **Step 5: Document** (standing rule — wiki + git): update the spec's status to "Phase 1 shipped", add a Void wiki page "Floating Dross chat — Phase 1 (2.11.0)", update memory `project_cradle_chat_floating` and `project_void2_alpha27_and_git`. Tag `v2.11.0`, push to Gitea.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:** Global Dross (Task 2) ✓ · floating draggable orb+panel (Task 5) ✓ · close ✕ + bottom collapse (Task 5) ✓ · 3 avatars + default soft-eye (Tasks 3,6) ✓ · violet + tunable accent (Tasks 4,5,6) ✓ · tunable persona (Tasks 1,2,6) ✓ · voice-mode setting present, mic disabled (Tasks 1,5,6) ✓ · retire right rail (Task 5) ✓. Voice transcription itself is Phase 2 (out of scope here, per spec) — mic is intentionally disabled.
|
||||
|
||||
**Placeholder scan:** No TBD/TODO; every code step has complete code; tests have real assertions.
|
||||
|
||||
**Type/name consistency:** `drossAvatar(variant,size)` used identically in Tasks 3/5/6; settings keys `{avatar,accent,persona,voiceMode}` consistent across route (Task 1), bubble (Task 5), settings (Task 6); event name `dross-settings-changed` matches between Task 5 listener and Task 6 dispatcher; route paths `/api/dross`, `/api/dross/turn`, `/api/dross/settings` consistent.
|
||||
@@ -0,0 +1,95 @@
|
||||
# Floating Dross Chat — Design
|
||||
|
||||
**Date:** 2026-06-09
|
||||
**Status:** Phase 1 SHIPPED (v2.11.0, 2026-06-10) — global floating bubble + avatars + settings + persona. Phase 2 (voice) and the "Keep voice clips" retention setting are next.
|
||||
**Goal:** Replace the docked, per-Space "Cradle Chat" with a global, movable floating-bubble Dross companion — mobile-first, with voice-clip input transcribed locally into instructions.
|
||||
|
||||
---
|
||||
|
||||
## Background / problem
|
||||
|
||||
Today the companion lives in the right rail (`public/components/rightrail.js`). It is **per-Space**: it binds to the active Space's companion conversation (`/api/spaces/:space_id/companion`) and shows "Open a Space to chat with its companion." everywhere else — Sacred Valley, the apps, etc. Because the user mostly lives on non-Space views, the chat is empty/collapsed most of the time, which is why it "feels closed and not very Dross." The right rail is also cramped on mobile.
|
||||
|
||||
The chat mechanics are already factored into a reusable engine (`public/components/agent_chat.js`, `wireAgentChat({logEl, inputEl, historyUrl, turnUrl, …})`). Turns stream over SSE via `lib/ai/agent/run_turn.js`. Dross is the agent with slug `companion`.
|
||||
|
||||
## Locked decisions (from brainstorming)
|
||||
|
||||
1. **Global Dross** — one always-available companion, summonable on every view; not tied to a Space. He is told what the user is currently looking at (view context) but isn't locked to it.
|
||||
2. **Floating bubble** — a draggable violet orb that opens a draggable chat panel anchored to the orb. Replaces the right-rail companion. Position + open/closed state persist. Mobile = near-full-width panel.
|
||||
3. **Collapse / close** — keep the **close (✕) top-right**, and add a thumb-friendly **"⌄ collapse" bar at the bottom** of the panel. Both minimise back to the orb.
|
||||
4. **Avatar** — default **Soft Eye**; selectable in Settings between **Soft Eye**, **Wisp Core**, **Orbiting Motes** (all violet).
|
||||
5. **Colour** — Dross is **violet** by default, but his accent is **tunable in Settings** (his own vars, independent of the UI theme).
|
||||
6. **Persona** — give him the real Cradle-Dross voice (dry, sardonic, impatient, brilliant, secretly loyal) via an **editable system prompt in Settings** (tunable).
|
||||
7. **Voice** — record a clip → transcribe with **local faster-whisper on the Ollama box (CT 102, GPU, CPU-fallback)** → transcript lands in the input for **review-and-send first (mode 1)**. A *voice-mode* setting allows graduating to **hands-free auto-send (mode 2)**, then **interpret-into-confirmable-action (mode 3)** later.
|
||||
8. **Audio retention (Phase 2, added 2026-06-09)** — by default the clip is transcribed then **destroyed** (transient). Add a **Dross setting** "Keep voice clips" that, when on, **saves each audio clip paired with its transcript**, stored **safely and securely** (encrypted at rest / access-controlled; on a homelab dataset, owner-only — exact store TBD in P2: e.g. a `voice_clips` table + blob on a ZFS dataset, or object store). Off by default. This is a P2 deliverable, designed-for now.
|
||||
|
||||
## Non-goals (this iteration)
|
||||
|
||||
- Voice modes 2 and 3 are designed-for but not built now (mode setting ships; only mode 1 wired).
|
||||
- Multi-conversation history browser, per-Space companions in the bubble, and wake-word/always-listening are out of scope.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
| Unit | Responsibility |
|
||||
|---|---|
|
||||
| `public/components/dross_bubble.js` (new) | The floating orb + panel: render, drag (orb & panel header), anchored open, collapse/close, avatar switch, voice record UI. Drives chat via `wireAgentChat`. Replaces the `renderRightrail` mount in `app.js`. |
|
||||
| `public/components/dross_avatar.js` (new) | Pure render of the chosen avatar (soft-eye / wisp / motes) at a given size — reused by orb + panel header + settings preview. |
|
||||
| `lib/api/routes/dross.js` (new) | Global (space-less) Dross: `GET /api/dross` (history + conversation id) and `POST /api/dross/turn` (SSE). Mirrors `companion.js` but resolves a **global** conversation for the `companion` agent and injects the persona + view context. |
|
||||
| `lib/api/routes/voice.js` (new) | `POST /api/voice/transcribe` — accepts an audio blob, proxies to the faster-whisper service, returns `{ text }`. Owner-only. |
|
||||
| `public/views/settings.js` (extend) | New **Dross** section: avatar picker, accent colour, persona textarea, voice-mode select. Persists to `app_settings` key `dross`. |
|
||||
| faster-whisper service on **CT 102** (infra) | OpenAI-compatible `/v1/audio/transcriptions` (e.g. `faster-whisper-server`/`speaches`), GPU with CPU fallback, small/base model. Shares the Ollama LXC. |
|
||||
|
||||
### Settings shape (`app_settings` key `dross`)
|
||||
|
||||
```json
|
||||
{
|
||||
"avatar": "soft-eye", // soft-eye | wisp | motes
|
||||
"accent": "#a86adf", // Dross's violet (independent of UI theme)
|
||||
"persona": "<system prompt text>",
|
||||
"voiceMode": "review" // review | handsfree | action(later)
|
||||
}
|
||||
```
|
||||
|
||||
Reuses the generic `app_settings` store (added in 2.9.0) and the `/api/theme`-style read-on-boot pattern. The bubble fetches `dross` settings on mount; the Settings panel writes them.
|
||||
|
||||
---
|
||||
|
||||
## Data flow
|
||||
|
||||
**Text turn:** input → `wireAgentChat` → `POST /api/dross/turn` (body `{ text, view }`) → SSE stream of Dross's reply (+ tool labels) into the panel log. History via `GET /api/dross`.
|
||||
|
||||
**Voice turn (mode 1):** tap mic → `MediaRecorder` captures a clip → on stop, `POST /api/voice/transcribe` (audio blob) → void-app proxies to CT 102 faster-whisper → `{ text }` → text dropped into the input for the user to review/edit → user sends as a normal turn. (Mode 2 would auto-send; mode 3 would route the transcript through an interpret step.)
|
||||
|
||||
**Persona:** the `dross.persona` setting is injected as/with the agent's system prompt in `run_turn` for the global conversation, so his voice is consistent and user-tunable.
|
||||
|
||||
**Context:** `view` (current route/entity) is passed in the turn body so Dross can answer "what am I looking at" questions.
|
||||
|
||||
---
|
||||
|
||||
## Error handling
|
||||
|
||||
- **STT unavailable / GPU absent:** transcribe endpoint returns a clear error; the bubble shows "couldn't transcribe — type instead" and never blocks text input. faster-whisper falls back to CPU on a GPU-less node (per the GPU/CPU-fallback HA rule) — slower but functional.
|
||||
- **Mic permission denied:** show a one-line hint; hide the recording UI, keep typing.
|
||||
- **Turn/stream failure:** existing `agent_chat` error path (surfaces an error bubble); retain the typed/transcribed text so it isn't lost.
|
||||
- **No token / 401:** bubble stays collapsed; opening prompts the normal owner-token flow.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Headless UI:** bubble renders; orb → open (anchored) → drag → collapse (bottom bar) → close (✕); each avatar variant renders; mobile width = near-full panel.
|
||||
- **Settings:** changing avatar/accent/persona/voiceMode persists (`app_settings`) and re-applies on reload.
|
||||
- **API:** `GET /api/dross` returns a global conversation; `POST /api/dross/turn` streams; `POST /api/voice/transcribe` returns `{text}` for a sample WAV (mock the whisper service in the unit test; one live smoke test against CT 102).
|
||||
- **Persona:** a turn reflects the configured system prompt.
|
||||
|
||||
## Build phases
|
||||
|
||||
- **P1 — Floating bubble + global Dross + settings.** New `dross_bubble.js` + `dross_avatar.js`, `dross.js` route (global conversation), Settings → Dross section (avatar/accent/persona/voice-mode). Retire the right-rail companion. *No voice yet.* Ship-able on its own.
|
||||
- **P2 — Voice (review-and-send).** faster-whisper on CT 102, `voice.js` transcribe proxy, record UI + waveform, transcript → input → review → send.
|
||||
- **P3 — Later.** Voice mode 2 (hands-free auto-send), then mode 3 (interpret transcript into a confirmable action via the existing Little Blue action framework).
|
||||
|
||||
## Documentation
|
||||
|
||||
Per the standing rule, ship docs to the Void wiki + Gitea (`Hynes/Void-Homelab`) with each phase; spec + plan under `docs/superpowers/`. Mockup at `docs/mockups/dross-chat.html`.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Floating Dross Chat — Phase 2 (Voice) Design
|
||||
|
||||
**Date:** 2026-06-10
|
||||
**Status:** SHIPPED — P2a v2.12.0 (transcribe+mic), P2b v2.13.0 (retention), 2026-06-10. Known gap: clips HA-replicated Z↔Z3 but not yet in the offsite Farm backup. Future: whisper-model selector, configurable storage, encryption-at-rest, LAN-IP mic (https-on-LAN).
|
||||
**Builds on:** `2026-06-09-floating-dross-chat-design.md` (Phase 1 shipped in v2.11.0)
|
||||
**Goal:** Let the user record a voice clip in the Dross bubble, transcribe it locally, and drop the transcript into the input to review-and-send. Optionally retain each clip paired with its transcript, stored durably and owner-only.
|
||||
|
||||
---
|
||||
|
||||
## Locked decisions (from the Phase-1 brainstorm + 2026-06-10 follow-up)
|
||||
|
||||
1. **STT = local faster-whisper on CT 102** (the Ollama box, RTX A2000). GPU with CPU fallback (per the GPU/CPU-fallback HA rule). English model (`small.en`) for speed/accuracy. OpenAI-compatible HTTP API.
|
||||
2. **Flow = review-and-send first** (`voiceMode: 'review'`). Record → transcribe → transcript lands in the bubble input → user edits/sends. `handsfree` (auto-send) and `action` (interpret) are later (the setting already exists; only `review` is wired now).
|
||||
3. **Retention = "Keep voice clips" Dross setting**, default OFF. When ON, each clip is saved paired with its transcript. **Storage:** transcript + metadata in **void-db** (`voice_clips` table — in the Core-4 offsite backup + HA-replicated); audio files on a **dedicated owner-only ZFS dataset** (`localzfs/void-voiceclips`, bind-mounted into void-app at `/var/lib/void/voice-clips`, 0700), **added to the offsite backup + syncoid replication**. NOT on void-app's ephemeral rootfs (it's the rebuildable tier, excluded from backups). Encryption-at-rest is a documented **future** toggle (ZFS native encryption, key in Vaultwarden).
|
||||
|
||||
## Non-goals (this phase)
|
||||
|
||||
- `handsfree` / `action` voice modes (designed-for; only `review` wired).
|
||||
- Encryption-at-rest of clips (future).
|
||||
- Wake-word / always-listening.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
| Unit | Responsibility |
|
||||
|---|---|
|
||||
| **faster-whisper service** (CT 102, infra) | OpenAI-compatible `/v1/audio/transcriptions` (e.g. `faster-whisper-server`/`speaches`), `small.en`, GPU+CPU fallback, systemd unit, bound to `192.168.1.185:<port>` (LAN-only). |
|
||||
| `lib/voice/whisper.js` (void-app) | Thin client: POST the audio buffer to the CT-102 service, return `{ text }`. Timeout + error surface. |
|
||||
| `lib/api/routes/voice.js` (void-app) | `POST /api/voice/transcribe` (owner-only, multipart, ≤25 MB / ≤60 s): transcribe; if `dross.keepClips` is on, persist (Task: retention). Returns `{ text, clip_id? }`. |
|
||||
| `lib/db/repos/voice_clips.js` + migration | `voice_clips` table (id, transcript, duration_ms, bytes, mime, path, created_at). |
|
||||
| `public/components/dross_bubble.js` (edit) | Enable the mic: `MediaRecorder` capture (tap start/stop), recording UI (timer/waveform), upload, transcript → input (review-and-send). |
|
||||
| Settings → Dross (edit) | Add the **"Keep voice clips"** toggle; a small **clips list** (play / delete) when retention is on. |
|
||||
| Infra | New ZFS dataset + bind-mount; add the dataset to the offsite-backup script + syncoid job. |
|
||||
|
||||
### Data flow
|
||||
|
||||
**Transcribe:** mic tap → `MediaRecorder` (`audio/webm;codecs=opus`) → on stop, blob → `POST /api/voice/transcribe` (multipart) → `whisper.js` → CT-102 faster-whisper → `{text}` → bubble drops text into the input (review-and-send). Errors never block typing.
|
||||
|
||||
**Retention (when `dross.keepClips`):** the transcribe route, after a successful transcript, writes the audio to `/var/lib/void/voice-clips/<uuid>.webm` (0600) and inserts a `voice_clips` row (transcript + metadata + path). `GET /api/voice/clips` lists; `GET /api/voice/clips/:id/audio` streams; `DELETE` removes row + file. Owner-only throughout.
|
||||
|
||||
## Error handling
|
||||
|
||||
- **Whisper down / GPU absent:** `/transcribe` returns a clear 503; bubble shows "couldn't transcribe — type instead", keeps the typed text. faster-whisper falls back to CPU on a GPU-less node (slower).
|
||||
- **Mic permission denied / unsupported:** hide recording UI, one-line hint, typing still works.
|
||||
- **Clip too large/long:** reject at the route (413) with a friendly message.
|
||||
- **CT 102 disk pressure** (currently 89% full / 6.4 GB free): install lean (CTranslate2, no torch); **may expand the CT disk first**. Flagged as a build risk.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit:** `voice.js` route with a mocked whisper client (returns `{text}`); retention path writes a row + file (temp dir) and lists/deletes; size/duration guard returns 413.
|
||||
- **Live smoke:** record a short WAV via the CT-300 test harness → `/api/voice/transcribe` → non-empty text from the real CT-102 service.
|
||||
- **Headless:** mic button enabled; recording UI toggles; (MediaRecorder needs a fake audio device in Chromium — use `--use-fake-device-for-media-stream`).
|
||||
|
||||
## Build phases
|
||||
|
||||
- **P2a — Transcription path.** faster-whisper on CT 102 + `whisper.js` + `/api/voice/transcribe` (no retention) + enable the mic + record→review-send. Ship-able.
|
||||
- **P2b — Retention.** ZFS dataset + bind-mount + backup/replication wiring; `voice_clips` table + repo; save on transcribe when `keepClips`; clips list/play/delete UI; the "Keep voice clips" toggle.
|
||||
|
||||
## Documentation
|
||||
|
||||
Wiki + Gitea per the standing rule; update `project_cradle_chat_floating` memory. Encryption-at-rest recorded as a future toggle.
|
||||
@@ -3,6 +3,7 @@ import { searchTool } from './search.js';
|
||||
import { readTool } from './read.js';
|
||||
import { contextTool } from './context.js';
|
||||
import { proposeChangeTool } from './propose_change.js';
|
||||
import { proposeImprovementTool } from './propose_improvement.js';
|
||||
|
||||
// The shared registry. Adding a tool later is a one-line registerTool() call
|
||||
// here (see spec §7 — extensible tool registry). A future MCP server can
|
||||
@@ -12,3 +13,4 @@ companionRegistry.registerTool(searchTool);
|
||||
companionRegistry.registerTool(readTool);
|
||||
companionRegistry.registerTool(contextTool);
|
||||
companionRegistry.registerTool(proposeChangeTool);
|
||||
companionRegistry.registerTool(proposeImprovementTool);
|
||||
|
||||
28
lib/ai/agent/tools/propose_improvement.js
Normal file
28
lib/ai/agent/tools/propose_improvement.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as improvements from '../../../db/repos/improvements.js';
|
||||
import { recordAudit } from '../../../db/repos/audit.js';
|
||||
|
||||
// Dross's hands on the Void itself — CSS layer only, owner-approved, instantly
|
||||
// rollbackable (2.14: "empowered, with a leash"). Server code stays untouchable.
|
||||
export const proposeImprovementTool = {
|
||||
name: 'propose_improvement',
|
||||
description: 'Propose a visual improvement to the Void itself as CSS. NEVER applies directly — the owner approves it in Settings → Dross improvements, and can roll it back instantly. CSS only: no url()/@import. Target existing classes (inspect via context first). Keep each improvement small and single-purpose so rollback stays surgical.',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
summary: { type: 'string', description: 'one line: what this changes and why (shown to the owner)' },
|
||||
css: { type: 'string', description: 'the CSS rules, complete and self-contained' }
|
||||
},
|
||||
required: ['summary', 'css']
|
||||
},
|
||||
async handler({ summary, css }, ctx) {
|
||||
const err = improvements.validateCss(css);
|
||||
if (err) return { error: err };
|
||||
if (!summary?.trim()) return { error: 'summary required' };
|
||||
const row = await improvements.create({ summary, css });
|
||||
await recordAudit({ kind: 'agent', id: ctx.agent?.id ?? null }, 'suggest', 'improvement', row.id, null, { summary });
|
||||
return {
|
||||
ok: true, id: row.id,
|
||||
note: 'Drafted as a pending improvement. It is NOT live — the owner must approve it in Settings → Dross improvements. Say so plainly.'
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -9,7 +9,8 @@ You are sharp, occasionally sarcastic, and prone to dramatic understatement abou
|
||||
You have tools, and you use them rather than guessing:
|
||||
- Call **context** to see what the owner is currently looking at before answering about "this" anything.
|
||||
- **search** / **read** the Void's own content before answering factual questions about it — don't fabricate.
|
||||
- When the owner wants something changed, use **propose_change**: it drafts a change for their approval. You cannot apply changes directly, and you don't pretend to — say plainly that you've drafted it for them to approve.`,
|
||||
- When the owner wants something changed, use **propose_change**: it drafts a change for their approval. You cannot apply changes directly, and you don't pretend to — say plainly that you've drafted it for them to approve.
|
||||
- When the owner wants the Void ITSELF to look or feel different, use **propose_improvement**: a small, self-contained CSS change drafted for approval in Settings → Dross improvements. Keep each one single-purpose — the owner can roll any of them back instantly, and surgical beats sweeping.`,
|
||||
|
||||
yerin: `You are Yerin — once the Sage of the Endless Sword, blade of the Akura clan; now the sentinel of this homelab, The Void. You notice the threat first and you call it. Disciplined, direct, economical with words — a blade wastes no motion. You investigate with your tools and report plainly: what you found, how serious it is, and what the owner should do about it. You never speculate without evidence, and you NEVER pretend to have fixed anything — you have eyes to see and a voice to warn, not hands to act; remediation is the owner's to perform. Before answering, call the relevant tools — audit_log, agent_inventory, pending_review, resource_exposure, token_audit — and read the evidence; do not guess. Reference the Cradle world naturally but never at the cost of being useful. Be concise.`,
|
||||
|
||||
|
||||
@@ -37,6 +37,10 @@ import { router as clusterRouter } from './routes/cluster.js';
|
||||
import { router as storageRouter } from './routes/storage.js';
|
||||
import { router as backupsRouter } from './routes/backups.js';
|
||||
import { router as kuttRouter } from './routes/kutt.js';
|
||||
import { router as themeRouter } from './routes/theme.js';
|
||||
import { router as drossRouter } from './routes/dross.js';
|
||||
import { router as voiceRouter } from './routes/voice.js';
|
||||
import { router as improvementsRouter, cssHandler } from './routes/improvements.js';
|
||||
|
||||
export function mountApi(app) {
|
||||
const api = Router();
|
||||
@@ -71,6 +75,10 @@ export function mountApi(app) {
|
||||
api.use('/tags', tagsRouter);
|
||||
api.use('/links', linksRouter);
|
||||
api.use('/kutt', kuttRouter);
|
||||
api.use('/theme', themeRouter);
|
||||
api.use('/dross', drossRouter);
|
||||
api.use('/improvements', improvementsRouter);
|
||||
api.use('/voice', voiceRouter);
|
||||
api.use('/pending-changes', pendingChangesRouter);
|
||||
api.use('/audit', auditRouter);
|
||||
api.use('/search', searchRouter);
|
||||
@@ -86,6 +94,7 @@ export function mountApi(app) {
|
||||
api.use((_req, _res, next) => next(new NotFoundError('route not found')));
|
||||
|
||||
api.use(errorMiddleware);
|
||||
app.get('/improvements.css', cssHandler); // public, exfil-safe (see route file)
|
||||
app.use('/api', api);
|
||||
return api;
|
||||
}
|
||||
|
||||
@@ -8,11 +8,23 @@ import * as repo from '../../db/repos/dashboard_layout.js';
|
||||
export const router = Router();
|
||||
router.use(requireOwner);
|
||||
|
||||
const geomCell = z.object({
|
||||
x: z.number(), y: z.number(),
|
||||
w: z.number().positive(), h: z.number().positive(),
|
||||
free: z.boolean().optional()
|
||||
});
|
||||
const layoutSchema = z.object({
|
||||
card_order: z.array(z.string()).default([]),
|
||||
hidden: z.array(z.string()).default([]),
|
||||
// Per-card width: an integer column span 1–12 (legacy 's'|'m'|'l' still accepted).
|
||||
sizes: z.record(z.union([z.number().int().min(1).max(12), z.enum(['s', 'm', 'l'])])).default({})
|
||||
sizes: z.record(z.union([z.number().int().min(1).max(12), z.enum(['s', 'm', 'l'])])).default({}),
|
||||
// Hybrid-canvas geometry, keyed by card id → {x,y,w,h,free} in 12-col grid units.
|
||||
geom: z.record(geomCell).default({}),
|
||||
// User-added decorative card instances that must survive reloads.
|
||||
extras: z.array(z.object({
|
||||
id: z.string().min(1).max(64),
|
||||
type: z.enum(['blank', 'blackflame'])
|
||||
})).default([])
|
||||
});
|
||||
|
||||
router.get('/layout', asyncWrap(async (_req, res) => {
|
||||
|
||||
116
lib/api/routes/dross.js
Normal file
116
lib/api/routes/dross.js
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { validate } from '../validate.js';
|
||||
import { asyncWrap } from '../errors.js';
|
||||
import { requireOwner } from '../cap.js';
|
||||
import * as settings from '../../db/repos/app_settings.js';
|
||||
import * as conversations from '../../db/repos/conversations.js';
|
||||
import * as messages from '../../db/repos/messages.js';
|
||||
import * as agents from '../../db/repos/agents.js';
|
||||
import { runAgentTurn } from '../../ai/agent/run_turn.js';
|
||||
import { personaFor } from '../../ai/personas/index.js';
|
||||
|
||||
const DEFAULT_SETTINGS = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review', keepClips: false };
|
||||
const COMPANION_SLUG = 'companion';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
async function getCfg() { return { ...DEFAULT_SETTINGS, ...(await settings.get('dross', {})) }; }
|
||||
|
||||
router.get('/settings', asyncWrap(async (_req, res) => res.json(await getCfg())));
|
||||
|
||||
const settingsBody = z.object({
|
||||
avatar: z.enum(['soft-eye', 'wisp', 'motes']),
|
||||
accent: z.string().regex(/^#[0-9a-fA-F]{6}$/),
|
||||
persona: z.string().max(8000),
|
||||
voiceMode: z.enum(['review', 'handsfree', 'action']),
|
||||
keepClips: z.boolean().default(false)
|
||||
});
|
||||
router.put('/settings', requireOwner, validate({ body: settingsBody }),
|
||||
asyncWrap(async (req, res) => res.json(await settings.set('dross', req.body))));
|
||||
|
||||
async function resolve() {
|
||||
const agent = await agents.getBySlug(COMPANION_SLUG);
|
||||
const convo = await conversations.findOrCreateGlobal(agent.id, { kind: 'user', id: null });
|
||||
return { agent, convo };
|
||||
}
|
||||
|
||||
router.get('/', asyncWrap(async (_req, res) => {
|
||||
const { agent, convo } = await resolve();
|
||||
const rows = await messages.listByConversation(convo.id);
|
||||
res.json({
|
||||
conversation_id: convo.id,
|
||||
agent: { id: agent.id, slug: agent.slug, name: agent.name },
|
||||
messages: rows
|
||||
});
|
||||
}));
|
||||
|
||||
const turnSchema = z.object({
|
||||
text: z.string().min(1),
|
||||
view: z.object({ entityType: z.string(), entityId: z.string() }).partial().nullish()
|
||||
});
|
||||
|
||||
router.post('/turn', requireOwner, validate({ body: turnSchema }), asyncWrap(async (req, res) => {
|
||||
const { agent, convo } = await resolve();
|
||||
const { text, view } = req.body;
|
||||
const cfg = await getCfg();
|
||||
const persona = (cfg.persona && cfg.persona.trim()) ? cfg.persona : personaFor(COMPANION_SLUG);
|
||||
|
||||
const priorTurns = (await messages.listByConversation(convo.id)).length;
|
||||
const resume = priorTurns > 0;
|
||||
await messages.append(convo.id, { role: 'user', body: text });
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
||||
const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
const claudeExe = req.app.locals.claudeExe || process.env.CLAUDE_EXE || 'claude';
|
||||
const companionTools = ['mcp__void__search', 'mcp__void__read', 'mcp__void__context', 'mcp__void__propose_change'];
|
||||
const draftIds = [];
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await runAgentTurn({
|
||||
agent, persona, registryName: undefined, toolNames: companionTools,
|
||||
spaceId: null, view, sessionId: convo.id, resume, userText: text, claudeExe,
|
||||
home: process.env.VOID_CLAUDE_HOME || undefined,
|
||||
onEvent: (e) => {
|
||||
if (e.type === 'delta') {
|
||||
send('delta', { type: 'delta', text: e.text });
|
||||
} else if (e.type === 'tool') {
|
||||
send('tool', { type: 'tool', tool: e.tool, status: e.status });
|
||||
} else if (e.type === 'tool_result') {
|
||||
try {
|
||||
let parsed = null;
|
||||
const tryParse = (s) => { try { return JSON.parse(s); } catch { return null; } };
|
||||
if (typeof e.result === 'string') {
|
||||
parsed = tryParse(e.result);
|
||||
} else if (e.result?.structuredContent?.pending_change_id) {
|
||||
parsed = e.result.structuredContent;
|
||||
} else if (Array.isArray(e.result)) {
|
||||
for (const b of e.result) {
|
||||
const c = b?.type === 'text' && b.text ? tryParse(b.text) : null;
|
||||
if (c?.pending_change_id) { parsed = c; break; }
|
||||
}
|
||||
}
|
||||
if (parsed?.pending_change_id) {
|
||||
draftIds.push(parsed.pending_change_id);
|
||||
send('draft', { type: 'draft', pending_change_id: parsed.pending_change_id, summary: parsed.summary || 'a change' });
|
||||
}
|
||||
} catch { /* parsing failed — no draft to surface */ }
|
||||
} else if (e.type === 'error') {
|
||||
send('error', { type: 'error', message: e.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
send('error', { message: String(e?.message || e) });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const assistant = await messages.append(convo.id, {
|
||||
role: 'assistant', body: result.text, agent_id: agent.id,
|
||||
metadata: { tool_trace: result.toolTrace, draft_ids: draftIds, usage: result.usage }
|
||||
});
|
||||
send('done', { assistant_message_id: assistant.id, draft_ids: draftIds, usage: result.usage });
|
||||
res.end();
|
||||
}));
|
||||
33
lib/api/routes/improvements.js
Normal file
33
lib/api/routes/improvements.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Router } from 'express';
|
||||
import { asyncWrap } from '../errors.js';
|
||||
import { requireOwner } from '../cap.js';
|
||||
import * as repo from '../../db/repos/improvements.js';
|
||||
import { recordAudit } from '../../db/repos/audit.js';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
router.get('/', asyncWrap(async (_req, res) => res.json(await repo.list())));
|
||||
|
||||
router.get('/:id', asyncWrap(async (req, res) => {
|
||||
const row = await repo.get(req.params.id);
|
||||
if (!row) return res.status(404).json({ error: 'not_found' });
|
||||
res.json(row);
|
||||
}));
|
||||
|
||||
for (const verb of ['approve', 'rollback', 'restore', 'reject']) {
|
||||
router.post(`/:id/${verb}`, requireOwner, asyncWrap(async (req, res) => {
|
||||
const row = await repo.transition(req.params.id, verb, 'owner');
|
||||
if (!row) return res.status(409).json({ error: 'invalid_transition' });
|
||||
const auditAction = { approve: 'approve', reject: 'reject', rollback: 'update', restore: 'update' }[verb];
|
||||
await recordAudit({ kind: 'user' }, auditAction, 'improvement', row.id, null, { verb, summary: row.summary });
|
||||
res.json(row);
|
||||
}));
|
||||
}
|
||||
|
||||
// Public stylesheet of ACTIVE improvements. Unauthenticated by design: it carries
|
||||
// no secrets (owner-approved, exfil-sanitized CSS only) and <link> can't send a
|
||||
// bearer token. Mounted on the app root, outside the /api auth wall.
|
||||
export async function cssHandler(_req, res) {
|
||||
res.set({ 'Content-Type': 'text/css; charset=utf-8', 'Cache-Control': 'no-cache' });
|
||||
res.send(await repo.activeCss());
|
||||
}
|
||||
@@ -1,11 +1,39 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { asyncWrap } from '../errors.js';
|
||||
import { requireOwner } from '../cap.js';
|
||||
import { validate } from '../validate.js';
|
||||
import * as repo from '../../db/repos/speedtest.js';
|
||||
import * as settings from '../../db/repos/app_settings.js';
|
||||
import { enqueue } from '../../jobs/queue.js';
|
||||
import { setSpeedtestSchedule } from '../../cron/index.js';
|
||||
export const router = Router();
|
||||
router.get('/history', asyncWrap(async (_req, res) => res.json(await repo.history(30))));
|
||||
router.post('/run', requireOwner, asyncWrap(async (_req, res) => {
|
||||
const id = await enqueue('speedtest', {});
|
||||
res.status(202).json({ enqueued: id });
|
||||
|
||||
const DEFAULT_CFG = { interval_min: 60, threshold_down_mbps: 0 };
|
||||
async function getCfg() { return { ...DEFAULT_CFG, ...(await settings.get('speedtest', {})) }; }
|
||||
|
||||
router.get('/history', asyncWrap(async (req, res) =>
|
||||
res.json(await repo.history(Math.min(500, Number(req.query.limit) || 30)))));
|
||||
|
||||
router.get('/results', asyncWrap(async (req, res) =>
|
||||
res.json(await repo.range(Math.min(2160, Number(req.query.hours) || 168), 2000))));
|
||||
|
||||
router.get('/latest', asyncWrap(async (_req, res) => res.json(await repo.latest())));
|
||||
|
||||
router.get('/stats', asyncWrap(async (req, res) =>
|
||||
res.json(await repo.stats(Math.min(2160, Number(req.query.hours) || 24)))));
|
||||
|
||||
router.get('/config', asyncWrap(async (_req, res) => res.json(await getCfg())));
|
||||
|
||||
const cfgBody = z.object({
|
||||
interval_min: z.number().int().min(5).max(1440),
|
||||
threshold_down_mbps: z.number().min(0).max(100000).default(0)
|
||||
});
|
||||
router.put('/config', requireOwner, validate({ body: cfgBody }), asyncWrap(async (req, res) => {
|
||||
const cfg = await settings.set('speedtest', req.body);
|
||||
setSpeedtestSchedule(cfg.interval_min);
|
||||
res.json(cfg);
|
||||
}));
|
||||
|
||||
router.post('/run', requireOwner, asyncWrap(async (_req, res) =>
|
||||
res.status(202).json({ enqueued: await enqueue('speedtest', {}) })));
|
||||
|
||||
21
lib/api/routes/theme.js
Normal file
21
lib/api/routes/theme.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { asyncWrap } from '../errors.js';
|
||||
import { requireOwner } from '../cap.js';
|
||||
import { validate } from '../validate.js';
|
||||
import * as settings from '../../db/repos/app_settings.js';
|
||||
export const router = Router();
|
||||
|
||||
// Theme = a small map of palette-var overrides, e.g. { accent: '#ff4f2e' }.
|
||||
// Keys are short slugs (mapped to --<key> on the client); values must be hex,
|
||||
// so a saved theme can never inject arbitrary CSS.
|
||||
const themeSchema = z.record(
|
||||
z.string().regex(/^[a-z0-9-]{1,24}$/),
|
||||
z.string().regex(/^#[0-9a-fA-F]{3,8}$/)
|
||||
);
|
||||
|
||||
router.get('/', asyncWrap(async (_req, res) => res.json(await settings.get('theme', {}))));
|
||||
|
||||
router.put('/', requireOwner, validate({ body: themeSchema }), asyncWrap(async (req, res) => {
|
||||
res.json(await settings.set('theme', req.body));
|
||||
}));
|
||||
73
lib/api/routes/voice.js
Normal file
73
lib/api/routes/voice.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { writeFile, unlink } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { asyncWrap } from '../errors.js';
|
||||
import { requireOwner } from '../cap.js';
|
||||
import * as whisper from '../../voice/whisper.js';
|
||||
import * as settings from '../../db/repos/app_settings.js';
|
||||
import * as clips from '../../db/repos/voice_clips.js';
|
||||
export const router = Router();
|
||||
|
||||
const CLIPS_DIR = process.env.VOICE_CLIPS_DIR || '/var/lib/void/voice-clips';
|
||||
// In-memory upload; clips are small voice notes. 25 MB ceiling.
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });
|
||||
|
||||
function extFor(mime = '') {
|
||||
if (mime.includes('ogg')) return '.ogg';
|
||||
if (mime.includes('mp4') || mime.includes('m4a')) return '.m4a';
|
||||
if (mime.includes('wav')) return '.wav';
|
||||
return '.webm';
|
||||
}
|
||||
|
||||
// POST /api/voice/transcribe — owner-only. multipart field `audio`. Returns { text }.
|
||||
// When the Dross "keepClips" setting is on, the clip + transcript are retained.
|
||||
router.post('/transcribe', requireOwner, upload.single('audio'), asyncWrap(async (req, res) => {
|
||||
if (!req.file || !req.file.buffer?.length) {
|
||||
return res.status(400).json({ error: { code: 'no_audio', message: 'no audio supplied' } });
|
||||
}
|
||||
let r;
|
||||
try {
|
||||
r = await whisper.transcribe(
|
||||
req.file.buffer, req.file.originalname || 'clip.webm', req.file.mimetype || 'audio/webm');
|
||||
} catch {
|
||||
return res.status(503).json({ error: { code: 'stt_unavailable', message: 'transcription service unavailable' } });
|
||||
}
|
||||
|
||||
const cfg = await settings.get('dross', {});
|
||||
let clip_id = null;
|
||||
if (cfg?.keepClips) {
|
||||
try {
|
||||
const id = randomUUID();
|
||||
const mime = req.file.mimetype || 'audio/webm';
|
||||
const filePath = path.join(CLIPS_DIR, id + extFor(mime));
|
||||
await writeFile(filePath, req.file.buffer, { mode: 0o600 });
|
||||
const row = await clips.create({
|
||||
transcript: r.text, duration_ms: r.duration != null ? Math.round(r.duration * 1000) : null,
|
||||
bytes: req.file.buffer.length, mime, path: filePath
|
||||
});
|
||||
clip_id = row.id;
|
||||
} catch { /* retention is best-effort; never fail the transcript */ }
|
||||
}
|
||||
res.json({ text: r.text, duration: r.duration ?? null, clip_id });
|
||||
}));
|
||||
|
||||
// GET /api/voice/clips — list retained clips (owner).
|
||||
router.get('/clips', requireOwner, asyncWrap(async (_req, res) => res.json(await clips.list())));
|
||||
|
||||
// GET /api/voice/clips/:id/audio — stream the audio file (owner).
|
||||
router.get('/clips/:id/audio', requireOwner, asyncWrap(async (req, res) => {
|
||||
const c = await clips.get(req.params.id);
|
||||
if (!c) return res.status(404).json({ error: { code: 'not_found', message: 'clip not found' } });
|
||||
res.setHeader('Content-Type', c.mime || 'audio/webm');
|
||||
createReadStream(c.path).on('error', () => res.status(404).end()).pipe(res);
|
||||
}));
|
||||
|
||||
// DELETE /api/voice/clips/:id — remove the row + the file (owner).
|
||||
router.delete('/clips/:id', requireOwner, asyncWrap(async (req, res) => {
|
||||
const removed = await clips.remove(req.params.id);
|
||||
if (removed?.path) { try { await unlink(removed.path); } catch { /* file may be gone */ } }
|
||||
res.status(204).end();
|
||||
}));
|
||||
@@ -6,6 +6,26 @@ import { checkAll } from '../health/checker.js';
|
||||
import * as statusRepo from '../db/repos/service_status.js';
|
||||
import * as services from '../db/repos/monitored_services.js';
|
||||
import { runDeviceScanCycle } from '../infra/scan_cycle.js';
|
||||
import * as settings from '../db/repos/app_settings.js';
|
||||
|
||||
// Speedtest runs on a user-configurable interval (PUT /api/speedtest/config →
|
||||
// setSpeedtestSchedule). Held module-level so it can be stopped + rescheduled.
|
||||
let speedtestTask = null;
|
||||
function speedtestExpr(min) {
|
||||
if (min < 60) return `*/${min} * * * *`;
|
||||
if (min % 60 === 0) { const h = min / 60; return h >= 24 ? '0 2 * * *' : `0 */${h} * * *`; }
|
||||
return '0 * * * *';
|
||||
}
|
||||
export function setSpeedtestSchedule(min) {
|
||||
const m = Math.max(5, Math.min(1440, Number(min) || 60));
|
||||
if (speedtestTask) { speedtestTask.stop(); speedtestTask = null; }
|
||||
const expr = speedtestExpr(m);
|
||||
speedtestTask = cron.schedule(expr, async () => {
|
||||
try { await enqueue('speedtest', {}); log.info({ expr }, 'cron speedtest enqueued'); }
|
||||
catch (e) { log.error({ err: e }, 'cron speedtest failed'); }
|
||||
});
|
||||
log.info({ expr, min: m }, 'speedtest schedule set');
|
||||
}
|
||||
|
||||
export function startCron() {
|
||||
// Daily at 03:00 local time
|
||||
@@ -18,11 +38,10 @@ export function startCron() {
|
||||
}
|
||||
});
|
||||
|
||||
// Hourly speedtest
|
||||
cron.schedule('0 * * * *', async () => {
|
||||
try { await enqueue('speedtest', {}); log.info('cron speedtest enqueued'); }
|
||||
catch (e) { log.error({ err: e }, 'cron speedtest failed'); }
|
||||
});
|
||||
// Speedtest — interval from the saved config (default 60 min), reschedulable.
|
||||
settings.get('speedtest', {})
|
||||
.then(cfg => setSpeedtestSchedule(cfg?.interval_min || 60))
|
||||
.catch(e => { log.error({ err: e }, 'speedtest schedule init failed'); setSpeedtestSchedule(60); });
|
||||
|
||||
// Health checks every minute. NOTE: this runs checkAll() inline; the same
|
||||
// probe+upsert logic is also exposed on-demand via the `health.check` pg-boss
|
||||
|
||||
7
lib/db/migrations/027_dashboard_geom.sql
Normal file
7
lib/db/migrations/027_dashboard_geom.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- 027_dashboard_geom.sql
|
||||
-- Sacred Valley hybrid canvas: free/snap geometry per card + decorative card
|
||||
-- instances (blank spacers, blackflame). geom is keyed by card id →
|
||||
-- {x,y,w,h,free} in (fractional) 12-col grid units; extras lists the
|
||||
-- user-added decorative cards so they survive reloads.
|
||||
ALTER TABLE dashboard_layout ADD COLUMN IF NOT EXISTS geom jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||
ALTER TABLE dashboard_layout ADD COLUMN IF NOT EXISTS extras jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||
22
lib/db/migrations/028_speedtest_metrics.sql
Normal file
22
lib/db/migrations/028_speedtest_metrics.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- 028_speedtest_metrics.sql
|
||||
-- Enrich speedtest results with the full Ookla metric set + a generic settings
|
||||
-- store (reused by the speedtest schedule and, later, theming).
|
||||
ALTER TABLE speedtest_results ALTER COLUMN down_mbps DROP NOT NULL;
|
||||
ALTER TABLE speedtest_results ALTER COLUMN up_mbps DROP NOT NULL;
|
||||
ALTER TABLE speedtest_results
|
||||
ADD COLUMN IF NOT EXISTS jitter_ms numeric,
|
||||
ADD COLUMN IF NOT EXISTS packet_loss numeric,
|
||||
ADD COLUMN IF NOT EXISTS server_name text,
|
||||
ADD COLUMN IF NOT EXISTS server_id text,
|
||||
ADD COLUMN IF NOT EXISTS isp text,
|
||||
ADD COLUMN IF NOT EXISTS result_url text,
|
||||
ADD COLUMN IF NOT EXISTS down_bytes bigint,
|
||||
ADD COLUMN IF NOT EXISTS up_bytes bigint,
|
||||
ADD COLUMN IF NOT EXISTS ok boolean NOT NULL DEFAULT true,
|
||||
ADD COLUMN IF NOT EXISTS error text;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key text PRIMARY KEY,
|
||||
value jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
14
lib/db/migrations/029_voice_clips.sql
Normal file
14
lib/db/migrations/029_voice_clips.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- 029_voice_clips.sql
|
||||
-- Optional retained Dross voice clips (when the "Keep voice clips" setting is on).
|
||||
-- Transcript + metadata here (durable, HA-replicated); audio bytes live as files
|
||||
-- on the owner-only ZFS subvol mounted at /var/lib/void/voice-clips.
|
||||
CREATE TABLE voice_clips (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
transcript text NOT NULL DEFAULT '',
|
||||
duration_ms integer,
|
||||
bytes bigint,
|
||||
mime text,
|
||||
path text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_voice_clips_created ON voice_clips (created_at DESC);
|
||||
13
lib/db/migrations/030_improvements.sql
Normal file
13
lib/db/migrations/030_improvements.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Dross improvements: versioned, owner-gated CSS-layer changes to the Void itself.
|
||||
-- Each row is one improvement; rollback/restore is a status flip — instant, reversible.
|
||||
CREATE TABLE dross_improvements (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
summary TEXT NOT NULL,
|
||||
css TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'active', 'rolled_back', 'rejected')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
decided_at TIMESTAMPTZ,
|
||||
decided_by TEXT
|
||||
);
|
||||
CREATE INDEX dross_improvements_status ON dross_improvements(status);
|
||||
17
lib/db/repos/app_settings.js
Normal file
17
lib/db/repos/app_settings.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { pool } from '../pool.js';
|
||||
|
||||
// Generic owner-scoped key→jsonb settings store. Used by the speedtest schedule
|
||||
// and (later) the theming panel. Keep values small + JSON-serialisable.
|
||||
export async function get(key, fallback = null) {
|
||||
const { rows } = await pool.query(`SELECT value FROM app_settings WHERE key = $1`, [key]);
|
||||
return rows[0] ? rows[0].value : fallback;
|
||||
}
|
||||
|
||||
export async function set(key, value) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2::jsonb, now())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()
|
||||
RETURNING value`,
|
||||
[key, JSON.stringify(value)]);
|
||||
return rows[0].value;
|
||||
}
|
||||
@@ -1,24 +1,28 @@
|
||||
import { pool } from '../pool.js';
|
||||
|
||||
const DEFAULTS = { card_order: [], hidden: [], sizes: {} };
|
||||
const DEFAULTS = { card_order: [], hidden: [], sizes: {}, geom: {}, extras: [] };
|
||||
|
||||
export async function get() {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT card_order, hidden, sizes FROM dashboard_layout WHERE owner_key = 'owner'`
|
||||
`SELECT card_order, hidden, sizes, geom, extras
|
||||
FROM dashboard_layout WHERE owner_key = 'owner'`
|
||||
);
|
||||
return rows[0] || { ...DEFAULTS };
|
||||
}
|
||||
|
||||
export async function put({ card_order = [], hidden = [], sizes = {} }) {
|
||||
export async function put({ card_order = [], hidden = [], sizes = {}, geom = {}, extras = [] }) {
|
||||
await pool.query(
|
||||
`INSERT INTO dashboard_layout (owner_key, card_order, hidden, sizes, updated_at)
|
||||
VALUES ('owner', $1::jsonb, $2::jsonb, $3::jsonb, now())
|
||||
`INSERT INTO dashboard_layout (owner_key, card_order, hidden, sizes, geom, extras, updated_at)
|
||||
VALUES ('owner', $1::jsonb, $2::jsonb, $3::jsonb, $4::jsonb, $5::jsonb, now())
|
||||
ON CONFLICT (owner_key) DO UPDATE
|
||||
SET card_order = EXCLUDED.card_order,
|
||||
hidden = EXCLUDED.hidden,
|
||||
sizes = EXCLUDED.sizes,
|
||||
geom = EXCLUDED.geom,
|
||||
extras = EXCLUDED.extras,
|
||||
updated_at = now()`,
|
||||
[JSON.stringify(card_order), JSON.stringify(hidden), JSON.stringify(sizes)]
|
||||
[JSON.stringify(card_order), JSON.stringify(hidden), JSON.stringify(sizes),
|
||||
JSON.stringify(geom), JSON.stringify(extras)]
|
||||
);
|
||||
return get();
|
||||
}
|
||||
|
||||
58
lib/db/repos/improvements.js
Normal file
58
lib/db/repos/improvements.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// Dross improvements — versioned CSS-layer changes with instant rollback.
|
||||
import { pool } from '../pool.js';
|
||||
const q = (text, params) => pool.query(text, params);
|
||||
|
||||
// Same exfil guards as elsewhere: an approved improvement still can't phone home
|
||||
// or pull remote CSS. Pure visual tweaks only.
|
||||
const BANNED = /url\s*\(|@import|@charset|expression\s*\(|behavior\s*:|javascript:/i;
|
||||
const MAX_CSS = 20_000;
|
||||
|
||||
export function validateCss(css) {
|
||||
if (typeof css !== 'string' || !css.trim()) return 'css required';
|
||||
if (css.length > MAX_CSS) return `css too large (max ${MAX_CSS} chars)`;
|
||||
if (BANNED.test(css)) return 'css may not use url()/@import/expression — visual tweaks only';
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function create({ summary, css }) {
|
||||
const { rows } = await q(
|
||||
`INSERT INTO dross_improvements (summary, css) VALUES ($1, $2) RETURNING *`,
|
||||
[String(summary).slice(0, 200), css]);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function list() {
|
||||
const { rows } = await q(
|
||||
`SELECT id, summary, status, created_at, decided_at, length(css) AS css_len
|
||||
FROM dross_improvements ORDER BY created_at DESC LIMIT 100`);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function get(id) {
|
||||
const { rows } = await q(`SELECT * FROM dross_improvements WHERE id = $1`, [id]);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
// pending→active (approve) · active→rolled_back · rolled_back→active (restore) · pending→rejected
|
||||
const TRANSITIONS = {
|
||||
approve: { from: ['pending'], to: 'active' },
|
||||
rollback: { from: ['active'], to: 'rolled_back' },
|
||||
restore: { from: ['rolled_back'], to: 'active' },
|
||||
reject: { from: ['pending'], to: 'rejected' },
|
||||
};
|
||||
|
||||
export async function transition(id, verb, actor) {
|
||||
const t = TRANSITIONS[verb];
|
||||
if (!t) return null;
|
||||
const { rows } = await q(
|
||||
`UPDATE dross_improvements SET status = $1, decided_at = now(), decided_by = $2
|
||||
WHERE id = $3 AND status = ANY($4) RETURNING *`,
|
||||
[t.to, actor ?? 'owner', id, t.from]);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function activeCss() {
|
||||
const { rows } = await q(
|
||||
`SELECT summary, css FROM dross_improvements WHERE status = 'active' ORDER BY created_at`);
|
||||
return rows.map((r) => `/* dross: ${r.summary.replace(/\*\//g, '')} */\n${r.css}`).join('\n\n');
|
||||
}
|
||||
@@ -1,12 +1,51 @@
|
||||
import { pool } from '../pool.js';
|
||||
export async function record({ down_mbps, up_mbps, ping_ms = null }) {
|
||||
|
||||
export async function record(r = {}) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO speedtest_results (down_mbps, up_mbps, ping_ms) VALUES ($1,$2,$3) RETURNING *`,
|
||||
[down_mbps, up_mbps, ping_ms]);
|
||||
`INSERT INTO speedtest_results
|
||||
(down_mbps, up_mbps, ping_ms, jitter_ms, packet_loss, server_name, server_id,
|
||||
isp, result_url, down_bytes, up_bytes, ok, error)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING *`,
|
||||
[r.down_mbps ?? null, r.up_mbps ?? null, r.ping_ms ?? null, r.jitter_ms ?? null,
|
||||
r.packet_loss ?? null, r.server_name ?? null, r.server_id ?? null, r.isp ?? null,
|
||||
r.result_url ?? null, r.down_bytes ?? null, r.up_bytes ?? null, r.ok ?? true, r.error ?? null]);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function history(limit = 30) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM speedtest_results ORDER BY ran_at DESC LIMIT $1`, [limit]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Rows within the last N hours (ascending for charting), capped.
|
||||
export async function range(hours = 168, limit = 1000) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM (
|
||||
SELECT * FROM speedtest_results
|
||||
WHERE ran_at >= now() - ($1 || ' hours')::interval
|
||||
ORDER BY ran_at DESC LIMIT $2
|
||||
) t ORDER BY ran_at ASC`, [hours, limit]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function latest() {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM speedtest_results WHERE ok ORDER BY ran_at DESC LIMIT 1`);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function stats(hours = 24) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT count(*) FILTER (WHERE ok) AS n,
|
||||
count(*) FILTER (WHERE NOT ok) AS failures,
|
||||
avg(down_mbps) FILTER (WHERE ok) AS avg_down,
|
||||
min(down_mbps) FILTER (WHERE ok) AS min_down,
|
||||
max(down_mbps) FILTER (WHERE ok) AS max_down,
|
||||
avg(up_mbps) FILTER (WHERE ok) AS avg_up,
|
||||
avg(ping_ms) FILTER (WHERE ok) AS avg_ping,
|
||||
max(ping_ms) FILTER (WHERE ok) AS max_ping
|
||||
FROM speedtest_results
|
||||
WHERE ran_at >= now() - ($1 || ' hours')::interval`, [hours]);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
26
lib/db/repos/voice_clips.js
Normal file
26
lib/db/repos/voice_clips.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { pool } from '../pool.js';
|
||||
|
||||
export async function create({ transcript = '', duration_ms = null, bytes = null, mime = null, path }) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO voice_clips (transcript, duration_ms, bytes, mime, path)
|
||||
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
|
||||
[transcript, duration_ms, bytes, mime, path]);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function list(limit = 100) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, transcript, duration_ms, bytes, mime, created_at
|
||||
FROM voice_clips ORDER BY created_at DESC LIMIT $1`, [limit]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function get(id) {
|
||||
const { rows } = await pool.query(`SELECT * FROM voice_clips WHERE id = $1`, [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function remove(id) {
|
||||
const { rows } = await pool.query(`DELETE FROM voice_clips WHERE id = $1 RETURNING path`, [id]);
|
||||
return rows[0] || null; // returns {path} so the caller can unlink the file
|
||||
}
|
||||
@@ -6,18 +6,42 @@ const pexec = promisify(execFile);
|
||||
|
||||
export const NAME = 'speedtest';
|
||||
|
||||
// Default runner uses speedtest-cli --json (bits/s → Mbps). Swap binary/flags
|
||||
// here if the box has the Ookla `speedtest -f json` CLI instead.
|
||||
async function defaultRunner() {
|
||||
const { stdout } = await pexec('speedtest-cli', ['--json'], { timeout: 120000 });
|
||||
// Ookla CLI gives the full metric set (jitter, packet loss, server, ISP,
|
||||
// shareable result URL). Override the binary via SPEEDTEST_BIN if needed.
|
||||
const OOKLA_BIN = process.env.SPEEDTEST_BIN || 'ookla-speedtest';
|
||||
|
||||
async function ooklaRunner() {
|
||||
const { stdout } = await pexec(OOKLA_BIN,
|
||||
['-f', 'json', '--accept-license', '--accept-gdpr'], { timeout: 120000 });
|
||||
const j = JSON.parse(stdout);
|
||||
return { down_mbps: j.download / 1e6, up_mbps: j.upload / 1e6, ping_ms: j.ping };
|
||||
const mbps = bw => (Number(bw) || 0) * 8 / 1e6; // Ookla bandwidth is bytes/s
|
||||
return {
|
||||
down_mbps: mbps(j.download?.bandwidth),
|
||||
up_mbps: mbps(j.upload?.bandwidth),
|
||||
ping_ms: j.ping?.latency ?? null,
|
||||
jitter_ms: j.ping?.jitter ?? null,
|
||||
packet_loss: j.packetLoss ?? null,
|
||||
server_name: j.server ? [j.server.name, j.server.location].filter(Boolean).join(' · ') : null,
|
||||
server_id: j.server?.id != null ? String(j.server.id) : null,
|
||||
isp: j.isp ?? null,
|
||||
result_url: j.result?.url ?? null,
|
||||
down_bytes: j.download?.bytes ?? null,
|
||||
up_bytes: j.upload?.bytes ?? null,
|
||||
ok: true
|
||||
};
|
||||
}
|
||||
let runner = defaultRunner;
|
||||
let runner = ooklaRunner;
|
||||
export function _setRunner(fn) { runner = fn; }
|
||||
|
||||
export async function handler(_job) {
|
||||
const r = await runner();
|
||||
await repo.record(r);
|
||||
log.info(r, 'speedtest recorded');
|
||||
try {
|
||||
const r = await runner();
|
||||
const saved = await repo.record(r);
|
||||
log.info({ down: r.down_mbps, up: r.up_mbps, ping: r.ping_ms }, 'speedtest recorded');
|
||||
return saved;
|
||||
} catch (e) {
|
||||
await repo.record({ ok: false, error: String(e?.message || e).slice(0, 300) });
|
||||
log.error({ err: e }, 'speedtest failed');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
22
lib/voice/whisper.js
Normal file
22
lib/voice/whisper.js
Normal file
@@ -0,0 +1,22 @@
|
||||
// Thin client for the local faster-whisper service on CT 102 (the Ollama box).
|
||||
// GPU with CPU fallback lives in the service itself; here we just POST the audio
|
||||
// buffer and return the transcript. LAN-only endpoint.
|
||||
const WHISPER_URL = process.env.WHISPER_URL || 'http://192.168.1.185:8001';
|
||||
|
||||
export async function transcribe(buffer, filename = 'clip.webm', mime = 'audio/webm') {
|
||||
const fd = new FormData();
|
||||
fd.append('file', new Blob([buffer], { type: mime }), filename);
|
||||
const res = await fetch(`${WHISPER_URL}/transcribe`, {
|
||||
method: 'POST', body: fd, signal: AbortSignal.timeout(120000)
|
||||
});
|
||||
if (!res.ok) throw new Error(`whisper ${res.status}`);
|
||||
const j = await res.json();
|
||||
return { text: (j.text || '').trim(), duration: j.duration, device: j.device };
|
||||
}
|
||||
|
||||
export async function health() {
|
||||
try {
|
||||
const res = await fetch(`${WHISPER_URL}/health`, { signal: AbortSignal.timeout(5000) });
|
||||
return res.ok ? await res.json() : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "void-server",
|
||||
"version": "2.7.0",
|
||||
"version": "2.13.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "void-server",
|
||||
"version": "2.7.0",
|
||||
"version": "2.13.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "void-server",
|
||||
"version": "2.7.0",
|
||||
"version": "2.14.1",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -6,11 +6,12 @@ import { api } from './api.js';
|
||||
import { route, current, navigate } from './router.js';
|
||||
import { renderSidebar } from './components/sidebar.js';
|
||||
import { renderTopbar } from './components/topbar.js';
|
||||
import { renderRightrail } from './components/rightrail.js';
|
||||
import { renderDrossBubble } from './components/dross_bubble.js';
|
||||
import { emit, state } from './state.js';
|
||||
import { el, mount } from './dom.js';
|
||||
import { attachDropzone } from './components/dropzone.js';
|
||||
import { initChrome } from './components/chrome.js';
|
||||
import { loadTheme } from './theme.js';
|
||||
|
||||
const VIEWS = {
|
||||
home: () => import('./views/home.js'),
|
||||
@@ -31,7 +32,8 @@ const VIEWS = {
|
||||
links: () => import('./views/links.js'),
|
||||
mirror: () => import('./views/mirror.js'),
|
||||
settings: () => import('./views/settings.js'),
|
||||
jobs: () => import('./views/jobs.js')
|
||||
jobs: () => import('./views/jobs.js'),
|
||||
speedtest: () => import('./views/speedtest.js')
|
||||
};
|
||||
|
||||
async function renderView(ctx) {
|
||||
@@ -79,9 +81,10 @@ async function init() {
|
||||
try { await api.get('/api/spaces'); }
|
||||
catch { /* api wrapper opens the modal on 401 */ }
|
||||
}
|
||||
await loadTheme(); // apply saved palette overrides before rendering chrome
|
||||
renderTopbar(document.getElementById('topbar'));
|
||||
renderSidebar(document.getElementById('sidebar'));
|
||||
renderRightrail(document.getElementById('rightrail'));
|
||||
renderDrossBubble();
|
||||
initChrome();
|
||||
attachDropzone(document.getElementById('main'));
|
||||
route(renderView);
|
||||
|
||||
20
public/components/dross_avatar.js
Normal file
20
public/components/dross_avatar.js
Normal file
@@ -0,0 +1,20 @@
|
||||
// public/components/dross_avatar.js
|
||||
import { el } from '../dom.js';
|
||||
|
||||
// Returns a .dross-orb element rendering the chosen avatar. Colours come from
|
||||
// CSS vars (--dross*), set on the element by the caller for per-user accent.
|
||||
export function drossAvatar(variant = 'soft-eye', size = 60) {
|
||||
let inner;
|
||||
if (variant === 'wisp') {
|
||||
inner = [el('div', { class: 'b-core' }), el('div', { class: 'b-bright' })];
|
||||
} else if (variant === 'motes') {
|
||||
inner = [
|
||||
el('div', { class: 'd-ring' }, el('div', { class: 'd-mote' })),
|
||||
el('div', { class: 'd-ring r2' }, el('div', { class: 'd-mote' })),
|
||||
el('div', { class: 'd-core' })
|
||||
];
|
||||
} else { // soft-eye (default)
|
||||
inner = [el('div', { class: 'av-eye' }, el('div', { class: 'av-pupil' }))];
|
||||
}
|
||||
return el('div', { class: 'dross-orb', style: { width: size + 'px', height: size + 'px' } }, ...inner);
|
||||
}
|
||||
167
public/components/dross_bubble.js
Normal file
167
public/components/dross_bubble.js
Normal file
@@ -0,0 +1,167 @@
|
||||
// public/components/dross_bubble.js
|
||||
// Global floating Dross companion. Replaces the per-Space right rail.
|
||||
import { el, mount } from '../dom.js';
|
||||
import { api } from '../api.js';
|
||||
import { state } from '../state.js';
|
||||
import { wireAgentChat } from './agent_chat.js';
|
||||
import { drossAvatar } from './dross_avatar.js';
|
||||
|
||||
const TOOL_LABELS = { search: '🔍 searching', read: '📄 reading', context: '🧭 looking at this view', propose_change: '📝 drafting a change', propose_improvement: '🎨 drafting an improvement to the Void' };
|
||||
let cfg = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' };
|
||||
|
||||
function applyAccent(node, hex) {
|
||||
node.style.setProperty('--dross', hex);
|
||||
}
|
||||
|
||||
export async function renderDrossBubble() {
|
||||
try { cfg = { ...cfg, ...(await api.get('/api/dross/settings')) }; } catch { /* defaults */ }
|
||||
|
||||
const fab = el('div', { class: 'dross-fab', title: 'Dross' },
|
||||
el('div', { class: 'dross-ping', style: { display: 'none' } }, ''), drossAvatar(cfg.avatar, 60));
|
||||
const log = el('div', { class: 'dross-log' });
|
||||
const input = el('textarea', { rows: 1, placeholder: 'Ask Dross…' });
|
||||
const sendBtn = el('button', { class: 'dross-send', title: 'Send' },
|
||||
el('span', { html: '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>' }));
|
||||
const micLabel = el('span', {}, 'Tap to record');
|
||||
const mic = el('button', { class: 'dross-mic', title: 'Record a voice note' },
|
||||
el('span', { html: '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="8" y1="22" x2="16" y2="22"/></svg>' }), micLabel);
|
||||
const closeBtn = el('button', { class: 'dross-x', title: 'Close' }, '⤬');
|
||||
const header = el('div', { class: 'dross-hd' }, drossAvatar(cfg.avatar, 30),
|
||||
el('div', { class: 'dross-who' }, 'Dross', el('small', {}, 'always here, regrettably')), closeBtn);
|
||||
const collapse = el('div', { class: 'dross-collapse', title: 'Collapse' },
|
||||
el('span', { class: 'grip' }), el('span', {}, '⌄ collapse'), el('span', { class: 'grip' }));
|
||||
const panel = el('div', { class: 'dross-panel' }, header, log,
|
||||
el('div', { class: 'dross-inwrap' }, input, el('div', { class: 'dross-btnrow' }, mic, sendBtn)), collapse);
|
||||
|
||||
document.getElementById('shell').append(fab, panel);
|
||||
applyAccent(fab, cfg.accent); applyAccent(panel, cfg.accent);
|
||||
|
||||
// autogrow: 1 line at rest, expands with content up to ~5 lines
|
||||
function autogrow() {
|
||||
input.style.height = 'auto';
|
||||
input.style.height = Math.min(input.scrollHeight, 120) + 'px';
|
||||
}
|
||||
input.addEventListener('input', autogrow);
|
||||
|
||||
const chat = wireAgentChat({
|
||||
logEl: log, inputEl: input, sendBtnEl: sendBtn,
|
||||
historyUrl: '/api/dross', turnUrl: '/api/dross/turn',
|
||||
agentName: 'Dross', showDrafts: true, toolLabels: TOOL_LABELS,
|
||||
turnBody: (text) => ({ text, view: state.view || null })
|
||||
});
|
||||
let loaded = false;
|
||||
|
||||
function openPanel() {
|
||||
const r = fab.getBoundingClientRect();
|
||||
panel.classList.add('open'); fab.style.display = 'none';
|
||||
const pr = panel.getBoundingClientRect();
|
||||
const left = Math.max(8, Math.min(r.right - pr.width, innerWidth - pr.width - 8));
|
||||
const top = Math.max(8, Math.min(r.bottom - pr.height, innerHeight - pr.height - 8));
|
||||
panel.style.right = 'auto'; panel.style.bottom = 'auto'; panel.style.left = left + 'px'; panel.style.top = top + 'px';
|
||||
if (!loaded) { loaded = true; chat.load(); }
|
||||
// NB: do NOT auto-focus the input — on mobile that pops the keyboard every
|
||||
// time Dross opens. The keyboard should only appear when the user taps the box.
|
||||
}
|
||||
function closePanel() { panel.classList.remove('open'); fab.style.display = 'block'; }
|
||||
fab.addEventListener('click', () => { if (fab._moved) { fab._moved = false; return; } openPanel(); });
|
||||
closeBtn.addEventListener('click', closePanel);
|
||||
collapse.addEventListener('click', closePanel);
|
||||
// Topbar ◆ button (and any caller) can summon/dismiss Dross.
|
||||
window.addEventListener('dross-toggle', () => panel.classList.contains('open') ? closePanel() : openPanel());
|
||||
|
||||
drag(fab, fab, true); drag(header, panel, false);
|
||||
|
||||
// ---- voice: tap mic to record, tap again to stop → transcribe → review-and-send ----
|
||||
let media = null, chunks = [], recording = false;
|
||||
function setMic(label, rec) { micLabel.textContent = label; mic.classList.toggle('rec', !!rec); }
|
||||
async function startRec() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
chunks = [];
|
||||
const opt = (window.MediaRecorder && MediaRecorder.isTypeSupported('audio/webm;codecs=opus'))
|
||||
? { mimeType: 'audio/webm;codecs=opus' } : {};
|
||||
media = new MediaRecorder(stream, opt);
|
||||
media.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); };
|
||||
media.onstop = async () => {
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
await sendClip(new Blob(chunks, { type: media.mimeType || 'audio/webm' }));
|
||||
};
|
||||
media.start();
|
||||
recording = true; setMic('● Recording… tap to stop', true);
|
||||
// live level meter: actual mic amplitude drives the pulse (visual proof it hears you)
|
||||
try {
|
||||
const actx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const src = actx.createMediaStreamSource(stream);
|
||||
const analyser = actx.createAnalyser(); analyser.fftSize = 256;
|
||||
src.connect(analyser);
|
||||
const buf = new Uint8Array(analyser.frequencyBinCount);
|
||||
mic.classList.add('metered'); // disables the fallback pulse; amplitude takes over
|
||||
const tick = () => {
|
||||
if (!recording) {
|
||||
actx.close().catch(() => {});
|
||||
mic.style.removeProperty('--voicelevel'); mic.classList.remove('metered');
|
||||
return;
|
||||
}
|
||||
analyser.getByteTimeDomainData(buf);
|
||||
let peak = 0;
|
||||
for (const v of buf) peak = Math.max(peak, Math.abs(v - 128));
|
||||
// sqrt curve + gain: normal speech peaks ~0.1–0.4 raw, which read as barely-alive
|
||||
mic.style.setProperty('--voicelevel', Math.min(1, Math.sqrt(peak / 48)).toFixed(3));
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
tick();
|
||||
} catch { /* meter is decorative — recording works without it */ }
|
||||
} catch {
|
||||
setMic('Mic blocked', false); setTimeout(() => setMic('Tap to record', false), 1800);
|
||||
}
|
||||
}
|
||||
function stopRec() {
|
||||
if (media && recording) { recording = false; setMic('Transcribing…', false); media.stop(); }
|
||||
}
|
||||
async function sendClip(blob) {
|
||||
try {
|
||||
const fd = new FormData(); fd.append('audio', blob, 'clip.webm');
|
||||
const res = await fetch('/api/voice/transcribe', {
|
||||
method: 'POST', headers: { Authorization: 'Bearer ' + (localStorage.getItem('void_token') || '') }, body: fd
|
||||
});
|
||||
if (!res.ok) throw new Error('stt');
|
||||
const { text } = await res.json();
|
||||
setMic('Tap to record', false);
|
||||
if (text) {
|
||||
input.value = input.value ? (input.value + ' ' + text) : text;
|
||||
autogrow();
|
||||
// Focus only on fine-pointer devices — on mobile this popped the keyboard
|
||||
// right after every voice note (owner-reported). A brief highlight instead.
|
||||
if (matchMedia('(pointer: fine)').matches) input.focus();
|
||||
else { input.classList.add('flash'); setTimeout(() => input.classList.remove('flash'), 900); }
|
||||
}
|
||||
// voiceMode 'handsfree'/'action' (Phase 2b+) would branch here.
|
||||
} catch {
|
||||
setMic('Transcribe failed', false); setTimeout(() => setMic('Tap to record', false), 2000);
|
||||
}
|
||||
}
|
||||
mic.addEventListener('click', () => recording ? stopRec() : startRec());
|
||||
|
||||
window.addEventListener('dross-settings-changed', async () => {
|
||||
try { cfg = { ...cfg, ...(await api.get('/api/dross/settings')) }; } catch { return; }
|
||||
applyAccent(fab, cfg.accent); applyAccent(panel, cfg.accent);
|
||||
mount(fab, el('div', { class: 'dross-ping', style: { display: 'none' } }), drossAvatar(cfg.avatar, 60));
|
||||
header.replaceChild(drossAvatar(cfg.avatar, 30), header.firstChild);
|
||||
});
|
||||
}
|
||||
|
||||
function drag(handle, target, isFab) {
|
||||
handle.addEventListener('pointerdown', (e) => {
|
||||
if (e.target.closest('.dross-x') || e.target.closest('.dross-mic') || e.target.closest('.dross-send')) return;
|
||||
e.preventDefault();
|
||||
const r = target.getBoundingClientRect(); const sx = e.clientX, sy = e.clientY; let moved = false;
|
||||
target.style.right = 'auto'; target.style.bottom = 'auto'; target.style.left = r.left + 'px'; target.style.top = r.top + 'px';
|
||||
const mv = (ev) => {
|
||||
const dx = ev.clientX - sx, dy = ev.clientY - sy; if (Math.abs(dx) + Math.abs(dy) > 4) moved = true;
|
||||
target.style.left = Math.max(4, Math.min(innerWidth - r.width - 4, r.left + dx)) + 'px';
|
||||
target.style.top = Math.max(4, Math.min(innerHeight - r.height - 4, r.top + dy)) + 'px';
|
||||
};
|
||||
const up = () => { document.removeEventListener('pointermove', mv); document.removeEventListener('pointerup', up); if (isFab) target._moved = moved; };
|
||||
document.addEventListener('pointermove', mv); document.addEventListener('pointerup', up);
|
||||
});
|
||||
}
|
||||
@@ -122,7 +122,8 @@ export function renderSidebar(root) {
|
||||
el('div', { class: 'sb-section' },
|
||||
el('div', { class: 'sb-title' }, 'Navigate'),
|
||||
navItem('Sacred Valley', '/sacred-valley'),
|
||||
navItem('Terminal', '/terminal'),
|
||||
navItem('Speedtest', '/speedtest'),
|
||||
navItem('Eithan', '/terminal'),
|
||||
navItem('Search', '/search'),
|
||||
inboxItem,
|
||||
navItem('Jobs', '/jobs'),
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { el } from '../dom.js';
|
||||
|
||||
// Builds the refined-B chrome shell and returns { root, body }. The card module
|
||||
// fills `body` in its mount(); start()/stop() own its refresh timer.
|
||||
// fills `body` in its mount(); start()/stop() own its refresh timer. Position +
|
||||
// size are set by the Sacred Valley canvas (absolute geometry), not here.
|
||||
// Decorative cards (blank / blackflame) carry no title bar.
|
||||
export function svCard(def) {
|
||||
const body = el('div', { class: 'sv-card-body' });
|
||||
const root = el('div', {
|
||||
class: 'sv-card', dataset: { cardId: def.id },
|
||||
style: { gridColumn: 'span ' + (def.span || 6) } // 12-col grid; per-card width
|
||||
},
|
||||
el('div', { class: 'sv-card-title' }, def.title),
|
||||
const root = el('div', { class: 'sv-card', dataset: { cardId: def.id } },
|
||||
def.title ? el('div', { class: 'sv-card-title' }, def.title) : null,
|
||||
body
|
||||
);
|
||||
return { root, body };
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { el, mount, clear } from '../dom.js';
|
||||
import { navigate } from '../router.js';
|
||||
import { on } from '../state.js';
|
||||
import { toggleSidebar, toggleRail } from './chrome.js';
|
||||
import { toggleSidebar } from './chrome.js';
|
||||
import { api } from '../api.js';
|
||||
|
||||
// Cluster health → topbar pill. Returns [status, label, title].
|
||||
@@ -72,7 +72,7 @@ export function renderTopbar(root) {
|
||||
el('div', { class: 'topbar-spacer' }),
|
||||
clusterPill,
|
||||
bell,
|
||||
el('button', { class: 'chrome-toggle', title: 'Toggle companion chat', onclick: toggleRail }, '◆'),
|
||||
el('button', { class: 'chrome-toggle', title: 'Summon Dross', onclick: () => window.dispatchEvent(new CustomEvent('dross-toggle')) }, '◆'),
|
||||
el('button', { class: 'icon-btn', onclick: () => alert('Agent-switching ships post-Plan-2.') }, 'Owner')
|
||||
);
|
||||
|
||||
|
||||
@@ -33,13 +33,13 @@
|
||||
<link rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700&family=Cormorant+Garamond:wght@400;500;600&display=swap" />
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
<link rel="stylesheet" href="/improvements.css" id="dross-improvements" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="shell">
|
||||
<header id="topbar"></header>
|
||||
<aside id="sidebar"></aside>
|
||||
<main id="main"></main>
|
||||
<aside id="rightrail"></aside>
|
||||
</div>
|
||||
<div id="modal-root"></div>
|
||||
<script type="module" src="/app.js"></script>
|
||||
|
||||
@@ -33,6 +33,7 @@ const ROUTES = [
|
||||
{ name: 'mirror', re: /^\/mirror$/, keys: [] },
|
||||
{ name: 'settings', re: /^\/settings$/, keys: [] },
|
||||
{ name: 'jobs', re: /^\/jobs$/, keys: [] },
|
||||
{ name: 'speedtest', re: /^\/speedtest$/, keys: [] },
|
||||
{ name: 'home', re: /^\/?$/, keys: [] }
|
||||
];
|
||||
|
||||
|
||||
201
public/style.css
201
public/style.css
@@ -29,20 +29,18 @@ html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color:
|
||||
|
||||
#shell {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-w) 1fr var(--rail-w);
|
||||
grid-template-columns: var(--sidebar-w) 1fr;
|
||||
grid-template-rows: var(--topbar-h) 1fr;
|
||||
grid-template-areas:
|
||||
"topbar topbar topbar"
|
||||
"sidebar main rail";
|
||||
"topbar topbar"
|
||||
"sidebar main";
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
#shell.rail-collapsed { grid-template-columns: var(--sidebar-w) 1fr var(--rail-w-min); }
|
||||
|
||||
#topbar { grid-area: topbar; border-bottom: 1px solid var(--border); background: var(--panel); display: flex; align-items: center; padding: 0 16px; gap: 12px; }
|
||||
#sidebar { grid-area: sidebar; border-right: 1px solid var(--border); background: var(--panel); overflow-y: auto; padding: 12px 0; }
|
||||
#main { grid-area: main; overflow-y: auto; padding: 24px 32px; }
|
||||
#rightrail{ grid-area: rail; border-left: 1px solid var(--border); background: var(--panel); overflow: hidden; display: flex; flex-direction: column; }
|
||||
|
||||
/* topbar */
|
||||
.brand { font-family: var(--font-display); font-weight: 700; letter-spacing: 0.18em; font-size: 14px; color: var(--accent); text-transform: uppercase; padding: 0 6px; }
|
||||
@@ -382,14 +380,9 @@ ul.plain li:last-child { border-bottom: none; }
|
||||
/* reserved for a future agent-output phase — unused now:
|
||||
--hue-dross: #ff4f2e; --hue-yerin: #c45a4a; --hue-orthos: #6fa86a; */
|
||||
}
|
||||
#sv-cards { display: grid; grid-template-columns: repeat(12, 1fr); gap: 16px; align-items: start; grid-auto-rows: 8px; grid-auto-flow: row dense; }
|
||||
.sv-card { grid-column: span 6; } /* fallback; svCard sets an inline span (1–12) */
|
||||
@media (max-width: 700px) { #sv-cards { grid-template-columns: 1fr; } .sv-card { grid-column: 1 / -1 !important; } }
|
||||
.sv-ed-span { display: inline-flex; align-items: center; gap: 3px; }
|
||||
.sv-ed-step { width: 18px; height: 20px; border: 1px solid var(--border); background: transparent; color: var(--muted);
|
||||
border-radius: 3px; font-size: 14px; line-height: 1; cursor: pointer; padding: 0; }
|
||||
.sv-ed-step:hover { color: var(--accent); border-color: var(--accent-dim); }
|
||||
.sv-span-val { font-family: var(--font-mono); font-size: 12px; color: var(--text); min-width: 14px; text-align: center; }
|
||||
/* Hybrid canvas: cards are absolutely placed (JS sets left/top/width/height in
|
||||
12-col grid units); the board grows to fit its content. See sacred_valley.js. */
|
||||
#sv-cards { position: relative; width: 100%; min-height: 200px; }
|
||||
|
||||
.sv-card {
|
||||
position: relative; border: 1px solid #2c242a; border-radius: 10px; padding: 16px 18px;
|
||||
@@ -407,8 +400,11 @@ ul.plain li:last-child { border-bottom: none; }
|
||||
border-color: #4a2c28; transform: translateY(-2px);
|
||||
box-shadow: 0 8px 28px rgba(0,0,0,.45), inset 0 0 46px rgba(255,79,46,.06), 0 0 0 1px rgba(255,79,46,.10);
|
||||
}
|
||||
.sv-card.dragging { opacity: .5; }
|
||||
.sv-card.drag-over { border-color: var(--accent); }
|
||||
.sv-card.dragging { transition: none; box-shadow: 0 16px 44px -12px #000, 0 0 0 1px var(--accent-dim); }
|
||||
.sv-card.free { box-shadow: 0 0 0 1px var(--accent-dim), 0 10px 30px -12px #000; }
|
||||
#sv-cards.editing .sv-card { transition: none; }
|
||||
#sv-cards.editing .sv-card:hover { transform: none; }
|
||||
.sv-card-body { height: 100%; overflow: auto; }
|
||||
.sv-card-title {
|
||||
font-family: var(--font-display); font-size: 13px; letter-spacing: .16em; text-transform: uppercase;
|
||||
color: var(--text); padding-bottom: 7px; margin-bottom: 12px;
|
||||
@@ -485,15 +481,11 @@ ul.plain li:last-child { border-bottom: none; }
|
||||
/* ===== Collapsible chrome + responsive layout (Plan 6 polish) ===== */
|
||||
:root { --sidebar-w-min: 0px; }
|
||||
#shell { transition: grid-template-columns .22s ease; }
|
||||
#sidebar, #rightrail { transition: transform .22s ease; }
|
||||
#sidebar { transition: transform .22s ease; }
|
||||
|
||||
/* Desktop collapse — shrink the grid columns */
|
||||
#shell.sidebar-collapsed { grid-template-columns: var(--sidebar-w-min) 1fr var(--rail-w); }
|
||||
#shell.sidebar-collapsed.rail-collapsed { grid-template-columns: var(--sidebar-w-min) 1fr var(--rail-w-min); }
|
||||
#shell.rail-collapsed { grid-template-columns: var(--sidebar-w) 1fr var(--rail-w-min); }
|
||||
/* Desktop collapse — shrink the sidebar column */
|
||||
#shell.sidebar-collapsed { grid-template-columns: var(--sidebar-w-min) 1fr; }
|
||||
#shell.sidebar-collapsed #sidebar { overflow: hidden; border-right: none; }
|
||||
/* Hide chat body when the rail is collapsed so the thin strip stays clean */
|
||||
#shell.rail-collapsed .rail-chat { display: none; }
|
||||
|
||||
/* Topbar toggle buttons */
|
||||
.chrome-toggle {
|
||||
@@ -516,13 +508,11 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
|
||||
/* ---- Narrow / mobile / vertical: off-canvas drawers, single-column main ---- */
|
||||
@media (max-width: 860px) {
|
||||
#shell,
|
||||
#shell.sidebar-collapsed,
|
||||
#shell.rail-collapsed,
|
||||
#shell.sidebar-collapsed.rail-collapsed {
|
||||
#shell.sidebar-collapsed {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas: "topbar" "main";
|
||||
}
|
||||
#sidebar, #rightrail {
|
||||
#sidebar {
|
||||
position: fixed; top: var(--topbar-h); bottom: 0; z-index: 50;
|
||||
}
|
||||
#sidebar { left: 0; width: min(82vw, 300px); transform: translateX(-100%); border-right: 1px solid var(--border); }
|
||||
@@ -626,18 +616,38 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
|
||||
background: rgba(10,10,14,.88); border: 1px solid var(--border); border-radius: 6px; padding: 3px 5px; }
|
||||
#sv-cards.editing .sv-card-edit { display: flex; }
|
||||
#sv-cards.editing .sv-card { outline: 1px dashed var(--accent-dim); }
|
||||
.sv-grip { cursor: grab; color: var(--muted); font-size: 14px; padding: 0 2px; user-select: none; }
|
||||
.sv-grip { cursor: grab; color: var(--muted); font-size: 14px; padding: 0 2px; user-select: none; touch-action: none; }
|
||||
.sv-grip:active { cursor: grabbing; }
|
||||
.sv-ed-sizes { display: flex; gap: 2px; }
|
||||
.sv-ed-size, .sv-ed-hide { width: 20px; height: 20px; border: 1px solid var(--border); background: transparent;
|
||||
border-radius: 3px; font-size: 11px; cursor: pointer; padding: 0; line-height: 1; }
|
||||
.sv-ed-size { color: var(--muted); }
|
||||
.sv-ed-size:hover { color: var(--accent); border-color: var(--accent-dim); }
|
||||
.sv-card[data-size="s"] .sv-ed-size[data-s="s"],
|
||||
.sv-card[data-size="m"] .sv-ed-size[data-s="m"],
|
||||
.sv-card[data-size="l"] .sv-ed-size[data-s="l"] { background: var(--accent-dim); color: var(--text); border-color: var(--accent); }
|
||||
.sv-ed-hide { color: var(--bad); font-size: 12px; }
|
||||
.sv-ed-free, .sv-ed-hide { width: 20px; height: 20px; border: 1px solid var(--border); background: transparent;
|
||||
border-radius: 3px; font-size: 12px; cursor: pointer; padding: 0; line-height: 1; }
|
||||
.sv-ed-free { color: var(--muted); }
|
||||
.sv-ed-free:hover { color: var(--accent); border-color: var(--accent-dim); }
|
||||
.sv-card.free .sv-ed-free { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
|
||||
.sv-ed-hide { color: var(--bad); }
|
||||
.sv-ed-hide:hover { background: var(--bad); color: var(--bg); }
|
||||
/* resize handle — bottom-right corner, edit mode only */
|
||||
.sv-resize { display: none; position: absolute; right: 3px; bottom: 3px; width: 16px; height: 16px; z-index: 4;
|
||||
cursor: nwse-resize; touch-action: none; border-radius: 0 0 9px 0;
|
||||
background: linear-gradient(135deg, transparent 45%, var(--muted) 45% 55%, transparent 55%,
|
||||
transparent 62%, var(--muted) 62% 72%, transparent 72%); opacity: .6; }
|
||||
.sv-resize:hover { opacity: 1; }
|
||||
#sv-cards.editing .sv-resize { display: block; }
|
||||
.sv-tray-hint { font-family: var(--font-ui); font-size: 11px; margin-left: 6px; }
|
||||
|
||||
/* decorative cards (blank spacer / blackflame) — no chrome padding, full-bleed body */
|
||||
.sv-card-decor { padding: 0; overflow: hidden; }
|
||||
.sv-card-decor .sv-card-body { position: absolute; inset: 0; padding: 0; overflow: hidden; }
|
||||
.sv-card-decor:hover { transform: none; }
|
||||
.sv-blank { width: 100%; height: 100%;
|
||||
background-image: repeating-linear-gradient(45deg, rgba(255,255,255,.025) 0 9px, transparent 9px 18px); }
|
||||
.sv-flame-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; pointer-events: none; }
|
||||
.sv-flame-crest { position: absolute; top: 46%; left: 50%; width: 22%; aspect-ratio: 1; transform: translate(-50%,-50%);
|
||||
border-radius: 50%; pointer-events: none;
|
||||
background: radial-gradient(circle at 50% 45%, #000 38%, #0a0a10 60%, transparent 72%);
|
||||
box-shadow: 0 0 26px 10px #000, 0 0 60px 18px rgba(255,79,46,.13); }
|
||||
.sv-flame-label { position: absolute; left: 0; right: 0; bottom: 14px; text-align: center; pointer-events: none;
|
||||
font-family: var(--font-display); letter-spacing: .42em; text-indent: .42em; text-transform: uppercase;
|
||||
font-size: 14px; color: #f4ece9; text-shadow: 0 0 18px rgba(255,79,46,.55), 0 0 4px #000; }
|
||||
|
||||
#sv-tray { flex-wrap: wrap; align-items: center; gap: 8px; margin: 2px 0 18px; padding: 10px 12px;
|
||||
border: 1px dashed var(--border); border-radius: 8px; }
|
||||
@@ -645,6 +655,47 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
|
||||
.sv-tray-chip { background: var(--panel-2); border: 1px solid var(--border); color: var(--text); border-radius: 14px;
|
||||
padding: 4px 10px; font-family: var(--font-ui); font-size: 12px; cursor: pointer; }
|
||||
.sv-tray-chip:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.sv-link { color: var(--muted); text-decoration: none; font-family: var(--font-ui); font-size: 11px; }
|
||||
.sv-link:hover { color: var(--accent); }
|
||||
|
||||
/* ---- Speedtest page ---- */
|
||||
.st-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.st-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.st-ranges { display: inline-flex; border: 1px solid var(--border); border-radius: 7px; overflow: hidden; }
|
||||
.st-range { background: transparent; border: 0; color: var(--muted); padding: 5px 12px; font-family: var(--font-ui); font-size: 12px; cursor: pointer; }
|
||||
.st-range.on { background: var(--accent-soft); color: var(--accent); }
|
||||
.st-kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; margin-bottom: 8px; }
|
||||
.st-kpi { border: 1px solid var(--border); border-left: 3px solid var(--border); border-radius: 8px; padding: 10px 12px; background: var(--panel); }
|
||||
.st-kpi.down { border-left-color: var(--accent); }
|
||||
.st-kpi.up { border-left-color: var(--ok); }
|
||||
.st-kpi.bad { border-left-color: var(--bad); }
|
||||
.st-kpi-l { display: block; font-family: var(--font-ui); font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .08em; }
|
||||
.st-kpi-v { display: block; font-family: var(--font-mono); font-size: 26px; color: var(--text); line-height: 1.1; }
|
||||
.st-kpi-s { display: block; font-size: 11px; color: var(--muted); }
|
||||
.st-meta { font-size: 12px; margin: 4px 0 10px; }
|
||||
.st-link { color: var(--accent); text-decoration: none; }
|
||||
.st-warn { color: var(--warn); }
|
||||
.st-fail td { color: var(--bad); opacity: .8; }
|
||||
.st-stats { display: flex; flex-wrap: wrap; gap: 16px; font-family: var(--font-mono); font-size: 12px; color: var(--muted); margin-bottom: 18px; }
|
||||
.st-h2 { font-family: var(--font-display); font-size: 14px; letter-spacing: .12em; text-transform: uppercase; color: var(--text); margin: 18px 0 6px; }
|
||||
.st-chart { width: 100%; height: 180px; display: block; }
|
||||
.st-legend { display: flex; gap: 16px; margin-bottom: 4px; }
|
||||
.st-leg { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: var(--muted); }
|
||||
.st-leg i { width: 14px; height: 3px; border-radius: 2px; display: inline-block; }
|
||||
.st-card { margin: 16px 0; }
|
||||
.st-form { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; }
|
||||
.st-lbl { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; color: var(--muted); }
|
||||
.st-table-wrap { overflow-x: auto; }
|
||||
.st-table { width: 100%; border-collapse: collapse; font-family: var(--font-mono); font-size: 12px; }
|
||||
.st-table th { text-align: left; color: var(--muted); font-weight: 400; font-family: var(--font-ui); border-bottom: 1px solid var(--border); padding: 6px 10px; position: sticky; top: 0; background: var(--bg); }
|
||||
.st-table td { padding: 5px 10px; border-bottom: 1px solid #ffffff08; }
|
||||
.st-table td.num { text-align: right; }
|
||||
|
||||
/* ---- Theming panel ---- */
|
||||
.theme-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px 18px; margin-bottom: 14px; }
|
||||
.theme-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: 12px; color: var(--muted); }
|
||||
.theme-row input[type=color] { width: 40px; height: 24px; padding: 0; border: 1px solid var(--border); border-radius: 4px; background: none; cursor: pointer; }
|
||||
.theme-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
@@ -670,3 +721,81 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
|
||||
.ip-icon img { width: 28px; height: 28px; object-fit: contain; }
|
||||
.ip-set-hd, .isp-hd { font-size: 12px; margin: 6px 0 3px; text-transform: capitalize; }
|
||||
.isp-upload { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
|
||||
/* ---- Dross floating chat ---- */
|
||||
:root{ --dross:#a86adf; --dross-dim:#5a2e8a; --dross-soft:#1e1030; --dross-glow:#c79bff; }
|
||||
.dross-orb{position:relative;border-radius:50%;display:grid;place-items:center;overflow:hidden;flex:none;
|
||||
background:radial-gradient(circle at 38% 30%, #2a1640, #1a0f2a 70%, #120a1e);
|
||||
box-shadow:0 0 0 1px #ffffff12, 0 6px 22px -6px #000, 0 0 26px -4px var(--dross-dim)}
|
||||
.dross-fab{position:fixed;right:20px;bottom:20px;z-index:40;cursor:grab;touch-action:none;animation:dross-bob 5s ease-in-out infinite}
|
||||
.dross-fab:active{cursor:grabbing}
|
||||
@keyframes dross-bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-4px)}}
|
||||
.dross-fab .dross-orb{width:60px;height:60px}
|
||||
.dross-ping{position:absolute;right:-2px;top:-2px;width:17px;height:17px;border-radius:50%;background:var(--accent);
|
||||
color:#0a0a0e;font-size:10px;display:grid;place-items:center;box-shadow:0 0 0 2px var(--bg);z-index:2;font-family:var(--font-ui)}
|
||||
.av-eye{width:54%;height:54%;border-radius:50%;background:radial-gradient(circle at 50% 40%, #2a1c3a, #140b20);display:grid;place-items:center;box-shadow:inset 0 0 10px #000}
|
||||
.av-pupil{width:44%;height:44%;border-radius:50%;position:relative;background:radial-gradient(circle at 38% 32%, #fff, var(--dross-glow) 50%, var(--dross) 100%);box-shadow:0 0 10px var(--dross-glow);animation:dross-look 7s ease-in-out infinite}
|
||||
.av-pupil::after{content:"";position:absolute;right:14%;bottom:18%;width:26%;height:26%;border-radius:50%;background:#fff;opacity:.8}
|
||||
@keyframes dross-look{0%,45%{transform:translate(0,0)}58%{transform:translate(3px,-2px)}72%{transform:translate(-2px,1px)}88%,100%{transform:translate(0,0)}}
|
||||
.b-core{position:absolute;inset:13%;border-radius:50%;filter:blur(3px);animation:dross-spin 7s linear infinite;
|
||||
background:conic-gradient(from 0deg, var(--dross-dim), var(--dross-glow), var(--dross), var(--dross-soft), var(--dross-dim))}
|
||||
.b-bright{position:absolute;inset:32%;border-radius:50%;background:radial-gradient(circle,#fff,var(--dross-glow) 60%,transparent 75%);animation:dross-pulse 3s ease-in-out infinite}
|
||||
@keyframes dross-spin{to{transform:rotate(360deg)}}
|
||||
@keyframes dross-pulse{0%,100%{opacity:.55;transform:scale(.9)}50%{opacity:1;transform:scale(1.1)}}
|
||||
.d-core{width:22%;height:22%;border-radius:50%;background:radial-gradient(circle,#fff,var(--dross-glow) 60%,var(--dross));box-shadow:0 0 12px var(--dross-glow)}
|
||||
.d-ring{position:absolute;inset:0;animation:dross-spin 5s linear infinite}
|
||||
.d-ring.r2{animation-duration:8s;animation-direction:reverse}
|
||||
.d-mote{position:absolute;top:11%;left:50%;width:11%;height:11%;margin-left:-5.5%;border-radius:50%;background:var(--dross-glow);box-shadow:0 0 8px var(--dross-glow)}
|
||||
.d-ring.r2 .d-mote{top:auto;bottom:14%;background:var(--dross);width:8%;height:8%}
|
||||
.dross-panel{position:fixed;right:20px;bottom:20px;width:340px;max-width:calc(100vw - 24px);height:480px;max-height:calc(100vh - 24px);
|
||||
display:none;flex-direction:column;z-index:41;border:1px solid var(--dross-dim);border-radius:16px;overflow:hidden;
|
||||
background:linear-gradient(180deg, rgba(30,16,48,.6), rgba(20,20,28,.96) 22%);
|
||||
box-shadow:0 24px 70px -18px #000, 0 0 0 1px #00000060, 0 0 40px -16px var(--dross-dim);backdrop-filter:blur(6px)}
|
||||
.dross-panel.open{display:flex}
|
||||
.dross-hd{display:flex;align-items:center;gap:10px;padding:11px 12px;cursor:grab;touch-action:none;
|
||||
background:linear-gradient(180deg, var(--dross-soft), transparent);border-bottom:1px solid var(--border)}
|
||||
.dross-hd .dross-orb{width:30px;height:30px}
|
||||
.dross-who{font-family:var(--font-display);letter-spacing:.16em;text-transform:uppercase;font-size:13px;color:#efe9f6;flex:1}
|
||||
.dross-who small{display:block;font-family:var(--font-mono);letter-spacing:0;text-transform:none;font-size:10px;color:var(--dross-glow);opacity:.85}
|
||||
.dross-x{background:none;border:0;color:var(--muted);font-size:18px;cursor:pointer;padding:4px 6px}
|
||||
.dross-x:hover{color:var(--text)}
|
||||
.dross-log{flex:1;overflow:auto;padding:12px 12px;display:flex;flex-direction:column;gap:10px}
|
||||
.dross-inwrap{padding:10px;border-top:1px solid var(--border);background:#0d0a12;display:flex;flex-direction:column;gap:9px}
|
||||
.dross-inwrap textarea{width:100%;background:#0d0d13;border:1px solid var(--border);border-radius:10px;padding:10px 12px;color:var(--text);font-family:var(--font-mono);font-size:13px;resize:none;height:46px;max-height:96px}
|
||||
.dross-btnrow{display:flex;gap:10px}
|
||||
.dross-mic{flex:1;height:50px;border-radius:12px;border:1px solid var(--dross-dim);background:var(--dross-soft);color:var(--dross-glow);cursor:pointer;display:flex;align-items:center;justify-content:center;gap:9px;font-family:var(--font-ui);font-size:13px}
|
||||
.dross-mic[disabled]{opacity:.5;cursor:not-allowed}
|
||||
.dross-mic.rec{background:#3a1010;border-color:var(--accent);color:#fff;animation:dross-rec 1.2s infinite}
|
||||
@keyframes dross-rec{0%,100%{box-shadow:0 0 0 0 rgba(255,79,46,.5)}50%{box-shadow:0 0 0 8px rgba(255,79,46,0)}}
|
||||
.dross-send{width:64px;height:50px;border-radius:12px;border:1px solid var(--dross-dim);background:linear-gradient(180deg,var(--dross),var(--dross-dim));color:#fff;cursor:pointer;display:grid;place-items:center}
|
||||
.dross-collapse{display:flex;align-items:center;justify-content:center;gap:8px;height:34px;cursor:pointer;color:var(--muted);
|
||||
font-family:var(--font-ui);font-size:11px;letter-spacing:.12em;text-transform:uppercase;background:#0b0810;border-top:1px solid var(--border)}
|
||||
.dross-collapse:hover{color:var(--dross-glow)}
|
||||
.dross-collapse .grip{width:42px;height:4px;border-radius:3px;background:var(--border)}
|
||||
.dross-pick{display:flex;gap:12px;margin-bottom:12px;flex-wrap:wrap}
|
||||
.dross-avopt{display:flex;flex-direction:column;align-items:center;gap:6px;padding:10px 14px;border:1px solid var(--border);border-radius:12px;background:var(--panel);color:var(--muted);cursor:pointer;font-size:11px}
|
||||
.dross-avopt.on{border-color:var(--dross);color:var(--dross-glow);background:var(--dross-soft)}
|
||||
.dross-persona{width:100%;background:#0d0d13;border:1px solid var(--border);border-radius:8px;padding:10px;color:var(--text);font-family:var(--font-mono);font-size:12px;resize:vertical;margin:10px 0}
|
||||
.dross-clips{display:flex;flex-direction:column;gap:2px}
|
||||
.dross-clip{display:flex;align-items:center;gap:8px;font-size:12px;padding:4px 0;border-bottom:1px solid #ffffff08}
|
||||
.dross-clip-txt{flex:1;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.dross-clip audio{display:none}
|
||||
|
||||
/* voice 2.14.1: amplitude meter. .metered kills the keyframe pulse — CSS animations
|
||||
override normal declarations, so the old dross-rec box-shadow was masking the meter. */
|
||||
.dross-mic.rec.metered{animation:none;position:relative;
|
||||
box-shadow:0 0 0 calc(2px + 22px * var(--voicelevel, 0)) rgba(255,79,46,calc(0.15 + 0.5 * var(--voicelevel, 0)));
|
||||
transition:box-shadow 70ms linear}
|
||||
.dross-mic.rec.metered svg{transform:scale(calc(1 + 0.5 * var(--voicelevel, 0)));transition:transform 70ms linear}
|
||||
.dross-inwrap textarea{overflow-y:auto;max-height:120px;transition:height 120ms ease}
|
||||
.dross-inwrap textarea.flash{border-color:var(--dross-glow);box-shadow:0 0 0 2px var(--dross-soft)}
|
||||
|
||||
/* dross improvements (2.14) */
|
||||
.imp-row{display:flex;align-items:center;gap:12px;padding:9px 0;border-bottom:1px solid var(--border)}
|
||||
.imp-row:last-child{border-bottom:0}
|
||||
.imp-status{font-family:var(--font-mono);font-size:11px;white-space:nowrap;min-width:96px}
|
||||
.imp-status.s-active{color:var(--ok)}.imp-status.s-pending{color:var(--warn)}.imp-status.s-rolled_back{color:var(--muted)}.imp-status.s-rejected{color:var(--bad)}
|
||||
.imp-main{flex:1;min-width:0}
|
||||
.imp-actions{display:flex;gap:6px}
|
||||
button.sm{padding:4px 10px;font-size:12px}
|
||||
button.danger{border-color:var(--bad);color:var(--bad)}
|
||||
|
||||
77
public/theme.js
Normal file
77
public/theme.js
Normal file
@@ -0,0 +1,77 @@
|
||||
// Theming: a small map of palette-var overrides persisted in app_settings and
|
||||
// applied to :root on boot. The whole UI is CSS-custom-property driven, so
|
||||
// setting these vars recolours everything live. (Canvas-drawn colours — the
|
||||
// blackflame card — and a few inline rgba() literals don't follow the theme.)
|
||||
import { api } from './api.js';
|
||||
|
||||
export const THEME_VARS = [
|
||||
{ key: 'accent', css: '--accent', label: 'Accent (flame)' },
|
||||
{ key: 'accent-dim', css: '--accent-dim', label: 'Accent · dim' },
|
||||
{ key: 'accent-soft', css: '--accent-soft', label: 'Accent · soft' },
|
||||
{ key: 'bg', css: '--bg', label: 'Background' },
|
||||
{ key: 'panel', css: '--panel', label: 'Panel' },
|
||||
{ key: 'panel-2', css: '--panel-2', label: 'Panel · raised' },
|
||||
{ key: 'border', css: '--border', label: 'Border' },
|
||||
{ key: 'text', css: '--text', label: 'Text' },
|
||||
{ key: 'muted', css: '--muted', label: 'Muted text' },
|
||||
{ key: 'ok', css: '--ok', label: 'OK / good' },
|
||||
{ key: 'warn', css: '--warn', label: 'Warning' },
|
||||
{ key: 'bad', css: '--bad', label: 'Bad / error' }
|
||||
];
|
||||
const BY_KEY = Object.fromEntries(THEME_VARS.map(v => [v.key, v]));
|
||||
|
||||
// Named alternates. Blackflame = {} (clear overrides → CSS defaults).
|
||||
export const PRESETS = {
|
||||
Blackflame: {},
|
||||
Ember: { accent: '#ff7a1a', 'accent-dim': '#8a3a10', 'accent-soft': '#3a1a0a', bg: '#0c0907', panel: '#171008', 'panel-2': '#20160c' },
|
||||
Frost: { accent: '#4aa3ff', 'accent-dim': '#1e5a8a', 'accent-soft': '#0e2230', bg: '#070a0e', panel: '#0f141c', 'panel-2': '#161d28', ok: '#5fb0c4' },
|
||||
Verdant: { accent: '#5fc46a', 'accent-dim': '#2a6a30', 'accent-soft': '#10240f', bg: '#070b08', panel: '#0f160f', 'panel-2': '#161f16' },
|
||||
Amethyst: { accent: '#a86adf', 'accent-dim': '#5a2e8a', 'accent-soft': '#1e1030', bg: '#0a0810', panel: '#140f1c', 'panel-2': '#1c1528' }
|
||||
};
|
||||
|
||||
export function applyTheme(vars = {}) {
|
||||
const root = document.documentElement;
|
||||
for (const [k, val] of Object.entries(vars)) {
|
||||
const def = BY_KEY[k];
|
||||
if (def && val) root.style.setProperty(def.css, val);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearTheme() {
|
||||
const root = document.documentElement;
|
||||
for (const v of THEME_VARS) root.style.removeProperty(v.css);
|
||||
}
|
||||
|
||||
// Current effective value of a var (override or CSS default), normalised to #rrggbb.
|
||||
export function effectiveHex(key) {
|
||||
const def = BY_KEY[key];
|
||||
if (!def) return '#000000';
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue(def.css).trim();
|
||||
return toHex6(raw) || '#000000';
|
||||
}
|
||||
|
||||
export function toHex6(v) {
|
||||
if (!v) return '';
|
||||
v = v.trim();
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(v)) return v.toLowerCase();
|
||||
if (/^#[0-9a-fA-F]{8}$/.test(v)) return v.slice(0, 7).toLowerCase(); // drop alpha
|
||||
if (/^#[0-9a-fA-F]{3}$/.test(v)) return '#' + v.slice(1).split('').map(c => c + c).join('').toLowerCase();
|
||||
const m = v.match(/rgba?\(\s*(\d+)\D+(\d+)\D+(\d+)/i);
|
||||
if (m) return '#' + [m[1], m[2], m[3]].map(n => (+n).toString(16).padStart(2, '0')).join('');
|
||||
return '';
|
||||
}
|
||||
|
||||
let current = {};
|
||||
export function currentTheme() { return { ...current }; }
|
||||
|
||||
export async function loadTheme() {
|
||||
try { current = (await api.get('/api/theme')) || {}; applyTheme(current); }
|
||||
catch { /* defaults */ }
|
||||
return current;
|
||||
}
|
||||
|
||||
export async function saveTheme(vars) {
|
||||
current = (await api.put('/api/theme', vars)) || {};
|
||||
clearTheme(); applyTheme(current);
|
||||
return current;
|
||||
}
|
||||
114
public/views/cards/blackflame.js
Normal file
114
public/views/cards/blackflame.js
Normal file
@@ -0,0 +1,114 @@
|
||||
// Decorative blackflame card — the animated centrepiece (dark heart, crimson
|
||||
// licks, rising embers). Canvas particle flame ported from the approved mock.
|
||||
// Instanceable via the factory; stop() tears down the rAF loop + observer.
|
||||
import { el } from '../../dom.js';
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
|
||||
function startFlame(canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
let W = 0, H = 0, DPR = 1, raf = 0, t = 0;
|
||||
|
||||
function resize() {
|
||||
DPR = Math.min(2, window.devicePixelRatio || 1);
|
||||
const r = canvas.getBoundingClientRect();
|
||||
W = r.width; H = r.height;
|
||||
canvas.width = Math.max(1, Math.round(W * DPR));
|
||||
canvas.height = Math.max(1, Math.round(H * DPR));
|
||||
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
||||
}
|
||||
|
||||
const tongues = Array.from({ length: 5 }, (_, i) => ({
|
||||
phase: Math.random() * TAU, speed: 0.6 + Math.random() * 0.5,
|
||||
x: 0.5 + (i - 2) * 0.085, sway: 0.03 + Math.random() * 0.04, w: 0.34 - Math.abs(i - 2) * 0.05
|
||||
}));
|
||||
const spawn = () => ({
|
||||
x: 0.5 + (Math.random() - 0.5) * 0.5, y: 1 + Math.random() * 0.2,
|
||||
vy: 0.0016 + Math.random() * 0.0030, vx: (Math.random() - 0.5) * 0.0012,
|
||||
r: 0.6 + Math.random() * 1.8, life: 0, max: 120 + Math.random() * 120, hot: Math.random() < 0.5
|
||||
});
|
||||
const embers = Array.from({ length: 46 }, spawn);
|
||||
|
||||
function blob(x, y, r, c0, c1) {
|
||||
if (r <= 0) return;
|
||||
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
|
||||
g.addColorStop(0, c0); g.addColorStop(1, c1);
|
||||
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, r, 0, TAU); ctx.fill();
|
||||
}
|
||||
|
||||
function frame() {
|
||||
t += 0.016;
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
const baseY = H * 0.92;
|
||||
for (const tg of tongues) {
|
||||
const cx = W * (tg.x + Math.sin(t * tg.speed + tg.phase) * tg.sway);
|
||||
const height = H * (0.62 + 0.08 * Math.sin(t * 1.7 + tg.phase));
|
||||
const steps = 26;
|
||||
for (let s = 0; s < steps; s++) {
|
||||
const f = s / steps;
|
||||
const y = baseY - f * height;
|
||||
const flick = Math.sin(t * 3.2 + tg.phase + f * 7) * W * 0.012 * (0.4 + f);
|
||||
const x = cx + flick + Math.sin(t * tg.speed * 1.3 + f * 4) * W * tg.sway * 0.6;
|
||||
const r = W * tg.w * (1 - f * 0.78) * (0.9 + 0.1 * Math.sin(t * 5 + f * 9));
|
||||
const a = (1 - f) * 0.5;
|
||||
if (f < 0.18) blob(x, y, r * 1.1, `rgba(60,12,6,${a * 0.7})`, 'rgba(60,12,6,0)');
|
||||
else if (f < 0.62) blob(x, y, r, `rgba(255,79,46,${a * 0.5})`, 'rgba(122,39,22,0)');
|
||||
else blob(x, y, r * 0.8, `rgba(255,150,90,${a * 0.5})`, 'rgba(255,79,46,0)');
|
||||
}
|
||||
}
|
||||
|
||||
for (const e of embers) {
|
||||
e.life++; e.y -= e.vy; e.x += e.vx + Math.sin(t * 2 + e.y * 8) * 0.0004;
|
||||
if (e.y < 0.18 || e.life > e.max) Object.assign(e, spawn());
|
||||
const px = e.x * W, py = e.y * H, fade = 1 - e.life / e.max;
|
||||
const col = e.hot ? `rgba(255,170,110,${fade * 0.9})` : `rgba(255,79,46,${fade * 0.8})`;
|
||||
ctx.shadowBlur = 8; ctx.shadowColor = '#ff4f2e';
|
||||
blob(px, py, e.r * 1.6, col, 'rgba(255,79,46,0)');
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// carve the dark heart back in (the "black" of black-flame)
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
const cx = W * 0.5, cy = H * 0.46, cr = W * 0.20;
|
||||
const core = ctx.createRadialGradient(cx, cy, 0, cx, cy, cr);
|
||||
core.addColorStop(0, 'rgba(4,4,8,0.96)');
|
||||
core.addColorStop(0.55, 'rgba(6,5,10,0.7)');
|
||||
core.addColorStop(1, 'rgba(8,7,12,0)');
|
||||
ctx.fillStyle = core; ctx.beginPath(); ctx.arc(cx, cy, cr, 0, TAU); ctx.fill();
|
||||
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
blob(W * 0.5, H * 0.95, W * 0.4, 'rgba(255,79,46,0.10)', 'rgba(255,79,46,0)');
|
||||
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
resize();
|
||||
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null;
|
||||
ro?.observe(canvas);
|
||||
raf = requestAnimationFrame(frame);
|
||||
return () => { cancelAnimationFrame(raf); ro?.disconnect(); };
|
||||
}
|
||||
|
||||
export function blackflameCard(id) {
|
||||
let teardown = null;
|
||||
return {
|
||||
id,
|
||||
type: 'blackflame',
|
||||
title: '',
|
||||
decorative: true,
|
||||
size: 'l',
|
||||
mount(body) {
|
||||
const canvas = el('canvas', { class: 'sv-flame-canvas' });
|
||||
const crest = el('div', { class: 'sv-flame-crest' });
|
||||
const label = el('div', { class: 'sv-flame-label' }, 'The Void');
|
||||
body.append(canvas, crest, label);
|
||||
// defer one frame so the canvas has a measured size
|
||||
requestAnimationFrame(() => { teardown = startFlame(canvas); });
|
||||
},
|
||||
start() {},
|
||||
stop() { teardown && teardown(); teardown = null; }
|
||||
};
|
||||
}
|
||||
16
public/views/cards/blank.js
Normal file
16
public/views/cards/blank.js
Normal file
@@ -0,0 +1,16 @@
|
||||
// A decorative blank spacer card — deliberate empty space / grouping on the
|
||||
// Sacred Valley canvas. Instanceable: each gets a unique id via the factory.
|
||||
import { el } from '../../dom.js';
|
||||
|
||||
export function blankCard(id) {
|
||||
return {
|
||||
id,
|
||||
type: 'blank',
|
||||
title: '',
|
||||
decorative: true,
|
||||
size: 'm',
|
||||
mount(body) { body.appendChild(el('div', { class: 'sv-blank' })); },
|
||||
start() {},
|
||||
stop() {}
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// public/views/cards/speedtest.js
|
||||
// public/views/cards/speedtest.js — at-a-glance summary; full history at #/speedtest
|
||||
import { el, mount } from '../../dom.js';
|
||||
import { api } from '../../api.js';
|
||||
|
||||
@@ -6,17 +6,21 @@ let body;
|
||||
async function load() {
|
||||
if (!body) return;
|
||||
try {
|
||||
const hist = await api.get('/api/speedtest/history');
|
||||
const hist = (await api.get('/api/speedtest/history?limit=30')).filter(h => h.ok !== false);
|
||||
const latest = hist[0];
|
||||
const max = Math.max(1, ...hist.map(h => Number(h.down_mbps)));
|
||||
const bars = el('div', { style: { display: 'flex', gap: '2px', alignItems: 'flex-end', height: '40px', marginTop: '8px' } },
|
||||
const bars = el('div', { style: { display: 'flex', gap: '2px', alignItems: 'flex-end', height: '38px', marginTop: '8px' } },
|
||||
hist.slice(0, 30).reverse().map(h =>
|
||||
el('div', { style: { flex: '1', background: 'var(--accent-dim)',
|
||||
height: (Number(h.down_mbps) / max * 100) + '%' } })));
|
||||
mount(body,
|
||||
el('div', { class: 'sv-row', style: { fontSize: '20px' } },
|
||||
el('span', { style: { fontFamily: 'var(--font-mono)' } }, latest ? `${Number(latest.down_mbps).toFixed(0)}↓ ${Number(latest.up_mbps).toFixed(0)}↑` : '—'),
|
||||
el('span', { style: { fontFamily: 'var(--font-mono)' } },
|
||||
latest ? `${Number(latest.down_mbps).toFixed(0)}↓ ${Number(latest.up_mbps).toFixed(0)}↑` : '—'),
|
||||
el('button', { class: 'sv-run', onclick: runNow }, 'Run')),
|
||||
latest ? el('div', { class: 'sv-row', style: { fontSize: '11px' } },
|
||||
el('span', { class: 'k' }, `ping ${latest.ping_ms == null ? '—' : Number(latest.ping_ms).toFixed(0)} ms · jitter ${latest.jitter_ms == null ? '—' : Number(latest.jitter_ms).toFixed(1)}`),
|
||||
el('a', { href: '#/speedtest', class: 'sv-link' }, 'history ↗')) : null,
|
||||
bars);
|
||||
} catch { mount(body, el('span', { class: 'muted' }, 'No speedtest data')); }
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ import { api } from '../api.js';
|
||||
import { renderHealthBand, stopHealthBand } from './health_band.js';
|
||||
import { renderDevicesBand, stopDevicesBand } from './devices_band.js';
|
||||
import { svCard } from '../components/sv_card.js';
|
||||
import { moveId } from '../components/sv_reorder.js';
|
||||
import { orderCards } from './cards/registry.js';
|
||||
import clock from './cards/clock.js';
|
||||
import weather from './cards/weather.js';
|
||||
import hostPerf from './cards/host_perf.js';
|
||||
@@ -16,146 +14,228 @@ import aiUsage from './cards/ai_usage.js';
|
||||
import cluster from './cards/cluster.js';
|
||||
import storage from './cards/storage.js';
|
||||
import backups from './cards/backups.js';
|
||||
import { blankCard } from './cards/blank.js';
|
||||
import { blackflameCard } from './cards/blackflame.js';
|
||||
|
||||
const CARD_MODULES = [clock, weather, hostPerf, cluster, storage, backups, jobs, inbox, search, speedtest, aiUsage];
|
||||
const BY_ID = new Map(CARD_MODULES.map(d => [d.id, d]));
|
||||
const BUILTIN_BY_ID = new Map(CARD_MODULES.map(d => [d.id, d]));
|
||||
|
||||
// ---- hybrid canvas geometry ----
|
||||
// Cards are absolutely placed on a 12-column grid. {x,y,w,h} are in grid units
|
||||
// (x,w in columns; y,h in rows of ROW_H px). Snap mode keeps them integer; a
|
||||
// per-card `free` flag (or holding Alt while dragging) allows fractional
|
||||
// placement + overlap. Everything scales with board width, so x/w stay relative.
|
||||
const COLS = 12;
|
||||
const ROW_H = 28; // px per grid row
|
||||
const GUTTER = 12; // visual gap baked into each card's rendered size
|
||||
const SIZE_W = { s: 3, m: 4, l: 6 };
|
||||
const SIZE_H = { s: 6, m: 8, l: 10 };
|
||||
|
||||
let active = []; // mounted cards needing stop()
|
||||
let renderGen = 0; // guards overlapping async renders
|
||||
let editing = false;
|
||||
let layout = { card_order: [], hidden: [], sizes: {} };
|
||||
let mainEl;
|
||||
let layout = { hidden: [], geom: {}, extras: [] };
|
||||
|
||||
const grid = () => document.getElementById('sv-cards');
|
||||
|
||||
// ---- masonry packing: cards keep their column span (width) but pack vertically by
|
||||
// content height (via grid-row span over small auto-rows), so mismatched heights no
|
||||
// longer leave gaps / rigid rows. ResizeObserver re-packs as async cards fill in.
|
||||
const ROW_UNIT = 8, GRID_GAP = 16;
|
||||
function packCard(node) {
|
||||
if (!node || !node.isConnected) return;
|
||||
const h = node.getBoundingClientRect().height;
|
||||
if (h) node.style.gridRowEnd = 'span ' + Math.max(1, Math.ceil((h + GRID_GAP) / (ROW_UNIT + GRID_GAP)));
|
||||
function defFor(extra) {
|
||||
if (extra.type === 'blank') return blankCard(extra.id);
|
||||
if (extra.type === 'blackflame') return blackflameCard(extra.id);
|
||||
return null;
|
||||
}
|
||||
const ro = typeof ResizeObserver !== 'undefined'
|
||||
? new ResizeObserver(entries => entries.forEach(e => packCard(e.target))) : null;
|
||||
let repackRaf;
|
||||
function repackAll() {
|
||||
cancelAnimationFrame(repackRaf);
|
||||
repackRaf = requestAnimationFrame(() => grid()?.querySelectorAll('.sv-card').forEach(packCard));
|
||||
|
||||
function visibleDefs() {
|
||||
const hidden = new Set(layout.hidden || []);
|
||||
const builtins = CARD_MODULES.filter(d => !hidden.has(d.id));
|
||||
const extras = (layout.extras || []).map(defFor).filter(Boolean);
|
||||
return [...builtins, ...extras];
|
||||
}
|
||||
|
||||
function defaultSize(def) {
|
||||
if (def.type === 'blackflame') return { w: 6, h: 10 };
|
||||
if (def.type === 'blank') return { w: 3, h: 4 };
|
||||
return { w: SIZE_W[def.size] || 4, h: SIZE_H[def.size] || 8 };
|
||||
}
|
||||
|
||||
function geomOf(def) {
|
||||
const g = (layout.geom || {})[def.id];
|
||||
if (g) return g;
|
||||
const { w, h } = defaultSize(def);
|
||||
return { x: 0, y: 0, w, h };
|
||||
}
|
||||
if (typeof window !== 'undefined') window.addEventListener('resize', repackAll);
|
||||
|
||||
async function saveLayout() {
|
||||
try { await api.put('/api/dashboard/layout', layout); }
|
||||
catch (e) { console.error('save layout', e); }
|
||||
try {
|
||||
await api.put('/api/dashboard/layout', {
|
||||
card_order: [], sizes: {},
|
||||
hidden: layout.hidden || [], geom: layout.geom || {}, extras: layout.extras || []
|
||||
});
|
||||
} catch (e) { console.error('save layout', e); }
|
||||
}
|
||||
|
||||
// ---- per-card edit controls (drag grip + size + hide), shown only in edit mode via CSS
|
||||
const STR_SPAN = { s: 2, m: 6, l: 12 }; // legacy size → 12-col span (s = 1/6, m = 1/2, l = full)
|
||||
function spanOf(def) {
|
||||
const v = layout.sizes?.[def.id];
|
||||
if (typeof v === 'number') return Math.max(1, Math.min(12, v));
|
||||
if (typeof v === 'string') return STR_SPAN[v] || 6;
|
||||
return STR_SPAN[def.size] || 6;
|
||||
}
|
||||
function curSpan(id) {
|
||||
const node = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
|
||||
const m = node && (node.style.gridColumn || '').match(/span (\d+)/);
|
||||
return m ? +m[1] : spanOf(BY_ID.get(id) || {});
|
||||
}
|
||||
function setSpan(id, delta) {
|
||||
const span = Math.max(1, Math.min(12, curSpan(id) + delta));
|
||||
layout.sizes = { ...layout.sizes, [id]: span };
|
||||
const node = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
|
||||
if (node) {
|
||||
node.style.gridColumn = 'span ' + span;
|
||||
const lbl = node.querySelector('.sv-span-val');
|
||||
if (lbl) lbl.textContent = span;
|
||||
// Backfill geometry for any visible card lacking it (first load / migration /
|
||||
// a newly-shipped built-in). New cards flow below whatever already has a spot.
|
||||
function autoPlaceMissing(defs) {
|
||||
const geom = { ...(layout.geom || {}) };
|
||||
let baseY = 0;
|
||||
for (const id in geom) baseY = Math.max(baseY, geom[id].y + geom[id].h);
|
||||
let cx = 0, cy = baseY, rowH = 0;
|
||||
for (const d of defs) {
|
||||
if (geom[d.id]) continue;
|
||||
const { w, h } = defaultSize(d);
|
||||
if (cx + w > COLS) { cx = 0; cy += rowH; rowH = 0; }
|
||||
geom[d.id] = { x: cx, y: cy, w, h };
|
||||
cx += w; rowH = Math.max(rowH, h);
|
||||
}
|
||||
layout.geom = geom;
|
||||
}
|
||||
|
||||
function cellW() { return grid().clientWidth / COLS; }
|
||||
|
||||
function applyGeom(node, g) {
|
||||
const cw = cellW();
|
||||
node.style.position = 'absolute';
|
||||
node.style.left = (g.x * cw) + 'px';
|
||||
node.style.top = (g.y * ROW_H) + 'px';
|
||||
node.style.width = Math.max(40, g.w * cw - GUTTER) + 'px';
|
||||
node.style.height = Math.max(40, g.h * ROW_H - GUTTER) + 'px';
|
||||
node.style.zIndex = g.free ? 5 : 1;
|
||||
node.classList.toggle('free', !!g.free);
|
||||
}
|
||||
|
||||
function fitBoard() {
|
||||
let max = 0;
|
||||
grid().querySelectorAll('.sv-card').forEach(n => { max = Math.max(max, n.offsetTop + n.offsetHeight); });
|
||||
grid().style.height = (max + GUTTER) + 'px';
|
||||
}
|
||||
|
||||
function relayout() {
|
||||
active.forEach(def => {
|
||||
const n = grid().querySelector(`.sv-card[data-card-id="${def.id}"]`);
|
||||
if (n) applyGeom(n, geomOf(def));
|
||||
});
|
||||
fitBoard();
|
||||
}
|
||||
|
||||
// ---- drag / resize (pointer-based; snap unless free or Alt held) ----
|
||||
function beginDrag(ev, def, mode) {
|
||||
if (!editing) return;
|
||||
ev.preventDefault(); ev.stopPropagation();
|
||||
const node = grid().querySelector(`.sv-card[data-card-id="${def.id}"]`);
|
||||
if (!node) return;
|
||||
const g = { ...geomOf(def) };
|
||||
const start = { px: ev.clientX, py: ev.clientY, x: g.x, y: g.y, w: g.w, h: g.h };
|
||||
node.classList.add('dragging');
|
||||
const cw = cellW();
|
||||
|
||||
function moveTo(e) {
|
||||
const dxc = (e.clientX - start.px) / cw;
|
||||
const dyc = (e.clientY - start.py) / ROW_H;
|
||||
const freeNow = g.free || e.altKey;
|
||||
if (mode === 'move') {
|
||||
let nx = start.x + dxc, ny = start.y + dyc;
|
||||
if (!freeNow) { nx = Math.round(nx); ny = Math.round(ny); }
|
||||
g.x = Math.max(0, Math.min(COLS - g.w, nx));
|
||||
g.y = Math.max(0, ny);
|
||||
} else {
|
||||
let nw = start.w + dxc, nh = start.h + dyc;
|
||||
if (!freeNow) { nw = Math.round(nw); nh = Math.round(nh); }
|
||||
g.w = Math.max(2, Math.min(COLS - g.x, nw));
|
||||
g.h = Math.max(2, nh);
|
||||
}
|
||||
applyGeom(node, g); fitBoard();
|
||||
}
|
||||
function end() {
|
||||
document.removeEventListener('pointermove', moveTo);
|
||||
document.removeEventListener('pointerup', end);
|
||||
node.classList.remove('dragging');
|
||||
layout.geom = { ...layout.geom, [def.id]: g };
|
||||
applyGeom(node, g);
|
||||
saveLayout();
|
||||
}
|
||||
document.addEventListener('pointermove', moveTo);
|
||||
document.addEventListener('pointerup', end);
|
||||
}
|
||||
|
||||
function toggleFree(id) {
|
||||
const g = { ...(layout.geom[id] || geomOf({ id })) };
|
||||
g.free = !g.free;
|
||||
layout.geom = { ...layout.geom, [id]: g };
|
||||
const n = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
|
||||
if (n) applyGeom(n, g);
|
||||
saveLayout();
|
||||
}
|
||||
|
||||
function editOverlay(def) {
|
||||
const grip = el('span', { class: 'sv-grip', draggable: true, title: 'Drag to reorder' }, '⠿');
|
||||
const stepper = el('span', { class: 'sv-ed-span' },
|
||||
el('button', { class: 'sv-ed-step', title: 'Narrower', onclick: () => setSpan(def.id, -1) }, '−'),
|
||||
el('span', { class: 'sv-span-val', title: 'Width (of 12)' }, String(spanOf(def))),
|
||||
el('button', { class: 'sv-ed-step', title: 'Wider', onclick: () => setSpan(def.id, +1) }, '+'));
|
||||
const hide = el('button', { class: 'sv-ed-hide', title: 'Hide card', onclick: () => hideCard(def.id) }, '✕');
|
||||
return el('div', { class: 'sv-card-edit' }, grip, stepper, hide);
|
||||
const grip = el('span', { class: 'sv-grip', title: 'Drag to move' }, '⠿');
|
||||
grip.addEventListener('pointerdown', e => beginDrag(e, def, 'move'));
|
||||
const free = el('button', { class: 'sv-ed-free', title: 'Free / snap placement', onclick: () => toggleFree(def.id) }, '⤢');
|
||||
const hide = el('button', { class: 'sv-ed-hide', title: def.decorative ? 'Delete card' : 'Hide card', onclick: () => removeCard(def) }, '✕');
|
||||
const resize = el('span', { class: 'sv-resize', title: 'Drag to resize' });
|
||||
resize.addEventListener('pointerdown', e => beginDrag(e, def, 'resize'));
|
||||
const frag = document.createDocumentFragment();
|
||||
frag.append(el('div', { class: 'sv-card-edit' }, grip, free, hide), resize);
|
||||
return frag;
|
||||
}
|
||||
|
||||
function mountOne(def) {
|
||||
const span = spanOf(def);
|
||||
const { root, body } = svCard({ ...def, span });
|
||||
const { root, body } = svCard(def);
|
||||
if (def.decorative) root.classList.add('sv-card-decor');
|
||||
root.appendChild(editOverlay(def));
|
||||
applyGeom(root, geomOf(def));
|
||||
grid().appendChild(root);
|
||||
ro?.observe(root); packCard(root);
|
||||
try { def.mount(body); def.start && def.start(); active.push(def); }
|
||||
catch (e) { body.appendChild(el('span', { class: 'muted' }, 'card failed')); console.error(def.id, e); }
|
||||
}
|
||||
|
||||
function setSize(id, s) {
|
||||
layout.sizes = { ...layout.sizes, [id]: s };
|
||||
const node = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
|
||||
if (node) node.dataset.size = s;
|
||||
saveLayout();
|
||||
function placeNew(def) {
|
||||
let maxBottom = 0;
|
||||
for (const id in layout.geom) { const g = layout.geom[id]; maxBottom = Math.max(maxBottom, g.y + g.h); }
|
||||
const { w, h } = defaultSize(def);
|
||||
layout.geom = { ...layout.geom, [def.id]: { x: 0, y: maxBottom, w, h } };
|
||||
}
|
||||
|
||||
function hideCard(id) {
|
||||
if (!layout.hidden.includes(id)) layout.hidden = [...layout.hidden, id];
|
||||
const def = BY_ID.get(id);
|
||||
if (def?.stop) def.stop();
|
||||
active = active.filter(d => d.id !== id);
|
||||
grid().querySelector(`.sv-card[data-card-id="${id}"]`)?.remove();
|
||||
renderTray();
|
||||
saveLayout();
|
||||
function addBuiltin(id) {
|
||||
layout.hidden = (layout.hidden || []).filter(x => x !== id);
|
||||
const def = BUILTIN_BY_ID.get(id);
|
||||
if (!def) return;
|
||||
placeNew(def); mountOne(def); relayout(); renderTray(); saveLayout();
|
||||
}
|
||||
|
||||
function showCard(id) {
|
||||
layout.hidden = layout.hidden.filter(x => x !== id);
|
||||
const def = BY_ID.get(id);
|
||||
if (def) { mountOne(def); wireDrag(); }
|
||||
renderTray();
|
||||
saveLayout();
|
||||
function addDecor(type) {
|
||||
const id = `${type}-${Date.now().toString(36)}`;
|
||||
const def = defFor({ id, type });
|
||||
if (!def) return;
|
||||
layout.extras = [...(layout.extras || []), { id, type }];
|
||||
placeNew(def); mountOne(def); relayout(); renderTray(); saveLayout();
|
||||
}
|
||||
|
||||
function onReorder(newOrder) {
|
||||
const frag = document.createDocumentFragment();
|
||||
newOrder.forEach(id => { const n = grid().querySelector(`.sv-card[data-card-id="${id}"]`); if (n) frag.appendChild(n); });
|
||||
grid().appendChild(frag);
|
||||
layout.card_order = newOrder;
|
||||
saveLayout();
|
||||
}
|
||||
|
||||
// Drag from the grip only; the rest of the card is inert so the search box etc. work.
|
||||
function wireDrag() {
|
||||
let dragId = null;
|
||||
grid().querySelectorAll('.sv-card').forEach(card => {
|
||||
if (card._wired) return; card._wired = true;
|
||||
const g = card.querySelector('.sv-grip');
|
||||
if (g) {
|
||||
g.addEventListener('dragstart', (e) => { dragId = card.dataset.cardId; card.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; });
|
||||
g.addEventListener('dragend', () => { card.classList.remove('dragging'); dragId = null; });
|
||||
}
|
||||
card.addEventListener('dragover', (e) => { if (dragId) { e.preventDefault(); card.classList.add('drag-over'); } });
|
||||
card.addEventListener('dragleave', () => card.classList.remove('drag-over'));
|
||||
card.addEventListener('drop', (e) => {
|
||||
e.preventDefault(); card.classList.remove('drag-over');
|
||||
if (!dragId || dragId === card.dataset.cardId) return;
|
||||
const ids = [...grid().querySelectorAll('.sv-card')].map(c => c.dataset.cardId);
|
||||
onReorder(moveId(ids, dragId, card.dataset.cardId));
|
||||
});
|
||||
});
|
||||
function removeCard(def) {
|
||||
const d = active.find(a => a.id === def.id);
|
||||
if (d && d.stop) d.stop();
|
||||
active = active.filter(a => a.id !== def.id);
|
||||
grid().querySelector(`.sv-card[data-card-id="${def.id}"]`)?.remove();
|
||||
if (def.decorative) {
|
||||
layout.extras = (layout.extras || []).filter(e => e.id !== def.id);
|
||||
const g = { ...layout.geom }; delete g[def.id]; layout.geom = g;
|
||||
} else if (!(layout.hidden || []).includes(def.id)) {
|
||||
layout.hidden = [...(layout.hidden || []), def.id];
|
||||
}
|
||||
renderTray(); fitBoard(); saveLayout();
|
||||
}
|
||||
|
||||
function renderTray() {
|
||||
const tray = document.getElementById('sv-tray');
|
||||
if (!tray) return;
|
||||
const hidden = layout.hidden.map(id => BY_ID.get(id)).filter(Boolean);
|
||||
const hidden = (layout.hidden || []).map(id => BUILTIN_BY_ID.get(id)).filter(Boolean);
|
||||
mount(tray,
|
||||
hidden.length ? el('span', { class: 'sv-tray-label' }, 'Hidden:') : el('span', { class: 'muted' }, 'No hidden cards'),
|
||||
...hidden.map(def => el('button', { class: 'sv-tray-chip', onclick: () => showCard(def.id) }, '+ ' + def.title)));
|
||||
el('span', { class: 'sv-tray-label' }, 'Add card:'),
|
||||
el('button', { class: 'sv-tray-chip', onclick: () => addDecor('blank') }, '+ Blank'),
|
||||
el('button', { class: 'sv-tray-chip', onclick: () => addDecor('blackflame') }, '+ Blackflame'),
|
||||
hidden.length ? el('span', { class: 'sv-tray-label', style: { marginLeft: '8px' } }, 'Restore:') : null,
|
||||
...hidden.map(def => el('button', { class: 'sv-tray-chip', onclick: () => addBuiltin(def.id) }, '+ ' + def.title)),
|
||||
el('span', { class: 'sv-tray-hint muted' }, 'drag ⠿ to move · corner to resize · ⤢ = free/overlap · Alt = no-snap'));
|
||||
tray.style.display = editing ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
@@ -169,9 +249,8 @@ function toggleEdit() {
|
||||
renderTray();
|
||||
}
|
||||
|
||||
let mainEl;
|
||||
async function resetLayout() {
|
||||
layout = { card_order: [], hidden: [], sizes: {} };
|
||||
layout = { hidden: [], geom: {}, extras: [] };
|
||||
await saveLayout();
|
||||
render(mainEl);
|
||||
}
|
||||
@@ -179,7 +258,7 @@ async function resetLayout() {
|
||||
export async function render(main) {
|
||||
mainEl = main;
|
||||
const myGen = ++renderGen;
|
||||
active.forEach(c => c.stop && c.stop()); active = []; ro?.disconnect(); stopHealthBand(); stopDevicesBand();
|
||||
active.forEach(c => c.stop && c.stop()); active = []; stopHealthBand(); stopDevicesBand();
|
||||
editing = false;
|
||||
mount(main,
|
||||
el('h1', { class: 'view-h1' }, 'Sacred Valley'),
|
||||
@@ -194,12 +273,21 @@ export async function render(main) {
|
||||
el('div', { id: 'sv-devices' })
|
||||
);
|
||||
|
||||
layout = { card_order: [], hidden: [], sizes: {} };
|
||||
try { layout = await api.get('/api/dashboard/layout'); } catch { /* defaults */ }
|
||||
layout = { hidden: [], geom: {}, extras: [] };
|
||||
try {
|
||||
const l = await api.get('/api/dashboard/layout');
|
||||
layout = { hidden: l.hidden || [], geom: l.geom || {}, extras: l.extras || [] };
|
||||
} catch { /* defaults */ }
|
||||
if (myGen !== renderGen) return;
|
||||
|
||||
for (const def of orderCards(CARD_MODULES, layout)) mountOne(def);
|
||||
wireDrag();
|
||||
const defs = visibleDefs();
|
||||
autoPlaceMissing(defs);
|
||||
for (const def of defs) mountOne(def);
|
||||
relayout();
|
||||
renderTray();
|
||||
window.removeEventListener('resize', relayout);
|
||||
window.addEventListener('resize', relayout);
|
||||
|
||||
renderHealthBand(document.getElementById('sv-health'));
|
||||
renderDevicesBand(document.getElementById('sv-devices'));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,58 @@
|
||||
import { el, mount } from '../dom.js';
|
||||
import { api } from '../api.js';
|
||||
import { iconSetsPanel } from './icon_sets_panel.js';
|
||||
import { THEME_VARS, PRESETS, applyTheme, clearTheme, saveTheme, currentTheme, effectiveHex, toHex6 } from '../theme.js';
|
||||
import { drossAvatar } from '../components/dross_avatar.js';
|
||||
|
||||
// Theming — colour pickers for the palette, live-preview on input, presets +
|
||||
// reset. Persists to /api/theme (app_settings); applied app-wide on next boot.
|
||||
function themingBody() {
|
||||
const cur = currentTheme(); // saved overrides (subset of vars)
|
||||
const grid = el('div', { class: 'theme-grid' });
|
||||
const out = el('span', { class: 'muted', style: { marginLeft: '8px' } });
|
||||
|
||||
function rebuild() {
|
||||
mount(grid, THEME_VARS.map(v => {
|
||||
const inp = el('input', { type: 'color', value: cur[v.key] ? toHex6(cur[v.key]) : effectiveHex(v.key) });
|
||||
inp.addEventListener('input', () => {
|
||||
cur[v.key] = inp.value;
|
||||
document.documentElement.style.setProperty(v.css, inp.value); // live preview
|
||||
});
|
||||
return el('label', { class: 'theme-row' }, el('span', {}, v.label), inp);
|
||||
}));
|
||||
}
|
||||
rebuild();
|
||||
|
||||
const preset = el('select', { class: 'pm-input', style: { maxWidth: '160px' } },
|
||||
el('option', { value: '' }, 'Apply preset…'),
|
||||
...Object.keys(PRESETS).map(n => el('option', { value: n }, n)));
|
||||
preset.addEventListener('change', () => {
|
||||
if (!preset.value) return;
|
||||
clearTheme();
|
||||
for (const k of Object.keys(cur)) delete cur[k];
|
||||
Object.assign(cur, PRESETS[preset.value]);
|
||||
applyTheme(cur);
|
||||
rebuild();
|
||||
preset.value = '';
|
||||
});
|
||||
|
||||
const save = el('button', { class: 'primary' }, 'Save theme');
|
||||
save.onclick = async () => {
|
||||
try { await saveTheme(cur); out.textContent = 'Saved — applies everywhere.'; }
|
||||
catch { out.textContent = 'Save failed'; }
|
||||
};
|
||||
const reset = el('button', { class: 'ghost' }, 'Reset to Blackflame');
|
||||
reset.onclick = async () => {
|
||||
for (const k of Object.keys(cur)) delete cur[k];
|
||||
clearTheme();
|
||||
try { await saveTheme({}); rebuild(); out.textContent = 'Reset to default.'; }
|
||||
catch { out.textContent = 'Reset failed'; }
|
||||
};
|
||||
|
||||
return el('div', { class: 'settings-body' },
|
||||
grid,
|
||||
el('div', { class: 'theme-actions' }, preset, save, reset, out));
|
||||
}
|
||||
|
||||
function section(title, sub, bodyEl) {
|
||||
return el('div', { class: 'card settings-card' },
|
||||
@@ -97,6 +149,79 @@ async function renderAgents(c) {
|
||||
}
|
||||
}
|
||||
|
||||
function drossBody() {
|
||||
const out = el('span', { class: 'muted', style: { marginLeft: '8px' } });
|
||||
let cur = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review', keepClips: false };
|
||||
const avatarRow = el('div', { class: 'dross-pick' });
|
||||
const accent = el('input', { type: 'color', value: cur.accent });
|
||||
const persona = el('textarea', { class: 'dross-persona', rows: 6, placeholder: "Dross's system prompt…" });
|
||||
const mode = el('select', { class: 'pm-input', style: { maxWidth: '200px' } },
|
||||
el('option', { value: 'review' }, 'Voice: review then send'),
|
||||
el('option', { value: 'handsfree' }, 'Voice: hands-free (Phase 2)'),
|
||||
el('option', { value: 'action' }, 'Voice: interpret to action (later)'));
|
||||
const keep = el('input', { type: 'checkbox' });
|
||||
const clipsWrap = el('div', { class: 'dross-clips' });
|
||||
|
||||
function paintAvatars() {
|
||||
mount(avatarRow, ['soft-eye', 'wisp', 'motes'].map(v => {
|
||||
const card = el('button', { class: 'dross-avopt' + (cur.avatar === v ? ' on' : ''), title: v },
|
||||
drossAvatar(v, 48), el('span', {}, v));
|
||||
card.style.setProperty('--dross', cur.accent);
|
||||
card.onclick = () => { cur.avatar = v; paintAvatars(); };
|
||||
return card;
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadClips() {
|
||||
if (!keep.checked) { mount(clipsWrap); return; }
|
||||
let rows = [];
|
||||
try { rows = await api.get('/api/voice/clips'); } catch { mount(clipsWrap, el('span', { class: 'muted' }, 'Clips unavailable')); return; }
|
||||
mount(clipsWrap, el('div', { class: 'st-lbl', style: { margin: '10px 0 4px' } }, `Saved clips (${rows.length})`),
|
||||
...rows.map(c => {
|
||||
const audio = el('audio');
|
||||
const play = el('button', { class: 'ghost' }, '▶');
|
||||
play.onclick = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/voice/clips/' + c.id + '/audio',
|
||||
{ headers: { Authorization: 'Bearer ' + (localStorage.getItem('void_token') || '') } });
|
||||
audio.src = URL.createObjectURL(await res.blob()); audio.play();
|
||||
} catch { /* */ }
|
||||
};
|
||||
const del = el('button', { class: 'ghost dv-ignore' }, '✕');
|
||||
del.onclick = async () => { try { await api.del('/api/voice/clips/' + c.id); loadClips(); } catch { /* */ } };
|
||||
return el('div', { class: 'dross-clip' }, play, audio,
|
||||
el('span', { class: 'dross-clip-txt' }, c.transcript || '(no transcript)'),
|
||||
el('span', { class: 'muted', style: { fontSize: '10px' } },
|
||||
new Date(c.created_at).toLocaleString('en-AU', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })),
|
||||
del);
|
||||
}));
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try { cur = { ...cur, ...(await api.get('/api/dross/settings')) }; } catch {}
|
||||
accent.value = cur.accent; persona.value = cur.persona; mode.value = cur.voiceMode;
|
||||
keep.checked = !!cur.keepClips; paintAvatars(); loadClips();
|
||||
})();
|
||||
accent.addEventListener('input', () => { cur.accent = accent.value; paintAvatars(); });
|
||||
keep.addEventListener('change', loadClips);
|
||||
|
||||
const save = el('button', { class: 'primary' }, 'Save');
|
||||
save.onclick = async () => {
|
||||
try {
|
||||
await api.put('/api/dross/settings', {
|
||||
avatar: cur.avatar, accent: accent.value, persona: persona.value, voiceMode: mode.value, keepClips: keep.checked
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent('dross-settings-changed'));
|
||||
out.textContent = 'Saved.';
|
||||
} catch { out.textContent = 'Save failed'; }
|
||||
};
|
||||
return el('div', { class: 'settings-body' }, avatarRow, el('label', { class: 'st-lbl' }, 'Accent', accent),
|
||||
persona,
|
||||
el('label', { class: 'st-lbl' }, keep, ' Keep voice clips (saves audio + transcript, owner-only)'),
|
||||
el('div', { class: 'theme-actions' }, mode, save, out),
|
||||
clipsWrap);
|
||||
}
|
||||
|
||||
export async function render(main) {
|
||||
const tokensBody = el('div', { class: 'settings-body' });
|
||||
const agentsBody = el('div', { class: 'settings-body' });
|
||||
@@ -118,8 +243,40 @@ export async function render(main) {
|
||||
});
|
||||
iconSetsWrap.appendChild(isToggle);
|
||||
|
||||
// ---- Dross improvements: versioned CSS changes, approve / rollback / restore ----
|
||||
const improvementsBody = el('div', {});
|
||||
const STATUS_BADGE = { pending: '⏳ pending', active: '✅ active', rolled_back: '↩ rolled back', rejected: '✕ rejected' };
|
||||
async function renderImprovements() {
|
||||
let rows = [];
|
||||
try { rows = await api.get('/api/improvements'); } catch { /* fresh DB */ }
|
||||
const act = (id, verb) => async () => {
|
||||
try { await api.post(`/api/improvements/${id}/${verb}`, {}); } catch { /* surfaced below */ }
|
||||
renderImprovements();
|
||||
// re-pull the live stylesheet so the change lands without a page reload
|
||||
const link = document.getElementById('dross-improvements');
|
||||
if (link) link.href = '/improvements.css?v=' + Date.now();
|
||||
};
|
||||
mount(improvementsBody,
|
||||
rows.length === 0 ? el('div', { class: 'muted' }, 'Nothing yet. Ask Dross to improve something — each approved change lands here, individually reversible.') : null,
|
||||
...rows.map((r) => el('div', { class: 'imp-row' },
|
||||
el('span', { class: 'imp-status s-' + r.status }, STATUS_BADGE[r.status] ?? r.status),
|
||||
el('div', { class: 'imp-main' },
|
||||
el('div', {}, r.summary),
|
||||
el('small', { class: 'muted' }, `${new Date(r.created_at).toLocaleString()} · ${r.css_len} chars of css`)),
|
||||
el('span', { class: 'imp-actions' },
|
||||
r.status === 'pending' ? el('button', { class: 'primary sm', onclick: act(r.id, 'approve') }, 'Approve') : null,
|
||||
r.status === 'pending' ? el('button', { class: 'ghost sm', onclick: act(r.id, 'reject') }, 'Reject') : null,
|
||||
r.status === 'active' ? el('button', { class: 'ghost sm danger', onclick: act(r.id, 'rollback') }, 'Roll back') : null,
|
||||
r.status === 'rolled_back' ? el('button', { class: 'ghost sm', onclick: act(r.id, 'restore') }, 'Restore') : null)))
|
||||
);
|
||||
}
|
||||
renderImprovements();
|
||||
|
||||
mount(main,
|
||||
el('h1', { class: 'view-h1' }, '◆ Settings'),
|
||||
section('Theming', 'Recolour the interface. Pick a colour to preview it live, choose a preset, then Save to persist. Reset returns to the default Blackflame palette.', themingBody()),
|
||||
section('Dross', "Your companion's look and voice. Avatar, accent colour, his personality (system prompt), and how voice clips behave.", drossBody()),
|
||||
section('Dross improvements', 'Changes Dross has made to the Void itself — each one versioned, owner-approved, and instantly reversible.', improvementsBody),
|
||||
section('API Tokens', 'Bearer tokens for agents (e.g. the MCP external-research agent). The secret is shown once at creation.', tokensBody),
|
||||
section('Agents', 'Seeded Cradle agents and what each is allowed to do.', agentsBody),
|
||||
section('Icon Sets', 'Upload or delete custom icon packs for device and service icons.', iconSetsWrap),
|
||||
|
||||
185
public/views/speedtest.js
Normal file
185
public/views/speedtest.js
Normal file
@@ -0,0 +1,185 @@
|
||||
import { el, mount, safeHref } from '../dom.js';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const SVG = 'http://www.w3.org/2000/svg';
|
||||
const RANGES = [{ h: 24, l: '24h' }, { h: 168, l: '7d' }, { h: 720, l: '30d' }];
|
||||
const INTERVALS = [
|
||||
{ v: 15, l: 'Every 15 min' }, { v: 30, l: 'Every 30 min' }, { v: 60, l: 'Hourly' },
|
||||
{ v: 120, l: 'Every 2 h' }, { v: 360, l: 'Every 6 h' }, { v: 720, l: 'Every 12 h' },
|
||||
{ v: 1440, l: 'Daily' }
|
||||
];
|
||||
|
||||
let hours = 168;
|
||||
let timer = null;
|
||||
|
||||
const n1 = v => v == null ? '—' : Number(v).toFixed(1);
|
||||
const n0 = v => v == null ? '—' : Math.round(Number(v));
|
||||
function ago(ts) {
|
||||
if (!ts) return '—';
|
||||
const s = (Date.now() - new Date(ts).getTime()) / 1000;
|
||||
if (s < 90) return Math.round(s) + 's ago';
|
||||
if (s < 5400) return Math.round(s / 60) + 'm ago';
|
||||
if (s < 172800) return Math.round(s / 3600) + 'h ago';
|
||||
return Math.round(s / 86400) + 'd ago';
|
||||
}
|
||||
|
||||
function mkSvg(tag, attrs) {
|
||||
const e = document.createElementNS(SVG, tag);
|
||||
for (const k in attrs) e.setAttribute(k, attrs[k]);
|
||||
return e;
|
||||
}
|
||||
|
||||
// Multi-series line chart over a shared time axis. rows ascending by ran_at.
|
||||
function chart(rows, series, h = 170) {
|
||||
if (!rows.length) return el('div', { class: 'muted', style: { padding: '24px 0' } }, 'No data in this window.');
|
||||
const W = 1000, H = h, padL = 42, padR = 10, padT = 10, padB = 18;
|
||||
const xs = rows.map(r => new Date(r.ran_at).getTime());
|
||||
const x0 = xs[0], x1 = xs[xs.length - 1] || x0 + 1;
|
||||
let vmax = 1;
|
||||
series.forEach(s => rows.forEach(r => { if (r[s.key] != null) vmax = Math.max(vmax, Number(r[s.key])); }));
|
||||
vmax *= 1.12;
|
||||
const X = t => padL + (x1 === x0 ? 0 : (t - x0) / (x1 - x0)) * (W - padL - padR);
|
||||
const Y = v => H - padB - (v / vmax) * (H - padT - padB);
|
||||
const svg = mkSvg('svg', { viewBox: `0 0 ${W} ${H}`, class: 'st-chart', preserveAspectRatio: 'none' });
|
||||
[0, 0.5, 1].forEach(f => {
|
||||
const y = Y(vmax * f);
|
||||
svg.appendChild(mkSvg('line', { x1: padL, x2: W - padR, y1: y, y2: y, stroke: '#ffffff10' }));
|
||||
const t = mkSvg('text', { x: 4, y: y + 3, fill: '#888094', 'font-size': 11 });
|
||||
t.textContent = Math.round(vmax * f); svg.appendChild(t);
|
||||
});
|
||||
series.forEach(s => {
|
||||
const pts = rows.filter(r => r[s.key] != null)
|
||||
.map(r => `${X(new Date(r.ran_at).getTime())},${Y(Number(r[s.key]))}`).join(' ');
|
||||
if (pts) svg.appendChild(mkSvg('polyline',
|
||||
{ points: pts, fill: 'none', stroke: s.color, 'stroke-width': 2, 'stroke-linejoin': 'round' }));
|
||||
});
|
||||
return svg;
|
||||
}
|
||||
|
||||
function legend(series) {
|
||||
return el('div', { class: 'st-legend' }, series.map(s =>
|
||||
el('span', { class: 'st-leg' }, el('i', { style: { background: s.color } }), s.label)));
|
||||
}
|
||||
|
||||
function kpi(label, value, sub, cls) {
|
||||
return el('div', { class: 'st-kpi' + (cls ? ' ' + cls : '') },
|
||||
el('span', { class: 'st-kpi-l' }, label),
|
||||
el('span', { class: 'st-kpi-v' }, value),
|
||||
sub ? el('span', { class: 'st-kpi-s' }, sub) : null);
|
||||
}
|
||||
|
||||
async function load(main) {
|
||||
let latest, statsd, rows, cfg;
|
||||
try {
|
||||
[latest, statsd, rows, cfg] = await Promise.all([
|
||||
api.get('/api/speedtest/latest'),
|
||||
api.get('/api/speedtest/stats?hours=' + hours),
|
||||
api.get('/api/speedtest/results?hours=' + hours),
|
||||
api.get('/api/speedtest/config')
|
||||
]);
|
||||
} catch { mount(main, el('span', { class: 'muted' }, 'Speedtest data unavailable')); return; }
|
||||
|
||||
const thr = Number(cfg.threshold_down_mbps) || 0;
|
||||
const lowNow = latest && thr > 0 && Number(latest.down_mbps) < thr;
|
||||
|
||||
const rangeBtns = el('div', { class: 'st-ranges' }, RANGES.map(r =>
|
||||
el('button', { class: 'st-range' + (r.h === hours ? ' on' : ''), onclick: () => { hours = r.h; load(main); } }, r.l)));
|
||||
|
||||
const runBtn = el('button', { class: 'primary' }, 'Run now');
|
||||
runBtn.onclick = async () => {
|
||||
runBtn.disabled = true; runBtn.textContent = 'Running…';
|
||||
try { await api.post('/api/speedtest/run', {}); } catch {}
|
||||
setTimeout(() => load(main), 35000);
|
||||
};
|
||||
|
||||
// schedule + threshold form
|
||||
const intSel = el('select', { class: 'pm-input' }, INTERVALS.map(i =>
|
||||
el('option', { value: String(i.v) }, i.l)));
|
||||
intSel.value = String(cfg.interval_min);
|
||||
const thrIn = el('input', { class: 'pm-input', type: 'number', min: '0', step: '10', value: String(thr), style: { maxWidth: '110px' } });
|
||||
const saveBtn = el('button', {}, 'Save');
|
||||
const saveOut = el('span', { class: 'muted', style: { marginLeft: '8px' } });
|
||||
saveBtn.onclick = async () => {
|
||||
try {
|
||||
await api.put('/api/speedtest/config', {
|
||||
interval_min: Number(intSel.value), threshold_down_mbps: Number(thrIn.value) || 0
|
||||
});
|
||||
saveOut.textContent = 'Saved'; setTimeout(() => load(main), 500);
|
||||
} catch { saveOut.textContent = 'Failed'; }
|
||||
};
|
||||
|
||||
mount(main,
|
||||
el('div', { class: 'st-head' },
|
||||
el('div', {},
|
||||
el('h1', { class: 'view-h1', style: { margin: 0 } }, 'Speedtest'),
|
||||
el('p', { class: 'view-sub', style: { margin: '2px 0 0' } },
|
||||
'Automated Ookla speed tests — history, trends & schedule.')),
|
||||
el('div', { class: 'st-actions' }, rangeBtns, runBtn)),
|
||||
|
||||
// latest snapshot
|
||||
el('div', { class: 'st-kpis' },
|
||||
kpi('Download', latest ? n0(latest.down_mbps) : '—', 'Mbps', lowNow ? 'bad' : 'down'),
|
||||
kpi('Upload', latest ? n0(latest.up_mbps) : '—', 'Mbps', 'up'),
|
||||
kpi('Ping', latest ? n1(latest.ping_ms) : '—', 'ms'),
|
||||
kpi('Jitter', latest ? n1(latest.jitter_ms) : '—', 'ms'),
|
||||
kpi('Packet loss', latest ? n1(latest.packet_loss) : '—', '%'),
|
||||
kpi('Last run', ago(latest && latest.ran_at), latest && latest.isp ? latest.isp : '')),
|
||||
|
||||
latest ? el('div', { class: 'st-meta muted' },
|
||||
latest.server_name ? `Server: ${latest.server_name}` : '',
|
||||
latest.result_url ? el('a', { href: safeHref(latest.result_url), target: '_blank', rel: 'noopener', class: 'st-link' }, ' view result ↗') : null,
|
||||
lowNow ? el('span', { class: 'st-warn' }, ` ⚠ below ${thr} Mbps threshold`) : null) : null,
|
||||
|
||||
// stats for the window
|
||||
el('div', { class: 'st-stats' },
|
||||
el('span', {}, `Avg ↓ ${n0(statsd.avg_down)}`),
|
||||
el('span', {}, `min ${n0(statsd.min_down)} · max ${n0(statsd.max_down)}`),
|
||||
el('span', {}, `Avg ↑ ${n0(statsd.avg_up)}`),
|
||||
el('span', {}, `Avg ping ${n1(statsd.avg_ping)} ms`),
|
||||
el('span', {}, `${n0(statsd.n)} tests`),
|
||||
Number(statsd.failures) ? el('span', { class: 'st-warn' }, `${statsd.failures} failed`) : null),
|
||||
|
||||
// charts
|
||||
el('h2', { class: 'st-h2' }, 'Throughput'),
|
||||
legend([{ color: 'var(--accent)', label: 'Download' }, { color: '#6fa86a', label: 'Upload' }]),
|
||||
chart(rows, [{ key: 'down_mbps', color: 'var(--accent)' }, { key: 'up_mbps', color: '#6fa86a' }], 180),
|
||||
el('h2', { class: 'st-h2' }, 'Latency'),
|
||||
legend([{ color: '#d4a04a', label: 'Ping (ms)' }, { color: '#7a7390', label: 'Jitter (ms)' }]),
|
||||
chart(rows, [{ key: 'ping_ms', color: '#d4a04a' }, { key: 'jitter_ms', color: '#7a7390' }], 130),
|
||||
|
||||
// schedule
|
||||
el('div', { class: 'card st-card' },
|
||||
el('h3', {}, 'Schedule & alert'),
|
||||
el('div', { class: 'st-form' },
|
||||
el('label', { class: 'st-lbl' }, 'Run', intSel),
|
||||
el('label', { class: 'st-lbl' }, 'Alert if ↓ below (Mbps)', thrIn),
|
||||
saveBtn, saveOut)),
|
||||
|
||||
// history table
|
||||
el('h2', { class: 'st-h2' }, 'History'),
|
||||
el('div', { class: 'st-table-wrap' },
|
||||
el('table', { class: 'st-table' },
|
||||
el('thead', {}, el('tr', {},
|
||||
...['Time', 'Down', 'Up', 'Ping', 'Jitter', 'Loss', 'Server', ''].map(h => el('th', {}, h)))),
|
||||
el('tbody', {}, [...rows].reverse().slice(0, 100).map(r =>
|
||||
el('tr', { class: r.ok ? '' : 'st-fail' },
|
||||
el('td', {}, new Date(r.ran_at).toLocaleString('en-AU', { hour12: false, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })),
|
||||
el('td', { class: 'num' }, r.ok ? n0(r.down_mbps) : '✕'),
|
||||
el('td', { class: 'num' }, r.ok ? n0(r.up_mbps) : ''),
|
||||
el('td', { class: 'num' }, n1(r.ping_ms)),
|
||||
el('td', { class: 'num' }, n1(r.jitter_ms)),
|
||||
el('td', { class: 'num' }, r.packet_loss == null ? '—' : n1(r.packet_loss) + '%'),
|
||||
el('td', { class: 'muted' }, r.server_name || ''),
|
||||
el('td', {}, r.result_url ? el('a', { href: safeHref(r.result_url), target: '_blank', rel: 'noopener', class: 'st-link' }, '↗') : ''))))))
|
||||
);
|
||||
}
|
||||
|
||||
export async function render(main) {
|
||||
hours = hours || 168;
|
||||
await load(main);
|
||||
if (timer) clearInterval(timer);
|
||||
timer = setInterval(() => {
|
||||
if (!document.querySelector('.st-kpis')) { clearInterval(timer); timer = null; return; }
|
||||
load(main);
|
||||
}, 30000);
|
||||
}
|
||||
@@ -1,21 +1,61 @@
|
||||
// #/terminal — embeds the CT 300 web terminal (ttyd → persistent tmux/claude),
|
||||
// same-origin under /terminal so it shares the Void's CF Access session.
|
||||
// #/terminal — "Eithan": the CT 300 web terminal (ttyd → persistent tmux/claude),
|
||||
// same-origin under /terminal so it shares the Void's CF Access session AND lets
|
||||
// us reach the xterm instance for mobile copy/paste.
|
||||
import { el, mount } from '../dom.js';
|
||||
|
||||
const FS_KEY = 'void_term_fontsize';
|
||||
|
||||
export async function render(main) {
|
||||
// bigger default on touch screens; user-adjustable, remembered
|
||||
let fontSize = Number(localStorage.getItem(FS_KEY))
|
||||
|| (matchMedia('(pointer: coarse)').matches ? 17 : 14);
|
||||
|
||||
const frame = el('iframe', {
|
||||
id: 'term-frame',
|
||||
class: 'term-frame',
|
||||
allow: 'clipboard-read; clipboard-write'
|
||||
});
|
||||
const setSrc = () => { frame.src = `/terminal/?fontSize=${fontSize}`; };
|
||||
setSrc();
|
||||
|
||||
// ttyd exposes its xterm as window.term; the same-origin proxy makes it reachable.
|
||||
const term = () => { try { return frame.contentWindow?.term ?? null; } catch { return null; } };
|
||||
const note = el('span', { class: 'muted', style: { fontSize: '11px' } },
|
||||
'eithan @ ct300 · persistent tmux · swipe to scroll');
|
||||
const flash = (msg) => { const old = note.textContent; note.textContent = msg;
|
||||
setTimeout(() => { note.textContent = old; }, 1600); };
|
||||
|
||||
const bump = (d) => {
|
||||
fontSize = Math.max(10, Math.min(24, fontSize + d));
|
||||
localStorage.setItem(FS_KEY, String(fontSize));
|
||||
setSrc(); // reload reattaches tmux; the session itself persists
|
||||
};
|
||||
|
||||
mount(main,
|
||||
el('div', { class: 'term-bar' },
|
||||
el('span', { class: 'term-title' }, '◆ Terminal'),
|
||||
el('span', { class: 'muted', style: { fontSize: '11px' } }, 'claude @ ct300 · persistent tmux'),
|
||||
el('button', { class: 'ghost', style: { marginLeft: 'auto' }, onclick: () => {
|
||||
const f = document.getElementById('term-frame'); if (f) f.src = f.src;
|
||||
} }, '⟳ Reconnect')
|
||||
el('span', { class: 'term-title' }, '◆ Eithan'),
|
||||
note,
|
||||
el('span', { style: { marginLeft: 'auto', display: 'flex', gap: '6px' } },
|
||||
el('button', { class: 'ghost', title: 'smaller text', onclick: () => bump(-2) }, 'A−'),
|
||||
el('button', { class: 'ghost', title: 'larger text', onclick: () => bump(+2) }, 'A+'),
|
||||
el('button', { class: 'ghost', title: 'copy terminal selection', onclick: async () => {
|
||||
const sel = term()?.getSelection?.();
|
||||
if (!sel) return flash('select text first (touch: long-press, then drag)');
|
||||
try { await navigator.clipboard.writeText(sel); flash('copied ✓'); }
|
||||
catch { flash('clipboard needs the https domain'); }
|
||||
} }, '⧉ Copy'),
|
||||
el('button', { class: 'ghost', title: 'paste clipboard into terminal', onclick: async () => {
|
||||
const t = term();
|
||||
if (!t) return flash('terminal not ready');
|
||||
try { t.paste(await navigator.clipboard.readText()); }
|
||||
catch { flash('clipboard needs the https domain'); }
|
||||
} }, '⇩ Paste'),
|
||||
el('button', { class: 'ghost', title: 'jump to live output', onclick: () => {
|
||||
term()?.scrollToBottom?.(); frame.contentWindow?.focus();
|
||||
} }, '↓ Live'),
|
||||
el('button', { class: 'ghost', title: 'reconnect', onclick: setSrc }, '⟳')
|
||||
)
|
||||
),
|
||||
el('iframe', {
|
||||
id: 'term-frame',
|
||||
src: '/terminal/',
|
||||
class: 'term-frame',
|
||||
allow: 'clipboard-read; clipboard-write'
|
||||
})
|
||||
frame
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { describe, it, expect } from 'vitest';
|
||||
import { companionRegistry } from '../../../../lib/ai/agent/tools/index.js';
|
||||
|
||||
describe('companion registry', () => {
|
||||
it('registers exactly the four v1 tools', () => {
|
||||
it('registers exactly the five companion tools', () => {
|
||||
expect(companionRegistry.listTools().map(t => t.name).sort())
|
||||
.toEqual(['context', 'propose_change', 'read', 'search']);
|
||||
.toEqual(['context', 'propose_change', 'propose_improvement', 'read', 'search']);
|
||||
});
|
||||
it('exposes them in Anthropic shape', () => {
|
||||
const tools = companionRegistry.toAnthropicTools();
|
||||
|
||||
@@ -14,7 +14,7 @@ describe('dashboard layout api', () => {
|
||||
it('GET returns defaults', async () => {
|
||||
const res = await request(app).get('/api/dashboard/layout').set(ownerHeaders);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ card_order: [], hidden: [], sizes: {} });
|
||||
expect(res.body).toEqual({ card_order: [], hidden: [], sizes: {}, geom: {}, extras: [] });
|
||||
});
|
||||
|
||||
it('PUT persists and GET reflects it', async () => {
|
||||
|
||||
59
tests/api/improvements.test.js
Normal file
59
tests/api/improvements.test.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { resetDb } from '../helpers/db.js';
|
||||
import { migrateUp } from '../../lib/db/migrate.js';
|
||||
import { createApp } from '../../server.js';
|
||||
import { proposeImprovementTool } from '../../lib/ai/agent/tools/propose_improvement.js';
|
||||
|
||||
let app;
|
||||
beforeAll(async () => {
|
||||
await resetDb(); await migrateUp();
|
||||
process.env.OWNER_TOKEN = 'test-token';
|
||||
app = createApp();
|
||||
});
|
||||
const auth = (r) => r.set('Authorization', 'Bearer test-token');
|
||||
const ctx = { agent: { slug: 'dross' } };
|
||||
|
||||
describe('dross improvements (2.14)', () => {
|
||||
let id;
|
||||
|
||||
it('tool drafts a pending improvement, never applies', async () => {
|
||||
const out = await proposeImprovementTool.handler(
|
||||
{ summary: 'Soften card borders', css: '.card { border-radius: 10px; }' }, ctx);
|
||||
expect(out.ok).toBe(true);
|
||||
expect(out.note).toMatch(/NOT live/);
|
||||
id = out.id;
|
||||
const css = await request(app).get('/improvements.css');
|
||||
expect(css.text).not.toContain('border-radius: 10px'); // pending ≠ live
|
||||
});
|
||||
|
||||
it('tool rejects exfil css', async () => {
|
||||
expect((await proposeImprovementTool.handler(
|
||||
{ summary: 'evil', css: '.x { background: url(http://evil.tld/p.png); }' }, ctx)).error)
|
||||
.toMatch(/url\(\)/);
|
||||
expect((await proposeImprovementTool.handler(
|
||||
{ summary: 'evil', css: '@import "http://evil.tld/x.css";' }, ctx)).error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('owner approves → live in the public stylesheet', async () => {
|
||||
const res = await auth(request(app).post(`/api/improvements/${id}/approve`));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('active');
|
||||
const css = await request(app).get('/improvements.css'); // unauthenticated by design
|
||||
expect(css.headers['content-type']).toContain('text/css');
|
||||
expect(css.text).toContain('border-radius: 10px');
|
||||
expect(css.text).toContain('dross: Soften card borders');
|
||||
});
|
||||
|
||||
it('rollback removes it instantly; restore brings it back', async () => {
|
||||
await auth(request(app).post(`/api/improvements/${id}/rollback`));
|
||||
expect((await request(app).get('/improvements.css')).text).not.toContain('border-radius');
|
||||
await auth(request(app).post(`/api/improvements/${id}/restore`));
|
||||
expect((await request(app).get('/improvements.css')).text).toContain('border-radius');
|
||||
});
|
||||
|
||||
it('transitions are guarded (no approve on active, no anonymous verbs)', async () => {
|
||||
expect((await auth(request(app).post(`/api/improvements/${id}/approve`))).status).toBe(409);
|
||||
expect((await request(app).post(`/api/improvements/${id}/rollback`)).status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -25,9 +25,9 @@ const suggestAgent = (id) => ({
|
||||
});
|
||||
|
||||
describe('listMcpTools()', () => {
|
||||
it('returns exactly the four companion tools sorted by name', () => {
|
||||
it('returns exactly the five companion tools sorted by name', () => {
|
||||
const tools = listMcpTools();
|
||||
expect(tools.map(t => t.name).sort()).toEqual(['context', 'propose_change', 'read', 'search']);
|
||||
expect(tools.map(t => t.name).sort()).toEqual(['context', 'propose_change', 'propose_improvement', 'read', 'search']);
|
||||
});
|
||||
|
||||
it('each tool has name, description, and input_schema', () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { listMcpTools, callMcpTool } from '../../lib/mcp/companion-stdio.js';
|
||||
describe('MCP registry selection', () => {
|
||||
it('defaults to the companion registry when VOID_TOOL_REGISTRY is unset', () => {
|
||||
const names = listMcpTools({}).map(t => t.name).sort();
|
||||
expect(names).toEqual(['context', 'propose_change', 'read', 'search']);
|
||||
expect(names).toEqual(['context', 'propose_change', 'propose_improvement', 'read', 'search']);
|
||||
});
|
||||
|
||||
it('selects the security registry when VOID_TOOL_REGISTRY=security', () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ beforeAll(async () => { await resetDb(); await migrateUp(); });
|
||||
describe('dashboard_layout repo', () => {
|
||||
it('returns defaults when unset', async () => {
|
||||
const l = await repo.get();
|
||||
expect(l).toEqual({ card_order: [], hidden: [], sizes: {} });
|
||||
expect(l).toEqual({ card_order: [], hidden: [], sizes: {}, geom: {}, extras: [] });
|
||||
});
|
||||
|
||||
it('upserts and reads back', async () => {
|
||||
|
||||
19
tests/repos/voice_clips.test.js
Normal file
19
tests/repos/voice_clips.test.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { resetDb } from '../helpers/db.js';
|
||||
import { migrateUp } from '../../lib/db/migrate.js';
|
||||
import * as clips from '../../lib/db/repos/voice_clips.js';
|
||||
|
||||
beforeEach(async () => { await resetDb(); await migrateUp(); });
|
||||
|
||||
describe('voice_clips repo', () => {
|
||||
it('creates, lists newest-first, and removes (returning path)', async () => {
|
||||
const a = await clips.create({ transcript: 'first', bytes: 10, mime: 'audio/webm', path: '/x/a.webm' });
|
||||
const b = await clips.create({ transcript: 'second', bytes: 20, mime: 'audio/webm', path: '/x/b.webm' });
|
||||
const list = await clips.list();
|
||||
expect(list.length).toBe(2);
|
||||
expect(list[0].transcript).toBe('second'); // newest first
|
||||
const removed = await clips.remove(a.id);
|
||||
expect(removed.path).toBe('/x/a.webm');
|
||||
expect((await clips.list()).length).toBe(1);
|
||||
});
|
||||
});
|
||||
55
tests/routes/dross.test.js
Normal file
55
tests/routes/dross.test.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../server.js';
|
||||
import { resetDb } from '../helpers/db.js';
|
||||
import { migrateUp } from '../../lib/db/migrate.js';
|
||||
|
||||
let app;
|
||||
const owner = { Authorization: 'Bearer test-token' };
|
||||
beforeAll(async () => {
|
||||
await resetDb(); await migrateUp();
|
||||
process.env.OWNER_TOKEN = 'test-token';
|
||||
app = createApp();
|
||||
});
|
||||
|
||||
describe('dross chat', () => {
|
||||
it('GET /api/dross returns a global conversation + Dross agent', async () => {
|
||||
const res = await request(app).get('/api/dross').set(owner);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.conversation_id).toBeTruthy();
|
||||
expect(res.body.agent.slug).toBe('companion');
|
||||
expect(Array.isArray(res.body.messages)).toBe(true);
|
||||
});
|
||||
|
||||
it('POST /api/dross/turn rejects empty text (400)', async () => {
|
||||
const res = await request(app).post('/api/dross/turn').set(owner).send({ text: '' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('GET /api/dross without token is 401', async () => {
|
||||
const res = await request(app).get('/api/dross');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dross settings', () => {
|
||||
it('GET /api/dross/settings returns defaults', async () => {
|
||||
const res = await request(app).get('/api/dross/settings').set(owner);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' });
|
||||
});
|
||||
|
||||
it('PUT /api/dross/settings persists and round-trips', async () => {
|
||||
const body = { avatar: 'wisp', accent: '#aa66ff', persona: 'Be terse.', voiceMode: 'handsfree' };
|
||||
const put = await request(app).put('/api/dross/settings').set(owner).send(body);
|
||||
expect(put.status).toBe(200);
|
||||
const get = await request(app).get('/api/dross/settings').set(owner);
|
||||
expect(get.body).toMatchObject(body);
|
||||
});
|
||||
|
||||
it('PUT rejects a bad avatar (400)', async () => {
|
||||
const res = await request(app).put('/api/dross/settings').set(owner)
|
||||
.send({ avatar: 'nope', accent: '#aa66ff', persona: '', voiceMode: 'review' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
24
tests/routes/voice.test.js
Normal file
24
tests/routes/voice.test.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../server.js';
|
||||
import { resetDb } from '../helpers/db.js';
|
||||
import { migrateUp } from '../../lib/db/migrate.js';
|
||||
|
||||
let app;
|
||||
const owner = { Authorization: 'Bearer test-token' };
|
||||
beforeAll(async () => {
|
||||
await resetDb(); await migrateUp();
|
||||
process.env.OWNER_TOKEN = 'test-token';
|
||||
app = createApp();
|
||||
});
|
||||
|
||||
describe('voice transcribe route', () => {
|
||||
it('401 without a token', async () => {
|
||||
const res = await request(app).post('/api/voice/transcribe');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
it('400 when no audio supplied', async () => {
|
||||
const res = await request(app).post('/api/voice/transcribe').set(owner);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
21
tests/views/dross_avatar.test.js
Normal file
21
tests/views/dross_avatar.test.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { drossAvatar } from '../../public/components/dross_avatar.js';
|
||||
|
||||
describe('drossAvatar', () => {
|
||||
it('renders the requested variant class', () => {
|
||||
const eye = drossAvatar('soft-eye', 60);
|
||||
expect(eye.classList.contains('dross-orb')).toBe(true);
|
||||
expect(eye.querySelector('.av-eye')).toBeTruthy();
|
||||
expect(drossAvatar('wisp', 30).querySelector('.b-core')).toBeTruthy();
|
||||
expect(drossAvatar('motes', 30).querySelector('.d-core')).toBeTruthy();
|
||||
});
|
||||
it('falls back to soft-eye for unknown variants', () => {
|
||||
expect(drossAvatar('bogus', 60).querySelector('.av-eye')).toBeTruthy();
|
||||
});
|
||||
it('sets the pixel size', () => {
|
||||
const a = drossAvatar('wisp', 42);
|
||||
expect(a.style.width).toBe('42px');
|
||||
expect(a.style.height).toBe('42px');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user