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