Files

471 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ZHILA · agent
the choir: a model on the local server, acting through the same verbs as the hand.
without a server or a key it falls back to a local conductor — still using the tools. */
(function () {
'use strict';
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => Array.prototype.slice.call(r.querySelectorAll(s));
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const API = location.protocol === 'file:' ? 'http://127.0.0.1:4173' : '';
const E = window.ZHILA_ENGINE;
const Z = window.ZHILA;
const Q = window.ZHILA_QUARRY;
const logEl = $('#log');
const stateEl = $('#agent-state');
const stateText = $('#agent-state-text');
const netText = $('#net-text');
const TOOL_NAMES = ['get_state', 'list_cores', 'extract_core', 'fracture_core', 'strike_face',
'place_stone', 'move_stone', 'delete_stone', 'set_weather', 'set_rings',
'set_tempo', 'transport', 'open_core', 'set_macros'];
let live = false;
let busy = false;
let history = [];
let welcomed = false;
function timeNow() {
const d = new Date();
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
}
function scrollLog() {
if (logEl) logEl.scrollTop = logEl.scrollHeight;
}
function setStatus(text, thinking) {
if (!stateText) return;
stateText.textContent = text;
if (stateEl) stateEl.classList.toggle('is-thinking', !!thinking);
}
function addSay(text) {
if (!logEl || !text) return;
const li = document.createElement('li');
li.className = 'call call--say';
li.innerHTML = '<span class="call-t"></span><p></p>';
li.querySelector('.call-t').textContent = timeNow();
li.querySelector('p').textContent = text;
logEl.appendChild(li);
scrollLog();
}
function addYou(text) {
if (!logEl) return;
const li = document.createElement('li');
li.className = 'call call--you';
li.innerHTML = '<span class="call-t"></span><p></p>';
li.querySelector('.call-t').textContent = timeNow();
li.querySelector('p').textContent = '\u201C' + text + '\u201D';
logEl.appendChild(li);
scrollLog();
}
function argsHTML(args) {
const keys = Object.keys(args || {});
if (!keys.length) return '<span class="p">{ }</span>';
return '<span class="p">{ </span>' + keys.map(function (k) {
let v = args[k];
let val;
if (typeof v === 'string') val = '"' + esc(v) + '"';
else if (typeof v === 'object' && v !== null) val = esc(JSON.stringify(v));
else val = esc(String(v));
return '<span class="k">"' + esc(k) + '"</span><span class="p">: </span><span class="v">' + val + '</span>';
}).join('<span class="p">, </span>') + '<span class="p"> }</span>';
}
function argLine(name, a) {
const pad = n => String(n).padStart(2, '0');
const pitch = n => n ? (n > 0 ? '+' : '') + n + 'st' : '';
switch (name) {
case 'place_stone':
return [a.vein, a.core_id + ' f' + pad(a.face), a.start_bar + '–' + (+a.start_bar + +a.length_bars),
a.ring_bars ? 'ring ' + a.ring_bars : 'once', pitch(a.pitch), a.gain ? 'gain ' + a.gain : ''].filter(Boolean).join(' · ');
case 'strike_face':
return a.core_id + ' f' + pad(a.face);
case 'extract_core':
return a.name || 'new core';
case 'fracture_core':
case 'open_core':
return a.core_id;
case 'move_stone':
return 'stone ' + a.stone_id + ' → bar ' + a.start_bar;
case 'delete_stone':
return 'stone ' + a.stone_id;
case 'set_weather':
return ['age', 'pressure', 'air', 'heat'].filter(function (k) { return a[k] != null; })
.map(function (k) { return k + ' ' + a[k]; }).join(' · ');
case 'set_rings':
return a.count + ' rings';
case 'set_tempo':
return a.bpm + ' bpm';
case 'transport':
return a.action + (a.bar ? ' · bar ' + a.bar : '');
case 'set_macros':
return ['warmth', 'dust', 'threshold'].filter(function (k) { return a[k] != null; })
.map(function (k) { return k + ' ' + a[k]; }).join(' · ');
default:
return '';
}
}
function addTool(name, args, source) {
const li = document.createElement('li');
li.className = 'call call--tool';
li.innerHTML =
'<div class="call-head">' +
'<span class="call-t">' + timeNow() + '</span>' +
'<span class="call-name"></span>' +
'<span class="call-src">' + (source || 'api') + '</span>' +
'<span class="call-ms">…</span>' +
'</div>' +
'<div class="call-args call-args--line">' + esc(argLine(name, args)) + '</div>';
li.querySelector('.call-name').textContent = name;
li.title = JSON.stringify(args);
logEl.appendChild(li);
scrollLog();
return li;
}
function summarize(name, res) {
if (!res) return '';
if (res.error) return 'error: ' + res.error;
switch (name) {
case 'get_state':
return (res.stones ? res.stones.length : 0) + ' stones · bar ' + res.transport.bar + ' · ' + res.cores.length + ' cores · ' + res.view;
case 'list_cores':
return res.cores.length + ' cores: ' + res.cores.map(function (c) { return c.id; }).join(', ');
case 'extract_core':
return res.name + ' · ' + res.dur + 's · ' + res.seal;
case 'fracture_core':
return res.id + ' · re-cut · ' + res.loud.slice(0, 3).join(', ');
case 'strike_face':
return 'struck ' + res.core + ' f' + String(res.face + 1).padStart(2, '0');
case 'place_stone':
return res.vein + ' · bars ' + res.bars[0] + '–' + res.bars[1] + (res.ring ? ' · ring ' + res.ring : ' · once');
case 'move_stone':
return 'bars ' + res.bars[0] + '–' + res.bars[1];
case 'delete_stone':
return 'buried';
case 'set_weather':
return Object.keys(res.weather).map(function (k) { return k + ' ' + (+res.weather[k]).toFixed(2); }).join(' · ');
case 'set_rings':
return 'rings ' + res.rings;
case 'set_tempo':
return res.bpm + ' bpm';
case 'transport':
return (res.playing ? 'playing' : 'paused') + ' · bar ' + res.bar;
case 'open_core':
return 'quarry · ' + res.core;
case 'set_macros':
return Object.keys(res).map(function (k) { return k + ' ' + (+res[k]).toFixed(2); }).join(' · ');
default:
return JSON.stringify(res).slice(0, 140);
}
}
function finishTool(li, ms, res) {
if (!li) return;
const t = li.querySelector('.call-ms');
if (t) t.textContent = ms;
const name = li.querySelector('.call-name').textContent;
const read = name === 'get_state' || name === 'list_cores';
const line = document.createElement('div');
line.className = 'call-result';
line.textContent = (res && res.error ? 'error · ' : (read ? '' : 'ok · ')) + summarize(name, res);
if (res && res.error) line.style.color = 'rgba(232,64,42,.8)';
li.appendChild(line);
scrollLog();
}
/* ————— the verbs ————— */
async function exec(name, a) {
a = a || {};
if (!E) throw new Error('no engine');
switch (name) {
case 'get_state': {
return {
transport: { playing: E.isPlaying(), bar: +E.getPos().toFixed(2), bpm: E.getBpm() },
weather: Q ? Q.weather() : E.weather,
rings: Q ? Q.rings() : E.rings,
cores: E.cores.map(function (c) {
return { id: c.id, name: c.name, dur: +c.dur.toFixed(1), seal: c.seal, loud: c.ranked.slice(0, 4) };
}),
stones: Z.stones(),
shape: Z.shape(),
view: document.body.dataset.view,
selected_core: Q && Q.selectedCore() ? Q.selectedCore().id : null
};
}
case 'list_cores': {
return {
cores: E.cores.map(function (c) {
return {
id: c.id, name: c.name, dur: +c.dur.toFixed(1), seal: c.seal, faces: 16,
loud: c.ranked.slice(0, 5),
at: c.ranked.slice(0, 5).map(function (i) { return +c.faces[i].start.toFixed(2); })
};
})
};
}
case 'extract_core': {
const c = E.newCore({ name: (a.name || 'a core from the quarry').slice(0, 40), seal: a.seal || 'found' });
E.addLog('cores.extract', c.name, c.seal, 'api');
return { id: c.id, name: c.name, dur: +c.dur.toFixed(1), seal: c.seal, loud: c.ranked.slice(0, 4) };
}
case 'fracture_core': {
const c = E.refracture(a.core_id);
if (!c) throw new Error('no such core: ' + a.core_id);
if (Q) Q.refresh();
return { id: c.id, loud: c.ranked.slice(0, 4), faces: c.faces.length };
}
case 'strike_face': {
const core = E.byId(a.core_id);
if (!core) throw new Error('no such core: ' + a.core_id);
const face = clamp(Math.round(+a.face || 0), 0, 15);
E.strike(core.id, face, { pitch: 0, gain: 0.55 + 0.4 * (a.velocity == null ? 1 : a.velocity), ghost: true });
return { core: core.id, face: face, struck: true };
}
case 'place_stone': {
const r = Z.addStoneAt({
vein: a.vein, core: a.core_id, face: a.face,
start: a.start_bar, length: a.length_bars, ring: a.ring_bars,
pitch: a.pitch, gain: a.gain, name: a.name, actor: 'api'
});
if (r.error) throw new Error(r.error);
return r;
}
case 'move_stone': {
const r = Z.moveStoneAt(a.stone_id, a.start_bar);
if (r.error) throw new Error(r.error);
return r;
}
case 'delete_stone': {
const r = Z.deleteStoneAt(a.stone_id);
if (r.error) throw new Error(r.error);
return r;
}
case 'set_weather': {
const w = Q ? Q.setWeatherUI({ age: a.age, pressure: a.pressure, air: a.air, heat: a.heat }) : E.weather;
return { weather: w };
}
case 'set_rings': {
return { rings: Q ? Q.setRingsUI(a.count) : E.rings };
}
case 'set_tempo':
return { bpm: Z.setTempo(a.bpm) };
case 'transport':
return Z.transport(a.action, a.bar);
case 'open_core': {
if (Q) { Q.setView('quarry'); Q.focusCore(a.core_id); }
return { core: a.core_id, view: 'quarry' };
}
case 'set_macros': {
const out = {};
['warmth', 'dust', 'threshold'].forEach(function (k) {
if (a[k] != null) out[k] = Z.setMacroUI(k, a[k]);
});
return out;
}
default:
throw new Error('unknown tool: ' + name);
}
}
async function invokeTool(name, args, source) {
const li = addTool(name, args, source);
const t0 = performance.now();
let res;
try { res = await exec(name, args); }
catch (e) { res = { error: String(e && e.message || e) }; }
finishTool(li, ((performance.now() - t0) / 1000).toFixed(1) + 's', res);
return res;
}
/* ————— the model loop ————— */
function trimHistory() {
const h = history.slice();
while (h.length > 28) {
const i = h.findIndex(function (m) { return m.role === 'user'; });
if (i < 0) break;
h.splice(0, i + 1);
}
return h;
}
async function modelSay(text) {
history.push({ role: 'user', content: text });
setStatus('thinking', true);
let ran = 0;
let lastError = null;
let wireDown = false;
for (let step = 0; step < 6; step++) {
let r, j;
try {
r = await fetch(API + '/api/agent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: trimHistory() }),
signal: AbortSignal.timeout(95000)
});
j = await r.json().catch(function () { return null; });
} catch (e) {
lastError = e;
wireDown = true;
break;
}
if (!r.ok) {
lastError = new Error((j && j.error) || ('agent ' + r.status));
if (j && j.mode === 'local') { wireDown = true; live = false; }
break;
}
const m = j && j.message;
if (!m) { lastError = new Error('empty reply'); break; }
history.push(m);
if (m.content && m.content.trim()) addSay(m.content.trim());
if (!m.tool_calls || !m.tool_calls.length) { lastError = null; break; }
for (let k = 0; k < m.tool_calls.length; k++) {
const tc = m.tool_calls[k];
const name = tc.function && tc.function.name;
let args = {};
try { args = JSON.parse((tc.function && tc.function.arguments) || '{}'); } catch (e) {}
setStatus('calling ' + name, true);
const res = await invokeTool(name, args, 'api');
ran++;
history.push({ role: 'tool', tool_call_id: tc.id, name: name, content: JSON.stringify(res) });
}
}
if (lastError) {
if (wireDown) {
live = false;
document.body.classList.add('agent-offline');
if (netText) netText.textContent = 'local';
}
addSay('the choir lost the wire: ' + lastError.message + '.');
if (!ran) localSay(text, true);
}
}
/* ————— local conductor (no model) ————— */
async function localSay(text, afterError) {
const t = text.toLowerCase();
const core = (Q && Q.selectedCore()) || E.cores[0];
if (!core) {
addSay('there is no material in the archive yet.');
return;
}
const done = [];
const wantsStop = /(stop|стоп|пауза|pause)/.test(t);
const wantsPlay = /(play|играй|пуск|запусти|start)/.test(t);
if (wantsStop) { await invokeTool('transport', { action: 'pause' }, 'local'); done.push('paused'); }
else if (wantsPlay) { await invokeTool('transport', { action: 'play' }, 'local'); done.push('the wheels are turning'); }
if (/(fracture|cut|реж|трещин|разрез)/.test(t)) {
await invokeTool('fracture_core', { core_id: core.id }, 'local');
done.push('re-cut ' + core.id);
} else if (/(take|bring|extract|найд|принес|достань|новый|room|поле|запись)/.test(t)) {
const c = await invokeTool('extract_core', { name: text.slice(0, 42), seal: 'found' }, 'local');
done.push('the choir brought ' + c.name);
} else if (/(dark|темн|стар|old|fossil|истощ)/.test(t)) {
const w = await invokeTool('set_weather', { age: 0.72, air: 0.3 }, 'local');
done.push('weather: age 0.72, air 0.30');
} else if (/(wet|мокр|room|воздух|air|простор)/.test(t)) {
await invokeTool('set_weather', { air: 0.74, pressure: 0.3 }, 'local');
done.push('weather: air 0.74, pressure 0.30');
}
const wantsPlace = /(place|add|layer|слой|добавь|постав|loop|петл|пульс|bass|низ|голос|шум|звук|дыш)/.test(t);
if (wantsPlace || !done.length) {
let vein = 'silt';
if (/(bass|низ|бас|пол)/.test(t)) vein = 'bass';
else if (/(voice|голос|хор|петь)/.test(t)) vein = 'voice';
else if (/(noise|шум|воздух|ветер)/.test(t)) vein = 'noise';
else if (/(crack|трещ|грит)/.test(t)) vein = 'crack';
const face = E.rankFace(core.id, vein === 'bass' ? 0 : 1);
const start = Math.min(30, Math.ceil(E.getPos()) + 1);
const ring = /(once|один|раз|one)/.test(t) ? 0 : 2;
const pitch = vein === 'bass' ? -5 : 0;
const res = await invokeTool('place_stone', {
vein: vein, core_id: core.id, face: face,
start_bar: start, length_bars: 8, ring_bars: ring, pitch: pitch, gain: 0.8
}, 'local');
if (!res.error) done.push('laid ' + res.name + ' in ' + res.vein + ', bars ' + res.bars[0] + '–' + res.bars[1]);
}
addSay((afterError ? 'the wire is down, so i worked by hand. ' : '') + (done.length ? done.join('. ') + '.' : 'i listened, but found nothing to change.'));
}
async function say(text) {
text = String(text || '').trim();
if (!text) return;
if (busy) {
addSay('one at a time — the choir is still working.');
return;
}
busy = true;
addYou(text);
try {
if (live) {
await modelSay(text);
} else {
setStatus('local', false);
await localSay(text, false);
}
} catch (e) {
addSay('the choir failed: ' + (e && e.message || e));
} finally {
busy = false;
setStatus(live ? 'listening' : 'local', false);
}
}
/* ————— boot ————— */
async function init() {
const meta = $('.state-meta');
if (meta) meta.textContent = TOOL_NAMES.length + ' tools';
try {
const r = await fetch(API + '/api/agent', { signal: AbortSignal.timeout(3000) });
const j = await r.json();
live = !!j.live;
} catch (e) {
live = false;
}
document.body.classList.toggle('agent-offline', !live);
if (netText) netText.textContent = live ? 'live' : 'local';
const chip = $('.chip');
if (chip) {
chip.classList.toggle('is-live', live);
chip.title = live ? 'Embedded agent: live model on the local server' : 'Embedded agent: local conductor (no model on the wire)';
}
setStatus(live ? 'listening' : 'local', false);
if (!welcomed) {
welcomed = true;
addSay(live
? 'the choir is listening. say what should be heard — i can take cores, cut faces, lay stones, or change the weather.'
: 'the choir is local: no model on the wire. i can still take, cut, and lay by rule.');
}
}
window.ZHILA_AGENT = {
say: say,
isLive: function () { return live; },
isBusy: function () { return busy; },
tools: TOOL_NAMES.length
};
if (document.readyState === 'complete' || document.readyState === 'interactive') {
window.setTimeout(init, 250);
} else {
document.addEventListener('DOMContentLoaded', function () { window.setTimeout(init, 250); });
}
})();