openzoo 0.43.2 → 0.43.3
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 +82 -12
- package/lib/podagent.mjs +11 -2
- package/package.json +1 -1
package/lib/grokui.mjs
CHANGED
|
@@ -111,6 +111,11 @@ workspace folder, not their real project. Same one-line-no-prose reply format:
|
|
|
111
111
|
starting a real process, checking
|
|
112
112
|
actual CLI/login state, etc. — instead
|
|
113
113
|
of guessing or saying you can't.
|
|
114
|
+
Via RUN you can also make YOUR OWN paid openzoo calls — POST to
|
|
115
|
+
http://localhost:8402/v1/chat/completions (or /v1/hrr/bind) with curl/python/etc. Auth is
|
|
116
|
+
"Authorization: Bearer sk-openzoo" — any string works, x402 pays per call, not the key. Do
|
|
117
|
+
NOT tell the user you "can't fire the paid calls" or need "their client's bearer key" —
|
|
118
|
+
that's wrong, you can make these calls yourself via RUN.
|
|
114
119
|
For normal questions just answer directly — do not use any of these unless the request
|
|
115
120
|
actually calls for delegation or file work.`;
|
|
116
121
|
|
|
@@ -194,10 +199,21 @@ actually calls for delegation or file work.` };
|
|
|
194
199
|
// (instead of a private per-member log) so each bot sees what the others in
|
|
195
200
|
// the group already said — including earlier replies from THIS round, since
|
|
196
201
|
// runTurn pushes to t.history sequentially, one member at a time.
|
|
202
|
+
// OpenAI-shaped multimodal content: plain string when there's no image,
|
|
203
|
+
// [{type:'text',...}, {type:'image_url',...}] array when there is — mixing
|
|
204
|
+
// the two shapes on a string-only history entry would break providers that
|
|
205
|
+
// expect ONE consistent form per message.
|
|
206
|
+
function contentFor(text, images) {
|
|
207
|
+
if (!images || !images.length) return text;
|
|
208
|
+
// an empty text block alongside image_url content gets rejected (400) by
|
|
209
|
+
// at least one provider path — always give it something
|
|
210
|
+
return [{ type: 'text', text: text || 'Describe this image.' }, ...images.map((url) => ({ type: 'image_url', image_url: { url } }))];
|
|
211
|
+
}
|
|
212
|
+
|
|
197
213
|
function buildMemberMessages(t, member) {
|
|
198
214
|
const msgs = [{ role: 'system', content: member.systemPrompt || SYSTEM }];
|
|
199
215
|
for (const h of t.history) {
|
|
200
|
-
if (h.who === 'user') msgs.push({ role: 'user', content: h.text });
|
|
216
|
+
if (h.who === 'user') msgs.push({ role: 'user', content: contentFor(h.text, h.images) });
|
|
201
217
|
else if (h.name === member.name) msgs.push({ role: 'assistant', content: h.text });
|
|
202
218
|
else msgs.push({ role: 'user', content: `[${h.name}]: ${h.text}` });
|
|
203
219
|
}
|
|
@@ -341,10 +357,10 @@ async function tryDirective(reply, originId) {
|
|
|
341
357
|
// its full reply (or directive ack) is settled. Background turns — a SPAWNed
|
|
342
358
|
// subagent nobody's looking at yet — run with onEvent omitted and just use
|
|
343
359
|
// the plain non-streaming brain(), which is cheaper when nothing renders it.
|
|
344
|
-
async function runTurn(threadId, userText, onEvent) {
|
|
360
|
+
async function runTurn(threadId, userText, onEvent, images) {
|
|
345
361
|
const t = threads.get(threadId);
|
|
346
362
|
if (!t) return;
|
|
347
|
-
t.history.push({ who: 'user', text: userText });
|
|
363
|
+
t.history.push(images && images.length ? { who: 'user', text: userText, images } : { who: 'user', text: userText });
|
|
348
364
|
t.lastActivityAt = Date.now();
|
|
349
365
|
if (t.members) {
|
|
350
366
|
t.status = 'thinking';
|
|
@@ -392,7 +408,7 @@ async function runTurn(threadId, userText, onEvent) {
|
|
|
392
408
|
bindThread(t).catch(() => {});
|
|
393
409
|
return;
|
|
394
410
|
}
|
|
395
|
-
t.messages.push({ role: 'user', content: userText });
|
|
411
|
+
t.messages.push({ role: 'user', content: contentFor(userText, images) });
|
|
396
412
|
t.status = 'thinking';
|
|
397
413
|
let reply = '';
|
|
398
414
|
onEvent?.({ type: 'start', name: t.name, color: t.color });
|
|
@@ -508,6 +524,8 @@ const APP_HTML = `<!doctype html>
|
|
|
508
524
|
.bubble { padding: 11px 16px; border-radius: 20px; white-space: pre-wrap; word-break: break-word;
|
|
509
525
|
-webkit-user-select: text; user-select: text; cursor: text; }
|
|
510
526
|
.bubble a { color: #6ab0ff; text-decoration: underline; cursor: pointer; }
|
|
527
|
+
.bubble-images { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
|
|
528
|
+
.bubble-images img { max-width: 160px; max-height: 160px; border-radius: 12px; display: block; }
|
|
511
529
|
.runcard { background: #1c1c1e; border: 1px solid #333; border-radius: 14px; padding: 12px 14px; max-width: 100%; }
|
|
512
530
|
.runcmd { font-family: Menlo, monospace; font-size: 12.5px; color: #ececec; white-space: pre-wrap;
|
|
513
531
|
word-break: break-word; margin-bottom: 8px; }
|
|
@@ -548,6 +566,11 @@ const APP_HTML = `<!doctype html>
|
|
|
548
566
|
.achip { display: flex; align-items: center; gap: 6px; background: #2c2c2e; color: #ececec; border-radius: 10px;
|
|
549
567
|
padding: 4px 8px; font-size: 12px; }
|
|
550
568
|
.achip .ax { cursor: pointer; color: #8e8e93; }
|
|
569
|
+
.achip.aimg { padding: 4px; position: relative; }
|
|
570
|
+
.achip.aimg img { width: 40px; height: 40px; object-fit: cover; border-radius: 6px; display: block; }
|
|
571
|
+
.achip.aimg .ax { position: absolute; top: -4px; right: -4px; background: #000; border-radius: 50%;
|
|
572
|
+
width: 16px; height: 16px; display: flex; align-items: center; justify-content: center;
|
|
573
|
+
font-size: 10px; }
|
|
551
574
|
#inp { flex: 1; background: transparent; border: none; color: #ececec; font: inherit;
|
|
552
575
|
padding: 6px 0; min-width: 0; }
|
|
553
576
|
#inp::placeholder { color: #8e8e93; }
|
|
@@ -701,7 +724,7 @@ const APP_HTML = `<!doctype html>
|
|
|
701
724
|
}
|
|
702
725
|
|
|
703
726
|
let lastSpeaker = null;
|
|
704
|
-
function addRow(who, text, color, name, run) {
|
|
727
|
+
function addRow(who, text, color, name, run, images) {
|
|
705
728
|
const speakerKey = who + '|' + name;
|
|
706
729
|
if (who === 'bot' && speakerKey !== lastSpeaker) {
|
|
707
730
|
const hdr = document.createElement('div');
|
|
@@ -757,7 +780,19 @@ const APP_HTML = `<!doctype html>
|
|
|
757
780
|
} else {
|
|
758
781
|
const bubble = document.createElement('div');
|
|
759
782
|
bubble.className = 'bubble';
|
|
760
|
-
|
|
783
|
+
if (images && images.length) {
|
|
784
|
+
const strip = document.createElement('div');
|
|
785
|
+
strip.className = 'bubble-images';
|
|
786
|
+
for (const url of images) {
|
|
787
|
+
const img = document.createElement('img');
|
|
788
|
+
img.src = url;
|
|
789
|
+
strip.appendChild(img);
|
|
790
|
+
}
|
|
791
|
+
bubble.appendChild(strip);
|
|
792
|
+
}
|
|
793
|
+
const textEl = document.createElement('div');
|
|
794
|
+
textEl.innerHTML = renderMentions(text);
|
|
795
|
+
bubble.appendChild(textEl);
|
|
761
796
|
row.appendChild(bubble);
|
|
762
797
|
}
|
|
763
798
|
log.appendChild(row);
|
|
@@ -777,13 +812,14 @@ const APP_HTML = `<!doctype html>
|
|
|
777
812
|
lastSpeaker = null;
|
|
778
813
|
for (const h of full.history) {
|
|
779
814
|
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);
|
|
815
|
+
h.runId ? { id: h.runId, status: h.runStatus, output: h.runOutput } : undefined, h.images);
|
|
781
816
|
}
|
|
782
817
|
if (full.status === 'thinking') addRow('bot', '…', t.color, t.name);
|
|
783
818
|
if (wasNearBottom) log.scrollTop = log.scrollHeight;
|
|
784
819
|
}
|
|
785
820
|
|
|
786
821
|
let pendingFiles = [];
|
|
822
|
+
let pendingImages = [];
|
|
787
823
|
const attachChips = document.getElementById('attachChips');
|
|
788
824
|
function renderAttachChips() {
|
|
789
825
|
attachChips.innerHTML = '';
|
|
@@ -794,6 +830,13 @@ const APP_HTML = `<!doctype html>
|
|
|
794
830
|
chip.querySelector('.ax').addEventListener('click', () => { pendingFiles.splice(i, 1); renderAttachChips(); });
|
|
795
831
|
attachChips.appendChild(chip);
|
|
796
832
|
});
|
|
833
|
+
pendingImages.forEach((img, i) => {
|
|
834
|
+
const chip = document.createElement('span');
|
|
835
|
+
chip.className = 'achip aimg';
|
|
836
|
+
chip.innerHTML = '<img src="' + img.dataUrl + '"><span class="ax">✕</span>';
|
|
837
|
+
chip.querySelector('.ax').addEventListener('click', () => { pendingImages.splice(i, 1); renderAttachChips(); });
|
|
838
|
+
attachChips.appendChild(chip);
|
|
839
|
+
});
|
|
797
840
|
}
|
|
798
841
|
function readFileAsText(file) {
|
|
799
842
|
return new Promise((resolve) => {
|
|
@@ -803,28 +846,54 @@ const APP_HTML = `<!doctype html>
|
|
|
803
846
|
r.readAsText(file);
|
|
804
847
|
});
|
|
805
848
|
}
|
|
849
|
+
function readFileAsDataUrl(file) {
|
|
850
|
+
return new Promise((resolve) => {
|
|
851
|
+
const r = new FileReader();
|
|
852
|
+
r.onload = () => resolve(r.result);
|
|
853
|
+
r.onerror = () => resolve(null);
|
|
854
|
+
r.readAsDataURL(file);
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
async function addPastedImage(file) {
|
|
858
|
+
const dataUrl = await readFileAsDataUrl(file);
|
|
859
|
+
if (dataUrl) pendingImages.push({ name: file.name || 'pasted-image', dataUrl });
|
|
860
|
+
renderAttachChips();
|
|
861
|
+
send.classList.toggle('show', inp.value.trim().length > 0 || pendingFiles.length > 0 || pendingImages.length > 0);
|
|
862
|
+
}
|
|
863
|
+
inp.addEventListener('paste', (e) => {
|
|
864
|
+
const items = Array.from(e.clipboardData?.items || []);
|
|
865
|
+
const imageItems = items.filter((it) => it.type && it.type.startsWith('image/'));
|
|
866
|
+
if (!imageItems.length) return; // let normal text paste through
|
|
867
|
+
e.preventDefault();
|
|
868
|
+
for (const it of imageItems) { const f = it.getAsFile(); if (f) addPastedImage(f); }
|
|
869
|
+
});
|
|
806
870
|
|
|
807
871
|
async function submit() {
|
|
808
872
|
const task = inp.value.trim();
|
|
809
|
-
if ((!task && !pendingFiles.length) || !activeId) return;
|
|
873
|
+
if ((!task && !pendingFiles.length && !pendingImages.length) || !activeId) return;
|
|
810
874
|
inp.value = '';
|
|
811
875
|
send.classList.remove('show');
|
|
812
876
|
let full = task;
|
|
877
|
+
// an image with no caption still needs SOME text — an empty text block
|
|
878
|
+
// alongside image_url content gets rejected (400) by at least one path
|
|
879
|
+
if (!full && pendingImages.length) full = 'Describe this image.';
|
|
813
880
|
for (const f of pendingFiles) {
|
|
814
881
|
full += f.content !== null
|
|
815
882
|
? '\\n\\n--- attached: ' + f.name + ' ---\\n' + f.content
|
|
816
883
|
: '\\n\\n(attached binary file: ' + f.name + ', ' + f.size + ' bytes — content not readable as text)';
|
|
817
884
|
}
|
|
885
|
+
const images = pendingImages.map((i) => i.dataUrl);
|
|
818
886
|
pendingFiles = [];
|
|
887
|
+
pendingImages = [];
|
|
819
888
|
renderAttachChips();
|
|
820
889
|
await fetch('/drive', {
|
|
821
890
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
822
|
-
body: JSON.stringify({ threadId: activeId, task: full }),
|
|
891
|
+
body: JSON.stringify({ threadId: activeId, task: full, images }),
|
|
823
892
|
});
|
|
824
893
|
render();
|
|
825
894
|
}
|
|
826
895
|
|
|
827
|
-
inp.addEventListener('input', () => { send.classList.toggle('show', inp.value.trim().length > 0 || pendingFiles.length > 0); });
|
|
896
|
+
inp.addEventListener('input', () => { send.classList.toggle('show', inp.value.trim().length > 0 || pendingFiles.length > 0 || pendingImages.length > 0); });
|
|
828
897
|
send.addEventListener('click', submit);
|
|
829
898
|
inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
|
|
830
899
|
|
|
@@ -1079,10 +1148,11 @@ const server = http.createServer((req, res) => {
|
|
|
1079
1148
|
const chunks = [];
|
|
1080
1149
|
req.on('data', (d) => chunks.push(d));
|
|
1081
1150
|
req.on('end', async () => {
|
|
1082
|
-
let threadId = '', task = '';
|
|
1151
|
+
let threadId = '', task = '', images = [];
|
|
1083
1152
|
try {
|
|
1084
1153
|
const j = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
1085
1154
|
threadId = j.threadId; task = (j.task || '').toString();
|
|
1155
|
+
images = Array.isArray(j.images) ? j.images.filter((u) => typeof u === 'string') : [];
|
|
1086
1156
|
} catch { /* ignore */ }
|
|
1087
1157
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1088
1158
|
res.end(JSON.stringify({ ok: true }));
|
|
@@ -1113,7 +1183,7 @@ const server = http.createServer((req, res) => {
|
|
|
1113
1183
|
saveThreads();
|
|
1114
1184
|
return;
|
|
1115
1185
|
}
|
|
1116
|
-
runTurn(threadId, task).catch(() => {});
|
|
1186
|
+
runTurn(threadId, task, undefined, images).catch(() => {});
|
|
1117
1187
|
});
|
|
1118
1188
|
return;
|
|
1119
1189
|
}
|
package/lib/podagent.mjs
CHANGED
|
@@ -179,6 +179,15 @@ function execFrame(command, cwd = '/tmp') {
|
|
|
179
179
|
|
|
180
180
|
// ------------------------------------------------------------------- brain --
|
|
181
181
|
|
|
182
|
+
// deepseek-v4-pro-0813 (the default MODEL) doesn't expose modality info via
|
|
183
|
+
// /v1/models, and it's not something we can verify blind — rather than gamble
|
|
184
|
+
// on a text-only model silently ignoring pasted images, any message with
|
|
185
|
+
// multimodal (image_url) content routes to a model KNOWN to support vision.
|
|
186
|
+
const VISION_MODEL = process.env.OZ_VISION_MODEL || 'anthropic/claude-sonnet-5';
|
|
187
|
+
function hasImages(messages) {
|
|
188
|
+
return messages.some((m) => Array.isArray(m.content) && m.content.some((c) => c?.type === 'image_url'));
|
|
189
|
+
}
|
|
190
|
+
|
|
182
191
|
/** One openzoo chat turn. Paid per call by the box's own wallet via the local
|
|
183
192
|
* proxy — no key, no account. */
|
|
184
193
|
export async function brain(messages, contextId) {
|
|
@@ -196,7 +205,7 @@ export async function brain(messages, contextId) {
|
|
|
196
205
|
// guarantees every bot on every model actually has web search, instead of
|
|
197
206
|
// hoping nothing upstream (local proxy, gateway config) already set one.
|
|
198
207
|
// 900 was cutting real (especially web-search-backed) answers off mid-sentence
|
|
199
|
-
body: JSON.stringify({ model: MODEL, max_tokens: 4096, messages, plugins: [{ id: 'web' }] }),
|
|
208
|
+
body: JSON.stringify({ model: hasImages(messages) ? VISION_MODEL : MODEL, max_tokens: 4096, messages, plugins: [{ id: 'web' }] }),
|
|
200
209
|
});
|
|
201
210
|
const j = await r.json().catch(() => ({}));
|
|
202
211
|
return j?.choices?.[0]?.message?.content ?? '';
|
|
@@ -212,7 +221,7 @@ export async function brainStream(messages, onDelta, contextId) {
|
|
|
212
221
|
'content-type': 'application/json', authorization: 'Bearer sk-openzoo',
|
|
213
222
|
...(contextId ? { 'x-hrr-context': contextId } : {}),
|
|
214
223
|
},
|
|
215
|
-
body: JSON.stringify({ model: MODEL, max_tokens: 4096, messages, plugins: [{ id: 'web' }], stream: true }),
|
|
224
|
+
body: JSON.stringify({ model: hasImages(messages) ? VISION_MODEL : MODEL, max_tokens: 4096, messages, plugins: [{ id: 'web' }], stream: true }),
|
|
216
225
|
});
|
|
217
226
|
if (!r.ok || !r.body) {
|
|
218
227
|
// fall back to the non-streaming path rather than fail outright
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.43.
|
|
3
|
+
"version": "0.43.3",
|
|
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",
|