| |
| const $ = (id) => document.getElementById(id); |
| const chat = $('chat'); |
| let MODELS = []; |
|
|
| 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 (germplasm accessions) have: id, name, type (Indica/Sativa/Hybrid), growth stage (SEED -> SEEDLING -> MATURE), and genetics expressed as THC %, CBD %, Yield (g), GrowTime (days), plus BudColor and LeafColor as HSV hues (the bud/cola is the distinctive strain color). Each trait is an allele pair with a stability (high stability breeds true). Numeric traits inherit by dominance-weighted averaging; colors blend in HSV, so crossing distant hues yields a new vivid hue, not mud. Each specimen has a generation (F0 founders -> Fn) and a pedigree, and can breed/clone up to 2 times. |
| |
| RULES. To breed or clone, a specimen should be MATURE: grow a SEED twice (SEED->SEEDLING->MATURE). ALWAYS inspect state (list_inventory or germplasm) before acting so you use real ids. Chain tool calls to accomplish a goal; take initiative. Do NOT call the same tool repeatedly with identical arguments — one call returns everything; reuse that result. |
| |
| STYLE. Be a teacher. Briefly state your PLAN before acting, then after tool results explain what happened and what it means so the user learns the system. Keep prose tight. For analysis/filtering, call germplasm and reason over the data (e.g. purple buds = BudHue ~270-330, stable lines = Stability > 0.8, potent = high THC). End with a short summary.`; |
|
|
| const TOOLS = [ |
| fn('load_starters', 'Add the 12 baseline strains to the registry.', {}), |
| fn('list_inventory', 'List current specimens (id, name, type, stage, THC, generation).', {}), |
| fn('clear_inventory', 'Empty the registry.', {}), |
| fn('grow', 'Advance one specimen by one growth stage.', { seed_id: ['string', 'specimen id'] }, ['seed_id']), |
| fn('breed', 'Cross two specimens into an offspring.', { parent1_id: ['string', 'id'], parent2_id: ['string', 'id'] }, ['parent1_id', 'parent2_id']), |
| fn('clone', 'Vegetatively copy one specimen.', { seed_id: ['string', 'id'] }, ['seed_id']), |
| fn('germplasm', 'Full analytics dataset of every specimen (traits, hues, generation, pedigree) for filtering/analysis.', {}), |
| fn('predict_cross', 'Monte-Carlo predicted offspring trait + color ranges for two parents (does not create anything).', { parent1_id: ['string', 'id'], parent2_id: ['string', 'id'], n: ['integer', 'samples, default 200'] }, ['parent1_id', 'parent2_id']), |
| fn('bus_signals', 'Read the Observer Bus signal inbox (recent crosses as provenance receipts).', {}), |
| ]; |
| function fn(name, description, props, required = []) { |
| const properties = {}; |
| for (const [k, [type, desc]] of Object.entries(props)) properties[k] = { type, description: desc }; |
| return { type: 'function', function: { name, description, parameters: { type: 'object', properties, required } } }; |
| } |
|
|
| |
| 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; |
| } |
| function trimSeed(s) { |
| return { id: s.id, name: s.name, type: s.type, stage: s.stage, THC: s.thc, CBD: s.cbd, Yield: s.yield, GrowTime: s.grow_time, gen: s.lineage && s.lineage[0] ? '>=F1' : 'F0', can_attempt: s.can_attempt }; |
| } |
| const TOOL_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(trimSeed) }), |
| 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: { mean: p.THC.mean, range: [p.THC.min, p.THC.max] }, CBD: { mean: p.CBD.mean, range: [p.CBD.min, 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, msg: x.message })) }; }, |
| }; |
|
|
| |
| function bubble(cls, html) { const d = document.createElement('div'); d.className = 'bubble ' + cls; d.innerHTML = html; chat.appendChild(d); chat.scrollTop = chat.scrollHeight; return d; } |
| function esc(s) { return String(s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); } |
| function fmt(s) { return esc(s).replace(/\*\*(.+?)\*\*/g, '<b>$1</b>').replace(/\n/g, '<br>'); } |
| function toolCard(name, args, result, err) { |
| const d = document.createElement('details'); d.className = 'tool'; d.open = false; |
| d.innerHTML = `<summary>${err ? '⚠' : '▸'} <b>${esc(name)}</b>(${esc(JSON.stringify(args))})</summary><pre>${esc(typeof result === 'string' ? result : JSON.stringify(result, null, 2))}</pre>`; |
| chat.appendChild(d); chat.scrollTop = chat.scrollHeight; |
| } |
|
|
| |
| async function callLLM(messages) { |
| const token = $('token').value.trim(); |
| if (!token) throw new Error('Paste your HF token first (top bar).'); |
| const r = await fetch('/api/agent/chat', { |
| method: 'POST', headers: { 'Content-Type': 'application/json', 'X-HF-Token': token }, |
| body: JSON.stringify({ provider: $('provider').value, model: $('model').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 messages = [{ role: 'system', content: SYSTEM }]; |
| let busy = false; |
| async function send() { |
| if (busy) return; |
| const text = $('msg').value.trim(); |
| if (!text) return; |
| $('msg').value = ''; |
| bubble('user', fmt(text)); |
| messages.push({ role: 'user', content: text }); |
| busy = true; $('send').disabled = true; |
| const think = document.createElement('div'); think.className = 'thinking'; think.textContent = 'thinking'; chat.appendChild(think); |
| try { |
| for (let step = 0; step < 12; step++) { |
| const msg = await callLLM(messages); |
| messages.push(msg); |
| if (msg.content) bubble('assistant', fmt(msg.content)); |
| const calls = msg.tool_calls || []; |
| if (!calls.length) break; |
| for (const tc of calls) { |
| let args = {}; try { args = JSON.parse(tc.function.arguments || '{}'); } catch {} |
| let result, err = false; |
| try { result = await (TOOL_IMPL[tc.function.name] || (async () => ({ error: 'unknown tool' })))(args); } |
| catch (e) { result = { error: String(e.message || e) }; err = true; } |
| toolCard(tc.function.name, args, result, err); |
| messages.push({ role: 'tool', tool_call_id: tc.id, name: tc.function.name, content: JSON.stringify(result).slice(0, 6000) }); |
| } |
| } |
| } catch (e) { |
| bubble('error', 'Agent error: ' + esc(e.message || e) + '<br><span style="opacity:.7">Check your token/model. Some models 403 if your token lacks that provider — pick another from the list.</span>'); |
| } finally { |
| think.remove(); busy = false; $('send').disabled = false; |
| } |
| } |
|
|
| |
| const ctxFmt = (n) => !n ? '' : n >= 1e6 ? (n / 1e6).toFixed(n % 1e6 ? 1 : 0) + 'M' : n >= 1000 ? Math.round(n / 1000) + 'k' : '' + n; |
| const modelKey = () => 'weedsim_model_' + $('provider').value; |
| const tokKey = () => 'weedsim_tok_' + $('provider').value; |
|
|
| async function loadModels() { |
| const token = $('token').value.trim(); |
| if (!token) { $('model').placeholder = 'paste key to load models'; MODELS = []; renderModels(); return; } |
| $('model').placeholder = 'loading models…'; |
| try { |
| const r = await fetch('/api/agent/models?provider=' + $('provider').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(); |
| $('model').placeholder = 'model'; |
| if (!$('model').value) { |
| const def = MODELS.find(m => m.tools) || MODELS[0]; |
| if (def) { $('model').value = def.id; localStorage.setItem(modelKey(), def.id); } |
| } |
| } catch (e) { $('model').placeholder = 'model load failed: ' + (e.message || e); } |
| } |
| function renderModels() { |
| const tools = $('f-tools').checked, free = $('f-free').checked; |
| const list = MODELS.filter(m => (!tools || m.tools) && (!free || m.free)); |
| $('modellist').innerHTML = list.map(m => { |
| const tags = [m.tools ? '🔧' : '', m.free ? 'free' : '', ctxFmt(m.context), (m.providers || []).join(',')].filter(Boolean).join(' · '); |
| return `<option value="${m.id}">${tags}</option>`; |
| }).join(''); |
| $('model-count').textContent = `${list.length}/${MODELS.length} models`; |
| } |
|
|
| |
| function loadTokenField() { |
| $('token').value = localStorage.getItem(tokKey()) || ''; |
| $('token').placeholder = ($('provider').value === 'openrouter' ? 'OpenRouter API key' : 'HF token') + ' (stays in your browser)'; |
| } |
| $('provider').value = localStorage.getItem('weedsim_provider') || 'openrouter'; |
| loadTokenField(); |
| $('model').value = localStorage.getItem(modelKey()) || ''; |
| $('provider').addEventListener('change', () => { |
| localStorage.setItem('weedsim_provider', $('provider').value); |
| loadTokenField(); |
| $('model').value = localStorage.getItem(modelKey()) || ''; |
| $('token').value ? loadModels() : (MODELS = [], renderModels()); |
| }); |
| $('token').addEventListener('change', () => { localStorage.setItem(tokKey(), $('token').value.trim()); loadModels(); }); |
| $('model').addEventListener('change', () => localStorage.setItem(modelKey(), $('model').value.trim())); |
| $('f-tools').addEventListener('change', renderModels); |
| $('f-free').addEventListener('change', renderModels); |
| $('send').addEventListener('click', send); |
| $('msg').addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }); |
| if ($('token').value) loadModels(); |
|
|