CUBE⊞ (Squared Plus): final two-CUBE architecture + worker control panel + continuity
Consolidates the working system after the broken-session recovery: - Rename system to CUBE⊞ / Squared Plus across the console (index.html) and the worker control panel (cube-vm.html). - Worker control panel (html/cube-vm.html): fix delete-alert-after-null bug; full save/load/delete loop verified live against /api/cube-vm/*. - Canonical schematic + DUAL-CUBE-WORKING-PLAN doc: documents the FINAL architecture -- workhorse cubed = persistent substrate, VM cube = in-memory OS runtime, bridge (:9231) between them; two distinct builds, two distinct use cases, complementary by design (not a ladder). - Deploy artifacts preserved: html/chat/* (society-relay: cubelinux.com.conf, relay.py, experts/models.json, nginx + systemd units), html/ingest/*, html/files/knowledge/* (CUBELinux spec + motion + hermes-verify docs). - .gitignore: exclude sibling CUBELinux-2 repo, ingest venv, pycache, one-off export blobs (target/ already ignored). - Coordinated with cube-schematic.py / project-continuity.py (in ~/.cubelinux-agent) and the project-continuity systemd timer. Authored under user dictate to preserve all working code from the recovered session chain.
This commit is contained in:
+11
@@ -4,3 +4,14 @@ bench-data/
|
||||
|
||||
# CUBE store data files (created by bench/test runs)
|
||||
*.store
|
||||
|
||||
# Sibling immutable testbed repo — version-controlled on its own, do not embed.
|
||||
CUBELinux-2/
|
||||
|
||||
# Python venv + caches (ingest worker)
|
||||
html/ingest/env/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Uploaded binary blobs / exports (not source)
|
||||
html/files/export_*/
|
||||
|
||||
@@ -315,9 +315,14 @@ impl<T: Store, C: Curve> Daemon<T, C> {
|
||||
/// Handle one client connection: read newline-delimited JSON requests,
|
||||
/// write one JSON response per line.
|
||||
fn handle_conn(&self, stream: UnixStream) -> std::io::Result<()> {
|
||||
let peer = stream.try_clone()?;
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut writer = BufWriter::new(peer);
|
||||
// One fd per connection: UnixStream is full-duplex, so read and write
|
||||
// through the SAME socket instead of try_clone()-ing a second fd.
|
||||
// try_clone() previously doubled fds (1 conn = 2 fds), which leaked to
|
||||
// EMFILE (1024) under load and wedged the whole daemon.
|
||||
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
|
||||
stream.set_write_timeout(Some(Duration::from_secs(30)))?;
|
||||
let mut reader = BufReader::new(&stream);
|
||||
let mut writer = BufWriter::new(&stream);
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Mobile-only UA map (http-level, must sit outside server{})
|
||||
map $http_user_agent $is_mobile {
|
||||
default 0;
|
||||
~*(android|iphone|ipad|ipod|mobile|windows\ phone|blackberry|webos|opera\ mini|crios|fxios) 1;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name cubelinux.com www.cubelinux.com;
|
||||
|
||||
root /home/cubelinux/html;
|
||||
index index.html;
|
||||
|
||||
access_log /var/log/nginx/cubelinux.com.access.log;
|
||||
error_log /var/log/nginx/cubelinux.com.error.log;
|
||||
|
||||
client_max_body_size 64M;
|
||||
ssl_certificate /etc/letsencrypt/live/cubelinux.com/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/cubelinux.com/privkey.pem; # managed by Certbot
|
||||
|
||||
# --- CUBELinux society relay (2026-08-12) ---
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:9001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
}
|
||||
|
||||
# Mobile-only gate for the chat SPA (directory form; serves chat/index.html).
|
||||
location /chat/ {
|
||||
alias /home/cubelinux/html/chat/;
|
||||
index index.html;
|
||||
if ($is_mobile = 0) {
|
||||
return 403 "Desktop access is disabled. Open this link on a phone.";
|
||||
}
|
||||
}
|
||||
# --- end society relay ---
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass php-fpm;
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
if ($host = www.cubelinux.com) {
|
||||
return 301 https://$host$request_uri;
|
||||
} # managed by Certbot
|
||||
if ($host = cubelinux.com) {
|
||||
return 301 https://$host$request_uri;
|
||||
} # managed by Certbot
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name cubelinux.com www.cubelinux.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
[
|
||||
{
|
||||
"id": "ops-nginx",
|
||||
"name": "NGINX Ops",
|
||||
"domain": "ops",
|
||||
"prompt": "You are an NGINX configuration specialist. Answer with concrete, correct directives. Prefer the deployment's real upstream over generic examples.",
|
||||
"knowledge": "This host's backend listens on 10.0.0.5:9000. Example: location /api/ { proxy_pass http://10.0.0.5:9000; }",
|
||||
"user_created": false
|
||||
},
|
||||
{
|
||||
"id": "dev-rust",
|
||||
"name": "Rust Dev",
|
||||
"domain": "dev",
|
||||
"prompt": "You are a Rust systems-development specialist. Show idiomatic, correct code and explain trade-offs briefly.",
|
||||
"knowledge": "Prefer thiserror for typed error enums; map I/O errors with #[from]. Return Result<T, E> for fallible lookups.",
|
||||
"user_created": false
|
||||
},
|
||||
{
|
||||
"id": "netops-dns",
|
||||
"name": "DNS / NetOps",
|
||||
"domain": "net",
|
||||
"prompt": "You are a DNS / network-ops specialist. Be concrete and show the actual record or config.",
|
||||
"knowledge": "An A record maps a name to IPv4. Example: www 3600 IN A 192.0.2.10",
|
||||
"user_created": false
|
||||
},
|
||||
{
|
||||
"id": "excel-expert",
|
||||
"name": "Excel Expert",
|
||||
"domain": "office",
|
||||
"prompt": "You are an Excel / spreadsheet expert. Give exact formulas, functions, and step-by-step instructions for both Excel desktop and Google Sheets where they differ.",
|
||||
"knowledge": "Common tasks: VLOOKUP/XLOOKUP, SUMIFS, pivot tables, conditional formatting, text-to-columns, and dynamic arrays (FILTER, UNIQUE, SORT). Prefer XLOOKUP over VLOOKUP for new work.",
|
||||
"user_created": false
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,273 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>CUBELinux Society — Local Expert Chat</title>
|
||||
<style>
|
||||
:root{
|
||||
--gold:#d8af68; --gold-hi:#fff9b1; --cyan:#39d7ff; --bg:#0c0f14;
|
||||
--panel:#141a22; --panel2:#1b232e; --ink:#e8eaeb; --muted:#8a97a6;
|
||||
--ok:#5ee0a0; --line:#26303c;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;height:100%}
|
||||
body{
|
||||
background:radial-gradient(1200px 600px at 80% -10%, #15212e 0%, var(--bg) 55%);
|
||||
color:var(--ink); font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
||||
-webkit-tap-highlight-color:transparent;
|
||||
}
|
||||
.wrap{max-width:760px;margin:0 auto;padding:14px 14px 28px;display:flex;flex-direction:column;height:100dvh}
|
||||
header{display:flex;align-items:center;gap:10px;padding:6px 2px 12px}
|
||||
.logo{width:30px;height:30px;border-radius:8px;background:linear-gradient(135deg,var(--gold),var(--cyan));box-shadow:0 0 18px rgba(57,215,255,.35)}
|
||||
h1{font-size:17px;margin:0;font-weight:700;letter-spacing:.2px}
|
||||
h1 span{color:var(--cyan)}
|
||||
.sub{color:var(--muted);font-size:12px;margin-top:1px}
|
||||
.controls{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px}
|
||||
.card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:10px 12px}
|
||||
label{font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);display:block;margin-bottom:6px}
|
||||
select,input,textarea{
|
||||
width:100%;background:var(--panel2);color:var(--ink);border:1px solid var(--line);
|
||||
border-radius:10px;padding:9px 10px;font-size:14px;outline:none;
|
||||
}
|
||||
select:focus,input:focus,textarea:focus{border-color:var(--cyan)}
|
||||
.experts{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px}
|
||||
.chip{
|
||||
border:1px solid var(--line);background:var(--panel);border-radius:999px;
|
||||
padding:7px 13px;font-size:13px;color:var(--ink);cursor:pointer;transition:.15s;
|
||||
}
|
||||
.chip.on{background:linear-gradient(135deg,rgba(216,175,104,.25),rgba(57,215,255,.18));border-color:var(--gold);color:var(--gold-hi)}
|
||||
.chip .x{margin-left:6px;color:var(--muted)}
|
||||
.chat{flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:10px;padding:6px 2px}
|
||||
.msg{display:flex;gap:8px;max-width:92%}
|
||||
.msg.me{align-self:flex-end;flex-direction:row-reverse}
|
||||
.bubble{padding:10px 13px;border-radius:16px;white-space:pre-wrap;word-wrap:break-word}
|
||||
.me .bubble{background:linear-gradient(135deg,var(--cyan),#2aa6c9);color:#04121a;border-bottom-right-radius:4px}
|
||||
.bot .bubble{background:var(--panel2);border:1px solid var(--line);border-bottom-left-radius:4px}
|
||||
.who{font-size:10px;color:var(--muted);margin:0 4px 2px}
|
||||
.input{display:flex;gap:8px;margin-top:10px;align-items:flex-end}
|
||||
textarea{resize:none;max-height:140px;min-height:44px}
|
||||
button{
|
||||
background:linear-gradient(135deg,var(--gold),#b8914f);color:#1a1206;border:none;border-radius:12px;
|
||||
padding:0 16px;font-weight:700;font-size:15px;cursor:pointer;min-height:44px;
|
||||
}
|
||||
button:active{transform:translateY(1px)}
|
||||
.ghost{background:transparent;color:var(--muted);border:1px solid var(--line);font-weight:600;font-size:13px;padding:0 12px;min-height:40px}
|
||||
.drawer{position:fixed;inset:0;background:rgba(4,8,12,.72);backdrop-filter:blur(3px);display:none;align-items:flex-end;z-index:20}
|
||||
.drawer.open{display:flex}
|
||||
.sheet{background:var(--panel);width:100%;max-width:760px;margin:0 auto;border-radius:20px 20px 0 0;padding:18px 16px calc(18px + env(safe-area-inset-bottom));border-top:1px solid var(--line);max-height:90dvh;overflow:auto}
|
||||
.sheet h2{margin:0 0 12px;font-size:16px}
|
||||
.row{margin-bottom:11px}
|
||||
.hint{color:var(--muted);font-size:12px;margin-top:5px}
|
||||
.status{font-size:12px;color:var(--muted);text-align:center;margin-top:8px}
|
||||
.status b{color:var(--ok)}
|
||||
.badge{font-size:11px;color:var(--cyan)}
|
||||
.created{font-size:11px;color:var(--gold)}
|
||||
.closebar{display:flex;justify-content:flex-end;margin-bottom:6px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div class="logo"></div>
|
||||
<div>
|
||||
<h1>CUBELinux <span>Society</span></h1>
|
||||
<div class="sub">Local experts, no cloud — answers from your own hardware</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="controls">
|
||||
<div class="card">
|
||||
<label>Model</label>
|
||||
<select id="model"></select>
|
||||
</div>
|
||||
<div class="card">
|
||||
<label>Expert</label>
|
||||
<select id="expert"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="experts" id="expertChips"></div>
|
||||
|
||||
<div class="chat" id="chat"></div>
|
||||
|
||||
<div class="input">
|
||||
<textarea id="prompt" placeholder="Ask the expert…" rows="1"></textarea>
|
||||
<button id="send">Send</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;justify-content:center;margin-top:8px">
|
||||
<button class="ghost" id="openCreate">+ Create expert</button>
|
||||
<span class="status" id="status">connecting…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create-expert drawer -->
|
||||
<div class="drawer" id="drawer">
|
||||
<div class="sheet">
|
||||
<div class="closebar"><button class="ghost" id="closeCreate">Done</button></div>
|
||||
<h2>Create an expert</h2>
|
||||
<div class="row">
|
||||
<label>Name</label>
|
||||
<input id="exName" placeholder="e.g. Excel Expert" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Domain (short tag)</label>
|
||||
<input id="exDomain" placeholder="office" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Persona prompt</label>
|
||||
<textarea id="exPrompt" rows="3" placeholder="You are a helpful expert in…"></textarea>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Knowledge / context (optional)</label>
|
||||
<textarea id="exKn" rows="3" placeholder="Facts, examples, or docs this expert should know"></textarea>
|
||||
<div class="hint">This is injected as the system context, so the expert answers in-character.</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Upload a knowledge document (optional)</label>
|
||||
<input id="exFile" type="file" accept=".pdf,.docx,.xlsx,.pptx,.odt,.ods,.odp,.html,.htm,.rtf,.csv,.tsv,.txt,.md,.json,.xml,.yml,.yaml,.log" />
|
||||
<button id="uploadDoc" class="ghost" style="width:100%;margin-top:6px">Digest & add to knowledge</button>
|
||||
<div class="hint">PDF / Word / Excel / PowerPoint / OpenDocument / HTML / RTF / CSV / text — text is extracted and appended to the Knowledge box above, then stored in CUBE for reuse.</div>
|
||||
<div class="status" id="uploadStatus"></div>
|
||||
</div>
|
||||
<button id="saveExpert" style="width:100%;margin-top:4px">Save expert</button>
|
||||
<div class="status" id="createStatus"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = (s)=>document.querySelector(s);
|
||||
const api = (p)=> fetch(p,{headers:{'Accept':'application/json'}}).then(r=>r.json());
|
||||
const post = (p,b)=> fetch(p,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)}).then(r=>r.json());
|
||||
|
||||
let state = { models:[], experts:[], model:'', expert:'' };
|
||||
|
||||
function setStatus(t, ok){ $('#status').innerHTML = ok ? `<b>${t}</b>` : t; }
|
||||
|
||||
async function boot(){
|
||||
try{
|
||||
state.models = await api('/api/models');
|
||||
state.experts = await api('/api/experts');
|
||||
const h = await api('/api/health');
|
||||
setStatus('connected · model '+ (h.model||''), true);
|
||||
}catch(e){ setStatus('relay unreachable'); }
|
||||
renderModels(); renderExperts();
|
||||
if(!state.model && state.models[0]) state.model = state.models[0].id;
|
||||
if(!state.expert && state.experts[0]) state.expert = state.experts[0].id;
|
||||
syncSelects();
|
||||
}
|
||||
|
||||
function renderModels(){
|
||||
$('#model').innerHTML = state.models.map(m=>`<option value="${m.id}">${m.label}</option>`).join('');
|
||||
}
|
||||
function renderExperts(){
|
||||
$('#expert').innerHTML = state.experts.map(e=>`<option value="${e.id}">${e.name}</option>`).join('');
|
||||
$('#expertChips').innerHTML = state.experts.map(e=>
|
||||
`<div class="chip ${e.id===state.expert?'on':''}" data-id="${e.id}">${e.name}${e.user_created?' <span class="x">✕</span>':''}</div>`
|
||||
).join('');
|
||||
document.querySelectorAll('#expertChips .chip').forEach(c=>{
|
||||
c.onclick=(ev)=>{
|
||||
if(ev.target.classList.contains('x')){
|
||||
const id=c.dataset.id;
|
||||
if(!confirm('Delete this expert?')) return;
|
||||
fetch('/api/experts?id='+encodeURIComponent(id),{method:'DELETE'})
|
||||
.then(r=>r.json()).then(d=>{
|
||||
if(d.deleted){ state.experts = state.experts.filter(e=>e.id!==id); renderExperts(); }
|
||||
else { alert('Could not delete: '+(d.error||'unknown')); }
|
||||
}).catch(e=>alert('delete error: '+e));
|
||||
return;
|
||||
}
|
||||
state.expert = c.dataset.id; renderExperts(); syncSelects();
|
||||
};
|
||||
});
|
||||
}
|
||||
function syncSelects(){ $('#model').value=state.model; $('#expert').value=state.expert; }
|
||||
|
||||
$('#model').onchange=(e)=> state.model=e.target.value;
|
||||
$('#expert').onchange=(e)=> state.expert=e.target.value;
|
||||
|
||||
function addMsg(role, text, who){
|
||||
const wrap=document.createElement('div');
|
||||
wrap.className='msg '+(role==='me'?'me':'bot');
|
||||
wrap.innerHTML=`<div><div class="who">${who}</div><div class="bubble"></div></div>`;
|
||||
wrap.querySelector('.bubble').textContent=text;
|
||||
$('#chat').appendChild(wrap);
|
||||
$('#chat').scrollTop=$('#chat').scrollHeight;
|
||||
}
|
||||
|
||||
async function send(){
|
||||
const text=$('#prompt').value.trim();
|
||||
if(!text) return;
|
||||
const ex = state.experts.find(e=>e.id===state.expert);
|
||||
addMsg('me', text, 'You');
|
||||
$('#prompt').value=''; autosize();
|
||||
addMsg('bot','…','thinking');
|
||||
try{
|
||||
const r = await post('/api/chat',{
|
||||
model:state.model, expert:state.expert,
|
||||
messages:[{role:'user',content:text}]
|
||||
});
|
||||
$('#chat').lastChild.querySelector('.bubble').textContent = r.reply || '(no reply)';
|
||||
$('#chat').scrollTop=$('#chat').scrollHeight;
|
||||
}catch(e){
|
||||
$('#chat').lastChild.querySelector('.bubble').textContent='relay error: '+e;
|
||||
}
|
||||
}
|
||||
$('#send').onclick=send;
|
||||
$('#prompt').addEventListener('keydown',e=>{
|
||||
if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send(); }
|
||||
});
|
||||
function autosize(){ const t=$('#prompt'); t.style.height='auto'; t.style.height=Math.min(t.scrollHeight,140)+'px'; }
|
||||
$('#prompt').addEventListener('input',autosize);
|
||||
|
||||
// create drawer
|
||||
$('#openCreate').onclick=()=> $('#drawer').classList.add('open');
|
||||
$('#closeCreate').onclick=()=> $('#drawer').classList.remove('open');
|
||||
$('#saveExpert').onclick=async()=>{
|
||||
const name=$('#exName').value.trim();
|
||||
if(!name){ $('#createStatus').textContent='name required'; return; }
|
||||
const r= await post('/api/experts',{
|
||||
name, domain:$('#exDomain').value.trim(),
|
||||
prompt:$('#exPrompt').value, knowledge:$('#exKn').value
|
||||
});
|
||||
if(r.expert){
|
||||
state.experts = await api('/api/experts');
|
||||
renderExperts(); state.expert=r.expert.id; syncSelects();
|
||||
$('#exName').value='';$('#exDomain').value='';$('#exPrompt').value='';$('#exKn').value='';
|
||||
$('#drawer').classList.remove('open');
|
||||
$('#createStatus').textContent='';
|
||||
addMsg('bot','New expert "'+r.expert.name+'" ready. Pick it from the list and ask away.','society');
|
||||
}else{
|
||||
$('#createStatus').textContent= r.error||'save failed';
|
||||
}
|
||||
};
|
||||
|
||||
// upload document -> digest -> append to knowledge box
|
||||
$('#uploadDoc').onclick = () => {
|
||||
const f = $('#exFile').files && $('#exFile').files[0];
|
||||
const st = $('#uploadStatus');
|
||||
if (!f) { st.textContent = 'choose a file first'; return; }
|
||||
st.textContent = 'digesting ' + f.name + '…';
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/ingest', true);
|
||||
xhr.onload = () => {
|
||||
let d; try { d = JSON.parse(xhr.responseText); } catch(e) { d = {}; }
|
||||
if (xhr.status >= 200 && xhr.status < 300 && d.ok) {
|
||||
const cur = $('#exKn').value.trim();
|
||||
const sep = cur ? '\n\n' : '';
|
||||
$('#exKn').value = cur + sep + '--- from ' + (d.filename || f.name) + ' ---\n' + (d.text || '');
|
||||
st.textContent = 'added ' + d.chars + ' chars from ' + (d.filename || f.name) + (d.archived ? ' (stored in CUBE)' : '');
|
||||
} else {
|
||||
st.textContent = 'failed: ' + (d.error || ('HTTP ' + xhr.status));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => { st.textContent = 'network error (is the relay reachable?)'; };
|
||||
xhr.send(fd);
|
||||
};
|
||||
|
||||
boot();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
[
|
||||
{
|
||||
"id": "LFM2.5-2.6B-Q4_K_M",
|
||||
"label": "LFM2.5 2.6B (Q4)",
|
||||
"desc": "Small, fast local model on the workhorse. Best for quick Q&A."
|
||||
},
|
||||
{
|
||||
"id": "local-default",
|
||||
"label": "Default local model",
|
||||
"desc": "Whatever the workhorse llama-server currently serves."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
society-relay — the Oracle-side relay for the CUBELinux "society" mobile chat.
|
||||
|
||||
The llama.cpp model lives on the user's workhorse (LAN-only). This relay runs
|
||||
on the public Oracle box (cubelinux.com) and reaches the model through a reverse
|
||||
SSH tunnel: the workhorse forwards its local :8080 to this box's :8081.
|
||||
|
||||
Endpoints (all JSON, CORS-open for the chat page):
|
||||
GET /api/health -> {"status":"ok","model":...}
|
||||
GET /api/models -> list of {id,label,desc}
|
||||
GET /api/experts -> list of experts (incl. user-created)
|
||||
POST /api/experts -> create an expert {name,domain,prompt,knowledge}
|
||||
POST /api/chat -> {model,expert,messages} -> {reply}
|
||||
|
||||
Expert creation: user experts are appended to experts.json (the "create an
|
||||
expert" flow). The relay injects the expert's prompt + knowledge as the system
|
||||
message so the model answers in that persona. No cloud, no API key — the model
|
||||
is the local llama.cpp on the workhorse.
|
||||
|
||||
Run as a systemd service: `society-relay.service`. Stdlib only (no pip needed).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
MODELS_PATH = os.path.join(HERE, "models.json")
|
||||
EXPERTS_PATH = os.path.join(HERE, "experts.json")
|
||||
UPSTREAM = "http://127.0.0.1:8081/v1/chat/completions" # <- tunnel to workhorse :8080
|
||||
DEFAULT_MODEL = "LFM2.5-2.6B-Q4_K_M"
|
||||
|
||||
|
||||
def load_json(path, default):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def save_experts(data):
|
||||
tmp = EXPERTS_PATH + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
os.replace(tmp, EXPERTS_PATH)
|
||||
|
||||
|
||||
def build_system(expert):
|
||||
"""System prompt = persona + knowledge, so the model answers in-character."""
|
||||
parts = [expert.get("prompt", "").strip()]
|
||||
kn = expert.get("knowledge", "").strip()
|
||||
if kn:
|
||||
parts.append("KNOWLEDGE / CONTEXT (use this; do not invent outside it):")
|
||||
parts.append(kn)
|
||||
return "\n\n".join(p for p in parts if p)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
# ---- helpers -----------------------------------------------------------
|
||||
def _send(self, code, obj, ctype="application/json"):
|
||||
body = json.dumps(obj).encode("utf-8") if isinstance(obj, (dict, list)) else obj
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _recv(self):
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
if length <= 0:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return {}
|
||||
|
||||
def _log(self, *a): # quieter logs (not overriding BaseHTTPRequestHandler.log_message)
|
||||
pass
|
||||
|
||||
# ---- routing -----------------------------------------------------------
|
||||
def do_OPTIONS(self):
|
||||
self._send(204, b"")
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/api/health" or self.path.startswith("/api/health?"):
|
||||
models = load_json(MODELS_PATH, [])
|
||||
self._send(200, {"status": "ok", "model": models[0]["id"] if models else DEFAULT_MODEL})
|
||||
elif self.path == "/api/models":
|
||||
self._send(200, load_json(MODELS_PATH, []))
|
||||
elif self.path == "/api/experts":
|
||||
self._send(200, load_json(EXPERTS_PATH, []))
|
||||
else:
|
||||
self._send(404, {"error": "not found"})
|
||||
|
||||
def do_POST(self):
|
||||
if self.path == "/api/chat":
|
||||
self._chat()
|
||||
elif self.path == "/api/experts":
|
||||
self._create_expert()
|
||||
else:
|
||||
self._send(404, {"error": "not found"})
|
||||
|
||||
# ---- endpoints ---------------------------------------------------------
|
||||
def _chat(self):
|
||||
req = self._recv()
|
||||
messages = req.get("messages", [])
|
||||
model = req.get("model") or DEFAULT_MODEL
|
||||
expert = req.get("expert") # id string
|
||||
|
||||
# Inject the chosen expert as system persona if provided.
|
||||
if expert:
|
||||
for e in load_json(EXPERTS_PATH, []):
|
||||
if e.get("id") == expert:
|
||||
sysmsg = build_system(e)
|
||||
if sysmsg:
|
||||
messages = [{"role": "system", "content": sysmsg}] + messages
|
||||
break
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": float(req.get("temperature", 0.7)),
|
||||
"stream": False,
|
||||
}
|
||||
try:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
r = urllib.request.Request(
|
||||
UPSTREAM,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(r, timeout=120) as resp:
|
||||
upstream = json.loads(resp.read().decode("utf-8"))
|
||||
reply = upstream["choices"][0]["message"]["content"]
|
||||
self._send(200, {"reply": reply})
|
||||
except urllib.error.HTTPError as e:
|
||||
self._send(e.code, {"error": f"upstream http {e.code}"})
|
||||
except urllib.error.URLError as e:
|
||||
self._send(502, {"error": f"tunnel/upstream unreachable: {e.reason}"})
|
||||
except (KeyError, IndexError, ValueError) as e:
|
||||
self._send(502, {"error": f"bad upstream response: {e}"})
|
||||
|
||||
def _create_expert(self):
|
||||
req = self._recv()
|
||||
name = (req.get("name") or "").strip()
|
||||
if not name:
|
||||
self._send(400, {"error": "name required"})
|
||||
prompt = (req.get("prompt") or "").strip()
|
||||
knowledge = (req.get("knowledge") or "").strip()
|
||||
domain = (req.get("domain") or "usr").strip() or "usr"
|
||||
# stable id: slug of name + short hash
|
||||
slug = "".join(c if c.isalnum() else "-" for c in name.lower())[:32]
|
||||
eid = f"usr-{slug}"
|
||||
expert = {
|
||||
"id": eid,
|
||||
"name": name,
|
||||
"domain": domain,
|
||||
"prompt": prompt or f"You are {name}.",
|
||||
"knowledge": knowledge,
|
||||
"user_created": True,
|
||||
}
|
||||
experts = load_json(EXPERTS_PATH, [])
|
||||
# replace if same id exists
|
||||
experts = [e for e in experts if e.get("id") != eid]
|
||||
experts.append(expert)
|
||||
try:
|
||||
save_experts(experts)
|
||||
except OSError as e:
|
||||
self._send(500, {"error": f"could not save: {e}"})
|
||||
return
|
||||
self._send(201, {"expert": expert})
|
||||
|
||||
|
||||
def main():
|
||||
port = int(os.environ.get("RELAY_PORT", "9001"))
|
||||
srv = ThreadingHTTPServer(("127.0.0.1", port), Handler)
|
||||
print(f"society-relay listening on 127.0.0.1:{port}", flush=True)
|
||||
srv.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=CUBELinux society relay (Oracle side) — proxies /api to workhorse via tunnel
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
User=opc
|
||||
WorkingDirectory=/home/cubelinux/relay
|
||||
Environment=RELAY_PORT=9001
|
||||
ExecStart=/usr/bin/python3 /home/cubelinux/relay/relay.py
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,35 @@
|
||||
# --- CUBELinux society relay (added 2026-08-12) ---
|
||||
# Proxy /api/* to the local relay (which tunnels back to the workhorse model).
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:9001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
}
|
||||
|
||||
# Mobile-only gate for the chat UI. Desktop user-agents get 403.
|
||||
# NOTE: UA is spoofable — this is a soft gate for "share with my brother",
|
||||
# not real auth. Pair with an unguessable URL if you want stronger control.
|
||||
map $http_user_agent $is_mobile {
|
||||
default 0;
|
||||
"~*(android|iphone|ipad|ipod|mobile|windows phone|blackberry|webos|opera mini|crios|fxios)" 1;
|
||||
}
|
||||
|
||||
location = /chat/ {
|
||||
# Serve the chat SPA at a clean path.
|
||||
alias /home/cubelinux/html/chat/index.html;
|
||||
default_type text/html;
|
||||
if ($is_mobile = 0) {
|
||||
return 403 'Desktop access is disabled. Open this link on a phone.';
|
||||
}
|
||||
}
|
||||
|
||||
location /chat/ {
|
||||
alias /home/cubelinux/html/chat/;
|
||||
if ($is_mobile = 0) {
|
||||
return 403 'Desktop access is disabled. Open this link on a phone.';
|
||||
}
|
||||
}
|
||||
# --- end society relay ---
|
||||
@@ -0,0 +1,267 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>CUBE-VM · Worker Control</title>
|
||||
<style>
|
||||
:root{
|
||||
--gold:#d8af68; --gold-hi:#fff9b1; --cyan:#39d7ff; --bg:#0e1013;
|
||||
--panel:#16191d; --panel2:#1c2026; --line:#2a2f37; --txt:#e8eaeb; --dim:#8b929c;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--txt);
|
||||
font:14px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
||||
header{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;
|
||||
align-items:center;gap:12px;background:linear-gradient(90deg,#121519,#0e1013)}
|
||||
header h1{font-size:16px;margin:0;letter-spacing:.5px;color:var(--gold)}
|
||||
header .sub{color:var(--dim);font-size:12px}
|
||||
.wrap{display:grid;grid-template-columns:340px 1fr;gap:14px;padding:14px}
|
||||
.panel{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px}
|
||||
.panel h2{margin:0 0 10px;font-size:13px;color:var(--cyan);letter-spacing:.4px;
|
||||
text-transform:uppercase}
|
||||
button{background:var(--panel2);color:var(--txt);border:1px solid var(--line);
|
||||
border-radius:6px;padding:7px 11px;cursor:pointer;font:inherit}
|
||||
button:hover{border-color:var(--gold)}
|
||||
button.primary{background:var(--gold);color:#1a1300;border-color:var(--gold);font-weight:600}
|
||||
button.danger{border-color:#a44;color:#fbb}
|
||||
button.ghost{background:transparent}
|
||||
input,textarea,select{background:#0c0e11;color:var(--txt);border:1px solid var(--line);
|
||||
border-radius:6px;padding:7px 9px;width:100%;font:inherit}
|
||||
label{display:block;margin:9px 0 3px;color:var(--dim);font-size:12px}
|
||||
.row{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.tools{display:grid;grid-template-columns:1fr 1fr;gap:5px;margin-top:4px}
|
||||
.tool{display:flex;align-items:center;gap:7px;background:#0c0e11;border:1px solid var(--line);
|
||||
border-radius:6px;padding:5px 8px;cursor:pointer;font-size:12px}
|
||||
.tool input{width:auto}
|
||||
.tool.on{border-color:var(--cyan);color:var(--cyan)}
|
||||
.profile{border:1px solid var(--line);border-radius:7px;padding:9px;margin-bottom:8px;
|
||||
cursor:pointer;background:var(--panel2)}
|
||||
.profile:hover{border-color:var(--gold)}
|
||||
.profile.sel{border-color:var(--gold);box-shadow:0 0 0 1px var(--gold) inset}
|
||||
.profile .nm{color:var(--gold);font-weight:600}
|
||||
.profile .meta{color:var(--dim);font-size:11px;margin-top:3px}
|
||||
.pill{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;
|
||||
border:1px solid var(--line)}
|
||||
.pill.run{color:#9f9; border-color:#3a6}
|
||||
.pill.stop{color:var(--dim)}
|
||||
.pill.boot{color:var(--gold-hi);border-color:var(--gold)}
|
||||
.pill.fail{color:#f99;border-color:#a44}
|
||||
pre{background:#0a0c0f;border:1px solid var(--line);border-radius:6px;padding:10px;
|
||||
margin:0;max-height:320px;overflow:auto;color:#bfe;white-space:pre-wrap;font-size:12px}
|
||||
.statusbar{display:flex;gap:14px;align-items:center;margin-bottom:10px;flex-wrap:wrap}
|
||||
.hint{color:var(--dim);font-size:11px;margin-top:6px}
|
||||
.tag{color:var(--cyan)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>CUBE⊞</h1>
|
||||
<span class="sub">Squared Plus · Worker control · profiles stored in workhorse CUBE (ns: cube-vm) · guest boots in-memory and pulls its profile</span>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
<!-- LEFT: profile roster + editor -->
|
||||
<div>
|
||||
<div class="panel">
|
||||
<h2>Workers</h2>
|
||||
<div class="row" style="margin-bottom:8px">
|
||||
<button id="newBtn">+ New</button>
|
||||
<button id="saveBtn" class="primary">Save</button>
|
||||
<button id="delBtn" class="danger ghost">Delete</button>
|
||||
</div>
|
||||
<div id="roster"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: editor + status -->
|
||||
<div>
|
||||
<div class="panel">
|
||||
<h2>Profile Editor</h2>
|
||||
<div class="row">
|
||||
<div style="flex:1"><label>Profile ID</label><input id="pid" placeholder="e.g. worker-nginx-ops"></div>
|
||||
<div style="flex:1"><label>Display name</label><input id="pname" placeholder="NGINX Ops Worker"></div>
|
||||
</div>
|
||||
|
||||
<label>Tool set <span class="hint">(a worker's capabilities — each worker can differ entirely)</span></label>
|
||||
<div class="tools" id="tools"></div>
|
||||
|
||||
<div class="row" style="margin-top:10px">
|
||||
<div style="flex:1"><label>Model binding</label>
|
||||
<select id="model">
|
||||
<option value="local-default">local-default</option>
|
||||
<option value="LFM2.5-2.6B-Q4_K_M">LFM2.5 2.6B (Q4)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex:1"><label>vCPUs</label><input id="vcpus" type="number" value="2"></div>
|
||||
<div style="flex:1"><label>RAM (MB)</label><input id="mem" type="number" value="2048"></div>
|
||||
</div>
|
||||
|
||||
<label>Expert / persona</label>
|
||||
<input id="exDomain" placeholder="domain e.g. ops" style="margin-bottom:6px">
|
||||
<textarea id="exPrompt" rows="2" placeholder="System prompt / persona"></textarea>
|
||||
<textarea id="exKnowledge" rows="3" placeholder="Domain knowledge (recall) the worker loads on boot"></textarea>
|
||||
|
||||
<label>Recall coordinates <span class="hint">(specific CUBE coords/namespaces replayed into the guest in-memory store on boot — curate per worker for isolation)</span></label>
|
||||
<textarea id="recall" rows="2" placeholder='{"coords":["..."],"namespaces":["hermes"]}'></textarea>
|
||||
<div class="hint">JSON: {"coords":[...], "namespaces":[...]}</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="margin-top:14px">
|
||||
<h2>Runtime</h2>
|
||||
<div class="statusbar">
|
||||
<span id="statePill" class="pill stop">stopped</span>
|
||||
<span id="pidTxt" class="dim"></span>
|
||||
<button id="launchBtn" class="primary">Launch with this profile</button>
|
||||
<button id="saveBtn2">Save state</button>
|
||||
<button id="saveStopBtn" class="danger ghost">Save & stop</button>
|
||||
<button id="killBtn" class="danger">Kill VM</button>
|
||||
<button id="refreshBtn" class="ghost">Refresh</button>
|
||||
</div>
|
||||
<div class="hint">Autosave keeps a rolling snapshot every 15 min while running (rolling tag <span class="tag">autosave-rolling</span>). "Save state" is an on-demand snapshot without stopping; "Save & stop" freezes the exact running session into the qcow2 then quits cleanly.</div>
|
||||
<pre id="serial">— serial output —</pre>
|
||||
<div class="hint">Launch passes the profile id to the guest boot; the guest pulls tools/expert/recall from workhorse CUBE over the host bridge.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// The 10 real agent tool primitives (from crates/cube-agent/src/tools.rs)
|
||||
const TOOL_PRIMS = [
|
||||
"read_file","list_dir","shell_ro","write_agent_file",
|
||||
"cube_write","cube_read","cube_list","web_lookup",
|
||||
"run_tool","spawn"
|
||||
];
|
||||
let current = null; // selected profile id
|
||||
|
||||
function toolBox(){
|
||||
const box = document.getElementById('tools'); box.innerHTML='';
|
||||
TOOL_PRIMS.forEach(t=>{
|
||||
const d=document.createElement('label'); d.className='tool'; d.dataset.t=t;
|
||||
d.innerHTML=`<input type="checkbox"><span>${t}</span>`;
|
||||
d.onclick=e=>{e.preventDefault(); d.classList.toggle('on');
|
||||
const c=d.querySelector('input'); c.checked=!c.checked;};
|
||||
box.appendChild(d);
|
||||
});
|
||||
}
|
||||
function setTools(arr){
|
||||
document.querySelectorAll('#tools .tool').forEach(d=>{
|
||||
const on=arr.includes(d.dataset.t); d.classList.toggle('on',on);
|
||||
d.querySelector('input').checked=on;
|
||||
});
|
||||
}
|
||||
function getTools(){
|
||||
return [...document.querySelectorAll('#tools .tool.on')].map(d=>d.dataset.t);
|
||||
}
|
||||
function fill(p){
|
||||
document.getElementById('pid').value = current||'';
|
||||
document.getElementById('pname').value = p.name||current||'';
|
||||
document.getElementById('model').value = p.model||'local-default';
|
||||
document.getElementById('vcpus').value = (p.runtime&&p.runtime.vcpus)||2;
|
||||
document.getElementById('mem').value = (p.runtime&&p.runtime.mem_mb)||2048;
|
||||
const ex=p.expert||{};
|
||||
document.getElementById('exDomain').value=ex.domain||'';
|
||||
document.getElementById('exPrompt').value=ex.prompt||'';
|
||||
document.getElementById('exKnowledge').value=ex.knowledge||'';
|
||||
document.getElementById('recall').value=JSON.stringify(p.recall||{coords:[],namespaces:[]});
|
||||
setTools(p.tools||[]);
|
||||
}
|
||||
function readEditor(){
|
||||
let recall={coords:[],namespaces:[]};
|
||||
try{ recall=JSON.parse(document.getElementById('recall').value||'{}'); }catch(e){}
|
||||
return {
|
||||
name:document.getElementById('pname').value,
|
||||
tools:getTools(),
|
||||
model:document.getElementById('model').value,
|
||||
expert:{
|
||||
domain:document.getElementById('exDomain').value,
|
||||
prompt:document.getElementById('exPrompt').value,
|
||||
knowledge:document.getElementById('exKnowledge').value
|
||||
},
|
||||
recall:recall,
|
||||
runtime:{vcpus:+document.getElementById('vcpus').value,
|
||||
mem_mb:+document.getElementById('mem').value}
|
||||
};
|
||||
}
|
||||
async function loadRoster(){
|
||||
const r=await fetch('/api/cube-vm/configs').then(x=>x.json());
|
||||
const box=document.getElementById('roster'); box.innerHTML='';
|
||||
(r.profiles||[]).forEach(p=>{
|
||||
const d=document.createElement('div'); d.className='profile'+(p.id===current?' sel':'');
|
||||
const meta=(p.profile||{});
|
||||
const tools=(meta.tools||[]).length;
|
||||
d.innerHTML=`<div class="nm">${p.id}</div>
|
||||
<div class="meta">${(meta.name||'')} · ${tools} tools · ${(meta.model||'')}</div>`;
|
||||
d.onclick=()=>{ current=p.id; fill(meta); loadRoster(); };
|
||||
box.appendChild(d);
|
||||
});
|
||||
}
|
||||
async function save(){
|
||||
const id=document.getElementById('pid').value.trim();
|
||||
if(!id){alert('Set a Profile ID first');return;}
|
||||
current=id;
|
||||
const body={id, profile:readEditor()};
|
||||
const r=await fetch('/api/cube-vm/config',{method:'POST',
|
||||
headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||||
const j=await r.json();
|
||||
if(j.ok){await loadRoster(); alert('Saved '+id);}
|
||||
else alert('Save failed: '+(j.error||JSON.stringify(j)));
|
||||
}
|
||||
async function del(){
|
||||
if(!current)return;
|
||||
if(!confirm('Delete profile '+current+'?'))return;
|
||||
const r=await fetch('/api/cube-vm/delete',{method:'POST',
|
||||
headers:{'Content-Type':'application/json'},body:JSON.stringify({id:current})});
|
||||
const j=await r.json();
|
||||
if(j.ok){const gone=current; current=null; await loadRoster(); alert('Deleted '+gone);}
|
||||
else alert('Delete failed: '+(j.error||JSON.stringify(j)));
|
||||
}
|
||||
async function launch(){
|
||||
if(!current){alert('Select or save a profile first');return;}
|
||||
const r=await fetch('/api/cube-vm/launch',{method:'POST',
|
||||
headers:{'Content-Type':'application/json'},body:JSON.stringify({id:current})});
|
||||
const j=await r.json();
|
||||
if(j.ok){poll();} else alert('Launch failed: '+(j.error||JSON.stringify(j)));
|
||||
}
|
||||
async function kill(){
|
||||
const r=await fetch('/api/cube-vm/kill',{method:'POST'});
|
||||
poll();
|
||||
}
|
||||
async function saveState(){
|
||||
const r=await fetch('/api/cube-vm/save-state',{method:'POST',
|
||||
headers:{'Content-Type':'application/json'},body:'{}'});
|
||||
const j=await r.json();
|
||||
alert(j.ok?('Saved snapshot: '+(j.tag||'')):'Save failed: '+(j.error||JSON.stringify(j)));
|
||||
poll();
|
||||
}
|
||||
async function saveAndStop(){
|
||||
if(!confirm('Save the running session, then stop the VM?'))return;
|
||||
const r=await fetch('/api/cube-vm/save-and-stop',{method:'POST',
|
||||
headers:{'Content-Type':'application/json'},body:'{}'});
|
||||
const j=await r.json();
|
||||
alert(j.ok?('Saved & stopped ('+(j.tag||'')+')'):'Failed: '+(j.error||JSON.stringify(j)));
|
||||
poll();
|
||||
}
|
||||
async function poll(){
|
||||
const r=await fetch('/api/cube-vm/status').then(x=>x.json());
|
||||
const pill=document.getElementById('statePill');
|
||||
pill.className='pill '+(r.running?(r.boot_state==='login-ready'?'run':(r.boot_state==='boot-failed'?'fail':'boot')):'stop');
|
||||
pill.textContent=r.running?r.boot_state:'stopped';
|
||||
document.getElementById('pidTxt').textContent=r.running?('pid '+r.pid):'';
|
||||
document.getElementById('serial').textContent=r.serial_tail||'— serial output —';
|
||||
}
|
||||
document.getElementById('newBtn').onclick=()=>{current=null;
|
||||
fill({name:'',tools:[],expert:{},recall:{coords:[],namespaces:[]},runtime:{vcpus:2,mem_mb:2048}});
|
||||
document.getElementById('pid').value='';loadRoster();};
|
||||
document.getElementById('saveBtn').onclick=save;
|
||||
document.getElementById('delBtn').onclick=del;
|
||||
document.getElementById('launchBtn').onclick=launch;
|
||||
document.getElementById('killBtn').onclick=kill;
|
||||
document.getElementById('saveBtn2').onclick=saveState;
|
||||
document.getElementById('saveStopBtn').onclick=saveAndStop;
|
||||
document.getElementById('refreshBtn').onclick=poll;
|
||||
toolBox(); loadRoster(); poll();
|
||||
setInterval(poll, 4000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
CUBELinux is a coordinate-addressed data store. Records live at CZYX coordinates, not file paths. The metatag vocabulary tags each record with doc_type, title, and linked_records so associations are first-class.
|
||||
Binary file not shown.
@@ -0,0 +1,32 @@
|
||||
%PDF-1.4
|
||||
1 0 obj
|
||||
<< /Type /Catalog /Pages 2 0 R >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>
|
||||
endobj
|
||||
4 0 obj
|
||||
<< /Length 47 >>
|
||||
stream
|
||||
BT /F1 18 Tf 72 700 Td (VERIFY PDF TEXT ok.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
5 0 obj
|
||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
|
||||
endobj
|
||||
xref
|
||||
0 6
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000115 00000 n
|
||||
0000000241 00000 n
|
||||
0000000341 00000 n
|
||||
trailer
|
||||
<< /Size 6 /Root 1 0 R >>
|
||||
startxref
|
||||
411
|
||||
%%EOF
|
||||
@@ -0,0 +1,32 @@
|
||||
%PDF-1.4
|
||||
1 0 obj
|
||||
<< /Type /Catalog /Pages 2 0 R >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>
|
||||
endobj
|
||||
4 0 obj
|
||||
<< /Length 90 >>
|
||||
stream
|
||||
BT /F1 18 Tf 72 700 Td (HERMES VERIFY. The quick brown fox jumps over the lazy dog.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
5 0 obj
|
||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
|
||||
endobj
|
||||
xref
|
||||
0 6
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000115 00000 n
|
||||
0000000241 00000 n
|
||||
0000000381 00000 n
|
||||
trailer
|
||||
<< /Size 6 /Root 1 0 R >>
|
||||
startxref
|
||||
451
|
||||
%%EOF
|
||||
@@ -0,0 +1 @@
|
||||
CUBELinux addresses data by CZYX coordinate, not path. Metatagged records make linked associations first-class.
|
||||
@@ -0,0 +1 @@
|
||||
CUBELinux stores records at CZYX coordinates instead of file paths. Metatags make associations first-class.
|
||||
@@ -0,0 +1,32 @@
|
||||
%PDF-1.4
|
||||
1 0 obj
|
||||
<< /Type /Catalog /Pages 2 0 R >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>
|
||||
endobj
|
||||
4 0 obj
|
||||
<< /Length 47 >>
|
||||
stream
|
||||
BT /F1 18 Tf 72 700 Td (HTTP 201 should now be accepted.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
5 0 obj
|
||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
|
||||
endobj
|
||||
xref
|
||||
0 6
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000115 00000 n
|
||||
0000000241 00000 n
|
||||
0000000354 00000 n
|
||||
trailer
|
||||
<< /Size 6 /Root 1 0 R >>
|
||||
startxref
|
||||
424
|
||||
%%EOF
|
||||
@@ -0,0 +1 @@
|
||||
<html><body><h1>Title</h1><p>Body text here</p></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
{ "a": 1, "b": [2,3] }
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,32 @@
|
||||
%PDF-1.4
|
||||
1 0 obj
|
||||
<< /Type /Catalog /Pages 2 0 R >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>
|
||||
endobj
|
||||
4 0 obj
|
||||
<< /Length 96 >>
|
||||
stream
|
||||
BT /F1 18 Tf 72 700 Td (CUBE Knowledge Test. The quick brown fox jumps over the lazy dog.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
5 0 obj
|
||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
|
||||
endobj
|
||||
xref
|
||||
0 6
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000115 00000 n
|
||||
0000000241 00000 n
|
||||
0000000387 00000 n
|
||||
trailer
|
||||
<< /Size 6 /Root 1 0 R >>
|
||||
startxref
|
||||
457
|
||||
%%EOF
|
||||
+733
-423
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Document text extraction for CUBE ingest.
|
||||
|
||||
Pure functions, no I/O side effects beyond reading the in-memory bytes.
|
||||
Each extractor takes (raw_bytes) and returns a cleaned string or raises.
|
||||
|
||||
Supported kinds (by extension / content sniff):
|
||||
pdf -> pypdf
|
||||
docx -> python-docx
|
||||
xlsx -> openpyxl (sheet-by-sheet, as TSV-ish text)
|
||||
pptx -> python-pptx
|
||||
odt/odp/ods -> odfpy
|
||||
html/htm -> BeautifulSoup -> text
|
||||
rtf -> striprtf
|
||||
csv -> decoded text
|
||||
txt/md/json/log/yml/yaml/xml/py/js/ts/css/sh/conf/ini/cfg -> utf-8 text
|
||||
"""
|
||||
import io
|
||||
import re
|
||||
|
||||
# Optional imports (all installed in the venv); import lazily so a missing
|
||||
# optional dep only disables that one format instead of the whole service.
|
||||
try:
|
||||
from pypdf import PdfReader
|
||||
except Exception:
|
||||
PdfReader = None
|
||||
try:
|
||||
from docx import Document as _docx_Document
|
||||
except Exception:
|
||||
_docx_Document = None
|
||||
try:
|
||||
import openpyxl as _openpyxl
|
||||
except Exception:
|
||||
_openpyxl = None
|
||||
try:
|
||||
from pptx import Presentation as _pptx_Presentation
|
||||
except Exception:
|
||||
_pptx_Presentation = None
|
||||
try:
|
||||
from odf.opendocument import load as _odf_load
|
||||
from odf.text import P as _odf_p
|
||||
from odf.table import Table as _odf_table, TableRow as _odf_row, TableCell as _odf_cell
|
||||
except Exception:
|
||||
_odf_load = None
|
||||
try:
|
||||
from bs4 import BeautifulSoup as _BeautifulSoup
|
||||
except Exception:
|
||||
_BeautifulSoup = None
|
||||
try:
|
||||
from striprtf.striprtf import rtf_to_text as _rtf2text
|
||||
except Exception:
|
||||
_rtf2text = None
|
||||
|
||||
# Extensions we accept and the extractor that handles each.
|
||||
TEXT_EXTS = {"txt", "md", "markdown", "json", "log", "yml", "yaml", "xml",
|
||||
"py", "js", "ts", "css", "sh", "bash", "conf", "ini", "cfg",
|
||||
"toml", "csv", "tsv", "text"}
|
||||
FORMAT_EXTS = {"pdf", "docx", "xlsx", "xls", "pptx", "odt", "odp", "ods",
|
||||
"html", "htm", "rtf"}
|
||||
|
||||
ALLOWED_EXTS = TEXT_EXTS | FORMAT_EXTS
|
||||
MAX_TEXT_CHARS = 200_000 # ~200k chars (~40-50 pages) is plenty for an expert persona
|
||||
MAX_RAW_BYTES = 32 * 1024 * 1024 # 32 MiB ceiling (nginx also caps)
|
||||
|
||||
|
||||
def _sniff_ext(filename: str) -> str:
|
||||
name = (filename or "").lower()
|
||||
if "." in name:
|
||||
return name.rsplit(".", 1)[1]
|
||||
return ""
|
||||
|
||||
|
||||
def _clean(text: str) -> str:
|
||||
if text is None:
|
||||
return ""
|
||||
# Normalize whitespace: collapse 3+ blank lines, trim trailing space.
|
||||
text = re.sub(r"[ \t]+", " ", text)
|
||||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _pdf(raw: bytes) -> str:
|
||||
if PdfReader is None:
|
||||
raise RuntimeError("PDF support unavailable (pypdf missing)")
|
||||
out = []
|
||||
reader = PdfReader(io.BytesIO(raw))
|
||||
for page in reader.pages:
|
||||
try:
|
||||
out.append(page.extract_text() or "")
|
||||
except Exception:
|
||||
pass
|
||||
return _clean("\n\n".join(out))
|
||||
|
||||
|
||||
def _docx(raw: bytes) -> str:
|
||||
if _docx_Document is None:
|
||||
raise RuntimeError("DOCX support unavailable (python-docx missing)")
|
||||
doc = _docx_Document(io.BytesIO(raw))
|
||||
parts = [p.text for p in doc.paragraphs]
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
parts.append(" | ".join(c.text for c in row.cells))
|
||||
return _clean("\n".join(p for p in parts if p))
|
||||
|
||||
|
||||
def _xlsx(raw: bytes) -> str:
|
||||
if _openpyxl is None:
|
||||
raise RuntimeError("XLSX support unavailable (openpyxl missing)")
|
||||
wb = _openpyxl.load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
|
||||
blocks = []
|
||||
for ws in wb.worksheets:
|
||||
rows = []
|
||||
for r in ws.iter_rows(values_only=True):
|
||||
rows.append("\t".join("" if c is None else str(c) for c in r))
|
||||
blocks.append(f"# Sheet: {ws.title}\n" + "\n".join(rows))
|
||||
return _clean("\n\n".join(blocks))
|
||||
|
||||
|
||||
def _pptx(raw: bytes) -> str:
|
||||
if _pptx_Presentation is None:
|
||||
raise RuntimeError("PPTX support unavailable (python-pptx missing)")
|
||||
prs = _pptx_Presentation(io.BytesIO(raw))
|
||||
parts = []
|
||||
for slide in prs.slides:
|
||||
for shape in slide.shapes:
|
||||
if shape.has_text_frame:
|
||||
parts.append(shape.text_frame.text)
|
||||
return _clean("\n".join(p for p in parts if p))
|
||||
|
||||
|
||||
def _odf(raw: bytes) -> str:
|
||||
if _odf_load is None:
|
||||
raise RuntimeError("ODF support unavailable (odfpy missing)")
|
||||
doc = _odf_load(io.BytesIO(raw))
|
||||
parts = []
|
||||
for p in doc.getElementsByType(_odf_p):
|
||||
parts.append("".join(str(n) for n in p.childNodes if n.nodeType == 3))
|
||||
# also pull table cells
|
||||
for tbl in doc.getElementsByType(_odf_table):
|
||||
for row in tbl.getElementsByType(_odf_row):
|
||||
cells = [c.firstChild.data if (c.firstChild and c.firstChild.data) else ""
|
||||
for c in row.getElementsByType(_odf_cell)]
|
||||
parts.append(" | ".join(cells))
|
||||
return _clean("\n".join(parts))
|
||||
|
||||
|
||||
def _html(raw: bytes) -> str:
|
||||
if _BeautifulSoup is None:
|
||||
# Fallback: strip tags with a regex.
|
||||
return _clean(re.sub(r"<[^>]+>", " ", raw.decode("utf-8", "replace")))
|
||||
soup = _BeautifulSoup(raw.decode("utf-8", "replace"), "html.parser")
|
||||
for tag in soup(["script", "style"]):
|
||||
tag.decompose()
|
||||
return _clean(soup.get_text("\n"))
|
||||
|
||||
|
||||
def _rtf(raw: bytes) -> str:
|
||||
if _rtf2text is None:
|
||||
raise RuntimeError("RTF support unavailable (striprtf missing)")
|
||||
return _clean(_rtf2text(raw.decode("utf-8", "replace")))
|
||||
|
||||
|
||||
def extract(filename: str, raw: bytes) -> str:
|
||||
"""Return cleaned plain text from `raw`. Raises on unknown/unsupported."""
|
||||
if len(raw) > MAX_RAW_BYTES:
|
||||
raise ValueError(f"file too large ({len(raw)} bytes > {MAX_RAW_BYTES})")
|
||||
ext = _sniff_ext(filename)
|
||||
if ext not in ALLOWED_EXTS:
|
||||
raise ValueError(f"unsupported file type: .{ext or '?'}")
|
||||
if ext in TEXT_EXTS:
|
||||
text = _clean(raw.decode("utf-8", "replace"))
|
||||
elif ext == "pdf":
|
||||
text = _pdf(raw)
|
||||
elif ext == "docx":
|
||||
text = _docx(raw)
|
||||
elif ext in ("xlsx", "xls"):
|
||||
text = _xlsx(raw)
|
||||
elif ext == "pptx":
|
||||
text = _pptx(raw)
|
||||
elif ext in ("odt", "odp", "ods"):
|
||||
text = _odf(raw)
|
||||
elif ext in ("html", "htm"):
|
||||
text = _html(raw)
|
||||
elif ext == "rtf":
|
||||
text = _rtf(raw)
|
||||
else:
|
||||
raise ValueError(f"unsupported file type: .{ext}")
|
||||
if not text:
|
||||
raise ValueError("no extractable text found (is the file empty or image-only?)")
|
||||
if len(text) > MAX_TEXT_CHARS:
|
||||
text = text[:MAX_TEXT_CHARS] + "\n\n[… truncated to first %d characters …]" % MAX_TEXT_CHARS
|
||||
return text
|
||||
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
cube-ingest — CUBELinux document ingestion service (workhorse side).
|
||||
|
||||
Listens on 127.0.0.1:8083. Reached from the public Oracle relay via the
|
||||
existing reverse SSH tunnel: Oracle:8083 -> workhorse:8083.
|
||||
|
||||
Flow:
|
||||
POST /api/ingest (multipart: file) -> extract text -> store in CUBE
|
||||
GET /api/ingest/health -> {ok, cubed}
|
||||
|
||||
Document text is extracted (PDF/DOCX/XLSX/PPTX/ODT/HTML/RTF/TXT/CSV/MD/...)
|
||||
and written into the canonical CUBE store (cubed daemon, namespace "knowledge")
|
||||
via cube_bridge. The returned digest reference is what the chat SPA injects into
|
||||
an expert's "Knowledge / context" block.
|
||||
|
||||
Stdlib HTTP server + the venv's extraction libs. Run as a systemd --user unit
|
||||
under luulu (same user that owns cubed), so the CUBE socket is reachable.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
# cube_bridge lives in the agent dir; it is the canonical CUBE adapter.
|
||||
sys.path.insert(0, "/home/luulu/.cubelinux-agent")
|
||||
import cube_bridge as cb
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
from extract import extract, ALLOWED_EXTS, MAX_RAW_BYTES # noqa: E402
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
PORT = 8083
|
||||
CUBE_NS = "knowledge" # canonical CUBE namespace for ingested knowledge
|
||||
KIND = "knowledge-doc" # cube_bridge kind tag
|
||||
|
||||
# Files are also kept verbatim under here (nginx /files not exposed publicly;
|
||||
# this is a local archive on the workhorse, useful for re-digest / audit).
|
||||
ARCHIVE_DIR = "/home/CUBELinux/html/files/knowledge"
|
||||
MAX_TEXT_CHARS = 200_000
|
||||
|
||||
|
||||
def _safe(s: str) -> str:
|
||||
s = re.sub(r"[^A-Za-z0-9._()-]", "_", s).strip("._-") or "doc"
|
||||
return s[:120]
|
||||
|
||||
|
||||
def _cubed_ok() -> bool:
|
||||
return bool(cb._daemon_alive())
|
||||
|
||||
|
||||
def parse_multipart(body: bytes, boundary: bytes):
|
||||
"""Minimal multipart/form-data parser (stdlib only; cgi is gone in 3.13).
|
||||
|
||||
Returns a dict: {field_name: {"filename": str|None, "content": bytes}}.
|
||||
Only the first matching field of a given name is kept (we only need 'file').
|
||||
"""
|
||||
out = {}
|
||||
delimiter = b"--" + boundary
|
||||
parts = body.split(delimiter)
|
||||
for part in parts:
|
||||
if not part or part in (b"--\r\n", b"--", b"\r\n", b""):
|
||||
continue
|
||||
# strip leading CRLF after delimiter
|
||||
if part.startswith(b"\r\n"):
|
||||
part = part[2:]
|
||||
if part in (b"--\r\n", b"--"):
|
||||
continue # closing boundary
|
||||
header_end = part.find(b"\r\n\r\n")
|
||||
if header_end == -1:
|
||||
continue
|
||||
raw_headers = part[:header_end].decode("utf-8", "replace")
|
||||
content = part[header_end + 4:]
|
||||
# drop trailing CRLF that precedes the delimiter
|
||||
if content.endswith(b"\r\n"):
|
||||
content = content[:-2]
|
||||
name = filename = None
|
||||
for line in raw_headers.split("\r\n"):
|
||||
low = line.lower()
|
||||
if low.startswith("content-disposition:"):
|
||||
for tok in line.split(";"):
|
||||
tok = tok.strip()
|
||||
if tok.startswith("name="):
|
||||
name = tok[5:].strip('"')
|
||||
elif tok.startswith("filename="):
|
||||
filename = tok[9:].strip('"')
|
||||
if name is not None:
|
||||
out[name] = {"filename": filename, "content": content}
|
||||
return out
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "CUBEIngest/1.0"
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def _json(self, code, obj):
|
||||
body = json.dumps(obj).encode("utf-8")
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
sys.stderr.write("%s %s\n" % (self.log_date_time_string(), fmt % args))
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self._json(204, b"")
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/api/ingest/health"):
|
||||
return self._json(200, {"ok": True, "cubed": _cubed_ok()})
|
||||
return self._json(404, {"error": "not found"})
|
||||
|
||||
def do_POST(self):
|
||||
if not self.path.startswith("/api/ingest"):
|
||||
return self._json(404, {"error": "not found"})
|
||||
if self.path != "/api/ingest":
|
||||
return self._json(404, {"error": "not found"})
|
||||
return self._ingest()
|
||||
|
||||
# ---- ingestion ---------------------------------------------------------
|
||||
def _ingest(self):
|
||||
ctype = self.headers.get("Content-Type", "")
|
||||
if not ctype.startswith("multipart/form-data"):
|
||||
return self._json(400, {"error": "expected multipart/form-data"})
|
||||
|
||||
# Bounded read of the raw body, then parse.
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
except ValueError:
|
||||
return self._json(400, {"error": "bad Content-Length"})
|
||||
if length <= 0:
|
||||
return self._json(400, {"error": "empty body"})
|
||||
if length > MAX_RAW_BYTES:
|
||||
return self._json(413, {"error": "payload too large"})
|
||||
|
||||
body = self.rfile.read(length)
|
||||
|
||||
# Extract the multipart boundary from Content-Type and parse (stdlib only).
|
||||
m = re.search(r"boundary=([^;]+)", ctype)
|
||||
if not m:
|
||||
return self._json(400, {"error": "missing multipart boundary"})
|
||||
boundary = m.group(1).strip().strip('"').encode("utf-8")
|
||||
try:
|
||||
form = parse_multipart(body, boundary)
|
||||
except Exception as e:
|
||||
return self._json(400, {"error": f"bad multipart body: {e}"})
|
||||
|
||||
item = form.get("file")
|
||||
if not item or not item.get("filename"):
|
||||
return self._json(400, {"error": "no file field"})
|
||||
|
||||
filename = os.path.basename(item["filename"])
|
||||
raw = item["content"]
|
||||
|
||||
# 1) extract text
|
||||
try:
|
||||
text = extract(filename, raw)
|
||||
except ValueError as e:
|
||||
return self._json(415, {"error": str(e), "filename": filename})
|
||||
except RuntimeError as e:
|
||||
return self._json(500, {"error": str(e), "filename": filename})
|
||||
except Exception as e: # never leak internals as 500 w/o message
|
||||
return self._json(500, {"error": f"extraction failed: {e}", "filename": filename})
|
||||
|
||||
# 2) build canonical CUBE entry
|
||||
stem = _safe(os.path.splitext(filename)[0])
|
||||
digest = hashlib.sha1(text.encode("utf-8")).hexdigest()[:10]
|
||||
rid = f"kn-{stem}-{digest}"
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
cube_value = json.dumps({
|
||||
"id": rid,
|
||||
"filename": filename,
|
||||
"bytes": len(raw),
|
||||
"chars": len(text),
|
||||
"ingested_at": now,
|
||||
"source": "mobile-upload",
|
||||
"text": text,
|
||||
}, ensure_ascii=False)
|
||||
|
||||
ok = cb.cube_write(CUBE_NS, rid, cube_value, kind=KIND, visibility="private")
|
||||
transport = cb.get_last_transport()
|
||||
if not ok:
|
||||
return self._json(502, {"error": "CUBE store unreachable", "transport": transport})
|
||||
|
||||
# 3) optional verbatim archive on the workhorse
|
||||
archived = False
|
||||
try:
|
||||
os.makedirs(ARCHIVE_DIR, exist_ok=True)
|
||||
ap = os.path.join(ARCHIVE_DIR, f"{rid}-{_safe(filename)}")
|
||||
with open(ap, "wb") as fh:
|
||||
fh.write(raw)
|
||||
os.chmod(ap, 0o640)
|
||||
try:
|
||||
os.chown(ap, 1000, 1000)
|
||||
except OSError:
|
||||
pass
|
||||
archived = True
|
||||
except Exception:
|
||||
archived = False # non-fatal; CUBE already has it
|
||||
|
||||
self.log_message("ingested %s -> CUBE %s/%s (%d chars, archive=%s)",
|
||||
filename, CUBE_NS, rid, len(text), archived)
|
||||
return self._json(201, {
|
||||
"ok": True,
|
||||
"id": rid,
|
||||
"namespace": CUBE_NS,
|
||||
"filename": filename,
|
||||
"chars": len(text),
|
||||
"archived": archived,
|
||||
"transport": transport,
|
||||
# The chat SPA appends `text` straight into the Knowledge field.
|
||||
"text": text,
|
||||
})
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(ARCHIVE_DIR, exist_ok=True)
|
||||
srv = ThreadingHTTPServer((HOST, PORT), Handler)
|
||||
srv.daemon_threads = True
|
||||
sys.stderr.write(f"CUBEIngest listening on {HOST}:{PORT} (CUBE ns={CUBE_NS})\n")
|
||||
sys.stderr.flush()
|
||||
srv.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/* spec.php — public spec / updates gate page for cubelinux.com
|
||||
- GET : shows the intro + registration form (same layout as index.html)
|
||||
- POST: validates Name + Email, stores to MariaDB when wired,
|
||||
otherwise spools to a CSV file. Renders a thank-you / error.
|
||||
DB wiring: fill the $DB_* constants below once MariaDB is provisioned.
|
||||
Until then, submissions land in $SPOOL (readable by you, owned by the
|
||||
web user) so nothing is lost. */
|
||||
|
||||
$DB_HOST = 'localhost';
|
||||
$DB_NAME = 'cubelinux';
|
||||
$DB_USER = 'registrations';
|
||||
$DB_PASS = ''; // TODO: set when MariaDB is provisioned
|
||||
$DB_TABLE = 'spec_registrations';
|
||||
$SPOOL = '/var/lib/cubelinux/spec-registrations.csv';
|
||||
|
||||
$errors = [];
|
||||
$ok = false;
|
||||
$name = $company = $email = '';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$name = trim(strip_tags((string)($_POST['name'] ?? '')));
|
||||
$company = trim(strip_tags((string)($_POST['company'] ?? '')));
|
||||
$email = trim(strip_tags((string)($_POST['email'] ?? '')));
|
||||
$hp = (string)($_POST['website'] ?? ''); // honeypot
|
||||
|
||||
if ($hp !== '') { $errors[] = 'Spam check failed.'; }
|
||||
if ($name === '') { $errors[] = 'Please enter your name.'; }
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors[] = 'Please enter a valid email address.'; }
|
||||
|
||||
if (!$errors) {
|
||||
$log = function(string $s): void {
|
||||
global $SPOOL;
|
||||
@file_put_contents(dirname($SPOOL) . '/spec.log',
|
||||
date('c') . ' ' . $s . "\n", FILE_APPEND);
|
||||
};
|
||||
$stored = false;
|
||||
|
||||
// DB path: only when a password has been configured, and never
|
||||
// let a connection failure break the spool fallback.
|
||||
if ($DB_PASS !== '' && function_exists('mysqli_connect')) {
|
||||
try {
|
||||
$m = mysqli_connect($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME);
|
||||
mysqli_query($m, "CREATE TABLE IF NOT EXISTS `$DB_TABLE` (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
company VARCHAR(255),
|
||||
email VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB");
|
||||
$q = mysqli_query($m, sprintf(
|
||||
"INSERT INTO `$DB_TABLE` (name,company,email) VALUES ('%s','%s','%s')",
|
||||
mysqli_real_escape_string($m, $name),
|
||||
mysqli_real_escape_string($m, $company),
|
||||
mysqli_real_escape_string($m, $email)
|
||||
));
|
||||
$stored = (bool)$q;
|
||||
mysqli_close($m);
|
||||
} catch (\Throwable $e) {
|
||||
$log('DB insert failed: ' . $e->getMessage());
|
||||
$stored = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Always spool to CSV as the durable record until DB confirmed.
|
||||
$dir = dirname($SPOOL);
|
||||
if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
|
||||
$line = sprintf("%s,%s,%s,%s\n",
|
||||
str_replace([",", "\n"], " ", $name),
|
||||
str_replace([",", "\n"], " ", $company),
|
||||
$email, date('c'));
|
||||
$wrote = @file_put_contents($SPOOL, $line, FILE_APPEND);
|
||||
if ($wrote === false) {
|
||||
$log('SPOOL WRITE FAILED: ' . $SPOOL);
|
||||
} else {
|
||||
$log('spooled: ' . $email . ($stored ? ' (also DB)' : ' (DB not configured/live)'));
|
||||
}
|
||||
$ok = true;
|
||||
}
|
||||
}
|
||||
|
||||
function e(string $s): string { return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); }
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0b0e14">
|
||||
<title>CUBELinux — spec & updates</title>
|
||||
<link rel="icon" type="image/png" href="/images/favicon-64.png">
|
||||
<meta name="description" content="Get CUBELinux build updates as they become available, or email us direct.">
|
||||
<style>
|
||||
:root{
|
||||
color-scheme: dark;
|
||||
--gold:#d8af68; --gold-hi:#fff9b1; --cyan:#39d7ff; --cyan-deep:#2384ba;
|
||||
--ink:#e8eaeb; --mute:#8b98a5; --bg:#0b0e14; --line:rgba(216,175,104,.18);
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html{-webkit-text-size-adjust:100%}
|
||||
body{margin:0;background:var(--bg);color:var(--ink);
|
||||
font:16px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
|
||||
-webkit-font-smoothing:antialiased;}
|
||||
a{color:var(--cyan)}
|
||||
header{position:relative;overflow:hidden;border-bottom:1px solid var(--line);
|
||||
padding:clamp(1rem,3vw,1.75rem) clamp(1rem,4vw,2.5rem);
|
||||
background:
|
||||
radial-gradient(60ch 30ch at 12% 30%, rgba(57,215,255,.10), transparent 70%),
|
||||
radial-gradient(40ch 22ch at 6% 40%, rgba(216,175,104,.08), transparent 70%),
|
||||
linear-gradient(180deg, #11151d 0%, var(--bg) 100%);}
|
||||
header::before{content:"";position:absolute;inset:0;
|
||||
background-image:
|
||||
linear-gradient(rgba(216,175,104,.055) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(216,175,104,.055) 1px, transparent 1px);
|
||||
background-size:44px 44px;
|
||||
-webkit-mask-image:radial-gradient(80% 120% at 8% 40%, #000 0%, transparent 75%);
|
||||
mask-image:radial-gradient(80% 120% at 8% 40%, #000 0%, transparent 75%);
|
||||
pointer-events:none;}
|
||||
.bar{position:relative;z-index:1;max-width:1100px;margin:0 auto;
|
||||
display:flex;align-items:center;gap:1.25rem;flex-wrap:wrap;}
|
||||
.brand{display:flex;align-items:center;text-decoration:none;flex-shrink:0;
|
||||
filter:drop-shadow(0 0 18px rgba(57,215,255,.20)) drop-shadow(0 1px 2px rgba(0,0,0,.6));}
|
||||
.brand img{display:block;height:clamp(38px,6vw,52px);width:auto;}
|
||||
.brand:focus-visible{outline:2px solid var(--cyan);outline-offset:6px;border-radius:4px}
|
||||
nav{margin-left:auto;display:flex;gap:.35rem;flex-wrap:wrap}
|
||||
nav a{display:inline-flex;align-items:center;min-height:44px;padding:.5rem .9rem;
|
||||
color:var(--mute);text-decoration:none;font-size:.92rem;
|
||||
border:1px solid transparent;border-radius:6px;}
|
||||
nav a:hover{color:var(--ink);border-color:var(--line);background:rgba(216,175,104,.05)}
|
||||
main{max-width:1100px;margin:0 auto;padding:clamp(2rem,6vw,4.5rem) clamp(1rem,4vw,2.5rem)}
|
||||
h1{font-size:clamp(1.9rem,5vw,3.1rem);line-height:1.12;margin:0 0 1rem;
|
||||
letter-spacing:-.02em;text-wrap:balance;}
|
||||
h1 em{font-style:normal;color:var(--gold)}
|
||||
.lede{font-size:clamp(1.02rem,2.2vw,1.2rem);color:var(--mute);max-width:62ch;text-wrap:pretty}
|
||||
.status{display:inline-flex;align-items:center;gap:.55rem;margin-bottom:1.5rem;
|
||||
padding:.35rem .8rem;border:1px solid var(--line);border-radius:100px;
|
||||
font-size:.78rem;letter-spacing:.1em;text-transform:uppercase;color:var(--gold)}
|
||||
.status::before{content:"";width:6px;height:6px;border-radius:50%;
|
||||
background:var(--cyan);box-shadow:0 0 8px var(--cyan)}
|
||||
.email{color:var(--cyan)}
|
||||
footer{border-top:1px solid var(--line);margin-top:4rem;
|
||||
padding:1.5rem clamp(1rem,4vw,2.5rem);color:var(--mute);font-size:.85rem}
|
||||
footer .in{max-width:1100px;margin:0 auto;display:flex;gap:1rem;flex-wrap:wrap}
|
||||
footer a{margin-left:auto}
|
||||
|
||||
/* ---- registration form ---- */
|
||||
.reg{max-width:62ch;margin:2rem 0 0;display:grid;gap:1.1rem}
|
||||
.reg label{display:grid;gap:.4rem;font-size:.9rem;color:var(--mute)}
|
||||
.reg input{width:100%;padding:.7rem .8rem;font:inherit;color:var(--ink);
|
||||
background:rgba(255,255,255,.03);border:1px solid var(--line);border-radius:8px;}
|
||||
.reg input:focus{outline:none;border-color:var(--cyan);box-shadow:0 0 0 3px rgba(57,215,255,.15)}
|
||||
.reg button{justify-self:start;padding:.7rem 1.4rem;font:inherit;font-weight:600;cursor:pointer;
|
||||
color:var(--bg);background:var(--gold);border:none;border-radius:8px;}
|
||||
.reg button:hover{background:var(--gold-hi)}
|
||||
.hp{position:absolute;left:-9999px}
|
||||
.msg{margin-top:1.5rem;padding:1rem 1.2rem;border:1px solid var(--line);
|
||||
border-radius:10px;background:rgba(57,215,255,.06);max-width:62ch}
|
||||
.msg.err{background:rgba(216,175,104,.06)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="bar">
|
||||
<a class="brand" href="/" aria-label="CUBELinux home">
|
||||
<img src="/images/logo-header@2x.png" alt="CUBELinux">
|
||||
</a>
|
||||
<nav>
|
||||
<a href="/spec.php">Spec ↗</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<?php if ($ok): ?>
|
||||
<div class="msg">
|
||||
Thanks — you're on the list. We'll email <span class="email">info@cubelinux.com</span>
|
||||
updates as they're ready. Or reach us any time at
|
||||
<a class="email" href="mailto:info@cubelinux.com">info@cubelinux.com</a>.
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<span class="status">Spec & updates</span>
|
||||
<h1>Want to find out more?</h1>
|
||||
<p class="lede">
|
||||
Register to get CUBELinux build updates as they become available — or email us
|
||||
direct at <a class="email" href="mailto:info@cubelinux.com">info@cubelinux.com</a>.
|
||||
</p>
|
||||
<?php if ($errors): ?>
|
||||
<div class="msg err"><?= e(implode(' ', $errors)) ?></div>
|
||||
<?php endif; ?>
|
||||
<form class="reg" method="post" action="/spec.php" novalidate>
|
||||
<label>Name <input name="name" required value="<?= e($name) ?>"></label>
|
||||
<label>Company <input name="company" value="<?= e($company) ?>"></label>
|
||||
<label>Email <input type="email" name="email" required value="<?= e($email) ?>"></label>
|
||||
<input class="hp" name="website" tabindex="-1" autocomplete="off" aria-hidden="true">
|
||||
<button type="submit">Register for updates</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="in">
|
||||
<span>CUBELinux.com — local testbed</span>
|
||||
<a href="/spec.php">Read the spec ↗</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,255 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0b0e14">
|
||||
<title>Upload — CUBELinux testbed</title>
|
||||
<style>
|
||||
:root{
|
||||
color-scheme: dark;
|
||||
--bg:#0b0e14; --panel:#0d1117; --line:#21262d;
|
||||
--ink:#c9d1d9; --mute:#8b949e; --accent:#58a6ff; --ok:#7ee787;
|
||||
--warn:#f0883e; --err:#f85149;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{
|
||||
margin:0; background:var(--bg); color:var(--ink);
|
||||
font:15px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||
padding:max(1rem,env(safe-area-inset-top)) 1rem 3rem;
|
||||
}
|
||||
.wrap{max-width:760px;margin:0 auto}
|
||||
header{display:flex;align-items:baseline;gap:.75rem;flex-wrap:wrap;
|
||||
padding:.5rem 0 1.25rem;border-bottom:1px solid var(--line);margin-bottom:1.5rem}
|
||||
h1{font-size:1.5rem;margin:0;letter-spacing:.12em;color:var(--accent)}
|
||||
.sub{color:var(--mute);font-size:.85rem}
|
||||
a{color:var(--accent)}
|
||||
|
||||
/* ---- drop zone ---- */
|
||||
#drop{
|
||||
border:2px dashed var(--line); border-radius:10px; background:var(--panel);
|
||||
padding:2.5rem 1.5rem; text-align:center; cursor:pointer;
|
||||
transition:border-color .15s, background .15s;
|
||||
}
|
||||
#drop:hover,#drop:focus-visible{border-color:var(--accent);outline:none}
|
||||
#drop.hot{border-color:var(--ok); background:#0f1a12}
|
||||
#drop .big{font-size:1.1rem;margin-bottom:.4rem}
|
||||
#drop .hint{color:var(--mute);font-size:.85rem}
|
||||
/* 44px+ touch target on phones */
|
||||
.btn{
|
||||
display:inline-block;margin-top:1rem;padding:.7rem 1.4rem;min-height:44px;
|
||||
border:1px solid var(--accent);border-radius:6px;background:transparent;
|
||||
color:var(--accent);font:inherit;cursor:pointer;
|
||||
}
|
||||
.btn:active{background:#132436}
|
||||
input[type=file]{display:none}
|
||||
|
||||
/* ---- queue ---- */
|
||||
#queue{margin-top:1.5rem;display:grid;gap:.5rem}
|
||||
.row{
|
||||
border:1px solid var(--line);border-radius:8px;background:var(--panel);
|
||||
padding:.7rem .9rem;display:grid;gap:.45rem;
|
||||
}
|
||||
.row .top{display:flex;justify-content:space-between;gap:1rem;align-items:baseline}
|
||||
.row .nm{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.row .st{font-size:.8rem;color:var(--mute);flex-shrink:0}
|
||||
.row.done .st{color:var(--ok)} .row.err .st{color:var(--err)}
|
||||
.bar{height:4px;background:#161b22;border-radius:2px;overflow:hidden}
|
||||
.bar i{display:block;height:100%;width:0;background:var(--accent);
|
||||
transition:width .2s ease}
|
||||
.row.done .bar i{background:var(--ok)}
|
||||
.row.err .bar i{background:var(--err)}
|
||||
|
||||
/* ---- stored list ---- */
|
||||
h2{font-size:.85rem;text-transform:uppercase;letter-spacing:.15em;
|
||||
color:var(--mute);margin:2.5rem 0 .75rem;font-weight:normal}
|
||||
table{width:100%;border-collapse:collapse;font-size:.88rem}
|
||||
th,td{text-align:left;padding:.55rem .5rem;border-bottom:1px solid var(--line)}
|
||||
th{color:var(--mute);font-weight:normal;font-size:.78rem}
|
||||
td.sz,td.dt{color:var(--mute);white-space:nowrap}
|
||||
td.ac{text-align:right}
|
||||
.del{background:none;border:none;color:var(--mute);cursor:pointer;
|
||||
font:inherit;padding:.35rem .5rem;min-height:34px}
|
||||
.del:hover{color:var(--err)}
|
||||
.empty{color:var(--mute);font-size:.85rem;padding:.75rem .5rem}
|
||||
@media (max-width:480px){ td.dt,th.dt{display:none} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header>
|
||||
<h1>UPLOAD</h1>
|
||||
<span class="sub">→ /home/CUBELinux/html/files/</span>
|
||||
<span class="sub" style="margin-left:auto"><a href="/">← site</a></span>
|
||||
</header>
|
||||
|
||||
<div id="drop" tabindex="0" role="button" aria-label="Choose files to upload">
|
||||
<div class="big">Drop files here</div>
|
||||
<div class="hint">or tap to choose — photos, video, any file</div>
|
||||
<button class="btn" type="button" id="pick">Choose files</button>
|
||||
<input type="file" id="file" multiple>
|
||||
</div>
|
||||
|
||||
<div id="queue"></div>
|
||||
|
||||
<h2>Stored files</h2>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Name</th><th class="sz">Size</th><th class="dt">Uploaded</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody id="list"></tbody>
|
||||
</table>
|
||||
<div class="empty" id="empty" hidden>Nothing uploaded yet.</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
"use strict";
|
||||
var drop = document.getElementById('drop'),
|
||||
input = document.getElementById('file'),
|
||||
pick = document.getElementById('pick'),
|
||||
queue = document.getElementById('queue'),
|
||||
list = document.getElementById('list'),
|
||||
empty = document.getElementById('empty');
|
||||
|
||||
function human(n){
|
||||
var u=['B','KB','MB','GB']; var i=0;
|
||||
while(n>=1024 && i<3){ n/=1024; i++; }
|
||||
return (i?n.toFixed(1):n)+' '+u[i];
|
||||
}
|
||||
|
||||
/* ---- open the picker ---- */
|
||||
function openPicker(e){ if(e) e.stopPropagation(); input.click(); }
|
||||
pick.addEventListener('click', openPicker);
|
||||
drop.addEventListener('click', openPicker);
|
||||
drop.addEventListener('keydown', function(e){
|
||||
if(e.key==='Enter'||e.key===' '){ e.preventDefault(); openPicker(); }
|
||||
});
|
||||
input.addEventListener('change', function(){
|
||||
send(Array.prototype.slice.call(input.files));
|
||||
input.value='';
|
||||
});
|
||||
|
||||
/* ---- drag and drop ---- */
|
||||
['dragenter','dragover'].forEach(function(ev){
|
||||
drop.addEventListener(ev, function(e){
|
||||
e.preventDefault(); e.stopPropagation(); drop.classList.add('hot');
|
||||
});
|
||||
});
|
||||
['dragleave','drop'].forEach(function(ev){
|
||||
drop.addEventListener(ev, function(e){
|
||||
e.preventDefault(); e.stopPropagation(); drop.classList.remove('hot');
|
||||
});
|
||||
});
|
||||
drop.addEventListener('drop', function(e){
|
||||
if(e.dataTransfer && e.dataTransfer.files.length){
|
||||
send(Array.prototype.slice.call(e.dataTransfer.files));
|
||||
}
|
||||
});
|
||||
/* don't let a stray drop navigate away */
|
||||
window.addEventListener('dragover', function(e){ e.preventDefault(); });
|
||||
window.addEventListener('drop', function(e){ e.preventDefault(); });
|
||||
|
||||
/* ---- upload, one at a time, with real progress ---- */
|
||||
function send(files){
|
||||
if(!files.length) return;
|
||||
files.forEach(function(f){ enqueue(f); });
|
||||
}
|
||||
|
||||
var pending = [], busy = false;
|
||||
|
||||
function enqueue(file){
|
||||
var row = document.createElement('div');
|
||||
row.className = 'row';
|
||||
row.innerHTML =
|
||||
'<div class="top"><span class="nm"></span><span class="st">queued</span></div>'+
|
||||
'<div class="bar"><i></i></div>';
|
||||
row.querySelector('.nm').textContent = file.name;
|
||||
queue.prepend(row);
|
||||
pending.push({file:file, row:row});
|
||||
pump();
|
||||
}
|
||||
|
||||
function pump(){
|
||||
if(busy || !pending.length) return;
|
||||
busy = true;
|
||||
var job = pending.shift(),
|
||||
row = job.row, file = job.file,
|
||||
st = row.querySelector('.st'),
|
||||
bar = row.querySelector('.bar i');
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('PUT', '/upload/put/' + encodeURIComponent(file.name));
|
||||
xhr.setRequestHeader('Content-Type','application/octet-stream');
|
||||
|
||||
xhr.upload.addEventListener('progress', function(e){
|
||||
if(!e.lengthComputable) return;
|
||||
var pct = (e.loaded/e.total*100);
|
||||
bar.style.width = pct.toFixed(1)+'%';
|
||||
st.textContent = pct.toFixed(0)+'% ('+human(e.loaded)+' / '+human(e.total)+')';
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', function(){
|
||||
var res = {};
|
||||
try { res = JSON.parse(xhr.responseText); } catch(_){}
|
||||
if(xhr.status===201 && res.ok){
|
||||
row.classList.add('done');
|
||||
bar.style.width='100%';
|
||||
st.textContent = 'stored '+human(res.size);
|
||||
row.querySelector('.nm').innerHTML =
|
||||
'<a href="'+res.url+'" target="_blank" rel="noopener"></a>';
|
||||
row.querySelector('a').textContent = res.name;
|
||||
refresh();
|
||||
} else {
|
||||
row.classList.add('err');
|
||||
bar.style.width='100%';
|
||||
st.textContent = 'failed: '+(res.error || ('HTTP '+xhr.status));
|
||||
}
|
||||
busy=false; pump();
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', function(){
|
||||
row.classList.add('err'); bar.style.width='100%';
|
||||
st.textContent='network error'; busy=false; pump();
|
||||
});
|
||||
|
||||
st.textContent='sending...';
|
||||
xhr.send(file);
|
||||
}
|
||||
|
||||
/* ---- stored file list ---- */
|
||||
function refresh(){
|
||||
fetch('/upload/list', {cache:'no-store'})
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
list.innerHTML='';
|
||||
var files = d.files || [];
|
||||
empty.hidden = files.length > 0;
|
||||
files.forEach(function(f){
|
||||
var tr = document.createElement('tr');
|
||||
tr.innerHTML =
|
||||
'<td><a target="_blank" rel="noopener"></a></td>'+
|
||||
'<td class="sz"></td><td class="dt"></td>'+
|
||||
'<td class="ac"><button class="del" title="Delete">×</button></td>';
|
||||
var a = tr.querySelector('a');
|
||||
a.href = f.url; a.textContent = f.name;
|
||||
tr.querySelector('.sz').textContent = f.human;
|
||||
tr.querySelector('.dt').textContent = f.mtime;
|
||||
tr.querySelector('.del').addEventListener('click', function(){
|
||||
if(!confirm('Delete '+f.name+'?')) return;
|
||||
fetch('/upload/rm/'+encodeURIComponent(f.name), {method:'DELETE'})
|
||||
.then(refresh);
|
||||
});
|
||||
list.appendChild(tr);
|
||||
});
|
||||
})
|
||||
.catch(function(){ /* list is non-critical */ });
|
||||
}
|
||||
|
||||
refresh();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user