primocode 9.8.0-beta.2 → 9.8.0-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -6
- package/app/ansi.js +55 -0
- package/app/app.css +482 -281
- package/app/app.js +888 -315
- package/app/index.html +176 -101
- package/app/primo-marca.js +58 -1
- package/bin/primocode.js +697 -118
- package/comandos/explicar.md +14 -0
- package/comandos/publicar.md +10 -0
- package/comandos/revisar.md +13 -0
- package/lib/act.js +130 -26
- package/lib/api.js +8 -1
- package/lib/app.js +317 -74
- package/lib/browser.js +79 -3
- package/lib/catalogo.js +11 -3
- package/lib/checkpoint-git.js +218 -0
- package/lib/checkpoint.js +249 -0
- package/lib/claude-engine.js +25 -3
- package/lib/comandos-extra.js +481 -0
- package/lib/comandos.js +104 -0
- package/lib/config.js +21 -4
- package/lib/estudio/acervo-site.js +157 -0
- package/lib/estudio/assets.js +77 -3
- package/lib/estudio/cinema.js +354 -0
- package/lib/estudio/composicao.js +470 -0
- package/lib/estudio/edicao.js +450 -0
- package/lib/estudio/impacto.js +263 -0
- package/lib/estudio/index.js +769 -5
- package/lib/estudio/plano.js +5 -2
- package/lib/estudio/remotion.js +228 -23
- package/lib/estudio/slides.js +34 -30
- package/lib/fala.js +95 -1
- package/lib/local/cerebro.json +361 -180
- package/lib/local/entrada.js +18 -8
- package/lib/local/motor-cli.js +1 -1
- package/lib/mencoes.js +98 -0
- package/lib/offline.js +168 -0
- package/lib/painel.js +291 -0
- package/lib/perigo.js +256 -0
- package/lib/pre-leitura.js +118 -0
- package/lib/repeticao.js +4 -2
- package/lib/skills.js +6 -1
- package/lib/tempos.js +62 -0
- package/lib/ui.js +171 -17
- package/package.json +3 -2
- package/skills/apresentacao-pptx/SKILL.md +52 -0
- package/skills/cartaz-de-arte/LICENSE.txt +202 -0
- package/skills/cartaz-de-arte/SKILL.md +47 -0
- package/skills/criar-skill/LICENSE.txt +202 -0
- package/skills/criar-skill/SKILL.md +49 -0
- package/skills/depurar-de-verdade/LICENSE.txt +21 -0
- package/skills/depurar-de-verdade/SKILL.md +14 -5
- package/skills/design-de-verdade/LICENSE.txt +177 -0
- package/skills/design-de-verdade/SKILL.md +42 -0
- package/skills/documento-word/SKILL.md +57 -0
- package/skills/interface-revisada/SKILL.md +47 -0
- package/skills/me-ensina/LICENSE.txt +21 -0
- package/skills/me-ensina/SKILL.md +27 -0
- package/skills/me-entrevista/LICENSE.txt +21 -0
- package/skills/me-entrevista/SKILL.md +27 -0
- package/skills/motion-de-primeira/SKILL.md +1 -1
- package/skills/pdf-bonito/SKILL.md +48 -0
- package/skills/planilha-excel/SKILL.md +47 -0
- package/skills/react-sem-dor/SKILL.md +48 -0
- package/skills/revisao-antes-de-entregar/LICENSE.txt +21 -0
- package/skills/revisao-antes-de-entregar/SKILL.md +6 -1
- package/skills/site-com-motion/SKILL.md +39 -0
- package/skills/temas-prontos/LICENSE.txt +202 -0
- package/skills/temas-prontos/SKILL.md +50 -0
- package/skills/testar-o-site/LICENSE.txt +202 -0
- package/skills/testar-o-site/SKILL.md +46 -0
- package/skills/video-no-remotion/SKILL.md +48 -0
- package/studio/narrar.py +111 -10
- package/studio/rostos.py +52 -0
package/lib/app.js
CHANGED
|
@@ -23,14 +23,18 @@
|
|
|
23
23
|
* navegador em modo app. Se não houver nenhum navegador de Chromium, abre
|
|
24
24
|
* numa aba normal, que continua funcionando.
|
|
25
25
|
*
|
|
26
|
-
* ──
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
26
|
+
* ── O LAYOUT: CHAT, NÃO TERMINAL ─────────────────────────────────────────
|
|
27
|
+
* "Remove a IDE ou deixa idêntico ao Codex." A janela é um chat de app de
|
|
28
|
+
* mesa: barra lateral (novo chat, projetos, recentes), a pergunta no meio da
|
|
29
|
+
* tela vazia e o compositor embaixo com projeto, anexo, permissão, esforço e
|
|
30
|
+
* voz. A conversa aparece como chat — o pedido num balão, a fala do Primo em
|
|
31
|
+
* texto e cada ação dele numa linha cinza ("Editou style.css +12 −3").
|
|
30
32
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
33
|
+
* Essas linhas NÃO saem de reler a saída colorida do terminal: vêm do canal
|
|
34
|
+
* estruturado de lib/fala.js, que é quem desenha o terminal. Uma verdade só,
|
|
35
|
+
* duas telas. O terminal continua espelhado (`process.stdout.write`
|
|
36
|
+
* duplicado) e fica atrás do botão "Terminal" — quem quiser o detalhe cru
|
|
37
|
+
* ainda tem.
|
|
34
38
|
*
|
|
35
39
|
* ── SEGURANÇA ────────────────────────────────────────────────────────────
|
|
36
40
|
* Este servidor MANDA NO AGENTE: quem fala com ele executa comando, escreve
|
|
@@ -47,6 +51,11 @@ const os = require('os');
|
|
|
47
51
|
const path = require('path');
|
|
48
52
|
const crypto = require('crypto');
|
|
49
53
|
const { execFile } = require('child_process');
|
|
54
|
+
const fala = require('./fala.js');
|
|
55
|
+
const cfg = require('./config.js');
|
|
56
|
+
const perigo = require('./perigo.js');
|
|
57
|
+
const effort = require('./effort.js');
|
|
58
|
+
const extra = require('./comandos-extra.js');
|
|
50
59
|
|
|
51
60
|
const WEB = path.join(__dirname, '..', 'app');
|
|
52
61
|
const PORTA_PADRAO = Number(process.env.PRIMOCODE_APP_PORT) || 7788;
|
|
@@ -89,15 +98,87 @@ function tipoDe(arquivo) {
|
|
|
89
98
|
return 'application/octet-stream';
|
|
90
99
|
}
|
|
91
100
|
|
|
101
|
+
/* O anexo que chega pela janela vira `state.attachedFiles`, igual ao
|
|
102
|
+
/attach — e o /attach lê como TEXTO. Um PNG lido como texto entraria no
|
|
103
|
+
pedido como lixo e gastaria a cota inteira do modelo, então binário é
|
|
104
|
+
recusado aqui, com o motivo. 2 MB é o teto: acima disso o texto sozinho já
|
|
105
|
+
estoura o contexto de qualquer modelo grátis. */
|
|
106
|
+
const MAX_ANEXO = 2 * 1024 * 1024;
|
|
107
|
+
|
|
108
|
+
/* "Recentes" sem conta: os últimos pedidos do history.jsonl, o mesmo arquivo
|
|
109
|
+
que o /historico procura. Lê só o fim do arquivo — ele cresce para sempre,
|
|
110
|
+
e a barra lateral só mostra uns vinte. */
|
|
111
|
+
function recentesLocais(arquivo, limite = 20) {
|
|
112
|
+
let texto = '';
|
|
113
|
+
try {
|
|
114
|
+
const fd = fs.openSync(arquivo, 'r');
|
|
115
|
+
try {
|
|
116
|
+
const tam = fs.fstatSync(fd).size;
|
|
117
|
+
const ler = Math.min(tam, 128 * 1024);
|
|
118
|
+
const buf = Buffer.alloc(ler);
|
|
119
|
+
fs.readSync(fd, buf, 0, ler, tam - ler);
|
|
120
|
+
texto = buf.toString('utf8');
|
|
121
|
+
} finally { fs.closeSync(fd); }
|
|
122
|
+
} catch { return []; }
|
|
123
|
+
const vistos = new Set();
|
|
124
|
+
const fora = [];
|
|
125
|
+
const linhas = texto.split('\n').filter(Boolean).reverse();
|
|
126
|
+
for (const l of linhas) {
|
|
127
|
+
let j;
|
|
128
|
+
try { j = JSON.parse(l); } catch { continue; } // a 1ª linha pode vir cortada
|
|
129
|
+
const titulo = String(j.prompt || '').replace(/\s+/g, ' ').trim();
|
|
130
|
+
if (!titulo || titulo.startsWith('/') || vistos.has(titulo)) continue;
|
|
131
|
+
vistos.add(titulo);
|
|
132
|
+
fora.push({ titulo: titulo.slice(0, 200), quando: j.ts || null, projeto: j.project || null });
|
|
133
|
+
if (fora.length >= limite) break;
|
|
134
|
+
}
|
|
135
|
+
return fora;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function lerJson(req, max = 64 * 1024) {
|
|
139
|
+
return new Promise((ok) => {
|
|
140
|
+
let corpo = '';
|
|
141
|
+
let grande = false;
|
|
142
|
+
req.on('data', (c) => {
|
|
143
|
+
if (grande) return;
|
|
144
|
+
corpo += c;
|
|
145
|
+
if (corpo.length > max) { grande = true; }
|
|
146
|
+
});
|
|
147
|
+
req.on('end', () => {
|
|
148
|
+
if (grande) return ok({ __grande: true });
|
|
149
|
+
try { ok(JSON.parse(corpo || '{}') || {}); } catch { ok(null); }
|
|
150
|
+
});
|
|
151
|
+
req.on('error', () => ok(null));
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
92
155
|
/**
|
|
93
156
|
* Sobe a janela do PrimoCode.
|
|
94
157
|
*
|
|
158
|
+
* As ações da barra lateral e do compositor (projeto, permissão, esforço,
|
|
159
|
+
* anexo, voz, novo chat) chegam por funções próprias em vez de virar texto de
|
|
160
|
+
* comando: um "/permissoes livre" digitado pela janela entraria na conversa
|
|
161
|
+
* como se a pessoa tivesse escrito. Quem não passar a função cai no comando
|
|
162
|
+
* equivalente via `op.barra` — o teste e o `primo --app` antigo seguem de pé.
|
|
163
|
+
*
|
|
95
164
|
* @param {object} op
|
|
96
165
|
* @param {(texto:string)=>Promise} op.enviar manda um pedido ao agente
|
|
97
166
|
* @param {(texto:string)=>Promise} op.barra trata um comando /barra
|
|
98
167
|
* @param {()=>void} op.interromper para o que estiver rodando
|
|
99
|
-
* @param {()=>object} op.estado { pasta, modo, esforco,
|
|
168
|
+
* @param {()=>object} op.estado { pasta, projeto, modo, esforco, permissoes, voz, usuario, mascote, anexos }
|
|
100
169
|
* @param {string} op.versao
|
|
170
|
+
* @param {()=>string} [op.pastaProjeto] liga o painel (Prévia/Arquivos) nesta pasta
|
|
171
|
+
* @param {()=>Array} [op.projetos] a lista de ~/primocode (padrão: listarProjetos)
|
|
172
|
+
* @param {(dir:string)=>any} [op.entrarProjeto]
|
|
173
|
+
* @param {()=>any} [op.novoChat]
|
|
174
|
+
* @param {(m:string)=>any} [op.mudarPermissoes]
|
|
175
|
+
* @param {(n:string)=>any} [op.mudarEsforco]
|
|
176
|
+
* @param {(f:{path,content})=>any} [op.anexar]
|
|
177
|
+
* @param {(nome?:string)=>any} [op.desanexar]
|
|
178
|
+
* @param {(ligar:boolean)=>any} [op.voz]
|
|
179
|
+
* @param {(c:{id,titulo})=>Promise} [op.continuarConversa]
|
|
180
|
+
* @param {'rc'|'ide'} [op.abrirEm] 'ide' abre com o painel da prévia aberto
|
|
181
|
+
* @param {boolean} [op.semJanela] só o servidor, sem abrir navegador
|
|
101
182
|
*/
|
|
102
183
|
async function abrir(op) {
|
|
103
184
|
const chave = crypto.randomBytes(16).toString('hex');
|
|
@@ -105,10 +186,15 @@ async function abrir(op) {
|
|
|
105
186
|
let ocupado = false;
|
|
106
187
|
const fila = [];
|
|
107
188
|
|
|
108
|
-
// O histórico
|
|
109
|
-
//
|
|
189
|
+
// O histórico do TERMINAL para quem abrir a janela depois — é o que o
|
|
190
|
+
// botão "Terminal" mostra.
|
|
110
191
|
const memoria = [];
|
|
111
192
|
const MEMORIA_MAX = 400;
|
|
193
|
+
// O histórico do CHAT: o mesmo tipo de coisa, mas já estruturado (pedido,
|
|
194
|
+
// fala, ação, resultado). Sem ele, um F5 no meio de uma tarefa apagava a
|
|
195
|
+
// conversa da tela.
|
|
196
|
+
let conversa = [];
|
|
197
|
+
const CONVERSA_MAX = 800;
|
|
112
198
|
|
|
113
199
|
function emitir(evento, dados) {
|
|
114
200
|
const linha = `event: ${evento}\ndata: ${JSON.stringify(dados)}\n\n`;
|
|
@@ -116,8 +202,27 @@ async function abrir(op) {
|
|
|
116
202
|
try { res.write(linha); } catch { ouvintes.delete(res); }
|
|
117
203
|
}
|
|
118
204
|
}
|
|
205
|
+
function emitirChat(evento, dados) {
|
|
206
|
+
conversa.push({ evento, dados });
|
|
207
|
+
if (conversa.length > CONVERSA_MAX) conversa.shift();
|
|
208
|
+
emitir(evento, dados);
|
|
209
|
+
}
|
|
210
|
+
const estadoAgora = () => Object.assign({ versao: op.versao, ocupado }, op.estado());
|
|
211
|
+
|
|
212
|
+
const painel = op.pastaProjeto
|
|
213
|
+
? require('./painel.js').montar({ pasta: op.pastaProjeto, emitir: (e, d) => emitir(e, d) })
|
|
214
|
+
: null;
|
|
215
|
+
|
|
216
|
+
/* O que o agente faz chega estruturado por lib/fala.js. `falouNesta`
|
|
217
|
+
diz se o pedido atual produziu alguma coisa por ali: um /help, um
|
|
218
|
+
/status, não produz — e aí a janela mostra o texto que ele imprimiu. */
|
|
219
|
+
let falouNesta = false;
|
|
220
|
+
const aoFalar = (e) => { falouNesta = true; emitirChat('agente', e); };
|
|
221
|
+
fala.eventos.on('evento', aoFalar);
|
|
119
222
|
|
|
223
|
+
let capturando = null;
|
|
120
224
|
const pararEspelho = espelhar((pedaco) => {
|
|
225
|
+
if (capturando) capturando.push(pedaco);
|
|
121
226
|
if (ehRuido(pedaco)) return;
|
|
122
227
|
memoria.push(pedaco);
|
|
123
228
|
if (memoria.length > MEMORIA_MAX) memoria.shift();
|
|
@@ -132,28 +237,31 @@ async function abrir(op) {
|
|
|
132
237
|
while (fila.length) {
|
|
133
238
|
const t = String(fila.shift() || '').trim();
|
|
134
239
|
if (!t) continue;
|
|
135
|
-
|
|
240
|
+
emitirChat('voce', t);
|
|
241
|
+
falouNesta = false;
|
|
242
|
+
capturando = [];
|
|
136
243
|
try {
|
|
137
244
|
if (t.startsWith('/')) await op.barra(t);
|
|
138
245
|
else await op.enviar(t);
|
|
139
246
|
} catch (e) {
|
|
140
|
-
|
|
247
|
+
emitirChat('erro', (e && e.message) || String(e));
|
|
248
|
+
}
|
|
249
|
+
const impresso = capturando.join('');
|
|
250
|
+
capturando = null;
|
|
251
|
+
if (!falouNesta && impresso.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '').trim()) {
|
|
252
|
+
emitirChat('comando', impresso.slice(-20000));
|
|
141
253
|
}
|
|
142
254
|
}
|
|
143
255
|
ocupado = false;
|
|
144
256
|
emitir('ocupado', false);
|
|
145
|
-
emitir('estado',
|
|
257
|
+
emitir('estado', estadoAgora());
|
|
146
258
|
}
|
|
147
259
|
|
|
148
|
-
// `async` por causa das rotas de histórico, que esperam a nuvem. Uma
|
|
149
|
-
// exceção aqui viraria unhandledRejection e derrubaria o CLI inteiro
|
|
150
|
-
// junto com a janela, então o corpo fica dentro de um try/catch.
|
|
151
260
|
const servidor = http.createServer((req, res) => {
|
|
152
261
|
atender(req, res).catch((e) => {
|
|
153
262
|
// Uma exceção solta aqui vira unhandledRejection e derruba o CLI
|
|
154
263
|
// INTEIRO junto com a janela — o trabalho da pessoa some porque
|
|
155
|
-
// uma listagem
|
|
156
|
-
// janela segue de pé.
|
|
264
|
+
// uma listagem tropeçou. Aqui ela vira um 500 e a janela segue.
|
|
157
265
|
try {
|
|
158
266
|
if (!res.headersSent) res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
159
267
|
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
@@ -161,31 +269,40 @@ async function abrir(op) {
|
|
|
161
269
|
});
|
|
162
270
|
});
|
|
163
271
|
|
|
272
|
+
function json(res, status, corpo) {
|
|
273
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
274
|
+
res.end(JSON.stringify(corpo));
|
|
275
|
+
}
|
|
276
|
+
|
|
164
277
|
async function atender(req, res) {
|
|
165
278
|
const url = new URL(req.url, 'http://127.0.0.1');
|
|
166
279
|
const rota = url.pathname;
|
|
280
|
+
const POST = req.method === 'POST';
|
|
167
281
|
|
|
168
282
|
/* A chave protege a API, que é onde está tudo o que importa: a conversa
|
|
169
283
|
do usuário e a capacidade de dar ordens ao agente. A página em si —
|
|
170
284
|
HTML, CSS, JS — não guarda segredo nenhum e não faz nada sem a
|
|
171
|
-
chave, então ela é servida solta
|
|
172
|
-
|
|
173
|
-
Não é frouxidão, é o contrário: exigir chave nos estáticos também
|
|
174
|
-
quebrava a própria janela, porque o navegador pede /app.css e
|
|
175
|
-
/app.js sem parâmetro nenhum. A página vinha, ficava sem estilo e
|
|
176
|
-
sem script, e o "app bonito" era um formulário branco. */
|
|
285
|
+
chave, então ela é servida solta (exigir chave nos estáticos
|
|
286
|
+
quebrava a janela: o navegador pede /app.css sem parâmetro). */
|
|
177
287
|
if (rota.startsWith('/api/')) {
|
|
178
|
-
|
|
288
|
+
// A prévia leva a chave no CAMINHO (ver lib/painel.js): os
|
|
289
|
+
// arquivos que a página do projeto pede por endereço relativo não
|
|
290
|
+
// carregam query nenhuma.
|
|
291
|
+
const naPrevia = rota.match(/^\/api\/previa\/([0-9a-f]+)/);
|
|
292
|
+
const dada = (naPrevia && naPrevia[1]) || url.searchParams.get('k') || req.headers['x-primo-chave'];
|
|
179
293
|
if (dada !== chave) {
|
|
180
294
|
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
181
|
-
return res.end('chave inválida — abra a janela com o comando /
|
|
295
|
+
return res.end('chave inválida — abra a janela com o comando /rc');
|
|
182
296
|
}
|
|
183
297
|
}
|
|
184
298
|
/* CSRF: uma página de terceiro consegue disparar POST simples para
|
|
185
299
|
127.0.0.1 sem preflight. Ela não saberia a chave, mas o cinto e o
|
|
186
|
-
suspensório custam três linhas.
|
|
300
|
+
suspensório custam três linhas. A exceção é a prévia: ela roda num
|
|
301
|
+
iframe sandbox de origem opaca (`Origin: null`), só GET e só nas
|
|
302
|
+
rotas que LEEM o projeto. */
|
|
187
303
|
const origem = req.headers.origin;
|
|
188
|
-
|
|
304
|
+
const previaOpaca = origem === 'null' && req.method === 'GET' && rota.startsWith('/api/previa/');
|
|
305
|
+
if (origem && !previaOpaca && !origem.startsWith(`http://127.0.0.1:${servidor.__porta}`)
|
|
189
306
|
&& !origem.startsWith(`http://localhost:${servidor.__porta}`)) {
|
|
190
307
|
res.writeHead(403).end();
|
|
191
308
|
return;
|
|
@@ -201,72 +318,181 @@ async function abrir(op) {
|
|
|
201
318
|
});
|
|
202
319
|
res.write(': ligado\n\n');
|
|
203
320
|
ouvintes.add(res);
|
|
204
|
-
res.write(`event: estado\ndata: ${JSON.stringify(
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
321
|
+
res.write(`event: estado\ndata: ${JSON.stringify(estadoAgora())}\n\n`);
|
|
322
|
+
res.write(`event: terminal\ndata: ${JSON.stringify(memoria.join(''))}\n\n`);
|
|
323
|
+
res.write(`event: historia\ndata: ${JSON.stringify(conversa)}\n\n`);
|
|
208
324
|
res.write(`event: ocupado\ndata: ${JSON.stringify(ocupado)}\n\n`);
|
|
209
|
-
// Batida a cada 20s: proxy e sistema derrubam conexão parada
|
|
210
|
-
// uma janela que "morre sozinha depois de um tempo" seria um bug
|
|
211
|
-
// impossível de achar depois.
|
|
325
|
+
// Batida a cada 20s: proxy e sistema derrubam conexão parada.
|
|
212
326
|
const batida = setInterval(() => { try { res.write(': ok\n\n'); } catch {} }, 20000);
|
|
213
327
|
req.on('close', () => { clearInterval(batida); ouvintes.delete(res); });
|
|
214
328
|
return;
|
|
215
329
|
}
|
|
216
330
|
|
|
217
|
-
if (rota === '/api/enviar' &&
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
try { texto = JSON.parse(corpo).texto || ''; } catch { /* vazio */ }
|
|
223
|
-
res.writeHead(200, { 'Content-Type': 'application/json' }).end('{"ok":true}');
|
|
224
|
-
if (texto.trim()) processar(texto);
|
|
225
|
-
});
|
|
331
|
+
if (rota === '/api/enviar' && POST) {
|
|
332
|
+
const d = await lerJson(req, 200000);
|
|
333
|
+
const texto = d && typeof d.texto === 'string' ? d.texto : '';
|
|
334
|
+
json(res, 200, { ok: true });
|
|
335
|
+
if (texto.trim()) processar(texto);
|
|
226
336
|
return;
|
|
227
337
|
}
|
|
228
338
|
|
|
229
|
-
if (rota === '/api/parar' &&
|
|
339
|
+
if (rota === '/api/parar' && POST) {
|
|
230
340
|
try { op.interromper(); } catch {}
|
|
231
|
-
res
|
|
232
|
-
return;
|
|
341
|
+
return json(res, 200, { ok: true });
|
|
233
342
|
}
|
|
234
343
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
344
|
+
if (rota === '/api/estado') return json(res, 200, estadoAgora());
|
|
345
|
+
|
|
346
|
+
// ── projetos ────────────────────────────────────────────────────
|
|
347
|
+
const listar = () => (op.projetos ? op.projetos() : extra.listarProjetos(cfg.ROOT_DIR));
|
|
348
|
+
if (rota === '/api/projetos') {
|
|
349
|
+
const atual = (op.estado() || {}).projeto;
|
|
350
|
+
const projetos = listar().map((p) => ({
|
|
351
|
+
nome: p.nome, modificado: p.modificado || null, arquivos: p.arquivos || 0, atual: p.nome === atual,
|
|
352
|
+
}));
|
|
353
|
+
return json(res, 200, { ok: true, projetos });
|
|
354
|
+
}
|
|
355
|
+
if (rota === '/api/projeto' && POST) {
|
|
356
|
+
if (ocupado) return json(res, 409, { ok: false, error: 'o Primo está trabalhando — pare ou espere para trocar de projeto' });
|
|
357
|
+
const d = await lerJson(req);
|
|
358
|
+
if (!d) return json(res, 400, { ok: false, error: 'pedido ilegível' });
|
|
359
|
+
let dir = null;
|
|
360
|
+
/* Só entra em projeto que ESTÁ na lista de ~/primocode, por nome
|
|
361
|
+
exato. Aceitar um caminho aqui daria à página o poder de apontar
|
|
362
|
+
o agente para qualquer pasta do disco. */
|
|
363
|
+
if (typeof d.nome === 'string') {
|
|
364
|
+
const achado = listar().find((p) => p.nome === d.nome);
|
|
365
|
+
if (!achado) return json(res, 404, { ok: false, error: 'não achei esse projeto em ~/primocode' });
|
|
366
|
+
dir = achado.dir;
|
|
367
|
+
} else if (typeof d.novo === 'string') {
|
|
368
|
+
const nome = d.novo.trim().slice(0, 60);
|
|
369
|
+
if (!cfg.slugify(nome)) return json(res, 400, { ok: false, error: 'dê um nome com letras ou números' });
|
|
370
|
+
dir = cfg.createProjectDir(nome);
|
|
371
|
+
} else return json(res, 400, { ok: false, error: 'faltou o nome do projeto' });
|
|
372
|
+
if (op.entrarProjeto) await op.entrarProjeto(dir);
|
|
373
|
+
else await op.barra('/dir ' + dir);
|
|
374
|
+
emitir('estado', estadoAgora());
|
|
375
|
+
return json(res, 200, { ok: true, estado: estadoAgora() });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ── conversa ────────────────────────────────────────────────────
|
|
379
|
+
if (rota === '/api/novo-chat' && POST) {
|
|
380
|
+
if (ocupado) return json(res, 409, { ok: false, error: 'o Primo está trabalhando — pare antes de começar outro chat' });
|
|
381
|
+
if (op.novoChat) await op.novoChat();
|
|
382
|
+
else await op.barra('/clear');
|
|
383
|
+
conversa = [];
|
|
384
|
+
emitir('limpar', true);
|
|
385
|
+
emitir('estado', estadoAgora());
|
|
386
|
+
return json(res, 200, { ok: true });
|
|
247
387
|
}
|
|
248
388
|
|
|
249
|
-
|
|
389
|
+
/* "Recentes": as conversas da conta quando há conta (as mesmas do
|
|
390
|
+
/chats, do app e do celular); sem conta, os últimos pedidos
|
|
391
|
+
deste computador. A janela não fala com o Firestore por conta
|
|
392
|
+
própria — uma segunda implementação da cifra divergiria. */
|
|
393
|
+
if (rota === '/api/recentes') {
|
|
250
394
|
const nuvem = require('./nuvem.js');
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
395
|
+
const r = await nuvem.listarConversas({ limite: 30 }).catch(() => ({ ok: false }));
|
|
396
|
+
if (r && r.ok) {
|
|
397
|
+
return json(res, 200, { ok: true, fonte: 'nuvem', itens: r.conversas.map((c) => ({
|
|
398
|
+
id: c.id, titulo: c.titulo, quando: c.atualizada, mensagens: c.mensagens })) });
|
|
399
|
+
}
|
|
400
|
+
return json(res, 200, { ok: true, fonte: 'local', itens: recentesLocais(cfg.HISTORY_FILE) });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (rota === '/api/conversa/abrir' && POST) {
|
|
404
|
+
if (ocupado) return json(res, 409, { ok: false, error: 'o Primo está trabalhando' });
|
|
405
|
+
const d = await lerJson(req);
|
|
406
|
+
if (!d || typeof d.id !== 'string' || !d.id) return json(res, 400, { ok: false, error: 'faltou a conversa' });
|
|
407
|
+
const r = op.continuarConversa
|
|
408
|
+
? await op.continuarConversa({ id: d.id, titulo: String(d.titulo || '') })
|
|
409
|
+
: await require('./nuvem.js').lerConversa(d.id);
|
|
410
|
+
if (!r || !r.ok) return json(res, 200, { ok: false, error: (r && r.error) || 'não abriu' });
|
|
411
|
+
// A conversa aberta vira a da tela: pedido como balão, resposta
|
|
412
|
+
// como fala — e um F5 depois continua mostrando ela.
|
|
413
|
+
conversa = (r.mensagens || []).map((m) => (m.papel === 'user'
|
|
414
|
+
? { evento: 'voce', dados: String(m.texto || '') }
|
|
415
|
+
: { evento: 'agente', dados: { tipo: 'resposta', texto: String(m.texto || '') } }));
|
|
416
|
+
emitir('historia', conversa);
|
|
417
|
+
return json(res, 200, { ok: true, mensagens: (r.mensagens || []).length });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ── o compositor ────────────────────────────────────────────────
|
|
421
|
+
if (rota === '/api/permissoes' && POST) {
|
|
422
|
+
const d = await lerJson(req);
|
|
423
|
+
const m = d && d.modo;
|
|
424
|
+
if (!perigo.MODOS.includes(m)) return json(res, 400, { ok: false, error: 'use auto, perguntar ou livre' });
|
|
425
|
+
if (op.mudarPermissoes) await op.mudarPermissoes(m);
|
|
426
|
+
else await op.barra('/permissoes ' + m);
|
|
427
|
+
emitir('estado', estadoAgora());
|
|
428
|
+
return json(res, 200, { ok: true, estado: estadoAgora() });
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (rota === '/api/esforco' && POST) {
|
|
432
|
+
const d = await lerJson(req);
|
|
433
|
+
const n = d && d.nivel;
|
|
434
|
+
if (!effort.NOMES.includes(n)) return json(res, 400, { ok: false, error: 'esforço desconhecido' });
|
|
435
|
+
if (op.mudarEsforco) await op.mudarEsforco(n);
|
|
436
|
+
else await op.barra('/effort ' + n);
|
|
437
|
+
emitir('estado', estadoAgora());
|
|
438
|
+
return json(res, 200, { ok: true, estado: estadoAgora() });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (rota === '/api/anexar' && POST) {
|
|
442
|
+
const d = await lerJson(req, Math.ceil(MAX_ANEXO * 1.4) + 4096);
|
|
443
|
+
if (d && d.__grande) return json(res, 413, { ok: false, error: 'maior que 2 MB' });
|
|
444
|
+
if (!d || typeof d.nome !== 'string' || typeof d.base64 !== 'string') {
|
|
445
|
+
return json(res, 400, { ok: false, error: 'faltou o arquivo' });
|
|
446
|
+
}
|
|
447
|
+
// Só o NOME: o que vem da página não escolhe caminho nenhum.
|
|
448
|
+
const nome = path.basename(d.nome.replace(/\\/g, '/')).replace(/[\x00-\x1f]/g, '').slice(0, 120);
|
|
449
|
+
const buf = Buffer.from(d.base64, 'base64');
|
|
450
|
+
if (!nome) return json(res, 400, { ok: false, error: 'nome inválido' });
|
|
451
|
+
if (buf.length > MAX_ANEXO) return json(res, 413, { ok: false, error: 'maior que 2 MB' });
|
|
452
|
+
if (require('./painel.js').ehBinario(buf)) {
|
|
453
|
+
return json(res, 415, { ok: false, error: `${nome} não é texto — anexe código, texto ou dados (imagem e vídeo, peça pelo caminho)` });
|
|
454
|
+
}
|
|
455
|
+
if (!op.anexar) return json(res, 501, { ok: false, error: 'anexo indisponível aqui' });
|
|
456
|
+
await op.anexar({ path: nome, content: buf.toString('utf8') });
|
|
457
|
+
emitir('estado', estadoAgora());
|
|
458
|
+
return json(res, 200, { ok: true, estado: estadoAgora() });
|
|
255
459
|
}
|
|
256
460
|
|
|
257
|
-
if (rota === '/api/
|
|
258
|
-
|
|
259
|
-
|
|
461
|
+
if (rota === '/api/desanexar' && POST) {
|
|
462
|
+
const d = await lerJson(req);
|
|
463
|
+
if (op.desanexar) await op.desanexar(d && typeof d.nome === 'string' ? d.nome : undefined);
|
|
464
|
+
else await op.barra('/detach');
|
|
465
|
+
emitir('estado', estadoAgora());
|
|
466
|
+
return json(res, 200, { ok: true, estado: estadoAgora() });
|
|
260
467
|
}
|
|
261
468
|
|
|
469
|
+
if (rota === '/api/voz' && POST) {
|
|
470
|
+
const d = await lerJson(req);
|
|
471
|
+
const ligar = !!(d && d.ligar);
|
|
472
|
+
if (op.voz) await op.voz(ligar);
|
|
473
|
+
else await op.barra(ligar ? '/voice' : '/voice parar');
|
|
474
|
+
emitir('estado', estadoAgora());
|
|
475
|
+
return json(res, 200, { ok: true, estado: estadoAgora() });
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (rota.startsWith('/api/conversa/') && req.method === 'GET') {
|
|
479
|
+
const id = decodeURIComponent(rota.slice('/api/conversa/'.length));
|
|
480
|
+
const r = await require('./nuvem.js').lerConversa(id).catch((e) => ({ ok: false, error: e.message }));
|
|
481
|
+
return json(res, 200, r);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (painel && await painel.atender(req, res, url)) return;
|
|
485
|
+
if (rota.startsWith('/api/')) return json(res, 404, { ok: false, error: 'rota desconhecida' });
|
|
486
|
+
|
|
262
487
|
// Estáticos. `basename` corta qualquer "../" antes de virar caminho:
|
|
263
488
|
// este servidor fala com o disco do usuário e não pode servir o que
|
|
264
|
-
// estiver fora da pasta do aplicativo.
|
|
265
|
-
|
|
489
|
+
// estiver fora da pasta do aplicativo. /ide é a mesma janela: a IDE
|
|
490
|
+
// antiga virou o painel da direita.
|
|
491
|
+
const nome = (rota === '/' || rota === '/ide') ? 'index.html' : path.basename(rota);
|
|
266
492
|
const arquivo = path.join(WEB, nome);
|
|
267
493
|
fs.readFile(arquivo, (erro, dados) => {
|
|
268
494
|
if (erro) { res.writeHead(404).end('não encontrado'); return; }
|
|
269
|
-
res.writeHead(200, { 'Content-Type': tipoDe(nome) });
|
|
495
|
+
res.writeHead(200, { 'Content-Type': tipoDe(nome), 'Cache-Control': 'no-store' });
|
|
270
496
|
res.end(dados);
|
|
271
497
|
});
|
|
272
498
|
}
|
|
@@ -274,11 +500,26 @@ async function abrir(op) {
|
|
|
274
500
|
const porta = await escutar(servidor, PORTA_PADRAO);
|
|
275
501
|
servidor.__porta = porta;
|
|
276
502
|
const url = `http://127.0.0.1:${porta}/?k=${chave}`;
|
|
503
|
+
// A mesma janela, abrindo com a prévia à direita — o que o /ide virou.
|
|
504
|
+
const urlPainel = `${url}&painel=previa`;
|
|
277
505
|
|
|
278
|
-
|
|
506
|
+
// `semJanela` é para o teste (e para quem só quer o endereço): sem ele,
|
|
507
|
+
// rodar a suíte abriria uma janela de verdade na tela de quem roda.
|
|
508
|
+
const janela = op.semJanela ? { modo: 'nenhum' }
|
|
509
|
+
: await abrirJanela(op.abrirEm === 'ide' ? urlPainel : url);
|
|
279
510
|
return {
|
|
280
|
-
url, porta, chave, servidor, janela,
|
|
281
|
-
|
|
511
|
+
url, urlPainel, porta, chave, servidor, janela,
|
|
512
|
+
/** Um pedido digitado no TERMINAL, para aparecer como balão na janela. */
|
|
513
|
+
pedidoDoTerminal(texto) { if (String(texto || '').trim()) emitirChat('voce', String(texto).trim()); },
|
|
514
|
+
fechar() {
|
|
515
|
+
try { pararEspelho(); } catch {}
|
|
516
|
+
try { fala.eventos.removeListener('evento', aoFalar); } catch {}
|
|
517
|
+
try { if (painel) painel.fechar(); } catch {}
|
|
518
|
+
try { servidor.close(); } catch {}
|
|
519
|
+
// SSE abertos seguram o close() para sempre; a janela é que
|
|
520
|
+
// fechou, então as conexões dela também.
|
|
521
|
+
for (const r of ouvintes) { try { r.end(); } catch {} }
|
|
522
|
+
},
|
|
282
523
|
};
|
|
283
524
|
}
|
|
284
525
|
|
|
@@ -345,6 +586,8 @@ function abrirJanela(url) {
|
|
|
345
586
|
const filho = execFile(exe, [
|
|
346
587
|
`--app=${url}`,
|
|
347
588
|
`--user-data-dir=${perfil}`,
|
|
589
|
+
// Sem Keychain: ver SEM_KEYCHAIN em lib/browser.js.
|
|
590
|
+
...require('./browser.js').SEM_KEYCHAIN,
|
|
348
591
|
'--window-size=1180,820',
|
|
349
592
|
'--no-first-run',
|
|
350
593
|
'--no-default-browser-check',
|
|
@@ -369,4 +612,4 @@ function abrirJanela(url) {
|
|
|
369
612
|
});
|
|
370
613
|
}
|
|
371
614
|
|
|
372
|
-
module.exports = { abrir, abrirJanela, espelhar, ehRuido, candidatosDeNavegador };
|
|
615
|
+
module.exports = { abrir, abrirJanela, espelhar, ehRuido, candidatosDeNavegador, recentesLocais, MAX_ANEXO };
|