openzoo 0.43.0 → 0.43.2
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 +1124 -0
- package/lib/podagent.mjs +121 -31
- package/lib/proxy.js +27 -0
- package/package.json +1 -1
package/lib/grokui.mjs
ADDED
|
@@ -0,0 +1,1124 @@
|
|
|
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 { exec } from 'node:child_process';
|
|
9
|
+
import http from 'node:http';
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { brain, brainStream, PROXY } from './podagent.mjs';
|
|
15
|
+
|
|
16
|
+
const PORT = Number(process.env.OZ_GROKUI_PORT || 4173);
|
|
17
|
+
const STORE_DIR = path.join(homedir(), '.openzoo');
|
|
18
|
+
const STORE_FILE = path.join(STORE_DIR, 'grokui-threads.json');
|
|
19
|
+
|
|
20
|
+
// Real but SANDBOXED filesystem access for the bots — each THREAD has its own
|
|
21
|
+
// root dir (default: a dedicated workspace, never the user's whole disk), and
|
|
22
|
+
// the user can point a thread at a real project folder with "/dir <path>" in
|
|
23
|
+
// chat. safeResolveIn rejects any path that would escape that thread's root
|
|
24
|
+
// (../, absolute paths, symlink tricks via normalize) — access is real, but
|
|
25
|
+
// always contained to whatever root was explicitly chosen for that thread.
|
|
26
|
+
const WORKSPACE_DIR = path.join(homedir(), '.openzoo', 'grokui-workspace');
|
|
27
|
+
mkdirSync(WORKSPACE_DIR, { recursive: true });
|
|
28
|
+
function expandHome(p) { return p.startsWith('~') ? path.join(homedir(), p.slice(1)) : p; }
|
|
29
|
+
function dirFor(threadId) { return threads.get(threadId)?.dir || WORKSPACE_DIR; }
|
|
30
|
+
function safeResolveIn(base, rel) {
|
|
31
|
+
const full = path.normalize(path.join(base, rel));
|
|
32
|
+
if (full !== base && !full.startsWith(base + path.sep)) {
|
|
33
|
+
throw new Error("path escapes this thread's directory");
|
|
34
|
+
}
|
|
35
|
+
return full;
|
|
36
|
+
}
|
|
37
|
+
const MIME = { html: 'text/html', htm: 'text/html', css: 'text/css', js: 'application/javascript',
|
|
38
|
+
mjs: 'application/javascript', json: 'application/json', png: 'image/png', jpg: 'image/jpeg',
|
|
39
|
+
jpeg: 'image/jpeg', gif: 'image/gif', svg: 'image/svg+xml', txt: 'text/plain', md: 'text/plain' };
|
|
40
|
+
let workspacePort = null;
|
|
41
|
+
// route: /<threadId>/<relpath...> — each thread is served from ITS OWN dir
|
|
42
|
+
const workspaceServer = http.createServer((req, res) => {
|
|
43
|
+
try {
|
|
44
|
+
const urlPath = decodeURIComponent((req.url || '/').split('?')[0]).replace(/^\/+/, '');
|
|
45
|
+
const slash = urlPath.indexOf('/');
|
|
46
|
+
const threadId = slash === -1 ? urlPath : urlPath.slice(0, slash);
|
|
47
|
+
let rel = slash === -1 ? '' : urlPath.slice(slash + 1);
|
|
48
|
+
if (!rel) rel = 'index.html';
|
|
49
|
+
const full = safeResolveIn(dirFor(threadId), rel);
|
|
50
|
+
const data = readFileSync(full);
|
|
51
|
+
const ext = full.split('.').pop();
|
|
52
|
+
res.writeHead(200, { 'content-type': MIME[ext] || 'application/octet-stream' });
|
|
53
|
+
res.end(data);
|
|
54
|
+
} catch {
|
|
55
|
+
res.writeHead(404, { 'content-type': 'text/plain' });
|
|
56
|
+
res.end('not found');
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
workspaceServer.listen(0, '127.0.0.1', () => { workspacePort = workspaceServer.address().port; });
|
|
60
|
+
|
|
61
|
+
const PALETTE = ['#e91e8c', '#34c759', '#ff9500', '#5e5ce6', '#ff3b30', '#0a84ff', '#00c7be'];
|
|
62
|
+
function colorFor(name) {
|
|
63
|
+
let h = 0;
|
|
64
|
+
for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
|
65
|
+
return PALETTE[h % PALETTE.length];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const SYSTEM = `You are a helpful assistant served over openzoo (pay-per-call access to ~435
|
|
69
|
+
models, no API key, no account). Reply normally in plain text, concisely.
|
|
70
|
+
|
|
71
|
+
When a question has a genuine "which tool/service should I use" angle, prefer openzoo's own
|
|
72
|
+
capabilities where they actually fit (this proxy for inference, leCore/zoo_bind for memory,
|
|
73
|
+
the directives below for files/shell/delegation) over generic third-party suggestions. Don't
|
|
74
|
+
force it where it doesn't apply — a real "just use X" answer beats a forced openzoo plug.
|
|
75
|
+
|
|
76
|
+
If — and only if — the request genuinely calls for independent parallel work (the user
|
|
77
|
+
asks you to spawn/delegate/create agents, or a task splits cleanly into independent
|
|
78
|
+
subtasks), you may instead reply with EXACTLY one line, no prose, using one of:
|
|
79
|
+
SPAWN: <short name> | <task for the new agent> create a new thread with its own
|
|
80
|
+
independent agent and give it a task
|
|
81
|
+
SEND: <name> | <message> message an agent thread that already
|
|
82
|
+
exists (yours or one you spawned)
|
|
83
|
+
PING: <name> one-line status: still working, or
|
|
84
|
+
its last result
|
|
85
|
+
PEEK: <name> a fuller look — its last few messages,
|
|
86
|
+
not just the latest one
|
|
87
|
+
You are given the result before your next line, so none of these block you — check back
|
|
88
|
+
later if it's still working.
|
|
89
|
+
|
|
90
|
+
You ALSO have real (sandboxed) filesystem access, scoped to THIS thread's own directory —
|
|
91
|
+
the user sets or changes it by sending "/dir <path>" in chat; until they do, it's a private
|
|
92
|
+
workspace folder, not their real project. Same one-line-no-prose reply format:
|
|
93
|
+
WRITE: <relative path> | <content> create or overwrite a file
|
|
94
|
+
READ: <relative path> read a file back
|
|
95
|
+
SERVE: <relative path, or blank for the dir root> get a real http:// URL for a file —
|
|
96
|
+
use this instead of claiming you
|
|
97
|
+
"can't expose a port": you can serve
|
|
98
|
+
static files, just not run a process
|
|
99
|
+
FETCH: <url> actually fetch and read a page's real
|
|
100
|
+
text — web search only gives you short
|
|
101
|
+
snippets; use FETCH when asked to
|
|
102
|
+
"read" or quote something specific
|
|
103
|
+
RUN: <shell command> run a REAL shell command in this
|
|
104
|
+
thread's directory — by default this
|
|
105
|
+
pauses and waits for the user to
|
|
106
|
+
approve or deny it before anything
|
|
107
|
+
executes ("/mode auto" in chat skips
|
|
108
|
+
that wait). Use this for anything a
|
|
109
|
+
file write/read/serve can't do —
|
|
110
|
+
installing packages, running a build,
|
|
111
|
+
starting a real process, checking
|
|
112
|
+
actual CLI/login state, etc. — instead
|
|
113
|
+
of guessing or saying you can't.
|
|
114
|
+
For normal questions just answer directly — do not use any of these unless the request
|
|
115
|
+
actually calls for delegation or file work.`;
|
|
116
|
+
|
|
117
|
+
// id -> { id, name, color, parent, messages: [{role,content}], history: [{who,text}], status }
|
|
118
|
+
const threads = new Map();
|
|
119
|
+
|
|
120
|
+
// Threads are the whole point of the app — losing them on every restart (the
|
|
121
|
+
// server got restarted a lot while iterating this session) is a real bug, not
|
|
122
|
+
// a nice-to-have. Plain JSON on disk; the volume of chat here never justifies
|
|
123
|
+
// a database.
|
|
124
|
+
function saveThreads() {
|
|
125
|
+
try {
|
|
126
|
+
mkdirSync(STORE_DIR, { recursive: true });
|
|
127
|
+
writeFileSync(STORE_FILE, JSON.stringify([...threads.values()]));
|
|
128
|
+
} catch { /* best effort */ }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function loadThreads() {
|
|
132
|
+
try {
|
|
133
|
+
if (!existsSync(STORE_FILE)) return false;
|
|
134
|
+
const arr = JSON.parse(readFileSync(STORE_FILE, 'utf8'));
|
|
135
|
+
if (!Array.isArray(arr) || !arr.length) return false;
|
|
136
|
+
for (const t of arr) threads.set(t.id, t);
|
|
137
|
+
return true;
|
|
138
|
+
} catch { return false; }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function newThread(name, parent, members) {
|
|
142
|
+
const id = randomUUID();
|
|
143
|
+
const t = { id, name, color: members ? members[0].color : colorFor(name), parent: parent || null,
|
|
144
|
+
messages: members ? null : [{ role: 'system', content: SYSTEM }],
|
|
145
|
+
members: members || null, history: [], status: 'idle', createdAt: Date.now(), lastActivityAt: Date.now() };
|
|
146
|
+
threads.set(id, t);
|
|
147
|
+
saveThreads();
|
|
148
|
+
return t;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function makeMember(name) {
|
|
152
|
+
return { name, color: colorFor(name), systemPrompt:
|
|
153
|
+
`You are ${name}, one of several bots in a shared group chat served over openzoo
|
|
154
|
+
(pay-per-call access to ~435 models, no API key, no account). Reply normally and concisely
|
|
155
|
+
as yourself. You can see the WHOLE shared conversation, including what the other bots in
|
|
156
|
+
this group already said — their messages are prefixed "[Name]:" so you can tell them apart
|
|
157
|
+
from the human. A message addressed "@everyone" is meant for the whole group — give your
|
|
158
|
+
own take even if brief ("Passed." is fine when you have nothing to add). COORDINATE: if
|
|
159
|
+
another bot already handled or is handling the request (e.g. already spawned the exact
|
|
160
|
+
agent being asked for), do NOT repeat it — just acknowledge, or add something genuinely new.
|
|
161
|
+
|
|
162
|
+
When a question has a genuine "which tool/service" angle, prefer openzoo's own capabilities
|
|
163
|
+
where they actually fit over generic third-party suggestions — but don't force it.
|
|
164
|
+
|
|
165
|
+
You can ALSO delegate, same as any other agent here. If — and only if — asked to
|
|
166
|
+
spawn/delegate/create agents AND no other bot has already done it this round, reply with
|
|
167
|
+
EXACTLY one line, no prose, using one of:
|
|
168
|
+
SPAWN: <short name> | <task for the new agent> create a new thread with its own agent
|
|
169
|
+
SEND: <name> | <message> message an existing agent thread
|
|
170
|
+
PING: <name> one-line status, or its last result
|
|
171
|
+
PEEK: <name> a fuller look at its last few messages
|
|
172
|
+
|
|
173
|
+
You ALSO have real (sandboxed) filesystem access, scoped to THIS group's own directory —
|
|
174
|
+
the user sets or changes it with "/dir <path>" in chat. Same format:
|
|
175
|
+
WRITE: <relative path> | <content> create or overwrite a file
|
|
176
|
+
READ: <relative path> read a file back
|
|
177
|
+
SERVE: <relative path, or blank for the dir root> get a real http:// URL for it — use
|
|
178
|
+
this instead of saying you can't
|
|
179
|
+
expose a port
|
|
180
|
+
FETCH: <url> actually fetch and read a page's real
|
|
181
|
+
text — web search only gives snippets
|
|
182
|
+
RUN: <shell command> run a REAL shell command in this
|
|
183
|
+
group's shared directory — pauses the
|
|
184
|
+
WHOLE round for the user's approval
|
|
185
|
+
before anything executes ("/mode auto"
|
|
186
|
+
in chat skips that wait). Use this
|
|
187
|
+
instead of guessing or saying you
|
|
188
|
+
can't do something real.
|
|
189
|
+
For normal replies just answer directly — do not use any of these unless the request
|
|
190
|
+
actually calls for delegation or file work.` };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Rebuilds a member's context fresh from the shared thread history every turn
|
|
194
|
+
// (instead of a private per-member log) so each bot sees what the others in
|
|
195
|
+
// the group already said — including earlier replies from THIS round, since
|
|
196
|
+
// runTurn pushes to t.history sequentially, one member at a time.
|
|
197
|
+
function buildMemberMessages(t, member) {
|
|
198
|
+
const msgs = [{ role: 'system', content: member.systemPrompt || SYSTEM }];
|
|
199
|
+
for (const h of t.history) {
|
|
200
|
+
if (h.who === 'user') msgs.push({ role: 'user', content: h.text });
|
|
201
|
+
else if (h.name === member.name) msgs.push({ role: 'assistant', content: h.text });
|
|
202
|
+
else msgs.push({ role: 'user', content: `[${h.name}]: ${h.text}` });
|
|
203
|
+
}
|
|
204
|
+
return msgs;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function newGroupThread(names) {
|
|
208
|
+
const members = names.map(makeMember);
|
|
209
|
+
return newThread(names.join(', '), null, members);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Real leCore binding — POST /v1/hrr/bind on the local proxy, same free
|
|
213
|
+
// passthrough the wiki documents. Fire-and-forget after each turn: the next
|
|
214
|
+
// turn's brain()/brainStream() call picks up t.contextId once it lands, via
|
|
215
|
+
// the X-HRR-Context header, so retrieval is real and automatic, not a prompt
|
|
216
|
+
// claim about a mechanism that doesn't exist.
|
|
217
|
+
async function bindThread(t) {
|
|
218
|
+
const corpus = t.history.map((h) => (h.who === 'user' ? 'you' : (h.name || t.name)) + ': ' + h.text).join('\n');
|
|
219
|
+
if (!corpus.trim()) return;
|
|
220
|
+
try {
|
|
221
|
+
const r = await fetch(`${PROXY}/hrr/bind`, {
|
|
222
|
+
method: 'POST',
|
|
223
|
+
headers: { 'content-type': 'application/json' },
|
|
224
|
+
body: JSON.stringify({ corpus }),
|
|
225
|
+
});
|
|
226
|
+
const j = await r.json().catch(() => ({}));
|
|
227
|
+
if (j?.context_id) { t.contextId = j.context_id; saveThreads(); }
|
|
228
|
+
} catch { /* leCore sidecar unreachable — thread still works, just not bound this round */ }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// REAL shell execution, scoped to the thread's own directory. 'ask' mode
|
|
232
|
+
// (default) pauses and waits for an explicit approve/deny over HTTP before
|
|
233
|
+
// anything runs; 'auto' mode (set via "/mode auto" in chat) runs immediately.
|
|
234
|
+
// Either way this is not sandboxed like WRITE/READ — it can do anything the
|
|
235
|
+
// signed-in user's shell can — so 'ask' is the default, not 'auto'.
|
|
236
|
+
function execCommand(command, cwd) {
|
|
237
|
+
return new Promise((resolve) => {
|
|
238
|
+
exec(command, { cwd, timeout: 120000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
239
|
+
let out = (stdout || '') + (stderr ? '\n' + stderr : '');
|
|
240
|
+
if (err) out += `\n(exit ${err.code ?? 1})`;
|
|
241
|
+
resolve(out.slice(0, 6000) || '(no output)');
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function findByName(name) {
|
|
247
|
+
let best = null;
|
|
248
|
+
for (const t of threads.values()) {
|
|
249
|
+
if (t.name.toLowerCase() === name.toLowerCase() && (!best || t.createdAt > best.createdAt)) best = t;
|
|
250
|
+
}
|
|
251
|
+
return best;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (!loadThreads()) newThread('openzoo', null);
|
|
255
|
+
|
|
256
|
+
// Parses a SPAWN/SEND/PING directive out of a reply, performs its side effect
|
|
257
|
+
// (creating or messaging another thread), and returns the ack text to show in
|
|
258
|
+
// place of the raw directive line — or null if the reply wasn't a directive.
|
|
259
|
+
async function tryDirective(reply, originId) {
|
|
260
|
+
const spawn = /^SPAWN:\s*([^|]+)\|\s*([\s\S]+)/.exec(reply);
|
|
261
|
+
if (spawn) {
|
|
262
|
+
const name = spawn[1].trim();
|
|
263
|
+
const task = spawn[2].trim();
|
|
264
|
+
const sub = newThread(name, originId);
|
|
265
|
+
runTurn(sub.id, task).catch(() => {}); // fire and forget — runs independently
|
|
266
|
+
return `Spawned ${name} — working on it.`;
|
|
267
|
+
}
|
|
268
|
+
const sendM = /^SEND:\s*([^|]+)\|\s*([\s\S]+)/.exec(reply);
|
|
269
|
+
if (sendM) {
|
|
270
|
+
const name = sendM[1].trim();
|
|
271
|
+
const msg = sendM[2].trim();
|
|
272
|
+
const target = findByName(name);
|
|
273
|
+
if (target) runTurn(target.id, msg).catch(() => {});
|
|
274
|
+
return target ? `Messaged ${name}.` : `No thread named "${name}" to message.`;
|
|
275
|
+
}
|
|
276
|
+
const ping = /^PING:\s*(.+)/.exec(reply);
|
|
277
|
+
if (ping) {
|
|
278
|
+
const name = ping[1].trim();
|
|
279
|
+
const target = findByName(name);
|
|
280
|
+
const last = target?.history[target.history.length - 1];
|
|
281
|
+
return !target ? `No thread named "${name}".`
|
|
282
|
+
: target.status === 'thinking' ? `${name} is still working.`
|
|
283
|
+
: last ? `${name}: ${last.text}` : `${name} hasn't replied yet.`;
|
|
284
|
+
}
|
|
285
|
+
const peek = /^PEEK:\s*(.+)/.exec(reply);
|
|
286
|
+
if (peek) {
|
|
287
|
+
const name = peek[1].trim();
|
|
288
|
+
const target = findByName(name);
|
|
289
|
+
if (!target) return `No thread named "${name}".`;
|
|
290
|
+
const recent = target.history.slice(-4)
|
|
291
|
+
.map((h) => (h.who === 'user' ? 'you' : (h.name || target.name)) + ': ' + h.text).join('\n');
|
|
292
|
+
return `${name} (${target.status}):\n${recent || '(nothing yet)'}`;
|
|
293
|
+
}
|
|
294
|
+
const write = /^WRITE:\s*([^|]+)\|([\s\S]+)/.exec(reply);
|
|
295
|
+
if (write) {
|
|
296
|
+
const rel = write[1].trim();
|
|
297
|
+
const content = write[2].replace(/^\n/, '');
|
|
298
|
+
try {
|
|
299
|
+
const full = safeResolveIn(dirFor(originId), rel);
|
|
300
|
+
mkdirSync(path.dirname(full), { recursive: true });
|
|
301
|
+
writeFileSync(full, content);
|
|
302
|
+
return `Wrote ${rel} (${Buffer.byteLength(content)} bytes) to ${dirFor(originId)}.`;
|
|
303
|
+
} catch (e) { return `Couldn't write ${rel}: ${e.message}`; }
|
|
304
|
+
}
|
|
305
|
+
const readD = /^READ:\s*(.+)/.exec(reply);
|
|
306
|
+
if (readD) {
|
|
307
|
+
const rel = readD[1].trim();
|
|
308
|
+
try {
|
|
309
|
+
const data = readFileSync(safeResolveIn(dirFor(originId), rel), 'utf8');
|
|
310
|
+
return `${rel}:\n${data.slice(0, 4000)}${data.length > 4000 ? '\n…(truncated)' : ''}`;
|
|
311
|
+
} catch (e) { return `Couldn't read ${rel}: ${e.message}`; }
|
|
312
|
+
}
|
|
313
|
+
const serve = /^SERVE:\s*(.*)$/.exec(reply);
|
|
314
|
+
if (serve) {
|
|
315
|
+
const rel = serve[1].trim();
|
|
316
|
+
if (!workspacePort) return 'Workspace server is still starting — try again in a second.';
|
|
317
|
+
return `Serving at http://localhost:${workspacePort}/${originId}/${rel}`;
|
|
318
|
+
}
|
|
319
|
+
const fetchD = /^FETCH:\s*(\S+)/.exec(reply);
|
|
320
|
+
if (fetchD) {
|
|
321
|
+
const url = fetchD[1].trim();
|
|
322
|
+
try {
|
|
323
|
+
const r = await fetch(url, { headers: { 'user-agent': 'Mozilla/5.0 (openzoo grokui)' } });
|
|
324
|
+
const ct = r.headers.get('content-type') || '';
|
|
325
|
+
let text = await r.text();
|
|
326
|
+
if (ct.includes('html')) {
|
|
327
|
+
text = text
|
|
328
|
+
.replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
329
|
+
.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/&/g, '&')
|
|
330
|
+
.replace(/</g, '<').replace(/>/g, '>').replace(/\s+/g, ' ').trim();
|
|
331
|
+
}
|
|
332
|
+
return `${url} (${r.status}):\n${text.slice(0, 8000)}${text.length > 8000 ? '\n…(truncated)' : ''}`;
|
|
333
|
+
} catch (e) { return `Couldn't fetch ${url}: ${e.message}`; }
|
|
334
|
+
}
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// onEvent (optional) gets live progress for whoever's actually watching this
|
|
339
|
+
// call: {type:'start',name,color} when a bot begins its turn, {type:'delta',
|
|
340
|
+
// name,color,delta} per streamed token, {type:'final',name,color,text} once
|
|
341
|
+
// its full reply (or directive ack) is settled. Background turns — a SPAWNed
|
|
342
|
+
// subagent nobody's looking at yet — run with onEvent omitted and just use
|
|
343
|
+
// the plain non-streaming brain(), which is cheaper when nothing renders it.
|
|
344
|
+
async function runTurn(threadId, userText, onEvent) {
|
|
345
|
+
const t = threads.get(threadId);
|
|
346
|
+
if (!t) return;
|
|
347
|
+
t.history.push({ who: 'user', text: userText });
|
|
348
|
+
t.lastActivityAt = Date.now();
|
|
349
|
+
if (t.members) {
|
|
350
|
+
t.status = 'thinking';
|
|
351
|
+
// sequential, not parallel: each member's context is rebuilt from
|
|
352
|
+
// t.history right before its turn, so it sees every reply (including
|
|
353
|
+
// spawns/sends) the earlier members in THIS round already made
|
|
354
|
+
for (const m of t.members) {
|
|
355
|
+
const msgs = buildMemberMessages(t, m);
|
|
356
|
+
let r = '';
|
|
357
|
+
onEvent?.({ type: 'start', name: m.name, color: m.color });
|
|
358
|
+
try {
|
|
359
|
+
r = onEvent
|
|
360
|
+
? (await brainStream(msgs, (delta) => onEvent({ type: 'delta', name: m.name, color: m.color, delta }), t.contextId)).trim()
|
|
361
|
+
: (await brain(msgs, t.contextId)).trim();
|
|
362
|
+
} catch (e) { r = `error: ${e.message}`; }
|
|
363
|
+
const runMatch = /^RUN:\s*([\s\S]+)/.exec(r);
|
|
364
|
+
if (runMatch) {
|
|
365
|
+
const command = runMatch[1].trim();
|
|
366
|
+
if (t.runMode === 'auto') {
|
|
367
|
+
const output = await execCommand(command, dirFor(t.id));
|
|
368
|
+
const shown = `$ ${command}\n${output}`;
|
|
369
|
+
t.history.push({ who: 'bot', text: shown, name: m.name, color: m.color });
|
|
370
|
+
onEvent?.({ type: 'final', name: m.name, color: m.color, text: shown });
|
|
371
|
+
// this member's turn is done; the round continues to the next member
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const runId = randomUUID();
|
|
375
|
+
t.pendingRun = { runId, command, cwd: dirFor(t.id) };
|
|
376
|
+
t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending', name: m.name, color: m.color });
|
|
377
|
+
onEvent?.({ type: 'run-pending', runId, command, name: m.name, color: m.color });
|
|
378
|
+
// pauses the WHOLE round here — the rest of the group gets their turn
|
|
379
|
+
// on the round that runs after the user approves/denies
|
|
380
|
+
t.status = 'idle';
|
|
381
|
+
t.lastActivityAt = Date.now();
|
|
382
|
+
saveThreads();
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const ack = await tryDirective(r, t.id);
|
|
386
|
+
const finalText = ack ?? (r || '(no response)');
|
|
387
|
+
t.history.push({ who: 'bot', text: finalText, name: m.name, color: m.color });
|
|
388
|
+
onEvent?.({ type: 'final', name: m.name, color: m.color, text: finalText });
|
|
389
|
+
}
|
|
390
|
+
t.status = 'idle';
|
|
391
|
+
saveThreads();
|
|
392
|
+
bindThread(t).catch(() => {});
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
t.messages.push({ role: 'user', content: userText });
|
|
396
|
+
t.status = 'thinking';
|
|
397
|
+
let reply = '';
|
|
398
|
+
onEvent?.({ type: 'start', name: t.name, color: t.color });
|
|
399
|
+
try {
|
|
400
|
+
reply = onEvent
|
|
401
|
+
? (await brainStream(t.messages, (delta) => onEvent({ type: 'delta', name: t.name, color: t.color, delta }), t.contextId)).trim()
|
|
402
|
+
: (await brain(t.messages, t.contextId)).trim();
|
|
403
|
+
} catch (e) {
|
|
404
|
+
reply = `error: ${e.message}`;
|
|
405
|
+
}
|
|
406
|
+
t.messages.push({ role: 'assistant', content: reply });
|
|
407
|
+
const runMatch = /^RUN:\s*([\s\S]+)/.exec(reply);
|
|
408
|
+
if (runMatch) {
|
|
409
|
+
const command = runMatch[1].trim();
|
|
410
|
+
if (t.runMode === 'auto') {
|
|
411
|
+
const output = await execCommand(command, dirFor(t.id));
|
|
412
|
+
const shown = `$ ${command}\n${output}`;
|
|
413
|
+
t.messages.push({ role: 'user', content: `output:\n${output}` });
|
|
414
|
+
t.history.push({ who: 'bot', text: shown });
|
|
415
|
+
onEvent?.({ type: 'final', name: t.name, color: t.color, text: shown });
|
|
416
|
+
} else {
|
|
417
|
+
const runId = randomUUID();
|
|
418
|
+
t.pendingRun = { runId, command, cwd: dirFor(t.id) };
|
|
419
|
+
t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending' });
|
|
420
|
+
onEvent?.({ type: 'run-pending', runId, command, name: t.name, color: t.color });
|
|
421
|
+
}
|
|
422
|
+
t.status = 'idle';
|
|
423
|
+
t.lastActivityAt = Date.now();
|
|
424
|
+
saveThreads();
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const ack = await tryDirective(reply, t.id);
|
|
428
|
+
const finalText = ack ?? (reply || '(no response)');
|
|
429
|
+
t.history.push({ who: 'bot', text: finalText });
|
|
430
|
+
onEvent?.({ type: 'final', name: t.name, color: t.color, text: finalText });
|
|
431
|
+
t.status = 'idle';
|
|
432
|
+
t.lastActivityAt = Date.now();
|
|
433
|
+
saveThreads();
|
|
434
|
+
bindThread(t).catch(() => {});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function threadSummary(t) {
|
|
438
|
+
const last = t.history[t.history.length - 1];
|
|
439
|
+
return { id: t.id, name: t.name, color: t.color, parent: t.parent, status: t.status,
|
|
440
|
+
preview: last ? (last.who === 'user' ? last.text : last.text).slice(0, 60) : '',
|
|
441
|
+
createdAt: t.createdAt, lastActivityAt: t.lastActivityAt || t.createdAt,
|
|
442
|
+
dir: t.dir || WORKSPACE_DIR };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const APP_HTML = `<!doctype html>
|
|
446
|
+
<html><head><meta charset="utf-8"><title>openzoo</title>
|
|
447
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
448
|
+
<style>
|
|
449
|
+
:root { color-scheme: dark; }
|
|
450
|
+
* { box-sizing: border-box; }
|
|
451
|
+
html, body { margin: 0; height: 100%; background: #000; }
|
|
452
|
+
body { color: #ececec; font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
453
|
+
display: flex; }
|
|
454
|
+
#dragbar { -webkit-app-region: drag; position: fixed; top: 0; left: 0; right: 0; height: 28px; z-index: 1000; }
|
|
455
|
+
#sidebar { width: 280px; flex: 0 0 280px; border-right: 1px solid #1c1c1e; display: flex; flex-direction: column;
|
|
456
|
+
height: 100vh; padding-top: 28px; }
|
|
457
|
+
#main { padding-top: 28px; }
|
|
458
|
+
#sideTop { display: flex; align-items: center; gap: 4px; padding: 0 8px; }
|
|
459
|
+
#sideTop #search { flex: 1; }
|
|
460
|
+
#search { margin: 12px; padding: 8px 12px; background: #1c1c1e; border-radius: 10px; color: #ececec;
|
|
461
|
+
border: none; font: inherit; }
|
|
462
|
+
#search::placeholder { color: #8e8e93; }
|
|
463
|
+
#threads { flex: 1; overflow-y: auto; }
|
|
464
|
+
.trow { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; border-radius: 10px;
|
|
465
|
+
margin: 0 6px 2px; }
|
|
466
|
+
.trow:hover { background: #17171a; }
|
|
467
|
+
.trow.active { background: #1c1c1e; }
|
|
468
|
+
.tclose { flex: 0 0 20px; width: 20px; height: 20px; border-radius: 50%; border: none; background: transparent;
|
|
469
|
+
color: #8e8e93; display: none; align-items: center; justify-content: center; cursor: pointer;
|
|
470
|
+
font-size: 13px; }
|
|
471
|
+
.trow:hover .tclose { display: flex; }
|
|
472
|
+
.tclose:hover { background: #3a3a3c; color: #ececec; }
|
|
473
|
+
.tavatar { width: 36px; height: 36px; border-radius: 10px; flex: 0 0 36px; display: flex; align-items: center;
|
|
474
|
+
justify-content: center; color: #fff; font-weight: 600; font-size: 14px; }
|
|
475
|
+
.tmeta { min-width: 0; flex: 1; }
|
|
476
|
+
.tname { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
477
|
+
.tprev { font-size: 12px; color: #8e8e93; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
478
|
+
.tdot { width: 8px; height: 8px; border-radius: 50%; background: #0a84ff; flex: 0 0 8px; }
|
|
479
|
+
#main { flex: 1; display: flex; flex-direction: column; height: 100vh; }
|
|
480
|
+
#chatHeader { padding: 14px 20px; border-bottom: 1px solid #1c1c1e; display: flex; align-items: center; gap: 10px;
|
|
481
|
+
font-weight: 600; }
|
|
482
|
+
#chatHeader .tavatar { width: 26px; height: 26px; border-radius: 7px; font-size: 11px; flex: 0 0 26px; }
|
|
483
|
+
.hname { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
|
484
|
+
.hdir { font-weight: 400; font-size: 11px; color: #8e8e93; white-space: nowrap; overflow: hidden;
|
|
485
|
+
text-overflow: ellipsis; max-width: 420px; }
|
|
486
|
+
#hudBtn { margin-left: auto; }
|
|
487
|
+
#chatHeaderId { display: flex; align-items: center; gap: 10px; }
|
|
488
|
+
#hud { position: fixed; top: 40px; right: 14px; width: 250px; background: rgba(14,14,17,.94);
|
|
489
|
+
border: 1px solid #333340; border-radius: 10px; padding: 12px 14px; font: 11px/1.5 Menlo, monospace;
|
|
490
|
+
display: none; z-index: 300; box-shadow: 0 12px 30px rgba(0,0,0,.5); }
|
|
491
|
+
#hud.show { display: block; }
|
|
492
|
+
#hud .htitle { color: #b8f240; font-size: 10px; letter-spacing: .04em; margin-bottom: 10px; }
|
|
493
|
+
#hud .hrow { display: flex; justify-content: space-between; margin: 6px 0; color: #f0f0eb; font-size: 12px; }
|
|
494
|
+
#hud .hrow span:first-child { color: #999aa8; font-size: 10.5px; }
|
|
495
|
+
#hud .hlime { color: #b8f240; }
|
|
496
|
+
#hud .hember { color: #f28c4d; }
|
|
497
|
+
#hud .hfoot { border-top: 1px solid #333340; margin-top: 10px; padding-top: 8px; color: #999aa8; font-size: 10px; }
|
|
498
|
+
#sidebar, #main { -webkit-app-region: no-drag; }
|
|
499
|
+
#log { flex: 1; overflow-y: auto; padding: 20px 24px 12px; display: flex; flex-direction: column; gap: 6px;
|
|
500
|
+
-webkit-user-select: text; user-select: text; }
|
|
501
|
+
.hdr { align-self: flex-start; display: flex; align-items: center; gap: 6px; margin: 12px 0 4px;
|
|
502
|
+
color: #8e8e93; font-size: 13px; }
|
|
503
|
+
.hdr .avatar { width: 18px; height: 18px; border-radius: 5px; display: flex; align-items: center;
|
|
504
|
+
justify-content: center; color: #fff; font-size: 9px; font-weight: 700; }
|
|
505
|
+
.row { display: flex; max-width: 78%; margin: 2px 0; }
|
|
506
|
+
.row.user { align-self: flex-end; }
|
|
507
|
+
.row.bot { align-self: flex-start; }
|
|
508
|
+
.bubble { padding: 11px 16px; border-radius: 20px; white-space: pre-wrap; word-break: break-word;
|
|
509
|
+
-webkit-user-select: text; user-select: text; cursor: text; }
|
|
510
|
+
.bubble a { color: #6ab0ff; text-decoration: underline; cursor: pointer; }
|
|
511
|
+
.runcard { background: #1c1c1e; border: 1px solid #333; border-radius: 14px; padding: 12px 14px; max-width: 100%; }
|
|
512
|
+
.runcmd { font-family: Menlo, monospace; font-size: 12.5px; color: #ececec; white-space: pre-wrap;
|
|
513
|
+
word-break: break-word; margin-bottom: 8px; }
|
|
514
|
+
.runactions { display: flex; gap: 8px; }
|
|
515
|
+
.runbtn { border: none; border-radius: 8px; padding: 6px 14px; font-size: 13px; cursor: pointer; }
|
|
516
|
+
.runbtn.approve { background: #34c759; color: #000; }
|
|
517
|
+
.runbtn.deny { background: #3a3a3c; color: #ececec; }
|
|
518
|
+
.runbtn:disabled { opacity: .5; cursor: default; }
|
|
519
|
+
.runstatus { font-size: 12px; color: #8e8e93; margin-bottom: 6px; }
|
|
520
|
+
.runoutput { font-family: Menlo, monospace; font-size: 11.5px; color: #b8b8b8; white-space: pre-wrap;
|
|
521
|
+
word-break: break-word; max-height: 240px; overflow-y: auto; margin: 0; }
|
|
522
|
+
.row.user .bubble { background: #57575c; }
|
|
523
|
+
.row.bot .bubble { background: #262626; color: #ececec; }
|
|
524
|
+
.row.bot.pending .bubble { color: #8e8e93; }
|
|
525
|
+
.dots span { display: inline-block; width: 5px; height: 5px; margin-right: 3px; border-radius: 50%;
|
|
526
|
+
background: #8e8e93; animation: blink 1.2s infinite ease-in-out; }
|
|
527
|
+
.dots span:nth-child(2) { animation-delay: .2s; } .dots span:nth-child(3) { animation-delay: .4s; }
|
|
528
|
+
@keyframes blink { 0%, 80%, 100% { opacity: .25; } 40% { opacity: 1; } }
|
|
529
|
+
#bar { padding: 10px 16px 18px; position: relative; }
|
|
530
|
+
#row-input { display: flex; align-items: center; gap: 8px; }
|
|
531
|
+
#plusMenu { position: absolute; bottom: 62px; left: 16px; background: #1c1c1e; border-radius: 14px;
|
|
532
|
+
padding: 6px; display: none; flex-direction: column; min-width: 190px;
|
|
533
|
+
box-shadow: 0 8px 24px rgba(0,0,0,.5); z-index: 10; }
|
|
534
|
+
#plusMenu.show { display: flex; }
|
|
535
|
+
.pop-item { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px;
|
|
536
|
+
cursor: pointer; color: #ececec; font-size: 14px; }
|
|
537
|
+
.pop-item:hover { background: #2c2c2e; }
|
|
538
|
+
.pop-item svg { width: 18px; height: 18px; flex: 0 0 18px; }
|
|
539
|
+
.pop-item.record svg { color: #ff3b30; }
|
|
540
|
+
#pill { flex: 1; display: flex; align-items: center; gap: 6px; background: #2c2c2e; border-radius: 26px;
|
|
541
|
+
padding: 8px 10px 8px 14px; }
|
|
542
|
+
.icon-btn { width: 32px; height: 32px; border-radius: 50%; border: none; background: transparent;
|
|
543
|
+
color: #ececec; display: flex; align-items: center; justify-content: center; cursor: pointer;
|
|
544
|
+
flex: 0 0 32px; }
|
|
545
|
+
.icon-btn:hover { background: #3a3a3c; }
|
|
546
|
+
.icon-btn svg { width: 18px; height: 18px; }
|
|
547
|
+
#attachChips { display: flex; gap: 6px; flex-wrap: wrap; padding: 0 16px 6px; }
|
|
548
|
+
.achip { display: flex; align-items: center; gap: 6px; background: #2c2c2e; color: #ececec; border-radius: 10px;
|
|
549
|
+
padding: 4px 8px; font-size: 12px; }
|
|
550
|
+
.achip .ax { cursor: pointer; color: #8e8e93; }
|
|
551
|
+
#inp { flex: 1; background: transparent; border: none; color: #ececec; font: inherit;
|
|
552
|
+
padding: 6px 0; min-width: 0; }
|
|
553
|
+
#inp::placeholder { color: #8e8e93; }
|
|
554
|
+
#inp:focus { outline: none; }
|
|
555
|
+
#send { width: 34px; height: 34px; border-radius: 50%; border: none; background: #fff; color: #000;
|
|
556
|
+
display: none; align-items: center; justify-content: center; cursor: pointer; flex: 0 0 34px; }
|
|
557
|
+
#send.show { display: flex; }
|
|
558
|
+
#send svg { width: 16px; height: 16px; }
|
|
559
|
+
#composeOverlay { position: fixed; inset: 0; background: rgba(0,0,0,.5); display: none; align-items: flex-start;
|
|
560
|
+
justify-content: center; padding-top: 90px; z-index: 200; }
|
|
561
|
+
#composeOverlay.show { display: flex; }
|
|
562
|
+
#composeBox { width: 460px; max-height: 65vh; background: #1c1c1e; border-radius: 16px; overflow: hidden;
|
|
563
|
+
display: flex; flex-direction: column; box-shadow: 0 20px 60px rgba(0,0,0,.6); }
|
|
564
|
+
#composeTo { display: flex; align-items: center; gap: 8px; padding: 14px 16px; border-bottom: 1px solid #2c2c2e;
|
|
565
|
+
color: #8e8e93; flex-wrap: wrap; }
|
|
566
|
+
#chips { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
567
|
+
.chip { display: flex; align-items: center; gap: 6px; background: #2c2c2e; color: #ececec; border-radius: 14px;
|
|
568
|
+
padding: 3px 8px 3px 4px; font-size: 13px; }
|
|
569
|
+
.chip .cav { width: 16px; height: 16px; border-radius: 4px; display: inline-block; }
|
|
570
|
+
.chip .cx { cursor: pointer; color: #8e8e93; margin-left: 2px; }
|
|
571
|
+
#composeTo input { flex: 1; min-width: 100px; background: transparent; border: none; color: #ececec; font: inherit; }
|
|
572
|
+
#composeTo input:focus { outline: none; }
|
|
573
|
+
#composeList { overflow-y: auto; padding: 8px; }
|
|
574
|
+
.crow { display: flex; align-items: center; gap: 10px; padding: 10px; border-radius: 10px; cursor: pointer; }
|
|
575
|
+
.crow:hover { background: #2c2c2e; }
|
|
576
|
+
.crow .kbd { margin-left: auto; display: flex; gap: 4px; }
|
|
577
|
+
kbd { background: #2c2c2e; border-radius: 5px; padding: 2px 6px; font-size: 11px; color: #8e8e93; }
|
|
578
|
+
#composeFoot { display: flex; gap: 16px; padding: 10px 16px; border-top: 1px solid #2c2c2e; color: #8e8e93;
|
|
579
|
+
font-size: 12px; }
|
|
580
|
+
#composeFoot kbd { margin-right: 4px; }
|
|
581
|
+
.mention { background: #3a3a3c; border-radius: 10px; padding: 1px 8px; font-size: 0.92em; }
|
|
582
|
+
</style></head>
|
|
583
|
+
<body>
|
|
584
|
+
<div id="dragbar"></div>
|
|
585
|
+
<div id="sidebar">
|
|
586
|
+
<div id="sideTop">
|
|
587
|
+
<button class="icon-btn" id="newMsgBtn">
|
|
588
|
+
<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>
|
|
589
|
+
</button>
|
|
590
|
+
<input id="search" placeholder="Search">
|
|
591
|
+
</div>
|
|
592
|
+
<div id="threads"></div>
|
|
593
|
+
</div>
|
|
594
|
+
<div id="composeOverlay">
|
|
595
|
+
<div id="composeBox">
|
|
596
|
+
<div id="composeTo">
|
|
597
|
+
<span>To:</span>
|
|
598
|
+
<span id="chips"></span>
|
|
599
|
+
<input id="composeInp" placeholder="Search or create Bots">
|
|
600
|
+
<button class="icon-btn" id="composeClose">
|
|
601
|
+
<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>
|
|
602
|
+
</button>
|
|
603
|
+
</div>
|
|
604
|
+
<div id="composeList"></div>
|
|
605
|
+
<div id="composeFoot"><span><kbd>Tab</kbd> add</span><span><kbd>Enter</kbd> open</span></div>
|
|
606
|
+
</div>
|
|
607
|
+
</div>
|
|
608
|
+
<div id="main">
|
|
609
|
+
<div id="chatHeader">
|
|
610
|
+
<div id="chatHeaderId"></div>
|
|
611
|
+
<button class="icon-btn" id="hudBtn">◎</button>
|
|
612
|
+
</div>
|
|
613
|
+
<div id="hud">
|
|
614
|
+
<div class="htitle">YOUR WALLET · THIS SESSION</div>
|
|
615
|
+
<div class="hrow"><span>you've paid</span><span id="hYouSpent">—</span></div>
|
|
616
|
+
<div class="hrow"><span>our cost (cogs)</span><span id="hYouCogs">—</span></div>
|
|
617
|
+
<div class="hrow"><span>margin</span><span id="hYouMargin" class="hlime">—</span></div>
|
|
618
|
+
<div class="hrow"><span>direct would be</span><span id="hYouDirect" class="hember">—</span></div>
|
|
619
|
+
<div class="hfoot" id="hFoot">loading…</div>
|
|
620
|
+
</div>
|
|
621
|
+
<div id="log"></div>
|
|
622
|
+
<div id="bar">
|
|
623
|
+
<div id="plusMenu">
|
|
624
|
+
<div class="pop-item" id="attachBtn">
|
|
625
|
+
<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>
|
|
626
|
+
<span>Attach files</span>
|
|
627
|
+
</div>
|
|
628
|
+
</div>
|
|
629
|
+
<input id="fileInp" type="file" multiple style="position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;">
|
|
630
|
+
<div id="attachChips"></div>
|
|
631
|
+
<div id="row-input">
|
|
632
|
+
<div id="pill">
|
|
633
|
+
<button class="icon-btn" id="plusBtn">
|
|
634
|
+
<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>
|
|
635
|
+
</button>
|
|
636
|
+
<input id="inp" placeholder="Message" autofocus>
|
|
637
|
+
<button class="icon-btn" tabindex="-1">
|
|
638
|
+
<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>
|
|
639
|
+
</button>
|
|
640
|
+
</div>
|
|
641
|
+
<button id="send">
|
|
642
|
+
<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>
|
|
643
|
+
</button>
|
|
644
|
+
</div>
|
|
645
|
+
</div>
|
|
646
|
+
</div>
|
|
647
|
+
<script>
|
|
648
|
+
const threadsEl = document.getElementById('threads');
|
|
649
|
+
const chatHeader = document.getElementById('chatHeader');
|
|
650
|
+
const log = document.getElementById('log');
|
|
651
|
+
const inp = document.getElementById('inp');
|
|
652
|
+
const send = document.getElementById('send');
|
|
653
|
+
let activeId = null;
|
|
654
|
+
let knownThreads = [];
|
|
655
|
+
|
|
656
|
+
function initials(name) { return name.slice(0, 2).toUpperCase(); }
|
|
657
|
+
|
|
658
|
+
async function loadThreads() {
|
|
659
|
+
const list = await (await fetch('/threads')).json();
|
|
660
|
+
knownThreads = list;
|
|
661
|
+
if (!activeId && list.length) activeId = list[0].id;
|
|
662
|
+
threadsEl.innerHTML = '';
|
|
663
|
+
for (const t of list) {
|
|
664
|
+
const row = document.createElement('div');
|
|
665
|
+
row.className = 'trow' + (t.id === activeId ? ' active' : '');
|
|
666
|
+
row.innerHTML = '<div class="tavatar" style="background:' + t.color + '">' + initials(t.name) + '</div>' +
|
|
667
|
+
'<div class="tmeta"><div class="tname">' + t.name + '</div><div class="tprev">' +
|
|
668
|
+
(t.status === 'thinking' ? 'typing…' : (t.preview || '')) + '</div></div>' +
|
|
669
|
+
(t.status === 'thinking' ? '<div class="tdot"></div>' : '') +
|
|
670
|
+
'<button class="tclose" title="Remove">✕</button>';
|
|
671
|
+
row.addEventListener('click', () => { activeId = t.id; render(); });
|
|
672
|
+
row.querySelector('.tclose').addEventListener('click', async (e) => {
|
|
673
|
+
e.stopPropagation();
|
|
674
|
+
await fetch('/threads/' + t.id, { method: 'DELETE' });
|
|
675
|
+
if (activeId === t.id) activeId = null;
|
|
676
|
+
await loadThreads();
|
|
677
|
+
if (activeId) render();
|
|
678
|
+
});
|
|
679
|
+
threadsEl.appendChild(row);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
async function loadActiveMessages() {
|
|
684
|
+
if (!activeId) return null;
|
|
685
|
+
return await (await fetch('/threads/' + activeId)).json();
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function renderHeader(t) {
|
|
689
|
+
document.getElementById('chatHeaderId').innerHTML =
|
|
690
|
+
'<div class="tavatar" style="background:' + t.color + '">' + initials(t.name) + '</div>' +
|
|
691
|
+
'<div class="hname"><div>' + t.name + '</div><div class="hdir" title="' + escapeHtml(t.dir || '') +
|
|
692
|
+
'">' + escapeHtml(t.dir || '') + ' · type /dir <path> to change</div></div>';
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function escapeHtml(s) { return s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); }
|
|
696
|
+
function renderMentions(text) {
|
|
697
|
+
let out = escapeHtml(text);
|
|
698
|
+
out = out.replace(/(https?:\\/\\/[^\\s<]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>');
|
|
699
|
+
out = out.replace(/@(\\w+)/g, '<span class="mention">\u{1F465} $1</span>');
|
|
700
|
+
return out;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
let lastSpeaker = null;
|
|
704
|
+
function addRow(who, text, color, name, run) {
|
|
705
|
+
const speakerKey = who + '|' + name;
|
|
706
|
+
if (who === 'bot' && speakerKey !== lastSpeaker) {
|
|
707
|
+
const hdr = document.createElement('div');
|
|
708
|
+
hdr.className = 'hdr';
|
|
709
|
+
hdr.innerHTML = '<span class="avatar" style="background:' + color + '">' + initials(name) + '</span><span>' + name + '</span>';
|
|
710
|
+
log.appendChild(hdr);
|
|
711
|
+
}
|
|
712
|
+
lastSpeaker = speakerKey;
|
|
713
|
+
const row = document.createElement('div');
|
|
714
|
+
row.className = 'row ' + who;
|
|
715
|
+
if (run) {
|
|
716
|
+
const card = document.createElement('div');
|
|
717
|
+
card.className = 'runcard';
|
|
718
|
+
const cmdEl = document.createElement('div');
|
|
719
|
+
cmdEl.className = 'runcmd';
|
|
720
|
+
cmdEl.textContent = '$ ' + text;
|
|
721
|
+
card.appendChild(cmdEl);
|
|
722
|
+
if (run.status === 'pending') {
|
|
723
|
+
const actions = document.createElement('div');
|
|
724
|
+
actions.className = 'runactions';
|
|
725
|
+
const approve = document.createElement('button');
|
|
726
|
+
approve.className = 'runbtn approve';
|
|
727
|
+
approve.textContent = 'Approve';
|
|
728
|
+
const deny = document.createElement('button');
|
|
729
|
+
deny.className = 'runbtn deny';
|
|
730
|
+
deny.textContent = 'Deny';
|
|
731
|
+
approve.addEventListener('click', async () => {
|
|
732
|
+
approve.disabled = true; deny.disabled = true;
|
|
733
|
+
await fetch('/threads/' + activeId + '/run/' + run.id + '/approve', { method: 'POST' });
|
|
734
|
+
render();
|
|
735
|
+
});
|
|
736
|
+
deny.addEventListener('click', async () => {
|
|
737
|
+
approve.disabled = true; deny.disabled = true;
|
|
738
|
+
await fetch('/threads/' + activeId + '/run/' + run.id + '/deny', { method: 'POST' });
|
|
739
|
+
render();
|
|
740
|
+
});
|
|
741
|
+
actions.appendChild(approve);
|
|
742
|
+
actions.appendChild(deny);
|
|
743
|
+
card.appendChild(actions);
|
|
744
|
+
} else {
|
|
745
|
+
const status = document.createElement('div');
|
|
746
|
+
status.className = 'runstatus';
|
|
747
|
+
status.textContent = run.status === 'running' ? 'Running…' : run.status === 'denied' ? 'Denied' : 'Done';
|
|
748
|
+
card.appendChild(status);
|
|
749
|
+
if (run.output) {
|
|
750
|
+
const out = document.createElement('pre');
|
|
751
|
+
out.className = 'runoutput';
|
|
752
|
+
out.textContent = run.output;
|
|
753
|
+
card.appendChild(out);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
row.appendChild(card);
|
|
757
|
+
} else {
|
|
758
|
+
const bubble = document.createElement('div');
|
|
759
|
+
bubble.className = 'bubble';
|
|
760
|
+
bubble.innerHTML = renderMentions(text);
|
|
761
|
+
row.appendChild(bubble);
|
|
762
|
+
}
|
|
763
|
+
log.appendChild(row);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
async function render() {
|
|
767
|
+
const t = knownThreads.find((x) => x.id === activeId);
|
|
768
|
+
if (!t) return;
|
|
769
|
+
renderHeader(t);
|
|
770
|
+
inp.placeholder = 'Message ' + t.name;
|
|
771
|
+
const full = await loadActiveMessages();
|
|
772
|
+
if (!full || full.id !== activeId) return;
|
|
773
|
+
// only re-pin to bottom if the reader was already there — otherwise a
|
|
774
|
+
// background poll (tick() runs every 1.2s) yanks them back mid-scroll
|
|
775
|
+
const wasNearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 80;
|
|
776
|
+
log.innerHTML = '';
|
|
777
|
+
lastSpeaker = null;
|
|
778
|
+
for (const h of full.history) {
|
|
779
|
+
addRow(h.who, h.text, h.color || t.color, h.name || t.name,
|
|
780
|
+
h.runId ? { id: h.runId, status: h.runStatus, output: h.runOutput } : undefined);
|
|
781
|
+
}
|
|
782
|
+
if (full.status === 'thinking') addRow('bot', '…', t.color, t.name);
|
|
783
|
+
if (wasNearBottom) log.scrollTop = log.scrollHeight;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
let pendingFiles = [];
|
|
787
|
+
const attachChips = document.getElementById('attachChips');
|
|
788
|
+
function renderAttachChips() {
|
|
789
|
+
attachChips.innerHTML = '';
|
|
790
|
+
pendingFiles.forEach((f, i) => {
|
|
791
|
+
const chip = document.createElement('span');
|
|
792
|
+
chip.className = 'achip';
|
|
793
|
+
chip.innerHTML = '<span>' + escapeHtml(f.name) + (f.content === null ? ' (binary — name only)' : '') + '</span><span class="ax">✕</span>';
|
|
794
|
+
chip.querySelector('.ax').addEventListener('click', () => { pendingFiles.splice(i, 1); renderAttachChips(); });
|
|
795
|
+
attachChips.appendChild(chip);
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
function readFileAsText(file) {
|
|
799
|
+
return new Promise((resolve) => {
|
|
800
|
+
const r = new FileReader();
|
|
801
|
+
r.onload = () => resolve(r.result);
|
|
802
|
+
r.onerror = () => resolve(null);
|
|
803
|
+
r.readAsText(file);
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
async function submit() {
|
|
808
|
+
const task = inp.value.trim();
|
|
809
|
+
if ((!task && !pendingFiles.length) || !activeId) return;
|
|
810
|
+
inp.value = '';
|
|
811
|
+
send.classList.remove('show');
|
|
812
|
+
let full = task;
|
|
813
|
+
for (const f of pendingFiles) {
|
|
814
|
+
full += f.content !== null
|
|
815
|
+
? '\\n\\n--- attached: ' + f.name + ' ---\\n' + f.content
|
|
816
|
+
: '\\n\\n(attached binary file: ' + f.name + ', ' + f.size + ' bytes — content not readable as text)';
|
|
817
|
+
}
|
|
818
|
+
pendingFiles = [];
|
|
819
|
+
renderAttachChips();
|
|
820
|
+
await fetch('/drive', {
|
|
821
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
822
|
+
body: JSON.stringify({ threadId: activeId, task: full }),
|
|
823
|
+
});
|
|
824
|
+
render();
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
inp.addEventListener('input', () => { send.classList.toggle('show', inp.value.trim().length > 0 || pendingFiles.length > 0); });
|
|
828
|
+
send.addEventListener('click', submit);
|
|
829
|
+
inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
|
|
830
|
+
|
|
831
|
+
const plusBtn = document.getElementById('plusBtn');
|
|
832
|
+
const plusMenu = document.getElementById('plusMenu');
|
|
833
|
+
const fileInp = document.getElementById('fileInp');
|
|
834
|
+
plusBtn.addEventListener('click', (e) => { e.stopPropagation(); plusMenu.classList.toggle('show'); });
|
|
835
|
+
document.addEventListener('click', () => plusMenu.classList.remove('show'));
|
|
836
|
+
document.getElementById('attachBtn').addEventListener('click', (e) => { e.stopPropagation(); plusMenu.classList.remove('show'); fileInp.click(); });
|
|
837
|
+
fileInp.addEventListener('change', async () => {
|
|
838
|
+
for (const f of Array.from(fileInp.files)) {
|
|
839
|
+
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);
|
|
840
|
+
const content = (looksText && f.size < 200000) ? await readFileAsText(f) : null;
|
|
841
|
+
pendingFiles.push({ name: f.name, size: f.size, content });
|
|
842
|
+
}
|
|
843
|
+
fileInp.value = '';
|
|
844
|
+
renderAttachChips();
|
|
845
|
+
send.classList.toggle('show', inp.value.trim().length > 0 || pendingFiles.length > 0);
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
// --- compose overlay ("+" next to search: pick/create Bots, single or group) ---
|
|
849
|
+
const newMsgBtn = document.getElementById('newMsgBtn');
|
|
850
|
+
const composeOverlay = document.getElementById('composeOverlay');
|
|
851
|
+
const composeInp = document.getElementById('composeInp');
|
|
852
|
+
const composeList = document.getElementById('composeList');
|
|
853
|
+
const chipsEl = document.getElementById('chips');
|
|
854
|
+
const composeClose = document.getElementById('composeClose');
|
|
855
|
+
let composeSel = [];
|
|
856
|
+
|
|
857
|
+
function openCompose() {
|
|
858
|
+
composeSel = [];
|
|
859
|
+
chipsEl.innerHTML = '';
|
|
860
|
+
composeInp.value = '';
|
|
861
|
+
renderComposeList();
|
|
862
|
+
composeOverlay.classList.add('show');
|
|
863
|
+
composeInp.focus();
|
|
864
|
+
}
|
|
865
|
+
function closeCompose() { composeOverlay.classList.remove('show'); }
|
|
866
|
+
|
|
867
|
+
function addChip(t) {
|
|
868
|
+
composeSel.push({ name: t.name, color: t.color });
|
|
869
|
+
const chip = document.createElement('span');
|
|
870
|
+
chip.className = 'chip';
|
|
871
|
+
chip.innerHTML = '<span class="cav" style="background:' + t.color + '"></span>' + t.name + '<span class="cx">✕</span>';
|
|
872
|
+
chip.querySelector('.cx').addEventListener('click', () => {
|
|
873
|
+
composeSel = composeSel.filter((c) => c.name !== t.name);
|
|
874
|
+
chip.remove();
|
|
875
|
+
renderComposeList();
|
|
876
|
+
});
|
|
877
|
+
chipsEl.appendChild(chip);
|
|
878
|
+
composeInp.value = '';
|
|
879
|
+
renderComposeList();
|
|
880
|
+
composeInp.focus();
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function renderComposeList() {
|
|
884
|
+
const q = composeInp.value.trim().toLowerCase();
|
|
885
|
+
const chosen = new Set(composeSel.map((c) => c.name));
|
|
886
|
+
const candidates = knownThreads.filter((t) => !chosen.has(t.name) && t.name.toLowerCase().includes(q));
|
|
887
|
+
composeList.innerHTML = '';
|
|
888
|
+
const createRow = document.createElement('div');
|
|
889
|
+
createRow.className = 'crow';
|
|
890
|
+
createRow.innerHTML = '<div class="tavatar" style="background:#3a3a3c;width:28px;height:28px;border-radius:8px;font-size:15px">+</div>' +
|
|
891
|
+
'<div>Create new Bot' + (q ? ': ' + escapeHtml(composeInp.value.trim()) : '') + '</div>' +
|
|
892
|
+
'<div class="kbd"><kbd>⌘</kbd><kbd>1</kbd></div>';
|
|
893
|
+
createRow.addEventListener('click', async () => {
|
|
894
|
+
const name = composeInp.value.trim() || prompt('Bot name?');
|
|
895
|
+
if (!name) return;
|
|
896
|
+
const t = await (await fetch('/threads', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }) })).json();
|
|
897
|
+
activeId = t.id;
|
|
898
|
+
closeCompose();
|
|
899
|
+
await loadThreads(); await render();
|
|
900
|
+
});
|
|
901
|
+
composeList.appendChild(createRow);
|
|
902
|
+
candidates.slice(0, 8).forEach((t, i) => {
|
|
903
|
+
const row = document.createElement('div');
|
|
904
|
+
row.className = 'crow';
|
|
905
|
+
row.innerHTML = '<div class="tavatar" style="background:' + t.color + ';width:28px;height:28px;border-radius:8px;font-size:11px">' + initials(t.name) + '</div>' +
|
|
906
|
+
'<div>' + escapeHtml(t.name) + '</div><div class="kbd"><kbd>⌘</kbd><kbd>' + (i + 2) + '</kbd></div>';
|
|
907
|
+
row.addEventListener('click', () => addChip(t));
|
|
908
|
+
composeList.appendChild(row);
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
async function openOrCreateFromCompose() {
|
|
913
|
+
if (composeSel.length === 1) {
|
|
914
|
+
const t = knownThreads.find((x) => x.name === composeSel[0].name);
|
|
915
|
+
if (t) { activeId = t.id; closeCompose(); await loadThreads(); await render(); return; }
|
|
916
|
+
}
|
|
917
|
+
if (composeSel.length > 1) {
|
|
918
|
+
const t = await (await fetch('/threads/group', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ names: composeSel.map((c) => c.name) }) })).json();
|
|
919
|
+
activeId = t.id;
|
|
920
|
+
closeCompose();
|
|
921
|
+
await loadThreads(); await render();
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
newMsgBtn.addEventListener('click', (e) => { e.stopPropagation(); openCompose(); });
|
|
926
|
+
composeClose.addEventListener('click', closeCompose);
|
|
927
|
+
composeOverlay.addEventListener('click', (e) => { if (e.target === composeOverlay) closeCompose(); });
|
|
928
|
+
composeInp.addEventListener('input', renderComposeList);
|
|
929
|
+
composeInp.addEventListener('keydown', (e) => {
|
|
930
|
+
if (e.key === 'Escape') closeCompose();
|
|
931
|
+
if (e.key === 'Enter') openOrCreateFromCompose();
|
|
932
|
+
if (e.key === 'Tab') {
|
|
933
|
+
e.preventDefault();
|
|
934
|
+
const q = composeInp.value.trim().toLowerCase();
|
|
935
|
+
const chosen = new Set(composeSel.map((c) => c.name));
|
|
936
|
+
const cand = knownThreads.find((t) => !chosen.has(t.name) && t.name.toLowerCase().includes(q));
|
|
937
|
+
if (cand) addChip(cand);
|
|
938
|
+
}
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
async function tick() { await loadThreads(); await render(); }
|
|
942
|
+
tick();
|
|
943
|
+
setInterval(tick, 1200);
|
|
944
|
+
|
|
945
|
+
// --- cost HUD (ported from the Hammerspoon menu-bar widget, same source) ---
|
|
946
|
+
const hudBtn = document.getElementById('hudBtn');
|
|
947
|
+
const hud = document.getElementById('hud');
|
|
948
|
+
function usd(n) {
|
|
949
|
+
if (n === null || n === undefined) return '—';
|
|
950
|
+
if (n === 0) return '$0';
|
|
951
|
+
if (n < 0.01) return '$' + n.toFixed(4);
|
|
952
|
+
return '$' + n.toFixed(2);
|
|
953
|
+
}
|
|
954
|
+
async function refreshHud() {
|
|
955
|
+
try {
|
|
956
|
+
// fetched server-side by US (see /hud-summary below) — a renderer fetch
|
|
957
|
+
// straight to localhost:8402 would work fine, but routing it through
|
|
958
|
+
// our own backend keeps one fetch path if that ever needs to change.
|
|
959
|
+
const you = await (await fetch('/hud-summary')).json();
|
|
960
|
+
const spent = Number(you.spentUsd) || 0;
|
|
961
|
+
const cogs = Number(you.cogsUsd) || 0;
|
|
962
|
+
const direct = Number(you.directUsd) || 0;
|
|
963
|
+
const margin = spent > 0 ? Math.round((spent - cogs) / spent * 100) + '%' : '—';
|
|
964
|
+
document.getElementById('hYouSpent').textContent = usd(spent);
|
|
965
|
+
document.getElementById('hYouCogs').textContent = usd(cogs);
|
|
966
|
+
document.getElementById('hYouMargin').textContent = margin;
|
|
967
|
+
document.getElementById('hYouDirect').textContent = usd(direct);
|
|
968
|
+
document.getElementById('hFoot').textContent = (you.paidCalls || 0) + ' paid calls this session';
|
|
969
|
+
} catch (e) {
|
|
970
|
+
document.getElementById('hFoot').textContent = 'error: ' + e.message;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
let hudTimer = null;
|
|
974
|
+
hudBtn.addEventListener('click', (e) => {
|
|
975
|
+
e.stopPropagation();
|
|
976
|
+
hud.classList.toggle('show');
|
|
977
|
+
if (hud.classList.contains('show')) {
|
|
978
|
+
refreshHud();
|
|
979
|
+
hudTimer = setInterval(refreshHud, 30000);
|
|
980
|
+
} else if (hudTimer) {
|
|
981
|
+
clearInterval(hudTimer); hudTimer = null;
|
|
982
|
+
}
|
|
983
|
+
});
|
|
984
|
+
document.addEventListener('click', (e) => { if (!hud.contains(e.target)) hud.classList.remove('show'); });
|
|
985
|
+
</script>
|
|
986
|
+
</body></html>`;
|
|
987
|
+
|
|
988
|
+
const server = http.createServer((req, res) => {
|
|
989
|
+
if (req.method === 'GET' && req.url === '/hud-summary') {
|
|
990
|
+
(async () => {
|
|
991
|
+
let you = { spentUsd: 0, cogsUsd: 0, directUsd: 0, paidCalls: 0 };
|
|
992
|
+
try { you = await (await fetch('http://127.0.0.1:8402/v1/session')).json(); }
|
|
993
|
+
catch { /* local proxy not running — HUD shows zeros rather than guessing */ }
|
|
994
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
995
|
+
res.end(JSON.stringify(you));
|
|
996
|
+
})();
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
if (req.method === 'GET' && req.url === '/threads') {
|
|
1000
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1001
|
+
res.end(JSON.stringify([...threads.values()].sort((a, b) => b.lastActivityAt - a.lastActivityAt).map(threadSummary)));
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
if (req.method === 'GET' && req.url.startsWith('/threads/')) {
|
|
1005
|
+
const t = threads.get(req.url.split('/')[2]);
|
|
1006
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1007
|
+
res.end(t ? JSON.stringify({ id: t.id, history: t.history, status: t.status }) : '{}');
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
if (req.method === 'DELETE' && req.url.startsWith('/threads/')) {
|
|
1011
|
+
threads.delete(req.url.split('/')[2]);
|
|
1012
|
+
saveThreads();
|
|
1013
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1014
|
+
res.end('{"ok":true}');
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
{
|
|
1018
|
+
const runMatch = /^\/threads\/([^/]+)\/run\/([^/]+)\/(approve|deny)$/.exec(req.url || '');
|
|
1019
|
+
if (req.method === 'POST' && runMatch) {
|
|
1020
|
+
const [, id, runId, action] = runMatch;
|
|
1021
|
+
const t = threads.get(id);
|
|
1022
|
+
const entry = t?.history.find((h) => h.runId === runId && h.runStatus === 'pending');
|
|
1023
|
+
if (!t || !t.pendingRun || t.pendingRun.runId !== runId || !entry) {
|
|
1024
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
1025
|
+
res.end('{"ok":false}');
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
const { command, cwd } = t.pendingRun;
|
|
1029
|
+
delete t.pendingRun;
|
|
1030
|
+
if (action === 'deny') {
|
|
1031
|
+
entry.runStatus = 'denied';
|
|
1032
|
+
saveThreads();
|
|
1033
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1034
|
+
res.end('{"ok":true}');
|
|
1035
|
+
runTurn(t.id, '(you denied running that command)').catch(() => {});
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
entry.runStatus = 'running';
|
|
1039
|
+
saveThreads();
|
|
1040
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1041
|
+
res.end('{"ok":true}');
|
|
1042
|
+
execCommand(command, cwd).then((output) => {
|
|
1043
|
+
entry.runStatus = 'done';
|
|
1044
|
+
entry.runOutput = output;
|
|
1045
|
+
saveThreads();
|
|
1046
|
+
runTurn(t.id, `(command output)\n${output}`).catch(() => {});
|
|
1047
|
+
});
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
if (req.method === 'POST' && req.url === '/threads') {
|
|
1052
|
+
const chunks = [];
|
|
1053
|
+
req.on('data', (d) => chunks.push(d));
|
|
1054
|
+
req.on('end', () => {
|
|
1055
|
+
let name = 'New Bot';
|
|
1056
|
+
try { name = (JSON.parse(Buffer.concat(chunks).toString('utf8')).name || name).toString().trim() || name; }
|
|
1057
|
+
catch { /* ignore */ }
|
|
1058
|
+
const t = newThread(name, null);
|
|
1059
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1060
|
+
res.end(JSON.stringify(threadSummary(t)));
|
|
1061
|
+
});
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
if (req.method === 'POST' && req.url === '/threads/group') {
|
|
1065
|
+
const chunks = [];
|
|
1066
|
+
req.on('data', (d) => chunks.push(d));
|
|
1067
|
+
req.on('end', () => {
|
|
1068
|
+
let names = [];
|
|
1069
|
+
try { names = JSON.parse(Buffer.concat(chunks).toString('utf8')).names || []; } catch { /* ignore */ }
|
|
1070
|
+
names = names.filter(Boolean);
|
|
1071
|
+
if (!names.length) { res.writeHead(400, { 'content-type': 'application/json' }); res.end('{}'); return; }
|
|
1072
|
+
const t = newGroupThread(names);
|
|
1073
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1074
|
+
res.end(JSON.stringify(threadSummary(t)));
|
|
1075
|
+
});
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
if (req.method === 'POST' && req.url === '/drive') {
|
|
1079
|
+
const chunks = [];
|
|
1080
|
+
req.on('data', (d) => chunks.push(d));
|
|
1081
|
+
req.on('end', async () => {
|
|
1082
|
+
let threadId = '', task = '';
|
|
1083
|
+
try {
|
|
1084
|
+
const j = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
1085
|
+
threadId = j.threadId; task = (j.task || '').toString();
|
|
1086
|
+
} catch { /* ignore */ }
|
|
1087
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1088
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1089
|
+
// "/dir <path>" is a LOCAL control command, not sent to the model at
|
|
1090
|
+
// all — free, instant, sets which folder this thread's WRITE/READ/SERVE
|
|
1091
|
+
// are scoped to. Respecify any time by sending it again.
|
|
1092
|
+
const dirCmd = /^\/dir\s+(.+)/.exec(task.trim());
|
|
1093
|
+
const t = threads.get(threadId);
|
|
1094
|
+
if (dirCmd && t) {
|
|
1095
|
+
const full = path.resolve(expandHome(dirCmd[1].trim()));
|
|
1096
|
+
let ok = false;
|
|
1097
|
+
try { ok = statSync(full).isDirectory(); } catch { /* not a dir / doesn't exist */ }
|
|
1098
|
+
if (ok) {
|
|
1099
|
+
t.dir = full;
|
|
1100
|
+
t.history.push({ who: 'bot', text: `Working directory set to ${full}` });
|
|
1101
|
+
} else {
|
|
1102
|
+
t.history.push({ who: 'bot', text: `"${full}" isn't a directory that exists.` });
|
|
1103
|
+
}
|
|
1104
|
+
saveThreads();
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
// "/mode auto|ask" toggles whether RUN: commands execute immediately
|
|
1108
|
+
// or wait for an explicit approve/deny — also free/instant, no model call
|
|
1109
|
+
const modeCmd = /^\/mode\s+(auto|ask)\b/.exec(task.trim());
|
|
1110
|
+
if (modeCmd && t) {
|
|
1111
|
+
t.runMode = modeCmd[1];
|
|
1112
|
+
t.history.push({ who: 'bot', text: `Run mode set to ${modeCmd[1]}${modeCmd[1] === 'auto' ? ' — commands execute immediately, no approval.' : ' — commands wait for your approval.'}` });
|
|
1113
|
+
saveThreads();
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
runTurn(threadId, task).catch(() => {});
|
|
1117
|
+
});
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
res.writeHead(200, { 'content-type': 'text/html' });
|
|
1121
|
+
res.end(APP_HTML);
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
server.listen(PORT, '127.0.0.1', () => console.log(`[grokui] http://localhost:${PORT}`));
|