codetac 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/panel.mjs ADDED
@@ -0,0 +1,800 @@
1
+ // Painel: lista as ações do browser (Fase 2) e os pedidos gravados (Fase 1) e
2
+ // mostra, para cada um, a vista agrupada com frases de finalidade (Fase 3), a
3
+ // sequência completa por expansão e o código de cada função.
4
+ // Uso: node src/panel.mjs [--port 4000]
5
+ import http from 'node:http';
6
+ import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
7
+ import { join, relative, sep, isAbsolute } from 'node:path';
8
+ import { openStore } from './store.mjs';
9
+ import { dataDirectory } from './home.mjs';
10
+ import { createActionView } from './action-view.mjs';
11
+ import { digestAction, digestRequest } from './digest.mjs';
12
+ import { answerQuestion, createPurposes, describeConfig, loadConfig } from './ai.mjs';
13
+
14
+ const directory = dataDirectory();
15
+ const portIndex = process.argv.indexOf('--port');
16
+ const port = Number(portIndex > 0 ? process.argv[portIndex + 1] : process.env.CODETAC_PANEL_PORT || 4000);
17
+ const store = openStore(directory, { keep: Number(process.env.CODETAC_KEEP || 500) });
18
+
19
+ function json(response, status, value) {
20
+ response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
21
+ response.end(JSON.stringify(value));
22
+ }
23
+
24
+ // Only files inside the observed project of that recording can be shown.
25
+ function source(run, file, line, endLine) {
26
+ const root = store.root(run);
27
+ if (!root || !file || !isAbsolute(file)) return null;
28
+ let real;
29
+ try { real = realpathSync(file); } catch { return null; }
30
+ const within = relative(realpathSync(root), real);
31
+ if (within === '' || within.startsWith(`..${sep}`) || within === '..' || isAbsolute(within) || within.split(sep).includes('node_modules')) return null;
32
+ // Never hidden files (.env and similar), and only files with recorded functions.
33
+ if (within.split(sep).some(part => part.startsWith('.') && part !== '.next')) return null;
34
+ if (!store.recordedFile(run, file) && !view.browserFile(run, real)) return null;
35
+ const lines = readFileSync(real, 'utf8').split('\n');
36
+ const start = Math.max(1, line);
37
+ const end = Math.min(lines.length, endLine && endLine >= start ? Math.min(endLine, start + 400) : start + 40);
38
+ return { file: within, start, end, lines: lines.slice(start - 1, end) };
39
+ }
40
+
41
+ const view = createActionView(store);
42
+ const ai = await loadConfig({ directory });
43
+ const lang = ai.lang;
44
+ const purposes = ai.provider ? createPurposes({ config: ai, cache: store.purposeCache, readCode: source }) : null;
45
+ if (ai.provider) console.log(`Frases de finalidade: ${ai.model} (${ai.provider}${ai.local ? ', local: nada sai da máquina' : ', excertos redigidos são enviados'}).`);
46
+ else console.log(`Frases de finalidade: fixas, geradas dos factos${ai.problem ? ` (${ai.problem})` : ' (sem modelo de IA configurado)'}.`);
47
+
48
+ // Editor links (Phase 4): CODETAC_EDITOR = vscode (default), cursor, windsurf, nenhum.
49
+ const EDITORS = { vscode: 'vscode', cursor: 'cursor', windsurf: 'windsurf', vscodium: 'vscodium' };
50
+ const editor = String(process.env.CODETAC_EDITOR ?? 'vscode').toLowerCase();
51
+ const editorScheme = EDITORS[editor] ?? null;
52
+
53
+ // Detail requests live in the recording's folder, where the running
54
+ // application reads them (runtime.mjs).
55
+ function detailPath(run) {
56
+ if (!/^[\w.-]+$/.test(run ?? '') || !existsSync(join(directory, run))) return null;
57
+ return join(directory, run, 'detalhe.json');
58
+ }
59
+ function readDetail(run) {
60
+ const path = detailPath(run);
61
+ if (!path) return null;
62
+ try { return { functions: [], files: [], ...JSON.parse(readFileSync(path, 'utf8')) }; } catch { return { functions: [], files: [] }; }
63
+ }
64
+ async function body(request) {
65
+ let text = '';
66
+ for await (const chunk of request) { text += chunk; if (text.length > 100_000) throw new Error('Pedido demasiado grande.'); }
67
+ return JSON.parse(text || '{}');
68
+ }
69
+
70
+ async function actionWithDigest(id) {
71
+ const dossier = await view.resolvedAction(id);
72
+ if (dossier) dossier.digest = digestAction(dossier, { lang });
73
+ return dossier;
74
+ }
75
+ function requestWithDigest(id) {
76
+ store.ingest();
77
+ const dossier = store.dossier(id);
78
+ if (dossier) dossier.digest = digestRequest(dossier, { lang });
79
+ return dossier;
80
+ }
81
+
82
+ const server = http.createServer(async (request, response) => {
83
+ // Local only: a page served from another name (DNS rebinding) is refused.
84
+ if (!new Set([`127.0.0.1:${port}`, `localhost:${port}`]).has(request.headers.host)) {
85
+ json(response, 403, { error: 'Acesso permitido apenas em 127.0.0.1.' });
86
+ return;
87
+ }
88
+ const url = new URL(request.url, 'http://localhost');
89
+ try {
90
+ if (url.pathname === '/') {
91
+ // The page may be framed only by local pages (the bar in the application).
92
+ response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store',
93
+ 'content-security-policy': "frame-ancestors 'self' http://localhost:* http://127.0.0.1:* http://*.localhost:*" });
94
+ response.end(page);
95
+ return;
96
+ }
97
+ if (url.pathname === '/favicon.ico') {
98
+ response.writeHead(204);
99
+ response.end();
100
+ return;
101
+ }
102
+ if (url.pathname === '/api/ping') {
103
+ json(response, 200, { ok: true });
104
+ return;
105
+ }
106
+ if (url.pathname === '/api/runs') {
107
+ store.ingest();
108
+ json(response, 200, store.listRuns());
109
+ return;
110
+ }
111
+ if (url.pathname === '/api/requests') {
112
+ store.ingest();
113
+ json(response, 200, store.listRequests({ limit: Number(url.searchParams.get('limit') || 200), run: url.searchParams.get('run') || undefined,
114
+ withoutAction: url.searchParams.get('semAcao') === '1' }));
115
+ return;
116
+ }
117
+ if (url.pathname === '/api/actions') {
118
+ store.ingest();
119
+ json(response, 200, store.listActions({ limit: Number(url.searchParams.get('limit') || 200), run: url.searchParams.get('run') || undefined }));
120
+ return;
121
+ }
122
+ if (url.pathname === '/api/config') {
123
+ json(response, 200, { ...describeConfig(ai), editor: editorScheme });
124
+ return;
125
+ }
126
+ // Changes are accepted only from the panel itself: a custom header (which
127
+ // a page elsewhere cannot send without a preflight) and a local origin.
128
+ if (request.method === 'POST' && (request.headers['x-codetac'] !== '1'
129
+ || (request.headers.origin && !new Set([`http://127.0.0.1:${port}`, `http://localhost:${port}`]).has(request.headers.origin)))) {
130
+ json(response, 403, { error: 'Pedido recusado.' });
131
+ return;
132
+ }
133
+ if (url.pathname === '/api/detalhe') {
134
+ const run = request.method === 'POST' ? null : url.searchParams.get('run');
135
+ if (request.method !== 'POST') {
136
+ const spec = readDetail(run);
137
+ json(response, spec ? 200 : 404, spec ?? { error: 'Gravação desconhecida.' });
138
+ return;
139
+ }
140
+ const change = await body(request);
141
+ const spec = readDetail(change.run);
142
+ if (!spec) { json(response, 404, { error: 'Gravação desconhecida.' }); return; }
143
+ const same = item => item.file === change.file && item.line === change.line && (item.function ?? '') === (change.function ?? '');
144
+ if (change.op === 'limpar') { spec.functions = []; spec.files = []; }
145
+ else if (change.op === 'ficheiro') spec.files = spec.files.includes(change.file) ? spec.files.filter(file => file !== change.file) : [...spec.files, change.file];
146
+ else if (change.op === 'funcao') spec.functions = spec.functions.some(same) ? spec.functions.filter(item => !same(item))
147
+ : [...spec.functions, { file: change.file, line: change.line, function: change.function }];
148
+ spec.updatedAt = Date.now();
149
+ writeFileSync(detailPath(change.run), JSON.stringify(spec, null, 2));
150
+ json(response, 200, spec);
151
+ return;
152
+ }
153
+ if (url.pathname === '/api/pergunta' && request.method === 'POST') {
154
+ const asked = await body(request);
155
+ const dossier = requestWithDigest(String(asked.requestId ?? ''));
156
+ if (!dossier) { json(response, 404, { error: 'Pedido não encontrado.' }); return; }
157
+ json(response, 200, await answerQuestion({ config: ai, dossier, stepId: String(asked.stepId ?? ''), question: String(asked.question ?? ''), readCode: source }));
158
+ return;
159
+ }
160
+ // Purpose sentences of the AI model: generated in the background, the
161
+ // panel asks again until they are all there.
162
+ const wanted = url.pathname.match(/^\/api\/(actions|requests)\/(.+)\/finalidades$/);
163
+ if (wanted) {
164
+ if (!purposes) { json(response, 200, { finished: true, purposes: {}, done: 0, total: 0, errors: [] }); return; }
165
+ const id = decodeURIComponent(wanted[2]);
166
+ json(response, 200, purposes.status(`${wanted[1]}:${id}`, async () => {
167
+ if (wanted[1] === 'requests') return { dossiers: [requestWithDigest(id)].filter(Boolean) };
168
+ const action = await actionWithDigest(id);
169
+ if (!action) return { dossiers: [] };
170
+ if (action.pending) throw new Error('A ação ainda está em curso.');
171
+ return { action, dossiers: action.timeline.flatMap(item => item.server ?? []) };
172
+ }));
173
+ return;
174
+ }
175
+ const action = url.pathname.match(/^\/api\/actions\/(.+)$/);
176
+ if (action) {
177
+ const dossier = await actionWithDigest(decodeURIComponent(action[1]));
178
+ json(response, dossier ? 200 : 404, dossier ?? { error: 'Ação ainda não gravada ou desconhecida.' });
179
+ return;
180
+ }
181
+ const match = url.pathname.match(/^\/api\/requests\/(.+)$/);
182
+ if (match) {
183
+ const dossier = requestWithDigest(decodeURIComponent(match[1]));
184
+ json(response, dossier ? 200 : 404, dossier ?? { error: 'Pedido não encontrado.' });
185
+ return;
186
+ }
187
+ if (url.pathname === '/api/source') {
188
+ const result = source(url.searchParams.get('run'), url.searchParams.get('file'),
189
+ Number(url.searchParams.get('line')), Number(url.searchParams.get('end')) || null);
190
+ json(response, result ? 200 : 404, result ?? { error: 'Código indisponível para este ficheiro.' });
191
+ return;
192
+ }
193
+ json(response, 404, { error: 'Não encontrado.' });
194
+ } catch (error) {
195
+ json(response, 500, { error: String(error.message) });
196
+ }
197
+ });
198
+ server.listen(port, '127.0.0.1', () => console.log(`Painel do CodeTAC: http://127.0.0.1:${port}`));
199
+
200
+ const page = `<!doctype html>
201
+ <html lang="pt">
202
+ <head>
203
+ <meta charset="utf-8">
204
+ <meta name="viewport" content="width=device-width, initial-scale=1">
205
+ <title>CodeTAC — dossiês</title>
206
+ <style>
207
+ :root { --bg:#f7f7f5; --panel:#fff; --text:#1d1d1b; --muted:#6b6b66; --line:#e4e3de; --accent:#2f5bd3;
208
+ --db:#0f7b5f; --http:#6a4bc4; --ia:#b4531f; --mail:#1f73b4; --pay:#9b2c86; --file:#7a6a12; --auth:#3d6b2f; --error:#c0392b; --code:#f1f0ec;
209
+ --browser:#0b6e99; --server:#5b4bb4; }
210
+ @media (prefers-color-scheme: dark) { :root { --bg:#161615; --panel:#1f1f1d; --text:#ecebe6; --muted:#9a9993; --line:#33332f; --accent:#7ea2ff;
211
+ --db:#43c49f; --http:#a78bfa; --ia:#f59e6b; --mail:#6cb4ee; --pay:#e27dcc; --file:#d9c65a; --auth:#8bc77a; --error:#ff7b6b; --code:#262624;
212
+ --browser:#5cc2ec; --server:#a99bf5; } }
213
+ * { box-sizing:border-box; }
214
+ body { margin:0; font:14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; background:var(--bg); color:var(--text); }
215
+ header { padding:10px 20px; border-bottom:1px solid var(--line); display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
216
+ header h1 { font-size:16px; margin:0; }
217
+ header .tabs { display:flex; gap:4px; }
218
+ header .tabs button { font:inherit; font-size:13px; color:var(--muted); background:none; border:1px solid transparent; border-radius:6px; padding:3px 10px; cursor:pointer; }
219
+ header .tabs button.on { color:var(--text); border-color:var(--line); background:var(--panel); }
220
+ header .runs { margin-left:auto; color:var(--muted); font-size:12.5px; }
221
+ header select { font:inherit; color:var(--text); background:var(--panel); border:1px solid var(--line); border-radius:6px; padding:3px 6px; max-width:320px; }
222
+ main { display:grid; grid-template-columns:minmax(280px, 380px) 1fr; height:calc(100vh - 49px); }
223
+ body.embed header, body.embed #list { display:none; }
224
+ body.embed main { grid-template-columns:1fr; height:100vh; }
225
+ body.embed #detail { padding:12px 16px 30px; }
226
+ #list { overflow:auto; border-right:1px solid var(--line); background:var(--panel); }
227
+ #list button { all:unset; display:block; width:100%; padding:9px 14px; border-bottom:1px solid var(--line); cursor:pointer; }
228
+ #list button:hover, #list button.active { background:var(--code); }
229
+ .req-line { display:flex; gap:8px; align-items:baseline; }
230
+ .method { font-weight:600; font-size:12px; min-width:44px; }
231
+ .path { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-family:ui-monospace, Menlo, monospace; font-size:13px; }
232
+ .label { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:13.5px; font-weight:500; }
233
+ .meta { color:var(--muted); font-size:12px; margin-top:2px; }
234
+ .status-ok { color:var(--db); } .status-bad { color:var(--error); }
235
+ #detail { overflow:auto; padding:18px 24px 40px; }
236
+ .empty { color:var(--muted); padding:40px 0; }
237
+ h2 { font-size:17px; margin:0 0 4px; }
238
+ h2.mono { font-family:ui-monospace, Menlo, monospace; }
239
+ .note { color:var(--muted); font-size:12.5px; margin:0 0 16px; }
240
+ .minimal { border:1px solid #d9a200; background:rgba(217,162,0,.10); border-radius:6px; padding:8px 10px; font-size:13px; margin:0 0 12px; }
241
+ .step { display:grid; padding:2px 6px; border-radius:6px; align-items:baseline; }
242
+ .step.fn, .step.src { cursor:pointer; } .step.fn:hover, .step.src:hover { background:var(--code); }
243
+ .step .name { font-family:ui-monospace, Menlo, monospace; font-size:13px; }
244
+ .step .where { color:var(--muted); font-size:12px; margin-left:8px; font-family:ui-monospace, Menlo, monospace; }
245
+ .step .time { color:var(--muted); font-size:12px; font-variant-numeric:tabular-nums; white-space:nowrap; }
246
+ .step.error .name { color:var(--error); }
247
+ .tag { display:inline-block; font-size:11px; font-weight:600; padding:0 6px; border-radius:4px; margin-right:6px; border:1px solid currentColor; }
248
+ .k-base-de-dados { color:var(--db); } .k-http { color:var(--http); } .k-ia { color:var(--ia); } .k-email, .k-mensagem { color:var(--mail); }
249
+ .k-pagamento { color:var(--pay); } .k-ficheiros { color:var(--file); } .k-autenticação { color:var(--auth); }
250
+ .k-browser { color:var(--browser); } .k-servidor { color:var(--server); }
251
+ .boundary .name, .plain .name { font-family:system-ui, sans-serif; }
252
+ .boundary .detail, .plain .detail { color:var(--muted); font-size:12px; font-family:ui-monospace, Menlo, monospace; display:block; margin-left:0; white-space:pre-wrap; word-break:break-word; }
253
+ .cross { margin:6px 0 2px; padding:4px 8px; border-left:3px solid var(--server); background:var(--panel); border-radius:0 6px 6px 0; font-size:13px; }
254
+ .cross.browser { border-left-color:var(--browser); }
255
+ .cross .name { font-family:ui-monospace, Menlo, monospace; }
256
+ .server-part { margin:2px 0 8px 14px; padding-left:8px; border-left:1px dashed var(--line); }
257
+ section.box { margin-top:22px; padding:12px 14px; background:var(--panel); border:1px solid var(--line); border-radius:8px; }
258
+ section.box h3 { margin:0 0 6px; font-size:13px; }
259
+ section.box ul { margin:0; padding-left:18px; }
260
+ pre.code { background:var(--code); padding:10px 0; border-radius:8px; overflow:auto; font:12.5px/1.5 ui-monospace, Menlo, monospace; margin:8px 0 0; }
261
+ pre.code span.n { display:inline-block; width:48px; text-align:right; padding-right:12px; color:var(--muted); user-select:none; }
262
+ #code { position:sticky; bottom:0; }
263
+ .step { grid-template-columns:18px 1fr auto; gap:6px; }
264
+ .step.plain { grid-template-columns:1fr auto; }
265
+ .step .body { min-width:0; }
266
+ .step .sent { font-size:13.5px; }
267
+ .step .where { margin-left:8px; }
268
+ .caret { all:unset; width:18px; text-align:center; color:var(--muted); cursor:pointer; font-size:11px; }
269
+ .node.closed > .kids { display:none; }
270
+ #detail:not(.full) .step.internal, #detail:not(.full) .detail.sql { display:none; }
271
+ .step.grp .sent { color:var(--muted); }
272
+ .step.warn .sent::after { content:''; }
273
+ .k-grupo { color:var(--muted); }
274
+ .src-ia { display:inline-block; font-size:10.5px; font-weight:600; color:var(--accent); border:1px solid currentColor; border-radius:4px; padding:0 4px; margin-left:6px; vertical-align:1px; }
275
+ .src-rej { color:var(--muted); }
276
+ .summary { font-size:14.5px; margin:2px 0 4px; }
277
+ #ai { color:var(--muted); font-size:12px; margin:0 0 6px; }
278
+ .toolbar { display:flex; gap:8px; margin:0 0 8px; }
279
+ .acts { display:none; margin-left:8px; }
280
+ .step:hover .acts { display:inline; }
281
+ .mini { font:inherit; font-size:11px; color:var(--accent); background:none; border:1px solid var(--line); border-radius:5px; padding:0 5px; margin-left:4px; cursor:pointer; text-decoration:none; }
282
+ .mini.on { color:var(--db); border-color:currentColor; }
283
+ .acts:has(.mini.on) { display:inline; }
284
+ .values { font:12px/1.4 ui-monospace, Menlo, monospace; color:var(--muted); margin:0 0 3px; word-break:break-word; }
285
+ .k-detalhe { color:var(--db); }
286
+ .detail-state { font-size:12.5px; margin:0 0 8px; }
287
+ pre.code span.ran { background:color-mix(in srgb, var(--db) 18%, transparent); display:inline-block; width:100%; }
288
+ pre.code span.idle { opacity:.45; }
289
+ .ask { display:flex; gap:6px; margin-top:10px; }
290
+ .ask input { flex:1; font:inherit; padding:5px 8px; border:1px solid var(--line); border-radius:6px; background:var(--bg); color:var(--text); }
291
+ .ask button, .box .toolbar button { font:inherit; font-size:12.5px; padding:4px 10px; border:1px solid var(--line); border-radius:6px; background:var(--panel); color:var(--text); cursor:pointer; }
292
+ .answer { margin-top:8px; padding:8px 10px; border-left:3px solid var(--accent); background:var(--bg); border-radius:0 6px 6px 0; font-size:13.5px; }
293
+ .answer.unknown { border-left-color:var(--muted); }
294
+ .answer.rejected { border-left-color:var(--error); }
295
+ .toolbar button { font:inherit; font-size:12px; color:var(--muted); background:var(--panel); border:1px solid var(--line); border-radius:6px; padding:2px 8px; cursor:pointer; }
296
+ @media (max-width: 760px) { main { grid-template-columns:1fr; height:auto; } #list { max-height:40vh; } }
297
+ </style>
298
+ </head>
299
+ <body>
300
+ <header><h1>CodeTAC</h1><div class="tabs"><button data-tab="actions" class="on">Ações</button><button data-tab="requests">Pedidos sem ação</button></div>
301
+ <label class="runs">Gravação <select id="run"></select></label></header>
302
+ <main>
303
+ <nav id="list"><p class="empty" style="padding:14px">A carregar…</p></nav>
304
+ <article id="detail"><p class="empty">Escolha uma ação ou um pedido à esquerda para ver o que fez.</p></article>
305
+ </main>
306
+ <script>
307
+ const params = new URLSearchParams(location.search);
308
+ const embed = params.get('embed') === '1';
309
+ if (embed) document.body.classList.add('embed');
310
+ const list = document.getElementById('list');
311
+ const detail = document.getElementById('detail');
312
+ let tab = 'actions';
313
+ let selected = params.get('action') ? { type: 'action', id: params.get('action') } : params.get('request') ? { type: 'request', id: params.get('request') } : null;
314
+ const esc = value => String(value ?? '').replace(/[&<>"]/g, c => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;' }[c]));
315
+ const ms = value => value == null ? '' : value < 1 ? value.toFixed(2) + ' ms' : value < 1000 ? value.toFixed(1) + ' ms' : (value / 1000).toFixed(2) + ' s';
316
+ const time = at => at ? new Date(at).toLocaleTimeString('pt-PT') : '';
317
+ const kindLabel = { 'base-de-dados':'base de dados', http:'HTTP', ia:'IA', email:'email', mensagem:'mensagem', pagamento:'pagamento', ficheiros:'ficheiros', 'autenticação':'autenticação' };
318
+
319
+ const runSelect = document.getElementById('run');
320
+ async function loadRuns() {
321
+ const runs = await (await fetch('/api/runs')).json();
322
+ const current = runSelect.value;
323
+ runSelect.innerHTML = runs.map(r => '<option value="' + esc(r.run) + '">' + esc(r.run) + ' (' + (r.actions ? r.actions + ' ações, ' : '') + r.requests + ' pedidos)</option>').join('');
324
+ if (current && runs.some(r => r.run === current)) runSelect.value = current;
325
+ }
326
+ runSelect.addEventListener('change', () => { selected = null; loadList(); });
327
+ document.querySelector('.tabs').addEventListener('click', event => {
328
+ const button = event.target.closest('button[data-tab]');
329
+ if (!button) return;
330
+ tab = button.dataset.tab;
331
+ for (const item of document.querySelectorAll('.tabs button')) item.classList.toggle('on', item === button);
332
+ loadList();
333
+ });
334
+ async function loadList() {
335
+ const run = encodeURIComponent(runSelect.value);
336
+ if (tab === 'actions') {
337
+ const actions = await (await fetch('/api/actions?run=' + run)).json();
338
+ if (!actions.length) { list.innerHTML = '<p class="empty" style="padding:14px">Ainda não há ações gravadas nesta gravação. Use a aplicação no browser: cada clique fica aqui.</p>'; return; }
339
+ list.innerHTML = actions.map(a => '<button data-type="action" data-id="' + esc(a.actionId) + '"' + (selected && selected.id === a.actionId ? ' class="active"' : '') + '>' +
340
+ '<div class="label">' + esc(a.label) + '</div>' +
341
+ '<div class="meta">' + esc(a.page || '') + ' · ' + a.serverRequests + (a.serverRequests === 1 ? ' pedido' : ' pedidos') + ' ao servidor' +
342
+ (a.pending ? ' · em curso' : '') + ' · ' + time(a.at) + '</div></button>').join('');
343
+ return;
344
+ }
345
+ const requests = await (await fetch('/api/requests?semAcao=1&run=' + run)).json();
346
+ if (!requests.length) { list.innerHTML = '<p class="empty" style="padding:14px">Não há pedidos sem ação nesta gravação.</p>'; return; }
347
+ list.innerHTML = requests.map(r => '<button data-type="request" data-id="' + esc(r.requestId) + '"' + (selected && selected.id === r.requestId ? ' class="active"' : '') + '>' +
348
+ '<div class="req-line"><span class="method">' + esc(r.method) + '</span><span class="path">' + esc(r.path) + '</span></div>' +
349
+ '<div class="meta"><span class="' + (r.status >= 400 ? 'status-bad' : 'status-ok') + '">' + esc(r.status ?? '…') + '</span> · ' + ms(r.durationMs) +
350
+ ' · ' + (r.functions ?? 0) + ' funções · ' + (r.boundaries ?? 0) + ' fronteiras · ' + time(r.at) + '</div></button>').join('');
351
+ }
352
+ list.addEventListener('click', event => {
353
+ const button = event.target.closest('button[data-id]');
354
+ if (!button) return;
355
+ selected = { type: button.dataset.type, id: button.dataset.id };
356
+ show();
357
+ loadList();
358
+ });
359
+ function show() {
360
+ if (!selected) return;
361
+ return selected.type === 'action' ? loadAction(selected.id) : loadDossier(selected.id);
362
+ }
363
+
364
+ function describe(step) {
365
+ const r = step.result || {};
366
+ const parts = [];
367
+ if (step.kind === 'base-de-dados') {
368
+ parts.push((step.operation || '') + (step.tables && step.tables.length ? ' em ' + step.tables.join(', ') : ''));
369
+ if (r.rows != null) parts.push(r.rows + (r.rows === 1 ? ' linha' : ' linhas'));
370
+ if (r.affectedRows != null) parts.push(r.affectedRows + ' alteradas');
371
+ } else if (step.kind === 'email' || step.kind === 'mensagem') {
372
+ parts.push('para ' + (step.to || []).join(', ') + (step.subject ? ' — «' + step.subject + '»' : ''));
373
+ } else if (step.kind === 'ia') {
374
+ const model = r.model || step.model;
375
+ parts.push(step.provider + (model ? ' · ' + model : ''));
376
+ if (r.usage) parts.push((r.usage.input ?? '?') + ' + ' + (r.usage.output ?? '?') + ' tokens');
377
+ if (step.costUsd != null) parts.push('~US$ ' + step.costUsd.toFixed(4) + ' (estimado)');
378
+ } else if (step.kind === 'pagamento') {
379
+ parts.push(step.operation + ' · modo ' + step.mode + (step.amount ? ' · ' + step.amount + ' ' + (step.currency || '') : ''));
380
+ } else if (step.kind === 'ficheiros') {
381
+ parts.push((step.provider || '') + ' · ' + (step.operation || '') + (step.bucket ? ' · ' + step.bucket : '') + (step.bytes ? ' · ' + step.bytes + ' bytes' : ''));
382
+ } else {
383
+ parts.push((step.method || '') + ' ' + (step.host || '') + (step.path || ''));
384
+ }
385
+ if (r.status != null) parts.push('estado ' + r.status);
386
+ return parts.filter(Boolean).join(' · ');
387
+ }
388
+ function relative(root, file) { return root && file && file.startsWith(root + '/') ? file.slice(root.length + 1) : file; }
389
+ // Grouped view: one row per step with its purpose sentence; groups and
390
+ // functions with steps inside open with the arrow.
391
+ // Rows keep what the step box needs (code, values, questions) by id.
392
+ const stepData = new Map();
393
+ function editorLink(file, line) {
394
+ if (!aiConfig || !aiConfig.editor || !file || file[0] !== '/') return '';
395
+ return '<a class="mini" href="' + esc(aiConfig.editor + '://file' + encodeURI(file) + ':' + (line || 1) + ':1') + '" title="Abrir no editor na linha ' + esc(line) + '">abrir no editor</a>';
396
+ }
397
+ function asked(c, n) {
398
+ const spec = detailSpecs[c.run];
399
+ if (!spec) return false;
400
+ return spec.files.includes(n.file) || spec.functions.some(f => f.file === n.file && f.line === n.line && (f.function || '') === (n.function || ''));
401
+ }
402
+ function short(value) {
403
+ const text = JSON.stringify(value);
404
+ return text === undefined ? '' : text.length > 160 ? text.slice(0, 160) + '…' : text;
405
+ }
406
+ function valuesHtml(n, pad) {
407
+ const d = n.detail;
408
+ const args = d.args.map(a => esc(a.name) + ' = ' + esc(short(a.value))).join(', ');
409
+ const out = 'returned' in d ? 'devolveu ' + esc(short(d.returned)) : '<span style="color:var(--error)">lançou ' + esc(short(d.threw)) + '</span>';
410
+ return '<div class="values" ' + pad + '><span class="tag k-detalhe">valores</span>' + (args ? 'entrada: ' + args + ' · ' : 'sem argumentos · ') + out +
411
+ ' · ' + d.lines.length + (d.lines.length === 1 ? ' linha executada' : ' linhas executadas') + '</div>';
412
+ }
413
+ function rowHtml(n, c, depth, open) {
414
+ const pad = 'style="padding-left:' + (4 + depth * 18) + 'px"';
415
+ const valuesPad = 'style="padding-left:' + (28 + depth * 18) + 'px"';
416
+ const inner = n.type === 'group' ? n.children : (n.children || []);
417
+ const caret = inner.length ? '<button class="caret" aria-label="abrir ou fechar">' + (open ? '▾' : '▸') + '</button>' : '<span class="caret"></span>';
418
+ let row;
419
+ if (n.type !== 'group') stepData.set(n.id, { node: n, c });
420
+ if (n.type === 'group') {
421
+ row = '<div class="step grp' + (n.errors ? ' warn' : '') + '" ' + pad + '>' + caret + '<span class="body"><span class="tag k-grupo">' + n.count + ' passos</span><span class="sent">' + esc(n.sentence) + '</span></span><span class="time">' + ms(n.durationMs) + '</span></div>';
422
+ } else if (n.type === 'function') {
423
+ const where = n.file ? relative(c.root, n.file) + ':' + n.line : '';
424
+ // A function with nothing inside is named in its parent's sentence ("chama …"):
425
+ // the grouped view leaves it out; "Ver tudo" shows it in place.
426
+ const internal = depth > 0 && !inner.length && !n.error && !n.detail;
427
+ const on = asked(c, n);
428
+ row = '<div class="step fn' + (n.error ? ' error' : '') + (internal ? ' internal' : '') + '" ' + pad + ' data-step="' + esc(n.id) + '">' + caret +
429
+ '<span class="body"><span class="sent" data-node="' + esc(n.id) + '" data-facts="' + esc(n.purpose.text) + '">' + esc(n.purpose.text) + '</span>' +
430
+ '<span class="where" title="' + esc(where) + '">' + esc(n.function) + ' · ' + esc(where.split('/').pop()) + '</span>' + (n.error ? ' <span class="tag" style="color:var(--error)">erro</span>' : '') +
431
+ '<span class="acts"><button class="mini' + (on ? ' on' : '') + '" data-detalhe="' + esc(n.id) + '" title="' + (on ? 'Detalhe pedido: a próxima execução grava os valores. Carregue para desligar.' : 'Gravar os valores de entrada e saída e as linhas executadas desta função, a partir da próxima ação') + '">' + (on ? 'detalhe ligado' : 'pedir detalhe') + '</button>' +
432
+ editorLink(n.file, n.line) + '</span></span><span class="time">' + ms(n.durationMs) + '</span></div>' + (n.detail ? valuesHtml(n, valuesPad) : '');
433
+ } else {
434
+ const r = n.result || {};
435
+ row = '<div class="step boundary' + (n.error || r.error ? ' error' : '') + '" ' + pad + ' data-step="' + esc(n.id) + '">' + caret + '<span class="body"><span class="tag k-' + esc(n.kind) + '">' + esc(kindLabel[n.kind] || n.kind) + '</span>' +
436
+ '<span class="sent" data-node="' + esc(n.id) + '" data-facts="' + esc(n.purpose.text) + '">' + esc(n.purpose.text) + '</span>' +
437
+ (n.sql ? '<span class="detail sql">' + esc(n.sql) + '</span>' : '') +
438
+ (r.promptExcerpt || n.promptExcerpt ? '<span class="detail">pedido: «' + esc(r.promptExcerpt || n.promptExcerpt) + '»</span>' : '') +
439
+ (r.answerExcerpt ? '<span class="detail">resposta: «' + esc(r.answerExcerpt) + '»</span>' : '') +
440
+ '</span><span class="time">' + ms(n.durationMs) + '</span></div>';
441
+ }
442
+ if (!inner.length) return row;
443
+ return '<div class="node' + (open ? '' : ' closed') + '" data-start="' + (open ? 'open' : 'closed') + '">' + row + '<div class="kids">' + nodesHtml(inner, c, depth + 1, n.type === 'group' ? false : null) + '</div></div>';
444
+ }
445
+ // A step starts open when there is a boundary, an error or recorded values
446
+ // inside it, so the way to them is visible; groups start closed.
447
+ function busy(n) { return (n.children || []).some(c => c.type === 'boundary' || c.error || c.detail || busy(c)); }
448
+ function nodesHtml(nodes, c, depth, forced) {
449
+ return nodes.map(n => rowHtml(n, c, depth, forced === false ? false : n.type === 'group' ? false : busy(n))).join('');
450
+ }
451
+ function digestHtml(d) {
452
+ return nodesHtml(d.digest.nodes, { run: d.request.run, root: d.root, req: d.request.requestId }, 0, null);
453
+ }
454
+ // Detail requested in each recording (detalhe.json, read by the running app).
455
+ const detailSpecs = {};
456
+ async function loadDetail(run) {
457
+ if (!run) return;
458
+ try { detailSpecs[run] = await (await fetch('/api/detalhe?run=' + encodeURIComponent(run))).json(); } catch {}
459
+ if (!detailSpecs[run] || detailSpecs[run].error) detailSpecs[run] = { functions: [], files: [] };
460
+ }
461
+ function detailStateHtml(run) {
462
+ const spec = detailSpecs[run];
463
+ if (!spec || (!spec.functions.length && !spec.files.length)) return '';
464
+ const items = spec.functions.map(f => esc(f.function || '?') + ' <span class="where">' + esc(String(f.file).split('/').pop()) + ':' + f.line + '</span>')
465
+ .concat(spec.files.map(f => 'todo o ficheiro <span class="where">' + esc(String(f).split('/').pop()) + '</span>'));
466
+ return '<p class="detail-state"><span class="tag k-detalhe">detalhe ligado</span>' + items.join(', ') +
467
+ ' — vale a partir da próxima ação, com a aplicação a correr com o CodeTAC. <button class="mini" data-limpar="' + esc(run) + '">desligar tudo</button></p>';
468
+ }
469
+ async function changeDetail(change) {
470
+ const response = await fetch('/api/detalhe', { method: 'POST', headers: { 'content-type': 'application/json', 'x-codetac': '1' }, body: JSON.stringify(change) });
471
+ const spec = await response.json();
472
+ if (!spec.error) detailSpecs[change.run] = spec;
473
+ const open = document.getElementById('pergunta')?.dataset.step;
474
+ await show();
475
+ if (open && stepData.has(open)) openStep(open);
476
+ }
477
+ function effectsHtml(e) {
478
+ const lasting = e.items.filter(i => !['sem-alteracao', 'leitura-externa', 'custo'].includes(i.category));
479
+ const other = e.items.filter(i => ['sem-alteracao', 'leitura-externa', 'custo'].includes(i.category));
480
+ return '<section class="box effects"><h3>Efeitos permanentes</h3>' +
481
+ (lasting.length ? '<ul>' + lasting.map(i => '<li>' + esc(i.text) + '</li>').join('') + '</ul>' : '<p class="note" style="margin:0">' + esc(e.none) + '</p>') +
482
+ (other.length ? '<h3 style="margin-top:8px">Sem efeito permanente</h3><ul class="meta">' + other.map(i => '<li>' + esc(i.text) + '</li>').join('') + '</ul>' : '') +
483
+ '<p class="note" style="margin:6px 0 0">' + esc(e.unseen) + '</p></section>';
484
+ }
485
+ // Phase 5: the diagnostic codes, in plain words.
486
+ const REASONS = {
487
+ 'module-transform-failed': 'ficheiros que não foi possível preparar: correm normalmente, mas as funções deles não aparecem',
488
+ 'source-map-unreadable': 'ficheiros com source map ilegível: correm normalmente, mas as funções deles não aparecem',
489
+ 'eval-code-transform-failed': 'código gerado pelo bundler que não foi possível preparar: as funções dele não aparecem',
490
+ 'constructor-skipped': 'construtores de classes: correm, mas não aparecem na sequência',
491
+ 'generator-skipped': 'generators: correm, mas não aparecem na sequência',
492
+ 'parameter-redeclaration-skipped': 'funções que redeclaram um parâmetro: correm, mas não aparecem na sequência',
493
+ 'direct-eval-skipped': 'funções com eval direto: correm, mas não aparecem na sequência',
494
+ };
495
+ function minimalHtml(d) {
496
+ if (d.level !== 'minimo') return '';
497
+ return '<p class="minimal"><b>Modo mínimo.</b> As funções do projeto não estão a ser seguidas' + (d.reason ? ' (' + esc(d.reason) + ')' : '') +
498
+ '. Aparecem os pedidos, as fronteiras (base de dados, HTTP, email, ficheiros…) e o que se passou no browser. Para saber mais: <code>codetac diagnostico</code>.</p>';
499
+ }
500
+ function limitsHtml(limitations, extra) {
501
+ const items = limitations.map(l => '<li>' + esc(REASONS[l.reason] || l.reason) + ' (' + l.count + ')</li>').concat(extra || []);
502
+ if (!items.length) return '';
503
+ return '<section class="box"><h3>Limites de visão</h3><ul>' + items.join('') +
504
+ '</ul><p class="note" style="margin:6px 0 0">O que não aparece aqui não foi necessariamente omitido pela aplicação: há partes que correm mas não são mostradas.</p></section>';
505
+ }
506
+
507
+ async function loadDossier(id) {
508
+ const d = await (await fetch('/api/requests/' + encodeURIComponent(id))).json();
509
+ if (d.error) { detail.innerHTML = '<p class="empty">' + esc(d.error) + '</p>'; return; }
510
+ if (!aiConfig) aiConfig = await (await fetch('/api/config')).json();
511
+ await loadDetail(d.request.run);
512
+ const q = d.request.queryKeys.length ? '?' + d.request.queryKeys.map(k => esc(k) + '=…').join('&') : '';
513
+ let html = '<h2 class="mono">' + esc(d.request.method) + ' ' + esc(d.request.path) + q + '</h2>' +
514
+ '<p class="note">Estado ' + esc(d.request.status ?? 'em curso') + ' · ' + ms(d.request.durationMs) + ' · ' + time(d.request.at) +
515
+ ' · nível ' + esc(d.level) + (d.level === 'minimo' ? ': pedidos e fronteiras' : ': funções do projeto e fronteiras') + ', sem valores de argumentos nem de retorno.</p>';
516
+ html += minimalHtml(d) + '<p id="ai"></p>' + TOOLBAR + detailStateHtml(d.request.run);
517
+ if (!d.steps.length) html += '<p class="empty">Nenhuma função do projeto nem fronteira observada neste pedido.</p>';
518
+ else if (d.level !== 'minimo' && !d.steps.some(s => s.type === 'function')) html += '<p class="note">Nenhuma função do projeto foi observada neste pedido: só as fronteiras. O pedido pode ter sido tratado só por bibliotecas, ou por código que o CodeTAC não consegue seguir (<code>codetac diagnostico</code> explica).</p>';
519
+ html += digestHtml(d) + effectsHtml(d.digest.effects) + limitsHtml(d.limitations) + '<div id="code"></div>';
520
+ detail.innerHTML = html;
521
+ startPurposes('requests', id);
522
+ }
523
+
524
+ // AI purpose sentences, when a model is configured: they replace the fixed
525
+ // sentence as they arrive; the fixed one stays as the facts beside it.
526
+ const TOOLBAR = '<div class="toolbar"><button data-all="open">Ver tudo</button><button data-all="close">Vista agrupada</button></div>';
527
+ let aiConfig = null;
528
+ let purposeTimer = null;
529
+ const known = {};
530
+ async function startPurposes(kind, id) {
531
+ clearTimeout(purposeTimer);
532
+ if (!aiConfig) aiConfig = await (await fetch('/api/config')).json();
533
+ const box = document.getElementById('ai');
534
+ if (!box) return;
535
+ if (!aiConfig.active) {
536
+ box.textContent = 'Frases fixas, geradas só dos factos gravados' + (aiConfig.problem ? ' (' + aiConfig.problem + ')' : ' (sem modelo de IA configurado).');
537
+ return;
538
+ }
539
+ const r = await (await fetch('/api/' + kind + '/' + encodeURIComponent(id) + '/finalidades')).json();
540
+ known[id] = r.purposes;
541
+ applyPurposes(r.purposes);
542
+ const who = aiConfig.model + (aiConfig.local ? ' (modelo local: nada sai da máquina)' : ' (' + aiConfig.provider + ': são enviados excertos de código já redigidos)');
543
+ const rejected = Object.values(r.purposes).filter(p => p.rejected).length;
544
+ box.textContent = r.finished
545
+ ? 'Frases escritas por ' + who + ' e verificadas contra os factos' + (rejected ? '; ' + rejected + (rejected === 1 ? ' rejeitada ficou' : ' rejeitadas ficaram') + ' com a frase fixa' : '') + '.' + (r.errors.length ? ' Falhas: ' + r.errors.join('; ') : '')
546
+ : 'A escrever frases com ' + who + '… ' + r.done + (r.total != null ? ' de ' + r.total : '');
547
+ if (!r.finished && selected && selected.id === id) purposeTimer = setTimeout(() => startPurposes(kind, id), 1200);
548
+ }
549
+ function applyPurposes(map) {
550
+ for (const el of detail.querySelectorAll('[data-node]')) {
551
+ const p = map && map[el.dataset.node];
552
+ if (!p || el.dataset.applied) continue;
553
+ el.dataset.applied = '1';
554
+ if (p.source === 'ia') {
555
+ const boundary = el.closest('.boundary');
556
+ el.innerHTML = esc(p.text) + '<span class="src-ia" title="Escrita pelo modelo de IA a partir do código e dos factos; verificada contra os factos.">IA</span>' +
557
+ (boundary ? ' <span class="where">' + esc(el.dataset.facts) + '</span>' : '');
558
+ el.title = 'Factos gravados: ' + el.dataset.facts;
559
+ } else if (p.rejected) {
560
+ el.insertAdjacentHTML('beforeend', '<span class="src-ia src-rej" title="' + esc('Frase da IA rejeitada (' + p.rejected + '): «' + p.proposed + '»') + '">IA rejeitada</span>');
561
+ }
562
+ }
563
+ }
564
+
565
+ function srcAttrs(run, origin, before) {
566
+ if (!origin || !origin.project) return '';
567
+ return ' data-run="' + esc(run) + '" data-file="' + esc(origin.file) + '" data-line="' + Math.max(1, origin.line - (before || 0)) + '"';
568
+ }
569
+ function whereHtml(origin) {
570
+ if (!origin) return '';
571
+ if (origin.project) return '<span class="where">' + esc(origin.short) + ':' + esc(origin.line ?? '?') + '</span> ' + editorLink(origin.file, origin.line);
572
+ return origin.library ? '<span class="where">(' + esc(origin.library) + ')</span>' : '';
573
+ }
574
+ function componentsHtml(list, verb) {
575
+ const own = (list || []).filter(c => c.origin && c.origin.project);
576
+ const other = (list || []).length - own.length;
577
+ if (!own.length && !other) return '';
578
+ return '<li>' + verb + ': ' + (own.length ? own.map(c => '<b>' + esc(c.name) + '</b>' + (c.count > 1 ? ' (' + c.count + '×)' : '') +
579
+ ' <span class="where">' + esc(c.origin.short) + ':' + c.origin.line + '</span>').join(', ') : '') +
580
+ (other ? (own.length ? ' · ' : '') + '<span class="where">+' + other + ' da framework ou de bibliotecas</span>' : '') + '</li>';
581
+ }
582
+ let actionTimer = null;
583
+ async function loadAction(id) {
584
+ clearTimeout(actionTimer);
585
+ const response = await fetch('/api/actions/' + encodeURIComponent(id));
586
+ const d = await response.json();
587
+ if (d.error) {
588
+ detail.innerHTML = '<p class="empty">' + esc(d.error) + ' A tentar de novo…</p>';
589
+ actionTimer = setTimeout(() => loadAction(id), 1500);
590
+ return;
591
+ }
592
+ const run = d.run;
593
+ if (!aiConfig) aiConfig = await (await fetch('/api/config')).json();
594
+ await loadDetail(run);
595
+ let html = '<h2>' + esc(d.label) + '</h2><p class="summary" data-node="action" data-facts="' + esc(d.digest.summary) + '">' + esc(d.digest.summary) + '</p><p class="note">' + time(d.startedAt) + (d.durationMs ? ' · ação de ' + ms(d.durationMs) : '') +
596
+ ' · no browser: elemento, componente, handler e pedidos (sem funções uma a uma) · no servidor: nível ' + esc(d.level) + '</p>';
597
+ html += minimalHtml(d) + '<p id="ai"></p>' + TOOLBAR + detailStateHtml(run);
598
+ for (const item of d.digest.timeline) {
599
+ if (item.type === 'dev-group') {
600
+ html += '<div class="node closed"><div class="step grp"><button class="caret">▸</button><span class="body"><span class="tag k-grupo">' + item.items.length + '</span><span class="sent">' + esc(item.sentence) + '</span></span><span class="time"></span></div>' +
601
+ '<div class="kids">' + item.items.map(i => '<div class="step plain" style="padding-left:28px"><span class="body"><span class="where">' + esc(i.browser.method) + ' ' + esc(i.browser.path) + '</span></span><span class="time">' + ms(i.browser.durationMs) + '</span></div>').join('') + '</div></div>';
602
+ } else if (item.type === 'trigger') {
603
+ const t = item.trigger;
604
+ const c = t.component || {};
605
+ const place = c.origin && c.origin.project ? c.origin : c.project ? c.project.origin : c.origin;
606
+ const via = c.project ? ' · ' + esc(c.project.via) + ' (biblioteca)' + (c.project.name ? ' usado em ' + esc(c.project.name) : '') : '';
607
+ const verb = t.event === 'submit' ? 'envio de ' : t.event === 'change' ? 'alteração em ' : 'clique em ';
608
+ html += '<div class="step plain src" style="padding-left:6px"' + srcAttrs(run, place, 3) + '><span><span class="tag k-browser">browser</span><span class="name">' +
609
+ verb + esc(d.label) + '</span> ' + whereHtml(place) +
610
+ '<span class="detail">página ' + esc(item.page && item.page.path) + (c.name ? ' · componente ' + esc(c.name) : '') + via + '</span></span><span class="time"></span></div>';
611
+ const handlers = [t.handler, t.submit && t.submit.handler].filter(Boolean);
612
+ for (const h of handlers) {
613
+ html += '<div class="step plain" style="padding-left:24px"><span><span class="name">handler <b>' + esc(h.name) + '</b></span>' +
614
+ '<span class="where">' + esc(h.prop || (h.source === 'dom' ? 'addEventListener' : '')) + '</span></span><span class="time"></span></div>';
615
+ }
616
+ if (!handlers.length) html += '<div class="step plain" style="padding-left:24px"><span class="detail">Sem handler identificado neste elemento (comportamento nativo do browser).</span></div>';
617
+ } else if (item.type === 'request') {
618
+ const b = item.browser;
619
+ const chainHtml = (b.chain || []).map(f => '<span class="step src" style="display:inline;padding:0 2px"' + srcAttrs(run, { project: true, file: f.file, line: f.line }, 3) + '>' +
620
+ '<span class="name">' + esc(f.fn) + '</span> <span class="where">' + esc(f.short) + ':' + esc(f.line ?? '?') + '</span></span>').join(' → ');
621
+ const via = b.libraries && b.libraries.length ? ' <span class="where">via ' + b.libraries.map(esc).join(', ') + '</span>' : '';
622
+ html += '<div class="cross browser"><span class="tag k-browser">browser → ' + (b.sameOrigin ? 'servidor' : esc(b.host)) + '</span><span class="name">' +
623
+ esc(b.method) + ' ' + esc(b.path) + (b.queryKeys && b.queryKeys.length ? '?' + b.queryKeys.map(k => esc(k) + '=…').join('&') : '') + '</span> ' +
624
+ '<span class="where">' + (b.status != null ? 'estado ' + b.status : b.error ? 'falhou' : 'sem resposta') + ' · ' + ms(b.durationMs) + '</span>' +
625
+ (chainHtml || via ? '<div class="meta">chamado por: ' + (chainHtml || '<span class="where">(sem posição resolvida no projeto)</span>') + via + '</div>' : '') + '</div>';
626
+ html += serverHtml(item.server, b.sameOrigin, b);
627
+ } else if (item.type === 'navigation') {
628
+ const text = item.kind === 'saída da página' ? 'saiu de ' + esc(item.path) + ' (o browser carrega um documento novo)' : esc(item.kind) + ' → ' + esc(item.path);
629
+ html += '<div class="step plain"><span><span class="tag k-browser">navegação</span><span class="name">' + text + '</span></span><span class="time"></span></div>';
630
+ } else if (item.type === 'document') {
631
+ html += '<div class="cross"><span class="tag k-browser">nova página</span><span class="name">' + esc(item.page && item.page.path) + '</span></div>';
632
+ html += serverHtml(item.server, true);
633
+ } else if (item.type === 'screen') {
634
+ const s = item.screen || {};
635
+ const parts = [];
636
+ if (s.added || s.removed) parts.push((s.added || 0) + ' elementos acrescentados, ' + (s.removed || 0) + ' removidos');
637
+ if (s.text) parts.push(s.text + ' textos alterados');
638
+ if (s.attributes) parts.push(s.attributes + ' atributos alterados');
639
+ if (s.title) parts.push('título da página alterado');
640
+ const lines = [componentsHtml(s.stateChanged, 'estado alterado em'), componentsHtml(s.mounted, 'apareceu'), componentsHtml(s.unmounted, 'desapareceu')].join('');
641
+ html += '<div class="cross browser"><span class="tag k-browser">ecrã</span><span class="name">' + (parts.length ? esc(parts.join(' · ')) : 'nenhuma alteração visível observada') + '</span>' +
642
+ (lines ? '<ul class="meta" style="margin:4px 0 0;padding-left:18px">' + lines + '</ul>' : '') +
643
+ '<div class="meta">ação fechada: ' + esc(item.closedBy || '') + '</div></div>';
644
+ } else if (item.type === 'unmatched') {
645
+ html += '<div class="cross"><span class="tag k-servidor">servidor</span><span class="name">pedidos recebidos com esta ação sem registo no browser</span></div>';
646
+ html += serverHtml(item.server, true);
647
+ }
648
+ }
649
+ html += effectsHtml(d.digest.effects);
650
+ const extra = ['<li>No browser não se segue cada função: mostra-se o elemento, o componente, o handler e as funções do projeto no caminho até cada pedido.</li>',
651
+ '<li>Pedidos iniciados enquanto a ação está aberta são atribuídos a ela, mesmo quando vêm de um temporizador da página.</li>'];
652
+ if (!d.browserSeen) extra.push('<li>O browser não enviou o registo desta ação.</li>');
653
+ html += limitsHtml(d.limitations, extra) + '<div id="code"></div>';
654
+ const scroll = detail.scrollTop;
655
+ detail.innerHTML = html;
656
+ detail.scrollTop = scroll;
657
+ if (d.pending || !d.browserSeen) {
658
+ applyPurposes(known[id]);
659
+ actionTimer = setTimeout(() => loadAction(id), 1500);
660
+ } else startPurposes('actions', id);
661
+ }
662
+ function serverHtml(list, sameOrigin, browser) {
663
+ if (!list || !list.length) {
664
+ return sameOrigin ? '<div class="server-part"><p class="note" style="margin:4px 0">O servidor não registou este pedido (servido sem captura, por exemplo por um ficheiro estático ou por outro processo).</p></div>' : '';
665
+ }
666
+ const same = d => browser && list.length === 1 && browser.method === d.request.method && browser.path === d.request.path && browser.status === d.request.status;
667
+ return list.map(d => '<div class="server-part">' + (same(d) ? '' : '<div class="meta"><span class="tag k-servidor">servidor</span>' + esc(d.request.method) + ' ' + esc(d.request.path) +
668
+ ' · estado ' + esc(d.request.status ?? 'em curso') + ' · ' + ms(d.request.durationMs) + '</div>') +
669
+ (d.steps.length ? digestHtml(d) : '<p class="note" style="margin:4px 0">Nenhuma função do projeto nem fronteira neste pedido.</p>') + '</div>').join('');
670
+ }
671
+
672
+ detail.addEventListener('click', async event => {
673
+ const caret = event.target.closest('.caret');
674
+ if (caret && caret.tagName === 'BUTTON') {
675
+ const node = caret.closest('.node');
676
+ node.classList.toggle('closed');
677
+ caret.textContent = node.classList.contains('closed') ? '▸' : '▾';
678
+ return;
679
+ }
680
+ const all = event.target.closest('[data-all]');
681
+ if (all) {
682
+ const full = all.dataset.all === 'open';
683
+ detail.classList.toggle('full', full);
684
+ for (const node of detail.querySelectorAll('.node')) {
685
+ const close = full ? false : node.dataset.start === 'closed';
686
+ node.classList.toggle('closed', close);
687
+ node.querySelector(':scope > .step > .caret').textContent = close ? '▸' : '▾';
688
+ }
689
+ return;
690
+ }
691
+ if (event.target.closest('a.mini')) return;
692
+ const toggle = event.target.closest('[data-detalhe]');
693
+ if (toggle) {
694
+ const { node: n, c } = stepData.get(toggle.dataset.detalhe);
695
+ await changeDetail({ run: c.run, op: 'funcao', file: n.file, line: n.line, function: n.function });
696
+ return;
697
+ }
698
+ const whole = event.target.closest('[data-ficheiro]');
699
+ if (whole) { await changeDetail({ run: whole.dataset.run, op: 'ficheiro', file: whole.dataset.ficheiro }); return; }
700
+ const clear = event.target.closest('[data-limpar]');
701
+ if (clear) { await changeDetail({ run: clear.dataset.limpar, op: 'limpar' }); return; }
702
+ const ask = event.target.closest('[data-perguntar]');
703
+ if (ask) { askQuestion(ask.dataset.perguntar); return; }
704
+ if (event.target.closest('#code')) return;
705
+ const row = event.target.closest('[data-step]');
706
+ if (row) { openStep(row.dataset.step); return; }
707
+ const place = event.target.closest('[data-file]');
708
+ if (!place || !place.dataset.file) return;
709
+ const params = new URLSearchParams({ run: place.dataset.run, file: place.dataset.file, line: place.dataset.line, end: place.dataset.end || '' });
710
+ const result = await (await fetch('/api/source?' + params)).json();
711
+ const box = document.getElementById('code');
712
+ box.innerHTML = '<section class="box">' + (result.error ? '<p class="note" style="margin:0">' + esc(result.error) + '</p>'
713
+ : '<h3>' + esc(result.file) + ':' + result.start + '–' + result.end + ' ' + editorLink(place.dataset.file, Number(place.dataset.line) + 3) + '</h3>' + codeHtml(result, null)) + '</section>';
714
+ box.scrollIntoView({ block: 'nearest' });
715
+ });
716
+ detail.addEventListener('keydown', event => {
717
+ if (event.key === 'Enter' && event.target.id === 'pergunta') { event.preventDefault(); askQuestion(event.target.dataset.step); }
718
+ });
719
+
720
+ // Code of a function; with recorded detail, the lines that ran are marked
721
+ // and the others inside the function are dimmed.
722
+ function codeHtml(result, lines) {
723
+ const ran = lines ? new Set(lines) : null;
724
+ return '<pre class="code">' + result.lines.map((text, i) => {
725
+ const number = result.start + i;
726
+ const cls = ran ? (ran.has(number) ? ' class="ran"' : ' class="idle"') : '';
727
+ return '<span' + cls + '><span class="n">' + number + '</span>' + esc(text) + '</span>';
728
+ }).join('\\n') + '</pre>';
729
+ }
730
+ // The box of one step: code (functions), values, detail and a question.
731
+ async function openStep(id) {
732
+ const { node: n, c } = stepData.get(id);
733
+ const box = document.getElementById('code');
734
+ let html = '<section class="box">';
735
+ if (n.type === 'function') {
736
+ const params = new URLSearchParams({ run: c.run, file: n.file, line: n.line, end: n.endLine ?? '' });
737
+ const result = await (await fetch('/api/source?' + params)).json();
738
+ const on = asked(c, n);
739
+ const wholeFile = (detailSpecs[c.run] || { files: [] }).files.includes(n.file);
740
+ html += '<h3>' + esc(n.function) + ' — ' + esc(result.file || relative(c.root, n.file)) + ':' + n.line + ' ' + editorLink(n.file, n.line) + '</h3>' +
741
+ '<p class="note" style="margin:2px 0 6px">' + esc(n.purpose.text) + '</p>' +
742
+ '<div class="toolbar"><button data-detalhe="' + esc(n.id) + '">' + (on && !wholeFile ? 'Desligar o detalhe desta função' : 'Pedir detalhe desta função') + '</button>' +
743
+ '<button data-ficheiro="' + esc(n.file) + '" data-run="' + esc(c.run) + '">' + (wholeFile ? 'Desligar o detalhe do ficheiro' : 'Pedir detalhe de todo o ficheiro') + '</button></div>';
744
+ if (n.detail) {
745
+ const d = n.detail;
746
+ html += '<p class="note" style="margin:6px 0 2px">Valores gravados nesta chamada (depois da redação de segredos):</p><pre class="code values-full">' +
747
+ esc(JSON.stringify({ entrada: Object.fromEntries(d.args.map(a => [a.name, a.value])), ...('returned' in d ? { devolveu: d.returned } : { 'lançou': d.threw }) }, null, 2)) + '</pre>' +
748
+ '<p class="note" style="margin:6px 0 2px">A verde, as linhas que correram nesta chamada; esbatidas, as da função que não correram.</p>';
749
+ } else if (on) {
750
+ html += '<p class="note" style="margin:6px 0 2px">Detalhe pedido: repita a ação para gravar os valores e as linhas executadas.</p>';
751
+ }
752
+ html += result.error ? '<p class="note">' + esc(result.error) + '</p>' : codeHtml(result, n.detail ? n.detail.lines : null);
753
+ } else {
754
+ html += '<h3>' + esc(kindLabel[n.kind] || n.kind) + ': ' + esc(n.purpose.text) + '</h3>' + (n.sql ? '<pre class="code">' + esc(n.sql) + '</pre>' : '');
755
+ }
756
+ html += '<div class="ask"><input id="pergunta" data-step="' + esc(id) + '" placeholder="Pergunte sobre este passo (por exemplo: porque é que isto lê esta tabela?)" autocomplete="off">' +
757
+ '<button data-perguntar="' + esc(id) + '">Perguntar</button></div><div id="resposta"></div></section>';
758
+ box.innerHTML = html;
759
+ box.scrollIntoView({ block: 'nearest' });
760
+ }
761
+ async function askQuestion(id) {
762
+ const input = document.getElementById('pergunta');
763
+ const out = document.getElementById('resposta');
764
+ const question = input.value.trim();
765
+ if (!question) return;
766
+ const { c } = stepData.get(id);
767
+ out.innerHTML = '<p class="note">A pensar…</p>';
768
+ try {
769
+ const response = await fetch('/api/pergunta', { method: 'POST', headers: { 'content-type': 'application/json', 'x-codetac': '1' },
770
+ body: JSON.stringify({ requestId: c.req, stepId: id, question }) });
771
+ const a = await response.json();
772
+ if (a.error) { out.innerHTML = '<p class="note">' + esc(a.error) + '</p>'; return; }
773
+ if (!a.available) { out.innerHTML = '<p class="note">' + esc(a.text) + '</p>'; return; }
774
+ const source = a.model ? 'Resposta de ' + a.model + (a.local ? ' (modelo local)' : '') + (a.valuesSent ? ', com os valores gravados.' : '.') : '';
775
+ out.innerHTML = '<div class="answer' + (a.known ? '' : ' unknown') + (a.rejected ? ' rejected' : '') + '">' +
776
+ (a.rejected ? '<p class="note" style="color:var(--error);margin:0 0 4px">Resposta não verificada (' + esc(a.rejected) + '). Confirme no código.</p>' : '') +
777
+ (!a.known ? '<p class="note" style="margin:0 0 4px">Os factos gravados não chegam para responder com certeza:</p>' : '') +
778
+ '<p style="margin:0">' + esc(a.text) + '</p><p class="note" style="margin:4px 0 0">' + esc(source) + '</p></div>';
779
+ } catch (error) {
780
+ out.innerHTML = '<p class="note">Falhou: ' + esc(error.message) + '</p>';
781
+ }
782
+ }
783
+
784
+ if (embed) { show(); }
785
+ else {
786
+ loadRuns().then(async () => {
787
+ if (selected) {
788
+ const d = await (await fetch('/api/' + (selected.type === 'action' ? 'actions/' : 'requests/') + encodeURIComponent(selected.id))).json();
789
+ if (d.request && d.request.run) runSelect.value = d.request.run;
790
+ if (selected.type === 'request') tab = 'requests';
791
+ if (d.run) runSelect.value = d.run;
792
+ show();
793
+ }
794
+ loadList();
795
+ });
796
+ setInterval(() => loadRuns().then(loadList), 3000);
797
+ }
798
+ </script>
799
+ </body>
800
+ </html>`;