@spheredata/embed 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Josh Zhang, Zihuai He
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # @spheredata/embed
2
+
3
+ The embeddable SPHERE agent UI — a framework-agnostic `<sphere-agent>` Web
4
+ Component any web page can drop in, plus optional in-browser tool packs. Shadow
5
+ DOM keeps the host's CSS isolated; the agent loop runs client-side.
6
+
7
+ The product/brand is **SPHERE** — this package is published under the
8
+ `@spheredata` npm scope. The custom element is `<sphere-agent>` and the design
9
+ tokens are `--sphere-*` (unchanged).
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ npm i @spheredata/embed @spheredata/sdk
15
+ ```
16
+
17
+ A partner needs **both**: `@spheredata/sdk` provides `createSphere` (the backend
18
+ client), and this package provides the UI element you wire it into.
19
+
20
+ ## Quick start
21
+
22
+ ```html
23
+ <sphere-agent id="agent" layout="split" attachments>
24
+ <span slot="title">SPHERE Agent · ADRC</span>
25
+ <span slot="notice">Python runs locally; your data never leaves the page.</span>
26
+ </sphere-agent>
27
+
28
+ <script type="module">
29
+ import { createSphere } from "@spheredata/sdk";
30
+ import { pyodideTools, learningStore } from "@spheredata/embed/tools";
31
+ import "@spheredata/embed"; // registers the <sphere-agent> element
32
+
33
+ const sphere = createSphere({ portal: "adrc" });
34
+ const mem = learningStore(sphere.portal);
35
+
36
+ const el = document.getElementById("agent");
37
+ el.sphere = sphere;
38
+ el.systemPrompt = "You are a SPHERE data analyst…";
39
+ el.examples = ["What datasets are available?"];
40
+ el.tools = [...pyodideTools({ baseUrl: "/data/", datasets: { scrna: "scrna.csv" } }), mem.tool];
41
+ el.seed = mem.seed; // inject prior learnings at the start of each conversation
42
+ el.memory = mem; // drive the memory badge (count())
43
+ </script>
44
+ ```
45
+
46
+ ## Element API (properties)
47
+
48
+ | Property | Type | Purpose |
49
+ | --- | --- | --- |
50
+ | `sphere` | client | The `@spheredata/sdk` client (required). |
51
+ | `systemPrompt` | string | System message for the agent. |
52
+ | `tools` | array | Tool objects `{ name, description, input_schema, run() }`. |
53
+ | `examples` | string[] | Starter prompts shown in the welcome state. |
54
+ | `model` | string | Model id override. |
55
+ | `maxTokens` | number | Completion cap (default 4096). |
56
+ | `layout` | `"chat"` \| `"split"` | Single pane, or two-pane with a results panel for figures. |
57
+ | `attachments` | boolean | Enable file attach in the composer. |
58
+ | `accountUrl` | string | Where the "Add credits" link redirects (billing is never embedded). |
59
+ | `seed` | `() => messages[]` | Inject prior messages/learnings at conversation start. |
60
+ | `seedMessages` | array | Static alternative to `seed()`. |
61
+ | `memory` | `{ count() }` | Drives the memory badge. |
62
+
63
+ **Reflected attributes:** `layout`, `attachments`, `account-url`.
64
+ **Slots:** `title`, `notice`.
65
+
66
+ ## Tool packs — `@spheredata/embed/tools`
67
+
68
+ - `pyodideTools({ baseUrl, datasets, dataDictionary, packages })` — Python/pandas in
69
+ the browser (load datasets, run code, return figures). No server round-trip.
70
+ - `learningStore(namespace, storage?)` — persistent per-portal memory; exposes `.tool`,
71
+ `.seed`, and `.count()`.
72
+ - `calculatorTool` — safe arithmetic.
73
+ - `resolveDatasetUrl(baseUrl, datasets, name)` — helper for dataset URL resolution.
74
+
75
+ ## Theming
76
+
77
+ Defaults are the SPHERE brand and match [`@spheredata/design`](https://www.npmjs.com/package/@spheredata/design)
78
+ token values, so a host that imports `@spheredata/design/tokens.css` looks
79
+ identical. To re-skin per partner, override any of this Tier-2 contract on a host
80
+ ancestor (e.g. `:root` or the element):
81
+
82
+ ```css
83
+ :root {
84
+ --sphere-accent: #1d4ed8;
85
+ --sphere-accent-hover: #1e40af;
86
+ --sphere-radius: 6px;
87
+ --sphere-font: "Inter", sans-serif;
88
+ --sphere-surface: #ffffff;
89
+ --sphere-panel: #eef2ff;
90
+ --sphere-user-bg: #e0e7ff;
91
+ }
92
+ ```
93
+
94
+ Full contract: `--sphere-accent`, `--sphere-accent-hover`, `--sphere-radius`,
95
+ `--sphere-font`, `--sphere-surface`, `--sphere-panel`, `--sphere-line`,
96
+ `--sphere-text`, `--sphere-text-muted`, `--sphere-user-bg`, `--sphere-good`,
97
+ `--sphere-warn`, `--sphere-danger`, `--sphere-results-bg`, `--sphere-results-width`.
98
+
99
+ ## License
100
+
101
+ MIT
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@spheredata/embed",
3
+ "version": "0.0.1",
4
+ "publishConfig": { "access": "public" },
5
+ "description": "Embeddable SPHERE agent UI — the <sphere-agent> Web Component + optional tool packs.",
6
+ "keywords": ["sphere", "spheredata", "web-component", "custom-element", "ai", "agent", "embed", "pyodide"],
7
+ "homepage": "https://github.com/statzihuai/sphere-app-dev/tree/main/packages/embed#readme",
8
+ "repository": { "type": "git", "url": "git+https://github.com/statzihuai/sphere-app-dev.git", "directory": "packages/embed" },
9
+ "contributors": ["Josh Zhang", "Zihuai He"],
10
+ "type": "module",
11
+ "main": "sphere-agent.js",
12
+ "module": "sphere-agent.js",
13
+ "exports": {
14
+ ".": "./sphere-agent.js",
15
+ "./tools": "./sphere-tools.js"
16
+ },
17
+ "scripts": {
18
+ "test": "node --test"
19
+ },
20
+ "files": ["sphere-agent.js", "sphere-tools.js"],
21
+ "license": "MIT"
22
+ }
@@ -0,0 +1,727 @@
1
+ // <sphere-agent> — a drop-in agentic chat component any lab portal can import.
2
+ //
3
+ // import { createSphere } from "./sphere-client.js";
4
+ // import { pyodideTools, learningStore } from "./sphere-tools.js";
5
+ // import "./sphere-agent.js";
6
+ // const el = document.querySelector("sphere-agent");
7
+ // const sphere = createSphere({ portal: "adrc" });
8
+ // const mem = learningStore(sphere.portal);
9
+ // el.sphere = sphere;
10
+ // el.systemPrompt = "You are a SPHERE data analyst…";
11
+ // el.tools = [ ...pyodideTools({ baseUrl: "/data/", datasets: {…}, dataDictionary: DD }), mem.tool ];
12
+ // el.examples = ["What datasets are available?"];
13
+ // el.seed = mem.seed; // inject prior learnings at the start of each conversation
14
+ // el.memory = mem; // drive the memory badge (count())
15
+ // el.layout = "split"; // two-pane: chat transcript + results panel for figures
16
+ // el.attachments = true; // enable file attach in the composer
17
+ // el.accountUrl = "https://sphere.example/account"; // where "Add credits" redirects
18
+ //
19
+ // Theming: Shadow DOM keeps the host's CSS from leaking in, while CSS custom properties
20
+ // (which inherit through the shadow boundary) carry the theme in. Defaults are the SPHERE
21
+ // brand and match @spheredata/design's token values, so a host that imports
22
+ // @spheredata/design/tokens.css looks identical. To re-skin per partner/portal, override any of
23
+ // this Tier-2 contract on a host ancestor (e.g. :root or the element):
24
+ // --sphere-accent, --sphere-accent-hover, --sphere-radius, --sphere-font,
25
+ // --sphere-surface, --sphere-panel, --sphere-line, --sphere-text, --sphere-text-muted,
26
+ // --sphere-user-bg, --sphere-good, --sphere-warn, --sphere-danger,
27
+ // --sphere-results-bg, --sphere-results-width.
28
+ //
29
+ // Protocol: speaks the backend's OpenAI-compatible streaming + tool-calling contract (Butterbase is the
30
+ // OpenAI<->Anthropic adapter). Runs the agent loop client-side: one /fn/agent call per model turn, tool
31
+ // results fed back as messages. Account/billing is never embedded — the component redirects to accountUrl.
32
+
33
+ const STYLE = `
34
+ :host {
35
+ /* Per-portal theming (Tier-2 override contract): set any --sphere-* below to
36
+ re-skin this component for a partner. Defaults are the SPHERE brand and match
37
+ @spheredata/design's token values, so a host page that imports
38
+ @spheredata/design/tokens.css inherits identical values — no visual change. */
39
+ --_accent: var(--sphere-accent, #8c1515);
40
+ --_accent-hover: var(--sphere-accent-hover, #a51c1c);
41
+ --_radius: var(--sphere-radius, 10px);
42
+ --_surface: var(--sphere-surface, #ffffff);
43
+ --_panel: var(--sphere-panel, #f8f1e2);
44
+ --_line: var(--sphere-line, #e6dfd0);
45
+ --_text: var(--sphere-text, #1a1817);
46
+ --_muted: var(--sphere-text-muted, #7a7468);
47
+ --_user-bg: var(--sphere-user-bg, #f0eee9);
48
+ --_good: var(--sphere-good, #3f7a4a);
49
+ --_warn: var(--sphere-warn, #b8801f);
50
+ --_danger: var(--sphere-danger, #c0392b);
51
+ --_results-bg: var(--sphere-results-bg, #fbfbfa);
52
+ --_results-width: var(--sphere-results-width, 40%);
53
+ display: flex; flex-direction: column; min-height: 420px; height: 100%;
54
+ font-family: var(--sphere-font, inherit);
55
+ color: var(--_text); background: var(--_surface);
56
+ border: 1px solid var(--_line); border-radius: var(--_radius); overflow: hidden;
57
+ }
58
+ * { box-sizing: border-box; }
59
+ button { font-family: inherit; cursor: pointer; }
60
+ .hidden { display: none !important; }
61
+
62
+ /* auth */
63
+ .auth { padding: 28px; max-width: 460px; margin: auto; width: 100%; }
64
+ .auth h3 { margin: 0 0 6px; font-size: 19px; }
65
+ .auth p { color: var(--_muted); font-size: 14px; margin: 0 0 16px; }
66
+ .tabs { display: flex; gap: 14px; margin-bottom: 12px; }
67
+ .tabs button { background: none; border: none; padding: 6px 2px; font-size: 15px; color: var(--_muted); border-bottom: 2px solid transparent; }
68
+ .tabs button.active { color: var(--_accent); border-bottom-color: var(--_accent); font-weight: 600; }
69
+ label { display: block; font-size: 13px; color: var(--_muted); margin: 10px 0 4px; }
70
+ input, textarea, select { width: 100%; padding: 9px 11px; border: 1px solid var(--_line); border-radius: var(--_radius); font: inherit; font-size: 14px; background: var(--_surface); color: inherit; }
71
+ input:focus, textarea:focus, select:focus { outline: none; border-color: var(--_accent); }
72
+ .primary { background: var(--_accent); color: #fff; border: none; padding: 10px 18px; border-radius: var(--_radius); font-size: 14px; }
73
+ .primary:hover:not(:disabled) { background: var(--_accent-hover); }
74
+ .primary:disabled { opacity: .5; cursor: default; }
75
+ .err { color: var(--_danger); font-size: 13px; margin-top: 10px; min-height: 1em; }
76
+
77
+ /* chat */
78
+ .chat { display: flex; flex-direction: column; height: 100%; min-height: 0; }
79
+ .bar { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-bottom: 1px solid var(--_line); background: var(--_panel); flex: 0 0 auto; }
80
+ .bar .title { font-weight: 600; font-size: 14px; }
81
+ .bar .spacer { flex: 1; }
82
+ .status { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--_muted); }
83
+ .status .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--_good); flex: 0 0 auto; }
84
+ .status.busy .dot { background: var(--_warn); animation: pulse 1s ease-in-out infinite; }
85
+ .status.error .dot { background: var(--_danger); }
86
+ @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
87
+ .pill { background: color-mix(in srgb, var(--_good) 12%, transparent); color: var(--_good); border: 1px solid color-mix(in srgb, var(--_good) 30%, transparent); border-radius: 999px; padding: 3px 10px; font-size: 12px; font-weight: 600; cursor: pointer; }
88
+ .pill.low { background: color-mix(in srgb, var(--_danger) 10%, transparent); color: var(--_danger); border-color: color-mix(in srgb, var(--_danger) 30%, transparent); }
89
+ .mem { font-size: 12px; color: var(--_muted); border: 1px solid var(--_line); border-radius: 999px; padding: 2px 8px; }
90
+ .bar select { width: auto; padding: 4px 6px; font-size: 12px; }
91
+ .bar .link { background: none; border: none; color: var(--_accent); font-size: 13px; }
92
+ .who { font-size: 12px; color: var(--_muted); }
93
+
94
+ .notice { display: flex; align-items: flex-start; gap: 8px; margin: 8px 12px 0; padding: 8px 11px; border-radius: 7px; background: #eef2f7; color: #33415c; font-size: 12.5px; }
95
+ .notice .x { background: none; border: none; color: inherit; font-size: 15px; line-height: 1; margin-left: auto; }
96
+ .banner { margin: 8px 12px 0; padding: 8px 11px; border-radius: var(--_radius); background: color-mix(in srgb, var(--_danger) 10%, transparent); color: var(--_danger); font-size: 13px; display: flex; align-items: center; gap: 10px; }
97
+ .banner a { color: var(--_accent); font-weight: 600; }
98
+
99
+ .main { flex: 1 1 auto; display: flex; min-height: 0; }
100
+ .thread { flex: 1 1 auto; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; min-height: 0; }
101
+ .results { flex: 0 0 var(--_results-width); border-left: 1px solid var(--_line); background: var(--_results-bg); overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 12px; min-height: 0; }
102
+ .results h4 { margin: 0 0 4px; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--_muted); }
103
+ .results img { max-width: 100%; border: 1px solid var(--_line); border-radius: 6px; cursor: zoom-in; background: var(--_surface); }
104
+ .results .empty { color: var(--_muted); font-size: 13px; }
105
+ @media (max-width: 640px) { .main { flex-direction: column; } .results { flex-basis: auto; border-left: none; border-top: 1px solid var(--_line); } }
106
+
107
+ .welcome { color: var(--_muted); font-size: 14px; }
108
+ .welcome strong { color: inherit; display: block; margin-bottom: 8px; }
109
+ .chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
110
+ .chip { border: 1px solid var(--_line); border-radius: 999px; padding: 5px 12px; font-size: 12.5px; cursor: pointer; background: var(--_surface); }
111
+ .chip:hover { border-color: var(--_accent); color: var(--_accent); }
112
+
113
+ .msg { padding: 10px 13px; border-radius: 10px; font-size: 14.5px; line-height: 1.55; max-width: 90%; word-wrap: break-word; }
114
+ .msg.user { background: var(--_user-bg); align-self: flex-end; white-space: pre-wrap; }
115
+ .msg.assistant { background: var(--_results-bg); border: 1px solid var(--_line); align-self: flex-start; }
116
+ .msg.assistant p { margin: 0 0 8px; } .msg.assistant p:last-child { margin: 0; }
117
+ .msg.assistant h1, .msg.assistant h2, .msg.assistant h3 { margin: 4px 0 6px; line-height: 1.25; }
118
+ .msg.assistant h1 { font-size: 18px; } .msg.assistant h2 { font-size: 16px; } .msg.assistant h3 { font-size: 14.5px; }
119
+ .msg.assistant pre { background: var(--_panel); padding: 10px; border-radius: 7px; overflow-x: auto; font-size: 12.5px; }
120
+ .msg.assistant code { background: var(--_panel); padding: 1px 4px; border-radius: 4px; font-size: 12.5px; }
121
+ .msg.assistant pre code { background: none; padding: 0; }
122
+ .msg.assistant ul, .msg.assistant ol { margin: 0 0 8px; padding-left: 20px; }
123
+ .msg.assistant a { color: var(--_accent); }
124
+ .msg.assistant table { border-collapse: collapse; margin: 4px 0 8px; font-size: 13px; }
125
+ .msg.assistant th, .msg.assistant td { border: 1px solid var(--_line); padding: 4px 8px; text-align: left; }
126
+ .msg.assistant th { background: var(--_panel); }
127
+ .msg.assistant del { opacity: .6; }
128
+ .thinking { color: var(--_muted); font-size: 13px; font-style: italic; align-self: flex-start; }
129
+
130
+ .tool { align-self: flex-start; max-width: 90%; border: 1px solid var(--_line); border-radius: 8px; background: var(--_panel); font-size: 12.5px; overflow: hidden; }
131
+ .tool summary { cursor: pointer; padding: 7px 11px; list-style: none; display: flex; align-items: center; gap: 8px; }
132
+ .tool summary::-webkit-details-marker { display: none; }
133
+ .tool .badge { font-family: ui-monospace, monospace; background: var(--_accent); color: #fff; border-radius: 5px; padding: 1px 6px; font-size: 11px; }
134
+ .tool .sum { color: var(--_muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
135
+ .tool .glyph { font-weight: 700; } .tool .glyph.ok { color: var(--_good); } .tool .glyph.bad { color: var(--_danger); }
136
+ .tool pre { margin: 0; padding: 10px 11px; border-top: 1px solid var(--_line); background: var(--_surface); overflow-x: auto; font-size: 12px; max-height: 260px; }
137
+ .tool img { max-width: 100%; display: block; border-top: 1px solid var(--_line); }
138
+ .tool .ref { color: var(--_muted); font-style: italic; padding: 8px 11px; border-top: 1px solid var(--_line); background: var(--_surface); }
139
+
140
+ .attached { display: flex; flex-wrap: wrap; gap: 6px; padding: 6px 12px 0; }
141
+ .attached .a { font-size: 12px; background: var(--_panel); border: 1px solid var(--_line); border-radius: 6px; padding: 2px 8px; display: inline-flex; gap: 6px; align-items: center; }
142
+ .attached .a button { background: none; border: none; color: var(--_muted); cursor: pointer; }
143
+ .composer { display: flex; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--_line); flex: 0 0 auto; align-items: flex-end; }
144
+ .composer .attach { border: 1px solid var(--_line); background: var(--_surface); border-radius: 7px; padding: 8px 10px; }
145
+ .composer textarea { flex: 1; resize: none; min-height: 40px; max-height: 160px; }
146
+ `;
147
+
148
+ // Escape ALL HTML-significant chars incl. quotes — text from md() flows into attribute
149
+ // contexts (href), so unescaped quotes would allow attribute breakout / event-handler XSS.
150
+ const esc = (s) => String(s)
151
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
152
+ .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
153
+
154
+ // Minimal, safe markdown -> HTML. Every text path is escaped BEFORE inline formatting,
155
+ // so model/data output can never inject HTML. Supports headings, tables, fenced/inline code,
156
+ // bold/italic/strikethrough, lists, and http(s) links only.
157
+ function inline(escaped) {
158
+ return escaped
159
+ .replace(/`([^`]+)`/g, (_, c) => `<code>${c}</code>`)
160
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
161
+ .replace(/~~([^~]+)~~/g, "<del>$1</del>")
162
+ .replace(/(^|[^*])\*([^*]+)\*/g, "$1<em>$2</em>")
163
+ // http(s) links only; require // and forbid quotes/space in the URL. By the time this
164
+ // runs the text is already esc()'d (quotes are entities), so this is belt-and-suspenders.
165
+ .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s"']+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
166
+ }
167
+ function tableRow(line, cell) {
168
+ const cells = line.replace(/^\s*\|/, "").replace(/\|\s*$/, "").split("|").map((c) => c.trim());
169
+ return "<tr>" + cells.map((c) => `<${cell}>${inline(esc(c))}</${cell}>`).join("") + "</tr>";
170
+ }
171
+ function md(raw) {
172
+ if (!raw) return "";
173
+ const blocks = String(raw).replace(/\r/g, "").split(/\n{2,}/);
174
+ return blocks.map((block) => {
175
+ const fence = block.match(/^```(\w*)\n([\s\S]*?)```$/);
176
+ if (fence) return `<pre><code>${esc(fence[2])}</code></pre>`;
177
+ const lines = block.split("\n");
178
+ // table: every line piped, 2nd line is the header separator
179
+ if (lines.length >= 2 && lines.every((l) => /^\s*\|.*\|\s*$/.test(l)) && /^\s*\|[\s:|-]+\|\s*$/.test(lines[1])) {
180
+ const head = tableRow(lines[0], "th");
181
+ const body = lines.slice(2).map((l) => tableRow(l, "td")).join("");
182
+ return `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
183
+ }
184
+ const h = block.match(/^(#{1,3})\s+(.+)$/);
185
+ if (h && lines.length === 1) return `<h${h[1].length}>${inline(esc(h[2]))}</h${h[1].length}>`;
186
+ if (lines.every((l) => /^\s*[-*]\s+/.test(l))) {
187
+ return "<ul>" + lines.map((l) => `<li>${inline(esc(l.replace(/^\s*[-*]\s+/, "")))}</li>`).join("") + "</ul>";
188
+ }
189
+ if (lines.every((l) => /^\s*\d+\.\s+/.test(l))) {
190
+ return "<ol>" + lines.map((l) => `<li>${inline(esc(l.replace(/^\s*\d+\.\s+/, "")))}</li>`).join("") + "</ol>";
191
+ }
192
+ return `<p>${inline(esc(block)).replace(/\n/g, "<br>")}</p>`;
193
+ }).join("");
194
+ }
195
+
196
+ const MODELS = [
197
+ { id: "anthropic/claude-sonnet-4.6", label: "Sonnet — balanced" },
198
+ { id: "anthropic/claude-opus-4.8", label: "Opus — most capable" },
199
+ { id: "anthropic/claude-haiku-4.5", label: "Haiku — fastest" },
200
+ ];
201
+ // All current Claude 4.x models accept image input. Deliberately permissive: treat any
202
+ // claude* id (or an unset id) as vision-capable. If a future text-only claude-* ships, switch
203
+ // this to a static allow-list keyed off MODELS to avoid sending image_url to it (would 400).
204
+ const isVisionModel = (id) => !id || /claude/i.test(id);
205
+
206
+ // Pure: normalize a tool result into the string fed back to the model, the figures to render,
207
+ // and (when showToModel) the multimodal user turn that lets the model see the figure. Exported
208
+ // for tests. `vision` = whether the active model accepts image input.
209
+ function normalizeToolResult(result, vision) {
210
+ let textOut, images = null, panel = false, showToModel = false, explicitErr = null;
211
+ if (result && typeof result === "object") {
212
+ images = result.images ? result.images : (result.image ? [result.image] : null);
213
+ panel = !!result.panel; showToModel = !!result.showToModel;
214
+ if (typeof result.isError === "boolean") explicitErr = result.isError;
215
+ textOut = result.text != null ? String(result.text) : (images ? "[figure rendered]" : JSON.stringify(result));
216
+ } else {
217
+ textOut = typeof result === "string" ? result : JSON.stringify(result);
218
+ }
219
+ const isError = explicitErr != null
220
+ ? explicitErr
221
+ : (/^\s*(tool error|error)[:\n]/i.test(textOut) || /^No such tool:/.test(textOut));
222
+ let visionTurn = null;
223
+ if (showToModel && images && images.length) {
224
+ const last = images[images.length - 1];
225
+ const url = last.startsWith("data:") ? last : `data:image/png;base64,${last}`;
226
+ visionTurn = vision
227
+ ? { role: "user", content: [
228
+ { type: "text", text: "Here is the figure you produced — inspect it for layout issues (clipped labels, overlap, legends covering data) and fix if needed." },
229
+ { type: "image_url", image_url: { url } },
230
+ ] }
231
+ : { role: "user", content: "A figure was produced, but the current model cannot view images. Proceed without visual inspection." };
232
+ }
233
+ return { textOut, isError, images, panel, showToModel, visionTurn };
234
+ }
235
+
236
+ // Pure: build one OpenAI content part for an attachment. Text cap is measured in BYTES.
237
+ function attachmentPart(name, kind, data, vision) {
238
+ if (kind === "image") {
239
+ return vision
240
+ ? { type: "image_url", image_url: { url: data } }
241
+ : { type: "text", text: `[Attached image ${name} — current model can't view images]` };
242
+ }
243
+ const bytes = typeof Blob !== "undefined" ? new Blob([data]).size : Buffer.byteLength(data, "utf8");
244
+ if (bytes <= 100000) return { type: "text", text: `Attached ${name}:\n\`\`\`\n${data}\n\`\`\`` };
245
+ return { type: "text", text: `[Attached ${name} — ${bytes} bytes, too large to inline]` };
246
+ }
247
+
248
+ // Importable in non-DOM contexts (Node tests) without throwing: fall back to a dummy base
249
+ // and skip customElements registration when the DOM isn't present.
250
+ const Base = typeof HTMLElement !== "undefined" ? HTMLElement : class {};
251
+
252
+ class SphereAgent extends Base {
253
+ constructor() {
254
+ super();
255
+ this._sphere = null;
256
+ this._tools = [];
257
+ this._systemPrompt = "";
258
+ this._examples = [];
259
+ this._model = null;
260
+ this._maxTokens = 4096;
261
+ this._layout = "chat";
262
+ this._attachmentsEnabled = false;
263
+ this._accountUrl = "";
264
+ this._seed = null; // () => messages[] (called per fresh conversation)
265
+ this._seedMessages = null; // static array alternative
266
+ this._memory = null; // { count() } — drives the badge
267
+ this._messages = []; // OpenAI-format conversation (no system; sent separately)
268
+ this._attached = []; // pending File attachments
269
+ this._seeded = false;
270
+ this._busy = false;
271
+ this._abort = null;
272
+ this.attachShadow({ mode: "open" });
273
+ this.shadowRoot.innerHTML = `<style>${STYLE}</style>${this._skeleton()}`;
274
+ this._bind();
275
+ }
276
+
277
+ // ---- public API (properties) ----
278
+ set sphere(s) { this._sphere = s; this._refresh(); }
279
+ get sphere() { return this._sphere; }
280
+ set tools(t) { this._tools = Array.isArray(t) ? t : []; }
281
+ set systemPrompt(s) { this._systemPrompt = s || ""; }
282
+ set examples(e) { this._examples = Array.isArray(e) ? e : []; this._renderWelcome(); }
283
+ set model(m) { this._model = m || null; }
284
+ set maxTokens(n) { this._maxTokens = Number(n) || 4096; }
285
+ set layout(v) { this._layout = v === "split" ? "split" : "chat"; this._applyLayout(); }
286
+ set attachments(v) { this._attachmentsEnabled = !!v; this._applyAttachments(); }
287
+ set accountUrl(v) { this._accountUrl = v || ""; }
288
+ set seed(fn) { this._seed = typeof fn === "function" ? fn : null; }
289
+ set seedMessages(arr) { this._seedMessages = Array.isArray(arr) ? arr : null; }
290
+ set memory(m) { this._memory = m && typeof m.count === "function" ? m : null; this._renderMemBadge(); }
291
+
292
+ static get observedAttributes() { return ["layout", "attachments", "account-url"]; }
293
+ attributeChangedCallback(name, _old, val) {
294
+ if (name === "layout") this.layout = val;
295
+ else if (name === "attachments") this.attachments = val !== null && val !== "false";
296
+ else if (name === "account-url") this.accountUrl = val;
297
+ }
298
+
299
+ connectedCallback() { this._refresh(); }
300
+
301
+ // ---- skeleton ----
302
+ _skeleton() {
303
+ return `
304
+ <section class="auth" id="auth">
305
+ <div class="tabs"><button id="tab-in" class="active">Sign in</button><button id="tab-up">Create account</button></div>
306
+ <div id="name-wrap" class="hidden"><label>Name</label><input id="name" type="text" autocomplete="name"></div>
307
+ <label>Email</label><input id="email" type="email" autocomplete="email" placeholder="you@lab.edu">
308
+ <label>Password</label><input id="password" type="password" autocomplete="current-password" placeholder="••••••••">
309
+ <div style="margin-top:14px"><button class="primary" id="auth-go">Sign in</button></div>
310
+ <div class="err" id="auth-err"></div>
311
+ <p id="up-hint" class="hidden" style="margin-top:12px">New accounts start with <strong>$10</strong> in free credit.</p>
312
+ </section>
313
+
314
+ <section class="chat hidden" id="chat">
315
+ <div class="bar">
316
+ <span class="title"><slot name="title">SPHERE Agent</slot></span>
317
+ <span class="status" id="status"><span class="dot"></span><span id="status-text">Ready</span></span>
318
+ <span class="who" id="who"></span>
319
+ <span class="spacer"></span>
320
+ <span class="mem hidden" id="mem"></span>
321
+ <span class="pill" id="balance" title="Manage credits">—</span>
322
+ <select id="model"></select>
323
+ <button class="link" id="reset" title="New conversation">↺</button>
324
+ <button class="link" id="signout">Sign out</button>
325
+ </div>
326
+ <div class="notice hidden" id="notice"><span><slot name="notice"></slot></span><button class="x" id="notice-x" title="Dismiss">×</button></div>
327
+ <div class="banner hidden" id="banner"></div>
328
+ <div class="main">
329
+ <div class="thread" id="thread"></div>
330
+ <div class="results hidden" id="results"><h4>Results</h4><div class="empty" id="results-empty">Figures and analysis output appear here.</div></div>
331
+ </div>
332
+ <div class="attached hidden" id="attached"></div>
333
+ <div class="composer">
334
+ <button class="attach hidden" id="attach" title="Attach files">📎</button>
335
+ <input type="file" id="file" multiple class="hidden">
336
+ <textarea id="input" rows="2" placeholder="Ask the agent… (Enter to send, Shift+Enter for newline)"></textarea>
337
+ <button class="primary" id="send">Send</button>
338
+ <button class="link hidden" id="stop">Stop</button>
339
+ </div>
340
+ </section>`;
341
+ }
342
+
343
+ _$(id) { return this.shadowRoot.getElementById(id); }
344
+
345
+ _bind() {
346
+ const $ = (id) => this._$(id);
347
+ this._mode = "signin";
348
+ $("tab-in").onclick = () => this._setMode("signin");
349
+ $("tab-up").onclick = () => this._setMode("signup");
350
+ $("auth-go").onclick = () => this._auth();
351
+ $("signout").onclick = () => { this._sphere?.signOut(); this._messages = []; this._seeded = false; this._refresh(); };
352
+ $("reset").onclick = () => this._resetConversation();
353
+ $("send").onclick = () => this._send();
354
+ $("stop").onclick = () => this._abort?.abort();
355
+ $("balance").onclick = () => this._openAccount();
356
+ $("notice-x").onclick = () => this._dismissNotice();
357
+ $("attach").onclick = () => $("file").click();
358
+ $("file").onchange = (e) => this._addAttachments(e.target.files);
359
+ $("input").addEventListener("keydown", (e) => {
360
+ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); this._send(); }
361
+ });
362
+ const sel = $("model");
363
+ sel.innerHTML = MODELS.map((m) => `<option value="${m.id}">${m.label}</option>`).join("");
364
+ sel.onchange = () => { this._model = sel.value; };
365
+ }
366
+
367
+ _setMode(m) {
368
+ const $ = (id) => this._$(id);
369
+ this._mode = m;
370
+ $("tab-in").classList.toggle("active", m === "signin");
371
+ $("tab-up").classList.toggle("active", m === "signup");
372
+ $("name-wrap").classList.toggle("hidden", m !== "signup");
373
+ $("up-hint").classList.toggle("hidden", m !== "signup");
374
+ $("auth-go").textContent = m === "signin" ? "Sign in" : "Create account";
375
+ $("auth-err").textContent = "";
376
+ }
377
+
378
+ _refresh() {
379
+ const $ = (id) => this._$(id);
380
+ const signedIn = this._sphere && this._sphere.isSignedIn();
381
+ $("auth").classList.toggle("hidden", signedIn);
382
+ $("chat").classList.toggle("hidden", !signedIn);
383
+ if (signedIn) {
384
+ this._applyLayout(); this._applyAttachments(); this._applyNotice();
385
+ this._renderMemBadge(); this._renderWelcome(); this._loadAccount();
386
+ }
387
+ }
388
+
389
+ _applyLayout() { const r = this._$("results"); if (r) r.classList.toggle("hidden", this._layout !== "split"); }
390
+ _applyAttachments() { const a = this._$("attach"); if (a) a.classList.toggle("hidden", !this._attachmentsEnabled); }
391
+
392
+ // ---- notice banner (dismiss persisted per portal) ----
393
+ _noticeKey() { return "sphere-agent:notice:" + (this._sphere?.portal || "default"); }
394
+ _applyNotice() {
395
+ const notice = this._$("notice"); if (!notice) return;
396
+ const slot = notice.querySelector("slot[name=notice]");
397
+ const hasContent = slot && slot.assignedNodes && slot.assignedNodes().length > 0;
398
+ let dismissed = false;
399
+ try { dismissed = localStorage.getItem(this._noticeKey()) === "1"; } catch { /* ignore */ }
400
+ notice.classList.toggle("hidden", !hasContent || dismissed);
401
+ }
402
+ _dismissNotice() {
403
+ try { localStorage.setItem(this._noticeKey(), "1"); } catch { /* ignore */ }
404
+ this._$("notice").classList.add("hidden");
405
+ }
406
+
407
+ _openAccount() {
408
+ if (this._accountUrl) window.open(this._accountUrl, "_blank", "noopener");
409
+ }
410
+
411
+ async _auth() {
412
+ const $ = (id) => this._$(id);
413
+ const email = $("email").value.trim(), password = $("password").value, name = $("name").value.trim();
414
+ const btn = $("auth-go"), err = $("auth-err");
415
+ err.textContent = ""; btn.disabled = true;
416
+ try {
417
+ if (this._mode === "signup") await this._sphere.signUp(email, password, name || undefined);
418
+ await this._sphere.signIn(email, password);
419
+ this._refresh();
420
+ } catch (e) {
421
+ err.textContent = this._mode === "signup" ? "Could not create account. " + (e.message || "") : "Sign in failed — check your email and password.";
422
+ } finally { btn.disabled = false; }
423
+ }
424
+
425
+ _resetConversation() {
426
+ this._messages = []; this._seeded = false; this._attached = [];
427
+ this._$("thread").innerHTML = "";
428
+ const results = this._$("results");
429
+ results.querySelectorAll("img,.line").forEach((n) => n.remove());
430
+ this._$("results-empty").classList.remove("hidden");
431
+ this._renderAttached(); this._renderWelcome();
432
+ }
433
+
434
+ _renderWelcome() {
435
+ const thread = this._$("thread");
436
+ if (!thread || thread.childElementCount) return;
437
+ const chips = this._examples.map((x) => `<span class="chip">${esc(x)}</span>`).join("");
438
+ thread.innerHTML = `<div class="welcome"><strong>Ask the agent</strong>${this._examples.length ? `Try one:<div class="chips">${chips}</div>` : ""}</div>`;
439
+ thread.querySelectorAll(".chip").forEach((c) => (c.onclick = () => { this._$("input").value = c.textContent; this._send(); }));
440
+ }
441
+
442
+ _renderMemBadge() {
443
+ const mem = this._$("mem"); if (!mem) return;
444
+ const n = this._memory ? this._memory.count() : 0;
445
+ mem.textContent = n ? `Memory: ${n}` : "";
446
+ mem.classList.toggle("hidden", !n);
447
+ }
448
+
449
+ async _loadAccount() {
450
+ try {
451
+ const a = await this._sphere.getAccount();
452
+ this._setBalance(a.wallet?.balance_usd);
453
+ const bits = [a.user?.email, a.portal?.name].filter(Boolean);
454
+ this._$("who").textContent = bits.join(" · ");
455
+ } catch (_) {}
456
+ }
457
+
458
+ _setBalance(v) {
459
+ const pill = this._$("balance");
460
+ if (v == null) { pill.textContent = "—"; return; }
461
+ pill.textContent = `$${Number(v).toFixed(4)}`;
462
+ const low = Number(v) <= 0;
463
+ pill.classList.toggle("low", low);
464
+ this._$("send").disabled = low;
465
+ const banner = this._$("banner");
466
+ banner.classList.toggle("hidden", !low);
467
+ if (low) {
468
+ const link = this._accountUrl ? ` <a href="${esc(this._accountUrl)}" target="_blank" rel="noopener">Add credits ↗</a>` : "";
469
+ banner.innerHTML = `You're out of SPHERE credit.${link}`;
470
+ }
471
+ }
472
+
473
+ _setStatus(text, state) {
474
+ const s = this._$("status"); if (!s) return;
475
+ s.classList.remove("busy", "error");
476
+ if (state) s.classList.add(state);
477
+ this._$("status-text").textContent = text || "Ready";
478
+ }
479
+
480
+ // ---- attachments ----
481
+ _addAttachments(fileList) {
482
+ for (const f of fileList || []) this._attached.push(f);
483
+ this._$("file").value = "";
484
+ this._renderAttached();
485
+ }
486
+ _renderAttached() {
487
+ const wrap = this._$("attached"); if (!wrap) return;
488
+ wrap.classList.toggle("hidden", this._attached.length === 0);
489
+ wrap.innerHTML = this._attached.map((f, i) =>
490
+ `<span class="a">${esc(f.name)} <button data-i="${i}" title="Remove">×</button></span>`).join("");
491
+ wrap.querySelectorAll("button").forEach((b) => (b.onclick = () => {
492
+ this._attached.splice(Number(b.dataset.i), 1); this._renderAttached();
493
+ }));
494
+ }
495
+ _readFile(file) {
496
+ return new Promise((res, rej) => {
497
+ const fr = new FileReader();
498
+ fr.onerror = () => rej(new Error("read failed"));
499
+ if (/^image\//.test(file.type)) { fr.onload = () => res({ kind: "image", data: fr.result }); fr.readAsDataURL(file); }
500
+ else { fr.onload = () => res({ kind: "text", data: fr.result }); fr.readAsText(file); }
501
+ });
502
+ }
503
+ // Build OpenAI-shaped content for the user turn from text + pending attachments.
504
+ async _composeUserContent(text) {
505
+ if (!this._attached.length) return text;
506
+ const parts = [{ type: "text", text }];
507
+ const vision = isVisionModel(this._model);
508
+ for (const f of this._attached) {
509
+ try {
510
+ const r = await this._readFile(f);
511
+ parts.push(attachmentPart(f.name, r.kind, r.data, vision));
512
+ } catch { parts.push({ type: "text", text: `[Attached ${f.name} — could not read]` }); }
513
+ }
514
+ this._attached = []; this._renderAttached();
515
+ return parts;
516
+ }
517
+
518
+ // ---- DOM helpers ----
519
+ _bubble(role, html, asText) {
520
+ const div = document.createElement("div");
521
+ div.className = `msg ${role}`;
522
+ if (asText) div.textContent = html; else div.innerHTML = html;
523
+ this._$("thread").appendChild(div);
524
+ this._scroll();
525
+ return div;
526
+ }
527
+ _scroll() { const t = this._$("thread"); t.scrollTop = t.scrollHeight; }
528
+ _toolCard(name, input) {
529
+ const d = document.createElement("details");
530
+ d.className = "tool"; d.open = false;
531
+ const summary = (() => { const s = JSON.stringify(input); return s.length > 80 ? s.slice(0, 78) + "…" : s; })();
532
+ d.innerHTML = `<summary><span class="badge">${esc(name)}</span><span class="sum">${esc(summary)}</span><span class="glyph"></span></summary><pre>running…</pre>`;
533
+ this._$("thread").appendChild(d);
534
+ this._scroll();
535
+ return d;
536
+ }
537
+ // Route figure(s) to the results pane (split layout) or inline in the card.
538
+ _renderFigures(card, images, toPanel) {
539
+ const imgs = (Array.isArray(images) ? images : [images]).filter(Boolean);
540
+ if (!imgs.length) return;
541
+ const toData = (b) => (b.startsWith("data:") ? b : `data:image/png;base64,${b}`);
542
+ if (toPanel && this._layout === "split") {
543
+ const pane = this._$("results");
544
+ this._$("results-empty").classList.add("hidden");
545
+ for (const b of imgs) {
546
+ const img = document.createElement("img");
547
+ img.src = toData(b);
548
+ img.onclick = () => window.open(img.src, "_blank", "noopener");
549
+ pane.appendChild(img);
550
+ }
551
+ pane.scrollTop = pane.scrollHeight;
552
+ const ref = document.createElement("div"); ref.className = "ref";
553
+ ref.textContent = imgs.length > 1 ? `→ ${imgs.length} figures in results` : "→ figure in results";
554
+ card.appendChild(ref); card.open = true;
555
+ } else {
556
+ for (const b of imgs) {
557
+ const img = document.createElement("img");
558
+ img.src = toData(b); card.appendChild(img);
559
+ }
560
+ card.open = true;
561
+ }
562
+ this._scroll();
563
+ }
564
+
565
+ // ---- send + agent loop ----
566
+ async _send() {
567
+ if (this._busy || !this._sphere) return;
568
+ const input = this._$("input");
569
+ const text = input.value.trim();
570
+ if (!text && !this._attached.length) return;
571
+ input.value = "";
572
+ const welcome = this.shadowRoot.querySelector(".welcome"); if (welcome) welcome.remove();
573
+ this._bubble("user", text || "(attachment)", true);
574
+ // Seed prior context once per fresh conversation, before the first user message.
575
+ if (!this._seeded) {
576
+ const seeded = this._seed ? this._seed() : (this._seedMessages || []);
577
+ if (Array.isArray(seeded) && seeded.length) this._messages.push(...seeded);
578
+ this._seeded = true;
579
+ }
580
+ const content = await this._composeUserContent(text);
581
+ this._messages.push({ role: "user", content });
582
+ await this._runLoop();
583
+ }
584
+
585
+ async _runLoop() {
586
+ this._busy = true;
587
+ this._abort = new AbortController();
588
+ const $ = (id) => this._$(id);
589
+ $("send").disabled = true; $("stop").classList.remove("hidden");
590
+ this._setStatus("Thinking…", "busy");
591
+ let thinking = this._bubble("thinking", "Thinking…", true); thinking.classList.add("thinking");
592
+ try {
593
+ for (let i = 0; i < 16; i++) {
594
+ const turn = await this._streamTurn(() => { if (thinking) { thinking.remove(); thinking = null; } });
595
+ const asMsg = { role: "assistant", content: turn.text || "" };
596
+ if (turn.toolCalls.length) asMsg.tool_calls = turn.toolCalls.map((t) => ({ id: t.id, type: "function", function: { name: t.name, arguments: t.arguments } }));
597
+ this._messages.push(asMsg);
598
+
599
+ if (turn.finish === "tool_calls" && turn.toolCalls.length) {
600
+ const visionTurns = []; // figures to show the model after this batch of tool results
601
+ for (const call of turn.toolCalls) {
602
+ const input = this._safeParse(call.arguments);
603
+ const card = this._toolCard(call.name, input);
604
+ this._setStatus(this._toolStatus(call.name, input), "busy");
605
+ const result = await this._execTool(call.name, input);
606
+ const { textOut, isError } = this._applyToolResult(card, result, visionTurns);
607
+ this._messages.push({ role: "tool", tool_call_id: call.id, content: textOut });
608
+ this._setToolGlyph(card, isError);
609
+ }
610
+ // Feed any requested figures back to the model as a user turn (vision).
611
+ for (const vt of visionTurns) this._messages.push(vt);
612
+ this._setStatus("Thinking…", "busy");
613
+ thinking = this._bubble("thinking", "Thinking…", true); thinking.classList.add("thinking");
614
+ continue;
615
+ }
616
+ break;
617
+ }
618
+ } catch (e) {
619
+ if (thinking) { thinking.remove(); thinking = null; }
620
+ if (e?.message === "insufficient_credits") { this._setBalance(0); this._bubble("assistant", md("💳 You're out of credits.")); }
621
+ else if (e?.name === "AbortError") this._bubble("assistant", md("⏹ Stopped."));
622
+ else this._bubble("assistant", md("⚠️ " + (e?.message || "Something went wrong.")));
623
+ } finally {
624
+ if (thinking) thinking.remove();
625
+ this._busy = false; this._abort = null;
626
+ $("send").disabled = false; $("stop").classList.add("hidden");
627
+ this._setStatus("Ready");
628
+ this._renderMemBadge();
629
+ this._$("input").focus();
630
+ }
631
+ }
632
+
633
+ _toolStatus(name, input) {
634
+ const tool = this._tools.find((t) => t.name === name);
635
+ try { if (tool && typeof tool.status === "function") return tool.status(input) || "Working…"; } catch { /* ignore */ }
636
+ return name === "run_python" ? "Running Python…" : `Running ${name}…`;
637
+ }
638
+ _setToolGlyph(card, isError) {
639
+ const g = card.querySelector(".glyph");
640
+ if (g) { g.textContent = isError ? "✗" : "✓"; g.classList.add(isError ? "bad" : "ok"); }
641
+ }
642
+
643
+ // Normalize a tool result into the string fed back to the model, render figures,
644
+ // and (for showToModel results) queue a multimodal user turn so the model can see them.
645
+ _applyToolResult(card, result, visionTurns) {
646
+ const n = normalizeToolResult(result, isVisionModel(this._model));
647
+ card.querySelector("pre").textContent = n.textOut;
648
+ if (n.images && n.images.length) this._renderFigures(card, n.images, n.panel);
649
+ if (n.visionTurn) visionTurns.push(n.visionTurn);
650
+ return { textOut: n.textOut, isError: n.isError };
651
+ }
652
+
653
+ _safeParse(s) { try { return JSON.parse(s); } catch { return {}; } }
654
+
655
+ async _execTool(name, input) {
656
+ const tool = this._tools.find((t) => t.name === name);
657
+ if (!tool) return `No such tool: ${name}`;
658
+ try { return await tool.run(input); }
659
+ catch (e) { return "Error: " + (e?.message || e); } // "Error:" prefix => flagged as an error glyph
660
+ }
661
+
662
+ // One streaming model turn. Renders assistant text live, assembles tool_calls, updates balance.
663
+ async _streamTurn(onFirstByte) {
664
+ const tools = this._tools.length
665
+ ? this._tools.map((t) => ({ type: "function", function: { name: t.name, description: t.description || "", parameters: t.input_schema || { type: "object", properties: {} } } }))
666
+ : undefined;
667
+ const resp = await this._sphere.streamAgent({
668
+ model: this._model || undefined,
669
+ system: this._systemPrompt || undefined,
670
+ messages: this._messages,
671
+ max_tokens: this._maxTokens,
672
+ ...(tools ? { tools } : {}),
673
+ }, this._abort.signal);
674
+
675
+ if (!resp.ok) {
676
+ if (resp.status === 402) throw new Error("insufficient_credits");
677
+ if (resp.status === 401) { this._sphere.signOut(); this._refresh(); throw new Error("Session expired — sign in again."); }
678
+ const err = await resp.json().catch(() => ({}));
679
+ throw new Error(err.error || `API error ${resp.status}`);
680
+ }
681
+
682
+ const reader = resp.body.getReader();
683
+ const dec = new TextDecoder();
684
+ let text = "", bubble = null, flushed = false;
685
+ const toolCalls = []; // {id,name,arguments}
686
+ let finish = "stop";
687
+ let buf = "";
688
+
689
+ const renderLive = () => { if (bubble) bubble.textContent = text; this._scroll(); };
690
+
691
+ while (true) {
692
+ const { done, value } = await reader.read();
693
+ if (done) break;
694
+ buf += dec.decode(value, { stream: true });
695
+ const lines = buf.split("\n");
696
+ buf = lines.pop() || "";
697
+ for (const line of lines) {
698
+ if (!line.startsWith("data:")) continue;
699
+ const payload = line.slice(5).trim();
700
+ if (payload === "[DONE]" || !payload) continue;
701
+ let ev; try { ev = JSON.parse(payload); } catch { continue; }
702
+ if (ev.sphere) { this._setBalance(ev.sphere.balance_usd); continue; }
703
+ const choice = ev.choices?.[0]; if (!choice) continue;
704
+ const delta = choice.delta || {};
705
+ if (delta.content) {
706
+ if (!flushed) { flushed = true; onFirstByte?.(); bubble = this._bubble("assistant", "", true); }
707
+ text += delta.content; renderLive();
708
+ }
709
+ if (delta.tool_calls) {
710
+ for (const tc of delta.tool_calls) {
711
+ const idx = tc.index ?? 0;
712
+ toolCalls[idx] = toolCalls[idx] || { id: "", name: "", arguments: "" };
713
+ if (tc.id) toolCalls[idx].id = tc.id;
714
+ if (tc.function?.name) toolCalls[idx].name = tc.function.name;
715
+ if (tc.function?.arguments) toolCalls[idx].arguments += tc.function.arguments;
716
+ }
717
+ }
718
+ if (choice.finish_reason) finish = choice.finish_reason;
719
+ }
720
+ }
721
+ if (bubble && text) bubble.innerHTML = md(text); // seal: render markdown
722
+ return { text, toolCalls: toolCalls.filter(Boolean), finish };
723
+ }
724
+ }
725
+
726
+ if (typeof customElements !== "undefined") customElements.define("sphere-agent", SphereAgent);
727
+ export { SphereAgent, md, isVisionModel, normalizeToolResult, attachmentPart };
@@ -0,0 +1,343 @@
1
+ // Optional tool packs for <sphere-agent>. A "tool" is { name, description, input_schema, run }.
2
+ // `run(input)` returns a string, or an object describing output:
3
+ // { text, image } — text plus a single base64 PNG (rendered in the card / results panel)
4
+ // { text, images: [b64,…] } — text plus several figures
5
+ // { …, panel: true } — route the figure(s) to the two-pane results panel, not inline
6
+ // { …, showToModel: true } — also feed the figure back to the model (vision) as a user turn
7
+ // { …, isError: true } — explicitly mark the result as a failure (else inferred from text)
8
+ // An optional `status(input) -> string` lets a tool label its own running state in the status bar.
9
+ //
10
+ // Labs compose the tools they want:
11
+ // import { pyodideTools, learningStore } from "./sphere-tools.js";
12
+ // const mem = learningStore(sphere.portal);
13
+ // el.tools = [ ...pyodideTools({ baseUrl: "/data/", datasets: { demographics: "demographics.csv" } }), mem.tool ];
14
+ // el.seed = mem.seed; // inject prior learnings at the start of each conversation
15
+ // el.memory = mem; // drive the memory badge (count())
16
+
17
+ // ── calculator (no deps; safe arithmetic only) ──────────────────────
18
+ export const calculatorTool = {
19
+ name: "calculator",
20
+ description: "Evaluate a basic arithmetic expression (+ - * / parentheses).",
21
+ input_schema: { type: "object", properties: { expr: { type: "string", description: "e.g. (47*89)/3" } }, required: ["expr"] },
22
+ run: ({ expr }) => {
23
+ if (!/^[\d\s+\-*/().,]+$/.test(String(expr || ""))) return "Error: only numbers and + - * / ( ) allowed.";
24
+ try { return String(Function(`"use strict";return (${expr})`)()); }
25
+ catch (e) { return "Error: " + (e?.message || "bad expression"); }
26
+ },
27
+ };
28
+
29
+ // ── pure helpers (exported for unit tests) ──────────────────────────
30
+
31
+ // Resolve a dataset name to a fetchable URL. `datasets[name]` may be an absolute
32
+ // URL / data: URI (used as-is) or a path relative to `baseUrl`. One configurable
33
+ // `baseUrl` is the seam that lets a portal point at static files today and a
34
+ // SPHERE data API later with no code change.
35
+ export function resolveDatasetUrl(baseUrl, datasets, name) {
36
+ const v = datasets?.[name];
37
+ if (typeof v !== "string" || !v) return null; // missing or non-string -> not available
38
+ if (/^([a-z]+:)?\/\//i.test(v) || v.startsWith("data:")) return v; // absolute or data URI
39
+ return (baseUrl || "") + v; // relative to baseUrl
40
+ }
41
+
42
+ const LEARN_PREFIX = "sphere-agent:learnings:";
43
+ const LEARN_MAX = 30;
44
+
45
+ // localStorage-backed, per-namespace learning store. `storage` is injectable for tests.
46
+ export function learningStore(namespace, storage) {
47
+ const store = storage || (typeof globalThis !== "undefined" ? globalThis.localStorage : null);
48
+ const key = LEARN_PREFIX + (namespace || "default");
49
+
50
+ const read = () => {
51
+ try { const v = JSON.parse(store?.getItem(key) || "[]"); return Array.isArray(v) ? v : []; } catch { return []; }
52
+ };
53
+ const write = (arr) => { try { store?.setItem(key, JSON.stringify(arr)); } catch { /* quota / unavailable */ } };
54
+
55
+ const add = (issue, solution, ts = 0) => {
56
+ issue = String(issue || "").trim(); solution = String(solution || "").trim();
57
+ if (!issue || !solution) return { ok: false, note: "Both issue and solution are required." };
58
+ const arr = read();
59
+ if (arr.some((l) => l.issue === issue)) return { ok: true, count: arr.length, note: "Already saved." };
60
+ arr.push({ issue, solution, ts });
61
+ if (arr.length > LEARN_MAX) arr.splice(0, arr.length - LEARN_MAX);
62
+ write(arr);
63
+ return { ok: true, count: arr.length };
64
+ };
65
+
66
+ const tool = {
67
+ name: "save_learning",
68
+ description:
69
+ "Save a lesson to persistent memory for future sessions. Call this when you fix a new error " +
70
+ "(imports, data-shape surprises, API quirks) or discover something useful about the data " +
71
+ "(a column's real range, a merge gotcha, a working pattern). Be specific and actionable.",
72
+ input_schema: {
73
+ type: "object",
74
+ required: ["issue", "solution"],
75
+ properties: {
76
+ issue: { type: "string", description: 'The problem, e.g. "biomarkers ptau217 has ~40% NaN"' },
77
+ solution: { type: "string", description: 'The fix/insight, e.g. "dropna() before plotting; n 618→370"' },
78
+ },
79
+ },
80
+ run: ({ issue, solution }) => {
81
+ const r = add(issue, solution);
82
+ return r.ok ? `Saved to memory (${r.count ?? read().length} learnings).` : "Error: " + r.note;
83
+ },
84
+ };
85
+
86
+ // Synthetic [user, assistant] turns that re-inject prior learnings at conversation start.
87
+ const seed = () => {
88
+ const arr = read();
89
+ if (!arr.length) return [];
90
+ const lines = arr.map((l, i) => `${i + 1}. **Issue**: ${l.issue}\n **Fix**: ${l.solution}`).join("\n\n");
91
+ return [
92
+ { role: "user", content: `[Persistent memory from previous sessions — apply proactively]\n\n${lines}` },
93
+ { role: "assistant", content: `Understood. I'll apply these ${arr.length} learned fix${arr.length > 1 ? "es" : ""}.` },
94
+ ];
95
+ };
96
+
97
+ return { tool, seed, count: () => read().length, clear: () => write([]), _add: add };
98
+ }
99
+
100
+ // ── Pyodide pack: run Python in the browser; data never leaves the page ──
101
+ const PYODIDE_CDN = "https://cdn.jsdelivr.net/pyodide/v0.27.5/full/pyodide.js";
102
+ const DEFAULT_PACKAGES = ["pandas", "numpy", "scipy", "matplotlib", "scikit-learn", "statsmodels", "micropip"];
103
+ let _pyReady = null;
104
+
105
+ async function ensurePyodide(packages) {
106
+ if (_pyReady) return _pyReady;
107
+ _pyReady = (async () => {
108
+ if (!globalThis.loadPyodide) {
109
+ await new Promise((res, rej) => {
110
+ const s = document.createElement("script");
111
+ s.src = PYODIDE_CDN; s.onload = res; s.onerror = () => rej(new Error("failed to load Pyodide"));
112
+ document.head.appendChild(s);
113
+ });
114
+ }
115
+ const py = await globalThis.loadPyodide();
116
+ await py.loadPackage(packages && packages.length ? packages : DEFAULT_PACKAGES);
117
+ py.runPython(`
118
+ import io, os, sys, math, json, base64, warnings
119
+ import numpy as np, pandas as pd
120
+ import matplotlib
121
+ matplotlib.use('AGG')
122
+ import matplotlib.pyplot as plt
123
+ try:
124
+ import scipy, scipy.stats as stats
125
+ except Exception:
126
+ scipy = None; stats = None
127
+ try:
128
+ import statsmodels.api as sm
129
+ except Exception:
130
+ sm = None
131
+ try:
132
+ import sklearn
133
+ except Exception:
134
+ sklearn = None
135
+ sns = None # seaborn loaded lazily via micropip below
136
+ os.makedirs('/sandbox', exist_ok=True)
137
+ data = {} # data['name'] -> DataFrame
138
+ _sphere_datasets = {} # same, for iteration: for n, df in _sphere_datasets.items()
139
+ dd = {} # data dictionary: col -> label, injected by the host
140
+ _agent_figures = [] # base64 PNGs captured since last clear
141
+ def _capture_fig():
142
+ figs = [plt.figure(n) for n in plt.get_fignums()]
143
+ if not figs: return
144
+ for f in figs:
145
+ buf = io.BytesIO(); f.savefig(buf, format='png', bbox_inches='tight', dpi=150)
146
+ buf.seek(0); _agent_figures.append(base64.b64encode(buf.read()).decode())
147
+ plt.close('all')
148
+ `);
149
+ // seaborn is optional — try via micropip, leave sns=None if it fails.
150
+ try {
151
+ await py.runPythonAsync(`import micropip\nawait micropip.install('seaborn')\nimport seaborn as sns`);
152
+ } catch { /* sns stays None */ }
153
+ return py;
154
+ })();
155
+ return _pyReady;
156
+ }
157
+
158
+ export function pyodideTools({ datasets = {}, dataDictionary = null, packages = null, baseUrl = "" } = {}) {
159
+ const loaded = new Set();
160
+ let ddInjected = false;
161
+
162
+ const injectDd = (py) => {
163
+ if (ddInjected || !dataDictionary) return;
164
+ // Accept either {col: label} or {modality: [{col,label}, …]} (portal shape).
165
+ const flat = {};
166
+ for (const v of Object.values(dataDictionary)) {
167
+ if (Array.isArray(v)) for (const e of v) { if (e && e.col) flat[e.col] = e.label ?? e.col; }
168
+ }
169
+ const merged = Array.isArray(Object.values(dataDictionary)[0]) ? flat : dataDictionary;
170
+ py.globals.set("_dd_json", JSON.stringify(merged));
171
+ py.runPython("import json as _json\ndd.clear(); dd.update(_json.loads(_dd_json))");
172
+ ddInjected = true;
173
+ };
174
+
175
+ const load_dataset = {
176
+ name: "load_dataset",
177
+ description:
178
+ "Load one or more named datasets into Python as pandas DataFrames (available as `data['<name>']` " +
179
+ "AND as a variable named after the dataset). Available: " + (Object.keys(datasets).join(", ") || "(none configured)") +
180
+ ". Call before run_python; you can load several at once.",
181
+ input_schema: {
182
+ type: "object",
183
+ properties: { datasets: { type: "array", items: { type: "string" }, description: "names to load" } },
184
+ required: ["datasets"],
185
+ },
186
+ status: ({ datasets: names }) => `Loading ${(names || []).join(", ") || "data"}…`,
187
+ run: async ({ datasets: names }) => {
188
+ const py = await ensurePyodide(packages);
189
+ injectDd(py);
190
+ const out = [];
191
+ let ok = 0, fail = 0;
192
+ for (const name of names || []) {
193
+ if (loaded.has(name)) { out.push(`${name}: already loaded`); ok++; continue; }
194
+ const url = resolveDatasetUrl(baseUrl, datasets, name);
195
+ if (!url) { out.push(`${name}: not available`); fail++; continue; }
196
+ try {
197
+ const resp = await fetch(url);
198
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
199
+ const csv = await resp.text();
200
+ py.globals.set("_csv_text", csv); py.globals.set("_csv_name", name);
201
+ py.runPython(
202
+ "import pandas as pd, io as _io\n" +
203
+ "_df = pd.read_csv(_io.StringIO(_csv_text))\n" +
204
+ "data[_csv_name] = _df\n_sphere_datasets[_csv_name] = _df\n" +
205
+ "globals()[_csv_name] = _df\n_shape = _df.shape\n_cols = list(_df.columns)"
206
+ );
207
+ const shape = py.globals.get("_shape").toJs();
208
+ const cols = Array.from(py.globals.get("_cols").toJs());
209
+ loaded.add(name);
210
+ out.push(`${name}: ${shape[0]} rows × ${shape[1]} cols\nColumns: ${cols.join(", ")}`);
211
+ ok++;
212
+ } catch (e) { out.push(`${name}: error ${e?.message || e}`); fail++; }
213
+ }
214
+ return { text: "Loaded:\n" + out.join("\n"), isError: ok === 0 && fail > 0 };
215
+ },
216
+ };
217
+
218
+ const run_python = {
219
+ name: "run_python",
220
+ description:
221
+ "Run Python in the browser sandbox. Pre-imported: os, sys, math, json, warnings, numpy as np, " +
222
+ "pandas as pd, matplotlib (Agg) + plt, seaborn as sns (may be None), scipy, scipy.stats as stats, " +
223
+ "statsmodels.api as sm, sklearn. Variables PERSIST across calls — build on previous results. Loaded " +
224
+ "datasets are DataFrames by name; the data dictionary is `dd` (col -> label). Use print() for output " +
225
+ "and plt.show()/savefig() for figures.",
226
+ input_schema: { type: "object", properties: { code: { type: "string" } }, required: ["code"] },
227
+ status: () => "Running Python…",
228
+ run: async ({ code }) => {
229
+ const py = await ensurePyodide(packages);
230
+ injectDd(py);
231
+ py.runPython("import sys, io as _io\n_agent_figures.clear()\n_stdout = sys.stdout\nsys.stdout = _io.StringIO()");
232
+ let err = null;
233
+ try { await py.runPythonAsync(code); }
234
+ catch (e) { err = e?.message || String(e); }
235
+ // Always restore stdout first; figure capture is best-effort and must not lose output.
236
+ py.runPython("_v = sys.stdout.getvalue(); sys.stdout = _stdout");
237
+ try { py.runPython("_capture_fig()"); } catch { /* ignore figure-capture errors */ }
238
+ const stdout = py.globals.get("_v");
239
+ const figs = Array.from(py.globals.get("_agent_figures").toJs());
240
+ const text = (err ? "Error:\n" + err + "\n" : "") + (stdout || (figs.length ? "(figure produced)" : "(no output)"));
241
+ if (figs.length > 1) return { text, images: figs };
242
+ if (figs.length === 1) return { text, image: figs[0] };
243
+ return text;
244
+ },
245
+ };
246
+
247
+ const write_file = {
248
+ name: "write_file",
249
+ description: "Write a text file to /sandbox/. Overwrites; content must be non-empty. Use to maintain analysis.py.",
250
+ input_schema: {
251
+ type: "object",
252
+ properties: { path: { type: "string" }, content: { type: "string" } },
253
+ required: ["path", "content"],
254
+ },
255
+ run: async ({ path, content }) => {
256
+ if (!content) return "Error: content is empty.";
257
+ const py = await ensurePyodide(packages);
258
+ const full = "/sandbox/" + String(path || "").replace(/^\/+/, "");
259
+ try { py.FS.writeFile(full, content); return `Wrote ${full} (${content.length} chars).`; }
260
+ catch (e) { return "Error: " + (e?.message || e); }
261
+ },
262
+ };
263
+
264
+ const read_file = {
265
+ name: "read_file",
266
+ description: "Read a text file from /sandbox/ (up to 50 KB).",
267
+ input_schema: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
268
+ run: async ({ path }) => {
269
+ const py = await ensurePyodide(packages);
270
+ const full = "/sandbox/" + String(path || "").replace(/^\/+/, "");
271
+ try { return py.FS.readFile(full, { encoding: "utf8" }).slice(0, 50000); }
272
+ catch { return `Error: ${full} not found.`; }
273
+ },
274
+ };
275
+
276
+ const list_files = {
277
+ name: "list_files",
278
+ description: "List files in /sandbox/.",
279
+ input_schema: { type: "object", properties: {}, required: [] },
280
+ run: async () => {
281
+ const py = await ensurePyodide(packages);
282
+ try { const f = Array.from(py.FS.readdir("/sandbox")).filter((n) => n !== "." && n !== ".."); return f.length ? f.join("\n") : "(empty)"; }
283
+ catch (e) { return "Error: " + (e?.message || e); }
284
+ },
285
+ };
286
+
287
+ const pip_install = {
288
+ name: "pip_install",
289
+ description: "Install extra Python packages via micropip. Common scientific packages are already available — only use for less common ones. Try import first.",
290
+ input_schema: { type: "object", properties: { packages: { type: "array", items: { type: "string" } } }, required: ["packages"] },
291
+ status: ({ packages: pk }) => `Installing ${(pk || []).join(" ")}…`,
292
+ run: async ({ packages: pk }) => {
293
+ const py = await ensurePyodide(packages);
294
+ const names = (pk || []).filter(Boolean);
295
+ if (!names.length) return "Error: no packages given.";
296
+ try {
297
+ py.globals.set("_pip_list", names);
298
+ await py.runPythonAsync("import micropip\nawait micropip.install(list(_pip_list))");
299
+ return "Installed: " + names.join(", ");
300
+ } catch (e) { return "Error: " + (e?.message || e); }
301
+ },
302
+ };
303
+
304
+ const run_analysis_py = {
305
+ name: "run_analysis_py",
306
+ description: "Run /sandbox/analysis.py against the loaded data as a final verification step. Its stdout/stderr and figures are shown in the results panel.",
307
+ input_schema: { type: "object", properties: {}, required: [] },
308
+ status: () => "Running analysis.py…",
309
+ run: async () => {
310
+ const py = await ensurePyodide(packages);
311
+ let code;
312
+ try { code = py.FS.readFile("/sandbox/analysis.py", { encoding: "utf8" }); }
313
+ catch { return "Error: /sandbox/analysis.py not found. Write it first with write_file."; }
314
+ py.globals.set("_analysis_code", code);
315
+ py.runPython("import sys, io as _io\n_agent_figures.clear()\n_stdout = sys.stdout\nsys.stdout = _io.StringIO()");
316
+ let err = null;
317
+ try { await py.runPythonAsync("exec(_analysis_code, globals())"); }
318
+ catch (e) { err = e?.message || String(e); }
319
+ py.runPython("_v = sys.stdout.getvalue(); sys.stdout = _stdout");
320
+ try { py.runPython("_capture_fig()"); } catch { /* ignore figure-capture errors */ }
321
+ const stdout = py.globals.get("_v");
322
+ const figs = Array.from(py.globals.get("_agent_figures").toJs());
323
+ const text = `exit ${err ? 1 : 0}\n` + (stdout || "") + (err ? "\n[stderr] " + err : "");
324
+ return { text, images: figs, panel: true, isError: !!err };
325
+ },
326
+ };
327
+
328
+ const view_figure = {
329
+ name: "view_figure",
330
+ description: "View the most recently produced figure with vision to check layout (clipped labels, overlap, legends covering data). Call after a figure is made; fix issues with another run_python.",
331
+ input_schema: { type: "object", properties: {}, required: [] },
332
+ status: () => "Inspecting figure…",
333
+ run: async () => {
334
+ const py = await ensurePyodide(packages);
335
+ const figs = Array.from(py.globals.get("_agent_figures").toJs());
336
+ const last = figs[figs.length - 1];
337
+ if (!last) return "No figure available yet. Run code that calls plt.show()/savefig() first.";
338
+ return { text: "Inspecting the figure for layout issues.", image: last, panel: true, showToModel: true };
339
+ },
340
+ };
341
+
342
+ return [load_dataset, run_python, write_file, read_file, list_files, pip_install, run_analysis_py, view_figure];
343
+ }