P0: Rust engine, wire daemon and audio-reactive descent surface
This commit is contained in:
@@ -0,0 +1,470 @@
|
||||
/* 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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); });
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user