openzoo 0.43.2 → 0.43.4

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 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
- bubble.innerHTML = renderMentions(text);
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,25 @@ 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
+
191
+ // A non-ok response with no usable content used to silently become '', which
192
+ // grokui.mjs then renders as a generic "(no response)" — indistinguishable
193
+ // from a model that genuinely had nothing to say. Callers should know WHY.
194
+ function httpErrorNote(status) {
195
+ if (status === 402) return '(payment failed — the wallet\'s x402 retry gave up after HTTP 402. Try again; if it keeps happening, check the wallet balance/RPC.)';
196
+ if (status === 429) return '(rate limited — HTTP 429, try again in a moment)';
197
+ if (status >= 500) return `(upstream error — HTTP ${status}, try again)`;
198
+ return status ? `(request failed — HTTP ${status})` : '';
199
+ }
200
+
182
201
  /** One openzoo chat turn. Paid per call by the box's own wallet via the local
183
202
  * proxy — no key, no account. */
184
203
  export async function brain(messages, contextId) {
@@ -196,10 +215,11 @@ export async function brain(messages, contextId) {
196
215
  // guarantees every bot on every model actually has web search, instead of
197
216
  // hoping nothing upstream (local proxy, gateway config) already set one.
198
217
  // 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' }] }),
218
+ body: JSON.stringify({ model: hasImages(messages) ? VISION_MODEL : MODEL, max_tokens: 4096, messages, plugins: [{ id: 'web' }] }),
200
219
  });
201
220
  const j = await r.json().catch(() => ({}));
202
- return j?.choices?.[0]?.message?.content ?? '';
221
+ const content = j?.choices?.[0]?.message?.content;
222
+ return content || (r.ok ? '' : httpErrorNote(r.status));
203
223
  }
204
224
 
205
225
  /** Same call, but streamed — invokes onDelta(text) as tokens arrive (for a
@@ -212,11 +232,12 @@ export async function brainStream(messages, onDelta, contextId) {
212
232
  'content-type': 'application/json', authorization: 'Bearer sk-openzoo',
213
233
  ...(contextId ? { 'x-hrr-context': contextId } : {}),
214
234
  },
215
- body: JSON.stringify({ model: MODEL, max_tokens: 4096, messages, plugins: [{ id: 'web' }], stream: true }),
235
+ body: JSON.stringify({ model: hasImages(messages) ? VISION_MODEL : MODEL, max_tokens: 4096, messages, plugins: [{ id: 'web' }], stream: true }),
216
236
  });
217
237
  if (!r.ok || !r.body) {
218
238
  // fall back to the non-streaming path rather than fail outright
219
- const text = await r.json().then((j) => j?.choices?.[0]?.message?.content ?? '').catch(() => '');
239
+ const content = await r.json().then((j) => j?.choices?.[0]?.message?.content).catch(() => undefined);
240
+ const text = content || (r.ok ? '' : httpErrorNote(r.status));
220
241
  if (text) onDelta(text);
221
242
  return text;
222
243
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.43.2",
3
+ "version": "0.43.4",
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",