weed-sim / static /agent-widget.js
tostido's picture
Ever-present agent HUD: floating, draggable, resizable, cross-page
e30a933
Raw
History Blame Contribute Delete
15.5 kB
// Ever-present WEED-SIM agent HUD — injects itself on every page, persists the
// conversation + window box across navigations, draggable + resizable.
(function () {
if (window.__awLoaded) return; window.__awLoaded = true;
const LS = localStorage;
const get = (k, d) => { try { return JSON.parse(LS.getItem(k)) ?? d; } catch { return LS.getItem(k) ?? d; } };
const set = (k, v) => LS.setItem(k, typeof v === 'string' ? v : JSON.stringify(v));
// ---- inject DOM ----
const wrap = document.createElement('div');
wrap.innerHTML = `
<div id="aw-fab"><span class="dot"></span>⌬ Agent</div>
<div id="aw-panel">
<div class="aw-head">
<span class="grip">⠿</span><span class="t">WEED-SIM AGENT</span>
<button data-act="cfg" title="settings">⚙</button>
<button data-act="clear" title="new chat">⟲</button>
<button data-act="min" title="minimize">—</button>
</div>
<div class="aw-cfg">
<select id="aw-prov"><option value="openrouter">OpenRouter</option><option value="hf">HF Inference</option></select>
<label><input type="checkbox" id="aw-ftools" checked> tools</label>
<label><input type="checkbox" id="aw-ffree"> free</label>
<input class="tok" id="aw-tok" type="password" placeholder="API key (browser-only)" autocomplete="off">
<input class="mdl" id="aw-mdl" list="aw-mdls" placeholder="model — paste key to load">
<datalist id="aw-mdls"></datalist>
<span class="cnt" id="aw-cnt"></span>
</div>
<div class="aw-log" id="aw-log"></div>
<div class="aw-foot">
<textarea id="aw-msg" placeholder="Ask the agent to breed, analyze, optimize…"></textarea>
<button id="aw-send">▶</button>
</div>
</div>`;
document.body.appendChild(wrap);
const $ = (id) => document.getElementById(id);
const fab = $('aw-fab'), panel = $('aw-panel'), log = $('aw-log'), head = panel.querySelector('.aw-head');
const cfg = panel.querySelector('.aw-cfg');
// ---- window box (position/size) persisted across pages ----
function restoreBox() {
const b = get('aw_box', null);
if (b) { panel.style.right = 'auto'; panel.style.bottom = 'auto'; panel.style.left = b.l + 'px'; panel.style.top = b.t + 'px'; panel.style.width = b.w + 'px'; panel.style.height = b.h + 'px'; }
}
function saveBox() { set('aw_box', { l: panel.offsetLeft, t: panel.offsetTop, w: panel.offsetWidth, h: panel.offsetHeight }); }
new ResizeObserver(() => { if (panel.classList.contains('open')) saveBox(); }).observe(panel);
let drag = null;
head.addEventListener('mousedown', (e) => { if (e.target.closest('button')) return; drag = { x: e.clientX, y: e.clientY, l: panel.offsetLeft, t: panel.offsetTop }; panel.style.right = 'auto'; panel.style.bottom = 'auto'; e.preventDefault(); });
window.addEventListener('mousemove', (e) => { if (!drag) return; let l = Math.max(0, Math.min(innerWidth - 60, drag.l + e.clientX - drag.x)), t = Math.max(0, Math.min(innerHeight - 36, drag.t + e.clientY - drag.y)); panel.style.left = l + 'px'; panel.style.top = t + 'px'; });
window.addEventListener('mouseup', () => { if (drag) { drag = null; saveBox(); } });
function open(o) { panel.classList.toggle('open', o); fab.style.display = o ? 'none' : 'flex'; set('aw_open', o); if (o) { restoreBox(); log.scrollTop = log.scrollHeight; } }
fab.onclick = () => open(true);
panel.querySelector('[data-act=min]').onclick = () => open(false);
panel.querySelector('[data-act=cfg]').onclick = () => cfg.classList.toggle('open');
panel.querySelector('[data-act=clear]').onclick = () => { messages = [{ role: 'system', content: SYSTEM }]; set('aw_chat', messages); log.innerHTML = ''; intro(); };
// ---- agent brain ----
const SYSTEM = `You are the WEED-SIM Agent — an autonomous orchestration agent for a cannabis-strain breeding simulator. You operate the user's LIVE session through tools, and you teach as you go.
DOMAIN. Specimens have: id, name, type (Indica/Sativa/Hybrid), stage (SEED->SEEDLING->MATURE), THC %, CBD %, Yield (g), GrowTime (days), and BudColor/LeafColor as HSV hues (the cola is the strain's signature color). Traits are allele pairs with a stability (high = breeds true); numerics inherit by dominance-weighted averaging; colors blend in HSV so distant crosses make a new vivid hue. Each specimen has a generation (F0 founders -> Fn), a pedigree, and up to 2 breed/clone attempts.
RULES. To breed/clone, a specimen should be MATURE (grow a SEED twice). ALWAYS inspect state (list_inventory or germplasm) before acting so you use real ids. Chain tools to reach a goal; take initiative. Do NOT repeat the same tool call with identical args — reuse the result.
STYLE. Teach. Briefly state your PLAN, act, then explain the result so the user learns. Keep it tight. For analysis/filtering use germplasm and reason over it (purple buds = BudHue ~270-330, stable = Stability>0.8, potent = high THC). End with a short summary.`;
const F = (name, description, props, required = []) => { const p = {}; for (const k in props) p[k] = { type: props[k][0], description: props[k][1] }; return { type: 'function', function: { name, description, parameters: { type: 'object', properties: p, required } } }; };
const TOOLS = [
F('load_starters', 'Add the 12 baseline strains.', {}),
F('list_inventory', 'List specimens (id, name, type, stage, THC, attempts).', {}),
F('clear_inventory', 'Empty the registry.', {}),
F('grow', 'Advance one specimen by a growth stage.', { seed_id: ['string', 'id'] }, ['seed_id']),
F('breed', 'Cross two specimens into offspring.', { parent1_id: ['string', 'id'], parent2_id: ['string', 'id'] }, ['parent1_id', 'parent2_id']),
F('clone', 'Vegetative copy of one specimen.', { seed_id: ['string', 'id'] }, ['seed_id']),
F('germplasm', 'Full dataset of all specimens (traits, hues, generation, pedigree) for filtering/analysis.', {}),
F('predict_cross', 'Monte-Carlo predicted offspring trait/color ranges (creates nothing).', { parent1_id: ['string', 'id'], parent2_id: ['string', 'id'], n: ['integer', 'samples'] }, ['parent1_id', 'parent2_id']),
F('bus_signals', 'Read the Observer Bus signal inbox (recent crosses as receipts).', {}),
];
async function api(path, method = 'GET', body) {
const r = await fetch(path, { method, headers: body ? { 'Content-Type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined });
const t = await r.text(); let d; try { d = JSON.parse(t); } catch { d = t; }
if (!r.ok) throw new Error(typeof d === 'object' ? (d.detail || JSON.stringify(d)) : d); return d;
}
const seed = (s) => ({ id: s.id, name: s.name, type: s.type, stage: s.stage, THC: s.thc, gen: s.lineage && s.lineage[0] ? '>=F1' : 'F0', can_attempt: s.can_attempt });
const IMPL = {
load_starters: async () => { await api('/api/inventory/starter', 'POST'); return { ok: true, note: '12 starters added' }; },
list_inventory: async () => ({ seeds: (await api('/api/inventory')).seeds.map(seed) }),
clear_inventory: async () => { await api('/api/inventory/clear', 'POST'); return { ok: true }; },
grow: async (a) => api(`/api/seed/${a.seed_id}/grow`, 'POST'),
breed: async (a) => api('/api/breed', 'POST', { parent1_id: a.parent1_id, parent2_id: a.parent2_id }),
clone: async (a) => api('/api/clone', 'POST', { seed_id: a.seed_id }),
germplasm: async () => { const g = await api('/api/lab/germplasm'); return { count: g.count, facets: g.facets, records: g.records.map(r => ({ id: r.germplasmDbId, name: r.germplasmName, type: r.germplasmType, gen: r.generation, stage: r.stage, THC: r.THC, CBD: r.CBD, Yield: r.Yield, GrowTime: r.GrowTime, BudHue: r.BudHue, LeafHue: r.LeafHue, budFamily: r.budFamily, Stability: r.Stability, pedigree: r.pedigree, can_attempt: r.canAttempt })) }; },
predict_cross: async (a) => { const p = await api('/api/lab/predict-cross', 'POST', { parent1_id: a.parent1_id, parent2_id: a.parent2_id, n: a.n || 200 }); return { parents: p.parents, THC: [p.THC.min, p.THC.mean, p.THC.max], CBD: [p.CBD.min, p.CBD.mean, p.CBD.max], Yield_mean: p.Yield.mean, GrowTime_mean: p.GrowTime.mean }; },
bus_signals: async () => { const s = await api('/api/deck/signals'); return { available: s.available, count: s.count, signals: (s.signals || []).map(x => ({ lane: x.lane, title: x.title })) }; },
};
// ---- render ----
const esc = (s) => String(s).replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
const fmt = (s) => esc(s).replace(/\*\*(.+?)\*\*/g, '<b>$1</b>').replace(/\n/g, '<br>');
function bub(cls, html) { const d = document.createElement('div'); d.className = 'aw-b ' + cls; d.innerHTML = html; log.appendChild(d); log.scrollTop = log.scrollHeight; return d; }
function toolCard(name, args, res) { const d = document.createElement('details'); d.className = 'aw-tool'; d.innerHTML = `<summary>▸ <b>${esc(name)}</b>(${esc(JSON.stringify(args))})</summary><pre>${esc(typeof res === 'string' ? res : JSON.stringify(res, null, 2))}</pre>`; log.appendChild(d); log.scrollTop = log.scrollHeight; }
function intro() { bub('intro', `<b>Ever-present agent.</b> It drives WEED-SIM on your live session — load, grow, breed, clone, analyze the germplasm, predict crosses, read the bus — and explains as it goes. Set a key in <b>⚙</b>, then try <span class="ex">“load starters and breed me a purple high-THC strain”</span>. Drag the title bar to move, drag the corner to resize.`); }
// ---- conversation state (persisted across pages) ----
let messages = get('aw_chat', null) || [{ role: 'system', content: SYSTEM }];
function renderHistory() {
log.innerHTML = '';
const tr = {}; messages.forEach(m => { if (m.role === 'tool') tr[m.tool_call_id] = m.content; });
let any = false;
messages.forEach(m => {
if (m.role === 'user') { bub('user', fmt(m.content)); any = true; }
else if (m.role === 'assistant') {
if (m.content) { bub('assistant', fmt(m.content)); any = true; }
(m.tool_calls || []).forEach(tc => { let a = {}; try { a = JSON.parse(tc.function.arguments || '{}'); } catch {} let r = tr[tc.id]; try { r = JSON.parse(r); } catch {} toolCard(tc.function.name, a, r); any = true; });
}
});
if (!any) intro();
}
function persist() { const trimmed = [messages[0], ...messages.slice(1).slice(-40)]; messages = trimmed; set('aw_chat', messages); }
// ---- LLM + loop ----
async function callLLM() {
const token = $('aw-tok').value.trim();
if (!token) throw new Error('Set an API key in ⚙ settings first.');
const r = await fetch('/api/agent/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-HF-Token': token }, body: JSON.stringify({ provider: $('aw-prov').value, model: $('aw-mdl').value.trim(), messages, tools: TOOLS, tool_choice: 'auto' }) });
const d = await r.json(); if (!r.ok) throw new Error(d.detail || ('HTTP ' + r.status)); return d.choices[0].message;
}
let busy = false;
async function send() {
if (busy) return; const text = $('aw-msg').value.trim(); if (!text) return; $('aw-msg').value = '';
bub('user', fmt(text)); messages.push({ role: 'user', content: text }); persist();
busy = true; $('aw-send').disabled = true;
const think = document.createElement('div'); think.className = 'aw-think'; think.textContent = 'thinking'; log.appendChild(think);
try {
for (let step = 0; step < 12; step++) {
const msg = await callLLM(); messages.push(msg); persist();
if (msg.content) bub('assistant', fmt(msg.content));
const calls = msg.tool_calls || []; if (!calls.length) break;
for (const tc of calls) {
let a = {}; try { a = JSON.parse(tc.function.arguments || '{}'); } catch {}
let res; try { res = await (IMPL[tc.function.name] || (async () => ({ error: 'unknown tool' })))(a); } catch (e) { res = { error: String(e.message || e) }; }
toolCard(tc.function.name, a, res);
messages.push({ role: 'tool', tool_call_id: tc.id, name: tc.function.name, content: JSON.stringify(res).slice(0, 6000) }); persist();
}
}
} catch (e) { bub('error', 'Agent error: ' + esc(e.message || e)); }
finally { think.remove(); busy = false; $('aw-send').disabled = false; }
}
// ---- models ----
let MODELS = [];
const ctxFmt = (n) => !n ? '' : n >= 1e6 ? (n / 1e6).toFixed(n % 1e6 ? 1 : 0) + 'M' : n >= 1e3 ? Math.round(n / 1e3) + 'k' : '' + n;
const mKey = () => 'weedsim_model_' + $('aw-prov').value, tKey = () => 'weedsim_tok_' + $('aw-prov').value;
async function loadModels() {
const token = $('aw-tok').value.trim(); if (!token) return;
$('aw-mdl').placeholder = 'loading…';
try {
const r = await fetch('/api/agent/models?provider=' + $('aw-prov').value, { headers: { 'X-HF-Token': token } });
const d = await r.json(); if (!r.ok) throw new Error(d.detail || r.status);
MODELS = d.models; renderModels(); $('aw-mdl').placeholder = 'model';
if (!$('aw-mdl').value) { const def = MODELS.find(m => m.tools) || MODELS[0]; if (def) { $('aw-mdl').value = def.id; set(mKey(), def.id); } }
} catch (e) { $('aw-mdl').placeholder = 'load failed: ' + (e.message || e); }
}
function renderModels() {
const t = $('aw-ftools').checked, fr = $('aw-ffree').checked;
const list = MODELS.filter(m => (!t || m.tools) && (!fr || m.free));
$('aw-mdls').innerHTML = list.map(m => `<option value="${m.id}">${[m.tools ? '🔧' : '', m.free ? 'free' : '', ctxFmt(m.context), (m.providers || []).join(',')].filter(Boolean).join(' · ')}</option>`).join('');
$('aw-cnt').textContent = `${list.length}/${MODELS.length} models`;
}
function loadTok() { $('aw-tok').value = LS.getItem(tKey()) || ''; $('aw-tok').placeholder = ($('aw-prov').value === 'openrouter' ? 'OpenRouter API key' : 'HF token') + ' (browser-only)'; $('aw-mdl').value = LS.getItem(mKey()) || ''; }
// ---- wire ----
$('aw-prov').value = LS.getItem('weedsim_provider') || 'openrouter'; loadTok();
$('aw-prov').onchange = () => { set('weedsim_provider', $('aw-prov').value); loadTok(); $('aw-tok').value ? loadModels() : (MODELS = [], renderModels()); };
$('aw-tok').onchange = () => { set(tKey(), $('aw-tok').value.trim()); loadModels(); };
$('aw-mdl').onchange = () => set(mKey(), $('aw-mdl').value.trim());
$('aw-ftools').onchange = renderModels; $('aw-ffree').onchange = renderModels;
$('aw-send').onclick = send;
$('aw-msg').onkeydown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } };
// Make any "Agent" nav link open the HUD on the current page instead of navigating.
document.addEventListener('click', (e) => { const a = e.target.closest('a[href="/agent"]'); if (a) { e.preventDefault(); open(true); } });
renderHistory();
if (get('aw_open', false)) open(true);
if ($('aw-tok').value) loadModels();
})();