// 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 = `
⌬ Agent
WEED-SIM AGENT
`; 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 => ({ '&': '&', '<': '<', '>': '>' }[c])); const fmt = (s) => esc(s).replace(/\*\*(.+?)\*\*/g, '$1').replace(/\n/g, '
'); 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 = `${esc(name)}(${esc(JSON.stringify(args))})
${esc(typeof res === 'string' ? res : JSON.stringify(res, null, 2))}
`; log.appendChild(d); log.scrollTop = log.scrollHeight; } function intro() { bub('intro', `Ever-present agent. 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 , then try “load starters and breed me a purple high-THC strain”. 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 => ``).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(); })();