primocode 8.32.0 → 8.33.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/app.css +175 -106
- package/app/app.js +171 -4
- package/app/index.html +125 -48
- package/app/primo-marca.js +113 -0
- package/bin/primocode.js +57 -15
- package/lib/api.js +67 -3
- package/lib/app.js +39 -1
- package/lib/boas-vindas.js +161 -0
- package/lib/modo.js +43 -3
- package/package.json +2 -2
package/app/index.html
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<!doctype html>
|
|
2
|
-
<html lang="pt-BR">
|
|
2
|
+
<html lang="pt-BR" data-tema="escuro">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
@@ -7,62 +7,139 @@
|
|
|
7
7
|
<link rel="stylesheet" href="/app.css">
|
|
8
8
|
<link rel="icon" href="/icone.svg">
|
|
9
9
|
</head>
|
|
10
|
+
<!--
|
|
11
|
+
A janela do PrimoCode (/rc).
|
|
12
|
+
|
|
13
|
+
É a MESMA interface da página /code do Conecta Primo — de propósito. Quem
|
|
14
|
+
usa o terminal, abre a janela e depois entra pelo navegador tem de
|
|
15
|
+
reconhecer onde está no primeiro olhar: mesma barra lateral, mesmo botão de
|
|
16
|
+
novo chat, mesmo prompt `❯`, mesmo mascote na tela vazia.
|
|
17
|
+
|
|
18
|
+
O que muda é de onde vem o conteúdo: aqui o histórico vem do servidor local
|
|
19
|
+
(/api/conversas), que usa o lib/nuvem.js — a mesma cifra e a mesma coleção
|
|
20
|
+
que a web lê direto do Firestore.
|
|
21
|
+
-->
|
|
10
22
|
<body>
|
|
11
23
|
|
|
12
|
-
<
|
|
13
|
-
<
|
|
14
|
-
<
|
|
15
|
-
<
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
</div>
|
|
28
|
-
</header>
|
|
24
|
+
<div class="tela">
|
|
25
|
+
<aside class="lado" id="lado" aria-label="Conversas e ajustes">
|
|
26
|
+
<div class="lado-topo">
|
|
27
|
+
<div class="marca-linha">
|
|
28
|
+
<span id="marcaTopo"></span>
|
|
29
|
+
<span class="nome">PrimoCode</span>
|
|
30
|
+
<span class="versao" id="versao"></span>
|
|
31
|
+
</div>
|
|
32
|
+
<button class="novo" id="btnNovo">
|
|
33
|
+
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true">
|
|
34
|
+
<path d="M12 5v14M5 12h14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/>
|
|
35
|
+
</svg>
|
|
36
|
+
Novo chat
|
|
37
|
+
</button>
|
|
38
|
+
</div>
|
|
29
39
|
|
|
30
|
-
<
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
<p>Escreva do jeito que você falaria. Ele lê a pasta, escreve o código,
|
|
35
|
-
roda o que precisar e conta o que fez.</p>
|
|
36
|
-
<div class="atalhos">
|
|
37
|
-
<button data-manda="/tudo">o que ele sabe fazer</button>
|
|
38
|
-
<button data-manda="/project">meus projetos</button>
|
|
39
|
-
<button data-manda="/effort">esforço</button>
|
|
40
|
-
<button data-manda="/help">comandos</button>
|
|
40
|
+
<div class="rotulo">Conversas</div>
|
|
41
|
+
<div class="recentes" id="recentes">
|
|
42
|
+
<div class="vazio-lista" id="listaVazia">
|
|
43
|
+
As conversas aparecem aqui quando você entra na sua conta com <code>/conectar</code>.
|
|
41
44
|
</div>
|
|
42
45
|
</div>
|
|
43
|
-
</div>
|
|
44
|
-
</main>
|
|
45
46
|
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
47
|
+
<div class="lado-pe">
|
|
48
|
+
<button class="pe-btn" id="btnConfig">
|
|
49
|
+
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true">
|
|
50
|
+
<circle cx="12" cy="12" r="3.1" fill="none" stroke="currentColor" stroke-width="1.9"/>
|
|
51
|
+
<path d="M12 2.8v2.4M12 18.8v2.4M21.2 12h-2.4M5.2 12H2.8M18.5 5.5l-1.7 1.7M7.2 16.8l-1.7 1.7M18.5 18.5l-1.7-1.7M7.2 7.2 5.5 5.5"
|
|
52
|
+
fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/>
|
|
53
|
+
</svg>
|
|
54
|
+
Configurações
|
|
55
|
+
</button>
|
|
56
|
+
<div class="chips">
|
|
57
|
+
<span class="chip" id="pasta" title="pasta de trabalho"></span>
|
|
58
|
+
<span class="chip" id="modo"></span>
|
|
59
|
+
<span class="chip" id="esforco"></span>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
</aside>
|
|
63
|
+
|
|
64
|
+
<main class="conversa">
|
|
65
|
+
<div class="rolo" id="rolo">
|
|
66
|
+
<div class="vazio" id="vazio">
|
|
67
|
+
<span id="marcaGrande"></span>
|
|
68
|
+
<h1>O que a gente vai construir?</h1>
|
|
69
|
+
<p>Escreva do jeito que você falaria. Ele lê a pasta, escreve o código,
|
|
70
|
+
roda o que precisar e conta o que fez.</p>
|
|
71
|
+
<div class="sugestoes">
|
|
72
|
+
<button class="sugestao" data-manda="/tudo">o que ele sabe fazer</button>
|
|
73
|
+
<button class="sugestao" data-manda="/project">meus projetos</button>
|
|
74
|
+
<button class="sugestao" data-manda="/effort">esforço</button>
|
|
75
|
+
<button class="sugestao" data-manda="/help">comandos</button>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
<div id="conversa" class="tira" hidden></div>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div class="rodape">
|
|
82
|
+
<div id="pensando" hidden><span class="pulso"></span><span id="pensando-txt">trabalhando</span></div>
|
|
83
|
+
<form id="forma" class="caixa">
|
|
84
|
+
<div class="campo-volta">
|
|
85
|
+
<span class="chevron">❯</span>
|
|
86
|
+
<textarea id="campo" rows="1" placeholder="Peça alguma coisa…"
|
|
87
|
+
autocomplete="off" spellcheck="false"></textarea>
|
|
88
|
+
</div>
|
|
89
|
+
<button type="submit" id="mandar" class="enviar" title="Enviar (Enter)" aria-label="Enviar">
|
|
90
|
+
<svg width="17" height="17" viewBox="0 0 24 24" aria-hidden="true">
|
|
91
|
+
<path d="M5 12h13M12.5 6l6 6-6 6" fill="none" stroke="currentColor"
|
|
92
|
+
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
93
|
+
</svg>
|
|
94
|
+
</button>
|
|
95
|
+
<button type="button" id="parar" class="enviar parar" title="Parar (Esc)" hidden aria-label="Parar">
|
|
96
|
+
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="2" fill="currentColor"/></svg>
|
|
97
|
+
</button>
|
|
98
|
+
</form>
|
|
99
|
+
<div class="dica" id="dica">Enter manda · Shift+Enter quebra linha · arraste um arquivo aqui</div>
|
|
100
|
+
</div>
|
|
101
|
+
</main>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<dialog id="dlgConfig">
|
|
105
|
+
<div class="dlg-topo">
|
|
106
|
+
<h2>Configurações</h2>
|
|
107
|
+
<button class="copiar" id="fecharConfig">Fechar</button>
|
|
108
|
+
</div>
|
|
109
|
+
<div class="dlg-corpo">
|
|
110
|
+
<div class="grupo">
|
|
111
|
+
<h3>Tema</h3>
|
|
112
|
+
<div class="opcoes" id="temas">
|
|
113
|
+
<button class="opcao" data-tema="escuro">Escuro</button>
|
|
114
|
+
<button class="opcao" data-tema="claro">Claro</button>
|
|
115
|
+
<button class="opcao" data-tema="sistema">Do sistema</button>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
<div class="grupo">
|
|
119
|
+
<h3>Cor</h3>
|
|
120
|
+
<div class="cores" id="cores">
|
|
121
|
+
<button class="cor" data-cor="ciano" style="background:#22d3ee" aria-label="Ciano"></button>
|
|
122
|
+
<button class="cor" data-cor="verde" style="background:#34d399" aria-label="Verde"></button>
|
|
123
|
+
<button class="cor" data-cor="roxo" style="background:#a78bfa" aria-label="Roxo"></button>
|
|
124
|
+
<button class="cor" data-cor="ambar" style="background:#fbbf24" aria-label="Âmbar"></button>
|
|
125
|
+
<button class="cor" data-cor="rosa" style="background:#fb7185" aria-label="Rosa"></button>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
<div class="grupo">
|
|
129
|
+
<h3>Esta janela</h3>
|
|
130
|
+
<div class="linha-info"><span>Pasta</span><b id="pastaConfig">—</b></div>
|
|
131
|
+
<div class="linha-info"><span>Modo</span><b id="modoConfig">—</b></div>
|
|
132
|
+
<div class="linha-info"><span>Esforço</span><b id="esforcoConfig">—</b></div>
|
|
133
|
+
<p style="margin:10px 0 0;font-size:13px;color:var(--fraco)">
|
|
134
|
+
Estes três se mudam no terminal: <code>/dir</code>, <code>/mode</code> e <code>/effort</code>.
|
|
135
|
+
</p>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</dialog>
|
|
63
139
|
|
|
64
140
|
<div id="alvo-solto"><div><strong>Solte aqui</strong><span>o caminho entra no pedido</span></div></div>
|
|
65
141
|
|
|
142
|
+
<script src="/primo-marca.js"></script>
|
|
66
143
|
<script src="/app.js"></script>
|
|
67
144
|
</body>
|
|
68
145
|
</html>
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* primo-marca.js — o mascote do PrimoCode, desenhado em pixel.
|
|
3
|
+
*
|
|
4
|
+
* CÓPIA de conecta-primo-ai/frontend/primo-marca.js. São repositórios
|
|
5
|
+
* diferentes e não há como importar de um para o outro; o que dá para fazer é
|
|
6
|
+
* manter os dois IDÊNTICOS e o teste conferir isso — um mascote que muda num
|
|
7
|
+
* lado e não no outro é a marca do produto se partindo em duas sem ninguém
|
|
8
|
+
* ver.
|
|
9
|
+
*
|
|
10
|
+
* ── POR QUE MATRIZ, E NÃO O PNG ──────────────────────────────────────────
|
|
11
|
+
* O logo veio como imagem. Guardar o PNG funcionaria, mas ele já é pixel art:
|
|
12
|
+
* redesenhá-lo como MATRIZ dá o mesmo desenho nítido em qualquer tamanho —
|
|
13
|
+
* favicon de 16px, cabeçalho de 28px, tela vazia de 96px — sem borrar, sem
|
|
14
|
+
* três arquivos para manter, e pesando algumas centenas de bytes em vez de
|
|
15
|
+
* dezenas de kB.
|
|
16
|
+
*
|
|
17
|
+
* E deixa a cor viver no TEMA: o mesmo bicho no claro e no escuro, sem PNG
|
|
18
|
+
* com fundo grudado.
|
|
19
|
+
*
|
|
20
|
+
* Se um dia o desenho mudar, muda a matriz — é uma linha por fileira, e dá
|
|
21
|
+
* para ler o bicho olhando o código.
|
|
22
|
+
*
|
|
23
|
+
* C = corpo (ciano claro) · D = detalhe (azul escuro): orelhas,
|
|
24
|
+
* braços e pés · . = vazio
|
|
25
|
+
*/
|
|
26
|
+
(function (raiz) {
|
|
27
|
+
"use strict";
|
|
28
|
+
|
|
29
|
+
var MATRIZ = [
|
|
30
|
+
"...DD....DD...",
|
|
31
|
+
"...DD....DD...",
|
|
32
|
+
"..CCCCCCCCCC..",
|
|
33
|
+
"..CCCCCCCCCC..",
|
|
34
|
+
"DDCCCCCCCCCCDD",
|
|
35
|
+
"DDCCCCCCCCCCDD",
|
|
36
|
+
"..CCCCCCCCCC..",
|
|
37
|
+
"..CCCCCCCCCC..",
|
|
38
|
+
"..CCCCCCCCCC..",
|
|
39
|
+
"...DD....DD...",
|
|
40
|
+
"...DD....DD...",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
var CORPO = "#5ec8ef";
|
|
44
|
+
var DETALHE = "#2b7fa8";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* O mascote como SVG.
|
|
48
|
+
*
|
|
49
|
+
* Retângulos colados, sem `shape-rendering: crispEdges`: em pixel art
|
|
50
|
+
* ampliado, o antialiasing do navegador deixa fios claros entre um quadrado
|
|
51
|
+
* e outro. Colar meio pixel a mais em cada lado (o `+0.02`) some com a
|
|
52
|
+
* costura sem mudar o desenho.
|
|
53
|
+
*
|
|
54
|
+
* @param {object} op
|
|
55
|
+
* @param {number} [op.tamanho] lado em px (o SVG é quadrado)
|
|
56
|
+
* @param {string} [op.corpo] cor do corpo
|
|
57
|
+
* @param {string} [op.detalhe] cor das orelhas, braços e pés
|
|
58
|
+
* @param {string} [op.fundo] cor do quadrado de trás (vazio = sem fundo)
|
|
59
|
+
* @param {string} [op.rotulo] texto alternativo; vazio = decorativo
|
|
60
|
+
*/
|
|
61
|
+
function svg(op) {
|
|
62
|
+
op = op || {};
|
|
63
|
+
var lado = op.tamanho || 32;
|
|
64
|
+
var corpo = op.corpo || CORPO;
|
|
65
|
+
var detalhe = op.detalhe || DETALHE;
|
|
66
|
+
var linhas = MATRIZ.length;
|
|
67
|
+
var colunas = MATRIZ[0].length;
|
|
68
|
+
// O desenho é mais largo que alto; a caixa é quadrada e ele fica centrado.
|
|
69
|
+
var caixa = Math.max(linhas, colunas);
|
|
70
|
+
var dx = (caixa - colunas) / 2;
|
|
71
|
+
var dy = (caixa - linhas) / 2;
|
|
72
|
+
|
|
73
|
+
/* UM caminho por cor, e não um retângulo por célula.
|
|
74
|
+
*
|
|
75
|
+
* Com retângulos separados, o navegador antialiasa a borda de cada um
|
|
76
|
+
* contra a do vizinho e o desenho ganha uma grade de fios claros no meio
|
|
77
|
+
* do corpo — visível já em 96px, feio em 200px. Num caminho só, as
|
|
78
|
+
* células vizinhas viram uma forma única e não há borda interna nenhuma
|
|
79
|
+
* para suavizar. */
|
|
80
|
+
var caminho = { C: [], D: [] };
|
|
81
|
+
for (var y = 0; y < linhas; y++) {
|
|
82
|
+
var fileira = MATRIZ[y];
|
|
83
|
+
for (var x = 0; x < colunas; x++) {
|
|
84
|
+
var ch = fileira[x];
|
|
85
|
+
if (ch !== "C" && ch !== "D") continue;
|
|
86
|
+
caminho[ch].push("M" + (x + dx) + " " + (y + dy) + "h1v1h-1z");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
var partes = [];
|
|
91
|
+
if (op.fundo) {
|
|
92
|
+
partes.push('<rect width="' + caixa + '" height="' + caixa
|
|
93
|
+
+ '" rx="' + (caixa * 0.18).toFixed(2) + '" fill="' + op.fundo + '"/>');
|
|
94
|
+
}
|
|
95
|
+
if (caminho.C.length) partes.push('<path d="' + caminho.C.join("") + '" fill="' + corpo + '"/>');
|
|
96
|
+
if (caminho.D.length) partes.push('<path d="' + caminho.D.join("") + '" fill="' + detalhe + '"/>');
|
|
97
|
+
|
|
98
|
+
var acessivel = op.rotulo
|
|
99
|
+
? ' role="img" aria-label="' + String(op.rotulo).replace(/"/g, """) + '"'
|
|
100
|
+
: ' aria-hidden="true"';
|
|
101
|
+
|
|
102
|
+
return '<svg viewBox="0 0 ' + caixa + " " + caixa + '" width="' + lado
|
|
103
|
+
+ '" height="' + lado + '"' + acessivel + ">" + partes.join("") + "</svg>";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** O mesmo bicho como favicon, sem pedir arquivo ao servidor. */
|
|
107
|
+
function favicon() {
|
|
108
|
+
var s = svg({ tamanho: 32, fundo: "#0a0f16" });
|
|
109
|
+
return "data:image/svg+xml," + encodeURIComponent(s);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
raiz.PrimoMarca = { svg: svg, favicon: favicon, MATRIZ: MATRIZ, CORPO: CORPO, DETALHE: DETALHE };
|
|
113
|
+
})(typeof window !== "undefined" ? window : globalThis);
|
package/bin/primocode.js
CHANGED
|
@@ -23,6 +23,7 @@ const { runAct, interromper } = require('../lib/act');
|
|
|
23
23
|
const claudeEngine = require('../lib/claude-engine');
|
|
24
24
|
const codexEngine = require('../lib/codex-engine');
|
|
25
25
|
const chaves = require('../lib/chaves');
|
|
26
|
+
const boasVindas = require('../lib/boas-vindas');
|
|
26
27
|
const pasta = require('../lib/pasta');
|
|
27
28
|
const catalogo = require('../lib/catalogo');
|
|
28
29
|
const effort = require('../lib/effort');
|
|
@@ -69,7 +70,7 @@ const state = {
|
|
|
69
70
|
engine: 'server', // 'server' (OpenRouter/Groq) | 'claude-code' (/claude) | 'codex' (/codex)
|
|
70
71
|
};
|
|
71
72
|
|
|
72
|
-
// A janela do /
|
|
73
|
+
// A janela do /rc, quando aberta. Uma por sessão: o segundo /rc traz a que
|
|
73
74
|
// existe para a frente em vez de subir outro servidor ao lado.
|
|
74
75
|
let janelaAberta = null;
|
|
75
76
|
|
|
@@ -87,11 +88,11 @@ ${c.bold('Comandos')}
|
|
|
87
88
|
${c.brand('/read')} <arquivo> mostra um arquivo
|
|
88
89
|
${c.brand('/run')} <comando> executa no shell do projeto
|
|
89
90
|
${c.brand('/open')} <arquivo|url> abre no navegador
|
|
90
|
-
${c.brand('/
|
|
91
|
+
${c.brand('/rc')} abre a janela do PrimoCode (chat, sem terminal)
|
|
91
92
|
${c.brand('/claude')} usa o Claude Code da SUA conta como motor (Opus)
|
|
92
93
|
${c.brand('/codex')} usa o Codex da SUA conta como motor (modelo barato)
|
|
93
94
|
${c.brand('/chave')} <provedor> sua própria chave de API (Claude, Codex)
|
|
94
|
-
${c.brand('/
|
|
95
|
+
${c.brand('/conectar')} entra na sua conta Conecta Primo · ${c.brand('/conta')} mostra o plano
|
|
95
96
|
${c.brand('/conversas')} [n] suas conversas salvas — as mesmas do app e do celular
|
|
96
97
|
${c.brand('/sair')} desconecta este computador
|
|
97
98
|
${c.brand('/status')} checa o servidor
|
|
@@ -134,11 +135,11 @@ const SLASH_COMANDOS = [
|
|
|
134
135
|
['/read', 'mostra um arquivo'],
|
|
135
136
|
['/run', 'roda um comando'],
|
|
136
137
|
['/open', 'abre no navegador'],
|
|
137
|
-
['/
|
|
138
|
+
['/rc', 'abre a janela do PrimoCode'],
|
|
138
139
|
['/claude', 'usa o Claude Code da sua conta como motor'],
|
|
139
140
|
['/codex', 'usa o Codex da sua conta como motor'],
|
|
140
141
|
['/chave', 'sua própria chave de API (Claude, Codex)'],
|
|
141
|
-
['/
|
|
142
|
+
['/conectar', 'entra na sua conta Conecta Primo'],
|
|
142
143
|
['/conta', 'sua conta, plano e computadores conectados'],
|
|
143
144
|
['/conversas', 'suas conversas salvas, as mesmas do app e do celular'],
|
|
144
145
|
['/sair', 'desconecta este computador'],
|
|
@@ -1039,14 +1040,22 @@ async function handleSlash(text) {
|
|
|
1039
1040
|
break;
|
|
1040
1041
|
}
|
|
1041
1042
|
|
|
1042
|
-
/* A janela. "Se mandar /
|
|
1043
|
+
/* A janela. "Se mandar /rc pra ele, ele abre o aplicativo — para o
|
|
1043
1044
|
Mac e o Windows, já instalado junto com o npm, com as cores do
|
|
1044
1045
|
PrimoCode. Um chat estilo terminal para quem não quer o terminal."
|
|
1045
1046
|
|
|
1046
|
-
Uma janela por sessão: mandar /
|
|
1047
|
+
Uma janela por sessão: mandar /rc de novo traz a que já existe
|
|
1047
1048
|
para a frente em vez de abrir outra do lado. */
|
|
1048
|
-
|
|
1049
|
-
|
|
1049
|
+
/* O nome antigo some da ajuda e do menu, mas continua respondendo —
|
|
1050
|
+
avisando. Some de vez e quem tinha o dedo viciado bate num "comando
|
|
1051
|
+
não existe" e acha que a atualização quebrou alguma coisa; o aviso
|
|
1052
|
+
custa uma linha e ensina o nome novo na hora em que ele é
|
|
1053
|
+
procurado, que é a única hora em que alguém lê. */
|
|
1054
|
+
case 'rc': case 'app': {
|
|
1055
|
+
if (cmd.toLowerCase() === 'app') {
|
|
1056
|
+
console.log(c.muted(' Agora é ') + c.brand('/rc') + c.muted('. Abrindo…'));
|
|
1057
|
+
}
|
|
1058
|
+
// `/rc --instalar` põe o PrimoCode no Dock, no menu Iniciar ou no
|
|
1050
1059
|
// menu de aplicativos, com o ícone do mascote. Uma vez só.
|
|
1051
1060
|
if (/^--?(instalar|install)$/.test(rest)) {
|
|
1052
1061
|
const r = require('../lib/instalar-app.js').instalar(VERSION);
|
|
@@ -1093,7 +1102,7 @@ async function handleSlash(text) {
|
|
|
1093
1102
|
}
|
|
1094
1103
|
console.log(c.muted(' O terminal continua funcionando: os dois mostram a mesma coisa.'));
|
|
1095
1104
|
// Oferecido UMA vez, e só a quem ainda não tem: repetir todo
|
|
1096
|
-
// /
|
|
1105
|
+
// /rc viraria ruído para quem já instalou de propósito.
|
|
1097
1106
|
if (!require('../lib/instalar-app.js').instalado()) {
|
|
1098
1107
|
console.log(c.dim(' Quer o ícone no ') + c.dim(os.platform() === 'darwin' ? 'Dock' : 'menu Iniciar')
|
|
1099
1108
|
+ c.dim('? ') + c.brand('/app --instalar'));
|
|
@@ -1107,12 +1116,15 @@ async function handleSlash(text) {
|
|
|
1107
1116
|
|
|
1108
1117
|
/* A conta do Conecta Primo. O login inteiro mora em lib/conta.js;
|
|
1109
1118
|
aqui é a tela dele. */
|
|
1110
|
-
case 'entrar': case 'login': {
|
|
1119
|
+
case 'conectar': case 'entrar': case 'login': {
|
|
1120
|
+
if (/^(entrar|login)$/.test(cmd.toLowerCase())) {
|
|
1121
|
+
console.log(c.muted(' Agora é ') + c.brand('/conectar') + c.muted('.'));
|
|
1122
|
+
}
|
|
1111
1123
|
const jaEsta = conta.atual();
|
|
1112
1124
|
if (jaEsta && rest !== 'trocar') {
|
|
1113
1125
|
console.log(c.muted(' Já conectado como ') + c.brand(jaEsta.uid.slice(0, 12) + '…')
|
|
1114
1126
|
+ c.muted(` · plano ${jaEsta.plano}`));
|
|
1115
|
-
console.log(c.dim(' Para entrar com outra conta: ') + c.brand('/
|
|
1127
|
+
console.log(c.dim(' Para entrar com outra conta: ') + c.brand('/conectar trocar'));
|
|
1116
1128
|
break;
|
|
1117
1129
|
}
|
|
1118
1130
|
if (rest === 'trocar') conta.sair();
|
|
@@ -1148,7 +1160,7 @@ async function handleSlash(text) {
|
|
|
1148
1160
|
mostram. Sem número, lista; com número, abre e continua dali. */
|
|
1149
1161
|
case 'conversas': case 'historico': {
|
|
1150
1162
|
if (!conta.atual()) {
|
|
1151
|
-
console.log(c.muted(' Não conectado. ') + c.brand('/
|
|
1163
|
+
console.log(c.muted(' Não conectado. ') + c.brand('/conectar')
|
|
1152
1164
|
+ c.muted(' para ver suas conversas aqui, no app e no celular.'));
|
|
1153
1165
|
break;
|
|
1154
1166
|
}
|
|
@@ -1197,14 +1209,14 @@ async function handleSlash(text) {
|
|
|
1197
1209
|
if (!conta.atual()) { console.log(c.muted(' Você não está conectado.')); break; }
|
|
1198
1210
|
conta.sair();
|
|
1199
1211
|
console.log(c.ok(' Desconectado deste computador.')
|
|
1200
|
-
+ c.dim(' A conta continua intacta; entre de novo com /
|
|
1212
|
+
+ c.dim(' A conta continua intacta; entre de novo com /conectar.'));
|
|
1201
1213
|
break;
|
|
1202
1214
|
}
|
|
1203
1215
|
|
|
1204
1216
|
case 'conta': {
|
|
1205
1217
|
const eu = conta.atual();
|
|
1206
1218
|
if (!eu) {
|
|
1207
|
-
console.log(c.muted(' Não conectado. ') + c.brand('/
|
|
1219
|
+
console.log(c.muted(' Não conectado. ') + c.brand('/conectar') + c.muted(' para entrar.'));
|
|
1208
1220
|
break;
|
|
1209
1221
|
}
|
|
1210
1222
|
const sp = new Spinner('conferindo').start();
|
|
@@ -1390,6 +1402,7 @@ async function main() {
|
|
|
1390
1402
|
pastaPadrao: state.workDirOrigem === 'padrao',
|
|
1391
1403
|
}));
|
|
1392
1404
|
rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: makePrompt(), completer: completarSlash });
|
|
1405
|
+
|
|
1393
1406
|
rl.prompt();
|
|
1394
1407
|
|
|
1395
1408
|
// Fila: o readline continua emitindo 'line' enquanto um pedido está sendo
|
|
@@ -1496,6 +1509,35 @@ async function main() {
|
|
|
1496
1509
|
});
|
|
1497
1510
|
|
|
1498
1511
|
rl.on('close', () => { closed = true; if (!busy) sayBye(); });
|
|
1512
|
+
|
|
1513
|
+
/* A primeira vez — e ela roda AQUI, depois de `line` e `close` existirem.
|
|
1514
|
+
*
|
|
1515
|
+
* Estava lá em cima, logo depois de criar o readline. Parecia certo e
|
|
1516
|
+
* estava errado: o `await` do assistente segurava o resto do main(), e
|
|
1517
|
+
* tudo o que chegasse pelo teclado nesse meio-tempo caía no vazio, porque
|
|
1518
|
+
* ainda não havia ouvinte de `line` para pegar. Quem colasse dois comandos
|
|
1519
|
+
* de uma vez perdia o segundo, e quem fechasse a entrada (Ctrl+D, um
|
|
1520
|
+
* script) saía sem nem o "Até logo" — porque o ouvinte de `close` também
|
|
1521
|
+
* ainda não existia.
|
|
1522
|
+
*
|
|
1523
|
+
* Digitando devagar ninguém via: os ouvintes ficavam prontos milissegundos
|
|
1524
|
+
* depois. Foi preciso um terminal de mentira mandando as linhas de uma vez
|
|
1525
|
+
* para o defeito aparecer.
|
|
1526
|
+
*
|
|
1527
|
+
* Aparece UMA vez; depois disso, /conectar e /chave é que são o caminho. */
|
|
1528
|
+
if (!boasVindas.jaAbriu({ arquivoConfig: cfg.CONFIG_FILE })) {
|
|
1529
|
+
boasVindas.rodar({
|
|
1530
|
+
perguntar: ask,
|
|
1531
|
+
mostrar: (t) => console.log(t),
|
|
1532
|
+
cores: c,
|
|
1533
|
+
interativo: !!(process.stdin.isTTY && process.stdout.isTTY),
|
|
1534
|
+
conectar: () => handleSlash('/conectar'),
|
|
1535
|
+
configurarChave: () => handleSlash('/chave'),
|
|
1536
|
+
}).then(() => { if (!closed && !busy) rl.prompt(); })
|
|
1537
|
+
// Um assistente que falha não pode levar o CLI junto: ele é uma
|
|
1538
|
+
// apresentação, não uma condição para usar o programa.
|
|
1539
|
+
.catch(() => {});
|
|
1540
|
+
}
|
|
1499
1541
|
}
|
|
1500
1542
|
|
|
1501
1543
|
main().catch((e) => { console.error(c.err(e.message || e)); process.exit(1); });
|
package/lib/api.js
CHANGED
|
@@ -35,6 +35,39 @@ function buildOptions(url, method, extraHeaders, bodyLength, token) {
|
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/* ── OS PRAZOS ────────────────────────────────────────────────────────────
|
|
39
|
+
* Sem prazo, um servidor que aceita a conexão e fica MUDO pendura o CLI para
|
|
40
|
+
* sempre: sem mensagem, sem spinner que pare, sem nada — e a pessoa só sai
|
|
41
|
+
* com Ctrl-C, sem saber o que houve. Aconteceu num teste real e custou um
|
|
42
|
+
* bom tempo de investigação para descobrir que não era lentidão, era trava.
|
|
43
|
+
*
|
|
44
|
+
* ── POR QUE NÃO UM PRAZO CURTO PARA CONECTAR ────────────────────────────
|
|
45
|
+
* A tentação é cortar em 20s "porque conectar é rápido". Aqui não é: o
|
|
46
|
+
* servidor roda no plano gratuito do Render, que DORME. A primeira chamada
|
|
47
|
+
* depois de um tempo parado espera a máquina subir — 30, 40, às vezes 60
|
|
48
|
+
* segundos — com o socket já aceito e nenhum byte voltando. Um teto curto
|
|
49
|
+
* trocaria uma trava rara por uma quebra TODO DIA, na primeira pergunta da
|
|
50
|
+
* manhã, e a pessoa leria "timeout" achando que está sem internet.
|
|
51
|
+
*
|
|
52
|
+
* Então os prazos são generosos e medem a coisa certa:
|
|
53
|
+
*
|
|
54
|
+
* · Nas chamadas curtas (refinar, plano, resumo), um teto duro na resposta
|
|
55
|
+
* inteira. Elas são pequenas; se passou disso, travou.
|
|
56
|
+
*
|
|
57
|
+
* · No stream, o prazo é de SILÊNCIO e reinicia a cada pedaço que chega.
|
|
58
|
+
* Um modelo pensando três minutos e escrevendo nunca é cortado; quem é
|
|
59
|
+
* cortado é quem para de falar sem fechar a conexão — a trava que não
|
|
60
|
+
* dava sinal nenhum.
|
|
61
|
+
*/
|
|
62
|
+
const PRAZO_RESPOSTA = Number(process.env.PRIMOCODE_TIMEOUT_RESPOSTA || 180000);
|
|
63
|
+
const PRAZO_SILENCIO = Number(process.env.PRIMOCODE_TIMEOUT_SILENCIO || 240000);
|
|
64
|
+
|
|
65
|
+
function erroDeTempo(oque, ms) {
|
|
66
|
+
return new Error(
|
|
67
|
+
`O servidor não ${oque} em ${Math.round(ms / 1000)}s (timeout). `
|
|
68
|
+
+ 'Pode ser a rede, ou o servidor fora do ar — tente de novo.');
|
|
69
|
+
}
|
|
70
|
+
|
|
38
71
|
function request(serverUrl, endpoint, payload, token) {
|
|
39
72
|
return new Promise((resolve, reject) => {
|
|
40
73
|
const url = new URL(endpoint, serverUrl);
|
|
@@ -51,7 +84,17 @@ function request(serverUrl, endpoint, payload, token) {
|
|
|
51
84
|
else resolve(json);
|
|
52
85
|
});
|
|
53
86
|
});
|
|
54
|
-
|
|
87
|
+
// Um relógio só, na resposta inteira. Nada de prazo de socket: ele
|
|
88
|
+
// mediria o silêncio da máquina subindo no Render e mataria a
|
|
89
|
+
// primeira chamada do dia.
|
|
90
|
+
const relogio = setTimeout(() => {
|
|
91
|
+
req.destroy(erroDeTempo('terminou de responder', PRAZO_RESPOSTA));
|
|
92
|
+
}, PRAZO_RESPOSTA);
|
|
93
|
+
if (relogio.unref) relogio.unref();
|
|
94
|
+
const parar = () => clearTimeout(relogio);
|
|
95
|
+
req.on('close', parar);
|
|
96
|
+
|
|
97
|
+
req.on('error', (e) => { parar(); reject(e); });
|
|
55
98
|
req.write(data);
|
|
56
99
|
req.end();
|
|
57
100
|
});
|
|
@@ -73,7 +116,23 @@ function requestStream(serverUrl, endpoint, payload, onEvent, token, sinal) {
|
|
|
73
116
|
const client = url.protocol === 'https:' ? https : http;
|
|
74
117
|
const options = buildOptions(url, 'POST', { Accept: 'text/event-stream' }, Buffer.byteLength(data), token);
|
|
75
118
|
|
|
119
|
+
/* O relógio do SILÊNCIO. Reiniciado a cada pedaço que chega, então um
|
|
120
|
+
modelo que pensa três minutos e vai escrevendo nunca é cortado — só
|
|
121
|
+
é cortado quem para de falar e não fecha a conexão, que é
|
|
122
|
+
exatamente a trava que não dava sinal nenhum. */
|
|
123
|
+
let relogio = null;
|
|
124
|
+
const morrerSeCalar = () => {
|
|
125
|
+
clearTimeout(relogio);
|
|
126
|
+
relogio = setTimeout(() => {
|
|
127
|
+
req.destroy(erroDeTempo('mandou nada por', PRAZO_SILENCIO));
|
|
128
|
+
}, PRAZO_SILENCIO);
|
|
129
|
+
if (relogio.unref) relogio.unref();
|
|
130
|
+
};
|
|
131
|
+
const calarORelogio = () => clearTimeout(relogio);
|
|
132
|
+
|
|
76
133
|
const req = client.request(options, (res) => {
|
|
134
|
+
morrerSeCalar();
|
|
135
|
+
res.on('data', morrerSeCalar);
|
|
77
136
|
// Erro HTTP não vem como SSE — o corpo é um JSON de erro.
|
|
78
137
|
if (res.statusCode >= 400) {
|
|
79
138
|
let body = '';
|
|
@@ -87,7 +146,7 @@ function requestStream(serverUrl, endpoint, payload, onEvent, token, sinal) {
|
|
|
87
146
|
}
|
|
88
147
|
|
|
89
148
|
let settled = false;
|
|
90
|
-
const finish = () => { if (!settled) { settled = true; resolve(); } };
|
|
149
|
+
const finish = () => { calarORelogio(); if (!settled) { settled = true; resolve(); } };
|
|
91
150
|
|
|
92
151
|
let buffer = '';
|
|
93
152
|
res.setEncoding('utf8');
|
|
@@ -125,13 +184,18 @@ function requestStream(serverUrl, endpoint, payload, onEvent, token, sinal) {
|
|
|
125
184
|
const cortar = () => {
|
|
126
185
|
if (cortado) return;
|
|
127
186
|
cortado = true;
|
|
187
|
+
calarORelogio();
|
|
128
188
|
try { req.destroy(); } catch {}
|
|
129
189
|
resolve();
|
|
130
190
|
};
|
|
131
191
|
if (sinal) sinal.addEventListener('abort', cortar, { once: true });
|
|
132
192
|
|
|
133
|
-
req.on('error', (e) => { if (!cortado) reject(e); });
|
|
193
|
+
req.on('error', (e) => { calarORelogio(); if (!cortado) reject(e); });
|
|
134
194
|
req.on('close', () => { if (sinal) sinal.removeEventListener('abort', cortar); });
|
|
195
|
+
// O relógio começa AQUI, não na primeira resposta: se o servidor
|
|
196
|
+
// aceitar a conexão e nunca mandar nada, `morrerSeCalar` nunca teria
|
|
197
|
+
// sido chamado e a trava voltaria inteira.
|
|
198
|
+
morrerSeCalar();
|
|
135
199
|
req.write(data);
|
|
136
200
|
req.end();
|
|
137
201
|
});
|
package/lib/app.js
CHANGED
|
@@ -145,7 +145,23 @@ async function abrir(op) {
|
|
|
145
145
|
emitir('estado', op.estado());
|
|
146
146
|
}
|
|
147
147
|
|
|
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.
|
|
148
151
|
const servidor = http.createServer((req, res) => {
|
|
152
|
+
atender(req, res).catch((e) => {
|
|
153
|
+
// Uma exceção solta aqui vira unhandledRejection e derruba o CLI
|
|
154
|
+
// INTEIRO junto com a janela — o trabalho da pessoa some porque
|
|
155
|
+
// uma listagem de conversa tropeçou. Aqui ela vira um 500 e a
|
|
156
|
+
// janela segue de pé.
|
|
157
|
+
try {
|
|
158
|
+
if (!res.headersSent) res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
159
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
160
|
+
} catch { /* socket já foi */ }
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
async function atender(req, res) {
|
|
149
165
|
const url = new URL(req.url, 'http://127.0.0.1');
|
|
150
166
|
const rota = url.pathname;
|
|
151
167
|
|
|
@@ -216,6 +232,28 @@ async function abrir(op) {
|
|
|
216
232
|
return;
|
|
217
233
|
}
|
|
218
234
|
|
|
235
|
+
/* O HISTÓRICO da janela vem do MESMO lugar que o do terminal:
|
|
236
|
+
lib/nuvem.js, coleção `primocode`, cifrado. A janela não fala com o
|
|
237
|
+
Firestore por conta própria — ela pergunta ao servidor dela, que já
|
|
238
|
+
tem a sessão e a cifra. Um segundo caminho até o banco significaria
|
|
239
|
+
uma segunda implementação da cifra, e a segunda divergiria da
|
|
240
|
+
primeira sem ninguém ver. */
|
|
241
|
+
if (rota === '/api/conversas') {
|
|
242
|
+
const nuvem = require('./nuvem.js');
|
|
243
|
+
const r = await nuvem.listarConversas({ limite: 40 })
|
|
244
|
+
.catch((e) => ({ ok: false, error: e.message }));
|
|
245
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
246
|
+
return res.end(JSON.stringify(r));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (rota.startsWith('/api/conversa/')) {
|
|
250
|
+
const nuvem = require('./nuvem.js');
|
|
251
|
+
const id = decodeURIComponent(rota.slice('/api/conversa/'.length));
|
|
252
|
+
const r = await nuvem.lerConversa(id).catch((e) => ({ ok: false, error: e.message }));
|
|
253
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
254
|
+
return res.end(JSON.stringify(r));
|
|
255
|
+
}
|
|
256
|
+
|
|
219
257
|
if (rota === '/api/estado') {
|
|
220
258
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
221
259
|
return res.end(JSON.stringify(Object.assign({ versao: op.versao }, op.estado())));
|
|
@@ -231,7 +269,7 @@ async function abrir(op) {
|
|
|
231
269
|
res.writeHead(200, { 'Content-Type': tipoDe(nome) });
|
|
232
270
|
res.end(dados);
|
|
233
271
|
});
|
|
234
|
-
}
|
|
272
|
+
}
|
|
235
273
|
|
|
236
274
|
const porta = await escutar(servidor, PORTA_PADRAO);
|
|
237
275
|
servidor.__porta = porta;
|