openzoo 0.48.87 → 0.48.89
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/grokui.mjs +395 -64
- package/lib/info.js +3 -1
- package/lib/livestatus.js +102 -0
- package/lib/podagent.mjs +78 -32
- package/lib/proxy.js +56 -13
- package/lib/session.js +58 -0
- package/package.json +1 -1
package/lib/grokui.mjs
CHANGED
|
@@ -12,6 +12,8 @@ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSyn
|
|
|
12
12
|
import { cpus, homedir } from 'node:os';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { adaptiveTopK, brain, brainRace, brainStream, tierModels, MODEL, PROXY, TIER_NAMES } from './podagent.mjs';
|
|
15
|
+
import { peekDirectiveStatus, STALE_THINKING_MS } from './livestatus.js';
|
|
16
|
+
import { creditBalance } from './info.js';
|
|
15
17
|
|
|
16
18
|
const PORT = Number(process.env.OZ_GROKUI_PORT || 4173);
|
|
17
19
|
// BIND HOST. Default 127.0.0.1 so the desktop app never exposes a shell-capable
|
|
@@ -54,6 +56,28 @@ const MIME = { html: 'text/html', htm: 'text/html', css: 'text/css', js: 'applic
|
|
|
54
56
|
mjs: 'application/javascript', json: 'application/json', png: 'image/png', jpg: 'image/jpeg',
|
|
55
57
|
jpeg: 'image/jpeg', gif: 'image/gif', svg: 'image/svg+xml', txt: 'text/plain', md: 'text/plain' };
|
|
56
58
|
let workspacePort = null;
|
|
59
|
+
let workspacePortResolve = () => {};
|
|
60
|
+
let workspaceBinding = false;
|
|
61
|
+
const workspacePortReady = new Promise((resolve) => { workspacePortResolve = resolve; });
|
|
62
|
+
function bindWorkspaceServer() {
|
|
63
|
+
if (workspaceServer.listening) {
|
|
64
|
+
workspacePort = workspaceServer.address().port;
|
|
65
|
+
workspacePortResolve(workspacePort);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (workspaceBinding) return;
|
|
69
|
+
workspaceBinding = true;
|
|
70
|
+
try {
|
|
71
|
+
workspaceServer.listen(0, '127.0.0.1', () => {
|
|
72
|
+
workspacePort = workspaceServer.address().port;
|
|
73
|
+
workspacePortResolve(workspacePort);
|
|
74
|
+
});
|
|
75
|
+
} catch (err) {
|
|
76
|
+
workspaceBinding = false;
|
|
77
|
+
console.error('[grokui] workspace server:', err.message);
|
|
78
|
+
setTimeout(bindWorkspaceServer, 250);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
57
81
|
// route: /<threadId>/<relpath...> — each thread is served from ITS OWN dir
|
|
58
82
|
const workspaceServer = http.createServer((req, res) => {
|
|
59
83
|
try {
|
|
@@ -65,14 +89,65 @@ const workspaceServer = http.createServer((req, res) => {
|
|
|
65
89
|
const full = safeResolveIn(dirFor(threadId), rel);
|
|
66
90
|
const data = readFileSync(full);
|
|
67
91
|
const ext = full.split('.').pop();
|
|
68
|
-
res.writeHead(200, {
|
|
92
|
+
res.writeHead(200, {
|
|
93
|
+
'content-type': MIME[ext] || 'application/octet-stream',
|
|
94
|
+
// EDIT of the same html must not be served from a cached first write.
|
|
95
|
+
'cache-control': 'no-store',
|
|
96
|
+
});
|
|
69
97
|
res.end(data);
|
|
70
98
|
} catch {
|
|
71
99
|
res.writeHead(404, { 'content-type': 'text/plain' });
|
|
72
100
|
res.end('not found');
|
|
73
101
|
}
|
|
74
102
|
});
|
|
75
|
-
workspaceServer.
|
|
103
|
+
workspaceServer.on('error', (err) => {
|
|
104
|
+
workspaceBinding = false;
|
|
105
|
+
console.error('[grokui] workspace server:', err.message);
|
|
106
|
+
setTimeout(bindWorkspaceServer, 250);
|
|
107
|
+
});
|
|
108
|
+
bindWorkspaceServer();
|
|
109
|
+
|
|
110
|
+
function isPreviewableRel(rel) {
|
|
111
|
+
const base = path.basename(String(rel || '').split('?')[0]).toLowerCase();
|
|
112
|
+
return base.endsWith('.html') || base.endsWith('.htm');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function ensureWorkspacePort(ms = 4000) {
|
|
116
|
+
if (workspacePort) return workspacePort;
|
|
117
|
+
if (!workspaceServer.listening) bindWorkspaceServer();
|
|
118
|
+
let timer;
|
|
119
|
+
const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(null), ms); });
|
|
120
|
+
const port = await Promise.race([workspacePortReady, timeout]);
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
return port || workspacePort;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function workspaceFileUrl(originId, rel) {
|
|
126
|
+
const clean = String(rel || '').replace(/^\/+/, '');
|
|
127
|
+
return `http://localhost:${workspacePort}/${originId}/${clean}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// WRITE/EDIT of a playable page must ack a real http:// URL (same shape as
|
|
131
|
+
// SERVE), not a dead disk path. Wait for the static server rather than
|
|
132
|
+
// telling the user to try again after they just wrote a game.
|
|
133
|
+
async function previewAck(originId, rel) {
|
|
134
|
+
if (!isPreviewableRel(rel)) return '';
|
|
135
|
+
const port = await ensureWorkspacePort();
|
|
136
|
+
if (!port) {
|
|
137
|
+
return `\nPreview: the workspace server is binding; the page is ${rel} and will be at `
|
|
138
|
+
+ `http://localhost/<port>/${originId}/${String(rel).replace(/^\/+/, '')}.`;
|
|
139
|
+
}
|
|
140
|
+
return `\nPreview: ${workspaceFileUrl(originId, rel)}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const HTML_PREVIEW_RULE = `
|
|
144
|
+
PREVIEW IS AUTOMATIC. After you WRITE or EDIT a .html / .htm file (including index.html),
|
|
145
|
+
the harness already served it — the WRITE ack includes a real http://localhost URL and the
|
|
146
|
+
chat bubble shows a live iframe. The harness will preview. Do not tell the user you
|
|
147
|
+
"can't preview", cannot open a browser, or dump a raw disk path (/Users/..., ~/.openzoo/...)
|
|
148
|
+
as the punchline. The page is already on screen. If you mention a location, use that
|
|
149
|
+
http://localhost link, never a filesystem path.
|
|
150
|
+
`;
|
|
76
151
|
|
|
77
152
|
const PALETTE = ['#e91e8c', '#34c759', '#ff9500', '#5e5ce6', '#ff3b30', '#0a84ff', '#00c7be'];
|
|
78
153
|
function colorFor(name) {
|
|
@@ -117,11 +192,15 @@ workspace folder, not their real project. Same one-line-no-prose reply format:
|
|
|
117
192
|
SERVE: <relative path, or blank for the dir root> get a real http:// URL for a file —
|
|
118
193
|
use this instead of claiming you
|
|
119
194
|
"can't expose a port": you can serve
|
|
120
|
-
static files, just not run a process
|
|
195
|
+
static files, just not run a process.
|
|
196
|
+
HTML writes are auto-served — you do
|
|
197
|
+
not need a separate SERVE for a
|
|
198
|
+
playable page.
|
|
121
199
|
FETCH: <url> actually fetch and read a page's real
|
|
122
200
|
text — web search only gives you short
|
|
123
201
|
snippets; use FETCH when asked to
|
|
124
202
|
"read" or quote something specific
|
|
203
|
+
${HTML_PREVIEW_RULE}
|
|
125
204
|
|
|
126
205
|
HOW YOUR SITE ACTUALLY GETS A URL — read this before writing web files.
|
|
127
206
|
|
|
@@ -296,7 +375,14 @@ function loadThreads() {
|
|
|
296
375
|
if (!existsSync(STORE_FILE)) return false;
|
|
297
376
|
const arr = JSON.parse(readFileSync(STORE_FILE, 'utf8'));
|
|
298
377
|
if (!Array.isArray(arr) || !arr.length) return false;
|
|
299
|
-
for (const t of arr)
|
|
378
|
+
for (const t of arr) {
|
|
379
|
+
// A crash mid-turn persisted status=thinking. Do not reload into mute "…".
|
|
380
|
+
if (t.status === 'thinking') {
|
|
381
|
+
t.status = 'idle';
|
|
382
|
+
t.liveStatus = '';
|
|
383
|
+
}
|
|
384
|
+
threads.set(t.id, t);
|
|
385
|
+
}
|
|
300
386
|
return true;
|
|
301
387
|
} catch { return false; }
|
|
302
388
|
}
|
|
@@ -367,9 +453,12 @@ the user sets or changes it with "/dir <path>" in chat. Same format:
|
|
|
367
453
|
READ: <relative path> read a file back
|
|
368
454
|
SERVE: <relative path, or blank for the dir root> get a real http:// URL for it — use
|
|
369
455
|
this instead of saying you can't
|
|
370
|
-
expose a port
|
|
456
|
+
expose a port. HTML writes are
|
|
457
|
+
auto-served; you do not need SERVE
|
|
458
|
+
just to preview a playable page.
|
|
371
459
|
FETCH: <url> actually fetch and read a page's real
|
|
372
460
|
text — web search only gives snippets
|
|
461
|
+
${HTML_PREVIEW_RULE}
|
|
373
462
|
RUN: <shell command> run a REAL shell command in this
|
|
374
463
|
group's shared directory — pauses the
|
|
375
464
|
WHOLE round for the user's approval
|
|
@@ -1106,6 +1195,24 @@ setInterval(() => {
|
|
|
1106
1195
|
}
|
|
1107
1196
|
}, 15000).unref();
|
|
1108
1197
|
|
|
1198
|
+
// A turn that is thinking with no deltas/status for too long is dead — the
|
|
1199
|
+
// stream reader used to hang forever and block the next user prompt behind
|
|
1200
|
+
// mute dots. Bump turnSeq so the in-flight runTurn bails, then idle.
|
|
1201
|
+
setInterval(() => {
|
|
1202
|
+
const now = Date.now();
|
|
1203
|
+
let dirty = false;
|
|
1204
|
+
for (const t of threads.values()) {
|
|
1205
|
+
if (t.status !== 'thinking') continue;
|
|
1206
|
+
const last = t.lastDeltaAt || t.thinkingAt || 0;
|
|
1207
|
+
if (!last || now - last < STALE_THINKING_MS) continue;
|
|
1208
|
+
t.turnSeq = (t.turnSeq || 0) + 1;
|
|
1209
|
+
t.status = 'idle';
|
|
1210
|
+
t.liveStatus = '';
|
|
1211
|
+
dirty = true;
|
|
1212
|
+
}
|
|
1213
|
+
if (dirty) saveThreads();
|
|
1214
|
+
}, 5000).unref();
|
|
1215
|
+
|
|
1109
1216
|
// Directives that only READ. These are safe to run at the same time, so a
|
|
1110
1217
|
// reply carrying several of them costs one round trip instead of N — a model
|
|
1111
1218
|
// that wants four files currently spends four full turns (and four payments)
|
|
@@ -1487,7 +1594,19 @@ function nameAddressedToSend(reply, originId) {
|
|
|
1487
1594
|
}).join('\n');
|
|
1488
1595
|
}
|
|
1489
1596
|
|
|
1490
|
-
|
|
1597
|
+
function emitLiveStatus(originId, onEvent, detail) {
|
|
1598
|
+
if (!detail) return;
|
|
1599
|
+
const t = threads.get(originId);
|
|
1600
|
+
if (t) {
|
|
1601
|
+
t.liveStatus = detail;
|
|
1602
|
+
t.lastDeltaAt = Date.now();
|
|
1603
|
+
}
|
|
1604
|
+
onEvent?.({ type: 'status', name: t?.name, color: t?.color, detail });
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
async function tryDirective(reply, originId, onEvent) {
|
|
1608
|
+
const trail = peekDirectiveStatus(reply);
|
|
1609
|
+
if (trail) emitLiveStatus(originId, onEvent, trail);
|
|
1491
1610
|
// Foreign envelope in, our directives out — before any matching runs, so
|
|
1492
1611
|
// every branch below sees the shape it was written for.
|
|
1493
1612
|
const translated = translateForeignToolCall(reply);
|
|
@@ -1512,7 +1631,7 @@ async function tryDirective(reply, originId) {
|
|
|
1512
1631
|
const batch = [...reply.matchAll(PARALLEL_DIRECTIVE)];
|
|
1513
1632
|
if (batch.length > 1) {
|
|
1514
1633
|
const results = await Promise.all(
|
|
1515
|
-
batch.map((m) => tryDirective(m[0].replace(/^[ \t>*-]*/, ''), originId)
|
|
1634
|
+
batch.map((m) => tryDirective(m[0].replace(/^[ \t>*-]*/, ''), originId, onEvent)
|
|
1516
1635
|
.catch((e) => `${m[1]}: ${e.message}`)),
|
|
1517
1636
|
);
|
|
1518
1637
|
return results.filter(Boolean).join('\n\n');
|
|
@@ -1613,7 +1732,7 @@ async function tryDirective(reply, originId) {
|
|
|
1613
1732
|
const sendAll = directiveLines(reply, 'SEND');
|
|
1614
1733
|
if (sendAll.length > 1) {
|
|
1615
1734
|
const out = [];
|
|
1616
|
-
for (const line of sendAll) out.push(await tryDirective('SEND: ' + line, originId));
|
|
1735
|
+
for (const line of sendAll) out.push(await tryDirective('SEND: ' + line, originId, onEvent));
|
|
1617
1736
|
return out.filter(Boolean).join('\n');
|
|
1618
1737
|
}
|
|
1619
1738
|
const sendM = sendAll.length === 1 ? /^([^|]+)\|([\s\S]+)/.exec(sendAll[0]) : null;
|
|
@@ -1642,7 +1761,7 @@ async function tryDirective(reply, originId) {
|
|
|
1642
1761
|
const pingAll = directiveLines(reply, 'PING');
|
|
1643
1762
|
if (pingAll.length > 1) {
|
|
1644
1763
|
const out = [];
|
|
1645
|
-
for (const line of pingAll) out.push(await tryDirective('PING: ' + line, originId));
|
|
1764
|
+
for (const line of pingAll) out.push(await tryDirective('PING: ' + line, originId, onEvent));
|
|
1646
1765
|
return out.filter(Boolean).join('\n');
|
|
1647
1766
|
}
|
|
1648
1767
|
const ping = pingAll.length === 1 ? [null, pingAll[0]] : null;
|
|
@@ -1687,7 +1806,7 @@ async function tryDirective(reply, originId) {
|
|
|
1687
1806
|
const full = safeResolveIn(dirFor(originId), rel);
|
|
1688
1807
|
mkdirSync(path.dirname(full), { recursive: true });
|
|
1689
1808
|
writeFileSync(full, content);
|
|
1690
|
-
return `Wrote ${rel} (${Buffer.byteLength(content)} bytes) to ${dirFor(originId)}
|
|
1809
|
+
return `Wrote ${rel} (${Buffer.byteLength(content)} bytes) to ${dirFor(originId)}.${await previewAck(originId, rel)}`;
|
|
1691
1810
|
} catch (e) { return `Couldn't write ${rel}: ${e.message}`; }
|
|
1692
1811
|
}
|
|
1693
1812
|
const readD = /^[ \t>*-]*READ:\s*(.+)/m.exec(reply);
|
|
@@ -1713,7 +1832,7 @@ async function tryDirective(reply, originId) {
|
|
|
1713
1832
|
if (hits === 0) return `EDIT ${rel}: that exact text isn't in the file — READ it first, the copy must match byte for byte.`;
|
|
1714
1833
|
if (hits > 1) return `EDIT ${rel}: that text appears ${hits} times — include more surrounding context so it matches exactly once.`;
|
|
1715
1834
|
writeFileSync(full, before.replace(oldStr, newStr));
|
|
1716
|
-
return `Edited ${rel} (${before.length} -> ${before.replace(oldStr, newStr).length} bytes)
|
|
1835
|
+
return `Edited ${rel} (${before.length} -> ${before.replace(oldStr, newStr).length} bytes).${await previewAck(originId, rel)}`;
|
|
1717
1836
|
} catch (e) { return `Couldn't edit ${rel}: ${e.message}`; }
|
|
1718
1837
|
}
|
|
1719
1838
|
|
|
@@ -1739,7 +1858,7 @@ async function tryDirective(reply, originId) {
|
|
|
1739
1858
|
applied.push(o.slice(0, 40));
|
|
1740
1859
|
}
|
|
1741
1860
|
writeFileSync(full, next);
|
|
1742
|
-
return `MULTIEDIT ${rel}: ${applied.length} edit(s) applied (${before.length} -> ${next.length} bytes)
|
|
1861
|
+
return `MULTIEDIT ${rel}: ${applied.length} edit(s) applied (${before.length} -> ${next.length} bytes).${await previewAck(originId, rel)}`;
|
|
1743
1862
|
} catch (e) { return `Couldn't multiedit ${rel}: ${e.message}`; }
|
|
1744
1863
|
}
|
|
1745
1864
|
|
|
@@ -1888,8 +2007,11 @@ async function tryDirective(reply, originId) {
|
|
|
1888
2007
|
const serve = /^[ \t>*-]*SERVE:\s*(.*)$/m.exec(reply);
|
|
1889
2008
|
if (serve) {
|
|
1890
2009
|
const rel = serve[1].trim();
|
|
1891
|
-
|
|
1892
|
-
|
|
2010
|
+
const port = await ensureWorkspacePort();
|
|
2011
|
+
if (!port) {
|
|
2012
|
+
return `Serving ${rel || 'index.html'} from ${dirFor(originId)} — waiting for the workspace port to bind.`;
|
|
2013
|
+
}
|
|
2014
|
+
return `Serving at http://localhost:${port}/${originId}/${rel}`;
|
|
1893
2015
|
}
|
|
1894
2016
|
const fetchD = /^[ \t>*-]*FETCH:\s*(\S+)/m.exec(reply);
|
|
1895
2017
|
if (fetchD) {
|
|
@@ -2026,59 +2148,78 @@ async function mcpDirective(url, tool, args) {
|
|
|
2026
2148
|
}
|
|
2027
2149
|
|
|
2028
2150
|
// onEvent (optional) gets live progress for whoever's actually watching this
|
|
2029
|
-
// call: {type:'start',name,color} when a bot begins its turn, {type:'
|
|
2030
|
-
//
|
|
2031
|
-
//
|
|
2151
|
+
// call: {type:'start',name,color} when a bot begins its turn, {type:'status',
|
|
2152
|
+
// detail} while paying / waiting / walking tools, {type:'delta',name,color,
|
|
2153
|
+
// delta} per streamed token, {type:'final',name,color,text} once its full
|
|
2154
|
+
// reply (or directive ack) is settled. Background turns — a SPAWNed
|
|
2032
2155
|
// subagent nobody's looking at yet — run with onEvent omitted and just use
|
|
2033
2156
|
// the plain non-streaming brain(), which is cheaper when nothing renders it.
|
|
2034
2157
|
async function runTurn(threadId, userText, onEvent, images) {
|
|
2035
2158
|
const t = threads.get(threadId);
|
|
2036
2159
|
if (!t) return;
|
|
2160
|
+
// A new user prompt must not sit behind a dead auto-continue. Bump the
|
|
2161
|
+
// generation so the previous runTurn's awaits bail instead of keeping
|
|
2162
|
+
// status=thinking and the UI on "…".
|
|
2163
|
+
if (!isHarnessUserText(userText)) t.turnSeq = (t.turnSeq || 0) + 1;
|
|
2164
|
+
const seq = t.turnSeq || 0;
|
|
2165
|
+
const stillMine = () => threads.get(threadId)?.turnSeq === seq;
|
|
2166
|
+
const paint = (ev) => {
|
|
2167
|
+
if (!stillMine()) return;
|
|
2168
|
+
if (ev.type === 'status' && ev.detail) t.liveStatus = ev.detail;
|
|
2169
|
+
if (ev.type === 'delta' || ev.type === 'status' || ev.type === 'start') t.lastDeltaAt = Date.now();
|
|
2170
|
+
onEvent?.(ev);
|
|
2171
|
+
};
|
|
2037
2172
|
t.history.push(images && images.length ? { who: 'user', text: userText, images } : { who: 'user', text: userText });
|
|
2038
2173
|
t.lastActivityAt = Date.now();
|
|
2174
|
+
t.status = 'thinking';
|
|
2175
|
+
t.thinkingAt = Date.now();
|
|
2176
|
+
t.lastDeltaAt = Date.now();
|
|
2177
|
+
t.liveStatus = 'waiting on model…';
|
|
2178
|
+
let chained = false;
|
|
2179
|
+
let parked = false;
|
|
2180
|
+
try {
|
|
2039
2181
|
if (t.members) {
|
|
2040
|
-
t.status = 'thinking';
|
|
2041
2182
|
// sequential, not parallel: each member's context is rebuilt from
|
|
2042
2183
|
// t.history right before its turn, so it sees every reply (including
|
|
2043
2184
|
// spawns/sends) the earlier members in THIS round already made
|
|
2044
2185
|
for (const m of t.members) {
|
|
2186
|
+
if (!stillMine()) return;
|
|
2045
2187
|
const msgs = buildMemberMessages(t, m);
|
|
2046
2188
|
let r = '';
|
|
2047
|
-
|
|
2189
|
+
paint({ type: 'start', name: m.name, color: m.color, detail: 'waiting on model…' });
|
|
2190
|
+
const emitStatus = (detail) => paint({ type: 'status', name: m.name, color: m.color, detail });
|
|
2048
2191
|
try {
|
|
2049
2192
|
r = onEvent
|
|
2050
|
-
? (await brainStream(msgs, (delta) =>
|
|
2193
|
+
? (await brainStream(msgs, (delta) => paint({ type: 'delta', name: m.name, color: m.color, delta }), t.contextId, undefined, undefined, 0, 0, emitStatus)).trim()
|
|
2051
2194
|
: (await brain(msgs, t.contextId)).trim();
|
|
2052
2195
|
} catch (e) { r = `error: ${e.message}`; }
|
|
2196
|
+
if (!stillMine()) return;
|
|
2053
2197
|
const runCmd = parseRun(r);
|
|
2054
2198
|
if (runCmd) {
|
|
2055
2199
|
const command = runCmd;
|
|
2056
2200
|
if (t.runMode === 'auto') {
|
|
2201
|
+
emitLiveStatus(t.id, paint, peekDirectiveStatus('', command));
|
|
2057
2202
|
const output = await execCommand(command, dirFor(t.id));
|
|
2058
2203
|
const shown = `$ ${command}\n${output}`;
|
|
2059
2204
|
t.history.push({ who: 'bot', text: shown, name: m.name, color: m.color });
|
|
2060
|
-
|
|
2205
|
+
paint({ type: 'final', name: m.name, color: m.color, text: shown });
|
|
2061
2206
|
// this member's turn is done; the round continues to the next member
|
|
2062
2207
|
continue;
|
|
2063
2208
|
}
|
|
2064
2209
|
const runId = randomUUID();
|
|
2065
2210
|
t.pendingRun = { runId, command, cwd: dirFor(t.id) };
|
|
2066
2211
|
t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending', name: m.name, color: m.color });
|
|
2067
|
-
|
|
2212
|
+
paint({ type: 'run-pending', runId, command, name: m.name, color: m.color });
|
|
2068
2213
|
// pauses the WHOLE round here — the rest of the group gets their turn
|
|
2069
2214
|
// on the round that runs after the user approves/denies
|
|
2070
|
-
|
|
2071
|
-
t.lastActivityAt = Date.now();
|
|
2072
|
-
saveThreads();
|
|
2215
|
+
parked = true;
|
|
2073
2216
|
return;
|
|
2074
2217
|
}
|
|
2075
|
-
const ack = await tryDirective(r, t.id);
|
|
2218
|
+
const ack = await tryDirective(r, t.id, paint);
|
|
2076
2219
|
const finalText = ack ?? (r || '(no response)');
|
|
2077
2220
|
t.history.push({ who: 'bot', text: finalText, name: m.name, color: m.color });
|
|
2078
|
-
|
|
2221
|
+
paint({ type: 'final', name: m.name, color: m.color, text: finalText });
|
|
2079
2222
|
}
|
|
2080
|
-
t.status = 'idle';
|
|
2081
|
-
saveThreads();
|
|
2082
2223
|
bindThread(t).catch(() => {});
|
|
2083
2224
|
return;
|
|
2084
2225
|
}
|
|
@@ -2092,9 +2233,8 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2092
2233
|
delete t.autoNudged;
|
|
2093
2234
|
}
|
|
2094
2235
|
t.messages.push({ role: 'user', content: contentFor(userText, images) });
|
|
2095
|
-
t.status = 'thinking';
|
|
2096
2236
|
let reply = '';
|
|
2097
|
-
|
|
2237
|
+
paint({ type: 'start', name: t.name, color: t.color, detail: 'waiting on model…' });
|
|
2098
2238
|
// Transient: the nudge is appended for THIS call only and never pushed into
|
|
2099
2239
|
// t.messages, so it can't accumulate across a chained auto run or get bound
|
|
2100
2240
|
// into the thread's context.
|
|
@@ -2119,7 +2259,8 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2119
2259
|
// `attempt` exists because a retry must be allowed to land somewhere else:
|
|
2120
2260
|
// see the empty-completion loop below.
|
|
2121
2261
|
const ask = async (attempt = 0) => {
|
|
2122
|
-
const emit = (delta) =>
|
|
2262
|
+
const emit = (delta) => paint({ type: 'delta', name: t.name, color: t.color, delta });
|
|
2263
|
+
const emitStatus = (detail) => paint({ type: 'status', name: t.name, color: t.color, detail });
|
|
2123
2264
|
// Retrieval breadth scales with the PROJECT's corpus, not this thread's —
|
|
2124
2265
|
// the holobrain is shared at the root, so that is the pool being searched.
|
|
2125
2266
|
const topK = adaptiveTopK((threads.get(rootOf(t).rootId) || t).boundItems);
|
|
@@ -2131,16 +2272,18 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2131
2272
|
// the middle (2 of 3) is a judged answer without the slowest entrant
|
|
2132
2273
|
// setting the latency.
|
|
2133
2274
|
const need = Math.min(Math.max(Number(t.raceNeed) || 1, 1), race);
|
|
2275
|
+
emitStatus('waiting on model…');
|
|
2134
2276
|
return (await brainRace(callMsgs, emit, t.contextId, models, need)).trim();
|
|
2135
2277
|
}
|
|
2136
2278
|
// A retry draws a DIFFERENT model from the tier rather than the same one.
|
|
2137
2279
|
const model = t.model || (await tierModels(t.tier || 'medium', attempt + 1, attempt > 0))[attempt] || undefined;
|
|
2138
2280
|
return (onEvent
|
|
2139
|
-
? (await brainStream(callMsgs, emit, t.contextId, model)).trim()
|
|
2281
|
+
? (await brainStream(callMsgs, emit, t.contextId, model, undefined, 0, topK, emitStatus)).trim()
|
|
2140
2282
|
: (await brain(callMsgs, t.contextId, model, topK)).trim());
|
|
2141
2283
|
};
|
|
2142
2284
|
try {
|
|
2143
2285
|
reply = await ask();
|
|
2286
|
+
if (!stillMine()) return;
|
|
2144
2287
|
// An EMPTY completion is transient far more often than it is meaningful —
|
|
2145
2288
|
// it showed up repeatedly as a dead "(no response)" bubble that cost the
|
|
2146
2289
|
// user a turn and told them nothing. Retry once before giving up, and if
|
|
@@ -2157,7 +2300,9 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2157
2300
|
// fix. A thread pinned with /model stays pinned; that was an explicit
|
|
2158
2301
|
// choice and silently answering as something else would be worse.
|
|
2159
2302
|
for (let i = 0; !reply && i < AUTO_EMPTY_RETRIES; i++) {
|
|
2303
|
+
paint({ type: 'status', name: t.name, color: t.color, detail: 'retrying…' });
|
|
2160
2304
|
await new Promise((r) => setTimeout(r, 400 * (i + 1)));
|
|
2305
|
+
if (!stillMine()) return;
|
|
2161
2306
|
reply = await ask(t.model ? 0 : i + 1);
|
|
2162
2307
|
}
|
|
2163
2308
|
if (!reply) {
|
|
@@ -2168,15 +2313,18 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2168
2313
|
} catch (e) {
|
|
2169
2314
|
reply = `error: ${e.message}`;
|
|
2170
2315
|
}
|
|
2316
|
+
if (!stillMine()) return;
|
|
2171
2317
|
t.messages.push({ role: 'assistant', content: reply });
|
|
2172
2318
|
const runCmd = parseRun(reply);
|
|
2173
2319
|
if (runCmd) {
|
|
2174
2320
|
const command = runCmd;
|
|
2175
2321
|
if (t.runMode === 'auto') {
|
|
2322
|
+
emitLiveStatus(t.id, paint, peekDirectiveStatus('', command));
|
|
2176
2323
|
const output = await execCommand(command, dirFor(t.id));
|
|
2324
|
+
if (!stillMine()) return;
|
|
2177
2325
|
const shown = `$ ${command}\n${output}`;
|
|
2178
2326
|
t.history.push({ who: 'bot', text: shown });
|
|
2179
|
-
|
|
2327
|
+
paint({ type: 'final', name: t.name, color: t.color, text: shown });
|
|
2180
2328
|
// FEED THE OUTPUT BACK. The 'ask' path already does this on approve, so
|
|
2181
2329
|
// auto mode was strictly LESS capable than the gated one: the command
|
|
2182
2330
|
// ran, the result was shown, and the model never saw it — no diagnosis,
|
|
@@ -2186,9 +2334,6 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2186
2334
|
// AUTO_MAX_STEPS chained commands per user message, reset whenever the
|
|
2187
2335
|
// user speaks again.
|
|
2188
2336
|
t.autoSteps = (t.autoSteps || 0) + 1;
|
|
2189
|
-
t.status = 'idle';
|
|
2190
|
-
t.lastActivityAt = Date.now();
|
|
2191
|
-
saveThreads();
|
|
2192
2337
|
if (t.autoSteps < AUTO_MAX_STEPS) {
|
|
2193
2338
|
// BIND BEFORE CHAINING. bindThread only ran at the end of a normal
|
|
2194
2339
|
// turn, and both auto paths return before reaching it — so in auto
|
|
@@ -2196,12 +2341,14 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2196
2341
|
// most material (command output, GLOB results, MCP tool lists). The
|
|
2197
2342
|
// holographic context stopped growing precisely when it mattered.
|
|
2198
2343
|
bindThread(t).catch(() => {});
|
|
2344
|
+
chained = true;
|
|
2199
2345
|
runTurn(threadId, condense('(command output)', output), onEvent).catch(() => {});
|
|
2200
2346
|
} else {
|
|
2201
2347
|
// Do not park. A "say continue" note is the failure mode auto exists
|
|
2202
2348
|
// to avoid — inject continue and keep going until DONE or ask.
|
|
2203
2349
|
t.autoSteps = 0;
|
|
2204
2350
|
bindThread(t).catch(() => {});
|
|
2351
|
+
chained = true;
|
|
2205
2352
|
runTurn(threadId, AUTO_CONTINUE, onEvent).catch(() => {});
|
|
2206
2353
|
}
|
|
2207
2354
|
return;
|
|
@@ -2210,20 +2357,16 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2210
2357
|
const runId = randomUUID();
|
|
2211
2358
|
t.pendingRun = { runId, command, cwd: dirFor(t.id) };
|
|
2212
2359
|
t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending' });
|
|
2213
|
-
|
|
2360
|
+
paint({ type: 'run-pending', runId, command, name: t.name, color: t.color });
|
|
2214
2361
|
}
|
|
2215
|
-
|
|
2216
|
-
t.lastActivityAt = Date.now();
|
|
2217
|
-
saveThreads();
|
|
2362
|
+
parked = true;
|
|
2218
2363
|
return;
|
|
2219
2364
|
}
|
|
2220
|
-
const ack = await tryDirective(reply, t.id);
|
|
2365
|
+
const ack = await tryDirective(reply, t.id, paint);
|
|
2366
|
+
if (!stillMine()) return;
|
|
2221
2367
|
const finalText = ack ?? (reply || '(no response)');
|
|
2222
2368
|
t.history.push({ who: 'bot', text: finalText });
|
|
2223
|
-
|
|
2224
|
-
t.status = 'idle';
|
|
2225
|
-
t.lastActivityAt = Date.now();
|
|
2226
|
-
saveThreads();
|
|
2369
|
+
paint({ type: 'final', name: t.name, color: t.color, text: finalText });
|
|
2227
2370
|
|
|
2228
2371
|
// AUTO CONTINUES AFTER *ANY* DIRECTIVE, not just RUN.
|
|
2229
2372
|
//
|
|
@@ -2240,6 +2383,7 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2240
2383
|
&& !/^[ \t>*-]*DONE:/m.test(reply)) {
|
|
2241
2384
|
t.autoSteps = (t.autoSteps || 0) + 1;
|
|
2242
2385
|
bindThread(t).catch(() => {}); // bind every hop, not just the last one
|
|
2386
|
+
chained = true;
|
|
2243
2387
|
if (t.autoSteps < AUTO_MAX_STEPS) {
|
|
2244
2388
|
runTurn(threadId, condense('(directive result)', ack), onEvent).catch(() => {});
|
|
2245
2389
|
} else {
|
|
@@ -2273,8 +2417,8 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2273
2417
|
// another continue rather than stopping.
|
|
2274
2418
|
t.autoNudged = true;
|
|
2275
2419
|
t.autoSteps = (t.autoSteps || 0) + 1;
|
|
2276
|
-
saveThreads();
|
|
2277
2420
|
bindThread(t).catch(() => {});
|
|
2421
|
+
chained = true;
|
|
2278
2422
|
if (t.autoSteps < AUTO_MAX_STEPS) {
|
|
2279
2423
|
runTurn(threadId, NUDGE, onEvent).catch(() => {});
|
|
2280
2424
|
} else {
|
|
@@ -2284,6 +2428,17 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2284
2428
|
return;
|
|
2285
2429
|
}
|
|
2286
2430
|
bindThread(t).catch(() => {});
|
|
2431
|
+
} finally {
|
|
2432
|
+
// A hung or thrown brainStream used to leave t.status = 'thinking' and the
|
|
2433
|
+
// UI on mute "…" forever. Always idle unless this hop chained or parked
|
|
2434
|
+
// on an approval — the next runTurn sets thinking again itself.
|
|
2435
|
+
if (stillMine() && !chained && !parked && !t.pendingRun) {
|
|
2436
|
+
t.status = 'idle';
|
|
2437
|
+
t.liveStatus = '';
|
|
2438
|
+
}
|
|
2439
|
+
t.lastActivityAt = Date.now();
|
|
2440
|
+
saveThreads();
|
|
2441
|
+
}
|
|
2287
2442
|
}
|
|
2288
2443
|
|
|
2289
2444
|
// Said it would, without a directive line. "Spawned X" and "working on it" are
|
|
@@ -2418,6 +2573,7 @@ function threadSummary(t) {
|
|
|
2418
2573
|
// an approval nobody knew it wanted. The blue dot means "working"; this
|
|
2419
2574
|
// means "your move".
|
|
2420
2575
|
awaitingUser: Boolean(t.pendingRun),
|
|
2576
|
+
liveStatus: t.status === 'thinking' ? (t.liveStatus || '') : '',
|
|
2421
2577
|
rootId: rootOf(t).rootId, depth: rootOf(t).depth,
|
|
2422
2578
|
rootName: (threads.get(rootOf(t).rootId) || t).name,
|
|
2423
2579
|
// The spend dial, so the header can show it without a round trip per
|
|
@@ -2425,7 +2581,8 @@ function threadSummary(t) {
|
|
|
2425
2581
|
tier: t.tier || 'medium', race: Number(t.race) || 0, raceNeed: Number(t.raceNeed) || 1, model: t.model || '',
|
|
2426
2582
|
// How many bots sit BELOW this one. The ping-all affordance belongs on
|
|
2427
2583
|
// anyone with a crew, not only on a project root.
|
|
2428
|
-
kids: subtreeOf(t.id).length
|
|
2584
|
+
kids: subtreeOf(t.id).length,
|
|
2585
|
+
workspacePort: workspacePort || 0 };
|
|
2429
2586
|
}
|
|
2430
2587
|
|
|
2431
2588
|
const APP_HTML = `<!doctype html>
|
|
@@ -2557,6 +2714,10 @@ const APP_HTML = `<!doctype html>
|
|
|
2557
2714
|
.wrow .wcopy { flex: 0 0 auto; color: #6f7080; font-size: 10px; text-transform: uppercase; letter-spacing: .06em;
|
|
2558
2715
|
user-select: none; }
|
|
2559
2716
|
.wrow:hover .wcopy { color: #b8f240; }
|
|
2717
|
+
.wcredit { border: 1px solid #2a3a18; background: rgba(184,242,64,.08); border-radius: 12px;
|
|
2718
|
+
padding: 12px 14px; margin-bottom: 12px; }
|
|
2719
|
+
.wcredit .wbig { color: #b8f240; font-size: 22px; font-weight: 700; letter-spacing: -0.03em; }
|
|
2720
|
+
.wcredit .wlab2 { color: #8e8e93; font-size: 11px; margin-top: 2px; }
|
|
2560
2721
|
.wbal { border: 1px solid #1c1c1e; border-radius: 12px; padding: 10px 12px; margin-bottom: 10px;
|
|
2561
2722
|
font-size: 12px; color: #ececec; line-height: 1.7; word-break: break-word; }
|
|
2562
2723
|
.wnote { color: #6f7080; font-size: 11px; line-height: 1.6; margin-top: 12px; }
|
|
@@ -2579,11 +2740,14 @@ const APP_HTML = `<!doctype html>
|
|
|
2579
2740
|
inside the dials (race / wallet / ◎) once they take a second row. top is
|
|
2580
2741
|
set from the header's live bottom in placeHud(). z-index stays high; the
|
|
2581
2742
|
bug was geometry, not stacking. */
|
|
2582
|
-
#hud { position: absolute; right: 14px; width:
|
|
2743
|
+
#hud { position: absolute; right: 14px; width: 270px; background: rgba(14,14,17,.94);
|
|
2583
2744
|
border: 1px solid #333340; border-radius: 10px; padding: 12px 14px; font: 11px/1.5 Menlo, monospace;
|
|
2584
2745
|
display: none; z-index: 300; box-shadow: 0 12px 30px rgba(0,0,0,.5); }
|
|
2585
2746
|
#hud.show { display: block; }
|
|
2586
2747
|
#hud .htitle { color: #b8f240; font-size: 10px; letter-spacing: .04em; margin-bottom: 10px; }
|
|
2748
|
+
#hud .htitle.hsession { margin-top: 12px; padding-top: 10px; border-top: 1px solid #333340; color: #6f7080; }
|
|
2749
|
+
#hud .hcredit { color: #b8f240; font-size: 22px; font-weight: 700; letter-spacing: -0.03em; line-height: 1.15; }
|
|
2750
|
+
#hud .hcreditlab { color: #999aa8; font-size: 10px; margin: 2px 0 8px; }
|
|
2587
2751
|
#hud .hrow { display: flex; justify-content: space-between; margin: 6px 0; color: #f0f0eb; font-size: 12px; }
|
|
2588
2752
|
#hud .hrow span:first-child { color: #999aa8; font-size: 10.5px; }
|
|
2589
2753
|
#hud .hlime { color: #b8f240; }
|
|
@@ -2621,7 +2785,13 @@ const APP_HTML = `<!doctype html>
|
|
|
2621
2785
|
/* rendered markdown. The bubble is pre-wrap for plain text, but block
|
|
2622
2786
|
elements carry their own spacing — leaving pre-wrap on would add the
|
|
2623
2787
|
source newlines back on top of it and double every gap. */
|
|
2624
|
-
.bubble:has(> p, > .md-h, > .md-table, > .md-list, > .md-pre) { white-space: normal; }
|
|
2788
|
+
.bubble:has(> p, > .md-h, > .md-table, > .md-list, > .md-pre, > .html-preview-wrap) { white-space: normal; }
|
|
2789
|
+
.row:has(.html-preview) { max-width: 92%; }
|
|
2790
|
+
.html-preview-wrap { margin-top: 10px; }
|
|
2791
|
+
.html-preview-open { display: inline-block; margin-bottom: 6px; font-size: 12px; color: #6ab0ff;
|
|
2792
|
+
text-decoration: underline; cursor: pointer; }
|
|
2793
|
+
.html-preview { display: block; width: 100%; height: 420px; border: 1px solid #3a3a3c;
|
|
2794
|
+
border-radius: 12px; background: #111; }
|
|
2625
2795
|
.bubble > p { margin: 0 0 10px; }
|
|
2626
2796
|
.bubble > p:last-child { margin-bottom: 0; }
|
|
2627
2797
|
.md-h { margin: 14px 0 8px; font-size: 15px; font-weight: 600; line-height: 1.3; }
|
|
@@ -2690,6 +2860,8 @@ const APP_HTML = `<!doctype html>
|
|
|
2690
2860
|
background: #8e8e93; animation: blink 1.2s infinite ease-in-out; }
|
|
2691
2861
|
.dots span:nth-child(2) { animation-delay: .2s; } .dots span:nth-child(3) { animation-delay: .4s; }
|
|
2692
2862
|
@keyframes blink { 0%, 80%, 100% { opacity: .25; } 40% { opacity: 1; } }
|
|
2863
|
+
.tstatus { color: #8e8e93; font-size: 13px; margin-left: 6px; }
|
|
2864
|
+
.ttrail { display: block; color: #8e8e93; font-size: 12.5px; margin-top: 8px; }
|
|
2693
2865
|
#bar { padding: 10px 16px 18px; position: relative; }
|
|
2694
2866
|
#row-input { display: flex; align-items: center; gap: 8px; }
|
|
2695
2867
|
#plusMenu { position: absolute; bottom: 62px; left: 16px; background: #1c1c1e; border-radius: 14px;
|
|
@@ -2904,6 +3076,7 @@ const APP_HTML = `<!doctype html>
|
|
|
2904
3076
|
: '';
|
|
2905
3077
|
let activeId = null;
|
|
2906
3078
|
let knownThreads = [];
|
|
3079
|
+
let workspacePort = 0;
|
|
2907
3080
|
|
|
2908
3081
|
function initials(name) { return name.slice(0, 2).toUpperCase(); }
|
|
2909
3082
|
|
|
@@ -2937,6 +3110,7 @@ const APP_HTML = `<!doctype html>
|
|
|
2937
3110
|
// seeing WHY something matched, not just that it did.
|
|
2938
3111
|
const hitById = searchHits ? new Map(searchHits.map((h) => [h.id, h])) : null;
|
|
2939
3112
|
knownThreads = list;
|
|
3113
|
+
if (list[0] && list[0].workspacePort) workspacePort = Number(list[0].workspacePort) || workspacePort;
|
|
2940
3114
|
if (!activeId && list.length) activeId = list[0].id;
|
|
2941
3115
|
threadsEl.innerHTML = '';
|
|
2942
3116
|
const shown = hitById
|
|
@@ -2983,7 +3157,7 @@ const APP_HTML = `<!doctype html>
|
|
|
2983
3157
|
'<div class="tmeta"><div class="tname">' + t.name + '</div><div class="tprev">' +
|
|
2984
3158
|
(hitById && hitById.get(t.id) && hitById.get(t.id).snippet
|
|
2985
3159
|
? hitById.get(t.id).snippet
|
|
2986
|
-
: t.awaitingUser ? 'waiting for you' : t.status === 'thinking' ? 'typing…' : (t.preview || '')) + '</div></div>' +
|
|
3160
|
+
: t.awaitingUser ? 'waiting for you' : t.status === 'thinking' ? escapeHtml(t.liveStatus || 'typing…') : (t.preview || '')) + '</div></div>' +
|
|
2987
3161
|
// awaitingUser WINS over thinking: a thread blocked on an approval is
|
|
2988
3162
|
// NOT working, and showing a working indicator there is a lie that
|
|
2989
3163
|
// quietly costs you a subagent nobody knows is stuck.
|
|
@@ -3039,7 +3213,9 @@ const APP_HTML = `<!doctype html>
|
|
|
3039
3213
|
|
|
3040
3214
|
async function loadActiveMessages() {
|
|
3041
3215
|
if (!activeId) return null;
|
|
3042
|
-
|
|
3216
|
+
const data = await (await fetch(API + '/threads/' + activeId)).json();
|
|
3217
|
+
if (data && data.workspacePort) workspacePort = Number(data.workspacePort) || workspacePort;
|
|
3218
|
+
return data;
|
|
3043
3219
|
}
|
|
3044
3220
|
|
|
3045
3221
|
function renderHeader(t) {
|
|
@@ -3126,7 +3302,7 @@ const APP_HTML = `<!doctype html>
|
|
|
3126
3302
|
w = r.ok ? await r.json() : null;
|
|
3127
3303
|
} catch (e) { w = null; }
|
|
3128
3304
|
walletBody.innerHTML = '';
|
|
3129
|
-
if (!w || (!w.solana && !w.evm)) {
|
|
3305
|
+
if (!w || (!w.solana && !w.evm && w.creditUsd == null)) {
|
|
3130
3306
|
const p = document.createElement('div');
|
|
3131
3307
|
p.className = 'wnote wempty';
|
|
3132
3308
|
p.textContent = 'Could not reach the local openzoo proxy on :8402. It may still be starting — try again in a few seconds.';
|
|
@@ -3144,7 +3320,17 @@ const APP_HTML = `<!doctype html>
|
|
|
3144
3320
|
}
|
|
3145
3321
|
if (w.solana) walletBody.appendChild(walletRow('Solana', w.solana));
|
|
3146
3322
|
if (w.evm) walletBody.appendChild(walletRow('Base / RH', w.evm));
|
|
3147
|
-
if (w.
|
|
3323
|
+
if (Array.isArray(w.holdings) && w.holdings.length) {
|
|
3324
|
+
const b = document.createElement('div');
|
|
3325
|
+
b.className = 'wbal';
|
|
3326
|
+
b.textContent = w.holdings.filter((h) => h.chain === 'solana' || Number(h.ui) > 0).map((h) => {
|
|
3327
|
+
const qty = (h.ui) + ' ' + h.symbol + (h.chain && h.chain !== 'solana' ? ' (' + h.chain + ')' : '');
|
|
3328
|
+
if (h.usd == null || !isFinite(Number(h.usd))) return qty;
|
|
3329
|
+
const n = Number(h.usd);
|
|
3330
|
+
return qty + ' ($' + (n >= 0.01 || n === 0 ? n.toFixed(2) : n.toFixed(4)) + ')';
|
|
3331
|
+
}).join('\\n');
|
|
3332
|
+
walletBody.appendChild(b);
|
|
3333
|
+
} else if (w.balances) {
|
|
3148
3334
|
const b = document.createElement('div');
|
|
3149
3335
|
b.className = 'wbal';
|
|
3150
3336
|
b.textContent = w.balances;
|
|
@@ -3217,6 +3403,114 @@ const APP_HTML = `<!doctype html>
|
|
|
3217
3403
|
document.getElementById('modeAuto').addEventListener('click', () => setMode('auto'));
|
|
3218
3404
|
|
|
3219
3405
|
function escapeHtml(s) { return s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); }
|
|
3406
|
+
function clientWorkspaceUrl(rel) {
|
|
3407
|
+
if (!workspacePort || !activeId) return '';
|
|
3408
|
+
rel = String(rel || '').replace(/^\\/+/, '');
|
|
3409
|
+
return 'http://localhost:' + workspacePort + '/' + activeId + '/' + rel;
|
|
3410
|
+
}
|
|
3411
|
+
function relFromDiskPath(p) {
|
|
3412
|
+
p = String(p || '');
|
|
3413
|
+
if (p.indexOf('file://') === 0) p = decodeURIComponent(p.slice(7));
|
|
3414
|
+
const t = knownThreads.find(function (x) { return x.id === activeId; });
|
|
3415
|
+
const dir = (t && t.dir) || '';
|
|
3416
|
+
if (dir && (p === dir || p.indexOf(dir + '/') === 0)) {
|
|
3417
|
+
return p.slice(dir.length).replace(/^\\/+/, '');
|
|
3418
|
+
}
|
|
3419
|
+
const marker = '/grokui-workspace/';
|
|
3420
|
+
const i = p.indexOf(marker);
|
|
3421
|
+
if (i >= 0) return p.slice(i + marker.length);
|
|
3422
|
+
const base = p.split('/').pop() || '';
|
|
3423
|
+
return /\\.(html?|HTML?)$/.test(base) ? base : '';
|
|
3424
|
+
}
|
|
3425
|
+
function servedHrefForPath(p) {
|
|
3426
|
+
p = String(p || '');
|
|
3427
|
+
const t = knownThreads.find(function (x) { return x.id === activeId; });
|
|
3428
|
+
const dir = (t && t.dir) || '';
|
|
3429
|
+
if (dir && p === dir) return clientWorkspaceUrl('');
|
|
3430
|
+
if (/\\.(html?|HTML?)$/.test(p) || p.indexOf('grokui-workspace') >= 0 || (dir && p.indexOf(dir) === 0)) {
|
|
3431
|
+
return clientWorkspaceUrl(relFromDiskPath(p));
|
|
3432
|
+
}
|
|
3433
|
+
return '';
|
|
3434
|
+
}
|
|
3435
|
+
// Turn "Wrote foo.html" and /Users/.../foo.html into the live localhost URL
|
|
3436
|
+
// the workspace server already exposes — never a file:// Electron blocks.
|
|
3437
|
+
function linkWorkspacePaths(o) {
|
|
3438
|
+
o = o.replace(/\\b(Wrote|Edited)\\s+([^\\n<]+?)\\s+\\(/g, function (m, verb, file) {
|
|
3439
|
+
const f = file.trim();
|
|
3440
|
+
if (!/\\.(html?|HTML?)$/.test(f)) return m;
|
|
3441
|
+
const url = clientWorkspaceUrl(f);
|
|
3442
|
+
if (!url) return m;
|
|
3443
|
+
return verb + ' <a href="' + url + '" target="_blank" rel="noopener">' + f + '</a> (';
|
|
3444
|
+
});
|
|
3445
|
+
o = o.replace(/\\b(MULTIEDIT)\\s+([^\\s:<]+\\.(?:html?|HTML?))/g, function (m, verb, file) {
|
|
3446
|
+
const url = clientWorkspaceUrl(file);
|
|
3447
|
+
if (!url) return m;
|
|
3448
|
+
return verb + ' <a href="' + url + '" target="_blank" rel="noopener">' + file + '</a>';
|
|
3449
|
+
});
|
|
3450
|
+
o = o.replace(/file:\\/\\/([^\\s<)]+)/g, function (m, raw) {
|
|
3451
|
+
return servedHrefForPath(decodeURIComponent(raw)) || m;
|
|
3452
|
+
});
|
|
3453
|
+
o = o.replace(/(^|[\\s(])((?:\\/Users\\/|\\/home\\/|\\/opt\\/|\\/workspace\\/|\\/tmp\\/|\\/var\\/|~\\/)[^\\s<)]+)/g, function (m, pre, raw) {
|
|
3454
|
+
const punct = /[.,;:]+$/.exec(raw);
|
|
3455
|
+
const p = punct ? raw.slice(0, -punct[0].length) : raw;
|
|
3456
|
+
const href = servedHrefForPath(p);
|
|
3457
|
+
if (!href) return m;
|
|
3458
|
+
return pre + '<a href="' + href + '" target="_blank" rel="noopener">' + p + '</a>' + (punct ? punct[0] : '');
|
|
3459
|
+
});
|
|
3460
|
+
return o;
|
|
3461
|
+
}
|
|
3462
|
+
function htmlPreviewUrl(text) {
|
|
3463
|
+
const s = String(text || '');
|
|
3464
|
+
let m = /https?:\\/\\/localhost:\\d+\\/\\S+\\.(?:html?|HTML?)/.exec(s);
|
|
3465
|
+
if (m) return m[0].replace(/[.,;)]+$/, '');
|
|
3466
|
+
m = /(?:Preview:|Serving at)\\s+(https?:\\/\\/localhost:\\d+\\/\\S+)/.exec(s);
|
|
3467
|
+
if (m) {
|
|
3468
|
+
const u = m[1].replace(/[.,;)]+$/, '');
|
|
3469
|
+
if (/\\.(html?|HTML?)/.test(u) || /\\/[0-9a-fA-F-]{36}\\/?$/.test(u)) return u;
|
|
3470
|
+
}
|
|
3471
|
+
m = /\\b(?:Wrote|Edited|MULTIEDIT)\\s+([^\\n]+?)\\s*(?:\\(|:)/.exec(s);
|
|
3472
|
+
if (m && /\\.(html?|HTML?)$/.test(m[1].trim())) return clientWorkspaceUrl(m[1].trim());
|
|
3473
|
+
m = /(?:\\/Users\\/|\\/home\\/|\\/workspace\\/)\\S+\\.(?:html?|HTML?)/.exec(s);
|
|
3474
|
+
if (m) return servedHrefForPath(m[0].replace(/[.,;)]+$/, '')) || '';
|
|
3475
|
+
return '';
|
|
3476
|
+
}
|
|
3477
|
+
function htmlPreviewKey(text, url) {
|
|
3478
|
+
const bytes = /\\((\\d+)\\s+bytes/.exec(text) || /->\\s+(\\d+)\\s+bytes/.exec(text);
|
|
3479
|
+
return url + '#' + (bytes ? bytes[1] : '0');
|
|
3480
|
+
}
|
|
3481
|
+
let parkedPreviews = {};
|
|
3482
|
+
function parkPreviews() {
|
|
3483
|
+
const parked = {};
|
|
3484
|
+
const nodes = log.querySelectorAll('.html-preview-wrap');
|
|
3485
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
3486
|
+
const el = nodes[i];
|
|
3487
|
+
const k = el.getAttribute('data-preview');
|
|
3488
|
+
if (k) parked[k] = el;
|
|
3489
|
+
el.remove();
|
|
3490
|
+
}
|
|
3491
|
+
return parked;
|
|
3492
|
+
}
|
|
3493
|
+
function previewFrame(url, key) {
|
|
3494
|
+
const existing = parkedPreviews[key];
|
|
3495
|
+
if (existing) { delete parkedPreviews[key]; return existing; }
|
|
3496
|
+
const wrap = document.createElement('div');
|
|
3497
|
+
wrap.className = 'html-preview-wrap';
|
|
3498
|
+
wrap.setAttribute('data-preview', key);
|
|
3499
|
+
const open = document.createElement('a');
|
|
3500
|
+
open.className = 'html-preview-open';
|
|
3501
|
+
open.href = url;
|
|
3502
|
+
open.target = '_blank';
|
|
3503
|
+
open.rel = 'noopener';
|
|
3504
|
+
open.textContent = 'open';
|
|
3505
|
+
const frame = document.createElement('iframe');
|
|
3506
|
+
frame.className = 'html-preview';
|
|
3507
|
+
frame.src = url;
|
|
3508
|
+
frame.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-pointer-lock allow-forms');
|
|
3509
|
+
frame.title = 'preview';
|
|
3510
|
+
wrap.appendChild(open);
|
|
3511
|
+
wrap.appendChild(frame);
|
|
3512
|
+
return wrap;
|
|
3513
|
+
}
|
|
3220
3514
|
// Inline span-level markdown. Runs AFTER escapeHtml, so every tag below is
|
|
3221
3515
|
// one we created — model output can never inject its own.
|
|
3222
3516
|
function mdInline(s) {
|
|
@@ -3225,6 +3519,7 @@ const APP_HTML = `<!doctype html>
|
|
|
3225
3519
|
o = o.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');
|
|
3226
3520
|
o = o.replace(/(^|[^*])\\*([^*\\n]+)\\*/g, '$1<em>$2</em>');
|
|
3227
3521
|
o = o.replace(/\\[([^\\]]+)\\]\\((https?:[^)\\s]+)\\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
|
3522
|
+
o = linkWorkspacePaths(o);
|
|
3228
3523
|
// bare URLs, but only at a boundary — inside href="..." the preceding
|
|
3229
3524
|
// char is a quote, so links we just built are left alone
|
|
3230
3525
|
o = o.replace(/(^|[\\s(])(https?:\\/\\/[^\\s<)]+)/g, '$1<a href="$2" target="_blank" rel="noopener">$2</a>');
|
|
@@ -3562,6 +3857,10 @@ const APP_HTML = `<!doctype html>
|
|
|
3562
3857
|
}
|
|
3563
3858
|
const textEl = document.createElement('div');
|
|
3564
3859
|
textEl.innerHTML = renderMentions(text);
|
|
3860
|
+
if (who === 'bot') {
|
|
3861
|
+
const preview = htmlPreviewUrl(text);
|
|
3862
|
+
if (preview) textEl.appendChild(previewFrame(preview, htmlPreviewKey(text, preview)));
|
|
3863
|
+
}
|
|
3565
3864
|
bubble.appendChild(textEl);
|
|
3566
3865
|
row.appendChild(bubble);
|
|
3567
3866
|
// Copy the message SOURCE, not rendered HTML — markdown, code fences and
|
|
@@ -3582,6 +3881,7 @@ const APP_HTML = `<!doctype html>
|
|
|
3582
3881
|
log.appendChild(row);
|
|
3583
3882
|
}
|
|
3584
3883
|
|
|
3884
|
+
let lastRenderKey = '';
|
|
3585
3885
|
async function render() {
|
|
3586
3886
|
const t = knownThreads.find((x) => x.id === activeId);
|
|
3587
3887
|
if (!t) return;
|
|
@@ -3589,9 +3889,18 @@ const APP_HTML = `<!doctype html>
|
|
|
3589
3889
|
inp.placeholder = 'Message ' + t.name;
|
|
3590
3890
|
const full = await loadActiveMessages();
|
|
3591
3891
|
if (!full || full.id !== activeId) return;
|
|
3892
|
+
const renderKey = String(workspacePort) + '|' + full.id + '|' + full.status + '|' + (full.history || []).map(function (h) {
|
|
3893
|
+
return [h.who, h.text, h.runStatus, h.runOutput, (h.images || []).join(',')].join('|#');
|
|
3894
|
+
}).join('||');
|
|
3895
|
+
if (renderKey === lastRenderKey) {
|
|
3896
|
+
if (streamBuf) paintStream();
|
|
3897
|
+
return;
|
|
3898
|
+
}
|
|
3899
|
+
lastRenderKey = renderKey;
|
|
3592
3900
|
// only re-pin to bottom if the reader was already there — otherwise a
|
|
3593
3901
|
// background poll (tick() runs every 1.2s) yanks them back mid-scroll
|
|
3594
3902
|
const wasNearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 80;
|
|
3903
|
+
parkedPreviews = parkPreviews();
|
|
3595
3904
|
log.innerHTML = '';
|
|
3596
3905
|
lastSpeaker = null;
|
|
3597
3906
|
for (const h of full.history) {
|
|
@@ -3599,11 +3908,12 @@ const APP_HTML = `<!doctype html>
|
|
|
3599
3908
|
h.runId ? { id: h.runId, status: h.runStatus, output: h.runOutput } : undefined, h.images);
|
|
3600
3909
|
}
|
|
3601
3910
|
if (full.status === 'thinking') {
|
|
3911
|
+
if (full.liveStatus) streamStatus = full.liveStatus;
|
|
3602
3912
|
addRow('bot', streamBuf || '…', t.color, t.name);
|
|
3603
3913
|
// Tag the live bubble so deltas can repaint just this node instead of
|
|
3604
3914
|
// re-rendering (and re-fetching) the whole thread on every token.
|
|
3605
3915
|
const b = log.querySelector('.row:last-child .bubble');
|
|
3606
|
-
if (b) b.id = 'streamBubble';
|
|
3916
|
+
if (b) { b.id = 'streamBubble'; paintStream(); }
|
|
3607
3917
|
}
|
|
3608
3918
|
if (wasNearBottom) log.scrollTop = log.scrollHeight;
|
|
3609
3919
|
}
|
|
@@ -3612,13 +3922,28 @@ const APP_HTML = `<!doctype html>
|
|
|
3612
3922
|
// The server has always been able to stream; /drive just never asked for it,
|
|
3613
3923
|
// so a turn showed "…" for its whole duration and then arrived in one lump.
|
|
3614
3924
|
let streamBuf = '';
|
|
3925
|
+
let streamStatus = '';
|
|
3615
3926
|
let es = null, esId = null;
|
|
3927
|
+
function liveBubbleHtml() {
|
|
3928
|
+
if (streamBuf) {
|
|
3929
|
+
const trail = streamStatus && /^(RUN|READ|WRITE|EDIT|SPAWN|SEND|GLOB|GREP|MCP|FETCH|TODO|SERVE|PING|PEEK|MULTIEDIT|NOTEBOOK):/i.test(streamStatus)
|
|
3930
|
+
? '<span class="ttrail">' + escapeHtml(streamStatus) + '</span>' : '';
|
|
3931
|
+
return escapeHtml(streamBuf) + trail;
|
|
3932
|
+
}
|
|
3933
|
+
const dots = '<span class="dots"><span></span><span></span><span></span></span>';
|
|
3934
|
+
const st = streamStatus ? '<span class="tstatus">' + escapeHtml(streamStatus) + '</span>' : '';
|
|
3935
|
+
return dots + (st ? ' ' + st : '');
|
|
3936
|
+
}
|
|
3616
3937
|
function paintStream() {
|
|
3617
3938
|
const b = document.getElementById('streamBubble');
|
|
3618
3939
|
if (!b) { render(); return; }
|
|
3619
|
-
//
|
|
3620
|
-
//
|
|
3621
|
-
|
|
3940
|
+
// Deltas stay as text; a silent wait paints dots + one mutating status
|
|
3941
|
+
// line so a 20–40s pay/model wait is obviously alive.
|
|
3942
|
+
if (streamBuf && !(streamStatus && /^(RUN|READ|WRITE|EDIT|SPAWN|SEND|GLOB|GREP|MCP|FETCH|TODO|SERVE|PING|PEEK|MULTIEDIT|NOTEBOOK):/i.test(streamStatus))) {
|
|
3943
|
+
b.textContent = streamBuf;
|
|
3944
|
+
} else {
|
|
3945
|
+
b.innerHTML = liveBubbleHtml();
|
|
3946
|
+
}
|
|
3622
3947
|
if (log.scrollHeight - log.scrollTop - log.clientHeight < 140) log.scrollTop = log.scrollHeight;
|
|
3623
3948
|
}
|
|
3624
3949
|
function connectStream(id) {
|
|
@@ -3626,13 +3951,15 @@ const APP_HTML = `<!doctype html>
|
|
|
3626
3951
|
if (es) es.close();
|
|
3627
3952
|
esId = id;
|
|
3628
3953
|
streamBuf = '';
|
|
3954
|
+
streamStatus = '';
|
|
3629
3955
|
es = new EventSource('/stream/' + id); // EventSource reconnects on its own
|
|
3630
3956
|
es.onmessage = (e) => {
|
|
3631
3957
|
let ev;
|
|
3632
3958
|
try { ev = JSON.parse(e.data); } catch { return; }
|
|
3633
|
-
if (ev.type === 'start') { streamBuf = ''; paintStream(); }
|
|
3959
|
+
if (ev.type === 'start') { streamBuf = ''; streamStatus = ev.detail || 'waiting on model…'; paintStream(); }
|
|
3960
|
+
else if (ev.type === 'status') { streamStatus = ev.detail || streamStatus; paintStream(); }
|
|
3634
3961
|
else if (ev.type === 'delta') { streamBuf += ev.delta || ''; paintStream(); }
|
|
3635
|
-
else if (ev.type === 'final' || ev.type === 'run-pending') { streamBuf = ''; render(); }
|
|
3962
|
+
else if (ev.type === 'final' || ev.type === 'run-pending') { streamBuf = ''; streamStatus = ''; render(); }
|
|
3636
3963
|
};
|
|
3637
3964
|
es.onerror = () => { /* EventSource retries; the 1.2s poll is the backstop */ };
|
|
3638
3965
|
}
|
|
@@ -4020,7 +4347,6 @@ const server = http.createServer((req, res) => {
|
|
|
4020
4347
|
try { w = await (await fetch(`${PROXY}/wallet`)).json(); }
|
|
4021
4348
|
catch { /* proxy not up yet — say so rather than render an empty modal */ }
|
|
4022
4349
|
try {
|
|
4023
|
-
const { creditBalance } = await import('./info.js');
|
|
4024
4350
|
w.creditUsd = await creditBalance();
|
|
4025
4351
|
} catch { /* leave credit off if the gateway is down */ }
|
|
4026
4352
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
@@ -4078,11 +4404,10 @@ const server = http.createServer((req, res) => {
|
|
|
4078
4404
|
|
|
4079
4405
|
if (req.method === 'GET' && req.url === '/hud-summary') {
|
|
4080
4406
|
(async () => {
|
|
4081
|
-
let you = { spentUsd: 0, cogsUsd: 0, directUsd: 0, paidCalls: 0, creditUsd: null };
|
|
4407
|
+
let you = { spentUsd: 0, cogsUsd: 0, directUsd: 0, paidCalls: 0, creditUsd: null, chainUsd: null };
|
|
4082
4408
|
try { you = { ...you, ...(await (await fetch('http://127.0.0.1:8402/v1/session')).json()) }; }
|
|
4083
4409
|
catch { /* local proxy not running — HUD shows zeros rather than guessing */ }
|
|
4084
4410
|
try {
|
|
4085
|
-
const { creditBalance } = await import('./info.js');
|
|
4086
4411
|
you.creditUsd = await creditBalance();
|
|
4087
4412
|
} catch { /* credit is advisory */ }
|
|
4088
4413
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
@@ -4165,7 +4490,11 @@ const server = http.createServer((req, res) => {
|
|
|
4165
4490
|
if (req.method === 'GET' && req.url.startsWith('/threads/')) {
|
|
4166
4491
|
const t = threads.get(req.url.split('/')[2]);
|
|
4167
4492
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
4168
|
-
res.end(t ? JSON.stringify({
|
|
4493
|
+
res.end(t ? JSON.stringify({
|
|
4494
|
+
id: t.id, history: t.history, status: t.status,
|
|
4495
|
+
liveStatus: t.liveStatus || '',
|
|
4496
|
+
workspacePort: workspacePort || 0, dir: t.dir || WORKSPACE_DIR,
|
|
4497
|
+
}) : '{}');
|
|
4169
4498
|
return;
|
|
4170
4499
|
}
|
|
4171
4500
|
if (req.method === 'DELETE' && req.url.startsWith('/threads/')) {
|
|
@@ -4298,3 +4627,5 @@ const server = http.createServer((req, res) => {
|
|
|
4298
4627
|
});
|
|
4299
4628
|
|
|
4300
4629
|
server.listen(PORT, BIND, () => console.log(`[grokui] http://${BIND === '0.0.0.0' ? 'localhost' : BIND}:${PORT}`));
|
|
4630
|
+
|
|
4631
|
+
export { tryDirective, ensureWorkspacePort, isPreviewableRel, previewAck, workspaceFileUrl };
|