wisp-agents 0.24.1 → 0.25.0
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/CHANGELOG.md +16 -1
- package/README.md +44 -4
- package/bin/wisp.js +55 -1
- package/package.json +2 -2
- package/server/adapters/codex.js +3 -0
- package/server/client.js +2 -2
- package/server/index.js +17 -2
- package/server/mcp-ask.js +44 -2
- package/server/openapi.js +38 -0
- package/server/protocol.js +32 -3
- package/server/render.js +16 -1
- package/server/routes/automations.js +8 -5
- package/server/routes/internal.js +61 -0
- package/server/routes/meta.js +5 -1
- package/server/routes/settings.js +17 -3
- package/server/routes/spells.js +147 -0
- package/server/runner.js +10 -3
- package/server/services/launch.js +7 -2
- package/server/services/spell-hooks.js +99 -0
- package/server/spell-guide.js +135 -0
- package/server/spells-data.js +60 -0
- package/server/spells.js +647 -0
- package/web/css/feed.css +1 -1
- package/web/css/spells.css +205 -0
- package/web/index.html +4 -0
- package/web/js/app.js +3 -0
- package/web/js/blocks.js +7 -1
- package/web/js/features/actions.js +3 -0
- package/web/js/features/commands.js +11 -0
- package/web/js/features/composer.js +12 -0
- package/web/js/features/feed.js +3 -0
- package/web/js/features/keys.js +15 -0
- package/web/js/features/launcher.js +56 -11
- package/web/js/features/live.js +9 -1
- package/web/js/features/settings.js +21 -0
- package/web/js/features/slash.js +2 -1
- package/web/js/features/spell-actions.js +64 -0
- package/web/js/features/spell-ui.js +398 -0
- package/web/js/features/spells.js +715 -0
- package/web/js/features/turns.js +2 -0
- package/web/js/spell-schema.js +246 -0
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
// Spells on the page: what every spell that's on adds to wisp, changes in it or hides, applied
|
|
2
|
+
// from S.config.spells (GET /config) each time the config loads. Spells name slots and parts from
|
|
3
|
+
// spell-schema.js; this file maps them onto wisp's markup, so a spell never touches the DOM.
|
|
4
|
+
//
|
|
5
|
+
// Spell code (a spell's `script`) runs only in a sandboxed iframe with no network, no DOM and no
|
|
6
|
+
// wisp API: it gets the data wisp fetched for it and returns values. Blocks it returns are checked
|
|
7
|
+
// and drawn by wisp's own renderer.
|
|
8
|
+
|
|
9
|
+
import { esc } from '../md.js';
|
|
10
|
+
import { api } from '../core/api.js';
|
|
11
|
+
import * as bus from '../core/bus.js';
|
|
12
|
+
import { $, $$, el, icon } from '../core/dom.js';
|
|
13
|
+
import { S } from '../core/state.js';
|
|
14
|
+
import { overlay } from '../ui/overlay.js';
|
|
15
|
+
import { toast, toastError, tryApi } from '../ui/toast.js';
|
|
16
|
+
import { renderPresentation, setBlockFallback } from '../blocks.js';
|
|
17
|
+
import { CARD_HEAD_ORDER, PARTS, fillTemplate, scriptBody, whenMatches } from '../spell-schema.js';
|
|
18
|
+
|
|
19
|
+
// --- which spells apply ----------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
let trying = null; // { spell, session }: a spell being tried in this window only (Try it)
|
|
22
|
+
|
|
23
|
+
export function activeSpells() {
|
|
24
|
+
const on = (S.config.spells || []).filter((s) => s.id !== trying?.spell.id);
|
|
25
|
+
return trying ? [...on, trying.spell] : on;
|
|
26
|
+
}
|
|
27
|
+
const spellById = (id) => activeSpells().find((s) => s.id === id) || null;
|
|
28
|
+
const summaryOf = (id) => (id ? S.sessions.get(id) || null : null);
|
|
29
|
+
// What {field} templates read: the task's summary, plus anything the caller adds ({args}).
|
|
30
|
+
const tplCtx = (session, extra = {}) => ({ ...(session || {}), ...extra });
|
|
31
|
+
|
|
32
|
+
// --- the sandbox -----------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
const SANDBOX_HTML = `<!doctype html><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'"><script>
|
|
35
|
+
let fns = null;
|
|
36
|
+
const reply = (m) => parent.postMessage(m, '*');
|
|
37
|
+
addEventListener('message', (e) => {
|
|
38
|
+
const m = e.data || {};
|
|
39
|
+
if (m.type === 'load') {
|
|
40
|
+
try { fns = new Function(m.body)() || {}; reply({ type: 'loaded', ok: true }); }
|
|
41
|
+
catch (x) { reply({ type: 'loaded', ok: false, error: String((x && x.message) || x) }); }
|
|
42
|
+
} else if (m.type === 'call') {
|
|
43
|
+
Promise.resolve()
|
|
44
|
+
.then(() => { const f = fns && fns[m.fn]; if (typeof f !== 'function') throw new Error('the script has no function ' + m.fn); return f(...m.args); })
|
|
45
|
+
.then((v) => reply({ type: 'result', id: m.id, value: v === undefined ? null : JSON.parse(JSON.stringify(v)) }), (x) => reply({ type: 'result', id: m.id, error: String((x && x.message) || x) }));
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
reply({ type: 'ready' });
|
|
49
|
+
<\/script>`;
|
|
50
|
+
|
|
51
|
+
const frames = new Map(); // spell id -> { frame, script, ready, pending, seq, fails }
|
|
52
|
+
const CALL_MS = 3000;
|
|
53
|
+
|
|
54
|
+
window.addEventListener('message', (e) => {
|
|
55
|
+
for (const f of frames.values()) {
|
|
56
|
+
if (e.source !== f.frame.contentWindow) continue;
|
|
57
|
+
const m = e.data || {};
|
|
58
|
+
if (m.type === 'ready') f.onReady?.();
|
|
59
|
+
else if (m.type === 'loaded') f.onLoaded?.(m);
|
|
60
|
+
else if (m.type === 'result') f.pending.get(m.id)?.(m);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
function sandbox(spell) {
|
|
66
|
+
const have = frames.get(spell.id);
|
|
67
|
+
if (have && have.script === spell.script) return have;
|
|
68
|
+
have?.frame.remove();
|
|
69
|
+
const frame = document.createElement('iframe');
|
|
70
|
+
frame.setAttribute('sandbox', 'allow-scripts');
|
|
71
|
+
frame.setAttribute('aria-hidden', 'true');
|
|
72
|
+
frame.tabIndex = -1;
|
|
73
|
+
frame.className = 'sp-sandbox';
|
|
74
|
+
const f = { frame, script: spell.script, pending: new Map(), seq: 0, fails: 0 };
|
|
75
|
+
f.ready = new Promise((resolve, reject) => {
|
|
76
|
+
f.onReady = () => frame.contentWindow.postMessage({ type: 'load', body: scriptBody(spell.script || '') }, '*');
|
|
77
|
+
f.onLoaded = (m) => (m.ok ? resolve() : reject(new Error(`${spell.name}: ${m.error}`)));
|
|
78
|
+
setTimeout(() => reject(new Error(`${spell.name}: its script didn't load`)), 5000);
|
|
79
|
+
});
|
|
80
|
+
f.ready.catch(() => {});
|
|
81
|
+
frame.srcdoc = SANDBOX_HTML;
|
|
82
|
+
document.body.append(frame);
|
|
83
|
+
frames.set(spell.id, f);
|
|
84
|
+
return f;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Call one of a spell's script functions with JSON arguments. Resolves with what it returned.
|
|
88
|
+
export async function callSpell(spell, fn, args = []) {
|
|
89
|
+
const f = sandbox(spell);
|
|
90
|
+
await f.ready;
|
|
91
|
+
const id = ++f.seq;
|
|
92
|
+
try {
|
|
93
|
+
const out = await new Promise((resolve, reject) => {
|
|
94
|
+
const t = setTimeout(() => (f.pending.delete(id), reject(new Error(`${spell.name}: ${fn}() took longer than ${CALL_MS / 1000}s`))), CALL_MS);
|
|
95
|
+
f.pending.set(id, (m) => (clearTimeout(t), f.pending.delete(id), m.error ? reject(new Error(`${spell.name}: ${fn}(): ${m.error}`)) : resolve(m.value)));
|
|
96
|
+
f.frame.contentWindow.postMessage({ type: 'call', id, fn, args: JSON.parse(JSON.stringify(args)) }, '*');
|
|
97
|
+
});
|
|
98
|
+
f.fails = 0;
|
|
99
|
+
return out;
|
|
100
|
+
} catch (err) {
|
|
101
|
+
// A spell that keeps failing goes off (or stops being tried) so it can't keep the page busy.
|
|
102
|
+
if (++f.fails >= 3) {
|
|
103
|
+
f.fails = 0;
|
|
104
|
+
if (trying?.spell.id === spell.id) undoTry();
|
|
105
|
+
else if ((S.config.spells || []).some((s) => s.id === spell.id)) {
|
|
106
|
+
api(`/spells/${spell.id}`, { method: 'PATCH', body: { enabled: false } }).catch(() => {});
|
|
107
|
+
toast(`Turned the spell <b>${esc(spell.name)}</b> off: it kept failing (${esc(err.message)})`, { tone: 'bad', timeout: 9000 });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// What a script may return as blocks: the built-in ones that only show things. Links in actions
|
|
115
|
+
// become markdown links; images, questions and follow-up buttons are dropped.
|
|
116
|
+
const SHOW_BLOCKS = new Set(['hero', 'markdown', 'steps', 'stats', 'files', 'tree', 'code', 'compare', 'flow', 'chart', 'checklist', 'callout', 'terminal', 'tests', 'commits']);
|
|
117
|
+
export function cleanBlocks(v) {
|
|
118
|
+
const list = Array.isArray(v) ? v : v && typeof v === 'object' ? [v] : [];
|
|
119
|
+
if (JSON.stringify(list).length > 200_000) return [{ type: 'callout', tone: 'warn', title: 'Too much to show', text: 'The spell returned more than 200 KB.' }];
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const b of list.slice(0, 60)) {
|
|
122
|
+
if (!b || typeof b !== 'object' || typeof b.type !== 'string') continue;
|
|
123
|
+
if (b.type === 'actions') {
|
|
124
|
+
const links = (b.items || []).filter((x) => x && /^https?:\/\//i.test(x.url || '')).map((x) => `- [${String(x.label || x.url).replace(/[[\]]/g, '')}](${String(x.url).replace(/[()\s]/g, encodeURIComponent)})`);
|
|
125
|
+
if (links.length) out.push({ type: 'markdown', text: `${b.title ? `**${b.title}**\n\n` : ''}${links.join('\n')}` });
|
|
126
|
+
} else if (b.type === 'markdown') out.push({ type: 'markdown', text: String(b.text || '').replace(/!\[[^\]]*\]\([^)]*\)/g, '') });
|
|
127
|
+
else if (SHOW_BLOCKS.has(b.type)) out.push({ ...b, type: b.type });
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The context blocks a spell returns render with: nothing to reply to or run.
|
|
133
|
+
const showCtx = { delay: 0, turn: null, session: null, isLatest: false, fileUrl: () => '', zoom: () => {}, reply: () => {}, draft: () => {} };
|
|
134
|
+
export const drawBlocks = (blocks) => renderPresentation({ blocks: cleanBlocks(blocks) }, showCtx);
|
|
135
|
+
|
|
136
|
+
// --- data ------------------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
const dataCache = new Map(); // `${spell}:${name}:${session}` -> { at, value, p }
|
|
139
|
+
|
|
140
|
+
async function dataOne(spell, name, sessionId) {
|
|
141
|
+
const src = spell.data?.[name];
|
|
142
|
+
if (!src) throw new Error(`${spell.name} has no data source ${name}`);
|
|
143
|
+
const perTask = sessionId && (src.cwd === 'task' || (src.gh || []).some((a) => a.includes('{')) || String(src.wisp || '').includes('{'));
|
|
144
|
+
const key = `${spell.id}:${name}:${perTask ? sessionId : ''}`;
|
|
145
|
+
const hit = dataCache.get(key);
|
|
146
|
+
if (hit && Date.now() - hit.at < (src.refresh || 60) * 1000) return hit.p;
|
|
147
|
+
// A spell being tried isn't installed yet: its sources go through try-data, which checks it first.
|
|
148
|
+
const installed = (S.config.spells || []).some((x) => x.id === spell.id && JSON.stringify(x.data) === JSON.stringify(spell.data));
|
|
149
|
+
const session = perTask ? sessionId : undefined;
|
|
150
|
+
const p = (installed ? api(`/spells/${spell.id}/data/${name}`, { method: 'POST', body: { session } }) : api('/spells/try-data', { method: 'POST', body: { spell, name, session } })).then((r) => r.value);
|
|
151
|
+
dataCache.set(key, { at: Date.now(), p });
|
|
152
|
+
p.catch(() => dataCache.delete(key));
|
|
153
|
+
return p;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function spellData(spell, names = [], sessionId = null) {
|
|
157
|
+
const out = {};
|
|
158
|
+
await Promise.all(names.map(async (n) => (out[n] = await dataOne(spell, n, sessionId))));
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
const countOf = (v) => (Array.isArray(v) ? v.length : Array.isArray(v?.items) ? v.items.length : typeof v?.total_count === 'number' ? v.total_count : v ? 1 : 0);
|
|
162
|
+
const allData = (spell) => Object.keys(spell.data || {});
|
|
163
|
+
|
|
164
|
+
// --- actions ---------------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
const httpOnly = (u) => (/^https?:\/\/[^\s"'<>]+$/i.test(u) ? u : '');
|
|
167
|
+
|
|
168
|
+
async function confirmExec(spell, command) {
|
|
169
|
+
const key = `wisp.spellExec.${spell.id}`;
|
|
170
|
+
const ok = JSON.parse(localStorage.getItem(key) || '[]');
|
|
171
|
+
if (ok.includes(command)) return true;
|
|
172
|
+
return new Promise((resolve) => {
|
|
173
|
+
const node = el(`
|
|
174
|
+
<form class="modal sp-confirm">
|
|
175
|
+
<header><h2>${icon('terminal')} Run this command?</h2><button type="button" class="icon-btn close">${icon('x')}</button></header>
|
|
176
|
+
<p>The spell <b>${esc(spell.name)}</b> wants to run this in the task's folder:</p>
|
|
177
|
+
<pre class="code"><code>$ ${esc(command)}</code></pre>
|
|
178
|
+
<footer><button type="button" class="ghost" data-a="no">Cancel</button><button class="primary" type="submit">Run it</button></footer>
|
|
179
|
+
</form>`);
|
|
180
|
+
let done = false;
|
|
181
|
+
const finish = (v) => {
|
|
182
|
+
if (done) return;
|
|
183
|
+
done = true;
|
|
184
|
+
close();
|
|
185
|
+
resolve(v);
|
|
186
|
+
};
|
|
187
|
+
const close = overlay(node, { onClose: () => finish(false) });
|
|
188
|
+
$('.close', node).onclick = () => {
|
|
189
|
+
finish(false);
|
|
190
|
+
};
|
|
191
|
+
$('[data-a="no"]', node).onclick = () => {
|
|
192
|
+
finish(false);
|
|
193
|
+
};
|
|
194
|
+
node.onsubmit = (e) => {
|
|
195
|
+
e.preventDefault();
|
|
196
|
+
localStorage.setItem(key, JSON.stringify([...ok, command]));
|
|
197
|
+
finish(true);
|
|
198
|
+
};
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Put text into a box at its caret (the prompt box when there's no box in reach).
|
|
203
|
+
function insertInto(ta, text) {
|
|
204
|
+
if (!ta) return;
|
|
205
|
+
const at = ta.selectionStart ?? ta.value.length;
|
|
206
|
+
ta.value = ta.value.slice(0, at) + text + ta.value.slice(ta.selectionEnd ?? at);
|
|
207
|
+
ta.dispatchEvent(new Event('input'));
|
|
208
|
+
ta.focus();
|
|
209
|
+
ta.setSelectionRange(at + text.length, at + text.length);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ctx: { session: id of the card's task, input: the box the click came from, args: what followed a /command }
|
|
213
|
+
export async function runAction(spell, action, ctx = {}) {
|
|
214
|
+
if (!action) return;
|
|
215
|
+
const [k, v] = Object.entries(action)[0];
|
|
216
|
+
const s = summaryOf(ctx.session);
|
|
217
|
+
const fill = (t) => fillTemplate(t, tplCtx(s, { args: ctx.args || '' }));
|
|
218
|
+
const need = () => {
|
|
219
|
+
if (!s) throw new Error('This works on a task: use it from a card, or focus one first.');
|
|
220
|
+
return s;
|
|
221
|
+
};
|
|
222
|
+
const m = await import('./spell-actions.js');
|
|
223
|
+
try {
|
|
224
|
+
switch (k) {
|
|
225
|
+
case 'open': {
|
|
226
|
+
const url = httpOnly(fill(v));
|
|
227
|
+
if (!url) throw new Error(`Not a link: ${fill(v) || '(empty)'}`);
|
|
228
|
+
window.open(url, '_blank', 'noopener');
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
case 'openAll': {
|
|
232
|
+
let urls;
|
|
233
|
+
if (v.data) {
|
|
234
|
+
const value = (await spellData(spell, [v.data], ctx.session))[v.data];
|
|
235
|
+
urls = (Array.isArray(value) ? value : value?.items || []).map((x) => (typeof x === 'string' ? x : x?.[v.field || 'url']));
|
|
236
|
+
} else {
|
|
237
|
+
// A function's links count only when they're in the spell's own data (so a script can't
|
|
238
|
+
// put what it read into a link of its own).
|
|
239
|
+
const data = await spellData(spell, v.data?.length ? v.data : allData(spell), ctx.session);
|
|
240
|
+
const seen = JSON.stringify(data);
|
|
241
|
+
urls = ((await callSpell(spell, v.fn, [data, { session: s }])) || []).filter((u) => typeof u === 'string' && seen.includes(JSON.stringify(u).slice(1, -1)));
|
|
242
|
+
}
|
|
243
|
+
urls = [...new Set(urls.map(String).filter(httpOnly))];
|
|
244
|
+
if (!urls.length) return toast(`${esc(spell.name)}: nothing to open`);
|
|
245
|
+
const { opened } = await api('/open', { method: 'POST', body: { urls } });
|
|
246
|
+
return toast(`Opened ${opened.length} link${opened.length === 1 ? '' : 's'}${urls.length > opened.length ? ` (the first ${opened.length} of ${urls.length})` : ''}`);
|
|
247
|
+
}
|
|
248
|
+
case 'panel':
|
|
249
|
+
return openPanel(spell, v, ctx);
|
|
250
|
+
case 'insert':
|
|
251
|
+
return insertInto(ctx.input || (s && $('.reply', S.nodes.get(s.id)?.card)) || $('#prompt'), fill(v));
|
|
252
|
+
case 'copy':
|
|
253
|
+
await navigator.clipboard.writeText(fill(v));
|
|
254
|
+
return toast('Copied');
|
|
255
|
+
case 'toast':
|
|
256
|
+
case 'notify':
|
|
257
|
+
return toast(esc(fill(v)));
|
|
258
|
+
case 'setLaunch':
|
|
259
|
+
return $('#launcher')?.spellSet?.(v);
|
|
260
|
+
case 'launchWith':
|
|
261
|
+
return $('#launcher')?.spellLaunch?.({ ...v, text: v.text ? fill(v.text) : '' });
|
|
262
|
+
case 'send':
|
|
263
|
+
case 'reply':
|
|
264
|
+
return m.sendTo(need().id, fill(v));
|
|
265
|
+
case 'launch':
|
|
266
|
+
return m.launchTask({ ...v, prompt: fill(v.prompt), cwd: v.cwd ? fill(v.cwd) : '' }, s);
|
|
267
|
+
case 'rerun':
|
|
268
|
+
return m.rerunOn(need(), { ...v, prompt: v.prompt ? fill(v.prompt) : '' });
|
|
269
|
+
case 'recipe':
|
|
270
|
+
return m.recipe(fill(v));
|
|
271
|
+
case 'ship':
|
|
272
|
+
return m.ship(need().id);
|
|
273
|
+
case 'done':
|
|
274
|
+
return m.done(need().id);
|
|
275
|
+
case 'archive':
|
|
276
|
+
return m.archive(need().id);
|
|
277
|
+
case 'exec': {
|
|
278
|
+
const command = fill(v);
|
|
279
|
+
if (!(await confirmExec(spell, command))) return;
|
|
280
|
+
return m.exec(need().id, command);
|
|
281
|
+
}
|
|
282
|
+
case 'webhook':
|
|
283
|
+
throw new Error('Webhooks run from hooks, not buttons.');
|
|
284
|
+
}
|
|
285
|
+
} catch (err) {
|
|
286
|
+
toastError(err);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// --- panels ----------------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
async function panelBlocks(spell, panel, sessionId) {
|
|
293
|
+
const data = await spellData(spell, panel.data || [], sessionId);
|
|
294
|
+
return cleanBlocks(await callSpell(spell, panel.render, [data, { session: summaryOf(sessionId) }]));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Fill `host` with the panel; unchanged blocks keep their nodes (no replayed animations).
|
|
298
|
+
async function paintPanel(host, spell, panel, sessionId) {
|
|
299
|
+
try {
|
|
300
|
+
const blocks = await panelBlocks(spell, panel, sessionId);
|
|
301
|
+
const sig = JSON.stringify(blocks);
|
|
302
|
+
if (host.dataset.sig === sig) return;
|
|
303
|
+
host.dataset.sig = sig;
|
|
304
|
+
host.replaceChildren(blocks.length ? drawBlocks(blocks) : el('<p class="sp-empty">Nothing to show.</p>'));
|
|
305
|
+
} catch (err) {
|
|
306
|
+
host.dataset.sig = '';
|
|
307
|
+
host.replaceChildren(el(`<div class="sp-error">${icon('warn')}<span>${esc(err.message)}</span></div>`));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function openPanel(spell, panelId, ctx = {}) {
|
|
312
|
+
const panel = (spell.panels || []).find((p) => p.id === panelId);
|
|
313
|
+
if (!panel) return toast(`${esc(spell.name)} has no panel ${esc(panelId)}`, { tone: 'bad' });
|
|
314
|
+
const node = el(`
|
|
315
|
+
<div class="modal sp-panel ${panel.as === 'drawer' ? 'sp-drawer' : ''}" data-spell="${esc(spell.id)}">
|
|
316
|
+
<header><h2>${panel.icon ? icon(panel.icon) : ''}${esc(panel.title || spell.name)}</h2><span class="sp-by" title="From the spell ${esc(spell.name)}">${icon('spark')}${esc(spell.name)}</span><button class="icon-btn sp-refresh" title="Refresh">${icon('replay')}</button><button class="icon-btn close">${icon('x')}</button></header>
|
|
317
|
+
<div class="sp-panel-body"><div class="it-loading"><span class="orb tiny"></span>Loading…</div></div>
|
|
318
|
+
</div>`);
|
|
319
|
+
const body = $('.sp-panel-body', node);
|
|
320
|
+
const close = overlay(node);
|
|
321
|
+
$('.close', node).onclick = close;
|
|
322
|
+
const paint = () => {
|
|
323
|
+
paintPanel(body, spell, panel, ctx.session);
|
|
324
|
+
};
|
|
325
|
+
$('.sp-refresh', node).onclick = () => {
|
|
326
|
+
for (const k of [...dataCache.keys()]) if (k.startsWith(`${spell.id}:`)) dataCache.delete(k);
|
|
327
|
+
paint();
|
|
328
|
+
};
|
|
329
|
+
paint();
|
|
330
|
+
if (panel.refresh?.every) {
|
|
331
|
+
const t = setInterval(() => (node.isConnected ? paint() : clearInterval(t)), panel.refresh.every * 1000);
|
|
332
|
+
}
|
|
333
|
+
for (const ev of panel.refresh?.on || []) bus.on(`spell:${ev}`, paint, { node });
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// --- slots: the HTML for each place, and one click handler for all of them ---------------------
|
|
337
|
+
|
|
338
|
+
const items = (slot, session) =>
|
|
339
|
+
activeSpells().flatMap((spell) =>
|
|
340
|
+
[...(spell.buttons || []).map((b, i) => ({ spell, b, kind: 'buttons', i })), ...(spell.chips || []).map((b, i) => ({ spell, b, kind: 'chips', i }))].filter(({ b }) => b.slot === slot && (!session || whenMatches(b.when, session)) && (session || !b.when)),
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
const badgeCache = new Map(); // `${spell}:${kind}:${i}:${session}` -> text, filled asynchronously
|
|
344
|
+
|
|
345
|
+
function badgeOf(spell, b, kind, i, session) {
|
|
346
|
+
const badge = b.badge;
|
|
347
|
+
if (!badge) return '';
|
|
348
|
+
if (badge.text) return fillTemplate(badge.text, tplCtx(session));
|
|
349
|
+
const key = `${spell.id}:${kind}:${i}:${session?.id || ''}`;
|
|
350
|
+
if (!badgeCache.has(key)) {
|
|
351
|
+
badgeCache.set(key, '');
|
|
352
|
+
const p = badge.count ? spellData(spell, [badge.count], session?.id).then((d) => String(countOf(d[badge.count]) || '')) : spellData(spell, badge.data?.length ? badge.data : allData(spell), session?.id).then((d) => callSpell(spell, badge.fn, [d, { session }])).then((v) => (v === null || v === undefined ? '' : String(v).slice(0, 12)));
|
|
353
|
+
p.then((text) => {
|
|
354
|
+
badgeCache.set(key, text);
|
|
355
|
+
for (const n of $$(`[data-badge="${CSS.escape(key)}"]`)) {
|
|
356
|
+
n.textContent = text;
|
|
357
|
+
n.hidden = !text;
|
|
358
|
+
}
|
|
359
|
+
}).catch(() => {});
|
|
360
|
+
}
|
|
361
|
+
return badgeCache.get(key);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const chipCache = new Map();
|
|
365
|
+
function chipText(spell, c, i, session) {
|
|
366
|
+
if (!c.fn) return fillTemplate(c.text, tplCtx(session));
|
|
367
|
+
const key = `${spell.id}:chip:${i}:${session?.id || ''}:${session?.updatedAt || ''}`;
|
|
368
|
+
if (!chipCache.has(key)) {
|
|
369
|
+
chipCache.set(key, chipCache.get(`${spell.id}:chip:${i}:${session?.id || ''}:last`) || '');
|
|
370
|
+
spellData(spell, c.data?.length ? c.data : allData(spell), session?.id)
|
|
371
|
+
.then((d) => callSpell(spell, c.fn, [d, { session }]))
|
|
372
|
+
.then((v) => {
|
|
373
|
+
const text = v === null || v === undefined ? '' : String(v).slice(0, 60);
|
|
374
|
+
chipCache.set(key, text);
|
|
375
|
+
chipCache.set(`${spell.id}:chip:${i}:${session?.id || ''}:last`, text);
|
|
376
|
+
for (const n of $$(`[data-chip="${CSS.escape(`${spell.id}:${i}:${session?.id || ''}`)}"]`)) {
|
|
377
|
+
$('.sp-t', n).textContent = text;
|
|
378
|
+
n.hidden = !text;
|
|
379
|
+
}
|
|
380
|
+
})
|
|
381
|
+
.catch(() => {});
|
|
382
|
+
}
|
|
383
|
+
return chipCache.get(key);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const attrs = (spell, kind, i, extra = '') => `data-sp="${esc(spell.id)}" data-spk="${kind}" data-spi="${i}" ${extra}`;
|
|
387
|
+
const tip = (spell, b) => esc(`${b.title || b.label || ''}${b.title || b.label ? ' · ' : ''}spell: ${spell.name}`);
|
|
388
|
+
|
|
389
|
+
// The markup of one slot. session: the card's task summary, for card slots.
|
|
390
|
+
export function slotHtml(slot, session = null) {
|
|
391
|
+
const list = items(slot, session);
|
|
392
|
+
if (!list.length) return '';
|
|
393
|
+
return list
|
|
394
|
+
.map(({ spell, b, kind, i }) => {
|
|
395
|
+
if (kind === 'chips') {
|
|
396
|
+
const text = chipText(spell, b, i, session);
|
|
397
|
+
const k = `${spell.id}:${i}:${session?.id || ''}`;
|
|
398
|
+
const tag = b.do ? 'button' : 'span';
|
|
399
|
+
return `<${tag} class="sp-chip tone-${b.tone}" ${attrs(spell, kind, i, `data-chip="${esc(k)}"`)} title="${tip(spell, b)}" ${text ? '' : 'hidden'}>${b.icon ? icon(b.icon) : ''}<span class="sp-t">${esc(text)}</span></${tag}>`;
|
|
400
|
+
}
|
|
401
|
+
const badge = badgeOf(spell, b, kind, i, session);
|
|
402
|
+
const bkey = `${spell.id}:${kind}:${i}:${session?.id || ''}`;
|
|
403
|
+
const n = b.badge ? `<b class="sp-n" data-badge="${esc(bkey)}" ${badge ? '' : 'hidden'}>${esc(badge)}</b>` : '';
|
|
404
|
+
const label = b.label ? `<span>${esc(b.label)}</span>` : '';
|
|
405
|
+
const ic = b.icon ? icon(b.icon) : '';
|
|
406
|
+
switch (slot) {
|
|
407
|
+
case 'topbar':
|
|
408
|
+
return `<button class="auto-btn sp-btn" ${attrs(spell, kind, i)} title="${tip(spell, b)}">${ic || icon('spark')}${label}${n}</button>`;
|
|
409
|
+
case 'float':
|
|
410
|
+
return `<button class="sp-fab${b.primary ? ' primary' : ''}" ${attrs(spell, kind, i)} title="${tip(spell, b)}" aria-label="${esc(b.label || b.icon)}">${ic || icon('spark')}${b.label ? `<span class="sp-fab-l">${esc(b.label)}</span>` : ''}${n}</button>`;
|
|
411
|
+
case 'card.hover':
|
|
412
|
+
return `<button class="it-quick sp-quick" ${attrs(spell, kind, i)} title="${tip(spell, b)}" aria-label="${esc(b.label || b.icon)}">${ic || icon('spark')}</button>`;
|
|
413
|
+
case 'launcher.chips':
|
|
414
|
+
case 'composer.chips':
|
|
415
|
+
return `<button type="button" class="sp-qr" ${attrs(spell, kind, i)} title="${tip(spell, b)}">${ic}${label || esc(b.icon)}${n}</button>`;
|
|
416
|
+
default:
|
|
417
|
+
return `<button type="button" class="ghost sm sp-btn" ${attrs(spell, kind, i)} title="${tip(spell, b)}">${ic}${label}${n}</button>`;
|
|
418
|
+
}
|
|
419
|
+
})
|
|
420
|
+
.join('');
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Every spell element on the page answers here: the card it's in is the task it works on.
|
|
424
|
+
document.addEventListener(
|
|
425
|
+
'click',
|
|
426
|
+
(e) => {
|
|
427
|
+
const n = e.target.closest?.('[data-sp]');
|
|
428
|
+
if (!n || n.closest('.sp-preview')) return;
|
|
429
|
+
const spell = spellById(n.dataset.sp);
|
|
430
|
+
const item = spell?.[n.dataset.spk]?.[Number(n.dataset.spi)];
|
|
431
|
+
if (!item?.do) return;
|
|
432
|
+
e.preventDefault();
|
|
433
|
+
e.stopPropagation();
|
|
434
|
+
const card = n.closest('.item[data-id]');
|
|
435
|
+
const input = n.closest('.composer')?.querySelector('.reply') || n.closest('#launcher')?.querySelector('#prompt') || null;
|
|
436
|
+
n.closest('.menu')?.close?.();
|
|
437
|
+
runAction(spell, item.do, { session: card?.dataset.id || n.dataset.task || (n.closest('.sp-float, #topbar') ? null : S.focus), input });
|
|
438
|
+
},
|
|
439
|
+
true,
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
// A floating group opens and closes on its own button.
|
|
443
|
+
document.addEventListener('click', (e) => {
|
|
444
|
+
const fan = e.target.closest?.('.sp-fan-btn');
|
|
445
|
+
for (const f of $$('.sp-fan.open')) if (!fan || f !== fan.parentElement) f.classList.remove('open');
|
|
446
|
+
if (!fan) return;
|
|
447
|
+
const f = fan.parentElement;
|
|
448
|
+
f.classList.toggle('open');
|
|
449
|
+
fan.setAttribute('aria-expanded', f.classList.contains('open'));
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// The floating stack: one button per spell button, folded into a fan-out per `group`.
|
|
453
|
+
function floatHtml() {
|
|
454
|
+
const list = items('float');
|
|
455
|
+
if (!list.length) return '';
|
|
456
|
+
const groups = new Map();
|
|
457
|
+
const loose = [];
|
|
458
|
+
for (const x of list) (x.b.group ? groups.get(x.b.group)?.push(x) || groups.set(x.b.group, [x]) : loose.push(x));
|
|
459
|
+
return [
|
|
460
|
+
...[...groups].map(
|
|
461
|
+
([name, xs]) => `<div class="sp-fan" data-fan="${esc(name)}"><div class="sp-fan-items">${xs.map(renderFab).join('')}</div><button class="sp-fab sp-fan-btn" aria-expanded="false" title="${esc(name)}">${icon('spark')}<span class="sp-fab-l">${esc(name)}</span></button></div>`,
|
|
462
|
+
),
|
|
463
|
+
...loose.map(renderFab),
|
|
464
|
+
].join('');
|
|
465
|
+
}
|
|
466
|
+
function renderFab({ spell, b, kind, i }) {
|
|
467
|
+
const badge = badgeOf(spell, b, kind, i, null);
|
|
468
|
+
const bkey = `${spell.id}:${kind}:${i}:`;
|
|
469
|
+
return `<button class="sp-fab${b.primary ? ' primary' : ''}" ${attrs(spell, kind, i)} title="${tip(spell, b)}" aria-label="${esc(b.label || b.icon)}">${b.icon ? icon(b.icon) : icon('spark')}${b.label ? `<span class="sp-fab-l">${esc(b.label)}</span>` : ''}${b.badge ? `<b class="sp-n" data-badge="${esc(bkey)}" ${badge ? '' : 'hidden'}>${esc(badge)}</b>` : ''}</button>`;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// --- parts: style, props, order, hide, tokens ------------------------------------------------
|
|
473
|
+
|
|
474
|
+
// A card head's pieces in the order a spell asks for (unlisted ones after, the chevron last).
|
|
475
|
+
function orderCss(order) {
|
|
476
|
+
return ['#feed .it-side > * { order: 500; }', ...order.map((k, i) => `#feed .it-side > ${CARD_HEAD_ORDER[k]} { order: ${i + 1}; }`), '#feed .it-side > .chev { order: 999; }'].join('\n');
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function styleSheet(spells) {
|
|
480
|
+
const css = [];
|
|
481
|
+
const root = [];
|
|
482
|
+
const dark = [];
|
|
483
|
+
for (const s of spells) {
|
|
484
|
+
for (const [k, v] of Object.entries(s.ui?.tokens || {})) root.push(`${k}: ${v};`);
|
|
485
|
+
for (const [k, v] of Object.entries(s.ui?.dark || {})) dark.push(`${k}: ${v};`);
|
|
486
|
+
for (const h of s.ui?.hide || []) if (PARTS[h]) css.push(`${PARTS[h]} { display: none !important; }`);
|
|
487
|
+
for (const [name, p] of Object.entries(s.parts || {})) {
|
|
488
|
+
const sel = PARTS[name];
|
|
489
|
+
if (!sel) continue;
|
|
490
|
+
if (p.style && Object.keys(p.style).length) css.push(`${sel} { ${Object.entries(p.style).map(([k, v]) => `${k}: ${v} !important;`).join(' ')} }`);
|
|
491
|
+
if (p.props?.monospace) css.push(`${sel} { font-family: var(--mono) !important; }`);
|
|
492
|
+
if (p.props?.fontSize) css.push(`${sel} { font-size: ${p.props.fontSize}px !important; }`);
|
|
493
|
+
if (p.props?.rows) css.push(`${sel} { min-height: calc(${p.props.rows} * 1.5em + 12px) !important; }`);
|
|
494
|
+
if (p.props?.lines) css.push(`${sel} { white-space: normal !important; display: -webkit-box !important; -webkit-line-clamp: ${p.props.lines}; -webkit-box-orient: vertical; overflow: hidden; }`);
|
|
495
|
+
if (p.order?.length) css.push(orderCss(p.order));
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (root.length) css.unshift(`:root, :root[data-theme] { ${root.join(' ')} }`);
|
|
499
|
+
if (dark.length) css.push(`@media (prefers-color-scheme: dark) { :root:not([data-theme='light']) { ${dark.join(' ')} } }`, `:root[data-theme='dark'] { ${dark.join(' ')} }`);
|
|
500
|
+
return css.join('\n');
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// A prop a part has from the spells that are on (the last spell wins).
|
|
504
|
+
export function spellProp(part, key) {
|
|
505
|
+
let v;
|
|
506
|
+
for (const s of activeSpells()) if (s.parts?.[part]?.props?.[key] !== undefined) v = s.parts[part].props[key];
|
|
507
|
+
return v;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// --- applying it all -------------------------------------------------------------------------
|
|
511
|
+
|
|
512
|
+
let lastSig = '';
|
|
513
|
+
let launcherSig = '';
|
|
514
|
+
|
|
515
|
+
// Called after every config load and when trying a spell. Repaints only what spells touch.
|
|
516
|
+
export function applySpells({ force = false } = {}) {
|
|
517
|
+
const spells = S.config.spellsSafe ? [] : activeSpells();
|
|
518
|
+
const sig = JSON.stringify(spells);
|
|
519
|
+
if (!force && sig === lastSig) return;
|
|
520
|
+
lastSig = sig;
|
|
521
|
+
badgeCache.clear();
|
|
522
|
+
let style = $('#spell-style');
|
|
523
|
+
if (!style) (style = document.createElement('style')), (style.id = 'spell-style'), document.head.append(style);
|
|
524
|
+
style.textContent = styleSheet(spells);
|
|
525
|
+
document.body.classList.toggle('sp-compact', spellProp('card', 'density') === 'compact');
|
|
526
|
+
document.body.classList.toggle('sp-comfy', spellProp('card', 'density') === 'comfortable');
|
|
527
|
+
|
|
528
|
+
// App slots.
|
|
529
|
+
const top = $('#spTop');
|
|
530
|
+
if (top) top.innerHTML = slotHtml('topbar');
|
|
531
|
+
const fl = $('#spFloat');
|
|
532
|
+
if (fl) {
|
|
533
|
+
fl.innerHTML = floatHtml();
|
|
534
|
+
fl.hidden = !fl.innerHTML;
|
|
535
|
+
fl.classList.toggle('left', items('float').some(({ b }) => b.corner === 'bottom-left'));
|
|
536
|
+
}
|
|
537
|
+
paintFeedTop(spells);
|
|
538
|
+
// The launcher and the open cards' boxes and heads.
|
|
539
|
+
const lsig = JSON.stringify(spells.map((s) => [s.buttons?.filter((b) => b.slot.startsWith('launcher')), s.chips?.filter((c) => c.slot.startsWith('launcher')), s.parts?.['launcher.input']]));
|
|
540
|
+
if (lsig !== launcherSig) {
|
|
541
|
+
launcherSig = lsig;
|
|
542
|
+
bus.emit('spells:launcher');
|
|
543
|
+
}
|
|
544
|
+
for (const id of S.open) paintCard(id);
|
|
545
|
+
bus.emit('spells');
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function paintFeedTop(spells) {
|
|
549
|
+
const host = $('#spFeedTop');
|
|
550
|
+
if (!host) return;
|
|
551
|
+
const panels = spells.flatMap((spell) => (spell.panels || []).filter((p) => p.as === 'feed').map((p) => ({ spell, p })));
|
|
552
|
+
const keep = new Set(panels.map(({ spell, p }) => `${spell.id}:${p.id}`));
|
|
553
|
+
for (const n of $$('.sp-feed', host)) if (!keep.has(n.dataset.k)) n.remove();
|
|
554
|
+
for (const { spell, p } of panels) {
|
|
555
|
+
const k = `${spell.id}:${p.id}`;
|
|
556
|
+
let n = $(`.sp-feed[data-k="${CSS.escape(k)}"]`, host);
|
|
557
|
+
if (!n) {
|
|
558
|
+
n = el(`<section class="sp-feed card" data-k="${esc(k)}"><header>${p.icon ? icon(p.icon) : icon('spark')}<b>${esc(p.title || spell.name)}</b><span class="sp-by">${esc(spell.name)}</span></header><div class="sp-feed-body"></div></section>`);
|
|
559
|
+
host.append(n);
|
|
560
|
+
if (p.refresh?.every) {
|
|
561
|
+
const t = setInterval(() => (n.isConnected ? paintPanel($('.sp-feed-body', n), spell, p, null) : clearInterval(t)), p.refresh.every * 1000);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
paintPanel($('.sp-feed-body', n), spell, p, null);
|
|
565
|
+
}
|
|
566
|
+
host.hidden = !panels.length;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// A card's spell parts: its box's chips and bar buttons, and panels at its top and bottom.
|
|
570
|
+
export function paintCard(id) {
|
|
571
|
+
const n = S.nodes.get(id);
|
|
572
|
+
const s = S.sessions.get(id);
|
|
573
|
+
if (!n?.body || !s) return;
|
|
574
|
+
const comp = $('.composer', n.body);
|
|
575
|
+
if (comp) {
|
|
576
|
+
let chips = $('.sp-cchips', comp);
|
|
577
|
+
if (!chips) (chips = el('<div class="sp-cchips"></div>')), $('.reply', comp)?.closest('.mention-wrap, .composer > *')?.before(chips) || comp.prepend(chips);
|
|
578
|
+
const ch = slotHtml('composer.chips', s);
|
|
579
|
+
if (chips.dataset.h !== ch) (chips.innerHTML = ch), (chips.dataset.h = ch);
|
|
580
|
+
chips.hidden = !ch;
|
|
581
|
+
let bar = $('.sp-cbar', comp);
|
|
582
|
+
if (!bar) (bar = el('<span class="sp-cbar"></span>')), $('.send', comp)?.before(bar);
|
|
583
|
+
const bh = slotHtml('composer.bar', s);
|
|
584
|
+
if (bar.dataset.h !== bh) (bar.innerHTML = bh), (bar.dataset.h = bh);
|
|
585
|
+
const ph = spellProp('composer.input', 'placeholder');
|
|
586
|
+
const ta = $('.reply', comp);
|
|
587
|
+
if (ta && ph && !ta.disabled && !s.ask) ta.placeholder = ph;
|
|
588
|
+
}
|
|
589
|
+
for (const where of ['top', 'bottom']) {
|
|
590
|
+
let host = $(`.sp-card-${where}`, n.body);
|
|
591
|
+
const panels = activeSpells().flatMap((spell) => (spell.panels || []).filter((p) => p.as === 'card' && p.slot === `card.${where}` && whenMatches(p.when, s)).map((p) => ({ spell, p })));
|
|
592
|
+
if (!host) {
|
|
593
|
+
if (!panels.length) continue;
|
|
594
|
+
host = el(`<div class="sp-card sp-card-${where}"></div>`);
|
|
595
|
+
if (where === 'top') $('.it-bar', n.body)?.after(host);
|
|
596
|
+
else $('.it-dock', n.body)?.before(host);
|
|
597
|
+
}
|
|
598
|
+
const keep = new Set(panels.map(({ spell, p }) => `${spell.id}:${p.id}`));
|
|
599
|
+
for (const x of $$('.sp-cpanel', host)) if (!keep.has(x.dataset.k)) x.remove();
|
|
600
|
+
for (const { spell, p } of panels) {
|
|
601
|
+
const k = `${spell.id}:${p.id}`;
|
|
602
|
+
let x = $(`.sp-cpanel[data-k="${CSS.escape(k)}"]`, host);
|
|
603
|
+
if (!x) {
|
|
604
|
+
x = el(`<section class="sp-cpanel" data-k="${esc(k)}"><header>${p.icon ? icon(p.icon) : icon('spark')}<b>${esc(p.title || spell.name)}</b><span class="sp-by">${esc(spell.name)}</span></header><div class="sp-cpanel-body"></div></section>`);
|
|
605
|
+
host.append(x);
|
|
606
|
+
}
|
|
607
|
+
paintPanel($('.sp-cpanel-body', x), spell, p, id);
|
|
608
|
+
}
|
|
609
|
+
host.hidden = !panels.length;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// The launcher asks for its spell parts when it renders (features/launcher.js).
|
|
614
|
+
export function launcherParts(box) {
|
|
615
|
+
const bar = $('.l-bar', box);
|
|
616
|
+
const send = $('#launch', box);
|
|
617
|
+
if (bar && send) send.insertAdjacentHTML('beforebegin', `<span class="sp-lbar">${slotHtml('launcher.bar')}</span>`);
|
|
618
|
+
const chips = slotHtml('launcher.chips');
|
|
619
|
+
if (chips) bar?.insertAdjacentHTML('afterend', `<div class="sp-lchips">${chips}</div>`);
|
|
620
|
+
const ta = $('#prompt', box);
|
|
621
|
+
const rows = spellProp('launcher.input', 'rows');
|
|
622
|
+
if (ta && rows) ta.rows = rows;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// --- agent blocks: types spells add, drawn through their render function ---------------------
|
|
626
|
+
|
|
627
|
+
const blockCache = new Map(); // JSON of the block -> rendered blocks
|
|
628
|
+
|
|
629
|
+
setBlockFallback((b) => {
|
|
630
|
+
const spell = activeSpells().find((s) => (s.blocks || []).some((x) => x.type === b.type));
|
|
631
|
+
if (!spell) {
|
|
632
|
+
const node = el(`<div class="sp-block sp-off card"><div class="sp-off-head">${icon('spark')}<span>A <b>${esc(b.type)}</b> block from a spell that isn't on</span><button class="link xs">Show data</button></div><pre class="code" hidden><code>${esc(JSON.stringify(b, null, 2))}</code></pre></div>`);
|
|
633
|
+
$('button', node).onclick = () => {
|
|
634
|
+
$('pre', node).hidden = !$('pre', node).hidden;
|
|
635
|
+
};
|
|
636
|
+
return node;
|
|
637
|
+
}
|
|
638
|
+
const def = spell.blocks.find((x) => x.type === b.type);
|
|
639
|
+
const node = el(`<div class="sp-block" data-spell="${esc(spell.id)}" title="Drawn by the spell ${esc(spell.name)}"></div>`);
|
|
640
|
+
const key = `${spell.id}:${JSON.stringify(b)}`;
|
|
641
|
+
const draw = (blocks) => node.replaceChildren(drawBlocks(blocks));
|
|
642
|
+
if (blockCache.has(key)) draw(blockCache.get(key));
|
|
643
|
+
else {
|
|
644
|
+
node.append(el(`<div class="it-loading"><span class="orb tiny"></span>${esc(spell.name)}…</div>`));
|
|
645
|
+
callSpell(spell, def.render, [b, { block: b }])
|
|
646
|
+
.then((v) => {
|
|
647
|
+
const blocks = cleanBlocks(v);
|
|
648
|
+
blockCache.set(key, blocks);
|
|
649
|
+
if (blockCache.size > 300) blockCache.delete(blockCache.keys().next().value);
|
|
650
|
+
draw(blocks);
|
|
651
|
+
})
|
|
652
|
+
.catch((err) => node.replaceChildren(el(`<div class="sp-error">${icon('warn')}<span>${esc(err.message)}</span></div>`)));
|
|
653
|
+
}
|
|
654
|
+
return node;
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
// --- trying a spell --------------------------------------------------------------------------
|
|
658
|
+
|
|
659
|
+
export function tryOn(spell, session = null) {
|
|
660
|
+
trying = { spell, session };
|
|
661
|
+
for (const k of [...dataCache.keys()]) if (k.startsWith(`${spell.id}:`)) dataCache.delete(k);
|
|
662
|
+
applySpells({ force: true });
|
|
663
|
+
let bar = $('#spTry');
|
|
664
|
+
if (!bar) (bar = el('<div class="sp-try" id="spTry" role="status"></div>')), document.body.append(bar);
|
|
665
|
+
bar.innerHTML = `${icon('spark')}<span>Trying <b>${esc(spell.name)}</b> in this window</span><button class="primary sm" data-a="keep">Keep</button><button class="ghost sm" data-a="undo">Undo</button>`;
|
|
666
|
+
bar.onclick = (e) => {
|
|
667
|
+
const a = e.target.closest('[data-a]')?.dataset.a;
|
|
668
|
+
if (a === 'keep') keep(spell, session);
|
|
669
|
+
if (a === 'undo') undoTry();
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
export function undoTry() {
|
|
674
|
+
trying = null;
|
|
675
|
+
$('#spTry')?.remove();
|
|
676
|
+
applySpells({ force: true });
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
export const tryingId = () => trying?.spell.id || null;
|
|
680
|
+
|
|
681
|
+
// Install and turn on (only ever from a click). Optimistic: the spell applies at once.
|
|
682
|
+
export async function keep(spell, session = null) {
|
|
683
|
+
const was = S.config.spells || [];
|
|
684
|
+
S.config.spells = [...was.filter((s) => s.id !== spell.id), spell];
|
|
685
|
+
trying = null;
|
|
686
|
+
$('#spTry')?.remove();
|
|
687
|
+
applySpells({ force: true });
|
|
688
|
+
try {
|
|
689
|
+
await api('/spells', { method: 'POST', body: { spell, enable: true, source: { kind: session ? 'cast' : 'local', ...(session && { session }) } } });
|
|
690
|
+
toast(`${icon('spark')} <b>${esc(spell.name)}</b> is on`, { action: { label: 'Spells', run: () => import('./settings.js').then((m) => m.openSettings({ pane: 'spells' })) } });
|
|
691
|
+
bus.emit('spells:changed');
|
|
692
|
+
return true;
|
|
693
|
+
} catch (err) {
|
|
694
|
+
S.config.spells = was;
|
|
695
|
+
applySpells({ force: true });
|
|
696
|
+
toastError(err);
|
|
697
|
+
return false;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
export async function setOn(id, on) {
|
|
702
|
+
const was = S.config.spells || [];
|
|
703
|
+
if (!on) {
|
|
704
|
+
S.config.spells = was.filter((s) => s.id !== id);
|
|
705
|
+
applySpells({ force: true });
|
|
706
|
+
}
|
|
707
|
+
const r = await tryApi(() => api(`/spells/${id}`, { method: 'PATCH', body: { enabled: on } }));
|
|
708
|
+
if (!r && !on) (S.config.spells = was), applySpells({ force: true });
|
|
709
|
+
bus.emit('spells:changed');
|
|
710
|
+
return r;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Live events the spells' panels refresh on.
|
|
714
|
+
bus.on('config', () => bus.emit('spell:config'));
|
|
715
|
+
export const spellEvent = (name) => bus.emit(`spell:${name}`);
|