openzoo 0.48.89 → 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 +651 -42
- package/lib/mcp.js +2 -0
- package/lib/pay.js +10 -2
- package/lib/proxy.js +6 -1
- package/lib/subscription.js +207 -0
- package/package.json +1 -1
package/lib/grokui.mjs
CHANGED
|
@@ -14,6 +14,12 @@ import path from 'node:path';
|
|
|
14
14
|
import { adaptiveTopK, brain, brainRace, brainStream, tierModels, MODEL, PROXY, TIER_NAMES } from './podagent.mjs';
|
|
15
15
|
import { peekDirectiveStatus, STALE_THINKING_MS } from './livestatus.js';
|
|
16
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';
|
|
17
23
|
|
|
18
24
|
const PORT = Number(process.env.OZ_GROKUI_PORT || 4173);
|
|
19
25
|
// BIND HOST. Default 127.0.0.1 so the desktop app never exposes a shell-capable
|
|
@@ -28,9 +34,12 @@ const STORE_FILE = path.join(STORE_DIR, 'grokui-threads.json');
|
|
|
28
34
|
// Real but SANDBOXED filesystem access for the bots — each THREAD has its own
|
|
29
35
|
// root dir (default: a dedicated workspace, never the user's whole disk), and
|
|
30
36
|
// the user can point a thread at a real project folder with "/dir <path>" in
|
|
31
|
-
// chat. safeResolveIn
|
|
32
|
-
// (../,
|
|
33
|
-
//
|
|
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.
|
|
34
43
|
// Where a thread's WRITE/READ/RUN/LS/GLOB/GREP are scoped by default.
|
|
35
44
|
//
|
|
36
45
|
// Overridable because a BOX puts uploaded files somewhere else: box-server
|
|
@@ -45,13 +54,48 @@ const WORKSPACE_DIR = process.env.OZ_WORKSPACE_DIR
|
|
|
45
54
|
mkdirSync(WORKSPACE_DIR, { recursive: true });
|
|
46
55
|
function expandHome(p) { return p.startsWith('~') ? path.join(homedir(), p.slice(1)) : p; }
|
|
47
56
|
function dirFor(threadId) { return threads.get(threadId)?.dir || WORKSPACE_DIR; }
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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)) {
|
|
51
66
|
throw new Error("path escapes this thread's directory");
|
|
52
67
|
}
|
|
53
68
|
return full;
|
|
54
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
|
+
}
|
|
55
99
|
const MIME = { html: 'text/html', htm: 'text/html', css: 'text/css', js: 'application/javascript',
|
|
56
100
|
mjs: 'application/javascript', json: 'application/json', png: 'image/png', jpg: 'image/jpeg',
|
|
57
101
|
jpeg: 'image/jpeg', gif: 'image/gif', svg: 'image/svg+xml', txt: 'text/plain', md: 'text/plain' };
|
|
@@ -381,6 +425,18 @@ function loadThreads() {
|
|
|
381
425
|
t.status = 'idle';
|
|
382
426
|
t.liveStatus = '';
|
|
383
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
|
+
}
|
|
384
440
|
threads.set(t.id, t);
|
|
385
441
|
}
|
|
386
442
|
return true;
|
|
@@ -721,6 +777,21 @@ function sanitizeRunCommand(command) {
|
|
|
721
777
|
//
|
|
722
778
|
// Also tolerates the directive being wrapped in a markdown code fence, which
|
|
723
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
|
+
|
|
724
795
|
function parseRun(reply) {
|
|
725
796
|
// NATIVE TOOL-CALL ENVELOPE FIRST. deepseek-v4-pro has real function calling,
|
|
726
797
|
// and when told to emit "RUN: <cmd>" it frequently wraps the call in its own
|
|
@@ -747,10 +818,8 @@ function parseRun(reply) {
|
|
|
747
818
|
// the whole envelope was dropped in silence: the bot then explained what it
|
|
748
819
|
// was "about to run" forever, never running anything. Match the shape of the
|
|
749
820
|
// envelope, not one vendor's spelling of it.
|
|
750
|
-
const
|
|
751
|
-
|
|
752
|
-
const dsml = new RegExp(`<${SEP}DSML[^>]*\\bparameter\\b[^>]*\\bname="${NAME}"[^>]*>([\\s\\S]*?)<\\/${SEP}DSML`, 'i').exec(reply);
|
|
753
|
-
if (dsml) return sanitizeRunCommand(dsml[1]);
|
|
821
|
+
const dsmlCmd = dsmlRunCommand(reply);
|
|
822
|
+
if (dsmlCmd !== undefined) return dsmlCmd && !looksLikeMcpAsBash(dsmlCmd) ? dsmlCmd : null;
|
|
754
823
|
|
|
755
824
|
// SEVERAL "RUN:" LINES IN ONE REPLY RUN BACK TO BACK.
|
|
756
825
|
//
|
|
@@ -782,12 +851,26 @@ function parseRun(reply) {
|
|
|
782
851
|
const fenced = /^```[\w-]*\n([\s\S]*?)```/.exec(cmd.trim());
|
|
783
852
|
if (fenced) cmd = fenced[1];
|
|
784
853
|
else cmd = cmd.replace(/\n```[\s\S]*$/, ''); // trailing fence + any posttext
|
|
854
|
+
cmd = sliceToNextDirective(cmd);
|
|
785
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;
|
|
786
861
|
if (cmd) cmds.push(cmd);
|
|
787
862
|
}
|
|
788
863
|
return cmds.length ? cmds.join('\n') : null;
|
|
789
864
|
}
|
|
790
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
|
+
|
|
791
874
|
// RUN through BASH, not /bin/sh. node's exec() defaults to /bin/sh, which on
|
|
792
875
|
// Debian is dash — so every bash-ism a model writes (`for … do`, `[[ ]]`,
|
|
793
876
|
// arrays, process substitution) dies as
|
|
@@ -1228,7 +1311,10 @@ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '__
|
|
|
1228
1311
|
function walkDir(base, rel = '', out = [], depth = 0) {
|
|
1229
1312
|
if (depth > 12 || out.length > 5000) return out;
|
|
1230
1313
|
let entries = [];
|
|
1231
|
-
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; }
|
|
1232
1318
|
for (const e of entries) {
|
|
1233
1319
|
const r = rel ? path.join(rel, e.name) : e.name;
|
|
1234
1320
|
if (e.isDirectory()) {
|
|
@@ -1626,6 +1712,14 @@ async function tryDirective(reply, originId, onEvent) {
|
|
|
1626
1712
|
// separator-insensitive. That is what makes this safe: "Note:" or "Step 2:"
|
|
1627
1713
|
// never matches a live agent, so ordinary prose is untouched.
|
|
1628
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
|
+
}
|
|
1629
1723
|
// FAN OUT FIRST. Each line is re-entered on its own, so every branch below
|
|
1630
1724
|
// stays single-directive and none of them had to learn about batching.
|
|
1631
1725
|
const batch = [...reply.matchAll(PARALLEL_DIRECTIVE)];
|
|
@@ -1889,8 +1983,8 @@ async function tryDirective(reply, originId, onEvent) {
|
|
|
1889
1983
|
if (ls) {
|
|
1890
1984
|
const rel = ls[1].trim() || '.';
|
|
1891
1985
|
try {
|
|
1892
|
-
const full =
|
|
1893
|
-
const entries =
|
|
1986
|
+
const full = inDir(dirFor(originId), rel);
|
|
1987
|
+
const entries = listDir(dirFor(originId), rel);
|
|
1894
1988
|
if (!entries.length) return `${rel}: (empty)`;
|
|
1895
1989
|
const lines = entries.slice(0, 300).map((e) => {
|
|
1896
1990
|
if (e.isDirectory()) return ` ${e.name}/`;
|
|
@@ -1915,9 +2009,11 @@ async function tryDirective(reply, originId, onEvent) {
|
|
|
1915
2009
|
// becomes `*`, which is what was meant.
|
|
1916
2010
|
const glob = /^(?:GLOB|LS|LIST|DIR|FIND):[ \t]*(.*)$/m.exec(reply);
|
|
1917
2011
|
if (glob) {
|
|
1918
|
-
|
|
2012
|
+
let pattern = glob[1].trim() || '*';
|
|
1919
2013
|
try {
|
|
1920
2014
|
const base = dirFor(originId);
|
|
2015
|
+
const stripped = stripBasePrefix(base, pattern);
|
|
2016
|
+
if (stripped !== null) pattern = stripped || '*';
|
|
1921
2017
|
const re = globToRe(pattern.startsWith('./') ? pattern.slice(2) : pattern);
|
|
1922
2018
|
const hits = walkDir(base).filter((f) => re.test(f) || re.test(path.basename(f)));
|
|
1923
2019
|
if (!hits.length) return `GLOB ${pattern}: no matches`;
|
|
@@ -1936,7 +2032,12 @@ async function tryDirective(reply, originId, onEvent) {
|
|
|
1936
2032
|
try { re = new RegExp(pattern, 'i'); }
|
|
1937
2033
|
catch { return `GREP: ${pattern} isn't a valid regex.`; }
|
|
1938
2034
|
let files = walkDir(base);
|
|
1939
|
-
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
|
+
}
|
|
1940
2041
|
const out = [];
|
|
1941
2042
|
for (const f of files) {
|
|
1942
2043
|
if (out.length > 200) break;
|
|
@@ -2006,7 +2107,14 @@ async function tryDirective(reply, originId, onEvent) {
|
|
|
2006
2107
|
|
|
2007
2108
|
const serve = /^[ \t>*-]*SERVE:\s*(.*)$/m.exec(reply);
|
|
2008
2109
|
if (serve) {
|
|
2009
|
-
|
|
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}`; }
|
|
2010
2118
|
const port = await ensureWorkspacePort();
|
|
2011
2119
|
if (!port) {
|
|
2012
2120
|
return `Serving ${rel || 'index.html'} from ${dirFor(originId)} — waiting for the workspace port to bind.`;
|
|
@@ -2194,6 +2302,7 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2194
2302
|
: (await brain(msgs, t.contextId)).trim();
|
|
2195
2303
|
} catch (e) { r = `error: ${e.message}`; }
|
|
2196
2304
|
if (!stillMine()) return;
|
|
2305
|
+
r = stripThinkTags(r);
|
|
2197
2306
|
const runCmd = parseRun(r);
|
|
2198
2307
|
if (runCmd) {
|
|
2199
2308
|
const command = runCmd;
|
|
@@ -2314,6 +2423,7 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2314
2423
|
reply = `error: ${e.message}`;
|
|
2315
2424
|
}
|
|
2316
2425
|
if (!stillMine()) return;
|
|
2426
|
+
reply = stripThinkTags(reply);
|
|
2317
2427
|
t.messages.push({ role: 'assistant', content: reply });
|
|
2318
2428
|
const runCmd = parseRun(reply);
|
|
2319
2429
|
if (runCmd) {
|
|
@@ -2582,6 +2692,9 @@ function threadSummary(t) {
|
|
|
2582
2692
|
// How many bots sit BELOW this one. The ping-all affordance belongs on
|
|
2583
2693
|
// anyone with a crew, not only on a project root.
|
|
2584
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,
|
|
2585
2698
|
workspacePort: workspacePort || 0 };
|
|
2586
2699
|
}
|
|
2587
2700
|
|
|
@@ -2635,8 +2748,22 @@ const APP_HTML = `<!doctype html>
|
|
|
2635
2748
|
font-size: 13px; }
|
|
2636
2749
|
.trow:hover .tclose { display: flex; }
|
|
2637
2750
|
.tclose:hover { background: #3a3a3c; color: #ececec; }
|
|
2638
|
-
|
|
2639
|
-
|
|
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); } }
|
|
2640
2767
|
.tmeta { min-width: 0; flex: 1; }
|
|
2641
2768
|
.tname { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
2642
2769
|
.tprev { font-size: 12px; color: #8e8e93; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
@@ -2651,11 +2778,13 @@ const APP_HTML = `<!doctype html>
|
|
|
2651
2778
|
animation: twarnpulse 1.6s ease-in-out infinite;
|
|
2652
2779
|
}
|
|
2653
2780
|
@keyframes twarnpulse { 0%,100% { opacity: 1; } 50% { opacity: .45; } }
|
|
2654
|
-
@media (prefers-reduced-motion: reduce) {
|
|
2781
|
+
@media (prefers-reduced-motion: reduce) {
|
|
2782
|
+
.twarn, .bot-pfp .bot-bob, .bot-pfp .bot-eyes { animation: none; }
|
|
2783
|
+
}
|
|
2655
2784
|
#main { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column; height: 100vh; }
|
|
2656
2785
|
#chatHeader { padding: 14px 20px; border-bottom: 1px solid #1c1c1e; display: flex; align-items: center;
|
|
2657
2786
|
flex-wrap: wrap; gap: 8px 10px; font-weight: 600; }
|
|
2658
|
-
#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; }
|
|
2659
2788
|
/* Title shrinks and wraps; the spend dials must stay on screen. margin-left:auto
|
|
2660
2789
|
on #modeToggle used to shove cheap/race/wallet off the right edge. */
|
|
2661
2790
|
#chatHeaderId { display: flex; align-items: center; gap: 10px; flex: 1 1 120px; min-width: 0; overflow: hidden; }
|
|
@@ -2722,6 +2851,30 @@ const APP_HTML = `<!doctype html>
|
|
|
2722
2851
|
font-size: 12px; color: #ececec; line-height: 1.7; word-break: break-word; }
|
|
2723
2852
|
.wnote { color: #6f7080; font-size: 11px; line-height: 1.6; margin-top: 12px; }
|
|
2724
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; }
|
|
2725
2878
|
/* Slash autocomplete. Anchored above the composer because the composer sits
|
|
2726
2879
|
at the bottom of the viewport — a dropdown BELOW it would render off
|
|
2727
2880
|
screen. */
|
|
@@ -2768,8 +2921,8 @@ const APP_HTML = `<!doctype html>
|
|
|
2768
2921
|
-webkit-user-select: text; user-select: text; }
|
|
2769
2922
|
.hdr { align-self: flex-start; display: flex; align-items: center; gap: 6px; margin: 12px 0 4px;
|
|
2770
2923
|
color: #8e8e93; font-size: 13px; }
|
|
2771
|
-
.hdr .avatar { width: 18px; height: 18px; border-radius:
|
|
2772
|
-
|
|
2924
|
+
.hdr .avatar { width: 18px; height: 18px; border-radius: 50%; overflow: hidden; display: flex;
|
|
2925
|
+
align-items: center; justify-content: center; background: #1c1c1e; }
|
|
2773
2926
|
/* min-width:0 is load-bearing. A flex item defaults to min-width:auto, so it
|
|
2774
2927
|
refuses to shrink below its content's intrinsic width — one long
|
|
2775
2928
|
unbreakable line (a curl command, a JSON blob) in a <pre> then stretches
|
|
@@ -2969,8 +3122,26 @@ const APP_HTML = `<!doctype html>
|
|
|
2969
3122
|
<div id="walletOverlay" data-component="wallet-modal">
|
|
2970
3123
|
<div id="walletBox">
|
|
2971
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>
|
|
2972
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>
|
|
2973
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>
|
|
2974
3145
|
</div>
|
|
2975
3146
|
</div>
|
|
2976
3147
|
<div id="main">
|
|
@@ -3005,13 +3176,14 @@ const APP_HTML = `<!doctype html>
|
|
|
3005
3176
|
</optgroup>
|
|
3006
3177
|
</select>
|
|
3007
3178
|
<button class="dial" id="walletBtn" data-component="wallet-open"
|
|
3008
|
-
title="
|
|
3179
|
+
title="Wallet/x402 or subscribe with a card — deposit addresses, live balances, Stripe plans">wallet</button>
|
|
3009
3180
|
<button class="icon-btn" id="reloadBtn" title="Restart grokui on this box">↻</button>
|
|
3010
3181
|
<button class="icon-btn" id="hudBtn">◎</button>
|
|
3011
3182
|
</div>
|
|
3012
3183
|
</div>
|
|
3013
3184
|
<div id="hud">
|
|
3014
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>
|
|
3015
3187
|
<div class="hrow"><span>prepaid credit</span><span id="hCredit" class="hlime">—</span></div>
|
|
3016
3188
|
<div class="hrow"><span>you've paid</span><span id="hYouSpent">—</span></div>
|
|
3017
3189
|
<div class="hrow"><span>our cost (cogs)</span><span id="hYouCogs">—</span></div>
|
|
@@ -3078,7 +3250,153 @@ const APP_HTML = `<!doctype html>
|
|
|
3078
3250
|
let knownThreads = [];
|
|
3079
3251
|
let workspacePort = 0;
|
|
3080
3252
|
|
|
3081
|
-
|
|
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
|
+
}
|
|
3082
3400
|
|
|
3083
3401
|
// SEARCH. The input existed with no handler at all — typing in it did
|
|
3084
3402
|
// nothing, which is worse than not shipping it. Debounced because every
|
|
@@ -3153,7 +3471,7 @@ const APP_HTML = `<!doctype html>
|
|
|
3153
3471
|
// to nothing.
|
|
3154
3472
|
if (t.depth) row.style.paddingLeft = (10 + Math.min(t.depth, 4) * 12) + 'px';
|
|
3155
3473
|
if (t.depth) row.title = 'spawned under ' + (t.rootName || 'a parent');
|
|
3156
|
-
row.innerHTML = '<div class="tavatar"
|
|
3474
|
+
row.innerHTML = '<div class="tavatar">' + botPfp(t.name, t.members) + '</div>' +
|
|
3157
3475
|
'<div class="tmeta"><div class="tname">' + t.name + '</div><div class="tprev">' +
|
|
3158
3476
|
(hitById && hitById.get(t.id) && hitById.get(t.id).snippet
|
|
3159
3477
|
? hitById.get(t.id).snippet
|
|
@@ -3220,7 +3538,7 @@ const APP_HTML = `<!doctype html>
|
|
|
3220
3538
|
|
|
3221
3539
|
function renderHeader(t) {
|
|
3222
3540
|
document.getElementById('chatHeaderId').innerHTML =
|
|
3223
|
-
'<div class="tavatar"
|
|
3541
|
+
'<div class="tavatar">' + botPfp(t.name, t.members) + '</div>' +
|
|
3224
3542
|
'<div class="hname"><div>' + t.name + '</div><div class="hdir" title="' + escapeHtml(t.dir || '') +
|
|
3225
3543
|
'">' + escapeHtml(t.dir || '') + ' · type /dir <path> to change</div></div>';
|
|
3226
3544
|
setModeButtons(t.runMode || 'ask');
|
|
@@ -3305,8 +3623,9 @@ const APP_HTML = `<!doctype html>
|
|
|
3305
3623
|
if (!w || (!w.solana && !w.evm && w.creditUsd == null)) {
|
|
3306
3624
|
const p = document.createElement('div');
|
|
3307
3625
|
p.className = 'wnote wempty';
|
|
3308
|
-
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.';
|
|
3309
3627
|
walletBody.appendChild(p);
|
|
3628
|
+
renderSubLane(w && w.subscription ? w.subscription : null);
|
|
3310
3629
|
return;
|
|
3311
3630
|
}
|
|
3312
3631
|
if (w.creditUsd != null && w.creditUsd !== '') {
|
|
@@ -3340,29 +3659,193 @@ const APP_HTML = `<!doctype html>
|
|
|
3340
3659
|
note.className = 'wnote';
|
|
3341
3660
|
// funded === false is the genuinely-empty case. Undefined means the proxy
|
|
3342
3661
|
// did not say, and guessing "empty" there would send someone to top up a
|
|
3343
|
-
// wallet that is fine.
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
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');
|
|
3348
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();
|
|
3349
3824
|
}
|
|
3350
3825
|
document.getElementById('walletBtn').addEventListener('click', openWallet);
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
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.
|
|
3354
3837
|
(async function maybeOpenWalletOnce() {
|
|
3355
3838
|
if (localStorage.getItem('openzoo.wallet.seen')) return;
|
|
3356
3839
|
for (let i = 0; i < 8; i++) {
|
|
3357
3840
|
try {
|
|
3358
3841
|
const r = await fetch(API + '/wallet');
|
|
3359
3842
|
const w = r.ok ? await r.json() : null;
|
|
3360
|
-
if (!w || (!w.solana && !w.evm)) {
|
|
3843
|
+
if (!w || (!w.solana && !w.evm && !(w.subscription && w.subscription.active))) {
|
|
3361
3844
|
await new Promise((res) => setTimeout(res, 400));
|
|
3362
3845
|
continue;
|
|
3363
3846
|
}
|
|
3364
3847
|
localStorage.setItem('openzoo.wallet.seen', '1');
|
|
3365
|
-
if (w.funded === false) await openWallet();
|
|
3848
|
+
if (w.funded === false && !(w.subscription && w.subscription.active)) await openWallet();
|
|
3366
3849
|
return;
|
|
3367
3850
|
} catch (e) {
|
|
3368
3851
|
await new Promise((res) => setTimeout(res, 400));
|
|
@@ -3403,6 +3886,13 @@ const APP_HTML = `<!doctype html>
|
|
|
3403
3886
|
document.getElementById('modeAuto').addEventListener('click', () => setMode('auto'));
|
|
3404
3887
|
|
|
3405
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
|
+
}
|
|
3406
3896
|
function clientWorkspaceUrl(rel) {
|
|
3407
3897
|
if (!workspacePort || !activeId) return '';
|
|
3408
3898
|
rel = String(rel || '').replace(/^\\/+/, '');
|
|
@@ -3786,11 +4276,12 @@ const APP_HTML = `<!doctype html>
|
|
|
3786
4276
|
|
|
3787
4277
|
let lastSpeaker = null;
|
|
3788
4278
|
function addRow(who, text, color, name, run, images) {
|
|
4279
|
+
if (who === 'bot') text = stripThinkTags(text);
|
|
3789
4280
|
const speakerKey = who + '|' + name;
|
|
3790
4281
|
if (who === 'bot' && speakerKey !== lastSpeaker) {
|
|
3791
4282
|
const hdr = document.createElement('div');
|
|
3792
4283
|
hdr.className = 'hdr';
|
|
3793
|
-
hdr.innerHTML = '<span class="avatar"
|
|
4284
|
+
hdr.innerHTML = '<span class="avatar">' + botPfp(name) + '</span><span>' + name + '</span>';
|
|
3794
4285
|
log.appendChild(hdr);
|
|
3795
4286
|
}
|
|
3796
4287
|
lastSpeaker = speakerKey;
|
|
@@ -3928,7 +4419,7 @@ const APP_HTML = `<!doctype html>
|
|
|
3928
4419
|
if (streamBuf) {
|
|
3929
4420
|
const trail = streamStatus && /^(RUN|READ|WRITE|EDIT|SPAWN|SEND|GLOB|GREP|MCP|FETCH|TODO|SERVE|PING|PEEK|MULTIEDIT|NOTEBOOK):/i.test(streamStatus)
|
|
3930
4421
|
? '<span class="ttrail">' + escapeHtml(streamStatus) + '</span>' : '';
|
|
3931
|
-
return escapeHtml(streamBuf) + trail;
|
|
4422
|
+
return escapeHtml(stripThinkTags(streamBuf)) + trail;
|
|
3932
4423
|
}
|
|
3933
4424
|
const dots = '<span class="dots"><span></span><span></span><span></span></span>';
|
|
3934
4425
|
const st = streamStatus ? '<span class="tstatus">' + escapeHtml(streamStatus) + '</span>' : '';
|
|
@@ -4155,7 +4646,7 @@ const APP_HTML = `<!doctype html>
|
|
|
4155
4646
|
composeList.innerHTML = '';
|
|
4156
4647
|
const createRow = document.createElement('div');
|
|
4157
4648
|
createRow.className = 'crow';
|
|
4158
|
-
createRow.innerHTML = '<div class="tavatar
|
|
4649
|
+
createRow.innerHTML = '<div class="tavatar tavatar-sm tavatar-plus">+</div>' +
|
|
4159
4650
|
'<div>Create new Bot' + (q ? ': ' + escapeHtml(composeInp.value.trim()) : '') + '</div>' +
|
|
4160
4651
|
'<div class="kbd"><kbd>⌘</kbd><kbd>1</kbd></div>';
|
|
4161
4652
|
createRow.addEventListener('click', async () => {
|
|
@@ -4170,7 +4661,7 @@ const APP_HTML = `<!doctype html>
|
|
|
4170
4661
|
candidates.slice(0, 8).forEach((t, i) => {
|
|
4171
4662
|
const row = document.createElement('div');
|
|
4172
4663
|
row.className = 'crow';
|
|
4173
|
-
row.innerHTML = '<div class="tavatar
|
|
4664
|
+
row.innerHTML = '<div class="tavatar tavatar-sm">' + botPfp(t.name) + '</div>' +
|
|
4174
4665
|
'<div>' + escapeHtml(t.name) + '</div><div class="kbd"><kbd>⌘</kbd><kbd>' + (i + 2) + '</kbd></div>';
|
|
4175
4666
|
row.addEventListener('click', () => addChip(t));
|
|
4176
4667
|
composeList.appendChild(row);
|
|
@@ -4278,6 +4769,16 @@ const APP_HTML = `<!doctype html>
|
|
|
4278
4769
|
const you = await (await fetch(API + '/hud-summary')).json();
|
|
4279
4770
|
const creditEl = document.getElementById('hCredit');
|
|
4280
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
|
+
}
|
|
4281
4782
|
const spent = Number(you.spentUsd) || 0;
|
|
4282
4783
|
const cogs = Number(you.cogsUsd) || 0;
|
|
4283
4784
|
const direct = Number(you.directUsd) || 0;
|
|
@@ -4349,12 +4850,116 @@ const server = http.createServer((req, res) => {
|
|
|
4349
4850
|
try {
|
|
4350
4851
|
w.creditUsd = await creditBalance();
|
|
4351
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();
|
|
4352
4856
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
4353
4857
|
res.end(JSON.stringify(w));
|
|
4354
4858
|
})();
|
|
4355
4859
|
return;
|
|
4356
4860
|
}
|
|
4357
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
|
+
|
|
4358
4963
|
// Restart grokui in place — and ACTUALLY PICK UP THE NEW BUILD.
|
|
4359
4964
|
//
|
|
4360
4965
|
// Exiting is the restart: on a production box, box-server's ensureOz() poll
|
|
@@ -4410,6 +5015,7 @@ const server = http.createServer((req, res) => {
|
|
|
4410
5015
|
try {
|
|
4411
5016
|
you.creditUsd = await creditBalance();
|
|
4412
5017
|
} catch { /* credit is advisory */ }
|
|
5018
|
+
you.subscription = subscriptionPublicView();
|
|
4413
5019
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
4414
5020
|
res.end(JSON.stringify(you));
|
|
4415
5021
|
})();
|
|
@@ -4628,4 +5234,7 @@ const server = http.createServer((req, res) => {
|
|
|
4628
5234
|
|
|
4629
5235
|
server.listen(PORT, BIND, () => console.log(`[grokui] http://${BIND === '0.0.0.0' ? 'localhost' : BIND}:${PORT}`));
|
|
4630
5236
|
|
|
4631
|
-
export {
|
|
5237
|
+
export {
|
|
5238
|
+
tryDirective, ensureWorkspacePort, isPreviewableRel, previewAck, workspaceFileUrl,
|
|
5239
|
+
parseRun, looksLikeMcpAsBash, stripThinkTags, safeResolveIn, inDir, listDir,
|
|
5240
|
+
};
|
package/lib/mcp.js
CHANGED
|
@@ -8,6 +8,7 @@ import { tokenBalance } from './x402.js';
|
|
|
8
8
|
import { askWithContext, contextCacheDisabled, BIND_MIN_CHARS } from './hrr.js';
|
|
9
9
|
import { listContexts } from './contexts.js';
|
|
10
10
|
import { withNamespace } from './namespace.js';
|
|
11
|
+
import { subscriptionPublicView } from './subscription.js';
|
|
11
12
|
|
|
12
13
|
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
13
14
|
// The model zoo_ask uses when the caller does not name one. Opus 5 by default:
|
|
@@ -383,6 +384,7 @@ export function buildMcpServer() {
|
|
|
383
384
|
fundHint: 'send a few cents of a listed asset to the address for that rail — Solana assets to solanaAddress, Base assets to evmAddress. The shim converts to whatever the 402 quotes, at payment time. Force a rail with OPENZOO_RAIL=solana|base|robinhood.',
|
|
384
385
|
balances,
|
|
385
386
|
receipts: client.receipts.map((r) => ({ at: r.at, line: r.line })),
|
|
387
|
+
subscription: subscriptionPublicView(),
|
|
386
388
|
});
|
|
387
389
|
});
|
|
388
390
|
|
package/lib/pay.js
CHANGED
|
@@ -13,6 +13,7 @@ import { withNamespace } from './namespace.js';
|
|
|
13
13
|
import {
|
|
14
14
|
resolvePool, poolState, depositForShares, buildWrapInstructions, sendWrap,
|
|
15
15
|
} from './wrap.js';
|
|
16
|
+
import { applySubscriptionHeaders, loadSubscription, stripAuthorization } from './subscription.js';
|
|
16
17
|
|
|
17
18
|
export class QuoteTooHighError extends Error {
|
|
18
19
|
constructor(billedUsd, quote) {
|
|
@@ -333,8 +334,15 @@ export class PayClient {
|
|
|
333
334
|
// Contexts are tenanted by this namespace server-side — a request without
|
|
334
335
|
// it cannot see corpora this wallet bound.
|
|
335
336
|
init = { ...init, headers: withNamespace(init.headers || {}) };
|
|
337
|
+
// Subscription key · no x402. A stored Stripe key is a bearer on the zoo
|
|
338
|
+
// API (same as the public /billing/done snippet). Wallet/x402 stays if
|
|
339
|
+
// there is no key, or if the gateway still answers 402.
|
|
340
|
+
const sub = loadSubscription();
|
|
341
|
+
if (sub?.key) init = { ...init, headers: applySubscriptionHeaders(init.headers, sub) };
|
|
336
342
|
const first = await fetch(url, init);
|
|
337
|
-
if (first.status !== 402)
|
|
343
|
+
if (first.status !== 402) {
|
|
344
|
+
return { response: first, paid: false, subscription: Boolean(sub?.key && first.ok) };
|
|
345
|
+
}
|
|
338
346
|
|
|
339
347
|
const quote = parse402(await first.json());
|
|
340
348
|
// config.rail (OPENZOO_RAIL) steers every front — proxy, demo, MCP — since
|
|
@@ -394,7 +402,7 @@ export class PayClient {
|
|
|
394
402
|
onStage?.('paying');
|
|
395
403
|
const response = await fetch(url, {
|
|
396
404
|
...init,
|
|
397
|
-
headers: { ...(init.headers || {}), 'X-PAYMENT': payment.header },
|
|
405
|
+
headers: { ...stripAuthorization(init.headers || {}), 'X-PAYMENT': payment.header },
|
|
398
406
|
});
|
|
399
407
|
const settle = decodeSettleHeader(response.headers.get('x-payment-response'))
|
|
400
408
|
|| { signature: payment.ownerSignature };
|
package/lib/proxy.js
CHANGED
|
@@ -24,6 +24,7 @@ import { anthropicToOpenAI, openAIToAnthropic, streamOpenAIToAnthropic, writeAnt
|
|
|
24
24
|
import { responsesToChat, chatToResponses, writeResponsesSse } from './responses.js';
|
|
25
25
|
import { loadSessionSpend, saveSessionSpend } from './session.js';
|
|
26
26
|
import { creditBalance, quotedPrices } from './info.js';
|
|
27
|
+
import { subscriptionPublicView } from './subscription.js';
|
|
27
28
|
import { priceHoldings } from './livestatus.js';
|
|
28
29
|
|
|
29
30
|
const HOP_BY_HOP = new Set([
|
|
@@ -891,6 +892,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
891
892
|
res.end(JSON.stringify({
|
|
892
893
|
spentUsd: sessionSpent, cogsUsd: sessionCogs, directUsd: sessionDirect, paidCalls,
|
|
893
894
|
creditUsd, chainUsd: money.chainUsd,
|
|
895
|
+
subscription: subscriptionPublicView(),
|
|
894
896
|
}));
|
|
895
897
|
return;
|
|
896
898
|
}
|
|
@@ -914,6 +916,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
914
916
|
creditUsd,
|
|
915
917
|
chainUsd: money.chainUsd,
|
|
916
918
|
holdings: money.holdings,
|
|
919
|
+
subscription: subscriptionPublicView(),
|
|
917
920
|
}));
|
|
918
921
|
return;
|
|
919
922
|
}
|
|
@@ -1021,7 +1024,9 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1021
1024
|
},
|
|
1022
1025
|
mcp: `${self.replace(/\/v1$/, '')}/mcp`,
|
|
1023
1026
|
upstream: config.apiBase,
|
|
1024
|
-
payment:
|
|
1027
|
+
payment: subscriptionPublicView().active
|
|
1028
|
+
? 'subscription key � no x402 � wallet/x402 remains the other method'
|
|
1029
|
+
: 'x402 per request from the operator\'s local burner wallet � no API key, no account',
|
|
1025
1030
|
auth: viaTunnel
|
|
1026
1031
|
? 'this public URL requires the oz_… bearer for paid endpoints; /v1/models and /v1/hrr/bind are free'
|
|
1027
1032
|
: 'localhost is keyless',
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stripe subscription keys for the zoo API — the other pay lane next to
|
|
3
|
+
* wallet/x402. The live billing API is zoo.openzoo.fun; this file does not
|
|
4
|
+
* invent a second backend.
|
|
5
|
+
*
|
|
6
|
+
* After checkout the site lands on /billing/done?session=<cs_…> and polls
|
|
7
|
+
* GET /api/billing/key?session=… until Stripe confirms. That response is how
|
|
8
|
+
* a desktop client receives the key (no Stripe cookie on Electron). A user
|
|
9
|
+
* who already subscribed can paste the same key, or that success URL.
|
|
10
|
+
*
|
|
11
|
+
* Use: Authorization: Bearer <key> against x402-tokens.fly.dev — no 402
|
|
12
|
+
* signing. Wallet/x402 stays if no key is stored.
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
export const BILLING_ORIGIN = 'https://zoo.openzoo.fun';
|
|
19
|
+
export const SUBSCRIPTIONS_PAGE = 'https://zoo.openzoo.fun/subscriptions';
|
|
20
|
+
|
|
21
|
+
export function subscriptionFile(home = os.homedir()) {
|
|
22
|
+
return process.env.OPENZOO_SUBSCRIPTION_PATH
|
|
23
|
+
|| path.join(home, '.openzoo', 'subscription.json');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function titleCase(id) {
|
|
27
|
+
const s = String(id || '').trim();
|
|
28
|
+
if (!s) return '';
|
|
29
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function asKey(v) {
|
|
33
|
+
return String(v || '').trim();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Persist a subscription key (chmod 600). Never log the value. */
|
|
37
|
+
export function saveSubscription(rec, file = subscriptionFile()) {
|
|
38
|
+
const key = asKey(rec?.key);
|
|
39
|
+
if (!key) return null;
|
|
40
|
+
const payload = {
|
|
41
|
+
key,
|
|
42
|
+
tier: rec.tier ? String(rec.tier) : null,
|
|
43
|
+
tierName: rec.tierName ? String(rec.tierName) : (rec.tier ? titleCase(rec.tier) : null),
|
|
44
|
+
sessionId: rec.sessionId ? String(rec.sessionId) : null,
|
|
45
|
+
savedAt: Date.now(),
|
|
46
|
+
};
|
|
47
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
48
|
+
const tmp = `${file}.tmp`;
|
|
49
|
+
fs.writeFileSync(tmp, JSON.stringify(payload), { mode: 0o600 });
|
|
50
|
+
fs.renameSync(tmp, file);
|
|
51
|
+
cached = { file, mtime: fs.statSync(file).mtimeMs, data: payload };
|
|
52
|
+
return payload;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function clearSubscription(file = subscriptionFile()) {
|
|
56
|
+
try { fs.unlinkSync(file); } catch { /* already gone */ }
|
|
57
|
+
if (cached.file === file) cached = { file: '', mtime: 0, data: null };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let cached = { file: '', mtime: 0, data: null };
|
|
61
|
+
|
|
62
|
+
export function loadSubscription(file = subscriptionFile()) {
|
|
63
|
+
const envKey = asKey(process.env.OPENZOO_SUBSCRIPTION_KEY);
|
|
64
|
+
if (envKey) {
|
|
65
|
+
return {
|
|
66
|
+
key: envKey,
|
|
67
|
+
tier: process.env.OPENZOO_SUBSCRIPTION_TIER || null,
|
|
68
|
+
tierName: process.env.OPENZOO_SUBSCRIPTION_TIER
|
|
69
|
+
? titleCase(process.env.OPENZOO_SUBSCRIPTION_TIER)
|
|
70
|
+
: null,
|
|
71
|
+
sessionId: null,
|
|
72
|
+
source: 'env',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const st = fs.statSync(file);
|
|
77
|
+
if (cached.file === file && cached.mtime === st.mtimeMs && cached.data) return cached.data;
|
|
78
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
79
|
+
if (!asKey(data?.key)) {
|
|
80
|
+
cached = { file, mtime: st.mtimeMs, data: null };
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const rec = {
|
|
84
|
+
key: asKey(data.key),
|
|
85
|
+
tier: data.tier || null,
|
|
86
|
+
tierName: data.tierName || (data.tier ? titleCase(data.tier) : null),
|
|
87
|
+
sessionId: data.sessionId || null,
|
|
88
|
+
source: 'file',
|
|
89
|
+
};
|
|
90
|
+
cached = { file, mtime: st.mtimeMs, data: rec };
|
|
91
|
+
return rec;
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Public HUD/wallet view — never includes the secret. */
|
|
98
|
+
export function subscriptionPublicView(sub = loadSubscription()) {
|
|
99
|
+
if (!asKey(sub?.key)) return { active: false };
|
|
100
|
+
const name = String(sub.tierName || titleCase(sub.tier) || '').trim();
|
|
101
|
+
return {
|
|
102
|
+
active: true,
|
|
103
|
+
tier: sub.tier || null,
|
|
104
|
+
tierName: name || null,
|
|
105
|
+
label: name ? `${name} · no x402` : 'Subscription key · no x402',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A paste is either the bearer key itself, or the site's success URL
|
|
111
|
+
* (`/billing/done?session=cs_…`). session_id is accepted too — Stripe's
|
|
112
|
+
* default query name — but the live page uses `session`.
|
|
113
|
+
*/
|
|
114
|
+
export function parseSubscriptionPaste(text) {
|
|
115
|
+
const raw = String(text || '').trim();
|
|
116
|
+
if (!raw) return { error: 'empty' };
|
|
117
|
+
let session = '';
|
|
118
|
+
try {
|
|
119
|
+
if (/^https?:\/\//i.test(raw) || raw.includes('session=')) {
|
|
120
|
+
const url = new URL(raw, BILLING_ORIGIN);
|
|
121
|
+
session = url.searchParams.get('session') || url.searchParams.get('session_id') || '';
|
|
122
|
+
}
|
|
123
|
+
} catch { /* not a URL */ }
|
|
124
|
+
if (!session) {
|
|
125
|
+
const m = /(?:session_id|session)=([A-Za-z0-9_]+)/.exec(raw);
|
|
126
|
+
if (m) session = m[1];
|
|
127
|
+
}
|
|
128
|
+
if (session) return { session };
|
|
129
|
+
if (/^https?:\/\//i.test(raw)) return { error: 'no session in URL' };
|
|
130
|
+
if (raw.length < 8 || /\s/.test(raw)) return { error: 'not a key' };
|
|
131
|
+
return { key: raw };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function applySubscriptionHeaders(headers = {}, sub = loadSubscription()) {
|
|
135
|
+
const key = asKey(sub?.key);
|
|
136
|
+
if (!key) return headers;
|
|
137
|
+
return { ...headers, authorization: `Bearer ${key}` };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function stripAuthorization(headers = {}) {
|
|
141
|
+
const out = { ...headers };
|
|
142
|
+
delete out.authorization;
|
|
143
|
+
delete out.Authorization;
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function billingJson(url, init) {
|
|
148
|
+
const r = await fetch(url, init);
|
|
149
|
+
const body = await r.json().catch(() => ({}));
|
|
150
|
+
return { http: r.status, body };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function billingTiers() {
|
|
154
|
+
const { http, body } = await billingJson(`${BILLING_ORIGIN}/api/billing/tiers`);
|
|
155
|
+
if (!body?.ok || !Array.isArray(body.tiers)) {
|
|
156
|
+
throw new Error(body?.error || `tiers HTTP ${http}`);
|
|
157
|
+
}
|
|
158
|
+
return body;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function billingCheckout(tier) {
|
|
162
|
+
const id = String(tier || '').trim();
|
|
163
|
+
if (!id) throw new Error('tier required');
|
|
164
|
+
const { http, body } = await billingJson(`${BILLING_ORIGIN}/api/billing/checkout`, {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers: { 'content-type': 'application/json' },
|
|
167
|
+
body: JSON.stringify({ tier: id }),
|
|
168
|
+
});
|
|
169
|
+
if (!body?.ok || !body.url) {
|
|
170
|
+
throw new Error(body?.error || `checkout HTTP ${http}`);
|
|
171
|
+
}
|
|
172
|
+
return { ok: true, url: body.url, sessionId: body.sessionId || null, tier: id };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Poll the same endpoint the public /billing/done page uses. */
|
|
176
|
+
export async function fetchBillingKey(session) {
|
|
177
|
+
const sid = String(session || '').trim();
|
|
178
|
+
if (!sid) return { ok: false, error: 'session required' };
|
|
179
|
+
const { body } = await billingJson(
|
|
180
|
+
`${BILLING_ORIGIN}/api/billing/key?session=${encodeURIComponent(sid)}`,
|
|
181
|
+
);
|
|
182
|
+
return body && typeof body === 'object' ? body : { ok: false, error: 'empty key response' };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* If the live key endpoint returned a key, persist it and return a public
|
|
187
|
+
* view (the secret stays on disk). Pending/error bodies pass through.
|
|
188
|
+
*/
|
|
189
|
+
export function ingestBillingKeyResponse(body, extra = {}, file = subscriptionFile()) {
|
|
190
|
+
const key = asKey(body?.key);
|
|
191
|
+
if (!key) {
|
|
192
|
+
if (body?.pending) return { ok: true, pending: true, saved: false };
|
|
193
|
+
return {
|
|
194
|
+
ok: false,
|
|
195
|
+
pending: false,
|
|
196
|
+
saved: false,
|
|
197
|
+
error: body?.error || 'no key yet',
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
const rec = saveSubscription({
|
|
201
|
+
key,
|
|
202
|
+
tier: body.tier || extra.tier || null,
|
|
203
|
+
tierName: body.tierName || body.name || extra.tierName || null,
|
|
204
|
+
sessionId: extra.sessionId || extra.session || null,
|
|
205
|
+
}, file);
|
|
206
|
+
return { ok: true, pending: false, saved: true, ...subscriptionPublicView(rec) };
|
|
207
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.48.
|
|
3
|
+
"version": "0.48.92",
|
|
4
4
|
"description": "Local x402-paying proxy + MCP server for openzoo.fun \u2014 point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana and Base rails live; Robinhood experimental.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|