@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 +21 -0
- package/README.md +101 -0
- package/package.json +22 -0
- package/sphere-agent.js +727 -0
- package/sphere-tools.js +343 -0
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
|
+
}
|
package/sphere-agent.js
ADDED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
152
|
+
.replace(/"/g, """).replace(/'/g, "'");
|
|
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 };
|
package/sphere-tools.js
ADDED
|
@@ -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
|
+
}
|