weed-sim / static /agent.js
tostido's picture
Add native agent: chat that drives WEED-SIM via tool calling
4076330
Raw
History Blame Contribute Delete
11.5 kB
// 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[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();