File size: 15,536 Bytes
e30a933 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 | // 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 => ({ '&': '&', '<': '<', '>': '>' }[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();
})();
|