openzoo 0.48.87 → 0.48.92
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 +1045 -105
- package/lib/info.js +3 -1
- package/lib/livestatus.js +102 -0
- package/lib/mcp.js +2 -0
- package/lib/pay.js +10 -2
- package/lib/podagent.mjs +78 -32
- package/lib/proxy.js +62 -14
- package/lib/session.js +58 -0
- package/lib/subscription.js +207 -0
- package/package.json +1 -1
package/lib/grokui.mjs
CHANGED
|
@@ -12,6 +12,14 @@ 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';
|
|
17
|
+
import {
|
|
18
|
+
SUBSCRIPTIONS_PAGE,
|
|
19
|
+
saveSubscription, clearSubscription,
|
|
20
|
+
subscriptionPublicView, parseSubscriptionPaste,
|
|
21
|
+
billingTiers, billingCheckout, fetchBillingKey, ingestBillingKeyResponse,
|
|
22
|
+
} from './subscription.js';
|
|
15
23
|
|
|
16
24
|
const PORT = Number(process.env.OZ_GROKUI_PORT || 4173);
|
|
17
25
|
// BIND HOST. Default 127.0.0.1 so the desktop app never exposes a shell-capable
|
|
@@ -26,9 +34,12 @@ const STORE_FILE = path.join(STORE_DIR, 'grokui-threads.json');
|
|
|
26
34
|
// Real but SANDBOXED filesystem access for the bots — each THREAD has its own
|
|
27
35
|
// root dir (default: a dedicated workspace, never the user's whole disk), and
|
|
28
36
|
// the user can point a thread at a real project folder with "/dir <path>" in
|
|
29
|
-
// chat. safeResolveIn
|
|
30
|
-
// (../,
|
|
31
|
-
//
|
|
37
|
+
// chat. inDir / safeResolveIn reject any path that would escape that thread's
|
|
38
|
+
// root (../, symlink tricks). An absolute path that is ALREADY inside the
|
|
39
|
+
// root is used as-is — path.join(base, '/Users/...') doubles the prefix
|
|
40
|
+
// (MEASURED live: LIST of t.dir produced
|
|
41
|
+
// ENOENT scandir '/Users/…/Users/…/'). path.resolve treats an absolute
|
|
42
|
+
// second arg as a new root, which is the right join.
|
|
32
43
|
// Where a thread's WRITE/READ/RUN/LS/GLOB/GREP are scoped by default.
|
|
33
44
|
//
|
|
34
45
|
// Overridable because a BOX puts uploaded files somewhere else: box-server
|
|
@@ -43,17 +54,74 @@ const WORKSPACE_DIR = process.env.OZ_WORKSPACE_DIR
|
|
|
43
54
|
mkdirSync(WORKSPACE_DIR, { recursive: true });
|
|
44
55
|
function expandHome(p) { return p.startsWith('~') ? path.join(homedir(), p.slice(1)) : p; }
|
|
45
56
|
function dirFor(threadId) { return threads.get(threadId)?.dir || WORKSPACE_DIR; }
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Resolve `rel` inside `base`. If `rel` is already absolute, use it as-is
|
|
59
|
+
* when it stays inside `base` — never path.join(base, '/Users/...').
|
|
60
|
+
*/
|
|
61
|
+
function inDir(base, rel) {
|
|
62
|
+
const root = path.resolve(expandHome(String(base || '.')));
|
|
63
|
+
const raw = expandHome(String(rel ?? '').trim() || '.');
|
|
64
|
+
const full = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(root, raw);
|
|
65
|
+
if (full !== root && !full.startsWith(root + path.sep)) {
|
|
49
66
|
throw new Error("path escapes this thread's directory");
|
|
50
67
|
}
|
|
51
68
|
return full;
|
|
52
69
|
}
|
|
70
|
+
function safeResolveIn(base, rel) { return inDir(base, rel); }
|
|
71
|
+
function listDir(base, rel = '.') {
|
|
72
|
+
return readdirSync(inDir(base, rel), { withFileTypes: true });
|
|
73
|
+
}
|
|
74
|
+
/** If `spec` is an absolute path inside `base`, return the relative remainder
|
|
75
|
+
* ('' when it IS the base). Non-absolute specs are left alone (null). */
|
|
76
|
+
function stripBasePrefix(base, spec) {
|
|
77
|
+
const raw = expandHome(String(spec || '').trim());
|
|
78
|
+
if (!raw || !path.isAbsolute(raw)) return null;
|
|
79
|
+
const root = path.resolve(expandHome(String(base || '.')));
|
|
80
|
+
const full = path.resolve(raw);
|
|
81
|
+
if (full === root) return '';
|
|
82
|
+
if (full.startsWith(root + path.sep)) return full.slice(root.length + 1);
|
|
83
|
+
throw new Error("path escapes this thread's directory");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Reasoning models leak `<think>…</think>` / `<thinking>…` into the visible
|
|
88
|
+
* reply. MEASURED live on thread tetris: the user-visible bubble contained
|
|
89
|
+
* the raw tags, and the next turn sent them back to the model. Strip complete
|
|
90
|
+
* blocks, an unclosed opener (live stream), and stray closers.
|
|
91
|
+
*/
|
|
92
|
+
function stripThinkTags(text) {
|
|
93
|
+
let s = String(text ?? '');
|
|
94
|
+
s = s.replace(/<think(?:ing)?\b[^>]*>[\s\S]*?<\/think(?:ing)?>/gi, '');
|
|
95
|
+
s = s.replace(/<think(?:ing)?\b[^>]*>[\s\S]*$/i, '');
|
|
96
|
+
s = s.replace(/<\/think(?:ing)?>/gi, '');
|
|
97
|
+
return s.replace(/^\n+|\n+$/g, '').trim();
|
|
98
|
+
}
|
|
53
99
|
const MIME = { html: 'text/html', htm: 'text/html', css: 'text/css', js: 'application/javascript',
|
|
54
100
|
mjs: 'application/javascript', json: 'application/json', png: 'image/png', jpg: 'image/jpeg',
|
|
55
101
|
jpeg: 'image/jpeg', gif: 'image/gif', svg: 'image/svg+xml', txt: 'text/plain', md: 'text/plain' };
|
|
56
102
|
let workspacePort = null;
|
|
103
|
+
let workspacePortResolve = () => {};
|
|
104
|
+
let workspaceBinding = false;
|
|
105
|
+
const workspacePortReady = new Promise((resolve) => { workspacePortResolve = resolve; });
|
|
106
|
+
function bindWorkspaceServer() {
|
|
107
|
+
if (workspaceServer.listening) {
|
|
108
|
+
workspacePort = workspaceServer.address().port;
|
|
109
|
+
workspacePortResolve(workspacePort);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (workspaceBinding) return;
|
|
113
|
+
workspaceBinding = true;
|
|
114
|
+
try {
|
|
115
|
+
workspaceServer.listen(0, '127.0.0.1', () => {
|
|
116
|
+
workspacePort = workspaceServer.address().port;
|
|
117
|
+
workspacePortResolve(workspacePort);
|
|
118
|
+
});
|
|
119
|
+
} catch (err) {
|
|
120
|
+
workspaceBinding = false;
|
|
121
|
+
console.error('[grokui] workspace server:', err.message);
|
|
122
|
+
setTimeout(bindWorkspaceServer, 250);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
57
125
|
// route: /<threadId>/<relpath...> — each thread is served from ITS OWN dir
|
|
58
126
|
const workspaceServer = http.createServer((req, res) => {
|
|
59
127
|
try {
|
|
@@ -65,14 +133,65 @@ const workspaceServer = http.createServer((req, res) => {
|
|
|
65
133
|
const full = safeResolveIn(dirFor(threadId), rel);
|
|
66
134
|
const data = readFileSync(full);
|
|
67
135
|
const ext = full.split('.').pop();
|
|
68
|
-
res.writeHead(200, {
|
|
136
|
+
res.writeHead(200, {
|
|
137
|
+
'content-type': MIME[ext] || 'application/octet-stream',
|
|
138
|
+
// EDIT of the same html must not be served from a cached first write.
|
|
139
|
+
'cache-control': 'no-store',
|
|
140
|
+
});
|
|
69
141
|
res.end(data);
|
|
70
142
|
} catch {
|
|
71
143
|
res.writeHead(404, { 'content-type': 'text/plain' });
|
|
72
144
|
res.end('not found');
|
|
73
145
|
}
|
|
74
146
|
});
|
|
75
|
-
workspaceServer.
|
|
147
|
+
workspaceServer.on('error', (err) => {
|
|
148
|
+
workspaceBinding = false;
|
|
149
|
+
console.error('[grokui] workspace server:', err.message);
|
|
150
|
+
setTimeout(bindWorkspaceServer, 250);
|
|
151
|
+
});
|
|
152
|
+
bindWorkspaceServer();
|
|
153
|
+
|
|
154
|
+
function isPreviewableRel(rel) {
|
|
155
|
+
const base = path.basename(String(rel || '').split('?')[0]).toLowerCase();
|
|
156
|
+
return base.endsWith('.html') || base.endsWith('.htm');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function ensureWorkspacePort(ms = 4000) {
|
|
160
|
+
if (workspacePort) return workspacePort;
|
|
161
|
+
if (!workspaceServer.listening) bindWorkspaceServer();
|
|
162
|
+
let timer;
|
|
163
|
+
const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(null), ms); });
|
|
164
|
+
const port = await Promise.race([workspacePortReady, timeout]);
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
return port || workspacePort;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function workspaceFileUrl(originId, rel) {
|
|
170
|
+
const clean = String(rel || '').replace(/^\/+/, '');
|
|
171
|
+
return `http://localhost:${workspacePort}/${originId}/${clean}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// WRITE/EDIT of a playable page must ack a real http:// URL (same shape as
|
|
175
|
+
// SERVE), not a dead disk path. Wait for the static server rather than
|
|
176
|
+
// telling the user to try again after they just wrote a game.
|
|
177
|
+
async function previewAck(originId, rel) {
|
|
178
|
+
if (!isPreviewableRel(rel)) return '';
|
|
179
|
+
const port = await ensureWorkspacePort();
|
|
180
|
+
if (!port) {
|
|
181
|
+
return `\nPreview: the workspace server is binding; the page is ${rel} and will be at `
|
|
182
|
+
+ `http://localhost/<port>/${originId}/${String(rel).replace(/^\/+/, '')}.`;
|
|
183
|
+
}
|
|
184
|
+
return `\nPreview: ${workspaceFileUrl(originId, rel)}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const HTML_PREVIEW_RULE = `
|
|
188
|
+
PREVIEW IS AUTOMATIC. After you WRITE or EDIT a .html / .htm file (including index.html),
|
|
189
|
+
the harness already served it — the WRITE ack includes a real http://localhost URL and the
|
|
190
|
+
chat bubble shows a live iframe. The harness will preview. Do not tell the user you
|
|
191
|
+
"can't preview", cannot open a browser, or dump a raw disk path (/Users/..., ~/.openzoo/...)
|
|
192
|
+
as the punchline. The page is already on screen. If you mention a location, use that
|
|
193
|
+
http://localhost link, never a filesystem path.
|
|
194
|
+
`;
|
|
76
195
|
|
|
77
196
|
const PALETTE = ['#e91e8c', '#34c759', '#ff9500', '#5e5ce6', '#ff3b30', '#0a84ff', '#00c7be'];
|
|
78
197
|
function colorFor(name) {
|
|
@@ -117,11 +236,15 @@ workspace folder, not their real project. Same one-line-no-prose reply format:
|
|
|
117
236
|
SERVE: <relative path, or blank for the dir root> get a real http:// URL for a file —
|
|
118
237
|
use this instead of claiming you
|
|
119
238
|
"can't expose a port": you can serve
|
|
120
|
-
static files, just not run a process
|
|
239
|
+
static files, just not run a process.
|
|
240
|
+
HTML writes are auto-served — you do
|
|
241
|
+
not need a separate SERVE for a
|
|
242
|
+
playable page.
|
|
121
243
|
FETCH: <url> actually fetch and read a page's real
|
|
122
244
|
text — web search only gives you short
|
|
123
245
|
snippets; use FETCH when asked to
|
|
124
246
|
"read" or quote something specific
|
|
247
|
+
${HTML_PREVIEW_RULE}
|
|
125
248
|
|
|
126
249
|
HOW YOUR SITE ACTUALLY GETS A URL — read this before writing web files.
|
|
127
250
|
|
|
@@ -296,7 +419,26 @@ function loadThreads() {
|
|
|
296
419
|
if (!existsSync(STORE_FILE)) return false;
|
|
297
420
|
const arr = JSON.parse(readFileSync(STORE_FILE, 'utf8'));
|
|
298
421
|
if (!Array.isArray(arr) || !arr.length) return false;
|
|
299
|
-
for (const t of arr)
|
|
422
|
+
for (const t of arr) {
|
|
423
|
+
// A crash mid-turn persisted status=thinking. Do not reload into mute "…".
|
|
424
|
+
if (t.status === 'thinking') {
|
|
425
|
+
t.status = 'idle';
|
|
426
|
+
t.liveStatus = '';
|
|
427
|
+
}
|
|
428
|
+
if (Array.isArray(t.history)) {
|
|
429
|
+
for (const h of t.history) {
|
|
430
|
+
if (h && h.who === 'bot' && typeof h.text === 'string') h.text = stripThinkTags(h.text);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (Array.isArray(t.messages)) {
|
|
434
|
+
for (const m of t.messages) {
|
|
435
|
+
if (m && m.role === 'assistant' && typeof m.content === 'string') {
|
|
436
|
+
m.content = stripThinkTags(m.content);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
threads.set(t.id, t);
|
|
441
|
+
}
|
|
300
442
|
return true;
|
|
301
443
|
} catch { return false; }
|
|
302
444
|
}
|
|
@@ -367,9 +509,12 @@ the user sets or changes it with "/dir <path>" in chat. Same format:
|
|
|
367
509
|
READ: <relative path> read a file back
|
|
368
510
|
SERVE: <relative path, or blank for the dir root> get a real http:// URL for it — use
|
|
369
511
|
this instead of saying you can't
|
|
370
|
-
expose a port
|
|
512
|
+
expose a port. HTML writes are
|
|
513
|
+
auto-served; you do not need SERVE
|
|
514
|
+
just to preview a playable page.
|
|
371
515
|
FETCH: <url> actually fetch and read a page's real
|
|
372
516
|
text — web search only gives snippets
|
|
517
|
+
${HTML_PREVIEW_RULE}
|
|
373
518
|
RUN: <shell command> run a REAL shell command in this
|
|
374
519
|
group's shared directory — pauses the
|
|
375
520
|
WHOLE round for the user's approval
|
|
@@ -632,6 +777,21 @@ function sanitizeRunCommand(command) {
|
|
|
632
777
|
//
|
|
633
778
|
// Also tolerates the directive being wrapped in a markdown code fence, which
|
|
634
779
|
// is the other shape models reach for unprompted.
|
|
780
|
+
const MCP_AS_BASH_REFUSE = 'That RUN: body is MCP tool names, not a shell command. '
|
|
781
|
+
+ 'get_skill, proofnetwork-*, publish-update, and MCP: lines must not be executed by bash '
|
|
782
|
+
+ '— that is how a live thread printed `/bin/bash: get_skill: command not found`. '
|
|
783
|
+
+ 'Emit a real MCP call instead:\n'
|
|
784
|
+
+ ' MCP: <url> | <tool> | {"arg": "value"}\n'
|
|
785
|
+
+ 'or list tools with:\n'
|
|
786
|
+
+ ' MCP: <url>';
|
|
787
|
+
|
|
788
|
+
/** Skill names / MCP: lines the model listed, then a RUN: tried to shell. */
|
|
789
|
+
function looksLikeMcpAsBash(command) {
|
|
790
|
+
const text = String(command || '');
|
|
791
|
+
if (/^[ \t>*-]*MCP:/m.test(text)) return true;
|
|
792
|
+
return /^(?:[ \t>*-]*)(?:get_skill|publish-update|proofnetwork[-_][A-Za-z0-9._-]*)\b/im.test(text);
|
|
793
|
+
}
|
|
794
|
+
|
|
635
795
|
function parseRun(reply) {
|
|
636
796
|
// NATIVE TOOL-CALL ENVELOPE FIRST. deepseek-v4-pro has real function calling,
|
|
637
797
|
// and when told to emit "RUN: <cmd>" it frequently wraps the call in its own
|
|
@@ -658,10 +818,8 @@ function parseRun(reply) {
|
|
|
658
818
|
// the whole envelope was dropped in silence: the bot then explained what it
|
|
659
819
|
// was "about to run" forever, never running anything. Match the shape of the
|
|
660
820
|
// envelope, not one vendor's spelling of it.
|
|
661
|
-
const
|
|
662
|
-
|
|
663
|
-
const dsml = new RegExp(`<${SEP}DSML[^>]*\\bparameter\\b[^>]*\\bname="${NAME}"[^>]*>([\\s\\S]*?)<\\/${SEP}DSML`, 'i').exec(reply);
|
|
664
|
-
if (dsml) return sanitizeRunCommand(dsml[1]);
|
|
821
|
+
const dsmlCmd = dsmlRunCommand(reply);
|
|
822
|
+
if (dsmlCmd !== undefined) return dsmlCmd && !looksLikeMcpAsBash(dsmlCmd) ? dsmlCmd : null;
|
|
665
823
|
|
|
666
824
|
// SEVERAL "RUN:" LINES IN ONE REPLY RUN BACK TO BACK.
|
|
667
825
|
//
|
|
@@ -693,12 +851,26 @@ function parseRun(reply) {
|
|
|
693
851
|
const fenced = /^```[\w-]*\n([\s\S]*?)```/.exec(cmd.trim());
|
|
694
852
|
if (fenced) cmd = fenced[1];
|
|
695
853
|
else cmd = cmd.replace(/\n```[\s\S]*$/, ''); // trailing fence + any posttext
|
|
854
|
+
cmd = sliceToNextDirective(cmd);
|
|
696
855
|
cmd = sanitizeRunCommand(cmd);
|
|
856
|
+
// A RUN: that swallowed MCP: / get_skill / proofnetwork-* is the
|
|
857
|
+
// over-match that produced `/bin/bash: line 3: RUN:: command not found`
|
|
858
|
+
// and then `/bin/bash: get_skill: command not found`. Refuse the batch
|
|
859
|
+
// rather than join skill names into one script.
|
|
860
|
+
if (cmd && looksLikeMcpAsBash(cmd)) return null;
|
|
697
861
|
if (cmd) cmds.push(cmd);
|
|
698
862
|
}
|
|
699
863
|
return cmds.length ? cmds.join('\n') : null;
|
|
700
864
|
}
|
|
701
865
|
|
|
866
|
+
function dsmlRunCommand(reply) {
|
|
867
|
+
const SEP = '[||\\s]*';
|
|
868
|
+
const NAME = '(?:command|cmd|shell_command|script)';
|
|
869
|
+
const dsml = new RegExp(`<${SEP}DSML[^>]*\\bparameter\\b[^>]*\\bname="${NAME}"[^>]*>([\\s\\S]*?)<\\/${SEP}DSML`, 'i').exec(reply);
|
|
870
|
+
if (!dsml) return undefined;
|
|
871
|
+
return sanitizeRunCommand(dsml[1]);
|
|
872
|
+
}
|
|
873
|
+
|
|
702
874
|
// RUN through BASH, not /bin/sh. node's exec() defaults to /bin/sh, which on
|
|
703
875
|
// Debian is dash — so every bash-ism a model writes (`for … do`, `[[ ]]`,
|
|
704
876
|
// arrays, process substitution) dies as
|
|
@@ -1106,6 +1278,24 @@ setInterval(() => {
|
|
|
1106
1278
|
}
|
|
1107
1279
|
}, 15000).unref();
|
|
1108
1280
|
|
|
1281
|
+
// A turn that is thinking with no deltas/status for too long is dead — the
|
|
1282
|
+
// stream reader used to hang forever and block the next user prompt behind
|
|
1283
|
+
// mute dots. Bump turnSeq so the in-flight runTurn bails, then idle.
|
|
1284
|
+
setInterval(() => {
|
|
1285
|
+
const now = Date.now();
|
|
1286
|
+
let dirty = false;
|
|
1287
|
+
for (const t of threads.values()) {
|
|
1288
|
+
if (t.status !== 'thinking') continue;
|
|
1289
|
+
const last = t.lastDeltaAt || t.thinkingAt || 0;
|
|
1290
|
+
if (!last || now - last < STALE_THINKING_MS) continue;
|
|
1291
|
+
t.turnSeq = (t.turnSeq || 0) + 1;
|
|
1292
|
+
t.status = 'idle';
|
|
1293
|
+
t.liveStatus = '';
|
|
1294
|
+
dirty = true;
|
|
1295
|
+
}
|
|
1296
|
+
if (dirty) saveThreads();
|
|
1297
|
+
}, 5000).unref();
|
|
1298
|
+
|
|
1109
1299
|
// Directives that only READ. These are safe to run at the same time, so a
|
|
1110
1300
|
// reply carrying several of them costs one round trip instead of N — a model
|
|
1111
1301
|
// that wants four files currently spends four full turns (and four payments)
|
|
@@ -1121,7 +1311,10 @@ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '__
|
|
|
1121
1311
|
function walkDir(base, rel = '', out = [], depth = 0) {
|
|
1122
1312
|
if (depth > 12 || out.length > 5000) return out;
|
|
1123
1313
|
let entries = [];
|
|
1124
|
-
try {
|
|
1314
|
+
try {
|
|
1315
|
+
const dir = rel && path.isAbsolute(rel) ? inDir(base, rel) : path.join(base, rel);
|
|
1316
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
1317
|
+
} catch { return out; }
|
|
1125
1318
|
for (const e of entries) {
|
|
1126
1319
|
const r = rel ? path.join(rel, e.name) : e.name;
|
|
1127
1320
|
if (e.isDirectory()) {
|
|
@@ -1487,7 +1680,19 @@ function nameAddressedToSend(reply, originId) {
|
|
|
1487
1680
|
}).join('\n');
|
|
1488
1681
|
}
|
|
1489
1682
|
|
|
1490
|
-
|
|
1683
|
+
function emitLiveStatus(originId, onEvent, detail) {
|
|
1684
|
+
if (!detail) return;
|
|
1685
|
+
const t = threads.get(originId);
|
|
1686
|
+
if (t) {
|
|
1687
|
+
t.liveStatus = detail;
|
|
1688
|
+
t.lastDeltaAt = Date.now();
|
|
1689
|
+
}
|
|
1690
|
+
onEvent?.({ type: 'status', name: t?.name, color: t?.color, detail });
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
async function tryDirective(reply, originId, onEvent) {
|
|
1694
|
+
const trail = peekDirectiveStatus(reply);
|
|
1695
|
+
if (trail) emitLiveStatus(originId, onEvent, trail);
|
|
1491
1696
|
// Foreign envelope in, our directives out — before any matching runs, so
|
|
1492
1697
|
// every branch below sees the shape it was written for.
|
|
1493
1698
|
const translated = translateForeignToolCall(reply);
|
|
@@ -1507,12 +1712,20 @@ async function tryDirective(reply, originId) {
|
|
|
1507
1712
|
// separator-insensitive. That is what makes this safe: "Note:" or "Step 2:"
|
|
1508
1713
|
// never matches a live agent, so ordinary prose is untouched.
|
|
1509
1714
|
reply = nameAddressedToSend(reply, originId);
|
|
1715
|
+
// MCP SKILL NAMES ARE NOT SHELL. parseRun used to hand get_skill /
|
|
1716
|
+
// proofnetwork-* / MCP: to bash. Refuse here so the model sees the
|
|
1717
|
+
// real MCP: directive, including DSML-wrapped RUN bodies with no RUN: line.
|
|
1718
|
+
const runBodies = directiveLines(reply, 'RUN');
|
|
1719
|
+
const dsmlCmd = dsmlRunCommand(reply);
|
|
1720
|
+
if (runBodies.some(looksLikeMcpAsBash) || (dsmlCmd && looksLikeMcpAsBash(dsmlCmd))) {
|
|
1721
|
+
return MCP_AS_BASH_REFUSE;
|
|
1722
|
+
}
|
|
1510
1723
|
// FAN OUT FIRST. Each line is re-entered on its own, so every branch below
|
|
1511
1724
|
// stays single-directive and none of them had to learn about batching.
|
|
1512
1725
|
const batch = [...reply.matchAll(PARALLEL_DIRECTIVE)];
|
|
1513
1726
|
if (batch.length > 1) {
|
|
1514
1727
|
const results = await Promise.all(
|
|
1515
|
-
batch.map((m) => tryDirective(m[0].replace(/^[ \t>*-]*/, ''), originId)
|
|
1728
|
+
batch.map((m) => tryDirective(m[0].replace(/^[ \t>*-]*/, ''), originId, onEvent)
|
|
1516
1729
|
.catch((e) => `${m[1]}: ${e.message}`)),
|
|
1517
1730
|
);
|
|
1518
1731
|
return results.filter(Boolean).join('\n\n');
|
|
@@ -1613,7 +1826,7 @@ async function tryDirective(reply, originId) {
|
|
|
1613
1826
|
const sendAll = directiveLines(reply, 'SEND');
|
|
1614
1827
|
if (sendAll.length > 1) {
|
|
1615
1828
|
const out = [];
|
|
1616
|
-
for (const line of sendAll) out.push(await tryDirective('SEND: ' + line, originId));
|
|
1829
|
+
for (const line of sendAll) out.push(await tryDirective('SEND: ' + line, originId, onEvent));
|
|
1617
1830
|
return out.filter(Boolean).join('\n');
|
|
1618
1831
|
}
|
|
1619
1832
|
const sendM = sendAll.length === 1 ? /^([^|]+)\|([\s\S]+)/.exec(sendAll[0]) : null;
|
|
@@ -1642,7 +1855,7 @@ async function tryDirective(reply, originId) {
|
|
|
1642
1855
|
const pingAll = directiveLines(reply, 'PING');
|
|
1643
1856
|
if (pingAll.length > 1) {
|
|
1644
1857
|
const out = [];
|
|
1645
|
-
for (const line of pingAll) out.push(await tryDirective('PING: ' + line, originId));
|
|
1858
|
+
for (const line of pingAll) out.push(await tryDirective('PING: ' + line, originId, onEvent));
|
|
1646
1859
|
return out.filter(Boolean).join('\n');
|
|
1647
1860
|
}
|
|
1648
1861
|
const ping = pingAll.length === 1 ? [null, pingAll[0]] : null;
|
|
@@ -1687,7 +1900,7 @@ async function tryDirective(reply, originId) {
|
|
|
1687
1900
|
const full = safeResolveIn(dirFor(originId), rel);
|
|
1688
1901
|
mkdirSync(path.dirname(full), { recursive: true });
|
|
1689
1902
|
writeFileSync(full, content);
|
|
1690
|
-
return `Wrote ${rel} (${Buffer.byteLength(content)} bytes) to ${dirFor(originId)}
|
|
1903
|
+
return `Wrote ${rel} (${Buffer.byteLength(content)} bytes) to ${dirFor(originId)}.${await previewAck(originId, rel)}`;
|
|
1691
1904
|
} catch (e) { return `Couldn't write ${rel}: ${e.message}`; }
|
|
1692
1905
|
}
|
|
1693
1906
|
const readD = /^[ \t>*-]*READ:\s*(.+)/m.exec(reply);
|
|
@@ -1713,7 +1926,7 @@ async function tryDirective(reply, originId) {
|
|
|
1713
1926
|
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
1927
|
if (hits > 1) return `EDIT ${rel}: that text appears ${hits} times — include more surrounding context so it matches exactly once.`;
|
|
1715
1928
|
writeFileSync(full, before.replace(oldStr, newStr));
|
|
1716
|
-
return `Edited ${rel} (${before.length} -> ${before.replace(oldStr, newStr).length} bytes)
|
|
1929
|
+
return `Edited ${rel} (${before.length} -> ${before.replace(oldStr, newStr).length} bytes).${await previewAck(originId, rel)}`;
|
|
1717
1930
|
} catch (e) { return `Couldn't edit ${rel}: ${e.message}`; }
|
|
1718
1931
|
}
|
|
1719
1932
|
|
|
@@ -1739,7 +1952,7 @@ async function tryDirective(reply, originId) {
|
|
|
1739
1952
|
applied.push(o.slice(0, 40));
|
|
1740
1953
|
}
|
|
1741
1954
|
writeFileSync(full, next);
|
|
1742
|
-
return `MULTIEDIT ${rel}: ${applied.length} edit(s) applied (${before.length} -> ${next.length} bytes)
|
|
1955
|
+
return `MULTIEDIT ${rel}: ${applied.length} edit(s) applied (${before.length} -> ${next.length} bytes).${await previewAck(originId, rel)}`;
|
|
1743
1956
|
} catch (e) { return `Couldn't multiedit ${rel}: ${e.message}`; }
|
|
1744
1957
|
}
|
|
1745
1958
|
|
|
@@ -1770,8 +1983,8 @@ async function tryDirective(reply, originId) {
|
|
|
1770
1983
|
if (ls) {
|
|
1771
1984
|
const rel = ls[1].trim() || '.';
|
|
1772
1985
|
try {
|
|
1773
|
-
const full =
|
|
1774
|
-
const entries =
|
|
1986
|
+
const full = inDir(dirFor(originId), rel);
|
|
1987
|
+
const entries = listDir(dirFor(originId), rel);
|
|
1775
1988
|
if (!entries.length) return `${rel}: (empty)`;
|
|
1776
1989
|
const lines = entries.slice(0, 300).map((e) => {
|
|
1777
1990
|
if (e.isDirectory()) return ` ${e.name}/`;
|
|
@@ -1796,9 +2009,11 @@ async function tryDirective(reply, originId) {
|
|
|
1796
2009
|
// becomes `*`, which is what was meant.
|
|
1797
2010
|
const glob = /^(?:GLOB|LS|LIST|DIR|FIND):[ \t]*(.*)$/m.exec(reply);
|
|
1798
2011
|
if (glob) {
|
|
1799
|
-
|
|
2012
|
+
let pattern = glob[1].trim() || '*';
|
|
1800
2013
|
try {
|
|
1801
2014
|
const base = dirFor(originId);
|
|
2015
|
+
const stripped = stripBasePrefix(base, pattern);
|
|
2016
|
+
if (stripped !== null) pattern = stripped || '*';
|
|
1802
2017
|
const re = globToRe(pattern.startsWith('./') ? pattern.slice(2) : pattern);
|
|
1803
2018
|
const hits = walkDir(base).filter((f) => re.test(f) || re.test(path.basename(f)));
|
|
1804
2019
|
if (!hits.length) return `GLOB ${pattern}: no matches`;
|
|
@@ -1817,7 +2032,12 @@ async function tryDirective(reply, originId) {
|
|
|
1817
2032
|
try { re = new RegExp(pattern, 'i'); }
|
|
1818
2033
|
catch { return `GREP: ${pattern} isn't a valid regex.`; }
|
|
1819
2034
|
let files = walkDir(base);
|
|
1820
|
-
if (scope) {
|
|
2035
|
+
if (scope) {
|
|
2036
|
+
const stripped = stripBasePrefix(base, scope);
|
|
2037
|
+
const use = stripped !== null ? stripped : scope;
|
|
2038
|
+
const sre = globToRe(use);
|
|
2039
|
+
files = files.filter((f) => sre.test(f) || f.startsWith(use));
|
|
2040
|
+
}
|
|
1821
2041
|
const out = [];
|
|
1822
2042
|
for (const f of files) {
|
|
1823
2043
|
if (out.length > 200) break;
|
|
@@ -1887,9 +2107,19 @@ async function tryDirective(reply, originId) {
|
|
|
1887
2107
|
|
|
1888
2108
|
const serve = /^[ \t>*-]*SERVE:\s*(.*)$/m.exec(reply);
|
|
1889
2109
|
if (serve) {
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
2110
|
+
let rel = serve[1].trim();
|
|
2111
|
+
try {
|
|
2112
|
+
if (rel) {
|
|
2113
|
+
const root = path.resolve(dirFor(originId));
|
|
2114
|
+
const full = inDir(root, rel);
|
|
2115
|
+
rel = full === root ? '' : full.slice(root.length + 1);
|
|
2116
|
+
}
|
|
2117
|
+
} catch (e) { return `Couldn't serve ${serve[1].trim()}: ${e.message}`; }
|
|
2118
|
+
const port = await ensureWorkspacePort();
|
|
2119
|
+
if (!port) {
|
|
2120
|
+
return `Serving ${rel || 'index.html'} from ${dirFor(originId)} — waiting for the workspace port to bind.`;
|
|
2121
|
+
}
|
|
2122
|
+
return `Serving at http://localhost:${port}/${originId}/${rel}`;
|
|
1893
2123
|
}
|
|
1894
2124
|
const fetchD = /^[ \t>*-]*FETCH:\s*(\S+)/m.exec(reply);
|
|
1895
2125
|
if (fetchD) {
|
|
@@ -2026,59 +2256,79 @@ async function mcpDirective(url, tool, args) {
|
|
|
2026
2256
|
}
|
|
2027
2257
|
|
|
2028
2258
|
// 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
|
-
//
|
|
2259
|
+
// call: {type:'start',name,color} when a bot begins its turn, {type:'status',
|
|
2260
|
+
// detail} while paying / waiting / walking tools, {type:'delta',name,color,
|
|
2261
|
+
// delta} per streamed token, {type:'final',name,color,text} once its full
|
|
2262
|
+
// reply (or directive ack) is settled. Background turns — a SPAWNed
|
|
2032
2263
|
// subagent nobody's looking at yet — run with onEvent omitted and just use
|
|
2033
2264
|
// the plain non-streaming brain(), which is cheaper when nothing renders it.
|
|
2034
2265
|
async function runTurn(threadId, userText, onEvent, images) {
|
|
2035
2266
|
const t = threads.get(threadId);
|
|
2036
2267
|
if (!t) return;
|
|
2268
|
+
// A new user prompt must not sit behind a dead auto-continue. Bump the
|
|
2269
|
+
// generation so the previous runTurn's awaits bail instead of keeping
|
|
2270
|
+
// status=thinking and the UI on "…".
|
|
2271
|
+
if (!isHarnessUserText(userText)) t.turnSeq = (t.turnSeq || 0) + 1;
|
|
2272
|
+
const seq = t.turnSeq || 0;
|
|
2273
|
+
const stillMine = () => threads.get(threadId)?.turnSeq === seq;
|
|
2274
|
+
const paint = (ev) => {
|
|
2275
|
+
if (!stillMine()) return;
|
|
2276
|
+
if (ev.type === 'status' && ev.detail) t.liveStatus = ev.detail;
|
|
2277
|
+
if (ev.type === 'delta' || ev.type === 'status' || ev.type === 'start') t.lastDeltaAt = Date.now();
|
|
2278
|
+
onEvent?.(ev);
|
|
2279
|
+
};
|
|
2037
2280
|
t.history.push(images && images.length ? { who: 'user', text: userText, images } : { who: 'user', text: userText });
|
|
2038
2281
|
t.lastActivityAt = Date.now();
|
|
2282
|
+
t.status = 'thinking';
|
|
2283
|
+
t.thinkingAt = Date.now();
|
|
2284
|
+
t.lastDeltaAt = Date.now();
|
|
2285
|
+
t.liveStatus = 'waiting on model…';
|
|
2286
|
+
let chained = false;
|
|
2287
|
+
let parked = false;
|
|
2288
|
+
try {
|
|
2039
2289
|
if (t.members) {
|
|
2040
|
-
t.status = 'thinking';
|
|
2041
2290
|
// sequential, not parallel: each member's context is rebuilt from
|
|
2042
2291
|
// t.history right before its turn, so it sees every reply (including
|
|
2043
2292
|
// spawns/sends) the earlier members in THIS round already made
|
|
2044
2293
|
for (const m of t.members) {
|
|
2294
|
+
if (!stillMine()) return;
|
|
2045
2295
|
const msgs = buildMemberMessages(t, m);
|
|
2046
2296
|
let r = '';
|
|
2047
|
-
|
|
2297
|
+
paint({ type: 'start', name: m.name, color: m.color, detail: 'waiting on model…' });
|
|
2298
|
+
const emitStatus = (detail) => paint({ type: 'status', name: m.name, color: m.color, detail });
|
|
2048
2299
|
try {
|
|
2049
2300
|
r = onEvent
|
|
2050
|
-
? (await brainStream(msgs, (delta) =>
|
|
2301
|
+
? (await brainStream(msgs, (delta) => paint({ type: 'delta', name: m.name, color: m.color, delta }), t.contextId, undefined, undefined, 0, 0, emitStatus)).trim()
|
|
2051
2302
|
: (await brain(msgs, t.contextId)).trim();
|
|
2052
2303
|
} catch (e) { r = `error: ${e.message}`; }
|
|
2304
|
+
if (!stillMine()) return;
|
|
2305
|
+
r = stripThinkTags(r);
|
|
2053
2306
|
const runCmd = parseRun(r);
|
|
2054
2307
|
if (runCmd) {
|
|
2055
2308
|
const command = runCmd;
|
|
2056
2309
|
if (t.runMode === 'auto') {
|
|
2310
|
+
emitLiveStatus(t.id, paint, peekDirectiveStatus('', command));
|
|
2057
2311
|
const output = await execCommand(command, dirFor(t.id));
|
|
2058
2312
|
const shown = `$ ${command}\n${output}`;
|
|
2059
2313
|
t.history.push({ who: 'bot', text: shown, name: m.name, color: m.color });
|
|
2060
|
-
|
|
2314
|
+
paint({ type: 'final', name: m.name, color: m.color, text: shown });
|
|
2061
2315
|
// this member's turn is done; the round continues to the next member
|
|
2062
2316
|
continue;
|
|
2063
2317
|
}
|
|
2064
2318
|
const runId = randomUUID();
|
|
2065
2319
|
t.pendingRun = { runId, command, cwd: dirFor(t.id) };
|
|
2066
2320
|
t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending', name: m.name, color: m.color });
|
|
2067
|
-
|
|
2321
|
+
paint({ type: 'run-pending', runId, command, name: m.name, color: m.color });
|
|
2068
2322
|
// pauses the WHOLE round here — the rest of the group gets their turn
|
|
2069
2323
|
// on the round that runs after the user approves/denies
|
|
2070
|
-
|
|
2071
|
-
t.lastActivityAt = Date.now();
|
|
2072
|
-
saveThreads();
|
|
2324
|
+
parked = true;
|
|
2073
2325
|
return;
|
|
2074
2326
|
}
|
|
2075
|
-
const ack = await tryDirective(r, t.id);
|
|
2327
|
+
const ack = await tryDirective(r, t.id, paint);
|
|
2076
2328
|
const finalText = ack ?? (r || '(no response)');
|
|
2077
2329
|
t.history.push({ who: 'bot', text: finalText, name: m.name, color: m.color });
|
|
2078
|
-
|
|
2330
|
+
paint({ type: 'final', name: m.name, color: m.color, text: finalText });
|
|
2079
2331
|
}
|
|
2080
|
-
t.status = 'idle';
|
|
2081
|
-
saveThreads();
|
|
2082
2332
|
bindThread(t).catch(() => {});
|
|
2083
2333
|
return;
|
|
2084
2334
|
}
|
|
@@ -2092,9 +2342,8 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2092
2342
|
delete t.autoNudged;
|
|
2093
2343
|
}
|
|
2094
2344
|
t.messages.push({ role: 'user', content: contentFor(userText, images) });
|
|
2095
|
-
t.status = 'thinking';
|
|
2096
2345
|
let reply = '';
|
|
2097
|
-
|
|
2346
|
+
paint({ type: 'start', name: t.name, color: t.color, detail: 'waiting on model…' });
|
|
2098
2347
|
// Transient: the nudge is appended for THIS call only and never pushed into
|
|
2099
2348
|
// t.messages, so it can't accumulate across a chained auto run or get bound
|
|
2100
2349
|
// into the thread's context.
|
|
@@ -2119,7 +2368,8 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2119
2368
|
// `attempt` exists because a retry must be allowed to land somewhere else:
|
|
2120
2369
|
// see the empty-completion loop below.
|
|
2121
2370
|
const ask = async (attempt = 0) => {
|
|
2122
|
-
const emit = (delta) =>
|
|
2371
|
+
const emit = (delta) => paint({ type: 'delta', name: t.name, color: t.color, delta });
|
|
2372
|
+
const emitStatus = (detail) => paint({ type: 'status', name: t.name, color: t.color, detail });
|
|
2123
2373
|
// Retrieval breadth scales with the PROJECT's corpus, not this thread's —
|
|
2124
2374
|
// the holobrain is shared at the root, so that is the pool being searched.
|
|
2125
2375
|
const topK = adaptiveTopK((threads.get(rootOf(t).rootId) || t).boundItems);
|
|
@@ -2131,16 +2381,18 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2131
2381
|
// the middle (2 of 3) is a judged answer without the slowest entrant
|
|
2132
2382
|
// setting the latency.
|
|
2133
2383
|
const need = Math.min(Math.max(Number(t.raceNeed) || 1, 1), race);
|
|
2384
|
+
emitStatus('waiting on model…');
|
|
2134
2385
|
return (await brainRace(callMsgs, emit, t.contextId, models, need)).trim();
|
|
2135
2386
|
}
|
|
2136
2387
|
// A retry draws a DIFFERENT model from the tier rather than the same one.
|
|
2137
2388
|
const model = t.model || (await tierModels(t.tier || 'medium', attempt + 1, attempt > 0))[attempt] || undefined;
|
|
2138
2389
|
return (onEvent
|
|
2139
|
-
? (await brainStream(callMsgs, emit, t.contextId, model)).trim()
|
|
2390
|
+
? (await brainStream(callMsgs, emit, t.contextId, model, undefined, 0, topK, emitStatus)).trim()
|
|
2140
2391
|
: (await brain(callMsgs, t.contextId, model, topK)).trim());
|
|
2141
2392
|
};
|
|
2142
2393
|
try {
|
|
2143
2394
|
reply = await ask();
|
|
2395
|
+
if (!stillMine()) return;
|
|
2144
2396
|
// An EMPTY completion is transient far more often than it is meaningful —
|
|
2145
2397
|
// it showed up repeatedly as a dead "(no response)" bubble that cost the
|
|
2146
2398
|
// user a turn and told them nothing. Retry once before giving up, and if
|
|
@@ -2157,7 +2409,9 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2157
2409
|
// fix. A thread pinned with /model stays pinned; that was an explicit
|
|
2158
2410
|
// choice and silently answering as something else would be worse.
|
|
2159
2411
|
for (let i = 0; !reply && i < AUTO_EMPTY_RETRIES; i++) {
|
|
2412
|
+
paint({ type: 'status', name: t.name, color: t.color, detail: 'retrying…' });
|
|
2160
2413
|
await new Promise((r) => setTimeout(r, 400 * (i + 1)));
|
|
2414
|
+
if (!stillMine()) return;
|
|
2161
2415
|
reply = await ask(t.model ? 0 : i + 1);
|
|
2162
2416
|
}
|
|
2163
2417
|
if (!reply) {
|
|
@@ -2168,15 +2422,19 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2168
2422
|
} catch (e) {
|
|
2169
2423
|
reply = `error: ${e.message}`;
|
|
2170
2424
|
}
|
|
2425
|
+
if (!stillMine()) return;
|
|
2426
|
+
reply = stripThinkTags(reply);
|
|
2171
2427
|
t.messages.push({ role: 'assistant', content: reply });
|
|
2172
2428
|
const runCmd = parseRun(reply);
|
|
2173
2429
|
if (runCmd) {
|
|
2174
2430
|
const command = runCmd;
|
|
2175
2431
|
if (t.runMode === 'auto') {
|
|
2432
|
+
emitLiveStatus(t.id, paint, peekDirectiveStatus('', command));
|
|
2176
2433
|
const output = await execCommand(command, dirFor(t.id));
|
|
2434
|
+
if (!stillMine()) return;
|
|
2177
2435
|
const shown = `$ ${command}\n${output}`;
|
|
2178
2436
|
t.history.push({ who: 'bot', text: shown });
|
|
2179
|
-
|
|
2437
|
+
paint({ type: 'final', name: t.name, color: t.color, text: shown });
|
|
2180
2438
|
// FEED THE OUTPUT BACK. The 'ask' path already does this on approve, so
|
|
2181
2439
|
// auto mode was strictly LESS capable than the gated one: the command
|
|
2182
2440
|
// ran, the result was shown, and the model never saw it — no diagnosis,
|
|
@@ -2186,9 +2444,6 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2186
2444
|
// AUTO_MAX_STEPS chained commands per user message, reset whenever the
|
|
2187
2445
|
// user speaks again.
|
|
2188
2446
|
t.autoSteps = (t.autoSteps || 0) + 1;
|
|
2189
|
-
t.status = 'idle';
|
|
2190
|
-
t.lastActivityAt = Date.now();
|
|
2191
|
-
saveThreads();
|
|
2192
2447
|
if (t.autoSteps < AUTO_MAX_STEPS) {
|
|
2193
2448
|
// BIND BEFORE CHAINING. bindThread only ran at the end of a normal
|
|
2194
2449
|
// turn, and both auto paths return before reaching it — so in auto
|
|
@@ -2196,12 +2451,14 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2196
2451
|
// most material (command output, GLOB results, MCP tool lists). The
|
|
2197
2452
|
// holographic context stopped growing precisely when it mattered.
|
|
2198
2453
|
bindThread(t).catch(() => {});
|
|
2454
|
+
chained = true;
|
|
2199
2455
|
runTurn(threadId, condense('(command output)', output), onEvent).catch(() => {});
|
|
2200
2456
|
} else {
|
|
2201
2457
|
// Do not park. A "say continue" note is the failure mode auto exists
|
|
2202
2458
|
// to avoid — inject continue and keep going until DONE or ask.
|
|
2203
2459
|
t.autoSteps = 0;
|
|
2204
2460
|
bindThread(t).catch(() => {});
|
|
2461
|
+
chained = true;
|
|
2205
2462
|
runTurn(threadId, AUTO_CONTINUE, onEvent).catch(() => {});
|
|
2206
2463
|
}
|
|
2207
2464
|
return;
|
|
@@ -2210,20 +2467,16 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2210
2467
|
const runId = randomUUID();
|
|
2211
2468
|
t.pendingRun = { runId, command, cwd: dirFor(t.id) };
|
|
2212
2469
|
t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending' });
|
|
2213
|
-
|
|
2470
|
+
paint({ type: 'run-pending', runId, command, name: t.name, color: t.color });
|
|
2214
2471
|
}
|
|
2215
|
-
|
|
2216
|
-
t.lastActivityAt = Date.now();
|
|
2217
|
-
saveThreads();
|
|
2472
|
+
parked = true;
|
|
2218
2473
|
return;
|
|
2219
2474
|
}
|
|
2220
|
-
const ack = await tryDirective(reply, t.id);
|
|
2475
|
+
const ack = await tryDirective(reply, t.id, paint);
|
|
2476
|
+
if (!stillMine()) return;
|
|
2221
2477
|
const finalText = ack ?? (reply || '(no response)');
|
|
2222
2478
|
t.history.push({ who: 'bot', text: finalText });
|
|
2223
|
-
|
|
2224
|
-
t.status = 'idle';
|
|
2225
|
-
t.lastActivityAt = Date.now();
|
|
2226
|
-
saveThreads();
|
|
2479
|
+
paint({ type: 'final', name: t.name, color: t.color, text: finalText });
|
|
2227
2480
|
|
|
2228
2481
|
// AUTO CONTINUES AFTER *ANY* DIRECTIVE, not just RUN.
|
|
2229
2482
|
//
|
|
@@ -2240,6 +2493,7 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2240
2493
|
&& !/^[ \t>*-]*DONE:/m.test(reply)) {
|
|
2241
2494
|
t.autoSteps = (t.autoSteps || 0) + 1;
|
|
2242
2495
|
bindThread(t).catch(() => {}); // bind every hop, not just the last one
|
|
2496
|
+
chained = true;
|
|
2243
2497
|
if (t.autoSteps < AUTO_MAX_STEPS) {
|
|
2244
2498
|
runTurn(threadId, condense('(directive result)', ack), onEvent).catch(() => {});
|
|
2245
2499
|
} else {
|
|
@@ -2273,8 +2527,8 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2273
2527
|
// another continue rather than stopping.
|
|
2274
2528
|
t.autoNudged = true;
|
|
2275
2529
|
t.autoSteps = (t.autoSteps || 0) + 1;
|
|
2276
|
-
saveThreads();
|
|
2277
2530
|
bindThread(t).catch(() => {});
|
|
2531
|
+
chained = true;
|
|
2278
2532
|
if (t.autoSteps < AUTO_MAX_STEPS) {
|
|
2279
2533
|
runTurn(threadId, NUDGE, onEvent).catch(() => {});
|
|
2280
2534
|
} else {
|
|
@@ -2284,6 +2538,17 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2284
2538
|
return;
|
|
2285
2539
|
}
|
|
2286
2540
|
bindThread(t).catch(() => {});
|
|
2541
|
+
} finally {
|
|
2542
|
+
// A hung or thrown brainStream used to leave t.status = 'thinking' and the
|
|
2543
|
+
// UI on mute "…" forever. Always idle unless this hop chained or parked
|
|
2544
|
+
// on an approval — the next runTurn sets thinking again itself.
|
|
2545
|
+
if (stillMine() && !chained && !parked && !t.pendingRun) {
|
|
2546
|
+
t.status = 'idle';
|
|
2547
|
+
t.liveStatus = '';
|
|
2548
|
+
}
|
|
2549
|
+
t.lastActivityAt = Date.now();
|
|
2550
|
+
saveThreads();
|
|
2551
|
+
}
|
|
2287
2552
|
}
|
|
2288
2553
|
|
|
2289
2554
|
// Said it would, without a directive line. "Spawned X" and "working on it" are
|
|
@@ -2418,6 +2683,7 @@ function threadSummary(t) {
|
|
|
2418
2683
|
// an approval nobody knew it wanted. The blue dot means "working"; this
|
|
2419
2684
|
// means "your move".
|
|
2420
2685
|
awaitingUser: Boolean(t.pendingRun),
|
|
2686
|
+
liveStatus: t.status === 'thinking' ? (t.liveStatus || '') : '',
|
|
2421
2687
|
rootId: rootOf(t).rootId, depth: rootOf(t).depth,
|
|
2422
2688
|
rootName: (threads.get(rootOf(t).rootId) || t).name,
|
|
2423
2689
|
// The spend dial, so the header can show it without a round trip per
|
|
@@ -2425,7 +2691,11 @@ function threadSummary(t) {
|
|
|
2425
2691
|
tier: t.tier || 'medium', race: Number(t.race) || 0, raceNeed: Number(t.raceNeed) || 1, model: t.model || '',
|
|
2426
2692
|
// How many bots sit BELOW this one. The ping-all affordance belongs on
|
|
2427
2693
|
// anyone with a crew, not only on a project root.
|
|
2428
|
-
kids: subtreeOf(t.id).length
|
|
2694
|
+
kids: subtreeOf(t.id).length,
|
|
2695
|
+
// Names only — enough for the sidebar/header to stack a little crew PFP
|
|
2696
|
+
// without shipping every member's system prompt to the browser.
|
|
2697
|
+
members: t.members ? t.members.map((m) => m.name) : undefined,
|
|
2698
|
+
workspacePort: workspacePort || 0 };
|
|
2429
2699
|
}
|
|
2430
2700
|
|
|
2431
2701
|
const APP_HTML = `<!doctype html>
|
|
@@ -2478,8 +2748,22 @@ const APP_HTML = `<!doctype html>
|
|
|
2478
2748
|
font-size: 13px; }
|
|
2479
2749
|
.trow:hover .tclose { display: flex; }
|
|
2480
2750
|
.tclose:hover { background: #3a3a3c; color: #ececec; }
|
|
2481
|
-
|
|
2482
|
-
|
|
2751
|
+
/* BOT PFPs. Grok Bot uses a cute illustrated face, not two letters in a
|
|
2752
|
+
rounded square. The SVG is generated in botPfp(); this just frames it
|
|
2753
|
+
as a round clip and runs a cheap idle bob/blink. overflow:hidden clips
|
|
2754
|
+
the bounce so it cannot paint over the HUD or wallet. */
|
|
2755
|
+
.tavatar { width: 36px; height: 36px; border-radius: 50%; flex: 0 0 36px; overflow: hidden;
|
|
2756
|
+
display: flex; align-items: center; justify-content: center; background: #1c1c1e;
|
|
2757
|
+
color: #fff; font-weight: 600; font-size: 14px; }
|
|
2758
|
+
.tavatar svg { width: 100%; height: 100%; display: block; }
|
|
2759
|
+
.tavatar-sm { width: 28px; height: 28px; flex: 0 0 28px; }
|
|
2760
|
+
.tavatar-plus { background: #3a3a3c; font-size: 15px; }
|
|
2761
|
+
.bot-pfp .bot-bob { transform-box: fill-box; transform-origin: 50% 70%;
|
|
2762
|
+
animation: botbob 2.8s ease-in-out infinite; animation-delay: var(--bot-delay, 0s); }
|
|
2763
|
+
.bot-pfp .bot-eyes { transform-box: fill-box; transform-origin: 50% 50%;
|
|
2764
|
+
animation: botblink 3.8s step-end infinite; animation-delay: var(--bot-blink, 0s); }
|
|
2765
|
+
@keyframes botbob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-1.5px); } }
|
|
2766
|
+
@keyframes botblink { 0%,88%,100% { transform: scaleY(1); } 90%,94% { transform: scaleY(0.08); } }
|
|
2483
2767
|
.tmeta { min-width: 0; flex: 1; }
|
|
2484
2768
|
.tname { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
2485
2769
|
.tprev { font-size: 12px; color: #8e8e93; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
@@ -2494,11 +2778,13 @@ const APP_HTML = `<!doctype html>
|
|
|
2494
2778
|
animation: twarnpulse 1.6s ease-in-out infinite;
|
|
2495
2779
|
}
|
|
2496
2780
|
@keyframes twarnpulse { 0%,100% { opacity: 1; } 50% { opacity: .45; } }
|
|
2497
|
-
@media (prefers-reduced-motion: reduce) {
|
|
2781
|
+
@media (prefers-reduced-motion: reduce) {
|
|
2782
|
+
.twarn, .bot-pfp .bot-bob, .bot-pfp .bot-eyes { animation: none; }
|
|
2783
|
+
}
|
|
2498
2784
|
#main { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column; height: 100vh; }
|
|
2499
2785
|
#chatHeader { padding: 14px 20px; border-bottom: 1px solid #1c1c1e; display: flex; align-items: center;
|
|
2500
2786
|
flex-wrap: wrap; gap: 8px 10px; font-weight: 600; }
|
|
2501
|
-
#chatHeader .tavatar { width: 26px; height: 26px; border-radius:
|
|
2787
|
+
#chatHeader .tavatar { width: 26px; height: 26px; border-radius: 50%; font-size: 11px; flex: 0 0 26px; }
|
|
2502
2788
|
/* Title shrinks and wraps; the spend dials must stay on screen. margin-left:auto
|
|
2503
2789
|
on #modeToggle used to shove cheap/race/wallet off the right edge. */
|
|
2504
2790
|
#chatHeaderId { display: flex; align-items: center; gap: 10px; flex: 1 1 120px; min-width: 0; overflow: hidden; }
|
|
@@ -2557,10 +2843,38 @@ const APP_HTML = `<!doctype html>
|
|
|
2557
2843
|
.wrow .wcopy { flex: 0 0 auto; color: #6f7080; font-size: 10px; text-transform: uppercase; letter-spacing: .06em;
|
|
2558
2844
|
user-select: none; }
|
|
2559
2845
|
.wrow:hover .wcopy { color: #b8f240; }
|
|
2846
|
+
.wcredit { border: 1px solid #2a3a18; background: rgba(184,242,64,.08); border-radius: 12px;
|
|
2847
|
+
padding: 12px 14px; margin-bottom: 12px; }
|
|
2848
|
+
.wcredit .wbig { color: #b8f240; font-size: 22px; font-weight: 700; letter-spacing: -0.03em; }
|
|
2849
|
+
.wcredit .wlab2 { color: #8e8e93; font-size: 11px; margin-top: 2px; }
|
|
2560
2850
|
.wbal { border: 1px solid #1c1c1e; border-radius: 12px; padding: 10px 12px; margin-bottom: 10px;
|
|
2561
2851
|
font-size: 12px; color: #ececec; line-height: 1.7; word-break: break-word; }
|
|
2562
2852
|
.wnote { color: #6f7080; font-size: 11px; line-height: 1.6; margin-top: 12px; }
|
|
2563
2853
|
.wempty { color: #f28c4d; }
|
|
2854
|
+
.wlane { border-top: 1px solid #2c2c2e; margin-top: 16px; padding-top: 14px; }
|
|
2855
|
+
.wlanetitle { font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
|
|
2856
|
+
color: #ececec; margin-bottom: 4px; }
|
|
2857
|
+
.wtag { color: #b8f240; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; margin-bottom: 8px; }
|
|
2858
|
+
.wtier { border: 1px solid #1c1c1e; border-radius: 12px; padding: 10px 12px; margin-bottom: 8px; }
|
|
2859
|
+
.wtier.hot { border-color: #b8f240; }
|
|
2860
|
+
.wtier .wtn { font-size: 14px; font-weight: 600; }
|
|
2861
|
+
.wtier .wtp { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 18px;
|
|
2862
|
+
font-weight: 700; margin: 4px 0 2px; }
|
|
2863
|
+
.wtier .wts { color: #b8f240; font-size: 11px; }
|
|
2864
|
+
.wtier .wtb { color: #8e8e93; font-size: 11px; margin: 4px 0 8px; }
|
|
2865
|
+
.wtier button { border: 1px solid #2c2c2e; background: #131315; color: #ececec; font: inherit;
|
|
2866
|
+
font-size: 12px; border-radius: 8px; padding: 6px 10px; cursor: pointer; }
|
|
2867
|
+
.wtier.hot button { background: #b8f240; border-color: #b8f240; color: #0b0b0d; font-weight: 600; }
|
|
2868
|
+
.wtier button:disabled { opacity: .5; cursor: default; }
|
|
2869
|
+
.wpaste { margin-top: 10px; }
|
|
2870
|
+
.wpaste input { width: 100%; background: #0b0b0d; border: 1px solid #2c2c2e; border-radius: 8px;
|
|
2871
|
+
color: #ececec; font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
2872
|
+
padding: 8px 10px; margin: 6px 0; }
|
|
2873
|
+
.wpaste button { border: 1px solid #2c2c2e; background: #131315; color: #ececec; font: inherit;
|
|
2874
|
+
font-size: 12px; border-radius: 8px; padding: 6px 10px; cursor: pointer; }
|
|
2875
|
+
.wquiet { margin-top: 10px; font-size: 11px; }
|
|
2876
|
+
.wquiet a { color: #6ab0ff; }
|
|
2877
|
+
.wsubon { color: #b8f240; font-size: 13px; font-weight: 600; margin-bottom: 8px; }
|
|
2564
2878
|
/* Slash autocomplete. Anchored above the composer because the composer sits
|
|
2565
2879
|
at the bottom of the viewport — a dropdown BELOW it would render off
|
|
2566
2880
|
screen. */
|
|
@@ -2579,11 +2893,14 @@ const APP_HTML = `<!doctype html>
|
|
|
2579
2893
|
inside the dials (race / wallet / ◎) once they take a second row. top is
|
|
2580
2894
|
set from the header's live bottom in placeHud(). z-index stays high; the
|
|
2581
2895
|
bug was geometry, not stacking. */
|
|
2582
|
-
#hud { position: absolute; right: 14px; width:
|
|
2896
|
+
#hud { position: absolute; right: 14px; width: 270px; background: rgba(14,14,17,.94);
|
|
2583
2897
|
border: 1px solid #333340; border-radius: 10px; padding: 12px 14px; font: 11px/1.5 Menlo, monospace;
|
|
2584
2898
|
display: none; z-index: 300; box-shadow: 0 12px 30px rgba(0,0,0,.5); }
|
|
2585
2899
|
#hud.show { display: block; }
|
|
2586
2900
|
#hud .htitle { color: #b8f240; font-size: 10px; letter-spacing: .04em; margin-bottom: 10px; }
|
|
2901
|
+
#hud .htitle.hsession { margin-top: 12px; padding-top: 10px; border-top: 1px solid #333340; color: #6f7080; }
|
|
2902
|
+
#hud .hcredit { color: #b8f240; font-size: 22px; font-weight: 700; letter-spacing: -0.03em; line-height: 1.15; }
|
|
2903
|
+
#hud .hcreditlab { color: #999aa8; font-size: 10px; margin: 2px 0 8px; }
|
|
2587
2904
|
#hud .hrow { display: flex; justify-content: space-between; margin: 6px 0; color: #f0f0eb; font-size: 12px; }
|
|
2588
2905
|
#hud .hrow span:first-child { color: #999aa8; font-size: 10.5px; }
|
|
2589
2906
|
#hud .hlime { color: #b8f240; }
|
|
@@ -2604,8 +2921,8 @@ const APP_HTML = `<!doctype html>
|
|
|
2604
2921
|
-webkit-user-select: text; user-select: text; }
|
|
2605
2922
|
.hdr { align-self: flex-start; display: flex; align-items: center; gap: 6px; margin: 12px 0 4px;
|
|
2606
2923
|
color: #8e8e93; font-size: 13px; }
|
|
2607
|
-
.hdr .avatar { width: 18px; height: 18px; border-radius:
|
|
2608
|
-
|
|
2924
|
+
.hdr .avatar { width: 18px; height: 18px; border-radius: 50%; overflow: hidden; display: flex;
|
|
2925
|
+
align-items: center; justify-content: center; background: #1c1c1e; }
|
|
2609
2926
|
/* min-width:0 is load-bearing. A flex item defaults to min-width:auto, so it
|
|
2610
2927
|
refuses to shrink below its content's intrinsic width — one long
|
|
2611
2928
|
unbreakable line (a curl command, a JSON blob) in a <pre> then stretches
|
|
@@ -2621,7 +2938,13 @@ const APP_HTML = `<!doctype html>
|
|
|
2621
2938
|
/* rendered markdown. The bubble is pre-wrap for plain text, but block
|
|
2622
2939
|
elements carry their own spacing — leaving pre-wrap on would add the
|
|
2623
2940
|
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; }
|
|
2941
|
+
.bubble:has(> p, > .md-h, > .md-table, > .md-list, > .md-pre, > .html-preview-wrap) { white-space: normal; }
|
|
2942
|
+
.row:has(.html-preview) { max-width: 92%; }
|
|
2943
|
+
.html-preview-wrap { margin-top: 10px; }
|
|
2944
|
+
.html-preview-open { display: inline-block; margin-bottom: 6px; font-size: 12px; color: #6ab0ff;
|
|
2945
|
+
text-decoration: underline; cursor: pointer; }
|
|
2946
|
+
.html-preview { display: block; width: 100%; height: 420px; border: 1px solid #3a3a3c;
|
|
2947
|
+
border-radius: 12px; background: #111; }
|
|
2625
2948
|
.bubble > p { margin: 0 0 10px; }
|
|
2626
2949
|
.bubble > p:last-child { margin-bottom: 0; }
|
|
2627
2950
|
.md-h { margin: 14px 0 8px; font-size: 15px; font-weight: 600; line-height: 1.3; }
|
|
@@ -2690,6 +3013,8 @@ const APP_HTML = `<!doctype html>
|
|
|
2690
3013
|
background: #8e8e93; animation: blink 1.2s infinite ease-in-out; }
|
|
2691
3014
|
.dots span:nth-child(2) { animation-delay: .2s; } .dots span:nth-child(3) { animation-delay: .4s; }
|
|
2692
3015
|
@keyframes blink { 0%, 80%, 100% { opacity: .25; } 40% { opacity: 1; } }
|
|
3016
|
+
.tstatus { color: #8e8e93; font-size: 13px; margin-left: 6px; }
|
|
3017
|
+
.ttrail { display: block; color: #8e8e93; font-size: 12.5px; margin-top: 8px; }
|
|
2693
3018
|
#bar { padding: 10px 16px 18px; position: relative; }
|
|
2694
3019
|
#row-input { display: flex; align-items: center; gap: 8px; }
|
|
2695
3020
|
#plusMenu { position: absolute; bottom: 62px; left: 16px; background: #1c1c1e; border-radius: 14px;
|
|
@@ -2797,8 +3122,26 @@ const APP_HTML = `<!doctype html>
|
|
|
2797
3122
|
<div id="walletOverlay" data-component="wallet-modal">
|
|
2798
3123
|
<div id="walletBox">
|
|
2799
3124
|
<h3>Your wallet</h3>
|
|
3125
|
+
<div class="wsub">Two ways to pay, side by side. Wallet/x402 stays. A card subscription sits next to it — not instead of it.</div>
|
|
3126
|
+
<div class="wlanetitle">Wallet / x402</div>
|
|
2800
3127
|
<div class="wsub">This is <b>your</b> local burner on this machine (or this box). Keys stay in ~/.openzoo/wallet.json. It is not openzoo’s wallet, not a shared zoo account, not the model’s. You fund these deposit addresses; the app pays x402 per call from this wallet. Public addresses only — the UI never shows the key.</div>
|
|
2801
3128
|
<div id="walletBody">loading…</div>
|
|
3129
|
+
<div class="wlane" id="subLane" data-component="subscribe-lane">
|
|
3130
|
+
<div class="wlanetitle">Subscribe with a card</div>
|
|
3131
|
+
<div class="wtag">Subscription key · no x402</div>
|
|
3132
|
+
<div class="wsub">Same plans as the public page. Checkout opens in the system browser — never an in-app Stripe window. After Stripe, this app polls the site’s key endpoint with the checkout session, or you paste the key from the success page.</div>
|
|
3133
|
+
<div id="subStatus"></div>
|
|
3134
|
+
<div id="subTiers">loading plans…</div>
|
|
3135
|
+
<div class="wpaste">
|
|
3136
|
+
<div class="wlab">I already subscribed — paste key</div>
|
|
3137
|
+
<input id="subKeyInp" type="text" autocomplete="off" spellcheck="false"
|
|
3138
|
+
placeholder="key, or the /billing/done?session=… URL">
|
|
3139
|
+
<button type="button" id="subKeyBtn">Save key</button>
|
|
3140
|
+
<button type="button" id="subForgetBtn" hidden>Remove key</button>
|
|
3141
|
+
</div>
|
|
3142
|
+
<div class="wnote" id="subNote"></div>
|
|
3143
|
+
<div class="wquiet"><a id="subPageLink" href="${SUBSCRIPTIONS_PAGE}" target="_blank" rel="noopener">Full subscriptions page</a></div>
|
|
3144
|
+
</div>
|
|
2802
3145
|
</div>
|
|
2803
3146
|
</div>
|
|
2804
3147
|
<div id="main">
|
|
@@ -2833,13 +3176,14 @@ const APP_HTML = `<!doctype html>
|
|
|
2833
3176
|
</optgroup>
|
|
2834
3177
|
</select>
|
|
2835
3178
|
<button class="dial" id="walletBtn" data-component="wallet-open"
|
|
2836
|
-
title="
|
|
3179
|
+
title="Wallet/x402 or subscribe with a card — deposit addresses, live balances, Stripe plans">wallet</button>
|
|
2837
3180
|
<button class="icon-btn" id="reloadBtn" title="Restart grokui on this box">↻</button>
|
|
2838
3181
|
<button class="icon-btn" id="hudBtn">◎</button>
|
|
2839
3182
|
</div>
|
|
2840
3183
|
</div>
|
|
2841
3184
|
<div id="hud">
|
|
2842
3185
|
<div class="htitle">YOUR WALLET · THIS SESSION</div>
|
|
3186
|
+
<div class="hrow" id="hSubRow" hidden><span>subscription</span><span id="hSub" class="hlime">—</span></div>
|
|
2843
3187
|
<div class="hrow"><span>prepaid credit</span><span id="hCredit" class="hlime">—</span></div>
|
|
2844
3188
|
<div class="hrow"><span>you've paid</span><span id="hYouSpent">—</span></div>
|
|
2845
3189
|
<div class="hrow"><span>our cost (cogs)</span><span id="hYouCogs">—</span></div>
|
|
@@ -2904,8 +3248,155 @@ const APP_HTML = `<!doctype html>
|
|
|
2904
3248
|
: '';
|
|
2905
3249
|
let activeId = null;
|
|
2906
3250
|
let knownThreads = [];
|
|
2907
|
-
|
|
2908
|
-
|
|
3251
|
+
let workspacePort = 0;
|
|
3252
|
+
|
|
3253
|
+
// BOT PFPs. Same job as Grok Bot's agent faces: a round illustrated
|
|
3254
|
+
// creature, unique per name, idle-animated, no network. Hash is the same
|
|
3255
|
+
// 31-multiply used by colorFor, so a name always paints the same bot.
|
|
3256
|
+
// Built with string concat — template literals inside APP_HTML would be
|
|
3257
|
+
// interpolated by the outer backtick string before the browser sees them.
|
|
3258
|
+
let botPfpSeq = 0;
|
|
3259
|
+
function nameHash(name) {
|
|
3260
|
+
let h = 0;
|
|
3261
|
+
const s = String(name || '');
|
|
3262
|
+
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
|
|
3263
|
+
return h;
|
|
3264
|
+
}
|
|
3265
|
+
function botPalettes() {
|
|
3266
|
+
return [
|
|
3267
|
+
['#ff6b9d', '#ffd0e0', '#c9184a'],
|
|
3268
|
+
['#ffb347', '#ffe4b3', '#c27800'],
|
|
3269
|
+
['#5eead4', '#ccfbf1', '#0f766e'],
|
|
3270
|
+
['#b8f240', '#e6ffb3', '#4d7c0f'],
|
|
3271
|
+
['#c084fc', '#edd4ff', '#7e22ce'],
|
|
3272
|
+
['#60a5fa', '#dbeafe', '#1d4ed8'],
|
|
3273
|
+
['#fb7185', '#ffe4e6', '#be123c'],
|
|
3274
|
+
['#34d399', '#d1fae5', '#047857'],
|
|
3275
|
+
['#fbbf24', '#fef3c7', '#b45309'],
|
|
3276
|
+
['#a78bfa', '#ede9fe', '#6d28d9'],
|
|
3277
|
+
['#38bdf8', '#e0f2fe', '#0369a1'],
|
|
3278
|
+
['#f472b6', '#fce7f3', '#9d174d']
|
|
3279
|
+
];
|
|
3280
|
+
}
|
|
3281
|
+
function botFaceInner(name) {
|
|
3282
|
+
const h = nameHash(name);
|
|
3283
|
+
const pal = botPalettes()[h % 12];
|
|
3284
|
+
const fur = pal[0], light = pal[1], line = pal[2];
|
|
3285
|
+
const ears = (h >>> 4) % 5;
|
|
3286
|
+
const eyes = (h >>> 8) % 5;
|
|
3287
|
+
const mouth = (h >>> 12) % 5;
|
|
3288
|
+
const extra = (h >>> 16) % 4;
|
|
3289
|
+
const gid = 'bfg' + (++botPfpSeq);
|
|
3290
|
+
let s = '<g class="bot-bob">';
|
|
3291
|
+
s += '<defs><radialGradient id="' + gid + '" cx="35%" cy="30%" r="75%">'
|
|
3292
|
+
+ '<stop offset="0%" stop-color="' + light + '"/>'
|
|
3293
|
+
+ '<stop offset="100%" stop-color="' + fur + '"/>'
|
|
3294
|
+
+ '</radialGradient></defs>';
|
|
3295
|
+
if (ears === 0) {
|
|
3296
|
+
s += '<ellipse cx="16" cy="18" rx="9" ry="10" fill="' + fur + '"/>'
|
|
3297
|
+
+ '<ellipse cx="48" cy="18" rx="9" ry="10" fill="' + fur + '"/>'
|
|
3298
|
+
+ '<ellipse cx="16" cy="19" rx="4.5" ry="5.5" fill="' + light + '"/>'
|
|
3299
|
+
+ '<ellipse cx="48" cy="19" rx="4.5" ry="5.5" fill="' + light + '"/>';
|
|
3300
|
+
} else if (ears === 1) {
|
|
3301
|
+
s += '<polygon points="10,28 17,6 29,22" fill="' + fur + '"/>'
|
|
3302
|
+
+ '<polygon points="54,28 47,6 35,22" fill="' + fur + '"/>'
|
|
3303
|
+
+ '<polygon points="14,26 18,11 26,22" fill="' + light + '"/>'
|
|
3304
|
+
+ '<polygon points="50,26 46,11 38,22" fill="' + light + '"/>';
|
|
3305
|
+
} else if (ears === 2) {
|
|
3306
|
+
s += '<ellipse cx="11" cy="34" rx="8" ry="14" fill="' + fur + '" transform="rotate(-28 11 34)"/>'
|
|
3307
|
+
+ '<ellipse cx="53" cy="34" rx="8" ry="14" fill="' + fur + '" transform="rotate(28 53 34)"/>'
|
|
3308
|
+
+ '<ellipse cx="12" cy="34" rx="4" ry="8" fill="' + light + '" transform="rotate(-28 12 34)"/>'
|
|
3309
|
+
+ '<ellipse cx="52" cy="34" rx="4" ry="8" fill="' + light + '" transform="rotate(28 52 34)"/>';
|
|
3310
|
+
} else if (ears === 3) {
|
|
3311
|
+
s += '<line x1="22" y1="20" x2="17" y2="6" stroke="' + line + '" stroke-width="2.2" stroke-linecap="round"/>'
|
|
3312
|
+
+ '<line x1="42" y1="20" x2="47" y2="6" stroke="' + line + '" stroke-width="2.2" stroke-linecap="round"/>'
|
|
3313
|
+
+ '<circle cx="16" cy="5" r="3.6" fill="' + light + '" stroke="' + line + '" stroke-width="1"/>'
|
|
3314
|
+
+ '<circle cx="48" cy="5" r="3.6" fill="' + light + '" stroke="' + line + '" stroke-width="1"/>';
|
|
3315
|
+
} else {
|
|
3316
|
+
s += '<ellipse cx="22" cy="10" rx="6" ry="16" fill="' + fur + '"/>'
|
|
3317
|
+
+ '<ellipse cx="42" cy="10" rx="6" ry="16" fill="' + fur + '"/>'
|
|
3318
|
+
+ '<ellipse cx="22" cy="11" rx="2.6" ry="10" fill="' + light + '"/>'
|
|
3319
|
+
+ '<ellipse cx="42" cy="11" rx="2.6" ry="10" fill="' + light + '"/>';
|
|
3320
|
+
}
|
|
3321
|
+
s += '<circle cx="32" cy="36" r="22" fill="url(#' + gid + ')" stroke="' + line + '" stroke-width="1.1"/>'
|
|
3322
|
+
+ '<ellipse cx="24" cy="26" rx="8" ry="5" fill="#fff" opacity="0.28"/>';
|
|
3323
|
+
if (extra === 1 || extra === 2) {
|
|
3324
|
+
s += '<ellipse cx="20" cy="42" rx="5.5" ry="3.2" fill="#ff8fab" opacity="0.5"/>'
|
|
3325
|
+
+ '<ellipse cx="44" cy="42" rx="5.5" ry="3.2" fill="#ff8fab" opacity="0.5"/>';
|
|
3326
|
+
}
|
|
3327
|
+
if (extra === 3) {
|
|
3328
|
+
s += '<circle cx="22" cy="40" r="1.1" fill="' + line + '" opacity="0.4"/>'
|
|
3329
|
+
+ '<circle cx="26" cy="43" r="0.9" fill="' + line + '" opacity="0.35"/>'
|
|
3330
|
+
+ '<circle cx="42" cy="40" r="1.1" fill="' + line + '" opacity="0.4"/>'
|
|
3331
|
+
+ '<circle cx="38" cy="43" r="0.9" fill="' + line + '" opacity="0.35"/>';
|
|
3332
|
+
}
|
|
3333
|
+
s += '<g class="bot-eyes">';
|
|
3334
|
+
if (eyes === 0) {
|
|
3335
|
+
s += '<circle cx="24" cy="35" r="3.6" fill="#1a1220"/>'
|
|
3336
|
+
+ '<circle cx="40" cy="35" r="3.6" fill="#1a1220"/>'
|
|
3337
|
+
+ '<circle cx="25.2" cy="33.8" r="1.15" fill="#fff"/>'
|
|
3338
|
+
+ '<circle cx="41.2" cy="33.8" r="1.15" fill="#fff"/>';
|
|
3339
|
+
} else if (eyes === 1) {
|
|
3340
|
+
s += '<ellipse cx="24" cy="35" rx="3.2" ry="4.6" fill="#1a1220"/>'
|
|
3341
|
+
+ '<ellipse cx="40" cy="35" rx="3.2" ry="4.6" fill="#1a1220"/>'
|
|
3342
|
+
+ '<circle cx="24.8" cy="33.2" r="1" fill="#fff"/>'
|
|
3343
|
+
+ '<circle cx="40.8" cy="33.2" r="1" fill="#fff"/>';
|
|
3344
|
+
} else if (eyes === 2) {
|
|
3345
|
+
s += '<path d="M20 36 q4 -6 8 0" fill="none" stroke="#1a1220" stroke-width="2.2" stroke-linecap="round"/>'
|
|
3346
|
+
+ '<path d="M36 36 q4 -6 8 0" fill="none" stroke="#1a1220" stroke-width="2.2" stroke-linecap="round"/>';
|
|
3347
|
+
} else if (eyes === 3) {
|
|
3348
|
+
s += '<circle cx="24" cy="35" r="4.4" fill="#1a1220"/>'
|
|
3349
|
+
+ '<circle cx="40" cy="35" r="4.4" fill="#1a1220"/>'
|
|
3350
|
+
+ '<circle cx="25.4" cy="33.4" r="1.5" fill="#fff"/>'
|
|
3351
|
+
+ '<circle cx="41.4" cy="33.4" r="1.5" fill="#fff"/>'
|
|
3352
|
+
+ '<circle cx="22.8" cy="36.4" r="0.7" fill="#fff" opacity="0.7"/>';
|
|
3353
|
+
} else {
|
|
3354
|
+
s += '<path d="M20 35 q4 5 8 0" fill="none" stroke="#1a1220" stroke-width="2.2" stroke-linecap="round"/>'
|
|
3355
|
+
+ '<circle cx="40" cy="35" r="3.6" fill="#1a1220"/>'
|
|
3356
|
+
+ '<circle cx="41.2" cy="33.8" r="1.15" fill="#fff"/>';
|
|
3357
|
+
}
|
|
3358
|
+
s += '</g>';
|
|
3359
|
+
if (mouth === 0) {
|
|
3360
|
+
s += '<path d="M26 46 q6 7 12 0" fill="none" stroke="#1a1220" stroke-width="2" stroke-linecap="round"/>';
|
|
3361
|
+
} else if (mouth === 1) {
|
|
3362
|
+
s += '<ellipse cx="32" cy="48" rx="5" ry="3.4" fill="#3a1a22"/>'
|
|
3363
|
+
+ '<ellipse cx="32" cy="49.4" rx="3.2" ry="1.6" fill="#ff6b8a" opacity="0.85"/>';
|
|
3364
|
+
} else if (mouth === 2) {
|
|
3365
|
+
s += '<path d="M25 46 q4 6 6 0 q4 6 6 0" fill="none" stroke="#1a1220" stroke-width="2" stroke-linecap="round"/>';
|
|
3366
|
+
} else if (mouth === 3) {
|
|
3367
|
+
s += '<path d="M26 45 q6 6 12 0" fill="none" stroke="#1a1220" stroke-width="2" stroke-linecap="round"/>'
|
|
3368
|
+
+ '<ellipse cx="34" cy="50.5" rx="3.1" ry="3.4" fill="#ff6b8a"/>';
|
|
3369
|
+
} else {
|
|
3370
|
+
s += '<circle cx="32" cy="47.5" r="1.7" fill="#1a1220"/>';
|
|
3371
|
+
}
|
|
3372
|
+
s += '</g>';
|
|
3373
|
+
return s;
|
|
3374
|
+
}
|
|
3375
|
+
function botPfp(name, members) {
|
|
3376
|
+
let names = (members && members.length > 1) ? members.slice(0, 3) : [name || 'Bot'];
|
|
3377
|
+
if (names.length === 1 && String(name || '').indexOf(', ') !== -1) {
|
|
3378
|
+
const parts = String(name).split(', ');
|
|
3379
|
+
const cleaned = [];
|
|
3380
|
+
for (let i = 0; i < parts.length && cleaned.length < 3; i++) {
|
|
3381
|
+
if (parts[i]) cleaned.push(parts[i]);
|
|
3382
|
+
}
|
|
3383
|
+
if (cleaned.length > 1) names = cleaned;
|
|
3384
|
+
}
|
|
3385
|
+
const delay = nameHash(names[0]);
|
|
3386
|
+
let inner = '';
|
|
3387
|
+
if (names.length === 1) inner = botFaceInner(names[0]);
|
|
3388
|
+
else if (names.length === 2) {
|
|
3389
|
+
inner = '<g transform="translate(-2,6) scale(0.7)">' + botFaceInner(names[0]) + '</g>'
|
|
3390
|
+
+ '<g transform="translate(20,8) scale(0.7)">' + botFaceInner(names[1]) + '</g>';
|
|
3391
|
+
} else {
|
|
3392
|
+
inner = '<g transform="translate(-4,2) scale(0.58)">' + botFaceInner(names[0]) + '</g>'
|
|
3393
|
+
+ '<g transform="translate(22,4) scale(0.58)">' + botFaceInner(names[1]) + '</g>'
|
|
3394
|
+
+ '<g transform="translate(8,16) scale(0.62)">' + botFaceInner(names[2]) + '</g>';
|
|
3395
|
+
}
|
|
3396
|
+
return '<svg class="bot-pfp" viewBox="0 0 64 64" aria-hidden="true" style="--bot-delay:-'
|
|
3397
|
+
+ ((delay % 20) / 8) + 's;--bot-blink:-' + (((delay >>> 3) % 30) / 10) + 's">'
|
|
3398
|
+
+ inner + '</svg>';
|
|
3399
|
+
}
|
|
2909
3400
|
|
|
2910
3401
|
// SEARCH. The input existed with no handler at all — typing in it did
|
|
2911
3402
|
// nothing, which is worse than not shipping it. Debounced because every
|
|
@@ -2937,6 +3428,7 @@ const APP_HTML = `<!doctype html>
|
|
|
2937
3428
|
// seeing WHY something matched, not just that it did.
|
|
2938
3429
|
const hitById = searchHits ? new Map(searchHits.map((h) => [h.id, h])) : null;
|
|
2939
3430
|
knownThreads = list;
|
|
3431
|
+
if (list[0] && list[0].workspacePort) workspacePort = Number(list[0].workspacePort) || workspacePort;
|
|
2940
3432
|
if (!activeId && list.length) activeId = list[0].id;
|
|
2941
3433
|
threadsEl.innerHTML = '';
|
|
2942
3434
|
const shown = hitById
|
|
@@ -2979,11 +3471,11 @@ const APP_HTML = `<!doctype html>
|
|
|
2979
3471
|
// to nothing.
|
|
2980
3472
|
if (t.depth) row.style.paddingLeft = (10 + Math.min(t.depth, 4) * 12) + 'px';
|
|
2981
3473
|
if (t.depth) row.title = 'spawned under ' + (t.rootName || 'a parent');
|
|
2982
|
-
row.innerHTML = '<div class="tavatar"
|
|
3474
|
+
row.innerHTML = '<div class="tavatar">' + botPfp(t.name, t.members) + '</div>' +
|
|
2983
3475
|
'<div class="tmeta"><div class="tname">' + t.name + '</div><div class="tprev">' +
|
|
2984
3476
|
(hitById && hitById.get(t.id) && hitById.get(t.id).snippet
|
|
2985
3477
|
? hitById.get(t.id).snippet
|
|
2986
|
-
: t.awaitingUser ? 'waiting for you' : t.status === 'thinking' ? 'typing…' : (t.preview || '')) + '</div></div>' +
|
|
3478
|
+
: t.awaitingUser ? 'waiting for you' : t.status === 'thinking' ? escapeHtml(t.liveStatus || 'typing…') : (t.preview || '')) + '</div></div>' +
|
|
2987
3479
|
// awaitingUser WINS over thinking: a thread blocked on an approval is
|
|
2988
3480
|
// NOT working, and showing a working indicator there is a lie that
|
|
2989
3481
|
// quietly costs you a subagent nobody knows is stuck.
|
|
@@ -3039,12 +3531,14 @@ const APP_HTML = `<!doctype html>
|
|
|
3039
3531
|
|
|
3040
3532
|
async function loadActiveMessages() {
|
|
3041
3533
|
if (!activeId) return null;
|
|
3042
|
-
|
|
3534
|
+
const data = await (await fetch(API + '/threads/' + activeId)).json();
|
|
3535
|
+
if (data && data.workspacePort) workspacePort = Number(data.workspacePort) || workspacePort;
|
|
3536
|
+
return data;
|
|
3043
3537
|
}
|
|
3044
3538
|
|
|
3045
3539
|
function renderHeader(t) {
|
|
3046
3540
|
document.getElementById('chatHeaderId').innerHTML =
|
|
3047
|
-
'<div class="tavatar"
|
|
3541
|
+
'<div class="tavatar">' + botPfp(t.name, t.members) + '</div>' +
|
|
3048
3542
|
'<div class="hname"><div>' + t.name + '</div><div class="hdir" title="' + escapeHtml(t.dir || '') +
|
|
3049
3543
|
'">' + escapeHtml(t.dir || '') + ' · type /dir <path> to change</div></div>';
|
|
3050
3544
|
setModeButtons(t.runMode || 'ask');
|
|
@@ -3126,11 +3620,12 @@ const APP_HTML = `<!doctype html>
|
|
|
3126
3620
|
w = r.ok ? await r.json() : null;
|
|
3127
3621
|
} catch (e) { w = null; }
|
|
3128
3622
|
walletBody.innerHTML = '';
|
|
3129
|
-
if (!w || (!w.solana && !w.evm)) {
|
|
3623
|
+
if (!w || (!w.solana && !w.evm && w.creditUsd == null)) {
|
|
3130
3624
|
const p = document.createElement('div');
|
|
3131
3625
|
p.className = 'wnote wempty';
|
|
3132
|
-
p.textContent = 'Could not reach the local openzoo proxy on :8402. It may still be starting — try again in a few seconds.';
|
|
3626
|
+
p.textContent = 'Could not reach the local openzoo proxy on :8402. It may still be starting — try again in a few seconds. You can still subscribe with a card below.';
|
|
3133
3627
|
walletBody.appendChild(p);
|
|
3628
|
+
renderSubLane(w && w.subscription ? w.subscription : null);
|
|
3134
3629
|
return;
|
|
3135
3630
|
}
|
|
3136
3631
|
if (w.creditUsd != null && w.creditUsd !== '') {
|
|
@@ -3144,7 +3639,17 @@ const APP_HTML = `<!doctype html>
|
|
|
3144
3639
|
}
|
|
3145
3640
|
if (w.solana) walletBody.appendChild(walletRow('Solana', w.solana));
|
|
3146
3641
|
if (w.evm) walletBody.appendChild(walletRow('Base / RH', w.evm));
|
|
3147
|
-
if (w.
|
|
3642
|
+
if (Array.isArray(w.holdings) && w.holdings.length) {
|
|
3643
|
+
const b = document.createElement('div');
|
|
3644
|
+
b.className = 'wbal';
|
|
3645
|
+
b.textContent = w.holdings.filter((h) => h.chain === 'solana' || Number(h.ui) > 0).map((h) => {
|
|
3646
|
+
const qty = (h.ui) + ' ' + h.symbol + (h.chain && h.chain !== 'solana' ? ' (' + h.chain + ')' : '');
|
|
3647
|
+
if (h.usd == null || !isFinite(Number(h.usd))) return qty;
|
|
3648
|
+
const n = Number(h.usd);
|
|
3649
|
+
return qty + ' ($' + (n >= 0.01 || n === 0 ? n.toFixed(2) : n.toFixed(4)) + ')';
|
|
3650
|
+
}).join('\\n');
|
|
3651
|
+
walletBody.appendChild(b);
|
|
3652
|
+
} else if (w.balances) {
|
|
3148
3653
|
const b = document.createElement('div');
|
|
3149
3654
|
b.className = 'wbal';
|
|
3150
3655
|
b.textContent = w.balances;
|
|
@@ -3154,29 +3659,193 @@ const APP_HTML = `<!doctype html>
|
|
|
3154
3659
|
note.className = 'wnote';
|
|
3155
3660
|
// funded === false is the genuinely-empty case. Undefined means the proxy
|
|
3156
3661
|
// did not say, and guessing "empty" there would send someone to top up a
|
|
3157
|
-
// wallet that is fine.
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3662
|
+
// wallet that is fine. A live subscription is the other pay lane — do not
|
|
3663
|
+
// nag an empty wallet as fatal when calls already skip x402.
|
|
3664
|
+
const subOn = w.subscription && w.subscription.active;
|
|
3665
|
+
note.textContent = subOn
|
|
3666
|
+
? ('Wallet is optional while a subscription is active. ' + (w.funding || ''))
|
|
3667
|
+
: (w.funded === false
|
|
3668
|
+
? 'This wallet is EMPTY — wallet/x402 calls will fail with HTTP 402 until you fund the addresses above, or subscribe with a card below. ' + (w.funding || '')
|
|
3669
|
+
: (w.funding || ''));
|
|
3670
|
+
if (w.funded === false && !subOn) note.classList.add('wempty');
|
|
3162
3671
|
if (note.textContent.trim()) walletBody.appendChild(note);
|
|
3672
|
+
renderSubLane(w.subscription || null);
|
|
3673
|
+
}
|
|
3674
|
+
var subPollTimer = null;
|
|
3675
|
+
var subBuying = null;
|
|
3676
|
+
function setSubNote(text, empty) {
|
|
3677
|
+
const el = document.getElementById('subNote');
|
|
3678
|
+
if (!el) return;
|
|
3679
|
+
el.textContent = text || '';
|
|
3680
|
+
el.className = empty ? 'wnote wempty' : 'wnote';
|
|
3681
|
+
}
|
|
3682
|
+
function renderSubLane(sub) {
|
|
3683
|
+
const status = document.getElementById('subStatus');
|
|
3684
|
+
const forget = document.getElementById('subForgetBtn');
|
|
3685
|
+
if (status) {
|
|
3686
|
+
status.innerHTML = '';
|
|
3687
|
+
if (sub && sub.active) {
|
|
3688
|
+
const p = document.createElement('div');
|
|
3689
|
+
p.className = 'wsubon';
|
|
3690
|
+
p.textContent = sub.label || (sub.tierName || 'Subscription') + ' · no x402';
|
|
3691
|
+
status.appendChild(p);
|
|
3692
|
+
}
|
|
3693
|
+
}
|
|
3694
|
+
if (forget) forget.hidden = !(sub && sub.active);
|
|
3695
|
+
loadSubTiers();
|
|
3696
|
+
}
|
|
3697
|
+
async function loadSubTiers() {
|
|
3698
|
+
const box = document.getElementById('subTiers');
|
|
3699
|
+
if (!box) return;
|
|
3700
|
+
let body = null;
|
|
3701
|
+
try {
|
|
3702
|
+
const r = await fetch(API + '/billing/tiers');
|
|
3703
|
+
body = r.ok ? await r.json() : null;
|
|
3704
|
+
} catch (e) { body = null; }
|
|
3705
|
+
box.innerHTML = '';
|
|
3706
|
+
const tiers = body && body.ok && Array.isArray(body.tiers) ? body.tiers : [];
|
|
3707
|
+
if (!tiers.length) {
|
|
3708
|
+
const p = document.createElement('div');
|
|
3709
|
+
p.className = 'wnote wempty';
|
|
3710
|
+
p.textContent = 'Could not load live plans from zoo.openzoo.fun — try again, or use the full subscriptions page.';
|
|
3711
|
+
box.appendChild(p);
|
|
3712
|
+
return;
|
|
3713
|
+
}
|
|
3714
|
+
tiers.forEach(function (t) {
|
|
3715
|
+
const art = document.createElement('div');
|
|
3716
|
+
art.className = 'wtier' + (t.id === 'pro' ? ' hot' : '');
|
|
3717
|
+
art.setAttribute('data-tier', t.id);
|
|
3718
|
+
const tag = document.createElement('div');
|
|
3719
|
+
tag.className = 'wtag';
|
|
3720
|
+
tag.textContent = t.id === 'pro' ? 'Most teams want this' : '';
|
|
3721
|
+
const name = document.createElement('div');
|
|
3722
|
+
name.className = 'wtn';
|
|
3723
|
+
name.textContent = t.name || t.id;
|
|
3724
|
+
const price = document.createElement('div');
|
|
3725
|
+
price.className = 'wtp';
|
|
3726
|
+
price.textContent = '$' + ((Number(t.monthlyCents) || 0) / 100).toFixed(0) + '/mo';
|
|
3727
|
+
const share = document.createElement('div');
|
|
3728
|
+
share.className = 'wts';
|
|
3729
|
+
share.textContent = (t.savingsSharePct != null ? t.savingsSharePct : '?') + '% savings share';
|
|
3730
|
+
const blurb = document.createElement('div');
|
|
3731
|
+
blurb.className = 'wtb';
|
|
3732
|
+
blurb.textContent = t.blurb || '';
|
|
3733
|
+
const btn = document.createElement('button');
|
|
3734
|
+
btn.type = 'button';
|
|
3735
|
+
btn.textContent = subBuying === t.id ? 'Opening checkout…' : ('Get ' + (t.name || t.id));
|
|
3736
|
+
btn.disabled = subBuying != null;
|
|
3737
|
+
btn.addEventListener('click', function () { buyTier(t.id); });
|
|
3738
|
+
art.append(tag, name, price, share, blurb, btn);
|
|
3739
|
+
box.appendChild(art);
|
|
3740
|
+
});
|
|
3741
|
+
}
|
|
3742
|
+
function openSystemBrowser(url) {
|
|
3743
|
+
// Electron's setWindowOpenHandler routes target=_blank to shell.openExternal.
|
|
3744
|
+
// Never load Stripe inside this window.
|
|
3745
|
+
window.open(url, '_blank', 'noopener,noreferrer');
|
|
3746
|
+
}
|
|
3747
|
+
function stopSubPoll() {
|
|
3748
|
+
if (subPollTimer) { clearInterval(subPollTimer); subPollTimer = null; }
|
|
3749
|
+
}
|
|
3750
|
+
function startSubPoll(sessionId) {
|
|
3751
|
+
stopSubPoll();
|
|
3752
|
+
var tries = 0;
|
|
3753
|
+
async function tick() {
|
|
3754
|
+
tries += 1;
|
|
3755
|
+
try {
|
|
3756
|
+
const r = await fetch(API + '/billing/key?session=' + encodeURIComponent(sessionId));
|
|
3757
|
+
const j = r.ok ? await r.json() : null;
|
|
3758
|
+
if (j && j.saved) {
|
|
3759
|
+
stopSubPoll();
|
|
3760
|
+
setSubNote('Subscription key saved · no x402');
|
|
3761
|
+
await openWallet();
|
|
3762
|
+
return;
|
|
3763
|
+
}
|
|
3764
|
+
if (j && j.pending) setSubNote('Waiting for Stripe to confirm…');
|
|
3765
|
+
else if (j && j.error && j.error !== 'session required') setSubNote(j.error, true);
|
|
3766
|
+
} catch (e) { /* keep polling */ }
|
|
3767
|
+
if (tries >= 60) {
|
|
3768
|
+
stopSubPoll();
|
|
3769
|
+
setSubNote('Still waiting on Stripe — paste the key from the success page if you have it.');
|
|
3770
|
+
}
|
|
3771
|
+
}
|
|
3772
|
+
subPollTimer = setInterval(tick, 2000);
|
|
3773
|
+
tick();
|
|
3774
|
+
}
|
|
3775
|
+
async function buyTier(tier) {
|
|
3776
|
+
subBuying = tier;
|
|
3777
|
+
setSubNote('');
|
|
3778
|
+
loadSubTiers();
|
|
3779
|
+
try {
|
|
3780
|
+
const r = await fetch(API + '/billing/checkout', {
|
|
3781
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
3782
|
+
body: JSON.stringify({ tier: tier }),
|
|
3783
|
+
});
|
|
3784
|
+
const j = await r.json();
|
|
3785
|
+
if (!j || !j.ok || !j.url) throw new Error((j && j.error) || 'checkout failed');
|
|
3786
|
+
openSystemBrowser(j.url);
|
|
3787
|
+
setSubNote('Checkout opened in your system browser. This window will pick up the key when Stripe confirms.');
|
|
3788
|
+
if (j.sessionId) startSubPoll(j.sessionId);
|
|
3789
|
+
} catch (e) {
|
|
3790
|
+
setSubNote(e.message || String(e), true);
|
|
3791
|
+
}
|
|
3792
|
+
subBuying = null;
|
|
3793
|
+
loadSubTiers();
|
|
3794
|
+
}
|
|
3795
|
+
async function savePastedSub() {
|
|
3796
|
+
const inp = document.getElementById('subKeyInp');
|
|
3797
|
+
const paste = inp ? inp.value.trim() : '';
|
|
3798
|
+
if (!paste) { setSubNote('Paste a key or the billing/done URL.', true); return; }
|
|
3799
|
+
try {
|
|
3800
|
+
const r = await fetch(API + '/billing/key', {
|
|
3801
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
3802
|
+
body: JSON.stringify({ paste: paste }),
|
|
3803
|
+
});
|
|
3804
|
+
const j = await r.json();
|
|
3805
|
+
if (j && j.pending && j.session) {
|
|
3806
|
+
setSubNote('Waiting for Stripe to confirm…');
|
|
3807
|
+
startSubPoll(j.session);
|
|
3808
|
+
return;
|
|
3809
|
+
}
|
|
3810
|
+
if (!j || !j.saved) throw new Error((j && j.error) || 'could not save key');
|
|
3811
|
+
if (inp) inp.value = '';
|
|
3812
|
+
setSubNote('Subscription key saved · no x402');
|
|
3813
|
+
await openWallet();
|
|
3814
|
+
} catch (e) {
|
|
3815
|
+
setSubNote(e.message || String(e), true);
|
|
3816
|
+
}
|
|
3817
|
+
}
|
|
3818
|
+
async function forgetSub() {
|
|
3819
|
+
try {
|
|
3820
|
+
await fetch(API + '/billing/key', { method: 'DELETE' });
|
|
3821
|
+
} catch (e) { /* still refresh */ }
|
|
3822
|
+
setSubNote('Subscription key removed. Wallet/x402 is the pay method again.');
|
|
3823
|
+
await openWallet();
|
|
3163
3824
|
}
|
|
3164
3825
|
document.getElementById('walletBtn').addEventListener('click', openWallet);
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3826
|
+
const subKeyBtn = document.getElementById('subKeyBtn');
|
|
3827
|
+
if (subKeyBtn) subKeyBtn.addEventListener('click', savePastedSub);
|
|
3828
|
+
const subForgetBtn = document.getElementById('subForgetBtn');
|
|
3829
|
+
if (subForgetBtn) subForgetBtn.addEventListener('click', forgetSub);
|
|
3830
|
+
const subKeyInp = document.getElementById('subKeyInp');
|
|
3831
|
+
if (subKeyInp) subKeyInp.addEventListener('keydown', function (e) {
|
|
3832
|
+
if (e.key === 'Enter') { e.preventDefault(); savePastedSub(); }
|
|
3833
|
+
});
|
|
3834
|
+
// First launch: if the burner is empty AND there is no subscription, open
|
|
3835
|
+
// the wallet once so they see addresses — and the card lane. localStorage
|
|
3836
|
+
// so a funded session, a saved key, or a dismiss does not keep popping it.
|
|
3168
3837
|
(async function maybeOpenWalletOnce() {
|
|
3169
3838
|
if (localStorage.getItem('openzoo.wallet.seen')) return;
|
|
3170
3839
|
for (let i = 0; i < 8; i++) {
|
|
3171
3840
|
try {
|
|
3172
3841
|
const r = await fetch(API + '/wallet');
|
|
3173
3842
|
const w = r.ok ? await r.json() : null;
|
|
3174
|
-
if (!w || (!w.solana && !w.evm)) {
|
|
3843
|
+
if (!w || (!w.solana && !w.evm && !(w.subscription && w.subscription.active))) {
|
|
3175
3844
|
await new Promise((res) => setTimeout(res, 400));
|
|
3176
3845
|
continue;
|
|
3177
3846
|
}
|
|
3178
3847
|
localStorage.setItem('openzoo.wallet.seen', '1');
|
|
3179
|
-
if (w.funded === false) await openWallet();
|
|
3848
|
+
if (w.funded === false && !(w.subscription && w.subscription.active)) await openWallet();
|
|
3180
3849
|
return;
|
|
3181
3850
|
} catch (e) {
|
|
3182
3851
|
await new Promise((res) => setTimeout(res, 400));
|
|
@@ -3217,6 +3886,121 @@ const APP_HTML = `<!doctype html>
|
|
|
3217
3886
|
document.getElementById('modeAuto').addEventListener('click', () => setMode('auto'));
|
|
3218
3887
|
|
|
3219
3888
|
function escapeHtml(s) { return s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); }
|
|
3889
|
+
function stripThinkTags(s) {
|
|
3890
|
+
s = String(s == null ? '' : s);
|
|
3891
|
+
s = s.replace(/<think(?:ing)?\\b[^>]*>[\\s\\S]*?<\\/think(?:ing)?>/gi, '');
|
|
3892
|
+
s = s.replace(/<think(?:ing)?\\b[^>]*>[\\s\\S]*$/i, '');
|
|
3893
|
+
s = s.replace(/<\\/think(?:ing)?>/gi, '');
|
|
3894
|
+
return s.replace(/^\\n+|\\n+$/g, '').trim();
|
|
3895
|
+
}
|
|
3896
|
+
function clientWorkspaceUrl(rel) {
|
|
3897
|
+
if (!workspacePort || !activeId) return '';
|
|
3898
|
+
rel = String(rel || '').replace(/^\\/+/, '');
|
|
3899
|
+
return 'http://localhost:' + workspacePort + '/' + activeId + '/' + rel;
|
|
3900
|
+
}
|
|
3901
|
+
function relFromDiskPath(p) {
|
|
3902
|
+
p = String(p || '');
|
|
3903
|
+
if (p.indexOf('file://') === 0) p = decodeURIComponent(p.slice(7));
|
|
3904
|
+
const t = knownThreads.find(function (x) { return x.id === activeId; });
|
|
3905
|
+
const dir = (t && t.dir) || '';
|
|
3906
|
+
if (dir && (p === dir || p.indexOf(dir + '/') === 0)) {
|
|
3907
|
+
return p.slice(dir.length).replace(/^\\/+/, '');
|
|
3908
|
+
}
|
|
3909
|
+
const marker = '/grokui-workspace/';
|
|
3910
|
+
const i = p.indexOf(marker);
|
|
3911
|
+
if (i >= 0) return p.slice(i + marker.length);
|
|
3912
|
+
const base = p.split('/').pop() || '';
|
|
3913
|
+
return /\\.(html?|HTML?)$/.test(base) ? base : '';
|
|
3914
|
+
}
|
|
3915
|
+
function servedHrefForPath(p) {
|
|
3916
|
+
p = String(p || '');
|
|
3917
|
+
const t = knownThreads.find(function (x) { return x.id === activeId; });
|
|
3918
|
+
const dir = (t && t.dir) || '';
|
|
3919
|
+
if (dir && p === dir) return clientWorkspaceUrl('');
|
|
3920
|
+
if (/\\.(html?|HTML?)$/.test(p) || p.indexOf('grokui-workspace') >= 0 || (dir && p.indexOf(dir) === 0)) {
|
|
3921
|
+
return clientWorkspaceUrl(relFromDiskPath(p));
|
|
3922
|
+
}
|
|
3923
|
+
return '';
|
|
3924
|
+
}
|
|
3925
|
+
// Turn "Wrote foo.html" and /Users/.../foo.html into the live localhost URL
|
|
3926
|
+
// the workspace server already exposes — never a file:// Electron blocks.
|
|
3927
|
+
function linkWorkspacePaths(o) {
|
|
3928
|
+
o = o.replace(/\\b(Wrote|Edited)\\s+([^\\n<]+?)\\s+\\(/g, function (m, verb, file) {
|
|
3929
|
+
const f = file.trim();
|
|
3930
|
+
if (!/\\.(html?|HTML?)$/.test(f)) return m;
|
|
3931
|
+
const url = clientWorkspaceUrl(f);
|
|
3932
|
+
if (!url) return m;
|
|
3933
|
+
return verb + ' <a href="' + url + '" target="_blank" rel="noopener">' + f + '</a> (';
|
|
3934
|
+
});
|
|
3935
|
+
o = o.replace(/\\b(MULTIEDIT)\\s+([^\\s:<]+\\.(?:html?|HTML?))/g, function (m, verb, file) {
|
|
3936
|
+
const url = clientWorkspaceUrl(file);
|
|
3937
|
+
if (!url) return m;
|
|
3938
|
+
return verb + ' <a href="' + url + '" target="_blank" rel="noopener">' + file + '</a>';
|
|
3939
|
+
});
|
|
3940
|
+
o = o.replace(/file:\\/\\/([^\\s<)]+)/g, function (m, raw) {
|
|
3941
|
+
return servedHrefForPath(decodeURIComponent(raw)) || m;
|
|
3942
|
+
});
|
|
3943
|
+
o = o.replace(/(^|[\\s(])((?:\\/Users\\/|\\/home\\/|\\/opt\\/|\\/workspace\\/|\\/tmp\\/|\\/var\\/|~\\/)[^\\s<)]+)/g, function (m, pre, raw) {
|
|
3944
|
+
const punct = /[.,;:]+$/.exec(raw);
|
|
3945
|
+
const p = punct ? raw.slice(0, -punct[0].length) : raw;
|
|
3946
|
+
const href = servedHrefForPath(p);
|
|
3947
|
+
if (!href) return m;
|
|
3948
|
+
return pre + '<a href="' + href + '" target="_blank" rel="noopener">' + p + '</a>' + (punct ? punct[0] : '');
|
|
3949
|
+
});
|
|
3950
|
+
return o;
|
|
3951
|
+
}
|
|
3952
|
+
function htmlPreviewUrl(text) {
|
|
3953
|
+
const s = String(text || '');
|
|
3954
|
+
let m = /https?:\\/\\/localhost:\\d+\\/\\S+\\.(?:html?|HTML?)/.exec(s);
|
|
3955
|
+
if (m) return m[0].replace(/[.,;)]+$/, '');
|
|
3956
|
+
m = /(?:Preview:|Serving at)\\s+(https?:\\/\\/localhost:\\d+\\/\\S+)/.exec(s);
|
|
3957
|
+
if (m) {
|
|
3958
|
+
const u = m[1].replace(/[.,;)]+$/, '');
|
|
3959
|
+
if (/\\.(html?|HTML?)/.test(u) || /\\/[0-9a-fA-F-]{36}\\/?$/.test(u)) return u;
|
|
3960
|
+
}
|
|
3961
|
+
m = /\\b(?:Wrote|Edited|MULTIEDIT)\\s+([^\\n]+?)\\s*(?:\\(|:)/.exec(s);
|
|
3962
|
+
if (m && /\\.(html?|HTML?)$/.test(m[1].trim())) return clientWorkspaceUrl(m[1].trim());
|
|
3963
|
+
m = /(?:\\/Users\\/|\\/home\\/|\\/workspace\\/)\\S+\\.(?:html?|HTML?)/.exec(s);
|
|
3964
|
+
if (m) return servedHrefForPath(m[0].replace(/[.,;)]+$/, '')) || '';
|
|
3965
|
+
return '';
|
|
3966
|
+
}
|
|
3967
|
+
function htmlPreviewKey(text, url) {
|
|
3968
|
+
const bytes = /\\((\\d+)\\s+bytes/.exec(text) || /->\\s+(\\d+)\\s+bytes/.exec(text);
|
|
3969
|
+
return url + '#' + (bytes ? bytes[1] : '0');
|
|
3970
|
+
}
|
|
3971
|
+
let parkedPreviews = {};
|
|
3972
|
+
function parkPreviews() {
|
|
3973
|
+
const parked = {};
|
|
3974
|
+
const nodes = log.querySelectorAll('.html-preview-wrap');
|
|
3975
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
3976
|
+
const el = nodes[i];
|
|
3977
|
+
const k = el.getAttribute('data-preview');
|
|
3978
|
+
if (k) parked[k] = el;
|
|
3979
|
+
el.remove();
|
|
3980
|
+
}
|
|
3981
|
+
return parked;
|
|
3982
|
+
}
|
|
3983
|
+
function previewFrame(url, key) {
|
|
3984
|
+
const existing = parkedPreviews[key];
|
|
3985
|
+
if (existing) { delete parkedPreviews[key]; return existing; }
|
|
3986
|
+
const wrap = document.createElement('div');
|
|
3987
|
+
wrap.className = 'html-preview-wrap';
|
|
3988
|
+
wrap.setAttribute('data-preview', key);
|
|
3989
|
+
const open = document.createElement('a');
|
|
3990
|
+
open.className = 'html-preview-open';
|
|
3991
|
+
open.href = url;
|
|
3992
|
+
open.target = '_blank';
|
|
3993
|
+
open.rel = 'noopener';
|
|
3994
|
+
open.textContent = 'open';
|
|
3995
|
+
const frame = document.createElement('iframe');
|
|
3996
|
+
frame.className = 'html-preview';
|
|
3997
|
+
frame.src = url;
|
|
3998
|
+
frame.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-pointer-lock allow-forms');
|
|
3999
|
+
frame.title = 'preview';
|
|
4000
|
+
wrap.appendChild(open);
|
|
4001
|
+
wrap.appendChild(frame);
|
|
4002
|
+
return wrap;
|
|
4003
|
+
}
|
|
3220
4004
|
// Inline span-level markdown. Runs AFTER escapeHtml, so every tag below is
|
|
3221
4005
|
// one we created — model output can never inject its own.
|
|
3222
4006
|
function mdInline(s) {
|
|
@@ -3225,6 +4009,7 @@ const APP_HTML = `<!doctype html>
|
|
|
3225
4009
|
o = o.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');
|
|
3226
4010
|
o = o.replace(/(^|[^*])\\*([^*\\n]+)\\*/g, '$1<em>$2</em>');
|
|
3227
4011
|
o = o.replace(/\\[([^\\]]+)\\]\\((https?:[^)\\s]+)\\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
|
4012
|
+
o = linkWorkspacePaths(o);
|
|
3228
4013
|
// bare URLs, but only at a boundary — inside href="..." the preceding
|
|
3229
4014
|
// char is a quote, so links we just built are left alone
|
|
3230
4015
|
o = o.replace(/(^|[\\s(])(https?:\\/\\/[^\\s<)]+)/g, '$1<a href="$2" target="_blank" rel="noopener">$2</a>');
|
|
@@ -3491,11 +4276,12 @@ const APP_HTML = `<!doctype html>
|
|
|
3491
4276
|
|
|
3492
4277
|
let lastSpeaker = null;
|
|
3493
4278
|
function addRow(who, text, color, name, run, images) {
|
|
4279
|
+
if (who === 'bot') text = stripThinkTags(text);
|
|
3494
4280
|
const speakerKey = who + '|' + name;
|
|
3495
4281
|
if (who === 'bot' && speakerKey !== lastSpeaker) {
|
|
3496
4282
|
const hdr = document.createElement('div');
|
|
3497
4283
|
hdr.className = 'hdr';
|
|
3498
|
-
hdr.innerHTML = '<span class="avatar"
|
|
4284
|
+
hdr.innerHTML = '<span class="avatar">' + botPfp(name) + '</span><span>' + name + '</span>';
|
|
3499
4285
|
log.appendChild(hdr);
|
|
3500
4286
|
}
|
|
3501
4287
|
lastSpeaker = speakerKey;
|
|
@@ -3562,6 +4348,10 @@ const APP_HTML = `<!doctype html>
|
|
|
3562
4348
|
}
|
|
3563
4349
|
const textEl = document.createElement('div');
|
|
3564
4350
|
textEl.innerHTML = renderMentions(text);
|
|
4351
|
+
if (who === 'bot') {
|
|
4352
|
+
const preview = htmlPreviewUrl(text);
|
|
4353
|
+
if (preview) textEl.appendChild(previewFrame(preview, htmlPreviewKey(text, preview)));
|
|
4354
|
+
}
|
|
3565
4355
|
bubble.appendChild(textEl);
|
|
3566
4356
|
row.appendChild(bubble);
|
|
3567
4357
|
// Copy the message SOURCE, not rendered HTML — markdown, code fences and
|
|
@@ -3582,6 +4372,7 @@ const APP_HTML = `<!doctype html>
|
|
|
3582
4372
|
log.appendChild(row);
|
|
3583
4373
|
}
|
|
3584
4374
|
|
|
4375
|
+
let lastRenderKey = '';
|
|
3585
4376
|
async function render() {
|
|
3586
4377
|
const t = knownThreads.find((x) => x.id === activeId);
|
|
3587
4378
|
if (!t) return;
|
|
@@ -3589,9 +4380,18 @@ const APP_HTML = `<!doctype html>
|
|
|
3589
4380
|
inp.placeholder = 'Message ' + t.name;
|
|
3590
4381
|
const full = await loadActiveMessages();
|
|
3591
4382
|
if (!full || full.id !== activeId) return;
|
|
4383
|
+
const renderKey = String(workspacePort) + '|' + full.id + '|' + full.status + '|' + (full.history || []).map(function (h) {
|
|
4384
|
+
return [h.who, h.text, h.runStatus, h.runOutput, (h.images || []).join(',')].join('|#');
|
|
4385
|
+
}).join('||');
|
|
4386
|
+
if (renderKey === lastRenderKey) {
|
|
4387
|
+
if (streamBuf) paintStream();
|
|
4388
|
+
return;
|
|
4389
|
+
}
|
|
4390
|
+
lastRenderKey = renderKey;
|
|
3592
4391
|
// only re-pin to bottom if the reader was already there — otherwise a
|
|
3593
4392
|
// background poll (tick() runs every 1.2s) yanks them back mid-scroll
|
|
3594
4393
|
const wasNearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 80;
|
|
4394
|
+
parkedPreviews = parkPreviews();
|
|
3595
4395
|
log.innerHTML = '';
|
|
3596
4396
|
lastSpeaker = null;
|
|
3597
4397
|
for (const h of full.history) {
|
|
@@ -3599,11 +4399,12 @@ const APP_HTML = `<!doctype html>
|
|
|
3599
4399
|
h.runId ? { id: h.runId, status: h.runStatus, output: h.runOutput } : undefined, h.images);
|
|
3600
4400
|
}
|
|
3601
4401
|
if (full.status === 'thinking') {
|
|
4402
|
+
if (full.liveStatus) streamStatus = full.liveStatus;
|
|
3602
4403
|
addRow('bot', streamBuf || '…', t.color, t.name);
|
|
3603
4404
|
// Tag the live bubble so deltas can repaint just this node instead of
|
|
3604
4405
|
// re-rendering (and re-fetching) the whole thread on every token.
|
|
3605
4406
|
const b = log.querySelector('.row:last-child .bubble');
|
|
3606
|
-
if (b) b.id = 'streamBubble';
|
|
4407
|
+
if (b) { b.id = 'streamBubble'; paintStream(); }
|
|
3607
4408
|
}
|
|
3608
4409
|
if (wasNearBottom) log.scrollTop = log.scrollHeight;
|
|
3609
4410
|
}
|
|
@@ -3612,13 +4413,28 @@ const APP_HTML = `<!doctype html>
|
|
|
3612
4413
|
// The server has always been able to stream; /drive just never asked for it,
|
|
3613
4414
|
// so a turn showed "…" for its whole duration and then arrived in one lump.
|
|
3614
4415
|
let streamBuf = '';
|
|
4416
|
+
let streamStatus = '';
|
|
3615
4417
|
let es = null, esId = null;
|
|
4418
|
+
function liveBubbleHtml() {
|
|
4419
|
+
if (streamBuf) {
|
|
4420
|
+
const trail = streamStatus && /^(RUN|READ|WRITE|EDIT|SPAWN|SEND|GLOB|GREP|MCP|FETCH|TODO|SERVE|PING|PEEK|MULTIEDIT|NOTEBOOK):/i.test(streamStatus)
|
|
4421
|
+
? '<span class="ttrail">' + escapeHtml(streamStatus) + '</span>' : '';
|
|
4422
|
+
return escapeHtml(stripThinkTags(streamBuf)) + trail;
|
|
4423
|
+
}
|
|
4424
|
+
const dots = '<span class="dots"><span></span><span></span><span></span></span>';
|
|
4425
|
+
const st = streamStatus ? '<span class="tstatus">' + escapeHtml(streamStatus) + '</span>' : '';
|
|
4426
|
+
return dots + (st ? ' ' + st : '');
|
|
4427
|
+
}
|
|
3616
4428
|
function paintStream() {
|
|
3617
4429
|
const b = document.getElementById('streamBubble');
|
|
3618
4430
|
if (!b) { render(); return; }
|
|
3619
|
-
//
|
|
3620
|
-
//
|
|
3621
|
-
|
|
4431
|
+
// Deltas stay as text; a silent wait paints dots + one mutating status
|
|
4432
|
+
// line so a 20–40s pay/model wait is obviously alive.
|
|
4433
|
+
if (streamBuf && !(streamStatus && /^(RUN|READ|WRITE|EDIT|SPAWN|SEND|GLOB|GREP|MCP|FETCH|TODO|SERVE|PING|PEEK|MULTIEDIT|NOTEBOOK):/i.test(streamStatus))) {
|
|
4434
|
+
b.textContent = streamBuf;
|
|
4435
|
+
} else {
|
|
4436
|
+
b.innerHTML = liveBubbleHtml();
|
|
4437
|
+
}
|
|
3622
4438
|
if (log.scrollHeight - log.scrollTop - log.clientHeight < 140) log.scrollTop = log.scrollHeight;
|
|
3623
4439
|
}
|
|
3624
4440
|
function connectStream(id) {
|
|
@@ -3626,13 +4442,15 @@ const APP_HTML = `<!doctype html>
|
|
|
3626
4442
|
if (es) es.close();
|
|
3627
4443
|
esId = id;
|
|
3628
4444
|
streamBuf = '';
|
|
4445
|
+
streamStatus = '';
|
|
3629
4446
|
es = new EventSource('/stream/' + id); // EventSource reconnects on its own
|
|
3630
4447
|
es.onmessage = (e) => {
|
|
3631
4448
|
let ev;
|
|
3632
4449
|
try { ev = JSON.parse(e.data); } catch { return; }
|
|
3633
|
-
if (ev.type === 'start') { streamBuf = ''; paintStream(); }
|
|
4450
|
+
if (ev.type === 'start') { streamBuf = ''; streamStatus = ev.detail || 'waiting on model…'; paintStream(); }
|
|
4451
|
+
else if (ev.type === 'status') { streamStatus = ev.detail || streamStatus; paintStream(); }
|
|
3634
4452
|
else if (ev.type === 'delta') { streamBuf += ev.delta || ''; paintStream(); }
|
|
3635
|
-
else if (ev.type === 'final' || ev.type === 'run-pending') { streamBuf = ''; render(); }
|
|
4453
|
+
else if (ev.type === 'final' || ev.type === 'run-pending') { streamBuf = ''; streamStatus = ''; render(); }
|
|
3636
4454
|
};
|
|
3637
4455
|
es.onerror = () => { /* EventSource retries; the 1.2s poll is the backstop */ };
|
|
3638
4456
|
}
|
|
@@ -3828,7 +4646,7 @@ const APP_HTML = `<!doctype html>
|
|
|
3828
4646
|
composeList.innerHTML = '';
|
|
3829
4647
|
const createRow = document.createElement('div');
|
|
3830
4648
|
createRow.className = 'crow';
|
|
3831
|
-
createRow.innerHTML = '<div class="tavatar
|
|
4649
|
+
createRow.innerHTML = '<div class="tavatar tavatar-sm tavatar-plus">+</div>' +
|
|
3832
4650
|
'<div>Create new Bot' + (q ? ': ' + escapeHtml(composeInp.value.trim()) : '') + '</div>' +
|
|
3833
4651
|
'<div class="kbd"><kbd>⌘</kbd><kbd>1</kbd></div>';
|
|
3834
4652
|
createRow.addEventListener('click', async () => {
|
|
@@ -3843,7 +4661,7 @@ const APP_HTML = `<!doctype html>
|
|
|
3843
4661
|
candidates.slice(0, 8).forEach((t, i) => {
|
|
3844
4662
|
const row = document.createElement('div');
|
|
3845
4663
|
row.className = 'crow';
|
|
3846
|
-
row.innerHTML = '<div class="tavatar
|
|
4664
|
+
row.innerHTML = '<div class="tavatar tavatar-sm">' + botPfp(t.name) + '</div>' +
|
|
3847
4665
|
'<div>' + escapeHtml(t.name) + '</div><div class="kbd"><kbd>⌘</kbd><kbd>' + (i + 2) + '</kbd></div>';
|
|
3848
4666
|
row.addEventListener('click', () => addChip(t));
|
|
3849
4667
|
composeList.appendChild(row);
|
|
@@ -3951,6 +4769,16 @@ const APP_HTML = `<!doctype html>
|
|
|
3951
4769
|
const you = await (await fetch(API + '/hud-summary')).json();
|
|
3952
4770
|
const creditEl = document.getElementById('hCredit');
|
|
3953
4771
|
if (creditEl) creditEl.textContent = (you.creditUsd == null) ? '—' : usd(Number(you.creditUsd) || 0);
|
|
4772
|
+
const subRow = document.getElementById('hSubRow');
|
|
4773
|
+
const subEl = document.getElementById('hSub');
|
|
4774
|
+
if (subRow && subEl) {
|
|
4775
|
+
if (you.subscription && you.subscription.active) {
|
|
4776
|
+
subRow.hidden = false;
|
|
4777
|
+
subEl.textContent = you.subscription.label || you.subscription.tierName || 'Subscription key · no x402';
|
|
4778
|
+
} else {
|
|
4779
|
+
subRow.hidden = true;
|
|
4780
|
+
}
|
|
4781
|
+
}
|
|
3954
4782
|
const spent = Number(you.spentUsd) || 0;
|
|
3955
4783
|
const cogs = Number(you.cogsUsd) || 0;
|
|
3956
4784
|
const direct = Number(you.directUsd) || 0;
|
|
@@ -4020,15 +4848,118 @@ const server = http.createServer((req, res) => {
|
|
|
4020
4848
|
try { w = await (await fetch(`${PROXY}/wallet`)).json(); }
|
|
4021
4849
|
catch { /* proxy not up yet — say so rather than render an empty modal */ }
|
|
4022
4850
|
try {
|
|
4023
|
-
const { creditBalance } = await import('./info.js');
|
|
4024
4851
|
w.creditUsd = await creditBalance();
|
|
4025
4852
|
} catch { /* leave credit off if the gateway is down */ }
|
|
4853
|
+
// Subscription is local (~/.openzoo/subscription.json). Merge it here so
|
|
4854
|
+
// an older :8402 that does not yet know about Stripe still shows the lane.
|
|
4855
|
+
w.subscription = subscriptionPublicView();
|
|
4026
4856
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
4027
4857
|
res.end(JSON.stringify(w));
|
|
4028
4858
|
})();
|
|
4029
4859
|
return;
|
|
4030
4860
|
}
|
|
4031
4861
|
|
|
4862
|
+
// Live Stripe plans — never a stale hardcoded $9/$29/$99. Same origin so
|
|
4863
|
+
// the renderer does not have to talk to zoo.openzoo.fun itself.
|
|
4864
|
+
if (req.method === 'GET' && req.url === '/billing/tiers') {
|
|
4865
|
+
(async () => {
|
|
4866
|
+
try {
|
|
4867
|
+
const body = await billingTiers();
|
|
4868
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
4869
|
+
res.end(JSON.stringify(body));
|
|
4870
|
+
} catch (e) {
|
|
4871
|
+
res.writeHead(502, { 'content-type': 'application/json' });
|
|
4872
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
4873
|
+
}
|
|
4874
|
+
})();
|
|
4875
|
+
return;
|
|
4876
|
+
}
|
|
4877
|
+
|
|
4878
|
+
if (req.method === 'POST' && req.url === '/billing/checkout') {
|
|
4879
|
+
const chunks = [];
|
|
4880
|
+
req.on('data', (d) => chunks.push(d));
|
|
4881
|
+
req.on('end', async () => {
|
|
4882
|
+
let tier = '';
|
|
4883
|
+
try { tier = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}').tier || ''; }
|
|
4884
|
+
catch { /* ignore */ }
|
|
4885
|
+
try {
|
|
4886
|
+
const body = await billingCheckout(tier);
|
|
4887
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
4888
|
+
res.end(JSON.stringify(body));
|
|
4889
|
+
} catch (e) {
|
|
4890
|
+
res.writeHead(502, { 'content-type': 'application/json' });
|
|
4891
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
4892
|
+
}
|
|
4893
|
+
});
|
|
4894
|
+
return;
|
|
4895
|
+
}
|
|
4896
|
+
|
|
4897
|
+
// GET ?session= polls the same endpoint the public /billing/done page uses.
|
|
4898
|
+
// On a key, persist it locally and return a public view — never the secret
|
|
4899
|
+
// (this UI can sit on a public box URL).
|
|
4900
|
+
if (req.method === 'GET' && (req.url || '').startsWith('/billing/key')) {
|
|
4901
|
+
(async () => {
|
|
4902
|
+
const q = new URL(req.url, 'http://x').searchParams;
|
|
4903
|
+
const session = q.get('session') || q.get('session_id') || '';
|
|
4904
|
+
try {
|
|
4905
|
+
const body = await fetchBillingKey(session);
|
|
4906
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
4907
|
+
res.end(JSON.stringify(ingestBillingKeyResponse(body, {
|
|
4908
|
+
sessionId: session,
|
|
4909
|
+
tier: q.get('tier') || null,
|
|
4910
|
+
})));
|
|
4911
|
+
} catch (e) {
|
|
4912
|
+
res.writeHead(502, { 'content-type': 'application/json' });
|
|
4913
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
4914
|
+
}
|
|
4915
|
+
})();
|
|
4916
|
+
return;
|
|
4917
|
+
}
|
|
4918
|
+
|
|
4919
|
+
if (req.method === 'POST' && req.url === '/billing/key') {
|
|
4920
|
+
const chunks = [];
|
|
4921
|
+
req.on('data', (d) => chunks.push(d));
|
|
4922
|
+
req.on('end', async () => {
|
|
4923
|
+
let paste = '';
|
|
4924
|
+
let tier = '';
|
|
4925
|
+
try {
|
|
4926
|
+
const j = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
4927
|
+
paste = j.paste || j.key || '';
|
|
4928
|
+
tier = j.tier || '';
|
|
4929
|
+
} catch { /* ignore */ }
|
|
4930
|
+
const parsed = parseSubscriptionPaste(paste);
|
|
4931
|
+
if (parsed.error) {
|
|
4932
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
4933
|
+
res.end(JSON.stringify({ ok: false, error: parsed.error }));
|
|
4934
|
+
return;
|
|
4935
|
+
}
|
|
4936
|
+
if (parsed.session) {
|
|
4937
|
+
try {
|
|
4938
|
+
const body = await fetchBillingKey(parsed.session);
|
|
4939
|
+
const out = ingestBillingKeyResponse(body, { sessionId: parsed.session, tier });
|
|
4940
|
+
if (out.pending) out.session = parsed.session;
|
|
4941
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
4942
|
+
res.end(JSON.stringify(out));
|
|
4943
|
+
} catch (e) {
|
|
4944
|
+
res.writeHead(502, { 'content-type': 'application/json' });
|
|
4945
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
4946
|
+
}
|
|
4947
|
+
return;
|
|
4948
|
+
}
|
|
4949
|
+
saveSubscription({ key: parsed.key, tier: tier || null });
|
|
4950
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
4951
|
+
res.end(JSON.stringify({ ok: true, saved: true, ...subscriptionPublicView() }));
|
|
4952
|
+
});
|
|
4953
|
+
return;
|
|
4954
|
+
}
|
|
4955
|
+
|
|
4956
|
+
if (req.method === 'DELETE' && req.url === '/billing/key') {
|
|
4957
|
+
clearSubscription();
|
|
4958
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
4959
|
+
res.end(JSON.stringify({ ok: true, saved: false, ...subscriptionPublicView() }));
|
|
4960
|
+
return;
|
|
4961
|
+
}
|
|
4962
|
+
|
|
4032
4963
|
// Restart grokui in place — and ACTUALLY PICK UP THE NEW BUILD.
|
|
4033
4964
|
//
|
|
4034
4965
|
// Exiting is the restart: on a production box, box-server's ensureOz() poll
|
|
@@ -4078,13 +5009,13 @@ const server = http.createServer((req, res) => {
|
|
|
4078
5009
|
|
|
4079
5010
|
if (req.method === 'GET' && req.url === '/hud-summary') {
|
|
4080
5011
|
(async () => {
|
|
4081
|
-
let you = { spentUsd: 0, cogsUsd: 0, directUsd: 0, paidCalls: 0, creditUsd: null };
|
|
5012
|
+
let you = { spentUsd: 0, cogsUsd: 0, directUsd: 0, paidCalls: 0, creditUsd: null, chainUsd: null };
|
|
4082
5013
|
try { you = { ...you, ...(await (await fetch('http://127.0.0.1:8402/v1/session')).json()) }; }
|
|
4083
5014
|
catch { /* local proxy not running — HUD shows zeros rather than guessing */ }
|
|
4084
5015
|
try {
|
|
4085
|
-
const { creditBalance } = await import('./info.js');
|
|
4086
5016
|
you.creditUsd = await creditBalance();
|
|
4087
5017
|
} catch { /* credit is advisory */ }
|
|
5018
|
+
you.subscription = subscriptionPublicView();
|
|
4088
5019
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
4089
5020
|
res.end(JSON.stringify(you));
|
|
4090
5021
|
})();
|
|
@@ -4165,7 +5096,11 @@ const server = http.createServer((req, res) => {
|
|
|
4165
5096
|
if (req.method === 'GET' && req.url.startsWith('/threads/')) {
|
|
4166
5097
|
const t = threads.get(req.url.split('/')[2]);
|
|
4167
5098
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
4168
|
-
res.end(t ? JSON.stringify({
|
|
5099
|
+
res.end(t ? JSON.stringify({
|
|
5100
|
+
id: t.id, history: t.history, status: t.status,
|
|
5101
|
+
liveStatus: t.liveStatus || '',
|
|
5102
|
+
workspacePort: workspacePort || 0, dir: t.dir || WORKSPACE_DIR,
|
|
5103
|
+
}) : '{}');
|
|
4169
5104
|
return;
|
|
4170
5105
|
}
|
|
4171
5106
|
if (req.method === 'DELETE' && req.url.startsWith('/threads/')) {
|
|
@@ -4298,3 +5233,8 @@ const server = http.createServer((req, res) => {
|
|
|
4298
5233
|
});
|
|
4299
5234
|
|
|
4300
5235
|
server.listen(PORT, BIND, () => console.log(`[grokui] http://${BIND === '0.0.0.0' ? 'localhost' : BIND}:${PORT}`));
|
|
5236
|
+
|
|
5237
|
+
export {
|
|
5238
|
+
tryDirective, ensureWorkspacePort, isPreviewableRel, previewAck, workspaceFileUrl,
|
|
5239
|
+
parseRun, looksLikeMcpAsBash, stripThinkTags, safeResolveIn, inDir, listDir,
|
|
5240
|
+
};
|