@chalky/chalky 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -1
- package/app/SYSTEM.md +3 -2
- package/app/extensions/boards/browser.js +357 -0
- package/app/extensions/boards/guide.md +4 -0
- package/app/extensions/boards/index.ts +98 -0
- package/app/extensions/commands/browser.js +13 -0
- package/app/extensions/commands/guide.md +3 -0
- package/app/extensions/commands/index.ts +96 -0
- package/app/extensions/doodle/browser.js +11 -1
- package/app/extensions/doodle/index.ts +1 -1
- package/app/extensions/frames/browser.js +7 -4
- package/app/extensions/frames/index.ts +16 -7
- package/app/extensions/login/browser.js +363 -0
- package/app/extensions/login/index.ts +214 -0
- package/app/extensions/point/browser.js +4 -2
- package/app/extensions/point/index.ts +18 -3
- package/app/extensions/ui/browser.js +1 -0
- package/app/extensions/ui/index.ts +23 -3
- package/app/extensions/versions/index.ts +2 -2
- package/app/extensions/voice/browser.js +71 -0
- package/app/extensions/voice/index.ts +37 -0
- package/app/settings.json +3 -0
- package/bin/chalky.mjs +1 -1
- package/package.json +1 -1
- package/server.ts +14 -8
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
// are written down as words, between the steps.
|
|
14
14
|
// Enter in the message line (also with the marker off) sends the steps, the drawing still on the page and
|
|
15
15
|
// the words typed there, together: doodle_send, to the agent picked in the chat.
|
|
16
|
+
// boards going to another board (boards/) is like a click: the drawing so far becomes a step, then "went to
|
|
17
|
+
// board N". Each step says the board it was drawn on.
|
|
16
18
|
// Things with a drag of their own keep it: a surface's label (moves it), its resize corner, lists to pick from,
|
|
17
19
|
// sliders, draggable things, frames; text boxes don't (a drag that starts on one draws). Ctrl+Z takes back
|
|
18
20
|
// the last mark; Esc turns the tools off (ink stays).
|
|
@@ -204,6 +206,7 @@ export default async function (page) {
|
|
|
204
206
|
const box = marks.length ? bounds(marks.flatMap((m) => m.points)) : { x: 0, y: 0, w: innerWidth, h: innerHeight };
|
|
205
207
|
const at = inMain(box.x, box.y); // the part of the main space the picture shows, so the agent can place things
|
|
206
208
|
const step = { drawn: marks.length > 0, words: marks.filter((m) => m.text).map((m) => m.text), then: [], shot: null,
|
|
209
|
+
board: Number(document.body.dataset.board) || 1, // the board shown (boards/)
|
|
207
210
|
area: { x: Math.round(at.x), y: Math.round(at.y), width: Math.round(box.w), height: Math.round(box.h) } };
|
|
208
211
|
step.ready = full ? picture(full, box).then((s) => { step.shot = s; }, () => {}) : Promise.resolve();
|
|
209
212
|
steps.push(step);
|
|
@@ -211,6 +214,13 @@ export default async function (page) {
|
|
|
211
214
|
marks = [];
|
|
212
215
|
show();
|
|
213
216
|
}
|
|
217
|
+
// going to another board (boards/, just before it changes) is like a click: the drawing so far becomes a step
|
|
218
|
+
document.addEventListener("board", (e) => {
|
|
219
|
+
if (!marks.length && !steps.length && !first.length) return; // no doodling going on
|
|
220
|
+
if (marks.length) keep(frame());
|
|
221
|
+
(steps.length ? steps.at(-1).then : first).push(`went to board ${e.detail.to}`);
|
|
222
|
+
render();
|
|
223
|
+
});
|
|
214
224
|
function clicked(t, surface) { // a click, as words
|
|
215
225
|
const label = short(t.getAttribute("aria-label") || t.title || t.textContent || t.value || t.localName, 30);
|
|
216
226
|
const row = t.closest("tr, li");
|
|
@@ -382,7 +392,7 @@ export default async function (page) {
|
|
|
382
392
|
render();
|
|
383
393
|
await Promise.all(sent.map((s) => s.ready));
|
|
384
394
|
page.send({ type: "doodle_send", to: document.getElementById("to")?.value, text, first: before,
|
|
385
|
-
steps: sent.map((s) => ({ shot: s.shot?.path || null, area: s.area, drawn: s.drawn, words: s.words, then: s.then })) });
|
|
395
|
+
steps: sent.map((s) => ({ shot: s.shot?.path || null, area: s.area, drawn: s.drawn, words: s.words, then: s.then, board: s.board })) });
|
|
386
396
|
allOff();
|
|
387
397
|
}, true);
|
|
388
398
|
}
|
|
@@ -30,7 +30,7 @@ function message(msg: any) {
|
|
|
30
30
|
steps.forEach((st, i) => {
|
|
31
31
|
const has = shots.includes(st.shot), a = st.area;
|
|
32
32
|
if (has) n++;
|
|
33
|
-
lines.push(`Step ${i + 1}` + (has ? ` (picture ${n})` : " (no picture)")
|
|
33
|
+
lines.push(`Step ${i + 1}` + (st.board ? ` on board ${st.board}` : "") + (has ? ` (picture ${n})` : " (no picture)")
|
|
34
34
|
+ (has && a ? `, showing x=${a.x} y=${a.y} w=${a.width} h=${a.height} of the page` : "")
|
|
35
35
|
+ (st.drawn ? "" : ": nothing drawn, how the screen looked after those clicks"));
|
|
36
36
|
for (const w of st.words ?? []) lines.push(` words: "${w}"`);
|
|
@@ -64,8 +64,10 @@ export default async function (page) {
|
|
|
64
64
|
document.addEventListener("keydown", (e) => { if (e.key === "Escape") setOn(false); });
|
|
65
65
|
send.onclick = () => {
|
|
66
66
|
for (const flush of Object.values(timers)) { clearTimeout(flush.t); flush(); } // text still waiting on the typing pause goes first
|
|
67
|
-
page.send({ type: "frames_send", to: document.getElementById("to")?.value });
|
|
67
|
+
page.send({ type: "frames_send", to: document.getElementById("to")?.value, board: board() });
|
|
68
68
|
};
|
|
69
|
+
const board = () => Number(document.body.dataset.board) || 1; // the board shown (boards/): frames are drawn and sent there
|
|
70
|
+
document.addEventListener("board", () => setTimeout(() => render(shown))); // after the switch: count that board's
|
|
69
71
|
|
|
70
72
|
const inMain = (x, y) => { // window px -> main-space px (as point.js)
|
|
71
73
|
const m = area.getBoundingClientRect();
|
|
@@ -94,7 +96,7 @@ export default async function (page) {
|
|
|
94
96
|
const f = { x: parseInt(ghost.style.left), y: parseInt(ghost.style.top), width: parseInt(ghost.style.width), height: parseInt(ghost.style.height) };
|
|
95
97
|
ghost.remove();
|
|
96
98
|
start = ghost = null;
|
|
97
|
-
if (f.width >= 40 && f.height >= 30) { focusNew = true; page.send({ type: "frame_add", ...f }); } // a click or a slip is not a frame
|
|
99
|
+
if (f.width >= 40 && f.height >= 30) { focusNew = true; page.send({ type: "frame_add", ...f, board: board() }); } // a click or a slip is not a frame
|
|
98
100
|
});
|
|
99
101
|
|
|
100
102
|
const boxes = {}; // id -> its box on the page
|
|
@@ -103,7 +105,7 @@ export default async function (page) {
|
|
|
103
105
|
document.addEventListener("erase", (e) => { // the doodle eraser at {x, y}, main px: unsent frames under it go
|
|
104
106
|
const p = e.detail;
|
|
105
107
|
for (const [id, f] of Object.entries(shown)) {
|
|
106
|
-
if (f.sent || p.x < f.x || p.x > f.x + f.width || p.y < f.y || p.y > f.y + f.height) continue;
|
|
108
|
+
if (f.sent || (f.board ?? 1) !== board() || p.x < f.x || p.x > f.x + f.width || p.y < f.y || p.y > f.y + f.height) continue;
|
|
107
109
|
delete shown[id]; // once, though the eraser keeps moving over it
|
|
108
110
|
page.send({ type: "frame_remove", id });
|
|
109
111
|
}
|
|
@@ -134,13 +136,14 @@ export default async function (page) {
|
|
|
134
136
|
newest = box;
|
|
135
137
|
}
|
|
136
138
|
put(box, f);
|
|
139
|
+
box.dataset.board = f.board ?? 1; // boards/ shows it on its board
|
|
137
140
|
box.classList.toggle("sent", f.sent);
|
|
138
141
|
const text = box.querySelector("textarea");
|
|
139
142
|
text.readOnly = f.sent;
|
|
140
143
|
if (document.activeElement !== text) text.value = f.text;
|
|
141
144
|
}
|
|
142
145
|
if (focusNew && newest) { focusNew = false; newest.querySelector("textarea").focus(); }
|
|
143
|
-
ready = Object.values(frames).filter((f) => !f.sent && f.text.trim()).length;
|
|
146
|
+
ready = Object.values(frames).filter((f) => !f.sent && f.text.trim() && (f.board ?? 1) === board()).length;
|
|
144
147
|
showReady();
|
|
145
148
|
send.title = `Implement: send ${ready} frame${ready > 1 ? "s" : ""} to the agent to build, together`;
|
|
146
149
|
}
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
// frames.js). browser.js adds a tool to the palette: drag boxes on the main space, type in each what goes there,
|
|
3
3
|
// then press Implement. All the frames go together, as one message, to the agent picked in the chat (else agent1),
|
|
4
4
|
// so it sees the whole layout and decides how to split the work (alone, or with helpers: README.md, not code).
|
|
5
|
-
// A frame is {x, y, width, height, text, sent, to
|
|
5
|
+
// A frame is {x, y, width, height, text, sent, to?, board} (main-space px, where ui_render's place puts a surface;
|
|
6
|
+
// board: the one it was drawn on, boards/; Implement sends the frames of the board shown), with an
|
|
6
7
|
// id (frame-1, frame-2, ...: never reused). An agent fills it with ui_render(surface="frame-1", place=<its place>);
|
|
7
8
|
// when that surface appears (a ui watcher, host.api.ui.watch), the frame is done and its dashed box goes. The user
|
|
8
9
|
// can remove a frame (its ✕); if it was sent, its agent is told. Until a frame is sent, its corner resizes it.
|
|
9
10
|
// GET /frames {id: frame}
|
|
10
|
-
// page messages frame_add {x, y, width, height}, frame_place {id, x, y, width, height}, frame_text {id, text},
|
|
11
|
-
// frame_remove {id}, frames_send {to}
|
|
11
|
+
// page messages frame_add {x, y, width, height, board}, frame_place {id, x, y, width, height}, frame_text {id, text},
|
|
12
|
+
// frame_remove {id}, frames_send {to, board}
|
|
12
13
|
// event "frames" {frames: {id: frame}} to every open page, when they changed
|
|
13
14
|
// Saved in state/frames.json as {last: <last id number>, frames: {id: frame}}; shared by every agent (host.shared).
|
|
14
15
|
import fs from "node:fs";
|
|
@@ -40,6 +41,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
40
41
|
host.api.ui?.watch("frames", (sid: string) => {
|
|
41
42
|
if (frames[sid] && host.shared.get("ui")?.surfaces[sid]) { delete frames[sid]; changed(); }
|
|
42
43
|
});
|
|
44
|
+
host.api.boards?.onRemove("frames", (n: number) => { // a removed board (boards/) takes its frames with it
|
|
45
|
+
const ids = Object.keys(frames).filter((id) => (frames[id].board ?? 1) === n);
|
|
46
|
+
const gone = ids.map((id) => ({ owner: frames[id].sent ? frames[id].to : undefined, what: `frame ${id}` }));
|
|
47
|
+
for (const id of ids) delete frames[id];
|
|
48
|
+
if (ids.length) changed();
|
|
49
|
+
return gone;
|
|
50
|
+
});
|
|
43
51
|
});
|
|
44
52
|
|
|
45
53
|
host.routes.set("/frames", (_req: any, res: any) => res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(frames)));
|
|
@@ -47,7 +55,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
47
55
|
host.pageHandlers.set("frames", (msg: any) => {
|
|
48
56
|
const f = frames[msg.id], swarm = host.api.swarm;
|
|
49
57
|
if (msg.type === "frame_add") {
|
|
50
|
-
frames[`frame-${++saved.last}`] = { ...box(msg), text: "", sent: false };
|
|
58
|
+
frames[`frame-${++saved.last}`] = { ...box(msg), text: "", sent: false, board: Number(msg.board) || 1 };
|
|
51
59
|
changed();
|
|
52
60
|
} else if (msg.type === "frame_place" && f && !f.sent) {
|
|
53
61
|
Object.assign(f, box(msg, f));
|
|
@@ -61,13 +69,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
61
69
|
if (f.sent) swarm?.send("user", f.to, `[frames on the UI] the user removed ${msg.id} ("${f.text}"): don't fill it.`, true);
|
|
62
70
|
} else if (msg.type === "frames_send" && swarm) {
|
|
63
71
|
const to = msg.to in swarm.status() ? msg.to : "agent1";
|
|
64
|
-
const
|
|
72
|
+
const board = Number(msg.board) || 1; // the board shown: its frames only (the user sees no others)
|
|
73
|
+
const ready = Object.entries(frames).filter(([, f]: any) => !f.sent && f.text.trim() && (f.board ?? 1) === board);
|
|
65
74
|
if (!ready.length) return;
|
|
66
75
|
for (const [, f] of ready as any) Object.assign(f, { sent: true, to });
|
|
67
76
|
changed();
|
|
68
|
-
const lines = [`[frames on the UI] the user laid out ${ready.length} frame${ready.length > 1 ? "s" : ""} to fill:`,
|
|
77
|
+
const lines = [`[frames on the UI] the user laid out ${ready.length} frame${ready.length > 1 ? "s" : ""} on board ${board} to fill:`,
|
|
69
78
|
...ready.map(([id, f]: any) => ` ${id}: "${f.text}" · place=${place(f)}`),
|
|
70
|
-
|
|
79
|
+
` Fill each with ui_render(surface=<its id>, place=<its place>, board=${board}, ...); its dashed box goes when it appears.`];
|
|
71
80
|
const waiting = Object.keys(frames).filter((id) => frames[id].sent && !ready.some(([r]) => r === id));
|
|
72
81
|
if (waiting.length) lines.push(` Sent before and not filled yet: ${waiting.join(", ")}.`);
|
|
73
82
|
swarm.send("user", to, lines.join("\n"));
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// Browser half of the login extension: /login and /logout open a dialog in the middle of the page, as /mcp does
|
|
2
|
+
// (a click on the backdrop, Esc or ✕ closes it; closing it during a sign-in cancels the sign-in).
|
|
3
|
+
// list Sign in (pi's providers) and custom endpoints, each "connected" / "sign in again" (GET /login)
|
|
4
|
+
// sign-in what pi asks for, as it asks (event "login"): a link to open, a code to type, a choice, a box to paste
|
|
5
|
+
// into; ✕ cancels
|
|
6
|
+
// custom name, API type, base URL, key (optional), model names as chips (Enter or "," after each; a paste is
|
|
7
|
+
// split); "Ask the endpoint" lists its models to pick from
|
|
8
|
+
// logout the saved logins, each with Log out / Remove
|
|
9
|
+
// model (/model, and after connecting) the usable models, grouped by provider; "Use for" all agents or one agent
|
|
10
|
+
// (an agent's own choice wins; "Same as all agents" drops it). A tick shows which agents use a model now
|
|
11
|
+
// The server does the work (index.ts); keys typed here go to it once and never come back. CHALKY_LOGIN=off: no commands.
|
|
12
|
+
export default function (page) {
|
|
13
|
+
const APIS = { "openai-completions": "OpenAI", "openai-responses": "OpenAI Responses", "anthropic-messages": "Anthropic",
|
|
14
|
+
"google-generative-ai": "Google" }; // the API the endpoint speaks
|
|
15
|
+
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
16
|
+
|
|
17
|
+
const style = document.createElement("style");
|
|
18
|
+
style.textContent = `
|
|
19
|
+
#login { width: min(560px, calc(100vw - 32px)); max-height: 75vh; padding: 0; box-sizing: border-box; color: var(--text);
|
|
20
|
+
background: var(--glass-strong); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
|
|
21
|
+
border: 1px solid var(--edge); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; font-size: 13px; }
|
|
22
|
+
#login[open] { display: flex; flex-direction: column; }
|
|
23
|
+
#login::backdrop { background: rgba(0, 0, 0, .08); }
|
|
24
|
+
#login:focus { outline: none; }
|
|
25
|
+
#login .body > .btns:last-child { position: sticky; bottom: -16px; margin: 0 -14px -16px; padding: 10px 14px;
|
|
26
|
+
background: var(--glass-strong); border-top: 1px solid var(--line); } /* Use / Save stay in view */
|
|
27
|
+
#login .head { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--line); font-weight: 600; }
|
|
28
|
+
#login .head button { border: 0; background: none; padding: 0; color: var(--accent); font: inherit; font-weight: 400; cursor: pointer; }
|
|
29
|
+
#login .head .x { margin-left: auto; color: var(--muted); font-size: 15px; }
|
|
30
|
+
#login .body { flex: 1; min-height: 0; overflow-y: auto; padding: 12px 14px 16px; display: grid; gap: 12px; align-content: start;
|
|
31
|
+
grid-auto-rows: max-content; } /* rows keep their height: the body scrolls, not each list */
|
|
32
|
+
#login .group { font-size: 10.5px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); margin: 4px 0 -6px; }
|
|
33
|
+
#login .rows { display: grid; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
|
|
34
|
+
#login .row { display: flex; align-items: center; gap: 10px; padding: 8px 10px; cursor: pointer; }
|
|
35
|
+
#login .row + .row { border-top: 1px solid var(--line); }
|
|
36
|
+
#login .row:hover { background: var(--hover); }
|
|
37
|
+
#login .row .how { margin-left: auto; font-size: 11px; color: var(--muted); white-space: nowrap; }
|
|
38
|
+
#login .dot { width: 22px; height: 22px; border-radius: 6px; display: grid; place-items: center; flex: none;
|
|
39
|
+
background: var(--hover); color: var(--soft); font-weight: 600; font-size: 11px; }
|
|
40
|
+
#login .chip { font-size: 10.5px; padding: 1px 7px; border-radius: 99px; white-space: nowrap; margin-left: auto; }
|
|
41
|
+
#login .chip.ok { background: rgba(52, 199, 89, 0.14); color: #1f8a3e; }
|
|
42
|
+
#login .chip.expired { background: rgba(255, 159, 10, 0.16); color: #b25e00; }
|
|
43
|
+
#login label { display: grid; gap: 5px; font-size: 12px; color: var(--soft); }
|
|
44
|
+
#login input, #login select { font: inherit; color: var(--text); background: rgba(255, 255, 255, 0.9); border: 1px solid var(--line);
|
|
45
|
+
border-radius: 9px; padding: 7px 10px; outline: none; min-width: 0; }
|
|
46
|
+
#login input:focus, #login select:focus, #login .tags:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(0, 113, 227, 0.12); }
|
|
47
|
+
#login .mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12.5px; }
|
|
48
|
+
#login .two { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
|
49
|
+
#login .btns { display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; }
|
|
50
|
+
#login .btn { border: 0; border-radius: 9px; padding: 7px 14px; font: 500 13px inherit; font-family: inherit; cursor: pointer;
|
|
51
|
+
background: var(--hover); color: var(--text); text-decoration: none; }
|
|
52
|
+
#login .btn.primary { background: var(--accent); color: #fff; }
|
|
53
|
+
#login .btn.small { padding: 3px 10px; font-size: 12px; }
|
|
54
|
+
#login .btn:disabled { opacity: 0.5; cursor: default; }
|
|
55
|
+
#login .hint { font-size: 11.5px; color: var(--muted); }
|
|
56
|
+
#login .row .hint { white-space: nowrap; }
|
|
57
|
+
#login .error { color: var(--danger); font-size: 12.5px; }
|
|
58
|
+
#login .wait { display: flex; gap: 10px; align-items: center; color: var(--soft); }
|
|
59
|
+
#login .spin { width: 14px; height: 14px; border-radius: 50%; border: 2px solid rgba(0, 113, 227, 0.15); border-top-color: var(--accent);
|
|
60
|
+
animation: login-spin 1s linear infinite; flex: none; }
|
|
61
|
+
@keyframes login-spin { to { transform: rotate(360deg) } }
|
|
62
|
+
#login .code { font: 600 26px ui-monospace, "SF Mono", Menlo, monospace; letter-spacing: 0.12em; text-align: center; padding: 14px;
|
|
63
|
+
border-radius: 10px; background: rgba(255, 255, 255, 0.9); border: 1px dashed var(--line); user-select: all; }
|
|
64
|
+
#login details summary { cursor: pointer; color: var(--accent); font-size: 12.5px; }
|
|
65
|
+
#login details[open] summary { margin-bottom: 8px; }
|
|
66
|
+
#login .tags { display: flex; flex-wrap: wrap; gap: 6px; background: rgba(255, 255, 255, 0.9); border: 1px solid var(--line); border-radius: 9px; padding: 5px; }
|
|
67
|
+
#login .tags input { border: 0; box-shadow: none; padding: 3px 4px; flex: 1; min-width: 120px; background: none; }
|
|
68
|
+
#login .tag { font: 12px ui-monospace, "SF Mono", Menlo, monospace; background: rgba(0, 113, 227, 0.1); border-radius: 6px;
|
|
69
|
+
padding: 3px 4px 3px 8px; display: inline-flex; gap: 2px; align-items: center; }
|
|
70
|
+
#login .tag button { border: 0; background: none; color: var(--muted); cursor: pointer; padding: 0 4px; font-size: 11px; }
|
|
71
|
+
#login .found { max-height: 150px; overflow-y: auto; display: flex; flex-wrap: wrap; gap: 4px; }
|
|
72
|
+
#login .found button { border: 1px solid var(--line); background: none; border-radius: 6px; padding: 2px 7px; cursor: pointer;
|
|
73
|
+
font: 11.5px ui-monospace, "SF Mono", Menlo, monospace; color: var(--soft); }
|
|
74
|
+
#login .found button:hover { background: var(--hover); color: var(--text); }
|
|
75
|
+
#login .for { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; color: var(--soft); }
|
|
76
|
+
#login .seg { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
|
|
77
|
+
#login .seg button { border: 0; background: none; padding: 3px 10px; font: inherit; font-size: 12px; cursor: pointer; color: var(--text); }
|
|
78
|
+
#login .seg button.on { background: var(--accent); color: #fff; }
|
|
79
|
+
#login .radio { width: 14px; height: 14px; border-radius: 50%; border: 1.5px solid var(--muted); flex: none; box-sizing: border-box; }
|
|
80
|
+
#login .row.on .radio { border: 4.5px solid var(--accent); }
|
|
81
|
+
#login .model-id { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12.5px; overflow-wrap: anywhere; }`;
|
|
82
|
+
document.head.append(style);
|
|
83
|
+
|
|
84
|
+
const sheet = document.createElement("dialog");
|
|
85
|
+
sheet.id = "login";
|
|
86
|
+
document.body.append(sheet);
|
|
87
|
+
sheet.onclick = (e) => { if (e.target === sheet) sheet.close(); }; // a click on the backdrop closes it
|
|
88
|
+
sheet.onclose = () => { // ✕, Esc, the backdrop, Done
|
|
89
|
+
if (view === "signin") page.send({ type: "login_cancel" });
|
|
90
|
+
view = signing = form = null;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
let view = null; // "list" | "signin" | "custom" | "logout" | "done"
|
|
94
|
+
let signing = null; // the provider being signed in to
|
|
95
|
+
let form = null; // the custom form's state: {id, editing, api, baseUrl, models: [], found: []}
|
|
96
|
+
|
|
97
|
+
function open(next) {
|
|
98
|
+
if (!sheet.open) sheet.showModal();
|
|
99
|
+
next();
|
|
100
|
+
}
|
|
101
|
+
const close = () => sheet.close();
|
|
102
|
+
|
|
103
|
+
function draw(title, body, back) {
|
|
104
|
+
sheet.innerHTML = `<div class="head">${back ? `<button class="back">‹ Back</button>` : ""}${esc(title)}<button class="x" title="Close">✕</button></div>
|
|
105
|
+
<div class="body">${body}</div>`;
|
|
106
|
+
sheet.querySelector(".x").onclick = close;
|
|
107
|
+
if (back) sheet.querySelector(".back").onclick = back;
|
|
108
|
+
return sheet.querySelector(".body");
|
|
109
|
+
}
|
|
110
|
+
const chip = (status) => status === "connected" ? `<span class="chip ok">connected</span>`
|
|
111
|
+
: status === "expired" ? `<span class="chip expired">sign in again</span>` : "";
|
|
112
|
+
const fetchList = () => fetch("/login").then((r) => r.ok ? r.json() : Promise.reject(new Error(`/login: ${r.status}`)));
|
|
113
|
+
|
|
114
|
+
// ---- the list
|
|
115
|
+
async function showList() {
|
|
116
|
+
view = "list";
|
|
117
|
+
const body = draw("Connect a model", `<div class="wait"><span class="spin"></span>Checking your logins…</div>`);
|
|
118
|
+
let data;
|
|
119
|
+
try { data = await fetchList(); } catch (e) { body.innerHTML = `<div class="error">${esc(e.message)}</div>`; return; }
|
|
120
|
+
if (view !== "list") return;
|
|
121
|
+
body.innerHTML = `<input class="search" placeholder="Search…" aria-label="Search">
|
|
122
|
+
<div class="group">Sign in</div>
|
|
123
|
+
<div class="rows signin">${data.providers.map((p) => `<div class="row" data-id="${esc(p.id)}" data-name="${esc(p.name)}">
|
|
124
|
+
<span class="dot">${esc(p.name[0])}</span>${esc(p.name)}${p.subscription ? "" : `<span class="hint">not a subscription</span>`}
|
|
125
|
+
${chip(p.status) || `<span class="how">sign in</span>`}</div>`).join("")}</div>
|
|
126
|
+
<div class="group">Custom endpoints</div>
|
|
127
|
+
<div class="rows custom">${data.custom.map((c) => `<div class="row" data-id="${esc(c.id)}">
|
|
128
|
+
<span class="dot">${esc(c.id[0].toUpperCase())}</span>${esc(c.id)} <span class="hint">${esc(c.models.length)} model${c.models.length === 1 ? "" : "s"}</span>
|
|
129
|
+
${chip(c.status) || `<span class="how">edit</span>`}</div>`).join("")}
|
|
130
|
+
<div class="row new"><span class="dot">+</span>Custom endpoint<span class="how">URL · key · models</span></div></div>`;
|
|
131
|
+
const search = body.querySelector(".search");
|
|
132
|
+
search.oninput = () => {
|
|
133
|
+
const q = search.value.toLowerCase();
|
|
134
|
+
for (const r of body.querySelectorAll(".row:not(.new)")) r.style.display = `${r.dataset.id} ${r.dataset.name ?? ""}`.toLowerCase().includes(q) ? "" : "none";
|
|
135
|
+
};
|
|
136
|
+
search.focus();
|
|
137
|
+
for (const r of body.querySelectorAll(".signin .row")) r.onclick = () => startSignIn(r.dataset.id, r.dataset.name);
|
|
138
|
+
for (const r of body.querySelectorAll(".custom .row:not(.new)"))
|
|
139
|
+
r.onclick = () => showCustom(data.custom.find((c) => c.id === r.dataset.id));
|
|
140
|
+
body.querySelector(".row.new").onclick = () => showCustom();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---- sign in: the sheet shows what pi asks for, as it asks
|
|
144
|
+
function startSignIn(id, name) {
|
|
145
|
+
view = "signin";
|
|
146
|
+
signing = { id, name };
|
|
147
|
+
const body = draw(name, `<div class="link"></div><div class="wait"><span class="spin"></span><span class="msg">Starting…</span></div>
|
|
148
|
+
<div class="ask"></div><div class="error"></div>`, () => { page.send({ type: "login_cancel" }); showList(); });
|
|
149
|
+
body.dataset.provider = id;
|
|
150
|
+
page.send({ type: "login_start", provider: id });
|
|
151
|
+
}
|
|
152
|
+
function onSignIn(ev) {
|
|
153
|
+
const body = sheet.querySelector(".body");
|
|
154
|
+
if (view !== "signin" || !body) return;
|
|
155
|
+
const msg = body.querySelector(".msg"), link = body.querySelector(".link"), ask = body.querySelector(".ask");
|
|
156
|
+
if (ev.step === "notify") {
|
|
157
|
+
const e = ev.event;
|
|
158
|
+
if (e.type === "auth_url") {
|
|
159
|
+
link.innerHTML = `<div class="btns" style="justify-content:flex-start"><a class="btn primary" target="_blank" rel="noopener"
|
|
160
|
+
href="${esc(e.url)}">Open the ${esc(signing.name)} sign-in page ↗</a></div>`;
|
|
161
|
+
msg.textContent = "Waiting for you to finish in the other tab…";
|
|
162
|
+
} else if (e.type === "device_code") {
|
|
163
|
+
link.innerHTML = `<div class="hint" style="text-align:center">Go to <a target="_blank" rel="noopener" href="${esc(e.verificationUri)}">${esc(e.verificationUri)}</a> and enter</div>
|
|
164
|
+
<div class="code">${esc(e.userCode)}</div>
|
|
165
|
+
<div class="btns" style="justify-content:center"><button class="btn copy">Copy code</button>
|
|
166
|
+
<a class="btn primary" target="_blank" rel="noopener" href="${esc(e.verificationUri)}">Open ↗</a></div>`;
|
|
167
|
+
link.querySelector(".copy").onclick = () => navigator.clipboard?.writeText(e.userCode);
|
|
168
|
+
msg.textContent = "Waiting…";
|
|
169
|
+
} else msg.textContent = e.message; // progress, info
|
|
170
|
+
}
|
|
171
|
+
if (ev.step === "prompt") {
|
|
172
|
+
const p = ev.prompt;
|
|
173
|
+
if (p.type === "select") {
|
|
174
|
+
ask.innerHTML = `<div class="hint">${esc(p.message)}</div><div class="rows">${p.options.map((o) =>
|
|
175
|
+
`<div class="row" data-id="${esc(o.id)}">${esc(o.label)}</div>`).join("")}</div>`;
|
|
176
|
+
for (const r of ask.querySelectorAll(".row")) r.onclick = () => { ask.innerHTML = ""; page.send({ type: "login_answer", text: r.dataset.id }); };
|
|
177
|
+
msg.textContent = "Choose one:";
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
// text, secret, manual_code: a box to type or paste into. manual_code goes with a link already open: tucked away
|
|
181
|
+
const field = `<label>${esc(p.message)}<input class="mono" type="${p.type === "secret" ? "password" : "text"}"
|
|
182
|
+
placeholder="${esc(p.placeholder ?? "")}"></label><div class="btns"><button class="btn primary">Continue</button></div>`;
|
|
183
|
+
ask.innerHTML = p.type === "manual_code" ? `<details><summary>The page after signing in didn't load?</summary>${field}</details>` : field;
|
|
184
|
+
const input = ask.querySelector("input"), go = () => { page.send({ type: "login_answer", text: input.value }); ask.innerHTML = ""; msg.textContent = "Checking…"; };
|
|
185
|
+
ask.querySelector(".btn").onclick = go;
|
|
186
|
+
input.onkeydown = (e) => { if (e.key === "Enter") go(); };
|
|
187
|
+
if (p.type !== "manual_code") input.focus();
|
|
188
|
+
}
|
|
189
|
+
if (ev.step === "prompt_done") ask.innerHTML = "";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---- custom endpoint
|
|
193
|
+
function showCustom(c) {
|
|
194
|
+
view = "custom";
|
|
195
|
+
form = { editing: !!c, found: [], models: [...(c?.models ?? [])] };
|
|
196
|
+
const body = draw(c ? c.id : "Custom endpoint", `
|
|
197
|
+
<div class="two"><label>Name<input class="id" value="${esc(c?.id ?? "")}" placeholder="openrouter" ${c ? "disabled" : ""}></label>
|
|
198
|
+
<label>Speaks<select class="api">${Object.entries(APIS).map(([v, l]) => `<option value="${v}" ${v === (c?.api ?? "openai-completions") ? "selected" : ""}>${l}</option>`).join("")}</select></label></div>
|
|
199
|
+
<label>Base URL<input class="url mono" value="${esc(c?.baseUrl ?? "")}" placeholder="https://openrouter.ai/api/v1"></label>
|
|
200
|
+
<label><span>API key <span class="hint">(optional${c ? "; leave empty to keep the saved one" : ""})</span></span><input class="key mono" type="password" autocomplete="off"></label>
|
|
201
|
+
<label><span>Models <span class="hint">(Enter or "," after each; paste several at once)</span></span><div class="tags"><input class="add mono" placeholder="model name"></div></label>
|
|
202
|
+
<div class="found"></div>
|
|
203
|
+
<div class="error"></div>
|
|
204
|
+
<div class="btns" style="justify-content:space-between"><button class="btn ask">Ask the endpoint</button><button class="btn primary save">Save</button></div>`,
|
|
205
|
+
showList);
|
|
206
|
+
const tags = body.querySelector(".tags"), add = body.querySelector(".add"), found = body.querySelector(".found");
|
|
207
|
+
const drawTags = () => {
|
|
208
|
+
for (const t of tags.querySelectorAll(".tag")) t.remove();
|
|
209
|
+
for (const m of form.models) {
|
|
210
|
+
const t = document.createElement("span");
|
|
211
|
+
t.className = "tag";
|
|
212
|
+
t.innerHTML = `${esc(m)}<button title="Remove">✕</button>`;
|
|
213
|
+
t.querySelector("button").onclick = (e) => { e.preventDefault(); form.models = form.models.filter((x) => x !== m); drawTags(); drawFound(); };
|
|
214
|
+
tags.insertBefore(t, add);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
const drawFound = () => { // the endpoint's own names, narrowed by what is being typed; click one to add it
|
|
218
|
+
const q = add.value.trim().toLowerCase();
|
|
219
|
+
const left = form.found.filter((m) => !form.models.includes(m) && m.toLowerCase().includes(q));
|
|
220
|
+
found.innerHTML = left.slice(0, 200).map((m) => `<button data-m="${esc(m)}">${esc(m)}</button>`).join("")
|
|
221
|
+
+ (left.length > 200 ? `<span class="hint">${left.length - 200} more: type to narrow</span>` : "");
|
|
222
|
+
for (const b of found.querySelectorAll("button")) b.onclick = () => addNames([b.dataset.m]);
|
|
223
|
+
};
|
|
224
|
+
const addNames = (names) => {
|
|
225
|
+
for (const n of names.map((s) => s.trim()).filter(Boolean)) if (!form.models.includes(n)) form.models.push(n);
|
|
226
|
+
add.value = "";
|
|
227
|
+
drawTags(); drawFound();
|
|
228
|
+
};
|
|
229
|
+
add.onkeydown = (e) => {
|
|
230
|
+
if (e.key === "Enter" || e.key === ",") { e.preventDefault(); addNames(add.value.split(",")); }
|
|
231
|
+
else if (e.key === "Backspace" && !add.value && form.models.length) { form.models.pop(); drawTags(); drawFound(); }
|
|
232
|
+
};
|
|
233
|
+
add.oninput = drawFound;
|
|
234
|
+
add.onpaste = (e) => { e.preventDefault(); addNames((e.clipboardData.getData("text") || "").split(/[\s,]+/)); };
|
|
235
|
+
tags.onclick = () => add.focus();
|
|
236
|
+
drawTags();
|
|
237
|
+
const value = (sel) => body.querySelector(sel).value.trim();
|
|
238
|
+
body.querySelector(".ask").onclick = () => {
|
|
239
|
+
body.querySelector(".error").textContent = "";
|
|
240
|
+
found.innerHTML = `<span class="hint">Asking…</span>`;
|
|
241
|
+
page.send({ type: "login_list_models", id: c?.id, api: value(".api"), baseUrl: value(".url"), key: value(".key") });
|
|
242
|
+
};
|
|
243
|
+
body.querySelector(".save").onclick = () => {
|
|
244
|
+
if (add.value.trim()) addNames(add.value.split(",")); // names typed but not yet added
|
|
245
|
+
body.querySelector(".error").textContent = "";
|
|
246
|
+
body.querySelector(".save").disabled = true;
|
|
247
|
+
page.send({ type: "login_custom", id: value(".id"), api: value(".api"), baseUrl: value(".url"), key: value(".key"), models: form.models });
|
|
248
|
+
};
|
|
249
|
+
(c ? add : body.querySelector(".id")).focus();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---- logout
|
|
253
|
+
async function showLogout() {
|
|
254
|
+
view = "logout";
|
|
255
|
+
const body = draw("Saved logins", `<div class="wait"><span class="spin"></span>Loading…</div>`);
|
|
256
|
+
let data;
|
|
257
|
+
try { data = await fetchList(); } catch (e) { body.innerHTML = `<div class="error">${esc(e.message)}</div>`; return; }
|
|
258
|
+
if (view !== "logout") return;
|
|
259
|
+
const rows = [...data.providers.filter((p) => p.status).map((p) => ({ id: p.id, name: p.name, how: p.status === "expired" ? "expired" : "signed in", act: "Log out" })),
|
|
260
|
+
...data.custom.map((c) => ({ id: c.id, name: `${c.id} (custom)`, how: c.status ? "key" : "no key", act: "Remove" }))];
|
|
261
|
+
body.innerHTML = rows.length ? `<div class="rows">${rows.map((r) => `<div class="row" data-id="${esc(r.id)}"><span class="dot">${esc(r.name[0].toUpperCase())}</span>
|
|
262
|
+
${esc(r.name)}<span class="how">${r.how}</span><button class="btn small">${r.act}</button></div>`).join("")}</div>
|
|
263
|
+
<div class="hint">Log out removes the login from this machine (app/auth.json). Remove also deletes the endpoint.</div>`
|
|
264
|
+
: `<div class="hint">No saved logins. Type /login to add one.</div>`;
|
|
265
|
+
for (const b of body.querySelectorAll(".row .btn")) b.onclick = () => {
|
|
266
|
+
b.disabled = true;
|
|
267
|
+
page.send({ type: "logout", provider: b.closest(".row").dataset.id });
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---- choose the model
|
|
272
|
+
async function showModels(title = "Choose the model") {
|
|
273
|
+
view = "model";
|
|
274
|
+
const body = draw(title, `<div class="wait"><span class="spin"></span>Loading models…</div>`);
|
|
275
|
+
let data;
|
|
276
|
+
try { data = await fetch("/login/models").then((r) => r.ok ? r.json() : Promise.reject(new Error(`/login/models: ${r.status}`))); }
|
|
277
|
+
catch (e) { body.innerHTML = `<div class="error">${esc(e.message)}</div>`; return; }
|
|
278
|
+
if (view !== "model") return;
|
|
279
|
+
const key = (m) => m ? `${m.provider}/${m.id}` : "same";
|
|
280
|
+
const firstAgentModel = data.agents[0]?.model;
|
|
281
|
+
form = { scope: "all", pick: key(data.default ?? firstAgentModel) };
|
|
282
|
+
const label = (k) => k === "same" ? "the all-agents model" : k.slice(k.indexOf("/") + 1);
|
|
283
|
+
function render() {
|
|
284
|
+
const agent = data.agents.find((a) => a.name === form.scope);
|
|
285
|
+
const users = (k) => data.agents.filter((a) => key(a.model) === k).map((a) => a.name);
|
|
286
|
+
body.innerHTML = `<div class="for">Use for <span class="seg"><button data-for="all" class="${form.scope === "all" ? "on" : ""}">All agents</button>${data.agents.map((a) =>
|
|
287
|
+
`<button data-for="${esc(a.name)}" class="${form.scope === a.name ? "on" : ""}">${esc(a.name)}</button>`).join("")}</span></div>
|
|
288
|
+
<div class="hint">${agent ? `Only ${esc(agent.name)}; the others keep theirs.` : "Every agent that hasn't picked its own."}</div>
|
|
289
|
+
<input class="search" placeholder="Search models…" aria-label="Search models">
|
|
290
|
+
${agent ? `<div class="rows"><div class="row ${form.pick === "same" ? "on" : ""}" data-k="same"><span class="radio"></span>Same as all agents
|
|
291
|
+
<span class="how">${esc(data.default ? data.default.id : "MODEL_BASE_URL")}</span></div></div>` : ""}
|
|
292
|
+
${data.groups.map((g) => `<div class="group">${esc(g.name)}</div><div class="rows">${g.models.map((m) => {
|
|
293
|
+
const k = `${g.provider}/${m.id}`, who = users(k);
|
|
294
|
+
return `<div class="row ${form.pick === k ? "on" : ""}" data-k="${esc(k)}" data-q="${esc(`${g.name} ${m.id} ${m.name}`.toLowerCase())}"><span class="radio"></span>
|
|
295
|
+
<span class="model-id">${esc(m.id)}</span><span class="how">${who.map((n) => `${esc(n)} ✓`).join(" ")}${who.length && m.context ? " · " : ""}${m.context ? `${Math.round(m.context / 1000)}k` : ""}</span></div>`;
|
|
296
|
+
}).join("")}</div>`).join("") || `<div class="hint">No models yet: type /login to connect one.</div>`}
|
|
297
|
+
<div class="error"></div>
|
|
298
|
+
<div class="btns"><button class="btn primary use">Use ${esc(label(form.pick))}</button></div>`;
|
|
299
|
+
for (const b of body.querySelectorAll(".seg button")) b.onclick = () => {
|
|
300
|
+
form.scope = b.dataset.for;
|
|
301
|
+
const a = data.agents.find((x) => x.name === form.scope);
|
|
302
|
+
form.pick = a ? (a.own ? key(a.model) : "same") : key(data.default ?? firstAgentModel);
|
|
303
|
+
render();
|
|
304
|
+
};
|
|
305
|
+
for (const r of body.querySelectorAll(".row[data-k]")) r.onclick = () => {
|
|
306
|
+
form.pick = r.dataset.k;
|
|
307
|
+
for (const x of body.querySelectorAll(".row.on")) x.classList.remove("on");
|
|
308
|
+
r.classList.add("on");
|
|
309
|
+
body.querySelector(".use").textContent = `Use ${label(form.pick)}`;
|
|
310
|
+
};
|
|
311
|
+
const search = body.querySelector(".search");
|
|
312
|
+
search.oninput = () => {
|
|
313
|
+
const q = search.value.toLowerCase();
|
|
314
|
+
for (const r of body.querySelectorAll(".row[data-q]")) r.style.display = r.dataset.q.includes(q) ? "" : "none";
|
|
315
|
+
};
|
|
316
|
+
body.querySelector(".use").onclick = () => {
|
|
317
|
+
const [provider, ...rest] = form.pick === "same" ? [] : form.pick.split("/");
|
|
318
|
+
if (form.scope === "all" && !provider) return;
|
|
319
|
+
form.said = form.scope === "all" ? `all agents now use ${rest.join("/")}`
|
|
320
|
+
: provider ? `${form.scope} now uses ${rest.join("/")}` : `${form.scope} is back on the all-agents model`;
|
|
321
|
+
body.querySelector(".use").disabled = true;
|
|
322
|
+
page.send({ type: "model_set", for: form.scope, provider, id: rest.join("/") || undefined });
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
render();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ---- what the server says
|
|
329
|
+
page.on("login", (ev) => {
|
|
330
|
+
if (!sheet.open) return;
|
|
331
|
+
const err = sheet.querySelector(".error");
|
|
332
|
+
if (ev.step === "error") {
|
|
333
|
+
if (err) err.textContent = ev.text;
|
|
334
|
+
sheet.querySelector(".save")?.removeAttribute("disabled");
|
|
335
|
+
const msg = sheet.querySelector(".msg");
|
|
336
|
+
if (view === "signin" && msg) { msg.textContent = "Didn't work:"; sheet.querySelector(".spin")?.remove(); }
|
|
337
|
+
if (view === "custom") sheet.querySelector(".found").innerHTML = "";
|
|
338
|
+
if (view === "model") { sheet.querySelector(".use")?.removeAttribute("disabled"); form.said = null; }
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (ev.step === "done") {
|
|
342
|
+
page.note(`connected to ${ev.provider}`);
|
|
343
|
+
return showModels(`Connected to ${ev.provider} ✓`);
|
|
344
|
+
}
|
|
345
|
+
if (ev.step === "cancelled" && view === "signin") return showList();
|
|
346
|
+
if (ev.step === "models" && view === "custom") {
|
|
347
|
+
form.found = ev.models;
|
|
348
|
+
sheet.querySelector(".add").dispatchEvent(new Event("input"));
|
|
349
|
+
if (!ev.models.length) sheet.querySelector(".found").innerHTML = `<span class="hint">The endpoint listed no models: type their names.</span>`;
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (ev.step === "changed" && view === "logout") return showLogout();
|
|
353
|
+
if (ev.step === "changed" && view === "model" && form?.said) { page.note(form.said); return close(); }
|
|
354
|
+
onSignIn(ev);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
fetch("/login/on").then((r) => { // CHALKY_LOGIN=off: the server has no /login, and the page no commands
|
|
358
|
+
if (!r.ok) return;
|
|
359
|
+
page.command("login", () => open(showList), "Connect a model: sign in with a subscription, or a custom endpoint");
|
|
360
|
+
page.command("logout", () => open(showLogout), "Remove a saved login");
|
|
361
|
+
page.command("model", () => open(() => showModels()), "Choose the model, for all agents or one");
|
|
362
|
+
});
|
|
363
|
+
}
|