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 => ({ '&': '&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();
})();