openzoo 0.43.0 → 0.43.1
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 +856 -0
- package/lib/podagent.mjs +71 -29
- package/lib/proxy.js +10 -0
- package/package.json +1 -1
package/lib/grokui.mjs
ADDED
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
// Standalone Grok-Bot-lookalike desktop chat client, backed directly by
|
|
2
|
+
// openzoo — no sandbox/daemon hijack required. Sidebar of threads + a
|
|
3
|
+
// message canvas, styled to match /Applications/Grok Bot.app. The twist
|
|
4
|
+
// Grok Bot actually has: any thread's agent can SPAWN a new thread with its
|
|
5
|
+
// own independent agent (and that agent can spawn further threads too) —
|
|
6
|
+
// reusing the same SPAWN/SEND pattern podagent.mjs built for shell delegation,
|
|
7
|
+
// adapted here for plain chat.
|
|
8
|
+
import http from 'node:http';
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { brain } from './podagent.mjs';
|
|
14
|
+
|
|
15
|
+
const PORT = Number(process.env.OZ_GROKUI_PORT || 4173);
|
|
16
|
+
const STORE_DIR = path.join(homedir(), '.openzoo');
|
|
17
|
+
const STORE_FILE = path.join(STORE_DIR, 'grokui-threads.json');
|
|
18
|
+
|
|
19
|
+
// Real but SANDBOXED filesystem access for the bots — each THREAD has its own
|
|
20
|
+
// root dir (default: a dedicated workspace, never the user's whole disk), and
|
|
21
|
+
// the user can point a thread at a real project folder with "/dir <path>" in
|
|
22
|
+
// chat. safeResolveIn rejects any path that would escape that thread's root
|
|
23
|
+
// (../, absolute paths, symlink tricks via normalize) — access is real, but
|
|
24
|
+
// always contained to whatever root was explicitly chosen for that thread.
|
|
25
|
+
const WORKSPACE_DIR = path.join(homedir(), '.openzoo', 'grokui-workspace');
|
|
26
|
+
mkdirSync(WORKSPACE_DIR, { recursive: true });
|
|
27
|
+
function expandHome(p) { return p.startsWith('~') ? path.join(homedir(), p.slice(1)) : p; }
|
|
28
|
+
function dirFor(threadId) { return threads.get(threadId)?.dir || WORKSPACE_DIR; }
|
|
29
|
+
function safeResolveIn(base, rel) {
|
|
30
|
+
const full = path.normalize(path.join(base, rel));
|
|
31
|
+
if (full !== base && !full.startsWith(base + path.sep)) {
|
|
32
|
+
throw new Error("path escapes this thread's directory");
|
|
33
|
+
}
|
|
34
|
+
return full;
|
|
35
|
+
}
|
|
36
|
+
const MIME = { html: 'text/html', htm: 'text/html', css: 'text/css', js: 'application/javascript',
|
|
37
|
+
mjs: 'application/javascript', json: 'application/json', png: 'image/png', jpg: 'image/jpeg',
|
|
38
|
+
jpeg: 'image/jpeg', gif: 'image/gif', svg: 'image/svg+xml', txt: 'text/plain', md: 'text/plain' };
|
|
39
|
+
let workspacePort = null;
|
|
40
|
+
// route: /<threadId>/<relpath...> — each thread is served from ITS OWN dir
|
|
41
|
+
const workspaceServer = http.createServer((req, res) => {
|
|
42
|
+
try {
|
|
43
|
+
const urlPath = decodeURIComponent((req.url || '/').split('?')[0]).replace(/^\/+/, '');
|
|
44
|
+
const slash = urlPath.indexOf('/');
|
|
45
|
+
const threadId = slash === -1 ? urlPath : urlPath.slice(0, slash);
|
|
46
|
+
let rel = slash === -1 ? '' : urlPath.slice(slash + 1);
|
|
47
|
+
if (!rel) rel = 'index.html';
|
|
48
|
+
const full = safeResolveIn(dirFor(threadId), rel);
|
|
49
|
+
const data = readFileSync(full);
|
|
50
|
+
const ext = full.split('.').pop();
|
|
51
|
+
res.writeHead(200, { 'content-type': MIME[ext] || 'application/octet-stream' });
|
|
52
|
+
res.end(data);
|
|
53
|
+
} catch {
|
|
54
|
+
res.writeHead(404, { 'content-type': 'text/plain' });
|
|
55
|
+
res.end('not found');
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
workspaceServer.listen(0, '127.0.0.1', () => { workspacePort = workspaceServer.address().port; });
|
|
59
|
+
|
|
60
|
+
const PALETTE = ['#e91e8c', '#34c759', '#ff9500', '#5e5ce6', '#ff3b30', '#0a84ff', '#00c7be'];
|
|
61
|
+
function colorFor(name) {
|
|
62
|
+
let h = 0;
|
|
63
|
+
for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
|
64
|
+
return PALETTE[h % PALETTE.length];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const SYSTEM = `You are a helpful assistant served over openzoo (pay-per-call access to ~435
|
|
68
|
+
models, no API key, no account). Reply normally in plain text, concisely.
|
|
69
|
+
|
|
70
|
+
If — and only if — the request genuinely calls for independent parallel work (the user
|
|
71
|
+
asks you to spawn/delegate/create agents, or a task splits cleanly into independent
|
|
72
|
+
subtasks), you may instead reply with EXACTLY one line, no prose, using one of:
|
|
73
|
+
SPAWN: <short name> | <task for the new agent> create a new thread with its own
|
|
74
|
+
independent agent and give it a task
|
|
75
|
+
SEND: <name> | <message> message an agent thread that already
|
|
76
|
+
exists (yours or one you spawned)
|
|
77
|
+
PING: <name> one-line status: still working, or
|
|
78
|
+
its last result
|
|
79
|
+
PEEK: <name> a fuller look — its last few messages,
|
|
80
|
+
not just the latest one
|
|
81
|
+
You are given the result before your next line, so none of these block you — check back
|
|
82
|
+
later if it's still working.
|
|
83
|
+
|
|
84
|
+
You ALSO have real (sandboxed) filesystem access, scoped to THIS thread's own directory —
|
|
85
|
+
the user sets or changes it by sending "/dir <path>" in chat; until they do, it's a private
|
|
86
|
+
workspace folder, not their real project. Same one-line-no-prose reply format:
|
|
87
|
+
WRITE: <relative path> | <content> create or overwrite a file
|
|
88
|
+
READ: <relative path> read a file back
|
|
89
|
+
SERVE: <relative path, or blank for the dir root> get a real http:// URL for a file —
|
|
90
|
+
use this instead of claiming you
|
|
91
|
+
"can't expose a port": you can serve
|
|
92
|
+
static files, just not run a process
|
|
93
|
+
For normal questions just answer directly — do not use any of these unless the request
|
|
94
|
+
actually calls for delegation or file work.`;
|
|
95
|
+
|
|
96
|
+
// id -> { id, name, color, parent, messages: [{role,content}], history: [{who,text}], status }
|
|
97
|
+
const threads = new Map();
|
|
98
|
+
|
|
99
|
+
// Threads are the whole point of the app — losing them on every restart (the
|
|
100
|
+
// server got restarted a lot while iterating this session) is a real bug, not
|
|
101
|
+
// a nice-to-have. Plain JSON on disk; the volume of chat here never justifies
|
|
102
|
+
// a database.
|
|
103
|
+
function saveThreads() {
|
|
104
|
+
try {
|
|
105
|
+
mkdirSync(STORE_DIR, { recursive: true });
|
|
106
|
+
writeFileSync(STORE_FILE, JSON.stringify([...threads.values()]));
|
|
107
|
+
} catch { /* best effort */ }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function loadThreads() {
|
|
111
|
+
try {
|
|
112
|
+
if (!existsSync(STORE_FILE)) return false;
|
|
113
|
+
const arr = JSON.parse(readFileSync(STORE_FILE, 'utf8'));
|
|
114
|
+
if (!Array.isArray(arr) || !arr.length) return false;
|
|
115
|
+
for (const t of arr) threads.set(t.id, t);
|
|
116
|
+
return true;
|
|
117
|
+
} catch { return false; }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function newThread(name, parent, members) {
|
|
121
|
+
const id = randomUUID();
|
|
122
|
+
const t = { id, name, color: members ? members[0].color : colorFor(name), parent: parent || null,
|
|
123
|
+
messages: members ? null : [{ role: 'system', content: SYSTEM }],
|
|
124
|
+
members: members || null, history: [], status: 'idle', createdAt: Date.now() };
|
|
125
|
+
threads.set(id, t);
|
|
126
|
+
saveThreads();
|
|
127
|
+
return t;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function makeMember(name) {
|
|
131
|
+
return { name, color: colorFor(name), systemPrompt:
|
|
132
|
+
`You are ${name}, one of several bots in a shared group chat served over openzoo
|
|
133
|
+
(pay-per-call access to ~435 models, no API key, no account). Reply normally and concisely
|
|
134
|
+
as yourself. You can see the WHOLE shared conversation, including what the other bots in
|
|
135
|
+
this group already said — their messages are prefixed "[Name]:" so you can tell them apart
|
|
136
|
+
from the human. A message addressed "@everyone" is meant for the whole group — give your
|
|
137
|
+
own take even if brief ("Passed." is fine when you have nothing to add). COORDINATE: if
|
|
138
|
+
another bot already handled or is handling the request (e.g. already spawned the exact
|
|
139
|
+
agent being asked for), do NOT repeat it — just acknowledge, or add something genuinely new.
|
|
140
|
+
|
|
141
|
+
You can ALSO delegate, same as any other agent here. If — and only if — asked to
|
|
142
|
+
spawn/delegate/create agents AND no other bot has already done it this round, reply with
|
|
143
|
+
EXACTLY one line, no prose, using one of:
|
|
144
|
+
SPAWN: <short name> | <task for the new agent> create a new thread with its own agent
|
|
145
|
+
SEND: <name> | <message> message an existing agent thread
|
|
146
|
+
PING: <name> one-line status, or its last result
|
|
147
|
+
PEEK: <name> a fuller look at its last few messages
|
|
148
|
+
|
|
149
|
+
You ALSO have real (sandboxed) filesystem access, scoped to THIS group's own directory —
|
|
150
|
+
the user sets or changes it with "/dir <path>" in chat. Same format:
|
|
151
|
+
WRITE: <relative path> | <content> create or overwrite a file
|
|
152
|
+
READ: <relative path> read a file back
|
|
153
|
+
SERVE: <relative path, or blank for the dir root> get a real http:// URL for it — use
|
|
154
|
+
this instead of saying you can't
|
|
155
|
+
expose a port
|
|
156
|
+
For normal replies just answer directly — do not use any of these unless the request
|
|
157
|
+
actually calls for delegation or file work.` };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Rebuilds a member's context fresh from the shared thread history every turn
|
|
161
|
+
// (instead of a private per-member log) so each bot sees what the others in
|
|
162
|
+
// the group already said — including earlier replies from THIS round, since
|
|
163
|
+
// runTurn pushes to t.history sequentially, one member at a time.
|
|
164
|
+
function buildMemberMessages(t, member) {
|
|
165
|
+
const msgs = [{ role: 'system', content: member.systemPrompt || SYSTEM }];
|
|
166
|
+
for (const h of t.history) {
|
|
167
|
+
if (h.who === 'user') msgs.push({ role: 'user', content: h.text });
|
|
168
|
+
else if (h.name === member.name) msgs.push({ role: 'assistant', content: h.text });
|
|
169
|
+
else msgs.push({ role: 'user', content: `[${h.name}]: ${h.text}` });
|
|
170
|
+
}
|
|
171
|
+
return msgs;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function newGroupThread(names) {
|
|
175
|
+
const members = names.map(makeMember);
|
|
176
|
+
return newThread(names.join(', '), null, members);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function findByName(name) {
|
|
180
|
+
let best = null;
|
|
181
|
+
for (const t of threads.values()) {
|
|
182
|
+
if (t.name.toLowerCase() === name.toLowerCase() && (!best || t.createdAt > best.createdAt)) best = t;
|
|
183
|
+
}
|
|
184
|
+
return best;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!loadThreads()) newThread('openzoo', null);
|
|
188
|
+
|
|
189
|
+
// Parses a SPAWN/SEND/PING directive out of a reply, performs its side effect
|
|
190
|
+
// (creating or messaging another thread), and returns the ack text to show in
|
|
191
|
+
// place of the raw directive line — or null if the reply wasn't a directive.
|
|
192
|
+
function tryDirective(reply, originId) {
|
|
193
|
+
const spawn = /^SPAWN:\s*([^|]+)\|\s*([\s\S]+)/.exec(reply);
|
|
194
|
+
if (spawn) {
|
|
195
|
+
const name = spawn[1].trim();
|
|
196
|
+
const task = spawn[2].trim();
|
|
197
|
+
const sub = newThread(name, originId);
|
|
198
|
+
runTurn(sub.id, task).catch(() => {}); // fire and forget — runs independently
|
|
199
|
+
return `Spawned ${name} — working on it.`;
|
|
200
|
+
}
|
|
201
|
+
const sendM = /^SEND:\s*([^|]+)\|\s*([\s\S]+)/.exec(reply);
|
|
202
|
+
if (sendM) {
|
|
203
|
+
const name = sendM[1].trim();
|
|
204
|
+
const msg = sendM[2].trim();
|
|
205
|
+
const target = findByName(name);
|
|
206
|
+
if (target) runTurn(target.id, msg).catch(() => {});
|
|
207
|
+
return target ? `Messaged ${name}.` : `No thread named "${name}" to message.`;
|
|
208
|
+
}
|
|
209
|
+
const ping = /^PING:\s*(.+)/.exec(reply);
|
|
210
|
+
if (ping) {
|
|
211
|
+
const name = ping[1].trim();
|
|
212
|
+
const target = findByName(name);
|
|
213
|
+
const last = target?.history[target.history.length - 1];
|
|
214
|
+
return !target ? `No thread named "${name}".`
|
|
215
|
+
: target.status === 'thinking' ? `${name} is still working.`
|
|
216
|
+
: last ? `${name}: ${last.text}` : `${name} hasn't replied yet.`;
|
|
217
|
+
}
|
|
218
|
+
const peek = /^PEEK:\s*(.+)/.exec(reply);
|
|
219
|
+
if (peek) {
|
|
220
|
+
const name = peek[1].trim();
|
|
221
|
+
const target = findByName(name);
|
|
222
|
+
if (!target) return `No thread named "${name}".`;
|
|
223
|
+
const recent = target.history.slice(-4)
|
|
224
|
+
.map((h) => (h.who === 'user' ? 'you' : (h.name || target.name)) + ': ' + h.text).join('\n');
|
|
225
|
+
return `${name} (${target.status}):\n${recent || '(nothing yet)'}`;
|
|
226
|
+
}
|
|
227
|
+
const write = /^WRITE:\s*([^|]+)\|([\s\S]+)/.exec(reply);
|
|
228
|
+
if (write) {
|
|
229
|
+
const rel = write[1].trim();
|
|
230
|
+
const content = write[2].replace(/^\n/, '');
|
|
231
|
+
try {
|
|
232
|
+
const full = safeResolveIn(dirFor(originId), rel);
|
|
233
|
+
mkdirSync(path.dirname(full), { recursive: true });
|
|
234
|
+
writeFileSync(full, content);
|
|
235
|
+
return `Wrote ${rel} (${Buffer.byteLength(content)} bytes) to ${dirFor(originId)}.`;
|
|
236
|
+
} catch (e) { return `Couldn't write ${rel}: ${e.message}`; }
|
|
237
|
+
}
|
|
238
|
+
const readD = /^READ:\s*(.+)/.exec(reply);
|
|
239
|
+
if (readD) {
|
|
240
|
+
const rel = readD[1].trim();
|
|
241
|
+
try {
|
|
242
|
+
const data = readFileSync(safeResolveIn(dirFor(originId), rel), 'utf8');
|
|
243
|
+
return `${rel}:\n${data.slice(0, 4000)}${data.length > 4000 ? '\n…(truncated)' : ''}`;
|
|
244
|
+
} catch (e) { return `Couldn't read ${rel}: ${e.message}`; }
|
|
245
|
+
}
|
|
246
|
+
const serve = /^SERVE:\s*(.*)$/.exec(reply);
|
|
247
|
+
if (serve) {
|
|
248
|
+
const rel = serve[1].trim();
|
|
249
|
+
if (!workspacePort) return 'Workspace server is still starting — try again in a second.';
|
|
250
|
+
return `Serving at http://localhost:${workspacePort}/${originId}/${rel}`;
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function runTurn(threadId, userText) {
|
|
256
|
+
const t = threads.get(threadId);
|
|
257
|
+
if (!t) return;
|
|
258
|
+
t.history.push({ who: 'user', text: userText });
|
|
259
|
+
if (t.members) {
|
|
260
|
+
t.status = 'thinking';
|
|
261
|
+
// sequential, not parallel: each member's context is rebuilt from
|
|
262
|
+
// t.history right before its turn, so it sees every reply (including
|
|
263
|
+
// spawns/sends) the earlier members in THIS round already made
|
|
264
|
+
for (const m of t.members) {
|
|
265
|
+
const msgs = buildMemberMessages(t, m);
|
|
266
|
+
let r = '';
|
|
267
|
+
try { r = (await brain(msgs)).trim(); } catch (e) { r = `error: ${e.message}`; }
|
|
268
|
+
const ack = tryDirective(r, t.id);
|
|
269
|
+
t.history.push({ who: 'bot', text: ack ?? (r || '(no response)'), name: m.name, color: m.color });
|
|
270
|
+
}
|
|
271
|
+
t.status = 'idle';
|
|
272
|
+
saveThreads();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
t.messages.push({ role: 'user', content: userText });
|
|
276
|
+
t.status = 'thinking';
|
|
277
|
+
let reply = '';
|
|
278
|
+
try {
|
|
279
|
+
reply = (await brain(t.messages)).trim();
|
|
280
|
+
} catch (e) {
|
|
281
|
+
reply = `error: ${e.message}`;
|
|
282
|
+
}
|
|
283
|
+
t.messages.push({ role: 'assistant', content: reply });
|
|
284
|
+
const ack = tryDirective(reply, t.id);
|
|
285
|
+
t.history.push({ who: 'bot', text: ack ?? (reply || '(no response)') });
|
|
286
|
+
t.status = 'idle';
|
|
287
|
+
saveThreads();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function threadSummary(t) {
|
|
291
|
+
const last = t.history[t.history.length - 1];
|
|
292
|
+
return { id: t.id, name: t.name, color: t.color, parent: t.parent, status: t.status,
|
|
293
|
+
preview: last ? (last.who === 'user' ? last.text : last.text).slice(0, 60) : '', createdAt: t.createdAt };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const APP_HTML = `<!doctype html>
|
|
297
|
+
<html><head><meta charset="utf-8"><title>openzoo</title>
|
|
298
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
299
|
+
<style>
|
|
300
|
+
:root { color-scheme: dark; }
|
|
301
|
+
* { box-sizing: border-box; }
|
|
302
|
+
html, body { margin: 0; height: 100%; background: #000; }
|
|
303
|
+
body { color: #ececec; font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
304
|
+
display: flex; }
|
|
305
|
+
#dragbar { -webkit-app-region: drag; position: fixed; top: 0; left: 0; right: 0; height: 28px; z-index: 1000; }
|
|
306
|
+
#sidebar { width: 280px; flex: 0 0 280px; border-right: 1px solid #1c1c1e; display: flex; flex-direction: column;
|
|
307
|
+
height: 100vh; padding-top: 28px; }
|
|
308
|
+
#main { padding-top: 28px; }
|
|
309
|
+
#sideTop { display: flex; align-items: center; gap: 4px; padding: 0 8px; }
|
|
310
|
+
#sideTop #search { flex: 1; }
|
|
311
|
+
#search { margin: 12px; padding: 8px 12px; background: #1c1c1e; border-radius: 10px; color: #ececec;
|
|
312
|
+
border: none; font: inherit; }
|
|
313
|
+
#search::placeholder { color: #8e8e93; }
|
|
314
|
+
#threads { flex: 1; overflow-y: auto; }
|
|
315
|
+
.trow { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; border-radius: 10px;
|
|
316
|
+
margin: 0 6px 2px; }
|
|
317
|
+
.trow:hover { background: #17171a; }
|
|
318
|
+
.trow.active { background: #1c1c1e; }
|
|
319
|
+
.tavatar { width: 36px; height: 36px; border-radius: 10px; flex: 0 0 36px; display: flex; align-items: center;
|
|
320
|
+
justify-content: center; color: #fff; font-weight: 600; font-size: 14px; }
|
|
321
|
+
.tmeta { min-width: 0; flex: 1; }
|
|
322
|
+
.tname { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
323
|
+
.tprev { font-size: 12px; color: #8e8e93; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
324
|
+
.tdot { width: 8px; height: 8px; border-radius: 50%; background: #0a84ff; flex: 0 0 8px; }
|
|
325
|
+
#main { flex: 1; display: flex; flex-direction: column; height: 100vh; }
|
|
326
|
+
#chatHeader { padding: 14px 20px; border-bottom: 1px solid #1c1c1e; display: flex; align-items: center; gap: 10px;
|
|
327
|
+
font-weight: 600; }
|
|
328
|
+
#chatHeader .tavatar { width: 26px; height: 26px; border-radius: 7px; font-size: 11px; flex: 0 0 26px; }
|
|
329
|
+
#hudBtn { margin-left: auto; }
|
|
330
|
+
#chatHeaderId { display: flex; align-items: center; gap: 10px; }
|
|
331
|
+
#hud { position: fixed; top: 40px; right: 14px; width: 250px; background: rgba(14,14,17,.94);
|
|
332
|
+
border: 1px solid #333340; border-radius: 10px; padding: 12px 14px; font: 11px/1.5 Menlo, monospace;
|
|
333
|
+
display: none; z-index: 300; box-shadow: 0 12px 30px rgba(0,0,0,.5); }
|
|
334
|
+
#hud.show { display: block; }
|
|
335
|
+
#hud .htitle { color: #b8f240; font-size: 10px; letter-spacing: .04em; margin-bottom: 10px; }
|
|
336
|
+
#hud .hrow { display: flex; justify-content: space-between; margin: 6px 0; color: #f0f0eb; font-size: 12px; }
|
|
337
|
+
#hud .hrow span:first-child { color: #999aa8; font-size: 10.5px; }
|
|
338
|
+
#hud .hlime { color: #b8f240; }
|
|
339
|
+
#hud .hember { color: #f28c4d; }
|
|
340
|
+
#hud .hfoot { border-top: 1px solid #333340; margin-top: 10px; padding-top: 8px; color: #999aa8; font-size: 10px; }
|
|
341
|
+
#log { flex: 1; overflow-y: auto; padding: 20px 24px 12px; display: flex; flex-direction: column; gap: 6px; }
|
|
342
|
+
.hdr { align-self: flex-start; display: flex; align-items: center; gap: 6px; margin: 12px 0 4px;
|
|
343
|
+
color: #8e8e93; font-size: 13px; }
|
|
344
|
+
.hdr .avatar { width: 18px; height: 18px; border-radius: 5px; display: flex; align-items: center;
|
|
345
|
+
justify-content: center; color: #fff; font-size: 9px; font-weight: 700; }
|
|
346
|
+
.row { display: flex; max-width: 78%; margin: 2px 0; }
|
|
347
|
+
.row.user { align-self: flex-end; }
|
|
348
|
+
.row.bot { align-self: flex-start; }
|
|
349
|
+
.bubble { padding: 11px 16px; border-radius: 20px; white-space: pre-wrap; word-break: break-word; }
|
|
350
|
+
.row.user .bubble { background: #57575c; }
|
|
351
|
+
.row.bot .bubble { background: #262626; color: #ececec; }
|
|
352
|
+
.row.bot.pending .bubble { color: #8e8e93; }
|
|
353
|
+
.dots span { display: inline-block; width: 5px; height: 5px; margin-right: 3px; border-radius: 50%;
|
|
354
|
+
background: #8e8e93; animation: blink 1.2s infinite ease-in-out; }
|
|
355
|
+
.dots span:nth-child(2) { animation-delay: .2s; } .dots span:nth-child(3) { animation-delay: .4s; }
|
|
356
|
+
@keyframes blink { 0%, 80%, 100% { opacity: .25; } 40% { opacity: 1; } }
|
|
357
|
+
#bar { padding: 10px 16px 18px; position: relative; }
|
|
358
|
+
#row-input { display: flex; align-items: center; gap: 8px; }
|
|
359
|
+
#plusMenu { position: absolute; bottom: 62px; left: 16px; background: #1c1c1e; border-radius: 14px;
|
|
360
|
+
padding: 6px; display: none; flex-direction: column; min-width: 190px;
|
|
361
|
+
box-shadow: 0 8px 24px rgba(0,0,0,.5); z-index: 10; }
|
|
362
|
+
#plusMenu.show { display: flex; }
|
|
363
|
+
.pop-item { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px;
|
|
364
|
+
cursor: pointer; color: #ececec; font-size: 14px; }
|
|
365
|
+
.pop-item:hover { background: #2c2c2e; }
|
|
366
|
+
.pop-item svg { width: 18px; height: 18px; flex: 0 0 18px; }
|
|
367
|
+
.pop-item.record svg { color: #ff3b30; }
|
|
368
|
+
#pill { flex: 1; display: flex; align-items: center; gap: 6px; background: #2c2c2e; border-radius: 26px;
|
|
369
|
+
padding: 8px 10px 8px 14px; }
|
|
370
|
+
.icon-btn { width: 32px; height: 32px; border-radius: 50%; border: none; background: transparent;
|
|
371
|
+
color: #ececec; display: flex; align-items: center; justify-content: center; cursor: pointer;
|
|
372
|
+
flex: 0 0 32px; }
|
|
373
|
+
.icon-btn:hover { background: #3a3a3c; }
|
|
374
|
+
.icon-btn svg { width: 18px; height: 18px; }
|
|
375
|
+
#attachChips { display: flex; gap: 6px; flex-wrap: wrap; padding: 0 16px 6px; }
|
|
376
|
+
.achip { display: flex; align-items: center; gap: 6px; background: #2c2c2e; color: #ececec; border-radius: 10px;
|
|
377
|
+
padding: 4px 8px; font-size: 12px; }
|
|
378
|
+
.achip .ax { cursor: pointer; color: #8e8e93; }
|
|
379
|
+
#inp { flex: 1; background: transparent; border: none; color: #ececec; font: inherit;
|
|
380
|
+
padding: 6px 0; min-width: 0; }
|
|
381
|
+
#inp::placeholder { color: #8e8e93; }
|
|
382
|
+
#inp:focus { outline: none; }
|
|
383
|
+
#send { width: 34px; height: 34px; border-radius: 50%; border: none; background: #fff; color: #000;
|
|
384
|
+
display: none; align-items: center; justify-content: center; cursor: pointer; flex: 0 0 34px; }
|
|
385
|
+
#send.show { display: flex; }
|
|
386
|
+
#send svg { width: 16px; height: 16px; }
|
|
387
|
+
#composeOverlay { position: fixed; inset: 0; background: rgba(0,0,0,.5); display: none; align-items: flex-start;
|
|
388
|
+
justify-content: center; padding-top: 90px; z-index: 200; }
|
|
389
|
+
#composeOverlay.show { display: flex; }
|
|
390
|
+
#composeBox { width: 460px; max-height: 65vh; background: #1c1c1e; border-radius: 16px; overflow: hidden;
|
|
391
|
+
display: flex; flex-direction: column; box-shadow: 0 20px 60px rgba(0,0,0,.6); }
|
|
392
|
+
#composeTo { display: flex; align-items: center; gap: 8px; padding: 14px 16px; border-bottom: 1px solid #2c2c2e;
|
|
393
|
+
color: #8e8e93; flex-wrap: wrap; }
|
|
394
|
+
#chips { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
395
|
+
.chip { display: flex; align-items: center; gap: 6px; background: #2c2c2e; color: #ececec; border-radius: 14px;
|
|
396
|
+
padding: 3px 8px 3px 4px; font-size: 13px; }
|
|
397
|
+
.chip .cav { width: 16px; height: 16px; border-radius: 4px; display: inline-block; }
|
|
398
|
+
.chip .cx { cursor: pointer; color: #8e8e93; margin-left: 2px; }
|
|
399
|
+
#composeTo input { flex: 1; min-width: 100px; background: transparent; border: none; color: #ececec; font: inherit; }
|
|
400
|
+
#composeTo input:focus { outline: none; }
|
|
401
|
+
#composeList { overflow-y: auto; padding: 8px; }
|
|
402
|
+
.crow { display: flex; align-items: center; gap: 10px; padding: 10px; border-radius: 10px; cursor: pointer; }
|
|
403
|
+
.crow:hover { background: #2c2c2e; }
|
|
404
|
+
.crow .kbd { margin-left: auto; display: flex; gap: 4px; }
|
|
405
|
+
kbd { background: #2c2c2e; border-radius: 5px; padding: 2px 6px; font-size: 11px; color: #8e8e93; }
|
|
406
|
+
#composeFoot { display: flex; gap: 16px; padding: 10px 16px; border-top: 1px solid #2c2c2e; color: #8e8e93;
|
|
407
|
+
font-size: 12px; }
|
|
408
|
+
#composeFoot kbd { margin-right: 4px; }
|
|
409
|
+
.mention { background: #3a3a3c; border-radius: 10px; padding: 1px 8px; font-size: 0.92em; }
|
|
410
|
+
</style></head>
|
|
411
|
+
<body>
|
|
412
|
+
<div id="dragbar"></div>
|
|
413
|
+
<div id="sidebar">
|
|
414
|
+
<div id="sideTop">
|
|
415
|
+
<button class="icon-btn" id="newMsgBtn">
|
|
416
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
417
|
+
</button>
|
|
418
|
+
<input id="search" placeholder="Search">
|
|
419
|
+
</div>
|
|
420
|
+
<div id="threads"></div>
|
|
421
|
+
</div>
|
|
422
|
+
<div id="composeOverlay">
|
|
423
|
+
<div id="composeBox">
|
|
424
|
+
<div id="composeTo">
|
|
425
|
+
<span>To:</span>
|
|
426
|
+
<span id="chips"></span>
|
|
427
|
+
<input id="composeInp" placeholder="Search or create Bots">
|
|
428
|
+
<button class="icon-btn" id="composeClose">
|
|
429
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
430
|
+
</button>
|
|
431
|
+
</div>
|
|
432
|
+
<div id="composeList"></div>
|
|
433
|
+
<div id="composeFoot"><span><kbd>Tab</kbd> add</span><span><kbd>Enter</kbd> open</span></div>
|
|
434
|
+
</div>
|
|
435
|
+
</div>
|
|
436
|
+
<div id="main">
|
|
437
|
+
<div id="chatHeader">
|
|
438
|
+
<div id="chatHeaderId"></div>
|
|
439
|
+
<button class="icon-btn" id="hudBtn">◎</button>
|
|
440
|
+
</div>
|
|
441
|
+
<div id="hud">
|
|
442
|
+
<div class="htitle">ALL OF OPENZOO · TODAY</div>
|
|
443
|
+
<div class="hrow"><span>paid (metered)</span><span id="hPaid">—</span></div>
|
|
444
|
+
<div class="hrow"><span>our cost (cogs)</span><span id="hCogs">—</span></div>
|
|
445
|
+
<div class="hrow"><span>margin</span><span id="hMargin" class="hlime">—</span></div>
|
|
446
|
+
<div class="hrow"><span>direct would be</span><span id="hDirect" class="hember">—</span></div>
|
|
447
|
+
<div class="hrow"><span>leCore saving</span><span id="hSaved" class="hlime">—</span></div>
|
|
448
|
+
<div class="hfoot" id="hFoot">loading…</div>
|
|
449
|
+
<div class="htitle" style="margin-top:10px">YOUR WALLET · THIS SESSION</div>
|
|
450
|
+
<div class="hrow"><span>you've spent</span><span id="hYouSpent">—</span></div>
|
|
451
|
+
<div class="hrow"><span>your paid calls</span><span id="hYouCalls">—</span></div>
|
|
452
|
+
</div>
|
|
453
|
+
<div id="log"></div>
|
|
454
|
+
<div id="bar">
|
|
455
|
+
<div id="plusMenu">
|
|
456
|
+
<div class="pop-item" id="attachBtn">
|
|
457
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a5 5 0 0 1-7.07-7.07l9.19-9.19a3.5 3.5 0 0 1 4.95 4.95l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
|
458
|
+
<span>Attach files</span>
|
|
459
|
+
</div>
|
|
460
|
+
</div>
|
|
461
|
+
<input id="fileInp" type="file" multiple style="position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;">
|
|
462
|
+
<div id="attachChips"></div>
|
|
463
|
+
<div id="row-input">
|
|
464
|
+
<div id="pill">
|
|
465
|
+
<button class="icon-btn" id="plusBtn">
|
|
466
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
467
|
+
</button>
|
|
468
|
+
<input id="inp" placeholder="Message" autofocus>
|
|
469
|
+
<button class="icon-btn" tabindex="-1">
|
|
470
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z"/><path d="M19 10v1a7 7 0 0 1-14 0v-1"/><line x1="12" y1="18" x2="12" y2="22"/></svg>
|
|
471
|
+
</button>
|
|
472
|
+
</div>
|
|
473
|
+
<button id="send">
|
|
474
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5"/><path d="M6 11l6-6 6 6"/></svg>
|
|
475
|
+
</button>
|
|
476
|
+
</div>
|
|
477
|
+
</div>
|
|
478
|
+
</div>
|
|
479
|
+
<script>
|
|
480
|
+
const threadsEl = document.getElementById('threads');
|
|
481
|
+
const chatHeader = document.getElementById('chatHeader');
|
|
482
|
+
const log = document.getElementById('log');
|
|
483
|
+
const inp = document.getElementById('inp');
|
|
484
|
+
const send = document.getElementById('send');
|
|
485
|
+
let activeId = null;
|
|
486
|
+
let knownThreads = [];
|
|
487
|
+
|
|
488
|
+
function initials(name) { return name.slice(0, 2).toUpperCase(); }
|
|
489
|
+
|
|
490
|
+
async function loadThreads() {
|
|
491
|
+
const list = await (await fetch('/threads')).json();
|
|
492
|
+
knownThreads = list;
|
|
493
|
+
if (!activeId && list.length) activeId = list[0].id;
|
|
494
|
+
threadsEl.innerHTML = '';
|
|
495
|
+
for (const t of list) {
|
|
496
|
+
const row = document.createElement('div');
|
|
497
|
+
row.className = 'trow' + (t.id === activeId ? ' active' : '');
|
|
498
|
+
row.innerHTML = '<div class="tavatar" style="background:' + t.color + '">' + initials(t.name) + '</div>' +
|
|
499
|
+
'<div class="tmeta"><div class="tname">' + t.name + '</div><div class="tprev">' +
|
|
500
|
+
(t.status === 'thinking' ? 'typing…' : (t.preview || '')) + '</div></div>' +
|
|
501
|
+
(t.status === 'thinking' ? '<div class="tdot"></div>' : '');
|
|
502
|
+
row.addEventListener('click', () => { activeId = t.id; render(); });
|
|
503
|
+
threadsEl.appendChild(row);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function loadActiveMessages() {
|
|
508
|
+
if (!activeId) return null;
|
|
509
|
+
return await (await fetch('/threads/' + activeId)).json();
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function renderHeader(t) {
|
|
513
|
+
document.getElementById('chatHeaderId').innerHTML =
|
|
514
|
+
'<div class="tavatar" style="background:' + t.color + '">' + initials(t.name) + '</div><div>' + t.name + '</div>';
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function escapeHtml(s) { return s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); }
|
|
518
|
+
function renderMentions(text) { return escapeHtml(text).replace(/@(\\w+)/g, '<span class="mention">\u{1F465} $1</span>'); }
|
|
519
|
+
|
|
520
|
+
let lastSpeaker = null;
|
|
521
|
+
function addRow(who, text, color, name) {
|
|
522
|
+
const speakerKey = who + '|' + name;
|
|
523
|
+
if (who === 'bot' && speakerKey !== lastSpeaker) {
|
|
524
|
+
const hdr = document.createElement('div');
|
|
525
|
+
hdr.className = 'hdr';
|
|
526
|
+
hdr.innerHTML = '<span class="avatar" style="background:' + color + '">' + initials(name) + '</span><span>' + name + '</span>';
|
|
527
|
+
log.appendChild(hdr);
|
|
528
|
+
}
|
|
529
|
+
lastSpeaker = speakerKey;
|
|
530
|
+
const row = document.createElement('div');
|
|
531
|
+
row.className = 'row ' + who;
|
|
532
|
+
const bubble = document.createElement('div');
|
|
533
|
+
bubble.className = 'bubble';
|
|
534
|
+
bubble.innerHTML = renderMentions(text);
|
|
535
|
+
row.appendChild(bubble);
|
|
536
|
+
log.appendChild(row);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function render() {
|
|
540
|
+
const t = knownThreads.find((x) => x.id === activeId);
|
|
541
|
+
if (!t) return;
|
|
542
|
+
renderHeader(t);
|
|
543
|
+
inp.placeholder = 'Message ' + t.name;
|
|
544
|
+
const full = await loadActiveMessages();
|
|
545
|
+
if (!full || full.id !== activeId) return;
|
|
546
|
+
// only re-pin to bottom if the reader was already there — otherwise a
|
|
547
|
+
// background poll (tick() runs every 1.2s) yanks them back mid-scroll
|
|
548
|
+
const wasNearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 80;
|
|
549
|
+
log.innerHTML = '';
|
|
550
|
+
lastSpeaker = null;
|
|
551
|
+
for (const h of full.history) addRow(h.who, h.text, h.color || t.color, h.name || t.name);
|
|
552
|
+
if (full.status === 'thinking') addRow('bot', '…', t.color, t.name);
|
|
553
|
+
if (wasNearBottom) log.scrollTop = log.scrollHeight;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
let pendingFiles = [];
|
|
557
|
+
const attachChips = document.getElementById('attachChips');
|
|
558
|
+
function renderAttachChips() {
|
|
559
|
+
attachChips.innerHTML = '';
|
|
560
|
+
pendingFiles.forEach((f, i) => {
|
|
561
|
+
const chip = document.createElement('span');
|
|
562
|
+
chip.className = 'achip';
|
|
563
|
+
chip.innerHTML = '<span>' + escapeHtml(f.name) + (f.content === null ? ' (binary — name only)' : '') + '</span><span class="ax">✕</span>';
|
|
564
|
+
chip.querySelector('.ax').addEventListener('click', () => { pendingFiles.splice(i, 1); renderAttachChips(); });
|
|
565
|
+
attachChips.appendChild(chip);
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
function readFileAsText(file) {
|
|
569
|
+
return new Promise((resolve) => {
|
|
570
|
+
const r = new FileReader();
|
|
571
|
+
r.onload = () => resolve(r.result);
|
|
572
|
+
r.onerror = () => resolve(null);
|
|
573
|
+
r.readAsText(file);
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function submit() {
|
|
578
|
+
const task = inp.value.trim();
|
|
579
|
+
if ((!task && !pendingFiles.length) || !activeId) return;
|
|
580
|
+
inp.value = '';
|
|
581
|
+
send.classList.remove('show');
|
|
582
|
+
let full = task;
|
|
583
|
+
for (const f of pendingFiles) {
|
|
584
|
+
full += f.content !== null
|
|
585
|
+
? '\\n\\n--- attached: ' + f.name + ' ---\\n' + f.content
|
|
586
|
+
: '\\n\\n(attached binary file: ' + f.name + ', ' + f.size + ' bytes — content not readable as text)';
|
|
587
|
+
}
|
|
588
|
+
pendingFiles = [];
|
|
589
|
+
renderAttachChips();
|
|
590
|
+
await fetch('/drive', {
|
|
591
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
592
|
+
body: JSON.stringify({ threadId: activeId, task: full }),
|
|
593
|
+
});
|
|
594
|
+
render();
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
inp.addEventListener('input', () => { send.classList.toggle('show', inp.value.trim().length > 0 || pendingFiles.length > 0); });
|
|
598
|
+
send.addEventListener('click', submit);
|
|
599
|
+
inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
|
|
600
|
+
|
|
601
|
+
const plusBtn = document.getElementById('plusBtn');
|
|
602
|
+
const plusMenu = document.getElementById('plusMenu');
|
|
603
|
+
const fileInp = document.getElementById('fileInp');
|
|
604
|
+
plusBtn.addEventListener('click', (e) => { e.stopPropagation(); plusMenu.classList.toggle('show'); });
|
|
605
|
+
document.addEventListener('click', () => plusMenu.classList.remove('show'));
|
|
606
|
+
document.getElementById('attachBtn').addEventListener('click', (e) => { e.stopPropagation(); plusMenu.classList.remove('show'); fileInp.click(); });
|
|
607
|
+
fileInp.addEventListener('change', async () => {
|
|
608
|
+
for (const f of Array.from(fileInp.files)) {
|
|
609
|
+
const looksText = /^text\\//.test(f.type) || /\\.(txt|md|js|mjs|ts|tsx|jsx|py|json|css|html|csv|log|ya?ml|sh)$/i.test(f.name);
|
|
610
|
+
const content = (looksText && f.size < 200000) ? await readFileAsText(f) : null;
|
|
611
|
+
pendingFiles.push({ name: f.name, size: f.size, content });
|
|
612
|
+
}
|
|
613
|
+
fileInp.value = '';
|
|
614
|
+
renderAttachChips();
|
|
615
|
+
send.classList.toggle('show', inp.value.trim().length > 0 || pendingFiles.length > 0);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
// --- compose overlay ("+" next to search: pick/create Bots, single or group) ---
|
|
619
|
+
const newMsgBtn = document.getElementById('newMsgBtn');
|
|
620
|
+
const composeOverlay = document.getElementById('composeOverlay');
|
|
621
|
+
const composeInp = document.getElementById('composeInp');
|
|
622
|
+
const composeList = document.getElementById('composeList');
|
|
623
|
+
const chipsEl = document.getElementById('chips');
|
|
624
|
+
const composeClose = document.getElementById('composeClose');
|
|
625
|
+
let composeSel = [];
|
|
626
|
+
|
|
627
|
+
function openCompose() {
|
|
628
|
+
composeSel = [];
|
|
629
|
+
chipsEl.innerHTML = '';
|
|
630
|
+
composeInp.value = '';
|
|
631
|
+
renderComposeList();
|
|
632
|
+
composeOverlay.classList.add('show');
|
|
633
|
+
composeInp.focus();
|
|
634
|
+
}
|
|
635
|
+
function closeCompose() { composeOverlay.classList.remove('show'); }
|
|
636
|
+
|
|
637
|
+
function addChip(t) {
|
|
638
|
+
composeSel.push({ name: t.name, color: t.color });
|
|
639
|
+
const chip = document.createElement('span');
|
|
640
|
+
chip.className = 'chip';
|
|
641
|
+
chip.innerHTML = '<span class="cav" style="background:' + t.color + '"></span>' + t.name + '<span class="cx">✕</span>';
|
|
642
|
+
chip.querySelector('.cx').addEventListener('click', () => {
|
|
643
|
+
composeSel = composeSel.filter((c) => c.name !== t.name);
|
|
644
|
+
chip.remove();
|
|
645
|
+
renderComposeList();
|
|
646
|
+
});
|
|
647
|
+
chipsEl.appendChild(chip);
|
|
648
|
+
composeInp.value = '';
|
|
649
|
+
renderComposeList();
|
|
650
|
+
composeInp.focus();
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function renderComposeList() {
|
|
654
|
+
const q = composeInp.value.trim().toLowerCase();
|
|
655
|
+
const chosen = new Set(composeSel.map((c) => c.name));
|
|
656
|
+
const candidates = knownThreads.filter((t) => !chosen.has(t.name) && t.name.toLowerCase().includes(q));
|
|
657
|
+
composeList.innerHTML = '';
|
|
658
|
+
const createRow = document.createElement('div');
|
|
659
|
+
createRow.className = 'crow';
|
|
660
|
+
createRow.innerHTML = '<div class="tavatar" style="background:#3a3a3c;width:28px;height:28px;border-radius:8px;font-size:15px">+</div>' +
|
|
661
|
+
'<div>Create new Bot' + (q ? ': ' + escapeHtml(composeInp.value.trim()) : '') + '</div>' +
|
|
662
|
+
'<div class="kbd"><kbd>⌘</kbd><kbd>1</kbd></div>';
|
|
663
|
+
createRow.addEventListener('click', async () => {
|
|
664
|
+
const name = composeInp.value.trim() || prompt('Bot name?');
|
|
665
|
+
if (!name) return;
|
|
666
|
+
const t = await (await fetch('/threads', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }) })).json();
|
|
667
|
+
activeId = t.id;
|
|
668
|
+
closeCompose();
|
|
669
|
+
await loadThreads(); await render();
|
|
670
|
+
});
|
|
671
|
+
composeList.appendChild(createRow);
|
|
672
|
+
candidates.slice(0, 8).forEach((t, i) => {
|
|
673
|
+
const row = document.createElement('div');
|
|
674
|
+
row.className = 'crow';
|
|
675
|
+
row.innerHTML = '<div class="tavatar" style="background:' + t.color + ';width:28px;height:28px;border-radius:8px;font-size:11px">' + initials(t.name) + '</div>' +
|
|
676
|
+
'<div>' + escapeHtml(t.name) + '</div><div class="kbd"><kbd>⌘</kbd><kbd>' + (i + 2) + '</kbd></div>';
|
|
677
|
+
row.addEventListener('click', () => addChip(t));
|
|
678
|
+
composeList.appendChild(row);
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async function openOrCreateFromCompose() {
|
|
683
|
+
if (composeSel.length === 1) {
|
|
684
|
+
const t = knownThreads.find((x) => x.name === composeSel[0].name);
|
|
685
|
+
if (t) { activeId = t.id; closeCompose(); await loadThreads(); await render(); return; }
|
|
686
|
+
}
|
|
687
|
+
if (composeSel.length > 1) {
|
|
688
|
+
const t = await (await fetch('/threads/group', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ names: composeSel.map((c) => c.name) }) })).json();
|
|
689
|
+
activeId = t.id;
|
|
690
|
+
closeCompose();
|
|
691
|
+
await loadThreads(); await render();
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
newMsgBtn.addEventListener('click', (e) => { e.stopPropagation(); openCompose(); });
|
|
696
|
+
composeClose.addEventListener('click', closeCompose);
|
|
697
|
+
composeOverlay.addEventListener('click', (e) => { if (e.target === composeOverlay) closeCompose(); });
|
|
698
|
+
composeInp.addEventListener('input', renderComposeList);
|
|
699
|
+
composeInp.addEventListener('keydown', (e) => {
|
|
700
|
+
if (e.key === 'Escape') closeCompose();
|
|
701
|
+
if (e.key === 'Enter') openOrCreateFromCompose();
|
|
702
|
+
if (e.key === 'Tab') {
|
|
703
|
+
e.preventDefault();
|
|
704
|
+
const q = composeInp.value.trim().toLowerCase();
|
|
705
|
+
const chosen = new Set(composeSel.map((c) => c.name));
|
|
706
|
+
const cand = knownThreads.find((t) => !chosen.has(t.name) && t.name.toLowerCase().includes(q));
|
|
707
|
+
if (cand) addChip(cand);
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
async function tick() { await loadThreads(); await render(); }
|
|
712
|
+
tick();
|
|
713
|
+
setInterval(tick, 1200);
|
|
714
|
+
|
|
715
|
+
// --- cost HUD (ported from the Hammerspoon menu-bar widget, same source) ---
|
|
716
|
+
const hudBtn = document.getElementById('hudBtn');
|
|
717
|
+
const hud = document.getElementById('hud');
|
|
718
|
+
function usd(n) {
|
|
719
|
+
if (n === null || n === undefined) return '—';
|
|
720
|
+
if (n === 0) return '$0';
|
|
721
|
+
if (n < 0.01) return '$' + n.toFixed(4);
|
|
722
|
+
return '$' + n.toFixed(2);
|
|
723
|
+
}
|
|
724
|
+
async function refreshHud() {
|
|
725
|
+
try {
|
|
726
|
+
// fetched server-side by US (see /hud-summary below) — a renderer fetch
|
|
727
|
+
// straight to x402-tokens.fly.dev fails as an opaque "Failed to fetch":
|
|
728
|
+
// no Access-Control-Allow-Origin on that response, so Chromium blocks
|
|
729
|
+
// reading it even though the request itself succeeds. Our own backend
|
|
730
|
+
// has no such restriction.
|
|
731
|
+
const j = await (await fetch('/hud-summary')).json();
|
|
732
|
+
const t = j.today || {};
|
|
733
|
+
const matched = Number(t.usdPaidWithCogs) || null;
|
|
734
|
+
const cogs = Number(t.usdCogs) || null;
|
|
735
|
+
const direct = Number(t.usdDirect) || null;
|
|
736
|
+
const margin = (cogs !== null && matched) ? Math.round((matched - cogs) / matched * 100) + '%' : '—';
|
|
737
|
+
const saved = (direct !== null && matched) ? (direct / matched).toFixed(1) + 'x' : '—';
|
|
738
|
+
document.getElementById('hPaid').textContent = usd(matched);
|
|
739
|
+
document.getElementById('hCogs').textContent = usd(cogs);
|
|
740
|
+
document.getElementById('hMargin').textContent = margin;
|
|
741
|
+
document.getElementById('hDirect').textContent = usd(direct);
|
|
742
|
+
document.getElementById('hSaved').textContent = saved;
|
|
743
|
+
document.getElementById('hFoot').textContent =
|
|
744
|
+
(t.calls || 0) + ' calls · ' + (t.paid || 0) + ' paid · ' + (t.distinctPayers || 0) + ' payers';
|
|
745
|
+
const you = j.you;
|
|
746
|
+
document.getElementById('hYouSpent').textContent = you ? usd(you.spentUsd) : '— (local proxy not reachable)';
|
|
747
|
+
document.getElementById('hYouCalls').textContent = you ? String(you.paidCalls) : '—';
|
|
748
|
+
} catch (e) {
|
|
749
|
+
document.getElementById('hFoot').textContent = 'error: ' + e.message;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
let hudTimer = null;
|
|
753
|
+
hudBtn.addEventListener('click', (e) => {
|
|
754
|
+
e.stopPropagation();
|
|
755
|
+
hud.classList.toggle('show');
|
|
756
|
+
if (hud.classList.contains('show')) {
|
|
757
|
+
refreshHud();
|
|
758
|
+
hudTimer = setInterval(refreshHud, 30000);
|
|
759
|
+
} else if (hudTimer) {
|
|
760
|
+
clearInterval(hudTimer); hudTimer = null;
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
document.addEventListener('click', (e) => { if (!hud.contains(e.target)) hud.classList.remove('show'); });
|
|
764
|
+
</script>
|
|
765
|
+
</body></html>`;
|
|
766
|
+
|
|
767
|
+
const server = http.createServer((req, res) => {
|
|
768
|
+
if (req.method === 'GET' && req.url === '/hud-summary') {
|
|
769
|
+
(async () => {
|
|
770
|
+
let today = {};
|
|
771
|
+
try { today = (await (await fetch('https://x402-tokens.fly.dev/v1/usage/summary')).json()).today || {}; }
|
|
772
|
+
catch { /* gateway unreachable — HUD shows — for the global rows */ }
|
|
773
|
+
let you = null;
|
|
774
|
+
try { you = await (await fetch('http://127.0.0.1:8402/v1/session')).json(); }
|
|
775
|
+
catch { /* local proxy not running — HUD says so instead of guessing */ }
|
|
776
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
777
|
+
res.end(JSON.stringify({ today, you }));
|
|
778
|
+
})();
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
if (req.method === 'GET' && req.url === '/threads') {
|
|
782
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
783
|
+
res.end(JSON.stringify([...threads.values()].sort((a, b) => b.createdAt - a.createdAt).map(threadSummary)));
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
if (req.method === 'GET' && req.url.startsWith('/threads/')) {
|
|
787
|
+
const t = threads.get(req.url.split('/')[2]);
|
|
788
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
789
|
+
res.end(t ? JSON.stringify({ id: t.id, history: t.history, status: t.status }) : '{}');
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
if (req.method === 'POST' && req.url === '/threads') {
|
|
793
|
+
const chunks = [];
|
|
794
|
+
req.on('data', (d) => chunks.push(d));
|
|
795
|
+
req.on('end', () => {
|
|
796
|
+
let name = 'New Bot';
|
|
797
|
+
try { name = (JSON.parse(Buffer.concat(chunks).toString('utf8')).name || name).toString().trim() || name; }
|
|
798
|
+
catch { /* ignore */ }
|
|
799
|
+
const t = newThread(name, null);
|
|
800
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
801
|
+
res.end(JSON.stringify(threadSummary(t)));
|
|
802
|
+
});
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
if (req.method === 'POST' && req.url === '/threads/group') {
|
|
806
|
+
const chunks = [];
|
|
807
|
+
req.on('data', (d) => chunks.push(d));
|
|
808
|
+
req.on('end', () => {
|
|
809
|
+
let names = [];
|
|
810
|
+
try { names = JSON.parse(Buffer.concat(chunks).toString('utf8')).names || []; } catch { /* ignore */ }
|
|
811
|
+
names = names.filter(Boolean);
|
|
812
|
+
if (!names.length) { res.writeHead(400, { 'content-type': 'application/json' }); res.end('{}'); return; }
|
|
813
|
+
const t = newGroupThread(names);
|
|
814
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
815
|
+
res.end(JSON.stringify(threadSummary(t)));
|
|
816
|
+
});
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
if (req.method === 'POST' && req.url === '/drive') {
|
|
820
|
+
const chunks = [];
|
|
821
|
+
req.on('data', (d) => chunks.push(d));
|
|
822
|
+
req.on('end', async () => {
|
|
823
|
+
let threadId = '', task = '';
|
|
824
|
+
try {
|
|
825
|
+
const j = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
826
|
+
threadId = j.threadId; task = (j.task || '').toString();
|
|
827
|
+
} catch { /* ignore */ }
|
|
828
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
829
|
+
res.end(JSON.stringify({ ok: true }));
|
|
830
|
+
// "/dir <path>" is a LOCAL control command, not sent to the model at
|
|
831
|
+
// all — free, instant, sets which folder this thread's WRITE/READ/SERVE
|
|
832
|
+
// are scoped to. Respecify any time by sending it again.
|
|
833
|
+
const dirCmd = /^\/dir\s+(.+)/.exec(task.trim());
|
|
834
|
+
const t = threads.get(threadId);
|
|
835
|
+
if (dirCmd && t) {
|
|
836
|
+
const full = path.resolve(expandHome(dirCmd[1].trim()));
|
|
837
|
+
let ok = false;
|
|
838
|
+
try { ok = statSync(full).isDirectory(); } catch { /* not a dir / doesn't exist */ }
|
|
839
|
+
if (ok) {
|
|
840
|
+
t.dir = full;
|
|
841
|
+
t.history.push({ who: 'bot', text: `Working directory set to ${full}` });
|
|
842
|
+
} else {
|
|
843
|
+
t.history.push({ who: 'bot', text: `"${full}" isn't a directory that exists.` });
|
|
844
|
+
}
|
|
845
|
+
saveThreads();
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
runTurn(threadId, task).catch(() => {});
|
|
849
|
+
});
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
res.writeHead(200, { 'content-type': 'text/html' });
|
|
853
|
+
res.end(APP_HTML);
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
server.listen(PORT, '127.0.0.1', () => console.log(`[grokui] http://localhost:${PORT}`));
|
package/lib/podagent.mjs
CHANGED
|
@@ -27,14 +27,14 @@ const PORTS = (process.env.OZ_AGENT_PORTS || '1337,6080,1340,6081')
|
|
|
27
27
|
.split(',').map((s) => Number(s.trim())).filter(Boolean);
|
|
28
28
|
const LOG = process.env.OZ_AGENT_LOG || '/var/log/openzoo/agent.jsonl';
|
|
29
29
|
const PROXY = process.env.OZ_PROXY || 'http://127.0.0.1:8402/v1';
|
|
30
|
-
const MODEL = process.env.OZ_BRAIN_MODEL || '
|
|
30
|
+
const MODEL = process.env.OZ_BRAIN_MODEL || 'deepseek/deepseek-v4-pro-0813';
|
|
31
31
|
const MAX_STEPS = Number(process.env.OZ_MAX_STEPS || 10);
|
|
32
32
|
|
|
33
33
|
// Matches the Grok Bot chat surface itself (dark canvas, right-aligned grey
|
|
34
34
|
// user pills, pink-avatar left-aligned replies, pill input bar with + / mic) —
|
|
35
35
|
// this renders INSIDE Grok Bot's own sandbox panel (its sidebar/titlebar are
|
|
36
36
|
// the app's own chrome, not ours), so only the message canvas needs to match.
|
|
37
|
-
const VNC_CHAT_HTML = `<!doctype html>
|
|
37
|
+
export const VNC_CHAT_HTML = `<!doctype html>
|
|
38
38
|
<html><head><meta charset="utf-8"><title>openzoo box</title>
|
|
39
39
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
40
40
|
<style>
|
|
@@ -43,23 +43,28 @@ const VNC_CHAT_HTML = `<!doctype html>
|
|
|
43
43
|
html, body { margin: 0; height: 100%; background: #000; }
|
|
44
44
|
body { color: #ececec; font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
45
45
|
display: flex; flex-direction: column; }
|
|
46
|
-
#log { flex: 1; overflow-y: auto; padding: 28px 24px 12px; display: flex; flex-direction: column; gap:
|
|
47
|
-
.
|
|
48
|
-
|
|
46
|
+
#log { flex: 1; overflow-y: auto; padding: 28px 24px 12px; display: flex; flex-direction: column; gap: 6px; }
|
|
47
|
+
.hdr { align-self: flex-start; display: flex; align-items: center; gap: 6px; margin: 12px 0 4px;
|
|
48
|
+
color: #8e8e93; font-size: 13px; }
|
|
49
|
+
.hdr .avatar { width: 18px; height: 18px; border-radius: 5px; }
|
|
50
|
+
.hdr .avatar svg { width: 10px; height: 10px; }
|
|
51
|
+
.row { display: flex; max-width: 78%; margin: 2px 0; }
|
|
52
|
+
.row.user { align-self: flex-end; }
|
|
49
53
|
.row.bot { align-self: flex-start; }
|
|
50
54
|
.avatar { width: 30px; height: 30px; border-radius: 8px; flex: 0 0 30px; background: #e91e8c;
|
|
51
55
|
display: flex; align-items: center; justify-content: center; }
|
|
52
56
|
.avatar svg { width: 16px; height: 16px; }
|
|
53
|
-
.bubble { padding: 11px
|
|
54
|
-
.row.user .bubble { background: #
|
|
55
|
-
.row.bot .bubble { background:
|
|
57
|
+
.bubble { padding: 11px 16px; border-radius: 20px; white-space: pre-wrap; word-break: break-word; }
|
|
58
|
+
.row.user .bubble { background: #57575c; }
|
|
59
|
+
.row.bot .bubble { background: #262626; color: #ececec; }
|
|
56
60
|
.row.bot.pending .bubble { color: #8e8e93; }
|
|
57
61
|
.dots span { display: inline-block; width: 5px; height: 5px; margin-right: 3px; border-radius: 50%;
|
|
58
62
|
background: #8e8e93; animation: blink 1.2s infinite ease-in-out; }
|
|
59
63
|
.dots span:nth-child(2) { animation-delay: .2s; } .dots span:nth-child(3) { animation-delay: .4s; }
|
|
60
64
|
@keyframes blink { 0%, 80%, 100% { opacity: .25; } 40% { opacity: 1; } }
|
|
61
65
|
#bar { padding: 10px 16px 18px; }
|
|
62
|
-
#
|
|
66
|
+
#row-input { display: flex; align-items: center; gap: 8px; }
|
|
67
|
+
#pill { flex: 1; display: flex; align-items: center; gap: 6px; background: #2c2c2e; border-radius: 26px;
|
|
63
68
|
padding: 8px 10px 8px 14px; }
|
|
64
69
|
.icon-btn { width: 32px; height: 32px; border-radius: 50%; border: none; background: transparent;
|
|
65
70
|
color: #ececec; display: flex; align-items: center; justify-content: center; cursor: pointer;
|
|
@@ -70,17 +75,26 @@ const VNC_CHAT_HTML = `<!doctype html>
|
|
|
70
75
|
padding: 6px 0; min-width: 0; }
|
|
71
76
|
#inp::placeholder { color: #8e8e93; }
|
|
72
77
|
#inp:focus { outline: none; }
|
|
78
|
+
#send { width: 34px; height: 34px; border-radius: 50%; border: none; background: #fff; color: #000;
|
|
79
|
+
display: none; align-items: center; justify-content: center; cursor: pointer; flex: 0 0 34px; }
|
|
80
|
+
#send.show { display: flex; }
|
|
81
|
+
#send svg { width: 16px; height: 16px; }
|
|
73
82
|
</style></head>
|
|
74
83
|
<body>
|
|
75
84
|
<div id="log"></div>
|
|
76
85
|
<div id="bar">
|
|
77
|
-
<div id="
|
|
78
|
-
<
|
|
79
|
-
<
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
<
|
|
86
|
+
<div id="row-input">
|
|
87
|
+
<div id="pill">
|
|
88
|
+
<button class="icon-btn" tabindex="-1">
|
|
89
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
90
|
+
</button>
|
|
91
|
+
<input id="inp" placeholder="Message openzoo" autofocus>
|
|
92
|
+
<button class="icon-btn" tabindex="-1">
|
|
93
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z"/><path d="M19 10v1a7 7 0 0 1-14 0v-1"/><line x1="12" y1="18" x2="12" y2="22"/></svg>
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
<button id="send">
|
|
97
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5"/><path d="M6 11l6-6 6 6"/></svg>
|
|
84
98
|
</button>
|
|
85
99
|
</div>
|
|
86
100
|
</div>
|
|
@@ -89,15 +103,17 @@ const VNC_CHAT_HTML = `<!doctype html>
|
|
|
89
103
|
const inp = document.getElementById('inp');
|
|
90
104
|
const send = document.getElementById('send');
|
|
91
105
|
const AVATAR = '<svg viewBox="0 0 24 24" fill="none"><path d="M6 10c0-1 1-2 2-1l2 2 2-2c1-1 2 0 2 1v2c0 2-2 3-4 3s-4-1-4-3v-2z" fill="#fff"/></svg>';
|
|
106
|
+
let lastWho = null;
|
|
92
107
|
function addRow(who, text) {
|
|
108
|
+
if (who === 'bot' && lastWho !== 'bot') {
|
|
109
|
+
const hdr = document.createElement('div');
|
|
110
|
+
hdr.className = 'hdr';
|
|
111
|
+
hdr.innerHTML = '<span class="avatar">' + AVATAR + '</span><span>openzoo</span>';
|
|
112
|
+
log.appendChild(hdr);
|
|
113
|
+
}
|
|
114
|
+
lastWho = who;
|
|
93
115
|
const row = document.createElement('div');
|
|
94
116
|
row.className = 'row ' + who + (text === null ? ' pending' : '');
|
|
95
|
-
if (who === 'bot') {
|
|
96
|
-
const av = document.createElement('div');
|
|
97
|
-
av.className = 'avatar';
|
|
98
|
-
av.innerHTML = AVATAR;
|
|
99
|
-
row.appendChild(av);
|
|
100
|
-
}
|
|
101
117
|
const bubble = document.createElement('div');
|
|
102
118
|
bubble.className = 'bubble';
|
|
103
119
|
bubble.innerHTML = text === null ? '<span class="dots"><span></span><span></span><span></span></span>' : '';
|
|
@@ -111,7 +127,8 @@ const VNC_CHAT_HTML = `<!doctype html>
|
|
|
111
127
|
const task = inp.value.trim();
|
|
112
128
|
if (!task) return;
|
|
113
129
|
inp.value = '';
|
|
114
|
-
|
|
130
|
+
send.classList.remove('show');
|
|
131
|
+
inp.disabled = true;
|
|
115
132
|
addRow('user', task);
|
|
116
133
|
const pending = addRow('bot', null);
|
|
117
134
|
try {
|
|
@@ -126,8 +143,9 @@ const VNC_CHAT_HTML = `<!doctype html>
|
|
|
126
143
|
pending.row.classList.remove('pending');
|
|
127
144
|
pending.bubble.textContent = 'error: ' + e.message;
|
|
128
145
|
}
|
|
129
|
-
inp.disabled = false;
|
|
146
|
+
inp.disabled = false; inp.focus();
|
|
130
147
|
}
|
|
148
|
+
inp.addEventListener('input', () => { send.classList.toggle('show', inp.value.trim().length > 0); });
|
|
131
149
|
send.addEventListener('click', submit);
|
|
132
150
|
inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
|
|
133
151
|
</script>
|
|
@@ -163,11 +181,15 @@ function execFrame(command, cwd = '/tmp') {
|
|
|
163
181
|
|
|
164
182
|
/** One openzoo chat turn. Paid per call by the box's own wallet via the local
|
|
165
183
|
* proxy — no key, no account. */
|
|
166
|
-
async function brain(messages) {
|
|
184
|
+
export async function brain(messages) {
|
|
167
185
|
const r = await fetch(`${PROXY}/chat/completions`, {
|
|
168
186
|
method: 'POST',
|
|
169
187
|
headers: { 'content-type': 'application/json', authorization: 'Bearer sk-openzoo' },
|
|
170
|
-
|
|
188
|
+
// explicit, not relying on the gateway's "inject when caller said nothing"
|
|
189
|
+
// default — an explicit plugins array is always respected as-is, so this
|
|
190
|
+
// guarantees every bot on every model actually has web search, instead of
|
|
191
|
+
// hoping nothing upstream (local proxy, gateway config) already set one.
|
|
192
|
+
body: JSON.stringify({ model: MODEL, max_tokens: 900, messages, plugins: [{ id: 'web' }] }),
|
|
171
193
|
});
|
|
172
194
|
const j = await r.json().catch(() => ({}));
|
|
173
195
|
return j?.choices?.[0]?.message?.content ?? '';
|
|
@@ -186,9 +208,29 @@ be careful, never destructive, prefer read-before-write, and explain nothing to
|
|
|
186
208
|
PROTOCOL — reply with EXACTLY one line, no prose, no code fences:
|
|
187
209
|
RUN: <a single shell command> to execute a step
|
|
188
210
|
DONE: <a short natural-language answer> when the task is complete (this text is shown in the UI)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
211
|
+
SPAWN: <name> | <task> to delegate an independent subtask to a fresh subagent
|
|
212
|
+
SEND: <name> | <message> to message a subagent you (or the user) already spawned
|
|
213
|
+
PING: <name> to check whether a subagent is still working or has finished
|
|
214
|
+
You are given each command's output before your next line. SPAWN does not block you — the subagent
|
|
215
|
+
runs independently and you continue your own reasoning immediately; check on it later with PING or
|
|
216
|
+
wait for it to SEND you its result. All subagents share the ONE real exec channel (the user's Mac),
|
|
217
|
+
so their shell commands queue safely behind each other automatically — you never need to worry about
|
|
218
|
+
that part. If the task needs no shell (a question, an explanation), answer it directly with a single
|
|
219
|
+
DONE: line. Keep DONE summaries human and useful — they are the assistant's reply to the user, not a
|
|
220
|
+
log.`;
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------- subagents --
|
|
223
|
+
|
|
224
|
+
// name -> { mailbox: string[], done: boolean, result: string|null }. In-process
|
|
225
|
+
// registry — every agent (root task + SPAWNed subagents) shares the ONE real
|
|
226
|
+
// exec channel (queueDrive), but reason (call the brain) independently and
|
|
227
|
+
// concurrently, so SPAWN genuinely doesn't block the spawning agent.
|
|
228
|
+
const agents = new Map();
|
|
229
|
+
|
|
230
|
+
function ensureAgent(name) {
|
|
231
|
+
if (!agents.has(name)) agents.set(name, { mailbox: [], done: false, result: null });
|
|
232
|
+
return agents.get(name);
|
|
233
|
+
}
|
|
192
234
|
|
|
193
235
|
/** The agent loop for one task, executed through the connected daemon. Each
|
|
194
236
|
* RUN is pushed as an exec frame; the daemon's result frames (captured in
|
package/lib/proxy.js
CHANGED
|
@@ -342,6 +342,16 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
342
342
|
// both surfaces: point base_url at /v1 for transparent context spilling,
|
|
343
343
|
// or add /mcp for tools (zoo_bind, zoo_ask...). Running two commands to
|
|
344
344
|
// get both was friction nobody should pay.
|
|
345
|
+
// This wallet's own running total for THIS proxy process — every paid
|
|
346
|
+
// call through this port counts (GUI, MCP, CLI, any harness), not just
|
|
347
|
+
// whichever surface happens to be asking. Local-only, no auth needed:
|
|
348
|
+
// it's a number, not a capability.
|
|
349
|
+
if (req.method === 'GET' && (req.url || '').split('?')[0] === '/v1/session') {
|
|
350
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
351
|
+
res.end(JSON.stringify({ spentUsd: sessionSpent, paidCalls }));
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
345
355
|
if ((req.url || '').split('?')[0] === '/mcp') {
|
|
346
356
|
try {
|
|
347
357
|
const { handleMcpRequest } = await import('./mcphttp.js');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.43.
|
|
3
|
+
"version": "0.43.1",
|
|
4
4
|
"description": "Local x402-paying proxy + MCP server for openzoo.fun — 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",
|