openzoo 0.35.2 → 0.35.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/gui.html ADDED
@@ -0,0 +1,258 @@
1
+ <!doctype html>
2
+ <html lang="en" data-page="openzoo-gui">
3
+ <head>
4
+ <meta charset="utf-8" data-meta="charset">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>openzoo</title>
7
+ <style data-component="styles">
8
+ :root {
9
+ --paper: #faf6ee; --card: #ffffff; --ink: #22271f; --muted: #7a806f;
10
+ --leaf: #3e7c4f; --leaf-soft: #e3efe0; --line: #e6e0d2; --accent-warm: #f3e8d2;
11
+ }
12
+ * { box-sizing: border-box; }
13
+ body { margin: 0; background: var(--paper); color: var(--ink);
14
+ font: 15px/1.55 ui-rounded, "SF Pro Rounded", -apple-system, "Segoe UI", sans-serif;
15
+ display: flex; flex-direction: column; height: 100vh; }
16
+ header { display: flex; align-items: center; gap: 10px; padding: 12px 18px;
17
+ border-bottom: 1px solid var(--line); background: var(--card); }
18
+ header h1 { font-size: 17px; margin: 0; font-weight: 700; letter-spacing: -.2px; }
19
+ select, button, textarea { font: inherit; color: inherit; }
20
+ select { border: 1px solid var(--line); border-radius: 12px; padding: 6px 10px; background: var(--paper); max-width: 320px; }
21
+ .chip { font-size: 12px; color: var(--muted); background: var(--paper);
22
+ border: 1px solid var(--line); border-radius: 999px; padding: 4px 10px; white-space: nowrap; }
23
+ .chip.on { color: var(--leaf); border-color: var(--leaf); background: var(--leaf-soft); }
24
+ #chat { flex: 1; overflow-y: auto; padding: 22px 0; }
25
+ .row { max-width: 780px; margin: 0 auto 14px; padding: 0 18px; display: flex; }
26
+ .bubble { padding: 10px 14px; border-radius: 16px; width: fit-content;
27
+ max-width: 100%; white-space: pre-wrap; overflow-wrap: break-word; }
28
+ [data-role="bubble-wrap"] { min-width: 0; max-width: min(85%, 640px);
29
+ display: flex; flex-direction: column; }
30
+ .row[data-row="user"] [data-role="bubble-wrap"] { margin-left: auto; align-items: flex-end; }
31
+ .me { background: var(--ink); color: var(--paper); border-bottom-right-radius: 4px; }
32
+ .zoo { background: var(--card); border: 1px solid var(--line); border-bottom-left-radius: 4px; }
33
+ .meta { font-size: 11px; color: var(--muted); margin-top: 4px; }
34
+ footer { border-top: 1px solid var(--line); background: var(--card); padding: 12px 18px; }
35
+ .composer { max-width: 780px; margin: 0 auto; display: flex; gap: 10px; align-items: flex-end; }
36
+ textarea { flex: 1; resize: none; border: 1px solid var(--line); border-radius: 14px;
37
+ padding: 10px 14px; background: var(--paper); min-height: 44px; max-height: 160px; outline: none; }
38
+ textarea:focus { border-color: var(--leaf); }
39
+ .send { background: var(--leaf); color: #fff; border: 0; border-radius: 14px;
40
+ padding: 10px 18px; font-weight: 700; cursor: pointer; }
41
+ .send:active { transform: scale(.97); }
42
+ .ticker { max-width: 780px; margin: 8px auto 0; font-size: 12px; color: var(--muted);
43
+ display: flex; gap: 14px; justify-content: center; }
44
+ #drawer { display: none; border-bottom: 1px solid var(--line); background: var(--accent-warm); padding: 14px 18px; }
45
+ #drawer.open { display: block; }
46
+ .drawer-inner { max-width: 780px; margin: 0 auto; }
47
+ .drawer-inner textarea { width: 100%; background: #fffdf7; min-height: 90px; }
48
+ .tiny { font-size: 12px; color: var(--muted); }
49
+ .ghost { background: none; border: 1px solid var(--line); border-radius: 12px; padding: 6px 12px; cursor: pointer; }
50
+ .ghost:hover { border-color: var(--leaf); color: var(--leaf); }
51
+ </style>
52
+ </head>
53
+ <body data-component="app">
54
+ <header data-component="topbar">
55
+ <span data-role="logo" style="font-size:22px">🦓</span>
56
+ <h1 data-role="title">openzoo</h1>
57
+ <input id="model" list="modelList" data-component="model-picker" placeholder="🔎 pick an animal…"
58
+ style="border:1px solid var(--line);border-radius:12px;padding:6px 10px;background:var(--paper);width:280px">
59
+ <datalist id="modelList" data-component="model-datalist"></datalist>
60
+ <button class="ghost" id="brainBtn" data-component="brain-toggle">🧠 teach the zoo</button>
61
+ <button class="ghost" id="newBtn" data-component="new-chat" title="fresh session">✨ new</button>
62
+ <span class="chip" id="ctxChip" data-component="ctx-chip" style="display:none"></span>
63
+ <span style="flex:1" data-role="spacer"></span>
64
+ <span class="chip" id="netChip" data-component="net-chip">connecting…</span>
65
+ </header>
66
+
67
+ <div id="drawer" data-component="brain-drawer">
68
+ <div class="drawer-inner" data-role="drawer-inner">
69
+ <p class="tiny" data-role="drawer-hint" style="margin:0 0 8px">Paste anything big — docs, a codebase, a book. The zoo binds it once
70
+ (free) and every later question retrieves just the relevant slices. That's the whole trick.</p>
71
+ <textarea id="corpus" data-component="corpus-input" placeholder="paste your corpus here… (or drop a .txt/.md file on this box)"></textarea>
72
+ <div style="margin-top:8px; display:flex; gap:10px; align-items:center" data-role="drawer-actions">
73
+ <button class="send" id="bindBtn" data-component="bind-button">bind it</button>
74
+ <span class="tiny" id="bindStatus" data-role="bind-status"></span>
75
+ </div>
76
+ </div>
77
+ </div>
78
+
79
+ <div id="chat" data-component="chat-scroll">
80
+ <div class="row" data-row="welcome"><div class="bubble zoo" data-role="welcome-bubble">welcome to the zoo 🦁 — pick a model up top, say anything.
81
+ paste a huge corpus with 🧠 and ask questions against it for pennies: it never gets re-sent, only retrieved.</div></div>
82
+ </div>
83
+
84
+ <footer data-component="composer-bar">
85
+ <div class="composer" data-role="composer">
86
+ <textarea id="box" data-component="message-input" placeholder="say something to the animals…" rows="1"></textarea>
87
+ <button class="send" id="sendBtn" data-component="send-button">send</button>
88
+ </div>
89
+ <div class="ticker" data-component="ticker">
90
+ <span id="spent" data-role="spent-ticker">this session: $0.0000</span>
91
+ <span id="saved" data-role="saved-ticker"></span>
92
+ <span id="calls" data-role="calls-ticker"></span>
93
+ </div>
94
+ </footer>
95
+
96
+ <script data-component="app-script">
97
+ const $ = (id) => document.getElementById(id);
98
+ const SKEY = "openzoo.session.v1";
99
+ const state = Object.assign(
100
+ { messages: [], ctx: null, spent: 0, saved: 0, calls: 0, model: "" },
101
+ JSON.parse(localStorage.getItem(SKEY) || "{}"),
102
+ { busy: false },
103
+ );
104
+ function persist() {
105
+ const { messages, ctx, spent, saved, calls } = state;
106
+ localStorage.setItem(SKEY, JSON.stringify({ messages, ctx, spent, saved, calls, model: $("model")?.value || "" }));
107
+ }
108
+
109
+ const ANIMAL = (id) => id.startsWith("anthropic") ? "🦒" : id.startsWith("openai") ? "🦉"
110
+ : id.startsWith("deepseek") ? "🐋" : id.startsWith("x-ai") ? "🦊" : id.startsWith("google") ? "🐦"
111
+ : id.startsWith("meta") ? "🦙" : id.startsWith("mistral") ? "🐈" : id.startsWith("qwen") ? "🐼" : "🐾";
112
+
113
+ async function loadModels() {
114
+ try {
115
+ const r = await fetch("/v1/models");
116
+ const d = await r.json();
117
+ const models = (d.data || []).filter((m) => !m.id.startsWith("~") && !m.id.includes(":batch"));
118
+ models.sort((a, b) => a.id.localeCompare(b.id));
119
+ const dl = $("modelList");
120
+ dl.innerHTML = "";
121
+ for (const m of models) {
122
+ const o = document.createElement("option");
123
+ o.value = m.id;
124
+ o.setAttribute("data-option", m.id);
125
+ const inM = m.pricing?.prompt ? (1e6 * m.pricing.prompt).toFixed(2) : "?";
126
+ o.label = `${ANIMAL(m.id)} $${inM}/M`;
127
+ dl.appendChild(o);
128
+ }
129
+ if (!$("model").value) {
130
+ const pick = models.find((m) => m.id.includes("deepseek-v4-pro")) || models[0];
131
+ if (pick) $("model").value = pick.id;
132
+ }
133
+ $("netChip").textContent = `${models.length} animals`;
134
+ $("netChip").classList.add("on");
135
+ } catch { $("netChip").textContent = "proxy unreachable"; }
136
+ }
137
+
138
+ function bubble(text, mine, meta) {
139
+ const row = document.createElement("div");
140
+ row.className = "row";
141
+ row.setAttribute("data-row", mine ? "user" : "assistant");
142
+ const wrap = document.createElement("div");
143
+ wrap.setAttribute("data-role", "bubble-wrap");
144
+ const b = document.createElement("div");
145
+ b.className = `bubble ${mine ? "me" : "zoo"}`;
146
+ b.setAttribute("data-role", "bubble");
147
+ b.textContent = text;
148
+ wrap.appendChild(b);
149
+ if (meta) {
150
+ const m = document.createElement("div");
151
+ m.className = "meta";
152
+ m.setAttribute("data-role", "bubble-meta");
153
+ m.textContent = meta;
154
+ wrap.appendChild(m);
155
+ }
156
+ row.appendChild(wrap);
157
+ $("chat").appendChild(row);
158
+ $("chat").scrollTop = $("chat").scrollHeight;
159
+ return b;
160
+ }
161
+
162
+ async function send() {
163
+ const text = $("box").value.trim();
164
+ if (!text || state.busy) return;
165
+ state.busy = true;
166
+ $("box").value = "";
167
+ bubble(text, true);
168
+ state.messages.push({ role: "user", content: text });
169
+ const thinking = bubble("…", false);
170
+ try {
171
+ const headers = { "content-type": "application/json" };
172
+ if (state.ctx) headers["x-hrr-context"] = state.ctx;
173
+ const r = await fetch("/v1/chat/completions", {
174
+ method: "POST", headers,
175
+ body: JSON.stringify({ model: $("model").value, messages: state.messages, max_tokens: 2048 }),
176
+ });
177
+ const d = await r.json();
178
+ const content = d.choices?.[0]?.message?.content || d.error?.message || JSON.stringify(d).slice(0, 300);
179
+ thinking.textContent = content;
180
+ state.messages.push({ role: "assistant", content });
181
+ const x = d.x402 || {};
182
+ state.calls += 1;
183
+ if (typeof x.billedUsd === "number") state.spent += x.billedUsd;
184
+ if (typeof x.savesVsDirect === "number" && x.savesVsDirect > 0) state.saved += x.savesVsDirect;
185
+ const bits = [];
186
+ if (typeof x.billedUsd === "number") bits.push(`$${x.billedUsd.toFixed(4)}`);
187
+ if (x.lecore?.engaged) bits.push(`🧠 ${x.lecore.recalled ?? "?"} slices`);
188
+ if (bits.length) thinking.parentElement.querySelector("[data-role=bubble-meta]")?.remove(),
189
+ thinking.insertAdjacentHTML("afterend", `<div class="meta" data-role="bubble-meta">${bits.join(" · ")}</div>`);
190
+ $("spent").textContent = `this session: $${state.spent.toFixed(4)}`;
191
+ if (state.saved > 0) $("saved").textContent = `saved ~$${state.saved.toFixed(4)} vs direct 🎉`;
192
+ $("calls").textContent = `${state.calls} call${state.calls === 1 ? "" : "s"}`;
193
+ persist();
194
+ } catch (e) {
195
+ thinking.textContent = `the zoo hiccuped: ${e.message}`;
196
+ }
197
+ state.busy = false;
198
+ }
199
+
200
+ async function bind() {
201
+ const corpus = $("corpus").value;
202
+ if (!corpus.trim()) return;
203
+ $("bindStatus").textContent = "binding…";
204
+ try {
205
+ const r = await fetch("/v1/hrr/bind", {
206
+ method: "POST", headers: { "content-type": "application/json" },
207
+ body: JSON.stringify({ corpus }),
208
+ });
209
+ const d = await r.json();
210
+ if (d.context_id) {
211
+ state.ctx = d.context_id;
212
+ $("bindStatus").textContent = `bound ${(corpus.length / 1000).toFixed(0)}k chars ✓`;
213
+ const chip = $("ctxChip");
214
+ chip.style.display = "";
215
+ chip.classList.add("on");
216
+ chip.textContent = `🧠 ${d.context_id.slice(0, 14)}… attached`;
217
+ $("drawer").classList.remove("open");
218
+ persist();
219
+ } else {
220
+ $("bindStatus").textContent = d.error?.message || d.error || "bind failed";
221
+ }
222
+ } catch (e) { $("bindStatus").textContent = e.message; }
223
+ }
224
+
225
+ function restore() {
226
+ for (const m of state.messages) bubble(m.content, m.role === "user");
227
+ $("spent").textContent = `this session: $${state.spent.toFixed(4)}`;
228
+ if (state.saved > 0) $("saved").textContent = `saved ~$${state.saved.toFixed(4)} vs direct 🎉`;
229
+ if (state.calls) $("calls").textContent = `${state.calls} call${state.calls === 1 ? "" : "s"}`;
230
+ if (state.model) $("model").value = state.model;
231
+ if (state.ctx) {
232
+ const chip = $("ctxChip");
233
+ chip.style.display = "";
234
+ chip.classList.add("on");
235
+ chip.textContent = `🧠 ${state.ctx.slice(0, 14)}… attached`;
236
+ }
237
+ }
238
+ restore();
239
+ $("newBtn").onclick = () => {
240
+ localStorage.removeItem(SKEY);
241
+ location.reload();
242
+ };
243
+ $("sendBtn").onclick = send;
244
+ $("box").addEventListener("keydown", (e) => {
245
+ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); }
246
+ });
247
+ $("brainBtn").onclick = () => $("drawer").classList.toggle("open");
248
+ $("bindBtn").onclick = bind;
249
+ $("corpus").addEventListener("drop", async (e) => {
250
+ e.preventDefault();
251
+ const f = e.dataTransfer?.files?.[0];
252
+ if (f) $("corpus").value = await f.text();
253
+ });
254
+ $("corpus").addEventListener("dragover", (e) => e.preventDefault());
255
+ loadModels();
256
+ </script>
257
+ </body>
258
+ </html>
package/lib/proxy.js CHANGED
@@ -352,6 +352,23 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
352
352
  return;
353
353
  }
354
354
 
355
+ // THE CUTESY GUI. Local browsers hitting GET / get a little chat app —
356
+ // model zoo, bind-a-corpus drawer, live spent/saved ticker off the x402
357
+ // receipts. Tunnel traffic (cf headers) keeps the JSON discovery below:
358
+ // the GUI is the operator's, not the public's.
359
+ {
360
+ const p0 = (req.url || '').split('?')[0];
361
+ const local = !(req.headers['cf-connecting-ip'] || req.headers['cf-ray']);
362
+ if (local && req.method === 'GET' && (p0 === '/' || p0 === '/gui')) {
363
+ try {
364
+ const html = readFileSync(new URL('./gui.html', import.meta.url), 'utf8');
365
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
366
+ res.end(html);
367
+ return;
368
+ } catch { /* fall through to proxy behavior if the file is missing */ }
369
+ }
370
+ }
371
+
355
372
  const normalized = normalizePath(req.url);
356
373
  if (normalized !== req.url) {
357
374
  log(`path ${req.url} -> ${normalized} (base_url already ends in /v1)`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.35.2",
3
+ "version": "0.35.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",