File size: 11,526 Bytes
4076330 | 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 | // WEED-SIM Agent — browser tool-calling loop over the live API via HF providers.
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 } } };
}
// ---- tool execution against the live WEED-SIM API (session header auto-added) ----
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 })) }; },
};
// ---- chat rendering ----
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;
}
// ---- LLM call ----
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;
}
// ---- agent loop ----
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;
}
}
// ---- model catalog (per provider) ----
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`;
}
// ---- wiring (provider-aware) ----
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();
|