hub: incremental polling (since= skeleton delta, nt rewind marker, seg ?off tail fetch) to cut relay traffic
This commit is contained in:
1 parent
0c54d5c0bc
commit
d426d45e9b
2 files changed
+27
-16
No files matched your search
+19
-11
@@ -138,7 +138,9 @@ async function sync(first){
|
||||
if (!cur) return;
|
||||
if (!first && document.hidden) return; // background tab costs nothing
|
||||
const name = cur, m = $('#main');
|
||||
let q = `/api/${encodeURIComponent(name)}/messages?last=1`;
|
||||
const olds = first ? [] : [...m.querySelectorAll('.task')];
|
||||
const since = Math.max(0, olds.length - 1); // rows below the tail are immutable: don't resend them
|
||||
let q = `/api/${encodeURIComponent(name)}/messages?since=${since}`;
|
||||
if (!first && sig) q += `&sig=${encodeURIComponent(sig)}`;
|
||||
let r;
|
||||
try { r = await api(q); } catch(e){ return; }
|
||||
@@ -148,19 +150,20 @@ async function sync(first){
|
||||
if (r.error){ if (first) m.innerHTML = `<div class="empty">${esc(r.error)}</div>`; return; }
|
||||
|
||||
const tasks = r.tasks || [];
|
||||
const total = 'nt' in r ? r.nt : tasks.length; // an old host ignores `since` and sends everything
|
||||
const t0 = tasks.find(t => t.i === 0);
|
||||
// /clear (or trim) rewinds indices -> nothing to reuse, refetch from scratch
|
||||
if (!first && (olds.length > total || (olds.length && t0 && olds[0]._input !== t0.input)))
|
||||
return open_(name);
|
||||
const atEnd = m.scrollHeight - m.scrollTop - m.clientHeight < 80;
|
||||
const olds = [...m.querySelectorAll('.task')];
|
||||
// /clear (or trim) rewinds indices -> nothing to reuse, start over
|
||||
const reset = first || olds.length > tasks.length ||
|
||||
(olds.length && olds[0]._input !== (tasks[0] || {}).input);
|
||||
if (reset) m.innerHTML = '<div class="hd"></div>';
|
||||
if (first || !m.querySelector('.hd')) m.innerHTML = '<div class="hd"></div>';
|
||||
m.querySelector('.hd').textContent = r.title || name;
|
||||
|
||||
for (const t of tasks){
|
||||
const el = m.querySelector(`.task[data-i="${t.i}"]`);
|
||||
if (el) el.update(t); else m.appendChild(taskEl(name, t));
|
||||
}
|
||||
if (reset || atEnd) m.scrollTop = m.scrollHeight;
|
||||
if (first || atEnd) m.scrollTop = m.scrollHeight;
|
||||
}
|
||||
|
||||
// one agent task: input bubble + collapsible output steps
|
||||
@@ -198,15 +201,20 @@ function segEl(name, i, s){
|
||||
<span class="tt"></span><span class="n"></span></summary>
|
||||
<div class="body dim">…</div>`;
|
||||
const tt = det.querySelector('.tt'), nn = det.querySelector('.n'), body = det.querySelector('.body');
|
||||
let n = -1, loaded = -1, busy = false; // loaded = length of the text on screen
|
||||
let n = -1, loaded = -1, busy = false, raw = ''; // raw = full text on screen, kept so growth only fetches the tail
|
||||
|
||||
const pull = async () => { // fetch full text of this step, on demand
|
||||
const pull = async () => { // fetch only what we don't have yet
|
||||
if (busy || loaded === n) return;
|
||||
busy = true;
|
||||
try {
|
||||
const r = await api(`/api/${encodeURIComponent(name)}/seg/${i}/${s.j}`);
|
||||
const base = `/api/${encodeURIComponent(name)}/seg/${i}/${s.j}`;
|
||||
let off = raw.length;
|
||||
let r = await api(`${base}?off=${off}`);
|
||||
if (!r.error && r.n < off){ off = 0; r = await api(base); } // step rewound (trim/clear) -> full refetch
|
||||
body.classList.remove('dim');
|
||||
body.innerHTML = r.error ? '⚠ ' + esc(r.error) : md(r.content);
|
||||
if (r.error){ body.innerHTML = '⚠ ' + esc(r.error); loaded = n; return; }
|
||||
raw = (off ? raw : '') + (r.content || '');
|
||||
body.innerHTML = md(raw);
|
||||
loaded = n;
|
||||
} catch(e){} finally { busy = false; }
|
||||
};
|
||||
|
||||
+8
-5
@@ -1,6 +1,6 @@
|
||||
"""GA Hub: `import hub` -> client (silent if no server); `python hub.py` -> WS server.
|
||||
hub.connect(agent, 'stapp') # or override any hook: connect(a, n, put_task=, get_outputs=, abort=)
|
||||
default put -> 'busy' if agent.is_running, else parks {'text','q'} in agent._hub_inbox for the UI
|
||||
default put -> 'busy' if agent.is_running, else parks plain text in agent._hub_inbox for the UI
|
||||
HTTP (errors = {'error','code'} + status: offline/gone 404, busy 409, timeout 504, nosupport 501, badop 400):
|
||||
GET peers -> [{name,title,n_msgs,sig}] | {name}/messages?detail=1&sig= -> {title,tasks:[{i,input,steps:
|
||||
[{j,title,n}]}],sig} or {same:1,sig} | {name}/seg/{i}/{j}?off=N -> {content,off,n} (step bodies, tailable)
|
||||
@@ -57,10 +57,12 @@ class HubClient:
|
||||
w = sum(2 if ord(ch) > 0x2E80 else 1 for ch in title) # display width: a CJK char is worth two
|
||||
if len(ins) > 1 and w < TITLE_MIN: title = ins[-2].split('\n')[0] + ' <- ' + title
|
||||
det, rows = int(c.get('detail', 1)), []
|
||||
since = max(0, int(c.get('since') or 0)) # delta: the client already holds rows < since (they are immutable)
|
||||
for i, (t, o) in enumerate(zip(tasks, outs)):
|
||||
if i < since: continue
|
||||
steps = [{'j': j, 'title': self._stitle(i, j, s), 'n': len(s or '')} for j, s in enumerate(o)]
|
||||
rows.append({'i': i, 'input': t.get('input', ''), **({'steps': steps} if det else {'ns': len(o)})})
|
||||
return {'title': title[:80], 'tasks': rows, 'sig': sig, **self.state()}
|
||||
return {'title': title[:80], 'tasks': rows, 'nt': len(tasks), 'sig': sig, **self.state()} # nt lets a delta client spot /clear
|
||||
def _seg(self, c):
|
||||
tasks, off = list(self.get_outputs() or []), max(0, int(c.get('off') or 0))
|
||||
i, j = int(c.get('i', -1)), int(c.get('j', -1))
|
||||
@@ -92,11 +94,12 @@ def serve():
|
||||
def connect(agent, name=None, put_task=None, get_outputs=None, abort=None, fold=None):
|
||||
"""One line to wire a GA host: `hub.connect(agent, 'stapp')`; any hook can still be overridden.
|
||||
Default put refuses while the agent is busy (a remote must not cut in line), else it parks
|
||||
(text, queue) in agent._hub_inbox so a UI can pop it and echo the task as its own bubble.
|
||||
plain text in agent._hub_inbox; the UI feeds it through its own input entrance when idle,
|
||||
so remote tasks behave exactly like typed ones (bubble/stream/stop). Requires a live UI tab.
|
||||
Never raises: a broken hub must not break its host."""
|
||||
def _put(text):
|
||||
if getattr(agent, 'is_running', False): return {'error': f'peer {name} is busy', 'code': 'busy'}
|
||||
agent._hub_inbox.append({'text': text, 'q': agent.put_task(text, source='hub')})
|
||||
agent._hub_inbox.append(text) # no put_task here: the UI's unified entrance sends it
|
||||
try:
|
||||
try: serve() # best effort: bring up a local hub if none is listening
|
||||
except Exception: pass
|
||||
@@ -179,7 +182,7 @@ if __name__ == '__main__':
|
||||
for n in [x for x in pcache if x not in peers]: pcache.pop(n, None)
|
||||
return rows
|
||||
@app.get('/api/{name}/messages')
|
||||
async def api_messages(name: str, detail: int = 1, sig: str = None): return out(await ask(name, {'op': 'get', 'detail': detail, 'sig': sig}))
|
||||
async def api_messages(name: str, detail: int = 1, sig: str = None, since: int = 0): return out(await ask(name, {'op': 'get', 'detail': detail, 'sig': sig, 'since': since}))
|
||||
@app.get('/api/{name}/seg/{i}/{j}')
|
||||
async def api_seg(name: str, i: int, j: int, off: int = 0): return out(await ask(name, {'op': 'seg', 'i': i, 'j': j, 'off': off}))
|
||||
@app.post('/api/{name}/put')
|
||||
|
||||
Reference in new issue
Block a user