primocode 9.8.0-beta.7 → 9.8.0-beta.9
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 +6 -5
- 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 +99 -39
- package/lib/act.js +5 -0
- package/lib/app.js +297 -89
- package/lib/fala.js +95 -1
- package/lib/offline.js +168 -0
- package/lib/{ide.js → painel.js} +41 -87
- package/package.json +2 -3
- package/skills/design-de-verdade/SKILL.md +2 -0
- package/skills/motion-de-primeira/SKILL.md +1 -1
- package/skills/site-com-motion/SKILL.md +39 -0
- package/app/ide.css +0 -345
- package/app/ide.html +0 -183
- package/app/ide.js +0 -639
- package/kits/acolhedor/index.html +0 -130
- package/kits/acolhedor/script.js +0 -25
- package/kits/acolhedor/style.css +0 -146
- package/kits/editorial/index.html +0 -115
- package/kits/editorial/script.js +0 -19
- package/kits/editorial/style.css +0 -112
- package/kits/landing/index.html +0 -162
- package/kits/landing/script.js +0 -199
- package/kits/landing/style.css +0 -219
- package/kits/premium/index.html +0 -83
- package/kits/premium/script.js +0 -11
- package/kits/premium/style.css +0 -87
- package/kits/vitrine/index.html +0 -108
- package/kits/vitrine/script.js +0 -20
- package/kits/vitrine/style.css +0 -117
- package/lib/kits.js +0 -206
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,17 +98,86 @@ 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
|
|
101
|
-
* @param {()=>string} [op.pastaProjeto] liga
|
|
102
|
-
* @param {
|
|
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
|
|
103
181
|
* @param {boolean} [op.semJanela] só o servidor, sem abrir navegador
|
|
104
182
|
*/
|
|
105
183
|
async function abrir(op) {
|
|
@@ -108,10 +186,15 @@ async function abrir(op) {
|
|
|
108
186
|
let ocupado = false;
|
|
109
187
|
const fila = [];
|
|
110
188
|
|
|
111
|
-
// O histórico
|
|
112
|
-
//
|
|
189
|
+
// O histórico do TERMINAL para quem abrir a janela depois — é o que o
|
|
190
|
+
// botão "Terminal" mostra.
|
|
113
191
|
const memoria = [];
|
|
114
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;
|
|
115
198
|
|
|
116
199
|
function emitir(evento, dados) {
|
|
117
200
|
const linha = `event: ${evento}\ndata: ${JSON.stringify(dados)}\n\n`;
|
|
@@ -119,15 +202,27 @@ async function abrir(op) {
|
|
|
119
202
|
try { res.write(linha); } catch { ouvintes.delete(res); }
|
|
120
203
|
}
|
|
121
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());
|
|
122
211
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
quem abriu diz qual é a pasta do projeto. */
|
|
126
|
-
const ide = op.pastaProjeto
|
|
127
|
-
? require('./ide.js').montar({ pasta: op.pastaProjeto, emitir: (e, d) => emitir(e, d) })
|
|
212
|
+
const painel = op.pastaProjeto
|
|
213
|
+
? require('./painel.js').montar({ pasta: op.pastaProjeto, emitir: (e, d) => emitir(e, d) })
|
|
128
214
|
: null;
|
|
129
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);
|
|
222
|
+
|
|
223
|
+
let capturando = null;
|
|
130
224
|
const pararEspelho = espelhar((pedaco) => {
|
|
225
|
+
if (capturando) capturando.push(pedaco);
|
|
131
226
|
if (ehRuido(pedaco)) return;
|
|
132
227
|
memoria.push(pedaco);
|
|
133
228
|
if (memoria.length > MEMORIA_MAX) memoria.shift();
|
|
@@ -142,28 +237,31 @@ async function abrir(op) {
|
|
|
142
237
|
while (fila.length) {
|
|
143
238
|
const t = String(fila.shift() || '').trim();
|
|
144
239
|
if (!t) continue;
|
|
145
|
-
|
|
240
|
+
emitirChat('voce', t);
|
|
241
|
+
falouNesta = false;
|
|
242
|
+
capturando = [];
|
|
146
243
|
try {
|
|
147
244
|
if (t.startsWith('/')) await op.barra(t);
|
|
148
245
|
else await op.enviar(t);
|
|
149
246
|
} catch (e) {
|
|
150
|
-
|
|
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));
|
|
151
253
|
}
|
|
152
254
|
}
|
|
153
255
|
ocupado = false;
|
|
154
256
|
emitir('ocupado', false);
|
|
155
|
-
emitir('estado',
|
|
257
|
+
emitir('estado', estadoAgora());
|
|
156
258
|
}
|
|
157
259
|
|
|
158
|
-
// `async` por causa das rotas de histórico, que esperam a nuvem. Uma
|
|
159
|
-
// exceção aqui viraria unhandledRejection e derrubaria o CLI inteiro
|
|
160
|
-
// junto com a janela, então o corpo fica dentro de um try/catch.
|
|
161
260
|
const servidor = http.createServer((req, res) => {
|
|
162
261
|
atender(req, res).catch((e) => {
|
|
163
262
|
// Uma exceção solta aqui vira unhandledRejection e derruba o CLI
|
|
164
263
|
// INTEIRO junto com a janela — o trabalho da pessoa some porque
|
|
165
|
-
// uma listagem
|
|
166
|
-
// janela segue de pé.
|
|
264
|
+
// uma listagem tropeçou. Aqui ela vira um 500 e a janela segue.
|
|
167
265
|
try {
|
|
168
266
|
if (!res.headersSent) res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
169
267
|
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
@@ -171,40 +269,39 @@ async function abrir(op) {
|
|
|
171
269
|
});
|
|
172
270
|
});
|
|
173
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
|
+
|
|
174
277
|
async function atender(req, res) {
|
|
175
278
|
const url = new URL(req.url, 'http://127.0.0.1');
|
|
176
279
|
const rota = url.pathname;
|
|
280
|
+
const POST = req.method === 'POST';
|
|
177
281
|
|
|
178
282
|
/* A chave protege a API, que é onde está tudo o que importa: a conversa
|
|
179
283
|
do usuário e a capacidade de dar ordens ao agente. A página em si —
|
|
180
284
|
HTML, CSS, JS — não guarda segredo nenhum e não faz nada sem a
|
|
181
|
-
chave, então ela é servida solta
|
|
182
|
-
|
|
183
|
-
Não é frouxidão, é o contrário: exigir chave nos estáticos também
|
|
184
|
-
quebrava a própria janela, porque o navegador pede /app.css e
|
|
185
|
-
/app.js sem parâmetro nenhum. A página vinha, ficava sem estilo e
|
|
186
|
-
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). */
|
|
187
287
|
if (rota.startsWith('/api/')) {
|
|
188
|
-
// A prévia
|
|
288
|
+
// A prévia leva a chave no CAMINHO (ver lib/painel.js): os
|
|
189
289
|
// arquivos que a página do projeto pede por endereço relativo não
|
|
190
290
|
// carregam query nenhuma.
|
|
191
|
-
const naPrevia = rota.match(/^\/api\/
|
|
291
|
+
const naPrevia = rota.match(/^\/api\/previa\/([0-9a-f]+)/);
|
|
192
292
|
const dada = (naPrevia && naPrevia[1]) || url.searchParams.get('k') || req.headers['x-primo-chave'];
|
|
193
293
|
if (dada !== chave) {
|
|
194
294
|
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
195
|
-
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');
|
|
196
296
|
}
|
|
197
297
|
}
|
|
198
298
|
/* CSRF: uma página de terceiro consegue disparar POST simples para
|
|
199
299
|
127.0.0.1 sem preflight. Ela não saberia a chave, mas o cinto e o
|
|
200
|
-
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. */
|
|
201
303
|
const origem = req.headers.origin;
|
|
202
|
-
|
|
203
|
-
opaca, e o navegador manda `Origin: null` até num <script
|
|
204
|
-
type="module">. Só GET e só nas rotas da prévia, que apenas LEEM o
|
|
205
|
-
projeto — nenhuma delas dá ordem ao agente. */
|
|
206
|
-
const previaOpaca = origem === 'null' && req.method === 'GET'
|
|
207
|
-
&& rota.startsWith('/api/ide/previa/');
|
|
304
|
+
const previaOpaca = origem === 'null' && req.method === 'GET' && rota.startsWith('/api/previa/');
|
|
208
305
|
if (origem && !previaOpaca && !origem.startsWith(`http://127.0.0.1:${servidor.__porta}`)
|
|
209
306
|
&& !origem.startsWith(`http://localhost:${servidor.__porta}`)) {
|
|
210
307
|
res.writeHead(403).end();
|
|
@@ -221,74 +318,181 @@ async function abrir(op) {
|
|
|
221
318
|
});
|
|
222
319
|
res.write(': ligado\n\n');
|
|
223
320
|
ouvintes.add(res);
|
|
224
|
-
res.write(`event: estado\ndata: ${JSON.stringify(
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
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`);
|
|
228
324
|
res.write(`event: ocupado\ndata: ${JSON.stringify(ocupado)}\n\n`);
|
|
229
|
-
// Batida a cada 20s: proxy e sistema derrubam conexão parada
|
|
230
|
-
// uma janela que "morre sozinha depois de um tempo" seria um bug
|
|
231
|
-
// impossível de achar depois.
|
|
325
|
+
// Batida a cada 20s: proxy e sistema derrubam conexão parada.
|
|
232
326
|
const batida = setInterval(() => { try { res.write(': ok\n\n'); } catch {} }, 20000);
|
|
233
327
|
req.on('close', () => { clearInterval(batida); ouvintes.delete(res); });
|
|
234
328
|
return;
|
|
235
329
|
}
|
|
236
330
|
|
|
237
|
-
if (rota === '/api/enviar' &&
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
try { texto = JSON.parse(corpo).texto || ''; } catch { /* vazio */ }
|
|
243
|
-
res.writeHead(200, { 'Content-Type': 'application/json' }).end('{"ok":true}');
|
|
244
|
-
if (texto.trim()) processar(texto);
|
|
245
|
-
});
|
|
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);
|
|
246
336
|
return;
|
|
247
337
|
}
|
|
248
338
|
|
|
249
|
-
if (rota === '/api/parar' &&
|
|
339
|
+
if (rota === '/api/parar' && POST) {
|
|
250
340
|
try { op.interromper(); } catch {}
|
|
251
|
-
res
|
|
252
|
-
return;
|
|
341
|
+
return json(res, 200, { ok: true });
|
|
253
342
|
}
|
|
254
343
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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 });
|
|
267
387
|
}
|
|
268
388
|
|
|
269
|
-
|
|
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') {
|
|
270
394
|
const nuvem = require('./nuvem.js');
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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) });
|
|
275
401
|
}
|
|
276
402
|
|
|
277
|
-
if (
|
|
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
|
+
}
|
|
278
430
|
|
|
279
|
-
if (rota === '/api/
|
|
280
|
-
|
|
281
|
-
|
|
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() });
|
|
282
439
|
}
|
|
283
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() });
|
|
459
|
+
}
|
|
460
|
+
|
|
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() });
|
|
467
|
+
}
|
|
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
|
+
|
|
284
487
|
// Estáticos. `basename` corta qualquer "../" antes de virar caminho:
|
|
285
488
|
// este servidor fala com o disco do usuário e não pode servir o que
|
|
286
|
-
// estiver fora da pasta do aplicativo.
|
|
287
|
-
|
|
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);
|
|
288
492
|
const arquivo = path.join(WEB, nome);
|
|
289
493
|
fs.readFile(arquivo, (erro, dados) => {
|
|
290
494
|
if (erro) { res.writeHead(404).end('não encontrado'); return; }
|
|
291
|
-
res.writeHead(200, { 'Content-Type': tipoDe(nome) });
|
|
495
|
+
res.writeHead(200, { 'Content-Type': tipoDe(nome), 'Cache-Control': 'no-store' });
|
|
292
496
|
res.end(dados);
|
|
293
497
|
});
|
|
294
498
|
}
|
|
@@ -296,17 +500,21 @@ async function abrir(op) {
|
|
|
296
500
|
const porta = await escutar(servidor, PORTA_PADRAO);
|
|
297
501
|
servidor.__porta = porta;
|
|
298
502
|
const url = `http://127.0.0.1:${porta}/?k=${chave}`;
|
|
299
|
-
|
|
503
|
+
// A mesma janela, abrindo com a prévia à direita — o que o /ide virou.
|
|
504
|
+
const urlPainel = `${url}&painel=previa`;
|
|
300
505
|
|
|
301
506
|
// `semJanela` é para o teste (e para quem só quer o endereço): sem ele,
|
|
302
507
|
// rodar a suíte abriria uma janela de verdade na tela de quem roda.
|
|
303
508
|
const janela = op.semJanela ? { modo: 'nenhum' }
|
|
304
|
-
: await abrirJanela(op.abrirEm === 'ide'
|
|
509
|
+
: await abrirJanela(op.abrirEm === 'ide' ? urlPainel : url);
|
|
305
510
|
return {
|
|
306
|
-
url,
|
|
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()); },
|
|
307
514
|
fechar() {
|
|
308
515
|
try { pararEspelho(); } catch {}
|
|
309
|
-
try {
|
|
516
|
+
try { fala.eventos.removeListener('evento', aoFalar); } catch {}
|
|
517
|
+
try { if (painel) painel.fechar(); } catch {}
|
|
310
518
|
try { servidor.close(); } catch {}
|
|
311
519
|
// SSE abertos seguram o close() para sempre; a janela é que
|
|
312
520
|
// fechou, então as conexões dela também.
|
|
@@ -404,4 +612,4 @@ function abrirJanela(url) {
|
|
|
404
612
|
});
|
|
405
613
|
}
|
|
406
614
|
|
|
407
|
-
module.exports = { abrir, abrirJanela, espelhar, ehRuido, candidatosDeNavegador };
|
|
615
|
+
module.exports = { abrir, abrirJanela, espelhar, ehRuido, candidatosDeNavegador, recentesLocais, MAX_ANEXO };
|