primocode 7.16.0 → 7.18.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/bin/primocode.js +141 -63
- package/lib/act.js +0 -13
- package/lib/desktop.js +4 -4
- package/lib/referencia.js +3 -4
- package/lib/tools.js +1 -1
- package/lib/ui.js +2 -2
- package/package.json +1 -1
- package/lib/streaming.js +0 -759
package/bin/primocode.js
CHANGED
|
@@ -22,7 +22,6 @@ const cfg = require('../lib/config');
|
|
|
22
22
|
const { runAct, interromper } = require('../lib/act');
|
|
23
23
|
const pasta = require('../lib/pasta');
|
|
24
24
|
const catalogo = require('../lib/catalogo');
|
|
25
|
-
const streaming = require('../lib/streaming');
|
|
26
25
|
const effort = require('../lib/effort');
|
|
27
26
|
|
|
28
27
|
// A pasta de onde o comando foi dado. Não é mais onde ele trabalha (o padrão é
|
|
@@ -57,7 +56,6 @@ ${c.bold('Comandos')}
|
|
|
57
56
|
${c.brand('/read')} <arquivo> mostra um arquivo
|
|
58
57
|
${c.brand('/run')} <comando> executa no shell do projeto
|
|
59
58
|
${c.brand('/open')} <arquivo|url> abre no navegador
|
|
60
|
-
${c.brand('/streaming')} painel ao vivo — veja o agente mexendo no computador
|
|
61
59
|
${c.brand('/status')} checa o servidor
|
|
62
60
|
${c.brand('/name')} <nome> como o Primo te chama
|
|
63
61
|
${c.brand('/mascot')} <estilo> mascote: primo|fox|cat
|
|
@@ -81,7 +79,7 @@ ${c.bold('Onde ele trabalha')}
|
|
|
81
79
|
|
|
82
80
|
// ── Autopreenchimento dos comandos (estilo Claude Code) ────────────────
|
|
83
81
|
// Metade do comando já serve: Tab completa, e um prefixo único executa
|
|
84
|
-
// direto — "/
|
|
82
|
+
// direto — "/eff" vira "/effort" sozinho. A lista é só a dos nomes
|
|
85
83
|
// canônicos; os apelidos (/cd, /stream…) continuam valendo por extenso.
|
|
86
84
|
const SLASH_COMANDOS = [
|
|
87
85
|
['/help', 'todos os comandos'],
|
|
@@ -97,7 +95,6 @@ const SLASH_COMANDOS = [
|
|
|
97
95
|
['/read', 'mostra um arquivo'],
|
|
98
96
|
['/run', 'roda um comando'],
|
|
99
97
|
['/open', 'abre no navegador'],
|
|
100
|
-
['/streaming', 'painel ao vivo — vídeo dele trabalhando'],
|
|
101
98
|
['/status', 'checa o servidor'],
|
|
102
99
|
['/name', 'como ele te chama'],
|
|
103
100
|
['/mascot', 'primo | fox | cat'],
|
|
@@ -122,12 +119,11 @@ function completarSlash(line) {
|
|
|
122
119
|
// Régua com os níveis, o marcador ▲ embaixo do escolhido, "Faster" à
|
|
123
120
|
// esquerda e "Smarter" à direita. ←/→ move, Enter confirma, Esc cancela.
|
|
124
121
|
|
|
125
|
-
|
|
122
|
+
// O layout sai em texto PLANO, com as marcas de onde pintar. Assim os dois
|
|
123
|
+
// pintores (o normal e o do ultracode animado) desenham exatamente a mesma
|
|
124
|
+
// régua — sem duas contas de posição para manter em sincronia.
|
|
125
|
+
function layoutEffort(idx) {
|
|
126
126
|
const cols = Math.max(52, Math.min(process.stdout.columns || 100, 104));
|
|
127
|
-
const regua = '▔'.repeat(cols);
|
|
128
|
-
const linhas = [];
|
|
129
|
-
|
|
130
|
-
// Cada nível ocupa uma coluna própria; o ultracode fica destacado no fim.
|
|
131
127
|
const rotulos = effort.NOMES;
|
|
132
128
|
const larguras = rotulos.map((n) => Math.max(n.length + 3, 9));
|
|
133
129
|
const posicoes = [];
|
|
@@ -137,38 +133,130 @@ function desenharEffort(idx) {
|
|
|
137
133
|
x += larguras[i];
|
|
138
134
|
}
|
|
139
135
|
const fimRegua = x;
|
|
140
|
-
|
|
141
|
-
linhas.push(c.dim(regua));
|
|
142
|
-
linhas.push(' ' + c.bold(c.white('Effort')));
|
|
143
|
-
linhas.push('');
|
|
144
|
-
|
|
145
|
-
// Faster ....▲.... Smarter — a trilha, com o marcador na posição atual.
|
|
146
136
|
const marca = posicoes[idx];
|
|
137
|
+
|
|
147
138
|
let trilha = '';
|
|
148
139
|
for (let k = 23; k < fimRegua; k++) {
|
|
149
|
-
if (k === marca) trilha +=
|
|
150
|
-
else if (k === fimRegua - 12) trilha +=
|
|
151
|
-
else trilha +=
|
|
140
|
+
if (k === marca) trilha += '▲';
|
|
141
|
+
else if (k === fimRegua - 12) trilha += '┆'; // ultracode é um degrau à parte
|
|
142
|
+
else trilha += '─';
|
|
152
143
|
}
|
|
153
|
-
linhas.push(' ' + c.muted('Faster') + ' '.repeat(Math.max(1, fimRegua - 23 - 6 - 7)) + c.muted('Smarter'));
|
|
154
|
-
linhas.push(' ' + trilha);
|
|
155
144
|
|
|
156
|
-
|
|
157
|
-
let
|
|
145
|
+
let nomes = ' '.repeat(23);
|
|
146
|
+
let deNome = 0, ateNome = 0;
|
|
158
147
|
for (let i = 0; i < rotulos.length; i++) {
|
|
159
148
|
const nome = rotulos[i];
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
linhaNomes += ' '.repeat(pad) + texto + ' '.repeat(espaco - nome.length - pad);
|
|
149
|
+
const pad = Math.floor((larguras[i] - nome.length) / 2);
|
|
150
|
+
if (i === idx) { deNome = nomes.length + pad; ateNome = deNome + nome.length; }
|
|
151
|
+
nomes += ' '.repeat(pad) + nome + ' '.repeat(larguras[i] - nome.length - pad);
|
|
164
152
|
}
|
|
165
|
-
linhas.push(linhaNomes);
|
|
166
153
|
|
|
167
154
|
const nivel = effort.nivelDe(rotulos[idx]);
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
155
|
+
const vaoExtremos = Math.max(1, fimRegua - 23 - 6 - 7);
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
cols,
|
|
159
|
+
marca,
|
|
160
|
+
destaque: { de: deNome, ate: ateNome },
|
|
161
|
+
linhas: [
|
|
162
|
+
{ tipo: 'regua', texto: '▔'.repeat(cols) },
|
|
163
|
+
{ tipo: 'titulo', texto: ' Effort' },
|
|
164
|
+
{ tipo: 'vazio', texto: '' },
|
|
165
|
+
{ tipo: 'extremos', texto: ' '.repeat(23) + 'Faster' + ' '.repeat(vaoExtremos) + 'Smarter' },
|
|
166
|
+
{ tipo: 'trilha', texto: ' '.repeat(23) + trilha },
|
|
167
|
+
{ tipo: 'nomes', texto: nomes },
|
|
168
|
+
{ tipo: 'resumo', texto: ' '.repeat(23) + nivel.resumo },
|
|
169
|
+
{ tipo: 'vazio', texto: '' },
|
|
170
|
+
{ tipo: 'ajuda', texto: ' ←/→ ajusta · Enter confirma · Esc cancela' },
|
|
171
|
+
],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── O brilho do ultracode ────────────────────────────────────────────────
|
|
176
|
+
// No nível máximo o painel inteiro ganha um fundo roxo que ondula, como no
|
|
177
|
+
// Claude Code. São faixas verticais de roxo em brilhos diferentes; a onda
|
|
178
|
+
// anda para o lado a cada quadro, e o texto fica branco por cima.
|
|
179
|
+
//
|
|
180
|
+
// Roxos do cubo de 256 cores, ordenados pelo brilho REAL (luminância), do
|
|
181
|
+
// mais escuro ao mais claro — a onda escolhe um deles por faixa. Fora da
|
|
182
|
+
// lista ficaram os roxos escuros que puxam para o vinho (53-56): quebravam a
|
|
183
|
+
// família violeta. Só 256 cores (não truecolor): funciona em qualquer
|
|
184
|
+
// terminal, inclusive no Terminal.app do macOS.
|
|
185
|
+
const ROXOS = [57, 92, 93, 63, 98, 99, 105, 141];
|
|
186
|
+
|
|
187
|
+
function fundoRoxo(x, fase) {
|
|
188
|
+
// Duas ondas de períodos diferentes: o encontro delas dá o desenho
|
|
189
|
+
// irregular de faixas, em vez de listras repetidas e óbvias.
|
|
190
|
+
const faixa = Math.floor(x / 2);
|
|
191
|
+
const onda = Math.sin(faixa * 0.55 + fase) * 0.6 + Math.sin(faixa * 0.17 - fase * 0.7) * 0.4;
|
|
192
|
+
const i = Math.round((onda + 1) / 2 * (ROXOS.length - 1));
|
|
193
|
+
return ROXOS[Math.max(0, Math.min(ROXOS.length - 1, i))];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Pinta a linha inteira (até a largura do painel) com a onda por baixo.
|
|
197
|
+
// `destaque` é o trecho que fica branco e forte (o nível escolhido); o resto
|
|
198
|
+
// da linha sai num lilás claro, para o escolhido saltar aos olhos.
|
|
199
|
+
function pintarLinhaUltra(texto, largura, fase, opts = {}) {
|
|
200
|
+
const base = opts.base || 231; // branco
|
|
201
|
+
const apagado = opts.apagado || base;
|
|
202
|
+
const destaque = opts.destaque;
|
|
203
|
+
let saida = '';
|
|
204
|
+
let corFundo = -1;
|
|
205
|
+
let corTexto = -1;
|
|
206
|
+
let forte = null;
|
|
207
|
+
|
|
208
|
+
for (let x = 0; x < largura; x++) {
|
|
209
|
+
const fundo = fundoRoxo(x, fase);
|
|
210
|
+
if (fundo !== corFundo) { saida += `\x1b[48;5;${fundo}m`; corFundo = fundo; }
|
|
211
|
+
|
|
212
|
+
const dentro = destaque && x >= destaque.de && x < destaque.ate;
|
|
213
|
+
const querForte = opts.negrito || dentro;
|
|
214
|
+
const querCor = dentro ? base : apagado;
|
|
215
|
+
if (querForte !== forte) { saida += querForte ? '\x1b[1m' : '\x1b[22m'; forte = querForte; }
|
|
216
|
+
if (querCor !== corTexto) { saida += `\x1b[38;5;${querCor}m`; corTexto = querCor; }
|
|
217
|
+
|
|
218
|
+
saida += texto[x] || ' ';
|
|
219
|
+
}
|
|
220
|
+
return saida + '\x1b[0m';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function desenharEffort(idx, fase) {
|
|
224
|
+
const L = layoutEffort(idx);
|
|
225
|
+
const ultra = effort.NOMES[idx] === 'ultracode';
|
|
226
|
+
|
|
227
|
+
if (ultra) {
|
|
228
|
+
// No ultracode o painel todo vira fundo animado. Cada linha é pintada
|
|
229
|
+
// com a MESMA onda, então as faixas ficam alinhadas de cima a baixo.
|
|
230
|
+
const LILAS = 189; // lilás bem claro, para o que não é o escolhido
|
|
231
|
+
return L.linhas.map((l) => {
|
|
232
|
+
if (l.tipo === 'titulo') return pintarLinhaUltra(l.texto, L.cols, fase, { negrito: true });
|
|
233
|
+
if (l.tipo === 'nomes') return pintarLinhaUltra(l.texto, L.cols, fase, { destaque: L.destaque, apagado: LILAS });
|
|
234
|
+
if (l.tipo === 'resumo' || l.tipo === 'extremos') return pintarLinhaUltra(l.texto, L.cols, fase, { base: LILAS, apagado: LILAS });
|
|
235
|
+
return pintarLinhaUltra(l.texto, L.cols, fase, {});
|
|
236
|
+
}).join('\n');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return L.linhas.map((l) => {
|
|
240
|
+
const t = l.texto;
|
|
241
|
+
switch (l.tipo) {
|
|
242
|
+
case 'regua': return c.dim(t);
|
|
243
|
+
case 'titulo': return c.bold(c.white(t));
|
|
244
|
+
case 'extremos': return c.muted(t);
|
|
245
|
+
case 'trilha': {
|
|
246
|
+
// O marcador aceso, o resto da trilha recuada.
|
|
247
|
+
const antes = t.slice(0, L.marca);
|
|
248
|
+
const depois = t.slice(L.marca + 1);
|
|
249
|
+
return c.dim(antes) + c.brand('▲') + c.dim(depois);
|
|
250
|
+
}
|
|
251
|
+
case 'nomes': {
|
|
252
|
+
const { de, ate } = L.destaque;
|
|
253
|
+
return c.muted(t.slice(0, de)) + c.bold(c.brand(t.slice(de, ate))) + c.muted(t.slice(ate));
|
|
254
|
+
}
|
|
255
|
+
case 'resumo': return c.dim(t);
|
|
256
|
+
case 'ajuda': return c.muted(t);
|
|
257
|
+
default: return t;
|
|
258
|
+
}
|
|
259
|
+
}).join('\n');
|
|
172
260
|
}
|
|
173
261
|
|
|
174
262
|
// Desenha, escuta as setas, e devolve o nome escolhido (ou null se cancelou).
|
|
@@ -180,21 +268,38 @@ function sliderEffort(atual) {
|
|
|
180
268
|
}
|
|
181
269
|
let idx = effort.indiceDe(atual);
|
|
182
270
|
const ALTURA = 9; // linhas que o desenho ocupa — para redesenhar por cima
|
|
271
|
+
let fase = 0;
|
|
272
|
+
let anima = null;
|
|
183
273
|
|
|
184
274
|
const pintar = (primeira) => {
|
|
185
275
|
if (!primeira) process.stdout.write(`\x1b[${ALTURA}A\r`);
|
|
186
|
-
process.stdout.write(desenharEffort(idx).split('\n').map((l) => '\x1b[2K' + l).join('\n') + '\n');
|
|
276
|
+
process.stdout.write(desenharEffort(idx, fase).split('\n').map((l) => '\x1b[2K' + l).join('\n') + '\n');
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// O fundo do ultracode ondula; sair dele apaga a animação na hora, para
|
|
280
|
+
// não redesenhar sozinho um painel que já está parado.
|
|
281
|
+
const cuidarDaAnimacao = () => {
|
|
282
|
+
const ultra = effort.NOMES[idx] === 'ultracode';
|
|
283
|
+
if (ultra && !anima) {
|
|
284
|
+
anima = setInterval(() => { fase += 0.32; pintar(false); }, 90);
|
|
285
|
+
anima.unref();
|
|
286
|
+
} else if (!ultra && anima) {
|
|
287
|
+
clearInterval(anima); anima = null;
|
|
288
|
+
}
|
|
187
289
|
};
|
|
290
|
+
|
|
188
291
|
pintar(true);
|
|
292
|
+
cuidarDaAnimacao();
|
|
189
293
|
|
|
190
294
|
const aoTeclar = (ch, key) => {
|
|
191
295
|
if (!key) return;
|
|
192
|
-
if (key.name === 'right' || key.name === 'l') { idx = Math.min(effort.NOMES.length - 1, idx + 1); pintar(false); return; }
|
|
193
|
-
if (key.name === 'left' || key.name === 'h') { idx = Math.max(0, idx - 1); pintar(false); return; }
|
|
296
|
+
if (key.name === 'right' || key.name === 'l') { idx = Math.min(effort.NOMES.length - 1, idx + 1); cuidarDaAnimacao(); pintar(false); return; }
|
|
297
|
+
if (key.name === 'left' || key.name === 'h') { idx = Math.max(0, idx - 1); cuidarDaAnimacao(); pintar(false); return; }
|
|
194
298
|
if (key.name === 'return' || key.name === 'enter') { encerrar(effort.NOMES[idx]); return; }
|
|
195
299
|
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { encerrar(null); return; }
|
|
196
300
|
};
|
|
197
301
|
const encerrar = (valor) => {
|
|
302
|
+
if (anima) { clearInterval(anima); anima = null; }
|
|
198
303
|
process.stdin.removeListener('keypress', aoTeclar);
|
|
199
304
|
sliderAtivo = false;
|
|
200
305
|
resolve(valor);
|
|
@@ -222,7 +327,7 @@ function mostrarMenuSlash() {
|
|
|
222
327
|
for (const [nome, desc] of SLASH_COMANDOS) {
|
|
223
328
|
console.log(` ${c.brand(ui.padRight(nome, 12))}${c.muted(desc)}`);
|
|
224
329
|
}
|
|
225
|
-
console.log(c.dim('\n Tab completa · metade do comando já serve: /
|
|
330
|
+
console.log(c.dim('\n Tab completa · metade do comando já serve: /eff vira /effort'));
|
|
226
331
|
}
|
|
227
332
|
|
|
228
333
|
let rl = null;
|
|
@@ -667,33 +772,6 @@ async function handleSlash(text) {
|
|
|
667
772
|
break;
|
|
668
773
|
}
|
|
669
774
|
|
|
670
|
-
// Painel ao vivo: um servidorzinho local mostra cada ação do agente na
|
|
671
|
-
// tela + a foto tirada logo depois dela. Liga/desliga; /streaming on|off
|
|
672
|
-
// também funciona.
|
|
673
|
-
case 'streaming': case 'stream': case 'live': {
|
|
674
|
-
const pedido = rest.toLowerCase();
|
|
675
|
-
const ligar = pedido === 'on' ? true : pedido === 'off' ? false : !streaming.isOn();
|
|
676
|
-
|
|
677
|
-
if (!ligar) {
|
|
678
|
-
if (streaming.isOn()) { streaming.stop(); console.log(c.ok('Streaming desligado.')); }
|
|
679
|
-
else console.log(c.muted('O streaming já estava desligado.'));
|
|
680
|
-
break;
|
|
681
|
-
}
|
|
682
|
-
if (streaming.isOn()) {
|
|
683
|
-
console.log(c.muted(`Já está ligado: ${streaming.url()}`));
|
|
684
|
-
break;
|
|
685
|
-
}
|
|
686
|
-
try {
|
|
687
|
-
const url = await streaming.start();
|
|
688
|
-
console.log(c.ok('Streaming ligado: ') + c.white(url));
|
|
689
|
-
console.log(c.muted(' Dê uma instrução e veja cada passo dele aí, ao vivo. /streaming de novo desliga.'));
|
|
690
|
-
await tools.openInBrowser(url);
|
|
691
|
-
} catch (e) {
|
|
692
|
-
console.log(c.err(`Não consegui ligar o streaming: ${e.message}`));
|
|
693
|
-
}
|
|
694
|
-
break;
|
|
695
|
-
}
|
|
696
|
-
|
|
697
775
|
case 'name': {
|
|
698
776
|
if (!rest) { console.log(c.muted(` Nome atual: ${state.config.userName || '(nenhum)'}`)); break; }
|
|
699
777
|
state.config.userName = rest;
|
|
@@ -770,7 +848,7 @@ async function handleSlash(text) {
|
|
|
770
848
|
case 'exit': case 'quit': if (rl) rl.close(); break;
|
|
771
849
|
default: {
|
|
772
850
|
// "/" sozinho abre o menu; um prefixo único autopreenche e executa
|
|
773
|
-
// ("/
|
|
851
|
+
// ("/eff" → /effort); prefixo ambíguo mostra as opções.
|
|
774
852
|
if (!cmd) { mostrarMenuSlash(); break; }
|
|
775
853
|
const casam = SLASH_COMANDOS.map(([nome]) => nome.slice(1)).filter((n) => n.startsWith(cmd.toLowerCase()));
|
|
776
854
|
if (casam.length === 1) {
|
package/lib/act.js
CHANGED
|
@@ -15,10 +15,6 @@ const tools = require('./tools');
|
|
|
15
15
|
const api = require('./api');
|
|
16
16
|
const cfg = require('./config');
|
|
17
17
|
const catalogo = require('./catalogo');
|
|
18
|
-
// "painel", não "streaming": dentro do runAct existe um `let streaming` (a
|
|
19
|
-
// flag de texto chegando em stream) que SOMBREARIA este módulo — e o step()
|
|
20
|
-
// viraria TypeError engolido pelo catch. Foi um bug real, pego em teste.
|
|
21
|
-
const painel = require('./streaming');
|
|
22
18
|
|
|
23
19
|
const MAX_ITERATIONS = 15;
|
|
24
20
|
const MAX_PROFUNDIDADE = 2; // principal + 1 nível de subagente
|
|
@@ -201,11 +197,6 @@ async function runAct(userPrompt, opts) {
|
|
|
201
197
|
|
|
202
198
|
let finalText = '';
|
|
203
199
|
|
|
204
|
-
// O pedido do usuário aparece na barra de comando do painel, digitado
|
|
205
|
-
// letra a letra — só o pedido principal; sub-tarefa de agente não é
|
|
206
|
-
// instrução de gente.
|
|
207
|
-
if (!subagent) { try { painel.instrucao(userPrompt); } catch {} }
|
|
208
|
-
|
|
209
200
|
// Pedido novo zera o pedido de parada anterior.
|
|
210
201
|
if (!subagent) PARAR = false;
|
|
211
202
|
|
|
@@ -308,10 +299,6 @@ async function runAct(userPrompt, opts) {
|
|
|
308
299
|
const result = await tools.execute(tc.name, tc.arguments, { projectDir: pastaAtual });
|
|
309
300
|
logResult(tc.name, result, profundidade);
|
|
310
301
|
|
|
311
|
-
// Painel ao vivo (/streaming): registra o passo e fotografa a tela.
|
|
312
|
-
// Nunca pode derrubar o loop — falha de foto é problema do painel.
|
|
313
|
-
try { painel.step(tc.name, tc.arguments, result); } catch {}
|
|
314
|
-
|
|
315
302
|
// Uma escrita bem-sucedida limpa a dívida: o agente se corrigiu.
|
|
316
303
|
if (result && result.ok === false) falhasPendentes.push(`${tc.name}(${tc.arguments?.path || ''}): ${result.error}`);
|
|
317
304
|
else if (['write_file', 'edit_file'].includes(tc.name)) falhasPendentes.length = 0;
|
package/lib/desktop.js
CHANGED
|
@@ -659,8 +659,8 @@ function clickElement(nome, opts = {}) {
|
|
|
659
659
|
}
|
|
660
660
|
|
|
661
661
|
// ── Screenshot ────────────────────────────────────────
|
|
662
|
-
// A ferramenta do Linux é resolvida UMA vez:
|
|
663
|
-
//
|
|
662
|
+
// A ferramenta do Linux é resolvida UMA vez: um `which` a cada screenshot
|
|
663
|
+
// seria desperdício.
|
|
664
664
|
let LINUX_SHOT;
|
|
665
665
|
function linuxShot() {
|
|
666
666
|
if (LINUX_SHOT === undefined) LINUX_SHOT = firstCmd(['scrot', 'maim', 'gnome-screenshot', 'spectacle', 'import']);
|
|
@@ -669,8 +669,8 @@ function linuxShot() {
|
|
|
669
669
|
|
|
670
670
|
/**
|
|
671
671
|
* O comando de shell que fotografa a tela para `filename` — ou null quando a
|
|
672
|
-
* plataforma não tem como. Existe separado do screenshot() para
|
|
673
|
-
*
|
|
672
|
+
* plataforma não tem como. Existe separado do screenshot() para quem precisa
|
|
673
|
+
* capturar de forma assíncrona, sem travar o processo.
|
|
674
674
|
*/
|
|
675
675
|
function screenshotCommand(filename) {
|
|
676
676
|
if (PLATFORM === 'darwin') return `screencapture -x "${filename}"`;
|
package/lib/referencia.js
CHANGED
|
@@ -8,10 +8,9 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Duas decisões de projeto, deliberadas:
|
|
10
10
|
*
|
|
11
|
-
* 1. É VISÍVEL, não oculto. Cada pesquisa aparece no terminal
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* justamente para mostrar tudo.
|
|
11
|
+
* 1. É VISÍVEL, não oculto. Cada pesquisa aparece no terminal com as fontes
|
|
12
|
+
* que ele abriu. Um agente que abre coisas escondido do dono da máquina é
|
|
13
|
+
* um agente em que não dá para confiar.
|
|
15
14
|
*
|
|
16
15
|
* 2. Ele extrai CONHECIMENTO (como se faz, qual API, que cuidado tomar), não
|
|
17
16
|
* blocos de código para colar. Copiar trechos de terceiros para dentro do
|
package/lib/tools.js
CHANGED
|
@@ -391,7 +391,7 @@ async function despachar(name, args, ctx) {
|
|
|
391
391
|
|
|
392
392
|
// Pesquisa de referência: abre documentação e exemplos sobre o
|
|
393
393
|
// pedido e devolve o TEXTO para o modelo aprender antes de
|
|
394
|
-
// escrever. Aparece no terminal
|
|
394
|
+
// escrever. Aparece no terminal, com as fontes — nada oculto.
|
|
395
395
|
case 'pesquisar_referencia':
|
|
396
396
|
return referencia.pesquisar({
|
|
397
397
|
consulta: args.consulta || args.query || '',
|
package/lib/ui.js
CHANGED
|
@@ -157,13 +157,13 @@ const NOME = 'Primo Code';
|
|
|
157
157
|
const TIPS = [
|
|
158
158
|
'Peça um app novo, ou aponte para um repositório e mande clonar',
|
|
159
159
|
'/dir <pasta> para ele trabalhar numa pasta que já é sua',
|
|
160
|
-
'/help lista tudo · /effort ajusta o esforço · /
|
|
160
|
+
'/help lista tudo · /effort ajusta o esforço · /status confere a API',
|
|
161
161
|
];
|
|
162
162
|
|
|
163
163
|
const NEWS = [
|
|
164
164
|
'/effort escolhe o esforço num slider — do rápido ao mais esperto',
|
|
165
165
|
'Do "high" para cima ele PESQUISA referências antes de escrever',
|
|
166
|
-
'Esc Esc para o agente
|
|
166
|
+
'Esc Esc a qualquer momento para o agente no meio do trabalho',
|
|
167
167
|
];
|
|
168
168
|
|
|
169
169
|
// ── Cabeçalho ────────────────────────────────────────────────────────────
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "primocode",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.18.0",
|
|
4
4
|
"description": "PrimoCode — agente de engenharia com IA e cursor próprio. Modelos grátis. Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
|
|
5
5
|
"main": "bin/primocode.js",
|
|
6
6
|
"bin": {
|
package/lib/streaming.js
DELETED
|
@@ -1,759 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* streaming.js — o painel "ao vivo": ver o agente mexendo no computador.
|
|
3
|
-
*
|
|
4
|
-
* O agente age no desktop do usuário (clica, digita, abre janela), mas rápido
|
|
5
|
-
* demais para acompanhar a olho nu — e às vezes numa janela que não está na
|
|
6
|
-
* frente. O /streaming liga um servidorzinho HTTP local que abre um site com
|
|
7
|
-
* a cara do PrimoCode: mascote + nome no cabeçalho e, abaixo, uma "janela"
|
|
8
|
-
* com ABAS — "Ao vivo" mostra a tela agora, e cada app/janela em que o agente
|
|
9
|
-
* mexeu vira uma aba própria, com os passos e as fotos daquele app. Dá para
|
|
10
|
-
* voltar e rever o que ele já fez, passo a passo.
|
|
11
|
-
*
|
|
12
|
-
* Zero dependências: http nativo, uma página HTML embutida que consulta
|
|
13
|
-
* /state a cada 700ms. Só escuta em 127.0.0.1 — nada disso sai da máquina.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
'use strict';
|
|
17
|
-
|
|
18
|
-
const http = require('http');
|
|
19
|
-
const fs = require('fs');
|
|
20
|
-
const path = require('path');
|
|
21
|
-
const os = require('os');
|
|
22
|
-
const { exec } = require('child_process');
|
|
23
|
-
const desktop = require('./desktop');
|
|
24
|
-
|
|
25
|
-
const DIR = path.join(os.homedir(), '.primocode', 'streaming');
|
|
26
|
-
const MAX_LOG = 300;
|
|
27
|
-
// Fotos guardadas em disco para o "voltar no tempo" das abas. Além disso a
|
|
28
|
-
// pasta viraria um álbum infinito — as mais velhas saem.
|
|
29
|
-
const MAX_FRAMES = 60;
|
|
30
|
-
const MAX_ABAS = 12;
|
|
31
|
-
// Teto de disco: mesmo com poucos quadros, tela 4K dá PNG gordo. Passou
|
|
32
|
-
// disso, as fotos mais velhas saem até caber. 120 MB de screenshots numa
|
|
33
|
-
// pasta escondida é exatamente o tipo de coisa que ninguém vai limpar.
|
|
34
|
-
const MAX_BYTES = 120 * 1024 * 1024;
|
|
35
|
-
let bytesEmDisco = 0;
|
|
36
|
-
// Uma foto por ação no máximo a cada 600ms: digitar letra por letra não pode
|
|
37
|
-
// virar uma rajada de screenshots.
|
|
38
|
-
const INTERVALO_FOTO_MS = 600;
|
|
39
|
-
|
|
40
|
-
const state = {
|
|
41
|
-
on: false,
|
|
42
|
-
server: null,
|
|
43
|
-
url: '',
|
|
44
|
-
frames: [], // [{ id, hora, acao, aba }] — id é o nome do arquivo
|
|
45
|
-
proximoFrame: 1,
|
|
46
|
-
abas: [], // [{ id, nome, passos: 0 }] — "Ao vivo" é virtual, não entra aqui
|
|
47
|
-
abaAtual: null,
|
|
48
|
-
log: [],
|
|
49
|
-
instrucoes: [], // o que o usuário pediu — a página autopreenche digitando
|
|
50
|
-
erroFoto: null,
|
|
51
|
-
ultimaFoto: 0,
|
|
52
|
-
passo: 0,
|
|
53
|
-
// ── vídeo ao vivo ──
|
|
54
|
-
liveSeq: 0, // sobe a cada frame novo; a página troca a imagem quando muda
|
|
55
|
-
liveAt: 0, // quando o último frame de vídeo foi capturado
|
|
56
|
-
viewerAt: 0, // último sinal de que alguém está com a página aberta
|
|
57
|
-
liveTimer: null,
|
|
58
|
-
liveBusy: false, // uma captura em voo por vez — sem isso cada poll da página
|
|
59
|
-
// abria OUTRA cadeia de captura e elas se multiplicavam
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
const LIVE = () => path.join(DIR, 'live.png');
|
|
63
|
-
const LIVE_TMP = () => path.join(DIR, 'live-tmp.png');
|
|
64
|
-
|
|
65
|
-
// ── O vídeo: um loop de captura enquanto alguém assiste ──────────────────
|
|
66
|
-
// "Vídeo" aqui é o jeito honesto de fazer sem dependência: fotografar a tela
|
|
67
|
-
// em sequência (2 a 3 quadros por segundo, conforme a máquina aguenta) e a
|
|
68
|
-
// página trocar a imagem no ritmo — como um VNC simples. O loop é assíncrono
|
|
69
|
-
// (exec, nunca execSync) para não roubar tempo do agente, escreve num tmp e
|
|
70
|
-
// renomeia (troca atômica: o navegador nunca lê um PNG pela metade), e SÓ
|
|
71
|
-
// roda enquanto a página deu sinal de vida há menos de 8s — painel fechado
|
|
72
|
-
// não custa nada.
|
|
73
|
-
function viuAgora() {
|
|
74
|
-
state.viewerAt = Date.now();
|
|
75
|
-
agendarVideo(0);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function agendarVideo(ms) {
|
|
79
|
-
if (!state.on || state.liveTimer || state.liveBusy) return;
|
|
80
|
-
state.liveTimer = setTimeout(cicloVideo, ms);
|
|
81
|
-
state.liveTimer.unref();
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function cicloVideo() {
|
|
85
|
-
state.liveTimer = null;
|
|
86
|
-
if (!state.on || state.liveBusy) return;
|
|
87
|
-
if (Date.now() - state.viewerAt > 8000) return; // ninguém olhando — o próximo /state religa
|
|
88
|
-
|
|
89
|
-
const cmd = desktop.screenshotCommand(LIVE_TMP());
|
|
90
|
-
if (!cmd) { if (!state.erroFoto) state.erroFoto = 'screenshot indisponível nesta máquina'; return; }
|
|
91
|
-
|
|
92
|
-
const t0 = Date.now();
|
|
93
|
-
state.liveBusy = true;
|
|
94
|
-
exec(cmd, { timeout: 8000 }, (err) => {
|
|
95
|
-
state.liveBusy = false;
|
|
96
|
-
const dur = Date.now() - t0;
|
|
97
|
-
if (!err) {
|
|
98
|
-
try {
|
|
99
|
-
// Rename é atômico; no Windows ele pode falhar se o navegador
|
|
100
|
-
// estiver lendo o arquivo NAQUELE instante — aí copia por cima.
|
|
101
|
-
try { fs.renameSync(LIVE_TMP(), LIVE()); }
|
|
102
|
-
catch { fs.copyFileSync(LIVE_TMP(), LIVE()); try { fs.unlinkSync(LIVE_TMP()); } catch {} }
|
|
103
|
-
state.liveSeq++;
|
|
104
|
-
state.liveAt = Date.now();
|
|
105
|
-
state.erroFoto = null;
|
|
106
|
-
} catch {}
|
|
107
|
-
} else if (!state.erroFoto) {
|
|
108
|
-
state.erroFoto = corta(String(err.message || err), 120);
|
|
109
|
-
}
|
|
110
|
-
// Máquina rápida ≈ 3 quadros/s; máquina lenta desacelera sozinha.
|
|
111
|
-
agendarVideo(Math.max(280, Math.min(1200, dur)));
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Ações cujo efeito aparece NA TELA — são as que merecem uma foto nova.
|
|
116
|
-
const ACOES_NA_TELA = new Set([
|
|
117
|
-
'desktop_click', 'desktop_double_click', 'desktop_click_element',
|
|
118
|
-
'desktop_move', 'desktop_drag', 'desktop_type', 'desktop_key',
|
|
119
|
-
'desktop_scroll', 'desktop_open_app', 'desktop_focus', 'desktop_screenshot',
|
|
120
|
-
'browser_open', 'browser_click', 'browser_type', 'browser_key',
|
|
121
|
-
'browser_scroll',
|
|
122
|
-
]);
|
|
123
|
-
|
|
124
|
-
function isOn() { return state.on; }
|
|
125
|
-
function url() { return state.url; }
|
|
126
|
-
|
|
127
|
-
const corta = (s, n) => { const t = String(s ?? ''); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
|
|
128
|
-
|
|
129
|
-
// A frase humana de cada ação — é o que aparece na linha do tempo.
|
|
130
|
-
function descrever(name, args = {}) {
|
|
131
|
-
switch (name) {
|
|
132
|
-
case 'desktop_click': return `clique em (${args.x}, ${args.y})`;
|
|
133
|
-
case 'desktop_double_click': return `clique duplo em (${args.x}, ${args.y})`;
|
|
134
|
-
case 'desktop_click_element': return `clique em “${corta(args.name, 40)}”${args.double ? ' (duplo)' : ''}`;
|
|
135
|
-
case 'desktop_move': return `mouse para (${args.x}, ${args.y})`;
|
|
136
|
-
case 'desktop_drag': return `arrastou (${args.from_x}, ${args.from_y}) → (${args.to_x}, ${args.to_y})`;
|
|
137
|
-
case 'desktop_type': return `digitou “${corta(args.text, 40)}”`;
|
|
138
|
-
case 'desktop_key': return `tecla ${args.key}`;
|
|
139
|
-
case 'desktop_scroll': return `rolou a tela (${args.dy ?? 300})`;
|
|
140
|
-
case 'desktop_open_app': return `abriu ${corta(args.app || args.name, 40)}`;
|
|
141
|
-
case 'desktop_focus': return `focou a janela “${corta(args.window || args.title, 40)}”`;
|
|
142
|
-
case 'desktop_windows': return 'listou as janelas abertas';
|
|
143
|
-
case 'desktop_elements': return `leu os elementos${args.window ? ` de “${corta(args.window, 30)}”` : ' da janela em foco'}`;
|
|
144
|
-
case 'desktop_screenshot': return 'tirou um print da tela';
|
|
145
|
-
case 'desktop_size': return 'mediu a tela';
|
|
146
|
-
case 'browser_open': return `abriu ${corta(args.url, 60)} no navegador`;
|
|
147
|
-
case 'browser_click': return `clicou em “${corta(args.text || args.selector || `${args.x},${args.y}`, 40)}” na página`;
|
|
148
|
-
case 'browser_type': return `digitou “${corta(args.text, 40)}” na página`;
|
|
149
|
-
case 'browser_key': return `tecla ${args.key} na página`;
|
|
150
|
-
case 'browser_scroll': return 'rolou a página';
|
|
151
|
-
case 'browser_read': return 'leu a página';
|
|
152
|
-
case 'browser_screenshot': return 'print da página';
|
|
153
|
-
case 'browser_wait': return `esperou ${corta(args.selector, 40)}`;
|
|
154
|
-
case 'browser_eval': return 'rodou JavaScript na página';
|
|
155
|
-
case 'write_file': return `criou/reescreveu ${corta(args.path, 50)}`;
|
|
156
|
-
case 'edit_file': return `editou ${corta(args.path, 50)}`;
|
|
157
|
-
case 'multi_edit': return `editou ${corta(args.path, 50)} (várias trocas)`;
|
|
158
|
-
case 'read_file': return `leu ${corta(args.path, 50)}`;
|
|
159
|
-
case 'run_shell': return `rodou: ${corta(args.command, 60)}`;
|
|
160
|
-
case 'run_in_new_terminal': return `terminal novo: ${corta(args.command, 50)}`;
|
|
161
|
-
case 'write_todos': return 'atualizou o plano';
|
|
162
|
-
case 'pesquisar_referencia': return `pesquisou referências: “${corta(args.consulta || args.query, 50)}”`;
|
|
163
|
-
default: return name.replace(/_/g, ' ');
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// ── Abas: uma por app/janela em que o agente mexeu ────────────────────────
|
|
168
|
-
// Abrir um app, focar uma janela ou abrir um site começa (ou reencontra) a
|
|
169
|
-
// aba daquele contexto. As ações seguintes caem nela — é o que deixa o
|
|
170
|
-
// "o que a IA já fez" navegável depois, em vez de um rolo único.
|
|
171
|
-
|
|
172
|
-
function nomeDeAba(name, args = {}) {
|
|
173
|
-
if (name === 'desktop_open_app') return String(args.app || args.name || '').trim();
|
|
174
|
-
if (name === 'desktop_focus') return String(args.window || args.title || '').trim();
|
|
175
|
-
if (name === 'browser_open') {
|
|
176
|
-
try { return new URL(String(args.url)).hostname || 'navegador'; } catch { return 'navegador'; }
|
|
177
|
-
}
|
|
178
|
-
if (args && args.window) return String(args.window).trim();
|
|
179
|
-
|
|
180
|
-
// Trabalho que não é de janela também merece aba — senão o painel fica
|
|
181
|
-
// vazio de "o que ela fez" justamente no caso mais comum (mexer em
|
|
182
|
-
// arquivo, rodar comando, pesquisar referência).
|
|
183
|
-
if (['write_file', 'edit_file', 'multi_edit', 'read_file'].includes(name)) {
|
|
184
|
-
const arq = String(args.path || '').split(/[\\/]/).pop();
|
|
185
|
-
return arq ? `📄 ${arq}` : '📄 Arquivos';
|
|
186
|
-
}
|
|
187
|
-
if (['run_shell', 'run_in_new_terminal'].includes(name)) return '⌨ Terminal';
|
|
188
|
-
if (name === 'pesquisar_referencia') return '🔎 Pesquisa';
|
|
189
|
-
if (['glob', 'grep', 'list_dir'].includes(name)) return '🔍 Busca';
|
|
190
|
-
if (name.startsWith('browser_')) return '🌐 Navegador';
|
|
191
|
-
if (name.startsWith('desktop_')) return '🖥 Tela';
|
|
192
|
-
if (name === 'spawn_agent') return '🤖 Subagente';
|
|
193
|
-
return '';
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
let proximaAba = 1;
|
|
197
|
-
function entrarNaAba(nome) {
|
|
198
|
-
const alvo = corta(nome, 24);
|
|
199
|
-
if (!alvo) return;
|
|
200
|
-
let aba = state.abas.find((a) => a.nome.toLowerCase() === alvo.toLowerCase());
|
|
201
|
-
if (!aba) {
|
|
202
|
-
// id de contador próprio: com `abas.length + 1`, uma aba descartada
|
|
203
|
-
// fazia a próxima nascer com id repetido — e os passos da nova
|
|
204
|
-
// apareciam dentro da antiga.
|
|
205
|
-
aba = { id: proximaAba++, nome: alvo, passos: 0 };
|
|
206
|
-
state.abas.push(aba);
|
|
207
|
-
if (state.abas.length > MAX_ABAS) state.abas.shift();
|
|
208
|
-
}
|
|
209
|
-
state.abaAtual = aba;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// Guarda o quadro e cobra o aluguel: acima de MAX_FRAMES ou de MAX_BYTES, as
|
|
213
|
-
// fotos mais velhas são APAGADAS do disco. É o que impede a pasta de virar um
|
|
214
|
-
// álbum infinito na máquina do usuário.
|
|
215
|
-
function registrarFrame(meta, arquivo) {
|
|
216
|
-
let tamanho = 0;
|
|
217
|
-
try { tamanho = fs.statSync(arquivo).size; } catch {}
|
|
218
|
-
state.frames.push({ ...meta, bytes: tamanho });
|
|
219
|
-
bytesEmDisco += tamanho;
|
|
220
|
-
|
|
221
|
-
while (state.frames.length > MAX_FRAMES || (bytesEmDisco > MAX_BYTES && state.frames.length > 1)) {
|
|
222
|
-
const velho = state.frames.shift();
|
|
223
|
-
bytesEmDisco -= velho.bytes || 0;
|
|
224
|
-
try { fs.unlinkSync(path.join(DIR, `frame-${velho.id}.png`)); } catch {}
|
|
225
|
-
}
|
|
226
|
-
if (bytesEmDisco < 0) bytesEmDisco = 0;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/** Apaga TUDO que o painel deixou no disco. Chamado ao desligar e ao sair. */
|
|
230
|
-
function limparDisco() {
|
|
231
|
-
try {
|
|
232
|
-
for (const f of fs.readdirSync(DIR)) {
|
|
233
|
-
if (/^(frame-\d+|live|live-tmp)\.png$/.test(f)) {
|
|
234
|
-
try { fs.unlinkSync(path.join(DIR, f)); } catch {}
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
} catch {}
|
|
238
|
-
state.frames = [];
|
|
239
|
-
bytesEmDisco = 0;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* A instrução que o usuário acabou de dar. A página autopreenche na barra de
|
|
244
|
-
* comando, letra por letra, e marca o começo dela na linha do tempo.
|
|
245
|
-
*/
|
|
246
|
-
function instrucao(texto) {
|
|
247
|
-
if (!state.on) return;
|
|
248
|
-
const t = String(texto || '').trim();
|
|
249
|
-
if (!t) return;
|
|
250
|
-
state.instrucoes.push({ n: state.instrucoes.length + 1, hora: new Date().toTimeString().slice(0, 8), texto: corta(t, 300) });
|
|
251
|
-
if (state.instrucoes.length > 40) state.instrucoes.splice(0, state.instrucoes.length - 40);
|
|
252
|
-
|
|
253
|
-
state.passo++;
|
|
254
|
-
state.log.push({ n: state.passo, hora: new Date().toTimeString().slice(0, 8), tipo: 'instrucao', acao: corta(t, 160), ok: true, aba: null });
|
|
255
|
-
if (state.log.length > MAX_LOG) state.log.splice(0, state.log.length - MAX_LOG);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Chamada pelo loop do agente depois de CADA tool. Quando o streaming está
|
|
260
|
-
* desligado é um no-op — o caminho normal não paga nada por isto.
|
|
261
|
-
*/
|
|
262
|
-
function step(name, args, result) {
|
|
263
|
-
if (!state.on) return;
|
|
264
|
-
|
|
265
|
-
const troca = nomeDeAba(name, args || {});
|
|
266
|
-
if (troca) entrarNaAba(troca);
|
|
267
|
-
if (!state.abaAtual) entrarNaAba('🖥 Tela');
|
|
268
|
-
|
|
269
|
-
state.passo++;
|
|
270
|
-
if (state.abaAtual) state.abaAtual.passos++;
|
|
271
|
-
|
|
272
|
-
const entrada = {
|
|
273
|
-
n: state.passo,
|
|
274
|
-
hora: new Date().toTimeString().slice(0, 8),
|
|
275
|
-
acao: descrever(name, args || {}),
|
|
276
|
-
ok: !(result && result.ok === false),
|
|
277
|
-
erro: result && result.ok === false ? corta(result.error, 120) : undefined,
|
|
278
|
-
aba: state.abaAtual ? state.abaAtual.id : null,
|
|
279
|
-
};
|
|
280
|
-
|
|
281
|
-
if (ACOES_NA_TELA.has(name)) {
|
|
282
|
-
const frame = capturar(entrada);
|
|
283
|
-
if (frame) entrada.frame = frame;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
state.log.push(entrada);
|
|
287
|
-
if (state.log.length > MAX_LOG) state.log.splice(0, state.log.length - MAX_LOG);
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function capturar(entrada) {
|
|
291
|
-
const agora = Date.now();
|
|
292
|
-
if (agora - state.ultimaFoto < INTERVALO_FOTO_MS) return null;
|
|
293
|
-
state.ultimaFoto = agora;
|
|
294
|
-
|
|
295
|
-
const id = state.proximoFrame;
|
|
296
|
-
const arquivo = path.join(DIR, `frame-${id}.png`);
|
|
297
|
-
|
|
298
|
-
// Com o vídeo rodando, o quadro mais novo já está no disco — copiar é
|
|
299
|
-
// grátis e não fotografa duas vezes a mesma tela.
|
|
300
|
-
if (agora - state.liveAt < 900) {
|
|
301
|
-
try { fs.copyFileSync(LIVE(), arquivo); } catch { return null; }
|
|
302
|
-
state.proximoFrame++;
|
|
303
|
-
registrarFrame({ id, hora: entrada.hora, acao: entrada.acao, aba: entrada.aba }, arquivo);
|
|
304
|
-
return id;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
const r = desktop.screenshot(arquivo);
|
|
308
|
-
if (!r.ok) { if (!state.erroFoto) state.erroFoto = r.error; return null; }
|
|
309
|
-
|
|
310
|
-
state.erroFoto = null;
|
|
311
|
-
state.proximoFrame++;
|
|
312
|
-
registrarFrame({ id, hora: entrada.hora, acao: entrada.acao, aba: entrada.aba }, arquivo);
|
|
313
|
-
return id;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// ── Página ────────────────────────────────────────────────────────────────
|
|
317
|
-
// O desenho combinado: mascote + PrimoCode no cabeçalho; abaixo, uma "janela"
|
|
318
|
-
// de borda branca com as três bolinhas e as ABAS; dentro, a tela que a IA
|
|
319
|
-
// está usando (ou usou, nas abas antigas), com os passos daquela aba embaixo.
|
|
320
|
-
|
|
321
|
-
// O mascote do CLI (▐▛███▜▌ …) redesenhado em SVG, célula a célula, na mesma
|
|
322
|
-
// paleta: bloco cheio na cor viva, contorno na apagada.
|
|
323
|
-
// O mascote: um robozinho PROGRAMADOR. A cabeça é um monitor com o prompt
|
|
324
|
-
// "❯_" na tela (é isso que ele faz: linha de comando), antena com luz de
|
|
325
|
-
// atividade e dois bracinhos que digitam num tecladinho. O anterior era um
|
|
326
|
-
// bloco de pixels sem leitura nenhuma — não dizia "programação", não dizia
|
|
327
|
-
// nada. Olhos, antena, mão e cursor têm classe própria: a animação é CSS.
|
|
328
|
-
const MASCOTE_SVG = `<svg viewBox="0 0 44 40" aria-hidden="true">
|
|
329
|
-
<!-- antena com a luz de atividade -->
|
|
330
|
-
<line x1="22" y1="1.5" x2="22" y2="6" stroke="#0891b2" stroke-width="1.6" stroke-linecap="round"/>
|
|
331
|
-
<circle class="antena" cx="22" cy="2" r="2.3" fill="#22d3ee"/>
|
|
332
|
-
<!-- corpo/monitor -->
|
|
333
|
-
<rect x="4" y="6" width="36" height="24" rx="5" fill="#0e2233" stroke="#22d3ee" stroke-width="1.8"/>
|
|
334
|
-
<rect x="7.5" y="9.5" width="29" height="17" rx="3" fill="#071522"/>
|
|
335
|
-
<!-- olhos -->
|
|
336
|
-
<g fill="#22d3ee">
|
|
337
|
-
<rect class="olho" x="12" y="14" width="5" height="6" rx="2.2"/>
|
|
338
|
-
<rect class="olho" x="27" y="14" width="5" height="6" rx="2.2"/>
|
|
339
|
-
</g>
|
|
340
|
-
<!-- o prompt: ❯ e o cursor piscando (é um programador, afinal) -->
|
|
341
|
-
<path d="M12.5 23.2 l2.4 1.5 -2.4 1.5" fill="none" stroke="#5eead4" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
342
|
-
<rect class="cursor" x="17" y="23" width="4.5" height="3.2" rx=".6" fill="#5eead4"/>
|
|
343
|
-
<!-- braços + teclado -->
|
|
344
|
-
<g stroke="#0891b2" stroke-width="1.8" stroke-linecap="round">
|
|
345
|
-
<path class="braco braco1" d="M6 27 L3.5 33"/>
|
|
346
|
-
<path class="braco braco2" d="M38 27 L40.5 33"/>
|
|
347
|
-
</g>
|
|
348
|
-
<rect x="8" y="33" width="28" height="5" rx="2" fill="#12384d" stroke="#0891b2" stroke-width="1.2"/>
|
|
349
|
-
<g fill="#22d3ee" opacity=".85">
|
|
350
|
-
<rect class="tecla t1" x="11" y="35" width="4" height="1.6" rx=".6"/>
|
|
351
|
-
<rect class="tecla t2" x="17" y="35" width="4" height="1.6" rx=".6"/>
|
|
352
|
-
<rect class="tecla t3" x="23" y="35" width="4" height="1.6" rx=".6"/>
|
|
353
|
-
<rect class="tecla t1" x="29" y="35" width="4" height="1.6" rx=".6"/>
|
|
354
|
-
</g>
|
|
355
|
-
</svg>`;
|
|
356
|
-
|
|
357
|
-
// A tela do painel, na identidade do app — a MESMA paleta do design system do
|
|
358
|
-
// CLI (lib/ui.js): primária cyan #22d3ee, neutros slate, rose para falha. A
|
|
359
|
-
// estrutura é a do desenho do dono: mascote inclinado no canto, "Primocode"
|
|
360
|
-
// no alto, a janela com as bolinhas e as abas separadas por "|", a tela
|
|
361
|
-
// embaixo. Dentro da tela roda o VÍDEO ao vivo (o loop de captura acima); as
|
|
362
|
-
// abas em cima são tudo que o agente abriu — clicáveis, cada uma com seus
|
|
363
|
-
// quadros e passos; e a barra de comando autopreenche a instrução digitando.
|
|
364
|
-
// O favicon é o próprio mascote — o viewBox vem dele, não fixo (já ficou
|
|
365
|
-
// errado uma vez quando o desenho mudou de tamanho e o número aqui não).
|
|
366
|
-
const FAVICON = 'data:image/svg+xml,' + encodeURIComponent(
|
|
367
|
-
MASCOTE_SVG.replace('<svg ', '<svg xmlns="http://www.w3.org/2000/svg" '),
|
|
368
|
-
);
|
|
369
|
-
|
|
370
|
-
const PAGINA = `<!doctype html>
|
|
371
|
-
<html lang="pt-BR"><head><meta charset="utf-8">
|
|
372
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
373
|
-
<title>Primocode · Ao vivo</title>
|
|
374
|
-
<link rel="icon" href="${FAVICON}">
|
|
375
|
-
<style>
|
|
376
|
-
* { box-sizing: border-box; margin: 0; }
|
|
377
|
-
::selection { background: rgba(34,211,238,.28); }
|
|
378
|
-
body {
|
|
379
|
-
background: radial-gradient(1100px 480px at 50% -120px, #12263f 0%, #0d1729 46%, #0b1220 100%) fixed #0b1220;
|
|
380
|
-
color: #e2e8f0; font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
381
|
-
min-height: 100vh; display: flex; flex-direction: column; align-items: center; padding: 0 18px 34px;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
/* ── Cabeçalho: mascote inclinado + nome, como no desenho ── */
|
|
385
|
-
#topo { position: relative; width: min(1120px, 100%); padding: 44px 0 26px; text-align: center; }
|
|
386
|
-
#topo .mascote { position: absolute; left: 8px; top: 18px; width: 62px; height: 56px; filter: drop-shadow(0 8px 20px rgba(34,211,238,.35)); animation: flutua 3s ease-in-out infinite; }
|
|
387
|
-
@keyframes flutua { 0%, 100% { transform: translateY(0) } 50% { transform: translateY(5px) } }
|
|
388
|
-
#topo .mascote svg { width: 100%; height: 100%; display: block; overflow: visible; }
|
|
389
|
-
/* vivo: pisca os olhos, o cursor do prompt pisca, a antena respira e as
|
|
390
|
-
mãozinhas digitam no teclado */
|
|
391
|
-
.mascote .olho { transform-box: fill-box; transform-origin: center; animation: piscaOlho 4s infinite; }
|
|
392
|
-
@keyframes piscaOlho { 0%, 91%, 100% { transform: scaleY(1) } 94%, 96% { transform: scaleY(.1) } }
|
|
393
|
-
.mascote .cursor { animation: piscaCursor 1.05s steps(1) infinite; }
|
|
394
|
-
@keyframes piscaCursor { 50% { opacity: 0 } }
|
|
395
|
-
.mascote .antena { animation: respira 2.2s ease-in-out infinite; }
|
|
396
|
-
@keyframes respira { 0%, 100% { opacity: 1; r: 2.3 } 50% { opacity: .55; r: 1.9 } }
|
|
397
|
-
.mascote .braco { transform-box: fill-box; transform-origin: top center; }
|
|
398
|
-
.mascote .braco1 { animation: digita .62s ease-in-out infinite; }
|
|
399
|
-
.mascote .braco2 { animation: digita .62s ease-in-out infinite; animation-delay: .31s; }
|
|
400
|
-
@keyframes digita { 0%, 100% { transform: translateY(0) } 50% { transform: translateY(1.6px) } }
|
|
401
|
-
.mascote .tecla { animation: teclaAcende 1.24s steps(1) infinite; }
|
|
402
|
-
.mascote .t2 { animation-delay: .31s } .mascote .t3 { animation-delay: .62s }
|
|
403
|
-
@keyframes teclaAcende { 0%, 74% { opacity: .35 } 75%, 99% { opacity: 1 } }
|
|
404
|
-
#topo h1 { font-size: clamp(34px, 5.6vw, 54px); font-weight: 650; letter-spacing: .4px; color: #f1f5f9; text-shadow: 0 4px 28px rgba(34,211,238,.22); }
|
|
405
|
-
#topo h1 span { color: #22d3ee; }
|
|
406
|
-
#topo .vivo { position: absolute; right: 2px; top: 48px; display: inline-flex; align-items: center; gap: 8px; color: #94a3b8; font-size: 12.5px; background: rgba(15,23,42,.85); border: 1px solid #1e293b; border-radius: 999px; padding: 6px 14px; }
|
|
407
|
-
.pulso { width: 8px; height: 8px; border-radius: 50%; background: #22d3ee; box-shadow: 0 0 10px #22d3ee99; animation: pulsa 1.4s infinite; }
|
|
408
|
-
@keyframes pulsa { 50% { opacity: .3 } }
|
|
409
|
-
|
|
410
|
-
/* ── A janela ── */
|
|
411
|
-
#janela {
|
|
412
|
-
width: min(1120px, 100%); background: #000; border: 1px solid #24344d; border-radius: 22px;
|
|
413
|
-
overflow: hidden; display: flex; flex-direction: column;
|
|
414
|
-
box-shadow: 0 30px 80px rgba(2,6,23,.75), 0 0 60px rgba(34,211,238,.07), inset 0 1px 0 rgba(226,232,240,.07);
|
|
415
|
-
}
|
|
416
|
-
#barra { position: relative; display: flex; align-items: center; background: linear-gradient(#152238, #101a2c); padding: 13px 22px; min-height: 54px; border-bottom: 1px solid #0a1120; }
|
|
417
|
-
#barra .b { flex: none; width: 13px; height: 13px; border-radius: 50%; margin-right: 9px; box-shadow: inset 0 -1.5px 3px rgba(0,0,0,.35); }
|
|
418
|
-
#barra .b.r { background: #ff5f57; } #barra .b.y { background: #febc2e; } #barra .b.g { background: #28c840; }
|
|
419
|
-
|
|
420
|
-
/* Abas: tudo que ele abriu, clicável, separado por | como no desenho */
|
|
421
|
-
#abas { position: absolute; left: 50%; transform: translateX(-50%); max-width: 62%; display: flex; align-items: center; gap: 11px; overflow-x: auto; white-space: nowrap; scrollbar-width: none; font-size: clamp(14px, 1.9vw, 18px); font-weight: 550; }
|
|
422
|
-
#abas::-webkit-scrollbar { display: none; }
|
|
423
|
-
.aba { position: relative; border: 0; background: none; cursor: pointer; font: inherit; color: #64748b; padding: 4px 3px 7px; transition: color .18s ease; }
|
|
424
|
-
.aba:hover { color: #cbd5e1; }
|
|
425
|
-
.aba.ativa { color: #22d3ee; text-shadow: 0 0 18px rgba(34,211,238,.5); }
|
|
426
|
-
.aba.ativa::after { content: ''; position: absolute; left: 15%; right: 15%; bottom: 0; height: 2px; border-radius: 2px; background: #22d3ee; box-shadow: 0 0 10px #22d3ee; }
|
|
427
|
-
.aba .qtd { color: #475569; font-size: .68em; vertical-align: super; margin-left: 4px; font-weight: 600; }
|
|
428
|
-
.sep { color: #334155; font-weight: 300; }
|
|
429
|
-
|
|
430
|
-
/* ── Barra de comando: a instrução autopreenchendo ── */
|
|
431
|
-
#comando { display: flex; align-items: center; gap: 11px; background: #0a1120; border-bottom: 1px solid #060b16; padding: 11px 22px; font-family: ui-monospace, 'Cascadia Code', Consolas, monospace; font-size: 14px; min-height: 44px; }
|
|
432
|
-
#comando .chevron { color: #22d3ee; font-weight: 700; flex: none; text-shadow: 0 0 10px rgba(34,211,238,.6); }
|
|
433
|
-
#comando .texto { color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
434
|
-
#comando .texto.vazio { color: #475569; font-style: italic; }
|
|
435
|
-
#comando .cursor { flex: none; width: 8px; height: 16px; background: #22d3ee; margin-left: 2px; animation: pisca 1s steps(1) infinite; box-shadow: 0 0 8px rgba(34,211,238,.7); }
|
|
436
|
-
@keyframes pisca { 50% { opacity: 0 } }
|
|
437
|
-
#comando .hora { margin-left: auto; color: #475569; font-size: 11.5px; flex: none; }
|
|
438
|
-
|
|
439
|
-
/* ── A tela: o vídeo ao vivo ── */
|
|
440
|
-
#tela { position: relative; min-height: 440px; display: flex; align-items: center; justify-content: center; background: #000; }
|
|
441
|
-
#tela img { width: 100%; max-height: 60vh; object-fit: contain; display: none; }
|
|
442
|
-
#aviso { text-align: center; padding: 84px 24px; }
|
|
443
|
-
#aviso .titulo { font-size: clamp(19px, 3vw, 28px); font-weight: 550; color: #f1f5f9; }
|
|
444
|
-
#aviso .sub { color: #64748b; font-size: 14px; margin-top: 13px; line-height: 1.7; }
|
|
445
|
-
#badge { position: absolute; top: 14px; right: 16px; display: none; align-items: center; gap: 7px; background: rgba(8,12,22,.78); backdrop-filter: blur(6px); border: 1px solid #1e293b; color: #f1f5f9; font-size: 11.5px; font-weight: 700; letter-spacing: 1.2px; padding: 6px 12px; border-radius: 999px; }
|
|
446
|
-
#badge .rec { width: 8px; height: 8px; border-radius: 50%; background: #fb7185; box-shadow: 0 0 9px #fb7185; animation: pulsa 1.1s infinite; }
|
|
447
|
-
#carimbo { position: absolute; left: 16px; bottom: 13px; background: rgba(8,12,22,.78); backdrop-filter: blur(6px); border: 1px solid #1e293b; color: #94a3b8; font-size: 12px; padding: 5px 13px; border-radius: 999px; display: none; max-width: 82%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
448
|
-
#carimbo b { color: #22d3ee; font-weight: 600; }
|
|
449
|
-
|
|
450
|
-
/* ── Linha do tempo ── */
|
|
451
|
-
#historico { width: min(1120px, 100%); padding-top: 18px; }
|
|
452
|
-
#filme { display: flex; gap: 9px; padding: 4px 2px 13px; overflow-x: auto; scrollbar-width: thin; scrollbar-color: #1e293b transparent; }
|
|
453
|
-
.foto { flex: none; border: 1.5px solid #1e293b; border-radius: 9px; background: #000; cursor: pointer; padding: 0; width: 100px; height: 60px; overflow: hidden; opacity: .6; transition: transform .16s ease, opacity .16s ease, border-color .16s ease, box-shadow .16s ease; }
|
|
454
|
-
.foto:hover { opacity: 1; transform: translateY(-2px); border-color: #475569; }
|
|
455
|
-
.foto img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
456
|
-
.foto.ativa { border-color: #22d3ee; opacity: 1; box-shadow: 0 0 0 2px rgba(34,211,238,.3), 0 6px 18px rgba(2,6,23,.6); }
|
|
457
|
-
|
|
458
|
-
#cartao { background: #0f172a; border: 1px solid #1e293b; border-radius: 16px; overflow: hidden; }
|
|
459
|
-
#cartao header { display: flex; align-items: center; justify-content: space-between; padding: 11px 17px; border-bottom: 1px solid #16223a; color: #94a3b8; font-size: 13px; font-weight: 600; letter-spacing: .3px; }
|
|
460
|
-
#cartao header span b { color: #22d3ee; }
|
|
461
|
-
#passos { max-height: 236px; overflow-y: auto; padding: 5px 0 9px; scrollbar-width: thin; scrollbar-color: #1e293b transparent; }
|
|
462
|
-
#passos::-webkit-scrollbar { width: 8px; } #passos::-webkit-scrollbar-thumb { background: #1e293b; border-radius: 4px; }
|
|
463
|
-
.item { padding: 5px 17px; display: flex; gap: 10px; font-size: 13.5px; align-items: baseline; transition: background .12s ease; }
|
|
464
|
-
.item:hover { background: #131f35; }
|
|
465
|
-
.item .n { color: #475569; min-width: 3ch; text-align: right; font-variant-numeric: tabular-nums; }
|
|
466
|
-
.item .ok { color: #22d3ee; } .item .falha { color: #fb7185; }
|
|
467
|
-
.item .txt { flex: 1; word-break: break-word; color: #cbd5e1; }
|
|
468
|
-
.item .txt .erro { color: #fb7185; font-size: 12px; display: block; margin-top: 1px; }
|
|
469
|
-
.item .hora { color: #475569; font-size: 11px; font-variant-numeric: tabular-nums; }
|
|
470
|
-
.item.temfoto { cursor: pointer; } .item.temfoto:hover .txt { color: #f1f5f9; }
|
|
471
|
-
.item.pedido { background: #0c1526; border-top: 1px solid #16223a; border-bottom: 1px solid #16223a; margin: 6px 0; padding: 8px 17px; }
|
|
472
|
-
.item.pedido .txt { color: #f1f5f9; font-weight: 600; }
|
|
473
|
-
.item.pedido .chev { color: #22d3ee; font-weight: 800; }
|
|
474
|
-
|
|
475
|
-
footer { color: #475569; font-size: 12px; padding-top: 17px; }
|
|
476
|
-
footer b { color: #64748b; font-weight: 600; }
|
|
477
|
-
</style></head>
|
|
478
|
-
<body>
|
|
479
|
-
<div id="topo">
|
|
480
|
-
<div class="mascote">${MASCOTE_SVG}</div>
|
|
481
|
-
<h1>Primo<span>code</span></h1>
|
|
482
|
-
<span class="vivo"><span class="pulso"></span><span id="st">conectando…</span></span>
|
|
483
|
-
</div>
|
|
484
|
-
|
|
485
|
-
<div id="janela">
|
|
486
|
-
<div id="barra">
|
|
487
|
-
<span class="b r"></span><span class="b y"></span><span class="b g"></span>
|
|
488
|
-
<nav id="abas"></nav>
|
|
489
|
-
</div>
|
|
490
|
-
<div id="comando">
|
|
491
|
-
<span class="chevron">❯</span>
|
|
492
|
-
<span class="texto vazio" id="cmdtexto">aguardando a sua instrução no terminal…</span>
|
|
493
|
-
<span class="cursor"></span>
|
|
494
|
-
<span class="hora" id="cmdhora"></span>
|
|
495
|
-
</div>
|
|
496
|
-
<section id="tela">
|
|
497
|
-
<img id="frame" alt="tela do computador, ao vivo">
|
|
498
|
-
<div id="aviso">
|
|
499
|
-
<div class="titulo">A tela do computador aparece aqui, em vídeo</div>
|
|
500
|
-
<div class="sub">Dê uma instrução no terminal do PrimoCode e assista ele trabalhando, ao vivo.<br>Cada app que ele abrir vira uma aba lá em cima — clique nelas para rever o que já foi feito.</div>
|
|
501
|
-
</div>
|
|
502
|
-
<div id="badge"><span class="rec"></span>AO VIVO</div>
|
|
503
|
-
<div id="carimbo"></div>
|
|
504
|
-
</section>
|
|
505
|
-
</div>
|
|
506
|
-
|
|
507
|
-
<div id="historico">
|
|
508
|
-
<div id="filme"></div>
|
|
509
|
-
<div id="cartao">
|
|
510
|
-
<header><span>Linha do tempo · <b id="abanome">Ao vivo</b></span><span id="resumo"></span></header>
|
|
511
|
-
<div id="passos"></div>
|
|
512
|
-
</div>
|
|
513
|
-
</div>
|
|
514
|
-
<footer>só nesta máquina <b>(127.0.0.1)</b> · <b>/streaming</b> desliga</footer>
|
|
515
|
-
|
|
516
|
-
<script>
|
|
517
|
-
const VIVO = 0; // id da aba "Ao vivo"
|
|
518
|
-
let dados = { abas: [], frames: [], log: [], instrucoes: [], liveSeq: 0 };
|
|
519
|
-
let abaSel = VIVO; // aba escolhida pelo usuário
|
|
520
|
-
let fotoSel = null; // frame fixado (null = vídeo/mais recente)
|
|
521
|
-
let ultimoEstado = '';
|
|
522
|
-
|
|
523
|
-
function esc(s) { return String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
|
|
524
|
-
|
|
525
|
-
// ── o vídeo: busca o quadro direto, desde o primeiro segundo ──
|
|
526
|
-
// Não espera o /state confirmar nada: enquanto não há seq, tenta com
|
|
527
|
-
// timestamp (um 404 só significa "primeiro quadro ainda não saiu" — tenta de
|
|
528
|
-
// novo). Assim a tela aparece assim que o painel abre, sem depender de o
|
|
529
|
-
// agente ter feito qualquer coisa.
|
|
530
|
-
let quadroNaTela = '';
|
|
531
|
-
let buscandoQuadro = false;
|
|
532
|
-
function modoVideo() { return abaSel === VIVO && fotoSel == null; }
|
|
533
|
-
function videoTick() {
|
|
534
|
-
if (modoVideo() && !buscandoQuadro) {
|
|
535
|
-
const marca = dados.liveSeq > 0 ? 's' + dados.liveSeq : 't' + Date.now();
|
|
536
|
-
if (marca !== quadroNaTela) {
|
|
537
|
-
buscandoQuadro = true;
|
|
538
|
-
const pre = new Image(); // pré-carrega fora da tela:
|
|
539
|
-
pre.onload = () => { // o quadro só entra inteiro
|
|
540
|
-
buscandoQuadro = false;
|
|
541
|
-
if (!modoVideo()) return;
|
|
542
|
-
const img = document.getElementById('frame');
|
|
543
|
-
img.src = pre.src; img.style.display = 'block';
|
|
544
|
-
document.getElementById('aviso').style.display = 'none';
|
|
545
|
-
document.getElementById('badge').style.display = 'flex';
|
|
546
|
-
document.getElementById('carimbo').style.display = 'none';
|
|
547
|
-
quadroNaTela = marca;
|
|
548
|
-
};
|
|
549
|
-
pre.onerror = () => { buscandoQuadro = false; };
|
|
550
|
-
pre.src = '/live?' + marca;
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
setTimeout(videoTick, 300);
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
// ── a instrução autopreenchendo, letra por letra ──
|
|
557
|
-
let digitando = null, instrucaoNaTela = 0;
|
|
558
|
-
function autopreencher(inst) {
|
|
559
|
-
if (!inst || inst.n === instrucaoNaTela) return;
|
|
560
|
-
instrucaoNaTela = inst.n;
|
|
561
|
-
clearInterval(digitando);
|
|
562
|
-
const el = document.getElementById('cmdtexto');
|
|
563
|
-
el.classList.remove('vazio');
|
|
564
|
-
el.textContent = '';
|
|
565
|
-
document.getElementById('cmdhora').textContent = inst.hora;
|
|
566
|
-
let i = 0;
|
|
567
|
-
digitando = setInterval(() => {
|
|
568
|
-
i += 1 + Math.floor(Math.random() * 2); // cadência de gente digitando
|
|
569
|
-
el.textContent = inst.texto.slice(0, i);
|
|
570
|
-
if (i >= inst.texto.length) clearInterval(digitando);
|
|
571
|
-
}, 32);
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
function framesDaAba(id) {
|
|
575
|
-
return id === VIVO ? dados.frames : dados.frames.filter((f) => f.aba === id);
|
|
576
|
-
}
|
|
577
|
-
function logDaAba(id) {
|
|
578
|
-
return id === VIVO ? dados.log : dados.log.filter((e) => e.aba === id || e.tipo === 'instrucao');
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
function render() {
|
|
582
|
-
// Abas: "Ao vivo" + tudo que ele abriu, separadas por |
|
|
583
|
-
const nav = document.getElementById('abas');
|
|
584
|
-
const abas = [{ id: VIVO, nome: 'Ao vivo' }, ...dados.abas];
|
|
585
|
-
nav.innerHTML = abas.map((a) =>
|
|
586
|
-
'<button class="aba' + (a.id === abaSel ? ' ativa' : '') + '" data-id="' + a.id + '">' + esc(a.nome)
|
|
587
|
-
+ (a.passos ? '<span class="qtd">' + a.passos + '</span>' : '') + '</button>'
|
|
588
|
-
).join('<span class="sep">|</span>');
|
|
589
|
-
nav.querySelectorAll('.aba').forEach((b) => b.onclick = () => {
|
|
590
|
-
abaSel = Number(b.dataset.id); fotoSel = null; quadroNaTela = -1; render();
|
|
591
|
-
});
|
|
592
|
-
const abaObj = abas.find((a) => a.id === abaSel) || abas[0];
|
|
593
|
-
document.getElementById('abanome').textContent = abaObj.nome;
|
|
594
|
-
|
|
595
|
-
// Fora do modo vídeo (aba antiga ou foto fixada): mostra o quadro daquela aba
|
|
596
|
-
const fs = framesDaAba(abaSel);
|
|
597
|
-
const img = document.getElementById('frame');
|
|
598
|
-
const aviso = document.getElementById('aviso');
|
|
599
|
-
const badge = document.getElementById('badge');
|
|
600
|
-
const carimbo = document.getElementById('carimbo');
|
|
601
|
-
if (!modoVideo()) {
|
|
602
|
-
const atual = fotoSel != null ? fs.find((f) => f.id === fotoSel) : fs[fs.length - 1];
|
|
603
|
-
badge.style.display = 'none';
|
|
604
|
-
if (atual) {
|
|
605
|
-
if (img.dataset.n !== 'f' + atual.id) { img.src = '/frame?n=' + atual.id; img.dataset.n = 'f' + atual.id; }
|
|
606
|
-
img.style.display = 'block'; aviso.style.display = 'none';
|
|
607
|
-
carimbo.style.display = 'block';
|
|
608
|
-
carimbo.innerHTML = '<b>#' + atual.id + '</b> · ' + atual.hora + ' · ' + esc(atual.acao)
|
|
609
|
-
+ (fotoSel != null ? ' — <b>clique na miniatura de novo para voltar</b>' : '');
|
|
610
|
-
} else {
|
|
611
|
-
img.style.display = 'none'; aviso.style.display = 'block'; carimbo.style.display = 'none';
|
|
612
|
-
}
|
|
613
|
-
} else {
|
|
614
|
-
carimbo.style.display = 'none';
|
|
615
|
-
if (dados.erroFoto) { aviso.style.display = 'block'; aviso.querySelector('.sub').textContent = 'Sem imagem da tela: ' + dados.erroFoto; }
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
// Filmstrip da aba (as últimas 16)
|
|
619
|
-
const filme = document.getElementById('filme');
|
|
620
|
-
const tira = fs.slice(-16);
|
|
621
|
-
const marcada = fotoSel != null ? fotoSel : -1;
|
|
622
|
-
filme.innerHTML = tira.map((f) =>
|
|
623
|
-
'<button class="foto' + (f.id === marcada ? ' ativa' : '') + '" data-id="' + f.id + '" title="' + esc(f.acao) + '"><img loading="lazy" src="/frame?n=' + f.id + '"></button>').join('');
|
|
624
|
-
filme.querySelectorAll('.foto').forEach((b) => b.onclick = () => {
|
|
625
|
-
const id = Number(b.dataset.id);
|
|
626
|
-
fotoSel = (fotoSel === id) ? null : id; // clicar de novo volta ao vídeo
|
|
627
|
-
quadroNaTela = -1;
|
|
628
|
-
render();
|
|
629
|
-
});
|
|
630
|
-
if (fotoSel == null) filme.scrollLeft = filme.scrollWidth;
|
|
631
|
-
|
|
632
|
-
// Linha do tempo da aba (mais novos primeiro); pedidos viram divisórias
|
|
633
|
-
const passos = logDaAba(abaSel).slice(-60).reverse();
|
|
634
|
-
document.getElementById('resumo').textContent = passos.filter((e) => e.tipo !== 'instrucao').length + ' passos';
|
|
635
|
-
document.getElementById('passos').innerHTML = passos.map((e) => {
|
|
636
|
-
if (e.tipo === 'instrucao') {
|
|
637
|
-
return '<div class="item pedido"><span class="n">' + e.n + '</span><span class="chev">❯</span>'
|
|
638
|
-
+ '<span class="txt">' + esc(e.acao) + '</span><span class="hora">' + e.hora + '</span></div>';
|
|
639
|
-
}
|
|
640
|
-
return '<div class="item' + (e.frame ? ' temfoto" data-frame="' + e.frame : '') + '">'
|
|
641
|
-
+ '<span class="n">' + e.n + '</span>'
|
|
642
|
-
+ '<span class="' + (e.ok ? 'ok' : 'falha') + '">' + (e.ok ? '✔' : '✗') + '</span>'
|
|
643
|
-
+ '<span class="txt">' + esc(e.acao) + (e.erro ? '<span class="erro">' + esc(e.erro) + '</span>' : '') + '</span>'
|
|
644
|
-
+ '<span class="hora">' + e.hora + '</span></div>';
|
|
645
|
-
}).join('');
|
|
646
|
-
document.querySelectorAll('.item.temfoto').forEach((el) => el.onclick = () => { fotoSel = Number(el.dataset.frame); quadroNaTela = -1; render(); });
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
async function tick() {
|
|
650
|
-
try {
|
|
651
|
-
const s = await (await fetch('/state')).json();
|
|
652
|
-
dados = s;
|
|
653
|
-
document.getElementById('st').textContent = 'ao vivo';
|
|
654
|
-
autopreencher(s.instrucoes[s.instrucoes.length - 1]);
|
|
655
|
-
const assinatura = JSON.stringify([s.frames.length, s.log.length, s.abas.length, s.erroFoto]);
|
|
656
|
-
if (assinatura !== ultimoEstado) { ultimoEstado = assinatura; render(); }
|
|
657
|
-
} catch (e) {
|
|
658
|
-
document.getElementById('st').textContent = 'reconectando…';
|
|
659
|
-
}
|
|
660
|
-
setTimeout(tick, 600);
|
|
661
|
-
}
|
|
662
|
-
tick();
|
|
663
|
-
videoTick();
|
|
664
|
-
</script>
|
|
665
|
-
</body></html>`;
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
function start() {
|
|
669
|
-
if (state.on) return Promise.resolve(state.url);
|
|
670
|
-
try { fs.mkdirSync(DIR, { recursive: true }); } catch {}
|
|
671
|
-
// Fotos de sessões passadas não pertencem a esta linha do tempo.
|
|
672
|
-
try { for (const f of fs.readdirSync(DIR)) if (/^(frame-\d+|live|live-tmp)\.png$/.test(f)) fs.unlinkSync(path.join(DIR, f)); } catch {}
|
|
673
|
-
|
|
674
|
-
return new Promise((resolve, reject) => {
|
|
675
|
-
const server = http.createServer((req, res) => {
|
|
676
|
-
const u = new URL(req.url || '/', 'http://x');
|
|
677
|
-
if (u.pathname === '/state') {
|
|
678
|
-
viuAgora(); // página aberta = vídeo ligado
|
|
679
|
-
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
680
|
-
res.end(JSON.stringify({
|
|
681
|
-
erroFoto: state.erroFoto,
|
|
682
|
-
abas: state.abas,
|
|
683
|
-
frames: state.frames,
|
|
684
|
-
log: state.log.slice(-120),
|
|
685
|
-
instrucoes: state.instrucoes.slice(-20),
|
|
686
|
-
liveSeq: state.liveSeq,
|
|
687
|
-
}));
|
|
688
|
-
return;
|
|
689
|
-
}
|
|
690
|
-
if (u.pathname === '/live') {
|
|
691
|
-
viuAgora();
|
|
692
|
-
fs.readFile(LIVE(), (err, buf) => {
|
|
693
|
-
if (err) { res.writeHead(404); res.end(); return; }
|
|
694
|
-
res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' });
|
|
695
|
-
res.end(buf);
|
|
696
|
-
});
|
|
697
|
-
return;
|
|
698
|
-
}
|
|
699
|
-
if (u.pathname === '/frame') {
|
|
700
|
-
// O id vem de fora: só serve o arquivo se for número — nada de
|
|
701
|
-
// path traversal por aqui.
|
|
702
|
-
const id = Math.round(Number(u.searchParams.get('n')));
|
|
703
|
-
const conhecido = Number.isFinite(id) && state.frames.some((f) => f.id === id);
|
|
704
|
-
if (!conhecido) { res.writeHead(404); res.end(); return; }
|
|
705
|
-
fs.readFile(path.join(DIR, `frame-${id}.png`), (err, buf) => {
|
|
706
|
-
if (err) { res.writeHead(404); res.end(); return; }
|
|
707
|
-
// Cada frame é imutável: o navegador pode guardar à vontade.
|
|
708
|
-
res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'max-age=3600' });
|
|
709
|
-
res.end(buf);
|
|
710
|
-
});
|
|
711
|
-
return;
|
|
712
|
-
}
|
|
713
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
714
|
-
res.end(PAGINA);
|
|
715
|
-
});
|
|
716
|
-
|
|
717
|
-
server.on('error', reject);
|
|
718
|
-
// unref: o painel não pode segurar o processo vivo — /exit tem de sair
|
|
719
|
-
// mesmo com o streaming ligado.
|
|
720
|
-
server.on('connection', (s) => s.unref());
|
|
721
|
-
// Só na própria máquina: isto é a tela do usuário, não um serviço.
|
|
722
|
-
server.listen(0, '127.0.0.1', () => {
|
|
723
|
-
server.unref();
|
|
724
|
-
state.server = server;
|
|
725
|
-
state.on = true;
|
|
726
|
-
state.url = `http://127.0.0.1:${server.address().port}`;
|
|
727
|
-
// O vídeo começa JÁ: quando o navegador terminar de abrir, o
|
|
728
|
-
// primeiro quadro está no ar — nada de tela vazia esperando ação.
|
|
729
|
-
state.viewerAt = Date.now();
|
|
730
|
-
agendarVideo(50);
|
|
731
|
-
armarFaxina();
|
|
732
|
-
resolve(state.url);
|
|
733
|
-
});
|
|
734
|
-
});
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
function stop() {
|
|
738
|
-
if (state.server) { try { state.server.close(); } catch {} }
|
|
739
|
-
if (state.liveTimer) { clearTimeout(state.liveTimer); state.liveTimer = null; }
|
|
740
|
-
state.server = null;
|
|
741
|
-
state.on = false;
|
|
742
|
-
state.url = '';
|
|
743
|
-
limparDisco(); // desligou o painel, as fotos vão junto
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
// Fechou o PrimoCode (/exit, Ctrl+C, Ctrl+D): nada de print esquecido no
|
|
747
|
-
// disco. `exit` cobre a saída normal; os sinais cobrem o resto.
|
|
748
|
-
let faxinaArmada = false;
|
|
749
|
-
function armarFaxina() {
|
|
750
|
-
if (faxinaArmada) return;
|
|
751
|
-
faxinaArmada = true;
|
|
752
|
-
const faxina = () => { try { limparDisco(); } catch {} };
|
|
753
|
-
process.once('exit', faxina);
|
|
754
|
-
for (const sinal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
755
|
-
process.once(sinal, () => { faxina(); process.exit(0); });
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
module.exports = { start, stop, step, instrucao, isOn, url, descrever, limparDisco };
|