@floless/app 0.5.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/bin/floless.mjs +13 -0
- package/dist/floless-server.cjs +56801 -0
- package/dist/skills/floless-app-bridge/SKILL.md +80 -0
- package/dist/skills/floless-app-routines/SKILL.md +168 -0
- package/dist/skills/floless-app-routines/references/routines-api.md +130 -0
- package/dist/skills/floless-app-workflows/SKILL.md +352 -0
- package/dist/skills/floless-app-workflows/references/dev-server-and-run-trace.md +119 -0
- package/dist/skills/floless-app-workflows/references/exec-contract.md +104 -0
- package/dist/web/app.css +2129 -0
- package/dist/web/app.js +1334 -0
- package/dist/web/apple-touch-icon.png +0 -0
- package/dist/web/aware.js +3274 -0
- package/dist/web/favicon.ico +0 -0
- package/dist/web/favicon.svg +98 -0
- package/dist/web/index.html +484 -0
- package/launch.mjs +543 -0
- package/package.json +43 -0
- package/teardown.mjs +128 -0
|
@@ -0,0 +1,3274 @@
|
|
|
1
|
+
/* ============================================================================
|
|
2
|
+
* aware.js — wires the demo skin to live AWARE data via the floless.app server.
|
|
3
|
+
*
|
|
4
|
+
* Loaded AFTER app.js, so it shares its globals (PROMPTS, AGENTS, state,
|
|
5
|
+
* renderTopology, renderInspect, selectAgent, renderChat, $promptSel, $runBtn,
|
|
6
|
+
* escapeHtml, showToast, appendNarration). It mutates PROMPTS/AGENTS from real
|
|
7
|
+
* /api data and reuses the existing renderers — the canvas stays the demo's.
|
|
8
|
+
*
|
|
9
|
+
* The UI never becomes the brain: it reads AWARE state, triggers `aware` verbs
|
|
10
|
+
* (compile/run) through /api, and enforces the build-once/run-forever gate.
|
|
11
|
+
* ========================================================================== */
|
|
12
|
+
(() => {
|
|
13
|
+
const $compileBtn = document.getElementById('compile-btn');
|
|
14
|
+
const $menuBakeItem = document.getElementById('menu-bake-item');
|
|
15
|
+
const $menuBakeLabel = document.getElementById('menu-bake-label');
|
|
16
|
+
const $simBtn = document.getElementById('sim-btn');
|
|
17
|
+
const $runState = document.getElementById('run-state');
|
|
18
|
+
const $notesStrip = document.getElementById('notes-strip');
|
|
19
|
+
const $reportModal = document.getElementById('report-modal');
|
|
20
|
+
const $reportFrame = document.getElementById('report-frame');
|
|
21
|
+
const $reportOverlay = document.getElementById('report-overlay');
|
|
22
|
+
const $reportTitle = document.getElementById('report-title');
|
|
23
|
+
const $reportSub = document.getElementById('report-sub');
|
|
24
|
+
const $reportOpen = document.getElementById('report-open');
|
|
25
|
+
const $reportClose = document.getElementById('report-close');
|
|
26
|
+
|
|
27
|
+
// One persistent array the inspect Execution tab reads; we mutate in place so
|
|
28
|
+
// every AGENTS[node].execution reference stays valid across re-renders.
|
|
29
|
+
const liveTrace = [];
|
|
30
|
+
// Latest normalized app payload, keyed by id, so handlers can read mode/gate.
|
|
31
|
+
const apps = new Map();
|
|
32
|
+
let currentId = null;
|
|
33
|
+
|
|
34
|
+
// Local UI-state persistence (per origin; never touches the .flo or lock).
|
|
35
|
+
// The last-opened app is restored on refresh; per-app input values are saved
|
|
36
|
+
// explicitly via "Save inputs" (Ctrl+S) and rehydrated when the app opens.
|
|
37
|
+
const LS_LAST_APP = 'floless:lastApp';
|
|
38
|
+
const lsInputsKey = (id) => 'floless:inputs:' + id;
|
|
39
|
+
function loadSavedInputs(id) {
|
|
40
|
+
try {
|
|
41
|
+
const v = JSON.parse(localStorage.getItem(lsInputsKey(id)) || 'null');
|
|
42
|
+
return v && typeof v === 'object' ? v : null;
|
|
43
|
+
} catch { return null; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Notes-strip collapse memory, keyed by app + a signature of the note set, so a
|
|
47
|
+
// collapsed strip STAYS collapsed across sessions — but a recompile that changes
|
|
48
|
+
// the notes (a new issue) invalidates the signature and re-expands, so the user
|
|
49
|
+
// never misses a genuinely new note.
|
|
50
|
+
const lsNotesKey = (id) => 'floless:notes:' + id;
|
|
51
|
+
function notesSig(notes) {
|
|
52
|
+
const s = notes.map((nt) => (nt.nodeId || '') + '|' + (nt.kind || '') + '|' + nt.text).join('\n');
|
|
53
|
+
let h = 5381;
|
|
54
|
+
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
|
|
55
|
+
return notes.length + ':' + (h >>> 0).toString(36);
|
|
56
|
+
}
|
|
57
|
+
// Tri-state: true/false = the user's remembered choice for THIS note set;
|
|
58
|
+
// undefined = no choice yet (caller defaults by severity).
|
|
59
|
+
function notesRememberedCollapse(id, sig) {
|
|
60
|
+
try {
|
|
61
|
+
const v = JSON.parse(localStorage.getItem(lsNotesKey(id)) || 'null');
|
|
62
|
+
if (v && v.sig === sig) return !!v.collapsed;
|
|
63
|
+
} catch { /* ignore */ }
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
function saveNotesCollapsed(id, sig, collapsed) {
|
|
67
|
+
try { localStorage.setItem(lsNotesKey(id), JSON.stringify({ sig, collapsed })); } catch { /* ignore */ }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Re-phrase a recognized AWARE info-note into plain language for AEC users.
|
|
71
|
+
// Severity always comes from note.kind, never this text — so if the wording
|
|
72
|
+
// ever changes and the pattern misses, we just fall back to the raw note.
|
|
73
|
+
function humanizeNote(text) {
|
|
74
|
+
const m = /command\s+(\S+)\s+(?:not found|is mode-overridable);\s*using author-declared mode:\s*(read|write)/i.exec(text);
|
|
75
|
+
if (m) {
|
|
76
|
+
const cmd = m[1];
|
|
77
|
+
return m[2].toLowerCase() === 'read'
|
|
78
|
+
? `Runs custom read-only code (${cmd}) using the read-only mode set on this node — no model or file changes.`
|
|
79
|
+
: `Runs custom code (${cmd}) in the write mode set on this node.`;
|
|
80
|
+
}
|
|
81
|
+
return text;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const AGENT_ICONS = {
|
|
85
|
+
tekla: '⊞', file: '🗀', excel: '◵', revit: '◰', slack: '✦',
|
|
86
|
+
'trimble-connect': '☁', 'microsoft-365': '✉', email: '✉', http: '⇄',
|
|
87
|
+
};
|
|
88
|
+
const iconFor = (agent) => AGENT_ICONS[agent] || '◈';
|
|
89
|
+
|
|
90
|
+
// The demo's renderChat staggers narration via setTimeout; its bootstrap call
|
|
91
|
+
// (fired before this script runs) would otherwise drip stale demo lines into
|
|
92
|
+
// our chat. Replace it with a synchronous version, and clear any stragglers
|
|
93
|
+
// already scheduled by the original before we took over.
|
|
94
|
+
renderChat = function renderChatSync() {
|
|
95
|
+
const p = PROMPTS[state.promptKey];
|
|
96
|
+
$messages.innerHTML = '';
|
|
97
|
+
if (!p) return;
|
|
98
|
+
$messages.appendChild(msgEl('user', '$ you', p.userText));
|
|
99
|
+
(p.narration || []).forEach((line) => $messages.appendChild(msgEl('aware', 'claude', mdInline(line))));
|
|
100
|
+
$messages.scrollTop = $messages.scrollHeight;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
async function api(path, opts) {
|
|
104
|
+
const res = await fetch(path, {
|
|
105
|
+
headers: { 'content-type': 'application/json' },
|
|
106
|
+
...opts,
|
|
107
|
+
});
|
|
108
|
+
const body = await res.json().catch(() => ({ ok: false, error: `HTTP ${res.status}` }));
|
|
109
|
+
if (!res.ok || body.ok === false) throw new Error(body.error || `HTTP ${res.status}`);
|
|
110
|
+
return body;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── transform: normalized app -> the renderer's PROMPTS/AGENTS shapes ──────
|
|
114
|
+
|
|
115
|
+
function nodeYaml(n) {
|
|
116
|
+
const lines = [`id: ${n.id}`, `kind: ${n.kind}`];
|
|
117
|
+
if (n.agent) lines.push(`agent: ${n.agent}`);
|
|
118
|
+
if (n.command) lines.push(`command: ${n.command}`);
|
|
119
|
+
lines.push(`mode: ${n.mode}`);
|
|
120
|
+
const inputs = n.inputs && Object.keys(n.inputs).length ? n.inputs : n.config;
|
|
121
|
+
if (inputs && Object.keys(inputs).length) {
|
|
122
|
+
lines.push('inputs:');
|
|
123
|
+
for (const [k, v] of Object.entries(inputs)) lines.push(` ${k}: ${JSON.stringify(v)}`);
|
|
124
|
+
}
|
|
125
|
+
if (n.notes && n.notes.length) {
|
|
126
|
+
lines.push('notes:');
|
|
127
|
+
for (const note of n.notes) lines.push(` - [${note.kind}] ${note.text}`);
|
|
128
|
+
}
|
|
129
|
+
return lines.join('\n');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function kvTable(obj) {
|
|
133
|
+
const entries = Object.entries(obj || {});
|
|
134
|
+
if (!entries.length) return '<p class="empty-state">none</p>';
|
|
135
|
+
return `<table class="kv">${entries
|
|
136
|
+
.map(([k, v]) => `<tr><th>${escapeHtml(k)}</th><td>${escapeHtml(typeof v === 'string' ? v : JSON.stringify(v))}</td></tr>`)
|
|
137
|
+
.join('')}</table>`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Dependency-free C# highlighter for the Code tab. Single-pass scanner that
|
|
141
|
+
// handles comments/strings/chars BEFORE keywords (so keywords inside strings
|
|
142
|
+
// aren't recolored), escapes every token's text, and reuses the demo's token
|
|
143
|
+
// classes (kw/ty/st/cm + a new nu for numbers). Kept tiny + local on purpose —
|
|
144
|
+
// the thin UI ships no syntax-highlighting library (no supply-chain surface).
|
|
145
|
+
const CSHARP_KEYWORDS = new Set([
|
|
146
|
+
'using', 'namespace', 'class', 'struct', 'enum', 'interface', 'public', 'private',
|
|
147
|
+
'protected', 'internal', 'static', 'readonly', 'const', 'var', 'new', 'return', 'if',
|
|
148
|
+
'else', 'for', 'foreach', 'while', 'do', 'switch', 'case', 'break', 'continue', 'try',
|
|
149
|
+
'catch', 'finally', 'throw', 'in', 'out', 'ref', 'is', 'as', 'null', 'true', 'false',
|
|
150
|
+
'void', 'int', 'long', 'short', 'byte', 'uint', 'ulong', 'double', 'float', 'decimal',
|
|
151
|
+
'bool', 'string', 'char', 'object', 'dynamic', 'this', 'base', 'typeof', 'nameof',
|
|
152
|
+
'default', 'get', 'set', 'async', 'await', 'yield', 'lock', 'params', 'when', 'where',
|
|
153
|
+
]);
|
|
154
|
+
function highlightCSharp(src) {
|
|
155
|
+
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
156
|
+
const span = (cls, text) => `<span class="${cls}">${esc(text)}</span>`;
|
|
157
|
+
const n = src.length;
|
|
158
|
+
let out = '';
|
|
159
|
+
let i = 0;
|
|
160
|
+
while (i < n) {
|
|
161
|
+
const c = src[i];
|
|
162
|
+
if (c === '/' && src[i + 1] === '/') { // line comment
|
|
163
|
+
let j = src.indexOf('\n', i); if (j < 0) j = n;
|
|
164
|
+
out += span('cm', src.slice(i, j)); i = j; continue;
|
|
165
|
+
}
|
|
166
|
+
if (c === '/' && src[i + 1] === '*') { // block comment
|
|
167
|
+
let j = src.indexOf('*/', i + 2); j = j < 0 ? n : j + 2;
|
|
168
|
+
out += span('cm', src.slice(i, j)); i = j; continue;
|
|
169
|
+
}
|
|
170
|
+
if (c === '@' && src[i + 1] === '"') { // verbatim string ("" escapes a quote)
|
|
171
|
+
let j = i + 2;
|
|
172
|
+
while (j < n) { if (src[j] === '"') { if (src[j + 1] === '"') { j += 2; continue; } j++; break; } j++; }
|
|
173
|
+
out += span('st', src.slice(i, j)); i = j; continue;
|
|
174
|
+
}
|
|
175
|
+
if (c === '"' || c === "'" || (c === '$' && src[i + 1] === '"')) { // string / char
|
|
176
|
+
const quote = c === '$' ? '"' : c;
|
|
177
|
+
let j = c === '$' ? i + 2 : i + 1;
|
|
178
|
+
while (j < n) {
|
|
179
|
+
if (src[j] === '\\') { j += 2; continue; }
|
|
180
|
+
if (src[j] === quote) { j++; break; }
|
|
181
|
+
if (src[j] === '\n') break;
|
|
182
|
+
j++;
|
|
183
|
+
}
|
|
184
|
+
out += span('st', src.slice(i, j)); i = j; continue;
|
|
185
|
+
}
|
|
186
|
+
if (c >= '0' && c <= '9') { // number
|
|
187
|
+
let j = i; while (j < n && /[0-9._a-fA-FxXdflmDFLM]/.test(src[j])) j++;
|
|
188
|
+
out += span('nu', src.slice(i, j)); i = j; continue;
|
|
189
|
+
}
|
|
190
|
+
if (/[A-Za-z_]/.test(c)) { // identifier / keyword / type
|
|
191
|
+
let j = i; while (j < n && /[A-Za-z0-9_]/.test(src[j])) j++;
|
|
192
|
+
const word = src.slice(i, j);
|
|
193
|
+
if (CSHARP_KEYWORDS.has(word)) out += span('kw', word);
|
|
194
|
+
else if (/^[A-Z]/.test(word)) out += span('ty', word);
|
|
195
|
+
else out += esc(word);
|
|
196
|
+
i = j; continue;
|
|
197
|
+
}
|
|
198
|
+
out += esc(c); i++;
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Plain-English summary of an exec node = its leading `//` comment header
|
|
204
|
+
// (authored as prose). Used in the Description tab so it explains what the node
|
|
205
|
+
// does — the raw C# is the Code tab's job, never Description's.
|
|
206
|
+
function leadingComment(code) {
|
|
207
|
+
const out = [];
|
|
208
|
+
for (const raw of String(code).split(/\r?\n/)) {
|
|
209
|
+
const line = raw.trim();
|
|
210
|
+
if (line.startsWith('//')) { out.push(line.replace(/^\/\/+\s?/, '')); continue; }
|
|
211
|
+
if (line === '') { if (out.length) break; else continue; }
|
|
212
|
+
break; // first real (non-comment, non-blank) line ends the header block
|
|
213
|
+
}
|
|
214
|
+
return out.join(' ').replace(/\s+/g, ' ').trim();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function buildAgentEntry(app, n) {
|
|
218
|
+
const pin = app.lock.agentPins && n.agent ? app.lock.agentPins[n.agent] : null;
|
|
219
|
+
// All file-derived strings are escaped here: cardEl/renderInspect inject these
|
|
220
|
+
// into innerHTML raw, so a hostile node id/agent/command/note must not be HTML.
|
|
221
|
+
const agentLabel = escapeHtml(n.agent || n.kind);
|
|
222
|
+
const cmd = n.command ? escapeHtml(n.command) : '';
|
|
223
|
+
const mode = escapeHtml(n.mode);
|
|
224
|
+
const modeBadge = `<span class="lvl ${n.mode === 'write' ? 'warn' : 'info'}">${mode}</span>`;
|
|
225
|
+
const notesHtml = n.notes.length
|
|
226
|
+
? `<p><strong>Compile notes:</strong></p><ul>${n.notes.map((x) => `<li><span class="note-kind ${x.kind}">${x.kind}</span> ${escapeHtml(humanizeNote(x.text))}</li>`).join('')}</ul>`
|
|
227
|
+
: '';
|
|
228
|
+
const inputs = n.inputs && Object.keys(n.inputs).length ? n.inputs : n.config;
|
|
229
|
+
// The exec node's Roslyn C# (the actual script the host runs). null for
|
|
230
|
+
// pure-agent nodes that carry no inline code.
|
|
231
|
+
const execSource = n.config && typeof n.config.code === 'string' ? n.config.code : null;
|
|
232
|
+
// Description tab: a plain-English explanation + a small input table that
|
|
233
|
+
// EXCLUDES the code (code belongs to the Code tab, never dumped here).
|
|
234
|
+
const plainDesc = execSource ? leadingComment(execSource) : '';
|
|
235
|
+
const inputsNoCode = {};
|
|
236
|
+
for (const [k, v] of Object.entries(inputs || {})) if (k !== 'code') inputsNoCode[k] = v;
|
|
237
|
+
return {
|
|
238
|
+
_mode: n.mode,
|
|
239
|
+
icon: iconFor(n.agent),
|
|
240
|
+
kind: n.kind === 'agent' && n.agent ? `${agentLabel} agent` : escapeHtml(n.kind),
|
|
241
|
+
version: pin ? `v${escapeHtml(String(pin))}` : '—',
|
|
242
|
+
title: escapeHtml(n.id),
|
|
243
|
+
subtitle: `${agentLabel}${cmd ? '/' + cmd : ''} · ${mode}`,
|
|
244
|
+
blurb: n.notes[0] ? escapeHtml(humanizeNote(n.notes[0].text)) : `${mode}-mode ${cmd ? '<code>' + cmd + '</code>' : escapeHtml(n.kind)} node`,
|
|
245
|
+
description: `${plainDesc ? `<p>${escapeHtml(plainDesc)}</p>` : ''}<p>Resolves to ${modeBadge} via <code>${escapeHtml(n.agent || n.kind)}${n.command ? '.' + escapeHtml(n.command) : ''}</code>.</p>${Object.keys(inputsNoCode).length ? `<p><strong>Inputs</strong></p>${kvTable(inputsNoCode)}` : ''}${execSource ? `<p class="dim-note">Full source in the <strong>Code</strong> tab.</p>` : ''}${notesHtml}`,
|
|
246
|
+
skill: `<h3>Provided by</h3><p><code>${escapeHtml(n.agent || n.kind)}</code>${pin ? ' · pinned <code>v' + escapeHtml(pin) + '</code>' : ''}${n.command ? ' · command <code>' + escapeHtml(n.command) + '</code>' : ''}</p>
|
|
247
|
+
<p>Resolved write/read mode is part of the approved <code>.lock</code> — see the <strong>Code</strong> tab.</p>${notesHtml}`,
|
|
248
|
+
// Code tab: the REAL node source. For exec nodes (Roslyn C# run against a
|
|
249
|
+
// live host) that's `config.code` straight from the .flo — the same text
|
|
250
|
+
// the host compiles, so "Debug in VS" attaches to exactly this. For
|
|
251
|
+
// non-exec nodes there is no script; we show the resolved lockfile YAML.
|
|
252
|
+
code: execSource
|
|
253
|
+
? highlightCSharp(`// ${n.agent || n.kind}${n.command ? '.' + n.command : ''} · node "${n.id}" · live source from ~/.aware/apps/${app.id}/\n${execSource}`)
|
|
254
|
+
: app.lock.present
|
|
255
|
+
? `<span class="cm"># ${escapeHtml(app.id)}.lock · node "${escapeHtml(n.id)}"</span>\n${escapeHtml(nodeYaml(n))}`
|
|
256
|
+
: `<span class="cm"># not compiled</span>\nHit ⎙ Compile to freeze the .lock.`,
|
|
257
|
+
// raw (unescaped) exec source for the Debug-in-VS handler; null for non-exec.
|
|
258
|
+
_execSource: execSource,
|
|
259
|
+
_execVersion: n.config && typeof n.config.version === 'string' ? n.config.version : null,
|
|
260
|
+
_appId: app.id,
|
|
261
|
+
_nodeId: n.id,
|
|
262
|
+
execution: liveTrace,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Best-effort DAG layering when a source declares layout: dag (longest-path
|
|
267
|
+
// columns; rows by arrival order within a column). Linear apps skip this.
|
|
268
|
+
function layoutDag(nodes, connections) {
|
|
269
|
+
const depth = new Map(nodes.map((n) => [n.id, 0]));
|
|
270
|
+
for (let pass = 0; pass < nodes.length; pass++) {
|
|
271
|
+
for (const c of connections) {
|
|
272
|
+
depth.set(c.to, Math.max(depth.get(c.to) || 0, (depth.get(c.from) || 0) + 1));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const rowByCol = {};
|
|
276
|
+
return nodes.map((n) => {
|
|
277
|
+
const col = (depth.get(n.id) || 0) + 1;
|
|
278
|
+
rowByCol[col] = (rowByCol[col] || 0) + 1;
|
|
279
|
+
return { id: n.id, col, row: rowByCol[col] };
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function transform(app) {
|
|
284
|
+
app.nodes.forEach((n) => { AGENTS[n.id] = buildAgentEntry(app, n); });
|
|
285
|
+
|
|
286
|
+
const userText = escapeHtml(app.description?.trim()
|
|
287
|
+
? app.description.trim().split('\n')[0]
|
|
288
|
+
: `${app.displayName} — ${app.nodes.length} node${app.nodes.length === 1 ? '' : 's'}.`);
|
|
289
|
+
// Escape interpolated values (not the literal **/`` markup, which mdInline renders).
|
|
290
|
+
const narration = [
|
|
291
|
+
`Reading **${escapeHtml(app.name)}** from \`~/.aware/apps/${escapeHtml(app.id)}\` — the canvas is a view of the real \`.flo\`.`,
|
|
292
|
+
...app.nodes.map((n) => `**${escapeHtml(n.id)}** → \`${escapeHtml(n.agent || n.kind)}${n.command ? '/' + escapeHtml(n.command) : ''}\` · ${escapeHtml(n.mode)}`),
|
|
293
|
+
];
|
|
294
|
+
if (app.notes.length) narration.push(`ℹ ${app.notes.length} compiler note${app.notes.length === 1 ? '' : 's'} — see the strip over the canvas.`);
|
|
295
|
+
narration.push(app.runnable
|
|
296
|
+
? 'Lock is fresh (source-hash matches). **Run** is armed.'
|
|
297
|
+
: app.runState === 'drift'
|
|
298
|
+
? 'The `.flo` changed since the last approve — **Compile** to refresh the lock before Run.'
|
|
299
|
+
: 'No lock yet — **Compile** to freeze the approved contract.');
|
|
300
|
+
|
|
301
|
+
if (app.layout === 'dag') {
|
|
302
|
+
PROMPTS[app.id] = { name: app.name, layout: 'dag', nodes: layoutDag(app.nodes, app.connections), connections: app.connections, userText, narration };
|
|
303
|
+
} else {
|
|
304
|
+
const ids = app.nodes.map((n) => n.id);
|
|
305
|
+
const wires = ids.slice(1).map((_, i) => app.connections[i]?.label || '');
|
|
306
|
+
PROMPTS[app.id] = { name: app.name, layout: 'linear', nodes: ids, wires, userText, narration };
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ── gate + notes UI ────────────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
function paintGate(app) {
|
|
313
|
+
// Task-language label (AEC users don't know "lock"/"drift"); the tooltip carries
|
|
314
|
+
// the why + the Compile step.
|
|
315
|
+
const labels = { ready: 'Ready to run', drift: 'Needs refresh', uncompiled: 'Not set up' };
|
|
316
|
+
$runState.className = `run-state ${app.runState}`;
|
|
317
|
+
$runState.textContent = labels[app.runState] || '';
|
|
318
|
+
const stateTips = {
|
|
319
|
+
ready: 'Ready to run.\nThis workflow was compiled from exactly the current .flo (the approved “lock” matches the source), so ▶ Run workflow is enabled.',
|
|
320
|
+
drift: 'Needs refresh.\nThe .flo changed since the last Compile, so the approved lock is out of date and Run is blocked. Hit ⎙ Compile to re-approve it.',
|
|
321
|
+
uncompiled: 'Not set up yet.\nThere is no approved lock for this workflow, so Run is blocked. Hit ⎙ Compile to freeze one — then ▶ Run workflow turns on.',
|
|
322
|
+
};
|
|
323
|
+
$runState.dataset.tip = stateTips[app.runState] || '';
|
|
324
|
+
|
|
325
|
+
$runBtn.disabled = !app.runnable;
|
|
326
|
+
$runBtn.dataset.tip = app.runnable
|
|
327
|
+
? 'Run the approved workflow'
|
|
328
|
+
: app.runState === 'drift'
|
|
329
|
+
? 'Blocked: the .flo changed since the last approve — Compile first'
|
|
330
|
+
: 'Blocked: not compiled — Compile first';
|
|
331
|
+
|
|
332
|
+
const needsCompile = app.runState !== 'ready';
|
|
333
|
+
$compileBtn.disabled = false;
|
|
334
|
+
$compileBtn.textContent = needsCompile ? '⎙ Compile *' : '⎙ Compile';
|
|
335
|
+
|
|
336
|
+
// Bake: package the workflow as a reusable agent. Gated on the same runnable
|
|
337
|
+
// state as Run (baking a stale/uncompiled app makes no sense — the lock is
|
|
338
|
+
// what the server reads to mirror inputs). Disabled-with-tooltip (teach the
|
|
339
|
+
// path), not hidden. Label flips to "Re-bake" when already exposed-as-agent.
|
|
340
|
+
if ($menuBakeItem) {
|
|
341
|
+
$menuBakeLabel.textContent = app.baked ? 'Re-bake agent' : 'Bake into agent';
|
|
342
|
+
$menuBakeItem.disabled = !app.runnable;
|
|
343
|
+
$menuBakeItem.dataset.tip = app.runnable
|
|
344
|
+
? (app.baked
|
|
345
|
+
? 'Already a reusable agent. Re-bake to refresh its exposed interface after edits.'
|
|
346
|
+
: 'Package this workflow as a reusable agent — drop it as a single node in any other workflow.')
|
|
347
|
+
: app.runState === 'drift'
|
|
348
|
+
? 'Compile first — the .flo changed since the last approve, so Bake is blocked.'
|
|
349
|
+
: 'Compile first — there is no approved lock yet, so Bake is blocked.';
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (app.notes.length) {
|
|
353
|
+
const n = app.notes.length;
|
|
354
|
+
const sig = notesSig(app.notes);
|
|
355
|
+
// Strip severity = worst note kind (info < warn < error). Drives colour +
|
|
356
|
+
// icon; AWARE 0.48 tags each note with `kind` (#170) so we render by it
|
|
357
|
+
// rather than guessing from the text.
|
|
358
|
+
const worst = app.notes.some((x) => x.kind === 'error') ? 'error'
|
|
359
|
+
: app.notes.some((x) => x.kind === 'warn') ? 'warn' : 'info';
|
|
360
|
+
const icon = worst === 'error' ? '✕' : worst === 'warn' ? '⚠' : 'ℹ';
|
|
361
|
+
$notesStrip.hidden = false;
|
|
362
|
+
$notesStrip.dataset.appId = app.id;
|
|
363
|
+
$notesStrip.dataset.sig = sig;
|
|
364
|
+
$notesStrip.classList.remove('sev-info', 'sev-warn', 'sev-error');
|
|
365
|
+
$notesStrip.classList.add('sev-' + worst);
|
|
366
|
+
$notesStrip.innerHTML =
|
|
367
|
+
`<div class="notes-head"><span class="notes-title">${icon} ${n} compiler note${n === 1 ? '' : 's'}</span>` +
|
|
368
|
+
`<button class="notes-dismiss" type="button" aria-label="Collapse compiler notes" data-tip="Collapse">×</button></div>` +
|
|
369
|
+
`<ul>${app.notes.map((nt) => `<li><span class="note-kind ${nt.kind}">${nt.kind}</span><code>${escapeHtml(nt.nodeId)}</code> — ${escapeHtml(humanizeNote(nt.text))}</li>`).join('')}</ul>`;
|
|
370
|
+
// Collapse decision: a remembered choice for this exact note set wins;
|
|
371
|
+
// otherwise default to collapsed for benign info-only sets (keep the canvas
|
|
372
|
+
// quiet for AEC users) and expanded when anything is warn/error.
|
|
373
|
+
const remembered = notesRememberedCollapse(app.id, sig);
|
|
374
|
+
$notesStrip.classList.toggle('collapsed', remembered !== undefined ? remembered : worst === 'info');
|
|
375
|
+
} else {
|
|
376
|
+
$notesStrip.hidden = true;
|
|
377
|
+
$notesStrip.classList.remove('collapsed');
|
|
378
|
+
delete $notesStrip.dataset.appId;
|
|
379
|
+
delete $notesStrip.dataset.sig;
|
|
380
|
+
$notesStrip.innerHTML = '';
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function paintModes() {
|
|
385
|
+
document.querySelectorAll('.agent-card').forEach((card) => {
|
|
386
|
+
const a = AGENTS[card.dataset.agentId];
|
|
387
|
+
card.classList.toggle('mode-write', a?._mode === 'write');
|
|
388
|
+
card.classList.toggle('mode-read', a?._mode === 'read');
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ── load / select / compile / run ───────────────────────────────────────────
|
|
393
|
+
|
|
394
|
+
async function loadApp(id) {
|
|
395
|
+
const { app } = await api(`/api/app/${encodeURIComponent(id)}`);
|
|
396
|
+
apps.set(id, app);
|
|
397
|
+
currentId = id;
|
|
398
|
+
try { localStorage.setItem(LS_LAST_APP, id); } catch { /* private mode / quota */ }
|
|
399
|
+
transform(app);
|
|
400
|
+
liveTrace.length = 0;
|
|
401
|
+
state.promptKey = id;
|
|
402
|
+
state.selectedAgentId = null;
|
|
403
|
+
state.hasRun = false;
|
|
404
|
+
if ($promptSel.value !== id) $promptSel.value = id;
|
|
405
|
+
setComboTriggerLabel(id);
|
|
406
|
+
setComboSelected(id);
|
|
407
|
+
renderChat();
|
|
408
|
+
renderTopology(); // selects first node + renderInspect
|
|
409
|
+
paintModes();
|
|
410
|
+
markStars();
|
|
411
|
+
seedAppInputs(app);
|
|
412
|
+
markSpecialNodes();
|
|
413
|
+
paintGate(app);
|
|
414
|
+
refreshDirtyIndicator(); // clean app → dot off; switched back to a dirty one → dot on
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function loadApps() {
|
|
418
|
+
setComboTriggerLabel('loading…');
|
|
419
|
+
renderCanvasPlaceholder('loading');
|
|
420
|
+
const { apps: list } = await api('/api/apps');
|
|
421
|
+
$promptSel.innerHTML = '';
|
|
422
|
+
if (!list.length) {
|
|
423
|
+
// The guided empty-state panel owns the "install a workflow" message now —
|
|
424
|
+
// don't also raise the warn-coloured notes strip (it's for real compiler
|
|
425
|
+
// notes, and a yellow band for a neutral first-run reads as a false alarm).
|
|
426
|
+
$notesStrip.hidden = true;
|
|
427
|
+
$runBtn.disabled = true;
|
|
428
|
+
$compileBtn.disabled = true;
|
|
429
|
+
if ($menuBakeItem) $menuBakeItem.disabled = true;
|
|
430
|
+
if ($wfTrigger) $wfTrigger.disabled = true;
|
|
431
|
+
setComboTriggerLabel('no workflows installed');
|
|
432
|
+
renderCanvasPlaceholder('empty');
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const $apps = document.getElementById('apps-count');
|
|
436
|
+
if ($apps) $apps.textContent = list.length;
|
|
437
|
+
for (const a of list) {
|
|
438
|
+
const opt = document.createElement('option');
|
|
439
|
+
opt.value = a.id;
|
|
440
|
+
opt.textContent = `${a.id} · ${a.nodes} node${a.nodes === 1 ? '' : 's'} · ${a.layout}`;
|
|
441
|
+
$promptSel.appendChild(opt);
|
|
442
|
+
}
|
|
443
|
+
buildWorkflowCombo(list); // searchable, provider-grouped picker over the hidden select
|
|
444
|
+
// Restore the last-opened app (remembered across refresh); fall back to the
|
|
445
|
+
// first app when there's no saved choice or it's no longer installed.
|
|
446
|
+
let startId = list[0].id;
|
|
447
|
+
try {
|
|
448
|
+
const saved = localStorage.getItem(LS_LAST_APP);
|
|
449
|
+
if (saved && list.some((a) => a.id === saved)) startId = saved;
|
|
450
|
+
} catch { /* ignore */ }
|
|
451
|
+
await loadApp(startId);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ── Workflow combobox (searchable, provider-grouped) ─────────────────────────
|
|
455
|
+
// A custom control over the hidden <select id="prompt-select"> — the select stays
|
|
456
|
+
// the value model (it keeps firing change → loadApp), and this adds search +
|
|
457
|
+
// provider grouping a native <select> can't. Provider comes from /api/apps.
|
|
458
|
+
const PROVIDER_LABELS = {
|
|
459
|
+
tekla: 'Tekla', revit: 'Revit', sketchup: 'SketchUp', excel: 'Excel',
|
|
460
|
+
supabase: 'Supabase', http: 'HTTP', file: 'File', email: 'Email',
|
|
461
|
+
'trimble-connect': 'Trimble Connect', 'microsoft-365': 'Microsoft 365',
|
|
462
|
+
'ui-inspector': 'UI Inspector', 'html-report': 'HTML Report',
|
|
463
|
+
'google-workspace': 'Google Workspace', other: 'Other',
|
|
464
|
+
};
|
|
465
|
+
const PROVIDER_ORDER = ['tekla', 'revit', 'sketchup', 'excel', 'supabase', 'http', 'trimble-connect', 'microsoft-365', 'email', 'file', 'ui-inspector'];
|
|
466
|
+
const providerLabel = (p) => PROVIDER_LABELS[p] || (p ? p.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) : 'Other');
|
|
467
|
+
|
|
468
|
+
const $wfCombo = document.getElementById('wf-combo');
|
|
469
|
+
const $wfTrigger = document.getElementById('wf-trigger');
|
|
470
|
+
const $wfTriggerLabel = document.getElementById('wf-trigger-label');
|
|
471
|
+
const $wfPopover = document.getElementById('wf-popover');
|
|
472
|
+
const $wfSearch = document.getElementById('wf-search');
|
|
473
|
+
const $wfList = document.getElementById('wf-list');
|
|
474
|
+
let comboHi = -1; // index into the currently-visible options (-1 = none)
|
|
475
|
+
|
|
476
|
+
function setComboTriggerLabel(text) { if ($wfTriggerLabel) $wfTriggerLabel.textContent = text || 'select workflow'; }
|
|
477
|
+
function setComboSelected(id) {
|
|
478
|
+
if (!$wfList) return;
|
|
479
|
+
$wfList.querySelectorAll('.wf-option').forEach((o) => {
|
|
480
|
+
const sel = o.dataset.id === id;
|
|
481
|
+
o.classList.toggle('selected', sel);
|
|
482
|
+
o.setAttribute('aria-selected', sel ? 'true' : 'false');
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Rebuild the grouped option list from the /api/apps payload (id, nodes, layout,
|
|
487
|
+
// provider). Groups are ordered by PROVIDER_ORDER, then any extras alpha, "Other"
|
|
488
|
+
// last. All file-derived strings are escaped (they land in innerHTML).
|
|
489
|
+
function buildWorkflowCombo(list) {
|
|
490
|
+
if (!$wfList) return;
|
|
491
|
+
if ($wfTrigger) $wfTrigger.disabled = false;
|
|
492
|
+
const groups = new Map();
|
|
493
|
+
for (const a of list) {
|
|
494
|
+
const p = a.provider || 'other';
|
|
495
|
+
if (!groups.has(p)) groups.set(p, []);
|
|
496
|
+
groups.get(p).push(a);
|
|
497
|
+
}
|
|
498
|
+
const order = [
|
|
499
|
+
...PROVIDER_ORDER.filter((p) => groups.has(p)),
|
|
500
|
+
...[...groups.keys()].filter((p) => !PROVIDER_ORDER.includes(p) && p !== 'other').sort(),
|
|
501
|
+
...(groups.has('other') ? ['other'] : []),
|
|
502
|
+
];
|
|
503
|
+
let html = '';
|
|
504
|
+
for (const p of order) {
|
|
505
|
+
const rows = groups.get(p).slice().sort((x, y) => x.id.localeCompare(y.id));
|
|
506
|
+
html += `<div class="wf-group" role="group" aria-label="${escapeAttr(providerLabel(p))}"><div class="wf-group-header">${escapeHtml(providerLabel(p))}</div>`;
|
|
507
|
+
for (const a of rows) {
|
|
508
|
+
const meta = `${escapeHtml(String(a.nodes))} node${a.nodes === 1 ? '' : 's'} · ${escapeHtml(String(a.layout))}`;
|
|
509
|
+
html += `<button type="button" class="wf-option" role="option" id="wf-opt-${escapeAttr(a.id)}" data-id="${escapeAttr(a.id)}" data-search="${escapeAttr((a.id + ' ' + p).toLowerCase())}" aria-selected="false">`
|
|
510
|
+
+ `<span class="wf-option-name">${escapeHtml(a.id)}</span><span class="wf-option-meta">${meta}</span></button>`;
|
|
511
|
+
}
|
|
512
|
+
html += '</div>';
|
|
513
|
+
}
|
|
514
|
+
html += `<div class="wf-empty-search" hidden>No workflows match '<span class="wf-empty-term"></span>'</div>`;
|
|
515
|
+
$wfList.innerHTML = html;
|
|
516
|
+
setComboSelected(currentId || $promptSel.value);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function comboVisibleOptions() {
|
|
520
|
+
return [...$wfList.querySelectorAll('.wf-option')].filter((o) => !o.hidden && !o.closest('.wf-group').hidden);
|
|
521
|
+
}
|
|
522
|
+
function comboClearHighlight() {
|
|
523
|
+
$wfList.querySelectorAll('.wf-option.highlighted').forEach((o) => o.classList.remove('highlighted'));
|
|
524
|
+
comboHi = -1;
|
|
525
|
+
if ($wfSearch) $wfSearch.removeAttribute('aria-activedescendant');
|
|
526
|
+
}
|
|
527
|
+
function comboFilter(q) {
|
|
528
|
+
q = (q || '').toLowerCase().trim();
|
|
529
|
+
let any = false;
|
|
530
|
+
$wfList.querySelectorAll('.wf-group').forEach((g) => {
|
|
531
|
+
let groupVisible = false;
|
|
532
|
+
g.querySelectorAll('.wf-option').forEach((o) => {
|
|
533
|
+
const match = !q || o.dataset.search.includes(q);
|
|
534
|
+
o.hidden = !match;
|
|
535
|
+
if (match) groupVisible = true;
|
|
536
|
+
});
|
|
537
|
+
g.hidden = !groupVisible;
|
|
538
|
+
if (groupVisible) any = true;
|
|
539
|
+
});
|
|
540
|
+
const empty = $wfList.querySelector('.wf-empty-search');
|
|
541
|
+
if (empty) { empty.hidden = any; empty.querySelector('.wf-empty-term').textContent = q; }
|
|
542
|
+
comboClearHighlight();
|
|
543
|
+
}
|
|
544
|
+
function comboMove(delta) {
|
|
545
|
+
const vis = comboVisibleOptions();
|
|
546
|
+
if (!vis.length) return;
|
|
547
|
+
// From no highlight: Down → first, Up → last. Otherwise step + clamp.
|
|
548
|
+
comboHi = comboHi < 0 ? (delta > 0 ? 0 : vis.length - 1) : Math.max(0, Math.min(vis.length - 1, comboHi + delta));
|
|
549
|
+
vis.forEach((o, i) => o.classList.toggle('highlighted', i === comboHi));
|
|
550
|
+
const hi = vis[comboHi];
|
|
551
|
+
hi.scrollIntoView({ block: 'nearest' });
|
|
552
|
+
if ($wfSearch) $wfSearch.setAttribute('aria-activedescendant', hi.id);
|
|
553
|
+
}
|
|
554
|
+
function openCombo() {
|
|
555
|
+
if (!$wfPopover || ($wfTrigger && $wfTrigger.disabled)) return;
|
|
556
|
+
$wfPopover.hidden = false;
|
|
557
|
+
$wfTrigger.classList.add('open');
|
|
558
|
+
$wfTrigger.setAttribute('aria-expanded', 'true');
|
|
559
|
+
if ($wfSearch) $wfSearch.value = '';
|
|
560
|
+
comboFilter('');
|
|
561
|
+
setComboSelected(currentId || $promptSel.value);
|
|
562
|
+
const sel = $wfList.querySelector('.wf-option.selected');
|
|
563
|
+
if (sel) sel.scrollIntoView({ block: 'nearest' });
|
|
564
|
+
setTimeout(() => { if ($wfSearch) $wfSearch.focus(); }, 0);
|
|
565
|
+
}
|
|
566
|
+
function closeCombo(focusTrigger) {
|
|
567
|
+
if (!$wfPopover || $wfPopover.hidden) return;
|
|
568
|
+
$wfPopover.hidden = true;
|
|
569
|
+
$wfTrigger.classList.remove('open');
|
|
570
|
+
$wfTrigger.setAttribute('aria-expanded', 'false');
|
|
571
|
+
comboClearHighlight();
|
|
572
|
+
if (focusTrigger && $wfTrigger) $wfTrigger.focus();
|
|
573
|
+
}
|
|
574
|
+
// Pick a workflow: drive the hidden <select> (dispatch change → loadApp). Picking
|
|
575
|
+
// the already-open workflow just closes (no redundant reload).
|
|
576
|
+
// Re-entrancy latch: the switch-confirm dialog is async, and Ctrl+O (doOpen) can
|
|
577
|
+
// reopen the picker over it — ignore a second pick until the first resolves.
|
|
578
|
+
let switchConfirmPending = false;
|
|
579
|
+
async function selectComboById(id) {
|
|
580
|
+
const switching = id && id !== currentId;
|
|
581
|
+
// Guard a switch away from an app with unsaved input changes: close the picker,
|
|
582
|
+
// then ask Save / Don't save / Cancel before leaving. Non-user reloads (compile,
|
|
583
|
+
// fs-change, restore-last) call loadApp directly and never hit this path.
|
|
584
|
+
if (switching && isInputsDirty(currentId)) {
|
|
585
|
+
if (switchConfirmPending) return; // a decision is already pending
|
|
586
|
+
switchConfirmPending = true;
|
|
587
|
+
closeCombo(true);
|
|
588
|
+
const app = apps.get(currentId);
|
|
589
|
+
let choice;
|
|
590
|
+
try { choice = await confirmUnsavedSwitch(app ? app.displayName : currentId); }
|
|
591
|
+
finally { switchConfirmPending = false; }
|
|
592
|
+
if (choice === 'cancel') return; // stay on the current workflow
|
|
593
|
+
if (choice === 'save') saveCurrentInputs();
|
|
594
|
+
else revertInputs(currentId); // 'discard' → drop unsaved edits
|
|
595
|
+
} else {
|
|
596
|
+
closeCombo(true); // committed pick → return focus to the trigger, not the hidden search
|
|
597
|
+
}
|
|
598
|
+
if (switching) {
|
|
599
|
+
$promptSel.value = id;
|
|
600
|
+
$promptSel.dispatchEvent(new Event('change', { bubbles: true }));
|
|
601
|
+
}
|
|
602
|
+
setComboTriggerLabel(id);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if ($wfTrigger) {
|
|
606
|
+
$wfTrigger.addEventListener('click', () => ($wfPopover.hidden ? openCombo() : closeCombo(false)));
|
|
607
|
+
$wfTrigger.addEventListener('keydown', (e) => {
|
|
608
|
+
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openCombo(); }
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
if ($wfSearch) {
|
|
612
|
+
$wfSearch.addEventListener('input', () => comboFilter($wfSearch.value));
|
|
613
|
+
$wfSearch.addEventListener('keydown', (e) => {
|
|
614
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); comboMove(1); }
|
|
615
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); comboMove(-1); }
|
|
616
|
+
else if (e.key === 'Enter') {
|
|
617
|
+
e.preventDefault();
|
|
618
|
+
const vis = comboVisibleOptions();
|
|
619
|
+
const pick = comboHi >= 0 ? vis[comboHi] : (vis.length === 1 ? vis[0] : null);
|
|
620
|
+
if (pick) selectComboById(pick.dataset.id);
|
|
621
|
+
} else if (e.key === 'Escape') { e.preventDefault(); closeCombo(true); }
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
if ($wfList) {
|
|
625
|
+
$wfList.addEventListener('click', (e) => {
|
|
626
|
+
const o = e.target.closest('.wf-option');
|
|
627
|
+
if (o) selectComboById(o.dataset.id);
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
document.addEventListener('mousedown', (e) => {
|
|
631
|
+
if ($wfPopover && !$wfPopover.hidden && $wfCombo && !$wfCombo.contains(e.target)) closeCombo(false);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
// ── app inputs + HTML Viewer ────────────────────────────────────────────────
|
|
635
|
+
|
|
636
|
+
// The id of the report-viewer node — the terminal node a user double-clicks to
|
|
637
|
+
// LOAD the last report. The header "▶ Run workflow" generates a fresh one. The app
|
|
638
|
+
// qualifies only if some node runs exec C# returning HTML; the viewer is then
|
|
639
|
+
// the LAST node in the chain (e.g. the dedicated "HTML Report Viewer" node),
|
|
640
|
+
// which is also where extractReportHtml's output naturally surfaces.
|
|
641
|
+
// null when the app builds no report.
|
|
642
|
+
function reportNodeId() {
|
|
643
|
+
const app = currentId && apps.get(currentId);
|
|
644
|
+
if (!app || !app.nodes.length) return null;
|
|
645
|
+
// Qualify the app only if some node actually PRODUCES report HTML — its exec
|
|
646
|
+
// code returns an `html` field (the `data.result.html` extractReportHtml keys
|
|
647
|
+
// on), or it forwards `{{ <node>.result.html }}` through its args (a
|
|
648
|
+
// pass-through viewer). "any node has exec code" is NOT enough: a plain
|
|
649
|
+
// status-bar app like hello-world runs exec yet emits no HTML, so it has no
|
|
650
|
+
// report-viewer node (and Run must fill the trace, not open an empty viewer).
|
|
651
|
+
// The code test matches `html` in anonymous-object-property position only —
|
|
652
|
+
// `html =` OR shorthand `html` (preceded by `{`/`,`, followed by `=`/`,`/`}`)
|
|
653
|
+
// — so a local `html` variable in a non-report node doesn't false-qualify it.
|
|
654
|
+
const producesHtml = app.nodes.some((n) => {
|
|
655
|
+
const cfg = n.config;
|
|
656
|
+
if (!cfg) return false;
|
|
657
|
+
if (typeof cfg.code === 'string' && /[,{]\s*html\s*[=,}]/.test(cfg.code)) return true;
|
|
658
|
+
if (cfg.args && typeof cfg.args === 'object') {
|
|
659
|
+
for (const v of Object.values(cfg.args)) {
|
|
660
|
+
if (typeof v === 'string' && /\{\{\s*[\w.-]+\.result\.html\s*\}\}/.test(v)) return true;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return false;
|
|
664
|
+
});
|
|
665
|
+
return producesHtml ? app.nodes[app.nodes.length - 1].id : null;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// The node that carries the app inputs = the first node templating {{ inputs.* }}
|
|
669
|
+
// in its config.args. Double-clicking it opens the inputs dialog. null when the
|
|
670
|
+
// app declares no inputs / no node consumes them.
|
|
671
|
+
function inputNodeId() {
|
|
672
|
+
const app = currentId && apps.get(currentId);
|
|
673
|
+
if (!app || !Array.isArray(app.inputs) || !app.inputs.length) return null;
|
|
674
|
+
for (const n of app.nodes) {
|
|
675
|
+
const args = n.config && n.config.args;
|
|
676
|
+
if (args && typeof args === 'object') {
|
|
677
|
+
for (const v of Object.values(args)) {
|
|
678
|
+
if (typeof v === 'string' && /\{\{\s*inputs\.[A-Za-z0-9._-]+\s*\}\}/.test(v)) return n.id;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Append a first-class action button to a card (e.g. "View report ▸"). The
|
|
686
|
+
// click is self-contained: it stops the canvas/inspect handlers from also
|
|
687
|
+
// firing, then runs the action. Idempotent — callers clear stale buttons first.
|
|
688
|
+
function addNodeAction(card, label, onClick) {
|
|
689
|
+
const btn = document.createElement('button');
|
|
690
|
+
btn.type = 'button';
|
|
691
|
+
btn.className = 'node-action';
|
|
692
|
+
btn.textContent = label;
|
|
693
|
+
btn.addEventListener('click', (e) => {
|
|
694
|
+
e.stopPropagation();
|
|
695
|
+
e.preventDefault();
|
|
696
|
+
onClick();
|
|
697
|
+
});
|
|
698
|
+
card.appendChild(btn);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Show the input node's current values inline ("phase = 2"), so the user sees
|
|
702
|
+
// what the next run will use without opening the dialog. Reads live
|
|
703
|
+
// appInputValues (declared defaults + any saved/overridden value); refreshed on
|
|
704
|
+
// every markSpecialNodes — which openInputsDialog calls right after a change.
|
|
705
|
+
function addNodeInputs(card) {
|
|
706
|
+
const vals = currentInputs();
|
|
707
|
+
const keys = Object.keys(vals);
|
|
708
|
+
if (!keys.length) return;
|
|
709
|
+
const wrap = document.createElement('div');
|
|
710
|
+
wrap.className = 'node-inputs';
|
|
711
|
+
wrap.innerHTML = keys
|
|
712
|
+
.map((k) => `<span class="ni-pair"><span class="ni-key">${escapeHtml(k)}</span><span class="ni-val">${escapeHtml(String(vals[k]))}</span></span>`)
|
|
713
|
+
.join('');
|
|
714
|
+
card.appendChild(wrap);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Flag report + input nodes on the canvas and give each a visible action
|
|
718
|
+
// button — "View report ▸" / "Set inputs ▸" — so the affordance is first-class,
|
|
719
|
+
// not a hidden double-click. (Double-click still works via the delegated handler.)
|
|
720
|
+
// The input node also gets an at-a-glance read-out of its current values.
|
|
721
|
+
function markSpecialNodes() {
|
|
722
|
+
const rid = reportNodeId();
|
|
723
|
+
const iid = inputNodeId();
|
|
724
|
+
document.querySelectorAll('.agent-card').forEach((card) => {
|
|
725
|
+
const id = card.dataset.agentId;
|
|
726
|
+
const isReport = !!rid && id === rid;
|
|
727
|
+
const isInput = !!iid && id === iid;
|
|
728
|
+
card.classList.toggle('report-node', isReport);
|
|
729
|
+
card.classList.toggle('input-node', isInput);
|
|
730
|
+
// clear anything we injected last render, so re-renders never stack
|
|
731
|
+
card.querySelectorAll('.node-action, .node-inputs').forEach((b) => b.remove());
|
|
732
|
+
if (isInput) addNodeInputs(card); // current values, above the button
|
|
733
|
+
if (isReport) addNodeAction(card, 'View report ▸', () => showReport(id));
|
|
734
|
+
if (isInput) addNodeAction(card, 'Set inputs ▸', () => openInputsDialog());
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Per-app declared-input values, set via the input node's double-click dialog.
|
|
739
|
+
// Seeded from each input's declared default so the app is runnable with no action.
|
|
740
|
+
// (The old above-canvas inputs strip was removed — inputs live on the node now.)
|
|
741
|
+
const appInputValues = new Map(); // appId -> { name: value }
|
|
742
|
+
|
|
743
|
+
// Dirty tracking for input VALUES (never the .flo/lock). dirtyBaseline holds a
|
|
744
|
+
// canonical snapshot of an app's inputs at the moment they were last seeded or
|
|
745
|
+
// saved; "dirty" = the live values diverge from it. Comparing to a captured
|
|
746
|
+
// baseline (not raw localStorage) means defaults-untouched reads clean, and
|
|
747
|
+
// editing a value back to its saved value clears dirty again.
|
|
748
|
+
const dirtyBaseline = new Map(); // appId -> canonical JSON string
|
|
749
|
+
function canonicalInputs(obj) {
|
|
750
|
+
const o = obj || {};
|
|
751
|
+
return JSON.stringify(Object.keys(o).sort().map((k) => [k, o[k]]));
|
|
752
|
+
}
|
|
753
|
+
function setInputBaseline(id) { if (id) dirtyBaseline.set(id, canonicalInputs(appInputValues.get(id))); }
|
|
754
|
+
function isInputsDirty(id) {
|
|
755
|
+
return !!id && dirtyBaseline.has(id) && canonicalInputs(appInputValues.get(id)) !== dirtyBaseline.get(id);
|
|
756
|
+
}
|
|
757
|
+
// Throw away unsaved edits: restore the live values to the baseline snapshot.
|
|
758
|
+
function revertInputs(id) {
|
|
759
|
+
if (!id || !dirtyBaseline.has(id)) return;
|
|
760
|
+
appInputValues.set(id, Object.fromEntries(JSON.parse(dirtyBaseline.get(id))));
|
|
761
|
+
}
|
|
762
|
+
// Toggle the unsaved-changes indicator (header dot + menu Save item) for the
|
|
763
|
+
// active app. Cheap; safe to call on every input change, save, and app load.
|
|
764
|
+
function refreshDirtyIndicator() {
|
|
765
|
+
const dirty = isInputsDirty(currentId);
|
|
766
|
+
const dot = document.getElementById('wf-dirty-dot');
|
|
767
|
+
if (dot) dot.hidden = !dirty;
|
|
768
|
+
const saveItem = document.querySelector('.menu-item[data-action="save"]');
|
|
769
|
+
if (saveItem) saveItem.classList.toggle('menu-item-dirty', dirty);
|
|
770
|
+
}
|
|
771
|
+
// Refresh/close guard: unsaved input values live only in memory, so warn before
|
|
772
|
+
// the page unloads while dirty (the browser shows its own generic confirm).
|
|
773
|
+
window.addEventListener('beforeunload', (e) => {
|
|
774
|
+
if (isInputsDirty(currentId)) { e.preventDefault(); e.returnValue = ''; }
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
function seedAppInputs(app) {
|
|
778
|
+
if (app && Array.isArray(app.inputs) && app.inputs.length) {
|
|
779
|
+
if (!appInputValues.has(app.id)) {
|
|
780
|
+
const seed = {};
|
|
781
|
+
app.inputs.forEach((inp) => { if (inp.default != null) seed[inp.name] = inp.default; });
|
|
782
|
+
// Overlay explicitly-saved values ("Save inputs"), but only for inputs the
|
|
783
|
+
// .flo still declares (a renamed/removed input is dropped) and only after
|
|
784
|
+
// coercing to the declared type — mirrors the inputs dialog, so a tampered
|
|
785
|
+
// or stale localStorage value can't feed a bad type to `aware app run`.
|
|
786
|
+
const saved = loadSavedInputs(app.id);
|
|
787
|
+
if (saved) {
|
|
788
|
+
app.inputs.forEach((inp) => {
|
|
789
|
+
if (!(inp.name in saved)) return;
|
|
790
|
+
let v = saved[inp.name];
|
|
791
|
+
if (inp.type === 'integer' || inp.type === 'number') {
|
|
792
|
+
const num = Number(v);
|
|
793
|
+
if (Number.isNaN(num)) return; // keep the default rather than forward garbage
|
|
794
|
+
v = num;
|
|
795
|
+
}
|
|
796
|
+
seed[inp.name] = v;
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
appInputValues.set(app.id, seed);
|
|
800
|
+
setInputBaseline(app.id); // first open this session → clean baseline
|
|
801
|
+
} else {
|
|
802
|
+
// Drop values whose declared input was renamed/removed since last seed.
|
|
803
|
+
const names = new Set(app.inputs.map((i) => i.name));
|
|
804
|
+
const cur = appInputValues.get(app.id);
|
|
805
|
+
Object.keys(cur).forEach((k) => { if (!names.has(k)) delete cur[k]; });
|
|
806
|
+
// Prune the baseline by the same key set so a .flo schema change (rename/
|
|
807
|
+
// remove) isn't mistaken for a user edit. Unchanged schema → no-op; edits to
|
|
808
|
+
// surviving keys stay dirty.
|
|
809
|
+
if (dirtyBaseline.has(app.id)) {
|
|
810
|
+
const base = Object.fromEntries(JSON.parse(dirtyBaseline.get(app.id)).filter(([k]) => names.has(k)));
|
|
811
|
+
dirtyBaseline.set(app.id, canonicalInputs(base));
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Current declared-input values for the active app (a copy).
|
|
818
|
+
function currentInputs() {
|
|
819
|
+
return { ...(appInputValues.get(currentId) || {}) };
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Save inputs (Ctrl+S / menu): persist this app's current input values locally so
|
|
823
|
+
// they survive refresh + reopen. Pure UI state — the .flo and lock are untouched,
|
|
824
|
+
// so Run stays armed. Apps with no declared inputs have nothing to save.
|
|
825
|
+
function saveCurrentInputs() {
|
|
826
|
+
const app = currentId && apps.get(currentId);
|
|
827
|
+
if (!app) { showToast('no app open', 'info'); return; }
|
|
828
|
+
if (!Array.isArray(app.inputs) || !app.inputs.length) {
|
|
829
|
+
showToast('this workflow has no inputs to save', 'info');
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
try {
|
|
833
|
+
localStorage.setItem(lsInputsKey(app.id), JSON.stringify(appInputValues.get(app.id) || {}));
|
|
834
|
+
setInputBaseline(app.id); // saved → this is the new clean baseline
|
|
835
|
+
refreshDirtyIndicator();
|
|
836
|
+
showToast(`Saved inputs · ${app.displayName}`, 'ok');
|
|
837
|
+
} catch {
|
|
838
|
+
showToast('could not save inputs', 'err');
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// Double-click the input node → styled dialog to set the declared inputs. Writes
|
|
843
|
+
// to appInputValues; the header "▶ Run workflow" uses them. No native window.prompt.
|
|
844
|
+
async function openInputsDialog() {
|
|
845
|
+
const app = currentId && apps.get(currentId);
|
|
846
|
+
if (!app || !Array.isArray(app.inputs) || !app.inputs.length) return;
|
|
847
|
+
const cur = currentInputs();
|
|
848
|
+
const fields = app.inputs.map((inp) => ({
|
|
849
|
+
name: inp.name,
|
|
850
|
+
label: inp.name + (inp.description ? ` — ${inp.description}` : ''),
|
|
851
|
+
type: inp.type === 'integer' || inp.type === 'number' ? 'number' : 'text',
|
|
852
|
+
value: cur[inp.name] != null ? cur[inp.name] : inp.default != null ? inp.default : '',
|
|
853
|
+
}));
|
|
854
|
+
const res = await formModal({ title: `Inputs · ${app.displayName}`, sub: 'Set the values this run uses, then ▶ Run workflow.', fields, okLabel: 'Set inputs' });
|
|
855
|
+
if (!res) return;
|
|
856
|
+
const store = {};
|
|
857
|
+
app.inputs.forEach((inp) => {
|
|
858
|
+
let v = String(res[inp.name] ?? '').trim();
|
|
859
|
+
if (v === '') { if (inp.default != null) v = String(inp.default); else return; }
|
|
860
|
+
if (inp.type === 'integer' || inp.type === 'number') { const num = Number(v); if (!Number.isNaN(num)) { store[inp.name] = num; return; } }
|
|
861
|
+
store[inp.name] = v;
|
|
862
|
+
});
|
|
863
|
+
appInputValues.set(currentId, store);
|
|
864
|
+
markSpecialNodes();
|
|
865
|
+
refreshDirtyIndicator(); // values changed → reflect unsaved state in the header/menu
|
|
866
|
+
const badge = Object.entries(store).map(([k, v]) => `${k}=${v}`).join(' · ');
|
|
867
|
+
appendNarration(`Inputs set — <strong>${escapeHtml(badge)}</strong>. Run with <strong>▶ Run workflow</strong>.`);
|
|
868
|
+
showToast('Inputs set: ' + badge, 'ok');
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
let reportRunning = false;
|
|
872
|
+
// Set when the user hits Stop. api() throws a bare Error on the cancelled
|
|
873
|
+
// response (the {cancelled:true} flag is lost), so we read this flag in the
|
|
874
|
+
// run catch blocks to show "cancelled" rather than a misleading "run failed".
|
|
875
|
+
let cancelRequested = false;
|
|
876
|
+
// The most recent rendered report per app id: { nodeId, label, html }. Lets a
|
|
877
|
+
// double-click LOAD the last result instantly instead of re-running.
|
|
878
|
+
const lastReportByApp = new Map();
|
|
879
|
+
|
|
880
|
+
// Stop the in-flight run (the escape hatch for a hung/unattached host). Marks
|
|
881
|
+
// cancelRequested so the catch shows "cancelled", clears the canvas running
|
|
882
|
+
// pulse, and asks the server to kill the `aware app run` child. The in-flight
|
|
883
|
+
// /api/run then rejects via api() and the catch renders the cancelled state.
|
|
884
|
+
async function stopRun() {
|
|
885
|
+
if (!reportRunning && !state.running) return;
|
|
886
|
+
cancelRequested = true;
|
|
887
|
+
clearNodeStatus();
|
|
888
|
+
const stopBtn = document.querySelector('.overlay-stop');
|
|
889
|
+
if (stopBtn) { stopBtn.disabled = true; stopBtn.textContent = 'Cancelling…'; }
|
|
890
|
+
try { await api('/api/run/stop', { method: 'POST' }); } catch { /* the run's own catch surfaces it */ }
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function paintReport(html) { $reportFrame.srcdoc = html; $reportOverlay.hidden = true; $reportOverlay.innerHTML = ''; }
|
|
894
|
+
|
|
895
|
+
// Double-click the HTML Viewer node → LOAD the last report (no run; running is
|
|
896
|
+
// the header "▶ Run workflow" button's job). Shows a prompt if nothing has run yet.
|
|
897
|
+
function showReport(nodeId) {
|
|
898
|
+
const app = currentId && apps.get(currentId);
|
|
899
|
+
if (!app) return;
|
|
900
|
+
$reportModal.dataset.nodeId = nodeId;
|
|
901
|
+
$reportTitle.textContent = `HTML Viewer · ${app.displayName}`;
|
|
902
|
+
showModal($reportModal);
|
|
903
|
+
if (reportRunning) return; // a run is in flight — its overlay already shows progress
|
|
904
|
+
const cached = lastReportByApp.get(currentId);
|
|
905
|
+
if (cached && cached.html) {
|
|
906
|
+
$reportSub.innerHTML = `Last report${cached.label ? ' · ' + escapeHtml(cached.label) : ''} — rendered from the run's output, never composed by the UI.`;
|
|
907
|
+
paintReport(cached.html);
|
|
908
|
+
} else {
|
|
909
|
+
$reportFrame.srcdoc = '';
|
|
910
|
+
$reportOverlay.hidden = false;
|
|
911
|
+
$reportOverlay.innerHTML = `<div>No report yet. Click <strong>▶ Run workflow</strong> (top right) to run it against the live model, then double-click to view it any time.</div>`;
|
|
912
|
+
$reportSub.textContent = 'Double-click loads the last report; ▶ Run workflow generates a fresh one.';
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// The dedicated RUN: executes the app for real (simulate:false) with the
|
|
917
|
+
// current inputs, renders + caches the HTML the report node returned. The UI
|
|
918
|
+
// never builds the HTML — it relays exactly what the exec node produced.
|
|
919
|
+
async function runReport(nodeId, opts = {}) {
|
|
920
|
+
const app = currentId && apps.get(currentId);
|
|
921
|
+
if (!app) return;
|
|
922
|
+
if (!app.runnable) { showToast('Compile the workflow first — Run is gated on a fresh lock.', 'warn'); return; }
|
|
923
|
+
if (reportRunning) return;
|
|
924
|
+
reportRunning = true;
|
|
925
|
+
cancelRequested = false;
|
|
926
|
+
markCanvasRunning(); // paints behind the modal; visible once it's closed
|
|
927
|
+
|
|
928
|
+
const inputs = currentInputs();
|
|
929
|
+
const inputBadge = Object.entries(inputs).map(([k, v]) => `${k}=${v}`).join(' · ');
|
|
930
|
+
$reportTitle.textContent = `HTML Viewer · ${app.displayName}`;
|
|
931
|
+
$reportSub.innerHTML = `Live run of <code>${escapeHtml(nodeId)}</code>${inputBadge ? ' · ' + escapeHtml(inputBadge) : ''} — rendered from the node's output, never composed by the UI.`;
|
|
932
|
+
$reportFrame.srcdoc = ''; // clear any previously rendered report while this run is in flight
|
|
933
|
+
$reportOverlay.hidden = false;
|
|
934
|
+
$reportOverlay.innerHTML = opts.debug
|
|
935
|
+
? `<div class="spinner"></div><div>Launching the .NET debugger for <code>${escapeHtml(nodeId)}</code> — a Windows picker will pop; choose your <strong>Visual Studio</strong> to attach to <code>aware-tekla.exe</code>, then step through. The report renders when you let it finish.</div>`
|
|
936
|
+
: `<div class="spinner"></div><div>Running <code>${escapeHtml(currentId)}</code> against the live model…</div><button type="button" class="overlay-stop">■ Stop run</button>`;
|
|
937
|
+
showModal($reportModal);
|
|
938
|
+
$reportModal.dataset.nodeId = nodeId;
|
|
939
|
+
|
|
940
|
+
try {
|
|
941
|
+
// Debug-in-VS goes straight to the host bridge (Debugger.Launch injected),
|
|
942
|
+
// so VS attaches to aware-tekla.exe; a normal run goes through aware app run.
|
|
943
|
+
const res = opts.debug
|
|
944
|
+
? await api('/api/debug-node', { method: 'POST', body: JSON.stringify({ id: currentId, nodeId, inputs }) })
|
|
945
|
+
: await api('/api/run', { method: 'POST', body: JSON.stringify({ id: currentId, simulate: false, inputs }) });
|
|
946
|
+
// Reflect the run in the Execution tab too.
|
|
947
|
+
if (Array.isArray(res.events)) { liveTrace.length = 0; res.events.forEach(pushTrace); state.hasRun = true; if (state.currentTab === 'execution') renderInspect(); }
|
|
948
|
+
const html = res.report && res.report.html;
|
|
949
|
+
if (!html) {
|
|
950
|
+
$reportOverlay.innerHTML = `<div>Run completed but <code>${escapeHtml(nodeId)}</code> returned no <code>html</code>. Check the Execution tab.</div>`;
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
paintReport(html);
|
|
954
|
+
lastReportByApp.set(currentId, { nodeId, label: inputBadge, html });
|
|
955
|
+
$reportModal.dataset.html = '1';
|
|
956
|
+
appendNarration(`Rendered the report from <strong>${escapeHtml(nodeId)}</strong>${inputBadge ? ' (' + escapeHtml(inputBadge) + ')' : ''} — live run against the model.`);
|
|
957
|
+
} catch (e) {
|
|
958
|
+
const msg = e && e.message ? e.message : String(e);
|
|
959
|
+
if (cancelRequested) {
|
|
960
|
+
// User hit Stop — an expected outcome, not a failure. A node already
|
|
961
|
+
// executing on the host may still finish; the run result is discarded.
|
|
962
|
+
clearNodeStatus(); // wipe any status painted before the flag was set
|
|
963
|
+
$reportOverlay.innerHTML = `<div>Run cancelled. A node already running on the host may still finish there; click <strong>▶ Run workflow</strong> to run again.</div>`;
|
|
964
|
+
showToast('Run cancelled', 'info');
|
|
965
|
+
} else {
|
|
966
|
+
// Expected, recoverable failure (host not attached, stale lock, …): show it
|
|
967
|
+
// in the viewer overlay + a toast. No console.error — the server returns the
|
|
968
|
+
// failure in-band, so a failed run isn't a console-worthy fault.
|
|
969
|
+
$reportOverlay.innerHTML = `<div>Run failed: ${escapeHtml(msg)}. Check the host is attached and the lock is fresh.</div>`;
|
|
970
|
+
showToast('Run failed: ' + msg, 'warn');
|
|
971
|
+
}
|
|
972
|
+
} finally {
|
|
973
|
+
reportRunning = false;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function showModal(el) { el.classList.add('show'); }
|
|
978
|
+
function hideModal(el) { el.classList.remove('show'); }
|
|
979
|
+
|
|
980
|
+
// Save-on-switch confirmation. Resolves with 'save' | 'discard' | 'cancel'.
|
|
981
|
+
// Save is the primary (Enter); Esc and the backdrop both cancel (the safe,
|
|
982
|
+
// non-destructive default — never lose edits to a stray key/click).
|
|
983
|
+
const $confirmModal = document.getElementById('confirm-modal');
|
|
984
|
+
function confirmUnsavedSwitch(appName) {
|
|
985
|
+
return new Promise((resolve) => {
|
|
986
|
+
const $sub = document.getElementById('confirm-modal-sub');
|
|
987
|
+
const $save = document.getElementById('confirm-save');
|
|
988
|
+
const $dont = document.getElementById('confirm-dont-save');
|
|
989
|
+
const $cancel = document.getElementById('confirm-cancel');
|
|
990
|
+
$sub.textContent = `You changed the inputs for “${appName}” but haven’t saved them yet. What would you like to do?`;
|
|
991
|
+
showModal($confirmModal);
|
|
992
|
+
setTimeout(() => $save.focus(), 0);
|
|
993
|
+
const done = (result) => {
|
|
994
|
+
hideModal($confirmModal);
|
|
995
|
+
$save.onclick = $dont.onclick = $cancel.onclick = $confirmModal.onclick = null;
|
|
996
|
+
document.removeEventListener('keydown', onKey, true);
|
|
997
|
+
resolve(result);
|
|
998
|
+
};
|
|
999
|
+
const onKey = (e) => {
|
|
1000
|
+
if (e.key === 'Escape') { e.preventDefault(); done('cancel'); }
|
|
1001
|
+
else if (e.key === 'Enter') { e.preventDefault(); done('save'); }
|
|
1002
|
+
};
|
|
1003
|
+
$save.onclick = () => done('save');
|
|
1004
|
+
$dont.onclick = () => done('discard');
|
|
1005
|
+
$cancel.onclick = () => done('cancel');
|
|
1006
|
+
$confirmModal.onclick = (e) => { if (e.target === $confirmModal) done('cancel'); };
|
|
1007
|
+
document.addEventListener('keydown', onKey, true);
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// Styled replacement for window.prompt — matches the app's modal style/scale.
|
|
1012
|
+
// `fields`: [{name,label,type,value,placeholder,multiline}]. Resolves with a
|
|
1013
|
+
// { name: value } object on Save (Enter), or null on Cancel (Esc / backdrop).
|
|
1014
|
+
const $formModal = document.getElementById('form-modal');
|
|
1015
|
+
function formModal({ title, sub = '', fields, okLabel = 'Save' }) {
|
|
1016
|
+
return new Promise((resolve) => {
|
|
1017
|
+
const $title = document.getElementById('form-modal-title');
|
|
1018
|
+
const $sub = document.getElementById('form-modal-sub');
|
|
1019
|
+
const $body = document.getElementById('form-modal-body');
|
|
1020
|
+
const $ok = document.getElementById('form-modal-ok');
|
|
1021
|
+
const $cancel = document.getElementById('form-modal-cancel');
|
|
1022
|
+
$title.textContent = title;
|
|
1023
|
+
$sub.textContent = sub || '';
|
|
1024
|
+
$sub.hidden = !sub;
|
|
1025
|
+
$body.innerHTML = fields.map((f) => {
|
|
1026
|
+
const id = `fm-${f.name}`;
|
|
1027
|
+
const val = f.value != null ? String(f.value) : '';
|
|
1028
|
+
const ph = escapeHtml(f.placeholder || '');
|
|
1029
|
+
let ctl;
|
|
1030
|
+
if (f.type === 'images') {
|
|
1031
|
+
ctl = `<div class="fm-images" data-fm-images="${escapeHtml(f.name)}">
|
|
1032
|
+
<div class="fm-drop" role="button" tabindex="0" aria-label="Paste a screenshot or click to attach">Paste a screenshot (Ctrl+V) or click to add<input type="file" accept="image/*" multiple class="fm-file-input" tabindex="-1" aria-hidden="true"></div>
|
|
1033
|
+
<div class="fm-thumbs" hidden></div>
|
|
1034
|
+
</div>`;
|
|
1035
|
+
} else {
|
|
1036
|
+
ctl = f.multiline
|
|
1037
|
+
? `<textarea id="${id}" rows="4" data-fm="${escapeHtml(f.name)}" placeholder="${ph}">${escapeHtml(val)}</textarea>`
|
|
1038
|
+
: `<input id="${id}" type="${f.type || 'text'}"${f.type === 'number' ? ' step="1"' : ''} data-fm="${escapeHtml(f.name)}" value="${escapeHtml(val)}" placeholder="${ph}">`;
|
|
1039
|
+
}
|
|
1040
|
+
return `<div class="modal-field"><label for="${id}">${escapeHtml(f.label || f.name)}</label>${ctl}</div>`;
|
|
1041
|
+
}).join('');
|
|
1042
|
+
$ok.textContent = okLabel;
|
|
1043
|
+
showModal($formModal);
|
|
1044
|
+
const first = $body.querySelector('input:not(.fm-file-input),textarea');
|
|
1045
|
+
if (first) { first.focus(); if (first.select) first.select(); }
|
|
1046
|
+
// ── images-field setup ──────────────────────────────────────────────────────
|
|
1047
|
+
const imageStates = new Map();
|
|
1048
|
+
$body.querySelectorAll('.fm-images').forEach((box) => {
|
|
1049
|
+
const name = box.dataset.fmImages;
|
|
1050
|
+
const state = [];
|
|
1051
|
+
const MAX = 8;
|
|
1052
|
+
const thumbs = box.querySelector('.fm-thumbs');
|
|
1053
|
+
const drop = box.querySelector('.fm-drop');
|
|
1054
|
+
const fileInput = box.querySelector('.fm-file-input');
|
|
1055
|
+
const renderThumbs = () => {
|
|
1056
|
+
thumbs.hidden = state.length === 0;
|
|
1057
|
+
thumbs.innerHTML = state.map((im, i) =>
|
|
1058
|
+
`<div class="fm-thumb"><img src="${escapeAttr(im.dataUrl)}" alt="${escapeAttr(im.name || 'screenshot ' + (i + 1))}"><button type="button" class="fm-thumb-del" data-i="${i}" aria-label="Remove">×</button></div>`
|
|
1059
|
+
).join('');
|
|
1060
|
+
thumbs.querySelectorAll('.fm-thumb-del').forEach((b) => {
|
|
1061
|
+
b.onclick = () => { state.splice(Number(b.dataset.i), 1); renderThumbs(); };
|
|
1062
|
+
});
|
|
1063
|
+
};
|
|
1064
|
+
const addFile = (file) => {
|
|
1065
|
+
if (!file || !file.type.startsWith('image/')) return;
|
|
1066
|
+
if (state.length >= MAX) { showToast(`Max ${MAX} snapshots`, 'warn'); return; }
|
|
1067
|
+
const reader = new FileReader();
|
|
1068
|
+
reader.onload = () => { state.push({ name: file.name, dataUrl: reader.result }); renderThumbs(); };
|
|
1069
|
+
reader.readAsDataURL(file);
|
|
1070
|
+
};
|
|
1071
|
+
imageStates.set(name, { state, renderThumbs });
|
|
1072
|
+
drop.onclick = () => fileInput.click();
|
|
1073
|
+
drop.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); } };
|
|
1074
|
+
fileInput.onchange = () => {
|
|
1075
|
+
const picked = Array.from(fileInput.files || []);
|
|
1076
|
+
const room = Math.max(0, MAX - state.length);
|
|
1077
|
+
if (picked.length > room) showToast(`Max ${MAX} snapshots`, 'warn');
|
|
1078
|
+
picked.slice(0, room).forEach(addFile);
|
|
1079
|
+
fileInput.value = '';
|
|
1080
|
+
};
|
|
1081
|
+
});
|
|
1082
|
+
// ── paste support: paste an image into the modal → first images field ──────
|
|
1083
|
+
$body.onpaste = (e) => {
|
|
1084
|
+
const box = $body.querySelector('.fm-images');
|
|
1085
|
+
if (!box) return;
|
|
1086
|
+
const items = Array.from((e.clipboardData && e.clipboardData.items) || []);
|
|
1087
|
+
const imgs = items.filter((it) => it.kind === 'file' && it.type.startsWith('image/'));
|
|
1088
|
+
if (!imgs.length) return;
|
|
1089
|
+
e.preventDefault();
|
|
1090
|
+
const name = box.dataset.fmImages;
|
|
1091
|
+
const entry = imageStates.get(name);
|
|
1092
|
+
if (!entry) return;
|
|
1093
|
+
const { state, renderThumbs } = entry;
|
|
1094
|
+
const room = Math.max(0, 8 - state.length);
|
|
1095
|
+
if (imgs.length > room) showToast('Max 8 snapshots', 'warn');
|
|
1096
|
+
imgs.slice(0, room).forEach((it) => {
|
|
1097
|
+
const file = it.getAsFile();
|
|
1098
|
+
if (!file) return;
|
|
1099
|
+
const reader = new FileReader();
|
|
1100
|
+
reader.onload = () => { state.push({ name: file.name || 'pasted.png', dataUrl: reader.result }); renderThumbs(); };
|
|
1101
|
+
reader.readAsDataURL(file);
|
|
1102
|
+
});
|
|
1103
|
+
};
|
|
1104
|
+
const collect = () => {
|
|
1105
|
+
const out = {};
|
|
1106
|
+
$body.querySelectorAll('[data-fm]').forEach((el) => { out[el.dataset.fm] = el.value; });
|
|
1107
|
+
imageStates.forEach((entry, name) => { out[name] = entry.state; });
|
|
1108
|
+
return out;
|
|
1109
|
+
};
|
|
1110
|
+
const done = (result) => {
|
|
1111
|
+
hideModal($formModal);
|
|
1112
|
+
$ok.onclick = null; $cancel.onclick = null; $formModal.onclick = null; $body.onkeydown = null; $body.onpaste = null;
|
|
1113
|
+
resolve(result);
|
|
1114
|
+
};
|
|
1115
|
+
$ok.onclick = () => done(collect());
|
|
1116
|
+
$cancel.onclick = () => done(null);
|
|
1117
|
+
$formModal.onclick = (e) => { if (e.target === $formModal) done(null); };
|
|
1118
|
+
$body.onkeydown = (e) => {
|
|
1119
|
+
if (e.key === 'Enter' && e.target.tagName !== 'TEXTAREA') { e.preventDefault(); done(collect()); }
|
|
1120
|
+
else if (e.key === 'Escape') { e.preventDefault(); done(null); }
|
|
1121
|
+
};
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// Double-click a node on the canvas: the report node → LOAD the last report (no
|
|
1126
|
+
// run); the input node → open the styled inputs dialog. Delegated + bound once;
|
|
1127
|
+
// survives every renderTopology that rebuilds the cards.
|
|
1128
|
+
$topology.addEventListener('dblclick', (e) => {
|
|
1129
|
+
const card = e.target.closest('.agent-card');
|
|
1130
|
+
if (!card) return;
|
|
1131
|
+
const id = card.dataset.agentId;
|
|
1132
|
+
if (!id) return;
|
|
1133
|
+
if (id === reportNodeId()) showReport(id);
|
|
1134
|
+
else if (id === inputNodeId()) openInputsDialog();
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
$reportClose.onclick = () => hideModal($reportModal);
|
|
1138
|
+
$reportModal.addEventListener('click', (e) => { if (e.target === $reportModal) hideModal($reportModal); });
|
|
1139
|
+
// The Stop button is rebuilt into the overlay each run — delegate so one
|
|
1140
|
+
// listener survives every innerHTML swap.
|
|
1141
|
+
$reportOverlay.addEventListener('click', (e) => { if (e.target.closest('.overlay-stop')) stopRun(); });
|
|
1142
|
+
$reportOpen.onclick = () => {
|
|
1143
|
+
const html = $reportFrame.srcdoc;
|
|
1144
|
+
if (!html) return;
|
|
1145
|
+
// Open via a blob: URL — an opaque origin, so the report can't script the
|
|
1146
|
+
// app origin (mirrors the iframe's sandbox intent).
|
|
1147
|
+
const url = URL.createObjectURL(new Blob([html], { type: 'text/html' }));
|
|
1148
|
+
window.open(url, '_blank');
|
|
1149
|
+
setTimeout(() => URL.revokeObjectURL(url), 30000);
|
|
1150
|
+
};
|
|
1151
|
+
$promptSel.onchange = () => { loadApp($promptSel.value).catch(reportErr); };
|
|
1152
|
+
|
|
1153
|
+
$compileBtn.onclick = async () => {
|
|
1154
|
+
if (!currentId || $compileBtn.disabled) return;
|
|
1155
|
+
const id = currentId;
|
|
1156
|
+
$compileBtn.disabled = true;
|
|
1157
|
+
const prev = $compileBtn.textContent;
|
|
1158
|
+
$compileBtn.textContent = '⎙ Compiling…';
|
|
1159
|
+
try {
|
|
1160
|
+
await api('/api/compile', { method: 'POST', body: JSON.stringify({ id }) });
|
|
1161
|
+
await loadApp(id);
|
|
1162
|
+
const app = apps.get(id);
|
|
1163
|
+
appendNarration(app.runnable
|
|
1164
|
+
? `Compiled · <code>${escapeHtml(id)}.lock</code> frozen at source-hash. Run is armed.`
|
|
1165
|
+
: `Compiled, but ${app.notes.length} note(s) remain — review the strip.`);
|
|
1166
|
+
showToast('Compiled — lock frozen', 'ok');
|
|
1167
|
+
} catch (e) {
|
|
1168
|
+
$compileBtn.textContent = prev;
|
|
1169
|
+
$compileBtn.disabled = false;
|
|
1170
|
+
reportErr(e);
|
|
1171
|
+
}
|
|
1172
|
+
};
|
|
1173
|
+
|
|
1174
|
+
// ── Bake into agent ──────────────────────────────────────────────────────────
|
|
1175
|
+
// Open a preview/confirm modal (agent name + the inputs it will expose, mirrored
|
|
1176
|
+
// 1:1 from the app's declared inputs), then POST /api/bake. The action is
|
|
1177
|
+
// subscription-gated server-side (402 → the standard sign-in prompt); the UI just
|
|
1178
|
+
// triggers the verb and renders the outcome. Thin-UI: composes nothing itself.
|
|
1179
|
+
const $bakeModal = document.getElementById('bake-modal');
|
|
1180
|
+
function openBakeModal() {
|
|
1181
|
+
if (!currentId || ($menuBakeItem && $menuBakeItem.disabled)) return;
|
|
1182
|
+
const app = apps.get(currentId);
|
|
1183
|
+
if (!app) return;
|
|
1184
|
+
document.getElementById('bake-title').textContent = app.baked ? 'Re-bake agent' : 'Bake into agent';
|
|
1185
|
+
const inps = app.inputs || [];
|
|
1186
|
+
const inputsHtml = inps.length
|
|
1187
|
+
? inps.map((i) => `<span class="ni-pair"><span class="ni-key">${escapeHtml(i.name)}</span><span class="ni-val">${escapeHtml(i.type)}</span></span>`).join('')
|
|
1188
|
+
: '<span class="bake-noinputs">No inputs — this agent is called with no parameters.</span>';
|
|
1189
|
+
// Rebuild the whole body each open — a prior runBake() replaces it with the
|
|
1190
|
+
// spinner (destroying #bake-name/#bake-inputs), so we can't assume they exist.
|
|
1191
|
+
const $body = document.getElementById('bake-body');
|
|
1192
|
+
$body.classList.remove('busy');
|
|
1193
|
+
$body.innerHTML =
|
|
1194
|
+
`<div class="modal-field"><label>Agent name</label><div id="bake-name" class="bake-name"><code>${escapeHtml(app.id)}</code></div></div>` +
|
|
1195
|
+
`<div class="modal-field"><label>Agent inputs</label><div id="bake-inputs" class="bake-inputs">${inputsHtml}</div></div>`;
|
|
1196
|
+
const $confirm = document.getElementById('bake-confirm');
|
|
1197
|
+
const $cancel = document.getElementById('bake-cancel');
|
|
1198
|
+
$confirm.disabled = false; $cancel.disabled = false;
|
|
1199
|
+
$confirm.textContent = '⊙ Bake';
|
|
1200
|
+
showModal($bakeModal);
|
|
1201
|
+
setTimeout(() => $confirm.focus(), 0);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
async function runBake() {
|
|
1205
|
+
const id = currentId;
|
|
1206
|
+
if (!id) return;
|
|
1207
|
+
const $confirm = document.getElementById('bake-confirm');
|
|
1208
|
+
const $cancel = document.getElementById('bake-cancel');
|
|
1209
|
+
const $body = document.getElementById('bake-body');
|
|
1210
|
+
$confirm.disabled = true; $cancel.disabled = true;
|
|
1211
|
+
$confirm.textContent = '⊙ Baking…';
|
|
1212
|
+
$body.classList.add('busy');
|
|
1213
|
+
$body.innerHTML = '<div class="bake-progress"><span class="rtn-spinner"></span><span>Reinstalling as an agent — this takes a few seconds…</span></div>';
|
|
1214
|
+
try {
|
|
1215
|
+
await api('/api/bake', { method: 'POST', body: JSON.stringify({ id }) });
|
|
1216
|
+
hideModal($bakeModal);
|
|
1217
|
+
await loadApp(id);
|
|
1218
|
+
appendNarration(`Baked <strong>${escapeHtml(id)}</strong> as a reusable agent — find it in the Agents library (<strong>⊞ Agents</strong>).`);
|
|
1219
|
+
showToast(`Baked — now in the Agents library as “${id}”`, 'ok');
|
|
1220
|
+
} catch (e) {
|
|
1221
|
+
hideModal($bakeModal);
|
|
1222
|
+
// The server restores the original install on failure (recoverable) → warn, not err.
|
|
1223
|
+
const msg = (e && e.message ? e.message : 'Bake failed').slice(0, 80);
|
|
1224
|
+
showToast(`Bake failed: ${msg}`, 'warn');
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
document.getElementById('bake-cancel').onclick = () => hideModal($bakeModal);
|
|
1229
|
+
document.getElementById('bake-confirm').onclick = () => runBake();
|
|
1230
|
+
$bakeModal.onclick = (e) => { if (e.target === $bakeModal && !document.getElementById('bake-confirm').disabled) hideModal($bakeModal); };
|
|
1231
|
+
|
|
1232
|
+
// ── Graft into agent ────────────────────────────────────────────────────────
|
|
1233
|
+
// Build an agent from a foreign tool (DLL / C# source / NuGet / OpenAPI / …).
|
|
1234
|
+
// The server stages an `aware build` into a throwaway AWARE_HOME, we preview the
|
|
1235
|
+
// discovered commands, then commit. Thin-UI: we trigger the verb and render the
|
|
1236
|
+
// result; AWARE owns the reader + recipes (incl. the Tekla model-plugin recipe),
|
|
1237
|
+
// so we SURFACE its output and shape nothing. The wizard body is rebuilt per step.
|
|
1238
|
+
//
|
|
1239
|
+
// SAFETY: every dynamic value below is escapeHtml()-escaped (the app's standard);
|
|
1240
|
+
// markup is injected via graftFill() (replaceChildren + insertAdjacentHTML) so the
|
|
1241
|
+
// escaping discipline is explicit at each call site.
|
|
1242
|
+
const $graftModal = document.getElementById('graft-modal');
|
|
1243
|
+
const $graftBack = document.getElementById('graft-back');
|
|
1244
|
+
const $graftCancel = document.getElementById('graft-cancel');
|
|
1245
|
+
const $graftPrimary = document.getElementById('graft-primary');
|
|
1246
|
+
function graftFill(el, markup) { if (!el) return; el.replaceChildren(); if (markup) el.insertAdjacentHTML('beforeend', markup); }
|
|
1247
|
+
|
|
1248
|
+
// Featured kinds (shown first) are the AEC-relevant ones; the rest live under "More".
|
|
1249
|
+
const GRAFT_KINDS = [
|
|
1250
|
+
{ kind: 'dlls', label: '.NET / Tekla DLL', desc: 'A .dll or folder of .dlls — Tekla plugins, in-house .NET libraries', featured: true, decompile: true, match: true,
|
|
1251
|
+
field: { label: 'DLL path or glob', placeholder: 'C:\\Vendor\\Plugin\\*.dll', hint: 'Paste a path or a glob (a folder’s *.dll). Multiple assemblies load together.' } },
|
|
1252
|
+
{ kind: 'csharp', label: 'C# source', desc: 'A .cs file/folder, or a .csproj / .sln — your in-house source', featured: true,
|
|
1253
|
+
field: { label: 'C# source path', placeholder: 'C:\\src\\MyPlugin (or a .csproj / .sln)', hint: 'A .cs file, folder, .csproj or .sln. AWARE reads it with Roslyn.' } },
|
|
1254
|
+
{ kind: 'nuget', label: 'NuGet package', desc: 'A NuGet package by name and version', featured: true, decompile: true,
|
|
1255
|
+
field: { label: 'Package', placeholder: 'Tekla.Structures.Model@2025.0', hint: 'Format: Package@version' } },
|
|
1256
|
+
{ kind: 'openapi', label: 'OpenAPI / REST', desc: 'An OpenAPI 3.x spec — a URL or a local file', featured: true,
|
|
1257
|
+
field: { label: 'Spec URL or file', placeholder: 'https://api.example.com/openapi.json', hint: 'A URL, or a local .json / .yaml file path' } },
|
|
1258
|
+
{ kind: 'cli', label: 'CLI tool', desc: 'Any command-line tool on your PATH', featured: false,
|
|
1259
|
+
field: { label: 'Command name', placeholder: 'ffmpeg', hint: 'The command as it appears on your PATH' } },
|
|
1260
|
+
{ kind: 'com', label: 'COM ProgID', desc: 'A COM component by ProgID', featured: false,
|
|
1261
|
+
field: { label: 'ProgID', placeholder: 'Excel.Application', hint: 'The COM ProgID from the registry' } },
|
|
1262
|
+
{ kind: 'python', label: 'Python module', desc: 'An importable Python module', featured: false,
|
|
1263
|
+
field: { label: 'Module', placeholder: 'requests', hint: 'The importable module name' } },
|
|
1264
|
+
{ kind: 'npm', label: 'npm package', desc: 'An npm package (reflected from its .d.ts)', featured: false,
|
|
1265
|
+
field: { label: 'Package', placeholder: 'three@0.162.0', hint: 'Format: package@version' } },
|
|
1266
|
+
{ kind: 'ruby', label: 'Ruby gem', desc: 'A RubyGems package', featured: false,
|
|
1267
|
+
field: { label: 'Gem', placeholder: 'aws-sdk@3.1.0', hint: 'Format: gem@version' } },
|
|
1268
|
+
{ kind: 'yard', label: 'YARD docs', desc: 'YARD-rendered Ruby docs — URL or folder', featured: false,
|
|
1269
|
+
field: { label: 'URL or directory', placeholder: 'https://docs.mylib.com', hint: 'A YARD-doc URL or a local directory' } },
|
|
1270
|
+
{ kind: 'headers', label: 'C / C++ headers', desc: 'A C/C++ header glob', featured: false,
|
|
1271
|
+
field: { label: 'Header glob', placeholder: 'C:\\SDK\\include\\*.h', hint: 'Paste a glob or a folder path' } },
|
|
1272
|
+
];
|
|
1273
|
+
|
|
1274
|
+
let graftState = {};
|
|
1275
|
+
const graftCfg = (kind) => GRAFT_KINDS.find((k) => k.kind === kind) || GRAFT_KINDS[0];
|
|
1276
|
+
const graftBodyEl = () => document.getElementById('graft-body');
|
|
1277
|
+
function graftFooter({ backShown = false, primaryLabel = 'Introspect', primaryDisabled = false } = {}) {
|
|
1278
|
+
$graftBack.hidden = !backShown;
|
|
1279
|
+
$graftPrimary.textContent = primaryLabel;
|
|
1280
|
+
$graftPrimary.disabled = primaryDisabled;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
function openGraftModal() {
|
|
1284
|
+
graftState = { kind: 'dlls', ref: '', decompile: false, showMore: false, preview: null, stagedRef: null, onPrimary: runGraftIntrospect, onBack: null };
|
|
1285
|
+
document.getElementById('graft-title').textContent = 'Graft into agent';
|
|
1286
|
+
document.getElementById('graft-sub').textContent = 'Point at a DLL, package, or API spec — AWARE reads it and builds an agent you can drop into any workflow.';
|
|
1287
|
+
$graftBack.disabled = false; $graftCancel.disabled = false;
|
|
1288
|
+
graftRenderPicker();
|
|
1289
|
+
showModal($graftModal);
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function graftRenderPicker() {
|
|
1293
|
+
graftState.onPrimary = runGraftIntrospect;
|
|
1294
|
+
graftState.onBack = null;
|
|
1295
|
+
const cfg = graftCfg(graftState.kind);
|
|
1296
|
+
const tiles = GRAFT_KINDS.filter((k) => k.featured || graftState.showMore).map((k) =>
|
|
1297
|
+
`<button type="button" class="graft-kind${k.kind === graftState.kind ? ' selected' : ''}" data-kind="${k.kind}">` +
|
|
1298
|
+
`<div class="gk-name">${escapeHtml(k.label)}</div><div class="gk-desc">${escapeHtml(k.desc)}</div></button>`).join('');
|
|
1299
|
+
const moreBtn = graftState.showMore ? '' : '<button type="button" class="graft-more" id="graft-more">▸ More source types</button>';
|
|
1300
|
+
const decompile = cfg.decompile
|
|
1301
|
+
? `<label class="rtn-toggle" style="margin-top:4px;"><input type="checkbox" id="graft-decompile"${graftState.decompile ? ' checked' : ''}><span class="rtn-toggle-track"></span><span class="rtn-toggle-label">Include decompiled private types (slower)</span></label>`
|
|
1302
|
+
: '';
|
|
1303
|
+
graftFill(graftBodyEl(),
|
|
1304
|
+
`<div class="modal-field"><label>Source type</label><div class="graft-kinds">${tiles}</div>${moreBtn}</div>` +
|
|
1305
|
+
`<div class="modal-field"><label id="graft-field-label">${escapeHtml(cfg.field.label)}</label>` +
|
|
1306
|
+
`<div class="graft-row"><input type="text" id="graft-ref" placeholder="${escapeHtml(cfg.field.placeholder)}" value="${escapeHtml(graftState.ref)}" autocomplete="off" spellcheck="false"></div>` +
|
|
1307
|
+
`<div class="graft-hint" id="graft-hint">${escapeHtml(cfg.field.hint)}</div>` +
|
|
1308
|
+
`<div id="graft-matched"></div>${decompile}</div>`);
|
|
1309
|
+
graftBodyEl().querySelectorAll('.graft-kind').forEach((el) => { el.onclick = () => { graftState.kind = el.dataset.kind; graftRenderPicker(); }; });
|
|
1310
|
+
const more = document.getElementById('graft-more'); if (more) more.onclick = () => { graftState.showMore = true; graftRenderPicker(); };
|
|
1311
|
+
const ref = document.getElementById('graft-ref');
|
|
1312
|
+
ref.oninput = () => {
|
|
1313
|
+
graftState.ref = ref.value;
|
|
1314
|
+
graftFooter({ primaryLabel: 'Introspect', primaryDisabled: !graftState.ref.trim() });
|
|
1315
|
+
if (graftCfg(graftState.kind).match) graftMatchDebounced();
|
|
1316
|
+
};
|
|
1317
|
+
ref.onkeydown = (e) => { if (e.key === 'Enter' && graftState.ref.trim()) runGraftIntrospect(); };
|
|
1318
|
+
const dec = document.getElementById('graft-decompile'); if (dec) dec.onchange = () => { graftState.decompile = dec.checked; };
|
|
1319
|
+
graftFooter({ backShown: false, primaryLabel: 'Introspect', primaryDisabled: !graftState.ref.trim() });
|
|
1320
|
+
setTimeout(() => ref.focus(), 0);
|
|
1321
|
+
if (graftCfg(graftState.kind).match && graftState.ref.trim()) graftMatch();
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
let graftMatchTimer = null;
|
|
1325
|
+
function graftMatchDebounced() { clearTimeout(graftMatchTimer); graftMatchTimer = setTimeout(graftMatch, 350); }
|
|
1326
|
+
async function graftMatch() {
|
|
1327
|
+
const el = document.getElementById('graft-matched'); if (!el) return;
|
|
1328
|
+
const glob = graftState.ref.trim();
|
|
1329
|
+
if (!glob) { el.replaceChildren(); return; }
|
|
1330
|
+
try {
|
|
1331
|
+
const { files } = await api('/api/graft/match', { method: 'POST', body: JSON.stringify({ glob }) });
|
|
1332
|
+
if (!files || !files.length) { el.replaceChildren(); return; }
|
|
1333
|
+
graftFill(el,
|
|
1334
|
+
`<div class="graft-matched"><div class="graft-matched-h">Matched assemblies (${files.length})</div>` +
|
|
1335
|
+
`<div class="graft-matched-f">${files.map(escapeHtml).join('<br>')}</div></div>` +
|
|
1336
|
+
(files.length > 1 ? '<div class="graft-hint">All assemblies load together so AWARE can resolve references across them.</div>' : ''));
|
|
1337
|
+
} catch { el.replaceChildren(); }
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
function graftIntrospectingLabel(kind) {
|
|
1341
|
+
if (kind === 'nuget') return 'Restoring the NuGet package and reading its API…';
|
|
1342
|
+
if (kind === 'dlls' || kind === 'csharp') return 'Reading your assemblies…';
|
|
1343
|
+
if (kind === 'openapi' || kind === 'yard') return 'Fetching and reading the spec…';
|
|
1344
|
+
return 'Discovering commands…';
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
async function runGraftIntrospect() {
|
|
1348
|
+
const cfg = graftCfg(graftState.kind);
|
|
1349
|
+
graftFill(graftBodyEl(),
|
|
1350
|
+
`<div class="graft-progress"><span class="rtn-spinner"></span><span>${escapeHtml(graftIntrospectingLabel(graftState.kind))}</span></div>` +
|
|
1351
|
+
'<div class="graft-hint">Large packages can take 30–60 seconds (a NuGet restore is included).</div>');
|
|
1352
|
+
graftFooter({ backShown: false, primaryLabel: 'Introspecting…', primaryDisabled: true });
|
|
1353
|
+
try {
|
|
1354
|
+
const body = { sourceKind: graftState.kind, sourceRef: graftState.ref.trim() };
|
|
1355
|
+
if (cfg.decompile && graftState.decompile) body.decompile = true;
|
|
1356
|
+
const { preview } = await api('/api/graft/introspect', { method: 'POST', body: JSON.stringify(body) });
|
|
1357
|
+
graftState.preview = preview;
|
|
1358
|
+
graftState.stagedRef = preview.stagedRef;
|
|
1359
|
+
graftRenderPreview(preview);
|
|
1360
|
+
} catch (e) {
|
|
1361
|
+
graftRenderPicker(); // restores the entered values from graftState
|
|
1362
|
+
const msg = (e && e.message) ? e.message : 'Introspection failed';
|
|
1363
|
+
const err = document.createElement('div');
|
|
1364
|
+
err.className = 'graft-err';
|
|
1365
|
+
err.textContent = msg;
|
|
1366
|
+
graftBodyEl().appendChild(err);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
function graftPluginName(preview) {
|
|
1371
|
+
for (const c of preview.commands) {
|
|
1372
|
+
const m = (c.summary || '').match(/Insert the "([^"]+)"/);
|
|
1373
|
+
if (m) return m[1];
|
|
1374
|
+
}
|
|
1375
|
+
return null;
|
|
1376
|
+
}
|
|
1377
|
+
function graftCmdHtml(c) {
|
|
1378
|
+
const mode = (c.kind === 'write' || c.kind === 'read') ? `<span class="graft-cmd-mode ${c.kind}">${c.kind}</span>` : '';
|
|
1379
|
+
const inputs = (c.inputs && c.inputs.length)
|
|
1380
|
+
? `<div class="graft-cmd-in">${c.inputs.map((i) => `${escapeHtml(i.name)} <span style="opacity:.6">(${escapeHtml(i.type)})</span>`).join(' · ')}</div>`
|
|
1381
|
+
: '';
|
|
1382
|
+
return `<div class="graft-cmd"><span class="graft-cmd-name">${escapeHtml(c.name)}</span>${mode}${inputs}</div>`;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function graftRenderPreview(preview) {
|
|
1386
|
+
document.getElementById('graft-title').textContent = 'Review your new agent';
|
|
1387
|
+
document.getElementById('graft-sub').textContent = 'Here is what AWARE found — create it to add it to your Agents library.';
|
|
1388
|
+
const tekla = preview.recipe === 'tekla-plugin';
|
|
1389
|
+
const badge = tekla
|
|
1390
|
+
? '<div class="graft-badge tekla">⊕ Tekla plug-in recipe</div>'
|
|
1391
|
+
: '<div class="graft-badge">◈ generic reflection</div>';
|
|
1392
|
+
let hero = '';
|
|
1393
|
+
if (tekla) {
|
|
1394
|
+
const name = graftPluginName(preview) || preview.agentId;
|
|
1395
|
+
hero =
|
|
1396
|
+
`<dl class="graft-identity"><dt>Plug-in</dt><dd>${escapeHtml(name)}</dd>` +
|
|
1397
|
+
`<dt>Recipe</dt><dd>Tekla model plug-in</dd>` +
|
|
1398
|
+
`<dt>Source</dt><dd>${escapeHtml(preview.sourceRef)}</dd></dl>` +
|
|
1399
|
+
'<div class="graft-hint">The picked points become a typed <code>input-points</code> input — no dialog, no mouse.</div>';
|
|
1400
|
+
}
|
|
1401
|
+
const warnings = (preview.warnings || []).map((w) => `<div class="graft-warn">⚠ ${escapeHtml(w)}</div>`).join('');
|
|
1402
|
+
const cmds = preview.commands.length
|
|
1403
|
+
? `<div class="graft-cmds">${preview.commands.map(graftCmdHtml).join('')}</div>`
|
|
1404
|
+
: '<div class="graft-warn">No commands found in this source. It may have no public API surface, or the reader couldn’t parse it — try a different entry point or path.</div>';
|
|
1405
|
+
const skills = preview.skills ? `<div class="graft-skills">${preview.skills} skill${preview.skills === 1 ? '' : 's'} generated.</div>` : '';
|
|
1406
|
+
graftFill(graftBodyEl(),
|
|
1407
|
+
badge + hero + warnings + cmds + skills +
|
|
1408
|
+
`<div class="modal-field"><label>Agent name</label><div class="bake-name"><code>${escapeHtml(preview.agentId)}</code></div></div>`);
|
|
1409
|
+
graftState.onBack = graftRenderPicker;
|
|
1410
|
+
graftState.onPrimary = () => runGraftCommit(false);
|
|
1411
|
+
graftFooter({ backShown: true, primaryLabel: 'Create agent', primaryDisabled: preview.commands.length === 0 });
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
async function runGraftCommit(force) {
|
|
1415
|
+
if (!graftState.stagedRef) return;
|
|
1416
|
+
graftFooter({ backShown: true, primaryLabel: 'Creating…', primaryDisabled: true });
|
|
1417
|
+
$graftBack.disabled = true; $graftCancel.disabled = true;
|
|
1418
|
+
try {
|
|
1419
|
+
const body = { stagedRef: graftState.stagedRef };
|
|
1420
|
+
if (force) body.force = true;
|
|
1421
|
+
const res = await api('/api/graft/commit', { method: 'POST', body: JSON.stringify(body) });
|
|
1422
|
+
graftFill(graftBodyEl(), `<div class="graft-success"><div class="gs-icon">⊕</div><div class="gs-msg">Agent created — now in the Agents library as <code>${escapeHtml(res.agentId)}</code>.</div></div>`);
|
|
1423
|
+
$graftBack.hidden = true; $graftCancel.disabled = false; $graftPrimary.textContent = 'Done'; $graftPrimary.disabled = false;
|
|
1424
|
+
graftState.onPrimary = () => hideModal($graftModal);
|
|
1425
|
+
loadAgentCatalog().catch(() => {}); // refresh count immediately (SSE also fires for other tabs)
|
|
1426
|
+
appendNarration(`Grafted <strong>${escapeHtml(res.agentId)}</strong> from ${escapeHtml(graftState.preview.sourceRef)} — find it in the Agents library (<strong>⊞ Agents</strong>).`);
|
|
1427
|
+
showToast(`Agent created — “${res.agentId}” is now in the Agents library`, 'ok');
|
|
1428
|
+
setTimeout(() => { if ($graftModal.classList.contains('show')) hideModal($graftModal); }, 1500);
|
|
1429
|
+
} catch (e) {
|
|
1430
|
+
$graftBack.disabled = false; $graftCancel.disabled = false;
|
|
1431
|
+
const msg = (e && e.message) ? e.message : 'Create failed';
|
|
1432
|
+
if (/already (installed|exists)/i.test(msg) && !force) {
|
|
1433
|
+
// The stage is kept server-side on a collision — offer to overwrite.
|
|
1434
|
+
graftFooter({ backShown: true, primaryLabel: 'Overwrite existing', primaryDisabled: false });
|
|
1435
|
+
graftState.onPrimary = () => runGraftCommit(true);
|
|
1436
|
+
if (!graftBodyEl().querySelector('.graft-warn.collide')) {
|
|
1437
|
+
const warn = document.createElement('div');
|
|
1438
|
+
warn.className = 'graft-warn collide';
|
|
1439
|
+
warn.textContent = `⚠ ${msg} Click “Overwrite existing” to replace it.`;
|
|
1440
|
+
graftBodyEl().prepend(warn);
|
|
1441
|
+
}
|
|
1442
|
+
} else {
|
|
1443
|
+
graftFooter({ backShown: true, primaryLabel: 'Create agent', primaryDisabled: false });
|
|
1444
|
+
graftState.onPrimary = () => runGraftCommit(false);
|
|
1445
|
+
showToast(`Create failed: ${msg.slice(0, 80)}`, 'warn');
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
$graftBack.onclick = () => { if (graftState.onBack) graftState.onBack(); };
|
|
1451
|
+
$graftCancel.onclick = () => hideModal($graftModal);
|
|
1452
|
+
$graftPrimary.onclick = () => { if (graftState.onPrimary) graftState.onPrimary(); };
|
|
1453
|
+
$graftModal.onclick = (e) => { if (e.target === $graftModal && !$graftCancel.disabled) hideModal($graftModal); };
|
|
1454
|
+
|
|
1455
|
+
// Secondary entry: "⊕ Graft new agent" inside the Agents Library modal.
|
|
1456
|
+
const $libGraft = document.getElementById('lib-graft');
|
|
1457
|
+
if ($libGraft) $libGraft.onclick = () => { hideModal($libModal); openGraftModal(); };
|
|
1458
|
+
|
|
1459
|
+
// Route the ≡ menu's "Graft"/"Bake" items to their modals. handleMenuAction lives
|
|
1460
|
+
// in app.js; openGraftModal/openBakeModal live here — so wrap it (the same pattern
|
|
1461
|
+
// app.js's doOpen/doSave are reassigned from this file).
|
|
1462
|
+
const _handleMenuAction = handleMenuAction;
|
|
1463
|
+
handleMenuAction = function (action) {
|
|
1464
|
+
if (action === 'graft') { openGraftModal(); return; }
|
|
1465
|
+
if (action === 'bake') { openBakeModal(); return; }
|
|
1466
|
+
_handleMenuAction(action);
|
|
1467
|
+
};
|
|
1468
|
+
|
|
1469
|
+
// ── App self-update tag ───────────────────────────────────────────────────────
|
|
1470
|
+
// Surface updater.ts: poll GET /api/update (on load + every 6h); when a newer
|
|
1471
|
+
// installed build exists, show a footer tag → confirm → POST /api/update/apply,
|
|
1472
|
+
// which downloads + relaunches into the new version. Hidden in dev/npm (supported:false).
|
|
1473
|
+
const $appUpdate = document.getElementById('app-update');
|
|
1474
|
+
async function refreshUpdate() {
|
|
1475
|
+
if (!$appUpdate) return;
|
|
1476
|
+
try {
|
|
1477
|
+
const r = await fetch('/api/update');
|
|
1478
|
+
const d = await r.json();
|
|
1479
|
+
if (r.ok && d.supported && d.updateAvailable && d.targetVersion) {
|
|
1480
|
+
$appUpdate.textContent = '↑ Update to v' + d.targetVersion;
|
|
1481
|
+
$appUpdate.dataset.tip = 'A newer floless.app is available — click to download and relaunch into v' + d.targetVersion;
|
|
1482
|
+
$appUpdate.hidden = false;
|
|
1483
|
+
} else {
|
|
1484
|
+
$appUpdate.hidden = true; // up-to-date / unsupported (dev/npm) / feed error → no tag
|
|
1485
|
+
}
|
|
1486
|
+
} catch { $appUpdate.hidden = true; }
|
|
1487
|
+
}
|
|
1488
|
+
if ($appUpdate) {
|
|
1489
|
+
$appUpdate.onclick = async () => {
|
|
1490
|
+
const v = $appUpdate.textContent.replace(/^[^0-9]*/, ''); // "↑ Update to v0.4.1" → "0.4.1"
|
|
1491
|
+
if (!window.confirm('Download v' + v + ' and relaunch floless.app now?')) return;
|
|
1492
|
+
$appUpdate.disabled = true;
|
|
1493
|
+
$appUpdate.textContent = '↑ Updating…';
|
|
1494
|
+
try {
|
|
1495
|
+
const r = await fetch('/api/update/apply', { method: 'POST', headers: { 'content-type': 'application/json' } });
|
|
1496
|
+
const d = await r.json().catch(() => ({}));
|
|
1497
|
+
if (!r.ok || !d.ok) throw new Error(d.error || 'update failed');
|
|
1498
|
+
// Success: the server is exiting + Update.exe relaunches the new build. The health
|
|
1499
|
+
// poll flips to offline (R1 overlay), then the new version reconnects on its own.
|
|
1500
|
+
} catch (e) {
|
|
1501
|
+
$appUpdate.disabled = false;
|
|
1502
|
+
showToast('Update failed — ' + String((e && e.message) || e).slice(0, 80), 'warn');
|
|
1503
|
+
refreshUpdate();
|
|
1504
|
+
}
|
|
1505
|
+
};
|
|
1506
|
+
refreshUpdate();
|
|
1507
|
+
setInterval(refreshUpdate, 6 * 60 * 60 * 1000); // re-check every 6h so a long-running window notices
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
// ONE Run (the approved single-Run model). "▶ Run workflow" does a REAL run against
|
|
1511
|
+
// the live host, using the app inputs. If the app has a report node, it drives
|
|
1512
|
+
// the in-app HTML Viewer (renders + caches the returned HTML); otherwise it
|
|
1513
|
+
// fills the Execution trace. "Simulate" is the demoted secondary: every node is
|
|
1514
|
+
// stubbed from its output-schema, no host sidecar is contacted (AWARE v0.37) —
|
|
1515
|
+
// a composition check that runs even when the real agents aren't connected. There is
|
|
1516
|
+
// no second "Run" control anywhere else; the viewer node only LOADS the last
|
|
1517
|
+
// report on double-click.
|
|
1518
|
+
async function runApp({ simulate = false } = {}) {
|
|
1519
|
+
const app = currentId && apps.get(currentId);
|
|
1520
|
+
if (!app) return;
|
|
1521
|
+
if (!app.runnable) { showToast($runBtn.dataset.tip, 'warn'); return; }
|
|
1522
|
+
if (state.running || reportRunning) return;
|
|
1523
|
+
|
|
1524
|
+
markCanvasRunning(); // immediate during-run feedback; the trace refines per node
|
|
1525
|
+
|
|
1526
|
+
// Real run + a report node → the HTML Viewer path (renders the report,
|
|
1527
|
+
// caches it for double-click, and mirrors the run into the Execution tab).
|
|
1528
|
+
const rid = reportNodeId();
|
|
1529
|
+
if (!simulate && rid) { runReport(rid); return; }
|
|
1530
|
+
|
|
1531
|
+
state.running = true;
|
|
1532
|
+
cancelRequested = false;
|
|
1533
|
+
$runBtn.disabled = true;
|
|
1534
|
+
if ($simBtn) $simBtn.disabled = true;
|
|
1535
|
+
$runBtn.textContent = '◆ Running…';
|
|
1536
|
+
liveTrace.length = 0;
|
|
1537
|
+
state.hasRun = true;
|
|
1538
|
+
try {
|
|
1539
|
+
const res = await api('/api/run', { method: 'POST', body: JSON.stringify({ id: currentId, simulate, inputs: currentInputs() }) });
|
|
1540
|
+
// SSE usually fills liveTrace first; backfill from the response if it didn't.
|
|
1541
|
+
if (liveTrace.length === 0 && Array.isArray(res.events)) res.events.forEach(pushTrace);
|
|
1542
|
+
switchToExecution();
|
|
1543
|
+
const steps = liveTrace.filter((r) => r.kind === 'node-start').length;
|
|
1544
|
+
appendNarration(simulate
|
|
1545
|
+
? `Simulated run complete · ${steps} node${steps === 1 ? '' : 's'} stubbed (no host needed) · see the <strong>Execution</strong> tab.`
|
|
1546
|
+
: `Run complete · ${steps} node${steps === 1 ? '' : 's'} against the live model · see the <strong>Execution</strong> tab.`);
|
|
1547
|
+
} catch (e) {
|
|
1548
|
+
const msg = (e && e.message) ? e.message : String(e);
|
|
1549
|
+
if (cancelRequested) {
|
|
1550
|
+
clearNodeStatus();
|
|
1551
|
+
showToast('Run cancelled', 'info');
|
|
1552
|
+
appendNarration('Run cancelled. Click <strong>▶ Run workflow</strong> to run again.');
|
|
1553
|
+
} else {
|
|
1554
|
+
// A failed run is an expected, recoverable outcome (the server returns it
|
|
1555
|
+
// in-band) — surface it as a toast + narration, not a console error.
|
|
1556
|
+
showToast((simulate ? 'Simulate failed' : 'Run failed') + ': ' + msg, 'warn');
|
|
1557
|
+
appendNarration(simulate
|
|
1558
|
+
? `Simulate couldn't validate <code>${escapeHtml(currentId)}</code> — it stubs each node from its output-schema, but exec nodes have none, so the cross-node templates render undefined. Use <strong>▶ Run workflow</strong> for a live run against the host.`
|
|
1559
|
+
: `Run failed: ${escapeHtml(msg)}. Check the host is attached and the lock is fresh.`);
|
|
1560
|
+
}
|
|
1561
|
+
} finally {
|
|
1562
|
+
state.running = false;
|
|
1563
|
+
$runBtn.disabled = false;
|
|
1564
|
+
if ($simBtn) $simBtn.disabled = false;
|
|
1565
|
+
$runBtn.textContent = '▶ Run workflow';
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
$runBtn.onclick = () => runApp({ simulate: false });
|
|
1569
|
+
if ($simBtn) $simBtn.onclick = () => runApp({ simulate: true });
|
|
1570
|
+
|
|
1571
|
+
function switchToExecution() {
|
|
1572
|
+
state.currentTab = 'execution';
|
|
1573
|
+
document.querySelectorAll('.tabs button').forEach((b) => b.classList.toggle('active', b.dataset.tab === 'execution'));
|
|
1574
|
+
renderInspect();
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
// ── trace + SSE ──────────────────────────────────────────────────────────────
|
|
1578
|
+
|
|
1579
|
+
function fmtTs(ts) {
|
|
1580
|
+
const d = ts ? new Date(ts) : new Date();
|
|
1581
|
+
return isNaN(d.getTime()) ? '' : d.toTimeString().slice(0, 8);
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// Trace fields come from the run JSONL (file-derived) and render raw into the
|
|
1585
|
+
// Execution tab via renderInspect — escape everything that lands in `msg`.
|
|
1586
|
+
// ── Per-node run status on the canvas ──────────────────────────────────────
|
|
1587
|
+
// The server broadcasts the whole trace when `aware app run` returns, so we
|
|
1588
|
+
// also mark every node "running" the instant Run is clicked (markCanvasRunning)
|
|
1589
|
+
// — that's the during-run feedback; pushTrace then refines each node to
|
|
1590
|
+
// done/error as the (batched) trace arrives. Covers UI runs, report runs (the
|
|
1591
|
+
// canvas paints behind the modal), and terminal-driven runs (trace-file).
|
|
1592
|
+
const NODE_STAT_LABEL = { running: '◆ running', done: '✓ done', error: '✗ failed' };
|
|
1593
|
+
function setNodeStatus(nodeId, status) {
|
|
1594
|
+
if (!nodeId) return;
|
|
1595
|
+
const card = document.querySelector(`.agent-card[data-agent-id="${(window.CSS && CSS.escape) ? CSS.escape(nodeId) : nodeId}"]`);
|
|
1596
|
+
if (!card) return;
|
|
1597
|
+
card.classList.remove('node-running', 'node-done', 'node-error');
|
|
1598
|
+
card.classList.add('node-' + status);
|
|
1599
|
+
let pip = card.querySelector('.node-stat');
|
|
1600
|
+
if (!pip) { pip = document.createElement('span'); pip.className = 'node-stat'; card.appendChild(pip); }
|
|
1601
|
+
pip.textContent = NODE_STAT_LABEL[status] || status;
|
|
1602
|
+
}
|
|
1603
|
+
function clearNodeStatus() {
|
|
1604
|
+
document.querySelectorAll('.agent-card').forEach((card) => {
|
|
1605
|
+
card.classList.remove('node-running', 'node-done', 'node-error');
|
|
1606
|
+
const pip = card.querySelector('.node-stat');
|
|
1607
|
+
if (pip) pip.remove();
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
function markCanvasRunning() {
|
|
1611
|
+
clearNodeStatus();
|
|
1612
|
+
const app = currentId && apps.get(currentId);
|
|
1613
|
+
if (!app || !Array.isArray(app.nodes)) return;
|
|
1614
|
+
app.nodes.forEach((n) => setNodeStatus(n.id, 'running'));
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
function pushTrace(ev) {
|
|
1618
|
+
const node = escapeHtml(ev.node || '');
|
|
1619
|
+
const agent = escapeHtml(ev.agent || '');
|
|
1620
|
+
const cmd = ev.command ? '/' + escapeHtml(ev.command) : '';
|
|
1621
|
+
let lvl = 'info';
|
|
1622
|
+
let msg;
|
|
1623
|
+
switch (ev.kind) {
|
|
1624
|
+
case 'run-start': msg = `run start · ${escapeHtml(ev.app || '')} (${escapeHtml(ev.instance || 'default')})`; break;
|
|
1625
|
+
case 'node-start': lvl = 'event'; msg = `node ${node} · ${agent}${cmd}`; break;
|
|
1626
|
+
case 'would-write': lvl = 'warn'; msg = `would-write ${node} · ${escapeHtml(JSON.stringify(ev.proposed_inputs || {}))}`; break;
|
|
1627
|
+
case 'node-output': case 'output': case 'write': lvl = 'ok'; msg = `${escapeHtml(ev.kind)} ${node}`.trim(); break;
|
|
1628
|
+
case 'run-end': lvl = ev.status === 'ok' ? 'ok' : 'err'; msg = `run ${escapeHtml(ev.status || 'ended')}`; break;
|
|
1629
|
+
default: msg = `${escapeHtml(ev.kind)} ${node}`.trim();
|
|
1630
|
+
}
|
|
1631
|
+
// `node` (the raw node id) feeds the Execution tab's node-filter chips.
|
|
1632
|
+
liveTrace.push({ kind: ev.kind, ts: fmtTs(ev.ts), lvl, msg, node: ev.node || '' });
|
|
1633
|
+
|
|
1634
|
+
// Paint the node's status on the canvas as the trace streams in. Once the
|
|
1635
|
+
// user hits Stop, leave the canvas alone — late SSE events (the fs-watcher
|
|
1636
|
+
// replaying the partial JSONL) would otherwise re-pulse a node "running"
|
|
1637
|
+
// after stopRun() cleared it.
|
|
1638
|
+
if (cancelRequested) return;
|
|
1639
|
+
if (ev.kind === 'run-start') clearNodeStatus();
|
|
1640
|
+
else if (ev.node && ev.kind === 'node-start') setNodeStatus(ev.node, 'running');
|
|
1641
|
+
else if (ev.node && /^(node-output|output|write|node-end|node-ok)$/.test(ev.kind)) setNodeStatus(ev.node, 'done');
|
|
1642
|
+
else if (ev.node && /error/i.test(ev.kind)) setNodeStatus(ev.node, 'error');
|
|
1643
|
+
else if (ev.kind === 'run-end' && ev.status && ev.status !== 'ok') {
|
|
1644
|
+
// run failed — any node still "running" (started, never produced output) failed
|
|
1645
|
+
document.querySelectorAll('.agent-card.node-running').forEach((c) => setNodeStatus(c.dataset.agentId, 'error'));
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
// ── Server status light + in-tab recovery ─────────────────────────────────
|
|
1650
|
+
// The footer dot/label show whether the LOCAL server is reachable, driven by the
|
|
1651
|
+
// SSE connection (onopen/onerror) plus a 5s /api/health backstop. When the server
|
|
1652
|
+
// is down we raise a prominent overlay; both it and the footer Restart are real
|
|
1653
|
+
// <a href="floless://start"> anchors the USER clicks. A genuine click carries the
|
|
1654
|
+
// user activation Chromium (Chrome AND Edge) requires to hand the registered scheme
|
|
1655
|
+
// off to the OS protocol handler (which relaunches the server even though HTTP is
|
|
1656
|
+
// dead). For a registered scheme the browser intercepts the click and shows its
|
|
1657
|
+
// native "Open FlolessApp?" prompt INSTEAD of following the URL, so this tab is
|
|
1658
|
+
// never navigated away. (A synthetic anchor.click() or hidden iframe — the old
|
|
1659
|
+
// approach — is silently dropped for external protocols: that was the dead-Restart
|
|
1660
|
+
// bug.) The tab then heals itself: the next health tick flips back online and, if
|
|
1661
|
+
// we never managed to load, pulls the workspace.
|
|
1662
|
+
let serverOnline = null;
|
|
1663
|
+
|
|
1664
|
+
// The overlay leads with AUTO-RECOVERY. The login watchdog (FlolessApp --supervise)
|
|
1665
|
+
// respawns a dead server within seconds, so a crash heals itself with NO user action
|
|
1666
|
+
// — the overlay's job is to reassure ("reconnecting…"), not to demand a click. We
|
|
1667
|
+
// keep a SMALL secondary "Start FloLess now" link as a manual kick, but it stays
|
|
1668
|
+
// HIDDEN until recovery is clearly stuck (still offline after ESCALATE_MS): showing
|
|
1669
|
+
// a button while the copy says "no action needed" is contradictory, and the
|
|
1670
|
+
// floless:// launch is a no-op on corporate-managed browsers anyway (they ignore the
|
|
1671
|
+
// user-level scheme handler). The link's real value is on non-managed browsers; the
|
|
1672
|
+
// reliable fallback when the watchdog itself is down is "reopen FloLess", which the
|
|
1673
|
+
// escalation line offers. The link is a real <a href> the USER clicks — a synthetic
|
|
1674
|
+
// anchor.click() is silently dropped by Chromium for external protocols.
|
|
1675
|
+
const ESCALATE_MS = 15000; // 3 missed 5s health polls ⇒ the watchdog isn't recovering
|
|
1676
|
+
let escalateTimer = null;
|
|
1677
|
+
let lastFocusBeforeOverlay = null;
|
|
1678
|
+
function onStartClickFeedback() {
|
|
1679
|
+
// Feedback only — the href performs the launch (genuine user activation); must NOT
|
|
1680
|
+
// preventDefault. No "approve the dialog" promise: on managed browsers no dialog
|
|
1681
|
+
// ever appears, so the escalation line's "reopen FloLess" stays the honest path.
|
|
1682
|
+
const s = document.getElementById('so-status');
|
|
1683
|
+
if (s) s.textContent = 'Starting FloLess…';
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
let offlineOverlayEl = null;
|
|
1687
|
+
function showOfflineOverlay() {
|
|
1688
|
+
if (offlineOverlayEl) return;
|
|
1689
|
+
lastFocusBeforeOverlay = document.activeElement; // restored on heal (WCAG 2.4.3)
|
|
1690
|
+
const wrap = document.createElement('div');
|
|
1691
|
+
wrap.id = 'server-offline-overlay';
|
|
1692
|
+
// role=status on the card → screen readers announce its text on insertion. The
|
|
1693
|
+
// spinner is decorative (aria-hidden). The manual link is hidden until escalation.
|
|
1694
|
+
wrap.innerHTML =
|
|
1695
|
+
`<div class="so-card" role="status" tabindex="-1">
|
|
1696
|
+
<div class="so-spinner" aria-hidden="true"></div>
|
|
1697
|
+
<h1>Reconnecting to FloLess…</h1>
|
|
1698
|
+
<p>The local server stopped — this tab reconnects automatically in a few seconds.</p>
|
|
1699
|
+
<p class="so-status" id="so-status"></p>
|
|
1700
|
+
<a id="so-start" href="floless://start" class="secondary" hidden>Start FloLess now</a>
|
|
1701
|
+
</div>`;
|
|
1702
|
+
document.body.appendChild(wrap);
|
|
1703
|
+
offlineOverlayEl = wrap;
|
|
1704
|
+
document.getElementById('so-start').addEventListener('click', onStartClickFeedback);
|
|
1705
|
+
// No action is required, so move focus to the CARD (tabindex=-1), NOT the link —
|
|
1706
|
+
// keeps keyboard focus inside the overlay without implying the link is the action.
|
|
1707
|
+
wrap.querySelector('.so-card').focus();
|
|
1708
|
+
// Reveal the manual fallback only if recovery is clearly stuck (watchdog down).
|
|
1709
|
+
if (escalateTimer) clearTimeout(escalateTimer);
|
|
1710
|
+
escalateTimer = setTimeout(() => {
|
|
1711
|
+
if (serverOnline === true) return; // healed
|
|
1712
|
+
const s = document.getElementById('so-status');
|
|
1713
|
+
const a = document.getElementById('so-start');
|
|
1714
|
+
if (s) s.textContent = 'Taking longer than usual — try the button below, or reopen FloLess to bring it back.';
|
|
1715
|
+
if (a) a.hidden = false;
|
|
1716
|
+
}, ESCALATE_MS);
|
|
1717
|
+
}
|
|
1718
|
+
function hideOfflineOverlay() {
|
|
1719
|
+
if (escalateTimer) { clearTimeout(escalateTimer); escalateTimer = null; }
|
|
1720
|
+
if (!offlineOverlayEl) return;
|
|
1721
|
+
offlineOverlayEl.remove();
|
|
1722
|
+
offlineOverlayEl = null;
|
|
1723
|
+
// Return focus to wherever it was before the overlay grabbed it.
|
|
1724
|
+
if (lastFocusBeforeOverlay && document.contains(lastFocusBeforeOverlay)) {
|
|
1725
|
+
try { lastFocusBeforeOverlay.focus(); } catch { /* element gone */ }
|
|
1726
|
+
}
|
|
1727
|
+
lastFocusBeforeOverlay = null;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
// ── AWARE first-run setup overlay ──────────────────────────────────────────
|
|
1731
|
+
// First launch installs the AWARE runtime (npm i -g). While that runs the
|
|
1732
|
+
// workspace is meaningless, so we raise a blocking card (mirrors the offline
|
|
1733
|
+
// overlay's chrome, differentiated by copy). The server's bootstrap state
|
|
1734
|
+
// machine (probing→installing→ready|failed) drives it over SSE, with the 5s
|
|
1735
|
+
// health poll as a backstop. Three states: installing / failed / ready-beat.
|
|
1736
|
+
|
|
1737
|
+
// Pure: reason → the fixed, user-facing body copy for the FAILED state.
|
|
1738
|
+
// Isolated so the wording is trivially reviewable (and unit-testable). NEVER
|
|
1739
|
+
// leaks raw error codes (EACCES/permission denied) — those are dev phrasing.
|
|
1740
|
+
function bootstrapBody(reason) {
|
|
1741
|
+
switch (reason) {
|
|
1742
|
+
case 'no-node':
|
|
1743
|
+
case 'no-npm':
|
|
1744
|
+
return 'AWARE requires Node.js, which was not found on this machine. Install Node.js (nodejs.org), then try again.';
|
|
1745
|
+
case 'eacces':
|
|
1746
|
+
return "Couldn't install because this location requires administrator permissions. Run FloLess as administrator, or install Node.js globally yourself and try again.";
|
|
1747
|
+
case 'offline':
|
|
1748
|
+
return 'No network connection was detected. Connect to the internet, then try again.';
|
|
1749
|
+
case 'smoke-failed':
|
|
1750
|
+
return "AWARE installed but didn't start correctly. This sometimes resolves on its own — try again. If it keeps failing, reinstall AWARE manually: npm i -g @aware-aeco/cli";
|
|
1751
|
+
default:
|
|
1752
|
+
return "Setup didn't complete. Try again.";
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
let bootstrapOverlayEl = null;
|
|
1757
|
+
let bootstrapState = null; // last applied {status, reason, remediation}
|
|
1758
|
+
let bootstrapReadyTimer = null; // the 400ms "ready" dwell before dismiss
|
|
1759
|
+
// Set by the boot sequence: invoked ONCE when AWARE first reaches ready, to route
|
|
1760
|
+
// to the workspace (licensed) or the sign-in gate (unlicensed). "Setup first" — the
|
|
1761
|
+
// runtime overlay precedes the gate, so this routing is deferred until AWARE is up.
|
|
1762
|
+
let onBootstrapReady = null;
|
|
1763
|
+
|
|
1764
|
+
function showBootstrapOverlay() {
|
|
1765
|
+
if (bootstrapOverlayEl) return;
|
|
1766
|
+
// Only grab the pre-overlay focus if another overlay hasn't already (the
|
|
1767
|
+
// offline overlay shares this var); avoids clobbering it with our own card.
|
|
1768
|
+
if (!offlineOverlayEl && !lastFocusBeforeOverlay) lastFocusBeforeOverlay = document.activeElement;
|
|
1769
|
+
const wrap = document.createElement('div');
|
|
1770
|
+
wrap.id = 'aware-bootstrap-overlay';
|
|
1771
|
+
// role=status announces the card text on insertion; spinner + log are
|
|
1772
|
+
// decorative/noisy (aria-hidden); bs-status is the polite live channel.
|
|
1773
|
+
wrap.innerHTML =
|
|
1774
|
+
`<div class="bs-card" role="status" tabindex="-1">
|
|
1775
|
+
<div class="bs-spinner" aria-hidden="true"></div>
|
|
1776
|
+
<h1></h1>
|
|
1777
|
+
<p class="bs-body"></p>
|
|
1778
|
+
<p class="bs-status" id="bs-status" aria-live="polite"></p>
|
|
1779
|
+
<p class="bs-log" aria-hidden="true"></p>
|
|
1780
|
+
<p class="bs-remediation"></p>
|
|
1781
|
+
<button type="button" class="secondary" hidden>Try again</button>
|
|
1782
|
+
</div>`;
|
|
1783
|
+
document.body.appendChild(wrap);
|
|
1784
|
+
bootstrapOverlayEl = wrap;
|
|
1785
|
+
wrap.querySelector('.secondary').addEventListener('click', onBootstrapRetry);
|
|
1786
|
+
wrap.querySelector('.bs-card').focus();
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
// The live npm ticker: REPLACE (never append) the single line; aria-hidden so
|
|
1790
|
+
// the rapid output is not announced. Only meaningful while installing.
|
|
1791
|
+
function setBootstrapLogLine(line) {
|
|
1792
|
+
if (!bootstrapOverlayEl) return;
|
|
1793
|
+
const log = bootstrapOverlayEl.querySelector('.bs-log');
|
|
1794
|
+
if (log) log.textContent = line || '';
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
// Drive the card from a {status, reason, remediation} snapshot (SSE or health).
|
|
1798
|
+
function updateBootstrap(s) {
|
|
1799
|
+
if (!s) return;
|
|
1800
|
+
const status = s.status;
|
|
1801
|
+
if (status === 'ready') return finishBootstrapReady();
|
|
1802
|
+
if (status !== 'installing' && status !== 'probing' && status !== 'failed') return; // idle — nothing to show yet
|
|
1803
|
+
showBootstrapOverlay();
|
|
1804
|
+
if (!bootstrapOverlayEl) return;
|
|
1805
|
+
const prev = bootstrapState;
|
|
1806
|
+
bootstrapState = { status, reason: s.reason ?? null, remediation: s.remediation ?? null };
|
|
1807
|
+
const card = bootstrapOverlayEl.querySelector('.bs-card');
|
|
1808
|
+
const spinner = bootstrapOverlayEl.querySelector('.bs-spinner');
|
|
1809
|
+
const h1 = bootstrapOverlayEl.querySelector('h1');
|
|
1810
|
+
const body = bootstrapOverlayEl.querySelector('.bs-body');
|
|
1811
|
+
const statusEl = bootstrapOverlayEl.querySelector('.bs-status');
|
|
1812
|
+
const log = bootstrapOverlayEl.querySelector('.bs-log');
|
|
1813
|
+
const remediation = bootstrapOverlayEl.querySelector('.bs-remediation');
|
|
1814
|
+
const retry = bootstrapOverlayEl.querySelector('.secondary');
|
|
1815
|
+
if (status === 'failed') {
|
|
1816
|
+
// No spinner on failure — absence of motion is the signal (no error icon).
|
|
1817
|
+
if (spinner) spinner.remove();
|
|
1818
|
+
h1.textContent = 'Setup failed';
|
|
1819
|
+
body.textContent = bootstrapBody(s.reason);
|
|
1820
|
+
statusEl.textContent = '';
|
|
1821
|
+
if (log) log.textContent = ''; // ticker is only meaningful while installing
|
|
1822
|
+
// Server remediation is SUPPLEMENTARY dim context — omit if empty or if it
|
|
1823
|
+
// duplicates the fixed body (the fixed copy is the user-facing message).
|
|
1824
|
+
const rem = (s.remediation || '').trim();
|
|
1825
|
+
remediation.textContent = rem && rem !== body.textContent ? rem : '';
|
|
1826
|
+
retry.hidden = false;
|
|
1827
|
+
// Respect an in-flight retry: an SSE/poll re-render of `failed` mid-retry
|
|
1828
|
+
// would otherwise re-enable the button and allow duplicate clicks. The
|
|
1829
|
+
// server is idempotent today (terminal-state guard on /api/bootstrap/retry),
|
|
1830
|
+
// but the UI shouldn't depend on that.
|
|
1831
|
+
retry.disabled = bootstrapRetryInFlight;
|
|
1832
|
+
retry.textContent = bootstrapRetryInFlight ? 'Trying…' : 'Try again';
|
|
1833
|
+
} else {
|
|
1834
|
+
// probing / installing — spinner spins, no button.
|
|
1835
|
+
if (!bootstrapOverlayEl.querySelector('.bs-spinner')) {
|
|
1836
|
+
const sp = document.createElement('div');
|
|
1837
|
+
sp.className = 'bs-spinner';
|
|
1838
|
+
sp.setAttribute('aria-hidden', 'true');
|
|
1839
|
+
card.insertBefore(sp, h1);
|
|
1840
|
+
}
|
|
1841
|
+
h1.textContent = 'Setting up AWARE…';
|
|
1842
|
+
body.textContent = 'Installing the AWARE runtime. This happens once.';
|
|
1843
|
+
// probing may say "Checking…"; installing clears it (the ticker carries the signal).
|
|
1844
|
+
statusEl.textContent = status === 'probing' ? 'Checking for AWARE…' : '';
|
|
1845
|
+
if (status !== 'installing' && log) log.textContent = ''; // clear ticker when not installing
|
|
1846
|
+
remediation.textContent = '';
|
|
1847
|
+
retry.hidden = true;
|
|
1848
|
+
}
|
|
1849
|
+
// Re-focus the card when the STATE changes (not on every event) so the new
|
|
1850
|
+
// h1/body is re-announced via role=status without spamming on each ticker line.
|
|
1851
|
+
if (!prev || prev.status !== status) {
|
|
1852
|
+
if (document.activeElement !== card) card.focus();
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
// Transition installing→ready: a brief "Ready" beat, then remove (not instant)
|
|
1857
|
+
// so success registers before the workspace paints. A dwell, not an animation.
|
|
1858
|
+
function finishBootstrapReady() {
|
|
1859
|
+
if (!bootstrapOverlayEl) {
|
|
1860
|
+
// No overlay was ever raised (the happy path AWARE-already-ready boot, or a
|
|
1861
|
+
// very fast probing→ready before the first poll tick painted). Record the
|
|
1862
|
+
// state and route now if the boot sequence wired onBootstrapReady — without
|
|
1863
|
+
// this, routing depends on the boot's second health fetch winning the race
|
|
1864
|
+
// against SSE/poll delivering `ready` first. (One-shot; onBootstrapReady is
|
|
1865
|
+
// self-gated by `routed`, so a later call from the boot fetch is a no-op.)
|
|
1866
|
+
bootstrapState = { status: 'ready', reason: null, remediation: null };
|
|
1867
|
+
if (onBootstrapReady) onBootstrapReady();
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
if (bootstrapReadyTimer) return; // beat already scheduled
|
|
1871
|
+
bootstrapState = { status: 'ready', reason: null, remediation: null };
|
|
1872
|
+
const card = bootstrapOverlayEl.querySelector('.bs-card');
|
|
1873
|
+
const spinner = bootstrapOverlayEl.querySelector('.bs-spinner');
|
|
1874
|
+
if (spinner) spinner.remove();
|
|
1875
|
+
if (card) {
|
|
1876
|
+
card.querySelector('h1').textContent = 'AWARE is ready.';
|
|
1877
|
+
card.querySelector('.bs-body').textContent = '';
|
|
1878
|
+
card.querySelector('.bs-status').textContent = '';
|
|
1879
|
+
card.querySelector('.bs-log').textContent = '';
|
|
1880
|
+
card.querySelector('.bs-remediation').textContent = '';
|
|
1881
|
+
card.querySelector('.secondary').hidden = true;
|
|
1882
|
+
}
|
|
1883
|
+
bootstrapReadyTimer = setTimeout(() => {
|
|
1884
|
+
hideBootstrapOverlay();
|
|
1885
|
+
// AWARE is up — NOW route to the workspace or the sign-in gate (deferred until
|
|
1886
|
+
// ready by the "setup first" boot sequence). One-shot; guarded in the callback.
|
|
1887
|
+
if (onBootstrapReady) onBootstrapReady();
|
|
1888
|
+
}, 400);
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
function hideBootstrapOverlay() {
|
|
1892
|
+
if (bootstrapReadyTimer) { clearTimeout(bootstrapReadyTimer); bootstrapReadyTimer = null; }
|
|
1893
|
+
if (!bootstrapOverlayEl) return;
|
|
1894
|
+
bootstrapOverlayEl.remove();
|
|
1895
|
+
bootstrapOverlayEl = null;
|
|
1896
|
+
// Restore focus only if WE own the saved focus and no other overlay is up.
|
|
1897
|
+
if (!offlineOverlayEl && lastFocusBeforeOverlay && document.contains(lastFocusBeforeOverlay)) {
|
|
1898
|
+
try { lastFocusBeforeOverlay.focus(); } catch { /* element gone */ }
|
|
1899
|
+
lastFocusBeforeOverlay = null;
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
let bootstrapRetryInFlight = false;
|
|
1904
|
+
|
|
1905
|
+
async function onBootstrapRetry(e) {
|
|
1906
|
+
if (bootstrapRetryInFlight) return; // guard double-clicks even before disabled paints
|
|
1907
|
+
bootstrapRetryInFlight = true;
|
|
1908
|
+
const btn = e.currentTarget;
|
|
1909
|
+
btn.disabled = true;
|
|
1910
|
+
btn.textContent = 'Trying…';
|
|
1911
|
+
try {
|
|
1912
|
+
await api('/api/bootstrap/retry', { method: 'POST' });
|
|
1913
|
+
// Server kicked the state machine back to probing/installing; the SSE
|
|
1914
|
+
// stream + health poll rebuild the card. Pre-empt with a probing view so
|
|
1915
|
+
// the click feels responsive even before the first event arrives — but
|
|
1916
|
+
// only if no faster SSE/poll event has already moved us past `failed`
|
|
1917
|
+
// (else we'd briefly clobber a definitive update with stale optimism).
|
|
1918
|
+
if (bootstrapState?.status === 'failed') {
|
|
1919
|
+
updateBootstrap({ status: 'probing', reason: null, remediation: null });
|
|
1920
|
+
}
|
|
1921
|
+
} catch (err) {
|
|
1922
|
+
// Retry call itself failed (server unreachable mid-retry) — leave the
|
|
1923
|
+
// failed card up, SURFACE the error so the user knows the click landed
|
|
1924
|
+
// nowhere (silent re-enable looks like the click didn't register), and
|
|
1925
|
+
// re-enable so they can try again. The offline overlay (if the server
|
|
1926
|
+
// is truly down) layers on top via its own health path.
|
|
1927
|
+
const statusEl = bootstrapOverlayEl?.querySelector('.bs-status');
|
|
1928
|
+
if (statusEl) statusEl.textContent = 'Retry failed — server unreachable.';
|
|
1929
|
+
console.warn('bootstrap retry failed', err);
|
|
1930
|
+
btn.disabled = false;
|
|
1931
|
+
btn.textContent = 'Try again';
|
|
1932
|
+
} finally {
|
|
1933
|
+
bootstrapRetryInFlight = false;
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
function initServerStatusUI() {
|
|
1938
|
+
if (document.getElementById('server-status-restart')) return;
|
|
1939
|
+
const style = document.createElement('style');
|
|
1940
|
+
style.textContent = `
|
|
1941
|
+
footer .status .stat-server .dot.offline{background:var(--err,#f87171);box-shadow:0 0 6px var(--err,#f87171);}
|
|
1942
|
+
#server-status-restart{appearance:none;border:1px solid var(--err,#f87171);background:transparent;color:var(--err,#f87171);
|
|
1943
|
+
font-family:var(--mono,monospace);font-size:11px;cursor:pointer;padding:1px 7px;border-radius:4px;margin-left:2px;text-decoration:none;display:inline-block;}
|
|
1944
|
+
/* display:inline-block on the ID above would beat the UA [hidden]{display:none}
|
|
1945
|
+
rule (ID > attribute specificity), leaving the link visible while online — so
|
|
1946
|
+
restore hiding with an ID+attribute rule that wins. */
|
|
1947
|
+
#server-status-restart[hidden]{display:none;}
|
|
1948
|
+
#server-status-restart:hover{background:color-mix(in srgb,var(--err,#f87171) 14%,transparent);}
|
|
1949
|
+
#server-offline-overlay{position:fixed;inset:0;z-index:99998;display:flex;align-items:center;justify-content:center;
|
|
1950
|
+
background:color-mix(in srgb,var(--bg,#020817) 88%,transparent);backdrop-filter:blur(2px);
|
|
1951
|
+
color:var(--text,#f8fafc);font-family:var(--ui,system-ui,sans-serif);}
|
|
1952
|
+
#server-offline-overlay .so-card{max-width:420px;text-align:center;padding:36px 34px;border-radius:14px;
|
|
1953
|
+
background:var(--surface,#0b1424);border:1px solid var(--border,#1e293b);box-shadow:0 20px 60px rgba(0,0,0,.5);}
|
|
1954
|
+
#server-offline-overlay .so-spinner{width:30px;height:30px;margin:0 auto 14px;border-radius:50%;
|
|
1955
|
+
border:3px solid color-mix(in srgb,var(--text-dim,#94a3b8) 28%,transparent);border-top-color:var(--accent,#3b82f6);
|
|
1956
|
+
animation:so-spin .8s linear infinite;}
|
|
1957
|
+
@keyframes so-spin{to{transform:rotate(360deg);}}
|
|
1958
|
+
@media (prefers-reduced-motion:reduce){#server-offline-overlay .so-spinner{animation:none;}}
|
|
1959
|
+
#server-offline-overlay h1{font-size:19px;margin:0 0 8px;font-weight:600;}
|
|
1960
|
+
#server-offline-overlay p{font-size:13.5px;line-height:1.5;color:var(--text-dim,#94a3b8);margin:0 0 4px;}
|
|
1961
|
+
#server-offline-overlay .so-card:focus{outline:none;}
|
|
1962
|
+
#server-offline-overlay .so-status{min-height:16px;margin:10px 0 0;font-size:12.5px;color:var(--text-dim,#94a3b8);}
|
|
1963
|
+
#server-offline-overlay .secondary{appearance:none;cursor:pointer;font-size:12.5px;margin-top:16px;
|
|
1964
|
+
background:transparent;border:1px solid var(--border,#1e293b);color:var(--text-dim,#94a3b8);
|
|
1965
|
+
padding:7px 16px;border-radius:7px;text-decoration:none;display:inline-block;}
|
|
1966
|
+
#server-offline-overlay .secondary:hover{border-color:var(--accent,#3b82f6);color:var(--text,#f8fafc);}
|
|
1967
|
+
/* display:inline-block (for padding) would beat the UA [hidden] rule — keep the
|
|
1968
|
+
link hidden until escalation with an ID+attribute rule that wins on specificity. */
|
|
1969
|
+
#server-offline-overlay .secondary[hidden]{display:none;}
|
|
1970
|
+
/* ── AWARE first-run setup overlay ──────────────────────────────────────
|
|
1971
|
+
Shares the offline overlay's .so-card chrome (same tokens, spinner, copy
|
|
1972
|
+
scale) but differentiates by COPY, not color/icon (see the UX spec). Sits
|
|
1973
|
+
BELOW the offline overlay (99997 < 99998 < 99999 gate): a server crash
|
|
1974
|
+
mid-setup should layer the reconnect card on top. */
|
|
1975
|
+
#aware-bootstrap-overlay{position:fixed;inset:0;z-index:99997;display:flex;align-items:center;justify-content:center;
|
|
1976
|
+
background:color-mix(in srgb,var(--bg,#020817) 88%,transparent);backdrop-filter:blur(2px);
|
|
1977
|
+
color:var(--text,#f8fafc);font-family:var(--ui,system-ui,sans-serif);}
|
|
1978
|
+
#aware-bootstrap-overlay .bs-card{max-width:420px;text-align:center;padding:36px 34px;border-radius:14px;
|
|
1979
|
+
background:var(--surface,#0b1424);border:1px solid var(--border,#1e293b);box-shadow:0 20px 60px rgba(0,0,0,.5);}
|
|
1980
|
+
#aware-bootstrap-overlay .bs-card:focus{outline:none;}
|
|
1981
|
+
#aware-bootstrap-overlay .bs-spinner{width:30px;height:30px;margin:0 auto 14px;border-radius:50%;
|
|
1982
|
+
border:3px solid color-mix(in srgb,var(--text-dim,#94a3b8) 28%,transparent);border-top-color:var(--accent,#3b82f6);
|
|
1983
|
+
animation:so-spin .8s linear infinite;}
|
|
1984
|
+
@media (prefers-reduced-motion:reduce){#aware-bootstrap-overlay .bs-spinner{animation:none;}}
|
|
1985
|
+
#aware-bootstrap-overlay h1{font-size:19px;margin:0 0 8px;font-weight:600;}
|
|
1986
|
+
#aware-bootstrap-overlay p{font-size:13.5px;line-height:1.5;color:var(--text-dim,#94a3b8);margin:0 0 4px;}
|
|
1987
|
+
#aware-bootstrap-overlay .bs-status{min-height:16px;margin:10px 0 0;font-size:12.5px;color:var(--text-dim,#94a3b8);}
|
|
1988
|
+
/* live npm ticker: dim mono single-line, truncated; replaced (not appended) per SSE line. */
|
|
1989
|
+
#aware-bootstrap-overlay .bs-log{font-family:var(--mono,monospace);font-size:11.5px;color:var(--text-dim,#94a3b8);
|
|
1990
|
+
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-height:16px;margin:8px 0 0;opacity:.7;}
|
|
1991
|
+
#aware-bootstrap-overlay .bs-remediation{margin:8px 0 0;font-size:12.5px;color:var(--text-dim,#94a3b8);}
|
|
1992
|
+
#aware-bootstrap-overlay .secondary{appearance:none;cursor:pointer;font-size:12.5px;margin-top:16px;
|
|
1993
|
+
background:transparent;border:1px solid var(--border,#1e293b);color:var(--text-dim,#94a3b8);
|
|
1994
|
+
padding:7px 16px;border-radius:7px;}
|
|
1995
|
+
#aware-bootstrap-overlay .secondary:hover{border-color:var(--accent,#3b82f6);color:var(--text,#f8fafc);}
|
|
1996
|
+
#aware-bootstrap-overlay .secondary:disabled{cursor:default;opacity:.55;border-color:var(--border,#1e293b);color:var(--text-dim,#94a3b8);}
|
|
1997
|
+
#aware-bootstrap-overlay .bs-log:empty,#aware-bootstrap-overlay .bs-remediation:empty{display:none;}`;
|
|
1998
|
+
document.head.appendChild(style);
|
|
1999
|
+
const stat = document.querySelector('footer .status .stat'); // first stat = runtime
|
|
2000
|
+
if (!stat) return;
|
|
2001
|
+
stat.classList.add('stat-server');
|
|
2002
|
+
const btn = document.createElement('a'); // real anchor — the href performs the launch
|
|
2003
|
+
btn.id = 'server-status-restart';
|
|
2004
|
+
btn.href = 'floless://start';
|
|
2005
|
+
btn.textContent = '↻ Restart FloLess';
|
|
2006
|
+
btn.dataset.tip = 'Restart the local server';
|
|
2007
|
+
btn.hidden = true;
|
|
2008
|
+
btn.addEventListener('click', () => {
|
|
2009
|
+
// Feedback only — the href launches; don't preventDefault.
|
|
2010
|
+
btn.textContent = 'starting…';
|
|
2011
|
+
setTimeout(() => { btn.textContent = '↻ Restart FloLess'; }, 4000); // health poll flips the light back to green
|
|
2012
|
+
});
|
|
2013
|
+
stat.appendChild(btn);
|
|
2014
|
+
}
|
|
2015
|
+
function setServerStatus(online) {
|
|
2016
|
+
if (online === serverOnline) return;
|
|
2017
|
+
const wasOnline = serverOnline;
|
|
2018
|
+
serverOnline = online;
|
|
2019
|
+
const stat = document.querySelector('footer .status .stat-server') || document.querySelector('footer .status .stat');
|
|
2020
|
+
const dot = stat && stat.querySelector('.dot');
|
|
2021
|
+
const label = stat && stat.querySelector('.stat-val');
|
|
2022
|
+
const btn = document.getElementById('server-status-restart');
|
|
2023
|
+
if (dot) dot.classList.toggle('offline', !online);
|
|
2024
|
+
if (label) label.textContent = online ? 'runtime online' : 'server offline';
|
|
2025
|
+
if (btn) btn.hidden = online;
|
|
2026
|
+
if (online) {
|
|
2027
|
+
hideOfflineOverlay(); // also clears the escalation timer + restores focus
|
|
2028
|
+
// Recovered from a boot-time / never-loaded outage: now that the server is
|
|
2029
|
+
// back, pull the workspace so the canvas fills in instead of staying empty.
|
|
2030
|
+
if (wasOnline === false && !currentId) loadWorkspaceData();
|
|
2031
|
+
} else {
|
|
2032
|
+
showOfflineOverlay();
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
let shownVersion = false;
|
|
2036
|
+
function startHealthPoll() {
|
|
2037
|
+
const tick = async () => {
|
|
2038
|
+
let ok = false;
|
|
2039
|
+
try {
|
|
2040
|
+
const r = await fetch('/api/health', { cache: 'no-store' });
|
|
2041
|
+
ok = r.ok;
|
|
2042
|
+
if (ok) {
|
|
2043
|
+
const h = await r.json();
|
|
2044
|
+
// Stamp the build + AWARE runtime versions into the footer once (so a
|
|
2045
|
+
// stale install or AWARE mismatch is self-diagnosable). appVersion is
|
|
2046
|
+
// the real sq.version on an install (package.json only from source);
|
|
2047
|
+
// awareVersion is the aware npm package this app drives.
|
|
2048
|
+
if (!shownVersion) {
|
|
2049
|
+
const av = document.getElementById('app-version');
|
|
2050
|
+
if (av && h && h.appVersion) av.textContent = 'v' + h.appVersion;
|
|
2051
|
+
const wv = document.getElementById('aware-version');
|
|
2052
|
+
if (wv && h && h.awareVersion) wv.textContent = 'AWARE ' + h.awareVersion;
|
|
2053
|
+
if (h && (h.appVersion || h.awareVersion)) shownVersion = true;
|
|
2054
|
+
}
|
|
2055
|
+
// Bootstrap BACKSTOP: reconcile the setup overlay from cached health
|
|
2056
|
+
// state in case an SSE event was missed. ready → finish the beat &
|
|
2057
|
+
// dismiss; failed/installing/probing → (re)render that state.
|
|
2058
|
+
if (h && h.bootstrap) {
|
|
2059
|
+
updateBootstrap({ status: h.bootstrap, reason: h.bootstrapReason ?? null, remediation: h.bootstrapRemediation ?? null });
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
} catch { ok = false; }
|
|
2063
|
+
setServerStatus(ok);
|
|
2064
|
+
};
|
|
2065
|
+
tick();
|
|
2066
|
+
setInterval(tick, 5000);
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
// Re-check the subscription after a seat-takeover and gate this session if it
|
|
2070
|
+
// lost access. Re-uses the bootstrap gate panel.
|
|
2071
|
+
async function recheckLicenseAndGate() {
|
|
2072
|
+
let s; try { s = await api('/api/license/status'); } catch { return; }
|
|
2073
|
+
if (s.state !== 'valid' && s.state !== 'offline-grace') renderGate(s);
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
function connectSse() {
|
|
2077
|
+
const es = new EventSource('/api/events');
|
|
2078
|
+
let reloadTimer = null;
|
|
2079
|
+
es.onopen = () => setServerStatus(true);
|
|
2080
|
+
es.onmessage = (e) => {
|
|
2081
|
+
let m;
|
|
2082
|
+
try { m = JSON.parse(e.data); } catch { return; }
|
|
2083
|
+
if (!m || typeof m !== 'object') return; // valid non-object JSON (null/number) has no .type
|
|
2084
|
+
if (m.type === 'bootstrap') {
|
|
2085
|
+
// First-run AWARE setup state machine. `line` carries an npm ticker line
|
|
2086
|
+
// (installing only) → dim ticker; `status`/`reason` drive the card state.
|
|
2087
|
+
if (m.line != null) setBootstrapLogLine(m.line);
|
|
2088
|
+
if (m.status) updateBootstrap({ status: m.status, reason: m.reason ?? null, remediation: m.remediation ?? null });
|
|
2089
|
+
} else if (m.type === 'trace' && m.id === currentId) {
|
|
2090
|
+
pushTrace(m.event);
|
|
2091
|
+
if (state.currentTab === 'execution') renderInspect();
|
|
2092
|
+
} else if (m.type === 'trace-file' && m.id === currentId && !state.running) {
|
|
2093
|
+
// A terminal-driven run wrote a trace for the open app (our own UI runs
|
|
2094
|
+
// are handled by the type:'trace' path above and skipped via state.running).
|
|
2095
|
+
liveTrace.length = 0;
|
|
2096
|
+
(m.events || []).forEach(pushTrace);
|
|
2097
|
+
state.hasRun = true;
|
|
2098
|
+
if (state.currentTab === 'execution') renderInspect();
|
|
2099
|
+
} else if (m.type === 'run-started' && m.id === currentId) {
|
|
2100
|
+
liveTrace.length = 0;
|
|
2101
|
+
state.hasRun = true;
|
|
2102
|
+
} else if (m.type === 'templates-changed') {
|
|
2103
|
+
loadTemplates().catch(() => {});
|
|
2104
|
+
} else if (m.type === 'baked' && m.id === currentId) {
|
|
2105
|
+
// Baked (here or in another tab) → refresh so the menu item flips to "Re-bake".
|
|
2106
|
+
loadApp(currentId).catch(() => {});
|
|
2107
|
+
} else if (m.type === 'grafted') {
|
|
2108
|
+
// A new agent was grafted (here or in another tab) → refresh the catalog +
|
|
2109
|
+
// count, and re-render the library if it's open.
|
|
2110
|
+
loadAgentCatalog().then(() => {
|
|
2111
|
+
if ($libModal && $libModal.classList.contains('show')) renderLibrary();
|
|
2112
|
+
}).catch(() => {});
|
|
2113
|
+
} else if (m.type === 'request-added' || m.type === 'requests-changed') {
|
|
2114
|
+
loadRequests();
|
|
2115
|
+
} else if (m.type === 'routine-changed') {
|
|
2116
|
+
loadRoutinesData();
|
|
2117
|
+
} else if (m.type === 'routine-run-started') {
|
|
2118
|
+
runningRoutines.add(m.id);
|
|
2119
|
+
if ($routinesModal.classList.contains('show')) renderRoutinesList();
|
|
2120
|
+
} else if (m.type === 'routine-run-ended') {
|
|
2121
|
+
runningRoutines.delete(m.id);
|
|
2122
|
+
loadRoutinesData();
|
|
2123
|
+
} else if (m.type === 'trigger-session-changed') {
|
|
2124
|
+
applyTriggerSnapshot(m.id, m.snapshot);
|
|
2125
|
+
} else if (m.type === 'connect-result') {
|
|
2126
|
+
// The device-code sign-in resolved. On success, refresh so the card flips
|
|
2127
|
+
// to Connected; otherwise surface the outcome in the integration's slot.
|
|
2128
|
+
connecting.delete(m.id);
|
|
2129
|
+
const slot = dcSlot(m.id);
|
|
2130
|
+
if (m.status === 'connected') {
|
|
2131
|
+
if (slot) slot.innerHTML = '';
|
|
2132
|
+
showToast(`Connected ${m.id}`, 'ok');
|
|
2133
|
+
refreshIntegrations();
|
|
2134
|
+
} else if (slot) {
|
|
2135
|
+
slot.innerHTML = `Sign-in ${escapeHtml(m.status || 'failed')}${m.error ? ' — ' + escapeHtml(m.error) : ''}. Click Connect to retry.`;
|
|
2136
|
+
}
|
|
2137
|
+
} else if (m.type === 'fs-change' && m.kind === 'credential') {
|
|
2138
|
+
// `aware connect`/`disconnect` wrote/removed a token in the local vault —
|
|
2139
|
+
// refresh the Integrations window so its status reflects reality live.
|
|
2140
|
+
refreshIntegrations();
|
|
2141
|
+
} else if (m.type === 'fs-change' && (m.kind === 'source' || m.kind === 'lock') && currentId && pathHasSegment(m.path, currentId)) {
|
|
2142
|
+
// The host AI (or anyone) edited this app's .flo/.lock in the terminal —
|
|
2143
|
+
// refresh so the canvas + gate reflect reality.
|
|
2144
|
+
clearTimeout(reloadTimer);
|
|
2145
|
+
reloadTimer = setTimeout(() => loadApp(currentId).catch(reportErr), 250);
|
|
2146
|
+
} else if (m.type === 'seat-taken') {
|
|
2147
|
+
// This session's seat was claimed by another device (newest-login-wins).
|
|
2148
|
+
showToast('Your session was taken over on another device — sign in to continue.', 'err');
|
|
2149
|
+
recheckLicenseAndGate();
|
|
2150
|
+
}
|
|
2151
|
+
};
|
|
2152
|
+
es.onerror = () => setServerStatus(false); // server unreachable → red; EventSource auto-reconnects (onopen → green)
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
function reportErr(e) {
|
|
2156
|
+
console.error(e);
|
|
2157
|
+
showToast(e.message || String(e), 'err');
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
// True if `id` appears as a full path segment (avoids `foo` matching `foo-bar`).
|
|
2161
|
+
function pathHasSegment(path, id) {
|
|
2162
|
+
return path.split(/[\\/]/).includes(id);
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
// ── templates (reusable node palette / "notes"), server-persisted ───────────
|
|
2166
|
+
// state.favorites holds the template list so app.js's category-suggestion code
|
|
2167
|
+
// keeps working; the chip/star/library behaviour is overridden below.
|
|
2168
|
+
let agentCatalog = [];
|
|
2169
|
+
|
|
2170
|
+
async function loadTemplates() {
|
|
2171
|
+
const { templates } = await api('/api/templates');
|
|
2172
|
+
state.favorites = templates;
|
|
2173
|
+
renderFavBar();
|
|
2174
|
+
markStars();
|
|
2175
|
+
$favCount.textContent = templates.length;
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
// The node a chip/star refers to is identified by source {appId,nodeId}; a card
|
|
2179
|
+
// is "starred" when a template was captured from it in this app.
|
|
2180
|
+
function templateForNode(nodeId) {
|
|
2181
|
+
return state.favorites.find((t) => t.source && t.source.appId === currentId && t.source.nodeId === nodeId);
|
|
2182
|
+
}
|
|
2183
|
+
function markStars() {
|
|
2184
|
+
document.querySelectorAll('.agent-card .fav-btn').forEach((btn) => {
|
|
2185
|
+
btn.classList.toggle('faved', !!templateForNode(btn.dataset.fav));
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
// Star on a card: save this node as a reusable template (or remove if already).
|
|
2190
|
+
toggleFav = function toggleFavTpl(nodeId) {
|
|
2191
|
+
const existing = templateForNode(nodeId);
|
|
2192
|
+
if (existing) { deleteTemplate(existing.id); return; }
|
|
2193
|
+
openAddFavModal(nodeId);
|
|
2194
|
+
};
|
|
2195
|
+
|
|
2196
|
+
openAddFavModal = function openAddFavModalTpl(nodeId) {
|
|
2197
|
+
state.pendingFavAgentId = nodeId;
|
|
2198
|
+
const a = AGENTS[nodeId];
|
|
2199
|
+
$addFavSub.textContent = `Save "${nodeId}" as a reusable template — usable in any project.`;
|
|
2200
|
+
$favName.value = a ? a.title : nodeId;
|
|
2201
|
+
$favCat.value = '';
|
|
2202
|
+
renderCategorySuggestions();
|
|
2203
|
+
$addFavModal.classList.add('show');
|
|
2204
|
+
setTimeout(() => $favCat.focus(), 50);
|
|
2205
|
+
};
|
|
2206
|
+
|
|
2207
|
+
commitFav = async function commitFavTpl() {
|
|
2208
|
+
const nodeId = state.pendingFavAgentId;
|
|
2209
|
+
if (!nodeId) return;
|
|
2210
|
+
const name = ($favName.value || '').trim() || nodeId;
|
|
2211
|
+
const category = ($favCat.value || '').trim() || 'Uncategorized';
|
|
2212
|
+
const app = apps.get(currentId);
|
|
2213
|
+
const n = app && app.nodes.find((x) => x.id === nodeId);
|
|
2214
|
+
if (!n) { showToast('node not found', 'err'); return; }
|
|
2215
|
+
const node = {
|
|
2216
|
+
agent: n.agent,
|
|
2217
|
+
command: n.command,
|
|
2218
|
+
kind: n.kind,
|
|
2219
|
+
config: n.inputs && Object.keys(n.inputs).length ? n.inputs : n.config,
|
|
2220
|
+
};
|
|
2221
|
+
try {
|
|
2222
|
+
await api('/api/templates', { method: 'POST', body: JSON.stringify({ name, category, node, source: { appId: currentId, nodeId } }) });
|
|
2223
|
+
$addFavModal.classList.remove('show');
|
|
2224
|
+
state.pendingFavAgentId = null;
|
|
2225
|
+
await loadTemplates();
|
|
2226
|
+
showToast(`Saved template "${name}"`, 'ok');
|
|
2227
|
+
} catch (e) { reportErr(e); }
|
|
2228
|
+
};
|
|
2229
|
+
|
|
2230
|
+
async function deleteTemplate(id) {
|
|
2231
|
+
try { await api(`/api/templates/${encodeURIComponent(id)}`, { method: 'DELETE' }); await loadTemplates(); }
|
|
2232
|
+
catch (e) { reportErr(e); }
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
// Template chips: click = "use in this app" (reverse channel); × = delete.
|
|
2236
|
+
renderFavBar = function renderFavBarTpl() {
|
|
2237
|
+
const tpls = state.favorites;
|
|
2238
|
+
$favCount.textContent = tpls.length;
|
|
2239
|
+
if (!tpls.length) { $favChipRow.innerHTML = ''; $favBarEmpty.style.display = 'block'; return; }
|
|
2240
|
+
$favBarEmpty.style.display = 'none';
|
|
2241
|
+
$favChipRow.innerHTML = tpls.map((t) => `
|
|
2242
|
+
<div class="fav-chip" data-tpl="${escapeAttr(t.id)}" data-tip="Use in this workflow · ${escapeAttr(t.category)} · ${escapeAttr((t.node.agent || t.node.kind) + (t.node.command ? '/' + t.node.command : ''))}">
|
|
2243
|
+
<span class="cat">${escapeHtml(t.category)}</span>
|
|
2244
|
+
<span class="name">${escapeHtml(t.name)}</span>
|
|
2245
|
+
<span class="del" data-tip="Delete template">×</span>
|
|
2246
|
+
</div>`).join('');
|
|
2247
|
+
$favChipRow.querySelectorAll('.fav-chip').forEach((chip) => {
|
|
2248
|
+
const id = chip.dataset.tpl;
|
|
2249
|
+
chip.onclick = (e) => { if (e.target.closest('.del')) return; useTemplate(id); };
|
|
2250
|
+
chip.querySelector('.del').onclick = (e) => { e.stopPropagation(); deleteTemplate(id); };
|
|
2251
|
+
});
|
|
2252
|
+
};
|
|
2253
|
+
|
|
2254
|
+
refreshFavMarkers = function refreshFavMarkersTpl() {
|
|
2255
|
+
markStars();
|
|
2256
|
+
if ($libModal.classList.contains('show')) renderLibrary();
|
|
2257
|
+
};
|
|
2258
|
+
saveFavs = function saveFavsNoop() { $favCount.textContent = state.favorites.length; };
|
|
2259
|
+
|
|
2260
|
+
// ── reverse channel: record intent for the host AI, copy a paste-ready line ──
|
|
2261
|
+
function instructionFor(req) {
|
|
2262
|
+
if (req.type === 'use-template' && req.template) {
|
|
2263
|
+
const t = req.template;
|
|
2264
|
+
return `In floless app "${req.appId}", add a node from my saved template "${t.name}" (agent ${t.node.agent || t.node.kind}${t.node.command ? ' / ' + t.node.command : ''}, config ${JSON.stringify(t.node.config || {})}). Edit the .flo, then run \`aware app compile\`.`;
|
|
2265
|
+
}
|
|
2266
|
+
if (req.type === 'tweak') {
|
|
2267
|
+
const base = `In floless app "${req.appId}", change only node "${req.nodeId}": ${req.instruction}. Edit just that node in the .flo, then run \`aware app compile\`.`;
|
|
2268
|
+
return req.snapshots && req.snapshots.length
|
|
2269
|
+
? `${base}\nReference snapshots (read these for visual context): ${req.snapshots.join(', ')}`
|
|
2270
|
+
: base;
|
|
2271
|
+
}
|
|
2272
|
+
return '';
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
async function copyToClipboard(text) {
|
|
2276
|
+
try { await navigator.clipboard.writeText(text); return true; } catch { return false; }
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
async function useTemplate(templateId) {
|
|
2280
|
+
if (!currentId) { showToast('open a workflow first', 'warn'); return; }
|
|
2281
|
+
try {
|
|
2282
|
+
const { request } = await api('/api/use-template', { method: 'POST', body: JSON.stringify({ appId: currentId, templateId }) });
|
|
2283
|
+
const line = instructionFor(request);
|
|
2284
|
+
const copied = await copyToClipboard(line);
|
|
2285
|
+
appendNarration(`Queued template <strong>${escapeHtml(request.template.name)}</strong> for workflow <code>${escapeHtml(currentId)}</code> — the UI can’t edit the workflow itself, so your terminal AI picks this up and applies it. ${copied ? 'Instruction copied to your clipboard — paste it in.' : 'Open the requests chip (bottom-right) to copy it.'}`);
|
|
2286
|
+
showToast(copied ? 'Queued for your terminal AI · copied to clipboard' : 'Queued for your terminal AI', 'ok');
|
|
2287
|
+
loadRequests();
|
|
2288
|
+
} catch (e) { reportErr(e); }
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
async function tweakNode() {
|
|
2292
|
+
if (!currentId || !state.selectedAgentId) return;
|
|
2293
|
+
const node = state.selectedAgentId;
|
|
2294
|
+
const res = await formModal({
|
|
2295
|
+
title: `Tweak · ${node}`,
|
|
2296
|
+
sub: 'Describe the change in plain English. It is queued for your terminal AI to pick up (pull it with the floless skill, or copy it).',
|
|
2297
|
+
fields: [
|
|
2298
|
+
{ name: 'instruction', label: 'What should change about this node?', multiline: true, placeholder: 'e.g. also group by ASSEMBLY_POS and add a per-assembly subtotal' },
|
|
2299
|
+
{ name: 'snapshots', label: 'Screenshots (optional)', type: 'images' },
|
|
2300
|
+
],
|
|
2301
|
+
okLabel: 'Queue for terminal',
|
|
2302
|
+
});
|
|
2303
|
+
const instruction = res && res.instruction ? res.instruction.trim() : '';
|
|
2304
|
+
if (!instruction) return;
|
|
2305
|
+
try {
|
|
2306
|
+
const snaps = Array.isArray(res.snapshots) ? res.snapshots.map((s) => ({ name: s.name, dataUrl: s.dataUrl })) : [];
|
|
2307
|
+
const { request } = await api('/api/tweak', { method: 'POST', body: JSON.stringify({ appId: currentId, nodeId: node, instruction, snapshots: snaps }) });
|
|
2308
|
+
const copied = await copyToClipboard(instructionFor(request));
|
|
2309
|
+
appendNarration(`Tweak queued for <code>${escapeHtml(node)}</code> — your terminal AI can pull it (floless skill) ${copied ? 'or paste the copied instruction' : ''}.`);
|
|
2310
|
+
const toastMsg = snaps.length
|
|
2311
|
+
? `Tweak + ${snaps.length} snapshot(s) queued${copied ? ' + copied' : ''}`
|
|
2312
|
+
: copied ? 'Tweak queued + copied' : 'Tweak queued';
|
|
2313
|
+
showToast(toastMsg, 'ok');
|
|
2314
|
+
loadRequests();
|
|
2315
|
+
} catch (e) { reportErr(e); }
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
let pendingRequests = [];
|
|
2319
|
+
async function loadRequests() {
|
|
2320
|
+
try {
|
|
2321
|
+
const { requests } = await api('/api/requests');
|
|
2322
|
+
pendingRequests = requests;
|
|
2323
|
+
const $rc = document.getElementById('req-count');
|
|
2324
|
+
const $rs = document.getElementById('req-stat');
|
|
2325
|
+
if ($rc) $rc.textContent = requests.length;
|
|
2326
|
+
if ($rs) $rs.classList.toggle('has-pending', requests.length > 0);
|
|
2327
|
+
const $rm = document.getElementById('requests-modal');
|
|
2328
|
+
if ($rm && $rm.classList.contains('show')) renderRequests(); // keep an open list live
|
|
2329
|
+
} catch { /* non-fatal */ }
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
// The Requests window: every queued change in a list, each copyable + clearable,
|
|
2333
|
+
// plus copy-all / clear-all. instructionFor() output (which embeds file-derived
|
|
2334
|
+
// appId/nodeId/instruction) is rendered via textContent-equivalent escaping.
|
|
2335
|
+
function renderRequests() {
|
|
2336
|
+
const $list = document.getElementById('requests-list');
|
|
2337
|
+
const $clear = document.getElementById('requests-clear');
|
|
2338
|
+
const $copy = document.getElementById('requests-copy');
|
|
2339
|
+
if (!$list) return;
|
|
2340
|
+
const empty = pendingRequests.length === 0;
|
|
2341
|
+
// Hide (not just disable) the bulk actions when there's nothing to act on — a
|
|
2342
|
+
// disabled "Clear all"/"Copy all" over an empty list reads as broken UI.
|
|
2343
|
+
if ($clear) $clear.hidden = empty;
|
|
2344
|
+
if ($copy) $copy.hidden = empty;
|
|
2345
|
+
if (empty) {
|
|
2346
|
+
$list.innerHTML = '<div class="empty-state">No requests queued. Save a Template or ✎ Tweak a node to send a change to your terminal AI.</div>';
|
|
2347
|
+
return;
|
|
2348
|
+
}
|
|
2349
|
+
$list.innerHTML = pendingRequests.map((r) => {
|
|
2350
|
+
const label = r.type === 'use-template' ? 'template' : 'tweak';
|
|
2351
|
+
const badgeCls = r.type === 'tweak' ? 'req-type req-type-tweak' : 'req-type';
|
|
2352
|
+
const target = r.type === 'tweak' && r.nodeId ? ` · node <code>${escapeHtml(r.nodeId)}</code>` : '';
|
|
2353
|
+
const when = r.createdAt ? new Date(r.createdAt) : null;
|
|
2354
|
+
const time = when && !isNaN(when) ? `<span class="req-time">${escapeHtml(nowStamp(when))}</span>` : '';
|
|
2355
|
+
return `
|
|
2356
|
+
<div class="req-item">
|
|
2357
|
+
<div class="req-info">
|
|
2358
|
+
<div class="req-head"><span class="${badgeCls}">${label}</span> <code>${escapeHtml(r.appId || '')}</code>${target}${time}</div>
|
|
2359
|
+
<div class="req-instruction">${escapeHtml(instructionFor(r))}</div>
|
|
2360
|
+
${r.snapshots && r.snapshots.length ? `<div class="req-thumbs">${r.snapshots.map((_, i) => `<button type="button" class="req-thumb" data-id="${escapeAttr(r.id)}" data-n="${i}" aria-label="View screenshot ${i + 1}"><img src="/api/requests/${encodeURIComponent(r.id)}/snapshot/${i}" alt=""></button>`).join('')}</div>` : ''}
|
|
2361
|
+
</div>
|
|
2362
|
+
<div class="req-actions">
|
|
2363
|
+
<button class="req-copy" data-id="${escapeAttr(r.id)}" data-tip="Copy this instruction">⧉</button>
|
|
2364
|
+
<button class="req-del" data-id="${escapeAttr(r.id)}" data-tip="Clear this request">×</button>
|
|
2365
|
+
</div>
|
|
2366
|
+
</div>`;
|
|
2367
|
+
}).join('');
|
|
2368
|
+
$list.querySelectorAll('.req-copy').forEach((b) => {
|
|
2369
|
+
b.onclick = async () => {
|
|
2370
|
+
const r = pendingRequests.find((x) => x.id === b.dataset.id);
|
|
2371
|
+
if (!r) return;
|
|
2372
|
+
const copied = await copyToClipboard(instructionFor(r));
|
|
2373
|
+
showToast(copied ? 'Copied — paste it to your terminal AI' : 'copy failed', copied ? 'ok' : 'err');
|
|
2374
|
+
};
|
|
2375
|
+
});
|
|
2376
|
+
$list.querySelectorAll('.req-del').forEach((b) => { b.onclick = () => deleteOneRequest(b.dataset.id); });
|
|
2377
|
+
$list.querySelectorAll('.req-thumb').forEach((b) => {
|
|
2378
|
+
b.onclick = () => window.open(`/api/requests/${encodeURIComponent(b.dataset.id)}/snapshot/${b.dataset.n}`, '_blank');
|
|
2379
|
+
});
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
async function deleteOneRequest(id) {
|
|
2383
|
+
try { await api(`/api/requests/${encodeURIComponent(id)}`, { method: 'DELETE' }); await loadRequests(); renderRequests(); }
|
|
2384
|
+
catch (e) { reportErr(e); }
|
|
2385
|
+
}
|
|
2386
|
+
async function clearAllRequests() {
|
|
2387
|
+
if (!pendingRequests.length) return;
|
|
2388
|
+
try { await api('/api/requests', { method: 'DELETE' }); await loadRequests(); renderRequests(); showToast('Cleared all requests', 'ok'); }
|
|
2389
|
+
catch (e) { reportErr(e); }
|
|
2390
|
+
}
|
|
2391
|
+
function openRequests() {
|
|
2392
|
+
renderRequests();
|
|
2393
|
+
document.getElementById('requests-modal').classList.add('show');
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
// ── Agents library: real installed agents + their commands ───────────────────
|
|
2397
|
+
async function loadAgentCatalog() {
|
|
2398
|
+
const { agents } = await api('/api/agents');
|
|
2399
|
+
agentCatalog = agents;
|
|
2400
|
+
const $ac = document.getElementById('agents-count');
|
|
2401
|
+
if ($ac) $ac.textContent = agents.length;
|
|
2402
|
+
// Narrative banner shows the REAL installed-agent count, not a placeholder.
|
|
2403
|
+
const $acn = document.getElementById('agent-count-narr');
|
|
2404
|
+
if ($acn) $acn.textContent = agents.length;
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
openLibrary = function openLibraryReal() {
|
|
2408
|
+
$libSearch.value = '';
|
|
2409
|
+
renderLibrary();
|
|
2410
|
+
$libModal.classList.add('show');
|
|
2411
|
+
if (!agentCatalog.length) loadAgentCatalog().then(renderLibrary).catch(reportErr);
|
|
2412
|
+
};
|
|
2413
|
+
|
|
2414
|
+
renderLibrary = function renderLibraryReal() {
|
|
2415
|
+
const q = ($libSearch.value || '').toLowerCase().trim();
|
|
2416
|
+
const items = agentCatalog.filter((a) => !q || a.id.toLowerCase().includes(q) || (a.kind || '').toLowerCase().includes(q));
|
|
2417
|
+
$libList.innerHTML = items.map((a) => `
|
|
2418
|
+
<div class="lib-item" data-agent="${escapeAttr(a.id)}">
|
|
2419
|
+
<div class="info">
|
|
2420
|
+
<div class="name">${escapeHtml(a.id)} <span style="color:var(--text-dim);font-weight:400">v${escapeHtml(String(a.version))}</span></div>
|
|
2421
|
+
<div class="meta">${escapeHtml(a.kind || 'agent')} · ${a.commands} command${a.commands === 1 ? '' : 's'} · ${a.skills} skill${a.skills === 1 ? '' : 's'}</div>
|
|
2422
|
+
</div>
|
|
2423
|
+
<button class="lib-fav" data-expand="${escapeAttr(a.id)}" data-tip="Show commands">⌄</button>
|
|
2424
|
+
</div>
|
|
2425
|
+
<div class="lib-commands" id="cmds-${escapeAttr(a.id)}" hidden></div>
|
|
2426
|
+
`).join('') || '<div class="empty-state">No agents match.</div>';
|
|
2427
|
+
$libList.querySelectorAll('[data-expand]').forEach((btn) => {
|
|
2428
|
+
btn.onclick = () => expandAgent(btn.dataset.expand);
|
|
2429
|
+
});
|
|
2430
|
+
};
|
|
2431
|
+
|
|
2432
|
+
async function expandAgent(id) {
|
|
2433
|
+
const box = document.getElementById(`cmds-${id}`);
|
|
2434
|
+
if (!box) return;
|
|
2435
|
+
if (!box.hidden) { box.hidden = true; return; }
|
|
2436
|
+
box.hidden = false;
|
|
2437
|
+
box.innerHTML = '<div class="cmd-desc">loading…</div>';
|
|
2438
|
+
try {
|
|
2439
|
+
const { agent } = await api(`/api/agent/${encodeURIComponent(id)}`);
|
|
2440
|
+
const cmds = Array.isArray(agent.commands) ? agent.commands : [];
|
|
2441
|
+
box.innerHTML = cmds.map((c) => `
|
|
2442
|
+
<div class="lib-cmd">
|
|
2443
|
+
<span class="cmd-name">${escapeHtml(c.name)}</span>
|
|
2444
|
+
<span class="cmd-cat">${escapeHtml(c.category || '')}</span>
|
|
2445
|
+
<span class="cmd-desc">${escapeHtml(c.description || '')}</span>
|
|
2446
|
+
<button class="cmd-save" data-agent="${escapeAttr(id)}" data-cmd="${escapeAttr(c.name)}" data-tip="Save as template">★</button>
|
|
2447
|
+
</div>`).join('') || '<div class="cmd-desc">no commands</div>';
|
|
2448
|
+
box.querySelectorAll('.cmd-save').forEach((btn) => {
|
|
2449
|
+
btn.onclick = () => saveAgentCommandTemplate(btn.dataset.agent, btn.dataset.cmd);
|
|
2450
|
+
});
|
|
2451
|
+
} catch (e) { box.innerHTML = `<div class="cmd-desc">error: ${escapeHtml(e.message)}</div>`; }
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
async function saveAgentCommandTemplate(agent, command) {
|
|
2455
|
+
try {
|
|
2456
|
+
await api('/api/templates', { method: 'POST', body: JSON.stringify({
|
|
2457
|
+
name: command, category: agent,
|
|
2458
|
+
node: { agent, command, kind: 'agent', config: {} },
|
|
2459
|
+
}) });
|
|
2460
|
+
await loadTemplates();
|
|
2461
|
+
showToast(`Saved template "${command}"`, 'ok');
|
|
2462
|
+
} catch (e) { reportErr(e); }
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
// ── Integrations: real connection state from the local vault ────────────────
|
|
2466
|
+
const VENDOR_ICON = { microsoft: '▦', google: 'G', trimble: '◬', autodesk: 'A' };
|
|
2467
|
+
let integrations = [];
|
|
2468
|
+
|
|
2469
|
+
openIntegrations = function openIntegrationsReal() {
|
|
2470
|
+
$integrationsModal.classList.add('show');
|
|
2471
|
+
renderIntegrations();
|
|
2472
|
+
refreshIntegrations();
|
|
2473
|
+
};
|
|
2474
|
+
|
|
2475
|
+
// Re-fetch live connection state from the local vault; re-render if the modal
|
|
2476
|
+
// is open. Called on open and whenever a credential file changes (SSE), so an
|
|
2477
|
+
// open window flips to Connected the moment `aware connect` writes the token.
|
|
2478
|
+
function refreshIntegrations() {
|
|
2479
|
+
return api('/api/integrations')
|
|
2480
|
+
.then(({ integrations: list }) => {
|
|
2481
|
+
integrations = list;
|
|
2482
|
+
if ($integrationsModal.classList.contains('show')) renderIntegrations();
|
|
2483
|
+
})
|
|
2484
|
+
.catch(reportErr);
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
renderIntegrations = function renderIntegrationsReal() {
|
|
2488
|
+
if (!integrations.length) {
|
|
2489
|
+
$integrationsList.innerHTML = '<div class="empty-state">Loading installed integrations…</div>';
|
|
2490
|
+
return;
|
|
2491
|
+
}
|
|
2492
|
+
$integrationsList.innerHTML = integrations.map((i) => {
|
|
2493
|
+
const action = i.status === 'connected' ? 'Disconnect' : i.status === 'expired' ? 'Reconnect' : 'Connect';
|
|
2494
|
+
const badge = i.status === 'connected' ? 'Connected' : i.status === 'expired' ? 'Token expired' : 'Not connected';
|
|
2495
|
+
const meta = i.status === 'disconnected' ? '—' : i.expiresAt ? `expires ${new Date(i.expiresAt).toLocaleString()}` : 'connected';
|
|
2496
|
+
return `
|
|
2497
|
+
<div class="int-item int-${i.status}">
|
|
2498
|
+
<div class="int-icon">${escapeHtml(VENDOR_ICON[i.vendor] || (i.name[0] || '?'))}</div>
|
|
2499
|
+
<div class="int-info">
|
|
2500
|
+
<div class="int-name">${escapeHtml(i.name)}</div>
|
|
2501
|
+
<div class="int-kind">${escapeHtml(i.vendor || 'integration')} · agent <code>${escapeHtml(i.agent)}</code></div>
|
|
2502
|
+
<div class="int-scopes">${escapeHtml((i.network || []).join(' · ') || '—')}</div>
|
|
2503
|
+
<div class="int-meta">${escapeHtml(meta)}</div>
|
|
2504
|
+
<div class="int-row">
|
|
2505
|
+
<span class="int-badge int-badge-${i.status}">${badge}</span>
|
|
2506
|
+
<button class="int-action" data-int="${escapeAttr(i.id)}" data-int-action="${action.toLowerCase()}">${action}</button>
|
|
2507
|
+
</div>
|
|
2508
|
+
${i.status === 'connected' ? '' : `
|
|
2509
|
+
<label class="int-flow" data-tip="Use a one-time device code instead of opening the browser — for headless or locked-down machines where the loopback sign-in can't run">
|
|
2510
|
+
<input type="checkbox" class="int-flow-toggle" data-flow="${escapeAttr(i.id)}"> use a device code instead
|
|
2511
|
+
</label>`}
|
|
2512
|
+
<div class="int-devicecode" data-dc="${escapeAttr(i.id)}"></div>
|
|
2513
|
+
</div>
|
|
2514
|
+
</div>`;
|
|
2515
|
+
}).join('');
|
|
2516
|
+
$integrationsList.querySelectorAll('.int-action').forEach((btn) => {
|
|
2517
|
+
btn.onclick = async () => {
|
|
2518
|
+
// Connect/Reconnect drive the device-code sign-in IN the app (AWARE owns
|
|
2519
|
+
// the OAuth + token storage; we only show the code + reflect the result —
|
|
2520
|
+
// the token never crosses floless.app). Disconnect stays a copied command.
|
|
2521
|
+
if (btn.dataset.intAction === 'disconnect') {
|
|
2522
|
+
const cmd = 'aware disconnect ' + btn.dataset.int;
|
|
2523
|
+
const copied = await copyToClipboard(cmd);
|
|
2524
|
+
showToast(`${copied ? 'Copied — run' : 'Run'} in your terminal: ${cmd}`, 'info');
|
|
2525
|
+
return;
|
|
2526
|
+
}
|
|
2527
|
+
await startConnect(btn.dataset.int);
|
|
2528
|
+
};
|
|
2529
|
+
});
|
|
2530
|
+
};
|
|
2531
|
+
|
|
2532
|
+
// Device-code sign-in: ask the server to start `aware connect --device-code`,
|
|
2533
|
+
// show the user the code + verification URL, then wait for the SSE result.
|
|
2534
|
+
// `connecting` guards against duplicate clicks spawning parallel polls.
|
|
2535
|
+
const connecting = new Set();
|
|
2536
|
+
function dcSlot(id) {
|
|
2537
|
+
return $integrationsList.querySelector(`.int-devicecode[data-dc="${(window.CSS && CSS.escape) ? CSS.escape(id) : id}"]`);
|
|
2538
|
+
}
|
|
2539
|
+
async function startConnect(id) {
|
|
2540
|
+
if (connecting.has(id)) return;
|
|
2541
|
+
connecting.add(id);
|
|
2542
|
+
const slot = dcSlot(id);
|
|
2543
|
+
// Default flow is OAuth loopback+PKCE (safer; aware opens the browser). The
|
|
2544
|
+
// per-card toggle opts into device-code (a one-time code) for headless /
|
|
2545
|
+
// loopback-blocked machines.
|
|
2546
|
+
const esc = (window.CSS && CSS.escape) ? CSS.escape(id) : id;
|
|
2547
|
+
const toggle = $integrationsList.querySelector(`.int-flow-toggle[data-flow="${esc}"]`);
|
|
2548
|
+
const body = toggle && toggle.checked ? JSON.stringify({ flow: 'device-code' }) : '{}';
|
|
2549
|
+
if (slot) slot.innerHTML = 'Starting sign-in…';
|
|
2550
|
+
try {
|
|
2551
|
+
const res = await api(`/api/connect/${encodeURIComponent(id)}`, { method: 'POST', body });
|
|
2552
|
+
if (slot) {
|
|
2553
|
+
if (res.flow === 'device-code' && res.prompt) {
|
|
2554
|
+
// Fallback flow: show the device code to enter at the verification URL.
|
|
2555
|
+
slot.innerHTML =
|
|
2556
|
+
`Go to <a href="${escapeAttr(res.prompt.verification_uri)}" target="_blank" rel="noopener">${escapeHtml(res.prompt.verification_uri)}</a>` +
|
|
2557
|
+
` and enter code <strong class="dc-code">${escapeHtml(res.prompt.user_code)}</strong> — waiting for sign-in…`;
|
|
2558
|
+
} else {
|
|
2559
|
+
// OAuth: aware opened the provider sign-in in the browser.
|
|
2560
|
+
slot.innerHTML = 'Opening your browser to sign in… complete it there and this will update automatically.';
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
} catch (e) {
|
|
2564
|
+
connecting.delete(id);
|
|
2565
|
+
if (slot) slot.innerHTML = '';
|
|
2566
|
+
reportErr(e);
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
// Show the per-node Tweak button when a node is selected, and the Debug-in-VS
|
|
2571
|
+
// button only for exec nodes (those carry C# the host can run under a debugger).
|
|
2572
|
+
// Debug lives HERE in the main window, not in the report modal.
|
|
2573
|
+
const _selectAgent = selectAgent;
|
|
2574
|
+
selectAgent = function selectAgentTweakAware(id) {
|
|
2575
|
+
_selectAgent(id);
|
|
2576
|
+
const tb = document.getElementById('tweak-btn');
|
|
2577
|
+
if (tb) tb.hidden = !id;
|
|
2578
|
+
const db = document.getElementById('debug-btn');
|
|
2579
|
+
if (db) {
|
|
2580
|
+
const node = id && currentId && apps.get(currentId)?.nodes.find((n) => n.id === id);
|
|
2581
|
+
db.hidden = !(node && node.config && typeof node.config.code === 'string');
|
|
2582
|
+
}
|
|
2583
|
+
};
|
|
2584
|
+
|
|
2585
|
+
// ── Routines (scheduled .flo runs) ──────────────────────────────────────────
|
|
2586
|
+
// A routine is a TIME-based trigger of `aware app run` (the server scheduler fires
|
|
2587
|
+
// it). This panel is pure CRUD over /api/routines — the same seam the authoring
|
|
2588
|
+
// skill writes to. The UI never composes a workflow; it schedules an existing one.
|
|
2589
|
+
const $routinesModal = document.getElementById('routines-modal');
|
|
2590
|
+
const $routineEditModal = document.getElementById('routine-edit-modal');
|
|
2591
|
+
let routinesData = [];
|
|
2592
|
+
let routinesMax = 15;
|
|
2593
|
+
let editingRoutineId = null;
|
|
2594
|
+
let editWorkflows = []; // app list for the workflow <select>, cached per edit-open
|
|
2595
|
+
let editTriggerSource = null; // picked workflow's triggerSource (null = not trigger-eligible)
|
|
2596
|
+
let editRoutineMode = 'schedule'; // 'schedule' | 'trigger' — chosen kind in the edit form
|
|
2597
|
+
const runningRoutines = new Set(); // ids executing now (SSE start → ended)
|
|
2598
|
+
|
|
2599
|
+
const RTN_DAY_LABELS = { sun: 'Sun', mon: 'Mon', tue: 'Tue', wed: 'Wed', thu: 'Thu', fri: 'Fri', sat: 'Sat' };
|
|
2600
|
+
const RTN_DAY_ORDER = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
|
2601
|
+
|
|
2602
|
+
function scheduleText(s) {
|
|
2603
|
+
if (!s || !s.kind) return '—';
|
|
2604
|
+
if (s.kind === 'hourly') return s.everyHours === 1 ? 'Every hour' : `Every ${s.everyHours} hours`;
|
|
2605
|
+
if (s.kind === 'daily') return `Daily · ${s.time}`;
|
|
2606
|
+
if (s.kind === 'weekdays') return `Weekdays · ${s.time}`;
|
|
2607
|
+
if (s.kind === 'weekly') {
|
|
2608
|
+
const days = (s.days || []).slice().sort((a, b) => RTN_DAY_ORDER.indexOf(a) - RTN_DAY_ORDER.indexOf(b)).map((d) => RTN_DAY_LABELS[d] || d).join(', ');
|
|
2609
|
+
return `${days || '—'} · ${s.time}`;
|
|
2610
|
+
}
|
|
2611
|
+
if (s.kind === 'cron') return `Custom · ${s.expr}`;
|
|
2612
|
+
return '—';
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
// Build the hour/minute <select> options once (00–23 / 00–59). App-styled selects
|
|
2616
|
+
// replace the native <input type="time"> so the picker matches the dark baseline.
|
|
2617
|
+
function fillTimeSelects() {
|
|
2618
|
+
const $h = document.getElementById('rtn-hour');
|
|
2619
|
+
const $m = document.getElementById('rtn-minute');
|
|
2620
|
+
if (!$h || !$m || $h.options.length) return; // build once
|
|
2621
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
2622
|
+
$h.innerHTML = Array.from({ length: 24 }, (_, i) => `<option value="${pad(i)}">${pad(i)}</option>`).join('');
|
|
2623
|
+
$m.innerHTML = Array.from({ length: 60 }, (_, i) => `<option value="${pad(i)}">${pad(i)}</option>`).join('');
|
|
2624
|
+
}
|
|
2625
|
+
function setTimeSelects(hhmm) {
|
|
2626
|
+
const [h, m] = (hhmm || '07:00').split(':');
|
|
2627
|
+
const $h = document.getElementById('rtn-hour');
|
|
2628
|
+
const $m = document.getElementById('rtn-minute');
|
|
2629
|
+
if ($h) $h.value = (h || '07').padStart(2, '0');
|
|
2630
|
+
if ($m) $m.value = (m || '00').padStart(2, '0');
|
|
2631
|
+
}
|
|
2632
|
+
function getTimeSelects() {
|
|
2633
|
+
const h = document.getElementById('rtn-hour').value || '07';
|
|
2634
|
+
const m = document.getElementById('rtn-minute').value || '00';
|
|
2635
|
+
return `${h}:${m}`;
|
|
2636
|
+
}
|
|
2637
|
+
function relTime(iso) {
|
|
2638
|
+
if (!iso) return '—';
|
|
2639
|
+
const ms = new Date(iso).getTime() - Date.now();
|
|
2640
|
+
if (isNaN(ms)) return '—';
|
|
2641
|
+
if (ms <= 0) return 'due';
|
|
2642
|
+
const min = Math.round(ms / 60000);
|
|
2643
|
+
if (min < 60) return `in ${min} min`;
|
|
2644
|
+
const h = Math.round(min / 60);
|
|
2645
|
+
if (h < 48) return `in ${h} h`;
|
|
2646
|
+
return `in ${Math.round(h / 24)} d`;
|
|
2647
|
+
}
|
|
2648
|
+
function absTime(iso) {
|
|
2649
|
+
if (!iso) return '';
|
|
2650
|
+
const d = new Date(iso);
|
|
2651
|
+
return isNaN(d.getTime()) ? '' : d.toLocaleString();
|
|
2652
|
+
}
|
|
2653
|
+
function lastRunTip(r) {
|
|
2654
|
+
if (!r.lastRun) return 'Never run yet';
|
|
2655
|
+
const lr = r.lastRun;
|
|
2656
|
+
const dur = lr.durationMs != null ? ` · ${(lr.durationMs / 1000).toFixed(1)} s` : '';
|
|
2657
|
+
return `Last run ${lr.status}${lr.reason ? ' — ' + lr.reason : ''} · ${absTime(lr.at)}${dur}`;
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
// ── Trigger-routine row helpers ─────────────────────────────────────────────
|
|
2661
|
+
// A trigger routine's "when" descriptor (mirrors scheduleText for timers): the
|
|
2662
|
+
// source agent/command it fires on, from the routine's TriggerBinding.
|
|
2663
|
+
function triggerWhenText(r) {
|
|
2664
|
+
const t = r.trigger || {};
|
|
2665
|
+
const src = t.agent ? `${t.agent}${t.command ? '/' + t.command : ''}` : 'event';
|
|
2666
|
+
return `On trigger · ${src}`;
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
// Short "x ago" for a PAST instant (relTime is for future fires). '' when absent.
|
|
2670
|
+
function agoTime(iso) {
|
|
2671
|
+
if (!iso) return '';
|
|
2672
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
2673
|
+
if (isNaN(ms) || ms < 0) return '';
|
|
2674
|
+
const min = Math.round(ms / 60000);
|
|
2675
|
+
if (min < 1) return 'just now';
|
|
2676
|
+
if (min < 60) return `${min}m ago`;
|
|
2677
|
+
const h = Math.round(min / 60);
|
|
2678
|
+
if (h < 48) return `${h}h ago`;
|
|
2679
|
+
return `${Math.round(h / 24)}d ago`;
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
// Map a trigger routine's live session snapshot to its status cell —
|
|
2683
|
+
// {label, dotClass, tip, sr} mirroring a schedule row's nextLabel/dot. Dot classes
|
|
2684
|
+
// reuse the existing palette: listening=ok, blocked=skipped, error=failed, stopped=muted.
|
|
2685
|
+
// Pure render of server-pushed state; the UI never composes it.
|
|
2686
|
+
function triggerStatus(r) {
|
|
2687
|
+
if (!r.enabled) return { label: 'stopped', dotClass: '', tip: 'Disabled — turn on to start watching', sr: 'Status: stopped' };
|
|
2688
|
+
if (r.broken) return { label: 'blocked', dotClass: 'skipped', tip: r.broken.reason, sr: `Status: blocked — ${r.broken.reason}` };
|
|
2689
|
+
const ss = r.session || null;
|
|
2690
|
+
const state = ss ? ss.state : 'stopped';
|
|
2691
|
+
if (state === 'blocked') {
|
|
2692
|
+
const why = (ss && ss.error) || 'Not runnable — needs Compile';
|
|
2693
|
+
return { label: 'blocked', dotClass: 'skipped', tip: why, sr: `Status: blocked — ${why}` };
|
|
2694
|
+
}
|
|
2695
|
+
if (state === 'error') {
|
|
2696
|
+
const why = (ss && ss.error) || 'The watcher exited with an error';
|
|
2697
|
+
return { label: 'error', dotClass: 'failed', tip: why, sr: `Status: error — ${why}` };
|
|
2698
|
+
}
|
|
2699
|
+
if (state === 'listening') {
|
|
2700
|
+
const fired = (ss && ss.firedCount) || 0;
|
|
2701
|
+
const label = fired ? `listening · ${fired}×` : 'listening';
|
|
2702
|
+
let tip = 'Watching for events — runs on each one';
|
|
2703
|
+
if (fired) {
|
|
2704
|
+
const le = ss.lastEvent;
|
|
2705
|
+
tip = `Fired ${fired}×` + (le ? ` · ${le.summary} · ${agoTime(le.at) || absTime(le.at)}` : '');
|
|
2706
|
+
}
|
|
2707
|
+
const sr = `Status: listening, fired ${fired} times` + (ss && ss.lastEvent ? `, last event ${ss.lastEvent.summary}` : '');
|
|
2708
|
+
return { label, dotClass: 'ok', tip, sr };
|
|
2709
|
+
}
|
|
2710
|
+
return { label: 'stopped', dotClass: '', tip: 'Not running', sr: 'Status: stopped' };
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
async function loadRoutinesData() {
|
|
2714
|
+
try {
|
|
2715
|
+
const res = await api('/api/routines');
|
|
2716
|
+
routinesData = res.routines || [];
|
|
2717
|
+
routinesMax = res.max || 15;
|
|
2718
|
+
// Reconcile running indicators from the server's authoritative state (queued/
|
|
2719
|
+
// active) so a missed SSE end-event can't leave a spinner wedged on.
|
|
2720
|
+
runningRoutines.clear();
|
|
2721
|
+
for (const r of routinesData) if (r.running) runningRoutines.add(r.id);
|
|
2722
|
+
if ($routinesModal.classList.contains('show')) renderRoutinesList();
|
|
2723
|
+
} catch { /* non-fatal */ }
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
// SSE: a trigger routine's live session snapshot changed. Update the in-memory
|
|
2727
|
+
// row and re-render; flash the row when its fired count climbs (a real event).
|
|
2728
|
+
function applyTriggerSnapshot(id, snapshot) {
|
|
2729
|
+
const r = routinesData.find((x) => x.id === id);
|
|
2730
|
+
if (!r) { loadRoutinesData(); return; } // unknown id (created in another tab) → resync
|
|
2731
|
+
const prevFired = (r.session && r.session.firedCount) || 0;
|
|
2732
|
+
r.session = snapshot;
|
|
2733
|
+
if (!$routinesModal.classList.contains('show')) return;
|
|
2734
|
+
renderRoutinesList();
|
|
2735
|
+
if (snapshot && snapshot.firedCount > prevFired) flashRoutineRow(id);
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
// Briefly tint a row when it just fired. Re-applies on rapid repeats via a forced
|
|
2739
|
+
// reflow (else the animation wouldn't restart). Honors reduced-motion (CSS).
|
|
2740
|
+
function flashRoutineRow(id) {
|
|
2741
|
+
// ids are slugs ([a-z0-9._-]) — safe inside a quoted attribute selector as-is.
|
|
2742
|
+
const el = document.querySelector(`.rtn-item[data-id="${id}"]`);
|
|
2743
|
+
if (!el) return;
|
|
2744
|
+
el.classList.remove('rtn-fired-flash');
|
|
2745
|
+
void el.offsetWidth;
|
|
2746
|
+
el.classList.add('rtn-fired-flash');
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
function renderRoutinesList() {
|
|
2750
|
+
const $list = document.getElementById('routines-list');
|
|
2751
|
+
const $quota = document.getElementById('rtn-quota');
|
|
2752
|
+
const $add = document.getElementById('rtn-add');
|
|
2753
|
+
if (!$list) return;
|
|
2754
|
+
const n = routinesData.length;
|
|
2755
|
+
const full = n >= routinesMax;
|
|
2756
|
+
const near = !full && n >= routinesMax - 3;
|
|
2757
|
+
$quota.className = 'rtn-quota' + (full ? ' full' : near ? ' near' : '');
|
|
2758
|
+
$quota.innerHTML = `<span class="num">${n}</span> of ${routinesMax} routines used` +
|
|
2759
|
+
(full ? ' · <span class="cap-note">limit reached — delete one to add another</span>' : '');
|
|
2760
|
+
if ($add) $add.hidden = full; // hide (don't disable) at the cap
|
|
2761
|
+
if (!n) {
|
|
2762
|
+
$list.innerHTML = '<div class="empty-state">No routines yet. Add one to run a workflow on a schedule.</div>';
|
|
2763
|
+
return;
|
|
2764
|
+
}
|
|
2765
|
+
$list.innerHTML = routinesData.map((r) => {
|
|
2766
|
+
const isTrigger = r.kind === 'trigger';
|
|
2767
|
+
const running = runningRoutines.has(r.id);
|
|
2768
|
+
const broken = r.broken ? `<span class="rtn-dot">·</span><span style="color:var(--err)">${escapeHtml(r.broken.reason)}</span>` : '';
|
|
2769
|
+
let statusEl, nextTip, nextLabel;
|
|
2770
|
+
if (isTrigger) {
|
|
2771
|
+
const t = triggerStatus(r);
|
|
2772
|
+
nextLabel = t.label;
|
|
2773
|
+
nextTip = t.tip;
|
|
2774
|
+
statusEl = `<span class="rtn-status ${t.dotClass}" data-tip="${escapeAttr(t.tip)}"><span class="sr-only">${escapeHtml(t.sr)}</span></span>`;
|
|
2775
|
+
} else {
|
|
2776
|
+
statusEl = running
|
|
2777
|
+
? '<span class="rtn-spinner" data-tip="Running now…"></span>'
|
|
2778
|
+
: `<span class="rtn-status ${escapeAttr(r.lastRun ? r.lastRun.status : '')}" data-tip="${escapeAttr(lastRunTip(r))}"></span>`;
|
|
2779
|
+
nextTip = r.nextFireAt ? `Next run: ${absTime(r.nextFireAt)}` : (r.broken ? r.broken.reason : r.enabled ? 'Not scheduled' : 'Disabled');
|
|
2780
|
+
nextLabel = !r.enabled ? 'disabled' : r.broken ? 'broken' : relTime(r.nextFireAt);
|
|
2781
|
+
}
|
|
2782
|
+
const whenText = isTrigger ? triggerWhenText(r) : scheduleText(r.schedule);
|
|
2783
|
+
const toggleTip = isTrigger ? (r.enabled ? 'Stop listening' : 'Start listening') : `${r.enabled ? 'Disable' : 'Enable'} this routine`;
|
|
2784
|
+
return `
|
|
2785
|
+
<div class="rtn-item ${isTrigger ? 'trigger ' : ''}${r.enabled ? '' : 'disabled'} ${running ? 'running' : ''}" data-id="${escapeAttr(r.id)}">
|
|
2786
|
+
<div class="rtn-main">
|
|
2787
|
+
<div class="rtn-name">${escapeHtml(r.name)}</div>
|
|
2788
|
+
<div class="rtn-sub"><span class="rtn-wf">${escapeHtml(r.workflow)}</span><span class="rtn-dot">·</span><span>${escapeHtml(whenText)}</span>${broken}</div>
|
|
2789
|
+
</div>
|
|
2790
|
+
<div class="rtn-meta"${isTrigger ? ' aria-live="polite" aria-atomic="true"' : ''}>
|
|
2791
|
+
<span class="rtn-next" data-tip="${escapeAttr(nextTip)}">${escapeHtml(nextLabel)}</span>
|
|
2792
|
+
${statusEl}
|
|
2793
|
+
</div>
|
|
2794
|
+
<div class="rtn-actions">
|
|
2795
|
+
<label class="rtn-toggle" data-tip="${escapeAttr(toggleTip)}">
|
|
2796
|
+
<input type="checkbox" data-rtn-toggle="${escapeAttr(r.id)}" ${r.enabled ? 'checked' : ''}>
|
|
2797
|
+
<span class="rtn-toggle-track"></span>
|
|
2798
|
+
</label>
|
|
2799
|
+
${isTrigger ? '' : `<button class="rtn-act" data-rtn-run="${escapeAttr(r.id)}" data-tip="Run now"${running ? ' disabled' : ''}>▶</button>`}
|
|
2800
|
+
<button class="rtn-act" data-rtn-edit="${escapeAttr(r.id)}" data-tip="Edit">✎</button>
|
|
2801
|
+
<button class="rtn-act rtn-del-act" data-rtn-del="${escapeAttr(r.id)}" data-tip="Delete">×</button>
|
|
2802
|
+
</div>
|
|
2803
|
+
</div>`;
|
|
2804
|
+
}).join('');
|
|
2805
|
+
$list.querySelectorAll('[data-rtn-toggle]').forEach((el) => { el.onchange = () => toggleRoutine(el.dataset.rtnToggle, el.checked); });
|
|
2806
|
+
$list.querySelectorAll('[data-rtn-run]').forEach((b) => { b.onclick = () => runRoutineNow(b.dataset.rtnRun); });
|
|
2807
|
+
$list.querySelectorAll('[data-rtn-edit]').forEach((b) => { b.onclick = () => openRoutineEdit(b.dataset.rtnEdit); });
|
|
2808
|
+
$list.querySelectorAll('[data-rtn-del]').forEach((b) => { b.onclick = () => deleteRoutineUi(b.dataset.rtnDel); });
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
async function toggleRoutine(id, enabled) {
|
|
2812
|
+
try {
|
|
2813
|
+
await api(`/api/routines/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify({ enabled }) });
|
|
2814
|
+
} catch (e) { reportErr(e); }
|
|
2815
|
+
loadRoutinesData();
|
|
2816
|
+
}
|
|
2817
|
+
async function runRoutineNow(id) {
|
|
2818
|
+
try {
|
|
2819
|
+
await api(`/api/routines/${encodeURIComponent(id)}/run`, { method: 'POST' });
|
|
2820
|
+
runningRoutines.add(id);
|
|
2821
|
+
renderRoutinesList();
|
|
2822
|
+
showToast('Routine started', 'ok');
|
|
2823
|
+
} catch (e) { reportErr(e); }
|
|
2824
|
+
}
|
|
2825
|
+
// Destructive-action confirm. Cancel holds focus (so Enter/Esc/backdrop all cancel);
|
|
2826
|
+
// only an explicit Delete click removes the routine. Resolves true to proceed.
|
|
2827
|
+
function confirmDeleteRoutine(r) {
|
|
2828
|
+
return new Promise((resolve) => {
|
|
2829
|
+
const $m = document.getElementById('rtn-delete-modal');
|
|
2830
|
+
const $sub = document.getElementById('rtn-delete-sub');
|
|
2831
|
+
const $confirm = document.getElementById('rtn-delete-confirm');
|
|
2832
|
+
const $cancel = document.getElementById('rtn-delete-cancel');
|
|
2833
|
+
const name = r && r.name ? r.name : 'this routine';
|
|
2834
|
+
const when = r ? (r.kind === 'trigger' ? triggerWhenText(r) : scheduleText(r.schedule)) : '';
|
|
2835
|
+
const verb = r && r.kind === 'trigger' ? 'watches' : 'runs';
|
|
2836
|
+
$sub.textContent = `“${name}”${when && when !== '—' ? ` ${verb} ${when}` : ''} — this can’t be undone.`;
|
|
2837
|
+
showModal($m);
|
|
2838
|
+
setTimeout(() => $cancel.focus(), 0); // safe default: Enter activates Cancel
|
|
2839
|
+
const done = (result) => {
|
|
2840
|
+
hideModal($m);
|
|
2841
|
+
$confirm.onclick = $cancel.onclick = $m.onclick = null;
|
|
2842
|
+
document.removeEventListener('keydown', onKey, true);
|
|
2843
|
+
resolve(result);
|
|
2844
|
+
};
|
|
2845
|
+
const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } };
|
|
2846
|
+
$confirm.onclick = () => done(true);
|
|
2847
|
+
$cancel.onclick = () => done(false);
|
|
2848
|
+
$m.onclick = (e) => { if (e.target === $m) done(false); };
|
|
2849
|
+
document.addEventListener('keydown', onKey, true);
|
|
2850
|
+
});
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
async function deleteRoutineUi(id) {
|
|
2854
|
+
const r = routinesData.find((x) => x.id === id);
|
|
2855
|
+
if (!(await confirmDeleteRoutine(r))) return; // guard against an accidental × click
|
|
2856
|
+
try {
|
|
2857
|
+
await api(`/api/routines/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
2858
|
+
await loadRoutinesData();
|
|
2859
|
+
showToast('Routine deleted', 'ok');
|
|
2860
|
+
} catch (e) { reportErr(e); }
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
// Schedule builder: reveal only the fields the chosen kind needs.
|
|
2864
|
+
// Switch the edit form between schedule and trigger layouts. Hides the schedule
|
|
2865
|
+
// builder and shows the read-only trigger-source line for 'trigger' (only honored
|
|
2866
|
+
// when the picked workflow is actually trigger-eligible).
|
|
2867
|
+
function setRoutineMode(mode) {
|
|
2868
|
+
editRoutineMode = (mode === 'trigger' && editTriggerSource) ? 'trigger' : 'schedule';
|
|
2869
|
+
const trig = editRoutineMode === 'trigger';
|
|
2870
|
+
document.querySelectorAll('#rtn-mode-field .rtn-mode-btn').forEach((b) => {
|
|
2871
|
+
const on = b.dataset.mode === editRoutineMode;
|
|
2872
|
+
b.classList.toggle('active', on);
|
|
2873
|
+
b.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
2874
|
+
});
|
|
2875
|
+
const schedKind = document.getElementById('rtn-sched-kind-field');
|
|
2876
|
+
const schedDetail = document.getElementById('rtn-sched-detail');
|
|
2877
|
+
const trigField = document.getElementById('rtn-trigger-field');
|
|
2878
|
+
if (schedKind) schedKind.hidden = trig;
|
|
2879
|
+
if (schedDetail) schedDetail.hidden = trig;
|
|
2880
|
+
if (trigField) trigField.hidden = !trig;
|
|
2881
|
+
if (trig && editTriggerSource) {
|
|
2882
|
+
const t = editTriggerSource;
|
|
2883
|
+
const el = document.getElementById('rtn-trigger-src');
|
|
2884
|
+
if (el) el.textContent = `${t.agent || ''}${t.command ? '/' + t.command : ''}` || 'event source';
|
|
2885
|
+
}
|
|
2886
|
+
const enLabel = document.getElementById('rtn-enabled-label');
|
|
2887
|
+
if (enLabel) enLabel.textContent = trig ? 'Enabled — start watching now' : 'Enabled';
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
// Reveal the schedule-vs-trigger chooser only when it's a real choice: a NEW
|
|
2891
|
+
// routine on a trigger-eligible workflow. On edit the kind is fixed (inputs are
|
|
2892
|
+
// coerced against the workflow), so the chooser is hidden and the saved kind shown.
|
|
2893
|
+
function applyRoutineModeAvailability() {
|
|
2894
|
+
const $modeField = document.getElementById('rtn-mode-field');
|
|
2895
|
+
if (editingRoutineId) {
|
|
2896
|
+
if ($modeField) $modeField.hidden = true; // kind fixed after create
|
|
2897
|
+
setRoutineMode(editRoutineMode);
|
|
2898
|
+
return;
|
|
2899
|
+
}
|
|
2900
|
+
const eligible = !!editTriggerSource;
|
|
2901
|
+
if ($modeField) $modeField.hidden = !eligible;
|
|
2902
|
+
setRoutineMode(eligible ? 'trigger' : 'schedule'); // event is the sensible default for a streaming source
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
function applySchedKind(kind) {
|
|
2906
|
+
document.querySelectorAll('#rtn-sched-detail [data-sched]').forEach((el) => {
|
|
2907
|
+
el.hidden = !el.dataset.sched.split(' ').includes(kind);
|
|
2908
|
+
});
|
|
2909
|
+
}
|
|
2910
|
+
function renderDayChips(selected) {
|
|
2911
|
+
const $days = document.getElementById('rtn-days');
|
|
2912
|
+
$days.innerHTML = RTN_DAY_ORDER.map((d) =>
|
|
2913
|
+
`<button type="button" class="tf-chip rtn-day${selected.includes(d) ? ' active' : ''}" data-day="${d}">${RTN_DAY_LABELS[d]}</button>`).join('');
|
|
2914
|
+
$days.querySelectorAll('.rtn-day').forEach((b) => { b.onclick = () => b.classList.toggle('active'); });
|
|
2915
|
+
}
|
|
2916
|
+
function selectedDays() {
|
|
2917
|
+
return [...document.querySelectorAll('#rtn-days .rtn-day.active')].map((b) => b.dataset.day);
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
// Render the chosen workflow's declared inputs (seeded from defaults / the routine's
|
|
2921
|
+
// saved values). Hidden when the workflow declares none.
|
|
2922
|
+
async function loadRoutineInputs(workflowId, prefill) {
|
|
2923
|
+
const $field = document.getElementById('rtn-inputs-field');
|
|
2924
|
+
const $inputs = document.getElementById('rtn-inputs');
|
|
2925
|
+
$inputs.innerHTML = '';
|
|
2926
|
+
$field.hidden = true;
|
|
2927
|
+
editTriggerSource = null;
|
|
2928
|
+
if (!workflowId) { applyRoutineModeAvailability(); return; }
|
|
2929
|
+
let app;
|
|
2930
|
+
try { ({ app } = await api(`/api/app/${encodeURIComponent(workflowId)}`)); } catch { applyRoutineModeAvailability(); return; }
|
|
2931
|
+
if (document.getElementById('rtn-workflow').value !== workflowId) return; // a newer workflow selection superseded this fetch
|
|
2932
|
+
editTriggerSource = (app && app.triggerSource) || null;
|
|
2933
|
+
applyRoutineModeAvailability(); // reveal/hide the schedule-vs-trigger chooser
|
|
2934
|
+
const inps = (app && app.inputs) || [];
|
|
2935
|
+
if (!inps.length) return;
|
|
2936
|
+
$field.hidden = false;
|
|
2937
|
+
// The DOM id is index-based (not the workflow-controlled input name) so a crafted
|
|
2938
|
+
// input name can't inject into the id/for attributes. The real key rides the
|
|
2939
|
+
// escaped data-rtn-in attribute, which collectRoutineInputs reads.
|
|
2940
|
+
$inputs.innerHTML = inps.map((inp, i) => {
|
|
2941
|
+
const id = `rtn-in-${i}`;
|
|
2942
|
+
const val = prefill && inp.name in prefill ? prefill[inp.name] : (inp.default != null ? inp.default : '');
|
|
2943
|
+
const type = inp.type === 'integer' || inp.type === 'number' ? 'number' : 'text';
|
|
2944
|
+
return `<div class="modal-field"><label for="${id}">${escapeHtml(inp.name)}${inp.description ? ' — ' + escapeHtml(inp.description) : ''}</label>`
|
|
2945
|
+
+ `<input id="${id}" type="${type}" data-rtn-in="${escapeAttr(inp.name)}" value="${escapeAttr(String(val))}"></div>`;
|
|
2946
|
+
}).join('');
|
|
2947
|
+
}
|
|
2948
|
+
function collectRoutineInputs() {
|
|
2949
|
+
const out = {};
|
|
2950
|
+
document.querySelectorAll('#rtn-inputs [data-rtn-in]').forEach((el) => {
|
|
2951
|
+
const v = el.value.trim();
|
|
2952
|
+
if (v === '') return;
|
|
2953
|
+
out[el.dataset.rtnIn] = el.type === 'number' && !isNaN(Number(v)) ? Number(v) : v;
|
|
2954
|
+
});
|
|
2955
|
+
return out;
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
async function openRoutineEdit(id) {
|
|
2959
|
+
editingRoutineId = id || null;
|
|
2960
|
+
const r = id ? routinesData.find((x) => x.id === id) : null;
|
|
2961
|
+
const $wf = document.getElementById('rtn-workflow');
|
|
2962
|
+
try {
|
|
2963
|
+
const { apps: list } = await api('/api/apps');
|
|
2964
|
+
editWorkflows = list;
|
|
2965
|
+
} catch { editWorkflows = []; }
|
|
2966
|
+
$wf.innerHTML = editWorkflows.map((a) => `<option value="${escapeAttr(a.id)}">${escapeHtml(a.id)}</option>`).join('');
|
|
2967
|
+
document.getElementById('rtn-edit-title').textContent = r ? 'Edit routine' : 'New routine';
|
|
2968
|
+
document.getElementById('rtn-name').value = r ? r.name : '';
|
|
2969
|
+
const wfId = r ? r.workflow : (editWorkflows[0] ? editWorkflows[0].id : '');
|
|
2970
|
+
$wf.value = wfId;
|
|
2971
|
+
// Workflow is fixed after create (inputs are coerced against it) — disable on edit.
|
|
2972
|
+
$wf.disabled = !!r;
|
|
2973
|
+
if (r) $wf.setAttribute('data-tip', 'Workflow is fixed — delete and recreate to use a different one');
|
|
2974
|
+
else $wf.removeAttribute('data-tip');
|
|
2975
|
+
editRoutineMode = (r && r.kind === 'trigger') ? 'trigger' : 'schedule';
|
|
2976
|
+
const sched = (r && r.schedule) ? r.schedule : { kind: 'daily', time: '07:00' };
|
|
2977
|
+
fillTimeSelects();
|
|
2978
|
+
document.getElementById('rtn-kind').value = sched.kind;
|
|
2979
|
+
document.getElementById('rtn-everyhours').value = sched.kind === 'hourly' ? String(sched.everyHours) : '2';
|
|
2980
|
+
setTimeSelects(sched.time || '07:00');
|
|
2981
|
+
document.getElementById('rtn-cron').value = sched.kind === 'cron' ? sched.expr : '';
|
|
2982
|
+
renderDayChips(sched.kind === 'weekly' ? (sched.days || []) : []);
|
|
2983
|
+
applySchedKind(sched.kind);
|
|
2984
|
+
document.getElementById('rtn-enabled').checked = r ? r.enabled : true;
|
|
2985
|
+
await loadRoutineInputs(wfId, r ? r.inputs : null);
|
|
2986
|
+
showModal($routineEditModal);
|
|
2987
|
+
setTimeout(() => document.getElementById('rtn-name').focus(), 0);
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
async function saveRoutine() {
|
|
2991
|
+
const name = document.getElementById('rtn-name').value.trim();
|
|
2992
|
+
if (!name) { showToast('Name is required', 'warn'); return; }
|
|
2993
|
+
const workflow = document.getElementById('rtn-workflow').value;
|
|
2994
|
+
if (!workflow) { showToast('Pick a workflow', 'warn'); return; }
|
|
2995
|
+
const inputs = collectRoutineInputs();
|
|
2996
|
+
const enabled = document.getElementById('rtn-enabled').checked;
|
|
2997
|
+
const asTrigger = editRoutineMode === 'trigger' && !!editTriggerSource;
|
|
2998
|
+
let schedule;
|
|
2999
|
+
if (!asTrigger) {
|
|
3000
|
+
const kind = document.getElementById('rtn-kind').value;
|
|
3001
|
+
if (kind === 'hourly') {
|
|
3002
|
+
const n = Math.max(1, Math.min(24, parseInt(document.getElementById('rtn-everyhours').value, 10) || 1));
|
|
3003
|
+
schedule = { kind: 'hourly', everyHours: n };
|
|
3004
|
+
} else if (kind === 'weekly') {
|
|
3005
|
+
const days = selectedDays();
|
|
3006
|
+
if (!days.length) { showToast('Pick at least one weekday', 'warn'); return; }
|
|
3007
|
+
schedule = { kind: 'weekly', days, time: getTimeSelects() };
|
|
3008
|
+
} else if (kind === 'cron') {
|
|
3009
|
+
const expr = document.getElementById('rtn-cron').value.trim();
|
|
3010
|
+
if (!expr) { showToast('Enter a cron expression', 'warn'); return; }
|
|
3011
|
+
schedule = { kind: 'cron', expr };
|
|
3012
|
+
} else {
|
|
3013
|
+
schedule = { kind, time: getTimeSelects() };
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
try {
|
|
3017
|
+
if (editingRoutineId) {
|
|
3018
|
+
const body = asTrigger ? { name, inputs, enabled } : { name, schedule, inputs, enabled };
|
|
3019
|
+
await api(`/api/routines/${encodeURIComponent(editingRoutineId)}`, { method: 'PATCH', body: JSON.stringify(body) });
|
|
3020
|
+
showToast('Routine updated', 'ok');
|
|
3021
|
+
} else {
|
|
3022
|
+
const body = asTrigger ? { kind: 'trigger', name, workflow, inputs, enabled } : { name, workflow, schedule, inputs, enabled };
|
|
3023
|
+
await api('/api/routines', { method: 'POST', body: JSON.stringify(body) });
|
|
3024
|
+
showToast('Routine created', 'ok');
|
|
3025
|
+
}
|
|
3026
|
+
hideModal($routineEditModal);
|
|
3027
|
+
await loadRoutinesData();
|
|
3028
|
+
} catch (e) { showToast(e.message || 'Could not save routine', 'err'); }
|
|
3029
|
+
}
|
|
3030
|
+
|
|
3031
|
+
openRoutines = function openRoutinesReal() {
|
|
3032
|
+
renderRoutinesList();
|
|
3033
|
+
showModal($routinesModal);
|
|
3034
|
+
loadRoutinesData();
|
|
3035
|
+
};
|
|
3036
|
+
|
|
3037
|
+
// Wiring (the modal elements are static in index.html, present when this runs).
|
|
3038
|
+
document.getElementById('routines-btn').onclick = () => openRoutines();
|
|
3039
|
+
document.getElementById('routines-close').onclick = () => hideModal($routinesModal);
|
|
3040
|
+
$routinesModal.onclick = (e) => { if (e.target === $routinesModal) hideModal($routinesModal); };
|
|
3041
|
+
document.getElementById('rtn-add').onclick = () => openRoutineEdit(null);
|
|
3042
|
+
document.getElementById('rtn-edit-cancel').onclick = () => hideModal($routineEditModal);
|
|
3043
|
+
document.getElementById('rtn-edit-save').onclick = () => saveRoutine();
|
|
3044
|
+
$routineEditModal.onclick = (e) => { if (e.target === $routineEditModal) hideModal($routineEditModal); };
|
|
3045
|
+
document.getElementById('rtn-kind').onchange = (e) => applySchedKind(e.target.value);
|
|
3046
|
+
document.querySelectorAll('#rtn-mode-field .rtn-mode-btn').forEach((b) => { b.onclick = () => setRoutineMode(b.dataset.mode); });
|
|
3047
|
+
document.getElementById('rtn-workflow').onchange = (e) => { if (!editingRoutineId) loadRoutineInputs(e.target.value, null); };
|
|
3048
|
+
document.addEventListener('keydown', (e) => {
|
|
3049
|
+
if (e.key !== 'Escape') return;
|
|
3050
|
+
if ($routineEditModal.classList.contains('show')) hideModal($routineEditModal);
|
|
3051
|
+
else if ($routinesModal.classList.contains('show')) hideModal($routinesModal);
|
|
3052
|
+
});
|
|
3053
|
+
|
|
3054
|
+
// ── init (after app.js bootstrap) ────────────────────────────────────────────
|
|
3055
|
+
// app.js already stored the ORIGINAL functions as .onclick/.oninput before we
|
|
3056
|
+
// reassigned the globals; rebind via arrows so they resolve our versions at
|
|
3057
|
+
// call-time (otherwise Save/library-search silently run the demo code).
|
|
3058
|
+
$browseBtn.onclick = () => openLibrary();
|
|
3059
|
+
$libSearch.oninput = () => renderLibrary();
|
|
3060
|
+
|
|
3061
|
+
// Compile-notes strip: × collapses it to a faint one-line pill; clicking the
|
|
3062
|
+
// collapsed pill re-expands. Delegated (the strip's innerHTML is rebuilt each
|
|
3063
|
+
// load). The choice is remembered per app+note-signature (see notesSig).
|
|
3064
|
+
$notesStrip.addEventListener('click', (e) => {
|
|
3065
|
+
if (e.target.closest('.notes-dismiss')) $notesStrip.classList.add('collapsed');
|
|
3066
|
+
else if ($notesStrip.classList.contains('collapsed')) $notesStrip.classList.remove('collapsed');
|
|
3067
|
+
else return; // click inside the expanded body — nothing to toggle
|
|
3068
|
+
const id = $notesStrip.dataset.appId, sig = $notesStrip.dataset.sig;
|
|
3069
|
+
if (id && sig) saveNotesCollapsed(id, sig, $notesStrip.classList.contains('collapsed'));
|
|
3070
|
+
});
|
|
3071
|
+
|
|
3072
|
+
// Take over the menu/keyboard Save from app.js's demo stub: in the real app the
|
|
3073
|
+
// UI can't author the .flo (that's the terminal AI's job), so "Save" persists the
|
|
3074
|
+
// current workflow's input values locally. "Save to…" (demo .flo download) is
|
|
3075
|
+
// retired — its menu item is removed; neutralize its Ctrl+Shift+S shortcut too.
|
|
3076
|
+
doSave = () => saveCurrentInputs();
|
|
3077
|
+
doSaveAs = () => saveCurrentInputs();
|
|
3078
|
+
// "Open" can't open a local .flo — a workflow is an installed AWARE app, not a
|
|
3079
|
+
// file the UI authors. It opens the searchable workflow picker so you can find one.
|
|
3080
|
+
doOpen = () => openCombo();
|
|
3081
|
+
const $favSave = document.getElementById('fav-save');
|
|
3082
|
+
if ($favSave) $favSave.onclick = () => commitFav();
|
|
3083
|
+
|
|
3084
|
+
const $tweakBtn = document.getElementById('tweak-btn');
|
|
3085
|
+
if ($tweakBtn) $tweakBtn.onclick = () => { tweakNode(); };
|
|
3086
|
+
const $debugBtn = document.getElementById('debug-btn');
|
|
3087
|
+
if ($debugBtn) $debugBtn.onclick = () => { if (state.selectedAgentId) runReport(state.selectedAgentId, { debug: true }); };
|
|
3088
|
+
const $reqStat = document.getElementById('req-stat');
|
|
3089
|
+
if ($reqStat) $reqStat.onclick = () => openRequests();
|
|
3090
|
+
const $reqModal = document.getElementById('requests-modal');
|
|
3091
|
+
const $reqClose = document.getElementById('requests-close');
|
|
3092
|
+
if ($reqClose) $reqClose.onclick = () => $reqModal.classList.remove('show');
|
|
3093
|
+
if ($reqModal) $reqModal.onclick = (e) => { if (e.target === $reqModal) $reqModal.classList.remove('show'); };
|
|
3094
|
+
const $reqClear = document.getElementById('requests-clear');
|
|
3095
|
+
if ($reqClear) $reqClear.onclick = () => clearAllRequests();
|
|
3096
|
+
const $reqCopy = document.getElementById('requests-copy');
|
|
3097
|
+
if ($reqCopy) $reqCopy.onclick = async () => {
|
|
3098
|
+
if (!pendingRequests.length) return;
|
|
3099
|
+
const text = pendingRequests.map(instructionFor).filter(Boolean).join('\n\n');
|
|
3100
|
+
const copied = await copyToClipboard(text);
|
|
3101
|
+
showToast(copied ? `Copied ${pendingRequests.length} request(s) — paste to your terminal AI` : 'copy failed', copied ? 'ok' : 'err');
|
|
3102
|
+
};
|
|
3103
|
+
document.addEventListener('keydown', (e) => {
|
|
3104
|
+
if (e.key === 'Escape' && $reqModal && $reqModal.classList.contains('show')) $reqModal.classList.remove('show');
|
|
3105
|
+
});
|
|
3106
|
+
|
|
3107
|
+
// Pull all workspace data (agents/templates/requests + the apps list). Factored
|
|
3108
|
+
// out of bootWorkspace so the health poll can re-pull it after the server comes
|
|
3109
|
+
// back from a boot-time outage (setServerStatus → recovery).
|
|
3110
|
+
function loadWorkspaceData() {
|
|
3111
|
+
Promise.all([
|
|
3112
|
+
loadAgentCatalog().catch(() => {}),
|
|
3113
|
+
loadTemplates().catch(() => {}),
|
|
3114
|
+
loadRequests().catch(() => {}),
|
|
3115
|
+
]);
|
|
3116
|
+
return loadApps().catch((e) => {
|
|
3117
|
+
reportErr(e);
|
|
3118
|
+
setComboTriggerLabel('server unreachable');
|
|
3119
|
+
renderCanvasPlaceholder('offline');
|
|
3120
|
+
});
|
|
3121
|
+
}
|
|
3122
|
+
|
|
3123
|
+
// Load the workspace. Entered ONLY for a valid (or offline-grace) subscription AND
|
|
3124
|
+
// after AWARE is ready (see the boot sequence below). The license-independent setup
|
|
3125
|
+
// — overlay CSS, the health poll, and SSE — runs in the boot sequence, so a
|
|
3126
|
+
// setting-up/failed runtime is surfaced even to a signed-out user instead of being
|
|
3127
|
+
// masked by the gate.
|
|
3128
|
+
function bootWorkspace() {
|
|
3129
|
+
loadWorkspaceData();
|
|
3130
|
+
// The demo bootstrap scheduled narration appends (≤720ms out) before we
|
|
3131
|
+
// overrode renderChat; re-render once they've all fired to wipe stragglers.
|
|
3132
|
+
setTimeout(() => { if (currentId) renderChat(); }, 900);
|
|
3133
|
+
}
|
|
3134
|
+
|
|
3135
|
+
// Subscription gate (the server enforces it on every /api/* with 402; the UI
|
|
3136
|
+
// mirrors it so an ungated user sees a sign-in panel, not a broken workspace).
|
|
3137
|
+
// We fetch NO workspace data while ungated. The license endpoints stay open.
|
|
3138
|
+
function renderGate(status) {
|
|
3139
|
+
if (document.getElementById('license-gate')) return;
|
|
3140
|
+
const expired = status.state === 'expired';
|
|
3141
|
+
// signInUrl is server-generated, but escape it anyway before it lands in an
|
|
3142
|
+
// innerHTML href — a crafted FLOLESS_WEB_BASE shouldn't be able to break out.
|
|
3143
|
+
const subUrl = escapeHtml(status.signInUrl || 'https://floless.io');
|
|
3144
|
+
const style = document.createElement('style');
|
|
3145
|
+
style.textContent = `
|
|
3146
|
+
#license-gate{position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;
|
|
3147
|
+
background:var(--bg,#020817);color:var(--text,#f8fafc);font-family:var(--ui,system-ui,sans-serif);}
|
|
3148
|
+
#license-gate .lg-card{max-width:420px;text-align:center;padding:40px 36px;border-radius:14px;
|
|
3149
|
+
background:var(--surface,#0b1424);border:1px solid var(--border,#1e293b);box-shadow:0 20px 60px rgba(0,0,0,.5);}
|
|
3150
|
+
#license-gate .lg-mark{color:var(--accent,#3b82f6);width:54px;height:54px;margin:0 auto 18px;}
|
|
3151
|
+
#license-gate h1{font-size:20px;margin:0 0 8px;font-weight:600;}
|
|
3152
|
+
#license-gate p{font-size:14px;line-height:1.5;color:var(--text-dim,#94a3b8);margin:0 0 22px;}
|
|
3153
|
+
#license-gate .primary{appearance:none;border:none;cursor:pointer;font-size:14px;font-weight:600;
|
|
3154
|
+
padding:11px 22px;border-radius:8px;background:var(--accent,#3b82f6);color:#fff;}
|
|
3155
|
+
#license-gate .primary:hover{filter:brightness(1.08);}
|
|
3156
|
+
#license-gate .lg-status{min-height:18px;margin:14px 0 0;font-size:12.5px;color:var(--text-dim,#94a3b8);}
|
|
3157
|
+
#license-gate .lg-sub{display:inline-block;margin-top:6px;font-size:12.5px;color:var(--accent,#3b82f6);text-decoration:none;}
|
|
3158
|
+
#license-gate .lg-sub:hover{text-decoration:underline;}
|
|
3159
|
+
#license-gate .lg-version{margin:18px 0 0;font-size:11px;font-family:var(--mono,monospace);color:var(--text-muted,#9aa5b6);}`;
|
|
3160
|
+
document.head.appendChild(style);
|
|
3161
|
+
const wrap = document.createElement('div');
|
|
3162
|
+
wrap.id = 'license-gate';
|
|
3163
|
+
wrap.innerHTML = `
|
|
3164
|
+
<div class="lg-card">
|
|
3165
|
+
<div class="lg-mark"><svg viewBox="0 0 192 181" fill="none" xmlns="http://www.w3.org/2000/svg" width="54" height="54">
|
|
3166
|
+
<path d="M93.4818 26.2302L32.3762 26.2313C25.5914 26.2314 19.347 32.217 15.0271 38.0025C11.2275 43.0912 8.72093 49.9322 8.72093 56.5314C8.72093 64.7036 10.9644 71.8345 15.0271 77.7851C19.2332 83.9459 26.1884 90.7551 32.3762 90.7551H159.101C165.715 90.7551 171.878 93.8886 176.177 98.8111C180.654 103.937 183.14 111.953 183.14 118.757C183.14 126.308 181.52 131.542 177.974 137.069C174.152 143.027 159.887 151.23 159.887 151.23" stroke="currentColor" stroke-width="11.6719" stroke-linecap="round"/>
|
|
3167
|
+
<path d="M90.1163 8.72095L106.704 26.7965" stroke="currentColor" stroke-width="11.6712" stroke-linecap="round"/>
|
|
3168
|
+
<path d="M90.1163 44.9346L106.704 26.859" stroke="currentColor" stroke-width="11.6712" stroke-linecap="round"/>
|
|
3169
|
+
<path d="M78.5336 136.746L95.1213 154.822" stroke="currentColor" stroke-width="11.6712" stroke-linecap="round"/>
|
|
3170
|
+
<path d="M78.5336 172.96L95.1213 154.885" stroke="currentColor" stroke-width="11.6712" stroke-linecap="round"/>
|
|
3171
|
+
<path d="M61.4378 154.466H82.2457" stroke="currentColor" stroke-width="11.6712" stroke-linecap="square"/>
|
|
3172
|
+
<path class="mark-node" d="M33.4144 174.908C43.8527 174.908 52.3146 166.446 52.3146 156.008C52.3146 145.57 43.8527 137.108 33.4144 137.108C22.9762 137.108 14.5143 145.57 14.5143 156.008C14.5143 166.446 22.9762 174.908 33.4144 174.908Z" fill="white" stroke="currentColor" stroke-width="8.72093" stroke-linecap="round" stroke-linejoin="round"/>
|
|
3173
|
+
<path class="mark-node" d="M138.092 174.908C148.53 174.908 156.992 166.446 156.992 156.008C156.992 145.57 148.53 137.108 138.092 137.108C127.654 137.108 119.192 145.57 119.192 156.008C119.192 166.446 127.654 174.908 138.092 174.908Z" fill="white" stroke="currentColor" stroke-width="8.72093" stroke-linecap="round" stroke-linejoin="round"/>
|
|
3174
|
+
<path class="mark-node" d="M149.714 43.6142C160.152 43.6142 168.614 35.1523 168.614 24.7141C168.614 14.2758 160.152 5.81396 149.714 5.81396C139.276 5.81396 130.814 14.2758 130.814 24.7141C130.814 35.1523 139.276 43.6142 149.714 43.6142Z" fill="white" stroke="currentColor" stroke-width="8.72093" stroke-linecap="round" stroke-linejoin="round"/>
|
|
3175
|
+
</svg></div>
|
|
3176
|
+
<h1>${expired ? 'Your subscription has expired' : 'Subscription required'}</h1>
|
|
3177
|
+
<p>${expired
|
|
3178
|
+
? 'Renew your FloLess subscription to keep using floless.app.'
|
|
3179
|
+
: 'floless.app runs on your FloLess subscription. Sign in to continue.'}</p>
|
|
3180
|
+
<button id="lg-signin" class="primary">Sign in at floless.io</button>
|
|
3181
|
+
<p class="lg-status" id="lg-status"></p>
|
|
3182
|
+
<a class="lg-sub" href="${subUrl}" target="_blank" rel="noopener">${expired ? 'Manage subscription →' : 'No subscription yet? Subscribe →'}</a>
|
|
3183
|
+
<p class="lg-version" id="lg-version"></p>
|
|
3184
|
+
</div>`;
|
|
3185
|
+
document.body.appendChild(wrap);
|
|
3186
|
+
// Show the installed build (so a stale install is self-diagnosable from the
|
|
3187
|
+
// gate — the highest-value spot since that's where a confused user lands).
|
|
3188
|
+
// /api/health is un-gated, so this works even while unlicensed.
|
|
3189
|
+
fetch('/api/health', { cache: 'no-store' })
|
|
3190
|
+
.then((r) => r.json())
|
|
3191
|
+
.then((h) => { if (h && h.appVersion) document.getElementById('lg-version').textContent = 'v' + h.appVersion; })
|
|
3192
|
+
.catch(() => { /* version is a nicety; never block the gate on it */ });
|
|
3193
|
+
document.getElementById('lg-signin').onclick = async () => {
|
|
3194
|
+
const s = document.getElementById('lg-status');
|
|
3195
|
+
s.textContent = 'Opening sign-in in your browser…';
|
|
3196
|
+
try { await api('/api/license/start', { method: 'POST' }); } catch { /* ignore */ }
|
|
3197
|
+
s.textContent = 'Waiting for sign-in to complete…';
|
|
3198
|
+
const deadline = Date.now() + 185000;
|
|
3199
|
+
const tick = async () => {
|
|
3200
|
+
let st; try { st = await api('/api/license/status'); } catch { st = {}; }
|
|
3201
|
+
if (st.state === 'valid' || st.state === 'offline-grace') { location.reload(); return; }
|
|
3202
|
+
if (Date.now() > deadline) { s.textContent = 'Timed out — click to try again.'; return; }
|
|
3203
|
+
setTimeout(tick, 2000);
|
|
3204
|
+
};
|
|
3205
|
+
tick();
|
|
3206
|
+
};
|
|
3207
|
+
}
|
|
3208
|
+
|
|
3209
|
+
// ── boot ──────────────────────────────────────────────────────────────────────
|
|
3210
|
+
// AWARE setup is orthogonal to the subscription, so surface the bootstrap state and
|
|
3211
|
+
// run the health poll REGARDLESS of license — and show "Setting up AWARE…" FIRST.
|
|
3212
|
+
// The runtime must exist before a sign-in gate or workspace is meaningful, and a
|
|
3213
|
+
// FAILED install must be visible (with its remediation) rather than hidden behind
|
|
3214
|
+
// the gate. Once AWARE is ready we route: valid subscription → workspace, else gate.
|
|
3215
|
+
(async () => {
|
|
3216
|
+
initServerStatusUI(); // overlay CSS + server-status light (license-independent)
|
|
3217
|
+
startHealthPoll(); // drives the bootstrap overlay via the un-gated /api/health
|
|
3218
|
+
|
|
3219
|
+
let licenseStatus = null;
|
|
3220
|
+
let licenseFetchFailed = false;
|
|
3221
|
+
try { licenseStatus = await api('/api/license/status'); }
|
|
3222
|
+
catch { licenseFetchFailed = true; /* server unreachable → the offline-overlay path (health poll) covers it */ }
|
|
3223
|
+
const licensed = !!licenseStatus &&
|
|
3224
|
+
(licenseStatus.state === 'valid' || licenseStatus.state === 'offline-grace');
|
|
3225
|
+
// SSE carries the live bootstrap ticker + workspace events, but /api/events is
|
|
3226
|
+
// subscription-gated — an unlicensed EventSource would 402 → a false "server
|
|
3227
|
+
// offline". So connect only when licensed; it's safe to connect during install
|
|
3228
|
+
// (the handlers no-op until a workspace is loaded).
|
|
3229
|
+
if (licensed) connectSse();
|
|
3230
|
+
|
|
3231
|
+
let routed = false;
|
|
3232
|
+
onBootstrapReady = async () => {
|
|
3233
|
+
if (routed) return;
|
|
3234
|
+
routed = true; // gate re-entry before any await
|
|
3235
|
+
let effectiveStatus = licenseStatus;
|
|
3236
|
+
let effectivelyLicensed = licensed;
|
|
3237
|
+
// License fetch failed during boot — could be a transient blip while
|
|
3238
|
+
// health later recovered. Retry once before falling through to the gate
|
|
3239
|
+
// so a momentary 5xx on /api/license/status doesn't strand a signed-in
|
|
3240
|
+
// user at "no subscription". (A genuinely-down server is covered by the
|
|
3241
|
+
// offline overlay driven by the health poll.)
|
|
3242
|
+
if (licenseFetchFailed) {
|
|
3243
|
+
try {
|
|
3244
|
+
const r = await api('/api/license/status');
|
|
3245
|
+
effectiveStatus = r;
|
|
3246
|
+
effectivelyLicensed = !!r && (r.state === 'valid' || r.state === 'offline-grace');
|
|
3247
|
+
// The synchronous boot path skipped connectSse() because `licensed`
|
|
3248
|
+
// was false. Recover live updates now that we know we're licensed —
|
|
3249
|
+
// otherwise the workspace would render without SSE and feel stale.
|
|
3250
|
+
if (effectivelyLicensed && !licensed) connectSse();
|
|
3251
|
+
} catch { /* still failing — fall through to gate */ }
|
|
3252
|
+
}
|
|
3253
|
+
if (effectivelyLicensed) bootWorkspace();
|
|
3254
|
+
else renderGate(effectiveStatus || { state: 'none' });
|
|
3255
|
+
};
|
|
3256
|
+
|
|
3257
|
+
// Setup-first: if AWARE isn't ready, show the overlay and DEFER routing until it
|
|
3258
|
+
// is (the health poll + finishBootstrapReady → onBootstrapReady do the routing).
|
|
3259
|
+
let h = null;
|
|
3260
|
+
try { h = await (await fetch('/api/health', { cache: 'no-store' })).json(); }
|
|
3261
|
+
catch { /* health unreachable → route now; the offline overlay handles a down server */ }
|
|
3262
|
+
if (h && h.bootstrap && h.bootstrap !== 'ready') {
|
|
3263
|
+
updateBootstrap({ status: h.bootstrap, reason: h.bootstrapReason ?? null, remediation: h.bootstrapRemediation ?? null });
|
|
3264
|
+
} else {
|
|
3265
|
+
// Health says ready — but the parallel health poll may have transiently
|
|
3266
|
+
// observed installing/probing between this boot's two health fetches and
|
|
3267
|
+
// raised the overlay. finishBootstrapReady both dismisses any raised
|
|
3268
|
+
// overlay (with the brief Ready dwell) AND routes via onBootstrapReady,
|
|
3269
|
+
// collapsing the two paths into one and avoiding "workspace painted
|
|
3270
|
+
// behind a stuck bootstrap card" until the next poll tick.
|
|
3271
|
+
finishBootstrapReady();
|
|
3272
|
+
}
|
|
3273
|
+
})();
|
|
3274
|
+
})();
|